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  `