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