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
5package main
6
7import (
8	"bytes"
9	"fmt"
10	"io/ioutil"
11	"os"
12	"path/filepath"
13	"strings"
14)
15
16func cmdReword(args []string) {
17	flags.Usage = func() {
18		fmt.Fprintf(stderr(), "Usage: %s reword %s [commit...]\n",
19			progName, globalFlags)
20		exit(2)
21	}
22	flags.Parse(args)
23	args = flags.Args()
24
25	// Check that we understand the structure
26	// before we let the user spend time editing messages.
27	b := CurrentBranch()
28	pending := b.Pending()
29	if len(pending) == 0 {
30		dief("reword: no commits pending")
31	}
32	if b.Name == "HEAD" {
33		dief("reword: no current branch")
34	}
35	var last *Commit
36	for i := len(pending) - 1; i >= 0; i-- {
37		c := pending[i]
38		if last != nil && !c.HasParent(last.Hash) {
39			dief("internal error: confused about pending commit graph: parent %.7s vs %.7s", last.Hash, c.Parents)
40		}
41		last = c
42	}
43
44	headState := func() (head, branch string) {
45		head = trim(cmdOutput("git", "rev-parse", "HEAD"))
46		for _, line := range nonBlankLines(cmdOutput("git", "branch", "-l")) {
47			if strings.HasPrefix(line, "* ") {
48				branch = trim(line[1:])
49				return head, branch
50			}
51		}
52		dief("internal error: cannot find current branch")
53		panic("unreachable")
54	}
55
56	head, branch := headState()
57	if head != last.Hash {
58		dief("internal error: confused about pending commit graph: HEAD vs parent: %.7s vs %.7s", head, last.Hash)
59	}
60	if branch != b.Name {
61		dief("internal error: confused about pending commit graph: branch name %s vs %s", branch, b.Name)
62	}
63
64	// Build list of commits to be reworded.
65	// Do first, in case there are typos on the command line.
66	var cs []*Commit
67	newMsg := make(map[*Commit]string)
68	if len(args) == 0 {
69		for _, c := range pending {
70			cs = append(cs, c)
71		}
72	} else {
73		for _, arg := range args {
74			c := b.CommitByRev("reword", arg)
75			cs = append(cs, c)
76		}
77	}
78	for _, c := range cs {
79		newMsg[c] = ""
80	}
81
82	// Invoke editor to reword all the messages message.
83	// Save the edits to REWORD_MSGS immediately after editor exit
84	// in case we for some reason cannot apply the changes - don't want
85	// to throw away the user's writing.
86	// But we don't use REWORD_MSGS as the actual editor file,
87	// because if there are multiple git rewords happening
88	// (perhaps the user has forgotten about one in another window),
89	// we don't want them to step on each other during editing.
90	var buf bytes.Buffer
91	saveFile := filepath.Join(gitPathDir(), "REWORD_MSGS")
92	saveBuf := func() {
93		if err := ioutil.WriteFile(saveFile, buf.Bytes(), 0666); err != nil {
94			dief("cannot save messages: %v", err)
95		}
96	}
97	saveBuf() // make sure it works before we let the user edit anything
98	printf("editing messages (new texts logged in %s in case of failure)", saveFile)
99	note := "edited messages saved in " + saveFile
100
101	if len(cs) == 1 {
102		c := cs[0]
103		edited := editor(c.Message)
104		if edited == "" {
105			dief("edited message is empty")
106		}
107		newMsg[c] = edited
108		fmt.Fprintf(&buf, "# %s\n\n%s\n\n", c.Subject, edited)
109		saveBuf()
110	} else {
111		// Edit all at once.
112		var ed bytes.Buffer
113		ed.WriteString(rewordProlog)
114		byHash := make(map[string]*Commit)
115		for _, c := range cs {
116			if strings.HasPrefix(c.Message, "# ") || strings.Contains(c.Message, "\n# ") {
117				// Will break our framing.
118				// Should be pretty rare since 'git commit' and 'git commit --amend'
119				// delete lines beginning with # after editing sessions.
120				dief("commit %.7s has a message line beginning with # - cannot reword with other commits", c.Hash)
121			}
122			hash := c.Hash[:7]
123			byHash[hash] = c
124			// Two blank lines before #, one after.
125			// Lots of space to make it easier to see the boundaries
126			// between commit messages.
127			fmt.Fprintf(&ed, "\n\n# %s %s\n\n%s\n", hash, c.Subject, c.Message)
128		}
129		edited := editor(ed.String())
130		if edited == "" {
131			dief("edited text is empty")
132		}
133
134		// Save buffer for user before going further.
135		buf.WriteString(edited)
136		saveBuf()
137
138		for i, text := range strings.Split("\n"+edited, "\n# ") {
139			if i == 0 {
140				continue
141			}
142			text = "# " + text // restore split separator
143
144			// Pull out # hash header line and body.
145			hdr, body, _ := cut(text, "\n")
146
147			// Cut blank lines at start and end of body but keep newline-terminated.
148			for body != "" {
149				line, rest, _ := cut(body, "\n")
150				if line != "" {
151					break
152				}
153				body = rest
154			}
155			body = strings.TrimRight(body, " \t\n")
156			if body != "" {
157				body += "\n"
158			}
159
160			// Look up hash.
161			f := strings.Fields(hdr)
162			if len(f) < 2 {
163				dief("edited text has # line with no commit hash\n%s", note)
164			}
165			c := byHash[f[1]]
166			if c == nil {
167				dief("cannot find commit for header: %s\n%s", strings.TrimSpace(hdr), note)
168			}
169			newMsg[c] = body
170		}
171	}
172
173	// Rebuild the commits the way git would,
174	// but without doing any git checkout that
175	// would affect the files in the working directory.
176	var newHash string
177	last = nil
178	for i := len(pending) - 1; i >= 0; i-- {
179		c := pending[i]
180		if (newMsg[c] == "" || newMsg[c] == c.Message) && newHash == "" {
181			// Have not started making changes yet. Leave exactly as is.
182			last = c
183			continue
184		}
185		// Rebuilding.
186		msg := newMsg[c]
187		if msg == "" {
188			msg = c.Message
189		}
190		if last != nil && newHash != "" && !c.HasParent(last.Hash) {
191			dief("internal error: confused about pending commit graph")
192		}
193		gitArgs := []string{"commit-tree", "-p"}
194		for _, p := range c.Parents {
195			if last != nil && newHash != "" && p == last.Hash {
196				p = newHash
197			}
198			gitArgs = append(gitArgs, p)
199		}
200		gitArgs = append(gitArgs, "-m", msg, c.Tree)
201		os.Setenv("GIT_AUTHOR_NAME", c.AuthorName)
202		os.Setenv("GIT_AUTHOR_EMAIL", c.AuthorEmail)
203		os.Setenv("GIT_AUTHOR_DATE", c.AuthorDate)
204		newHash = trim(cmdOutput("git", gitArgs...))
205		last = c
206	}
207	if newHash == "" {
208		// No messages changed.
209		return
210	}
211
212	// Attempt swap of HEAD but leave index and working copy alone.
213	// No obvious way to make it atomic, but check for races.
214	head, branch = headState()
215	if head != pending[0].Hash {
216		dief("cannot reword: commits changed underfoot\n%s", note)
217	}
218	if branch != b.Name {
219		dief("cannot reword: branch changed underfoot\n%s", note)
220	}
221	run("git", "reset", "--soft", newHash)
222}
223
224func cut(s, sep string) (before, after string, ok bool) {
225	i := strings.Index(s, sep)
226	if i < 0 {
227		return s, "", false
228	}
229	return s[:i], s[i+len(sep):], true
230}
231
232var rewordProlog = `Rewording multiple commit messages.
233The # lines separate the different commits and must be left unchanged.
234`
235