diff --git a/.golangci.yml b/.golangci.yml index d215e910..8739e4e9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -18,6 +18,13 @@ linters-settings: rules: - name: unused-parameter disabled: true + unused: + field-writes-are-uses: false + post-statements-are-reads: true + exported-fields-are-used: false + parameters-are-used: true + local-variables-are-used: false + generated-is-used: false issues: exclude: diff --git a/docs/mdbook/SUMMARY.md b/docs/mdbook/SUMMARY.md index 1a8092b4..d63d4d3e 100644 --- a/docs/mdbook/SUMMARY.md +++ b/docs/mdbook/SUMMARY.md @@ -50,6 +50,28 @@ - [`exclude_tags`](./configuration/exclude_tags.md) - [`skip`](./configuration/skip.md) - [`only`](./configuration/only.md) + - [`jobs`](./configuration/jobs.md) + - [`name`](./configuration/name.md) + - [`run`](./configuration/run.md) + - [`script`](./configuration/script.md) + - [`runner`](./configuration/runner.md) + - [`group`](./configuration/group.md) + - [`parallel`](./configuration/parallel.md) + - [`piped`](./configuration/piped.md) + - [`jobs`](./configuration/jobs.md) + - [`skip`](./configuration/skip.md) + - [`only`](./configuration/only.md) + - [`tags`](./configuration/tags.md) + - [`glob`](./configuration/glob.md) + - [`files`](./configuration/files.md) + - [`file_types`](./configuration/file_types.md) + - [`env`](./configuration/env.md) + - [`root`](./configuration/root.md) + - [`exclude`](./configuration/exclude.md) + - [`fail_text`](./configuration/fail_text.md) + - [`stage_fixed`](./configuration/stage_fixed.md) + - [`interactive`](./configuration/interactive.md) + - [`use_stdin`](./configuration/use_stdin.md) - [`commands`](./configuration/Commands.md) - [`run`](./configuration/run.md) - [`skip`](./configuration/skip.md) diff --git a/docs/mdbook/configuration/Scripts.md b/docs/mdbook/configuration/Scripts.md index 1e45a6ef..150c699b 100644 --- a/docs/mdbook/configuration/Scripts.md +++ b/docs/mdbook/configuration/Scripts.md @@ -16,7 +16,20 @@ To add a script for a `pre-commit` hook: runner: bash ``` -**Example** +### Script options + +- [`runner`](./runner.md) +- [`skip`](./skip.md) +- [`only`](./only.md) +- [`tags`](./tags.md) +- [`env`](./env.md) +- [`fail_text`](./fail_text.md) +- [`stage_fixed`](./stage_fixed.md) +- [`interactive`](./interactive.md) +- [`use_stdin`](./use_stdin.md) +- [`priority`](./priority.md) + +### Example Let's create a bash script to check commit templates `.lefthook/commit-msg/template_checker`: @@ -43,16 +56,3 @@ commit-msg: ``` When you try to commit `git commit -m "bad commit text"` script `template_checker` will be executed. Since commit text doesn't match the described pattern the commit process will be interrupted. - -### Script options - -- [`runner`](./runner.md) -- [`skip`](./skip.md) -- [`only`](./only.md) -- [`tags`](./tags.md) -- [`env`](./env.md) -- [`fail_text`](./fail_text.md) -- [`stage_fixed`](./stage_fixed.md) -- [`interactive`](./interactive.md) -- [`use_stdin`](./use_stdin.md) -- [`priority`](./priority.md) diff --git a/docs/mdbook/configuration/group.md b/docs/mdbook/configuration/group.md new file mode 100644 index 00000000..28efecfb --- /dev/null +++ b/docs/mdbook/configuration/group.md @@ -0,0 +1,30 @@ +## `group` + +Specifies a group of jobs and option to run them with. + +- [`parallel`](./parallel.md) +- [`piped`](./piped.md) +- [`jobs`](./jobs.md) + +### Example + +```yml +# lefthook.yml + +pre-commit: + jobs: + - group: + parallel: true + jobs: + - run: echo hello from a group +``` + +> **Note:** To make a group mergeable with settings defined in local config or extends you have to specify the name of the job group belongs to: +> ```yml +> pre-commit: +> jobs: +> - name: a name of a group +> group: +> jobs: +> - run: echo from a group job +> ``` diff --git a/docs/mdbook/configuration/jobs.md b/docs/mdbook/configuration/jobs.md new file mode 100644 index 00000000..1ef4bd9e --- /dev/null +++ b/docs/mdbook/configuration/jobs.md @@ -0,0 +1,72 @@ +## `jobs` + +Job can either be a command or a script. Configuring `jobs` is more flexible than configuring `commands` and `scripts`, although all options are supported now. + +```yml +# lefthook.yml + +pre-commit: + jobs: + - run: yarn lint + - run: yarn test +``` + +This is how jobs configuration differ from commands and scripts: + +- Jobs have optional names. Lefthook merges named jobs across [extends](./extends.md) and [local configs](../examples/lefthook-local.md). Unnamed jobs get appended in the definition order. +- Jobs can have groups of other jobs. For groups you can specify [`parallel`](./parallel.md) or [`piped`](./piped.md) flow for a bunch of jobs. Also [`glob`](./glob.md) and [`root`](./root.md) parameters of a group apply to all its jobs (even nested). + +### Job options + +- [`name`](./name.md) +- [`run`](./run.md) +- [`script`](./script.md) +- [`runner`](./runner.md) +- [`group`](./group.md) + - [`parallel`](./parallel.md) + - [`piped`](./piped.md) + - [`jobs`](./jobs.md) +- [`skip`](./skip.md) +- [`only`](./only.md) +- [`tags`](./tags.md) +- [`glob`](./glob.md) +- [`files`](./files.md) +- [`file_types`](./file_types.md) +- [`env`](./env.md) +- [`root`](./root.md) +- [`exclude`](./exclude.md) +- [`fail_text`](./fail_text.md) +- [`stage_fixed`](./stage_fixed.md) +- [`interactive`](./interactive.md) +- [`use_stdin`](./use_stdin.md) + +### Example + +> **Note:** Currently only `root` and `glob` options are applied to group jobs. Other options must be set for each job separately. If you find this inconvenient, please submit a [feature request](https://github.com/evilmartians/lefthook/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.md). + +A simple configuration with one piped group which executes in parallel with other jobs. + +```yml +# lefthook.yml + +pre-commit: + parallel: true + jobs: + - name: migrate + root: backend/ + glob: "db/migrations/*" + group: + piped: true + jobs: + - run: bundle install + - run: rails db:migrate + - run: yarn lint --fix {staged_files} + root: frontend/ + stage_fixed: true + - run: bundle exec rubocop + root: backend/ + - run: golangci-lint + root: proxy/ + - script: verify.sh + runner: bash +``` diff --git a/docs/mdbook/configuration/name.md b/docs/mdbook/configuration/name.md new file mode 100644 index 00000000..3cc39849 --- /dev/null +++ b/docs/mdbook/configuration/name.md @@ -0,0 +1,14 @@ +## `name` + +Name of a job. Will be printed in summary. If specified, the jobs can be merged with a jobs of the same name in a [local config](../examples/lefthook-local.md) or [extends](./extends.md). + +### Example + +```yml +# lefthook.yml + +pre-commit: + jobs: + - name: lint and fix + run: yarn run eslint --fix {staged_files} +``` diff --git a/docs/mdbook/configuration/script.md b/docs/mdbook/configuration/script.md new file mode 100644 index 00000000..d0f69d28 --- /dev/null +++ b/docs/mdbook/configuration/script.md @@ -0,0 +1,20 @@ +## `script` + +Name of a script to execute. The rules are the same as for [`scripts`](./Scripts.md) + +### Example + +```yml +# lefthook.yml + +pre-commit: + jobs: + - script: linter.sh + runner: bash +``` + +```bash +# .lefthook/pre-commit/linter.sh + +echo "Everything is OK" +``` diff --git a/docs/mdbook/examples/lefthook-local.md b/docs/mdbook/examples/lefthook-local.md index 53c7d8aa..9541a3a4 100644 --- a/docs/mdbook/examples/lefthook-local.md +++ b/docs/mdbook/examples/lefthook-local.md @@ -11,20 +11,9 @@ pre-commit: commands: - lint-frontend: - run: yarn lint - glob: ".{ts,tsx}" - lint-backend: + lint: run: bundle exec rubocop {staged_files} glob: "*.rb" - test-frontend: - run: yarn test - glob: "*.tsx" - test-backend: - run: bundle exec rspec - glob: "spec/*" - check-typos: - run: typos {staged_files} check-links: run: lychee {staged_files} ``` @@ -35,10 +24,8 @@ pre-commit: pre-commit: parallel: true # run all commands concurrently commands: - lint-backend: + lint: run: docker-compose run backend {cmd} # wrap the original command with docker-compose - test-backend: - run: docker-compose run backend {cmd} check-links: skip: true # skip checking links @@ -59,23 +46,12 @@ post-merge: pre-commit: parallel: true commands: - lint-frontend: - run: yarn lint - glob: "*.{ts,tsx}" - lint-backend: + lint: run: docker-compose run backend bundle exec rubocop {staged_files} glob: "*.rb" - test-frontend: - run: yarn test - glob: "*.tsx" - test-backend: - run: docker-compose run backend bundle exec rspec - glob: "spec/*" check-links: run: lychee {staged_files} skip: true - check-typos: - run: typos {staged_files} post-merge: files: "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD" diff --git a/internal/config/command.go b/internal/config/command.go index b2639442..d2a4074f 100644 --- a/internal/config/command.go +++ b/internal/config/command.go @@ -2,12 +2,9 @@ package config import ( "errors" - - "github.com/evilmartians/lefthook/internal/git" - "github.com/evilmartians/lefthook/internal/system" ) -var errFilesIncompatible = errors.New("One of your runners contains incompatible file types") +var ErrFilesIncompatible = errors.New("One of your runners contains incompatible file types") type Command struct { Run string `json:"run" mapstructure:"run" toml:"run" yaml:"run"` @@ -31,19 +28,6 @@ type Command struct { StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` } -func (c Command) Validate() error { - if !isRunnerFilesCompatible(c.Run) { - return errFilesIncompatible - } - - return nil -} - -func (c Command) DoSkip(state func() git.State) bool { - skipChecker := NewSkipChecker(system.Cmd) - return skipChecker.check(state, c.Skip, c.Only) -} - func (c Command) ExecutionPriority() int { return c.Priority } diff --git a/internal/config/config.go b/internal/config/config.go index d49f2fe5..3d4f270f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,8 +11,6 @@ import ( "github.com/mitchellh/mapstructure" toml "github.com/pelletier/go-toml/v2" "gopkg.in/yaml.v3" - - "github.com/evilmartians/lefthook/internal/version" ) type DumpFormat int @@ -46,10 +44,6 @@ type Config struct { Hooks map[string]*Hook `mapstructure:"-"` } -func (c *Config) Validate() error { - return version.CheckCovered(c.MinVersion) -} - func (c *Config) Md5() (checksum string, err error) { configBytes := new(bytes.Buffer) diff --git a/internal/config/files.go b/internal/config/files.go index 82e70c4e..e7c049ad 100644 --- a/internal/config/files.go +++ b/internal/config/files.go @@ -9,9 +9,6 @@ const ( SubPushFiles string = "{push_files}" ) -func isRunnerFilesCompatible(runner string) bool { - if strings.Contains(runner, SubStagedFiles) && strings.Contains(runner, SubPushFiles) { - return false - } - return true +func IsRunFilesCompatible(run string) bool { + return !(strings.Contains(run, SubStagedFiles) && strings.Contains(run, SubPushFiles)) } diff --git a/internal/config/hook.go b/internal/config/hook.go index 15278f62..75d28a47 100644 --- a/internal/config/hook.go +++ b/internal/config/hook.go @@ -1,38 +1,28 @@ package config import ( - "errors" - "github.com/evilmartians/lefthook/internal/git" "github.com/evilmartians/lefthook/internal/system" ) const CMD = "{cmd}" -var errPipedAndParallelSet = errors.New("conflicting options 'piped' and 'parallel' are set to 'true', remove one of this option from hook group") - type Hook struct { - Commands map[string]*Command `json:"commands,omitempty" mapstructure:"-" toml:"commands,omitempty" yaml:",omitempty"` - Scripts map[string]*Script `json:"scripts,omitempty" mapstructure:"-" toml:"scripts,omitempty" yaml:",omitempty"` - - Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"` Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"` Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"` Follow bool `json:"follow,omitempty" mapstructure:"follow" toml:"follow,omitempty" yaml:",omitempty"` + Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"` ExcludeTags []string `json:"exclude_tags,omitempty" koanf:"exclude_tags" mapstructure:"exclude_tags" toml:"exclude_tags,omitempty" yaml:"exclude_tags,omitempty"` Skip interface{} `json:"skip,omitempty" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"` Only interface{} `json:"only,omitempty" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"` -} -func (h *Hook) Validate() error { - if h.Parallel && h.Piped { - return errPipedAndParallelSet - } + Jobs []*Job `json:"jobs,omitempty" mapstructure:"jobs" toml:"jobs,omitempty" yaml:",omitempty"` - return nil + Commands map[string]*Command `json:"commands,omitempty" mapstructure:"-" toml:"commands,omitempty" yaml:",omitempty"` + Scripts map[string]*Script `json:"scripts,omitempty" mapstructure:"-" toml:"scripts,omitempty" yaml:",omitempty"` } func (h *Hook) DoSkip(state func() git.State) bool { skipChecker := NewSkipChecker(system.Cmd) - return skipChecker.check(state, h.Skip, h.Only) + return skipChecker.Check(state, h.Skip, h.Only) } diff --git a/internal/config/job.go b/internal/config/job.go new file mode 100644 index 00000000..a69e7c9f --- /dev/null +++ b/internal/config/job.go @@ -0,0 +1,49 @@ +package config + +type Job struct { + Name string `json:"name,omitempty" mapstructure:"name" toml:"name,omitempty" yaml:",omitempty"` + Run string `json:"run,omitempty" mapstructure:"run" toml:"run,omitempty" yaml:",omitempty"` + Script string `json:"script,omitempty" mapstructure:"script" toml:"script,omitempty" yaml:",omitempty"` + Runner string `json:"runner,omitempty" mapstructure:"runner" toml:"runner,omitempty" yaml:",omitempty"` + + Glob string `json:"glob,omitempty" mapstructure:"glob" toml:"glob,omitempty" yaml:",omitempty"` + Root string `json:"root,omitempty" mapstructure:"root" toml:"root,omitempty" yaml:",omitempty"` + Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"` + FailText string `json:"fail_text,omitempty" koanf:"fail_text" mapstructure:"fail_text" toml:"fail_text,omitempty" yaml:"fail_text,omitempty"` + + Tags []string `json:"tags,omitempty" mapstructure:"tags" toml:"tags,omitempty" yaml:",omitempty"` + FileTypes []string `json:"file_types,omitempty" koanf:"file_types" mapstructure:"file_types" toml:"file_types,omitempty" yaml:"file_types,omitempty"` + + Env map[string]string `json:"env,omitempty" mapstructure:"env" toml:"env,omitempty" yaml:",omitempty"` + + Interactive bool `json:"interactive,omitempty" mapstructure:"interactive" toml:"interactive,omitempty" yaml:",omitempty"` + UseStdin bool `json:"use_stdin,omitempty" koanf:"use_stdin" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:",omitempty"` + StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` + + Exclude interface{} `json:"exclude,omitempty" mapstructure:"exclude" toml:"exclude,omitempty" yaml:",omitempty"` + Skip interface{} `json:"skip,omitempty" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"` + Only interface{} `json:"only,omitempty" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"` + + Group *Group `json:"group,omitempty" mapstructure:"group" toml:"group,omitempty" yaml:",omitempty"` +} + +type Group struct { + Root string `json:"root,omitempty" mapstructure:"root" toml:"root,omitempty" yaml:",omitempty"` + Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"` + Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"` + Jobs []*Job `json:"jobs,omitempty" mapstructure:"jobs" toml:"jobs,omitempty" yaml:",omitempty"` +} + +func (job *Job) PrintableName(id string) string { + if len(job.Name) != 0 { + return job.Name + } + if len(job.Run) != 0 { + return job.Run + } + if len(job.Script) != 0 { + return job.Script + } + + return "[" + id + "]" +} diff --git a/internal/config/load.go b/internal/config/load.go index 7946f0ba..1125b8f0 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -29,7 +29,7 @@ const ( ) var ( - hookKeyRegexp = regexp.MustCompile(`^(?P[^.]+)\.(scripts|commands)`) + hookKeyRegexp = regexp.MustCompile(`^(?P[^.]+)\.(scripts|commands|jobs)`) localConfigNames = []string{"lefthook-local", ".lefthook-local"} mainConfigNames = []string{"lefthook", ".lefthook"} extensions = []string{ @@ -44,6 +44,8 @@ var ( ".json": json.Parser(), ".toml": toml.Parser(), } + + mergeJobsOption = koanf.WithMergeFunc(mergeJobs) ) // ConfigNotFoundError. @@ -63,7 +65,7 @@ func loadOne(k *koanf.Koanf, filesystem afero.Fs, root string, names []string) e continue } - if err := k.Load(kfs.Provider(newIOFS(filesystem), config), parsers[extension]); err != nil { + if err := k.Load(kfs.Provider(newIOFS(filesystem), config), parsers[extension], mergeJobsOption); err != nil { return err } @@ -114,16 +116,18 @@ func Load(filesystem afero.Fs, repo *git.Repository) (*Config, error) { } // Load optional local config (e.g. lefthook-local.yml) + var noLocal bool if err := loadOne(secondary, filesystem, repo.RootPath, localConfigNames); err != nil { var configNotFoundErr ConfigNotFoundError if ok := errors.As(err, &configNotFoundErr); !ok { return nil, err } + noLocal = true } // Load local `extends` localExtends := secondary.Strings("extends") - if len(localExtends) > 0 && !slices.Equal(extends, localExtends) { + if !noLocal && !slices.Equal(extends, localExtends) { if err := extend(secondary, filesystem, repo.RootPath, localExtends); err != nil { return nil, err } @@ -172,10 +176,10 @@ func loadRemotes(k *koanf.Koanf, filesystem afero.Fs, repo *git.Repository, remo parser, ok := parsers[filepath.Ext(configPath)] if !ok { - panic("TODO: unknown extension to parse") + return fmt.Errorf("can't parse config '%[1]s', file has unsupported or no extension\nhint: rename %[1]s to %[1]s.yml", configPath) } - if err := k.Load(kfs.Provider(newIOFS(filesystem), configPath), parser); err != nil { + if err := k.Load(kfs.Provider(newIOFS(filesystem), configPath), parser, mergeJobsOption); err != nil { return err } @@ -221,9 +225,9 @@ func extendRecursive(k *koanf.Koanf, filesystem afero.Fs, root string, extends [ extent := koanf.New(".") parser, ok := parsers[filepath.Ext(path)] if !ok { - panic("TODO: unknown extension for extent " + path) + return fmt.Errorf("can't parse config '%[1]s', file has unsupported or no extension\nhint: rename %[1]s to %[1]s.yml", path) } - if err := extent.Load(kfs.Provider(newIOFS(filesystem), path), parser); err != nil { + if err := extent.Load(kfs.Provider(newIOFS(filesystem), path), parser, mergeJobsOption); err != nil { return err } @@ -231,7 +235,7 @@ func extendRecursive(k *koanf.Koanf, filesystem afero.Fs, root string, extends [ return err } - if err := k.Merge(extent); err != nil { + if err := k.Load(koanfProvider{extent}, nil, mergeJobsOption); err != nil { return err } } @@ -326,6 +330,20 @@ func addHook(name string, main, secondary *koanf.Koanf, c *Config) error { default: } + var destJobs, srcJobs []interface{} + switch jobs := dest["jobs"].(type) { + case []interface{}: + destJobs = jobs + default: + } + switch jobs := src["jobs"].(type) { + case []interface{}: + srcJobs = jobs + default: + } + + destJobs = mergeJobsSlice(srcJobs, destJobs) + maps.Merge(src, dest) if len(destCommands) > 0 { @@ -347,6 +365,10 @@ func addHook(name string, main, secondary *koanf.Koanf, c *Config) error { } } + if len(destJobs) > 0 { + dest["jobs"] = destJobs + } + return nil }) @@ -396,3 +418,138 @@ func (k koanfProvider) Read() (map[string]interface{}, error) { func (k koanfProvider) ReadBytes() ([]byte, error) { panic("not implemented") } + +func mergeJobs(src, dest map[string]interface{}) error { + srcJobs := make(map[string][]interface{}) + + for name, maybeHook := range src { + switch hook := maybeHook.(type) { + case map[string]interface{}: + switch jobs := hook["jobs"].(type) { + case []interface{}: + srcJobs[name] = jobs + default: + } + default: + } + } + + destJobs := make(map[string][]interface{}) + for name, maybeHook := range dest { + switch hook := maybeHook.(type) { + case map[string]interface{}: + switch jobs := hook["jobs"].(type) { + case []interface{}: + destJobs[name] = jobs + default: + } + default: + } + } + + if len(srcJobs) == 0 || len(destJobs) == 0 { + maps.Merge(src, dest) + return nil + } + + for hook, newJobs := range srcJobs { + oldJobs, ok := destJobs[hook] + if !ok { + destJobs[hook] = newJobs + continue + } + + destJobs[hook] = mergeJobsSlice(newJobs, oldJobs) + } + + maps.Merge(src, dest) + + for name, maybeHook := range dest { + if jobs, ok := destJobs[name]; ok { + switch hook := maybeHook.(type) { + case map[string]interface{}: + hook["jobs"] = jobs + default: + } + } + } + + return nil +} + +func mergeJobsSlice(src, dest []interface{}) []interface{} { + mergeable := make(map[string]map[string]interface{}) + result := make([]interface{}, 0, len(dest)) + + for _, maybeJob := range dest { + switch destJob := maybeJob.(type) { + case map[string]interface{}: + switch name := destJob["name"].(type) { + case string: + mergeable[name] = destJob + default: + } + + result = append(result, maybeJob) + default: + } + } + + for _, maybeJob := range src { + switch srcJob := maybeJob.(type) { + case map[string]interface{}: + switch name := srcJob["name"].(type) { + case string: + destJob, ok := mergeable[name] + if ok { + var srcSubJobs []interface{} + var destSubJobs []interface{} + + switch srcGroup := srcJob["group"].(type) { + case map[string]interface{}: + switch subJobs := srcGroup["jobs"].(type) { + case []interface{}: + srcSubJobs = subJobs + default: + } + default: + } + switch destGroup := destJob["group"].(type) { + case map[string]interface{}: + switch subJobs := destGroup["jobs"].(type) { + case []interface{}: + destSubJobs = subJobs + default: + } + default: + } + + if len(destSubJobs) != 0 && len(srcSubJobs) != 0 { + destSubJobs = mergeJobsSlice(srcSubJobs, destSubJobs) + } + + maps.Merge(srcJob, destJob) + + if len(destSubJobs) != 0 { + switch destGroup := destJob["group"].(type) { + case map[string]interface{}: + switch destGroup["jobs"].(type) { + case []interface{}: + destGroup["jobs"] = destSubJobs + default: + } + default: + } + } + continue + } + default: + } + + result = append(result, maybeJob) + default: + } + } + + return result +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 99005845..f35e9069 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -709,6 +709,106 @@ pre-commit: }, }, }, + "with jobs": { + files: map[string]string{ + "lefthook.yml": ` +pre-commit: + jobs: + - run: 1 + - run: 2 + name: second +`, + "lefthook-local.yml": ` +pre-commit: + jobs: + - run: 3 + - run: local 2 + name: second +`, + }, + result: &Config{ + SourceDir: ".lefthook", + SourceDirLocal: ".lefthook-local", + Hooks: map[string]*Hook{ + "pre-commit": { + Jobs: []*Job{ + {Run: "1"}, + {Run: "local 2", Name: "second"}, + {Run: "3"}, + }, + }, + }, + }, + }, + "with nested jobs": { + files: map[string]string{ + "lefthook.yml": ` +pre-commit: + jobs: + - name: group 1 + group: + jobs: + - run: 1.1 + - run: 1.2 + - name: nested + group: + jobs: + - run: 1.nested.1 + - run: 1.nested.2 + name: nested 2 +`, + "lefthook-local.yml": ` +pre-commit: + jobs: + - name: group 1 + glob: "*.rb" + group: + parallel: true + jobs: + - name: nested + group: + jobs: + - run: 1.nested.2 local + name: nested 2 + - run: 1.nested.3 + - run: 1.3 + - run: 1.4 +`, + }, + result: &Config{ + SourceDir: ".lefthook", + SourceDirLocal: ".lefthook-local", + Hooks: map[string]*Hook{ + "pre-commit": { + Jobs: []*Job{ + { + Name: "group 1", + Glob: "*.rb", + Group: &Group{ + Parallel: true, + Jobs: []*Job{ + {Run: "1.1"}, + {Run: "1.2"}, + { + Name: "nested", + Group: &Group{ + Jobs: []*Job{ + {Run: "1.nested.1"}, + {Run: "1.nested.2 local", Name: "nested 2"}, + {Run: "1.nested.3"}, + }, + }, + }, + {Run: "1.3"}, + {Run: "1.4"}, + }, + }, + }, + }, + }, + }, + }, + }, } { fs := afero.Afero{Fs: afero.NewMemMapFs()} repo := &git.Repository{ @@ -738,7 +838,7 @@ pre-commit: result, err := Load(fs.Fs, repo) assert.NoError(err) - assert.Equal(result, tt.result) + assert.Equal(tt.result, result) }) } diff --git a/internal/config/script.go b/internal/config/script.go index 472ed10f..704b507b 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -22,7 +22,7 @@ type Script struct { func (s Script) DoSkip(state func() git.State) bool { skipChecker := NewSkipChecker(system.Cmd) - return skipChecker.check(state, s.Skip, s.Only) + return skipChecker.Check(state, s.Skip, s.Only) } func (s Script) ExecutionPriority() int { diff --git a/internal/config/skip_checker.go b/internal/config/skip_checker.go index 5febc061..9217307b 100644 --- a/internal/config/skip_checker.go +++ b/internal/config/skip_checker.go @@ -17,7 +17,7 @@ func NewSkipChecker(cmd system.Command) *skipChecker { } // check returns the result of applying a skip/only setting which can be a branch, git state, shell command, etc. -func (sc *skipChecker) check(state func() git.State, skip interface{}, only interface{}) bool { +func (sc *skipChecker) Check(state func() git.State, skip interface{}, only interface{}) bool { if skip == nil && only == nil { return false } diff --git a/internal/config/skip_checker_test.go b/internal/config/skip_checker_test.go index a83e13f9..7b74cc40 100644 --- a/internal/config/skip_checker_test.go +++ b/internal/config/skip_checker_test.go @@ -18,7 +18,7 @@ func (mc mockCmd) Run(cmd []string, _root string, _in io.Reader, _out io.Writer, } } -func TestDoSkip(t *testing.T) { +func TestSkipChecker_Check(t *testing.T) { skipChecker := NewSkipChecker(mockCmd{}) for _, tt := range [...]struct { @@ -151,7 +151,7 @@ func TestDoSkip(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - if skipChecker.check(tt.state, tt.skip, tt.only) != tt.skipped { + if skipChecker.Check(tt.state, tt.skip, tt.only) != tt.skipped { t.Errorf("Expected: %v, Was %v", tt.skipped, !tt.skipped) } }) diff --git a/internal/lefthook/run.go b/internal/lefthook/run.go index f7d97e5f..2d41798c 100644 --- a/internal/lefthook/run.go +++ b/internal/lefthook/run.go @@ -13,6 +13,7 @@ import ( "github.com/evilmartians/lefthook/internal/config" "github.com/evilmartians/lefthook/internal/lefthook/runner" "github.com/evilmartians/lefthook/internal/log" + "github.com/evilmartians/lefthook/internal/version" ) const ( @@ -21,6 +22,8 @@ const ( envOutput = "LEFTHOOK_OUTPUT" // "meta,success,failure,summary,skips,execution,execution_out,execution_info" ) +var errPipedAndParallelSet = errors.New("conflicting options 'piped' and 'parallel' are set to 'true', remove one of this option from hook group") + type RunArgs struct { NoTTY bool AllFiles bool @@ -64,7 +67,7 @@ func (l *Lefthook) Run(hookName string, args RunArgs, gitArgs []string) error { return err } - if err = cfg.Validate(); err != nil { + if err = version.CheckCovered(cfg.MinVersion); err != nil { return err } @@ -115,8 +118,9 @@ func (l *Lefthook) Run(hookName string, args RunArgs, gitArgs []string) error { return fmt.Errorf("Hook %s doesn't exist in the config", hookName) } - if err := hook.Validate(); err != nil { - return err + + if hook.Parallel && hook.Piped { + return errPipedAndParallelSet } if args.FilesFromStdin { @@ -166,11 +170,12 @@ func (l *Lefthook) Run(hookName string, args RunArgs, gitArgs []string) error { Files: args.Files, Force: args.Force, RunOnlyCommands: args.RunOnlyCommands, + SourceDirs: sourceDirs, }, ) startTime := time.Now() - results, runErr := r.RunAll(ctx, sourceDirs) + results, runErr := r.RunAll(ctx) if runErr != nil { return fmt.Errorf("failed to run the hook: %w", runErr) } @@ -221,13 +226,17 @@ func printSummary( ) } + logResults(0, results, logSettings) +} + +func logResults(indent int, results []runner.Result, logSettings log.Settings) { if logSettings.LogSuccess() { for _, result := range results { if !result.Success() { continue } - log.Success(result.Name) + log.Success(indent, result.Name) } } @@ -237,7 +246,11 @@ func printSummary( continue } - log.Failure(result.Name, result.Text()) + log.Failure(indent, result.Name, result.Text()) + + if len(result.Sub) > 0 { + logResults(indent+1, result.Sub, logSettings) + } } } } @@ -255,9 +268,6 @@ func (l *Lefthook) configHookCompletions() []string { if err != nil { return nil } - if err = cfg.Validate(); err != nil { - return nil - } hooks := make([]string, 0, len(cfg.Hooks)) for hook := range cfg.Hooks { hooks = append(hooks, hook) @@ -278,9 +288,6 @@ func (l *Lefthook) configHookCommandCompletions(hookName string) []string { if err != nil { return nil } - if err = cfg.Validate(); err != nil { - return nil - } if hook, found := cfg.Hooks[hookName]; !found { return nil } else { diff --git a/internal/lefthook/runner/filters/filters.go b/internal/lefthook/runner/filters/filters.go index e107012e..1df64b63 100644 --- a/internal/lefthook/runner/filters/filters.go +++ b/internal/lefthook/runner/filters/filters.go @@ -10,7 +10,6 @@ import ( "github.com/gobwas/glob" "github.com/spf13/afero" - "github.com/evilmartians/lefthook/internal/config" "github.com/evilmartians/lefthook/internal/log" ) @@ -29,17 +28,24 @@ const ( executableMask = 0o111 ) -func Apply(fs afero.Fs, command *config.Command, files []string) []string { +type Params struct { + Glob string + Root string + FileTypes []string + Exclude interface{} +} + +func Apply(fs afero.Fs, files []string, params Params) []string { if len(files) == 0 { return nil } log.Debug("[lefthook] files before filters:\n", files) - files = byGlob(files, command.Glob) - files = byExclude(files, command.Exclude) - files = byRoot(files, command.Root) - files = byType(fs, files, command.FileTypes) + files = byGlob(files, params.Glob) + files = byExclude(files, params.Exclude) + files = byRoot(files, params.Root) + files = byType(fs, files, params.FileTypes) log.Debug("[lefthook] files after filters:\n", files) diff --git a/internal/lefthook/runner/prepare_command.go b/internal/lefthook/runner/jobs/build_command.go similarity index 66% rename from internal/lefthook/runner/prepare_command.go rename to internal/lefthook/runner/jobs/build_command.go index e65b8b5b..a4ce0739 100644 --- a/internal/lefthook/runner/prepare_command.go +++ b/internal/lefthook/runner/jobs/build_command.go @@ -1,11 +1,12 @@ -package runner +package jobs import ( "fmt" + "regexp" "runtime" "strings" - "gopkg.in/alessio/shellescape.v1" + "github.com/alessio/shellescape" "github.com/evilmartians/lefthook/internal/config" "github.com/evilmartians/lefthook/internal/lefthook/runner/filters" @@ -13,50 +14,25 @@ import ( "github.com/evilmartians/lefthook/internal/system" ) -// An object that describes the single command's run option. -type run struct { - commands []string - files []string -} +var surroundingQuotesRegexp = regexp.MustCompile(`^'(.*)'$`) -// Stats for template replacements in a command string. +// template is stats for template replacements in a command string. type template struct { files []string cnt int } -func (r *Runner) prepareCommand(name string, command *config.Command) (*run, error) { - if command.DoSkip(r.Repo.State) { - return nil, &skipError{"settings"} - } - - if intersect(r.Hook.ExcludeTags, command.Tags) { - return nil, &skipError{"tags"} - } - - if intersect(r.Hook.ExcludeTags, []string{name}) { - return nil, &skipError{"name"} - } - - if err := command.Validate(); err != nil { +func buildCommand(params *Params) (*Job, error) { + if err := params.validateCommand(); err != nil { return nil, err } - args, err := r.buildRun(command) - if err != nil { - return nil, err - } - - return args, nil -} - -func (r *Runner) buildRun(command *config.Command) (*run, error) { - filesCmd := r.Hook.Files - if len(command.Files) > 0 { - filesCmd = command.Files + filesCmd := params.Hook.Files + if len(params.Files) > 0 { + filesCmd = params.Files } if len(filesCmd) > 0 { - filesCmd = replacePositionalArguments(filesCmd, r.GitArgs) + filesCmd = replacePositionalArguments(filesCmd, params.GitArgs) } var stagedFiles func() ([]string, error) @@ -64,15 +40,15 @@ func (r *Runner) buildRun(command *config.Command) (*run, error) { var allFiles func() ([]string, error) var cmdFiles func() ([]string, error) - if len(r.Files) > 0 { - stagedFiles = func() ([]string, error) { return r.Files, nil } + if len(params.ForceFiles) > 0 { + stagedFiles = func() ([]string, error) { return params.ForceFiles, nil } pushFiles = stagedFiles allFiles = stagedFiles cmdFiles = stagedFiles } else { - stagedFiles = r.Repo.StagedFiles - pushFiles = r.Repo.PushFiles - allFiles = r.Repo.AllFiles + stagedFiles = params.Repo.StagedFiles + pushFiles = params.Repo.PushFiles + allFiles = params.Repo.AllFiles cmdFiles = func() ([]string, error) { var cmd []string if runtime.GOOS == "windows" { @@ -80,7 +56,7 @@ func (r *Runner) buildRun(command *config.Command) (*run, error) { } else { cmd = []string{"sh", "-c", filesCmd} } - return r.Repo.FilesByCommand(cmd, command.Root) + return params.Repo.FilesByCommand(cmd, params.Root) } } @@ -93,8 +69,14 @@ func (r *Runner) buildRun(command *config.Command) (*run, error) { templates := make(map[string]*template) + filterParams := filters.Params{ + Glob: params.Glob, + Exclude: params.Exclude, + Root: params.Root, + FileTypes: params.FileTypes, + } for filesType, fn := range filesFns { - cnt := strings.Count(command.Run, filesType) + cnt := strings.Count(params.Run, filesType) if cnt == 0 { continue } @@ -107,9 +89,9 @@ func (r *Runner) buildRun(command *config.Command) (*run, error) { return nil, fmt.Errorf("error replacing %s: %w", filesType, err) } - files = filters.Apply(r.Repo.Fs, command, files) - if !r.Force && len(files) == 0 { - return nil, &skipError{"no files for inspection"} + files = filters.Apply(params.Repo.Fs, files, filterParams) + if !params.Force && len(files) == 0 { + return nil, SkipError{"no files for inspection"} } templ.files = files @@ -118,53 +100,53 @@ func (r *Runner) buildRun(command *config.Command) (*run, error) { // Checking substitutions and skipping execution if it is empty. // // Special case for `files` option: return if the result of files command is empty. - if !r.Force && len(filesCmd) > 0 && templates[config.SubFiles] == nil { + if !params.Force && len(filesCmd) > 0 && templates[config.SubFiles] == nil { files, err := filesFns[config.SubFiles]() if err != nil { return nil, fmt.Errorf("error calling replace command for %s: %w", config.SubFiles, err) } - files = filters.Apply(r.Repo.Fs, command, files) + files = filters.Apply(params.Repo.Fs, files, filterParams) if len(files) == 0 { - return nil, &skipError{"no files for inspection"} + return nil, SkipError{"no files for inspection"} } } - runString := command.Run - runString = replacePositionalArguments(runString, r.GitArgs) + runString := params.Run + runString = replacePositionalArguments(runString, params.GitArgs) maxlen := system.MaxCmdLen() result := replaceInChunks(runString, templates, maxlen) - if r.Force || len(result.files) != 0 { + if params.Force || len(result.Files) != 0 { return result, nil } - if config.HookUsesStagedFiles(r.HookName) { - ok, err := r.canSkipCommand(command, templates[config.SubStagedFiles], r.Repo.StagedFiles) + if config.HookUsesStagedFiles(params.HookName) { + ok, err := canSkipJob(params, filterParams, templates[config.SubStagedFiles], params.Repo.StagedFiles) if err != nil { return nil, err } if ok { - return nil, &skipError{"no matching staged files"} + return nil, SkipError{"no matching staged files"} } } - if config.HookUsesPushFiles(r.HookName) { - ok, err := r.canSkipCommand(command, templates[config.SubPushFiles], r.Repo.PushFiles) + if config.HookUsesPushFiles(params.HookName) { + ok, err := canSkipJob(params, filterParams, templates[config.SubPushFiles], params.Repo.PushFiles) if err != nil { return nil, err } if ok { - return nil, &skipError{"no matching push files"} + return nil, SkipError{"no matching push files"} } } return result, nil } -func (r *Runner) canSkipCommand(command *config.Command, template *template, filesFn func() ([]string, error)) (bool, error) { +func canSkipJob(params *Params, filterParams filters.Params, template *template, filesFn func() ([]string, error)) (bool, error) { if template != nil { return len(template.files) == 0, nil } @@ -173,7 +155,7 @@ func (r *Runner) canSkipCommand(command *config.Command, template *template, fil if err != nil { return false, fmt.Errorf("error getting files: %w", err) } - if len(filters.Apply(r.Repo.Fs, command, files)) == 0 { + if len(filters.Apply(params.Repo.Fs, files, filterParams)) == 0 { return true, nil } @@ -202,10 +184,10 @@ func escapeFiles(files []string) []string { return filesEsc } -func replaceInChunks(str string, templates map[string]*template, maxlen int) *run { +func replaceInChunks(str string, templates map[string]*template, maxlen int) *Job { if len(templates) == 0 { - return &run{ - commands: []string{str}, + return &Job{ + Execs: []string{str}, } } @@ -250,9 +232,9 @@ func replaceInChunks(str string, templates map[string]*template, maxlen int) *ru } } - return &run{ - commands: commands, - files: allFiles, + return &Job{ + Execs: commands, + Files: allFiles, } } diff --git a/internal/lefthook/runner/prepare_command_test.go b/internal/lefthook/runner/jobs/build_command_test.go similarity index 50% rename from internal/lefthook/runner/prepare_command_test.go rename to internal/lefthook/runner/jobs/build_command_test.go index c4ead4e9..7e654fd2 100644 --- a/internal/lefthook/runner/prepare_command_test.go +++ b/internal/lefthook/runner/jobs/build_command_test.go @@ -1,31 +1,13 @@ -package runner +package jobs import ( "fmt" "testing" -) - -func slicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - r := make(map[string]struct{}) - - for _, item := range a { - r[item] = struct{}{} - } - - for _, item := range b { - if _, ok := r[item]; !ok { - return false - } - } - - return true -} + "github.com/stretchr/testify/assert" +) -func TestGetNChars(t *testing.T) { +func Test_getNChars(t *testing.T) { for i, tt := range [...]struct { source, cut, rest []string n int @@ -74,24 +56,21 @@ func TestGetNChars(t *testing.T) { }, } { t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { + assert := assert.New(t) cut, rest := getNChars(tt.source, tt.n) - if !slicesEqual(cut, tt.cut) { - t.Errorf("expected cut %v to be equal to %v", cut, tt.cut) - } - if !slicesEqual(rest, tt.rest) { - t.Errorf("expected rest %v to be equal to %v", rest, tt.rest) - } + assert.EqualValues(cut, tt.cut) + assert.EqualValues(rest, tt.rest) }) } } -func TestReplaceInChunks(t *testing.T) { +func Test_replaceInChunks(t *testing.T) { for i, tt := range [...]struct { str string templates map[string]*template maxlen int - res *run + job *Job }{ { str: "echo {staged_files}", @@ -102,9 +81,9 @@ func TestReplaceInChunks(t *testing.T) { }, }, maxlen: 300, - res: &run{ - commands: []string{"echo file1 file2 file3"}, - files: []string{"file1", "file2", "file3"}, + job: &Job{ + Execs: []string{"echo file1 file2 file3"}, + Files: []string{"file1", "file2", "file3"}, }, }, { @@ -116,13 +95,13 @@ func TestReplaceInChunks(t *testing.T) { }, }, maxlen: 10, - res: &run{ - commands: []string{ + job: &Job{ + Execs: []string{ "echo file1", "echo file2", "echo file3", }, - files: []string{"file1", "file2", "file3"}, + Files: []string{"file1", "file2", "file3"}, }, }, { @@ -134,12 +113,12 @@ func TestReplaceInChunks(t *testing.T) { }, }, maxlen: 49, // (49 - 17(len of command without templates)) / 2 = 16, but we need 17 (3 words + 2 spaces) - res: &run{ - commands: []string{ + job: &Job{ + Execs: []string{ "echo file1 file2 && git add file1 file2", "echo file3 && git add file3", }, - files: []string{"file1", "file2", "file3"}, + Files: []string{"file1", "file2", "file3"}, }, }, { @@ -151,11 +130,11 @@ func TestReplaceInChunks(t *testing.T) { }, }, maxlen: 51, - res: &run{ - commands: []string{ + job: &Job{ + Execs: []string{ "echo file1 file2 file3 && git add file1 file2 file3", }, - files: []string{"file1", "file2", "file3"}, + Files: []string{"file1", "file2", "file3"}, }, }, { @@ -171,12 +150,12 @@ func TestReplaceInChunks(t *testing.T) { }, }, maxlen: 10, - res: &run{ - commands: []string{ + job: &Job{ + Execs: []string{ "echo push-file && git add file1", "echo push-file && git add file2", }, - files: []string{"push-file", "file1", "file2"}, + Files: []string{"push-file", "file1", "file2"}, }, }, { @@ -192,31 +171,92 @@ func TestReplaceInChunks(t *testing.T) { }, }, maxlen: 27, - res: &run{ - commands: []string{ + job: &Job{ + Execs: []string{ "echo push1 && git add file1", "echo push2 && git add file2", "echo push3 && git add file2", }, - files: []string{"push1", "push2", "push3", "file1", "file2"}, + Files: []string{"push1", "push2", "push3", "file1", "file2"}, }, }, } { t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { - res := replaceInChunks(tt.str, tt.templates, tt.maxlen) - if !slicesEqual(res.files, tt.res.files) { - t.Errorf("expected files %v to be equal to %v", res.files, tt.res.files) - } + assert := assert.New(t) + job := replaceInChunks(tt.str, tt.templates, tt.maxlen) + + assert.ElementsMatch(job.Files, tt.job.Files) + assert.Equal(job.Execs, tt.job.Execs) + }) + } +} - if len(res.commands) != len(tt.res.commands) { - t.Errorf("expected commands to be %d instead of %d", len(tt.res.commands), len(res.commands)) - } else { - for i, command := range res.commands { - if command != tt.res.commands[i] { - t.Errorf("expected command %v to be equal to %v", command, tt.res.commands[i]) - } - } - } +func Test_replaceQuoted(t *testing.T) { + for i, tt := range [...]struct { + name, source, substitution string + files []string + result string + }{ + { + name: "without substitutions", + source: "echo", + substitution: "{staged_files}", + files: []string{"a", "b"}, + result: "echo", + }, + { + name: "with simple substitution", + source: "echo {staged_files}", + substitution: "{staged_files}", + files: []string{"test.rb", "README"}, + result: "echo test.rb README", + }, + { + name: "with single quoted substitution", + source: "echo '{staged_files}'", + substitution: "{staged_files}", + files: []string{"test.rb", "README"}, + result: "echo 'test.rb' 'README'", + }, + { + name: "with double quoted substitution", + source: `echo "{staged_files}"`, + substitution: "{staged_files}", + files: []string{"test.rb", "README"}, + result: `echo "test.rb" "README"`, + }, + { + name: "with escaped files double quoted", + source: `echo "{staged_files}"`, + substitution: "{staged_files}", + files: []string{"'test me.rb'", "README"}, + result: `echo "test me.rb" "README"`, + }, + { + name: "with escaped files single quoted", + source: "echo '{staged_files}'", + substitution: "{staged_files}", + files: []string{"'test me.rb'", "README"}, + result: `echo 'test me.rb' 'README'`, + }, + { + name: "with escaped files", + source: "echo {staged_files}", + substitution: "{staged_files}", + files: []string{"'test me.rb'", "README"}, + result: `echo 'test me.rb' README`, + }, + { + name: "with many substitutions", + source: `echo "{staged_files}" {staged_files}`, + substitution: "{staged_files}", + files: []string{"'test me.rb'", "README"}, + result: `echo "test me.rb" "README" 'test me.rb' README`, + }, + } { + t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { + result := replaceQuoted(tt.source, tt.substitution, tt.files) + assert.Equal(t, result, tt.result) }) } } diff --git a/internal/lefthook/runner/jobs/build_script.go b/internal/lefthook/runner/jobs/build_script.go new file mode 100644 index 00000000..356583fa --- /dev/null +++ b/internal/lefthook/runner/jobs/build_script.go @@ -0,0 +1,80 @@ +package jobs + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/alessio/shellescape" + + "github.com/evilmartians/lefthook/internal/log" +) + +const ( + executableFileMode os.FileMode = 0o751 + executableMask os.FileMode = 0o111 +) + +type scriptNotExistsError struct { + scriptPath string +} + +func (s scriptNotExistsError) Error() string { + return fmt.Sprintf("script does not exist: %s", s.scriptPath) +} + +func buildScript(params *Params) (*Job, error) { + if err := params.validateScript(); err != nil { + return nil, err + } + + var scriptExists bool + execs := make([]string, 0) + for _, sourceDir := range params.SourceDirs { + scriptPath := filepath.Join(sourceDir, params.HookName, params.Script) + fileInfo, err := params.Repo.Fs.Stat(scriptPath) + if os.IsNotExist(err) { + log.Debugf("[lefthook] script doesn't exist: %s", scriptPath) + continue + } + if err != nil { + log.Errorf("Failed to get info about a script: %s", params.Script) + return nil, err + } + + scriptExists = true + + if !fileInfo.Mode().IsRegular() { + log.Debugf("[lefthook] script '%s' is not a regular file, skipping", scriptPath) + return nil, &SkipError{"not a regular file"} + } + + // Make sure file is executable + if (fileInfo.Mode() & executableMask) == 0 { + if err := params.Repo.Fs.Chmod(scriptPath, executableFileMode); err != nil { + log.Errorf("Couldn't change file mode to make file executable: %s", err) + return nil, err + } + } + + var args []string + if len(params.Runner) > 0 { + args = append(args, params.Runner) + } + + args = append(args, shellescape.Quote(scriptPath)) + args = append(args, params.GitArgs...) + + execs = append(execs, strings.Join(args, " ")) + } + + if !scriptExists { + return nil, scriptNotExistsError{params.Script} + } + + return &Job{ + Execs: execs, + Files: []string{}, + }, nil +} diff --git a/internal/lefthook/runner/jobs/jobs.go b/internal/lefthook/runner/jobs/jobs.go new file mode 100644 index 00000000..44363443 --- /dev/null +++ b/internal/lefthook/runner/jobs/jobs.go @@ -0,0 +1,95 @@ +package jobs + +import ( + "github.com/evilmartians/lefthook/internal/config" + "github.com/evilmartians/lefthook/internal/git" + "github.com/evilmartians/lefthook/internal/system" +) + +type Params struct { + Repo *git.Repository + Hook *config.Hook + HookName string + GitArgs []string + Force bool + ForceFiles []string + SourceDirs []string + + Run string + Root string + Runner string + Script string + Glob string + Files string + FileTypes []string + Tags []string + Exclude interface{} + Only interface{} + Skip interface{} +} + +type Job struct { + Execs []string + Files []string +} + +func New(name string, params *Params) (*Job, error) { + if params.skip() { + return nil, SkipError{"settings"} + } + + if intersect(params.Hook.ExcludeTags, params.Tags) { + return nil, SkipError{"tags"} + } + + if intersect(params.Hook.ExcludeTags, []string{name}) { + return nil, SkipError{"name"} + } + + var err error + var job *Job + if len(params.Run) != 0 { + job, err = buildCommand(params) + } else { + job, err = buildScript(params) + } + + if err != nil { + return nil, err + } + + return job, nil +} + +func (p *Params) skip() bool { + skipChecker := config.NewSkipChecker(system.Cmd) + return skipChecker.Check(p.Repo.State, p.Skip, p.Only) +} + +func (p *Params) validateCommand() error { + if !config.IsRunFilesCompatible(p.Run) { + return config.ErrFilesIncompatible + } + + return nil +} + +func (p *Params) validateScript() error { + return nil +} + +func intersect(a, b []string) bool { + intersections := make(map[string]struct{}, len(a)) + + for _, v := range a { + intersections[v] = struct{}{} + } + + for _, v := range b { + if _, ok := intersections[v]; ok { + return true + } + } + + return false +} diff --git a/internal/lefthook/runner/jobs/skip_error.go b/internal/lefthook/runner/jobs/skip_error.go new file mode 100644 index 00000000..5358c021 --- /dev/null +++ b/internal/lefthook/runner/jobs/skip_error.go @@ -0,0 +1,10 @@ +package jobs + +// SkipError implements error interface but indicates that the execution needs to be skipped. +type SkipError struct { + reason string +} + +func (r SkipError) Error() string { + return r.reason +} diff --git a/internal/lefthook/runner/prepare_script.go b/internal/lefthook/runner/prepare_script.go deleted file mode 100644 index b210de25..00000000 --- a/internal/lefthook/runner/prepare_script.go +++ /dev/null @@ -1,45 +0,0 @@ -package runner - -import ( - "os" - "strings" - - "gopkg.in/alessio/shellescape.v1" - - "github.com/evilmartians/lefthook/internal/config" - "github.com/evilmartians/lefthook/internal/log" -) - -func (r *Runner) prepareScript(script *config.Script, path string, file os.FileInfo) (string, error) { - if script.DoSkip(r.Repo.State) { - return "", &skipError{"settings"} - } - - if intersect(r.Hook.ExcludeTags, script.Tags) { - return "", &skipError{"excluded tags"} - } - - // Skip non-regular files (dirs, symlinks, sockets, etc.) - if !file.Mode().IsRegular() { - log.Debugf("[lefthook] file %s is not a regular file, skipping", file.Name()) - return "", &skipError{"not a regular file"} - } - - // Make sure file is executable - if (file.Mode() & executableMask) == 0 { - if err := r.Repo.Fs.Chmod(path, executableFileMode); err != nil { - log.Errorf("Couldn't change file mode to make file executable: %s", err) - return "", err - } - } - - var args []string - if len(script.Runner) > 0 { - args = append(args, script.Runner) - } - - args = append(args, shellescape.Quote(path)) - args = append(args, r.GitArgs...) - - return strings.Join(args, " "), nil -} diff --git a/internal/lefthook/runner/result.go b/internal/lefthook/runner/result.go index 947cfbf9..28fec7f5 100644 --- a/internal/lefthook/runner/result.go +++ b/internal/lefthook/runner/result.go @@ -10,9 +10,10 @@ const ( // Result contains name of a command/script and an optional fail string. type Result struct { + Sub []Result Name string - status status text string + status status } func (r Result) Success() bool { @@ -38,3 +39,18 @@ func succeeded(name string) Result { func failed(name, text string) Result { return Result{Name: name, status: failure, text: text} } + +func groupResult(name string, results []Result) Result { + var stat status = success + for _, res := range results { + if res.status == failure { + stat = failure + break + } + if res.status == skip { + stat = skip + } + } + + return Result{Name: name, status: stat, Sub: results} +} diff --git a/internal/lefthook/runner/run_jobs.go b/internal/lefthook/runner/run_jobs.go new file mode 100644 index 00000000..827eeccb --- /dev/null +++ b/internal/lefthook/runner/run_jobs.go @@ -0,0 +1,227 @@ +package runner + +import ( + "context" + "errors" + "path/filepath" + "strconv" + "sync" + "sync/atomic" + + "github.com/evilmartians/lefthook/internal/config" + "github.com/evilmartians/lefthook/internal/lefthook/runner/exec" + "github.com/evilmartians/lefthook/internal/lefthook/runner/filters" + "github.com/evilmartians/lefthook/internal/lefthook/runner/jobs" + "github.com/evilmartians/lefthook/internal/log" +) + +var ( + errJobContainsBothRunAndScript = errors.New("both `run` and `script` are not permitted") + errEmptyJob = errors.New("no execution instructions") + errEmptyGroup = errors.New("empty groups are not permitted") +) + +type domain struct { + failed *atomic.Bool + + glob string + root string +} + +func (r *Runner) runJobs(ctx context.Context) []Result { + var wg sync.WaitGroup + + results := make([]Result, 0, len(r.Hook.Jobs)) + resultsChan := make(chan Result, len(r.Hook.Jobs)) + + var failed atomic.Bool + domain := &domain{failed: &failed} + + for i, job := range r.Hook.Jobs { + id := strconv.Itoa(i) + + if domain.failed.Load() && r.Hook.Piped { + r.logSkip(job.PrintableName(id), "broken pipe") + continue + } + + if !r.Hook.Parallel { + results = append(results, r.runJob(ctx, domain, id, job)) + continue + } + + wg.Add(1) + go func(job *config.Job) { + defer wg.Done() + resultsChan <- r.runJob(ctx, domain, id, job) + }(job) + } + + wg.Wait() + close(resultsChan) + for result := range resultsChan { + results = append(results, result) + } + + return results +} + +func (r *Runner) runJob(ctx context.Context, domain *domain, id string, job *config.Job) Result { + // Check if do job is properly configured + if len(job.Run) > 0 && len(job.Script) > 0 { + return failed(job.PrintableName(id), errJobContainsBothRunAndScript.Error()) + } + if len(job.Run) == 0 && len(job.Script) == 0 && job.Group == nil { + return failed(job.PrintableName(id), errEmptyJob.Error()) + } + + if job.Interactive && !r.DisableTTY && !r.Hook.Follow { + log.StopSpinner() + defer log.StartSpinner() + } + + if len(job.Run) != 0 || len(job.Script) != 0 { + return r.runSingleJob(ctx, domain, id, job) + } + + if job.Group != nil { + inheritedDomain := *domain + inheritedDomain.glob = first(job.Glob, domain.glob) + inheritedDomain.root = first(job.Root, domain.root) + groupName := first(job.Name, "["+id+"]") + return r.runGroup(ctx, groupName, &inheritedDomain, job.Group) + } + + return failed(job.PrintableName(id), "don't know how to run job") +} + +func (r *Runner) runSingleJob(ctx context.Context, domain *domain, id string, job *config.Job) Result { + name := job.PrintableName(id) + + root := first(job.Root, domain.root) + glob := first(job.Glob, domain.glob) + executionJob, err := jobs.New(name, &jobs.Params{ + Repo: r.Repo, + Hook: r.Hook, + HookName: r.HookName, + ForceFiles: r.Files, + Force: r.Force, + SourceDirs: r.SourceDirs, + GitArgs: r.GitArgs, + Run: job.Run, + Root: root, + Runner: job.Runner, + Script: job.Script, + Glob: glob, + Files: job.Files, + FileTypes: job.FileTypes, + Tags: job.Tags, + Exclude: job.Exclude, + Only: job.Only, + Skip: job.Skip, + }) + if err != nil { + r.logSkip(name, err.Error()) + + var skipErr jobs.SkipError + if errors.As(err, &skipErr) { + return skipped(name) + } + + domain.failed.Store(true) + return failed(name, err.Error()) + } + + ok := r.run(ctx, exec.Options{ + Name: name, + Root: filepath.Join(r.Repo.RootPath, root), + Commands: executionJob.Execs, + Interactive: job.Interactive && !r.DisableTTY, + UseStdin: job.UseStdin, + Env: job.Env, + }, r.Hook.Follow) + + if !ok { + domain.failed.Store(true) + return failed(name, job.FailText) + } + + if config.HookUsesStagedFiles(r.HookName) && job.StageFixed { + files := executionJob.Files + + if len(files) == 0 { + var err error + files, err = r.Repo.StagedFiles() + if err != nil { + log.Warn("Couldn't stage fixed files:", err) + return succeeded(name) + } + + files = filters.Apply(r.Repo.Fs, files, filters.Params{ + Glob: glob, + Root: root, + Exclude: job.Exclude, + FileTypes: job.FileTypes, + }) + } + + if len(root) > 0 { + for i, file := range files { + files[i] = filepath.Join(root, file) + } + } + + r.addStagedFiles(files) + } + + return succeeded(name) +} + +func (r *Runner) runGroup(ctx context.Context, groupName string, domain *domain, group *config.Group) Result { + if len(group.Jobs) == 0 { + return failed(groupName, errEmptyGroup.Error()) + } + + results := make([]Result, 0, len(group.Jobs)) + resultsChan := make(chan Result, len(group.Jobs)) + var wg sync.WaitGroup + + for i, job := range group.Jobs { + id := strconv.Itoa(i) + + if domain.failed.Load() && group.Piped { + r.logSkip(job.PrintableName(id), "broken pipe") + continue + } + + if !group.Parallel { + results = append(results, r.runJob(ctx, domain, id, job)) + continue + } + + wg.Add(1) + go func(job *config.Job) { + defer wg.Done() + resultsChan <- r.runJob(ctx, domain, id, job) + }(job) + } + + wg.Wait() + close(resultsChan) + for result := range resultsChan { + results = append(results, result) + } + + return groupResult(groupName, results) +} + +// first finds first non-empty string and returns it. +func first(args ...string) string { + for _, a := range args { + if len(a) > 0 { + return a + } + } + + return "" +} diff --git a/internal/lefthook/runner/runner.go b/internal/lefthook/runner/runner.go index 2ceb9692..db383a3d 100644 --- a/internal/lefthook/runner/runner.go +++ b/internal/lefthook/runner/runner.go @@ -8,7 +8,6 @@ import ( "io" "os" "path/filepath" - "regexp" "slices" "sort" "strconv" @@ -24,17 +23,12 @@ import ( "github.com/evilmartians/lefthook/internal/git" "github.com/evilmartians/lefthook/internal/lefthook/runner/exec" "github.com/evilmartians/lefthook/internal/lefthook/runner/filters" + "github.com/evilmartians/lefthook/internal/lefthook/runner/jobs" "github.com/evilmartians/lefthook/internal/log" "github.com/evilmartians/lefthook/internal/system" ) -const ( - executableFileMode os.FileMode = 0o751 - executableMask os.FileMode = 0o111 - execLogPadding = 2 -) - -var surroundingQuotesRegexp = regexp.MustCompile(`^'(.*)'$`) +const execLogPadding = 2 type Options struct { Repo *git.Repository @@ -47,6 +41,7 @@ type Options struct { Force bool Files []string RunOnlyCommands []string + SourceDirs []string } // Runner responds for actual execution and handling the results. @@ -72,15 +67,6 @@ func New(opts Options) *Runner { } } -// skipError implements error interface but indicates that the execution needs to be skipped. -type skipError struct { - reason string -} - -func (r *skipError) Error() string { - return r.reason -} - type executable interface { *config.Command | *config.Script ExecutionPriority() int @@ -88,10 +74,10 @@ type executable interface { // RunAll runs scripts and commands. // LFS hook is executed at first if needed. -func (r *Runner) RunAll(ctx context.Context, sourceDirs []string) ([]Result, error) { +func (r *Runner) RunAll(ctx context.Context) ([]Result, error) { results := make([]Result, 0, len(r.Hook.Commands)+len(r.Hook.Scripts)) - if r.Hook.DoSkip(r.Repo.State) { + if config.NewSkipChecker(system.Cmd).Check(r.Repo.State, r.Hook.Skip, r.Hook.Only) { r.logSkip(r.HookName, "hook setting") return results, nil } @@ -105,8 +91,8 @@ func (r *Runner) RunAll(ctx context.Context, sourceDirs []string) ([]Result, err defer log.StopSpinner() } - scriptDirs := make([]string, 0, len(sourceDirs)) - for _, sourceDir := range sourceDirs { + scriptDirs := make([]string, 0, len(r.SourceDirs)) + for _, sourceDir := range r.SourceDirs { scriptDirs = append(scriptDirs, filepath.Join( sourceDir, r.HookName, )) @@ -114,6 +100,8 @@ func (r *Runner) RunAll(ctx context.Context, sourceDirs []string) ([]Result, err r.preHook() + results = append(results, r.runJobs(ctx)...) + for _, dir := range scriptDirs { results = append(results, r.runScripts(ctx, dir)...) } @@ -309,16 +297,14 @@ func (r *Runner) runScripts(ctx context.Context, dir string) []Result { continue } - path := filepath.Join(dir, file.Name()) - if r.Hook.Parallel { wg.Add(1) - go func(script *config.Script, path string, file os.FileInfo, resChan chan Result) { + go func(script *config.Script, file os.FileInfo, resChan chan Result) { defer wg.Done() - resChan <- r.runScript(ctx, script, path, file) - }(script, path, file, resChan) + resChan <- r.runScript(ctx, script, file) + }(script, file, resChan) } else { - results = append(results, r.runScript(ctx, script, path, file)) + results = append(results, r.runScript(ctx, script, file)) } } @@ -339,19 +325,31 @@ func (r *Runner) runScripts(ctx context.Context, dir string) []Result { continue } - path := filepath.Join(dir, file.Name()) - results = append(results, r.runScript(ctx, script, path, file)) + results = append(results, r.runScript(ctx, script, file)) } return results } -func (r *Runner) runScript(ctx context.Context, script *config.Script, path string, file os.FileInfo) Result { - command, err := r.prepareScript(script, path, file) +func (r *Runner) runScript(ctx context.Context, script *config.Script, file os.FileInfo) Result { + job, err := jobs.New(file.Name(), &jobs.Params{ + Repo: r.Repo, + Hook: r.Hook, + HookName: r.HookName, + ForceFiles: r.Files, + Force: r.Force, + GitArgs: r.GitArgs, + SourceDirs: r.SourceDirs, + Runner: script.Runner, + Script: file.Name(), + Tags: script.Tags, + Only: script.Only, + Skip: script.Skip, + }) if err != nil { r.logSkip(file.Name(), err.Error()) - var skipErr *skipError + var skipErr jobs.SkipError if errors.As(err, &skipErr) { return skipped(file.Name()) } @@ -368,7 +366,7 @@ func (r *Runner) runScript(ctx context.Context, script *config.Script, path stri ok := r.run(ctx, exec.Options{ Name: file.Name(), Root: r.Repo.RootPath, - Commands: []string{command}, + Commands: job.Execs, Interactive: script.Interactive && !r.DisableTTY, UseStdin: script.UseStdin, Env: script.Env, @@ -452,11 +450,27 @@ func (r *Runner) runCommands(ctx context.Context) []Result { } func (r *Runner) runCommand(ctx context.Context, name string, command *config.Command) Result { - run, err := r.prepareCommand(name, command) + job, err := jobs.New(name, &jobs.Params{ + Repo: r.Repo, + Hook: r.Hook, + HookName: r.HookName, + ForceFiles: r.Files, + Force: r.Force, + GitArgs: r.GitArgs, + Run: command.Run, + Root: command.Root, + Glob: command.Glob, + Files: command.Files, + FileTypes: command.FileTypes, + Tags: command.Tags, + Exclude: command.Exclude, + Only: command.Only, + Skip: command.Skip, + }) if err != nil { r.logSkip(name, err.Error()) - var skipErr *skipError + var skipErr jobs.SkipError if errors.As(err, &skipErr) { return skipped(name) } @@ -473,7 +487,7 @@ func (r *Runner) runCommand(ctx context.Context, name string, command *config.Co ok := r.run(ctx, exec.Options{ Name: name, Root: filepath.Join(r.Repo.RootPath, command.Root), - Commands: run.commands, + Commands: job.Execs, Interactive: command.Interactive && !r.DisableTTY, UseStdin: command.UseStdin, Env: command.Env, @@ -487,7 +501,7 @@ func (r *Runner) runCommand(ctx context.Context, name string, command *config.Co result := succeeded(name) if config.HookUsesStagedFiles(r.HookName) && command.StageFixed { - files := run.files + files := job.Files if len(files) == 0 { var err error @@ -497,7 +511,12 @@ func (r *Runner) runCommand(ctx context.Context, name string, command *config.Co return result } - files = filters.Apply(r.Repo.Fs, command, files) + files = filters.Apply(r.Repo.Fs, files, filters.Params{ + Glob: command.Glob, + Root: command.Root, + Exclude: command.Exclude, + FileTypes: command.FileTypes, + }) } if len(command.Root) > 0 { @@ -552,23 +571,6 @@ func (r *Runner) run(ctx context.Context, opts exec.Options, follow bool) bool { return err == nil } -// Returns whether two arrays have at least one similar element. -func intersect(a, b []string) bool { - intersections := make(map[string]struct{}, len(a)) - - for _, v := range a { - intersections[v] = struct{}{} - } - - for _, v := range b { - if _, ok := intersections[v]; ok { - return true - } - } - - return false -} - func (r *Runner) logSkip(name, reason string) { if !r.LogSettings.LogSkips() { return diff --git a/internal/lefthook/runner/runner_test.go b/internal/lefthook/runner/runner_test.go index f8bc08cd..fb8b91a6 100644 --- a/internal/lefthook/runner/runner_test.go +++ b/internal/lefthook/runner/runner_test.go @@ -744,6 +744,7 @@ func TestRunAll(t *testing.T) { GitArgs: tt.args, Force: tt.force, SkipLFS: tt.skipLFS, + SourceDirs: tt.sourceDirs, }, executor: executor{}, cmd: cmd{}, @@ -762,7 +763,7 @@ func TestRunAll(t *testing.T) { t.Run(name, func(t *testing.T) { assert := assert.New(t) git.ResetState() - results, err := runner.RunAll(context.Background(), tt.sourceDirs) + results, err := runner.RunAll(context.Background()) assert.NoError(err) var success, fail []Result @@ -788,76 +789,6 @@ func TestRunAll(t *testing.T) { } } -func TestReplaceQuoted(t *testing.T) { - for i, tt := range [...]struct { - name, source, substitution string - files []string - result string - }{ - { - name: "without substitutions", - source: "echo", - substitution: "{staged_files}", - files: []string{"a", "b"}, - result: "echo", - }, - { - name: "with simple substitution", - source: "echo {staged_files}", - substitution: "{staged_files}", - files: []string{"test.rb", "README"}, - result: "echo test.rb README", - }, - { - name: "with single quoted substitution", - source: "echo '{staged_files}'", - substitution: "{staged_files}", - files: []string{"test.rb", "README"}, - result: "echo 'test.rb' 'README'", - }, - { - name: "with double quoted substitution", - source: `echo "{staged_files}"`, - substitution: "{staged_files}", - files: []string{"test.rb", "README"}, - result: `echo "test.rb" "README"`, - }, - { - name: "with escaped files double quoted", - source: `echo "{staged_files}"`, - substitution: "{staged_files}", - files: []string{"'test me.rb'", "README"}, - result: `echo "test me.rb" "README"`, - }, - { - name: "with escaped files single quoted", - source: "echo '{staged_files}'", - substitution: "{staged_files}", - files: []string{"'test me.rb'", "README"}, - result: `echo 'test me.rb' 'README'`, - }, - { - name: "with escaped files", - source: "echo {staged_files}", - substitution: "{staged_files}", - files: []string{"'test me.rb'", "README"}, - result: `echo 'test me.rb' README`, - }, - { - name: "with many substitutions", - source: `echo "{staged_files}" {staged_files}`, - substitution: "{staged_files}", - files: []string{"'test me.rb'", "README"}, - result: `echo "test me.rb" "README" 'test me.rb' README`, - }, - } { - t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { - result := replaceQuoted(tt.source, tt.substitution, tt.files) - assert.Equal(t, result, tt.result) - }) - } -} - //nolint:dupl func TestSortByPriorityCommands(t *testing.T) { for i, tt := range [...]struct { diff --git a/internal/log/log.go b/internal/log/log.go index b0843cc4..41bd6d97 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -280,24 +280,24 @@ func LogMeta(hookName string) { ) } -func Success(name string) { - format := "✔️ %s\n" +func Success(indent int, name string) { + format := "%s✔️ %s\n" if !Colorized() { - format = "✓ %s\n" + format = "%s✓ %s\n" } - Infof(format, Green(name)) + Infof(format, strings.Repeat(" ", indent), Green(name)) } -func Failure(name, failText string) { +func Failure(indent int, name, failText string) { if len(failText) != 0 { failText = fmt.Sprintf(": %s", failText) } - format := "🥊 %s%s\n" + format := "%s🥊 %s%s\n" if !Colorized() { - format = "✗ %s%s\n" + format = "%s✗ %s%s\n" } - Infof(format, Red(name), Red(failText)) + Infof(format, strings.Repeat(" ", indent), Red(name), Red(failText)) } func box(left, right string) { diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 00000000..1a753d89 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,23 @@ +skip_lfs: true + +pre-commit: + parallel: true + jobs: + - name: lint & test + glob: "*.go" + group: + jobs: + - run: make lint + stage_fixed: true + + - run: make test + + - name: check links + run: lychee --max-concurrency 3 {staged_files} + glob: '*.md' + exclude: + - CHANGELOG.md + + - name: fix typos + run: typos --write-changes {staged_files} + stage_fixed: true diff --git a/testdata/dump.txt b/testdata/dump.txt index 6e16ea44..f3c8ec19 100644 --- a/testdata/dump.txt +++ b/testdata/dump.txt @@ -49,6 +49,7 @@ colors: red: '#FF1493' yellow: '#F0E68C' pre-commit: + follow: true commands: lint: run: yarn lint {staged_files} @@ -68,7 +69,6 @@ pre-commit: FOO: bar use_stdin: true stage_fixed: true - follow: true -- lefthook-dumped.json -- { "colors": { @@ -79,6 +79,7 @@ pre-commit: "yellow": "#F0E68C" }, "pre-commit": { + "follow": true, "commands": { "lint": { "run": "yarn lint {staged_files}", @@ -106,8 +107,7 @@ pre-commit: "use_stdin": true, "stage_fixed": true } - }, - "follow": true + } } } -- lefthook-dumped.toml -- diff --git a/testdata/exclude.txt b/testdata/exclude.txt index 8213e8b4..15c92bd6 100644 --- a/testdata/exclude.txt +++ b/testdata/exclude.txt @@ -28,9 +28,8 @@ regexp: exclude: '^(a.txt|b.txt)' array: - commands: - echo: - run: echo {staged_files} + jobs: + - run: echo {staged_files} exclude: - a.txt - b.txt diff --git a/testdata/job_fail_text.txt b/testdata/job_fail_text.txt new file mode 100644 index 00000000..d3cdb358 --- /dev/null +++ b/testdata/job_fail_text.txt @@ -0,0 +1,16 @@ +exec git init +exec git config user.email "you@example.com" +exec git config user.name "Your Name" +exec git add -A +exec lefthook install +! exec git commit -m 'test' +stderr '\s*fails: no such command\s*' + +-- lefthook.yml -- +output: + - failure +pre-commit: + jobs: + - name: fails + run: oops-no-such-command + fail_text: no such command diff --git a/testdata/job_filter_by_file_type.txt b/testdata/job_filter_by_file_type.txt new file mode 100644 index 00000000..5e28c9f1 --- /dev/null +++ b/testdata/job_filter_by_file_type.txt @@ -0,0 +1,57 @@ +[windows] skip + +exec git init +exec git config user.email "you@example.com" +exec git config user.name "Your Name" +exec lefthook install +chmod 777 executable +symlink symlink -> results +exec git add -A +exec git commit -m 'test' +exec lefthook run filters +stdout '.*all ❯\s+executable lefthook.yml results symlink\s+┃.*' +stdout '.*filter_text ❯\s+executable lefthook.yml results\s+┃.*' +stdout '.*filter_executable ❯\s+executable\s+┃.*' +stdout '.*filter_symlink ❯\s+symlink\s+┃.*' +stdout '.*filter_not_symlink ❯\s+executable lefthook.yml results\s+┃.*' +stdout '.*filter_not_executable ❯\s+lefthook.yml results symlink\s*' + +-- lefthook.yml -- +output: + - execution + - skips +filters: + piped: true + jobs: + - name: all + run: echo {all_files} + + - name: filter_text + run: echo {all_files} + file_types: text + + - name: filter_executable + run: echo {all_files} + file_types: executable + + - name: filter_symlink + run: echo {all_files} + file_types: symlink + + - name: filter_not_symlink + run: echo {all_files} + file_types: not symlink + + - name: filter_not_executable + run: echo {all_files} + file_types: + - not executable + +-- results -- +some text + +-- executable -- +#!/bin/sh + +echo 'Executable' + diff --git a/testdata/job_merging.txt b/testdata/job_merging.txt new file mode 100644 index 00000000..6e4f5641 --- /dev/null +++ b/testdata/job_merging.txt @@ -0,0 +1,125 @@ +[windows] skip + +exec git init +exec lefthook dump +cmp stdout dump.yml +! stderr . + +-- lefthook.yml -- +extends: + - extends/e1.yml + +pre-commit: + jobs: + - name: group + group: + jobs: + - name: child + run: named + - run: 0 no-name + - name: echo + run: echo 0 + - run: lefthook.yml + +-- extends/e1.yml -- +extends: + - extends/e2.yml + +pre-commit: + jobs: + - name: group + group: + jobs: + - name: child + run: child named + - run: 1 no-name + - name: echo + run: echo 1 + skip: true + - run: e1 + +e1: + jobs: + - name: echo + run: e1 + +-- extends/e2.yml -- +extends: + - extends/e3.yml + +pre-commit: + jobs: + - name: group + glob: "*.rb" + group: + jobs: + - name: child + run: child named with glob + - run: 2 no-name + - name: echo + run: echo 2 + tags: ["backend"] + - run: e2 + +e2: + jobs: + - name: echo + run: e2 + +-- extends/e3.yml -- +pre-commit: + jobs: + - name: group + glob: "*.rb" + group: + jobs: + - name: child + stage_fixed: true + - run: 3 no-name + - name: echo + glob: 3 + - run: e3 + +e3: + jobs: + - name: echo + run: e3 + +-- dump.yml -- +e1: + jobs: + - name: echo + run: e1 +e2: + jobs: + - name: echo + run: e2 +e3: + jobs: + - name: echo + run: e3 +extends: + - extends/e3.yml +pre-commit: + jobs: + - name: group + glob: '*.rb' + group: + jobs: + - name: child + run: child named with glob + stage_fixed: true + - run: 0 no-name + - run: 1 no-name + - run: 2 no-name + - run: 3 no-name + - name: echo + run: echo 2 + glob: "3" + tags: + - backend + skip: true + - run: lefthook.yml + - run: e1 + - run: e2 + - run: e3 diff --git a/testdata/job_stage_fixed.txt b/testdata/job_stage_fixed.txt new file mode 100644 index 00000000..fc321fb0 --- /dev/null +++ b/testdata/job_stage_fixed.txt @@ -0,0 +1,24 @@ +exec git init +exec lefthook install +exec git config user.email "you@example.com" +exec git config user.name "Your Name" +exec git add -A +exec git status --short +exec git commit -m 'test stage_fixed' +exec git status --short +! stdout . + +-- lefthook.yml -- +min_version: 1.1.1 +pre-commit: + jobs: + - stage_fixed: true + run: | + echo newline >> "[file].js" + echo newline >> file.txt + +-- file.txt -- +sometext + +-- [file].js -- +somecode diff --git a/testdata/remotes.txt b/testdata/remotes.txt index 9836ba77..a9262a7e 100644 --- a/testdata/remotes.txt +++ b/testdata/remotes.txt @@ -19,6 +19,7 @@ remotes: -- lefthook-dump.yml -- DEPRECATED: "remotes"."config" option is deprecated and will be omitted in the next major release, use "configs" option instead pre-commit: + parallel: true commands: js-lint: run: npx eslint --fix {staged_files} && git add {staged_files} @@ -38,7 +39,6 @@ pre-commit: scripts: good_job.js: runner: node - parallel: true pre-push: commands: spelling: