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