@@ -15,8 +15,9 @@ use crate::services::repository_identity::resolve::{
1515 resolve_repository_identity, RepositoryIdentitySource ,
1616} ;
1717use crate :: services:: setup:: {
18- config_merge, iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets,
19- persisted_optional_workflows, repair_merge_target_asset, EmbeddedAsset , SetupTarget ,
18+ config_merge, hook_merge, iter_embedded_assets_for_setup_target_with_selection,
19+ iter_required_hook_assets, persisted_optional_workflows, repair_merge_target_asset,
20+ EmbeddedAsset , SetupTarget ,
2021} ;
2122
2223use super :: types:: {
@@ -318,17 +319,28 @@ fn inspect_hook_content_state_without_problem(
318319 } ;
319320
320321 match fs:: read ( hook_path) {
321- Ok ( bytes) => {
322- if bytes == expected_hook. bytes {
323- HookContentState :: Current
324- } else {
325- HookContentState :: Stale
326- }
327- }
322+ Ok ( bytes) => hook_managed_block_content_state ( hook_name, & bytes, expected_hook. bytes ) ,
328323 Err ( _) => HookContentState :: Unknown ,
329324 }
330325}
331326
327+ /// Classifies a hook's on-disk bytes against the canonical template by SCE
328+ /// managed-block currency (merging the canonical block into `bytes` is a
329+ /// no-op) rather than whole-file equality, so foreign content a repository
330+ /// has appended around the block does not read as drift. An unbalanced or
331+ /// partial managed block is also reported `Stale`, since it needs the same
332+ /// `--fix` repair as a drifted one.
333+ fn hook_managed_block_content_state (
334+ hook_name : & str ,
335+ bytes : & [ u8 ] ,
336+ canonical : & [ u8 ] ,
337+ ) -> HookContentState {
338+ match hook_merge:: merge_or_create_hook ( Some ( bytes) , canonical, hook_name) {
339+ Ok ( merge) if merge. bytes == bytes => HookContentState :: Current ,
340+ Ok ( _) | Err ( _) => HookContentState :: Stale ,
341+ }
342+ }
343+
332344#[ allow( dead_code) ]
333345fn inspect_repository_hooks (
334346 repository_root : & Path ,
@@ -1568,13 +1580,7 @@ fn inspect_hook_content_state(
15681580 } ;
15691581
15701582 match fs:: read ( hook_path) {
1571- Ok ( bytes) => {
1572- if bytes == expected_hook. bytes {
1573- HookContentState :: Current
1574- } else {
1575- HookContentState :: Stale
1576- }
1577- }
1583+ Ok ( bytes) => hook_managed_block_content_state ( hook_name, & bytes, expected_hook. bytes ) ,
15781584 Err ( error) => {
15791585 problems. push ( DoctorProblem {
15801586 kind : ProblemKind :: HookReadFailed ,
@@ -1602,8 +1608,9 @@ mod tests {
16021608 use std:: path:: PathBuf ;
16031609
16041610 use super :: {
1605- collect_claude_integration_groups, collect_opencode_integration_groups,
1606- collect_pi_integration_groups, inspect_claude_integration_health, IntegrationContentState ,
1611+ collect_claude_integration_groups, collect_hook_file_health,
1612+ collect_opencode_integration_groups, collect_pi_integration_groups,
1613+ inspect_claude_integration_health, HookContentState , IntegrationContentState ,
16071614 IntegrationGroupHealth ,
16081615 } ;
16091616 use crate :: services:: setup:: OPTIONAL_WORKFLOWS ;
@@ -1942,4 +1949,150 @@ mod tests {
19421949
19431950 std:: fs:: remove_dir_all ( & root) . ok ( ) ;
19441951 }
1952+
1953+ fn canonical_pre_commit_bytes ( ) -> & ' static [ u8 ] {
1954+ crate :: services:: setup:: iter_required_hook_assets ( )
1955+ . find ( |asset| asset. relative_path == "pre-commit" )
1956+ . expect ( "embedded catalog carries pre-commit" )
1957+ . bytes
1958+ }
1959+
1960+ #[ cfg( unix) ]
1961+ fn mark_executable ( path : & std:: path:: Path ) {
1962+ use std:: os:: unix:: fs:: PermissionsExt ;
1963+ std:: fs:: set_permissions ( path, std:: fs:: Permissions :: from_mode ( 0o755 ) )
1964+ . expect ( "mark hook executable" ) ;
1965+ }
1966+
1967+ #[ test]
1968+ fn hook_with_foreign_content_and_current_block_reports_current ( ) {
1969+ let dir = unique_temp_repository_root ( "hook-foreign-current" ) ;
1970+ let foreign_prefix = b"#!/bin/sh\n echo husky-style-guard\n " . to_vec ( ) ;
1971+ let merge = crate :: services:: setup:: hook_merge:: merge_or_create_hook (
1972+ Some ( & foreign_prefix) ,
1973+ canonical_pre_commit_bytes ( ) ,
1974+ "pre-commit" ,
1975+ )
1976+ . expect ( "merge over foreign hook should succeed" ) ;
1977+
1978+ let hook_path = dir. join ( "pre-commit" ) ;
1979+ std:: fs:: write ( & hook_path, & merge. bytes ) . expect ( "write foreign-plus-block hook" ) ;
1980+ #[ cfg( unix) ]
1981+ mark_executable ( & hook_path) ;
1982+
1983+ let health = collect_hook_file_health ( & dir) ;
1984+ let pre_commit = health
1985+ . iter ( )
1986+ . find ( |hook| hook. name == "pre-commit" )
1987+ . expect ( "pre-commit health present" ) ;
1988+ assert_eq ! ( pre_commit. content_state, HookContentState :: Current ) ;
1989+
1990+ std:: fs:: remove_dir_all ( & dir) . ok ( ) ;
1991+ }
1992+
1993+ #[ test]
1994+ fn hook_with_drifted_managed_block_reports_stale ( ) {
1995+ let dir = unique_temp_repository_root ( "hook-drifted-stale" ) ;
1996+ let canonical_text =
1997+ String :: from_utf8 ( canonical_pre_commit_bytes ( ) . to_vec ( ) ) . expect ( "hook is utf8" ) ;
1998+ let drifted_text = canonical_text. replace (
1999+ "sce hooks pre-commit \" $@\" " ,
2000+ "sce hooks pre-commit \" $@\" # drifted" ,
2001+ ) ;
2002+ assert_ne ! (
2003+ drifted_text, canonical_text,
2004+ "drift fixture should actually differ from canonical"
2005+ ) ;
2006+
2007+ let hook_path = dir. join ( "pre-commit" ) ;
2008+ std:: fs:: write ( & hook_path, drifted_text. as_bytes ( ) ) . expect ( "write drifted hook" ) ;
2009+ #[ cfg( unix) ]
2010+ mark_executable ( & hook_path) ;
2011+
2012+ let health = collect_hook_file_health ( & dir) ;
2013+ let pre_commit = health
2014+ . iter ( )
2015+ . find ( |hook| hook. name == "pre-commit" )
2016+ . expect ( "pre-commit health present" ) ;
2017+ assert_eq ! ( pre_commit. content_state, HookContentState :: Stale ) ;
2018+
2019+ std:: fs:: remove_dir_all ( & dir) . ok ( ) ;
2020+ }
2021+
2022+ fn init_git_repo ( label : & str ) -> PathBuf {
2023+ let repo = unique_temp_repository_root ( label) ;
2024+ let output = std:: process:: Command :: new ( "git" )
2025+ . args ( [ "init" , "-q" ] )
2026+ . current_dir ( & repo)
2027+ . output ( )
2028+ . expect ( "git init should spawn" ) ;
2029+ assert ! (
2030+ output. status. success( ) ,
2031+ "git init failed: {}" ,
2032+ String :: from_utf8_lossy( & output. stderr)
2033+ ) ;
2034+ repo
2035+ }
2036+
2037+ #[ test]
2038+ fn fix_repairs_drifted_hook_content_while_preserving_foreign_content ( ) {
2039+ let repo = init_git_repo ( "hook-fix-repair" ) ;
2040+
2041+ let initial_outcome = crate :: services:: setup:: install_required_git_hooks ( & repo)
2042+ . expect ( "initial hook install should succeed" ) ;
2043+ let pre_commit_path = initial_outcome
2044+ . hook_results
2045+ . iter ( )
2046+ . find ( |result| result. hook_name == "pre-commit" )
2047+ . expect ( "pre-commit hook installed" )
2048+ . hook_path
2049+ . clone ( ) ;
2050+ let hooks_directory = pre_commit_path
2051+ . parent ( )
2052+ . expect ( "hook path has a parent directory" )
2053+ . to_path_buf ( ) ;
2054+
2055+ let foreign_prefix = b"#!/bin/sh\n echo husky-style-guard\n " . to_vec ( ) ;
2056+ let foreign_plus_block = crate :: services:: setup:: hook_merge:: merge_or_create_hook (
2057+ Some ( & foreign_prefix) ,
2058+ canonical_pre_commit_bytes ( ) ,
2059+ "pre-commit" ,
2060+ )
2061+ . expect ( "merge over foreign hook should succeed" ) ;
2062+ let drifted_text = String :: from_utf8 ( foreign_plus_block. bytes . clone ( ) )
2063+ . expect ( "hook is utf8" )
2064+ . replace (
2065+ "sce hooks pre-commit \" $@\" " ,
2066+ "sce hooks pre-commit \" $@\" # drifted" ,
2067+ ) ;
2068+ std:: fs:: write ( & pre_commit_path, drifted_text. as_bytes ( ) )
2069+ . expect ( "seed foreign-plus-drifted-block hook" ) ;
2070+ #[ cfg( unix) ]
2071+ mark_executable ( & pre_commit_path) ;
2072+
2073+ let health_before = collect_hook_file_health ( & hooks_directory) ;
2074+ let pre_commit_before = health_before
2075+ . iter ( )
2076+ . find ( |hook| hook. name == "pre-commit" )
2077+ . expect ( "pre-commit health present" ) ;
2078+ assert_eq ! ( pre_commit_before. content_state, HookContentState :: Stale ) ;
2079+
2080+ crate :: services:: setup:: install_required_git_hooks ( & repo)
2081+ . expect ( "'--fix' repair reuses the canonical setup hook installation" ) ;
2082+
2083+ let repaired_bytes = std:: fs:: read ( & pre_commit_path) . expect ( "read repaired hook" ) ;
2084+ assert ! (
2085+ repaired_bytes. starts_with( & foreign_prefix) ,
2086+ "foreign content should survive the repair"
2087+ ) ;
2088+
2089+ let health_after = collect_hook_file_health ( & hooks_directory) ;
2090+ let pre_commit_after = health_after
2091+ . iter ( )
2092+ . find ( |hook| hook. name == "pre-commit" )
2093+ . expect ( "pre-commit health present" ) ;
2094+ assert_eq ! ( pre_commit_after. content_state, HookContentState :: Current ) ;
2095+
2096+ std:: fs:: remove_dir_all ( & repo) . ok ( ) ;
2097+ }
19452098}
0 commit comments