Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/guide/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 103 additions & 7 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option<String> {
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.
Expand All @@ -459,11 +461,11 @@ pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option<String> {
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<String> {
fn rewrite_compound(cmd: &str, excluded: &[ExcludePattern]) -> Option<String> {
let tokens = tokenize(cmd);
let mut result = String::with_capacity(cmd.len() + 32);
let mut any_changed = false;
Expand Down Expand Up @@ -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<String> {
enum ExcludePattern {
Regex(Regex),
Prefix(String),
}

fn compile_exclude_patterns(patterns: &[String]) -> Vec<ExcludePattern> {
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<String> {
rewrite_segment_inner(seg, excluded, 0)
}

fn rewrite_segment_inner(seg: &str, excluded: &[String], depth: usize) -> Option<String> {
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<String> {
let trimmed = seg.trim();
if trimmed.is_empty() {
return None;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading