workmux 0.1.212

An opinionated workflow tool that orchestrates git worktrees and tmux
//! Copilot CLI status tracking setup.
//!
//! Detects Copilot CLI via the `~/.copilot/` directory.
//! Installs hooks by writing hooks.json to `.github/hooks/workmux-status/`
//! in the current git repository.
//!
//! Unlike Claude/OpenCode which install globally, Copilot hooks are per-repo.
//! See https://github.com/github/copilot-cli/issues/1157

use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;

use super::StatusCheck;

/// Hooks configuration embedded at compile time.
const HOOKS_JSON: &str = include_str!("../../.github/hooks/workmux-status/hooks.json");

fn copilot_dir() -> Option<PathBuf> {
    if let Ok(dir) = std::env::var("COPILOT_CONFIG_DIR") {
        return Some(PathBuf::from(dir));
    }
    home::home_dir().map(|h| h.join(".copilot"))
}

/// Detect if Copilot CLI is present via filesystem.
/// Also requires being in a git repo since hooks are per-repo.
pub fn detect() -> Option<&'static str> {
    if crate::git::get_repo_root().is_err() {
        return None;
    }
    if copilot_dir().is_some_and(|d| d.is_dir()) {
        return Some("found ~/.copilot/");
    }
    None
}

/// Check if workmux hooks are installed for Copilot in the current repo.
pub fn check() -> Result<StatusCheck> {
    let root = match crate::git::get_repo_root() {
        Ok(r) => r,
        Err(e) => return Ok(StatusCheck::Error(e.to_string())),
    };

    let hooks_dir = root.join(".github/hooks");
    if !hooks_dir.is_dir() {
        return Ok(StatusCheck::NotInstalled);
    }

    // Scan all hooks.json files under .github/hooks/*/
    if let Ok(entries) = fs::read_dir(&hooks_dir) {
        for entry in entries.flatten() {
            if !entry.path().is_dir() {
                continue;
            }
            let hooks_file = entry.path().join("hooks.json");
            if hooks_file.exists()
                && let Ok(content) = fs::read_to_string(&hooks_file)
                && content.contains("workmux set-window-status")
            {
                return Ok(StatusCheck::Installed);
            }
        }
    }

    Ok(StatusCheck::NotInstalled)
}

/// Install workmux hooks for Copilot CLI in the current repo.
pub fn install() -> Result<String> {
    let root = crate::git::get_repo_root()
        .context("Must be in a git repository to install Copilot hooks")?;
    let hooks_dir = root.join(".github/hooks/workmux-status");

    fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/workmux-status/")?;

    let hooks_file = hooks_dir.join("hooks.json");
    fs::write(&hooks_file, HOOKS_JSON).context("Failed to write hooks.json")?;

    Ok(format!(
        "Installed hooks to {}",
        hooks_file
            .strip_prefix(&root)
            .unwrap_or(&hooks_file)
            .display()
    ))
}

/// Remove workmux hooks for Copilot CLI from the current repo.
///
/// Deletes the `.github/hooks/workmux-status/` directory tree (wholly
/// created by workmux, no merge needed). Cleans up empty parent dirs.
pub fn uninstall() -> Result<String> {
    let root = match crate::git::get_repo_root() {
        Ok(r) => r,
        Err(_) => return Ok("Not in a git repository, nothing to uninstall".to_string()),
    };
    uninstall_at(root)
}

fn uninstall_at(root: PathBuf) -> Result<String> {
    let hooks_dir = root.join(".github/hooks/workmux-status");
    if hooks_dir.exists() {
        fs::remove_dir_all(&hooks_dir)?;
        // Remove .github/hooks/ if now empty
        let hooks_parent = root.join(".github/hooks");
        if hooks_parent
            .read_dir()
            .is_ok_and(|mut it| it.next().is_none())
        {
            let _ = fs::remove_dir(&hooks_parent);
        }
        Ok("Removed .github/hooks/workmux-status/ from current repo".to_string())
    } else {
        Ok("No Copilot hooks found in current repo".to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hooks_json_is_valid() {
        let parsed: serde_json::Value =
            serde_json::from_str(HOOKS_JSON).expect("embedded hooks.json is valid JSON");
        assert_eq!(parsed.get("version").and_then(|v| v.as_u64()), Some(1));
        let hooks = parsed.get("hooks").unwrap().as_object().unwrap();
        assert!(hooks.contains_key("userPromptSubmitted"));
        assert!(hooks.contains_key("postToolUse"));
        assert!(hooks.contains_key("agentStop"));
    }

    #[test]
    fn test_hooks_json_contains_workmux_command() {
        assert!(HOOKS_JSON.contains("workmux set-window-status"));
    }

    #[test]
    fn test_uninstall_no_hooks_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let result = uninstall_at(tmp.path().to_path_buf()).unwrap();
        assert!(result.contains("No Copilot hooks found"));
    }

    #[test]
    fn test_uninstall_removes_hooks_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let hooks_dir = tmp.path().join(".github/hooks/workmux-status");
        std::fs::create_dir_all(&hooks_dir).unwrap();
        std::fs::write(hooks_dir.join("hooks.json"), "{}").unwrap();

        let result = uninstall_at(tmp.path().to_path_buf()).unwrap();
        assert!(result.contains("Removed .github/hooks/workmux-status"));

        // Verify directory is gone
        assert!(!hooks_dir.exists());
    }

    #[test]
    fn test_uninstall_idempotent() {
        let tmp = tempfile::tempdir().unwrap();
        let hooks_dir = tmp.path().join(".github/hooks/workmux-status");
        std::fs::create_dir_all(&hooks_dir).unwrap();
        std::fs::write(hooks_dir.join("hooks.json"), "{}").unwrap();

        let result1 = uninstall_at(tmp.path().to_path_buf()).unwrap();
        assert!(result1.contains("Removed"));
        let result2 = uninstall_at(tmp.path().to_path_buf()).unwrap();
        assert!(result2.contains("No Copilot hooks found"));
    }
}