"""List downstream commits that are not upstream and are visible in the diff.
Only include changes that are visible when you diff
the downstream and usptream branches.
This will naturally exclude changes that already landed upstream
in some form but were not merged or cherry picked.
This will also exclude changes that were added then reverted downstream.
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
import argparse
import os
import subprocess
def git(args):
"""Git command.
Args:
args: A list of arguments to be sent to the git command.
Returns:
The output of the git command.
"""
command = ['git']
command.extend(args)
with open(os.devnull, 'w') as devull:
return subprocess.check_output(command, stderr=devull)
class CommitFinder(object):
def __init__(self, working_dir, upstream, downstream):
self.working_dir = working_dir
self.upstream = upstream
self.downstream = downstream
def __call__(self, filename):
insertion_commits = set()
if os.path.isfile(os.path.join(self.working_dir, filename)):
blame_output = git(['-C', self.working_dir, 'blame', '-l',
'%s..%s' % (self.upstream, self.downstream),
'--', filename])
for line in blame_output.splitlines():
# The commit is the first field of a line
blame_fields = line.split(' ', 1)
# Some lines can be empty
if blame_fields:
insertion_commits.add(blame_fields[0])
return insertion_commits
def find_insertion_commits(upstream, downstream, working_dir):
"""Finds all commits that insert lines on top of the upstream baseline.
Args:
upstream: Upstream branch to be used as a baseline.
downstream: Downstream branch to search for commits missing upstream.
working_dir: Run as if git was started in this directory.
Returns:
A set of commits that insert lines on top of the upstream baseline.
"""
insertion_commits = set()
diff_files = git(['-C', working_dir, 'diff',
'--name-only',
'--diff-filter=d',
upstream,
downstream])
diff_files = diff_files.splitlines()
finder = CommitFinder(working_dir, upstream, downstream)
commits_per_file = [finder(filename) for filename in diff_files]
for commits in commits_per_file:
insertion_commits.update(commits)
return insertion_commits
def find(upstream, downstream, working_dir):
"""Finds downstream commits that are not upstream and are visible in the diff.
Args:
upstream: Upstream branch to be used as a baseline.
downstream: Downstream branch to search for commits missing upstream.
working_dir: Run as if git was started in thid directory.
Returns:
A set of downstream commits missing upstream.
"""
commits_not_upstreamed = set()
revlist_output = git(['-C', working_dir, 'rev-list', '--no-merges',
'%s..%s' % (upstream, downstream)])
downstream_only_commits = set(revlist_output.splitlines())
insertion_commits = set()
# If there are no downstream-only commits there's no point in
# futher filtering
if downstream_only_commits:
insertion_commits = find_insertion_commits(upstream, downstream,
working_dir)
# The commits that are only downstream and are visible in 'git blame' are the
# ones that insert lines in the diff between upstream and downstream.
commits_not_upstreamed.update(
downstream_only_commits.intersection(insertion_commits))
# TODO(diegowilson) add commits that deleted lines
return commits_not_upstreamed
def main():
parser = argparse.ArgumentParser(
description='Finds commits yet to be applied upstream.')
parser.add_argument(
'upstream',
help='Upstream branch to be used as a baseline.',
)
parser.add_argument(
'downstream',
help='Downstream branch to search for commits missing upstream.',
)
parser.add_argument(
'-C',
'--working_directory',
help='Run as if git was started in thid directory',
default='.',)
args = parser.parse_args()
upstream = args.upstream
downstream = args.downstream
working_dir = os.path.abspath(args.working_directory)
print('\n'.join(find(upstream, downstream, working_dir)))
if __name__ == '__main__':
main()