Repository: evilmartians/lefthook (language: python)

Issue:
Using conditions for Skip and Only
### :zap: Summary

Implementing conditional support for the `skip` and `only` options would improve control over hook execution and performance by skipping unnecessary steps and providing a flexible solution to meet different project requirements.

### Value

Implementing a feature to use conditions for the `skip` and `only` options can provide several benefits:

- **Selective Execution:** Conditions allow developers to selectively execute hooks based on specific criteria, enabling more granular control over when scripts run. This can be useful for running scripts only on certain branches, commit messages, or file changes.
- **Optimized Workflow:** With conditional execution, you can optimize the workflow by skipping unnecessary steps in certain scenarios. For instance, you might want to skip certain checks on feature branches or only run specific tasks on the main branch.
- **Customizable Automation:** Conditions provide a way to customize automation based on project requirements. This flexibility allows teams to tailor their hook scripts to fit the unique needs of their development process.
- **Shared Configurations**: makes it easier to integrate with shared remote configurations that could be more generic and customizable.
- **Enhanced Code Quality:** By selectively applying hooks based on conditions such as file paths or commit messages, you can enforce coding standards and perform checks relevant to the changes being made. This contributes to maintaining and enhancing overall code quality.
- **Improved Performance:** Skipping unnecessary hooks can improve performance by reducing the execution time of CI/CD processes. This is crucial for maintaining efficient and speedy development pipelines.

Incorporating these features enhances the adaptability and efficiency of Lefthook, empowering teams to build a more tailored and effective continuous integration environment.

### Behavior and configuration changes

Below is a small example of how the configuration could look using this new feature

```yaml
pre-commit:
  commands:
    sast:
      only:
        - run: test -n "${USER}" && test -n "${PASS}"
      glob: "*.go"
      run: my_sast_tool --user "${USER}" --pass "${PASS}" {staged_files}
    dast:
      skip:
        - run: test "${NO_DAST}" -eq 1
        - run: npx is-ci
        - ref: dev/*
      glob: "*.go"
      run: my_dast_tool {staged_files}
```


Discussion / hints:

Fix patch:
diff --git a/docs/configuration.md b/docs/configuration.md
index 0d1f3db9..4e9f2f02 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -922,6 +922,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..1d47ac9a 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(NewOsExec())
+	return skipChecker.Check(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..87971979
--- /dev/null
+++ b/internal/config/exec.go
@@ -0,0 +1,35 @@
+package config
+
+import (
+	"os/exec"
+	"runtime"
+)
+
+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 {
+	if commandLine == "" {
+		return false
+	}
+
+	var cmd *exec.Cmd
+	if runtime.GOOS == "windows" {
+		cmd = exec.Command("powershell", "-Command", commandLine)
+	} else {
+		cmd = exec.Command("sh", "-c", commandLine)
+	}
+
+	err := cmd.Run()
+
+	return err == nil
+}
diff --git a/internal/config/hook.go b/internal/config/hook.go
index 692cc5cb..3a958c72 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(NewOsExec())
+	return skipChecker.Check(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..085b65f7 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(NewOsExec())
+	return skipChecker.Check(gitState, s.Skip, s.Only)
 }
 
 type scriptRunnerReplace struct {
diff --git a/internal/config/skip.go b/internal/config/skip.go
deleted file mode 100644
index 9b52f3bd..00000000
--- a/internal/config/skip.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package config
-
-import (
-	"github.com/gobwas/glob"
-
-	"github.com/evilmartians/lefthook/internal/git"
-)
-
-func doSkip(gitState git.State, skip, only interface{}) bool {
-	if skip != nil {
-		if matches(gitState, skip) {
-			return true
-		}
-	}
-
-	if only != nil {
-		return !matches(gitState, only)
-	}
-
-	return false
-}
-
-func 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 false
-}
diff --git a/internal/config/skip_checker.go b/internal/config/skip_checker.go
new file mode 100644
index 00000000..581610f7
--- /dev/null
+++ b/internal/config/skip_checker.go
@@ -0,0 +1,95 @@
+package config
+
+import (
+	"github.com/gobwas/glob"
+
+	"github.com/evilmartians/lefthook/internal/git"
+	"github.com/evilmartians/lefthook/internal/log"
+)
+
+type SkipChecker struct {
+	Executor Exec
+}
+
+func NewSkipChecker(executor Exec) *SkipChecker {
+	if executor == nil {
+		executor = NewOsExec()
+	}
+
+	return &SkipChecker{Executor: executor}
+}
+
+func (sc *SkipChecker) Check(gitState git.State, skip interface{}, only interface{}) bool {
+	if skip != nil {
+		if sc.matches(gitState, skip) {
+			return true
+		}
+	}
+
+	if only != nil {
+		return !sc.matches(gitState, only)
+	}
+
+	return false
+}
+
+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{}:
+		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
+	}
+
+	result := sc.Executor.Cmd(commandLine)
+
+	log.Debugf("[lefthook] skip/only cmd: %s, result: %t", commandLine, result)
+
+	return result
+}


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e78f6669..50c65239 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -32,7 +32,7 @@ jobs:
     strategy:
       matrix:
         go-version: [1.21.x]
-        os: [ubuntu-latest, macos-latest]
+        os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     env:
       GOCOVERDIR: ${{ github.workspace }}/_icoverdir_
@@ -91,5 +91,5 @@ jobs:
     steps:
       - uses: coverallsapp/github-action@v2
         with:
-          carryforward: "integration-1.21.x ubuntu-latest,integration-1.21.x macos-latest,1.21.x ubuntu-latest,1.21.x macos-latest,1.21.x windows-latest"
+          carryforward: "integration-1.21.x ubuntu-latest,integration-1.21.x macos-latest,integration-1.21.x windows-latest,1.21.x ubuntu-latest,1.21.x macos-latest,1.21.x windows-latest"
           parallel-finished: true
