github.com/golang/review@v0.0.0-20190122205339-266ee1edf5c3/git-codereview/hook.go (about) 1 // Copyright 2014 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package main 6 7 import ( 8 "bytes" 9 "crypto/rand" 10 "fmt" 11 "io" 12 "io/ioutil" 13 "os" 14 "path/filepath" 15 "regexp" 16 "strings" 17 ) 18 19 var hookFiles = []string{ 20 "commit-msg", 21 "pre-commit", 22 } 23 24 func installHook(args []string) { 25 flags.Parse(args) 26 hooksDir := gitPath("hooks") 27 for _, hookFile := range hookFiles { 28 filename := filepath.Join(hooksDir, hookFile) 29 hookContent := fmt.Sprintf(hookScript, hookFile) 30 31 if data, err := ioutil.ReadFile(filename); err == nil { 32 // Special case: remove old hooks that use 'git-review' 33 oldHookContent := fmt.Sprintf(oldHookScript, hookFile) 34 if string(data) == oldHookContent { 35 verbosef("removing old %v hook", hookFile) 36 os.Remove(filename) 37 } 38 // Special case: remove old commit-msg shell script 39 // in favor of invoking the git-codereview hook 40 // implementation, which will be easier to change in 41 // the future. 42 if hookFile == "commit-msg" && string(data) == oldCommitMsgHook { 43 verbosef("removing old commit-msg hook") 44 os.Remove(filename) 45 } 46 } 47 48 // If hook file exists, assume it is okay. 49 _, err := os.Stat(filename) 50 if err == nil { 51 if *verbose > 0 { 52 data, err := ioutil.ReadFile(filename) 53 if err != nil { 54 verbosef("reading hook: %v", err) 55 } else if string(data) != hookContent { 56 verbosef("unexpected hook content in %s", filename) 57 } 58 } 59 continue 60 } 61 62 if !os.IsNotExist(err) { 63 dief("checking hook: %v", err) 64 } 65 66 verbosef("installing %s hook", hookFile) 67 if _, err := os.Stat(hooksDir); os.IsNotExist(err) { 68 verbosef("creating hooks directory %s", hooksDir) 69 if err := os.Mkdir(hooksDir, 0777); err != nil { 70 dief("creating hooks directory: %v", err) 71 } 72 } 73 if err := ioutil.WriteFile(filename, []byte(hookContent), 0700); err != nil { 74 dief("writing hook: %v", err) 75 } 76 } 77 } 78 79 func repoRoot() string { 80 return filepath.Clean(trim(cmdOutput("git", "rev-parse", "--show-toplevel"))) 81 } 82 83 // gitPath resolve the $GIT_DIR/path, taking in consideration 84 // all other path relocations, e.g. hooks for linked worktrees 85 // are not kept in their gitdir, but shared in the main one. 86 func gitPath(path string) string { 87 root := repoRoot() 88 // git 2.13.0 changed the behavior of --git-path from printing 89 // a path relative to the repo root to printing a path 90 // relative to the working directory (issue #19477). Normalize 91 // both behaviors by running the command from the repo root. 92 p, err := trimErr(cmdOutputErr("git", "-C", root, "rev-parse", "--git-path", path)) 93 if err != nil { 94 // When --git-path is not available, assume the common case. 95 p = filepath.Join(".git", path) 96 } 97 if !filepath.IsAbs(p) { 98 p = filepath.Join(root, p) 99 } 100 return p 101 } 102 103 var hookScript = `#!/bin/sh 104 exec git-codereview hook-invoke %s "$@" 105 ` 106 107 var oldHookScript = `#!/bin/sh 108 exec git-review hook-invoke %s "$@" 109 ` 110 111 func cmdHookInvoke(args []string) { 112 flags.Parse(args) 113 args = flags.Args() 114 if len(args) == 0 { 115 dief("usage: git-codereview hook-invoke <hook-name> [args...]") 116 } 117 switch args[0] { 118 case "commit-msg": 119 hookCommitMsg(args[1:]) 120 case "pre-commit": 121 hookPreCommit(args[1:]) 122 } 123 } 124 125 var ( 126 issueRefRE = regexp.MustCompile(`(?P<space>\s)(?P<ref>#\d+\w)`) 127 oldFixesRETemplate = `Fixes +(issue +(%s)?#?)?(?P<issueNum>[0-9]+)` 128 ) 129 130 // hookCommitMsg is installed as the git commit-msg hook. 131 // It adds a Change-Id line to the bottom of the commit message 132 // if there is not one already. 133 func hookCommitMsg(args []string) { 134 if len(args) != 1 { 135 dief("usage: git-codereview hook-invoke commit-msg message.txt\n") 136 } 137 138 // We used to bail in detached head mode, but it's very common 139 // to be modifying things during git rebase -i and it's annoying 140 // that those new commits made don't get Commit-Msg lines. 141 // Let's try keeping the hook on and see what breaks. 142 /* 143 b := CurrentBranch() 144 if b.DetachedHead() { 145 // Likely executing rebase or some other internal operation. 146 // Probably a mistake to make commit message changes. 147 return 148 } 149 */ 150 151 file := args[0] 152 oldData, err := ioutil.ReadFile(file) 153 if err != nil { 154 dief("%v", err) 155 } 156 data := append([]byte{}, oldData...) 157 data = stripComments(data) 158 159 // Empty message not allowed. 160 if len(bytes.TrimSpace(data)) == 0 { 161 dief("empty commit message") 162 } 163 164 // Insert a blank line between first line and subsequent lines if not present. 165 eol := bytes.IndexByte(data, '\n') 166 if eol != -1 && len(data) > eol+1 && data[eol+1] != '\n' { 167 data = append(data, 0) 168 copy(data[eol+1:], data[eol:]) 169 data[eol+1] = '\n' 170 } 171 172 issueRepo := config()["issuerepo"] 173 // Update issue references to point to issue repo, if set. 174 if issueRepo != "" { 175 data = issueRefRE.ReplaceAll(data, []byte("${space}"+issueRepo+"${ref}")) 176 } 177 // TestHookCommitMsgIssueRepoRewrite makes sure the regex is valid 178 oldFixesRE := regexp.MustCompile(fmt.Sprintf(oldFixesRETemplate, regexp.QuoteMeta(issueRepo))) 179 data = oldFixesRE.ReplaceAll(data, []byte("Fixes "+issueRepo+"#${issueNum}")) 180 181 if haveGerrit() { 182 // Complain if two Change-Ids are present. 183 // This can happen during an interactive rebase; 184 // it is easy to forget to remove one of them. 185 nChangeId := bytes.Count(data, []byte("\nChange-Id: ")) 186 if nChangeId > 1 { 187 dief("multiple Change-Id lines") 188 } 189 190 // Add Change-Id to commit message if not present. 191 if nChangeId == 0 { 192 n := len(data) 193 for n > 0 && data[n-1] == '\n' { 194 n-- 195 } 196 var id [20]byte 197 if _, err := io.ReadFull(rand.Reader, id[:]); err != nil { 198 dief("generating Change-Id: %v", err) 199 } 200 data = append(data[:n], fmt.Sprintf("\n\nChange-Id: I%x\n", id[:])...) 201 } 202 203 // Add branch prefix to commit message if not present and on a 204 // dev or release branch and not a special Git fixup! or 205 // squash! commit message. 206 b := CurrentBranch() 207 branch := strings.TrimPrefix(b.OriginBranch(), "origin/") 208 if strings.HasPrefix(branch, "dev.") || strings.HasPrefix(branch, "release-branch.") { 209 prefix := "[" + branch + "] " 210 if !bytes.HasPrefix(data, []byte(prefix)) && !isFixup(data) { 211 data = []byte(prefix + string(data)) 212 } 213 } 214 } 215 216 // Write back. 217 if !bytes.Equal(data, oldData) { 218 if err := ioutil.WriteFile(file, data, 0666); err != nil { 219 dief("%v", err) 220 } 221 } 222 } 223 224 var ( 225 fixupBang = []byte("fixup!") 226 squashBang = []byte("squash!") 227 228 ignoreBelow = []byte("\n# ------------------------ >8 ------------------------\n") 229 ) 230 231 // isFixup reports whether text is a Git fixup! or squash! commit, 232 // which must not have a prefix. 233 func isFixup(text []byte) bool { 234 return bytes.HasPrefix(text, fixupBang) || bytes.HasPrefix(text, squashBang) 235 } 236 237 // stripComments strips lines that begin with "#" and removes the 238 // "everything below will be removed" section containing the diff when 239 // using commit --verbose. 240 func stripComments(in []byte) []byte { 241 // Issue 16376 242 if i := bytes.Index(in, ignoreBelow); i >= 0 { 243 in = in[:i+1] 244 } 245 return regexp.MustCompile(`(?m)^#.*\n`).ReplaceAll(in, nil) 246 } 247 248 // hookPreCommit is installed as the git pre-commit hook. 249 // It prevents commits to the master branch. 250 // It checks that the Go files added, copied, or modified by 251 // the change are gofmt'd, and if not it prints gofmt instructions 252 // and exits with nonzero status. 253 func hookPreCommit(args []string) { 254 // We used to bail in detached head mode, but it's very common 255 // to be modifying things during git rebase -i and it's annoying 256 // that those new commits made don't get the gofmt check. 257 // Let's try keeping the hook on and see what breaks. 258 /* 259 b := CurrentBranch() 260 if b.DetachedHead() { 261 // This is an internal commit such as during git rebase. 262 // Don't die, and don't force gofmt. 263 return 264 } 265 */ 266 267 // Prevent commits to master branches, but only if we're here for code review. 268 if haveGerrit() { 269 b := CurrentBranch() 270 if !b.IsLocalOnly() && b.Name != "HEAD" { 271 dief("cannot commit on %s branch", b.Name) 272 } 273 } 274 275 hookGofmt() 276 } 277 278 func hookGofmt() { 279 if os.Getenv("GIT_GOFMT_HOOK") == "off" { 280 fmt.Fprintf(stderr(), "git-codereview pre-commit gofmt hook disabled by $GIT_GOFMT_HOOK=off\n") 281 return 282 } 283 284 files, stderr := runGofmt(gofmtPreCommit) 285 286 if stderr != "" { 287 msgf := printf 288 if len(files) == 0 { 289 msgf = dief 290 } 291 msgf("gofmt reported errors:\n\t%s", strings.Replace(strings.TrimSpace(stderr), "\n", "\n\t", -1)) 292 } 293 294 if len(files) == 0 { 295 return 296 } 297 298 dief("gofmt needs to format these files (run 'git gofmt'):\n\t%s", 299 strings.Join(files, "\n\t")) 300 } 301 302 // This is NOT USED ANYMORE. 303 // It is here only for comparing against old commit-hook files. 304 var oldCommitMsgHook = `#!/bin/sh 305 # From Gerrit Code Review 2.2.1 306 # 307 # Part of Gerrit Code Review (http://code.google.com/p/gerrit/) 308 # 309 # Copyright (C) 2009 The Android Open Source Project 310 # 311 # Licensed under the Apache License, Version 2.0 (the "License"); 312 # you may not use this file except in compliance with the License. 313 # You may obtain a copy of the License at 314 # 315 # http://www.apache.org/licenses/LICENSE-2.0 316 # 317 # Unless required by applicable law or agreed to in writing, software 318 # distributed under the License is distributed on an "AS IS" BASIS, 319 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 320 # See the License for the specific language governing permissions and 321 # limitations under the License. 322 # 323 324 CHANGE_ID_AFTER="Bug|Issue" 325 MSG="$1" 326 327 # Check for, and add if missing, a unique Change-Id 328 # 329 add_ChangeId() { 330 clean_message=` + "`" + `sed -e ' 331 /^diff --git a\/.*/{ 332 s/// 333 q 334 } 335 /^Signed-off-by:/d 336 /^#/d 337 ' "$MSG" | git stripspace` + "`" + ` 338 if test -z "$clean_message" 339 then 340 return 341 fi 342 343 if grep -i '^Change-Id:' "$MSG" >/dev/null 344 then 345 return 346 fi 347 348 id=` + "`" + `_gen_ChangeId` + "`" + ` 349 perl -e ' 350 $MSG = shift; 351 $id = shift; 352 $CHANGE_ID_AFTER = shift; 353 354 undef $/; 355 open(I, $MSG); $_ = <I>; close I; 356 s|^diff --git a/.*||ms; 357 s|^#.*$||mg; 358 exit unless $_; 359 360 @message = split /\n/; 361 $haveFooter = 0; 362 $startFooter = @message; 363 for($line = @message - 1; $line >= 0; $line--) { 364 $_ = $message[$line]; 365 366 if (/^[a-zA-Z0-9-]+:/ && !m,^[a-z0-9-]+://,) { 367 $haveFooter++; 368 next; 369 } 370 next if /^[ []/; 371 $startFooter = $line if ($haveFooter && /^\r?$/); 372 last; 373 } 374 375 @footer = @message[$startFooter+1..@message]; 376 @message = @message[0..$startFooter]; 377 push(@footer, "") unless @footer; 378 379 for ($line = 0; $line < @footer; $line++) { 380 $_ = $footer[$line]; 381 next if /^($CHANGE_ID_AFTER):/i; 382 last; 383 } 384 splice(@footer, $line, 0, "Change-Id: I$id"); 385 386 $_ = join("\n", @message, @footer); 387 open(O, ">$MSG"); print O; close O; 388 ' "$MSG" "$id" "$CHANGE_ID_AFTER" 389 } 390 _gen_ChangeIdInput() { 391 echo "tree ` + "`" + `git write-tree` + "`" + `" 392 if parent=` + "`" + `git rev-parse HEAD^0 2>/dev/null` + "`" + ` 393 then 394 echo "parent $parent" 395 fi 396 echo "author ` + "`" + `git var GIT_AUTHOR_IDENT` + "`" + `" 397 echo "committer ` + "`" + `git var GIT_COMMITTER_IDENT` + "`" + `" 398 echo 399 printf '%s' "$clean_message" 400 } 401 _gen_ChangeId() { 402 _gen_ChangeIdInput | 403 git hash-object -t commit --stdin 404 } 405 406 407 add_ChangeId 408 `