Index: trychange.py |
diff --git a/trychange.py b/trychange.py |
index f93d9a418778fd11c5cc52ac660952941ac88297..f979e6bfd6ce116edddfa7cacf2f640f681d3d81 100755 |
--- a/trychange.py |
+++ b/trychange.py |
@@ -4,10 +4,11 @@ |
# found in the LICENSE file. |
"""Client-side script to send a try job to the try server. It communicates to |
-the try server by either writting to a svn repository or by directly connecting |
-to the server by HTTP. |
+the try server by either writting to a svn/git repository or by directly |
+connecting to the server by HTTP. |
""" |
+import contextlib |
import datetime |
import errno |
import getpass |
@@ -70,6 +71,8 @@ Examples: |
-f include/b.h |
""" |
+GIT_PATCH_DIR_BASENAME = os.path.join('git-try', 'patches-git') |
+GIT_BRANCH_FILE = 'ref' |
def DieWithError(message): |
print >> sys.stderr, message |
@@ -164,7 +167,10 @@ class SCM(object): |
'port': self.GetCodeReviewSetting('TRYSERVER_HTTP_PORT'), |
'host': self.GetCodeReviewSetting('TRYSERVER_HTTP_HOST'), |
'svn_repo': self.GetCodeReviewSetting('TRYSERVER_SVN_URL'), |
+ 'git_repo': self.GetCodeReviewSetting('TRYSERVER_GIT_URL'), |
'project': self.GetCodeReviewSetting('TRYSERVER_PROJECT'), |
+ # Primarily for revision=auto |
+ 'revision': self.GetCodeReviewSetting('TRYSERVER_REVISION'), |
'root': self.GetCodeReviewSetting('TRYSERVER_ROOT'), |
'patchlevel': self.GetCodeReviewSetting('TRYSERVER_PATCHLEVEL'), |
} |
@@ -410,7 +416,9 @@ def _GenTSBotSpec(checkouts, change, changed_files, options): |
def _ParseSendChangeOptions(bot_spec, options): |
- """Parse common options passed to _SendChangeHTTP and _SendChangeSVN.""" |
+ """Parse common options passed to _SendChangeHTTP, _SendChangeSVN and |
+ _SendChangeGit. |
+ """ |
values = [ |
('user', options.user), |
('name', options.name), |
@@ -481,6 +489,44 @@ def _SendChangeHTTP(bot_spec, options): |
raise NoTryServerAccess('%s is unaccessible. Got:\n%s' % (url, response)) |
+@contextlib.contextmanager |
+def _TempFilename(name, contents=None): |
+ """Create a temporary directory, append the specified name and yield. |
+ |
+ In contrast to NamedTemporaryFile, does not keep the file open. |
+ Deletes the file on __exit__. |
+ """ |
+ temp_dir = tempfile.mkdtemp(prefix=name) |
+ try: |
+ path = os.path.join(temp_dir, name) |
+ if contents: |
+ with open(path, 'w') as f: |
+ f.write(contents) |
+ yield path |
+ finally: |
+ shutil.rmtree(temp_dir, True) |
+ |
+ |
+@contextlib.contextmanager |
+def _PrepareDescriptionAndPatchFiles(description, options): |
+ """Creates temporary files with description and patch. |
+ |
+ __enter__ called on the return value returns a tuple of patch_filename and |
+ description_filename. |
+ |
+ Args: |
+ description: contents of description file. |
+ options: patchset options object. Must have attributes: user, |
+ name (of patch) and diff (contents of patch). |
+ """ |
+ current_time = str(datetime.datetime.now()).replace(':', '.') |
+ patch_basename = '%s.%s.%s.diff' % (Escape(options.user), |
+ Escape(options.name), current_time) |
+ with _TempFilename('description', description) as description_filename: |
+ with _TempFilename(patch_basename, options.diff) as patch_filename: |
+ yield patch_filename, description_filename |
+ |
+ |
def _SendChangeSVN(bot_spec, options): |
"""Send a change to the try server by committing a diff file on a subversion |
server.""" |
@@ -497,45 +543,150 @@ def _SendChangeSVN(bot_spec, options): |
if options.dry_run: |
return |
- # Create a temporary directory, put a uniquely named file in it with the diff |
- # content and svn import that. |
- temp_dir = tempfile.mkdtemp() |
- temp_file = tempfile.NamedTemporaryFile() |
- try: |
- try: |
- # Description |
- temp_file.write(description) |
- temp_file.flush() |
- |
- # Diff file |
- current_time = str(datetime.datetime.now()).replace(':', '.') |
- file_name = (Escape(options.user) + '.' + Escape(options.name) + |
- '.%s.diff' % current_time) |
- full_path = os.path.join(temp_dir, file_name) |
- with open(full_path, 'wb') as f: |
- f.write(options.diff) |
- |
- # Committing it will trigger a try job. |
- if sys.platform == "cygwin": |
- # Small chromium-specific issue here: |
- # git-try uses /usr/bin/python on cygwin but svn.bat will be used |
- # instead of /usr/bin/svn by default. That causes bad things(tm) since |
- # Windows' svn.exe has no clue about cygwin paths. Hence force to use |
- # the cygwin version in this particular context. |
- exe = "/usr/bin/svn" |
- else: |
- exe = "svn" |
- command = [exe, 'import', '-q', temp_dir, options.svn_repo, '--file', |
- temp_file.name] |
- if scm.SVN.AssertVersion("1.5")[0]: |
- command.append('--no-ignore') |
+ with _PrepareDescriptionAndPatchFiles(description, options) as ( |
+ patch_filename, description_filename): |
+ if sys.platform == "cygwin": |
+ # Small chromium-specific issue here: |
+ # git-try uses /usr/bin/python on cygwin but svn.bat will be used |
+ # instead of /usr/bin/svn by default. That causes bad things(tm) since |
+ # Windows' svn.exe has no clue about cygwin paths. Hence force to use |
+ # the cygwin version in this particular context. |
+ exe = "/usr/bin/svn" |
+ else: |
+ exe = "svn" |
+ patch_dir = os.path.dirname(patch_filename) |
+ command = [exe, 'import', '-q', patch_dir, options.svn_repo, '--file', |
+ description_filename] |
+ if scm.SVN.AssertVersion("1.5")[0]: |
+ command.append('--no-ignore') |
+ try: |
subprocess2.check_call(command) |
except subprocess2.CalledProcessError, e: |
raise NoTryServerAccess(str(e)) |
- finally: |
- temp_file.close() |
- shutil.rmtree(temp_dir, True) |
+ |
+ |
+def _GetPatchGitRepo(git_url): |
+ """Gets a path to a Git repo with patches. |
+ |
+ Stores patches in .git/git-try/patches-git directory, a git repo. If it |
+ doesn't exist yet or its origin URL is different, cleans up and clones it. |
+ If it existed before, then pulls changes. |
+ |
+ Does not support SVN repo. |
+ |
+ Returns a path to the directory with patches. |
+ """ |
+ git_dir = scm.GIT.GetGitDir(os.getcwd()) |
+ patch_dir = os.path.join(git_dir, GIT_PATCH_DIR_BASENAME) |
+ |
+ logging.info('Looking for git repo for patches') |
+ # Is there already a repo with the expected url or should we clone? |
+ clone = True |
+ if os.path.exists(patch_dir) and scm.GIT.IsInsideWorkTree(patch_dir): |
+ existing_url = scm.GIT.Capture( |
+ ['config', '--local', 'remote.origin.url'], |
+ cwd=patch_dir) |
+ clone = existing_url != git_url |
+ |
+ if clone: |
+ if os.path.exists(patch_dir): |
+ logging.info('Cleaning up') |
+ shutil.rmtree(patch_dir, True) |
+ logging.info('Cloning patch repo') |
+ scm.GIT.Capture(['clone', git_url, GIT_PATCH_DIR_BASENAME], cwd=git_dir) |
+ email = scm.GIT.GetEmail(cwd=os.getcwd()) |
+ scm.GIT.Capture(['config', '--local', 'user.email', email], cwd=patch_dir) |
+ else: |
+ if scm.GIT.IsWorkTreeDirty(patch_dir): |
+ logging.info('Work dir is dirty: hard reset!') |
+ scm.GIT.Capture(['reset', '--hard'], cwd=patch_dir) |
+ logging.info('Updating patch repo') |
+ scm.GIT.Capture(['pull', 'origin', 'master'], cwd=patch_dir) |
+ |
+ return os.path.abspath(patch_dir) |
+ |
+ |
+def _SendChangeGit(bot_spec, options): |
+ """Send a change to the try server by committing a diff file to a GIT repo""" |
+ if not options.git_repo: |
+ raise NoTryServerAccess('Please use the --git_repo option to specify the ' |
+ 'try server git repository to connect to.') |
+ |
+ values = _ParseSendChangeOptions(bot_spec, options) |
+ comment_subject = '%s.%s' % (options.user, options.name) |
+ comment_body = ''.join("%s=%s\n" % (k, v) for k, v in values) |
+ description = '%s\n\n%s' % (comment_subject, comment_body) |
+ logging.info('Sending by GIT') |
+ logging.info(description) |
+ logging.info(options.git_repo) |
+ logging.info(options.diff) |
+ if options.dry_run: |
+ return |
+ |
+ patch_dir = _GetPatchGitRepo(options.git_repo) |
+ def patch_git(*args): |
+ return scm.GIT.Capture(list(args), cwd=patch_dir) |
+ def add_and_commit(filename, comment_filename): |
+ patch_git('add', filename) |
+ patch_git('commit', '-F', comment_filename) |
+ |
+ assert scm.GIT.IsInsideWorkTree(patch_dir) |
+ assert not scm.GIT.IsWorkTreeDirty(patch_dir) |
+ |
+ with _PrepareDescriptionAndPatchFiles(description, options) as ( |
+ patch_filename, description_filename): |
+ logging.info('Committing patch') |
+ target_branch = ('refs/patches/' + |
+ os.path.basename(patch_filename).replace(' ','-')) |
+ target_filename = os.path.join(patch_dir, 'patch.diff') |
+ branch_file = os.path.join(patch_dir, GIT_BRANCH_FILE) |
+ try: |
+ # Crete a new branch and put the patch there |
+ patch_git('checkout', '--orphan', target_branch) |
+ patch_git('reset') |
+ patch_git('clean', '-f') |
+ shutil.copyfile(patch_filename, target_filename) |
+ add_and_commit(target_filename, description_filename) |
+ assert not scm.GIT.IsWorkTreeDirty(patch_dir) |
+ |
+ # Update the branch file in the master |
+ patch_git('checkout', 'master') |
+ |
+ def update_branch(): |
+ with open(branch_file, 'w') as f: |
+ f.write(target_branch) |
+ add_and_commit(branch_file, description_filename) |
+ def push(): |
+ patch_git('push', 'origin', 'master', target_branch) |
+ def push_fixup(): |
+ patch_git('fetch', 'origin') |
+ patch_git('reset', '--hard', 'origin/master') |
+ update_branch() |
+ def push_failed(): |
+ raise NoTryServerAccess(str(e)) |
+ |
+ update_branch() |
+ logging.info('Pushing patch') |
+ _retry(push, push_fixup, push_failed) |
+ except subprocess2.CalledProcessError, e: |
+ # Restore state. |
+ patch_git('checkout', 'master') |
+ patch_git('reset', '--hard', 'origin/master') |
+ raise |
+ |
+ |
+def _retry(action, fixup, fail, attempts=3): |
+ """Calls action until it passes without exceptions""" |
+ for attempt in xrange(attempts): |
+ try: |
+ action() |
+ except subprocess2.CalledProcessError: |
+ is_last = attempt == attempts - 1 |
+ if is_last: |
+ fail() |
+ else: |
+ fixup() |
def PrintSuccess(bot_spec, options): |
@@ -651,7 +802,9 @@ def gen_parser(prog): |
" and exit. Do not send patch. Like --dry_run" |
" but less verbose.") |
group.add_option("-r", "--revision", |
- help="Revision to use for the try job; default: the " |
+ help="Revision to use for the try job. If 'auto' is " |
+ "specified, it is resolved to the revision a patch is " |
+ "generated against (Git only). Default: the " |
"revision will be determined by the try server; see " |
"its waterfall for more info") |
group.add_option("-c", "--clobber", action="store_true", |
@@ -737,6 +890,18 @@ def gen_parser(prog): |
help="SVN url to use to write the changes in; --use_svn is " |
"implied when using --svn_repo") |
parser.add_option_group(group) |
+ |
+ group = optparse.OptionGroup(parser, "Access the try server with Git") |
+ group.add_option("--use_git", |
ghost stip (do not use)
2014/03/28 23:24:08
it doesn't look like this option is used, please d
nodir
2014/03/31 20:25:14
It is used the same way as use_svn. If set, the op
|
+ action="store_const", |
+ const=_SendChangeGit, |
+ dest="send_patch", |
+ help="Use GIT to talk to the try server") |
+ group.add_option("-G", "--git_repo", |
+ metavar="GIT_URL", |
+ help="GIT url to use to write the changes in; --use_git is " |
+ "implied when using --git_repo") |
+ parser.add_option_group(group) |
return parser |
@@ -805,7 +970,6 @@ def TryChange(argv, |
try: |
changed_files = None |
# Always include os.getcwd() in the checkout settings. |
- checkouts = [] |
path = os.getcwd() |
file_list = [] |
@@ -819,12 +983,29 @@ def TryChange(argv, |
# Clear file list so that the correct list will be retrieved from the |
# upstream branch. |
file_list = [] |
- checkouts.append(GuessVCS(options, path, file_list)) |
- checkouts[0].AutomagicalSettings() |
+ |
+ current_vcs = GuessVCS(options, path, file_list) |
+ current_vcs.AutomagicalSettings() |
+ options = current_vcs.options |
+ vcs_is_git = type(current_vcs) is GIT |
+ |
+ # So far, git_repo doesn't work with SVN |
+ if options.git_repo and not vcs_is_git: |
+ parser.error('--git_repo option is supported only for GIT repositories') |
+ |
+ # If revision==auto, resolve it |
+ if options.revision and options.revision.lower() == 'auto': |
+ if not vcs_is_git: |
+ parser.error('--revision=auto is supported only for GIT repositories') |
+ options.revision = scm.GIT.Capture( |
+ ['rev-parse', current_vcs.diff_against], |
+ cwd=path) |
+ |
+ checkouts = [current_vcs] |
for item in options.sub_rep: |
# Pass file_list=None because we don't know the sub repo's file list. |
checkout = GuessVCS(options, |
- os.path.join(checkouts[0].checkout_root, item), |
+ os.path.join(current_vcs.checkout_root, item), |
None) |
if checkout.checkout_root in [c.checkout_root for c in checkouts]: |
parser.error('Specified the root %s two times.' % |
@@ -833,9 +1014,10 @@ def TryChange(argv, |
can_http = options.port and options.host |
can_svn = options.svn_repo |
+ can_git = options.git_repo |
# If there was no transport selected yet, now we must have enough data to |
# select one. |
- if not options.send_patch and not (can_http or can_svn): |
+ if not options.send_patch and not (can_http or can_svn or can_git): |
parser.error('Please specify an access method.') |
# Convert options.diff into the content of the diff. |
@@ -913,23 +1095,30 @@ def TryChange(argv, |
print ' %s' % (bot[0]) |
return 0 |
- # Send the patch. |
+ # Determine sending protocol |
if options.send_patch: |
# If forced. |
- options.send_patch(bot_spec, options) |
- PrintSuccess(bot_spec, options) |
- return 0 |
- try: |
- if can_http: |
- _SendChangeHTTP(bot_spec, options) |
+ senders = [options.send_patch] |
+ else: |
+ # Try sending patch using avaialble protocols |
+ all_senders = [ |
+ (_SendChangeHTTP, can_http), |
+ (_SendChangeSVN, can_svn), |
+ (_SendChangeGit, can_git) |
+ ] |
+ senders = [sender for sender, can in all_senders if can] |
+ |
+ # Send the patch. |
+ for sender in senders: |
+ try: |
+ sender(bot_spec, options) |
PrintSuccess(bot_spec, options) |
return 0 |
- except NoTryServerAccess: |
- if not can_svn: |
- raise |
- _SendChangeSVN(bot_spec, options) |
- PrintSuccess(bot_spec, options) |
- return 0 |
+ except NoTryServerAccess: |
+ is_last = sender == senders[-1] |
+ if is_last: |
+ raise |
+ assert False, "Unreachable code" |
except (InvalidScript, NoTryServerAccess), e: |
if swallow_exception: |
return 1 |