refactor(build): reformat code and add tonic health dependency

- Reformatted build script with proper indentation and line breaks
- Added tonic-health dependency to Cargo.toml and updated lock file
- Improved error handling in disk cache with concurrent deletion checks
- Refactored conditional chains using && and let expressions
- Reformatted struct initialization and function parameter lists
- Added proper spacing and alignment in language stats processing
- Improved assertion formatting in test cases
- Reorganized import statements and code layout in multiple files
- Updated metrics functions with better parameter handling and formatting
This commit is contained in:
zhenyi
2026-06-11 13:56:15 +08:00
parent c32a7cad2f
commit a40da90ef9
31 changed files with 696 additions and 417 deletions
Generated
+14
View File
@@ -721,6 +721,7 @@ dependencies = [
"tokio-stream",
"tokio-util",
"tonic",
"tonic-health",
"tonic-prost",
"tonic-prost-build",
"tracing",
@@ -3075,6 +3076,19 @@ dependencies = [
"syn",
]
[[package]]
name = "tonic-health"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcfab99db777fba2802f0dfa861d1628d1ae916fb199d29819941f139ae85082"
dependencies = [
"prost",
"tokio",
"tokio-stream",
"tonic",
"tonic-prost",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
+1
View File
@@ -33,6 +33,7 @@ thiserror = { version = "2", features = [] }
prost = "0.14"
prost-types = "0.14"
tonic = { version = "0.14", features = ["transport"] }
tonic-health = "0.14.6"
tonic-prost = "0.14"
tempfile = "3"
dotenvy = "0.15"
+36 -20
View File
@@ -1,7 +1,7 @@
use crate::actor::message::{
AppendEntriesRequest, AppendEntriesResponse, ElectionRequest, ElectionResult, GitNodeMessage,
NodeHealth, ReadIndexResponse, RefUpdateEvent, RoleChangedEvent, RouteDecision,
ROLE_PRIMARY, ROLE_REPLICA, RAFT_MSG_VERSION,
NodeHealth, RAFT_MSG_VERSION, ROLE_PRIMARY, ROLE_REPLICA, ReadIndexResponse, RefUpdateEvent,
RoleChangedEvent, RouteDecision,
};
use crate::actor::raft_log::RaftLog;
use crate::pb::RepositoryHeader;
@@ -94,9 +94,8 @@ impl Actor for GitNodeActor {
// Initialize Raft log with disk persistence
let raft_data_dir = args.data_dir.join("raft");
let raft_log = RaftLog::new(&raft_data_dir).map_err(|e| {
ActorProcessingErr::from(format!("failed to init raft log: {e}"))
})?;
let raft_log = RaftLog::new(&raft_data_dir)
.map_err(|e| ActorProcessingErr::from(format!("failed to init raft log: {e}")))?;
tracing::info!(
storage_name = %args.storage_name,
entries = raft_log.len(),
@@ -451,9 +450,7 @@ fn should_accept_election(request: &ElectionRequest, state: &GitNodeState) -> bo
);
return false;
}
if request.last_log_term == my_last_term
&& request.last_log_index < my_last_index
{
if request.last_log_term == my_last_term && request.last_log_index < my_last_index {
tracing::warn!(
candidate_index = request.last_log_index,
my_index = my_last_index,
@@ -796,8 +793,8 @@ fn handle_append_entries(
};
}
}
if state.raft_log.term_at(entry.index) == 0 {
if let Some(raft_entry) = entry.to_entry()
if state.raft_log.term_at(entry.index) == 0
&& let Some(raft_entry) = entry.to_entry()
&& let Err(e) = state.raft_log.append_reserved(raft_entry)
{
tracing::error!(error = %e, "failed to append raft entry");
@@ -811,7 +808,6 @@ fn handle_append_entries(
};
}
}
}
// Step 5: Update commit_index
if request.leader_commit > state.raft_log.commit_index() {
@@ -858,7 +854,10 @@ fn handle_read_index(state: &GitNodeState) -> ReadIndexResponse {
ReadIndexResponse {
commit_index: state.raft_log.commit_index(),
leader_term: state.current_term,
is_leader: state.is_primary && state.leader_lease_deadline.is_some_and(|d| d > Instant::now()),
is_leader: state.is_primary
&& state
.leader_lease_deadline
.is_some_and(|d| d > Instant::now()),
}
}
@@ -902,8 +901,12 @@ pub async fn broadcast_append_entries(
match ractor::call_t!(actor_ref, GitNodeMessage::AppendEntries, 5000, request) {
Ok(response) if response.success => {
success_count += 1;
state.match_index.insert(follower_id.clone(), response.match_index);
state.next_index.insert(follower_id, response.match_index + 1);
state
.match_index
.insert(follower_id.clone(), response.match_index);
state
.next_index
.insert(follower_id, response.match_index + 1);
}
Ok(response) => {
// Follower rejected — update next_index for retry
@@ -916,7 +919,9 @@ pub async fn broadcast_append_entries(
// Decrement next_index (optimization: use conflict info)
let next = state.next_index.get(&follower_id).copied().unwrap_or(1);
if response.conflict_index > 0 && response.conflict_index < next {
state.next_index.insert(follower_id, response.conflict_index);
state
.next_index
.insert(follower_id, response.conflict_index);
} else if next > 1 {
state.next_index.insert(follower_id, next - 1);
}
@@ -933,7 +938,9 @@ pub async fn broadcast_append_entries(
/// Check if Leader lease is still valid.
pub fn is_leader_lease_valid(state: &GitNodeState) -> bool {
state.is_primary
&& state.leader_lease_deadline.is_some_and(|d| d > Instant::now())
&& state
.leader_lease_deadline
.is_some_and(|d| d > Instant::now())
}
/// Update Leader lease after successful majority replication.
@@ -1006,8 +1013,12 @@ async fn handle_raft_write(
match ractor::call_t!(actor_ref, GitNodeMessage::AppendEntries, 5000, request) {
Ok(response) if response.success => {
success_count += 1;
state.match_index.insert(follower_id.clone(), response.match_index);
state.next_index.insert(follower_id, response.match_index + 1);
state
.match_index
.insert(follower_id.clone(), response.match_index);
state
.next_index
.insert(follower_id, response.match_index + 1);
}
Ok(response) => {
tracing::debug!(
@@ -1083,7 +1094,10 @@ fn apply_raft_command(state: &mut GitNodeState, command: &crate::actor::raft_log
storage_name = %storage_name,
"applying RegisterRepo from Raft log"
);
state.repos.entry(relative_path.clone()).or_insert_with(|| RepoEntry {
state
.repos
.entry(relative_path.clone())
.or_insert_with(|| RepoEntry {
role: ROLE_REPLICA.to_string(),
last_commit: String::new(),
read_only: false,
@@ -1121,5 +1135,7 @@ fn apply_raft_command(state: &mut GitNodeState, command: &crate::actor::raft_log
}
// Advance last_applied
state.raft_log.advance_last_applied(state.raft_log.commit_index());
state
.raft_log
.advance_last_applied(state.raft_log.commit_index());
}
+23 -4
View File
@@ -159,7 +159,6 @@ pub enum GitNodeMessage {
TriggerElection,
// ── Raft consensus messages ──────────────────────────────
/// AppendEntries RPC: Leader → Follower log replication.
#[rpc]
AppendEntries(AppendEntriesRequest, RpcReplyPort<AppendEntriesResponse>),
@@ -418,7 +417,16 @@ impl BytesConvertable for AppendEntriesRequest {
});
}
let leader_commit = read_u64(&bytes, &mut offset);
Self { version, term, leader_id, leader_grpc_addr, prev_log_index, prev_log_term, entries, leader_commit }
Self {
version,
term,
leader_id,
leader_grpc_addr,
prev_log_index,
prev_log_term,
entries,
leader_commit,
}
}
}
@@ -457,7 +465,14 @@ impl BytesConvertable for AppendEntriesResponse {
let match_index = read_u64(&bytes, &mut offset);
let conflict_index = read_u64(&bytes, &mut offset);
let conflict_term = read_u64(&bytes, &mut offset);
Self { version, term, success, match_index, conflict_index, conflict_term }
Self {
version,
term,
success,
match_index,
conflict_index,
conflict_term,
}
}
}
@@ -502,7 +517,11 @@ impl BytesConvertable for ReadIndexResponse {
let commit_index = read_u64(&bytes, &mut offset);
let leader_term = read_u64(&bytes, &mut offset);
let is_leader = bytes.get(offset).copied().unwrap_or(0) == 1;
Self { commit_index, leader_term, is_leader }
Self {
commit_index,
leader_term,
is_leader,
}
}
}
+6 -7
View File
@@ -7,15 +7,14 @@ pub mod sync;
pub use handler::find_primary_in_cluster;
pub use handler::{
broadcast_append_entries, broadcast_ref_update, broadcast_role_changed,
get_category_members, get_cluster_nodes, is_leader_lease_valid, list_all_groups,
renew_leader_lease, route_group_for, start_node_actor, GitNodeActor, GitNodeArgs, RepoEntry,
GitNodeActor, GitNodeArgs, RepoEntry, broadcast_append_entries, broadcast_ref_update,
broadcast_role_changed, get_category_members, get_cluster_nodes, is_leader_lease_valid,
list_all_groups, renew_leader_lease, route_group_for, start_node_actor,
};
pub use message::{
AppendEntriesRequest, AppendEntriesResponse, ElectionRequest, ElectionResult,
GitNodeMessage, NodeHealth, ReadIndexRequest, ReadIndexResponse, RefUpdateEvent,
RepoActorMessage, RoleChangedEvent, RouteDecision, SerializedRaftEntry,
ROLE_PRIMARY, ROLE_REPLICA, RAFT_MSG_VERSION,
AppendEntriesRequest, AppendEntriesResponse, ElectionRequest, ElectionResult, GitNodeMessage,
NodeHealth, RAFT_MSG_VERSION, ROLE_PRIMARY, ROLE_REPLICA, ReadIndexRequest, ReadIndexResponse,
RefUpdateEvent, RepoActorMessage, RoleChangedEvent, RouteDecision, SerializedRaftEntry,
};
pub use raft_log::{Command as RaftCommand, LogEntry as RaftLogEntry, RaftLog};
pub use server::init_actor_cluster;
+55 -24
View File
@@ -13,9 +13,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
use ractor_cluster::BytesConvertable;
use crate::error::{GitError, GitResult};
use crate::actor::snapshot::{RaftSnapshot, SnapshotStorage};
use crate::actor::handler::RepoEntry;
use crate::actor::snapshot::{RaftSnapshot, SnapshotStorage};
use crate::error::{GitError, GitResult};
use std::collections::HashMap;
/// Protocol version for forward/backward compatibility.
@@ -56,11 +56,19 @@ impl Command {
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::new();
match self {
Command::RefUpdate { relative_path, ref_name, old_oid, new_oid } => {
Command::RefUpdate {
relative_path,
ref_name,
old_oid,
new_oid,
} => {
buf.push(0); // tag
encode_strings(&mut buf, &[relative_path, ref_name, old_oid, new_oid]);
}
Command::RegisterRepo { relative_path, storage_name } => {
Command::RegisterRepo {
relative_path,
storage_name,
} => {
buf.push(1);
encode_strings(&mut buf, &[relative_path, storage_name]);
}
@@ -68,7 +76,10 @@ impl Command {
buf.push(2);
encode_strings(&mut buf, &[relative_path]);
}
Command::SetPrimary { storage_name, relative_paths } => {
Command::SetPrimary {
storage_name,
relative_paths,
} => {
buf.push(3);
encode_string(&mut buf, storage_name);
buf.extend((relative_paths.len() as u32).to_be_bytes());
@@ -192,7 +203,12 @@ pub struct LogEntry {
impl LogEntry {
pub fn new(term: u64, index: u64, command: Command) -> Self {
let checksum = Self::compute_checksum(term, index, &command);
Self { term, index, command, checksum }
Self {
term,
index,
command,
checksum,
}
}
fn compute_checksum(term: u64, index: u64, command: &Command) -> u32 {
@@ -243,7 +259,12 @@ impl LogEntry {
return None;
}
Some(LogEntry { term, index, command, checksum })
Some(LogEntry {
term,
index,
command,
checksum,
})
}
}
@@ -320,7 +341,9 @@ impl RaftStorage {
.append(true)
.open(&self.index_path)
.map_err(GitError::Io)?;
index_file.write_all(&index_entry.encode()).map_err(GitError::Io)?;
index_file
.write_all(&index_entry.encode())
.map_err(GitError::Io)?;
index_file.flush().map_err(GitError::Io)?;
Ok(entry.index)
@@ -383,7 +406,12 @@ impl RaftStorage {
);
break;
}
entries.push(LogEntry { term, index, command, checksum });
entries.push(LogEntry {
term,
index,
command,
checksum,
});
}
None => {
tracing::warn!(index, "failed to decode command during recovery, stopping");
@@ -478,7 +506,10 @@ impl RaftLog {
let entries = storage.load_all()?;
let next_index = entries.last().map(|e| e.index + 1).unwrap_or(snapshot_index + 1);
let next_index = entries
.last()
.map(|e| e.index + 1)
.unwrap_or(snapshot_index + 1);
let last_applied = entries.last().map(|e| e.index).unwrap_or(snapshot_index);
Ok(Self {
@@ -610,7 +641,9 @@ impl RaftLog {
return Ok(()); // Nothing to compact
}
let keep: Vec<LogEntry> = self.entries.iter()
let keep: Vec<LogEntry> = self
.entries
.iter()
.filter(|e| e.index >= from_index)
.cloned()
.collect();
@@ -622,11 +655,7 @@ impl RaftLog {
self.storage.truncate_and_rebuild(&keep)?;
self.entries = keep;
tracing::info!(
from_index,
kept = self.entries.len(),
"raft log compacted"
);
tracing::info!(from_index, kept = self.entries.len(), "raft log compacted");
Ok(())
}
@@ -635,7 +664,9 @@ impl RaftLog {
/// when a follower detects a term mismatch, it must delete the conflicting entry
/// and all entries that follow.
pub fn truncate_from(&mut self, from_index: u64) -> GitResult<()> {
let keep: Vec<LogEntry> = self.entries.iter()
let keep: Vec<LogEntry> = self
.entries
.iter()
.filter(|e| e.index < from_index)
.cloned()
.collect();
@@ -677,11 +708,7 @@ impl RaftLog {
/// Create a snapshot of the current state and compact the log.
pub fn create_snapshot(&mut self, repos: HashMap<String, RepoEntry>) -> GitResult<()> {
let snapshot = RaftSnapshot::new(
self.last_applied,
self.term_at(self.last_applied),
repos,
);
let snapshot = RaftSnapshot::new(self.last_applied, self.term_at(self.last_applied), repos);
self.snapshot_storage.save(&snapshot)?;
self.snapshot_index = snapshot.last_included_index;
@@ -701,12 +728,16 @@ impl RaftLog {
}
/// Restore state from a snapshot.
pub fn restore_snapshot(&mut self, snapshot: RaftSnapshot) -> GitResult<HashMap<String, RepoEntry>> {
pub fn restore_snapshot(
&mut self,
snapshot: RaftSnapshot,
) -> GitResult<HashMap<String, RepoEntry>> {
self.snapshot_index = snapshot.last_included_index;
self.snapshot_term = snapshot.last_included_term;
self.commit_index = snapshot.last_included_index;
self.last_applied = snapshot.last_included_index;
self.next_index.store(snapshot.last_included_index + 1, Ordering::SeqCst);
self.next_index
.store(snapshot.last_included_index + 1, Ordering::SeqCst);
// Clear all entries (they're covered by the snapshot)
self.entries.clear();
+5 -2
View File
@@ -79,11 +79,14 @@ impl RaftSnapshot {
let last_commit = read_string(data, &mut offset)?;
let read_only = data.get(offset).copied().unwrap_or(0) == 1;
offset += 1;
repos.insert(path, RepoEntry {
repos.insert(
path,
RepoEntry {
role,
last_commit,
read_only,
});
},
);
}
Some(Self {
+4 -5
View File
@@ -219,7 +219,9 @@ async fn sync_via_pack_service_to_file(
temp_dir: &Path,
) -> Result<Option<PathBuf>, String> {
use crate::pb::pack_service_client::PackServiceClient;
use crate::pb::{AdvertiseRefsRequest, PackObjectsOptions, PackObjectsRequest, RepositoryHeader};
use crate::pb::{
AdvertiseRefsRequest, PackObjectsOptions, PackObjectsRequest, RepositoryHeader,
};
use tokio::io::AsyncWriteExt;
use tokio_stream::StreamExt;
@@ -358,10 +360,7 @@ fn update_local_ref(repo_path: &Path, ref_name: &str, new_oid: &str) {
/// Apply a committed Raft command to the local git repository.
/// This is called on followers when they receive committed entries from the leader.
pub fn apply_raft_command_to_repo(
repo_prefix: &Path,
command: &crate::actor::raft_log::Command,
) {
pub fn apply_raft_command_to_repo(repo_prefix: &Path, command: &crate::actor::raft_log::Command) {
match command {
crate::actor::raft_log::Command::RefUpdate {
relative_path,
+50 -24
View File
@@ -120,7 +120,10 @@ fn generate_linguist(
(".m", "Objective-C"), // Mercury, MUF, etc. also claim .m
(".w", "CWeb"), // OpenSCAD also claims .w
(".q", "Q"), // KBD also claims .q
].iter().cloned().collect();
]
.iter()
.cloned()
.collect();
for (ext, (lang, ltype)) in ext_primary {
if let Some(&preferred) = priority_overrides.get(ext.as_str()) {
@@ -140,7 +143,11 @@ fn generate_linguist(
if let Some(entry) = languages.get(preferred)
&& entry.extensions.iter().any(|e| e.to_lowercase() == ext)
{
ext_map.push((ext.to_string(), preferred.to_string(), entry.lang_type.clone()));
ext_map.push((
ext.to_string(),
preferred.to_string(),
entry.lang_type.clone(),
));
}
}
}
@@ -172,8 +179,12 @@ fn generate_linguist(
code.push_str("/// Key is lowercase extension including the dot, e.g. \".rs\".\n");
code.push_str("pub static EXTENSION_MAP: &[(&str, &str, &str)] = &[\n");
for (ext, lang, ltype) in &ext_map {
code.push_str(&format!(" (\"{}\", \"{}\", \"{}\"),\n",
escape_str(ext), escape_str(lang), escape_str(ltype)));
code.push_str(&format!(
" (\"{}\", \"{}\", \"{}\"),\n",
escape_str(ext),
escape_str(lang),
escape_str(ltype)
));
}
code.push_str("];\n\n");
@@ -182,8 +193,12 @@ fn generate_linguist(
code.push_str("/// Key is exact filename, e.g. \"Makefile\", \"Dockerfile\".\n");
code.push_str("pub static FILENAME_MAP: &[(&str, &str, &str)] = &[\n");
for (fname, lang, ltype) in &fname_map {
code.push_str(&format!(" (\"{}\", \"{}\", \"{}\"),\n",
escape_str(fname), escape_str(lang), escape_str(ltype)));
code.push_str(&format!(
" (\"{}\", \"{}\", \"{}\"),\n",
escape_str(fname),
escape_str(lang),
escape_str(ltype)
));
}
code.push_str("];\n\n");
@@ -191,8 +206,11 @@ fn generate_linguist(
code.push_str("/// Language name to type mapping.\n");
code.push_str("pub static LANG_TYPE_MAP: &[(&str, &str)] = &[\n");
for (lang, ltype) in &lang_type_map {
code.push_str(&format!(" (\"{}\", \"{}\"),\n",
escape_str(lang), escape_str(ltype)));
code.push_str(&format!(
" (\"{}\", \"{}\"),\n",
escape_str(lang),
escape_str(ltype)
));
}
code.push_str("];\n\n");
@@ -202,8 +220,11 @@ fn generate_linguist(
let mut group_vec: Vec<_> = lang_group_map.iter().collect();
group_vec.sort_by(|a, b| a.0.cmp(b.0));
for (lang, group) in group_vec {
code.push_str(&format!(" (\"{}\", \"{}\"),\n",
escape_str(lang), escape_str(group)));
code.push_str(&format!(
" (\"{}\", \"{}\"),\n",
escape_str(lang),
escape_str(group)
));
}
code.push_str("];\n\n");
@@ -213,23 +234,28 @@ fn generate_linguist(
code.push_str(" match ext {\n");
// Image extensions
let image_exts = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
".webp", ".tiff", ".tif", ".psd", ".raw", ".heic", ".heif", ".avif",
".apng", ".jfif", ".pjpeg", ".pjp"];
let image_exts = [
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".tiff", ".tif", ".psd",
".raw", ".heic", ".heif", ".avif", ".apng", ".jfif", ".pjpeg", ".pjp",
];
for ext in &image_exts {
code.push_str(&format!(" \"{}\" => \"Image\",\n", ext));
}
// Video extensions
let video_exts = [".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm",
".m4v", ".mpg", ".mpeg", ".3gp", ".3g2", ".ogv", ".vob"];
let video_exts = [
".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".mpg", ".mpeg", ".3gp",
".3g2", ".ogv", ".vob",
];
for ext in &video_exts {
code.push_str(&format!(" \"{}\" => \"Video\",\n", ext));
}
// Audio extensions
let audio_exts = [".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a",
".opus", ".aiff", ".ape", ".alac", ".mid", ".midi"];
let audio_exts = [
".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a", ".opus", ".aiff", ".ape", ".alac",
".mid", ".midi",
];
for ext in &audio_exts {
code.push_str(&format!(" \"{}\" => \"Audio\",\n", ext));
}
@@ -241,11 +267,12 @@ fn generate_linguist(
}
// Other binary
let binary_exts = [".exe", ".dll", ".so", ".dylib", ".a", ".lib", ".o",
".obj", ".bin", ".dat", ".db", ".sqlite", ".sqlite3", ".pyc", ".pyo",
".class", ".jar", ".war", ".ear", ".zip", ".tar", ".gz",
".bz2", ".xz", ".7z", ".rar", ".pdf", ".doc", ".docx", ".xls",
".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wasm", ".node"];
let binary_exts = [
".exe", ".dll", ".so", ".dylib", ".a", ".lib", ".o", ".obj", ".bin", ".dat", ".db",
".sqlite", ".sqlite3", ".pyc", ".pyo", ".class", ".jar", ".war", ".ear", ".zip", ".tar",
".gz", ".bz2", ".xz", ".7z", ".rar", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt",
".pptx", ".odt", ".ods", ".odp", ".wasm", ".node",
];
for ext in &binary_exts {
code.push_str(&format!(" \"{}\" => \"Binary\",\n", ext));
}
@@ -259,8 +286,7 @@ fn generate_linguist(
}
fn escape_str(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn proto_files(proto_dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
+4 -5
View File
@@ -39,17 +39,16 @@ impl GitBare {
for oid_bytes in &request.oids {
let hex: String = oid_bytes.iter().map(|b| format!("{b:02x}")).collect();
if let Ok(oid) = gix::ObjectId::from_hex(hex.as_bytes()) {
if let Ok(obj) = repo.find_object(oid) {
if let Ok(commit) = obj.try_into_commit() {
if let Ok(oid) = gix::ObjectId::from_hex(hex.as_bytes())
&& let Ok(obj) = repo.find_object(oid)
&& let Ok(commit) = obj.try_into_commit()
{
commits.push(crate::commit::get_commit::commit_to_pb(
self,
&commit,
request.include_stats,
));
}
}
}
if commits.len() >= 100 {
break;
}
+4 -5
View File
@@ -58,16 +58,15 @@ impl GitBare {
for line in stdout.lines().skip(request.offset as usize) {
let hex = line.trim();
if let Ok(oid) = gix::ObjectId::from_hex(hex.as_bytes()) {
if let Ok(obj) = repo.find_object(oid) {
if let Ok(commit) = obj.try_into_commit() {
if let Ok(oid) = gix::ObjectId::from_hex(hex.as_bytes())
&& let Ok(obj) = repo.find_object(oid)
&& let Ok(commit) = obj.try_into_commit()
{
commits.push(crate::commit::get_commit::commit_to_pb(
self, &commit, false,
));
}
}
}
}
Ok(CommitsByMessageResponse { commits })
}
+21 -10
View File
@@ -271,10 +271,8 @@ impl DiskCache {
}
let start = std::time::Instant::now();
let path = self.cache_file_path(namespace, digest);
if !path.exists() {
crate::metrics::record_cache_op("disk", "miss", start.elapsed());
return Ok(None);
}
// Check expiry before reading, but handle concurrent deletion gracefully.
if let Ok(metadata) = std::fs::metadata(&path)
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = SystemTime::now().duration_since(modified)
@@ -292,7 +290,9 @@ impl DiskCache {
crate::metrics::record_cache_op("disk", "expired", start.elapsed());
return Ok(None);
}
let data = std::fs::read(&path).map_err(GitError::Io)?;
match std::fs::read(&path) {
Ok(data) => {
tracing::debug!(
namespace = %namespace,
digest = %digest,
@@ -303,6 +303,14 @@ impl DiskCache {
crate::metrics::record_cache_op("disk", "hit", start.elapsed());
Ok(Some(data))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// File was deleted between metadata check and read — treat as miss.
crate::metrics::record_cache_op("disk", "miss", start.elapsed());
Ok(None)
}
Err(e) => Err(GitError::Io(e)),
}
}
/// Insert a cached response for the given namespace and digest.
pub fn insert(&self, namespace: &str, digest: &str, data: &[u8]) -> GitResult<()> {
@@ -338,9 +346,8 @@ impl DiskCache {
return Ok(None);
}
let path = self.cache_file_path(namespace, digest);
if !path.exists() {
return Ok(None);
}
// Check expiry; handle concurrent deletion gracefully.
if let Ok(metadata) = std::fs::metadata(&path)
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = SystemTime::now().duration_since(modified)
@@ -349,8 +356,12 @@ impl DiskCache {
std::fs::remove_file(&path).ok();
return Ok(None);
}
let file = std::fs::File::open(&path).map_err(GitError::Io)?;
Ok(Some(file))
match std::fs::File::open(&path) {
Ok(file) => Ok(Some(file)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(GitError::Io(e)),
}
}
/// Open a cache file for streaming write.
+10 -14
View File
@@ -128,10 +128,14 @@ pub fn run_hook_dir(
/// Run a single hook script with stdin data and timeout.
fn run_single_script(script_path: &Path, stdin_data: &[u8], timeout: Duration) -> HookResult {
// Use Stdio::null() for stdout/stderr to prevent pipe-buffer deadlock.
// With Stdio::piped() + never reading, a hook that writes >64KB of output
// would block the child on write(), and the parent's try_wait() would
// loop until timeout before killing it.
let child = std::process::Command::new(script_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match child {
@@ -143,17 +147,12 @@ fn run_single_script(script_path: &Path, stdin_data: &[u8], timeout: Duration) -
let wait_result = c.wait_timeout(timeout);
match wait_result {
Ok(Some(status)) => {
// Process exited within timeout, get its output
// Note: We already have the status, so we need to construct output differently
// Since wait_with_output would fail after try_wait, we return status-only output
HookResult {
Ok(Some(status)) => HookResult {
accepted: status.success(),
exit_code: status.code().unwrap_or(-1),
stdout: String::new(), // stdout was consumed by the process
stderr: String::new(), // stderr was consumed by the process
}
}
stdout: String::new(),
stderr: String::new(),
},
Ok(None) => {
tracing::warn!(
script = %script_path.display(),
@@ -161,7 +160,6 @@ fn run_single_script(script_path: &Path, stdin_data: &[u8], timeout: Duration) -
"hook script timed out, killing"
);
let _ = c.kill();
// Explicitly wait to reap the zombie process
let _ = c.wait();
HookResult::rejected(format!(
"hook script timed out after {}s: {}",
@@ -171,7 +169,6 @@ fn run_single_script(script_path: &Path, stdin_data: &[u8], timeout: Duration) -
}
Err(e) => {
let _ = c.kill();
// Explicitly wait to reap the zombie process
let _ = c.wait();
HookResult::rejected(format!("hook script wait error: {e}"))
}
@@ -183,7 +180,6 @@ fn run_single_script(script_path: &Path, stdin_data: &[u8], timeout: Duration) -
error = %e,
"failed to spawn hook script"
);
// If the script can't be executed, treat as rejection
HookResult::rejected(format!("failed to spawn hook script: {e}"))
}
}
+9 -1
View File
@@ -106,7 +106,15 @@ pub fn validate_hook_content(content: &str) -> GitResult<()> {
/// Check for common obfuscation attempts.
fn check_obfuscation_attempts(content: &str) -> GitResult<()> {
// Check for excessive use of special characters that might indicate obfuscation
let special_char_count = content.chars().filter(|c| matches!(c, '$' | '`' | '\\' | '|' | ';' | '&' | '(' | ')' | '{' | '}' | '[' | ']')).count();
let special_char_count = content
.chars()
.filter(|c| {
matches!(
c,
'$' | '`' | '\\' | '|' | ';' | '&' | '(' | ')' | '{' | '}' | '[' | ']'
)
})
.count();
let total_chars = content.chars().count();
// If more than 30% of content is special characters, it's suspicious
+11 -7
View File
@@ -75,7 +75,9 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
builder = builder.max_log_files(retention);
}
let file_appender = builder.build(&log_dir).expect("failed to create log directory");
let file_appender = builder
.build(&log_dir)
.expect("failed to create log directory");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let file_layer = fmt::layer()
@@ -143,10 +145,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
);
if disk_cache_enabled {
tracing::info!(
max_age_secs = disk_cache_max_age,
"disk cache enabled"
);
tracing::info!(max_age_secs = disk_cache_max_age, "disk cache enabled");
disk_cache.cleanup_on_startup()?;
gitks::disk_cache::start_cache_cleanup_task(disk_cache.clone(), Duration::from_secs(300));
} else {
@@ -290,8 +289,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let raft_data_dir = repo_prefix.join(".gitks_raft");
let (node_actor, node_handle) =
init_actor_cluster(svc.clone(), storage_name.clone(), grpc_addr.clone(), raft_data_dir).await?;
let (node_actor, node_handle) = init_actor_cluster(
svc.clone(),
storage_name.clone(),
grpc_addr.clone(),
raft_data_dir,
)
.await?;
let svc = svc
.with_actor(node_actor.clone())
.with_grpc_addr(grpc_addr.clone());
+137 -62
View File
@@ -20,7 +20,7 @@
//! - GET /debug/config — Runtime configuration
use dashmap::DashMap;
use std::sync::atomic::{AtomicU64, AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
@@ -82,7 +82,9 @@ struct MetricsInner {
static METRICS: OnceLock<Arc<MetricsInner>> = OnceLock::new();
/// Handle for dynamic log level reload.
static LOG_RELOAD_HANDLE: OnceLock<Option<tracing_subscriber::reload::Handle<EnvFilter, tracing_subscriber::Registry>>> = OnceLock::new();
static LOG_RELOAD_HANDLE: OnceLock<
Option<tracing_subscriber::reload::Handle<EnvFilter, tracing_subscriber::Registry>>,
> = OnceLock::new();
use tracing_subscriber::EnvFilter;
@@ -141,7 +143,9 @@ fn record_duration_bucket(map: &DashMap<String, AtomicU64>, key_prefix: &str, du
}
pub fn set_slow_request_threshold(ms: u64) {
metrics().slow_request_threshold_ms.store(ms, Ordering::Relaxed);
metrics()
.slow_request_threshold_ms
.store(ms, Ordering::Relaxed);
}
pub fn set_ready(ready: bool) {
@@ -254,12 +258,19 @@ pub fn record_hook_execution(hook_type: &str, result: &str, duration: Duration)
record_duration_bucket(&m.hook_duration_buckets, hook_type, duration_ms);
}
pub fn set_raft_state(term: u64, commit_index: u64, last_applied: u64, is_leader: bool, log_entries: u64) {
pub fn set_raft_state(
term: u64,
commit_index: u64,
last_applied: u64,
is_leader: bool,
log_entries: u64,
) {
let m = metrics();
m.raft_term.store(term, Ordering::Relaxed);
m.raft_commit_index.store(commit_index, Ordering::Relaxed);
m.raft_last_applied.store(last_applied, Ordering::Relaxed);
m.raft_is_leader.store(if is_leader { 1 } else { 0 }, Ordering::Relaxed);
m.raft_is_leader
.store(if is_leader { 1 } else { 0 }, Ordering::Relaxed);
m.raft_log_entries.store(log_entries, Ordering::Relaxed);
}
@@ -268,7 +279,8 @@ pub fn inc_raft_append_entries(success: bool) {
let m = metrics();
m.raft_append_entries_total.fetch_add(1, Ordering::Relaxed);
if success {
m.raft_append_entries_success.fetch_add(1, Ordering::Relaxed);
m.raft_append_entries_success
.fetch_add(1, Ordering::Relaxed);
}
}
@@ -296,14 +308,22 @@ fn prom_escape(value: &str) -> String {
out
}
fn render_counter_map(out: &mut String, name: &str, help: &str, map: &DashMap<String, AtomicU64>, labels: &[&str]) {
fn render_counter_map(
out: &mut String,
name: &str,
help: &str,
map: &DashMap<String, AtomicU64>,
labels: &[&str],
) {
out.push_str(&format!("# HELP {name} {help}\n"));
out.push_str(&format!("# TYPE {name} counter\n"));
for entry in map {
let (key, count) = (entry.key(), entry.value().load(Ordering::Relaxed));
let parts: Vec<&str> = key.split(':').collect();
if parts.len() == labels.len() {
let label_str: String = labels.iter().zip(parts.iter())
let label_str: String = labels
.iter()
.zip(parts.iter())
.map(|(l, v)| format!("{l}=\"{}\"", prom_escape(v)))
.collect::<Vec<_>>()
.join(",");
@@ -324,7 +344,10 @@ fn render_histogram(out: &mut String, name: &str, help: &str, map: &DashMap<Stri
} else {
bound_str.to_string()
};
out.push_str(&format!("{name}_bucket{{method=\"{}\",le=\"{le}\"}} {count}\n", prom_escape(key)));
out.push_str(&format!(
"{name}_bucket{{method=\"{}\",le=\"{le}\"}} {count}\n",
prom_escape(key)
));
}
}
out.push('\n');
@@ -350,16 +373,30 @@ pub fn render_metrics() -> String {
out.push_str(&format!("gitks_repository_count {repos}\n\n"));
// gRPC requests
render_counter_map(&mut out, "gitks_requests_total",
"Total gRPC requests by method and status", &m.request_count, &["method", "status"]);
render_counter_map(
&mut out,
"gitks_requests_total",
"Total gRPC requests by method and status",
&m.request_count,
&["method", "status"],
);
// gRPC duration
render_histogram(&mut out, "gitks_request_duration_milliseconds",
"Request duration histogram in ms", &m.duration_buckets);
render_histogram(
&mut out,
"gitks_request_duration_milliseconds",
"Request duration histogram in ms",
&m.duration_buckets,
);
// Slow requests
render_counter_map(&mut out, "gitks_slow_requests_total",
"Slow gRPC requests by method", &m.slow_request_count, &["method"]);
render_counter_map(
&mut out,
"gitks_slow_requests_total",
"Slow gRPC requests by method",
&m.slow_request_count,
&["method"],
);
// Cache
let hits = m.cache_hits.load(Ordering::Relaxed);
@@ -372,26 +409,58 @@ pub fn render_metrics() -> String {
out.push_str(&format!("gitks_cache_misses_total {misses}\n\n"));
// Errors
render_counter_map(&mut out, "gitks_errors_total",
"Total errors by kind", &m.error_count, &["kind"]);
render_counter_map(
&mut out,
"gitks_errors_total",
"Total errors by kind",
&m.error_count,
&["kind"],
);
// Git subprocess
render_counter_map(&mut out, "gitks_git_cmd_total",
"Git subprocess calls by command", &m.git_cmd_count, &["command"]);
render_histogram(&mut out, "gitks_git_cmd_duration_milliseconds",
"Git subprocess duration in ms", &m.git_cmd_duration_buckets);
render_counter_map(
&mut out,
"gitks_git_cmd_total",
"Git subprocess calls by command",
&m.git_cmd_count,
&["command"],
);
render_histogram(
&mut out,
"gitks_git_cmd_duration_milliseconds",
"Git subprocess duration in ms",
&m.git_cmd_duration_buckets,
);
// Cache operations
render_counter_map(&mut out, "gitks_cache_ops_total",
"Cache operations by cache and result", &m.cache_op_count, &["cache", "result"]);
render_histogram(&mut out, "gitks_cache_op_duration_milliseconds",
"Cache operation duration in ms", &m.cache_op_duration_buckets);
render_counter_map(
&mut out,
"gitks_cache_ops_total",
"Cache operations by cache and result",
&m.cache_op_count,
&["cache", "result"],
);
render_histogram(
&mut out,
"gitks_cache_op_duration_milliseconds",
"Cache operation duration in ms",
&m.cache_op_duration_buckets,
);
// Hook execution
render_counter_map(&mut out, "gitks_hook_executions_total",
"Hook executions by type and result", &m.hook_count, &["hook_type", "result"]);
render_histogram(&mut out, "gitks_hook_duration_milliseconds",
"Hook execution duration in ms", &m.hook_duration_buckets);
render_counter_map(
&mut out,
"gitks_hook_executions_total",
"Hook executions by type and result",
&m.hook_count,
&["hook_type", "result"],
);
render_histogram(
&mut out,
"gitks_hook_duration_milliseconds",
"Hook execution duration in ms",
&m.hook_duration_buckets,
);
// Raft consensus metrics
let raft_term = m.raft_term.load(Ordering::Relaxed);
@@ -426,11 +495,15 @@ pub fn render_metrics() -> String {
out.push_str("# HELP gitks_raft_append_entries_total Total AppendEntries RPCs sent\n");
out.push_str("# TYPE gitks_raft_append_entries_total counter\n");
out.push_str(&format!("gitks_raft_append_entries_total {raft_ae_total}\n\n"));
out.push_str(&format!(
"gitks_raft_append_entries_total {raft_ae_total}\n\n"
));
out.push_str("# HELP gitks_raft_append_entries_success Successful AppendEntries RPCs\n");
out.push_str("# TYPE gitks_raft_append_entries_success counter\n");
out.push_str(&format!("gitks_raft_append_entries_success {raft_ae_success}\n\n"));
out.push_str(&format!(
"gitks_raft_append_entries_success {raft_ae_success}\n\n"
));
out.push_str("# HELP gitks_raft_elections_total Total elections triggered\n");
out.push_str("# TYPE gitks_raft_elections_total counter\n");
@@ -438,7 +511,9 @@ pub fn render_metrics() -> String {
out.push_str("# HELP gitks_raft_elections_won Elections won by this node\n");
out.push_str("# TYPE gitks_raft_elections_won counter\n");
out.push_str(&format!("gitks_raft_elections_won {raft_elections_won}\n\n"));
out.push_str(&format!(
"gitks_raft_elections_won {raft_elections_won}\n\n"
));
out
}
@@ -446,12 +521,12 @@ pub fn render_metrics() -> String {
use bytes::Bytes;
use http_body_util::Full;
use hyper::body::Incoming;
use hyper::{Request, Response, Method};
use hyper::service::Service;
use hyper::{Method, Request, Response};
use std::convert::Infallible;
use std::future::Future;
use std::pin::Pin;
use std::net::SocketAddr;
use std::pin::Pin;
/// Global cancel token for the HTTP server, set from main.
static HTTP_CANCEL: OnceLock<tokio_util::sync::CancellationToken> = OnceLock::new();
@@ -501,9 +576,7 @@ async fn handle_request(req: Request<Incoming>) -> Result<Response<Full<Bytes>>,
let body = render_metrics();
text_response(200, "text/plain; version=0.0.4; charset=utf-8", body)
}
(Method::GET, "/health") => {
json_response(200, r#"{"status":"healthy"}"#)
}
(Method::GET, "/health") => json_response(200, r#"{"status":"healthy"}"#),
(Method::GET, "/ready") => {
if metrics().ready.load(Ordering::Relaxed) {
json_response(200, r#"{"status":"ready"}"#)
@@ -519,30 +592,28 @@ async fn handle_request(req: Request<Incoming>) -> Result<Response<Full<Bytes>>,
};
json_response(200, &format!(r#"{{"log_level":"{msg}"}}"#))
}
(Method::PUT, "/debug/log-level") => {
match handle_log_level_update(req).await {
(Method::PUT, "/debug/log-level") => match handle_log_level_update(req).await {
Ok(resp) => resp,
Err(e) => json_response(400, &format!(r#"{{"error":"{e}"}}"#)),
}
}
},
(Method::GET, "/debug/config") => {
let threshold = metrics().slow_request_threshold_ms.load(Ordering::Relaxed);
let ready = metrics().ready.load(Ordering::Relaxed);
json_response(200, &format!(
r#"{{"slow_request_threshold_ms":{},"ready":{}}}"#, threshold, ready
))
}
_ => {
json_response(404, r#"{"error":"not found"}"#)
json_response(
200,
&format!(
r#"{{"slow_request_threshold_ms":{},"ready":{}}}"#,
threshold, ready
),
)
}
_ => json_response(404, r#"{"error":"not found"}"#),
};
Ok(response)
}
async fn handle_log_level_update(
req: Request<Incoming>,
) -> Result<Response<Full<Bytes>>, String> {
async fn handle_log_level_update(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, String> {
use http_body_util::BodyExt;
let body_bytes = req
@@ -551,8 +622,8 @@ async fn handle_log_level_update(
.map_err(|e| format!("failed to read body: {e}"))?
.to_bytes();
let new_filter = String::from_utf8(body_bytes.to_vec())
.map_err(|e| format!("invalid UTF-8: {e}"))?;
let new_filter =
String::from_utf8(body_bytes.to_vec()).map_err(|e| format!("invalid UTF-8: {e}"))?;
let new_filter = new_filter.trim().to_string();
if new_filter.is_empty() {
@@ -566,25 +637,29 @@ async fn handle_log_level_update(
Ok(json_response(500, &format!(r#"{{"error":"{e}"}}"#)))
} else {
tracing::info!(new_filter = %new_filter, "log level updated via HTTP");
Ok(json_response(200, &format!(
r#"{{"status":"ok","filter":"{}"}}"#, new_filter
)))
Ok(json_response(
200,
&format!(r#"{{"status":"ok","filter":"{}"}}"#, new_filter),
))
}
}
Err(e) => Ok(json_response(400, &format!(
r#"{{"error":"invalid filter: {e}"}}"#
))),
Err(e) => Ok(json_response(
400,
&format!(r#"{{"error":"invalid filter: {e}"}}"#),
)),
},
_ => Ok(json_response(501, r#"{"error":"dynamic log level not configured"}"#)),
_ => Ok(json_response(
501,
r#"{"error":"dynamic log level not configured"}"#,
)),
}
}
/// Start the HTTP server (metrics + health + debug) using hyper 1.x.
pub fn start_metrics_server(port: u16) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let listener = match tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port)))
.await
{
let listener =
match tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).await {
Ok(l) => l,
Err(e) => {
tracing::error!(port, error = %e, "failed to bind HTTP server");
+7 -3
View File
@@ -6,9 +6,9 @@ use tokio::process::Command;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use super::CancellableReceiverStream;
use crate::bare::GitBare;
use crate::pb::ReceivePackResponse;
use super::CancellableReceiverStream;
/// Maximum time allowed for a git receive-pack process before it is killed.
const RECEIVE_PACK_TIMEOUT: Duration = Duration::from_secs(1800); // 30 minutes
@@ -28,7 +28,8 @@ impl GitBare {
input: impl tokio_stream::Stream<Item = Result<crate::pb::ReceivePackRequest, tonic::Status>>
+ Send
+ 'static,
) -> Result<CancellableReceiverStream<Result<ReceivePackResponse, tonic::Status>>, tonic::Status> {
) -> Result<CancellableReceiverStream<Result<ReceivePackResponse, tonic::Status>>, tonic::Status>
{
let bare_dir = self.bare_dir.to_string_lossy().into_owned();
tracing::info!(
repo = %bare_dir,
@@ -186,6 +187,9 @@ impl GitBare {
let rx_stream = ReceiverStream::new(rx);
let cancel_guard = cancel_token_clone.clone().drop_guard();
Ok(super::CancellableReceiverStream::new(rx_stream, cancel_guard))
Ok(super::CancellableReceiverStream::new(
rx_stream,
cancel_guard,
))
}
}
+7 -3
View File
@@ -6,9 +6,9 @@ use tokio::process::Command;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use super::CancellableReceiverStream;
use crate::bare::GitBare;
use crate::pb::UploadPackResponse;
use super::CancellableReceiverStream;
/// Maximum time allowed for a git upload-pack process before it is killed.
const UPLOAD_PACK_TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes
@@ -28,7 +28,8 @@ impl GitBare {
input: impl tokio_stream::Stream<Item = Result<crate::pb::UploadPackRequest, tonic::Status>>
+ Send
+ 'static,
) -> Result<CancellableReceiverStream<Result<UploadPackResponse, tonic::Status>>, tonic::Status> {
) -> Result<CancellableReceiverStream<Result<UploadPackResponse, tonic::Status>>, tonic::Status>
{
let bare_dir = self.bare_dir.to_string_lossy().into_owned();
tracing::info!(
repo = %bare_dir,
@@ -189,6 +190,9 @@ impl GitBare {
let rx_stream = ReceiverStream::new(rx);
let cancel_guard = cancel_token_clone.clone().drop_guard();
Ok(super::CancellableReceiverStream::new(rx_stream, cancel_guard))
Ok(super::CancellableReceiverStream::new(
rx_stream,
cancel_guard,
))
}
}
+1 -2
View File
@@ -192,8 +192,7 @@ pub fn set_max_concurrent(max: usize) {
.collect();
for key in keys {
l.semaphores
.insert(key, Arc::new(Semaphore::new(max)));
l.semaphores.insert(key, Arc::new(Semaphore::new(max)));
}
tracing::info!(max_concurrent = max, "rate limit max_concurrent updated");
+1 -1
View File
@@ -69,7 +69,7 @@ impl GitBare {
// Sort direction
let sort_prefix = match SortDirection::try_from(request.sort_direction) {
Ok(SortDirection::Asc) => "",
Ok(SortDirection::Desc) | _ => "-",
_ => "-",
};
args.push(format!("--sort={sort_prefix}refname"));
+4 -3
View File
@@ -53,11 +53,12 @@ impl GitBare {
})?;
}
drop(child.stdin.take());
let output = child.wait_with_output().map_err(|e| {
crate::error::GitError::CommandFailed {
let output =
child
.wait_with_output()
.map_err(|e| crate::error::GitError::CommandFailed {
status_code: None,
stderr: e.to_string(),
}
})?;
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
+3 -3
View File
@@ -113,15 +113,15 @@ impl GitBare {
])
.output();
if let Ok(out) = head_output {
if !out.status.success() {
if let Ok(out) = head_output
&& !out.status.success()
{
tracing::warn!(
repo = %self.bare_dir.display(),
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
"failed to auto-set remote HEAD"
);
}
}
Ok(UpdateRemoteMirrorResponse {
ok: true,
+13 -6
View File
@@ -5,9 +5,7 @@ use gix::object::tree::EntryKind;
use crate::bare::GitBare;
use crate::error::{GitError, GitResult};
use crate::pb::{
GetLanguageStatsRequest, GetLanguageStatsResponse, LanguageStat, object_selector,
};
use crate::pb::{GetLanguageStatsRequest, GetLanguageStatsResponse, LanguageStat, object_selector};
// Include the generated linguist rules
include!(concat!(env!("OUT_DIR"), "/linguist_generated.rs"));
@@ -181,7 +179,9 @@ impl GitBare {
let mut resolved: HashMap<String, LangStats> = HashMap::new();
for (lang, s) in stats {
let target = resolve_group(&lang).unwrap_or(&lang);
let entry = resolved.entry(target.to_string()).or_insert_with(|| LangStats {
let entry = resolved
.entry(target.to_string())
.or_insert_with(|| LangStats {
lang_type: s.lang_type.clone(),
..Default::default()
});
@@ -214,7 +214,11 @@ impl GitBare {
})
.collect();
languages.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.language.cmp(&b.language)));
languages.sort_by(|a, b| {
b.bytes
.cmp(&a.bytes)
.then_with(|| a.language.cmp(&b.language))
});
Ok(GetLanguageStatsResponse {
languages,
@@ -283,7 +287,10 @@ impl GitBare {
*ctx.total_bytes += size;
*ctx.total_lines += lines;
let s = ctx.stats.entry(lang_key.clone()).or_insert_with(|| LangStats {
let s = ctx
.stats
.entry(lang_key.clone())
.or_insert_with(|| LangStats {
lang_type: lang_type.to_string(),
..Default::default()
});
+1 -1
View File
@@ -57,7 +57,7 @@ impl GitBare {
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 found = parts.get(1).is_none_or(|&s| s != "missing");
let size = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
sizes.push(ObjectSize {
oid: oid.to_string(),
+6 -6
View File
@@ -19,14 +19,14 @@ impl GitBare {
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 (stats.commit_graph_size_bytes == 0 || strategy == OptimizeStrategy::Aggressive)
&& 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;
@@ -42,15 +42,15 @@ impl GitBare {
}
// Prune if aggressive
if strategy == OptimizeStrategy::Aggressive {
if let Ok(resp) = run_gc(self, true, true) {
if strategy == OptimizeStrategy::Aggressive
&& 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) {
+3 -3
View File
@@ -58,8 +58,9 @@ impl GitBare {
// 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>() {
if prefix_parts.len() >= 3
&& let Ok(line_num) = prefix_parts[0].parse::<u32>()
{
results.push(SearchResult {
path: prefix_parts[2].to_string(),
line: line_num,
@@ -68,7 +69,6 @@ impl GitBare {
}
}
}
}
Ok(SearchFilesByContentResponse { results })
}
+15 -13
View File
@@ -7,10 +7,20 @@ use crate::error::GitError;
use crate::error::GitResult;
/// Characters that are never allowed in git ref names / revision strings.
///
/// Git disallows: space, `~`, `^`, `:`, `?`, `*`, `[`, `\`, and all ASCII
/// control characters (bytes 031 and 127). The control characters are
/// checked separately via `is_ascii_control()`.
const FORBIDDEN_REF_CHARS: &[char] = &[
'~', '^', ':', '?', '*', '[', '\\', ' ', '\n', '\r', '\t', '\0',
'~', '^', ':', '?', '*', '[', '\\', ' ',
];
/// Returns true if `c` is an ASCII control character (bytes 031, 127).
fn is_ascii_control(c: char) -> bool {
let b = c as u32;
b <= 31 || b == 127
}
/// Validate a git reference name (branch, tag, etc.).
///
/// Git ref rules (from `git check-ref-format`):
@@ -44,7 +54,7 @@ pub fn validate_ref_name(name: &str) -> GitResult<()> {
"ref name cannot contain '@{{': {name}"
)));
}
if name.contains(|c: char| FORBIDDEN_REF_CHARS.contains(&c)) {
if name.contains(|c: char| FORBIDDEN_REF_CHARS.contains(&c) || is_ascii_control(c)) {
return Err(GitError::InvalidArgument(format!(
"ref name contains forbidden character: {name}"
)));
@@ -267,13 +277,7 @@ pub fn validate_config_key(key: &str) -> GitResult<()> {
}
/// Allowed URL schemes for git remotes.
const ALLOWED_REMOTE_SCHEMES: &[&str] = &[
"http://",
"https://",
"ssh://",
"git://",
"git+ssh://",
];
const ALLOWED_REMOTE_SCHEMES: &[&str] = &["http://", "https://", "ssh://", "git://", "git+ssh://"];
/// Validate a remote URL for git operations.
///
@@ -309,16 +313,14 @@ pub fn validate_remote_url(url: &str) -> GitResult<()> {
/// Refspecs must not contain null bytes, newlines, or shell metacharacters.
pub fn validate_refspec(refspec: &str) -> GitResult<()> {
if refspec.is_empty() {
return Err(GitError::InvalidArgument(
"refspec cannot be empty".into(),
));
return Err(GitError::InvalidArgument("refspec cannot be empty".into()));
}
if refspec.contains('\0') || refspec.contains('\n') || refspec.contains('\r') {
return Err(GitError::InvalidArgument(
"refspec contains invalid characters".into(),
));
}
if refspec.contains(|c: char| matches!(c, '$' | '`' | '(' | ')' | '{' | '}' | '|' | ';' | '&' | '<' | '>')) {
if refspec.contains(['$', '`', '(', ')', '{', '}', '|', ';', '&', '<', '>']) {
return Err(GitError::InvalidArgument(format!(
"refspec contains shell metacharacter: {refspec}"
)));
+86 -34
View File
@@ -122,12 +122,15 @@ impl GitksService {
pub fn cleanup_route_cache(&self) {
let before = self.route_cache.len();
self.route_cache.retain(|_key, cached| {
cached.created_at.elapsed() < ROUTE_CACHE_TTL
});
self.route_cache
.retain(|_key, cached| cached.created_at.elapsed() < ROUTE_CACHE_TTL);
let removed = before - self.route_cache.len();
if removed > 0 {
tracing::debug!(removed, remaining = self.route_cache.len(), "route cache cleaned");
tracing::debug!(
removed,
remaining = self.route_cache.len(),
"route cache cleaned"
);
}
}
@@ -388,9 +391,10 @@ impl GitksService {
&self,
command: crate::actor::raft_log::Command,
) -> Result<(), tonic::Status> {
let actor = self.node_actor.as_ref().ok_or_else(|| {
tonic::Status::failed_precondition("node actor not initialized")
})?;
let actor = self
.node_actor
.as_ref()
.ok_or_else(|| tonic::Status::failed_precondition("node actor not initialized"))?;
// Send the command to the actor for Raft processing
let result = ractor::call_t!(
@@ -405,7 +409,9 @@ impl GitksService {
if success {
Ok(())
} else {
Err(tonic::Status::aborted("Raft consensus failed: not leader or timeout"))
Err(tonic::Status::aborted(
"Raft consensus failed: not leader or timeout",
))
}
}
Err(e) => Err(tonic::Status::internal(format!("Raft write error: {e}"))),
@@ -415,20 +421,16 @@ impl GitksService {
/// Perform a ReadIndex check to ensure this node can serve consistent reads.
/// This confirms the Leader is still valid before reading from local state.
pub async fn raft_read_index(&self) -> Result<(), tonic::Status> {
let actor = self.node_actor.as_ref().ok_or_else(|| {
tonic::Status::failed_precondition("node actor not initialized")
})?;
let actor = self
.node_actor
.as_ref()
.ok_or_else(|| tonic::Status::failed_precondition("node actor not initialized"))?;
let request = crate::actor::message::ReadIndexRequest {
relative_path: String::new(),
};
let result = ractor::call_t!(
actor,
GitNodeMessage::ReadIndex,
5000,
request
);
let result = ractor::call_t!(actor, GitNodeMessage::ReadIndex, 5000, request);
match result {
Ok(response) => {
@@ -436,7 +438,7 @@ impl GitksService {
Ok(())
} else {
Err(tonic::Status::failed_precondition(
"not leader, cannot serve consistent read"
"not leader, cannot serve consistent read",
))
}
}
@@ -649,23 +651,73 @@ pub async fn serve(
let span = tracing::info_span!("gitks.server", %addr);
let _enter = span.enter();
tracing::info!("registering gRPC services");
let (health_reporter, health_service) = tonic_health::server::health_reporter();
let repo_svc = repository_service_server::RepositoryServiceServer::new(svc.clone());
let archive_svc = archive_service_server::ArchiveServiceServer::new(svc.clone());
let blame_svc = blame_service_server::BlameServiceServer::new(svc.clone());
let branch_svc = branch_service_server::BranchServiceServer::new(svc.clone());
let commit_svc = commit_service_server::CommitServiceServer::new(svc.clone());
let diff_svc = diff_service_server::DiffServiceServer::new(svc.clone());
let merge_svc = merge_service_server::MergeServiceServer::new(svc.clone());
let pack_svc = pack_service_server::PackServiceServer::new(svc.clone());
let ref_svc = ref_service_server::RefServiceServer::new(svc.clone());
let remote_svc = remote_service_server::RemoteServiceServer::new(svc.clone());
let tag_svc = tag_service_server::TagServiceServer::new(svc.clone());
let tree_svc = tree_service_server::TreeServiceServer::new(svc);
health_reporter
.set_serving::<repository_service_server::RepositoryServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<archive_service_server::ArchiveServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<blame_service_server::BlameServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<branch_service_server::BranchServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<commit_service_server::CommitServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<diff_service_server::DiffServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<merge_service_server::MergeServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<pack_service_server::PackServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<ref_service_server::RefServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<remote_service_server::RemoteServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<tag_service_server::TagServiceServer<GitksService>>()
.await;
health_reporter
.set_serving::<tree_service_server::TreeServiceServer<GitksService>>()
.await;
let server = tonic::transport::Server::builder()
.add_service(repository_service_server::RepositoryServiceServer::new(
svc.clone(),
))
.add_service(archive_service_server::ArchiveServiceServer::new(
svc.clone(),
))
.add_service(blame_service_server::BlameServiceServer::new(svc.clone()))
.add_service(branch_service_server::BranchServiceServer::new(svc.clone()))
.add_service(commit_service_server::CommitServiceServer::new(svc.clone()))
.add_service(diff_service_server::DiffServiceServer::new(svc.clone()))
.add_service(merge_service_server::MergeServiceServer::new(svc.clone()))
.add_service(pack_service_server::PackServiceServer::new(svc.clone()))
.add_service(ref_service_server::RefServiceServer::new(svc.clone()))
.add_service(remote_service_server::RemoteServiceServer::new(svc.clone()))
.add_service(tag_service_server::TagServiceServer::new(svc.clone()))
.add_service(tree_service_server::TreeServiceServer::new(svc));
.add_service(health_service)
.add_service(repo_svc)
.add_service(archive_svc)
.add_service(blame_svc)
.add_service(branch_svc)
.add_service(commit_svc)
.add_service(diff_svc)
.add_service(merge_svc)
.add_service(pack_svc)
.add_service(ref_svc)
.add_service(remote_svc)
.add_service(tag_svc)
.add_service(tree_svc);
tracing::info!("server ready, starting to accept connections");
server.serve(addr).await
}
+1 -1
View File
@@ -1,9 +1,9 @@
use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use crate::pack::CancellableReceiverStream;
use crate::pb::pack_service_client::PackServiceClient;
use crate::pb::*;
use crate::pack::CancellableReceiverStream;
use super::{GitksService, into_status};
+9 -3
View File
@@ -12,11 +12,16 @@ impl RemoteService for GitksService {
) -> Result<tonic::Response<FindRemoteRepositoryResponse>, tonic::Status> {
let m = crate::metrics::RequestMetrics::new("gitks.RemoteService/FindRemoteRepository");
let inner = request.into_inner();
let span = tracing::info_span!("remote.find_remote_repository", remote_url = %inner.remote_url);
let span =
tracing::info_span!("remote.find_remote_repository", remote_url = %inner.remote_url);
let _enter = span.enter();
tracing::info!(remote_url = %inner.remote_url, "find_remote_repository");
let resp = find_remote_repository(inner).map_err(super::into_status)?;
tracing::info!(refs_count = resp.refs.len(), exists = resp.exists, "find_remote_repository done");
tracing::info!(
refs_count = resp.refs.len(),
exists = resp.exists,
"find_remote_repository done"
);
m.record("ok");
Ok(tonic::Response::new(resp))
}
@@ -27,7 +32,8 @@ impl RemoteService for GitksService {
) -> Result<tonic::Response<FindRemoteRootRefResponse>, tonic::Status> {
let m = crate::metrics::RequestMetrics::new("gitks.RemoteService/FindRemoteRootRef");
let inner = request.into_inner();
let span = tracing::info_span!("remote.find_remote_root_ref", remote_url = %inner.remote_url);
let span =
tracing::info_span!("remote.find_remote_root_ref", remote_url = %inner.remote_url);
let _enter = span.enter();
tracing::info!(remote_url = %inner.remote_url, "find_remote_root_ref");
let resp = find_remote_root_ref(inner).map_err(super::into_status)?;
+10 -6
View File
@@ -7,7 +7,11 @@ use gitks::repository::lang_stats::{EXTENSION_MAP, FILENAME_MAP};
fn test_extension_map_lookup() {
// Verify .md is in the map
let result = EXTENSION_MAP.binary_search_by(|&(e, _, _)| e.cmp(".md"));
assert!(result.is_ok(), ".md should be in EXTENSION_MAP, got {:?}", result);
assert!(
result.is_ok(),
".md should be in EXTENSION_MAP, got {:?}",
result
);
let idx = result.unwrap();
assert_eq!(EXTENSION_MAP[idx].1, "Markdown");
assert_eq!(EXTENSION_MAP[idx].2, "prose");
@@ -122,10 +126,7 @@ fn test_language_stats_with_path() {
// Should NOT find README.md (it's at root level)
let md = resp.languages.iter().find(|l| l.language == "Markdown");
assert!(
md.is_none(),
"should not find Markdown in src/ directory"
);
assert!(md.is_none(), "should not find Markdown in src/ directory");
}
#[test]
@@ -145,6 +146,9 @@ fn test_language_stats_line_count_excludes_blank_lines() {
let md = resp.languages.iter().find(|l| l.language == "Markdown");
if let Some(md) = md {
// README.md: "# Test" and "Updated." are non-blank = 2 lines
assert!(md.lines >= 2, "should count at least 2 code lines for README.md");
assert!(
md.lines >= 2,
"should count at least 2 code lines for README.md"
);
}
}