aboutsummaryrefslogtreecommitdiff
path: root/git-codereview
diff options
context:
space:
mode:
Diffstat (limited to 'git-codereview')
-rw-r--r--git-codereview/branch.go71
-rw-r--r--git-codereview/doc.go29
-rw-r--r--git-codereview/mail.go2
-rw-r--r--git-codereview/pending.go8
-rw-r--r--git-codereview/review.go4
-rw-r--r--git-codereview/reword.go234
-rw-r--r--git-codereview/reword_test.go68
-rw-r--r--git-codereview/submit.go1
8 files changed, 388 insertions, 29 deletions
diff --git a/git-codereview/branch.go b/git-codereview/branch.go
index 787a80d..5d277c8 100644
--- a/git-codereview/branch.go
+++ b/git-codereview/branch.go
@@ -33,13 +33,18 @@ type Branch struct {
// A Commit describes a single pending commit on a Git branch.
type Commit struct {
- Hash string // commit hash
- ShortHash string // abbreviated commit hash
- Parent string // parent hash
- Merge string // for merges, hash of commit being merged into Parent
- Message string // commit message
- Subject string // first line of commit message
- ChangeID string // Change-Id in commit message ("" if missing)
+ Hash string // commit hash
+ ShortHash string // abbreviated commit hash
+ Parent string // parent hash (== Parents[0])
+ Parents []string // all parent hashes (merges have > 1)
+ Tree string // tree hash
+ Message string // commit message
+ Subject string // first line of commit message
+ ChangeID string // Change-Id in commit message ("" if missing)
+
+ AuthorName string // author name (from %an)
+ AuthorEmail string // author email (from %ae)
+ AuthorDate string // author date as Unix timestamp string (from %at)
// For use by pending command.
g *GerritChange // associated Gerrit change data
@@ -47,6 +52,16 @@ type Commit struct {
committed []string // list of files in this commit
}
+// HasParent reports whether hash appears in c.Parents.
+func (c *Commit) HasParent(hash string) bool {
+ for _, p := range c.Parents {
+ if p == hash {
+ return true
+ }
+ }
+ return false
+}
+
// Config returns the configuration for the branch.
func (b *Branch) Config() map[string]string {
if b.config != nil {
@@ -227,8 +242,10 @@ func (b *Branch) loadPending() {
// Note: --topo-order means child first, then parent.
origin := b.OriginBranch()
- const numField = 5
- all := trim(cmdOutput("git", "log", "--topo-order", "--format=format:%H%x00%h%x00%P%x00%B%x00%s%x00", origin+".."+b.FullName(), "--"))
+ const numField = 9
+ all := trim(cmdOutput("git", "log", "--topo-order",
+ "--format=format:%H%x00%h%x00%P%x00%T%x00%B%x00%s%x00%an%x00%ae%x00%at%x00",
+ origin+".."+b.FullName(), "--"))
fields := strings.Split(all, "\x00")
if len(fields) < numField {
return // nothing pending
@@ -236,16 +253,23 @@ func (b *Branch) loadPending() {
for i, field := range fields {
fields[i] = strings.TrimLeft(field, "\r\n")
}
+Log:
for i := 0; i+numField <= len(fields); i += numField {
+ parents := strings.Fields(fields[i+2])
c := &Commit{
- Hash: fields[i],
- ShortHash: fields[i+1],
- Parent: strings.TrimSpace(fields[i+2]), // %P starts with \n for some reason
- Message: fields[i+3],
- Subject: fields[i+4],
+ Hash: fields[i],
+ ShortHash: fields[i+1],
+ Parent: parents[0],
+ Parents: parents,
+ Tree: fields[i+3],
+ Message: fields[i+4],
+ Subject: fields[i+5],
+ AuthorName: fields[i+6],
+ AuthorEmail: fields[i+7],
+ AuthorDate: fields[i+8],
}
- if j := strings.Index(c.Parent, " "); j >= 0 {
- c.Parent, c.Merge = c.Parent[:j], c.Parent[j+1:]
+
+ if len(c.Parents) > 1 {
// Found merge point.
// Merges break the invariant that the last shared commit (the branchpoint)
// is the parent of the final commit in the log output.
@@ -254,15 +278,12 @@ func (b *Branch) loadPending() {
// even if we later see additional commits on a different branch leading down to
// a lower location on the same origin branch.
// Check c.Merge (the second parent) too, so we don't depend on the parent order.
- if strings.Contains(cmdOutput("git", "branch", "-a", "--contains", c.Parent), " remotes/"+origin+"\n") {
- b.pending = append(b.pending, c)
- b.branchpoint = c.Parent
- break
- }
- if strings.Contains(cmdOutput("git", "branch", "-a", "--contains", c.Merge), " remotes/"+origin+"\n") {
- b.pending = append(b.pending, c)
- b.branchpoint = c.Merge
- break
+ for _, parent := range c.Parents {
+ if strings.Contains(cmdOutput("git", "branch", "-a", "--contains", parent), " remotes/"+origin+"\n") {
+ b.pending = append(b.pending, c)
+ b.branchpoint = parent
+ break Log
+ }
}
}
for _, line := range lines(c.Message) {
diff --git a/git-codereview/doc.go b/git-codereview/doc.go
index b9eb230..62c42ea 100644
--- a/git-codereview/doc.go
+++ b/git-codereview/doc.go
@@ -48,8 +48,10 @@ aliases in their .gitconfig file:
mail = codereview mail
pending = codereview pending
rebase-work = codereview rebase-work
+ reword = codereview reword
submit = codereview submit
sync = codereview sync
+ sync-branch = codereview sync-branch
Single-Commit Work Branches
@@ -282,7 +284,10 @@ Useful aliases include “git p” for “git pending” and “git pl” for
Rebase-work
The rebase-work command runs git rebase in interactive mode over pending changes.
-It is shorthand for “git rebase -i $(git codereview branchpoint)”.
+
+ git codereview rebase-work
+
+The command is shorthand for “git rebase -i $(git codereview branchpoint)”.
It differs from plain “git rebase -i” in that the latter will try to incorporate
new commits from the origin branch during the rebase;
“git codereview rebase-work” does not.
@@ -290,6 +295,28 @@ new commits from the origin branch during the rebase;
In multiple-commit workflows, rebase-work is used so often that it can be helpful
to alias it to “git rw”.
+Reword
+
+The reword command edits pending commit messages.
+
+ git codereview reword [commit...]
+
+Reword opens the editor on the commit messages for the named comments.
+When the editing is finished, it applies the changes to the pending commits.
+If no commit is listed, reword applies to all pending commits.
+
+Reword is similar in effect to running “git codereview rebase-work” and changing
+the script action for the named commits to “reword”, or (with no arguments)
+to “git commit --amend”, but it only affects the commit messages, not the state
+of the git staged index, nor any checked-out files. This more careful implementation
+makes it safe to use when there are local changes or, for example, when tests are
+running that would be broken by temporary changes to the checked-out tree,
+as would happen during “git codereview rebase-work”.
+
+Reword is most useful for editing commit messages on a multiple-commit work
+branch, but it can also be useful in single-commit work branches to allow
+editing a commit message without committing staged changes at the same time.
+
Submit
The submit command pushes the pending change to the Gerrit server and tells
diff --git a/git-codereview/mail.go b/git-codereview/mail.go
index 3b60eb3..b7fc9f9 100644
--- a/git-codereview/mail.go
+++ b/git-codereview/mail.go
@@ -59,7 +59,7 @@ func cmdMail(args []string) {
return
}
- if len(ListFiles(c)) == 0 && c.Merge == "" {
+ if len(ListFiles(c)) == 0 && len(c.Parents) == 1 {
dief("cannot mail: commit %s is empty", c.ShortHash)
}
diff --git a/git-codereview/pending.go b/git-codereview/pending.go
index f3e3369..0f61208 100644
--- a/git-codereview/pending.go
+++ b/git-codereview/pending.go
@@ -321,8 +321,12 @@ func formatCommit(w io.Writer, c *Commit, short bool) {
case "ABANDONED":
tags = append(tags, "abandoned")
}
- if c.Merge != "" {
- tags = append(tags, "merge="+c.Merge[:7])
+ if len(c.Parents) > 1 {
+ var h []string
+ for _, p := range c.Parents[1:] {
+ h = append(h, p[:7])
+ }
+ tags = append(tags, "merge="+strings.Join(h, ","))
}
if len(tags) > 0 {
fmt.Fprintf(w, " (%s)", strings.Join(tags, ", "))
diff --git a/git-codereview/review.go b/git-codereview/review.go
index 5c7f16d..6dcb6a0 100644
--- a/git-codereview/review.go
+++ b/git-codereview/review.go
@@ -33,6 +33,7 @@ func initFlags() {
flags = flag.NewFlagSet("", flag.ExitOnError)
flags.Usage = func() {
fmt.Fprintf(stderr(), usage, progName, progName)
+ exit(2)
}
flags.SetOutput(stderr())
flags.BoolVar(noRun, "n", false, "print but do not run commands")
@@ -65,6 +66,7 @@ Available commands:
mail [-r reviewer,...] [-cc mail,...] [options] [commit]
pending [-c] [-l] [-s]
rebase-work
+ reword [commit...]
submit [-i | commit...]
sync
sync-branch [-continue]
@@ -109,6 +111,8 @@ func main() {
cmd = cmdPending
case "rebase-work":
cmd = cmdRebaseWork
+ case "reword":
+ cmd = cmdReword
case "submit":
cmd = cmdSubmit
case "sync":
diff --git a/git-codereview/reword.go b/git-codereview/reword.go
new file mode 100644
index 0000000..b2ad0de
--- /dev/null
+++ b/git-codereview/reword.go
@@ -0,0 +1,234 @@
+// Copyright 2020 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"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func cmdReword(args []string) {
+ flags.Usage = func() {
+ fmt.Fprintf(stderr(), "Usage: %s reword %s [commit...]\n",
+ progName, globalFlags)
+ exit(2)
+ }
+ flags.Parse(args)
+ args = flags.Args()
+
+ // Check that we understand the structure
+ // before we let the user spend time editing messages.
+ b := CurrentBranch()
+ pending := b.Pending()
+ if len(pending) == 0 {
+ dief("reword: no commits pending")
+ }
+ if b.Name == "HEAD" {
+ dief("reword: no current branch")
+ }
+ var last *Commit
+ for i := len(pending) - 1; i >= 0; i-- {
+ c := pending[i]
+ if last != nil && !c.HasParent(last.Hash) {
+ dief("internal error: confused about pending commit graph: parent %.7s vs %.7s", last.Hash, c.Parents)
+ }
+ last = c
+ }
+
+ headState := func() (head, branch string) {
+ head = trim(cmdOutput("git", "rev-parse", "HEAD"))
+ for _, line := range nonBlankLines(cmdOutput("git", "branch", "-l")) {
+ if strings.HasPrefix(line, "* ") {
+ branch = trim(line[1:])
+ return head, branch
+ }
+ }
+ dief("internal error: cannot find current branch")
+ panic("unreachable")
+ }
+
+ head, branch := headState()
+ if head != last.Hash {
+ dief("internal error: confused about pending commit graph: HEAD vs parent: %.7s vs %.7s", head, last.Hash)
+ }
+ if branch != b.Name {
+ dief("internal error: confused about pending commit graph: branch name %s vs %s", branch, b.Name)
+ }
+
+ // Build list of commits to be reworded.
+ // Do first, in case there are typos on the command line.
+ var cs []*Commit
+ newMsg := make(map[*Commit]string)
+ if len(args) == 0 {
+ for _, c := range pending {
+ cs = append(cs, c)
+ }
+ } else {
+ for _, arg := range args {
+ c := b.CommitByRev("reword", arg)
+ cs = append(cs, c)
+ }
+ }
+ for _, c := range cs {
+ newMsg[c] = ""
+ }
+
+ // Invoke editor to reword all the messages message.
+ // Save the edits to REWORD_MSGS immediately after editor exit
+ // in case we for some reason cannot apply the changes - don't want
+ // to throw away the user's writing.
+ // But we don't use REWORD_MSGS as the actual editor file,
+ // because if there are multiple git rewords happening
+ // (perhaps the user has forgotten about one in another window),
+ // we don't want them to step on each other during editing.
+ var buf bytes.Buffer
+ saveFile := filepath.Join(repoRoot(), ".git/REWORD_MSGS")
+ saveBuf := func() {
+ if err := ioutil.WriteFile(saveFile, buf.Bytes(), 0666); err != nil {
+ dief("cannot save messages: %v", err)
+ }
+ }
+ saveBuf() // make sure it works before we let the user edit anything
+ printf("editing messages (new texts logged in %s in case of failure)", saveFile)
+ note := "edited messages saved in " + saveFile
+
+ if len(cs) == 1 {
+ c := cs[0]
+ edited := editor(c.Message)
+ if edited == "" {
+ dief("edited message is empty")
+ }
+ newMsg[c] = edited
+ fmt.Fprintf(&buf, "# %s\n\n%s\n\n", c.Subject, edited)
+ saveBuf()
+ } else {
+ // Edit all at once.
+ var ed bytes.Buffer
+ ed.WriteString(rewordProlog)
+ byHash := make(map[string]*Commit)
+ for _, c := range cs {
+ if strings.HasPrefix(c.Message, "# ") || strings.Contains(c.Message, "\n# ") {
+ // Will break our framing.
+ // Should be pretty rare since 'git commit' and 'git commit --amend'
+ // delete lines beginning with # after editing sessions.
+ dief("commit %.7s has a message line beginning with # - cannot reword with other commits", c.Hash)
+ }
+ hash := c.Hash[:7]
+ byHash[hash] = c
+ // Two blank lines before #, one after.
+ // Lots of space to make it easier to see the boundaries
+ // between commit messages.
+ fmt.Fprintf(&ed, "\n\n# %s %s\n\n%s\n", hash, c.Subject, c.Message)
+ }
+ edited := editor(ed.String())
+ if edited == "" {
+ dief("edited text is empty")
+ }
+
+ // Save buffer for user before going further.
+ buf.WriteString(edited)
+ saveBuf()
+
+ for i, text := range strings.Split("\n"+edited, "\n# ") {
+ if i == 0 {
+ continue
+ }
+ text = "# " + text // restore split separator
+
+ // Pull out # hash header line and body.
+ hdr, body, _ := cut(text, "\n")
+
+ // Cut blank lines at start and end of body but keep newline-terminated.
+ for body != "" {
+ line, rest, _ := cut(body, "\n")
+ if line != "" {
+ break
+ }
+ body = rest
+ }
+ body = strings.TrimRight(body, " \t\n")
+ if body != "" {
+ body += "\n"
+ }
+
+ // Look up hash.
+ f := strings.Fields(hdr)
+ if len(f) < 2 {
+ dief("edited text has # line with no commit hash\n%s", note)
+ }
+ c := byHash[f[1]]
+ if c == nil {
+ dief("cannot find commit for header: %s\n%s", strings.TrimSpace(hdr), note)
+ }
+ newMsg[c] = body
+ }
+ }
+
+ // Rebuild the commits the way git would,
+ // but without doing any git checkout that
+ // would affect the files in the working directory.
+ var newHash string
+ last = nil
+ for i := len(pending) - 1; i >= 0; i-- {
+ c := pending[i]
+ if (newMsg[c] == "" || newMsg[c] == c.Message) && newHash == "" {
+ // Have not started making changes yet. Leave exactly as is.
+ last = c
+ continue
+ }
+ // Rebuilding.
+ msg := newMsg[c]
+ if msg == "" {
+ msg = c.Message
+ }
+ if last != nil && newHash != "" && !c.HasParent(last.Hash) {
+ dief("internal error: confused about pending commit graph")
+ }
+ gitArgs := []string{"commit-tree", "-p"}
+ for _, p := range c.Parents {
+ if last != nil && newHash != "" && p == last.Hash {
+ p = newHash
+ }
+ gitArgs = append(gitArgs, p)
+ }
+ gitArgs = append(gitArgs, "-m", msg, c.Tree)
+ os.Setenv("GIT_AUTHOR_NAME", c.AuthorName)
+ os.Setenv("GIT_AUTHOR_EMAIL", c.AuthorEmail)
+ os.Setenv("GIT_AUTHOR_DATE", c.AuthorDate)
+ newHash = trim(cmdOutput("git", gitArgs...))
+ last = c
+ }
+ if newHash == "" {
+ // No messages changed.
+ return
+ }
+
+ // Attempt swap of HEAD but leave index and working copy alone.
+ // No obvious way to make it atomic, but check for races.
+ head, branch = headState()
+ if head != pending[0].Hash {
+ dief("cannot reword: commits changed underfoot\n%s", note)
+ }
+ if branch != b.Name {
+ dief("cannot reword: branch changed underfoot\n%s", note)
+ }
+ run("git", "reset", "--soft", newHash)
+}
+
+func cut(s, sep string) (before, after string, ok bool) {
+ i := strings.Index(s, sep)
+ if i < 0 {
+ return s, "", false
+ }
+ return s[:i], s[i+len(sep):], true
+}
+
+var rewordProlog = `Rewording multiple commit messages.
+The # lines separate the different commits and must be left unchanged.
+`
diff --git a/git-codereview/reword_test.go b/git-codereview/reword_test.go
new file mode 100644
index 0000000..c370b37
--- /dev/null
+++ b/git-codereview/reword_test.go
@@ -0,0 +1,68 @@
+// Copyright 2020 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 (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestReword(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+
+ gt.work(t)
+ gt.work(t)
+ gt.work(t)
+ trun(t, gt.client, "git", "tag", "MSG3")
+ gt.work(t)
+ trun(t, gt.client, "git", "tag", "MSG4")
+ const fakeName = "Grace R. Emlin"
+ os.Setenv("GIT_AUTHOR_NAME", fakeName)
+ gt.work(t)
+ os.Unsetenv("GIT_AUTHOR_NAME")
+
+ write(t, gt.client+"/file", "pending work", 0644) // would make git checkout unhappy
+
+ testMainDied(t, "rebase-work")
+ testNoStdout(t)
+ testPrintedStderr(t, "cannot rebase with uncommitted work")
+
+ os.Setenv("GIT_EDITOR", "sed -i.bak -e s/msg/MESSAGE/")
+
+ testMain(t, "reword", "MSG3", "MSG4")
+ testNoStdout(t)
+ testPrintedStderr(t, "editing messages (new texts logged in",
+ ".git/REWORD_MSGS in case of failure)")
+
+ testMain(t, "pending", "-c", "-l", "-s")
+ testNoStderr(t)
+ testPrintedStdout(t,
+ "msg #2",
+ "MESSAGE #3",
+ "MESSAGE #4",
+ "msg #5",
+ )
+
+ testMain(t, "reword") // reword all
+ testNoStdout(t)
+ testPrintedStderr(t, "editing messages (new texts logged in",
+ ".git/REWORD_MSGS in case of failure)")
+
+ testMain(t, "pending", "-c", "-l", "-s")
+ testNoStderr(t)
+ testPrintedStdout(t,
+ "MESSAGE #2",
+ "MESSAGE #3",
+ "MESSAGE #4",
+ "MESSAGE #5",
+ )
+
+ out := trun(t, gt.client, "git", "log", "-n1")
+ if !strings.Contains(out, fakeName) {
+ t.Fatalf("reword lost author name (%s): %v\n", fakeName, out)
+ }
+}
diff --git a/git-codereview/submit.go b/git-codereview/submit.go
index 5f684ca..fc02713 100644
--- a/git-codereview/submit.go
+++ b/git-codereview/submit.go
@@ -17,6 +17,7 @@ func cmdSubmit(args []string) {
flags.BoolVar(&interactive, "i", false, "interactively select commits to submit")
flags.Usage = func() {
fmt.Fprintf(stderr(), "Usage: %s submit %s [-i | commit...]\n", progName, globalFlags)
+ exit(2)
}
flags.Parse(args)
if interactive && flags.NArg() > 0 {