summaryrefslogtreecommitdiffhomepage
path: root/misc/git_hook/git-hook.go
blob: 89e78b120b28b50ed06f7f7abf886fe2ca4001c7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
// 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.
//
// # 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.
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"
)

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 "pre-commit":
		err = preCommit(args)
	case "commit-msg":
		err = commitMsg(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
	}
	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
}