1"""List downstream commits that are not upstream and are visible in the diff.
2
3Only include changes that are visible when you diff
4the downstream and usptream branches.
5
6This will naturally exclude changes that already landed upstream
7in some form but were not merged or cherry picked.
8
9This will also exclude changes that were added then reverted downstream.
10
11"""
12
13from __future__ import absolute_import
14from __future__ import division
15from __future__ import print_function
16import argparse
17import os
18import subprocess
19
20
21def git(args):
22  """Git command.
23
24  Args:
25    args: A list of arguments to be sent to the git command.
26
27  Returns:
28    The output of the git command.
29  """
30
31  command = ['git']
32  command.extend(args)
33  with open(os.devnull, 'w') as devull:
34    return subprocess.check_output(command, stderr=devull)
35
36
37class CommitFinder(object):
38
39  def __init__(self, working_dir, upstream, downstream):
40    self.working_dir = working_dir
41    self.upstream = upstream
42    self.downstream = downstream
43
44  def __call__(self, filename):
45    insertion_commits = set()
46
47    if os.path.isfile(os.path.join(self.working_dir, filename)):
48      blame_output = git(['-C', self.working_dir, 'blame', '-l',
49                          '%s..%s' % (self.upstream, self.downstream),
50                          '--', filename])
51      for line in blame_output.splitlines():
52        # The commit is the first field of a line
53        blame_fields = line.split(' ', 1)
54        # Some lines can be empty
55        if blame_fields:
56          insertion_commits.add(blame_fields[0])
57
58    return insertion_commits
59
60
61def find_insertion_commits(upstream, downstream, working_dir):
62  """Finds all commits that insert lines on top of the upstream baseline.
63
64  Args:
65    upstream: Upstream branch to be used as a baseline.
66    downstream: Downstream branch to search for commits missing upstream.
67    working_dir: Run as if git was started in this directory.
68
69  Returns:
70    A set of commits that insert lines on top of the upstream baseline.
71  """
72
73  insertion_commits = set()
74
75  diff_files = git(['-C', working_dir, 'diff',
76                    '--name-only',
77                    '--diff-filter=d',
78                    upstream,
79                    downstream])
80  diff_files = diff_files.splitlines()
81
82  finder = CommitFinder(working_dir, upstream, downstream)
83  commits_per_file = [finder(filename) for filename in diff_files]
84
85  for commits in commits_per_file:
86    insertion_commits.update(commits)
87
88  return insertion_commits
89
90
91def find(upstream, downstream, working_dir):
92  """Finds downstream commits that are not upstream and are visible in the diff.
93
94  Args:
95    upstream: Upstream branch to be used as a baseline.
96    downstream: Downstream branch to search for commits missing upstream.
97    working_dir: Run as if git was started in thid directory.
98
99  Returns:
100    A set of downstream commits missing upstream.
101  """
102
103  revlist_output = git(['-C', working_dir, 'rev-list', '--no-merges',
104                        '%s..%s' % (upstream, downstream)])
105  downstream_only_commits = set(revlist_output.splitlines())
106  # TODO(slobdell b/78283222) resolve commits not upstreamed that are purely reverts
107  return downstream_only_commits
108
109
110def main():
111  parser = argparse.ArgumentParser(
112      description='Finds commits yet to be applied upstream.')
113  parser.add_argument(
114      'upstream',
115      help='Upstream branch to be used as a baseline.',
116  )
117  parser.add_argument(
118      'downstream',
119      help='Downstream branch to search for commits missing upstream.',
120  )
121  parser.add_argument(
122      '-C',
123      '--working_directory',
124      help='Run as if git was started in thid directory',
125      default='.',)
126  args = parser.parse_args()
127  upstream = args.upstream
128  downstream = args.downstream
129  working_dir = os.path.abspath(args.working_directory)
130
131  print('\n'.join(find(upstream, downstream, working_dir)))
132
133
134if __name__ == '__main__':
135  main()
136