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