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
5package main
6
7import (
8	"fmt"
9	"os"
10	"path/filepath"
11	"regexp"
12	"strconv"
13	"strings"
14)
15
16var commitMsg string
17var changeAuto bool
18var changeQuick bool
19
20func cmdChange(args []string) {
21	// NOTE: New flags should be added to the usage message below as well as doc.go.
22	flags.StringVar(&commitMsg, "m", "", "specify a commit message")
23	flags.BoolVar(&changeAuto, "a", false, "add changes to any tracked files")
24	flags.BoolVar(&changeQuick, "q", false, "do not edit pending commit msg")
25	flags.Parse(args)
26	if len(flags.Args()) > 1 {
27		fmt.Fprintf(stderr(), "Usage: %s change %s [-a] [-m msg] [-q] [branch]\n", progName, globalFlags)
28		exit(2)
29	}
30
31	if _, err := cmdOutputErr("git", "rev-parse", "--abbrev-ref", "MERGE_HEAD"); err == nil {
32		diePendingMerge("change")
33	}
34	// Note: A rebase with a conflict + rebase --continue sometimes leaves behind REBASE_HEAD.
35	// So check for the rebase-merge directory instead, which it does a better job cleaning up.
36	if _, err := os.Stat(filepath.Join(gitPathDir(), "rebase-merge")); err == nil {
37		dief("cannot change: found pending rebase or sync")
38	}
39
40	// Checkout or create branch, if specified.
41	target := flags.Arg(0)
42	if target != "" {
43		checkoutOrCreate(target)
44		b := CurrentBranch()
45		if HasStagedChanges() && !b.HasPendingCommit() {
46			commitChanges(false)
47		}
48		b.check()
49		return
50	}
51
52	// Create or amend change commit.
53	b := CurrentBranch()
54	amend := b.HasPendingCommit()
55	if amend {
56		// Dies if there is not exactly one commit.
57		b.DefaultCommit("amend change", "")
58	}
59	commitChanges(amend)
60	b.loadedPending = false // force reload after commitChanges
61	b.check()
62}
63
64func (b *Branch) check() {
65	staged, unstaged, _ := LocalChanges()
66	if len(staged) == 0 && len(unstaged) == 0 {
67		// No staged changes, no unstaged changes.
68		// If the branch is behind upstream, now is a good time to point that out.
69		// This applies to both local work branches and tracking branches.
70		b.loadPending()
71		if n := b.CommitsBehind(); n > 0 {
72			printf("warning: %d commit%s behind %s; run 'git codereview sync' to update.", n, suffix(n, "s"), b.OriginBranch())
73		}
74	}
75}
76
77var testCommitMsg string
78
79func commitChanges(amend bool) {
80	// git commit will run the gofmt hook.
81	// Run it now to give a better error (won't show a git commit command failing).
82	hookGofmt()
83
84	if HasUnstagedChanges() && !HasStagedChanges() && !changeAuto {
85		printf("warning: unstaged changes and no staged changes; use 'git add' or 'git change -a'")
86	}
87	commit := func(amend bool) {
88		args := []string{"commit", "-q", "--allow-empty"}
89		if amend {
90			args = append(args, "--amend")
91			if changeQuick {
92				args = append(args, "--no-edit")
93			}
94		}
95		if commitMsg != "" {
96			args = append(args, "-m", commitMsg)
97		} else if testCommitMsg != "" {
98			args = append(args, "-m", testCommitMsg)
99		}
100		if changeAuto {
101			args = append(args, "-a")
102		}
103		run("git", args...)
104	}
105	commit(amend)
106	for !commitMessageOK() {
107		fmt.Print("re-edit commit message (y/n)? ")
108		if !scanYes() {
109			break
110		}
111		commit(true)
112	}
113	printf("change updated.")
114}
115
116func checkoutOrCreate(target string) {
117	// If it's a valid Gerrit number CL or CL/PS or GitHub pull request number PR,
118	// checkout the CL or PR.
119	cl, ps, isCL := parseCL(target)
120	if isCL {
121		what := "CL"
122		if !haveGerrit() && haveGitHub() {
123			what = "PR"
124			if ps != "" {
125				dief("change PR syntax is NNN not NNN.PP")
126			}
127		}
128		if what == "CL" && !haveGerrit() {
129			dief("cannot change to a CL without gerrit")
130		}
131		if HasStagedChanges() || HasUnstagedChanges() {
132			dief("cannot change to a %s with uncommitted work", what)
133		}
134		checkoutCL(what, cl, ps)
135		return
136	}
137
138	if strings.ToUpper(target) == "HEAD" {
139		// Git gets very upset and confused if you 'git change head'
140		// on systems with case-insensitive file names: the branch
141		// head conflicts with the usual HEAD.
142		dief("invalid branch name %q: ref name HEAD is reserved for git.", target)
143	}
144
145	// If local branch exists, check it out.
146	for _, b := range LocalBranches() {
147		if b.Name == target {
148			run("git", "checkout", "-q", target)
149			printf("changed to branch %v.", target)
150			return
151		}
152	}
153
154	// If origin branch exists, create local branch tracking it.
155	for _, name := range OriginBranches() {
156		if name == "origin/"+target {
157			run("git", "checkout", "-q", "-t", "-b", target, name)
158			printf("created branch %v tracking %s.", target, name)
159			return
160		}
161	}
162
163	// Otherwise, this is a request to create a local work branch.
164	// Check for reserved names. We take everything with a dot.
165	if strings.Contains(target, ".") {
166		dief("invalid branch name %v: branch names with dots are reserved for git-codereview.", target)
167	}
168
169	// If the current branch has a pending commit, building
170	// on top of it will not help. Don't allow that.
171	// Otherwise, inherit branchpoint and upstream from the current branch.
172	b := CurrentBranch()
173	branchpoint := "HEAD"
174	if b.HasPendingCommit() {
175		fmt.Fprintf(stderr(), "warning: pending changes on %s are not copied to new branch %s\n", b.Name, target)
176		branchpoint = b.Branchpoint()
177	}
178
179	origin := b.OriginBranch()
180
181	// NOTE: This is different from git checkout -q -t -b origin,
182	// because the -t wold use the origin directly, and that may be
183	// ahead of the current directory. The goal of this command is
184	// to create a new branch for work on the current directory,
185	// not to incorporate new commits at the same time (use 'git sync' for that).
186	// The ideal is that HEAD doesn't change at all.
187	// In the absence of pending commits, that ideal is achieved.
188	// But if there are pending commits, it'd be too confusing to have them
189	// on two different work branches, so we drop them and use the
190	// branchpoint they started from (after warning above), moving HEAD
191	// as little as possible.
192	run("git", "checkout", "-q", "-b", target, branchpoint)
193	run("git", "branch", "-q", "--set-upstream-to", origin)
194	printf("created branch %v tracking %s.", target, origin)
195}
196
197// Checkout the patch set of the given CL. When patch set is empty, use the latest.
198func checkoutCL(what, cl, ps string) {
199	if what == "CL" && ps == "" {
200		change, err := readGerritChange(cl + "?o=CURRENT_REVISION")
201		if err != nil {
202			dief("cannot change to CL %s: %v", cl, err)
203		}
204		rev, ok := change.Revisions[change.CurrentRevision]
205		if !ok {
206			dief("cannot change to CL %s: invalid current revision from gerrit", cl)
207		}
208		ps = strconv.Itoa(rev.Number)
209	}
210
211	var ref string
212	if what == "CL" {
213		var group string
214		if len(cl) > 1 {
215			group = cl[len(cl)-2:]
216		} else {
217			group = "0" + cl
218		}
219		cl = fmt.Sprintf("%s/%s", cl, ps)
220		ref = fmt.Sprintf("refs/changes/%s/%s", group, cl)
221	} else {
222		ref = fmt.Sprintf("pull/%s/head", cl)
223	}
224	err := runErr("git", "fetch", "-q", "origin", ref)
225	if err != nil {
226		dief("cannot change to %v %s: %v", what, cl, err)
227	}
228	err = runErr("git", "checkout", "-q", "FETCH_HEAD")
229	if err != nil {
230		dief("cannot change to %s %s: %v", what, cl, err)
231	}
232	if *noRun {
233		return
234	}
235	subject, err := trimErr(cmdOutputErr("git", "log", "--format=%s", "-1"))
236	if err != nil {
237		printf("changed to %s %s.", what, cl)
238		dief("cannot read change subject from git: %v", err)
239	}
240	printf("changed to %s %s.\n\t%s", what, cl, subject)
241}
242
243var parseCLRE = regexp.MustCompile(`^([0-9]+)(?:/([0-9]+))?$`)
244
245// parseCL validates and splits the CL number and patch set (if present).
246func parseCL(arg string) (cl, patchset string, ok bool) {
247	m := parseCLRE.FindStringSubmatch(arg)
248	if len(m) == 0 {
249		return "", "", false
250	}
251	return m[1], m[2], true
252}
253
254var messageRE = regexp.MustCompile(`^(\[[a-zA-Z0-9.-]+\] )?[a-zA-Z0-9-/,. ]+: `)
255
256func commitMessageOK() bool {
257	body := cmdOutput("git", "log", "--format=format:%B", "-n", "1")
258	ok := true
259	if !messageRE.MatchString(body) {
260		fmt.Print(commitMessageWarning)
261		ok = false
262	}
263	return ok
264}
265
266const commitMessageWarning = `
267Your CL description appears not to use the standard form.
268
269The first line of your change description is conventionally a one-line summary
270of the change, prefixed by the primary affected package, and is used as the
271subject for code review mail; the rest of the description elaborates.
272
273Examples:
274
275	encoding/rot13: new package
276
277	math: add IsInf, IsNaN
278
279	net: fix cname in LookupHost
280
281	unicode: update to Unicode 5.0.2
282
283`
284
285func scanYes() bool {
286	var s string
287	fmt.Scan(&s)
288	return strings.HasPrefix(strings.ToLower(s), "y")
289}
290