feat(core): implement Git repository operations with gRPC services

- Add advertise_refs functionality for Git protocol communication
- Implement archive service with TAR/ZIP format support and streaming
- Create blame service for Git file annotation with line tracking
- Add branch management including create, delete, rename and compare operations
- Implement merge checking with conflict detection and fast-forward handling
- Add cherry-pick functionality for applying commits between branches
- Integrate gix library for Git repository operations and object handling
- Add comprehensive test suite covering all Git operations
- Implement proper error handling and repository validation
- Add pagination support for large result sets
- Create protobuf definitions for all Git operations and data structures
- Add build system for gRPC code generation and dependency management
This commit is contained in:
zhenyi
2026-06-04 13:05:38 +08:00
commit dcb0fb74c5
98 changed files with 20569 additions and 0 deletions
+743
View File
@@ -0,0 +1,743 @@
use gitks::bare::GitBare;
use gitks::pb::{
CreateCommitAction, CreateCommitRequest, FindFilesRequest, FsckRequest, GetBlobRequest,
GetBranchRequest, GetCommitDiffRequest, GetCommitRequest, GetDiffRequest, GetDiffStatsRequest,
GetPatchRequest, GetTreeRequest, ListBranchesRequest, ListCommitsRequest, ListTagsRequest,
ListTreeRequest, ObjectName, ObjectSelector, Pagination, create_commit_action, object_selector,
};
fn run_git(work_dir: &std::path::Path, args: &[&str]) -> duct::Expression {
duct::cmd("git", {
let work_str = work_dir.to_string_lossy().into_owned();
let mut v: Vec<String> = vec!["-C".into(), work_str];
v.extend(args.iter().map(|s| s.to_string()));
v
})
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
}
fn run(work_dir: &std::path::Path, args: &[&str]) {
let result = run_git(work_dir, args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
.unwrap();
assert!(
result.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&result.stderr)
);
}
/// Create a temporary bare repo with real git history.
fn setup_bare_repo() -> (tempfile::TempDir, GitBare) {
let dir = tempfile::tempdir().expect("create temp dir");
let bare_dir = dir.path().join("test-repo");
duct::cmd(
"git",
["init", "--bare", bare_dir.to_string_lossy().as_ref()],
)
.run()
.expect("git init --bare");
let work_dir = dir.path().join("work");
duct::cmd(
"git",
[
"clone",
bare_dir.to_string_lossy().as_ref(),
work_dir.to_string_lossy().as_ref(),
],
)
.run()
.expect("clone");
run(&work_dir, &["checkout", "-b", "main"]);
// Initial commit: README.md
std::fs::write(work_dir.join("README.md"), "# Test\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "initial commit"]);
// Branch feature from initial
run(&work_dir, &["branch", "feature"]);
// Second commit: add src.txt
std::fs::write(work_dir.join("src.txt"), "source\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "second commit"]);
// Third commit: modify README.md
std::fs::write(work_dir.join("README.md"), "# Test\n\nUpdated.\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "third commit"]);
// Create a subdirectory with nested file
std::fs::create_dir_all(work_dir.join("src/lib")).unwrap();
std::fs::write(work_dir.join("src/lib/mod.rs"), "pub fn hello() {}\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "add nested file"]);
run(&work_dir, &["tag", "v0.1.0"]);
run(
&work_dir,
&["push", "-f", "origin", "main:main", "feature:feature"],
);
run(
&work_dir,
&["push", "-f", "origin", "refs/tags/v0.1.0:refs/tags/v0.1.0"],
);
(
dir,
GitBare {
bare_dir: bare_dir.clone(),
},
)
}
#[test]
fn test_list_branches() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.list_branches(ListBranchesRequest {
repository: None,
pattern: String::new(),
merged_into_head: false,
not_merged_into_head: false,
pagination: None,
sort_direction: 0,
})
.expect("list_branches");
let names: Vec<String> = result.branches.iter().map(|b| b.name.clone()).collect();
assert!(names.contains(&"feature".to_string()), "names: {names:?}");
assert!(
result.branches.len() >= 2,
"got {} branches",
result.branches.len()
);
}
#[test]
fn test_get_branch() {
let (_dir, gb) = setup_bare_repo();
let branch = gb
.get_branch(GetBranchRequest {
repository: None,
name: "feature".into(),
})
.expect("get_branch");
assert_eq!(branch.full_ref, "refs/heads/feature");
let oid = branch.target_oid.unwrap();
assert!(!oid.value.is_empty(), "oid.value must be binary bytes");
assert_eq!(oid.value.len(), 20, "SHA-1 binary is 20 bytes");
assert_eq!(oid.hex.len(), 40, "SHA-1 hex is 40 chars");
}
#[test]
fn test_branch_pagination() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.list_branches(ListBranchesRequest {
repository: None,
pattern: String::new(),
merged_into_head: false,
not_merged_into_head: false,
pagination: Some(Pagination {
page_size: 1,
page_token: String::new(),
}),
sort_direction: 0,
})
.expect("list_branches");
let page_info = result.page_info.unwrap();
assert_eq!(result.branches.len(), 1);
assert!(page_info.has_next_page);
assert!(!page_info.next_page_token.is_empty());
// Fetch second page
let result2 = gb
.list_branches(ListBranchesRequest {
repository: None,
pattern: String::new(),
merged_into_head: false,
not_merged_into_head: false,
pagination: Some(Pagination {
page_size: 1,
page_token: page_info.next_page_token,
}),
sort_direction: 0,
})
.expect("list_branches page 2");
assert!(!result2.branches.is_empty());
assert_ne!(result.branches[0].name, result2.branches[0].name);
}
#[test]
fn test_list_commits() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.list_commits(ListCommitsRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: String::new(),
since: None,
until: None,
first_parent: false,
all: false,
reverse: false,
max_parents: 0,
min_parents: 0,
pagination: Some(Pagination {
page_size: 100,
page_token: String::new(),
}),
})
.expect("list_commits");
assert!(
result.commits.len() >= 4,
"expected >=4 commits, got {}",
result.commits.len()
);
// Oid binary encoding check
let first = &result.commits[0];
let oid = first.oid.as_ref().unwrap();
assert_eq!(oid.value.len(), 20, "binary OID must be 20 bytes for SHA-1");
assert_eq!(oid.hex.len(), 40);
}
#[test]
fn test_list_commits_with_pagination() {
let (_dir, gb) = setup_bare_repo();
let page1 = gb
.list_commits(ListCommitsRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: String::new(),
since: None,
until: None,
first_parent: false,
all: false,
reverse: false,
max_parents: 0,
min_parents: 0,
pagination: Some(Pagination {
page_size: 2,
page_token: String::new(),
}),
})
.expect("list_commits page 1");
assert_eq!(page1.commits.len(), 2);
let pi = page1.page_info.unwrap();
assert!(pi.has_next_page);
let page2 = gb
.list_commits(ListCommitsRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: String::new(),
since: None,
until: None,
first_parent: false,
all: false,
reverse: false,
max_parents: 0,
min_parents: 0,
pagination: Some(Pagination {
page_size: 2,
page_token: pi.next_page_token,
}),
})
.expect("list_commits page 2");
assert!(!page2.commits.is_empty());
// Page 2 commits should differ from page 1
assert_ne!(page1.commits[0].oid, page2.commits[0].oid);
}
#[test]
fn test_get_commit() {
let (_dir, gb) = setup_bare_repo();
let commit = gb
.get_commit(GetCommitRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
include_stats: false,
include_raw: false,
})
.expect("get_commit");
assert!(commit.oid.is_some());
assert_eq!(commit.subject, "add nested file");
assert!(!commit.parent_oids.is_empty(), "should have parent");
}
#[test]
fn test_get_diff() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.get_diff(GetDiffRequest {
repository: None,
base: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main~3".into(),
})),
}),
head: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: None,
pagination: None,
})
.expect("get_diff");
assert!(!result.files.is_empty(), "diff should have changed files");
// Check that file paths are populated (not empty strings)
let paths: Vec<&str> = result.files.iter().map(|f| f.new_path.as_str()).collect();
let has_src = paths.iter().any(|p| p.contains("src.txt"));
assert!(has_src, "should include src.txt in diff, got: {paths:?}");
// Stats should not all be zero
let stats = result.stats.unwrap();
assert!(
stats.additions > 0 || stats.changed_files > 0,
"stats should be non-zero: {stats:?}"
);
}
#[test]
fn test_get_diff_with_patch() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.get_diff(GetDiffRequest {
repository: None,
base: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main~1".into(),
})),
}),
head: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: Some(gitks::pb::DiffOptions {
include_patch: true,
context_lines: 3,
..Default::default()
}),
pagination: None,
})
.expect("get_diff with patch");
assert!(!result.files.is_empty());
for file in &result.files {
if !file.binary {
assert!(
!file.patch.is_empty(),
"non-binary file should have patch data: {}",
file.new_path
);
}
}
}
#[test]
fn test_get_commit_diff_root() {
let (_dir, gb) = setup_bare_repo();
// Get the root commit (first commit on main)
let commits = gb
.list_commits(ListCommitsRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: String::new(),
since: None,
until: None,
first_parent: false,
all: false,
reverse: true,
max_parents: 0,
min_parents: 0,
pagination: Some(Pagination {
page_size: 1,
page_token: String::new(),
}),
})
.expect("list_commits for root");
let root_oid = commits.commits[0].oid.as_ref().unwrap().hex.clone();
// get_commit_diff on root commit should not fail
let result = gb
.get_commit_diff(GetCommitDiffRequest {
repository: None,
commit: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: root_oid,
})),
}),
options: None,
pagination: None,
})
.expect("get_commit_diff on root");
assert!(
!result.files.is_empty(),
"root commit diff should show added files"
);
}
#[test]
fn test_get_diff_stats() {
let (_dir, gb) = setup_bare_repo();
let stats = gb
.get_diff_stats(GetDiffStatsRequest {
repository: None,
base: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main~3".into(),
})),
}),
head: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: None,
})
.expect("get_diff_stats");
assert!(
stats.additions > 0 || stats.changed_files > 0,
"stats should be non-zero: {stats:?}"
);
}
#[test]
fn test_compare_commits() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.compare_commits(gitks::pb::CompareCommitsRequest {
repository: None,
base: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "feature".into(),
})),
}),
head: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
straight: false,
first_parent: false,
pagination: Some(Pagination {
page_size: 100,
page_token: String::new(),
}),
})
.expect("compare_commits");
// feature branched off after initial commit; main has 3 more commits
assert!(
!result.commits.is_empty(),
"should find commits between feature and main"
);
assert!(result.merge_base.is_some(), "should find merge base");
let stats = result.stats.unwrap();
assert!(stats.additions > 0, "should have additions: {stats:?}");
}
#[test]
fn test_create_commit_with_actions() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.create_commit(CreateCommitRequest {
repository: None,
branch: "main".into(),
message: "created via API".into(),
author: None,
committer: None,
actions: vec![CreateCommitAction {
action: create_commit_action::Action::CreateCommitActionCreate as i32,
file_path: "api_file.txt".into(),
previous_path: String::new(),
content: b"hello from api".to_vec(),
encoding: String::new(),
executable: false,
last_commit_oid: None,
}],
start_revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
force: false,
trailers: vec![],
})
.expect("create_commit");
let commit = result.commit.unwrap();
assert_eq!(commit.subject, "created via API");
assert!(!commit.parent_oids.is_empty(), "should have parent");
// Verify the file was created
let blob = gb
.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "api_file.txt".into(),
oid: None,
max_bytes: 0,
})
.expect("get_blob after create_commit");
assert_eq!(blob.data, b"hello from api");
}
#[test]
fn test_create_commit_delete_action() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.create_commit(CreateCommitRequest {
repository: None,
branch: "main".into(),
message: "delete src.txt".into(),
author: None,
committer: None,
actions: vec![CreateCommitAction {
action: create_commit_action::Action::CreateCommitActionDelete as i32,
file_path: "src.txt".into(),
previous_path: String::new(),
content: vec![],
encoding: String::new(),
executable: false,
last_commit_oid: None,
}],
start_revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
force: false,
trailers: vec![],
})
.expect("create_commit delete");
let commit = result.commit.unwrap();
assert_eq!(commit.subject, "delete src.txt");
// Verify file is gone
let blob_result = gb.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "src.txt".into(),
oid: None,
max_bytes: 0,
});
assert!(blob_result.is_err(), "src.txt should be deleted");
}
#[test]
fn test_list_tree_recursive() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.list_tree(ListTreeRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: String::new(),
recursive: true,
pagination: None,
})
.expect("list_tree recursive");
let paths: Vec<String> = result.entries.iter().map(|e| e.path.clone()).collect();
assert!(
paths.iter().any(|p| p.contains("src/lib/mod.rs")),
"recursive tree should include nested files, got: {paths:?}"
);
}
#[test]
fn test_get_tree_subpath() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.get_tree(GetTreeRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "src".into(),
})
.expect("get_tree subpath");
// OID should be the src subtree, not the root tree
assert!(result.oid.is_some());
let root_tree = gb
.get_tree(GetTreeRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: String::new(),
})
.expect("get_tree root");
assert_ne!(
result.oid.unwrap().hex,
root_tree.oid.unwrap().hex,
"subtree OID should differ from root tree OID"
);
}
#[test]
fn test_find_files() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.find_files(FindFilesRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
pattern: "mod.rs".into(),
pathspec: vec![],
pagination: None,
})
.expect("find_files");
assert!(!result.files.is_empty(), "should find mod.rs files");
assert!(
result.files.iter().all(|f| f.path.contains("mod.rs")),
"all results should match pattern"
);
}
#[test]
fn test_list_tags() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.list_tags(ListTagsRequest {
repository: None,
pattern: String::new(),
pagination: None,
sort_direction: 0,
})
.expect("list_tags");
let names: Vec<String> = result.tags.iter().map(|t| t.name.clone()).collect();
assert!(names.contains(&"v0.1.0".to_string()), "names: {names:?}");
}
#[test]
fn test_fsck_clean_repo() {
let (_dir, gb) = setup_bare_repo();
let result = gb
.fsck(FsckRequest {
repository: None,
strict: false,
connectivity_only: false,
})
.expect("fsck");
assert!(result.ok);
assert!(result.errors.is_empty());
}
#[test]
fn test_get_patch() {
let (_dir, gb) = setup_bare_repo();
let patches = gb
.get_patch(GetPatchRequest {
repository: None,
base: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main~1".into(),
})),
}),
head: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: None,
})
.expect("get_patch");
assert!(!patches.is_empty());
let combined: String = patches
.iter()
.map(|p| String::from_utf8_lossy(&p.data).to_string())
.collect();
assert!(
combined.contains("diff --git") || combined.contains("@@"),
"patch should contain diff output: {combined}"
);
}
#[test]
fn test_oid_binary_encoding() {
let (_dir, gb) = setup_bare_repo();
let commit = gb
.get_commit(GetCommitRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
include_stats: false,
include_raw: false,
})
.expect("get_commit");
let oid = commit.oid.unwrap();
// Binary value should be raw bytes, not hex string bytes
assert_eq!(
oid.value.len(),
20,
"SHA-1 binary is 20 bytes, got {}",
oid.value.len()
);
assert_eq!(oid.hex.len(), 40, "SHA-1 hex is 40 chars");
// Verify hex and binary match
let hex_from_bytes: String = oid.value.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex_from_bytes, oid.hex, "binary and hex must match");
}