feat(api): extend commit and diff services with new functionality

- Add FindCommit, ListCommitsByOid, CommitIsAncestor RPCs to CommitService
- Add CheckObjectsExist, CommitsByMessage, GetCommitStats RPCs to CommitService
- Add LastCommitForPath, CountCommits, CountDivergingCommits RPCs to CommitService
- Add RawDiff, RawPatch, FindChangedPaths RPCs to DiffService
- Add FindMergeBase, WriteRef, SearchFilesByContent RPCs to RepositoryService
- Add SearchFilesByName, ObjectsSize, RepositorySize RPCs to RepositoryService
- Add FindLicense, OptimizeRepository, GetRawChanges RPCs to RepositoryService
- Add FetchRemote, CreateRepositoryFromURL RPCs to RepositoryService
- Implement server handlers for all new RPC methods
- Add new modules for commit counting, finding, and querying features
- Add new modules for diff changed paths and raw operations
- Add new modules for refs and remote operations
- Remove unnecessary comments from various source files
- Update proto definitions with new message types and service methods
This commit is contained in:
zhenyi
2026-06-08 15:37:08 +08:00
parent 8f472a0443
commit 66afd932ed
43 changed files with 3070 additions and 75 deletions
+112
View File
@@ -0,0 +1,112 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::*;
impl GitBare {
/// Detect license by reading LICENSE/COPYING files and doing basic matching.
pub fn find_license(&self) -> GitResult<FindLicenseResponse> {
let possible_paths = [
"LICENSE", "LICENSE.md", "LICENSE.txt",
"LICENCE", "LICENCE.md", "LICENCE.txt",
"COPYING", "COPYING.md", "COPYING.txt",
"UNLICENSE",
];
for path in &possible_paths {
let output = std::process::Command::new("git")
.args([
"--git-dir",
&self.bare_dir.to_string_lossy(),
"show",
&format!("HEAD:{path}"),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
if output.status.success() {
let content = String::from_utf8_lossy(&output.stdout);
let (spdx, name, conf) = detect_license(&content);
if conf > 0.0 {
return Ok(FindLicenseResponse {
license_spdx: spdx.to_string(),
license_name: name.to_string(),
confidence: conf,
license_path: path.to_string(),
});
}
}
}
Ok(FindLicenseResponse::default())
}
}
/// Very basic license detection by keyword matching.
/// Returns (SPDX identifier, human-readable name, confidence).
fn detect_license(content: &str) -> (&'static str, &'static str, f64) {
let lower = content.to_lowercase();
// MIT
if lower.contains("permission is hereby granted, free of charge") && lower.contains("mit") {
return ("MIT", "MIT License", 0.95);
}
// Apache 2.0
if lower.contains("apache license, version 2.0") || lower.contains("apache-2.0") {
return ("Apache-2.0", "Apache License 2.0", 0.95);
}
// GPL 3.0
if lower.contains("gnu general public license") && lower.contains("version 3") {
return ("GPL-3.0", "GNU General Public License v3.0", 0.90);
}
// GPL 2.0
if lower.contains("gnu general public license") && lower.contains("version 2") {
return ("GPL-2.0", "GNU General Public License v2.0", 0.90);
}
// BSD 3
if lower.contains("redistribution and use in source and binary forms")
&& lower.contains("neither the name of")
{
return ("BSD-3-Clause", "BSD 3-Clause License", 0.85);
}
// BSD 2
if lower.contains("redistribution and use in source and binary forms") {
return ("BSD-2-Clause", "BSD 2-Clause License", 0.80);
}
// AGPL
if lower.contains("gnu affero general public license") {
return ("AGPL-3.0", "GNU Affero General Public License v3.0", 0.90);
}
// LGPL
if lower.contains("gnu lesser general public license") {
return ("LGPL-3.0", "GNU Lesser General Public License v3.0", 0.85);
}
// MPL
if lower.contains("mozilla public license") {
return ("MPL-2.0", "Mozilla Public License 2.0", 0.90);
}
// Unlicense
if lower.contains("this is free and unencumbered software released into the public domain") {
return ("Unlicense", "The Unlicense", 0.95);
}
// ISC
if lower.contains("permission to use, copy, modify, and/or distribute")
&& lower.contains("isc")
{
return ("ISC", "ISC License", 0.80);
}
("", "", 0.0)
}
+73
View File
@@ -0,0 +1,73 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::*;
impl GitBare {
/// Find the best merge base for a set of revisions (OIDs).
pub fn find_merge_base(&self, request: FindMergeBaseRequest) -> GitResult<FindMergeBaseResponse> {
if request.revisions.is_empty() {
return Ok(FindMergeBaseResponse::default());
}
let revisions: Vec<String> = request
.revisions
.iter()
.map(|b| String::from_utf8_lossy(b).to_string())
.collect();
if revisions.len() < 2 {
return Ok(FindMergeBaseResponse {
base_oid: revisions.first().cloned().unwrap_or_default(),
});
}
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"merge-base".to_string(),
];
args.extend(revisions.iter().cloned());
let output = std::process::Command::new("git")
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
if !output.status.success() {
return Ok(FindMergeBaseResponse {
base_oid: String::new(),
});
}
let base_oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(FindMergeBaseResponse { base_oid })
}
/// Check if one commit is an ancestor of another.
pub fn commit_is_ancestor(&self, request: CommitIsAncestorRequest) -> GitResult<CommitIsAncestorResponse> {
crate::sanitize::validate_revision(&request.ancestor_oid)?;
crate::sanitize::validate_revision(&request.descendant_oid)?;
let result = std::process::Command::new("git")
.args([
"--git-dir",
&self.bare_dir.to_string_lossy(),
"merge-base",
"--is-ancestor",
&request.ancestor_oid,
&request.descendant_oid,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
Ok(CommitIsAncestorResponse { is_ancestor: result })
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod find_license;
pub mod find_merge_base;
pub mod objects_size;
pub mod optimize;
pub mod raw_changes;
pub mod search_files;
+93
View File
@@ -0,0 +1,93 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::*;
impl GitBare {
/// Get sizes for a list of objects by OID.
pub fn objects_size(&self, request: ObjectsSizeRequest) -> GitResult<ObjectsSizeResponse> {
if request.oids.is_empty() {
return Ok(ObjectsSizeResponse::default());
}
let mut input = String::new();
for oid in &request.oids {
crate::sanitize::validate_revision(oid)?;
input.push_str(oid);
input.push('\n');
}
let mut child = std::process::Command::new("git")
.args([
"--git-dir",
&self.bare_dir.to_string_lossy(),
"cat-file",
"--batch-check=%(objectname) %(objectsize)",
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(input.as_bytes()).map_err(|e| {
crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
}
})?;
}
let output = child.wait_with_output().map_err(|e| {
crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut sizes = Vec::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let oid = parts[0];
let found = parts.get(1).map_or(true, |&s| s != "missing");
let size = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
sizes.push(ObjectSize {
oid: oid.to_string(),
size,
found,
});
}
}
Ok(ObjectsSizeResponse { sizes })
}
/// Get total repository size on disk.
pub fn repository_size(&self) -> GitResult<RepositorySizeResponse> {
let output = std::process::Command::new("du")
.args(["-sb", &self.bare_dir.to_string_lossy()])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let size = stdout
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
Ok(RepositorySizeResponse { size_bytes: size })
}
}
+168
View File
@@ -0,0 +1,168 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::*;
impl GitBare {
/// Run heuristic optimization based on repo state.
pub fn optimize_repository(&self, request: OptimizeRepositoryRequest) -> GitResult<OptimizeRepositoryResponse> {
let strategy = OptimizeStrategy::try_from(request.strategy).unwrap_or(OptimizeStrategy::Heuristic);
let mut stdout_all = String::new();
let mut stderr_all = String::new();
match strategy {
OptimizeStrategy::Heuristic | OptimizeStrategy::Aggressive => {
let stats = self.get_repository_statistics()?;
// Run commit-graph write if needed
if stats.commit_graph_size_bytes == 0 || strategy == OptimizeStrategy::Aggressive {
if let Ok(resp) = write_commit_graph(self, false, false) {
if !resp.ok { stderr_all.push_str(&resp.stderr); }
stdout_all.push_str(&resp.stdout);
}
}
// Repack if many loose objects or packfiles
let repack_needed = stats.loose_object_count > 1000 || stats.packfile_count > 10;
if repack_needed || strategy == OptimizeStrategy::Aggressive {
let full = strategy == OptimizeStrategy::Aggressive;
if let Ok(resp) = run_repack(self, full, true, true) {
if !resp.ok { stderr_all.push_str(&resp.stderr); }
stdout_all.push_str(&resp.stdout);
}
}
// Prune if aggressive
if strategy == OptimizeStrategy::Aggressive {
if let Ok(resp) = run_gc(self, true, true) {
if !resp.ok { stderr_all.push_str(&resp.stderr); }
stdout_all.push_str(&resp.stdout);
}
}
}
OptimizeStrategy::Incremental => {
// Just run commit-graph write incrementally
if let Ok(resp) = write_commit_graph(self, false, false) {
if !resp.ok { stderr_all.push_str(&resp.stderr); }
stdout_all.push_str(&resp.stdout);
}
}
OptimizeStrategy::Unspecified => {}
}
Ok(OptimizeRepositoryResponse {
ok: stderr_all.is_empty(),
stdout: stdout_all,
stderr: stderr_all,
})
}
fn get_repository_statistics(&self) -> GitResult<RepositoryStatistics> {
// Count loose objects
let loose = std::fs::read_dir(self.bare_dir.join("objects"))
.map(|d| {
d.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|t| t.is_dir()).unwrap_or(false)
&& e.file_name().to_string_lossy().len() == 2
})
.count() as u64
})
.unwrap_or(0);
// Count packfiles
let pack_dir = self.bare_dir.join("objects").join("pack");
let pack_count = std::fs::read_dir(&pack_dir)
.map(|d| d.filter_map(|e| e.ok()).count() as u64)
.unwrap_or(0);
// Check commit-graph
let cg_size = std::fs::metadata(
self.bare_dir.join("objects").join("info").join("commit-graph")
)
.map(|m| m.len())
.unwrap_or(0);
Ok(RepositoryStatistics {
size_bytes: 0,
loose_object_count: loose,
packed_object_count: 0,
packfile_count: pack_count,
reference_count: 0,
commit_graph_size_bytes: cg_size,
multi_pack_index_size_bytes: 0,
})
}
}
fn write_commit_graph(gb: &GitBare, _split: bool, _replace: bool) -> GitResult<RepositoryMaintenanceResponse> {
let out = std::process::Command::new("git")
.args([
"--git-dir", &gb.bare_dir.to_string_lossy(),
"commit-graph", "write", "--reachable",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
Ok(RepositoryMaintenanceResponse {
ok: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
fn run_repack(gb: &GitBare, full: bool, bitmaps: bool, _midx: bool) -> GitResult<RepositoryMaintenanceResponse> {
let mut args = vec![
"--git-dir".to_string(), gb.bare_dir.to_string_lossy().into_owned(),
"repack".to_string(),
];
if full { args.push("-ad".to_string()); } else { args.push("-d".to_string()); }
if bitmaps { args.push("--write-bitmap-index".to_string()); }
let out = std::process::Command::new("git")
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
Ok(RepositoryMaintenanceResponse {
ok: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
fn run_gc(gb: &GitBare, prune: bool, aggressive: bool) -> GitResult<RepositoryMaintenanceResponse> {
let mut args = vec![
"--git-dir".to_string(), gb.bare_dir.to_string_lossy().into_owned(),
"gc".to_string(),
];
if prune { args.push("--prune=now".to_string()); }
if aggressive { args.push("--aggressive".to_string()); }
let out = std::process::Command::new("git")
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
Ok(RepositoryMaintenanceResponse {
ok: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
+81
View File
@@ -0,0 +1,81 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::*;
impl GitBare {
/// Get raw changes between two revisions (file-level changes only, no diff content).
pub fn get_raw_changes(&self, request: GetRawChangesRequest) -> GitResult<GetRawChangesResponse> {
crate::sanitize::validate_revision(&request.base)?;
crate::sanitize::validate_revision(&request.head)?;
let output = std::process::Command::new("git")
.args([
"--git-dir",
&self.bare_dir.to_string_lossy(),
"diff-tree",
"--raw",
"-r",
"--root",
&request.base,
&request.head,
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut changes = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if !line.starts_with(':') { continue; }
let line = &line[1..];
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 5 { continue; }
let old_mode = u32::from_str_radix(parts[0], 8).unwrap_or(0);
let new_mode = u32::from_str_radix(parts[1], 8).unwrap_or(0);
let old_oid = parts[2].to_string();
let new_oid = parts[3].to_string();
let status_str = parts[4];
let status_letter = status_str.chars().next().unwrap_or('M');
let operation = match status_letter {
'A' => raw_change::Operation::RawChangeOperationAdded as i32,
'D' => raw_change::Operation::RawChangeOperationDeleted as i32,
'R' => raw_change::Operation::RawChangeOperationRenamed as i32,
'C' => raw_change::Operation::RawChangeOperationCopied as i32,
'M' | 'T' => raw_change::Operation::RawChangeOperationModified as i32,
_ => raw_change::Operation::RawChangeOperationUnspecified as i32,
};
let (old_path, new_path) = if parts.len() >= 6 {
(parts[5].to_string(), if status_letter == 'R' || status_letter == 'C' {
parts.get(6).map(|s| s.to_string()).unwrap_or_default()
} else {
String::new()
})
} else {
(String::new(), String::new())
};
changes.push(RawChange {
operation,
old_path,
new_path,
old_mode,
new_mode,
old_oid,
new_oid,
similarity: 0.0,
});
}
Ok(GetRawChangesResponse { changes })
}
}
+125
View File
@@ -0,0 +1,125 @@
use crate::bare::GitBare;
use crate::error::GitResult;
use crate::pb::*;
impl GitBare {
/// Search file contents with a regex pattern.
pub fn search_files_by_content(&self, request: SearchFilesByContentRequest) -> GitResult<SearchFilesByContentResponse> {
crate::sanitize::validate_revision(&request.revision)?;
let revision = if request.revision.is_empty() { "HEAD" } else { &request.revision };
let max_results = if request.max_results == 0 { 100 } else { request.max_results };
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"grep".to_string(),
"-I".to_string(), // don't match binary files
"--line-number".to_string(),
"--column".to_string(),
];
if !request.case_sensitive {
args.push("-i".to_string());
}
args.push(format!("--max-count={}", max_results));
args.push("-e".to_string());
args.push(request.query.clone());
args.push(revision.to_string());
let output = std::process::Command::new("git")
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
// git grep returns exit code 1 when no matches found — that's not an error
let stdout = String::from_utf8_lossy(&output.stdout);
let mut results = Vec::new();
for line in stdout.lines() {
// Format: path:line:col:matched_text
if let Some((path_and_rest, matched)) = line.rsplit_once(':') {
let prefix_parts: Vec<&str> = path_and_rest.rsplitn(3, ':').collect();
if prefix_parts.len() >= 3 {
if let Ok(line_num) = prefix_parts[0].parse::<u32>() {
results.push(SearchResult {
path: prefix_parts[2].to_string(),
line: line_num,
matched_text: matched.to_string(),
});
}
}
}
}
Ok(SearchFilesByContentResponse { results })
}
/// Search file names matching a pattern.
pub fn search_files_by_name(&self, request: SearchFilesByNameRequest) -> GitResult<SearchFilesByNameResponse> {
let revision = if request.revision.is_empty() { "HEAD" } else { &request.revision };
crate::sanitize::validate_revision(revision)?;
let max_results = if request.max_results == 0 { 100 } else { request.max_results };
let mut args = vec![
"--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(),
"ls-tree".to_string(),
];
if request.recursive {
args.push("-r".to_string());
}
args.push("--name-only".to_string());
args.push(revision.to_string());
let output = std::process::Command::new("git")
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut results = Vec::new();
for line in stdout.lines() {
let path = line.trim();
if path.is_empty() || crate::sanitize::validate_file_path(path).is_err() {
continue;
}
// Simple substring/case-insensitive matching for file names
let query = &request.query;
let matched = if query.is_empty() {
true
} else {
path.to_lowercase().contains(&query.to_lowercase())
};
if matched {
results.push(SearchResult {
path: path.to_string(),
line: 0,
matched_text: String::new(),
});
if results.len() >= max_results as usize {
break;
}
}
}
Ok(SearchFilesByNameResponse { results })
}
}