use anyhow::{Context, Error};
use git2::Sort;
use crate::git::repository::core::{CommitInfo, GitRepo};
impl GitRepo {
pub fn list_commits(&self) -> Result<Vec<CommitInfo>, Error> {
let mut revwalk = self.repo().revwalk().context("Failed to create revwalk")?;
revwalk
.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)
.context("Failed to set sorting")?;
match self.repo().head() {
Ok(_) => {
revwalk.push_head().context("Failed to push HEAD")?;
}
Err(_) => {
return Ok(Vec::new());
}
}
let mut commits = Vec::new();
for oid in revwalk {
let oid = oid.context("Failed to get commit OID")?;
let commit = self
.repo()
.find_commit(oid)
.context("Failed to find commit")?;
commits.push(CommitInfo {
hash: oid.to_string(),
message: commit.message().unwrap_or("").to_string(),
});
}
Ok(commits)
}
pub fn add(&self, pathspecs: &[&str]) -> Result<&Self, Error> {
let mut index = self
.repo()
.index()
.context("Failed to get repository index")?;
index
.add_all(pathspecs, git2::IndexAddOption::DEFAULT, None)
.context("Failed to add files to index")?;
index.write().context("Failed to write index")?;
Ok(self)
}
pub fn commit(&self, message: &str) -> Result<String, Error> {
let signature = self
.create_signature()
.context("Failed to create signature")?;
let mut index = self
.repo()
.index()
.context("Failed to get repository index")?;
let tree_id = index
.write_tree()
.context("Failed to write tree from index")?;
let tree = self
.repo()
.find_tree(tree_id)
.context("Failed to find tree")?;
let parent_commit = match self.repo().head() {
Ok(head) => {
let target = head.target().context("Failed to get HEAD target")?;
Some(
self.repo()
.find_commit(target)
.context("Failed to find parent commit")?,
)
}
Err(_) => None, };
let parents: Vec<_> = parent_commit.iter().collect();
let commit_id = self
.repo()
.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parents,
)
.context("Failed to create commit")?;
Ok(commit_id.to_string())
}
pub fn get_branch_commit_info(&self, branch: &str) -> Result<String, Error> {
let branch_ref = format!("refs/heads/{branch}");
let reference = self
.repo()
.find_reference(&branch_ref)
.context(format!("Failed to find branch reference: {branch_ref}"))?;
let commit_oid = reference
.target()
.ok_or_else(|| anyhow::anyhow!("Branch reference has no target"))?;
let commit = self
.repo()
.find_commit(commit_oid)
.context("Failed to find commit")?;
let short_hash = commit.id().to_string()[..7].to_string();
let message = commit.message().unwrap_or("No commit message");
let first_line = message.lines().next().unwrap_or("No commit message");
Ok(format!("{short_hash} {first_line}"))
}
pub fn has_staged_changes(&self) -> Result<bool, Error> {
let mut index = self
.repo()
.index()
.context("Failed to get repository index")?;
if self.repo().head().is_err() {
return Ok(!index.is_empty());
}
let head = self.repo().head().context("Failed to get HEAD")?;
let head_commit = head
.peel_to_commit()
.context("Failed to peel HEAD to commit")?;
let head_tree = head_commit.tree().context("Failed to get HEAD tree")?;
let index_tree_id = index.write_tree().context("Failed to write index tree")?;
Ok(head_tree.id() != index_tree_id)
}
pub fn get_staged_diff(&self) -> Result<git2::Diff<'_>, Error> {
let index = self
.repo()
.index()
.context("Failed to get repository index")?;
let diff = if self.repo().head().is_err() {
let empty_tree = self
.repo()
.treebuilder(None)?
.write()
.context("Failed to create empty tree")?;
let empty_tree = self.repo().find_tree(empty_tree)?;
self.repo()
.diff_tree_to_index(Some(&empty_tree), Some(&index), None)
.context("Failed to create diff from empty tree to index")?
} else {
let head = self.repo().head().context("Failed to get HEAD")?;
let head_commit = head
.peel_to_commit()
.context("Failed to peel HEAD to commit")?;
let head_tree = head_commit.tree().context("Failed to get HEAD tree")?;
self.repo()
.diff_tree_to_index(Some(&head_tree), Some(&index), None)
.context("Failed to create diff from HEAD to index")?
};
Ok(diff)
}
pub fn diff_to_string(&self, diff: &git2::Diff) -> Result<String, Error> {
let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
match line.origin() {
'+' | '-' | ' ' | 'F' | 'H' => {
diff_text.push(line.origin());
if let Ok(content) = std::str::from_utf8(line.content()) {
diff_text.push_str(content);
}
}
_ => {
if let Ok(content) = std::str::from_utf8(line.content()) {
diff_text.push_str(content);
}
}
}
true
})?;
Ok(diff_text)
}
pub fn diff_staged(&self) -> Result<String, Error> {
let diff = self.get_staged_diff()?;
self.diff_to_string(&diff)
}
pub fn is_working_tree_clean(&self) -> Result<bool, Error> {
let statuses = self
.repo()
.statuses(None)
.context("Failed to read repository status")?;
Ok(statuses.is_empty())
}
pub fn list_commits_between(&self, base: &str, head: &str) -> Result<Vec<String>, Error> {
let mut revwalk = self.repo().revwalk().context("Failed to create revwalk")?;
revwalk
.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)
.context("Failed to set revwalk sorting")?;
revwalk
.push_range(&format!("{base}..{head}"))
.context("Failed to walk commit range")?;
let mut commits = Vec::new();
for oid in revwalk {
let oid = oid.context("Failed to read commit from range")?;
commits.push(oid.to_string());
}
Ok(commits)
}
pub fn get_commit_message(&self, commit_sha: &str) -> Result<String, Error> {
let oid = git2::Oid::from_str(commit_sha).context("Invalid commit SHA")?;
let commit = self
.repo()
.find_commit(oid)
.context("Failed to find commit")?;
Ok(commit.message().unwrap_or_default().to_string())
}
pub fn get_commit_subject(&self, commit_sha: &str) -> Result<String, Error> {
let oid = git2::Oid::from_str(commit_sha).context("Invalid commit SHA")?;
let commit = self
.repo()
.find_commit(oid)
.context("Failed to find commit")?;
Ok(commit.summary().unwrap_or_default().to_string())
}
pub fn get_commit_parent_count(&self, commit_sha: &str) -> Result<usize, Error> {
let oid = git2::Oid::from_str(commit_sha).context("Invalid commit SHA")?;
let commit = self
.repo()
.find_commit(oid)
.context("Failed to find commit")?;
Ok(commit.parent_count())
}
pub fn create_synthetic_child_commit(
&self,
parent_sha: &str,
tree_source_sha: &str,
message: &str,
) -> Result<String, Error> {
let parent_oid = git2::Oid::from_str(parent_sha).context("Invalid parent commit SHA")?;
let parent = self
.repo()
.find_commit(parent_oid)
.context("Failed to find parent commit")?;
let source_oid =
git2::Oid::from_str(tree_source_sha).context("Invalid tree-source commit SHA")?;
let source_commit = self
.repo()
.find_commit(source_oid)
.context("Failed to find tree-source commit")?;
let source_tree = source_commit
.tree()
.context("Failed to get source commit tree")?;
let signature = self
.create_signature()
.context("Failed to create commit signature")?;
let commit_id = self
.repo()
.commit(
None,
&signature,
&signature,
message,
&source_tree,
&[&parent],
)
.context("Failed to create synthetic child commit")?;
Ok(commit_id.to_string())
}
}
#[cfg(test)]
mod tests {
use crate::test_utils::{create_test_repo, RepoAssertions, RepoTestOperations};
#[test]
fn list_commits_works_in_repo_without_any_commit() {
let (_temp_dir, repo) = create_test_repo();
let commits = repo.list_commits().unwrap();
assert_eq!(commits.len(), 0);
}
#[test]
fn list_commits_works() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
let commits = repo.list_commits().unwrap();
assert_eq!(commits.len(), 0);
repo.add_file_and_commit("test_file_1.txt", "foo", "Test commit 1")?
.add_file_and_commit("test_file_2.txt", "foo", "Test commit 2")?
.add_file_and_commit("test_file_3.txt", "foo", "Test commit 3")?
.assert_commit_messages(&["Test commit 3", "Test commit 2", "Test commit 1"]);
let commits = repo.list_commits().unwrap();
assert_eq!(commits.len(), 3);
Ok(())
}
#[test]
fn add_works_for_single_file_path() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
let file_name = "test_file.txt";
repo.add_file(file_name, "foo")?.add(&[file_name])?;
let index = repo.repo().index().unwrap();
let entry = index.get_path(std::path::Path::new(file_name), 0);
assert!(entry.is_some(), "File should be in the index after adding");
Ok(())
}
#[test]
fn add_works_for_glob_patterns() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
repo.add_file("test_file_1.txt", "foo")?
.add_file("test_file_2.txt", "foo")?
.add_file("test_file_non_text.rs", "foo")?
.add(&["*.txt"])?;
let index = repo.repo().index().unwrap();
assert_eq!(index.len(), 2);
Ok(())
}
#[test]
fn add_works_for_all_files() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
repo.add_file("test_file_1.txt", "foo")?
.add_file("test_file_2.txt", "foo")?
.add_file("test_file_non_text.rs", "foo")?
.add(&["."])?;
let index = repo.repo().index().unwrap();
assert_eq!(index.len(), 3);
Ok(())
}
#[test]
fn has_staged_changes_works() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
assert!(!repo.has_staged_changes().unwrap());
repo.add_file("test.txt", "content")?;
assert!(!repo.has_staged_changes().unwrap());
repo.add(&["test.txt"])?;
assert!(repo.has_staged_changes().unwrap());
repo.commit("Initial commit")?;
assert!(!repo.has_staged_changes().unwrap());
repo.add_file("test2.txt", "content2")?
.add(&["test2.txt"])?;
assert!(repo.has_staged_changes().unwrap());
Ok(())
}
#[test]
fn diff_staged_works() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
let diff = repo.diff_staged().unwrap();
assert!(diff.is_empty());
repo.add_file("test.txt", "Hello World")?
.add(&["test.txt"])?;
let diff = repo.diff_staged().unwrap();
assert!(diff.contains("Hello World"));
assert!(diff.contains("+Hello World"));
repo.commit("Add test file")?;
let diff = repo.diff_staged().unwrap();
assert!(diff.is_empty());
repo.add_file("test.txt", "Hello World\nSecond line")?
.add(&["test.txt"])?;
let diff = repo.diff_staged().unwrap();
assert!(diff.contains("+Second line"));
Ok(())
}
#[test]
fn get_staged_diff_and_diff_to_string_work() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
repo.add_file("test.txt", "Hello World")?
.add(&["test.txt"])?;
let diff_obj = repo.get_staged_diff().unwrap();
assert_eq!(diff_obj.deltas().len(), 1);
let diff_string = repo.diff_to_string(&diff_obj).unwrap();
assert!(diff_string.contains("Hello World"));
let direct_diff = repo.diff_staged().unwrap();
assert_eq!(diff_string, direct_diff);
Ok(())
}
#[test]
fn get_branch_commit_info_works() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, repo) = create_test_repo();
repo.add_file_and_commit("README.md", "initial", "Initial commit")?;
let commit_info = repo.get_branch_commit_info("master").unwrap();
assert!(commit_info.contains("Initial commit"));
assert!(commit_info.len() > 7);
repo.create_and_checkout_branch("feature")?
.add_file_and_commit("feature.txt", "feature content", "Add feature")?;
let feature_commit_info = repo.get_branch_commit_info("feature").unwrap();
assert!(feature_commit_info.contains("Add feature"));
let result = repo.get_branch_commit_info("nonexistent");
assert!(result.is_err());
Ok(())
}
}