/
pair.go
346 lines (279 loc) · 8.89 KB
/
pair.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"net"
"os"
"os/exec"
"sort"
"strings"
"gopkg.in/yaml.v1"
)
var branch = flag.String("b", "", "switch to this branch prefixed with the current pair authors")
func main() {
flag.Usage = usage
flag.Parse()
configFile := os.ExpandEnv("$PAIR_GIT_CONFIG")
if configFile == "" {
configFile = os.ExpandEnv("$HOME/.gitconfig_local")
}
emailTemplate := os.ExpandEnv("$PAIR_EMAIL")
if emailTemplate == "" {
var err error
emailTemplate, err = GetDefaultEmailTemplate()
if err != nil {
fmt.Fprintln(os.Stderr, "error: please set $PAIR_EMAIL to configure the pair email template")
os.Exit(1)
}
}
if *branch != "" {
if switchToPairBranch(configFile, *branch, emailTemplate) {
os.Exit(0)
} else {
os.Exit(1)
}
}
usernames := flag.Args()
if len(usernames) == 0 {
// $ pair
if !printCurrentPairedUsers(configFile) {
os.Exit(1)
}
} else {
// $ pair author1 author2
pairsFile := os.ExpandEnv("$PAIR_FILE")
if pairsFile == "" {
pairsFile = os.ExpandEnv("$HOME/.pairs")
}
if !setAndPrintNewPairedUsers(pairsFile, configFile, emailTemplate, usernames) {
os.Exit(1)
}
}
}
func usage() {
fmt.Println(
`pair USER1 [USER2 [...]]
pair [OPTIONS]
Configures your git author and committer info by changing ~/.gitconfig_local.
This is meant to be used both as a means of adding multiple authors to a commit
and an alternative to editing your ~/.git_config (which is checked into git).
Options
-b BRANCH Switches to a git branch prefixed with the paired usernames.
Examples
# configure paired git author info for this shell
$ pair jsmith alice
Alice Barns and Jon Smith <git+alice+jsmith@example.com>
# use the same author info as the last time pair was run
$ pair
Alice Barns and Jon Smith <git+alice+jsmith@example.com>
# create a branch to work on a feature
$ pair -b ONCALL-843
Switched to a new branch 'alice+jsmith/ONCALL-843'
Configuration
PAIR_FILE YAML file with a map of usernames to full names (default: ~/.pairs).
PAIR_GIT_CONFIG Git config file for reading and writing author info (default: ~/.gitconfig).`)
defaultEmailTemplate, err := GetDefaultEmailTemplate()
if err == nil {
defaultEmailTemplate = " (default: " + defaultEmailTemplate + ")"
}
fmt.Println(" PAIR_EMAIL Email address to base derived email addresses on" + defaultEmailTemplate + ".")
}
func printCurrentPairedUsers(configFile string) bool {
name, err := gitConfig(configFile, "user.name")
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to get current git author name: %v\n", err)
return false
}
email, err := gitConfig(configFile, "user.email")
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to get current git author email: %v\n", err)
return false
}
fmt.Printf("%s <%s>\n", name, email)
return true
}
func setAndPrintNewPairedUsers(pairsFile string, configFile string, emailTemplate string, usernames []string) bool {
f, err := os.Open(pairsFile)
var authorMap map[string]string
if err == nil {
authorMap, err = readAuthorsByUsername(bufio.NewReader(f))
}
if f != nil {
f.Close()
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to read authors from file (%s): %v", pairsFile, err)
return false
}
sort.Strings(usernames)
email, err := emailAddressForUsernames(emailTemplate, usernames)
var name string
if err == nil {
name, err = namesForUsernames(usernames, authorMap)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return false
}
err = setGitConfig(configFile, "user.name", name)
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to set current git author name: %v\n", err)
return false
}
err = setGitConfig(configFile, "user.email", email)
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to set current git author name: %v\n", err)
return false
}
return printCurrentPairedUsers(configFile)
}
func switchToPairBranch(configFile string, branch string, emailTemplate string) bool {
email, err := gitConfig(configFile, "user.email")
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to get current git author email from config file: %s\n", configFile)
return false
}
templateUsername, _, err := SplitEmail(emailTemplate)
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to parse template email address: %s\n", emailTemplate)
return false
}
usernames, _, err := SplitEmail(email)
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to parse email address: %s\n", email)
return false
}
// Remove any preceding e.g. "git+" from "git+lb+mb".
usernames = strings.TrimPrefix(usernames, templateUsername+"+")
fullBranch := usernames + "/" + branch
cmd := exec.Command("git", "rev-parse", fullBranch)
err = cmd.Run()
args := []string{"checkout"}
if err != nil {
// The branch does not exist, so create it with the `-b' flag.
args = append(args, "-b", fullBranch, "master")
} else {
// The branch already exists, so just switch to it.
args = append(args, fullBranch)
}
cmd = exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "error: unable to check out git branch: %s\n", fullBranch)
return false
}
return true
}
// GetDefaultEmailTemplate determines a default email template from the current network.
func GetDefaultEmailTemplate() (string, error) {
dnsNames, err := LookupReverseDNSNamesByInterface("en0")
if err != nil {
return "", err
}
for _, dnsName := range dnsNames {
hostnameParts := strings.Split(dnsName, ".")
if len(hostnameParts) >= 3 {
return "git@" + strings.Join(hostnameParts[len(hostnameParts)-3:len(hostnameParts)-1], "."), nil
}
}
return "", errors.New("expected a hostname to be a fully-qualified domain name: " + strings.Join(dnsNames, ","))
}
// LookupReverseDNSNamesByInterface finds the DNS names for the given network interface (e.g. "en0").
func LookupReverseDNSNamesByInterface(interfaceName string) ([]string, error) {
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return nil, err
}
addrs, err := iface.Addrs()
if err != nil {
return nil, err
}
for _, addr := range addrs {
cidr := addr.String()
ip, _, err := net.ParseCIDR(cidr)
if err == nil {
names, err := net.LookupAddr(ip.String())
if err == nil && len(names) > 0 {
return names, nil
}
}
}
return nil, nil
}
// SplitEmail splits an email address into the username and the host.
// An error is returned if the email does not contain a "@" character.
func SplitEmail(email string) (string, string, error) {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return "", "", errors.New("invalid email address: " + email)
}
return parts[0], parts[1], nil
}
// gitConfig retrieves the value of a property from a specific git config file.
// It returns the value as a string along with any error that occurred.
func gitConfig(configFile string, property string) (string, error) {
cmd := exec.Command("git", "config", "--file", configFile, property)
output, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimRight(string(output), "\r\n"), nil
}
// setGitConfig sets the value of a property within a specific git config file.
// It returns any error that occurred.
func setGitConfig(configFile string, property string, value string) error {
cmd := exec.Command("git", "config", "--file", configFile, property, value)
return cmd.Run()
}
// readAuthorsByUsername gets a map of username -> full name for possible git authors.
// pairs should be reader open to data containing a YAML map.
func readAuthorsByUsername(pairs io.Reader) (map[string]string, error) {
var authorMap map[string]string
bytes, err := ioutil.ReadAll(pairs)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(bytes, &authorMap)
if err != nil {
return nil, err
}
return authorMap, nil
}
// emailAddressForUsernames generates an email address from a list of usernames.
// For example, given "michael" and "lindsay" returns "michael+lindsay".
func emailAddressForUsernames(emailTemplate string, usernames []string) (string, error) {
user, host, err := SplitEmail(emailTemplate)
if err != nil {
return "", err
}
switch len(usernames) {
case 0:
return emailTemplate, nil
case 1:
return fmt.Sprintf("%s@%s", usernames[0], host), nil
default:
return fmt.Sprintf("%s+%s@%s", user, strings.Join(usernames, "+"), host), nil
}
}
// namesForUsernames joins names corresponding to usernames with " and ".
// For example, given "michael" and "lindsay" returns "Michael Bluth and Lindsay Bluth".
func namesForUsernames(usernames []string, authorMap map[string]string) (string, error) {
if len(usernames) == 0 {
return "", nil
}
var names []string
for _, username := range usernames {
name, ok := authorMap[username]
if !ok {
return "", errors.New("no such username: " + username)
}
names = append(names, name)
}
return strings.Join(names, " and "), nil
}