├── codereview.cfg ├── go.mod ├── .gitattributes ├── README.md ├── CONTRIBUTING.md ├── PATENTS ├── LICENSE └── git-codereview ├── editor.go ├── config_test.go ├── reword_test.go ├── config.go ├── change_test.go ├── branch_test.go ├── api_test.go ├── reword.go ├── submit.go ├── change.go ├── mail_test.go ├── submit_test.go ├── gofmt_test.go ├── review.go ├── mail.go ├── pending_test.go ├── sync_test.go ├── hook.go ├── gofmt.go ├── sync.go ├── util_test.go └── pending.go /codereview.cfg: -------------------------------------------------------------------------------- 1 | issuerepo: golang/go 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module golang.org/x/review 2 | 3 | go 1.24.0 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Treat all files in this repo as binary, with no git magic updating 2 | # line endings. Windows users contributing to Go will need to use a 3 | # modern version of git and editors capable of LF line endings. 4 | # 5 | # We'll prevent accidental CRLF line endings from entering the repo 6 | # via the git-review gofmt checks. 7 | # 8 | # See golang.org/issue/9281 9 | 10 | * -text 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-codereview 2 | 3 | The git-codereview tool is a command-line tool for working with Gerrit. 4 | 5 | ## Download/Install 6 | 7 | The easiest way to install is to run `go install golang.org/x/review/git-codereview@latest`. 8 | 9 | Run `git codereview hooks` to install Gerrit hooks for your git repository. 10 | 11 | ## Report Issues / Send Patches 12 | 13 | This repository uses Gerrit for code changes. To learn how to submit changes to 14 | this repository, see https://go.dev/doc/contribute. 15 | 16 | The git repository is https://go.googlesource.com/review. 17 | 18 | The main issue tracker for the review repository is located at 19 | https://go.dev/issues. Prefix your issue with "x/review:" in the 20 | subject line, so it is easy to find. 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Go 2 | 3 | Go is an open source project. 4 | 5 | It is the work of hundreds of contributors. We appreciate your help! 6 | 7 | ## Filing issues 8 | 9 | When [filing an issue](https://golang.org/issue/new), make sure to answer these five questions: 10 | 11 | 1. What version of Go are you using (`go version`)? 12 | 2. What operating system and processor architecture are you using? 13 | 3. What did you do? 14 | 4. What did you expect to see? 15 | 5. What did you see instead? 16 | 17 | General questions should go to the [golang-nuts mailing list](https://groups.google.com/group/golang-nuts) instead of the issue tracker. 18 | The gophers there will answer or ask you to file an issue if you've tripped over a bug. 19 | 20 | ## Contributing code 21 | 22 | Please read the [Contribution Guidelines](https://golang.org/doc/contribute.html) 23 | before sending patches. 24 | 25 | Unless otherwise noted, the Go source files are distributed under 26 | the BSD-style license found in the LICENSE file. 27 | -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional IP Rights Grant (Patents) 2 | 3 | "This implementation" means the copyrightable works distributed by 4 | Google as part of the Go project. 5 | 6 | Google hereby grants to You a perpetual, worldwide, non-exclusive, 7 | no-charge, royalty-free, irrevocable (except as stated in this section) 8 | patent license to make, have made, use, offer to sell, sell, import, 9 | transfer and otherwise run, modify and propagate the contents of this 10 | implementation of Go, where such license applies only to those patent 11 | claims, both currently owned or controlled by Google and acquired in 12 | the future, licensable by Google that are necessarily infringed by this 13 | implementation of Go. This grant does not include claims that would be 14 | infringed only as a consequence of further modification of this 15 | implementation. If you or your agent or exclusive licensee institute or 16 | order or agree to the institution of patent litigation against any 17 | entity (including a cross-claim or counterclaim in a lawsuit) alleging 18 | that this implementation of Go or any code incorporated within this 19 | implementation of Go constitutes direct or contributory patent 20 | infringement, or inducement of patent infringement, then any patent 21 | rights granted to you under this License for this implementation of Go 22 | shall terminate as of the date such litigation is filed. 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 The Go Authors. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /git-codereview/editor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 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 | "io" 9 | "os" 10 | "os/exec" 11 | ) 12 | 13 | // editor invokes an interactive editor on a temporary file containing 14 | // initial, blocks until the editor exits, and returns the (possibly 15 | // edited) contents of the temporary file. It follows the conventions 16 | // of git for selecting and invoking the editor (see git-var(1)). 17 | func editor(initial string) string { 18 | // Query the git editor command. 19 | gitEditor := trim(cmdOutput("git", "var", "GIT_EDITOR")) 20 | 21 | // Create temporary file. 22 | temp, err := os.CreateTemp("", "git-codereview") 23 | if err != nil { 24 | dief("creating temp file: %v", err) 25 | } 26 | tempName := temp.Name() 27 | defer os.Remove(tempName) 28 | if _, err := io.WriteString(temp, initial); err != nil { 29 | dief("%v", err) 30 | } 31 | if err := temp.Close(); err != nil { 32 | dief("%v", err) 33 | } 34 | 35 | // Invoke the editor. See git's prepare_shell_cmd. 36 | cmd := exec.Command("sh", "-c", gitEditor+" \"$@\"", gitEditor, tempName) 37 | cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr 38 | if err := cmd.Run(); err != nil { 39 | os.Remove(tempName) 40 | dief("editor exited with: %v", err) 41 | } 42 | 43 | // Read the edited file. 44 | b, err := os.ReadFile(tempName) 45 | if err != nil { 46 | dief("%v", err) 47 | } 48 | return string(b) 49 | } 50 | -------------------------------------------------------------------------------- /git-codereview/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 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 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestParseConfig(t *testing.T) { 13 | cases := []struct { 14 | raw string 15 | want map[string]string 16 | wanterr bool 17 | }{ 18 | {raw: "", want: map[string]string{}}, 19 | {raw: "issuerepo: golang/go", want: map[string]string{"issuerepo": "golang/go"}}, 20 | {raw: "# comment", want: map[string]string{}}, 21 | {raw: "# comment\n k : v \n# comment 2\n\n k2:v2\n", want: map[string]string{"k": "v", "k2": "v2"}}, 22 | } 23 | 24 | for _, tt := range cases { 25 | cfg, err := parseConfig(tt.raw) 26 | if err != nil != tt.wanterr { 27 | t.Errorf("parse(%q) error: %v", tt.raw, err) 28 | continue 29 | } 30 | if !reflect.DeepEqual(cfg, tt.want) { 31 | t.Errorf("parse(%q)=%v want %v", tt.raw, cfg, tt.want) 32 | } 33 | } 34 | } 35 | 36 | func TestHaveGerritInternal(t *testing.T) { 37 | tests := []struct { 38 | gerrit string 39 | origin string 40 | want bool 41 | }{ 42 | {gerrit: "off", want: false}, 43 | {gerrit: "on", want: true}, 44 | {origin: "invalid url", want: false}, 45 | {origin: "https://github.com/golang/go", want: false}, 46 | {origin: "http://github.com/golang/go", want: false}, 47 | {origin: "git@github.com:golang/go", want: false}, 48 | {origin: "git@github.com:golang/go.git", want: false}, 49 | {origin: "git@github.com:/golang/go", want: false}, 50 | {origin: "git@github.com:/golang/go.git", want: false}, 51 | {origin: "ssh://git@github.com/golang/go", want: false}, 52 | {origin: "ssh://git@github.com/golang/go.git", want: false}, 53 | {origin: "git+ssh://git@github.com/golang/go", want: false}, 54 | {origin: "git+ssh://git@github.com/golang/go.git", want: false}, 55 | {origin: "git://github.com/golang/go", want: false}, 56 | {origin: "git://github.com/golang/go.git", want: false}, 57 | {origin: "sso://go/tools", want: true}, // Google-internal 58 | {origin: "rpc://go/tools", want: true}, // Google-internal 59 | {origin: "http://go.googlesource.com/sys", want: false}, 60 | {origin: "https://go.googlesource.com/review", want: true}, 61 | {origin: "https://go.googlesource.com/review/", want: true}, 62 | } 63 | 64 | for _, test := range tests { 65 | if got := haveGerritInternal(test.gerrit, test.origin); got != test.want { 66 | t.Errorf("haveGerritInternal(%q, %q) = %t, want %t", test.gerrit, test.origin, got, test.want) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /git-codereview/reword_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 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 | "os" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestReword(t *testing.T) { 14 | gt := newGitTest(t) 15 | defer gt.done() 16 | 17 | gt.work(t) 18 | gt.work(t) 19 | gt.work(t) 20 | trun(t, gt.client, "git", "tag", "MSG3") 21 | gt.work(t) 22 | trun(t, gt.client, "git", "tag", "MSG4") 23 | const fakeName = "Grace R. Emlin" 24 | os.Setenv("GIT_AUTHOR_NAME", fakeName) 25 | gt.work(t) 26 | os.Unsetenv("GIT_AUTHOR_NAME") 27 | 28 | write(t, gt.client+"/file", "pending work", 0644) // would make git checkout unhappy 29 | 30 | testMainDied(t, "rebase-work") 31 | testNoStdout(t) 32 | testPrintedStderr(t, "cannot rebase with uncommitted work") 33 | 34 | os.Setenv("GIT_EDITOR", "sed -i.bak -e s/msg/MESSAGE/") 35 | defer os.Unsetenv("GIT_EDITOR") 36 | 37 | testMain(t, "reword", "MSG3", "MSG4") 38 | testNoStdout(t) 39 | testPrintedStderr(t, "editing messages (new texts logged in", 40 | ".git"+string(os.PathSeparator)+"REWORD_MSGS in case of failure)") 41 | 42 | testMain(t, "pending", "-c", "-l", "-s") 43 | testNoStderr(t) 44 | testPrintedStdout(t, 45 | "msg #2", 46 | "MESSAGE #3", 47 | "MESSAGE #4", 48 | "msg #5", 49 | ) 50 | 51 | testMain(t, "reword") // reword all 52 | testNoStdout(t) 53 | testPrintedStderr(t, "editing messages (new texts logged in", 54 | ".git"+string(os.PathSeparator)+"REWORD_MSGS in case of failure)") 55 | 56 | testMain(t, "pending", "-c", "-l", "-s") 57 | testNoStderr(t) 58 | testPrintedStdout(t, 59 | "MESSAGE #2", 60 | "MESSAGE #3", 61 | "MESSAGE #4", 62 | "MESSAGE #5", 63 | ) 64 | 65 | out := trun(t, gt.client, "git", "log", "-n1") 66 | if !strings.Contains(out, fakeName) { 67 | t.Fatalf("reword lost author name (%s):\n%s", fakeName, out) 68 | } 69 | 70 | write(t, gt.client+"/codereview.cfg", "issuerepo: my/issues\ngerrit: on\n", 0644) 71 | 72 | os.Setenv("GIT_EDITOR", "sed -i.bak -e 's/Change-Id:.*/Fixes #12345/'") 73 | testMain(t, "reword", "HEAD") // editing single commit message 74 | out = trun(t, gt.client, "git", "log", "-n1", "HEAD") 75 | if !strings.Contains(out, "Fixes my/issues#12345") || !strings.Contains(out, "Change-Id:") { 76 | t.Fatalf("reword single commit did not run commit message hook:\n%s", out) 77 | } 78 | 79 | trun(t, gt.client, "git", "reset", "--hard", "MSG4") 80 | testMain(t, "reword") // editing all commit messages 81 | out = trun(t, gt.client, "git", "log", "-n1", "HEAD") 82 | if !strings.Contains(out, "Fixes my/issues#12345") || !strings.Contains(out, "Change-Id:") { 83 | t.Fatalf("reword multiple commits did not run commit message hook:\n%s", out) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /git-codereview/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 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 | "fmt" 9 | "net/url" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | var ( 16 | configPath string 17 | cachedConfig map[string]string 18 | ) 19 | 20 | // Config returns the code review config. 21 | // Configs consist of lines of the form "key: value". 22 | // Lines beginning with # are comments. 23 | // If there is no config, it returns an empty map. 24 | // If the config is malformed, it dies. 25 | func config() map[string]string { 26 | if cachedConfig != nil { 27 | return cachedConfig 28 | } 29 | configPath = filepath.Join(repoRoot(), "codereview.cfg") 30 | b, err := os.ReadFile(configPath) 31 | raw := string(b) 32 | if err != nil { 33 | verbosef("%sfailed to load config from %q: %v", raw, configPath, err) 34 | cachedConfig = make(map[string]string) 35 | return cachedConfig 36 | } 37 | cachedConfig, err = parseConfig(raw) 38 | if err != nil { 39 | dief("%v", err) 40 | } 41 | return cachedConfig 42 | } 43 | 44 | // haveGerrit returns true if gerrit should be used. 45 | // To enable gerrit, codereview.cfg must be present with "gerrit" property set to 46 | // the gerrit https URL or the git origin must be to 47 | // "https://.googlesource.com/". 48 | func haveGerrit() bool { 49 | gerrit := config()["gerrit"] 50 | origin := trim(cmdOutput("git", "config", "remote.origin.url")) 51 | return haveGerritInternal(gerrit, origin) 52 | } 53 | 54 | // haveGerritInternal is the same as haveGerrit but factored out 55 | // for testing. 56 | func haveGerritInternal(gerrit, origin string) bool { 57 | if gerrit == "off" { 58 | return false 59 | } 60 | if gerrit != "" { 61 | return true 62 | } 63 | 64 | u, err := url.Parse(origin) 65 | if err != nil { 66 | return false 67 | } 68 | if u.Scheme == "sso" || u.Scheme == "rpc" { 69 | return true 70 | } 71 | if u.Scheme != "https" { 72 | return false 73 | } 74 | return strings.HasSuffix(u.Host, ".googlesource.com") 75 | } 76 | 77 | func haveGitHub() bool { 78 | origin := trim(cmdOutput("git", "config", "remote.origin.url")) 79 | return strings.Contains(origin, "github.com") 80 | } 81 | 82 | func parseConfig(raw string) (map[string]string, error) { 83 | cfg := make(map[string]string) 84 | for _, line := range nonBlankLines(raw) { 85 | line = strings.TrimSpace(line) 86 | if strings.HasPrefix(line, "#") { 87 | // comment line 88 | continue 89 | } 90 | fields := strings.SplitN(line, ":", 2) 91 | if len(fields) != 2 { 92 | return nil, fmt.Errorf("bad config line, expected 'key: value': %q", line) 93 | } 94 | cfg[strings.TrimSpace(fields[0])] = strings.TrimSpace(fields[1]) 95 | } 96 | return cfg, nil 97 | } 98 | -------------------------------------------------------------------------------- /git-codereview/change_test.go: -------------------------------------------------------------------------------- 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 | "fmt" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestChange(t *testing.T) { 14 | gt := newGitTest(t) 15 | defer gt.done() 16 | 17 | t.Logf("main -> main") 18 | testMain(t, "change", "main") 19 | testRan(t, "git checkout -q main") 20 | branchpoint := strings.TrimSpace(trun(t, gt.client, "git", "rev-parse", "HEAD")) 21 | 22 | testCommitMsg = "foo: my commit msg" 23 | t.Logf("main -> work") 24 | testMain(t, "change", "work") 25 | testRan(t, "git checkout -q -b work HEAD", 26 | "git branch -q --set-upstream-to origin/main") 27 | 28 | t.Logf("work -> main") 29 | testMain(t, "change", "main") 30 | testRan(t, "git checkout -q main") 31 | 32 | t.Logf("main -> work with staged changes") 33 | write(t, gt.client+"/file", "new content", 0644) 34 | trun(t, gt.client, "git", "add", "file") 35 | testMain(t, "change", "work") 36 | testRan(t, "git checkout -q work", 37 | "git commit -q --allow-empty -m foo: my commit msg") 38 | 39 | t.Logf("work -> work2") 40 | testMain(t, "change", "work2") 41 | testRan(t, "git checkout -q -b work2 "+branchpoint, 42 | "git branch -q --set-upstream-to origin/main") 43 | 44 | t.Logf("work2 -> dev.branch") 45 | testMain(t, "change", "dev.branch") 46 | testRan(t, "git checkout -q -t -b dev.branch origin/dev.branch") 47 | 48 | testMain(t, "pending", "-c") 49 | testPrintedStdout(t, "tracking dev.branch") 50 | testMain(t, "change", "main") 51 | testMain(t, "change", "work2") 52 | 53 | t.Logf("server work × 2") 54 | gt.serverWork(t) 55 | gt.serverWork(t) 56 | testMain(t, "sync") 57 | 58 | t.Logf("-> work with server ahead") 59 | testMain(t, "change", "work") 60 | testPrintedStderr(t, "warning: 2 commits behind origin/main; run 'git codereview sync' to update") 61 | } 62 | 63 | func TestChangeHEAD(t *testing.T) { 64 | gt := newGitTest(t) 65 | defer gt.done() 66 | 67 | testMainDied(t, "change", "HeAd") 68 | testPrintedStderr(t, "invalid branch name \"HeAd\": ref name HEAD is reserved for git") 69 | } 70 | 71 | func TestMessageRE(t *testing.T) { 72 | for _, c := range []struct { 73 | in string 74 | want bool 75 | }{ 76 | {"blah", false}, 77 | {"[release-branch.go1.4] blah", false}, 78 | {"[release-branch.go1.4] math: fix cosine", true}, 79 | {"math: fix cosine", true}, 80 | {"math/rand: make randomer", true}, 81 | {"math/rand, crypto/rand: fix random sources", true}, 82 | {"cmd/internal/rsc.io/x86: update from external repo", true}, 83 | {"_content/blog: fix typos", true}, 84 | } { 85 | got := messageRE.MatchString(c.in) 86 | if got != c.want { 87 | t.Errorf("MatchString(%q) = %v, want %v", c.in, got, c.want) 88 | } 89 | } 90 | } 91 | 92 | func TestChangeAmendCommit(t *testing.T) { 93 | gt := newGitTest(t) 94 | defer gt.done() 95 | 96 | testCommitMsg = "foo: amended commit message" 97 | gt.work(t) 98 | 99 | write(t, gt.client+"/file", "new content in work to be amend", 0644) 100 | trun(t, gt.client, "git", "add", "file") 101 | testMain(t, "change") 102 | } 103 | 104 | func TestChangeFailAmendWithMultiplePending(t *testing.T) { 105 | gt := newGitTest(t) 106 | defer gt.done() 107 | 108 | testCommitMsg = "foo: amended commit message" 109 | gt.work(t) 110 | gt.work(t) 111 | 112 | write(t, gt.client+"/file", "new content in work to be amend", 0644) 113 | trun(t, gt.client, "git", "add", "file") 114 | testMainDied(t, "change") 115 | testPrintedStderr(t, "multiple changes pending") 116 | } 117 | 118 | func TestChangeCL(t *testing.T) { 119 | gt := newGitTest(t) 120 | defer gt.done() 121 | 122 | srv := newGerritServer(t) 123 | defer srv.done() 124 | 125 | // Ensure that 'change' with a CL accepts we have gerrit. Test address is injected by newGerritServer. 126 | write(t, gt.server+"/codereview.cfg", "gerrit: on", 0644) 127 | trun(t, gt.server, "git", "add", "codereview.cfg") 128 | trun(t, gt.server, "git", "commit", "-m", "codereview.cfg on main") 129 | trun(t, gt.client, "git", "pull") 130 | defer srv.done() 131 | 132 | hash1 := trim(trun(t, gt.server, "git", "rev-parse", "dev.branch")) 133 | hash2 := trim(trun(t, gt.server, "git", "rev-parse", "release.branch")) 134 | trun(t, gt.server, "git", "update-ref", "refs/changes/00/100/1", hash1) 135 | trun(t, gt.server, "git", "update-ref", "refs/changes/00/100/2", hash2) 136 | trun(t, gt.server, "git", "update-ref", "refs/changes/00/100/3", hash1) 137 | srv.setReply("/a/changes/100", gerritReply{f: func() gerritReply { 138 | changeJSON := `{ 139 | "current_revision": "HASH", 140 | "revisions": { 141 | "HASH": { 142 | "_number": 3 143 | } 144 | } 145 | }` 146 | changeJSON = strings.Replace(changeJSON, "HASH", hash1, -1) 147 | return gerritReply{body: ")]}'\n" + changeJSON} 148 | }}) 149 | 150 | checkChangeCL := func(arg, ref, hash string) { 151 | testMain(t, "change", "main") 152 | testMain(t, "change", arg) 153 | testRan(t, 154 | fmt.Sprintf("git fetch -q origin %s", ref), 155 | "git checkout -q FETCH_HEAD") 156 | if hash != trim(trun(t, gt.client, "git", "rev-parse", "HEAD")) { 157 | t.Fatalf("hash do not match for CL %s", arg) 158 | } 159 | } 160 | 161 | checkChangeCL("100/1", "refs/changes/00/100/1", hash1) 162 | checkChangeCL("100/2", "refs/changes/00/100/2", hash2) 163 | checkChangeCL("100", "refs/changes/00/100/3", hash1) 164 | 165 | // turn off gerrit, make it look like we are on GitHub 166 | write(t, gt.server+"/codereview.cfg", "nothing: here", 0644) 167 | trun(t, gt.server, "git", "add", "codereview.cfg") 168 | trun(t, gt.server, "git", "commit", "-m", "new codereview.cfg on main") 169 | testMain(t, "change", "main") 170 | trun(t, gt.client, "git", "pull", "-r") 171 | trun(t, gt.client, "git", "remote", "set-url", "origin", "https://github.com/google/not-a-project") 172 | 173 | testMain(t, "change", "-n", "123") 174 | testNoStdout(t) 175 | testPrintedStderr(t, 176 | "git fetch -q origin pull/123/head", 177 | "git checkout -q FETCH_HEAD", 178 | ) 179 | } 180 | 181 | func TestChangeWithMessage(t *testing.T) { 182 | gt := newGitTest(t) 183 | defer gt.done() 184 | 185 | testMain(t, "change", "new_branch") 186 | testMain(t, "change", "-m", "foo: some commit message") 187 | testRan(t, "git commit -q --allow-empty -m foo: some commit message") 188 | } 189 | 190 | func TestChangeWithSignoff(t *testing.T) { 191 | gt := newGitTest(t) 192 | defer gt.done() 193 | 194 | testMain(t, "change", "new_branch") 195 | // There are no staged changes, hence an empty commit will be created. 196 | // Hence we also need a commit message. 197 | testMain(t, "change", "-s", "-m", "foo: bar") 198 | testRan(t, "git commit -q --allow-empty -m foo: bar -s") 199 | } 200 | -------------------------------------------------------------------------------- /git-codereview/branch_test.go: -------------------------------------------------------------------------------- 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 | "path/filepath" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | func TestCurrentBranch(t *testing.T) { 15 | gt := newGitTest(t) 16 | defer gt.done() 17 | 18 | t.Logf("on main") 19 | checkCurrentBranch(t, "main", "origin/main", false, "", "") 20 | 21 | t.Logf("on newbranch") 22 | trun(t, gt.client, "git", "checkout", "--no-track", "-b", "newbranch") 23 | checkCurrentBranch(t, "newbranch", "origin/main", false, "", "") 24 | 25 | t.Logf("making change") 26 | write(t, gt.client+"/file", "i made a change", 0644) 27 | trun(t, gt.client, "git", "commit", "-a", "-m", "My change line.\n\nChange-Id: I0123456789abcdef0123456789abcdef\n") 28 | checkCurrentBranch(t, "newbranch", "origin/main", true, "I0123456789abcdef0123456789abcdef", "My change line.") 29 | 30 | t.Logf("on dev.branch") 31 | trun(t, gt.client, "git", "checkout", "-t", "-b", "dev.branch", "origin/dev.branch") 32 | checkCurrentBranch(t, "dev.branch", "origin/dev.branch", false, "", "") 33 | 34 | t.Logf("on newdev") 35 | trun(t, gt.client, "git", "checkout", "-t", "-b", "newdev", "origin/dev.branch") 36 | checkCurrentBranch(t, "newdev", "origin/dev.branch", false, "", "") 37 | 38 | t.Logf("making change") 39 | write(t, gt.client+"/file", "i made another change", 0644) 40 | trun(t, gt.client, "git", "commit", "-a", "-m", "My other change line.\n\nChange-Id: I1123456789abcdef0123456789abcdef\n") 41 | checkCurrentBranch(t, "newdev", "origin/dev.branch", true, "I1123456789abcdef0123456789abcdef", "My other change line.") 42 | 43 | t.Logf("detached head mode") 44 | trun(t, gt.client, "git", "checkout", "main^0") 45 | checkCurrentBranch(t, "HEAD", "", false, "", "") 46 | trun(t, gt.client, "git", "checkout", "dev.branch^0") 47 | checkCurrentBranch(t, "HEAD", "origin/dev.branch", false, "", "") 48 | } 49 | 50 | func checkCurrentBranch(t *testing.T, name, origin string, hasPending bool, changeID, subject string) { 51 | t.Helper() 52 | b := CurrentBranch() 53 | if b.Name != name { 54 | t.Errorf("b.Name = %q, want %q", b.Name, name) 55 | } 56 | if x := b.OriginBranch(); x != origin { 57 | t.Errorf("b.OriginBranch() = %q, want %q", x, origin) 58 | } 59 | if x := b.HasPendingCommit(); x != hasPending { 60 | t.Errorf("b.HasPendingCommit() = %v, want %v", x, hasPending) 61 | } 62 | if work := b.Pending(); len(work) > 0 { 63 | c := work[0] 64 | if x := c.ChangeID; x != changeID { 65 | t.Errorf("b.Pending()[0].ChangeID = %q, want %q", x, changeID) 66 | } 67 | if x := c.Subject; x != subject { 68 | t.Errorf("b.Pending()[0].Subject = %q, want %q", x, subject) 69 | } 70 | } 71 | } 72 | 73 | func TestLocalBranches(t *testing.T) { 74 | gt := newGitTest(t) 75 | defer gt.done() 76 | 77 | t.Logf("on main") 78 | checkLocalBranches(t, "main") 79 | 80 | t.Logf("on dev branch") 81 | trun(t, gt.client, "git", "checkout", "-b", "newbranch") 82 | checkLocalBranches(t, "main", "newbranch") 83 | 84 | t.Logf("detached head mode") 85 | trun(t, gt.client, "git", "checkout", "HEAD^0") 86 | checkLocalBranches(t, "HEAD", "main", "newbranch") 87 | 88 | t.Logf("worktree") 89 | wt := filepath.Join(gt.tmpdir, "git-worktree") 90 | trun(t, gt.client, "git", "worktree", "add", "-b", "wtbranch", wt) 91 | checkLocalBranches(t, "HEAD", "main", "newbranch", "wtbranch") 92 | } 93 | 94 | func checkLocalBranches(t *testing.T, want ...string) { 95 | var names []string 96 | branches := LocalBranches() 97 | for _, b := range branches { 98 | names = append(names, b.Name) 99 | } 100 | if !reflect.DeepEqual(names, want) { 101 | t.Errorf("LocalBranches() = %v, want %v", names, want) 102 | } 103 | } 104 | 105 | func TestAmbiguousRevision(t *testing.T) { 106 | gt := newGitTest(t) 107 | defer gt.done() 108 | gt.work(t) 109 | 110 | t.Logf("creating file paths that conflict with revision parameters") 111 | mkdir(t, gt.client+"/origin") 112 | write(t, gt.client+"/origin/main..work", "Uh-Oh! SpaghettiOs", 0644) 113 | mkdir(t, gt.client+"/work..origin") 114 | write(t, gt.client+"/work..origin/main", "Be sure to drink your Ovaltine", 0644) 115 | 116 | b := CurrentBranch() 117 | b.Submitted("I123456789") 118 | } 119 | 120 | func TestBranchpoint(t *testing.T) { 121 | gt := newGitTest(t) 122 | defer gt.done() 123 | 124 | // Get hash corresponding to checkout (known to server). 125 | hash := strings.TrimSpace(trun(t, gt.client, "git", "rev-parse", "HEAD")) 126 | 127 | // Any work we do after this point should find hash as branchpoint. 128 | for i := 0; i < 4; i++ { 129 | testMain(t, "branchpoint") 130 | t.Logf("numCommits=%d", i) 131 | testPrintedStdout(t, hash) 132 | testNoStderr(t) 133 | 134 | gt.work(t) 135 | } 136 | } 137 | 138 | func TestRebaseWork(t *testing.T) { 139 | gt := newGitTest(t) 140 | defer gt.done() 141 | 142 | // Get hash corresponding to checkout (known to server). 143 | // Any work we do after this point should find hash as branchpoint. 144 | hash := strings.TrimSpace(trun(t, gt.client, "git", "rev-parse", "HEAD")) 145 | 146 | testMainDied(t, "rebase-work", "-n") 147 | testPrintedStderr(t, "no pending work") 148 | 149 | write(t, gt.client+"/file", "uncommitted", 0644) 150 | testMainDied(t, "rebase-work", "-n") 151 | testPrintedStderr(t, "cannot rebase with uncommitted work") 152 | 153 | gt.work(t) 154 | 155 | for i := 0; i < 4; i++ { 156 | testMain(t, "rebase-work", "-n") 157 | t.Logf("numCommits=%d", i) 158 | testPrintedStderr(t, "git rebase -i "+hash) 159 | 160 | gt.work(t) 161 | } 162 | } 163 | 164 | func TestBranchpointMerge(t *testing.T) { 165 | gt := newGitTest(t) 166 | defer gt.done() 167 | 168 | // commit more work on main 169 | write(t, gt.server+"/file", "more work", 0644) 170 | trun(t, gt.server, "git", "commit", "-m", "work", "file") 171 | 172 | // update client 173 | trun(t, gt.client, "git", "checkout", "main") 174 | trun(t, gt.client, "git", "pull") 175 | 176 | hash := strings.TrimSpace(trun(t, gt.client, "git", "rev-parse", "HEAD")) 177 | 178 | // Merge dev.branch but delete the codereview.cfg that comes in, 179 | // or else we'll think we are on the wrong branch. 180 | trun(t, gt.client, "git", "merge", "-m", "merge", "origin/dev.branch") 181 | trun(t, gt.client, "git", "rm", "codereview.cfg") 182 | trun(t, gt.client, "git", "commit", "-m", "rm codereview.cfg") 183 | 184 | // check branchpoint is old head (despite this commit having two parents) 185 | bp := CurrentBranch().Branchpoint() 186 | if bp != hash { 187 | t.Logf("branches:\n%s", trun(t, gt.client, "git", "branch", "-a", "-v")) 188 | t.Logf("log:\n%s", trun(t, gt.client, "git", "log", "--graph", "--decorate")) 189 | t.Logf("log origin/main..HEAD:\n%s", trun(t, gt.client, "git", "log", "origin/main..HEAD")) 190 | t.Fatalf("branchpoint=%q, want %q", bp, hash) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /git-codereview/api_test.go: -------------------------------------------------------------------------------- 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 | "os" 9 | "path/filepath" 10 | "testing" 11 | ) 12 | 13 | var authTests = []struct { 14 | netrc string 15 | cookiefile string 16 | user string 17 | password string 18 | cookieName string 19 | cookieValue string 20 | died bool 21 | }{ 22 | { 23 | // If we specify the empty string here, git will store an empty 24 | // value for the local http.cookiefile, and fall back to the global 25 | // http.cookiefile, which will fail this test on any machine that has 26 | // a global http.cookiefile configured. If we write a local, invalid 27 | // value, git will try to load the local cookie file (and then fail 28 | // later). 29 | cookiefile: " ", 30 | died: true, 31 | }, 32 | { 33 | netrc: "machine go.googlesource.com login u1 password pw\n", 34 | cookiefile: " ", // prevent global fallback 35 | user: "u1", 36 | password: "pw", 37 | }, 38 | { 39 | cookiefile: "go.googlesource.com TRUE / TRUE 2147483647 o2 git-u2=pw\n", 40 | cookieName: "o2", 41 | cookieValue: "git-u2=pw", 42 | }, 43 | { 44 | cookiefile: ".googlesource.com TRUE / TRUE 2147483647 o3 git-u3=pw\n", 45 | cookieName: "o3", 46 | cookieValue: "git-u3=pw", 47 | }, 48 | { 49 | cookiefile: ".googlesource.com TRUE / TRUE 2147483647 o4 WRONG\n" + 50 | "go.googlesource.com TRUE / TRUE 2147483647 o4 git-u4=pw\n", 51 | cookieName: "o4", 52 | cookieValue: "git-u4=pw", 53 | }, 54 | { 55 | cookiefile: "go.googlesource.com TRUE / TRUE 2147483647 o5 git-u5=pw\n" + 56 | ".googlesource.com TRUE / TRUE 2147483647 o5 WRONG\n", 57 | cookieName: "o5", 58 | cookieValue: "git-u5=pw", 59 | }, 60 | { 61 | netrc: "machine go.googlesource.com login u6 password pw\n", 62 | cookiefile: "BOGUS", 63 | user: "u6", 64 | password: "pw", 65 | }, 66 | { 67 | netrc: "BOGUS", 68 | cookiefile: "go.googlesource.com TRUE / TRUE 2147483647 o7 git-u7=pw\n", 69 | cookieName: "o7", 70 | cookieValue: "git-u7=pw", 71 | }, 72 | { 73 | netrc: "machine go.googlesource.com login u8 password pw\n", 74 | cookiefile: "MISSING", 75 | user: "u8", 76 | password: "pw", 77 | }, 78 | } 79 | 80 | func TestLoadAuth(t *testing.T) { 81 | gt := newGitTest(t) 82 | defer gt.done() 83 | gt.work(t) 84 | 85 | defer os.Setenv("HOME", os.Getenv("HOME")) 86 | os.Setenv("HOME", gt.client) 87 | 88 | testHomeDir = gt.client 89 | netrc := filepath.Join(gt.client, netrcName()) 90 | defer func() { 91 | testHomeDir = "" 92 | }() 93 | trun(t, gt.client, "git", "config", "remote.origin.url", "https://go.googlesource.com/go") 94 | 95 | for i, tt := range authTests { 96 | t.Logf("#%d", i) 97 | auth.initialized = false 98 | auth.user = "" 99 | auth.password = "" 100 | auth.cookieName = "" 101 | auth.cookieValue = "" 102 | trun(t, gt.client, "git", "config", "http.cookiefile", "XXX") 103 | trun(t, gt.client, "git", "config", "--unset", "http.cookiefile") 104 | 105 | remove(t, netrc) 106 | remove(t, gt.client+"/.cookies") 107 | if tt.netrc != "" { 108 | write(t, netrc, tt.netrc, 0644) 109 | } 110 | if tt.cookiefile != "" { 111 | if tt.cookiefile != "MISSING" { 112 | write(t, gt.client+"/.cookies", tt.cookiefile, 0644) 113 | } 114 | trun(t, gt.client, "git", "config", "http.cookiefile", "~/.cookies") 115 | } 116 | 117 | // Run command via testMain to trap stdout, stderr, death. 118 | if tt.died { 119 | testMainDied(t, "test-loadAuth") 120 | } else { 121 | testMain(t, "test-loadAuth") 122 | } 123 | 124 | if auth.user != tt.user || auth.password != tt.password { 125 | t.Errorf("#%d: have user, password = %q, %q, want %q, %q", i, auth.user, auth.password, tt.user, tt.password) 126 | } 127 | if auth.cookieName != tt.cookieName || auth.cookieValue != tt.cookieValue { 128 | t.Errorf("#%d: have cookie name, value = %q, %q, want %q, %q", i, auth.cookieName, auth.cookieValue, tt.cookieName, tt.cookieValue) 129 | } 130 | } 131 | } 132 | 133 | func TestLoadGerritOrigin(t *testing.T) { 134 | list := []struct { 135 | origin, originUrl string 136 | 137 | fail bool 138 | host, url, project string 139 | }{ 140 | { 141 | // *.googlesource.com 142 | origin: "", 143 | originUrl: "https://go.googlesource.com/crypto", 144 | host: "go.googlesource.com", 145 | url: "https://go-review.googlesource.com", 146 | project: "crypto", 147 | }, 148 | { 149 | // Clone with sso://go/ (Google-internal but common with Go developers) 150 | origin: "", 151 | originUrl: "sso://go/tools", 152 | host: "go.googlesource.com", 153 | url: "https://go-review.googlesource.com", 154 | project: "tools", 155 | }, 156 | { 157 | // Clone with rpc://go/ (Google-internal but common with Go developers) 158 | origin: "", 159 | originUrl: "rpc://go/tools", 160 | host: "go.googlesource.com", 161 | url: "https://go-review.googlesource.com", 162 | project: "tools", 163 | }, 164 | { 165 | // Gerrit origin is set. 166 | // Gerrit is hosted on a sub-domain. 167 | origin: "https://gerrit.mysite.com", 168 | originUrl: "https://gerrit.mysite.com/projectA", 169 | host: "gerrit.mysite.com", 170 | url: "https://gerrit.mysite.com", 171 | project: "projectA", 172 | }, 173 | { 174 | // Gerrit origin is set. 175 | // Gerrit is hosted under sub-path under "/gerrit". 176 | origin: "https://mysite.com/gerrit", 177 | originUrl: "https://mysite.com/gerrit/projectA", 178 | host: "mysite.com", 179 | url: "https://mysite.com/gerrit", 180 | project: "projectA", 181 | }, 182 | { 183 | // Gerrit origin is set. 184 | // Gerrit is hosted under sub-path under "/gerrit" and repo is under 185 | // sub-folder. 186 | origin: "https://mysite.com/gerrit", 187 | originUrl: "https://mysite.com/gerrit/sub/projectA", 188 | host: "mysite.com", 189 | url: "https://mysite.com/gerrit", 190 | project: "sub/projectA", 191 | }, 192 | } 193 | 194 | for _, item := range list { 195 | auth.initialized = false 196 | auth.host = "" 197 | auth.url = "" 198 | auth.project = "" 199 | err := loadGerritOriginInternal(item.origin, item.originUrl) 200 | if err != nil && !item.fail { 201 | t.Errorf("unexpected error from item %q %q: %v", item.origin, item.originUrl, err) 202 | continue 203 | } 204 | if auth.host != item.host || auth.url != item.url || auth.project != item.project { 205 | t.Errorf("want %q %q %q, got %q %q %q", item.host, item.url, item.project, auth.host, auth.url, auth.project) 206 | continue 207 | } 208 | if item.fail { 209 | continue 210 | } 211 | have := haveGerritInternal(item.origin, item.originUrl) 212 | if !have { 213 | t.Errorf("for %q %q expect haveGerrit() == true, but got false", item.origin, item.originUrl) 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /git-codereview/reword.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 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 | "fmt" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | func cmdReword(args []string) { 16 | flags.Usage = func() { 17 | fmt.Fprintf(stderr(), "Usage: %s reword %s [commit...]\n", 18 | progName, globalFlags) 19 | exit(2) 20 | } 21 | flags.Parse(args) 22 | args = flags.Args() 23 | 24 | // Check that we understand the structure 25 | // before we let the user spend time editing messages. 26 | b := CurrentBranch() 27 | pending := b.Pending() 28 | if len(pending) == 0 { 29 | dief("reword: no commits pending") 30 | } 31 | if b.Name == "HEAD" { 32 | dief("reword: no current branch") 33 | } 34 | var last *Commit 35 | for i := len(pending) - 1; i >= 0; i-- { 36 | c := pending[i] 37 | if last != nil && !c.HasParent(last.Hash) { 38 | dief("internal error: confused about pending commit graph: parent %.7s vs %.7s", last.Hash, c.Parents) 39 | } 40 | last = c 41 | } 42 | 43 | headState := func() (head, branch string) { 44 | head = trim(cmdOutput("git", "rev-parse", "HEAD")) 45 | for _, line := range nonBlankLines(cmdOutput("git", "branch", "-l")) { 46 | if strings.HasPrefix(line, "* ") { 47 | branch = trim(line[1:]) 48 | return head, branch 49 | } 50 | } 51 | dief("internal error: cannot find current branch") 52 | panic("unreachable") 53 | } 54 | 55 | head, branch := headState() 56 | if head != last.Hash { 57 | dief("internal error: confused about pending commit graph: HEAD vs parent: %.7s vs %.7s", head, last.Hash) 58 | } 59 | if branch != b.Name { 60 | dief("internal error: confused about pending commit graph: branch name %s vs %s", branch, b.Name) 61 | } 62 | 63 | // Build list of commits to be reworded. 64 | // Do first, in case there are typos on the command line. 65 | var cs []*Commit 66 | newMsg := make(map[*Commit]string) 67 | if len(args) == 0 { 68 | for _, c := range pending { 69 | cs = append(cs, c) 70 | } 71 | } else { 72 | for _, arg := range args { 73 | c := b.CommitByRev("reword", arg) 74 | cs = append(cs, c) 75 | } 76 | } 77 | for _, c := range cs { 78 | newMsg[c] = "" 79 | } 80 | 81 | // Invoke editor to reword all the messages message. 82 | // Save the edits to REWORD_MSGS immediately after editor exit 83 | // in case we for some reason cannot apply the changes - don't want 84 | // to throw away the user's writing. 85 | // But we don't use REWORD_MSGS as the actual editor file, 86 | // because if there are multiple git rewords happening 87 | // (perhaps the user has forgotten about one in another window), 88 | // we don't want them to step on each other during editing. 89 | var buf bytes.Buffer 90 | saveFile := filepath.Join(gitPathDir(), "REWORD_MSGS") 91 | saveBuf := func() { 92 | if err := os.WriteFile(saveFile, buf.Bytes(), 0666); err != nil { 93 | dief("cannot save messages: %v", err) 94 | } 95 | } 96 | saveBuf() // make sure it works before we let the user edit anything 97 | printf("editing messages (new texts logged in %s in case of failure)", saveFile) 98 | note := "edited messages saved in " + saveFile 99 | 100 | if len(cs) == 1 { 101 | c := cs[0] 102 | edited := editor(c.Message) 103 | if edited == "" { 104 | dief("edited message is empty") 105 | } 106 | newMsg[c] = string(fixCommitMessage([]byte(edited))) 107 | fmt.Fprintf(&buf, "# %s\n\n%s\n\n", c.Subject, edited) 108 | saveBuf() 109 | } else { 110 | // Edit all at once. 111 | var ed bytes.Buffer 112 | ed.WriteString(rewordProlog) 113 | byHash := make(map[string]*Commit) 114 | for _, c := range cs { 115 | if strings.HasPrefix(c.Message, "# ") || strings.Contains(c.Message, "\n# ") { 116 | // Will break our framing. 117 | // Should be pretty rare since 'git commit' and 'git commit --amend' 118 | // delete lines beginning with # after editing sessions. 119 | dief("commit %.7s has a message line beginning with # - cannot reword with other commits", c.Hash) 120 | } 121 | hash := c.Hash[:7] 122 | byHash[hash] = c 123 | // Two blank lines before #, one after. 124 | // Lots of space to make it easier to see the boundaries 125 | // between commit messages. 126 | fmt.Fprintf(&ed, "\n\n# %s %s\n\n%s\n", hash, c.Subject, c.Message) 127 | } 128 | edited := editor(ed.String()) 129 | if edited == "" { 130 | dief("edited text is empty") 131 | } 132 | 133 | // Save buffer for user before going further. 134 | buf.WriteString(edited) 135 | saveBuf() 136 | 137 | for i, text := range strings.Split("\n"+edited, "\n# ") { 138 | if i == 0 { 139 | continue 140 | } 141 | text = "# " + text // restore split separator 142 | 143 | // Pull out # hash header line and body. 144 | hdr, body, _ := strings.Cut(text, "\n") 145 | 146 | // Cut blank lines at start and end of body but keep newline-terminated. 147 | for body != "" { 148 | line, rest, _ := strings.Cut(body, "\n") 149 | if line != "" { 150 | break 151 | } 152 | body = rest 153 | } 154 | body = strings.TrimRight(body, " \t\n") 155 | if body != "" { 156 | body += "\n" 157 | } 158 | 159 | // Look up hash. 160 | f := strings.Fields(hdr) 161 | if len(f) < 2 { 162 | dief("edited text has # line with no commit hash\n%s", note) 163 | } 164 | c := byHash[f[1]] 165 | if c == nil { 166 | dief("cannot find commit for header: %s\n%s", strings.TrimSpace(hdr), note) 167 | } 168 | newMsg[c] = string(fixCommitMessage([]byte(body))) 169 | } 170 | } 171 | 172 | // Rebuild the commits the way git would, 173 | // but without doing any git checkout that 174 | // would affect the files in the working directory. 175 | var newHash string 176 | last = nil 177 | for i := len(pending) - 1; i >= 0; i-- { 178 | c := pending[i] 179 | if (newMsg[c] == "" || newMsg[c] == c.Message) && newHash == "" { 180 | // Have not started making changes yet. Leave exactly as is. 181 | last = c 182 | continue 183 | } 184 | // Rebuilding. 185 | msg := newMsg[c] 186 | if msg == "" { 187 | msg = c.Message 188 | } 189 | if last != nil && newHash != "" && !c.HasParent(last.Hash) { 190 | dief("internal error: confused about pending commit graph") 191 | } 192 | gitArgs := []string{"commit-tree", "-p"} 193 | for _, p := range c.Parents { 194 | if last != nil && newHash != "" && p == last.Hash { 195 | p = newHash 196 | } 197 | gitArgs = append(gitArgs, p) 198 | } 199 | gitArgs = append(gitArgs, "-m", msg, c.Tree) 200 | os.Setenv("GIT_AUTHOR_NAME", c.AuthorName) 201 | os.Setenv("GIT_AUTHOR_EMAIL", c.AuthorEmail) 202 | os.Setenv("GIT_AUTHOR_DATE", c.AuthorDate) 203 | newHash = trim(cmdOutput("git", gitArgs...)) 204 | last = c 205 | } 206 | if newHash == "" { 207 | // No messages changed. 208 | return 209 | } 210 | 211 | // Attempt swap of HEAD but leave index and working copy alone. 212 | // No obvious way to make it atomic, but check for races. 213 | head, branch = headState() 214 | if head != pending[0].Hash { 215 | dief("cannot reword: commits changed underfoot\n%s", note) 216 | } 217 | if branch != b.Name { 218 | dief("cannot reword: branch changed underfoot\n%s", note) 219 | } 220 | run("git", "reset", "--soft", newHash) 221 | } 222 | 223 | var rewordProlog = `Rewording multiple commit messages. 224 | The # lines separate the different commits and must be left unchanged. 225 | ` 226 | -------------------------------------------------------------------------------- /git-codereview/submit.go: -------------------------------------------------------------------------------- 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 | "fmt" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | func cmdSubmit(args []string) { 15 | // NOTE: New flags should be added to the usage message below as well as doc.go. 16 | var interactive bool 17 | flags.BoolVar(&interactive, "i", false, "interactively select commits to submit") 18 | flags.Usage = func() { 19 | fmt.Fprintf(stderr(), "Usage: %s submit %s [-i | commit...]\n", progName, globalFlags) 20 | exit(2) 21 | } 22 | flags.Parse(args) 23 | if interactive && flags.NArg() > 0 { 24 | flags.Usage() 25 | exit(2) 26 | } 27 | 28 | b := CurrentBranch() 29 | var cs []*Commit 30 | if interactive { 31 | hashes := submitHashes(b) 32 | if len(hashes) == 0 { 33 | printf("nothing to submit") 34 | return 35 | } 36 | for _, hash := range hashes { 37 | cs = append(cs, b.CommitByRev("submit", hash)) 38 | } 39 | } else if args := flags.Args(); len(args) >= 1 { 40 | for _, arg := range args { 41 | cs = append(cs, b.CommitByRev("submit", arg)) 42 | } 43 | } else { 44 | cs = append(cs, b.DefaultCommit("submit", "must specify commit on command line or use submit -i")) 45 | } 46 | 47 | // No staged changes. 48 | // Also, no unstaged changes, at least for now. 49 | // This makes sure the sync at the end will work well. 50 | // We can relax this later if there is a good reason. 51 | checkStaged("submit") 52 | checkUnstaged("submit") 53 | 54 | // Submit the changes. 55 | var g *GerritChange 56 | for _, c := range cs { 57 | printf("submitting %s %s", c.ShortHash, c.Subject) 58 | g = submit(b, c) 59 | } 60 | 61 | // Sync client to revision that Gerrit committed, but only if we can do it cleanly. 62 | // Otherwise require user to run 'git codereview sync' themselves (if they care). 63 | run("git", "fetch", "-q") 64 | if len(cs) == 1 && len(b.Pending()) == 1 { 65 | if err := runErr("git", "checkout", "-q", "-B", b.Name, g.CurrentRevision, "--"); err != nil { 66 | dief("submit succeeded, but cannot sync local branch\n"+ 67 | "\trun 'git codereview sync' to sync, or\n"+ 68 | "\trun 'git branch -D %s; git change master; git codereview sync' to discard local branch", b.Name) 69 | } 70 | } else { 71 | printf("submit succeeded; run 'git codereview sync' to sync") 72 | } 73 | 74 | // Done! Change is submitted, branch is up to date, ready for new work. 75 | } 76 | 77 | // submit submits a single commit c on branch b and returns the 78 | // GerritChange for the submitted change. It dies if the submit fails. 79 | func submit(b *Branch, c *Commit) *GerritChange { 80 | if strings.Contains(strings.ToLower(c.Message), "do not submit") { 81 | dief("%s: CL says DO NOT SUBMIT", c.ShortHash) 82 | } 83 | 84 | // Fetch Gerrit information about this change. 85 | g, err := b.GerritChange(c, "LABELS", "CURRENT_REVISION") 86 | if err != nil { 87 | dief("%v", err) 88 | } 89 | 90 | // Pre-check that this change appears submittable. 91 | // The final submit will check this too, but it is better to fail now. 92 | if err = submitCheck(g); err != nil { 93 | dief("cannot submit: %v", err) 94 | } 95 | 96 | // Upload most recent revision if not already on server. 97 | 98 | if c.Hash != g.CurrentRevision { 99 | run("git", "push", "-q", "origin", b.PushSpec(c)) 100 | 101 | // Refetch change information. 102 | g, err = b.GerritChange(c, "LABELS", "CURRENT_REVISION") 103 | if err != nil { 104 | dief("%v", err) 105 | } 106 | } 107 | 108 | if *noRun { 109 | printf("stopped before submit") 110 | return g 111 | } 112 | 113 | // Otherwise, try the submit. Sends back updated GerritChange, 114 | // but we need extended information and the reply is in the 115 | // "SUBMITTED" state anyway, so ignore the GerritChange 116 | // in the response and fetch a new one below. 117 | if err := gerritAPI("/a/changes/"+fullChangeID(b, c)+"/submit", []byte(`{"wait_for_merge": true}`), nil); err != nil { 118 | dief("cannot submit: %v", err) 119 | } 120 | 121 | // It is common to get back "SUBMITTED" for a split second after the 122 | // request is made. That indicates that the change has been queued for submit, 123 | // but the first merge (the one wait_for_merge waited for) 124 | // failed, possibly due to a spurious condition. We see this often, and the 125 | // status usually changes to MERGED shortly thereafter. 126 | // Wait a little while to see if we can get to a different state. 127 | const steps = 6 128 | const max = 2 * time.Second 129 | for i := 0; i < steps; i++ { 130 | time.Sleep(max * (1 << uint(i+1)) / (1 << steps)) 131 | g, err = b.GerritChange(c, "LABELS", "CURRENT_REVISION") 132 | if err != nil { 133 | dief("waiting for merge: %v", err) 134 | } 135 | if g.Status != "SUBMITTED" { 136 | break 137 | } 138 | } 139 | 140 | switch g.Status { 141 | default: 142 | dief("submit error: unexpected post-submit Gerrit change status %q", g.Status) 143 | 144 | case "MERGED": 145 | // good 146 | 147 | case "SUBMITTED": 148 | // see above 149 | dief("cannot submit: timed out waiting for change to be submitted by Gerrit") 150 | } 151 | 152 | return g 153 | } 154 | 155 | // submitCheck checks that g should be submittable. This is 156 | // necessarily a best-effort check. 157 | // 158 | // g must have the "LABELS" option. 159 | func submitCheck(g *GerritChange) error { 160 | // Check Gerrit change status. 161 | switch g.Status { 162 | default: 163 | return fmt.Errorf("unexpected Gerrit change status %q", g.Status) 164 | 165 | case "NEW", "SUBMITTED": 166 | // Not yet "MERGED", so try the submit. 167 | // "SUBMITTED" is a weird state. It means that Submit has been clicked once, 168 | // but it hasn't happened yet, usually because of a merge failure. 169 | // The user may have done git codereview sync and may now have a mergeable 170 | // copy waiting to be uploaded, so continue on as if it were "NEW". 171 | 172 | case "MERGED": 173 | // Can happen if moving between different clients. 174 | return fmt.Errorf("change already submitted, run 'git codereview sync'") 175 | 176 | case "ABANDONED": 177 | return fmt.Errorf("change abandoned") 178 | } 179 | 180 | // Check for label approvals (like CodeReview+2). 181 | for _, name := range g.LabelNames() { 182 | label := g.Labels[name] 183 | if label.Optional { 184 | continue 185 | } 186 | if label.Rejected != nil { 187 | return fmt.Errorf("change has %s rejection", name) 188 | } 189 | if label.Approved == nil { 190 | return fmt.Errorf("change missing %s approval", name) 191 | } 192 | } 193 | 194 | return nil 195 | } 196 | 197 | // submitHashes interactively prompts for commits to submit. 198 | func submitHashes(b *Branch) []string { 199 | // Get pending commits on b. 200 | pending := b.Pending() 201 | for _, c := range pending { 202 | // Note that DETAILED_LABELS does not imply LABELS. 203 | c.g, c.gerr = b.GerritChange(c, "CURRENT_REVISION", "LABELS", "DETAILED_LABELS") 204 | if c.g == nil { 205 | c.g = new(GerritChange) 206 | } 207 | } 208 | 209 | // Construct submit script. 210 | var script bytes.Buffer 211 | for i := len(pending) - 1; i >= 0; i-- { 212 | c := pending[i] 213 | 214 | if c.g.ID == "" { 215 | fmt.Fprintf(&script, "# change not on Gerrit:\n#") 216 | } else if err := submitCheck(c.g); err != nil { 217 | fmt.Fprintf(&script, "# %v:\n#", err) 218 | } 219 | 220 | formatCommit(&script, c, true) 221 | } 222 | 223 | fmt.Fprintf(&script, ` 224 | # The above commits will be submitted in order from top to bottom 225 | # when you exit the editor. 226 | # 227 | # These lines can be re-ordered, removed, and commented out. 228 | # 229 | # If you remove all lines, the submit will be aborted. 230 | `) 231 | 232 | // Edit the script. 233 | final := editor(script.String()) 234 | 235 | // Parse the final script. 236 | var hashes []string 237 | for _, line := range lines(final) { 238 | line := strings.TrimSpace(line) 239 | if len(line) == 0 || line[0] == '#' { 240 | continue 241 | } 242 | if i := strings.Index(line, " "); i >= 0 { 243 | line = line[:i] 244 | } 245 | hashes = append(hashes, line) 246 | } 247 | 248 | return hashes 249 | } 250 | -------------------------------------------------------------------------------- /git-codereview/change.go: -------------------------------------------------------------------------------- 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 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | ) 15 | 16 | var commitMsg string 17 | var changeAuto bool 18 | var changeQuick bool 19 | var changeSignoff bool 20 | 21 | func cmdChange(args []string) { 22 | // NOTE: New flags should be added to the usage message below as well as doc.go. 23 | flags.StringVar(&commitMsg, "m", "", "specify a commit message") 24 | flags.BoolVar(&changeAuto, "a", false, "add changes to any tracked files") 25 | flags.BoolVar(&changeQuick, "q", false, "do not edit pending commit msg") 26 | flags.BoolVar(&changeSignoff, "s", false, "add a Signed-off-by trailer at the end of the commit message") 27 | flags.Parse(args) 28 | if len(flags.Args()) > 1 { 29 | fmt.Fprintf(stderr(), "Usage: %s change %s [-a] [-m msg] [-q] [branch]\n", progName, globalFlags) 30 | exit(2) 31 | } 32 | 33 | if _, err := cmdOutputErr("git", "rev-parse", "--abbrev-ref", "MERGE_HEAD"); err == nil { 34 | diePendingMerge("change") 35 | } 36 | // Note: A rebase with a conflict + rebase --continue sometimes leaves behind REBASE_HEAD. 37 | // So check for the rebase-merge directory instead, which it does a better job cleaning up. 38 | if _, err := os.Stat(filepath.Join(gitPathDir(), "rebase-merge")); err == nil { 39 | dief("cannot change: found pending rebase or sync") 40 | } 41 | 42 | // Checkout or create branch, if specified. 43 | target := flags.Arg(0) 44 | if target != "" { 45 | checkoutOrCreate(target) 46 | b := CurrentBranch() 47 | if HasStagedChanges() && !b.HasPendingCommit() { 48 | commitChanges(false) 49 | } 50 | b.check() 51 | return 52 | } 53 | 54 | // Create or amend change commit. 55 | b := CurrentBranch() 56 | amend := b.HasPendingCommit() 57 | if amend { 58 | // Dies if there is not exactly one commit. 59 | b.DefaultCommit("amend change", "") 60 | } 61 | commitChanges(amend) 62 | b.loadedPending = false // force reload after commitChanges 63 | b.check() 64 | } 65 | 66 | func (b *Branch) check() { 67 | staged, unstaged, _ := LocalChanges() 68 | if len(staged) == 0 && len(unstaged) == 0 { 69 | // No staged changes, no unstaged changes. 70 | // If the branch is behind upstream, now is a good time to point that out. 71 | // This applies to both local work branches and tracking branches. 72 | b.loadPending() 73 | if n := b.CommitsBehind(); n > 0 { 74 | printf("warning: %d commit%s behind %s; run 'git codereview sync' to update.", n, suffix(n, "s"), b.OriginBranch()) 75 | } 76 | } 77 | } 78 | 79 | var testCommitMsg string 80 | 81 | func commitChanges(amend bool) { 82 | // git commit will run the gofmt hook. 83 | // Run it now to give a better error (won't show a git commit command failing). 84 | hookGofmt() 85 | 86 | if HasUnstagedChanges() && !HasStagedChanges() && !changeAuto { 87 | printf("warning: unstaged changes and no staged changes; use 'git add' or 'git change -a'") 88 | } 89 | commit := func(amend bool) { 90 | args := []string{"commit", "-q", "--allow-empty"} 91 | if amend { 92 | args = append(args, "--amend") 93 | if changeQuick { 94 | args = append(args, "--no-edit") 95 | } 96 | } 97 | if commitMsg != "" { 98 | args = append(args, "-m", commitMsg) 99 | } else if testCommitMsg != "" { 100 | args = append(args, "-m", testCommitMsg) 101 | } 102 | if changeAuto { 103 | args = append(args, "-a") 104 | } 105 | if changeSignoff { 106 | args = append(args, "-s") 107 | } 108 | run("git", args...) 109 | } 110 | commit(amend) 111 | for !commitMessageOK() { 112 | fmt.Print("re-edit commit message (y/n)? ") 113 | if !scanYes() { 114 | break 115 | } 116 | commit(true) 117 | } 118 | printf("change updated.") 119 | } 120 | 121 | func checkoutOrCreate(target string) { 122 | // If it's a valid Gerrit number CL or CL/PS or GitHub pull request number PR, 123 | // checkout the CL or PR. 124 | cl, ps, isCL := parseCL(target) 125 | if isCL { 126 | what := "CL" 127 | if !haveGerrit() && haveGitHub() { 128 | what = "PR" 129 | if ps != "" { 130 | dief("change PR syntax is NNN not NNN.PP") 131 | } 132 | } 133 | if what == "CL" && !haveGerrit() { 134 | dief("cannot change to a CL without gerrit") 135 | } 136 | if HasStagedChanges() || HasUnstagedChanges() { 137 | dief("cannot change to a %s with uncommitted work", what) 138 | } 139 | checkoutCL(what, cl, ps) 140 | return 141 | } 142 | 143 | if strings.ToUpper(target) == "HEAD" { 144 | // Git gets very upset and confused if you 'git change head' 145 | // on systems with case-insensitive file names: the branch 146 | // head conflicts with the usual HEAD. 147 | dief("invalid branch name %q: ref name HEAD is reserved for git.", target) 148 | } 149 | 150 | // If local branch exists, check it out. 151 | for _, b := range LocalBranches() { 152 | if b.Name == target { 153 | run("git", "checkout", "-q", target) 154 | printf("changed to branch %v.", target) 155 | return 156 | } 157 | } 158 | 159 | // If origin branch exists, create local branch tracking it. 160 | for _, name := range OriginBranches() { 161 | if name == "origin/"+target { 162 | run("git", "checkout", "-q", "-t", "-b", target, name) 163 | printf("created branch %v tracking %s.", target, name) 164 | return 165 | } 166 | } 167 | 168 | // Otherwise, this is a request to create a local work branch. 169 | // Check for reserved names. We take everything with a dot. 170 | if strings.Contains(target, ".") { 171 | dief("invalid branch name %v: branch names with dots are reserved for git-codereview.", target) 172 | } 173 | 174 | // If the current branch has a pending commit, building 175 | // on top of it will not help. Don't allow that. 176 | // Otherwise, inherit branchpoint and upstream from the current branch. 177 | b := CurrentBranch() 178 | branchpoint := "HEAD" 179 | if b.HasPendingCommit() { 180 | fmt.Fprintf(stderr(), "warning: pending changes on %s are not copied to new branch %s\n", b.Name, target) 181 | branchpoint = b.Branchpoint() 182 | } 183 | 184 | origin := b.OriginBranch() 185 | 186 | // NOTE: This is different from git checkout -q -t -b origin, 187 | // because the -t would use the origin directly, and that may be 188 | // ahead of the current directory. The goal of this command is 189 | // to create a new branch for work on the current directory, 190 | // not to incorporate new commits at the same time (use 'git codereview sync' for that). 191 | // The ideal is that HEAD doesn't change at all. 192 | // In the absence of pending commits, that ideal is achieved. 193 | // But if there are pending commits, it'd be too confusing to have them 194 | // on two different work branches, so we drop them and use the 195 | // branchpoint they started from (after warning above), moving HEAD 196 | // as little as possible. 197 | run("git", "checkout", "-q", "-b", target, branchpoint) 198 | run("git", "branch", "-q", "--set-upstream-to", origin) 199 | printf("created branch %v tracking %s.", target, origin) 200 | } 201 | 202 | // Checkout the patch set of the given CL. When patch set is empty, use the latest. 203 | func checkoutCL(what, cl, ps string) { 204 | if what == "CL" && ps == "" { 205 | change, err := readGerritChange(cl + "?o=CURRENT_REVISION") 206 | if err != nil { 207 | dief("cannot change to CL %s: %v", cl, err) 208 | } 209 | rev, ok := change.Revisions[change.CurrentRevision] 210 | if !ok { 211 | dief("cannot change to CL %s: invalid current revision from gerrit", cl) 212 | } 213 | ps = strconv.Itoa(rev.Number) 214 | } 215 | 216 | var ref string 217 | if what == "CL" { 218 | var group string 219 | if len(cl) > 1 { 220 | group = cl[len(cl)-2:] 221 | } else { 222 | group = "0" + cl 223 | } 224 | cl = fmt.Sprintf("%s/%s", cl, ps) 225 | ref = fmt.Sprintf("refs/changes/%s/%s", group, cl) 226 | } else { 227 | ref = fmt.Sprintf("pull/%s/head", cl) 228 | } 229 | err := runErr("git", "fetch", "-q", "origin", ref) 230 | if err != nil { 231 | dief("cannot change to %v %s: %v", what, cl, err) 232 | } 233 | err = runErr("git", "checkout", "-q", "FETCH_HEAD") 234 | if err != nil { 235 | dief("cannot change to %s %s: %v", what, cl, err) 236 | } 237 | if *noRun { 238 | return 239 | } 240 | subject, err := trimErr(cmdOutputErr("git", "log", "--format=%s", "-1")) 241 | if err != nil { 242 | printf("changed to %s %s.", what, cl) 243 | dief("cannot read change subject from git: %v", err) 244 | } 245 | printf("changed to %s %s.\n\t%s", what, cl, subject) 246 | } 247 | 248 | var parseCLRE = regexp.MustCompile(`^([0-9]+)(?:/([0-9]+))?$`) 249 | 250 | // parseCL validates and splits the CL number and patch set (if present). 251 | func parseCL(arg string) (cl, patchset string, ok bool) { 252 | m := parseCLRE.FindStringSubmatch(arg) 253 | if len(m) == 0 { 254 | return "", "", false 255 | } 256 | return m[1], m[2], true 257 | } 258 | 259 | var messageRE = regexp.MustCompile(`^(\[[a-zA-Z0-9.-]+\] )?[a-zA-Z0-9-_/,. ]+: `) 260 | 261 | func commitMessageOK() bool { 262 | body := cmdOutput("git", "log", "--format=format:%B", "-n", "1") 263 | ok := true 264 | if !messageRE.MatchString(body) { 265 | fmt.Print(commitMessageWarning) 266 | ok = false 267 | } 268 | return ok 269 | } 270 | 271 | const commitMessageWarning = ` 272 | Your CL description appears not to use the standard form. 273 | 274 | The first line of your change description is conventionally a one-line summary 275 | of the change, prefixed by the primary affected package, and is used as the 276 | subject for code review mail; the rest of the description elaborates. 277 | 278 | Examples: 279 | 280 | encoding/rot13: new package 281 | 282 | math: add IsInf, IsNaN 283 | 284 | net: fix cname in LookupHost 285 | 286 | unicode: update to Unicode 5.0.2 287 | 288 | ` 289 | 290 | func scanYes() bool { 291 | var s string 292 | fmt.Scan(&s) 293 | return strings.HasPrefix(strings.ToLower(s), "y") 294 | } 295 | -------------------------------------------------------------------------------- /git-codereview/mail_test.go: -------------------------------------------------------------------------------- 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 | "fmt" 9 | "testing" 10 | ) 11 | 12 | func TestMail(t *testing.T) { 13 | gt := newGitTest(t) 14 | defer gt.done() 15 | gt.work(t) 16 | 17 | h := CurrentBranch().Pending()[0].ShortHash 18 | 19 | // fake auth information to avoid Gerrit error 20 | auth.initialized = true 21 | auth.host = "gerrit.fake" 22 | auth.user = "not-a-user" 23 | defer func() { 24 | auth.initialized = false 25 | auth.host = "" 26 | auth.user = "" 27 | }() 28 | 29 | testMain(t, "mail") 30 | testRan(t, 31 | "git push -q origin HEAD:refs/for/main", 32 | "git tag --no-sign -f work.mailed "+h) 33 | } 34 | 35 | func TestDoNotMail(t *testing.T) { 36 | gt := newGitTest(t) 37 | defer gt.done() 38 | gt.work(t) 39 | trun(t, gt.client, "git", "commit", "--amend", "-m", "This is my commit.\n\nDO NOT MAIL\n") 40 | 41 | testMainDied(t, "mail") 42 | testPrintedStderr(t, "DO NOT MAIL") 43 | 44 | trun(t, gt.client, "git", "commit", "--amend", "-m", "fixup! This is my commit.") 45 | 46 | testMainDied(t, "mail") 47 | testPrintedStderr(t, "fixup! commit") 48 | 49 | trun(t, gt.client, "git", "commit", "--amend", "-m", "squash! This is my commit.") 50 | 51 | testMainDied(t, "mail") 52 | testPrintedStderr(t, "squash! commit") 53 | 54 | trun(t, gt.client, "git", "commit", "--amend", "-m", "This is my commit.\n\nDO NOT MAIL\n") 55 | 56 | // Do not mail even when the DO NOT MAIL is a parent of the thing we asked to mail. 57 | gt.work(t) 58 | testMainDied(t, "mail", "HEAD") 59 | testPrintedStderr(t, "DO NOT MAIL") 60 | } 61 | 62 | func TestDoNotMailTempFiles(t *testing.T) { 63 | // fake auth information to avoid Gerrit error 64 | auth.initialized = true 65 | auth.host = "gerrit.fake" 66 | auth.user = "not-a-user" 67 | defer func() { 68 | auth.initialized = false 69 | auth.host = "" 70 | auth.user = "" 71 | }() 72 | 73 | testFile := func(file string) { 74 | gt := newGitTest(t) 75 | defer gt.done() 76 | gt.work(t) 77 | gt.workFile(t, file) 78 | testMainDied(t, "mail", "HEAD") 79 | testPrintedStderr(t, "cannot mail temporary") 80 | } 81 | 82 | testFile("vim-backup.go~") 83 | testFile("#emacs-auto-save.go#") 84 | testFile(".#emacs-lock.go") 85 | 86 | // Do not mail when a parent of the thing we asked to mail has temporary files. 87 | gt := newGitTest(t) 88 | defer gt.done() 89 | gt.work(t) 90 | gt.workFile(t, "backup~") 91 | gt.work(t) 92 | testMainDied(t, "mail", "HEAD") 93 | testPrintedStderr(t, "cannot mail temporary") 94 | } 95 | 96 | func TestMailNonPrintables(t *testing.T) { 97 | gt := newGitTest(t) 98 | defer gt.done() 99 | gt.work(t) 100 | 101 | // fake auth information to avoid Gerrit error 102 | auth.initialized = true 103 | auth.host = "gerrit.fake" 104 | auth.user = "not-a-user" 105 | defer func() { 106 | auth.initialized = false 107 | auth.host = "" 108 | auth.user = "" 109 | }() 110 | 111 | trun(t, gt.client, "git", "commit", "--amend", "-m", "This is my commit.\x10\n\n") 112 | testMainDied(t, "mail") 113 | testPrintedStderr(t, "message with non-printable") 114 | 115 | // This should be mailed. 116 | trun(t, gt.client, "git", "commit", "--amend", "-m", "Printable unicode: \u263A \u0020. Spaces: \v \f \r \t\n\n") 117 | testMain(t, "mail", "HEAD") 118 | } 119 | 120 | func TestMailGitHub(t *testing.T) { 121 | gt := newGitTest(t) 122 | defer gt.done() 123 | gt.work(t) 124 | 125 | trun(t, gt.client, "git", "config", "remote.origin.url", "https://github.com/golang/go") 126 | 127 | testMainDied(t, "mail") 128 | testPrintedStderr(t, "git origin must be a Gerrit host, not GitHub: https://github.com/golang/go") 129 | } 130 | 131 | func TestMailAmbiguousRevision(t *testing.T) { 132 | gt := newGitTest(t) 133 | defer gt.done() 134 | gt.work(t) 135 | 136 | t.Logf("creating file that conflicts with revision parameter") 137 | b := CurrentBranch() 138 | mkdir(t, gt.client+"/origin") 139 | write(t, gt.client+"/"+b.Branchpoint()+"..HEAD", "foo", 0644) 140 | 141 | testMain(t, "mail", "-diff") 142 | } 143 | 144 | func TestMailMultiple(t *testing.T) { 145 | gt := newGitTest(t) 146 | defer gt.done() 147 | 148 | srv := newGerritServer(t) 149 | defer srv.done() 150 | 151 | gt.work(t) 152 | gt.work(t) 153 | gt.work(t) 154 | 155 | testMainDied(t, "mail") 156 | testPrintedStderr(t, "cannot mail: multiple changes pending") 157 | 158 | // Mail first two and test non-HEAD mail. 159 | h := CurrentBranch().Pending()[1].ShortHash 160 | testMain(t, "mail", "HEAD^") 161 | testRan(t, 162 | "git push -q origin "+h+":refs/for/main", 163 | "git tag --no-sign -f work.mailed "+h) 164 | 165 | // Mail HEAD. 166 | h = CurrentBranch().Pending()[0].ShortHash 167 | testMain(t, "mail", "HEAD") 168 | testRan(t, 169 | "git push -q origin HEAD:refs/for/main", 170 | "git tag --no-sign -f work.mailed "+h) 171 | } 172 | 173 | var reviewerLog = []string{ 174 | "Fake 1 ", 175 | "Fake 1 ", 176 | "Fake 1 ", 177 | "Reviewer 1 ", 178 | "Reviewer 1 ", 179 | "Reviewer 1 ", 180 | "Reviewer 1 ", 181 | "Reviewer 1 ", 182 | "Other ", 183 | "", 184 | "Reviewer 2 ", 185 | "Reviewer 2 ", 186 | "Reviewer 2 ", 187 | "Reviewer 2 ", 188 | } 189 | 190 | func TestMailShort(t *testing.T) { 191 | gt := newGitTest(t) 192 | defer gt.done() 193 | 194 | // fake auth information to avoid Gerrit error 195 | auth.initialized = true 196 | auth.host = "gerrit.fake" 197 | auth.user = "not-a-user" 198 | defer func() { 199 | auth.initialized = false 200 | auth.host = "" 201 | auth.user = "" 202 | }() 203 | 204 | // Seed commit history with reviewers. 205 | for i, addr := range reviewerLog { 206 | write(t, gt.server+"/file", fmt.Sprintf("v%d", i), 0644) 207 | trun(t, gt.server, "git", "commit", "-a", "-m", "msg\n\nReviewed-by: "+addr+"\n") 208 | } 209 | trun(t, gt.client, "git", "pull") 210 | 211 | // Do some work. 212 | gt.work(t) 213 | 214 | h := CurrentBranch().Pending()[0].ShortHash 215 | 216 | testMain(t, "mail") 217 | testRan(t, 218 | "git push -q origin HEAD:refs/for/main", 219 | "git tag --no-sign -f work.mailed "+h) 220 | 221 | testMain(t, "mail", "-r", "r1") 222 | testRan(t, 223 | "git push -q origin HEAD:refs/for/main%r=r1@golang.org", 224 | "git tag --no-sign -f work.mailed "+h) 225 | 226 | testMain(t, "mail", "-r", "other,anon", "-cc", "r1,full@email.com") 227 | testRan(t, 228 | "git push -q origin HEAD:refs/for/main%r=other@golang.org,r=anon@golang.org,cc=r1@golang.org,cc=full@email.com", 229 | "git tag --no-sign -f work.mailed "+h) 230 | 231 | testMainDied(t, "mail", "-r", "other", "-r", "anon,r1,missing") 232 | testPrintedStderr(t, "unknown reviewer: missing") 233 | 234 | // Test shortOptOut. 235 | orig := shortOptOut 236 | defer func() { shortOptOut = orig }() 237 | shortOptOut = map[string]bool{"r2@old.example": true} 238 | testMain(t, "mail", "-r", "r2") 239 | testRan(t, 240 | "git push -q origin HEAD:refs/for/main%r=r2@new.example", 241 | "git tag --no-sign -f work.mailed "+h) 242 | } 243 | 244 | func TestWIP(t *testing.T) { 245 | gt := newGitTest(t) 246 | defer gt.done() 247 | gt.work(t) 248 | 249 | h := CurrentBranch().Pending()[0].ShortHash 250 | 251 | testMain(t, "mail", "-wip") 252 | testRan(t, 253 | "git push -q origin HEAD:refs/for/main%wip", 254 | "git tag --no-sign -f work.mailed "+h) 255 | } 256 | 257 | func TestMailTopic(t *testing.T) { 258 | gt := newGitTest(t) 259 | defer gt.done() 260 | gt.work(t) 261 | 262 | h := CurrentBranch().Pending()[0].ShortHash 263 | 264 | // fake auth information to avoid Gerrit error 265 | auth.initialized = true 266 | auth.host = "gerrit.fake" 267 | auth.user = "not-a-user" 268 | defer func() { 269 | auth.initialized = false 270 | auth.host = "" 271 | auth.user = "" 272 | }() 273 | 274 | testMainDied(t, "mail", "-topic", "contains,comma") 275 | testPrintedStderr(t, "topic may not contain a comma") 276 | 277 | testMain(t, "mail", "-topic", "test-topic") 278 | testRan(t, 279 | "git push -q origin HEAD:refs/for/main%topic=test-topic", 280 | "git tag --no-sign -f work.mailed "+h) 281 | } 282 | 283 | func TestMailHashtag(t *testing.T) { 284 | gt := newGitTest(t) 285 | defer gt.done() 286 | gt.work(t) 287 | 288 | h := CurrentBranch().Pending()[0].ShortHash 289 | 290 | // fake auth information to avoid Gerrit error 291 | auth.initialized = true 292 | auth.host = "gerrit.fake" 293 | auth.user = "not-a-user" 294 | defer func() { 295 | auth.initialized = false 296 | auth.host = "" 297 | auth.user = "" 298 | }() 299 | 300 | testMain(t, "mail", "-hashtag", "test1,test2") 301 | testRan(t, 302 | "git push -q origin HEAD:refs/for/main%hashtag=test1,hashtag=test2", 303 | "git tag --no-sign -f work.mailed "+h) 304 | testMain(t, "mail", "-hashtag", "") 305 | testRan(t, 306 | "git push -q origin HEAD:refs/for/main", 307 | "git tag --no-sign -f work.mailed "+h) 308 | 309 | testMainDied(t, "mail", "-hashtag", "test1,,test3") 310 | testPrintedStderr(t, "hashtag may not contain empty tags") 311 | } 312 | 313 | func TestMailEmpty(t *testing.T) { 314 | gt := newGitTest(t) 315 | defer gt.done() 316 | 317 | // fake auth information to avoid Gerrit error 318 | auth.initialized = true 319 | auth.host = "gerrit.fake" 320 | auth.user = "not-a-user" 321 | defer func() { 322 | auth.initialized = false 323 | auth.host = "" 324 | auth.user = "" 325 | }() 326 | 327 | testMain(t, "change", "work") 328 | testRan(t, "git checkout -q -b work HEAD", 329 | "git branch -q --set-upstream-to origin/main") 330 | 331 | t.Logf("creating empty change") 332 | testCommitMsg = "foo: this commit will be empty" 333 | testMain(t, "change") 334 | testRan(t, "git commit -q --allow-empty -m foo: this commit will be empty") 335 | 336 | h := CurrentBranch().Pending()[0].ShortHash 337 | 338 | testMainDied(t, "mail") 339 | testPrintedStderr(t, "cannot mail: commit "+h+" is empty") 340 | } 341 | -------------------------------------------------------------------------------- /git-codereview/submit_test.go: -------------------------------------------------------------------------------- 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 | "os" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | func TestSubmitErrors(t *testing.T) { 15 | gt := newGitTest(t) 16 | defer gt.done() 17 | 18 | srv := newGerritServer(t) 19 | defer srv.done() 20 | 21 | t.Logf("> no commit") 22 | testMainDied(t, "submit") 23 | testPrintedStderr(t, "cannot submit: no changes pending") 24 | write(t, gt.client+"/file1", "", 0644) 25 | trun(t, gt.client, "git", "add", "file1") 26 | trun(t, gt.client, "git", "commit", "-m", "msg\n\nDO NOT SUBMIT\n\nChange-Id: I123456789\n") 27 | 28 | // Gerrit checks this too, but add a local check. 29 | t.Logf("> do not submit") 30 | testMainDied(t, "submit") 31 | testPrintedStderr(t, "DO NOT SUBMIT") 32 | trun(t, gt.client, "git", "commit", "--amend", "-m", "msg\n\nChange-Id: I123456789\n") 33 | 34 | t.Logf("> staged changes") 35 | write(t, gt.client+"/file1", "asdf", 0644) 36 | trun(t, gt.client, "git", "add", "file1") 37 | testMainDied(t, "submit") 38 | testPrintedStderr(t, "cannot submit: staged changes exist", 39 | "git status", "!git stash", "!git add", "git-codereview change") 40 | testNoStdout(t) 41 | 42 | t.Logf("> unstaged changes") 43 | write(t, gt.client+"/file1", "actual content", 0644) 44 | testMainDied(t, "submit") 45 | testPrintedStderr(t, "cannot submit: unstaged changes exist", 46 | "git status", "git stash", "git add", "git-codereview change") 47 | testNoStdout(t) 48 | testRan(t) 49 | trun(t, gt.client, "git", "add", "file1") 50 | trun(t, gt.client, "git", "commit", "--amend", "--no-edit") 51 | 52 | t.Logf("> not found") 53 | testMainDied(t, "submit") 54 | testPrintedStderr(t, "change not found on Gerrit server") 55 | 56 | const id = "I123456789" 57 | 58 | t.Logf("> malformed json") 59 | srv.setJSON(id, "XXX") 60 | testMainDied(t, "submit") 61 | testRan(t) // nothing 62 | testPrintedStderr(t, "malformed json response") 63 | 64 | t.Logf("> unexpected change status") 65 | srv.setJSON(id, `{"status": "UNEXPECTED"}`) 66 | testMainDied(t, "submit") 67 | testRan(t) // nothing 68 | testPrintedStderr(t, "cannot submit: unexpected Gerrit change status \"UNEXPECTED\"") 69 | 70 | t.Logf("> already merged") 71 | srv.setJSON(id, `{"status": "MERGED"}`) 72 | testMainDied(t, "submit") 73 | testRan(t) // nothing 74 | testPrintedStderr(t, "cannot submit: change already submitted, run 'git codereview sync'") 75 | 76 | t.Logf("> abandoned") 77 | srv.setJSON(id, `{"status": "ABANDONED"}`) 78 | testMainDied(t, "submit") 79 | testRan(t) // nothing 80 | testPrintedStderr(t, "cannot submit: change abandoned") 81 | 82 | t.Logf("> missing approval") 83 | srv.setJSON(id, `{"status": "NEW", "labels": {"Code-Review": {}, "Color": {"Optional": true}}}`) 84 | testMainDied(t, "submit") 85 | testRan(t) // nothing 86 | testPrintedStderr(t, "cannot submit: change missing Code-Review approval") 87 | 88 | t.Logf("> rejection") 89 | srv.setJSON(id, `{"status": "NEW", "labels": {"Code-Review": {"rejected": {}}}}`) 90 | testMainDied(t, "submit") 91 | testRan(t) // nothing 92 | testPrintedStderr(t, "cannot submit: change has Code-Review rejection") 93 | 94 | t.Logf("> submit with unexpected status") 95 | const newJSON = `{"status": "NEW", "labels": {"Code-Review": {"approved": {}}}}` 96 | srv.setJSON(id, newJSON) 97 | srv.setReply("/a/changes/proj~main~I123456789/submit", gerritReply{body: ")]}'\n" + newJSON}) 98 | testMainDied(t, "submit") 99 | testRan(t, "git push -q origin HEAD:refs/for/main") 100 | testPrintedStderr(t, "submit error: unexpected post-submit Gerrit change status \"NEW\"") 101 | } 102 | 103 | func TestSubmitTimeout(t *testing.T) { 104 | gt := newGitTest(t) 105 | defer gt.done() 106 | srv := newGerritServer(t) 107 | defer srv.done() 108 | 109 | gt.work(t) 110 | 111 | setJSON := func(json string) { 112 | srv.setReply("/a/changes/proj~main~I123456789", gerritReply{body: ")]}'\n" + json}) 113 | } 114 | 115 | t.Log("> submit with timeout") 116 | const submittedJSON = `{"status": "SUBMITTED", "mergeable": true, "labels": {"Code-Review": {"approved": {}}}}` 117 | setJSON(submittedJSON) 118 | srv.setReply("/a/changes/proj~main~I123456789/submit", gerritReply{body: ")]}'\n" + submittedJSON}) 119 | testMainDied(t, "submit") 120 | testRan(t, "git push -q origin HEAD:refs/for/main") 121 | testPrintedStderr(t, "cannot submit: timed out waiting for change to be submitted by Gerrit") 122 | } 123 | 124 | func TestSubmit(t *testing.T) { 125 | gt := newGitTest(t) 126 | defer gt.done() 127 | srv := newGerritServer(t) 128 | defer srv.done() 129 | 130 | gt.work(t) 131 | trun(t, gt.client, "git", "tag", "-f", "work.mailed") 132 | clientHead := strings.TrimSpace(trun(t, gt.client, "git", "log", "-n", "1", "--format=format:%H")) 133 | 134 | write(t, gt.server+"/file", "another change", 0644) 135 | trun(t, gt.server, "git", "add", "file") 136 | trun(t, gt.server, "git", "commit", "-m", "conflict") 137 | serverHead := strings.TrimSpace(trun(t, gt.server, "git", "log", "-n", "1", "--format=format:%H")) 138 | 139 | t.Log("> submit") 140 | var ( 141 | newJSON = `{"status": "NEW", "mergeable": true, "current_revision": "` + clientHead + `", "labels": {"Code-Review": {"approved": {}}}}` 142 | submittedJSON = `{"status": "SUBMITTED", "mergeable": true, "current_revision": "` + clientHead + `", "labels": {"Code-Review": {"approved": {}}}}` 143 | mergedJSON = `{"status": "MERGED", "mergeable": true, "current_revision": "` + serverHead + `", "labels": {"Code-Review": {"approved": {}}}}` 144 | ) 145 | submitted := false 146 | npoll := 0 147 | srv.setReply("/a/changes/proj~main~I123456789", gerritReply{f: func() gerritReply { 148 | if !submitted { 149 | return gerritReply{body: ")]}'\n" + newJSON} 150 | } 151 | if npoll++; npoll <= 2 { 152 | return gerritReply{body: ")]}'\n" + submittedJSON} 153 | } 154 | return gerritReply{body: ")]}'\n" + mergedJSON} 155 | }}) 156 | srv.setReply("/a/changes/proj~main~I123456789/submit", gerritReply{f: func() gerritReply { 157 | if submitted { 158 | return gerritReply{status: 409} 159 | } 160 | submitted = true 161 | return gerritReply{body: ")]}'\n" + submittedJSON} 162 | }}) 163 | 164 | testMain(t, "submit", "-n") 165 | testPrintedStderr(t, "submitting") 166 | testPrintedStderr(t, "stopped before submit") 167 | 168 | testMain(t, "pending", "-c", "-l") 169 | testPrintedStdout(t, "(current branch)") 170 | testPrintedStdout(t, "Files in this change:") 171 | 172 | testMain(t, "submit") 173 | testRan(t, 174 | "git fetch -q", 175 | "git checkout -q -B work "+serverHead+" --") 176 | } 177 | 178 | func TestSubmitMultiple(t *testing.T) { 179 | gt := newGitTest(t) 180 | defer gt.done() 181 | 182 | srv := newGerritServer(t) 183 | defer srv.done() 184 | 185 | cl1, cl2 := testSubmitMultiple(t, gt, srv) 186 | testMain(t, "submit", cl1.CurrentRevision, cl2.CurrentRevision) 187 | } 188 | 189 | func TestSubmitMultipleNamed(t *testing.T) { 190 | gt := newGitTest(t) 191 | defer gt.done() 192 | 193 | srv := newGerritServer(t) 194 | defer srv.done() 195 | 196 | _, _ = testSubmitMultiple(t, gt, srv) 197 | testMain(t, "submit", "HEAD^", "HEAD") 198 | } 199 | 200 | func TestSubmitInteractive(t *testing.T) { 201 | if runtime.GOOS == "windows" { 202 | t.Skip("see golang.org/issue/13406") 203 | } 204 | 205 | gt := newGitTest(t) 206 | defer gt.done() 207 | 208 | os.Setenv("GIT_EDITOR", ":") 209 | testMain(t, "submit", "-i") 210 | testPrintedStderr(t, "nothing to submit") 211 | 212 | srv := newGerritServer(t) 213 | defer srv.done() 214 | 215 | cl1, cl2 := testSubmitMultiple(t, gt, srv) 216 | 217 | os.Setenv("GIT_EDITOR", "echo > ") 218 | testMain(t, "submit", "-i") 219 | if cl1.Status != "NEW" { 220 | t.Fatalf("want cl1.Status == NEW; got %v", cl1.Status) 221 | } 222 | if cl2.Status != "NEW" { 223 | t.Fatalf("want cl2.Status == NEW; got %v", cl2.Status) 224 | } 225 | 226 | os.Setenv("GIT_EDITOR", "( echo "+cl1.CurrentRevision+"; echo; echo '# comment' ) > ") 227 | testMain(t, "submit", "-i") 228 | if cl1.Status != "MERGED" { 229 | t.Fatalf("want cl1.Status == MERGED; got %v", cl1.Status) 230 | } 231 | if cl2.Status != "NEW" { 232 | t.Fatalf("want cl2.Status == NEW; got %v", cl2.Status) 233 | } 234 | } 235 | 236 | func testSubmitMultiple(t *testing.T, gt *gitTest, srv *gerritServer) (*GerritChange, *GerritChange) { 237 | write(t, gt.client+"/file1", "", 0644) 238 | trun(t, gt.client, "git", "add", "file1") 239 | trun(t, gt.client, "git", "commit", "-m", "msg\n\nChange-Id: I0000001\n") 240 | hash1 := strings.TrimSpace(trun(t, gt.client, "git", "log", "-n", "1", "--format=format:%H")) 241 | 242 | write(t, gt.client+"/file2", "", 0644) 243 | trun(t, gt.client, "git", "add", "file2") 244 | trun(t, gt.client, "git", "commit", "-m", "msg\n\nChange-Id: I0000002\n") 245 | hash2 := strings.TrimSpace(trun(t, gt.client, "git", "log", "-n", "1", "--format=format:%H")) 246 | 247 | testMainDied(t, "submit") 248 | testPrintedStderr(t, "cannot submit: multiple changes pending") 249 | 250 | cl1 := GerritChange{ 251 | Status: "NEW", 252 | CurrentRevision: hash1, 253 | Labels: map[string]*GerritLabel{"Code-Review": {Approved: new(GerritAccount)}}, 254 | } 255 | cl2 := GerritChange{ 256 | Status: "NEW", 257 | CurrentRevision: hash2, 258 | Labels: map[string]*GerritLabel{"Code-Review": {Approved: new(GerritAccount)}}, 259 | } 260 | 261 | srv.setReply("/a/changes/proj~main~I0000001", gerritReply{f: func() gerritReply { 262 | return gerritReply{json: cl1} 263 | }}) 264 | srv.setReply("/a/changes/proj~main~I0000001/submit", gerritReply{f: func() gerritReply { 265 | if cl1.Status != "NEW" { 266 | return gerritReply{status: 409} 267 | } 268 | cl1.Status = "MERGED" 269 | return gerritReply{json: cl1} 270 | }}) 271 | srv.setReply("/a/changes/proj~main~I0000002", gerritReply{f: func() gerritReply { 272 | return gerritReply{json: cl2} 273 | }}) 274 | srv.setReply("/a/changes/proj~main~I0000002/submit", gerritReply{f: func() gerritReply { 275 | if cl2.Status != "NEW" { 276 | return gerritReply{status: 409} 277 | } 278 | cl2.Status = "MERGED" 279 | return gerritReply{json: cl2} 280 | }}) 281 | return &cl1, &cl2 282 | } 283 | -------------------------------------------------------------------------------- /git-codereview/gofmt_test.go: -------------------------------------------------------------------------------- 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 | "fmt" 9 | "os" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | const ( 15 | goodGo = "package good\n" 16 | badGo = " package bad1 " 17 | badGoFixed = "package bad1\n" 18 | bad2Go = " package bad2 " 19 | bad2GoFixed = "package bad2\n" 20 | brokenGo = "package B R O K E N" 21 | ) 22 | 23 | func TestGofmt(t *testing.T) { 24 | // Test of basic operations. 25 | gt := newGitTest(t) 26 | defer gt.done() 27 | 28 | gt.work(t) 29 | 30 | if err := os.MkdirAll(gt.client+"/test/bench", 0755); err != nil { 31 | t.Fatal(err) 32 | } 33 | if err := os.MkdirAll(gt.client+"/vendor", 0755); err != nil { 34 | t.Fatal(err) 35 | } 36 | write(t, gt.client+"/bad.go", badGo, 0644) 37 | write(t, gt.client+"/good.go", goodGo, 0644) 38 | write(t, gt.client+"/vendor/bad.go", badGo, 0644) 39 | write(t, gt.client+"/test/bad.go", badGo, 0644) 40 | write(t, gt.client+"/test/good.go", goodGo, 0644) 41 | write(t, gt.client+"/test/bench/bad.go", badGo, 0644) 42 | write(t, gt.client+"/test/bench/good.go", goodGo, 0644) 43 | trun(t, gt.client, "git", "add", ".") // make files tracked 44 | 45 | testMain(t, "gofmt", "-l") 46 | testPrintedStdout(t, "bad.go\n", "!good.go", fromSlash("!test/bad"), fromSlash("test/bench/bad.go"), fromSlash("!vendor/bad.go")) 47 | testMain(t, "gofmt", "-l") 48 | testPrintedStdout(t, "bad.go\n", "!good.go", fromSlash("!test/bad"), fromSlash("test/bench/bad.go"), fromSlash("!vendor/bad.go")) 49 | 50 | testMain(t, "gofmt") 51 | testNoStdout(t) 52 | 53 | testMain(t, "gofmt", "-l") 54 | testNoStdout(t) 55 | 56 | write(t, gt.client+"/bad.go", badGo, 0644) 57 | write(t, gt.client+"/broken.go", brokenGo, 0644) 58 | trun(t, gt.client, "git", "add", ".") 59 | testMainDied(t, "gofmt", "-l") 60 | testPrintedStdout(t, "bad.go") 61 | testPrintedStderr(t, "gofmt reported errors", "broken.go") 62 | } 63 | 64 | func TestGofmtSubdir(t *testing.T) { 65 | // Check that gofmt prints relative paths for files in or below the current directory. 66 | gt := newGitTest(t) 67 | defer gt.done() 68 | 69 | gt.work(t) 70 | 71 | mkdir(t, gt.client+"/dir1") 72 | mkdir(t, gt.client+"/longnamedir2") 73 | write(t, gt.client+"/dir1/bad1.go", badGo, 0644) 74 | write(t, gt.client+"/longnamedir2/bad2.go", badGo, 0644) 75 | trun(t, gt.client, "git", "add", ".") // make files tracked 76 | 77 | chdir(t, gt.client) 78 | testMain(t, "gofmt", "-l") 79 | testPrintedStdout(t, fromSlash("dir1/bad1.go"), fromSlash("longnamedir2/bad2.go")) 80 | 81 | chdir(t, gt.client+"/dir1") 82 | testMain(t, "gofmt", "-l") 83 | testPrintedStdout(t, "bad1.go", fromSlash("!/bad1.go"), fromSlash("longnamedir2/bad2.go")) 84 | 85 | chdir(t, gt.client+"/longnamedir2") 86 | testMain(t, "gofmt", "-l") 87 | testPrintedStdout(t, "bad2.go", fromSlash("!/bad2.go"), fromSlash("dir1/bad1.go")) 88 | 89 | mkdir(t, gt.client+"/z") 90 | chdir(t, gt.client+"/z") 91 | testMain(t, "gofmt", "-l") 92 | testPrintedStdout(t, fromSlash("longnamedir2/bad2.go"), fromSlash("dir1/bad1.go")) 93 | } 94 | 95 | func TestGofmtSubdirIndexCheckout(t *testing.T) { 96 | // Like TestGofmtSubdir but bad Go files are only in index, not working copy. 97 | // Check also that prints a correct path (relative or absolute) for files outside the 98 | // current directory, even when running with Git before 2.3.0 which doesn't 99 | // handle those right in git checkout-index --temp. 100 | 101 | gt := newGitTest(t) 102 | defer gt.done() 103 | 104 | gt.work(t) 105 | 106 | mkdir(t, gt.client+"/dir1") 107 | mkdir(t, gt.client+"/longnamedir2") 108 | write(t, gt.client+"/dir1/bad1.go", badGo, 0644) 109 | write(t, gt.client+"/longnamedir2/bad2.go", badGo, 0644) 110 | trun(t, gt.client, "git", "add", ".") // put files in index 111 | write(t, gt.client+"/dir1/bad1.go", goodGo, 0644) 112 | write(t, gt.client+"/longnamedir2/bad2.go", goodGo, 0644) 113 | 114 | chdir(t, gt.client) 115 | testMain(t, "gofmt", "-l") 116 | testPrintedStdout(t, fromSlash("dir1/bad1.go (staged)"), fromSlash("longnamedir2/bad2.go (staged)")) 117 | 118 | chdir(t, gt.client+"/dir1") 119 | testMain(t, "gofmt", "-l") 120 | testPrintedStdout(t, "bad1.go (staged)", fromSlash("!/bad1.go"), fromSlash("longnamedir2/bad2.go (staged)")) 121 | 122 | chdir(t, gt.client+"/longnamedir2") 123 | testMain(t, "gofmt", "-l") 124 | testPrintedStdout(t, "bad2.go (staged)", fromSlash("!/bad2.go"), fromSlash("dir1/bad1.go (staged)")) 125 | 126 | mkdir(t, gt.client+"/z") 127 | chdir(t, gt.client+"/z") 128 | testMain(t, "gofmt", "-l") 129 | testPrintedStdout(t, fromSlash("longnamedir2/bad2.go (staged)"), fromSlash("dir1/bad1.go (staged)")) 130 | } 131 | 132 | func TestGofmtUnstaged(t *testing.T) { 133 | // Test when unstaged files are different from staged ones. 134 | // See TestHookPreCommitUnstaged for an explanation. 135 | // In this test we use two different kinds of bad files, so that 136 | // we can test having a bad file in the index and a different 137 | // bad file in the working directory. 138 | 139 | gt := newGitTest(t) 140 | defer gt.done() 141 | gt.work(t) 142 | 143 | name := []string{"good", "bad", "bad2", "broken"} 144 | orig := []string{goodGo, badGo, bad2Go, brokenGo} 145 | fixed := []string{goodGo, badGoFixed, bad2GoFixed, brokenGo} 146 | const N = 4 147 | 148 | var allFiles, wantOut, wantErr []string 149 | writeFiles := func(n int) { 150 | allFiles = nil 151 | wantOut = nil 152 | wantErr = nil 153 | for i := 0; i < N*N*N; i++ { 154 | // determine n'th digit of 3-digit base-N value i 155 | j := i 156 | for k := 0; k < (3 - 1 - n); k++ { 157 | j /= N 158 | } 159 | text := orig[j%N] 160 | file := fmt.Sprintf("%s-%s-%s.go", name[i/N/N], name[(i/N)%N], name[i%N]) 161 | allFiles = append(allFiles, file) 162 | write(t, gt.client+"/"+file, text, 0644) 163 | 164 | if (i/N)%N != i%N { 165 | staged := file + " (staged)" 166 | switch { 167 | case strings.Contains(file, "-bad-"), strings.Contains(file, "-bad2-"): 168 | wantOut = append(wantOut, staged) 169 | wantErr = append(wantErr, "!"+staged) 170 | case strings.Contains(file, "-broken-"): 171 | wantOut = append(wantOut, "!"+staged) 172 | wantErr = append(wantErr, staged) 173 | default: 174 | wantOut = append(wantOut, "!"+staged) 175 | wantErr = append(wantErr, "!"+staged) 176 | } 177 | } 178 | switch { 179 | case strings.Contains(file, "-bad.go"), strings.Contains(file, "-bad2.go"): 180 | if (i/N)%N != i%N { 181 | file += " (unstaged)" 182 | } 183 | wantOut = append(wantOut, file+"\n") 184 | wantErr = append(wantErr, "!"+file+":", "!"+file+" (unstaged)") 185 | case strings.Contains(file, "-broken.go"): 186 | wantOut = append(wantOut, "!"+file+"\n", "!"+file+" (unstaged)") 187 | wantErr = append(wantErr, file+":") 188 | default: 189 | wantOut = append(wantOut, "!"+file+"\n", "!"+file+":", "!"+file+" (unstaged)") 190 | wantErr = append(wantErr, "!"+file+"\n", "!"+file+":", "!"+file+" (unstaged)") 191 | } 192 | } 193 | } 194 | 195 | // committed files 196 | writeFiles(0) 197 | trun(t, gt.client, "git", "add", ".") 198 | trun(t, gt.client, "git", "commit", "-m", "msg") 199 | 200 | // staged files 201 | writeFiles(1) 202 | trun(t, gt.client, "git", "add", ".") 203 | 204 | // unstaged files 205 | writeFiles(2) 206 | 207 | // Check that gofmt -l shows the right output and errors. 208 | testMainDied(t, "gofmt", "-l") 209 | testPrintedStdout(t, wantOut...) 210 | testPrintedStderr(t, wantErr...) 211 | 212 | // Again (last command should not have written anything). 213 | testMainDied(t, "gofmt", "-l") 214 | testPrintedStdout(t, wantOut...) 215 | testPrintedStderr(t, wantErr...) 216 | 217 | // Reformat in place. 218 | testMainDied(t, "gofmt") 219 | testNoStdout(t) 220 | testPrintedStderr(t, wantErr...) 221 | 222 | // Read files to make sure unstaged did not bleed into staged. 223 | for i, file := range allFiles { 224 | if data, err := os.ReadFile(gt.client + "/" + file); err != nil { 225 | t.Errorf("%v", err) 226 | } else if want := fixed[i%N]; string(data) != want { 227 | t.Errorf("%s: working tree = %q, want %q", file, string(data), want) 228 | } 229 | if data, want := trun(t, gt.client, "git", "show", ":"+file), fixed[i/N%N]; data != want { 230 | t.Errorf("%s: index = %q, want %q", file, data, want) 231 | } 232 | if data, want := trun(t, gt.client, "git", "show", "HEAD:"+file), orig[i/N/N]; data != want { 233 | t.Errorf("%s: commit = %q, want %q", file, data, want) 234 | } 235 | } 236 | 237 | // Check that gofmt -l still shows the errors. 238 | testMainDied(t, "gofmt", "-l") 239 | testNoStdout(t) 240 | testPrintedStderr(t, wantErr...) 241 | } 242 | 243 | func TestGofmtAmbiguousRevision(t *testing.T) { 244 | gt := newGitTest(t) 245 | defer gt.done() 246 | 247 | t.Logf("creating file that conflicts with revision parameter") 248 | write(t, gt.client+"/HEAD", "foo", 0644) 249 | 250 | testMain(t, "gofmt") 251 | } 252 | 253 | func TestGofmtFastForwardMerge(t *testing.T) { 254 | gt := newGitTest(t) 255 | defer gt.done() 256 | 257 | // merge dev.branch into main 258 | write(t, gt.server+"/file", "more work", 0644) 259 | trun(t, gt.server, "git", "commit", "-m", "work", "file") 260 | trun(t, gt.server, "git", "merge", "-m", "merge", "dev.branch") 261 | 262 | // add bad go file on main 263 | write(t, gt.server+"/bad.go", "package {\n", 0644) 264 | trun(t, gt.server, "git", "add", "bad.go") 265 | trun(t, gt.server, "git", "commit", "-m", "bad go") 266 | 267 | // update client 268 | trun(t, gt.client, "git", "checkout", "main") 269 | trun(t, gt.client, "git", "pull") 270 | testMain(t, "change", "dev.branch") 271 | trun(t, gt.client, "git", "pull") 272 | 273 | // merge main into dev.branch, fast forward merge 274 | trun(t, gt.client, "git", "merge", "--ff-only", "main") 275 | 276 | // verify that now client is in a state where just the tag is changing; there's no new commit. 277 | mainHash := strings.TrimSpace(trun(t, gt.server, "git", "rev-parse", "main")) 278 | devHash := strings.TrimSpace(trun(t, gt.client, "git", "rev-parse", "HEAD")) 279 | 280 | if mainHash != devHash { 281 | t.Logf("branches:\n%s", trun(t, gt.client, "git", "branch", "-a", "-v")) 282 | t.Logf("log:\n%s", trun(t, gt.client, "git", "log", "--graph", "--decorate")) 283 | t.Fatalf("setup wrong - got different commit hashes on main and dev branch") 284 | } 285 | 286 | // check that gofmt finds nothing to do, ignoring the bad (but committed) file1.go. 287 | testMain(t, "gofmt") 288 | testNoStdout(t) 289 | testNoStderr(t) 290 | } 291 | -------------------------------------------------------------------------------- /git-codereview/review.go: -------------------------------------------------------------------------------- 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 | // TODO(rsc): Document multi-change branch behavior. 6 | 7 | // Command git-codereview provides a simple command-line user interface for 8 | // working with git repositories and the Gerrit code review system. 9 | // See "git-codereview help" for details. 10 | package main // import "golang.org/x/review/git-codereview" 11 | 12 | import ( 13 | "bytes" 14 | "flag" 15 | "fmt" 16 | "io" 17 | "os" 18 | "os/exec" 19 | "strconv" 20 | "strings" 21 | "time" 22 | ) 23 | 24 | var ( 25 | flags *flag.FlagSet 26 | verbose = new(count) // installed as -v below 27 | noRun = new(bool) 28 | ) 29 | 30 | const progName = "git-codereview" 31 | 32 | // makeChange returns inverse of noRun for readability. 33 | func makeChange() bool { 34 | return !*noRun 35 | } 36 | 37 | func initFlags() { 38 | flags = flag.NewFlagSet("", flag.ExitOnError) 39 | flags.Usage = func() { 40 | fmt.Fprintf(stderr(), usage, progName, progName) 41 | exit(2) 42 | } 43 | flags.SetOutput(stderr()) 44 | flags.BoolVar(noRun, "n", false, "print but do not run commands") 45 | flags.Var(verbose, "v", "report commands") 46 | } 47 | 48 | const globalFlags = "[-n] [-v]" 49 | 50 | const usage = `Usage: %s ` + globalFlags + ` 51 | 52 | Use "%s help" for a list of commands. 53 | ` 54 | 55 | const help = `Usage: %s ` + globalFlags + ` 56 | 57 | Git-codereview is a git helper command for managing pending commits 58 | against an upstream server, typically a Gerrit server. 59 | 60 | The -n flag prints commands that would make changes but does not run them. 61 | The -v flag prints those commands as they run. 62 | 63 | Available commands: 64 | 65 | branchpoint 66 | change [name] 67 | change NNNN[/PP] 68 | gofmt [-l] 69 | help 70 | hooks 71 | mail [-r reviewer,...] [-cc mail,...] [options] [commit] 72 | pending [-c] [-g] [-l] [-s] 73 | rebase-work 74 | reword [commit...] 75 | submit [-i | commit...] 76 | sync 77 | sync-branch [-continue] 78 | 79 | See https://pkg.go.dev/golang.org/x/review/git-codereview 80 | for the full details of each command. 81 | ` 82 | 83 | func main() { 84 | initFlags() 85 | 86 | if len(os.Args) < 2 { 87 | flags.Usage() 88 | exit(2) 89 | } 90 | command, args := os.Args[1], os.Args[2:] 91 | 92 | // NOTE: Keep this switch in sync with the list of commands above. 93 | var cmd func([]string) 94 | switch command { 95 | default: 96 | flags.Usage() 97 | exit(2) // avoid installing hooks. 98 | case "help": 99 | fmt.Fprintf(stdout(), help, progName) 100 | return // avoid installing hooks. 101 | case "hooks": // in case hooks weren't installed. 102 | installHook(args, false) 103 | return // avoid invoking installHook twice. 104 | 105 | case "branchpoint": 106 | cmd = cmdBranchpoint 107 | case "change": 108 | cmd = cmdChange 109 | case "gofmt": 110 | cmd = cmdGofmt 111 | case "hook-invoke": 112 | cmd = cmdHookInvoke 113 | case "mail", "m": 114 | cmd = cmdMail 115 | case "pending": 116 | cmd = cmdPending 117 | case "rebase-work": 118 | cmd = cmdRebaseWork 119 | case "reword": 120 | cmd = cmdReword 121 | case "submit": 122 | cmd = cmdSubmit 123 | case "sync": 124 | cmd = cmdSync 125 | case "sync-branch": 126 | cmd = cmdSyncBranch 127 | case "test-loadAuth": // for testing only. 128 | cmd = func([]string) { loadAuth() } 129 | } 130 | 131 | // Install hooks automatically, but only if this is a Gerrit repo. 132 | if haveGerrit() { 133 | // Don't pass installHook args directly, 134 | // since args might contain args meant for other commands. 135 | // Filter down to just global flags. 136 | var hookArgs []string 137 | for _, arg := range args { 138 | switch arg { 139 | case "-n", "-v": 140 | hookArgs = append(hookArgs, arg) 141 | } 142 | } 143 | installHook(hookArgs, true) 144 | } 145 | 146 | cmd(args) 147 | } 148 | 149 | func expectZeroArgs(args []string, command string) { 150 | flags.Parse(args) 151 | if len(flags.Args()) > 0 { 152 | fmt.Fprintf(stderr(), "Usage: %s %s %s\n", progName, command, globalFlags) 153 | exit(2) 154 | } 155 | } 156 | 157 | func setEnglishLocale(cmd *exec.Cmd) { 158 | // Override the existing locale to prevent non-English locales from 159 | // interfering with string parsing. See golang.org/issue/33895. 160 | if cmd.Env == nil { 161 | cmd.Env = os.Environ() 162 | } 163 | cmd.Env = append(cmd.Env, "LC_ALL=C") 164 | } 165 | 166 | func run(command string, args ...string) { 167 | if err := runErr(command, args...); err != nil { 168 | if *verbose == 0 { 169 | // If we're not in verbose mode, print the command 170 | // before dying to give context to the failure. 171 | fmt.Fprintf(stderr(), "(running: %s)\n", commandString(command, args)) 172 | } 173 | dief("%v", err) 174 | } 175 | } 176 | 177 | func runErr(command string, args ...string) error { 178 | return runDirErr(".", command, args...) 179 | } 180 | 181 | var runLogTrap []string 182 | 183 | func runDirErr(dir, command string, args ...string) error { 184 | if *noRun || *verbose == 1 { 185 | fmt.Fprintln(stderr(), commandString(command, args)) 186 | } else if *verbose > 1 { 187 | start := time.Now() 188 | defer func() { 189 | fmt.Fprintf(stderr(), "%s # %.3fs\n", commandString(command, args), time.Since(start).Seconds()) 190 | }() 191 | } 192 | if *noRun { 193 | return nil 194 | } 195 | if runLogTrap != nil { 196 | runLogTrap = append(runLogTrap, strings.TrimSpace(command+" "+strings.Join(args, " "))) 197 | } 198 | cmd := exec.Command(command, args...) 199 | cmd.Stdin = os.Stdin 200 | cmd.Stdout = stdout() 201 | cmd.Stderr = stderr() 202 | if dir != "." { 203 | cmd.Dir = dir 204 | } 205 | setEnglishLocale(cmd) 206 | return cmd.Run() 207 | } 208 | 209 | // cmdOutput runs the command line, returning its output. 210 | // If the command cannot be run or does not exit successfully, 211 | // cmdOutput dies. 212 | // 213 | // NOTE: cmdOutput must be used only to run commands that read state, 214 | // not for commands that make changes. Commands that make changes 215 | // should be run using runDirErr so that the -v and -n flags apply to them. 216 | func cmdOutput(command string, args ...string) string { 217 | return cmdOutputDir(".", command, args...) 218 | } 219 | 220 | // cmdOutputDir runs the command line in dir, returning its output. 221 | // If the command cannot be run or does not exit successfully, 222 | // cmdOutput dies. 223 | // 224 | // NOTE: cmdOutput must be used only to run commands that read state, 225 | // not for commands that make changes. Commands that make changes 226 | // should be run using runDirErr so that the -v and -n flags apply to them. 227 | func cmdOutputDir(dir, command string, args ...string) string { 228 | s, err := cmdOutputDirErr(dir, command, args...) 229 | if err != nil { 230 | fmt.Fprintf(stderr(), "%v\n%s\n", commandString(command, args), s) 231 | dief("%v", err) 232 | } 233 | return s 234 | } 235 | 236 | // cmdOutputErr runs the command line in dir, returning its output 237 | // and any error results. 238 | // 239 | // NOTE: cmdOutputErr must be used only to run commands that read state, 240 | // not for commands that make changes. Commands that make changes 241 | // should be run using runDirErr so that the -v and -n flags apply to them. 242 | func cmdOutputErr(command string, args ...string) (string, error) { 243 | return cmdOutputDirErr(".", command, args...) 244 | } 245 | 246 | // cmdOutputDirErr runs the command line in dir, returning its output 247 | // and any error results. 248 | // 249 | // NOTE: cmdOutputDirErr must be used only to run commands that read state, 250 | // not for commands that make changes. Commands that make changes 251 | // should be run using runDirErr so that the -v and -n flags apply to them. 252 | func cmdOutputDirErr(dir, command string, args ...string) (string, error) { 253 | // NOTE: We only show these non-state-modifying commands with -v -v. 254 | // Otherwise things like 'git codereview sync -v' show all our internal "find out about 255 | // the git repo" commands, which is confusing if you are just trying to find 256 | // out what git codereview sync means. 257 | if *verbose > 1 { 258 | start := time.Now() 259 | defer func() { 260 | fmt.Fprintf(stderr(), "%s # %.3fs\n", commandString(command, args), time.Since(start).Seconds()) 261 | }() 262 | } 263 | cmd := exec.Command(command, args...) 264 | if dir != "." { 265 | cmd.Dir = dir 266 | } 267 | setEnglishLocale(cmd) 268 | b, err := cmd.CombinedOutput() 269 | return string(b), err 270 | } 271 | 272 | // trim is shorthand for strings.TrimSpace. 273 | func trim(text string) string { 274 | return strings.TrimSpace(text) 275 | } 276 | 277 | // trimErr applies strings.TrimSpace to the result of cmdOutput(Dir)Err, 278 | // passing the error along unmodified. 279 | func trimErr(text string, err error) (string, error) { 280 | return strings.TrimSpace(text), err 281 | } 282 | 283 | // lines returns the lines in text. 284 | func lines(text string) []string { 285 | out := strings.Split(text, "\n") 286 | // Split will include a "" after the last line. Remove it. 287 | if n := len(out) - 1; n >= 0 && out[n] == "" { 288 | out = out[:n] 289 | } 290 | return out 291 | } 292 | 293 | // nonBlankLines returns the non-blank lines in text. 294 | func nonBlankLines(text string) []string { 295 | var out []string 296 | for _, s := range lines(text) { 297 | if strings.TrimSpace(s) != "" { 298 | out = append(out, s) 299 | } 300 | } 301 | return out 302 | } 303 | 304 | func commandString(command string, args []string) string { 305 | return strings.Join(append([]string{command}, args...), " ") 306 | } 307 | 308 | func dief(format string, args ...interface{}) { 309 | printf(format, args...) 310 | exit(1) 311 | } 312 | 313 | var exitTrap func() 314 | 315 | func exit(code int) { 316 | if exitTrap != nil { 317 | exitTrap() 318 | } 319 | os.Exit(code) 320 | } 321 | 322 | func verbosef(format string, args ...interface{}) { 323 | if *verbose > 0 { 324 | printf(format, args...) 325 | } 326 | } 327 | 328 | var stdoutTrap, stderrTrap *bytes.Buffer 329 | 330 | func stdout() io.Writer { 331 | if stdoutTrap != nil { 332 | return stdoutTrap 333 | } 334 | return os.Stdout 335 | } 336 | 337 | func stderr() io.Writer { 338 | if stderrTrap != nil { 339 | return stderrTrap 340 | } 341 | return os.Stderr 342 | } 343 | 344 | func printf(format string, args ...interface{}) { 345 | fmt.Fprintf(stderr(), "%s: %s\n", progName, fmt.Sprintf(format, args...)) 346 | } 347 | 348 | // count is a flag.Value that is like a flag.Bool and a flag.Int. 349 | // If used as -name, it increments the count, but -name=x sets the count. 350 | // Used for verbose flag -v. 351 | type count int 352 | 353 | func (c *count) String() string { 354 | return fmt.Sprint(int(*c)) 355 | } 356 | 357 | func (c *count) Set(s string) error { 358 | switch s { 359 | case "true": 360 | *c++ 361 | case "false": 362 | *c = 0 363 | default: 364 | n, err := strconv.Atoi(s) 365 | if err != nil { 366 | return fmt.Errorf("invalid count %q", s) 367 | } 368 | *c = count(n) 369 | } 370 | return nil 371 | } 372 | 373 | func (c *count) IsBoolFlag() bool { 374 | return true 375 | } 376 | -------------------------------------------------------------------------------- /git-codereview/mail.go: -------------------------------------------------------------------------------- 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 | "fmt" 9 | "os" 10 | "regexp" 11 | "sort" 12 | "strings" 13 | "unicode" 14 | "unicode/utf8" 15 | ) 16 | 17 | func cmdMail(args []string) { 18 | // NOTE: New flags should be added to the usage message below as well as doc.go. 19 | var ( 20 | rList = new(stringList) // installed below 21 | ccList = new(stringList) // installed below 22 | 23 | diff = flags.Bool("diff", false, "show change commit diff and don't upload or mail") 24 | force = flags.Bool("f", false, "mail even if there are staged changes") 25 | hashtagList = new(stringList) // installed below 26 | noKeyCheck = flags.Bool("nokeycheck", false, "set 'git push -o nokeycheck', to prevent Gerrit from checking for private keys") 27 | topic = flags.String("topic", "", "set Gerrit topic") 28 | trybot = flags.Bool("trybot", false, "run trybots on the uploaded CLs") 29 | wip = flags.Bool("wip", false, "set the status of a change to Work-in-Progress") 30 | noverify = flags.Bool("no-verify", false, "disable presubmits") 31 | autoSubmit = flags.Bool("autosubmit", false, "set autosubmit on the uploaded CLs") 32 | ) 33 | flags.Var(rList, "r", "comma-separated list of reviewers") 34 | flags.Var(ccList, "cc", "comma-separated list of people to CC:") 35 | flags.Var(hashtagList, "hashtag", "comma-separated list of tags to set") 36 | 37 | flags.Usage = func() { 38 | fmt.Fprintf(stderr(), 39 | "Usage: %s mail %s [-r reviewer,...] [-cc mail,...]\n"+ 40 | "\t[-autosubmit] [-f] [-diff] [-hashtag tag,...]\n"+ 41 | "\t[-nokeycheck] [-topic topic] [-trybot] [-wip]\n"+ 42 | "\t[commit]\n", progName, globalFlags) 43 | exit(2) 44 | } 45 | flags.Parse(args) 46 | if len(flags.Args()) > 1 { 47 | flags.Usage() 48 | exit(2) 49 | } 50 | 51 | var trybotVotes []string 52 | switch os.Getenv("GIT_CODEREVIEW_TRYBOT") { 53 | case "", "luci": 54 | trybotVotes = []string{"Commit-Queue+1"} 55 | case "farmer": 56 | trybotVotes = []string{"Run-TryBot"} 57 | case "both": 58 | trybotVotes = []string{"Commit-Queue+1", "Run-TryBot"} 59 | default: 60 | fmt.Fprintf(stderr(), "GIT_CODEREVIEW_TRYBOT must be unset, blank, or one of 'luci', 'farmer', or 'both'\n") 61 | exit(2) 62 | } 63 | 64 | b := CurrentBranch() 65 | 66 | var c *Commit 67 | if len(flags.Args()) == 1 { 68 | c = b.CommitByRev("mail", flags.Arg(0)) 69 | } else { 70 | c = b.DefaultCommit("mail", "must specify commit on command line; use HEAD to mail all pending changes") 71 | } 72 | 73 | if *diff { 74 | run("git", "diff", b.Branchpoint()[:7]+".."+c.ShortHash, "--") 75 | return 76 | } 77 | 78 | if len(ListFiles(c)) == 0 && len(c.Parents) == 1 { 79 | dief("cannot mail: commit %s is empty", c.ShortHash) 80 | } 81 | 82 | foundCommit := false 83 | for _, c1 := range b.Pending() { 84 | if c1 == c { 85 | foundCommit = true 86 | } 87 | if !foundCommit { 88 | continue 89 | } 90 | if strings.Contains(strings.ToLower(c1.Message), "do not mail") { 91 | dief("%s: CL says DO NOT MAIL", c1.ShortHash) 92 | } 93 | if strings.HasPrefix(c1.Message, "fixup!") { 94 | dief("%s: CL is a fixup! commit", c1.ShortHash) 95 | } 96 | if strings.HasPrefix(c1.Message, "squash!") { 97 | dief("%s: CL is a squash! commit", c1.ShortHash) 98 | } 99 | 100 | for _, f := range ListFiles(c1) { 101 | if strings.HasPrefix(f, ".#") || strings.HasSuffix(f, "~") || 102 | (strings.HasPrefix(f, "#") && strings.HasSuffix(f, "#")) { 103 | dief("cannot mail temporary files: %s", f) 104 | } 105 | } 106 | } 107 | if !foundCommit { 108 | // b.CommitByRev and b.DefaultCommit both return a commit on b. 109 | dief("internal error: did not find chosen commit on current branch") 110 | } 111 | 112 | if !*force && HasStagedChanges() { 113 | dief("there are staged changes; aborting.\n"+ 114 | "Use '%s change' to include them or '%s mail -f' to force it.", progName, progName) 115 | } 116 | 117 | if !utf8.ValidString(c.Message) { 118 | dief("cannot mail message with invalid UTF-8") 119 | } 120 | for _, r := range c.Message { 121 | if !unicode.IsPrint(r) && !unicode.IsSpace(r) { 122 | dief("cannot mail message with non-printable rune %q", r) 123 | } 124 | } 125 | 126 | // for side effect of dying with a good message if origin is GitHub 127 | loadGerritOrigin() 128 | 129 | refSpec := b.PushSpec(c) 130 | start := "%" 131 | if *rList != "" { 132 | refSpec += mailList(start, "r", string(*rList)) 133 | start = "," 134 | } 135 | if *ccList != "" { 136 | refSpec += mailList(start, "cc", string(*ccList)) 137 | start = "," 138 | } 139 | if *hashtagList != "" { 140 | for _, tag := range strings.Split(string(*hashtagList), ",") { 141 | if tag == "" { 142 | dief("hashtag may not contain empty tags") 143 | } 144 | refSpec += start + "hashtag=" + tag 145 | start = "," 146 | } 147 | } 148 | if *topic != "" { 149 | // There's no way to escape the topic, but the only 150 | // ambiguous character is ',' (though other characters 151 | // like ' ' will be rejected outright by git). 152 | if strings.Contains(*topic, ",") { 153 | dief("topic may not contain a comma") 154 | } 155 | refSpec += start + "topic=" + *topic 156 | start = "," 157 | } 158 | if *trybot { 159 | for _, v := range trybotVotes { 160 | refSpec += start + "l=" + v 161 | start = "," 162 | } 163 | } 164 | if *wip { 165 | refSpec += start + "wip" 166 | start = "," 167 | } 168 | if *autoSubmit { 169 | refSpec += start + "l=Auto-Submit" 170 | } 171 | args = []string{"push", "-q"} 172 | if *noKeyCheck { 173 | args = append(args, "-o", "nokeycheck") 174 | } 175 | if *noverify { 176 | args = append(args, "--no-verify") 177 | } 178 | args = append(args, "origin", refSpec) 179 | run("git", args...) 180 | 181 | // Create local tag for mailed change. 182 | // If in the 'work' branch, this creates or updates work.mailed. 183 | // Older mailings are in the reflog, so work.mailed is newest, 184 | // work.mailed@{1} is the one before that, work.mailed@{2} before that, 185 | // and so on. 186 | // Git doesn't actually have a concept of a local tag, 187 | // but Gerrit won't let people push tags to it, so the tag 188 | // can't propagate out of the local client into the official repo. 189 | // There is no conflict with the branch names people are using 190 | // for work, because git change rejects any name containing a dot. 191 | // The space of names with dots is ours (the Go team's) to define. 192 | run("git", "tag", "--no-sign", "-f", b.Name+".mailed", c.ShortHash) 193 | } 194 | 195 | // PushSpec returns the spec for a Gerrit push command to publish the change c in b. 196 | // If c is nil, PushSpec returns a spec for pushing all changes in b. 197 | func (b *Branch) PushSpec(c *Commit) string { 198 | local := "HEAD" 199 | if c != nil && (len(b.Pending()) == 0 || b.Pending()[0].Hash != c.Hash) { 200 | local = c.ShortHash 201 | } 202 | return local + ":refs/for/" + strings.TrimPrefix(b.OriginBranch(), "origin/") 203 | } 204 | 205 | // mailAddressRE matches the mail addresses we admit. It's restrictive but admits 206 | // all the addresses in the Go CONTRIBUTORS file at time of writing (tested separately). 207 | var mailAddressRE = regexp.MustCompile(`^([a-zA-Z0-9][-_.a-zA-Z0-9]*)(@[-_.a-zA-Z0-9]+)?$`) 208 | 209 | // mailList turns the list of mail addresses from the flag value into the format 210 | // expected by gerrit. The start argument is a % or , depending on where we 211 | // are in the processing sequence. 212 | func mailList(start, tag string, flagList string) string { 213 | errors := false 214 | spec := start 215 | short := "" 216 | long := "" 217 | for i, addr := range strings.Split(flagList, ",") { 218 | m := mailAddressRE.FindStringSubmatch(addr) 219 | if m == nil { 220 | printf("invalid reviewer mail address: %s", addr) 221 | errors = true 222 | continue 223 | } 224 | if m[2] == "" { 225 | email := mailLookup(addr) 226 | if email == "" { 227 | printf("unknown reviewer: %s", addr) 228 | errors = true 229 | continue 230 | } 231 | short += "," + addr 232 | long += "," + email 233 | addr = email 234 | } 235 | if i > 0 { 236 | spec += "," 237 | } 238 | spec += tag + "=" + addr 239 | } 240 | if short != "" { 241 | verbosef("expanded %s to %s", short[1:], long[1:]) 242 | } 243 | if errors { 244 | exit(1) 245 | } 246 | return spec 247 | } 248 | 249 | // reviewers is the list of reviewers for the current repository, 250 | // sorted by how many reviews each has done. 251 | var reviewers []reviewer 252 | 253 | type reviewer struct { 254 | addr string 255 | count int 256 | } 257 | 258 | // mailLookup translates the short name (like adg) into a full 259 | // email address (like adg@golang.org). 260 | // It returns "" if no translation is found. 261 | // The algorithm for expanding short user names is as follows: 262 | // Look at the git commit log for the current repository, 263 | // extracting all the email addresses in Reviewed-By lines 264 | // and sorting by how many times each address appears. 265 | // For each short user name, walk the list, most common 266 | // address first, and use the first address found that has 267 | // the short user name on the left side of the @. 268 | func mailLookup(short string) string { 269 | loadReviewers() 270 | 271 | short += "@" 272 | for _, r := range reviewers { 273 | if strings.HasPrefix(r.addr, short) && !shortOptOut[r.addr] { 274 | return r.addr 275 | } 276 | } 277 | return "" 278 | } 279 | 280 | // shortOptOut lists email addresses whose owners have opted out 281 | // from consideration for purposes of expanding short user names. 282 | var shortOptOut = map[string]bool{ 283 | "dmitshur@google.com": true, // My @golang.org is primary; @google.com is used for +1 only. 284 | "matloob@google.com": true, // My @golang.org is primary; @google.com is used for +1 only. 285 | } 286 | 287 | // loadReviewers reads the reviewer list from the current git repo 288 | // and leaves it in the global variable reviewers. 289 | // See the comment on mailLookup for a description of how the 290 | // list is generated and used. 291 | func loadReviewers() { 292 | if reviewers != nil { 293 | return 294 | } 295 | countByAddr := map[string]int{} 296 | for _, line := range nonBlankLines(cmdOutput("git", "log", "--format=format:%B", "-n", "1000")) { 297 | if strings.HasPrefix(line, "Reviewed-by:") { 298 | f := strings.Fields(line) 299 | addr := f[len(f)-1] 300 | if strings.HasPrefix(addr, "<") && strings.Contains(addr, "@") && strings.HasSuffix(addr, ">") { 301 | countByAddr[addr[1:len(addr)-1]]++ 302 | } 303 | } 304 | } 305 | 306 | reviewers = []reviewer{} 307 | for addr, count := range countByAddr { 308 | reviewers = append(reviewers, reviewer{addr, count}) 309 | } 310 | sort.Sort(reviewersByCount(reviewers)) 311 | } 312 | 313 | type reviewersByCount []reviewer 314 | 315 | func (x reviewersByCount) Len() int { return len(x) } 316 | func (x reviewersByCount) Swap(i, j int) { x[i], x[j] = x[j], x[i] } 317 | func (x reviewersByCount) Less(i, j int) bool { 318 | if x[i].count != x[j].count { 319 | return x[i].count > x[j].count 320 | } 321 | return x[i].addr < x[j].addr 322 | } 323 | 324 | // stringList is a flag.Value that is like flag.String, but if repeated 325 | // keeps appending to the old value, inserting commas as separators. 326 | // This allows people to write -r rsc,adg (like the old hg command) 327 | // but also -r rsc -r adg (like standard git commands). 328 | // This does change the meaning of -r rsc -r adg (it used to mean just adg). 329 | type stringList string 330 | 331 | func (x *stringList) String() string { 332 | return string(*x) 333 | } 334 | 335 | func (x *stringList) Set(s string) error { 336 | if *x != "" && s != "" { 337 | *x += "," 338 | } 339 | *x += stringList(s) 340 | return nil 341 | } 342 | -------------------------------------------------------------------------------- /git-codereview/pending_test.go: -------------------------------------------------------------------------------- 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 | "fmt" 9 | "os" 10 | "os/exec" 11 | "regexp" 12 | "strings" 13 | "testing" 14 | ) 15 | 16 | func TestPendingNone(t *testing.T) { 17 | gt := newGitTest(t) 18 | defer gt.done() 19 | 20 | testPending(t, ` 21 | main (current branch) 22 | 23 | `) 24 | } 25 | 26 | func TestPendingNoneBranch(t *testing.T) { 27 | gt := newGitTest(t) 28 | defer gt.done() 29 | 30 | trun(t, gt.client, "git", "checkout", "--no-track", "-b", "work") 31 | 32 | testPending(t, ` 33 | work (current branch) 34 | 35 | `) 36 | } 37 | 38 | func TestPendingBasic(t *testing.T) { 39 | gt := newGitTest(t) 40 | defer gt.done() 41 | gt.work(t) 42 | 43 | testPending(t, ` 44 | work REVHASH..REVHASH (current branch) 45 | + REVHASH 46 | msg 47 | 48 | Change-Id: I123456789 49 | 50 | Files in this change: 51 | file 52 | 53 | `) 54 | } 55 | 56 | func TestPendingComplex(t *testing.T) { 57 | gt := newGitTest(t) 58 | defer gt.done() 59 | gt.work(t) 60 | 61 | write(t, gt.server+"/file", "v2", 0644) 62 | trun(t, gt.server, "git", "commit", "-a", "-m", "v2") 63 | 64 | write(t, gt.server+"/file", "v3", 0644) 65 | trun(t, gt.server, "git", "commit", "-a", "-m", "v3") 66 | 67 | trun(t, gt.client, "git", "fetch") 68 | trun(t, gt.client, "git", "checkout", "-b", "work3ignored", "-t", "origin/main") 69 | 70 | write(t, gt.server+"/file", "v4", 0644) 71 | trun(t, gt.server, "git", "commit", "-a", "-m", "v4") 72 | 73 | trun(t, gt.client, "git", "fetch") 74 | trun(t, gt.client, "git", "checkout", "-b", "work2", "-t", "origin/main") 75 | write(t, gt.client+"/file", "modify", 0644) 76 | write(t, gt.client+"/file1", "new", 0644) 77 | trun(t, gt.client, "git", "add", "file", "file1") 78 | trun(t, gt.client, "git", "commit", "-m", "some changes") 79 | write(t, gt.client+"/file1", "modify", 0644) 80 | write(t, gt.client+"/afile1", "new", 0644) 81 | trun(t, gt.client, "git", "add", "file1", "afile1") 82 | write(t, gt.client+"/file1", "modify again", 0644) 83 | write(t, gt.client+"/file", "modify again", 0644) 84 | write(t, gt.client+"/bfile", "untracked", 0644) 85 | 86 | testPending(t, ` 87 | work2 REVHASH..REVHASH (current branch) 88 | + uncommitted changes 89 | Files untracked: 90 | bfile 91 | Files unstaged: 92 | file 93 | file1 94 | Files staged: 95 | afile1 96 | file1 97 | 98 | + REVHASH 99 | some changes 100 | 101 | Files in this change: 102 | file 103 | file1 104 | 105 | work REVHASH..REVHASH (3 behind) 106 | + REVHASH 107 | msg 108 | 109 | Change-Id: I123456789 110 | 111 | Files in this change: 112 | file 113 | 114 | `) 115 | 116 | testPendingArgs(t, []string{"-c"}, ` 117 | work2 REVHASH..REVHASH (current branch) 118 | + uncommitted changes 119 | Files untracked: 120 | bfile 121 | Files unstaged: 122 | file 123 | file1 124 | Files staged: 125 | afile1 126 | file1 127 | 128 | + REVHASH 129 | some changes 130 | 131 | Files in this change: 132 | file 133 | file1 134 | 135 | `) 136 | 137 | testPendingArgs(t, []string{"-c", "-s"}, ` 138 | work2 REVHASH..REVHASH (current branch) 139 | + uncommitted changes 140 | Files untracked: 141 | bfile 142 | Files unstaged: 143 | file 144 | file1 145 | Files staged: 146 | afile1 147 | file1 148 | + REVHASH some changes 149 | 150 | `) 151 | 152 | testPendingArgs(t, []string{"-g"}, ` 153 | - branch work updated 0001-01-01 00:00:00 +0000 UTC 154 | - branch work2 updated 0001-01-01 00:00:00 +0000 UTC 155 | `) 156 | } 157 | 158 | func TestPendingMultiChange(t *testing.T) { 159 | gt := newGitTest(t) 160 | defer gt.done() 161 | 162 | gt.work(t) 163 | write(t, gt.client+"/file", "v2", 0644) 164 | trun(t, gt.client, "git", "commit", "-a", "-m", "v2") 165 | 166 | write(t, gt.client+"/file", "v4", 0644) 167 | trun(t, gt.client, "git", "add", "file") 168 | 169 | write(t, gt.client+"/file", "v5", 0644) 170 | write(t, gt.client+"/file2", "v6", 0644) 171 | 172 | testPending(t, ` 173 | work REVHASH..REVHASH (current branch) 174 | + uncommitted changes 175 | Files untracked: 176 | file2 177 | Files unstaged: 178 | file 179 | Files staged: 180 | file 181 | 182 | + REVHASH 183 | v2 184 | 185 | Files in this change: 186 | file 187 | 188 | + REVHASH 189 | msg 190 | 191 | Change-Id: I123456789 192 | 193 | Files in this change: 194 | file 195 | 196 | `) 197 | 198 | testPendingArgs(t, []string{"-s"}, ` 199 | work REVHASH..REVHASH (current branch) 200 | + uncommitted changes 201 | Files untracked: 202 | file2 203 | Files unstaged: 204 | file 205 | Files staged: 206 | file 207 | + REVHASH v2 208 | + REVHASH msg 209 | 210 | `) 211 | 212 | testPendingArgs(t, []string{"-g"}, ` 213 | - branch work updated 0001-01-01 00:00:00 +0000 UTC 214 | `) 215 | } 216 | 217 | func TestPendingGerrit(t *testing.T) { 218 | gt := newGitTest(t) 219 | defer gt.done() 220 | gt.work(t) 221 | 222 | srv := newGerritServer(t) 223 | defer srv.done() 224 | 225 | // Test error from Gerrit server. 226 | testPending(t, ` 227 | work REVHASH..REVHASH (current branch) 228 | + REVHASH 229 | msg 230 | 231 | Change-Id: I123456789 232 | 233 | Files in this change: 234 | file 235 | 236 | `) 237 | 238 | testPendingReply(srv, "I123456789", CurrentBranch().Pending()[0].Hash, "MERGED", 0) 239 | 240 | // Test local mode does not talk to any server. 241 | // Make client 1 behind server. 242 | // The '1 behind' should not show up, nor any Gerrit information. 243 | write(t, gt.server+"/file", "v4", 0644) 244 | trun(t, gt.server, "git", "add", "file") 245 | trun(t, gt.server, "git", "commit", "-m", "msg") 246 | testPendingArgs(t, []string{"-l"}, ` 247 | work REVHASH..REVHASH (current branch) 248 | + REVHASH 249 | msg 250 | 251 | Change-Id: I123456789 252 | 253 | Files in this change: 254 | file 255 | 256 | `) 257 | 258 | testPendingArgs(t, []string{"-l", "-s"}, ` 259 | work REVHASH..REVHASH (current branch) 260 | + REVHASH msg 261 | 262 | `) 263 | 264 | // Without -l, the 1 behind should appear, as should Gerrit information. 265 | testPending(t, ` 266 | work REVHASH..REVHASH (current branch, all mailed, all submitted, 1 behind) 267 | + REVHASH http://127.0.0.1:PORT/1234 (mailed, submitted) 268 | msg 269 | 270 | Change-Id: I123456789 271 | 272 | Code-Review: 273 | +1 Grace Emlin 274 | -2 George Opher 275 | Other-Label: 276 | +2 The Owner 277 | Files in this change: 278 | file 279 | 280 | `) 281 | 282 | testPendingArgs(t, []string{"-s"}, ` 283 | work REVHASH..REVHASH (current branch, all mailed, all submitted, 1 behind) 284 | + REVHASH msg (CL 1234 -2 +1, mailed, submitted) 285 | 286 | `) 287 | 288 | // Since pending did a fetch, 1 behind should show up even with -l. 289 | testPendingArgs(t, []string{"-l"}, ` 290 | work REVHASH..REVHASH (current branch, 1 behind) 291 | + REVHASH 292 | msg 293 | 294 | Change-Id: I123456789 295 | 296 | Files in this change: 297 | file 298 | 299 | `) 300 | 301 | testPendingArgs(t, []string{"-l", "-s"}, ` 302 | work REVHASH..REVHASH (current branch, 1 behind) 303 | + REVHASH msg 304 | 305 | `) 306 | 307 | testPendingArgs(t, []string{"-g"}, ` 308 | - branch work submitted 309 | `) 310 | } 311 | 312 | func TestPendingGerritMultiChange(t *testing.T) { 313 | gt := newGitTest(t) 314 | defer gt.done() 315 | 316 | gt.work(t) 317 | hash1 := CurrentBranch().Pending()[0].Hash 318 | 319 | write(t, gt.client+"/file", "v2", 0644) 320 | trun(t, gt.client, "git", "commit", "-a", "-m", "v2\n\nChange-Id: I2345") 321 | hash2 := CurrentBranch().Pending()[0].Hash 322 | 323 | write(t, gt.client+"/file", "v4", 0644) 324 | trun(t, gt.client, "git", "add", "file") 325 | 326 | write(t, gt.client+"/file", "v5", 0644) 327 | write(t, gt.client+"/file2", "v6", 0644) 328 | 329 | srv := newGerritServer(t) 330 | defer srv.done() 331 | 332 | testPendingReply(srv, "I123456789", hash1, "MERGED", 0) 333 | testPendingReply(srv, "I2345", hash2, "NEW", 99) 334 | 335 | testPending(t, ` 336 | work REVHASH..REVHASH (current branch, all mailed) 337 | + uncommitted changes 338 | Files untracked: 339 | file2 340 | Files unstaged: 341 | file 342 | Files staged: 343 | file 344 | 345 | + REVHASH http://127.0.0.1:PORT/1234 (mailed, 99 unresolved comments) 346 | v2 347 | 348 | Change-Id: I2345 349 | 350 | Code-Review: 351 | +1 Grace Emlin 352 | -2 George Opher 353 | Other-Label: 354 | +2 The Owner 355 | Files in this change: 356 | file 357 | 358 | + REVHASH http://127.0.0.1:PORT/1234 (mailed, submitted) 359 | msg 360 | 361 | Change-Id: I123456789 362 | 363 | Code-Review: 364 | +1 Grace Emlin 365 | -2 George Opher 366 | Other-Label: 367 | +2 The Owner 368 | Files in this change: 369 | file 370 | 371 | `) 372 | 373 | testPendingArgs(t, []string{"-s"}, ` 374 | work REVHASH..REVHASH (current branch, all mailed) 375 | + uncommitted changes 376 | Files untracked: 377 | file2 378 | Files unstaged: 379 | file 380 | Files staged: 381 | file 382 | + REVHASH v2 (CL 1234 -2 +1, mailed, 99 unresolved comments) 383 | + REVHASH msg (CL 1234 -2 +1, mailed, submitted) 384 | 385 | `) 386 | 387 | testPendingArgs(t, []string{"-g"}, ` 388 | - branch work updated 2025-02-06 00:00:00 +0000 UTC 389 | https://go.dev/cl/1234 390 | https://go.dev/cl/1234 391 | `) 392 | } 393 | 394 | func TestPendingGerritMultiChange15(t *testing.T) { 395 | gt := newGitTest(t) 396 | defer gt.done() 397 | srv := newGerritServer(t) 398 | defer srv.done() 399 | 400 | gt.work(t) 401 | hash1 := CurrentBranch().Pending()[0].Hash 402 | testPendingReply(srv, "I123456789", hash1, "MERGED", 0) 403 | 404 | for i := 1; i < 15; i++ { 405 | write(t, gt.client+"/file", fmt.Sprintf("v%d", i), 0644) 406 | trun(t, gt.client, "git", "commit", "-a", "-m", fmt.Sprintf("v%d\n\nChange-Id: I%010d", i, i)) 407 | hash2 := CurrentBranch().Pending()[0].Hash 408 | testPendingReply(srv, fmt.Sprintf("I%010d", i), hash2, "NEW", 0) 409 | } 410 | 411 | testPendingArgs(t, []string{"-s"}, ` 412 | work REVHASH..REVHASH (current branch, all mailed) 413 | + REVHASH v14 (CL 1234 -2 +1, mailed) 414 | + REVHASH v13 (CL 1234 -2 +1, mailed) 415 | + REVHASH v12 (CL 1234 -2 +1, mailed) 416 | + REVHASH v11 (CL 1234 -2 +1, mailed) 417 | + REVHASH v10 (CL 1234 -2 +1, mailed) 418 | + REVHASH v9 (CL 1234 -2 +1, mailed) 419 | + REVHASH v8 (CL 1234 -2 +1, mailed) 420 | + REVHASH v7 (CL 1234 -2 +1, mailed) 421 | + REVHASH v6 (CL 1234 -2 +1, mailed) 422 | + REVHASH v5 (CL 1234 -2 +1, mailed) 423 | + REVHASH v4 (CL 1234 -2 +1, mailed) 424 | + REVHASH v3 (CL 1234 -2 +1, mailed) 425 | + REVHASH v2 (CL 1234 -2 +1, mailed) 426 | + REVHASH v1 (CL 1234 -2 +1, mailed) 427 | + REVHASH msg (CL 1234 -2 +1, mailed, submitted) 428 | 429 | `) 430 | 431 | testPendingArgs(t, []string{"-g"}, ` 432 | - branch work updated 2025-02-06 00:00:00 +0000 UTC 433 | https://go.dev/cl/1234 434 | https://go.dev/cl/1234 435 | https://go.dev/cl/1234 436 | https://go.dev/cl/1234 437 | https://go.dev/cl/1234 438 | https://go.dev/cl/1234 439 | https://go.dev/cl/1234 440 | https://go.dev/cl/1234 441 | https://go.dev/cl/1234 442 | https://go.dev/cl/1234 443 | https://go.dev/cl/1234 444 | https://go.dev/cl/1234 445 | https://go.dev/cl/1234 446 | https://go.dev/cl/1234 447 | https://go.dev/cl/1234 448 | `) 449 | } 450 | 451 | func testPendingReply(srv *gerritServer, id, rev, status string, unresolved int) { 452 | srv.setJSON(id, `{ 453 | "id": "proj~main~`+id+`", 454 | "project": "proj", 455 | "current_revision": "`+rev+`", 456 | "status": "`+status+`", 457 | "unresolved_comment_count":`+fmt.Sprint(unresolved)+`, 458 | "_number": 1234, 459 | "owner": {"_id": 42}, 460 | "created": "2025-02-06 00:00:00.000000000", 461 | "labels": { 462 | "Code-Review": { 463 | "all": [ 464 | { 465 | "_id": 42, 466 | "value": 0 467 | }, 468 | { 469 | "_id": 43, 470 | "name": "George Opher", 471 | "value": -2 472 | }, 473 | { 474 | "_id": 44, 475 | "name": "Grace Emlin", 476 | "value": 1 477 | } 478 | ] 479 | }, 480 | "Trybot-Spam": { 481 | "all": [ 482 | { 483 | "_account_id": 42, 484 | "name": "The Owner", 485 | "value": 0 486 | } 487 | ] 488 | }, 489 | "Other-Label": { 490 | "all": [ 491 | { 492 | "_id": 43, 493 | "name": "George Opher", 494 | "value": 0 495 | }, 496 | { 497 | "_account_id": 42, 498 | "name": "The Owner", 499 | "value": 2 500 | } 501 | ] 502 | } 503 | } 504 | }`) 505 | } 506 | 507 | func testPending(t *testing.T, want string) { 508 | t.Helper() 509 | testPendingArgs(t, nil, want) 510 | } 511 | 512 | func testPendingArgs(t *testing.T, args []string, want string) { 513 | t.Helper() 514 | // fake auth information to avoid Gerrit error 515 | if !auth.initialized { 516 | auth.initialized = true 517 | auth.host = "gerrit.fake" 518 | auth.user = "not-a-user" 519 | defer func() { 520 | auth.initialized = false 521 | auth.host = "" 522 | auth.user = "" 523 | }() 524 | } 525 | 526 | want = strings.Replace(want, "\n\t", "\n", -1) 527 | want = strings.Replace(want, "\n\t", "\n", -1) 528 | want = strings.TrimPrefix(want, "\n") 529 | 530 | testMain(t, append([]string{"pending"}, args...)...) 531 | out := testStdout.Bytes() 532 | 533 | out = regexp.MustCompile(`\b[0-9a-f]{7}\b`).ReplaceAllLiteral(out, []byte("REVHASH")) 534 | out = regexp.MustCompile(`\b127\.0\.0\.1:\d+\b`).ReplaceAllLiteral(out, []byte("127.0.0.1:PORT")) 535 | out = regexp.MustCompile(`(?m)[ \t]+$`).ReplaceAllLiteral(out, nil) // ignore trailing space differences 536 | 537 | if string(out) != want { 538 | t.Errorf("invalid pending output:\n%s\nwant:\n%s", out, want) 539 | if d, err := diff([]byte(want), out); err == nil { 540 | t.Errorf("diff want actual:\n%s", d) 541 | } 542 | } 543 | } 544 | 545 | func diff(b1, b2 []byte) (data []byte, err error) { 546 | f1, err := os.CreateTemp("", "gofmt") 547 | if err != nil { 548 | return 549 | } 550 | defer os.Remove(f1.Name()) 551 | defer f1.Close() 552 | 553 | f2, err := os.CreateTemp("", "gofmt") 554 | if err != nil { 555 | return 556 | } 557 | defer os.Remove(f2.Name()) 558 | defer f2.Close() 559 | 560 | f1.Write(b1) 561 | f2.Write(b2) 562 | 563 | data, err = exec.Command("diff", "-u", f1.Name(), f2.Name()).CombinedOutput() 564 | if len(data) > 0 { 565 | // diff exits with a non-zero status when the files don't match. 566 | // Ignore that failure as long as we get output. 567 | err = nil 568 | } 569 | return 570 | 571 | } 572 | -------------------------------------------------------------------------------- /git-codereview/sync_test.go: -------------------------------------------------------------------------------- 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 | "os" 10 | "path/filepath" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestSync(t *testing.T) { 16 | gt := newGitTest(t) 17 | defer gt.done() 18 | 19 | testMain(t, "change", "work") 20 | 21 | // check for error with unstaged changes 22 | write(t, gt.client+"/file1", "", 0644) 23 | trun(t, gt.client, "git", "add", "file1") 24 | write(t, gt.client+"/file1", "actual content", 0644) 25 | testMainDied(t, "sync") 26 | testPrintedStderr(t, "cannot sync: unstaged changes exist", 27 | "git status", "git stash", "git add", "git-codereview change") 28 | testNoStdout(t) 29 | 30 | // check for error with staged changes 31 | trun(t, gt.client, "git", "add", "file1") 32 | testMainDied(t, "sync") 33 | testPrintedStderr(t, "cannot sync: staged changes exist", 34 | "git status", "!git stash", "!git add", "git-codereview change") 35 | testNoStdout(t) 36 | 37 | // check for success after stash 38 | trun(t, gt.client, "git", "stash") 39 | testMain(t, "sync") 40 | testNoStdout(t) 41 | testNoStderr(t) 42 | 43 | // make server 1 step ahead of client 44 | write(t, gt.server+"/file", "new content", 0644) 45 | trun(t, gt.server, "git", "add", "file") 46 | trun(t, gt.server, "git", "commit", "-m", "msg") 47 | 48 | // check for success 49 | testMain(t, "sync") 50 | testNoStdout(t) 51 | testNoStderr(t) 52 | } 53 | 54 | func TestSyncRebase(t *testing.T) { 55 | gt := newGitTest(t) 56 | defer gt.done() 57 | 58 | // Suppress --reapply-cherry-picks hint. 59 | trun(t, gt.client, "git", "config", "advice.skippedCherryPicks", "false") 60 | 61 | // client 3 ahead 62 | gt.work(t) 63 | gt.work(t) 64 | gt.work(t) 65 | 66 | b := CurrentBranch() 67 | if len(b.Pending()) != 3 { 68 | t.Fatalf("have %d pending CLs, want 3", len(b.Pending())) 69 | } 70 | top := b.Pending()[0].Hash 71 | 72 | // check for success for sync no-op 73 | testMain(t, "sync") 74 | testNoStdout(t) 75 | testNoStderr(t) 76 | 77 | b = CurrentBranch() 78 | if len(b.Pending()) != 3 { 79 | t.Fatalf("have %d pending CLs after no-op sync, want 3", len(b.Pending())) 80 | } 81 | if b.Pending()[0].Hash != top { 82 | t.Fatalf("CL hashes changed during no-op sync") 83 | } 84 | 85 | // submit first two CLs - gt.serverWork does same thing gt.work does, but on server 86 | 87 | gt.serverWork(t) 88 | gt.serverWorkUnrelated(t, "") // wedge in unrelated work to get different hashes 89 | gt.serverWork(t) 90 | 91 | testMain(t, "sync") 92 | testNoStdout(t) 93 | testNoStderr(t) 94 | 95 | // there should be one left, and it should be a different hash 96 | b = CurrentBranch() 97 | if len(b.Pending()) != 1 { 98 | t.Fatalf("have %d pending CLs after submitting two, want 1", len(b.Pending())) 99 | } 100 | if b.Pending()[0].Hash == top { 101 | t.Fatalf("CL hashes DID NOT change during sync after submit") 102 | } 103 | 104 | // submit final change 105 | gt.serverWork(t) 106 | 107 | testMain(t, "sync") 108 | testNoStdout(t) 109 | testNoStderr(t) 110 | 111 | // there should be none left 112 | b = CurrentBranch() 113 | if len(b.Pending()) != 0 { 114 | t.Fatalf("have %d pending CLs after final sync, want 0", len(b.Pending())) 115 | } 116 | 117 | // sync -v prints git output. 118 | // also exercising -v parsing. 119 | testMain(t, "sync", "-v=true") 120 | testNoStdout(t) 121 | testPrintedStderr(t, "git -c advice.skippedCherryPicks=false pull -q -r origin main") 122 | 123 | testMain(t, "sync", "-v=1") 124 | testNoStdout(t) 125 | testPrintedStderr(t, "git -c advice.skippedCherryPicks=false pull -q -r origin main") 126 | 127 | testMain(t, "sync", "-v") 128 | testNoStdout(t) 129 | testPrintedStderr(t, "git -c advice.skippedCherryPicks=false pull -q -r origin main") 130 | 131 | testMain(t, "sync", "-v=false") 132 | testNoStdout(t) 133 | testNoStderr(t) 134 | } 135 | 136 | func TestBranchConfig(t *testing.T) { 137 | gt := newGitTest(t) 138 | defer gt.done() 139 | gt.work(t) // do the main-branch work setup now to avoid unwanted change below 140 | 141 | trun(t, gt.client, "git", "checkout", "dev.branch") 142 | testMain(t, "pending", "-c", "-l") 143 | // The !+ means reject any output with a +, which introduces a pending commit. 144 | // There should be no pending commits. 145 | testPrintedStdout(t, "dev.branch (current branch, tracking dev.branch)", "!+") 146 | 147 | // If we make a branch with raw git, 148 | // the codereview.cfg should help us see the tracking info 149 | // even though git doesn't know the right upstream. 150 | trun(t, gt.client, "git", "checkout", "-b", "mywork", "HEAD^0") 151 | if out, err := cmdOutputDirErr(gt.client, "git", "rev-parse", "--abbrev-ref", "@{u}"); err == nil { 152 | t.Fatalf("git knows @{u} but should not:\n%s", out) 153 | } 154 | testMain(t, "pending", "-c", "-l") 155 | testPrintedStdout(t, "mywork (current branch, tracking dev.branch)", "!+") 156 | // Pending should have set @{u} correctly for us. 157 | if out, err := cmdOutputDirErr(gt.client, "git", "rev-parse", "--abbrev-ref", "@{u}"); err != nil { 158 | t.Fatalf("git does not know @{u} but should: %v\n%s", err, out) 159 | } else if out = strings.TrimSpace(out); out != "origin/dev.branch" { 160 | t.Fatalf("git @{u} = %q, want %q", out, "origin/dev.branch") 161 | } 162 | 163 | // Even if we add a pending commit, we should see the right tracking info. 164 | // The !codereview.cfg makes sure we do not see the codereview.cfg-changing 165 | // commit from the server in the output. (That would happen if we were printing 166 | // new commits relative to main instead of relative to dev.branch.) 167 | gt.work(t) 168 | testMain(t, "pending", "-c", "-l") 169 | testHideRevHashes(t) 170 | testPrintedStdout(t, "mywork REVHASH..REVHASH (current branch, tracking dev.branch)", "!codereview.cfg") 171 | 172 | // If we make a new branch using the old work HEAD 173 | // then we should be back to something tracking main. 174 | trun(t, gt.client, "git", "checkout", "-b", "mywork2", "work^0") 175 | gt.work(t) 176 | testMain(t, "pending", "-c", "-l") 177 | testHideRevHashes(t) 178 | testPrintedStdout(t, "mywork2 REVHASH..REVHASH (current branch)", "!codereview.cfg") 179 | 180 | // Now look at all branches, which should use the appropriate configs 181 | // from the commits on each branch. 182 | testMain(t, "pending", "-l") 183 | testHideRevHashes(t) 184 | testPrintedStdout(t, "mywork2 REVHASH..REVHASH (current branch)", 185 | "mywork REVHASH..REVHASH (tracking dev.branch)", 186 | "work REVHASH..REVHASH\n") // the \n checks for not having a (tracking main) 187 | } 188 | 189 | func TestDetachedHead(t *testing.T) { 190 | gt := newGitTest(t) 191 | defer gt.done() 192 | gt.work(t) // do the main-branch work setup now to avoid unwanted change below 193 | 194 | trun(t, gt.client, "git", "checkout", "HEAD^0") // enter detached HEAD mode with one pending commit 195 | 196 | // Pending should succeed and just print very little. 197 | testMain(t, "pending", "-c", "-l") 198 | testPrintedStdout(t, "HEAD (detached, remote branch unknown)", "!+") 199 | testNoStderr(t) 200 | 201 | // Sync, branchpoint should fail - branch unknown 202 | // (there is no "upstream" coming from git, and there's no branch line 203 | // in codereview.cfg on main in the test setup). 204 | for _, cmd := range []string{"sync", "branchpoint"} { 205 | testMainDied(t, cmd) 206 | testNoStdout(t) 207 | testPrintedStderr(t, "cannot "+cmd+": no origin branch (in detached HEAD mode)") 208 | } 209 | 210 | // If we switch to dev.branch, which does have a branch line, 211 | // detached HEAD mode should be able to find the branchpoint. 212 | trun(t, gt.client, "git", "checkout", "dev.branch") 213 | gt.work(t) 214 | trun(t, gt.client, "git", "checkout", "HEAD^0") 215 | } 216 | 217 | func TestSyncBranch(t *testing.T) { 218 | gt := newGitTest(t) 219 | defer gt.done() 220 | 221 | gt.serverWork(t) 222 | gt.serverWork(t) 223 | trun(t, gt.server, "git", "checkout", "dev.branch") 224 | gt.serverWorkUnrelated(t, "") 225 | gt.serverWorkUnrelated(t, "") 226 | gt.serverWorkUnrelated(t, "") 227 | trun(t, gt.server, "git", "checkout", "main") 228 | 229 | testMain(t, "change", "dev.branch") 230 | testMain(t, "sync-branch") 231 | testHideRevHashes(t) 232 | testPrintedStdout(t, "[dev.branch] all: merge main (REVHASH) into dev.branch", 233 | "Merge List:", 234 | "+ DATE REVHASH msg #2", 235 | "+ DATE REVHASH", 236 | ) 237 | testPrintedStderr(t, "* Merge commit created.", 238 | "Run 'git codereview mail' to send for review.") 239 | } 240 | 241 | func TestSyncBranchWorktree(t *testing.T) { 242 | gt := newGitTest(t) 243 | defer gt.done() 244 | 245 | gt.serverWork(t) 246 | gt.serverWork(t) 247 | trun(t, gt.server, "git", "checkout", "dev.branch") 248 | gt.serverWorkUnrelated(t, "") 249 | gt.serverWorkUnrelated(t, "") 250 | gt.serverWorkUnrelated(t, "") 251 | trun(t, gt.server, "git", "checkout", "main") 252 | 253 | wt := filepath.Join(gt.tmpdir, "git-worktree") 254 | trun(t, gt.client, "git", "worktree", "add", "-b", "dev.branch", wt, "origin/dev.branch") 255 | if err := os.Chdir(wt); err != nil { 256 | t.Fatal(err) 257 | } 258 | 259 | testMain(t, "sync-branch") 260 | testHideRevHashes(t) 261 | testPrintedStdout(t, "[dev.branch] all: merge main (REVHASH) into dev.branch") 262 | } 263 | 264 | func TestSyncBranchMergeBack(t *testing.T) { 265 | gt := newGitTest(t) 266 | defer gt.done() 267 | 268 | // server does not default to having a codereview.cfg on main, 269 | // but sync-branch requires one. 270 | var mainCfg = []byte("branch: main\n") 271 | os.WriteFile(filepath.Join(gt.server, "codereview.cfg"), mainCfg, 0666) 272 | trun(t, gt.server, "git", "add", "codereview.cfg") 273 | trun(t, gt.server, "git", "commit", "-m", "config for main", "codereview.cfg") 274 | 275 | gt.serverWork(t) 276 | gt.serverWork(t) 277 | trun(t, gt.server, "git", "checkout", "dev.branch") 278 | gt.serverWorkUnrelated(t, "work on dev.branch#1") 279 | gt.serverWorkUnrelated(t, "work on dev.branch#2") 280 | gt.serverWorkUnrelated(t, "work on dev.branch#3") 281 | trun(t, gt.server, "git", "checkout", "main") 282 | testMain(t, "change", "dev.branch") 283 | 284 | // Merge back should fail because there are 285 | // commits in the parent that have not been merged here yet. 286 | testMainDied(t, "sync-branch", "--merge-back-to-parent") 287 | testNoStdout(t) 288 | testPrintedStderr(t, "parent has new commits") 289 | 290 | // Bring them in, creating a merge commit. 291 | testMain(t, "sync-branch") 292 | 293 | // Merge back should still fail - merge commit not submitted yet. 294 | testMainDied(t, "sync-branch", "--merge-back-to-parent") 295 | testNoStdout(t) 296 | testPrintedStderr(t, "pending changes exist") 297 | 298 | // Push the changes back to the server. 299 | // (In a real system this would happen through Gerrit.) 300 | trun(t, gt.client, "git", "push", "origin") 301 | 302 | devHash := trim(trun(t, gt.client, "git", "rev-parse", "HEAD")) 303 | 304 | // Nothing should be pending. 305 | testMain(t, "pending", "-c") 306 | testPrintedStdout(t, "!+") 307 | 308 | // This time should work. 309 | testMain(t, "sync-branch", "--merge-back-to-parent") 310 | testPrintedStdout(t, 311 | "\n\tall: REVERSE MERGE dev.branch ("+devHash[:7]+") into main", 312 | "This commit is a REVERSE MERGE.", 313 | "It merges dev.branch back into its parent branch, main.", 314 | "This marks the end of development on dev.branch.", 315 | "Merge List:", 316 | "msg #2", 317 | "!config for main", 318 | ) 319 | testPrintedStderr(t, "Merge commit created.") 320 | 321 | data, err := os.ReadFile(filepath.Join(gt.client, "codereview.cfg")) 322 | if err != nil { 323 | t.Fatal(err) 324 | } 325 | if !bytes.Equal(data, mainCfg) { 326 | t.Fatalf("codereview.cfg not restored:\n%s", data) 327 | } 328 | 329 | // Check pending knows what branch it is on. 330 | testMain(t, "pending", "-c") 331 | testHideRevHashes(t) 332 | testPrintedStdout(t, 333 | "dev.branch REVHASH..REVHASH (current branch)", // no "tracking dev.branch" anymore 334 | "REVHASH (merge=REVHASH)", 335 | "Merge List:", 336 | "!config for main", 337 | ) 338 | 339 | // Check that mail sends the merge to the right place! 340 | testMain(t, "mail", "-n") 341 | testNoStdout(t) 342 | testPrintedStderr(t, 343 | "git push -q origin HEAD:refs/for/main", 344 | "git tag --no-sign -f dev.branch.mailed", 345 | ) 346 | } 347 | 348 | func TestSyncBranchConflict(t *testing.T) { 349 | gt := newGitTest(t) 350 | defer gt.done() 351 | 352 | gt.serverWork(t) 353 | gt.serverWork(t) 354 | trun(t, gt.server, "git", "checkout", "dev.branch") 355 | gt.serverWork(t) 356 | trun(t, gt.server, "git", "checkout", "main") 357 | 358 | testMain(t, "change", "dev.branch") 359 | 360 | testMainDied(t, "sync-branch") 361 | testNoStdout(t) 362 | testPrintedStderr(t, 363 | "git-codereview: sync-branch: merge conflicts in:", 364 | " - file", 365 | "Please fix them (use 'git status' to see the list again),", 366 | "then 'git add' or 'git rm' to resolve them,", 367 | "and then 'git codereview sync-branch -continue' to continue.", 368 | "Or run 'git merge --abort' to give up on this sync-branch.", 369 | ) 370 | 371 | // Other client-changing commands should fail now. 372 | testDisallowed := func(cmd ...string) { 373 | t.Helper() 374 | testMainDied(t, cmd...) 375 | testNoStdout(t) 376 | testPrintedStderr(t, 377 | "git-codereview: cannot "+cmd[0]+": found pending merge", 378 | "Run 'git codereview sync-branch -continue' if you fixed", 379 | "merge conflicts after a previous sync-branch operation.", 380 | "Or run 'git merge --abort' to give up on the sync-branch.", 381 | ) 382 | } 383 | testDisallowed("change", "main") 384 | testDisallowed("sync-branch") 385 | 386 | // throw away server changes to resolve merge 387 | trun(t, gt.client, "git", "checkout", "HEAD", "file") 388 | 389 | // Still cannot change branches even with conflicts resolved. 390 | testDisallowed("change", "main") 391 | testDisallowed("sync-branch") 392 | 393 | testMain(t, "sync-branch", "-continue") 394 | testHideRevHashes(t) 395 | testPrintedStdout(t, 396 | "[dev.branch] all: merge main (REVHASH) into dev.branch", 397 | "+ REVHASH (merge=REVHASH)", 398 | "Conflicts:", 399 | "- file", 400 | "Merge List:", 401 | "+ DATE REVHASH msg #2", 402 | "+ DATE REVHASH", 403 | ) 404 | testPrintedStderr(t, 405 | "* Merge commit created.", 406 | "Run 'git codereview mail' to send for review.", 407 | ) 408 | 409 | // Check that pending only shows the merge, not more commits. 410 | testMain(t, "pending", "-c", "-l", "-s") 411 | n := strings.Count(testStdout.String(), "+") 412 | if n != 1 { 413 | t.Fatalf("git pending shows %d commits, want 1:\n%s", n, testStdout.String()) 414 | } 415 | testNoStderr(t) 416 | 417 | // Check that mail sends the merge to the right place! 418 | testMain(t, "mail", "-n") 419 | testNoStdout(t) 420 | testPrintedStderr(t, 421 | "git push -q origin HEAD:refs/for/dev.branch", 422 | "git tag --no-sign -f dev.branch.mailed", 423 | ) 424 | } 425 | -------------------------------------------------------------------------------- /git-codereview/hook.go: -------------------------------------------------------------------------------- 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 | "os" 13 | "path/filepath" 14 | "regexp" 15 | "strings" 16 | ) 17 | 18 | var hookFiles = []string{ 19 | "commit-msg", 20 | "pre-commit", 21 | } 22 | 23 | // installHook installs Git hooks to enforce code review conventions. 24 | // 25 | // auto is whether hooks are being installed automatically as part of 26 | // running another git-codereview command, rather than an explicit 27 | // invocation of the 'hooks' command itself. 28 | func installHook(args []string, auto bool) { 29 | flags.Parse(args) 30 | hooksDir := gitPath("hooks") 31 | var existingHooks []string 32 | for _, hookFile := range hookFiles { 33 | filename := filepath.Join(hooksDir, hookFile) 34 | hookContent := fmt.Sprintf(hookScript, hookFile) 35 | 36 | if data, err := os.ReadFile(filename); err == nil { 37 | // Special case: remove old hooks that use 'git-review' 38 | oldHookContent := fmt.Sprintf(oldHookScript, hookFile) 39 | if string(data) == oldHookContent { 40 | verbosef("removing old %v hook", hookFile) 41 | if makeChange() { 42 | os.Remove(filename) 43 | } 44 | } 45 | // Special case: remove old commit-msg shell script 46 | // in favor of invoking the git-codereview hook 47 | // implementation, which will be easier to change in 48 | // the future. 49 | if hookFile == "commit-msg" && string(data) == oldCommitMsgHook { 50 | verbosef("removing old commit-msg hook") 51 | if makeChange() { 52 | os.Remove(filename) 53 | } 54 | } 55 | } 56 | 57 | // If hook file exists but has different content, let the user know. 58 | _, err := os.Stat(filename) 59 | if err == nil { 60 | data, err := os.ReadFile(filename) 61 | if err != nil { 62 | verbosef("reading hook: %v", err) 63 | } else if string(data) != hookContent { 64 | verbosef("unexpected hook content in %s", filename) 65 | if !auto { 66 | existingHooks = append(existingHooks, filename) 67 | } 68 | } 69 | continue 70 | } 71 | 72 | if !os.IsNotExist(err) { 73 | dief("checking hook: %v", err) 74 | } 75 | 76 | verbosef("installing %s hook", hookFile) 77 | if _, err := os.Stat(hooksDir); os.IsNotExist(err) { 78 | verbosef("creating hooks directory %s", hooksDir) 79 | if makeChange() { 80 | if err := os.Mkdir(hooksDir, 0777); err != nil { 81 | dief("creating hooks directory: %v", err) 82 | } 83 | } 84 | } 85 | if makeChange() { 86 | if err := os.WriteFile(filename, []byte(hookContent), 0700); err != nil { 87 | dief("writing hook: %v", err) 88 | } 89 | } 90 | } 91 | 92 | switch { 93 | case len(existingHooks) == 1: 94 | dief("Hooks file %s already exists."+ 95 | "\nTo install git-codereview hooks, delete that"+ 96 | " file and re-run 'git-codereview hooks'.", 97 | existingHooks[0]) 98 | case len(existingHooks) > 1: 99 | dief("Hooks files %s already exist."+ 100 | "\nTo install git-codereview hooks, delete these"+ 101 | " files and re-run 'git-codereview hooks'.", 102 | strings.Join(existingHooks, ", ")) 103 | } 104 | } 105 | 106 | // repoRoot returns the root of the currently selected git repo, or 107 | // worktree root if this is an alternate worktree of a repo. 108 | func repoRoot() string { 109 | return filepath.Clean(trim(cmdOutput("git", "rev-parse", "--show-toplevel"))) 110 | } 111 | 112 | // gitPathDir returns the directory used by git to store temporary 113 | // files such as COMMIT_EDITMSG, FETCH_HEAD, and such for the repo. 114 | // For a simple git repo, this will be /.git, and for an 115 | // alternate worktree of a repo it will be in 116 | // /.git/worktrees/. 117 | func gitPathDir() string { 118 | gcd := trim(cmdOutput("git", "rev-parse", "--git-path", ".")) 119 | result, err := filepath.Abs(gcd) 120 | if err != nil { 121 | dief("%v", err) 122 | } 123 | return result 124 | } 125 | 126 | // gitPath resolve the $GIT_DIR/path, taking in consideration 127 | // all other path relocations, e.g. hooks for linked worktrees 128 | // are not kept in their gitdir, but shared in the main one. 129 | func gitPath(path string) string { 130 | root := repoRoot() 131 | // git 2.13.0 changed the behavior of --git-path from printing 132 | // a path relative to the repo root to printing a path 133 | // relative to the working directory (issue #19477). Normalize 134 | // both behaviors by running the command from the repo root. 135 | p, err := trimErr(cmdOutputErr("git", "-C", root, "rev-parse", "--git-path", path)) 136 | if err != nil { 137 | // When --git-path is not available, assume the common case. 138 | p = filepath.Join(".git", path) 139 | } 140 | if !filepath.IsAbs(p) { 141 | p = filepath.Join(root, p) 142 | } 143 | return p 144 | } 145 | 146 | var hookScript = `#!/bin/sh 147 | exec git-codereview hook-invoke %s "$@" 148 | ` 149 | 150 | var oldHookScript = `#!/bin/sh 151 | exec git-review hook-invoke %s "$@" 152 | ` 153 | 154 | func cmdHookInvoke(args []string) { 155 | flags.Parse(args) 156 | args = flags.Args() 157 | if len(args) == 0 { 158 | dief("usage: git-codereview hook-invoke [args...]") 159 | } 160 | switch args[0] { 161 | case "commit-msg": 162 | hookCommitMsg(args[1:]) 163 | case "pre-commit": 164 | hookPreCommit(args[1:]) 165 | } 166 | } 167 | 168 | var ( 169 | issueRefRE = regexp.MustCompile(`(?P\s)(?P#\d+\w)`) 170 | oldFixesRETemplate = `Fixes +(issue +(%s)?#?)?(?P[0-9]+)` 171 | ) 172 | 173 | // hookCommitMsg is installed as the git commit-msg hook. 174 | // It adds a Change-Id line to the bottom of the commit message 175 | // if there is not one already. 176 | func hookCommitMsg(args []string) { 177 | if len(args) != 1 { 178 | dief("usage: git-codereview hook-invoke commit-msg message.txt\n") 179 | } 180 | 181 | // We used to bail in detached head mode, but it's very common 182 | // to be modifying things during git rebase -i and it's annoying 183 | // that those new commits made don't get Commit-Msg lines. 184 | // Let's try keeping the hook on and see what breaks. 185 | /* 186 | b := CurrentBranch() 187 | if b.DetachedHead() { 188 | // Likely executing rebase or some other internal operation. 189 | // Probably a mistake to make commit message changes. 190 | return 191 | } 192 | */ 193 | 194 | file := args[0] 195 | oldData, err := os.ReadFile(file) 196 | if err != nil { 197 | dief("%v", err) 198 | } 199 | 200 | data := fixCommitMessage(oldData) 201 | 202 | // Write back. 203 | if !bytes.Equal(data, oldData) { 204 | if err := os.WriteFile(file, data, 0666); err != nil { 205 | dief("%v", err) 206 | } 207 | } 208 | } 209 | 210 | // fixCommitMessage fixes various commit message issues, 211 | // including adding a Change-Id line and rewriting #12345 212 | // into repo#12345 as directed by codereview.cfg. 213 | func fixCommitMessage(msg []byte) []byte { 214 | data := append([]byte{}, msg...) 215 | data = stripComments(data) 216 | 217 | // Empty message not allowed. 218 | if len(bytes.TrimSpace(data)) == 0 { 219 | dief("empty commit message") 220 | } 221 | 222 | // Insert a blank line between first line and subsequent lines if not present. 223 | eol := bytes.IndexByte(data, '\n') 224 | if eol != -1 && len(data) > eol+1 && data[eol+1] != '\n' { 225 | data = append(data, 0) 226 | copy(data[eol+1:], data[eol:]) 227 | data[eol+1] = '\n' 228 | } 229 | 230 | issueRepo := config()["issuerepo"] 231 | // Update issue references to point to issue repo, if set. 232 | if issueRepo != "" { 233 | data = issueRefRE.ReplaceAll(data, []byte("${space}"+issueRepo+"${ref}")) 234 | } 235 | // TestHookCommitMsgIssueRepoRewrite makes sure the regex is valid 236 | oldFixesRE := regexp.MustCompile(fmt.Sprintf(oldFixesRETemplate, regexp.QuoteMeta(issueRepo))) 237 | data = oldFixesRE.ReplaceAll(data, []byte("Fixes "+issueRepo+"#${issueNum}")) 238 | 239 | if haveGerrit() { 240 | // Complain if two Change-Ids are present. 241 | // This can happen during an interactive rebase; 242 | // it is easy to forget to remove one of them. 243 | nChangeId := bytes.Count(data, []byte("\nChange-Id: ")) 244 | if nChangeId > 1 { 245 | dief("multiple Change-Id lines") 246 | } 247 | 248 | // Add Change-Id to commit message if not present. 249 | if nChangeId == 0 { 250 | data = bytes.TrimRight(data, "\n") 251 | sep := "\n\n" 252 | if endsWithMetadataLine(data) { 253 | sep = "\n" 254 | } 255 | data = append(data, fmt.Sprintf("%sChange-Id: I%x\n", sep, randomBytes())...) 256 | } 257 | 258 | // Add branch prefix to commit message if not present and on a 259 | // dev or release branch and not a special Git fixup! or 260 | // squash! commit message. 261 | b := CurrentBranch() 262 | branch := strings.TrimPrefix(b.OriginBranch(), "origin/") 263 | if strings.HasPrefix(branch, "dev.") || strings.HasPrefix(branch, "release-branch.") { 264 | prefix := "[" + branch + "] " 265 | if !bytes.HasPrefix(data, []byte(prefix)) && !isFixup(data) { 266 | data = []byte(prefix + string(data)) 267 | } 268 | } 269 | } 270 | 271 | return data 272 | } 273 | 274 | // randomBytes returns 20 random bytes suitable for use in a Change-Id line. 275 | func randomBytes() []byte { 276 | var id [20]byte 277 | if _, err := io.ReadFull(rand.Reader, id[:]); err != nil { 278 | dief("generating Change-Id: %v", err) 279 | } 280 | return id[:] 281 | } 282 | 283 | var metadataLineRE = regexp.MustCompile(`^[a-zA-Z0-9-]+: `) 284 | 285 | // endsWithMetadataLine reports whether the given commit message ends with a 286 | // metadata line such as "Bug: #42" or "Signed-off-by: Al ". 287 | func endsWithMetadataLine(msg []byte) bool { 288 | i := bytes.LastIndexByte(msg, '\n') 289 | return i >= 0 && metadataLineRE.Match(msg[i+1:]) 290 | } 291 | 292 | var ( 293 | fixupBang = []byte("fixup!") 294 | squashBang = []byte("squash!") 295 | 296 | ignoreBelow = []byte("\n# ------------------------ >8 ------------------------\n") 297 | ) 298 | 299 | // isFixup reports whether text is a Git fixup! or squash! commit, 300 | // which must not have a prefix. 301 | func isFixup(text []byte) bool { 302 | return bytes.HasPrefix(text, fixupBang) || bytes.HasPrefix(text, squashBang) 303 | } 304 | 305 | // stripComments strips lines that begin with "#" and removes the 306 | // "everything below will be removed" section containing the diff when 307 | // using commit --verbose. 308 | func stripComments(in []byte) []byte { 309 | // Issue 16376 310 | if i := bytes.Index(in, ignoreBelow); i >= 0 { 311 | in = in[:i+1] 312 | } 313 | return regexp.MustCompile(`(?m)^#.*\n`).ReplaceAll(in, nil) 314 | } 315 | 316 | // hookPreCommit is installed as the git pre-commit hook. 317 | // It prevents commits to the master branch. 318 | // It checks that the Go files added, copied, or modified by 319 | // the change are gofmt'd, and if not it prints gofmt instructions 320 | // and exits with nonzero status. 321 | func hookPreCommit(args []string) { 322 | // We used to bail in detached head mode, but it's very common 323 | // to be modifying things during git rebase -i and it's annoying 324 | // that those new commits made don't get the gofmt check. 325 | // Let's try keeping the hook on and see what breaks. 326 | /* 327 | b := CurrentBranch() 328 | if b.DetachedHead() { 329 | // This is an internal commit such as during git rebase. 330 | // Don't die, and don't force gofmt. 331 | return 332 | } 333 | */ 334 | 335 | hookGofmt() 336 | } 337 | 338 | func hookGofmt() { 339 | if os.Getenv("GIT_GOFMT_HOOK") == "off" { 340 | fmt.Fprintf(stderr(), "git-codereview pre-commit gofmt hook disabled by $GIT_GOFMT_HOOK=off\n") 341 | return 342 | } 343 | 344 | files, stderr := runGofmt(gofmtPreCommit) 345 | 346 | if stderr != "" { 347 | msgf := printf 348 | if len(files) == 0 { 349 | msgf = dief 350 | } 351 | msgf("gofmt reported errors:\n\t%s", strings.Replace(strings.TrimSpace(stderr), "\n", "\n\t", -1)) 352 | } 353 | 354 | if len(files) == 0 { 355 | return 356 | } 357 | 358 | dief("gofmt needs to format these files (run 'git codereview gofmt'):\n\t%s", 359 | strings.Join(files, "\n\t")) 360 | } 361 | 362 | // This is NOT USED ANYMORE. 363 | // It is here only for comparing against old commit-hook files. 364 | var oldCommitMsgHook = `#!/bin/sh 365 | # From Gerrit Code Review 2.2.1 366 | # 367 | # Part of Gerrit Code Review (http://code.google.com/p/gerrit/) 368 | # 369 | # Copyright (C) 2009 The Android Open Source Project 370 | # 371 | # Licensed under the Apache License, Version 2.0 (the "License"); 372 | # you may not use this file except in compliance with the License. 373 | # You may obtain a copy of the License at 374 | # 375 | # http://www.apache.org/licenses/LICENSE-2.0 376 | # 377 | # Unless required by applicable law or agreed to in writing, software 378 | # distributed under the License is distributed on an "AS IS" BASIS, 379 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 380 | # See the License for the specific language governing permissions and 381 | # limitations under the License. 382 | # 383 | 384 | CHANGE_ID_AFTER="Bug|Issue" 385 | MSG="$1" 386 | 387 | # Check for, and add if missing, a unique Change-Id 388 | # 389 | add_ChangeId() { 390 | clean_message=` + "`" + `sed -e ' 391 | /^diff --git a\/.*/{ 392 | s/// 393 | q 394 | } 395 | /^Signed-off-by:/d 396 | /^#/d 397 | ' "$MSG" | git stripspace` + "`" + ` 398 | if test -z "$clean_message" 399 | then 400 | return 401 | fi 402 | 403 | if grep -i '^Change-Id:' "$MSG" >/dev/null 404 | then 405 | return 406 | fi 407 | 408 | id=` + "`" + `_gen_ChangeId` + "`" + ` 409 | perl -e ' 410 | $MSG = shift; 411 | $id = shift; 412 | $CHANGE_ID_AFTER = shift; 413 | 414 | undef $/; 415 | open(I, $MSG); $_ = ; close I; 416 | s|^diff --git a/.*||ms; 417 | s|^#.*$||mg; 418 | exit unless $_; 419 | 420 | @message = split /\n/; 421 | $haveFooter = 0; 422 | $startFooter = @message; 423 | for($line = @message - 1; $line >= 0; $line--) { 424 | $_ = $message[$line]; 425 | 426 | if (/^[a-zA-Z0-9-]+:/ && !m,^[a-z0-9-]+://,) { 427 | $haveFooter++; 428 | next; 429 | } 430 | next if /^[ []/; 431 | $startFooter = $line if ($haveFooter && /^\r?$/); 432 | last; 433 | } 434 | 435 | @footer = @message[$startFooter+1..@message]; 436 | @message = @message[0..$startFooter]; 437 | push(@footer, "") unless @footer; 438 | 439 | for ($line = 0; $line < @footer; $line++) { 440 | $_ = $footer[$line]; 441 | next if /^($CHANGE_ID_AFTER):/i; 442 | last; 443 | } 444 | splice(@footer, $line, 0, "Change-Id: I$id"); 445 | 446 | $_ = join("\n", @message, @footer); 447 | open(O, ">$MSG"); print O; close O; 448 | ' "$MSG" "$id" "$CHANGE_ID_AFTER" 449 | } 450 | _gen_ChangeIdInput() { 451 | echo "tree ` + "`" + `git write-tree` + "`" + `" 452 | if parent=` + "`" + `git rev-parse HEAD^0 2>/dev/null` + "`" + ` 453 | then 454 | echo "parent $parent" 455 | fi 456 | echo "author ` + "`" + `git var GIT_AUTHOR_IDENT` + "`" + `" 457 | echo "committer ` + "`" + `git var GIT_COMMITTER_IDENT` + "`" + `" 458 | echo 459 | printf '%s' "$clean_message" 460 | } 461 | _gen_ChangeId() { 462 | _gen_ChangeIdInput | 463 | git hash-object -t commit --stdin 464 | } 465 | 466 | 467 | add_ChangeId 468 | ` 469 | -------------------------------------------------------------------------------- /git-codereview/gofmt.go: -------------------------------------------------------------------------------- 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 | "flag" 10 | "fmt" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "sort" 15 | "strings" 16 | ) 17 | 18 | var gofmtList bool 19 | 20 | func cmdGofmt(args []string) { 21 | // NOTE: New flags should be added to the usage message below as well as doc.go. 22 | flags.BoolVar(&gofmtList, "l", false, "list files that need to be formatted") 23 | flags.Parse(args) 24 | if len(flag.Args()) > 0 { 25 | fmt.Fprintf(stderr(), "Usage: %s gofmt %s [-l]\n", progName, globalFlags) 26 | exit(2) 27 | } 28 | 29 | f := gofmtCommand 30 | if !gofmtList { 31 | f |= gofmtWrite 32 | } 33 | 34 | files, stderr := runGofmt(f) 35 | if gofmtList { 36 | w := stdout() 37 | for _, file := range files { 38 | fmt.Fprintf(w, "%s\n", file) 39 | } 40 | } 41 | if stderr != "" { 42 | dief("gofmt reported errors:\n\t%s", strings.Replace(strings.TrimSpace(stderr), "\n", "\n\t", -1)) 43 | } 44 | } 45 | 46 | const ( 47 | gofmtPreCommit = 1 << iota 48 | gofmtCommand 49 | gofmtWrite 50 | ) 51 | 52 | // runGofmt runs the external gofmt command over modified files. 53 | // 54 | // The definition of "modified files" depends on the bit flags. 55 | // If gofmtPreCommit is set, then runGofmt considers *.go files that 56 | // differ between the index (staging area) and the branchpoint 57 | // (the latest commit before the branch diverged from upstream). 58 | // If gofmtCommand is set, then runGofmt considers all those files 59 | // in addition to files with unstaged modifications. 60 | // It never considers untracked files. 61 | // 62 | // As a special case for the main repo (but applied everywhere) 63 | // *.go files under a top-level test directory are excluded from the 64 | // formatting requirement, except run.go and those in test/bench/. 65 | // 66 | // If gofmtWrite is set (only with gofmtCommand, meaning this is 'git gofmt'), 67 | // runGofmt replaces the original files with their formatted equivalents. 68 | // Git makes this difficult. In general the file in the working tree 69 | // (the local file system) can have unstaged changes that make it different 70 | // from the equivalent file in the index. To help pass the precommit hook, 71 | // 'git gofmt' must make it easy to update the files in the index. 72 | // One option is to run gofmt on all the files of the same name in the 73 | // working tree and leave it to the user to sort out what should be staged 74 | // back into the index. Another is to refuse to reformat files for which 75 | // different versions exist in the index vs the working tree. Both of these 76 | // options are unsatisfying: they foist busy work onto the user, 77 | // and it's exactly the kind of busy work that a program is best for. 78 | // Instead, when runGofmt finds files in the index that need 79 | // reformatting, it reformats them there, bypassing the working tree. 80 | // It also reformats files in the working tree that need reformatting. 81 | // For both, only files modified since the branchpoint are considered. 82 | // The result should be that both index and working tree get formatted 83 | // correctly and diffs between the two remain meaningful (free of 84 | // formatting distractions). Modifying files in the index directly may 85 | // surprise Git users, but it seems the best of a set of bad choices, and 86 | // of course those users can choose not to use 'git gofmt'. 87 | // This is a bit more work than the other git commands do, which is 88 | // a little worrying, but the choice being made has the nice property 89 | // that if 'git gofmt' is interrupted, a second 'git gofmt' will put things into 90 | // the same state the first would have. 91 | // 92 | // runGofmt returns a list of files that need (or needed) reformatting. 93 | // If gofmtPreCommit is set, the names always refer to files in the index. 94 | // If gofmtCommand is set, then a name without a suffix (see below) 95 | // refers to both the copy in the index and the copy in the working tree 96 | // and implies that the two copies are identical. Otherwise, in the case 97 | // that the index and working tree differ, the file name will have an explicit 98 | // " (staged)" or " (unstaged)" suffix saying which is meant. 99 | // 100 | // runGofmt also returns any standard error output from gofmt, 101 | // usually indicating syntax errors in the Go source files. 102 | // If gofmtCommand is set, syntax errors in index files that do not match 103 | // the working tree show a " (staged)" suffix after the file name. 104 | // The errors never use the " (unstaged)" suffix, in order to keep 105 | // references to the local file system in the standard file:line form. 106 | func runGofmt(flags int) (files []string, stderrText string) { 107 | pwd, err := os.Getwd() 108 | if err != nil { 109 | dief("%v", err) 110 | } 111 | pwd = filepath.Clean(pwd) // convert to host \ syntax 112 | if !strings.HasSuffix(pwd, string(filepath.Separator)) { 113 | pwd += string(filepath.Separator) 114 | } 115 | 116 | b := CurrentBranch() 117 | repo := repoRoot() 118 | if !strings.HasSuffix(repo, string(filepath.Separator)) { 119 | repo += string(filepath.Separator) 120 | } 121 | 122 | // Find files modified in the index compared to the branchpoint. 123 | // The default of HEAD will only compare against the most recent commit. 124 | // But if we know the origin branch, and this isn't a branch tag move, 125 | // then check all the pending commits. 126 | branchpt := "HEAD" 127 | if b.OriginBranch() != "" { 128 | isBranchTagMove := strings.Contains(cmdOutput("git", "branch", "-r", "--contains", b.FullName()), "origin/") 129 | if !isBranchTagMove { 130 | branchpt = b.Branchpoint() 131 | } 132 | } 133 | indexFiles := addRoot(repo, filter(gofmtRequired, nonBlankLines(cmdOutput("git", "diff", "--name-only", "--diff-filter=ACM", "--cached", branchpt, "--")))) 134 | localFiles := addRoot(repo, filter(gofmtRequired, nonBlankLines(cmdOutput("git", "diff", "--name-only", "--diff-filter=ACM")))) 135 | localFilesMap := stringMap(localFiles) 136 | isUnstaged := func(file string) bool { 137 | return localFilesMap[file] 138 | } 139 | 140 | if len(indexFiles) == 0 && ((flags&gofmtCommand) == 0 || len(localFiles) == 0) { 141 | return 142 | } 143 | 144 | // Determine which files have unstaged changes and are therefore 145 | // different from their index versions. For those, the index version must 146 | // be copied into a temporary file in the local file system. 147 | needTemp := filter(isUnstaged, indexFiles) 148 | 149 | // Map between temporary file name and place in file tree where 150 | // file would be checked out (if not for the unstaged changes). 151 | tempToFile := map[string]string{} 152 | fileToTemp := map[string]string{} 153 | cleanup := func() {} // call before dying (defer won't run) 154 | if len(needTemp) > 0 { 155 | // Ask Git to copy the index versions into temporary files. 156 | // Git stores the temporary files, named .merge_*, in the repo root. 157 | // Unlike the Git commands above, the non-temp file names printed 158 | // here are relative to the current directory, not the repo root. 159 | 160 | // git checkout-index --temp is broken on windows. Running this command: 161 | // 162 | // git checkout-index --temp -- bad-bad-bad2.go bad-bad-broken.go bad-bad-good.go bad-bad2-bad.go bad-bad2-broken.go bad-bad2-good.go bad-broken-bad.go bad-broken-bad2.go bad-broken-good.go bad-good-bad.go bad-good-bad2.go bad-good-broken.go bad2-bad-bad2.go bad2-bad-broken.go bad2-bad-good.go bad2-bad2-bad.go bad2-bad2-broken.go bad2-bad2-good.go bad2-broken-bad.go bad2-broken-bad2.go bad2-broken-good.go bad2-good-bad.go bad2-good-bad2.go bad2-good-broken.go broken-bad-bad2.go broken-bad-broken.go broken-bad-good.go broken-bad2-bad.go broken-bad2-broken.go broken-bad2-good.go 163 | // 164 | // produces this output 165 | // 166 | // .merge_file_a05448 bad-bad-bad2.go 167 | // .merge_file_b05448 bad-bad-broken.go 168 | // .merge_file_c05448 bad-bad-good.go 169 | // .merge_file_d05448 bad-bad2-bad.go 170 | // .merge_file_e05448 bad-bad2-broken.go 171 | // .merge_file_f05448 bad-bad2-good.go 172 | // .merge_file_g05448 bad-broken-bad.go 173 | // .merge_file_h05448 bad-broken-bad2.go 174 | // .merge_file_i05448 bad-broken-good.go 175 | // .merge_file_j05448 bad-good-bad.go 176 | // .merge_file_k05448 bad-good-bad2.go 177 | // .merge_file_l05448 bad-good-broken.go 178 | // .merge_file_m05448 bad2-bad-bad2.go 179 | // .merge_file_n05448 bad2-bad-broken.go 180 | // .merge_file_o05448 bad2-bad-good.go 181 | // .merge_file_p05448 bad2-bad2-bad.go 182 | // .merge_file_q05448 bad2-bad2-broken.go 183 | // .merge_file_r05448 bad2-bad2-good.go 184 | // .merge_file_s05448 bad2-broken-bad.go 185 | // .merge_file_t05448 bad2-broken-bad2.go 186 | // .merge_file_u05448 bad2-broken-good.go 187 | // .merge_file_v05448 bad2-good-bad.go 188 | // .merge_file_w05448 bad2-good-bad2.go 189 | // .merge_file_x05448 bad2-good-broken.go 190 | // .merge_file_y05448 broken-bad-bad2.go 191 | // .merge_file_z05448 broken-bad-broken.go 192 | // error: unable to create file .merge_file_XXXXXX (No error) 193 | // .merge_file_XXXXXX broken-bad-good.go 194 | // error: unable to create file .merge_file_XXXXXX (No error) 195 | // .merge_file_XXXXXX broken-bad2-bad.go 196 | // error: unable to create file .merge_file_XXXXXX (No error) 197 | // .merge_file_XXXXXX broken-bad2-broken.go 198 | // error: unable to create file .merge_file_XXXXXX (No error) 199 | // .merge_file_XXXXXX broken-bad2-good.go 200 | // 201 | // so limit the number of file arguments to 25. 202 | for len(needTemp) > 0 { 203 | n := len(needTemp) 204 | if n > 25 { 205 | n = 25 206 | } 207 | args := []string{"checkout-index", "--temp", "--"} 208 | args = append(args, needTemp[:n]...) 209 | // Until Git 2.3.0, git checkout-index --temp is broken if not run in the repo root. 210 | // Work around by running in the repo root. 211 | // http://article.gmane.org/gmane.comp.version-control.git/261739 212 | // https://github.com/git/git/commit/74c4de5 213 | for _, line := range nonBlankLines(cmdOutputDir(repo, "git", args...)) { 214 | i := strings.Index(line, "\t") 215 | if i < 0 { 216 | continue 217 | } 218 | temp, file := line[:i], line[i+1:] 219 | temp = filepath.Join(repo, temp) 220 | file = filepath.Join(repo, file) 221 | tempToFile[temp] = file 222 | fileToTemp[file] = temp 223 | } 224 | needTemp = needTemp[n:] 225 | } 226 | cleanup = func() { 227 | for temp := range tempToFile { 228 | os.Remove(temp) 229 | } 230 | tempToFile = nil 231 | } 232 | defer cleanup() 233 | } 234 | dief := func(format string, args ...interface{}) { 235 | cleanup() 236 | dief(format, args...) // calling top-level dief function 237 | } 238 | 239 | // Run gofmt to find out which files need reformatting; 240 | // if gofmtWrite is set, reformat them in place. 241 | // For references to local files, remove leading pwd if present 242 | // to make relative to current directory. 243 | // Temp files and local-only files stay as absolute paths for easy matching in output. 244 | args := []string{"-l"} 245 | if flags&gofmtWrite != 0 { 246 | args = append(args, "-w") 247 | } 248 | for _, file := range indexFiles { 249 | if isUnstaged(file) { 250 | args = append(args, fileToTemp[file]) 251 | } else { 252 | args = append(args, strings.TrimPrefix(file, pwd)) 253 | } 254 | } 255 | if flags&gofmtCommand != 0 { 256 | args = append(args, localFiles...) 257 | } 258 | 259 | if *verbose > 1 { 260 | fmt.Fprintln(stderr(), commandString("gofmt", args)) 261 | } 262 | cmd := exec.Command("gofmt", args...) 263 | var stdout, stderr bytes.Buffer 264 | cmd.Stdout = &stdout 265 | cmd.Stderr = &stderr 266 | err = cmd.Run() 267 | 268 | if stderr.Len() == 0 && err != nil { 269 | // Error but no stderr: usually can't find gofmt. 270 | dief("invoking gofmt: %v", err) 271 | } 272 | 273 | // Build file list. 274 | files = lines(stdout.String()) 275 | 276 | // Restage files that need to be restaged. 277 | if flags&gofmtWrite != 0 { 278 | add := []string{"add"} 279 | write := []string{"hash-object", "-w", "--"} 280 | updateIndex := []string{} 281 | for _, file := range files { 282 | if real := tempToFile[file]; real != "" { 283 | write = append(write, file) 284 | updateIndex = append(updateIndex, strings.TrimPrefix(real, repo)) 285 | } else if !isUnstaged(file) { 286 | add = append(add, file) 287 | } 288 | } 289 | if len(add) > 1 { 290 | run("git", add...) 291 | } 292 | if len(updateIndex) > 0 { 293 | hashes := nonBlankLines(cmdOutput("git", write...)) 294 | if len(hashes) != len(write)-3 { 295 | dief("git hash-object -w did not write expected number of objects") 296 | } 297 | var buf bytes.Buffer 298 | for i, name := range updateIndex { 299 | fmt.Fprintf(&buf, "100644 %s\t%s\n", hashes[i], name) 300 | } 301 | verbosef("git update-index --index-info") 302 | cmd := exec.Command("git", "update-index", "--index-info") 303 | cmd.Stdin = &buf 304 | out, err := cmd.CombinedOutput() 305 | if err != nil { 306 | dief("git update-index: %v\n%s", err, out) 307 | } 308 | } 309 | } 310 | 311 | // Remap temp files back to original names for caller. 312 | for i, file := range files { 313 | if real := tempToFile[file]; real != "" { 314 | if flags&gofmtCommand != 0 { 315 | real += " (staged)" 316 | } 317 | files[i] = strings.TrimPrefix(real, pwd) 318 | } else if isUnstaged(file) { 319 | files[i] = strings.TrimPrefix(file+" (unstaged)", pwd) 320 | } 321 | } 322 | 323 | // Rewrite temp names in stderr, and shorten local file names. 324 | // No suffix added for local file names (see comment above). 325 | text := "\n" + stderr.String() 326 | for temp, file := range tempToFile { 327 | if flags&gofmtCommand != 0 { 328 | file += " (staged)" 329 | } 330 | text = strings.Replace(text, "\n"+temp+":", "\n"+strings.TrimPrefix(file, pwd)+":", -1) 331 | } 332 | for _, file := range localFiles { 333 | text = strings.Replace(text, "\n"+file+":", "\n"+strings.TrimPrefix(file, pwd)+":", -1) 334 | } 335 | text = text[1:] 336 | 337 | sort.Strings(files) 338 | return files, text 339 | } 340 | 341 | // gofmtRequired reports whether the specified file should be checked 342 | // for gofmt'dness by the pre-commit hook. 343 | // The file name is relative to the repo root. 344 | func gofmtRequired(file string) bool { 345 | // TODO: Consider putting this policy into codereview.cfg. 346 | if !strings.HasSuffix(file, ".go") { 347 | return false 348 | } 349 | if strings.HasPrefix(file, "vendor/") || strings.Contains(file, "/vendor/") { 350 | return false 351 | } 352 | if strings.HasPrefix(file, "testdata/") || strings.Contains(file, "/testdata/") { 353 | return false 354 | } 355 | if !strings.HasPrefix(file, "test/") { 356 | return true 357 | } 358 | return strings.HasPrefix(file, "test/bench/") || file == "test/run.go" 359 | } 360 | 361 | // stringMap returns a map m such that m[s] == true if s was in the original list. 362 | func stringMap(list []string) map[string]bool { 363 | m := map[string]bool{} 364 | for _, x := range list { 365 | m[x] = true 366 | } 367 | return m 368 | } 369 | 370 | // filter returns the elements in list satisfying f. 371 | func filter(f func(string) bool, list []string) []string { 372 | var out []string 373 | for _, x := range list { 374 | if f(x) { 375 | out = append(out, x) 376 | } 377 | } 378 | return out 379 | } 380 | 381 | func addRoot(root string, list []string) []string { 382 | var out []string 383 | for _, x := range list { 384 | out = append(out, filepath.Join(root, x)) 385 | } 386 | return out 387 | } 388 | -------------------------------------------------------------------------------- /git-codereview/sync.go: -------------------------------------------------------------------------------- 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 | "encoding/json" 9 | "flag" 10 | "fmt" 11 | "os" 12 | "strings" 13 | ) 14 | 15 | func cmdSync(args []string) { 16 | expectZeroArgs(args, "sync") 17 | 18 | // Get current branch and commit ID for fixup after pull. 19 | b := CurrentBranch() 20 | b.NeedOriginBranch("sync") 21 | var id string 22 | if work := b.Pending(); len(work) > 0 { 23 | id = work[0].ChangeID 24 | } 25 | 26 | // If this is a Gerrit repo, disable the status advice that 27 | // tells users to run 'git push' and so on, like the marked (<<<) lines: 28 | // 29 | // % git status 30 | // On branch master 31 | // Your branch is ahead of 'origin/master' by 3 commits. <<< 32 | // (use "git push" to publish your local commits) <<< 33 | // ... 34 | // 35 | // (This advice is inappropriate when using Gerrit.) 36 | if len(b.Pending()) > 0 && haveGerrit() { 37 | // Only disable if statusHints is unset in the local config. 38 | // This allows users who really want them to put them back 39 | // in the .git/config for the Gerrit-cloned repo. 40 | _, err := cmdOutputErr("git", "config", "--local", "advice.statusHints") 41 | if err != nil { 42 | run("git", "config", "--local", "advice.statusHints", "false") 43 | } 44 | } 45 | 46 | // Don't sync with staged or unstaged changes. 47 | // rebase is going to complain if we don't, and we can give a nicer error. 48 | checkStaged("sync") 49 | checkUnstaged("sync") 50 | 51 | // Pull remote changes into local branch. 52 | // We do this in one command so that people following along with 'git codereview sync -v' 53 | // see fewer commands to understand. 54 | // We want to pull in the remote changes from the upstream branch 55 | // and rebase the current pending commit (if any) on top of them. 56 | // If there is no pending commit, the pull will do a fast-forward merge. 57 | // 58 | // The -c advice.skippedCherryPicks=false disables this message: 59 | // 60 | // hint: use --reapply-cherry-picks to include skipped commits 61 | // hint: Disable this message with "git config advice.skippedCherryPicks false" 62 | // 63 | if *verbose > 1 { 64 | run("git", "-c", "advice.skippedCherryPicks=false", "pull", "-q", "-r", "-v", "origin", strings.TrimPrefix(b.OriginBranch(), "origin/")) 65 | } else { 66 | run("git", "-c", "advice.skippedCherryPicks=false", "pull", "-q", "-r", "origin", strings.TrimPrefix(b.OriginBranch(), "origin/")) 67 | } 68 | 69 | b = CurrentBranch() // discard any cached information 70 | if len(b.Pending()) == 1 && b.Submitted(id) { 71 | // If the change commit has been submitted, 72 | // roll back change leaving any changes unstaged. 73 | // Pull should have done this for us, but check just in case. 74 | run("git", "reset", b.Branchpoint()) 75 | } 76 | } 77 | 78 | func checkStaged(cmd string) { 79 | if HasStagedChanges() { 80 | dief("cannot %s: staged changes exist\n"+ 81 | "\trun 'git status' to see changes\n"+ 82 | "\trun 'git-codereview change' to commit staged changes", cmd) 83 | } 84 | } 85 | 86 | func checkUnstaged(cmd string) { 87 | if HasUnstagedChanges() { 88 | dief("cannot %s: unstaged changes exist\n"+ 89 | "\trun 'git status' to see changes\n"+ 90 | "\trun 'git stash' to save unstaged changes\n"+ 91 | "\trun 'git add' and 'git-codereview change' to commit staged changes", cmd) 92 | } 93 | } 94 | 95 | type syncBranchStatus struct { 96 | Local string 97 | Parent string 98 | Branch string 99 | ParentHash string 100 | BranchHash string 101 | Conflicts []string 102 | } 103 | 104 | func syncBranchStatusFile() string { 105 | return gitPath("codereview-sync-branch-status") 106 | } 107 | 108 | func readSyncBranchStatus() *syncBranchStatus { 109 | data, err := os.ReadFile(syncBranchStatusFile()) 110 | if err != nil { 111 | dief("cannot sync-branch: reading status: %v", err) 112 | } 113 | status := new(syncBranchStatus) 114 | err = json.Unmarshal(data, status) 115 | if err != nil { 116 | dief("cannot sync-branch: reading status: %v", err) 117 | } 118 | return status 119 | } 120 | 121 | func writeSyncBranchStatus(status *syncBranchStatus) { 122 | js, err := json.MarshalIndent(status, "", "\t") 123 | if err != nil { 124 | dief("cannot sync-branch: writing status: %v", err) 125 | } 126 | if err := os.WriteFile(syncBranchStatusFile(), js, 0666); err != nil { 127 | dief("cannot sync-branch: writing status: %v", err) 128 | } 129 | } 130 | 131 | func cmdSyncBranch(args []string) { 132 | os.Setenv("GIT_EDITOR", ":") // do not bring up editor during merge, commit 133 | os.Setenv("GIT_GOFMT_HOOK", "off") // do not require gofmt during merge 134 | 135 | var cont, mergeBackToParent bool 136 | flags.BoolVar(&cont, "continue", false, "continue after merge conflicts") 137 | flags.BoolVar(&mergeBackToParent, "merge-back-to-parent", false, "for shutting down the dev branch") 138 | flags.Parse(args) 139 | if len(flag.Args()) > 0 { 140 | fmt.Fprintf(stderr(), "Usage: %s sync-branch %s [-continue]\n", progName, globalFlags) 141 | exit(2) 142 | } 143 | 144 | parent := config()["parent-branch"] 145 | if parent == "" { 146 | dief("cannot sync-branch: codereview.cfg does not list parent-branch") 147 | } 148 | 149 | branch := config()["branch"] 150 | if parent == "" { 151 | dief("cannot sync-branch: codereview.cfg does not list branch") 152 | } 153 | 154 | b := CurrentBranch() 155 | if b.DetachedHead() { 156 | dief("cannot sync-branch: on detached head") 157 | } 158 | if len(b.Pending()) > 0 { 159 | dief("cannot sync-branch: pending changes exist\n" + 160 | "\trun 'git codereview pending' to see them") 161 | } 162 | 163 | if cont { 164 | // Note: There is no -merge-back-to-parent -continue 165 | // because -merge-back-to-parent never has merge conflicts. 166 | // (It requires that the parent be fully merged into the 167 | // dev branch or it won't even attempt the reverse merge.) 168 | if mergeBackToParent { 169 | dief("cannot use -continue with -merge-back-to-parent") 170 | } 171 | if _, err := os.Stat(syncBranchStatusFile()); err != nil { 172 | dief("cannot sync-branch -continue: no pending sync-branch status file found") 173 | } 174 | syncBranchContinue(syncBranchContinueFlag, b, readSyncBranchStatus()) 175 | return 176 | } 177 | 178 | if _, err := cmdOutputErr("git", "rev-parse", "--abbrev-ref", "MERGE_HEAD"); err == nil { 179 | diePendingMerge("sync-branch") 180 | } 181 | 182 | // Don't sync with staged or unstaged changes. 183 | // rebase is going to complain if we don't, and we can give a nicer error. 184 | checkStaged("sync") 185 | checkUnstaged("sync") 186 | 187 | // Make sure client is up-to-date on current branch. 188 | // Note that this does a remote fetch of b.OriginBranch() (aka branch). 189 | cmdSync(nil) 190 | 191 | // Pull down parent commits too. 192 | quiet := "-q" 193 | if *verbose > 0 { 194 | quiet = "-v" 195 | } 196 | run("git", "fetch", quiet, "origin", "refs/heads/"+parent+":refs/remotes/origin/"+parent) 197 | 198 | // Write the status file to make sure we can, before starting a merge. 199 | status := &syncBranchStatus{ 200 | Local: b.Name, 201 | Parent: parent, 202 | ParentHash: gitHash("origin/" + parent), 203 | Branch: branch, 204 | BranchHash: gitHash("origin/" + branch), 205 | } 206 | writeSyncBranchStatus(status) 207 | 208 | parentHash, err := cmdOutputErr("git", "rev-parse", "origin/"+parent) 209 | if err != nil { 210 | dief("cannot sync-branch: cannot resolve origin/%s: %v\n%s", parent, err, parentHash) 211 | } 212 | branchHash, err := cmdOutputErr("git", "rev-parse", "origin/"+branch) 213 | if err != nil { 214 | dief("cannot sync-branch: cannot resolve origin/%s: %v\n%s", branch, err, branchHash) 215 | } 216 | parentHash = trim(parentHash) 217 | branchHash = trim(branchHash) 218 | 219 | // Only --merge-back-to-parent when there's nothing waiting 220 | // to be merged in from parent. If a non-trivial merge needs 221 | // to be done, it should be done first on the dev branch, 222 | // not the parent branch. 223 | if mergeBackToParent { 224 | other := cmdOutput("git", "log", "--format=format:+ %cd %h %s", "--date=short", "origin/"+branch+"..origin/"+parent) 225 | if other != "" { 226 | dief("cannot sync-branch --merge-back-to-parent: parent has new commits.\n"+ 227 | "\trun 'git codereview sync-branch' to bring them into this branch first:\n%s", 228 | other) 229 | } 230 | } 231 | 232 | // Start the merge. 233 | if mergeBackToParent { 234 | // Change HEAD back to "parent" and merge "branch" into it, 235 | // even though we could instead merge "parent" into "branch". 236 | // This way the parent-branch lineage ends up the first parent 237 | // of the merge, the same as it would when we are doing it by hand 238 | // with a plain "git merge". This may help the display of the 239 | // merge graph in some tools more closely reflect what we did. 240 | run("git", "reset", "--hard", "origin/"+parent) 241 | _, err = cmdOutputErr("git", "merge", "--no-ff", "origin/"+branch) 242 | } else { 243 | _, err = cmdOutputErr("git", "merge", "--no-ff", "origin/"+parent) 244 | } 245 | 246 | // Resolve codereview.cfg the right way - never take it from the merge. 247 | // For a regular sync-branch we keep the branch's. 248 | // For a merge-back-to-parent we take the parent's. 249 | // The codereview.cfg contains the branch config and we don't want 250 | // it to change. 251 | what := branchHash 252 | if mergeBackToParent { 253 | what = parentHash 254 | } 255 | cmdOutputDir(repoRoot(), "git", "checkout", what, "--", "codereview.cfg") 256 | 257 | if mergeBackToParent { 258 | syncBranchContinue(syncBranchMergeBackFlag, b, status) 259 | return 260 | } 261 | 262 | if err != nil { 263 | // Check whether the only listed file is codereview.cfg and try again if so. 264 | // Build list of unmerged files. 265 | for _, s := range nonBlankLines(cmdOutputDir(repoRoot(), "git", "status", "-b", "--porcelain")) { 266 | // Unmerged status is anything with a U and also AA and DD. 267 | if len(s) >= 4 && s[2] == ' ' && (s[0] == 'U' || s[1] == 'U' || s[0:2] == "AA" || s[0:2] == "DD") { 268 | status.Conflicts = append(status.Conflicts, s[3:]) 269 | } 270 | } 271 | if len(status.Conflicts) == 0 { 272 | // Must have been codereview.cfg that was the problem. 273 | // Try continuing the merge. 274 | // Note that as of Git 2.12, git merge --continue is a synonym for git commit, 275 | // but older Gits do not have merge --continue. 276 | var out string 277 | out, err = cmdOutputErr("git", "commit", "-m", "TEMPORARY MERGE MESSAGE") 278 | if err != nil { 279 | printf("git commit failed with no apparent unmerged files:\n%s\n", out) 280 | } 281 | } else { 282 | writeSyncBranchStatus(status) 283 | } 284 | } 285 | 286 | if err != nil { 287 | if len(status.Conflicts) == 0 { 288 | dief("cannot sync-branch: git merge failed but no conflicts found\n"+ 289 | "(unexpected error, please ask for help!)\n\ngit status:\n%s\ngit status -b --porcelain:\n%s", 290 | cmdOutputDir(repoRoot(), "git", "status"), 291 | cmdOutputDir(repoRoot(), "git", "status", "-b", "--porcelain")) 292 | } 293 | dief("sync-branch: merge conflicts in:\n\t- %s\n\n"+ 294 | "Please fix them (use 'git status' to see the list again),\n"+ 295 | "then 'git add' or 'git rm' to resolve them,\n"+ 296 | "and then 'git codereview sync-branch -continue' to continue.\n"+ 297 | "Or run 'git merge --abort' to give up on this sync-branch.\n", 298 | strings.Join(status.Conflicts, "\n\t- ")) 299 | } 300 | 301 | syncBranchContinue("", b, status) 302 | } 303 | 304 | func diePendingMerge(cmd string) { 305 | dief("cannot %s: found pending merge\n"+ 306 | "Run 'git codereview sync-branch -continue' if you fixed\n"+ 307 | "merge conflicts after a previous sync-branch operation.\n"+ 308 | "Or run 'git merge --abort' to give up on the sync-branch.\n", 309 | cmd) 310 | } 311 | 312 | func prefixFor(branch string) string { 313 | if strings.HasPrefix(branch, "dev.") || strings.HasPrefix(branch, "release-branch.") { 314 | return "[" + branch + "] " 315 | } 316 | return "" 317 | } 318 | 319 | const ( 320 | syncBranchContinueFlag = " -continue" 321 | syncBranchMergeBackFlag = " -merge-back-to-parent" 322 | ) 323 | 324 | func syncBranchContinue(flag string, b *Branch, status *syncBranchStatus) { 325 | if h := gitHash("origin/" + status.Parent); h != status.ParentHash { 326 | dief("cannot sync-branch%s: parent hash changed: %.7s -> %.7s", flag, status.ParentHash, h) 327 | } 328 | if h := gitHash("origin/" + status.Branch); h != status.BranchHash { 329 | dief("cannot sync-branch%s: branch hash changed: %.7s -> %.7s", flag, status.BranchHash, h) 330 | } 331 | if b.Name != status.Local { 332 | dief("cannot sync-branch%s: branch changed underfoot: %s -> %s", flag, status.Local, b.Name) 333 | } 334 | 335 | var ( 336 | dst = status.Branch 337 | dstHash = status.BranchHash 338 | src = status.Parent 339 | srcHash = status.ParentHash 340 | ) 341 | if flag == syncBranchMergeBackFlag { 342 | // This is a reverse merge: commits are flowing 343 | // in the opposite direction from normal. 344 | dst, src = src, dst 345 | dstHash, srcHash = srcHash, dstHash 346 | } 347 | 348 | prefix := prefixFor(dst) 349 | op := "merge" 350 | if flag == syncBranchMergeBackFlag { 351 | op = "REVERSE MERGE" 352 | } 353 | msg := fmt.Sprintf("%sall: %s %s (%.7s) into %s", prefix, op, src, srcHash, dst) 354 | 355 | if flag == syncBranchContinueFlag { 356 | // Need to commit the merge. 357 | 358 | // Check that the state of the client is the way we left it before any merge conflicts. 359 | mergeHead, err := cmdOutputErr("git", "rev-parse", "MERGE_HEAD") 360 | if err != nil { 361 | dief("cannot sync-branch%s: no pending merge\n"+ 362 | "If you accidentally ran 'git merge --continue' or 'git commit',\n"+ 363 | "then use 'git reset --hard HEAD^' to undo.\n", flag) 364 | } 365 | mergeHead = trim(mergeHead) 366 | if mergeHead != srcHash { 367 | dief("cannot sync-branch%s: MERGE_HEAD is %.7s, but origin/%s is %.7s", flag, mergeHead, src, srcHash) 368 | } 369 | head := gitHash("HEAD") 370 | if head != dstHash { 371 | dief("cannot sync-branch%s: HEAD is %.7s, but origin/%s is %.7s", flag, head, dst, dstHash) 372 | } 373 | 374 | if HasUnstagedChanges() { 375 | dief("cannot sync-branch%s: unstaged changes (unresolved conflicts)\n"+ 376 | "\tUse 'git status' to see them, 'git add' or 'git rm' to resolve them,\n"+ 377 | "\tand then run 'git codereview sync-branch -continue' again.\n", flag) 378 | } 379 | 380 | run("git", "commit", "-m", msg) 381 | } 382 | 383 | // Amend the merge message, which may be auto-generated by git 384 | // or may have been written by us during the post-conflict commit above, 385 | // to use our standard format and list the incorporated CLs. 386 | 387 | // Merge must never sync codereview.cfg, 388 | // because it contains the src and dst config. 389 | // Force the on-dst copy back while amending the commit. 390 | cmdOutputDir(repoRoot(), "git", "checkout", "origin/"+dst, "--", "codereview.cfg") 391 | 392 | conflictMsg := "" 393 | if len(status.Conflicts) > 0 { 394 | conflictMsg = "Conflicts:\n\n- " + strings.Join(status.Conflicts, "\n- ") + "\n\n" 395 | } 396 | 397 | if flag == syncBranchMergeBackFlag { 398 | msg += fmt.Sprintf("\n\n"+ 399 | "This commit is a REVERSE MERGE.\n"+ 400 | "It merges %s back into its parent branch, %s.\n"+ 401 | "This marks the end of development on %s.\n", 402 | status.Branch, status.Parent, status.Branch) 403 | } 404 | 405 | msg += fmt.Sprintf("\n\n%sMerge List:\n\n%s", conflictMsg, 406 | cmdOutput("git", "log", "--format=format:+ %cd %h %s", "--date=short", "HEAD^1..HEAD^2")) 407 | run("git", "commit", "--amend", "-m", msg) 408 | 409 | fmt.Fprintf(stderr(), "\n") 410 | 411 | cmdPending([]string{"-c", "-l"}) 412 | fmt.Fprintf(stderr(), "\n* Merge commit created.\nRun 'git codereview mail' to send for review.\n") 413 | 414 | os.Remove(syncBranchStatusFile()) 415 | } 416 | -------------------------------------------------------------------------------- /git-codereview/util_test.go: -------------------------------------------------------------------------------- 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 | "encoding/json" 10 | "fmt" 11 | "net" 12 | "net/http" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "reflect" 17 | "regexp" 18 | "runtime/debug" 19 | "strings" 20 | "sync" 21 | "testing" 22 | ) 23 | 24 | var gitversion = "unknown git version" // git version for error logs 25 | 26 | type gitTest struct { 27 | pwd string // current directory before test 28 | tmpdir string // temporary directory holding repos 29 | server string // server repo root 30 | client string // client repo root 31 | nwork int // number of calls to work method 32 | nworkServer int // number of calls to serverWork method 33 | nworkOther int // number of calls to serverWorkUnrelated method 34 | } 35 | 36 | // resetReadOnlyFlagAll resets windows read-only flag 37 | // set on path and any children it contains. 38 | // The flag is set by git and has to be removed. 39 | // os.Remove refuses to remove files with read-only flag set. 40 | func resetReadOnlyFlagAll(path string) error { 41 | fi, err := os.Stat(path) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | if !fi.IsDir() { 47 | return os.Chmod(path, 0666) 48 | } 49 | 50 | fd, err := os.Open(path) 51 | if err != nil { 52 | return err 53 | } 54 | defer fd.Close() 55 | 56 | names, _ := fd.Readdirnames(-1) 57 | for _, name := range names { 58 | resetReadOnlyFlagAll(path + string(filepath.Separator) + name) 59 | } 60 | return nil 61 | } 62 | 63 | func (gt *gitTest) done() { 64 | os.Chdir(gt.pwd) // change out of gt.tmpdir first, otherwise following os.RemoveAll fails on windows 65 | resetReadOnlyFlagAll(gt.tmpdir) 66 | os.RemoveAll(gt.tmpdir) 67 | cachedConfig = nil 68 | } 69 | 70 | // doWork simulates commit 'n' touching 'file' in 'dir' 71 | func doWork(t *testing.T, n int, dir, file, changeid string, msg string) { 72 | t.Helper() 73 | write(t, dir+"/"+file, fmt.Sprintf("new content %d", n), 0644) 74 | trun(t, dir, "git", "add", file) 75 | suffix := "" 76 | if n > 1 { 77 | suffix = fmt.Sprintf(" #%d", n) 78 | } 79 | if msg != "" { 80 | msg += "\n\n" 81 | } 82 | cmsg := fmt.Sprintf("%smsg%s\n\nChange-Id: I%d%s\n", msg, suffix, n, changeid) 83 | trun(t, dir, "git", "commit", "-m", cmsg) 84 | } 85 | 86 | func (gt *gitTest) work(t *testing.T) { 87 | t.Helper() 88 | if gt.nwork == 0 { 89 | trun(t, gt.client, "git", "checkout", "-b", "work") 90 | trun(t, gt.client, "git", "branch", "--set-upstream-to", "origin/main") 91 | trun(t, gt.client, "git", "tag", "work") // make sure commands do the right thing when there is a tag of the same name 92 | } 93 | 94 | // make local change on client 95 | gt.nwork++ 96 | doWork(t, gt.nwork, gt.client, "file", "23456789", "") 97 | } 98 | 99 | func (gt *gitTest) workFile(t *testing.T, file string) { 100 | t.Helper() 101 | // make local change on client in the specific file 102 | gt.nwork++ 103 | doWork(t, gt.nwork, gt.client, file, "23456789", "") 104 | } 105 | 106 | func (gt *gitTest) serverWork(t *testing.T) { 107 | t.Helper() 108 | // make change on server 109 | // duplicating the sequence of changes in gt.work to simulate them 110 | // having gone through Gerrit and submitted with possibly 111 | // different commit hashes but the same content. 112 | gt.nworkServer++ 113 | doWork(t, gt.nworkServer, gt.server, "file", "23456789", "") 114 | } 115 | 116 | func (gt *gitTest) serverWorkUnrelated(t *testing.T, msg string) { 117 | t.Helper() 118 | // make unrelated change on server 119 | // this makes history different on client and server 120 | gt.nworkOther++ 121 | doWork(t, gt.nworkOther, gt.server, "otherfile", "9999", msg) 122 | } 123 | 124 | func newGitTest(t *testing.T) (gt *gitTest) { 125 | t.Helper() 126 | // The Linux builders seem not to have git in their paths. 127 | // That makes this whole repo a bit useless on such systems, 128 | // but make sure the tests don't fail. 129 | _, err := exec.LookPath("git") 130 | if err != nil { 131 | t.Skipf("cannot find git in path: %v", err) 132 | } 133 | 134 | tmpdir, err := os.MkdirTemp("", "git-codereview-test") 135 | if err != nil { 136 | t.Fatal(err) 137 | } 138 | defer func() { 139 | if gt == nil { 140 | os.RemoveAll(tmpdir) 141 | } 142 | }() 143 | 144 | gitversion = trun(t, tmpdir, "git", "--version") 145 | 146 | server := tmpdir + "/git-origin" 147 | 148 | mkdir(t, server) 149 | write(t, server+"/file", "this is main", 0644) 150 | write(t, server+"/.gitattributes", "* -text\n", 0644) 151 | trun(t, server, "git", "init", ".") 152 | trun(t, server, "git", "config", "user.name", "gopher") 153 | trun(t, server, "git", "config", "user.email", "gopher@example.com") 154 | trun(t, server, "git", "add", "file", ".gitattributes") 155 | trun(t, server, "git", "commit", "-m", "initial commit") 156 | 157 | // Newer gits use a default branch name of main. 158 | // Older ones used master. 159 | // So the branch name now may be main or master. 160 | // We would like to assume main for the tests. 161 | // Newer gits would let us do 162 | // git init --initial-branch=main . 163 | // above, but older gits don't have initial-branch. 164 | // And we don't trust older gits to handle a no-op branch rename. 165 | // So rename it to something different, and then to main. 166 | // Then we'll be in a known state. 167 | trun(t, server, "git", "branch", "-M", "certainly-not-main") 168 | trun(t, server, "git", "branch", "-M", "main") 169 | 170 | for _, name := range []string{"dev.branch", "release.branch"} { 171 | trun(t, server, "git", "checkout", "main") 172 | trun(t, server, "git", "checkout", "-b", name) 173 | write(t, server+"/file."+name, "this is "+name, 0644) 174 | cfg := "branch: " + name + "\n" 175 | if name == "dev.branch" { 176 | cfg += "parent-branch: main\n" 177 | } 178 | write(t, server+"/codereview.cfg", cfg, 0644) 179 | trun(t, server, "git", "add", "file."+name, "codereview.cfg") 180 | trun(t, server, "git", "commit", "-m", "on "+name) 181 | } 182 | trun(t, server, "git", "checkout", "main") 183 | 184 | client := tmpdir + "/git-client" 185 | mkdir(t, client) 186 | trun(t, client, "git", "clone", server, ".") 187 | trun(t, client, "git", "config", "user.name", "gopher") 188 | trun(t, client, "git", "config", "user.email", "gopher@example.com") 189 | 190 | // write stub hooks to keep installHook from installing its own. 191 | // If it installs its own, git will look for git-codereview on the current path 192 | // and may find an old git-codereview that does just about anything. 193 | // In any event, we wouldn't be testing what we want to test. 194 | // Tests that want to exercise hooks need to arrange for a git-codereview 195 | // in the path and replace these with the real ones. 196 | if _, err := os.Stat(client + "/.git/hooks"); os.IsNotExist(err) { 197 | mkdir(t, client+"/.git/hooks") 198 | } 199 | for _, h := range hookFiles { 200 | write(t, client+"/.git/hooks/"+h, "#!/bin/sh\nexit 0\n", 0755) 201 | } 202 | 203 | trun(t, client, "git", "config", "core.editor", "false") 204 | pwd, err := os.Getwd() 205 | if err != nil { 206 | t.Fatal(err) 207 | } 208 | 209 | if err := os.Chdir(client); err != nil { 210 | t.Fatal(err) 211 | } 212 | 213 | return &gitTest{ 214 | pwd: pwd, 215 | tmpdir: tmpdir, 216 | server: server, 217 | client: client, 218 | } 219 | } 220 | 221 | func (gt *gitTest) enableGerrit(t *testing.T) { 222 | t.Helper() 223 | write(t, gt.server+"/codereview.cfg", "gerrit: myserver\n", 0644) 224 | trun(t, gt.server, "git", "add", "codereview.cfg") 225 | trun(t, gt.server, "git", "commit", "-m", "add gerrit") 226 | trun(t, gt.client, "git", "pull", "-r") 227 | } 228 | 229 | func (gt *gitTest) removeStubHooks() { 230 | os.RemoveAll(gt.client + "/.git/hooks/") 231 | } 232 | 233 | func mkdir(t *testing.T, dir string) { 234 | if err := os.Mkdir(dir, 0777); err != nil { 235 | t.Helper() 236 | t.Fatal(err) 237 | } 238 | } 239 | 240 | func chdir(t *testing.T, dir string) { 241 | if err := os.Chdir(dir); err != nil { 242 | t.Helper() 243 | t.Fatal(err) 244 | } 245 | } 246 | 247 | func write(t *testing.T, file, data string, perm os.FileMode) { 248 | if err := os.WriteFile(file, []byte(data), perm); err != nil { 249 | t.Helper() 250 | t.Fatal(err) 251 | } 252 | } 253 | 254 | func read(t *testing.T, file string) []byte { 255 | b, err := os.ReadFile(file) 256 | if err != nil { 257 | t.Helper() 258 | t.Fatal(err) 259 | } 260 | return b 261 | } 262 | 263 | func remove(t *testing.T, file string) { 264 | if err := os.RemoveAll(file); err != nil { 265 | t.Helper() 266 | t.Fatal(err) 267 | } 268 | } 269 | 270 | func trun(t *testing.T, dir string, cmdline ...string) string { 271 | cmd := exec.Command(cmdline[0], cmdline[1:]...) 272 | cmd.Dir = dir 273 | setEnglishLocale(cmd) 274 | out, err := cmd.CombinedOutput() 275 | if err != nil { 276 | t.Helper() 277 | if cmdline[0] == "git" { 278 | t.Fatalf("in %s/, ran %s with %s:\n%v\n%s", filepath.Base(dir), cmdline, gitversion, err, out) 279 | } 280 | t.Fatalf("in %s/, ran %s: %v\n%s", filepath.Base(dir), cmdline, err, out) 281 | } 282 | return string(out) 283 | } 284 | 285 | // fromSlash is like filepath.FromSlash, but it ignores ! at the start of the path 286 | // and " (staged)" at the end. 287 | func fromSlash(path string) string { 288 | if len(path) > 0 && path[0] == '!' { 289 | return "!" + fromSlash(path[1:]) 290 | } 291 | if strings.HasSuffix(path, " (staged)") { 292 | return fromSlash(path[:len(path)-len(" (staged)")]) + " (staged)" 293 | } 294 | return filepath.FromSlash(path) 295 | } 296 | 297 | var ( 298 | runLog []string 299 | testStderr *bytes.Buffer 300 | testStdout *bytes.Buffer 301 | died bool 302 | mainCanDie bool 303 | ) 304 | 305 | func testMainDied(t *testing.T, args ...string) { 306 | t.Helper() 307 | mainCanDie = true 308 | testMain(t, args...) 309 | if !died { 310 | t.Fatalf("expected to die, did not\nstdout:\n%sstderr:\n%s", testStdout, testStderr) 311 | } 312 | } 313 | 314 | func testMain(t *testing.T, args ...string) { 315 | t.Helper() 316 | *noRun = false 317 | *verbose = 0 318 | cachedConfig = nil 319 | 320 | t.Logf("git-codereview %s", strings.Join(args, " ")) 321 | 322 | canDie := mainCanDie 323 | mainCanDie = false // reset for next invocation 324 | 325 | defer func() { 326 | t.Helper() 327 | 328 | runLog = runLogTrap 329 | testStdout = stdoutTrap 330 | testStderr = stderrTrap 331 | 332 | exitTrap = nil 333 | runLogTrap = nil 334 | stdoutTrap = nil 335 | stderrTrap = nil 336 | if err := recover(); err != nil { 337 | if died && canDie { 338 | return 339 | } 340 | var msg string 341 | if died { 342 | msg = "died" 343 | } else { 344 | msg = fmt.Sprintf("panic: %v\n%s", err, debug.Stack()) 345 | } 346 | t.Fatalf("%s\nstdout:\n%sstderr:\n%s", msg, testStdout, testStderr) 347 | } 348 | 349 | if testStdout.Len() > 0 { 350 | t.Logf("stdout:\n%s", testStdout) 351 | } 352 | if testStderr.Len() > 0 { 353 | t.Logf("stderr:\n%s", testStderr) 354 | } 355 | }() 356 | 357 | exitTrap = func() { 358 | died = true 359 | panic("died") 360 | } 361 | died = false 362 | runLogTrap = []string{} // non-nil, to trigger saving of commands 363 | stdoutTrap = new(bytes.Buffer) 364 | stderrTrap = new(bytes.Buffer) 365 | 366 | os.Args = append([]string{"git-codereview"}, args...) 367 | main() 368 | } 369 | 370 | func testRan(t *testing.T, cmds ...string) { 371 | if cmds == nil { 372 | cmds = []string{} 373 | } 374 | if !reflect.DeepEqual(runLog, cmds) { 375 | t.Helper() 376 | t.Errorf("ran:\n%s", strings.Join(runLog, "\n")) 377 | t.Errorf("wanted:\n%s", strings.Join(cmds, "\n")) 378 | } 379 | } 380 | 381 | func testPrinted(t *testing.T, buf *bytes.Buffer, name string, messages ...string) { 382 | all := buf.String() 383 | var errors bytes.Buffer 384 | for _, msg := range messages { 385 | if strings.HasPrefix(msg, "!") { 386 | if strings.Contains(all, msg[1:]) { 387 | fmt.Fprintf(&errors, "%s does (but should not) contain %q\n", name, msg[1:]) 388 | } 389 | continue 390 | } 391 | if !strings.Contains(all, msg) { 392 | fmt.Fprintf(&errors, "%s does not contain %q\n", name, msg) 393 | } 394 | } 395 | if errors.Len() > 0 { 396 | t.Helper() 397 | t.Fatalf("wrong output\n%s%s:\n%s", &errors, name, all) 398 | } 399 | } 400 | 401 | func testHideRevHashes(t *testing.T) { 402 | for _, b := range []*bytes.Buffer{testStdout, testStderr} { 403 | out := b.Bytes() 404 | out = regexp.MustCompile(`\b[0-9a-f]{7}\b`).ReplaceAllLiteral(out, []byte("REVHASH")) 405 | out = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`).ReplaceAllLiteral(out, []byte("DATE")) 406 | b.Reset() 407 | b.Write(out) 408 | } 409 | } 410 | 411 | func testPrintedStdout(t *testing.T, messages ...string) { 412 | t.Helper() 413 | testPrinted(t, testStdout, "stdout", messages...) 414 | } 415 | 416 | func testPrintedStderr(t *testing.T, messages ...string) { 417 | t.Helper() 418 | testPrinted(t, testStderr, "stderr", messages...) 419 | } 420 | 421 | func testNoStdout(t *testing.T) { 422 | if testStdout.Len() != 0 { 423 | t.Helper() 424 | t.Fatalf("unexpected stdout:\n%s", testStdout) 425 | } 426 | } 427 | 428 | func testNoStderr(t *testing.T) { 429 | if testStderr.Len() != 0 { 430 | t.Helper() 431 | t.Fatalf("unexpected stderr:\n%s", testStderr) 432 | } 433 | } 434 | 435 | type gerritServer struct { 436 | l net.Listener 437 | mu sync.Mutex 438 | reply map[string]gerritReply 439 | } 440 | 441 | func newGerritServer(t *testing.T) *gerritServer { 442 | l, err := net.Listen("tcp", "127.0.0.1:0") 443 | if err != nil { 444 | t.Helper() 445 | t.Fatalf("starting fake gerrit: %v", err) 446 | } 447 | 448 | auth.initialized = true 449 | auth.host = l.Addr().String() 450 | auth.url = "http://" + auth.host 451 | auth.project = "proj" 452 | auth.user = "gopher" 453 | auth.password = "PASSWORD" 454 | 455 | s := &gerritServer{l: l, reply: make(map[string]gerritReply)} 456 | go http.Serve(l, s) 457 | return s 458 | } 459 | 460 | func (s *gerritServer) done() { 461 | s.l.Close() 462 | auth.initialized = false 463 | auth.host = "" 464 | auth.url = "" 465 | auth.project = "" 466 | auth.user = "" 467 | auth.password = "" 468 | } 469 | 470 | type gerritReply struct { 471 | status int 472 | body string 473 | json interface{} 474 | f func() gerritReply 475 | } 476 | 477 | func (s *gerritServer) setReply(path string, reply gerritReply) { 478 | s.mu.Lock() 479 | defer s.mu.Unlock() 480 | s.reply[path] = reply 481 | } 482 | 483 | func (s *gerritServer) setJSON(id, json string) { 484 | s.setReply("/a/changes/proj~main~"+id, gerritReply{body: ")]}'\n" + json}) 485 | } 486 | 487 | func (s *gerritServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { 488 | if req.URL.Path == "/a/changes/" { 489 | s.serveChangesQuery(w, req) 490 | return 491 | } 492 | s.mu.Lock() 493 | defer s.mu.Unlock() 494 | reply, ok := s.reply[req.URL.Path] 495 | if !ok { 496 | http.NotFound(w, req) 497 | return 498 | } 499 | if reply.f != nil { 500 | reply = reply.f() 501 | } 502 | if reply.status != 0 { 503 | w.WriteHeader(reply.status) 504 | } 505 | if reply.json != nil { 506 | body, err := json.Marshal(reply.json) 507 | if err != nil { 508 | dief("%v", err) 509 | } 510 | reply.body = ")]}'\n" + string(body) 511 | } 512 | if len(reply.body) > 0 { 513 | w.Write([]byte(reply.body)) 514 | } 515 | } 516 | 517 | func (s *gerritServer) serveChangesQuery(w http.ResponseWriter, req *http.Request) { 518 | s.mu.Lock() 519 | defer s.mu.Unlock() 520 | qs := req.URL.Query()["q"] 521 | if len(qs) > 10 { 522 | http.Error(w, "too many queries", 500) 523 | } 524 | var buf bytes.Buffer 525 | fmt.Fprintf(&buf, ")]}'\n") 526 | end := "" 527 | if len(qs) > 1 { 528 | fmt.Fprintf(&buf, "[") 529 | end = "]" 530 | } 531 | sep := "" 532 | for _, q := range qs { 533 | fmt.Fprintf(&buf, "%s[", sep) 534 | if strings.HasPrefix(q, "change:") { 535 | reply, ok := s.reply[req.URL.Path+strings.TrimPrefix(q, "change:")] 536 | if ok { 537 | if reply.json != nil { 538 | body, err := json.Marshal(reply.json) 539 | if err != nil { 540 | dief("%v", err) 541 | } 542 | reply.body = ")]}'\n" + string(body) 543 | } 544 | body := reply.body 545 | i := strings.Index(body, "\n") 546 | if i > 0 { 547 | body = body[i+1:] 548 | } 549 | fmt.Fprintf(&buf, "%s", body) 550 | } 551 | } 552 | fmt.Fprintf(&buf, "]") 553 | sep = "," 554 | } 555 | fmt.Fprintf(&buf, "%s", end) 556 | w.Write(buf.Bytes()) 557 | } 558 | 559 | func TestUsage(t *testing.T) { 560 | gt := newGitTest(t) 561 | defer gt.done() 562 | 563 | testMainDied(t) 564 | testPrintedStderr(t, "Usage: git-codereview ") 565 | 566 | testMainDied(t, "not-a-command") 567 | testPrintedStderr(t, "Usage: git-codereview ") 568 | 569 | // During tests we configure the flag package to panic on error 570 | // instead of 571 | testMainDied(t, "mail", "-not-a-flag") 572 | testPrintedStderr(t, "flag provided but not defined") 573 | } 574 | -------------------------------------------------------------------------------- /git-codereview/pending.go: -------------------------------------------------------------------------------- 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 | "fmt" 10 | "io" 11 | "net/http" 12 | "slices" 13 | "sort" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | var ( 19 | pendingLocal bool // -l flag, use only local operations (no network) 20 | pendingCurrentOnly bool // -c flag, show only current branch 21 | pendingShort bool // -s flag, short display 22 | pendingGerrit bool // -g flag, Gerrit based short display 23 | ) 24 | 25 | // A pendingBranch collects information about a single pending branch. 26 | // We overlap the reading of this information for each branch. 27 | type pendingBranch struct { 28 | *Branch // standard Branch functionality 29 | current bool // is this the current branch? 30 | staged []string // files in staging area, only if current==true 31 | unstaged []string // files unstaged in local directory, only if current==true 32 | untracked []string // files untracked in local directory, only if current==true 33 | } 34 | 35 | // load populates b with information about the branch. 36 | func (b *pendingBranch) load() { 37 | b.loadPending() 38 | if !b.current && b.commitsAhead == 0 { 39 | // Won't be displayed, don't bother looking any closer. 40 | return 41 | } 42 | b.OriginBranch() // cache result 43 | if b.current { 44 | b.staged, b.unstaged, b.untracked = LocalChanges() 45 | } 46 | var changeIDs []string 47 | var commits []*Commit 48 | for _, c := range b.Pending() { 49 | c.committed = ListFiles(c) 50 | if c.ChangeID == "" { 51 | c.gerr = fmt.Errorf("missing Change-Id in commit message") 52 | } else { 53 | changeIDs = append(changeIDs, fullChangeID(b.Branch, c)) 54 | commits = append(commits, c) 55 | } 56 | } 57 | if !pendingLocal { 58 | gs, err := b.GerritChanges(changeIDs, "DETAILED_LABELS", "CURRENT_REVISION", "MESSAGES", "DETAILED_ACCOUNTS") 59 | if len(gs) != len(commits) && err == nil { 60 | err = fmt.Errorf("invalid response from Gerrit server - %d queries but %d results", len(changeIDs), len(gs)) 61 | } 62 | if err != nil { 63 | for _, c := range commits { 64 | if c.gerr != nil { 65 | c.gerr = err 66 | } 67 | } 68 | } else { 69 | for i, c := range commits { 70 | if len(gs[i]) == 1 { 71 | c.g = gs[i][0] 72 | } 73 | } 74 | } 75 | } 76 | for _, c := range b.Pending() { 77 | if c.g == nil { 78 | c.g = new(GerritChange) // easier for formatting code 79 | } 80 | } 81 | } 82 | 83 | func cmdPending(args []string) { 84 | // NOTE: New flags should be added to the usage message below as well as doc.go. 85 | flags.BoolVar(&pendingCurrentOnly, "c", false, "show only current branch") 86 | flags.BoolVar(&pendingLocal, "l", false, "use only local information - no network operations") 87 | flags.BoolVar(&pendingShort, "s", false, "show short listing (may not be used with -g)") 88 | flags.BoolVar(&pendingGerrit, "g", false, "show a different Gerrit-based listing (may not be used with -s)") 89 | flags.Parse(args) 90 | if len(flags.Args()) > 0 { 91 | fmt.Fprintf(stderr(), "Usage: %s pending %s [-c] [-g] [-l] [-s]\n", progName, globalFlags) 92 | exit(2) 93 | } 94 | 95 | if pendingShort && pendingGerrit { 96 | fmt.Fprintf(stderr(), "%s: using -g and -s together is not supported\n", progName) 97 | exit(2) 98 | } 99 | 100 | // Fetch info about remote changes, so that we can say which branches need sync. 101 | doneFetch := make(chan bool, 1) 102 | if pendingLocal { 103 | doneFetch <- true 104 | } else { 105 | http.DefaultClient.Timeout = 60 * time.Second 106 | go func() { 107 | run("git", "fetch", "-q") 108 | doneFetch <- true 109 | }() 110 | } 111 | 112 | // Build list of pendingBranch structs to be filled in. 113 | // The current branch is always first. 114 | var branches []*pendingBranch 115 | branches = []*pendingBranch{{Branch: CurrentBranch(), current: true}} 116 | if !pendingCurrentOnly { 117 | current := CurrentBranch().Name 118 | for _, b := range LocalBranches() { 119 | if b.Name != current { 120 | branches = append(branches, &pendingBranch{Branch: b}) 121 | } 122 | } 123 | } 124 | 125 | // The various data gathering is a little slow, 126 | // especially run in serial with a lot of branches. 127 | // Overlap inspection of multiple branches. 128 | // Each branch is only accessed by a single worker. 129 | 130 | // Build work queue. 131 | work := make(chan *pendingBranch, len(branches)) 132 | done := make(chan bool, len(branches)) 133 | for _, b := range branches { 134 | work <- b 135 | } 136 | close(work) 137 | 138 | // Kick off goroutines to do work. 139 | n := len(branches) 140 | if n > 10 { 141 | n = 10 142 | } 143 | for i := 0; i < n; i++ { 144 | go func() { 145 | for b := range work { 146 | // This b.load may be using a stale origin/master ref, which is OK. 147 | b.load() 148 | done <- true 149 | } 150 | }() 151 | } 152 | 153 | // Wait for goroutines to finish. 154 | // Note: Counting work items, not goroutines (there may be fewer goroutines). 155 | for range branches { 156 | <-done 157 | } 158 | <-doneFetch 159 | 160 | if !pendingGerrit { 161 | printPendingStandard(branches) 162 | } else { 163 | printPendingGerrit(branches) 164 | } 165 | } 166 | 167 | // printPendingStandard prints the default output format, 168 | // as modified by pendingShort. 169 | func printPendingStandard(branches []*pendingBranch) { 170 | // Print output. 171 | // If there are multiple changes in the current branch, the output splits them out into separate sections, 172 | // in reverse commit order, to match git log output. 173 | // 174 | // wbshadow 7a524a1..a496c1e (current branch, all mailed, 23 behind, tracking master) 175 | // + uncommitted changes 176 | // Files unstaged: 177 | // src/runtime/proc1.go 178 | // 179 | // + a496c1e https://go-review.googlesource.com/2064 (mailed) 180 | // runtime: add missing write barriers in append's copy of slice data 181 | // 182 | // Found with GODEBUG=wbshadow=1 mode. 183 | // Eventually that will run automatically, but right now 184 | // it still detects other missing write barriers. 185 | // 186 | // Change-Id: Ic8624401d7c8225a935f719f96f2675c6f5c0d7c 187 | // 188 | // Code-Review: 189 | // +0 Austin Clements, Rick Hudson 190 | // Files in this change: 191 | // src/runtime/slice.go 192 | // 193 | // + 95390c7 https://go-review.googlesource.com/2061 (mailed) 194 | // runtime: add GODEBUG wbshadow for finding missing write barriers 195 | // 196 | // This is the detection code. It works well enough that I know of 197 | // a handful of missing write barriers. However, those are subtle 198 | // enough that I'll address them in separate followup CLs. 199 | // 200 | // Change-Id: If863837308e7c50d96b5bdc7d65af4969bf53a6e 201 | // 202 | // Code-Review: 203 | // +0 Austin Clements, Rick Hudson 204 | // Files in this change: 205 | // src/runtime/extern.go 206 | // src/runtime/malloc1.go 207 | // src/runtime/malloc2.go 208 | // src/runtime/mgc.go 209 | // src/runtime/mgc0.go 210 | // src/runtime/proc1.go 211 | // src/runtime/runtime1.go 212 | // src/runtime/runtime2.go 213 | // src/runtime/stack1.go 214 | // 215 | // The first line only gives information that applies to the entire branch: 216 | // the name, the commit range, whether this is the current branch, whether 217 | // all the commits are mailed/submitted, how far behind, what remote branch 218 | // it is tracking. 219 | // The individual change sections have per-change information: the hash of that 220 | // commit, the URL on the Gerrit server, whether it is mailed/submitted, the list of 221 | // files in that commit. The uncommitted file modifications are shown as a separate 222 | // section, at the beginning, to fit better into the reverse commit order. 223 | // 224 | // The short view compresses the listing down to two lines per commit: 225 | // wbshadow 7a524a1..a496c1e (current branch, all mailed, 23 behind, tracking master) 226 | // + uncommitted changes 227 | // Files unstaged: 228 | // src/runtime/proc1.go 229 | // + a496c1e runtime: add missing write barriers in append's copy of slice data (CL 2064, mailed) 230 | // + 95390c7 runtime: add GODEBUG wbshadow for finding missing write barriers (CL 2061, mailed) 231 | 232 | var buf bytes.Buffer 233 | printFileList := func(name string, list []string) { 234 | if len(list) == 0 { 235 | return 236 | } 237 | fmt.Fprintf(&buf, "\tFiles %s:\n", name) 238 | for _, file := range list { 239 | fmt.Fprintf(&buf, "\t\t%s\n", file) 240 | } 241 | } 242 | 243 | for _, b := range branches { 244 | if !b.current && b.commitsAhead == 0 { 245 | // Hide branches with no work on them. 246 | continue 247 | } 248 | 249 | fmt.Fprintf(&buf, "%s", b.Name) 250 | work := b.Pending() 251 | if len(work) > 0 { 252 | fmt.Fprintf(&buf, " %.7s..%s", b.branchpoint, work[0].ShortHash) 253 | } 254 | var tags []string 255 | if b.DetachedHead() { 256 | tags = append(tags, "detached") 257 | } else if b.current { 258 | tags = append(tags, "current branch") 259 | } 260 | if allMailed(work) && len(work) > 0 { 261 | tags = append(tags, "all mailed") 262 | } 263 | if allSubmitted(work) && len(work) > 0 { 264 | tags = append(tags, "all submitted") 265 | } 266 | if n := b.CommitsBehind(); n > 0 { 267 | tags = append(tags, fmt.Sprintf("%d behind", n)) 268 | } 269 | if br := b.OriginBranch(); br == "" { 270 | tags = append(tags, "remote branch unknown") 271 | } else if br != "origin/master" && br != "origin/main" { 272 | tags = append(tags, "tracking "+strings.TrimPrefix(b.OriginBranch(), "origin/")) 273 | } 274 | if len(tags) > 0 { 275 | fmt.Fprintf(&buf, " (%s)", strings.Join(tags, ", ")) 276 | } 277 | fmt.Fprintf(&buf, "\n") 278 | printed := false 279 | 280 | if b.current && len(b.staged)+len(b.unstaged)+len(b.untracked) > 0 { 281 | printed = true 282 | fmt.Fprintf(&buf, "+ uncommitted changes\n") 283 | printFileList("untracked", b.untracked) 284 | printFileList("unstaged", b.unstaged) 285 | printFileList("staged", b.staged) 286 | if !pendingShort { 287 | fmt.Fprintf(&buf, "\n") 288 | } 289 | } 290 | 291 | for _, c := range work { 292 | printed = true 293 | fmt.Fprintf(&buf, "+ ") 294 | formatCommit(&buf, c, pendingShort) 295 | if !pendingShort { 296 | printFileList("in this change", c.committed) 297 | fmt.Fprintf(&buf, "\n") 298 | } 299 | } 300 | if pendingShort || !printed { 301 | fmt.Fprintf(&buf, "\n") 302 | } 303 | } 304 | 305 | stdout().Write(buf.Bytes()) 306 | } 307 | 308 | // formatCommit writes detailed information about c to w. c.g must 309 | // have the "CURRENT_REVISION" (or "ALL_REVISIONS") and 310 | // "DETAILED_LABELS" options set. 311 | // 312 | // If short is true, this writes a single line overview. 313 | // 314 | // If short is false, this writes detailed information about the 315 | // commit and its Gerrit state. 316 | func formatCommit(w io.Writer, c *Commit, short bool) { 317 | g := c.g 318 | if g == nil { 319 | g = new(GerritChange) 320 | } 321 | msg := strings.TrimRight(c.Message, "\r\n") 322 | fmt.Fprintf(w, "%s", c.ShortHash) 323 | var tags []string 324 | if short { 325 | if i := strings.Index(msg, "\n"); i >= 0 { 326 | msg = msg[:i] 327 | } 328 | fmt.Fprintf(w, " %s", msg) 329 | if g.Number != 0 { 330 | tags = append(tags, fmt.Sprintf("CL %d%s", g.Number, codeReviewScores(g))) 331 | } 332 | } else { 333 | if g.Number != 0 { 334 | fmt.Fprintf(w, " %s/%d", auth.url, g.Number) 335 | } 336 | } 337 | if g.CurrentRevision == c.Hash { 338 | tags = append(tags, "mailed") 339 | } 340 | switch g.Status { 341 | case "MERGED": 342 | tags = append(tags, "submitted") 343 | case "ABANDONED": 344 | tags = append(tags, "abandoned") 345 | } 346 | if len(c.Parents) > 1 { 347 | var h []string 348 | for _, p := range c.Parents[1:] { 349 | h = append(h, p[:7]) 350 | } 351 | tags = append(tags, "merge="+strings.Join(h, ",")) 352 | } 353 | if g.UnresolvedCommentCount > 0 { 354 | tags = append(tags, fmt.Sprintf("%d unresolved comments", g.UnresolvedCommentCount)) 355 | } 356 | if len(tags) > 0 { 357 | fmt.Fprintf(w, " (%s)", strings.Join(tags, ", ")) 358 | } 359 | fmt.Fprintf(w, "\n") 360 | if short { 361 | return 362 | } 363 | 364 | fmt.Fprintf(w, "\t%s\n", strings.Replace(msg, "\n", "\n\t", -1)) 365 | fmt.Fprintf(w, "\n") 366 | 367 | for _, name := range g.LabelNames() { 368 | label := g.Labels[name] 369 | minValue := 10000 370 | maxValue := -10000 371 | byScore := map[int][]string{} 372 | for _, x := range label.All { 373 | // Hide CL owner unless owner score is nonzero. 374 | if g.Owner != nil && x.ID == g.Owner.ID && x.Value == 0 { 375 | continue 376 | } 377 | byScore[x.Value] = append(byScore[x.Value], x.Name) 378 | if minValue > x.Value { 379 | minValue = x.Value 380 | } 381 | if maxValue < x.Value { 382 | maxValue = x.Value 383 | } 384 | } 385 | // Unless there are scores to report, do not show labels other than Code-Review. 386 | // This hides Run-TryBot and TryBot-Result. 387 | if minValue >= 0 && maxValue <= 0 && name != "Code-Review" { 388 | continue 389 | } 390 | fmt.Fprintf(w, "\t%s:\n", name) 391 | for score := maxValue; score >= minValue; score-- { 392 | who := byScore[score] 393 | if len(who) == 0 || score == 0 && name != "Code-Review" { 394 | continue 395 | } 396 | sort.Strings(who) 397 | fmt.Fprintf(w, "\t\t%+d %s\n", score, strings.Join(who, ", ")) 398 | } 399 | } 400 | } 401 | 402 | // codeReviewScores reports the code review scores as tags for the short output. 403 | // 404 | // g must have the "DETAILED_LABELS" option set. 405 | func codeReviewScores(g *GerritChange) string { 406 | label := g.Labels["Code-Review"] 407 | if label == nil { 408 | return "" 409 | } 410 | minValue := 10000 411 | maxValue := -10000 412 | for _, x := range label.All { 413 | if minValue > x.Value { 414 | minValue = x.Value 415 | } 416 | if maxValue < x.Value { 417 | maxValue = x.Value 418 | } 419 | } 420 | var scores string 421 | if minValue < 0 { 422 | scores += fmt.Sprintf(" %d", minValue) 423 | } 424 | if maxValue > 0 { 425 | scores += fmt.Sprintf(" %+d", maxValue) 426 | } 427 | return scores 428 | } 429 | 430 | // printPendingGerrit prints the Gerrit-based format. 431 | // This prints only CLs that have been fully sent to Gerrit, 432 | // with their CL number and the topic line. 433 | func printPendingGerrit(branches []*pendingBranch) { 434 | type branchBuf struct { 435 | name string 436 | buf bytes.Buffer 437 | updated time.Time 438 | } 439 | var branchBufs []*branchBuf 440 | 441 | for _, b := range branches { 442 | if b.commitsAhead == 0 { 443 | continue 444 | } 445 | 446 | work := b.Pending() 447 | 448 | if len(work) == 0 { 449 | continue 450 | } 451 | 452 | var updatedStr string 453 | for _, c := range work { 454 | if c.g.Updated != "" { 455 | updatedStr = c.g.Updated 456 | break 457 | } 458 | if c.g.Created != "" { 459 | updatedStr = c.g.Created 460 | break 461 | } 462 | } 463 | 464 | var updated time.Time 465 | if updatedStr != "" { 466 | var err error 467 | updated, err = time.Parse("2006-01-02 15:04:05.999999999", updatedStr) 468 | if err != nil { 469 | fmt.Fprintf(stderr(), "failed to parse gerrit timestamp %q: %v\n", updatedStr, err) 470 | } 471 | } 472 | 473 | bb := &branchBuf{ 474 | name: b.Name, 475 | updated: updated, 476 | } 477 | branchBufs = append(branchBufs, bb) 478 | 479 | if allSubmitted(work) { 480 | fmt.Fprintf(&bb.buf, "- branch %s submitted\n", b.Name) 481 | continue 482 | } 483 | if allAbandoned(work) { 484 | fmt.Fprintf(&bb.buf, "- branch %s abandoned\n", b.Name) 485 | continue 486 | } 487 | 488 | fmt.Fprintf(&bb.buf, "- branch %s updated %s\n", b.Name, updated) 489 | for _, c := range work { 490 | if c.g.Number == 0 { 491 | continue 492 | } 493 | fmt.Fprintf(&bb.buf, " https://go.dev/cl/%d %s\n", c.g.Number, c.g.Subject) 494 | } 495 | } 496 | 497 | slices.SortFunc(branchBufs, func(a, b *branchBuf) int { 498 | if r := a.updated.Compare(b.updated); r != 0 { 499 | return r 500 | } 501 | return strings.Compare(a.name, b.name) 502 | }) 503 | 504 | for _, bb := range branchBufs { 505 | stdout().Write(bb.buf.Bytes()) 506 | } 507 | } 508 | 509 | // allMailed reports whether all commits in work have been posted to Gerrit. 510 | func allMailed(work []*Commit) bool { 511 | for _, c := range work { 512 | if c.Hash != c.g.CurrentRevision { 513 | return false 514 | } 515 | } 516 | return true 517 | } 518 | 519 | // allSubmitted reports whether all commits in work have been submitted to the origin branch. 520 | func allSubmitted(work []*Commit) bool { 521 | for _, c := range work { 522 | if c.g.Status != "MERGED" { 523 | return false 524 | } 525 | } 526 | return true 527 | } 528 | 529 | // allAbandoned reports whether all commits in work have been abandoned. 530 | func allAbandoned(work []*Commit) bool { 531 | for _, c := range work { 532 | if c.g.Status != "ABANDONED" { 533 | return false 534 | } 535 | } 536 | return true 537 | } 538 | 539 | // suffix returns an empty string if n == 1, s otherwise. 540 | func suffix(n int, s string) string { 541 | if n == 1 { 542 | return "" 543 | } 544 | return s 545 | } 546 | --------------------------------------------------------------------------------