diff --git a/docs/guide/getting-started/configuration.md b/docs/guide/getting-started/configuration.md index 2c649945e..da7602391 100644 --- a/docs/guide/getting-started/configuration.md +++ b/docs/guide/getting-started/configuration.md @@ -91,6 +91,19 @@ Prevent specific commands from being rewritten by the hook: exclude_commands = ["git rebase", "git cherry-pick", "docker exec"] ``` +Patterns match against the full command after stripping env prefixes (`sudo`, `VAR=val`), so `"psql"` excludes both `psql -h localhost` and `PGPASSWORD=x psql -h localhost`. + +Subcommand patterns work too: `"git push"` excludes `git push origin main` but not `git status`. + +Patterns starting with `^` are treated as regex: + +```toml +[hooks] +exclude_commands = ["^curl", "^wget", "git rebase"] +``` + +Invalid regex patterns fall back to prefix matching. + Or for a single invocation: ```bash diff --git a/hooks/README.md b/hooks/README.md index 62875c028..6a6744281 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -184,7 +184,7 @@ Example: `cargo fmt --all && cargo test` becomes `rtk cargo fmt --all && rtk car ### Override Controls - **`RTK_DISABLED=1`**: Per-command override (`RTK_DISABLED=1 git status` runs raw) -- **`exclude_commands`**: In `~/.config/rtk/config.toml`, list commands to never rewrite +- **`exclude_commands`**: In `~/.config/rtk/config.toml`, list commands to never rewrite. Matches against the full command after stripping env prefixes. Subcommand patterns work (`"git push"` excludes `git push origin main`). Patterns starting with `^` are treated as regex. - **Already-RTK**: `rtk git status` passes through unchanged (no `rtk rtk git`) ## Exit Code Contract diff --git a/src/discover/registry.rs b/src/discover/registry.rs index ee5f7a7be..3e371c09c 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -447,6 +447,8 @@ pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option { return None; } + let compiled = compile_exclude_patterns(excluded); + // Simple (non-compound) already-RTK command — return as-is. // For compound commands that start with "rtk" (e.g. "rtk git add . && cargo test"), // fall through to rewrite_compound so the remaining segments get rewritten. @@ -459,11 +461,11 @@ pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option { return Some(trimmed.to_string()); } - rewrite_compound(trimmed, excluded) + rewrite_compound(trimmed, &compiled) } /// Rewrite a compound command (with `&&`, `||`, `;`, `|`) by rewriting each segment. -fn rewrite_compound(cmd: &str, excluded: &[String]) -> Option { +fn rewrite_compound(cmd: &str, excluded: &[ExcludePattern]) -> Option { let tokens = tokenize(cmd); let mut result = String::with_capacity(cmd.len() + 32); let mut any_changed = false; @@ -595,11 +597,54 @@ const SHELL_PREFIX_BUILTINS: &[&str] = &["noglob", "command", "builtin", "exec", const MAX_PREFIX_DEPTH: usize = 10; -fn rewrite_segment(seg: &str, excluded: &[String]) -> Option { +enum ExcludePattern { + Regex(Regex), + Prefix(String), +} + +fn compile_exclude_patterns(patterns: &[String]) -> Vec { + patterns + .iter() + .filter_map(|pattern| { + let trimmed = pattern.trim(); + if trimmed.is_empty() || trimmed == "^" { + eprintln!( + "rtk: warning: ignoring trivial exclude_commands pattern '{}'", + pattern + ); + return None; + } + let anchored = if trimmed.starts_with('^') { + trimmed.to_string() + } else { + format!(r"^{}($|\s)", regex::escape(trimmed)) + }; + Some(match Regex::new(&anchored) { + Ok(re) => ExcludePattern::Regex(re), + Err(e) => { + eprintln!( + "rtk: warning: invalid exclude_commands pattern '{}': {}", + pattern, e + ); + ExcludePattern::Prefix(pattern.clone()) + } + }) + }) + .collect() +} + +fn rewrite_segment(seg: &str, excluded: &[ExcludePattern]) -> Option { rewrite_segment_inner(seg, excluded, 0) } -fn rewrite_segment_inner(seg: &str, excluded: &[String], depth: usize) -> Option { +fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool { + excluded.iter().any(|pat| match pat { + ExcludePattern::Regex(re) => re.is_match(cmd), + ExcludePattern::Prefix(prefix) => cmd.starts_with(prefix.as_str()), + }) +} + +fn rewrite_segment_inner(seg: &str, excluded: &[ExcludePattern], depth: usize) -> Option { let trimmed = seg.trim(); if trimmed.is_empty() { return None; @@ -647,9 +692,9 @@ fn rewrite_segment_inner(seg: &str, excluded: &[String], depth: usize) -> Option // Use classify_command for correct ignore/prefix handling let rtk_equivalent = match classify_command(cmd_part) { Classification::Supported { rtk_equivalent, .. } => { - // Check if the base command is excluded from rewriting (#243) - let base = cmd_part.split_whitespace().next().unwrap_or(""); - if excluded.iter().any(|e| e == base) { + let stripped = ENV_PREFIX.replace(cmd_part, ""); + let cmd_clean = stripped.trim(); + if is_excluded(cmd_clean, excluded) { return None; } rtk_equivalent @@ -2853,6 +2898,57 @@ mod tests { ); } + #[test] + fn test_exclude_env_prefixed_command() { + let excluded = vec!["psql".to_string()]; + assert_eq!( + rewrite_command("PGPASSWORD=postgres psql -h localhost", &excluded), + None + ); + } + + #[test] + fn test_exclude_subcommand_pattern() { + let excluded = vec!["git push".to_string()]; + assert_eq!(rewrite_command("git push origin main", &excluded), None); + } + + #[test] + fn test_exclude_regex_pattern() { + let excluded = vec!["^curl".to_string()]; + assert_eq!(rewrite_command("curl http://example.com", &excluded), None); + } + + #[test] + fn test_exclude_invalid_regex_fallback() { + let excluded = vec!["curl[".to_string()]; + assert!(rewrite_command("curl http://example.com", &excluded).is_some()); + } + + #[test] + fn test_exclude_does_not_substring_match() { + let excluded = vec!["go".to_string()]; + assert!(rewrite_command("golangci-lint run ./...", &excluded).is_some()); + } + + #[test] + fn test_exclude_does_not_match_hyphenated_command() { + let excluded = vec!["golangci".to_string()]; + assert!(rewrite_command("golangci-lint run ./...", &excluded).is_some()); + } + + #[test] + fn test_exclude_empty_pattern_ignored() { + let excluded = vec!["".to_string()]; + assert!(rewrite_command("git status", &excluded).is_some()); + } + + #[test] + fn test_exclude_bare_anchor_ignored() { + let excluded = vec!["^".to_string()]; + assert!(rewrite_command("git status", &excluded).is_some()); + } + #[test] fn test_all_patterns_are_valid_regex() { use regex::Regex;