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
+1
View File
@@ -0,0 +1 @@
/target
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/lib/config/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/lib/git/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/gitks.iml" filepath="$PROJECT_DIR$/.idea/gitks.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
Generated
+2961
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "gitks"
version = "1.0.0"
edition = "2024"
authors = ["gitks contributors"]
description = "A gRPC-accessible Git repository operations library for bare repositories"
repository = ""
readme = ""
homepage = ""
license = "PolyForm-Noncommercial-1.0.0"
keywords = ["git", "grpc", "bare-repository", "gix"]
categories = ["development-tools"]
documentation = ""
[lib]
path = "lib.rs"
name = "gitks"
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
gix = { version = "0.84.0", default-features = false, features = ["serde", "blame", "sha256", "sha1", "tracing", "merge", "max-performance-safe", "revision"] }
gix-archive = { version = "0.33.0", features = ["sha256","sha1","document-features"] }
duct = { version = "1.1.1", features = [] }
tracing = { version = "0.1.32", features = ["log"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
thiserror = { version = "2.0.18", features = [] }
prost = "0.13"
prost-types = "0.13"
tonic = { version = "0.12", features = ["transport"] }
tempfile = "3"
[build-dependencies]
tonic-build = "0.12"
+58
View File
@@ -0,0 +1,58 @@
PolyForm Noncommercial License 1.0.0
Copyright (c) 2024 gitks contributors
License: "Noncommercial" as defined below.
"Noncommercial" means primarily intended for or directed towards the
advantage or monetary gain of a business, commercial entity, or for-profit
organization. A use is "Noncommercial" if it is not primarily intended for
or directed towards commercial advantage or monetary compensation.
1. Grant of Copyright License. Subject to the terms of this license,
Licensor grants you a worldwide, royalty-free, non-exclusive, limited
license to exercise the Licensed Rights in the Licensed Material for
Noncommercial purposes only.
2. Grant of Patent License. Subject to the terms of this license, Licensor
grants you a worldwide, royalty-free, non-exclusive, limited license
under patent claims owned or controlled by Licensor that are embodied
in the Licensed Material as furnished by Licensor, to make, use, sell,
offer for sale, have made, and import the Licensed Material for
Noncommercial purposes only.
3. Limitations. The license granted in Section 1 and Section 2 above is
expressly limited to Noncommercial purposes. You may not exercise the
Licensed Rights for the purpose of providing services to third parties,
including but not limited to:
(a) offering the Licensed Material as a hosted or managed service
where third parties access or use the Licensed Material;
(b) offering the Licensed Material as part of a product or service
that is sold, licensed, or otherwise provided for monetary gain;
(c) using the Licensed Material to provide consulting, support, or
other services for monetary gain.
4. Acceptance. Any use of the Licensed Material in violation of this
license will automatically terminate your rights under this license
for the current and all future versions of the Licensed Material.
5. Patents. If you institute patent litigation against any entity
(including a cross-claim or counterclaim in a lawsuit) alleging that
the Licensed Material constitutes direct or contributory patent
infringement, then any patent licenses granted to you under this
license for the Licensed Material shall terminate as of the date
such litigation is filed.
6. Disclaimer of Warranty. THE LICENSED MATERIAL IS PROVIDED "AS IS" AND
WITHOUT ANY WARRANTY OF ANY KIND. LICENSOR DISCLAIMS ALL WARRANTIES,
EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES
OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A
PARTICULAR PURPOSE.
7. Limitation of Liability. IN NO EVENT WILL LICENSOR BE LIABLE TO YOU
FOR ANY DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THESE TERMS OR IN CONNECTION
WITH THE USE OR INABILITY TO USE THE LICENSED MATERIAL, EVEN IF
LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
For the full license text, see: https://polyformproject.org/licenses/noncommercial/1.0.0
+7
View File
@@ -0,0 +1,7 @@
# gitks
A Git bare repository operation library based on gRPC.
## License
[PolyForm Noncommercial 1.0.0](LICENSE) — Free for noncommercial use. For commercial licenses, please contact us.
+45
View File
@@ -0,0 +1,45 @@
use std::process::Command;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{ArchiveChunk, ArchiveRequest, archive_options, object_selector};
impl GitBare {
pub fn get_archive(&self, request: ArchiveRequest) -> GitResult<Vec<ArchiveChunk>> {
let revision = match request.treeish.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let options = request.options.unwrap_or_default();
let format = archive_options::Format::try_from(options.format)
.unwrap_or(archive_options::Format::ArchiveFormatTar);
let mut args = vec!["archive".to_string()];
args.push(match format {
archive_options::Format::ArchiveFormatZip => "--format=zip".into(),
_ => "--format=tar".into(),
});
if !options.prefix.is_empty() {
args.push(format!("--prefix={}", options.prefix));
}
args.push(revision);
if !options.pathspec.is_empty() {
args.push("--".into());
args.extend(options.pathspec);
}
let output = Command::new("git")
.arg("--git-dir")
.arg(&self.bare_dir)
.args(&args)
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
status_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(vec![ArchiveChunk {
data: output.stdout,
}])
}
}
+68
View File
@@ -0,0 +1,68 @@
use std::process::Command;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{
ArchiveEntry, ListArchiveEntriesRequest, ListArchiveEntriesResponse, ObjectType, PageInfo,
object_selector,
};
impl GitBare {
pub fn list_archive_entries(
&self,
request: ListArchiveEntriesRequest,
) -> GitResult<ListArchiveEntriesResponse> {
let revision = match request.treeish.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let mut args = vec!["ls-tree".to_string(), "-r".into(), "-l".into(), revision];
if !request.pathspec.is_empty() {
args.push("--".into());
args.extend(request.pathspec);
}
let output = Command::new("git")
.arg("--git-dir")
.arg(&self.bare_dir)
.args(&args)
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
status_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
let entries = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let (meta, path) = line.split_once('\t')?;
let parts: Vec<&str> = meta.split_whitespace().collect();
let hex = parts.get(2)?.to_string();
Some(ArchiveEntry {
path: path.to_string(),
oid: Some(self.oid_to_pb(hex)),
mode: u32::from_str_radix(parts.first().copied().unwrap_or("0"), 8)
.unwrap_or(0),
size: parts.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
r#type: match parts.get(1).copied().unwrap_or_default() {
"tree" => ObjectType::Tree as i32,
"blob" => ObjectType::Blob as i32,
"commit" => ObjectType::Commit as i32,
"tag" => ObjectType::Tag as i32,
_ => ObjectType::Unspecified as i32,
},
})
})
.collect::<Vec<_>>();
let total_count = entries.len() as u64;
Ok(ListArchiveEntriesResponse {
entries,
page_info: Some(PageInfo {
next_page_token: String::new(),
has_next_page: false,
total_count,
}),
})
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod get_archive;
pub mod list_archive_entries;
+112
View File
@@ -0,0 +1,112 @@
use std::path::{Path, PathBuf};
use crate::error::{GitError, GitResult};
use crate::pb::RepositoryHeader;
pub struct GitBare {
pub bare_dir: PathBuf,
}
impl GitBare {
pub fn gix_repo(&self) -> GitResult<gix::Repository> {
gix::open(&self.bare_dir)
.map_err(|e| GitError::Internal(format!("failed to open gix repository: {e}")))
}
pub fn from_repository_header(header: &RepositoryHeader) -> GitResult<Self> {
let storage_path = header.storage_path.trim();
let relative_path = header.relative_path.trim();
let storage_name = header.storage_name.trim();
let _ = storage_name; // reserved for future sharding logic
// Build base path: storage_path if given, else relative_path alone
let base = if !storage_path.is_empty() {
let p = Path::new(storage_path);
if !p.is_absolute() {
return Err(GitError::InvalidArgument(
"storage_path must be an absolute path".into(),
));
}
PathBuf::from(p)
} else if !relative_path.is_empty() {
// relative_path alone is rejected unless absolute
return Err(GitError::InvalidArgument(
"relative_path requires storage_path to be set".into(),
));
} else {
return Err(GitError::InvalidArgument("empty repository path".into()));
};
// Join relative_path if provided
let bare_dir = if !relative_path.is_empty() && !storage_path.is_empty() {
let candidate = base.join(relative_path);
// Canonicalize to resolve any `..` / symlinks, then check still under base
let canonical = candidate
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
// Path traversal check: canonical resolved dir must start with base
let base_canon = base.canonicalize().unwrap_or_else(|_| base.clone());
if !canonical.starts_with(&base_canon) {
return Err(GitError::InvalidArgument(format!(
"path traversal detected: {relative_path} escapes storage root"
)));
}
canonical
} else if !storage_path.is_empty() {
base.canonicalize().unwrap_or(base)
} else {
return Err(GitError::InvalidArgument("empty repository path".into()));
};
// Validate bare_dir exists, is a directory, and is readable
if !bare_dir.exists() {
return Err(GitError::RepoNotFound);
}
if !bare_dir.is_dir() {
return Err(GitError::InvalidArgument(format!(
"not a directory: {}",
bare_dir.display()
)));
}
// Accept either bare repos (HEAD file) or non-bare (HEAD + .git)
let head_path = bare_dir.join("HEAD");
if !head_path.exists() {
// Maybe it's a non-bare repo
let git_dir = bare_dir.join(".git");
if git_dir.is_dir() && git_dir.join("HEAD").exists() {
return Ok(Self { bare_dir: git_dir });
}
return Err(GitError::NotBareRepository);
}
Ok(Self { bare_dir })
}
/// Detect the repository's object format (SHA-1 or SHA-256).
pub fn object_format(&self) -> crate::pb::ObjectFormat {
let repo = self.gix_repo().ok();
let kind = repo
.map(|r| r.object_hash())
.unwrap_or(gix::hash::Kind::Sha1);
match kind {
gix::hash::Kind::Sha1 => crate::pb::ObjectFormat::Sha1,
gix::hash::Kind::Sha256 => crate::pb::ObjectFormat::Sha256,
_ => crate::pb::ObjectFormat::Unspecified,
}
}
/// Convert a hex object id to a protobuf Oid.
///
/// `Oid.value` is the binary hash bytes, while `Oid.hex` keeps the printable
/// lowercase representation for clients that prefer string IDs.
pub fn oid_to_pb(&self, hex: impl Into<String>) -> crate::pb::Oid {
let hex = hex.into().to_lowercase();
let format = self.object_format();
crate::pb::Oid {
value: crate::oid::hex_to_bytes(&hex).unwrap_or_default(),
hex,
format: format as i32,
}
}
}
+152
View File
@@ -0,0 +1,152 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{BlameHunk, BlameLine, BlameRequest, BlameResponse, PageInfo};
impl GitBare {
pub fn blame(&self, request: BlameRequest) -> GitResult<BlameResponse> {
let revision = match request.revision.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"blame".to_string(),
"--porcelain".to_string(),
];
if let Some(range) = &request.range {
args.push("-L".into());
args.push(format!("{},{}", range.start, range.end));
}
args.push(revision);
args.push("--".into());
args.push(request.path.clone());
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let output = String::from_utf8_lossy(&result.stdout);
let hunks = parse_porcelain_blame(&output, &request.path, self);
let total_count = hunks.len() as u64;
Ok(BlameResponse {
hunks,
page_info: Some(PageInfo {
next_page_token: String::new(),
has_next_page: false,
total_count,
}),
truncated: false,
})
}
}
fn parse_porcelain_blame(output: &str, path: &str, repo: &GitBare) -> Vec<BlameHunk> {
let mut hunks = Vec::new();
let mut current_hunk: Option<BlameHunk> = None;
for line in output.lines() {
if let Some(content) = line.strip_prefix('\t') {
if let Some(ref mut hunk) = current_hunk {
let next_line_no = hunk.final_start_line + hunk.lines.len() as u32;
hunk.lines.push(BlameLine {
final_line: next_line_no,
original_line: 0,
content: content.as_bytes().to_vec(),
});
}
continue;
}
let parts: Vec<&str> = line.splitn(4, ' ').collect();
if parts.is_empty() {
continue;
}
let token = parts[0];
if token.len() == 40 || token.len() == 64 {
let orig_line: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let final_line: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
if let Some(prev) = current_hunk.take() {
hunks.push(prev);
}
current_hunk = Some(BlameHunk {
commit: Some(crate::pb::Commit {
oid: Some(repo.oid_to_pb(token)),
abbreviated_oid: token.chars().take(7).collect(),
subject: String::new(),
message: String::new(),
..Default::default()
}),
original_path: String::new(),
final_path: path.to_string(),
original_start_line: orig_line,
final_start_line: final_line,
line_count: 0,
boundary: false,
lines: Vec::new(),
});
} else if let Some(ref mut hunk) = current_hunk {
match token {
"author" => {
if let Some(commit) = hunk.commit.as_mut() {
let name = line.strip_prefix("author ").unwrap_or_default();
if let Some(sig) = commit.author.as_mut() {
if let Some(id) = sig.identity.as_mut() {
id.name = name.to_string();
}
} else {
commit.author = Some(crate::pb::Signature {
identity: Some(crate::pb::Identity {
name: name.to_string(),
email: String::new(),
}),
..Default::default()
});
}
}
}
"author-mail" => {
if let Some(commit) = hunk.commit.as_mut() {
let email = line
.strip_prefix("author-mail ")
.unwrap_or_default()
.trim_matches(|c| c == '<' || c == '>')
.to_string();
if let Some(sig) = commit.author.as_mut()
&& let Some(id) = sig.identity.as_mut()
{
id.email = email;
}
}
}
"filename" => {
hunk.original_path = line.strip_prefix("filename ").unwrap_or(path).to_string();
}
"boundary" => {
hunk.boundary = true;
}
_ => {}
}
}
}
if let Some(hunk) = current_hunk {
hunks.push(hunk);
}
for hunk in &mut hunks {
hunk.line_count = hunk.lines.len() as u32;
}
hunks
}
+1
View File
@@ -0,0 +1 @@
pub mod do_blame;
+70
View File
@@ -0,0 +1,70 @@
use gix::object::tree::EntryKind;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{Blob, GetBlobRequest, object_selector};
impl GitBare {
pub fn get_blob(&self, request: GetBlobRequest) -> GitResult<Blob> {
let repo = self.gix_repo()?;
let (blob, mode, path) = if let Some(oid) = request.oid.as_ref() {
let id = gix::hash::ObjectId::from_hex(oid.hex.as_bytes())
.map_err(|e| GitError::InvalidOid(e.to_string()))?;
(
repo.find_object(id)?
.try_into_blob()
.map_err(|e| GitError::Gix(e.to_string()))?,
0,
request.path,
)
} else {
let revision = match request.revision.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let tree = repo
.rev_parse_single(format!("{}^{{tree}}", revision).as_str())?
.object()?
.try_into_tree()
.map_err(|e| GitError::Gix(e.to_string()))?;
let entry = tree
.lookup_entry_by_path(&request.path)?
.ok_or_else(|| GitError::NotFound(request.path.clone()))?;
let mode = u32::from_str_radix(&format!("{:o}", entry.mode()), 8).unwrap_or(0);
if !matches!(
entry.mode().kind(),
EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link
) {
return Err(GitError::InvalidArgument(
"path does not point to a blob".into(),
));
}
(
entry
.object()?
.try_into_blob()
.map_err(|e| GitError::Gix(e.to_string()))?,
mode,
request.path,
)
};
let original_size = blob.data.len() as i64;
let mut data = blob.data.clone();
let truncated = request.max_bytes > 0 && data.len() > request.max_bytes as usize;
if truncated {
data.truncate(request.max_bytes as usize);
}
let hex = blob.id.to_string();
Ok(Blob {
oid: Some(self.oid_to_pb(hex)),
path,
mode,
size: original_size,
binary: blob.data.contains(&0),
encoding: String::new(),
truncated,
data,
})
}
}
+16
View File
@@ -0,0 +1,16 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{GetBlobRequest, GetRawBlobRequest, GetRawBlobResponse};
impl GitBare {
pub fn get_raw_blob(&self, request: GetRawBlobRequest) -> GitResult<Vec<GetRawBlobResponse>> {
let blob = self.get_blob(GetBlobRequest {
repository: request.repository,
revision: request.revision,
path: request.path,
oid: request.oid,
max_bytes: 0,
})?;
Ok(vec![GetRawBlobResponse { data: blob.data }])
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod get_blob;
pub mod get_raw_blob;
+54
View File
@@ -0,0 +1,54 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{CompareBranchRequest, CompareBranchResponse};
impl GitBare {
pub fn compare_branch(
&self,
request: CompareBranchRequest,
) -> GitResult<CompareBranchResponse> {
let repo = self.gix_repo()?;
let source_ref = format!("refs/heads/{}", request.source_branch);
let target_ref = format!("refs/heads/{}", request.target_branch);
let source_id = repo.find_reference(source_ref.as_str())?.peel_to_id()?;
let target_id = repo.find_reference(target_ref.as_str())?.peel_to_id()?;
let source_hex = source_id.to_string();
let target_hex = target_id.to_string();
let merge_base = repo
.merge_base(source_id.detach(), target_id.detach())
.ok()
.map(|id| self.oid_to_pb(id.to_string()));
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"rev-list",
"--left-right",
"--count",
&format!("{}...{}", source_hex, target_hex),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let output = String::from_utf8_lossy(&result.stdout);
let parts: Vec<&str> = output.split_whitespace().collect();
let ahead_by = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let behind_by = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
Ok(CompareBranchResponse {
ahead: ahead_by > 0,
behind: behind_by > 0,
ahead_by,
behind_by,
merge_base,
})
}
}
+36
View File
@@ -0,0 +1,36 @@
use std::process::Command;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{Branch, CreateBranchRequest, GetBranchRequest, object_selector};
impl GitBare {
pub fn create_branch(&self, request: CreateBranchRequest) -> GitResult<Branch> {
let revision = match request.start_point.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let mut args = vec!["branch".to_string()];
if request.force {
args.push("-f".into());
}
args.push(request.name.clone());
args.push(revision);
let output = Command::new("git")
.arg("--git-dir")
.arg(&self.bare_dir)
.args(&args)
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
status_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
self.get_branch(GetBranchRequest {
repository: request.repository,
name: request.name,
})
}
}
+23
View File
@@ -0,0 +1,23 @@
use std::process::Command;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::DeleteBranchRequest;
impl GitBare {
pub fn delete_branch(&self, request: DeleteBranchRequest) -> GitResult<()> {
let flag = if request.force { "-D" } else { "-d" };
let output = Command::new("git")
.arg("--git-dir")
.arg(&self.bare_dir)
.args(["branch", flag, &request.name])
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
status_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(())
}
}
+23
View File
@@ -0,0 +1,23 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{Branch, GetBranchRequest};
impl GitBare {
pub fn get_branch(&self, request: GetBranchRequest) -> GitResult<Branch> {
let repo = self.gix_repo()?;
let refname = format!("refs/heads/{}", request.name);
let mut r = repo.find_reference(refname.as_str())?;
let hex = r.peel_to_id()?.to_string();
Ok(Branch {
name: request.name,
full_ref: refname,
target_oid: Some(self.oid_to_pb(hex)),
commit: None,
upstream: None,
is_default: false,
is_head: false,
is_merged: false,
is_detached: false,
})
}
}
+78
View File
@@ -0,0 +1,78 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::paginate;
use crate::pb::{Branch, ListBranchesRequest, ListBranchesResponse};
impl GitBare {
pub fn list_branches(&self, request: ListBranchesRequest) -> GitResult<ListBranchesResponse> {
let repo = self.gix_repo()?;
let merged_set = if request.merged_into_head || request.not_merged_into_head {
let flag = if request.merged_into_head {
"--merged"
} else {
"--no-merged"
};
let check = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"branch",
flag,
"HEAD",
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run();
match check {
Ok(out) => String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().trim_start_matches("* ").to_string())
.collect::<Vec<_>>(),
Err(_) => Vec::new(),
}
} else {
Vec::new()
};
let mut branches: Vec<Branch> = Vec::new();
for r in repo.references()?.local_branches()? {
let mut r = r.map_err(|e| crate::error::GitError::Gix(e.to_string()))?;
let name = r.name().shorten().to_string();
if !request.pattern.is_empty() && !name.contains(&request.pattern) {
continue;
}
if request.merged_into_head && !merged_set.contains(&name) {
continue;
}
if request.not_merged_into_head && merged_set.contains(&name) {
continue;
}
let hex = r
.peel_to_id()
.ok()
.map(|id| id.to_string())
.unwrap_or_default();
branches.push(Branch {
name,
full_ref: r.name().to_string(),
target_oid: Some(self.oid_to_pb(hex)),
commit: None,
upstream: None,
is_default: false,
is_head: false,
is_merged: false,
is_detached: false,
});
}
paginate::apply_sort(&mut branches, request.sort_direction);
let (branches, page_info) = paginate::paginate(&branches, request.pagination.as_ref());
Ok(ListBranchesResponse {
branches,
page_info: Some(page_info),
})
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod compare_branch;
pub mod create_branch;
pub mod delete_branch;
pub mod get_branch;
pub mod list_branches;
pub mod rename_branch;
pub mod set_branch_upstream;
pub mod update_branch_target;
+25
View File
@@ -0,0 +1,25 @@
use std::process::Command;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{Branch, GetBranchRequest, RenameBranchRequest};
impl GitBare {
pub fn rename_branch(&self, request: RenameBranchRequest) -> GitResult<Branch> {
let output = Command::new("git")
.arg("--git-dir")
.arg(&self.bare_dir)
.args(["branch", "-m", &request.old_name, &request.new_name])
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
status_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
self.get_branch(GetBranchRequest {
repository: request.repository,
name: request.new_name,
})
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{Branch, GetBranchRequest, SetBranchUpstreamRequest};
impl GitBare {
pub fn set_branch_upstream(&self, request: SetBranchUpstreamRequest) -> GitResult<Branch> {
if let Some(upstream) = request.upstream {
let tracking = format!("{}/{}", upstream.remote_name, upstream.remote_branch_name);
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"branch",
"--set-upstream-to",
&tracking,
&request.name,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
}
self.get_branch(GetBranchRequest {
repository: request.repository,
name: request.name,
})
}
}
+39
View File
@@ -0,0 +1,39 @@
#![allow(clippy::collapsible_if)]
use std::process::Command;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{Branch, GetBranchRequest, UpdateBranchTargetRequest};
impl GitBare {
pub fn update_branch_target(&self, request: UpdateBranchTargetRequest) -> GitResult<Branch> {
let new_oid = request
.new_oid
.as_ref()
.ok_or_else(|| GitError::InvalidArgument("new_oid is required".into()))?
.hex
.clone();
let refname = format!("refs/heads/{}", request.name);
let mut args = vec!["update-ref".to_string(), refname.clone(), new_oid];
if !request.force
&& let Some(old) = request.expected_old_oid.as_ref()
{
args.push(old.hex.clone());
}
let output = Command::new("git")
.arg("--git-dir")
.arg(&self.bare_dir)
.args(&args)
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
status_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
self.get_branch(GetBranchRequest {
repository: request.repository,
name: refname.trim_start_matches("refs/heads/").to_string(),
})
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::fs;
use std::path::{Path, PathBuf};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let proto_dir = manifest_dir.join("proto");
let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
fs::create_dir_all(&out_dir)?;
clean_generated_files(&out_dir)?;
let protos = proto_files(&proto_dir)?;
for proto in &protos {
println!("cargo:rerun-if-changed={}", proto.display());
}
println!("cargo:rerun-if-changed={}", proto_dir.display());
println!("cargo:rerun-if-changed=build.rs");
tonic_build::configure()
.build_client(true)
.build_server(true)
.emit_rerun_if_changed(false)
.out_dir(&out_dir)
.compile_protos(&protos, &[proto_dir])?;
Ok(())
}
fn proto_files(proto_dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let mut files = fs::read_dir(proto_dir)?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<Result<Vec<_>, _>>()?;
files.retain(|path| path.extension().is_some_and(|ext| ext == "proto"));
files.sort();
Ok(files)
}
fn clean_generated_files(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
for entry in fs::read_dir(out_dir)? {
let path = entry?.path();
let is_generated_rs = path.extension().is_some_and(|ext| ext == "rs")
&& path.file_name().is_some_and(|name| name != "mod.rs");
if is_generated_rs {
fs::remove_file(path)?;
}
}
Ok(())
}
+161
View File
@@ -0,0 +1,161 @@
use crate::bare::GitBare;
use crate::commit::create_commit::command_ok;
use crate::error::{GitError, GitResult};
use crate::pb::{CherryPickCommitRequest, CreateCommitResponse, GetCommitRequest};
impl GitBare {
pub fn cherry_pick_commit(
&self,
request: CherryPickCommitRequest,
) -> GitResult<CreateCommitResponse> {
let target_branch = request.branch.clone();
let cp_revision = match request.commit.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => return Err(GitError::InvalidArgument("commit is required".into())),
};
let repo = self.gix_repo()?;
let branch_ref = format!("refs/heads/{}", target_branch);
let branch_tip = repo
.find_reference(branch_ref.as_str())
.ok()
.and_then(|mut r| r.peel_to_id().ok())
.map(|id| id.to_string())
.ok_or_else(|| GitError::RefNotFound(target_branch.clone()))?;
let cp_id = repo.rev_parse_single(cp_revision.as_str())?;
let cp_obj = cp_id
.object()?
.try_into_commit()
.map_err(|e| GitError::Gix(e.to_string()))?;
let parent_id = cp_obj.parent_ids().next().map(|p| p.to_string());
let tmp_index = tempfile::Builder::new()
.prefix("gitks-cp-")
.tempfile_in(&self.bare_dir)?;
let idx_path = tmp_index.path().to_string_lossy().into_owned();
let bare = self.bare_dir.to_string_lossy().into_owned();
let read_tree = duct::cmd(
"git",
["--git-dir", bare.as_str(), "read-tree", branch_tip.as_str()],
)
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(read_tree)?;
let mut format_patch_args = vec![
"--git-dir".to_string(),
bare.clone(),
"format-patch".to_string(),
"--stdout".to_string(),
"--full-index".to_string(),
"--binary".to_string(),
"-1".to_string(),
];
if parent_id.is_none() {
format_patch_args.push("--root".to_string());
}
format_patch_args.push(cp_revision.clone());
let diff = duct::cmd("git", &format_patch_args)
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let patch_data = command_ok(diff)?;
let apply = duct::cmd(
"git",
[
"--git-dir",
bare.as_str(),
"apply",
"--cached",
"--allow-empty",
"-",
],
)
.env("GIT_INDEX_FILE", &idx_path)
.stdin_bytes(patch_data.as_bytes())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !apply.status.success() {
return Err(GitError::Internal(format!(
"cherry-pick apply failed: {}",
String::from_utf8_lossy(&apply.stderr)
)));
}
let write_tree = duct::cmd("git", ["--git-dir", bare.as_str(), "write-tree"])
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let tree_id = command_ok(write_tree)?.trim().to_string();
let message = cp_obj.message_raw()?.to_string();
let parents = vec![branch_tip.clone()];
let commit_id = self.commit_tree(
&tree_id,
&parents,
&message,
request.committer.as_ref(),
request.committer.as_ref(),
)?;
self.update_branch_ref(&target_branch, &commit_id, Some(&branch_tip), false)?;
Ok(CreateCommitResponse {
commit: Some(self.get_commit(GetCommitRequest {
repository: request.repository,
revision: Some(crate::pb::ObjectSelector {
selector: Some(crate::pb::object_selector::Selector::Revision(
crate::pb::ObjectName {
revision: commit_id,
},
)),
}),
include_stats: false,
include_raw: false,
})?),
branch: target_branch,
})
}
pub(crate) fn update_branch_ref(
&self,
branch: &str,
commit_id: &str,
old_value: Option<&str>,
force: bool,
) -> GitResult<()> {
let refname = format!("refs/heads/{}", branch);
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"update-ref".into(),
refname,
commit_id.to_string(),
];
if !force {
args.push(old_value.unwrap_or(crate::oid::ZERO_OID).to_string());
}
let update = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(update).map(|_| ())
}
}
+94
View File
@@ -0,0 +1,94 @@
use crate::bare::GitBare;
use crate::diff::get_diff_stats::diff_stats_for_range;
use crate::error::{GitError, GitResult};
use crate::paginate;
use crate::pb::{
CommitStats, CompareCommitsRequest, CompareCommitsResponse, GetCommitRequest, object_selector,
};
impl GitBare {
pub fn compare_commits(
&self,
request: CompareCommitsRequest,
) -> GitResult<CompareCommitsResponse> {
let repo = self.gix_repo()?;
let base = match request.base.clone().and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let head = match request.head.clone().and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let base_id = repo.rev_parse_single(base.as_str())?;
let head_id = repo.rev_parse_single(head.as_str())?;
let merge_base = repo
.merge_base(base_id.detach(), head_id.detach())
.ok()
.map(|id| self.oid_to_pb(id.to_string()));
let range = if request.straight {
format!("{base}..{head}")
} else {
format!("{base}...{head}")
};
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"rev-list".into(),
];
if request.first_parent {
args.push("--first-parent".into());
}
args.push(range);
let rev_list = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !rev_list.status.success() {
return Err(GitError::CommandFailed {
status_code: rev_list.status.code(),
stderr: String::from_utf8_lossy(&rev_list.stderr).into_owned(),
});
}
let ids = String::from_utf8_lossy(&rev_list.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let (page_ids, page_info) = paginate::paginate(&ids, request.pagination.as_ref());
let mut commits = Vec::with_capacity(page_ids.len());
for id in page_ids {
commits.push(self.get_commit(GetCommitRequest {
repository: request.repository.clone(),
revision: Some(crate::pb::ObjectSelector {
selector: Some(object_selector::Selector::Revision(crate::pb::ObjectName {
revision: id,
})),
}),
include_stats: false,
include_raw: false,
})?);
}
let diff_stats = diff_stats_for_range(self, &base, &head, None)?;
Ok(CompareCommitsResponse {
commits,
stats: Some(CommitStats {
additions: diff_stats.additions,
deletions: diff_stats.deletions,
changed_files: diff_stats.changed_files,
}),
page_info: Some(page_info),
merge_base,
})
}
}
+375
View File
@@ -0,0 +1,375 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::oid::ZERO_OID;
use crate::pb::{
CreateCommitRequest, CreateCommitResponse, GetCommitRequest, ObjectName, ObjectSelector,
create_commit_action, object_selector,
};
impl GitBare {
pub fn create_commit(&self, request: CreateCommitRequest) -> GitResult<CreateCommitResponse> {
let repo = self.gix_repo()?;
let start_rev = match request.start_revision.clone().and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => request.branch.clone(),
};
let parent_id = repo
.rev_parse_single(start_rev.as_str())
.ok()
.map(|id| id.to_string());
let current_branch_tip = repo
.find_reference(format!("refs/heads/{}", request.branch).as_str())
.ok()
.and_then(|mut r| r.peel_to_id().ok())
.map(|id| id.to_string());
let tree_id = if request.actions.is_empty() {
let Some(parent) = parent_id.as_ref() else {
return Err(GitError::InvalidArgument(
"cannot create an empty root commit without file actions".into(),
));
};
self.rev_parse_tree(parent)?
} else {
self.tree_from_actions(parent_id.as_deref(), &request.actions)?
};
let message = commit_message_with_trailers(&request);
let parents: Vec<String> = parent_id.iter().cloned().collect();
let commit_id = self.commit_tree(
&tree_id,
&parents,
&message,
request.author.as_ref(),
request.committer.as_ref(),
)?;
self.update_branch_after_commit(&request, &commit_id, current_branch_tip.as_deref())?;
let revision = Some(ObjectSelector {
selector: Some(object_selector::Selector::Revision(ObjectName {
revision: commit_id,
})),
});
Ok(CreateCommitResponse {
commit: Some(self.get_commit(GetCommitRequest {
repository: request.repository,
revision,
include_stats: false,
include_raw: false,
})?),
branch: request.branch,
})
}
fn rev_parse_tree(&self, revision: &str) -> GitResult<String> {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"rev-parse",
&format!("{revision}^{{tree}}"),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(result).map(|stdout| stdout.trim().to_string())
}
fn tree_from_actions(
&self,
parent_id: Option<&str>,
actions: &[crate::pb::CreateCommitAction],
) -> GitResult<String> {
let tmp_index = tempfile::Builder::new()
.prefix("gitks-index-")
.tempfile_in(&self.bare_dir)
.map_err(GitError::Io)?;
let tmp_index_path = tmp_index.path().to_string_lossy().into_owned();
if let Some(parent) = parent_id {
let read_tree = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"read-tree",
parent,
],
)
.env("GIT_INDEX_FILE", &tmp_index_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(read_tree)?;
}
for action in actions {
self.apply_commit_action(&tmp_index_path, action)?;
}
let write_tree = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"write-tree",
],
)
.env("GIT_INDEX_FILE", &tmp_index_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(write_tree).map(|stdout| stdout.trim().to_string())
}
fn apply_commit_action(
&self,
index_path: &str,
action: &crate::pb::CreateCommitAction,
) -> GitResult<()> {
let action_type = create_commit_action::Action::try_from(action.action)
.unwrap_or(create_commit_action::Action::CreateCommitActionUnspecified);
match action_type {
create_commit_action::Action::CreateCommitActionCreate
| create_commit_action::Action::CreateCommitActionUpdate => {
let hash = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"hash-object",
"-w",
"--stdin",
],
)
.stdin_bytes(action.content.clone())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let blob = command_ok(hash)?.trim().to_string();
let mode = if action.executable {
"100755"
} else {
"100644"
};
self.update_index_cacheinfo(index_path, mode, &blob, &action.file_path)
}
create_commit_action::Action::CreateCommitActionDelete => {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"update-index",
"--force-remove",
&action.file_path,
],
)
.env("GIT_INDEX_FILE", index_path)
.env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(result).map(|_| ())
}
create_commit_action::Action::CreateCommitActionMove => {
if action.previous_path.is_empty() {
return Err(GitError::InvalidArgument(
"MOVE action requires previous_path".into(),
));
}
let (mode, oid) = self.index_entry(index_path, &action.previous_path)?;
self.update_index_cacheinfo(index_path, &mode, &oid, &action.file_path)?;
let remove = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"update-index",
"--force-remove",
&action.previous_path,
],
)
.env("GIT_INDEX_FILE", index_path)
.env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(remove).map(|_| ())
}
create_commit_action::Action::CreateCommitActionChmod => {
let (_old_mode, oid) = self.index_entry(index_path, &action.file_path)?;
let mode = if action.executable {
"100755"
} else {
"100644"
};
self.update_index_cacheinfo(index_path, mode, &oid, &action.file_path)
}
create_commit_action::Action::CreateCommitActionUnspecified => Err(
GitError::InvalidArgument("unspecified commit action".into()),
),
}
}
fn index_entry(&self, index_path: &str, path: &str) -> GitResult<(String, String)> {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"ls-files",
"-s",
"--",
path,
],
)
.env("GIT_INDEX_FILE", index_path)
.env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let stdout = command_ok(result)?;
let line = stdout
.lines()
.next()
.ok_or_else(|| GitError::NotFound(path.to_string()))?;
let parts = line.split_whitespace().collect::<Vec<_>>();
if parts.len() < 2 {
return Err(GitError::ParseError(format!(
"invalid index entry for {path}: {line}"
)));
}
Ok((parts[0].to_string(), parts[1].to_string()))
}
fn update_index_cacheinfo(
&self,
index_path: &str,
mode: &str,
oid: &str,
path: &str,
) -> GitResult<()> {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"update-index",
"--add",
"--cacheinfo",
mode,
oid,
path,
],
)
.env("GIT_INDEX_FILE", index_path)
.env("GIT_WORK_TREE", self.bare_dir.to_string_lossy().as_ref())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(result).map(|_| ())
}
pub(crate) fn commit_tree(
&self,
tree_id: &str,
parent_ids: &[String],
message: &str,
author: Option<&crate::pb::Signature>,
committer: Option<&crate::pb::Signature>,
) -> GitResult<String> {
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"commit-tree".into(),
tree_id.to_string(),
];
for parent in parent_ids {
args.push("-p".into());
args.push(parent.clone());
}
args.push("-m".into());
args.push(message.to_string());
let mut cmd = duct::cmd("git", &args).stdout_capture().stderr_capture();
if let Some(author) = author.and_then(|s| s.identity.as_ref()) {
cmd = cmd
.env("GIT_AUTHOR_NAME", &author.name)
.env("GIT_AUTHOR_EMAIL", &author.email);
}
if let Some(committer) = committer.and_then(|s| s.identity.as_ref()) {
cmd = cmd
.env("GIT_COMMITTER_NAME", &committer.name)
.env("GIT_COMMITTER_EMAIL", &committer.email);
}
let commit = cmd.unchecked().run()?;
command_ok(commit).map(|stdout| stdout.trim().to_string())
}
fn update_branch_after_commit(
&self,
request: &CreateCommitRequest,
commit_id: &str,
current_branch_tip: Option<&str>,
) -> GitResult<()> {
let refname = format!("refs/heads/{}", request.branch);
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"update-ref".into(),
refname,
commit_id.to_string(),
];
if !request.force {
args.push(current_branch_tip.unwrap_or(ZERO_OID).to_string());
}
let update = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(update).map(|_| ())
}
}
fn commit_message_with_trailers(request: &CreateCommitRequest) -> String {
if request.trailers.is_empty() {
return request.message.clone();
}
let mut message = request.message.trim_end().to_string();
message.push_str("\n\n");
for trailer in &request.trailers {
let separator = if trailer.separator_present { ": " } else { " " };
message.push_str(&trailer.key);
message.push_str(separator);
message.push_str(&trailer.value);
message.push('\n');
}
message
}
pub(crate) fn command_ok(output: std::process::Output) -> GitResult<String> {
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
Err(GitError::CommandFailed {
status_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
+76
View File
@@ -0,0 +1,76 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{Commit, GetCommitRequest, object_selector};
impl GitBare {
pub fn get_commit(&self, request: GetCommitRequest) -> GitResult<Commit> {
let repo = self.gix_repo()?;
let revision = match request.revision.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let id = repo.rev_parse_single(revision.as_str())?;
let commit = id
.object()?
.try_into_commit()
.map_err(|e| GitError::Gix(e.to_string()))?;
let hex = commit.id.to_string();
let tree_hex = commit.tree_id()?.to_string();
let message = commit.message_raw()?.to_string();
let (subject, body) = message
.split_once('\n')
.map(|(s, b)| (s.to_string(), b.trim_start_matches('\n').to_string()))
.unwrap_or_else(|| (message.clone(), String::new()));
let author_sig = commit.author().ok();
let committer_sig = commit.committer().ok();
Ok(Commit {
oid: Some(self.oid_to_pb(hex.clone())),
abbreviated_oid: commit
.short_id()
.map(|s| s.to_string())
.unwrap_or_else(|_| hex.chars().take(7).collect()),
parent_oids: commit
.parent_ids()
.map(|p| self.oid_to_pb(p.to_string()))
.collect(),
tree_oid: Some(self.oid_to_pb(tree_hex)),
author: author_sig.as_ref().map(gix_sig_to_pb),
committer: committer_sig.as_ref().map(gix_sig_to_pb),
subject,
body,
message,
trailers: Vec::new(),
signature: None,
stats: None,
authored_at: author_sig.as_ref().map(|s| prost_types::Timestamp {
seconds: s.seconds(),
nanos: 0,
}),
committed_at: committer_sig.as_ref().map(|s| prost_types::Timestamp {
seconds: s.seconds(),
nanos: 0,
}),
raw: if request.include_raw {
commit.data.clone()
} else {
Vec::new()
},
})
}
}
pub(crate) fn gix_sig_to_pb(sig: &gix::actor::SignatureRef<'_>) -> crate::pb::Signature {
let time = sig.time().ok();
crate::pb::Signature {
identity: Some(crate::pb::Identity {
name: sig.name.to_string(),
email: sig.email.to_string(),
}),
when: Some(prost_types::Timestamp {
seconds: sig.seconds(),
nanos: 0,
}),
timezone_offset: time.map(|t| t.offset / 60).unwrap_or(0),
}
}
+28
View File
@@ -0,0 +1,28 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{GetCommitAncestorsRequest, GetCommitAncestorsResponse, ListCommitsRequest};
impl GitBare {
pub fn get_commit_ancestors(
&self,
request: GetCommitAncestorsRequest,
) -> GitResult<GetCommitAncestorsResponse> {
let response = self.list_commits(ListCommitsRequest {
repository: request.repository,
revision: request.revision,
path: String::new(),
since: None,
until: None,
first_parent: request.first_parent,
all: false,
reverse: false,
max_parents: 0,
min_parents: 0,
pagination: request.pagination,
})?;
Ok(GetCommitAncestorsResponse {
commits: response.commits,
page_info: response.page_info,
})
}
}
+86
View File
@@ -0,0 +1,86 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::paginate;
use crate::pb::{GetCommitRequest, ListCommitsRequest, ListCommitsResponse, object_selector};
impl GitBare {
pub fn list_commits(&self, request: ListCommitsRequest) -> GitResult<ListCommitsResponse> {
let revision = match request.revision.clone().and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"rev-list".into(),
];
if request.first_parent {
args.push("--first-parent".into());
}
if request.reverse {
args.push("--reverse".into());
}
if request.max_parents > 0 {
args.push(format!("--max-parents={}", request.max_parents));
}
if request.min_parents > 0 {
args.push(format!("--min-parents={}", request.min_parents));
}
if let Some(since) = request.since.as_ref() {
args.push(format!("--since=@{}", since.seconds));
}
if let Some(until) = request.until.as_ref() {
args.push(format!("--until=@{}", until.seconds));
}
if request.all {
args.push("--all".into());
} else {
args.push(revision);
}
if !request.path.is_empty() {
args.push("--".into());
args.push(request.path.clone());
}
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let ids = String::from_utf8_lossy(&result.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let (page_ids, page_info) = paginate::paginate(&ids, request.pagination.as_ref());
let mut commits = Vec::with_capacity(page_ids.len());
for id in page_ids {
commits.push(self.get_commit(GetCommitRequest {
repository: request.repository.clone(),
revision: Some(crate::pb::ObjectSelector {
selector: Some(object_selector::Selector::Revision(crate::pb::ObjectName {
revision: id,
})),
}),
include_stats: false,
include_raw: false,
})?);
}
Ok(ListCommitsResponse {
commits,
page_info: Some(page_info),
})
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod cherry_pick_commit;
pub mod compare_commits;
pub mod create_commit;
pub mod get_commit;
pub mod get_commit_ancestors;
pub mod list_commits;
pub mod revert_commit;
+146
View File
@@ -0,0 +1,146 @@
use crate::bare::GitBare;
use crate::commit::create_commit::command_ok;
use crate::error::{GitError, GitResult};
use crate::pb::{CreateCommitResponse, GetCommitRequest, RevertCommitRequest};
impl GitBare {
pub fn revert_commit(&self, request: RevertCommitRequest) -> GitResult<CreateCommitResponse> {
let target_branch = request.branch.clone();
let revert_revision = match request.commit.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => return Err(GitError::InvalidArgument("commit is required".into())),
};
let repo = self.gix_repo()?;
let branch_ref = format!("refs/heads/{}", target_branch);
let branch_tip = repo
.find_reference(branch_ref.as_str())
.ok()
.and_then(|mut r| r.peel_to_id().ok())
.map(|id| id.to_string())
.ok_or_else(|| GitError::RefNotFound(target_branch.clone()))?;
let revert_id = repo.rev_parse_single(revert_revision.as_str())?;
let revert_obj = revert_id
.object()?
.try_into_commit()
.map_err(|e| GitError::Gix(e.to_string()))?;
let parent_ids: Vec<String> = revert_obj.parent_ids().map(|p| p.to_string()).collect();
if parent_ids.len() > 1 {
return Err(GitError::InvalidArgument(
"reverting merge commits is not supported without mainline".into(),
));
}
let parent_hex = parent_ids
.first()
.ok_or_else(|| GitError::InvalidArgument("cannot revert root commit".into()))?;
let tmp_index = tempfile::Builder::new()
.prefix("gitks-revert-")
.tempfile_in(&self.bare_dir)?;
let idx_path = tmp_index.path().to_string_lossy().into_owned();
let bare = self.bare_dir.to_string_lossy().into_owned();
let read_tree = duct::cmd(
"git",
["--git-dir", bare.as_str(), "read-tree", branch_tip.as_str()],
)
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(read_tree)?;
let diff = duct::cmd(
"git",
[
"--git-dir",
bare.as_str(),
"diff",
"--binary",
"--full-index",
revert_revision.as_str(),
parent_hex.as_str(),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let patch_data = command_ok(diff)?;
let apply = duct::cmd(
"git",
[
"--git-dir",
bare.as_str(),
"apply",
"--cached",
"--allow-empty",
"-",
],
)
.env("GIT_INDEX_FILE", &idx_path)
.stdin_bytes(patch_data.as_bytes())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !apply.status.success() {
return Err(GitError::Internal(format!(
"revert apply failed: {}",
String::from_utf8_lossy(&apply.stderr)
)));
}
let write_tree = duct::cmd("git", ["--git-dir", bare.as_str(), "write-tree"])
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let tree_id = command_ok(write_tree)?.trim().to_string();
let subject = revert_obj
.message_raw()?
.to_string()
.lines()
.next()
.unwrap_or_default()
.to_string();
let message = format!(
"Revert \"{}\"\n\nThis reverts commit {}.",
subject, revert_revision
);
let commit_id = self.commit_tree(
&tree_id,
std::slice::from_ref(&branch_tip),
&message,
request.committer.as_ref(),
request.committer.as_ref(),
)?;
self.update_branch_ref(&target_branch, &commit_id, Some(&branch_tip), false)?;
Ok(CreateCommitResponse {
commit: Some(self.get_commit(GetCommitRequest {
repository: request.repository,
revision: Some(crate::pb::ObjectSelector {
selector: Some(crate::pb::object_selector::Selector::Revision(
crate::pb::ObjectName {
revision: commit_id,
},
)),
}),
include_stats: false,
include_raw: false,
})?),
branch: target_branch,
})
}
}
+1
View File
@@ -0,0 +1 @@
+86
View File
@@ -0,0 +1,86 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{GetCommitDiffRequest, GetDiffRequest, GetDiffResponse};
impl GitBare {
pub fn get_commit_diff(&self, request: GetCommitDiffRequest) -> GitResult<GetDiffResponse> {
let commit = match request.commit.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let base = self.first_parent_or_empty_tree(&commit)?;
self.get_diff(GetDiffRequest {
repository: request.repository,
base: Some(crate::pb::ObjectSelector {
selector: Some(crate::pb::object_selector::Selector::Revision(
crate::pb::ObjectName { revision: base },
)),
}),
head: Some(crate::pb::ObjectSelector {
selector: Some(crate::pb::object_selector::Selector::Revision(
crate::pb::ObjectName { revision: commit },
)),
}),
options: request.options,
pagination: request.pagination,
})
}
fn first_parent_or_empty_tree(&self, commit: &str) -> GitResult<String> {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"rev-list",
"--parents",
"-n",
"1",
commit,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let output = String::from_utf8_lossy(&result.stdout);
let parts = output.split_whitespace().collect::<Vec<_>>();
if let Some(parent) = parts.get(1) {
return Ok((*parent).to_string());
}
let empty_tree = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"hash-object",
"-t",
"tree",
"-w",
"--stdin",
],
)
.stdin_bytes(Vec::<u8>::new())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !empty_tree.status.success() {
return Err(GitError::CommandFailed {
status_code: empty_tree.status.code(),
stderr: String::from_utf8_lossy(&empty_tree.stderr).into_owned(),
});
}
Ok(String::from_utf8_lossy(&empty_tree.stdout)
.trim()
.to_string())
}
}
+330
View File
@@ -0,0 +1,330 @@
use crate::bare::GitBare;
use crate::diff::get_diff_stats::{diff_stats_for_range, push_diff_options};
use crate::error::{GitError, GitResult};
use crate::paginate;
use crate::pb::diff_file::ChangeType;
use crate::pb::{DiffFile, GetDiffRequest, GetDiffResponse};
#[derive(Debug, Clone)]
struct NameStatusEntry {
status: char,
old_path: String,
new_path: String,
similarity: f64,
}
#[derive(Debug, Clone, Default)]
struct TreeMeta {
oid_hex: String,
mode: u32,
}
impl GitBare {
pub fn get_diff(&self, request: GetDiffRequest) -> GitResult<GetDiffResponse> {
let base = match request.base.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let head = match request.head.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let options = request.options.as_ref();
let entries = self.diff_name_status(&base, &head, options)?;
let max_files = options.and_then(|o| (o.max_files > 0).then_some(o.max_files as usize));
let overflow = max_files.is_some_and(|max| entries.len() > max);
let entries_to_build =
max_files.map_or(entries.as_slice(), |max| &entries[..entries.len().min(max)]);
let mut files = Vec::with_capacity(entries_to_build.len());
for entry in entries_to_build {
let old_meta = if !entry.old_path.is_empty() {
self.tree_meta(&base, &entry.old_path).ok().flatten()
} else {
None
};
let new_meta = if !entry.new_path.is_empty() {
self.tree_meta(&head, &entry.new_path).ok().flatten()
} else {
None
};
let (additions, deletions, binary) = self.path_numstat(&base, &head, entry)?;
let (patch, too_large) = self.path_patch(&base, &head, entry, options)?;
files.push(DiffFile {
old_path: entry.old_path.clone(),
new_path: entry.new_path.clone(),
old_oid: old_meta.as_ref().map(|m| self.oid_to_pb(&m.oid_hex)),
new_oid: new_meta.as_ref().map(|m| self.oid_to_pb(&m.oid_hex)),
old_mode: old_meta.as_ref().map(|m| m.mode).unwrap_or(0),
new_mode: new_meta.as_ref().map(|m| m.mode).unwrap_or(0),
change_type: change_type(entry.status) as i32,
binary,
too_large,
additions,
deletions,
hunks: Vec::new(),
patch,
similarity: entry.similarity,
});
}
let stats = diff_stats_for_range(self, &base, &head, options)?;
let (files, page_info) = paginate::paginate(&files, request.pagination.as_ref());
Ok(GetDiffResponse {
files,
stats: Some(stats),
page_info: Some(page_info),
overflow,
})
}
fn diff_name_status(
&self,
base: &str,
head: &str,
options: Option<&crate::pb::DiffOptions>,
) -> GitResult<Vec<NameStatusEntry>> {
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"diff".into(),
"--name-status".into(),
"-z".into(),
];
push_diff_options(&mut args, options);
args.push(base.to_string());
args.push(head.to_string());
if let Some(options) = options
&& !options.pathspec.is_empty()
{
args.push("--".into());
args.extend(options.pathspec.iter().cloned());
}
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let parts = result
.stdout
.split(|b| *b == 0)
.filter(|part| !part.is_empty())
.map(|part| String::from_utf8_lossy(part).into_owned())
.collect::<Vec<_>>();
let mut entries = Vec::new();
let mut idx = 0;
while idx < parts.len() {
let status_token = &parts[idx];
idx += 1;
let status = status_token.chars().next().unwrap_or('M');
let similarity = status_token
.get(1..)
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
if matches!(status, 'R' | 'C') {
if idx + 1 >= parts.len() {
break;
}
let old_path = parts[idx].clone();
let new_path = parts[idx + 1].clone();
idx += 2;
entries.push(NameStatusEntry {
status,
old_path,
new_path,
similarity,
});
} else {
if idx >= parts.len() {
break;
}
let path = parts[idx].clone();
idx += 1;
let (old_path, new_path) = match status {
'A' => (String::new(), path),
'D' => (path, String::new()),
_ => (path.clone(), path),
};
entries.push(NameStatusEntry {
status,
old_path,
new_path,
similarity,
});
}
}
Ok(entries)
}
fn tree_meta(&self, revision: &str, path: &str) -> GitResult<Option<TreeMeta>> {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"ls-tree",
"-z",
"-l",
revision,
"--",
path,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() || result.stdout.is_empty() {
return Ok(None);
}
let record = result
.stdout
.split(|b| *b == 0)
.find(|part| !part.is_empty())
.map(|part| String::from_utf8_lossy(part).into_owned());
let Some(record) = record else {
return Ok(None);
};
let Some((meta, _path)) = record.split_once('\t') else {
return Ok(None);
};
let parts = meta.split_whitespace().collect::<Vec<_>>();
if parts.len() < 3 {
return Ok(None);
}
Ok(Some(TreeMeta {
mode: u32::from_str_radix(parts[0], 8).unwrap_or(0),
oid_hex: parts[2].to_string(),
}))
}
fn path_numstat(
&self,
base: &str,
head: &str,
entry: &NameStatusEntry,
) -> GitResult<(u32, u32, bool)> {
let path = if entry.new_path.is_empty() {
&entry.old_path
} else {
&entry.new_path
};
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"diff",
"--numstat",
base,
head,
"--",
path,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let line = String::from_utf8_lossy(&result.stdout)
.lines()
.next()
.unwrap_or_default()
.to_string();
let mut parts = line.split('\t');
let add = parts.next().unwrap_or_default();
let del = parts.next().unwrap_or_default();
let binary = add == "-" || del == "-";
Ok((add.parse().unwrap_or(0), del.parse().unwrap_or(0), binary))
}
fn path_patch(
&self,
base: &str,
head: &str,
entry: &NameStatusEntry,
options: Option<&crate::pb::DiffOptions>,
) -> GitResult<(Vec<u8>, bool)> {
let Some(options) = options else {
return Ok((Vec::new(), false));
};
if !options.include_patch {
return Ok((Vec::new(), false));
}
let path = if entry.new_path.is_empty() {
&entry.old_path
} else {
&entry.new_path
};
let context = options.context_lines.to_string();
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"diff".into(),
"--patch".into(),
format!("--unified={context}"),
];
if options.include_binary {
args.push("--binary".into());
}
push_diff_options(&mut args, Some(options));
args.push(base.to_string());
args.push(head.to_string());
args.push("--".into());
args.push(path.to_string());
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let mut patch = result.stdout;
let too_large = options.max_bytes > 0 && patch.len() > options.max_bytes as usize;
if too_large {
patch.truncate(options.max_bytes as usize);
}
Ok((patch, too_large))
}
}
fn change_type(status: char) -> ChangeType {
match status {
'A' => ChangeType::DiffFileChangeTypeAdded,
'D' => ChangeType::DiffFileChangeTypeDeleted,
'R' => ChangeType::DiffFileChangeTypeRenamed,
'C' => ChangeType::DiffFileChangeTypeCopied,
'T' => ChangeType::DiffFileChangeTypeTypeChanged,
'U' => ChangeType::DiffFileChangeTypeUnmerged,
_ => ChangeType::DiffFileChangeTypeModified,
}
}
+108
View File
@@ -0,0 +1,108 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::GetDiffStatsRequest;
impl GitBare {
pub fn get_diff_stats(&self, request: GetDiffStatsRequest) -> GitResult<crate::pb::DiffStats> {
let base = match request.base.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let head = match request.head.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
diff_stats_for_range(self, &base, &head, request.options.as_ref())
}
}
pub(crate) fn diff_stats_for_range(
repo: &GitBare,
base: &str,
head: &str,
options: Option<&crate::pb::DiffOptions>,
) -> GitResult<crate::pb::DiffStats> {
let mut args = vec![
"--git-dir".to_string(),
repo.bare_dir.to_string_lossy().into_owned(),
"diff".into(),
"--shortstat".into(),
];
push_diff_options(&mut args, options);
args.push(base.to_string());
args.push(head.to_string());
if let Some(options) = options
&& !options.pathspec.is_empty()
{
args.push("--".into());
args.extend(options.pathspec.iter().cloned());
}
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
Ok(parse_shortstat(&String::from_utf8_lossy(&result.stdout)))
}
pub(crate) fn parse_shortstat(output: &str) -> crate::pb::DiffStats {
let mut stats = crate::pb::DiffStats::default();
for part in output.trim().split(',') {
let part = part.trim();
if let Some(n) = part
.strip_suffix(" insertion(+)")
.or_else(|| part.strip_suffix(" insertions(+)"))
{
stats.additions = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part
.strip_suffix(" deletion(-)")
.or_else(|| part.strip_suffix(" deletions(-)"))
{
stats.deletions = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part
.strip_suffix(" file changed")
.or_else(|| part.strip_suffix(" files changed"))
{
stats.changed_files = n.trim().parse().unwrap_or(0);
}
}
stats
}
pub(crate) fn push_diff_options(args: &mut Vec<String>, options: Option<&crate::pb::DiffOptions>) {
let Some(options) = options else {
return;
};
if options.rename_detection {
args.push("-M".into());
}
if options.copy_detection {
args.push("-C".into());
}
match crate::pb::diff_options::WhitespaceMode::try_from(options.whitespace_mode)
.unwrap_or(crate::pb::diff_options::WhitespaceMode::DiffWhitespaceModeDefault)
{
crate::pb::diff_options::WhitespaceMode::DiffWhitespaceModeIgnoreAll => {
args.push("-w".into())
}
crate::pb::diff_options::WhitespaceMode::DiffWhitespaceModeIgnoreChange => {
args.push("-b".into())
}
crate::pb::diff_options::WhitespaceMode::DiffWhitespaceModeIgnoreEol => {
args.push("--ignore-space-at-eol".into());
}
_ => {}
}
}
+42
View File
@@ -0,0 +1,42 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{GetPatchRequest, GetPatchResponse};
impl GitBare {
pub fn get_patch(&self, request: GetPatchRequest) -> GitResult<Vec<GetPatchResponse>> {
let base = match request.base.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let head = match request.head.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"diff",
"--patch",
&base,
&head,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
Ok(vec![GetPatchResponse {
data: result.stdout,
}])
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod get_commit_diff;
pub mod get_diff;
pub mod get_diff_stats;
pub mod get_patch;
+78
View File
@@ -0,0 +1,78 @@
pub type GitResult<T> = Result<T, GitError>;
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("repository is not bare")]
NotBareRepository,
#[error("git command failed with status {status_code:?}: {stderr}")]
CommandFailed {
status_code: Option<i32>,
stderr: String,
},
#[error("unsafe git command rejected: {0}")]
UnsafeCommand(String),
#[error("object not found: {0}")]
ObjectNotFound(String),
#[error("reference not found: {0}")]
RefNotFound(String),
#[error("parse error: {0}")]
ParseError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("gix error: {0}")]
Gix(String),
#[error("repository not found")]
RepoNotFound,
#[error("internal error: {0}")]
Internal(String),
#[error("not found: {0}")]
NotFound(String),
#[error("invalid oid: {0}")]
InvalidOid(String),
#[error("locked: {0}")]
Locked(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("authentication failed: {0}")]
AuthFailed(String),
#[error("payload too large: {0}")]
PayloadTooLarge(String),
#[error("invalid argument: {0}")]
InvalidArgument(String),
}
macro_rules! impl_gix_error {
($err_type:path) => {
impl From<$err_type> for GitError {
fn from(e: $err_type) -> Self {
GitError::Gix(e.to_string())
}
}
};
}
impl_gix_error!(gix::object::find::existing::Error);
impl_gix_error!(gix::object::find::existing::with_conversion::Error);
impl_gix_error!(gix::object::find::Error);
impl_gix_error!(gix::reference::iter::Error);
impl_gix_error!(gix::reference::iter::init::Error);
impl_gix_error!(gix::reference::find::existing::Error);
impl_gix_error!(gix::reference::find::Error);
impl_gix_error!(gix::reference::head_id::Error);
impl_gix_error!(gix::repository::merge_bases_many::Error);
impl_gix_error!(gix::reference::peel::Error);
impl_gix_error!(gix::repository::blame_file::Error);
impl_gix_error!(gix::blame::Error);
impl_gix_error!(gix::revision::walk::Error);
impl_gix_error!(gix::revision::walk::iter::Error);
impl_gix_error!(gix::revision::spec::parse::single::Error);
impl_gix_error!(gix::open::Error);
impl_gix_error!(gix::objs::decode::Error);
impl_gix_error!(gix::date::Error);
impl_gix_error!(gix::repository::diff_tree_to_tree::Error);
impl From<Box<dyn std::error::Error + Send + Sync>> for GitError {
fn from(e: Box<dyn std::error::Error + Send + Sync>) -> Self {
GitError::Gix(e.to_string())
}
}
+24
View File
@@ -0,0 +1,24 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
impl GitBare {
pub fn init_repository(&self, bare: bool) -> GitResult<()> {
let mut args = vec!["init".to_string()];
if bare {
args.push("--bare".into());
}
args.push(self.bare_dir.to_string_lossy().into_owned());
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
Ok(())
}
}
+17
View File
@@ -0,0 +1,17 @@
pub mod archive;
pub mod bare;
pub mod blame;
pub mod blob;
pub mod branch;
pub mod commit;
pub mod diff;
pub mod error;
pub mod init;
pub mod merge;
pub mod oid;
pub mod pack;
pub mod paginate;
pub mod pb;
pub mod refs;
pub mod tag;
pub mod tree;
+111
View File
@@ -0,0 +1,111 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{CheckMergeRequest, MergeResult, merge_result};
impl GitBare {
pub fn check_merge(&self, request: CheckMergeRequest) -> GitResult<MergeResult> {
let target = match request.target.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let source = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let repo = self.gix_repo()?;
let target_id = repo.rev_parse_single(target.as_str())?;
let source_id = repo.rev_parse_single(source.as_str())?;
if target_id == source_id {
return Ok(MergeResult {
status: merge_result::Status::MergeResultStatusAlreadyUpToDate as i32,
commit: None,
merge_base: Some(self.oid_to_pb(target_id.to_string())),
conflicts: vec![],
stats: None,
message: String::from("Already up to date"),
});
}
let merge_base = repo
.merge_base(target_id.detach(), source_id.detach())
.ok()
.map(|id| id.to_string());
if let Some(ref base) = merge_base {
if *base == target_id.to_string() {
return Ok(MergeResult {
status: merge_result::Status::MergeResultStatusFastForward as i32,
commit: None,
merge_base: Some(self.oid_to_pb(base.clone())),
conflicts: vec![],
stats: None,
message: String::from("Fast-forward"),
});
}
if *base == source_id.to_string() {
return Ok(MergeResult {
status: merge_result::Status::MergeResultStatusAlreadyUpToDate as i32,
commit: None,
merge_base: Some(self.oid_to_pb(base.clone())),
conflicts: vec![],
stats: None,
message: String::from("Already up to date"),
});
}
}
let merge_base_oid = merge_base.as_ref().map(|b| self.oid_to_pb(b.clone()));
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"merge-tree",
"--write-tree",
"--no-messages",
"-z",
&target,
&source,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let stdout = String::from_utf8_lossy(&result.stdout).into_owned();
if result.status.success() {
Ok(MergeResult {
status: merge_result::Status::MergeResultStatusMerged as i32,
commit: None,
merge_base: merge_base_oid,
conflicts: vec![],
stats: None,
message: stdout.trim().to_string(),
})
} else {
let conflicts = stdout
.split('\0')
.filter(|s| !s.is_empty())
.map(|path| crate::pb::MergeConflict {
path: path.to_string(),
..Default::default()
})
.collect();
Ok(MergeResult {
status: merge_result::Status::MergeResultStatusConflicts as i32,
commit: None,
merge_base: merge_base_oid,
conflicts,
stats: None,
message: String::from("Merge conflicts detected"),
})
}
}
}
+173
View File
@@ -0,0 +1,173 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{MergeRequest, MergeResult, merge_result};
impl GitBare {
pub fn merge(&self, request: MergeRequest) -> GitResult<MergeResult> {
let target_branch = request.target_branch.clone();
let source_revision = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => return Err(GitError::InvalidArgument("source is required".into())),
};
let repo = self.gix_repo()?;
let branch_ref = format!("refs/heads/{}", target_branch);
let target_id = repo
.find_reference(branch_ref.as_str())
.ok()
.and_then(|mut r| r.peel_to_id().ok())
.map(|id| id.to_string())
.ok_or_else(|| GitError::RefNotFound(target_branch.clone()))?;
let source_id = repo.rev_parse_single(source_revision.as_str())?.to_string();
if target_id == source_id {
return Ok(MergeResult {
status: merge_result::Status::MergeResultStatusAlreadyUpToDate as i32,
commit: None,
merge_base: Some(self.oid_to_pb(target_id)),
conflicts: vec![],
stats: None,
message: String::from("Already up to date"),
});
}
let target_oid = gix::hash::ObjectId::from_hex(target_id.as_bytes())
.map_err(|e| GitError::InvalidOid(e.to_string()))?;
let source_oid = gix::hash::ObjectId::from_hex(source_id.as_bytes())
.map_err(|e| GitError::InvalidOid(e.to_string()))?;
let merge_base = repo
.merge_base(target_oid, source_oid)
.ok()
.map(|id| id.to_string());
let ff_mode = request
.options
.as_ref()
.map(|o| o.fast_forward)
.unwrap_or(0);
let ff_only =
ff_mode == crate::pb::merge_options::FastForwardMode::MergeFastForwardModeOnly as i32;
if let Some(ref base) = merge_base {
if *base == target_id {
let no_ff = ff_mode
== crate::pb::merge_options::FastForwardMode::MergeFastForwardModeNoFf as i32;
if !no_ff {
self.update_branch_ref(&target_branch, &source_id, Some(&target_id), false)?;
return Ok(MergeResult {
status: merge_result::Status::MergeResultStatusFastForward as i32,
commit: None,
merge_base: Some(self.oid_to_pb(base.clone())),
conflicts: vec![],
stats: None,
message: format!("Fast-forward to {}", source_id),
});
}
}
if *base == source_id {
return Ok(MergeResult {
status: merge_result::Status::MergeResultStatusAlreadyUpToDate as i32,
commit: None,
merge_base: Some(self.oid_to_pb(base.clone())),
conflicts: vec![],
stats: None,
message: String::from("Already up to date"),
});
}
}
if ff_only {
return Ok(MergeResult {
status: merge_result::Status::MergeResultStatusAborted as i32,
commit: None,
merge_base: merge_base.map(|b| self.oid_to_pb(b)),
conflicts: vec![],
stats: None,
message: String::from("Not possible to fast-forward"),
});
}
let bare = self.bare_dir.to_string_lossy().into_owned();
let result = duct::cmd(
"git",
[
"--git-dir",
bare.as_str(),
"merge-tree",
"--write-tree",
"--no-messages",
"-z",
target_id.as_str(),
source_id.as_str(),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let stdout = String::from_utf8_lossy(&result.stdout).into_owned();
if result.status.success() {
let merged_tree = stdout.trim().to_string();
let message = if !request.message.is_empty() {
request.message.clone()
} else {
format!("Merge '{}' into {}", source_revision, target_branch)
};
let parents = vec![target_id.clone(), source_id.clone()];
let commit_id = self.commit_tree(
&merged_tree,
&parents,
&message,
request.committer.as_ref(),
request.committer.as_ref(),
)?;
self.update_branch_ref(&target_branch, &commit_id, Some(&target_id), false)?;
Ok(MergeResult {
status: merge_result::Status::MergeResultStatusMerged as i32,
commit: Some(self.get_commit(crate::pb::GetCommitRequest {
repository: request.repository,
revision: Some(crate::pb::ObjectSelector {
selector: Some(crate::pb::object_selector::Selector::Revision(
crate::pb::ObjectName {
revision: commit_id,
},
)),
}),
include_stats: false,
include_raw: false,
})?),
merge_base: merge_base.map(|b| self.oid_to_pb(b)),
conflicts: vec![],
stats: None,
message,
})
} else {
let conflicts = stdout
.split('\0')
.filter(|s| !s.is_empty())
.map(|path| crate::pb::MergeConflict {
path: path.to_string(),
..Default::default()
})
.collect();
Ok(MergeResult {
status: merge_result::Status::MergeResultStatusConflicts as i32,
commit: None,
merge_base: merge_base.map(|b| self.oid_to_pb(b)),
conflicts,
stats: None,
message: String::from("Merge conflicts detected"),
})
}
}
}
+69
View File
@@ -0,0 +1,69 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::paginate;
use crate::pb::{ListMergeConflictsRequest, ListMergeConflictsResponse, MergeConflict};
impl GitBare {
pub fn list_merge_conflicts(
&self,
request: ListMergeConflictsRequest,
) -> GitResult<ListMergeConflictsResponse> {
let target = match request.target.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => return Err(GitError::InvalidArgument("target is required".into())),
};
let source = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => return Err(GitError::InvalidArgument("source is required".into())),
};
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"merge-tree",
"--write-tree",
"--name-only",
"-z",
target.as_str(),
source.as_str(),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let stdout = String::from_utf8_lossy(&result.stdout);
if result.status.success() {
return Ok(ListMergeConflictsResponse {
conflicts: vec![],
page_info: Some(crate::pb::PageInfo {
next_page_token: String::new(),
has_next_page: false,
total_count: 0,
}),
});
}
let mut conflicts: Vec<MergeConflict> = stdout
.split('\0')
.filter(|s| !s.is_empty())
.map(|path| MergeConflict {
path: path.to_string(),
..Default::default()
})
.collect();
paginate::apply_sort(&mut conflicts, 0);
let (conflicts, page_info) = paginate::paginate(&conflicts, request.pagination.as_ref());
Ok(ListMergeConflictsResponse {
conflicts,
page_info: Some(page_info),
})
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod check_merge;
pub mod do_merge;
pub mod list_merge_conflicts;
pub mod rebase;
pub mod resolve_merge_conflicts;
+192
View File
@@ -0,0 +1,192 @@
use crate::bare::GitBare;
use crate::commit::create_commit::command_ok;
use crate::error::{GitError, GitResult};
use crate::pb::{RebaseRequest, RebaseResult, rebase_result};
impl GitBare {
pub fn rebase(&self, request: RebaseRequest) -> GitResult<RebaseResult> {
let branch = request.branch.clone();
let upstream_revision = match request.upstream.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => return Err(GitError::InvalidArgument("upstream is required".into())),
};
let repo = self.gix_repo()?;
let branch_ref = format!("refs/heads/{}", branch);
let branch_tip = repo
.find_reference(branch_ref.as_str())
.ok()
.and_then(|mut r| r.peel_to_id().ok())
.map(|id| id.to_string())
.ok_or_else(|| GitError::RefNotFound(branch.clone()))?;
let upstream_id = repo
.rev_parse_single(upstream_revision.as_str())?
.to_string();
if branch_tip == upstream_id {
return Ok(RebaseResult {
status: rebase_result::Status::RebaseResultStatusAlreadyUpToDate as i32,
head: None,
conflicts: vec![],
});
}
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"rev-list",
"--reverse",
&format!("{}..{}", upstream_id, branch_tip),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
let commits: Vec<String> = String::from_utf8_lossy(&result.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
if commits.is_empty() {
return Ok(RebaseResult {
status: rebase_result::Status::RebaseResultStatusAlreadyUpToDate as i32,
head: None,
conflicts: vec![],
});
}
let mut current_tip = upstream_id.clone();
for commit_hex in &commits {
current_tip =
self.rebase_one_commit(commit_hex, &current_tip, request.committer.as_ref())?;
}
self.update_branch_ref(&branch, &current_tip, Some(&branch_tip), false)?;
Ok(RebaseResult {
status: rebase_result::Status::RebaseResultStatusRebased as i32,
head: Some(self.get_commit(crate::pb::GetCommitRequest {
repository: request.repository,
revision: Some(crate::pb::ObjectSelector {
selector: Some(crate::pb::object_selector::Selector::Revision(
crate::pb::ObjectName {
revision: current_tip,
},
)),
}),
include_stats: false,
include_raw: false,
})?),
conflicts: vec![],
})
}
fn rebase_one_commit(
&self,
commit_hex: &str,
new_parent: &str,
committer: Option<&crate::pb::Signature>,
) -> GitResult<String> {
let repo = self.gix_repo()?;
let id = repo.rev_parse_single(commit_hex)?;
let obj = id
.object()?
.try_into_commit()
.map_err(|e| GitError::Gix(e.to_string()))?;
let message = obj.message_raw()?.to_string();
let author = obj.author().ok();
let bare = self.bare_dir.to_string_lossy().into_owned();
let tmp_index = tempfile::Builder::new()
.prefix("gitks-rebase-")
.tempfile_in(&self.bare_dir)?;
let idx_path = tmp_index.path().to_string_lossy().into_owned();
let read_tree = duct::cmd("git", ["--git-dir", bare.as_str(), "read-tree", new_parent])
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(read_tree)?;
let diff = duct::cmd(
"git",
[
"--git-dir",
bare.as_str(),
"format-patch",
"--stdout",
"--full-index",
"--binary",
"-1",
commit_hex,
],
)
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let patch_data = command_ok(diff)?;
let apply = duct::cmd(
"git",
[
"--git-dir",
bare.as_str(),
"apply",
"--cached",
"--allow-empty",
"-",
],
)
.env("GIT_INDEX_FILE", &idx_path)
.stdin_bytes(patch_data.as_bytes())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !apply.status.success() {
return Err(GitError::Internal(format!(
"rebase apply failed for {}: {}",
commit_hex,
String::from_utf8_lossy(&apply.stderr)
)));
}
let write_tree = duct::cmd("git", ["--git-dir", bare.as_str(), "write-tree"])
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let tree_id = command_ok(write_tree)?.trim().to_string();
let parents = vec![new_parent.to_string()];
self.commit_tree(
&tree_id,
&parents,
&message,
author
.as_ref()
.map(|a| crate::commit::get_commit::gix_sig_to_pb(a))
.as_ref(),
committer,
)
}
}
+128
View File
@@ -0,0 +1,128 @@
use crate::bare::GitBare;
use crate::commit::create_commit::command_ok;
use crate::error::{GitError, GitResult};
use crate::pb::{MergeResult, ResolveMergeConflictsRequest, merge_result};
impl GitBare {
pub fn resolve_merge_conflicts(
&self,
request: ResolveMergeConflictsRequest,
) -> GitResult<MergeResult> {
let target_branch = request.target_branch.clone();
let source_revision = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => return Err(GitError::InvalidArgument("source is required".into())),
};
let repo = self.gix_repo()?;
let branch_ref = format!("refs/heads/{}", target_branch);
let target_id = repo
.find_reference(branch_ref.as_str())
.ok()
.and_then(|mut r| r.peel_to_id().ok())
.map(|id| id.to_string())
.ok_or_else(|| GitError::RefNotFound(target_branch.clone()))?;
let source_id = repo.rev_parse_single(source_revision.as_str())?.to_string();
let bare = self.bare_dir.to_string_lossy().into_owned();
let tmp_index = tempfile::Builder::new()
.prefix("gitks-resolve-")
.tempfile_in(&self.bare_dir)?;
let idx_path = tmp_index.path().to_string_lossy().into_owned();
let read_tree = duct::cmd(
"git",
["--git-dir", bare.as_str(), "read-tree", target_id.as_str()],
)
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(read_tree)?;
for resolution in &request.resolutions {
let hash = duct::cmd(
"git",
["--git-dir", bare.as_str(), "hash-object", "-w", "--stdin"],
)
.stdin_bytes(resolution.content.clone())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let blob = command_ok(hash)?.trim().to_string();
let update = duct::cmd(
"git",
[
"--git-dir",
bare.as_str(),
"update-index",
"--add",
"--cacheinfo",
"100644",
&blob,
&resolution.path,
],
)
.env("GIT_INDEX_FILE", &idx_path)
.env("GIT_WORK_TREE", bare.as_str())
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
command_ok(update)?;
}
let write_tree = duct::cmd("git", ["--git-dir", bare.as_str(), "write-tree"])
.env("GIT_INDEX_FILE", &idx_path)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let tree_id = command_ok(write_tree)?.trim().to_string();
let message = if !request.message.is_empty() {
request.message.clone()
} else {
format!(
"Merge '{}' into {} (resolved conflicts)",
source_revision, target_branch
)
};
let parents = vec![target_id.clone(), source_id.clone()];
let commit_id = self.commit_tree(
&tree_id,
&parents,
&message,
request.committer.as_ref(),
request.committer.as_ref(),
)?;
self.update_branch_ref(&target_branch, &commit_id, Some(&target_id), false)?;
Ok(MergeResult {
status: merge_result::Status::MergeResultStatusMerged as i32,
commit: Some(self.get_commit(crate::pb::GetCommitRequest {
repository: request.repository,
revision: Some(crate::pb::ObjectSelector {
selector: Some(crate::pb::object_selector::Selector::Revision(
crate::pb::ObjectName {
revision: commit_id,
},
)),
}),
include_stats: false,
include_raw: false,
})?),
merge_base: None,
conflicts: vec![],
stats: None,
message,
})
}
}
+53
View File
@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
/// The null OID representing "no object" in git protocol.
pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
pub struct ObjectId(pub String);
impl ObjectId {
pub fn new(hex: impl AsRef<str>) -> Self {
Self(hex.as_ref().to_lowercase())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ObjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for ObjectId {
fn as_ref(&self) -> &str {
&self.0
}
}
pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, crate::error::GitError> {
let hex = hex.trim();
if !hex.len().is_multiple_of(2) {
return Err(crate::error::GitError::InvalidOid(
"hex oid has odd length".into(),
));
}
(0..hex.len())
.step_by(2)
.map(|idx| {
u8::from_str_radix(&hex[idx..idx + 2], 16)
.map_err(|e| crate::error::GitError::InvalidOid(e.to_string()))
})
.collect()
}
impl TryFrom<&ObjectId> for gix::hash::ObjectId {
type Error = crate::error::GitError;
fn try_from(id: &ObjectId) -> Result<Self, Self::Error> {
gix::hash::ObjectId::from_hex(id.as_str().as_bytes())
.map_err(|e| crate::error::GitError::InvalidOid(format!("invalid hex oid: {e}")))
}
}
+57
View File
@@ -0,0 +1,57 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{AdvertiseRefsRequest, AdvertiseRefsResponse, ReferenceAdvertisement};
impl GitBare {
pub fn advertise_refs(
&self,
_request: AdvertiseRefsRequest,
) -> GitResult<AdvertiseRefsResponse> {
let repo = self.gix_repo()?;
let mut references = Vec::new();
for r in repo.references()?.all()? {
let mut r = match r {
Ok(r) => r,
Err(_) => continue,
};
let name = r.name().to_string();
let target_oid = r.peel_to_id().ok().map(|id| self.oid_to_pb(id.to_string()));
let is_symbolic = r.target().try_id().is_none();
let symbolic_target = if is_symbolic {
match r.target() {
gix::refs::TargetRef::Symbolic(name) => name.to_string(),
_ => String::new(),
}
} else {
String::new()
};
// Peel past tags to get the commit OID if this is a tag ref
let peeled_oid = if name.starts_with("refs/tags/") {
r.peel_to_id().ok().map(|id| self.oid_to_pb(id.to_string()))
} else {
None
};
references.push(ReferenceAdvertisement {
name,
target_oid,
peeled_oid,
symbolic: is_symbolic,
symbolic_target,
});
}
// Sort by name for deterministic output
references.sort_by(|a, b| a.name.cmp(&b.name));
Ok(AdvertiseRefsResponse {
references,
capabilities: vec![
"report-status".into(),
"delete-refs".into(),
"side-band-64k".into(),
"ofs-delta".into(),
"multi_ack_detailed".into(),
"multi_ack".into(),
"symref=HEAD".into(),
],
})
}
}
+45
View File
@@ -0,0 +1,45 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{FsckRequest, FsckResponse};
impl GitBare {
pub fn fsck(&self, request: FsckRequest) -> GitResult<FsckResponse> {
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"fsck".to_string(),
];
if request.strict {
args.push("--strict".into());
}
if request.connectivity_only {
args.push("--connectivity-only".into());
}
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
let ok = result.status.success();
let mut errors = Vec::new();
let mut warnings = Vec::new();
for line in stdout.lines().chain(stderr.lines()) {
if line.contains("error:") || line.contains("fatal:") {
errors.push(
line.trim_start_matches("error: ")
.trim_start_matches("fatal: ")
.to_string(),
);
} else if line.contains("warning:") {
warnings.push(line.trim_start_matches("warning: ").to_string());
}
}
Ok(FsckResponse {
ok,
errors,
warnings,
})
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::io::Write;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{IndexPackRequest, IndexPackResponse};
impl GitBare {
/// Index a pack file from streamed input.
///
/// Client-streaming → unary response.
/// Collects all input chunks into a single pack, then runs `git index-pack`.
pub fn index_pack(&self, inputs: Vec<IndexPackRequest>) -> GitResult<IndexPackResponse> {
// Reassemble all chunks into a single pack data buffer
let mut pack_data = Vec::new();
let mut strict = false;
let mut keep = false;
for input in &inputs {
pack_data.extend_from_slice(&input.data);
if input.strict {
strict = true;
}
if input.keep {
keep = true;
}
}
if pack_data.is_empty() {
return Err(GitError::InvalidArgument("empty pack data".into()));
}
let pack_dir = self.bare_dir.join("objects").join("pack");
std::fs::create_dir_all(&pack_dir).map_err(GitError::Io)?;
// Write pack data to a unique temp file in the pack directory.
let mut tmp_file = tempfile::Builder::new()
.prefix("tmp_index_pack_")
.tempfile_in(&pack_dir)
.map_err(GitError::Io)?;
tmp_file.write_all(&pack_data).map_err(GitError::Io)?;
let tmp_path = tmp_file.path().to_path_buf();
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"index-pack".to_string(),
];
if strict {
args.push("--strict".into());
}
if keep {
args.push("--keep".into());
}
args.push(tmp_path.to_string_lossy().into_owned());
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
drop(tmp_file);
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
// Parse the output to extract the pack hash
let output = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
let all_output = format!("{output}\n{stderr}");
// git index-pack outputs the .idx and .pack filenames
// e.g. "... pack-<hex>.pack ... pack-<hex>.idx"
let pack_hash = all_output
.lines()
.filter_map(|line| {
// Look for the hash after "pack-" and before ".idx" or ".pack"
let trimmed = line.trim();
if let Some(idx) = trimmed.find("pack-") {
let rest = &trimmed[idx + 5..];
if let Some(end) = rest.find('.') {
let hex = &rest[..end];
if hex.len() == 40 || hex.len() == 64 {
return Some(hex.to_string());
}
}
}
None
})
.next();
// Try to get object count from .idx if it exists
let mut object_count = 0u64;
if let Some(ref hash) = pack_hash {
let idx_path = pack_dir.join(format!("pack-{}.idx", hash));
if idx_path.exists() {
let verify = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"verify-pack",
"-v",
idx_path.to_string_lossy().as_ref(),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run();
if let Ok(v) = verify {
let out = String::from_utf8_lossy(&v.stdout);
object_count = out
.lines()
.filter(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
parts.len() >= 3
&& parts
.first()
.map(|s| s.len() == 40 || s.len() == 64)
.unwrap_or(false)
})
.count() as u64;
}
}
}
Ok(IndexPackResponse {
pack_hash: pack_hash.map(|h| self.oid_to_pb(h)),
object_count,
stderr: stderr.into_owned(),
})
}
}
+92
View File
@@ -0,0 +1,92 @@
use crate::bare::GitBare;
use crate::error::GitError;
use crate::paginate;
use crate::pb::{ListPackfilesRequest, ListPackfilesResponse, PackfileInfo};
impl GitBare {
pub fn list_packfiles(
&self,
request: ListPackfilesRequest,
) -> crate::error::GitResult<ListPackfilesResponse> {
let pack_dir = self.bare_dir.join("objects").join("pack");
let mut packfiles = Vec::new();
if pack_dir.exists() {
for entry in std::fs::read_dir(&pack_dir).map_err(GitError::Io)? {
let entry = entry.map_err(GitError::Io)?;
let name = entry.file_name().to_string_lossy().into_owned();
if !name.ends_with(".pack") {
continue;
}
let metadata = entry.metadata().map_err(GitError::Io)?;
let base_name = name.trim_end_matches(".pack");
let idx_name = format!("{base_name}.idx");
let bmp_name = format!("{base_name}.bitmap");
let rev_name = format!("{base_name}.rev");
let keep_name = format!("{base_name}.keep");
let pack_hash = base_name
.strip_prefix("pack-")
.filter(|hex| !hex.is_empty())
.map(|hex| self.oid_to_pb(hex));
// Count objects
let mut object_count = 0u64;
if let Some(hash_str) = base_name.strip_prefix("pack-") {
let idx_path = pack_dir.join(format!("pack-{hash_str}.idx"));
if idx_path.exists() {
let verify = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"verify-pack",
"-v",
idx_path.to_string_lossy().as_ref(),
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run();
if let Ok(v) = verify {
let out = String::from_utf8_lossy(&v.stdout);
object_count = out
.lines()
.filter(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
parts.len() >= 3
&& parts
.first()
.map(|s| s.len() == 40 || s.len() == 64)
.unwrap_or(false)
})
.count() as u64;
}
}
}
packfiles.push(PackfileInfo {
name,
pack_hash,
size_bytes: metadata.len(),
index_size_bytes: pack_dir
.join(&idx_name)
.metadata()
.map(|m| m.len())
.unwrap_or(0),
object_count,
has_bitmap: pack_dir.join(&bmp_name).exists(),
has_rev_index: pack_dir.join(&rev_name).exists(),
kept: pack_dir.join(&keep_name).exists(),
});
}
}
packfiles.sort_by(|a, b| a.name.cmp(&b.name));
let (packfiles, page_info) = paginate::paginate(&packfiles, request.pagination.as_ref());
Ok(ListPackfilesResponse {
packfiles,
page_info: Some(page_info),
})
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod advertise_refs;
pub mod fsck;
pub mod index_pack;
pub mod list_packfiles;
pub mod pack_objects;
pub mod receive_pack;
pub mod upload_pack;
+160
View File
@@ -0,0 +1,160 @@
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tokio_stream::wrappers::ReceiverStream;
use crate::bare::GitBare;
use crate::pb::PackfileChunk;
impl GitBare {
/// Pack objects using git-pack-objects --stdout.
///
/// Unary request → server-streaming response.
/// The returned stream yields `PackfileChunk` chunks as pack data is produced.
pub async fn pack_objects(
&self,
request: crate::pb::PackObjectsRequest,
) -> Result<ReceiverStream<Result<PackfileChunk, tonic::Status>>, tonic::Status> {
let bare_dir = self.bare_dir.clone();
let bare_dir_str = bare_dir.to_string_lossy().into_owned();
let (tx, rx) = tokio::sync::mpsc::channel(8);
tokio::spawn(async move {
let opts = request.options.as_ref();
let has_wants = opts.is_some_and(|o| !o.wants.is_empty());
let mut args = vec![
"--git-dir".to_string(),
bare_dir_str,
"pack-objects".to_string(),
"--stdout".to_string(),
];
// --all is mutually exclusive with explicit revision selection.
if !has_wants {
args.push("--all".into());
} else {
args.push("--revs".into());
}
if opts.is_some_and(|o| o.thin_pack) {
args.push("--thin".into());
}
if opts.is_some_and(|o| !o.use_bitmaps) {
args.push("--no-use-bitmaps".into());
}
if opts.is_some_and(|o| o.delta_base_offset) {
args.push("--delta-base-offset".into());
}
let stdin_data = generate_pack_input(&request);
let mut child = match Command::new("git")
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = tx
.send(Err(tonic::Status::internal(format!(
"failed to spawn git pack-objects: {e}"
))))
.await;
return;
}
};
let mut stdin = child.stdin.take();
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
let stdin_task = async move {
if let Some(mut stdin) = stdin.take() {
let _ = stdin.write_all(&stdin_data).await;
}
};
let stdout_task = {
let tx = tx.clone();
async move {
if let Some(mut stdout) = stdout.take() {
let mut buf = vec![0u8; 65536];
loop {
match stdout.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if tx
.send(Ok(PackfileChunk {
data: buf[..n].to_vec(),
}))
.await
.is_err()
{
break;
}
}
Err(e) => {
let _ = tx
.send(Err(tonic::Status::internal(format!(
"read error: {e}"
))))
.await;
break;
}
}
}
}
}
};
let stderr_task = {
let tx = tx.clone();
async move {
if let Some(mut stderr) = stderr.take() {
let mut s = String::new();
if stderr.read_to_string(&mut s).await.is_ok() && !s.is_empty() {
let _ = tx.send(Err(tonic::Status::internal(s))).await;
}
}
}
};
tokio::join!(stdin_task, stdout_task, stderr_task);
match child.wait().await {
Ok(status) if !status.success() => {
let _ = tx
.send(Err(tonic::Status::internal(
"git pack-objects exited with error",
)))
.await;
}
Err(e) => {
let _ = tx
.send(Err(tonic::Status::internal(format!("wait error: {e}"))))
.await;
}
_ => {}
}
});
Ok(ReceiverStream::new(rx))
}
}
fn generate_pack_input(req: &crate::pb::PackObjectsRequest) -> Vec<u8> {
let mut input = String::new();
if let Some(opts) = req.options.as_ref() {
for want in &opts.wants {
input.push_str(&format!("{}\n", want.hex));
}
for have in &opts.haves {
input.push_str(&format!("^{}\n", have.hex));
}
}
input.into_bytes()
}
+142
View File
@@ -0,0 +1,142 @@
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use crate::bare::GitBare;
use crate::pb::ReceivePackResponse;
impl GitBare {
/// Receive pack data using git-receive-pack with true concurrent streaming.
///
/// Client-streaming input → server-streaming output.
/// Stdin packets are forwarded to the child process as they arrive from the client,
/// while stdout is concurrently read and streamed back via a channel.
pub async fn receive_pack(
&self,
input: impl tokio_stream::Stream<Item = Result<crate::pb::ReceivePackRequest, tonic::Status>>
+ Send
+ 'static,
) -> Result<ReceiverStream<Result<ReceivePackResponse, tonic::Status>>, tonic::Status> {
let bare_dir = self.bare_dir.to_string_lossy().into_owned();
let (tx, rx) = tokio::sync::mpsc::channel(16);
let stream = Box::pin(input);
tokio::spawn(async move {
let stream = stream;
let mut child = match Command::new("git")
.arg("--git-dir")
.arg(&bare_dir)
.arg("receive-pack")
.arg(&bare_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = tx
.send(Err(tonic::Status::internal(format!(
"failed to spawn git receive-pack: {e}"
))))
.await;
return;
}
};
let mut stdin = child.stdin.take();
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
let stdin_task = {
let mut stream = stream;
async move {
if let Some(mut stdin) = stdin.take() {
while let Some(result) = stream.next().await {
match result {
Ok(req) => {
if stdin.write_all(&req.packet).await.is_err() {
break;
}
if req.done {
break;
}
}
Err(_) => break,
}
}
drop(stdin);
}
}
};
let stdout_task = {
let tx = tx.clone();
async move {
if let Some(mut stdout) = stdout.take() {
let mut buf = vec![0u8; 65536];
loop {
match stdout.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if tx
.send(Ok(ReceivePackResponse {
packet: buf[..n].to_vec(),
stderr: String::new(),
}))
.await
.is_err()
{
break;
}
}
Err(_) => break,
}
}
}
}
};
let stderr_task = {
let tx = tx.clone();
async move {
if let Some(mut stderr) = stderr.take() {
let mut s = String::new();
if stderr.read_to_string(&mut s).await.is_ok() && !s.is_empty() {
let _ = tx
.send(Ok(ReceivePackResponse {
packet: Vec::new(),
stderr: s,
}))
.await;
}
}
}
};
tokio::join!(stdin_task, stdout_task, stderr_task);
match child.wait().await {
Ok(status) if !status.success() => {
let _ = tx
.send(Err(tonic::Status::internal(
"git receive-pack exited with error",
)))
.await;
}
Err(e) => {
let _ = tx
.send(Err(tonic::Status::internal(format!("wait error: {e}"))))
.await;
}
_ => {}
}
});
Ok(ReceiverStream::new(rx))
}
}
+147
View File
@@ -0,0 +1,147 @@
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use crate::bare::GitBare;
use crate::pb::UploadPackResponse;
impl GitBare {
/// Upload pack data using git-upload-pack with true concurrent streaming.
///
/// Client-streaming input → server-streaming output.
/// Stdin packets are forwarded to the child process as they arrive from the client,
/// while stdout is concurrently read and streamed back via a channel.
pub async fn upload_pack(
&self,
input: impl tokio_stream::Stream<Item = Result<crate::pb::UploadPackRequest, tonic::Status>>
+ Send
+ 'static,
) -> Result<ReceiverStream<Result<UploadPackResponse, tonic::Status>>, tonic::Status> {
let bare_dir = self.bare_dir.to_string_lossy().into_owned();
let (tx, rx) = tokio::sync::mpsc::channel(16);
// Move input into the spawned task to make it 'static
let stream = Box::pin(input);
tokio::spawn(async move {
let stream = stream;
let mut child = match Command::new("git")
.arg("--git-dir")
.arg(&bare_dir)
.arg("upload-pack")
.arg(&bare_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = tx
.send(Err(tonic::Status::internal(format!(
"failed to spawn git upload-pack: {e}"
))))
.await;
return;
}
};
let mut stdin = child.stdin.take();
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
// Concurrent: write stdin packets, read stdout chunks, read stderr
let stdin_task = {
let mut stream = stream;
async move {
if let Some(mut stdin) = stdin.take() {
while let Some(result) = stream.next().await {
match result {
Ok(req) => {
if stdin.write_all(&req.packet).await.is_err() {
break;
}
if req.done {
break;
}
}
Err(_) => break,
}
}
// Close stdin to signal end-of-input
drop(stdin);
}
}
};
let stdout_task = {
let tx = tx.clone();
async move {
if let Some(mut stdout) = stdout.take() {
let mut buf = vec![0u8; 65536];
loop {
match stdout.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if tx
.send(Ok(UploadPackResponse {
packet: buf[..n].to_vec(),
stderr: String::new(),
}))
.await
.is_err()
{
break;
}
}
Err(_) => break,
}
}
}
}
};
let stderr_task = {
let tx = tx.clone();
async move {
if let Some(mut stderr) = stderr.take() {
let mut s = String::new();
if stderr.read_to_string(&mut s).await.is_ok() && !s.is_empty() {
let _ = tx
.send(Ok(UploadPackResponse {
packet: Vec::new(),
stderr: s,
}))
.await;
}
}
}
};
// Run all three concurrently
tokio::join!(stdin_task, stdout_task, stderr_task);
// Wait for child exit
match child.wait().await {
Ok(status) if !status.success() => {
let _ = tx
.send(Err(tonic::Status::internal(
"git upload-pack exited with error",
)))
.await;
}
Err(e) => {
let _ = tx
.send(Err(tonic::Status::internal(format!("wait error: {e}"))))
.await;
}
_ => {}
}
});
Ok(ReceiverStream::new(rx))
}
}
+46
View File
@@ -0,0 +1,46 @@
use crate::pb::{PageInfo, Pagination};
/// Simple offset-based pagination over an in-memory slice.
/// The `page_token` is a decimal string encoding the start offset.
pub fn paginate<T: Clone>(items: &[T], pagination: Option<&Pagination>) -> (Vec<T>, PageInfo) {
let page_size = pagination
.map(|p| p.page_size as usize)
.unwrap_or(items.len().max(1))
.max(1);
let start_offset = pagination
.and_then(|p| {
if p.page_token.is_empty() {
None
} else {
p.page_token.parse::<usize>().ok()
}
})
.unwrap_or(0)
.min(items.len());
let end = std::cmp::min(start_offset + page_size, items.len());
let has_next = end < items.len();
let next_page_token = if has_next {
end.to_string()
} else {
String::new()
};
(
items[start_offset..end].to_vec(),
PageInfo {
next_page_token,
has_next_page: has_next,
total_count: items.len() as u64,
},
)
}
/// Apply sort direction. Ascending = no-op (preserves order).
/// Descending reverses the slice.
pub fn apply_sort<T>(items: &mut [T], sort_direction: i32) {
if sort_direction == crate::pb::SortDirection::Desc as i32 {
items.reverse();
}
}
+8923
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
#![allow(clippy::all)]
#![allow(missing_docs)]
#![allow(rustdoc::bare_urls)]
include!(concat!(env!("OUT_DIR"), "/gitks.rs"));
+58
View File
@@ -0,0 +1,58 @@
syntax = "proto3";
package gitks;
import "oid.proto";
import "repository.proto";
message ArchiveOptions {
enum Format {
ARCHIVE_FORMAT_UNSPECIFIED = 0;
ARCHIVE_FORMAT_TAR = 1;
ARCHIVE_FORMAT_TAR_GZ = 2;
ARCHIVE_FORMAT_TAR_BZ2 = 3;
ARCHIVE_FORMAT_TAR_XZ = 4;
ARCHIVE_FORMAT_ZIP = 5;
}
Format format = 1;
string prefix = 2;
repeated string pathspec = 3;
int32 compression_level = 4;
bool include_global_extended_pax_headers = 5;
}
message ArchiveRequest {
RepositoryHeader repository = 1;
ObjectSelector treeish = 2;
ArchiveOptions options = 3;
}
message ArchiveChunk {
bytes data = 1;
}
message ArchiveEntry {
string path = 1;
Oid oid = 2;
uint32 mode = 3;
int64 size = 4;
ObjectType type = 5;
}
message ListArchiveEntriesRequest {
RepositoryHeader repository = 1;
ObjectSelector treeish = 2;
repeated string pathspec = 3;
Pagination pagination = 4;
}
message ListArchiveEntriesResponse {
repeated ArchiveEntry entries = 1;
PageInfo page_info = 2;
}
service ArchiveService {
rpc GetArchive(ArchiveRequest) returns (stream ArchiveChunk);
rpc ListArchiveEntries(ListArchiveEntriesRequest) returns (ListArchiveEntriesResponse);
}
+56
View File
@@ -0,0 +1,56 @@
syntax = "proto3";
package gitks;
import "commit.proto";
import "oid.proto";
import "repository.proto";
message LineRange {
uint32 start = 1;
uint32 end = 2;
}
message BlameOptions {
bool detect_move = 1;
bool detect_copy = 2;
uint32 score = 3;
repeated string ignore_revisions = 4;
}
message BlameRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string path = 3;
LineRange range = 4;
BlameOptions options = 5;
Pagination pagination = 6;
}
message BlameLine {
uint32 final_line = 1;
uint32 original_line = 2;
bytes content = 3;
}
message BlameHunk {
Commit commit = 1;
string original_path = 2;
string final_path = 3;
uint32 original_start_line = 4;
uint32 final_start_line = 5;
uint32 line_count = 6;
bool boundary = 7;
repeated BlameLine lines = 8;
}
message BlameResponse {
repeated BlameHunk hunks = 1;
PageInfo page_info = 2;
bool truncated = 3;
}
service BlameService {
rpc Blame(BlameRequest) returns (BlameResponse);
rpc StreamBlame(BlameRequest) returns (stream BlameHunk);
}
+114
View File
@@ -0,0 +1,114 @@
syntax = "proto3";
package gitks;
import "google/protobuf/empty.proto";
import "commit.proto";
import "oid.proto";
import "repository.proto";
message BranchUpstream {
string remote_name = 1;
string remote_url = 2;
string remote_branch_name = 3;
string local_branch_name = 4;
}
message Branch {
string name = 1;
string full_ref = 2;
Oid target_oid = 3;
Commit commit = 4;
BranchUpstream upstream = 5;
bool is_default = 6;
bool is_head = 7;
bool is_merged = 8;
bool is_detached = 9;
}
// Backward-compatible payload name used by earlier clients.
message PayloadBranch {
PayloadCommit commit = 1;
string name = 2;
BranchUpstream upstream = 3;
bool is_head = 4;
}
message RequestBranchInit {}
message ListBranchesRequest {
RepositoryHeader repository = 1;
string pattern = 2;
bool merged_into_head = 3;
bool not_merged_into_head = 4;
Pagination pagination = 5;
SortDirection sort_direction = 6;
}
message ListBranchesResponse {
repeated Branch branches = 1;
PageInfo page_info = 2;
}
message GetBranchRequest {
RepositoryHeader repository = 1;
string name = 2;
}
message CreateBranchRequest {
RepositoryHeader repository = 1;
string name = 2;
ObjectSelector start_point = 3;
bool force = 4;
}
message DeleteBranchRequest {
RepositoryHeader repository = 1;
string name = 2;
bool force = 3;
}
message RenameBranchRequest {
RepositoryHeader repository = 1;
string old_name = 2;
string new_name = 3;
}
message UpdateBranchTargetRequest {
RepositoryHeader repository = 1;
string name = 2;
Oid expected_old_oid = 3;
Oid new_oid = 4;
bool force = 5;
}
message SetBranchUpstreamRequest {
RepositoryHeader repository = 1;
string name = 2;
BranchUpstream upstream = 3;
}
message CompareBranchRequest {
RepositoryHeader repository = 1;
string source_branch = 2;
string target_branch = 3;
}
message CompareBranchResponse {
bool ahead = 1;
bool behind = 2;
uint32 ahead_by = 3;
uint32 behind_by = 4;
Oid merge_base = 5;
}
service BranchService {
rpc ListBranches(ListBranchesRequest) returns (ListBranchesResponse);
rpc GetBranch(GetBranchRequest) returns (Branch);
rpc CreateBranch(CreateBranchRequest) returns (Branch);
rpc DeleteBranch(DeleteBranchRequest) returns (google.protobuf.Empty);
rpc RenameBranch(RenameBranchRequest) returns (Branch);
rpc UpdateBranchTarget(UpdateBranchTargetRequest) returns (Branch);
rpc SetBranchUpstream(SetBranchUpstreamRequest) returns (Branch);
rpc CompareBranch(CompareBranchRequest) returns (CompareBranchResponse);
}
+165
View File
@@ -0,0 +1,165 @@
syntax = "proto3";
package gitks;
import "google/protobuf/timestamp.proto";
import "oid.proto";
import "repository.proto";
import "tagger.proto";
message PayloadCommit {
PayloadTagger author = 1;
PayloadTagger committer = 2;
Oid oid = 3;
string message = 4;
repeated Oid parents = 5;
Oid tree = 6;
google.protobuf.Timestamp timestamp = 7;
}
message CommitTrailer {
string key = 1;
string value = 2;
bool separator_present = 3;
}
message CommitStats {
uint32 additions = 1;
uint32 deletions = 2;
uint32 changed_files = 3;
}
message Commit {
Oid oid = 1;
string abbreviated_oid = 2;
repeated Oid parent_oids = 3;
Oid tree_oid = 4;
Signature author = 5;
Signature committer = 6;
string subject = 7;
string body = 8;
string message = 9;
repeated CommitTrailer trailers = 10;
VerifiedSignature signature = 11;
CommitStats stats = 12;
google.protobuf.Timestamp authored_at = 13;
google.protobuf.Timestamp committed_at = 14;
bytes raw = 15;
}
message ListCommitsRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string path = 3;
google.protobuf.Timestamp since = 4;
google.protobuf.Timestamp until = 5;
bool first_parent = 6;
bool all = 7;
bool reverse = 8;
uint32 max_parents = 9;
uint32 min_parents = 10;
Pagination pagination = 11;
}
message ListCommitsResponse {
repeated Commit commits = 1;
PageInfo page_info = 2;
}
message GetCommitRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
bool include_stats = 3;
bool include_raw = 4;
}
message GetCommitAncestorsRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
bool first_parent = 3;
Pagination pagination = 4;
}
message GetCommitAncestorsResponse {
repeated Commit commits = 1;
PageInfo page_info = 2;
}
message CreateCommitAction {
enum Action {
CREATE_COMMIT_ACTION_UNSPECIFIED = 0;
CREATE_COMMIT_ACTION_CREATE = 1;
CREATE_COMMIT_ACTION_UPDATE = 2;
CREATE_COMMIT_ACTION_DELETE = 3;
CREATE_COMMIT_ACTION_MOVE = 4;
CREATE_COMMIT_ACTION_CHMOD = 5;
}
Action action = 1;
string file_path = 2;
string previous_path = 3;
bytes content = 4;
string encoding = 5;
bool executable = 6;
Oid last_commit_oid = 7;
}
message CreateCommitRequest {
RepositoryHeader repository = 1;
string branch = 2;
string message = 3;
Signature author = 4;
Signature committer = 5;
repeated CreateCommitAction actions = 6;
ObjectSelector start_revision = 7;
bool force = 8;
repeated CommitTrailer trailers = 9;
}
message CreateCommitResponse {
Commit commit = 1;
string branch = 2;
}
message RevertCommitRequest {
RepositoryHeader repository = 1;
ObjectSelector commit = 2;
string branch = 3;
Signature committer = 4;
string message = 5;
}
message CherryPickCommitRequest {
RepositoryHeader repository = 1;
ObjectSelector commit = 2;
string branch = 3;
Signature committer = 4;
string message = 5;
uint32 mainline = 6;
}
message CompareCommitsRequest {
RepositoryHeader repository = 1;
ObjectSelector base = 2;
ObjectSelector head = 3;
bool straight = 4;
bool first_parent = 5;
Pagination pagination = 6;
}
message CompareCommitsResponse {
repeated Commit commits = 1;
CommitStats stats = 2;
PageInfo page_info = 3;
Oid merge_base = 4;
}
service CommitService {
rpc ListCommits(ListCommitsRequest) returns (ListCommitsResponse);
rpc GetCommit(GetCommitRequest) returns (Commit);
rpc GetCommitAncestors(GetCommitAncestorsRequest) returns (GetCommitAncestorsResponse);
rpc CreateCommit(CreateCommitRequest) returns (CreateCommitResponse);
rpc RevertCommit(RevertCommitRequest) returns (CreateCommitResponse);
rpc CherryPickCommit(CherryPickCommitRequest) returns (CreateCommitResponse);
rpc CompareCommits(CompareCommitsRequest) returns (CompareCommitsResponse);
}
+140
View File
@@ -0,0 +1,140 @@
syntax = "proto3";
package gitks;
import "oid.proto";
import "repository.proto";
message DiffOptions {
enum WhitespaceMode {
DIFF_WHITESPACE_MODE_UNSPECIFIED = 0;
DIFF_WHITESPACE_MODE_DEFAULT = 1;
DIFF_WHITESPACE_MODE_IGNORE_ALL = 2;
DIFF_WHITESPACE_MODE_IGNORE_CHANGE = 3;
DIFF_WHITESPACE_MODE_IGNORE_EOL = 4;
}
bool recursive = 1;
bool include_binary = 2;
bool include_patch = 3;
bool rename_detection = 4;
bool copy_detection = 5;
uint32 context_lines = 6;
repeated string pathspec = 7;
WhitespaceMode whitespace_mode = 8;
uint64 max_files = 9;
uint64 max_bytes = 10;
}
message DiffLine {
enum LineType {
DIFF_LINE_TYPE_UNSPECIFIED = 0;
DIFF_LINE_TYPE_CONTEXT = 1;
DIFF_LINE_TYPE_ADDED = 2;
DIFF_LINE_TYPE_DELETED = 3;
DIFF_LINE_TYPE_HUNK_HEADER = 4;
DIFF_LINE_TYPE_NO_NEWLINE = 5;
}
LineType type = 1;
int32 old_line = 2;
int32 new_line = 3;
bytes content = 4;
bool truncated = 5;
}
message DiffHunk {
string header = 1;
uint32 old_start = 2;
uint32 old_lines = 3;
uint32 new_start = 4;
uint32 new_lines = 5;
repeated DiffLine lines = 6;
}
message DiffFile {
enum ChangeType {
DIFF_FILE_CHANGE_TYPE_UNSPECIFIED = 0;
DIFF_FILE_CHANGE_TYPE_ADDED = 1;
DIFF_FILE_CHANGE_TYPE_MODIFIED = 2;
DIFF_FILE_CHANGE_TYPE_DELETED = 3;
DIFF_FILE_CHANGE_TYPE_RENAMED = 4;
DIFF_FILE_CHANGE_TYPE_COPIED = 5;
DIFF_FILE_CHANGE_TYPE_TYPE_CHANGED = 6;
DIFF_FILE_CHANGE_TYPE_UNMERGED = 7;
}
string old_path = 1;
string new_path = 2;
Oid old_oid = 3;
Oid new_oid = 4;
uint32 old_mode = 5;
uint32 new_mode = 6;
ChangeType change_type = 7;
bool binary = 8;
bool too_large = 9;
uint32 additions = 10;
uint32 deletions = 11;
repeated DiffHunk hunks = 12;
bytes patch = 13;
double similarity = 14;
}
message DiffStats {
uint32 additions = 1;
uint32 deletions = 2;
uint32 changed_files = 3;
}
message Diff {
repeated DiffFile files = 1;
DiffStats stats = 2;
bool overflow = 3;
}
message GetDiffRequest {
RepositoryHeader repository = 1;
ObjectSelector base = 2;
ObjectSelector head = 3;
DiffOptions options = 4;
Pagination pagination = 5;
}
message GetDiffResponse {
repeated DiffFile files = 1;
DiffStats stats = 2;
PageInfo page_info = 3;
bool overflow = 4;
}
message GetCommitDiffRequest {
RepositoryHeader repository = 1;
ObjectSelector commit = 2;
DiffOptions options = 3;
Pagination pagination = 4;
}
message GetPatchRequest {
RepositoryHeader repository = 1;
ObjectSelector base = 2;
ObjectSelector head = 3;
DiffOptions options = 4;
}
message GetPatchResponse {
bytes data = 1;
}
message GetDiffStatsRequest {
RepositoryHeader repository = 1;
ObjectSelector base = 2;
ObjectSelector head = 3;
DiffOptions options = 4;
}
service DiffService {
rpc GetDiff(GetDiffRequest) returns (GetDiffResponse);
rpc GetCommitDiff(GetCommitDiffRequest) returns (GetDiffResponse);
rpc GetPatch(GetPatchRequest) returns (stream GetPatchResponse);
rpc GetDiffStats(GetDiffStatsRequest) returns (DiffStats);
}
+139
View File
@@ -0,0 +1,139 @@
syntax = "proto3";
package gitks;
import "commit.proto";
import "diff.proto";
import "oid.proto";
import "repository.proto";
import "tagger.proto";
message MergeOptions {
enum Strategy {
MERGE_STRATEGY_UNSPECIFIED = 0;
MERGE_STRATEGY_RECURSIVE = 1;
MERGE_STRATEGY_ORT = 2;
MERGE_STRATEGY_RESOLVE = 3;
MERGE_STRATEGY_OCTOPUS = 4;
MERGE_STRATEGY_OURS = 5;
MERGE_STRATEGY_SUBTREE = 6;
}
enum FastForwardMode {
MERGE_FAST_FORWARD_MODE_UNSPECIFIED = 0;
MERGE_FAST_FORWARD_MODE_ALLOWED = 1;
MERGE_FAST_FORWARD_MODE_ONLY = 2;
MERGE_FAST_FORWARD_MODE_NO_FF = 3;
}
Strategy strategy = 1;
FastForwardMode fast_forward = 2;
bool squash = 3;
bool no_commit = 4;
bool allow_unrelated_histories = 5;
repeated string strategy_options = 6;
}
message MergeConflictSection {
string label = 1;
bytes content = 2;
}
message MergeConflict {
string path = 1;
uint32 mode = 2;
Oid base_oid = 3;
Oid ours_oid = 4;
Oid theirs_oid = 5;
repeated MergeConflictSection sections = 6;
bool binary = 7;
}
message MergeResult {
enum Status {
MERGE_RESULT_STATUS_UNSPECIFIED = 0;
MERGE_RESULT_STATUS_MERGED = 1;
MERGE_RESULT_STATUS_FAST_FORWARD = 2;
MERGE_RESULT_STATUS_ALREADY_UP_TO_DATE = 3;
MERGE_RESULT_STATUS_CONFLICTS = 4;
MERGE_RESULT_STATUS_ABORTED = 5;
}
Status status = 1;
Commit commit = 2;
Oid merge_base = 3;
repeated MergeConflict conflicts = 4;
DiffStats stats = 5;
string message = 6;
}
message MergeRequest {
RepositoryHeader repository = 1;
string target_branch = 2;
ObjectSelector source = 3;
Signature committer = 4;
string message = 5;
MergeOptions options = 6;
}
message CheckMergeRequest {
RepositoryHeader repository = 1;
ObjectSelector target = 2;
ObjectSelector source = 3;
MergeOptions options = 4;
}
message ListMergeConflictsRequest {
RepositoryHeader repository = 1;
ObjectSelector target = 2;
ObjectSelector source = 3;
Pagination pagination = 4;
}
message ListMergeConflictsResponse {
repeated MergeConflict conflicts = 1;
PageInfo page_info = 2;
}
message ResolveMergeConflict {
string path = 1;
bytes content = 2;
}
message ResolveMergeConflictsRequest {
RepositoryHeader repository = 1;
string target_branch = 2;
ObjectSelector source = 3;
repeated ResolveMergeConflict resolutions = 4;
Signature committer = 5;
string message = 6;
}
message RebaseRequest {
RepositoryHeader repository = 1;
string branch = 2;
ObjectSelector upstream = 3;
Signature committer = 4;
}
message RebaseResult {
enum Status {
REBASE_RESULT_STATUS_UNSPECIFIED = 0;
REBASE_RESULT_STATUS_REBASED = 1;
REBASE_RESULT_STATUS_ALREADY_UP_TO_DATE = 2;
REBASE_RESULT_STATUS_CONFLICTS = 3;
REBASE_RESULT_STATUS_ABORTED = 4;
}
Status status = 1;
Commit head = 2;
repeated MergeConflict conflicts = 3;
}
service MergeService {
rpc CheckMerge(CheckMergeRequest) returns (MergeResult);
rpc Merge(MergeRequest) returns (MergeResult);
rpc ListMergeConflicts(ListMergeConflictsRequest) returns (ListMergeConflictsResponse);
rpc ResolveMergeConflicts(ResolveMergeConflictsRequest) returns (MergeResult);
rpc Rebase(RebaseRequest) returns (RebaseResult);
}
+64
View File
@@ -0,0 +1,64 @@
syntax = "proto3";
package gitks;
// Git object hash algorithm. GitHub and Gitaly both need to support SHA-1 today
// and SHA-256 repositories as they become more common.
enum ObjectFormat {
OBJECT_FORMAT_UNSPECIFIED = 0;
OBJECT_FORMAT_SHA1 = 1;
OBJECT_FORMAT_SHA256 = 2;
}
// Git object kind.
enum ObjectType {
OBJECT_TYPE_UNSPECIFIED = 0;
OBJECT_TYPE_COMMIT = 1;
OBJECT_TYPE_TREE = 2;
OBJECT_TYPE_BLOB = 3;
OBJECT_TYPE_TAG = 4;
}
// Canonical object id. `value` preserves the original binary representation used
// by the existing API; `hex` is the normalized lowercase hex form for clients.
message Oid {
bytes value = 1;
string hex = 2;
ObjectFormat format = 3;
}
message ObjectName {
// Revision expression, refname, oid hex, or pseudo-ref such as HEAD.
string revision = 1;
}
message ObjectSelector {
oneof selector {
Oid oid = 1;
ObjectName revision = 2;
}
}
message ObjectIdentity {
Oid oid = 1;
ObjectType type = 2;
int64 size = 3;
string abbreviated_oid = 4;
}
message Pagination {
uint32 page_size = 1;
string page_token = 2;
}
message PageInfo {
string next_page_token = 1;
bool has_next_page = 2;
uint64 total_count = 3;
}
enum SortDirection {
SORT_DIRECTION_UNSPECIFIED = 0;
SORT_DIRECTION_ASC = 1;
SORT_DIRECTION_DESC = 2;
}
+134
View File
@@ -0,0 +1,134 @@
syntax = "proto3";
package gitks;
import "oid.proto";
import "repository.proto";
message GitProtocolFeatures {
uint32 version = 1;
repeated string capabilities = 2;
repeated string server_options = 3;
repeated string agent = 4;
}
message ReferenceAdvertisement {
string name = 1;
Oid target_oid = 2;
Oid peeled_oid = 3;
bool symbolic = 4;
string symbolic_target = 5;
}
message AdvertiseRefsRequest {
RepositoryHeader repository = 1;
GitProtocolFeatures protocol = 2;
string service = 3;
}
message AdvertiseRefsResponse {
repeated ReferenceAdvertisement references = 1;
repeated string capabilities = 2;
}
message UploadPackRequest {
RepositoryHeader repository = 1;
GitProtocolFeatures protocol = 2;
bytes packet = 3;
bool done = 4;
}
message UploadPackResponse {
bytes packet = 1;
string stderr = 2;
}
message ReceivePackRequest {
RepositoryHeader repository = 1;
GitProtocolFeatures protocol = 2;
bytes packet = 3;
bool done = 4;
}
message ReceivePackResponse {
bytes packet = 1;
string stderr = 2;
}
message PackObjectsOptions {
repeated Oid wants = 1;
repeated Oid haves = 2;
repeated string shallow_revisions = 3;
uint32 deepen = 4;
bool thin_pack = 5;
bool include_tag = 6;
bool use_bitmaps = 7;
bool delta_base_offset = 8;
repeated string pathspec = 9;
}
message PackObjectsRequest {
RepositoryHeader repository = 1;
PackObjectsOptions options = 2;
}
message PackfileChunk {
bytes data = 1;
}
message IndexPackRequest {
RepositoryHeader repository = 1;
bytes data = 2;
bool done = 3;
bool keep = 4;
bool strict = 5;
}
message IndexPackResponse {
Oid pack_hash = 1;
uint64 object_count = 2;
string stderr = 3;
}
message ListPackfilesRequest {
RepositoryHeader repository = 1;
Pagination pagination = 2;
}
message PackfileInfo {
string name = 1;
Oid pack_hash = 2;
uint64 size_bytes = 3;
uint64 index_size_bytes = 4;
uint64 object_count = 5;
bool has_bitmap = 6;
bool has_rev_index = 7;
bool kept = 8;
}
message ListPackfilesResponse {
repeated PackfileInfo packfiles = 1;
PageInfo page_info = 2;
}
message FsckRequest {
RepositoryHeader repository = 1;
bool strict = 2;
bool connectivity_only = 3;
}
message FsckResponse {
bool ok = 1;
repeated string errors = 2;
repeated string warnings = 3;
}
service PackService {
rpc AdvertiseRefs(AdvertiseRefsRequest) returns (AdvertiseRefsResponse);
rpc UploadPack(stream UploadPackRequest) returns (stream UploadPackResponse);
rpc ReceivePack(stream ReceivePackRequest) returns (stream ReceivePackResponse);
rpc PackObjects(PackObjectsRequest) returns (stream PackfileChunk);
rpc IndexPack(stream IndexPackRequest) returns (IndexPackResponse);
rpc ListPackfiles(ListPackfilesRequest) returns (ListPackfilesResponse);
rpc Fsck(FsckRequest) returns (FsckResponse);
}
+157
View File
@@ -0,0 +1,157 @@
syntax = "proto3";
package gitks;
import "google/protobuf/empty.proto";
import "oid.proto";
// Repository identity used by storage-facing RPCs.
message RepositoryHeader {
// Logical storage shard or disk name.
string storage_name = 1;
// Path relative to the storage root, usually ending in `.git` for bare repos.
string relative_path = 2;
// Optional absolute path for embedded/local deployments.
string storage_path = 3;
}
message Repository {
RepositoryHeader header = 1;
bool bare = 2;
bool empty = 3;
ObjectFormat object_format = 4;
string default_branch = 5;
string git_object_directory = 6;
repeated string git_alternate_object_directories = 7;
}
message RepositoryStatistics {
uint64 size_bytes = 1;
uint64 loose_object_count = 2;
uint64 packed_object_count = 3;
uint64 packfile_count = 4;
uint64 reference_count = 5;
uint64 commit_graph_size_bytes = 6;
uint64 multi_pack_index_size_bytes = 7;
}
message RepositoryConfigEntry {
string key = 1;
repeated string values = 2;
}
message RepositoryObjectFormatRequest {
RepositoryHeader repository = 1;
}
message RepositoryObjectFormatResponse {
ObjectFormat object_format = 1;
}
message GetRepositoryRequest {
RepositoryHeader repository = 1;
}
message InitRepositoryRequest {
RepositoryHeader repository = 1;
bool bare = 2;
ObjectFormat object_format = 3;
string initial_branch = 4;
}
message DeleteRepositoryRequest {
RepositoryHeader repository = 1;
}
message RepositoryExistsRequest {
RepositoryHeader repository = 1;
}
message RepositoryExistsResponse {
bool exists = 1;
}
message GetDefaultBranchRequest {
RepositoryHeader repository = 1;
}
message GetDefaultBranchResponse {
string name = 1;
}
message SetDefaultBranchRequest {
RepositoryHeader repository = 1;
string name = 2;
}
message GetRepositoryConfigRequest {
RepositoryHeader repository = 1;
repeated string keys = 2;
}
message GetRepositoryConfigResponse {
repeated RepositoryConfigEntry entries = 1;
}
message SetRepositoryConfigRequest {
RepositoryHeader repository = 1;
repeated RepositoryConfigEntry entries = 2;
}
message RepositoryStatisticsRequest {
RepositoryHeader repository = 1;
}
message RepositoryHealthRequest {
RepositoryHeader repository = 1;
bool connectivity_only = 2;
}
message RepositoryHealthResponse {
bool ok = 1;
repeated string warnings = 2;
repeated string errors = 3;
RepositoryStatistics statistics = 4;
}
message GarbageCollectRequest {
RepositoryHeader repository = 1;
bool prune = 2;
bool aggressive = 3;
}
message RepackRequest {
RepositoryHeader repository = 1;
bool full = 2;
bool write_bitmaps = 3;
bool write_multi_pack_index = 4;
}
message WriteCommitGraphRequest {
RepositoryHeader repository = 1;
bool replace = 2;
bool split = 3;
}
message RepositoryMaintenanceResponse {
bool ok = 1;
string stdout = 2;
string stderr = 3;
}
service RepositoryService {
rpc GetRepository(GetRepositoryRequest) returns (Repository);
rpc InitRepository(InitRepositoryRequest) returns (Repository);
rpc DeleteRepository(DeleteRepositoryRequest) returns (google.protobuf.Empty);
rpc RepositoryExists(RepositoryExistsRequest) returns (RepositoryExistsResponse);
rpc GetObjectFormat(RepositoryObjectFormatRequest) returns (RepositoryObjectFormatResponse);
rpc GetDefaultBranch(GetDefaultBranchRequest) returns (GetDefaultBranchResponse);
rpc SetDefaultBranch(SetDefaultBranchRequest) returns (google.protobuf.Empty);
rpc GetRepositoryConfig(GetRepositoryConfigRequest) returns (GetRepositoryConfigResponse);
rpc SetRepositoryConfig(SetRepositoryConfigRequest) returns (google.protobuf.Empty);
rpc GetRepositoryStatistics(RepositoryStatisticsRequest) returns (RepositoryStatistics);
rpc CheckRepositoryHealth(RepositoryHealthRequest) returns (RepositoryHealthResponse);
rpc GarbageCollect(GarbageCollectRequest) returns (RepositoryMaintenanceResponse);
rpc Repack(RepackRequest) returns (RepositoryMaintenanceResponse);
rpc WriteCommitGraph(WriteCommitGraphRequest) returns (RepositoryMaintenanceResponse);
}
+67
View File
@@ -0,0 +1,67 @@
syntax = "proto3";
package gitks;
import "google/protobuf/empty.proto";
import "oid.proto";
import "repository.proto";
import "tagger.proto";
message Tag {
string name = 1;
string full_ref = 2;
Oid target_oid = 3;
ObjectType target_type = 4;
Oid tag_oid = 5;
bool annotated = 6;
Signature tagger = 7;
string message = 8;
VerifiedSignature signature = 9;
bytes raw = 10;
}
message ListTagsRequest {
RepositoryHeader repository = 1;
string pattern = 2;
Pagination pagination = 3;
SortDirection sort_direction = 4;
}
message ListTagsResponse {
repeated Tag tags = 1;
PageInfo page_info = 2;
}
message GetTagRequest {
RepositoryHeader repository = 1;
string name = 2;
bool include_raw = 3;
}
message CreateTagRequest {
RepositoryHeader repository = 1;
string name = 2;
ObjectSelector target = 3;
string message = 4;
Signature tagger = 5;
bool force = 6;
bool annotated = 7;
}
message DeleteTagRequest {
RepositoryHeader repository = 1;
string name = 2;
}
message VerifyTagRequest {
RepositoryHeader repository = 1;
string name = 2;
}
service TagService {
rpc ListTags(ListTagsRequest) returns (ListTagsResponse);
rpc GetTag(GetTagRequest) returns (Tag);
rpc CreateTag(CreateTagRequest) returns (Tag);
rpc DeleteTag(DeleteTagRequest) returns (google.protobuf.Empty);
rpc VerifyTag(VerifyTagRequest) returns (VerifiedSignature);
}
+51
View File
@@ -0,0 +1,51 @@
syntax = "proto3";
package gitks;
import "google/protobuf/timestamp.proto";
// Git identity attached to commits and tags.
message Identity {
string name = 1;
string email = 2;
}
// Git signature with timestamp and timezone offset.
message Signature {
Identity identity = 1;
google.protobuf.Timestamp when = 2;
// Offset in minutes east of UTC, as stored by git.
int32 timezone_offset = 3;
}
// Backward-compatible payload name used by earlier Rust structs.
message PayloadTagger {
string email = 1;
string name = 2;
}
message VerifiedSignature {
enum Reason {
REASON_UNSPECIFIED = 0;
REASON_VALID = 1;
REASON_EXPIRED_KEY = 2;
REASON_NOT_SIGNING_KEY = 3;
REASON_GPGVERIFY_ERROR = 4;
REASON_GPGVERIFY_UNAVAILABLE = 5;
REASON_UNSIGNED = 6;
REASON_UNKNOWN_SIGNATURE_TYPE = 7;
REASON_NO_USER = 8;
REASON_UNVERIFIED_EMAIL = 9;
REASON_BAD_EMAIL = 10;
REASON_UNKNOWN_KEY = 11;
REASON_MALFORMED_SIGNATURE = 12;
REASON_INVALID = 13;
}
bool verified = 1;
Reason reason = 2;
string signature = 3;
string payload = 4;
string key_fingerprint = 5;
string signer = 6;
}
+118
View File
@@ -0,0 +1,118 @@
syntax = "proto3";
package gitks;
import "oid.proto";
import "repository.proto";
message TreeEntry {
enum EntryType {
TREE_ENTRY_TYPE_UNSPECIFIED = 0;
TREE_ENTRY_TYPE_TREE = 1;
TREE_ENTRY_TYPE_BLOB = 2;
TREE_ENTRY_TYPE_COMMIT = 3;
TREE_ENTRY_TYPE_SYMLINK = 4;
TREE_ENTRY_TYPE_EXECUTABLE = 5;
}
string name = 1;
string path = 2;
Oid oid = 3;
EntryType type = 4;
uint32 mode = 5;
int64 size = 6;
}
message Tree {
Oid oid = 1;
string path = 2;
repeated TreeEntry entries = 3;
bool truncated = 4;
}
message Blob {
Oid oid = 1;
string path = 2;
uint32 mode = 3;
int64 size = 4;
bytes data = 5;
string encoding = 6;
bool binary = 7;
bool truncated = 8;
}
message FileMetadata {
string path = 1;
Oid oid = 2;
uint32 mode = 3;
int64 size = 4;
ObjectType type = 5;
bool binary = 6;
}
message ListTreeRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string path = 3;
bool recursive = 4;
Pagination pagination = 5;
}
message ListTreeResponse {
repeated TreeEntry entries = 1;
PageInfo page_info = 2;
bool truncated = 3;
}
message GetTreeRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string path = 3;
}
message GetBlobRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string path = 3;
Oid oid = 4;
uint64 max_bytes = 5;
}
message GetRawBlobRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string path = 3;
Oid oid = 4;
}
message GetRawBlobResponse {
bytes data = 1;
}
message GetFileMetadataRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string path = 3;
}
message FindFilesRequest {
RepositoryHeader repository = 1;
ObjectSelector revision = 2;
string pattern = 3;
repeated string pathspec = 4;
Pagination pagination = 5;
}
message FindFilesResponse {
repeated FileMetadata files = 1;
PageInfo page_info = 2;
}
service TreeService {
rpc ListTree(ListTreeRequest) returns (ListTreeResponse);
rpc GetTree(GetTreeRequest) returns (Tree);
rpc GetBlob(GetBlobRequest) returns (Blob);
rpc GetRawBlob(GetRawBlobRequest) returns (stream GetRawBlobResponse);
rpc GetFileMetadata(GetFileMetadataRequest) returns (FileMetadata);
rpc FindFiles(FindFilesRequest) returns (FindFilesResponse);
}
+25
View File
@@ -0,0 +1,25 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::ReferenceAdvertisement;
impl GitBare {
pub fn list_refs(&self) -> GitResult<Vec<ReferenceAdvertisement>> {
let repo = self.gix_repo()?;
let mut refs = Vec::new();
for r in repo.references()?.all()? {
let mut r = match r {
Ok(r) => r,
Err(_) => continue,
};
let hex = r.peel_to_id().map(|id| id.to_string()).unwrap_or_default();
refs.push(ReferenceAdvertisement {
name: r.name().to_string(),
target_oid: Some(self.oid_to_pb(hex)),
peeled_oid: None,
symbolic: r.target().try_id().is_none(),
symbolic_target: String::new(),
});
}
Ok(refs)
}
}
+1
View File
@@ -0,0 +1 @@
pub mod list_refs;
+46
View File
@@ -0,0 +1,46 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{CreateTagRequest, GetTagRequest, Tag};
impl GitBare {
pub fn create_tag(&self, request: CreateTagRequest) -> GitResult<Tag> {
let target = match request.target.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"tag".to_string(),
];
if request.force {
args.push("--force".into());
}
if request.annotated {
args.push("--annotate".into());
if !request.message.is_empty() {
args.push("-m".into());
args.push(request.message.clone());
}
}
args.push(request.name.clone());
args.push(target);
let result = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
self.get_tag(GetTagRequest {
repository: request.repository,
name: request.name,
include_raw: false,
})
}
}
+29
View File
@@ -0,0 +1,29 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::DeleteTagRequest;
impl GitBare {
pub fn delete_tag(&self, request: DeleteTagRequest) -> GitResult<()> {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"tag",
"-d",
&request.name,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
if !result.status.success() {
return Err(GitError::CommandFailed {
status_code: result.status.code(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
});
}
Ok(())
}
}
+63
View File
@@ -0,0 +1,63 @@
use gix::bstr::ByteSlice;
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{GetTagRequest, ObjectType, Tag};
impl GitBare {
pub fn get_tag(&self, request: GetTagRequest) -> GitResult<Tag> {
let repo = self.gix_repo()?;
let refname = format!("refs/tags/{}", request.name);
let mut r = repo.find_reference(refname.as_str())?;
let full_ref = r.name().as_bstr().to_string();
let raw_target_hex = r.target().try_id().map(|id| id.to_string());
let peeled_hex = r.peel_to_id()?.to_string();
let is_annotated = raw_target_hex
.as_ref()
.is_some_and(|raw| *raw != peeled_hex);
let mut tag = Tag {
name: request.name,
full_ref,
target_oid: Some(self.oid_to_pb(peeled_hex)),
target_type: ObjectType::Commit as i32,
tag_oid: None,
annotated: false,
tagger: None,
message: String::new(),
signature: None,
raw: Vec::new(),
};
if is_annotated
&& let Some(ref raw_hex) = raw_target_hex
&& let Ok(id) = gix::hash::ObjectId::from_hex(raw_hex.as_bytes())
{
tag.tag_oid = Some(self.oid_to_pb(raw_hex.clone()));
if let Ok(obj) = repo.find_object(id)
&& let Ok(tag_obj) = obj.try_into_tag()
{
tag.annotated = true;
if let Ok(Some(tagger)) = tag_obj.tagger() {
tag.tagger = Some(crate::pb::Signature {
identity: Some(crate::pb::Identity {
name: tagger.name.to_string(),
email: tagger.email.to_string(),
}),
when: None,
timezone_offset: 0,
});
}
if let Ok(decoded) = tag_obj.decode() {
tag.message = String::from_utf8_lossy(decoded.message.trim()).into_owned();
}
if request.include_raw {
tag.raw = tag_obj.data.clone();
}
}
}
Ok(tag)
}
}
+41
View File
@@ -0,0 +1,41 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::paginate;
use crate::pb::{ListTagsRequest, ListTagsResponse, Tag};
impl GitBare {
pub fn list_tags(&self, request: ListTagsRequest) -> GitResult<ListTagsResponse> {
let repo = self.gix_repo()?;
let mut tags = Vec::new();
for r in repo.references()?.tags()? {
let mut r = r.map_err(|e| crate::error::GitError::Gix(e.to_string()))?;
let name = r.name().shorten().to_string();
if !request.pattern.is_empty() && !name.contains(&request.pattern) {
continue;
}
let hex = r
.peel_to_id()
.ok()
.map(|id| id.to_string())
.unwrap_or_default();
tags.push(Tag {
name: name.clone(),
full_ref: r.name().to_string(),
target_oid: Some(self.oid_to_pb(hex)),
target_type: crate::pb::ObjectType::Commit as i32,
tag_oid: None,
annotated: false,
tagger: None,
message: String::new(),
signature: None,
raw: Vec::new(),
});
}
paginate::apply_sort(&mut tags, request.sort_direction);
let (tags, page_info) = paginate::paginate(&tags, request.pagination.as_ref());
Ok(ListTagsResponse {
tags,
page_info: Some(page_info),
})
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod create_tag;
pub mod delete_tag;
pub mod get_tag;
pub mod list_tags;
pub mod verify_tag;
+35
View File
@@ -0,0 +1,35 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::{VerifiedSignature, VerifyTagRequest};
impl GitBare {
pub fn verify_tag(&self, request: VerifyTagRequest) -> GitResult<VerifiedSignature> {
let result = duct::cmd(
"git",
[
"--git-dir",
self.bare_dir.to_string_lossy().as_ref(),
"tag",
"-v",
&request.name,
],
)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()?;
let verified = result.status.success();
Ok(VerifiedSignature {
verified,
reason: if verified {
crate::pb::verified_signature::Reason::Valid as i32
} else {
crate::pb::verified_signature::Reason::GpgverifyError as i32
},
signature: String::new(),
payload: String::new(),
key_fingerprint: String::new(),
signer: String::new(),
})
}
}
+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);
}
+56
View File
@@ -0,0 +1,56 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::paginate;
use crate::pb::{
FileMetadata, FindFilesRequest, FindFilesResponse, ListTreeRequest, ObjectType, tree_entry,
};
impl GitBare {
pub fn find_files(&self, request: FindFilesRequest) -> GitResult<FindFilesResponse> {
let revision = request.revision.clone();
let root = if request.pathspec.is_empty() {
vec![String::new()]
} else {
request.pathspec.clone()
};
let mut files = Vec::new();
for pathspec in root {
let response = self.list_tree(ListTreeRequest {
repository: request.repository.clone(),
revision: revision.clone(),
path: pathspec,
recursive: true,
pagination: None,
})?;
for entry in response.entries {
if !request.pattern.is_empty() && !entry.path.contains(&request.pattern) {
continue;
}
let object_type = match tree_entry::EntryType::try_from(entry.r#type)
.unwrap_or(tree_entry::EntryType::TreeEntryTypeUnspecified)
{
tree_entry::EntryType::TreeEntryTypeTree => ObjectType::Tree,
tree_entry::EntryType::TreeEntryTypeCommit => ObjectType::Commit,
tree_entry::EntryType::TreeEntryTypeUnspecified => ObjectType::Unspecified,
_ => ObjectType::Blob,
} as i32;
files.push(FileMetadata {
path: entry.path,
oid: entry.oid,
mode: entry.mode,
size: entry.size,
r#type: object_type,
binary: false,
});
}
}
files.sort_by(|a, b| a.path.cmp(&b.path));
let (files, page_info) = paginate::paginate(&files, request.pagination.as_ref());
Ok(FindFilesResponse {
files,
page_info: Some(page_info),
})
}
}
+38
View File
@@ -0,0 +1,38 @@
use gix::object::tree::EntryKind;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{FileMetadata, GetFileMetadataRequest, ObjectType, object_selector};
impl GitBare {
pub fn get_file_metadata(&self, request: GetFileMetadataRequest) -> GitResult<FileMetadata> {
let repo = self.gix_repo()?;
let revision = match request.revision.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let tree = repo
.rev_parse_single(format!("{}^{{tree}}", revision).as_str())?
.object()?
.try_into_tree()
.map_err(|e| GitError::Gix(e.to_string()))?;
let entry = tree
.lookup_entry_by_path(&request.path)?
.ok_or_else(|| GitError::NotFound(request.path.clone()))?;
let hex = entry.id().to_string();
let kind = match entry.mode().kind() {
EntryKind::Tree => ObjectType::Tree,
EntryKind::Commit => ObjectType::Commit,
_ => ObjectType::Blob,
} as i32;
Ok(FileMetadata {
path: request.path,
oid: Some(self.oid_to_pb(hex)),
mode: u32::from_str_radix(&format!("{:o}", entry.mode()), 8).unwrap_or(0),
size: 0,
r#type: kind,
binary: false,
})
}
}
+43
View File
@@ -0,0 +1,43 @@
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{GetTreeRequest, ListTreeRequest, Tree};
impl GitBare {
pub fn get_tree(&self, request: GetTreeRequest) -> GitResult<Tree> {
let entries = self.list_tree(ListTreeRequest {
repository: request.repository,
revision: request.revision.clone(),
path: request.path.clone(),
recursive: false,
pagination: None,
})?;
let repo = self.gix_repo()?;
let revision = request
.revision
.and_then(|s| s.selector)
.map(|s| match s {
crate::pb::object_selector::Selector::Oid(oid) => oid.hex,
crate::pb::object_selector::Selector::Revision(name) => name.revision,
})
.unwrap_or_else(|| "HEAD".into());
let root = repo
.rev_parse_single(format!("{}^{{tree}}", revision).as_str())?
.object()?
.try_into_tree()
.map_err(|e| GitError::Gix(e.to_string()))?;
let tree_hex = if request.path.is_empty() {
root.id.to_string()
} else {
root.lookup_entry_by_path(&request.path)?
.ok_or_else(|| GitError::NotFound(request.path.clone()))?
.id()
.to_string()
};
Ok(Tree {
oid: Some(self.oid_to_pb(tree_hex)),
path: request.path,
entries: entries.entries,
truncated: false,
})
}
}
+87
View File
@@ -0,0 +1,87 @@
use gix::object::tree::EntryKind;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::paginate;
use crate::pb::{ListTreeRequest, ListTreeResponse, TreeEntry, object_selector, tree_entry};
impl GitBare {
pub fn list_tree(&self, request: ListTreeRequest) -> GitResult<ListTreeResponse> {
let repo = self.gix_repo()?;
let revision = match request.revision.clone().and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let mut tree = repo
.rev_parse_single(format!("{}^{{tree}}", revision).as_str())?
.object()?
.try_into_tree()
.map_err(|e| GitError::Gix(e.to_string()))?;
if !request.path.is_empty() {
let entry = tree
.lookup_entry_by_path(&request.path)?
.ok_or_else(|| GitError::NotFound(request.path.clone()))?;
tree = entry
.object()?
.try_into_tree()
.map_err(|e| GitError::Gix(e.to_string()))?;
}
let base = request.path.trim_matches('/').to_string();
let mut entries = Vec::new();
for entry in tree.iter() {
let entry = entry?;
let name = String::from_utf8_lossy(entry.filename()).into_owned();
let path = if base.is_empty() {
name.clone()
} else {
format!("{base}/{name}")
};
let kind = entry.kind();
let hex = entry.id().to_string();
entries.push(TreeEntry {
name,
path: path.clone(),
oid: Some(self.oid_to_pb(hex)),
r#type: entry_type(kind) as i32,
mode: u32::from_str_radix(&format!("{:o}", entry.mode()), 8).unwrap_or(0),
size: entry_size(&repo, entry.id().to_string().as_str()).unwrap_or(0),
});
if request.recursive && matches!(kind, EntryKind::Tree) {
let child = self.list_tree(ListTreeRequest {
repository: request.repository.clone(),
revision: request.revision.clone(),
path,
recursive: true,
pagination: None,
})?;
entries.extend(child.entries);
}
}
let (entries, page_info) = paginate::paginate(&entries, request.pagination.as_ref());
Ok(ListTreeResponse {
entries,
page_info: Some(page_info),
truncated: false,
})
}
}
fn entry_type(kind: EntryKind) -> tree_entry::EntryType {
match kind {
EntryKind::Tree => tree_entry::EntryType::TreeEntryTypeTree,
EntryKind::Blob => tree_entry::EntryType::TreeEntryTypeBlob,
EntryKind::BlobExecutable => tree_entry::EntryType::TreeEntryTypeExecutable,
EntryKind::Link => tree_entry::EntryType::TreeEntryTypeSymlink,
EntryKind::Commit => tree_entry::EntryType::TreeEntryTypeCommit,
}
}
fn entry_size(repo: &gix::Repository, oid: &str) -> Option<i64> {
let id = gix::hash::ObjectId::from_hex(oid.as_bytes()).ok()?;
let object = repo.find_object(id).ok()?;
object.data.len().try_into().ok()
}
+4
View File
@@ -0,0 +1,4 @@
pub mod find_files;
pub mod get_file_metadata;
pub mod get_tree;
pub mod list_tree;