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