diff --git a/internal/hooks/order.go b/internal/hooks/order.go new file mode 100644 index 0000000..4e373e7 --- /dev/null +++ b/internal/hooks/order.go @@ -0,0 +1,74 @@ +// This file orders validated hook descriptors into the two execution +// sequences the apply orchestrator uses (PLAN.md Section 12.2): before hooks +// run repository scope first, then groups lexically; after hooks run groups +// lexically first, then repository scope last. Within one phase, names sort +// bytewise. Each sort moves only its own phase, leaving the other phase's +// hooks in stable order, so the two sequences are independent and compose +// into the Section 10.3 order without a rescan. +package hooks + +import ( + "sort" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +// SortBefore orders before hooks into repository scope, then groups +// lexically, then names bytewise. After hooks keep their relative order. +func SortBefore(hooks []deployment.Hook) { + sort.SliceStable(hooks, func(first, second int) bool { + a, b := hooks[first], hooks[second] + if a.Phase != b.Phase { + return a.Phase == deployment.HookBefore + } + if a.Phase == deployment.HookBefore { + return LessBefore(a, b) + } + return false + }) +} + +// SortAfter orders after hooks into groups lexically, then repository scope +// last, then names bytewise. Before hooks keep their relative order. +func SortAfter(hooks []deployment.Hook) { + sort.SliceStable(hooks, func(first, second int) bool { + a, b := hooks[first], hooks[second] + if a.Phase != b.Phase { + return a.Phase == deployment.HookBefore + } + if a.Phase == deployment.HookAfter { + return LessAfter(a, b) + } + return false + }) +} + +// LessBefore reports whether a precedes b among before-phase hooks: repository +// scope first, then groups bytewise, then names bytewise. +func LessBefore(a, b deployment.Hook) bool { + if a.Scope.Group != b.Scope.Group { + if a.Scope.Group == "" { + return true + } + if b.Scope.Group == "" { + return false + } + return a.Scope.Group < b.Scope.Group + } + return a.Name < b.Name +} + +// LessAfter reports whether a precedes b among after-phase hooks: groups +// bytewise first, repository scope last, then names bytewise. +func LessAfter(a, b deployment.Hook) bool { + if a.Scope.Group != b.Scope.Group { + if a.Scope.Group == "" { + return false + } + if b.Scope.Group == "" { + return true + } + return a.Scope.Group < b.Scope.Group + } + return a.Name < b.Name +} diff --git a/internal/hooks/order_test.go b/internal/hooks/order_test.go new file mode 100644 index 0000000..926be07 --- /dev/null +++ b/internal/hooks/order_test.go @@ -0,0 +1,127 @@ +package hooks + +import ( + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestHookOrdering(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"before puts repository first", testOrderBeforeRepositoryFirst}, + {"after puts repository last", testOrderAfterRepositoryLast}, + {"groups sort lexically", testOrderGroupLexical}, + {"names tie-break bytewise", testOrderNameTies}, + {"phases are independent", testOrderPhaseIndependence}, + {"single scope sorts by name", testOrderSingleScope}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testOrderBeforeRepositoryFirst(t *testing.T) { + hooks := []deployment.Hook{ + orderHook("atuin", deployment.HookBefore, "b.sh"), + orderHook("", deployment.HookBefore, "a.sh"), + orderHook("ghostty", deployment.HookBefore, "a.sh"), + } + SortBefore(hooks) + assertOrder(t, hooks, []deployment.Hook{ + orderHook("", deployment.HookBefore, "a.sh"), + orderHook("atuin", deployment.HookBefore, "b.sh"), + orderHook("ghostty", deployment.HookBefore, "a.sh"), + }) +} + +func testOrderAfterRepositoryLast(t *testing.T) { + hooks := []deployment.Hook{ + orderHook("atuin", deployment.HookAfter, "b.sh"), + orderHook("", deployment.HookAfter, "a.sh"), + orderHook("ghostty", deployment.HookAfter, "a.sh"), + } + SortAfter(hooks) + assertOrder(t, hooks, []deployment.Hook{ + orderHook("atuin", deployment.HookAfter, "b.sh"), + orderHook("ghostty", deployment.HookAfter, "a.sh"), + orderHook("", deployment.HookAfter, "a.sh"), + }) +} + +func testOrderGroupLexical(t *testing.T) { + hooks := []deployment.Hook{ + orderHook("zsh", deployment.HookAfter, "x.sh"), + orderHook("atuin", deployment.HookAfter, "x.sh"), + orderHook("ghostty", deployment.HookAfter, "x.sh"), + } + SortAfter(hooks) + assertOrder(t, hooks, []deployment.Hook{ + orderHook("atuin", deployment.HookAfter, "x.sh"), + orderHook("ghostty", deployment.HookAfter, "x.sh"), + orderHook("zsh", deployment.HookAfter, "x.sh"), + }) +} + +func testOrderNameTies(t *testing.T) { + hooks := []deployment.Hook{ + orderHook("atuin", deployment.HookBefore, "install.sh"), + orderHook("atuin", deployment.HookBefore, "finish.sh"), + orderHook("atuin", deployment.HookBefore, "clone.sh"), + } + SortBefore(hooks) + assertOrder(t, hooks, []deployment.Hook{ + orderHook("atuin", deployment.HookBefore, "clone.sh"), + orderHook("atuin", deployment.HookBefore, "finish.sh"), + orderHook("atuin", deployment.HookBefore, "install.sh"), + }) +} + +func testOrderPhaseIndependence(t *testing.T) { + hooks := []deployment.Hook{ + orderHook("", deployment.HookAfter, "z.sh"), + orderHook("", deployment.HookBefore, "a.sh"), + orderHook("atuin", deployment.HookAfter, "a.sh"), + } + SortBefore(hooks) + assertOrder(t, hooks, []deployment.Hook{ + orderHook("", deployment.HookBefore, "a.sh"), + orderHook("", deployment.HookAfter, "z.sh"), + orderHook("atuin", deployment.HookAfter, "a.sh"), + }) +} + +func testOrderSingleScope(t *testing.T) { + hooks := []deployment.Hook{ + orderHook("", deployment.HookAfter, "mid.sh"), + orderHook("", deployment.HookAfter, "early.sh"), + orderHook("", deployment.HookAfter, "late.sh"), + } + SortAfter(hooks) + assertOrder(t, hooks, []deployment.Hook{ + orderHook("", deployment.HookAfter, "early.sh"), + orderHook("", deployment.HookAfter, "late.sh"), + orderHook("", deployment.HookAfter, "mid.sh"), + }) +} + +func orderHook(scope string, phase deployment.HookPhase, name string) deployment.Hook { + return deployment.Hook{ + Scope: deployment.NewScope(scope), Phase: phase, Name: name, + AbsolutePath: "/abs/" + name, RepositoryPath: name, + } +} + +func assertOrder(t *testing.T, got []deployment.Hook, want []deployment.Hook) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("hooks = %d, want %d", len(got), len(want)) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("hook %d = %+v, want %+v", index, got[index], want[index]) + } + } +}