use async_trait::async_trait;
use cuenv_core::DryRun;
use cuenv_core::Result;
use cuenv_core::manifest::Base;
use std::path::Path;
use crate::commands::CommandExecutor;
use crate::commands::git_hooks::find_git_root;
use crate::commands::sync::provider::{SyncMode, SyncOptions, SyncProvider, SyncResult};
pub struct GitHooksSyncProvider;
#[async_trait]
impl SyncProvider for GitHooksSyncProvider {
fn name(&self) -> &'static str {
"git-hooks"
}
fn description(&self) -> &'static str {
"Sync git hook scripts (pre-push, pre-commit)"
}
fn has_config(&self, _manifest: &Base) -> bool {
false
}
async fn sync_path(
&self,
path: &Path,
_package: &str,
options: &SyncOptions,
executor: &CommandExecutor,
) -> Result<SyncResult> {
let Ok(git_root) = find_git_root(path) else {
return Ok(SyncResult::success(
"Not in a git repository. Skipping git hooks sync.",
));
};
let module = executor.get_module(path)?;
let mut all_pre_push_hooks = std::collections::HashMap::new();
for instance in module.projects() {
if let Ok(project) = instance.deserialize::<cuenv_core::manifest::Project>() {
let hooks = project.pre_push_hooks_map();
for (name, hook) in hooks {
let hook_name = format_hook_name(&instance.path, &project.name, name);
all_pre_push_hooks.insert(hook_name, hook);
}
}
}
if all_pre_push_hooks.is_empty() {
return Ok(SyncResult::success(
"No pre-push hooks configured in this project.",
));
}
let dry_run = options.mode == SyncMode::DryRun;
let check = options.mode == SyncMode::Check;
let output = sync_pre_push_hook(&git_root, &all_pre_push_hooks, dry_run.into(), check)?;
Ok(SyncResult::success(output))
}
async fn sync_workspace(
&self,
_package: &str,
options: &SyncOptions,
executor: &CommandExecutor,
) -> Result<SyncResult> {
let cwd = std::env::current_dir().map_err(|e| {
cuenv_core::Error::configuration(format!("Failed to get current directory: {e}"))
})?;
let Ok(git_root) = find_git_root(&cwd) else {
return Ok(SyncResult::success(
"Not in a git repository. Skipping git hooks sync.",
));
};
let module = executor.discover_all_modules(&cwd)?;
let mut all_pre_push_hooks = std::collections::HashMap::new();
for instance in module.projects() {
if let Ok(project) = instance.deserialize::<cuenv_core::manifest::Project>() {
let hooks = project.pre_push_hooks_map();
for (name, hook) in hooks {
let hook_name = format_hook_name(&instance.path, &project.name, name);
all_pre_push_hooks.insert(hook_name, hook);
}
}
}
if all_pre_push_hooks.is_empty() {
return Ok(SyncResult::success(
"No pre-push hooks configured in any project.",
));
}
let dry_run = options.mode == SyncMode::DryRun;
let check = options.mode == SyncMode::Check;
let output = sync_pre_push_hook(&git_root, &all_pre_push_hooks, dry_run.into(), check)?;
Ok(SyncResult::success(output))
}
}
fn format_hook_name(instance_path: &std::path::Path, project_name: &str, name: String) -> String {
if instance_path.as_os_str().is_empty() || instance_path == std::path::Path::new(".") {
name
} else {
format!("{project_name}:{name}")
}
}
fn sync_pre_push_hook(
git_root: &Path,
_hooks: &std::collections::HashMap<String, cuenv_hooks::Hook>,
dry_run: DryRun,
check: bool,
) -> Result<String> {
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
let hooks_dir = git_root.join(".git/hooks");
let pre_push_path = hooks_dir.join("pre-push");
let hook_script = generate_pre_push_script();
if check {
if pre_push_path.exists() {
let existing = fs::read_to_string(&pre_push_path).unwrap_or_default();
if existing == hook_script {
return Ok("pre-push: in sync".to_string());
}
return Err(cuenv_core::Error::configuration(
"pre-push hook out of sync. Run 'cuenv sync git-hooks' to update.",
));
}
return Err(cuenv_core::Error::configuration(
"pre-push hook missing. Run 'cuenv sync git-hooks' to create.",
));
}
if pre_push_path.exists() && !dry_run.is_dry_run() {
let existing = fs::read_to_string(&pre_push_path).unwrap_or_default();
if existing == hook_script {
return Ok("pre-push: unchanged".to_string());
}
}
if dry_run.is_dry_run() {
if pre_push_path.exists() {
return Ok("pre-push: Would update".to_string());
}
return Ok("pre-push: Would create".to_string());
}
if !hooks_dir.exists() {
fs::create_dir_all(&hooks_dir).map_err(|e| cuenv_core::Error::Io {
source: e,
path: Some(hooks_dir.clone().into_boxed_path()),
operation: "create hooks directory".to_string(),
})?;
}
let existed = pre_push_path.exists();
fs::write(&pre_push_path, &hook_script).map_err(|e| cuenv_core::Error::Io {
source: e,
path: Some(pre_push_path.clone().into_boxed_path()),
operation: "write pre-push hook".to_string(),
})?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&pre_push_path)
.map_err(|e| cuenv_core::Error::Io {
source: e,
path: Some(pre_push_path.clone().into_boxed_path()),
operation: "get hook permissions".to_string(),
})?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&pre_push_path, perms).map_err(|e| cuenv_core::Error::Io {
source: e,
path: Some(pre_push_path.clone().into_boxed_path()),
operation: "set hook permissions".to_string(),
})?;
}
if existed {
Ok("pre-push: Updated".to_string())
} else {
Ok("pre-push: Created".to_string())
}
}
fn generate_pre_push_script() -> String {
let mut script = String::from(
r"#!/bin/sh
# Generated by cuenv - do not edit
# Source: hooks.prePush in env.cue
#
# This hook runs cuenv pre-push hook tasks before pushing.
# Each task is filtered by its inputs to only run when relevant files changed.
set -e
",
);
script.push_str(r#"# Run the pre-push hooks aggregator task
# This task depends on all individual pre-push hooks and will run them in parallel
# Each hook task filters itself based on changed files via CUENV_CHANGED_FILES
# Read stdin for refs being pushed (standard git pre-push input)
while read local_ref local_sha remote_ref remote_sha
do
# Get changed files between local and remote
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
# New branch - compare against default branch
remote_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main")
base_sha=$(git merge-base "$local_sha" "origin/$remote_branch" 2>/dev/null || echo "$local_sha~1")
else
base_sha="$remote_sha"
fi
# Get changed files, with error logging
if ! changed_files=$(git diff --name-only "$base_sha" "$local_sha" 2>&1); then
echo "Warning: Failed to get changed files: $changed_files" >&2
changed_files=""
fi
if [ -z "$changed_files" ]; then
echo "No files changed. Skipping pre-push hooks."
continue
fi
export CUENV_CHANGED_FILES="$changed_files"
export CUENV_PRE_PUSH_LOCAL_SHA="$local_sha"
export CUENV_PRE_PUSH_REMOTE_SHA="$remote_sha"
echo "Running pre-push hooks..."
cuenv task hooks.pre-push
exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "Pre-push hooks failed. Push aborted."
exit $exit_code
fi
done
exit 0
"#);
script
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_pre_push_script() {
let script = generate_pre_push_script();
assert!(script.contains("#!/bin/sh"));
assert!(script.contains("cuenv task hooks.pre-push"));
}
}