Repository: evilmartians/lefthook (language: python)

Issue:
`--files` path separator  not specified, whitespace doesn't work so it doesn't process globs
### :wrench: Summary

`--files` is supposed to take a list of file paths, but it isn't specified what the separator between these paths should be. A pretty common use case is glob patterns in a shell. Those don't work, only the first path is taken. 
### Lefthook version

<!-- The result of `lefthook version -f` -->

1.5.2

### Steps to reproduce

<!-- All bug reports should include detailed steps to reproduce -->

```yml
pre-push:
  commands: &pre-push
    bandit:
      files: git ls-files pyproject.toml
      glob: '*.py'
      run: echo bandit --configfile pyproject.toml --quiet -- {staged_files}
```

```console
$ echo **.py
src/__version__.py src/package/__main__.py src/package/__init__.py
```

```console
$ lefthook run pre-push --commands bandit --files src/**.py --force
╭────────────────────────────────────╮
│ 🥊 lefthook v1.5.2  hook: pre-push │
╰────────────────────────────────────╯
┃  bandit ❯ 

bandit --configfile pyproject.toml --quiet -- src/__version__.py
```

### Expected results

Bandit is being run with all paths matched by the glob.

### Actual results

Bandit is being run with the first path matched by the glob.

### Possible Solution

<!--- Optionally suggest a fix or reason for the bug -->
Reuse `$IFS` is the input separator under Unix, a similar variable or whitespace under Windows, and offer a `-0` option to use the null byte as file path separator instead.

### Logs / Screenshots

<!--- If the error can be reproduced via GUI or CLI command, please send the logs. Make sure you have set LEFTHOOK_VERBOSE=true before running the command -->

```bash
LEFTHOOK_VERBOSE=true git ...
```



Discussion / hints:
Thank you for the issue. I noticed that in the library that parses the arguments the `,` separator is used. I will try to find a solution. But as a workaround you can somehow join the files with `,`, for example:

```bash
$ lefthook run pre-push --commands bandit --files "$(ls config/*.yml | tr '\n' ',' | sed 's/,$/\n/')" --force
```
@mrexox Thanks. It's not a work-around applicable to us, since we author Lefthook-configurations that are cross-platform.
@mrexox Would it be possible to change the comma separator to the zero byte? That solves a lot of the issue, and interoperates with Git subcommands and Unix commands that support a `-z` option, to  use the null byte to separate paths.
@sanmai-NL , lefthook uses a library that parses commands, and it seems like commas are hard-coded as a separator: https://github.com/spf13/pflag/blob/d5e0c0615acee7028e1e2740a11102313be88de1/string_slice.go#L113-L122

That's because it uses CSV reader: https://github.com/spf13/pflag/blob/d5e0c0615acee7028e1e2740a11102313be88de1/string_slice.go#L43.  It will require to change the commands parsing library to allow zero byte separated values. I'm afraid, I don't have enough capacity for such change.

Fix patch:
diff --git a/cmd/run.go b/cmd/run.go
index d8f45f91..bc73829e 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/evilmartians/lefthook/internal/config"
 	"github.com/evilmartians/lefthook/internal/lefthook"
