@@ -28,9 +28,15 @@ DOMAIN = 'example.com'
28
28
import sys
29
29
import os
30
30
import optparse
31
+ import posixpath
31
32
import subprocess
32
33
import tempfile
33
34
import git
35
+ import shlex
36
+ import codecs
37
+ from StringIO import StringIO
38
+ from email .message import Message
39
+ from email .header import Header
34
40
35
41
usage = """
36
42
%%prog -r <reviewer> [-r <another-reviewer>] [-s <summary>] [-m <message>] [options] {<since>|<revision-range>}
@@ -43,24 +49,60 @@ Name a range of commits, or name a single commit (e.g., 'HEAD^' or
43
49
""" .strip () % CC_EMAIL
44
50
45
51
46
- def show_usage (args ):
47
- print >> sys .stderr , usage
48
- return 2
52
+ # Monkeypatch a bug in git-python.
53
+ import git .commit , git .repo
54
+
55
+ def git__commit__Commit__find_all (cls , repo , ref , path = None , ** kwargs ):
56
+ options = {'pretty' : 'raw' }
57
+ options .update (kwargs )
58
+
59
+ lpath = [p for p in (path ,) if p is not None ]
60
+ output = repo .git .rev_list (ref , '--' , * lpath , ** options )
61
+ return cls .list_from_string (repo , output )
62
+ git .commit .Commit .find_all = classmethod (git__commit__Commit__find_all )
63
+
64
+ def git__repo__Repo__commit (self , id , path = None ):
65
+ options = {'max_count' : 1 }
66
+
67
+ commits = git .commit .Commit .find_all (self , id , path , ** options )
68
+
69
+ if not commits :
70
+ raise ValueError , 'Invalid identifier %s' % id
71
+ return commits [0 ]
72
+ git .repo .Repo .commit = git__repo__Repo__commit
73
+ # End monkeypatch.
74
+
75
+
76
+ def check_unicode (option , opt , value ):
77
+ try :
78
+ return unicode (value , 'utf-8' , 'strict' )
79
+ except UnicodeDecodeError :
80
+ raise optparse .OptionValueError ('option %s: invalid UTF-8 string' % opt )
81
+
82
+ class MyOption (optparse .Option ):
83
+ TYPES = optparse .Option .TYPES + ('unicode' ,)
84
+ TYPE_CHECKER = dict (optparse .Option .TYPE_CHECKER , unicode = check_unicode )
49
85
50
86
51
87
def parse_options (args ):
52
- parser = optparse .OptionParser (usage )
53
- parser .add_option ('-r' , '--reviewer' , type = 'string' , dest = 'reviewers' , action = "append" ,
88
+ parser = optparse .OptionParser (usage , option_class = MyOption )
89
+ parser .add_option ('--first-parent' , action = 'store_true' , dest = 'first_parent' ,
90
+ help = 'follow first parents only' )
91
+ parser .add_option ('-r' , '--reviewer' , type = 'unicode' , dest = 'reviewers' , action = "append" ,
54
92
help = 'the person you are asking to do the review' )
55
93
parser .add_option ('--stdout' , action = 'store_true' , dest = 'stdout' ,
56
94
help = 'send to standard output rather than send mail' )
57
95
parser .add_option ('--format' , type = 'choice' , dest = 'format' ,
58
96
choices = ['oneline' , 'message' , 'patch' ],
59
97
help = "'patch' (default for one commit), 'message' (default for more), or 'oneline'" )
60
- parser .add_option ('-s' , '--summary' , type = 'string ' , dest = 'summary' ,
98
+ parser .add_option ('-s' , '--summary' , type = 'unicode ' , dest = 'summary' ,
61
99
help = 'summary for subject line' )
62
- parser .add_option ('-m' , '--message' , type = 'string ' , dest = 'message' ,
100
+ parser .add_option ('-m' , '--message' , type = 'unicode ' , dest = 'message' ,
63
101
help = 'message for body of email' )
102
+ parser .add_option ('-t' , '--testing' , type = 'unicode' , dest = 'testing' ,
103
+ help = 'extent and methods of testing employed' )
104
+ parser .add_option ('-e' , '--edit' , action = 'store_true' , dest = 'edit' ,
105
+ help = 'spawn $EDITOR and edit review request' )
64
106
options , args = parser .parse_args (args )
65
107
if not options .reviewers :
66
108
parser .error ('reviewer required' )
@@ -76,68 +118,206 @@ def parse_options(args):
76
118
return options , args
77
119
78
120
79
- def parse_revs (repo , args ):
80
- if len (args ) == 1 and '..' not in args [0 ]:
81
- return list (repo .commits_between (args [0 ], 'HEAD' ))
121
+ def get_default_remote (repo ):
122
+ try :
123
+ return repo .git .config ('--get' , 'remotes.default' )
124
+ except git .errors .GitCommandError :
125
+ try :
126
+ branch = repo .active_branch
127
+ return repo .git .config ('--get' , 'branch.%s.remote' % branch )
128
+ except git .errors .GitCommandError :
129
+ return 'origin'
130
+
131
+
132
+ def get_reponame (repo ):
133
+ remote = get_default_remote (repo )
134
+
135
+ try :
136
+ url = repo .git .config ('--get' , 'remote.%s.url' % remote )
137
+ except git .errors .GitCommandError :
138
+ url = repo .wd
139
+
140
+ name = posixpath .basename (posixpath .normpath (url .split (':' , 1 )[- 1 ]))
141
+ if name .endswith ('.git' ):
142
+ name = name [:- len ('.git' )]
143
+ return name
144
+
145
+
146
+ def parse_revs (repo , opts , args ):
147
+ args = repo .git .rev_parse (* args ).splitlines ()
148
+ if len (args ) == 1 :
149
+ args = ['^' + args [0 ].lstrip ('^' ), 'HEAD' ]
150
+ if opts .first_parent :
151
+ args [:0 ] = ['--first-parent' ]
82
152
return [repo .commit (c ) for c in repo .git .rev_list ('--reverse' , * args ).split ()]
83
153
84
154
85
- def write_mail ( out , repo , opts , revs ):
86
- ident = repo .git .var ('GIT_AUTHOR_IDENT' )
155
+ def make_header ( repo , opts , revs ):
156
+ ident = unicode ( repo .git .var ('GIT_AUTHOR_IDENT' ), 'utf-8' , 'replace ' )
87
157
me = ident [:ident .rindex ('>' ) + 1 ]
88
- reponame = os .path .basename (repo .wd )
89
- objective_summary = '%d commit(s) to %s' % (len (revs ), revs [- 1 ].id_abbrev )
158
+ reponame = get_reponame (repo )
159
+
160
+ remote = get_default_remote (repo )
161
+ (sha , name ) = repo .git .name_rev (revs [- 1 ].id ,
162
+ refs = 'refs/remotes/%s/*' % (remote ,),
163
+ always = True ).split ()
164
+ prefix = 'remotes/' + remote + "/"
165
+ if name .startswith (prefix ):
166
+ name = name [len (prefix ):]
167
+ tip_name = '%s (%s)' % (name , revs [- 1 ].id_abbrev )
168
+ else :
169
+ print >> sys .stderr , "WARNING: Can't find this commit in remote -- did you push?"
170
+ tip_name = revs [- 1 ].id_abbrev
171
+
172
+ objective_summary = '%d commit(s) to %s' % (len (revs ), tip_name )
90
173
summary = ('%s (%s)' % (opts .summary , objective_summary ) if opts .summary
91
174
else objective_summary )
92
175
93
- print >> out , 'From: ' + me
94
- print >> out , 'To: ' + ', ' .join (opts .reviewers )
95
- print >> out , 'Cc: ' + CC_EMAIL
96
- print >> out , 'Subject: %s review: %s' % (reponame , summary )
97
- print >> out
98
- print >> out , 'Dear %s,' % ", " .join (opts .reviewers )
99
- print >> out
100
- print >> out , 'At your convenience, please review the following commits.'
101
- print >> out , 'Reply with any comments, or advance master when you are satisfied.'
102
- print >> out
176
+ return [('From' , Header (me )),
177
+ ('To' , Header (', ' .join (opts .reviewers ))),
178
+ ('Cc' , Header (CC_EMAIL )),
179
+ ('Subject' , Header ('%s review: %s' % (reponame , summary )))]
180
+
181
+
182
+ def write_template (target , repo , opts ):
183
+ ident = unicode (repo .git .var ('GIT_AUTHOR_IDENT' ), 'utf-8' , 'replace' )
184
+ me = ident [:ident .rindex ('>' ) + 1 ]
185
+
186
+ print >> target , 'Dear %s,' % ", " .join (opts .reviewers )
187
+ print >> target
188
+ print >> target , 'At your convenience, please review the following commits.'
189
+ print >> target , 'Reply with any comments, or advance master when you are satisfied.'
190
+ print >> target
103
191
if opts .message :
104
- print >> out , opts .message
105
- print >> out
106
- print >> out , 'Thanks,'
107
- print >> out , me
108
- print >> out
109
- print >> out
192
+ print >> target , opts .message
193
+ print >> target
194
+ print >> target , 'Testing:' ,
195
+ if opts .testing :
196
+ print >> target , opts .testing
197
+ else :
198
+ print >> target , '(No formal testing done, or none specified.)'
199
+ print >> target
200
+ print >> target , 'Thanks,'
201
+ print >> target , me
202
+
203
+
204
+ def write_commitmsg (target , repo , opts , revs ):
205
+
110
206
if opts .format == 'oneline' :
111
207
for r in revs :
112
- print >> out , repo .git .log ('-n1' , '--oneline' , r )
208
+ print >> target , unicode ( repo .git .log ('-n1' , '--oneline' , r ), 'utf-8' , 'replace' )
113
209
elif opts .format == 'message' or opts .format is None and len (revs ) > 1 :
114
210
for r in revs :
115
- print >> out , repo .git .log ('-n1' , '--stat' , r )
116
- print >> out
211
+ if opts .first_parent :
212
+ print >> target , unicode (repo .git .log ('-n1' , r ), 'utf-8' , 'replace' )
213
+ print >> target , unicode (repo .git .diff ('--stat' , str (r )+ '^' , r ), 'utf-8' , 'replace' )
214
+ else :
215
+ print >> target , unicode (repo .git .log ('-n1' , '--stat' , r ), 'utf-8' , 'replace' )
216
+ print >> target
117
217
elif opts .format == 'patch' or opts .format is None and len (revs ) == 1 :
118
- print >> out , repo .git .show ('--stat' , '-p' , * revs )
218
+ for r in revs :
219
+ if opts .first_parent :
220
+ print >> target , unicode (repo .git .log ('-n1' , r ), 'utf-8' , 'replace' )
221
+ print >> target , unicode (repo .git .diff ('--stat' , '-p' , str (r )+ '^' , r ), 'utf-8' , 'replace' )
222
+ else :
223
+ print >> target , unicode (repo .git .log ('-n1' , '--stat' , '-p' , r ), 'utf-8' , 'replace' )
224
+ print >> target
119
225
else :
120
- this_is_an_error
226
+ raise Exception ("Bad format option." )
227
+
228
+
229
+ def edit (repo , opts , revs ):
230
+ headers = make_header (repo , opts , revs )
231
+
232
+ template = StringIO ()
233
+ commitmsg = StringIO ()
234
+
235
+ write_template (template , repo , opts )
236
+ write_commitmsg (commitmsg , repo , opts , revs )
237
+
238
+ temp = codecs .getwriter ('utf-8' )(tempfile .NamedTemporaryFile (prefix = "review-" ))
239
+
240
+ # Prepare editable buffer.
241
+
242
+ print >> temp , """# This is an editable review request. All lines beginning with # will
243
+ # be ignored. To abort the commit, remove all lines from this buffer."""
244
+ print >> temp , "#"
245
+ for (key , value ) in headers :
246
+ print >> temp , u"# %s: %s" % (key , value )
247
+ print >> temp
248
+ print >> temp , template .getvalue ()
249
+ for line in commitmsg .getvalue ().splitlines ():
250
+ print >> temp , "# " + line
251
+ temp .flush ()
252
+
253
+ # Open EDITOR to edit buffer.
254
+
255
+ editor = os .getenv ('EDITOR' ,'emacs' )
256
+ subprocess .check_call (shlex .split (editor ) + [temp .name ])
257
+
258
+ # Check if buffer is empty, and if so abort.
259
+
260
+ if (os .path .getsize (temp .name ) == 0 ):
261
+ print >> sys .stderr , "Aborting due to empty buffer."
262
+ sys .exit (2 )
263
+
264
+ # Reopen temp file, slurp it in, and reconstruct mail.
265
+
266
+ final = codecs .open (temp .name , 'r' , 'utf-8' )
267
+ msg = Message ()
268
+ for (key , value ) in headers :
269
+ msg [key ] = value
270
+ msg .set_payload (
271
+ ("" .join (line for line in final if not line .startswith ("#" )).strip () +
272
+ "\n \n " + commitmsg .getvalue ()).encode ('utf-8' ),
273
+ 'utf-8' )
274
+
275
+ # Clean up.
276
+
277
+ temp .close ()
278
+ final .close ()
279
+ try :
280
+ os .unlink (temp .name )
281
+ except OSError :
282
+ pass
283
+ return msg
121
284
122
285
123
286
def main (args ):
124
287
opts , args = parse_options (args )
125
288
repo = git .Repo ()
126
- revs = parse_revs (repo , args [1 :])
289
+ revs = parse_revs (repo , opts , args [1 :])
127
290
if not revs :
128
291
print >> sys .stderr , '%s: no revisions specified' % os .path .basename (args [0 ])
129
292
return 2
293
+
294
+ if opts .edit :
295
+ msg = edit (repo , opts , revs )
296
+
297
+ else :
298
+ # Just build the message.
299
+ msg = Message ()
300
+ for (key , value ) in make_header (repo , opts , revs ):
301
+ msg [key ] = value
302
+
303
+ template = StringIO ()
304
+ commitmsg = StringIO ()
305
+
306
+ write_template (template , repo , opts )
307
+ write_commitmsg (commitmsg , repo , opts , revs )
308
+ msg .set_payload (
309
+ (template .getvalue () + "\n " + commitmsg .getvalue ()).encode ('utf-8' ),
310
+ 'utf-8' )
311
+
312
+ # Send or print the message, as appropriate.
130
313
if opts .stdout :
131
- out = sys .stdout
314
+ for (key , value ) in msg .items ():
315
+ print >> sys .stdout , u"%s: %s" % (key , value )
316
+ print >> sys .stdout
317
+ print >> sys .stdout , msg .get_payload (decode = True ),
132
318
else :
133
- outfd , outname = tempfile .mkstemp (prefix = 'review-' )
134
- out = file (outname , 'w' )
135
- write_mail (out , repo , opts , revs )
136
- if not opts .stdout :
137
- out .close ()
138
- subprocess .check_call (['/usr/sbin/sendmail' , '-bm' , '-t' ],
139
- stdin = file (outname ))
140
-
319
+ subprocess .Popen (['/usr/sbin/sendmail' , '-bm' , '-t' ],
320
+ stdin = subprocess .PIPE ).communicate (msg .as_string ())
141
321
142
322
if __name__ == '__main__' :
143
323
sys .exit (main (sys .argv ))
0 commit comments