Menu
Public repository / 3367b5f4b77e4085d5cc513b20e67403d2207543
AkurAI-Build / src/history.rs
Latest change 34fb1ee8d3093927513e21ddd902f4ce1d7385c1 - Commit history, commit diff, compare and blame API and pages (OLI-268) by Ólafur Búi Ólafsson
//! Commit history, commit diff, compare and blame over a bare mirror.
use std::path::Path;
use anyhow::{Result, ensure};
use serde::Serialize;
use crate::{config::validate_ref, tree::validate_code_path};
/// Most commits a single history page returns.
pub const MAX_PAGE: usize = 100;
/// Largest diff payload returned before it is truncated.
const MAX_DIFF_BYTES: usize = 1024 * 1024;
const FIELD_SEPARATOR: char = '\u{1f}';
const RECORD_SEPARATOR: char = '\u{1e}';
#[derive(Clone, Debug, Serialize)]
pub struct CommitSummary {
pub sha: String,
pub parents: Vec<String>,
pub author: String,
pub email: String,
pub committed_at: i64,
pub subject: String,
}
#[derive(Debug, Serialize)]
pub struct CommitPage {
pub reference: String,
pub commits: Vec<CommitSummary>,
pub has_more: bool,
}
#[derive(Debug, Serialize)]
pub struct FileChange {
pub path: String,
pub additions: Option<u64>,
pub deletions: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct Diff {
pub files: Vec<FileChange>,
pub patch: String,
pub truncated: bool,
}
#[derive(Debug, Serialize)]
pub struct CommitDetail {
#[serde(flatten)]
pub summary: CommitSummary,
pub body: String,
pub diff: Diff,
}
#[derive(Debug, Serialize)]
pub struct Comparison {
pub base: String,
pub head: String,
pub merge_base: Option<String>,
pub commits: Vec<CommitSummary>,
pub diff: Diff,
}
#[derive(Debug, Serialize)]
pub struct BlameLine {
pub line: usize,
pub sha: String,
pub author: String,
pub committed_at: i64,
pub summary: String,
pub content: String,
}
#[derive(Debug, Serialize)]
pub struct Blame {
pub reference: String,
pub path: String,
pub lines: Vec<BlameLine>,
}
const LOG_FORMAT: &str = "%H%x1f%P%x1f%an%x1f%ae%x1f%ct%x1f%s%x1e";
fn git(repo: &Path, args: &[&str]) -> Result<String> {
let output = crate::git_process::command(repo).args(args).output()?;
ensure!(
output.status.success(),
"git failed: {}",
String::from_utf8_lossy(&output.stderr)
.lines()
.next()
.unwrap_or("unknown error")
);
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn parse_summary(record: &str) -> Option<CommitSummary> {
let mut fields = record.trim_start_matches('\n').split(FIELD_SEPARATOR);
let sha = fields.next()?.to_owned();
let parents = fields
.next()?
.split_whitespace()
.map(str::to_owned)
.collect();
Some(CommitSummary {
sha,
parents,
author: fields.next()?.to_owned(),
email: fields.next()?.to_owned(),
committed_at: fields.next()?.parse().ok()?,
subject: fields.next()?.to_owned(),
})
}
fn parse_log(raw: &str) -> Vec<CommitSummary> {
raw.split(RECORD_SEPARATOR)
.filter_map(parse_summary)
.collect()
}
pub fn commits(
repo: &Path,
reference: &str,
path: &str,
skip: usize,
limit: usize,
) -> Result<CommitPage> {
validate_ref(reference)?;
validate_code_path(path)?;
ensure!((1..=MAX_PAGE).contains(&limit), "invalid page size");
let format = format!("--format={LOG_FORMAT}");
let skip_arg = format!("--skip={skip}");
let count_arg = format!("--max-count={}", limit + 1);
let mut args = vec![
"log",
"--no-color",
&format,
&skip_arg,
&count_arg,
reference,
"--",
];
if !path.is_empty() {
args.push(path);
}
let mut commits = parse_log(&git(repo, &args)?);
let has_more = commits.len() > limit;
commits.truncate(limit);
Ok(CommitPage {
reference: reference.to_owned(),
commits,
has_more,
})
}
fn parse_numstat(raw: &str) -> Vec<FileChange> {
raw.split('\0')
.filter_map(|record| {
let mut fields = record.trim_start_matches('\n').splitn(3, '\t');
let additions = fields.next()?;
let deletions = fields.next()?;
Some(FileChange {
path: fields.next()?.to_owned(),
additions: additions.parse().ok(),
deletions: deletions.parse().ok(),
})
})
.collect()
}
fn truncate_patch(mut patch: String) -> (String, bool) {
if patch.len() <= MAX_DIFF_BYTES {
return (patch, false);
}
let mut end = MAX_DIFF_BYTES;
while !patch.is_char_boundary(end) {
end -= 1;
}
patch.truncate(end);
(patch, true)
}
/// Diff produced by `git <command> <targets>` (e.g. `diff a...b`).
fn diff_with(repo: &Path, command: &[&str], targets: &[&str]) -> Result<Diff> {
let build = |extra: &[&'static str]| {
let mut args: Vec<&str> = command.to_vec();
args.extend_from_slice(extra);
args.extend_from_slice(targets);
args
};
let patch = git(repo, &build(&["--no-color", "--no-renames", "-p"]))?;
let numstat = git(repo, &build(&["--no-renames", "--numstat", "-z"]))?;
let (patch, truncated) = truncate_patch(patch);
Ok(Diff {
files: parse_numstat(&numstat),
patch,
truncated,
})
}
pub fn commit(repo: &Path, sha: &str) -> Result<CommitDetail> {
validate_ref(sha)?;
let format = format!("--format={LOG_FORMAT}%b");
let raw = git(repo, &["show", "-s", "--no-color", &format, sha, "--"])?;
let (record, body) = raw
.split_once(RECORD_SEPARATOR)
.ok_or_else(|| anyhow::anyhow!("unknown commit {sha}"))?;
let summary = parse_summary(record).ok_or_else(|| anyhow::anyhow!("unknown commit {sha}"))?;
let diff = match summary.parents.first() {
Some(parent) => diff_with(repo, &["diff"], &[parent, &summary.sha])?,
None => diff_with(
repo,
&["diff-tree", "--root", "--no-commit-id"],
&[&summary.sha],
)?,
};
Ok(CommitDetail {
summary,
body: body.trim().to_owned(),
diff,
})
}
pub fn compare(repo: &Path, base: &str, head: &str) -> Result<Comparison> {
validate_ref(base)?;
validate_ref(head)?;
let merge_base = git(repo, &["merge-base", base, head])
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
let range = format!("{base}..{head}");
let format = format!("--format={LOG_FORMAT}");
let log = git(
repo,
&[
"log",
"--no-color",
&format,
"--max-count=250",
&range,
"--",
],
)?;
let three_dot = format!("{base}...{head}");
Ok(Comparison {
base: base.to_owned(),
head: head.to_owned(),
merge_base,
commits: parse_log(&log),
diff: diff_with(repo, &["diff"], &[&three_dot])?,
})
}
pub fn blame(repo: &Path, reference: &str, path: &str) -> Result<Blame> {
validate_ref(reference)?;
validate_code_path(path)?;
ensure!(!path.is_empty(), "blame needs a file path");
let raw = git(repo, &["blame", "--porcelain", reference, "--", path])?;
Ok(Blame {
reference: reference.to_owned(),
path: path.to_owned(),
lines: parse_blame(&raw),
})
}
fn parse_blame(raw: &str) -> Vec<BlameLine> {
use std::collections::HashMap;
let mut known: HashMap<String, (String, i64, String)> = HashMap::new();
let mut lines = Vec::new();
let mut current: Option<(String, usize)> = None;
for row in raw.lines() {
if let Some(content) = row.strip_prefix('\t') {
if let Some((sha, line)) = current.take() {
let (author, committed_at, summary) = known.get(&sha).cloned().unwrap_or_default();
lines.push(BlameLine {
line,
sha,
author,
committed_at,
summary,
content: content.to_owned(),
});
}
continue;
}
let mut words = row.split(' ');
let first = words.next().unwrap_or_default();
let is_header = first.len() >= 40 && first.bytes().all(|b| b.is_ascii_hexdigit());
if is_header {
let final_line = words.nth(1).and_then(|n| n.parse().ok()).unwrap_or(0);
known.entry(first.to_owned()).or_default();
current = Some((first.to_owned(), final_line));
} else if let Some((sha, _)) = ¤t
&& let Some(entry) = known.get_mut(sha)
{
match row.split_once(' ') {
Some(("author", value)) => entry.0 = value.to_owned(),
Some(("author-time", value)) => entry.1 = value.parse().unwrap_or(0),
Some(("summary", value)) => entry.2 = value.to_owned(),
_ => {}
}
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git_process::{command, output};
use std::fs;
use tempfile::{TempDir, tempdir};
fn run(repo: &Path, args: &[&str]) {
let status = command(repo).args(args).status().expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
/// main: c1 (a.txt) -> c2 (a.txt edit, b.txt); topic branches from c1 with t.txt.
fn fixture() -> (TempDir, String, String, String) {
let dir = tempdir().expect("tempdir");
let repo = dir.path();
run(repo, &["init", "-b", "main"]);
run(repo, &["config", "user.name", "Ada"]);
run(repo, &["config", "user.email", "ada@example.invalid"]);
fs::write(repo.join("a.txt"), "one\ntwo\n").expect("fixture");
run(repo, &["add", "."]);
run(repo, &["commit", "-m", "first"]);
let c1 = output(repo, ["rev-parse", "HEAD"]).expect("fixture");
run(repo, &["checkout", "-b", "topic"]);
fs::write(repo.join("t.txt"), "topic\n").expect("fixture");
run(repo, &["add", "."]);
run(repo, &["commit", "-m", "topic work"]);
run(repo, &["checkout", "main"]);
run(repo, &["config", "user.name", "Bob"]);
fs::write(repo.join("a.txt"), "one\nTWO\n").expect("fixture");
fs::write(repo.join("b.txt"), "bee\n").expect("fixture");
run(repo, &["add", "."]);
run(repo, &["commit", "-m", "second\n\nlong body"]);
let c2 = output(repo, ["rev-parse", "HEAD"]).expect("fixture");
let topic = output(repo, ["rev-parse", "topic"]).expect("fixture");
(dir, c1, c2, topic)
}
#[test]
fn lists_commits_newest_first_with_paging() {
let (dir, c1, c2, _) = fixture();
let page = commits(dir.path(), "main", "", 0, 1).expect("fixture");
assert_eq!(page.commits.len(), 1);
assert_eq!(page.commits[0].sha, c2);
assert_eq!(page.commits[0].subject, "second");
assert_eq!(page.commits[0].author, "Bob");
assert_eq!(page.commits[0].parents, vec![c1.clone()]);
assert!(page.has_more);
let rest = commits(dir.path(), "main", "", 1, 1).expect("fixture");
assert_eq!(rest.commits[0].sha, c1);
assert!(!rest.has_more);
}
#[test]
fn filters_history_by_path() {
let (dir, _, c2, _) = fixture();
let page = commits(dir.path(), "main", "b.txt", 0, 10).expect("fixture");
assert_eq!(page.commits.len(), 1);
assert_eq!(page.commits[0].sha, c2);
}
#[test]
fn rejects_option_like_refs_and_oversized_pages() {
let (dir, ..) = fixture();
assert!(commits(dir.path(), "--output=x", "", 0, 10).is_err());
assert!(commits(dir.path(), "main", "../x", 0, 10).is_err());
assert!(commits(dir.path(), "main", "", 0, MAX_PAGE + 1).is_err());
assert!(commits(dir.path(), "main", "", 0, 0).is_err());
}
#[test]
fn commit_detail_has_body_and_diff() {
let (dir, _, c2, _) = fixture();
let detail = commit(dir.path(), &c2).expect("fixture");
assert_eq!(detail.summary.sha, c2);
assert_eq!(detail.body, "long body");
let paths: Vec<_> = detail.diff.files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, ["a.txt", "b.txt"]);
assert_eq!(detail.diff.files[0].additions, Some(1));
assert_eq!(detail.diff.files[0].deletions, Some(1));
assert!(detail.diff.patch.contains("+TWO"));
assert!(!detail.diff.truncated);
}
#[test]
fn root_commit_diff_lists_added_files() {
let (dir, c1, ..) = fixture();
let detail = commit(dir.path(), &c1).expect("fixture");
assert!(detail.summary.parents.is_empty());
assert_eq!(detail.diff.files.len(), 1);
assert!(detail.diff.patch.contains("+one"));
}
#[test]
fn compare_uses_merge_base_three_dot_semantics() {
let (dir, c1, c2, topic) = fixture();
let result = compare(dir.path(), "main", "topic").expect("fixture");
assert_eq!(result.merge_base.as_deref(), Some(c1.as_str()));
assert_eq!(result.commits.len(), 1);
assert_eq!(result.commits[0].sha, topic);
let paths: Vec<_> = result.diff.files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, ["t.txt"], "main-only changes must not appear");
let reverse = compare(dir.path(), "topic", "main").expect("fixture");
assert_eq!(reverse.commits[0].sha, c2);
}
#[test]
fn blame_attributes_each_line() {
let (dir, c1, c2, _) = fixture();
let result = blame(dir.path(), "main", "a.txt").expect("fixture");
assert_eq!(result.lines.len(), 2);
assert_eq!(result.lines[0].sha, c1);
assert_eq!(result.lines[0].author, "Ada");
assert_eq!(result.lines[0].content, "one");
assert_eq!(result.lines[1].sha, c2);
assert_eq!(result.lines[1].author, "Bob");
assert_eq!(result.lines[1].summary, "second");
assert_eq!(result.lines[1].line, 2);
assert!(blame(dir.path(), "main", "missing.txt").is_err());
}
#[test]
fn oversized_diff_is_truncated() {
let (dir, ..) = fixture();
let repo = dir.path();
fs::write(repo.join("big.txt"), "x\n".repeat(MAX_DIFF_BYTES)).expect("fixture");
run(repo, &["add", "."]);
run(repo, &["commit", "-m", "big"]);
let sha = output(repo, ["rev-parse", "HEAD"]).expect("fixture");
let detail = commit(repo, &sha).expect("fixture");
assert!(detail.diff.truncated);
assert!(detail.diff.patch.len() <= MAX_DIFF_BYTES);
}
}