xref: /freebsd/tools/tools/git/git-mfc (revision b7b73d0fc1f0969e7e53d1a967d4ecc8aef2d35d)
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