use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
pub const DEFAULT_MAX_INPUT_BYTES: u64 = 64 * 1024 * 1024;
const AFTER_LONG_HELP: &str = "\
Examples:
# List addressable sub-hunks: 1-based per-file index + 16-hex content id
git diff src/main.rs | hunkpick list
# Machine-readable listing (adds id_count: how many sub-hunks share an id)
git diff src/main.rs | hunkpick list --json
# Stage sub-hunks 1 and 3 of a single-file diff
git diff src/main.rs | hunkpick select 1,3 | git apply --cached
# Multi-file diff (git diff over several files): address sub-hunks per path.
# A bare index needs a single-file diff; with many files every selector needs path:.
git diff src/a.rs src/b.rs src/c.rs | hunkpick select src/a.rs:1,3 src/c.rs:2-4 | git apply --cached
# Every sub-hunk of a file (or of a single-file diff)
git diff | hunkpick select src/main.rs:* | git apply --cached
# Split original hunk 1 at new-file line 5 (cut point must be a context line)
git diff src/lib.rs | hunkpick split 1 --at 5
Content ids (@<id>):
Every sub-hunk in `list` output carries a stable 16-hex content id, also
accepted by `select` as @<id>. The id hashes only the file path and the
sub-hunk's changed (+/-) lines -- not its context or the @@ line numbers --
so it survives a re-diff even when staging a neighbour renumbers the bare
indices or rewrites the surrounding context. Capture it once, reuse it across
the whole diff -> stage -> re-diff loop. (Byte-identical changes share an id;
`list --json` reports id_count, 1 = unique.)
# Select by content id (stable across re-diffs)
git diff | hunkpick select @8002dd73f0dfd2f4 | git apply --cached
# In a multi-file diff an id still addresses its own file: the path is part of
# the id, so the same edit in another file gets a different id.
git diff src/a.rs src/b.rs src/c.rs | hunkpick select @8002dd73f0dfd2f4 | git apply --cached
# Several ids at once (space-separated); mix with path: selectors freely.
# Read the ids from `list --json` first (the machine-readable form), then select:
git diff | hunkpick list --json
git diff | hunkpick select @8002dd73f0dfd2f4 @bf7bdaaf30c1e2d4 src/lib.rs:2 | git apply --cached
# Full loop: list ONCE, then stage groups by @id (one or more ids each),
# re-running git diff every round. The ids from the single `list` stay valid
# even as staging renumbers the bare indices, so the listing is never re-read.
# `*` takes whatever sub-hunks are left at the end.
git diff src/x.js | hunkpick list --json # capture ids once (id_count flags shared ids)
git diff src/x.js | hunkpick select @bf7bdaaf30c1e2d4 | git apply --cached && git commit -m 'fix: ...'
git diff src/x.js | hunkpick select @058b36528575a870 @399e1cd421e268cc | git apply --cached && git commit -m 'feat: ...'
git diff src/x.js | hunkpick select '*' | git apply --cached && git commit -m 'chore: ...'
Each subcommand has its own detailed --help (full selector grammar, content-id
rules, verification flags):
hunkpick list --help | hunkpick select --help | hunkpick split --help";
const AFTER_SHORT_HELP: &str = "Run 'hunkpick --help' for examples and content-id usage.";
#[derive(Parser, Debug)]
#[command(
name = "hunkpick",
version,
after_help = AFTER_SHORT_HELP,
after_long_help = AFTER_LONG_HELP
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(clap::Args, Debug)]
pub struct VerifyOpts {
#[arg(long)]
pub no_verify_result_diff_internal: bool,
#[arg(long)]
pub verify_result_diff_git: bool,
#[arg(short = 'C', value_name = "DIR", requires = "verify_result_diff_git")]
pub dir: Option<PathBuf>,
}
#[derive(clap::Args, Debug)]
pub struct InputOpts {
#[arg(short = 'i', long = "input", value_name = "FILE")]
pub input: Option<PathBuf>,
#[arg(long = "max-input-bytes", value_name = "N", default_value_t = DEFAULT_MAX_INPUT_BYTES)]
pub max_input_bytes: u64,
}
#[derive(Subcommand, Debug)]
pub enum Command {
List {
#[arg(long)]
json: bool,
#[arg(long, value_enum, default_value_t = ColorMode::Auto)]
color: ColorMode,
#[command(flatten)]
input: InputOpts,
},
Select {
#[arg(verbatim_doc_comment)]
selectors: Vec<String>,
#[command(flatten)]
input: InputOpts,
#[command(flatten)]
verify: VerifyOpts,
},
Split {
hunk: String,
#[arg(long = "at", value_delimiter = ',', required = true)]
at: Vec<u32>,
#[command(flatten)]
input: InputOpts,
#[command(flatten)]
verify: VerifyOpts,
},
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum ColorMode {
Auto,
Always,
Never,
}
pub fn resolve_color(mode: ColorMode) -> bool {
let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
let no_color = std::env::var_os("NO_COLOR").is_some();
resolve_color_with(mode, is_tty, no_color)
}
pub fn resolve_color_with(mode: ColorMode, is_tty: bool, no_color: bool) -> bool {
match mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => is_tty && !no_color,
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn never_disables_color() {
assert!(!resolve_color_with(ColorMode::Never, true, false));
}
#[test]
fn always_enables_even_without_tty() {
assert!(resolve_color_with(ColorMode::Always, false, false));
}
#[test]
fn auto_follows_tty_unless_no_color() {
assert!(resolve_color_with(ColorMode::Auto, true, false));
assert!(!resolve_color_with(ColorMode::Auto, true, true));
assert!(!resolve_color_with(ColorMode::Auto, false, false));
}
#[test]
fn split_with_verify_flags_parses() {
let cli = Cli::try_parse_from([
"hunkpick",
"split",
"f",
"--at",
"3,5",
"--verify-result-diff-git",
"-C",
"/tmp",
])
.unwrap();
match cli.command {
Command::Split {
hunk, at, verify, ..
} => {
assert_eq!(hunk, "f");
assert_eq!(at, vec![3, 5]);
assert!(verify.verify_result_diff_git);
assert_eq!(verify.dir.as_deref(), Some(std::path::Path::new("/tmp")));
}
_ => panic!("expected split"),
}
}
#[test]
fn select_no_internal_flag_parses() {
let cli = Cli::try_parse_from([
"hunkpick",
"select",
"1,3",
"--no-verify-result-diff-internal",
])
.unwrap();
match cli.command {
Command::Select {
selectors, verify, ..
} => {
assert_eq!(selectors, vec!["1,3".to_string()]);
assert!(verify.no_verify_result_diff_internal);
}
_ => panic!("expected select"),
}
}
#[test]
fn dash_c_without_git_flag_is_rejected_by_clap() {
let res = Cli::try_parse_from(["hunkpick", "select", "1", "-C", "/tmp"]);
assert!(res.is_err());
}
}