AkurAI Build
Menu

Public repository / 0e01aafc5042febc63a50aafdb66008ea3187006

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, _)) = &current
            && 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);
    }
}