mirror of
https://github.com/rtk-ai/rtk.git
synced 2026-09-19 07:33:17 +08:00
13188a88b2
Branch names containing '/' (e.g. feature/user-auth, main...feature/auth) were incorrectly treated as file paths by the string heuristic in normalize_diff_args, causing '--' to be injected before them and making git treat them as pathspecs — resulting in silent empty output (exit 0). Replace looks_like_path() with a three-tier detection strategy: - Explicit path prefixes (. ~) → always a path, no filesystem check needed - Contains path separator (/ \) → use filesystem existence to distinguish branch names (feature/auth, not on disk) from real paths (src/main.rs) - Bare word with no separator → never inject '--', regardless of filesystem state (avoids misfire when a file shares a name with a branch/ref) Introduce normalize_diff_args_impl with injectable path-checker for testability. Update all existing tests to use mock existence checks. Add three regression tests: branch-with-slash, range-with-slash, and bare-word-that-exists-on-disk. Fixes: #1431 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2606 lines
85 KiB
Rust
2606 lines
85 KiB
Rust
//! Filters git output — log, status, diff, and more — keeping just the essential info.
|
||
|
||
use crate::core::config;
|
||
use crate::core::stream::exec_capture;
|
||
use crate::core::tracking;
|
||
use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command};
|
||
use std::process::Stdio;
|
||
use anyhow::{Context, Result};
|
||
use std::ffi::OsString;
|
||
use std::process::Command;
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum GitCommand {
|
||
Diff,
|
||
Log,
|
||
Status,
|
||
Show,
|
||
Add,
|
||
Commit,
|
||
Push,
|
||
Pull,
|
||
Branch,
|
||
Fetch,
|
||
Stash { subcommand: Option<String> },
|
||
Worktree,
|
||
}
|
||
|
||
/// Create a git Command with global options (e.g. -C, -c, --git-dir, --work-tree)
|
||
/// prepended before any subcommand arguments.
|
||
fn git_cmd(global_args: &[String]) -> Command {
|
||
let mut cmd = resolved_command("git");
|
||
for arg in global_args {
|
||
cmd.arg(arg);
|
||
}
|
||
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),
|
||
}
|
||
}
|
||
|
||
/// Re-insert `--` before the first path-like argument when clap has consumed it.
|
||
///
|
||
/// clap's `trailing_var_arg = true` silently drops `--` when it appears as the
|
||
/// first positional argument (before any other positional). This means:
|
||
/// `rtk git diff -- file` → args = ["file"] (clap ate `--`)
|
||
/// `rtk git diff HEAD -- file` → args = ["HEAD", "--", "file"] (preserved)
|
||
///
|
||
/// Without the `--` separator git may treat an unambiguous path as a revision and
|
||
/// emit "fatal: ambiguous argument". We re-insert `--` before the first path-like
|
||
/// argument; see `normalize_diff_args_impl` for the detection rules.
|
||
fn normalize_diff_args(args: &[String]) -> Vec<String> {
|
||
normalize_diff_args_impl(args, |p| std::path::Path::new(p).exists())
|
||
}
|
||
|
||
/// Testable core of `normalize_diff_args` — accepts an injectable filesystem existence checker.
|
||
///
|
||
/// The path-detection logic is:
|
||
/// 1. Explicit path prefixes (`.`, `~`) → always a path, no filesystem check needed.
|
||
/// 2. Contains path separator (`/`, `\`) → use `path_exists` to distinguish branch names
|
||
/// (e.g. `feature/auth`) from real paths (e.g. `src/main.rs`).
|
||
/// 3. Bare word with no separator → never a path (avoids injecting `--` when a file
|
||
/// happens to share a name with a branch or ref, e.g. a file named `main`).
|
||
fn normalize_diff_args_impl<F>(args: &[String], path_exists: F) -> Vec<String>
|
||
where
|
||
F: Fn(&str) -> bool,
|
||
{
|
||
// Already has `--` — nothing to do
|
||
if args.iter().any(|a| a == "--") {
|
||
return args.to_vec();
|
||
}
|
||
let path_start = args.iter().position(|arg| {
|
||
if arg.starts_with('-') {
|
||
return false;
|
||
}
|
||
// Explicit path prefixes — always treat as path regardless of existence
|
||
if arg.starts_with('.') || arg.starts_with('~') {
|
||
return true;
|
||
}
|
||
// Contains path separator — use filesystem check to distinguish
|
||
// branch names (feature/auth) from real paths (src/main.rs)
|
||
if arg.contains('/') || arg.contains('\\') {
|
||
return path_exists(arg);
|
||
}
|
||
// Bare word (no separator, no special prefix) — never inject `--`
|
||
// This avoids misidentifying a ref/branch as a path even if a same-named
|
||
// file happens to exist on disk.
|
||
false
|
||
});
|
||
match path_start {
|
||
Some(idx) => {
|
||
let mut out = args[..idx].to_vec();
|
||
out.push("--".to_string());
|
||
out.extend_from_slice(&args[idx..]);
|
||
out
|
||
}
|
||
None => args.to_vec(),
|
||
}
|
||
}
|
||
|
||
fn run_diff(
|
||
args: &[String],
|
||
max_lines: Option<usize>,
|
||
verbose: u8,
|
||
global_args: &[String],
|
||
) -> Result<i32> {
|
||
let timer = tracking::TimedExecution::start();
|
||
|
||
// Re-insert `--` when clap's trailing_var_arg consumed it (issue #1215)
|
||
let args = &normalize_diff_args(args);
|
||
|
||
// Check if user wants stat output
|
||
let wants_stat = args
|
||
.iter()
|
||
.any(|arg| arg == "--stat" || arg == "--numstat" || arg == "--shortstat");
|
||
|
||
// Check if user wants compact diff (default RTK behavior)
|
||
let wants_compact = !args.iter().any(|arg| arg == "--no-compact");
|
||
|
||
if wants_stat || !wants_compact {
|
||
// User wants stat or explicitly no compacting - pass through directly
|
||
let mut cmd = git_cmd(global_args);
|
||
cmd.arg("diff");
|
||
for arg in args {
|
||
if arg == "--no-compact" {
|
||
continue; // RTK flag, not a git flag
|
||
}
|
||
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);
|
||
}
|
||
|
||
// Default RTK behavior: stat first, then compacted diff
|
||
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:");
|
||
}
|
||
|
||
// Print stat summary first
|
||
println!("{}", result.stdout.trim());
|
||
|
||
// Now get actual diff but compact it
|
||
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!("\n--- Changes ---");
|
||
let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500));
|
||
println!("{}", compacted);
|
||
final_output.push_str("\n--- Changes ---\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();
|
||
|
||
// If user wants --stat or --format only, pass through
|
||
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"));
|
||
|
||
// `git show rev:path` prints a blob, not a commit diff. In this mode we should
|
||
// pass through directly to avoid duplicated output from compact-show steps.
|
||
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);
|
||
}
|
||
|
||
// Get raw output for tracking
|
||
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();
|
||
|
||
// Step 1: one-line commit summary
|
||
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());
|
||
|
||
// Step 2: --stat summary
|
||
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);
|
||
}
|
||
|
||
// Step 3: compacted diff
|
||
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!("\n--- Changes ---");
|
||
}
|
||
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 {
|
||
// Detect `rev:path` style arguments while ignoring flags like `--pretty=format:...`.
|
||
!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") {
|
||
// Flush hunk truncation before starting a new file
|
||
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("@@") {
|
||
// Flush hunk truncation before starting a new hunk
|
||
if hunk_skipped > 0 {
|
||
result.push(format!(" ... ({} lines truncated)", hunk_skipped));
|
||
was_truncated = true;
|
||
hunk_skipped = 0;
|
||
}
|
||
in_hunk = true;
|
||
hunk_shown = 0;
|
||
// Preserve the full unified diff hunk header, including trailing
|
||
// function / symbol context after the second @@ marker.
|
||
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("\\") {
|
||
// Context line
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Flush last hunk
|
||
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");
|
||
|
||
// Check if user provided format flags
|
||
let has_format_flag = args.iter().any(|arg| {
|
||
arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format")
|
||
});
|
||
|
||
// Check if user provided limit flag (-N, -n N, --max-count=N, --max-count N)
|
||
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")
|
||
});
|
||
|
||
// Apply RTK defaults only if user didn't specify them
|
||
// Use %b (body) to preserve first line of commit body for agent context
|
||
// (BREAKING CHANGE, Closes #xxx, design notes)
|
||
if !has_format_flag {
|
||
cmd.args(["--pretty=format:%h %s (%ar) <%an>%n%b%n---END---"]);
|
||
}
|
||
|
||
// Determine limit: respect user's explicit -N flag, use sensible defaults otherwise
|
||
let (limit, user_set_limit) = if has_limit_flag {
|
||
// User explicitly passed -N / -n N / --max-count=N → respect their choice
|
||
let n = parse_user_limit(args).unwrap_or(10);
|
||
(n, true)
|
||
} else if has_format_flag {
|
||
// --oneline / --pretty without -N: user wants compact output, allow more
|
||
cmd.arg("-50");
|
||
(50, false)
|
||
} else {
|
||
// No flags at all: default to 10
|
||
cmd.arg("-10");
|
||
(10, false)
|
||
};
|
||
|
||
// Only add --no-merges if user didn't explicitly request merge commits
|
||
let wants_merges = args
|
||
.iter()
|
||
.any(|arg| arg == "--merges" || arg == "--min-parents=2");
|
||
if !wants_merges {
|
||
cmd.arg("--no-merges");
|
||
}
|
||
|
||
// Pass all user arguments
|
||
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:");
|
||
}
|
||
|
||
// Post-process: truncate long messages, cap lines only if RTK set the default
|
||
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)
|
||
}
|
||
|
||
/// Filter git log output: truncate long messages, cap lines
|
||
/// Parse the user-specified limit from git log args.
|
||
/// Handles: -20, -n 20, --max-count=20, --max-count 20
|
||
fn parse_user_limit(args: &[String]) -> Option<usize> {
|
||
let mut iter = args.iter();
|
||
while let Some(arg) = iter.next() {
|
||
// -20 (combined digit form)
|
||
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);
|
||
}
|
||
}
|
||
// -n 20 (two-token form)
|
||
if arg == "-n" {
|
||
if let Some(next) = iter.next() {
|
||
if let Ok(n) = next.parse::<usize>() {
|
||
return Some(n);
|
||
}
|
||
}
|
||
}
|
||
// --max-count=20
|
||
if let Some(rest) = arg.strip_prefix("--max-count=") {
|
||
if let Ok(n) = rest.parse::<usize>() {
|
||
return Some(n);
|
||
}
|
||
}
|
||
// --max-count 20 (two-token form)
|
||
if arg == "--max-count" {
|
||
if let Some(next) = iter.next() {
|
||
if let Ok(n) = next.parse::<usize>() {
|
||
return Some(n);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// When `user_set_limit` is true, the user explicitly passed `-N` to git log,
|
||
/// so we skip line capping (git already returns exactly N commits) and use a
|
||
/// wider truncation threshold (120 chars) to preserve commit context that LLMs
|
||
/// need for rebase/squash operations.
|
||
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 };
|
||
|
||
// When user specified their own format (--oneline, --pretty, --format),
|
||
// RTK did not inject ---END--- markers. Use simple line-based truncation.
|
||
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");
|
||
}
|
||
|
||
// RTK injected format: split output into commit blocks separated by ---END---
|
||
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();
|
||
// First line is the header: hash subject (date) <author>
|
||
let header = match lines.next() {
|
||
Some(h) => truncate_line(h.trim(), truncate_width),
|
||
None => continue,
|
||
};
|
||
// Remaining lines are the body — keep up to 3 non-empty, non-trailer lines
|
||
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()
|
||
}
|
||
|
||
/// Truncate a single line to `width` characters, appending "..." if needed
|
||
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 {
|
||
let lines: Vec<&str> = porcelain.lines().collect();
|
||
|
||
if lines.is_empty() {
|
||
return "Clean working tree".to_string();
|
||
}
|
||
|
||
let mut output = String::new();
|
||
|
||
// Parse branch info
|
||
if let Some(branch_line) = lines.first() {
|
||
if branch_line.starts_with("##") {
|
||
let branch = branch_line.trim_start_matches("## ");
|
||
output.push_str(&format!("* {}\n", branch));
|
||
}
|
||
}
|
||
|
||
// Count changes by type
|
||
let mut staged = 0;
|
||
let mut modified = 0;
|
||
let mut untracked = 0;
|
||
let mut conflicts = 0;
|
||
|
||
let mut staged_files = Vec::new();
|
||
let mut modified_files = Vec::new();
|
||
let mut untracked_files = Vec::new();
|
||
|
||
for line in lines.iter().skip(1) {
|
||
if line.len() < 3 {
|
||
continue;
|
||
}
|
||
let status = line.get(0..2).unwrap_or(" ");
|
||
let file = line.get(3..).unwrap_or("");
|
||
|
||
match status.chars().next().unwrap_or(' ') {
|
||
'M' | 'A' | 'D' | 'R' | 'C' => {
|
||
staged += 1;
|
||
staged_files.push(file);
|
||
}
|
||
'U' => conflicts += 1,
|
||
_ => {}
|
||
}
|
||
|
||
match status.chars().nth(1).unwrap_or(' ') {
|
||
'M' | 'D' => {
|
||
modified += 1;
|
||
modified_files.push(file);
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
if status == "??" {
|
||
untracked += 1;
|
||
untracked_files.push(file);
|
||
}
|
||
}
|
||
|
||
// Build summary
|
||
let limits = config::limits();
|
||
let max_files = limits.status_max_files;
|
||
let max_untracked = limits.status_max_untracked;
|
||
|
||
if staged > 0 {
|
||
output.push_str(&format!("+ Staged: {} files\n", staged));
|
||
for f in staged_files.iter().take(max_files) {
|
||
output.push_str(&format!(" {}\n", f));
|
||
}
|
||
if staged_files.len() > max_files {
|
||
output.push_str(&format!(
|
||
" ... +{} more\n",
|
||
staged_files.len() - max_files
|
||
));
|
||
}
|
||
}
|
||
|
||
if modified > 0 {
|
||
output.push_str(&format!("~ Modified: {} files\n", modified));
|
||
for f in modified_files.iter().take(max_files) {
|
||
output.push_str(&format!(" {}\n", f));
|
||
}
|
||
if modified_files.len() > max_files {
|
||
output.push_str(&format!(
|
||
" ... +{} more\n",
|
||
modified_files.len() - max_files
|
||
));
|
||
}
|
||
}
|
||
|
||
if untracked > 0 {
|
||
output.push_str(&format!("? Untracked: {} files\n", untracked));
|
||
for f in untracked_files.iter().take(max_untracked) {
|
||
output.push_str(&format!(" {}\n", f));
|
||
}
|
||
if untracked_files.len() > max_untracked {
|
||
output.push_str(&format!(
|
||
" ... +{} more\n",
|
||
untracked_files.len() - max_untracked
|
||
));
|
||
}
|
||
}
|
||
|
||
if conflicts > 0 {
|
||
output.push_str(&format!("conflicts: {} files\n", conflicts));
|
||
}
|
||
|
||
// When working tree is clean (only branch line, no changes)
|
||
if staged == 0 && modified == 0 && untracked == 0 && conflicts == 0 {
|
||
output.push_str("clean — nothing to commit\n");
|
||
}
|
||
|
||
output.trim_end().to_string()
|
||
}
|
||
|
||
/// Minimal filtering for git status with user-provided args
|
||
fn filter_status_with_args(output: &str) -> String {
|
||
let mut result = Vec::new();
|
||
|
||
for line in output.lines() {
|
||
let trimmed = line.trim();
|
||
|
||
// Skip empty lines
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Skip git hints - can appear at start or within line
|
||
if trimmed.starts_with("(use \"git")
|
||
|| trimmed.starts_with("(create/copy files")
|
||
|| trimmed.contains("(use \"git add")
|
||
|| trimmed.contains("(use \"git restore")
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// Special case: clean working tree
|
||
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 user provided flags, apply minimal filtering
|
||
if !args.is_empty() {
|
||
let mut cmd = git_cmd(global_args);
|
||
cmd.arg("status").args(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);
|
||
}
|
||
|
||
// Apply minimal filtering: strip ANSI, remove hints, empty lines
|
||
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);
|
||
}
|
||
|
||
// Default RTK compact mode (no args provided)
|
||
// Get raw git status for tracking
|
||
let mut raw_cmd = git_cmd(global_args);
|
||
raw_cmd.args(["status"]);
|
||
let raw_output = exec_capture(&mut raw_cmd)
|
||
.map(|r| r.stdout)
|
||
.unwrap_or_default();
|
||
|
||
let mut cmd = git_cmd(global_args);
|
||
cmd.args(["status", "--porcelain", "-b"]);
|
||
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);
|
||
timer.track("git status", "rtk git status", &raw_output, &message);
|
||
return Ok(result.exit_code);
|
||
}
|
||
|
||
let formatted = format_status_output(&result.stdout);
|
||
|
||
println!("{}", formatted);
|
||
|
||
// Track for statistics
|
||
timer.track("git status", "rtk git status", &raw_output, &formatted);
|
||
|
||
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");
|
||
|
||
// Pass all arguments directly to git (flags like -A, -p, --all, etc.)
|
||
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() {
|
||
// Count what was added
|
||
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() {
|
||
"ok (nothing to add)".to_string()
|
||
} else {
|
||
// Parse "1 file changed, 5 insertions(+)" format
|
||
let short = stat_result.stdout.lines().last().unwrap_or("").trim();
|
||
if short.is_empty() {
|
||
"ok".to_string()
|
||
} else {
|
||
format!("ok {}", short)
|
||
}
|
||
};
|
||
|
||
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 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() {
|
||
// Extract commit hash from output like "[main abc1234] message"
|
||
let compact = if let Some(line) = stdout.lines().next() {
|
||
if let Some(hash_start) = line.find(' ') {
|
||
let hash = line[1..hash_start].split(' ').next_back().unwrap_or("");
|
||
if !hash.is_empty() && hash.len() >= 7 {
|
||
format!("ok {}", &hash[..7.min(hash.len())])
|
||
} else {
|
||
"ok".to_string()
|
||
}
|
||
} else {
|
||
"ok".to_string()
|
||
}
|
||
} 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)
|
||
}
|
||
|
||
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 output = cmd.stdin(Stdio::inherit()).output().context("Failed to run git push")?;
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let raw = format!("{}{}", stdout, stderr);
|
||
|
||
if output.status.success() {
|
||
let compact = if stderr.contains("Everything up-to-date") {
|
||
"ok (up-to-date)".to_string()
|
||
} else {
|
||
let mut push_info = String::new();
|
||
for line in stderr.lines() {
|
||
if line.contains("->") {
|
||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||
if parts.len() >= 3 {
|
||
push_info = format!("ok {}", parts[parts.len() - 1]);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if !push_info.is_empty() {
|
||
push_info
|
||
} else {
|
||
"ok".to_string()
|
||
}
|
||
};
|
||
|
||
println!("{}", compact);
|
||
|
||
timer.track(
|
||
&format!("git push {}", args.join(" ")),
|
||
&format!("rtk git push {}", args.join(" ")),
|
||
&raw,
|
||
&compact,
|
||
);
|
||
} else {
|
||
eprintln!("FAILED: git push");
|
||
if !stderr.trim().is_empty() {
|
||
eprintln!("{}", stderr);
|
||
}
|
||
if !stdout.trim().is_empty() {
|
||
eprintln!("{}", stdout);
|
||
}
|
||
return Ok(exit_code_from_output(&output, "git push"));
|
||
}
|
||
|
||
Ok(0)
|
||
}
|
||
|
||
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 {
|
||
// Count files changed
|
||
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") {
|
||
// Parse "3 files changed, 10 insertions(+), 2 deletions(-)"
|
||
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");
|
||
}
|
||
|
||
// Detect write operations: delete, rename, copy, upstream tracking
|
||
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"
|
||
});
|
||
|
||
// Detect flags that produce specific output (not a branch list)
|
||
let has_show_flag = args.iter().any(|a| a == "--show-current");
|
||
|
||
// Detect list-mode flags
|
||
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=")
|
||
});
|
||
|
||
// Detect positional arguments (not flags) — indicates branch creation
|
||
let has_positional_arg = args.iter().any(|a| !a.starts_with('-'));
|
||
|
||
// --show-current: passthrough with raw stdout (not "ok")
|
||
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);
|
||
}
|
||
|
||
// Write operation: action flags, or positional args without list flags (= branch creation)
|
||
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);
|
||
}
|
||
|
||
// List mode: show compact branch list
|
||
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() {
|
||
result.push(format!(" remote-only ({}):", remote_only.len()));
|
||
for b in remote_only.iter().take(10) {
|
||
result.push(format!(" {}", b));
|
||
}
|
||
if remote_only.len() > 10 {
|
||
result.push(format!(" ... +{} more", remote_only.len() - 10));
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Count new refs from stderr (git fetch outputs to stderr)
|
||
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 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("pop") | Some("apply") | Some("drop") | Some("push") => {
|
||
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!("ok stash {}", sub);
|
||
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(sub) => {
|
||
// Unrecognized subcommand: passthrough to git stash <sub> [args]
|
||
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!("ok stash {}", sub);
|
||
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);
|
||
}
|
||
}
|
||
None => {
|
||
// Default: git stash (push)
|
||
let mut cmd = git_cmd(global_args);
|
||
cmd.arg("stash");
|
||
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() {
|
||
if result.stdout.contains("No local changes") {
|
||
let msg = "ok (nothing to stash)";
|
||
println!("{}", msg);
|
||
msg.to_string()
|
||
} else {
|
||
let msg = "ok stashed";
|
||
println!("{}", msg);
|
||
msg.to_string()
|
||
}
|
||
} else {
|
||
eprintln!("FAILED: git stash");
|
||
if !result.stderr.trim().is_empty() {
|
||
eprintln!("{}", result.stderr);
|
||
}
|
||
combined.clone()
|
||
};
|
||
|
||
timer.track("git stash", "rtk git stash", &combined, &msg);
|
||
|
||
if !result.success() {
|
||
return Ok(result.exit_code);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(0)
|
||
}
|
||
|
||
fn filter_stash_list(output: &str) -> String {
|
||
// Format: "stash@{0}: WIP on main: abc1234 commit message"
|
||
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..];
|
||
// Compact: strip "WIP on branch:" prefix if present
|
||
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");
|
||
}
|
||
|
||
// If args contain "add", "remove", "prune" etc., pass through
|
||
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);
|
||
}
|
||
|
||
// Default: list mode
|
||
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;
|
||
}
|
||
// Format: "/path/to/worktree abc1234 [branch]"
|
||
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")
|
||
}
|
||
|
||
/// Runs an unsupported git subcommand by passing it through directly
|
||
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();
|
||
// On Windows, resolved_command returns full path (e.g. "C:\Program Files\Git\bin\git.exe")
|
||
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_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() {
|
||
// Build a hunk with 25 changed lines — should NOT be truncated with limit 30
|
||
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() {
|
||
// Build a diff with 150 output result lines across multiple files — should NOT be cut at 100
|
||
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"
|
||
);
|
||
}
|
||
|
||
// ----- normalize_diff_args (issue #1215 + branch-name fix #1431) -----
|
||
//
|
||
// Tests use normalize_diff_args_impl with a mock path-existence checker so
|
||
// they don't depend on the real filesystem.
|
||
|
||
fn exists_mock<'a>(existing: &'a [&'a str]) -> impl Fn(&str) -> bool + 'a {
|
||
move |p| existing.contains(&p)
|
||
}
|
||
|
||
/// Baseline: `--` already present → no-op, args unchanged.
|
||
#[test]
|
||
fn test_normalize_diff_args_noop_when_separator_present() {
|
||
let args = vec![
|
||
"HEAD".to_string(),
|
||
"--".to_string(),
|
||
"src/main.rs".to_string(),
|
||
];
|
||
assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args);
|
||
}
|
||
|
||
/// Core regression (issue #1215): clap ate `--` before a real file path.
|
||
/// When the path exists on disk, `--` must be re-inserted.
|
||
#[test]
|
||
fn test_normalize_diff_args_reinserts_separator_before_existing_path() {
|
||
let args = vec!["apps/client/frontend/src/MyComponent.tsx".to_string()];
|
||
let normalized = normalize_diff_args_impl(
|
||
&args,
|
||
exists_mock(&["apps/client/frontend/src/MyComponent.tsx"]),
|
||
);
|
||
assert_eq!(
|
||
normalized,
|
||
vec![
|
||
"--".to_string(),
|
||
"apps/client/frontend/src/MyComponent.tsx".to_string()
|
||
],
|
||
"-- must be injected before an existing path"
|
||
);
|
||
}
|
||
|
||
/// Ref before path: ["HEAD", "src/foo.rs"] where src/foo.rs exists → inject after HEAD.
|
||
#[test]
|
||
fn test_normalize_diff_args_reinserts_separator_after_ref() {
|
||
let args = vec!["HEAD".to_string(), "src/foo.rs".to_string()];
|
||
let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"]));
|
||
assert_eq!(
|
||
normalized,
|
||
vec!["HEAD".to_string(), "--".to_string(), "src/foo.rs".to_string()]
|
||
);
|
||
}
|
||
|
||
/// Flags before path: ["--cached", "src/foo.rs"] where src/foo.rs exists.
|
||
#[test]
|
||
fn test_normalize_diff_args_reinserts_separator_after_flag() {
|
||
let args = vec!["--cached".to_string(), "src/foo.rs".to_string()];
|
||
let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"]));
|
||
assert_eq!(
|
||
normalized,
|
||
vec!["--cached".to_string(), "--".to_string(), "src/foo.rs".to_string()]
|
||
);
|
||
}
|
||
|
||
/// Pure flags (no paths) → no injection.
|
||
#[test]
|
||
fn test_normalize_diff_args_no_injection_for_pure_flags() {
|
||
let args = vec!["--stat".to_string(), "--cached".to_string()];
|
||
assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args);
|
||
}
|
||
|
||
/// Dotfile that exists on disk → inject `--`.
|
||
#[test]
|
||
fn test_normalize_diff_args_dotfile_is_path() {
|
||
let args = vec![".gitignore".to_string()];
|
||
let normalized = normalize_diff_args_impl(&args, exists_mock(&[".gitignore"]));
|
||
assert_eq!(
|
||
normalized,
|
||
vec!["--".to_string(), ".gitignore".to_string()]
|
||
);
|
||
}
|
||
|
||
/// A bare ref (HEAD) that doesn't exist as a file → no injection.
|
||
#[test]
|
||
fn test_normalize_diff_args_no_injection_for_bare_ref() {
|
||
let args = vec!["HEAD".to_string()];
|
||
assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args);
|
||
}
|
||
|
||
/// Branch name with `/` that does NOT exist as a file → no injection.
|
||
/// Regression for issue #1431: `rtk git diff feature/user-auth` must not inject `--`.
|
||
#[test]
|
||
fn test_normalize_diff_args_no_injection_for_branch_with_slash() {
|
||
let args = vec!["feature/user-auth".to_string()];
|
||
assert_eq!(
|
||
normalize_diff_args_impl(&args, exists_mock(&[])),
|
||
args,
|
||
"branch names containing '/' must not trigger -- injection"
|
||
);
|
||
}
|
||
|
||
/// Range syntax with `/` → no injection.
|
||
/// Regression: `rtk git diff main...feature/user-auth` produced no output.
|
||
#[test]
|
||
fn test_normalize_diff_args_no_injection_for_range_with_slash() {
|
||
let args = vec!["main...feature/user-auth".to_string()];
|
||
assert_eq!(
|
||
normalize_diff_args_impl(&args, exists_mock(&[])),
|
||
args,
|
||
"revision ranges like main...feature/user-auth must not trigger -- injection"
|
||
);
|
||
}
|
||
|
||
/// Bare word that happens to exist as a file on disk → still no injection.
|
||
/// A file named "main" must not cause `--` to be injected when the user
|
||
/// intends `rtk git diff main` as a branch comparison.
|
||
#[test]
|
||
fn test_normalize_diff_args_no_injection_for_bare_word_even_if_file_exists() {
|
||
let args = vec!["main".to_string()];
|
||
assert_eq!(
|
||
normalize_diff_args_impl(&args, exists_mock(&["main"])),
|
||
args,
|
||
"bare words must never trigger -- injection even when a same-named file exists"
|
||
);
|
||
}
|
||
|
||
#[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"));
|
||
// remote-only should show release/v2 but not main or feature/auth (already local)
|
||
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 = "";
|
||
let result = format_status_output(porcelain);
|
||
assert_eq!(result, "Clean working tree");
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_status_output_modified_files() {
|
||
let porcelain = "## main...origin/main\n M src/main.rs\n M src/lib.rs\n";
|
||
let result = format_status_output(porcelain);
|
||
assert!(result.contains("* main...origin/main"));
|
||
assert!(result.contains("~ Modified: 2 files"));
|
||
assert!(result.contains("src/main.rs"));
|
||
assert!(result.contains("src/lib.rs"));
|
||
assert!(!result.contains("Staged"));
|
||
assert!(!result.contains("Untracked"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_status_output_untracked_files() {
|
||
let porcelain = "## feature/new\n?? temp.txt\n?? debug.log\n?? test.sh\n";
|
||
let result = format_status_output(porcelain);
|
||
assert!(result.contains("* feature/new"));
|
||
assert!(result.contains("? Untracked: 3 files"));
|
||
assert!(result.contains("temp.txt"));
|
||
assert!(result.contains("debug.log"));
|
||
assert!(result.contains("test.sh"));
|
||
assert!(!result.contains("Modified"));
|
||
}
|
||
|
||
#[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("+ Staged: 2 files"));
|
||
assert!(result.contains("staged.rs"));
|
||
assert!(result.contains("added.rs"));
|
||
assert!(result.contains("~ Modified: 1 files"));
|
||
assert!(result.contains("modified.rs"));
|
||
assert!(result.contains("? Untracked: 1 files"));
|
||
assert!(result.contains("untracked.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_status_output_truncation() {
|
||
// Test that >15 staged files show "... +N more"
|
||
let mut porcelain = String::from("## main\n");
|
||
for i in 1..=20 {
|
||
porcelain.push_str(&format!("M file{}.rs\n", i));
|
||
}
|
||
let result = format_status_output(&porcelain);
|
||
assert!(result.contains("+ Staged: 20 files"));
|
||
assert!(result.contains("file1.rs"));
|
||
assert!(result.contains("file15.rs"));
|
||
assert!(result.contains("... +5 more"));
|
||
assert!(!result.contains("file16.rs"));
|
||
assert!(!result.contains("file20.rs"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_status_modified_truncation() {
|
||
// Test that >15 modified files show "... +N more"
|
||
let mut porcelain = String::from("## main\n");
|
||
for i in 1..=20 {
|
||
porcelain.push_str(&format!(" M file{}.rs\n", i));
|
||
}
|
||
let result = format_status_output(&porcelain);
|
||
assert!(result.contains("~ Modified: 20 files"));
|
||
assert!(result.contains("file1.rs"));
|
||
assert!(result.contains("file15.rs"));
|
||
assert!(result.contains("... +5 more"));
|
||
assert!(!result.contains("file16.rs"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_status_untracked_truncation() {
|
||
// Test that >10 untracked files show "... +N more"
|
||
let mut porcelain = String::from("## main\n");
|
||
for i in 1..=15 {
|
||
porcelain.push_str(&format!("?? file{}.rs\n", i));
|
||
}
|
||
let result = format_status_output(&porcelain);
|
||
assert!(result.contains("? Untracked: 15 files"));
|
||
assert!(result.contains("file1.rs"));
|
||
assert!(result.contains("file10.rs"));
|
||
assert!(result.contains("... +5 more"));
|
||
assert!(!result.contains("file11.rs"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_passthrough_accepts_args() {
|
||
// Test that run_passthrough compiles and has correct signature
|
||
let _args: Vec<OsString> = vec![OsString::from("tag"), OsString::from("--list")];
|
||
// Compile-time verification that the function exists with correct signature
|
||
}
|
||
|
||
#[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() {
|
||
// Commit with body: first non-trailer body line should appear indented
|
||
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:"));
|
||
// def5678 has no body — just header
|
||
assert!(result.contains("def5678"));
|
||
// 3 lines: header1, body1 indented, header2
|
||
assert_eq!(result.lines().count(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_log_output_skips_trailers() {
|
||
// Body with only trailers should not produce a body line
|
||
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() {
|
||
// When user explicitly passes -N, all N lines should be returned (no re-truncation)
|
||
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() {
|
||
// When user explicitly passes -N, lines up to 120 chars should NOT be truncated
|
||
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);
|
||
|
||
// Default truncates at 80 chars
|
||
assert!(
|
||
result_default.contains("..."),
|
||
"Default should truncate at 80 chars"
|
||
);
|
||
// User-set limit uses wider threshold (120 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()
|
||
}
|
||
// Simulate verbose git log output (default format with full metadata)
|
||
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() {
|
||
// Thai characters: each is 3 bytes. A line with >80 bytes but few chars
|
||
let thai_msg = format!("abc1234 {} (2 days ago) <author>", "ก".repeat(30));
|
||
let result = filter_log_output(&thai_msg, 10, false, false);
|
||
// Should not panic
|
||
assert!(result.contains("abc1234"));
|
||
// The line has 30 Thai chars + other text, so > 80 chars total
|
||
// truncate_line now counts chars, not bytes
|
||
// 30 Thai + ~33 other = 63 chars < 80 threshold, so no truncation
|
||
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);
|
||
// Should not panic
|
||
// 20 emoji + ~30 other chars = ~50 chars < 80, no truncation needed
|
||
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);
|
||
// Should not panic
|
||
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"));
|
||
}
|
||
|
||
/// Regression test: --oneline and other user format flags must preserve all commits.
|
||
/// Before fix, filter_log_output split on ---END--- which doesn't exist when
|
||
/// the user specifies their own format, resulting in only 2 commits surviving.
|
||
#[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);
|
||
// All 5 lines must survive — no ---END--- splitting
|
||
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";
|
||
|
||
// user_set_limit=true means respect all lines (no cap)
|
||
let result = filter_log_output(oneline_output, 3, true, true);
|
||
assert_eq!(result.lines().count(), 5);
|
||
|
||
// user_set_limit=false means cap at limit
|
||
let result = filter_log_output(oneline_output, 3, false, true);
|
||
assert_eq!(result.lines().count(), 3);
|
||
}
|
||
|
||
/// Regression test: `git branch <name>` must create, not list.
|
||
/// Before fix, positional args fell into list mode which added `-a`,
|
||
/// turning creation into a pattern-filtered listing (silent no-op).
|
||
#[test]
|
||
#[ignore] // Integration test: requires git repo
|
||
fn test_branch_creation_not_swallowed() {
|
||
let branch = "test-rtk-create-branch-regression";
|
||
// Create branch via run_branch
|
||
run_branch(&[branch.to_string()], 0, &[]).expect("run_branch should succeed");
|
||
// Verify it exists
|
||
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
|
||
);
|
||
// Cleanup
|
||
let _ = Command::new("git").args(["branch", "-d", branch]).output();
|
||
}
|
||
|
||
/// Regression test: `git branch <name> <commit>` must create from commit.
|
||
#[test]
|
||
#[ignore] // Integration test: requires git repo
|
||
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\"."
|
||
]
|
||
);
|
||
}
|
||
|
||
// #327: git commit -am "msg" must pass -am through to git
|
||
#[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] // Requires `cargo build` first — run with `cargo test --ignored`
|
||
fn test_git_status_not_a_repo_exits_nonzero() {
|
||
// Run rtk git status in a directory that is not a git repo
|
||
let tmp = std::env::temp_dir().join("rtk_test_not_a_repo");
|
||
let _ = std::fs::create_dir_all(&tmp);
|
||
|
||
// Build the path to the test binary
|
||
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");
|
||
|
||
// Should exit with non-zero (128 from git)
|
||
assert!(
|
||
!output.status.success(),
|
||
"Expected non-zero exit code for git status outside a repo, got {:?}",
|
||
output.status.code()
|
||
);
|
||
|
||
// Message should be on stderr, not stdout
|
||
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);
|
||
}
|
||
|
||
// --- truncation accuracy ---
|
||
|
||
#[test]
|
||
fn test_format_status_overflow_count_exact() {
|
||
// 25 staged files, default status_max_files = 15
|
||
// Should show 15, overflow = 25 - 15 = 10, report "+10 more"
|
||
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("+10 more"),
|
||
"Expected '+10 more' for 25 staged files (max_files=15), got:\n{}",
|
||
result
|
||
);
|
||
assert!(
|
||
result.contains("Staged: 25 files"),
|
||
"Expected 'Staged: 25 files', got:\n{}",
|
||
result
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_compact_diff_recovery_hint_present() {
|
||
// A hunk with 110 lines exceeds max_hunk_lines (100), triggers truncation
|
||
// The recovery hint must appear so LLMs can re-fetch the full diff
|
||
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() {
|
||
// 150 change lines in one hunk: 100 shown, 50 silently dropped
|
||
// Must report the exact count, not just "(truncated)"
|
||
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_filter_log_output_body_omission_indicator() {
|
||
// Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted"
|
||
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
|
||
);
|
||
}
|
||
}
|