From 51f32aad480ecb7af4b06c9cc2edf9db3e413371 Mon Sep 17 00:00:00 2001 From: Valentin Kiselev Date: Tue, 29 Oct 2024 10:16:30 +0300 Subject: [PATCH] feat: add recursive 'actions' option --- .golangci.yml | 7 + internal/config/actions.go | 59 +++++ internal/config/command.go | 18 +- internal/config/config.go | 6 - internal/config/files.go | 7 +- internal/config/hook.go | 20 +- internal/config/script.go | 2 +- internal/config/skip_checker.go | 2 +- internal/config/skip_checker_test.go | 4 +- internal/lefthook/run.go | 31 ++- internal/lefthook/runner/action/action.go | 95 ++++++++ .../build_command.go} | 112 ++++----- .../build_command_test.go} | 162 ++++++++----- .../lefthook/runner/action/build_script.go | 80 +++++++ internal/lefthook/runner/action/skip_error.go | 10 + internal/lefthook/runner/filters/filters.go | 18 +- internal/lefthook/runner/prepare_script.go | 45 ---- internal/lefthook/runner/result.go | 18 +- internal/lefthook/runner/run_actions.go | 213 ++++++++++++++++++ internal/lefthook/runner/runner.go | 112 ++++----- internal/lefthook/runner/runner_test.go | 73 +----- internal/log/log.go | 16 +- lefthook.yml | 23 ++ testdata/dump.txt | 6 +- testdata/remotes.txt | 2 +- 25 files changed, 766 insertions(+), 375 deletions(-) create mode 100644 internal/config/actions.go create mode 100644 internal/lefthook/runner/action/action.go rename internal/lefthook/runner/{prepare_command.go => action/build_command.go} (66%) rename internal/lefthook/runner/{prepare_command_test.go => action/build_command_test.go} (50%) create mode 100644 internal/lefthook/runner/action/build_script.go create mode 100644 internal/lefthook/runner/action/skip_error.go delete mode 100644 internal/lefthook/runner/prepare_script.go create mode 100644 internal/lefthook/runner/run_actions.go create mode 100644 lefthook.yml 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/internal/config/actions.go b/internal/config/actions.go new file mode 100644 index 00000000..699afe40 --- /dev/null +++ b/internal/config/actions.go @@ -0,0 +1,59 @@ +package config + +type Action 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" 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" 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" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:",omitempty"` + StageFixed bool `json:"stage_fixed,omitempty" 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 { + Name string `json:"name,omitempty" mapstructure:"name" toml:"name,omitempty" yaml:",omitempty"` + 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"` + Glob string `json:"glob,omitempty" mapstructure:"glob" toml:"glob,omitempty" yaml:",omitempty"` + Actions []*Action `json:"actions,omitempty" mapstructure:"actions" toml:"actions,omitempty" yaml:",omitempty"` +} + +func (action *Action) PrintableName(id string) string { + if len(action.Name) != 0 { + return action.Name + } + if len(action.Run) != 0 { + return action.Run + } + if len(action.Script) != 0 { + return action.Script + } + + return "[" + id + "]" +} + +func (g *Group) PrintableName(id string) string { + if len(g.Name) != 0 { + return g.Name + } + + return "[" + id + "]" +} diff --git a/internal/config/command.go b/internal/config/command.go index 4f51dd5c..f52a0632 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 7aa1dc38..775b1c2f 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..71eca2b8 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 - } + Actions []*Action `json:"actions,omitempty" mapstructure:"actions" toml:"actions,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/script.go b/internal/config/script.go index df3e71f0..930d9311 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 ff9fc129..b3f23021 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 { @@ -167,11 +171,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) } @@ -222,13 +227,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) } } @@ -238,7 +247,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) + } } } } @@ -256,9 +269,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) @@ -279,9 +289,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/action/action.go b/internal/lefthook/runner/action/action.go new file mode 100644 index 00000000..005080a5 --- /dev/null +++ b/internal/lefthook/runner/action/action.go @@ -0,0 +1,95 @@ +package action + +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 Action struct { + Execs []string + Files []string +} + +func New(name string, params *Params) (*Action, 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 action *Action + if len(params.Run) != 0 { + action, err = buildCommand(params) + } else { + action, err = buildScript(params) + } + + if err != nil { + return nil, err + } + + return action, 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/prepare_command.go b/internal/lefthook/runner/action/build_command.go similarity index 66% rename from internal/lefthook/runner/prepare_command.go rename to internal/lefthook/runner/action/build_command.go index e65b8b5b..9d51bebe 100644 --- a/internal/lefthook/runner/prepare_command.go +++ b/internal/lefthook/runner/action/build_command.go @@ -1,11 +1,12 @@ -package runner +package action 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) (*Action, 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 := canSkipAction(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 := canSkipAction(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 canSkipAction(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) *Action { if len(templates) == 0 { - return &run{ - commands: []string{str}, + return &Action{ + 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 &Action{ + Execs: commands, + Files: allFiles, } } diff --git a/internal/lefthook/runner/prepare_command_test.go b/internal/lefthook/runner/action/build_command_test.go similarity index 50% rename from internal/lefthook/runner/prepare_command_test.go rename to internal/lefthook/runner/action/build_command_test.go index c4ead4e9..67d25c14 100644 --- a/internal/lefthook/runner/prepare_command_test.go +++ b/internal/lefthook/runner/action/build_command_test.go @@ -1,31 +1,13 @@ -package runner +package action 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 + action *Action }{ { 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"}, + action: &Action{ + 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{ + action: &Action{ + 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{ + action: &Action{ + 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{ + action: &Action{ + 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{ + action: &Action{ + 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{ + action: &Action{ + 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) + action := replaceInChunks(tt.str, tt.templates, tt.maxlen) + + assert.ElementsMatch(action.Files, tt.action.Files) + assert.Equal(action.Execs, tt.action.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/action/build_script.go b/internal/lefthook/runner/action/build_script.go new file mode 100644 index 00000000..2a504d05 --- /dev/null +++ b/internal/lefthook/runner/action/build_script.go @@ -0,0 +1,80 @@ +package action + +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) (*Action, 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 &Action{ + Execs: execs, + Files: []string{}, + }, nil +} diff --git a/internal/lefthook/runner/action/skip_error.go b/internal/lefthook/runner/action/skip_error.go new file mode 100644 index 00000000..66e68cd2 --- /dev/null +++ b/internal/lefthook/runner/action/skip_error.go @@ -0,0 +1,10 @@ +package action + +// 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/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_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_actions.go b/internal/lefthook/runner/run_actions.go new file mode 100644 index 00000000..770b7063 --- /dev/null +++ b/internal/lefthook/runner/run_actions.go @@ -0,0 +1,213 @@ +package runner + +import ( + "context" + "errors" + "path/filepath" + "strconv" + "sync" + "sync/atomic" + + "github.com/evilmartians/lefthook/internal/config" + "github.com/evilmartians/lefthook/internal/lefthook/runner/action" + "github.com/evilmartians/lefthook/internal/lefthook/runner/exec" + "github.com/evilmartians/lefthook/internal/lefthook/runner/filters" + "github.com/evilmartians/lefthook/internal/log" +) + +var ( + errActionContainsBothRunAndScript = errors.New("both `run` and `script` are not permitted") + errEmptyAction = errors.New("no execution instructions") + errEmptyGroup = errors.New("empty groups are not permitted") +) + +type domain struct { + failed atomic.Bool + glob string +} + +func (r *Runner) runActions(ctx context.Context) []Result { + var wg sync.WaitGroup + + results := make([]Result, 0, len(r.Hook.Actions)) + resultsChan := make(chan Result, len(r.Hook.Actions)) + domain := &domain{} + for i, action := range r.Hook.Actions { + id := strconv.Itoa(i) + + if domain.failed.Load() && r.Hook.Piped { + r.logSkip(action.PrintableName(id), "broken pipe") + continue + } + + if !r.Hook.Parallel { + results = append(results, r.runAction(ctx, domain, id, action)) + continue + } + + wg.Add(1) + go func(action *config.Action) { + defer wg.Done() + resultsChan <- r.runAction(ctx, domain, id, action) + }(action) + } + + wg.Wait() + close(resultsChan) + for result := range resultsChan { + results = append(results, result) + } + + return results +} + +func (r *Runner) runAction(ctx context.Context, domain *domain, id string, action *config.Action) Result { + // Check if do action is properly configured + if len(action.Run) > 0 && len(action.Script) > 0 { + return failed(action.PrintableName(id), errActionContainsBothRunAndScript.Error()) + } + if len(action.Run) == 0 && len(action.Script) == 0 && action.Group == nil { + return failed(action.PrintableName(id), errEmptyAction.Error()) + } + + if action.Interactive && !r.DisableTTY && !r.Hook.Follow { + log.StopSpinner() + defer log.StartSpinner() + } + + if len(action.Run) != 0 || len(action.Script) != 0 { + return r.runSingleAction(ctx, domain, id, action) + } + + if action.Group != nil { + return r.runGroup(ctx, id, action.Group) + } + + return failed(action.PrintableName(id), "don't know how to run action") +} + +func (r *Runner) runSingleAction(ctx context.Context, domain *domain, id string, act *config.Action) Result { + name := act.PrintableName(id) + + glob := act.Glob + if len(glob) == 0 { + glob = domain.glob + } + + runAction, err := action.New(name, &action.Params{ + Repo: r.Repo, + Hook: r.Hook, + HookName: r.HookName, + ForceFiles: r.Files, + Force: r.Force, + SourceDirs: r.SourceDirs, + GitArgs: r.GitArgs, + Run: act.Run, + Root: act.Root, + Runner: act.Runner, + Script: act.Script, + Glob: glob, + Files: act.Files, + FileTypes: act.FileTypes, + Tags: act.Tags, + Exclude: act.Exclude, + Only: act.Only, + Skip: act.Skip, + }) + if err != nil { + r.logSkip(name, err.Error()) + + var skipErr action.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, act.Root), + Commands: runAction.Execs, + Interactive: act.Interactive && !r.DisableTTY, + UseStdin: act.UseStdin, + Env: act.Env, + }, r.Hook.Follow) + + if !ok { + domain.failed.Store(true) + return failed(name, act.FailText) + } + + if config.HookUsesStagedFiles(r.HookName) && act.StageFixed { + files := runAction.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: act.Glob, + Root: act.Root, + Exclude: act.Exclude, + FileTypes: act.FileTypes, + }) + } + + if len(act.Root) > 0 { + for i, file := range files { + files[i] = filepath.Join(act.Root, file) + } + } + + r.addStagedFiles(files) + } + + return succeeded(name) +} + +func (r *Runner) runGroup(ctx context.Context, groupId string, group *config.Group) Result { + name := group.PrintableName(groupId) + + if len(group.Actions) == 0 { + return failed(name, errEmptyGroup.Error()) + } + + results := make([]Result, 0, len(group.Actions)) + resultsChan := make(chan Result, len(group.Actions)) + domain := &domain{glob: group.Glob} + var wg sync.WaitGroup + + for i, action := range group.Actions { + id := strconv.Itoa(i) + + if domain.failed.Load() && group.Piped { + r.logSkip(action.PrintableName(id), "broken pipe") + continue + } + + if !group.Parallel { + results = append(results, r.runAction(ctx, domain, id, action)) + continue + } + + wg.Add(1) + go func(action *config.Action) { + defer wg.Done() + resultsChan <- r.runAction(ctx, domain, id, action) + }(action) + } + + wg.Wait() + close(resultsChan) + for result := range resultsChan { + results = append(results, result) + } + + return groupResult(name, results) +} diff --git a/internal/lefthook/runner/runner.go b/internal/lefthook/runner/runner.go index 2ceb9692..452d2ca7 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" @@ -22,19 +21,14 @@ import ( "github.com/evilmartians/lefthook/internal/config" "github.com/evilmartians/lefthook/internal/git" + "github.com/evilmartians/lefthook/internal/lefthook/runner/action" "github.com/evilmartians/lefthook/internal/lefthook/runner/exec" "github.com/evilmartians/lefthook/internal/lefthook/runner/filters" "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,10 @@ func (r *Runner) RunAll(ctx context.Context, sourceDirs []string) ([]Result, err defer log.StopSpinner() } - scriptDirs := make([]string, 0, len(sourceDirs)) - for _, sourceDir := range sourceDirs { + results = append(results, r.runActions(ctx)...) + + scriptDirs := make([]string, 0, len(r.SourceDirs)) + for _, sourceDir := range r.SourceDirs { scriptDirs = append(scriptDirs, filepath.Join( sourceDir, r.HookName, )) @@ -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 { + scriptAction, err := action.New(file.Name(), &action.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 action.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: scriptAction.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) + runAction, err := action.New(name, &action.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 action.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: runAction.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 := runAction.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..b35523ea --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,23 @@ +skip_lfs: true + +pre-commit: + parallel: true + actions: + - group: + name: check code + glob: "*.go" + actions: + - run: make lint + stage_fixed: true + + - run: make test + + - run: lychee --max-concurrency 3 {staged_files} + name: check links + 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 a57cfb25..a13d3b45 100644 --- a/testdata/dump.txt +++ b/testdata/dump.txt @@ -42,6 +42,7 @@ colors: red: '#FF1493' yellow: '#F0E68C' pre-commit: + follow: true commands: lint: run: yarn lint {staged_files} @@ -54,7 +55,6 @@ pre-commit: run: yarn test skip: merge glob: '*.js' - follow: true -- lefthook-dumped.json -- { "colors": { @@ -65,6 +65,7 @@ pre-commit: "yellow": "#F0E68C" }, "pre-commit": { + "follow": true, "commands": { "lint": { "run": "yarn lint {staged_files}", @@ -82,8 +83,7 @@ pre-commit: "skip": "merge", "glob": "*.js" } - }, - "follow": true + } } } -- lefthook-dumped.toml -- 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: