use crate::core::args_utils;
use crate::core::stream::{
self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode,
};
use crate::core::tracking;
use crate::core::truncate::CAP_WARNINGS;
use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command};
use anyhow::{Context, Result};
use std::ffi::OsString;
use std::process::Command;
use std::process::Stdio;
#[derive(Debug, Clone)]
pub enum GitCommand {
Diff,
Log,
Status,
Show,
Add,
Commit,
Push,
Pull,
Branch,
Fetch,
Stash { subcommand: Option<String> },
Worktree,
}
fn git_cmd(global_args: &[String]) -> Command {
let mut cmd = resolved_command("git");
for arg in global_args {
cmd.arg(arg);
}
cmd
}
fn git_cmd_c_locale(global_args: &[String]) -> Command {
let mut cmd = git_cmd(global_args);
cmd.env("LC_ALL", "C");
cmd
}
fn uses_compact_status_path(args: &[String]) -> bool {
if args.is_empty() {
return true;
}
let mut saw_branch = false;
for arg in args {
match arg.as_str() {
"-b" | "--branch" => saw_branch = true,
"-sb" | "-bs" => return true,
"-s" | "--short" => {}
_ => return false,
}
}
saw_branch
}
fn build_status_command(args: &[String], global_args: &[String]) -> Command {
let mut cmd = git_cmd(global_args);
cmd.arg("status");
if uses_compact_status_path(args) {
cmd.args(["--porcelain", "-b"]);
} else {
cmd.args(args);
}
cmd
}
pub fn run(
cmd: GitCommand,
args: &[String],
max_lines: Option<usize>,
verbose: u8,
global_args: &[String],
) -> Result<i32> {
match cmd {
GitCommand::Diff => run_diff(args, max_lines, verbose, global_args),
GitCommand::Log => run_log(args, max_lines, verbose, global_args),
GitCommand::Status => run_status(args, verbose, global_args),
GitCommand::Show => run_show(args, max_lines, verbose, global_args),
GitCommand::Add => run_add(args, verbose, global_args),
GitCommand::Commit => run_commit(args, verbose, global_args),
GitCommand::Push => run_push(args, verbose, global_args),
GitCommand::Pull => run_pull(args, verbose, global_args),
GitCommand::Branch => run_branch(args, verbose, global_args),
GitCommand::Fetch => run_fetch(args, verbose, global_args),
GitCommand::Stash { subcommand } => {
run_stash(subcommand.as_deref(), args, verbose, global_args)
}
GitCommand::Worktree => run_worktree(args, verbose, global_args),
}
}
fn run_diff(
args: &[String],
max_lines: Option<usize>,
verbose: u8,
global_args: &[String],
) -> Result<i32> {
let timer = tracking::TimedExecution::start();
let args = &args_utils::restore_double_dash(args);
let wants_stat = args
.iter()
.any(|arg| arg == "--stat" || arg == "--numstat" || arg == "--shortstat");
let wants_compact = !args.iter().any(|arg| arg == "--no-compact");
if wants_stat || !wants_compact {
let mut cmd = git_cmd(global_args);
cmd.arg("diff");
for arg in args {
if arg == "--no-compact" {
continue; }
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git diff")?;
if !result.success() {
eprintln!("{}", result.stderr);
return Ok(result.exit_code);
}
println!("{}", result.stdout.trim());
timer.track(
&format!("git diff {}", args.join(" ")),
&format!("rtk git diff {} (passthrough)", args.join(" ")),
&result.stdout,
&result.stdout,
);
return Ok(0);
}
let mut cmd = git_cmd(global_args);
cmd.arg("diff").arg("--stat");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git diff")?;
if !result.success() {
if !result.stderr.trim().is_empty() {
eprint!("{}", result.stderr);
}
timer.track(
&format!("git diff {}", args.join(" ")),
&format!("rtk git diff {}", args.join(" ")),
&result.stdout,
&result.stdout,
);
return Ok(result.exit_code);
}
if verbose > 0 {
eprintln!("Git diff summary:");
}
println!("{}", result.stdout.trim());
let mut diff_cmd = git_cmd(global_args);
diff_cmd.arg("diff");
for arg in args {
diff_cmd.arg(arg);
}
let diff_result = exec_capture(&mut diff_cmd).context("Failed to run git diff")?;
let mut final_output = result.stdout.clone();
if !diff_result.stdout.is_empty() {
println!("\nChanges:");
let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500));
println!("{}", compacted);
final_output.push_str("\nChanges:\n");
final_output.push_str(&compacted);
}
timer.track(
&format!("git diff {}", args.join(" ")),
&format!("rtk git diff {}", args.join(" ")),
&format!("{}\n{}", result.stdout, diff_result.stdout),
&final_output,
);
Ok(0)
}
fn run_show(
args: &[String],
max_lines: Option<usize>,
verbose: u8,
global_args: &[String],
) -> Result<i32> {
let timer = tracking::TimedExecution::start();
let wants_stat_only = args
.iter()
.any(|arg| arg == "--stat" || arg == "--numstat" || arg == "--shortstat");
let wants_format = args
.iter()
.any(|arg| arg.starts_with("--pretty") || arg.starts_with("--format"));
let wants_blob_show = args.iter().any(|arg| is_blob_show_arg(arg));
if wants_stat_only || wants_format || wants_blob_show {
let mut cmd = git_cmd(global_args);
cmd.arg("show");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git show")?;
if !result.success() {
eprintln!("{}", result.stderr);
return Ok(result.exit_code);
}
if wants_blob_show {
print!("{}", result.stdout);
} else {
println!("{}", result.stdout.trim());
}
timer.track(
&format!("git show {}", args.join(" ")),
&format!("rtk git show {} (passthrough)", args.join(" ")),
&result.stdout,
&result.stdout,
);
return Ok(0);
}
let mut raw_cmd = git_cmd(global_args);
raw_cmd.arg("show");
for arg in args {
raw_cmd.arg(arg);
}
let raw_output = exec_capture(&mut raw_cmd)
.map(|r| r.stdout)
.unwrap_or_default();
let mut summary_cmd = git_cmd(global_args);
summary_cmd.args(["show", "--no-patch", "--pretty=format:%h %s (%ar) <%an>"]);
for arg in args {
summary_cmd.arg(arg);
}
let summary_result = exec_capture(&mut summary_cmd).context("Failed to run git show")?;
if !summary_result.success() {
eprintln!("{}", summary_result.stderr);
return Ok(summary_result.exit_code);
}
println!("{}", summary_result.stdout.trim());
let mut stat_cmd = git_cmd(global_args);
stat_cmd.args(["show", "--stat", "--pretty=format:"]);
for arg in args {
stat_cmd.arg(arg);
}
let stat_result = exec_capture(&mut stat_cmd).context("Failed to run git show --stat")?;
let stat_text = stat_result.stdout.trim();
if !stat_text.is_empty() {
println!("{}", stat_text);
}
let mut diff_cmd = git_cmd(global_args);
diff_cmd.args(["show", "--pretty=format:"]);
for arg in args {
diff_cmd.arg(arg);
}
let diff_result = exec_capture(&mut diff_cmd).context("Failed to run git show (diff)")?;
let diff_text = diff_result.stdout.trim();
let mut final_output = summary_result.stdout.clone();
if !diff_text.is_empty() {
if verbose > 0 {
println!("\nChanges:");
}
let compacted = compact_diff(diff_text, max_lines.unwrap_or(500));
println!("{}", compacted);
final_output.push_str(&format!("\n{}", compacted));
}
timer.track(
&format!("git show {}", args.join(" ")),
&format!("rtk git show {}", args.join(" ")),
&raw_output,
&final_output,
);
Ok(0)
}
fn is_blob_show_arg(arg: &str) -> bool {
!arg.starts_with('-') && arg.contains(':')
}
pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String {
let mut result = Vec::new();
let mut current_file = String::new();
let mut added = 0;
let mut removed = 0;
let mut in_hunk = false;
let mut hunk_shown = 0;
let mut hunk_skipped = 0usize;
let max_hunk_lines = 100;
let mut was_truncated = false;
for line in diff.lines() {
if line.starts_with("diff --git") {
if hunk_skipped > 0 {
result.push(format!(" ... ({} lines truncated)", hunk_skipped));
was_truncated = true;
hunk_skipped = 0;
}
if !current_file.is_empty() && (added > 0 || removed > 0) {
result.push(format!(" +{} -{}", added, removed));
}
current_file = line.split(" b/").nth(1).unwrap_or("unknown").to_string();
result.push(format!("\n{}", current_file));
added = 0;
removed = 0;
in_hunk = false;
hunk_shown = 0;
} else if line.starts_with("@@") {
if hunk_skipped > 0 {
result.push(format!(" ... ({} lines truncated)", hunk_skipped));
was_truncated = true;
hunk_skipped = 0;
}
in_hunk = true;
hunk_shown = 0;
result.push(format!(" {}", line));
} else if in_hunk {
if line.starts_with('+') && !line.starts_with("+++") {
added += 1;
if hunk_shown < max_hunk_lines {
result.push(format!(" {}", line));
hunk_shown += 1;
} else {
hunk_skipped += 1;
}
} else if line.starts_with('-') && !line.starts_with("---") {
removed += 1;
if hunk_shown < max_hunk_lines {
result.push(format!(" {}", line));
hunk_shown += 1;
} else {
hunk_skipped += 1;
}
} else if hunk_shown < max_hunk_lines && !line.starts_with("\\") {
if hunk_shown > 0 {
result.push(format!(" {}", line));
hunk_shown += 1;
}
}
}
if result.len() >= max_lines {
result.push("\n... (more changes truncated)".to_string());
was_truncated = true;
break;
}
}
if hunk_skipped > 0 {
result.push(format!(" ... ({} lines truncated)", hunk_skipped));
was_truncated = true;
}
if !current_file.is_empty() && (added > 0 || removed > 0) {
result.push(format!(" +{} -{}", added, removed));
}
if was_truncated {
result.push("[full diff: rtk git diff --no-compact]".to_string());
}
result.join("\n")
}
fn run_log(
args: &[String],
_max_lines: Option<usize>,
verbose: u8,
global_args: &[String],
) -> Result<i32> {
let timer = tracking::TimedExecution::start();
let mut cmd = git_cmd(global_args);
cmd.arg("log");
let has_format_flag = args.iter().any(|arg| {
arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format")
});
let has_limit_flag = args.iter().any(|arg| {
(arg.starts_with('-') && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
|| arg == "-n"
|| arg.starts_with("--max-count")
});
if !has_format_flag {
cmd.args(["--pretty=format:%h %s (%ar) <%an>%n%b%n---END---"]);
}
let (limit, user_set_limit) = if has_limit_flag {
let n = parse_user_limit(args).unwrap_or(10);
(n, true)
} else if has_format_flag {
cmd.arg("-50");
(50, false)
} else {
cmd.arg("-10");
(10, false)
};
let wants_merges = args
.iter()
.any(|arg| arg == "--merges" || arg == "--min-parents=2" || arg == "--no-merges");
if !wants_merges && !has_limit_flag {
cmd.arg("--no-merges");
}
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git log")?;
if !result.success() {
eprintln!("{}", result.stderr);
return Ok(result.exit_code);
}
if verbose > 0 {
eprintln!("Git log output:");
}
let filtered = filter_log_output(&result.stdout, limit, user_set_limit, has_format_flag);
println!("{}", filtered);
timer.track(
&format!("git log {}", args.join(" ")),
&format!("rtk git log {}", args.join(" ")),
&result.stdout,
&filtered,
);
Ok(0)
}
fn parse_user_limit(args: &[String]) -> Option<usize> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg.starts_with('-')
&& arg.len() > 1
&& arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
{
if let Ok(n) = arg[1..].parse::<usize>() {
return Some(n);
}
}
if arg == "-n" {
if let Some(next) = iter.next() {
if let Ok(n) = next.parse::<usize>() {
return Some(n);
}
}
}
if let Some(rest) = arg.strip_prefix("--max-count=") {
if let Ok(n) = rest.parse::<usize>() {
return Some(n);
}
}
if arg == "--max-count" {
if let Some(next) = iter.next() {
if let Ok(n) = next.parse::<usize>() {
return Some(n);
}
}
}
}
None
}
pub(crate) fn filter_log_output(
output: &str,
limit: usize,
user_set_limit: bool,
user_format: bool,
) -> String {
let truncate_width = if user_set_limit { 120 } else { 80 };
if user_format {
let lines: Vec<&str> = output.lines().collect();
let max_lines = if user_set_limit { lines.len() } else { limit };
return lines
.iter()
.take(max_lines)
.map(|l| truncate_line(l, truncate_width))
.collect::<Vec<_>>()
.join("\n");
}
let commits: Vec<&str> = output.split("---END---").collect();
let max_commits = if user_set_limit { commits.len() } else { limit };
let mut result = Vec::new();
for block in commits.iter().take(max_commits) {
let block = block.trim();
if block.is_empty() {
continue;
}
let mut lines = block.lines();
let header = match lines.next() {
Some(h) => truncate_line(h.trim(), truncate_width),
None => continue,
};
let all_body_lines: Vec<&str> = lines
.map(|l| l.trim())
.filter(|l| {
!l.is_empty()
&& !l.starts_with("Signed-off-by:")
&& !l.starts_with("Co-authored-by:")
})
.collect();
let body_omitted = all_body_lines.len().saturating_sub(3);
let body_lines = &all_body_lines[..all_body_lines.len().min(3)];
if body_lines.is_empty() {
result.push(header);
} else {
let mut entry = header;
for body in body_lines {
entry.push_str(&format!("\n {}", truncate_line(body, truncate_width)));
}
if body_omitted > 0 {
entry.push_str(&format!("\n [+{} lines omitted]", body_omitted));
}
result.push(entry);
}
}
result.join("\n").trim().to_string()
}
fn truncate_line(line: &str, width: usize) -> String {
if line.chars().count() > width {
let truncated: String = line.chars().take(width - 3).collect();
format!("{}...", truncated)
} else {
line.to_string()
}
}
pub(crate) fn format_status_output(porcelain: &str) -> String {
format_status_inner(porcelain, None)
}
pub(crate) fn format_status_output_detached(porcelain: &str, detached_ref: &str) -> String {
format_status_inner(porcelain, Some(detached_ref))
}
fn format_status_inner(porcelain: &str, detached: Option<&str>) -> String {
let lines: Vec<&str> = porcelain
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
if lines.is_empty() {
return "Clean working tree".to_string();
}
let mut output = Vec::new();
if let Some(branch_line) = lines.first() {
if branch_line.starts_with("##") {
let branch = branch_line.trim_start_matches("## ");
let display = detached.unwrap_or(branch);
output.push(format!("* {}", display));
} else {
output.push((*branch_line).to_string());
}
}
for line in lines.iter().skip(1) {
output.push((*line).to_string());
}
if lines.len() == 1 && lines[0].starts_with("##") {
output.push("clean — nothing to commit".to_string());
}
output.join("\n")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GitStatusState {
Rebase,
MergeConflicts,
MergeReadyToCommit,
CherryPick,
Revert,
Bisect,
Am,
SparseCheckout,
}
impl GitStatusState {
fn summary(self) -> &'static str {
match self {
Self::Rebase => "rebase in progress",
Self::MergeConflicts => "merge in progress. unresolved conflicts",
Self::MergeReadyToCommit => "merge in progress. no conflicts",
Self::CherryPick => "cherry-pick in progress",
Self::Revert => "revert in progress",
Self::Bisect => "bisect in progress",
Self::Am => "am session in progress",
Self::SparseCheckout => "sparse checkout enabled",
}
}
}
const REBASE_INDICATORS: &[&str] = &[
"rebase in progress",
"You are currently rebasing",
"You are currently editing",
"You are currently splitting",
"Last command done",
"Next command to do",
"No commands remaining",
];
fn detect_status_state(line: &str) -> Option<GitStatusState> {
if line.contains("All conflicts fixed but you are still merging") {
Some(GitStatusState::MergeReadyToCommit)
} else if line.contains("You have unmerged paths") {
Some(GitStatusState::MergeConflicts)
} else if line.contains("You are currently cherry-picking") {
Some(GitStatusState::CherryPick)
} else if line.contains("You are currently reverting") {
Some(GitStatusState::Revert)
} else if line.contains("You are currently bisecting") {
Some(GitStatusState::Bisect)
} else if line.contains("You are in the middle of an am session") {
Some(GitStatusState::Am)
} else if line.contains("You are in a sparse checkout") {
Some(GitStatusState::SparseCheckout)
} else if REBASE_INDICATORS.iter().any(|i| line.contains(i)) {
Some(GitStatusState::Rebase)
} else {
None
}
}
fn extract_state_header(raw: &str) -> Option<String> {
const STOPPERS: &[&str] = &[
"Changes to be committed:",
"Changes not staged for commit:",
"Untracked files:",
"Unmerged paths:",
"no changes added to commit",
"nothing to commit",
"nothing added to commit",
];
for line in raw.lines() {
let stripped = line.trim();
if STOPPERS.iter().any(|s| stripped.starts_with(s)) {
break;
}
if let Some(state) = detect_status_state(stripped) {
return Some(state.summary().to_string());
}
}
None
}
fn extract_detached_head(raw: &str) -> Option<String> {
raw.lines()
.map(str::trim)
.find(|l| l.starts_with("HEAD detached "))
.map(str::to_string)
}
fn filter_status_with_args(output: &str) -> String {
let mut result = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("(use \"git")
|| trimmed.starts_with("(create/copy files")
|| trimmed.contains("(use \"git add")
|| trimmed.contains("(use \"git restore")
{
continue;
}
if trimmed.contains("nothing to commit") && trimmed.contains("working tree clean") {
result.push(trimmed.to_string());
break;
}
result.push(line.to_string());
}
if result.is_empty() {
"ok".to_string()
} else {
result.join("\n")
}
}
fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if !uses_compact_status_path(args) {
let mut cmd = build_status_command(args, global_args);
let result = exec_capture(&mut cmd).context("Failed to run git status")?;
if !result.success() {
if !result.stderr.trim().is_empty() {
eprint!("{}", result.stderr);
}
timer.track(
&format!("git status {}", args.join(" ")),
&format!("rtk git status {}", args.join(" ")),
&result.stdout,
&result.stdout,
);
return Ok(result.exit_code);
}
if verbose > 0 || !result.stderr.is_empty() {
eprint!("{}", result.stderr);
}
let filtered = filter_status_with_args(&result.stdout);
print!("{}", filtered);
timer.track(
&format!("git status {}", args.join(" ")),
&format!("rtk git status {}", args.join(" ")),
&result.stdout,
&filtered,
);
return Ok(0);
}
let mut raw_cmd = git_cmd_c_locale(global_args);
raw_cmd.arg("status");
raw_cmd.args(args);
let raw_output = exec_capture(&mut raw_cmd)
.map(|r| r.stdout)
.unwrap_or_default();
let mut cmd = build_status_command(args, global_args);
let result = exec_capture(&mut cmd).context("Failed to run git status")?;
if !result.stderr.is_empty() && result.stderr.contains("not a git repository") {
let message = "Not a git repository".to_string();
eprintln!("{}", message);
let original_cmd = if args.is_empty() {
"git status".to_string()
} else {
format!("git status {}", args.join(" "))
};
let rtk_cmd = if args.is_empty() {
"rtk git status".to_string()
} else {
format!("rtk git status {}", args.join(" "))
};
timer.track(&original_cmd, &rtk_cmd, &raw_output, &message);
return Ok(result.exit_code);
}
let formatted = match extract_detached_head(&raw_output) {
Some(detached_ref) => format_status_output_detached(&result.stdout, &detached_ref),
None => format_status_output(&result.stdout),
};
let final_output = match extract_state_header(&raw_output) {
Some(state) => format!("{}\n{}", state, formatted),
None => formatted,
};
println!("{}", final_output);
let original_cmd = if args.is_empty() {
"git status".to_string()
} else {
format!("git status {}", args.join(" "))
};
let rtk_cmd = if args.is_empty() {
"rtk git status".to_string()
} else {
format!("rtk git status {}", args.join(" "))
};
timer.track(&original_cmd, &rtk_cmd, &raw_output, &final_output);
Ok(0)
}
fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
let mut cmd = git_cmd(global_args);
cmd.arg("add");
if args.is_empty() {
cmd.arg(".");
} else {
for arg in args {
cmd.arg(arg);
}
}
let result = exec_capture(&mut cmd).context("Failed to run git add")?;
if verbose > 0 {
eprintln!("git add executed");
}
let raw_output = format!("{}\n{}", result.stdout, result.stderr);
if result.success() {
let mut stat_cmd = git_cmd(global_args);
stat_cmd.args(["diff", "--cached", "--stat", "--shortstat"]);
let stat_result = exec_capture(&mut stat_cmd).context("Failed to check staged files")?;
let compact = if stat_result.stdout.trim().is_empty() {
String::new()
} else {
let short = stat_result.stdout.lines().last().unwrap_or("").trim();
if short.is_empty() {
"ok".to_string()
} else {
format!("ok {}", short)
}
};
if !compact.is_empty() {
println!("{}", compact);
}
timer.track(
&format!("git add {}", args.join(" ")),
&format!("rtk git add {}", args.join(" ")),
&raw_output,
&compact,
);
} else {
eprintln!("FAILED: git add");
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
if !result.stdout.trim().is_empty() {
eprintln!("{}", result.stdout);
}
return Ok(result.exit_code);
}
Ok(0)
}
fn build_commit_command(args: &[String], global_args: &[String]) -> Command {
let mut cmd = git_cmd(global_args);
cmd.arg("commit");
for arg in args {
cmd.arg(arg);
}
cmd
}
fn parse_commit_output(line: &str) -> String {
if let Some(bracket_end) = line.find(']') {
let bracket_content = &line[1..bracket_end];
let hash = bracket_content.split_whitespace().next_back().unwrap_or("");
if !hash.is_empty() && hash.len() >= 7 {
let short_hash: String = hash.chars().take(7).collect();
format!("ok {}", short_hash)
} else {
"ok".to_string()
}
} else {
"ok".to_string()
}
}
fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
let original_cmd = format!("git commit {}", args.join(" "));
if verbose > 0 {
eprintln!("{}", original_cmd);
}
let output = build_commit_command(args, global_args)
.stdin(Stdio::inherit())
.output()
.context("Failed to run git commit")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = exit_code_from_output(&output, "git commit");
let raw_output = format!("{}\n{}", stdout, stderr);
if output.status.success() {
let compact = if let Some(line) = stdout.lines().next() {
parse_commit_output(line)
} else {
"ok".to_string()
};
println!("{}", compact);
timer.track(&original_cmd, "rtk git commit", &raw_output, &compact);
} else if stderr.contains("nothing to commit") || stdout.contains("nothing to commit") {
println!("ok (nothing to commit)");
timer.track(
&original_cmd,
"rtk git commit",
&raw_output,
"ok (nothing to commit)",
);
} else {
if !stderr.trim().is_empty() {
eprint!("{}", stderr);
}
if !stdout.trim().is_empty() {
eprint!("{}", stdout);
}
timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output);
return Ok(exit_code);
}
Ok(0)
}
const GIT_PUSH_NOISE_PREFIXES: &[&str] = &[
"Enumerating objects:",
"Counting objects:",
"Compressing objects:",
"Writing objects:",
"Delta compression using",
"Total ",
];
#[derive(Default)]
struct GitPushLineHandler {
up_to_date: bool,
pushed_ref: Option<String>,
}
impl LineHandler for GitPushLineHandler {
fn should_skip(&mut self, line: &str) -> bool {
if line.is_empty() {
return true;
}
let trimmed = line.trim_start();
GIT_PUSH_NOISE_PREFIXES
.iter()
.any(|p| trimmed.starts_with(p))
}
fn observe_line(&mut self, line: &str) {
if line.contains("Everything up-to-date") {
self.up_to_date = true;
}
if self.pushed_ref.is_none() {
if let Some(idx) = line.find(" -> ") {
let after = &line[idx + 4..];
if let Some(dest) = after.split_whitespace().next() {
self.pushed_ref = Some(dest.to_string());
}
}
}
}
fn format_summary(&self, exit_code: i32, _raw: &str) -> Option<String> {
if exit_code != 0 {
return None;
}
let summary = if self.up_to_date {
"ok (up-to-date)".to_string()
} else if let Some(dest) = &self.pushed_ref {
format!("ok {}", dest)
} else {
"ok".to_string()
};
Some(format!("{}\n", summary))
}
}
fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
eprintln!("git push");
}
let mut cmd = git_cmd(global_args);
cmd.arg("push");
for arg in args {
cmd.arg(arg);
}
let cmd_label = format!("git push {}", args.join(" "));
let filter = LineStreamFilter::new(GitPushLineHandler::default());
let result = stream::run_streaming(
&mut cmd,
StdinMode::Inherit,
FilterMode::Streaming(Box::new(filter)),
)
.context("Failed to run git push")?;
timer.track(
&cmd_label,
&format!("rtk {}", cmd_label),
&result.raw,
&result.filtered,
);
Ok(result.exit_code)
}
fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
eprintln!("git pull");
}
let mut cmd = git_cmd(global_args);
cmd.arg("pull");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git pull")?;
let raw_output = format!("{}\n{}", result.stdout, result.stderr);
if result.success() {
let compact = if result.stdout.contains("Already up to date")
|| result.stdout.contains("Already up-to-date")
{
"ok (up-to-date)".to_string()
} else {
let mut files = 0;
let mut insertions = 0;
let mut deletions = 0;
for line in result.stdout.lines() {
if line.contains("file") && line.contains("changed") {
for part in line.split(',') {
let part = part.trim();
if part.contains("file") {
files = part
.split_whitespace()
.next()
.and_then(|n| n.parse().ok())
.unwrap_or(0);
} else if part.contains("insertion") {
insertions = part
.split_whitespace()
.next()
.and_then(|n| n.parse().ok())
.unwrap_or(0);
} else if part.contains("deletion") {
deletions = part
.split_whitespace()
.next()
.and_then(|n| n.parse().ok())
.unwrap_or(0);
}
}
}
}
if files > 0 {
format!("ok {} files +{} -{}", files, insertions, deletions)
} else {
"ok".to_string()
}
};
println!("{}", compact);
timer.track(
&format!("git pull {}", args.join(" ")),
&format!("rtk git pull {}", args.join(" ")),
&raw_output,
&compact,
);
} else {
eprintln!("FAILED: git pull");
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
if !result.stdout.trim().is_empty() {
eprintln!("{}", result.stdout);
}
return Ok(result.exit_code);
}
Ok(0)
}
fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
eprintln!("git branch");
}
let has_action_flag = args.iter().any(|a| {
a == "-d"
|| a == "-D"
|| a == "-m"
|| a == "-M"
|| a == "-c"
|| a == "-C"
|| a == "--set-upstream-to"
|| a.starts_with("--set-upstream-to=")
|| a == "-u"
|| a == "--unset-upstream"
|| a == "--edit-description"
});
let has_show_flag = args.iter().any(|a| a == "--show-current");
let has_list_flag = args.iter().any(|a| {
a == "-a"
|| a == "--all"
|| a == "-r"
|| a == "--remotes"
|| a == "--list"
|| a == "--merged"
|| a == "--no-merged"
|| a == "--contains"
|| a == "--no-contains"
|| a == "--format"
|| a.starts_with("--format=")
|| a == "--sort"
|| a.starts_with("--sort=")
|| a == "--points-at"
|| a.starts_with("--points-at=")
});
let has_positional_arg = args.iter().any(|a| !a.starts_with('-'));
if has_show_flag {
let mut cmd = git_cmd(global_args);
cmd.arg("branch");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git branch")?;
let combined = result.combined();
let trimmed = result.stdout.trim();
timer.track(
&format!("git branch {}", args.join(" ")),
&format!("rtk git branch {}", args.join(" ")),
&combined,
trimmed,
);
if result.success() {
println!("{}", trimmed);
} else {
eprintln!("FAILED: git branch {}", args.join(" "));
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
return Ok(result.exit_code);
}
return Ok(0);
}
if has_action_flag || (has_positional_arg && !has_list_flag) {
let mut cmd = git_cmd(global_args);
cmd.arg("branch");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git branch")?;
let combined = result.combined();
let msg = if result.success() { "ok" } else { &combined };
timer.track(
&format!("git branch {}", args.join(" ")),
&format!("rtk git branch {}", args.join(" ")),
&combined,
msg,
);
if result.success() {
println!("ok");
} else {
eprintln!("FAILED: git branch {}", args.join(" "));
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
if !result.stdout.trim().is_empty() {
eprintln!("{}", result.stdout);
}
return Ok(result.exit_code);
}
return Ok(0);
}
let mut cmd = git_cmd(global_args);
cmd.arg("branch");
if !has_list_flag {
cmd.arg("-a");
}
cmd.arg("--no-color");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git branch")?;
if !result.success() {
if !result.stderr.trim().is_empty() {
eprint!("{}", result.stderr);
}
timer.track(
&format!("git branch {}", args.join(" ")),
&format!("rtk git branch {}", args.join(" ")),
&result.stdout,
&result.stdout,
);
return Ok(result.exit_code);
}
let filtered = filter_branch_output(&result.stdout);
println!("{}", filtered);
timer.track(
&format!("git branch {}", args.join(" ")),
&format!("rtk git branch {}", args.join(" ")),
&result.stdout,
&filtered,
);
Ok(0)
}
fn filter_branch_output(output: &str) -> String {
let mut current = String::new();
let mut local: Vec<String> = Vec::new();
let mut remote: Vec<String> = Vec::new();
let mut seen_remote: std::collections::HashSet<String> = std::collections::HashSet::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(branch) = line.strip_prefix("* ") {
current = branch.to_string();
} else if let Some(rest) = line.strip_prefix("remotes/") {
if let Some(slash_pos) = rest.find('/') {
let branch = &rest[slash_pos + 1..];
if branch.starts_with("HEAD ") {
continue;
}
if seen_remote.insert(branch.to_string()) {
remote.push(branch.to_string());
}
}
} else {
local.push(line.to_string());
}
}
let mut result = Vec::new();
result.push(format!("* {}", current));
if !local.is_empty() {
for b in &local {
result.push(format!(" {}", b));
}
}
if !remote.is_empty() {
let remote_only: Vec<&String> = remote
.iter()
.filter(|r| *r != ¤t && !local.contains(r))
.collect();
if !remote_only.is_empty() {
const MAX_REMOTE_BRANCHES: usize = CAP_WARNINGS;
result.push(format!(" remote-only ({}):", remote_only.len()));
for b in remote_only.iter().take(MAX_REMOTE_BRANCHES) {
result.push(format!(" {}", b));
}
if remote_only.len() > MAX_REMOTE_BRANCHES {
result.push(format!(
" ... +{} more",
remote_only.len() - MAX_REMOTE_BRANCHES
));
}
}
}
result.join("\n")
}
fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
eprintln!("git fetch");
}
let mut cmd = git_cmd(global_args);
cmd.arg("fetch");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git fetch")?;
let raw = result.combined();
if !result.success() {
eprintln!("FAILED: git fetch");
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
return Ok(result.exit_code);
}
let new_refs: usize = result
.stderr
.lines()
.filter(|l| l.contains("->") || l.contains("[new"))
.count();
let msg = if new_refs > 0 {
format!("ok fetched ({} new refs)", new_refs)
} else {
"ok fetched".to_string()
};
println!("{}", msg);
timer.track("git fetch", "rtk git fetch", &raw, &msg);
Ok(0)
}
fn format_stash_message(subcommand: Option<&str>, result: &CaptureResult) -> String {
match subcommand {
None | Some("push") | Some("save") => {
if result.combined().contains("No local changes") {
"No local changes to save".to_string()
} else {
"ok stashed".to_string()
}
}
Some(sub) => format!("ok stash {}", sub),
}
}
fn run_stash(
subcommand: Option<&str>,
args: &[String],
verbose: u8,
global_args: &[String],
) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
eprintln!("git stash {:?}", subcommand);
}
match subcommand {
Some("list") => {
let mut cmd = git_cmd(global_args);
cmd.args(["stash", "list"]);
let result = exec_capture(&mut cmd).context("Failed to run git stash list")?;
if result.stdout.trim().is_empty() {
let msg = "No stashes";
println!("{}", msg);
timer.track("git stash list", "rtk git stash list", &result.stdout, msg);
return Ok(0);
}
let filtered = filter_stash_list(&result.stdout);
println!("{}", filtered);
timer.track(
"git stash list",
"rtk git stash list",
&result.stdout,
&filtered,
);
}
Some("show") => {
let mut cmd = git_cmd(global_args);
cmd.args(["stash", "show", "-p"]);
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git stash show")?;
let filtered = if result.stdout.trim().is_empty() {
let msg = "Empty stash";
println!("{}", msg);
msg.to_string()
} else {
let compacted = compact_diff(&result.stdout, 100);
println!("{}", compacted);
compacted
};
timer.track(
"git stash show",
"rtk git stash show",
&result.stdout,
&filtered,
);
}
Some("apply") | Some("branch") | Some("clear") | Some("create") | Some("drop")
| Some("export") | Some("import") | Some("pop") | Some("store") => {
let sub = subcommand.unwrap();
let mut cmd = git_cmd(global_args);
cmd.args(["stash", sub]);
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git stash")?;
let combined = result.combined();
let msg = if result.success() {
let msg = format_stash_message(subcommand, &result);
println!("{}", msg);
msg
} else {
eprintln!("FAILED: git stash {}", sub);
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
combined.clone()
};
timer.track(
&format!("git stash {}", sub),
&format!("rtk git stash {}", sub),
&combined,
&msg,
);
if !result.success() {
return Ok(result.exit_code);
}
}
Some(_) | None => {
let (sub, arg) = match subcommand {
Some("save") => ("save", None),
Some("push") => ("push", None),
Some(s) => ("push", Some(s)),
None => ("push", None),
};
let mut cmd = git_cmd(global_args);
cmd.args(["stash", sub]);
if let Some(arg) = arg {
cmd.arg(arg);
}
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git stash")?;
let combined = result.combined();
let msg = if result.success() {
let msg = format_stash_message(subcommand, &result);
println!("{}", msg);
msg
} else {
eprintln!("FAILED: git stash {}", sub);
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
combined.clone()
};
timer.track(
&format!("git stash {}", sub),
&format!("rtk git stash {}", sub),
&combined,
&msg,
);
if !result.success() {
return Ok(result.exit_code);
}
}
}
Ok(0)
}
fn filter_stash_list(output: &str) -> String {
let mut result = Vec::new();
for line in output.lines() {
if let Some(colon_pos) = line.find(": ") {
let index = &line[..colon_pos];
let rest = &line[colon_pos + 2..];
let message = if let Some(second_colon) = rest.find(": ") {
rest[second_colon + 2..].trim()
} else {
rest.trim()
};
result.push(format!("{}: {}", index, message));
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
eprintln!("git worktree list");
}
let has_action = args.iter().any(|a| {
a == "add" || a == "remove" || a == "prune" || a == "lock" || a == "unlock" || a == "move"
});
if has_action {
let mut cmd = git_cmd(global_args);
cmd.arg("worktree");
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git worktree")?;
let combined = result.combined();
let msg = if result.success() { "ok" } else { &combined };
timer.track(
&format!("git worktree {}", args.join(" ")),
&format!("rtk git worktree {}", args.join(" ")),
&combined,
msg,
);
if result.success() {
println!("ok");
} else {
eprintln!("FAILED: git worktree {}", args.join(" "));
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
return Ok(result.exit_code);
}
return Ok(0);
}
let mut cmd = git_cmd(global_args);
cmd.args(["worktree", "list"]);
let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?;
let filtered = filter_worktree_list(&result.stdout);
println!("{}", filtered);
timer.track(
"git worktree list",
"rtk git worktree",
&result.stdout,
&filtered,
);
Ok(0)
}
fn filter_worktree_list(output: &str) -> String {
let home = dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
let mut result = Vec::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
let mut path = parts[0].to_string();
if !home.is_empty() && path.starts_with(&home) {
path = format!("~{}", &path[home.len()..]);
}
let hash = parts[1];
let branch = parts[2..].join(" ");
result.push(format!("{} {} {}", path, hash, branch));
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
pub fn run_passthrough(args: &[OsString], global_args: &[String], verbose: u8) -> Result<i32> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
eprintln!("git passthrough: {:?}", args);
}
let status = git_cmd(global_args)
.args(args)
.status()
.context("Failed to run git")?;
let args_str = tracking::args_display(args);
timer.track_passthrough(
&format!("git {}", args_str),
&format!("rtk git {} (passthrough)", args_str),
);
if !status.success() {
return Ok(exit_code_from_status(&status, "git"));
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_git_cmd_no_global_args() {
let cmd = git_cmd(&[]);
let program = cmd.get_program().to_string_lossy().to_string();
let basename = std::path::Path::new(&program)
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
assert_eq!(basename, "git");
let args: Vec<_> = cmd.get_args().collect();
assert!(args.is_empty());
}
#[test]
fn test_git_cmd_with_directory() {
let global_args = vec!["-C".to_string(), "/tmp".to_string()];
let cmd = git_cmd(&global_args);
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, vec!["-C", "/tmp"]);
}
#[test]
fn test_git_cmd_with_multiple_global_args() {
let global_args = vec![
"-C".to_string(),
"/tmp".to_string(),
"-c".to_string(),
"user.name=test".to_string(),
"--git-dir".to_string(),
"/foo/.git".to_string(),
];
let cmd = git_cmd(&global_args);
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(
args,
vec![
"-C",
"/tmp",
"-c",
"user.name=test",
"--git-dir",
"/foo/.git"
]
);
}
#[test]
fn test_git_cmd_with_boolean_flags() {
let global_args = vec!["--no-pager".to_string(), "--bare".to_string()];
let cmd = git_cmd(&global_args);
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, vec!["--no-pager", "--bare"]);
}
#[test]
fn test_git_cmd_c_locale_sets_stable_env() {
let cmd = git_cmd_c_locale(&[]);
let envs: Vec<_> = cmd
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.expect("env value").to_string_lossy().to_string(),
)
})
.collect();
assert!(envs.contains(&("LC_ALL".to_string(), "C".to_string())));
}
#[test]
fn test_build_status_command_default_compact() {
let cmd = build_status_command(&[], &[]);
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, vec!["status", "--porcelain", "-b"]);
}
#[test]
fn test_uses_compact_status_path_for_branch_and_short_flags() {
assert!(uses_compact_status_path(&["-b".to_string()]));
assert!(uses_compact_status_path(&["--branch".to_string()]));
assert!(uses_compact_status_path(&["-sb".to_string()]));
assert!(uses_compact_status_path(&[
"-s".to_string(),
"-b".to_string()
]));
assert!(uses_compact_status_path(&[
"--short".to_string(),
"--branch".to_string()
]));
assert!(!uses_compact_status_path(&["-s".to_string()]));
assert!(!uses_compact_status_path(&["--short".to_string()]));
assert!(!uses_compact_status_path(&["--porcelain".to_string()]));
assert!(!uses_compact_status_path(&["-uno".to_string()]));
}
#[test]
fn test_build_status_command_with_user_args_passthrough() {
let args = vec!["--short".to_string(), "--branch".to_string()];
let cmd = build_status_command(&args, &[]);
let cmd_args: Vec<_> = cmd.get_args().collect();
assert_eq!(cmd_args, vec!["status", "--porcelain", "-b"]);
}
#[test]
fn test_build_status_command_with_incompatible_user_args_passthrough() {
let args = vec!["--porcelain".to_string(), "-uno".to_string()];
let cmd = build_status_command(&args, &[]);
let cmd_args: Vec<_> = cmd.get_args().collect();
assert_eq!(cmd_args, vec!["status", "--porcelain", "-uno"]);
}
#[test]
fn test_compact_diff() {
let diff = r#"diff --git a/foo.rs b/foo.rs
--- a/foo.rs
+++ b/foo.rs
@@ -1,3 +1,4 @@
fn main() {
+ println!("hello");
}
"#;
let result = compact_diff(diff, 100);
assert!(result.contains("foo.rs"));
assert!(result.contains("+"));
}
#[test]
fn test_compact_diff_preserves_full_hunk_header_context() {
let diff = r#"diff --git a/foo.rs b/foo.rs
--- a/foo.rs
+++ b/foo.rs
@@ -10,3 +10,4 @@ fn important_context() {
fn main() {
+ println!("hello");
}
"#;
let result = compact_diff(diff, 100);
assert!(
result.contains("@@ -10,3 +10,4 @@ fn important_context() {"),
"Expected full hunk header with trailing context, got:\n{}",
result
);
}
#[test]
fn test_compact_diff_increased_hunk_limit() {
let mut diff =
"diff --git a/big.rs b/big.rs\n--- a/big.rs\n+++ b/big.rs\n@@ -1,25 +1,25 @@\n"
.to_string();
for i in 1..=25 {
diff.push_str(&format!("+line{}\n", i));
}
let result = compact_diff(&diff, 500);
assert!(
!result.contains("... (truncated)"),
"25 lines should not be truncated with max_hunk_lines=30"
);
assert!(result.contains("+line25"));
}
#[test]
fn test_compact_diff_increased_total_limit() {
let mut diff = String::new();
for f in 1..=5 {
diff.push_str(&format!("diff --git a/file{f}.rs b/file{f}.rs\n--- a/file{f}.rs\n+++ b/file{f}.rs\n@@ -1,20 +1,20 @@\n"));
for i in 1..=20 {
diff.push_str(&format!("+line{f}_{i}\n"));
}
}
let result = compact_diff(&diff, 500);
assert!(
!result.contains("more changes truncated"),
"5 files × 20 lines should not exceed max_lines=500"
);
}
#[test]
fn test_is_blob_show_arg() {
assert!(is_blob_show_arg("develop:modules/pairs_backtest.py"));
assert!(is_blob_show_arg("HEAD:src/main.rs"));
assert!(!is_blob_show_arg("--pretty=format:%h"));
assert!(!is_blob_show_arg("--format=short"));
assert!(!is_blob_show_arg("HEAD"));
}
#[test]
fn test_filter_branch_output() {
let output = "* main\n feature/auth\n fix/bug-123\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature/auth\n remotes/origin/release/v2\n";
let result = filter_branch_output(output);
assert!(result.contains("* main"));
assert!(result.contains("feature/auth"));
assert!(result.contains("fix/bug-123"));
assert!(result.contains("remote-only"));
assert!(result.contains("release/v2"));
}
#[test]
fn test_filter_branch_no_remotes() {
let output = "* main\n develop\n";
let result = filter_branch_output(output);
assert!(result.contains("* main"));
assert!(result.contains("develop"));
assert!(!result.contains("remote-only"));
}
#[test]
fn test_filter_branch_multi_remote() {
let output = "* main\n develop\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature-x\n remotes/upstream/main\n remotes/upstream/release-v3\n remotes/fork/main\n remotes/fork/experiment\n";
let result = filter_branch_output(output);
assert!(result.contains("* main"));
assert!(result.contains("develop"));
assert!(
result.contains("feature-x"),
"origin branch shown: {}",
result
);
assert!(
result.contains("release-v3"),
"upstream branch shown: {}",
result
);
assert!(
result.contains("experiment"),
"fork branch shown: {}",
result
);
assert!(
!result.contains("remotes/"),
"remote prefix stripped: {}",
result
);
let main_count = result.matches("main").count();
assert!(
main_count <= 2,
"main deduplicated across remotes (found {} occurrences): {}",
main_count,
result
);
}
#[test]
fn test_filter_stash_list() {
let output =
"stash@{0}: WIP on main: abc1234 fix login\nstash@{1}: On feature: def5678 wip\n";
let result = filter_stash_list(output);
assert!(result.contains("stash@{0}: abc1234 fix login"));
assert!(result.contains("stash@{1}: def5678 wip"));
}
#[test]
fn test_filter_worktree_list() {
let output =
"/home/user/project abc1234 [main]\n/home/user/worktrees/feat def5678 [feature]\n";
let result = filter_worktree_list(output);
assert!(result.contains("abc1234"));
assert!(result.contains("[main]"));
assert!(result.contains("[feature]"));
}
#[test]
fn test_format_status_output_clean() {
let porcelain = "## main...origin/main\n";
let result = format_status_output(porcelain);
assert_eq!(result, "* main...origin/main\nclean — nothing to commit");
}
#[test]
fn test_extract_state_header_clean_returns_none() {
let raw = "On branch main\nYour branch is up to date with 'origin/main'.\n\nnothing to commit, working tree clean\n";
assert_eq!(extract_state_header(raw), None);
}
#[test]
fn test_extract_state_header_no_state_with_changes_returns_none() {
let raw = "On branch main\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n\tmodified: src/main.rs\n\nno changes added to commit\n";
assert_eq!(extract_state_header(raw), None);
}
#[test]
fn test_extract_state_header_editing_while_rebasing() {
let raw = "On branch feature\n\ninteractive rebase in progress; onto abc1234\nLast command done (1 command done):\n edit abc123 some message\nNo commands remaining.\nYou are currently editing a commit while rebasing branch 'feature' on 'abc1234'.\n (use \"git commit --amend\" to amend the current commit)\n (use \"git rebase --continue\" once you are satisfied with your changes)\n\nnothing to commit, working tree clean\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "rebase in progress");
}
#[test]
fn test_extract_state_header_merge_unresolved() {
let raw = "On branch main\nYou have unmerged paths.\n (fix conflicts and run \"git commit\")\n (use \"git merge --abort\" to abort the merge)\n\nUnmerged paths:\n\tboth modified: src/main.rs\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "merge in progress. unresolved conflicts");
}
#[test]
fn test_extract_state_header_cherry_pick() {
let raw = "On branch main\n\nYou are currently cherry-picking commit abc1234.\n (fix conflicts and run \"git cherry-pick --continue\")\n (use \"git cherry-pick --abort\" to cancel the cherry-pick operation)\n\nnothing to commit, working tree clean\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "cherry-pick in progress");
}
#[test]
fn test_extract_state_header_bisect() {
let raw = "On branch main\n\nYou are currently bisecting, started from branch 'main'.\n (use \"git bisect reset\" to get back to the original branch)\n\nnothing to commit, working tree clean\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "bisect in progress");
}
#[test]
fn test_extract_state_header_revert() {
let raw = "On branch main\n\nYou are currently reverting commit abc1234.\n (fix conflicts and run \"git revert --continue\")\n (use \"git revert --abort\" to cancel the revert operation)\n\nnothing to commit, working tree clean\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "revert in progress");
}
#[test]
fn test_extract_state_header_merge_in_middle() {
let raw = "On branch main\n\nAll conflicts fixed but you are still merging.\n (use \"git commit\" to conclude merge)\n\nChanges to be committed:\n\tmodified: src/main.rs\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "merge in progress. no conflicts");
}
#[test]
fn test_extract_state_header_am_session() {
let raw = "On branch main\n\nYou are in the middle of an am session.\n (use \"git am --continue\" to continue)\n (use \"git am --abort\" to restore the original branch)\n\nnothing to commit, working tree clean\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "am session in progress");
}
#[test]
fn test_extract_state_header_sparse_checkout() {
let raw = "On branch main\n\nYou are in a sparse checkout with 17% of tracked files present.\n\nnothing to commit, working tree clean\n";
let out = extract_state_header(raw).expect("state expected");
assert_eq!(out, "sparse checkout enabled");
}
#[test]
fn test_format_status_output_preserves_nested_untracked_paths() {
let porcelain = "## main\n?? tmp/c.txt\n?? tmp/nested/d.txt\n";
let result = format_status_output(porcelain);
assert!(result.contains("* main"));
assert!(result.contains("?? tmp/c.txt"));
assert!(result.contains("?? tmp/nested/d.txt"));
assert!(
result.lines().all(|line| line != "?? tmp/"),
"Nested untracked files must not collapse back to a directory marker:\n{}",
result
);
}
#[test]
fn test_format_status_output_mixed_changes() {
let porcelain = r#"## main
M staged.rs
M modified.rs
A added.rs
?? untracked.txt
"#;
let result = format_status_output(porcelain);
assert!(result.contains("* main"));
assert!(result.contains("M staged.rs"));
assert!(result.contains(" M modified.rs"));
assert!(result.contains("A added.rs"));
assert!(result.contains("?? untracked.txt"));
assert!(!result.contains("Staged"));
assert!(!result.contains("Modified"));
assert!(!result.contains("Untracked"));
}
#[test]
fn test_format_status_output_preserves_rename_and_conflict_lines() {
let porcelain = "## main\nR old.rs -> new.rs\nUU conflict.rs\nMM mixed.rs\n";
let result = format_status_output(porcelain);
assert!(result.contains("* main"));
assert!(result.contains("R old.rs -> new.rs"));
assert!(result.contains("UU conflict.rs"));
assert!(result.contains("MM mixed.rs"));
assert!(!result.contains("conflicts:"));
}
#[test]
fn test_run_passthrough_accepts_args() {
let _args: Vec<OsString> = vec![OsString::from("tag"), OsString::from("--list")];
}
#[test]
fn test_filter_log_output() {
let output = "abc1234 This is a commit message (2 days ago) <author>\n\n---END---\ndef5678 Another commit (1 week ago) <other>\n\n---END---\n";
let result = filter_log_output(output, 10, false, false);
assert!(result.contains("abc1234"));
assert!(result.contains("def5678"));
assert_eq!(result.lines().count(), 2);
}
#[test]
fn test_filter_log_output_with_body() {
let output = "abc1234 feat: add feature (2 days ago) <author>\nBREAKING CHANGE: removed old API\nSigned-off-by: Author <a@b.com>\n---END---\ndef5678 fix: typo (1 day ago) <other>\n\n---END---\n";
let result = filter_log_output(output, 10, false, false);
assert!(result.contains("abc1234"));
assert!(result.contains("BREAKING CHANGE: removed old API"));
assert!(!result.contains("Signed-off-by:"));
assert!(result.contains("def5678"));
assert_eq!(result.lines().count(), 3);
}
#[test]
fn test_filter_log_output_skips_trailers() {
let output = "abc1234 chore: bump (1 day ago) <bot>\nSigned-off-by: Bot <bot@ci>\nCo-authored-by: Human <h@b>\n---END---\n";
let result = filter_log_output(output, 10, false, false);
assert!(result.contains("abc1234"));
assert!(!result.contains("Signed-off-by:"));
assert!(!result.contains("Co-authored-by:"));
assert_eq!(result.lines().count(), 1);
}
#[test]
fn test_filter_log_output_truncate_long() {
let long_line = "abc1234 ".to_string() + &"x".repeat(100) + " (2 days ago) <author>";
let result = filter_log_output(&long_line, 10, false, false);
assert!(result.chars().count() < long_line.chars().count());
assert!(result.contains("..."));
assert!(result.chars().count() <= 80);
}
#[test]
fn test_filter_log_output_cap_lines() {
let output = (0..20)
.map(|i| format!("hash{} message {} (1 day ago) <author>\n\n---END---", i, i))
.collect::<Vec<_>>()
.join("\n");
let result = filter_log_output(&output, 5, false, false);
assert_eq!(result.lines().count(), 5);
}
#[test]
fn test_filter_log_output_user_limit_no_cap() {
let output = (0..20)
.map(|i| format!("hash{} message {} (1 day ago) <author>\n\n---END---", i, i))
.collect::<Vec<_>>()
.join("\n");
let result = filter_log_output(&output, 20, true, false);
assert_eq!(
result.lines().count(),
20,
"User's -20 should return all 20 lines"
);
}
#[test]
fn test_filter_log_output_user_limit_wider_truncation() {
let line_90_chars = format!("abc1234 {} (2 days ago) <author>", "x".repeat(60));
assert!(line_90_chars.chars().count() > 80);
assert!(line_90_chars.chars().count() < 120);
let result_default = filter_log_output(&line_90_chars, 10, false, false);
let result_user = filter_log_output(&line_90_chars, 10, true, false);
assert!(
result_default.contains("..."),
"Default should truncate at 80 chars"
);
assert!(
!result_user.contains("..."),
"User limit should not truncate 90-char line"
);
}
#[test]
fn test_parse_user_limit_combined() {
let args: Vec<String> = vec!["-20".into()];
assert_eq!(parse_user_limit(&args), Some(20));
}
#[test]
fn test_parse_user_limit_n_space() {
let args: Vec<String> = vec!["-n".into(), "15".into()];
assert_eq!(parse_user_limit(&args), Some(15));
}
#[test]
fn test_parse_user_limit_max_count_eq() {
let args: Vec<String> = vec!["--max-count=30".into()];
assert_eq!(parse_user_limit(&args), Some(30));
}
#[test]
fn test_parse_user_limit_max_count_space() {
let args: Vec<String> = vec!["--max-count".into(), "25".into()];
assert_eq!(parse_user_limit(&args), Some(25));
}
#[test]
fn test_parse_user_limit_none() {
let args: Vec<String> = vec!["--oneline".into()];
assert_eq!(parse_user_limit(&args), None);
}
#[test]
fn test_filter_log_output_token_savings() {
fn count_tokens(text: &str) -> usize {
text.split_whitespace().count()
}
let input = (0..20)
.map(|i| {
format!(
"commit abc123{:02x}\nAuthor: User Name <user@example.com>\nDate: Mon Mar 10 10:00:00 2026 +0000\n\n fix: commit message number {}\n\n Extended body with details about the change.\n",
i, i
)
})
.collect::<Vec<_>>()
.join("\n");
let output = filter_log_output(&input, 10, false, false);
let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(&input) as f64 * 100.0);
assert!(
savings >= 60.0,
"Expected ≥60% token savings, got {:.1}%",
savings
);
}
#[test]
fn test_filter_status_with_args() {
let output = r#"On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/main.rs
no changes added to commit (use "git add" and/or "git commit -a")
"#;
let result = filter_status_with_args(output);
eprintln!("Result:\n{}", result);
assert!(result.contains("On branch main"));
assert!(result.contains("modified: src/main.rs"));
assert!(
!result.contains("(use \"git"),
"Result should not contain git hints"
);
}
#[test]
fn test_filter_status_with_args_clean() {
let output = "nothing to commit, working tree clean\n";
let result = filter_status_with_args(output);
assert!(result.contains("nothing to commit"));
}
#[test]
fn test_filter_log_output_multibyte() {
let thai_msg = format!("abc1234 {} (2 days ago) <author>", "ก".repeat(30));
let result = filter_log_output(&thai_msg, 10, false, false);
assert!(result.contains("abc1234"));
assert!(result.contains("abc1234"));
}
#[test]
fn test_filter_log_output_emoji() {
let emoji_msg = "abc1234 🎉🎊🎈🎁🎂🎄🎃🎆🎇✨🎉🎊🎈🎁🎂🎄🎃🎆🎇✨ (1 day ago) <user>";
let result = filter_log_output(emoji_msg, 10, false, false);
assert!(result.contains("abc1234"));
}
#[test]
fn test_format_status_output_thai_filename() {
let porcelain = "## main\n M สวัสดี.txt\n?? ทดสอบ.rs\n";
let result = format_status_output(porcelain);
assert!(result.contains("* main"));
assert!(result.contains("สวัสดี.txt"));
assert!(result.contains("ทดสอบ.rs"));
}
#[test]
fn test_format_status_output_emoji_filename() {
let porcelain = "## main\nA 🎉-party.txt\n M 日本語ファイル.rs\n";
let result = format_status_output(porcelain);
assert!(result.contains("* main"));
}
#[test]
fn test_parse_commit_output_normal() {
let line = "[main abc1234def] add feature";
assert_eq!(parse_commit_output(line), "ok abc1234");
}
#[test]
fn test_parse_commit_output_root_commit() {
let line = "[main (root-commit) abc1234def] initial commit";
assert_eq!(parse_commit_output(line), "ok abc1234");
}
#[test]
fn test_parse_commit_output_multibyte_branch() {
let line = "[分支名 abc1234def] 提交消息";
assert_eq!(parse_commit_output(line), "ok abc1234");
}
#[test]
fn test_parse_commit_output_thai_branch() {
let line = "[สาขา abc1234def] commit message";
assert_eq!(parse_commit_output(line), "ok abc1234");
}
#[test]
fn test_parse_commit_output_no_bracket() {
let line = "some other output";
assert_eq!(parse_commit_output(line), "ok");
}
#[test]
fn test_parse_commit_output_short_hash() {
let line = "[main abc12] message";
assert_eq!(parse_commit_output(line), "ok");
}
#[test]
fn test_parse_commit_output_empty() {
assert_eq!(parse_commit_output(""), "ok");
}
#[test]
fn test_filter_log_output_user_format_oneline() {
let oneline_output = "abc1234 feat: add feature\n\
def5678 fix: typo\n\
ghi9012 chore: bump deps\n\
jkl3456 docs: update readme\n\
mno7890 test: add tests\n";
let result = filter_log_output(oneline_output, 10, false, true);
assert_eq!(result.lines().count(), 5);
assert!(result.contains("abc1234"));
assert!(result.contains("mno7890"));
}
#[test]
fn test_filter_log_output_user_format_with_limit() {
let oneline_output = "abc1234 feat: add feature\n\
def5678 fix: typo\n\
ghi9012 chore: bump deps\n\
jkl3456 docs: update readme\n\
mno7890 test: add tests\n";
let result = filter_log_output(oneline_output, 3, true, true);
assert_eq!(result.lines().count(), 5);
let result = filter_log_output(oneline_output, 3, false, true);
assert_eq!(result.lines().count(), 3);
}
#[test]
#[ignore] fn test_branch_creation_not_swallowed() {
let branch = "test-rtk-create-branch-regression";
run_branch(&[branch.to_string()], 0, &[]).expect("run_branch should succeed");
let output = Command::new("git")
.args(["branch", "--list", branch])
.output()
.expect("git branch --list should work");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(branch),
"Branch '{}' was not created. run_branch silently swallowed the creation.",
branch
);
let _ = Command::new("git").args(["branch", "-d", branch]).output();
}
#[test]
#[ignore] fn test_branch_creation_from_commit() {
let branch = "test-rtk-create-from-commit";
run_branch(&[branch.to_string(), "HEAD".to_string()], 0, &[])
.expect("run_branch with start-point should succeed");
let output = Command::new("git")
.args(["branch", "--list", branch])
.output()
.expect("git branch --list should work");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(branch),
"Branch '{}' was not created from commit.",
branch
);
let _ = Command::new("git").args(["branch", "-d", branch]).output();
}
#[test]
fn test_commit_single_message() {
let args = vec!["-m".to_string(), "fix: typo".to_string()];
let cmd = build_commit_command(&args, &[]);
let cmd_args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert_eq!(cmd_args, vec!["commit", "-m", "fix: typo"]);
}
#[test]
fn test_commit_multiple_messages() {
let args = vec![
"-m".to_string(),
"feat: add multi-paragraph support".to_string(),
"-m".to_string(),
"This allows git commit -m \"title\" -m \"body\".".to_string(),
];
let cmd = build_commit_command(&args, &[]);
let cmd_args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert_eq!(
cmd_args,
vec![
"commit",
"-m",
"feat: add multi-paragraph support",
"-m",
"This allows git commit -m \"title\" -m \"body\"."
]
);
}
#[test]
fn test_commit_am_flag() {
let args = vec!["-am".to_string(), "quick fix".to_string()];
let cmd = build_commit_command(&args, &[]);
let cmd_args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert_eq!(cmd_args, vec!["commit", "-am", "quick fix"]);
}
#[test]
fn test_commit_amend() {
let args = vec![
"--amend".to_string(),
"-m".to_string(),
"new msg".to_string(),
];
let cmd = build_commit_command(&args, &[]);
let cmd_args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert_eq!(cmd_args, vec!["commit", "--amend", "-m", "new msg"]);
}
#[test]
#[ignore] fn test_git_status_not_a_repo_exits_nonzero() {
let tmp = std::env::temp_dir().join("rtk_test_not_a_repo");
let _ = std::fs::create_dir_all(&tmp);
let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("debug")
.join("rtk");
assert!(
bin_path.exists(),
"Debug binary not found at {:?} — run `cargo build` first",
bin_path
);
let output = std::process::Command::new(&bin_path)
.args(["git", "status"])
.current_dir(&tmp)
.output()
.expect("Failed to run rtk");
assert!(
!output.status.success(),
"Expected non-zero exit code for git status outside a repo, got {:?}",
output.status.code()
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stderr.to_lowercase().contains("not a git repository"),
"Expected 'not a git repository' on stderr, got stderr={:?}, stdout={:?}",
stderr,
stdout
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_format_status_output_shows_every_file_when_many_are_dirty() {
let mut porcelain = String::from("## main...origin/main\n");
for i in 0..25 {
porcelain.push_str(&format!("M staged_file_{}.rs\n", i));
}
let result = format_status_output(&porcelain);
assert!(
result.contains("staged_file_24.rs"),
"Expected the last staged file to remain visible, got:\n{}",
result
);
assert!(
result.lines().count() == 26,
"Expected branch + all 25 staged files, got:\n{}",
result
);
assert!(
!result.contains("... +"),
"Status output must not hide dirty paths behind overflow markers:\n{}",
result
);
}
#[test]
fn test_compact_diff_recovery_hint_present() {
let mut diff = String::new();
diff.push_str("diff --git a/large.rs b/large.rs\n");
diff.push_str("--- a/large.rs\n");
diff.push_str("+++ b/large.rs\n");
diff.push_str("@@ -1,150 +1,150 @@\n");
for i in 0..110 {
diff.push_str(&format!("+added line {}\n", i));
}
let result = compact_diff(&diff, 500);
assert!(
result.contains("[full diff: rtk git diff --no-compact]"),
"Expected recovery hint when hunk is truncated, got:\n{}",
result
);
}
#[test]
fn test_compact_diff_hunk_truncation_count_accurate() {
let mut diff = String::from(
"diff --git a/large.rs b/large.rs\n--- a/large.rs\n+++ b/large.rs\n@@ -1,150 +1,150 @@\n",
);
for i in 0..150 {
diff.push_str(&format!("+line {}\n", i));
}
let result = compact_diff(&diff, 500);
assert!(
result.contains("50 lines truncated"),
"Expected '50 lines truncated' (150 - 100 = 50), got:\n{}",
result
);
}
#[test]
fn test_extract_detached_head_returns_line() {
let raw = "HEAD detached at abc1234\nnothing to commit, working tree clean\n";
assert_eq!(
extract_detached_head(raw),
Some("HEAD detached at abc1234".to_string())
);
}
#[test]
fn test_extract_detached_head_on_branch_is_none() {
let raw = "On branch main\nnothing to commit, working tree clean\n";
assert!(extract_detached_head(raw).is_none());
}
#[test]
fn test_format_status_output_detached_head() {
let porcelain = "## HEAD (no branch)\n M src/main.rs\n";
let result = format_status_output_detached(porcelain, "HEAD detached at abc1234");
assert!(
result.contains("HEAD detached at abc1234"),
"should use explicit detached ref, got: {result}"
);
assert!(
!result.contains("HEAD (no branch)"),
"should not show opaque porcelain string, got: {result}"
);
}
#[test]
fn test_filter_log_output_body_omission_indicator() {
let body_lines = (1..=6)
.map(|i| format!("body line {}", i))
.collect::<Vec<_>>()
.join("\n");
let output = format!(
"abc1234 feat: big change (1 day ago) <author>\n{}\n---END---\n",
body_lines
);
let result = filter_log_output(&output, 10, false, false);
assert!(
result.contains("+3 lines omitted"),
"Expected '+3 lines omitted' when 6 body lines truncated to 3, got:\n{}",
result
);
}
fn run_push_filter(input: &str, exit_code: i32) -> String {
use crate::core::stream::StreamFilter;
let mut f = LineStreamFilter::new(GitPushLineHandler::default());
let mut out = String::new();
for line in input.lines() {
if let Some(s) = f.feed_line(line) {
out.push_str(&s);
}
}
out.push_str(&f.flush());
if let Some(s) = f.on_exit(exit_code, input) {
out.push_str(&s);
}
out
}
#[test]
fn test_push_filter_drops_progress_phases() {
let input = "\
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 8 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 312 bytes | 312.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
To https://github.com/foo/bar.git
abc1234..def5678 master -> master
";
let result = run_push_filter(input, 0);
for prefix in GIT_PUSH_NOISE_PREFIXES {
assert!(
!result.contains(prefix),
"noise prefix '{}' leaked through, got: {}",
prefix,
result
);
}
assert!(result.contains("To https://github.com/foo/bar.git"));
assert!(result.contains("master -> master"));
assert!(result.ends_with("ok master\n"), "got: {}", result);
}
#[test]
fn test_push_filter_up_to_date_summary() {
let input = "Everything up-to-date\n";
let result = run_push_filter(input, 0);
assert!(result.contains("Everything up-to-date"));
assert!(result.ends_with("ok (up-to-date)\n"), "got: {}", result);
}
#[test]
fn test_push_filter_passes_remote_messages_through() {
let input = "\
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
remote: GitHub found 1 vulnerability on foo/bar's default branch (1 moderate).
To https://github.com/foo/bar.git
abc1234..def5678 feature -> feature
";
let result = run_push_filter(input, 0);
assert!(result.contains("remote: Resolving deltas"));
assert!(result.contains("remote: GitHub found 1 vulnerability"));
assert!(result.ends_with("ok feature\n"), "got: {}", result);
}
#[test]
fn test_push_filter_no_summary_on_failure() {
let input = "\
To https://github.com/foo/bar.git
! [rejected] master -> master (non-fast-forward)
error: failed to push some refs to 'https://github.com/foo/bar.git'
";
let result = run_push_filter(input, 1);
assert!(result.contains("[rejected]"));
assert!(result.contains("error: failed to push"));
assert!(
!result.contains("ok "),
"summary leaked on failure, got: {}",
result
);
}
#[test]
fn test_push_filter_first_ref_wins_for_summary() {
let input = "\
To https://github.com/foo/bar.git
abc1234..def5678 feat/a -> feat/a
1111111..2222222 feat/b -> feat/b
";
let result = run_push_filter(input, 0);
assert!(result.ends_with("ok feat/a\n"), "got: {}", result);
}
#[test]
fn test_push_filter_token_savings_on_verbose_output() {
let input = "\
Enumerating objects: 142, done.
Counting objects: 100% (142/142), done.
Delta compression using up to 8 threads
Compressing objects: 100% (88/88), done.
Writing objects: 100% (104/104), 28.50 KiB | 14.25 MiB/s, done.
Total 104 (delta 64), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (64/64), completed with 24 local objects.
To https://github.com/foo/bar.git
abc1234..def5678 master -> master
";
let result = run_push_filter(input, 0);
let count_tokens = |s: &str| s.split_whitespace().count();
let input_tokens = count_tokens(input);
let output_tokens = count_tokens(&result);
let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0);
assert!(
savings >= 60.0,
"expected >=60% savings, got {:.1}% (in={}, out={})",
savings,
input_tokens,
output_tokens
);
}
}