use std::path::Path;
use git2::{Delta, DiffFindOptions, ErrorCode, Repository, Sort, Tree};
use crate::error::DiffError;
use crate::types::DocDiffFileStatus;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitMeta {
pub hash: String,
pub short_hash: String,
pub parents: Vec<String>,
pub author: Option<String>,
pub date: Option<String>,
pub subject: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevisionContent {
pub meta: CommitMeta,
pub old_text: String,
pub new_text: String,
pub status: DocDiffFileStatus,
pub path: String,
pub old_path: Option<String>,
}
pub fn discover_repo(path: &Path) -> Result<Option<Repository>, DiffError> {
match Repository::discover(path) {
Ok(repo) => Ok(Some(repo)),
Err(e) if e.code() == ErrorCode::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn doc_revisions(
repo: &Repository,
doc_rel_path: &str,
limit: usize,
) -> Result<Vec<RevisionContent>, DiffError> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
if revwalk.push_head().is_err() {
return Ok(Vec::new());
}
let mut current_path = doc_rel_path.to_string();
let mut out = Vec::new();
for oid in revwalk {
if out.len() >= limit {
break;
}
let oid = oid?;
let commit = repo.find_commit(oid)?;
let commit_tree = commit.tree()?;
let parent = commit.parents().next();
let parent_tree: Option<Tree> = match &parent {
Some(p) => Some(p.tree()?),
None => None,
};
if commit.parent_count() > 1
&& merge_is_treesame_to_a_parent(&commit, &commit_tree, ¤t_path)?
{
continue;
}
let mut diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
let mut find_opts = DiffFindOptions::new();
find_opts.renames(true);
find_opts.rename_threshold(50);
diff.find_similar(Some(&mut find_opts))?;
let mut matched: Option<(DocDiffFileStatus, String, Option<String>)> = None;
for delta in diff.deltas() {
let new_path = delta.new_file().path().and_then(|p| p.to_str());
let old_path = delta.old_file().path().and_then(|p| p.to_str());
let touches = new_path == Some(current_path.as_str());
let touches_deleted =
delta.status() == Delta::Deleted && old_path == Some(current_path.as_str());
if touches || touches_deleted {
let status = classify(delta.status());
let resolved_new = new_path.unwrap_or(current_path.as_str()).to_string();
let resolved_old = old_path.map(|s| s.to_string());
matched = Some((status, resolved_new, resolved_old));
break;
}
}
let (status, new_path, old_path) = match matched {
Some(m) => m,
None => continue,
};
let new_text = if status == DocDiffFileStatus::Deleted {
String::new()
} else {
blob_text(repo, &commit_tree, &new_path)
};
let old_text = match (&parent_tree, status) {
(_, DocDiffFileStatus::Added) => String::new(),
(Some(pt), _) => {
let read_path = old_path.as_deref().unwrap_or(new_path.as_str());
blob_text(repo, pt, read_path)
}
(None, _) => String::new(),
};
let rename_old_path = if status == DocDiffFileStatus::Renamed {
old_path.clone()
} else {
None
};
out.push(RevisionContent {
meta: commit_meta(&commit)?,
old_text,
new_text,
status,
path: new_path,
old_path: rename_old_path,
});
if status == DocDiffFileStatus::Renamed {
if let Some(op) = old_path {
current_path = op;
}
}
}
Ok(out)
}
pub fn head_source(docs_dir: &Path, doc_rel_path: &str) -> Option<String> {
let repo = Repository::discover(docs_dir).ok()?;
let workdir = repo.workdir()?.to_path_buf();
let abs = docs_dir.join(doc_rel_path).canonicalize().ok()?;
let work = workdir.canonicalize().ok()?;
let rel = abs.strip_prefix(&work).ok()?;
let rel_str = rel.to_str()?;
let tree = repo.head().ok()?.peel_to_tree().ok()?;
let entry = tree.get_path(Path::new(rel_str)).ok()?;
let object = entry.to_object(&repo).ok()?;
let blob = object.as_blob()?;
Some(String::from_utf8_lossy(blob.content()).into_owned())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlobalFileChange {
pub status: DocDiffFileStatus,
pub path: String,
pub old_path: Option<String>,
pub old_text: String,
pub new_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlobalRevision {
pub meta: CommitMeta,
pub files: Vec<GlobalFileChange>,
}
fn under_prefix(path: &str, prefix: &str) -> bool {
path == prefix || path.starts_with(&format!("{prefix}/"))
}
pub fn global_doc_revisions(
repo: &Repository,
docs_prefix: &str,
limit: usize,
) -> Result<Vec<GlobalRevision>, DiffError> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
if revwalk.push_head().is_err() {
return Ok(Vec::new());
}
let mut out: Vec<GlobalRevision> = Vec::new();
for oid in revwalk {
if out.len() >= limit {
break;
}
let oid = oid?;
let commit = repo.find_commit(oid)?;
let commit_tree = commit.tree()?;
let parent = commit.parents().next();
let parent_tree: Option<Tree> = match &parent {
Some(p) => Some(p.tree()?),
None => None,
};
if commit.parent_count() > 1
&& merge_docs_treesame_to_a_parent(&commit, &commit_tree, docs_prefix)?
{
continue;
}
let mut diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
let mut find_opts = DiffFindOptions::new();
find_opts.renames(true);
find_opts.rename_threshold(50);
diff.find_similar(Some(&mut find_opts))?;
let mut files: Vec<GlobalFileChange> = Vec::new();
for delta in diff.deltas() {
let new_path = delta.new_file().path().and_then(|p| p.to_str());
let old_path = delta.old_file().path().and_then(|p| p.to_str());
let status = classify(delta.status());
let touch_path = if status == DocDiffFileStatus::Deleted {
old_path
} else {
new_path
};
let Some(touch_path) = touch_path else {
continue;
};
if !under_prefix(touch_path, docs_prefix) {
continue;
}
let resolved_new = new_path.unwrap_or(touch_path).to_string();
let resolved_old = old_path.map(|s| s.to_string());
let new_text = if status == DocDiffFileStatus::Deleted {
String::new()
} else {
blob_text(repo, &commit_tree, &resolved_new)
};
let old_text = match (&parent_tree, status) {
(_, DocDiffFileStatus::Added) => String::new(),
(Some(pt), _) => {
let read_path = resolved_old.as_deref().unwrap_or(resolved_new.as_str());
blob_text(repo, pt, read_path)
}
(None, _) => String::new(),
};
let rename_old_path = if status == DocDiffFileStatus::Renamed {
resolved_old.clone()
} else {
None
};
files.push(GlobalFileChange {
status,
path: resolved_new,
old_path: rename_old_path,
old_text,
new_text,
});
}
if files.is_empty() {
continue;
}
files.sort_by(|a, b| a.path.cmp(&b.path));
out.push(GlobalRevision {
meta: commit_meta(&commit)?,
files,
});
}
Ok(out)
}
fn merge_docs_treesame_to_a_parent(
commit: &git2::Commit,
commit_tree: &Tree,
docs_prefix: &str,
) -> Result<bool, DiffError> {
let merge_oid = blob_oid_at(commit_tree, docs_prefix);
for parent in commit.parents() {
let parent_tree = parent.tree()?;
if blob_oid_at(&parent_tree, docs_prefix) == merge_oid {
return Ok(true);
}
}
Ok(false)
}
fn merge_is_treesame_to_a_parent(
commit: &git2::Commit,
commit_tree: &Tree,
path: &str,
) -> Result<bool, DiffError> {
let merge_oid = blob_oid_at(commit_tree, path);
for parent in commit.parents() {
let parent_tree = parent.tree()?;
if blob_oid_at(&parent_tree, path) == merge_oid {
return Ok(true);
}
}
Ok(false)
}
fn blob_oid_at(tree: &Tree, path: &str) -> Option<git2::Oid> {
tree.get_path(Path::new(path)).ok().map(|e| e.id())
}
fn classify(delta: Delta) -> DocDiffFileStatus {
match delta {
Delta::Added => DocDiffFileStatus::Added,
Delta::Deleted => DocDiffFileStatus::Deleted,
Delta::Renamed => DocDiffFileStatus::Renamed,
_ => DocDiffFileStatus::Modified,
}
}
fn blob_text(repo: &Repository, tree: &Tree, path: &str) -> String {
let entry = match tree.get_path(Path::new(path)) {
Ok(e) => e,
Err(_) => return String::new(),
};
let object = match entry.to_object(repo) {
Ok(o) => o,
Err(_) => return String::new(),
};
match object.as_blob() {
Some(blob) => String::from_utf8_lossy(blob.content()).into_owned(),
None => String::new(),
}
}
fn commit_meta(commit: &git2::Commit) -> Result<CommitMeta, DiffError> {
let hash = commit.id().to_string();
let short_hash = commit
.as_object()
.short_id()
.ok()
.and_then(|buf| buf.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| hash.chars().take(7).collect());
let parents = commit.parent_ids().map(|oid| oid.to_string()).collect();
let author = commit.author().name().and_then(|n| {
if n.is_empty() {
None
} else {
Some(n.to_string())
}
});
let date = rfc3339(commit.time());
let subject = commit
.message()
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string();
Ok(CommitMeta {
hash,
short_hash,
parents,
author,
date,
subject,
})
}
fn rfc3339(time: git2::Time) -> Option<String> {
use chrono::{FixedOffset, TimeZone};
let offset = FixedOffset::east_opt(time.offset_minutes() * 60)?;
offset
.timestamp_opt(time.seconds(), 0)
.single()
.map(|dt| dt.to_rfc3339())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempRepo;
#[test]
fn discover_repo_returns_none_outside_git() {
let dir = std::env::temp_dir().join(format!("docgen_nogit_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(discover_repo(&dir).unwrap().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_repo_finds_temp_repo() {
let r = TempRepo::init();
r.commit_file("docs/a.md", "x\n", "a");
assert!(discover_repo(&r.dir).unwrap().is_some());
}
#[test]
fn doc_revisions_lists_commits_newest_first_with_content() {
let r = TempRepo::init();
r.commit_file("docs/a.md", "# A\nfirst\n", "add a");
r.commit_file("docs/a.md", "# A\nsecond\n", "edit a");
r.commit_file("docs/other.md", "x\n", "unrelated");
let revs = doc_revisions(&r.repo, "docs/a.md", 50).unwrap();
assert_eq!(revs.len(), 2);
assert_eq!(revs[0].meta.subject, "edit a");
assert_eq!(revs[1].meta.subject, "add a");
assert_eq!(revs[0].new_text, "# A\nsecond\n");
assert_eq!(revs[0].old_text, "# A\nfirst\n");
assert_eq!(revs[0].status, DocDiffFileStatus::Modified);
assert_eq!(revs[1].old_text, "");
assert_eq!(revs[1].new_text, "# A\nfirst\n");
assert_eq!(revs[1].status, DocDiffFileStatus::Added);
assert!(revs[0].meta.hash.starts_with(&revs[0].meta.short_hash));
assert_eq!(revs[1].meta.parents.len(), 0);
assert_eq!(revs[0].meta.parents.len(), 1);
assert_eq!(revs[0].meta.author.as_deref(), Some("docgen test"));
assert!(revs[0].meta.date.is_some());
}
#[test]
fn doc_revisions_follows_a_rename() {
let r = TempRepo::init();
r.commit_file("docs/old.md", "# Doc\nbody line\nmore\n", "create");
r.rename_file("docs/old.md", "docs/new.md");
r.commit_all("rename");
let revs = doc_revisions(&r.repo, "docs/new.md", 50).unwrap();
assert!(revs
.iter()
.any(|rev| rev.status == DocDiffFileStatus::Renamed
&& rev.old_path.as_deref() == Some("docs/old.md")));
assert!(revs.iter().any(|rev| rev.meta.subject == "create"));
}
#[test]
fn doc_revisions_rename_with_heavy_edit_is_add_delete_not_followed() {
let r = TempRepo::init();
r.commit_file(
"docs/old.md",
"alpha beta gamma\ndelta epsilon zeta\neta theta iota\n",
"create old",
);
r.rename_file("docs/old.md", "docs/new.md");
std::fs::write(
r.dir.join("docs/new.md"),
"completely different content here\nnothing in common at all\nbrand new words only\n",
)
.unwrap();
r.commit_all("rename and rewrite");
let revs = doc_revisions(&r.repo, "docs/new.md", 50).unwrap();
assert_eq!(revs.len(), 1);
assert_eq!(revs[0].status, DocDiffFileStatus::Added);
assert_eq!(revs[0].old_path, None);
assert_eq!(revs[0].old_text, "");
}
#[test]
fn doc_revisions_empty_for_untouched_path() {
let r = TempRepo::init();
r.commit_file("docs/a.md", "x\n", "a");
assert!(doc_revisions(&r.repo, "docs/ghost.md", 50)
.unwrap()
.is_empty());
}
#[test]
fn doc_revisions_empty_for_initialized_repo_with_no_commits() {
let r = TempRepo::init();
assert!(doc_revisions(&r.repo, "docs/a.md", 50).unwrap().is_empty());
}
#[test]
fn doc_revisions_records_deletion() {
let r = TempRepo::init();
r.commit_file("docs/a.md", "# A\nbody\n", "add a");
r.delete_file("docs/a.md");
r.commit_all("remove a");
let revs = doc_revisions(&r.repo, "docs/a.md", 50).unwrap();
assert_eq!(revs[0].meta.subject, "remove a");
assert_eq!(revs[0].status, DocDiffFileStatus::Deleted);
assert_eq!(revs[0].new_text, "");
assert_eq!(revs[0].old_text, "# A\nbody\n");
}
#[test]
fn doc_revisions_respects_limit() {
let r = TempRepo::init();
r.commit_file("docs/a.md", "1\n", "edit 1");
r.commit_file("docs/a.md", "2\n", "edit 2");
r.commit_file("docs/a.md", "3\n", "edit 3");
let revs = doc_revisions(&r.repo, "docs/a.md", 2).unwrap();
assert_eq!(revs.len(), 2);
assert_eq!(revs[0].meta.subject, "edit 3");
assert_eq!(revs[1].meta.subject, "edit 2");
}
#[test]
fn doc_revisions_skips_merge_commit_treesame_to_a_parent() {
let r = TempRepo::init();
r.commit_file("docs/a.md", "# A\nl1\n", "base");
r.checkout_new_branch("feature");
r.commit_file("docs/a.md", "# A\nl1\nfeatureline\n", "feature edit");
r.checkout_branch("master");
r.commit_file("docs/b.txt", "b\n", "unrelated b");
r.merge_no_ff("feature", "merge feature");
let revs = doc_revisions(&r.repo, "docs/a.md", 50).unwrap();
let subjects: Vec<&str> = revs.iter().map(|x| x.meta.subject.as_str()).collect();
assert_eq!(subjects, vec!["feature edit", "base"]);
}
}