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::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 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 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 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 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 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
189pub 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 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 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 let (_fetch_ok, rebase_target) = git::fetch_and_rebase_target(&base_branch, repo, &cwd);
255
256 if interactive {
258 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 if !base_path.exists() {
336 return Err(CwError::WorktreeNotFound(messages::base_repo_not_found(
337 &base_path.display().to_string(),
338 )));
339 }
340
341 println!(
343 "{}",
344 style(format!(
345 "Merging {} into {}...",
346 feature_branch, base_branch
347 ))
348 .yellow()
349 );
350
351 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 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 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 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 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
435fn 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 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 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 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 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 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 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 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
578fn 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 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}