diff --git a/internal/git/exec.go b/internal/git/exec.go new file mode 100644 index 00000000..51ee4d76 --- /dev/null +++ b/internal/git/exec.go @@ -0,0 +1,64 @@ +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{} +} + +func (o *OsExec) Cmd(cmd string) (string, error) { + args := strings.Split(cmd, " ") + return o.CmdArgs(args...) +} + +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 +} + +func (o *OsExec) CmdArgs(args ...string) (string, error) { + out, err := o.rawExecArgs(args...) + if err != nil { + return "", err + } + + return strings.TrimSpace(out), nil +} + +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 80dcd3a3..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" @@ -18,15 +17,20 @@ const ( 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" - stashMessage = "lefthook auto backup" + 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 + Git Exec HooksPath string RootPath string GitPath string @@ -35,13 +39,13 @@ type Repository struct { } // 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 } @@ -49,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 } @@ -61,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 } @@ -71,11 +75,12 @@ func NewRepository(fs afero.Fs) (*Repository, error) { return &Repository{ Fs: fs, + Git: git, HooksPath: hooksPath, RootPath: rootPath, GitPath: gitPath, InfoPath: infoPath, - unstagedPatchPath: filepath.Join(infoPath, "lefthook-unstaged.patch"), + unstagedPatchPath: filepath.Join(infoPath, unstagedPatchName), }, nil } @@ -101,7 +106,7 @@ func (r *Repository) PushFiles() ([]string, error) { // unstaged changes. // See https://git-scm.com/docs/git-status#_short_format. func (r *Repository) PartiallyStagedFiles() ([]string, error) { - lines, err := r.gitLines(cmdStatusShort) + lines, err := r.Git.CmdLines(cmdStatusShort) if err != nil { return []string{}, err } @@ -109,13 +114,20 @@ func (r *Repository) PartiallyStagedFiles() ([]string, error) { partiallyStaged := make([]string, 0) for _, line := range lines { - if len(line) < 3 { + if len(line) < minStatusLen { continue } - m1 := line[0] // index - m2 := line[1] // working tree + + index := line[0] + workingTree := line[1] + filename := line[3:] - if m1 != ' ' && m1 != '?' && m2 != ' ' && m2 != '?' && len(filename) > 0 { + idx := strings.Index(filename, "->") + if idx != -1 { + filename = filename[idx+3:] + } + + if index != ' ' && index != '?' && workingTree != ' ' && workingTree != '?' && len(filename) > 0 { partiallyStaged = append(partiallyStaged, filename) } } @@ -124,16 +136,16 @@ func (r *Repository) PartiallyStagedFiles() ([]string, error) { } func (r *Repository) SaveUnstaged(files []string) error { - _, err := execGitCmd( + _, err := r.Git.CmdArgs( append([]string{ "git", "diff", "--binary", // support binary files - "--unified=0", // do not add lines around diff for consistent behaviour - "--no-color", // disable colors for consistent behaviour - "--no-ext-diff", // disable external diff tools for consistent behaviour - "--src-prefix=a/", // force prefix for consistent behaviour - "--dst-prefix=b/", // force prefix for consistent behaviour + "--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", @@ -146,7 +158,7 @@ func (r *Repository) SaveUnstaged(files []string) error { } func (r *Repository) HideUnstaged(files []string) error { - _, err := execGitCmd( + _, err := r.Git.CmdArgs( append([]string{ "git", "checkout", @@ -163,7 +175,7 @@ func (r *Repository) RestoreUnstaged() error { return nil } - _, err := execGitCmd( + _, err := r.Git.CmdArgs( "git", "apply", "-v", @@ -180,13 +192,13 @@ func (r *Repository) RestoreUnstaged() error { return err } -func (r *Repository) StashUnstaged() (string, error) { - stashHash, err := execGit(cmdCreateStash) +func (r *Repository) StashUnstaged() error { + stashHash, err := r.Git.Cmd(cmdCreateStash) if err != nil { - return "", err + return err } - _, err = execGitCmd( + _, err = r.Git.CmdArgs( "git", "stash", "store", @@ -196,51 +208,53 @@ func (r *Repository) StashUnstaged() (string, error) { stashHash, ) if err != nil { - return "", err + return err } - return stashHash, nil -} - -func (r *Repository) DropUnstagedStash(hash string) error { - _, err := execGitCmd( - "git", - "stash", - "drop", - "--quiet", - hash, - ) - - return err + 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.gitLines(command) +func (r *Repository) DropUnstagedStash() error { + lines, err := r.Git.CmdLines(cmdListStash) if err != nil { - return nil, err + return err } - return r.extractFiles(lines) -} - -func (r *Repository) gitLines(command string) ([]string, error) { - var cmd *exec.Cmd + 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 + } - if runtime.GOOS == "windows" { - commandArg := strings.Split(command, " ") - cmd = exec.Command(commandArg[0], commandArg[1:]...) - } else { - cmd = exec.Command("sh", "-c", command) + stashID := stashRegexp.SubexpIndex("stash") + + if len(matches[stashID]) > 0 { + _, err := r.Git.CmdArgs( + "git", + "stash", + "drop", + "--quiet", + matches[stashID], + ) + if err != nil { + return err + } + } } - outputBytes, err := cmd.CombinedOutput() + 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 strings.Split(string(outputBytes), "\n"), nil + return r.extractFiles(lines) } func (r *Repository) extractFiles(lines []string) ([]string, error) { @@ -275,22 +289,3 @@ func (r *Repository) isFile(path string) (bool, error) { return !stat.IsDir(), nil } - -func execGit(command string) (string, error) { - args := strings.Split(command, " ") - return execGitCmd(args...) -} - -// execGitCmd executes git command with LEFTHOOK=0 in order -// to prevent calling subsequent lefthook hooks. -func execGitCmd(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 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_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/runner.go b/internal/lefthook/runner/runner.go index e5eeda64..f2c52fff 100644 --- a/internal/lefthook/runner/runner.go +++ b/internal/lefthook/runner/runner.go @@ -43,10 +43,9 @@ type Opts struct { type Runner struct { Opts - partiallyStagedFiles []string - partiallyStagedStashHash string - failed atomic.Bool - executor Executor + partiallyStagedFiles []string + failed atomic.Bool + executor Executor } func NewRunner(opts Opts) *Runner { @@ -143,8 +142,8 @@ func (r *Runner) runLFSHook() error { } if err != nil && (requiredExists || configExists) { - log.Warn(output) - return fmt.Errorf("git-lfs command failed: %s", err) + log.Warnf("git-lfs command failed: %s\n", output) + return err } return nil @@ -179,10 +178,16 @@ func (r *Runner) preHook() { return } + log.Debug("[lefthook] saving partially staged files") + r.partiallyStagedFiles = partiallyStagedFiles - r.Repo.SaveUnstaged(r.partiallyStagedFiles) + err = r.Repo.SaveUnstaged(r.partiallyStagedFiles) + if err != nil { + log.Warnf("Couldn't save unstaged changes: %s\n", err) + return + } - stashHash, err := r.Repo.StashUnstaged() + err = r.Repo.StashUnstaged() if err != nil { log.Warnf("Couldn't stash partially staged files: %s\n", err) return @@ -195,8 +200,6 @@ func (r *Runner) preHook() { } log.Debugf("[lefthook] hide partially staged files: %v\n", r.partiallyStagedFiles) - - r.partiallyStagedStashHash = stashHash } func (r *Runner) postHook() { @@ -205,7 +208,7 @@ func (r *Runner) postHook() { return } - if err := r.Repo.DropUnstagedStash(r.partiallyStagedStashHash); err != nil { + if err := r.Repo.DropUnstagedStash(); err != nil { log.Warnf("Couldn't remove unstaged files backup: %s\n", err) } } diff --git a/internal/lefthook/runner/runner_test.go b/internal/lefthook/runner/runner_test.go index 8d92aec9..ffa3e8ce 100644 --- a/internal/lefthook/runner/runner_test.go +++ b/internal/lefthook/runner/runner_test.go @@ -29,6 +29,24 @@ 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" @@ -39,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,