1#!/usr/bin/env python3 2# 3# Copyright (c) 2025 Klara, Inc. 4# Copyright (c) 2026 Mark Johnston <markj@FreeBSD.org> 5# 6# SPDX-License-Identifier: BSD-2-Clause 7# 8# git-mfc - Helper command for MFCing commits to stable or release branches. 9# 10 11import argparse 12import datetime 13import os 14import re 15import subprocess 16import sys 17 18 19def err(code, msg): 20 print(os.path.basename(sys.argv[0]) + ': ' + msg, file=sys.stderr) 21 sys.exit(code) 22 23 24try: 25 import git 26except ImportError: 27 err(1, "gitpython module missing; install it with pip or the devel/py-gitpython package") 28 29 30def commit_summary(commit): 31 return f'{commit.hexsha} ("{commit.summary}")' 32 33 34def commit_match(commit, author, committer): 35 return (author is None or author in commit.author.email) and \ 36 (committer is None or committer in commit.committer.email) 37 38 39def origin_branch(): 40 """ 41 Determine the default origin branch by parsing sys/conf/newvers.sh. 42 """ 43 newvers = 'sys/conf/newvers.sh' 44 if not os.path.exists(newvers): 45 err(1, f"{newvers} not found; use --origin to specify the upstream branch") 46 47 revision = None 48 branch = None 49 with open(newvers) as f: 50 for line in f: 51 m = re.match(r'^REVISION="([^"]+)"', line) 52 if m: 53 revision = m.group(1) 54 m = re.match(r'^BRANCH="([^"]+)"', line) 55 if m: 56 branch = m.group(1) 57 58 if branch is None: 59 err(1, f"could not determine BRANCH from {newvers}") 60 if branch == "CURRENT": 61 err(1, "this is a CURRENT tree, we do not MFC to CURRENT") 62 if branch == "STABLE" or branch == "PRERELEASE": 63 return "main" 64 if re.match(r'^BETA\d+$', branch) or re.match(r'^ALPHA\d+$', branch): 65 if revision is None: 66 err(1, f"could not determine REVISION from {newvers}") 67 return "stable/" + revision.split('.')[0] 68 if re.match(r'^RELEASE(-p\d+)?$', branch): 69 if revision is None: 70 err(1, f"could not determine REVISION from {newvers}") 71 major = revision.split('.')[0] 72 return f"stable/{major}" 73 74 err(1, f"unexpected BRANCH value '{branch}' in {newvers}") 75 76 77def fixes(repo, commit, warn=False): 78 """ 79 Look at the commit's log message and return a list of commits that it fixes. 80 81 Hard-code a bunch of special cases in the src repo, where the fixes tag is 82 valid but malformed somehow. 83 """ 84 special = { 85 "956100d60fa86daebc1bd46ea62f207f6b54d93d": ["1bdf1b8186359d4ea26e26de03e03b7c1543b707"], 86 "013c58ced6aef26bad7ca5c6eb829b9d586f6edb": ["2fefe2c88b31dc7f173c9424f3eb13d49e98d55a"], 87 "63c9b018069b58bacb2875c83c676cc8c4f40d69": ["2fefe2c88b31dc7f173c9424f3eb13d49e98d55a"], 88 "27c41b28b1d7749c4dde266a12cd9ff93f168767": ["2ad0f7e91582dde5475ceb1a1942930549e5c628"], 89 "56c45700f2ae15755358f2da8266247613c564df": ["b1c38df05d79c81ee1e9fd0942774820a4ffcb63"], 90 "1580f8d9c1740e0c54554e6c185573d34f2dcf76": [], 91 "a18be39aafc1b55fd67603ee6a6d820d0fcd877f": ["518cdd344ec51584478f39b9a9cac77fc0766ce1"], 92 "337f5e7a44c4d5ac29e0b0c0fe957bb3b9bae222": ["518cdd344ec51584478f39b9a9cac77fc0766ce1"], 93 "be8e84c2e9240110ce99bb8d14259073340e4ef6": ["de78657a3aef3c2efbcbdf9ea73a95283c91fab0"], 94 "9b56dfd27c64fcaf2dfbaa1eb3e2bd2b163fa56c": [], 95 "5fea0aec5c8e7e8e68a3f0de6ae717588344a6b4": ["deaae83da0571af7c97acf8f1444a00350e21e3a"], 96 "93bc3d83a11a1dbebd264616d63af3dd32cc1c8c": ["06410c1b51637e5e1f392d553b5008948af58014"], 97 "1a9ec72022ddce0cacb9aedda3d69aa95cd45ed0": ["f9a71840d8ee0cfff4ec983a589be53648e7017d"], 98 "b033e3684fd14c798405420449eb55e1d4f9df1e": ["f42421307b115345ea1a6c8fdf61fbb581e4400f"], 99 "2a5b083c1f7e0a4518bd944241af1d1102997c4d": ["58580dc0c694b107ba7ba1b14dc8ee8f306e53f6"], 100 "cbb7441245cf2b22efed17dec3c88587c980c691": ["05322eec86ae42a8f873ba10d8da61bd61b5b48f"], 101 "7e68af7ce2c1b892954df415774fe59fd2f1b62f": [], 102 "2612f1b8649bb4f069a6a064ed714daa5f10d037": [], 103 "43d632779b7d1df6c96a5cc0f506c13e09a845f3": ["2358492b0347b11178fb594069330820f11ec81f"], 104 "a693d17b9985a03bd9b5108e890d669005ab41eb": ["8a42005d1e491932666eb9f0be3e70ea1a28a3f7"], 105 "a04ca1c229195c7089b878a94fbe76505ea693b5": ["93b7818226cf5270646725805b4a8c17a1ad3761"], 106 "852088f6af6c5cd44542dde72aa5c3f4c4f9353c": ["b5fb9ae6872c499f1a02bec41f48b163a73a2aaa"], 107 "9c5d7e4a0c02bc45b61f565586da2abcc65d70fa": ["bec000c9c1ef409989685bb03ff0532907befb4a"], 108 "894cb08f0d3656fdb81f4d89085bedc4235f3cb6": ["5678d1d98a348f315453555377ccb28821a2ffcd"], 109 "cbddb2f02c7687d1039abcffd931e94e481c11a5": ["0118b0c8e58a438a931a5ce1bf8d7ae6208cc61b"], 110 "e9da71cd35d46ca13da4396d99e0af1703290e68": [], 111 "43cd6bbba053f02999d846ef297655ebec2d218b": ["38cbdae33b7c3f772845c06f52b86c0ddeab6a17"], 112 "94efe9f91be7f3aa501983716db5a4378282a734": ["7520b88860d7a79432e12ffcc47056844518bb62"], 113 "b332adfa96218148dfbb936a9c09d00484c868e3": ["7520b88860d7a79432e12ffcc47056844518bb62"], 114 "b911f504005df67f8c25f9b2f817c16588cd309c": ["801fb66a7e34c340f23d82f2b375feee4bd87df4"], 115 "3d37e7e5f540f513ab1d8fa61d9208c43b889401": ["fb9baa9b2045a193a3caf0a46b5cac5ef7a84b61"], 116 "0912408a281f203c43d0b3f73c38117336588342": ["9e33a616939fcff87f7539e3c41323deca5c74ce"], 117 "c036339ddf0cf80164f41ea31f1d8d27f4a068a9": ["a305b20ead13bb29880e15ff20c3bb83b5397a82"], 118 "9b56dfd27c64fcaf5dfbaa1eb3e2bd2b163fa56c": [], 119 "ab92cab02633580f763a38a329a5b25050bb4fbf": [], 120 "28fdb212adc0431fff683749a1307038e25ff58e": [], 121 "811912c46b5886f1aa3bb7a51a6ec1270bc947a8": [], 122 "813d981e1e78daffde4b2a05df35d054fcb4343f": [], 123 "ecf2a89a997ad4a14339b6a2f544e44b422620a0": [], 124 "f6517a7e69c10c6057d6c990a9f3ea22a2b62398": [], 125 "a5770eb54f7d13717098b5c34cc2dd51d2772021": ["86c06ff8864bc0e2233980c741b689714000850d"], 126 "b84d0aaa4e64fb95b105d0d38f6295fec7a82110": [], 127 "b586c66baf4824d175d051b3f5b06588c9aa2bc8": [], 128 } 129 if commit.hexsha in special: 130 return [repo.commit(c) for c in special[commit.hexsha]] 131 132 if commit.committed_date < datetime.datetime(2021, 1, 1).timestamp(): 133 return [] 134 135 regexp = re.compile(r'^([0-9a-f][0-9a-f]+)[, \(]?') 136 137 fixed = [] 138 for line in commit.message.splitlines(): 139 for tag, offset in (("Fixes", 1), ("MFC-with", 1), ("MFC with", 2)): 140 if line.startswith(tag + ':'): 141 line = ' '.join(line.split(':')[1:]).strip() 142 m = regexp.match(line) 143 if not m: 144 continue 145 try: 146 fixed.append(repo.commit(m.group(1))) 147 except (git.exc.BadName, ValueError): 148 if warn: 149 print(f"warning: {commit.hexsha[:12]}: {tag} tag " 150 f"references unknown commit {m.group(1)}", 151 file=sys.stderr) 152 return fixed 153 154 155def reverts(commit): 156 """ 157 Look at the commit's log message and return the hash of the commit it 158 reverts, or None. 159 """ 160 regexp = re.compile(r'^This reverts commit ([0-9a-f]{40})') 161 for line in commit.message.splitlines(): 162 m = regexp.match(line) 163 if m: 164 return m.group(1) 165 return None 166 167 168def mfcclosure(repo, upstream, commits, _reverted=None): 169 """ 170 Given a set of commits to MFC from the upstream branch, return a tuple 171 (tomfc, reverted) where tomfc is a list of all commits in the original 172 set plus those which fix up commits in the original set (excluding 173 reverted fixups), and reverted is the set of commit hashes that have 174 been reverted on the upstream branch. 175 176 Reverts are collected during the upstream walk, so no separate scan is 177 needed. Recursive calls share the same reverted set via _reverted. 178 """ 179 tomfc = [] 180 for rev in commits: 181 try: 182 if ".." in rev: 183 revlist = list(repo.iter_commits(rev)) 184 revlist.reverse() 185 tomfc.extend(revlist) 186 else: 187 tomfc.append(repo.commit(rev)) 188 except git.exc.BadName: 189 err(1, f"Revision '{rev}' is invalid") 190 191 toplevel = _reverted is None 192 if toplevel: 193 _reverted = set() 194 195 tovisit = tomfc.copy() 196 197 for commit in repo.iter_commits(upstream): 198 r = reverts(commit) 199 if r: 200 _reverted.add(r) 201 202 if commit in tovisit: 203 tovisit.remove(commit) 204 if len(tovisit) == 0: 205 break 206 continue 207 208 for fix in fixes(repo, commit): 209 if fix in tomfc and commit not in tomfc: 210 if commit.hexsha in _reverted: 211 print(f"warning: skipping {commit_summary(commit)}: " 212 f"fixup was reverted upstream", file=sys.stderr) 213 continue 214 i = tomfc.index(fix) + 1 215 sub, _ = mfcclosure(repo, upstream, [commit.hexsha], 216 _reverted=_reverted) 217 tomfc[i:i] = sub 218 else: 219 err(1, "the following commits are not in the upstream branch: " + 220 ', '.join([c.hexsha for c in tovisit])) 221 return (tomfc, _reverted) 222 223 224def mfc_origins(repo, commit, warn=False): 225 """ 226 Given a commit, return a list of upstream commits that it was 227 cherry-picked from. This is usually a single commit, but may be 228 multiple if several upstream commits were squashed into one MFC. 229 230 Handle a bunch of special commits where the existing cherry-pick 231 metadata is incorrect. 232 """ 233 special = { 234 # stable/15 235 "e224f2c1b98ba065942c83d322cdb8e3987d0c81": ["068fea0aa15bceb7b6b01687542b58ee81d1d887"], 236 "48578fbefa666abfcfe3a47269343febefae94f9": ["728ec0c094ce473ae17ebd1adb05f0959bf3a68e"], 237 "2bf7d850c53e55ffced055235de432055f0cb9e2": ["7e79bc8ce70693a892c443c42af5ec16a95ba466"], 238 "e554d44c3a7ffa8664e89f35aec80b917f69e6b3": ["561dc357c2f5892af3aa481a1020860b7ff473e0"], 239 # stable/14 240 "aab45924bdb10338654e25ab9ecec106b7eb368b": ["c57c02ebf7bcc9b02a0dc11711e8d8a6960ad34b"], 241 "11dde2c8b7156a7d2072589c22c2f3c0de6880d8": ["62af5b9dc6205289a0ace964d060fba64e71ef28"], 242 "19f7b2bbc4e4fec33b7e8d546dd05a79533ca8e4": ["d0cbb1930e82a53b07b1091402ff14cdfe7a4898"], 243 "d034ff89b84f8470eb5bbcbb65c64c762e07f0bd": ["ac2156c139f8f685b84a71a7f0f164d6cccc7656"], 244 "fce2a3509a65a374820dc889929f8e8f5dbd1707": ["a9722e5ae8519a9a28e950dbd8d489178e9bc27e"], 245 "5946b0c6cbc77e6c5f62f5f7e635c6036e14f4d0": [], 246 "06bb8e1dab004ccb283f7a20fe84aa1326baf6b7": [], 247 "e5fadc41b48045d8978993d6c4ac72c64542b470": ["e20971500194d2f7299e9d01ca3b20e9bc6b4009"], 248 "3ba946aebf747e12da4fb22ee0e45ee0e3a233ee": ["c4380d5b9383e2a062840573b36ab643b65b2610"], 249 "3a84c630f2361763f8cd1390847677dfe8fb0b01": ["841be1d0495224a47d3dbfd8647c7fcbf58b205c"], 250 "827fd20d07a46879c66bb6899ad1d780845b1b6c": ["83fa80a5500ed7baccedfa90ad55ac879af710c9"], 251 "be72364ad64ac3c350cbd47130794db254fc77b0": ["b1ccab17213b38aa269e85c6b371d69eb25b6ca9"], 252 "4fc0ae5d6b459156317acc070acbbdc53cda1d6e": ["7359df79d3affeccf17f12f16c23ba3d4c5e0346"], 253 # stable/13 254 "fb4bc4c325eca15247a2e21ded0a70f92ec15488": ["c57c02ebf7bcc9b02a0dc11711e8d8a6960ad34b"], 255 "213406054e46b50d5df6d28b01a0a3410132b322": ["62af5b9dc6205289a0ace964d060fba64e71ef28"], 256 "eb6edafe8f484a69ff074018e5b93e481787923e": ["d0cbb1930e82a53b07b1091402ff14cdfe7a4898"], 257 "ced9fa71eaf95a27d5672c3419d7d9ca6c189168": ["ac2156c139f8f685b84a71a7f0f164d6cccc7656"], 258 "65bab39e140f97cace92a2923e50c6b654b02e22": ["346483b1f10454c5617a25d5e136829f60fb1184"], 259 "972637dc06a04432dc58e240b8ef3e9f538b98bb": ["9209ea69bc03e7e9f678b2294da7a0317b5c9c5b"], 260 "93a95ebbf7c8eb85aeb53a9fde329348992333e7": ["6ccff5c0452c4bc2a4dd497e39a801ab8db8a021"], 261 "9d31ae318711825d3a6ffa544d197708905435cf": ["aef815e7873b006bd040ac1690425709635e32e7"], 262 "a75324d674f5df10f1407080a49cfe933dbb06ec": ["64ae2f785e0672286901c15045a24cbc533538d3"], 263 "0729ba2f49c956789701aecb70f4f555181fd3a7": ["91a8bed5a49eb2d1e4e096a4c68c108cebec8818"], 264 "bec0d2c9c8413707b0fff8e65fb96aa53f149be3": ["8d5c7813061dfa0b187500dfe3aeea7a28181c13"], 265 "66b1d696db1284ac9f9bb4037ffc288091f5ba84": ["f9a039060e834e89e0b2de1e8a442d78bd5c083d"], 266 } 267 if commit.hexsha in special: 268 return [repo.commit(s) for s in special[commit.hexsha]] 269 270 origins = [] 271 regexp = re.compile(r'^\(cherry picked from commit ([0-9a-f]{40})') 272 for line in commit.message.splitlines(): 273 m = regexp.match(line) 274 if m: 275 try: 276 origins.append(repo.commit(m.group(1))) 277 except (git.exc.BadName, ValueError): 278 if warn: 279 print(f"warning: {commit.hexsha[:12]}: cherry-picked from " 280 f"unknown commit {m.group(1)}", file=sys.stderr) 281 return origins 282 283 284def load_ignore_list(repo): 285 """ 286 Load commit hashes from $HOME/.git-mfc-ignore. Each line has the format: 287 <commit-hash> [ignored text] 288 where the hash may be abbreviated. Blank lines and lines starting with 289 '#' are skipped. Each hash is resolved to a full commit to validate it. 290 """ 291 path = os.path.join(os.environ['HOME'], '.git-mfc-ignore') 292 commits = set() 293 try: 294 with open(path) as f: 295 for lineno, line in enumerate(f, 1): 296 line = line.strip() 297 if not line or line.startswith('#'): 298 continue 299 abbrev = line.split()[0] 300 try: 301 commits.add(repo.commit(abbrev).hexsha) 302 except (git.exc.BadName, ValueError): 303 err(1, f"{path}:{lineno}: unknown commit {abbrev}") 304 except FileNotFoundError: 305 pass 306 return commits 307 308 309def dangling(repo, upstream, author=None, committer=None): 310 """ 311 Find commits in the current branch which have been cherry-picked from 312 upstream but are missing fixup commits. 313 """ 314 base = repo.merge_base('HEAD', upstream, all=True)[-1] 315 result = [] 316 picked = [] 317 mine = [] 318 for commit in repo.iter_commits(base.hexsha + '..HEAD'): 319 refs = mfc_origins(repo, commit, warn=True) 320 for ref in refs: 321 picked.append(ref.hexsha) 322 if commit_match(ref, author, committer): 323 mine.append(ref.hexsha) 324 # Create a mapping of all upstream commits to their fixups, 325 # tracking reverts as we go. 326 fixups = {} 327 reverted = set() 328 for commit in repo.iter_commits(base.hexsha + '..' + upstream): 329 r = reverts(commit) 330 if r: 331 reverted.add(r) 332 for fix in fixes(repo, commit, warn=True): 333 if fix.hexsha not in fixups: 334 fixups[fix.hexsha] = [] 335 fixups[fix.hexsha].append(commit.hexsha) 336 # For each of the user's cherry-picks, find fixups that haven't been 337 # cherry-picked (by anyone) or reverted. 338 for commit in mine: 339 for f in fixups.get(commit, []): 340 if f not in picked and f not in reverted: 341 result.append((repo.commit(f), repo.commit(commit))) 342 return result 343 344 345def mfc_after(commit): 346 """ 347 Parse "MFC after:" tags from a commit message and return the time delta, 348 or None if there is no parseable MFC-after tag. 349 """ 350 units = { 351 'day': 1, 'days': 1, 352 'week': 7, 'weeks': 7, 353 'month': 30, 'months': 30, 354 } 355 regexp = re.compile(r'^MFC[ -]after:\s*(\d+)\s+(\w+)', re.IGNORECASE) 356 for line in commit.message.splitlines(): 357 m = regexp.match(line) 358 if m: 359 count = int(m.group(1)) 360 unit = m.group(2).lower() 361 if unit in units: 362 return datetime.timedelta(days=count * units[unit]) 363 return None 364 365 366def pending(repo, upstream, author=None, committer=None, baking=False): 367 """ 368 Find commits in the upstream branch that have an "MFC after:" tag, whose 369 waiting period has elapsed, and which have not been cherry-picked to the 370 current branch. 371 372 If baking is True, also include commits whose waiting period 373 has not yet elapsed. 374 """ 375 base = repo.merge_base('HEAD', upstream, all=True)[-1] 376 377 # Collect the set of upstream commits already cherry-picked. 378 picked = set() 379 for commit in repo.iter_commits(base.hexsha + '..HEAD'): 380 for ref in mfc_origins(repo, commit): 381 picked.add(ref.hexsha) 382 383 results = {} 384 385 mfs = re.match(r'.*/stable/\d+$', upstream) is not None 386 387 now = datetime.datetime.now(datetime.timezone.utc) 388 revlist = list(repo.iter_commits(base.hexsha + '..' + upstream)) 389 revlist.reverse() 390 for commit in revlist: 391 r = reverts(commit) 392 if r and r in results: 393 # Don't report reverted commits. 394 del results[r] 395 continue 396 397 if commit.hexsha in picked: 398 # Don't report commits that have already been cherry-picked. 399 continue 400 401 if not commit_match(commit, author, committer): 402 continue 403 404 # If this is a merge-from-stable then there's no timeout, any 405 # unmerged commit is eligible. 406 if mfs: 407 ready_date = now 408 ready = True 409 else: 410 delta = mfc_after(commit) 411 if delta is None: 412 # Not yet eligible for MFC. 413 continue 414 415 committed = datetime.datetime.fromtimestamp( 416 commit.committed_date, tz=datetime.timezone.utc) 417 ready_date = committed + delta 418 ready = now >= ready_date 419 420 if not baking and not ready: 421 continue 422 423 results[commit.hexsha] = (commit, ready_date, ready) 424 425 return list(results.values()) 426 427 428def cherry_pick(commits, edit=False): 429 """Cherry-pick a list of commits with -x. Abort on failure.""" 430 cmd = ['git', 'cherry-pick', '-x', '-m1'] 431 if edit: 432 cmd.append('-e') 433 cmd.extend([c.hexsha for c in commits]) 434 result = subprocess.run(cmd, capture_output=False) 435 if result.returncode != 0: 436 print("\nCherry-pick failed.", file=sys.stderr) 437 print("Resolve the conflict and run 'git mfc --continue',", file=sys.stderr) 438 print("or run 'git mfc --abort' to give up.", file=sys.stderr) 439 sys.exit(1) 440 441 442def main(): 443 parser = argparse.ArgumentParser( 444 prog='git mfc', 445 description='Cherry-pick commits from main to a stable branch, ' 446 'automatically including fixup commits.', 447 ) 448 parser.add_argument( 449 '--abort', action='store_true', 450 help='Abort a cherry-pick in progress', 451 ) 452 parser.add_argument( 453 '--all', action='store_true', 454 help='With --pending/--dangling, show commits from all authors', 455 ) 456 parser.add_argument( 457 '-a', '--author', type=str, default=None, 458 help='Filter --pending/--dangling results by author (default: ignore author)', 459 ) 460 parser.add_argument( 461 '--baking', action='store_true', 462 help='With --pending, also show commits whose MFC-after period has not yet elapsed', 463 ) 464 parser.add_argument( 465 '-c', '--committer', type=str, default=None, 466 help='Filter --pending/--dangling results by committer (default: current user)', 467 ) 468 parser.add_argument( 469 '--continue', action='store_true', dest='continue_', 470 help='Continue cherry-picking after resolving a conflict', 471 ) 472 parser.add_argument( 473 '--dangling', action='store_true', 474 help='Find cherry-picked commits in the current branch that are ' 475 'missing fixup commits from the origin branch', 476 ) 477 parser.add_argument( 478 '-e', '--edit', action='store_true', 479 help='Edit the commit message before committing', 480 ) 481 parser.add_argument( 482 '-f', '--force', action='store_true', 483 help='Cherry-pick commits even if they appear to be already present', 484 ) 485 parser.add_argument( 486 '--ignore-reverts', action='store_true', 487 help='Cherry-pick commits even if they were reverted upstream', 488 ) 489 parser.add_argument( 490 '-n', action='store_true', 491 help='List the commits that would be cherry-picked, but do not act', 492 ) 493 parser.add_argument( 494 '--pending', action='store_true', 495 help='Show upstream commits with MFC-after tags that are ready to ' 496 'be cherry-picked', 497 ) 498 parser.add_argument( 499 '--origin', type=str, default=None, 500 help='Upstream branch (default: derived from sys/conf/newvers.sh)', 501 ) 502 parser.add_argument( 503 '-r', '--remote', type=str, default=None, 504 help='Upstream remote (default: tracking remote of current branch)', 505 ) 506 parser.add_argument( 507 'commits', nargs='*', metavar='revision', 508 help='Commits to MFC (hashes, ranges with "..", etc.)', 509 ) 510 args = parser.parse_args() 511 512 mode = [] 513 if args.dangling: 514 mode.append('--dangling') 515 if args.pending: 516 mode.append('--pending') 517 if args.abort: 518 mode.append('--abort') 519 if args.continue_: 520 mode.append('--continue') 521 if len(mode) > 1: 522 err(1, 'usage error: only one of --dangling, --pending, --abort, ' 523 'or --continue may be specified') 524 if len(mode) == 1 and len(args.commits) > 0: 525 err(1, f'usage error: revisions cannot be specified with {mode[0]}') 526 if len(mode) == 0 and len(args.commits) == 0: 527 err(1, 'usage error: at least one revision is required') 528 529 origin = args.origin if args.origin else origin_branch() 530 531 repo = git.Repo() 532 if args.remote: 533 remote = args.remote 534 else: 535 tracking = repo.active_branch.tracking_branch() 536 if tracking: 537 remote = tracking.remote_name 538 else: 539 err(1, "could not find upstream branch, use --remote") 540 if remote not in repo.remotes: 541 err(1, f"remote repository '{remote}' not found") 542 543 upstream = remote + '/' + origin 544 545 if not args.n and not args.abort and not args.continue_: 546 repo.remotes[remote].fetch(origin) 547 548 # Do we want to filter commits by author or committer? By default, use 549 # the current git user's email to match the committer field. 550 author = committer = None 551 if not args.all: 552 user_email = repo.config_reader().get_value('user', 'email', default=None) 553 if args.author: 554 author = args.author 555 556 if args.committer: 557 committer = args.committer 558 else: 559 committer = user_email 560 if committer is None: 561 err(1, "could not determine user email; use -c or --all") 562 563 if args.dangling: 564 ignored = load_ignore_list(repo) 565 missing = dangling(repo, upstream, author=author, committer=committer) 566 for fixup, commit in missing: 567 if fixup.hexsha in ignored: 568 continue 569 print(f'{commit_summary(fixup)} fixes {commit_summary(commit)}') 570 elif args.pending: 571 ignored = load_ignore_list(repo) 572 results = pending(repo, upstream, author=author, committer=committer, 573 baking=args.baking) 574 for commit, ready_date, ready in results: 575 if commit.hexsha in ignored: 576 continue 577 date_str = ready_date.strftime('%Y-%m-%d') 578 if ready: 579 status = f"ready since {date_str}" 580 else: 581 status = f"ready on {date_str}" 582 print(f'{commit_summary(commit)} ({status})') 583 elif args.abort: 584 result = subprocess.run(['git', 'cherry-pick', '--abort'], 585 capture_output=False) 586 if result.returncode != 0: 587 err(1, "failed to abort cherry-pick") 588 elif args.continue_: 589 result = subprocess.run(['git', 'cherry-pick', '--continue'], 590 capture_output=False) 591 if result.returncode != 0: 592 err(1, "failed to continue cherry-pick") 593 else: 594 tomfc, reverted = mfcclosure(repo, upstream, args.commits) 595 596 # Check if any explicitly requested commits were reverted. 597 if not args.ignore_reverts: 598 for commit in tomfc: 599 if commit.hexsha in reverted: 600 err(1, f"{commit_summary(commit)} was reverted " 601 f"upstream; use --ignore-reverts to override") 602 603 # Find commits already cherry-picked to the current branch so we 604 # can skip them. This lets the user re-run the same command after 605 # resolving a conflict without re-applying earlier commits. 606 if not args.force: 607 already_picked = set() 608 base = repo.merge_base('HEAD', upstream, all=True)[-1] 609 for c in repo.iter_commits(base.hexsha + '..HEAD'): 610 for ref in mfc_origins(repo, c): 611 already_picked.add(ref.hexsha) 612 else: 613 already_picked = set() 614 615 if args.n: 616 for commit in tomfc: 617 if commit.hexsha in already_picked: 618 continue 619 print(commit.hexsha, commit.summary) 620 return 621 622 topick = [] 623 for commit in tomfc: 624 if commit.hexsha in already_picked: 625 print(f"Skipping {commit.hexsha[:12]} {commit.summary} " 626 f"(already cherry-picked)") 627 continue 628 print(f"Cherry-picking {commit.hexsha[:12]} {commit.summary}") 629 topick.append(commit) 630 if topick: 631 cherry_pick(topick, edit=args.edit) 632 633 634if __name__ == '__main__': 635 main() 636