Skip to content
Clone
159a1eb
636 lines 22.5 KB Rust
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<String> = 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<String> = 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<String> = 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: std::error::Error + Send + Sync + 'static>(e: E) -> BabyError {
share::facade_error(e)
}
pub fn parse_signature_line(line: &str) -> Result<Signature, BabyError> {
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<time::UtcOffset, BabyError> {
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<CommitInfo, BabyError> {
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<gix::ObjectId> = None;
let mut parents: Vec<gix::ObjectId> = Vec::new();
let mut author: Option<Signature> = None;
let mut committer: Option<Signature> = None;
let mut gpg_signature: Option<String> = None;
let mut message_lines: Vec<String> = 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<gix::ObjectId, BabyError> {
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<Vec<CommitInfo>, 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<time::OffsetDateTime, BabyError> {
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<Vec<TreeEntry>, 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::<u64>().ok());
out.push(TreeEntry {
oid,
name: name_field.to_string(),
mode,
size,
});
}
Ok(out)
}
pub fn parse_shortstat(bytes: &[u8]) -> Result<CommitStats, BabyError> {
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<Vec<CommitShortStat>, 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<u64> = None;
for part in rest.split_whitespace() {
if let Ok(n) = part.trim_end_matches(',').parse::<u64>() {
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)
}