Skip to content

Commit 50f5774

Browse files
authored
Merge pull request #9 from mhagger/recursive-option
Add a `--recursive`/`-r` option
2 parents f79a45d + 87c248f commit 50f5774

File tree

2 files changed

+154
-36
lines changed

2 files changed

+154
-36
lines changed

README

+4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Options:
3131
the configured pattern(s) with the given name (see
3232
whenmerged.<name>.pattern below under CONFIGURATION).
3333
-s, --default Shorthand for "--name=default".
34+
-r, --recursive Follow merges back recursively.
3435
--abbrev=N Abbreviate commit SHA1s to the specified number of
3536
characters (or more if needed to avoid ambiguity).
3637
See also whenmerged.abbrev below under CONFIGURATION.
@@ -57,6 +58,9 @@ Examples:
5758
git when-merged 0a1b -n releases # Use whenmerged.releases.pattern
5859
git when-merged 0a1b -s # Use whenmerged.default.pattern
5960

61+
git when-merged 0a1b -r feature-1 # If merged indirectly, show all
62+
# merges involved.
63+
6064
git when-merged 0a1b -d feature-1 # Show diff for each merge commit
6165
git when-merged 0a1b -v feature-1 # Display merge commit in gitk
6266

bin/git-when-merged

+150-36
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ Examples:
5050
git when-merged 0a1b -n releases # Use whenmerged.releases.pattern
5151
git when-merged 0a1b -s # Use whenmerged.default.pattern
5252
53+
git when-merged 0a1b -r feature-1 # If merged indirectly, show all
54+
# merges involved.
55+
5356
git when-merged 0a1b -l feature-1 # Show log for the merge commit
5457
git when-merged 0a1b -d feature-1 # Show diff for the merge commit
5558
git when-merged 0a1b -v feature-1 # Display merge commit in gitk
@@ -214,6 +217,13 @@ def rev_parse(arg, abbrev=None):
214217

215218

216219
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+
217227
process = subprocess.Popen(
218228
['git', 'rev-list'] + list(args) + ['--'],
219229
stdout=subprocess.PIPE,
@@ -226,43 +236,115 @@ def rev_list(*args):
226236
raise Failure('git rev-list %s failed' % (' '.join(args),))
227237

228238

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+
230285

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):
232305
"""Return the SHA1 of the commit that merged commit into branch.
233306
234307
It is assumed that content is always merged in via the second or
235308
subsequent parents of a merge commit."""
236309

237310
try:
238311
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)
242314

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))
246316

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))
250319

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)
255322

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)
264330

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
266348

267349

268350
class Parser(optparse.OptionParser):
@@ -302,6 +384,9 @@ def get_full_name(branch):
302384
return branch
303385

304386

387+
FORMAT = '%(refname)-38s %(msg)s\n'
388+
389+
305390
def main(args):
306391
parser = Parser(
307392
prog='git when-merged',
@@ -341,6 +426,11 @@ def main(args):
341426
action='append_const', dest='names', const='default',
342427
help='Shorthand for "--name=default".',
343428
)
429+
parser.add_option(
430+
'--recursive', '-r',
431+
action='store_true',
432+
help='Follow merges back recursively.',
433+
)
344434
parser.add_option(
345435
'--abbrev', metavar='N',
346436
action='store', type='int', default=default_abbrev,
@@ -418,21 +508,45 @@ def main(args):
418508
branches.add(get_full_name('HEAD'))
419509

420510
for branch in sorted(branches):
511+
first = True
421512
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))
423548
except Failure as e:
424549
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,)])
436550

437551

438552
main(sys.argv[1:])

0 commit comments

Comments
 (0)