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:
Fernando Serboncini 2026-04-17 16:24:39 -04:00 committed by GitHub
parent 1fbb834dc3
commit 514d7d28e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 583 additions and 358 deletions

View File

@ -1 +1 @@
1
2

34
misc/git_hook/README.md Normal file
View 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.

View File

@ -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
}

View 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
}

View 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
}

View 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`,
)

View 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" "$@"

View 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
}

View 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
}

View File

@ -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 ""
}