misc/git_hook: reject pushes that add large files
Add a large blob check to the pre-push hook, using the same git tree diff logic as corp's check-file-size CI workflow (the check-git-accidental-large-file GitHub Action): diff the pushed tree against the remote's old tree (or the merge base with the remote's default branch for new refs) and reject any new or changed blob over 1.5 MB. Unlike the CI check, which only guards PRs into main, the hook runs before pushing to any branch, catching mistakes before they permanently bloat the remote repo. Set TS_SKIP_LARGE_FILE_CHECK=1 to push a large file intentionally, mirroring the skip-large-file-check commit message tag honored by CI. This folds the go.mod replace check and the new check into a single CheckPrePush entry point so both share one read of the hook's stdin; corp's git-hook.go needs the matching call site update when it next bumps its tailscale.com dependency. Updates tailscale/corp#9863 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I1c8cf2a277ce854d45c0ea809bed7c06b3295374
This commit is contained in:
committed by
Brad Fitzpatrick
parent
b062abb1ea
commit
8c98d2a417
@@ -14,6 +14,15 @@ From the repo root:
|
|||||||
The script auto-updates in the future.
|
The script auto-updates in the future.
|
||||||
|
|
||||||
|
|
||||||
|
## Large file check
|
||||||
|
|
||||||
|
The pre-push hook rejects pushes that add or change any blob over 1.5
|
||||||
|
MB, using the same tree diff logic as the check-file-size CI workflow.
|
||||||
|
To push a large file intentionally, set an environment variable:
|
||||||
|
|
||||||
|
TS_SKIP_LARGE_FILE_CHECK=1 git push ...
|
||||||
|
|
||||||
|
|
||||||
## Adding your own hooks
|
## Adding your own hooks
|
||||||
|
|
||||||
Create an executable `.git/hooks/<hook-name>.local` to chain a custom
|
Create an executable `.git/hooks/<hook-name>.local` to chain a custom
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import (
|
|||||||
"tailscale.com/misc/git_hook/githook"
|
"tailscale.com/misc/git_hook/githook"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxPushBlobSize is the largest new or changed blob allowed in a
|
||||||
|
// push. It matches the 1.5 MB limit enforced by the check-file-size CI
|
||||||
|
// workflow. Set TS_SKIP_LARGE_FILE_CHECK=1 to override.
|
||||||
|
const maxPushBlobSize = 1_500_000
|
||||||
|
|
||||||
var pushRemotes = []string{
|
var pushRemotes = []string{
|
||||||
"git@github.com:tailscale/tailscale",
|
"git@github.com:tailscale/tailscale",
|
||||||
"git@github.com:tailscale/tailscale.git",
|
"git@github.com:tailscale/tailscale.git",
|
||||||
@@ -51,7 +56,10 @@ func main() {
|
|||||||
case "commit-msg":
|
case "commit-msg":
|
||||||
err = githook.AddChangeID(args)
|
err = githook.AddChangeID(args)
|
||||||
case "pre-push":
|
case "pre-push":
|
||||||
err = githook.CheckGoModReplaces(args, pushRemotes, nil)
|
err = githook.CheckPrePush(args, githook.PrePushConfig{
|
||||||
|
WatchedRemotes: pushRemotes,
|
||||||
|
MaxBlobSize: maxPushBlobSize,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("git-hook: %v: %v", cmd, err)
|
log.Fatalf("git-hook: %v: %v", cmd, err)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3
|
4
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package githook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// skipLargeFileCheckEnv is the environment variable that, when set to a
|
||||||
|
// non-empty value, permits pushing new or changed blobs larger than the
|
||||||
|
// configured maximum size. It matches the "skip-large-file-check" commit
|
||||||
|
// message tag honored by the corp check-file-size CI workflow.
|
||||||
|
const skipLargeFileCheckEnv = "TS_SKIP_LARGE_FILE_CHECK"
|
||||||
|
|
||||||
|
// checkLargeBlobs rejects the push p if it adds or changes any blob
|
||||||
|
// larger than maxSize bytes, comparing the tree being pushed against
|
||||||
|
// the remote's previous tree (or, for new refs, the merge base with the
|
||||||
|
// remote's default branch). The same tree diff logic runs in CI via the
|
||||||
|
// check-git-accidental-large-file GitHub Action; this catches mistakes
|
||||||
|
// before they permanently bloat the remote repo.
|
||||||
|
func checkLargeBlobs(remoteName string, p push, maxSize int64) error {
|
||||||
|
if p.localSHA == zeroRef {
|
||||||
|
// Allow ref deletions.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if os.Getenv(skipLargeFileCheckEnv) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
afterTree, err := treeOf(p.localSHA)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving tree of %v: %v", p.localSHA, err)
|
||||||
|
}
|
||||||
|
beforeTree := findBaseTree(remoteName, p)
|
||||||
|
if beforeTree == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "git-hook: pre-push: no base tree found for %s; skipping large file check\n", p.remoteRef)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
large := appendLargeAdditions(nil, beforeTree, afterTree, "", maxSize)
|
||||||
|
if len(large) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, f := range large {
|
||||||
|
fmt.Fprintf(&sb, "\t%s: %d bytes (%0.1f MiB)\n", f.path, f.size, float64(f.size)/(1<<20))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("push adds files larger than %d bytes:\n%sset %s=1 to push anyway", maxSize, sb.String(), skipLargeFileCheckEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBaseTree returns the tree hash to diff the push against, or the
|
||||||
|
// empty string if no suitable base is available locally. For updates to
|
||||||
|
// an existing remote ref it uses the remote's old commit. For new refs
|
||||||
|
// it falls back to the merge base with the remote's default branch.
|
||||||
|
func findBaseTree(remoteName string, p push) string {
|
||||||
|
if p.remoteSHA != zeroRef {
|
||||||
|
if tree, err := treeOf(p.remoteSHA); err == nil {
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ref := range []string{
|
||||||
|
"refs/remotes/" + remoteName + "/HEAD",
|
||||||
|
"refs/remotes/" + remoteName + "/main",
|
||||||
|
"refs/remotes/" + remoteName + "/master",
|
||||||
|
} {
|
||||||
|
out, err := exec.Command("git", "merge-base", p.localSHA, ref).Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tree, err := treeOf(strings.TrimSpace(string(out))); err == nil {
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeOf resolves a git ref or commit to its tree hash.
|
||||||
|
func treeOf(ref string) (string, error) {
|
||||||
|
out, err := exec.Command("git", "rev-parse", "--verify", ref+"^{tree}").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeEntry is a single entry from git ls-tree.
|
||||||
|
type treeEntry struct {
|
||||||
|
mode string
|
||||||
|
typ string // "blob", "tree", or "commit"
|
||||||
|
hash string
|
||||||
|
size int64 // -1 for non-blob entries
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsTree returns the entries of the given tree object.
|
||||||
|
func lsTree(treeHash string) ([]treeEntry, error) {
|
||||||
|
out, err := exec.Command("git", "ls-tree", "-z", "--long", treeHash).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("git ls-tree %s: %v", treeHash, err)
|
||||||
|
}
|
||||||
|
var entries []treeEntry
|
||||||
|
for record := range bytes.SplitSeq(out, []byte{0}) {
|
||||||
|
if len(record) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Format: "<mode> <type> <hash> <size>\t<name>"
|
||||||
|
metaPart, name, ok := bytes.Cut(record, []byte{'\t'})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta := strings.Fields(string(metaPart))
|
||||||
|
if len(meta) != 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64 = -1
|
||||||
|
if meta[3] != "-" {
|
||||||
|
size, _ = strconv.ParseInt(meta[3], 10, 64)
|
||||||
|
}
|
||||||
|
entries = append(entries, treeEntry{
|
||||||
|
mode: meta[0],
|
||||||
|
typ: meta[1],
|
||||||
|
hash: meta[2],
|
||||||
|
size: size,
|
||||||
|
name: string(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type largeFile struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLargeAdditions walks two trees and returns dst plus any new or
|
||||||
|
// changed blobs exceeding maxSize. If beforeHash is empty, all blobs in
|
||||||
|
// afterHash are considered new. Unchanged subtrees are skipped without
|
||||||
|
// recursing, so the walk only visits the changed parts of the tree.
|
||||||
|
func appendLargeAdditions(dst []largeFile, beforeHash, afterHash, prefix string, maxSize int64) []largeFile {
|
||||||
|
afterEntries, err := lsTree(afterHash)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "git-hook: pre-push: %v\n", err)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
var beforeByName map[string]treeEntry
|
||||||
|
if beforeHash != "" {
|
||||||
|
beforeEntries, err := lsTree(beforeHash)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "git-hook: pre-push: %v\n", err)
|
||||||
|
}
|
||||||
|
beforeByName = make(map[string]treeEntry, len(beforeEntries))
|
||||||
|
for _, e := range beforeEntries {
|
||||||
|
beforeByName[e.name] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ae := range afterEntries {
|
||||||
|
if ae.mode == "160000" {
|
||||||
|
continue // skip submodules
|
||||||
|
}
|
||||||
|
|
||||||
|
be, inBefore := beforeByName[ae.name]
|
||||||
|
|
||||||
|
switch ae.typ {
|
||||||
|
case "tree":
|
||||||
|
if inBefore && be.hash == ae.hash {
|
||||||
|
continue // subtree unchanged
|
||||||
|
}
|
||||||
|
var beforeSub string
|
||||||
|
if inBefore && be.typ == "tree" {
|
||||||
|
beforeSub = be.hash
|
||||||
|
}
|
||||||
|
dst = appendLargeAdditions(dst, beforeSub, ae.hash, prefix+ae.name+"/", maxSize)
|
||||||
|
case "blob":
|
||||||
|
if inBefore && be.hash == ae.hash {
|
||||||
|
continue // blob unchanged
|
||||||
|
}
|
||||||
|
if ae.size > maxSize {
|
||||||
|
dst = append(dst, largeFile{path: prefix + ae.name, size: ae.size})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -14,36 +14,51 @@ import (
|
|||||||
"golang.org/x/mod/modfile"
|
"golang.org/x/mod/modfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckGoModReplaces reads pushes from stdin and, for pushes to a
|
// PrePushConfig configures CheckPrePush.
|
||||||
// remote URL in watchedRemotes, rejects any commit whose go.mod has a
|
type PrePushConfig struct {
|
||||||
// directory-path replace that is not in allowedReplaceDirs. args is
|
// WatchedRemotes are the remote URLs whose pushes are subject to
|
||||||
// the pre-push hook's argv (remoteName, remoteLoc).
|
// the go.mod replace check.
|
||||||
|
WatchedRemotes []string
|
||||||
|
|
||||||
|
// AllowedReplaceDirs are the directory-path go.mod replace targets
|
||||||
|
// that are permitted in pushed commits.
|
||||||
|
AllowedReplaceDirs []string
|
||||||
|
|
||||||
|
// MaxBlobSize, if positive, is the largest new or changed blob in
|
||||||
|
// bytes allowed in a push to any remote. Pushes adding larger
|
||||||
|
// blobs are rejected unless the TS_SKIP_LARGE_FILE_CHECK
|
||||||
|
// environment variable is set to a non-empty value.
|
||||||
|
MaxBlobSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPrePush reads pushes from stdin and validates them per cfg.
|
||||||
|
// args is the pre-push hook's argv (remoteName, remoteLoc).
|
||||||
//
|
//
|
||||||
// Intended as a pre-push hook.
|
// Intended as a pre-push hook.
|
||||||
// https://git-scm.com/docs/githooks#_pre_push
|
// https://git-scm.com/docs/githooks#_pre_push
|
||||||
func CheckGoModReplaces(args []string, watchedRemotes, allowedReplaceDirs []string) error {
|
func CheckPrePush(args []string, cfg PrePushConfig) error {
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
return fmt.Errorf("pre-push: expected 2 args, got %d", len(args))
|
return fmt.Errorf("pre-push: expected 2 args, got %d", len(args))
|
||||||
}
|
}
|
||||||
remoteLoc := args[1]
|
remoteName, remoteLoc := args[0], args[1]
|
||||||
|
|
||||||
watched := slices.Contains(watchedRemotes, remoteLoc)
|
|
||||||
if !watched {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pushes, err := readPushes()
|
pushes, err := readPushes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading pushes: %w", err)
|
return fmt.Errorf("reading pushes: %w", err)
|
||||||
}
|
}
|
||||||
|
watched := slices.Contains(cfg.WatchedRemotes, remoteLoc)
|
||||||
for _, p := range pushes {
|
for _, p := range pushes {
|
||||||
if p.isDoNotMergeRef() {
|
if watched && !p.isDoNotMergeRef() {
|
||||||
continue
|
if err := checkCommit(p.localSHA, cfg.AllowedReplaceDirs); err != nil {
|
||||||
}
|
|
||||||
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 fmt.Errorf("not allowing push of %v to %v: %v", p.localSHA, p.remoteRef, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if cfg.MaxBlobSize > 0 {
|
||||||
|
if err := checkLargeBlobs(remoteName, p, cfg.MaxBlobSize); err != nil {
|
||||||
|
return fmt.Errorf("not allowing push of %v to %v: %v", p.localSHA, p.remoteRef, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user