diff --git a/internal/config/skip_test.go b/internal/config/skip_checker_test.go
similarity index 79%
rename from internal/config/skip_test.go
rename to internal/config/skip_checker_test.go
index d4d3c6f3..4956859f 100644
--- a/internal/config/skip_test.go
+++ b/internal/config/skip_checker_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.Check(tt.state, tt.skip, tt.only) != tt.skipped {
 				t.Errorf("Expected: %v, Was %v", tt.skipped, !tt.skipped)
 			}
 		})
diff --git a/testdata/add.txt b/testdata/add.txt
index 4aa86233..da9e0e95 100644
--- a/testdata/add.txt
+++ b/testdata/add.txt
@@ -1,3 +1,5 @@
+[windows] skip
+
 exec git init
 exec lefthook add pre-commit
 ! stderr .
diff --git a/testdata/dump.txt b/testdata/dump.txt
index 3857f0d4..5a5559e3 100644
--- a/testdata/dump.txt
+++ b/testdata/dump.txt
@@ -1,3 +1,5 @@
+[windows] skip
+
 exec git init
 exec lefthook dump
 cmp stdout lefthook-dumped.yml
diff --git a/testdata/hide_unstaged.txt b/testdata/hide_unstaged.txt
index 4147604e..5aa67510 100644
--- a/testdata/hide_unstaged.txt
+++ b/testdata/hide_unstaged.txt
@@ -1,3 +1,5 @@
+[windows] skip
+
 exec git init
 exec lefthook install
 exec git config user.email "you@example.com"
diff --git a/testdata/remote.txt b/testdata/remote.txt
index 72435af1..df0c246b 100644
--- a/testdata/remote.txt
+++ b/testdata/remote.txt
@@ -1,3 +1,5 @@
+[windows] skip
+
 exec git init
 exec lefthook install
 
diff --git a/testdata/remotes.txt b/testdata/remotes.txt
index 7858b6e3..beffc20f 100644
--- a/testdata/remotes.txt
+++ b/testdata/remotes.txt
@@ -1,3 +1,5 @@
+[windows] skip
+
 exec git init
 exec lefthook install
 
diff --git a/testdata/run_interrupt.txt b/testdata/run_interrupt.txt
index 9103d014..2716c53d 100644
--- a/testdata/run_interrupt.txt
+++ b/testdata/run_interrupt.txt
@@ -1,3 +1,5 @@
+[windows] skip
+
 chmod 0700 hook.sh
 chmod 0700 commit-with-interrupt.sh
 exec git init
diff --git a/testdata/sh_syntax_in_files.txt b/testdata/sh_syntax_in_files.txt
index a45c9a25..b65a7cb7 100644
--- a/testdata/sh_syntax_in_files.txt
+++ b/testdata/sh_syntax_in_files.txt
@@ -1,9 +1,12 @@
+[windows] skip
+
 exec git init
 exec lefthook install
 exec git config user.email "you@example.com"
 exec git config user.name "Your Name"
 
 exec lefthook run echo_files
+
 stdout '1.txt 10.txt'
 
 -- lefthook.yml --
diff --git a/testdata/skip_run.txt b/testdata/skip_run.txt
new file mode 100644
index 00000000..7e2af672
--- /dev/null
+++ b/testdata/skip_run.txt
@@ -0,0 +1,42 @@
+[windows] skip
+
+exec git init
+exec git add -A
+exec lefthook run skip
+! stdout 'Ha-ha!'
+exec lefthook run no-skip
+stdout 'Ha-ha!'
+
+exec lefthook run skip-var
+! stdout 'Ha-ha!'
+
+env VAR=1
+exec lefthook run skip-var
+stdout 'Ha-ha!'
+
+-- lefthook.yml --
+skip_output:
+  - skips
+  - meta
+  - summary
+  - execution_info
+skip:
+  skip:
+    - run: test 1 -eq 1
+  commands:
+    echo:
+      run: echo 'Ha-ha!'
+
+no-skip:
+  skip:
+    - run: "[ 1 -eq 0 ]"
+  commands:
+    echo:
+      run: echo 'Ha-ha!'
+
+skip-var:
+  skip:
+    - run: test -z $VAR
+  commands:
+    echo:
+      run: echo 'Ha-ha!'
diff --git a/testdata/skip_run_windows.txt b/testdata/skip_run_windows.txt
new file mode 100644
index 00000000..f5d6fd82
--- /dev/null
+++ b/testdata/skip_run_windows.txt
@@ -0,0 +1,42 @@
+[!windows] skip
+
+exec git init
+exec git add -A
+exec lefthook run skip
+! stdout 'Ha-ha!'
+exec lefthook run no-skip
+stdout 'Ha-ha!'
+
+exec lefthook run skip-var
+! stdout 'Ha-ha!'
+
+env VAR=1
+exec lefthook run skip-var
+stdout 'Ha-ha!'
+
+-- lefthook.yml --
+skip_output:
+  - skips
+  - meta
+  - summary
+  - execution_info
+skip:
+  skip:
+    - run: if (1 -eq 1) { exit 0 } else { exit 1 }
+  commands:
+    echo:
+      run: echo 'Ha-ha!'
+
+no-skip:
+  skip:
+    - run: if (1 -eq 0) { exit 0 } else { exit 1 }
+  commands:
+    echo:
+      run: echo 'Ha-ha!'
+
+skip-var:
+  skip:
+    - run: if ([string]::IsNullOrEmpty($env:VAR)) { exit 0 } else { exit 1 }
+  commands:
+    echo:
+      run: echo 'Ha-ha!'