diff --git a/src/commands/git_hook_handlers.rs b/src/commands/git_hook_handlers.rs index 18672347e..3aedc7fe0 100644 --- a/src/commands/git_hook_handlers.rs +++ b/src/commands/git_hook_handlers.rs @@ -38,8 +38,9 @@ pub const ENV_SKIP_ALL_HOOKS: &str = "GIT_AI_SKIP_ALL_HOOKS"; pub const ENV_SKIP_MANAGED_HOOKS: &str = "GITAI_SKIP_MANAGED_HOOKS"; const ENV_SKIP_MANAGED_HOOKS_LEGACY: &str = "GIT_AI_SKIP_MANAGED_HOOKS"; -// All core hooks we proxy/forward. We install every known hook name so global forwarding works -// even when git-ai doesn't have managed behavior for that hook. +// All core hooks recognised by git. Non-managed hooks get symlinks to the git-ai binary only +// when the corresponding hook script exists in the forward target directory, so git-ai can +// properly forward to it at the original path (preserving $0/dirname for Husky-style hooks). const CORE_GIT_HOOK_NAMES: &[&str] = &[ "applypatch-msg", "pre-applypatch", @@ -72,7 +73,7 @@ const CORE_GIT_HOOK_NAMES: &[&str] = &[ "pre-solve-refs", ]; -// Hooks with managed git-ai behavior. We only install these to minimize hook churn. +// Hooks with managed git-ai behavior. Always installed as symlinks. const MANAGED_GIT_HOOK_NAMES: &[&str] = &[ "pre-commit", "prepare-commit-msg", @@ -425,6 +426,45 @@ fn ensure_hook_symlink( Ok(true) } +fn remove_hook_entry(hook_path: &Path) -> Result<(), GitAiError> { + if hook_path.is_dir() { + fs::remove_dir_all(hook_path)?; + } else { + fs::remove_file(hook_path)?; + } + Ok(()) +} + +fn sync_non_managed_hook_symlinks( + managed_hooks_dir: &Path, + binary_path: &Path, + forward_hooks_path: Option<&str>, + dry_run: bool, +) -> Result { + let mut changed = false; + let forward_dir = forward_hooks_path.map(Path::new); + + for hook_name in CORE_GIT_HOOK_NAMES { + if MANAGED_GIT_HOOK_NAMES.contains(hook_name) { + continue; + } + let hook_path = managed_hooks_dir.join(hook_name); + let original_exists = forward_dir + .map(|d| d.join(hook_name)) + .is_some_and(|p| p.exists() && !p.is_dir()); + + if original_exists { + changed |= ensure_hook_symlink(&hook_path, binary_path, dry_run)?; + } else if hook_path.exists() || hook_path.symlink_metadata().is_ok() { + changed = true; + if !dry_run { + remove_hook_entry(&hook_path)?; + } + } + } + Ok(changed) +} + fn is_path_inside_component(path: &Path, component: &str) -> bool { path.components().any(|part| { part.as_os_str() @@ -574,22 +614,12 @@ pub fn ensure_repo_hooks_installed( changed |= ensure_hook_symlink(&hook_path, &binary_path, dry_run)?; } - for hook_name in CORE_GIT_HOOK_NAMES { - if MANAGED_GIT_HOOK_NAMES.contains(hook_name) { - continue; - } - let hook_path = managed_hooks_dir.join(hook_name); - if hook_path.exists() || hook_path.symlink_metadata().is_ok() { - changed = true; - if !dry_run { - if hook_path.is_dir() { - fs::remove_dir_all(&hook_path)?; - } else { - fs::remove_file(&hook_path)?; - } - } - } - } + changed |= sync_non_managed_hook_symlinks( + &managed_hooks_dir, + &binary_path, + forward_hooks_path.as_deref(), + dry_run, + )?; changed |= set_hooks_path_in_config( &local_config_path, @@ -792,8 +822,9 @@ fn execute_forwarded_hook( hook_args: &[String], stdin_bytes: &[u8], repo: Option<&Repository>, + cached_forward_dir: Option, ) -> i32 { - let Some(forward_hooks_dir) = should_forward_repo_state_first(repo) else { + let Some(forward_hooks_dir) = cached_forward_dir.or_else(|| should_forward_repo_state_first(repo)) else { return 0; }; @@ -882,24 +913,18 @@ fn maybe_enable_rebase_hook_mask(repo: &Repository) { return; } - let session_id = format!( - "{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) - ); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let session_id = format!("{}-{}", std::process::id(), now_ms); let state = RebaseHookMaskState { schema_version: rebase_hook_mask_state_schema_version(), managed_hooks_path: managed_hooks_dir.to_string_lossy().to_string(), masked_hooks, active: true, session_id, - created_at_unix_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0), + created_at_unix_ms: now_ms as u64, }; let _ = save_rebase_hook_mask_state(&state_path, &state, false); } @@ -947,22 +972,39 @@ fn force_restore_rebase_hooks(repo: &Repository) { restore_rebase_hooks_for_repo(repo, true); } -fn parse_hook_stdin(stdin: &[u8]) -> Vec<(String, String)> { +fn parse_whitespace_fields(stdin: &[u8], min_fields: usize) -> Vec> { String::from_utf8_lossy(stdin) .lines() .filter_map(|line| { - let mut parts = line.split_whitespace(); - let old_sha = parts.next()?; - let new_sha = parts.next()?; - Some((old_sha.to_string(), new_sha.to_string())) + let fields: Vec = line.split_whitespace().map(String::from).collect(); + if fields.len() >= min_fields { + Some(fields) + } else { + None + } }) .collect() } +fn parse_hook_stdin(stdin: &[u8]) -> Vec<(String, String)> { + parse_whitespace_fields(stdin, 2) + .into_iter() + .map(|fields| (fields[0].clone(), fields[1].clone())) + .collect() +} + fn is_valid_git_oid(value: &str) -> bool { (value.len() == 40 || value.len() == 64) && value.chars().all(|c| c.is_ascii_hexdigit()) } +fn is_valid_git_oid_or_abbrev(value: &str) -> bool { + value.len() >= 7 && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn is_null_oid(value: &str) -> bool { + !value.is_empty() && value.chars().all(|c| c == '0') +} + fn resolve_squash_source_head(repo: &Repository) -> Option { // Some Git versions keep MERGE_HEAD for --squash, others do not. let merge_head_path = repo.path().join("MERGE_HEAD"); @@ -1129,15 +1171,9 @@ fn was_fast_forward_pull(repository: &Repository, expected_new_head: &str) -> bo } fn parse_reference_transaction_stdin(stdin: &[u8]) -> Vec<(String, String, String)> { - String::from_utf8_lossy(stdin) - .lines() - .filter_map(|line| { - let mut parts = line.split_whitespace(); - let old = parts.next()?; - let new = parts.next()?; - let reference = parts.next()?; - Some((old.to_string(), new.to_string(), reference.to_string())) - }) + parse_whitespace_fields(stdin, 3) + .into_iter() + .map(|fields| (fields[0].clone(), fields[1].clone(), fields[2].clone())) .collect() } @@ -1188,7 +1224,7 @@ fn maybe_handle_reset_reference_transaction( return; }; - if old_head.chars().all(|c| c == '0') || new_head.chars().all(|c| c == '0') { + if is_null_oid(&old_head) || is_null_oid(&new_head) { return; } @@ -1315,11 +1351,11 @@ fn maybe_handle_stash_reference_transaction( let before_count = before_state .as_ref() .map(|state| state.before_count) - .unwrap_or_else(|| if old.chars().all(|c| c == '0') { 0 } else { 1 }); + .unwrap_or_else(|| if is_null_oid(&old) { 0 } else { 1 }); let after_count = stash_entry_count(repo).unwrap_or(before_count); - let old_is_zero = old.chars().all(|c| c == '0'); - let new_is_zero = new.chars().all(|c| c == '0'); + let old_is_zero = is_null_oid(&old); + let new_is_zero = is_null_oid(&new); if !new_is_zero && (old_is_zero || after_count > before_count) { // Stash push/save created a new stash entry. Persist authorship in stash notes. @@ -1612,7 +1648,7 @@ fn latest_cherry_pick_source_from_sequencer(repo: &Repository) -> Option let mut parts = trimmed.split_whitespace(); let _command = parts.next()?; let source = parts.next()?; - if is_valid_git_oid(source) { + if is_valid_git_oid_or_abbrev(source) { return Some(source.to_string()); } } @@ -1798,10 +1834,7 @@ fn handle_rebase_post_rewrite_from_stdin(repo: &mut Repository, stdin: &[u8]) { new_commits.len() )); - let original_head = original_commits - .last() - .cloned() - .unwrap_or_else(|| original_commits[0].clone()); + let original_head = original_commits.last().cloned().unwrap(); let new_head = repo .head() .ok() @@ -1947,7 +1980,7 @@ fn run_managed_hook( } // During clone, post-checkout typically runs once with an all-zero old sha. - if hook_args[0].chars().all(|c| c == '0') && !new_head.chars().all(|c| c == '0') { + if is_null_oid(&hook_args[0]) && !is_null_oid(&new_head) { let _ = fetch_authorship_notes(&repo, "origin"); } @@ -2013,26 +2046,6 @@ fn run_managed_hook( maybe_capture_cherry_pick_pre_commit_state(&repo); 0 } - "commit-msg" - | "pre-merge-commit" - | "pre-auto-gc" - | "sendemail-validate" - | "post-index-change" - | "applypatch-msg" - | "pre-applypatch" - | "post-applypatch" - | "pre-receive" - | "update" - | "proc-receive" - | "post-receive" - | "post-update" - | "push-to-checkout" - | "pre-solve-refs" - | "fsmonitor-watchman" - | "p4-changelist" - | "p4-prepare-changelist" - | "p4-post-changelist" - | "p4-pre-submit" => 0, _ => 0, } } @@ -2057,37 +2070,8 @@ fn is_rebase_in_progress_from_context() -> bool { git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply").is_dir() } -fn is_cherry_pick_in_progress_from_context() -> bool { - let Some(git_dir) = git_dir_from_context() else { - return false; - }; - git_dir.join("CHERRY_PICK_HEAD").is_file() || git_dir.join("sequencer").is_dir() -} - fn hook_has_no_managed_behavior(hook_name: &str) -> bool { - matches!( - hook_name, - "commit-msg" - | "pre-merge-commit" - | "pre-auto-gc" - | "sendemail-validate" - | "post-index-change" - | "applypatch-msg" - | "pre-applypatch" - | "post-applypatch" - | "pre-receive" - | "update" - | "proc-receive" - | "post-receive" - | "post-update" - | "push-to-checkout" - | "pre-solve-refs" - | "fsmonitor-watchman" - | "p4-changelist" - | "p4-prepare-changelist" - | "p4-post-changelist" - | "p4-pre-submit" - ) + !MANAGED_GIT_HOOK_NAMES.contains(&hook_name) } fn hook_requires_managed_repo_lookup( @@ -2096,30 +2080,32 @@ fn hook_requires_managed_repo_lookup( stdin_data: &[u8], ) -> bool { match hook_name { - // During rebases these hooks are frequent and intentionally no-op in managed logic. - // Skip repository lookup up front. "pre-commit" | "post-commit" => !is_rebase_in_progress_from_context(), - // Managed hook logic is a no-op for these hooks. _ if hook_has_no_managed_behavior(hook_name) => false, - // Only needed for cherry-pick path capture. "prepare-commit-msg" => { if is_rebase_in_progress_from_context() { return false; } needs_prepare_commit_msg_handling() } - // Needed only for stash/reset handling; in all other flows this hook is a no-op. "reference-transaction" => { - let updates = parse_reference_transaction_stdin(stdin_data); let phase = hook_args.first().map(String::as_str).unwrap_or(""); - let in_rebase_or_cherry_pick = - is_rebase_in_progress_from_context() || is_cherry_pick_in_progress_from_context(); - - if updates + let git_dir = git_dir_from_context(); + let in_rebase_or_cherry_pick = git_dir + .as_ref() + .map(|d| { + d.join("rebase-merge").is_dir() + || d.join("rebase-apply").is_dir() + || d.join("CHERRY_PICK_HEAD").is_file() + || d.join("sequencer").is_dir() + }) + .unwrap_or(false); + + let has_stash_update = parse_whitespace_fields(stdin_data, 3) .iter() - .any(|(_, _, reference)| reference == "refs/stash") - && config::Config::get().feature_flags().rewrite_stash - { + .any(|fields| fields.len() >= 3 && fields[2] == "refs/stash"); + + if has_stash_update && config::Config::get().feature_flags().rewrite_stash { return matches!(phase, "prepared" | "committed" | "aborted"); } @@ -2135,8 +2121,9 @@ fn hook_requires_managed_repo_lookup( return false; } - updates.iter().any(|(_, _, reference)| { - reference == "HEAD" || reference.starts_with("refs/heads/") + parse_whitespace_fields(stdin_data, 3).iter().any(|fields| { + fields.len() >= 3 + && (fields[2] == "HEAD" || fields[2].starts_with("refs/heads/")) }) } _ => true, @@ -2153,7 +2140,8 @@ pub fn handle_git_hook_invocation(hook_name: &str, hook_args: &[String]) -> i32 let skip_managed_hooks = std::env::var(ENV_SKIP_MANAGED_HOOKS).as_deref() == Ok("1") || std::env::var(ENV_SKIP_MANAGED_HOOKS_LEGACY).as_deref() == Ok("1"); - let forward_hooks_dir_exists = should_forward_repo_state_first(None).is_some(); + let cached_forward_dir = should_forward_repo_state_first(None); + let forward_hooks_dir_exists = cached_forward_dir.is_some(); // Fast path: child wrapper invocations in both mode set skip-managed-hooks. // If there is no forwarding target, this hook execution is guaranteed to be a no-op. @@ -2204,7 +2192,7 @@ pub fn handle_git_hook_invocation(hook_name: &str, hook_args: &[String]) -> i32 } let forward_start = Instant::now(); - let status = execute_forwarded_hook(hook_name, hook_args, &stdin_data, repo.as_ref()); + let status = execute_forwarded_hook(hook_name, hook_args, &stdin_data, repo.as_ref(), cached_forward_dir); let forward_ms = forward_start.elapsed().as_millis(); if perf_enabled { debug_performance_log_structured(serde_json::json!({ @@ -2331,10 +2319,17 @@ mod tests { ); } - assert!( - !managed_hooks_dir.join("commit-msg").exists(), - "non-managed hooks should not be provisioned" - ); + for hook_name in CORE_GIT_HOOK_NAMES { + if MANAGED_GIT_HOOK_NAMES.contains(hook_name) { + continue; + } + let hook_path = managed_hooks_dir.join(hook_name); + assert!( + !hook_path.exists() && hook_path.symlink_metadata().is_err(), + "non-managed hook should NOT be provisioned when no original script exists in forward dir: {}", + hook_name + ); + } let state_path = repo_state_path(&repo); let state = read_repo_hook_state(&state_path) @@ -2526,4 +2521,416 @@ mod tests { "bare repos should self-heal using git dir path" ); } + + #[test] + fn valid_git_oid_accepts_sha1_and_sha256() { + assert!(is_valid_git_oid( + "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + )); + assert!(is_valid_git_oid( + "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3a94a8fe5ccb19ba61c4c0873" + )); + } + + #[test] + fn valid_git_oid_rejects_short_and_invalid() { + assert!(!is_valid_git_oid("abcdef0")); + assert!(!is_valid_git_oid("")); + assert!(!is_valid_git_oid( + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + )); + assert!(!is_valid_git_oid("abc")); + } + + #[test] + fn valid_git_oid_or_abbrev_accepts_short_hex() { + assert!(is_valid_git_oid_or_abbrev("abcdef0")); + assert!(is_valid_git_oid_or_abbrev( + "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + )); + assert!(!is_valid_git_oid_or_abbrev("abcde")); + assert!(!is_valid_git_oid_or_abbrev("")); + assert!(!is_valid_git_oid_or_abbrev("zzzzzzz")); + } + + #[test] + fn parse_reference_transaction_stdin_extracts_three_fields() { + let input = b"0000000 1111111 refs/heads/main\naaa bbb refs/stash\n"; + let parsed = parse_reference_transaction_stdin(input); + assert_eq!(parsed.len(), 2); + assert_eq!( + parsed[0], + ( + "0000000".to_string(), + "1111111".to_string(), + "refs/heads/main".to_string() + ) + ); + assert_eq!( + parsed[1], + ( + "aaa".to_string(), + "bbb".to_string(), + "refs/stash".to_string() + ) + ); + } + + #[test] + fn parse_reference_transaction_stdin_skips_incomplete_lines() { + let input = b"only_one_field\ntwo fields\nold new ref\n"; + let parsed = parse_reference_transaction_stdin(input); + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0], + ( + "old".to_string(), + "new".to_string(), + "ref".to_string() + ) + ); + } + + #[test] + fn parse_hook_stdin_skips_single_field_lines() { + let input = b"only_one\nabc def\n\nghi jkl extra\n"; + let parsed = parse_hook_stdin(input); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0], ("abc".to_string(), "def".to_string())); + assert_eq!(parsed[1], ("ghi".to_string(), "jkl".to_string())); + } + + #[test] + fn is_path_inside_component_finds_nested_segment() { + assert!(is_path_inside_component( + Path::new("/home/user/.git-ai/hooks"), + ".git-ai" + )); + assert!(!is_path_inside_component( + Path::new("/home/user/hooks"), + ".git-ai" + )); + assert!(is_path_inside_component( + Path::new("/a/.GIT-AI/b"), + ".git-ai" + )); + } + + #[test] + fn is_path_inside_any_git_ai_dir_detects_git_ai_subtree() { + assert!(is_path_inside_any_git_ai_dir(Path::new( + "/repo/.git/ai/hooks" + ))); + assert!(!is_path_inside_any_git_ai_dir(Path::new( + "/repo/.git/hooks" + ))); + assert!(!is_path_inside_any_git_ai_dir(Path::new( + "/repo/ai/hooks" + ))); + assert!(is_path_inside_any_git_ai_dir(Path::new( + "/other/.git/AI/deep/path" + ))); + } + + #[test] + fn hook_has_no_managed_behavior_classifies_correctly() { + assert!(hook_has_no_managed_behavior("commit-msg")); + assert!(hook_has_no_managed_behavior("pre-auto-gc")); + assert!(hook_has_no_managed_behavior("fsmonitor-watchman")); + assert!(!hook_has_no_managed_behavior("pre-commit")); + assert!(!hook_has_no_managed_behavior("post-rewrite")); + assert!(!hook_has_no_managed_behavior("reference-transaction")); + assert!(!hook_has_no_managed_behavior("pre-push")); + } + + #[test] + fn cherry_pick_batch_state_serialization_roundtrip() { + let state = CherryPickBatchState { + schema_version: cherry_pick_batch_state_schema_version(), + initial_head: "abc123".to_string(), + mappings: vec![ + CherryPickBatchMapping { + source_commit: "aaa".to_string(), + new_commit: "bbb".to_string(), + }, + CherryPickBatchMapping { + source_commit: "ccc".to_string(), + new_commit: "ddd".to_string(), + }, + ], + active: true, + }; + + let json = serde_json::to_string_pretty(&state).expect("serialization should succeed"); + let deserialized: CherryPickBatchState = + serde_json::from_str(&json).expect("deserialization should succeed"); + assert_eq!(state, deserialized); + } + + #[test] + fn ensure_hook_symlink_is_idempotent() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let repo = init_repo(&tmp.path().join("repo")); + ensure_repo_hooks_installed(&repo, false).expect("ensure repo hooks should succeed"); + + let managed_hooks_dir = managed_git_hooks_dir_for_repo(&repo); + let binary_path = std::env::current_exe() + .ok() + .and_then(|path| fs::canonicalize(path).ok()) + .unwrap_or_else(|| PathBuf::from("git-ai")); + + let hook_path = managed_hooks_dir.join("pre-commit"); + let first = ensure_hook_symlink(&hook_path, &binary_path, false) + .expect("first ensure_hook_symlink"); + assert!(!first, "second call should report no change"); + + let second = ensure_hook_symlink(&hook_path, &binary_path, false) + .expect("second ensure_hook_symlink"); + assert!(!second, "third call should also report no change"); + } + + #[test] + fn rebase_hook_mask_double_enable_is_noop() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let repo = init_repo(&tmp.path().join("repo")); + ensure_repo_hooks_installed(&repo, false).expect("ensure repo hooks should succeed"); + + maybe_enable_rebase_hook_mask(&repo); + + let state_path = rebase_hook_mask_state_path(&repo); + let state1 = read_rebase_hook_mask_state(&state_path) + .expect("read should succeed") + .expect("state should exist"); + + maybe_enable_rebase_hook_mask(&repo); + + let state2 = read_rebase_hook_mask_state(&state_path) + .expect("read should succeed") + .expect("state should exist"); + assert_eq!( + state1.session_id, state2.session_id, + "second enable should not create a new session" + ); + + restore_rebase_hooks_for_repo(&repo, true); + } + + #[test] + fn repo_hook_state_serialization_roundtrip() { + let state = RepoHookState { + schema_version: repo_hook_state_schema_version(), + managed_hooks_path: "/tmp/test/.git/ai/hooks".to_string(), + original_local_hooks_path: Some("/tmp/user-hooks".to_string()), + forward_mode: ForwardMode::RepoLocal, + forward_hooks_path: Some("/tmp/user-hooks".to_string()), + binary_path: "/usr/local/bin/git-ai".to_string(), + }; + + let json = serde_json::to_string_pretty(&state).expect("serialization should succeed"); + let deserialized: RepoHookState = + serde_json::from_str(&json).expect("deserialization should succeed"); + assert_eq!(state, deserialized); + } + + #[test] + fn forward_mode_none_serialization() { + let state = RepoHookState { + forward_mode: ForwardMode::None, + ..Default::default() + }; + let json = serde_json::to_string(&state).expect("serialization should succeed"); + assert!(json.contains("\"none\"")); + let deserialized: RepoHookState = + serde_json::from_str(&json).expect("deserialization should succeed"); + assert_eq!(deserialized.forward_mode, ForwardMode::None); + } + + #[test] + fn parse_whitespace_fields_handles_empty_input() { + let empty: &[u8] = b""; + assert!(parse_whitespace_fields(empty, 2).is_empty()); + assert!(parse_whitespace_fields(b"\n\n", 1).is_empty()); + assert!(parse_whitespace_fields(b" \n \n", 1).is_empty()); + } + + #[test] + fn managed_git_hook_names_subset_of_core() { + for name in MANAGED_GIT_HOOK_NAMES { + assert!( + CORE_GIT_HOOK_NAMES.contains(name), + "managed hook {:?} should be in CORE_GIT_HOOK_NAMES", + name + ); + } + } + + #[test] + fn rebase_terminal_hooks_subset_of_managed() { + for name in REBASE_TERMINAL_HOOK_NAMES { + assert!( + MANAGED_GIT_HOOK_NAMES.contains(name), + "rebase terminal hook {:?} should be in MANAGED_GIT_HOOK_NAMES", + name + ); + } + } + + #[test] + #[serial] + fn ensure_repo_hooks_no_forward_target_skips_non_managed() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let isolated_home = tmp.path().join("home"); + fs::create_dir_all(&isolated_home).expect("failed to create isolated home"); + let empty_global = isolated_home.join(".gitconfig"); + fs::write(&empty_global, "").expect("failed to write empty global config"); + let _home = EnvVarGuard::set("HOME", isolated_home.to_string_lossy().as_ref()); + let _global = EnvVarGuard::set( + "GIT_CONFIG_GLOBAL", + empty_global.to_string_lossy().as_ref(), + ); + + let repo = init_repo(&tmp.path().join("repo")); + + let _ = + ensure_repo_hooks_installed(&repo, false).expect("ensure repo hooks should succeed"); + + let managed_hooks_dir = managed_git_hooks_dir_for_repo(&repo); + for hook_name in MANAGED_GIT_HOOK_NAMES { + let hook_path = managed_hooks_dir.join(hook_name); + assert!( + hook_path.exists() || hook_path.symlink_metadata().is_ok(), + "managed hook should exist: {}", + hook_name + ); + } + assert!( + !managed_hooks_dir.join("commit-msg").exists(), + "non-managed hooks should NOT be provisioned without a forward target" + ); + } + + #[test] + fn non_managed_hooks_provisioned_only_when_original_exists() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let repo = init_repo(&tmp.path().join("repo")); + let user_hooks = tmp.path().join("user-hooks"); + fs::create_dir_all(&user_hooks).expect("failed to create user hooks dir"); + + fs::write(user_hooks.join("commit-msg"), "#!/bin/sh\nexit 0\n") + .expect("failed to write commit-msg hook"); + fs::write(user_hooks.join("pre-merge-commit"), "#!/bin/sh\nexit 0\n") + .expect("failed to write pre-merge-commit hook"); + + let local_config = repo.path().join("config"); + set_hooks_path_in_config( + &local_config, + gix_config::Source::Local, + &user_hooks.to_string_lossy(), + false, + ) + .expect("failed to set preexisting local hooksPath"); + + let _ = + ensure_repo_hooks_installed(&repo, false).expect("ensure repo hooks should succeed"); + + let managed_hooks_dir = managed_git_hooks_dir_for_repo(&repo); + + assert!( + managed_hooks_dir.join("commit-msg").symlink_metadata().is_ok(), + "commit-msg should be provisioned when original exists in forward dir" + ); + assert!( + managed_hooks_dir.join("pre-merge-commit").symlink_metadata().is_ok(), + "pre-merge-commit should be provisioned when original exists in forward dir" + ); + + assert!( + managed_hooks_dir.join("applypatch-msg").symlink_metadata().is_err(), + "hooks without originals in forward dir should not be provisioned" + ); + } + + #[test] + fn non_managed_hook_symlinks_cleaned_on_resync() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let managed_dir = tmp.path().join("managed"); + fs::create_dir_all(&managed_dir).expect("failed to create managed dir"); + let binary = tmp.path().join("fake-binary"); + fs::write(&binary, "").expect("failed to write fake binary"); + + let forward_dir = tmp.path().join("forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + fs::write(forward_dir.join("commit-msg"), "#!/bin/sh\nexit 0\n") + .expect("failed to write commit-msg"); + + let changed = sync_non_managed_hook_symlinks( + &managed_dir, + &binary, + Some(forward_dir.to_string_lossy().as_ref()), + false, + ) + .expect("sync should succeed"); + assert!(changed, "first sync should report changes"); + assert!( + managed_dir.join("commit-msg").symlink_metadata().is_ok(), + "commit-msg symlink should exist after sync" + ); + + fs::remove_file(forward_dir.join("commit-msg")).expect("failed to remove original"); + let changed = sync_non_managed_hook_symlinks( + &managed_dir, + &binary, + Some(forward_dir.to_string_lossy().as_ref()), + false, + ) + .expect("resync should succeed"); + assert!(changed, "resync should report changes (stale removal)"); + assert!( + managed_dir.join("commit-msg").symlink_metadata().is_err(), + "commit-msg symlink should be removed after original deleted" + ); + } + + #[test] + fn null_oid_detection() { + assert!(is_null_oid("0000000000000000000000000000000000000000")); + assert!(is_null_oid("0000000")); + assert!(!is_null_oid("")); + assert!(!is_null_oid("abc0000000000000000000000000000000000000")); + assert!(!is_null_oid("0000000000000000000000000000000000000001")); + } + + #[test] + fn hook_has_no_managed_behavior_matches_managed_list() { + for name in MANAGED_GIT_HOOK_NAMES { + assert!( + !hook_has_no_managed_behavior(name), + "managed hook {:?} should NOT be classified as no-managed-behavior", + name + ); + } + assert!(hook_has_no_managed_behavior("commit-msg")); + assert!(hook_has_no_managed_behavior("pre-merge-commit")); + assert!(hook_has_no_managed_behavior("fsmonitor-watchman")); + assert!(hook_has_no_managed_behavior("totally-unknown-hook")); + } + + #[test] + fn hook_has_no_managed_behavior_consistent_with_core() { + for name in CORE_GIT_HOOK_NAMES { + if MANAGED_GIT_HOOK_NAMES.contains(name) { + assert!( + !hook_has_no_managed_behavior(name), + "core+managed hook {:?} should have managed behavior", + name + ); + } else { + assert!( + hook_has_no_managed_behavior(name), + "core-only hook {:?} should have no managed behavior", + name + ); + } + } + } } diff --git a/tests/hook_forwarding.rs b/tests/hook_forwarding.rs new file mode 100644 index 000000000..bc25cdac8 --- /dev/null +++ b/tests/hook_forwarding.rs @@ -0,0 +1,608 @@ +mod repos; + +use repos::test_repo::TestRepo; +use serial_test::serial; +use std::fs; +use std::path::{Path, PathBuf}; + +struct EnvVarGuard { + key: &'static str, + old: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let old = std::env::var(key).ok(); + unsafe { + std::env::set_var(key, value); + } + Self { key, old } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + if let Some(old) = &self.old { + std::env::set_var(self.key, old); + } else { + std::env::remove_var(self.key); + } + } + } +} + +#[cfg(unix)] +fn set_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path) + .expect("failed to stat file") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("failed to set executable bit"); +} + +fn managed_hooks_dir(repo: &TestRepo) -> PathBuf { + repo.path().join(".git").join("ai").join("hooks") +} + +fn hook_state_path(repo: &TestRepo) -> PathBuf { + repo.path() + .join(".git") + .join("ai") + .join("git_hooks_state.json") +} + +fn configure_forward_target(repo: &TestRepo, forward_dir: &Path) { + repo.git(&[ + "config", + "--local", + "core.hooksPath", + forward_dir.to_string_lossy().as_ref(), + ]) + .expect("setting core.hooksPath should succeed"); + + repo.git_ai(&["git-hooks", "ensure"]) + .expect("git-hooks ensure should succeed"); +} + +fn prepare_file(repo: &TestRepo, filename: &str) { + fs::write(repo.path().join(filename), "hello\n").expect("failed to write file"); + repo.git(&["add", filename]).expect("git add should succeed"); +} + +#[cfg(unix)] +fn commit_msg_marker_script(marker_path: &Path) -> String { + format!( + "#!/bin/sh\necho commit-msg-fired >> '{}'\n", + marker_path.to_string_lossy() + ) +} + +// --------------------------------------------------------------------------- +// 1. Hooks mode forwards non-managed commit-msg hook +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_forwards_non_managed_commit_msg_hook() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".husky"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let marker_path = repo.path().join(".git").join("commit-msg-marker.txt"); + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write(&commit_msg_hook, commit_msg_marker_script(&marker_path)) + .expect("failed to write commit-msg hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &forward_dir); + + assert!( + managed_hooks_dir(&repo).join("commit-msg").symlink_metadata().is_ok(), + "commit-msg should be provisioned when it exists in the forward target" + ); + + prepare_file(&repo, "forwarded.txt"); + + repo.git(&["commit", "-m", "forwarded commit-msg hook"]) + .expect("commit should succeed"); + + let marker = fs::read_to_string(&marker_path).expect("marker should exist"); + let count = marker + .lines() + .filter(|line| line.trim() == "commit-msg-fired") + .count(); + assert_eq!(count, 1, "commit-msg should fire exactly once"); +} + +// --------------------------------------------------------------------------- +// 2. commit-msg hook receives the message file argument +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_commit_msg_receives_message_file_arg() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("arg-hooks"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let marker_path = repo.path().join(".git").join("arg-marker.txt"); + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write( + &commit_msg_hook, + format!( + "#!/bin/sh\nfirst=\"$(head -n 1 \"$1\")\"\necho \"msg:${{first}}\" >> '{}'\n", + marker_path.to_string_lossy() + ), + ) + .expect("failed to write commit-msg hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &forward_dir); + + prepare_file(&repo, "arg.txt"); + + repo.git(&["commit", "-m", "verify arg passing"]) + .expect("commit should succeed"); + + let marker = fs::read_to_string(&marker_path).expect("marker should exist"); + assert!( + marker.contains("msg:verify arg passing"), + "expected commit-msg hook to read the commit message; got: {}", + marker + ); +} + +// --------------------------------------------------------------------------- +// 3. Forwarded commit-msg failure blocks the commit +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_commit_msg_failure_propagates() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("failing-hooks"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write(&commit_msg_hook, "#!/bin/sh\nexit 2\n").expect("failed to write hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &forward_dir); + + prepare_file(&repo, "fail.txt"); + + let result = repo.git(&["commit", "-m", "should fail"]); + assert!( + result.is_err(), + "commit should fail when forwarded commit-msg exits non-zero" + ); +} + +// --------------------------------------------------------------------------- +// 4. Non-managed hooks are not provisioned when the forward dir lacks them +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_non_managed_hooks_not_provisioned_without_original() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("empty-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + configure_forward_target(&repo, &forward_dir); + + let managed_dir = managed_hooks_dir(&repo); + assert!( + !managed_dir.join("commit-msg").exists() + && managed_dir.join("commit-msg").symlink_metadata().is_err(), + "commit-msg should not be provisioned when it does not exist in the forward target" + ); + + prepare_file(&repo, "no-hook.txt"); + repo.git(&["commit", "-m", "no commit-msg hook"]) + .expect("commit should succeed"); +} + +// --------------------------------------------------------------------------- +// 5. ensure picks up a newly added non-managed hook +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_ensure_picks_up_new_hook_in_forward_dir() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("dynamic-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + configure_forward_target(&repo, &forward_dir); + + let managed_dir = managed_hooks_dir(&repo); + assert!( + managed_dir.join("commit-msg").symlink_metadata().is_err(), + "commit-msg should not be provisioned before it exists in the forward dir" + ); + + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write(&commit_msg_hook, "#!/bin/sh\nexit 0\n").expect("failed to write hook"); + set_executable(&commit_msg_hook); + + repo.git_ai(&["git-hooks", "ensure"]) + .expect("re-ensure should succeed"); + + assert!( + managed_dir.join("commit-msg").symlink_metadata().is_ok(), + "commit-msg should be provisioned after being added to forward dir" + ); +} + +// --------------------------------------------------------------------------- +// 6. ensure removes stale non-managed hook symlink after deletion +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_ensure_removes_stale_symlink() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("stale-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write(&commit_msg_hook, "#!/bin/sh\nexit 0\n").expect("failed to write hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &forward_dir); + + let managed_dir = managed_hooks_dir(&repo); + assert!( + managed_dir.join("commit-msg").symlink_metadata().is_ok(), + "commit-msg should be provisioned initially" + ); + + fs::remove_file(&commit_msg_hook).expect("failed to remove original hook"); + + repo.git_ai(&["git-hooks", "ensure"]) + .expect("re-ensure should succeed"); + + assert!( + managed_dir.join("commit-msg").symlink_metadata().is_err(), + "stale commit-msg symlink should be removed after original is deleted" + ); +} + +// --------------------------------------------------------------------------- +// 7. Husky-style hooks using dirname "$0" work via forwarding +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_husky_style_dirname_resolution() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let husky_dir = repo.path().join(".husky"); + let internal = husky_dir.join("_"); + fs::create_dir_all(&internal).expect("failed to create .husky/_"); + + fs::write(internal.join("husky.sh"), "#!/bin/sh\n") + .expect("failed to write husky.sh"); + set_executable(&internal.join("husky.sh")); + + let marker_path = repo.path().join(".git").join("husky-marker.txt"); + let commit_msg_hook = husky_dir.join("commit-msg"); + fs::write( + &commit_msg_hook, + format!( + "#!/bin/sh\nhook_dir=\"$(dirname \"$0\")\"\nif [ -f \"$hook_dir/_/husky.sh\" ]; then\n echo husky-ok >> '{}'\nelse\n echo husky-broken:$hook_dir >> '{}'\nfi\n", + marker_path.to_string_lossy(), + marker_path.to_string_lossy() + ), + ) + .expect("failed to write husky commit-msg hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &husky_dir); + + prepare_file(&repo, "husky.txt"); + + repo.git(&["commit", "-m", "husky test"]) + .expect("commit should succeed"); + + let marker = fs::read_to_string(&marker_path).expect("marker should exist"); + assert!( + marker.contains("husky-ok"), + "expected husky dirname resolution to succeed; got: {}", + marker + ); + assert!( + !marker.contains("husky-broken"), + "expected husky dirname resolution to not be broken; got: {}", + marker + ); +} + +// --------------------------------------------------------------------------- +// 8. Directory entry in forward dir is ignored (not treated as hook) +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_directory_in_forward_dir_ignored() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("dir-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + fs::create_dir_all(forward_dir.join("commit-msg")) + .expect("failed to create hook directory"); + + configure_forward_target(&repo, &forward_dir); + + assert!( + managed_hooks_dir(&repo) + .join("commit-msg") + .symlink_metadata() + .is_err(), + "directory named commit-msg should not be provisioned" + ); +} + +// --------------------------------------------------------------------------- +// 9. Non-executable hook exists but is skipped during forwarding +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_non_executable_forwarded_hook_skipped() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("noexec-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let marker_path = repo.path().join(".git").join("noexec-marker.txt"); + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write( + &commit_msg_hook, + format!( + "#!/bin/sh\necho ran >> '{}'\nexit 1\n", + marker_path.to_string_lossy() + ), + ) + .expect("failed to write hook"); + + configure_forward_target(&repo, &forward_dir); + + prepare_file(&repo, "noexec.txt"); + + repo.git(&["commit", "-m", "non-exec hook should be skipped"]) + .expect("commit should succeed"); + + assert!( + fs::read_to_string(&marker_path).is_err(), + "marker should not exist because non-executable hook should not run" + ); +} + +// --------------------------------------------------------------------------- +// 10. Both mode: non-managed hook executes exactly once +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn both_mode_non_managed_hook_runs_exactly_once() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "both"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("both-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let marker_path = repo.path().join(".git").join("both-marker.txt"); + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write(&commit_msg_hook, commit_msg_marker_script(&marker_path)) + .expect("failed to write commit-msg hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &forward_dir); + + prepare_file(&repo, "both.txt"); + + repo.git(&["commit", "-m", "both mode commit"]) + .expect("commit should succeed"); + + let marker = fs::read_to_string(&marker_path).expect("marker should exist"); + let count = marker + .lines() + .filter(|line| line.trim() == "commit-msg-fired") + .count(); + assert_eq!( + count, 1, + "commit-msg should run once in both mode, ran {} times", + count + ); +} + +// --------------------------------------------------------------------------- +// 11. Non-managed hook symlinks point to same git-ai binary as managed hooks +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_non_managed_symlinks_point_to_git_ai_binary() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("binary-target"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write(&commit_msg_hook, "#!/bin/sh\nexit 0\n").expect("failed to write hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &forward_dir); + + let managed_dir = managed_hooks_dir(&repo); + let non_managed_link = managed_dir.join("commit-msg"); + let managed_link = managed_dir.join("pre-commit"); + + let non_managed_target = fs::read_link(&non_managed_link) + .expect("should read non-managed symlink target"); + let managed_target = fs::read_link(&managed_link).expect("should read managed symlink target"); + + let non_managed_canon = + fs::canonicalize(&non_managed_target).unwrap_or_else(|_| non_managed_target.clone()); + let managed_canon = fs::canonicalize(&managed_target).unwrap_or_else(|_| managed_target.clone()); + + assert_eq!( + non_managed_canon, managed_canon, + "non-managed hook should symlink to git-ai binary (same as managed hooks)" + ); +} + +// --------------------------------------------------------------------------- +// 12. State file records forward target after ensure +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_state_file_records_forward_target() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("state-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + configure_forward_target(&repo, &forward_dir); + + let state_raw = fs::read_to_string(hook_state_path(&repo)).expect("state should exist"); + let state: serde_json::Value = serde_json::from_str(&state_raw).expect("valid JSON"); + + assert_eq!(state["forward_mode"].as_str(), Some("repo_local")); + assert_eq!( + state["forward_hooks_path"].as_str().map(|s| s.trim()), + Some(forward_dir.to_string_lossy().trim()) + ); +} + +// --------------------------------------------------------------------------- +// 13. Managed hooks are installed in hooks mode +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_managed_hooks_always_installed() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + let managed_dir = managed_hooks_dir(&repo); + + for hook_name in [ + "pre-commit", + "prepare-commit-msg", + "post-commit", + "pre-rebase", + "post-checkout", + "post-merge", + "pre-push", + "post-rewrite", + "reference-transaction", + ] { + let hook_path = managed_dir.join(hook_name); + assert!( + hook_path.exists() || hook_path.symlink_metadata().is_ok(), + "managed hook {} should be installed", + hook_name + ); + } +} + +// --------------------------------------------------------------------------- +// 14. Managed attribution still works with forwarding enabled +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +#[serial] +fn hooks_mode_managed_hooks_still_produce_authorship_with_forwarding() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "hooks"); + + let repo = TestRepo::new(); + + let forward_dir = repo.path().join(".git").join("authorship-forward"); + fs::create_dir_all(&forward_dir).expect("failed to create forward dir"); + + let commit_msg_hook = forward_dir.join("commit-msg"); + fs::write(&commit_msg_hook, "#!/bin/sh\nexit 0\n").expect("failed to write hook"); + set_executable(&commit_msg_hook); + + configure_forward_target(&repo, &forward_dir); + + prepare_file(&repo, "authorship.txt"); + + repo.git_ai(&["checkpoint", "mock_ai", "authorship.txt"]) + .expect("checkpoint should succeed"); + + let commit = repo + .commit("authorship with forwarding") + .expect("commit should succeed"); + + assert!( + !commit.authorship_log.attestations.is_empty(), + "expected authorship attestations to be present" + ); +} + +// --------------------------------------------------------------------------- +// 15. Wrapper mode does not set up repo-local hook directory +// --------------------------------------------------------------------------- + +#[test] +#[serial] +fn wrapper_mode_does_not_install_hook_symlinks() { + let _mode = EnvVarGuard::set("GIT_AI_TEST_GIT_MODE", "wrapper"); + + let repo = TestRepo::new(); + + assert!( + !managed_hooks_dir(&repo).exists(), + "managed hooks dir should not exist in wrapper-only mode" + ); +}