use chrono::{TimeZone, Utc};
use git2::{BranchType, Oid, Repository};
use std::collections::HashMap;
use crate::error::{Result, TrvError};
use crate::vcs::traits::CommitInfo;
fn parse_commit_message(message: &str) -> (String, Option<String>) {
let mut lines = message.lines();
let summary = lines.next().unwrap_or("(no message)").to_string();
let body_text: String = lines
.skip_while(|l| l.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
let body = if body_text.trim().is_empty() {
None
} else {
Some(body_text)
};
(summary, body)
}
fn get_branch_tip_names(repo: &Repository) -> HashMap<Oid, Vec<String>> {
let mut names_by_tip: HashMap<Oid, Vec<String>> = HashMap::new();
if let Ok(branches) = repo.branches(Some(BranchType::Local)) {
for (branch, _) in branches.flatten() {
let Some(target) = branch.get().target() else {
continue;
};
let Ok(Some(name)) = branch.name() else {
continue;
};
names_by_tip
.entry(target)
.or_default()
.push(name.to_string());
}
}
for names in names_by_tip.values_mut() {
names.sort_unstable();
}
names_by_tip
}
pub fn get_recent_commits(
repo: &Repository,
offset: usize,
limit: usize,
) -> Result<Vec<CommitInfo>> {
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
let branch_tip_names = get_branch_tip_names(repo);
let mut commits = Vec::new();
for oid in revwalk.skip(offset).take(limit) {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let id = oid.to_string();
let short_id = id[..7.min(id.len())].to_string();
let full_message = commit.message().unwrap_or("(no message)");
let (summary, body) = parse_commit_message(full_message);
let author = commit.author().name().unwrap_or("Unknown").to_string();
let branch_name = branch_tip_names
.get(&oid)
.and_then(|names| names.first().cloned());
let time = Utc
.timestamp_opt(commit.time().seconds(), 0)
.single()
.unwrap_or_else(Utc::now);
commits.push(CommitInfo {
id,
short_id,
branch_name,
summary,
body,
author,
time,
});
}
Ok(commits)
}
pub fn get_commits_info(repo: &Repository, ids: &[String]) -> Result<Vec<CommitInfo>> {
let branch_tip_names = get_branch_tip_names(repo);
let mut commits = Vec::new();
for id_str in ids {
let oid = Oid::from_str(id_str)
.map_err(|e| TrvError::VcsCommand(format!("Invalid commit ID {id_str}: {e}")))?;
let commit = repo
.find_commit(oid)
.map_err(|e| TrvError::VcsCommand(format!("Commit not found {id_str}: {e}")))?;
let id = oid.to_string();
let short_id = id[..7.min(id.len())].to_string();
let full_message = commit.message().unwrap_or("(no message)");
let (summary, body) = parse_commit_message(full_message);
let author = commit.author().name().unwrap_or("Unknown").to_string();
let branch_name = branch_tip_names
.get(&oid)
.and_then(|names| names.first().cloned());
let time = Utc
.timestamp_opt(commit.time().seconds(), 0)
.single()
.unwrap_or_else(Utc::now);
commits.push(CommitInfo {
id,
short_id,
branch_name,
summary,
body,
author,
time,
});
}
Ok(commits)
}
pub fn resolve_revisions(repo: &Repository, revisions: &str) -> Result<Vec<String>> {
let revspec = repo.revparse(revisions)?;
let mode = revspec.mode();
let mut commit_ids = if mode.contains(git2::RevparseMode::MERGE_BASE) {
let from = revspec.from().ok_or_else(|| {
TrvError::VcsCommand("Invalid three-dot range: missing 'from'".into())
})?;
let to = revspec
.to()
.ok_or_else(|| TrvError::VcsCommand("Invalid three-dot range: missing 'to'".into()))?;
let merge_base = repo.merge_base(from.id(), to.id()).map_err(|_| {
TrvError::UnsupportedOperation("Three-dot range not supported for this revset".into())
})?;
let mut revwalk = repo.revwalk()?;
revwalk.push(to.id())?;
revwalk.hide(merge_base)?;
revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
let mut ids = Vec::new();
for oid in revwalk {
ids.push(oid?.to_string());
}
ids
} else if mode.contains(git2::RevparseMode::RANGE) {
let from = revspec
.from()
.ok_or_else(|| TrvError::VcsCommand("Invalid revision range: missing 'from'".into()))?;
let to = revspec
.to()
.ok_or_else(|| TrvError::VcsCommand("Invalid revision range: missing 'to'".into()))?;
let mut revwalk = repo.revwalk()?;
revwalk.push(to.id())?;
revwalk.hide(from.id())?;
revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
let mut ids = Vec::new();
for oid in revwalk {
ids.push(oid?.to_string());
}
ids
} else {
let obj = revspec
.from()
.ok_or_else(|| TrvError::VcsCommand("Invalid revision expression".into()))?;
let commit = obj
.peel_to_commit()
.map_err(|e| TrvError::VcsCommand(format!("Not a commit: {e}")))?;
vec![commit.id().to_string()]
};
if commit_ids.is_empty() {
if mode.contains(git2::RevparseMode::MERGE_BASE) {
return Ok(commit_ids);
}
return Err(TrvError::NoChanges);
}
commit_ids.reverse();
Ok(commit_ids)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
fn create_commit(repo: &Repository, file_name: &str, content: &str, msg: &str) -> Oid {
fs::write(repo.workdir().unwrap().join(file_name), content).expect("failed to write file");
let mut index = repo.index().expect("failed to open index");
index
.add_path(Path::new(file_name))
.expect("failed to add file to index");
index.write().expect("failed to write index");
let tree_id = index.write_tree().expect("failed to write tree");
let tree = repo.find_tree(tree_id).expect("failed to find tree");
let sig = git2::Signature::now("Test User", "test@example.com")
.expect("failed to create signature");
let parent_ref = repo.head().ok();
let parents: Vec<git2::Commit> = parent_ref
.and_then(|r| r.peel_to_commit().ok())
.into_iter()
.collect();
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parent_refs)
.expect("failed to create commit")
}
#[test]
fn should_resolve_three_dot_range_head_to_head_as_empty() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("failed to init repo");
create_commit(&repo, "file.txt", "hello\n", "initial");
let ids = resolve_revisions(&repo, "HEAD...HEAD").expect("three-dot resolve failed");
assert!(
ids.is_empty(),
"expected empty list for HEAD...HEAD, got {ids:?}"
);
}
#[test]
fn should_resolve_three_dot_range_walks_to_merge_base() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("failed to init repo");
repo.set_head("refs/heads/main")
.expect("failed to set HEAD to main");
let base_oid = create_commit(&repo, "base.txt", "base\n", "base");
repo.branch("feature", &repo.find_commit(base_oid).unwrap(), false)
.expect("failed to create feature branch");
let main_oid = create_commit(&repo, "main.txt", "main-only\n", "on main");
repo.set_head("refs/heads/feature")
.expect("failed to set HEAD to feature");
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
.expect("failed to checkout feature");
let feature_oid = create_commit(&repo, "feature.txt", "feature-only\n", "on feature");
let ids = resolve_revisions(&repo, "main...feature").expect("three-dot resolve failed");
assert_eq!(
ids.len(),
1,
"expected only feature-side commit, got {ids:?}"
);
assert_eq!(ids[0], feature_oid.to_string());
assert!(!ids.contains(&main_oid.to_string()));
}
#[test]
fn should_resolve_two_dot_range() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("failed to init repo");
let c1 = create_commit(&repo, "a.txt", "1\n", "c1");
let c2 = create_commit(&repo, "a.txt", "2\n", "c2");
let c3 = create_commit(&repo, "a.txt", "3\n", "c3");
let range = format!("{c1}..HEAD");
let ids = resolve_revisions(&repo, &range).expect("two-dot resolve failed");
assert_eq!(ids, vec![c2.to_string(), c3.to_string()]);
}
}