From 998f393ed0167ee3343859e6f1acefd168cc691a Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Thu, 4 Jun 2026 14:10:21 +0800 Subject: [PATCH] feat(server): add comprehensive Git repository services with test coverage - Implement ArchiveService for repository archive operations - Add BlameService for Git blame functionality - Create BranchService with full branch management capabilities - Integrate CommitService for commit operations and history - Add DiffService for generating diffs and patches - Implement MergeService with conflict resolution features - Add PackService for Git packfile operations - Create TagService for Git tag management - Add TreeService for Git tree operations - Implement comprehensive repository management functions - Add repository statistics and health checking capabilities - Include garbage collection and repacking operations - Add repository configuration management - Implement error handling and status conversion utilities - Add test suite covering all repository operations - Create utility functions for Git command execution - Add streaming response support for large data operations - Implement request resolution and validation helpers --- server/archive.rs | 29 +++++ server/blame.rs | 29 +++++ server/branch.rs | 86 ++++++++++++++ server/commit.rs | 76 ++++++++++++ server/diff.rs | 49 ++++++++ server/merge.rs | 56 +++++++++ server/mod.rs | 115 ++++++++++++++++++ server/pack.rs | 121 +++++++++++++++++++ server/repository.rs | 234 ++++++++++++++++++++++++++++++++++++ server/repository_maint.rs | 157 +++++++++++++++++++++++++ server/tag.rs | 56 +++++++++ server/tree.rs | 69 +++++++++++ tests/repository_test.rs | 235 +++++++++++++++++++++++++++++++++++++ 13 files changed, 1312 insertions(+) create mode 100644 server/archive.rs create mode 100644 server/blame.rs create mode 100644 server/branch.rs create mode 100644 server/commit.rs create mode 100644 server/diff.rs create mode 100644 server/merge.rs create mode 100644 server/mod.rs create mode 100644 server/pack.rs create mode 100644 server/repository.rs create mode 100644 server/repository_maint.rs create mode 100644 server/tag.rs create mode 100644 server/tree.rs create mode 100644 tests/repository_test.rs diff --git a/server/archive.rs b/server/archive.rs new file mode 100644 index 0000000..73c0be1 --- /dev/null +++ b/server/archive.rs @@ -0,0 +1,29 @@ +use crate::pb::*; + +use super::{GitksService, into_status, into_stream, resolve}; + +#[tonic::async_trait] +impl archive_service_server::ArchiveService for GitksService { + type GetArchiveStream = + tokio_stream::wrappers::ReceiverStream>; + + async fn get_archive( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let chunks = gb.get_archive(inner).map_err(into_status)?; + Ok(tonic::Response::new(into_stream(chunks))) + } + + async fn list_archive_entries( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.list_archive_entries(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/server/blame.rs b/server/blame.rs new file mode 100644 index 0000000..10d6616 --- /dev/null +++ b/server/blame.rs @@ -0,0 +1,29 @@ +use crate::pb::*; + +use super::{GitksService, into_status, into_stream, resolve}; + +#[tonic::async_trait] +impl blame_service_server::BlameService for GitksService { + type StreamBlameStream = + tokio_stream::wrappers::ReceiverStream>; + + async fn blame( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.blame(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn stream_blame( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.blame(inner).map_err(into_status)?; + Ok(tonic::Response::new(into_stream(resp.hunks))) + } +} diff --git a/server/branch.rs b/server/branch.rs new file mode 100644 index 0000000..e0ea29c --- /dev/null +++ b/server/branch.rs @@ -0,0 +1,86 @@ +use crate::pb::*; + +use super::{GitksService, into_status, resolve}; + +#[tonic::async_trait] +impl branch_service_server::BranchService for GitksService { + async fn list_branches( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.list_branches(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_branch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_branch(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn create_branch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.create_branch(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn delete_branch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + gb.delete_branch(inner).map_err(into_status)?; + Ok(tonic::Response::new(())) + } + + async fn rename_branch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.rename_branch(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn update_branch_target( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.update_branch_target(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn set_branch_upstream( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.set_branch_upstream(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn compare_branch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.compare_branch(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/server/commit.rs b/server/commit.rs new file mode 100644 index 0000000..d073249 --- /dev/null +++ b/server/commit.rs @@ -0,0 +1,76 @@ +use crate::pb::*; + +use super::{GitksService, into_status, resolve}; + +#[tonic::async_trait] +impl commit_service_server::CommitService for GitksService { + async fn list_commits( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.list_commits(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_commit( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_commit(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_commit_ancestors( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_commit_ancestors(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn create_commit( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.create_commit(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn revert_commit( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.revert_commit(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn cherry_pick_commit( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.cherry_pick_commit(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn compare_commits( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.compare_commits(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/server/diff.rs b/server/diff.rs new file mode 100644 index 0000000..0290b12 --- /dev/null +++ b/server/diff.rs @@ -0,0 +1,49 @@ +use crate::pb::*; + +use super::{GitksService, into_status, into_stream, resolve}; + +#[tonic::async_trait] +impl diff_service_server::DiffService for GitksService { + type GetPatchStream = + tokio_stream::wrappers::ReceiverStream>; + + async fn get_diff( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_diff(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_commit_diff( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_commit_diff(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_patch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let items = gb.get_patch(inner).map_err(into_status)?; + Ok(tonic::Response::new(into_stream(items))) + } + + async fn get_diff_stats( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_diff_stats(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/server/merge.rs b/server/merge.rs new file mode 100644 index 0000000..87810ff --- /dev/null +++ b/server/merge.rs @@ -0,0 +1,56 @@ +use crate::pb::*; + +use super::{GitksService, into_status, resolve}; + +#[tonic::async_trait] +impl merge_service_server::MergeService for GitksService { + async fn check_merge( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.check_merge(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn merge( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.merge(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn list_merge_conflicts( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.list_merge_conflicts(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn resolve_merge_conflicts( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.resolve_merge_conflicts(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn rebase( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.rebase(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/server/mod.rs b/server/mod.rs new file mode 100644 index 0000000..7cad495 --- /dev/null +++ b/server/mod.rs @@ -0,0 +1,115 @@ +mod archive; +mod blame; +mod branch; +mod commit; +mod diff; +mod merge; +mod pack; +mod repository; +mod repository_maint; +mod tag; +mod tree; + +use std::path::PathBuf; +use tokio_stream::wrappers::ReceiverStream; + +use crate::bare::GitBare; +use crate::error::GitError; +use crate::pb::{ + archive_service_server, blame_service_server, branch_service_server, commit_service_server, + diff_service_server, merge_service_server, pack_service_server, repository_service_server, + tag_service_server, tree_service_server, +}; + +#[derive(Clone, Copy)] +pub struct GitksService; + +pub(crate) fn into_status(e: GitError) -> tonic::Status { + match &e { + GitError::NotFound(_) + | GitError::ObjectNotFound(_) + | GitError::RefNotFound(_) + | GitError::RepoNotFound => tonic::Status::not_found(e.to_string()), + GitError::InvalidArgument(_) => tonic::Status::invalid_argument(e.to_string()), + GitError::PermissionDenied(_) => tonic::Status::permission_denied(e.to_string()), + GitError::Locked(_) => tonic::Status::failed_precondition(e.to_string()), + GitError::AuthFailed(_) => tonic::Status::unauthenticated(e.to_string()), + GitError::NotBareRepository => tonic::Status::failed_precondition(e.to_string()), + _ => tonic::Status::internal(e.to_string()), + } +} + +impl From for tonic::Status { + fn from(e: GitError) -> Self { + into_status(e) + } +} + +pub(crate) fn resolve( + header: Option<&crate::pb::RepositoryHeader>, +) -> Result { + let header = header.ok_or_else(|| tonic::Status::invalid_argument("repository is required"))?; + GitBare::from_repository_header(header).map_err(into_status) +} + +pub(crate) fn resolve_for_init( + header: Option<&crate::pb::RepositoryHeader>, +) -> Result { + let header = header.ok_or_else(|| tonic::Status::invalid_argument("repository is required"))?; + let storage_path = header.storage_path.trim(); + let relative_path = header.relative_path.trim(); + if storage_path.is_empty() { + return Err(tonic::Status::invalid_argument("storage_path is required")); + } + let p = std::path::Path::new(storage_path); + if !p.is_absolute() { + return Err(tonic::Status::invalid_argument( + "storage_path must be an absolute path", + )); + } + let base = PathBuf::from(p); + Ok(if !relative_path.is_empty() { + base.join(relative_path) + } else { + base + }) +} + +pub(crate) fn into_stream( + items: Vec, +) -> ReceiverStream> { + let (tx, rx) = tokio::sync::mpsc::channel(items.len().max(1)); + for item in items { + let _ = tx.try_send(Ok(item)); + } + ReceiverStream::new(rx) +} + +pub(crate) fn git_cmd(gb: &GitBare, args: &[&str]) -> Result { + let mut full_args: Vec = vec![ + "--git-dir".into(), + gb.bare_dir.to_string_lossy().into_owned(), + ]; + full_args.extend(args.iter().map(|s| s.to_string())); + std::process::Command::new("git") + .args(&full_args) + .output() + .map_err(|e| tonic::Status::internal(e.to_string())) +} + +pub async fn serve(addr: std::net::SocketAddr) -> Result<(), tonic::transport::Error> { + let svc = GitksService; + tonic::transport::Server::builder() + .add_service(repository_service_server::RepositoryServiceServer::new(svc)) + .add_service(archive_service_server::ArchiveServiceServer::new(svc)) + .add_service(blame_service_server::BlameServiceServer::new(svc)) + .add_service(branch_service_server::BranchServiceServer::new(svc)) + .add_service(commit_service_server::CommitServiceServer::new(svc)) + .add_service(diff_service_server::DiffServiceServer::new(svc)) + .add_service(merge_service_server::MergeServiceServer::new(svc)) + .add_service(pack_service_server::PackServiceServer::new(svc)) + .add_service(tag_service_server::TagServiceServer::new(svc)) + .add_service(tree_service_server::TreeServiceServer::new(svc)) + .serve(addr) + .await +} diff --git a/server/pack.rs b/server/pack.rs new file mode 100644 index 0000000..34fd08c --- /dev/null +++ b/server/pack.rs @@ -0,0 +1,121 @@ +use tokio_stream::StreamExt; +use tokio_stream::wrappers::ReceiverStream; + +use crate::pb::*; + +use super::{GitksService, into_status, resolve}; + +#[tonic::async_trait] +impl pack_service_server::PackService for GitksService { + type UploadPackStream = ReceiverStream>; + type ReceivePackStream = ReceiverStream>; + type PackObjectsStream = ReceiverStream>; + + async fn advertise_refs( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.advertise_refs(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn upload_pack( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let mut stream = request.into_inner(); + let first = stream + .next() + .await + .ok_or_else(|| tonic::Status::invalid_argument("empty upload-pack stream"))??; + let gb = resolve(first.repository.as_ref())?; + + let (tx, rx) = tokio::sync::mpsc::channel(16); + tx.send(Ok(first)) + .await + .map_err(|_| tonic::Status::internal("channel closed"))?; + tokio::spawn(async move { + while let Some(msg) = stream.next().await { + if tx.send(msg).await.is_err() { + break; + } + } + }); + + let result = gb.upload_pack(ReceiverStream::new(rx)).await?; + Ok(tonic::Response::new(result)) + } + + async fn receive_pack( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let mut stream = request.into_inner(); + let first = stream + .next() + .await + .ok_or_else(|| tonic::Status::invalid_argument("empty receive-pack stream"))??; + let gb = resolve(first.repository.as_ref())?; + + let (tx, rx) = tokio::sync::mpsc::channel(16); + tx.send(Ok(first)) + .await + .map_err(|_| tonic::Status::internal("channel closed"))?; + tokio::spawn(async move { + while let Some(msg) = stream.next().await { + if tx.send(msg).await.is_err() { + break; + } + } + }); + + let result = gb.receive_pack(ReceiverStream::new(rx)).await?; + Ok(tonic::Response::new(result)) + } + + async fn pack_objects( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let stream = gb.pack_objects(inner).await?; + Ok(tonic::Response::new(stream)) + } + + async fn index_pack( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let mut stream = request.into_inner(); + let mut inputs = Vec::new(); + while let Some(msg) = stream.next().await { + inputs.push(msg?); + } + let gb = resolve(inputs.first().and_then(|r| r.repository.as_ref()))?; + let resp = gb.index_pack(inputs).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn list_packfiles( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.list_packfiles(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn fsck( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.fsck(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/server/repository.rs b/server/repository.rs new file mode 100644 index 0000000..b54d72c --- /dev/null +++ b/server/repository.rs @@ -0,0 +1,234 @@ +use crate::pb::*; + +use super::{GitksService, git_cmd, into_status, repository_maint, resolve, resolve_for_init}; + +fn default_branch_name(gb: &crate::bare::GitBare) -> String { + git_cmd(gb, &["symbolic-ref", "HEAD"]) + .ok() + .and_then(|o| { + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix("refs/heads/") + .map(|b| b.to_string()) + }) + .unwrap_or_default() +} + +#[tonic::async_trait] +impl repository_service_server::RepositoryService for GitksService { + async fn get_repository( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let bare = gb.bare_dir.join("HEAD").exists(); + let object_format = gb.object_format(); + Ok(tonic::Response::new(Repository { + header: inner.repository, + bare, + object_format: object_format as i32, + default_branch: default_branch_name(&gb), + ..Default::default() + })) + } + + async fn init_repository( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let bare_dir = resolve_for_init(inner.repository.as_ref())?; + let gb = crate::bare::GitBare { bare_dir }; + gb.init_repository(inner.bare).map_err(into_status)?; + Ok(tonic::Response::new(Repository { + header: inner.repository, + bare: inner.bare, + ..Default::default() + })) + } + + async fn delete_repository( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let bare_dir = resolve_for_init(inner.repository.as_ref())?; + std::fs::remove_dir_all(&bare_dir).map_err(|e| tonic::Status::internal(e.to_string()))?; + Ok(tonic::Response::new(())) + } + + async fn repository_exists( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let bare_dir = resolve_for_init(inner.repository.as_ref())?; + let exists = bare_dir.exists() && bare_dir.is_dir() && bare_dir.join("HEAD").exists(); + Ok(tonic::Response::new(RepositoryExistsResponse { exists })) + } + + async fn get_object_format( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + Ok(tonic::Response::new(RepositoryObjectFormatResponse { + object_format: gb.object_format() as i32, + })) + } + + async fn get_default_branch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + Ok(tonic::Response::new(GetDefaultBranchResponse { + name: default_branch_name(&gb), + })) + } + + async fn set_default_branch( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let refname = format!("refs/heads/{}", inner.name); + let out = git_cmd(&gb, &["symbolic-ref", "HEAD", &refname])?; + if !out.status.success() { + return Err(tonic::Status::internal( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + Ok(tonic::Response::new(())) + } + + async fn get_repository_config( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let mut entries = Vec::new(); + if inner.keys.is_empty() { + let out = git_cmd(&gb, &["config", "--list"])?; + if !out.status.success() { + return Err(tonic::Status::internal( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + for line in String::from_utf8_lossy(&out.stdout).lines() { + if let Some((k, v)) = line.split_once('=') { + entries.push(RepositoryConfigEntry { + key: k.trim().to_string(), + values: vec![v.trim().to_string()], + }); + } + } + } else { + for key in &inner.keys { + let out = git_cmd(&gb, &["config", "--get-all", key])?; + if out.status.success() { + let vals: Vec = String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + if !vals.is_empty() { + entries.push(RepositoryConfigEntry { + key: key.clone(), + values: vals, + }); + } + } + } + } + Ok(tonic::Response::new(GetRepositoryConfigResponse { + entries, + })) + } + + async fn set_repository_config( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + for entry in &inner.entries { + if entry.values.is_empty() { + git_cmd(&gb, &["config", "--unset-all", &entry.key])?; + } else { + let _ = git_cmd( + &gb, + &["config", "--replace-all", &entry.key, &entry.values[0]], + ); + for v in entry.values.iter().skip(1) { + let _ = git_cmd(&gb, &["config", "--add", &entry.key, v]); + } + } + } + Ok(tonic::Response::new(())) + } + + async fn get_repository_statistics( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + Ok(tonic::Response::new(repository_maint::get_statistics(&gb))) + } + + async fn check_repository_health( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + Ok(tonic::Response::new(repository_maint::check_health( + &gb, + inner.connectivity_only, + )?)) + } + + async fn garbage_collect( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + Ok(tonic::Response::new(repository_maint::run_gc( + &gb, + inner.prune, + inner.aggressive, + )?)) + } + + async fn repack( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + Ok(tonic::Response::new(repository_maint::run_repack( + &gb, + inner.full, + inner.write_bitmaps, + inner.write_multi_pack_index, + )?)) + } + + async fn write_commit_graph( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + Ok(tonic::Response::new( + repository_maint::run_commit_graph_write(&gb, inner.split, inner.replace)?, + )) + } +} diff --git a/server/repository_maint.rs b/server/repository_maint.rs new file mode 100644 index 0000000..444f58e --- /dev/null +++ b/server/repository_maint.rs @@ -0,0 +1,157 @@ +use crate::pb::*; + +use super::git_cmd; + +pub(crate) fn maintenance_response(out: std::process::Output) -> RepositoryMaintenanceResponse { + RepositoryMaintenanceResponse { + ok: out.status.success(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +fn dir_size(path: &std::path::Path) -> u64 { + let mut total = 0u64; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_file() { + total += entry.metadata().map(|m| m.len()).unwrap_or(0); + } else if p.is_dir() { + total += dir_size(&p); + } + } + } + total +} + +fn count_refs(gb: &crate::bare::GitBare) -> u64 { + let out = git_cmd(gb, &["for-each-ref", "--format=%(refname)"]).unwrap_or_else(|_| { + std::process::Output { + status: Default::default(), + stdout: Vec::new(), + stderr: Vec::new(), + } + }); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|l| !l.is_empty()) + .count() as u64 +} + +fn file_len(path: &std::path::Path) -> u64 { + std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) +} + +pub(crate) fn get_statistics(gb: &crate::bare::GitBare) -> RepositoryStatistics { + let size_bytes = dir_size(&gb.bare_dir); + + let mut loose_object_count: u64 = 0; + let mut packed_object_count: u64 = 0; + let mut packfile_count: u64 = 0; + if let Ok(out) = git_cmd(gb, &["count-objects", "-v"]) { + for line in String::from_utf8_lossy(&out.stdout).lines() { + let line = line.trim(); + if let Some(v) = line.strip_prefix("count: ") { + loose_object_count = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("in-pack: ") { + packed_object_count = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("packs: ") { + packfile_count = v.trim().parse().unwrap_or(0); + } + } + } + + let reference_count = count_refs(gb); + let commit_graph_size_bytes = file_len(&gb.bare_dir.join("objects/info/commit-graph")); + let multi_pack_index_size_bytes = file_len(&gb.bare_dir.join("objects/pack/multi-pack-index")); + + RepositoryStatistics { + size_bytes, + loose_object_count, + packed_object_count, + packfile_count, + reference_count, + commit_graph_size_bytes, + multi_pack_index_size_bytes, + } +} + +pub(crate) fn check_health( + gb: &crate::bare::GitBare, + connectivity_only: bool, +) -> Result { + let mut args: Vec<&str> = vec!["fsck"]; + if connectivity_only { + args.push("--connectivity-only"); + } + let out = git_cmd(gb, &args)?; + let text = String::from_utf8_lossy(&out.stdout); + let mut warnings = Vec::new(); + let mut errors = Vec::new(); + for line in text.lines() { + if line.starts_with("error:") { + errors.push(line.to_string()); + } else if line.starts_with("warning:") { + warnings.push(line.to_string()); + } + } + Ok(RepositoryHealthResponse { + ok: out.status.success(), + warnings, + errors, + statistics: None, + }) +} + +pub(crate) fn run_gc( + gb: &crate::bare::GitBare, + prune: bool, + aggressive: bool, +) -> Result { + let mut args: Vec<&str> = vec!["gc"]; + if prune { + args.push("--prune=now"); + } + if aggressive { + args.push("--aggressive"); + } + let out = git_cmd(gb, &args)?; + Ok(maintenance_response(out)) +} + +pub(crate) fn run_repack( + gb: &crate::bare::GitBare, + full: bool, + write_bitmaps: bool, + write_multi_pack_index: bool, +) -> Result { + let mut args: Vec<&str> = vec!["repack", "-d"]; + if full { + args.push("-a"); + } + if write_bitmaps { + args.push("--write-bitmap-index"); + } + if write_multi_pack_index { + args.push("--write-midx"); + } + let out = git_cmd(gb, &args)?; + Ok(maintenance_response(out)) +} + +pub(crate) fn run_commit_graph_write( + gb: &crate::bare::GitBare, + split: bool, + replace: bool, +) -> Result { + let mut args: Vec<&str> = vec!["commit-graph", "write"]; + if split { + args.push("--split"); + } + if !replace { + args.push("--append"); + } + let out = git_cmd(gb, &args)?; + Ok(maintenance_response(out)) +} diff --git a/server/tag.rs b/server/tag.rs new file mode 100644 index 0000000..84fa152 --- /dev/null +++ b/server/tag.rs @@ -0,0 +1,56 @@ +use crate::pb::*; + +use super::{GitksService, into_status, resolve}; + +#[tonic::async_trait] +impl tag_service_server::TagService for GitksService { + async fn list_tags( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.list_tags(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_tag( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_tag(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn create_tag( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.create_tag(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn delete_tag( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + gb.delete_tag(inner).map_err(into_status)?; + Ok(tonic::Response::new(())) + } + + async fn verify_tag( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.verify_tag(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/server/tree.rs b/server/tree.rs new file mode 100644 index 0000000..38361ef --- /dev/null +++ b/server/tree.rs @@ -0,0 +1,69 @@ +use crate::pb::*; + +use super::{GitksService, into_status, into_stream, resolve}; + +#[tonic::async_trait] +impl tree_service_server::TreeService for GitksService { + type GetRawBlobStream = + tokio_stream::wrappers::ReceiverStream>; + + async fn list_tree( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.list_tree(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_tree( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_tree(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_blob( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_blob(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn get_raw_blob( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let items = gb.get_raw_blob(inner).map_err(into_status)?; + Ok(tonic::Response::new(into_stream(items))) + } + + async fn get_file_metadata( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.get_file_metadata(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } + + async fn find_files( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let inner = request.into_inner(); + let gb = resolve(inner.repository.as_ref())?; + let resp = gb.find_files(inner).map_err(into_status)?; + Ok(tonic::Response::new(resp)) + } +} diff --git a/tests/repository_test.rs b/tests/repository_test.rs new file mode 100644 index 0000000..9da5698 --- /dev/null +++ b/tests/repository_test.rs @@ -0,0 +1,235 @@ +mod common; + +use gitks::bare::GitBare; +use gitks::pb::repository_service_server::RepositoryService; +use gitks::pb::*; +use gitks::server::GitksService; + +fn header(gb: &GitBare) -> RepositoryHeader { + let parent = gb.bare_dir.parent().unwrap().to_string_lossy().into_owned(); + let name = gb + .bare_dir + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + RepositoryHeader { + storage_path: parent, + relative_path: name, + ..Default::default() + } +} + +fn req(gb: &GitBare, f: impl FnOnce(RepositoryHeader) -> T) -> tonic::Request { + tonic::Request::new(f(header(gb))) +} + +#[tokio::test] +async fn test_get_repository() { + let (_dir, gb) = common::setup_bare_repo(); + let repo = GitksService + .get_repository(req(&gb, |r| GetRepositoryRequest { + repository: Some(r), + })) + .await + .unwrap() + .into_inner(); + assert!(repo.bare); + assert_eq!(repo.object_format, ObjectFormat::Sha1 as i32); + assert_eq!(repo.default_branch, "main"); +} + +#[tokio::test] +async fn test_init_and_delete_repository() { + let dir = tempfile::tempdir().unwrap(); + let storage = dir.path().to_string_lossy().into_owned(); + let hdr = RepositoryHeader { + storage_path: storage.clone(), + relative_path: "new-repo".into(), + ..Default::default() + }; + let svc = GitksService; + svc.init_repository(tonic::Request::new(InitRepositoryRequest { + repository: Some(hdr.clone()), + bare: true, + ..Default::default() + })) + .await + .unwrap(); + assert!( + svc.repository_exists(tonic::Request::new(RepositoryExistsRequest { + repository: Some(hdr.clone()) + })) + .await + .unwrap() + .into_inner() + .exists + ); + svc.delete_repository(tonic::Request::new(DeleteRepositoryRequest { + repository: Some(hdr.clone()), + })) + .await + .unwrap(); + assert!( + !svc.repository_exists(tonic::Request::new(RepositoryExistsRequest { + repository: Some(hdr) + })) + .await + .unwrap() + .into_inner() + .exists + ); +} + +#[tokio::test] +async fn test_get_object_format() { + let (_dir, gb) = common::setup_bare_repo(); + let resp = GitksService + .get_object_format(req(&gb, |r| RepositoryObjectFormatRequest { + repository: Some(r), + })) + .await + .unwrap() + .into_inner(); + assert_eq!(resp.object_format, ObjectFormat::Sha1 as i32); +} + +#[tokio::test] +async fn test_get_set_default_branch() { + let (_dir, gb) = common::setup_bare_repo(); + let h = header(&gb); + assert_eq!( + GitksService + .get_default_branch(tonic::Request::new(GetDefaultBranchRequest { + repository: Some(h.clone()) + })) + .await + .unwrap() + .into_inner() + .name, + "main" + ); + GitksService + .set_default_branch(tonic::Request::new(SetDefaultBranchRequest { + repository: Some(h.clone()), + name: "feature".into(), + })) + .await + .unwrap(); + assert_eq!( + GitksService + .get_default_branch(tonic::Request::new(GetDefaultBranchRequest { + repository: Some(h) + })) + .await + .unwrap() + .into_inner() + .name, + "feature" + ); +} + +#[tokio::test] +async fn test_get_set_repository_config() { + let (_dir, gb) = common::setup_bare_repo(); + GitksService + .set_repository_config(tonic::Request::new(SetRepositoryConfigRequest { + repository: Some(header(&gb)), + entries: vec![RepositoryConfigEntry { + key: "test.key".into(), + values: vec!["val1".into(), "val2".into()], + }], + })) + .await + .unwrap(); + let entry = GitksService + .get_repository_config(tonic::Request::new(GetRepositoryConfigRequest { + repository: Some(header(&gb)), + keys: vec!["test.key".into()], + })) + .await + .unwrap() + .into_inner() + .entries + .into_iter() + .find(|e| e.key == "test.key") + .unwrap(); + assert_eq!(entry.values, vec!["val1", "val2"]); +} + +#[tokio::test] +async fn test_get_repository_statistics() { + let (_dir, gb) = common::setup_bare_repo(); + let s = GitksService + .get_repository_statistics(req(&gb, |r| RepositoryStatisticsRequest { + repository: Some(r), + })) + .await + .unwrap() + .into_inner(); + assert!(s.size_bytes > 0); + assert!(s.loose_object_count > 0 || s.packed_object_count > 0); + assert!(s.reference_count >= 2); +} + +#[tokio::test] +async fn test_check_repository_health() { + let (_dir, gb) = common::setup_bare_repo(); + let h = GitksService + .check_repository_health(tonic::Request::new(RepositoryHealthRequest { + repository: Some(header(&gb)), + connectivity_only: true, + })) + .await + .unwrap() + .into_inner(); + assert!(h.ok && h.errors.is_empty()); +} + +#[tokio::test] +async fn test_garbage_collect() { + let (_dir, gb) = common::setup_bare_repo(); + assert!( + GitksService + .garbage_collect(tonic::Request::new(GarbageCollectRequest { + repository: Some(header(&gb)), + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .ok + ); +} + +#[tokio::test] +async fn test_repack() { + let (_dir, gb) = common::setup_bare_repo(); + assert!( + GitksService + .repack(tonic::Request::new(RepackRequest { + repository: Some(header(&gb)), + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .ok + ); +} + +#[tokio::test] +async fn test_write_commit_graph() { + let (_dir, gb) = common::setup_bare_repo(); + assert!( + GitksService + .write_commit_graph(tonic::Request::new(WriteCommitGraphRequest { + repository: Some(header(&gb)), + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .ok + ); +}