Skip to main content

git_worktree_manager/operations/
worktree.rs

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