Skip to content

Commit faa3bc1

Browse files
author
atgim
committed
feat(config): support multi-word entries in exclude_commands
Extends exclude_commands matching from first-word-only equality to whitespace-aware token matching. Entries containing whitespace match the command's first N tokens (where N is the entry's token count), enabling subcommand-level exclusion without blocking the parent command. Example config: [hooks] exclude_commands = ["curl", "git diff", "kubectl describe"] - "curl" behaves exactly as before (excludes all curl invocations) - "git diff" excludes `git diff` with any args, but NOT `git status` - Word boundaries use whitespace, so "git diff" never matches `git difference` or `git diffoo` - Whitespace in both entry and command is normalized (handled by split_whitespace on both sides) Rationale: first-word-only matching forces all-or-nothing exclusion, making it impossible to exclude diff/patch-producing subcommands (`git diff`, `git show -p`) while keeping `git status` / `git log` filtered. Silent output corruption on these subcommands is particularly harmful for LLM agents that read diffs to make edits, since stripped context lines can lead to wrong interpretation of file state. Backward compatible: existing single-word entries unchanged. All existing tests pass. New tests cover multi-word exclusion, partial word safety, whitespace normalization, and mixed entry coexistence. Refs: #1282, #1313
1 parent 5c9e1ac commit faa3bc1

1 file changed

Lines changed: 92 additions & 3 deletions

File tree

src/discover/registry.rs

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,27 @@ const SHELL_PREFIX_BUILTINS: &[&str] = &["noglob", "command", "builtin", "exec",
595595

596596
const MAX_PREFIX_DEPTH: usize = 10;
597597

598+
/// Check whether a command should be excluded from rewriting based on a single
599+
/// `exclude_commands` entry.
600+
///
601+
/// - **Single-word entry** (no whitespace, e.g. `"curl"`): matches when the first
602+
/// whitespace-separated token of the command equals the entry.
603+
/// - **Multi-word entry** (contains whitespace, e.g. `"git diff"`): matches when
604+
/// the command's first N tokens equal the entry's tokens, where N is the
605+
/// entry's token count. This lets users exclude specific subcommands without
606+
/// blocking the entire parent command.
607+
///
608+
/// Word boundaries use whitespace, so `"git diff"` matches `"git diff HEAD"` and
609+
/// `"git diff"` exactly, but never `"git difference"` or `"git diffoo"`.
610+
fn is_command_excluded(cmd: &str, entry: &str) -> bool {
611+
let entry_tokens: Vec<&str> = entry.split_whitespace().collect();
612+
if entry_tokens.is_empty() {
613+
return false;
614+
}
615+
let cmd_tokens = cmd.split_whitespace().take(entry_tokens.len());
616+
cmd_tokens.eq(entry_tokens.iter().copied())
617+
}
618+
598619
fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
599620
rewrite_segment_inner(seg, excluded, 0)
600621
}
@@ -647,9 +668,13 @@ fn rewrite_segment_inner(seg: &str, excluded: &[String], depth: usize) -> Option
647668
// Use classify_command for correct ignore/prefix handling
648669
let rtk_equivalent = match classify_command(cmd_part) {
649670
Classification::Supported { rtk_equivalent, .. } => {
650-
// Check if the base command is excluded from rewriting (#243)
651-
let base = cmd_part.split_whitespace().next().unwrap_or("");
652-
if excluded.iter().any(|e| e == base) {
671+
// Check if the command is excluded from rewriting (#243).
672+
// Single-word entries match the first whitespace-separated token of the command
673+
// (existing behavior). Multi-word entries match as a whitespace-aware prefix,
674+
// so e.g. "git diff" matches `git diff HEAD~1 HEAD` but not `git status` or
675+
// `git difference`. This lets users exclude specific subcommands without
676+
// blocking the entire parent command.
677+
if excluded.iter().any(|e| is_command_excluded(cmd_part, e)) {
653678
return None;
654679
}
655680
rtk_equivalent
@@ -2853,6 +2878,70 @@ mod tests {
28532878
);
28542879
}
28552880

2881+
// --- Multi-word exclude_commands entries (subcommand-level exclusion) ---
2882+
2883+
#[test]
2884+
fn test_rewrite_multi_word_excludes_git_diff() {
2885+
// "git diff" entry excludes `git diff` with any args.
2886+
let excluded = vec!["git diff".to_string()];
2887+
assert_eq!(rewrite_command("git diff HEAD~1 HEAD", &excluded), None);
2888+
assert_eq!(rewrite_command("git diff", &excluded), None);
2889+
}
2890+
2891+
#[test]
2892+
fn test_rewrite_multi_word_allows_other_git_subcommands() {
2893+
// "git diff" does not block `git status`, `git log`, etc.
2894+
let excluded = vec!["git diff".to_string()];
2895+
assert_eq!(
2896+
rewrite_command("git status", &excluded),
2897+
Some("rtk git status".into())
2898+
);
2899+
assert_eq!(
2900+
rewrite_command("git log --oneline", &excluded),
2901+
Some("rtk git log --oneline".into())
2902+
);
2903+
}
2904+
2905+
#[test]
2906+
fn test_rewrite_multi_word_no_partial_word_match() {
2907+
// "git diff" must not match `git diffoo` or `git difference`.
2908+
// Verified at the helper level since the git rewrite pattern only
2909+
// matches a fixed subcommand set (no partial matches reach the
2910+
// exclude check), but the helper's correctness still matters.
2911+
assert!(!is_command_excluded("git diffoo --foo", "git diff"));
2912+
assert!(!is_command_excluded("git difference", "git diff"));
2913+
assert!(is_command_excluded("git diff HEAD", "git diff"));
2914+
assert!(is_command_excluded("git diff", "git diff"));
2915+
}
2916+
2917+
#[test]
2918+
fn test_rewrite_multi_word_and_single_word_coexist() {
2919+
// Single-word and multi-word entries can be mixed freely.
2920+
let excluded = vec!["curl".to_string(), "git diff".to_string()];
2921+
// curl still fully excluded (single-word behavior)
2922+
assert_eq!(rewrite_command("curl https://example.com", &excluded), None);
2923+
// git diff excluded (multi-word)
2924+
assert_eq!(rewrite_command("git diff HEAD", &excluded), None);
2925+
// git status still rewrites
2926+
assert_eq!(
2927+
rewrite_command("git status", &excluded),
2928+
Some("rtk git status".into())
2929+
);
2930+
}
2931+
2932+
#[test]
2933+
fn test_is_command_excluded_whitespace_normalization() {
2934+
// Multiple spaces in either command or entry are normalized.
2935+
assert!(is_command_excluded("git diff HEAD", "git diff"));
2936+
assert!(is_command_excluded("git diff HEAD", "git diff"));
2937+
}
2938+
2939+
#[test]
2940+
fn test_is_command_excluded_empty_entry_never_matches() {
2941+
assert!(!is_command_excluded("git status", ""));
2942+
assert!(!is_command_excluded("git status", " "));
2943+
}
2944+
28562945
#[test]
28572946
fn test_all_patterns_are_valid_regex() {
28582947
use regex::Regex;

0 commit comments

Comments
 (0)