diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43645ff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/git-pre-commit-hook diff --git a/blacklist.go b/blacklist.go new file mode 100644 index 0000000..0b9c996 --- /dev/null +++ b/blacklist.go @@ -0,0 +1,78 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "github.com/logrusorgru/aurora" +) + +func blacklisted() bool { + bl, err := blacklistedAux() + switch { + case err != nil: + Warn("failed to check blacklist: ", aurora.Red(err)) + return true + case bl: + Warn("skipping git-commit hooks in blacklisted repo") + return true + default: + return false + } +} + +func blacklistedAux() (bool, error) { + // open the user's blacklist file (and skip if ENOENT) + home, err := os.UserHomeDir() + if err != nil { + return true, err + } + + f, err := os.Open(filepath.Join(home, + "git/hooks/pre-commit-blacklist")) + switch { + case os.IsNotExist(err): + return false, nil + case err != nil: + return true, err + } + + // we're called from the root of the git repo + cwd, err := os.Getwd() + if err != nil { + return true, err + } + + // see if it matches + scanner := bufio.NewScanner(f) + for scanner.Scan() { + // get a line, stripping comments / spaces / blank lines + bl := scanner.Text() + if pos := strings.IndexRune(bl, '#'); pos != -1 { + bl = bl[:pos] + } + bl = strings.TrimSpace(bl) + if bl == "" { + continue + } + + // we know bl[0] can be dereferenced + switch bl[0] { + case '*': + return strings.HasSuffix(cwd, bl[1:]), nil + + case '~': + return cwd == home+bl[1:], nil + + default: + return cwd == bl, nil + } + } + if scanner.Err() != nil { + return false, scanner.Err() + } + + return false, nil +} diff --git a/check_gofmt.go b/check_gofmt.go new file mode 100644 index 0000000..76b3ffd --- /dev/null +++ b/check_gofmt.go @@ -0,0 +1,52 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/logrusorgru/aurora" +) + +// goFmt checks for bad formatting. It uses the "goimports" command. As of +// 2020-02, the command "goimports ." recurses and ignores module boundaries and +// non-go directories. It can therefore be run from the root of the git repo. +func goFmt() error { + // execute goimports + wbuf := bytes.NewBuffer(nil) + cmd := exec.Command("goimports", "-l", ".") + cmd.Stdout = wbuf + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + // non-zero return code = program error (not bad formatting) + return fmt.Errorf("%s: %v", aurora.Blue("goimports -l"), aurora.Red(err)) + } + + // iterate over list of changed files, may be empty + var ( + scanner = bufio.NewScanner(wbuf) + werr strings.Builder + ok = true + ) + fmt.Fprintln(&werr, aurora.Red("The following files need reformatting:")) + for scanner.Scan() { + // ignore generated .pb.go files + if strings.HasSuffix(scanner.Text(), ".pb.go") { + continue + } + + ok = false + werr.WriteByte('\t') + werr.WriteString(scanner.Text()) + werr.WriteByte('\n') + } + + if ok { + return nil + } + return errors.New(werr.String()) +} diff --git a/check_govet.go b/check_govet.go new file mode 100644 index 0000000..88897ae --- /dev/null +++ b/check_govet.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/logrusorgru/aurora" +) + +// goVet executes "go vet" recursively for each module in the git repo. As of +// go 1.13, "go vet ./..." is required to recurse but does not straddle +// module boundaries. It must be run in a valid package directory. It is +// significantly faster than running individual "go vet ." in each dir. +func goVet() error { + return filepath.Walk(".", goVetW) +} + +func goVetW(path string, info os.FileInfo, err error) error { + switch { + case err != nil: + return err + case info.Mode().IsDir() && strings.HasPrefix(info.Name(), "."): + return filepath.SkipDir + case info.Mode().IsRegular() && info.Name() == "go.mod": + dir := filepath.Dir(path) + Info(" running %q in %s", aurora.Blue("go vet"), aurora.Green(dir)) + cmd := exec.Command("go", "vet", "./...") + cmd.Dir = path + op, err := cmd.CombinedOutput() + switch { + case len(op) > 0 && err != nil: + return fmt.Errorf("go vet failed: %s\n%s\n", aurora.Red(err), op) + case len(op) > 0: + return fmt.Errorf("%s:\n%s\n", aurora.Red("go vet reports"), op) + case err != nil: + return err + } + } + return nil +} diff --git a/check_largefiles.go b/check_largefiles.go new file mode 100644 index 0000000..8d711cb --- /dev/null +++ b/check_largefiles.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/logrusorgru/aurora" +) + +const ( + largeFileLimit = 1 << 20 // 1MiB +) + +func checkLargeFiles() error { + return filepath.Walk(".", checkLargeFilesW) +} + +func checkLargeFilesW(path string, info os.FileInfo, err error) error { + switch { + case err != nil: + return err + case info.Size() > largeFileLimit: + return fmt.Errorf("committing large file: %s (%d bytes)", + aurora.Red(path), info.Size()) + case info.IsDir() && strings.HasPrefix(info.Name(), "."): + return filepath.SkipDir + default: + return nil + } +} diff --git a/git_stash.go b/git_stash.go new file mode 100644 index 0000000..462fade --- /dev/null +++ b/git_stash.go @@ -0,0 +1,45 @@ +package main + +import ( + "io/ioutil" + "os" + "os/exec" +) + +var gitStashName string + +func gitStashPush() { + // create a temporary file that _ensures_ we have something to + // "git stash push". Without this, if all changes were staged, then + // "git stash push" would be a no-op, with exit code 0, and the + // final "git stash pop" would erroneously pop whatever the user had + // stashed themselves. + f, err := ioutil.TempFile(".", "git-pre-commit-hook-stash.") + if err != nil { + Err("failed to create temporary file: %v", err) + } + gitStashName = f.Name() + f.Close() + + // perform git stash + cmd := exec.Command("git", "stash", "save", + "--keep-index", // don't stash staged changes + "--quiet", + "--include-untracked", // ignores new unstaged files + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err = cmd.Run(); err != nil { + Err("failed to run git stash: %v", err) + } +} + +func gitStashPop() { + defer os.Remove(gitStashName) + cmd := exec.Command("git", "stash", "pop", "--quiet") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + Err("failed to run git stash pop: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29dd091 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module src.lwithers.me.uk/go/git-pre-commit-hook + +go 1.13 + +require github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6823f4b --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 h1:bqDmpDG49ZRnB5PcgP0RXtQvnMSgIF14M7CBd2shtXs= +github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= diff --git a/has_go.go b/has_go.go new file mode 100644 index 0000000..5d6aaf2 --- /dev/null +++ b/has_go.go @@ -0,0 +1,53 @@ +package main + +import ( + "io/ioutil" + "path/filepath" + + "github.com/logrusorgru/aurora" +) + +func hasGo() bool { + h, err := hasGoAux(".", 3) + if err != nil { + Warn("error scanning directory for go.mod: %v", aurora.Red(err)) + return false + } + return h +} + +func hasGoAux(dir string, depth int) (bool, error) { + fi, err := ioutil.ReadDir(dir) + if err != nil { + return false, err + } + + var dirs []string + + for _, f := range fi { + switch { + case f.Name()[0] == '.': + // do nothing + + case f.IsDir(): + dirs = append(dirs, f.Name()) + + case f.Name() == "go.mod": + return true, nil + } + } + + if depth == 0 { + return false, nil + } + depth-- + + for _, subdir := range dirs { + h, err := hasGoAux(filepath.Join(dir, subdir), depth) + if h || err != nil { + return h, err + } + } + + return false, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..711be36 --- /dev/null +++ b/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if blacklisted() || !hasGo() { + os.Exit(0) + } + + var exitCode int + + Info("Stashing unstaged changes…") + gitStashPush() + + Info("Checking for large files…") + if err := checkLargeFiles(); err != nil { + fmt.Println(err) + exitCode = 1 + } + + Info("Running goimports…") + if err := goFmt(); err != nil { + fmt.Println(err) + exitCode = 1 + } + + Info("Running go vet…") + if err := goVet(); err != nil { + fmt.Println(err) + exitCode = 1 + } + + Info("Restoring unstaged changes…") + gitStashPop() + os.Exit(exitCode) +} diff --git a/output.go b/output.go new file mode 100644 index 0000000..6a004ba --- /dev/null +++ b/output.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "os" + + "github.com/logrusorgru/aurora" +) + +var ( + title = aurora.Blue("[~/git/hooks/bin/pre-commit]") + info = aurora.Blue("INFO") + warning = aurora.Yellow("WARN") + errorStr = aurora.Red("ERR ") +) + +func Info(format string, args ...interface{}) { + fmt.Fprintln(os.Stderr, title, info, fmt.Sprintf(format, args...)) +} + +func Warn(format string, args ...interface{}) { + fmt.Fprintln(os.Stderr, title, warning, fmt.Sprintf(format, args...)) +} + +func Err(format string, args ...interface{}) { + fmt.Fprintln(os.Stderr, title, errorStr, fmt.Sprintf(format, args...)) + os.Exit(1) +}