blob: 685d2f781a3e0740e9f910fb31a0e6349f84ba24 [file] [log] [blame]
[email protected]dfaecd22011-04-21 00:33:311# coding=utf8
[email protected]9799a072012-01-11 00:26:252# Copyright (c) 2012 The Chromium Authors. All rights reserved.
[email protected]dfaecd22011-04-21 00:33:313# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Manages a project checkout.
6
agablec972d182016-10-24 23:59:137Includes support only for git.
[email protected]dfaecd22011-04-21 00:33:318"""
9
[email protected]dfaecd22011-04-21 00:33:3110import fnmatch
11import logging
12import os
13import re
[email protected]5e975632011-09-29 18:07:0614import shutil
[email protected]dfaecd22011-04-21 00:33:3115import subprocess
16import sys
17import tempfile
18
vapier9f343712016-06-22 14:13:2019# The configparser module was renamed in Python 3.
20try:
21 import configparser
22except ImportError:
23 import ConfigParser as configparser
24
[email protected]dfaecd22011-04-21 00:33:3125import patch
26import scm
27import subprocess2
28
29
[email protected]9af0a112013-03-20 20:21:3530if sys.platform in ('cygwin', 'win32'):
31 # Disable timeouts on Windows since we can't have shells with timeouts.
32 GLOBAL_TIMEOUT = None
33 FETCH_TIMEOUT = None
34else:
35 # Default timeout of 15 minutes.
36 GLOBAL_TIMEOUT = 15*60
37 # Use a larger timeout for checkout since it can be a genuinely slower
38 # operation.
39 FETCH_TIMEOUT = 30*60
40
41
[email protected]dfaecd22011-04-21 00:33:3142def get_code_review_setting(path, key,
43 codereview_settings_file='codereview.settings'):
44 """Parses codereview.settings and return the value for the key if present.
45
46 Don't cache the values in case the file is changed."""
47 # TODO(maruel): Do not duplicate code.
48 settings = {}
49 try:
50 settings_file = open(os.path.join(path, codereview_settings_file), 'r')
51 try:
52 for line in settings_file.readlines():
53 if not line or line.startswith('#'):
54 continue
55 if not ':' in line:
56 # Invalid file.
57 return None
58 k, v = line.split(':', 1)
59 settings[k.strip()] = v.strip()
60 finally:
61 settings_file.close()
[email protected]004fb712011-06-21 20:02:1662 except IOError:
[email protected]dfaecd22011-04-21 00:33:3163 return None
64 return settings.get(key, None)
65
66
[email protected]4dd9f722012-10-01 16:23:0367def align_stdout(stdout):
68 """Returns the aligned output of multiple stdouts."""
69 output = ''
70 for item in stdout:
71 item = item.strip()
72 if not item:
73 continue
74 output += ''.join(' %s\n' % line for line in item.splitlines())
75 return output
76
77
[email protected]dfaecd22011-04-21 00:33:3178class PatchApplicationFailed(Exception):
79 """Patch failed to be applied."""
skobes2f3f1372016-10-25 15:08:2780 def __init__(self, errors, verbose):
81 super(PatchApplicationFailed, self).__init__(errors, verbose)
82 self.errors = errors
83 self.verbose = verbose
[email protected]34f68552012-05-09 19:18:3684
85 def __str__(self):
86 out = []
skobes2f3f1372016-10-25 15:08:2787 for e in self.errors:
88 p, status = e
89 if p and p.filename:
90 out.append('Failed to apply patch for %s:' % p.filename)
91 if status:
92 out.append(status)
93 if p and self.verbose:
94 out.append('Patch: %s' % p.dump())
[email protected]34f68552012-05-09 19:18:3695 return '\n'.join(out)
96
[email protected]dfaecd22011-04-21 00:33:3197
98class CheckoutBase(object):
99 # Set to None to have verbose output.
100 VOID = subprocess2.VOID
101
[email protected]6ed8b502011-06-12 01:05:35102 def __init__(self, root_dir, project_name, post_processors):
103 """
104 Args:
105 post_processor: list of lambda(checkout, patches) to call on each of the
106 modified files.
107 """
[email protected]a5129fb2011-06-20 18:36:25108 super(CheckoutBase, self).__init__()
[email protected]dfaecd22011-04-21 00:33:31109 self.root_dir = root_dir
110 self.project_name = project_name
[email protected]3cdb7f32011-05-05 16:37:24111 if self.project_name is None:
112 self.project_path = self.root_dir
113 else:
114 self.project_path = os.path.join(self.root_dir, self.project_name)
[email protected]dfaecd22011-04-21 00:33:31115 # Only used for logging purposes.
116 self._last_seen_revision = None
[email protected]a5129fb2011-06-20 18:36:25117 self.post_processors = post_processors
[email protected]dfaecd22011-04-21 00:33:31118 assert self.root_dir
[email protected]dfaecd22011-04-21 00:33:31119 assert self.project_path
[email protected]0aca0f92012-10-01 16:39:45120 assert os.path.isabs(self.project_path)
[email protected]dfaecd22011-04-21 00:33:31121
122 def get_settings(self, key):
123 return get_code_review_setting(self.project_path, key)
124
[email protected]51919772011-06-12 01:27:42125 def prepare(self, revision):
[email protected]dfaecd22011-04-21 00:33:31126 """Checks out a clean copy of the tree and removes any local modification.
127
128 This function shouldn't throw unless the remote repository is inaccessible,
129 there is no free disk space or hard issues like that.
[email protected]51919772011-06-12 01:27:42130
131 Args:
132 revision: The revision it should sync to, SCM specific.
[email protected]dfaecd22011-04-21 00:33:31133 """
134 raise NotImplementedError()
135
[email protected]c4396a12014-05-10 02:19:27136 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]dfaecd22011-04-21 00:33:31137 """Applies a patch and returns the list of modified files.
138
139 This function should throw patch.UnsupportedPatchFormat or
140 PatchApplicationFailed when relevant.
[email protected]8a1396c2011-04-22 00:14:24141
142 Args:
143 patches: patch.PatchSet object.
[email protected]dfaecd22011-04-21 00:33:31144 """
145 raise NotImplementedError()
146
147 def commit(self, commit_message, user):
148 """Commits the patch upstream, while impersonating 'user'."""
149 raise NotImplementedError()
150
[email protected]bc32ad12012-07-26 13:22:47151 def revisions(self, rev1, rev2):
152 """Returns the count of revisions from rev1 to rev2, e.g. len(]rev1, rev2]).
153
154 If rev2 is None, it means 'HEAD'.
155
156 Returns None if there is no link between the two.
157 """
158 raise NotImplementedError()
159
[email protected]dfaecd22011-04-21 00:33:31160
[email protected]3b5efdf2013-09-05 11:59:40161class GitCheckout(CheckoutBase):
162 """Manages a git checkout."""
163 def __init__(self, root_dir, project_name, remote_branch, git_url,
[email protected]c4396a12014-05-10 02:19:27164 commit_user, post_processors=None):
[email protected]3b5efdf2013-09-05 11:59:40165 super(GitCheckout, self).__init__(root_dir, project_name, post_processors)
166 self.git_url = git_url
167 self.commit_user = commit_user
[email protected]dfaecd22011-04-21 00:33:31168 self.remote_branch = remote_branch
[email protected]3b5efdf2013-09-05 11:59:40169 # The working branch where patches will be applied. It will track the
170 # remote branch.
[email protected]dfaecd22011-04-21 00:33:31171 self.working_branch = 'working_branch'
[email protected]3b5efdf2013-09-05 11:59:40172 # There is no reason to not hardcode origin.
[email protected]7e8c19d2014-03-19 16:47:37173 self.remote = 'origin'
174 # There is no reason to not hardcode master.
175 self.master_branch = 'master'
[email protected]dfaecd22011-04-21 00:33:31176
[email protected]51919772011-06-12 01:27:42177 def prepare(self, revision):
[email protected]dfaecd22011-04-21 00:33:31178 """Resets the git repository in a clean state.
179
180 Checks it out if not present and deletes the working branch.
181 """
[email protected]7dc11442014-03-12 22:37:32182 assert self.remote_branch
[email protected]7e8c19d2014-03-19 16:47:37183 assert self.git_url
[email protected]3b5efdf2013-09-05 11:59:40184
185 if not os.path.isdir(self.project_path):
186 # Clone the repo if the directory is not present.
[email protected]7e8c19d2014-03-19 16:47:37187 logging.info(
188 'Checking out %s in %s', self.project_name, self.project_path)
[email protected]3b5efdf2013-09-05 11:59:40189 self._check_call_git(
[email protected]7e8c19d2014-03-19 16:47:37190 ['clone', self.git_url, '-b', self.remote_branch, self.project_path],
[email protected]3b5efdf2013-09-05 11:59:40191 cwd=None, timeout=FETCH_TIMEOUT)
[email protected]7e8c19d2014-03-19 16:47:37192 else:
193 # Throw away all uncommitted changes in the existing checkout.
194 self._check_call_git(['checkout', self.remote_branch])
195 self._check_call_git(
196 ['reset', '--hard', '--quiet',
197 '%s/%s' % (self.remote, self.remote_branch)])
[email protected]3b5efdf2013-09-05 11:59:40198
[email protected]7e8c19d2014-03-19 16:47:37199 if revision:
200 try:
201 # Look if the commit hash already exist. If so, we can skip a
202 # 'git fetch' call.
[email protected]323ec372014-06-17 01:50:37203 revision = self._check_output_git(['rev-parse', revision]).rstrip()
[email protected]7e8c19d2014-03-19 16:47:37204 except subprocess.CalledProcessError:
205 self._check_call_git(
206 ['fetch', self.remote, self.remote_branch, '--quiet'])
[email protected]323ec372014-06-17 01:50:37207 revision = self._check_output_git(['rev-parse', revision]).rstrip()
[email protected]7e8c19d2014-03-19 16:47:37208 self._check_call_git(['checkout', '--force', '--quiet', revision])
209 else:
210 branches, active = self._branches()
211 if active != self.master_branch:
212 self._check_call_git(
213 ['checkout', '--force', '--quiet', self.master_branch])
214 self._sync_remote_branch()
[email protected]3b5efdf2013-09-05 11:59:40215
[email protected]7e8c19d2014-03-19 16:47:37216 if self.working_branch in branches:
217 self._call_git(['branch', '-D', self.working_branch])
[email protected]3b5efdf2013-09-05 11:59:40218 return self._get_head_commit_hash()
219
[email protected]7e8c19d2014-03-19 16:47:37220 def _sync_remote_branch(self):
221 """Syncs the remote branch."""
222 # We do a 'git pull origin master:refs/remotes/origin/master' instead of
[email protected]dabbea22014-04-21 23:58:11223 # 'git pull origin master' because from the manpage for git-pull:
[email protected]7e8c19d2014-03-19 16:47:37224 # A parameter <ref> without a colon is equivalent to <ref>: when
225 # pulling/fetching, so it merges <ref> into the current branch without
226 # storing the remote branch anywhere locally.
227 remote_tracked_path = 'refs/remotes/%s/%s' % (
228 self.remote, self.remote_branch)
229 self._check_call_git(
230 ['pull', self.remote,
231 '%s:%s' % (self.remote_branch, remote_tracked_path),
232 '--quiet'])
233
[email protected]3b5efdf2013-09-05 11:59:40234 def _get_head_commit_hash(self):
[email protected]11145db2013-10-03 12:43:40235 """Gets the current revision (in unicode) from the local branch."""
236 return unicode(self._check_output_git(['rev-parse', 'HEAD']).strip())
[email protected]dfaecd22011-04-21 00:33:31237
[email protected]c4396a12014-05-10 02:19:27238 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]3b5efdf2013-09-05 11:59:40239 """Applies a patch on 'working_branch' and switches to it.
[email protected]8a1396c2011-04-22 00:14:24240
[email protected]c4396a12014-05-10 02:19:27241 The changes remain staged on the current branch.
[email protected]8a1396c2011-04-22 00:14:24242 """
[email protected]b1d1a782011-09-29 14:13:55243 post_processors = post_processors or self.post_processors or []
[email protected]dfaecd22011-04-21 00:33:31244 # It this throws, the checkout is corrupted. Maybe worth deleting it and
245 # trying again?
[email protected]3cdb7f32011-05-05 16:37:24246 if self.remote_branch:
247 self._check_call_git(
[email protected]7e8c19d2014-03-19 16:47:37248 ['checkout', '-b', self.working_branch, '-t', self.remote_branch,
[email protected]3b5efdf2013-09-05 11:59:40249 '--quiet'])
250
skobes2f3f1372016-10-25 15:08:27251 errors = []
[email protected]5e975632011-09-29 18:07:06252 for index, p in enumerate(patches):
[email protected]4dd9f722012-10-01 16:23:03253 stdout = []
[email protected]dfaecd22011-04-21 00:33:31254 try:
Edward Lesmesf8792072017-09-13 08:05:12255 filepath = os.path.join(self.project_path, p.filename)
[email protected]dfaecd22011-04-21 00:33:31256 if p.is_delete:
[email protected]4dd9f722012-10-01 16:23:03257 if (not os.path.exists(filepath) and
[email protected]5e975632011-09-29 18:07:06258 any(p1.source_filename == p.filename for p1 in patches[0:index])):
[email protected]4dd9f722012-10-01 16:23:03259 # The file was already deleted if a prior patch with file rename
260 # was already processed because 'git apply' did it for us.
[email protected]5e975632011-09-29 18:07:06261 pass
262 else:
Edward Lesmesf8792072017-09-13 08:05:12263 stdout.append(self._check_output_git(['rm', p.filename]))
[email protected]d9eb69e2014-06-05 20:33:37264 assert(not os.path.exists(filepath))
[email protected]4dd9f722012-10-01 16:23:03265 stdout.append('Deleted.')
[email protected]dfaecd22011-04-21 00:33:31266 else:
Edward Lesmesf8792072017-09-13 08:05:12267 dirname = os.path.dirname(p.filename)
[email protected]dfaecd22011-04-21 00:33:31268 full_dir = os.path.join(self.project_path, dirname)
269 if dirname and not os.path.isdir(full_dir):
270 os.makedirs(full_dir)
[email protected]4dd9f722012-10-01 16:23:03271 stdout.append('Created missing directory %s.' % dirname)
[email protected]dfaecd22011-04-21 00:33:31272 if p.is_binary:
[email protected]4dd9f722012-10-01 16:23:03273 content = p.get()
274 with open(filepath, 'wb') as f:
275 f.write(content)
276 stdout.append('Added binary file %d bytes' % len(content))
Edward Lesmesf8792072017-09-13 08:05:12277 cmd = ['add', p.filename]
[email protected]4dd9f722012-10-01 16:23:03278 if verbose:
279 cmd.append('--verbose')
280 stdout.append(self._check_output_git(cmd))
[email protected]dfaecd22011-04-21 00:33:31281 else:
[email protected]58fe6622011-06-03 20:59:27282 # No need to do anything special with p.is_new or if not
283 # p.diff_hunks. git apply manages all that already.
[email protected]49dfcde2014-09-23 08:14:39284 cmd = ['apply', '--index', '-3', '-p%s' % p.patchlevel]
[email protected]4dd9f722012-10-01 16:23:03285 if verbose:
286 cmd.append('--verbose')
287 stdout.append(self._check_output_git(cmd, stdin=p.get(True)))
[email protected]b1d1a782011-09-29 14:13:55288 for post in post_processors:
[email protected]8a1396c2011-04-22 00:14:24289 post(self, p)
[email protected]4dd9f722012-10-01 16:23:03290 if verbose:
291 print p.filename
292 print align_stdout(stdout)
[email protected]dfaecd22011-04-21 00:33:31293 except OSError, e:
skobes2f3f1372016-10-25 15:08:27294 errors.append((p, '%s%s' % (align_stdout(stdout), e)))
[email protected]dfaecd22011-04-21 00:33:31295 except subprocess.CalledProcessError, e:
skobes2f3f1372016-10-25 15:08:27296 errors.append((p,
[email protected]4dd9f722012-10-01 16:23:03297 'While running %s;\n%s%s' % (
298 ' '.join(e.cmd),
299 align_stdout(stdout),
skobes2f3f1372016-10-25 15:08:27300 align_stdout([getattr(e, 'stdout', '')]))))
301 if errors:
302 raise PatchApplicationFailed(errors, verbose)
[email protected]3b5efdf2013-09-05 11:59:40303 found_files = self._check_output_git(
Aaron Gablef4068aa2017-12-12 23:14:09304 ['-c', 'core.quotePath=false', 'diff', '--ignore-submodules',
[email protected]c4396a12014-05-10 02:19:27305 '--name-only', '--staged']).splitlines(False)
[email protected]dc6a1d02014-05-10 04:42:48306 if sorted(patches.filenames) != sorted(found_files):
307 extra_files = sorted(set(found_files) - set(patches.filenames))
308 unpatched_files = sorted(set(patches.filenames) - set(found_files))
309 if extra_files:
310 print 'Found extra files: %r' % (extra_files,)
311 if unpatched_files:
312 print 'Found unpatched files: %r' % (unpatched_files,)
313
[email protected]dfaecd22011-04-21 00:33:31314
315 def commit(self, commit_message, user):
[email protected]3b5efdf2013-09-05 11:59:40316 """Commits, updates the commit message and pushes."""
[email protected]c4396a12014-05-10 02:19:27317 # TODO(hinoka): CQ no longer uses this, I think its deprecated.
318 # Delete this.
[email protected]bb050f62013-10-03 16:53:54319 assert self.commit_user
[email protected]1bf50972011-05-05 19:57:21320 assert isinstance(commit_message, unicode)
[email protected]3b5efdf2013-09-05 11:59:40321 current_branch = self._check_output_git(
322 ['rev-parse', '--abbrev-ref', 'HEAD']).strip()
323 assert current_branch == self.working_branch
[email protected]dabbea22014-04-21 23:58:11324
[email protected]c4396a12014-05-10 02:19:27325 commit_cmd = ['commit', '-m', commit_message]
[email protected]3b5efdf2013-09-05 11:59:40326 if user and user != self.commit_user:
327 # We do not have the first or last name of the user, grab the username
328 # from the email and call it the original author's name.
329 # TODO(rmistry): Do not need the below if user is already in
330 # "Name <email>" format.
331 name = user.split('@')[0]
332 commit_cmd.extend(['--author', '%s <%s>' % (name, user)])
333 self._check_call_git(commit_cmd)
334
335 # Push to the remote repository.
336 self._check_call_git(
[email protected]7e8c19d2014-03-19 16:47:37337 ['push', 'origin', '%s:%s' % (self.working_branch, self.remote_branch),
[email protected]39262282014-03-19 21:07:38338 '--quiet'])
[email protected]3b5efdf2013-09-05 11:59:40339 # Get the revision after the push.
340 revision = self._get_head_commit_hash()
[email protected]7e8c19d2014-03-19 16:47:37341 # Switch back to the remote_branch and sync it.
342 self._check_call_git(['checkout', self.remote_branch])
343 self._sync_remote_branch()
[email protected]3b5efdf2013-09-05 11:59:40344 # Delete the working branch since we are done with it.
345 self._check_call_git(['branch', '-D', self.working_branch])
346
347 return revision
[email protected]dfaecd22011-04-21 00:33:31348
349 def _check_call_git(self, args, **kwargs):
350 kwargs.setdefault('cwd', self.project_path)
351 kwargs.setdefault('stdout', self.VOID)
[email protected]9af0a112013-03-20 20:21:35352 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
[email protected]44b21b92012-11-08 19:37:08353 return subprocess2.check_call_out(['git'] + args, **kwargs)
[email protected]dfaecd22011-04-21 00:33:31354
355 def _call_git(self, args, **kwargs):
356 """Like check_call but doesn't throw on failure."""
357 kwargs.setdefault('cwd', self.project_path)
358 kwargs.setdefault('stdout', self.VOID)
[email protected]9af0a112013-03-20 20:21:35359 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
[email protected]44b21b92012-11-08 19:37:08360 return subprocess2.call(['git'] + args, **kwargs)
[email protected]dfaecd22011-04-21 00:33:31361
362 def _check_output_git(self, args, **kwargs):
363 kwargs.setdefault('cwd', self.project_path)
[email protected]9af0a112013-03-20 20:21:35364 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
[email protected]87e6d332011-09-09 19:01:28365 return subprocess2.check_output(
[email protected]44b21b92012-11-08 19:37:08366 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
[email protected]dfaecd22011-04-21 00:33:31367
368 def _branches(self):
369 """Returns the list of branches and the active one."""
370 out = self._check_output_git(['branch']).splitlines(False)
371 branches = [l[2:] for l in out]
372 active = None
373 for l in out:
374 if l.startswith('*'):
375 active = l[2:]
376 break
377 return branches, active
378
[email protected]bc32ad12012-07-26 13:22:47379 def revisions(self, rev1, rev2):
380 """Returns the number of actual commits between both hash."""
381 self._fetch_remote()
382
[email protected]7e8c19d2014-03-19 16:47:37383 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
[email protected]bc32ad12012-07-26 13:22:47384 # Revision range is ]rev1, rev2] and ordering matters.
385 try:
386 out = self._check_output_git(
387 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
388 except subprocess.CalledProcessError:
389 return None
390 return len(out.splitlines())
391
392 def _fetch_remote(self):
393 """Fetches the remote without rebasing."""
[email protected]3b5efdf2013-09-05 11:59:40394 # git fetch is always verbose even with -q, so redirect its output.
[email protected]7e8c19d2014-03-19 16:47:37395 self._check_output_git(['fetch', self.remote, self.remote_branch],
[email protected]9af0a112013-03-20 20:21:35396 timeout=FETCH_TIMEOUT)
[email protected]bc32ad12012-07-26 13:22:47397
[email protected]dfaecd22011-04-21 00:33:31398
[email protected]dfaecd22011-04-21 00:33:31399class ReadOnlyCheckout(object):
400 """Converts a checkout into a read-only one."""
[email protected]b1d1a782011-09-29 14:13:55401 def __init__(self, checkout, post_processors=None):
[email protected]a5129fb2011-06-20 18:36:25402 super(ReadOnlyCheckout, self).__init__()
[email protected]dfaecd22011-04-21 00:33:31403 self.checkout = checkout
[email protected]b1d1a782011-09-29 14:13:55404 self.post_processors = (post_processors or []) + (
405 self.checkout.post_processors or [])
[email protected]dfaecd22011-04-21 00:33:31406
[email protected]51919772011-06-12 01:27:42407 def prepare(self, revision):
408 return self.checkout.prepare(revision)
[email protected]dfaecd22011-04-21 00:33:31409
410 def get_settings(self, key):
411 return self.checkout.get_settings(key)
412
[email protected]c4396a12014-05-10 02:19:27413 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]b1d1a782011-09-29 14:13:55414 return self.checkout.apply_patch(
[email protected]4dd9f722012-10-01 16:23:03415 patches, post_processors or self.post_processors, verbose)
[email protected]dfaecd22011-04-21 00:33:31416
Quinten Yearsleyb2cc4a92016-12-15 21:53:26417 def commit(self, message, user): # pylint: disable=no-self-use
[email protected]dfaecd22011-04-21 00:33:31418 logging.info('Would have committed for %s with message: %s' % (
419 user, message))
420 return 'FAKE'
421
[email protected]bc32ad12012-07-26 13:22:47422 def revisions(self, rev1, rev2):
423 return self.checkout.revisions(rev1, rev2)
424
[email protected]dfaecd22011-04-21 00:33:31425 @property
426 def project_name(self):
427 return self.checkout.project_name
428
429 @property
430 def project_path(self):
431 return self.checkout.project_path