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) -> Vec { let mut args: Vec = 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, ) -> 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, ) -> Cmd { let args: Vec = 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, ) -> Cmd { let mut args: Vec = 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, ) -> Cmd { let mut args: Vec = 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 = 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 = 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, ) -> Cmd { let mut args: Vec = 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, detect_renames: Option, merge_parents: bool, ) -> Cmd { let mut args: Vec = 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 { 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, Option, DiffStatus); fn parse_raw_header(head: &str, abbrev: usize) -> Result { 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 { 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, 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::from_hex(hex.as_bytes()) .map_err(|source| BabyError::Custom(format!("invalid oid `{}`: {}", hex, source))) } pub fn parse_numstat_output(data: &[u8]) -> Result, 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 { if s == "-" { return Ok(0); } s.parse::() .map_err(|e| BabyError::Custom(format!("invalid numstat number `{}`: {}", s, e))) } pub fn parse_shortstat_output(data: &[u8]) -> Result { let s = String::from_utf8_lossy(data); let mut stat = ShortStat::default(); let mut prev: Option = None; for part in s.split_whitespace() { if let Ok(n) = part.trim_matches(',').parse::() { prev = Some(n); continue; } let word = part .trim_matches(|c: char| c == ',' || c == ')') .trim_end_matches("(+") .trim_end_matches("(-"); match word { "file" | "files" => stat.files_changed = prev.take().unwrap_or(0), "insertion" | "insertions" => stat.additions = prev.take().unwrap_or(0), "deletion" | "deletions" => stat.deletions = prev.take().unwrap_or(0), _ => {} } } Ok(stat) } pub fn parse_commit_diff_output( data: &[u8], abbrev: usize, ) -> Result, 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 = Vec::new(); let mut index_map: std::collections::HashMap = 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 { parse_raw_diff_line(line, abbrev) }