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 = 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 = 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 = 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 = 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"); }