blob: 73c92004aa66eb613f6ea2f31179f95044bd7e77 [file] [log] [blame]
sammc@chromium.org900a33f2015-09-29 06:57:09 +00001#!/usr/bin/env python
2# Copyright 2015 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"""git drover: A tool for merging changes to release branches."""
6
7import argparse
8import functools
9import logging
10import os
11import shutil
12import subprocess
13import sys
14import tempfile
15
16import git_common
17
18
19class Error(Exception):
20 pass
21
22
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +000023if os.name == 'nt':
24 # This is a just-good-enough emulation of os.symlink for drover to work on
25 # Windows. It uses junctioning of directories (most of the contents of
26 # the .git directory), but copies files. Note that we can't use
27 # CreateSymbolicLink or CreateHardLink here, as they both require elevation.
28 # Creating reparse points is what we want for the directories, but doing so
29 # is a relatively messy set of DeviceIoControl work at the API level, so we
30 # simply shell to `mklink /j` instead.
31 def emulate_symlink_windows(source, link_name):
32 if os.path.isdir(source):
33 subprocess.check_call(['mklink', '/j',
34 link_name.replace('/', '\\'),
35 source.replace('/', '\\')],
36 shell=True)
37 else:
38 shutil.copy(source, link_name)
39 mk_symlink = emulate_symlink_windows
40else:
41 mk_symlink = os.symlink
42
43
sammc@chromium.org900a33f2015-09-29 06:57:09 +000044class _Drover(object):
45
46 def __init__(self, branch, revision, parent_repo, dry_run):
47 self._branch = branch
48 self._branch_ref = 'refs/remotes/branch-heads/%s' % branch
49 self._revision = revision
50 self._parent_repo = os.path.abspath(parent_repo)
51 self._dry_run = dry_run
52 self._workdir = None
53 self._branch_name = None
54 self._dev_null_file = open(os.devnull, 'w')
55
56 def run(self):
57 """Runs this Drover instance.
58
59 Raises:
60 Error: An error occurred while attempting to cherry-pick this change.
61 """
62 try:
63 self._run_internal()
64 finally:
65 self._cleanup()
66
67 def _run_internal(self):
68 self._check_inputs()
69 if not self._confirm('Going to cherry-pick\n"""\n%s"""\nto %s.' % (
70 self._run_git_command(['show', '-s', self._revision]), self._branch)):
71 return
72 self._create_checkout()
73 self._prepare_cherry_pick()
74 if self._dry_run:
75 logging.info('--dry_run enabled; not landing.')
76 return
77
78 self._run_git_command(['cl', 'upload'],
79 error_message='Upload failed',
80 interactive=True)
81
82 if not self._confirm('About to land on %s.' % self._branch):
83 return
84 self._run_git_command(['cl', 'land', '--bypass-hooks'], interactive=True)
85
86 def _cleanup(self):
87 if self._branch_name:
88 try:
89 self._run_git_command(['cherry-pick', '--abort'])
90 except Error:
91 pass
92 self._run_git_command(['checkout', '--detach'])
93 self._run_git_command(['branch', '-D', self._branch_name])
94 if self._workdir:
95 logging.debug('Deleting %s', self._workdir)
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +000096 if os.name == 'nt':
97 # Use rmdir to properly handle the junctions we created.
98 subprocess.check_call(['rmdir', '/s', '/q', self._workdir], shell=True)
99 else:
100 shutil.rmtree(self._workdir)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000101 self._dev_null_file.close()
102
103 @staticmethod
104 def _confirm(message):
105 """Show a confirmation prompt with the given message.
106
107 Returns:
108 A bool representing whether the user wishes to continue.
109 """
110 result = ''
111 while result not in ('y', 'n'):
112 try:
113 result = raw_input('%s Continue (y/n)? ' % message)
114 except EOFError:
115 result = 'n'
116 return result == 'y'
117
118 def _check_inputs(self):
119 """Check the input arguments and ensure the parent repo is up to date."""
120
121 if not os.path.isdir(self._parent_repo):
122 raise Error('Invalid parent repo path %r' % self._parent_repo)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000123
124 self._run_git_command(['--help'], error_message='Unable to run git')
125 self._run_git_command(['status'],
126 error_message='%r is not a valid git repo' %
127 os.path.abspath(self._parent_repo))
128 self._run_git_command(['fetch', 'origin'],
129 error_message='Failed to fetch origin')
130 self._run_git_command(
131 ['rev-parse', '%s^{commit}' % self._branch_ref],
132 error_message='Branch %s not found' % self._branch_ref)
133 self._run_git_command(
134 ['rev-parse', '%s^{commit}' % self._revision],
135 error_message='Revision "%s" not found' % self._revision)
136
137 FILES_TO_LINK = [
138 'refs',
139 'logs/refs',
140 'info/refs',
141 'info/exclude',
142 'objects',
143 'hooks',
144 'packed-refs',
145 'remotes',
146 'rr-cache',
147 'svn',
148 ]
149 FILES_TO_COPY = ['config', 'HEAD']
150
151 def _create_checkout(self):
152 """Creates a checkout to use for cherry-picking.
153
154 This creates a checkout similarly to git-new-workdir. Most of the .git
155 directory is shared with the |self._parent_repo| using symlinks. This
156 differs from git-new-workdir in that the config is forked instead of shared.
157 This is so the new workdir can be a sparse checkout without affecting
158 |self._parent_repo|.
159 """
160 parent_git_dir = os.path.abspath(self._run_git_command(
161 ['rev-parse', '--git-dir']).strip())
162 self._workdir = tempfile.mkdtemp(prefix='drover_%s_' % self._branch)
163 logging.debug('Creating checkout in %s', self._workdir)
164 git_dir = os.path.join(self._workdir, '.git')
165 git_common.make_workdir_common(parent_git_dir, git_dir, self.FILES_TO_LINK,
scottmg@chromium.orgd4218d42015-10-07 23:49:20 +0000166 self.FILES_TO_COPY, mk_symlink)
sammc@chromium.org900a33f2015-09-29 06:57:09 +0000167 self._run_git_command(['config', 'core.sparsecheckout', 'true'])
168 with open(os.path.join(git_dir, 'info', 'sparse-checkout'), 'w') as f:
169 f.write('codereview.settings')
170
171 branch_name = os.path.split(self._workdir)[-1]
172 self._run_git_command(['checkout', '-b', branch_name, self._branch_ref])
173 self._branch_name = branch_name
174
175 def _prepare_cherry_pick(self):
176 self._run_git_command(['cherry-pick', '-x', self._revision],
177 error_message='Patch failed to apply')
178 self._run_git_command(['reset', '--hard'])
179
180 def _run_git_command(self, args, error_message=None, interactive=False):
181 """Runs a git command.
182
183 Args:
184 args: A list of strings containing the args to pass to git.
185 interactive:
186 error_message: A string containing the error message to report if the
187 command fails.
188
189 Raises:
190 Error: The command failed to complete successfully.
191 """
192 cwd = self._workdir if self._workdir else self._parent_repo
193 logging.debug('Running git %s (cwd %r)', ' '.join('%s' % arg
194 for arg in args), cwd)
195
196 run = subprocess.check_call if interactive else subprocess.check_output
197
198 try:
199 return run(['git'] + args,
200 shell=False,
201 cwd=cwd,
202 stderr=self._dev_null_file)
203 except (OSError, subprocess.CalledProcessError) as e:
204 if error_message:
205 raise Error(error_message)
206 else:
207 raise Error('Command %r failed: %s' % (' '.join(args), e))
208
209
210def cherry_pick_change(branch, revision, parent_repo, dry_run):
211 """Cherry-picks a change into a branch.
212
213 Args:
214 branch: A string containing the release branch number to which to
215 cherry-pick.
216 revision: A string containing the revision to cherry-pick. It can be any
217 string that git-rev-parse can identify as referring to a single
218 revision.
219 parent_repo: A string containing the path to the parent repo to use for this
220 cherry-pick.
221 dry_run: A boolean containing whether to stop before uploading the
222 cherry-pick cl.
223
224 Raises:
225 Error: An error occurred while attempting to cherry-pick |cl| to |branch|.
226 """
227 drover = _Drover(branch, revision, parent_repo, dry_run)
228 drover.run()
229
230
231def main():
232 parser = argparse.ArgumentParser(
233 description='Cherry-pick a change into a release branch.')
234 parser.add_argument(
235 '--branch',
236 type=str,
237 required=True,
238 metavar='<branch>',
239 help='the name of the branch to which to cherry-pick; e.g. 1234')
240 parser.add_argument('--cherry-pick',
241 type=str,
242 required=True,
243 metavar='<change>',
244 help=('the change to cherry-pick; this can be any string '
245 'that unambiguously refers to a revision'))
246 parser.add_argument(
247 '--parent_checkout',
248 type=str,
249 default=os.path.abspath('.'),
250 metavar='<path_to_parent_checkout>',
251 help=('the path to the chromium checkout to use as the source for a '
252 'creating git-new-workdir workdir to use for cherry-picking; '
253 'if unspecified, the current directory is used'))
254 parser.add_argument(
255 '--dry-run',
256 action='store_true',
257 default=False,
258 help=("don't actually upload and land; "
259 "just check that cherry-picking would succeed"))
260 parser.add_argument('-v',
261 '--verbose',
262 action='store_true',
263 default=False,
264 help='show verbose logging')
265 options = parser.parse_args()
266 if options.verbose:
267 logging.getLogger().setLevel(logging.DEBUG)
268 try:
269 cherry_pick_change(options.branch, options.cherry_pick,
270 options.parent_checkout, options.dry_run)
271 except Error as e:
272 logging.error(e.message)
273 sys.exit(128)
274
275
276if __name__ == '__main__':
277 main()