//! git CLI history: log, commit diff, last commits, archive (see docs/m2-interfaces.md). //! Every commit argument is a validated full hex OID; paths go after `--` as literal pathspecs. use std::collections::{HashMap, HashSet}; use std::path::Path; use std::process::Stdio; use tokio::io::AsyncReadExt; use crate::browse::{self, CommitInfo}; use crate::command::{GitCommand, GitEnv, ObjectSource}; use crate::error::{Error, Result}; /// Byte cap on any single git output stream read by this module. const OUTPUT_CAP: usize = 20 * 1024 * 1024; const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; const EMPTY_TREE_SHA256: &str = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; /// Record start (`%x1e`), field separator (`%x1f`), end of fields (`%x1d`). const REC: u8 = 0x1e; const FIELD: u8 = 0x1f; const END: u8 = 0x1d; /// Commit fields: oid, parents, author name/email/time, committer name/email/time, summary, message. const COMMIT_FORMAT: &str = "--format=%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%s%x1f%B%x1d"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArchiveFormat { TarGz, Zip, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffStatus { Added, Deleted, Modified, Renamed, Copied, TypeChanged, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineKind { Context, Add, Del, NoNewline, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiffLine { pub kind: LineKind, pub old_no: Option, pub new_no: Option, pub text: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Hunk { pub header: String, pub lines: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileDiff { pub old_path: Option, pub new_path: Option, pub status: DiffStatus, pub is_binary: bool, pub additions: u32, pub deletions: u32, pub hunks: Vec, pub truncated: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DiffLimits { pub max_files: usize, pub max_lines_per_file: usize, pub max_total_lines: usize, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitDiff { pub files: Vec, pub truncated_files: bool, } /// `git` with the forge-wide config overrides: `core.quotePath=false` through the /// environment (`GIT_CONFIG_COUNT`), so it applies before the subcommand as `-c` would. pub(crate) fn git(env: &GitEnv, subcommand: &'static str) -> GitCommand { GitCommand::new(env, subcommand) .env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", "core.quotePath") .env("GIT_CONFIG_VALUE_0", "false") } /// Accepts only a full SHA-1 (40) or SHA-256 (64) hex object id. pub(crate) fn validate_oid(oid: &str) -> Result<()> { let len_ok = oid.len() == 40 || oid.len() == 64; if len_ok && oid.bytes().all(|b| b.is_ascii_hexdigit()) { Ok(()) } else { Err(Error::InvalidInput(format!( "not a full commit oid: {oid:?}" ))) } } /// Pathspec that matches the given path literally (no glob or magic interpretation). fn literal_spec(path: &str) -> String { format!(":(literal){path}") } /// Accepts `[A-Za-z0-9._-]+`, excluding `.` and `..` and a leading `-`. fn validate_prefix(prefix: &str) -> Result<()> { let charset_ok = !prefix.is_empty() && prefix .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-'); if !charset_ok || prefix == "." || prefix == ".." || prefix.starts_with('-') { return Err(Error::InvalidInput(format!( "invalid archive prefix: {prefix:?}" ))); } Ok(()) } /// Feeds stdout chunks to `f` until EOF, until `f` returns false, or until /// [`OUTPUT_CAP`] bytes. Returns `true` only when EOF was reached. A non-zero exit /// after EOF becomes `Error::Failed` with stderr. async fn for_each_chunk(cmd: GitCommand, mut f: impl FnMut(&[u8]) -> bool) -> Result { let mut command = cmd.into_tokio(); command.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = command.spawn()?; let mut stdout = child .stdout .take() .ok_or_else(|| Error::Failed("git stdout missing".into()))?; let stderr_task = child.stderr.take().map(|mut e| { tokio::spawn(async move { let mut v = Vec::new(); let _ = e.read_to_end(&mut v).await; v }) }); let mut chunk = vec![0u8; 64 * 1024]; let mut total = 0usize; let mut eof = false; loop { let n = stdout.read(&mut chunk).await?; if n == 0 { eof = true; break; } let take = n.min(OUTPUT_CAP - total); let keep = f(&chunk[..take]); total += take; if !keep || take < n { break; } } if !eof { let _ = child.start_kill(); } drop(stdout); let status = child.wait().await?; let stderr = match stderr_task { Some(t) => t.await.unwrap_or_default(), None => Vec::new(), }; if eof && !status.success() { return Err(Error::Failed(format!( "git exited with {status}: {}", String::from_utf8_lossy(&stderr).trim() ))); } Ok(eof) } /// Reads the whole stdout up to [`OUTPUT_CAP`]. The flag is `false` when the cap cut it short. async fn read_capped(cmd: GitCommand) -> Result<(Vec, bool)> { let mut buf = Vec::new(); let complete = for_each_chunk(cmd, |c| { buf.extend_from_slice(c); true }) .await?; Ok((buf, complete)) } /// Parses one record (the bytes after a `%x1e` up to the next one). Returns the commit /// and the bytes after `%x1d` (file names when `--name-only` is used). fn parse_record(rec: &[u8]) -> Option<(CommitInfo, &[u8])> { let end = rec.iter().position(|&b| b == END)?; let fields = &rec[..end]; let rest = &rec[end + 1..]; let mut it = fields.splitn(10, |&b| b == FIELD); let text = |p: Option<&[u8]>| String::from_utf8_lossy(p.unwrap_or_default()).into_owned(); let time = |p: Option<&[u8]>| text(p).parse::().unwrap_or(0); let oid = text(it.next()); if oid.is_empty() { return None; } let parents = text(it.next()) .split_whitespace() .map(str::to_owned) .collect(); let author_name = text(it.next()); let author_email = text(it.next()); let author_time = time(it.next()); let committer_name = text(it.next()); let committer_email = text(it.next()); let committer_time = time(it.next()); let summary = text(it.next()); let message = text(it.next()).trim_end_matches('\n').to_owned(); let info = CommitInfo { oid, parents, author_name, author_email, author_time, committer_name, committer_email, committer_time, summary, message, }; Some((info, rest)) } /// Commit history for `commit`, optionally limited to `path`, newest first. pub async fn log( env: &GitEnv, repo: &Path, commit: &str, path: Option<&str>, skip: usize, limit: usize, ) -> Result> { validate_oid(commit)?; let mut cmd = git(env, "log") .git_dir(repo) .arg("-z") .arg("--no-color") .arg(COMMIT_FORMAT) .user_opt("--skip", &skip.to_string())? .user_opt("--max-count", &limit.to_string())? .user_revs(&[commit])?; if let Some(p) = path { browse::validate_repo_path(p)?; let spec = literal_spec(p); cmd = cmd.paths(&[spec.as_str()]); } let (buf, _complete) = read_capped(cmd).await?; Ok(buf .split(|&b| b == REC) .filter_map(parse_record) .map(|(info, _)| info) .collect()) } /// Commits reachable from `new` but not from `old` (when `old` is given), oldest first. At most /// `max` of the newest commits are returned. Both ids must be full object ids; they are passed /// after `--end-of-options`, so no value can be read as an option. pub async fn new_commits( env: &GitEnv, repo: &Path, old: Option<&str>, new: &str, max: usize, ) -> Result> { new_commits_with(env, repo, ObjectSource::default(), old, new, max).await } /// [`new_commits`], reading objects that are missing from `repo` from `src` as well. pub async fn new_commits_with( env: &GitEnv, repo: &Path, src: ObjectSource<'_>, old: Option<&str>, new: &str, max: usize, ) -> Result> { validate_oid(new)?; let exclude = match old { Some(old) => { validate_oid(old)?; Some(format!("^{old}")) } None => None, }; let mut revs = vec![new]; if let Some(exclude) = exclude.as_deref() { revs.push(exclude); } let cmd = git(env, "log") .git_dir(repo) .arg("-z") .arg("--no-color") .arg(COMMIT_FORMAT) .user_opt("--max-count", &max.to_string())? .objects(src)? .user_revs(&revs)?; let (buf, _complete) = read_capped(cmd).await?; let mut commits: Vec = buf .split(|&b| b == REC) .filter_map(parse_record) .map(|(info, _)| info) .collect(); commits.reverse(); Ok(commits) } /// Which `diff-tree` output a command produces. #[derive(Clone, Copy)] enum DiffMode { NameStatus, Patch, } fn diff_tree_cmd( env: &GitEnv, repo: &Path, src: ObjectSource<'_>, base: &str, oid: &str, mode: DiffMode, ) -> Result { let cmd = git(env, "diff-tree") .git_dir(repo) .arg("-r") .arg("-M") .arg("--no-commit-id") .arg("--no-color") .arg("--no-ext-diff") .arg("--no-textconv"); let cmd = match mode { DiffMode::NameStatus => cmd.arg("-z").arg("--name-status"), DiffMode::Patch => cmd.arg("-p").arg("--full-index"), }; cmd.objects(src)?.user_revs(&[base, oid]) } pub(crate) fn empty_tree_for(oid: &str) -> &'static str { if oid.len() == 64 { EMPTY_TREE_SHA256 } else { EMPTY_TREE_SHA1 } } /// First parent of `oid`, or `None` for a root commit. async fn first_parent(env: &GitEnv, repo: &Path, oid: &str) -> Result> { let out = git(env, "rev-list") .git_dir(repo) .arg("--parents") .arg("-n1") .user_revs(&[oid])? .output() .await?; let text = String::from_utf8_lossy(&out); let mut tokens = text.split_whitespace(); let _oid = tokens.next(); match tokens.next() { Some(p) => { validate_oid(p)?; Ok(Some(p.to_owned())) } None => Ok(None), } } /// One entry of `--name-status -z` output. struct NameEntry { status: DiffStatus, old_path: Option, new_path: Option, } fn status_of(code: u8) -> DiffStatus { match code { b'A' => DiffStatus::Added, b'D' => DiffStatus::Deleted, b'R' => DiffStatus::Renamed, b'C' => DiffStatus::Copied, b'T' => DiffStatus::TypeChanged, _ => DiffStatus::Modified, } } /// Parses NUL-separated `--name-status -z` output. A trailing partial record is dropped. fn parse_name_status(buf: &[u8]) -> Vec { let text = |p: &[u8]| String::from_utf8_lossy(p).into_owned(); let mut out = Vec::new(); let mut fields = buf.split(|&b| b == 0); while let Some(code) = fields.next() { let Some(&c) = code.first() else { continue }; if matches!(c, b'R' | b'C') { let (Some(a), Some(b)) = (fields.next(), fields.next()) else { break; }; out.push(NameEntry { status: status_of(c), old_path: Some(text(a)), new_path: Some(text(b)), }); } else { let Some(p) = fields.next() else { break }; let p = text(p); let (old_path, new_path) = match c { b'A' => (None, Some(p)), b'D' => (Some(p), None), _ => (Some(p.clone()), Some(p)), }; out.push(NameEntry { status: status_of(c), old_path, new_path, }); } } out } /// Byte offsets where `diff --git` headers start at the beginning of a line. fn block_starts(text: &str) -> Vec { text.match_indices("diff --git ") .map(|(i, _)| i) .filter(|&i| i == 0 || text.as_bytes()[i - 1] == b'\n') .collect() } /// `@@ -a[,b] +c[,d] @@` to the old and new start line numbers. fn parse_hunk_start(line: &str) -> (u32, u32) { let mut parts = line.split_whitespace().skip(1); let start = |p: Option<&str>, sign: char| { p.and_then(|p| p.strip_prefix(sign)) .and_then(|p| p.split(',').next()) .and_then(|n| n.parse::().ok()) .unwrap_or(1) }; let old = start(parts.next(), '-'); let new = start(parts.next(), '+'); (old, new) } /// Totals and hunks of one file's section of the patch. #[derive(Default)] struct BlockOut { is_binary: bool, additions: u32, deletions: u32, hunks: Vec, truncated: bool, } /// Parses one `diff --git` section. Lines beyond `limits.max_lines_per_file` or the /// shared `total_left` budget are dropped and mark the block truncated. Counts stay exact. fn parse_block(block: &str, limits: &DiffLimits, total_left: &mut usize) -> BlockOut { let mut out = BlockOut::default(); let mut file_lines = 0usize; let mut in_hunk = false; let mut pending_header: Option = None; let (mut old_no, mut new_no) = (0u32, 0u32); for line in block.lines() { if !in_hunk { if line.starts_with("@@") { in_hunk = true; (old_no, new_no) = parse_hunk_start(line); pending_header = Some(line.to_owned()); } else if line.starts_with("Binary files ") || line == "GIT binary patch" { out.is_binary = true; } continue; } if line.starts_with("@@") { (old_no, new_no) = parse_hunk_start(line); pending_header = Some(line.to_owned()); continue; } let (kind, text) = match line.as_bytes().first() { Some(b'+') => (LineKind::Add, &line[1..]), Some(b'-') => (LineKind::Del, &line[1..]), Some(b' ') => (LineKind::Context, &line[1..]), Some(b'\\') => (LineKind::NoNewline, line), _ => (LineKind::Context, line), }; let (dl_old, dl_new) = match kind { LineKind::Context => (Some(old_no), Some(new_no)), LineKind::Del => (Some(old_no), None), LineKind::Add => (None, Some(new_no)), LineKind::NoNewline => (None, None), }; match kind { LineKind::Add => { out.additions += 1; new_no += 1; } LineKind::Del => { out.deletions += 1; old_no += 1; } LineKind::Context => { old_no += 1; new_no += 1; } LineKind::NoNewline => {} } if file_lines >= limits.max_lines_per_file || *total_left == 0 { out.truncated = true; continue; } if let Some(h) = pending_header.take() { out.hunks.push(Hunk { header: h, lines: Vec::new(), }); } if let Some(hunk) = out.hunks.last_mut() { hunk.lines.push(DiffLine { kind, old_no: dl_old, new_no: dl_new, text: text.to_owned(), }); } file_lines += 1; *total_left -= 1; } out } /// The diff of `oid` against its first parent (or the empty tree for a root commit). /// Uses `diff-tree -M` for names and statuses and a second `diff-tree -p` run for hunks, /// joined by position. Both streams are capped at [`OUTPUT_CAP`]. pub async fn commit_diff( env: &GitEnv, repo: &Path, oid: &str, limits: DiffLimits, ) -> Result { validate_oid(oid)?; let base = match first_parent(env, repo, oid).await? { Some(p) => p, None => empty_tree_for(oid).to_owned(), }; diff_between(env, repo, ObjectSource::default(), &base, oid, limits).await } /// The diff from `base` (a commit or tree id) to `oid` (a commit id), with the same parsing and /// limits as [`commit_diff`]. Both ids must be full object ids. pub(crate) async fn diff_between( env: &GitEnv, repo: &Path, src: ObjectSource<'_>, base: &str, oid: &str, limits: DiffLimits, ) -> Result { validate_oid(base)?; validate_oid(oid)?; let (name_buf, names_complete) = read_capped(diff_tree_cmd( env, repo, src, base, oid, DiffMode::NameStatus, )?) .await?; let mut entries = parse_name_status(&name_buf); let mut truncated_files = false; if !names_complete { entries.pop(); truncated_files = true; } if entries.len() > limits.max_files { entries.truncate(limits.max_files); truncated_files = true; } let (patch_buf, patch_complete) = read_capped(diff_tree_cmd(env, repo, src, base, oid, DiffMode::Patch)?).await?; let patch = String::from_utf8_lossy(&patch_buf); let starts = block_starts(&patch); // A capped patch may end inside its last section, so that section is dropped. let usable = if patch_complete { starts.len() } else { starts.len().saturating_sub(1) }; let mut total_left = limits.max_total_lines; let mut files = Vec::with_capacity(entries.len()); for (i, entry) in entries.into_iter().enumerate() { let block = if i < usable { let end = starts.get(i + 1).copied().unwrap_or(patch.len()); Some(parse_block( &patch[starts[i]..end], &limits, &mut total_left, )) } else { None }; let (out, missing) = match block { Some(b) => (b, false), None => (BlockOut::default(), true), }; files.push(FileDiff { old_path: entry.old_path, new_path: entry.new_path, status: entry.status, is_binary: out.is_binary, additions: out.additions, deletions: out.deletions, hunks: out.hunks, truncated: missing || out.truncated, }); } Ok(CommitDiff { files, truncated_files, }) } /// The newest commit (walking back from `commit`) that touched each direct child of `dir`, /// keyed by child name. One `git log` walk, limited to `max_walk` commits, and it stops /// early once every name is found. `dir` is `""` for the repo root. pub async fn last_commits( env: &GitEnv, repo: &Path, commit: &str, dir: &str, names: &[String], max_walk: usize, ) -> Result> { validate_oid(commit)?; if !dir.is_empty() { browse::validate_repo_path(dir)?; } let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); let mut found: HashMap = HashMap::new(); if wanted.is_empty() || max_walk == 0 { return Ok(found); } let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") }; let mut cmd = git(env, "log") .git_dir(repo) .arg("-z") .arg("--no-color") .arg("--name-only") .arg(COMMIT_FORMAT) .user_opt("--max-count", &max_walk.to_string())? .user_revs(&[commit])?; if !dir.is_empty() { let spec = literal_spec(dir); cmd = cmd.paths(&[spec.as_str()]); } { let mut handle = |rec: &[u8]| -> bool { let Some((info, files)) = parse_record(rec) else { return true; }; // `-z --name-only` puts a NUL and/or newline between the format and the names. let start = files .iter() .position(|&b| b != 0 && b != b'\n') .unwrap_or(files.len()); let files = &files[start..]; for seg in files.split(|&b| b == 0).filter(|s| !s.is_empty()) { let path = String::from_utf8_lossy(seg); let rest: &str = if prefix.is_empty() { &path } else if let Some(r) = path.strip_prefix(prefix.as_str()) { r } else { continue; }; let child = rest.split('/').next().unwrap_or(rest); if wanted.contains(child) && !found.contains_key(child) { found.insert(child.to_owned(), info.clone()); } } found.len() < wanted.len() }; let mut pending: Vec = Vec::new(); let mut started = false; let mut stopped = false; let eof = for_each_chunk(cmd, |chunk| { for &b in chunk { if b == REC { if started && !handle(&pending) { stopped = true; return false; } pending.clear(); started = true; } else if started { pending.push(b); } } true }) .await?; if eof && started && !stopped { handle(&pending); } } Ok(found) } /// `git archive` for `commit`, with every entry under `/`. The caller streams stdout. pub fn archive_command( env: &GitEnv, repo: &Path, commit: &str, format: ArchiveFormat, prefix: &str, ) -> Result { validate_oid(commit)?; validate_prefix(prefix)?; let format_arg = match format { ArchiveFormat::TarGz => "--format=tar.gz", ArchiveFormat::Zip => "--format=zip", }; git(env, "archive") .git_dir(repo) .arg(format_arg) .user_opt("--prefix", &format!("{prefix}/"))? .user_revs(&[commit]) }