Skip to content
This repository was archived by the owner on Oct 9, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 36 additions & 21 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,81 +11,108 @@ use git2::{DiffStatsFormat, Repository};
use std::{
ffi::OsString,
io::{self, IsTerminal, Write},
num::NonZeroUsize,
path::{Component, Path, PathBuf},
sync::Arc,
};

/// The metadata outputs, named one by one in conflicts so clap's error names
/// the flag that was given rather than the whole group.
const METADATA: [&str; 5] = ["name_only", "name_status", "stat", "numstat", "shortstat"];

/// Structural diffs with Git-style comparison inputs
#[derive(Parser)]
#[command(
name = env!("CARGO_BIN_NAME"),
version,
group(ArgGroup::new("metadata").args(["name_only", "name_status", "stat", "numstat", "shortstat"])),
group(ArgGroup::new("metadata").args(METADATA)),
group(ArgGroup::new("names").args(["name_only", "name_status"])),
after_help = "Examples:\n diffr\n diffr --cached\n diffr main...HEAD -- src/\n diffr --no-index -- before.rs after.rs\n diffr main HEAD --format ndjson\n\nUnsupported Git flags are rejected; this is not a complete git diff implementation."
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
/// Directory in the repository to diff from; paths are relative to it
#[arg(long, default_value = ".")]
repo: PathBuf,
/// Concurrent file diffs for --format ndjson; results are emitted as each finishes
#[arg(short, long, default_value_t = 16)]
jobs: usize,
#[arg(short, long, default_value = "16")]
jobs: NonZeroUsize,
/// File tag priority: files carrying an earlier listed tag come first
#[arg(long, value_delimiter = ',')]
order: Vec<String>,
/// Compare the index with HEAD, or with the given revision
#[arg(long, visible_alias = "staged")]
cached: bool,
/// Compare from the merge base of the given revision and the second one, or HEAD
#[arg(long)]
merge_base: bool,
#[arg(long)]
/// Compare two files on disk rather than Git revisions
#[arg(long, conflicts_with_all = ["cached", "merge_base", "null"], conflicts_with_all = METADATA)]
no_index: bool,
/// Swap the two sides of the comparison
#[arg(short = 'R', long)]
reverse: bool,
/// Exit with 1 when there are differences
#[arg(long)]
exit_code: bool,
/// Print nothing; exit with 1 when there are differences
#[arg(long)]
quiet: bool,
/// Print the names of changed files
#[arg(long)]
name_only: bool,
/// Print the names and statuses of changed files
#[arg(long)]
name_status: bool,
/// Print a diffstat
#[arg(long)]
stat: bool,
/// Print added and deleted line counts for each file
#[arg(long)]
numstat: bool,
/// Print the diffstat's summary line
#[arg(long)]
shortstat: bool,
#[arg(short = 'z', long)]
/// Terminate --name-only and --name-status output with NULs
#[arg(short = 'z', long, requires = "names")]
null: bool,
/// Do not detect renames
#[arg(long)]
no_renames: bool,
/// Detect renames, which is the default
#[arg(short = 'M', long, conflicts_with = "no_renames")]
find_renames: bool,
/// Unchanged lines kept around each change; defaults to plugins.bundled.context.lines
#[arg(short = 'U', long)]
unified: Option<u32>,
/// Write the event stream to stdout instead of opening the terminal UI
#[arg(long)]
#[arg(long, conflicts_with = "quiet", conflicts_with_all = METADATA)]
format: Option<Format>,
/// Emit initial files followed by deferred annotations (NDJSON v4)
#[arg(long, requires = "format")]
#[arg(long, requires = "format", conflicts_with = "quiet", conflicts_with_all = METADATA)]
stream_annotations: bool,
/// Include every token's tree-sitter capture name in --format ndjson output
#[arg(long)]
#[arg(long, requires = "format", conflicts_with = "quiet", conflicts_with_all = METADATA)]
syntax: bool,
/// Columns for --stat; defaults to the terminal's width
#[arg(long)]
width: Option<usize>,
/// Don't consider comments when diffing
#[arg(long)]
ignore_comments: bool,
/// Files larger than this many bytes on either side get a line diff; defaults to diff.byte_limit
#[arg(long)]
byte_limit: Option<usize>,
/// The largest AST matching graph to explore for one file; defaults to diff.graph_limit
#[arg(long)]
graph_limit: Option<usize>,
/// Files with more parse errors than this get a line diff; defaults to diff.parse_error_limit
#[arg(long)]
parse_error_limit: Option<usize>,
/// Revisions, then paths
items: Vec<OsString>,
/// Paths
#[arg(last = true)]
paths: Vec<OsString>,
}
Expand Down Expand Up @@ -162,9 +189,6 @@ pub(crate) fn run() -> Result<i32> {
if !streaming && !metadata_or_quiet {
return launch_tui(&frontend_args, true);
}
if streaming && metadata_or_quiet {
return Err("--format ndjson cannot be combined with --quiet or metadata output".into());
}
let stream_options = crate::protocol::stream::Options {
syntax: args.syntax,
updates: args.stream_annotations,
Expand All @@ -176,9 +200,6 @@ pub(crate) fn run() -> Result<i32> {
stream_options,
);
}
if args.null && !args.name_only && !args.name_status {
return Err("-z currently requires --name-only or --name-status".into());
}
let location = std::fs::canonicalize(&args.repo)?;
let repo = Repository::discover(&location)?;
let workspace = repo.workdir().unwrap_or(repo.path());
Expand Down Expand Up @@ -215,12 +236,9 @@ pub(crate) fn run() -> Result<i32> {
)?;
session.diff_options = diff_options;
let changed = session.remaining() > 0;
if args.jobs == 0 {
return Err("--jobs must be at least 1".into());
}
let ended = crate::protocol::stream::write(
session,
args.jobs,
args.jobs.get(),
Arc::new(pipeline),
stream_options,
&mut io::stdout().lock(),
Expand Down Expand Up @@ -461,9 +479,6 @@ fn no_index(
if paths.len() != 2 {
return Err("--no-index requires two file paths".into());
}
if args.cached || args.merge_base || args.metadata() || args.null {
return Err("--no-index currently supports structural file output and --quiet, not index or metadata options".into());
}
let mut paths = paths;
if args.reverse {
paths.swap(0, 1);
Expand Down
Loading
Loading