forked from perkeep/perkeep
-
Notifications
You must be signed in to change notification settings - Fork 0
/
hook.go
383 lines (338 loc) · 10.3 KB
/
hook.go
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
/*
Copyright 2015 The Camlistore Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file adds the "hook" subcommand to devcam, to install and run git hooks.
package main
import (
"bytes"
"crypto/rand"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"camlistore.org/pkg/cmdmain"
)
var hookPath = ".git/hooks/"
var hookFiles = []string{
"pre-commit",
"commit-msg",
}
var ignoreBelow = []byte("\n# ------------------------ >8 ------------------------\n")
func (c *hookCmd) installHook() error {
root, err := repoRoot()
if err != nil {
return err
}
for _, hookFile := range hookFiles {
filename := filepath.Join(root, hookPath+hookFile)
hookContent := fmt.Sprintf(hookScript, hookFile)
// If hook file exists, assume it is okay.
_, err := os.Stat(filename)
if err == nil {
if c.verbose {
data, err := ioutil.ReadFile(filename)
if err != nil {
c.verbosef("reading hook: %v", err)
} else if string(data) != hookContent {
c.verbosef("unexpected hook content in %s", filename)
}
}
continue
}
if !os.IsNotExist(err) {
return fmt.Errorf("checking hook: %v", err)
}
c.verbosef("installing %s hook", hookFile)
if err := ioutil.WriteFile(filename, []byte(hookContent), 0700); err != nil {
return fmt.Errorf("writing hook: %v", err)
}
}
return nil
}
var hookScript = `#!/bin/sh
exec devcam hook %s "$@"
`
type hookCmd struct {
verbose bool
}
func init() {
cmdmain.RegisterCommand("hook", func(flags *flag.FlagSet) cmdmain.CommandRunner {
cmd := &hookCmd{}
flags.BoolVar(&cmd.verbose, "verbose", false, "Be verbose.")
// TODO(mpl): "-w" flag to run gofmt -w and devcam fixv -w. for now just print instruction.
return cmd
})
}
func (c *hookCmd) Usage() {
printf("Usage: devcam [globalopts] hook [[hook-name] [args...]]\n")
}
func (c *hookCmd) Examples() []string {
return []string{
"# install the hooks (if needed)",
"pre-commit # install the hooks (if needed), then run the pre-commit hook",
}
}
func (c *hookCmd) Describe() string {
return "Install git hooks for Camlistore, and if given, run the hook given as argument. Currently available hooks are: " + strings.TrimSuffix(strings.Join(hookFiles, ", "), ",") + "."
}
func (c *hookCmd) RunCommand(args []string) error {
if err := c.installHook(); err != nil {
return err
}
if len(args) == 0 {
return nil
}
switch args[0] {
case "pre-commit":
if err := c.hookPreCommit(args[1:]); err != nil {
if !(len(args) > 1 && args[1] == "test") {
printf("You can override these checks with 'git commit --no-verify'\n")
}
cmdmain.ExitWithFailure = true
return err
}
case "commit-msg":
if err := c.hookCommitMsg(args[1:]); err != nil {
cmdmain.ExitWithFailure = true
return err
}
}
return nil
}
// stripComments strips lines that begin with "#" and removes the diff section
// contained in verbose commits.
func stripComments(in []byte) []byte {
if i := bytes.Index(in, ignoreBelow); i >= 0 {
in = in[:i+1]
}
return regexp.MustCompile(`(?m)^#.*\n`).ReplaceAll(in, nil)
}
// hookCommitMsg is installed as the git commit-msg hook.
// It adds a Change-Id line to the bottom of the commit message
// if there is not one already.
// Code mostly copied from golang.org/x/review/git-codereview/hook.go
func (c *hookCmd) hookCommitMsg(args []string) error {
if len(args) != 1 {
return errors.New("usage: devcam hook commit-msg message.txt\n")
}
file := args[0]
oldData, err := ioutil.ReadFile(file)
if err != nil {
return err
}
data := append([]byte{}, oldData...)
data = stripComments(data)
// Empty message not allowed.
if len(bytes.TrimSpace(data)) == 0 {
return errors.New("empty commit message")
}
// Insert a blank line between first line and subsequent lines if not present.
eol := bytes.IndexByte(data, '\n')
if eol != -1 && len(data) > eol+1 && data[eol+1] != '\n' {
data = append(data, 0)
copy(data[eol+1:], data[eol:])
data[eol+1] = '\n'
}
// Complain if two Change-Ids are present.
// This can happen during an interactive rebase;
// it is easy to forget to remove one of them.
nChangeId := bytes.Count(data, []byte("\nChange-Id: "))
if nChangeId > 1 {
return errors.New("multiple Change-Id lines")
}
// Add Change-Id to commit message if not present.
if nChangeId == 0 {
n := len(data)
for n > 0 && data[n-1] == '\n' {
n--
}
var id [20]byte
if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
return fmt.Errorf("could not generate Change-Id: %v", err)
}
data = append(data[:n], fmt.Sprintf("\n\nChange-Id: I%x\n", id[:])...)
}
// Write back.
if !bytes.Equal(data, oldData) {
return ioutil.WriteFile(file, data, 0666)
}
return nil
}
// hookPreCommit does the following checks, in order:
// gofmt, and trailing space.
// If appropriate, any one of these checks prints the action
// required from the user, and the following checks are not
// performed.
func (c *hookCmd) hookPreCommit(args []string) (err error) {
if err = c.hookGofmt(); err != nil {
return err
}
return c.hookTrailingSpace()
}
// hookGofmt runs a gofmt check on the local files matching the files in the
// git staging area.
// An error is returned if something went wrong or if some of the files need
// gofmting. In the latter case, the instruction is printed.
func (c *hookCmd) hookGofmt() error {
if os.Getenv("GIT_GOFMT_HOOK") == "off" {
printf("gofmt disabled by $GIT_GOFMT_HOOK=off\n")
return nil
}
files, err := c.runGofmt()
if err != nil {
printf("gofmt hook reported errors:\n\t%v\n", strings.Replace(strings.TrimSpace(err.Error()), "\n", "\n\t", -1))
return errors.New("gofmt errors")
}
if len(files) == 0 {
return nil
}
printf("You need to format with gofmt:\n\tgofmt -w %s\n",
strings.Join(files, " "))
return errors.New("gofmt required")
}
func (c *hookCmd) hookTrailingSpace() error {
out, _ := cmdOutputDirErr(".", "git", "diff-index", "--check", "--diff-filter=ACM", "--cached", "HEAD", "--")
if out != "" {
printf("\n%s", out)
printf("Trailing whitespace detected, you need to clean it up manually.\n")
return errors.New("trailing whitespace.")
}
return nil
}
// runGofmt runs the external gofmt command over the local version of staged files.
// It returns the files that need gofmting.
func (c *hookCmd) runGofmt() (files []string, err error) {
repo, err := repoRoot()
if err != nil {
return nil, err
}
if !strings.HasSuffix(repo, string(filepath.Separator)) {
repo += string(filepath.Separator)
}
out, err := cmdOutputDirErr(".", "git", "diff-index", "--name-only", "--diff-filter=ACM", "--cached", "HEAD", "--")
if err != nil {
return nil, err
}
indexFiles := addRoot(repo, filter(gofmtRequired, nonBlankLines(out)))
if len(indexFiles) == 0 {
return
}
args := []string{"-l"}
// TODO(mpl): it would be nice to TrimPrefix the pwd from each file to get a shorter output.
// However, since git sets the pwd to GIT_DIR before running the pre-commit hook, we lost
// the actual pwd from when we ran `git commit`, so no dice so far.
for _, file := range indexFiles {
args = append(args, file)
}
if c.verbose {
fmt.Fprintln(cmdmain.Stderr, commandString("gofmt", args))
}
cmd := exec.Command("gofmt", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
// Error but no stderr: usually can't find gofmt.
if stderr.Len() == 0 {
return nil, fmt.Errorf("invoking gofmt: %v", err)
}
return nil, fmt.Errorf("%s: %v", stderr.String(), err)
}
// Build file list.
files = lines(stdout.String())
sort.Strings(files)
return files, nil
}
func printf(format string, args ...interface{}) {
cmdmain.Errorf(format, args...)
}
func addRoot(root string, list []string) []string {
var out []string
for _, x := range list {
out = append(out, filepath.Join(root, x))
}
return out
}
// nonBlankLines returns the non-blank lines in text.
func nonBlankLines(text string) []string {
var out []string
for _, s := range lines(text) {
if strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
return out
}
// filter returns the elements in list satisfying f.
func filter(f func(string) bool, list []string) []string {
var out []string
for _, x := range list {
if f(x) {
out = append(out, x)
}
}
return out
}
// gofmtRequired reports whether the specified file should be checked
// for gofmt'dness by the pre-commit hook.
// The file name is relative to the repo root.
func gofmtRequired(file string) bool {
if !strings.HasSuffix(file, ".go") {
return false
}
if !strings.HasPrefix(file, "test/") {
return true
}
return strings.HasPrefix(file, "test/bench/") || file == "test/run.go"
}
func commandString(command string, args []string) string {
return strings.Join(append([]string{command}, args...), " ")
}
func lines(text string) []string {
out := strings.Split(text, "\n")
// Split will include a "" after the last line. Remove it.
if n := len(out) - 1; n >= 0 && out[n] == "" {
out = out[:n]
}
return out
}
func (c *hookCmd) verbosef(format string, args ...interface{}) {
if c.verbose {
fmt.Fprintf(cmdmain.Stdout, format, args...)
}
}
// cmdOutputDirErr runs the command line in dir, returning its output
// and any error results.
//
// NOTE: cmdOutputDirErr must be used only to run commands that read state,
// not for commands that make changes. Commands that make changes
// should be run using runDirErr so that the -v and -n flags apply to them.
func cmdOutputDirErr(dir, command string, args ...string) (string, error) {
// NOTE: We only show these non-state-modifying commands with -v -v.
// Otherwise things like 'git sync -v' show all our internal "find out about
// the git repo" commands, which is confusing if you are just trying to find
// out what git sync means.
cmd := exec.Command(command, args...)
if dir != "." {
cmd.Dir = dir
}
b, err := cmd.CombinedOutput()
return string(b), err
}