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(&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 if !base_path.exists() {
328 return Err(CwError::WorktreeNotFound(messages::base_repo_not_found(
329 &base_path.display().to_string(),
330 )));
331 }
332
333 println!(
335 "{}",
336 style(format!(
337 "Merging {} into {}...",
338 feature_branch, base_branch
339 ))
340 .yellow()
341 );
342
343 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 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 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 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 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
427fn 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 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 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 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 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 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 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 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
570fn 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 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}