use std::path::Path;
use crate::error::{AppError, Result};
use super::{run_git_pathspec, Commit};
#[must_use = "the loaded commit history or error must be handled"]
pub(crate) fn load_commits(repo_root: &Path, repo_path: &Path) -> Result<Vec<Commit>> {
let output = run_git_pathspec(
repo_root,
"git log",
&["log", "--pretty=format:%H%x00%s%x00%cn, %ah%x00"],
repo_path,
)?;
let stdout = String::from_utf8_lossy(&output.stdout);
parse_commit_log(&stdout)
}
fn parse_commit_log(output: &str) -> Result<Vec<Commit>> {
let mut commits = Vec::new();
let mut fields = output.split('\0');
while let Some(hash) = fields.next() {
let hash = hash.trim_start_matches('\n');
if hash.is_empty() {
if fields.any(|field| !field.is_empty()) {
return Err(AppError::message("unexpected trailing git log field"));
}
break;
}
let Some(subject) = fields.next() else {
return Err(AppError::message(format!(
"unexpected git log record for commit {hash}"
)));
};
let Some(description) = fields.next() else {
return Err(AppError::message(format!(
"unexpected git log record for commit {hash}"
)));
};
commits.push(Commit {
hash: hash.to_string(),
subject: subject.to_string(),
description: description.to_string(),
});
}
Ok(commits)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_commit_log_with_nul_separated_fields() {
let output = "abc123\0subject with \x1e marker\0Name, 2 days ago\0";
let commits = parse_commit_log(output).expect("commit log should parse");
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].hash, "abc123");
assert_eq!(commits[0].subject, "subject with \x1e marker");
assert_eq!(commits[0].description, "Name, 2 days ago");
}
#[test]
fn parses_multiple_commit_log_records_without_newline_in_hash() {
let output = "abc123\0first\0Name, 2 days ago\0\ndef456\0second\0Name, 1 day ago\0";
let commits = parse_commit_log(output).expect("commit log should parse");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].hash, "abc123");
assert_eq!(commits[1].hash, "def456");
}
#[test]
fn rejects_malformed_commit_log_record() {
let error = parse_commit_log("abc123 subject only").expect_err("line should fail");
assert!(error.to_string().contains("unexpected git log record"));
}
#[test]
fn rejects_trailing_commit_log_fields() {
let error = parse_commit_log("abc123\0subject\0Name, 1 day ago\0\0junk")
.expect_err("trailing field should fail");
assert!(error
.to_string()
.contains("unexpected trailing git log field"));
}
}