review/git-review: rewrite commit-msg hook in Go

We are surely going to need to change this in the future.
Make the commit-hook script as trivial as possible, just
delegating to the git-review command, so that we have
some hope of making changes when we need to.

This should also be a good pattern for the gofmt hook.

Change-Id: Ia9cab1320cf50c12c1766a91f7164696b68d8922
Reviewed-on: https://go-review.googlesource.com/1584
Reviewed-by: Andrew Gerrand <adg@golang.org>
diff --git a/git-review/hook.go b/git-review/hook.go
index 7e86457..bb5b024 100644
--- a/git-review/hook.go
+++ b/git-review/hook.go
@@ -5,28 +5,59 @@
 package main
 
 import (
+	"bytes"
+	"crypto/rand"
+	"fmt"
+	"io"
 	"io/ioutil"
 	"os"
 	"path/filepath"
 	"runtime"
 )
 
-var hookFile = filepath.FromSlash(".git/hooks/commit-msg")
+var hookPath = ".git/hooks/"
+var hookFiles = []string{
+	"commit-msg",
+}
 
 func installHook() {
-	filename := filepath.Join(repoRoot(), hookFile)
-	_, err := os.Stat(filename)
-	if err == nil {
-		return
-	}
-	if !os.IsNotExist(err) {
-		dief("error checking for hook file: %v", err)
-	}
-	verbosef("Presubmit hook to add Change-Id to commit messages is missing.")
-	verbosef("Automatically creating it at %v.", filename)
-	hookContent := []byte(commitMsgHook)
-	if err := ioutil.WriteFile(filename, hookContent, 0700); err != nil {
-		dief("error writing hook file: %v", err)
+	for _, hookFile := range hookFiles {
+		filename := filepath.Join(repoRoot(), hookPath+hookFile)
+
+		// Special case: remove old commit-msg shell script
+		// in favor of invoking the git-review hook implementation,
+		// which will be easier to change in the future.
+		if hookFile == "commit-msg" {
+			data, err := ioutil.ReadFile(filename)
+			if err == nil && string(data) == oldCommitMsgHook {
+				verbosef("removing old commit-msg hook")
+				os.Remove(filename)
+			}
+		}
+
+		hookContent := fmt.Sprintf(hookScript, hookFile)
+
+		// If hook file exists, assume it is okay.
+		_, err := os.Stat(filename)
+		if err == nil {
+			if *verbose > 0 {
+				data, err := ioutil.ReadFile(filename)
+				if err != nil {
+					verbosef("reading hook: %v", err)
+				} else if string(data) != hookContent {
+					verbosef("unexpected hook content in %s", filename)
+				}
+			}
+			continue
+		}
+
+		if !os.IsNotExist(err) {
+			dief("checking hook: %v", err)
+		}
+		verbosef("installing %s hook", hookFile)
+		if err := ioutil.WriteFile(filename, []byte(hookContent), 0700); err != nil {
+			dief("writing hook: %v", err)
+		}
 	}
 }
 
@@ -50,7 +81,53 @@
 	}
 }
 
-var commitMsgHook = `#!/bin/sh
+var hookScript = `#!/bin/sh
+exec git-review hook-invoke %s "$@"
+`
+
+func hookInvoke(args []string) {
+	if len(args) == 0 {
+		dief("usage: git-review hook-invoke <hook-name> [args...]")
+	}
+	switch args[0] {
+	case "commit-msg":
+		hookCommitMsg(args[1:])
+	}
+}
+
+// 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.
+func hookCommitMsg(args []string) {
+	// Add Change-Id to commit message if needed.
+	if len(args) != 1 {
+		dief("usage: git-review hook-invoke commit-msg message.txt\n")
+	}
+	file := args[0]
+	data, err := ioutil.ReadFile(file)
+	if err != nil {
+		dief("%v", err)
+	}
+	if bytes.Contains(data, []byte("\nChange-Id: ")) {
+		return
+	}
+	n := len(data)
+	for n > 0 && data[n-1] == '\n' {
+		n--
+	}
+	var id [20]byte
+	if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
+		dief("generating Change-Id: %v", err)
+	}
+	data = append(data[:n], fmt.Sprintf("\n\nChange-Id: I%x\n", id[:])...)
+	if err := ioutil.WriteFile(file, data, 0666); err != nil {
+		dief("%v", err)
+	}
+}
+
+// This is NOT USED ANYMORE.
+// It is here only for comparing against old commit-hook files.
+var oldCommitMsgHook = `#!/bin/sh
 # From Gerrit Code Review 2.2.1
 #
 # Part of Gerrit Code Review (http://code.google.com/p/gerrit/)
