use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
const LORE_HOOK_MARKER: &str = "# Lore hook - managed by lore hooks install";
const POST_COMMIT_HOOK: &str = r#"#!/bin/sh
# Lore post-commit hook
# Lore hook - managed by lore hooks install
# Link any active sessions to this commit
if command -v lore >/dev/null 2>&1; then
lore link --current --commit HEAD 2>/dev/null || true
fi
"#;
const PREPARE_COMMIT_MSG_HOOK: &str = r#"#!/bin/sh
# Lore prepare-commit-msg hook - add session references
# Lore hook - managed by lore hooks install
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# Only run for regular commits (not merge, squash, etc.)
if [ "$COMMIT_SOURCE" = "" ] || [ "$COMMIT_SOURCE" = "message" ]; then
if command -v lore >/dev/null 2>&1; then
# Get active sessions that might be related to this commit
# This is a placeholder - full implementation would query lore
:
fi
fi
"#;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookType {
PostCommit,
PrepareCommitMsg,
}
impl HookType {
fn filename(&self) -> &'static str {
match self {
HookType::PostCommit => "post-commit",
HookType::PrepareCommitMsg => "prepare-commit-msg",
}
}
fn content(&self) -> &'static str {
match self {
HookType::PostCommit => POST_COMMIT_HOOK,
HookType::PrepareCommitMsg => PREPARE_COMMIT_MSG_HOOK,
}
}
fn all() -> &'static [HookType] {
&[HookType::PostCommit, HookType::PrepareCommitMsg]
}
}
#[derive(Subcommand)]
pub enum HooksCommand {
#[command(long_about = "Installs Lore's git hooks in the current repository's\n\
.git/hooks directory. The post-commit hook automatically\n\
links sessions to commits using time and file overlap.\n\
Existing hooks are backed up before being replaced.")]
Install {
#[arg(long)]
#[arg(long_help = "Replace existing hooks that are not managed by Lore.\n\
The original hooks are saved as <hook>.backup and can\n\
be restored with 'lore hooks uninstall'.")]
force: bool,
},
#[command(long_about = "Removes Lore's git hooks from the current repository.\n\
Only removes hooks that Lore installed (identified by marker).\n\
Restores backed-up hooks if they exist.")]
Uninstall,
#[command(long_about = "Shows which git hooks are currently installed and\n\
whether they are managed by Lore or are third-party hooks.")]
Status,
}
#[derive(clap::Args)]
#[command(after_help = "EXAMPLES:\n \
lore hooks install Install hooks (skips existing)\n \
lore hooks install --force Replace existing hooks\n \
lore hooks uninstall Remove Lore hooks\n \
lore hooks status Check installed hooks")]
pub struct Args {
#[command(subcommand)]
pub command: HooksCommand,
}
pub fn run(args: Args) -> Result<()> {
match args.command {
HooksCommand::Install { force } => run_install(force),
HooksCommand::Uninstall => run_uninstall(),
HooksCommand::Status => run_status(),
}
}
fn run_install(force: bool) -> Result<()> {
let hooks_dir = get_hooks_dir()?;
println!("Installing Lore hooks in {}", hooks_dir.display());
println!();
let mut installed_count = 0;
let mut skipped_count = 0;
for hook_type in HookType::all() {
let hook_path = hooks_dir.join(hook_type.filename());
let status = install_hook(&hook_path, *hook_type, force)?;
match status {
InstallStatus::Installed => {
println!(" {} {}", "Installed".green(), hook_type.filename());
installed_count += 1;
}
InstallStatus::Replaced => {
println!(
" {} {} (backed up existing to {}.backup)",
"Replaced".yellow(),
hook_type.filename(),
hook_type.filename()
);
installed_count += 1;
}
InstallStatus::Skipped => {
println!(
" {} {} (use --force to overwrite)",
"Skipped".yellow(),
hook_type.filename()
);
skipped_count += 1;
}
InstallStatus::AlreadyInstalled => {
println!(
" {} {} (already a Lore hook)",
"Skipped".dimmed(),
hook_type.filename()
);
skipped_count += 1;
}
}
}
println!();
if installed_count > 0 {
println!(
"Successfully installed {} hook(s).",
installed_count.to_string().green()
);
}
if skipped_count > 0 && !force {
println!("{}", "Use --force to overwrite existing hooks.".dimmed());
}
Ok(())
}
enum InstallStatus {
Installed,
Replaced,
Skipped,
AlreadyInstalled,
}
fn install_hook(hook_path: &Path, hook_type: HookType, force: bool) -> Result<InstallStatus> {
if hook_path.exists() {
let existing_content = fs::read_to_string(hook_path)
.with_context(|| format!("Failed to read existing hook: {}", hook_path.display()))?;
if existing_content.contains(LORE_HOOK_MARKER) {
write_hook(hook_path, hook_type)?;
return Ok(InstallStatus::AlreadyInstalled);
}
if !force {
return Ok(InstallStatus::Skipped);
}
let backup_path = hook_path.with_extension("backup");
fs::rename(hook_path, &backup_path)
.with_context(|| format!("Failed to backup hook to {}", backup_path.display()))?;
write_hook(hook_path, hook_type)?;
Ok(InstallStatus::Replaced)
} else {
write_hook(hook_path, hook_type)?;
Ok(InstallStatus::Installed)
}
}
fn write_hook(hook_path: &Path, hook_type: HookType) -> Result<()> {
fs::write(hook_path, hook_type.content())
.with_context(|| format!("Failed to write hook: {}", hook_path.display()))?;
#[cfg(unix)]
{
let mut perms = fs::metadata(hook_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(hook_path, perms)
.with_context(|| format!("Failed to set permissions on {}", hook_path.display()))?;
}
Ok(())
}
fn run_uninstall() -> Result<()> {
let hooks_dir = get_hooks_dir()?;
println!("Uninstalling Lore hooks from {}", hooks_dir.display());
println!();
let mut removed_count = 0;
let mut restored_count = 0;
let mut not_found_count = 0;
for hook_type in HookType::all() {
let hook_path = hooks_dir.join(hook_type.filename());
if !hook_path.exists() {
println!(
" {} {} (not installed)",
"Skipped".dimmed(),
hook_type.filename()
);
not_found_count += 1;
continue;
}
let content = fs::read_to_string(&hook_path)
.with_context(|| format!("Failed to read hook: {}", hook_path.display()))?;
if !content.contains(LORE_HOOK_MARKER) {
println!(
" {} {} (not a Lore hook)",
"Skipped".yellow(),
hook_type.filename()
);
continue;
}
fs::remove_file(&hook_path)
.with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?;
removed_count += 1;
let backup_path = hook_path.with_extension("backup");
if backup_path.exists() {
fs::rename(&backup_path, &hook_path)
.with_context(|| format!("Failed to restore backup: {}", backup_path.display()))?;
println!(
" {} {} (restored from backup)",
"Removed".green(),
hook_type.filename()
);
restored_count += 1;
} else {
println!(" {} {}", "Removed".green(), hook_type.filename());
}
}
println!();
if removed_count > 0 {
println!("Removed {} hook(s).", removed_count.to_string().green());
if restored_count > 0 {
println!(
"Restored {} original hook(s) from backup.",
restored_count.to_string().green()
);
}
} else if not_found_count == HookType::all().len() {
println!("{}", "No Lore hooks were installed.".yellow());
}
Ok(())
}
fn run_status() -> Result<()> {
let hooks_dir = get_hooks_dir()?;
println!("Git hooks status:");
println!();
for hook_type in HookType::all() {
let hook_path = hooks_dir.join(hook_type.filename());
let status = get_hook_status(&hook_path)?;
let status_str = match status {
HookStatus::Lore => "installed".green().to_string(),
HookStatus::Other => "other hook installed".yellow().to_string(),
HookStatus::None => "not installed".dimmed().to_string(),
};
println!(
" {:<20} {}",
format!("{}:", hook_type.filename()),
status_str
);
}
Ok(())
}
enum HookStatus {
Lore,
Other,
None,
}
fn get_hook_status(hook_path: &Path) -> Result<HookStatus> {
if !hook_path.exists() {
return Ok(HookStatus::None);
}
let content = fs::read_to_string(hook_path)
.with_context(|| format!("Failed to read hook: {}", hook_path.display()))?;
if content.contains(LORE_HOOK_MARKER) {
Ok(HookStatus::Lore)
} else {
Ok(HookStatus::Other)
}
}
fn get_hooks_dir() -> Result<PathBuf> {
let repo = git2::Repository::discover(".")
.context("Not in a git repository. Run this command from within a git repository.")?;
let git_dir = repo.path();
let hooks_dir = git_dir.join("hooks");
if !hooks_dir.exists() {
fs::create_dir_all(&hooks_dir).with_context(|| {
format!("Failed to create hooks directory: {}", hooks_dir.display())
})?;
}
Ok(hooks_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_repo() -> Result<(TempDir, PathBuf)> {
let temp_dir = TempDir::new()?;
let repo_path = temp_dir.path();
git2::Repository::init(repo_path)?;
let hooks_dir = repo_path.join(".git").join("hooks");
fs::create_dir_all(&hooks_dir)?;
Ok((temp_dir, hooks_dir))
}
#[test]
fn test_hook_type_filename() {
assert_eq!(HookType::PostCommit.filename(), "post-commit");
assert_eq!(HookType::PrepareCommitMsg.filename(), "prepare-commit-msg");
}
#[test]
fn test_hook_type_content_contains_marker() {
for hook_type in HookType::all() {
assert!(
hook_type.content().contains(LORE_HOOK_MARKER),
"Hook {} should contain the Lore marker",
hook_type.filename()
);
}
}
#[test]
fn test_hook_type_content_is_valid_shell_script() {
for hook_type in HookType::all() {
assert!(
hook_type.content().starts_with("#!/bin/sh"),
"Hook {} should start with shebang",
hook_type.filename()
);
}
}
#[test]
fn test_post_commit_hook_calls_link_current() {
let content = HookType::PostCommit.content();
assert!(
content.contains("lore link --current --commit HEAD"),
"Post-commit hook should call 'lore link --current --commit HEAD'"
);
}
#[test]
fn test_install_hook_fresh() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
let status = install_hook(&hook_path, HookType::PostCommit, false)?;
assert!(matches!(status, InstallStatus::Installed));
assert!(hook_path.exists());
let content = fs::read_to_string(&hook_path)?;
assert!(content.contains(LORE_HOOK_MARKER));
Ok(())
}
#[test]
fn test_install_hook_skips_existing() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
fs::write(&hook_path, "#!/bin/sh\necho 'existing hook'")?;
let status = install_hook(&hook_path, HookType::PostCommit, false)?;
assert!(matches!(status, InstallStatus::Skipped));
let content = fs::read_to_string(&hook_path)?;
assert!(content.contains("existing hook"));
Ok(())
}
#[test]
fn test_install_hook_force_creates_backup() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
let backup_path = hooks_dir.join("post-commit.backup");
fs::write(&hook_path, "#!/bin/sh\necho 'existing hook'")?;
let status = install_hook(&hook_path, HookType::PostCommit, true)?;
assert!(matches!(status, InstallStatus::Replaced));
assert!(backup_path.exists());
let backup_content = fs::read_to_string(&backup_path)?;
assert!(backup_content.contains("existing hook"));
let new_content = fs::read_to_string(&hook_path)?;
assert!(new_content.contains(LORE_HOOK_MARKER));
Ok(())
}
#[test]
fn test_install_hook_updates_existing_lore_hook() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
let old_content = format!("#!/bin/sh\n{LORE_HOOK_MARKER}\nold version");
fs::write(&hook_path, &old_content)?;
let status = install_hook(&hook_path, HookType::PostCommit, false)?;
assert!(matches!(status, InstallStatus::AlreadyInstalled));
let content = fs::read_to_string(&hook_path)?;
assert_eq!(content, POST_COMMIT_HOOK);
Ok(())
}
#[test]
fn test_get_hook_status_not_installed() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
let status = get_hook_status(&hook_path)?;
assert!(matches!(status, HookStatus::None));
Ok(())
}
#[test]
fn test_get_hook_status_lore_installed() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
fs::write(&hook_path, POST_COMMIT_HOOK)?;
let status = get_hook_status(&hook_path)?;
assert!(matches!(status, HookStatus::Lore));
Ok(())
}
#[test]
fn test_get_hook_status_other_installed() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
fs::write(&hook_path, "#!/bin/sh\necho 'other hook'")?;
let status = get_hook_status(&hook_path)?;
assert!(matches!(status, HookStatus::Other));
Ok(())
}
#[cfg(unix)]
#[test]
fn test_write_hook_sets_executable() -> Result<()> {
let (_temp_dir, hooks_dir) = create_test_repo()?;
let hook_path = hooks_dir.join("post-commit");
write_hook(&hook_path, HookType::PostCommit)?;
let metadata = fs::metadata(&hook_path)?;
let mode = metadata.permissions().mode();
assert!(mode & 0o100 != 0, "Hook should be executable");
Ok(())
}
}