@@ -13,7 +13,7 @@ use tokio::sync::mpsc;
1313use tokio_util:: sync:: CancellationToken ;
1414use tracing:: { debug, warn} ;
1515
16- use crate :: cli:: { Cli , Commands } ;
16+ use crate :: cli:: { Cli , Commands , HookAction } ;
1717use crate :: config:: Config ;
1818use crate :: error:: { Error , Result } ;
1919use 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" ) ]
0 commit comments