Skip to content
Open
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
149 changes: 149 additions & 0 deletions src/cmds/git/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub enum GitCommand {
Fetch,
Stash { subcommand: Option<String> },
Worktree,
CherryPick,
Remote,
}

/// Create a git Command with global options (e.g. -C, -c, --git-dir, --work-tree)
Expand Down Expand Up @@ -68,6 +70,8 @@ pub fn run(
run_stash(subcommand.as_deref(), args, verbose, global_args)
}
GitCommand::Worktree => run_worktree(args, verbose, global_args),
GitCommand::CherryPick => run_cherry_pick(args, verbose, global_args),
GitCommand::Remote => run_remote(args, verbose, global_args),
}
}

Expand Down Expand Up @@ -1771,6 +1775,151 @@ fn filter_worktree_list(output: &str) -> String {
}

/// Runs an unsupported git subcommand by passing it through directly
fn run_cherry_pick(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();

if verbose > 0 {
eprintln!("git cherry-pick");
}

let mut cmd = git_cmd(global_args);
cmd.arg("cherry-pick");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git cherry-pick")?;

let combined = result.combined();
let is_conflict = !result.success();

if is_conflict {
let stderr = result.stderr.trim();
let stdout = result.stdout.trim();
if !stderr.is_empty() {
eprintln!("{}", stderr);
}
if !stdout.is_empty() {
println!("{}", stdout);
}

timer.track(
&format!("git cherry-pick {}", args.join(" ")),
&format!("rtk git cherry-pick {} (conflict)", args.join(" ")),
&combined,
&combined,
);
return Ok(result.exit_code);
}

let output = result.stdout.trim();
let compact = if output.lines().count() <= 3 {
output.to_string()
} else {
output
.lines()
.filter(|l| {
!l.starts_with("Author:")
&& !l.starts_with("Date:")
&& !l.trim().is_empty()
})
.take(5)
.collect::<Vec<_>>()
.join("\n")
};

println!("{}", compact);

timer.track(
&format!("git cherry-pick {}", args.join(" ")),
&format!("rtk git cherry-pick {}", args.join(" ")),
&combined,
&compact,
);

Ok(0)
}

fn run_remote(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();

if verbose > 0 {
eprintln!("git remote");
}

let is_verbose = args.iter().any(|a| a == "-v" || a == "--verbose");
let has_action = args
.iter()
.any(|a| a == "add" || a == "remove" || a == "rename" || a == "set-url" || a == "get-url" || a == "prune");

let mut cmd = git_cmd(global_args);
cmd.arg("remote");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git remote")?;
let combined = result.combined();

if !result.success() {
eprintln!("{}", result.stderr.trim());
timer.track(
&format!("git remote {}", args.join(" ")),
&format!("rtk git remote {} (error)", args.join(" ")),
&combined,
&combined,
);
return Ok(result.exit_code);
}

let output = result.stdout.trim();

if has_action {
println!("{}", if output.is_empty() { "ok" } else { output });
timer.track(
&format!("git remote {}", args.join(" ")),
&format!("rtk git remote {}", args.join(" ")),
&combined,
if output.is_empty() { "ok" } else { output },
);
return Ok(0);
}

let compact = if is_verbose {
let mut lines: Vec<&str> = output.lines().collect();
lines.dedup_by(|a, b| {
let a_name = a.split_whitespace().next().unwrap_or("");
let b_name = b.split_whitespace().next().unwrap_or("");
a_name == b_name
});
lines
.into_iter()
.take(20)
.collect::<Vec<_>>()
.join("\n")
} else {
output
.lines()
.take(20)
.collect::<Vec<_>>()
.join("\n")
};

let display = if compact.is_empty() {
"(no remotes)".to_string()
} else {
compact
};
println!("{}", display);

timer.track(
&format!("git remote {}", args.join(" ")),
&format!("rtk git remote {}", args.join(" ")),
&combined,
&display,
);

Ok(0)
}

