@@ -243,12 +243,10 @@ hooks:
243243 t .Fatalf ("write trust store: %v" , err )
244244 }
245245
246- cfg := * config .StaticDefaults ()
247- cfg .Workdir = workspace
248246 service := & Service {events : make (chan RuntimeEvent , 64 )}
249- exec , err := buildRepoHookExecutor (service , cfg , config .StaticDefaults ().Runtime .Hooks )
247+ exec , err := buildRepoHookExecutorForWorkspace (service , workspace , config .StaticDefaults ().Runtime .Hooks )
250248 if err != nil {
251- t .Fatalf ("buildRepoHookExecutor () error = %v" , err )
249+ t .Fatalf ("buildRepoHookExecutorForWorkspace () error = %v" , err )
252250 }
253251 if exec != nil {
254252 t .Fatal ("expected nil repo executor for untrusted workspace" )
@@ -287,12 +285,10 @@ hooks:
287285 t .Fatalf ("write hooks: %v" , err )
288286 }
289287
290- cfg := * config .StaticDefaults ()
291- cfg .Workdir = workspace
292288 service := & Service {events : make (chan RuntimeEvent , 64 )}
293- exec , err := buildRepoHookExecutor (service , cfg , config .StaticDefaults ().Runtime .Hooks )
289+ exec , err := buildRepoHookExecutorForWorkspace (service , workspace , config .StaticDefaults ().Runtime .Hooks )
294290 if err != nil {
295- t .Fatalf ("buildRepoHookExecutor () error = %v" , err )
291+ t .Fatalf ("buildRepoHookExecutorForWorkspace () error = %v" , err )
296292 }
297293 if exec != nil {
298294 t .Fatal ("expected nil repo executor when trust store is missing" )
@@ -307,6 +303,91 @@ hooks:
307303 }
308304}
309305
306+ func TestDynamicRepoHookExecutorResolvesByRunWorkdir (t * testing.T ) {
307+ homeDir := t .TempDir ()
308+ t .Setenv ("HOME" , homeDir )
309+ workspaceA := filepath .Join (homeDir , "workspace-a" )
310+ workspaceB := filepath .Join (homeDir , "workspace-b" )
311+ if err := os .MkdirAll (filepath .Join (workspaceA , ".neocode" ), 0o755 ); err != nil {
312+ t .Fatalf ("mkdir workspaceA hooks dir: %v" , err )
313+ }
314+ if err := os .MkdirAll (filepath .Join (workspaceB , ".neocode" ), 0o755 ); err != nil {
315+ t .Fatalf ("mkdir workspaceB hooks dir: %v" , err )
316+ }
317+ if err := os .WriteFile (filepath .Join (workspaceA , ".neocode" , "hooks.yaml" ), []byte (`
318+ hooks:
319+ items:
320+ - id: repo-a
321+ point: before_tool_call
322+ scope: repo
323+ kind: builtin
324+ mode: sync
325+ handler: add_context_note
326+ params:
327+ note: repo-note-a
328+ ` ), 0o644 ); err != nil {
329+ t .Fatalf ("write workspaceA hooks: %v" , err )
330+ }
331+ if err := os .WriteFile (filepath .Join (workspaceB , ".neocode" , "hooks.yaml" ), []byte (`
332+ hooks:
333+ items:
334+ - id: repo-b
335+ point: before_tool_call
336+ scope: repo
337+ kind: builtin
338+ mode: sync
339+ handler: add_context_note
340+ params:
341+ note: repo-note-b
342+ ` ), 0o644 ); err != nil {
343+ t .Fatalf ("write workspaceB hooks: %v" , err )
344+ }
345+
346+ storePath := resolveTrustedWorkspacesPath ()
347+ if err := os .MkdirAll (filepath .Dir (storePath ), 0o755 ); err != nil {
348+ t .Fatalf ("mkdir trust store dir: %v" , err )
349+ }
350+ rawStore , err := json .Marshal (trustedWorkspaceStore {
351+ Version : repoHooksTrustStoreVersion ,
352+ Workspaces : []string {workspaceA , workspaceB },
353+ })
354+ if err != nil {
355+ t .Fatalf ("marshal trust store: %v" , err )
356+ }
357+ if err := os .WriteFile (storePath , rawStore , 0o644 ); err != nil {
358+ t .Fatalf ("write trust store: %v" , err )
359+ }
360+
361+ cfg := * config .StaticDefaults ()
362+ cfg .Workdir = workspaceA
363+ service := & Service {events : make (chan RuntimeEvent , 64 )}
364+ repoExecutor , err := buildRepoHookExecutor (service , cfg , config .StaticDefaults ().Runtime .Hooks )
365+ if err != nil {
366+ t .Fatalf ("buildRepoHookExecutor() error = %v" , err )
367+ }
368+ if repoExecutor == nil {
369+ t .Fatal ("expected dynamic repo executor" )
370+ }
371+
372+ run := func (workdir string ) runtimehooks.RunOutput {
373+ return repoExecutor .Run (context .Background (), runtimehooks .HookPointBeforeToolCall , runtimehooks.HookContext {
374+ Metadata : map [string ]any {
375+ "tool_name" : "bash" ,
376+ "workdir" : workdir ,
377+ },
378+ })
379+ }
380+
381+ first := run (workspaceA )
382+ if len (first .Results ) != 1 || first .Results [0 ].Message != "repo-note-a" {
383+ t .Fatalf ("workspaceA output = %+v, want repo-note-a" , first .Results )
384+ }
385+ second := run (workspaceB )
386+ if len (second .Results ) != 1 || second .Results [0 ].Message != "repo-note-b" {
387+ t .Fatalf ("workspaceB output = %+v, want repo-note-b" , second .Results )
388+ }
389+ }
390+
310391func containsRuntimeEventType (events []RuntimeEvent , target EventType ) bool {
311392 for _ , event := range events {
312393 if event .Type == target {
@@ -315,3 +396,175 @@ func containsRuntimeEventType(events []RuntimeEvent, target EventType) bool {
315396 }
316397 return false
317398}
399+
400+ func TestValidateRepoHookItemBranches (t * testing.T ) {
401+ base := config.RuntimeHookItemConfig {
402+ ID : "repo-hook" ,
403+ Point : "before_tool_call" ,
404+ Scope : "repo" ,
405+ Kind : "builtin" ,
406+ Mode : "sync" ,
407+ Handler : "add_context_note" ,
408+ TimeoutSec : 2 ,
409+ FailurePolicy : "warn_only" ,
410+ Params : map [string ]any {"note" : "x" },
411+ }
412+
413+ if err := validateRepoHookItem (base ); err != nil {
414+ t .Fatalf ("validateRepoHookItem(valid) error = %v" , err )
415+ }
416+
417+ cases := []struct {
418+ name string
419+ edit func (* config.RuntimeHookItemConfig )
420+ }{
421+ {name : "missing id" , edit : func (item * config.RuntimeHookItemConfig ) { item .ID = "" }},
422+ {name : "bad point" , edit : func (item * config.RuntimeHookItemConfig ) { item .Point = "session_start" }},
423+ {name : "bad scope" , edit : func (item * config.RuntimeHookItemConfig ) { item .Scope = "user" }},
424+ {name : "bad kind" , edit : func (item * config.RuntimeHookItemConfig ) { item .Kind = "command" }},
425+ {name : "bad mode" , edit : func (item * config.RuntimeHookItemConfig ) { item .Mode = "async" }},
426+ {name : "bad timeout" , edit : func (item * config.RuntimeHookItemConfig ) { item .TimeoutSec = 0 }},
427+ {name : "bad policy" , edit : func (item * config.RuntimeHookItemConfig ) { item .FailurePolicy = "deny" }},
428+ {name : "bad handler" , edit : func (item * config.RuntimeHookItemConfig ) { item .Handler = "unknown" }},
429+ {
430+ name : "warn_on_tool_call missing target" ,
431+ edit : func (item * config.RuntimeHookItemConfig ) {
432+ item .Handler = "warn_on_tool_call"
433+ item .Params = map [string ]any {}
434+ },
435+ },
436+ }
437+
438+ for _ , tc := range cases {
439+ tc := tc
440+ t .Run (tc .name , func (t * testing.T ) {
441+ item := base .Clone ()
442+ tc .edit (& item )
443+ if err := validateRepoHookItem (item ); err == nil {
444+ t .Fatalf ("validateRepoHookItem(%s) expected error" , tc .name )
445+ }
446+ })
447+ }
448+ }
449+
450+ func TestRuntimeHasWarnOnToolCallTargetsBranches (t * testing.T ) {
451+ cases := []struct {
452+ name string
453+ params map [string ]any
454+ want bool
455+ }{
456+ {name : "nil" , params : nil , want : false },
457+ {name : "tool_name" , params : map [string ]any {"tool_name" : "bash" }, want : true },
458+ {name : "tool_name blank" , params : map [string ]any {"tool_name" : " " }, want : false },
459+ {name : "tool_names" , params : map [string ]any {"tool_names" : []any {"bash" }}, want : true },
460+ {name : "tool_names blank" , params : map [string ]any {"tool_names" : []any {" " }}, want : false },
461+ }
462+ for _ , tc := range cases {
463+ tc := tc
464+ t .Run (tc .name , func (t * testing.T ) {
465+ if got := runtimeHasWarnOnToolCallTargets (tc .params ); got != tc .want {
466+ t .Fatalf ("runtimeHasWarnOnToolCallTargets() = %v, want %v" , got , tc .want )
467+ }
468+ })
469+ }
470+ }
471+
472+ func TestResolveRepoHooksPathBranches (t * testing.T ) {
473+ workspace := t .TempDir ()
474+ hooksPath := filepath .Join (workspace , ".neocode" , "hooks.yaml" )
475+
476+ path , found , err := resolveRepoHooksPath (workspace )
477+ if err != nil || found || path != hooksPath {
478+ t .Fatalf ("resolveRepoHooksPath(missing) = (%q,%v,%v), want (%q,false,nil)" , path , found , err , hooksPath )
479+ }
480+
481+ if err := os .MkdirAll (hooksPath , 0o755 ); err != nil {
482+ t .Fatalf ("mkdir hooks dir: %v" , err )
483+ }
484+ _ , _ , err = resolveRepoHooksPath (workspace )
485+ if err == nil || ! strings .Contains (strings .ToLower (err .Error ()), "directory" ) {
486+ t .Fatalf ("resolveRepoHooksPath(directory) error = %v, want directory error" , err )
487+ }
488+ }
489+
490+ func TestNormalizeTrustedWorkspacePathBranches (t * testing.T ) {
491+ if _ , err := normalizeTrustedWorkspacePath ("" ); err == nil {
492+ t .Fatal ("expected empty path error" )
493+ }
494+ if _ , err := normalizeTrustedWorkspacePath ("relative/path" ); err == nil {
495+ t .Fatal ("expected relative path error" )
496+ }
497+ workspace := t .TempDir ()
498+ got , err := normalizeTrustedWorkspacePath (workspace )
499+ if err != nil {
500+ t .Fatalf ("normalizeTrustedWorkspacePath(abs) error = %v" , err )
501+ }
502+ if strings .TrimSpace (got ) == "" {
503+ t .Fatal ("normalized workspace path should not be empty" )
504+ }
505+ }
506+
507+ func TestDynamicRepoHookExecutorCachesWorkspaceResult (t * testing.T ) {
508+ homeDir := t .TempDir ()
509+ t .Setenv ("HOME" , homeDir )
510+ workspace := filepath .Join (homeDir , "workspace" )
511+ if err := os .MkdirAll (filepath .Join (workspace , ".neocode" ), 0o755 ); err != nil {
512+ t .Fatalf ("mkdir hooks dir: %v" , err )
513+ }
514+ if err := os .WriteFile (filepath .Join (workspace , ".neocode" , "hooks.yaml" ), []byte (`
515+ hooks:
516+ items:
517+ - id: repo-cache
518+ point: before_tool_call
519+ scope: repo
520+ kind: builtin
521+ mode: sync
522+ handler: add_context_note
523+ params:
524+ note: repo-note-cache
525+ ` ), 0o644 ); err != nil {
526+ t .Fatalf ("write hooks file: %v" , err )
527+ }
528+ storePath := resolveTrustedWorkspacesPath ()
529+ if err := os .MkdirAll (filepath .Dir (storePath ), 0o755 ); err != nil {
530+ t .Fatalf ("mkdir trust store dir: %v" , err )
531+ }
532+ rawStore , err := json .Marshal (trustedWorkspaceStore {
533+ Version : repoHooksTrustStoreVersion ,
534+ Workspaces : []string {workspace },
535+ })
536+ if err != nil {
537+ t .Fatalf ("marshal trust store: %v" , err )
538+ }
539+ if err := os .WriteFile (storePath , rawStore , 0o644 ); err != nil {
540+ t .Fatalf ("write trust store: %v" , err )
541+ }
542+
543+ cfg := * config .StaticDefaults ()
544+ cfg .Workdir = workspace
545+ service := & Service {events : make (chan RuntimeEvent , 64 )}
546+ exec , err := buildRepoHookExecutor (service , cfg , config .StaticDefaults ().Runtime .Hooks )
547+ if err != nil {
548+ t .Fatalf ("buildRepoHookExecutor() error = %v" , err )
549+ }
550+ dynamic , ok := exec .(* dynamicRepoHookExecutor )
551+ if ! ok {
552+ t .Fatalf ("expected dynamicRepoHookExecutor, got %T" , exec )
553+ }
554+
555+ input := runtimehooks.HookContext {Metadata : map [string ]any {"workdir" : workspace }}
556+ first := dynamic .Run (context .Background (), runtimehooks .HookPointBeforeToolCall , input )
557+ second := dynamic .Run (context .Background (), runtimehooks .HookPointBeforeToolCall , input )
558+ if len (first .Results ) != 1 || len (second .Results ) != 1 {
559+ t .Fatalf ("unexpected cached run outputs: first=%+v second=%+v" , first .Results , second .Results )
560+ }
561+ if first .Results [0 ].Message != "repo-note-cache" || second .Results [0 ].Message != "repo-note-cache" {
562+ t .Fatalf ("unexpected note messages: first=%+v second=%+v" , first .Results , second .Results )
563+ }
564+ dynamic .mu .RLock ()
565+ cacheSize := len (dynamic .cache )
566+ dynamic .mu .RUnlock ()
567+ if cacheSize != 1 {
568+ t .Fatalf ("cache size = %d, want 1" , cacheSize )
569+ }
570+ }
0 commit comments