From 514d7d28e799a4ef5d829c4d966c8fff6c3e7cdb Mon Sep 17 00:00:00 2001 From: Fernando Serboncini Date: Fri, 17 Apr 2026 16:24:39 -0400 Subject: [PATCH] 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 --- misc/git_hook/HOOK_VERSION | 2 +- misc/git_hook/README.md | 34 +++ misc/git_hook/git-hook.go | 322 +++------------------------- misc/git_hook/githook/commit-msg.go | 64 ++++++ misc/git_hook/githook/githook.go | 46 ++++ misc/git_hook/githook/install.go | 177 +++++++++++++++ misc/git_hook/githook/launcher.sh | 47 ++++ misc/git_hook/githook/pre-commit.go | 62 ++++++ misc/git_hook/githook/pre-push.go | 112 ++++++++++ misc/install-git-hooks.go | 75 +------ 10 files changed, 583 insertions(+), 358 deletions(-) create mode 100644 misc/git_hook/README.md create mode 100644 misc/git_hook/githook/commit-msg.go create mode 100644 misc/git_hook/githook/githook.go create mode 100644 misc/git_hook/githook/install.go create mode 100755 misc/git_hook/githook/launcher.sh create mode 100644 misc/git_hook/githook/pre-commit.go create mode 100644 misc/git_hook/githook/pre-push.go diff --git a/misc/git_hook/HOOK_VERSION b/misc/git_hook/HOOK_VERSION index d00491fd7..0cfbf0888 100644 --- a/misc/git_hook/HOOK_VERSION +++ b/misc/git_hook/HOOK_VERSION @@ -1 +1 @@ -1 +2 diff --git a/misc/git_hook/README.md b/misc/git_hook/README.md new file mode 100644 index 000000000..81c15dc58 --- /dev/null +++ b/misc/git_hook/README.md @@ -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/.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. diff --git a/misc/git_hook/git-hook.go b/misc/git_hook/git-hook.go index 89e78b120..4af11c065 100644 --- a/misc/git_hook/git-hook.go +++ b/misc/git_hook/git-hook.go @@ -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 -// .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/.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: -// -// SP SP SP 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 -} diff --git a/misc/git_hook/githook/commit-msg.go b/misc/git_hook/githook/commit-msg.go new file mode 100644 index 000000000..e75bc79f3 --- /dev/null +++ b/misc/git_hook/githook/commit-msg.go @@ -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 +} diff --git a/misc/git_hook/githook/githook.go b/misc/git_hook/githook/githook.go new file mode 100644 index 000000000..de5d7e4c2 --- /dev/null +++ b/misc/git_hook/githook/githook.go @@ -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/.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 +} diff --git a/misc/git_hook/githook/install.go b/misc/git_hook/githook/install.go new file mode 100644 index 000000000..3c08daf8d --- /dev/null +++ b/misc/git_hook/githook/install.go @@ -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/. 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`, +) diff --git a/misc/git_hook/githook/launcher.sh b/misc/git_hook/githook/launcher.sh new file mode 100755 index 000000000..8a6d00885 --- /dev/null +++ b/misc/git_hook/githook/launcher.sh @@ -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" "$@" + diff --git a/misc/git_hook/githook/pre-commit.go b/misc/git_hook/githook/pre-commit.go new file mode 100644 index 000000000..30e4f6a9e --- /dev/null +++ b/misc/git_hook/githook/pre-commit.go @@ -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 +} diff --git a/misc/git_hook/githook/pre-push.go b/misc/git_hook/githook/pre-push.go new file mode 100644 index 000000000..9d5624523 --- /dev/null +++ b/misc/git_hook/githook/pre-push.go @@ -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 +} diff --git a/misc/install-git-hooks.go b/misc/install-git-hooks.go index c66ecb8f8..813a45601 100644 --- a/misc/install-git-hooks.go +++ b/misc/install-git-hooks.go @@ -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 "" -}