mirror of
https://github.com/rtk-ai/rtk.git
synced 2026-09-19 07:33:17 +08:00
75dda6c097
A hunk truncated at exactly one line over the cap rendered `... (1 deletions truncated)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4721 lines
164 KiB
Rust
4721 lines
164 KiB
Rust
//! 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 != ¤t && !local.contains(r))
|
||
.collect();
|
||
if !remote_only.is_empty() {
|
||
const MAX_REMOTE_BRANCHES: usize = CAP_WARNINGS;
|
||
result.push(format!(" remote-only ({}):", remote_only.len()));
|
||
for b in remote_only.iter().take(MAX_REMOTE_BRANCHES) {
|
||
result.push(format!(" {}", b));
|
||
}
|
||
if remote_only.len() > MAX_REMOTE_BRANCHES {
|
||
result.push(format!(
|
||
" ... +{} more",
|
||
remote_only.len() - MAX_REMOTE_BRANCHES
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
result.join("\n")
|
||
}
|
||
|
||
fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32> {
|
||
let timer = tracking::TimedExecution::start();
|
||
|
||
if verbose > 0 {
|
||
eprintln!("git fetch");
|
||
}
|
||
|
||
let mut cmd = git_cmd(global_args);
|
||
cmd.arg("fetch");
|
||
for arg in args {
|
||
cmd.arg(arg);
|
||
}
|
||
|
||
let result = exec_capture(&mut cmd).context("Failed to run git fetch")?;
|
||
let raw = result.combined();
|
||
|
||
if !result.success() {
|
||
eprintln!("FAILED: git fetch");
|
||
if !result.stderr.trim().is_empty() {
|
||
eprintln!("{}", result.stderr);
|
||
}
|
||
return Ok(result.exit_code);
|
||
}
|
||
|
||
// 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
|
||
);
|
||
}
|
||
}
|