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 commands.iter().any(|c| c.starts_with("gh ")) && !crate::doctor::gh_on_path() {
428 return Err(
429 "GitHub CLI (gh) not found on PATH. Install: https://cli.github.com — then run `gh auth login`"
430 .to_string(),
431 );
432 }
433
434 if !force && has_destructive_commands(parsed) {
435 eprintln!(
436 " {} Contains destructive commands. Use {} to override.",
437 "Blocked:".red().bold(),
438 "--force".bold()
439 );
440 return Ok(());
441 }
442
443 let has_creates = commands.iter().any(|c| c.starts_with("gh pr create"));
444 let has_merges = commands.iter().any(|c| c.starts_with("gh pr merge"));
445
446 let mut pr_number_map: HashMap<u32, u32> = HashMap::new();
447 let mut created_prs: Vec<u32> = Vec::new();
448
449 let predicted_merge_numbers: Vec<u32> = if has_creates && has_merges {
450 let open_prs = get_open_pr_numbers();
451 commands
452 .iter()
453 .filter_map(|c| extract_pr_merge_number(c))
454 .filter(|n| !open_prs.contains(n))
455 .collect()
456 } else {
457 Vec::new()
458 };
459
460 let mut failed_cmds: Vec<String> = Vec::new();
461 let mut branch_pushed = false;
462
463 for cmd_str in commands {
464 let mut actual_cmd = if cmd_str.starts_with("gh pr merge") {
465 if let Some(n) = extract_pr_merge_number(cmd_str) {
466 if let Some(&actual) = pr_number_map.get(&n) {
467 let replaced = cmd_str.replacen(&n.to_string(), &actual.to_string(), 1);
468 eprintln!(
469 " {} PR #{} → #{} (actual)",
470 "Remapped:".yellow().bold(),
471 n,
472 actual
473 );
474 replaced
475 } else {
476 cmd_str.to_string()
477 }
478 } else if !created_prs.is_empty() {
479 let last_pr = created_prs[created_prs.len() - 1];
481 let fixed = cmd_str.replacen("gh pr merge", &format!("gh pr merge {}", last_pr), 1);
482 eprintln!(
483 " {} Injecting PR #{} (last created)",
484 "Auto:".cyan().bold(),
485 last_pr
486 );
487 fixed
488 } else {
489 cmd_str.to_string()
490 }
491 } else {
492 cmd_str.to_string()
493 };
494
495 if actual_cmd.starts_with("gh pr create") {
496 actual_cmd = fix_gh_pr_create_head(&actual_cmd);
497 }
498
499 if actual_cmd.starts_with("gh pr create") && !branch_pushed {
500 if let Some(branch) = extract_head_branch(&actual_cmd) {
501 eprintln!(" {} Pushing branch `{}` to remote first...", "Auto:".cyan().bold(), branch);
502 let push_out = Command::new("git")
503 .args(["push", "origin", &branch])
504 .output();
505 if let Ok(o) = &push_out {
506 let out = String::from_utf8_lossy(&o.stdout);
507 let err = String::from_utf8_lossy(&o.stderr);
508 if !out.trim().is_empty() { println!("{out}"); }
509 if !err.trim().is_empty() { eprintln!("{err}"); }
510 }
511 branch_pushed = true;
512 }
513 }
514
515 println!(" {} {}", "Running:".cyan().bold(), actual_cmd);
516
517 let parts = shell_split(&actual_cmd);
518 if parts.is_empty() {
519 continue;
520 }
521
522 let (output, actual_cmd) = run_with_flag_retry(&actual_cmd)?;
523
524 let stdout = String::from_utf8_lossy(&output.stdout);
525 let stderr = String::from_utf8_lossy(&output.stderr);
526
527 if !stdout.trim().is_empty() {
528 println!("{stdout}");
529 }
530 if !stderr.trim().is_empty() {
531 eprintln!("{stderr}");
532 }
533
534 if !output.status.success() {
535 if actual_cmd.starts_with("git checkout -b ")
537 && (stderr.contains("already exists") || stderr.contains("already exist"))
538 {
539 let branch = actual_cmd.trim_start_matches("git checkout -b ").trim();
540 eprintln!(
541 " {} Branch already exists, switching to it instead...",
542 "Auto:".cyan().bold()
543 );
544 let retry = Command::new("git").args(["checkout", branch]).output();
545 if let Ok(o) = retry {
546 let out = String::from_utf8_lossy(&o.stdout);
547 let err = String::from_utf8_lossy(&o.stderr);
548 if !out.trim().is_empty() { println!("{out}"); }
549 if !err.trim().is_empty() { eprintln!("{err}"); }
550 if o.status.success() {
551 continue;
552 }
553 }
554 }
555
556 let is_gh_merge = actual_cmd.starts_with("gh pr merge");
557 let is_gh_create = actual_cmd.starts_with("gh pr create");
558
559 if is_gh_merge {
560 let stderr_str = stderr.to_string();
561 if stderr_str.contains("not allowed") || stderr_str.contains("not mergeable") {
562 if retry_merge_with_fallback(&actual_cmd).is_some() {
563 continue;
564 }
565 }
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 if is_gh_create {
577 eprintln!(
578 " {} `{}` failed (exit code {}). Continuing with remaining commands...",
579 "Skipped:".yellow().bold(),
580 actual_cmd,
581 output.status.code().unwrap_or(-1)
582 );
583 failed_cmds.push(actual_cmd);
584 continue;
585 }
586
587 let is_push_to_existing = actual_cmd.starts_with("git push")
588 && (stderr.contains("non-fast-forward") || stderr.contains("already exists"));
589 if is_push_to_existing {
590 eprintln!(
591 " {} Push failed but branch likely exists on remote. Continuing...",
592 "Note:".yellow().bold(),
593 );
594 continue;
595 }
596
597 let is_branch_delete = actual_cmd.contains("branch -D") || actual_cmd.contains("branch -d");
598 if is_branch_delete {
599 if stderr.contains("checked out") || stderr.contains("Cannot delete") {
600 eprintln!(" {} Switching to main before deleting...", "Auto:".cyan().bold());
601 let _ = Command::new("git").args(["checkout", "main"]).output();
602 let retry = Command::new(&parts[0]).args(&parts[1..]).output();
603 if let Ok(o) = retry {
604 if o.status.success() {
605 let out = String::from_utf8_lossy(&o.stdout);
606 if !out.trim().is_empty() { println!("{out}"); }
607 continue;
608 }
609 }
610 }
611 eprintln!(
612 " {} Branch may already be deleted. Continuing...",
613 "Note:".yellow().bold(),
614 );
615 continue;
616 }
617
618 let is_remote_delete = actual_cmd.contains("push origin --delete") || actual_cmd.contains("push origin :");
619 if is_remote_delete {
620 eprintln!(
621 " {} Branch may already be deleted. Continuing...",
622 "Note:".yellow().bold(),
623 );
624 continue;
625 }
626
627 return Err(format!(
628 "Command `{actual_cmd}` failed with exit code {}",
629 output.status.code().unwrap_or(-1)
630 ));
631 }
632
633 if cmd_str.starts_with("gh pr create") {
634 if let Some(pr_num) = parse_pr_number_from_output(&stdout) {
635 let idx = created_prs.len();
636 created_prs.push(pr_num);
637 if let Some(&predicted) = predicted_merge_numbers.get(idx) {
638 pr_number_map.insert(predicted, pr_num);
639 }
640 }
641 }
642 }
643
644 if has_creates || has_merges {
645 auto_merge_remaining_prs();
646 }
647
648 if failed_cmds.is_empty() {
649 println!(" {}", "All commands completed successfully.".green().bold());
650 } else {
651 eprintln!();
652 eprintln!(
653 " {} {} command(s) failed:",
654 "Summary:".yellow().bold(),
655 failed_cmds.len()
656 );
657 for cmd in &failed_cmds {
658 eprintln!(" {} {}", "✗".red(), cmd);
659 }
660 eprintln!();
661 return Err(format!("{} command(s) failed (see above)", failed_cmds.len()));
662 }
663
664 Ok(())
665}
666
667fn run_with_flag_retry(cmd: &str) -> Result<(std::process::Output, String), String> {
668 let mut current_cmd = cmd.to_string();
669 for _ in 0..3 {
670 let parts = shell_split(¤t_cmd);
671 if parts.is_empty() {
672 return Err("Empty command".to_string());
673 }
674 let output = Command::new(&parts[0])
675 .args(&parts[1..])
676 .output()
677 .map_err(|e| format!("Failed to run `{current_cmd}`: {e}"))?;
678
679 if output.status.success() {
680 return Ok((output, current_cmd));
681 }
682
683 let stderr = String::from_utf8_lossy(&output.stderr);
684 if let Some(bad_flag) = extract_bad_flag(&stderr) {
685 eprintln!(
686 " {} Removing hallucinated flag `{}`",
687 "Fix:".yellow().bold(),
688 bad_flag
689 );
690 current_cmd = remove_flag(¤t_cmd, &bad_flag);
691 println!(" {} {}", "Retrying:".cyan().bold(), current_cmd);
692 } else {
693 return Ok((output, current_cmd));
694 }
695 }
696 let parts = shell_split(¤t_cmd);
697 let output = Command::new(&parts[0])
698 .args(&parts[1..])
699 .output()
700 .map_err(|e| format!("Failed to run `{current_cmd}`: {e}"))?;
701 Ok((output, current_cmd))
702}
703
704pub fn extract_bad_flag(stderr: &str) -> Option<String> {
705 for line in stderr.lines() {
706 let line = line.trim();
707 if line.contains("unrecognized argument:") {
708 return line.split("unrecognized argument:").nth(1)
709 .map(|s| s.trim().to_string());
710 }
711 if line.contains("unknown option") {
712 if let Some(flag) = line.split("unknown option").nth(1) {
713 let cleaned = flag.trim()
714 .trim_start_matches(':')
715 .trim()
716 .trim_matches('\'')
717 .trim_matches('`')
718 .trim();
719 if !cleaned.is_empty() {
720 return Some(format!("--{}", cleaned));
721 }
722 }
723 }
724 if line.contains("unknown switch") {
725 if let Some(flag) = line.split('`').nth(1) {
726 return Some(flag.trim_matches('\'').to_string());
727 }
728 }
729 if line.contains("do not take a branch name") {
731 return Some("__strip_trailing_arg__".to_string());
732 }
733 }
734 None
735}
736
737pub fn remove_flag(cmd: &str, flag: &str) -> String {
738 if flag == "__strip_trailing_arg__" {
739 let parts = shell_split(cmd);
740 if parts.len() > 1 {
741 let without_last = &parts[..parts.len() - 1];
742 return without_last.iter()
743 .map(|p| if p.contains(' ') { format!("\"{}\"", p) } else { p.clone() })
744 .collect::<Vec<_>>()
745 .join(" ");
746 }
747 return cmd.to_string();
748 }
749 let flag_with_space = format!(" {}", flag);
750 let result = cmd.replace(&flag_with_space, "");
751 if result == cmd {
752 cmd.replace(flag, "").replace(" ", " ")
753 } else {
754 result
755 }
756}
757
758fn retry_merge_with_fallback(original_cmd: &str) -> Option<()> {
759 let strategies = ["--squash", "--rebase"];
760 for strategy in &strategies {
761 let retry_cmd = original_cmd
762 .replace("--merge", strategy);
763 eprintln!(
764 " {} Retrying with `{}`...",
765 "Fallback:".cyan().bold(),
766 strategy
767 );
768 let parts = shell_split(&retry_cmd);
769 if parts.is_empty() {
770 continue;
771 }
772 let output = Command::new(&parts[0])
773 .args(&parts[1..])
774 .output()
775 .ok()?;
776 let stdout = String::from_utf8_lossy(&output.stdout);
777 let stderr = String::from_utf8_lossy(&output.stderr);
778 if !stdout.trim().is_empty() {
779 println!("{stdout}");
780 }
781 if !stderr.trim().is_empty() {
782 eprintln!("{stderr}");
783 }
784 if output.status.success() {
785 eprintln!(
786 " {} Merged successfully with `{}`",
787 "OK:".green().bold(),
788 strategy
789 );
790 return Some(());
791 }
792 }
793 None
794}
795
796fn auto_merge_remaining_prs() {
797 let current_branch = Command::new("git")
798 .args(["rev-parse", "--abbrev-ref", "HEAD"])
799 .output()
800 .ok()
801 .filter(|o| o.status.success())
802 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
803
804 let Some(branch) = current_branch else { return };
805
806 let pr_output = Command::new("gh")
807 .args([
808 "pr", "list", "--state", "open", "--head", &branch,
809 "--json", "number", "--template", "{{range .}}{{.number}}\n{{end}}",
810 ])
811 .output()
812 .ok()
813 .filter(|o| o.status.success())
814 .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
815
816 let Some(prs) = pr_output else { return };
817 let pr_numbers: Vec<u32> = prs.lines().filter_map(|l| l.trim().parse().ok()).collect();
818
819 if pr_numbers.is_empty() {
820 return;
821 }
822
823 eprintln!(
824 "\n {} {} open PR(s) remaining for `{}`, merging...",
825 "Auto-merge:".cyan().bold(),
826 pr_numbers.len(),
827 branch
828 );
829
830 for (i, pr) in pr_numbers.iter().enumerate() {
831 let is_last = i == pr_numbers.len() - 1;
832 let mut args = vec!["pr".to_string(), "merge".to_string(), pr.to_string()];
833 args.push("--squash".to_string());
834 if is_last {
835 args.push("--delete-branch".to_string());
836 }
837 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
838 eprintln!(" {} gh pr merge {} --squash{}", "Running:".cyan().bold(), pr,
839 if is_last { " --delete-branch" } else { "" });
840
841 let output = Command::new("gh").args(&arg_refs).output();
842 if let Ok(o) = output {
843 let stdout = String::from_utf8_lossy(&o.stdout);
844 let stderr = String::from_utf8_lossy(&o.stderr);
845 if !stdout.trim().is_empty() { println!("{stdout}"); }
846 if !stderr.trim().is_empty() { eprintln!("{stderr}"); }
847 if !o.status.success() {
848 eprintln!(" {} PR #{} merge failed, trying --rebase...", "Fallback:".yellow().bold(), pr);
849 let pr_str = pr.to_string();
850 let mut retry_args = vec!["pr", "merge", &pr_str, "--rebase"];
851 if is_last { retry_args.push("--delete-branch"); }
852 let _ = Command::new("gh").args(&retry_args).output();
853 }
854 }
855 }
856}
857
858pub fn extract_head_branch(cmd: &str) -> Option<String> {
859 let parts = shell_split(cmd);
860 for (i, part) in parts.iter().enumerate() {
861 if part == "--head" {
862 return parts.get(i + 1).cloned();
863 }
864 }
865 None
866}
867
868pub fn fix_gh_pr_create_head(cmd: &str) -> String {
869 let Some(head) = extract_head_branch(cmd) else {
870 return cmd.to_string();
871 };
872 if branch_exists(&head) {
873 return cmd.to_string();
874 }
875 let Some(current) = current_branch() else {
876 return cmd.to_string();
877 };
878 eprintln!(
879 " {} Replacing hallucinated --head `{}` with current branch `{}`",
880 "Auto:".cyan().bold(),
881 head,
882 current
883 );
884 cmd.replacen(
885 &format!("--head {head}"),
886 &format!("--head {current}"),
887 1,
888 )
889}
890
891fn branch_exists(name: &str) -> bool {
892 Command::new("git")
893 .args(["show-ref", "--verify", "--quiet", &format!("refs/heads/{name}")])
894 .status()
895 .map(|s| s.success())
896 .unwrap_or(false)
897}
898
899fn current_branch() -> Option<String> {
900 Command::new("git")
901 .args(["branch", "--show-current"])
902 .output()
903 .ok()
904 .filter(|o| o.status.success())
905 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
906 .filter(|s| !s.is_empty())
907}
908
909pub fn extract_pr_merge_number(cmd: &str) -> Option<u32> {
910 let parts: Vec<&str> = cmd.split_whitespace().collect();
911 if parts.len() >= 4 && parts[0] == "gh" && parts[1] == "pr" && parts[2] == "merge" {
912 parts[3].parse().ok()
913 } else {
914 None
915 }
916}
917
918pub fn parse_pr_number_from_output(output: &str) -> Option<u32> {
919 for line in output.lines() {
920 let trimmed = line.trim();
921 if trimmed.contains("/pull/") {
922 return trimmed.rsplit('/').next()?.parse().ok();
923 }
924 }
925 None
926}
927
928fn get_open_pr_numbers() -> Vec<u32> {
929 Command::new("gh")
930 .args([
931 "pr", "list", "--state", "open", "--json", "number",
932 "--template", "{{range .}}{{.number}}\n{{end}}",
933 ])
934 .output()
935 .ok()
936 .filter(|o| o.status.success())
937 .map(|o| {
938 String::from_utf8_lossy(&o.stdout)
939 .lines()
940 .filter_map(|l| l.trim().parse().ok())
941 .collect()
942 })
943 .unwrap_or_default()
944}
945