#!/usr/bin/env python3 import subprocess import collections import os import sys import re import pprint FNULL = open(os.devnull, "w") pp = pprint.PrettyPrinter(indent=4, stream=sys.stderr) DEBUG = os.environ.get("NM_FIND_BACKPORTS_DEBUG", None) == "1" def dbg_log(s): if DEBUG: print(s, file=sys.stderr) def dbg_pprint(obj): if DEBUG: pp.pprint(obj) def print_err(s): print(s, file=sys.stderr) def die(s): print_err(s) sys.exit(1) def memoize(f): memo = {} def helper(x): if x not in memo: memo[x] = f(x) return memo[x] return helper def re_bin(r): return r.encode("utf8") def _keys_to_dict(itr): d = collections.OrderedDict() for c in itr: d[c] = None return d @memoize def git_ref_exists_full_path(ref): val = git_ref_exists(ref) if val: try: subprocess.check_output(["git", "show-ref", "-q", "--verify", str(ref)]) except subprocess.CalledProcessError: pass else: return val return None def _git_ref_exists_eval(ref): try: out = subprocess.check_output( ["git", "rev-parse", "--verify", str(ref) + "^{commit}"], stderr=FNULL, ) except subprocess.CalledProcessError: return None o = out.decode("ascii").strip() if len(o) == 40: return o raise Exception(f"git-rev-parse for '{ref}' returned unexpected output {out}") _git_ref_exists_cache = {} def git_ref_exists(ref): val = _git_ref_exists_cache.get(ref, False) if val is False: val = _git_ref_exists_eval(ref) _git_ref_exists_cache[ref] = val if val and ref != val: _git_ref_exists_cache[val] = val return val @memoize def git_get_head_name(ref): out = subprocess.check_output( ["git", "rev-parse", "--symbolic-full-name", str(ref)], stderr=FNULL ) return out.decode("utf-8").strip() def git_merge_base(a, b): out = subprocess.check_output(["git", "merge-base", str(a), str(b)], stderr=FNULL) out = out.decode("ascii").strip() assert git_ref_exists(out) return out def git_all_commits_grep(rnge, grep=None): if grep: grep = [("--grep=%s" % g) for g in grep] notes = ["-c", "notes.displayref=refs/notes/bugs"] else: grep = [] notes = [] out = subprocess.check_output( ["git"] + notes + ["log", "--pretty=%H", "--notes", "--reverse"] + grep + [str(rnge)], stderr=FNULL, ) return [x for x in out.decode("ascii").split("\n") if x] def git_logg(commits): commits = list(commits) if not commits: return "" out = subprocess.check_output( [ "git", "log", "--no-show-signature", "--no-walk", "--pretty=format:%Cred%h%Creset - %Cgreen(%ci)%Creset [%C(yellow)%an%Creset] %s%C(yellow)%d%Creset", "--abbrev-commit", "--date=local", ] + [str(c) for c in commits], stderr=FNULL, ) return out.decode("utf-8").strip() @memoize def git_all_commits(rnge): return git_all_commits_grep(rnge) @memoize def git_all_commits_set(rnge): return set(git_all_commits_grep(rnge)) def git_commit_sorted(commits): commits = list(commits) if not commits: return [] out = subprocess.check_output( ["git", "log", "--no-walk", "--pretty=%H", "--reverse"] + [str(x) for x in commits], stderr=FNULL, ) out = out.decode("ascii") return [x for x in out.split("\n") if x] @memoize def git_ref_commit_body(ref): return subprocess.check_output( [ "git", "-c", "notes.displayref=refs/notes/bugs", "log", "-n1", "--pretty=%B%n%N", str(ref), ], stderr=FNULL, ) @memoize def git_ref_commit_body_get_fixes(ref): body = git_ref_commit_body(ref) result = [] for mo in re.finditer(re_bin("\\b[fF]ixes: *([0-9a-z]+)\\b"), body): c = mo.group(1).decode("ascii") h = git_ref_exists(c) if h: result.append(h) if result: # The commit that contains a "Fixes:" line, can also contain an "Ignore-Fixes:" line # to disable it. This only makes sense with refs/notes/bugs notes, to fix up a wrong # annotation. for mo in re.finditer(re_bin("\\bIgnore-[fF]ixes: *([0-9a-z]+)\\b"), body): c = mo.group(1).decode("ascii") h = git_ref_exists(c) try: result.remove(h) except ValueError: pass return result @memoize def git_ref_commit_body_get_cherry_picked_one(ref): ref = git_ref_exists(ref) if not ref: return None body = git_ref_commit_body(ref) result = None for r in [ re_bin("\(cherry picked from commit ([0-9a-z]+)\)"), re_bin("\\bIgnore-Backport: *([0-9a-z]+)\\b"), ]: for mo in re.finditer(r, body): c = mo.group(1).decode("ascii") h = git_ref_exists(c) if h: if not result: result = [h] else: result.append(h) return result @memoize def git_ref_commit_body_get_cherry_picked_recurse(ref): ref = git_ref_exists(ref) if not ref: return None def do_recurse(result, ref): result2 = git_ref_commit_body_get_cherry_picked_one(ref) if result2: extra = [h2 for h2 in result2 if h2 not in result] if extra: result.extend(extra) for h2 in extra: do_recurse(result, h2) result = [] do_recurse(result, ref) return result def git_commits_annotate_fixes(rnge): commits = git_all_commits(rnge) c_dict = _keys_to_dict(commits) for c in git_all_commits_grep(rnge, grep=["[Ff]ixes:"]): ff = git_ref_commit_body_get_fixes(c) if ff: c_dict[c] = ff return c_dict def git_commits_annotate_cherry_picked(rnge): commits = git_all_commits(rnge) c_dict = _keys_to_dict(commits) for c in git_all_commits_grep( ref_head, grep=["cherry picked from commit", "Ignore-Backport:"] ): ff = git_ref_commit_body_get_cherry_picked_recurse(c) if ff: c_dict[c] = ff return c_dict def git_ref_in_history(ref, rnge): return git_ref_exists(ref) in git_all_commits_set(rnge) if __name__ == "__main__": if len(sys.argv) <= 1: ref_head0 = "HEAD" else: ref_head0 = sys.argv[1] ref_head = git_ref_exists(ref_head0) if not ref_head: die('Ref "%s" does not exist' % (ref_head0)) if not git_ref_exists_full_path("refs/notes/bugs"): die( "Notes refs/notes/bugs not found. Read CONTRIBUTING.md file for how to setup the notes" ) ref_upstreams = [] if len(sys.argv) <= 2: head_name = git_get_head_name(ref_head0) match = False if head_name: match = re.match("^refs/(heads|remotes/[^/]*)/nm-1-([0-9]+)$", head_name) if match: i = int(match.group(2)) while True: i += 2 r = "nm-1-" + str(i) if not git_ref_exists(r): r = "refs/remotes/origin/nm-1-" + str(i) if not git_ref_exists(r): break ref_upstreams.append(r) ref_upstreams.append("main") if not ref_upstreams: if len(sys.argv) <= 2: ref_upstreams = ["main"] else: ref_upstreams = list(sys.argv[2:]) for h in ref_upstreams: if not git_ref_exists(h): die('Upstream ref "%s" does not exist' % (h)) print_err("Check %s (%s)" % (ref_head0, ref_head)) print_err("Upstream refs: %s" % (ref_upstreams)) print_err('Check patches of "%s"...' % (ref_head)) own_commits_list = git_all_commits(ref_head) own_commits_cherry_picked = git_commits_annotate_cherry_picked(ref_head) cherry_picks_all = collections.OrderedDict() for c, cherry_picked in own_commits_cherry_picked.items(): if cherry_picked: for c2 in cherry_picked: l = cherry_picks_all.get(c2) if not l: cherry_picks_all[c2] = [c] else: l.append(c) own_commits_cherry_picked_flat = set() for c, p in own_commits_cherry_picked.items(): own_commits_cherry_picked_flat.add(c) if p: own_commits_cherry_picked_flat.update(p) dbg_log(">>> own_commits_cherry_picked") dbg_pprint(own_commits_cherry_picked) dbg_log(">>> cherry_picks_all") dbg_pprint(cherry_picks_all) # find all commits on the upstream branches that fix another commit. fixing_commits = {} for ref_upstream in ref_upstreams: ref_str = ref_head + ".." + ref_upstream print_err(f'Check upstream patches "{ref_str}"...') for c, fixes in git_commits_annotate_fixes(ref_str).items(): if not fixes: dbg_log(f">>> test {c} : SKIP (does not fix anything)") continue if c in cherry_picks_all: # commit 'c' is already backported. Skip it. dbg_log(f">>> test {c} => {fixes} : SKIP (already backported)") continue dbg_log(f">>> test {c} => {fixes} : process") for f in fixes: if f not in own_commits_cherry_picked_flat: # commit "c" fixes commit "f", but this is not one of our own commits # and not interesting. dbg_log(f">>> fixes {f} not in own_commits_cherry_picked") continue dbg_log(f">>> take {c} (fixes {fixes})") fixing_commits[c] = fixes break extra = collections.OrderedDict( [(c, git_ref_commit_body_get_cherry_picked_recurse(c)) for c in fixing_commits] ) extra2 = [] for c in extra: is_back = False for e_v in extra.values(): if c in e_v: is_back = True break if not is_back: extra2.append(c) commits_good = extra2 commits_good = git_commit_sorted(commits_good) print_err(git_logg(commits_good)) not_in = [ c for c in commits_good if not git_ref_in_history(c, f"{ref_head}..{ref_upstreams[0]}") ] if not_in: print_err("") print_err( f'WARNING: The following commits are not from the first reference "{ref_upstreams[0]}".' ) print_err( f' You may want to first backports those patches to "{ref_upstreams[0]}".' ) for l in git_logg(git_commit_sorted(not_in)).splitlines(): print_err(f" - {l}") print_err("") for c in reversed(commits_good): print("%s" % (c))