Skip to content

Commit ba9e99c

Browse files
committed
feat: add git hook install/uninstall/status commands
Adds `commitbee hook install` to create a prepare-commit-msg hook that auto-generates commit messages. Supports backup/restore of existing hooks, atomic writes, and ownership verification on uninstall.
1 parent f09ddb1 commit ba9e99c

2 files changed

Lines changed: 198 additions & 1 deletion

File tree

src/app.rs

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use tokio::sync::mpsc;
1313
use tokio_util::sync::CancellationToken;
1414
use tracing::{debug, warn};
1515

16-
use crate::cli::{Cli, Commands};
16+
use crate::cli::{Cli, Commands, HookAction};
1717
use crate::config::Config;
1818
use crate::error::{Error, Result};
1919
use crate::services::{
@@ -277,6 +277,7 @@ impl App {
277277
clap_complete::generate(*shell, &mut cmd, "commitbee", &mut std::io::stdout());
278278
Ok(())
279279
}
280+
Commands::Hook { action } => self.handle_hook(action),
280281
#[cfg(feature = "secure-storage")]
281282
Commands::SetKey { provider } => self.set_api_key(provider),
282283
#[cfg(feature = "secure-storage")]
@@ -361,6 +362,187 @@ impl App {
361362
Ok(())
362363
}
363364

365+
// ─── Hook Commands ───
366+
367+
fn handle_hook(&self, action: &HookAction) -> Result<()> {
368+
match action {
369+
HookAction::Install => self.hook_install(),
370+
HookAction::Uninstall => self.hook_uninstall(),
371+
HookAction::Status => self.hook_status(),
372+
}
373+
}
374+
375+
fn hook_dir(&self) -> Result<PathBuf> {
376+
// Verify we're in a git repo first
377+
let _git = GitService::discover()?;
378+
379+
let output = std::process::Command::new("git")
380+
.args(["rev-parse", "--git-dir"])
381+
.output()?;
382+
383+
if !output.status.success() {
384+
return Err(Error::Git("Cannot find .git directory".into()));
385+
}
386+
387+
let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
388+
Ok(PathBuf::from(git_dir).join("hooks"))
389+
}
390+
391+
fn hook_path(&self) -> Result<PathBuf> {
392+
Ok(self.hook_dir()?.join("prepare-commit-msg"))
393+
}
394+
395+
fn hook_install(&self) -> Result<()> {
396+
let hooks_dir = self.hook_dir()?;
397+
let hook_path = hooks_dir.join("prepare-commit-msg");
398+
let backup_path = hooks_dir.join("prepare-commit-msg.commitbee-backup");
399+
400+
// Create hooks directory if needed
401+
std::fs::create_dir_all(&hooks_dir)?;
402+
403+
// Back up existing hook if present and not ours
404+
if hook_path.exists() {
405+
let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
406+
if content.contains("# commitbee hook") {
407+
eprintln!(
408+
"{} Hook already installed at {}",
409+
style("✓").green().bold(),
410+
hook_path.display()
411+
);
412+
return Ok(());
413+
}
414+
std::fs::copy(&hook_path, &backup_path)?;
415+
eprintln!(
416+
"{} Backed up existing hook to {}",
417+
style("info:").cyan(),
418+
backup_path.display()
419+
);
420+
}
421+
422+
let hook_script = r#"#!/bin/sh
423+
# commitbee hook — auto-generated, do not edit
424+
# Generates commit messages using commitbee when committing interactively.
425+
# Skips merge, squash, amend, and message-provided commits.
426+
427+
COMMIT_MSG_FILE="$1"
428+
COMMIT_SOURCE="$2"
429+
430+
# Skip non-interactive commits (merge, squash, message, amend)
431+
case "$COMMIT_SOURCE" in
432+
merge|squash|message|commit)
433+
exit 0
434+
;;
435+
esac
436+
437+
# Only run if commitbee is available
438+
if ! command -v commitbee >/dev/null 2>&1; then
439+
exit 0
440+
fi
441+
442+
# Generate commit message and write to file
443+
MSG=$(commitbee --yes --dry-run 2>/dev/null)
444+
if [ $? -eq 0 ] && [ -n "$MSG" ]; then
445+
echo "$MSG" > "$COMMIT_MSG_FILE"
446+
fi
447+
"#;
448+
449+
// Write to temp file first, then rename (atomic)
450+
let temp_path = hooks_dir.join(".prepare-commit-msg.tmp");
451+
std::fs::write(&temp_path, hook_script)?;
452+
453+
// Set executable permissions
454+
#[cfg(unix)]
455+
{
456+
use std::os::unix::fs::PermissionsExt;
457+
let mut perms = std::fs::metadata(&temp_path)?.permissions();
458+
perms.set_mode(0o755);
459+
std::fs::set_permissions(&temp_path, perms)?;
460+
}
461+
462+
std::fs::rename(&temp_path, &hook_path)?;
463+
464+
eprintln!(
465+
"{} Hook installed at {}",
466+
style("✓").green().bold(),
467+
hook_path.display()
468+
);
469+
Ok(())
470+
}
471+
472+
fn hook_uninstall(&self) -> Result<()> {
473+
let hooks_dir = self.hook_dir()?;
474+
let hook_path = hooks_dir.join("prepare-commit-msg");
475+
let backup_path = hooks_dir.join("prepare-commit-msg.commitbee-backup");
476+
477+
if !hook_path.exists() {
478+
eprintln!(
479+
"{} No hook found at {}",
480+
style("info:").cyan(),
481+
hook_path.display()
482+
);
483+
return Ok(());
484+
}
485+
486+
// Verify it's our hook before removing
487+
let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
488+
if !content.contains("# commitbee hook") {
489+
return Err(Error::Git(format!(
490+
"Hook at {} was not installed by commitbee. Remove manually if intended.",
491+
hook_path.display()
492+
)));
493+
}
494+
495+
std::fs::remove_file(&hook_path)?;
496+
497+
// Restore backup if exists
498+
if backup_path.exists() {
499+
std::fs::rename(&backup_path, &hook_path)?;
500+
eprintln!(
501+
"{} Restored previous hook from backup",
502+
style("info:").cyan()
503+
);
504+
}
505+
506+
eprintln!(
507+
"{} Hook removed from {}",
508+
style("✓").green().bold(),
509+
hook_path.display()
510+
);
511+
Ok(())
512+
}
513+
514+
fn hook_status(&self) -> Result<()> {
515+
let hook_path = self.hook_path()?;
516+
517+
if !hook_path.exists() {
518+
eprintln!(
519+
"{} No prepare-commit-msg hook installed",
520+
style("✗").red().bold()
521+
);
522+
eprintln!(
523+
" Install with: {}",
524+
style("commitbee hook install").yellow()
525+
);
526+
return Ok(());
527+
}
528+
529+
let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
530+
if content.contains("# commitbee hook") {
531+
eprintln!(
532+
"{} CommitBee hook is installed at {}",
533+
style("✓").green().bold(),
534+
hook_path.display()
535+
);
536+
} else {
537+
eprintln!(
538+
"{} A prepare-commit-msg hook exists but was not installed by commitbee",
539+
style("info:").cyan()
540+
);
541+
}
542+
543+
Ok(())
544+
}
545+
364546
// ─── Keyring Commands ───
365547

366548
#[cfg(feature = "secure-storage")]

src/cli.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ pub struct Cli {
4141
pub command: Option<Commands>,
4242
}
4343

44+
#[derive(clap::Subcommand, Debug)]
45+
pub enum HookAction {
46+
/// Install prepare-commit-msg hook
47+
Install,
48+
/// Remove prepare-commit-msg hook
49+
Uninstall,
50+
/// Check if hook is installed
51+
Status,
52+
}
53+
4454
#[derive(clap::Subcommand, Debug)]
4555
pub enum Commands {
4656
/// Initialize config file
@@ -55,6 +65,11 @@ pub enum Commands {
5565
#[arg(value_enum)]
5666
shell: clap_complete::Shell,
5767
},
68+
/// Manage prepare-commit-msg git hook
69+
Hook {
70+
#[command(subcommand)]
71+
action: HookAction,
72+
},
5873
/// Store API key in system keychain
5974
#[cfg(feature = "secure-storage")]
6075
SetKey {

0 commit comments

Comments
 (0)