pub mod context;
pub mod diff;
pub mod repository;
use git2::Repository;
use std::path::{Path, PathBuf};
use crate::error::{Result, TrvError};
use crate::model::{DiffFile, DiffLine, FileStatus};
use super::traits::{CommitInfo, VcsBackend, VcsInfo, VcsType};
pub use context::{calculate_gap, fetch_context_lines};
pub use diff::{
get_commit_range_diff, get_staged_diff, get_unstaged_diff, get_working_tree_diff,
get_working_tree_with_commits_diff,
};
fn find_git_dir(start: &Path) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
let dot_git = dir.join(".git");
if dot_git.exists() {
return Some(dot_git);
}
if dir.join("HEAD").is_file() && dir.join("objects").is_dir() {
return Some(dir.to_path_buf());
}
current = dir.parent();
}
None
}
fn is_reftable_git_dir(git_dir: &Path) -> bool {
if git_dir.join("reftable").is_dir() {
return true;
}
let config_path = git_dir.join("config");
if let Ok(contents) = std::fs::read_to_string(&config_path) {
for raw_line in contents.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
let lower = line.to_ascii_lowercase();
if let Some((key, value)) = lower.split_once('=') {
let key = key.trim();
let value = value.trim();
if (key == "refformat" || key == "refstorage") && value == "reftable" {
return true;
}
}
}
}
false
}
fn classify_discover_failure(start: &Path) -> TrvError {
match find_git_dir(start) {
Some(git_dir) if is_reftable_git_dir(&git_dir) => TrvError::ReftableRepository,
_ => TrvError::NotARepository,
}
}
pub struct GitBackend {
repo: Repository,
info: VcsInfo,
}
impl GitBackend {
pub fn discover() -> Result<Self> {
let cwd = std::env::current_dir().map_err(|_| TrvError::NotARepository)?;
let repo = Repository::discover(&cwd).map_err(|_| classify_discover_failure(&cwd))?;
let root_path = repo
.workdir()
.ok_or(TrvError::NotARepository)?
.to_path_buf();
let head_commit = repo
.head()
.ok()
.and_then(|h| h.peel_to_commit().ok())
.map_or_else(|| "HEAD".to_string(), |c| c.id().to_string());
let branch_name = repo.head().ok().and_then(|h| {
if h.is_branch() {
h.shorthand().map(std::string::ToString::to_string)
} else {
None
}
});
let info = VcsInfo {
root_path,
head_commit,
branch_name,
vcs_type: VcsType::Git,
};
Ok(Self { repo, info })
}
}
impl VcsBackend for GitBackend {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
get_working_tree_diff(&self.repo)
}
fn get_staged_diff(&self) -> Result<Vec<DiffFile>> {
get_staged_diff(&self.repo)
}
fn get_unstaged_diff(&self) -> Result<Vec<DiffFile>> {
get_unstaged_diff(&self.repo)
}
fn fetch_context_lines(
&self,
file_path: &Path,
file_status: FileStatus,
start_line: u32,
end_line: u32,
) -> Result<Vec<DiffLine>> {
fetch_context_lines(&self.repo, file_path, file_status, start_line, end_line)
}
fn get_recent_commits(&self, offset: usize, limit: usize) -> Result<Vec<CommitInfo>> {
repository::get_recent_commits(&self.repo, offset, limit)
}
fn resolve_revisions(&self, revisions: &str) -> Result<Vec<String>> {
repository::resolve_revisions(&self.repo, revisions)
}
fn get_commit_range_diff(&self, commit_ids: &[String]) -> Result<Vec<DiffFile>> {
get_commit_range_diff(&self.repo, commit_ids)
}
fn get_commits_info(&self, ids: &[String]) -> Result<Vec<CommitInfo>> {
repository::get_commits_info(&self.repo, ids)
}
fn get_working_tree_with_commits_diff(&self, commit_ids: &[String]) -> Result<Vec<DiffFile>> {
get_working_tree_with_commits_diff(&self.repo, commit_ids)
}
fn is_working_tree_dirty(&self) -> Result<bool> {
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true).recurse_untracked_dirs(true);
let statuses = self.repo.statuses(Some(&mut opts))?;
Ok(!statuses.is_empty())
}
fn branch_exists(&self, name: &str) -> Result<bool> {
match self.repo.find_branch(name, git2::BranchType::Local) {
Ok(_) => Ok(true),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(false),
Err(e) => Err(e.into()),
}
}
fn create_branch(&self, name: &str) -> Result<()> {
let head = self.repo.head()?;
let commit = head.peel_to_commit()?;
self.repo.branch(name, &commit, false)?;
Ok(())
}
fn checkout_branch(&self, name: &str) -> Result<()> {
let refname = format!("refs/heads/{name}");
let obj = self.repo.revparse_single(&refname)?;
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.safe();
self.repo.checkout_tree(&obj, Some(&mut checkout))?;
self.repo.set_head(&refname)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn classify_discover_failure_detects_reftable_via_config() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let git_dir = temp_dir.path().join(".git");
fs::create_dir_all(&git_dir).expect("failed to create .git dir");
fs::create_dir_all(git_dir.join("reftable")).expect("failed to create reftable dir");
fs::write(
git_dir.join("config"),
"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefFormat = reftable\n",
)
.expect("failed to write config");
let err = classify_discover_failure(temp_dir.path());
assert!(
matches!(err, TrvError::ReftableRepository),
"expected ReftableRepository, got {err:?}"
);
}
#[test]
fn classify_discover_failure_detects_reftable_via_directory_only() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let git_dir = temp_dir.path().join(".git");
fs::create_dir_all(git_dir.join("reftable")).expect("failed to create reftable dir");
let err = classify_discover_failure(temp_dir.path());
assert!(matches!(err, TrvError::ReftableRepository));
}
#[test]
fn classify_discover_failure_returns_not_a_repository_when_no_git_dir() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let err = classify_discover_failure(temp_dir.path());
assert!(matches!(err, TrvError::NotARepository));
}
#[test]
fn is_reftable_git_dir_ignores_unrelated_config_keys() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let git_dir = temp_dir.path().join(".git");
fs::create_dir_all(&git_dir).expect("failed to create .git dir");
fs::write(
git_dir.join("config"),
"[core]\n\trepositoryformatversion = 0\n",
)
.expect("failed to write config");
assert!(!is_reftable_git_dir(&git_dir));
}
fn build_empty_backend() -> (tempfile::TempDir, GitBackend) {
use git2::{Repository, Signature};
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("failed to init repo");
let sig = Signature::now("test", "test@test").expect("sig");
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().expect("write tree")
};
let tree = repo.find_tree(tree_id).expect("find tree");
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.expect("initial commit");
drop(tree);
let info = VcsInfo {
root_path: temp_dir.path().to_path_buf(),
head_commit: "initial".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let backend = GitBackend { repo, info };
(temp_dir, backend)
}
#[test]
fn is_working_tree_dirty_false_on_clean_repo() {
let (_dir, backend) = build_empty_backend();
assert!(!backend.is_working_tree_dirty().unwrap());
}
#[test]
fn is_working_tree_dirty_true_with_untracked_file() {
let (dir, backend) = build_empty_backend();
fs::write(dir.path().join("new.txt"), "hi\n").expect("write");
assert!(backend.is_working_tree_dirty().unwrap());
}
#[test]
fn branch_exists_returns_false_for_missing_branch() {
let (_dir, backend) = build_empty_backend();
assert!(!backend.branch_exists("sparring-tests/ghost").unwrap());
}
#[test]
fn create_branch_then_exists_and_checkout_switches_head() {
let (_dir, backend) = build_empty_backend();
backend
.create_branch("sparring-tests/foo")
.expect("create branch");
assert!(backend.branch_exists("sparring-tests/foo").unwrap());
backend
.checkout_branch("sparring-tests/foo")
.expect("checkout");
let head = backend.repo.head().expect("head");
assert_eq!(head.shorthand(), Some("sparring-tests/foo"));
}
#[test]
fn create_branch_fails_when_branch_already_exists() {
let (_dir, backend) = build_empty_backend();
backend.create_branch("sparring-tests/foo").unwrap();
let err = backend
.create_branch("sparring-tests/foo")
.expect_err("second create should fail");
assert!(matches!(err, TrvError::Git(_)), "unexpected error: {err:?}");
}
}