1use 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
21pub 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 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 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 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::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 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 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 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 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 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
204pub 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 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 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 let (_fetch_ok, rebase_target) = git::fetch_and_rebase_target(&base_branch, repo, &cwd);
270
271 if interactive {
273 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 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 if !base_path.exists() {
358 return Err(CwError::WorktreeNotFound(messages::base_repo_not_found(
359 &base_path.display().to_string(),
360 )));
361 }
362
363 println!(
365 "{}",
366 style(format!(
367 "Merging {} into {}...",
368 feature_branch, base_branch
369 ))
370 .yellow()
371 );
372
373 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 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 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 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 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
457fn 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 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 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 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 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 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 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 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
600fn 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 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}