use crate::{GitRepo, ConfigLoader, MergePlanner, MergeExecutor, ExecutionOptionsBuilder, AutoUpdateParties, Error, Result};
use std::path::Path;
use std::fs;
use tracing::{info, warn, debug};
pub struct GitHookManager<'a> {
repo_path: &'a Path,
config_loader: &'a ConfigLoader,
}
impl<'a> GitHookManager<'a> {
pub fn new(repo_path: &'a Path, config_loader: &'a ConfigLoader) -> Self {
Self {
repo_path,
config_loader,
}
}
pub fn install_hooks(&self) -> Result<()> {
let hooks_dir = self.repo_path.join(".git/hooks");
if !hooks_dir.exists() {
return Err(Error::config(format!("Git hooks directory not found: {}", hooks_dir.display())));
}
self.install_pre_commit_hook(&hooks_dir)?;
self.install_post_commit_hook(&hooks_dir)?;
self.install_post_merge_hook(&hooks_dir)?;
info!("Git hooks installed successfully");
Ok(())
}
pub fn uninstall_hooks(&self) -> Result<()> {
let hooks_dir = self.repo_path.join(".git/hooks");
let pre_commit_hook = hooks_dir.join("pre-commit");
let post_commit_hook = hooks_dir.join("post-commit");
let post_merge_hook = hooks_dir.join("post-merge");
self.remove_hook_if_ours(&pre_commit_hook)?;
self.remove_hook_if_ours(&post_commit_hook)?;
self.remove_hook_if_ours(&post_merge_hook)?;
info!("Git hooks uninstalled successfully");
Ok(())
}
pub fn should_auto_update(&self, branch_name: &str) -> Result<Vec<String>> {
let config = self.config_loader.load_config(None)?;
debug!("Loaded config: auto_update.enabled = {}, {} parties configured",
config.auto_update.enabled, config.parties.len());
if !config.auto_update.enabled {
debug!("Auto-update is disabled");
return Ok(Vec::new());
}
let mut affected_parties = Vec::new();
for (party_name, party) in &config.parties {
debug!("Checking party '{}' with members: {:?}", party_name, party.members);
let branch_matches = party.members.iter().any(|member| {
let matches = member == branch_name || member == &format!("origin/{}", branch_name);
debug!(" member '{}' matches branch '{}': {}", member, branch_name, matches);
matches
});
if branch_matches {
debug!("Party '{}' contains branch '{}'", party_name, branch_name);
let should_update = match &config.auto_update.parties {
AutoUpdateParties::All(_) => true,
AutoUpdateParties::Specific(party_list) => party_list.contains(party_name),
};
debug!("Should auto-update party '{}': {}", party_name, should_update);
if should_update {
affected_parties.push(party_name.clone());
}
}
}
debug!("Branch '{}' affects parties: {:?}", branch_name, affected_parties);
Ok(affected_parties)
}
pub fn execute_auto_update(&self, parties: Vec<String>) -> Result<()> {
if parties.is_empty() {
return Ok(());
}
let config = self.config_loader.load_config(None)?;
let git_repo = GitRepo::open(self.repo_path)?;
let planner = MergePlanner::new(&config, &git_repo);
let executor = MergeExecutor::new(&git_repo)
.with_progress_bar(!config.auto_update.quiet);
info!("Auto-updating {} parties: {:?}", parties.len(), parties);
let options = ExecutionOptionsBuilder::new()
.allow_dirty() .build();
for party_name in parties {
match planner.create_plan(&party_name) {
Ok(plan) => {
match executor.execute_plan(&plan, &options) {
Ok(report) => {
info!("Auto-updated party '{}': {} successful, {} failed",
party_name, report.success_count(), report.failure_count());
if config.auto_update.push {
info!("Auto-push is enabled but not yet implemented");
}
}
Err(e) => {
warn!("Failed to auto-update party '{}': {}", party_name, e);
}
}
}
Err(e) => {
warn!("Failed to create plan for party '{}': {}", party_name, e);
}
}
}
Ok(())
}
fn install_post_commit_hook(&self, hooks_dir: &Path) -> Result<()> {
let hook_path = hooks_dir.join("post-commit");
let hook_content = self.generate_post_commit_hook_content();
if hook_path.exists() {
let existing_content = fs::read_to_string(&hook_path)?;
if !existing_content.contains("# branch-party auto-update") {
let combined_content = format!("{}\n\n{}", existing_content, hook_content);
fs::write(&hook_path, combined_content)?;
}
} else {
fs::write(&hook_path, hook_content)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms)?;
}
info!("Installed post-commit hook");
Ok(())
}
fn install_post_merge_hook(&self, hooks_dir: &Path) -> Result<()> {
let hook_path = hooks_dir.join("post-merge");
let hook_content = self.generate_post_merge_hook_content();
if hook_path.exists() {
let existing_content = fs::read_to_string(&hook_path)?;
if !existing_content.contains("# branch-party auto-update") {
let combined_content = format!("{}\n\n{}", existing_content, hook_content);
fs::write(&hook_path, combined_content)?;
}
} else {
fs::write(&hook_path, hook_content)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms)?;
}
info!("Installed post-merge hook");
Ok(())
}
fn generate_post_commit_hook_content(&self) -> String {
let repo_path = self.repo_path.display();
format!(r#"#!/bin/sh
# branch-party auto-update post-commit hook
# Automatically update party branches when member branches are committed to
# Get the current branch name
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
# Skip if we're on a party branch (to avoid infinite loops)
if echo "$BRANCH_NAME" | grep -q "^party/"; then
exit 0
fi
# Run branch-party auto-update
if command -v branch-party >/dev/null 2>&1; then
branch-party auto-update "$BRANCH_NAME" 2>/dev/null || true
elif [ -x "{}/target/debug/branch-party" ]; then
{}/target/debug/branch-party auto-update "$BRANCH_NAME" 2>/dev/null || true
elif [ -x "{}/target/release/branch-party" ]; then
{}/target/release/branch-party auto-update "$BRANCH_NAME" 2>/dev/null || true
fi
"#, repo_path, repo_path, repo_path, repo_path)
}
fn generate_post_merge_hook_content(&self) -> String {
let repo_path = self.repo_path.display();
format!(r#"#!/bin/sh
# branch-party auto-update post-merge hook
# Automatically update party branches after merges
# Get the current branch name
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
# Skip if we're on a party branch
if echo "$BRANCH_NAME" | grep -q "^party/"; then
exit 0
fi
# Run branch-party auto-update
if command -v branch-party >/dev/null 2>&1; then
branch-party auto-update "$BRANCH_NAME" 2>/dev/null || true
elif [ -x "{}/target/debug/branch-party" ]; then
{}/target/debug/branch-party auto-update "$BRANCH_NAME" 2>/dev/null || true
elif [ -x "{}/target/release/branch-party" ]; then
{}/target/release/branch-party auto-update "$BRANCH_NAME" 2>/dev/null || true
fi
"#, repo_path, repo_path, repo_path, repo_path)
}
fn install_pre_commit_hook(&self, hooks_dir: &Path) -> Result<()> {
let hook_path = hooks_dir.join("pre-commit");
let hook_content = self.generate_pre_commit_hook_content();
if hook_path.exists() {
let existing_content = fs::read_to_string(&hook_path)?;
if !existing_content.contains("# branch-party protection") {
let combined_content = format!("{}\n\n{}", hook_content, existing_content);
fs::write(&hook_path, combined_content)?;
}
} else {
fs::write(&hook_path, hook_content)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms)?;
}
info!("Installed pre-commit hook");
Ok(())
}
fn generate_pre_commit_hook_content(&self) -> String {
r#"#!/bin/sh
# branch-party protection pre-commit hook
# Prevents direct commits to party branches
# Get the current branch name
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
# Check if we're on a party branch
if echo "$BRANCH_NAME" | grep -q "^party/"; then
echo "❌ ERROR: Direct commits to party branches are not allowed!"
echo ""
echo "Party branches are automatically managed by branch-party."
echo "To make changes, commit to one of the source branches instead:"
echo ""
# Try to show which branches feed into this party
PARTY_NAME=$(echo "$BRANCH_NAME" | sed 's/^party\///')
echo "For party '$PARTY_NAME', make changes to:"
# Try to extract member branches from config (best effort)
CONFIG_FILE=".git/branch-party/config.yaml"
if [ -f "$CONFIG_FILE" ]; then
# Simple grep-based extraction (not perfect but helpful)
awk '/^[[:space:]]*'"$PARTY_NAME"':/,/^[[:space:]]*[^[:space:]]/ {
if (/^[[:space:]]*- /) {
gsub(/^[[:space:]]*- /, " • ");
print
}
}' "$CONFIG_FILE" | head -10
fi
echo ""
echo "Then the party branch will be automatically updated."
echo "Use 'git switch <source-branch>' to switch to a source branch."
echo ""
exit 1
fi
"#.to_string()
}
fn remove_hook_if_ours(&self, hook_path: &Path) -> Result<()> {
if !hook_path.exists() {
return Ok(());
}
let content = fs::read_to_string(hook_path)?;
if content.contains("# branch-party auto-update") || content.contains("# branch-party protection") {
if content.lines().filter(|line| !line.trim().is_empty() && !line.starts_with('#')).count() <= 10 {
fs::remove_file(hook_path)?;
info!("Removed hook: {}", hook_path.display());
} else {
let lines: Vec<&str> = content.lines().collect();
let mut new_lines = Vec::new();
let mut skip_our_section = false;
for line in lines {
if line.contains("# branch-party auto-update") || line.contains("# branch-party protection") {
skip_our_section = true;
continue;
}
if skip_our_section && (line.is_empty() || line.starts_with('#')) {
continue;
}
if skip_our_section && !line.trim().is_empty() && !line.starts_with('#') {
skip_our_section = false;
}
if !skip_our_section {
new_lines.push(line);
}
}
fs::write(hook_path, new_lines.join("\n"))?;
info!("Removed branch-party section from hook: {}", hook_path.display());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_hook_manager_creation() {
let temp_dir = TempDir::new().unwrap();
let config_loader = ConfigLoader::new(temp_dir.path().to_path_buf());
let hook_manager = GitHookManager::new(temp_dir.path(), &config_loader);
assert_eq!(hook_manager.repo_path, temp_dir.path());
}
}