diff --git a/git-review/hook_test.go b/git-review/hook_test.go
new file mode 100644
index 0000000..7770636
--- /dev/null
+++ b/git-review/hook_test.go
@@ -0,0 +1,99 @@
+// Copyright 2014 The Go Authors.  All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"bytes"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestHookCommitMsg(t *testing.T) {
+	gt := newGitTest(t)
+	defer gt.done()
+
+	// Check that hook adds Change-Id.
+	write(t, gt.client+"/msg.txt", "Test message.\n")
+	testMain(t, "hook-invoke", "commit-msg", gt.client+"/msg.txt")
+	data, err := ioutil.ReadFile(gt.client + "/msg.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !bytes.Contains(data, []byte("\n\nChange-Id: ")) {
+		t.Fatalf("after hook-invoke commit-msg, missing Change-Id:\n%s", data)
+	}
+
+	// Check that hook is no-op when Change-Id is already present.
+	testMain(t, "hook-invoke", "commit-msg", gt.client+"/msg.txt")
+	data1, err := ioutil.ReadFile(gt.client + "/msg.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !bytes.Equal(data, data1) {
+		t.Fatalf("second hook-invoke commit-msg changed Change-Id:\nbefore:\n%s\n\nafter:\n%s", data, data1)
+	}
+}
+
+func TestHooks(t *testing.T) {
+	gt := newGitTest(t)
+	defer gt.done()
+
+	gt.removeStubHooks()
+	testMain(t, "hooks") // install hooks
+
+	data, err := ioutil.ReadFile(gt.client + "/.git/hooks/commit-msg")
+	if err != nil {
+		t.Fatalf("hooks did not write commit-msg hook: %v", err)
+	}
+	if string(data) != "#!/bin/sh\nexec git-review hook-invoke commit-msg \"$@\"\n" {
+		t.Fatalf("invalid commit-msg hook:\n%s", string(data))
+	}
+}
+
+func TestHooksOverwriteOldCommitMsg(t *testing.T) {
+	gt := newGitTest(t)
+	defer gt.done()
+
+	write(t, gt.client+"/.git/hooks/commit-msg", oldCommitMsgHook)
+	testMain(t, "hooks") // install hooks
+	data, err := ioutil.ReadFile(gt.client + "/.git/hooks/commit-msg")
+	if err != nil {
+		t.Fatalf("hooks did not write commit-msg hook: %v", err)
+	}
+	if string(data) == oldCommitMsgHook {
+		t.Fatalf("hooks left old commit-msg hook in place")
+	}
+	if string(data) != "#!/bin/sh\nexec git-review hook-invoke commit-msg \"$@\"\n" {
+		t.Fatalf("invalid commit-msg hook:\n%s", string(data))
+	}
+}
+
+func TestHookCommitMsgFromGit(t *testing.T) {
+	gt := newGitTest(t)
+	defer gt.done()
+
+	trun(t, gt.pwd, "go", "build", "-o", gt.client+"/git-review")
+	path := os.Getenv("PATH")
+	defer os.Setenv("PATH", path)
+	os.Setenv("PATH", gt.client+string(filepath.ListSeparator)+path)
+
+	gt.removeStubHooks()
+	testMain(t, "hooks") // install hooks
+	write(t, gt.client+"/file", "more data")
+	trun(t, gt.client, "git", "add", "file")
+	trun(t, gt.client, "git", "commit", "-m", "mymsg")
+
+	log := trun(t, gt.client, "git", "log", "-n", "1")
+	if !strings.Contains(log, "mymsg") {
+		t.Fatalf("did not find mymsg in git log output:\n%s", log)
+	}
+	// The 4 spaces are because git indents the commit message proper.
+	if !strings.Contains(log, "\n    \n    Change-Id:") {
+		t.Fatalf("did not find Change-Id in git log output:\n%s", log)
+	}
+}
diff --git a/git-review/review.go b/git-review/review.go
index d06c928..054c510 100644
--- a/git-review/review.go
+++ b/git-review/review.go
@@ -117,6 +117,8 @@
 		change(args)
 	case "gofmt":
 		dief("gofmt not implemented")
+	case "hook-invoke":
+		hookInvoke(args)
 	case "hooks":
 		// done - installHook already ran
 	case "mail", "m":
diff --git a/git-review/util_test.go b/git-review/util_test.go
index 0c5c1e5..c2406dc 100644
--- a/git-review/util_test.go
+++ b/git-review/util_test.go
@@ -65,6 +65,17 @@
 	client := tmpdir + "/git-client"
 	mkdir(t, client)
 	trun(t, client, "git", "clone", server, ".")
+
+	// write stub hooks to keep installHook from installing its own.
+	// If it installs its own, git will look for git-review on the current path
+	// and may find an old git-review that does just about anything.
+	// In any event, we wouldn't be testing what we want to test.
+	// Tests that want to exercise hooks need to arrange for a git-review
+	// in the path and replace these with the real ones.
+	for _, h := range hookFiles {
+		write(t, client+"/.git/hooks/"+h, "#!/bin/bash\nexit 0\n")
+	}
+
 	trun(t, client, "git", "config", "core.editor", "false")
 	pwd, err := os.Getwd()
 	if err != nil {
@@ -85,6 +96,12 @@
 	return gt
 }
 
+func (gt *gitTest) removeStubHooks() {
+	for _, h := range hookFiles {
+		os.RemoveAll(gt.client + "/.git/hooks/" + h)
+	}
+}
+
 func mkdir(t *testing.T, dir string) {
 	if err := os.Mkdir(dir, 0777); err != nil {
 		t.Fatal(err)