Skip to main content

git_worktree_manager/operations/
git_ops.rs

1/// Git operations for pull requests and merging.
2///
3/// Mirrors src/git_worktree_manager/operations/git_ops.py (412 lines).
4use std::collections::HashMap;
5use std::process::Command;
6
7use console::style;
8
9use crate::constants::{
10    format_config_key, CONFIG_KEY_BASE_BRANCH, CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
11};
12use crate::error::{CwError, Result};
13use crate::git;
14use crate::hooks;
15use crate::registry;
16
17use super::helpers::{get_worktree_metadata, resolve_worktree_target};
18
19/// Create a GitHub Pull Request for the worktree.
20pub fn create_pr_worktree(
21    target: Option<&str>,
22    push: bool,
23    title: Option<&str>,
24    body: Option<&str>,
25    draft: bool,
26) -> Result<()> {
27    if !git::has_command("gh") {
28        return Err(CwError::Git(
29            "GitHub CLI (gh) is required to create pull requests.\n\
30             Install it from: https://cli.github.com/"
31                .to_string(),
32        ));
33    }
34
35    let (cwd, feature_branch, worktree_repo) = resolve_worktree_target(target, None)?;
36    let (base_branch, base_path) = get_worktree_metadata(&feature_branch, &worktree_repo)?;
37
38    println!("\n{}", style("Creating Pull Request:").cyan().bold());
39    println!("  Feature:     {}", style(&feature_branch).green());
40    println!("  Base:        {}", style(&base_branch).green());
41    println!("  Repo:        {}\n", style(base_path.display()).blue());
42
43    // Pre-PR hooks
44    let mut hook_ctx = HashMap::new();
45    hook_ctx.insert("branch".into(), feature_branch.clone());
46    hook_ctx.insert("base_branch".into(), base_branch.clone());
47    hook_ctx.insert("worktree_path".into(), cwd.to_string_lossy().to_string());
48    hook_ctx.insert("repo_path".into(), base_path.to_string_lossy().to_string());
49    hook_ctx.insert("event".into(), "pr.pre".into());
50    hook_ctx.insert("operation".into(), "pr".into());
51    hooks::run_hooks("pr.pre", &hook_ctx, Some(&cwd), Some(&base_path))?;
52
53    // Fetch
54    println!("{}", style("Fetching updates from remote...").yellow());
55    let fetch_ok = git::git_command(
56        &["fetch", "--all", "--prune"],
57        Some(&base_path),
58        false,
59        true,
60    )
61    .map(|r| r.returncode == 0)
62    .unwrap_or(false);
63
64    // Determine rebase target
65    let rebase_target = if fetch_ok {
66        let origin_ref = format!("origin/{}", base_branch);
67        if git::branch_exists(&origin_ref, Some(&cwd)) {
68            origin_ref
69        } else {
70            base_branch.clone()
71        }
72    } else {
73        base_branch.clone()
74    };
75
76    // Rebase
77    println!(
78        "{}",
79        style(format!(
80            "Rebasing {} onto {}...",
81            feature_branch, rebase_target
82        ))
83        .yellow()
84    );
85
86    match git::git_command(&["rebase", &rebase_target], Some(&cwd), false, true) {
87        Ok(r) if r.returncode == 0 => {}
88        _ => {
89            // Abort and report
90            let conflicts = git::git_command(
91                &["diff", "--name-only", "--diff-filter=U"],
92                Some(&cwd),
93                false,
94                true,
95            )
96            .ok()
97            .and_then(|r| {
98                if r.returncode == 0 && !r.stdout.trim().is_empty() {
99                    Some(r.stdout.trim().to_string())
100                } else {
101                    None
102                }
103            });
104
105            let _ = git::git_command(&["rebase", "--abort"], Some(&cwd), false, false);
106
107            let mut msg = format!(
108                "Rebase failed. Please resolve conflicts manually:\n  cd {}\n  git rebase {}",
109                cwd.display(),
110                rebase_target
111            );
112            if let Some(files) = conflicts {
113                msg.push_str("\n\nConflicted files:\n");
114                for f in files.lines() {
115                    msg.push_str(&format!("  - {}\n", f));
116                }
117            }
118            return Err(CwError::Rebase(msg));
119        }
120    }
121
122    println!("{} Rebase successful\n", style("*").green().bold());
123
124    // Push
125    if push {
126        println!(
127            "{}",
128            style(format!("Pushing {} to origin...", feature_branch)).yellow()
129        );
130        match git::git_command(
131            &["push", "-u", "origin", &feature_branch],
132            Some(&cwd),
133            false,
134            true,
135        ) {
136            Ok(r) if r.returncode == 0 => {
137                println!("{} Pushed to origin\n", style("*").green().bold());
138            }
139            Ok(r) => {
140                // Try force push with lease
141                match git::git_command(
142                    &[
143                        "push",
144                        "--force-with-lease",
145                        "-u",
146                        "origin",
147                        &feature_branch,
148                    ],
149                    Some(&cwd),
150                    false,
151                    true,
152                ) {
153                    Ok(r2) if r2.returncode == 0 => {
154                        println!("{} Force pushed to origin\n", style("*").green().bold());
155                    }
156                    _ => {
157                        return Err(CwError::Git(format!("Push failed: {}", r.stdout)));
158                    }
159                }
160            }
161            Err(e) => return Err(e),
162        }
163    }
164
165    // Create PR
166    println!("{}", style("Creating pull request...").yellow());
167
168    let mut pr_args = vec![
169        "gh".to_string(),
170        "pr".to_string(),
171        "create".to_string(),
172        "--base".to_string(),
173        base_branch.clone(),
174    ];
175
176    if let Some(t) = title {
177        pr_args.extend(["--title".to_string(), t.to_string()]);
178        if let Some(b) = body {
179            pr_args.extend(["--body".to_string(), b.to_string()]);
180        }
181    } else {
182        pr_args.push("--fill".to_string());
183    }
184
185    if draft {
186        pr_args.push("--draft".to_string());
187    }
188
189    let output = Command::new(&pr_args[0])
190        .args(&pr_args[1..])
191        .current_dir(&cwd)
192        .output()?;
193
194    if output.status.success() {
195        let pr_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
196        println!("{} Pull request created!\n", style("*").green().bold());
197        println!("{} {}\n", style("PR URL:").bold(), pr_url);
198        println!(
199            "{}\n",
200            style("Note: Worktree is still active. Use 'gw delete' to remove after PR is merged.")
201                .dim()
202        );
203
204        // Post-PR hooks
205        hook_ctx.insert("event".into(), "pr.post".into());
206        hook_ctx.insert("pr_url".into(), pr_url);
207        let _ = hooks::run_hooks("pr.post", &hook_ctx, Some(&cwd), Some(&base_path));
208    } else {
209        let stderr = String::from_utf8_lossy(&output.stderr);
210        return Err(CwError::Git(format!(
211            "Failed to create pull request: {}",
212            stderr
213        )));
214    }
215
216    Ok(())
217}
218
219/// Merge worktree: rebase, fast-forward merge, cleanup.
220pub fn merge_worktree(
221    target: Option<&str>,
222    push: bool,
223    _interactive: bool,
224    dry_run: bool,
225) -> Result<()> {
226    let (cwd, feature_branch, worktree_repo) = resolve_worktree_target(target, None)?;
227    let (base_branch, base_path) = get_worktree_metadata(&feature_branch, &worktree_repo)?;
228    let repo = &base_path;
229
230    println!("\n{}", style("Finishing worktree:").cyan().bold());
231    println!("  Feature:     {}", style(&feature_branch).green());
232    println!("  Base:        {}", style(&base_branch).green());
233    println!("  Repo:        {}\n", style(repo.display()).blue());
234
235    // Pre-merge hooks
236    let mut hook_ctx = HashMap::new();
237    hook_ctx.insert("branch".into(), feature_branch.clone());
238    hook_ctx.insert("base_branch".into(), base_branch.clone());
239    hook_ctx.insert("worktree_path".into(), cwd.to_string_lossy().to_string());
240    hook_ctx.insert("repo_path".into(), repo.to_string_lossy().to_string());
241    hook_ctx.insert("event".into(), "merge.pre".into());
242    hook_ctx.insert("operation".into(), "merge".into());
243    if !dry_run {
244        hooks::run_hooks("merge.pre", &hook_ctx, Some(&cwd), Some(repo))?;
245    }
246
247    // Dry run
248    if dry_run {
249        println!(
250            "{}\n",
251            style("DRY RUN MODE — No changes will be made")
252                .yellow()
253                .bold()
254        );
255        println!(
256            "{}\n",
257            style("The following operations would be performed:").bold()
258        );
259        println!("  1. Fetch updates from remote");
260        println!("  2. Rebase {} onto {}", feature_branch, base_branch);
261        println!("  3. Switch to {} in base repository", base_branch);
262        println!(
263            "  4. Merge {} into {} (fast-forward)",
264            feature_branch, base_branch
265        );
266        if push {
267            println!("  5. Push {} to origin", base_branch);
268            println!("  6. Remove worktree at {}", cwd.display());
269            println!("  7. Delete local branch {}", feature_branch);
270        } else {
271            println!("  5. Remove worktree at {}", cwd.display());
272            println!("  6. Delete local branch {}", feature_branch);
273        }
274        println!("\n{}\n", style("Run without --dry-run to execute.").dim());
275        return Ok(());
276    }
277
278    // Fetch
279    let fetch_ok = git::git_command(&["fetch", "--all", "--prune"], Some(repo), false, true)
280        .map(|r| r.returncode == 0)
281        .unwrap_or(false);
282
283    let rebase_target = if fetch_ok {
284        let origin_ref = format!("origin/{}", base_branch);
285        if git::branch_exists(&origin_ref, Some(&cwd)) {
286            origin_ref
287        } else {
288            base_branch.clone()
289        }
290    } else {
291        base_branch.clone()
292    };
293
294    // Rebase
295    println!(
296        "{}",
297        style(format!(
298            "Rebasing {} onto {}...",
299            feature_branch, rebase_target
300        ))
301        .yellow()
302    );
303
304    match git::git_command(&["rebase", &rebase_target], Some(&cwd), false, true) {
305        Ok(r) if r.returncode == 0 => {}
306        _ => {
307            let _ = git::git_command(&["rebase", "--abort"], Some(&cwd), false, false);
308            return Err(CwError::Rebase(format!(
309                "Rebase failed. Resolve conflicts manually:\n  cd {}\n  git rebase {}",
310                cwd.display(),
311                rebase_target
312            )));
313        }
314    }
315
316    println!("{} Rebase successful\n", style("*").green().bold());
317
318    // Verify base path
319    if !base_path.exists() {
320        return Err(CwError::WorktreeNotFound(format!(
321            "Base repository not found at: {}",
322            base_path.display()
323        )));
324    }
325
326    // Fast-forward merge
327    println!(
328        "{}",
329        style(format!(
330            "Merging {} into {}...",
331            feature_branch, base_branch
332        ))
333        .yellow()
334    );
335
336    // Switch to base branch if needed
337    let _ = git::git_command(
338        &["fetch", "--all", "--prune"],
339        Some(&base_path),
340        false,
341        false,
342    );
343    if let Ok(current) = git::get_current_branch(Some(&base_path)) {
344        if current != base_branch {
345            git::git_command(&["switch", &base_branch], Some(&base_path), true, false)?;
346        }
347    } else {
348        git::git_command(&["switch", &base_branch], Some(&base_path), true, false)?;
349    }
350
351    match git::git_command(
352        &["merge", "--ff-only", &feature_branch],
353        Some(&base_path),
354        false,
355        true,
356    ) {
357        Ok(r) if r.returncode == 0 => {}
358        _ => {
359            return Err(CwError::Merge(format!(
360                "Fast-forward merge failed. Manual intervention required:\n  cd {}\n  git merge {}",
361                base_path.display(),
362                feature_branch
363            )));
364        }
365    }
366
367    println!(
368        "{} Merged {} into {}\n",
369        style("*").green().bold(),
370        feature_branch,
371        base_branch
372    );
373
374    // Push
375    if push {
376        println!(
377            "{}",
378            style(format!("Pushing {} to origin...", base_branch)).yellow()
379        );
380        match git::git_command(
381            &["push", "origin", &base_branch],
382            Some(&base_path),
383            false,
384            true,
385        ) {
386            Ok(r) if r.returncode == 0 => {
387                println!("{} Pushed to origin\n", style("*").green().bold());
388            }
389            _ => {
390                println!("{} Push failed\n", style("!").yellow());
391            }
392        }
393    }
394
395    // Cleanup
396    println!("{}", style("Cleaning up worktree and branch...").yellow());
397
398    let _ = std::env::set_current_dir(repo);
399
400    git::remove_worktree_safe(&cwd, repo, true)?;
401    let _ = git::git_command(&["branch", "-D", &feature_branch], Some(repo), false, false);
402
403    // Remove metadata
404    let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &feature_branch);
405    let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, &feature_branch);
406    let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, &feature_branch);
407    git::unset_config(&bb_key, Some(repo));
408    git::unset_config(&bp_key, Some(repo));
409    git::unset_config(&ib_key, Some(repo));
410
411    println!("{}\n", style("* Cleanup complete!").green().bold());
412
413    // Post-merge hooks
414    hook_ctx.insert("event".into(), "merge.post".into());
415    let _ = hooks::run_hooks("merge.post", &hook_ctx, Some(repo), Some(repo));
416    let _ = registry::update_last_seen(repo);
417
418    Ok(())
419}