Skip to main content

git_cli/
executor.rs

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    // Check for injection patterns only OUTSIDE of quotes
250    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                // `:branch` is valid delete syntax — allow it
276            } 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    // Block commit with trailing bare hash references (LLM hallucination)
296    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                // Skip past the quoted message
301                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                // No PR number given — inject the last created PR number
489                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            // git checkout -b fails when branch already exists → retry without -b
541            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(&current_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(&current_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(&current_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        // "do not take a branch name" → git branch -r/-a was given an extra arg
735        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