use std::path::{Path, PathBuf}; use crate::command::cmd::Cmd; use crate::command::env::Env; use crate::command::error::CmdError; use crate::commit::types::{ CommitInfo, CommitShortStat, CommitStats, ListCommitsOptions, Signature, TreeEntry, TreeEntryMode, }; use crate::error::BabyError; use crate::share; pub const LOG_FORMAT: &str = "%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%s%x00%b"; pub const CALLER: &str = "gitbaby::commit"; pub struct StderrCtx<'a> { pub revision: &'a str, pub path: Option<&'a Path>, } pub fn validate_revision(rev: &str) -> Result<(), BabyError> { share::validate_revision(rev) } pub fn validate_path_local_no_escape(path: &Path) -> Result<(), BabyError> { share::validate_local_no_escape(path) } pub fn validate_list_commits_options(opts: &ListCommitsOptions) -> Result<(), BabyError> { validate_revision(&opts.revision)?; if let Some(p) = opts.path.as_deref() { validate_path_local_no_escape(p)?; } Ok(()) } pub fn build_env(alternates: &[PathBuf]) -> Env { share::build_env_safe(alternates) } pub fn build_get_commit_cmd(dir: &Path, env: Env, rev: &str) -> Cmd { let args = vec!["cat-file".to_string(), "-p".to_string(), rev.to_string()]; share::git_cmd(CALLER, dir, env, None, args) } pub fn build_rev_parse_cmd(dir: &Path, env: Env, rev: &str) -> Cmd { let args = vec![ "rev-parse".to_string(), "--verify".to_string(), "--end-of-options".to_string(), format!("{}^{{commit}}", rev), ]; share::git_cmd(CALLER, dir, env, None, args) } pub fn build_is_ancestor_cmd(dir: &Path, env: Env, ancestor: &str, descendant: &str) -> Cmd { let args = vec![ "merge-base".to_string(), "--is-ancestor".to_string(), ancestor.to_string(), descendant.to_string(), ]; share::git_cmd(CALLER, dir, env, None, args) } pub fn build_list_commits_cmd(dir: &Path, env: Env, opts: &ListCommitsOptions) -> Cmd { let mut args: Vec = Vec::with_capacity(8); args.push("-c".to_string()); args.push("core.quotepath=off".to_string()); args.push("log".to_string()); args.push("-z".to_string()); args.push(format!("--format={}", LOG_FORMAT)); if let Some(n) = opts.max_count { args.push(format!("-n{}", n)); } if let Some(n) = opts.skip { args.push(format!("--skip={}", n)); } if let Some(s) = opts.since.as_deref() { args.push(format!("--since={}", s)); } if let Some(u) = opts.until.as_deref() { args.push(format!("--until={}", u)); } if opts.first_parent { args.push("--first-parent".to_string()); } if !opts.revision.is_empty() { args.push(opts.revision.clone()); } if let Some(p) = opts.path.as_deref() { args.push("--".to_string()); args.push(p.to_string_lossy().into_owned()); } share::git_cmd(CALLER, dir, env, None, args) } pub fn build_count_commits_cmd(dir: &Path, env: Env, opts: &ListCommitsOptions) -> Cmd { let mut args: Vec = Vec::with_capacity(8); args.push("rev-list".to_string()); args.push("--count".to_string()); if let Some(s) = opts.since.as_deref() { args.push(format!("--since={}", s)); } if let Some(u) = opts.until.as_deref() { args.push(format!("--until={}", u)); } if opts.first_parent { args.push("--first-parent".to_string()); } if !opts.revision.is_empty() { args.push(opts.revision.clone()); } if let Some(p) = opts.path.as_deref() { args.push("--".to_string()); args.push(p.to_string_lossy().into_owned()); } share::git_cmd(CALLER, dir, env, None, args) } pub fn build_commit_stats_cmd(dir: &Path, env: Env, rev: &str, path: Option<&Path>) -> Cmd { let mut args: Vec = Vec::with_capacity(4); args.push("diff".to_string()); args.push("--shortstat".to_string()); args.push(format!("{}^!", rev)); if let Some(p) = path { args.push("--".to_string()); args.push(p.to_string_lossy().into_owned()); } share::git_cmd(CALLER, dir, env, None, args) } pub fn build_commit_short_stats_cmd(dir: &Path, env: Env, rev: &str) -> Cmd { let args = vec![ "-c".to_string(), "core.quotepath=off".to_string(), "log".to_string(), "--shortstat".to_string(), "--format=__GDCOMMIT__%H%x00%an%x00%ae%x00".to_string(), rev.to_string(), ]; share::git_cmd(CALLER, dir, env, None, args) } pub fn build_tree_entries_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd { let mut args = vec!["ls-tree".to_string(), "-l".to_string(), rev.to_string()]; let path_str = path.to_string_lossy(); if !path_str.is_empty() && path_str != "." { args.push("--".to_string()); args.push(format!("{}/", path_str.trim_end_matches('/'))); } share::git_cmd(CALLER, dir, env, None, args) } pub fn build_last_commit_for_path_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd { let args = vec![ "-c".to_string(), "core.quotepath=off".to_string(), "log".to_string(), "-1".to_string(), format!("--format={}", LOG_FORMAT), rev.to_string(), "--".to_string(), path.to_string_lossy().into_owned(), ]; share::git_cmd(CALLER, dir, env, None, args) } pub fn classify_stderr(stderr: &str, ctx: &StderrCtx<'_>) -> BabyError { let trimmed = stderr.trim(); if let Some(rest) = trimmed.strip_prefix("fatal: ambiguous argument '") && let Some(needle) = rest.strip_suffix("': unknown revision or path not in the working tree.") { return BabyError::RevisionNotFound(needle.to_string()); } if trimmed.contains("Needed a single revision") { return BabyError::RevisionNotFound(ctx.revision.to_string()); } if let Some(rest) = trimmed.strip_prefix("fatal: Not a tree: ") { return BabyError::NotATree { revision: rest.trim().to_string(), path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()), }; } if trimmed.contains("bad revision") || trimmed.contains("unknown revision") { let mut iter = trimmed .split_whitespace() .filter(|w| !w.starts_with("fatal:")); let rev = match iter.next() { Some(w) => w.trim_matches('\'').to_string(), None => String::new(), }; if rev.is_empty() { return BabyError::RevisionNotFound(ctx.revision.to_string()); } return BabyError::RevisionNotFound(rev); } if let Some(rest) = trimmed.strip_prefix("fatal: not a tree object ") { return BabyError::NotATree { revision: rest.trim().to_string(), path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()), }; } if let Some(rest) = trimmed.strip_prefix("fatal: not a commit: ") { return BabyError::RevisionInvalid(rest.trim().to_string()); } BabyError::Spawn { prog: "git".to_string(), source: std::io::Error::other("git non-zero exit"), stderr: trimmed.to_string(), } } pub fn classify_cmd_error(err: CmdError, ctx: &StderrCtx<'_>) -> BabyError { match err { CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ctx), CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source), other => BabyError::from(other), } } pub fn facade_error(e: E) -> BabyError { share::facade_error(e) } pub fn parse_signature_line(line: &str) -> Result { let lt_idx = line.rfind(" <").ok_or_else(|| BabyError::CommitParse { line_number: 0, message: format!("missing `<` in signature: {}", line), })?; let gt_idx = line.rfind('>').ok_or_else(|| BabyError::CommitParse { line_number: 0, message: format!("missing `>` in signature: {}", line), })?; if gt_idx <= lt_idx { return Err(BabyError::CommitParse { line_number: 0, message: format!("malformed signature delimiters: {}", line), }); } let name_raw = line[..lt_idx].trim().to_string(); let name = if let Some(stripped) = name_raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { unescape_quoted_name(stripped) } else { name_raw }; let email = line[lt_idx + 2..gt_idx].trim().to_string(); let tail = line[gt_idx + 1..].trim(); let mut parts = tail.split_whitespace(); let ts_str = parts.next().ok_or_else(|| BabyError::CommitParse { line_number: 0, message: format!("missing timestamp in signature: {}", line), })?; let tz_str = parts.next().ok_or_else(|| BabyError::CommitParse { line_number: 0, message: format!("missing timezone in signature: {}", line), })?; let unix: i64 = ts_str .parse() .map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))?; let offset = parse_offset(tz_str)?; let time = time::OffsetDateTime::from_unix_timestamp(unix) .map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))? .to_offset(offset); Ok(Signature { name, email, time }) } fn unescape_quoted_name(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars(); while let Some(c) = chars.next() { if c == '\\' { if let Some(next) = chars.next() { match next { 'n' => out.push('\n'), 't' => out.push('\t'), '\\' => out.push('\\'), '"' => out.push('"'), other => { out.push('\\'); out.push(other); } } } else { out.push('\\'); } } else { out.push(c); } } out } fn parse_offset(s: &str) -> Result { let bytes = s.as_bytes(); if bytes.len() != 5 || (bytes[0] != b'+' && bytes[0] != b'-') { return Err(BabyError::InvalidTimestamp(format!("offset {}", s))); } let sign = if bytes[0] == b'-' { -1 } else { 1 }; let hours: i32 = std::str::from_utf8(&bytes[1..3]) .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))? .parse() .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?; let mins: i32 = std::str::from_utf8(&bytes[3..5]) .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))? .parse() .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?; let total_seconds = sign * (hours * 3600 + mins * 60); time::UtcOffset::from_whole_seconds(total_seconds) .map_err(|_| BabyError::InvalidTimestamp(s.to_string())) } pub fn parse_commit_porcelain(id: gix::ObjectId, bytes: &[u8]) -> Result { let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse { line_number: 0, message: format!("commit porcelain is not utf-8: {}", source), })?; let mut tree_id: Option = None; let mut parents: Vec = Vec::new(); let mut author: Option = None; let mut committer: Option = None; let mut gpg_signature: Option = None; let mut message_lines: Vec = Vec::new(); let mut in_message = false; for (idx, line) in text.lines().enumerate() { let line_no = idx as u64 + 1; if in_message { message_lines.push(line.to_string()); continue; } if line.is_empty() { in_message = true; continue; } if let Some(rest) = line.strip_prefix("tree ") { tree_id = Some(parse_object_id_field(rest, line_no, "tree")?); continue; } if let Some(rest) = line.strip_prefix("parent ") { parents.push(parse_object_id_field(rest, line_no, "parent")?); continue; } if let Some(rest) = line.strip_prefix("author ") { author = Some( parse_signature_line(rest).map_err(|_| BabyError::CommitParse { line_number: line_no, message: format!("bad author: {}", rest), })?, ); continue; } if let Some(rest) = line.strip_prefix("committer ") { committer = Some( parse_signature_line(rest).map_err(|_| BabyError::CommitParse { line_number: line_no, message: format!("bad committer: {}", rest), })?, ); continue; } if let Some(rest) = line.strip_prefix("gpgsig ") { gpg_signature = Some(rest.trim_start().trim_end_matches('\r').to_string()); continue; } if line.starts_with(' ') && gpg_signature.is_some() { if let Some(sig) = gpg_signature.as_mut() { sig.push('\n'); sig.push_str(line.trim_start()); } continue; } return Err(BabyError::CommitParse { line_number: line_no, message: format!("unexpected header line: {}", line), }); } let tree_id = tree_id.ok_or_else(|| BabyError::CommitParse { line_number: 0, message: "commit porcelain missing tree".into(), })?; let author = author.ok_or_else(|| BabyError::CommitParse { line_number: 0, message: "commit porcelain missing author".into(), })?; let committer = committer.ok_or_else(|| BabyError::CommitParse { line_number: 0, message: "commit porcelain missing committer".into(), })?; let message = if message_lines.is_empty() { String::new() } else { let trimmed_start = message_lines .iter() .position(|l| !l.is_empty()) .unwrap_or(message_lines.len()); let mut lines = message_lines[trimmed_start..].to_vec(); while lines.last().is_some_and(|l| l.is_empty()) { lines.pop(); } lines.join("\n") }; Ok(CommitInfo { id, tree_id, parents, author, committer, message, gpg_signature, }) } fn parse_object_id_field(s: &str, line_no: u64, field: &str) -> Result { let hex = s.trim(); gix::ObjectId::from_hex(hex.as_bytes()).map_err(|source| BabyError::CommitParse { line_number: line_no, message: format!("bad {} oid `{}`: {}", field, hex, source), }) } pub fn parse_log_porcelain(bytes: &[u8]) -> Result, BabyError> { let mut chunks: Vec<&[u8]> = bytes.split(|b| *b == 0).collect(); if chunks.last().is_some_and(|c| c.is_empty()) { chunks.pop(); } fn parse_field<'a>(f: &'a [u8], what: &str) -> Result<&'a str, BabyError> { std::str::from_utf8(f).map_err(|source| BabyError::CommitParse { line_number: 0, message: format!("{} not utf-8: {}", what, source), }) } fn parse_unix(s: &str) -> Result { let unix: i64 = s .parse() .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?; time::OffsetDateTime::from_unix_timestamp(unix) .map(|t| t.to_offset(time::UtcOffset::UTC)) .map_err(|_| BabyError::InvalidTimestamp(s.to_string())) } let mut commits = Vec::with_capacity(chunks.len() / 11); for fields in chunks.as_chunks::<11>().0 { let id = gix::ObjectId::from_hex(fields[0]).map_err(|source| BabyError::CommitParse { line_number: 0, message: format!("bad id: {}", source), })?; let tree_id = gix::ObjectId::from_hex(fields[1]).map_err(|source| BabyError::CommitParse { line_number: 0, message: format!("bad tree: {}", source), })?; let mut parents = Vec::new(); if !fields[2].is_empty() { for hex in fields[2].split(|b| *b == b' ') { if hex.is_empty() { continue; } let p = gix::ObjectId::from_hex(hex).map_err(|source| BabyError::CommitParse { line_number: 0, message: format!("bad parent: {}", source), })?; parents.push(p); } } let author = Signature { name: parse_field(fields[3], "author name")?.to_string(), email: parse_field(fields[4], "author email")?.to_string(), time: parse_unix(parse_field(fields[5], "author time")?)?, }; let committer = Signature { name: parse_field(fields[6], "committer name")?.to_string(), email: parse_field(fields[7], "committer email")?.to_string(), time: parse_unix(parse_field(fields[8], "committer time")?)?, }; let subject = parse_field(fields[9], "subject")?.to_string(); let body = parse_field(fields[10], "body")? .trim_end_matches('\n') .to_string(); let message = if body.is_empty() { subject } else { format!("{}\n\n{}", subject, body) }; commits.push(CommitInfo { id, tree_id, parents, author, committer, message, gpg_signature: None, }); } Ok(commits) } pub fn parse_tree_entries(bytes: &[u8]) -> Result, BabyError> { let mut out = Vec::new(); for (idx, line) in bytes.split(|b| *b == b'\n').enumerate() { if line.is_empty() { continue; } let line_str = std::str::from_utf8(line).map_err(|source| BabyError::CommitParse { line_number: idx as u64 + 1, message: format!("tree entry not utf-8: {}", source), })?; let mut parts = line_str.splitn(3, ' '); let mode_str = parts.next().ok_or_else(|| BabyError::CommitParse { line_number: idx as u64 + 1, message: format!("tree entry missing mode: {}", line_str), })?; let mode = TreeEntryMode::from_octal_str(mode_str).ok_or_else(|| BabyError::CommitParse { line_number: idx as u64 + 1, message: format!("unknown tree mode: {}", mode_str), })?; parts.next(); let tail = parts.next().ok_or_else(|| BabyError::CommitParse { line_number: idx as u64 + 1, message: format!("tree entry missing tail: {}", line_str), })?; let (head, name_field) = tail .split_once('\t') .ok_or_else(|| BabyError::CommitParse { line_number: idx as u64 + 1, message: format!("tree entry missing name: {}", line_str), })?; let oid_hex = head.split_whitespace().next().unwrap_or(""); let oid = gix::ObjectId::from_hex(oid_hex.as_bytes()).map_err(|source| { BabyError::CommitParse { line_number: idx as u64 + 1, message: format!("bad tree oid `{}`: {}", oid_hex, source), } })?; let size = head .split_whitespace() .nth(1) .and_then(|s| s.parse::().ok()); out.push(TreeEntry { oid, name: name_field.to_string(), mode, size, }); } Ok(out) } pub fn parse_shortstat(bytes: &[u8]) -> Result { let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse { line_number: 0, message: format!("shortstat not utf-8: {}", source), })?; let mut stats = CommitStats::default(); for part in text.split_whitespace() { if let Some(rest) = part.strip_suffix(')') { if rest.contains("(+)") { let n: u64 = rest.trim_end_matches("(+)").parse().map_err(|source| { BabyError::CommitParse { line_number: 0, message: format!("bad additions number `{}`: {}", part, source), } })?; stats.additions = n; continue; } if rest.contains("(-)") { let n: u64 = rest.trim_end_matches("(-)").parse().map_err(|source| { BabyError::CommitParse { line_number: 0, message: format!("bad deletions number `{}`: {}", part, source), } })?; stats.deletions = n; continue; } } } Ok(stats) } pub fn parse_commit_short_stats(bytes: &[u8]) -> Result, BabyError> { let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse { line_number: 0, message: format!("commit shortstat stream not utf-8: {}", source), })?; let mut out = Vec::new(); for record in text.split("__GDCOMMIT__").skip(1) { let mut fields = record.splitn(4, '\0'); let Some(oid_hex) = fields.next() else { continue; }; let Some(name) = fields.next() else { continue }; let Some(email) = fields.next() else { continue }; let rest = fields.next().unwrap_or(""); let oid = gix::ObjectId::from_hex(oid_hex.as_bytes()).map_err(|source| { BabyError::CommitParse { line_number: 0, message: format!("bad commit oid `{}`: {}", oid_hex, source), } })?; let (additions, deletions) = shortstat_numbers(rest); out.push(CommitShortStat { oid, author_name: name.to_string(), author_email: email.to_string(), additions, deletions, }); } Ok(out) } fn shortstat_numbers(rest: &str) -> (u64, u64) { let mut additions = 0u64; let mut deletions = 0u64; let mut prev_num: Option = None; for part in rest.split_whitespace() { if let Ok(n) = part.trim_end_matches(',').parse::() { prev_num = Some(n); continue; } let word = part .trim_end_matches(',') .trim_end_matches(')') .trim_end_matches("(+") .trim_end_matches("(-"); if word == "insertion" || word == "insertions" { additions = prev_num.unwrap_or(0); } else if word == "deletion" || word == "deletions" { deletions = prev_num.unwrap_or(0); } } (additions, deletions) }