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.
205pub fn delete_worktree(
206    target: Option<&str>,
207    keep_branch: bool,
208    delete_remote: bool,
209    force: bool,
210    lookup_mode: Option<&str>,
211) -> Result<()> {
212    let main_repo = git::get_main_repo_root(None)?;
213    let (worktree_path, branch_name) = resolve_delete_target(target, &main_repo, lookup_mode)?;
214
215    // Safety: don't delete main repo
216    let wt_resolved = git::canonicalize_or(&worktree_path);
217    let main_resolved = git::canonicalize_or(&main_repo);
218    if wt_resolved == main_resolved {
219        return Err(CwError::Git(messages::cannot_delete_main_worktree()));
220    }
221
222    // If cwd is inside worktree, change to main repo
223    if let Ok(cwd) = std::env::current_dir() {
224        let cwd_str = cwd.to_string_lossy().to_string();
225        let wt_str = worktree_path.to_string_lossy().to_string();
226        if cwd_str.starts_with(&wt_str) {
227            let _ = std::env::set_current_dir(&main_repo);
228        }
229    }
230
231    // Pre-delete hooks
232    let base_branch = branch_name
233        .as_deref()
234        .and_then(|b| {
235            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, b);
236            git::get_config(&key, Some(&main_repo))
237        })
238        .unwrap_or_default();
239
240    let mut hook_ctx = build_hook_context(
241        &branch_name.clone().unwrap_or_default(),
242        &base_branch,
243        &worktree_path,
244        &main_repo,
245        "worktree.pre_delete",
246        "delete",
247    );
248    hooks::run_hooks(
249        "worktree.pre_delete",
250        &hook_ctx,
251        Some(&main_repo),
252        Some(&main_repo),
253    )?;
254
255    // Remove worktree
256    println!(
257        "{}",
258        style(messages::removing_worktree(&worktree_path)).yellow()
259    );
260    git::remove_worktree_safe(&worktree_path, &main_repo, force)?;
261    println!("{} Worktree removed\n", style("*").green().bold());
262
263    // Delete branch
264    if let Some(ref branch) = branch_name {
265        if !keep_branch {
266            println!(
267                "{}",
268                style(messages::deleting_local_branch(branch)).yellow()
269            );
270            let _ = git::git_command(&["branch", "-D", branch], Some(&main_repo), false, false);
271
272            // Remove metadata
273            let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
274            let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
275            let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch);
276            git::unset_config(&bb_key, Some(&main_repo));
277            git::unset_config(&bp_key, Some(&main_repo));
278            git::unset_config(&ib_key, Some(&main_repo));
279
280            println!(
281                "{} Local branch and metadata removed\n",
282                style("*").green().bold()
283            );
284
285            // Delete remote branch
286            if delete_remote {
287                println!(
288                    "{}",
289                    style(messages::deleting_remote_branch(branch)).yellow()
290                );
291                match git::git_command(
292                    &["push", "origin", &format!(":{}", branch)],
293                    Some(&main_repo),
294                    false,
295                    true,
296                ) {
297                    Ok(r) if r.returncode == 0 => {
298                        println!("{} Remote branch deleted\n", style("*").green().bold());
299                    }
300                    _ => {
301                        println!("{} Remote branch deletion failed\n", style("!").yellow());
302                    }
303                }
304            }
305        }
306    }
307
308    // Post-delete hooks
309    hook_ctx.insert("event".into(), "worktree.post_delete".into());
310    let _ = hooks::run_hooks(
311        "worktree.post_delete",
312        &hook_ctx,
313        Some(&main_repo),
314        Some(&main_repo),
315    );
316    let _ = registry::update_last_seen(&main_repo);
317
318    Ok(())
319}
320
321/// Resolve delete target to (worktree_path, branch_name).
322fn resolve_delete_target(
323    target: Option<&str>,
324    main_repo: &Path,
325    lookup_mode: Option<&str>,
326) -> Result<(PathBuf, Option<String>)> {
327    let target = target.map(|t| t.to_string()).unwrap_or_else(|| {
328        std::env::current_dir()
329            .unwrap_or_default()
330            .to_string_lossy()
331            .to_string()
332    });
333
334    let target_path = PathBuf::from(&target);
335
336    // Check if it's a filesystem path
337    if target_path.exists() {
338        let resolved = target_path.canonicalize().unwrap_or(target_path);
339        let branch = super::helpers::get_branch_for_worktree(main_repo, &resolved);
340        return Ok((resolved, branch));
341    }
342
343    // Try branch lookup (skip if lookup_mode is "worktree")
344    if lookup_mode != Some("worktree") {
345        if let Some(path) = git::find_worktree_by_intended_branch(main_repo, &target)? {
346            return Ok((path, Some(target)));
347        }
348    }
349
350    // Try worktree name lookup (skip if lookup_mode is "branch")
351    if lookup_mode != Some("branch") {
352        if let Some(path) = git::find_worktree_by_name(main_repo, &target)? {
353            let branch = super::helpers::get_branch_for_worktree(main_repo, &path);
354            return Ok((path, branch));
355        }
356    }
357
358    Err(CwError::WorktreeNotFound(messages::worktree_not_found(
359        &target,
360    )))
361}
362
363/// Sync worktree with base branch.
364pub fn sync_worktree(
365    target: Option<&str>,
366    all: bool,
367    _fetch_only: bool,
368    ai_merge: bool,
369    lookup_mode: Option<&str>,
370) -> Result<()> {
371    let repo = git::get_repo_root(None)?;
372
373    // Fetch first
374    println!("{}", style("Fetching updates from remote...").yellow());
375    let fetch_result = git::git_command(&["fetch", "--all", "--prune"], Some(&repo), false, true)?;
376    if fetch_result.returncode != 0 {
377        println!(
378            "{} Fetch failed or no remote configured\n",
379            style("!").yellow()
380        );
381    }
382
383    if _fetch_only {
384        println!("{} Fetch complete\n", style("*").green().bold());
385        return Ok(());
386    }
387
388    // Determine worktrees to sync
389    let worktrees_to_sync = if all {
390        let all_wt = git::parse_worktrees(&repo)?;
391        all_wt
392            .into_iter()
393            .filter(|(b, _)| b != "(detached)")
394            .map(|(b, p)| {
395                let branch = git::normalize_branch_name(&b).to_string();
396                (branch, p)
397            })
398            .collect::<Vec<_>>()
399    } else {
400        let resolved = resolve_worktree_target(target, lookup_mode)?;
401        vec![(resolved.branch, resolved.path)]
402    };
403
404    for (branch, wt_path) in &worktrees_to_sync {
405        let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
406        let base_branch = git::get_config(&base_key, Some(&repo));
407
408        if let Some(base) = base_branch {
409            println!("\n{}", style("Syncing worktree:").cyan().bold());
410            println!("  Branch: {}", style(branch).green());
411            println!("  Base:   {}", style(&base).green());
412            println!("  Path:   {}\n", style(wt_path.display()).blue());
413
414            // Determine rebase target (fetch already done above)
415            let rebase_target = {
416                let origin_base = format!("origin/{}", base);
417                if git::branch_exists(&origin_base, Some(wt_path)) {
418                    origin_base
419                } else {
420                    base.clone()
421                }
422            };
423
424            println!(
425                "{}",
426                style(messages::rebase_in_progress(branch, &rebase_target)).yellow()
427            );
428
429            match git::git_command(&["rebase", &rebase_target], Some(wt_path), false, true) {
430                Ok(r) if r.returncode == 0 => {
431                    println!("{} Rebase successful\n", style("*").green().bold());
432                }
433                _ => {
434                    if ai_merge {
435                        let conflicts = git::git_command(
436                            &["diff", "--name-only", "--diff-filter=U"],
437                            Some(wt_path),
438                            false,
439                            true,
440                        )
441                        .ok()
442                        .and_then(|r| {
443                            if r.returncode == 0 && !r.stdout.trim().is_empty() {
444                                Some(r.stdout.trim().to_string())
445                            } else {
446                                None
447                            }
448                        });
449
450                        let _ =
451                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
452
453                        let conflict_list = conflicts.as_deref().unwrap_or("(unknown)");
454                        let prompt = format!(
455                            "Resolve merge conflicts in this repository. The rebase of '{}' onto '{}' \
456                             failed with conflicts in: {}\n\
457                             Please examine the conflicted files and resolve them.",
458                            branch, rebase_target, conflict_list
459                        );
460
461                        println!(
462                            "\n{} Launching AI to resolve conflicts for '{}'...\n",
463                            style("*").cyan().bold(),
464                            branch
465                        );
466                        let _ = super::ai_tools::launch_ai_tool(
467                            wt_path,
468                            None,
469                            false,
470                            Some(&prompt),
471                            None,
472                        );
473                    } else {
474                        // Abort rebase on failure
475                        let _ =
476                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
477                        println!(
478                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n\
479                             Tip: Use --ai-merge flag to get AI assistance with conflicts\n",
480                            style("!").yellow(),
481                            branch
482                        );
483                    }
484                }
485            }
486        } else {
487            // No base branch metadata — try origin/branch
488            let origin_ref = format!("origin/{}", branch);
489            if git::branch_exists(&origin_ref, Some(wt_path)) {
490                println!("\n{}", style("Syncing worktree:").cyan().bold());
491                println!("  Branch: {}", style(branch).green());
492                println!("  Path:   {}\n", style(wt_path.display()).blue());
493
494                println!(
495                    "{}",
496                    style(messages::rebase_in_progress(branch, &origin_ref)).yellow()
497                );
498
499                match git::git_command(&["rebase", &origin_ref], Some(wt_path), false, true) {
500                    Ok(r) if r.returncode == 0 => {
501                        println!("{} Rebase successful\n", style("*").green().bold());
502                    }
503                    _ => {
504                        let _ =
505                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
506                        println!(
507                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n",
508                            style("!").yellow(),
509                            branch
510                        );
511                    }
512                }
513            }
514        }
515    }
516
517    Ok(())
518}