diff --git a/src/commands/git_handlers.rs b/src/commands/git_handlers.rs index ef2117e0..7055928c 100644 --- a/src/commands/git_handlers.rs +++ b/src/commands/git_handlers.rs @@ -51,6 +51,69 @@ struct CommandHooksContext { pre_commit_hook_result: Option, } +/// Return the alias definition for a given command name (if any) by consulting +/// `git config alias.` with the same global args as the invocation. +/// Returns `None` if no alias is configured. +fn get_alias_for_command(global_args: &[String], name: &str) -> Option { + // Build: + ["config", "--get", format!("alias.{}", name)] + let mut args: Vec = Vec::with_capacity(global_args.len() + 4); + args.extend(global_args.iter().cloned()); + args.push("config".to_string()); + args.push("--get".to_string()); + args.push(format!("alias.{}", name)); + + match Command::new(config::Config::get().git_cmd()).args(&args).output() { + Ok(output) if output.status.success() => { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + } + _ => None, + } +} + +/// Tokenize a git alias definition into argv-like tokens, handling simple +/// shell-style quotes and backslash escapes similarly to git's split_cmdline. +/// Returns None on unterminated quotes to avoid unsafe rewrites. +fn tokenize_alias(definition: &str) -> Option> { + let mut tokens: Vec = Vec::new(); + let mut current = String::new(); + let mut in_single = false; + let mut in_double = false; + let mut chars = definition.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '\'' => { + if !in_double { in_single = !in_single; } else { current.push(ch); } + } + '"' => { + if !in_single { in_double = !in_double; } else { current.push(ch); } + } + '\\' => { + if in_single { + // Backslash is literal inside single quotes + current.push('\\'); + } else { + if let Some(next) = chars.next() { current.push(next); } else { current.push('\\'); } + } + } + c if c.is_whitespace() => { + if in_single || in_double { + current.push(c); + } else if !current.is_empty() { + tokens.push(current.clone()); + current.clear(); + } + } + _ => current.push(ch), + } + } + + if in_single || in_double { return None; } + if !current.is_empty() { tokens.push(current); } + Some(tokens) +} + pub fn handle_git(args: &[String]) { // If we're being invoked from a shell completion context, bypass git-ai logic // and delegate directly to the real git so existing completion scripts work. @@ -60,11 +123,54 @@ pub fn handle_git(args: &[String]) { return; } - let mut command_hooks_context = CommandHooksContext { - pre_commit_hook_result: None, - }; + let mut command_hooks_context = CommandHooksContext { pre_commit_hook_result: None }; + + // First parse of raw args (may contain an alias as the command token) + let initial_parsed = parse_git_cli_args(args); - let parsed_args = parse_git_cli_args(args); + // Single-pass alias expansion: if the command is an alias, expand it once. + // For external aliases (starting with '!'), bypass hooks entirely and + // delegate to git immediately with the original args. + let parsed_args = if let Some(cmd) = initial_parsed.command.as_deref() { + if let Some(alias_def) = get_alias_for_command(&initial_parsed.global_args, cmd) { + let trimmed = alias_def.trim_start(); + if trimmed.starts_with('!') { + // External command alias: run real git immediately, no hooks. + debug_log("Detected external git alias; bypassing hooks and delegating to git"); + let orig = initial_parsed.to_invocation_vec(); + let status = proxy_to_git(&orig, false); + exit_with_status(status); + } + // Tokenize alias and build a new argv: globals + [alias tokens] + original command args + if let Some(mut alias_tokens) = tokenize_alias(trimmed) { + if !alias_tokens.is_empty() { + let mut expanded: Vec = Vec::with_capacity( + initial_parsed.global_args.len() + + usize::from(initial_parsed.saw_end_of_opts) + + alias_tokens.len() + + initial_parsed.command_args.len(), + ); + expanded.extend(initial_parsed.global_args.iter().cloned()); + if initial_parsed.saw_end_of_opts { + expanded.push("--".to_string()); + } + expanded.append(&mut alias_tokens); + expanded.extend(initial_parsed.command_args.iter().cloned()); + // Re-parse the expanded argv once; do not attempt to expand again. + parse_git_cli_args(&expanded) + } else { + initial_parsed.clone() + } + } else { + // Failed to safely tokenize; fall back to original to avoid incorrect behavior. + initial_parsed.clone() + } + } else { + initial_parsed.clone() + } + } else { + initial_parsed.clone() + }; // println!("command: {:?}", parsed_args.command); // println!("global_args: {:?}", parsed_args.global_args); // println!("command_args: {:?}", parsed_args.command_args);