git-codereview: new sync-branch and related fixes
This CL adds a new command, "git codereview sync-branch",
which does the appropriate git merge for the current branch.
This CL also fixes a bug in "git codereview branchpoint",
and therefore also commands like "git codereview pending",
which was getting the branchpoint wrong for merges,
with the effect that a merge showed too many pending CLs.
This CL also fixes a bug in "git codereview change", which was
formerly willing to run "git checkout" with a pending merge,
which had the effect of flattening the merge mysteriously.
Now it detects the merge and refuses to run.
All of this should make merges easier and less error-prone
as we use dev branches more often.
With the earlier CL in this stack that allows working directly
on local branches, this is now a great way to run a merge
updating dev.regabi:
git change dev.regabi
git sync-branch
(with appropriate aliases to avoid typing "codereview").
Fixes golang/go#26201.
Change-Id: Ic24603123ca5135a72004309f5bb208ff149c9eb
Reviewed-on: https://go-review.googlesource.com/c/review/+/279772
Trust: Russ Cox <rsc@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Matthew Dempsky <mdempsky@google.com>
diff --git a/git-codereview/branch.go b/git-codereview/branch.go
index 7c9b282..787a80d 100644
--- a/git-codereview/branch.go
+++ b/git-codereview/branch.go
@@ -7,9 +7,11 @@
import (
"bytes"
"fmt"
+ "io/ioutil"
"net/url"
"os"
"os/exec"
+ "path/filepath"
"regexp"
"runtime"
"strings"
@@ -17,12 +19,16 @@
// Branch describes a Git branch.
type Branch struct {
- Name string // branch name
+ Name string // branch name
+ Current bool // branch is currently checked out
+
loadedPending bool // following fields are valid
originBranch string // upstream origin branch
commitsAhead int // number of commits ahead of origin branch
branchpoint string // latest commit hash shared with origin branch
pending []*Commit // pending commits, newest first (children before parents)
+
+ config map[string]string // cached config; use Config instead
}
// A Commit describes a single pending commit on a Git branch.
@@ -41,10 +47,34 @@
committed []string // list of files in this commit
}
+// Config returns the configuration for the branch.
+func (b *Branch) Config() map[string]string {
+ if b.config != nil {
+ return b.config
+ }
+ var cfgText string
+ if b.Current {
+ data, _ := ioutil.ReadFile(filepath.Join(repoRoot(), "codereview.cfg"))
+ cfgText = string(data)
+ } else {
+ out, err := cmdOutputDirErr(repoRoot(), "git", "show", b.Name+":codereview.cfg")
+ if err == nil {
+ cfgText = out
+ }
+ }
+ cfg, err := parseConfig(cfgText)
+ if err != nil {
+ verbosef("failed to load config for branch %v: %v", b.Name, err)
+ cfg = make(map[string]string)
+ }
+ b.config = cfg
+ return b.config
+}
+
// CurrentBranch returns the current branch.
func CurrentBranch() *Branch {
name := strings.TrimPrefix(trim(cmdOutput("git", "rev-parse", "--abbrev-ref", "HEAD")), "heads/")
- return &Branch{Name: name}
+ return &Branch{Name: name, Current: true}
}
// DetachedHead reports whether branch b corresponds to a detached HEAD
@@ -68,6 +98,41 @@
if b.originBranch != "" {
return b.originBranch
}
+
+ cfg := b.Config()["branch"]
+ upstream := ""
+ if cfg != "" {
+ upstream = "origin/" + cfg
+ }
+ gitUpstream := b.gitOriginBranch()
+ if upstream == "" {
+ upstream = gitUpstream
+ }
+ if upstream == "" {
+ // Assume branch was created before we set upstream correctly.
+ // See if origin/main exists; if so, use it.
+ // Otherwise, fall back to origin/master.
+ argv := []string{"git", "rev-parse", "--abbrev-ref", "origin/main"}
+ cmd := exec.Command(argv[0], argv[1:]...)
+ setEnglishLocale(cmd)
+ if err := cmd.Run(); err == nil {
+ upstream = "origin/main"
+ } else {
+ upstream = "origin/master"
+ }
+ }
+
+ if gitUpstream != upstream && b.Current {
+ // Best effort attempt to correct setting for next time,
+ // and for "git status".
+ exec.Command("git", "branch", "-u", upstream).Run()
+ }
+
+ b.originBranch = upstream
+ return b.originBranch
+}
+
+func (b *Branch) gitOriginBranch() string {
argv := []string{"git", "rev-parse", "--abbrev-ref", b.Name + "@{u}"}
cmd := exec.Command(argv[0], argv[1:]...)
if runtime.GOOS == "windows" {
@@ -82,28 +147,14 @@
out, err := cmd.CombinedOutput()
if err == nil && len(out) > 0 {
- b.originBranch = string(bytes.TrimSpace(out))
- return b.originBranch
+ return string(bytes.TrimSpace(out))
}
// Have seen both "No upstream configured" and "no upstream configured".
if strings.Contains(string(out), "upstream configured") {
- // Assume branch was created before we set upstream correctly.
- // See if origin/main exists; if so, use it.
- // Otherwise, fall back to origin/master.
- argv := []string{"git", "rev-parse", "--abbrev-ref", "origin/main"}
- cmd := exec.Command(argv[0], argv[1:]...)
- setEnglishLocale(cmd)
- if out, err := cmd.CombinedOutput(); err == nil {
- b.originBranch = string(bytes.TrimSpace(out))
- // Best effort attempt to correct setting for next time,
- // and for "git status".
- exec.Command("git", "branch", "-u", "origin/main").Run()
- return b.originBranch
- }
- b.originBranch = "origin/master"
- return b.originBranch
+ return ""
}
+
fmt.Fprintf(stderr(), "%v\n%s\n", commandString(argv[0], argv[1:]), out)
dief("%v", err)
panic("not reached")
@@ -135,6 +186,20 @@
return b.branchpoint
}
+func gitHash(expr string) string {
+ out, err := cmdOutputErr("git", "rev-parse", expr)
+ if err != nil {
+ dief("cannot resolve %s: %v\n%s", expr, err, out)
+ }
+ if strings.HasPrefix(expr, "-") {
+ // Git has a bug where it will just echo these back with no error.
+ // (Try "git rev-parse -qwerty".)
+ // Reject them ourselves instead.
+ dief("cannot resolve %s: invalid reference", expr)
+ }
+ return trim(out)
+}
+
func (b *Branch) loadPending() {
if b.loadedPending {
return
@@ -142,7 +207,13 @@
b.loadedPending = true
// In case of early return.
- b.branchpoint = trim(cmdOutput("git", "rev-parse", "HEAD"))
+ // But avoid the git exec unless really needed.
+ b.branchpoint = ""
+ defer func() {
+ if b.branchpoint == "" {
+ b.branchpoint = gitHash("HEAD")
+ }
+ }()
if b.DetachedHead() {
return
@@ -165,7 +236,6 @@
for i, field := range fields {
fields[i] = strings.TrimLeft(field, "\r\n")
}
- foundMergeBranchpoint := false
for i := 0; i+numField <= len(fields); i += numField {
c := &Commit{
Hash: fields[i],
@@ -184,13 +254,15 @@
// 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), " "+origin+"\n") {
- foundMergeBranchpoint = true
+ 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), " "+origin+"\n") {
- foundMergeBranchpoint = true
+ 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 _, line := range lines(c.Message) {
@@ -203,11 +275,8 @@
c.ChangeID = line[len("Change-Id: "):]
}
}
-
b.pending = append(b.pending, c)
- if !foundMergeBranchpoint {
- b.branchpoint = c.Parent
- }
+ b.branchpoint = c.Parent
}
b.commitsAhead = len(b.pending)
}
@@ -418,6 +487,9 @@
// ListFiles returns the list of files in a given commit.
func ListFiles(c *Commit) []string {
+ if c.Parent == "" {
+ return nil
+ }
return nonBlankLines(cmdOutput("git", "diff", "--name-only", c.Parent, c.Hash, "--"))
}
diff --git a/git-codereview/branch_test.go b/git-codereview/branch_test.go
index e4530eb..9e999b7 100644
--- a/git-codereview/branch_test.go
+++ b/git-codereview/branch_test.go
@@ -166,9 +166,11 @@
hash := strings.TrimSpace(trun(t, gt.client, "git", "rev-parse", "HEAD"))
- // merge dev.branch
- testMain(t, "change", "work")
+ // Merge dev.branch but delete the codereview.cfg that comes in,
+ // or else we'll think we are on the wrong branch.
trun(t, gt.client, "git", "merge", "-m", "merge", "origin/dev.branch")
+ trun(t, gt.client, "git", "rm", "codereview.cfg")
+ trun(t, gt.client, "git", "commit", "-m", "rm codereview.cfg")
// check branchpoint is old head (despite this commit having two parents)
bp := CurrentBranch().Branchpoint()
diff --git a/git-codereview/change.go b/git-codereview/change.go
index 5320d9d..24cbb71 100644
--- a/git-codereview/change.go
+++ b/git-codereview/change.go
@@ -26,6 +26,13 @@
exit(2)
}
+ if _, err := cmdOutputErr("git", "rev-parse", "--abbrev-ref", "MERGE_HEAD"); err == nil {
+ diePendingMerge("change")
+ }
+ if _, err := cmdOutputErr("git", "rev-parse", "--abbrev-ref", "REBASE_HEAD"); err == nil {
+ dief("cannot change: found pending rebase or sync")
+ }
+
// Checkout or create branch, if specified.
target := flags.Arg(0)
if target != "" {
diff --git a/git-codereview/doc.go b/git-codereview/doc.go
index a72b6db..b9eb230 100644
--- a/git-codereview/doc.go
+++ b/git-codereview/doc.go
@@ -344,5 +344,27 @@
lines such as “Fixes #123” in a commit message will be rewritten to
“Fixes golang/go#123”.
+The “branch” key specifies the name of the branch on the origin server
+corresponding to the current checkout. If this setting is missing, git-codereview
+uses the name of the remote branch that the current checkout is tracking.
+If that setting is missing, git-codereview uses “main”.
+
+The “parent-branch” key specifies the name of the parent branch on
+the origin server. The parent branch is the branch from which the current
+pulls regular updates. For example the parent branch of “dev.feature”
+would typically be “main”, in which case it would have this codereview.cfg:
+
+ branch: dev.feature
+ parent-branch: main
+
+In a more complex configuration, one feature branch might depend upon
+another, like “dev.feature2” containing follow-on work for “dev.feature”,
+neither of which has merged yet. In this case, the dev.feature2 branch
+would have this codereview.cfg:
+
+ branch: dev.feature2
+ parent-branch: dev.feature
+
+The parent branch setting is used by the sync-branch command.
*/
package main
diff --git a/git-codereview/mail.go b/git-codereview/mail.go
index 55ab8ed..3b60eb3 100644
--- a/git-codereview/mail.go
+++ b/git-codereview/mail.go
@@ -59,7 +59,7 @@
return
}
- if len(ListFiles(c)) == 0 {
+ if len(ListFiles(c)) == 0 && c.Merge == "" {
dief("cannot mail: commit %s is empty", c.ShortHash)
}
diff --git a/git-codereview/pending.go b/git-codereview/pending.go
index c1594b5..f3e3369 100644
--- a/git-codereview/pending.go
+++ b/git-codereview/pending.go
@@ -293,6 +293,9 @@
// commit and its Gerrit state.
func formatCommit(w io.Writer, c *Commit, short bool) {
g := c.g
+ if g == nil {
+ g = new(GerritChange)
+ }
msg := strings.TrimRight(c.Message, "\r\n")
fmt.Fprintf(w, "%s", c.ShortHash)
var tags []string
@@ -318,6 +321,9 @@
case "ABANDONED":
tags = append(tags, "abandoned")
}
+ if c.Merge != "" {
+ tags = append(tags, "merge="+c.Merge[:7])
+ }
if len(tags) > 0 {
fmt.Fprintf(w, " (%s)", strings.Join(tags, ", "))
}
diff --git a/git-codereview/review.go b/git-codereview/review.go
index 0deb039..5c7f16d 100644
--- a/git-codereview/review.go
+++ b/git-codereview/review.go
@@ -67,6 +67,7 @@
rebase-work
submit [-i | commit...]
sync
+ sync-branch [-continue]
See https://pkg.go.dev/golang.org/x/review/git-codereview
for the full details of each command.
@@ -112,6 +113,8 @@
cmd = cmdSubmit
case "sync":
cmd = cmdSync
+ case "sync-branch":
+ cmd = cmdSyncBranch
case "test-loadAuth": // for testing only.
cmd = func([]string) { loadAuth() }
}
diff --git a/git-codereview/sync.go b/git-codereview/sync.go
index 9ea3e67..0334341 100644
--- a/git-codereview/sync.go
+++ b/git-codereview/sync.go
@@ -4,7 +4,15 @@
package main
-import "strings"
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+)
func cmdSync(args []string) {
expectZeroArgs(args, "sync")
@@ -78,3 +86,234 @@
"\trun 'git add' and 'git-codereview change' to commit staged changes", cmd)
}
}
+
+type syncBranchStatus struct {
+ Local string
+ Parent string
+ Branch string
+ ParentHash string
+ BranchHash string
+ Conflicts []string
+}
+
+func syncBranchStatusFile() string {
+ return filepath.Join(repoRoot(), ".git/codereview-sync-branch-status")
+}
+
+func readSyncBranchStatus() *syncBranchStatus {
+ data, err := ioutil.ReadFile(syncBranchStatusFile())
+ if err != nil {
+ dief("cannot sync-branch: reading status: %v", err)
+ }
+ status := new(syncBranchStatus)
+ err = json.Unmarshal(data, status)
+ if err != nil {
+ dief("cannot sync-branch: reading status: %v", err)
+ }
+ return status
+}
+
+func writeSyncBranchStatus(status *syncBranchStatus) {
+ js, err := json.MarshalIndent(status, "", "\t")
+ if err != nil {
+ dief("cannot sync-branch: writing status: %v", err)
+ }
+ if err := ioutil.WriteFile(syncBranchStatusFile(), js, 0666); err != nil {
+ dief("cannot sync-branch: writing status: %v", err)
+ }
+}
+
+func cmdSyncBranch(args []string) {
+ os.Setenv("GIT_EDITOR", ":") // do not bring up editor during merge, commit
+
+ var cont bool
+ flags.BoolVar(&cont, "continue", false, "continue after merge conflicts")
+ flags.Parse(args)
+ if len(flag.Args()) > 0 {
+ fmt.Fprintf(stderr(), "Usage: %s sync-branch %s [-continue]\n", progName, globalFlags)
+ exit(2)
+ }
+
+ parent := config()["parent-branch"]
+ if parent == "" {
+ dief("cannot sync-branch: codereview.cfg does not list parent-branch")
+ }
+
+ branch := config()["branch"]
+ if parent == "" {
+ dief("cannot sync-branch: codereview.cfg does not list branch")
+ }
+
+ b := CurrentBranch()
+ if b.DetachedHead() {
+ dief("cannot sync-branch: on detached head")
+ }
+ if len(b.Pending()) > 0 {
+ dief("cannot sync-branch: pending changes exist\n" +
+ "\trun 'git codereview pending' to see them")
+ }
+
+ if cont {
+ if _, err := os.Stat(syncBranchStatusFile()); err != nil {
+ dief("cannot sync-branch -continue: no pending sync-branch status file found")
+ }
+ syncBranchContinue(" -continue", b, readSyncBranchStatus())
+ return
+ }
+
+ if _, err := cmdOutputErr("git", "rev-parse", "--abbrev-ref", "MERGE_HEAD"); err == nil {
+ diePendingMerge("sync-branch")
+ }
+
+ // Don't sync with staged or unstaged changes.
+ // rebase is going to complain if we don't, and we can give a nicer error.
+ checkStaged("sync")
+ checkUnstaged("sync")
+
+ // Make sure client is up-to-date on current branch.
+ // Note that this does a remote fetch of b.OriginBranch() (aka branch).
+ cmdSync(nil)
+
+ // Pull down parent commits too.
+ quiet := "-q"
+ if *verbose > 0 {
+ quiet = "-v"
+ }
+ run("git", "fetch", quiet, "origin", "refs/heads/"+parent+":refs/remotes/origin/"+parent)
+
+ // Write the status file to make sure we can, before starting a merge.
+ status := &syncBranchStatus{
+ Local: b.Name,
+ Parent: parent,
+ ParentHash: gitHash("origin/" + parent),
+ Branch: branch,
+ BranchHash: gitHash("origin/" + branch),
+ }
+ writeSyncBranchStatus(status)
+
+ // Start the merge.
+ _, err := cmdOutputErr("git", "merge", "origin/"+parent)
+
+ // Resolve codereview.cfg the right way (never take it from the merge).
+ cmdOutputDir(repoRoot(), "git", "checkout", "HEAD", "--", "codereview.cfg")
+
+ if err != nil {
+ // Check whether the only listed file is codereview.cfg and try again if so.
+ // Build list of unmerged files.
+ for _, s := range nonBlankLines(cmdOutputDir(repoRoot(), "git", "status", "-b", "--porcelain")) {
+ // Unmerged status is anything with a U and also AA and DD.
+ if len(s) >= 4 && s[2] == ' ' && (s[0] == 'U' || s[1] == 'U' || s[0:2] == "AA" || s[0:2] == "DD") {
+ status.Conflicts = append(status.Conflicts, s[3:])
+ }
+ }
+ if len(status.Conflicts) == 0 {
+ // Must have been codereview.cfg that was the problem.
+ // Try continuing the merge.
+ // Note that as of Git 2.12, git merge --continue is a synonym for git commit,
+ // but older Gits do not have merge --continue.
+ var out string
+ out, err = cmdOutputErr("git", "commit", "-m", "TEMPORARY MERGE MESSAGE")
+ if err != nil {
+ printf("git commit failed with no apparent unmerged files:\n%s\n", out)
+ }
+ } else {
+ writeSyncBranchStatus(status)
+ }
+ }
+
+ if err != nil {
+ if len(status.Conflicts) == 0 {
+ dief("cannot sync-branch: git merge failed but no conflicts found\n" +
+ "(unexpected error, please ask for help!)")
+ }
+ dief("sync-branch: merge conflicts in:\n\t- %s\n\n"+
+ "Please fix them (use 'git status' to see the list again),\n"+
+ "then 'git add' or 'git rm' to resolve them,\n"+
+ "and then 'git sync-branch -continue' to continue.\n"+
+ "Or run 'git merge --abort' to give up on this sync-branch.\n",
+ strings.Join(status.Conflicts, "\n\t- "))
+ }
+
+ syncBranchContinue("", b, status)
+}
+
+func diePendingMerge(cmd string) {
+ dief("cannot %s: found pending merge\n"+
+ "Run 'git codereview sync-branch -continue' if you fixed\n"+
+ "merge conflicts after a previous sync-branch operation.\n"+
+ "Or run 'git merge --abort' to give up on the sync-branch.\n",
+ cmd)
+}
+
+func syncBranchContinue(flag string, b *Branch, status *syncBranchStatus) {
+ if h := gitHash("origin/" + status.Parent); h != status.ParentHash {
+ dief("cannot sync-branch%s: parent hash changed: %.7s -> %.7s", flag, status.ParentHash, h)
+ }
+ if h := gitHash("origin/" + status.Branch); h != status.BranchHash {
+ dief("cannot sync-branch%s: branch hash changed: %.7s -> %.7s", flag, status.BranchHash, h)
+ }
+ if b.Name != status.Local {
+ dief("cannot sync-branch%s: branch changed underfoot: %s -> %s", flag, status.Local, b.Name)
+ }
+
+ branch := status.Branch
+ parent := status.Parent
+ branchHash := status.BranchHash
+ parentHash := status.ParentHash
+
+ prefix := ""
+ if strings.HasPrefix(branch, "dev.") || strings.HasPrefix(branch, "release-branch.") {
+ prefix = "[" + branch + "] "
+ }
+ msg := fmt.Sprintf("%sall: merge %s (%.7s) into %s", prefix, parent, parentHash, branch)
+
+ if flag != "" {
+ // Need to commit the merge.
+
+ // Check that the state of the client is the way we left it before any merge conflicts.
+ mergeHead, err := cmdOutputErr("git", "rev-parse", "MERGE_HEAD")
+ if err != nil {
+ dief("cannot sync-branch%s: no pending merge\n"+
+ "If you accidentally ran 'git merge --continue',\n"+
+ "then use 'git reset --hard HEAD^' to undo.\n", flag)
+ }
+ mergeHead = trim(mergeHead)
+ if mergeHead != parentHash {
+ dief("cannot sync-branch%s: MERGE_HEAD is %.7s, but origin/%s is %.7s", flag, mergeHead, parent, parentHash)
+ }
+ head := gitHash("HEAD")
+ if head != branchHash {
+ dief("cannot sync-branch%s: HEAD is %.7s, but origin/%s is %.7s", flag, head, branch, branchHash)
+ }
+
+ if HasUnstagedChanges() {
+ dief("cannot sync-branch%s: unstaged changes (unresolved conflicts)\n"+
+ "\tUse 'git status' to see them, 'git add' or 'git rm' to resolve them,\n"+
+ "\tand then run 'git sync-branch -continue' again.\n", flag)
+ }
+
+ run("git", "commit", "-m", msg)
+ }
+
+ // Amend the merge message, which may be auto-generated by git
+ // or may have been written by us during the post-conflict commit above,
+ // to use our standard format and list the incorporated CLs.
+
+ // Merge must never sync codereview.cfg,
+ // because it contains the parent and branch config.
+ // Force the on-branch copy back while amending the commit.
+ cmdOutputDir(repoRoot(), "git", "checkout", "origin/"+branch, "--", "codereview.cfg")
+
+ conflictMsg := ""
+ if len(status.Conflicts) > 0 {
+ conflictMsg = "Conflicts:\n\n- " + strings.Join(status.Conflicts, "\n- ") + "\n\n"
+ }
+ msg = fmt.Sprintf("%s\n\n%sMerge List:\n\n%s", msg, conflictMsg,
+ cmdOutput("git", "log", "--format=format:+ %cd %h %s", "--date=short", "HEAD^1..HEAD^2"))
+ run("git", "commit", "--amend", "-m", msg)
+
+ fmt.Fprintf(stderr(), "\n")
+
+ cmdPending([]string{"-c", "-l"})
+ fmt.Fprintf(stderr(), "\n* Merge commit created.\nRun 'git codereview mail' to send for review.\n")
+}
diff --git a/git-codereview/sync_test.go b/git-codereview/sync_test.go
index 72cbdf8..1498098 100644
--- a/git-codereview/sync_test.go
+++ b/git-codereview/sync_test.go
@@ -4,7 +4,10 @@
package main
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestSync(t *testing.T) {
gt := newGitTest(t)
@@ -123,3 +126,158 @@
testNoStdout(t)
testNoStderr(t)
}
+
+func TestBranchConfig(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+ gt.work(t) // do the main-branch work setup now to avoid unwanted change below
+
+ trun(t, gt.client, "git", "checkout", "dev.branch")
+ testMain(t, "pending", "-c", "-l")
+ // The !+ means reject any output with a +, which introduces a pending commit.
+ // There should be no pending commits.
+ testPrintedStdout(t, "dev.branch (current branch, tracking dev.branch)", "!+")
+
+ // If we make a branch with raw git,
+ // the codereview.cfg should help us see the tracking info
+ // even though git doesn't know the right upstream.
+ trun(t, gt.client, "git", "checkout", "-b", "mywork", "HEAD^0")
+ if out, err := cmdOutputDirErr(gt.client, "git", "rev-parse", "--abbrev-ref", "@{u}"); err == nil {
+ t.Fatalf("git knows @{u} but should not:\n%s", out)
+ }
+ testMain(t, "pending", "-c", "-l")
+ testPrintedStdout(t, "mywork (current branch, tracking dev.branch)", "!+")
+ // Pending should have set @{u} correctly for us.
+ if out, err := cmdOutputDirErr(gt.client, "git", "rev-parse", "--abbrev-ref", "@{u}"); err != nil {
+ t.Fatalf("git does not know @{u} but should: %v\n%s", err, out)
+ } else if out = strings.TrimSpace(out); out != "origin/dev.branch" {
+ t.Fatalf("git @{u} = %q, want %q", out, "origin/dev.branch")
+ }
+
+ // Even if we add a pending commit, we should see the right tracking info.
+ // The !codereview.cfg makes sure we do not see the codereview.cfg-changing
+ // commit from the server in the output. (That would happen if we were printing
+ // new commits relative to main instead of relative to dev.branch.)
+ gt.work(t)
+ testMain(t, "pending", "-c", "-l")
+ testHideRevHashes(t)
+ testPrintedStdout(t, "mywork REVHASH..REVHASH (current branch, tracking dev.branch)", "!codereview.cfg")
+
+ // If we make a new branch using the old work HEAD
+ // then we should be back to something tracking main.
+ trun(t, gt.client, "git", "checkout", "-b", "mywork2", "work^0")
+ gt.work(t)
+ testMain(t, "pending", "-c", "-l")
+ testHideRevHashes(t)
+ testPrintedStdout(t, "mywork2 REVHASH..REVHASH (current branch)", "!codereview.cfg")
+
+ // Now look at all branches, which should use the appropriate configs
+ // from the commits on each branch.
+ testMain(t, "pending", "-l")
+ testHideRevHashes(t)
+ testPrintedStdout(t, "mywork2 REVHASH..REVHASH (current branch)",
+ "mywork REVHASH..REVHASH (tracking dev.branch)",
+ "work REVHASH..REVHASH\n") // the \n checks for not having a (tracking main)
+}
+
+func TestSyncBranch(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+
+ gt.serverWork(t)
+ gt.serverWork(t)
+ trun(t, gt.server, "git", "checkout", "dev.branch")
+ gt.serverWorkUnrelated(t)
+ gt.serverWorkUnrelated(t)
+ gt.serverWorkUnrelated(t)
+ trun(t, gt.server, "git", "checkout", "main")
+
+ testMain(t, "change", "dev.branch")
+ testMain(t, "sync-branch")
+ testHideRevHashes(t)
+ testPrintedStdout(t, "[dev.branch] all: merge main (REVHASH) into dev.branch",
+ "Merge List:",
+ "+ DATE REVHASH msg #2",
+ "+ DATE REVHASH",
+ )
+ testPrintedStderr(t, "* Merge commit created.",
+ "Run 'git codereview mail' to send for review.")
+}
+
+func TestSyncBranchConflict(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+
+ gt.serverWork(t)
+ gt.serverWork(t)
+ trun(t, gt.server, "git", "checkout", "dev.branch")
+ gt.serverWork(t)
+ trun(t, gt.server, "git", "checkout", "main")
+
+ testMain(t, "change", "dev.branch")
+
+ testMainDied(t, "sync-branch")
+ testNoStdout(t)
+ testPrintedStderr(t,
+ "git-codereview: sync-branch: merge conflicts in:",
+ " - file",
+ "Please fix them (use 'git status' to see the list again),",
+ "then 'git add' or 'git rm' to resolve them,",
+ "and then 'git sync-branch -continue' to continue.",
+ "Or run 'git merge --abort' to give up on this sync-branch.",
+ )
+
+ // Other client-changing commands should fail now.
+ testDisallowed := func(cmd ...string) {
+ t.Helper()
+ testMainDied(t, cmd...)
+ testNoStdout(t)
+ testPrintedStderr(t,
+ "git-codereview: cannot "+cmd[0]+": found pending merge",
+ "Run 'git codereview sync-branch -continue' if you fixed",
+ "merge conflicts after a previous sync-branch operation.",
+ "Or run 'git merge --abort' to give up on the sync-branch.",
+ )
+ }
+ testDisallowed("change", "main")
+ testDisallowed("sync-branch")
+
+ // throw away server changes to resolve merge
+ trun(t, gt.client, "git", "checkout", "HEAD", "file")
+
+ // Still cannot change branches even with conflicts resolved.
+ testDisallowed("change", "main")
+ testDisallowed("sync-branch")
+
+ testMain(t, "sync-branch", "-continue")
+ testHideRevHashes(t)
+ testPrintedStdout(t,
+ "[dev.branch] all: merge main (REVHASH) into dev.branch",
+ "+ REVHASH (merge=REVHASH)",
+ "Conflicts:",
+ "- file",
+ "Merge List:",
+ "+ DATE REVHASH msg #2",
+ "+ DATE REVHASH",
+ )
+ testPrintedStderr(t,
+ "* Merge commit created.",
+ "Run 'git codereview mail' to send for review.",
+ )
+
+ // Check that pending only shows the merge, not more commits.
+ testMain(t, "pending", "-c", "-l", "-s")
+ n := strings.Count(testStdout.String(), "+")
+ if n != 1 {
+ t.Fatalf("git pending shows %d commits, want 1:\n%s", n, testStdout.String())
+ }
+ testNoStderr(t)
+
+ // Check that mail sends the merge to the right place!
+ testMain(t, "mail", "-n")
+ testNoStdout(t)
+ testPrintedStderr(t,
+ "git push -q origin HEAD:refs/for/dev.branch",
+ "git tag -f dev.branch.mailed",
+ )
+}
diff --git a/git-codereview/util_test.go b/git-codereview/util_test.go
index b1b5534..a4f0b51 100644
--- a/git-codereview/util_test.go
+++ b/git-codereview/util_test.go
@@ -15,6 +15,8 @@
"os/exec"
"path/filepath"
"reflect"
+ "regexp"
+ "runtime/debug"
"strings"
"sync"
"testing"
@@ -167,7 +169,12 @@
trun(t, server, "git", "checkout", "main")
trun(t, server, "git", "checkout", "-b", name)
write(t, server+"/file."+name, "this is "+name, 0644)
- trun(t, server, "git", "add", "file."+name)
+ cfg := "branch: " + name + "\n"
+ if name == "dev.branch" {
+ cfg += "parent-branch: main\n"
+ }
+ write(t, server+"/codereview.cfg", cfg, 0644)
+ trun(t, server, "git", "add", "file."+name, "codereview.cfg")
trun(t, server, "git", "commit", "-m", "on "+name)
}
trun(t, server, "git", "checkout", "main")
@@ -330,7 +337,7 @@
if died {
msg = "died"
} else {
- msg = fmt.Sprintf("panic: %v", err)
+ msg = fmt.Sprintf("panic: %v\n%s", err, debug.Stack())
}
t.Fatalf("%s\nstdout:\n%sstderr:\n%s", msg, testStdout, testStderr)
}
@@ -380,6 +387,16 @@
}
}
+func testHideRevHashes(t *testing.T) {
+ for _, b := range []*bytes.Buffer{testStdout, testStderr} {
+ out := b.Bytes()
+ out = regexp.MustCompile(`\b[0-9a-f]{7}\b`).ReplaceAllLiteral(out, []byte("REVHASH"))
+ out = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`).ReplaceAllLiteral(out, []byte("DATE"))
+ b.Reset()
+ b.Write(out)
+ }
+}
+
func testPrintedStdout(t *testing.T, messages ...string) {
t.Helper()
testPrinted(t, testStdout, "stdout", messages...)