Skip to main content

git_worktree_manager/operations/
git_ops.rs

1/// Git operations for pull requests and merging.
2///
3use std::path::Path;
4use std::process::Command;
5use std::time::Duration;
6
7use console::style;
8
9use crate::config;
10use crate::constants::{
11    format_config_key, CONFIG_KEY_BASE_BRANCH, CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
12};
13use crate::error::{CwError, Result};
14use crate::git;
15use crate::hooks;
16use crate::registry;
17
18use super::helpers::{build_hook_context, get_worktree_metadata, resolve_worktree_target};
19use crate::messages;
20
21/// Create a GitHub Pull Request for the worktree.
22pub fn create_pr_worktree(
23    target: Option<&str>,
24    push: bool,
25    title: Option<&str>,
26    body: Option<&str>,
27    draft: bool,
28    lookup_mode: Option<&str>,
29) -> Result<()> {
30    if !git::has_command("gh") {
31        return Err(CwError::Git(messages::gh_cli_not_found()));
32    }
33
34    let resolved = resolve_worktree_target(target, lookup_mode)?;
35    let cwd = resolved.path;
36    let feature_branch = resolved.branch;
37    let (base_branch, base_path) = get_worktree_metadata(&feature_branch, &resolved.repo)?;
38
39    println!("\n{}", style("Creating Pull Request:").cyan().bold());
40    println!("  Feature:     {}", style(&feature_branch).green());
41    println!("  Base:        {}", style(&base_branch).green());
42    println!("  Repo:        {}\n", style(base_path.display()).blue());
43
44    // Pre-PR hooks
45    let mut hook_ctx = build_hook_context(
46        &feature_branch,
47        &base_branch,
48        &cwd,
49        &base_path,
50        "pr.pre",
51        "pr",
52    );
53    hooks::run_hooks("pr.pre", &hook_ctx, Some(&cwd), Some(&base_path))?;
54
55    // Fetch and determine rebase target
56    println!("{}", style("Fetching updates from remote...").yellow());
57    let (_fetch_ok, rebase_target) = git::fetch_and_rebase_target(&base_branch, &base_path, &cwd);
58
59    // Rebase
60    println!(
61        "{}",
62        style(messages::rebase_in_progress(
63            &feature_branch,
64            &rebase_target
65        ))
66        .yellow()
67    );
68
69    match git::git_command(&["rebase", &rebase_target], Some(&cwd), false, true) {
70        Ok(r) if r.returncode == 0 => {}
71        _ => {
72            // Abort and report
73            let conflicts = git::git_command(
74                &["diff", "--name-only", "--diff-filter=U"],
75                Some(&cwd),
76                false,
77                true,
78            )
79            .ok()
80            .and_then(|r| {
81                if r.returncode == 0 && !r.stdout.trim().is_empty() {
82                    Some(r.stdout.trim().to_string())
83                } else {
84                    None
85                }
86            });
87
88            let _ = git::git_command(&["rebase", "--abort"], Some(&cwd), false, false);
89
90            let conflict_vec = conflicts
91                .as_ref()
92                .map(|c| c.lines().map(String::from).collect::<Vec<_>>());
93            return Err(CwError::Rebase(messages::rebase_failed(
94                &cwd.display().to_string(),
95                &rebase_target,
96                conflict_vec.as_deref(),
97            )));
98        }
99    }
100
101    println!("{} Rebase successful\n", style("*").green().bold());
102
103    // Push
104    if push {
105        println!(
106            "{}",
107            style(messages::pushing_to_origin(&feature_branch)).yellow()
108        );
109        match git::git_command(
110            &["push", "-u", "origin", &feature_branch],
111            Some(&cwd),
112            false,
113            true,
114        ) {
115            Ok(r) if r.returncode == 0 => {
116                println!("{} Pushed to origin\n", style("*").green().bold());
117            }
118            Ok(r) => {
119                // Try force push with lease
120                match git::git_command(
121                    &[
122                        "push",
123                        "--force-with-lease",
124                        "-u",
125                        "origin",
126                        &feature_branch,
127                    ],
128                    Some(&cwd),
129                    false,
130                    true,
131                ) {
132                    Ok(r2) if r2.returncode == 0 => {
133                        println!("{} Force pushed to origin\n", style("*").green().bold());
134                    }
135                    _ => {
136                        return Err(CwError::Git(format!("Push failed: {}", r.stdout)));
137                    }
138                }
139            }
140            Err(e) => return Err(e),
141        }
142    }
143
144    // Create PR
145    println!("{}", style("Creating pull request...").yellow());
146
147    let mut pr_args = vec![
148        "gh".to_string(),
149        "pr".to_string(),
150        "create".to_string(),
151        "--base".to_string(),
152        base_branch.clone(),
153    ];
154
155    if let Some(t) = title {
156        pr_args.extend(["--title".to_string(), t.to_string()]);
157        if let Some(b) = body {
158            pr_args.extend(["--body".to_string(), b.to_string()]);
159        }
160    } else {
161        // Try AI-generated PR description
162        match generate_pr_description_with_ai(&feature_branch, &base_branch, &cwd) {
163            Some((ai_title, ai_body)) => {
164                pr_args.extend(["--title".to_string(), ai_title]);
165                pr_args.extend(["--body".to_string(), ai_body]);
166            }
167            None => {
168                pr_args.push("--fill".to_string());
169            }
170        }
171    }
172
173    if draft {
174        pr_args.push("--draft".to_string());
175    }
176
177    let output = Command::new(&pr_args[0])
178        .args(&pr_args[1..])
179        .current_dir(&cwd)
180        .output()?;
181
182    if output.status.success() {
183        let pr_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
184        println!("{} Pull request created!\n", style("*").green().bold());
185        println!("{} {}\n", style("PR URL:").bold(), pr_url);
186        println!(
187            "{}\n",
188            style("Note: Worktree is still active. Use 'gw delete' to remove after PR is merged.")
189                .dim()
190        );
191
192        // Post-PR hooks
193        hook_ctx.insert("event".into(), "pr.post".into());
194        hook_ctx.insert("pr_url".into(), pr_url);
195        let _ = hooks::run_hooks("pr.post", &hook_ctx, Some(&cwd), Some(&base_path));
196    } else {
197        let stderr = String::from_utf8_lossy(&output.stderr);
198        return Err(CwError::Git(messages::pr_creation_failed(&stderr)));
199    }
200
201    Ok(())
202}
203
204/// Merge worktree: rebase, fast-forward merge, cleanup.
205pub fn merge_worktree(
206    target: Option<&str>,
207    push: bool,
208    interactive: bool,
209    dry_run: bool,
210    ai_merge: bool,
211    lookup_mode: Option<&str>,
212) -> Result<()> {
213    let resolved = resolve_worktree_target(target, lookup_mode)?;
214    let cwd = resolved.path;
215    let feature_branch = resolved.branch;
216    let (base_branch, base_path) = get_worktree_metadata(&feature_branch, &resolved.repo)?;
217    let repo = &base_path;
218
219    println!("\n{}", style("Finishing worktree:").cyan().bold());
220    println!("  Feature:     {}", style(&feature_branch).green());
221    println!("  Base:        {}", style(&base_branch).green());
222    println!("  Repo:        {}\n", style(repo.display()).blue());
223
224    // Pre-merge hooks
225    let mut hook_ctx = build_hook_context(
226        &feature_branch,
227        &base_branch,
228        &cwd,
229        repo,
230        "merge.pre",
231        "merge",
232    );
233    if !dry_run {
234        hooks::run_hooks("merge.pre", &hook_ctx, Some(&cwd), Some(repo))?;
235    }
236
237    // Dry run
238    if dry_run {
239        println!(
240            "{}\n",
241            style("DRY RUN MODE — No changes will be made")
242                .yellow()
243                .bold()
244        );
245        println!(
246            "{}\n",
247            style("The following operations would be performed:").bold()
248        );
249        println!("  1. Fetch updates from remote");
250        println!("  2. Rebase {} onto {}", feature_branch, base_branch);
251        println!("  3. Switch to {} in base repository", base_branch);
252        println!(
253            "  4. Merge {} into {} (fast-forward)",
254            feature_branch, base_branch
255        );
256        if push {
257            println!("  5. Push {} to origin", base_branch);
258            println!("  6. Remove worktree at {}", cwd.display());
259            println!("  7. Delete local branch {}", feature_branch);
260        } else {
261            println!("  5. Remove worktree at {}", cwd.display());
262            println!("  6. Delete local branch {}", feature_branch);
263        }
264        println!("\n{}\n", style("Run without --dry-run to execute.").dim());
265        return Ok(());
266    }
267
268    // Fetch and determine rebase target
269    let (_fetch_ok, rebase_target) = git::fetch_and_rebase_target(&base_branch, repo, &cwd);
270
271    // Rebase
272    if interactive {
273        // Interactive rebase requires a TTY — run directly via inherited stdio
274        println!(
275            "{}",
276            style(format!(
277                "Interactive rebase of {} onto {}...",
278                feature_branch, rebase_target
279            ))
280            .yellow()
281        );
282        let status = Command::new("git")
283            .args(["rebase", "-i", &rebase_target])
284            .current_dir(&cwd)
285            .status();
286        match status {
287            Ok(s) if s.success() => {}
288            _ => {
289                return Err(CwError::Rebase(messages::rebase_failed(
290                    &cwd.display().to_string(),
291                    &rebase_target,
292                    None,
293                )));
294            }
295        }
296    } else {
297        println!(
298            "{}",
299            style(format!(
300                "Rebasing {} onto {}...",
301                feature_branch, rebase_target
302            ))
303            .yellow()
304        );
305
306        match git::git_command(&["rebase", &rebase_target], Some(&cwd), false, true) {
307            Ok(r) if r.returncode == 0 => {}
308            _ => {
309                if ai_merge {
310                    // Try AI-assisted conflict resolution
311                    let conflicts = git::git_command(
312                        &["diff", "--name-only", "--diff-filter=U"],
313                        Some(&cwd),
314                        false,
315                        true,
316                    )
317                    .ok()
318                    .and_then(|r| {
319                        if r.returncode == 0 && !r.stdout.trim().is_empty() {
320                            Some(r.stdout.trim().to_string())
321                        } else {
322                            None
323                        }
324                    });
325
326                    let _ = git::git_command(&["rebase", "--abort"], Some(&cwd), false, false);
327
328                    let conflict_list = conflicts.as_deref().unwrap_or("(unknown)");
329                    let prompt = format!(
330                        "Resolve merge conflicts in this repository. The rebase of '{}' onto '{}' \
331                         failed with conflicts in: {}\n\
332                         Please examine the conflicted files and resolve them.",
333                        feature_branch, rebase_target, conflict_list
334                    );
335
336                    println!(
337                        "\n{} Launching AI to resolve conflicts...\n",
338                        style("*").cyan().bold()
339                    );
340                    let _ = super::ai_tools::launch_ai_tool(&cwd, None, false, Some(&prompt), None);
341                    return Ok(());
342                }
343
344                let _ = git::git_command(&["rebase", "--abort"], Some(&cwd), false, false);
345                return Err(CwError::Rebase(messages::rebase_failed(
346                    &cwd.display().to_string(),
347                    &rebase_target,
348                    None,
349                )));
350            }
351        }
352    }
353
354    println!("{} Rebase successful\n", style("*").green().bold());
355
356    // Verify base path
357    if !base_path.exists() {
358        return Err(CwError::WorktreeNotFound(messages::base_repo_not_found(
359            &base_path.display().to_string(),
360        )));
361    }
362
363    // Fast-forward merge
364    println!(
365        "{}",
366        style(format!(
367            "Merging {} into {}...",
368            feature_branch, base_branch
369        ))
370        .yellow()
371    );
372
373    // Switch to base branch if needed
374    let _ = git::git_command(
375        &["fetch", "--all", "--prune"],
376        Some(&base_path),
377        false,
378        false,
379    );
380    if let Ok(current) = git::get_current_branch(Some(&base_path)) {
381        if current != base_branch {
382            git::git_command(&["switch", &base_branch], Some(&base_path), true, false)?;
383        }
384    } else {
385        git::git_command(&["switch", &base_branch], Some(&base_path), true, false)?;
386    }
387
388    match git::git_command(
389        &["merge", "--ff-only", &feature_branch],
390        Some(&base_path),
391        false,
392        true,
393    ) {
394        Ok(r) if r.returncode == 0 => {}
395        _ => {
396            return Err(CwError::Merge(messages::merge_failed(
397                &base_path.display().to_string(),
398                &feature_branch,
399            )));
400        }
401    }
402
403    println!(
404        "{} Merged {} into {}\n",
405        style("*").green().bold(),
406        feature_branch,
407        base_branch
408    );
409
410    // Push
411    if push {
412        println!(
413            "{}",
414            style(messages::pushing_to_origin(&base_branch)).yellow()
415        );
416        match git::git_command(
417            &["push", "origin", &base_branch],
418            Some(&base_path),
419            false,
420            true,
421        ) {
422            Ok(r) if r.returncode == 0 => {
423                println!("{} Pushed to origin\n", style("*").green().bold());
424            }
425            _ => {
426                println!("{} Push failed\n", style("!").yellow());
427            }
428        }
429    }
430
431    // Cleanup
432    println!("{}", style("Cleaning up worktree and branch...").yellow());
433
434    let _ = std::env::set_current_dir(repo);
435
436    git::remove_worktree_safe(&cwd, repo, true)?;
437    let _ = git::git_command(&["branch", "-D", &feature_branch], Some(repo), false, false);
438
439    // Remove metadata
440    let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &feature_branch);
441    let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, &feature_branch);
442    let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, &feature_branch);
443    git::unset_config(&bb_key, Some(repo));
444    git::unset_config(&bp_key, Some(repo));
445    git::unset_config(&ib_key, Some(repo));
446
447    println!("{}\n", style("* Cleanup complete!").green().bold());
448
449    // Post-merge hooks
450    hook_ctx.insert("event".into(), "merge.post".into());
451    let _ = hooks::run_hooks("merge.post", &hook_ctx, Some(repo), Some(repo));
452    let _ = registry::update_last_seen(repo);
453
454    Ok(())
455}
456
457/// Generate PR title and body using AI tool by analyzing commit history.
458///
459/// Returns `Some((title, body))` on success, `None` if AI is not configured or fails.
460fn generate_pr_description_with_ai(
461    feature_branch: &str,
462    base_branch: &str,
463    cwd: &Path,
464) -> Option<(String, String)> {
465    let ai_command = config::get_ai_tool_command().ok()?;
466    if ai_command.is_empty() {
467        return None;
468    }
469
470    // Get commit log for the feature branch
471    let log_result = git::git_command(
472        &[
473            "log",
474            &format!("{}..{}", base_branch, feature_branch),
475            "--pretty=format:Commit: %h%nAuthor: %an%nDate: %ad%nMessage: %s%n%b%n---",
476            "--date=short",
477        ],
478        Some(cwd),
479        false,
480        true,
481    )
482    .ok()?;
483
484    let commits_log = log_result.stdout.trim().to_string();
485    if commits_log.is_empty() {
486        return None;
487    }
488
489    // Get diff stats
490    let diff_stats = git::git_command(
491        &[
492            "diff",
493            "--stat",
494            &format!("{}...{}", base_branch, feature_branch),
495        ],
496        Some(cwd),
497        false,
498        true,
499    )
500    .ok()
501    .map(|r| r.stdout.trim().to_string())
502    .unwrap_or_default();
503
504    let prompt = format!(
505        "Analyze the following git commits and generate a pull request title and description.\n\n\
506         Branch: {} -> {}\n\n\
507         Commits:\n{}\n\n\
508         Diff Statistics:\n{}\n\n\
509         Please provide:\n\
510         1. A concise PR title (one line, following conventional commit format if applicable)\n\
511         2. A detailed PR description with:\n\
512            - Summary of changes (2-3 sentences)\n\
513            - Test plan (bullet points)\n\n\
514         Format your response EXACTLY as:\n\
515         TITLE: <your title here>\n\
516         BODY:\n\
517         <your body here>",
518        feature_branch, base_branch, commits_log, diff_stats
519    );
520
521    // Build AI command with prompt as positional argument
522    let ai_cmd = config::get_ai_tool_merge_command(&prompt).ok()?;
523    if ai_cmd.is_empty() {
524        return None;
525    }
526
527    println!("{}", style("Generating PR description with AI...").yellow());
528
529    // Run AI command with 60-second timeout
530    let mut child = match Command::new(&ai_cmd[0])
531        .args(&ai_cmd[1..])
532        .current_dir(cwd)
533        .stdout(std::process::Stdio::piped())
534        .stderr(std::process::Stdio::piped())
535        .spawn()
536    {
537        Ok(c) => c,
538        Err(_) => {
539            println!("{} Failed to start AI tool\n", style("!").yellow());
540            return None;
541        }
542    };
543
544    // Poll with timeout
545    let deadline =
546        std::time::Instant::now() + Duration::from_secs(crate::constants::AI_TOOL_TIMEOUT_SECS);
547    let status = loop {
548        match child.try_wait() {
549            Ok(Some(s)) => break s,
550            Ok(None) => {
551                if std::time::Instant::now() > deadline {
552                    let _ = child.kill();
553                    let _ = child.wait();
554                    println!("{} AI tool timed out\n", style("!").yellow());
555                    return None;
556                }
557                std::thread::sleep(Duration::from_millis(crate::constants::AI_TOOL_POLL_MS));
558            }
559            Err(_) => return None,
560        }
561    };
562
563    if !status.success() {
564        println!("{} AI tool failed\n", style("!").yellow());
565        return None;
566    }
567
568    // Read stdout from the completed child process
569    let mut stdout_buf = String::new();
570    if let Some(mut pipe) = child.stdout.take() {
571        use std::io::Read;
572        let _ = pipe.read_to_string(&mut stdout_buf);
573    }
574    let stdout = stdout_buf;
575    let text = stdout.trim();
576
577    // Parse TITLE: and BODY: from output
578    match parse_ai_pr_output(text) {
579        Some((t, b)) => {
580            println!(
581                "{} AI generated PR description\n",
582                style("*").green().bold()
583            );
584            println!("  {} {}", style("Title:").dim(), t);
585            let preview = if b.len() > 100 {
586                format!("{}...", &b[..100])
587            } else {
588                b.clone()
589            };
590            println!("  {} {}\n", style("Body:").dim(), preview);
591            Some((t, b))
592        }
593        None => {
594            println!("{} Could not parse AI output\n", style("!").yellow());
595            None
596        }
597    }
598}
599
600/// Parse AI-generated PR output into (title, body).
601///
602/// Expects format:
603/// ```text
604/// TITLE: <title>
605/// BODY:
606/// <body lines>
607/// ```
608fn parse_ai_pr_output(text: &str) -> Option<(String, String)> {
609    let mut title: Option<String> = None;
610    let mut body: Option<String> = None;
611    let lines: Vec<&str> = text.lines().collect();
612
613    for (i, line) in lines.iter().enumerate() {
614        if let Some(t) = line.strip_prefix("TITLE:") {
615            title = Some(t.trim().to_string());
616        } else if line.starts_with("BODY:") {
617            if i + 1 < lines.len() {
618                body = Some(lines[i + 1..].join("\n").trim().to_string());
619            } else {
620                body = Some(String::new());
621            }
622            break;
623        }
624    }
625
626    match (title, body) {
627        (Some(t), Some(b)) if !t.is_empty() && !b.is_empty() => Some((t, b)),
628        _ => None,
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    #[test]
637    fn test_parse_ai_pr_output_normal() {
638        let text = "TITLE: feat: add login page\nBODY:\n## Summary\nAdded login page\n\n## Test plan\n- Manual test";
639        let result = parse_ai_pr_output(text);
640        assert!(result.is_some());
641        let (title, body) = result.unwrap();
642        assert_eq!(title, "feat: add login page");
643        assert!(body.contains("## Summary"));
644        assert!(body.contains("## Test plan"));
645    }
646
647    #[test]
648    fn test_parse_ai_pr_output_empty() {
649        assert!(parse_ai_pr_output("").is_none());
650    }
651
652    #[test]
653    fn test_parse_ai_pr_output_title_only() {
654        let text = "TITLE: some title";
655        assert!(parse_ai_pr_output(text).is_none());
656    }
657
658    #[test]
659    fn test_parse_ai_pr_output_body_only() {
660        let text = "BODY:\nsome body text";
661        assert!(parse_ai_pr_output(text).is_none());
662    }
663
664    #[test]
665    fn test_parse_ai_pr_output_garbage() {
666        let text = "This is just some random AI output\nwithout proper format";
667        assert!(parse_ai_pr_output(text).is_none());
668    }
669
670    #[test]
671    fn test_parse_ai_pr_output_body_at_last_line() {
672        // BODY: is the last line — boundary check
673        let text = "TITLE: fix: something\nBODY:";
674        assert!(parse_ai_pr_output(text).is_none());
675    }
676
677    #[test]
678    fn test_parse_ai_pr_output_empty_title() {
679        let text = "TITLE:   \nBODY:\nsome body";
680        assert!(parse_ai_pr_output(text).is_none());
681    }
682
683    #[test]
684    fn test_parse_ai_pr_output_multiline_body() {
685        let text = "TITLE: chore: cleanup\nBODY:\nLine 1\nLine 2\nLine 3";
686        let result = parse_ai_pr_output(text).unwrap();
687        assert_eq!(result.0, "chore: cleanup");
688        assert_eq!(result.1, "Line 1\nLine 2\nLine 3");
689    }
690}