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