Files
gitks/hooks/runner.rs
T
zhenyi a40da90ef9 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
2026-06-11 13:56:15 +08:00

319 lines
9.4 KiB
Rust

//! Hook execution runner.
//!
//! Executes server hooks, custom hooks, and gRPC callback hooks
//! in sequence for each git hook type (pre-receive, update, post-receive).
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Output, Stdio};
use std::time::Duration;
/// Result of a hook execution.
#[derive(Debug)]
pub struct HookResult {
pub accepted: bool,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
impl HookResult {
pub fn accepted() -> Self {
Self {
accepted: true,
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
}
}
pub fn rejected(stderr: String) -> Self {
Self {
accepted: false,
exit_code: 1,
stdout: String::new(),
stderr,
}
}
pub fn from_output(output: &Output) -> Self {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Self {
accepted: output.status.success(),
exit_code: output.status.code().unwrap_or(-1),
stdout,
stderr,
}
}
}
/// Run all hook scripts in a directory (sorted alphabetically).
/// Returns the first rejection or accepted if all pass.
pub fn run_hook_dir(
hook_dir: &Path,
hook_type: &str,
stdin_data: &[u8],
timeout: Duration,
) -> HookResult {
if !hook_dir.exists() {
return HookResult::accepted();
}
let mut scripts: Vec<PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir(hook_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
// Skip files starting with '.' (hidden files) or ending with '~' or '.sample'
if name.starts_with('.') || name.ends_with('~') || name.ends_with(".sample") {
continue;
}
scripts.push(path);
}
}
}
scripts.sort();
let dir_start = std::time::Instant::now();
for script in &scripts {
tracing::debug!(
hook_type = %hook_type,
script = %script.display(),
"executing hook script"
);
let script_start = std::time::Instant::now();
let result = run_single_script(script, stdin_data, timeout);
let script_elapsed = script_start.elapsed();
crate::metrics::record_hook_execution(
hook_type,
if result.accepted { "ok" } else { "rejected" },
script_elapsed,
);
if !result.accepted {
tracing::warn!(
hook_type = %hook_type,
script = %script.display(),
exit_code = result.exit_code,
stderr = %result.stderr,
elapsed_ms = script_elapsed.as_millis() as u64,
"hook script rejected"
);
return result;
}
tracing::debug!(
hook_type = %hook_type,
script = %script.display(),
elapsed_ms = script_elapsed.as_millis() as u64,
"hook script passed"
);
}
let dir_elapsed = dir_start.elapsed();
if !scripts.is_empty() {
tracing::info!(
hook_type = %hook_type,
script_count = scripts.len(),
elapsed_ms = dir_elapsed.as_millis() as u64,
"hook dir completed"
);
}
HookResult::accepted()
}
/// 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::null())
.stderr(Stdio::null())
.spawn();
match child {
Ok(mut c) => {
if let Some(ref mut stdin) = c.stdin {
let _ = stdin.write_all(stdin_data);
}
c.stdin = None;
let wait_result = c.wait_timeout(timeout);
match wait_result {
Ok(Some(status)) => HookResult {
accepted: status.success(),
exit_code: status.code().unwrap_or(-1),
stdout: String::new(),
stderr: String::new(),
},
Ok(None) => {
tracing::warn!(
script = %script_path.display(),
timeout_secs = timeout.as_secs(),
"hook script timed out, killing"
);
let _ = c.kill();
let _ = c.wait();
HookResult::rejected(format!(
"hook script timed out after {}s: {}",
timeout.as_secs(),
script_path.display()
))
}
Err(e) => {
let _ = c.kill();
let _ = c.wait();
HookResult::rejected(format!("hook script wait error: {e}"))
}
}
}
Err(e) => {
tracing::warn!(
script = %script_path.display(),
error = %e,
"failed to spawn hook script"
);
HookResult::rejected(format!("failed to spawn hook script: {e}"))
}
}
}
/// Wait for a child process with timeout.
trait ChildWaitTimeout {
fn wait_timeout(
&mut self,
timeout: Duration,
) -> std::io::Result<Option<std::process::ExitStatus>>;
}
impl ChildWaitTimeout for std::process::Child {
fn wait_timeout(
&mut self,
timeout: Duration,
) -> std::io::Result<Option<std::process::ExitStatus>> {
let start = std::time::Instant::now();
loop {
match self.try_wait() {
Ok(Some(status)) => return Ok(Some(status)),
Ok(None) => {
if start.elapsed() > timeout {
return Ok(None); // timeout
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => return Err(e),
}
}
}
}
/// Shell-escape a value by wrapping in single quotes.
/// Any embedded single quotes are escaped as `'\''`.
fn shell_escape(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
/// Generate the gitks hook runner script content.
/// This script is installed into each repository's hooks/ directory
/// and orchestrates the execution of server hooks, custom hooks, and gRPC callbacks.
pub fn generate_hook_runner_script(
hook_type: &str,
repo_relative_path: &str,
server_hooks_dir: Option<&str>,
hook_callback_addr: Option<&str>,
hook_timeout_secs: u64,
) -> String {
let server_hooks_section = if let Some(dir) = server_hooks_dir {
let escaped_dir = shell_escape(dir);
let escaped_hook_type = shell_escape(hook_type);
format!(
r#"
# Run server hooks
SERVER_HOOKS_DIR={escaped_dir}/{escaped_hook_type}.d
if [ -d "$SERVER_HOOKS_DIR" ]; then
for script in "$SERVER_HOOKS_DIR"/*; do
[ -f "$script" ] || continue
base=$(basename "$script")
skip=false
case "$base" in .*|*.sample|*~) skip=true;; esac
if [ "$skip" = "false" ]; then
"$script"
exit_code=$?
if [ $exit_code -ne 0 ]; then
exit $exit_code
fi
fi
done
fi
"#
)
} else {
String::new()
};
let custom_hooks_section = r#"
# Run custom hooks (per-repository)
CUSTOM_HOOKS_DIR="$GIT_DIR/custom_hooks/$GITKS_HOOK_TYPE.d"
if [ -d "$CUSTOM_HOOKS_DIR" ]; then
for script in "$CUSTOM_HOOKS_DIR"/*; do
[ -f "$script" ] || continue
base=$(basename "$script")
skip=false
case "$base" in .*|*.sample|*~) skip=true;; esac
if [ "$skip" = "false" ]; then
"$script"
exit_code=$?
if [ $exit_code -ne 0 ]; then
exit $exit_code
fi
fi
done
fi
"#;
let grpc_callback_section = if let Some(addr) = hook_callback_addr {
let escaped_addr = shell_escape(addr);
format!(
r#"
# gRPC callback to external HookService
if [ -n {escaped_addr} ]; then
# gRPC callback is handled by the gitks service directly
# The service will make the gRPC call after the git hook completes
# This section is a placeholder - actual gRPC callback is handled
# by the gitks receive_pack handler
fi
"#
)
} else {
String::new()
};
let escaped_hook_type = shell_escape(hook_type);
let escaped_repo_path = shell_escape(repo_relative_path);
format!(
r#"#!/bin/sh
# gitks hook runner for {hook_type}
# Repository: {repo_relative_path}
# Auto-generated by gitks - do not modify manually
GITKS_HOOK_TYPE={escaped_hook_type}
GITKS_REPO_RELATIVE_PATH={escaped_repo_path}
GITKS_HOOK_TIMEOUT="{hook_timeout_secs}"
{server_hooks_section}
{custom_hooks_section}
{grpc_callback_section}
exit 0
"#
)
}