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
+169
View File
@@ -0,0 +1,169 @@
mod common;
use gitks::pb::*;
#[test]
fn test_get_archive_tar() {
let (_dir, gb) = common::setup_bare_repo();
let chunks = gb
.get_archive(ArchiveRequest {
repository: None,
treeish: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: Some(ArchiveOptions {
format: archive_options::Format::ArchiveFormatTar as i32,
..Default::default()
}),
})
.expect("get_archive tar");
assert!(!chunks.is_empty(), "should produce archive data");
let total_size: usize = chunks.iter().map(|c| c.data.len()).sum();
assert!(total_size > 0, "archive should not be empty");
}
#[test]
fn test_get_archive_zip() {
let (_dir, gb) = common::setup_bare_repo();
let chunks = gb
.get_archive(ArchiveRequest {
repository: None,
treeish: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: Some(ArchiveOptions {
format: archive_options::Format::ArchiveFormatZip as i32,
..Default::default()
}),
})
.expect("get_archive zip");
assert!(!chunks.is_empty());
let data = &chunks[0].data;
assert!(
data.starts_with(b"PK"),
"zip archive should start with PK magic bytes"
);
}
#[test]
fn test_list_archive_entries() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.list_archive_entries(ListArchiveEntriesRequest {
repository: None,
treeish: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
pathspec: vec![],
pagination: None,
})
.expect("list_archive_entries");
assert!(!result.entries.is_empty(), "should list entries");
let paths: Vec<&str> = result.entries.iter().map(|e| e.path.as_str()).collect();
assert!(
paths.iter().any(|p| p.contains("README.md")),
"should include README.md, got: {:?}",
paths
);
}
#[test]
fn test_get_archive_with_prefix() {
let (_dir, gb) = common::setup_bare_repo();
let chunks = gb
.get_archive(ArchiveRequest {
repository: None,
treeish: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: Some(ArchiveOptions {
format: archive_options::Format::ArchiveFormatTar as i32,
prefix: "project/".into(),
..Default::default()
}),
})
.expect("get_archive with prefix");
assert!(!chunks.is_empty());
}
#[test]
fn test_fsck_clean_repo() {
let (_dir, gb) = common::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_list_packfiles() {
let (_dir, gb) = common::setup_bare_repo();
duct::cmd(
"git",
[
"--git-dir",
gb.bare_dir.to_string_lossy().as_ref(),
"gc",
"--aggressive",
],
)
.run()
.expect("git gc");
let result = gb
.list_packfiles(ListPackfilesRequest {
repository: None,
pagination: None,
})
.expect("list_packfiles");
assert!(
!result.packfiles.is_empty(),
"bare repo should have packfiles after gc"
);
for pf in &result.packfiles {
assert!(pf.size_bytes > 0, "packfile should have size");
assert!(pf.name.ends_with(".pack"));
}
}
#[test]
fn test_advertise_refs() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.advertise_refs(AdvertiseRefsRequest {
repository: None,
protocol: None,
service: String::new(),
})
.expect("advertise_refs");
assert!(!result.references.is_empty(), "should have refs");
let ref_names: Vec<&str> = result.references.iter().map(|r| r.name.as_str()).collect();
assert!(
ref_names.iter().any(|n| n.contains("refs/heads/main")),
"should include main branch ref"
);
assert!(
!result.capabilities.is_empty(),
"should advertise capabilities"
);
}
+132
View File
@@ -0,0 +1,132 @@
mod common;
use gitks::pb::*;
#[test]
fn test_blame_basic() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.blame(BlameRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "README.md".into(),
range: None,
options: None,
pagination: None,
})
.expect("blame");
assert!(!result.hunks.is_empty(), "should have blame hunks");
for hunk in &result.hunks {
assert!(hunk.commit.is_some(), "each hunk should have a commit");
let commit = hunk.commit.as_ref().unwrap();
assert!(commit.oid.is_some(), "commit should have oid");
assert!(!commit.oid.as_ref().unwrap().hex.is_empty());
assert!(hunk.line_count > 0, "hunk should have lines");
assert!(!hunk.lines.is_empty(), "hunk should have parsed lines");
}
}
#[test]
fn test_blame_line_content() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.blame(BlameRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "README.md".into(),
range: None,
options: None,
pagination: None,
})
.expect("blame");
let all_lines: Vec<String> = result
.hunks
.iter()
.flat_map(|h| h.lines.iter())
.map(|l| String::from_utf8_lossy(&l.content).to_string())
.collect();
assert!(
all_lines.iter().any(|l| l.contains("# Test")),
"should contain file content, got: {:?}",
all_lines
);
}
#[test]
fn test_blame_with_range() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.blame(BlameRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "README.md".into(),
range: Some(LineRange { start: 1, end: 1 }),
options: None,
pagination: None,
})
.expect("blame with range");
assert!(!result.hunks.is_empty(), "should have hunks for range");
}
#[test]
fn test_blame_author_info() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.blame(BlameRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "README.md".into(),
range: None,
options: None,
pagination: None,
})
.expect("blame");
let hunk = &result.hunks[0];
let commit = hunk.commit.as_ref().unwrap();
if let Some(ref author) = commit.author {
if let Some(ref id) = author.identity {
assert_eq!(id.name, "Test", "author name should match");
assert_eq!(id.email, "test@example.com");
}
}
}
#[test]
fn test_blame_nonexistent_file() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb.blame(BlameRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "nonexistent.txt".into(),
range: None,
options: None,
pagination: None,
});
assert!(result.is_err(), "blame on nonexistent file should fail");
}
+200
View File
@@ -0,0 +1,200 @@
mod common;
use gitks::pb::*;
#[test]
fn test_list_branches() {
let (_dir, gb) = common::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()));
assert!(names.contains(&"main".to_string()));
assert!(result.branches.len() >= 2);
}
#[test]
fn test_list_branches_merged_filter() {
let (_dir, gb) = common::setup_bare_repo();
let merged = gb
.list_branches(ListBranchesRequest {
repository: None,
pattern: String::new(),
merged_into_head: true,
not_merged_into_head: false,
pagination: None,
sort_direction: 0,
})
.expect("list_branches merged");
let not_merged = gb
.list_branches(ListBranchesRequest {
repository: None,
pattern: String::new(),
merged_into_head: false,
not_merged_into_head: true,
pagination: None,
sort_direction: 0,
})
.expect("list_branches not merged");
let merged_names: Vec<&str> = merged.branches.iter().map(|b| b.name.as_str()).collect();
let not_merged_names: Vec<&str> = not_merged
.branches
.iter()
.map(|b| b.name.as_str())
.collect();
assert!(
merged_names.contains(&"main"),
"main should be merged into HEAD, got: {:?}",
merged_names
);
assert!(
not_merged_names.contains(&"feature"),
"feature should NOT be merged into HEAD, got: {:?}",
not_merged_names
);
}
#[test]
fn test_get_branch() {
let (_dir, gb) = common::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());
assert_eq!(oid.value.len(), 20);
assert_eq!(oid.hex.len(), 40);
}
#[test]
fn test_branch_pagination() {
let (_dir, gb) = common::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 page 1");
let page_info = result.page_info.unwrap();
assert_eq!(result.branches.len(), 1);
assert!(page_info.has_next_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_create_and_delete_branch() {
let (_dir, gb) = common::setup_bare_repo();
let branch = gb
.create_branch(CreateBranchRequest {
repository: None,
name: "new-branch".into(),
start_point: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
force: false,
})
.expect("create_branch");
assert_eq!(branch.name, "new-branch");
gb.delete_branch(DeleteBranchRequest {
repository: None,
name: "new-branch".into(),
force: true,
})
.expect("delete_branch");
let result = gb.get_branch(GetBranchRequest {
repository: None,
name: "new-branch".into(),
});
assert!(result.is_err(), "deleted branch should not exist");
}
#[test]
fn test_rename_branch() {
let (_dir, gb) = common::setup_bare_repo();
gb.create_branch(CreateBranchRequest {
repository: None,
name: "to-rename".into(),
start_point: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
force: false,
})
.expect("create branch for rename");
let renamed = gb
.rename_branch(RenameBranchRequest {
repository: None,
old_name: "to-rename".into(),
new_name: "renamed".into(),
})
.expect("rename_branch");
assert_eq!(renamed.name, "renamed");
let old = gb.get_branch(GetBranchRequest {
repository: None,
name: "to-rename".into(),
});
assert!(old.is_err(), "old branch name should not exist");
}
#[test]
fn test_compare_branch() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.compare_branch(CompareBranchRequest {
repository: None,
source_branch: "feature".into(),
target_branch: "main".into(),
})
.expect("compare_branch");
assert!(
result.ahead_by > 0 || result.behind_by > 0,
"branches should differ"
);
assert!(result.merge_base.is_some(), "should find merge base");
}
+469
View File
@@ -0,0 +1,469 @@
mod common;
use gitks::pb::*;
#[test]
fn test_get_commit_with_author() {
let (_dir, gb) = common::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.author.is_some(), "author must be populated");
let author = commit.author.as_ref().unwrap();
assert!(
author.identity.is_some(),
"author identity must be populated"
);
let id = author.identity.as_ref().unwrap();
assert_eq!(id.name, "Test", "author name should be 'Test'");
assert_eq!(id.email, "test@example.com");
assert!(commit.committer.is_some(), "committer must be populated");
assert!(
commit.authored_at.is_some(),
"authored_at must be populated"
);
assert!(
commit.committed_at.is_some(),
"committed_at must be populated"
);
assert!(
commit.authored_at.as_ref().unwrap().seconds > 0,
"timestamp must be non-zero"
);
}
#[test]
fn test_get_commit_subject_body() {
let (_dir, gb) = common::setup_bare_repo();
let commit = gb
.get_commit(GetCommitRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main~2".into(),
})),
}),
include_stats: false,
include_raw: false,
})
.expect("get_commit");
assert_eq!(commit.subject, "second commit");
assert!(!commit.message.is_empty());
assert!(commit.oid.is_some());
assert!(!commit.parent_oids.is_empty());
}
#[test]
fn test_get_commit_with_raw() {
let (_dir, gb) = common::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: true,
})
.expect("get_commit with raw");
assert!(
!commit.raw.is_empty(),
"raw data must be present when requested"
);
let raw_str = String::from_utf8_lossy(&commit.raw);
assert!(raw_str.contains("tree"), "raw should contain tree line");
assert!(raw_str.contains("author"), "raw should contain author line");
}
#[test]
fn test_list_commits_with_pagination() {
let (_dir, gb) = common::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());
assert_ne!(page1.commits[0].oid, page2.commits[0].oid);
}
#[test]
fn test_get_commit_ancestors_pagination() {
let (_dir, gb) = common::setup_bare_repo();
let page1 = gb
.get_commit_ancestors(GetCommitAncestorsRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
first_parent: false,
pagination: Some(Pagination {
page_size: 2,
page_token: String::new(),
}),
})
.expect("ancestors page 1");
assert_eq!(page1.commits.len(), 2);
let pi = page1.page_info.unwrap();
assert!(pi.has_next_page, "should have next page");
assert!(
!pi.next_page_token.is_empty(),
"next_page_token must be set"
);
let page2 = gb
.get_commit_ancestors(GetCommitAncestorsRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
first_parent: false,
pagination: Some(Pagination {
page_size: 2,
page_token: pi.next_page_token,
}),
})
.expect("ancestors page 2");
assert!(!page2.commits.is_empty(), "page 2 should have commits");
assert_ne!(
page1.commits[0].oid.as_ref().unwrap().hex,
page2.commits[0].oid.as_ref().unwrap().hex,
);
}
#[test]
fn test_compare_commits() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.compare_commits(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");
assert!(!result.commits.is_empty());
assert!(result.merge_base.is_some());
let stats = result.stats.unwrap();
assert!(stats.additions > 0);
}
#[test]
fn test_create_commit_and_cherry_pick() {
let (_dir, gb) = common::setup_bare_repo();
let created = gb
.create_commit(CreateCommitRequest {
repository: None,
branch: "feature".into(),
message: "cherry-pick source".into(),
author: Some(Signature {
identity: Some(Identity {
name: "Author".into(),
email: "author@test.com".into(),
}),
..Default::default()
}),
committer: Some(Signature {
identity: Some(Identity {
name: "Committer".into(),
email: "committer@test.com".into(),
}),
..Default::default()
}),
actions: vec![CreateCommitAction {
action: create_commit_action::Action::CreateCommitActionCreate as i32,
file_path: "cp_file.txt".into(),
previous_path: String::new(),
content: b"cherry pick me".to_vec(),
encoding: String::new(),
executable: false,
last_commit_oid: None,
}],
start_revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "feature".into(),
})),
}),
force: false,
trailers: vec![],
})
.expect("create_commit for cherry-pick source");
let source_oid = created
.commit
.as_ref()
.unwrap()
.oid
.as_ref()
.unwrap()
.hex
.clone();
let cp_result = gb
.cherry_pick_commit(CherryPickCommitRequest {
repository: None,
commit: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: source_oid.clone(),
})),
}),
branch: "main".into(),
committer: Some(Signature {
identity: Some(Identity {
name: "CP Committer".into(),
email: "cp@test.com".into(),
}),
..Default::default()
}),
message: String::new(),
mainline: 0,
})
.expect("cherry_pick_commit");
let cp_commit = cp_result.commit.unwrap();
assert_eq!(cp_commit.subject, "cherry-pick source");
let blob = gb
.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "cp_file.txt".into(),
oid: None,
max_bytes: 0,
})
.expect("get_blob after cherry-pick");
assert_eq!(blob.data, b"cherry pick me");
}
#[test]
fn test_cherry_pick_root_commit() {
let (dir, gb) = common::setup_bare_repo();
let work_dir = dir.path().join("work");
common::run(&work_dir, &["checkout", "--orphan", "root-source"]);
common::run(&work_dir, &["rm", "-rf", "."]);
std::fs::write(work_dir.join("root_only.txt"), "from root\n").unwrap();
common::run(&work_dir, &["add", "."]);
common::run(&work_dir, &["commit", "-m", "standalone root"]);
common::run(&work_dir, &["push", "-f", "origin", "root-source"]);
let root_oid = common::run_git(&work_dir, &["rev-parse", "root-source"])
.stdout_capture()
.stderr_capture()
.run()
.expect("find root commit")
.stdout;
let root_oid = String::from_utf8(root_oid).unwrap().trim().to_string();
gb.cherry_pick_commit(CherryPickCommitRequest {
repository: None,
commit: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: root_oid,
})),
}),
branch: "feature".into(),
committer: None,
message: String::new(),
mainline: 0,
})
.expect("cherry_pick_commit root");
let blob = gb
.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "feature".into(),
})),
}),
path: "root_only.txt".into(),
oid: None,
max_bytes: 0,
})
.expect("get root file after cherry-pick");
assert_eq!(blob.data, b"from root\n");
}
#[test]
fn test_revert_commit() {
let (_dir, gb) = common::setup_bare_repo();
let created = gb
.create_commit(CreateCommitRequest {
repository: None,
branch: "main".into(),
message: "to be reverted".into(),
author: None,
committer: None,
actions: vec![CreateCommitAction {
action: create_commit_action::Action::CreateCommitActionCreate as i32,
file_path: "revert_me.txt".into(),
previous_path: String::new(),
content: b"will be reverted".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 to_revert = created
.commit
.as_ref()
.unwrap()
.oid
.as_ref()
.unwrap()
.hex
.clone();
let revert_result = gb
.revert_commit(RevertCommitRequest {
repository: None,
commit: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: to_revert,
})),
}),
branch: "main".into(),
committer: None,
message: String::new(),
})
.expect("revert_commit");
let revert_commit = revert_result.commit.unwrap();
assert!(
revert_commit.subject.starts_with("Revert"),
"subject should start with 'Revert', got: {}",
revert_commit.subject
);
let blob_result = gb.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "revert_me.txt".into(),
oid: None,
max_bytes: 0,
});
assert!(
blob_result.is_err(),
"revert_me.txt should be deleted after revert"
);
}
#[test]
fn test_oid_binary_encoding() {
let (_dir, gb) = common::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();
assert_eq!(oid.value.len(), 20);
assert_eq!(oid.hex.len(), 40);
let hex_from_bytes: String = oid.value.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex_from_bytes, oid.hex);
}
+167
View File
@@ -0,0 +1,167 @@
use gitks::bare::GitBare;
pub 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")
}
pub 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)
);
}
pub 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"]);
std::fs::write(work_dir.join("README.md"), "# Test\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "initial commit"]);
run(&work_dir, &["branch", "feature"]);
std::fs::write(work_dir.join("src.txt"), "source\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "second commit"]);
std::fs::write(work_dir.join("README.md"), "# Test\n\nUpdated.\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "third commit"]);
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"],
);
duct::cmd(
"git",
[
"--git-dir",
bare_dir.to_string_lossy().as_ref(),
"symbolic-ref",
"HEAD",
"refs/heads/main",
],
)
.run()
.expect("set HEAD to main");
(dir, GitBare { bare_dir })
}
pub fn setup_bare_repo_with_conflict() -> (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"]);
std::fs::write(work_dir.join("file.txt"), "base content\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "base commit"]);
run(&work_dir, &["checkout", "-b", "branch-a"]);
std::fs::write(work_dir.join("file.txt"), "branch A content\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "branch A change"]);
run(&work_dir, &["checkout", "main"]);
run(&work_dir, &["checkout", "-b", "branch-b"]);
std::fs::write(work_dir.join("file.txt"), "branch B content\n").unwrap();
run(&work_dir, &["add", "."]);
run(&work_dir, &["commit", "-m", "branch B change"]);
run(
&work_dir,
&[
"push",
"-f",
"origin",
"main:main",
"branch-a:branch-a",
"branch-b:branch-b",
],
);
duct::cmd(
"git",
[
"--git-dir",
bare_dir.to_string_lossy().as_ref(),
"symbolic-ref",
"HEAD",
"refs/heads/main",
],
)
.run()
.expect("set HEAD to main");
(dir, GitBare { bare_dir })
}
+236
View File
@@ -0,0 +1,236 @@
mod common;
use gitks::pb::*;
#[test]
fn test_get_diff() {
let (_dir, gb) = common::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());
let paths: Vec<&str> = result.files.iter().map(|f| f.new_path.as_str()).collect();
assert!(
paths.iter().any(|p| p.contains("src.txt")),
"should include src.txt, got: {:?}",
paths
);
let stats = result.stats.unwrap();
assert!(stats.additions > 0 || stats.changed_files > 0);
}
#[test]
fn test_get_diff_with_patch() {
let (_dir, gb) = common::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(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: {}",
file.new_path
);
}
}
}
#[test]
fn test_get_diff_with_rename_detection() {
let (_dir, gb) = common::setup_bare_repo();
gb.create_commit(CreateCommitRequest {
repository: None,
branch: "main".into(),
message: "rename file".into(),
author: None,
committer: None,
actions: vec![
CreateCommitAction {
action: create_commit_action::Action::CreateCommitActionCreate as i32,
file_path: "renamed.txt".into(),
previous_path: String::new(),
content: b"source\n".to_vec(),
encoding: String::new(),
executable: false,
last_commit_oid: None,
},
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 rename commit");
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(DiffOptions {
rename_detection: true,
..Default::default()
}),
pagination: None,
})
.expect("get_diff with rename detection");
let has_rename = result
.files
.iter()
.any(|f| f.change_type == diff_file::ChangeType::DiffFileChangeTypeRenamed as i32);
assert!(
has_rename,
"should detect rename, files: {:?}",
result.files.len()
);
}
#[test]
fn test_get_commit_diff_root() {
let (_dir, gb) = common::setup_bare_repo();
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();
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 should have files");
}
#[test]
fn test_get_diff_stats() {
let (_dir, gb) = common::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);
}
#[test]
fn test_get_patch() {
let (_dir, gb) = common::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("@@"));
}
+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");
}
+300
View File
@@ -0,0 +1,300 @@
mod common;
use gitks::pb::*;
#[test]
fn test_check_merge_no_conflict() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.check_merge(CheckMergeRequest {
repository: None,
target: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "feature".into(),
})),
}),
options: None,
})
.expect("check_merge");
assert!(
result.status == merge_result::Status::MergeResultStatusMerged as i32
|| result.status == merge_result::Status::MergeResultStatusFastForward as i32
|| result.status == merge_result::Status::MergeResultStatusAlreadyUpToDate as i32,
"merge should be clean, got status: {}",
result.status
);
}
#[test]
fn test_check_merge_with_conflict() {
let (_dir, gb) = common::setup_bare_repo_with_conflict();
let result = gb
.check_merge(CheckMergeRequest {
repository: None,
target: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-a".into(),
})),
}),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-b".into(),
})),
}),
options: None,
})
.expect("check_merge with conflict");
assert_eq!(
result.status,
merge_result::Status::MergeResultStatusConflicts as i32,
"merge should have conflicts"
);
assert!(!result.conflicts.is_empty(), "should list conflicted files");
}
#[test]
fn test_check_merge_already_up_to_date() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.check_merge(CheckMergeRequest {
repository: None,
target: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
options: None,
})
.expect("check_merge same ref");
assert_eq!(
result.status,
merge_result::Status::MergeResultStatusAlreadyUpToDate as i32
);
}
#[test]
fn test_merge_fast_forward() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.merge(MergeRequest {
repository: None,
target_branch: "feature".into(),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
committer: None,
message: String::new(),
options: None,
})
.expect("merge fast-forward");
assert!(
result.status == merge_result::Status::MergeResultStatusFastForward as i32
|| result.status == merge_result::Status::MergeResultStatusAlreadyUpToDate as i32,
"feature should fast-forward to main, got: {}",
result.status
);
}
#[test]
fn test_merge_with_conflict() {
let (_dir, gb) = common::setup_bare_repo_with_conflict();
let result = gb
.merge(MergeRequest {
repository: None,
target_branch: "branch-a".into(),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-b".into(),
})),
}),
committer: None,
message: String::new(),
options: None,
})
.expect("merge with conflict");
assert_eq!(
result.status,
merge_result::Status::MergeResultStatusConflicts as i32,
"should detect conflicts"
);
}
#[test]
fn test_merge_fast_forward_only_aborts_non_fast_forward() {
let (_dir, gb) = common::setup_bare_repo_with_conflict();
let result = gb
.merge(MergeRequest {
repository: None,
target_branch: "branch-a".into(),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-b".into(),
})),
}),
committer: None,
message: String::new(),
options: Some(MergeOptions {
fast_forward: merge_options::FastForwardMode::MergeFastForwardModeOnly as i32,
..Default::default()
}),
})
.expect("merge fast-forward only");
assert_eq!(
result.status,
merge_result::Status::MergeResultStatusAborted as i32
);
assert!(result.commit.is_none());
}
#[test]
fn test_list_merge_conflicts() {
let (_dir, gb) = common::setup_bare_repo_with_conflict();
let result = gb
.list_merge_conflicts(ListMergeConflictsRequest {
repository: None,
target: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-a".into(),
})),
}),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-b".into(),
})),
}),
pagination: None,
})
.expect("list_merge_conflicts");
assert!(!result.conflicts.is_empty(), "should list conflicted files");
assert!(
result.conflicts.iter().any(|c| c.path == "file.txt"),
"file.txt should be conflicted"
);
}
#[test]
fn test_resolve_merge_conflicts() {
let (_dir, gb) = common::setup_bare_repo_with_conflict();
let result = gb
.resolve_merge_conflicts(ResolveMergeConflictsRequest {
repository: None,
target_branch: "branch-a".into(),
source: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-b".into(),
})),
}),
resolutions: vec![ResolveMergeConflict {
path: "file.txt".into(),
content: b"resolved content\n".to_vec(),
}],
committer: None,
message: "resolved conflicts".into(),
})
.expect("resolve_merge_conflicts");
assert_eq!(
result.status,
merge_result::Status::MergeResultStatusMerged as i32
);
assert!(result.commit.is_some());
let blob = gb
.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "branch-a".into(),
})),
}),
path: "file.txt".into(),
oid: None,
max_bytes: 0,
})
.expect("get resolved blob");
assert_eq!(String::from_utf8_lossy(&blob.data), "resolved content\n");
}
#[test]
fn test_rebase() {
let (_dir, gb) = common::setup_bare_repo();
gb.create_commit(CreateCommitRequest {
repository: None,
branch: "feature".into(),
message: "feature work".into(),
author: None,
committer: None,
actions: vec![CreateCommitAction {
action: create_commit_action::Action::CreateCommitActionCreate as i32,
file_path: "feature.txt".into(),
previous_path: String::new(),
content: b"feature content".to_vec(),
encoding: String::new(),
executable: false,
last_commit_oid: None,
}],
start_revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "feature".into(),
})),
}),
force: false,
trailers: vec![],
})
.expect("create feature commit");
let result = gb
.rebase(RebaseRequest {
repository: None,
branch: "feature".into(),
upstream: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
committer: None,
})
.expect("rebase");
assert_eq!(
result.status,
rebase_result::Status::RebaseResultStatusRebased as i32
);
assert!(result.head.is_some());
let blob = gb
.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "feature".into(),
})),
}),
path: "feature.txt".into(),
oid: None,
max_bytes: 0,
})
.expect("get rebased feature file");
assert_eq!(String::from_utf8_lossy(&blob.data), "feature content");
}
+146
View File
@@ -0,0 +1,146 @@
mod common;
use gitks::pb::*;
#[test]
fn test_list_tags() {
let (_dir, gb) = common::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()));
}
#[test]
fn test_get_tag() {
let (_dir, gb) = common::setup_bare_repo();
let tag = gb
.get_tag(GetTagRequest {
repository: None,
name: "v0.1.0".into(),
include_raw: false,
})
.expect("get_tag");
assert_eq!(tag.name, "v0.1.0");
assert!(tag.target_oid.is_some());
assert_eq!(tag.full_ref, "refs/tags/v0.1.0");
}
#[test]
fn test_create_and_delete_lightweight_tag() {
let (_dir, gb) = common::setup_bare_repo();
let tag = gb
.create_tag(CreateTagRequest {
repository: None,
name: "v0.2.0".into(),
target: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
message: String::new(),
tagger: None,
force: false,
annotated: false,
})
.expect("create_tag");
assert_eq!(tag.name, "v0.2.0");
assert!(!tag.annotated);
gb.delete_tag(DeleteTagRequest {
repository: None,
name: "v0.2.0".into(),
})
.expect("delete_tag");
let result = gb.get_tag(GetTagRequest {
repository: None,
name: "v0.2.0".into(),
include_raw: false,
});
assert!(result.is_err(), "deleted tag should not exist");
}
#[test]
fn test_create_annotated_tag() {
let (_dir, gb) = common::setup_bare_repo();
let tag = gb
.create_tag(CreateTagRequest {
repository: None,
name: "v1.0.0".into(),
target: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
message: "Release v1.0.0".into(),
tagger: None,
force: false,
annotated: true,
})
.expect("create annotated tag");
assert_eq!(tag.name, "v1.0.0");
assert!(tag.annotated, "should be annotated");
assert!(tag.tag_oid.is_some(), "annotated tag should have tag_oid");
assert!(
tag.message.contains("Release v1.0.0"),
"message should be set"
);
}
#[test]
fn test_list_tags_with_pattern() {
let (_dir, gb) = common::setup_bare_repo();
gb.create_tag(CreateTagRequest {
repository: None,
name: "release-1.0".into(),
target: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
message: String::new(),
tagger: None,
force: false,
annotated: false,
})
.expect("create release tag");
let result = gb
.list_tags(ListTagsRequest {
repository: None,
pattern: "release".into(),
pagination: None,
sort_direction: 0,
})
.expect("list_tags with pattern");
assert!(
result.tags.iter().all(|t| t.name.contains("release")),
"all tags should match pattern"
);
assert!(!result.tags.is_empty());
}
#[test]
fn test_verify_tag() {
let (_dir, gb) = common::setup_bare_repo();
let result = gb
.verify_tag(VerifyTagRequest {
repository: None,
name: "v0.1.0".into(),
})
.expect("verify_tag");
assert!(!result.verified, "unsigned tag should not be verified");
}
+176
View File
@@ -0,0 +1,176 @@
mod common;
use gitks::pb::*;
#[test]
fn test_list_tree_recursive() {
let (_dir, gb) = common::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")),
"should include nested files, got: {:?}",
paths
);
}
#[test]
fn test_get_tree_subpath() {
let (_dir, gb) = common::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");
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"
);
}
#[test]
fn test_find_files() {
let (_dir, gb) = common::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());
assert!(result.files.iter().all(|f| f.path.contains("mod.rs")));
}
#[test]
fn test_get_blob() {
let (_dir, gb) = common::setup_bare_repo();
let blob = gb
.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "README.md".into(),
oid: None,
max_bytes: 0,
})
.expect("get_blob");
let content = String::from_utf8_lossy(&blob.data);
assert!(content.contains("# Test"));
assert!(blob.size > 0);
assert!(!blob.binary);
}
#[test]
fn test_get_blob_with_truncation() {
let (_dir, gb) = common::setup_bare_repo();
let blob = gb
.get_blob(GetBlobRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "README.md".into(),
oid: None,
max_bytes: 5,
})
.expect("get_blob truncated");
assert_eq!(blob.data.len(), 5);
assert!(blob.truncated);
assert!(
blob.size > 5,
"size should be original size, not truncated size"
);
}
#[test]
fn test_get_file_metadata() {
let (_dir, gb) = common::setup_bare_repo();
let meta = gb
.get_file_metadata(GetFileMetadataRequest {
repository: None,
revision: Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: "main".into(),
})),
}),
path: "README.md".into(),
})
.expect("get_file_metadata");
assert_eq!(meta.path, "README.md");
assert!(meta.oid.is_some());
assert_eq!(meta.r#type, ObjectType::Blob as i32);
}
#[test]
fn test_list_tree_with_pagination() {
let (_dir, gb) = common::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: false,
pagination: Some(Pagination {
page_size: 1,
page_token: String::new(),
}),
})
.expect("list_tree paginated");
assert_eq!(result.entries.len(), 1);
let pi = result.page_info.unwrap();
assert!(pi.has_next_page);
}