Files
Nicolas Le Cam 75dda6c097 fix(git-diff): count one truncated line as singular
A hunk truncated at exactly one line over the cap rendered
`... (1 deletions truncated)`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:41:51 +02:00

4721 lines
164 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Filters git output — log, status, diff, and more — keeping just the essential info.
use crate::core::args_utils;
use crate::core::guard::never_worse;
use crate::core::runner::{self, RunOptions};
use crate::core::stream::{
self, exec_capture, exec_capture_stdin, CaptureResult, FilterMode, LineHandler,
LineStreamFilter, StdinMode,
};
use crate::core::tracking;
use crate::core::truncate::{CAP_LIST, CAP_WARNINGS};
use crate::core::utils::{
exit_code_from_status, join_with_overflow, resolved_command, strip_ansi,
};
use anyhow::{Context, Result};
use std::ffi::OsString;
use std::process::Command;
#[derive(Debug, Clone)]
pub enum GitCommand {
Diff,
Log,
Status,
Show,
Add,
Commit,
Checkout,
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
}
/// Create a git Command for internal parsing that must be locale-stable.
///
/// We only use this for non-user-facing parses where RTK depends on git's
/// English status phrases. User-visible passthrough output keeps the user's
/// locale.
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::Checkout => run_checkout(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();
// Re-insert `--` when clap's trailing_var_arg consumed it (issue #1215)
let args = &args_utils::restore_double_dash(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") && !emits_word_diff(args);
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:");
}
// 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 printed = if !diff_result.stdout.is_empty() {
let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500));
format!("{}\n\nChanges:\n{}", result.stdout.trim(), compacted)
} else {
result.stdout.trim().to_string()
};
let raw = format!("{}\n{}", result.stdout, diff_result.stdout);
let shown = never_worse(&raw, &printed);
println!("{}", shown);
timer.track(
&format!("git diff {}", args.join(" ")),
&format!("rtk git diff {}", args.join(" ")),
&raw,
shown,
);
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 || emits_word_diff(args) {
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);
}
let mut printed = summary_result.stdout.trim().to_string();
// 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() {
printed.push('\n');
printed.push_str(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();
if !diff_text.is_empty() {
if verbose > 0 {
printed.push_str("\n\nChanges:");
}
let compacted = compact_diff(diff_text, max_lines.unwrap_or(500));
printed.push('\n');
printed.push_str(&compacted);
}
let shown = never_worse(&raw_output, &printed);
println!("{}", shown);
timer.track(
&format!("git show {}", args.join(" ")),
&format!("rtk git show {}", args.join(" ")),
&raw_output,
shown,
);
Ok(0)
}
/// Whether these args make git emit a word diff rather than a line diff.
///
/// `compact_diff` reads a unified or combined diff: a body line's first column
/// (or columns) is a marker and the rest is content. A word diff drops the
/// marker entirely and puts `[-removed-]` / `{+added+}` inline, so its body
/// lines are arbitrary content in the marker position. A line starting with `+`
/// then counts as an addition, one starting with `\` is dropped as a
/// no-newline annotation, and one whose content happens to start `diff --`
/// opens a new file section. There is nothing to compact faithfully, so these
/// modes pass through.
///
/// `--word-diff=none` is the mode that turns a word diff back off, leaving an
/// ordinary unified diff to compact. Modes are last-one-wins, which is what
/// that mode is for: overriding an alias or an earlier flag on the same line.
fn emits_word_diff(args: &[String]) -> bool {
let mut word_diff = false;
for arg in args {
if let Some(mode) = arg.strip_prefix("--word-diff=") {
word_diff = mode != "none";
} else if arg == "--word-diff"
|| arg.starts_with("--word-diff-regex")
|| arg == "--color-words"
|| arg.starts_with("--color-words=")
{
// `--color-words[=<regex>]` takes a regex rather than a mode, so
// there is no `none` to honour on that spelling.
word_diff = true;
}
}
word_diff
}
fn is_blob_show_arg(arg: &str) -> bool {
// Detect `rev:path` style arguments while ignoring flags like `--pretty=format:...`.
!arg.starts_with('-') && arg.contains(':')
}
/// Path named by a diff section header.
///
/// `diff --git a/p b/p` carries the path twice; `diff --cc p` and
/// `diff --combined p` carry it once, as the whole remainder of the line. Only
/// the two-path form can be split at its midpoint, so the header kind decides
/// which shape to read: `diff --cc dup dup` names one file called `dup dup`,
/// not the file `dup` twice.
///
/// Under the default `core.quotepath`, git wraps a path in `"` and escapes any
/// non-ASCII byte, control character, quote or backslash inside it — but not a
/// space. The quoting is undone here, so the header carries the path as it is
/// on disk and a `grep` over the output finds it by name.
fn diff_header_path(line: &str) -> String {
let Some(rest) = line.splitn(3, ' ').nth(2) else {
return "unknown".to_string();
};
if !line.starts_with("diff --git ") {
return unquote_path(rest);
}
if let Some(path) = same_path_twice(rest) {
return path;
}
// A rename names two different paths, and the destination is the second.
if let Some(quoted) = rest
.split(" \"b/")
.nth(1)
.and_then(|dst| dst.strip_suffix('"'))
{
return unescape_path(quoted);
}
match rest.split(" b/").nth(1) {
Some(path) => path.to_string(),
None => unquote_path(rest),
}
}
/// The path a `diff --git` header names twice, split at the midpoint.
///
/// Anything but a rename names the same path on both sides, so the two halves
/// are the same length and the separating space sits dead centre. Splitting
/// there instead of on the first ` b/` keeps a path that contains that
/// substring — a file under a directory named `x b`. Prefixes are then dropped
/// by matching the halves against each other rather than by name, so
/// `--no-prefix` and any custom `--src-prefix` / `--dst-prefix` read alike.
///
/// `None` for a rename, whose halves differ past their first component, and for
/// anything else the two halves disagree on; both fall through to the ` b/`
/// split. A `--no-prefix` rename between two directories is the one shape this
/// cannot tell from a prefix pair — space-separated paths with no prefix are
/// ambiguous by construction — and it reads as the shared trailing path.
fn same_path_twice(rest: &str) -> Option<String> {
if rest.len().is_multiple_of(2) {
return None;
}
let mid = rest.len() / 2;
// A space at the midpoint is a char boundary, so both halves are valid.
if rest.as_bytes().get(mid) != Some(&b' ') {
return None;
}
let (left, right) = (unquote_path(&rest[..mid]), unquote_path(&rest[mid + 1..]));
if left == right {
return Some(left);
}
let (_, left_path) = left.split_once('/')?;
let (_, right_path) = right.split_once('/')?;
(left_path == right_path).then(|| right_path.to_string())
}
/// Undo git's `core.quotepath` quoting: `"a/\303\251.txt"` becomes `a/é.txt`.
///
/// A path git did not quote is returned as-is, so either form can be passed.
fn unquote_path(raw: &str) -> String {
match raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
Some(quoted) => unescape_path(quoted),
None => raw.to_string(),
}
}
/// Decode the C escapes inside a quoted path.
///
/// The octal escapes spell out the path's bytes one at a time, so a multi-byte
/// character arrives as several of them; they are collected as bytes and
/// decoded once at the end rather than per escape. A path whose bytes are not
/// UTF-8 keeps replacement characters, which is as close as a `String` gets.
fn unescape_path(quoted: &str) -> String {
let bytes = quoted.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'\\' || i + 1 == bytes.len() {
out.push(bytes[i]);
i += 1;
continue;
}
let escape = bytes[i + 1];
if escape.is_ascii_digit() {
let end = (i + 4).min(bytes.len());
let octal = std::str::from_utf8(&bytes[i + 1..end])
.ok()
.and_then(|digits| u8::from_str_radix(digits, 8).ok());
match octal {
Some(byte) => {
out.push(byte);
i = end;
}
// Not an octal escape after all: keep the backslash verbatim.
None => {
out.push(bytes[i]);
i += 1;
}
}
continue;
}
out.push(match escape {
b'a' => 0x07,
b'b' => 0x08,
b't' => b'\t',
b'n' => b'\n',
b'v' => 0x0b,
b'f' => 0x0c,
b'r' => b'\r',
// `\"` and `\\` stand for themselves.
other => other,
});
i += 2;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Line budget a hunk header declares, and how wide its body prefix is.
struct HunkHeader {
/// Lines the hunk spans in each parent, in marker-column order. One entry
/// for a unified `@@`, one per parent for a combined `@@@`.
parents: Vec<usize>,
/// Lines the hunk spans in the result file.
new: usize,
/// Marker columns: 1 for `@@`, and one per parent for a combined `@@@`.
prefix_width: usize,
}
impl HunkHeader {
/// Whether every declared line has been accounted for, which is where the
/// hunk body ends. A combined hunk is not done until *every* parent's
/// budget is spent: a line removed from only the second parent spends that
/// parent's budget and neither the first's nor the result's.
fn exhausted(&self) -> bool {
self.new == 0 && self.parents.iter().all(|&remaining| remaining == 0)
}
/// Charge a body line against the budgets it occupies.
///
/// Column `i` is the line's marker against parent `i + 1`. `-` there means
/// the line is in that parent and is being removed; a space on a line that
/// is not a removal means the line is in that parent unchanged. Both spend
/// one of that parent's lines. `+` there, or a space on a removal line,
/// means the line is not in that parent at all.
///
/// Collapsing the columns into one add/delete pair, as an aggregate over
/// the whole prefix does, loses that distinction and leaves a combined
/// hunk's budget unable to converge.
fn consume(&mut self, markers: &[u8]) {
let is_add = markers.contains(&b'+');
let is_del = markers.contains(&b'-');
for (i, remaining) in self.parents.iter_mut().enumerate() {
let column = markers.get(i).copied();
let present = if is_del {
column == Some(b'-')
} else {
// A line shorter than the prefix reads as context, which is
// what a bare blank line in a unified diff body is.
column != Some(b'+')
};
if present {
*remaining = remaining.saturating_sub(1);
}
}
// In the result file unless the line is a pure deletion.
if is_add || !is_del {
self.new = self.new.saturating_sub(1);
}
}
}
/// Parse `@@ -a,b +c,d @@` and the combined `@@@ -a,b -c,d +e,f @@@`.
///
/// The counts bound the hunk body, which is what lets the body end where the
/// hunk ends rather than running on until the next header. Anything after it —
/// an mbox envelope, a `--` signature, trailing prose — is then outside every
/// hunk and cannot be read as diff content. A count is 1 when the header omits
/// it (`@@ -1 +1 @@`).
fn parse_hunk_header(line: &str) -> Option<HunkHeader> {
let at_run = line.len() - line.trim_start_matches('@').len();
if at_run < 2 {
return None;
}
let body = line[at_run..].split('@').next()?;
let mut parents: Vec<usize> = Vec::new();
let mut new = None;
for group in body.split_whitespace() {
let Some(rest) = group.strip_prefix(['-', '+']) else {
continue;
};
let count = match rest.split_once(',') {
Some((_, c)) => c.parse::<usize>().ok()?,
None => 1,
};
if group.starts_with('-') {
// A combined header lists one range per parent, in the same order
// as the marker columns.
parents.push(count);
} else {
new = Some(count);
}
}
// `@@` has one marker column, `@@@` two, and so on for more parents.
let prefix_width = at_run - 1;
// A well-formed header lists exactly one range per marker column. When it
// does not, only the columns can be charged, so trust them: an untracked
// parent would otherwise sit at its declared count forever and the hunk
// would never close, while a parent with no column of its own would be
// charged against nothing. A missing range gets `usize::MAX`, which keeps
// the hunk open to the next header rather than dropping its body.
if parents.len() != prefix_width {
parents.resize(prefix_width, usize::MAX);
}
Some(HunkHeader {
parents,
new: new.unwrap_or(0),
prefix_width,
})
}
/// Render the note for change lines dropped past `max_hunk_lines`, split by
/// sign so an anchored `^-` / `^+` audit can tell what it did not see.
fn hunk_truncation_note(deletions: usize, additions: usize) -> Option<String> {
fn count(n: usize, noun: &str) -> String {
if n == 1 {
format!("{} {}", n, noun)
} else {
format!("{} {}s", n, noun)
}
}
match (deletions, additions) {
(0, 0) => None,
(0, a) => Some(format!(" ... ({} truncated)", count(a, "addition"))),
(d, 0) => Some(format!(" ... ({} truncated)", count(d, "deletion"))),
(d, a) => Some(format!(
" ... ({}, {} truncated)",
count(d, "deletion"),
count(a, "addition")
)),
}
}
/// Emit the buffered leading context, charged against the diff-wide budget.
///
/// Keeps the lines closest to the change when the budget cannot take all of
/// them. Called wherever a hunk closes as well as at its first change line:
/// context buffered by a hunk that ends without one would otherwise be dropped,
/// leaving a bare hunk header with nothing under it.
fn flush_leading_context(
buffer: &mut Vec<String>,
result: &mut Vec<String>,
total: &mut usize,
cap: usize,
) {
let room = cap.saturating_sub(*total);
let keep = buffer.len().min(room);
let skip = buffer.len() - keep;
for ctx in buffer.drain(..).skip(skip) {
result.push(ctx);
}
*total += keep;
}
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 hunk: Option<HunkHeader> = None;
let mut hunk_shown = 0;
let mut skipped_add = 0usize;
let mut skipped_del = 0usize;
let mut leading_context: Vec<String> = Vec::new();
let mut leading_context_total = 0usize;
let max_hunk_lines = 100;
// Context before a hunk's first change, up to three lines per hunk and
// `max_lines / 10` across the diff. It does not count against `max_lines`,
// so it cannot displace change lines, and the diff-wide cap is what bounds
// the overrun that exemption would otherwise allow: a diff of many small
// hunks would otherwise spend three exempt lines on every one of them.
let max_leading_context = 3;
let leading_context_cap = max_lines / 10;
let mut was_truncated = false;
for line in diff.lines() {
// Every diff section header (`--git`, `--cc`, `--combined`) opens a new
// file and closes any open hunk, so the `---` / `+++` headers that
// follow it are never read as hunk content.
if line.starts_with("diff --") {
flush_leading_context(
&mut leading_context,
&mut result,
&mut leading_context_total,
leading_context_cap,
);
if let Some(note) = hunk_truncation_note(skipped_del, skipped_add) {
result.push(note);
was_truncated = true;
skipped_del = 0;
skipped_add = 0;
}
if !current_file.is_empty() && (added > 0 || removed > 0) {
result.push(format!(" +{} -{}", added, removed));
}
current_file = diff_header_path(line);
result.push(format!("\n{}", current_file));
added = 0;
removed = 0;
hunk = None;
hunk_shown = 0;
} else if let Some(header) = parse_hunk_header(line) {
flush_leading_context(
&mut leading_context,
&mut result,
&mut leading_context_total,
leading_context_cap,
);
if let Some(note) = hunk_truncation_note(skipped_del, skipped_add) {
result.push(note);
was_truncated = true;
skipped_del = 0;
skipped_add = 0;
}
hunk = Some(header);
hunk_shown = 0;
// Preserve the full unified diff hunk header, including trailing
// function / symbol context after the second @@ marker.
result.push(line.to_string());
} else if let Some(header) = hunk.as_mut() {
if header.exhausted() {
hunk = None;
continue;
}
if line.starts_with('\\') {
// "\ No newline at end of file" annotates the line above and
// occupies no line in either file.
continue;
}
// Slice the marker columns as bytes. `prefix_width` counts columns,
// and the markers are ASCII by construction, but the body content
// right after them is not: `--word-diff` emits body lines with no
// marker column at all, so a `char`-unaware `&line[..width]` splits
// a leading multi-byte character and panics.
let width = header.prefix_width.min(line.len());
let markers = &line.as_bytes()[..width];
let is_add = markers.contains(&b'+');
let is_del = markers.contains(&b'-');
header.consume(markers);
// Hunk bodies emit at column 0 in git's own unified shape, so
// `^+` / `^-` anchor. rtk's own annotations stay indented so those
// same anchors never match them. Inside a hunk every `+`/`-` line
// is content: the `---` / `+++` file headers only ever appear
// before the first hunk header.
if is_add || is_del {
if is_add {
added += 1;
}
if is_del {
removed += 1;
}
if hunk_shown < max_hunk_lines {
// The context immediately preceding the change, so the body
// reads as contiguous with it. The diff-wide budget is
// charged on emit rather than on buffering, so a line the
// ring evicted never costs anything.
flush_leading_context(
&mut leading_context,
&mut result,
&mut leading_context_total,
leading_context_cap,
);
result.push(line.to_string());
hunk_shown += 1;
} else if is_del {
skipped_del += 1;
} else {
skipped_add += 1;
}
leading_context.clear();
} else if hunk_shown > 0 {
if hunk_shown < max_hunk_lines {
result.push(line.to_string());
hunk_shown += 1;
}
} else if leading_context_total < leading_context_cap {
// Keep the last `max_leading_context` lines rather than the
// first: with `-U10` or `--function-context` the first ones sit
// ten lines above the change and would imply an adjacency the
// file does not have.
if leading_context.len() == max_leading_context {
leading_context.remove(0);
}
leading_context.push(line.to_string());
}
if header.exhausted() {
hunk = None;
flush_leading_context(
&mut leading_context,
&mut result,
&mut leading_context_total,
leading_context_cap,
);
}
}
if result.len().saturating_sub(leading_context_total) >= max_lines {
result.push("\n... (more changes truncated)".to_string());
was_truncated = true;
break;
}
}
// Flush last hunk
flush_leading_context(
&mut leading_context,
&mut result,
&mut leading_context_total,
leading_context_cap,
);
if let Some(note) = hunk_truncation_note(skipped_del, skipped_add) {
result.push(note);
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> {
// Re-insert `--` when clap's trailing_var_arg consumed it (issue #1215):
// without this, `rtk git log -- -p` loses its literal "--" and
// `requests_raw_log_output`/`log_arg_tokens` can no longer tell that
// `-p` is a pathspec, not the real patch flag.
let args = &args_utils::restore_double_dash(args);
if requests_raw_log_output(args) {
let passthrough_args: Vec<OsString> = std::iter::once(OsString::from("log"))
.chain(args.iter().map(OsString::from))
.collect();
return run_passthrough(&passthrough_args, global_args, verbose);
}
let timer = tracking::TimedExecution::start();
let mut cmd = git_cmd(global_args);
cmd.arg("log");
// Tokenize once and share it: flag-vs-value classification is reused
// below by both the flag-presence checks and the limit parsing, and a
// value belonging to --grep/--author/etc. (e.g. `--grep --pretty`) must
// not be misread as one of the flags below.
let tokens = log_arg_tokens(args);
let flag_args = flag_args_from_tokens(&tokens);
// Check if user provided format flags
let has_format_flag = 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 = 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_limit_from_tokens(&tokens).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 = flag_args
.iter()
.any(|arg| *arg == "--merges" || *arg == "--min-parents=2" || *arg == "--no-merges");
// Don't add --no-merges if user explicitly requested merges or an exact count (-n N / --max-count)
if !wants_merges && !has_limit_flag {
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);
let filtered = never_worse(&result.stdout, &filtered).to_string();
println!("{}", filtered);
timer.track(
&format!("git log {}", args.join(" ")),
&format!("rtk git log {}", args.join(" ")),
&result.stdout,
&filtered,
);
Ok(0)
}
/// True for git log/diff options that take their value as a separate,
/// space-delimited token (e.g. `--grep -p` searches messages for the
/// literal string "-p"; it does not request patch output). Consuming
/// that value token keeps flag-lookalike values from being misread as
/// the corresponding boolean flag.
fn consumes_next_token_as_value(arg: &str) -> bool {
matches!(
arg,
"--after"
| "--anchored"
| "--author"
| "--before"
| "--color-moved-ws"
| "--committer"
| "--date"
| "--decorate-refs"
| "--decorate-refs-exclude"
| "--diff-algorithm"
| "--diff-filter"
| "--diff-merges"
| "--dst-prefix"
| "--encoding"
| "--exclude"
| "--find-object"
| "--glob"
| "--grep"
| "--grep-reflog"
| "--inter-hunk-context"
| "--line-prefix"
| "--max-depth"
| "--output"
| "--output-indicator-context"
| "--output-indicator-new"
| "--output-indicator-old"
| "--rotate-to"
| "--since"
| "--since-as-filter"
| "--skip"
| "--skip-to"
| "--src-prefix"
| "--stat-count"
| "--stat-name-width"
| "--stat-width"
| "--until"
| "--word-diff-regex"
| "--ws-error-highlight"
| "-G"
| "-I"
| "-L"
| "-O"
| "-S"
| "-l"
| "-n"
)
}
/// A git log argument, classified as either a flag or the value consumed
/// by the preceding flag.
enum LogArg<'a> {
Flag(&'a str),
Value { flag: &'a str, value: &'a str },
}
/// Tokenizes git log `args` into [`LogArg`]s, stopping at the `--` pathspec
/// separator (tokens after it are paths, never flags or their values —
/// e.g. `git log -- -5` means "history for the path literally named -5").
/// `-n`/`--max-count`'s own count and every option in
/// [`consumes_next_token_as_value`] are paired with the flag that consumes
/// them. Shared by every git-log flag/value/limit check in [`run_log`] so
/// `--`-handling and option-value handling live in one place instead of
/// being reimplemented per check.
fn log_arg_tokens(args: &[String]) -> Vec<LogArg<'_>> {
let mut tokens = Vec::with_capacity(args.len());
let mut iter = args.iter().take_while(|arg| *arg != "--");
while let Some(arg) = iter.next() {
let arg_str = arg.as_str();
if arg_str == "--max-count" || consumes_next_token_as_value(arg_str) {
if let Some(value) = iter.next() {
tokens.push(LogArg::Value {
flag: arg_str,
value: value.as_str(),
});
continue;
}
}
tokens.push(LogArg::Flag(arg_str));
}
tokens
}
/// Filters `tokens` down to the flags themselves, dropping every value
/// consumed by the preceding option.
fn flag_args_from_tokens<'a>(tokens: &[LogArg<'a>]) -> Vec<&'a str> {
tokens
.iter()
.map(|token| match token {
LogArg::Flag(flag) | LogArg::Value { flag, .. } => *flag,
})
.collect()
}
/// Filters `args` down to the tokens that are actual flags, dropping every
/// token consumed as a value by the preceding option. `run_log` shares a
/// single tokenization via [`flag_args_from_tokens`] instead; this
/// convenience wrapper exists for tests that only care about the flags.
#[cfg(test)]
fn real_flag_args(args: &[String]) -> Vec<&str> {
flag_args_from_tokens(&log_arg_tokens(args))
}
/// True for git log/diff flags that change the *shape* of git's raw output
/// (patch text, diffstat, name lists) in a way RTK's injected
/// `--pretty=format` + `---END---` markers can't coexist with — matching
/// this must request the untouched passthrough path instead of RTK's
/// filtered one (see [`requests_raw_log_output`]).
fn requests_raw_diff_shape(flag: &str) -> bool {
matches!(
flag,
"-p" | "-u"
| "--dirstat"
| "--name-only"
| "--name-status"
| "--numstat"
| "--patch"
| "--patch-with-raw"
| "--patch-with-stat"
| "--raw"
| "--shortstat"
| "--stat"
| "--summary"
) || flag.starts_with("--stat=")
|| flag.starts_with("--dirstat=")
}
fn requests_raw_log_output(args: &[String]) -> bool {
log_arg_tokens(args)
.iter()
.any(|token| matches!(token, LogArg::Flag(flag) if requests_raw_diff_shape(flag)))
}
/// Parse the user-specified limit from git log args.
/// Handles: -20, -n 20, --max-count=20, --max-count 20
/// `run_log` shares a single tokenization via [`parse_limit_from_tokens`]
/// instead; this convenience wrapper exists for tests.
#[cfg(test)]
fn parse_user_limit(args: &[String]) -> Option<usize> {
parse_limit_from_tokens(&log_arg_tokens(args))
}
fn parse_limit_from_tokens(tokens: &[LogArg<'_>]) -> Option<usize> {
for token in tokens {
match token {
// -20 (combined digit form)
LogArg::Flag(flag)
if flag.starts_with('-')
&& flag.len() > 1
&& flag.chars().nth(1).is_some_and(|c| c.is_ascii_digit()) =>
{
if let Ok(n) = flag[1..].parse::<usize>() {
return Some(n);
}
}
// -n 20 / --max-count 20 (two-token form)
LogArg::Value {
flag: "-n" | "--max-count",
value,
} => {
if let Ok(n) = value.parse::<usize>() {
return Some(n);
}
}
// --max-count=20
LogArg::Flag(flag) => {
if let Some(rest) = flag.strip_prefix("--max-count=") {
if let Ok(n) = rest.parse::<usize>() {
return Some(n);
}
}
}
LogArg::Value { .. } => {}
}
}
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 {
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
}
}
/// Extract a compact in-progress state summary from plain `git status` output.
///
/// Compact mode runs `git status --porcelain -b`, which omits the state header
/// git prints for rebase / merge / cherry-pick / revert / bisect / am / sparse
/// checkout. Hiding that block is a correctness bug — e.g. during an interactive
/// rebase edit, the user sees a "clean" status and misses "You are currently
/// editing a commit while rebasing ...".
///
/// This helper walks the plain-status output we already capture for tracking
/// and emits a compact, RTK-style summary rather than dumping git's full prose.
/// Returns `None` when no state is in progress.
fn extract_state_header(raw: &str) -> Option<String> {
// Headers of the file-change blocks — everything relevant to state appears
// above these in git's output, so they double as a terminator.
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
}
/// Extract the explicit "HEAD detached at/from <ref>" line from plain
/// `git status` output.
///
/// Porcelain `-b` collapses a detached HEAD to the opaque `## HEAD (no branch)`,
/// which an agent (or a distracted human) can misread as a branch literally
/// named `HEAD`. The plain-status output keeps the explicit SHA/ref, so we
/// surface that instead. Returns `None` when HEAD is on a branch.
fn extract_detached_head(raw: &str) -> Option<String> {
raw.lines()
.map(str::trim)
.find(|l| l.starts_with("HEAD detached "))
.map(str::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();
// Keep a narrow compact path for no-arg status and branch/short-only flags.
// More complex explicit args still use the existing minimal-filter path.
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);
}
// Apply minimal filtering: strip ANSI, remove hints, empty lines
let filtered = filter_status_with_args(&result.stdout);
let filtered = never_worse(&result.stdout, &filtered).to_string();
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.success() {
let message = if result.stderr.contains("not a git repository") {
"Not a git repository".to_string()
} else {
result.stderr.trim().to_string()
};
if !message.is_empty() {
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(" "))
};
let shown = never_worse(&raw_output, &message);
timer.track(&original_cmd, &rtk_cmd, &raw_output, shown);
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),
};
// Surface in-progress state (rebase/merge/cherry-pick/bisect/am) from the
// plain-status output we already captured for tracking. Porcelain omits it
// and hiding it misleads the user about the true repo state.
let final_output = match extract_state_header(&raw_output) {
Some(state) => format!("{}\n{}", state, formatted),
None => formatted,
};
let shown = never_worse(&raw_output, &final_output);
println!("{}", shown);
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, shown);
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")?;
// Mirror git's own behaviour: a no-op `git add` is silent. Emitting a
// generic "ok" here is misleading — an agent can't tell "staged N files"
// from "staged nothing" when both print "ok".
let compact = if stat_result.stdout.trim().is_empty() {
String::new()
} 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)
}
};
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
}
/// Parse the first line of `git commit` success output and return a compact token.
/// Handles: `[main abc1234def] message`, `[main (root-commit) abc1234def] msg`,
/// localized variants, and multibyte branch names.
fn parse_commit_output(line: &str) -> String {
// Locate the summary's own brackets rather than assuming the line starts
// with '['. git prints hook output before its summary, so the first line
// is often something else entirely; slicing from byte 1 panics outright
// when that line opens with a multi-byte character ("✅ lint passed]"),
// and a line decoded from non-UTF-8 bytes starts with a multi-byte U+FFFD.
// Both indices come from `find`, so both land on character boundaries.
let (Some(open), Some(bracket_end)) = (line.find('['), line.find(']')) else {
return "ok".to_string();
};
if open >= bracket_end {
return "ok".to_string();
}
let bracket_content = &line[open + 1..bracket_end];
let hash = bracket_content.split_whitespace().next_back().unwrap_or("");
if hash.chars().count() >= 7 {
let short_hash: String = hash.chars().take(7).collect();
format!("ok {}", short_hash)
} 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);
}
// stdin is inherited so an interactive editor, GPG passphrase prompt or
// credential helper still reaches the terminal.
let CaptureResult {
stdout,
stderr,
exit_code,
} = exec_capture_stdin(&mut build_commit_command(args, global_args))
.context("Failed to run git commit")?;
let raw_output = format!("{}\n{}", stdout, stderr);
match classify_commit_outcome(exit_code == 0, &stdout, exit_code) {
CommitOutcome::Ok(compact) => {
println!("{}", compact);
timer.track(&original_cmd, "rtk git commit", &raw_output, &compact);
Ok(0)
}
CommitOutcome::Failed(code) => {
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);
Ok(code)
}
}
}
/// Outcome of a `git commit`: a non-success status propagates the exit code
/// rather than being reported as "ok" (#2494).
enum CommitOutcome {
Ok(String),
Failed(i32),
}
/// Classify a `git commit` result.
fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> CommitOutcome {
if success {
// Extract commit hash from output
let compact = stdout
.lines()
.next()
.map(parse_commit_output)
.unwrap_or_else(|| "ok".to_string());
CommitOutcome::Ok(compact)
} else {
CommitOutcome::Failed(exit_code)
}
}
fn run_checkout(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
let args = args_utils::restore_double_dash(args);
if verbose > 0 {
eprintln!("git checkout");
}
let mut cmd = git_cmd(global_args);
cmd.arg("checkout");
for arg in &args {
cmd.arg(arg);
}
let args_display = args.join(" ");
let args_for_filter = args.clone();
runner::run_filtered_with_exit(
cmd,
"git checkout",
&args_display,
move |raw, exit_code| format_checkout_output(&args_for_filter, raw, exit_code),
RunOptions::with_tee("git_checkout"),
)
}
fn format_checkout_output(args: &[String], raw: &str, exit_code: i32) -> String {
if exit_code == 0 {
format_checkout_success(args, raw)
} else {
filter_checkout_failure(raw)
}
}
fn format_checkout_success(args: &[String], raw: &str) -> String {
if let Some(restored) = checkout_restored_count(args) {
return format!(
"ok {} {}",
restored,
pluralize(restored, "file restored", "files restored")
);
}
if let Some(branch) = checkout_reset_branch_arg(args) {
return format!("ok {}", branch);
}
for line in raw.lines().map(str::trim) {
if let Some(branch) = quoted_suffix(line, "Switched to a new branch ") {
return format!("ok {} (new)", branch);
}
if let Some(branch) = quoted_suffix(line, "Switched to branch ") {
return format!("ok {}", branch);
}
if let Some(branch) = quoted_suffix(line, "Already on ") {
return format!("ok {}", branch);
}
if let Some(rest) = line.strip_prefix("HEAD is now at ") {
let hash = rest.split_whitespace().next().unwrap_or("HEAD");
return format!("ok HEAD {}", hash);
}
if line.starts_with("Updated ") && line.contains(" path") {
return format!("ok {}", line.to_ascii_lowercase());
}
}
if let Some(branch) = checkout_new_branch_arg(args) {
return format!("ok {} (new)", branch);
}
if let Some(branch) = checkout_branch_arg(args) {
return format!("ok {}", branch);
}
"ok".to_string()
}
fn checkout_restored_count(args: &[String]) -> Option<usize> {
let separator = args.iter().position(|arg| arg == "--")?;
let count = args[separator + 1..]
.iter()
.filter(|arg| !arg.is_empty())
.count();
(count > 0).then_some(count)
}
fn checkout_new_branch_arg(args: &[String]) -> Option<&str> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"-b" | "--orphan" => return iter.next().map(String::as_str),
"-B" => {
iter.next();
}
_ => {
if let Some(branch) = arg.strip_prefix("--orphan=") {
return Some(branch);
}
}
}
}
None
}
fn checkout_reset_branch_arg(args: &[String]) -> Option<&str> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg == "-B" {
return iter.next().map(String::as_str);
}
}
None
}
fn checkout_branch_arg(args: &[String]) -> Option<&str> {
if args.iter().any(|arg| arg == "--") {
return None;
}
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"-b" | "-B" | "--orphan" => {
iter.next();
}
"-t" | "--track" | "--detach" => {}
_ if arg.starts_with('-') => {}
_ => return Some(arg),
}
}
None
}
fn quoted_suffix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
line.strip_prefix(prefix)
.and_then(|rest| rest.strip_prefix('\''))
.and_then(|rest| rest.strip_suffix('\''))
}
fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 {
singular
} else {
plural
}
}
fn filter_checkout_failure(raw: &str) -> String {
let mut important = Vec::new();
let mut in_file_list = false;
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let is_header = trimmed.starts_with("error:")
|| trimmed.starts_with("fatal:")
|| trimmed.starts_with("CONFLICT");
if is_header {
in_file_list = trimmed.contains("following")
&& trimmed.contains("files")
&& trimmed.ends_with(':');
important.push(trimmed.to_string());
continue;
}
if in_file_list {
if trimmed.starts_with("Please ") || trimmed.starts_with("Aborting") {
in_file_list = false;
} else if line.starts_with(char::is_whitespace) {
important.push(line.to_string());
continue;
}
}
if trimmed.starts_with("Aborting") {
important.push(trimmed.to_string());
}
}
if important.is_empty() {
raw.trim().to_string()
} else {
important.join("\n")
}
}
// Git push progress prefixes (stderr) — dropped from the stream.
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 {
// 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);
let filtered = never_worse(&result.stdout, &filtered).to_string();
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 != &current && !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);
}
// 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)
}
/// Format status message for stash operations.
/// - For create operations (push/save): checks for "No local changes"
/// - For other operations: uses "ok stash <subcommand>" format
fn format_stash_message(subcommand: Option<&str>, result: &CaptureResult) -> String {
match subcommand {
None | Some("push") | Some("save") => {
// A successful stash collapses to "ok stashed" (the WIP ref/sha git
// prints isn't needed to `git stash pop`). But a no-op must NOT look
// like success — pass git's "No local changes to save" through so the
// agent can tell nothing was stashed.
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() {
if !result.success() && !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr.trim());
}
timer.track("git stash list", "rtk git stash list", &result.stdout, "");
return Ok(result.exit_code);
}
let filtered = filter_stash_list(&result.stdout);
let filtered = never_worse(&result.stdout, &filtered).to_string();
println!("{}", filtered);
timer.track(
"git stash list",
"rtk git stash list",
&result.stdout,
&filtered,
);
}
Some("show") => {
let patch_mode = args.iter().any(|a| a == "-p" || a == "--patch");
let mut cmd = git_cmd(global_args);
cmd.args(["stash", "show"]);
for arg in args {
cmd.arg(arg);
}
let result = exec_capture(&mut cmd).context("Failed to run git stash show")?;
if result.stdout.trim().is_empty() {
if !result.success() && !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr.trim());
}
timer.track("git stash show", "rtk git stash show", &result.stdout, "");
return Ok(result.exit_code);
}
let filtered = if patch_mode && !emits_word_diff(args) {
compact_diff(&result.stdout, 100)
} else if patch_mode {
result.stdout.clone()
} else {
compact_stash_stat(&result.stdout)
};
let shown = crate::core::runner::emit_guarded(&filtered, None, &result.stdout);
timer.track(
"git stash show",
"rtk git stash show",
&result.stdout,
&shown,
);
}
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);
}
}
// Default: "git stash [push] [--] [<pathspec>...]" or "git stash save [<message>]"
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 {
// 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 compact_stash_stat(raw: &str) -> String {
let (files, summary) = parse_stash_stat(raw);
if files.is_empty() {
return raw.trim_end().to_string();
}
let total = files.len();
let mut out = join_with_overflow(&files[..total.min(CAP_LIST)], total, CAP_LIST, "files");
if total > CAP_LIST {
if let Some(hint) =
crate::core::tee::force_tee_tail_hint(&files.join("\n"), "git-stash-show", CAP_LIST + 1)
{
out.push(' ');
out.push_str(&hint);
}
}
if !summary.is_empty() {
out.push('\n');
out.push_str(&compress_stat_summary(&summary));
}
out
}
fn compress_stat_summary(summary: &str) -> String {
summary
.replace("insertions(+)", "+")
.replace("insertion(+)", "+")
.replace("deletions(-)", "-")
.replace("deletion(-)", "-")
.replace("files changed", "changed")
.replace("file changed", "changed")
.replace(",", "")
}
fn parse_stash_stat(stat: &str) -> (Vec<String>, String) {
let stat = strip_ansi(stat);
let mut files = Vec::new();
let mut summary = String::new();
for line in stat.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match diffstat_row(line) {
Some(row) => files.push(row),
None => summary = line.to_string(),
}
}
(files, summary)
}
fn diffstat_row(line: &str) -> Option<String> {
let bar = line.rfind('|')?;
let path = line[..bar].trim();
let rhs = line[bar + 1..].trim();
let is_diffstat_row = rhs.starts_with("Bin") || rhs.starts_with(|c: char| c.is_ascii_digit());
if path.is_empty() || !is_diffstat_row {
return None;
}
if rhs.starts_with("Bin") {
return Some(format!("{} (binary)", path));
}
let count = rhs.split_whitespace().next().unwrap_or("");
let sign = match (rhs.contains('+'), rhs.contains('-')) {
(true, true) => " +-",
(true, false) => " +",
(false, true) => " -",
(false, false) => "",
};
Some(format!("{} {}{}", path, count, sign))
}
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")?;
if !result.success() {
if !result.stderr.trim().is_empty() {
eprintln!("{}", result.stderr);
}
timer.track(
"git worktree list",
"rtk git worktree",
&result.stdout,
&result.stderr,
);
return Ok(result.exit_code);
}
let filtered = filter_worktree_list(&result.stdout);
let filtered = never_worse(&result.stdout, &filtered).to_string();
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_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_run_status_compact_propagates_non_repo_failure() {
// #2497: a `git status` failure other than "not a git repository"
// (here: a corrupt index) must propagate a non-zero exit, not be
// flattened into "Clean working tree" + exit 0.
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path().to_string_lossy().into_owned();
assert!(
Command::new("git")
.args(["-C", &p, "init", "-q"])
.status()
.expect("git init")
.success(),
"git init should succeed"
);
std::fs::write(dir.path().join(".git/index"), "corrupt-index").expect("corrupt index");
let global = vec!["-C".to_string(), p];
let code = run_status(&[], 0, &global).expect("run_status");
assert_ne!(
code, 0,
"corrupt-index git status must not be reported as success"
);
}
#[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_hunk_lines_are_grep_anchorable() {
let diff = "diff --git a/f.txt b/f.txt\n\
--- a/f.txt\n\
+++ b/f.txt\n\
@@ -1,5 +1,4 @@\n\
\x20keep1\n\
-DELETED_A\n\
\x20keep2\n\
-DELETED_B\n\
+ADDED\n";
let result = compact_diff(diff, 100);
let removed: Vec<&str> = result.lines().filter(|l| l.starts_with('-')).collect();
let added: Vec<&str> = result.lines().filter(|l| l.starts_with('+')).collect();
assert_eq!(removed, vec!["-DELETED_A", "-DELETED_B"], "`^-` must anchor");
assert_eq!(added, vec!["+ADDED"], "`^+` must anchor");
// rtk's own tally stays indented so these same greps never count it as
// a diff line. Without this, `^+` would pick up the "+1 -2" summary.
assert!(result.contains(" +1 -2"), "tally must stay indented");
// Context lines keep git's leading space, so they are not `^-`/`^+`.
// Both are emitted: the one before the first change as well as the one
// between changes.
assert!(
result.lines().any(|l| l == " keep1"),
"leading context must survive, got:\n{}",
result
);
assert!(result.lines().any(|l| l == " keep2"));
}
#[test]
fn test_compact_diff_keeps_content_starting_with_plus_or_minus() {
// `---` / `+++` are file headers only before the first `@@`. Inside a
// hunk, `++i;` and `-- sql comment` are content and must be neither
// dropped from the body nor missing from the tally.
let diff = "diff --git a/f.sql b/f.sql\n\
--- a/f.sql\n\
+++ b/f.sql\n\
@@ -1,2 +1,2 @@\n\
--- sql comment\n\
+++i;\n";
let result = compact_diff(diff, 100);
assert!(
result.lines().any(|l| l == "--- sql comment"),
"deleted SQL comment must survive, got:\n{}",
result
);
assert!(
result.lines().any(|l| l == "+++i;"),
"added `++i;` must survive, got:\n{}",
result
);
assert!(result.contains(" +1 -1"), "tally must count both, got:\n{}", result);
}
#[test]
fn test_compact_diff_leading_context_has_its_own_budget() {
// Leading context must not consume the 100-line change budget: a hunk
// opening with more context than the budget still shows every change.
let mut diff = String::from("diff --git a/f.rs b/f.rs\n@@ -1,120 +1,120 @@\n");
for i in 0..20 {
diff.push_str(&format!(" ctx{}\n", i));
}
for i in 0..100 {
diff.push_str(&format!("-del{}\n", i));
}
let result = compact_diff(&diff, 1000);
let ctx = result.lines().filter(|l| l.starts_with(" ctx")).count();
let dels = result.lines().filter(|l| l.starts_with("-del")).count();
assert_eq!(ctx, 3, "leading context is capped, got:\n{}", result);
assert_eq!(dels, 100, "every change must still be shown, got:\n{}", result);
assert!(
!result.contains("truncated"),
"no change was dropped, got:\n{}",
result
);
}
#[test]
fn test_compact_diff_combined_diff_headers_are_not_hunk_content() {
// `diff --cc` sections do not match `diff --git`, so the reset fires on
// the `diff --` prefix and the `---` / `+++` headers of every section
// stay outside the hunk body.
let diff = "diff --cc a.txt\n\
index ba2906d,e45c9c2..0000000\n\
--- a/a.txt\n\
+++ b/a.txt\n\
@@@ -1,1 -1,1 +1,5 @@@\n\
++<<<<<<< HEAD\n\
\x20+main\n\
++=======\n\
+ side\n\
++>>>>>>> side\n\
diff --cc z.txt\n\
index ba2906d,e45c9c2..0000000\n\
--- a/z.txt\n\
+++ b/z.txt\n\
@@@ -1,1 -1,1 +1,5 @@@\n\
++<<<<<<< HEAD\n\
\x20+main\n\
++=======\n\
+ side\n\
++>>>>>>> side\n";
let result = compact_diff(diff, 500);
assert!(
!result.lines().any(|l| l.starts_with("+++ b/")),
"file headers must not reach the hunk body, got:\n{}",
result
);
assert!(result.contains("z.txt"), "got:\n{}", result);
// A combined diff carries one marker column per parent, so ` +main` is
// an addition against the second parent. The tally counts all five
// added lines per file; an anchored `^+` sees only the four whose
// marker sits in column 1. That gap is documented in FEATURES.md.
assert_eq!(
result.matches(" +5 -0").count(),
2,
"column-2 markers must be counted, got:\n{}",
result
);
let anchored = result.lines().filter(|l| l.starts_with('+')).count();
assert_eq!(anchored, 8, "four per file anchor, got:\n{}", result);
}
#[test]
fn test_compact_diff_mbox_signature_is_not_a_deletion() {
// `gh pr diff --patch` yields an mbox: a bare `---` before the diffstat
// and a `-- ` signature after each patch, both at column 0. The hunk
// ends where its declared line counts run out, so neither is read as
// hunk content.
let diff = "From abc Mon Sep 17 00:00:00 2001\n\
Subject: [PATCH 1/2] one\n\
\n\
---\n\
\x20f.txt | 2 +-\n\
\n\
diff --git a/f.txt b/f.txt\n\
--- a/f.txt\n\
+++ b/f.txt\n\
@@ -1,2 +1,2 @@\n\
-old1\n\
+new1\n\
\x20tail1\n\
-- \n\
2.40.0\n\
\n\
From def Mon Sep 17 00:00:00 2001\n\
Subject: [PATCH 2/2] two\n\
\n\
---\n\
diff --git a/g.txt b/g.txt\n\
--- a/g.txt\n\
+++ b/g.txt\n\
@@ -1,2 +1,2 @@\n\
-old2\n\
+new2\n\
\x20tail2\n\
-- \n\
2.40.0\n";
let result = compact_diff(diff, 500);
let removed: Vec<&str> = result.lines().filter(|l| l.starts_with('-')).collect();
assert_eq!(
removed,
vec!["-old1", "-old2"],
"only real deletions anchor, got:\n{}",
result
);
assert!(
!result.contains("Subject:"),
"mbox envelope must stay out of the body, got:\n{}",
result
);
assert!(result.contains(" +1 -1"), "tally counts real changes only, got:\n{}", result);
}
#[test]
fn test_compact_diff_leading_context_is_adjacent_to_the_change() {
// With `-U10` the first context lines sit ten lines above the change.
// Emitting those would tell the reader that ctx3 precedes the deletion
// when ctx10 does.
let mut diff = String::from("diff --git a/f.rs b/f.rs\n@@ -1,11 +1,11 @@\n");
for i in 1..=10 {
diff.push_str(&format!(" ctx{}\n", i));
}
diff.push_str("-old\n+new\n");
let result = compact_diff(&diff, 500);
let ctx: Vec<&str> = result.lines().filter(|l| l.starts_with(" ctx")).collect();
assert_eq!(
ctx,
vec![" ctx8", " ctx9", " ctx10"],
"the last context lines, not the first, got:\n{}",
result
);
}
#[test]
fn test_diff_header_path_keeps_spaces() {
assert_eq!(
diff_header_path("diff --git a/my file.txt b/my file.txt"),
"my file.txt"
);
assert_eq!(diff_header_path("diff --cc my file.txt"), "my file.txt");
assert_eq!(
diff_header_path("diff --combined my file.txt"),
"my file.txt"
);
}
#[test]
fn test_diff_header_path_handles_gits_quoted_paths() {
// Under the default `core.quotepath`, git escapes a non-ASCII path and
// wraps it in quotes, which removes the ` b/` separator the plain form
// is split on. Without the quoted form handled, the fallback returned
// the whole remainder — both paths — as the section header.
assert_eq!(
diff_header_path(r#"diff --git "a/été.txt" "b/été.txt""#),
r"été.txt"
);
assert_eq!(
diff_header_path(r#"diff --cc "été.txt""#),
r"été.txt"
);
// A rename quotes each side on its own.
assert_eq!(
diff_header_path(r#"diff --git a/plain.txt "b/ét.txt""#),
r"ét.txt"
);
assert_eq!(
diff_header_path(r#"diff --git "a/ét.txt" b/plain.txt"#),
"plain.txt"
);
}
#[test]
fn test_diff_header_path_unescapes_gits_default_quoting() {
// What git actually emits under the default `core.quotepath`: one octal
// escape per byte, so the header has to be decoded rather than merely
// unwrapped, or `rtk git diff | grep été` finds nothing.
assert_eq!(
diff_header_path(r#"diff --git "a/\303\251t\303\251.txt" "b/\303\251t\303\251.txt""#),
"été.txt"
);
assert_eq!(
diff_header_path(r#"diff --cc "\303\251t\303\251.txt""#),
"été.txt"
);
assert_eq!(
diff_header_path(r#"diff --git a/plain.txt "b/\303\251t.txt""#),
"ét.txt"
);
// The single-character escapes, and a backslash standing for itself.
assert_eq!(
diff_header_path(r#"diff --cc "tab\there.txt""#),
"tab\there.txt"
);
assert_eq!(
diff_header_path(r#"diff --cc "quote\"here.txt""#),
"quote\"here.txt"
);
assert_eq!(
diff_header_path(r#"diff --cc "back\\slash.txt""#),
r"back\slash.txt"
);
}
#[test]
fn test_emits_word_diff_detects_every_form() {
for flag in [
"--word-diff",
"--word-diff=plain",
"--word-diff=porcelain",
"--word-diff-regex=.",
"--color-words",
"--color-words=.",
] {
assert!(
emits_word_diff(&[flag.to_string()]),
"{} must pass through",
flag
);
}
assert!(!emits_word_diff(&["--stat".to_string()]));
assert!(!emits_word_diff(&["-U10".to_string()]));
assert!(!emits_word_diff(&[]));
}
#[test]
fn test_emits_word_diff_honours_the_none_mode() {
// `--word-diff=none` leaves an ordinary unified diff, which compacts
// like any other. Treating it as a word diff passed the whole raw diff
// through, so a defensive `--word-diff=none` lost every saving.
assert!(!emits_word_diff(&["--word-diff=none".to_string()]));
// Modes are last-one-wins, which is what `none` exists to do.
assert!(!emits_word_diff(&[
"--word-diff".to_string(),
"--word-diff=none".to_string()
]));
assert!(emits_word_diff(&[
"--word-diff=none".to_string(),
"--word-diff".to_string()
]));
// `--color-words` takes a regex, so `none` there is a pattern.
assert!(emits_word_diff(&["--color-words=none".to_string()]));
}
#[test]
fn test_parse_hunk_header_reconciles_ranges_with_marker_columns() {
// A well-formed header lists one range per marker column. When it does
// not, only the columns can be charged. A missing range must not leave
// an untracked parent holding the hunk open forever, and an extra one
// must not sit at its declared count with no column to spend it.
let h = parse_hunk_header("@@@ -1 +1 @@@").expect("two columns, one range");
assert_eq!(h.prefix_width, 2);
assert_eq!(h.parents, vec![1, usize::MAX]);
let h = parse_hunk_header("@@@ -1 -1 -1 +0,0 @@@").expect("two columns, three ranges");
assert_eq!(h.prefix_width, 2);
assert_eq!(h.parents, vec![1, 1]);
}
#[test]
fn test_compact_diff_extra_range_does_not_strand_a_hunk() {
// With the third range untracked, `--x` left it at 1 forever, so the
// hunk never closed and the mbox signature became its content.
let out = compact_diff("diff --cc f\n@@@ -1 -1 -1 +0,0 @@@\n--x\n-- \n2.40.0\n", 100);
assert!(out.contains("--x"), "got:\n{}", out);
assert!(!out.contains("2.40.0"), "got:\n{}", out);
assert!(!out.contains("-- "), "got:\n{}", out);
// One line, removed from both parents, is one deletion.
assert!(out.contains("+0 -1"), "got:\n{}", out);
}
#[test]
fn test_compact_diff_missing_range_keeps_the_body() {
// One range for two columns: the untracked parent gets `usize::MAX`, so
// the hunk stays open to the next header rather than closing early and
// dropping ` -lost`.
let out = compact_diff("diff --cc f\n@@@ -1 +1 @@@\n +kept\n -lost\n", 100);
assert!(out.contains(" +kept"), "got:\n{}", out);
assert!(out.contains(" -lost"), "got:\n{}", out);
assert!(out.contains("+1 -1"), "got:\n{}", out);
}
#[test]
fn test_diff_header_path_splits_the_pair_at_its_midpoint() {
// A file under a directory named `x b` puts the ` b/` separator inside
// the path, so the first match is the wrong one.
assert_eq!(diff_header_path("diff --git a/x b/y b/x b/y"), "x b/y");
// `--no-prefix` and custom prefixes leave no ` b/` at all.
assert_eq!(diff_header_path("diff --git x x"), "x");
assert_eq!(
diff_header_path("diff --git src/main.rs src/main.rs"),
"src/main.rs"
);
// Prefixes are matched against each other, not by name, so a custom
// `--dst-prefix` reads like any other pair.
assert_eq!(diff_header_path("diff --git a/f.txt w/f.txt"), "f.txt");
assert_eq!(diff_header_path("diff --git i/f.txt w/f.txt"), "f.txt");
// A rename's halves disagree past their first component, so the ` b/`
// split still names the destination.
assert_eq!(diff_header_path("diff --git a/old.txt b/new.txt"), "new.txt");
}
#[test]
fn test_diff_header_path_does_not_split_single_path_headers() {
// `diff --cc` names one path. Splitting its remainder at the midpoint
// would read a file called `dup dup` as the file `dup` named twice.
assert_eq!(diff_header_path("diff --cc dup dup"), "dup dup");
assert_eq!(diff_header_path("diff --combined dup dup"), "dup dup");
assert_eq!(diff_header_path("diff --cc a/x b/x"), "a/x b/x");
}
#[test]
fn test_parse_hunk_header_counts() {
let h = parse_hunk_header("@@ -10,3 +10,4 @@ fn ctx() {").expect("unified header");
assert_eq!((h.parents.as_slice(), h.new, h.prefix_width), (&[3][..], 4, 1));
// Omitted counts mean one line.
let h = parse_hunk_header("@@ -1 +1 @@").expect("single-line header");
assert_eq!((h.parents.as_slice(), h.new), (&[1][..], 1));
// A combined header lists one range per parent, in marker-column order.
// Every one of them bounds the hunk body.
let h = parse_hunk_header("@@@ -1,1 -1,4 +1,5 @@@").expect("combined header");
assert_eq!(
(h.parents.as_slice(), h.new, h.prefix_width),
(&[1, 4][..], 5, 2)
);
assert!(parse_hunk_header("@ -1,1 +1,1 @").is_none());
assert!(parse_hunk_header("-- ").is_none());
assert!(parse_hunk_header("---").is_none());
}
#[test]
fn test_compact_diff_non_ascii_body_line_without_a_marker_does_not_panic() {
// `--word-diff` / `--color-words` emit body lines with no marker column,
// so content lands where the markers are sliced. Slicing by byte index
// split a leading multi-byte character and aborted the process.
let out = compact_diff(
"diff --git a/f.txt b/f.txt\n@@ -1,3 +1,3 @@\n-old\n+new\nécole ancienne ligne\n",
100,
);
assert!(out.contains("-old"), "got:\n{}", out);
assert!(out.contains("+new"), "got:\n{}", out);
assert!(out.contains("école ancienne ligne"), "got:\n{}", out);
}
#[test]
fn test_compact_diff_combined_hunk_ends_at_its_declared_length() {
// Every parent's declared range bounds the body. Charging only the
// first parent left `old` unable to converge on real conflict output,
// so the hunk never closed by count and the mbox / signature / prose
// guard did not apply to combined sections at all.
let conflict = "diff --cc f.txt\n@@@ -1,1 -1,1 +1,5 @@@\n++<<<<<<<\n +main\n++=======\n+ side\n++>>>>>>>\n-- \ntrailing signature\n";
let out = compact_diff(conflict, 100);
assert!(!out.contains("trailing signature"), "got:\n{}", out);
assert!(!out.contains("-- "), "got:\n{}", out);
assert!(out.contains("+5 -0"), "got:\n{}", out);
}
#[test]
fn test_compact_diff_combined_hunk_keeps_second_parent_removals() {
// `-1,2 -1,4 +1,2`: two removals spend only the second parent's budget.
// Closing on the first parent and the result alone dropped them with no
// tally and no truncation note — a silent loss.
let out = compact_diff(
"diff --cc f.txt\n@@@ -1,2 -1,4 +1,2 @@@\n a\n b\n -x\n -y\n",
100,
);
assert!(out.contains(" -x"), "got:\n{}", out);
assert!(out.contains(" -y"), "got:\n{}", out);
assert!(out.contains("+0 -2"), "got:\n{}", out);
}
#[test]
fn test_compact_diff_flushes_context_from_a_hunk_with_no_change_line() {
// The buffer drained only on the first change line, so a hunk that ends
// without one rendered as a bare header with nothing under it.
let out = compact_diff(
"diff --git a/g.txt b/g.txt\n@@ -1,3 +1,3 @@\n ctx1\n ctx2\n ctx3\n",
100,
);
assert!(out.contains(" ctx1"), "got:\n{}", out);
assert!(out.contains(" ctx3"), "got:\n{}", out);
}
#[test]
fn test_compact_diff_leading_context_does_not_displace_change_lines() {
// Leading context is exempt from `max_lines`, so the same number of
// change lines survives whether or not the hunks open with context.
let build = |with_context: bool| {
let mut diff = String::new();
for f in 0..30 {
diff.push_str(&format!("diff --git a/f{}.rs b/f{}.rs\n", f, f));
diff.push_str("@@ -1,20 +1,20 @@\n");
if with_context {
for c in 0..3 {
diff.push_str(&format!(" ctx{}_{}\n", f, c));
}
}
for i in 0..12 {
diff.push_str(&format!("-del{}_{}\n", f, i));
}
}
diff
};
let count_changes =
|out: &str| out.lines().filter(|l| l.starts_with("-del")).count();
let without = compact_diff(&build(false), 500);
let with = compact_diff(&build(true), 500);
assert_eq!(
count_changes(&with),
count_changes(&without),
"leading context displaced change lines:\n{}",
with
);
}
#[test]
fn test_compact_diff_leading_context_is_capped_across_the_diff() {
// The diff-wide cap is what bounds the exemption: without it, a diff of
// many small hunks would spend three exempt lines on each one.
let mut diff = String::new();
for f in 0..200 {
diff.push_str(&format!("diff --git a/f{}.rs b/f{}.rs\n", f, f));
diff.push_str("@@ -1,4 +1,4 @@\n");
for c in 0..3 {
diff.push_str(&format!(" ctx{}_{}\n", f, c));
}
diff.push_str(&format!("-del{}\n", f));
}
let result = compact_diff(&diff, 500);
let ctx = result.lines().filter(|l| l.starts_with(" ctx")).count();
assert!(
ctx <= 50,
"leading context must stay within max_lines / 10, got {} in:\n{}",
ctx,
result
);
}
#[test]
fn test_hunk_truncation_note_counts_one_as_singular() {
assert_eq!(
hunk_truncation_note(1, 0).as_deref(),
Some(" ... (1 deletion truncated)")
);
assert_eq!(
hunk_truncation_note(0, 1).as_deref(),
Some(" ... (1 addition truncated)")
);
assert_eq!(
hunk_truncation_note(1, 2).as_deref(),
Some(" ... (1 deletion, 2 additions truncated)")
);
assert_eq!(hunk_truncation_note(0, 0), None);
}
#[test]
fn test_compact_diff_truncation_note_splits_by_sign() {
// An anchored `^-` audit needs to know how many deletions it did not
// see, which a merged "N lines truncated" cannot tell it.
let mut diff = String::from("diff --git a/f.rs b/f.rs\n@@ -1,160 +1,160 @@\n");
for i in 0..80 {
diff.push_str(&format!("-del{}\n", i));
diff.push_str(&format!("+add{}\n", i));
}
let result = compact_diff(&diff, 1000);
assert!(
result.contains(" ... (30 deletions, 30 additions truncated)"),
"expected per-sign truncation note, got:\n{}",
result
);
}
#[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"
);
}
#[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_parse_stash_stat_strips_decorations() {
let raw = " del.md | 2 --\n keep.md | 5 ++++-\n logo.bin | Bin 0 -> 1024 bytes\n \
new.rs | 40 ++++++++\n 4 files changed, 44 insertions(+), 3 deletions(-)\n";
let (files, summary) = parse_stash_stat(raw);
assert_eq!(
files,
vec![
"del.md 2 -",
"keep.md 5 +-",
"logo.bin (binary)",
"new.rs 40 +"
]
);
assert_eq!(summary, "4 files changed, 44 insertions(+), 3 deletions(-)");
}
#[test]
fn test_parse_stash_stat_collapsed_bar() {
let (files, _) = parse_stash_stat(" .claude/CLAUDE.md | 234 +-\n");
assert_eq!(files, vec![".claude/CLAUDE.md 234 +-"]);
}
#[test]
fn test_compact_stash_stat_passthrough_numstat() {
let raw = "0\t1\tdel.md\n3\t2\tkeep.md\n1\t0\tn1.rs\n";
assert_eq!(
compact_stash_stat(raw),
"0\t1\tdel.md\n3\t2\tkeep.md\n1\t0\tn1.rs"
);
}
#[test]
fn test_compact_stash_stat_passthrough_name_only() {
let raw = "del.md\nkeep.md\nn1.rs\n";
assert_eq!(compact_stash_stat(raw), "del.md\nkeep.md\nn1.rs");
}
#[test]
fn test_compress_stat_summary_variants() {
assert_eq!(
compress_stat_summary("4 files changed, 60 insertions(+), 313 deletions(-)"),
"4 changed 60 + 313 -"
);
assert_eq!(
compress_stat_summary("1 file changed, 1 insertion(+)"),
"1 changed 1 +"
);
assert_eq!(
compress_stat_summary("1 file changed, 1 deletion(-)"),
"1 changed 1 -"
);
assert_eq!(
compress_stat_summary("2 files changed, 4 insertions(+), 1 deletion(-)"),
"2 changed 4 + 1 -"
);
}
#[test]
fn test_compact_stash_stat_compresses_summary() {
let raw = " a.txt | 2 ++\n 1 file changed, 2 insertions(+)\n";
assert_eq!(compact_stash_stat(raw), "a.txt 2 +\n1 changed 2 +");
}
#[test]
fn test_parse_stash_stat_last_pipe_is_separator() {
let (files, _) = parse_stash_stat(" weird|name.txt | 3 +++\n");
assert_eq!(files, vec!["weird|name.txt 3 +"]);
}
#[test]
fn test_parse_stash_stat_strips_ansi() {
let (files, _) = parse_stash_stat(" a.txt | 2 \x1b[32m++\x1b[m\n");
assert_eq!(files, vec!["a.txt 2 +"]);
}
#[test]
fn test_parse_stash_stat_empty() {
let (files, summary) = parse_stash_stat("");
assert!(files.is_empty());
assert!(summary.is_empty());
}
#[test]
fn test_parse_stash_stat_unicode_and_malformed_never_panic() {
let _ = parse_stash_stat("not a diffstat at all");
let _ = parse_stash_stat("| | |");
let (files, _) = parse_stash_stat(" 日本語.md | 5 +++--\n");
assert_eq!(files, vec!["日本語.md 5 +-"]);
}
#[test]
fn test_parse_stash_stat_savings() {
use crate::core::tracking::estimate_tokens;
let raw = " CONTRIBUTING.md | 305 \
----------------------------------------------------------\n \
README.md | 28 ++++--\n logo.bin | Bin 0 -> 2048 bytes\n \
newfeature.rs | 40 ++++++++\n \
4 files changed, 60 insertions(+), 313 deletions(-)\n";
let (files, summary) = parse_stash_stat(raw);
let compact = format!("{}\n{}", files.join("\n"), summary);
let savings =
100.0 - (estimate_tokens(&compact) as f64 / estimate_tokens(raw) as f64 * 100.0);
assert!(
savings >= 40.0,
"expected >=40% savings, got {:.1}%",
savings
);
}
#[test]
fn test_run_stash_list_propagates_failure() {
let dir = tempfile::tempdir().expect("tempdir");
let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()];
let code = run_stash(Some("list"), &[], 0, &global).expect("run_stash list");
assert_ne!(code, 0, "git stash list failure must propagate");
}
#[test]
fn test_run_stash_show_propagates_failure() {
let dir = tempfile::tempdir().expect("tempdir");
let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()];
let code = run_stash(Some("show"), &[], 0, &global).expect("run_stash show");
assert_ne!(code, 0, "git stash show failure must propagate");
}
#[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_run_worktree_list_propagates_failure() {
// #2497: `git worktree list` outside a repo exits non-zero; rtk must not
// report success (empty output + exit 0).
let dir = tempfile::tempdir().expect("tempdir");
let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()];
let code = run_worktree(&[], 0, &global).expect("run_worktree");
assert_ne!(code, 0, "git worktree list failure must propagate");
}
#[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() {
// 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_patch_log_flags_request_raw_output() {
for flag in [
"-p",
"-u",
"--patch",
"--patch-with-raw",
"--patch-with-stat",
] {
let args = vec![flag.to_string()];
assert!(requests_raw_log_output(&args), "{flag} should pass through");
}
}
#[test]
fn test_patch_flag_after_pathspec_separator_is_ignored() {
// `git log -- -p` means "show history for a path literally named -p",
// not "show patches" — the flag lookalike appears after `--`.
let args = vec!["--".to_string(), "-p".to_string()];
assert!(
!requests_raw_log_output(&args),
"-p after -- is a pathspec, not a patch flag, and should stay on the filtered path"
);
}
#[test]
fn test_non_patch_log_flags_remain_filtered() {
for flag in ["--no-patch", "--oneline", "--format=%H"] {
let args = vec![flag.to_string()];
assert!(
!requests_raw_log_output(&args),
"{flag} should remain on the filtered log path"
);
}
}
#[test]
fn test_diff_shape_flags_request_raw_output() {
// These change the shape of git's raw output (diffstat, name lists)
// the same way -p does — RTK's injected --pretty=format markers
// can't coexist with them, so they must stay on the raw path too.
for flag in [
"--dirstat",
"--dirstat=files",
"--name-only",
"--name-status",
"--numstat",
"--raw",
"--shortstat",
"--stat",
"--stat=80",
"--summary",
] {
let args = vec![flag.to_string()];
assert!(
requests_raw_log_output(&args),
"{flag} changes output shape and should request raw output"
);
}
}
#[test]
fn test_diff_shape_flag_as_value_of_grep_is_not_misdetected() {
// `git log --grep --stat` searches for the literal string
// "--stat"; git consumes it as --grep's value, not the --stat flag.
let args = vec!["--grep".to_string(), "--stat".to_string()];
assert!(
!requests_raw_log_output(&args),
"--stat as the value of --grep should stay on the filtered path"
);
}
#[test]
fn test_patch_flag_as_value_of_grep_is_not_misdetected() {
// `git log --grep -p` searches commit messages for the literal
// string "-p"; git does not treat it as the patch flag.
for opt in [
"--author",
"--committer",
"--diff-algorithm",
"--diff-filter",
"--grep",
"-G",
"-S",
] {
let args = vec![opt.to_string(), "-p".to_string()];
assert!(
!requests_raw_log_output(&args),
"-p as the value of {opt} should stay on the filtered path"
);
}
}
#[test]
fn test_patch_flag_still_detected_after_value_taking_option() {
// The value-taking option consumes only its own value token;
// a genuine -p later in the args still triggers the raw path.
let args = vec!["--grep".to_string(), "fix".to_string(), "-p".to_string()];
assert!(
requests_raw_log_output(&args),
"a real -p after --grep's value should still request raw output"
);
}
#[test]
fn test_optional_value_options_do_not_consume_next_token() {
// These options only take an attached value (-U3, --unified=3,
// --expand-tabs=4, --max-parents=2); a bare separate token after
// them is not their value, so it must not be swallowed. Confirmed
// against git 2.53.0: e.g. `git log --expand-tabs 4` fails with
// "fatal: ambiguous argument '4'" rather than treating 4 as the
// option's value.
for opt in [
"-U",
"--unified",
"--expand-tabs",
"--max-parents",
"--min-parents",
] {
let args = vec![opt.to_string(), "-p".to_string()];
assert!(
requests_raw_log_output(&args),
"a real -p after {opt} should still request raw output"
);
}
}
#[test]
fn test_real_flag_args_drops_value_taking_option_values() {
// `--grep`'s value is not itself a flag and must not appear in the
// filtered set, even when it looks like -N, --pretty, or --merges.
let args = vec!["--grep".to_string(), "-5".to_string()];
assert_eq!(real_flag_args(&args), vec!["--grep"]);
}
#[test]
fn test_real_flag_args_keeps_limit_flag_drops_its_value() {
let args = vec!["-n".to_string(), "15".to_string()];
assert_eq!(real_flag_args(&args), vec!["-n"]);
let args = vec!["--max-count".to_string(), "25".to_string()];
assert_eq!(real_flag_args(&args), vec!["--max-count"]);
}
#[test]
fn test_real_flag_args_keeps_genuine_flags() {
let args = vec!["--grep".to_string(), "fix".to_string(), "--oneline".to_string()];
assert_eq!(real_flag_args(&args), vec!["--grep", "--oneline"]);
}
#[test]
fn test_grep_value_looking_like_limit_flag_is_not_misdetected() {
// `git log --grep -5` searches commit messages for the literal
// string "-5"; it is not a request to limit output to 5 commits.
let args = vec!["--grep".to_string(), "-5".to_string()];
assert!(
!real_flag_args(&args)
.iter()
.any(|arg| arg.starts_with('-') && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit())),
"-5 as the value of --grep should not be seen as a limit flag"
);
assert_eq!(
parse_user_limit(&args),
None,
"-5 as the value of --grep should not be parsed as a limit"
);
}
#[test]
fn test_grep_value_looking_like_format_flag_is_not_misdetected() {
// `git log --grep --pretty` searches for the literal string
// "--pretty"; git consumes it as --grep's value, not a format flag.
let args = vec!["--grep".to_string(), "--pretty".to_string()];
assert!(
!real_flag_args(&args)
.iter()
.any(|arg| arg.starts_with("--pretty")),
"--pretty as the value of --grep should not be seen as a format flag"
);
}
#[test]
fn test_grep_value_looking_like_merges_flag_is_not_misdetected() {
// `git log --grep --merges` searches for the literal string
// "--merges"; git consumes it as --grep's value, not --merges.
let args = vec!["--grep".to_string(), "--merges".to_string()];
assert!(
!real_flag_args(&args).contains(&"--merges"),
"--merges as the value of --grep should not be seen as the --merges flag"
);
}
#[test]
fn test_parse_user_limit_skips_foreign_option_values() {
// A real limit later in the args is still found after a
// value-taking option's value is skipped.
let args = vec![
"--grep".to_string(),
"-5".to_string(),
"-20".to_string(),
];
assert_eq!(parse_user_limit(&args), Some(20));
}
#[test]
fn test_log_arg_tokens_stop_at_pathspec_separator() {
// `git log -- -5` means "history for the path literally named -5",
// not a limit flag — tokens after `--` must be ignored entirely.
let args = vec!["--".to_string(), "-5".to_string()];
assert!(
real_flag_args(&args).is_empty(),
"-5 after -- is a pathspec, not a flag"
);
assert_eq!(
parse_user_limit(&args),
None,
"-5 after -- should not be parsed as a limit"
);
}
#[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"));
}
// --- commit output parsing ---
#[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");
}
/// Regression test: multibyte branch name must not panic (was byte-slicing before fix)
#[test]
fn test_parse_commit_output_multibyte_branch() {
let line = "[分支名 abc1234def] 提交消息";
assert_eq!(parse_commit_output(line), "ok abc1234");
}
/// Regression test: Thai branch name (3 bytes per char)
#[test]
fn test_parse_commit_output_thai_branch() {
let line = "[สาขา abc1234def] commit message";
assert_eq!(parse_commit_output(line), "ok abc1234");
}
/// Regression: git prints hook output before its own summary. A first line
/// that opens with a multi-byte character and contains ']' used to panic on
/// `line[1..]` ("byte index 1 is not a char boundary").
#[test]
fn test_parse_commit_output_multibyte_prefix_does_not_panic() {
assert_eq!(parse_commit_output("✅ lint passed]"), "ok");
assert_eq!(parse_commit_output("→ hook] done"), "ok");
}
/// The same shape as above, but with a real summary after the hook text —
/// the hash must still be found via the bracket pair.
#[test]
fn test_parse_commit_output_after_multibyte_hook_prefix() {
assert_eq!(
parse_commit_output("✅ [main abc1234def] add feature"),
"ok abc1234"
);
}
/// A U+FFFD from lossily decoded output is itself multi-byte.
#[test]
fn test_parse_commit_output_replacement_char_prefix() {
assert_eq!(parse_commit_output("\u{FFFD}oops]"), "ok");
}
/// A closing bracket before any opening one must not slice backwards.
#[test]
fn test_parse_commit_output_close_before_open() {
assert_eq!(parse_commit_output("] stray [main abc1234def]"), "ok");
}
#[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() {
// Hash shorter than 7 chars — treat as "ok" (no hash shown)
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");
}
// --- commit outcome classification (issue #2494) ---
#[test]
fn test_classify_commit_success_extracts_hash() {
match classify_commit_outcome(true, "[main abc1234def] add feature", 0) {
CommitOutcome::Ok(s) => assert_eq!(s, "ok abc1234"),
CommitOutcome::Failed(_) => panic!("successful commit must be Ok"),
}
}
#[test]
fn test_classify_commit_success_empty_stdout() {
match classify_commit_outcome(true, "", 0) {
CommitOutcome::Ok(s) => assert_eq!(s, "ok"),
CommitOutcome::Failed(_) => panic!("successful commit must be Ok"),
}
}
#[test]
fn test_classify_commit_nothing_to_commit_is_failure() {
match classify_commit_outcome(
false,
"On branch main\nnothing to commit, working tree clean",
1,
) {
CommitOutcome::Failed(code) => assert_eq!(code, 1),
CommitOutcome::Ok(s) => panic!("nothing-to-commit must not be ok: {}", s),
}
}
#[test]
fn test_classify_commit_hook_abort_propagates_exit_code() {
match classify_commit_outcome(false, "pre-commit hook failed", 2) {
CommitOutcome::Failed(code) => assert_eq!(code, 2),
CommitOutcome::Ok(_) => panic!("hook abort must be a failure"),
}
}
/// 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_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() {
// 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 additions truncated"),
"Expected '50 additions 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() {
// 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
);
}
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
);
}
}