pub mod cmd;
pub use cmd::git_cmd;
mod snapshot;
pub use snapshot::AnalysisSnapshot;
use crate::Config;
use anyhow::{Context, Result};
use git2::{DiffOptions, Repository as Git2Repo};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const RENAME_SIMILARITY_THRESHOLD: u16 = 50;
pub const MAX_COMMITS: usize = 500;
const TRUNCATED_COMMIT_SENTINEL_ID: &str = "0000000000000000000000000000000000000000";
const TRUNCATED_COMMIT_SENTINEL_SHORT_ID: &str = "0000000";
pub fn short_sha(sha: &str) -> &str {
if sha.len() >= 7 { &sha[..7] } else { sha }
}
pub struct Repository {
inner: Git2Repo,
path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct ResolvedRef {
pub name: String,
pub commit_id: String,
pub is_remote: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diff {
pub base: String,
pub target: String,
pub base_commit_id: String,
pub target_commit_id: String,
pub files: Vec<FileChange>,
pub stats: DiffStats,
pub commits: Vec<CommitInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChange {
pub path: String,
pub status: FileStatus,
pub additions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FileStatus {
Added,
Modified,
Deleted,
Renamed,
Copied,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DiffStats {
pub files_changed: usize,
pub additions: usize,
pub deletions: usize,
pub copied: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitInfo {
pub id: String,
pub short_id: String,
pub author: String,
pub email: String,
pub date: String,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct BranchInfo {
pub name: String,
pub is_current: bool,
pub is_remote: bool,
pub remote_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct BranchList {
pub local: Vec<BranchInfo>,
pub remote: Vec<BranchInfo>,
pub current: Option<String>,
}
impl Repository {
pub fn open(path: &Path) -> Result<Self> {
let inner = Git2Repo::open(path)
.with_context(|| format!("Failed to open git repository at {}", path.display()))?;
Ok(Self {
inner,
path: path.to_path_buf(),
})
}
pub fn prepare_refs(&self, config: &Config) -> Result<()> {
if !config.do_fetch || self.inner.find_remote("origin").is_err() {
return Ok(());
}
let fetch_origin = git_cmd()
.args(["fetch", "--quiet", "--prune", "origin"])
.current_dir(&self.path)
.status()
.context("Failed to run git fetch origin")?;
if !fetch_origin.success() {
anyhow::bail!("git fetch origin failed with status {}", fetch_origin);
}
if let Some(pr_number) = config.pr_number {
let pr_ref = format!("pull/{pr_number}/head:refs/remotes/origin/pr/{pr_number}");
let fetch_pr = git_cmd()
.args(["fetch", "--quiet", "origin", &pr_ref])
.current_dir(&self.path)
.status()
.with_context(|| format!("Failed to fetch PR ref for #{pr_number}"))?;
if !fetch_pr.success() {
anyhow::bail!("git fetch PR ref failed with status {}", fetch_pr);
}
}
Ok(())
}
pub fn resolve_target(&self, config: &Config) -> Result<ResolvedRef> {
let name = config.target.clone().unwrap_or_else(|| {
self.inner
.head()
.ok()
.and_then(|h| h.shorthand().map(String::from))
.unwrap_or_else(|| "HEAD".to_string())
});
if let Some(pr_number) = config.pr_number {
if let Some(commit_id) = config.pr_head_oid.as_deref()
&& self.resolve_ref(commit_id).is_ok()
{
return Ok(ResolvedRef {
name,
commit_id: commit_id.to_string(),
is_remote: true,
});
}
let pr_ref = format!("origin/pr/{pr_number}");
if let Ok(commit_id) = self.resolve_ref(&pr_ref) {
return Ok(ResolvedRef {
name,
commit_id,
is_remote: true,
});
}
}
let (commit_id, is_remote) = if config.remote_mode {
let remote_name = format!("origin/{}", name);
let oid = self.resolve_ref(&remote_name)?;
(oid, true)
} else {
let oid = self.resolve_ref(&name)?;
(oid, false)
};
Ok(ResolvedRef {
name,
commit_id,
is_remote,
})
}
pub fn resolve_bases(&self, config: &Config) -> Result<Vec<ResolvedRef>> {
let mut resolved = Vec::new();
for base in &config.bases {
if let Some(pr_base_oid) = config.pr_base_oid.as_deref()
&& self.resolve_ref(pr_base_oid).is_ok()
{
resolved.push(ResolvedRef {
name: base.clone(),
commit_id: pr_base_oid.to_string(),
is_remote: true,
});
continue;
}
let result = if config.remote_only {
self.resolve_remote_ref(base)
} else if config.local_only {
self.resolve_ref(base).map(|id| (id, false))
} else {
self.resolve_ref(base)
.map(|id| (id, false))
.or_else(|_| self.resolve_remote_ref(base))
};
match result {
Ok((commit_id, is_remote)) => {
resolved.push(ResolvedRef {
name: base.clone(),
commit_id,
is_remote,
});
}
Err(e) => {
if !config.quiet {
eprintln!("Warning: Could not resolve base '{}': {}", base, e);
}
}
}
}
Ok(resolved)
}
pub fn generate_diffs(&self, target: &ResolvedRef, bases: &[ResolvedRef]) -> Result<Vec<Diff>> {
use colored::Colorize;
let mut diffs = Vec::new();
for base in bases {
if base.commit_id == target.commit_id {
eprintln!(
" {} Skipping '{}' - same commit as target ({})",
"ℹ".blue(),
base.name,
short_sha(&base.commit_id)
);
continue;
}
let diff = self.diff_refs(base, target)?;
diffs.push(diff);
}
Ok(diffs)
}
pub(crate) fn diff_refs(&self, base: &ResolvedRef, target: &ResolvedRef) -> Result<Diff> {
let base_commit = self
.inner
.find_commit(git2::Oid::from_str(&base.commit_id)?)?;
let target_commit = self
.inner
.find_commit(git2::Oid::from_str(&target.commit_id)?)?;
let base_tree = base_commit.tree()?;
let target_tree = target_commit.tree()?;
let mut opts = DiffOptions::new();
opts.patience(true);
opts.context_lines(3);
let mut diff =
self.inner
.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), Some(&mut opts))?;
let mut find_opts = git2::DiffFindOptions::new();
find_opts.renames(true);
find_opts.copies(true);
find_opts.rename_threshold(RENAME_SIMILARITY_THRESHOLD);
diff.find_similar(Some(&mut find_opts))?;
let mut files = Vec::new();
let num_deltas = diff.deltas().len();
for i in 0..num_deltas {
let Some(delta) = diff.get_delta(i) else {
continue;
};
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let status = match delta.status() {
git2::Delta::Added => FileStatus::Added,
git2::Delta::Deleted => FileStatus::Deleted,
git2::Delta::Modified => FileStatus::Modified,
git2::Delta::Renamed => FileStatus::Renamed,
git2::Delta::Copied => FileStatus::Copied,
_ => FileStatus::Modified,
};
let (additions, deletions) = match git2::Patch::from_diff(&diff, i) {
Ok(Some(patch)) => {
let (_, adds, dels) = patch.line_stats().unwrap_or((0, 0, 0));
(adds, dels)
}
_ => (0, 0),
};
files.push(FileChange {
path,
status,
additions,
deletions,
});
}
let diff_stats = diff.stats()?;
let copied_count = files
.iter()
.filter(|f| f.status == FileStatus::Copied)
.count();
let stats = DiffStats {
files_changed: diff_stats.files_changed(),
additions: diff_stats.insertions(),
deletions: diff_stats.deletions(),
copied: copied_count,
};
let commits = self.commits_between(&base.commit_id, &target.commit_id)?;
Ok(Diff {
base: base.name.clone(),
target: target.name.clone(),
base_commit_id: base.commit_id.clone(),
target_commit_id: target.commit_id.clone(),
files,
stats,
commits,
})
}
pub(crate) fn commits_between(
&self,
base_id: &str,
target_id: &str,
) -> Result<Vec<CommitInfo>> {
let mut revwalk = self.inner.revwalk()?;
let target_oid = git2::Oid::from_str(target_id)?;
let base_oid = git2::Oid::from_str(base_id)?;
revwalk.push(target_oid)?;
revwalk.hide(base_oid)?;
revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
let mut commits = Vec::new();
for oid in revwalk {
let oid = oid?;
let commit = self.inner.find_commit(oid)?;
let info = CommitInfo {
id: oid.to_string(),
short_id: {
let full = oid.to_string();
short_sha(&full).to_string()
},
author: commit.author().name().unwrap_or("Unknown").to_string(),
email: commit.author().email().unwrap_or("").to_string(),
date: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
.unwrap_or_default(),
message: commit.summary().unwrap_or("").to_string(),
};
commits.push(info);
}
truncate_commit_list(&mut commits);
Ok(commits)
}
fn resolve_ref(&self, name: &str) -> Result<String> {
if let Ok(branch) = self.inner.find_branch(name, git2::BranchType::Local)
&& let Some(target) = branch.get().target()
{
return Ok(target.to_string());
}
if let Ok(reference) = self.inner.find_reference(name)
&& let Some(target) = reference.target()
{
return Ok(target.to_string());
}
if let Ok(obj) = self.inner.revparse_single(name) {
return Ok(obj.id().to_string());
}
anyhow::bail!("Could not resolve ref: {}", name)
}
fn resolve_remote_ref(&self, name: &str) -> Result<(String, bool)> {
let remote_name = if name.starts_with("origin/") {
name.to_string()
} else {
format!("origin/{}", name)
};
if let Ok(branch) = self
.inner
.find_branch(&remote_name, git2::BranchType::Remote)
&& let Some(target) = branch.get().target()
{
return Ok((target.to_string(), true));
}
let ref_name = format!("refs/remotes/{}", remote_name);
if let Ok(reference) = self.inner.find_reference(&ref_name)
&& let Some(target) = reference.target()
{
return Ok((target.to_string(), true));
}
anyhow::bail!("Could not resolve remote ref: {}", remote_name)
}
pub fn commit_patch(&self, commit_id: &str) -> Result<String> {
let oid = git2::Oid::from_str(commit_id)?;
let commit = self.inner.find_commit(oid)?;
let parent = commit.parent(0).ok();
let parent_tree = parent.as_ref().map(|p| p.tree()).transpose()?;
let commit_tree = commit.tree()?;
let diff = self
.inner
.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
Ok(Self::diff_to_patch(&diff))
}
pub fn merge_base(&self, a: &str, b: &str) -> Result<String> {
let a_oid = git2::Oid::from_str(a)?;
let b_oid = git2::Oid::from_str(b)?;
Ok(self.inner.merge_base(a_oid, b_oid)?.to_string())
}
pub fn is_merge_commit(&self, commit_id: &str) -> Result<bool> {
let oid = git2::Oid::from_str(commit_id)?;
let commit = self.inner.find_commit(oid)?;
Ok(commit.parent_count() > 1)
}
pub fn commit_patch_scoped(&self, commit_id: &str, paths: &[&str]) -> Result<String> {
let oid = git2::Oid::from_str(commit_id)?;
let commit = self.inner.find_commit(oid)?;
let parent = commit.parent(0).ok();
let parent_tree = parent.as_ref().map(|p| p.tree()).transpose()?;
let commit_tree = commit.tree()?;
let mut opts = DiffOptions::new();
for path in paths {
opts.pathspec(path);
}
let diff = self.inner.diff_tree_to_tree(
parent_tree.as_ref(),
Some(&commit_tree),
Some(&mut opts),
)?;
Ok(Self::diff_to_patch(&diff))
}
pub fn commit_touches_paths(&self, commit_id: &str, paths: &[&str]) -> Result<bool> {
let oid = git2::Oid::from_str(commit_id)?;
let commit = self.inner.find_commit(oid)?;
let parent = commit.parent(0).ok();
let parent_tree = parent.as_ref().map(|p| p.tree()).transpose()?;
let commit_tree = commit.tree()?;
let mut opts = DiffOptions::new();
for path in paths {
opts.pathspec(path);
}
let diff = self.inner.diff_tree_to_tree(
parent_tree.as_ref(),
Some(&commit_tree),
Some(&mut opts),
)?;
Ok(diff.deltas().len() > 0)
}
pub fn wip_diff_scoped(&self, paths: &[&str]) -> Result<String> {
let head = self.inner.head()?.peel_to_tree()?;
let mut opts = DiffOptions::new();
for path in paths {
opts.pathspec(path);
}
let staged = self
.inner
.diff_tree_to_index(Some(&head), None, Some(&mut opts))?;
let mut opts2 = DiffOptions::new();
opts2.include_untracked(true);
opts2.recurse_untracked_dirs(true);
opts2.show_untracked_content(true);
for path in paths {
opts2.pathspec(path);
}
let unstaged = self.inner.diff_index_to_workdir(None, Some(&mut opts2))?;
let mut patch = Self::diff_to_patch(&staged);
let unstaged_patch = Self::diff_to_patch(&unstaged);
if !unstaged_patch.is_empty() {
if !patch.is_empty() {
patch.push('\n');
}
patch.push_str(&unstaged_patch);
}
Ok(patch)
}
pub fn wip_files_scoped(&self, paths: &[&str]) -> Result<Vec<String>> {
let head = self.inner.head()?.peel_to_tree()?;
let mut opts = DiffOptions::new();
for path in paths {
opts.pathspec(path);
}
let staged = self
.inner
.diff_tree_to_index(Some(&head), None, Some(&mut opts))?;
let mut opts2 = DiffOptions::new();
opts2.include_untracked(true);
opts2.recurse_untracked_dirs(true);
for path in paths {
opts2.pathspec(path);
}
let unstaged = self.inner.diff_index_to_workdir(None, Some(&mut opts2))?;
let mut files: Vec<String> = Vec::new();
for diff in [&staged, &unstaged] {
for i in 0..diff.deltas().len() {
if let Some(delta) = diff.get_delta(i) {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if !files.contains(&path) {
files.push(path);
}
}
}
}
Ok(files)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn file_at_commit(&self, commit_ref: &str, file_path: &str) -> Result<String> {
let obj = self.inner.revparse_single(commit_ref)?;
let tree = obj.peel_to_tree()?;
let safe_path = crate::paths::validate_repo_relative_str(file_path)?;
let entry = tree.get_path(safe_path)?;
let blob = self.inner.find_blob(entry.id())?;
Ok(String::from_utf8_lossy(blob.content()).to_string())
}
pub fn file_diff(&self, base: &str, target: &str, file_path: &str) -> Result<String> {
let base_obj = self.inner.revparse_single(base)?;
let target_obj = self.inner.revparse_single(target)?;
let safe_path = crate::paths::validate_repo_relative_str(file_path)?;
let base_tree = base_obj.peel_to_tree()?;
let target_tree = target_obj.peel_to_tree()?;
let mut opts = git2::DiffOptions::new();
opts.pathspec(safe_path);
let diff =
self.inner
.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), Some(&mut opts))?;
Ok(Self::diff_to_patch(&diff))
}
pub fn full_diff(&self, base: &str, target: &str) -> Result<String> {
let base_obj = self.inner.revparse_single(base)?;
let target_obj = self.inner.revparse_single(target)?;
let base_tree = base_obj.peel_to_tree()?;
let target_tree = target_obj.peel_to_tree()?;
let mut diff = self
.inner
.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), None)?;
let mut find_opts = git2::DiffFindOptions::new();
find_opts.renames(true);
find_opts.copies(true);
find_opts.rename_threshold(RENAME_SIMILARITY_THRESHOLD);
diff.find_similar(Some(&mut find_opts))?;
Ok(Self::diff_to_patch(&diff))
}
pub fn scoped_full_diff(&self, base: &str, target: &str, paths: &[&str]) -> Result<String> {
let base_obj = self.inner.revparse_single(base)?;
let target_obj = self.inner.revparse_single(target)?;
let base_tree = base_obj.peel_to_tree()?;
let target_tree = target_obj.peel_to_tree()?;
let mut diff = self
.inner
.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), None)?;
let mut find_opts = git2::DiffFindOptions::new();
find_opts.renames(true);
find_opts.copies(true);
find_opts.rename_threshold(RENAME_SIMILARITY_THRESHOLD);
diff.find_similar(Some(&mut find_opts))?;
let scope: std::collections::HashSet<&str> = paths.iter().copied().collect();
let in_scope = |p: Option<&Path>| {
p.map(|p| scope.contains(p.to_string_lossy().as_ref()))
.unwrap_or(false)
};
let mut patch = Vec::new();
if let Err(e) = diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
if !in_scope(delta.new_file().path()) && !in_scope(delta.old_file().path()) {
return true; }
let origin = line.origin();
if origin == '+' || origin == '-' || origin == ' ' {
patch.push(origin as u8);
}
patch.extend_from_slice(line.content());
true
}) {
eprintln!("[prview] warning: scoped diff patch formatting failed: {e}");
}
Ok(String::from_utf8_lossy(&patch).to_string())
}
fn diff_to_patch(diff: &git2::Diff<'_>) -> String {
let mut patch = Vec::new();
if let Err(e) = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let origin = line.origin();
if origin == '+' || origin == '-' || origin == ' ' {
patch.push(origin as u8);
}
patch.extend_from_slice(line.content());
true
}) {
eprintln!("[prview] warning: diff patch formatting failed: {e}");
}
String::from_utf8_lossy(&patch).to_string()
}
pub fn list_branches(&self) -> Result<BranchList> {
let mut local = Vec::new();
let mut remote = Vec::new();
let current = self
.inner
.head()
.ok()
.and_then(|h| h.shorthand().map(String::from));
for branch_result in self.inner.branches(Some(git2::BranchType::Local))? {
let (branch, _) = branch_result?;
if let Some(name) = branch.name()? {
local.push(BranchInfo {
name: name.to_string(),
is_current: current.as_deref() == Some(name),
is_remote: false,
remote_name: None,
});
}
}
for branch_result in self.inner.branches(Some(git2::BranchType::Remote))? {
let (branch, _) = branch_result?;
if let Some(name) = branch.name()? {
if name.ends_with("/HEAD") {
continue;
}
let remote_name = name.split('/').next().map(String::from);
let short_name = name.split('/').skip(1).collect::<Vec<_>>().join("/");
remote.push(BranchInfo {
name: short_name,
is_current: false,
is_remote: true,
remote_name,
});
}
}
local.sort_by(|a, b| a.name.cmp(&b.name));
remote.sort_by(|a, b| a.name.cmp(&b.name));
Ok(BranchList {
local,
remote,
current,
})
}
pub fn current_branch(&self) -> Option<String> {
self.inner
.head()
.ok()
.and_then(|h| h.shorthand().map(String::from))
}
}
fn truncate_commit_list(commits: &mut Vec<CommitInfo>) {
if commits.len() <= MAX_COMMITS {
return;
}
let total_before = commits.len();
let last = commits.pop().expect("len > MAX_COMMITS implies non-empty");
commits.truncate(MAX_COMMITS - 2);
let omitted = total_before.saturating_sub(commits.len() + 1);
commits.push(CommitInfo {
id: String::from(TRUNCATED_COMMIT_SENTINEL_ID),
short_id: String::from(TRUNCATED_COMMIT_SENTINEL_SHORT_ID),
author: String::from("[prview]"),
email: String::new(),
date: String::new(),
message: format!(
"[commit list truncated: omitted {} commit{} beyond the {} entry limit; last commit follows]",
omitted,
if omitted == 1 { "" } else { "s" },
MAX_COMMITS
),
});
commits.push(last);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{test_config_builder, test_generic_profile};
use std::fs;
fn run_git(repo: &Path, args: &[&str]) {
let status = git_cmd()
.args(args)
.current_dir(repo)
.status()
.expect("git command");
assert!(status.success(), "git {:?} failed with {status}", args);
}
fn write_commit(repo: &Path, name: &str, body: &str) -> String {
fs::write(repo.join(name), body).expect("write fixture");
run_git(repo, &["add", name]);
run_git(
repo,
&[
"-c",
"user.name=prview test",
"-c",
"user.email=prview@example.test",
"commit",
"-m",
name,
],
);
let output = git_cmd()
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.expect("rev-parse");
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn init_repo_with_diverged_local_base() -> (tempfile::TempDir, String, String) {
let tmp = tempfile::tempdir().expect("tempdir");
run_git(tmp.path(), &["init", "-q", "-b", "main"]);
let _initial_oid = write_commit(tmp.path(), "file.txt", "initial\n");
run_git(tmp.path(), &["checkout", "-q", "-b", "base"]);
let local_base_oid = write_commit(tmp.path(), "base.txt", "local base\n");
run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
let head_oid = write_commit(tmp.path(), "feature.txt", "feature\n");
run_git(tmp.path(), &["checkout", "-q", "base"]);
let github_base_oid = write_commit(tmp.path(), "base.txt", "github base\n");
assert_ne!(local_base_oid, github_base_oid);
(tmp, head_oid, github_base_oid)
}
fn commit(id_suffix: usize) -> CommitInfo {
let full_id = format!("{id_suffix:040x}");
CommitInfo {
short_id: short_sha(&full_id).to_string(),
id: full_id,
author: format!("Author {id_suffix}"),
email: format!("author{id_suffix}@example.com"),
date: format!("2024-01-{day:02}T00:00:00", day = (id_suffix % 28) + 1),
message: format!("commit {id_suffix}"),
}
}
#[test]
fn test_resolved_ref_creation() {
let resolved = ResolvedRef {
name: "feature/test".to_string(),
commit_id: "abc123def456".to_string(),
is_remote: false,
};
assert_eq!(resolved.name, "feature/test");
assert_eq!(resolved.commit_id, "abc123def456");
assert!(!resolved.is_remote);
}
#[test]
fn test_resolved_ref_remote() {
let resolved = ResolvedRef {
name: "main".to_string(),
commit_id: "deadbeef".to_string(),
is_remote: true,
};
assert_eq!(resolved.name, "main");
assert!(resolved.is_remote);
}
#[test]
fn pr_mode_resolves_target_and_base_by_github_oids() {
let (tmp, head_oid, github_base_oid) = init_repo_with_diverged_local_base();
let repo = Repository::open(tmp.path()).expect("repo");
let mut config = test_config_builder()
.repo_root(tmp.path())
.target(Some("feature"))
.bases(&["base"])
.profile(test_generic_profile())
.build();
config.pr_number = Some(6);
config.pr_head_oid = Some(head_oid.clone());
config.pr_base_oid = Some(github_base_oid.clone());
let target = repo.resolve_target(&config).expect("target");
let bases = repo.resolve_bases(&config).expect("bases");
assert_eq!(target.name, "feature");
assert_eq!(target.commit_id, head_oid);
assert!(target.is_remote);
assert_eq!(bases.len(), 1);
assert_eq!(bases[0].name, "base");
assert_eq!(bases[0].commit_id, github_base_oid);
assert!(bases[0].is_remote);
}
#[test]
fn test_file_status_equality() {
assert_eq!(FileStatus::Added, FileStatus::Added);
assert_ne!(FileStatus::Added, FileStatus::Modified);
assert_ne!(FileStatus::Deleted, FileStatus::Renamed);
}
#[test]
fn test_file_status_variants() {
let statuses = [
FileStatus::Added,
FileStatus::Modified,
FileStatus::Deleted,
FileStatus::Renamed,
FileStatus::Copied,
];
assert_eq!(statuses.len(), 5);
}
#[test]
fn test_file_change_creation() {
let change = FileChange {
path: "src/main.rs".to_string(),
status: FileStatus::Modified,
additions: 10,
deletions: 5,
};
assert_eq!(change.path, "src/main.rs");
assert_eq!(change.status, FileStatus::Modified);
assert_eq!(change.additions, 10);
assert_eq!(change.deletions, 5);
}
#[test]
fn test_file_change_added() {
let change = FileChange {
path: "new_file.rs".to_string(),
status: FileStatus::Added,
additions: 100,
deletions: 0,
};
assert_eq!(change.status, FileStatus::Added);
assert_eq!(change.deletions, 0);
}
#[test]
fn test_file_change_deleted() {
let change = FileChange {
path: "old_file.rs".to_string(),
status: FileStatus::Deleted,
additions: 0,
deletions: 50,
};
assert_eq!(change.status, FileStatus::Deleted);
assert_eq!(change.additions, 0);
}
#[test]
fn test_diff_stats_default() {
let stats = DiffStats::default();
assert_eq!(stats.files_changed, 0);
assert_eq!(stats.additions, 0);
assert_eq!(stats.deletions, 0);
}
#[test]
fn test_diff_stats_creation() {
let stats = DiffStats {
files_changed: 5,
additions: 100,
deletions: 50,
copied: 0,
};
assert_eq!(stats.files_changed, 5);
assert_eq!(stats.additions, 100);
assert_eq!(stats.deletions, 50);
assert_eq!(stats.copied, 0);
}
#[test]
fn test_commit_info_creation() {
let commit = CommitInfo {
id: "abc123def456789".to_string(),
short_id: "abc123d".to_string(),
author: "Test Author".to_string(),
email: "test@example.com".to_string(),
date: "2024-01-15T10:30:00".to_string(),
message: "Test commit message".to_string(),
};
assert_eq!(commit.id, "abc123def456789");
assert_eq!(commit.short_id, "abc123d");
assert_eq!(commit.author, "Test Author");
assert_eq!(commit.email, "test@example.com");
assert_eq!(commit.message, "Test commit message");
}
#[test]
fn test_commit_info_empty_email() {
let commit = CommitInfo {
id: "abc123".to_string(),
short_id: "abc".to_string(),
author: "Unknown".to_string(),
email: String::new(),
date: "2024-01-01T00:00:00".to_string(),
message: "Initial commit".to_string(),
};
assert!(commit.email.is_empty());
}
#[test]
fn test_diff_creation() {
let diff = Diff {
base: "main".to_string(),
target: "feature/test".to_string(),
base_commit_id: "abc123".to_string(),
target_commit_id: "def456".to_string(),
files: vec![FileChange {
path: "src/lib.rs".to_string(),
status: FileStatus::Modified,
additions: 10,
deletions: 5,
}],
stats: DiffStats {
files_changed: 1,
additions: 10,
deletions: 5,
copied: 0,
},
commits: vec![],
};
assert_eq!(diff.base, "main");
assert_eq!(diff.target, "feature/test");
assert_eq!(diff.files.len(), 1);
assert_eq!(diff.stats.files_changed, 1);
}
#[test]
fn test_diff_with_multiple_files() {
let diff = Diff {
base: "develop".to_string(),
target: "feature/multi".to_string(),
base_commit_id: "aaa111".to_string(),
target_commit_id: "bbb222".to_string(),
files: vec![
FileChange {
path: "file1.rs".to_string(),
status: FileStatus::Added,
additions: 50,
deletions: 0,
},
FileChange {
path: "file2.rs".to_string(),
status: FileStatus::Modified,
additions: 30,
deletions: 10,
},
FileChange {
path: "file3.rs".to_string(),
status: FileStatus::Deleted,
additions: 0,
deletions: 100,
},
],
stats: DiffStats {
files_changed: 3,
additions: 80,
deletions: 110,
copied: 0,
},
commits: vec![],
};
assert_eq!(diff.files.len(), 3);
assert_eq!(diff.stats.files_changed, 3);
}
#[test]
fn test_diff_with_commits() {
let diff = Diff {
base: "main".to_string(),
target: "feature/commits".to_string(),
base_commit_id: "base123".to_string(),
target_commit_id: "target456".to_string(),
files: vec![],
stats: DiffStats::default(),
commits: vec![
CommitInfo {
id: "commit1".to_string(),
short_id: "c1".to_string(),
author: "Author1".to_string(),
email: "a1@test.com".to_string(),
date: "2024-01-01T00:00:00".to_string(),
message: "First commit".to_string(),
},
CommitInfo {
id: "commit2".to_string(),
short_id: "c2".to_string(),
author: "Author2".to_string(),
email: "a2@test.com".to_string(),
date: "2024-01-02T00:00:00".to_string(),
message: "Second commit".to_string(),
},
],
};
assert_eq!(diff.commits.len(), 2);
assert_eq!(diff.commits[0].message, "First commit");
assert_eq!(diff.commits[1].message, "Second commit");
}
#[test]
fn test_file_status_serialization() {
let status = FileStatus::Added;
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(serialized, "\"added\"");
}
#[test]
fn test_file_status_deserialization() {
let deserialized: FileStatus = serde_json::from_str("\"modified\"").unwrap();
assert_eq!(deserialized, FileStatus::Modified);
}
#[test]
fn test_diff_stats_serialization() {
let stats = DiffStats {
files_changed: 10,
additions: 200,
deletions: 50,
copied: 0,
};
let json = serde_json::to_string(&stats).unwrap();
assert!(json.contains("\"files_changed\":10"));
assert!(json.contains("\"additions\":200"));
assert!(json.contains("\"deletions\":50"));
}
#[test]
fn test_diff_clone() {
let original = Diff {
base: "main".to_string(),
target: "feature".to_string(),
base_commit_id: "clone1".to_string(),
target_commit_id: "clone2".to_string(),
files: vec![],
stats: DiffStats::default(),
commits: vec![],
};
let cloned = original.clone();
assert_eq!(original.base, cloned.base);
assert_eq!(original.target, cloned.target);
}
#[test]
fn test_truncate_commit_list_preserves_last_commit_and_reports_omitted_count() {
let mut commits: Vec<CommitInfo> = (1..=MAX_COMMITS + 3).map(commit).collect();
let expected_last = commits.last().cloned().expect("last commit");
truncate_commit_list(&mut commits);
assert_eq!(commits.len(), MAX_COMMITS);
assert_eq!(commits[MAX_COMMITS - 2].id, TRUNCATED_COMMIT_SENTINEL_ID);
assert_eq!(
commits[MAX_COMMITS - 2].short_id,
TRUNCATED_COMMIT_SENTINEL_SHORT_ID
);
assert!(
commits[MAX_COMMITS - 2]
.message
.contains("omitted 4 commits beyond the 500 entry limit"),
"unexpected sentinel message: {}",
commits[MAX_COMMITS - 2].message
);
assert_eq!(
commits.last().map(|commit| &commit.id),
Some(&expected_last.id)
);
assert_eq!(
commits.last().map(|commit| &commit.message),
Some(&expected_last.message)
);
}
#[test]
fn test_file_change_clone() {
let original = FileChange {
path: "test.rs".to_string(),
status: FileStatus::Added,
additions: 10,
deletions: 0,
};
let cloned = original.clone();
assert_eq!(original.path, cloned.path);
assert_eq!(original.status, cloned.status);
}
#[test]
fn test_commit_info_clone() {
let original = CommitInfo {
id: "abc123".to_string(),
short_id: "abc".to_string(),
author: "Author".to_string(),
email: "email@test.com".to_string(),
date: "2024-01-01".to_string(),
message: "Test".to_string(),
};
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.author, cloned.author);
}
}