From f4d8b2554a2b750fc2bb0781840ef8c726a0f9a2 Mon Sep 17 00:00:00 2001 From: prog-supdex Date: Mon, 19 Feb 2024 14:21:21 +0100 Subject: [PATCH] handle "run" command in the skip/only setting --- docs/configuration.md | 15 +++++++ internal/config/command.go | 3 +- internal/config/exec.go | 34 +++++++++++++++ internal/config/hook.go | 3 +- internal/config/script.go | 3 +- internal/config/skip.go | 83 +++++++++++++++++++++++++++--------- internal/config/skip_test.go | 28 +++++++++++- 7 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 internal/config/exec.go diff --git a/docs/configuration.md b/docs/configuration.md index 5c8bb04e..ff646b89 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -884,6 +884,21 @@ pre-commit: run: yarn test ``` +Skipping hook by running a command: + +```yml +# lefthook.yml + +pre-commit: + skip: + - run: test "${NO_HOOK}" -eq 1 + commands: + lint: + run: yarn lint + text: + run: yarn test +``` + **Notes** Always skipping is useful when you have a `lefthook-local.yml` config and you don't want to run some commands locally. So you just overwrite the `skip` option for them to be `true`. diff --git a/internal/config/command.go b/internal/config/command.go index 79a70f2e..084255d1 100644 --- a/internal/config/command.go +++ b/internal/config/command.go @@ -40,7 +40,8 @@ func (c Command) Validate() error { } func (c Command) DoSkip(gitState git.State) bool { - return doSkip(gitState, c.Skip, c.Only) + skipChecker := NewSkipChecker(nil) + return skipChecker.DoSkip(gitState, c.Skip, c.Only) } type commandRunReplace struct { diff --git a/internal/config/exec.go b/internal/config/exec.go new file mode 100644 index 00000000..7f82b8dc --- /dev/null +++ b/internal/config/exec.go @@ -0,0 +1,34 @@ +package config + +import ( + "os/exec" + "strings" +) + +type Exec interface { + Cmd(commandLine string) bool +} + +type osExec struct{} + +// NewOsExec returns an object that executes given commands in the OS. +func NewOsExec() Exec { + return &osExec{} +} + +// Cmd runs plain string command. It checks only exit code and returns bool value. +func (o *osExec) Cmd(commandLine string) bool { + parts := strings.Fields(commandLine) + + if len(parts) == 0 { + return false + } + + cmdName := parts[0] + cmdArgs := parts[1:] + + cmd := exec.Command(cmdName, cmdArgs...) + err := cmd.Run() + + return err == nil +} diff --git a/internal/config/hook.go b/internal/config/hook.go index 692cc5cb..94ba2ce0 100644 --- a/internal/config/hook.go +++ b/internal/config/hook.go @@ -43,7 +43,8 @@ func (h *Hook) Validate() error { } func (h *Hook) DoSkip(gitState git.State) bool { - return doSkip(gitState, h.Skip, h.Only) + skipChecker := NewSkipChecker(nil) + return skipChecker.DoSkip(gitState, h.Skip, h.Only) } func unmarshalHooks(base, extra *viper.Viper) (*Hook, error) { diff --git a/internal/config/script.go b/internal/config/script.go index a2070dbe..d212d5b9 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -24,7 +24,8 @@ type Script struct { } func (s Script) DoSkip(gitState git.State) bool { - return doSkip(gitState, s.Skip, s.Only) + skipChecker := NewSkipChecker(nil) + return skipChecker.DoSkip(gitState, s.Skip, s.Only) } type scriptRunnerReplace struct { diff --git a/internal/config/skip.go b/internal/config/skip.go index 9b52f3bd..eceb6993 100644 --- a/internal/config/skip.go +++ b/internal/config/skip.go @@ -4,47 +4,90 @@ import ( "github.com/gobwas/glob" "github.com/evilmartians/lefthook/internal/git" + "github.com/evilmartians/lefthook/internal/log" ) -func doSkip(gitState git.State, skip, only interface{}) bool { +type SkipChecker struct { + Executor Exec +} + +func NewSkipChecker(executor Exec) *SkipChecker { + if executor == nil { + executor = NewOsExec() + } + + return &SkipChecker{Executor: executor} +} + +func (sc *SkipChecker) DoSkip(gitState git.State, skip interface{}, only interface{}) bool { if skip != nil { - if matches(gitState, skip) { + if sc.matches(gitState, skip) { return true } } if only != nil { - return !matches(gitState, only) + return !sc.matches(gitState, only) } return false } -func matches(gitState git.State, value interface{}) bool { +func (sc *SkipChecker) matches(gitState git.State, value interface{}) bool { switch typedValue := value.(type) { case bool: return typedValue case string: return typedValue == gitState.Step case []interface{}: - for _, state := range typedValue { - switch typedState := state.(type) { - case string: - if typedState == gitState.Step { - return true - } - case map[string]interface{}: - ref := typedState["ref"].(string) - if ref == gitState.Branch { - return true - } - - g := glob.MustCompile(ref) - if g.Match(gitState.Branch) { - return true - } + return sc.matchesSlices(gitState, typedValue) + } + return false +} + +func (sc *SkipChecker) matchesSlices(gitState git.State, slice []interface{}) bool { + for _, state := range slice { + switch typedState := state.(type) { + case string: + if typedState == gitState.Step { + return true + } + case map[string]interface{}: + if sc.matchesRef(gitState, typedState) { + return true + } + + if sc.matchesCommands(typedState) { + return true } } } + return false } + +func (sc *SkipChecker) matchesRef(gitState git.State, typedState map[string]interface{}) bool { + ref, ok := typedState["ref"].(string) + if !ok { + return false + } + + if ref == gitState.Branch { + return true + } + + g := glob.MustCompile(ref) + + return g.Match(gitState.Branch) +} + +func (sc *SkipChecker) matchesCommands(typedState map[string]interface{}) bool { + commandLine, ok := typedState["run"].(string) + if !ok { + return false + } + + log.Debug("[lefthook] skip/only cmd: ", commandLine) + + return sc.Executor.Cmd(commandLine) +} diff --git a/internal/config/skip_test.go b/internal/config/skip_test.go index d4d3c6f3..9fe247da 100644 --- a/internal/config/skip_test.go +++ b/internal/config/skip_test.go @@ -6,7 +6,15 @@ import ( "github.com/evilmartians/lefthook/internal/git" ) +type mockExecutor struct{} + +func (mc mockExecutor) Cmd(cmd string) bool { + return cmd == "success" +} + func TestDoSkip(t *testing.T) { + skipChecker := NewSkipChecker(mockExecutor{}) + for _, tt := range [...]struct { name string state git.State @@ -111,9 +119,27 @@ func TestDoSkip(t *testing.T) { only: "rebase", skipped: true, }, + { + name: "when skip with run command", + state: git.State{}, + skip: []interface{}{map[string]interface{}{"run": "success"}}, + skipped: true, + }, + { + name: "when skip with multi-run command", + state: git.State{Branch: "feat"}, + skip: []interface{}{map[string]interface{}{"run": "success", "ref": "feat"}}, + skipped: true, + }, + { + name: "when only with run command", + state: git.State{}, + only: []interface{}{map[string]interface{}{"run": "fail"}}, + skipped: true, + }, } { t.Run(tt.name, func(t *testing.T) { - if doSkip(tt.state, tt.skip, tt.only) != tt.skipped { + if skipChecker.DoSkip(tt.state, tt.skip, tt.only) != tt.skipped { t.Errorf("Expected: %v, Was %v", tt.skipped, !tt.skipped) } })