branch_party_core 0.1.1

Core library for branch-party CLI tool
Documentation
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,
        }
    }

    /// Install git hooks for automatic party updates
    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())));
        }

        // Install pre-commit hook (to prevent direct commits to party branches)
        self.install_pre_commit_hook(&hooks_dir)?;
        
        // Install post-commit hook
        self.install_post_commit_hook(&hooks_dir)?;
        
        // Install post-merge hook
        self.install_post_merge_hook(&hooks_dir)?;

        info!("Git hooks installed successfully");
        Ok(())
    }

    /// Uninstall git hooks
    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");

        // Remove hooks if they exist and were created by branch-party
        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(())
    }

    /// Check if auto-update should run for the current branch
    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();
        
        // Find parties that contain this branch
        for (party_name, party) in &config.parties {
            debug!("Checking party '{}' with members: {:?}", party_name, party.members);
            let branch_matches = party.members.iter().any(|member| {
                // Handle party references (@party_name) and direct branch references
                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);
                // Check if this party should be auto-updated
                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)
    }

    /// Execute auto-update for specified 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() // Auto-updates should not fail due to working tree state
            .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 {
                                // TODO: Implement auto-push functionality
                                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(())
    }

    /// Install post-commit hook
    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() {
            // If hook exists, check if it's ours or append to it
            let existing_content = fs::read_to_string(&hook_path)?;
            if !existing_content.contains("# branch-party auto-update") {
                // Append our hook to existing content
                let combined_content = format!("{}\n\n{}", existing_content, hook_content);
                fs::write(&hook_path, combined_content)?;
            }
        } else {
            fs::write(&hook_path, hook_content)?;
        }
        
        // Make executable
        #[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(())
    }

    /// Install post-merge hook
    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)?;
        }
        
        // Make executable
        #[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(())
    }

    /// Generate post-commit hook content
    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)
    }

    /// Generate post-merge hook content
    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)
    }

    /// Install pre-commit hook to prevent direct commits to party branches
    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)?;
        }
        
        // Make executable
        #[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(())
    }

    /// Generate pre-commit hook content
    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()
    }

    /// Remove hook if it was created by branch-party
    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 the entire file is ours, remove it
            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 {
                // Otherwise, just remove our section
                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);
        
        // Just test that we can create the manager
        assert_eq!(hook_manager.repo_path, temp_dir.path());
    }
}