diff --git a/.golangci.yml b/.golangci.yml index 631872c0..ce406f26 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,36 +9,50 @@ linters-settings: linters: disable-all: true enable: - - bodyclose - - depguard - - dogsled - - dupl - - errcheck - - exhaustive - - exportloopref - - gci - - goconst - - gocyclo - - gocyclo - - godot - - godox - - gofmt - - gofumpt - - goimports - - gomnd - - goprintffuncname - - gosimple - - govet - - ineffassign - - misspell - - nakedret - - nestif - - noctx - - nolintlint - - revive - - staticcheck - - typecheck - - unconvert - - unparam - - unused - - whitespace + - asasalint + - asciicheck + - bidichk + - bodyclose + - containedctx + - contextcheck + - decorder + - depguard + - dogsled + - dupl + - dupword + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - exportloopref + - forbidigo + - gci + - gochecknoinits + - goconst + - gocritic + - gocyclo + - godot + - godox + - gofmt + - gofumpt + - goheader + - goimports + - gomnd + - goprintffuncname + - gosimple + - govet + - ineffassign + - misspell + - nakedret + - nestif + - noctx + - nolintlint + - revive + - staticcheck + - typecheck + - unconvert + - unparam + - unused + - whitespace diff --git a/internal/config/available_hooks.go b/internal/config/available_hooks.go index 6bc782d7..6633948f 100644 --- a/internal/config/available_hooks.go +++ b/internal/config/available_hooks.go @@ -39,6 +39,10 @@ var AvailableHooks = [...]string{ "sendemail-validate", } +func HookUsesStagedFiles(hook string) bool { + return hook == "pre-commit" +} + func HookAvailable(hook string) bool { for _, name := range AvailableHooks { if name == hook { diff --git a/internal/config/command.go b/internal/config/command.go index 6e9558e0..78feb709 100644 --- a/internal/config/command.go +++ b/internal/config/command.go @@ -96,7 +96,7 @@ func mergeCommands(base, extra *viper.Viper) (map[string]*Command, error) { for key, replace := range runReplaces { if replace.Run != "" { - commands[key].Run = strings.Replace(commands[key].Run, CMD, replace.Run, -1) + commands[key].Run = strings.ReplaceAll(commands[key].Run, CMD, replace.Run) } } diff --git a/internal/config/load.go b/internal/config/load.go index a9b231be..d09fc77b 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "path/filepath" "regexp" @@ -82,7 +83,8 @@ func mergeAll(fs afero.Fs, repo *git.Repository) (*viper.Viper, error) { } if err := merge("lefthook-local", "", extends); err != nil { - if _, notFoundErr := err.(viper.ConfigFileNotFoundError); !notFoundErr { + var notFoundErr viper.ConfigFileNotFoundError + if ok := errors.As(err, ¬FoundErr); !ok { return nil, err } } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 0d920943..b98ebf88 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -416,11 +416,9 @@ pre-push: if err != nil { t.Errorf("should parse configs without errors: %s", err) - } else { - if !cmp.Equal(checkConfig, tt.result, cmpopts.IgnoreUnexported(Hook{})) { - t.Errorf("configs should be equal") - t.Errorf("(-want +got):\n%s", cmp.Diff(tt.result, checkConfig)) - } + } else if !cmp.Equal(checkConfig, tt.result, cmpopts.IgnoreUnexported(Hook{})) { + t.Errorf("configs should be equal") + t.Errorf("(-want +got):\n%s", cmp.Diff(tt.result, checkConfig)) } }) } diff --git a/internal/config/script.go b/internal/config/script.go index bf9496e2..587a076d 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -78,7 +78,7 @@ func mergeScripts(base, extra *viper.Viper) (map[string]*Script, error) { for key, replace := range runReplaces { if replace.Runner != "" { - scripts[key].Runner = strings.Replace(scripts[key].Runner, CMD, replace.Runner, -1) + scripts[key].Runner = strings.ReplaceAll(scripts[key].Runner, CMD, replace.Runner) } } diff --git a/internal/git/exec.go b/internal/git/exec.go new file mode 100644 index 00000000..20d90c5a --- /dev/null +++ b/internal/git/exec.go @@ -0,0 +1,68 @@ +package git + +import ( + "os" + "os/exec" + "strings" +) + +type Exec interface { + Cmd(cmd string) (string, error) + CmdArgs(args ...string) (string, error) + CmdLines(cmd string) ([]string, error) + RawCmd(cmd string) (string, error) +} + +type OsExec struct{} + +// NewOsExec returns an object that executes given commands +// in the OS. +func NewOsExec() *OsExec { + return &OsExec{} +} + +// Cmd runs plain string command. Trims spaces around output. +func (o *OsExec) Cmd(cmd string) (string, error) { + args := strings.Split(cmd, " ") + return o.CmdArgs(args...) +} + +// CmdLines runs plain string command, returns its output split by newline. +func (o *OsExec) CmdLines(cmd string) ([]string, error) { + out, err := o.RawCmd(cmd) + if err != nil { + return nil, err + } + + return strings.Split(out, "\n"), nil +} + +// CmdArgs runs a command provided with separted words. Trims spaces around output. +func (o *OsExec) CmdArgs(args ...string) (string, error) { + out, err := o.rawExecArgs(args...) + if err != nil { + return "", err + } + + return strings.TrimSpace(out), nil +} + +// RawCmd runs a plain string command returning unprocessed output as string. +func (o *OsExec) RawCmd(cmd string) (string, error) { + args := strings.Split(cmd, " ") + return o.rawExecArgs(args...) +} + +// rawExecArgs executes git command with LEFTHOOK=0 in order +// to prevent calling subsequent lefthook hooks. +func (o *OsExec) rawExecArgs(args ...string) (string, error) { + cmd := exec.Command(args[0], args[1:]...) + cmd.Env = append(os.Environ(), "LEFTHOOK=0") + + out, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + + return string(out), nil +} diff --git a/internal/git/remote.go b/internal/git/remote.go index 115b8aeb..11c31e1e 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -68,20 +68,22 @@ func (r *Repository) updateRemote(path, ref string) error { log.Debugf("Updating remote config repository: %s", path) if len(ref) != 0 { - cmdFetch := []string{"git", "-C", path, "fetch", "--quiet", "--depth", "1", "origin", ref} - _, err := execGit(strings.Join(cmdFetch, " ")) + _, err := r.Git.CmdArgs( + "git", "-C", path, "fetch", "--quiet", "--depth", "1", + "origin", ref, + ) if err != nil { return err } - cmdFetch = []string{"git", "-C", path, "checkout", "FETCH_HEAD"} - _, err = execGit(strings.Join(cmdFetch, " ")) + _, err = r.Git.CmdArgs( + "git", "-C", path, "checkout", "FETCH_HEAD", + ) if err != nil { return err } } else { - cmdFetch := []string{"git", "-C", path, "pull", "--quiet"} - _, err := execGit(strings.Join(cmdFetch, " ")) + _, err := r.Git.CmdArgs("git", "-C", path, "pull", "--quiet") if err != nil { return err } @@ -99,7 +101,7 @@ func (r *Repository) cloneRemote(path, url, ref string) error { } cmdClone = append(cmdClone, url) - _, err := execGit(strings.Join(cmdClone, " ")) + _, err := r.Git.CmdArgs(cmdClone...) if err != nil { return err } diff --git a/internal/git/repository.go b/internal/git/repository.go index 5d3d2c80..ba70d5cf 100644 --- a/internal/git/repository.go +++ b/internal/git/repository.go @@ -2,9 +2,8 @@ package git import ( "os" - "os/exec" "path/filepath" - "runtime" + "regexp" "strings" "github.com/spf13/afero" @@ -15,29 +14,38 @@ const ( cmdHooksPath = "git rev-parse --git-path hooks" cmdInfoPath = "git rev-parse --git-path info" cmdGitPath = "git rev-parse --git-dir" - cmdStagedFiles = "git diff --name-only --cached" + cmdStagedFiles = "git diff --name-only --cached --diff-filter=ACMR" cmdAllFiles = "git ls-files --cached" cmdPushFiles = "git diff --name-only HEAD @{push} || git diff --name-only HEAD master" - infoDirMode = 0o775 + cmdStatusShort = "git status --short" + cmdCreateStash = "git stash create" + cmdListStash = "git stash list" + + stashMessage = "lefthook auto backup" + unstagedPatchName = "lefthook-unstaged.patch" + infoDirMode = 0o775 + minStatusLen = 3 ) // Repository represents a git repository. type Repository struct { - Fs afero.Fs - HooksPath string - RootPath string - GitPath string - InfoPath string + Fs afero.Fs + Git Exec + HooksPath string + RootPath string + GitPath string + InfoPath string + unstagedPatchPath string } // NewRepository returns a Repository or an error, if git repository it not initialized. -func NewRepository(fs afero.Fs) (*Repository, error) { - rootPath, err := execGit(cmdRootPath) +func NewRepository(fs afero.Fs, git Exec) (*Repository, error) { + rootPath, err := git.Cmd(cmdRootPath) if err != nil { return nil, err } - hooksPath, err := execGit(cmdHooksPath) + hooksPath, err := git.Cmd(cmdHooksPath) if err != nil { return nil, err } @@ -45,7 +53,7 @@ func NewRepository(fs afero.Fs) (*Repository, error) { hooksPath = filepath.Join(rootPath, hooksPath) } - infoPath, err := execGit(cmdInfoPath) + infoPath, err := git.Cmd(cmdInfoPath) if err != nil { return nil, err } @@ -57,7 +65,7 @@ func NewRepository(fs afero.Fs) (*Repository, error) { } } - gitPath, err := execGit(cmdGitPath) + gitPath, err := git.Cmd(cmdGitPath) if err != nil { return nil, err } @@ -66,11 +74,13 @@ func NewRepository(fs afero.Fs) (*Repository, error) { } return &Repository{ - Fs: fs, - HooksPath: hooksPath, - RootPath: rootPath, - GitPath: gitPath, - InfoPath: infoPath, + Fs: fs, + Git: git, + HooksPath: hooksPath, + RootPath: rootPath, + GitPath: gitPath, + InfoPath: infoPath, + unstagedPatchPath: filepath.Join(infoPath, unstagedPatchName), }, nil } @@ -92,22 +102,157 @@ func (r *Repository) PushFiles() ([]string, error) { return r.FilesByCommand(cmdPushFiles) } -// FilesByCommand accepts git command and returns its result as a list of filepaths. -func (r *Repository) FilesByCommand(command string) ([]string, error) { - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - commandArg := strings.Split(command, " ") - cmd = exec.Command(commandArg[0], commandArg[1:]...) - } else { - cmd = exec.Command("sh", "-c", command) +// PartiallyStagedFiles returns the list of files that have both staged and +// unstaged changes. +// See https://git-scm.com/docs/git-status#_short_format. +func (r *Repository) PartiallyStagedFiles() ([]string, error) { + lines, err := r.Git.CmdLines(cmdStatusShort) + if err != nil { + return []string{}, err + } + + partiallyStaged := make([]string, 0) + + for _, line := range lines { + if len(line) < minStatusLen { + continue + } + + index := line[0] + workingTree := line[1] + + filename := line[3:] + idx := strings.Index(filename, "->") + if idx != -1 { + filename = filename[idx+3:] + } + + if index != ' ' && index != '?' && workingTree != ' ' && workingTree != '?' && len(filename) > 0 { + partiallyStaged = append(partiallyStaged, filename) + } + } + + return partiallyStaged, nil +} + +func (r *Repository) SaveUnstaged(files []string) error { + _, err := r.Git.CmdArgs( + append([]string{ + "git", + "diff", + "--binary", // support binary files + "--unified=0", // do not add lines around diff for consistent behavior + "--no-color", // disable colors for consistent behavior + "--no-ext-diff", // disable external diff tools for consistent behavior + "--src-prefix=a/", // force prefix for consistent behavior + "--dst-prefix=b/", // force prefix for consistent behavior + "--patch", // output a patch that can be applied + "--submodule=short", // always use the default short format for submodules + "--output", + r.unstagedPatchPath, + "--", + }, files...)..., + ) + + return err +} + +func (r *Repository) HideUnstaged(files []string) error { + _, err := r.Git.CmdArgs( + append([]string{ + "git", + "checkout", + "--force", + "--", + }, files...)..., + ) + + return err +} + +func (r *Repository) RestoreUnstaged() error { + if ok, _ := afero.Exists(r.Fs, r.unstagedPatchPath); !ok { + return nil + } + + _, err := r.Git.CmdArgs( + "git", + "apply", + "-v", + "--whitespace=nowarn", + "--recount", + "--unidiff-zero", + r.unstagedPatchPath, + ) + + if err == nil { + err = r.Fs.Remove(r.unstagedPatchPath) + } + + return err +} + +func (r *Repository) StashUnstaged() error { + stashHash, err := r.Git.Cmd(cmdCreateStash) + if err != nil { + return err } - outputBytes, err := cmd.CombinedOutput() + _, err = r.Git.CmdArgs( + "git", + "stash", + "store", + "--quiet", + "--message", + stashMessage, + stashHash, + ) if err != nil { - return []string{}, err + return err + } + + return nil +} + +func (r *Repository) DropUnstagedStash() error { + lines, err := r.Git.CmdLines(cmdListStash) + if err != nil { + return err + } + + stashRegexp := regexp.MustCompile(`^(?P[^ ]+):\s*` + stashMessage) + for i := range lines { + line := lines[len(lines)-i-1] + matches := stashRegexp.FindStringSubmatch(line) + if len(matches) == 0 { + continue + } + + stashID := stashRegexp.SubexpIndex("stash") + + if len(matches[stashID]) > 0 { + _, err := r.Git.CmdArgs( + "git", + "stash", + "drop", + "--quiet", + matches[stashID], + ) + if err != nil { + return err + } + } } - lines := strings.Split(string(outputBytes), "\n") + return nil +} + +// FilesByCommand accepts git command and returns its result as a list of filepaths. +func (r *Repository) FilesByCommand(command string) ([]string, error) { + lines, err := r.Git.CmdLines(command) + if err != nil { + return nil, err + } return r.extractFiles(lines) } @@ -144,15 +289,3 @@ func (r *Repository) isFile(path string) (bool, error) { return !stat.IsDir(), nil } - -func execGit(command string) (string, error) { - args := strings.Split(command, " ") - cmd := exec.Command(args[0], args[1:]...) - - out, err := cmd.CombinedOutput() - if err != nil { - return "", err - } - - return strings.TrimSpace(string(out)), nil -} diff --git a/internal/git/repository_test.go b/internal/git/repository_test.go new file mode 100644 index 00000000..0eb9199e --- /dev/null +++ b/internal/git/repository_test.go @@ -0,0 +1,84 @@ +package git + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +type GitMock struct { + cases map[string]string +} + +func (g GitMock) Cmd(cmd string) (string, error) { + res, err := g.RawCmd(cmd) + if err != nil { + return "", err + } + + return strings.TrimSpace(res), nil +} + +func (g GitMock) CmdArgs(args ...string) (string, error) { + return g.Cmd(strings.Join(args, " ")) +} + +func (g GitMock) CmdLines(cmd string) ([]string, error) { + res, err := g.Cmd(cmd) + if err != nil { + return nil, err + } + + return strings.Split(res, "\n"), nil +} + +func (g GitMock) RawCmd(cmd string) (string, error) { + res, ok := g.cases[cmd] + if !ok { + return "", errors.New("doesn't exist") + } + + return res, nil +} + +func TestPartiallyStagedFiles(t *testing.T) { + for i, tt := range [...]struct { + name, gitOut string + error bool + result []string + }{ + { + gitOut: `RM old-file -> new file +M staged +MM staged but changed +`, + result: []string{"new file", "staged but changed"}, + }, + } { + t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { + repository := &Repository{ + Git: GitMock{ + cases: map[string]string{ + "git status --short": tt.gitOut, + }, + }, + } + + files, err := repository.PartiallyStagedFiles() + if tt.error && err != nil { + t.Errorf("expected an error") + } + + if len(files) != len(tt.result) { + t.Errorf("expected %d files, but %d returned", len(tt.result), len(files)) + } + + for j, file := range files { + if tt.result[j] != file { + t.Errorf("file at index %d don't match: %s - %s", j, tt.result[j], file) + } + } + }) + } +} diff --git a/internal/lefthook/lefthook.go b/internal/lefthook/lefthook.go index fa9ae97b..236efc66 100644 --- a/internal/lefthook/lefthook.go +++ b/internal/lefthook/lefthook.go @@ -41,7 +41,7 @@ func initialize(opts *Options) (*Lefthook, error) { log.SetColors(!opts.NoColors) - repo, err := git.NewRepository(opts.Fs) + repo, err := git.NewRepository(opts.Fs, git.NewOsExec()) if err != nil { return nil, err } diff --git a/internal/lefthook/run.go b/internal/lefthook/run.go index 2477f1c0..eb5c180a 100644 --- a/internal/lefthook/run.go +++ b/internal/lefthook/run.go @@ -107,6 +107,7 @@ Run 'lefthook install' manually.`, Fs: l.Fs, Repo: l.repo, Hook: hook, + HookName: hookName, GitArgs: gitArgs, ResultChan: resultChan, SkipSettings: logSettings, @@ -131,7 +132,7 @@ Run 'lefthook install' manually.`, } go func() { - run.RunAll(hookName, sourceDirs) + run.RunAll(sourceDirs) close(resultChan) }() diff --git a/internal/lefthook/run_test.go b/internal/lefthook/run_test.go index fb19180b..659da059 100644 --- a/internal/lefthook/run_test.go +++ b/internal/lefthook/run_test.go @@ -10,6 +10,24 @@ import ( "github.com/evilmartians/lefthook/internal/git" ) +type GitMock struct{} + +func (g GitMock) Cmd(cmd string) (string, error) { + return "", nil +} + +func (g GitMock) CmdArgs(args ...string) (string, error) { + return "", nil +} + +func (g GitMock) CmdLines(cmd string) ([]string, error) { + return nil, nil +} + +func (g GitMock) RawCmd(cmd string) (string, error) { + return "", nil +} + func TestRun(t *testing.T) { root, err := filepath.Abs("src") if err != nil { @@ -140,6 +158,7 @@ pre-commit: Options: &Options{Fs: fs}, repo: &git.Repository{ Fs: fs, + Git: GitMock{}, HooksPath: hooksPath, RootPath: root, GitPath: gitPath, diff --git a/internal/lefthook/runner/execute_unix.go b/internal/lefthook/runner/execute_unix.go index f578a2ee..328f965e 100644 --- a/internal/lefthook/runner/execute_unix.go +++ b/internal/lefthook/runner/execute_unix.go @@ -4,7 +4,6 @@ package runner import ( - "bytes" "fmt" "io" "os" @@ -73,13 +72,11 @@ func (e CommandExecutor) Execute(opts ExecuteOptions, out io.Writer) error { return command.Wait() } -func (e CommandExecutor) RawExecute(command string, args ...string) (*bytes.Buffer, error) { - cmd := exec.Command(command, args...) +func (e CommandExecutor) RawExecute(command []string, out io.Writer) error { + cmd := exec.Command(command[0], command[1:]...) - var out bytes.Buffer - - cmd.Stdout = &out + cmd.Stdout = out cmd.Stderr = os.Stderr - return &out, cmd.Run() + return cmd.Run() } diff --git a/internal/lefthook/runner/execute_windows.go b/internal/lefthook/runner/execute_windows.go index 92a91f9f..d443ccf3 100644 --- a/internal/lefthook/runner/execute_windows.go +++ b/internal/lefthook/runner/execute_windows.go @@ -1,7 +1,6 @@ package runner import ( - "bytes" "fmt" "io" "os" @@ -50,13 +49,11 @@ func (e CommandExecutor) Execute(opts ExecuteOptions, out io.Writer) error { return command.Wait() } -func (e CommandExecutor) RawExecute(command string, args ...string) (*bytes.Buffer, error) { - cmd := exec.Command(command, args...) +func (e CommandExecutor) RawExecute(command []string, out io.Writer) error { + cmd := exec.Command(command[0], command[1:]...) - var out bytes.Buffer - - cmd.Stdout = &out + cmd.Stdout = out cmd.Stderr = os.Stderr - return &out, cmd.Run() + return cmd.Run() } diff --git a/internal/lefthook/runner/executor.go b/internal/lefthook/runner/executor.go index 7843c754..40bf1ccc 100644 --- a/internal/lefthook/runner/executor.go +++ b/internal/lefthook/runner/executor.go @@ -1,7 +1,6 @@ package runner import ( - "bytes" "io" ) @@ -17,5 +16,5 @@ type ExecuteOptions struct { // It is used here for testing purpose mostly. type Executor interface { Execute(opts ExecuteOptions, out io.Writer) error - RawExecute(command string, args ...string) (*bytes.Buffer, error) + RawExecute(command []string, out io.Writer) error } diff --git a/internal/lefthook/runner/prepare_command.go b/internal/lefthook/runner/prepare_command.go new file mode 100644 index 00000000..f52d1c3f --- /dev/null +++ b/internal/lefthook/runner/prepare_command.go @@ -0,0 +1,147 @@ +package runner + +import ( + "errors" + "fmt" + "strings" + + "gopkg.in/alessio/shellescape.v1" + + "github.com/evilmartians/lefthook/internal/config" + "github.com/evilmartians/lefthook/internal/log" +) + +func (r *Runner) prepareCommand(name string, command *config.Command) ([]string, error) { + if command.Skip != nil && command.DoSkip(r.Repo.State()) { + return nil, errors.New("settings") + } + + if intersect(r.Hook.ExcludeTags, command.Tags) { + return nil, errors.New("tags") + } + + if intersect(r.Hook.ExcludeTags, []string{name}) { + return nil, errors.New("name") + } + + if err := command.Validate(); err != nil { + r.fail(name, "") + return nil, errors.New("invalid conig") + } + + args, err := r.buildCommandArgs(command) + if err != nil { + log.Error(err) + return nil, errors.New("error") + } + if len(args) == 0 { + return nil, errors.New("no files for inspection") + } + + return args, nil +} + +func (r *Runner) buildCommandArgs(command *config.Command) ([]string, error) { + filesCommand := r.Hook.Files + if command.Files != "" { + filesCommand = command.Files + } + + filesTypeToFn := map[string]func() ([]string, error){ + config.SubStagedFiles: r.Repo.StagedFiles, + config.PushFiles: r.Repo.PushFiles, + config.SubAllFiles: r.Repo.AllFiles, + config.SubFiles: func() ([]string, error) { + return r.Repo.FilesByCommand(filesCommand) + }, + } + + runString := command.Run + for filesType, filesFn := range filesTypeToFn { + // Checking substitutions and skipping execution if it is empty. + // + // Special case - `files` option: return if the result of files + // command is empty. + if strings.Contains(runString, filesType) || + filesCommand != "" && filesType == config.SubFiles { + files, err := filesFn() + if err != nil { + return nil, fmt.Errorf("error replacing %s: %w", filesType, err) + } + if len(files) == 0 { + return nil, nil + } + + filesPrepared := prepareFiles(command, files) + if len(filesPrepared) == 0 { + return nil, nil + } + + runString = replaceQuoted(runString, filesType, filesPrepared) + } + } + + runString = strings.ReplaceAll(runString, "{0}", strings.Join(r.GitArgs, " ")) + for i, gitArg := range r.GitArgs { + runString = strings.ReplaceAll(runString, fmt.Sprintf("{%d}", i+1), gitArg) + } + + log.Debug("[lefthook] executing: ", runString) + + return strings.Split(runString, " "), nil +} + +func prepareFiles(command *config.Command, files []string) []string { + if files == nil { + return []string{} + } + + log.Debug("[lefthook] files before filters:\n", files) + + files = filterGlob(files, command.Glob) + files = filterExclude(files, command.Exclude) + files = filterRelative(files, command.Root) + + log.Debug("[lefthook] files after filters:\n", files) + + // Escape file names to prevent unexpected bugs + var filesEsc []string + for _, fileName := range files { + if len(fileName) > 0 { + filesEsc = append(filesEsc, shellescape.Quote(fileName)) + } + } + + log.Debug("[lefthook] files after escaping:\n", filesEsc) + + return filesEsc +} + +func replaceQuoted(source, substitution string, files []string) string { + for _, elem := range [][]string{ + {"\"", "\"" + substitution + "\""}, + {"'", "'" + substitution + "'"}, + {"", substitution}, + } { + quote := elem[0] + sub := elem[1] + if !strings.Contains(source, sub) { + continue + } + + quotedFiles := files + if len(quote) != 0 { + quotedFiles = make([]string, 0, len(files)) + for _, fileName := range files { + quotedFiles = append(quotedFiles, + quote+surroundingQuotesRegexp.ReplaceAllString(fileName, "$1")+quote) + } + } + + source = strings.ReplaceAll( + source, sub, strings.Join(quotedFiles, " "), + ) + } + + return source +} diff --git a/internal/lefthook/runner/prepare_script.go b/internal/lefthook/runner/prepare_script.go new file mode 100644 index 00000000..eeb2e174 --- /dev/null +++ b/internal/lefthook/runner/prepare_script.go @@ -0,0 +1,45 @@ +package runner + +import ( + "errors" + "os" + "strings" + + "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.Skip != nil && script.DoSkip(r.Repo.State()) { + return nil, errors.New("settings") + } + + if intersect(r.Hook.ExcludeTags, script.Tags) { + return nil, errors.New("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 nil, errors.New("not a regular file") + } + + // Make sure file is executable + if (file.Mode() & executableMask) == 0 { + if err := r.Fs.Chmod(path, executableFileMode); err != nil { + log.Errorf("Couldn't change file mode to make file executable: %s", err) + r.fail(file.Name(), "") + return nil, errors.New("system error") + } + } + + var args []string + if len(script.Runner) > 0 { + args = strings.Split(script.Runner, " ") + } + + args = append(args, path) + args = append(args, r.GitArgs...) + + return args, nil +} diff --git a/internal/lefthook/runner/runner.go b/internal/lefthook/runner/runner.go index a3157dc8..bfa57ce5 100644 --- a/internal/lefthook/runner/runner.go +++ b/internal/lefthook/runner/runner.go @@ -13,7 +13,6 @@ import ( "sync/atomic" "github.com/spf13/afero" - "gopkg.in/alessio/shellescape.v1" "github.com/evilmartians/lefthook/internal/config" "github.com/evilmartians/lefthook/internal/git" @@ -33,6 +32,7 @@ type Opts struct { Fs afero.Fs Repo *git.Repository Hook *config.Hook + HookName string GitArgs []string ResultChan chan Result SkipSettings log.SkipSettings @@ -43,8 +43,9 @@ type Opts struct { type Runner struct { Opts - failed atomic.Bool - executor Executor + partiallyStagedFiles []string + failed atomic.Bool + executor Executor } func NewRunner(opts Opts) *Runner { @@ -56,13 +57,13 @@ func NewRunner(opts Opts) *Runner { // RunAll runs scripts and commands. // LFS hook is executed at first if needed. -func (r *Runner) RunAll(hookName string, sourceDirs []string) { - if err := r.runLFSHook(hookName); err != nil { +func (r *Runner) RunAll(sourceDirs []string) { + if err := r.runLFSHook(); err != nil { log.Error(err) } if r.Hook.Skip != nil && r.Hook.DoSkip(r.Repo.State()) { - logSkip(hookName, "(SKIP BY HOOK SETTING)") + logSkip(r.HookName, "hook setting") return } @@ -74,15 +75,19 @@ func (r *Runner) RunAll(hookName string, sourceDirs []string) { scriptDirs := make([]string, len(sourceDirs)) for _, sourceDir := range sourceDirs { scriptDirs = append(scriptDirs, filepath.Join( - sourceDir, hookName, + sourceDir, r.HookName, )) } + r.preHook() + for _, dir := range scriptDirs { r.runScripts(dir) } r.runCommands() + + r.postHook() } func (r *Runner) fail(name, text string) { @@ -94,8 +99,8 @@ func (r *Runner) success(name string) { r.ResultChan <- resultSuccess(name) } -func (r *Runner) runLFSHook(hookName string) error { - if !git.IsLFSHook(hookName) { +func (r *Runner) runLFSHook() error { + if !git.IsLFSHook(r.HookName) { return nil } @@ -113,14 +118,15 @@ func (r *Runner) runLFSHook(hookName string) error { if git.IsLFSAvailable() { log.Debugf( - "[git-lfs] executing hook: git lfs %s %s", hookName, strings.Join(r.GitArgs, " "), + "[git-lfs] executing hook: git lfs %s %s", r.HookName, strings.Join(r.GitArgs, " "), ) - out, err := r.executor.RawExecute( - "git", + out := bytes.NewBuffer(make([]byte, 0)) + err := r.executor.RawExecute( append( - []string{"lfs", hookName}, + []string{"git", "lfs", r.HookName}, r.GitArgs..., - )..., + ), + out, ) output := strings.Trim(out.String(), "\n") @@ -136,8 +142,8 @@ func (r *Runner) runLFSHook(hookName string) error { } if err != nil && (requiredExists || configExists) { - log.Warn(output) - return fmt.Errorf("git-lfs command failed: %w", err) + log.Warnf("git-lfs command failed: %s\n", output) + return err } return nil @@ -145,7 +151,7 @@ func (r *Runner) runLFSHook(hookName string) error { if requiredExists || configExists { log.Errorf( - "This repository requires Git LFS, but 'git-lfs' wasn't found.\n"+ + "This Repository requires Git LFS, but 'git-lfs' wasn't found.\n"+ "Install 'git-lfs' or consider reviewing the files:\n"+ " - %s\n"+ " - %s\n", @@ -157,6 +163,56 @@ func (r *Runner) runLFSHook(hookName string) error { return nil } +func (r *Runner) preHook() { + if !config.HookUsesStagedFiles(r.HookName) { + return + } + + partiallyStagedFiles, err := r.Repo.PartiallyStagedFiles() + if err != nil { + log.Warnf("Couldn't find partially staged files: %s\n", err) + return + } + + if len(partiallyStagedFiles) == 0 { + return + } + + log.Debug("[lefthook] saving partially staged files") + + r.partiallyStagedFiles = partiallyStagedFiles + err = r.Repo.SaveUnstaged(r.partiallyStagedFiles) + if err != nil { + log.Warnf("Couldn't save unstaged changes: %s\n", err) + return + } + + err = r.Repo.StashUnstaged() + if err != nil { + log.Warnf("Couldn't stash partially staged files: %s\n", err) + return + } + + err = r.Repo.HideUnstaged(r.partiallyStagedFiles) + if err != nil { + log.Warnf("Couldn't hide unstaged files: %s\n", err) + return + } + + log.Debugf("[lefthook] hide partially staged files: %v\n", r.partiallyStagedFiles) +} + +func (r *Runner) postHook() { + if err := r.Repo.RestoreUnstaged(); err != nil { + log.Warnf("Couldn't restore hidden unstaged files: %s\n", err) + return + } + + if err := r.Repo.DropUnstagedStash(); err != nil { + log.Warnf("Couldn't remove unstaged files backup: %s\n", err) + } +} + func (r *Runner) runScripts(dir string) { files, err := afero.ReadDir(r.Fs, dir) // ReadDir already sorts files by .Name() if err != nil || len(files) == 0 { @@ -169,12 +225,12 @@ func (r *Runner) runScripts(dir string) { for _, file := range files { script, ok := r.Hook.Scripts[file.Name()] if !ok { - logSkip(file.Name(), "(SKIP BY NOT EXIST IN CONFIG)") + logSkip(file.Name(), "not specified in config file") continue } if r.failed.Load() && r.Hook.Piped { - logSkip(file.Name(), "(SKIP BY BROKEN PIPE)") + logSkip(file.Name(), "broken pipe") continue } @@ -201,7 +257,7 @@ func (r *Runner) runScripts(dir string) { for _, file := range interactiveScripts { script := r.Hook.Scripts[file.Name()] if r.failed.Load() { - logSkip(file.Name(), "(SKIP INTERACTIVE BY FAILED)") + logSkip(file.Name(), "non-interactive scripts failed") continue } @@ -212,39 +268,12 @@ func (r *Runner) runScripts(dir string) { } func (r *Runner) runScript(script *config.Script, path string, file os.FileInfo) { - if script.Skip != nil && script.DoSkip(r.Repo.State()) { - logSkip(file.Name(), "(SKIP BY SETTINGS)") - return - } - - if intersect(r.Hook.ExcludeTags, script.Tags) { - logSkip(file.Name(), "(SKIP BY TAGS)") - return - } - - // 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()) + args, err := r.prepareScript(script, path, file) + if err != nil { + logSkip(file.Name(), err.Error()) return } - // Make sure file is executable - if (file.Mode() & executableMask) == 0 { - if err := r.Fs.Chmod(path, executableFileMode); err != nil { - log.Errorf("Couldn't change file mode to make file executable: %s", err) - r.fail(file.Name(), "") - return - } - } - - var args []string - if len(script.Runner) > 0 { - args = strings.Split(script.Runner, " ") - } - - args = append(args, path) - args = append(args, r.GitArgs[:]...) - if script.Interactive && !r.DisableTTY && !r.Hook.Follow { log.StopSpinner() defer log.StartSpinner() @@ -273,7 +302,7 @@ func (r *Runner) runCommands() { for _, name := range commands { if r.failed.Load() && r.Hook.Piped { - logSkip(name, "(SKIP BY BROKEN PIPE)") + logSkip(name, "broken pipe") continue } @@ -297,7 +326,7 @@ func (r *Runner) runCommands() { for _, name := range interactiveCommands { if r.failed.Load() { - logSkip(name, "(SKIP INTERACTIVE BY FAILED)") + logSkip(name, "non-interactive commands failed") continue } @@ -306,34 +335,9 @@ func (r *Runner) runCommands() { } func (r *Runner) runCommand(name string, command *config.Command) { - if command.Skip != nil && command.DoSkip(r.Repo.State()) { - logSkip(name, "(SKIP BY SETTINGS)") - return - } - - if intersect(r.Hook.ExcludeTags, command.Tags) { - logSkip(name, "(SKIP BY TAGS)") - return - } - - if intersect(r.Hook.ExcludeTags, []string{name}) { - logSkip(name, "(SKIP BY NAME)") - return - } - - if err := command.Validate(); err != nil { - r.fail(name, "") - return - } - - args, err := r.buildCommandArgs(command) + args, err := r.prepareCommand(name, command) if err != nil { - log.Error(err) - logSkip(name, "(SKIP. ERROR)") - return - } - if len(args) == 0 { - logSkip(name, "(SKIP. NO FILES FOR INSPECTION)") + logSkip(name, err.Error()) return } @@ -352,111 +356,6 @@ func (r *Runner) runCommand(name string, command *config.Command) { }, r.Hook.Follow) } -func (r *Runner) buildCommandArgs(command *config.Command) ([]string, error) { - filesCommand := r.Hook.Files - if command.Files != "" { - filesCommand = command.Files - } - - filesTypeToFn := map[string]func() ([]string, error){ - config.SubStagedFiles: r.Repo.StagedFiles, - config.PushFiles: r.Repo.PushFiles, - config.SubAllFiles: r.Repo.AllFiles, - config.SubFiles: func() ([]string, error) { - return r.Repo.FilesByCommand(filesCommand) - }, - } - - runString := command.Run - for filesType, filesFn := range filesTypeToFn { - // Checking substitutions and skipping execution if it is empty. - // - // Special case - `files` option: return if the result of files - // command is empty. - if strings.Contains(runString, filesType) || - filesCommand != "" && filesType == config.SubFiles { - files, err := filesFn() - if err != nil { - return nil, fmt.Errorf("error replacing %s: %s", filesType, err) - } - if len(files) == 0 { - return nil, nil - } - - filesPrepared := prepareFiles(command, files) - if len(filesPrepared) == 0 { - return nil, nil - } - - runString = replaceQuoted(runString, filesType, filesPrepared) - } - } - - runString = strings.ReplaceAll(runString, "{0}", strings.Join(r.GitArgs, " ")) - for i, gitArg := range r.GitArgs { - runString = strings.ReplaceAll(runString, fmt.Sprintf("{%d}", i+1), gitArg) - } - - log.Debug("[lefthook] executing: ", runString) - - return strings.Split(runString, " "), nil -} - -func prepareFiles(command *config.Command, files []string) []string { - if files == nil { - return []string{} - } - - log.Debug("[lefthook] files before filters:\n", files) - - files = filterGlob(files, command.Glob) - files = filterExclude(files, command.Exclude) - files = filterRelative(files, command.Root) - - log.Debug("[lefthook] files after filters:\n", files) - - // Escape file names to prevent unexpected bugs - var filesEsc []string - for _, fileName := range files { - if len(fileName) > 0 { - filesEsc = append(filesEsc, shellescape.Quote(fileName)) - } - } - - log.Debug("[lefthook] files after escaping:\n", filesEsc) - - return filesEsc -} - -func replaceQuoted(source, substitution string, files []string) string { - for _, elem := range [][]string{ - {"\"", "\"" + substitution + "\""}, - {"'", "'" + substitution + "'"}, - {"", substitution}, - } { - quote := elem[0] - sub := elem[1] - if !strings.Contains(source, sub) { - continue - } - - quotedFiles := files - if len(quote) != 0 { - quotedFiles = make([]string, 0, len(files)) - for _, fileName := range files { - quotedFiles = append(quotedFiles, - quote+surroundingQuotesRegexp.ReplaceAllString(fileName, "$1")+quote) - } - } - - source = strings.ReplaceAll( - source, sub, strings.Join(quotedFiles, " "), - ) - } - - return source -} - func (r *Runner) run(opts ExecuteOptions, follow bool) { log.SetName(opts.name) defer log.UnsetName(opts.name) @@ -513,5 +412,12 @@ func intersect(a, b []string) bool { } func logSkip(name, reason string) { - log.Info(fmt.Sprintf("%s: %s", log.Bold(name), log.Yellow(reason))) + log.Info( + fmt.Sprintf( + "%s: %s %s", + log.Bold(name), + log.Gray("(skip)"), + log.Yellow(reason), + ), + ) } diff --git a/internal/lefthook/runner/runner_test.go b/internal/lefthook/runner/runner_test.go index acc5abca..ffa3e8ce 100644 --- a/internal/lefthook/runner/runner_test.go +++ b/internal/lefthook/runner/runner_test.go @@ -1,7 +1,6 @@ package runner import ( - "bytes" "errors" "fmt" "io" @@ -26,10 +25,28 @@ func (e TestExecutor) Execute(opts ExecuteOptions, out io.Writer) (err error) { return } -func (e TestExecutor) RawExecute(command string, args ...string) (*bytes.Buffer, error) { +func (e TestExecutor) RawExecute(command []string, out io.Writer) error { + return nil +} + +type GitMock struct{} + +func (g GitMock) Cmd(cmd string) (string, error) { + return "", nil +} + +func (g GitMock) CmdArgs(args ...string) (string, error) { + return "", nil +} + +func (g GitMock) CmdLines(cmd string) ([]string, error) { return nil, nil } +func (g GitMock) RawCmd(cmd string) (string, error) { + return "", nil +} + func TestRunAll(t *testing.T) { hookName := "pre-commit" @@ -40,6 +57,7 @@ func TestRunAll(t *testing.T) { gitPath := filepath.Join(root, ".git") repo := &git.Repository{ + Git: GitMock{}, HooksPath: filepath.Join(gitPath, "hooks"), RootPath: root, GitPath: gitPath, @@ -324,6 +342,7 @@ func TestRunAll(t *testing.T) { Fs: fs, Repo: repo, Hook: tt.hook, + HookName: hookName, GitArgs: tt.args, ResultChan: resultChan, }, @@ -346,7 +365,7 @@ func TestRunAll(t *testing.T) { } t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { - runner.RunAll(hookName, tt.sourceDirs) + runner.RunAll(tt.sourceDirs) close(resultChan) var success, fail []Result diff --git a/internal/log/log.go b/internal/log/log.go index f2f8c43b..6c82ef91 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -132,6 +132,10 @@ func Yellow(s string) string { return color(colorYellow).Render(s) } +func Gray(s string) string { + return color(colorGray).Render(s) +} + func Bold(s string) string { return lipgloss.NewStyle().Bold(true).Render(s) }