@@ -327,9 +327,34 @@ pub fn strip_disabled_prefix(cmd: &str) -> &str {
327327 trimmed[ prefix_len..] . trim_start ( )
328328}
329329
330- /// Rewrite a raw command to its RTK equivalent.
331- ///
332- /// Returns `Some(rewritten)` if the command has an RTK equivalent or is already RTK.
330+ lazy_static ! {
331+ // Match trailing shell redirections:
332+ // Alt 1: N>&M or N>&- (fd redirect/close): 2>&1, 1>&2, 2>&-
333+ // Alt 2: &>file or &>>file (bash redirect both): &>/dev/null
334+ // Alt 3: N>file or N>>file (fd to file): 2>/dev/null, >/tmp/out, 1>>log
335+ // Note: [^(\\s] excludes process substitutions like >(tee) from false-positive matching
336+ static ref TRAILING_REDIRECT : Regex =
337+ Regex :: new( r"\s+(?:[0-9]?>&[0-9-]|&>>?\S+|[0-9]?>>?\s*[^(\s]\S*)\s*$" ) . unwrap( ) ;
338+ }
339+
340+ /// Strip trailing stderr/stdout redirects from a command segment (#530).
341+ /// Returns (command_without_redirects, redirect_suffix).
342+ fn strip_trailing_redirects ( cmd : & str ) -> ( & str , & str ) {
343+ if let Some ( m) = TRAILING_REDIRECT . find ( cmd) {
344+ // Verify redirect is not inside quotes (single-pass count)
345+ let before = & cmd[ ..m. start ( ) ] ;
346+ let ( sq, dq) = before. chars ( ) . fold ( ( 0u32 , 0u32 ) , |( s, d) , c| match c {
347+ '\'' => ( s + 1 , d) ,
348+ '"' => ( s, d + 1 ) ,
349+ _ => ( s, d) ,
350+ } ) ;
351+ if sq % 2 == 0 && dq % 2 == 0 {
352+ return ( & cmd[ ..m. start ( ) ] , & cmd[ m. start ( ) ..] ) ;
353+ }
354+ }
355+ ( cmd, "" )
356+ }
357+
333358/// Returns `None` if the command is unsupported or ignored (hook should pass through).
334359///
335360/// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently.
@@ -565,30 +590,34 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
565590 return None ;
566591 }
567592
593+ // Strip trailing stderr/stdout redirects before matching (#530)
594+ // e.g. "git status 2>&1" → match "git status", re-append " 2>&1"
595+ let ( cmd_part, redirect_suffix) = strip_trailing_redirects ( trimmed) ;
596+
568597 // Already RTK — pass through unchanged
569- if trimmed . starts_with ( "rtk " ) || trimmed == "rtk" {
598+ if cmd_part . starts_with ( "rtk " ) || cmd_part == "rtk" {
570599 return Some ( trimmed. to_string ( ) ) ;
571600 }
572601
573602 // Special case: `head -N file` / `head --lines=N file` → `rtk read file --max-lines N`
574603 // Must intercept before generic prefix replacement, which would produce `rtk read -20 file`.
575604 // Only intercept when head has a flag (-N, --lines=N, -c, etc.); plain `head file` falls
576605 // through to the generic rewrite below and produces `rtk read file` as expected.
577- if trimmed . starts_with ( "head -" ) {
578- return rewrite_head_numeric ( trimmed ) ;
606+ if cmd_part . starts_with ( "head -" ) {
607+ return rewrite_head_numeric ( cmd_part ) . map ( |r| format ! ( "{}{}" , r , redirect_suffix ) ) ;
579608 }
580609
581610 // tail has several forms that are not compatible with generic prefix replacement.
582611 // Only rewrite recognized numeric line forms; otherwise skip rewrite.
583- if trimmed . starts_with ( "tail " ) {
584- return rewrite_tail_lines ( trimmed ) ;
612+ if cmd_part . starts_with ( "tail " ) {
613+ return rewrite_tail_lines ( cmd_part ) . map ( |r| format ! ( "{}{}" , r , redirect_suffix ) ) ;
585614 }
586615
587616 // Use classify_command for correct ignore/prefix handling
588- let rtk_equivalent = match classify_command ( trimmed ) {
617+ let rtk_equivalent = match classify_command ( cmd_part ) {
589618 Classification :: Supported { rtk_equivalent, .. } => {
590619 // Check if the base command is excluded from rewriting (#243)
591- let base = trimmed . split_whitespace ( ) . next ( ) . unwrap_or ( "" ) ;
620+ let base = cmd_part . split_whitespace ( ) . next ( ) . unwrap_or ( "" ) ;
592621 if excluded. iter ( ) . any ( |e| e == base) {
593622 return None ;
594623 }
@@ -601,13 +630,13 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
601630 let rule = RULES . iter ( ) . find ( |r| r. rtk_cmd == rtk_equivalent) ?;
602631
603632 // Extract env prefix (sudo, env VAR=val, etc.)
604- let stripped_cow = ENV_PREFIX . replace ( trimmed , "" ) ;
605- let env_prefix_len = trimmed . len ( ) - stripped_cow. len ( ) ;
606- let env_prefix = & trimmed [ ..env_prefix_len] ;
633+ let stripped_cow = ENV_PREFIX . replace ( cmd_part , "" ) ;
634+ let env_prefix_len = cmd_part . len ( ) - stripped_cow. len ( ) ;
635+ let env_prefix = & cmd_part [ ..env_prefix_len] ;
607636 let cmd_clean = stripped_cow. trim ( ) ;
608637
609638 // #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely
610- if has_rtk_disabled_prefix ( trimmed ) {
639+ if has_rtk_disabled_prefix ( cmd_part ) {
611640 return None ;
612641 }
613642
@@ -627,9 +656,9 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
627656 for & prefix in rule. rewrite_prefixes {
628657 if let Some ( rest) = strip_word_prefix ( cmd_clean, prefix) {
629658 let rewritten = if rest. is_empty ( ) {
630- format ! ( "{}{}" , env_prefix, rule. rtk_cmd)
659+ format ! ( "{}{}{} " , env_prefix, rule. rtk_cmd, redirect_suffix )
631660 } else {
632- format ! ( "{}{} {}" , env_prefix, rule. rtk_cmd, rest)
661+ format ! ( "{}{} {}{} " , env_prefix, rule. rtk_cmd, rest, redirect_suffix )
633662 } ;
634663 return Some ( rewritten) ;
635664 }
@@ -1285,6 +1314,35 @@ mod tests {
12851314 ) ;
12861315 }
12871316
1317+ #[ test]
1318+ fn test_rewrite_redirect_double ( ) {
1319+ // Double redirect: only last one stripped, but full command rewrites correctly
1320+ assert_eq ! (
1321+ rewrite_command( "git status 2>&1 >/dev/null" , & [ ] ) ,
1322+ Some ( "rtk git status 2>&1 >/dev/null" . into( ) )
1323+ ) ;
1324+ }
1325+
1326+ #[ test]
1327+ fn test_rewrite_redirect_fd_close ( ) {
1328+ // 2>&- (close stderr fd)
1329+ assert_eq ! (
1330+ rewrite_command( "git status 2>&-" , & [ ] ) ,
1331+ Some ( "rtk git status 2>&-" . into( ) )
1332+ ) ;
1333+ }
1334+
1335+ #[ test]
1336+ fn test_rewrite_redirect_quotes_not_stripped ( ) {
1337+ // Redirect-like chars inside quotes should NOT be stripped
1338+ // Known limitation: apostrophes cause conservative no-strip (safe fallback)
1339+ let result = rewrite_command ( "git commit -m \" it's fixed\" 2>&1" , & [ ] ) ;
1340+ assert ! (
1341+ result. is_some( ) ,
1342+ "Should still rewrite even with apostrophe"
1343+ ) ;
1344+ }
1345+
12881346 #[ test]
12891347 fn test_rewrite_background_amp_non_regression ( ) {
12901348 // background `&` must still work after redirect fix
0 commit comments