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(
311                        &cwd,
312                        None,
313                        false,
314                        Some(&prompt),
315                        None,
316                        false,
317                        false,
318                    );
319                    return Ok(());
320                }
321
322                let _ = git::git_command(&["rebase", "--abort"], Some(&cwd), false, false);
323                return Err(CwError::Rebase(messages::rebase_failed(
324                    &cwd.display().to_string(),
325                    &rebase_target,
326                    None,
327                )));
328            }
329        }
330    }
331
332    println!("{} Rebase successful\n", style("*").green().bold());
333
334    // Verify base path
335    if !base_path.exists() {
336        return Err(CwError::WorktreeNotFound(messages::base_repo_not_found(
337            &base_path.display().to_string(),
338        )));
339    }
340
341    // Fast-forward merge
342    println!(
343        "{}",
344        style(format!(
345            "Merging {} into {}...",
346            feature_branch, base_branch
347        ))
348        .yellow()
349    );
350
351    // Switch to base branch if needed
352    let _ = git::git_command(
353        &["fetch", "--all", "--prune"],
354        Some(&base_path),
355        false,
356        false,
357    );
358    if let Ok(current) = git::get_current_branch(Some(&base_path)) {
359        if current != base_branch {
360            git::git_command(&["switch", &base_branch], Some(&base_path), true, false)?;
361        }
362    } else {
363        git::git_command(&["switch", &base_branch], Some(&base_path), true, false)?;
364    }
365
366    match git::git_command(
367        &["merge", "--ff-only", &feature_branch],
368        Some(&base_path),
369        false,
370        true,
371    ) {
372        Ok(r) if r.returncode == 0 => {}
373        _ => {
374            return Err(CwError::Merge(messages::merge_failed(
375                &base_path.display().to_string(),
376                &feature_branch,
377            )));
378        }
379    }
380
381    println!(
382        "{} Merged {} into {}\n",
383        style("*").green().bold(),
384        feature_branch,
385        base_branch
386    );
387
388    // Push
389    if push {
390        println!(
391            "{}",
392            style(messages::pushing_to_origin(&base_branch)).yellow()
393        );
394        match git::git_command(
395            &["push", "origin", &base_branch],
396            Some(&base_path),
397            false,
398            true,
399        ) {
400            Ok(r) if r.returncode == 0 => {
401                println!("{} Pushed to origin\n", style("*").green().bold());
402            }
403            _ => {
404                println!("{} Push failed\n", style("!").yellow());
405            }
406        }
407    }
408
409    // Cleanup
410    println!("{}", style("Cleaning up worktree and branch...").yellow());
411
412    let _ = std::env::set_current_dir(repo);
413
414    git::remove_worktree_safe(&cwd, repo, true)?;
415    let _ = git::git_command(&["branch", "-D", &feature_branch], Some(repo), false, false);
416
417    // Remove metadata
418    let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &feature_branch);
419    let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, &feature_branch);
420    let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, &feature_branch);
421    git::unset_config(&bb_key, Some(repo));
422    git::unset_config(&bp_key, Some(repo));
423    git::unset_config(&ib_key, Some(repo));
424
425    println!("{}\n", style("* Cleanup complete!").green().bold());
426
427    // Post-merge hooks
428    hook_ctx.insert("event".into(), "merge.post".into());
429    let _ = hooks::run_hooks("merge.post", &hook_ctx, Some(repo), Some(repo));
430    let _ = registry::update_last_seen(repo);
431
432    Ok(())
433}
434
435/// Generate PR title and body using AI tool by analyzing commit history.
436///
437/// Returns `Some((title, body))` on success, `None` if AI is not configured or fails.
438fn generate_pr_description_with_ai(
439    feature_branch: &str,
440    base_branch: &str,
441    cwd: &Path,
442) -> Option<(String, String)> {
443    let ai_command = config::get_ai_tool_command().ok()?;
444    if ai_command.is_empty() {
445        return None;
446    }
447
448    // Get commit log for the feature branch
449    let log_result = git::git_command(
450        &[
451            "log",
452            &format!("{}..{}", base_branch, feature_branch),
453            "--pretty=format:Commit: %h%nAuthor: %an%nDate: %ad%nMessage: %s%n%b%n---",
454            "--date=short",
455        ],
456        Some(cwd),
457        false,
458        true,
459    )
460    .ok()?;
461
462    let commits_log = log_result.stdout.trim().to_string();
463    if commits_log.is_empty() {
464        return None;
465    }
466
467    // Get diff stats
468    let diff_stats = git::git_command(
469        &[
470            "diff",
471            "--stat",
472            &format!("{}...{}", base_branch, feature_branch),
473        ],
474        Some(cwd),
475        false,
476        true,
477    )
478    .ok()
479    .map(|r| r.stdout.trim().to_string())
480    .unwrap_or_default();
481
482    let prompt = format!(
483        "Analyze the following git commits and generate a pull request title and description.\n\n\
484         Branch: {} -> {}\n\n\
485         Commits:\n{}\n\n\
486         Diff Statistics:\n{}\n\n\
487         Please provide:\n\
488         1. A concise PR title (one line, following conventional commit format if applicable)\n\
489         2. A detailed PR description with:\n\
490            - Summary of changes (2-3 sentences)\n\
491            - Test plan (bullet points)\n\n\
492         Format your response EXACTLY as:\n\
493         TITLE: <your title here>\n\
494         BODY:\n\
495         <your body here>",
496        feature_branch, base_branch, commits_log, diff_stats
497    );
498
499    // Build AI command with prompt as positional argument
500    let ai_cmd = config::get_ai_tool_merge_command(&prompt).ok()?;
501    if ai_cmd.is_empty() {
502        return None;
503    }
504
505    println!("{}", style("Generating PR description with AI...").yellow());
506
507    // Run AI command with 60-second timeout
508    let mut child = match Command::new(&ai_cmd[0])
509        .args(&ai_cmd[1..])
510        .current_dir(cwd)
511        .stdout(std::process::Stdio::piped())
512        .stderr(std::process::Stdio::piped())
513        .spawn()
514    {
515        Ok(c) => c,
516        Err(_) => {
517            println!("{} Failed to start AI tool\n", style("!").yellow());
518            return None;
519        }
520    };
521
522    // Poll with timeout
523    let deadline =
524        std::time::Instant::now() + Duration::from_secs(crate::constants::AI_TOOL_TIMEOUT_SECS);
525    let status = loop {
526        match child.try_wait() {
527            Ok(Some(s)) => break s,
528            Ok(None) => {
529                if std::time::Instant::now() > deadline {
530                    let _ = child.kill();
531                    let _ = child.wait();
532                    println!("{} AI tool timed out\n", style("!").yellow());
533                    return None;
534                }
535                std::thread::sleep(Duration::from_millis(crate::constants::AI_TOOL_POLL_MS));
536            }
537            Err(_) => return None,
538        }
539    };
540
541    if !status.success() {
542        println!("{} AI tool failed\n", style("!").yellow());
543        return None;
544    }
545
546    // Read stdout from the completed child process
547    let mut stdout_buf = String::new();
548    if let Some(mut pipe) = child.stdout.take() {
549        use std::io::Read;
550        let _ = pipe.read_to_string(&mut stdout_buf);
551    }
552    let stdout = stdout_buf;
553    let text = stdout.trim();
554
555    // Parse TITLE: and BODY: from output
556    match parse_ai_pr_output(text) {
557        Some((t, b)) => {
558            println!(
559                "{} AI generated PR description\n",
560                style("*").green().bold()
561            );
562            println!("  {} {}", style("Title:").dim(), t);
563            let preview = if b.len() > 100 {
564                format!("{}...", &b[..100])
565            } else {
566                b.clone()
567            };
568            println!("  {} {}\n", style("Body:").dim(), preview);
569            Some((t, b))
570        }
571        None => {
572            println!("{} Could not parse AI output\n", style("!").yellow());
573            None
574        }
575    }
576}
577
578/// Parse AI-generated PR output into (title, body).
579///
580/// Expects format:
581/// ```text
582/// TITLE: <title>
583/// BODY:
584/// <body lines>
585/// ```
586fn parse_ai_pr_output(text: &str) -> Option<(String, String)> {
587    let mut title: Option<String> = None;
588    let mut body: Option<String> = None;
589    let lines: Vec<&str> = text.lines().collect();
590
591    for (i, line) in lines.iter().enumerate() {
592        if let Some(t) = line.strip_prefix("TITLE:") {
593            title = Some(t.trim().to_string());
594        } else if line.starts_with("BODY:") {
595            if i + 1 < lines.len() {
596                body = Some(lines[i + 1..].join("\n").trim().to_string());
597            } else {
598                body = Some(String::new());
599            }
600            break;
601        }
602    }
603
604    match (title, body) {
605        (Some(t), Some(b)) if !t.is_empty() && !b.is_empty() => Some((t, b)),
606        _ => None,
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn test_parse_ai_pr_output_normal() {
616        let text = "TITLE: feat: add login page\nBODY:\n## Summary\nAdded login page\n\n## Test plan\n- Manual test";
617        let result = parse_ai_pr_output(text);
618        assert!(result.is_some());
619        let (title, body) = result.unwrap();
620        assert_eq!(title, "feat: add login page");
621        assert!(body.contains("## Summary"));
622        assert!(body.contains("## Test plan"));
623    }
624
625    #[test]
626    fn test_parse_ai_pr_output_empty() {
627        assert!(parse_ai_pr_output("").is_none());
628    }
629
630    #[test]
631    fn test_parse_ai_pr_output_title_only() {
632        let text = "TITLE: some title";
633        assert!(parse_ai_pr_output(text).is_none());
634    }
635
636    #[test]
637    fn test_parse_ai_pr_output_body_only() {
638        let text = "BODY:\nsome body text";
639        assert!(parse_ai_pr_output(text).is_none());
640    }
641
642    #[test]
643    fn test_parse_ai_pr_output_garbage() {
644        let text = "This is just some random AI output\nwithout proper format";
645        assert!(parse_ai_pr_output(text).is_none());
646    }
647
648    #[test]
649    fn test_parse_ai_pr_output_body_at_last_line() {
650        // BODY: is the last line — boundary check
651        let text = "TITLE: fix: something\nBODY:";
652        assert!(parse_ai_pr_output(text).is_none());
653    }
654
655    #[test]
656    fn test_parse_ai_pr_output_empty_title() {
657        let text = "TITLE:   \nBODY:\nsome body";
658        assert!(parse_ai_pr_output(text).is_none());
659    }
660
661    #[test]
662    fn test_parse_ai_pr_output_multiline_body() {
663        let text = "TITLE: chore: cleanup\nBODY:\nLine 1\nLine 2\nLine 3";
664        let result = parse_ai_pr_output(text).unwrap();
665        assert_eq!(result.0, "chore: cleanup");
666        assert_eq!(result.1, "Line 1\nLine 2\nLine 3");
667    }
668}