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) -> Result<PathBuf> {
28    let repo = git::get_repo_root(None)?;
29
30    // Validate branch name
31    if !git::is_valid_branch_name(branch_name, Some(&repo)) {
32        let error_msg = git::get_branch_name_error(branch_name);
33        return Err(CwError::InvalidBranch(messages::invalid_branch_name(
34            &error_msg,
35        )));
36    }
37
38    // Check if worktree already exists
39    let existing = git::find_worktree_by_branch(&repo, branch_name)?.or(
40        git::find_worktree_by_branch(&repo, &format!("refs/heads/{}", branch_name))?,
41    );
42
43    if let Some(existing_path) = existing {
44        println!(
45            "\n{}\nBranch '{}' already has a worktree at:\n  {}\n",
46            style("! Worktree already exists").yellow().bold(),
47            style(branch_name).cyan(),
48            style(existing_path.display()).blue(),
49        );
50
51        if git::is_non_interactive() {
52            return Err(CwError::InvalidBranch(format!(
53                "Worktree for branch '{}' already exists at {}.\n\
54                 Use 'gw resume {}' to continue work.",
55                branch_name,
56                existing_path.display(),
57                branch_name,
58            )));
59        }
60
61        // In interactive mode, suggest resume
62        println!(
63            "Use '{}' to resume work in this worktree.\n",
64            style(format!("gw resume {}", branch_name)).cyan()
65        );
66        return Ok(existing_path);
67    }
68
69    // Determine if branch already exists
70    let mut branch_already_exists = false;
71    let mut is_remote_only = false;
72
73    if git::branch_exists(branch_name, Some(&repo)) {
74        println!(
75            "\n{}\nBranch '{}' already exists locally but has no worktree.\n",
76            style("! Branch already exists").yellow().bold(),
77            style(branch_name).cyan(),
78        );
79        branch_already_exists = true;
80    } else if git::remote_branch_exists(branch_name, Some(&repo), "origin") {
81        println!(
82            "\n{}\nBranch '{}' exists on remote but not locally.\n",
83            style("! Remote branch found").yellow().bold(),
84            style(branch_name).cyan(),
85        );
86        branch_already_exists = true;
87        is_remote_only = true;
88    }
89
90    // Determine base branch
91    let base = if is_remote_only && base_branch.is_none() {
92        git::get_current_branch(Some(&repo)).unwrap_or_else(|_| "main".to_string())
93    } else if let Some(b) = base_branch {
94        b.to_string()
95    } else {
96        git::get_current_branch(Some(&repo))
97            .map_err(|_| CwError::InvalidBranch(messages::cannot_determine_base_branch()))?
98    };
99
100    // Verify base branch
101    if (!is_remote_only || base_branch.is_some()) && !git::branch_exists(&base, Some(&repo)) {
102        return Err(CwError::InvalidBranch(messages::branch_not_found(&base)));
103    }
104
105    // Determine worktree path
106    let worktree_path = if let Some(p) = path {
107        PathBuf::from(p)
108            .canonicalize()
109            .unwrap_or_else(|_| PathBuf::from(p))
110    } else {
111        default_worktree_path(&repo, branch_name)
112    };
113
114    println!("\n{}", style("Creating new worktree:").cyan().bold());
115    println!("  Base branch: {}", style(&base).green());
116    println!("  New branch:  {}", style(branch_name).green());
117    println!("  Path:        {}\n", style(worktree_path.display()).blue());
118
119    // Pre-create hooks
120    let mut hook_ctx = build_hook_context(
121        branch_name,
122        &base,
123        &worktree_path,
124        &repo,
125        "worktree.pre_create",
126        "new",
127    );
128    hooks::run_hooks("worktree.pre_create", &hook_ctx, Some(&repo), Some(&repo))?;
129
130    // Create parent dir
131    if let Some(parent) = worktree_path.parent() {
132        let _ = std::fs::create_dir_all(parent);
133    }
134
135    // Fetch
136    let _ = git::git_command(&["fetch", "--all", "--prune"], Some(&repo), false, false);
137
138    // Create worktree
139    let wt_str = worktree_path.to_string_lossy().to_string();
140    if is_remote_only {
141        git::git_command(
142            &[
143                "worktree",
144                "add",
145                "-b",
146                branch_name,
147                &wt_str,
148                &format!("origin/{}", branch_name),
149            ],
150            Some(&repo),
151            true,
152            false,
153        )?;
154    } else if branch_already_exists {
155        git::git_command(
156            &["worktree", "add", &wt_str, branch_name],
157            Some(&repo),
158            true,
159            false,
160        )?;
161    } else {
162        git::git_command(
163            &["worktree", "add", "-b", branch_name, &wt_str, &base],
164            Some(&repo),
165            true,
166            false,
167        )?;
168    }
169
170    // Store metadata
171    let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
172    let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch_name);
173    let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch_name);
174    git::set_config(&bb_key, &base, Some(&repo))?;
175    git::set_config(&bp_key, &repo.to_string_lossy(), Some(&repo))?;
176    git::set_config(&ib_key, branch_name, Some(&repo))?;
177
178    // Register in global registry (non-fatal)
179    let _ = registry::register_repo(&repo);
180
181    println!(
182        "{} Worktree created successfully\n",
183        style("*").green().bold()
184    );
185
186    // Copy shared files
187    shared_files::share_files(&repo, &worktree_path);
188
189    // Post-create hooks
190    hook_ctx.insert("event".into(), "worktree.post_create".into());
191    let _ = hooks::run_hooks(
192        "worktree.post_create",
193        &hook_ctx,
194        Some(&worktree_path),
195        Some(&repo),
196    );
197
198    // Launch AI tool in the new worktree
199    if !no_ai {
200        let _ = super::ai_tools::launch_ai_tool(&worktree_path, _term, false, None);
201    }
202
203    Ok(worktree_path)
204}
205
206/// Delete a worktree by branch name, worktree directory name, or path.
207pub fn delete_worktree(
208    target: Option<&str>,
209    keep_branch: bool,
210    delete_remote: bool,
211    force: bool,
212    lookup_mode: Option<&str>,
213) -> Result<()> {
214    let main_repo = git::get_main_repo_root(None)?;
215    let (worktree_path, branch_name) = resolve_delete_target(target, &main_repo, lookup_mode)?;
216
217    // Safety: don't delete main repo
218    let wt_resolved = git::canonicalize_or(&worktree_path);
219    let main_resolved = git::canonicalize_or(&main_repo);
220    if wt_resolved == main_resolved {
221        return Err(CwError::Git(messages::cannot_delete_main_worktree()));
222    }
223
224    // If cwd is inside worktree, change to main repo
225    if let Ok(cwd) = std::env::current_dir() {
226        let cwd_str = cwd.to_string_lossy().to_string();
227        let wt_str = worktree_path.to_string_lossy().to_string();
228        if cwd_str.starts_with(&wt_str) {
229            let _ = std::env::set_current_dir(&main_repo);
230        }
231    }
232
233    // Pre-delete hooks
234    let base_branch = branch_name
235        .as_deref()
236        .and_then(|b| {
237            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, b);
238            git::get_config(&key, Some(&main_repo))
239        })
240        .unwrap_or_default();
241
242    let mut hook_ctx = build_hook_context(
243        &branch_name.clone().unwrap_or_default(),
244        &base_branch,
245        &worktree_path,
246        &main_repo,
247        "worktree.pre_delete",
248        "delete",
249    );
250    hooks::run_hooks(
251        "worktree.pre_delete",
252        &hook_ctx,
253        Some(&main_repo),
254        Some(&main_repo),
255    )?;
256
257    // Remove worktree
258    println!(
259        "{}",
260        style(messages::removing_worktree(&worktree_path)).yellow()
261    );
262    git::remove_worktree_safe(&worktree_path, &main_repo, force)?;
263    println!("{} Worktree removed\n", style("*").green().bold());
264
265    // Delete branch
266    if let Some(ref branch) = branch_name {
267        if !keep_branch {
268            println!(
269                "{}",
270                style(messages::deleting_local_branch(branch)).yellow()
271            );
272            let _ = git::git_command(&["branch", "-D", branch], Some(&main_repo), false, false);
273
274            // Remove metadata
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            // Delete remote branch
288            if delete_remote {
289                println!(
290                    "{}",
291                    style(messages::deleting_remote_branch(branch)).yellow()
292                );
293                match git::git_command(
294                    &["push", "origin", &format!(":{}", branch)],
295                    Some(&main_repo),
296                    false,
297                    true,
298                ) {
299                    Ok(r) if r.returncode == 0 => {
300                        println!("{} Remote branch deleted\n", style("*").green().bold());
301                    }
302                    _ => {
303                        println!("{} Remote branch deletion failed\n", style("!").yellow());
304                    }
305                }
306            }
307        }
308    }
309
310    // Post-delete hooks
311    hook_ctx.insert("event".into(), "worktree.post_delete".into());
312    let _ = hooks::run_hooks(
313        "worktree.post_delete",
314        &hook_ctx,
315        Some(&main_repo),
316        Some(&main_repo),
317    );
318    let _ = registry::update_last_seen(&main_repo);
319
320    Ok(())
321}
322
323/// Resolve delete target to (worktree_path, branch_name).
324fn resolve_delete_target(
325    target: Option<&str>,
326    main_repo: &Path,
327    lookup_mode: Option<&str>,
328) -> Result<(PathBuf, Option<String>)> {
329    let target = target.map(|t| t.to_string()).unwrap_or_else(|| {
330        std::env::current_dir()
331            .unwrap_or_default()
332            .to_string_lossy()
333            .to_string()
334    });
335
336    let target_path = PathBuf::from(&target);
337
338    // Check if it's a filesystem path
339    if target_path.exists() {
340        let resolved = target_path.canonicalize().unwrap_or(target_path);
341        let branch = super::helpers::get_branch_for_worktree(main_repo, &resolved);
342        return Ok((resolved, branch));
343    }
344
345    // Try branch lookup (skip if lookup_mode is "worktree")
346    if lookup_mode != Some("worktree") {
347        if let Some(path) = git::find_worktree_by_intended_branch(main_repo, &target)? {
348            return Ok((path, Some(target)));
349        }
350    }
351
352    // Try worktree name lookup (skip if lookup_mode is "branch")
353    if lookup_mode != Some("branch") {
354        if let Some(path) = git::find_worktree_by_name(main_repo, &target)? {
355            let branch = super::helpers::get_branch_for_worktree(main_repo, &path);
356            return Ok((path, branch));
357        }
358    }
359
360    Err(CwError::WorktreeNotFound(messages::worktree_not_found(
361        &target,
362    )))
363}
364
365/// Sync worktree with base branch.
366pub fn sync_worktree(
367    target: Option<&str>,
368    all: bool,
369    _fetch_only: bool,
370    ai_merge: bool,
371    lookup_mode: Option<&str>,
372) -> Result<()> {
373    let repo = git::get_repo_root(None)?;
374
375    // Fetch first
376    println!("{}", style("Fetching updates from remote...").yellow());
377    let fetch_result = git::git_command(&["fetch", "--all", "--prune"], Some(&repo), false, true)?;
378    if fetch_result.returncode != 0 {
379        println!(
380            "{} Fetch failed or no remote configured\n",
381            style("!").yellow()
382        );
383    }
384
385    if _fetch_only {
386        println!("{} Fetch complete\n", style("*").green().bold());
387        return Ok(());
388    }
389
390    // Determine worktrees to sync
391    let worktrees_to_sync = if all {
392        let all_wt = git::parse_worktrees(&repo)?;
393        all_wt
394            .into_iter()
395            .filter(|(b, _)| b != "(detached)")
396            .map(|(b, p)| {
397                let branch = git::normalize_branch_name(&b).to_string();
398                (branch, p)
399            })
400            .collect::<Vec<_>>()
401    } else {
402        let resolved = resolve_worktree_target(target, lookup_mode)?;
403        vec![(resolved.branch, resolved.path)]
404    };
405
406    for (branch, wt_path) in &worktrees_to_sync {
407        let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
408        let base_branch = git::get_config(&base_key, Some(&repo));
409
410        if let Some(base) = base_branch {
411            println!("\n{}", style("Syncing worktree:").cyan().bold());
412            println!("  Branch: {}", style(branch).green());
413            println!("  Base:   {}", style(&base).green());
414            println!("  Path:   {}\n", style(wt_path.display()).blue());
415
416            // Determine rebase target (fetch already done above)
417            let rebase_target = {
418                let origin_base = format!("origin/{}", base);
419                if git::branch_exists(&origin_base, Some(wt_path)) {
420                    origin_base
421                } else {
422                    base.clone()
423                }
424            };
425
426            println!(
427                "{}",
428                style(messages::rebase_in_progress(branch, &rebase_target)).yellow()
429            );
430
431            match git::git_command(&["rebase", &rebase_target], Some(wt_path), false, true) {
432                Ok(r) if r.returncode == 0 => {
433                    println!("{} Rebase successful\n", style("*").green().bold());
434                }
435                _ => {
436                    if ai_merge {
437                        let conflicts = git::git_command(
438                            &["diff", "--name-only", "--diff-filter=U"],
439                            Some(wt_path),
440                            false,
441                            true,
442                        )
443                        .ok()
444                        .and_then(|r| {
445                            if r.returncode == 0 && !r.stdout.trim().is_empty() {
446                                Some(r.stdout.trim().to_string())
447                            } else {
448                                None
449                            }
450                        });
451
452                        let _ =
453                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
454
455                        let conflict_list = conflicts.as_deref().unwrap_or("(unknown)");
456                        let prompt = format!(
457                            "Resolve merge conflicts in this repository. The rebase of '{}' onto '{}' \
458                             failed with conflicts in: {}\n\
459                             Please examine the conflicted files and resolve them.",
460                            branch, rebase_target, conflict_list
461                        );
462
463                        println!(
464                            "\n{} Launching AI to resolve conflicts for '{}'...\n",
465                            style("*").cyan().bold(),
466                            branch
467                        );
468                        let _ =
469                            super::ai_tools::launch_ai_tool(wt_path, None, false, Some(&prompt));
470                    } else {
471                        // Abort rebase on failure
472                        let _ =
473                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
474                        println!(
475                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n\
476                             Tip: Use --ai-merge flag to get AI assistance with conflicts\n",
477                            style("!").yellow(),
478                            branch
479                        );
480                    }
481                }
482            }
483        } else {
484            // No base branch metadata — try origin/branch
485            let origin_ref = format!("origin/{}", branch);
486            if git::branch_exists(&origin_ref, Some(wt_path)) {
487                println!("\n{}", style("Syncing worktree:").cyan().bold());
488                println!("  Branch: {}", style(branch).green());
489                println!("  Path:   {}\n", style(wt_path.display()).blue());
490
491                println!(
492                    "{}",
493                    style(messages::rebase_in_progress(branch, &origin_ref)).yellow()
494                );
495
496                match git::git_command(&["rebase", &origin_ref], Some(wt_path), false, true) {
497                    Ok(r) if r.returncode == 0 => {
498                        println!("{} Rebase successful\n", style("*").green().bold());
499                    }
500                    _ => {
501                        let _ =
502                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
503                        println!(
504                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n",
505                            style("!").yellow(),
506                            branch
507                        );
508                    }
509                }
510            }
511        }
512    }
513
514    Ok(())
515}