1use colored::Colorize;
2use regex::Regex;
3use std::collections::HashMap;
4use std::process::Command;
5
6pub struct ParsedOutput {
7 pub lines: Vec<OutputLine>,
8}
9
10pub enum OutputLine {
11 Comment(String),
12 GitCommand(String),
13 Other(String),
14}
15
16const DESTRUCTIVE_PATTERNS: &[&str] = &[
17 "push --force",
18 "push -f ",
19 "reset --hard",
20 "clean -f",
21 "clean -df",
22 "clean -fd",
23 "clean -xf",
24 "branch -D ",
25];
26
27pub fn parse_response(response: &str) -> ParsedOutput {
28 let cleaned = sanitize_response(response);
29
30 let lines = cleaned
31 .lines()
32 .filter(|l| !l.trim().is_empty())
33 .map(|line| {
34 let trimmed = line.trim();
35 if trimmed.starts_with('#') {
36 OutputLine::Comment(trimmed.to_string())
37 } else if trimmed.starts_with("git ") || trimmed.starts_with("gh ") {
38 let sanitized = strip_inline_comment(&strip_pipe_suffix(trimmed));
39 if has_placeholder(&sanitized) {
40 OutputLine::Other(format!("[BLOCKED placeholder] {trimmed}"))
41 } else if is_cherry_pick_in_pr_context(&sanitized, &cleaned) {
42 OutputLine::Other(format!("[BLOCKED cherry-pick] {trimmed} — use `gh pr create` with different --base instead"))
43 } else if is_checkout_for_cherry_pick(&sanitized, &cleaned) {
44 OutputLine::Other(format!("[BLOCKED checkout] {trimmed} — not needed for PR workflow"))
45 } else if is_safe_command(&sanitized) {
46 OutputLine::GitCommand(sanitized)
47 } else {
48 OutputLine::Other(format!("[BLOCKED] {trimmed}"))
49 }
50 } else {
51 OutputLine::Other(trimmed.to_string())
52 }
53 })
54 .collect();
55
56 ParsedOutput { lines }
57}
58
59pub fn sanitize_response(response: &str) -> String {
60 let mut result = response.to_string();
61
62 result = result.replace("```bash", "");
63 result = result.replace("```shell", "");
64 result = result.replace("```sh", "");
65 result = result.replace("```", "");
66
67 let lines: Vec<String> = result
68 .lines()
69 .map(|line| {
70 let trimmed = line.trim();
71 if let Some(rest) = strip_numbering(trimmed) {
72 rest.to_string()
73 } else {
74 trimmed.to_string()
75 }
76 })
77 .collect();
78
79 let joined = join_multiline_commands(&lines).join("\n");
80 fix_case_globs(&joined)
81}
82
83pub fn fix_case_globs(cmd: &str) -> String {
84 if let Ok(re) = Regex::new(r"([0-9a-f]{7,40})\)") {
85 re.replace_all(cmd, "${1}*)").to_string()
86 } else {
87 cmd.to_string()
88 }
89}
90
91pub fn join_multiline_commands(lines: &[String]) -> Vec<String> {
92 let mut merged: Vec<String> = Vec::new();
93 let mut accumulator = String::new();
94 let mut in_single;
95 let mut in_double;
96
97 for line in lines {
98 if accumulator.is_empty() {
99 if line.trim().starts_with('#') || line.trim().is_empty() {
100 merged.push(line.clone());
101 continue;
102 }
103 accumulator = line.clone();
104 } else {
105 accumulator.push(' ');
106 accumulator.push_str(line.trim());
107 }
108
109 in_single = false;
110 in_double = false;
111 for ch in accumulator.chars() {
112 match ch {
113 '\'' if !in_double => in_single = !in_single,
114 '"' if !in_single => in_double = !in_double,
115 _ => {}
116 }
117 }
118
119 if !in_single && !in_double {
120 merged.push(accumulator.clone());
121 accumulator.clear();
122 }
123 }
124
125 if !accumulator.is_empty() {
126 merged.push(accumulator);
127 }
128
129 merged
130}
131
132pub fn strip_numbering(line: &str) -> Option<&str> {
133 let bytes = line.as_bytes();
134 let mut i = 0;
135
136 while i < bytes.len() && bytes[i].is_ascii_digit() {
137 i += 1;
138 }
139 if i == 0 {
140 return None;
141 }
142
143 if i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')' || bytes[i] == b':') {
144 let rest = &line[i + 1..];
145 return Some(rest.trim_start());
146 }
147
148 let lower = line.to_lowercase();
149 if lower.starts_with("step ") {
150 if let Some(colon_pos) = line.find(':') {
151 return Some(line[colon_pos + 1..].trim_start());
152 }
153 }
154
155 None
156}
157
158pub fn is_cherry_pick_in_pr_context(cmd: &str, full_response: &str) -> bool {
159 if !cmd.contains("cherry-pick") {
160 return false;
161 }
162 let lower = full_response.to_lowercase();
163 lower.contains("gh pr create") || lower.contains("gh pr merge")
164}
165
166pub fn is_checkout_for_cherry_pick(cmd: &str, full_response: &str) -> bool {
167 if !cmd.starts_with("git checkout ") {
168 return false;
169 }
170 let lower = full_response.to_lowercase();
171 lower.contains("cherry-pick") && (lower.contains("gh pr create") || lower.contains("gh pr merge"))
172}
173
174pub fn strip_inline_comment(cmd: &str) -> String {
175 if let Some(pos) = find_unquoted_hash(cmd) {
176 cmd[..pos].trim().to_string()
177 } else {
178 cmd.to_string()
179 }
180}
181
182pub fn find_unquoted_hash(cmd: &str) -> Option<usize> {
183 let mut in_single = false;
184 let mut in_double = false;
185 let chars: Vec<char> = cmd.chars().collect();
186 for (i, &ch) in chars.iter().enumerate() {
187 match ch {
188 '\'' if !in_double => in_single = !in_single,
189 '"' if !in_single => in_double = !in_double,
190 '#' if !in_single && !in_double && i > 0 => {
191 let next = chars.get(i + 1);
192 let prev = chars.get(i - 1);
193 if prev == Some(&' ') && !next.map_or(false, |c| c.is_ascii_digit()) {
194 return Some(i);
195 }
196 }
197 _ => {}
198 }
199 }
200 None
201}
202
203pub fn has_placeholder(cmd: &str) -> bool {
204 let unquoted = strip_quoted_sections(cmd);
205 unquoted.contains('<') && unquoted.contains('>')
206}
207
208pub fn strip_pipe_suffix(cmd: &str) -> String {
209 let unquoted = strip_quoted_sections(cmd);
210 if unquoted.contains('|') {
211 let original_pos = find_unquoted_pipe(cmd);
212 if let Some(pos) = original_pos {
213 let stripped = cmd[..pos].trim().to_string();
214 let pipe_part = cmd[pos..].trim();
215 eprintln!(
216 " {} Stripped `{}` (pipes not supported)",
217 "Note:".yellow().bold(),
218 pipe_part
219 );
220 return stripped;
221 }
222 }
223 cmd.to_string()
224}
225
226pub fn find_unquoted_pipe(cmd: &str) -> Option<usize> {
227 let mut in_single = false;
228 let mut in_double = false;
229 for (i, ch) in cmd.chars().enumerate() {
230 match ch {
231 '\'' if !in_double => in_single = !in_single,
232 '"' if !in_single => in_double = !in_double,
233 '|' if !in_single && !in_double => return Some(i),
234 _ => {}
235 }
236 }
237 None
238}
239
240pub fn is_safe_command(cmd: &str) -> bool {
241 if !cmd.starts_with("git ") && !cmd.starts_with("gh ") {
242 return false;
243 }
244
245 if cmd.starts_with("gh ") {
246 return true;
247 }
248
249 let unquoted = strip_quoted_sections(cmd);
251 let injection_patterns = ["&&", "||", ";", "$(", "`", "|"];
252 for pat in &injection_patterns {
253 if unquoted.contains(pat) {
254 return false;
255 }
256 }
257
258 if let Some(n) = extract_head_offset(cmd) {
259 let commit_count = get_commit_count();
260 if n > commit_count {
261 eprintln!(
262 " {} HEAD~{} but repo only has {} commit(s). Skipping.",
263 "Warning:".yellow().bold(),
264 n,
265 commit_count
266 );
267 return false;
268 }
269 }
270
271 if cmd.contains("git push") && cmd.contains(':') {
272 let parts: Vec<&str> = cmd.split_whitespace().collect();
273 if let Some(refspec) = parts.last() {
274 if refspec.starts_with(':') {
275 } else if refspec.contains(':') && !refspec.contains("refs/tags/") {
277 eprintln!(
278 " {} Blocked push with refspec `{}`. Use `git push origin <branch>` and `gh pr create` instead.",
279 "Warning:".yellow().bold(),
280 refspec
281 );
282 return false;
283 }
284 }
285 }
286
287 if cmd.contains("rebase -i") || cmd.contains("rebase --interactive") {
288 eprintln!(
289 " {} Blocked `rebase -i` (no interactive editor available). Use `git reset --soft` or `git filter-branch`.",
290 "Warning:".yellow().bold(),
291 );
292 return false;
293 }
294
295 if cmd.contains("git commit") {
297 if let Ok(re) = Regex::new(r"[0-9a-f]{7,}\^?\s*$") {
298 let after_message = if let Some(pos) = cmd.find("-m ") {
299 let rest = &cmd[pos + 3..];
300 if rest.starts_with('"') {
302 rest[1..].find('"').map(|end| &rest[end + 2..])
303 } else if rest.starts_with('\'') {
304 rest[1..].find('\'').map(|end| &rest[end + 2..])
305 } else {
306 rest.split_whitespace().nth(1).map(|s| s)
307 }
308 } else {
309 None
310 };
311
312 if let Some(trailing) = after_message {
313 let trailing = trailing.trim();
314 if !trailing.is_empty() && re.is_match(trailing) {
315 eprintln!(
316 " {} Malformed commit command with trailing hash. Skipping.",
317 "Warning:".yellow().bold(),
318 );
319 return false;
320 }
321 }
322 }
323 }
324
325 true
326}
327
328pub fn strip_quoted_sections(cmd: &str) -> String {
329 let mut result = String::new();
330 let mut in_single = false;
331 let mut in_double = false;
332
333 for ch in cmd.chars() {
334 match ch {
335 '\'' if !in_double => {
336 in_single = !in_single;
337 }
338 '"' if !in_single => {
339 in_double = !in_double;
340 }
341 _ if !in_single && !in_double => {
342 result.push(ch);
343 }
344 _ => {}
345 }
346 }
347 result
348}
349
350pub fn extract_head_offset(cmd: &str) -> Option<u32> {
351 Regex::new(r"HEAD~(\d+)")
352 .ok()?
353 .captures(cmd)
354 .and_then(|c| c.get(1))
355 .and_then(|m| m.as_str().parse().ok())
356}
357
358fn get_commit_count() -> u32 {
359 Command::new("git")
360 .args(["rev-list", "--count", "HEAD"])
361 .output()
362 .ok()
363 .filter(|o| o.status.success())
364 .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
365 .unwrap_or(0)
366}
367
368pub fn shell_split(cmd: &str) -> Vec<String> {
369 let mut parts = Vec::new();
370 let mut current = String::new();
371 let mut in_single_quote = false;
372 let mut in_double_quote = false;
373
374 for ch in cmd.chars() {
375 match ch {
376 '\'' if !in_double_quote => {
377 in_single_quote = !in_single_quote;
378 }
379 '"' if !in_single_quote => {
380 in_double_quote = !in_double_quote;
381 }
382 ' ' if !in_single_quote && !in_double_quote => {
383 if !current.is_empty() {
384 parts.push(current.clone());
385 current.clear();
386 }
387 }
388 _ => {
389 current.push(ch);
390 }
391 }
392 }
393 if !current.is_empty() {
394 parts.push(current);
395 }
396
397 parts
398}
399
400pub fn has_destructive_commands(parsed: &ParsedOutput) -> bool {
401 parsed.lines.iter().any(|line| {
402 if let OutputLine::GitCommand(cmd) = line {
403 DESTRUCTIVE_PATTERNS.iter().any(|p| cmd.contains(p))
404 } else {
405 false
406 }
407 })
408}
409
410pub fn display(parsed: &ParsedOutput) {
411 println!();
412 for line in &parsed.lines {
413 match line {
414 OutputLine::Comment(c) => println!(" {}", c.dimmed()),
415 OutputLine::GitCommand(cmd) => {
416 if DESTRUCTIVE_PATTERNS.iter().any(|p| cmd.contains(p)) {
417 println!(" {} {}", "⚠".yellow(), cmd.red().bold());
418 } else {
419 println!(" {}", cmd.green().bold());
420 }
421 }
422 OutputLine::Other(text) => println!(" {}", text.yellow()),
423 }
424 }
425 println!();
426}
427
428pub fn execute_commands(parsed: &ParsedOutput, force: bool) -> Result<(), String> {
429 let commands: Vec<&str> = parsed
430 .lines
431 .iter()
432 .filter_map(|l| match l {
433 OutputLine::GitCommand(cmd) => Some(cmd.as_str()),
434 _ => None,
435 })
436 .collect();
437
438 if commands.is_empty() {
439 println!("{}", "No git commands found to execute.".yellow());
440 return Ok(());
441 }
442
443 if !force && has_destructive_commands(parsed) {
444 eprintln!(
445 " {} Contains destructive commands. Use {} to override.",
446 "Blocked:".red().bold(),
447 "--force".bold()
448 );
449 return Ok(());
450 }
451
452 let has_creates = commands.iter().any(|c| c.starts_with("gh pr create"));
453 let has_merges = commands.iter().any(|c| c.starts_with("gh pr merge"));
454
455 let mut pr_number_map: HashMap<u32, u32> = HashMap::new();
456 let mut created_prs: Vec<u32> = Vec::new();
457
458 let predicted_merge_numbers: Vec<u32> = if has_creates && has_merges {
459 let open_prs = get_open_pr_numbers();
460 commands
461 .iter()
462 .filter_map(|c| extract_pr_merge_number(c))
463 .filter(|n| !open_prs.contains(n))
464 .collect()
465 } else {
466 Vec::new()
467 };
468
469 let mut failed_cmds: Vec<String> = Vec::new();
470 let mut branch_pushed = false;
471
472 for cmd_str in commands {
473 let actual_cmd = if cmd_str.starts_with("gh pr merge") {
474 if let Some(n) = extract_pr_merge_number(cmd_str) {
475 if let Some(&actual) = pr_number_map.get(&n) {
476 let replaced = cmd_str.replacen(&n.to_string(), &actual.to_string(), 1);
477 eprintln!(
478 " {} PR #{} → #{} (actual)",
479 "Remapped:".yellow().bold(),
480 n,
481 actual
482 );
483 replaced
484 } else {
485 cmd_str.to_string()
486 }
487 } else if !created_prs.is_empty() {
488 let last_pr = created_prs[created_prs.len() - 1];
490 let fixed = cmd_str.replacen("gh pr merge", &format!("gh pr merge {}", last_pr), 1);
491 eprintln!(
492 " {} Injecting PR #{} (last created)",
493 "Auto:".cyan().bold(),
494 last_pr
495 );
496 fixed
497 } else {
498 cmd_str.to_string()
499 }
500 } else {
501 cmd_str.to_string()
502 };
503
504 if actual_cmd.starts_with("gh pr create") && !branch_pushed {
505 if let Some(branch) = extract_head_branch(&actual_cmd) {
506 eprintln!(" {} Pushing branch `{}` to remote first...", "Auto:".cyan().bold(), branch);
507 let push_out = Command::new("git")
508 .args(["push", "origin", &branch])
509 .output();
510 if let Ok(o) = &push_out {
511 let out = String::from_utf8_lossy(&o.stdout);
512 let err = String::from_utf8_lossy(&o.stderr);
513 if !out.trim().is_empty() { println!("{out}"); }
514 if !err.trim().is_empty() { eprintln!("{err}"); }
515 }
516 branch_pushed = true;
517 }
518 }
519
520 println!(" {} {}", "Running:".cyan().bold(), actual_cmd);
521
522 let parts = shell_split(&actual_cmd);
523 if parts.is_empty() {
524 continue;
525 }
526
527 let (output, actual_cmd) = run_with_flag_retry(&actual_cmd)?;
528
529 let stdout = String::from_utf8_lossy(&output.stdout);
530 let stderr = String::from_utf8_lossy(&output.stderr);
531
532 if !stdout.trim().is_empty() {
533 println!("{stdout}");
534 }
535 if !stderr.trim().is_empty() {
536 eprintln!("{stderr}");
537 }
538
539 if !output.status.success() {
540 if actual_cmd.starts_with("git checkout -b ")
542 && (stderr.contains("already exists") || stderr.contains("already exist"))
543 {
544 let branch = actual_cmd.trim_start_matches("git checkout -b ").trim();
545 eprintln!(
546 " {} Branch already exists, switching to it instead...",
547 "Auto:".cyan().bold()
548 );
549 let retry = Command::new("git").args(["checkout", branch]).output();
550 if let Ok(o) = retry {
551 let out = String::from_utf8_lossy(&o.stdout);
552 let err = String::from_utf8_lossy(&o.stderr);
553 if !out.trim().is_empty() { println!("{out}"); }
554 if !err.trim().is_empty() { eprintln!("{err}"); }
555 if o.status.success() {
556 continue;
557 }
558 }
559 }
560
561 let is_gh_merge = actual_cmd.starts_with("gh pr merge");
562 let is_gh_create = actual_cmd.starts_with("gh pr create");
563
564 if is_gh_merge {
565 let stderr_str = stderr.to_string();
566 if stderr_str.contains("not allowed") || stderr_str.contains("not mergeable") {
567 if retry_merge_with_fallback(&actual_cmd).is_some() {
568 continue;
569 }
570 }
571 eprintln!(
572 " {} `{}` failed (exit code {}). Continuing with remaining commands...",
573 "Skipped:".yellow().bold(),
574 actual_cmd,
575 output.status.code().unwrap_or(-1)
576 );
577 failed_cmds.push(actual_cmd);
578 continue;
579 }
580
581 if is_gh_create {
582 eprintln!(
583 " {} `{}` failed (exit code {}). Continuing with remaining commands...",
584 "Skipped:".yellow().bold(),
585 actual_cmd,
586 output.status.code().unwrap_or(-1)
587 );
588 failed_cmds.push(actual_cmd);
589 continue;
590 }
591
592 let is_push_to_existing = actual_cmd.starts_with("git push")
593 && (stderr.contains("non-fast-forward") || stderr.contains("already exists"));
594 if is_push_to_existing {
595 eprintln!(
596 " {} Push failed but branch likely exists on remote. Continuing...",
597 "Note:".yellow().bold(),
598 );
599 continue;
600 }
601
602 let is_branch_delete = actual_cmd.contains("branch -D") || actual_cmd.contains("branch -d");
603 if is_branch_delete {
604 if stderr.contains("checked out") || stderr.contains("Cannot delete") {
605 eprintln!(" {} Switching to main before deleting...", "Auto:".cyan().bold());
606 let _ = Command::new("git").args(["checkout", "main"]).output();
607 let retry = Command::new(&parts[0]).args(&parts[1..]).output();
608 if let Ok(o) = retry {
609 if o.status.success() {
610 let out = String::from_utf8_lossy(&o.stdout);
611 if !out.trim().is_empty() { println!("{out}"); }
612 continue;
613 }
614 }
615 }
616 eprintln!(
617 " {} Branch may already be deleted. Continuing...",
618 "Note:".yellow().bold(),
619 );
620 continue;
621 }
622
623 let is_remote_delete = actual_cmd.contains("push origin --delete") || actual_cmd.contains("push origin :");
624 if is_remote_delete {
625 eprintln!(
626 " {} Branch may already be deleted. Continuing...",
627 "Note:".yellow().bold(),
628 );
629 continue;
630 }
631
632 return Err(format!(
633 "Command `{actual_cmd}` failed with exit code {}",
634 output.status.code().unwrap_or(-1)
635 ));
636 }
637
638 if cmd_str.starts_with("gh pr create") {
639 if let Some(pr_num) = parse_pr_number_from_output(&stdout) {
640 let idx = created_prs.len();
641 created_prs.push(pr_num);
642 if let Some(&predicted) = predicted_merge_numbers.get(idx) {
643 pr_number_map.insert(predicted, pr_num);
644 }
645 }
646 }
647 }
648
649 if has_creates || has_merges {
650 auto_merge_remaining_prs();
651 }
652
653 if failed_cmds.is_empty() {
654 println!(" {}", "All commands completed successfully.".green().bold());
655 } else {
656 eprintln!();
657 eprintln!(
658 " {} {} command(s) failed:",
659 "Summary:".yellow().bold(),
660 failed_cmds.len()
661 );
662 for cmd in &failed_cmds {
663 eprintln!(" {} {}", "✗".red(), cmd);
664 }
665 eprintln!();
666 return Err(format!("{} command(s) failed (see above)", failed_cmds.len()));
667 }
668
669 Ok(())
670}
671
672fn run_with_flag_retry(cmd: &str) -> Result<(std::process::Output, String), String> {
673 let mut current_cmd = cmd.to_string();
674 for _ in 0..3 {
675 let parts = shell_split(¤t_cmd);
676 if parts.is_empty() {
677 return Err("Empty command".to_string());
678 }
679 let output = Command::new(&parts[0])
680 .args(&parts[1..])
681 .output()
682 .map_err(|e| format!("Failed to run `{current_cmd}`: {e}"))?;
683
684 if output.status.success() {
685 return Ok((output, current_cmd));
686 }
687
688 let stderr = String::from_utf8_lossy(&output.stderr);
689 if let Some(bad_flag) = extract_bad_flag(&stderr) {
690 eprintln!(
691 " {} Removing hallucinated flag `{}`",
692 "Fix:".yellow().bold(),
693 bad_flag
694 );
695 current_cmd = remove_flag(¤t_cmd, &bad_flag);
696 println!(" {} {}", "Retrying:".cyan().bold(), current_cmd);
697 } else {
698 return Ok((output, current_cmd));
699 }
700 }
701 let parts = shell_split(¤t_cmd);
702 let output = Command::new(&parts[0])
703 .args(&parts[1..])
704 .output()
705 .map_err(|e| format!("Failed to run `{current_cmd}`: {e}"))?;
706 Ok((output, current_cmd))
707}
708
709pub fn extract_bad_flag(stderr: &str) -> Option<String> {
710 for line in stderr.lines() {
711 let line = line.trim();
712 if line.contains("unrecognized argument:") {
713 return line.split("unrecognized argument:").nth(1)
714 .map(|s| s.trim().to_string());
715 }
716 if line.contains("unknown option") {
717 if let Some(flag) = line.split("unknown option").nth(1) {
718 let cleaned = flag.trim()
719 .trim_start_matches(':')
720 .trim()
721 .trim_matches('\'')
722 .trim_matches('`')
723 .trim();
724 if !cleaned.is_empty() {
725 return Some(format!("--{}", cleaned));
726 }
727 }
728 }
729 if line.contains("unknown switch") {
730 if let Some(flag) = line.split('`').nth(1) {
731 return Some(flag.trim_matches('\'').to_string());
732 }
733 }
734 if line.contains("do not take a branch name") {
736 return Some("__strip_trailing_arg__".to_string());
737 }
738 }
739 None
740}
741
742pub fn remove_flag(cmd: &str, flag: &str) -> String {
743 if flag == "__strip_trailing_arg__" {
744 let parts = shell_split(cmd);
745 if parts.len() > 1 {
746 let without_last = &parts[..parts.len() - 1];
747 return without_last.iter()
748 .map(|p| if p.contains(' ') { format!("\"{}\"", p) } else { p.clone() })
749 .collect::<Vec<_>>()
750 .join(" ");
751 }
752 return cmd.to_string();
753 }
754 let flag_with_space = format!(" {}", flag);
755 let result = cmd.replace(&flag_with_space, "");
756 if result == cmd {
757 cmd.replace(flag, "").replace(" ", " ")
758 } else {
759 result
760 }
761}
762
763fn retry_merge_with_fallback(original_cmd: &str) -> Option<()> {
764 let strategies = ["--squash", "--rebase"];
765 for strategy in &strategies {
766 let retry_cmd = original_cmd
767 .replace("--merge", strategy);
768 eprintln!(
769 " {} Retrying with `{}`...",
770 "Fallback:".cyan().bold(),
771 strategy
772 );
773 let parts = shell_split(&retry_cmd);
774 if parts.is_empty() {
775 continue;
776 }
777 let output = Command::new(&parts[0])
778 .args(&parts[1..])
779 .output()
780 .ok()?;
781 let stdout = String::from_utf8_lossy(&output.stdout);
782 let stderr = String::from_utf8_lossy(&output.stderr);
783 if !stdout.trim().is_empty() {
784 println!("{stdout}");
785 }
786 if !stderr.trim().is_empty() {
787 eprintln!("{stderr}");
788 }
789 if output.status.success() {
790 eprintln!(
791 " {} Merged successfully with `{}`",
792 "OK:".green().bold(),
793 strategy
794 );
795 return Some(());
796 }
797 }
798 None
799}
800
801fn auto_merge_remaining_prs() {
802 let current_branch = Command::new("git")
803 .args(["rev-parse", "--abbrev-ref", "HEAD"])
804 .output()
805 .ok()
806 .filter(|o| o.status.success())
807 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
808
809 let Some(branch) = current_branch else { return };
810
811 let pr_output = Command::new("gh")
812 .args([
813 "pr", "list", "--state", "open", "--head", &branch,
814 "--json", "number", "--template", "{{range .}}{{.number}}\n{{end}}",
815 ])
816 .output()
817 .ok()
818 .filter(|o| o.status.success())
819 .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
820
821 let Some(prs) = pr_output else { return };
822 let pr_numbers: Vec<u32> = prs.lines().filter_map(|l| l.trim().parse().ok()).collect();
823
824 if pr_numbers.is_empty() {
825 return;
826 }
827
828 eprintln!(
829 "\n {} {} open PR(s) remaining for `{}`, merging...",
830 "Auto-merge:".cyan().bold(),
831 pr_numbers.len(),
832 branch
833 );
834
835 for (i, pr) in pr_numbers.iter().enumerate() {
836 let is_last = i == pr_numbers.len() - 1;
837 let mut args = vec!["pr".to_string(), "merge".to_string(), pr.to_string()];
838 args.push("--squash".to_string());
839 if is_last {
840 args.push("--delete-branch".to_string());
841 }
842 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
843 eprintln!(" {} gh pr merge {} --squash{}", "Running:".cyan().bold(), pr,
844 if is_last { " --delete-branch" } else { "" });
845
846 let output = Command::new("gh").args(&arg_refs).output();
847 if let Ok(o) = output {
848 let stdout = String::from_utf8_lossy(&o.stdout);
849 let stderr = String::from_utf8_lossy(&o.stderr);
850 if !stdout.trim().is_empty() { println!("{stdout}"); }
851 if !stderr.trim().is_empty() { eprintln!("{stderr}"); }
852 if !o.status.success() {
853 eprintln!(" {} PR #{} merge failed, trying --rebase...", "Fallback:".yellow().bold(), pr);
854 let pr_str = pr.to_string();
855 let mut retry_args = vec!["pr", "merge", &pr_str, "--rebase"];
856 if is_last { retry_args.push("--delete-branch"); }
857 let _ = Command::new("gh").args(&retry_args).output();
858 }
859 }
860 }
861}
862
863pub fn extract_head_branch(cmd: &str) -> Option<String> {
864 let parts = shell_split(cmd);
865 for (i, part) in parts.iter().enumerate() {
866 if part == "--head" {
867 return parts.get(i + 1).cloned();
868 }
869 }
870 None
871}
872
873pub fn extract_pr_merge_number(cmd: &str) -> Option<u32> {
874 let parts: Vec<&str> = cmd.split_whitespace().collect();
875 if parts.len() >= 4 && parts[0] == "gh" && parts[1] == "pr" && parts[2] == "merge" {
876 parts[3].parse().ok()
877 } else {
878 None
879 }
880}
881
882pub fn parse_pr_number_from_output(output: &str) -> Option<u32> {
883 for line in output.lines() {
884 let trimmed = line.trim();
885 if trimmed.contains("/pull/") {
886 return trimmed.rsplit('/').next()?.parse().ok();
887 }
888 }
889 None
890}
891
892fn get_open_pr_numbers() -> Vec<u32> {
893 Command::new("gh")
894 .args([
895 "pr", "list", "--state", "open", "--json", "number",
896 "--template", "{{range .}}{{.number}}\n{{end}}",
897 ])
898 .output()
899 .ok()
900 .filter(|o| o.status.success())
901 .map(|o| {
902 String::from_utf8_lossy(&o.stdout)
903 .lines()
904 .filter_map(|l| l.trim().parse().ok())
905 .collect()
906 })
907 .unwrap_or_default()
908}
909