Skip to content
Clone
fad06fd
588 lines 18.3 KB Rust
use std::path::{Path, PathBuf};
use gix::ObjectId;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::command::error::CmdError;
use crate::diff::types::{
ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus,
NumStat, RangeDiffSpec, ShortStat, WhitespaceMode,
};
use crate::error::BabyError;
use crate::share;
pub const CALLER: &str = "gitbaby::diff";
pub fn validate_paths(paths: &[PathBuf]) -> Result<(), BabyError> {
for p in paths {
share::validate_local_no_escape(p)?;
}
Ok(())
}
pub fn build_diff_args(req: &DiffRequest, opts: &DiffOptions, hex_len: Option<u8>) -> Vec<String> {
let mut args: Vec<String> = vec!["diff".into(), "--full-index".into()];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
match opts.ignore_whitespace {
Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()),
Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()),
Some(WhitespaceMode::None) | None => {}
}
if let Some(threshold) = opts.detect_renames {
args.push(format!("--find-renames={}%", threshold));
}
if opts.word_diff {
args.push("--word-diff=porcelain".into());
}
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
args
}
pub fn build_raw_diff_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let args = build_diff_args(req, opts, hex_len);
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_patch_diff_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
_opts: &DiffOptions,
_hex_len: Option<u8>,
) -> Cmd {
let args: Vec<String> = vec![
"format-patch".into(),
"--stdout".into(),
format!("{}..{}", req.left.to_hex(), req.right.to_hex()),
"--no-signature".into(),
];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_commit_diff_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let mut args: Vec<String> = vec![
"-c".into(),
"core.quotepath=off".into(),
"-c".into(),
"diff.noprefix=false".into(),
"diff".into(),
"--no-color".into(),
"--patch".into(),
"--raw".into(),
];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
args.push("--full-index".into());
let empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let is_empty_tree_base = req.left == empty_tree;
if is_empty_tree_base {
args.push("--no-renames".into());
} else if let Some(r) = opts.detect_renames {
args.push(format!("--find-renames={}%", r));
} else {
args.push("--find-renames=50%".into());
}
match opts.ignore_whitespace {
Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()),
Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()),
Some(WhitespaceMode::None) | None => {}
}
if opts.word_diff {
args.push("--word-diff=porcelain".into());
}
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_commit_delta_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let mut args: Vec<String> = vec!["diff".into(), "--raw".into(), "--find-renames=30%".into()];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
args.push("--full-index".into());
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
let _ = opts;
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_numstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd {
let mut args: Vec<String> = vec!["diff".into(), "--numstat".into(), "-z".into()];
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_shortstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd {
let mut args: Vec<String> = vec!["diff".into(), "--shortstat".into()];
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
let _ = opts;
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_range_diff_cmd(
dir: &Path,
env: Env,
spec: &RangeDiffSpec,
_opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let mut args: Vec<String> = vec!["range-diff".into(), "--no-color".into()];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
match spec {
RangeDiffSpec::Range(r1, r2) => {
args.push(r1.clone());
args.push(r2.clone());
}
RangeDiffSpec::RevisionRange(r1, r2) => {
args.push(format!("{}...{}", r1, r2));
}
RangeDiffSpec::BaseWithRevisions(base, r1, r2) => {
args.push(base.clone());
args.push(r1.clone());
args.push(r2.clone());
}
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_find_changed_paths_cmd(
dir: &Path,
env: Env,
_requests: &[ChangedPathRequest],
filter_status: Option<char>,
detect_renames: Option<u8>,
merge_parents: bool,
) -> Cmd {
let mut args: Vec<String> = vec![
"diff-tree".into(),
"-z".into(),
"--stdin".into(),
"-r".into(),
"--root".into(),
"--no-commit-id".into(),
];
if let Some(ch) = filter_status {
args.push(format!("--diff-filter={}", ch));
} else {
args.push("--diff-filter=AMDTCR".into());
}
match detect_renames {
Some(threshold) => args.push(format!("--find-renames={}%", threshold)),
None => args.push("--no-renames".into()),
}
if merge_parents {
args.push("-m".into());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_diff_tree_stdin(requests: &[ChangedPathRequest]) -> Vec<u8> {
let mut buf = Vec::new();
for r in requests {
match r {
ChangedPathRequest::Commit(oid) => {
buf.extend_from_slice(oid.to_string().as_bytes());
buf.push(b'\n');
}
ChangedPathRequest::TreePair(base, head) => {
buf.extend_from_slice(head.to_string().as_bytes());
buf.push(b' ');
buf.extend_from_slice(base.to_string().as_bytes());
buf.push(b'\n');
}
}
}
buf
}
pub fn classify_stderr(stderr: &str, left: &str, right: &str) -> BabyError {
if stderr.contains("fatal: bad revision") || stderr.contains("bad revision") {
return BabyError::RevisionNotFound(format!("{}..{}", left, right));
}
BabyError::Custom(format!("git diff failed: {}", stderr.trim()))
}
pub fn classify_cmd_error(err: CmdError, left: &str, right: &str) -> BabyError {
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, left, right),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
const ZERO_OID_HEX: &str = "0000000000000000000000000000000000000000";
type ParsedRawHeader = (u32, u32, Option<ObjectId>, Option<ObjectId>, DiffStatus);
fn parse_raw_header(head: &str, abbrev: usize) -> Result<ParsedRawHeader, BabyError> {
let mut fields = head.splitn(5, ' ');
let old_mode_raw = fields.next().unwrap_or("").trim_start_matches(':');
let new_mode_raw = fields.next().unwrap_or("");
let old_oid_raw = fields.next().unwrap_or("");
let new_oid_raw = fields.next().unwrap_or("");
let status_raw = fields.next().unwrap_or("");
if old_mode_raw.is_empty()
|| new_mode_raw.is_empty()
|| old_oid_raw.is_empty()
|| new_oid_raw.is_empty()
|| status_raw.is_empty()
{
return Err(BabyError::Custom(format!(
"raw diff line too short: `{}`",
head
)));
}
let status_ch = status_raw
.chars()
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff status missing: `{}`", head)))?;
let status = DiffStatus::from_char(status_ch)
.ok_or_else(|| BabyError::Custom(format!("unknown diff status `{}`", status_ch)))?;
let old_mode = share::parse_git_mode(old_mode_raw)?;
let new_mode = share::parse_git_mode(new_mode_raw)?;
let old_oid = if old_oid_raw == ZERO_OID_HEX || old_oid_raw.len() < abbrev {
None
} else {
Some(parse_oid_prefix(old_oid_raw)?)
};
let new_oid = if new_oid_raw == ZERO_OID_HEX || new_oid_raw.len() < abbrev {
None
} else {
Some(parse_oid_prefix(new_oid_raw)?)
};
Ok((old_mode, new_mode, old_oid, new_oid, status))
}
pub fn parse_raw_diff_line(line: &str, abbrev: usize) -> Result<ChangedPath, BabyError> {
let (head, rest) = line
.split_once('\t')
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let (old_mode, new_mode, old_oid, new_oid, status) = parse_raw_header(head, abbrev)?;
let (old_path, new_path) = match status {
DiffStatus::Renamed | DiffStatus::Copied => rest
.split_once('\t')
.map(|(o, n)| (Some(PathBuf::from(o)), PathBuf::from(n)))
.unwrap_or((None, PathBuf::from(rest))),
_ => (None, PathBuf::from(rest)),
};
Ok(ChangedPath {
old_path,
new_path,
old_mode,
new_mode,
old_oid,
new_oid,
status,
})
}
pub fn parse_diff_tree_z_stream(
stdout: &[u8],
abbrev: usize,
) -> Result<Vec<ChangedPath>, BabyError> {
let chunks: Vec<&[u8]> = stdout.split(|b| *b == 0).collect();
let mut out = Vec::new();
let mut i = 0;
while i < chunks.len() {
if chunks[i].is_empty() {
i += 1;
continue;
}
let head = String::from_utf8_lossy(chunks[i]).into_owned();
if !head.starts_with(':') {
i += 1;
continue;
}
let (old_mode, new_mode, old_oid, new_oid, status) = parse_raw_header(&head, abbrev)?;
i += 1;
let first = chunks
.get(i)
.map(|c| String::from_utf8_lossy(c).into_owned());
i += 1;
let second = if matches!(status, DiffStatus::Renamed | DiffStatus::Copied) {
let s = chunks
.get(i)
.map(|c| String::from_utf8_lossy(c).into_owned());
i += 1;
s
} else {
None
};
let (old_path, new_path) = match status {
DiffStatus::Renamed | DiffStatus::Copied => (
first.filter(|s| !s.is_empty()).map(PathBuf::from),
PathBuf::from(second.filter(|s| !s.is_empty()).unwrap_or_default()),
),
_ => (
None,
PathBuf::from(first.filter(|s| !s.is_empty()).unwrap_or_default()),
),
};
out.push(ChangedPath {
old_path,
new_path,
old_mode,
new_mode,
old_oid,
new_oid,
status,
});
}
Ok(out)
}
pub fn parse_oid_prefix(hex: &str) -> Result<ObjectId, BabyError> {
ObjectId::from_hex(hex.as_bytes())
.map_err(|source| BabyError::Custom(format!("invalid oid `{}`: {}", hex, source)))
}
pub fn parse_numstat_output(data: &[u8]) -> Result<Vec<NumStat>, BabyError> {
let mut stats = Vec::new();
let s = String::from_utf8_lossy(data);
for chunk in s.split('\0') {
if chunk.is_empty() {
continue;
}
let mut parts = chunk.splitn(3, '\t');
let adds_raw = parts.next().unwrap_or("");
let dels_raw = parts.next().unwrap_or("");
let last = parts.next().unwrap_or("");
let mut iter = last.splitn(2, '\t');
let first = iter.next().unwrap_or("");
let second = iter.next();
let mut stat = NumStat {
additions: parse_numstat_number(adds_raw)?,
deletions: parse_numstat_number(dels_raw)?,
path: PathBuf::new(),
old_path: None,
};
match second {
Some(p) => {
stat.old_path = Some(PathBuf::from(first));
stat.path = PathBuf::from(p);
}
None => {
stat.path = PathBuf::from(first);
}
}
stats.push(stat);
}
Ok(stats)
}
fn parse_numstat_number(s: &str) -> Result<u64, BabyError> {
if s == "-" {
return Ok(0);
}
s.parse::<u64>()
.map_err(|e| BabyError::Custom(format!("invalid numstat number `{}`: {}", s, e)))
}
pub fn parse_shortstat_output(data: &[u8]) -> Result<ShortStat, BabyError> {
let s = String::from_utf8_lossy(data);
let mut stat = ShortStat::default();
let mut rest = s.trim();
if let Some(idx) = rest.find("files changed") {
stat.files_changed = parse_u64_before(rest, idx)?;
rest = &rest[idx + "files changed".len()..];
}
if let Some(idx) = rest.find("insertions") {
stat.additions = parse_u64_before(rest, idx)?;
rest = &rest[idx + "insertions".len()..];
}
if let Some(idx) = rest.find("deletions") {
stat.deletions = parse_u64_before(rest, idx)?;
}
Ok(stat)
}
fn parse_u64_before(s: &str, before: usize) -> Result<u64, BabyError> {
let prefix = &s[..before];
let trimmed = prefix.trim().trim_end_matches(',').trim();
trimmed
.parse::<u64>()
.map_err(|e| BabyError::Custom(format!("invalid number `{}`: {}", trimmed, e)))
}
pub fn parse_commit_diff_output(
data: &[u8],
abbrev: usize,
) -> Result<Vec<ChangedPathDiff>, BabyError> {
if data.is_empty() {
return Ok(Vec::new());
}
let diff_git_prefix = b"diff --git ";
let raw_end = data
.windows(diff_git_prefix.len())
.position(|w| w == diff_git_prefix)
.unwrap_or(data.len());
let raw_part = &data[..raw_end];
let patch_part = &data[raw_end..];
let mut entries: Vec<ChangedPathDiff> = Vec::new();
let mut index_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let raw_str = String::from_utf8_lossy(raw_part);
for line in raw_str.lines() {
let line = line.trim();
if line.is_empty() || !line.starts_with(':') {
continue;
}
if let Ok(cp) = parse_raw_diff_line(line, abbrev) {
let key = match &cp.old_path {
Some(old) => format!(
"a/{} b/{}",
old.to_string_lossy(),
cp.new_path.to_string_lossy()
),
None => format!("a/{0} b/{0}", cp.new_path.to_string_lossy()),
};
let idx = entries.len();
index_map.insert(key, idx);
entries.push(ChangedPathDiff {
path: cp,
lines_added: 0,
lines_removed: 0,
binary: false,
patch: None,
});
}
}
if !patch_part.is_empty() {
let mut cur_pos = 0;
while cur_pos < patch_part.len() {
let next_sep = patch_part[cur_pos + 1..]
.windows(b"\ndiff --git ".len())
.position(|w| w == b"\ndiff --git ")
.map(|p| cur_pos + 1 + p);
let block_end = next_sep.unwrap_or(patch_part.len());
let block = &patch_part[cur_pos..block_end];
cur_pos = block_end + 1;
if let Some(first_line_end) = block.iter().position(|&b| b == b'\n') {
let header = std::str::from_utf8(&block[..first_line_end]).unwrap_or("");
if let Some(tail) = header.strip_prefix("diff --git ")
&& let Some(&idx) = index_map.get(tail)
{
apply_patch_slice(&mut entries[idx], block);
}
}
}
}
Ok(entries)
}
const MAX_SINGLE_PATCH_BYTES: usize = 256 * 1024;
fn apply_patch_slice(e: &mut ChangedPathDiff, slice: &[u8]) {
let mut added = 0u64;
let mut removed = 0u64;
let mut binary = false;
for line in slice.split(|&b| b == b'\n') {
if line.starts_with(b"Binary files") {
binary = true;
} else if line.starts_with(b"+") && !line.starts_with(b"+++") {
added = added.saturating_add(1);
} else if line.starts_with(b"-") && !line.starts_with(b"---") {
removed = removed.saturating_add(1);
}
}
e.lines_added = added;
e.lines_removed = removed;
e.binary = binary;
if binary {
e.patch = None;
} else if slice.len() > MAX_SINGLE_PATCH_BYTES {
e.patch = Some(slice[..MAX_SINGLE_PATCH_BYTES].to_vec());
} else {
e.patch = Some(slice.to_vec());
}
}
pub fn parse_diff_tree_status_line(line: &str, abbrev: usize) -> Result<ChangedPath, BabyError> {
parse_raw_diff_line(line, abbrev)
}