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 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 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                // No PR number given — inject the last created PR number
480                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") && !branch_pushed {
496            if let Some(branch) = extract_head_branch(&actual_cmd) {
497                eprintln!("  {} Pushing branch `{}` to remote first...", "Auto:".cyan().bold(), branch);
498                let push_out = Command::new("git")
499                    .args(["push", "origin", &branch])
500                    .output();
501                if let Ok(o) = &push_out {
502                    let out = String::from_utf8_lossy(&o.stdout);
503                    let err = String::from_utf8_lossy(&o.stderr);
504                    if !out.trim().is_empty() { println!("{out}"); }
505                    if !err.trim().is_empty() { eprintln!("{err}"); }
506                }
507                branch_pushed = true;
508            }
509        }
510
511        println!("  {} {}", "Running:".cyan().bold(), actual_cmd);
512
513        let parts = shell_split(&actual_cmd);
514        if parts.is_empty() {
515            continue;
516        }
517
518        let (output, actual_cmd) = run_with_flag_retry(&actual_cmd)?;
519
520        let stdout = String::from_utf8_lossy(&output.stdout);
521        let stderr = String::from_utf8_lossy(&output.stderr);
522
523        if !stdout.trim().is_empty() {
524            println!("{stdout}");
525        }
526        if !stderr.trim().is_empty() {
527            eprintln!("{stderr}");
528        }
529
530        if !output.status.success() {
531            // git checkout -b fails when branch already exists → retry without -b
532            if actual_cmd.starts_with("git checkout -b ")
533                && (stderr.contains("already exists") || stderr.contains("already exist"))
534            {
535                let branch = actual_cmd.trim_start_matches("git checkout -b ").trim();
536                eprintln!(
537                    "  {} Branch already exists, switching to it instead...",
538                    "Auto:".cyan().bold()
539                );
540                let retry = Command::new("git").args(["checkout", branch]).output();
541                if let Ok(o) = retry {
542                    let out = String::from_utf8_lossy(&o.stdout);
543                    let err = String::from_utf8_lossy(&o.stderr);
544                    if !out.trim().is_empty() { println!("{out}"); }
545                    if !err.trim().is_empty() { eprintln!("{err}"); }
546                    if o.status.success() {
547                        continue;
548                    }
549                }
550            }
551
552            let is_gh_merge = actual_cmd.starts_with("gh pr merge");
553            let is_gh_create = actual_cmd.starts_with("gh pr create");
554
555            if is_gh_merge {
556                let stderr_str = stderr.to_string();
557                if stderr_str.contains("not allowed") || stderr_str.contains("not mergeable") {
558                    if retry_merge_with_fallback(&actual_cmd).is_some() {
559                        continue;
560                    }
561                }
562                eprintln!(
563                    "  {} `{}` failed (exit code {}). Continuing with remaining commands...",
564                    "Skipped:".yellow().bold(),
565                    actual_cmd,
566                    output.status.code().unwrap_or(-1)
567                );
568                failed_cmds.push(actual_cmd);
569                continue;
570            }
571
572            if is_gh_create {
573                eprintln!(
574                    "  {} `{}` failed (exit code {}). Continuing with remaining commands...",
575                    "Skipped:".yellow().bold(),
576                    actual_cmd,
577                    output.status.code().unwrap_or(-1)
578                );
579                failed_cmds.push(actual_cmd);
580                continue;
581            }
582
583            let is_push_to_existing = actual_cmd.starts_with("git push")
584                && (stderr.contains("non-fast-forward") || stderr.contains("already exists"));
585            if is_push_to_existing {
586                eprintln!(
587                    "  {} Push failed but branch likely exists on remote. Continuing...",
588                    "Note:".yellow().bold(),
589                );
590                continue;
591            }
592
593            let is_branch_delete = actual_cmd.contains("branch -D") || actual_cmd.contains("branch -d");
594            if is_branch_delete {
595                if stderr.contains("checked out") || stderr.contains("Cannot delete") {
596                    eprintln!("  {} Switching to main before deleting...", "Auto:".cyan().bold());
597                    let _ = Command::new("git").args(["checkout", "main"]).output();
598                    let retry = Command::new(&parts[0]).args(&parts[1..]).output();
599                    if let Ok(o) = retry {
600                        if o.status.success() {
601                            let out = String::from_utf8_lossy(&o.stdout);
602                            if !out.trim().is_empty() { println!("{out}"); }
603                            continue;
604                        }
605                    }
606                }
607                eprintln!(
608                    "  {} Branch may already be deleted. Continuing...",
609                    "Note:".yellow().bold(),
610                );
611                continue;
612            }
613
614            let is_remote_delete = actual_cmd.contains("push origin --delete") || actual_cmd.contains("push origin :");
615            if is_remote_delete {
616                eprintln!(
617                    "  {} Branch may already be deleted. Continuing...",
618                    "Note:".yellow().bold(),
619                );
620                continue;
621            }
622
623            return Err(format!(
624                "Command `{actual_cmd}` failed with exit code {}",
625                output.status.code().unwrap_or(-1)
626            ));
627        }
628
629        if cmd_str.starts_with("gh pr create") {
630            if let Some(pr_num) = parse_pr_number_from_output(&stdout) {
631                let idx = created_prs.len();
632                created_prs.push(pr_num);
633                if let Some(&predicted) = predicted_merge_numbers.get(idx) {
634                    pr_number_map.insert(predicted, pr_num);
635                }
636            }
637        }
638    }
639
640    if has_creates || has_merges {
641        auto_merge_remaining_prs();
642    }
643
644    if failed_cmds.is_empty() {
645        println!("  {}", "All commands completed successfully.".green().bold());
646    } else {
647        eprintln!();
648        eprintln!(
649            "  {} {} command(s) failed:",
650            "Summary:".yellow().bold(),
651            failed_cmds.len()
652        );
653        for cmd in &failed_cmds {
654            eprintln!("    {} {}", "✗".red(), cmd);
655        }
656        eprintln!();
657        return Err(format!("{} command(s) failed (see above)", failed_cmds.len()));
658    }
659
660    Ok(())
661}
662
663fn run_with_flag_retry(cmd: &str) -> Result<(std::process::Output, String), String> {
664    let mut current_cmd = cmd.to_string();
665    for _ in 0..3 {
666        let parts = shell_split(&current_cmd);
667        if parts.is_empty() {
668            return Err("Empty command".to_string());
669        }
670        let output = Command::new(&parts[0])
671            .args(&parts[1..])
672            .output()
673            .map_err(|e| format!("Failed to run `{current_cmd}`: {e}"))?;
674
675        if output.status.success() {
676            return Ok((output, current_cmd));
677        }
678
679        let stderr = String::from_utf8_lossy(&output.stderr);
680        if let Some(bad_flag) = extract_bad_flag(&stderr) {
681            eprintln!(
682                "  {} Removing hallucinated flag `{}`",
683                "Fix:".yellow().bold(),
684                bad_flag
685            );
686            current_cmd = remove_flag(&current_cmd, &bad_flag);
687            println!("  {} {}", "Retrying:".cyan().bold(), current_cmd);
688        } else {
689            return Ok((output, current_cmd));
690        }
691    }
692    let parts = shell_split(&current_cmd);
693    let output = Command::new(&parts[0])
694        .args(&parts[1..])
695        .output()
696        .map_err(|e| format!("Failed to run `{current_cmd}`: {e}"))?;
697    Ok((output, current_cmd))
698}
699
700pub fn extract_bad_flag(stderr: &str) -> Option<String> {
701    for line in stderr.lines() {
702        let line = line.trim();
703        if line.contains("unrecognized argument:") {
704            return line.split("unrecognized argument:").nth(1)
705                .map(|s| s.trim().to_string());
706        }
707        if line.contains("unknown option") {
708            if let Some(flag) = line.split("unknown option").nth(1) {
709                let cleaned = flag.trim()
710                    .trim_start_matches(':')
711                    .trim()
712                    .trim_matches('\'')
713                    .trim_matches('`')
714                    .trim();
715                if !cleaned.is_empty() {
716                    return Some(format!("--{}", cleaned));
717                }
718            }
719        }
720        if line.contains("unknown switch") {
721            if let Some(flag) = line.split('`').nth(1) {
722                return Some(flag.trim_matches('\'').to_string());
723            }
724        }
725        // "do not take a branch name" → git branch -r/-a was given an extra arg
726        if line.contains("do not take a branch name") {
727            return Some("__strip_trailing_arg__".to_string());
728        }
729    }
730    None
731}
732
733pub fn remove_flag(cmd: &str, flag: &str) -> String {
734    if flag == "__strip_trailing_arg__" {
735        let parts = shell_split(cmd);
736        if parts.len() > 1 {
737            let without_last = &parts[..parts.len() - 1];
738            return without_last.iter()
739                .map(|p| if p.contains(' ') { format!("\"{}\"", p) } else { p.clone() })
740                .collect::<Vec<_>>()
741                .join(" ");
742        }
743        return cmd.to_string();
744    }
745    let flag_with_space = format!(" {}", flag);
746    let result = cmd.replace(&flag_with_space, "");
747    if result == cmd {
748        cmd.replace(flag, "").replace("  ", " ")
749    } else {
750        result
751    }
752}
753
754fn retry_merge_with_fallback(original_cmd: &str) -> Option<()> {
755    let strategies = ["--squash", "--rebase"];
756    for strategy in &strategies {
757        let retry_cmd = original_cmd
758            .replace("--merge", strategy);
759        eprintln!(
760            "  {} Retrying with `{}`...",
761            "Fallback:".cyan().bold(),
762            strategy
763        );
764        let parts = shell_split(&retry_cmd);
765        if parts.is_empty() {
766            continue;
767        }
768        let output = Command::new(&parts[0])
769            .args(&parts[1..])
770            .output()
771            .ok()?;
772        let stdout = String::from_utf8_lossy(&output.stdout);
773        let stderr = String::from_utf8_lossy(&output.stderr);
774        if !stdout.trim().is_empty() {
775            println!("{stdout}");
776        }
777        if !stderr.trim().is_empty() {
778            eprintln!("{stderr}");
779        }
780        if output.status.success() {
781            eprintln!(
782                "  {} Merged successfully with `{}`",
783                "OK:".green().bold(),
784                strategy
785            );
786            return Some(());
787        }
788    }
789    None
790}
791
792fn auto_merge_remaining_prs() {
793    let current_branch = Command::new("git")
794        .args(["rev-parse", "--abbrev-ref", "HEAD"])
795        .output()
796        .ok()
797        .filter(|o| o.status.success())
798        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
799
800    let Some(branch) = current_branch else { return };
801
802    let pr_output = Command::new("gh")
803        .args([
804            "pr", "list", "--state", "open", "--head", &branch,
805            "--json", "number", "--template", "{{range .}}{{.number}}\n{{end}}",
806        ])
807        .output()
808        .ok()
809        .filter(|o| o.status.success())
810        .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
811
812    let Some(prs) = pr_output else { return };
813    let pr_numbers: Vec<u32> = prs.lines().filter_map(|l| l.trim().parse().ok()).collect();
814
815    if pr_numbers.is_empty() {
816        return;
817    }
818
819    eprintln!(
820        "\n  {} {} open PR(s) remaining for `{}`, merging...",
821        "Auto-merge:".cyan().bold(),
822        pr_numbers.len(),
823        branch
824    );
825
826    for (i, pr) in pr_numbers.iter().enumerate() {
827        let is_last = i == pr_numbers.len() - 1;
828        let mut args = vec!["pr".to_string(), "merge".to_string(), pr.to_string()];
829        args.push("--squash".to_string());
830        if is_last {
831            args.push("--delete-branch".to_string());
832        }
833        let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
834        eprintln!("  {} gh pr merge {} --squash{}", "Running:".cyan().bold(), pr,
835            if is_last { " --delete-branch" } else { "" });
836
837        let output = Command::new("gh").args(&arg_refs).output();
838        if let Ok(o) = output {
839            let stdout = String::from_utf8_lossy(&o.stdout);
840            let stderr = String::from_utf8_lossy(&o.stderr);
841            if !stdout.trim().is_empty() { println!("{stdout}"); }
842            if !stderr.trim().is_empty() { eprintln!("{stderr}"); }
843            if !o.status.success() {
844                eprintln!("  {} PR #{} merge failed, trying --rebase...", "Fallback:".yellow().bold(), pr);
845                let pr_str = pr.to_string();
846                let mut retry_args = vec!["pr", "merge", &pr_str, "--rebase"];
847                if is_last { retry_args.push("--delete-branch"); }
848                let _ = Command::new("gh").args(&retry_args).output();
849            }
850        }
851    }
852}
853
854pub fn extract_head_branch(cmd: &str) -> Option<String> {
855    let parts = shell_split(cmd);
856    for (i, part) in parts.iter().enumerate() {
857        if part == "--head" {
858            return parts.get(i + 1).cloned();
859        }
860    }
861    None
862}
863
864pub fn extract_pr_merge_number(cmd: &str) -> Option<u32> {
865    let parts: Vec<&str> = cmd.split_whitespace().collect();
866    if parts.len() >= 4 && parts[0] == "gh" && parts[1] == "pr" && parts[2] == "merge" {
867        parts[3].parse().ok()
868    } else {
869        None
870    }
871}
872
873pub fn parse_pr_number_from_output(output: &str) -> Option<u32> {
874    for line in output.lines() {
875        let trimmed = line.trim();
876        if trimmed.contains("/pull/") {
877            return trimmed.rsplit('/').next()?.parse().ok();
878        }
879    }
880    None
881}
882
883fn get_open_pr_numbers() -> Vec<u32> {
884    Command::new("gh")
885        .args([
886            "pr", "list", "--state", "open", "--json", "number",
887            "--template", "{{range .}}{{.number}}\n{{end}}",
888        ])
889        .output()
890        .ok()
891        .filter(|o| o.status.success())
892        .map(|o| {
893            String::from_utf8_lossy(&o.stdout)
894                .lines()
895                .filter_map(|l| l.trim().parse().ok())
896                .collect()
897        })
898        .unwrap_or_default()
899}
900