blob: ea0438b51a9e2767a5c114600d292ccfc909205b [file] [log] [blame] [edit]
#!/usr/bin/env python3
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import json
import logging
import pathlib
import tempfile
from util import jj_log
from util import run_command
from util import run_jj
from util import join_revsets
from util import split_description
_IMMUTABLE_PARENTS = 'parents.filter(|p| p.immutable()).map(|p| p.commit_id())'
_MUTABLE_PARENTS = 'parents.filter(|p| !p.immutable()).map(|p| p.commit_id())'
def fatal(*args, **kwargs):
logging.critical(*args, **kwargs)
exit(1)
def _collect_ids(values):
ids = set()
for value in values:
if value:
ids.update(value.split(' '))
return ids
def get_refspec_opts(args) -> list[str]:
# Extra options that can be specified at push time. Doc:
# https://gerrit-review.googlesource.com/Documentation/user-upload.html
refspec_opts = []
if args.topic:
# Documentation on Gerrit topics is here:
# https://gerrit-review.googlesource.com/Documentation/user-upload.html#topic
refspec_opts.append(f'topic={args.topic}')
# Code mostly stolen from `git_cl.py`
if args.private:
refspec_opts.append('private')
if args.send_mail:
refspec_opts.append('ready')
refspec_opts.append('notify=ALL')
if args.enable_auto_submit:
refspec_opts.append('l=Auto-Submit+1')
if args.enable_owners_override:
refspec_opts.append('l=Owners-Override+1')
if args.use_commit_queue:
refspec_opts.append('l=Commit-Queue+2')
elif args.cq_dry_run:
refspec_opts.append('l=Commit-Queue+1')
for cc in args.cc:
refspec_opts.append(f'cc={cc}')
for reviewer in args.reviewers:
refspec_opts.append(f'r={reviewer}')
return refspec_opts
def main(args):
logging.basicConfig(level=logging.getLevelNamesMapping()[args.verbosity])
revs = args.revisions + args.revision
implicit_revs = False
# If no revisions are provided, we will upload `@` unless it is empty and
# descriptionless, in which case we upload 'parents(@)'.
if len(revs) == 0:
rev = '@'
implicit_revs = True
else:
rev = join_revsets(revs)
snapshot_taken = False
if args.fix:
run_jj(['fix', '-s', f'mutable()::({rev})'])
# After running fix, jj creates another snapshot.
snapshot_taken = True
to_upload = jj_log(
revisions=f'mutable()::({rev})',
templates={
'commit_id': 'commit_id',
'empty': 'empty',
'desc': 'description',
'mutable_parents': _MUTABLE_PARENTS,
},
ignore_working_copy=snapshot_taken,
)
snapshot_taken = True
if implicit_revs:
# It's in reverse topological order, so to_upload[0] is the working copy '@'
wc = to_upload[0]
if not split_description(wc['desc'])[0] and wc['empty'] == 'true':
logging.info('No revisions provided and working copy is empty and ' +
'descriptionless, uploading parents(@)')
to_upload.remove(wc)
else:
logging.info('No revisions provided, uploading working copy')
for change in to_upload:
name = change['name']
desc, trailers = split_description(change['desc'])
# Don't trust `git cl presubmit` to pick up on these for stacked changes,
# since it assumes all the commits will be squashed.
if change['empty'] == 'true':
fatal('Attempting to upload an empty change %s', name)
if not desc:
fatal('Attempting to upload change with an empty description %s', name)
if 'Change-Id' not in trailers:
fatal('Attempting to upload change with no Change-Id %s', name)
if 'Bug' not in trailers and 'Fixed' not in trailers:
logging.warning(
'Change %s has no associated Bug. If this change has an associated ' +
'bug, run `jj bug add [--inherit]`', name)
if not args.bypass_hooks:
# Find the commits that `git cl presubmit` will actually run on
got_presubmits = jj_log(
revisions=f'mutable()::@',
templates={
'empty': 'empty',
'immutable_parents': _IMMUTABLE_PARENTS
},
ignore_working_copy=snapshot_taken,
)
# We could simplify this with another call to jj_log, but each call to
# jj_log can take a nontrivial amount of time.
immutable_parents = _collect_ids(c['immutable_parents']
for c in got_presubmits)
if len(immutable_parents) != 1:
fatal(
'%s has multiple different immutable parents of mutable ancestors. ' +
'Fix with a rebase or jj simplify-parents.',
rev,
)
want_presubmits = {x['name'] for x in to_upload if x['empty'] == 'false'}
got_presubmits = {
x['name']
for x in got_presubmits if x['empty'] == 'false'
}
if want_presubmits.intersection(got_presubmits):
for change in got_presubmits - want_presubmits:
logging.warning("Running presubmit on additional non-empty revision %s",
change)
for change in want_presubmits - got_presubmits:
logging.warning("Presubmits will be skipped for %s", change)
# Git CL presubmit is very annoying with stacked commits. It:
# * Always runs with files from @
# * This means that if you have an error in a parent commit fixed in a
# child commit, it won't pick up on it.
# * Simply concatenates the CL descriptions from mutable()::@-
# * It ignores the CL description for @
# * If, for example the first commit in the chain has an associated bug,
# it won't warn you other commits in the chain don't have one.
# This isn't any worse with jj than with git, but it is very annoying.
# In particular, if you're uploading any commit except @-, expect some
# weirdness.
with tempfile.NamedTemporaryFile(suffix='.json') as out:
out = pathlib.Path(out.name)
run_command([
'git',
'cl',
'presubmit',
# Allows it to run with a dirty tree and on no branch
'--force',
'--parallel',
# Unfortunately, upload skips certain checks which would be
# useful. However, it also skips certain checks we really don't
# want to run. CheckTreeIsOpen(), for example.
'--upload',
f'--json={out}',
next(iter(immutable_parents))
])
results = json.loads(out.read_text())
if results.get('errors', []) or results.get('warnings', []):
if not args.allow_warnings:
fatal('git cl presubmit had warnings.\n' +
'Hint: maybe you want --allow-warnings?')
else:
# For consistency's sake, we warn if the intersection of commits is small,
# so we should also warn if the intersection is emmpty.
logging.warning('git cl presubmit only supports running on the ' +
'revision @. `git cl presubmit` will be skipped')
# This could be simplified by another call to jj_log on heads(...),
# but this is more performant.
mutable_parents = _collect_ids(c['mutable_parents'] for c in to_upload)
if not to_upload:
fatal('%s resolved to the empty set', rev)
refspec = get_refspec_opts(args)
refspec_suffix = '%' + ','.join(refspec) if refspec else ''
for change in to_upload:
# Check if it's a head.
commit_id = change['commit_id']
if commit_id not in mutable_parents:
ref = f'{commit_id}:refs/for/{args.target_branch}{refspec_suffix}'
cmd = ['git', 'push', 'origin', ref]
logging.info('Uploading %s', change['name'])
if args.upload:
run_command(cmd)
else:
logging.info('no-upload: Would otherwise run `%s`', ' '.join(cmd))
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
'--verbosity',
help='Verbosity of logging',
default='INFO',
choices=logging.getLevelNamesMapping().keys(),
type=lambda x: x.upper(),
)
# Alternative form so users can write `upload -r foo` as well as `upload foo``
parser.add_argument('-r', '--revision', help=None, nargs='*', default=[])
parser.add_argument('revisions', help='Revisions to upload', nargs='*')
parser.add_argument(
'--no-fix',
help='Skips running `jj fix` before uploading',
action='store_false',
dest='fix',
)
parser.add_argument('--no-upload',
help='Doesn\'t actually upload the change to gerrit',
action='store_false',
dest='upload')
parser.add_argument(
'--allow-warnings',
help='Prevents presubmit warnings from blocking upload',
action='store_true',
)
# These args are directly copied from git_cl.py
parser.add_argument('--bypass-hooks',
action='store_true',
help='bypass upload presubmit hook')
# We use -R instead of the -r that git cl upload uses because -r in jj means
# revision.
parser.add_argument('-R',
'--reviewers',
action='append',
default=[],
help='reviewer email addresses')
parser.add_argument('--cc',
action='append',
default=[],
help='cc email addresses')
parser.add_argument('-s',
'--send-mail',
'--send-email',
dest='send_mail',
action='store_true',
help='send email to reviewer(s) and cc(s) immediately')
parser.add_argument('--target_branch',
'--target-branch',
metavar='TARGET',
help='Apply CL to remote branch TARGET.',
default='main')
parser.add_argument('--topic',
default=None,
help='Topic to specify when uploading')
parser.add_argument(
'-c',
'--use-commit-queue',
action='store_true',
default=False,
help='tell the CQ to commit this patchset; implies --send-mail',
)
parser.add_argument(
'-d',
'--dry-run',
'--cq-dry-run',
action='store_true',
dest='cq_dry_run',
default=False,
help='Send the patchset to do a CQ dry run right after upload.',
)
parser.add_argument(
'-a',
'--auto-submit',
'--enable-auto-submit',
action='store_true',
dest='enable_auto_submit',
help='Sends your change to the CQ after an approval. Only '
'works on repos that have the Auto-Submit label '
'enabled')
parser.add_argument('--enable-owners-override',
action='store_true',
help='Adds the Owners-Override label to your change.')
parser.add_argument('--private',
action='store_true',
help='Set the review private.')
main(parser.parse_args())