@@ -494,12 +494,17 @@ fn ensure_line(path: &Path, line: &str) -> Result<bool> {
494494 Ok ( true )
495495}
496496
497- fn deep_merge ( target : & mut serde_json:: Value , patch : serde_json:: Value , arrays : ArrayMerge ) {
497+ fn deep_merge (
498+ target : & mut serde_json:: Value ,
499+ patch : serde_json:: Value ,
500+ arrays : ArrayMerge ,
501+ owned_prefixes : & [ String ] ,
502+ ) {
498503 match ( target, patch) {
499504 ( serde_json:: Value :: Object ( target) , serde_json:: Value :: Object ( patch) ) => {
500505 for ( key, value) in patch {
501506 match target. get_mut ( & key) {
502- Some ( existing) => deep_merge ( existing, value, arrays) ,
507+ Some ( existing) => deep_merge ( existing, value, arrays, owned_prefixes ) ,
503508 None => {
504509 target. insert ( key, value) ;
505510 }
@@ -509,6 +514,14 @@ fn deep_merge(target: &mut serde_json::Value, patch: serde_json::Value, arrays:
509514 ( serde_json:: Value :: Array ( target) , serde_json:: Value :: Array ( patch) )
510515 if arrays == ArrayMerge :: Union =>
511516 {
517+ // Exact-equality union alone would accumulate st2's own entries across hook-set
518+ // upgrades: `$ST_HOOKS` expands content-addressed, so every upgrade renders each
519+ // entry with a new path and the old one would be retained beside it. An element
520+ // recognizably st2's — one referencing the hook root — that the patch no longer
521+ // states is therefore superseded and dropped; foreign entries are never touched.
522+ target. retain ( |element| {
523+ !contains_owned_string ( element, owned_prefixes) || patch. contains ( element)
524+ } ) ;
512525 for element in patch {
513526 if !target. contains ( & element) {
514527 target. push ( element) ;
@@ -519,6 +532,35 @@ fn deep_merge(target: &mut serde_json::Value, patch: serde_json::Value, arrays:
519532 }
520533}
521534
535+ /// Whether any string inside `value` marks it as an st2-rendered element: a reference to the
536+ /// installed hook root (any set version) or the unexpanded `$ST_HOOKS` variable.
537+ fn contains_owned_string ( value : & serde_json:: Value , owned_prefixes : & [ String ] ) -> bool {
538+ match value {
539+ serde_json:: Value :: String ( text) => owned_prefixes
540+ . iter ( )
541+ . any ( |prefix| text. starts_with ( prefix. as_str ( ) ) ) ,
542+ serde_json:: Value :: Array ( items) => items
543+ . iter ( )
544+ . any ( |item| contains_owned_string ( item, owned_prefixes) ) ,
545+ serde_json:: Value :: Object ( map) => map
546+ . values ( )
547+ . any ( |item| contains_owned_string ( item, owned_prefixes) ) ,
548+ _ => false ,
549+ }
550+ }
551+
552+ /// The string prefixes that mark a JSON element as st2-rendered for union supersession: the hook
553+ /// root that contains every installed set version, and the unexpanded variable spellings.
554+ fn owned_union_prefixes ( env : & BTreeMap < String , String > ) -> Vec < String > {
555+ let mut prefixes = vec ! [ "$ST_HOOKS" . to_string( ) , "${ST_HOOKS}" . to_string( ) ] ;
556+ if let Some ( hooks) = env. get ( "ST_HOOKS" )
557+ && let Some ( root) = Path :: new ( hooks) . parent ( )
558+ {
559+ prefixes. push ( format ! ( "{}/" , root. display( ) ) ) ;
560+ }
561+ prefixes
562+ }
563+
522564fn git_exclude ( workspace : & Path , line : & str ) -> Result < bool > {
523565 let output = Command :: new ( "git" )
524566 . args ( [ "-C" ] )
@@ -843,7 +885,7 @@ pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Resu
843885 spec. identity
844886 )
845887 } ) ?;
846- deep_merge ( & mut target, patch, arrays) ;
888+ deep_merge ( & mut target, patch, arrays, & owned_union_prefixes ( & env ) ) ;
847889 let mut bytes = serde_json:: to_vec_pretty ( & target) ?;
848890 bytes. push ( b'\n' ) ;
849891 let note = format ! ( "{}: upserted {}" , spec. identity, raw_destination) ;
@@ -1096,6 +1138,7 @@ mod tests {
10961138 "array" : [ 2 ]
10971139 } ) ,
10981140 ArrayMerge :: Replace ,
1141+ & [ ] ,
10991142 ) ;
11001143 assert_eq ! (
11011144 target,
@@ -1117,8 +1160,8 @@ mod tests {
11171160 let ours = serde_json:: json!( {
11181161 "hooks" : { "Stop" : [ { "hooks" : [ { "type" : "command" , "command" : "$ST_HOOKS/claude-observe.sh Stop" } ] } ] }
11191162 } ) ;
1120- deep_merge ( & mut target, ours. clone ( ) , ArrayMerge :: Union ) ;
1121- deep_merge ( & mut target, ours, ArrayMerge :: Union ) ;
1163+ deep_merge ( & mut target, ours. clone ( ) , ArrayMerge :: Union , & [ ] ) ;
1164+ deep_merge ( & mut target, ours, ArrayMerge :: Union , & [ ] ) ;
11221165 assert_eq ! (
11231166 target,
11241167 serde_json:: json!( {
@@ -1129,4 +1172,36 @@ mod tests {
11291172 } )
11301173 ) ;
11311174 }
1175+
1176+ /// A hook-set upgrade renders every entry under a new content-addressed path. Union must
1177+ /// supersede st2's prior entries — recognizable by the hook root — rather than accumulate
1178+ /// them, while a user's entry under any other path survives every merge.
1179+ #[ test]
1180+ fn union_supersedes_prior_hook_set_entries_but_never_foreign_ones ( ) {
1181+ let owned = vec ! [
1182+ "$ST_HOOKS" . to_string( ) ,
1183+ "${ST_HOOKS}" . to_string( ) ,
1184+ "/state/st2/hooks/" . to_string( ) ,
1185+ ] ;
1186+ let mut target = serde_json:: json!( {
1187+ "hooks" : { "Stop" : [
1188+ { "hooks" : [ { "type" : "command" , "command" : "user-audit.sh" } ] } ,
1189+ { "hooks" : [ { "type" : "command" , "command" : "/state/st2/hooks/set-v1/claude-observe.sh Stop" } ] }
1190+ ] }
1191+ } ) ;
1192+ let upgraded = serde_json:: json!( {
1193+ "hooks" : { "Stop" : [ { "hooks" : [ { "type" : "command" , "command" : "/state/st2/hooks/set-v2/claude-observe.sh Stop" } ] } ] }
1194+ } ) ;
1195+ deep_merge ( & mut target, upgraded. clone ( ) , ArrayMerge :: Union , & owned) ;
1196+ deep_merge ( & mut target, upgraded, ArrayMerge :: Union , & owned) ;
1197+ assert_eq ! (
1198+ target,
1199+ serde_json:: json!( {
1200+ "hooks" : { "Stop" : [
1201+ { "hooks" : [ { "type" : "command" , "command" : "user-audit.sh" } ] } ,
1202+ { "hooks" : [ { "type" : "command" , "command" : "/state/st2/hooks/set-v2/claude-observe.sh Stop" } ] }
1203+ ] }
1204+ } )
1205+ ) ;
1206+ }
11321207}
0 commit comments