blob: 928ffb35ff4d3c7e8234c0197a79e446c6b3122b [file] [log] [blame]
Pulser72e23242013-09-29 09:56:55 +01001#!/usr/bin/env python
2#
dianlujitaoe34c26d2024-01-13 17:33:41 +08003# Copyright (C) 2013-2015 The CyanogenMod Project
4# (C) 2017-2024 The LineageOS Project
Pulser72e23242013-09-29 09:56:55 +01005#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17#
18
19#
20# Run repopick.py -h for a description of this utility.
21#
22
dianlujitao769a9c62024-01-14 17:59:24 +080023import argparse
Pulser72e23242013-09-29 09:56:55 +010024import json
25import os
Pulser72e23242013-09-29 09:56:55 +010026import re
dianlujitao769a9c62024-01-14 17:59:24 +080027import subprocess
28import sys
Pulser72e23242013-09-29 09:56:55 +010029import textwrap
dianlujitao87a692f2024-01-14 17:01:12 +080030import urllib.parse
31import urllib.request
dianlujitao324541c2024-01-13 21:27:00 +080032from collections import defaultdict
dianlujitao49475322024-01-13 23:56:59 +080033from concurrent.futures import ThreadPoolExecutor
34from functools import cmp_to_key, partial
Marko Manb58468a2018-03-19 13:01:19 +010035from xml.etree import ElementTree
Pulser72e23242013-09-29 09:56:55 +010036
dianlujitao324541c2024-01-13 21:27:00 +080037# Default to LineageOS Gerrit
38DEFAULT_GERRIT = "https://gerrit.omnirom.org"
39
Pulser72e23242013-09-29 09:56:55 +010040
Luca Weissd1bbac62018-11-25 14:07:12 +010041# cmp() is not available in Python 3, define it manually
42# See https://docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
43def cmp(a, b):
44 return (a > b) - (a < b)
45
46
Pulser72e23242013-09-29 09:56:55 +010047# Verifies whether pathA is a subdirectory (or the same) as pathB
Marko Manb58468a2018-03-19 13:01:19 +010048def is_subdir(a, b):
dianlujitao769a9c62024-01-14 17:59:24 +080049 a = os.path.realpath(a) + "/"
50 b = os.path.realpath(b) + "/"
51 return b == a[: len(b)]
Pulser72e23242013-09-29 09:56:55 +010052
Pulser72e23242013-09-29 09:56:55 +010053
Marko Manb58468a2018-03-19 13:01:19 +010054def fetch_query_via_ssh(remote_url, query):
55 """Given a remote_url and a query, return the list of changes that fit it
dianlujitao769a9c62024-01-14 17:59:24 +080056 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
57 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API
58 """
59 if remote_url.count(":") == 2:
60 (uri, userhost, port) = remote_url.split(":")
Marko Manb58468a2018-03-19 13:01:19 +010061 userhost = userhost[2:]
dianlujitao769a9c62024-01-14 17:59:24 +080062 elif remote_url.count(":") == 1:
63 (uri, userhost) = remote_url.split(":")
Marko Manb58468a2018-03-19 13:01:19 +010064 userhost = userhost[2:]
dianlujitao20f0bdb2024-01-14 14:57:41 +080065 port = "29418"
Pulser72e23242013-09-29 09:56:55 +010066 else:
dianlujitao769a9c62024-01-14 17:59:24 +080067 raise Exception("Malformed URI: Expecting ssh://[user@]host[:port]")
Pulser72e23242013-09-29 09:56:55 +010068
dianlujitao769a9c62024-01-14 17:59:24 +080069 out = subprocess.check_output(
70 [
71 "ssh",
72 "-x",
dianlujitao20f0bdb2024-01-14 14:57:41 +080073 "-p",
74 port,
dianlujitao769a9c62024-01-14 17:59:24 +080075 userhost,
76 "gerrit",
77 "query",
dianlujitao20f0bdb2024-01-14 14:57:41 +080078 "--format",
79 "JSON",
80 "--patch-sets",
81 "--current-patch-set",
dianlujitao769a9c62024-01-14 17:59:24 +080082 query,
dianlujitao20f0bdb2024-01-14 14:57:41 +080083 ],
84 text=True,
dianlujitao769a9c62024-01-14 17:59:24 +080085 )
Marko Manb58468a2018-03-19 13:01:19 +010086 reviews = []
dianlujitao769a9c62024-01-14 17:59:24 +080087 for line in out.split("\n"):
Marko Manb58468a2018-03-19 13:01:19 +010088 try:
89 data = json.loads(line)
90 # make our data look like the http rest api data
91 review = {
dianlujitao769a9c62024-01-14 17:59:24 +080092 "branch": data["branch"],
93 "change_id": data["id"],
94 "current_revision": data["currentPatchSet"]["revision"],
95 "number": int(data["number"]),
96 "revisions": {
97 patch_set["revision"]: {
98 "_number": int(patch_set["number"]),
99 "fetch": {
100 "ssh": {
101 "ref": patch_set["ref"],
102 "url": "ssh://{0}:{1}/{2}".format(
103 userhost, port, data["project"]
104 ),
105 }
106 },
107 "commit": {
108 "parents": [
109 {"commit": parent} for parent in patch_set["parents"]
110 ]
111 },
112 }
113 for patch_set in data["patchSets"]
114 },
115 "subject": data["subject"],
116 "project": data["project"],
117 "status": data["status"],
Marko Manb58468a2018-03-19 13:01:19 +0100118 }
119 reviews.append(review)
120 except:
121 pass
Marko Manb58468a2018-03-19 13:01:19 +0100122 return reviews
Pulser72e23242013-09-29 09:56:55 +0100123
Pulser72e23242013-09-29 09:56:55 +0100124
dianlujitao87a692f2024-01-14 17:01:12 +0800125def build_query_url(remote_url, query, auth):
126 p = urllib.parse.urlparse(remote_url)._asdict()
127 p["path"] = ("/a" if auth else "") + "/changes"
128 p["query"] = urllib.parse.urlencode(
129 {
130 "q": query,
131 "o": ["CURRENT_REVISION", "ALL_REVISIONS", "ALL_COMMITS"],
132 },
133 doseq=True,
134 )
135 return urllib.parse.urlunparse(urllib.parse.ParseResult(**p))
Marko Manb58468a2018-03-19 13:01:19 +0100136
dianlujitao87a692f2024-01-14 17:01:12 +0800137
138def fetch_query_via_http(remote_url, query, auth=True):
139 """Given a query, fetch the change numbers via http"""
140 if auth:
141 gerritrc = os.path.expanduser("~/.gerritrc")
142 username = password = ""
143 if os.path.isfile(gerritrc):
144 with open(gerritrc, "r") as f:
145 for line in f:
146 parts = line.rstrip().split("|")
147 if parts[0] in remote_url:
148 username, password = parts[1], parts[2]
149
150 if username and password:
151 url = build_query_url(remote_url, query, auth)
152 password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
153 password_mgr.add_password(None, url, username, password)
154 auth_handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
155 opener = urllib.request.build_opener(auth_handler)
156 response = opener.open(url)
157 if response.getcode() != 200:
158 # They didn't get good authorization or data, Let's try the old way
159 return fetch_query_via_http(remote_url, query, False)
160 else:
161 return fetch_query_via_http(remote_url, query, False)
162 else:
163 url = build_query_url(remote_url, query, auth)
164 response = urllib.request.urlopen(url)
165
166 data = response.read().decode("utf-8")
167 reviews = json.loads(data[5:])
Marko Manb58468a2018-03-19 13:01:19 +0100168 for review in reviews:
dianlujitao769a9c62024-01-14 17:59:24 +0800169 review["number"] = review.pop("_number")
Marko Manb58468a2018-03-19 13:01:19 +0100170
171 return reviews
172
173
174def fetch_query(remote_url, query):
175 """Wrapper for fetch_query_via_proto functions"""
dianlujitao769a9c62024-01-14 17:59:24 +0800176 if remote_url[0:3] == "ssh":
Marko Manb58468a2018-03-19 13:01:19 +0100177 return fetch_query_via_ssh(remote_url, query)
dianlujitao769a9c62024-01-14 17:59:24 +0800178 elif remote_url[0:4] == "http":
dianlujitao87a692f2024-01-14 17:01:12 +0800179 return fetch_query_via_http(remote_url, query)
Marko Manb58468a2018-03-19 13:01:19 +0100180 else:
dianlujitao769a9c62024-01-14 17:59:24 +0800181 raise Exception(
182 "Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]"
183 )
Marko Manb58468a2018-03-19 13:01:19 +0100184
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000185
dianlujitao324541c2024-01-13 21:27:00 +0800186def is_closed(status):
187 return status not in ("OPEN", "NEW", "DRAFT")
Marko Manb58468a2018-03-19 13:01:19 +0100188
dianlujitao324541c2024-01-13 21:27:00 +0800189
dianlujitao49475322024-01-13 23:56:59 +0800190def commit_exists(project_path, revision):
191 return (
192 subprocess.call(
193 ["git", "cat-file", "-e", revision],
194 cwd=project_path,
195 stderr=subprocess.DEVNULL,
196 )
197 == 0
198 )
199
200
dianlujitao324541c2024-01-13 21:27:00 +0800201def main():
dianlujitao769a9c62024-01-14 17:59:24 +0800202 parser = argparse.ArgumentParser(
203 formatter_class=argparse.RawDescriptionHelpFormatter,
204 description=textwrap.dedent(
205 """\
Marko Manb58468a2018-03-19 13:01:19 +0100206 repopick.py is a utility to simplify the process of cherry picking
207 patches from OmniRom's Gerrit instance (or any gerrit instance of your choosing)
208
209 Given a list of change numbers, repopick will cd into the project path
210 and cherry pick the latest patch available.
211
212 With the --start-branch argument, the user can specify that a branch
213 should be created before cherry picking. This is useful for
214 cherry-picking many patches into a common branch which can be easily
215 abandoned later (good for testing other's changes.)
216
217 The --abandon-first argument, when used in conjunction with the
218 --start-branch option, will cause repopick to abandon the specified
dianlujitao769a9c62024-01-14 17:59:24 +0800219 branch in all repos first before performing any cherry picks."""
220 ),
221 )
222 parser.add_argument(
223 "change_number",
224 nargs="*",
225 help="change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.",
226 )
227 parser.add_argument(
228 "-i",
229 "--ignore-missing",
230 action="store_true",
231 help="do not error out if a patch applies to a missing directory",
232 )
233 parser.add_argument(
234 "-s",
235 "--start-branch",
236 nargs=1,
237 metavar="",
238 help="start the specified branch before cherry picking",
239 )
240 parser.add_argument(
241 "-r",
242 "--reset",
243 action="store_true",
244 help="reset to initial state (abort cherry-pick) if there is a conflict",
245 )
246 parser.add_argument(
247 "-a",
248 "--abandon-first",
249 action="store_true",
250 help="before cherry picking, abandon the branch specified in --start-branch",
251 )
252 parser.add_argument(
253 "-b",
254 "--auto-branch",
255 action="store_true",
256 help='shortcut to "--start-branch auto --abandon-first --ignore-missing"',
257 )
258 parser.add_argument(
259 "-q", "--quiet", action="store_true", help="print as little as possible"
260 )
261 parser.add_argument(
262 "-v",
263 "--verbose",
264 action="store_true",
265 help="print extra information to aid in debug",
266 )
267 parser.add_argument(
268 "-f",
269 "--force",
270 action="store_true",
271 help="force cherry pick even if change is closed",
272 )
273 parser.add_argument(
274 "-p", "--pull", action="store_true", help="execute pull instead of cherry-pick"
275 )
276 parser.add_argument(
277 "-P", "--path", metavar="", help="use the specified path for the change"
278 )
279 parser.add_argument(
280 "-t", "--topic", metavar="", help="pick all commits from a specified topic"
281 )
282 parser.add_argument(
283 "-Q", "--query", metavar="", help="pick all commits using the specified query"
284 )
285 parser.add_argument(
286 "-g",
287 "--gerrit",
dianlujitao324541c2024-01-13 21:27:00 +0800288 default=DEFAULT_GERRIT,
dianlujitao769a9c62024-01-14 17:59:24 +0800289 metavar="",
290 help="Gerrit Instance to use. Form proto://[user@]host[:port]",
291 )
292 parser.add_argument(
293 "-e",
294 "--exclude",
295 nargs=1,
296 metavar="",
297 help="exclude a list of commit numbers separated by a ,",
298 )
299 parser.add_argument(
300 "-c",
301 "--check-picked",
302 type=int,
303 default=10,
304 metavar="",
305 help="pass the amount of commits to check for already picked changes",
306 )
dianlujitao49475322024-01-13 23:56:59 +0800307 parser.add_argument(
308 "-j",
309 "--jobs",
310 type=int,
311 default=4,
312 metavar="",
313 help="max number of changes to pick in parallel",
314 )
Marko Manb58468a2018-03-19 13:01:19 +0100315 args = parser.parse_args()
316 if not args.start_branch and args.abandon_first:
dianlujitao769a9c62024-01-14 17:59:24 +0800317 parser.error(
318 "if --abandon-first is set, you must also give the branch name with --start-branch"
319 )
Marko Manb58468a2018-03-19 13:01:19 +0100320 if args.auto_branch:
321 args.abandon_first = True
322 args.ignore_missing = True
323 if not args.start_branch:
dianlujitao769a9c62024-01-14 17:59:24 +0800324 args.start_branch = ["auto"]
Marko Manb58468a2018-03-19 13:01:19 +0100325 if args.quiet and args.verbose:
dianlujitao769a9c62024-01-14 17:59:24 +0800326 parser.error("--quiet and --verbose cannot be specified together")
Marko Manb58468a2018-03-19 13:01:19 +0100327
328 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
dianlujitao769a9c62024-01-14 17:59:24 +0800329 parser.error(
330 "One (and only one) of change_number, topic, and query are allowed"
331 )
Marko Manb58468a2018-03-19 13:01:19 +0100332
333 # Change current directory to the top of the tree
dianlujitao769a9c62024-01-14 17:59:24 +0800334 if "ANDROID_BUILD_TOP" in os.environ:
335 top = os.environ["ANDROID_BUILD_TOP"]
Marko Manb58468a2018-03-19 13:01:19 +0100336
337 if not is_subdir(os.getcwd(), top):
dianlujitao769a9c62024-01-14 17:59:24 +0800338 sys.stderr.write(
339 "ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n"
340 )
Marko Manb58468a2018-03-19 13:01:19 +0100341 sys.exit(1)
dianlujitao769a9c62024-01-14 17:59:24 +0800342 os.chdir(os.environ["ANDROID_BUILD_TOP"])
Marko Manb58468a2018-03-19 13:01:19 +0100343
344 # Sanity check that we are being run from the top level of the tree
dianlujitao769a9c62024-01-14 17:59:24 +0800345 if not os.path.isdir(".repo"):
346 sys.stderr.write(
347 "ERROR: No .repo directory found. Please run this from the top of your tree.\n"
348 )
Pulser72e23242013-09-29 09:56:55 +0100349 sys.exit(1)
350
Marko Manb58468a2018-03-19 13:01:19 +0100351 # If --abandon-first is given, abandon the branch before starting
352 if args.abandon_first:
353 # Determine if the branch already exists; skip the abandon if it does not
dianlujitao20f0bdb2024-01-14 14:57:41 +0800354 plist = subprocess.check_output(["repo", "info"], text=True)
Marko Manb58468a2018-03-19 13:01:19 +0100355 needs_abandon = False
356 for pline in plist.splitlines():
dianlujitao769a9c62024-01-14 17:59:24 +0800357 matchObj = re.match(r"Local Branches.*\[(.*)\]", pline)
Marko Manb58468a2018-03-19 13:01:19 +0100358 if matchObj:
dianlujitao769a9c62024-01-14 17:59:24 +0800359 local_branches = re.split(r"\s*,\s*", matchObj.group(1))
Marko Manb58468a2018-03-19 13:01:19 +0100360 if any(args.start_branch[0] in s for s in local_branches):
361 needs_abandon = True
Pulser72e23242013-09-29 09:56:55 +0100362
Marko Manb58468a2018-03-19 13:01:19 +0100363 if needs_abandon:
364 # Perform the abandon only if the branch already exists
365 if not args.quiet:
dianlujitao769a9c62024-01-14 17:59:24 +0800366 print("Abandoning branch: %s" % args.start_branch[0])
dianlujitao20f0bdb2024-01-14 14:57:41 +0800367 subprocess.run(["repo", "abandon", args.start_branch[0]])
Marko Manb58468a2018-03-19 13:01:19 +0100368 if not args.quiet:
dianlujitao769a9c62024-01-14 17:59:24 +0800369 print("")
Pulser72e23242013-09-29 09:56:55 +0100370
Marko Manb58468a2018-03-19 13:01:19 +0100371 # Get the master manifest from repo
372 # - convert project name and revision to a path
373 project_name_to_data = {}
dianlujitao20f0bdb2024-01-14 14:57:41 +0800374 manifest = subprocess.check_output(["repo", "manifest"], text=True)
Marko Manb58468a2018-03-19 13:01:19 +0100375 xml_root = ElementTree.fromstring(manifest)
dianlujitao769a9c62024-01-14 17:59:24 +0800376 projects = xml_root.findall("project")
377 remotes = xml_root.findall("remote")
378 default_revision = xml_root.findall("default")[0].get("revision")
Marko Manb58468a2018-03-19 13:01:19 +0100379
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000380 # dump project data into the a list of dicts with the following data:
381 # {project: {path, revision}}
Marko Manb58468a2018-03-19 13:01:19 +0100382
383 for project in projects:
dianlujitao769a9c62024-01-14 17:59:24 +0800384 name = project.get("name")
Aaron Kling83fee0f2020-06-19 18:43:16 -0500385 # when name and path are equal, "repo manifest" doesn't return a path at all, so fall back to name
dianlujitao769a9c62024-01-14 17:59:24 +0800386 path = project.get("path", name)
387 revision = project.get("upstream")
Marko Manb58468a2018-03-19 13:01:19 +0100388 if revision is None:
389 for remote in remotes:
dianlujitao769a9c62024-01-14 17:59:24 +0800390 if remote.get("name") == project.get("remote"):
391 revision = remote.get("revision")
Marko Manb58468a2018-03-19 13:01:19 +0100392 if revision is None:
dianlujitao769a9c62024-01-14 17:59:24 +0800393 revision = project.get("revision", default_revision)
Marko Manb58468a2018-03-19 13:01:19 +0100394
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000395 if name not in project_name_to_data:
Marko Manb58468a2018-03-19 13:01:19 +0100396 project_name_to_data[name] = {}
dianlujitao769a9c62024-01-14 17:59:24 +0800397 revision = revision.split("refs/heads/")[-1]
Marko Manb58468a2018-03-19 13:01:19 +0100398 project_name_to_data[name][revision] = path
399
Gabriele Md91609d2018-03-31 14:26:59 +0200400 def cmp_reviews(review_a, review_b):
dianlujitao769a9c62024-01-14 17:59:24 +0800401 current_a = review_a["current_revision"]
402 parents_a = [
403 r["commit"] for r in review_a["revisions"][current_a]["commit"]["parents"]
404 ]
405 current_b = review_b["current_revision"]
406 parents_b = [
407 r["commit"] for r in review_b["revisions"][current_b]["commit"]["parents"]
408 ]
Gabriele Md91609d2018-03-31 14:26:59 +0200409 if current_a in parents_b:
410 return -1
411 elif current_b in parents_a:
412 return 1
413 else:
dianlujitao769a9c62024-01-14 17:59:24 +0800414 return cmp(review_a["number"], review_b["number"])
Gabriele Md91609d2018-03-31 14:26:59 +0200415
dianlujitao324541c2024-01-13 21:27:00 +0800416 # get data on requested changes
Marko Manb58468a2018-03-19 13:01:19 +0100417 if args.topic:
dianlujitao769a9c62024-01-14 17:59:24 +0800418 reviews = fetch_query(args.gerrit, "topic:{0}".format(args.topic))
419 change_numbers = [
420 str(r["number"]) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))
421 ]
dianlujitao324541c2024-01-13 21:27:00 +0800422 elif args.query:
Marko Manb58468a2018-03-19 13:01:19 +0100423 reviews = fetch_query(args.gerrit, args.query)
dianlujitao769a9c62024-01-14 17:59:24 +0800424 change_numbers = [
425 str(r["number"]) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))
426 ]
dianlujitao324541c2024-01-13 21:27:00 +0800427 else:
dianlujitao769a9c62024-01-14 17:59:24 +0800428 change_url_re = re.compile(r"https?://.+?/([0-9]+(?:/[0-9]+)?)/?")
dianlujitao324541c2024-01-13 21:27:00 +0800429 change_numbers = []
Marko Manb58468a2018-03-19 13:01:19 +0100430 for c in args.change_number:
Gabriele M1009aeb2018-04-01 17:50:58 +0200431 change_number = change_url_re.findall(c)
432 if change_number:
433 change_numbers.extend(change_number)
dianlujitao769a9c62024-01-14 17:59:24 +0800434 elif "-" in c:
435 templist = c.split("-")
Marko Manb58468a2018-03-19 13:01:19 +0100436 for i in range(int(templist[0]), int(templist[1]) + 1):
437 change_numbers.append(str(i))
438 else:
439 change_numbers.append(c)
dianlujitao769a9c62024-01-14 17:59:24 +0800440 reviews = fetch_query(
441 args.gerrit,
442 " OR ".join("change:{0}".format(x.split("/")[0]) for x in change_numbers),
443 )
Marko Manb58468a2018-03-19 13:01:19 +0100444
445 # make list of things to actually merge
dianlujitao324541c2024-01-13 21:27:00 +0800446 mergables = defaultdict(list)
Marko Manb58468a2018-03-19 13:01:19 +0100447
448 # If --exclude is given, create the list of commits to ignore
449 exclude = []
450 if args.exclude:
dianlujitao769a9c62024-01-14 17:59:24 +0800451 exclude = args.exclude[0].split(",")
Marko Manb58468a2018-03-19 13:01:19 +0100452
453 for change in change_numbers:
454 patchset = None
dianlujitao769a9c62024-01-14 17:59:24 +0800455 if "/" in change:
456 (change, patchset) = change.split("/")
Marko Manb58468a2018-03-19 13:01:19 +0100457
458 if change in exclude:
459 continue
460
461 change = int(change)
462
463 if patchset is not None:
464 patchset = int(patchset)
465
dianlujitao769a9c62024-01-14 17:59:24 +0800466 review = next((x for x in reviews if x["number"] == change), None)
Marko Manb58468a2018-03-19 13:01:19 +0100467 if review is None:
dianlujitao769a9c62024-01-14 17:59:24 +0800468 print("Change %d not found, skipping" % change)
Marko Manb58468a2018-03-19 13:01:19 +0100469 continue
470
dianlujitao324541c2024-01-13 21:27:00 +0800471 # Check if change is open and exit if it's not, unless -f is specified
472 if is_closed(review["status"]) and not args.force:
473 print(
474 "Change {} status is {}. Skipping the cherry pick.\nUse -f to force this pick.".format(
475 change, review["status"]
476 )
477 )
478 continue
Gabriele M1188cbd2018-04-01 17:50:57 +0200479
dianlujitao324541c2024-01-13 21:27:00 +0800480 # Convert the project name to a project path
481 # - check that the project path exists
482 if (
483 review["project"] in project_name_to_data
484 and review["branch"] in project_name_to_data[review["project"]]
485 ):
486 project_path = project_name_to_data[review["project"]][review["branch"]]
487 elif args.path:
488 project_path = args.path
489 elif (
490 review["project"] in project_name_to_data
491 and len(project_name_to_data[review["project"]]) == 1
492 ):
493 local_branch = list(project_name_to_data[review["project"]])[0]
494 project_path = project_name_to_data[review["project"]][local_branch]
495 print(
496 'WARNING: Project {0} has a different branch ("{1}" != "{2}")'.format(
497 project_path, local_branch, review["branch"]
498 )
499 )
500 elif args.ignore_missing:
501 print(
502 "WARNING: Skipping {0} since there is no project directory for: {1}\n".format(
503 review["id"], review["project"]
504 )
505 )
506 continue
507 else:
508 sys.stderr.write(
509 "ERROR: For {0}, could not determine the project path for project {1}\n".format(
510 review["id"], review["project"]
511 )
512 )
513 sys.exit(1)
514
515 item = {
516 "subject": review["subject"],
517 "project_path": project_path,
518 "branch": review["branch"],
519 "change_id": review["change_id"],
520 "change_number": review["number"],
521 "status": review["status"],
522 "patchset": review["revisions"][review["current_revision"]]["_number"],
523 "fetch": review["revisions"][review["current_revision"]]["fetch"],
524 "id": change,
dianlujitao49475322024-01-13 23:56:59 +0800525 "revision": review["current_revision"],
dianlujitao324541c2024-01-13 21:27:00 +0800526 }
527
Marko Manb58468a2018-03-19 13:01:19 +0100528 if patchset:
dianlujitao49475322024-01-13 23:56:59 +0800529 for x in review["revisions"]:
530 if review["revisions"][x]["_number"] == patchset:
531 item["fetch"] = review["revisions"][x]["fetch"]
532 item["id"] = "{0}/{1}".format(change, patchset)
533 item["patchset"] = patchset
534 item["revision"] = x
535 break
536 else:
dianlujitao769a9c62024-01-14 17:59:24 +0800537 args.quiet or print(
538 "ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.".format(
539 change, patchset
540 )
541 )
Marko Manb58468a2018-03-19 13:01:19 +0100542
dianlujitao324541c2024-01-13 21:27:00 +0800543 mergables[project_path].append(item)
Pulser72e23242013-09-29 09:56:55 +0100544
dianlujitao49475322024-01-13 23:56:59 +0800545 # round 1: start branch and drop picked changes
dianlujitao324541c2024-01-13 21:27:00 +0800546 for project_path, per_path_mergables in mergables.items():
Pulser72e23242013-09-29 09:56:55 +0100547 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
548 if args.start_branch:
dianlujitao20f0bdb2024-01-14 14:57:41 +0800549 subprocess.run(["repo", "start", args.start_branch[0], project_path])
Marko Manb58468a2018-03-19 13:01:19 +0100550
551 # Determine the maximum commits to check already picked changes
552 check_picked_count = args.check_picked
dianlujitao769a9c62024-01-14 17:59:24 +0800553 branch_commits_count = int(
554 subprocess.check_output(
dianlujitao20f0bdb2024-01-14 14:57:41 +0800555 [
556 "git",
557 "rev-list",
558 "--count",
559 "--max-count",
560 str(check_picked_count + 1),
561 "HEAD",
562 ],
563 cwd=project_path,
564 text=True,
dianlujitao769a9c62024-01-14 17:59:24 +0800565 )
566 )
Marko Manb58468a2018-03-19 13:01:19 +0100567 if branch_commits_count <= check_picked_count:
568 check_picked_count = branch_commits_count - 1
569
dianlujitao324541c2024-01-13 21:27:00 +0800570 picked_change_ids = []
571 for i in range(check_picked_count):
dianlujitao49475322024-01-13 23:56:59 +0800572 if not commit_exists(project_path, "HEAD~{0}".format(i)):
Marko Manb58468a2018-03-19 13:01:19 +0100573 continue
dianlujitao769a9c62024-01-14 17:59:24 +0800574 output = subprocess.check_output(
dianlujitao20f0bdb2024-01-14 14:57:41 +0800575 ["git", "show", "-q", f"HEAD~{i}"], cwd=project_path, text=True
dianlujitao769a9c62024-01-14 17:59:24 +0800576 )
Simon Shields6a726492019-11-18 23:56:08 +1100577 output = output.split()
dianlujitao769a9c62024-01-14 17:59:24 +0800578 if "Change-Id:" in output:
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000579 for j, t in enumerate(reversed(output)):
dianlujitao769a9c62024-01-14 17:59:24 +0800580 if t == "Change-Id:":
Marko Manb58468a2018-03-19 13:01:19 +0100581 head_change_id = output[len(output) - j]
dianlujitao324541c2024-01-13 21:27:00 +0800582 picked_change_ids.append(head_change_id.strip())
Marko Manb58468a2018-03-19 13:01:19 +0100583 break
Pulser72e23242013-09-29 09:56:55 +0100584
dianlujitao324541c2024-01-13 21:27:00 +0800585 for item in per_path_mergables:
586 # Check if change is already picked to HEAD...HEAD~check_picked_count
587 if item["change_id"] in picked_change_ids:
588 print(
589 "Skipping {0} - already picked in {1}".format(
590 item["id"], project_path
591 )
dianlujitao769a9c62024-01-14 17:59:24 +0800592 )
dianlujitao49475322024-01-13 23:56:59 +0800593 per_path_mergables.remove(item)
dianlujitao324541c2024-01-13 21:27:00 +0800594
dianlujitao49475322024-01-13 23:56:59 +0800595 # round 2: fetch changes in parallel if not pull
596 if not args.pull:
597 with ThreadPoolExecutor(max_workers=args.jobs) as e:
598 for per_path_mergables in mergables.values():
599 # changes are sorted so loop in reversed order to fetch top commits first
600 for item in reversed(per_path_mergables):
601 e.submit(partial(do_git_fetch_pull, args), item)
602
603 # round 3: apply changes in parallel for different projects, but sequential
604 # within each project
605 with ThreadPoolExecutor(max_workers=args.jobs) as e:
606
607 def bulk_pick_change(per_path_mergables):
608 for item in per_path_mergables:
609 apply_change(args, item)
610
611 for per_path_mergables in mergables.values():
612 e.submit(bulk_pick_change, per_path_mergables)
dianlujitao324541c2024-01-13 21:27:00 +0800613
614
dianlujitao49475322024-01-13 23:56:59 +0800615def do_git_fetch_pull(args, item):
dianlujitao324541c2024-01-13 21:27:00 +0800616 project_path = item["project_path"]
617
dianlujitao49475322024-01-13 23:56:59 +0800618 # commit object already exists, no need to fetch
619 if commit_exists(project_path, item["revision"]):
620 return
Pulser72e23242013-09-29 09:56:55 +0100621
dianlujitao324541c2024-01-13 21:27:00 +0800622 if "anonymous http" in item["fetch"]:
623 method = "anonymous http"
624 else:
625 method = "ssh"
Pulser72e23242013-09-29 09:56:55 +0100626
dianlujitao324541c2024-01-13 21:27:00 +0800627 if args.pull:
628 cmd = ["git", "pull", "--no-edit"]
629 else:
630 cmd = ["git", "fetch"]
631 if args.quiet:
632 cmd.append("--quiet")
633 cmd.extend(["", item["fetch"][method]["ref"]])
dianlujitao20f0bdb2024-01-14 14:57:41 +0800634
dianlujitao324541c2024-01-13 21:27:00 +0800635 # Try fetching from GitHub first if using default gerrit
636 if args.gerrit == DEFAULT_GERRIT:
637 if args.verbose:
638 print("Trying to fetch the change from GitHub")
Marko Manb58468a2018-03-19 13:01:19 +0100639
dianlujitao324541c2024-01-13 21:27:00 +0800640 cmd[-2] = "omnirom"
641 if not args.quiet:
642 print(cmd)
643 result = subprocess.call(cmd, cwd=project_path)
dianlujitao24df6a42024-01-14 17:21:29 +0800644 # Check if it worked
645 if result == 0 or commit_exists(project_path, item["revision"]):
646 return
647 print("ERROR: git command failed")
dianlujitao324541c2024-01-13 21:27:00 +0800648
dianlujitao24df6a42024-01-14 17:21:29 +0800649 # If not using the default gerrit or github failed, fetch from gerrit.
650 if args.verbose:
651 if args.gerrit == DEFAULT_GERRIT:
652 print(
653 "Fetching from GitHub didn't work, trying to fetch the change from Gerrit"
654 )
655 else:
656 print("Fetching from {0}".format(args.gerrit))
657
658 cmd[-2] = item["fetch"][method]["url"]
659 if not args.quiet:
660 print(cmd)
661 result = subprocess.call(cmd, cwd=project_path)
662 if result != 0 and not commit_exists(project_path, item["revision"]):
663 print("ERROR: git command failed")
664 sys.exit(result)
dianlujitao49475322024-01-13 23:56:59 +0800665
666
667def apply_change(args, item):
668 args.quiet or print("Applying change number {0}...".format(item["id"]))
669 if is_closed(item["status"]):
670 print("!! Force-picking a closed change !!\n")
671
672 project_path = item["project_path"]
673
674 # Print out some useful info
675 if not args.quiet:
676 print('--> Subject: "{0}"'.format(item["subject"]))
677 print("--> Project path: {0}".format(project_path))
678 print(
679 "--> Change number: {0} (Patch Set {1})".format(
680 item["id"], item["patchset"]
681 )
682 )
683
684 if args.pull:
685 do_git_fetch_pull(args, item)
686 else:
687 # Perform the cherry-pick
dianlujitao324541c2024-01-13 21:27:00 +0800688 if args.quiet:
689 cmd_out = subprocess.DEVNULL
690 else:
691 cmd_out = None
692 result = subprocess.call(
693 ["git", "cherry-pick", "--ff", item["revision"]],
694 cwd=project_path,
695 stdout=cmd_out,
696 stderr=cmd_out,
697 )
698 if result != 0:
dianlujitao769a9c62024-01-14 17:59:24 +0800699 result = subprocess.call(
dianlujitao324541c2024-01-13 21:27:00 +0800700 ["git", "diff-index", "--quiet", "HEAD", "--"],
dianlujitao20f0bdb2024-01-14 14:57:41 +0800701 cwd=project_path,
702 stdout=cmd_out,
703 stderr=cmd_out,
dianlujitao769a9c62024-01-14 17:59:24 +0800704 )
dianlujitao324541c2024-01-13 21:27:00 +0800705 if result == 0:
706 print(
707 "WARNING: git command resulted with an empty commit, aborting cherry-pick"
708 )
709 subprocess.call(
710 ["git", "cherry-pick", "--abort"],
dianlujitao20f0bdb2024-01-14 14:57:41 +0800711 cwd=project_path,
712 stdout=cmd_out,
713 stderr=cmd_out,
dianlujitao769a9c62024-01-14 17:59:24 +0800714 )
dianlujitao324541c2024-01-13 21:27:00 +0800715 elif args.reset:
716 print("ERROR: git command failed, aborting cherry-pick")
717 subprocess.call(
718 ["git", "cherry-pick", "--abort"],
719 cwd=project_path,
720 stdout=cmd_out,
721 stderr=cmd_out,
722 )
723 sys.exit(result)
724 else:
725 print("ERROR: git command failed")
726 sys.exit(result)
727 if not args.quiet:
728 print("")
729
730
731if __name__ == "__main__":
732 main()