blob: 412e61fd25526767e382521343a3d70a2b4aed26 [file] [log] [blame]
luqui@chromium.org0b887622014-09-03 02:31:03 +00001#!/usr/bin/env python
2# Copyright 2014 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +00007import json
luqui@chromium.org0b887622014-09-03 02:31:03 +00008import re
9import sys
10
11from collections import defaultdict
12
13import git_common as git
14
agable@chromium.orgd629fb42014-10-01 09:40:10 +000015
luqui@chromium.org0b887622014-09-03 02:31:03 +000016FOOTER_PATTERN = re.compile(r'^\s*([\w-]+): (.*)$')
Andrii Shyshkalov49fe9222016-12-15 11:05:06 +010017CHROME_COMMIT_POSITION_PATTERN = re.compile(r'^([\w/\-\.]+)@{#(\d+)}$')
luqui@chromium.org0b887622014-09-03 02:31:03 +000018GIT_SVN_ID_PATTERN = re.compile('^([^\s@]+)@(\d+)')
19
agable@chromium.orgd629fb42014-10-01 09:40:10 +000020
luqui@chromium.org0b887622014-09-03 02:31:03 +000021def normalize_name(header):
22 return '-'.join([ word.title() for word in header.strip().split('-') ])
23
24
25def parse_footer(line):
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000026 """Returns footer's (key, value) if footer is valid, else None."""
luqui@chromium.org0b887622014-09-03 02:31:03 +000027 match = FOOTER_PATTERN.match(line)
28 if match:
29 return (match.group(1), match.group(2))
30 else:
31 return None
32
33
34def parse_footers(message):
35 """Parses a git commit message into a multimap of footers."""
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000036 _, _, parsed_footers = split_footers(message)
37 footer_map = defaultdict(list)
38 if parsed_footers:
39 # Read footers from bottom to top, because latter takes precedense,
40 # and we want it to be first in the multimap value.
41 for (k, v) in reversed(parsed_footers):
42 footer_map[normalize_name(k)].append(v.strip())
43 return footer_map
44
45
46def split_footers(message):
47 """Returns (non_footer_lines, footer_lines, parsed footers).
48
49 Guarantees that:
50 (non_footer_lines + footer_lines) == message.splitlines().
51 parsed_footers is parse_footer applied on each line of footer_lines.
52 """
53 message_lines = list(message.splitlines())
luqui@chromium.org0b887622014-09-03 02:31:03 +000054 footer_lines = []
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000055 for line in reversed(message_lines):
luqui@chromium.org0b887622014-09-03 02:31:03 +000056 if line == '' or line.isspace():
57 break
58 footer_lines.append(line)
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000059 else:
60 # The whole description was consisting of footers,
61 # which means those aren't footers.
62 footer_lines = []
luqui@chromium.org0b887622014-09-03 02:31:03 +000063
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000064 footer_lines.reverse()
luqui@chromium.org0b887622014-09-03 02:31:03 +000065 footers = map(parse_footer, footer_lines)
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000066 if not footer_lines or not all(footers):
67 return message_lines, [], []
68 return message_lines[:-len(footer_lines)], footer_lines, footers
luqui@chromium.org0b887622014-09-03 02:31:03 +000069
70
mmoss@chromium.orgf0e41522015-06-10 19:52:01 +000071def get_footer_svn_id(branch=None):
72 if not branch:
73 branch = git.root()
74 svn_id = None
75 message = git.run('log', '-1', '--format=%B', branch)
76 footers = parse_footers(message)
77 git_svn_id = get_unique(footers, 'git-svn-id')
78 if git_svn_id:
79 match = GIT_SVN_ID_PATTERN.match(git_svn_id)
80 if match:
81 svn_id = match.group(1)
82 return svn_id
83
84
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +000085def get_footer_change_id(message):
86 """Returns a list of Gerrit's ChangeId from given commit message."""
87 return parse_footers(message).get(normalize_name('Change-Id'), [])
88
89
90def add_footer_change_id(message, change_id):
91 """Returns message with Change-ID footer in it.
92
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000093 Assumes that Change-Id is not yet in footers, which is then inserted at
94 earliest footer line which is after all of these footers:
95 Bug|Issue|Test|Feature.
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +000096 """
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000097 assert 'Change-Id' not in parse_footers(message)
98 return add_footer(message, 'Change-Id', change_id,
99 after_keys=['Bug', 'Issue', 'Test', 'Feature'])
100
101def add_footer(message, key, value, after_keys=None):
102 """Returns a message with given footer appended.
103
104 If after_keys is None (default), appends footer last.
105 Otherwise, after_keys must be iterable of footer keys, then the new footer
106 would be inserted at the topmost position such there would be no footer lines
107 after it with key matching one of after_keys.
108 For example, given
109 message='Header.\n\nAdded: 2016\nBug: 123\nVerified-By: CQ'
110 after_keys=['Bug', 'Issue']
111 the new footer will be inserted between Bug and Verified-By existing footers.
112 """
113 assert key == normalize_name(key), 'Use normalized key'
114 new_footer = '%s: %s' % (key, value)
115
116 top_lines, footer_lines, parsed_footers = split_footers(message)
117 if not footer_lines:
118 if not top_lines or top_lines[-1] != '':
119 top_lines.append('')
120 footer_lines = [new_footer]
121 elif not after_keys:
122 footer_lines.append(new_footer)
tandrii@chromium.org9fc50db2016-03-17 12:38:55 +0000123 else:
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +0000124 after_keys = set(map(normalize_name, after_keys))
125 # Iterate from last to first footer till we find the footer keys above.
126 for i, (key, _) in reversed(list(enumerate(parsed_footers))):
127 if normalize_name(key) in after_keys:
128 footer_lines.insert(i + 1, new_footer)
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +0000129 break
130 else:
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +0000131 footer_lines.insert(0, new_footer)
132 return '\n'.join(top_lines + footer_lines)
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +0000133
134
luqui@chromium.org0b887622014-09-03 02:31:03 +0000135def get_unique(footers, key):
136 key = normalize_name(key)
137 values = footers[key]
138 assert len(values) <= 1, 'Multiple %s footers' % key
139 if values:
140 return values[0]
141 else:
142 return None
143
144
145def get_position(footers):
iannucci@chromium.org74c44f62014-09-09 22:35:03 +0000146 """Get the commit position from the footers multimap using a heuristic.
luqui@chromium.org0b887622014-09-03 02:31:03 +0000147
148 Returns:
149 A tuple of the branch and the position on that branch. For example,
150
151 Cr-Commit-Position: refs/heads/master@{#292272}
152
153 would give the return value ('refs/heads/master', 292272). If
154 Cr-Commit-Position is not defined, we try to infer the ref and position
155 from git-svn-id. The position number can be None if it was not inferrable.
156 """
157
158 position = get_unique(footers, 'Cr-Commit-Position')
159 if position:
160 match = CHROME_COMMIT_POSITION_PATTERN.match(position)
161 assert match, 'Invalid Cr-Commit-Position value: %s' % position
162 return (match.group(1), match.group(2))
163
164 svn_commit = get_unique(footers, 'git-svn-id')
165 if svn_commit:
166 match = GIT_SVN_ID_PATTERN.match(svn_commit)
167 assert match, 'Invalid git-svn-id value: %s' % svn_commit
hinoka@chromium.org4593f472014-10-13 21:25:43 +0000168 # V8 has different semantics than Chromium.
169 if re.match(r'.*https?://v8\.googlecode\.com/svn/trunk',
170 match.group(1)):
171 return ('refs/heads/candidates', match.group(2))
172 if re.match(r'.*https?://v8\.googlecode\.com/svn/branches/bleeding_edge',
173 match.group(1)):
174 return ('refs/heads/master', match.group(2))
175
iannucci@chromium.org74c44f62014-09-09 22:35:03 +0000176 # Assume that any trunk svn revision will match the commit-position
177 # semantics.
iannucci@chromium.org0a17dab2014-09-09 23:07:36 +0000178 if re.match('.*/trunk.*$', match.group(1)):
luqui@chromium.org0b887622014-09-03 02:31:03 +0000179 return ('refs/heads/master', match.group(2))
iannucci@chromium.org74c44f62014-09-09 22:35:03 +0000180
181 # But for now only support faking branch-heads for chrome.
luqui@chromium.org0b887622014-09-03 02:31:03 +0000182 branch_match = re.match('.*/chrome/branches/([\w/-]+)/src$', match.group(1))
183 if branch_match:
184 # svn commit numbers do not map to branches.
185 return ('refs/branch-heads/%s' % branch_match.group(1), None)
186
187 raise ValueError('Unable to infer commit position from footers')
188
189
190def main(args):
191 parser = argparse.ArgumentParser(
192 formatter_class=argparse.ArgumentDefaultsHelpFormatter
193 )
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000194 parser.add_argument('ref', nargs='?', help="Git ref to retrieve footers from."
195 " Omit to parse stdin.")
luqui@chromium.org0b887622014-09-03 02:31:03 +0000196
197 g = parser.add_mutually_exclusive_group()
198 g.add_argument('--key', metavar='KEY',
199 help='Get all values for the given footer name, one per '
200 'line (case insensitive)')
201 g.add_argument('--position', action='store_true')
202 g.add_argument('--position-ref', action='store_true')
203 g.add_argument('--position-num', action='store_true')
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000204 g.add_argument('--json', help="filename to dump JSON serialized headers to.")
luqui@chromium.org0b887622014-09-03 02:31:03 +0000205
206
207 opts = parser.parse_args(args)
208
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000209 if opts.ref:
210 message = git.run('log', '-1', '--format=%B', opts.ref)
211 else:
212 message = '\n'.join(l for l in sys.stdin)
213
luqui@chromium.org0b887622014-09-03 02:31:03 +0000214 footers = parse_footers(message)
215
216 if opts.key:
217 for v in footers.get(normalize_name(opts.key), []):
218 print v
219 elif opts.position:
220 pos = get_position(footers)
221 print '%s@{#%s}' % (pos[0], pos[1] or '?')
222 elif opts.position_ref:
223 print get_position(footers)[0]
224 elif opts.position_num:
225 pos = get_position(footers)
226 assert pos[1], 'No valid position for commit'
227 print pos[1]
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000228 elif opts.json:
229 with open(opts.json, 'w') as f:
230 json.dump(footers, f)
luqui@chromium.org0b887622014-09-03 02:31:03 +0000231 else:
232 for k in footers.keys():
233 for v in footers[k]:
234 print '%s: %s' % (k, v)
sbc@chromium.org013731e2015-02-26 18:28:43 +0000235 return 0
luqui@chromium.org0b887622014-09-03 02:31:03 +0000236
237
238if __name__ == '__main__':
sbc@chromium.org013731e2015-02-26 18:28:43 +0000239 try:
240 sys.exit(main(sys.argv[1:]))
241 except KeyboardInterrupt:
242 sys.stderr.write('interrupted\n')
243 sys.exit(1)