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