diff --git a/.goreleaser.yml b/.goreleaser.yml index f8e5c7ca..7136f89e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -61,7 +61,7 @@ changelog: - '^spec:' - '^tmp:' - '^context:' - - '^\d\.\d\.\d:' + - '^\d+\.\d+\.\d+:' snapcrafts: - diff --git a/internal/config/command.go b/internal/config/command.go index f86d6d81..1e243da2 100644 --- a/internal/config/command.go +++ b/internal/config/command.go @@ -47,7 +47,7 @@ func (c Command) Validate() error { } func (c Command) DoSkip(gitState git.State) bool { - skipChecker := NewSkipChecker(system.Executor{}) + skipChecker := NewSkipChecker(system.Cmd) return skipChecker.check(gitState, c.Skip, c.Only) } diff --git a/internal/config/command_executor.go b/internal/config/command_executor.go index 859c539d..d181f440 100644 --- a/internal/config/command_executor.go +++ b/internal/config/command_executor.go @@ -1,21 +1,19 @@ package config import ( + "io" "runtime" -) -// Executor is a general execution interface for implicit commands. -type Executor interface { - Execute(args []string, root string) (string, error) -} + "github.com/evilmartians/lefthook/internal/system" +) // commandExecutor implements execution of a skip checks passed in a `run` option. type commandExecutor struct { - exec Executor + cmd system.Command } // cmd runs plain string command in a subshell returning the success of it. -func (c *commandExecutor) cmd(commandLine string) bool { +func (c *commandExecutor) execute(commandLine string) bool { if commandLine == "" { return false } @@ -27,7 +25,7 @@ func (c *commandExecutor) cmd(commandLine string) bool { args = []string{"sh", "-c", commandLine} } - _, err := c.exec.Execute(args, "") + err := c.cmd.Run(args, "", system.NullReader, io.Discard) return err == nil } diff --git a/internal/config/hook.go b/internal/config/hook.go index 64f065e9..f22290bc 100644 --- a/internal/config/hook.go +++ b/internal/config/hook.go @@ -44,7 +44,7 @@ func (h *Hook) Validate() error { } func (h *Hook) DoSkip(gitState git.State) bool { - skipChecker := NewSkipChecker(system.Executor{}) + skipChecker := NewSkipChecker(system.Cmd) return skipChecker.check(gitState, h.Skip, h.Only) } diff --git a/internal/config/script.go b/internal/config/script.go index 7b2b700e..bd3e1f8d 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -30,7 +30,7 @@ type scriptRunnerReplace struct { } func (s Script) DoSkip(gitState git.State) bool { - skipChecker := NewSkipChecker(system.Executor{}) + skipChecker := NewSkipChecker(system.Cmd) return skipChecker.check(gitState, s.Skip, s.Only) } diff --git a/internal/config/skip_checker.go b/internal/config/skip_checker.go index 009584fe..7b0d0907 100644 --- a/internal/config/skip_checker.go +++ b/internal/config/skip_checker.go @@ -5,14 +5,15 @@ import ( "github.com/evilmartians/lefthook/internal/git" "github.com/evilmartians/lefthook/internal/log" + "github.com/evilmartians/lefthook/internal/system" ) type skipChecker struct { exec *commandExecutor } -func NewSkipChecker(executor Executor) *skipChecker { - return &skipChecker{&commandExecutor{executor}} +func NewSkipChecker(cmd system.Command) *skipChecker { + return &skipChecker{&commandExecutor{cmd}} } // check returns the result of applying a skip/only setting which can be a branch, git state, shell command, etc. @@ -84,7 +85,7 @@ func (sc *skipChecker) matchesCommands(typedState map[string]interface{}) bool { return false } - result := sc.exec.cmd(commandLine) + result := sc.exec.execute(commandLine) log.Debugf("[lefthook] skip/only cmd: %s, result: %t", commandLine, result) diff --git a/internal/config/skip_checker_test.go b/internal/config/skip_checker_test.go index addac901..5b04f451 100644 --- a/internal/config/skip_checker_test.go +++ b/internal/config/skip_checker_test.go @@ -2,23 +2,24 @@ package config import ( "errors" + "io" "testing" "github.com/evilmartians/lefthook/internal/git" ) -type mockExecutor struct{} +type mockCmd struct{} -func (mc mockExecutor) Execute(args []string, _root string) (string, error) { - if len(args) == 3 && args[2] == "success" { - return "", nil +func (mc mockCmd) Run(cmd []string, _root string, _in io.Reader, _out io.Writer) error { + if len(cmd) == 3 && cmd[2] == "success" { + return nil } else { - return "", errors.New("failure") + return errors.New("failure") } } func TestDoSkip(t *testing.T) { - skipChecker := NewSkipChecker(mockExecutor{}) + skipChecker := NewSkipChecker(mockCmd{}) for _, tt := range [...]struct { name string diff --git a/internal/git/command_executor.go b/internal/git/command_executor.go index e71c3d70..e380dd00 100644 --- a/internal/git/command_executor.go +++ b/internal/git/command_executor.go @@ -1,33 +1,29 @@ package git import ( + "bytes" "fmt" "path/filepath" "strings" + "github.com/evilmartians/lefthook/internal/log" "github.com/evilmartians/lefthook/internal/system" ) -// Executor is a general execution interface for implicit commands. -// Added here mostly for mockable tests. -type Executor interface { - Execute(args []string, root string) (string, error) -} - // CommandExecutor provides some methods that take some effect on execution and/or result data. type CommandExecutor struct { - exec Executor + cmd system.Command root string } // NewExecutor returns an object that executes given commands in the OS. -func NewExecutor(exec Executor) *CommandExecutor { - return &CommandExecutor{exec: exec} +func NewExecutor(cmd system.Command) *CommandExecutor { + return &CommandExecutor{cmd: cmd} } // Cmd runs plain string command. Trims spaces around output. -func (c CommandExecutor) Cmd(args []string) (string, error) { - out, err := c.exec.Execute(args, c.root) +func (c CommandExecutor) Cmd(cmd []string) (string, error) { + out, err := c.execute(cmd, c.root) if err != nil { return "", err } @@ -55,7 +51,7 @@ func (c CommandExecutor) BatchedCmd(cmd []string, args []string) (string, error) // CmdLines runs plain string command, returns its output split by newline. func (c CommandExecutor) CmdLines(cmd []string) ([]string, error) { - out, err := c.exec.Execute(cmd, c.root) + out, err := c.execute(cmd, c.root) if err != nil { return nil, err } @@ -66,7 +62,7 @@ func (c CommandExecutor) CmdLines(cmd []string) ([]string, error) { // CmdLines runs plain string command, returns its output split by newline. func (c CommandExecutor) CmdLinesWithinFolder(cmd []string, folder string) ([]string, error) { root := filepath.Join(c.root, folder) - out, err := c.exec.Execute(cmd, root) + out, err := c.execute(cmd, root) if err != nil { return nil, err } @@ -74,6 +70,16 @@ func (c CommandExecutor) CmdLinesWithinFolder(cmd []string, folder string) ([]st return strings.Split(strings.TrimSpace(out), "\n"), nil } +func (c CommandExecutor) execute(cmd []string, root string) (string, error) { + out := bytes.NewBuffer(make([]byte, 0)) + err := c.cmd.Run(cmd, root, system.NullReader, out) + strOut := out.String() + + log.Debug("[lefthook] out: ", strOut) + + return strOut, err +} + func batchByLength(s []string, length int) [][]string { batches := make([][]string, 0) diff --git a/internal/git/repository_test.go b/internal/git/repository_test.go index 5a189bd8..3e2df8f2 100644 --- a/internal/git/repository_test.go +++ b/internal/git/repository_test.go @@ -3,21 +3,27 @@ package git import ( "errors" "fmt" + "io" "strings" "testing" ) -type GitMock struct { +type gitCmd struct { cases map[string]string } -func (g GitMock) Execute(cmd []string, _root string) (string, error) { +func (g gitCmd) Run(cmd []string, _root string, _in io.Reader, out io.Writer) error { res, ok := g.cases[(strings.Join(cmd, " "))] if !ok { - return "", errors.New("doesn't exist") + return errors.New("doesn't exist") } - return strings.TrimSpace(res), nil + _, err := out.Write([]byte(strings.TrimSpace(res))) + if err != nil { + return err + } + + return nil } func TestPartiallyStagedFiles(t *testing.T) { @@ -37,7 +43,7 @@ MM staged but changed t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { repository := &Repository{ Git: &CommandExecutor{ - exec: GitMock{ + cmd: gitCmd{ cases: map[string]string{ "git status --short --porcelain": tt.gitOut, }, diff --git a/internal/lefthook/lefthook.go b/internal/lefthook/lefthook.go index e688231a..6c81317e 100644 --- a/internal/lefthook/lefthook.go +++ b/internal/lefthook/lefthook.go @@ -51,7 +51,7 @@ func initialize(opts *Options) (*Lefthook, error) { log.SetColors(!opts.NoColors) - repo, err := git.NewRepository(opts.Fs, git.NewExecutor(system.Executor{})) + repo, err := git.NewRepository(opts.Fs, git.NewExecutor(system.Cmd)) if err != nil { return nil, err } diff --git a/internal/lefthook/run_test.go b/internal/lefthook/run_test.go index b605cefc..0a223919 100644 --- a/internal/lefthook/run_test.go +++ b/internal/lefthook/run_test.go @@ -2,6 +2,7 @@ package lefthook import ( "fmt" + "io" "path/filepath" "slices" "testing" @@ -11,10 +12,10 @@ import ( "github.com/evilmartians/lefthook/internal/git" ) -type GitMock struct{} +type gitCmd struct{} -func (g GitMock) Execute(_cmd []string, root string) (string, error) { - return "", nil +func (g gitCmd) Run([]string, string, io.Reader, io.Writer) error { + return nil } func TestRun(t *testing.T) { @@ -157,7 +158,7 @@ post-commit: Options: &Options{Fs: fs}, repo: &git.Repository{ Fs: fs, - Git: git.NewExecutor(GitMock{}), + Git: git.NewExecutor(gitCmd{}), HooksPath: hooksPath, RootPath: root, GitPath: gitPath, diff --git a/internal/lefthook/runner/exec/execute_unix.go b/internal/lefthook/runner/exec/execute_unix.go index 874aa169..ccaa9a9b 100644 --- a/internal/lefthook/runner/exec/execute_unix.go +++ b/internal/lefthook/runner/exec/execute_unix.go @@ -68,16 +68,6 @@ func (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader return nil } -func (e CommandExecutor) RawExecute(ctx context.Context, command []string, in io.Reader, out io.Writer) error { - cmd := exec.CommandContext(ctx, command[0], command[1:]...) - - cmd.Stdin = in - cmd.Stdout = out - cmd.Stderr = os.Stderr - - return cmd.Run() -} - func (e CommandExecutor) execute(ctx context.Context, cmdstr string, args *executeArgs) error { command := exec.CommandContext(ctx, "sh", "-c", cmdstr) command.Dir = args.root diff --git a/internal/lefthook/runner/exec/execute_windows.go b/internal/lefthook/runner/exec/execute_windows.go index 752a264f..47d6bc1c 100644 --- a/internal/lefthook/runner/exec/execute_windows.go +++ b/internal/lefthook/runner/exec/execute_windows.go @@ -59,16 +59,6 @@ func (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader return nil } -func (e CommandExecutor) RawExecute(ctx context.Context, command []string, in io.Reader, out io.Writer) error { - cmd := exec.CommandContext(ctx, command[0], command[1:]...) - - cmd.Stdin = in - cmd.Stdout = out - cmd.Stderr = os.Stderr - - return cmd.Run() -} - func (e CommandExecutor) execute(cmdstr string, args *executeArgs) error { cmdargs := strings.Split(cmdstr, " ") command := exec.Command(cmdargs[0]) diff --git a/internal/lefthook/runner/exec/executor.go b/internal/lefthook/runner/exec/executor.go index 12ec9a39..520536ad 100644 --- a/internal/lefthook/runner/exec/executor.go +++ b/internal/lefthook/runner/exec/executor.go @@ -16,6 +16,5 @@ type Options struct { // Executor provides an interface for command execution. // It is used here for testing purpose mostly. type Executor interface { - Execute(ctx context.Context, opts Options, in io.Reader, out io.Writer) error - RawExecute(ctx context.Context, command []string, in io.Reader, out io.Writer) error + Execute(context.Context, Options, io.Reader, io.Writer) error } diff --git a/internal/lefthook/runner/runner.go b/internal/lefthook/runner/runner.go index e1095f4e..312219fa 100644 --- a/internal/lefthook/runner/runner.go +++ b/internal/lefthook/runner/runner.go @@ -25,6 +25,7 @@ import ( "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 ( @@ -55,6 +56,7 @@ type Runner struct { partiallyStagedFiles []string failed atomic.Bool executor exec.Executor + cmd system.CommandWithContext } func New(opts Options) *Runner { @@ -65,6 +67,7 @@ func New(opts Options) *Runner { // and scripts access the same Git data STDIN is cached via cachedReader. stdin: NewCachedReader(os.Stdin), executor: exec.CommandExecutor{}, + cmd: system.Cmd, } } @@ -143,12 +146,13 @@ func (r *Runner) runLFSHook(ctx context.Context) error { "[git-lfs] executing hook: git lfs %s %s", r.HookName, strings.Join(r.GitArgs, " "), ) out := bytes.NewBuffer(make([]byte, 0)) - err := r.executor.RawExecute( + err := r.cmd.RunWithContext( ctx, append( []string{"git", "lfs", r.HookName}, r.GitArgs..., ), + "", r.stdin, out, ) @@ -497,7 +501,7 @@ func (r *Runner) run(ctx context.Context, opts exec.Options, follow bool) bool { defer log.UnsetName(opts.Name) // If the command does not explicitly `use_stdin` no input will be provided. - var in io.Reader = NewNullReader() + var in io.Reader = system.NullReader if opts.UseStdin { in = r.stdin } diff --git a/internal/lefthook/runner/runner_test.go b/internal/lefthook/runner/runner_test.go index 16888a81..295be378 100644 --- a/internal/lefthook/runner/runner_test.go +++ b/internal/lefthook/runner/runner_test.go @@ -19,9 +19,16 @@ import ( "github.com/evilmartians/lefthook/internal/log" ) -type TestExecutor struct{} +type ( + executor struct{} + cmd struct{} + gitCmd struct { + mux sync.Mutex + commands []string + } +) -func (e TestExecutor) Execute(_ctx context.Context, opts exec.Options, _in io.Reader, _out io.Writer) (err error) { +func (e executor) Execute(_ctx context.Context, opts exec.Options, _in io.Reader, _out io.Writer) (err error) { if strings.HasPrefix(opts.Commands[0], "success") { err = nil } else { @@ -31,16 +38,11 @@ func (e TestExecutor) Execute(_ctx context.Context, opts exec.Options, _in io.Re return } -func (e TestExecutor) RawExecute(_ctx context.Context, _command []string, _in io.Reader, _out io.Writer) error { +func (e cmd) RunWithContext(context.Context, []string, string, io.Reader, io.Writer) error { return nil } -type GitMock struct { - mux sync.Mutex - commands []string -} - -func (g *GitMock) Execute(cmd []string, _root string) (string, error) { +func (g *gitCmd) Run(cmd []string, _root string, _in io.Reader, out io.Writer) error { g.mux.Lock() g.commands = append(g.commands, strings.Join(cmd, " ")) g.mux.Unlock() @@ -49,16 +51,19 @@ func (g *GitMock) Execute(cmd []string, _root string) (string, error) { if cmdLine == "git diff --name-only --cached --diff-filter=ACMR" || cmdLine == "git diff --name-only HEAD @{push}" { root, _ := filepath.Abs("src") - return strings.Join([]string{ + _, err := out.Write([]byte(strings.Join([]string{ filepath.Join(root, "scripts", "script.sh"), filepath.Join(root, "README.md"), - }, "\n"), nil + }, "\n"))) + if err != nil { + return err + } } - return "", nil + return nil } -func (g *GitMock) reset() { +func (g *gitCmd) reset() { g.mux.Lock() g.commands = []string{} g.mux.Unlock() @@ -70,7 +75,7 @@ func TestRunAll(t *testing.T) { t.Errorf("unexpected error: %s", err) } - gitExec := &GitMock{} + gitExec := &gitCmd{} gitPath := filepath.Join(root, ".git") repo := &git.Repository{ Git: git.NewExecutor(gitExec), @@ -736,7 +741,6 @@ func TestRunAll(t *testing.T) { } { fs := afero.NewMemMapFs() repo.Fs = fs - executor := TestExecutor{} runner := &Runner{ Options: Options{ Repo: repo, @@ -746,7 +750,8 @@ func TestRunAll(t *testing.T) { GitArgs: tt.args, Force: tt.force, }, - executor: executor, + executor: executor{}, + cmd: cmd{}, } gitExec.reset() diff --git a/internal/lefthook/runner/null_reader.go b/internal/system/null_reader.go similarity index 72% rename from internal/lefthook/runner/null_reader.go rename to internal/system/null_reader.go index caf5a568..2b38daca 100644 --- a/internal/lefthook/runner/null_reader.go +++ b/internal/system/null_reader.go @@ -1,13 +1,11 @@ -package runner +package system import "io" // nullReader always returns `io.EOF`. type nullReader struct{} -func NewNullReader() io.Reader { - return nullReader{} -} +var NullReader = nullReader{} // Implements io.Reader interface. func (nullReader) Read(b []byte) (int, error) { diff --git a/internal/lefthook/runner/null_reader_test.go b/internal/system/null_reader_test.go similarity index 73% rename from internal/lefthook/runner/null_reader_test.go rename to internal/system/null_reader_test.go index df4fd06e..ee0387e4 100644 --- a/internal/lefthook/runner/null_reader_test.go +++ b/internal/system/null_reader_test.go @@ -1,4 +1,4 @@ -package runner +package system import ( "bytes" @@ -7,9 +7,7 @@ import ( ) func TestNullReader(t *testing.T) { - nullReader := NewNullReader() - - res, err := io.ReadAll(nullReader) + res, err := io.ReadAll(NullReader) if err != nil { t.Errorf("unexpected err: %s", err) } diff --git a/internal/system/system.go b/internal/system/system.go index 892ecaed..ce5bf82b 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -1,33 +1,58 @@ -// system package contains OS-specific implementations. +// system package contains wrappers for OS interactions. package system import ( + "context" + "io" "os" "os/exec" "github.com/evilmartians/lefthook/internal/log" ) -type Executor struct{} +type osCmd struct{} -// Execute executes git command with LEFTHOOK=0 in order -// to prevent calling subsequent lefthook hooks. -func (e Executor) Execute(args []string, root string) (string, error) { - log.Debug("[lefthook] cmd: ", args) +var Cmd = osCmd{} - cmd := exec.Command(args[0], args[1:]...) +type Command interface { + Run([]string, string, io.Reader, io.Writer) error +} + +type CommandWithContext interface { + RunWithContext(context.Context, []string, string, io.Reader, io.Writer) error +} + +// Run runs system command with LEFTHOOK=0 in order to prevent calling +// subsequent lefthook hooks. +func (c osCmd) RunWithContext(ctx context.Context, command []string, root string, in io.Reader, out io.Writer) error { + log.Debug("[lefthook] cmd: ", command) + + cmd := exec.CommandContext(ctx, command[0], command[1:]...) + + return run(cmd, root, in, out) +} + +func (c osCmd) Run(command []string, root string, in io.Reader, out io.Writer) error { + log.Debug("[lefthook] cmd: ", command) + + cmd := exec.Command(command[0], command[1:]...) + + return run(cmd, root, in, out) +} + +func run(cmd *exec.Cmd, root string, in io.Reader, out io.Writer) error { cmd.Env = append(os.Environ(), "LEFTHOOK=0") if len(root) > 0 { cmd.Dir = root + log.Debug("[lefthook] dir: ", root) } - out, err := cmd.CombinedOutput() - log.Debug("[lefthook] dir: ", root) + cmd.Stdin = in + cmd.Stdout = out + cmd.Stderr = os.Stderr + + err := cmd.Run() log.Debug("[lefthook] err: ", err) - log.Debug("[lefthook] out: ", string(out)) - if err != nil { - return "", err - } - return string(out), nil + return err }