+	"github.com/evilmartians/lefthook/internal/log"
 )
 
 func newRunCmd(opts *lefthook.Options) *cobra.Command {
@@ -57,9 +58,31 @@ func newRunCmd(opts *lefthook.Options) *cobra.Command {
 		"run hooks on all files",
 	)
 
-	runCmd.Flags().StringSliceVar(&runArgs.Files, "files", nil, "run on specified files. takes precedence over --all-files")
+	runCmd.Flags().BoolVar(
+		&runArgs.FilesFromStdin, "files-from-stdin", false,
+		"get files from standard input, null- or \\n-separated",
+	)
+
+	runCmd.Flags().StringSliceVar(
+		&runArgs.Files, "files", nil,
+		"run on specified files, comma-separated",
+	)
+
+	runCmd.Flags().StringArrayVar(
+		&runArgs.Files, "file", nil,
+		"run on specified file (repeat for multiple files). takes precedence over --all-files",
+	)
+
+	runCmd.Flags().StringSliceVar(
+		&runArgs.RunOnlyCommands, "commands", nil,
+		"run only specified commands",
+	)
+
+	err := runCmd.Flags().MarkDeprecated("files", "use --file flag instead")
+	if err != nil {
+		log.Warn("Unexpected error:", err)
+	}
 
-	runCmd.Flags().StringSliceVar(&runArgs.RunOnlyCommands, "commands", nil, "run only specified commands")
 	_ = runCmd.RegisterFlagCompletionFunc("commands", runHookCommandCompletions)
 
 	return &runCmd
diff --git a/docs/usage.md b/docs/usage.md
index fcfae0e1..3907ca1f 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -134,7 +134,7 @@ and optionally run either on all files (any `{staged_files}` placeholder acts as
 
 ```bash
 $ lefthook run pre-commit --all-files
-$ lefthook run pre-commit --files file1.js,file2.js
+$ lefthook run pre-commit --file file1.js --file file2.js
 ```
 
 (if both are specified, `--all-files` is ignored)
diff --git a/internal/config/files.go b/internal/config/files.go
index ba9f1da5..82e70c4e 100644
--- a/internal/config/files.go
+++ b/internal/config/files.go
@@ -6,11 +6,11 @@ const (
 	SubFiles       string = "{files}"
 	SubAllFiles    string = "{all_files}"
 	SubStagedFiles string = "{staged_files}"
-	PushFiles      string = "{push_files}"
+	SubPushFiles   string = "{push_files}"
 )
 
 func isRunnerFilesCompatible(runner string) bool {
-	if strings.Contains(runner, SubStagedFiles) && strings.Contains(runner, PushFiles) {
+	if strings.Contains(runner, SubStagedFiles) && strings.Contains(runner, SubPushFiles) {
 		return false
 	}
 	return true
diff --git a/internal/lefthook/run.go b/internal/lefthook/run.go
index da16fcb6..2b548a7b 100644
--- a/internal/lefthook/run.go
+++ b/internal/lefthook/run.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"os"
 	"os/signal"
 	"path/filepath"
@@ -24,6 +25,7 @@ const (
 type RunArgs struct {
 	NoTTY           bool
 	AllFiles        bool
+	FilesFromStdin  bool
 	Force           bool
 	Files           []string
 	RunOnlyCommands []string
@@ -110,6 +112,20 @@ Run 'lefthook install' manually.`,
 	startTime := time.Now()
 	resultChan := make(chan run.Result, len(hook.Commands)+len(hook.Scripts))
 
+	if args.FilesFromStdin {
+		paths, err := io.ReadAll(os.Stdin)
+		if err != nil {
+			return fmt.Errorf("failed to read the files from standard input: %w", err)
+		}
+		args.Files = append(args.Files, parseFilesFromString(string(paths))...)
+	} else if args.AllFiles {
+		files, err := l.repo.AllFiles()
+		if err != nil {
+			return fmt.Errorf("failed to get all files: %w", err)
+		}
+		args.Files = append(args.Files, files...)
+	}
+
 	runner := run.NewRunner(
 		run.Options{
 			Repo:            l.repo,
@@ -119,7 +135,6 @@ Run 'lefthook install' manually.`,
 			ResultChan:      resultChan,
 			SkipSettings:    logSettings,
 			DisableTTY:      cfg.NoTTY || args.NoTTY,
-			AllFiles:        args.AllFiles,
 			Files:           args.Files,
 			Force:           args.Force,
 			RunOnlyCommands: args.RunOnlyCommands,
@@ -276,3 +291,17 @@ func (l *Lefthook) configHookCommandCompletions(hookName string) []string {
 		return commands
 	}
 }
+
+// parseFilesFromString parses both `\0`- and `\n`-separated files.
+func parseFilesFromString(paths string) []string {
+	var result []string
+	start := 0
+	for i, c := range paths {
+		if c == 0 || c == '\n' {
+			result = append(result, paths[start:i])
+			start = i + 1
+		}
+	}
+	result = append(result, paths[start:])
+	return result
+}
diff --git a/internal/lefthook/run/prepare_command.go b/internal/lefthook/run/prepare_command.go
index c984a624..2c6bef7d 100644
--- a/internal/lefthook/run/prepare_command.go
+++ b/internal/lefthook/run/prepare_command.go
@@ -74,20 +74,20 @@ func (r *Runner) buildRun(command *config.Command) (*run, error, error) {
 	}
 
 	var stagedFiles func() ([]string, error)
-	switch {
-	case len(r.Files) > 0:
+	var pushFiles func() ([]string, error)
+	var allFiles func() ([]string, error)
+	var cmdFiles func() ([]string, error)
+
+	if len(r.Files) > 0 {
 		stagedFiles = func() ([]string, error) { return r.Files, nil }
-	case r.AllFiles:
-		stagedFiles = r.Repo.AllFiles
-	default:
+		pushFiles = stagedFiles
+		allFiles = stagedFiles
+		cmdFiles = stagedFiles
+	} else {
 		stagedFiles = r.Repo.StagedFiles
-	}
-
-	filesFns := map[string]func() ([]string, error){
-		config.SubStagedFiles: stagedFiles,
-		config.PushFiles:      r.Repo.PushFiles,
-		config.SubAllFiles:    r.Repo.AllFiles,
-		config.SubFiles: func() ([]string, error) {
+		pushFiles = r.Repo.PushFiles
+		allFiles = r.Repo.AllFiles
+		cmdFiles = func() ([]string, error) {
 			var cmd []string
 			if runtime.GOOS == "windows" {
 				cmd = strings.Split(filesCmd, " ")
@@ -95,7 +95,14 @@ func (r *Runner) buildRun(command *config.Command) (*run, error, error) {
 				cmd = []string{"sh", "-c", filesCmd}
 			}
 			return r.Repo.FilesByCommand(cmd)
-		},
+		}
+	}
+
+	filesFns := map[string]func() ([]string, error){
+		config.SubStagedFiles: stagedFiles,
+		config.SubPushFiles:   pushFiles,
+		config.SubAllFiles:    allFiles,
+		config.SubFiles:       cmdFiles,
 	}
 
 	templates := make(map[string]*template)
@@ -167,7 +174,7 @@ func (r *Runner) buildRun(command *config.Command) (*run, error, error) {
 	}
 
 	if config.HookUsesPushFiles(r.HookName) {
-		ok, err := canSkipCommand(command, templates[config.PushFiles], r.Repo.PushFiles)
+		ok, err := canSkipCommand(command, templates[config.SubPushFiles], r.Repo.PushFiles)
 		if err != nil {
 			return nil, err, nil
 		}
diff --git a/internal/lefthook/run/runner.go b/internal/lefthook/run/runner.go
index 3ccd3c52..17fada29 100644
--- a/internal/lefthook/run/runner.go
+++ b/internal/lefthook/run/runner.go
@@ -45,7 +45,6 @@ type Options struct {
 	ResultChan      chan Result
 	SkipSettings    log.SkipSettings
 	DisableTTY      bool
-	AllFiles        bool
 	Force           bool
 	Files           []string
 	RunOnlyCommands []string


Test patch:
diff --git a/testdata/files_override.txt b/testdata/files_override.txt
new file mode 100644
index 00000000..d5032dcd
--- /dev/null
+++ b/testdata/files_override.txt
@@ -0,0 +1,42 @@
+exec git init
+exec lefthook install
+exec git add -A
+
+exec lefthook run echo
+stdout 'a-file\.js'
+
+exec lefthook run echo --all-files
+stdout 'a-file\.js b_file\.go c,file\.rb'
+
+exec lefthook run echo --file a-file.js --file ghost.file
+stdout 'a-file\.js ghost\.file'
+
+stdin b_file.go
+exec lefthook run echo --files-from-stdin
+stdout 'b_file\.go c,file\.rb'
+
+stdin b_file.go
+exec lefthook run echo --files-from-stdin --file ghost.file
+stdout 'ghost\.file b_file\.go c,file\.rb'
+
+-- lefthook.yml --
+skip_output:
+  - meta
+  - execution_info
+  - summary
+
+echo:
+  commands:
+    echo:
+      files: echo a-file.js
+      run: echo "{files}"
+
+-- a-file.js --
+a-file.js
+
+-- b_file.go --
+b_file.go
+c,file.rb
+
+-- c,file.rb --
+c,file.rb