diff --git a/Makefile b/Makefile index 454d09b9..d6d3523d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED BY KRES, PLEASE DO NOT EDIT. # -# Generated on 2026-05-26T16:46:25Z by kres c267185-dirty. +# Generated on 2026-05-28T15:40:14Z by kres 80fec4e. # common variables @@ -214,7 +214,7 @@ lint-gofumpt: ## Runs gofumpt linter. .PHONY: fmt fmt: ## Formats the source code - @docker run --rm -it -v $(PWD):/src -w /src golang:$(GO_VERSION) \ + @docker run --rm -v $(PWD):/src -w /src golang:$(GO_VERSION) \ bash -c "export GOTOOLCHAIN=local; \ export GO111MODULE=on; export GOPROXY=https://proxy.golang.org; \ go install mvdan.cc/gofumpt@$(GOFUMPT_VERSION) && \ @@ -335,7 +335,7 @@ release-notes: $(ARTIFACTS) .PHONY: conformance conformance: @docker pull $(CONFORMANCE_IMAGE) - @docker run --rm -it -v $(PWD):/src -w /src $(CONFORMANCE_IMAGE) enforce + @docker run --rm -v $(PWD):/src -w /src $(CONFORMANCE_IMAGE) enforce .PHONY: renovate-local renovate-local: ## runs renovate locally to check syntax and test configuration diff --git a/cmd/kres/cmd/gen.go b/cmd/kres/cmd/gen.go index b8bea77c..2fcdefee 100644 --- a/cmd/kres/cmd/gen.go +++ b/cmd/kres/cmd/gen.go @@ -19,6 +19,7 @@ import ( "github.com/siderolabs/kres/internal/output/github" "github.com/siderolabs/kres/internal/output/gitignore" "github.com/siderolabs/kres/internal/output/golangci" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/license" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/output/markdownlint" @@ -91,6 +92,7 @@ func runGen() error { output.Wrap(release.NewOutput()), output.Wrap(markdownlint.NewOutput()), output.Wrap(template.NewOutput()), + output.Wrap(lefthook.NewOutput()), ) } diff --git a/internal/output/codecov/codecov.go b/internal/output/codecov/codecov.go index 2ad9cef4..90322a07 100644 --- a/internal/output/codecov/codecov.go +++ b/internal/output/codecov/codecov.go @@ -14,7 +14,7 @@ import ( ) const ( - filename = ".codecov.yml" + configFile = ".codecov.yml" ) //go:embed codecov.yml @@ -60,13 +60,13 @@ func (o *Output) Filenames() []string { return nil } - return []string{filename} + return []string{configFile} } // GenerateFile implements output.FileWriter interface. func (o *Output) GenerateFile(filename string, w io.Writer) error { switch filename { - case filename: + case configFile: return o.config(w) default: panic("unexpected filename: " + filename) diff --git a/internal/output/conform/conform.go b/internal/output/conform/conform.go index 2c0b2e14..17b1b077 100644 --- a/internal/output/conform/conform.go +++ b/internal/output/conform/conform.go @@ -17,7 +17,7 @@ import ( ) const ( - filename = ".conform.yaml" + configFile = ".conform.yaml" ) // Output implements .conform.yaml generation. @@ -96,13 +96,13 @@ func (o *Output) Filenames() []string { return nil } - return []string{filename} + return []string{configFile} } // GenerateFile implements output.FileWriter interface. func (o *Output) GenerateFile(filename string, w io.Writer) error { switch filename { - case filename: + case configFile: return o.config(w) default: panic("unexpected filename: " + filename) diff --git a/internal/output/dockerfile/dockerfile.go b/internal/output/dockerfile/dockerfile.go index a0f37d75..5352158c 100644 --- a/internal/output/dockerfile/dockerfile.go +++ b/internal/output/dockerfile/dockerfile.go @@ -17,8 +17,8 @@ import ( ) const ( - filename = "Dockerfile" - syntax = "docker/dockerfile-upstream:" + config.DockerfileFrontendImageVersion + configFile = "Dockerfile" + syntax = "docker/dockerfile-upstream:" + config.DockerfileFrontendImageVersion ) // Output implements Dockerfile and .dockerignore generation. @@ -55,13 +55,13 @@ func (o *Output) Filenames() []string { return nil } - return []string{filename} + return []string{configFile} } // GenerateFile implements output.FileWriter interface. func (o *Output) GenerateFile(filename string, w io.Writer) error { switch filename { - case filename: + case configFile: return o.dockerfile(w) default: panic("unexpected filename: " + filename) diff --git a/internal/output/dockerignore/dockerignore.go b/internal/output/dockerignore/dockerignore.go index 8ac6a747..ef0bb34b 100644 --- a/internal/output/dockerignore/dockerignore.go +++ b/internal/output/dockerignore/dockerignore.go @@ -13,7 +13,7 @@ import ( ) const ( - filename = ".dockerignore" + configFile = ".dockerignore" ) // Output implements .dockerignore generation. @@ -39,7 +39,7 @@ func (o *Output) Compile(compiler Compiler) error { // Filenames implements output.FileWriter interface. func (o *Output) Filenames() []string { - return []string{filename} + return []string{configFile} } // AllowLocalPath adds path to the list of paths to be copied into the context. @@ -52,7 +52,7 @@ func (o *Output) AllowLocalPath(paths ...string) *Output { // GenerateFile implements output.FileWriter interface. func (o *Output) GenerateFile(filename string, w io.Writer) error { switch filename { - case filename: + case configFile: return o.dockerignore(w) default: panic("unexpected filename: " + filename) diff --git a/internal/output/gitignore/gitignore.go b/internal/output/gitignore/gitignore.go index 134a0216..9d210bd7 100644 --- a/internal/output/gitignore/gitignore.go +++ b/internal/output/gitignore/gitignore.go @@ -13,7 +13,7 @@ import ( ) const ( - filename = ".gitignore" + configFile = ".gitignore" ) // Output implements .gitignore generation. @@ -44,13 +44,13 @@ func (o *Output) IgnorePath(paths ...string) { // Filenames implements output.FileWriter interface. func (o *Output) Filenames() []string { - return []string{filename} + return []string{configFile} } // GenerateFile implements output.FileWriter interface. func (o *Output) GenerateFile(filename string, w io.Writer) error { switch filename { - case filename: + case configFile: return o.gitignore(w) default: panic("unexpected filename: " + filename) diff --git a/internal/output/lefthook/command.go b/internal/output/lefthook/command.go new file mode 100644 index 00000000..853b6780 --- /dev/null +++ b/internal/output/lefthook/command.go @@ -0,0 +1,81 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lefthook + +// Command represents a single named command under a hook's commands: map. +type Command struct { //nolint:govet + Run string `yaml:"run"` + Tags []string `yaml:"tags,omitempty"` + Glob string `yaml:"glob,omitempty"` + Files string `yaml:"files,omitempty"` + Skip []string `yaml:"skip,omitempty"` + Only []string `yaml:"only,omitempty"` + Interactive bool `yaml:"interactive,omitempty"` + StageFixed bool `yaml:"stage_fixed,omitempty"` + Priority int `yaml:"priority,omitempty"` +} + +// WithRun sets the shell command lefthook executes for this command. +func (c *Command) WithRun(run string) *Command { + c.Run = run + + return c +} + +// WithTags attaches tags used for selective hook execution (lefthook --tags ...). +func (c *Command) WithTags(tags ...string) *Command { + c.Tags = tags + + return c +} + +// WithGlob restricts the command to files matching the given glob. +func (c *Command) WithGlob(glob string) *Command { + c.Glob = glob + + return c +} + +// WithFiles overrides the default file source (e.g. "git diff --name-only ..."). +func (c *Command) WithFiles(files string) *Command { + c.Files = files + + return c +} + +// WithSkip lists git states or refs where the command should be skipped (e.g. "merge", "rebase"). +func (c *Command) WithSkip(skip ...string) *Command { + c.Skip = skip + + return c +} + +// WithOnly is the inverse of WithSkip: command runs only in the listed states. +func (c *Command) WithOnly(only ...string) *Command { + c.Only = only + + return c +} + +// WithInteractive marks the command as needing a TTY (stdin/stdout passthrough). +func (c *Command) WithInteractive() *Command { + c.Interactive = true + + return c +} + +// WithStageFixed re-stages files modified by the command (useful for formatters). +func (c *Command) WithStageFixed() *Command { + c.StageFixed = true + + return c +} + +// WithPriority sets the command's run order within its hook (lower runs first). +func (c *Command) WithPriority(priority int) *Command { + c.Priority = priority + + return c +} diff --git a/internal/output/lefthook/config.go b/internal/output/lefthook/config.go new file mode 100644 index 00000000..c12e7ed3 --- /dev/null +++ b/internal/output/lefthook/config.go @@ -0,0 +1,78 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lefthook + +// Config is a declarative description of lefthook.yml contributions, suitable +// for unmarshalling from a kres.yaml block. +// +// Hooks reuse the very same builder types (Hook, Command, Job, Group) that +// serialize lefthook.yml, so the config is authored in lefthook's native schema +// and there is a single definitive set of types for both serializing the output +// and deserializing the config. +type Config struct { + Hooks map[string]*Hook `yaml:"hooks"` + Enabled bool `yaml:"enabled"` +} + +// Compile merges the configured hooks into the output. It is a no-op when the +// config is disabled. +func (c Config) Compile(output *Output) error { + if !c.Enabled { + return nil + } + + for name, cfgHook := range c.Hooks { + if cfgHook == nil { + continue + } + + hook := output.Hook(name) + + if cfgHook.Parallel != nil { + hook.WithParallel(*cfgHook.Parallel) + } + + if cfgHook.Piped { + hook.WithPiped(true) + } + + for cmdName, cmd := range cfgHook.Commands { + if cmd == nil { + continue + } + + *hook.Command(cmdName) = *cmd + } + + for _, job := range cfgHook.Jobs { + if job == nil { + continue + } + + // A named job wrapping a group is merged into the hook's group of + // the same name, so a config can extend the shared fix/lint groups + // emitted by the standard blocks instead of forking a new one. + if job.Group != nil && job.Name != "" { + group := hook.Group(job.Name) + + if job.Group.Parallel != nil { + group.WithParallel(*job.Group.Parallel) + } + + if job.Group.Piped { + group.WithPiped(true) + } + + group.Jobs = append(group.Jobs, job.Group.Jobs...) + + continue + } + + hook.Jobs = append(hook.Jobs, job) + } + } + + return nil +} diff --git a/internal/output/lefthook/group.go b/internal/output/lefthook/group.go new file mode 100644 index 00000000..03888ab6 --- /dev/null +++ b/internal/output/lefthook/group.go @@ -0,0 +1,39 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lefthook + +// Group is a container for nested Jobs with its own parallel/piped semantics. +// Used to express "run these in parallel, then those sequentially" without +// touching the hook-level execution model. +type Group struct { //nolint:govet + // Parallel is a pointer so an explicit `parallel: false` can be emitted + // (the bool zero value would otherwise be suppressed by omitempty). + Parallel *bool `yaml:"parallel,omitempty"` + Piped bool `yaml:"piped,omitempty"` + Jobs []*Job `yaml:"jobs,omitempty"` +} + +// WithParallel toggles parallel execution of this group's jobs. +func (g *Group) WithParallel(parallel bool) *Group { + g.Parallel = ¶llel + + return g +} + +// WithPiped enables piped (sequential, fail-fast, stdout-chained) execution. +func (g *Group) WithPiped(piped bool) *Group { + g.Piped = piped + + return g +} + +// Job appends a new job to the group and returns it for further configuration. +func (g *Group) Job() *Job { + j := &Job{} + + g.Jobs = append(g.Jobs, j) + + return j +} diff --git a/internal/output/lefthook/hook.go b/internal/output/lefthook/hook.go new file mode 100644 index 00000000..bbf62aa1 --- /dev/null +++ b/internal/output/lefthook/hook.go @@ -0,0 +1,95 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lefthook + +// Pre-commit stage keys: stable names for Hook.Group so multiple project blocks +// can append jobs to the same ordered group. Each value is both the lookup key +// and the group's emitted `name:`. +const ( + // PreCommitFixStage is stage 1: mutating formatters/generators, re-staged via stage_fixed. + PreCommitFixStage = "fix" + // PreCommitLintStage is stage 2: verification, runs after the fix stage. + PreCommitLintStage = "lint" + + // HookGroupPreCommit is the lefthook "pre-commit" hook name. + HookGroupPreCommit = "pre-commit" + // HookGroupCommitMsg is the lefthook "commit-msg" hook name. + HookGroupCommitMsg = "commit-msg" +) + +// Hook represents the configuration for a single git hook (e.g. pre-commit) +// inside lefthook.yml. A hook can declare either Commands (a named map) or +// Jobs (an ordered list with nested groups) — see lefthook docs for the +// trade-off; mixing both in a single hook is generally not recommended. +type Hook struct { //nolint:govet + // Parallel is a pointer so an explicit `parallel: false` can be emitted + // (with a plain bool + omitempty the false zero-value would be suppressed). + // Only meaningful for Commands-style hooks; Jobs-style hooks control + // parallelism per-Group. + Parallel *bool `yaml:"parallel,omitempty"` + Piped bool `yaml:"piped,omitempty"` + Commands map[string]*Command `yaml:"commands,omitempty"` + Jobs []*Job `yaml:"jobs,omitempty"` +} + +// WithParallel sets the hook-level parallel flag (Commands-style hooks only). +func (h *Hook) WithParallel(parallel bool) *Hook { + h.Parallel = ¶llel + + return h +} + +// WithPiped enables piped (sequential, fail-fast) execution of this hook's commands. +func (h *Hook) WithPiped(piped bool) *Hook { + h.Piped = piped + + return h +} + +// Command returns the named command on this hook, creating it on first access. +func (h *Hook) Command(name string) *Command { + if c, ok := h.Commands[name]; ok { + return c + } + + if h.Commands == nil { + h.Commands = map[string]*Command{} + } + + c := &Command{} + h.Commands[name] = c + + return c +} + +// Job appends a new job to this hook's Jobs list and returns it for further +// configuration. Each top-level entry runs in declaration order; use AsGroup +// on the returned job to nest a parallel/sequential group of inner jobs. +func (h *Hook) Job() *Job { + j := &Job{} + + h.Jobs = append(h.Jobs, j) + + return j +} + +// Group returns the named group on this hook, wrapped in a Job entry, creating +// it on first access (mirroring makefile.Output.Target). The name is emitted as +// the wrapping job's `name:` and doubles as the lookup key, so different project +// blocks can contribute jobs to the same group regardless of compile order; +// group emission order follows first-creation order. +func (h *Hook) Group(name string) *Group { + for _, j := range h.Jobs { + if j.Group != nil && j.Name == name { + return j.Group + } + } + + g := &Group{} + + h.Jobs = append(h.Jobs, &Job{Name: name, Group: g}) + + return g +} diff --git a/internal/output/lefthook/job.go b/internal/output/lefthook/job.go new file mode 100644 index 00000000..3666d36c --- /dev/null +++ b/internal/output/lefthook/job.go @@ -0,0 +1,132 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lefthook + +// Job is an entry under a hook's or group's jobs: list. Each Job either runs +// a command directly (via Run or Script) or wraps a nested Group. +type Job struct { //nolint:govet + Name string `yaml:"name,omitempty"` + Run string `yaml:"run,omitempty"` + Script string `yaml:"script,omitempty"` + Tags []string `yaml:"tags,omitempty"` + Glob []string `yaml:"glob,omitempty"` + Exclude []string `yaml:"exclude,omitempty"` + Root string `yaml:"root,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + Skip []string `yaml:"skip,omitempty"` + Only []string `yaml:"only,omitempty"` + Interactive bool `yaml:"interactive,omitempty"` + StageFixed bool `yaml:"stage_fixed,omitempty"` + Priority int `yaml:"priority,omitempty"` + Group *Group `yaml:"group,omitempty"` +} + +// WithName sets the job's display name. +func (j *Job) WithName(name string) *Job { + j.Name = name + + return j +} + +// WithRun sets the shell command to execute for this job. +func (j *Job) WithRun(run string) *Job { + j.Run = run + + return j +} + +// WithScript sets a script file to execute (relative to lefthook source_dir). +func (j *Job) WithScript(script string) *Job { + j.Script = script + + return j +} + +// WithTags attaches selectable tags (lefthook --tags ...). +func (j *Job) WithTags(tags ...string) *Job { + j.Tags = tags + + return j +} + +// WithGlob restricts the job to files matching the given glob(s). +func (j *Job) WithGlob(glob ...string) *Job { + j.Glob = glob + + return j +} + +// WithExclude is the inverse of WithGlob: skip files matching these patterns. +func (j *Job) WithExclude(exclude ...string) *Job { + j.Exclude = exclude + + return j +} + +// WithRoot changes the working directory for the job. +func (j *Job) WithRoot(root string) *Job { + j.Root = root + + return j +} + +// WithEnv sets an environment variable on the job; safe to call multiple times. +func (j *Job) WithEnv(name, value string) *Job { + if j.Env == nil { + j.Env = map[string]string{} + } + + j.Env[name] = value + + return j +} + +// WithSkip lists git states or refs where the job should be skipped (e.g. "merge", "rebase"). +func (j *Job) WithSkip(skip ...string) *Job { + j.Skip = skip + + return j +} + +// WithOnly is the inverse of WithSkip: job runs only in the listed states. +func (j *Job) WithOnly(only ...string) *Job { + j.Only = only + + return j +} + +// WithInteractive marks the job as needing a TTY (stdin/stdout passthrough). +func (j *Job) WithInteractive() *Job { + j.Interactive = true + + return j +} + +// WithStageFixed re-stages files modified by the job (useful for formatters). +func (j *Job) WithStageFixed() *Job { + j.StageFixed = true + + return j +} + +// WithPriority sets the job's run order within its container (lower runs first). +func (j *Job) WithPriority(priority int) *Job { + j.Priority = priority + + return j +} + +// AsGroup turns this job into a container for a nested Group, creating the +// group on first call and returning it for chained configuration. The job's +// Run/Script fields are typically left empty when AsGroup is used. +func (j *Job) AsGroup() *Group { + if j.Group != nil { + return j.Group + } + + j.Group = &Group{} + + return j.Group +} diff --git a/internal/output/lefthook/lefthook.go b/internal/output/lefthook/lefthook.go new file mode 100644 index 00000000..90ba2610 --- /dev/null +++ b/internal/output/lefthook/lefthook.go @@ -0,0 +1,103 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package lefthook implements output to lefthook.yml. +package lefthook + +import ( + "fmt" + "io" + + "go.yaml.in/yaml/v4" + + "github.com/siderolabs/kres/internal/output" +) + +const configFile = "lefthook.yml" + +// Output implements lefthook.yml generation. +type Output struct { + output.FileAdapter + + hooks map[string]*Hook + + enabled bool +} + +// NewOutput creates new lefthook.yml output. +func NewOutput() *Output { + o := &Output{ + hooks: map[string]*Hook{}, + } + + o.FileWriter = o + + return o +} + +// Compile implements [output.TypedWriter] interface. +func (o *Output) Compile(compiler Compiler) error { + return compiler.CompileLefthook(o) +} + +// Enable should be called to enable config generation. +func (o *Output) Enable() { + o.enabled = true +} + +// Hook returns the configuration for the named git hook (e.g. "pre-commit", +// "commit-msg"), creating it on first access. New hooks are blank — set the +// execution model via WithParallel/WithPiped, and populate either Commands +// or Jobs depending on which lefthook style you want. +func (o *Output) Hook(name string) *Hook { + if h, ok := o.hooks[name]; ok { + return h + } + + h := &Hook{} + o.hooks[name] = h + + return h +} + +// Filenames implements output.FileWriter interface. +func (o *Output) Filenames() []string { + if !o.enabled { + return nil + } + + return []string{configFile} +} + +// GenerateFile implements output.FileWriter interface. +func (o *Output) GenerateFile(filename string, w io.Writer) error { + switch filename { + case configFile: + return o.config(w) + default: + panic("unexpected filename: " + filename) + } +} + +func (o *Output) config(w io.Writer) error { + if _, err := w.Write([]byte(output.Preamble("# "))); err != nil { + return err + } + + encoder := yaml.NewEncoder(w) + defer encoder.Close() // nolint:errcheck + + encoder.SetIndent(2) + + if err := encoder.Encode(o.hooks); err != nil { + return fmt.Errorf("failed to encode lefthook config: %w", err) + } + + return nil +} + +// Compiler is implemented by project blocks which support lefthook.yml generation. +type Compiler interface { + CompileLefthook(*Output) error +} diff --git a/internal/output/lefthook/lefthook_test.go b/internal/output/lefthook/lefthook_test.go new file mode 100644 index 00000000..012499fe --- /dev/null +++ b/internal/output/lefthook/lefthook_test.go @@ -0,0 +1,296 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lefthook_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" + + "github.com/siderolabs/kres/internal/output/lefthook" +) + +func TestDisabledOutputProducesNoFiles(t *testing.T) { + o := lefthook.NewOutput() + + assert.Nil(t, o.Filenames()) +} + +func TestCommandsRoundTrip(t *testing.T) { + o := lefthook.NewOutput() + o.Enable() + + preCommit := o.Hook("pre-commit") + preCommit.Command("fmt").WithRun("make fmt") + preCommit.Command("lint").WithRun("make lint") + + o.Hook("commit-msg").Command("conformance").WithRun("make conformance") + + require.Equal(t, []string{"lefthook.yml"}, o.Filenames()) + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + var decoded map[string]struct { + Commands map[string]struct { + Run string `yaml:"run"` + } `yaml:"commands"` + } + + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &decoded)) + + require.Contains(t, decoded, "pre-commit") + require.Contains(t, decoded, "commit-msg") + + assert.Equal(t, "make fmt", decoded["pre-commit"].Commands["fmt"].Run) + assert.Equal(t, "make lint", decoded["pre-commit"].Commands["lint"].Run) + assert.Equal(t, "make conformance", decoded["commit-msg"].Commands["conformance"].Run) +} + +func TestParallelDefaultIsOmitted(t *testing.T) { + o := lefthook.NewOutput() + o.Enable() + + o.Hook("pre-commit").Command("fmt").WithRun("make fmt") + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + assert.NotContains(t, buf.String(), "parallel:") +} + +func TestParallelFalseIsEmitted(t *testing.T) { + o := lefthook.NewOutput() + o.Enable() + + o.Hook("commit-msg"). + WithParallel(false). + Command("conformance").WithRun("make conformance") + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + // *bool with explicit false must emit; bool+omitempty would have swallowed it. + assert.Contains(t, buf.String(), "parallel: false") +} + +func TestJobsAndGroups(t *testing.T) { + o := lefthook.NewOutput() + o.Enable() + + preCommit := o.Hook("pre-commit") + + parallelGroup := preCommit.Job().AsGroup().WithParallel(true) + parallelGroup.Job().WithRun("make fmt") + parallelGroup.Job().WithRun("make generate") + + sequentialGroup := preCommit.Job().AsGroup().WithParallel(false) + sequentialGroup.Job().WithRun("make lint") + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + var decoded map[string]struct { + Jobs []struct { + Group *struct { //nolint:govet + Parallel *bool `yaml:"parallel"` + Jobs []struct { + Run string `yaml:"run"` + } `yaml:"jobs"` + } `yaml:"group"` + } `yaml:"jobs"` + } + + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &decoded)) + + hook := decoded["pre-commit"] + require.Len(t, hook.Jobs, 2) + + require.NotNil(t, hook.Jobs[0].Group) + require.NotNil(t, hook.Jobs[0].Group.Parallel) + assert.True(t, *hook.Jobs[0].Group.Parallel) + require.Len(t, hook.Jobs[0].Group.Jobs, 2) + assert.Equal(t, "make fmt", hook.Jobs[0].Group.Jobs[0].Run) + assert.Equal(t, "make generate", hook.Jobs[0].Group.Jobs[1].Run) + + require.NotNil(t, hook.Jobs[1].Group) + require.NotNil(t, hook.Jobs[1].Group.Parallel) + assert.False(t, *hook.Jobs[1].Group.Parallel) + require.Len(t, hook.Jobs[1].Group.Jobs, 1) + assert.Equal(t, "make lint", hook.Jobs[1].Group.Jobs[0].Run) +} + +func TestNamedGroupsAreShared(t *testing.T) { + o := lefthook.NewOutput() + o.Enable() + + preCommit := o.Hook("pre-commit") + + // Two blocks append to the same stage-1 group via the same key. + preCommit.Group(lefthook.PreCommitFixStage).WithParallel(false).Job().WithName("lint-fmt").WithRun("make lint-fmt").WithStageFixed() + preCommit.Group(lefthook.PreCommitFixStage).Job().WithName("generate").WithRun("make generate").WithStageFixed() + + // A distinct key creates a second, ordered-after group. + preCommit.Group(lefthook.PreCommitLintStage).WithParallel(false).Job().WithRun("make lint") + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + var decoded map[string]struct { + Jobs []struct { //nolint:govet + Name string `yaml:"name"` + Group *struct { //nolint:govet + Parallel *bool `yaml:"parallel"` + Jobs []struct { + Name string `yaml:"name"` + Run string `yaml:"run"` + StageFixed bool `yaml:"stage_fixed"` + } `yaml:"jobs"` + } `yaml:"group"` + } `yaml:"jobs"` + } + + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &decoded)) + + hook := decoded["pre-commit"] + require.Len(t, hook.Jobs, 2, "same key must reuse one group, distinct key adds a second") + + // The group key is emitted as the wrapping job's name. + assert.Equal(t, lefthook.PreCommitFixStage, hook.Jobs[0].Name) + require.NotNil(t, hook.Jobs[0].Group) + require.Len(t, hook.Jobs[0].Group.Jobs, 2) + assert.Equal(t, "lint-fmt", hook.Jobs[0].Group.Jobs[0].Name) + assert.Equal(t, "make lint-fmt", hook.Jobs[0].Group.Jobs[0].Run) + assert.True(t, hook.Jobs[0].Group.Jobs[0].StageFixed) + assert.Equal(t, "generate", hook.Jobs[0].Group.Jobs[1].Name) + assert.Equal(t, "make generate", hook.Jobs[0].Group.Jobs[1].Run) + + assert.Equal(t, lefthook.PreCommitLintStage, hook.Jobs[1].Name) + require.NotNil(t, hook.Jobs[1].Group) + require.Len(t, hook.Jobs[1].Group.Jobs, 1) + assert.Equal(t, "make lint", hook.Jobs[1].Group.Jobs[0].Run) +} + +func TestJobBuilders(t *testing.T) { + o := lefthook.NewOutput() + o.Enable() + + o.Hook("pre-commit"). + Job(). + WithName("docs-check"). + WithRun("echo $E1"). + WithEnv("E1", "hello"). + WithEnv("E2", "world"). + WithGlob("*.md"). + WithExclude("README.md"). + WithRoot("subdir/"). + WithTags("docs"). + WithSkip("merge"). + WithOnly("ref: main"). + WithInteractive(). + WithStageFixed(). + WithPriority(5) + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + var decoded map[string]struct { + Jobs []struct { //nolint:govet + Name string `yaml:"name"` + Run string `yaml:"run"` + Env map[string]string `yaml:"env"` + Glob []string `yaml:"glob"` + Exclude []string `yaml:"exclude"` + Root string `yaml:"root"` + Tags []string `yaml:"tags"` + Skip []string `yaml:"skip"` + Only []string `yaml:"only"` + Interactive bool `yaml:"interactive"` + StageFixed bool `yaml:"stage_fixed"` + Priority int `yaml:"priority"` + } `yaml:"jobs"` + } + + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &decoded)) + + require.Len(t, decoded["pre-commit"].Jobs, 1) + + job := decoded["pre-commit"].Jobs[0] + assert.Equal(t, "docs-check", job.Name) + assert.Equal(t, "echo $E1", job.Run) + assert.Equal(t, map[string]string{"E1": "hello", "E2": "world"}, job.Env) + assert.Equal(t, []string{"*.md"}, job.Glob) + assert.Equal(t, []string{"README.md"}, job.Exclude) + assert.Equal(t, "subdir/", job.Root) + assert.Equal(t, []string{"docs"}, job.Tags) + assert.Equal(t, []string{"merge"}, job.Skip) + assert.Equal(t, []string{"ref: main"}, job.Only) + assert.True(t, job.Interactive) + assert.True(t, job.StageFixed) + assert.Equal(t, 5, job.Priority) +} + +func TestCommandBuilders(t *testing.T) { + o := lefthook.NewOutput() + o.Enable() + + o.Hook("pre-commit"). + WithParallel(true). + Command("go-fmt"). + WithRun("gofmt -l -w {staged_files}"). + WithTags("format", "go"). + WithGlob("*.go"). + WithFiles("git diff --name-only"). + WithSkip("merge", "rebase"). + WithOnly("ref: main"). + WithInteractive(). + WithStageFixed(). + WithPriority(10) + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + var decoded map[string]struct { //nolint:govet + Parallel *bool `yaml:"parallel"` + Commands map[string]struct { //nolint:govet + Run string `yaml:"run"` + Tags []string `yaml:"tags"` + Glob string `yaml:"glob"` + Files string `yaml:"files"` + Skip []string `yaml:"skip"` + Only []string `yaml:"only"` + Interactive bool `yaml:"interactive"` + StageFixed bool `yaml:"stage_fixed"` + Priority int `yaml:"priority"` + } `yaml:"commands"` + } + + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &decoded)) + + hook := decoded["pre-commit"] + require.NotNil(t, hook.Parallel) + assert.True(t, *hook.Parallel) + + cmd := hook.Commands["go-fmt"] + assert.Equal(t, "gofmt -l -w {staged_files}", cmd.Run) + assert.Equal(t, []string{"format", "go"}, cmd.Tags) + assert.Equal(t, "*.go", cmd.Glob) + assert.Equal(t, "git diff --name-only", cmd.Files) + assert.Equal(t, []string{"merge", "rebase"}, cmd.Skip) + assert.Equal(t, []string{"ref: main"}, cmd.Only) + assert.True(t, cmd.Interactive) + assert.True(t, cmd.StageFixed) + assert.Equal(t, 10, cmd.Priority) +} diff --git a/internal/output/markdownlint/markdownlint.go b/internal/output/markdownlint/markdownlint.go index 6efce21e..bf2fd79b 100644 --- a/internal/output/markdownlint/markdownlint.go +++ b/internal/output/markdownlint/markdownlint.go @@ -13,7 +13,7 @@ import ( ) const ( - filename = ".markdownlint.json" + configFile = ".markdownlint.json" ) // Output implements .markdownlint.json generation. @@ -60,13 +60,13 @@ func (o *Output) Filenames() []string { return nil } - return []string{filename} + return []string{configFile} } // GenerateFile implements output.FileWriter interface. func (o *Output) GenerateFile(filename string, w io.Writer) error { switch filename { - case filename: + case configFile: return o.config(w) default: panic("unexpected filename: " + filename) diff --git a/internal/output/sops/sops.go b/internal/output/sops/sops.go index f1c8649a..32fec36d 100644 --- a/internal/output/sops/sops.go +++ b/internal/output/sops/sops.go @@ -21,7 +21,7 @@ type Output struct { } const ( - filename = ".sops.yaml" + configFile = ".sops.yaml" ) // NewOutput initializes Output. @@ -54,13 +54,13 @@ func (o *Output) Filenames() []string { return nil } - return []string{filename} + return []string{configFile} } // GenerateFile implements output.FileWriter interface. func (o *Output) GenerateFile(filename string, w io.Writer) error { switch filename { - case filename: + case configFile: return o.sops(w) default: panic("unexpected filename: " + filename) diff --git a/internal/project/common/conformance.go b/internal/project/common/conformance.go index ad12852e..0f262d38 100644 --- a/internal/project/common/conformance.go +++ b/internal/project/common/conformance.go @@ -6,6 +6,7 @@ package common import ( "github.com/siderolabs/kres/internal/dag" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/meta" ) @@ -39,8 +40,16 @@ func (conformance *Conformance) CompileMakefile(output *makefile.Output) error { output.Target(conformance.Name()). Script("@docker pull $(" + conformanceImageEnvVarName + ")"). - Script("@docker run --rm -it -v $(PWD):/src -w /src $(" + conformanceImageEnvVarName + ") enforce"). + Script("@docker run --rm -v $(PWD):/src -w /src $(" + conformanceImageEnvVarName + ") enforce"). Phony() return nil } + +// CompileLefthook implements lefthook.Compiler. +func (conformance *Conformance) CompileLefthook(output *lefthook.Output) error { + output.Hook(lefthook.HookGroupCommitMsg).WithParallel(false). + Command("conformance").WithRun("make conformance") + + return nil +} diff --git a/internal/project/common/conformance_test.go b/internal/project/common/conformance_test.go index 56601447..78835cea 100644 --- a/internal/project/common/conformance_test.go +++ b/internal/project/common/conformance_test.go @@ -9,10 +9,12 @@ import ( "github.com/stretchr/testify/assert" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/common" ) func TestConformanceInterfaces(t *testing.T) { assert.Implements(t, (*makefile.Compiler)(nil), new(common.Conformance)) + assert.Implements(t, (*lefthook.Compiler)(nil), new(common.Conformance)) } diff --git a/internal/project/common/lint.go b/internal/project/common/lint.go index 59bda240..676bfd9f 100644 --- a/internal/project/common/lint.go +++ b/internal/project/common/lint.go @@ -11,6 +11,7 @@ import ( "github.com/siderolabs/kres/internal/dag" "github.com/siderolabs/kres/internal/output/ghworkflow" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/meta" ) @@ -82,3 +83,20 @@ func (lint *Lint) CompileMakefile(output *makefile.Output) error { return nil } + +// CompileLefthook implements lefthook.Compiler. +func (lint *Lint) CompileLefthook(output *lefthook.Output) error { + hookPreCommit := output.Hook(lefthook.HookGroupPreCommit) + + // stage 1: mutating formatters (shared group — generate/fmt blocks append here too). + hookPreCommit.Group(lefthook.PreCommitFixStage). + WithParallel(false). + Job().WithName("lint-fmt").WithRun("make lint-fmt").WithStageFixed() + + // stage 2: lint runs after, against the formatted tree. + hookPreCommit.Group(lefthook.PreCommitLintStage). + WithParallel(false). + Job().WithName("lint").WithRun("make lint") + + return nil +} diff --git a/internal/project/common/lint_test.go b/internal/project/common/lint_test.go index a8fc14ba..e9b83079 100644 --- a/internal/project/common/lint_test.go +++ b/internal/project/common/lint_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/siderolabs/kres/internal/output/ghworkflow" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/common" ) @@ -17,4 +18,5 @@ import ( func TestLintInterfaces(t *testing.T) { assert.Implements(t, (*makefile.Compiler)(nil), new(common.Lint)) assert.Implements(t, (*ghworkflow.Compiler)(nil), new(common.Lint)) + assert.Implements(t, (*lefthook.Compiler)(nil), new(common.Lint)) } diff --git a/internal/project/common/repository.go b/internal/project/common/repository.go index 1bda1246..6d002f9d 100644 --- a/internal/project/common/repository.go +++ b/internal/project/common/repository.go @@ -20,6 +20,7 @@ import ( "github.com/siderolabs/kres/internal/output" "github.com/siderolabs/kres/internal/output/conform" "github.com/siderolabs/kres/internal/output/conform/licensepolicy" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/license" "github.com/siderolabs/kres/internal/project/meta" ) @@ -65,6 +66,8 @@ type Repository struct { //nolint:govet BotName string `yaml:"botName"` SkipStaleWorkflow bool `yaml:"skipStaleWorkflow"` + + EnableLefthook bool `yaml:"enableLefthook"` } // LicenseConfig configures the license. @@ -114,6 +117,8 @@ func NewRepository(meta *meta.Options) *Repository { }, BotName: "talos-bot", + + EnableLefthook: true, } } @@ -125,6 +130,17 @@ func (r *Repository) AfterLoad() error { return nil } +// CompileLefthook implements lefthook.Compiler. +func (r *Repository) CompileLefthook(o *lefthook.Output) error { + if !r.EnableLefthook { + return nil + } + + o.Enable() + + return nil +} + // CompileConform implements conform.Compiler. func (r *Repository) CompileConform(o *conform.Output) error { if !r.EnableConform { @@ -231,7 +247,11 @@ func (r *Repository) enableBranchProtection(client *github.Client) error { enforceContexts := r.buildEnforceContexts(nil) if r.DryRun { - fmt.Printf("dry-run: branch protection for %s would require %d contexts:\n", targetBranch, len(enforceContexts)) + fmt.Printf( + "dry-run: branch protection for %s would require %d contexts:\n", + targetBranch, + len(enforceContexts), + ) for _, c := range enforceContexts { fmt.Printf(" - %s\n", c) @@ -290,17 +310,27 @@ func (r *Repository) enableLabels(client *github.Client) error { for _, name := range names { desc := labels[name] - existing, resp, err := client.Issues.GetLabel(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, name) + existing, resp, err := client.Issues.GetLabel( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + name, + ) if err != nil { if resp == nil || resp.StatusCode != http.StatusNotFound { return fmt.Errorf("failed to check label %q: %w", name, err) } - if _, _, err := client.Issues.CreateLabel(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, &github.Label{ - Name: new(name), - Color: new(autoLabelColor), - Description: new(desc), - }); err != nil { + if _, _, err := client.Issues.CreateLabel( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + &github.Label{ + Name: new(name), + Color: new(autoLabelColor), + Description: new(desc), + }, + ); err != nil { return fmt.Errorf("failed to create label %q: %w", name, err) } @@ -318,7 +348,13 @@ func (r *Repository) enableLabels(client *github.Client) error { } if patch.Description != nil || patch.Color != nil { - if _, _, err := client.Issues.EditLabel(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, name, patch); err != nil { + if _, _, err := client.Issues.EditLabel( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + name, + patch, + ); err != nil { return fmt.Errorf("failed to update label %q: %w", name, err) } } @@ -336,7 +372,11 @@ func (r *Repository) enableImmutableReleases(client *github.Client) error { return nil } - status, _, err := client.Repositories.AreImmutableReleasesEnabled(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository) + status, _, err := client.Repositories.AreImmutableReleasesEnabled( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + ) if err != nil { return fmt.Errorf("failed to check immutable releases status: %w", err) } @@ -346,7 +386,11 @@ func (r *Repository) enableImmutableReleases(client *github.Client) error { return nil } - if _, err = client.Repositories.EnableImmutableReleases(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository); err != nil { + if _, err = client.Repositories.EnableImmutableReleases( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + ); err != nil { return fmt.Errorf("failed to enable immutable releases: %w", err) } @@ -397,8 +441,17 @@ func (r *Repository) buildEnforceContexts(base []string) []string { } //nolint:gocyclo,cyclop -func (r *Repository) applyBranchProtection(client *github.Client, branch string, enforceContexts []string) error { - branchProtection, resp, err := client.Repositories.GetBranchProtection(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, branch) +func (r *Repository) applyBranchProtection( + client *github.Client, + branch string, + enforceContexts []string, +) error { + branchProtection, resp, err := client.Repositories.GetBranchProtection( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + branch, + ) if err != nil { if resp == nil || resp.StatusCode != http.StatusNotFound { return err @@ -428,7 +481,12 @@ func (r *Repository) applyBranchProtection(client *github.Client, branch string, } if branchProtection != nil { - sigProtected, _, sigErr := client.Repositories.GetSignaturesProtectedBranch(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, branch) + sigProtected, _, sigErr := client.Repositories.GetSignaturesProtectedBranch( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + branch, + ) if sigErr != nil { return nil //nolint:nilerr } @@ -454,7 +512,13 @@ func (r *Repository) applyBranchProtection(client *github.Client, branch string, } } - _, updateResp, err := client.Repositories.UpdateBranchProtection(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, branch, &req) + _, updateResp, err := client.Repositories.UpdateBranchProtection( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + branch, + &req, + ) if err != nil { if updateResp != nil && updateResp.StatusCode == http.StatusNotFound { fmt.Printf("branch %s not found, skipping protection\n", branch) @@ -465,7 +529,12 @@ func (r *Repository) applyBranchProtection(client *github.Client, branch string, return err } - if _, _, err = client.Repositories.RequireSignaturesOnProtectedBranch(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, branch); err != nil { + if _, _, err = client.Repositories.RequireSignaturesOnProtectedBranch( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + branch, + ); err != nil { return err } @@ -475,7 +544,12 @@ func (r *Repository) applyBranchProtection(client *github.Client, branch string, } func (r *Repository) enableConform(client *github.Client) error { - hooks, _, err := client.Repositories.ListHooks(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, &github.ListOptions{}) + hooks, _, err := client.Repositories.ListHooks( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + &github.ListOptions{}, + ) if err != nil { return err } @@ -486,18 +560,23 @@ func (r *Repository) enableConform(client *github.Client) error { } } - _, _, err = client.Repositories.CreateHook(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, &github.Hook{ - Active: new(true), - Config: &github.HookConfig{ - URL: &r.ConformWebhookURL, - ContentType: new("json"), - InsecureSSL: new("0"), - }, - Events: []string{ - "push", - "pull_request", + _, _, err = client.Repositories.CreateHook( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + &github.Hook{ + Active: new(true), + Config: &github.HookConfig{ + URL: &r.ConformWebhookURL, + ContentType: new("json"), + InsecureSSL: new("0"), + }, + Events: []string{ + "push", + "pull_request", + }, }, - }) + ) if err != nil { return err } @@ -508,7 +587,12 @@ func (r *Repository) enableConform(client *github.Client) error { } func (r *Repository) inviteBot(client *github.Client) error { - users, _, err := client.Repositories.ListCollaborators(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, &github.ListCollaboratorsOptions{}) + users, _, err := client.Repositories.ListCollaborators( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + &github.ListCollaboratorsOptions{}, + ) if err != nil { return err } @@ -519,9 +603,15 @@ func (r *Repository) inviteBot(client *github.Client) error { } } - _, resp, err := client.Repositories.AddCollaborator(context.Background(), r.meta.GitHubOrganization, r.meta.GitHubRepository, r.BotName, &github.RepositoryAddCollaboratorOptions{ - Permission: "maintain", - }) + _, resp, err := client.Repositories.AddCollaborator( + context.Background(), + r.meta.GitHubOrganization, + r.meta.GitHubRepository, + r.BotName, + &github.RepositoryAddCollaboratorOptions{ + Permission: "maintain", + }, + ) if err != nil { if resp.StatusCode == http.StatusNoContent { return nil diff --git a/internal/project/custom/custom.go b/internal/project/custom/custom.go index 27d11a77..fb5b18a9 100644 --- a/internal/project/custom/custom.go +++ b/internal/project/custom/custom.go @@ -17,6 +17,7 @@ import ( "github.com/siderolabs/kres/internal/output/dockerfile" dockerstep "github.com/siderolabs/kres/internal/output/dockerfile/step" "github.com/siderolabs/kres/internal/output/ghworkflow" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/meta" "github.com/siderolabs/kres/internal/project/service" @@ -98,6 +99,8 @@ type Step struct { SudoInCI bool `yaml:"sudoInCI"` MakeTarget string `yaml:"makeTarget"` + + Lefthook lefthook.Config `yaml:"lefthook"` } type Artifacts struct { //nolint:govet @@ -548,3 +551,8 @@ func (step *Step) CompileMakefile(output *makefile.Output) error { // SkipAsMakefileDependency marks step as skipped in Makefile dependency graph. func (step *Step) SkipAsMakefileDependency() {} + +// CompileLefthook implements lefthook.Compiler. +func (step *Step) CompileLefthook(output *lefthook.Output) error { + return step.Lefthook.Compile(output) +} diff --git a/internal/project/custom/custom_test.go b/internal/project/custom/custom_test.go new file mode 100644 index 00000000..1c382c4c --- /dev/null +++ b/internal/project/custom/custom_test.go @@ -0,0 +1,198 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package custom_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" + + "github.com/siderolabs/kres/internal/output/lefthook" + "github.com/siderolabs/kres/internal/project/custom" + "github.com/siderolabs/kres/internal/project/meta" +) + +func TestCompileLefthookDisabled(t *testing.T) { + step := custom.NewStep(&meta.Options{}, "custom") + step.Lefthook.Hooks = map[string]*lefthook.Hook{ + "pre-commit": { + Jobs: []*lefthook.Job{{Name: "custom", Run: "make custom"}}, + }, + } + + o := lefthook.NewOutput() + o.Enable() + + require.NoError(t, step.CompileLefthook(o)) + + // Lefthook is disabled, so the step contributes no jobs. + assert.Nil(t, o.Hook("pre-commit").Jobs) +} + +// lefthookFile mirrors the structure of a generated lefthook.yml closely enough +// to assert on hooks, commands, jobs and nested groups. +type lefthookFile map[string]struct { //nolint:govet + Parallel *bool `yaml:"parallel"` + Commands map[string]struct { + Run string `yaml:"run"` + } `yaml:"commands"` + Jobs []lefthookJob `yaml:"jobs"` +} + +type lefthookJob struct { //nolint:govet + Name string `yaml:"name"` + Run string `yaml:"run"` + StageFixed bool `yaml:"stage_fixed"` + Group *struct { + Parallel *bool `yaml:"parallel"` + Jobs []lefthookJob `yaml:"jobs"` + } `yaml:"group"` +} + +func decodeLefthook(t *testing.T, step *custom.Step) lefthookFile { + t.Helper() + + o := lefthook.NewOutput() + o.Enable() + + require.NoError(t, step.CompileLefthook(o)) + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + var decoded lefthookFile + + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &decoded)) + + return decoded +} + +// TestCompileLefthookJobsWithGroups covers the pre-commit shape from the +// generated lefthook.yml: top-level named jobs each wrapping a sequential group +// of stage-fixed jobs. +func TestCompileLefthookJobsWithGroups(t *testing.T) { + step := custom.NewStep(&meta.Options{}, "custom") + step.Lefthook.Enabled = true + step.Lefthook.Hooks = map[string]*lefthook.Hook{ + "pre-commit": { + Jobs: []*lefthook.Job{ + { + Name: lefthook.PreCommitFixStage, + Group: &lefthook.Group{ + Parallel: new(false), + Jobs: []*lefthook.Job{ + {Name: "generate", Run: "make generate", StageFixed: true}, + {Name: "fmt", Run: "make fmt", StageFixed: true}, + }, + }, + }, + { + Name: lefthook.PreCommitLintStage, + Group: &lefthook.Group{ + Parallel: new(false), + Jobs: []*lefthook.Job{ + {Name: "lint", Run: "make lint"}, + }, + }, + }, + }, + }, + } + + hook := decodeLefthook(t, step)["pre-commit"] + + require.Len(t, hook.Jobs, 2) + + fix := hook.Jobs[0] + assert.Equal(t, lefthook.PreCommitFixStage, fix.Name) + require.NotNil(t, fix.Group) + require.NotNil(t, fix.Group.Parallel) + assert.False(t, *fix.Group.Parallel) + require.Len(t, fix.Group.Jobs, 2) + assert.Equal(t, "generate", fix.Group.Jobs[0].Name) + assert.Equal(t, "make generate", fix.Group.Jobs[0].Run) + assert.True(t, fix.Group.Jobs[0].StageFixed) + assert.Equal(t, "fmt", fix.Group.Jobs[1].Name) + assert.True(t, fix.Group.Jobs[1].StageFixed) + + lint := hook.Jobs[1] + assert.Equal(t, lefthook.PreCommitLintStage, lint.Name) + require.NotNil(t, lint.Group) + require.Len(t, lint.Group.Jobs, 1) + assert.Equal(t, "lint", lint.Group.Jobs[0].Name) + assert.False(t, lint.Group.Jobs[0].StageFixed) +} + +// TestCompileLefthookCommands covers the commit-msg shape: a commands-style +// hook with an explicit parallel: false. +func TestCompileLefthookCommands(t *testing.T) { + step := custom.NewStep(&meta.Options{}, "custom") + step.Lefthook.Enabled = true + step.Lefthook.Hooks = map[string]*lefthook.Hook{ + "commit-msg": { + Parallel: new(false), + Commands: map[string]*lefthook.Command{ + "conformance": {Run: "make conformance"}, + }, + }, + } + + hook := decodeLefthook(t, step)["commit-msg"] + + require.NotNil(t, hook.Parallel) + assert.False(t, *hook.Parallel) + require.Contains(t, hook.Commands, "conformance") + assert.Equal(t, "make conformance", hook.Commands["conformance"].Run) +} + +// TestCompileLefthookMergesNamedGroup verifies a custom step appends to an +// existing named group rather than forking a new one. +func TestCompileLefthookMergesNamedGroup(t *testing.T) { + step := custom.NewStep(&meta.Options{}, "custom") + step.Lefthook.Enabled = true + step.Lefthook.Hooks = map[string]*lefthook.Hook{ + "pre-commit": { + Jobs: []*lefthook.Job{ + { + Name: lefthook.PreCommitFixStage, + Group: &lefthook.Group{ + Jobs: []*lefthook.Job{{Name: "custom", Run: "make custom", StageFixed: true}}, + }, + }, + }, + }, + } + + o := lefthook.NewOutput() + o.Enable() + + // Pre-seed the shared fix group, as a standard block would. + o.Hook("pre-commit").Group(lefthook.PreCommitFixStage). + WithParallel(false). + Job().WithName("generate").WithRun("make generate").WithStageFixed() + + require.NoError(t, step.CompileLefthook(o)) + + var buf bytes.Buffer + + require.NoError(t, o.GenerateFile("lefthook.yml", &buf)) + + var decoded lefthookFile + + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &decoded)) + + hook := decoded["pre-commit"] + require.Len(t, hook.Jobs, 1, "custom step must extend the existing fix group, not add a new top-level job") + + group := hook.Jobs[0].Group + require.NotNil(t, group) + require.Len(t, group.Jobs, 2) + assert.Equal(t, "generate", group.Jobs[0].Name) + assert.Equal(t, "custom", group.Jobs[1].Name) +} diff --git a/internal/project/golang/generate.go b/internal/project/golang/generate.go index cc3cccc0..52e5cc9a 100644 --- a/internal/project/golang/generate.go +++ b/internal/project/golang/generate.go @@ -15,6 +15,7 @@ import ( "github.com/siderolabs/kres/internal/output/dockerfile" "github.com/siderolabs/kres/internal/output/dockerfile/step" "github.com/siderolabs/kres/internal/output/dockerignore" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/license" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/output/template" @@ -431,6 +432,16 @@ func (generate *Generate) CompileTemplates(output *template.Output) error { return nil } +// CompileLefthook implements lefthook.Compiler. +func (generate *Generate) CompileLefthook(output *lefthook.Output) error { + output.Hook(lefthook.HookGroupPreCommit). + Group(lefthook.PreCommitFixStage). + WithParallel(false). + Job().WithName("generate").WithRun("make generate").WithStageFixed() + + return nil +} + func (generate *Generate) versionPackagePath() string { return strings.TrimSpace(generate.VersionPackagePath) } diff --git a/internal/project/golang/generate_test.go b/internal/project/golang/generate_test.go index a5e1b29e..51a403ba 100644 --- a/internal/project/golang/generate_test.go +++ b/internal/project/golang/generate_test.go @@ -8,14 +8,41 @@ import ( "bytes" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/siderolabs/kres/internal/output/dockerfile" "github.com/siderolabs/kres/internal/output/dockerignore" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/project/golang" "github.com/siderolabs/kres/internal/project/meta" ) +func TestGenerateInterfaces(t *testing.T) { + assert.Implements(t, (*dockerfile.Compiler)(nil), new(golang.Generate)) + assert.Implements(t, (*dockerignore.Compiler)(nil), new(golang.Generate)) + assert.Implements(t, (*lefthook.Compiler)(nil), new(golang.Generate)) +} + +func TestGenerateLefthook(t *testing.T) { + generate := golang.NewGenerate(&meta.Options{}) + + // `make generate` joins the shared fix stage as a named job. + output := lefthook.NewOutput() + output.Enable() + + require.NoError(t, generate.CompileLefthook(output)) + + var buf bytes.Buffer + + require.NoError(t, output.GenerateFile("lefthook.yml", &buf)) + + rendered := buf.String() + assert.Contains(t, rendered, "name: generate") + assert.Contains(t, rendered, "run: make generate") + assert.Contains(t, rendered, "stage_fixed: true") +} + func TestGenerateExtraInputs(t *testing.T) { generate := golang.NewGenerate(&meta.Options{ CachePath: "/root/.cache", diff --git a/internal/project/golang/gofumpt.go b/internal/project/golang/gofumpt.go index 5eedb604..4bffd501 100644 --- a/internal/project/golang/gofumpt.go +++ b/internal/project/golang/gofumpt.go @@ -11,6 +11,7 @@ import ( "github.com/siderolabs/kres/internal/dag" "github.com/siderolabs/kres/internal/output/dockerfile" "github.com/siderolabs/kres/internal/output/dockerfile/step" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/meta" ) @@ -54,7 +55,7 @@ func (lint *Gofumpt) CompileMakefile(output *makefile.Output) error { output.Target("fmt").Description("Formats the source code"). Phony(). Script( - `@docker run --rm -it -v $(PWD):/src -w /src golang:$(GO_VERSION) \ + `@docker run --rm -v $(PWD):/src -w /src golang:$(GO_VERSION) \ bash -c "export GOTOOLCHAIN=local; \ export GO111MODULE=on; export GOPROXY=https://proxy.golang.org; \ go install mvdan.cc/gofumpt@$(GOFUMPT_VERSION) && \ @@ -80,3 +81,15 @@ func (lint *Gofumpt) CompileDockerfile(output *dockerfile.Output) error { return nil } + +// CompileLefthook implements lefthook.Compiler. +func (lint *Gofumpt) CompileLefthook(output *lefthook.Output) error { + // stage 1: gofumpt rewrites files, so it joins the shared fix group (alongside + // lint-fmt) and re-stages what it changes. + output.Hook(lefthook.HookGroupPreCommit). + Group(lefthook.PreCommitFixStage). + WithParallel(false). + Job().WithName("fmt").WithRun("make fmt").WithStageFixed() + + return nil +} diff --git a/internal/project/golang/gofumpt_test.go b/internal/project/golang/gofumpt_test.go index 84ba15e1..f8ebd32b 100644 --- a/internal/project/golang/gofumpt_test.go +++ b/internal/project/golang/gofumpt_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/siderolabs/kres/internal/output/dockerfile" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/golang" ) @@ -17,4 +18,5 @@ import ( func TestGofumptInterfaces(t *testing.T) { assert.Implements(t, (*dockerfile.Compiler)(nil), new(golang.Gofumpt)) assert.Implements(t, (*makefile.Compiler)(nil), new(golang.Gofumpt)) + assert.Implements(t, (*lefthook.Compiler)(nil), new(golang.Gofumpt)) } diff --git a/internal/project/js/protobuf.go b/internal/project/js/protobuf.go index 4ce164f3..d2b8d404 100644 --- a/internal/project/js/protobuf.go +++ b/internal/project/js/protobuf.go @@ -12,6 +12,7 @@ import ( "github.com/siderolabs/kres/internal/dag" "github.com/siderolabs/kres/internal/output/dockerfile" "github.com/siderolabs/kres/internal/output/dockerfile/step" + "github.com/siderolabs/kres/internal/output/lefthook" "github.com/siderolabs/kres/internal/output/makefile" "github.com/siderolabs/kres/internal/project/meta" ) @@ -187,3 +188,13 @@ func (proto *Protobuf) CompileDockerfile(output *dockerfile.Output) error { return nil } + +// CompileLefthook implements lefthook.Compiler. +func (proto *Protobuf) CompileLefthook(output *lefthook.Output) error { + output.Hook(lefthook.HookGroupPreCommit). + Group(lefthook.PreCommitFixStage). + WithParallel(false). + Job().WithName("generate " + proto.Name()).WithRun("make generate-" + proto.Name()).WithStageFixed() + + return nil +} diff --git a/internal/project/js/protobuf_test.go b/internal/project/js/protobuf_test.go new file mode 100644 index 00000000..bc444834 --- /dev/null +++ b/internal/project/js/protobuf_test.go @@ -0,0 +1,44 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package js_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/kres/internal/output/dockerfile" + "github.com/siderolabs/kres/internal/output/lefthook" + "github.com/siderolabs/kres/internal/output/makefile" + "github.com/siderolabs/kres/internal/project/js" + "github.com/siderolabs/kres/internal/project/meta" +) + +func TestProtobufInterfaces(t *testing.T) { + assert.Implements(t, (*dockerfile.Compiler)(nil), new(js.Protobuf)) + assert.Implements(t, (*makefile.Compiler)(nil), new(js.Protobuf)) + assert.Implements(t, (*lefthook.Compiler)(nil), new(js.Protobuf)) +} + +func TestProtobufLefthook(t *testing.T) { + proto := js.NewProtobuf(&meta.Options{}, "frontend") + + // `make generate-frontend` joins the shared fix stage as a named job. + output := lefthook.NewOutput() + output.Enable() + + require.NoError(t, proto.CompileLefthook(output)) + + var buf bytes.Buffer + + require.NoError(t, output.GenerateFile("lefthook.yml", &buf)) + + rendered := buf.String() + assert.Contains(t, rendered, "name: generate frontend") + assert.Contains(t, rendered, "run: make generate-frontend") + assert.Contains(t, rendered, "stage_fixed: true") +} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 00000000..cd657ca9 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,30 @@ +# THIS FILE WAS AUTOMATICALLY GENERATED BY KRES, PLEASE DO NOT EDIT. +# +# Generated on 2026-05-29T17:08:20Z by kres 9dccc81-dirty. + +commit-msg: + parallel: false + commands: + conformance: + run: make conformance +pre-commit: + jobs: + - name: fix + group: + parallel: false + jobs: + - name: generate + run: make generate + stage_fixed: true + - name: fmt + run: make fmt + stage_fixed: true + - name: lint-fmt + run: make lint-fmt + stage_fixed: true + - name: lint + group: + parallel: false + jobs: + - name: lint + run: make lint