blob: dbd50f208c2ec3e4a124262108083db54ea06c12 [file] [log] [blame]
Josip Sokcevic4de5dea2022-03-23 21:15:14 +00001#!/usr/bin/env python3
maruel@chromium.org96550942015-05-22 18:46:51 +00002# Copyright 2015 The Chromium Authors. All rights reserved.
szager@chromium.org03fd85b2014-06-09 23:43:33 +00003# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
maruel@chromium.org96550942015-05-22 18:46:51 +00005"""Rolls DEPS controlled dependency.
szager@chromium.org03fd85b2014-06-09 23:43:33 +00006
Josip Sokcevicc1a06c12022-01-12 00:44:14 +00007Works only with git checkout and git dependencies. Currently this script will
8always roll to the tip of to origin/main.
szager@chromium.org03fd85b2014-06-09 23:43:33 +00009"""
10
sbc@chromium.org30e5b232015-06-01 18:06:59 +000011import argparse
Thiago Perrottaa0892812022-09-03 11:22:46 +000012import itertools
szager@chromium.org03fd85b2014-06-09 23:43:33 +000013import os
14import re
Corentin Wallez5157fbf2020-11-12 22:31:35 +000015import subprocess2
szager@chromium.org03fd85b2014-06-09 23:43:33 +000016import sys
Bruce Dawson03af44a2022-12-27 19:37:58 +000017import tempfile
szager@chromium.org03fd85b2014-06-09 23:43:33 +000018
scottmg@chromium.orgc20f4702015-05-23 00:44:46 +000019NEED_SHELL = sys.platform.startswith('win')
Mike Frysinger124bb8e2023-09-06 05:48:55 +000020GCLIENT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
21 'gclient.py')
scottmg@chromium.orgc20f4702015-05-23 00:44:46 +000022
Marc-Antoine Ruel1e2cb152019-04-17 17:32:52 +000023# Commit subject that will be considered a roll. In the format generated by the
24# git log used, so it's "<year>-<month>-<day> <author> <subject>"
25_ROLL_SUBJECT = re.compile(
26 # Date
27 r'^\d\d\d\d-\d\d-\d\d '
28 # Author
29 r'[^ ]+ '
30 # Subject
31 r'('
Mike Frysinger124bb8e2023-09-06 05:48:55 +000032 # Generated by
33 # https://skia.googlesource.com/buildbot/+/HEAdA/autoroll/go/repo_manager/deps_repo_manager.go
34 r'Roll [^ ]+ [a-f0-9]+\.\.[a-f0-9]+ \(\d+ commits\)'
35 r'|'
36 # Generated by
37 # https://chromium.googlesource.com/infra/infra/+/HEAD/recipes/recipe_modules/recipe_autoroller/api.py
38 r'Roll recipe dependencies \(trivial\)\.'
Marc-Antoine Ruel1e2cb152019-04-17 17:32:52 +000039 r')$')
40
41
sbc@chromium.orge5d984b2015-05-29 22:09:39 +000042class Error(Exception):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000043 pass
sbc@chromium.orge5d984b2015-05-29 22:09:39 +000044
45
smut7036c4f2016-06-09 14:28:48 -070046class AlreadyRolledError(Error):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000047 pass
smut7036c4f2016-06-09 14:28:48 -070048
49
scottmg@chromium.orgc20f4702015-05-23 00:44:46 +000050def check_output(*args, **kwargs):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000051 """subprocess2.check_output() passing shell=True on Windows for git."""
52 kwargs.setdefault('shell', NEED_SHELL)
53 return subprocess2.check_output(*args, **kwargs).decode('utf-8')
scottmg@chromium.orgc20f4702015-05-23 00:44:46 +000054
55
56def check_call(*args, **kwargs):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000057 """subprocess2.check_call() passing shell=True on Windows for git."""
58 kwargs.setdefault('shell', NEED_SHELL)
59 subprocess2.check_call(*args, **kwargs)
scottmg@chromium.orgc20f4702015-05-23 00:44:46 +000060
61
Corentin Wallez5157fbf2020-11-12 22:31:35 +000062def return_code(*args, **kwargs):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000063 """subprocess2.call() passing shell=True on Windows for git and
Edward Lesmescf06cad2020-12-14 22:03:23 +000064 subprocess2.DEVNULL for stdout and stderr."""
Mike Frysinger124bb8e2023-09-06 05:48:55 +000065 kwargs.setdefault('shell', NEED_SHELL)
66 kwargs.setdefault('stdout', subprocess2.DEVNULL)
67 kwargs.setdefault('stderr', subprocess2.DEVNULL)
68 return subprocess2.call(*args, **kwargs)
Corentin Wallez5157fbf2020-11-12 22:31:35 +000069
70
71def is_pristine(root):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000072 """Returns True if a git checkout is pristine."""
73 # `git rev-parse --verify` has a non-zero return code if the revision
74 # doesn't exist.
75 diff_cmd = ['git', 'diff', '--ignore-submodules', 'origin/main']
76 return (not check_output(diff_cmd, cwd=root).strip()
77 and not check_output(diff_cmd + ['--cached'], cwd=root).strip())
Corentin Wallez5157fbf2020-11-12 22:31:35 +000078
szager@chromium.org03fd85b2014-06-09 23:43:33 +000079
Josip Sokcevic9c0dc302020-11-20 18:41:25 +000080def get_log_url(upstream_url, head, tot):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000081 """Returns an URL to read logs via a Web UI if applicable."""
82 if re.match(r'https://[^/]*\.googlesource\.com/', upstream_url):
83 # gitiles
84 return '%s/+log/%s..%s' % (upstream_url, head[:12], tot[:12])
85 if upstream_url.startswith('https://github.com/'):
86 upstream_url = upstream_url.rstrip('/')
87 if upstream_url.endswith('.git'):
88 upstream_url = upstream_url[:-len('.git')]
89 return '%s/compare/%s...%s' % (upstream_url, head[:12], tot[:12])
90 return None
maruel@chromium.org398ed342015-09-29 12:27:25 +000091
92
93def should_show_log(upstream_url):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000094 """Returns True if a short log should be included in the tree."""
95 # Skip logs for very active projects.
96 if upstream_url.endswith('/v8/v8.git'):
97 return False
98 if 'webrtc' in upstream_url:
99 return False
100 return True
maruel@chromium.org398ed342015-09-29 12:27:25 +0000101
102
Edward Lemurfaae42e2018-11-26 18:34:30 +0000103def gclient(args):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000104 """Executes gclient with the given args and returns the stdout."""
105 return check_output([sys.executable, GCLIENT_PATH] + args).strip()
sbc@chromium.org98201122015-04-22 20:21:34 +0000106
maruel@chromium.org96550942015-05-22 18:46:51 +0000107
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000108def generate_commit_message(full_dir, dependency, head, roll_to, no_log,
109 log_limit):
110 """Creates the commit message for this specific roll."""
111 commit_range = '%s..%s' % (head, roll_to)
112 commit_range_for_header = '%s..%s' % (head[:9], roll_to[:9])
113 upstream_url = check_output(['git', 'config', 'remote.origin.url'],
114 cwd=full_dir).strip()
115 log_url = get_log_url(upstream_url, head, roll_to)
116 cmd = ['git', 'log', commit_range, '--date=short', '--no-merges']
117 logs = check_output(
118 # Args with '=' are automatically quoted.
119 cmd + ['--format=%ad %ae %s', '--'],
120 cwd=full_dir).rstrip()
121 logs = re.sub(r'(?m)^(\d\d\d\d-\d\d-\d\d [^@]+)@[^ ]+( .*)$', r'\1\2', logs)
122 lines = logs.splitlines()
123 cleaned_lines = [l for l in lines if not _ROLL_SUBJECT.match(l)]
124 logs = '\n'.join(cleaned_lines) + '\n'
Marc-Antoine Ruel85a8c102017-12-12 15:42:25 -0500125
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000126 nb_commits = len(lines)
127 rolls = nb_commits - len(cleaned_lines)
128 header = 'Roll %s/ %s (%d commit%s%s)\n\n' % (
129 dependency, commit_range_for_header, nb_commits,
130 's' if nb_commits > 1 else '',
131 ('; %s trivial rolls' % rolls) if rolls else '')
132 log_section = ''
133 if log_url:
134 log_section = log_url + '\n\n'
135 log_section += '$ %s ' % ' '.join(cmd)
136 log_section += '--format=\'%ad %ae %s\'\n'
137 log_section = log_section.replace(commit_range, commit_range_for_header)
138 # It is important that --no-log continues to work, as it is used by
139 # internal -> external rollers. Please do not remove or break it.
140 if not no_log and should_show_log(upstream_url):
141 if len(cleaned_lines) > log_limit:
142 # Keep the first N/2 log entries and last N/2 entries.
143 lines = logs.splitlines(True)
144 lines = lines[:log_limit // 2] + ['(...)\n'
145 ] + lines[-log_limit // 2:]
146 logs = ''.join(lines)
147 log_section += logs
148 return header + log_section
maruel@chromium.org398ed342015-09-29 12:27:25 +0000149
maruel@chromium.org96550942015-05-22 18:46:51 +0000150
Aravind Vasudevan474e28e2023-01-18 20:29:51 +0000151def is_submoduled():
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000152 """Returns true if gclient root has submodules"""
153 return os.path.isfile(os.path.join(gclient(['root']), ".gitmodules"))
Aravind Vasudevan474e28e2023-01-18 20:29:51 +0000154
155
156def get_submodule_rev(submodule):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000157 """Returns revision of the given submodule path"""
158 rev_output = check_output(['git', 'submodule', 'status', submodule],
159 cwd=gclient(['root'])).strip()
Aravind Vasudevan474e28e2023-01-18 20:29:51 +0000160
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000161 # git submodule status <path> returns all submodules with its rev in the
162 # pattern: `(+|-| )(<revision>) (submodule.path)`
163 revision = rev_output.split(' ')[0]
164 return revision[1:] if revision[0] in ('+', '-') else revision
Aravind Vasudevan474e28e2023-01-18 20:29:51 +0000165
166
Edward Lemurfaae42e2018-11-26 18:34:30 +0000167def calculate_roll(full_dir, dependency, roll_to):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000168 """Calculates the roll for a dependency by processing gclient_dict, and
Marc-Antoine Ruel85a8c102017-12-12 15:42:25 -0500169 fetching the dependency via git.
170 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000171 # if the super-project uses submodules, get rev directly using git.
172 if is_submoduled():
173 head = get_submodule_rev(dependency)
174 else:
175 head = gclient(['getdep', '-r', dependency])
176 if not head:
177 raise Error('%s is unpinned.' % dependency)
178 check_call(['git', 'fetch', 'origin', '--quiet'], cwd=full_dir)
179 if roll_to == 'origin/HEAD':
180 check_output(['git', 'remote', 'set-head', 'origin', '-a'],
181 cwd=full_dir)
Josip Sokcevic69902d02021-02-02 21:57:55 +0000182
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000183 roll_to = check_output(['git', 'rev-parse', roll_to], cwd=full_dir).strip()
184 return head, roll_to
Marc-Antoine Ruel85a8c102017-12-12 15:42:25 -0500185
Josip Sokcevic69902d02021-02-02 21:57:55 +0000186
Robert Iannuccic1e65942018-10-18 17:59:45 +0000187def gen_commit_msg(logs, cmdline, reviewers, bug):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000188 """Returns the final commit message."""
189 commit_msg = ''
190 if len(logs) > 1:
191 commit_msg = 'Rolling %d dependencies\n\n' % len(logs)
192 commit_msg += '\n\n'.join(logs)
193 commit_msg += '\nCreated with:\n ' + cmdline + '\n'
194 commit_msg += 'R=%s\n' % ','.join(reviewers) if reviewers else ''
195 commit_msg += '\nBug: %s\n' % bug if bug else ''
196 return commit_msg
Marc-Antoine Ruel85a8c102017-12-12 15:42:25 -0500197
198
Edward Lemurfaae42e2018-11-26 18:34:30 +0000199def finalize(commit_msg, current_dir, rolls):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000200 """Commits changes to the DEPS file, then uploads a CL."""
201 print('Commit message:')
202 print('\n'.join(' ' + i for i in commit_msg.splitlines()))
Marc-Antoine Ruel85a8c102017-12-12 15:42:25 -0500203
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000204 # Pull the dependency to the right revision. This is surprising to users
205 # otherwise. The revision update is done before commiting to update
206 # submodule revision if present.
207 for dependency, (_head, roll_to, full_dir) in sorted(rolls.items()):
208 check_call(['git', 'checkout', '--quiet', roll_to], cwd=full_dir)
Aravind Vasudevan474e28e2023-01-18 20:29:51 +0000209
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000210 # This adds the submodule revision update to the commit.
211 if is_submoduled():
212 check_call([
213 'git', 'update-index', '--add', '--cacheinfo',
214 '160000,{},{}'.format(roll_to, dependency)
215 ],
216 cwd=current_dir)
Aravind Vasudevan474e28e2023-01-18 20:29:51 +0000217
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000218 check_call(['git', 'add', 'DEPS'], cwd=current_dir)
219 # We have to set delete=False and then let the object go out of scope so
220 # that the file can be opened by name on Windows.
221 with tempfile.NamedTemporaryFile('w+', newline='', delete=False) as f:
222 commit_filename = f.name
223 f.write(commit_msg)
224 check_call(['git', 'commit', '--quiet', '--file', commit_filename],
225 cwd=current_dir)
226 os.remove(commit_filename)
maruel@chromium.org398ed342015-09-29 12:27:25 +0000227
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000228
maruel@chromium.org96550942015-05-22 18:46:51 +0000229def main():
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000230 parser = argparse.ArgumentParser(description=__doc__)
231 parser.add_argument('--ignore-dirty-tree',
232 action='store_true',
233 help='Roll anyways, even if there is a diff.')
234 parser.add_argument(
235 '-r',
236 '--reviewer',
237 action='append',
238 help='To specify multiple reviewers, either use a comma separated '
239 'list, e.g. -r joe,jane,john or provide the flag multiple times, e.g. '
240 '-r joe -r jane. Defaults to @chromium.org')
241 parser.add_argument('-b',
242 '--bug',
243 help='Associate a bug number to the roll')
244 # It is important that --no-log continues to work, as it is used by
245 # internal -> external rollers. Please do not remove or break it.
246 parser.add_argument(
247 '--no-log',
248 action='store_true',
249 help='Do not include the short log in the commit message')
250 parser.add_argument('--log-limit',
251 type=int,
252 default=100,
253 help='Trim log after N commits (default: %(default)s)')
254 parser.add_argument(
255 '--roll-to',
256 default='origin/HEAD',
257 help='Specify the new commit to roll to (default: %(default)s)')
258 parser.add_argument('--key',
259 action='append',
260 default=[],
261 help='Regex(es) for dependency in DEPS file')
262 parser.add_argument('dep_path', nargs='+', help='Path(s) to dependency')
263 args = parser.parse_args()
maruel@chromium.org96550942015-05-22 18:46:51 +0000264
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000265 if len(args.dep_path) > 1:
266 if args.roll_to != 'origin/HEAD':
267 parser.error(
268 'Can\'t use multiple paths to roll simultaneously and --roll-to'
269 )
270 if args.key:
271 parser.error(
272 'Can\'t use multiple paths to roll simultaneously and --key')
273 reviewers = None
274 if args.reviewer:
275 reviewers = list(itertools.chain(*[r.split(',')
276 for r in args.reviewer]))
277 for i, r in enumerate(reviewers):
278 if not '@' in r:
279 reviewers[i] = r + '@chromium.org'
maruel@chromium.org96550942015-05-22 18:46:51 +0000280
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000281 gclient_root = gclient(['root'])
282 current_dir = os.getcwd()
283 dependencies = sorted(
284 d.replace('\\', '/').rstrip('/') for d in args.dep_path)
285 cmdline = 'roll-dep ' + ' '.join(dependencies) + ''.join(' --key ' + k
286 for k in args.key)
287 try:
288 if not args.ignore_dirty_tree and not is_pristine(current_dir):
289 raise Error('Ensure %s is clean first (no non-merged commits).' %
290 current_dir)
291 # First gather all the information without modifying anything, except
292 # for a git fetch.
293 rolls = {}
294 for dependency in dependencies:
295 full_dir = os.path.normpath(os.path.join(gclient_root, dependency))
296 if not os.path.isdir(full_dir):
297 print('Dependency %s not found at %s' % (dependency, full_dir))
298 full_dir = os.path.normpath(
299 os.path.join(current_dir, dependency))
300 print('Will look for relative dependency at %s' % full_dir)
301 if not os.path.isdir(full_dir):
302 raise Error('Directory not found: %s (%s)' %
303 (dependency, full_dir))
Edward Lemurfaae42e2018-11-26 18:34:30 +0000304
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000305 head, roll_to = calculate_roll(full_dir, dependency, args.roll_to)
306 if roll_to == head:
307 if len(dependencies) == 1:
308 raise AlreadyRolledError('No revision to roll!')
309 print('%s: Already at latest commit %s' % (dependency, roll_to))
310 else:
311 print('%s: Rolling from %s to %s' %
312 (dependency, head[:10], roll_to[:10]))
313 rolls[dependency] = (head, roll_to, full_dir)
sbc@chromium.orge5d984b2015-05-29 22:09:39 +0000314
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000315 logs = []
316 setdep_args = []
317 for dependency, (head, roll_to, full_dir) in sorted(rolls.items()):
318 log = generate_commit_message(full_dir, dependency, head, roll_to,
319 args.no_log, args.log_limit)
320 logs.append(log)
321 setdep_args.extend(['-r', '{}@{}'.format(dependency, roll_to)])
Edward Lesmesc772cf72018-04-03 14:47:30 -0400322
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000323 # DEPS is updated even if the repository uses submodules.
324 gclient(['setdep'] + setdep_args)
Marc-Antoine Ruel85a8c102017-12-12 15:42:25 -0500325
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000326 commit_msg = gen_commit_msg(logs, cmdline, reviewers, args.bug)
327 finalize(commit_msg, current_dir, rolls)
328 except Error as e:
329 sys.stderr.write('error: %s\n' % e)
330 return 2 if isinstance(e, AlreadyRolledError) else 1
331 except subprocess2.CalledProcessError:
332 return 1
sbc@chromium.orge5d984b2015-05-29 22:09:39 +0000333
Marc-Antoine Ruel85a8c102017-12-12 15:42:25 -0500334 print('')
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000335 if not reviewers:
336 print('You forgot to pass -r, make sure to insert a R=foo@example.com '
337 'line')
338 print('to the commit description before emailing.')
339 print('')
340 print('Run:')
341 print(' git cl upload --send-mail')
342 return 0
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000343
sbc@chromium.org98201122015-04-22 20:21:34 +0000344
szager@chromium.org03fd85b2014-06-09 23:43:33 +0000345if __name__ == '__main__':
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000346 sys.exit(main())