mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-04 19:56:35 +02:00
misc/git_hook: extract shared githook package; auto-rebuild on version bump (#19440)
Pull the hook logic into a reusable githook library package so tailscale/corp can share it via a thin wrapper main instead of keeping a forked copy in sync. The install flow also changes: a wrapper scripts now build the binary and reinstall the git hooks. Pulling new shared code no longer requires re-running the installer. Updates tailscale/corp#39860 Change-Id: I4d606d11c8c883015c190c54e3387a7f9fe4dd32 Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
This commit is contained in:
parent
1fbb834dc3
commit
514d7d28e7
@ -1 +1 @@
|
||||
1
|
||||
2
|
||||
|
||||
34
misc/git_hook/README.md
Normal file
34
misc/git_hook/README.md
Normal file
@ -0,0 +1,34 @@
|
||||
# git_hook
|
||||
|
||||
Tailscale's git hooks.
|
||||
|
||||
The shared logic lives in the `githook/` package and is also imported by
|
||||
`tailscale/corp`.
|
||||
|
||||
## Install
|
||||
|
||||
From the repo root:
|
||||
|
||||
./tool/go run ./misc/install-git-hooks.go
|
||||
|
||||
The script auto-updates in the future.
|
||||
|
||||
|
||||
## Adding your own hooks
|
||||
|
||||
Create an executable `.git/hooks/<hook-name>.local` to chain a custom
|
||||
script after a built-in hook. For example, put a custom check in
|
||||
`.git/hooks/pre-commit.local` and `chmod +x` it. The local hook runs
|
||||
only if the built-in hook succeeds; failure aborts the git operation.
|
||||
|
||||
|
||||
## Changing the shared code
|
||||
|
||||
When you change anything under `githook/` or `launcher.sh`, bump
|
||||
`HOOK_VERSION` in the same commit so every dev auto rebuilds on their next
|
||||
git operation.
|
||||
|
||||
Because `tailscale/corp` imports `githook/`, also plan the downstream
|
||||
update: after landing here, bump corp's `tailscale.com` dependency and
|
||||
bump corp's own `misc/git_hook/HOOK_VERSION` on a separate commit. Both are
|
||||
required.
|
||||
@ -1,322 +1,66 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The git-hook command is Tailscale's git hooks. It's built by
|
||||
// misc/install-git-hooks.go and installed into .git/hooks
|
||||
// as .git/hooks/ts-git-hook, with shell wrappers.
|
||||
// The git-hook command is Tailscale's git hook binary, built and
|
||||
// installed under .git/hooks/ts-git-hook-bin by the launcher at
|
||||
// .git/hooks/ts-git-hook. misc/install-git-hooks.go writes the initial
|
||||
// launcher; subsequent HOOK_VERSION bumps trigger self-rebuilds.
|
||||
//
|
||||
// # Adding your own hooks
|
||||
//
|
||||
// To add your own hook for one that we have already hooked, create a file named
|
||||
// <hook-name>.local in .git/hooks. For example, to add your own pre-commit hook,
|
||||
// create .git/hooks/pre-commit.local and make it executable. It will be run after
|
||||
// the ts-git-hook, if ts-git-hook executes successfully.
|
||||
// To add your own hook alongside one we already hook, create an executable
|
||||
// file .git/hooks/<hook-name>.local (e.g. pre-commit.local). It runs after
|
||||
// the built-in hook succeeds.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/sourcegraph/go-diff/diff"
|
||||
"golang.org/x/mod/modfile"
|
||||
"tailscale.com/misc/git_hook/githook"
|
||||
)
|
||||
|
||||
//go:embed HOOK_VERSION
|
||||
var compiledHookVersion string
|
||||
|
||||
var pushRemotes = []string{
|
||||
"git@github.com:tailscale/tailscale",
|
||||
"git@github.com:tailscale/tailscale.git",
|
||||
"https://github.com/tailscale/tailscale",
|
||||
"https://github.com/tailscale/tailscale.git",
|
||||
}
|
||||
|
||||
// hooks are the hook names this binary handles. Used by install to
|
||||
// write per-hook wrappers; must stay in sync with the dispatcher below.
|
||||
var hooks = []string{"pre-commit", "commit-msg", "pre-push"}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
if len(os.Args) < 2 {
|
||||
return
|
||||
}
|
||||
cmd, args := os.Args[1], os.Args[2:]
|
||||
|
||||
var err error
|
||||
switch cmd {
|
||||
case "version":
|
||||
fmt.Print(strings.TrimSpace(compiledHookVersion))
|
||||
case "install":
|
||||
err = githook.WriteHooks(hooks)
|
||||
case "pre-commit":
|
||||
err = preCommit(args)
|
||||
err = githook.CheckForbiddenMarkers()
|
||||
case "commit-msg":
|
||||
err = commitMsg(args)
|
||||
err = githook.AddChangeID(args)
|
||||
case "pre-push":
|
||||
err = prePush(args)
|
||||
case "post-checkout":
|
||||
err = postCheckout(args)
|
||||
err = githook.CheckGoModReplaces(args, pushRemotes, nil)
|
||||
}
|
||||
if err != nil {
|
||||
p := log.Fatalf
|
||||
if nfe, ok := err.(nonFatalErr); ok {
|
||||
p = log.Printf
|
||||
err = nfe
|
||||
}
|
||||
p("git-hook: %v: %v", cmd, err)
|
||||
log.Fatalf("git-hook: %v: %v", cmd, err)
|
||||
}
|
||||
|
||||
if err == nil || errors.Is(err, nonFatalErr{}) {
|
||||
err := runLocalHook(cmd, args)
|
||||
if err != nil {
|
||||
log.Fatalf("git-hook: %v", err)
|
||||
}
|
||||
if err := githook.RunLocalHook(cmd, args); err != nil {
|
||||
log.Fatalf("git-hook: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runLocalHook(hookName string, args []string) error {
|
||||
cmdPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hookDir := filepath.Dir(cmdPath)
|
||||
localHookPath := filepath.Join(hookDir, hookName+".local")
|
||||
if _, err := os.Stat(localHookPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("checking for local hook: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(localHookPath, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("running local hook %q: %w", localHookPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pre-commit: "It takes no parameters, and is invoked before
|
||||
// obtaining the proposed commit log message and making a
|
||||
// commit. Exiting with a non-zero status from this script causes the
|
||||
// git commit command to abort before creating a commit."
|
||||
//
|
||||
// https://git-scm.com/docs/githooks#_pre_commit
|
||||
func preCommit(_ []string) error {
|
||||
diffOut, err := exec.Command("git", "diff", "--cached").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not get git diff: %w", err)
|
||||
}
|
||||
|
||||
diffs, err := diff.ParseMultiFileDiff(diffOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not parse diff: %w", err)
|
||||
}
|
||||
|
||||
foundForbidden := false
|
||||
for _, diff := range diffs {
|
||||
for _, hunk := range diff.Hunks {
|
||||
lines := bytes.Split(hunk.Body, []byte{'\n'})
|
||||
for i, line := range lines {
|
||||
if len(line) == 0 || line[0] != '+' {
|
||||
continue
|
||||
}
|
||||
for _, forbidden := range preCommitForbiddenPatterns {
|
||||
if bytes.Contains(line, forbidden) {
|
||||
if !foundForbidden {
|
||||
color.New(color.Bold, color.FgRed, color.Underline).Printf("%s found:\n", forbidden)
|
||||
}
|
||||
// Output file name (dropping the b/ prefix) and line
|
||||
// number so that it can be linkified by terminals.
|
||||
fmt.Printf("%s:%d: %s\n", diff.NewName[2:], int(hunk.NewStartLine)+i, line[1:])
|
||||
foundForbidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundForbidden {
|
||||
return fmt.Errorf("Found forbidden string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var preCommitForbiddenPatterns = [][]byte{
|
||||
// Use concatenation to avoid including the forbidden literals (and thus
|
||||
// triggering the pre-commit hook).
|
||||
[]byte("NOCOM" + "MIT"),
|
||||
[]byte("DO NOT " + "SUBMIT"),
|
||||
}
|
||||
|
||||
// https://git-scm.com/docs/githooks#_commit_msg
|
||||
func commitMsg(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: commit-msg message.txt")
|
||||
}
|
||||
file := args[0]
|
||||
msg, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg = filterCutLine(msg)
|
||||
|
||||
var id [20]byte
|
||||
if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
|
||||
return fmt.Errorf("could not generate Change-Id: %v", err)
|
||||
}
|
||||
cmdLines := [][]string{
|
||||
// Trim whitespace and comments.
|
||||
{"git", "stripspace", "--strip-comments"},
|
||||
// Add Change-Id trailer.
|
||||
{"git", "interpret-trailers", "--no-divider", "--where=start", "--if-exists", "doNothing", "--trailer", fmt.Sprintf("Change-Id: I%x", id)},
|
||||
}
|
||||
for _, cmdLine := range cmdLines {
|
||||
if len(msg) == 0 {
|
||||
// Don't allow commands to go from empty commit message to non-empty (issue 2205).
|
||||
break
|
||||
}
|
||||
cmd := exec.Command(cmdLine[0], cmdLine[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(msg)
|
||||
msg, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run '%v': %w\n%s", cmd, err, msg)
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(file, msg, 0666)
|
||||
}
|
||||
|
||||
// pre-push: "this hook is called by git-push and can be used to
|
||||
// prevent a push from taking place. The hook is called with two
|
||||
// parameters which provide the name and location of the destination
|
||||
// remote, if a named remote is not being used both values will be the
|
||||
// same.
|
||||
//
|
||||
// Information about what is to be pushed is provided on the hook's
|
||||
// standard input with lines of the form:
|
||||
//
|
||||
// <local ref> SP <local sha1> SP <remote ref> SP <remote sha1> LF
|
||||
//
|
||||
// More: https://git-scm.com/docs/githooks#_pre_push
|
||||
func prePush(args []string) error {
|
||||
remoteName, remoteLoc := args[0], args[1]
|
||||
_ = remoteName
|
||||
|
||||
pushes, err := readPushes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading pushes: %w", err)
|
||||
}
|
||||
|
||||
switch remoteLoc {
|
||||
case "git@github.com:tailscale/tailscale", "git@github.com:tailscale/tailscale.git",
|
||||
"https://github.com/tailscale/tailscale", "https://github.com/tailscale/tailscale.git":
|
||||
for _, p := range pushes {
|
||||
if p.isDoNotMergeRef() {
|
||||
continue
|
||||
}
|
||||
if err := checkCommit(p.localSHA); err != nil {
|
||||
return fmt.Errorf("not allowing push of %v to %v: %v", p.localSHA, p.remoteRef, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:embed HOOK_VERSION
|
||||
var compiledHookVersion string
|
||||
|
||||
// post-checkout: "This hook is invoked when a git-checkout[1] or
|
||||
// git-switch[1] is run after having updated the worktree. The hook is
|
||||
// given three parameters: the ref of the previous HEAD, the ref of
|
||||
// the new HEAD (which may or may not have changed), and a flag
|
||||
// indicating whether the checkout was a branch checkout (changing
|
||||
// branches, flag=1) or a file checkout (retrieving a file from the
|
||||
// index, flag=0).
|
||||
//
|
||||
// More: https://git-scm.com/docs/githooks#_post_checkout
|
||||
func postCheckout(_ []string) error {
|
||||
compiled, err := strconv.Atoi(strings.TrimSpace(compiledHookVersion))
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't parse compiled-in hook version: %v", err)
|
||||
}
|
||||
|
||||
bs, err := os.ReadFile("misc/git_hook/HOOK_VERSION")
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// Probably checked out a commit that predates the existence
|
||||
// of HOOK_VERSION, don't complain.
|
||||
return nil
|
||||
}
|
||||
actual, err := strconv.Atoi(strings.TrimSpace(string(bs)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't parse misc/git_hook/HOOK_VERSION: %v", err)
|
||||
}
|
||||
|
||||
if actual > compiled {
|
||||
return nonFatalErr{fmt.Errorf("a newer git hook script is available, please run `./tool/go run ./misc/install-git-hooks.go`")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCommit(sha string) error {
|
||||
// Allow people to delete remote refs.
|
||||
if sha == zeroRef {
|
||||
return nil
|
||||
}
|
||||
// Check that go.mod doesn't contain replacements to directories.
|
||||
goMod, err := exec.Command("git", "show", sha+":go.mod").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", goMod, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse its go.mod: %v", err)
|
||||
}
|
||||
for _, r := range mf.Replace {
|
||||
if modfile.IsDirectoryPath(r.New.Path) {
|
||||
return fmt.Errorf("go.mod contains replace from %v => %v", r.Old.Path, r.New.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const zeroRef = "0000000000000000000000000000000000000000"
|
||||
|
||||
type push struct {
|
||||
localRef string // "refs/heads/bradfitz/githooks"
|
||||
localSHA string // what's being pushed
|
||||
remoteRef string // "refs/heads/bradfitz/githooks", "refs/heads/main"
|
||||
remoteSHA string // old value being replaced, or zeroRef if it doesn't exist
|
||||
}
|
||||
|
||||
func (p *push) isDoNotMergeRef() bool {
|
||||
return strings.HasSuffix(p.remoteRef, "/DO-NOT-MERGE")
|
||||
}
|
||||
|
||||
func readPushes() (pushes []push, err error) {
|
||||
bs := bufio.NewScanner(os.Stdin)
|
||||
for bs.Scan() {
|
||||
f := strings.Fields(bs.Text())
|
||||
if len(f) != 4 {
|
||||
return nil, fmt.Errorf("unexpected push line %q", bs.Text())
|
||||
}
|
||||
pushes = append(pushes, push{f[0], f[1], f[2], f[3]})
|
||||
}
|
||||
if err := bs.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pushes, nil
|
||||
}
|
||||
|
||||
// nonFatalErr is an error wrapper type to indicate that main() should
|
||||
// not exit fatally.
|
||||
type nonFatalErr struct {
|
||||
error
|
||||
}
|
||||
|
||||
var gitCutLine = []byte("# ------------------------ >8 ------------------------")
|
||||
|
||||
// filterCutLine searches for a git cutline (see above) and filters it and any
|
||||
// following lines from the given message. This is typically produced in a
|
||||
// commit message file by `git commit -v`.
|
||||
func filterCutLine(msg []byte) []byte {
|
||||
if before, _, ok := bytes.Cut(msg, gitCutLine); ok {
|
||||
return before
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
64
misc/git_hook/githook/commit-msg.go
Normal file
64
misc/git_hook/githook/commit-msg.go
Normal file
@ -0,0 +1,64 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package githook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// AddChangeID strips comments from the commit message at args[0] and
|
||||
// prepends a random Change-Id trailer.
|
||||
//
|
||||
// Intended as a commit-msg hook.
|
||||
// https://git-scm.com/docs/githooks#_commit_msg
|
||||
func AddChangeID(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: commit-msg message.txt")
|
||||
}
|
||||
file := args[0]
|
||||
msg, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg = filterCutLine(msg)
|
||||
|
||||
var id [20]byte
|
||||
if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
|
||||
return fmt.Errorf("could not generate Change-Id: %v", err)
|
||||
}
|
||||
cmdLines := [][]string{
|
||||
{"git", "stripspace", "--strip-comments"},
|
||||
{"git", "interpret-trailers", "--no-divider", "--where=start", "--if-exists", "doNothing", "--trailer", fmt.Sprintf("Change-Id: I%x", id)},
|
||||
}
|
||||
for _, cmdLine := range cmdLines {
|
||||
if len(msg) == 0 {
|
||||
// Don't let commands turn an empty message into a non-empty one (issue 2205).
|
||||
break
|
||||
}
|
||||
cmd := exec.Command(cmdLine[0], cmdLine[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(msg)
|
||||
msg, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run %v: %w\n%s", cmd, err, msg)
|
||||
}
|
||||
}
|
||||
return os.WriteFile(file, msg, 0666)
|
||||
}
|
||||
|
||||
var gitCutLine = []byte("# ------------------------ >8 ------------------------")
|
||||
|
||||
// filterCutLine strips a `git commit -v`-style cutline and everything
|
||||
// after it from msg.
|
||||
func filterCutLine(msg []byte) []byte {
|
||||
if before, _, ok := bytes.Cut(msg, gitCutLine); ok {
|
||||
return before
|
||||
}
|
||||
return msg
|
||||
}
|
||||
46
misc/git_hook/githook/githook.go
Normal file
46
misc/git_hook/githook/githook.go
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package githook contains the shared implementation of Tailscale's git
|
||||
// hooks. The tailscale/tailscale and tailscale/corp repositories each have
|
||||
// a thin main package that dispatches to this one, calling individual
|
||||
// hook functions with per-repo arguments as needed.
|
||||
package githook
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Launcher is the canonical bytes of launcher.sh. Downstream repos
|
||||
// (e.g. tailscale/corp) rely on these bytes at install time.
|
||||
//
|
||||
//go:embed launcher.sh
|
||||
var Launcher []byte
|
||||
|
||||
// RunLocalHook runs an optional user-supplied hook at
|
||||
// .git/hooks/<name>.local, if present.
|
||||
func RunLocalHook(hookName string, args []string) error {
|
||||
cmdPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localHookPath := filepath.Join(filepath.Dir(cmdPath), hookName+".local")
|
||||
if _, err := os.Stat(localHookPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("checking for local hook: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(localHookPath, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("running local hook %q: %w", localHookPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
177
misc/git_hook/githook/install.go
Normal file
177
misc/git_hook/githook/install.go
Normal file
@ -0,0 +1,177 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package githook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Install writes the launcher to .git/hooks/ts-git-hook and runs it
|
||||
// once with "version", bootstrapping the binary build and per-hook
|
||||
// wrappers. Called from each repo's misc/install-git-hooks.go.
|
||||
func Install() error {
|
||||
hookDir, err := findHookDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(hookDir, "ts-git-hook")
|
||||
if err := writeLauncher(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The launcher execs the binary with our arg at the end; we pass
|
||||
// "version" only to trigger the rebuild-if-stale path, and discard
|
||||
// its stdout so the version string doesn't leak to the caller.
|
||||
cmd := exec.Command(target, "version")
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("initial hook setup failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteHooks writes the launcher to .git/hooks/ts-git-hook and a wrapper
|
||||
// for each name in hooks to .git/hooks/<name>. Stale wrappers from
|
||||
// prior versions (ours, but no longer in hooks) are removed. If a path
|
||||
// we are about to write exists and is not one of our wrappers,
|
||||
// WriteHooks aborts with an error rather than clobber the user's hook.
|
||||
// Called by the binary's "install" handler (after a rebuild) and by
|
||||
// Install (initial setup).
|
||||
func WriteHooks(hooks []string) error {
|
||||
hookDir, err := findHookDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLauncher(filepath.Join(hookDir, "ts-git-hook")); err != nil {
|
||||
return err
|
||||
}
|
||||
want := make(map[string]bool, len(hooks))
|
||||
for _, h := range hooks {
|
||||
want[h] = true
|
||||
}
|
||||
entries, err := os.ReadDir(hookDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading hooks dir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
path := filepath.Join(hookDir, name)
|
||||
mine, err := isOurWrapper(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspecting %s: %v", path, err)
|
||||
}
|
||||
switch {
|
||||
case want[name] && !mine:
|
||||
return fmt.Errorf("%s exists and is not a ts-git-hook wrapper; "+
|
||||
"move your hook to %s.local (it will be chained after the wrapper) or delete it, then re-run: ./tool/go run ./misc/install-git-hooks.go",
|
||||
path, name)
|
||||
case !want[name] && mine:
|
||||
// Stale wrapper from a prior version (e.g. a hook we used
|
||||
// to install but no longer do).
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("removing stale wrapper %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, h := range hooks {
|
||||
content := fmt.Sprintf(wrapperScript, h)
|
||||
if err := os.WriteFile(filepath.Join(hookDir, h), []byte(content), 0755); err != nil {
|
||||
return fmt.Errorf("writing wrapper for %s: %v", h, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOurWrapper reports whether path is a hook wrapper written by us
|
||||
// (in any historical format). Files we will never own (the launcher
|
||||
// itself, user-chained .local hooks, git's .sample examples) return
|
||||
// false unconditionally and are not read. An I/O error other than
|
||||
// "not found" is returned to the caller; a missing file is not an
|
||||
// error.
|
||||
func isOurWrapper(path string) (bool, error) {
|
||||
name := filepath.Base(path)
|
||||
if name == "ts-git-hook" ||
|
||||
strings.HasSuffix(name, ".local") ||
|
||||
strings.HasSuffix(name, ".sample") {
|
||||
return false, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return wrapperRE.Match(b), nil
|
||||
}
|
||||
|
||||
// writeLauncher writes the embedded launcher to target via atomic rename,
|
||||
// so a currently-running launcher keeps reading its old inode.
|
||||
func writeLauncher(target string) error {
|
||||
dir, name := filepath.Split(target)
|
||||
f, err := os.CreateTemp(dir, name+".*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp launcher: %v", err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
if _, err := f.Write(Launcher); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("writing temp launcher: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0755); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("installing launcher: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findHookDir() (string, error) {
|
||||
out, err := exec.Command("git", "rev-parse", "--git-path", "hooks").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("finding hooks dir: %v, %s", err, out)
|
||||
}
|
||||
hookDir, err := filepath.Abs(strings.TrimSpace(string(out)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fi, err := os.Stat(hookDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("checking hooks dir: %v", err)
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return "", fmt.Errorf("%s is not a directory", hookDir)
|
||||
}
|
||||
return hookDir, nil
|
||||
}
|
||||
|
||||
const wrapperScript = `#!/usr/bin/env bash
|
||||
exec "$(dirname "${BASH_SOURCE[0]}")/ts-git-hook" %s "$@"
|
||||
`
|
||||
|
||||
// wrapperRE matches every historical shape of wrapperScript: a tiny
|
||||
// bash script that execs a sibling ts-git-hook with a single hook-name
|
||||
// argument. The inner quoting of ${BASH_SOURCE[0]} changed between
|
||||
// versions, hence the "?s.
|
||||
var wrapperRE = regexp.MustCompile(
|
||||
`\A#!/usr/bin/env bash\nexec "\$\(dirname "?\$\{BASH_SOURCE\[0\]\}"?\)/ts-git-hook" [\w-]+ "\$@"\n?\z`,
|
||||
)
|
||||
47
misc/git_hook/githook/launcher.sh
Executable file
47
misc/git_hook/githook/launcher.sh
Executable file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# ts-git-hook launcher (installed at .git/hooks/ts-git-hook).
|
||||
#
|
||||
# Written by misc/install-git-hooks.go from the canonical copy embedded
|
||||
# in tailscale.com/misc/git_hook/githook. On every invocation it:
|
||||
#
|
||||
# 1. Compares misc/git_hook/HOOK_VERSION against the binary's version.
|
||||
# 2. If stale or missing: rebuilds .git/hooks/ts-git-hook-bin and runs
|
||||
# `ts-git-hook-bin install` to refresh the launcher and per-hook
|
||||
# wrappers.
|
||||
# 3. Execs the binary with the hook's args.
|
||||
#
|
||||
# Any change to this file or the binary must bump HOOK_VERSION.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
echo "git-hook: not in a git repo" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
HOOK_DIR="$(git -C "$REPO_ROOT" rev-parse --git-path hooks)"
|
||||
case "$HOOK_DIR" in
|
||||
/*) ;;
|
||||
*) HOOK_DIR="$REPO_ROOT/$HOOK_DIR" ;;
|
||||
esac
|
||||
|
||||
# Windows (Git for Windows / MSYS2) needs .exe suffixes.
|
||||
EXE=""
|
||||
case "$(uname -s)" in MINGW* | MSYS* | CYGWIN*) EXE=".exe" ;; esac
|
||||
|
||||
BINARY="$HOOK_DIR/ts-git-hook-bin$EXE"
|
||||
WANT="$(cat "$REPO_ROOT/misc/git_hook/HOOK_VERSION" 2>/dev/null || echo 0)"
|
||||
HAVE="$("$BINARY" version 2>/dev/null || echo none)"
|
||||
|
||||
if [ "$WANT" != "$HAVE" ]; then
|
||||
GO="$REPO_ROOT/tool/go$EXE"
|
||||
if [ ! -x "$GO" ]; then GO=go; fi
|
||||
echo "git-hook: rebuilding ts-git-hook-bin..." >&2
|
||||
(cd "$REPO_ROOT" && "$GO" build -o "$BINARY" ./misc/git_hook) || {
|
||||
echo "git-hook: rebuild failed, run: ./tool/go run ./misc/install-git-hooks.go" >&2
|
||||
exit 1
|
||||
}
|
||||
"$BINARY" install
|
||||
fi
|
||||
|
||||
exec "$BINARY" "$@"
|
||||
|
||||
62
misc/git_hook/githook/pre-commit.go
Normal file
62
misc/git_hook/githook/pre-commit.go
Normal file
@ -0,0 +1,62 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package githook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/sourcegraph/go-diff/diff"
|
||||
)
|
||||
|
||||
var preCommitForbiddenPatterns = [][]byte{
|
||||
// Concatenation avoids tripping the check on this file.
|
||||
[]byte("NOCOM" + "MIT"),
|
||||
[]byte("DO NOT " + "SUBMIT"),
|
||||
}
|
||||
|
||||
// CheckForbiddenMarkers scans the staged diff for forbidden markers
|
||||
// and returns an error if any are found.
|
||||
//
|
||||
// Intended as a pre-commit hook.
|
||||
// https://git-scm.com/docs/githooks#_pre_commit
|
||||
func CheckForbiddenMarkers() error {
|
||||
diffOut, err := exec.Command("git", "diff", "--cached").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get git diff: %w", err)
|
||||
}
|
||||
|
||||
diffs, err := diff.ParseMultiFileDiff(diffOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse diff: %w", err)
|
||||
}
|
||||
|
||||
foundForbidden := false
|
||||
for _, d := range diffs {
|
||||
for _, hunk := range d.Hunks {
|
||||
lines := bytes.Split(hunk.Body, []byte{'\n'})
|
||||
for i, line := range lines {
|
||||
if len(line) == 0 || line[0] != '+' {
|
||||
continue
|
||||
}
|
||||
for _, forbidden := range preCommitForbiddenPatterns {
|
||||
if bytes.Contains(line, forbidden) {
|
||||
if !foundForbidden {
|
||||
color.New(color.Bold, color.FgRed, color.Underline).Printf("%s found:\n", forbidden)
|
||||
}
|
||||
fmt.Printf("%s:%d: %s\n", d.NewName[2:], int(hunk.NewStartLine)+i, line[1:])
|
||||
foundForbidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundForbidden {
|
||||
return errors.New("found forbidden string")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
112
misc/git_hook/githook/pre-push.go
Normal file
112
misc/git_hook/githook/pre-push.go
Normal file
@ -0,0 +1,112 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package githook
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
// CheckGoModReplaces reads pushes from stdin and, for pushes to a
|
||||
// remote URL in watchedRemotes, rejects any commit whose go.mod has a
|
||||
// directory-path replace that is not in allowedReplaceDirs. args is
|
||||
// the pre-push hook's argv (remoteName, remoteLoc).
|
||||
//
|
||||
// Intended as a pre-push hook.
|
||||
// https://git-scm.com/docs/githooks#_pre_push
|
||||
func CheckGoModReplaces(args []string, watchedRemotes, allowedReplaceDirs []string) error {
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("pre-push: expected 2 args, got %d", len(args))
|
||||
}
|
||||
remoteLoc := args[1]
|
||||
|
||||
watched := false
|
||||
for _, r := range watchedRemotes {
|
||||
if r == remoteLoc {
|
||||
watched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !watched {
|
||||
return nil
|
||||
}
|
||||
|
||||
pushes, err := readPushes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading pushes: %w", err)
|
||||
}
|
||||
for _, p := range pushes {
|
||||
if p.isDoNotMergeRef() {
|
||||
continue
|
||||
}
|
||||
if err := checkCommit(p.localSHA, allowedReplaceDirs); err != nil {
|
||||
return fmt.Errorf("not allowing push of %v to %v: %v", p.localSHA, p.remoteRef, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCommit(sha string, allowedReplaceDirs []string) error {
|
||||
if sha == zeroRef {
|
||||
// Allow ref deletions.
|
||||
return nil
|
||||
}
|
||||
goMod, err := exec.Command("git", "show", sha+":go.mod").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", goMod, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse its go.mod: %v", err)
|
||||
}
|
||||
for _, r := range mf.Replace {
|
||||
if !modfile.IsDirectoryPath(r.New.Path) {
|
||||
continue
|
||||
}
|
||||
allowed := false
|
||||
for _, a := range allowedReplaceDirs {
|
||||
if a == r.New.Path {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("go.mod contains replace from %v => %v", r.Old.Path, r.New.Path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const zeroRef = "0000000000000000000000000000000000000000"
|
||||
|
||||
type push struct {
|
||||
localRef string
|
||||
localSHA string
|
||||
remoteRef string
|
||||
remoteSHA string
|
||||
}
|
||||
|
||||
func (p *push) isDoNotMergeRef() bool {
|
||||
return strings.HasSuffix(p.remoteRef, "/DO-NOT-MERGE")
|
||||
}
|
||||
|
||||
func readPushes() (pushes []push, err error) {
|
||||
bs := bufio.NewScanner(os.Stdin)
|
||||
for bs.Scan() {
|
||||
f := strings.Fields(bs.Text())
|
||||
if len(f) != 4 {
|
||||
return nil, fmt.Errorf("unexpected push line %q", bs.Text())
|
||||
}
|
||||
pushes = append(pushes, push{f[0], f[1], f[2], f[3]})
|
||||
}
|
||||
if err := bs.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pushes, nil
|
||||
}
|
||||
@ -3,80 +3,19 @@
|
||||
|
||||
//go:build ignore
|
||||
|
||||
// The install-git-hooks program installs git hooks.
|
||||
//
|
||||
// It installs a Go binary at .git/hooks/ts-git-hook and a pre-hook
|
||||
// forwarding shell wrapper to .git/hooks/NAME.
|
||||
// The install-git-hooks program installs git hooks by delegating to
|
||||
// githook.Install. See that function's doc for what it does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/misc/git_hook/githook"
|
||||
)
|
||||
|
||||
var hooks = []string{
|
||||
"pre-push",
|
||||
"pre-commit",
|
||||
"commit-msg",
|
||||
"post-checkout",
|
||||
}
|
||||
|
||||
func fatalf(format string, a ...any) {
|
||||
log.SetFlags(0)
|
||||
log.Fatalf("install-git-hooks: "+format, a...)
|
||||
}
|
||||
|
||||
func main() {
|
||||
out, err := exec.Command("git", "rev-parse", "--git-common-dir").CombinedOutput()
|
||||
if err != nil {
|
||||
fatalf("finding git dir: %v, %s", err, out)
|
||||
}
|
||||
gitDir := strings.TrimSpace(string(out))
|
||||
|
||||
hookDir := filepath.Join(gitDir, "hooks")
|
||||
if fi, err := os.Stat(hookDir); err != nil {
|
||||
fatalf("checking hooks dir: %v", err)
|
||||
} else if !fi.IsDir() {
|
||||
fatalf("%s is not a directory", hookDir)
|
||||
}
|
||||
|
||||
buildOut, err := exec.Command(goBin(), "build",
|
||||
"-o", filepath.Join(hookDir, "ts-git-hook"+exe()),
|
||||
"./misc/git_hook").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalf("go build git-hook: %v, %s", err, buildOut)
|
||||
}
|
||||
|
||||
for _, hook := range hooks {
|
||||
content := fmt.Sprintf(hookScript, hook)
|
||||
file := filepath.Join(hookDir, hook)
|
||||
// Install the hook. If it already exists, overwrite it, in case there's
|
||||
// been changes.
|
||||
if err := os.WriteFile(file, []byte(content), 0755); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
log.SetFlags(0)
|
||||
if err := githook.Install(); err != nil {
|
||||
log.Fatalf("install-git-hooks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const hookScript = `#!/usr/bin/env bash
|
||||
exec "$(dirname ${BASH_SOURCE[0]})/ts-git-hook" %s "$@"
|
||||
`
|
||||
|
||||
func goBin() string {
|
||||
if p, err := exec.LookPath("go"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "go"
|
||||
}
|
||||
|
||||
func exe() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ".exe"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user