pub fn run_passthrough(args: &[OsString], global_args: &[String], verbose: u8) -> Result<i32> {
let timer = tracking::TimedExecution::start();

Expand Down
8 changes: 4 additions & 4 deletions src/core/toml_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1621,8 +1621,8 @@ match_command = "^make\\b"
let filters = make_filters(BUILTIN_TOML);
assert_eq!(
filters.len(),
59,
"Expected exactly 59 built-in filters, got {}. \
61,
"Expected exactly 61 built-in filters, got {}. \
Update this count when adding/removing filters in src/filters/.",
filters.len()
);
Expand Down Expand Up @@ -1682,8 +1682,8 @@ expected = "output line 1\noutput line 2"
// All 59 existing filters still present + 1 new = 60
assert_eq!(
filters.len(),
60,
"Expected 60 filters after concat (59 built-in + 1 new)"
62,
"Expected 62 filters after concat (61 built-in + 1 new)"
);

// New filter is discoverable
Expand Down
68 changes: 66 additions & 2 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,8 +1093,20 @@ mod tests {
fn test_registry_covers_all_git_subcommands() {
// Verify that every GitCommand subcommand has a matching pattern
for subcmd in [
"status", "log", "diff", "show", "add", "commit", "push", "pull", "branch", "fetch",
"stash", "worktree",
"status",
"log",
"diff",
"show",
"add",
"commit",
"push",
"pull",
"branch",
"fetch",
"stash",
"worktree",
"cherry-pick",
"remote",
] {
let cmd = format!("git {subcmd}");
match classify_command(&cmd) {
Expand Down Expand Up @@ -1958,6 +1970,58 @@ mod tests {
));
}

#[test]
fn test_classify_yarn_lint() {
assert!(matches!(
classify_command("yarn lint"),
Classification::Supported { category, .. } if category == "Build"
));
assert!(matches!(
classify_command("yarn lint src/"),
Classification::Supported { .. }
));
}

#[test]
fn test_classify_yarn_test() {
assert!(matches!(
classify_command("yarn test"),
Classification::Supported { category, .. } if category == "Build"
));
}

#[test]
fn test_classify_dig() {
assert!(matches!(
classify_command("dig example.com +short"),
Classification::Supported { category, .. } if category == "Network"
));
}

#[test]
fn test_classify_ssh() {
assert!(matches!(
classify_command("ssh user@host ls /var/log"),
Classification::Supported { category, .. } if category == "Network"
));
}

#[test]
fn test_classify_git_cherry_pick() {
assert!(matches!(
classify_command("git cherry-pick abc1234"),
Classification::Supported { category, .. } if category == "Git"
));
}

#[test]
fn test_classify_git_remote() {
assert!(matches!(
classify_command("git remote -v"),
Classification::Supported { category, .. } if category == "Git"
));
}

#[test]
fn test_classify_docker_build() {
assert!(matches!(
Expand Down
29 changes: 28 additions & 1 deletion src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub struct RtkRule {

pub const RULES: &[RtkRule] = &[
RtkRule {
pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)",
pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree|cherry-pick|remote)",
rtk_cmd: "rtk git",
rewrite_prefixes: &["git", "yadm"],
category: "Git",
Expand Down Expand Up @@ -878,6 +878,33 @@ pub const RULES: &[RtkRule] = &[
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
pattern: r"^yarn\s+(lint|test|build|check)\b",
rtk_cmd: "rtk yarn",
rewrite_prefixes: &["yarn"],
category: "Build",
savings_pct: 65.0,
subcmd_savings: &[("lint", 65.0), ("test", 80.0)],
subcmd_status: &[],
},
RtkRule {
pattern: r"^dig\s+",
rtk_cmd: "rtk dig",
rewrite_prefixes: &["dig"],
category: "Network",
savings_pct: 55.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
pattern: r"^ssh\s+",
rtk_cmd: "rtk ssh",
rewrite_prefixes: &["ssh"],
category: "Network",
savings_pct: 50.0,
subcmd_savings: &[],
subcmd_status: &[],
},
];

pub const IGNORED_PREFIXES: &[&str] = &[
Expand Down
45 changes: 45 additions & 0 deletions src/filters/dig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[filters.dig]
description = "Compact dig output — keep ANSWER section and query metadata, strip decorative headers"
match_command = "^dig\\b"
strip_ansi = true
keep_lines_matching = [
"^;;\\s*(ANSWER|AUTHORITY|ADDITIONAL)\\s+SECTION",
"^;;\\s*Query\\s+time",
"^;;\\s*SERVER:",
"^;;\\s*MSG SIZE",
"IN\\s+",
"^;",
"^[a-zA-Z0-9].*IN\\s+",
]
max_lines = 30
on_empty = "dig: no answer"

[[tests.dig]]
name = "keeps answer section and metadata"
input = """
; <<>> DiG 9.10.6 <<>> example.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 12345
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; QUESTION SECTION:
;example.com.\t\tIN\tA

;; ANSWER SECTION:
example.com.\t5\tIN\tA\t93.184.216.34

;; Query time: 42 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; MSG SIZE rcvd: 56
"""
expected = ";; ANSWER SECTION:\nexample.com.\t5\tIN\tA\t93.184.216.34\n;; Query time: 42 msec\n;; SERVER: 8.8.8.8#53(8.8.8.8)\n;; MSG SIZE rcvd: 56"

[[tests.dig]]
name = "no answer shows fallback"
input = """
; <<>> DiG 9.10.6 <<>> nonexistent.example
;; global options: +cmd
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 54321
"""
expected = "dig: no answer"
Loading