* Copyright(c) 2024-2026 China Telecom Cloud Technologies Co., Ltd. All rights
* reserved. ctscat is licensed under Mulan PSL v2. You can use this software
* according to the terms and conditions of the Mulan PSL V2. You may obtain a
* copy of Mulan PSL v2 at: http://license.coscl.org.cn/MulanPSL2.
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY
* KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. See the Mulan PSL v2 for
* more details.
*/
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use git2::{Repository, Sort};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitCommit {
pub sha: String,
pub message: String,
pub author: String,
pub author_email: String,
pub committed_at: DateTime<Utc>,
pub files_changed: usize,
}
#[derive(Debug, Clone)]
pub struct CommitDiff {
pub l1_ahead: Vec<GitCommit>,
pub l2_ahead: Vec<GitCommit>,
}
pub struct GitRepositoryClient {
repo_path: PathBuf,
}
impl GitRepositoryClient {
pub fn new(repo_path: impl AsRef<Path>) -> Result<Self> {
let repo_path = repo_path.as_ref().to_path_buf();
if !repo_path.exists() {
return Err(anyhow!("仓库路径不存在: {}", repo_path.display()));
}
Ok(Self { repo_path })
}
pub fn clone_or_pull(url: &str, target_path: impl AsRef<Path>, branch: &str) -> Result<Self> {
let target_path = target_path.as_ref();
if target_path.exists() {
info!(url = url, path = ?target_path, "更新现有仓库");
let repo = Repository::open(target_path).context("打开现有仓库失败")?;
Self::fetch_and_checkout(&repo, branch)?;
} else {
info!(url = url, path = ?target_path, "克隆新仓库");
Repository::clone(url, target_path).context("克隆仓库失败")?;
let repo = Repository::open(target_path).context("打开新克隆的仓库失败")?;
Self::fetch_and_checkout(&repo, branch)?;
}
Self::new(target_path)
}
pub fn get_commits(&self, branch: &str) -> Result<Vec<GitCommit>> {
let repo = Repository::open(&self.repo_path).context("打开仓库失败")?;
let branch_name = format!("refs/heads/{}", branch);
let obj = repo
.revparse_single(&branch_name)
.or_else(|_| repo.revparse_single(&format!("remotes/origin/{}", branch)))
.context(format!("找不到分支: {}", branch))?;
let mut revwalk = repo.revwalk().context("创建 revwalk 失败")?;
revwalk.push(obj.id()).context("设置 revwalk 起点失败")?;
revwalk
.set_sorting(Sort::TIME | Sort::REVERSE)
.context("设置排序失败")?;
let mut commits = Vec::new();
for oid in revwalk {
let oid = oid.context("获取 OID 失败")?;
let commit = repo.find_commit(oid).context("查找 commit 失败")?;
let files_changed = Self::count_files_changed(&repo, &commit)?;
commits.push(GitCommit {
sha: commit.id().to_string(),
message: commit
.message()
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string(),
author: commit.author().name().unwrap_or("Unknown").to_string(),
author_email: commit.author().email().unwrap_or("").to_string(),
committed_at: DateTime::<Utc>::from_timestamp(commit.time().seconds(), 0)
.unwrap_or_else(Utc::now),
files_changed,
});
}
debug!(branch = branch, count = commits.len(), "获取 commits 完成");
Ok(commits)
}
pub fn compare_branches(&self, l1_branch: &str, l2_branch: &str) -> Result<CommitDiff> {
let l1_commits = self
.get_commits(l1_branch)
.context("获取 L1 branch commits 失败")?;
let l2_commits = self
.get_commits(l2_branch)
.context("获取 L2 branch commits 失败")?;
let diff = Self::compute_diff(&l1_commits, &l2_commits);
info!(
l1_branch = l1_branch,
l2_branch = l2_branch,
l1_ahead = diff.l1_ahead.len(),
l2_ahead = diff.l2_ahead.len(),
"分支对比完成"
);
Ok(diff)
}
pub fn compute_diff(l1_commits: &[GitCommit], l2_commits: &[GitCommit]) -> CommitDiff {
let l2_shas: std::collections::HashSet<_> =
l2_commits.iter().map(|c| c.sha.as_str()).collect();
let l1_ahead: Vec<GitCommit> = l1_commits
.iter()
.filter(|c| !l2_shas.contains(c.sha.as_str()))
.cloned()
.collect();
let l1_shas: std::collections::HashSet<_> =
l1_commits.iter().map(|c| c.sha.as_str()).collect();
let l2_ahead: Vec<GitCommit> = l2_commits
.iter()
.filter(|c| !l1_shas.contains(c.sha.as_str()))
.cloned()
.collect();
CommitDiff { l1_ahead, l2_ahead }
}
fn fetch_and_checkout(repo: &Repository, branch: &str) -> Result<()> {
if let Ok(mut remote) = repo.find_remote("origin") {
remote
.fetch(&[branch], None, None)
.context("Fetch 分支失败")?;
}
let obj = repo
.revparse_single(&format!("refs/heads/{}", branch))
.or_else(|_| repo.revparse_single(&format!("remotes/origin/{}", branch)))
.context(format!("找不到分支: {}", branch))?;
repo.set_head_detached(obj.id()).context("检出分支失败")?;
Ok(())
}
fn count_files_changed(repo: &Repository, commit: &git2::Commit) -> Result<usize> {
if commit.parent_count() == 0 {
let tree = commit.tree().context("获取树失败")?;
Ok(tree.len())
} else {
let parent = commit.parent(0).context("获取父 commit 失败")?;
let parent_tree = parent.tree().context("获取父树失败")?;
let commit_tree = commit.tree().context("获取 commit 树失败")?;
let diff = repo
.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
.context("生成 diff 失败")?;
Ok(diff.deltas().len())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use git2::{Oid, Repository, Signature};
use tempfile::TempDir;
fn create_commit(repo: &Repository, message: &str, parent: Option<Oid>) -> Oid {
let signature = Signature::now("Test User", "test@example.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let parents = if let Some(p) = parent {
vec![repo.find_commit(p).unwrap()]
} else {
vec![]
};
let parents_refs: Vec<&git2::Commit> = parents.iter().collect();
repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parents_refs,
)
.unwrap()
}
#[test]
fn test_new_invalid_path() {
let result = GitRepositoryClient::new("/invalid/path");
assert!(result.is_err());
}
#[test]
fn test_compute_diff() {
let now = Utc::now();
let c1 = GitCommit {
sha: "sha1".to_string(),
message: "m1".to_string(),
author: "a".to_string(),
author_email: "e".to_string(),
committed_at: now,
files_changed: 1,
};
let c2 = GitCommit {
sha: "sha2".to_string(),
message: "m2".to_string(),
author: "a".to_string(),
author_email: "e".to_string(),
committed_at: now,
files_changed: 1,
};
let c3 = GitCommit {
sha: "sha3".to_string(),
message: "m3".to_string(),
author: "a".to_string(),
author_email: "e".to_string(),
committed_at: now,
files_changed: 1,
};
let diff =
GitRepositoryClient::compute_diff(&[c1.clone(), c2.clone()], std::slice::from_ref(&c1));
assert_eq!(diff.l1_ahead.len(), 1);
assert_eq!(diff.l1_ahead[0].sha, "sha2");
assert_eq!(diff.l2_ahead.len(), 0);
let diff =
GitRepositoryClient::compute_diff(std::slice::from_ref(&c1), &[c1.clone(), c3.clone()]);
assert_eq!(diff.l1_ahead.len(), 0);
assert_eq!(diff.l2_ahead.len(), 1);
assert_eq!(diff.l2_ahead[0].sha, "sha3");
}
#[test]
fn test_get_commits() {
let temp_dir = TempDir::new().unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
let oid1 = create_commit(&repo, "First commit", None);
let oid2 = create_commit(&repo, "Second commit", Some(oid1));
let client = GitRepositoryClient::new(temp_dir.path()).unwrap();
let commits = client.get_commits("master").unwrap();
assert_eq!(commits.len(), 2);
let shas: Vec<String> = commits.iter().map(|c| c.sha.clone()).collect();
assert!(shas.contains(&oid1.to_string()));
assert!(shas.contains(&oid2.to_string()));
}
#[test]
fn test_compute_diff_no_diff() {
let commit1 = GitCommit {
sha: "abc123".to_string(),
message: "test".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let l1 = vec![commit1.clone()];
let l2 = vec![commit1];
let diff = GitRepositoryClient::compute_diff(&l1, &l2);
assert_eq!(diff.l1_ahead.len(), 0);
assert_eq!(diff.l2_ahead.len(), 0);
}
#[test]
fn test_compute_diff_l1_ahead() {
let commit1 = GitCommit {
sha: "abc123".to_string(),
message: "first".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let commit2 = GitCommit {
sha: "def456".to_string(),
message: "second".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let l1 = vec![commit1.clone(), commit2.clone()];
let l2 = vec![commit1];
let diff = GitRepositoryClient::compute_diff(&l1, &l2);
assert_eq!(diff.l1_ahead.len(), 1);
assert_eq!(diff.l2_ahead.len(), 0);
assert_eq!(diff.l1_ahead[0].sha, "def456");
}
#[test]
fn test_compute_diff_l2_ahead() {
let commit1 = GitCommit {
sha: "abc123".to_string(),
message: "first".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let commit2 = GitCommit {
sha: "def456".to_string(),
message: "second".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let l1 = vec![commit1.clone()];
let l2 = vec![commit1, commit2.clone()];
let diff = GitRepositoryClient::compute_diff(&l1, &l2);
assert_eq!(diff.l1_ahead.len(), 0);
assert_eq!(diff.l2_ahead.len(), 1);
assert_eq!(diff.l2_ahead[0].sha, "def456");
}
#[test]
fn test_compute_diff_both_different() {
let commit1 = GitCommit {
sha: "aaa".to_string(),
message: "first".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let commit2 = GitCommit {
sha: "bbb".to_string(),
message: "second".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let commit3 = GitCommit {
sha: "ccc".to_string(),
message: "third".to_string(),
author: "test".to_string(),
author_email: "test@example.com".to_string(),
committed_at: Utc::now(),
files_changed: 1,
};
let l1 = vec![commit1.clone(), commit2.clone()];
let l2 = vec![commit1, commit3.clone()];
let diff = GitRepositoryClient::compute_diff(&l1, &l2);
assert_eq!(diff.l1_ahead.len(), 1);
assert_eq!(diff.l2_ahead.len(), 1);
assert_eq!(diff.l1_ahead[0].sha, "bbb");
assert_eq!(diff.l2_ahead[0].sha, "ccc");
}
}