blob: 7c83bc2c76656a3160bc62f018bb9052ebd8edbb [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
Pulser72e23242013-09-29 09:56:55 +010037
Luca Weissd1bbac62018-11-25 14:07:12 +010038# cmp() is not available in Python 3, define it manually
39# See https://docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons
40def cmp(a, b):
41 return (a > b) - (a < b)
42
43
Pulser72e23242013-09-29 09:56:55 +010044# Verifies whether pathA is a subdirectory (or the same) as pathB
Marko Manb58468a2018-03-19 13:01:19 +010045def is_subdir(a, b):
dianlujitao769a9c62024-01-14 17:59:24 +080046 a = os.path.realpath(a) + "/"
47 b = os.path.realpath(b) + "/"
48 return b == a[: len(b)]
Pulser72e23242013-09-29 09:56:55 +010049
Pulser72e23242013-09-29 09:56:55 +010050
Marko Manb58468a2018-03-19 13:01:19 +010051def fetch_query_via_ssh(remote_url, query):
52 """Given a remote_url and a query, return the list of changes that fit it
dianlujitao769a9c62024-01-14 17:59:24 +080053 This function is slightly messy - the ssh api does not return data in the same structure as the HTTP REST API
54 We have to get the data, then transform it to match what we're expecting from the HTTP RESET API
55 """
56 if remote_url.count(":") == 2:
dianlujitao99e500b2024-01-14 17:27:02 +080057 (_, userhost, port) = remote_url.split(":")
Marko Manb58468a2018-03-19 13:01:19 +010058 userhost = userhost[2:]
dianlujitao769a9c62024-01-14 17:59:24 +080059 elif remote_url.count(":") == 1:
dianlujitao99e500b2024-01-14 17:27:02 +080060 (_, userhost) = remote_url.split(":")
Marko Manb58468a2018-03-19 13:01:19 +010061 userhost = userhost[2:]
dianlujitao20f0bdb2024-01-14 14:57:41 +080062 port = "29418"
Pulser72e23242013-09-29 09:56:55 +010063 else:
dianlujitao769a9c62024-01-14 17:59:24 +080064 raise Exception("Malformed URI: Expecting ssh://[user@]host[:port]")
Pulser72e23242013-09-29 09:56:55 +010065
dianlujitao769a9c62024-01-14 17:59:24 +080066 out = subprocess.check_output(
67 [
68 "ssh",
69 "-x",
dianlujitao20f0bdb2024-01-14 14:57:41 +080070 "-p",
71 port,
dianlujitao769a9c62024-01-14 17:59:24 +080072 userhost,
73 "gerrit",
74 "query",
dianlujitao20f0bdb2024-01-14 14:57:41 +080075 "--format",
76 "JSON",
77 "--patch-sets",
78 "--current-patch-set",
dianlujitao769a9c62024-01-14 17:59:24 +080079 query,
dianlujitao20f0bdb2024-01-14 14:57:41 +080080 ],
81 text=True,
dianlujitao769a9c62024-01-14 17:59:24 +080082 )
Marko Manb58468a2018-03-19 13:01:19 +010083 reviews = []
dianlujitao769a9c62024-01-14 17:59:24 +080084 for line in out.split("\n"):
Marko Manb58468a2018-03-19 13:01:19 +010085 try:
86 data = json.loads(line)
87 # make our data look like the http rest api data
88 review = {
dianlujitao769a9c62024-01-14 17:59:24 +080089 "branch": data["branch"],
90 "change_id": data["id"],
91 "current_revision": data["currentPatchSet"]["revision"],
92 "number": int(data["number"]),
93 "revisions": {
94 patch_set["revision"]: {
95 "_number": int(patch_set["number"]),
96 "fetch": {
97 "ssh": {
98 "ref": patch_set["ref"],
99 "url": "ssh://{0}:{1}/{2}".format(
100 userhost, port, data["project"]
101 ),
102 }
103 },
104 "commit": {
105 "parents": [
106 {"commit": parent} for parent in patch_set["parents"]
107 ]
108 },
109 }
110 for patch_set in data["patchSets"]
111 },
112 "subject": data["subject"],
113 "project": data["project"],
114 "status": data["status"],
Marko Manb58468a2018-03-19 13:01:19 +0100115 }
116 reviews.append(review)
dianlujitao99e500b2024-01-14 17:27:02 +0800117 except Exception:
Marko Manb58468a2018-03-19 13:01:19 +0100118 pass
Marko Manb58468a2018-03-19 13:01:19 +0100119 return reviews
Pulser72e23242013-09-29 09:56:55 +0100120
Pulser72e23242013-09-29 09:56:55 +0100121
dianlujitao87a692f2024-01-14 17:01:12 +0800122def build_query_url(remote_url, query, auth):
123 p = urllib.parse.urlparse(remote_url)._asdict()
124 p["path"] = ("/a" if auth else "") + "/changes"
125 p["query"] = urllib.parse.urlencode(
126 {
127 "q": query,
128 "o": ["CURRENT_REVISION", "ALL_REVISIONS", "ALL_COMMITS"],
129 },
130 doseq=True,
131 )
132 return urllib.parse.urlunparse(urllib.parse.ParseResult(**p))
Marko Manb58468a2018-03-19 13:01:19 +0100133
dianlujitao87a692f2024-01-14 17:01:12 +0800134
135def fetch_query_via_http(remote_url, query, auth=True):
136 """Given a query, fetch the change numbers via http"""
137 if auth:
138 gerritrc = os.path.expanduser("~/.gerritrc")
139 username = password = ""
140 if os.path.isfile(gerritrc):
141 with open(gerritrc, "r") as f:
142 for line in f:
143 parts = line.rstrip().split("|")
144 if parts[0] in remote_url:
145 username, password = parts[1], parts[2]
146
147 if username and password:
148 url = build_query_url(remote_url, query, auth)
149 password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
150 password_mgr.add_password(None, url, username, password)
151 auth_handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
152 opener = urllib.request.build_opener(auth_handler)
153 response = opener.open(url)
154 if response.getcode() != 200:
155 # They didn't get good authorization or data, Let's try the old way
156 return fetch_query_via_http(remote_url, query, False)
157 else:
158 return fetch_query_via_http(remote_url, query, False)
159 else:
160 url = build_query_url(remote_url, query, auth)
161 response = urllib.request.urlopen(url)
162
163 data = response.read().decode("utf-8")
164 reviews = json.loads(data[5:])
Marko Manb58468a2018-03-19 13:01:19 +0100165 for review in reviews:
dianlujitao769a9c62024-01-14 17:59:24 +0800166 review["number"] = review.pop("_number")
Marko Manb58468a2018-03-19 13:01:19 +0100167
168 return reviews
169
170
171def fetch_query(remote_url, query):
172 """Wrapper for fetch_query_via_proto functions"""
dianlujitao769a9c62024-01-14 17:59:24 +0800173 if remote_url[0:3] == "ssh":
Marko Manb58468a2018-03-19 13:01:19 +0100174 return fetch_query_via_ssh(remote_url, query)
dianlujitao769a9c62024-01-14 17:59:24 +0800175 elif remote_url[0:4] == "http":
dianlujitao87a692f2024-01-14 17:01:12 +0800176 return fetch_query_via_http(remote_url, query)
Marko Manb58468a2018-03-19 13:01:19 +0100177 else:
dianlujitao769a9c62024-01-14 17:59:24 +0800178 raise Exception(
179 "Gerrit URL should be in the form http[s]://hostname/ or ssh://[user@]host[:port]"
180 )
Marko Manb58468a2018-03-19 13:01:19 +0100181
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000182
dianlujitao324541c2024-01-13 21:27:00 +0800183def is_closed(status):
184 return status not in ("OPEN", "NEW", "DRAFT")
Marko Manb58468a2018-03-19 13:01:19 +0100185
dianlujitao324541c2024-01-13 21:27:00 +0800186
dianlujitao27e18992024-02-08 20:23:35 +0800187def is_omnirom_gerrit(remote_url):
188 p = urllib.parse.urlparse(remote_url)
189 return p.hostname == "gerrit.omnirom.org"
190
191
dianlujitao49475322024-01-13 23:56:59 +0800192def commit_exists(project_path, revision):
193 return (
194 subprocess.call(
195 ["git", "cat-file", "-e", revision],
196 cwd=project_path,
197 stderr=subprocess.DEVNULL,
198 )
199 == 0
200 )
201
202
dianlujitao324541c2024-01-13 21:27:00 +0800203def main():
dianlujitao769a9c62024-01-14 17:59:24 +0800204 parser = argparse.ArgumentParser(
205 formatter_class=argparse.RawDescriptionHelpFormatter,
206 description=textwrap.dedent(
207 """\
Marko Manb58468a2018-03-19 13:01:19 +0100208 repopick.py is a utility to simplify the process of cherry picking
209 patches from OmniRom's Gerrit instance (or any gerrit instance of your choosing)
210
211 Given a list of change numbers, repopick will cd into the project path
212 and cherry pick the latest patch available.
213
214 With the --start-branch argument, the user can specify that a branch
215 should be created before cherry picking. This is useful for
216 cherry-picking many patches into a common branch which can be easily
217 abandoned later (good for testing other's changes.)
218
219 The --abandon-first argument, when used in conjunction with the
220 --start-branch option, will cause repopick to abandon the specified
dianlujitao769a9c62024-01-14 17:59:24 +0800221 branch in all repos first before performing any cherry picks."""
222 ),
223 )
224 parser.add_argument(
225 "change_number",
226 nargs="*",
227 help="change number to cherry pick. Use {change number}/{patchset number} to get a specific revision.",
228 )
229 parser.add_argument(
230 "-i",
231 "--ignore-missing",
232 action="store_true",
233 help="do not error out if a patch applies to a missing directory",
234 )
235 parser.add_argument(
236 "-s",
237 "--start-branch",
238 nargs=1,
239 metavar="",
240 help="start the specified branch before cherry picking",
241 )
242 parser.add_argument(
243 "-r",
244 "--reset",
245 action="store_true",
246 help="reset to initial state (abort cherry-pick) if there is a conflict",
247 )
248 parser.add_argument(
249 "-a",
250 "--abandon-first",
251 action="store_true",
252 help="before cherry picking, abandon the branch specified in --start-branch",
253 )
254 parser.add_argument(
255 "-b",
256 "--auto-branch",
257 action="store_true",
258 help='shortcut to "--start-branch auto --abandon-first --ignore-missing"',
259 )
260 parser.add_argument(
261 "-q", "--quiet", action="store_true", help="print as little as possible"
262 )
263 parser.add_argument(
264 "-v",
265 "--verbose",
266 action="store_true",
267 help="print extra information to aid in debug",
268 )
269 parser.add_argument(
270 "-f",
271 "--force",
272 action="store_true",
273 help="force cherry pick even if change is closed",
274 )
275 parser.add_argument(
276 "-p", "--pull", action="store_true", help="execute pull instead of cherry-pick"
277 )
278 parser.add_argument(
279 "-P", "--path", metavar="", help="use the specified path for the change"
280 )
281 parser.add_argument(
282 "-t", "--topic", metavar="", help="pick all commits from a specified topic"
283 )
284 parser.add_argument(
285 "-Q", "--query", metavar="", help="pick all commits using the specified query"
286 )
287 parser.add_argument(
288 "-g",
289 "--gerrit",
dianlujitao27e18992024-02-08 20:23:35 +0800290 default="https://gerrit.omnirom.org",
dianlujitao769a9c62024-01-14 17:59:24 +0800291 metavar="",
292 help="Gerrit Instance to use. Form proto://[user@]host[:port]",
293 )
294 parser.add_argument(
295 "-e",
296 "--exclude",
297 nargs=1,
298 metavar="",
299 help="exclude a list of commit numbers separated by a ,",
300 )
301 parser.add_argument(
302 "-c",
303 "--check-picked",
304 type=int,
305 default=10,
306 metavar="",
307 help="pass the amount of commits to check for already picked changes",
308 )
dianlujitao49475322024-01-13 23:56:59 +0800309 parser.add_argument(
310 "-j",
311 "--jobs",
312 type=int,
313 default=4,
314 metavar="",
315 help="max number of changes to pick in parallel",
316 )
Marko Manb58468a2018-03-19 13:01:19 +0100317 args = parser.parse_args()
318 if not args.start_branch and args.abandon_first:
dianlujitao769a9c62024-01-14 17:59:24 +0800319 parser.error(
320 "if --abandon-first is set, you must also give the branch name with --start-branch"
321 )
Marko Manb58468a2018-03-19 13:01:19 +0100322 if args.auto_branch:
323 args.abandon_first = True
324 args.ignore_missing = True
325 if not args.start_branch:
dianlujitao769a9c62024-01-14 17:59:24 +0800326 args.start_branch = ["auto"]
Marko Manb58468a2018-03-19 13:01:19 +0100327 if args.quiet and args.verbose:
dianlujitao769a9c62024-01-14 17:59:24 +0800328 parser.error("--quiet and --verbose cannot be specified together")
Marko Manb58468a2018-03-19 13:01:19 +0100329
330 if (1 << bool(args.change_number) << bool(args.topic) << bool(args.query)) != 2:
dianlujitao769a9c62024-01-14 17:59:24 +0800331 parser.error(
332 "One (and only one) of change_number, topic, and query are allowed"
333 )
Marko Manb58468a2018-03-19 13:01:19 +0100334
335 # Change current directory to the top of the tree
dianlujitao769a9c62024-01-14 17:59:24 +0800336 if "ANDROID_BUILD_TOP" in os.environ:
337 top = os.environ["ANDROID_BUILD_TOP"]
Marko Manb58468a2018-03-19 13:01:19 +0100338
339 if not is_subdir(os.getcwd(), top):
dianlujitao769a9c62024-01-14 17:59:24 +0800340 sys.stderr.write(
341 "ERROR: You must run this tool from within $ANDROID_BUILD_TOP!\n"
342 )
Marko Manb58468a2018-03-19 13:01:19 +0100343 sys.exit(1)
dianlujitao769a9c62024-01-14 17:59:24 +0800344 os.chdir(os.environ["ANDROID_BUILD_TOP"])
Marko Manb58468a2018-03-19 13:01:19 +0100345
346 # Sanity check that we are being run from the top level of the tree
dianlujitao769a9c62024-01-14 17:59:24 +0800347 if not os.path.isdir(".repo"):
348 sys.stderr.write(
349 "ERROR: No .repo directory found. Please run this from the top of your tree.\n"
350 )
Pulser72e23242013-09-29 09:56:55 +0100351 sys.exit(1)
352
Marko Manb58468a2018-03-19 13:01:19 +0100353 # If --abandon-first is given, abandon the branch before starting
354 if args.abandon_first:
355 # Determine if the branch already exists; skip the abandon if it does not
dianlujitao20f0bdb2024-01-14 14:57:41 +0800356 plist = subprocess.check_output(["repo", "info"], text=True)
Marko Manb58468a2018-03-19 13:01:19 +0100357 needs_abandon = False
358 for pline in plist.splitlines():
dianlujitao769a9c62024-01-14 17:59:24 +0800359 matchObj = re.match(r"Local Branches.*\[(.*)\]", pline)
Marko Manb58468a2018-03-19 13:01:19 +0100360 if matchObj:
dianlujitao769a9c62024-01-14 17:59:24 +0800361 local_branches = re.split(r"\s*,\s*", matchObj.group(1))
Marko Manb58468a2018-03-19 13:01:19 +0100362 if any(args.start_branch[0] in s for s in local_branches):
363 needs_abandon = True
Pulser72e23242013-09-29 09:56:55 +0100364
Marko Manb58468a2018-03-19 13:01:19 +0100365 if needs_abandon:
366 # Perform the abandon only if the branch already exists
367 if not args.quiet:
dianlujitao769a9c62024-01-14 17:59:24 +0800368 print("Abandoning branch: %s" % args.start_branch[0])
dianlujitao20f0bdb2024-01-14 14:57:41 +0800369 subprocess.run(["repo", "abandon", args.start_branch[0]])
Marko Manb58468a2018-03-19 13:01:19 +0100370 if not args.quiet:
dianlujitao769a9c62024-01-14 17:59:24 +0800371 print("")
Pulser72e23242013-09-29 09:56:55 +0100372
Marko Manb58468a2018-03-19 13:01:19 +0100373 # Get the master manifest from repo
374 # - convert project name and revision to a path
375 project_name_to_data = {}
dianlujitao20f0bdb2024-01-14 14:57:41 +0800376 manifest = subprocess.check_output(["repo", "manifest"], text=True)
Marko Manb58468a2018-03-19 13:01:19 +0100377 xml_root = ElementTree.fromstring(manifest)
dianlujitao769a9c62024-01-14 17:59:24 +0800378 projects = xml_root.findall("project")
379 remotes = xml_root.findall("remote")
380 default_revision = xml_root.findall("default")[0].get("revision")
dianlujitao99e500b2024-01-14 17:27:02 +0800381 if not default_revision:
382 raise ValueError("Failed to get revision from manifest")
Marko Manb58468a2018-03-19 13:01:19 +0100383
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000384 # dump project data into the a list of dicts with the following data:
385 # {project: {path, revision}}
Marko Manb58468a2018-03-19 13:01:19 +0100386
387 for project in projects:
dianlujitao769a9c62024-01-14 17:59:24 +0800388 name = project.get("name")
Aaron Kling83fee0f2020-06-19 18:43:16 -0500389 # 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 +0800390 path = project.get("path", name)
391 revision = project.get("upstream")
Marko Manb58468a2018-03-19 13:01:19 +0100392 if revision is None:
393 for remote in remotes:
dianlujitao769a9c62024-01-14 17:59:24 +0800394 if remote.get("name") == project.get("remote"):
395 revision = remote.get("revision")
Marko Manb58468a2018-03-19 13:01:19 +0100396 if revision is None:
dianlujitao769a9c62024-01-14 17:59:24 +0800397 revision = project.get("revision", default_revision)
Marko Manb58468a2018-03-19 13:01:19 +0100398
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000399 if name not in project_name_to_data:
Marko Manb58468a2018-03-19 13:01:19 +0100400 project_name_to_data[name] = {}
dianlujitao769a9c62024-01-14 17:59:24 +0800401 revision = revision.split("refs/heads/")[-1]
Marko Manb58468a2018-03-19 13:01:19 +0100402 project_name_to_data[name][revision] = path
403
Gabriele Md91609d2018-03-31 14:26:59 +0200404 def cmp_reviews(review_a, review_b):
dianlujitao769a9c62024-01-14 17:59:24 +0800405 current_a = review_a["current_revision"]
406 parents_a = [
407 r["commit"] for r in review_a["revisions"][current_a]["commit"]["parents"]
408 ]
409 current_b = review_b["current_revision"]
410 parents_b = [
411 r["commit"] for r in review_b["revisions"][current_b]["commit"]["parents"]
412 ]
Gabriele Md91609d2018-03-31 14:26:59 +0200413 if current_a in parents_b:
414 return -1
415 elif current_b in parents_a:
416 return 1
417 else:
dianlujitao769a9c62024-01-14 17:59:24 +0800418 return cmp(review_a["number"], review_b["number"])
Gabriele Md91609d2018-03-31 14:26:59 +0200419
dianlujitao324541c2024-01-13 21:27:00 +0800420 # get data on requested changes
Marko Manb58468a2018-03-19 13:01:19 +0100421 if args.topic:
dianlujitao769a9c62024-01-14 17:59:24 +0800422 reviews = fetch_query(args.gerrit, "topic:{0}".format(args.topic))
423 change_numbers = [
424 str(r["number"]) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))
425 ]
dianlujitao324541c2024-01-13 21:27:00 +0800426 elif args.query:
Marko Manb58468a2018-03-19 13:01:19 +0100427 reviews = fetch_query(args.gerrit, args.query)
dianlujitao769a9c62024-01-14 17:59:24 +0800428 change_numbers = [
429 str(r["number"]) for r in sorted(reviews, key=cmp_to_key(cmp_reviews))
430 ]
dianlujitao324541c2024-01-13 21:27:00 +0800431 else:
dianlujitao769a9c62024-01-14 17:59:24 +0800432 change_url_re = re.compile(r"https?://.+?/([0-9]+(?:/[0-9]+)?)/?")
dianlujitao324541c2024-01-13 21:27:00 +0800433 change_numbers = []
Marko Manb58468a2018-03-19 13:01:19 +0100434 for c in args.change_number:
Gabriele M1009aeb2018-04-01 17:50:58 +0200435 change_number = change_url_re.findall(c)
436 if change_number:
437 change_numbers.extend(change_number)
dianlujitao769a9c62024-01-14 17:59:24 +0800438 elif "-" in c:
439 templist = c.split("-")
Marko Manb58468a2018-03-19 13:01:19 +0100440 for i in range(int(templist[0]), int(templist[1]) + 1):
441 change_numbers.append(str(i))
442 else:
443 change_numbers.append(c)
dianlujitao769a9c62024-01-14 17:59:24 +0800444 reviews = fetch_query(
445 args.gerrit,
446 " OR ".join("change:{0}".format(x.split("/")[0]) for x in change_numbers),
447 )
Marko Manb58468a2018-03-19 13:01:19 +0100448
449 # make list of things to actually merge
dianlujitao324541c2024-01-13 21:27:00 +0800450 mergables = defaultdict(list)
Marko Manb58468a2018-03-19 13:01:19 +0100451
452 # If --exclude is given, create the list of commits to ignore
453 exclude = []
454 if args.exclude:
dianlujitao769a9c62024-01-14 17:59:24 +0800455 exclude = args.exclude[0].split(",")
Marko Manb58468a2018-03-19 13:01:19 +0100456
457 for change in change_numbers:
458 patchset = None
dianlujitao769a9c62024-01-14 17:59:24 +0800459 if "/" in change:
460 (change, patchset) = change.split("/")
Marko Manb58468a2018-03-19 13:01:19 +0100461
462 if change in exclude:
463 continue
464
465 change = int(change)
466
467 if patchset is not None:
468 patchset = int(patchset)
469
dianlujitao769a9c62024-01-14 17:59:24 +0800470 review = next((x for x in reviews if x["number"] == change), None)
Marko Manb58468a2018-03-19 13:01:19 +0100471 if review is None:
dianlujitao769a9c62024-01-14 17:59:24 +0800472 print("Change %d not found, skipping" % change)
Marko Manb58468a2018-03-19 13:01:19 +0100473 continue
474
dianlujitao324541c2024-01-13 21:27:00 +0800475 # Check if change is open and exit if it's not, unless -f is specified
476 if is_closed(review["status"]) and not args.force:
477 print(
478 "Change {} status is {}. Skipping the cherry pick.\nUse -f to force this pick.".format(
479 change, review["status"]
480 )
481 )
482 continue
Gabriele M1188cbd2018-04-01 17:50:57 +0200483
dianlujitao324541c2024-01-13 21:27:00 +0800484 # Convert the project name to a project path
485 # - check that the project path exists
486 if (
487 review["project"] in project_name_to_data
488 and review["branch"] in project_name_to_data[review["project"]]
489 ):
490 project_path = project_name_to_data[review["project"]][review["branch"]]
491 elif args.path:
492 project_path = args.path
493 elif (
494 review["project"] in project_name_to_data
495 and len(project_name_to_data[review["project"]]) == 1
496 ):
497 local_branch = list(project_name_to_data[review["project"]])[0]
498 project_path = project_name_to_data[review["project"]][local_branch]
499 print(
500 'WARNING: Project {0} has a different branch ("{1}" != "{2}")'.format(
501 project_path, local_branch, review["branch"]
502 )
503 )
504 elif args.ignore_missing:
505 print(
506 "WARNING: Skipping {0} since there is no project directory for: {1}\n".format(
LuK13379e1b8152024-03-11 11:57:32 +0100507 review["number"], review["project"]
dianlujitao324541c2024-01-13 21:27:00 +0800508 )
509 )
510 continue
511 else:
512 sys.stderr.write(
513 "ERROR: For {0}, could not determine the project path for project {1}\n".format(
LuK13379e1b8152024-03-11 11:57:32 +0100514 review["number"], review["project"]
dianlujitao324541c2024-01-13 21:27:00 +0800515 )
516 )
517 sys.exit(1)
518
519 item = {
520 "subject": review["subject"],
521 "project_path": project_path,
522 "branch": review["branch"],
523 "change_id": review["change_id"],
524 "change_number": review["number"],
525 "status": review["status"],
526 "patchset": review["revisions"][review["current_revision"]]["_number"],
527 "fetch": review["revisions"][review["current_revision"]]["fetch"],
528 "id": change,
dianlujitao49475322024-01-13 23:56:59 +0800529 "revision": review["current_revision"],
dianlujitao324541c2024-01-13 21:27:00 +0800530 }
531
Marko Manb58468a2018-03-19 13:01:19 +0100532 if patchset:
dianlujitao49475322024-01-13 23:56:59 +0800533 for x in review["revisions"]:
534 if review["revisions"][x]["_number"] == patchset:
535 item["fetch"] = review["revisions"][x]["fetch"]
536 item["id"] = "{0}/{1}".format(change, patchset)
537 item["patchset"] = patchset
538 item["revision"] = x
539 break
540 else:
dianlujitao99e500b2024-01-14 17:27:02 +0800541 if not args.quiet:
542 print(
543 "ERROR: The patch set {0}/{1} could not be found, using CURRENT_REVISION instead.".format(
544 change, patchset
545 )
dianlujitao769a9c62024-01-14 17:59:24 +0800546 )
Marko Manb58468a2018-03-19 13:01:19 +0100547
dianlujitao324541c2024-01-13 21:27:00 +0800548 mergables[project_path].append(item)
Pulser72e23242013-09-29 09:56:55 +0100549
dianlujitao49475322024-01-13 23:56:59 +0800550 # round 1: start branch and drop picked changes
LuK1337d1e49ae2024-02-08 13:47:41 +0100551 for project_path in mergables:
Pulser72e23242013-09-29 09:56:55 +0100552 # If --start-branch is given, create the branch (more than once per path is okay; repo ignores gracefully)
553 if args.start_branch:
dianlujitao20f0bdb2024-01-14 14:57:41 +0800554 subprocess.run(["repo", "start", args.start_branch[0], project_path])
Marko Manb58468a2018-03-19 13:01:19 +0100555
556 # Determine the maximum commits to check already picked changes
557 check_picked_count = args.check_picked
dianlujitao769a9c62024-01-14 17:59:24 +0800558 branch_commits_count = int(
559 subprocess.check_output(
dianlujitao20f0bdb2024-01-14 14:57:41 +0800560 [
561 "git",
562 "rev-list",
563 "--count",
564 "--max-count",
565 str(check_picked_count + 1),
566 "HEAD",
567 ],
568 cwd=project_path,
569 text=True,
dianlujitao769a9c62024-01-14 17:59:24 +0800570 )
571 )
Marko Manb58468a2018-03-19 13:01:19 +0100572 if branch_commits_count <= check_picked_count:
573 check_picked_count = branch_commits_count - 1
574
dianlujitao324541c2024-01-13 21:27:00 +0800575 picked_change_ids = []
576 for i in range(check_picked_count):
dianlujitao49475322024-01-13 23:56:59 +0800577 if not commit_exists(project_path, "HEAD~{0}".format(i)):
Marko Manb58468a2018-03-19 13:01:19 +0100578 continue
dianlujitao769a9c62024-01-14 17:59:24 +0800579 output = subprocess.check_output(
dianlujitao20f0bdb2024-01-14 14:57:41 +0800580 ["git", "show", "-q", f"HEAD~{i}"], cwd=project_path, text=True
dianlujitao769a9c62024-01-14 17:59:24 +0800581 )
Simon Shields6a726492019-11-18 23:56:08 +1100582 output = output.split()
dianlujitao769a9c62024-01-14 17:59:24 +0800583 if "Change-Id:" in output:
Aayush Gupta2f4522c2020-07-26 07:19:19 +0000584 for j, t in enumerate(reversed(output)):
dianlujitao769a9c62024-01-14 17:59:24 +0800585 if t == "Change-Id:":
Marko Manb58468a2018-03-19 13:01:19 +0100586 head_change_id = output[len(output) - j]
dianlujitao324541c2024-01-13 21:27:00 +0800587 picked_change_ids.append(head_change_id.strip())
Marko Manb58468a2018-03-19 13:01:19 +0100588 break
Pulser72e23242013-09-29 09:56:55 +0100589
LuK1337d1e49ae2024-02-08 13:47:41 +0100590 def filter_picked(item):
dianlujitao324541c2024-01-13 21:27:00 +0800591 # Check if change is already picked to HEAD...HEAD~check_picked_count
592 if item["change_id"] in picked_change_ids:
593 print(
594 "Skipping {0} - already picked in {1}".format(
595 item["id"], project_path
596 )
dianlujitao769a9c62024-01-14 17:59:24 +0800597 )
LuK1337d1e49ae2024-02-08 13:47:41 +0100598 return False
599 return True
600
601 mergables[project_path] = list(filter(filter_picked, mergables[project_path]))
dianlujitao324541c2024-01-13 21:27:00 +0800602
dianlujitao49475322024-01-13 23:56:59 +0800603 # round 2: fetch changes in parallel if not pull
604 if not args.pull:
605 with ThreadPoolExecutor(max_workers=args.jobs) as e:
606 for per_path_mergables in mergables.values():
607 # changes are sorted so loop in reversed order to fetch top commits first
608 for item in reversed(per_path_mergables):
609 e.submit(partial(do_git_fetch_pull, args), item)
610
611 # round 3: apply changes in parallel for different projects, but sequential
612 # within each project
613 with ThreadPoolExecutor(max_workers=args.jobs) as e:
614
615 def bulk_pick_change(per_path_mergables):
616 for item in per_path_mergables:
617 apply_change(args, item)
618
619 for per_path_mergables in mergables.values():
620 e.submit(bulk_pick_change, per_path_mergables)
dianlujitao324541c2024-01-13 21:27:00 +0800621
622
dianlujitao49475322024-01-13 23:56:59 +0800623def do_git_fetch_pull(args, item):
dianlujitao324541c2024-01-13 21:27:00 +0800624 project_path = item["project_path"]
625
dianlujitao49475322024-01-13 23:56:59 +0800626 # commit object already exists, no need to fetch
LuK1337ad75e972024-02-11 19:10:52 +0100627 if not args.pull and commit_exists(project_path, item["revision"]):
dianlujitao49475322024-01-13 23:56:59 +0800628 return
Pulser72e23242013-09-29 09:56:55 +0100629
dianlujitao324541c2024-01-13 21:27:00 +0800630 if "anonymous http" in item["fetch"]:
631 method = "anonymous http"
632 else:
633 method = "ssh"
Pulser72e23242013-09-29 09:56:55 +0100634
dianlujitao324541c2024-01-13 21:27:00 +0800635 if args.pull:
636 cmd = ["git", "pull", "--no-edit"]
637 else:
638 cmd = ["git", "fetch"]
639 if args.quiet:
640 cmd.append("--quiet")
641 cmd.extend(["", item["fetch"][method]["ref"]])
dianlujitao20f0bdb2024-01-14 14:57:41 +0800642
dianlujitao27e18992024-02-08 20:23:35 +0800643 # Try fetching from GitHub first if using omnirom gerrit
644 if is_omnirom_gerrit(args.gerrit):
dianlujitao324541c2024-01-13 21:27:00 +0800645 if args.verbose:
646 print("Trying to fetch the change from GitHub")
Marko Manb58468a2018-03-19 13:01:19 +0100647
dianlujitao324541c2024-01-13 21:27:00 +0800648 cmd[-2] = "omnirom"
649 if not args.quiet:
650 print(cmd)
651 result = subprocess.call(cmd, cwd=project_path)
dianlujitao24df6a42024-01-14 17:21:29 +0800652 # Check if it worked
653 if result == 0 or commit_exists(project_path, item["revision"]):
654 return
655 print("ERROR: git command failed")
dianlujitao324541c2024-01-13 21:27:00 +0800656
dianlujitao27e18992024-02-08 20:23:35 +0800657 # If not using the omnirom gerrit or github failed, fetch from gerrit.
dianlujitao24df6a42024-01-14 17:21:29 +0800658 if args.verbose:
dianlujitao27e18992024-02-08 20:23:35 +0800659 if is_omnirom_gerrit(args.gerrit):
dianlujitao24df6a42024-01-14 17:21:29 +0800660 print(
661 "Fetching from GitHub didn't work, trying to fetch the change from Gerrit"
662 )
663 else:
664 print("Fetching from {0}".format(args.gerrit))
665
666 cmd[-2] = item["fetch"][method]["url"]
667 if not args.quiet:
668 print(cmd)
669 result = subprocess.call(cmd, cwd=project_path)
670 if result != 0 and not commit_exists(project_path, item["revision"]):
671 print("ERROR: git command failed")
672 sys.exit(result)
dianlujitao49475322024-01-13 23:56:59 +0800673
674
675def apply_change(args, item):
dianlujitao99e500b2024-01-14 17:27:02 +0800676 if not args.quiet:
677 print("Applying change number {0}...".format(item["id"]))
dianlujitao49475322024-01-13 23:56:59 +0800678 if is_closed(item["status"]):
679 print("!! Force-picking a closed change !!\n")
680
681 project_path = item["project_path"]
682
683 # Print out some useful info
684 if not args.quiet:
685 print('--> Subject: "{0}"'.format(item["subject"]))
686 print("--> Project path: {0}".format(project_path))
687 print(
688 "--> Change number: {0} (Patch Set {1})".format(
689 item["id"], item["patchset"]
690 )
691 )
692
693 if args.pull:
694 do_git_fetch_pull(args, item)
695 else:
696 # Perform the cherry-pick
dianlujitao324541c2024-01-13 21:27:00 +0800697 if args.quiet:
698 cmd_out = subprocess.DEVNULL
699 else:
700 cmd_out = None
701 result = subprocess.call(
702 ["git", "cherry-pick", "--ff", item["revision"]],
703 cwd=project_path,
704 stdout=cmd_out,
705 stderr=cmd_out,
706 )
707 if result != 0:
dianlujitao769a9c62024-01-14 17:59:24 +0800708 result = subprocess.call(
dianlujitao324541c2024-01-13 21:27:00 +0800709 ["git", "diff-index", "--quiet", "HEAD", "--"],
dianlujitao20f0bdb2024-01-14 14:57:41 +0800710 cwd=project_path,
711 stdout=cmd_out,
712 stderr=cmd_out,
dianlujitao769a9c62024-01-14 17:59:24 +0800713 )
dianlujitao324541c2024-01-13 21:27:00 +0800714 if result == 0:
715 print(
716 "WARNING: git command resulted with an empty commit, aborting cherry-pick"
717 )
718 subprocess.call(
719 ["git", "cherry-pick", "--abort"],
dianlujitao20f0bdb2024-01-14 14:57:41 +0800720 cwd=project_path,
721 stdout=cmd_out,
722 stderr=cmd_out,
dianlujitao769a9c62024-01-14 17:59:24 +0800723 )
dianlujitao324541c2024-01-13 21:27:00 +0800724 elif args.reset:
725 print("ERROR: git command failed, aborting cherry-pick")
726 subprocess.call(
727 ["git", "cherry-pick", "--abort"],
728 cwd=project_path,
729 stdout=cmd_out,
730 stderr=cmd_out,
731 )
732 sys.exit(result)
733 else:
734 print("ERROR: git command failed")
735 sys.exit(result)
736 if not args.quiet:
737 print("")
738
739
740if __name__ == "__main__":
741 main()