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:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+14
@@ -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>
|
||||
Generated
+8
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+33
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}])
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_archive;
|
||||
pub mod list_archive_entries;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod do_blame;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_blob;
|
||||
pub mod get_raw_blob;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod get_commit_diff;
|
||||
pub mod get_diff;
|
||||
pub mod get_diff_stats;
|
||||
pub mod get_patch;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ¤t_tip, request.committer.as_ref())?;
|
||||
}
|
||||
|
||||
self.update_branch_ref(&branch, ¤t_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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}")))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
#![allow(clippy::all)]
|
||||
#![allow(missing_docs)]
|
||||
#![allow(rustdoc::bare_urls)]
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/gitks.rs"));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod list_refs;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod create_tag;
|
||||
pub mod delete_tag;
|
||||
pub mod get_tag;
|
||||
pub mod list_tags;
|
||||
pub mod verify_tag;
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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("@@"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod find_files;
|
||||
pub mod get_file_metadata;
|
||||
pub mod get_tree;
|
||||
pub mod list_tree;
|
||||
Reference in New Issue
Block a user