@@ -50,6 +50,9 @@ Examples:
50
50
git when-merged 0a1b -n releases # Use whenmerged.releases.pattern
51
51
git when-merged 0a1b -s # Use whenmerged.default.pattern
52
52
53
+ git when-merged 0a1b -r feature-1 # If merged indirectly, show all
54
+ # merges involved.
55
+
53
56
git when-merged 0a1b -l feature-1 # Show log for the merge commit
54
57
git when-merged 0a1b -d feature-1 # Show diff for the merge commit
55
58
git when-merged 0a1b -v feature-1 # Display merge commit in gitk
@@ -214,6 +217,13 @@ def rev_parse(arg, abbrev=None):
214
217
215
218
216
219
def rev_list (* args ):
220
+ """Iterate over (commit, [parent,...]) for the selected commits.
221
+
222
+ args are passed as arguments to "git rev-list" to select which
223
+ commits should be iterated over.
224
+
225
+ """
226
+
217
227
process = subprocess .Popen (
218
228
['git' , 'rev-list' ] + list (args ) + ['--' ],
219
229
stdout = subprocess .PIPE ,
@@ -226,43 +236,115 @@ def rev_list(*args):
226
236
raise Failure ('git rev-list %s failed' % (' ' .join (args ),))
227
237
228
238
229
- FORMAT = '%(refname)-38s %(msg)s\n '
239
+ def rev_list_with_parents (* args ):
240
+ cmd = ['git' , 'log' , '--format=%H %P' ] + list (args ) + ['--' ]
241
+ process = subprocess .Popen (cmd , stdout = subprocess .PIPE )
242
+ for line in process .stdout :
243
+ words = _decode_output (line ).strip ().split ()
244
+ yield (words [0 ], words [1 :])
245
+
246
+ retcode = process .wait ()
247
+ if retcode :
248
+ raise Failure ('command "%s" failed' % (' ' .join (cmd ),))
249
+
250
+
251
+ class CommitGraph :
252
+ def __init__ (self , * args ):
253
+ self .commits = dict (rev_list_with_parents (* args ))
254
+
255
+ def __contains__ (self , commit ):
256
+ return commit in self .commits
257
+
258
+ def __getitem__ (self , commit ):
259
+ return self .commits [commit ]
260
+
261
+ def first_parent_path (self , commit ):
262
+ """Iterate over the commits in the first-parent ancestry of commit.
263
+
264
+ Iterate over the commits that are within this CommitGraph that
265
+ are also in the first-parent ancestry of the specified commit.
266
+ commit must be a full 40-character SHA-1.
267
+
268
+ """
269
+
270
+ while True :
271
+ try :
272
+ parents = self [commit ]
273
+ except KeyError :
274
+ return
275
+ yield commit
276
+ if not parents :
277
+ return
278
+ commit = parents [0 ]
279
+
280
+
281
+ class MergeNotFoundError (Exception ):
282
+ def __init__ (self , refname ):
283
+ self .refname = refname
284
+
230
285
231
- def find_merge (commit , branch , abbrev ):
286
+ class InvalidCommitError (MergeNotFoundError ):
287
+ msg = 'Is not a valid commit!'
288
+
289
+
290
+ class DoesNotContainCommitError (MergeNotFoundError ):
291
+ msg = 'Does not contain commit.'
292
+
293
+
294
+ class DirectlyOnBranchError (MergeNotFoundError ):
295
+ msg = 'Commit is directly on this branch.'
296
+
297
+
298
+ class MergedViaMultipleParentsError (MergeNotFoundError ):
299
+ def __init__ (self , refname , parents ):
300
+ MergeNotFoundError .__init__ (self , refname )
301
+ self .msg = 'Merged via multiple parents: %s' % (' ' .join (parents ),)
302
+
303
+
304
+ def find_merge (commit , branch ):
232
305
"""Return the SHA1 of the commit that merged commit into branch.
233
306
234
307
It is assumed that content is always merged in via the second or
235
308
subsequent parents of a merge commit."""
236
309
237
310
try :
238
311
branch_sha1 = rev_parse (branch )
239
- except Failure as e :
240
- sys .stdout .write (FORMAT % dict (refname = branch , msg = 'Is not a valid commit!' ))
241
- return None
312
+ except Failure :
313
+ raise InvalidCommitError (branch )
242
314
243
- branch_commits = set (
244
- rev_list ('--first-parent' , branch_sha1 , '--not' , '%s^@' % (commit ,))
245
- )
315
+ commit_graph = CommitGraph ('--ancestry-path' , '%s..%s' % (commit , branch_sha1 ))
246
316
247
- if commit in branch_commits :
248
- sys .stdout .write (FORMAT % dict (refname = branch , msg = 'Commit is directly on this branch.' ))
249
- return None
317
+ while True :
318
+ branch_commits = list (commit_graph .first_parent_path (branch_sha1 ))
250
319
251
- last = None
252
- for commit in rev_list ('--ancestry-path' , '%s..%s' % (commit , branch_sha1 ,)):
253
- if commit in branch_commits :
254
- last = commit
320
+ if not branch_commits :
321
+ raise DoesNotContainCommitError (branch )
255
322
256
- if not last :
257
- sys .stdout .write (FORMAT % dict (refname = branch , msg = 'Does not contain commit.' ))
258
- else :
259
- if abbrev is not None :
260
- msg = rev_parse (last , abbrev = abbrev )
261
- else :
262
- msg = last
263
- sys .stdout .write (FORMAT % dict (refname = branch , msg = msg ))
323
+ # The last entry in branch_commits is the one that merged in
324
+ # commit.
325
+ last = branch_commits [- 1 ]
326
+ parents = commit_graph [last ]
327
+
328
+ if parents [0 ] == commit :
329
+ raise DirectlyOnBranchError (branch )
264
330
265
- return last
331
+ yield last
332
+
333
+ if commit in parents :
334
+ # The commit was merged in directly:
335
+ return
336
+
337
+ # Find which parent(s) merged in the commit:
338
+ parents = [
339
+ parent
340
+ for parent in parents
341
+ if parent in commit_graph
342
+ ]
343
+ assert (parents )
344
+ if len (parents ) > 1 :
345
+ raise MergedViaMultipleParentsError (branch , parents )
346
+
347
+ [branch_sha1 ] = parents
266
348
267
349
268
350
class Parser (optparse .OptionParser ):
@@ -302,6 +384,9 @@ def get_full_name(branch):
302
384
return branch
303
385
304
386
387
+ FORMAT = '%(refname)-38s %(msg)s\n '
388
+
389
+
305
390
def main (args ):
306
391
parser = Parser (
307
392
prog = 'git when-merged' ,
@@ -341,6 +426,11 @@ def main(args):
341
426
action = 'append_const' , dest = 'names' , const = 'default' ,
342
427
help = 'Shorthand for "--name=default".' ,
343
428
)
429
+ parser .add_option (
430
+ '--recursive' , '-r' ,
431
+ action = 'store_true' ,
432
+ help = 'Follow merges back recursively.' ,
433
+ )
344
434
parser .add_option (
345
435
'--abbrev' , metavar = 'N' ,
346
436
action = 'store' , type = 'int' , default = default_abbrev ,
@@ -418,21 +508,45 @@ def main(args):
418
508
branches .add (get_full_name ('HEAD' ))
419
509
420
510
for branch in sorted (branches ):
511
+ first = True
421
512
try :
422
- merge = find_merge (commit , branch , options .abbrev )
513
+ for merge in find_merge (commit , branch ):
514
+ if options .abbrev is not None :
515
+ msg = rev_parse (merge , abbrev = options .abbrev )
516
+ else :
517
+ msg = merge
518
+
519
+ if first :
520
+ sys .stdout .write (FORMAT % dict (refname = branch , msg = msg ))
521
+ else :
522
+ sys .stdout .write (FORMAT % dict (refname = '' , msg = 'via %s' % (msg ,)))
523
+
524
+ if options .log :
525
+ subprocess .check_call (['git' , '--no-pager' , 'log' , '--no-walk' , merge ])
526
+
527
+ if options .diff :
528
+ subprocess .check_call (['git' , '--no-pager' , 'show' , merge ])
529
+
530
+ if options .visualize :
531
+ subprocess .check_call (['gitk' , '--all' , '--select-commit=%s' % (merge ,)])
532
+
533
+ if options .recursive :
534
+ first = False
535
+ else :
536
+ break
537
+
538
+ except DirectlyOnBranchError as e :
539
+ if first :
540
+ sys .stdout .write (FORMAT % dict (refname = e .refname , msg = e .msg ))
541
+ except MergedViaMultipleParentsError as e :
542
+ if first :
543
+ sys .stdout .write (FORMAT % dict (refname = e .refname , msg = e .msg ))
544
+ else :
545
+ sys .stdout .write (FORMAT % dict (refname = '' , msg = e .msg ))
546
+ except MergeNotFoundError as e :
547
+ sys .stdout .write (FORMAT % dict (refname = e .refname , msg = e .msg ))
423
548
except Failure as e :
424
549
sys .stderr .write ('%s\n ' % (e .message ,))
425
- continue
426
-
427
- if merge :
428
- if options .log :
429
- subprocess .check_call (['git' , '--no-pager' , 'log' , '--no-walk' , merge ])
430
-
431
- if options .diff :
432
- subprocess .check_call (['git' , '--no-pager' , 'show' , merge ])
433
-
434
- if options .visualize :
435
- subprocess .check_call (['gitk' , '--all' , '--select-commit=%s' % (merge ,)])
436
550
437
551
438
552
main (sys .argv [1 :])
0 commit comments