summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFernando Serboncini <fserb@tailscale.com>2026-04-17 16:24:39 -0400
committerGitHub <noreply@github.com>2026-04-17 16:24:39 -0400
commit514d7d28e799a4ef5d829c4d966c8fff6c3e7cdb (patch)
treeabe29c0abafcc5f6170fe326e4d3dff1fc24bf1b
parent1fbb834dc32a12f98cada56060b71bfffb37301e (diff)
downloadtailscale-514d7d28e799a4ef5d829c4d966c8fff6c3e7cdb.tar.xz
tailscale-514d7d28e799a4ef5d829c4d966c8fff6c3e7cdb.zip
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>
-rw-r--r--misc/git_hook/HOOK_VERSION2
-rw-r--r--misc/git_hook/README.md34
-rw-r--r--misc/git_hook/git-hook.go322
-rw-r--r--misc/git_hook/githook/commit-msg.go64
-rw-r--r--misc/git_hook/githook/githook.go46
-rw-r--r--misc/git_hook/githook/install.go177
-rwxr-xr-xmisc/git_hook/githook/launcher.sh47
-rw-r--r--misc/git_hook/githook/pre-commit.go62
-rw-r--r--misc/git_hook/githook/pre-push.go112
-rw-r--r--misc/install-git-hooks.go75
10 files changed, 583 insertions, 358 deletions
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/<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.
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
-// <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)
- }
- if err != nil {
- p := log.Fatalf
- if nfe, ok := err.(nonFatalErr); ok {
- p = log.Printf
- err = nfe
- }
- p("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)
- }
- }
-}
-
-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
+ err = githook.CheckGoModReplaces(args, pushRemotes, nil)
}
- 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
+ log.Fatalf("git-hook: %v: %v", cmd, 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
+ if err := githook.RunLocalHook(cmd, args); err != nil {
+ log.Fatalf("git-hook: %v", err)
}
- 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/<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
+}
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/<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`,
+)
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"
-)
-
-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...)
-}
+ "tailscale.com/misc/git_hook/githook"
+)
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)
- }
- }
-}
-
-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"
+ log.SetFlags(0)
+ if err := githook.Install(); err != nil {
+ log.Fatalf("install-git-hooks: %v", err)
}
- return ""
}