From 7f61f4180b645527d4c82404b18712946e77e551 Mon Sep 17 00:00:00 2001 From: Eric Engestrom Date: Wed, 3 Jun 2020 00:22:33 +0200 Subject: [PATCH] introduce `commit_in_branch.py` script to help devs figure this out It's been pointed out to me that determining whether a commit is present in a stable branch is non-trivial (cherry-picks are a pain to search for) and the commands are hard to remember, making it too much to ask. This script aims to solve that problem; at its simplest form, it only takes a commit and a branch and tells the user whether that commit predates the branch, was cherry-picked to it, or is not present in any form in the branch. $ bin/commit_in_branch.py e58a10af640ba58b6001f5c5ad750b782547da76 fdo/20.1 Commit e58a10af640ba58b6001f5c5ad750b782547da76 is in branch 20.1 $ echo $? 0 $ bin/commit_in_branch.py dd2bd68fa69124c86cd008b256d06f44fab8e6cd fdo/20.1 Commit dd2bd68fa69124c86cd008b256d06f44fab8e6cd was backported to branch 20.1 as commit d043d24654c851f0be57dbbf48274b5373dea42b $ echo $? 0 $ bin/commit_in_branch.py master fdo/20.1 Commit 2fbcfe170bf50fcbcd2fc70a564a4d69096d968c is NOT in branch 20.1 $ echo $? 1 Signed-off-by: Eric Engestrom Part-of: --- bin/commit_in_branch.py | 141 +++++++++++++++++++++++++++++++++++ bin/commit_in_branch_test.py | 116 ++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100755 bin/commit_in_branch.py create mode 100644 bin/commit_in_branch_test.py diff --git a/bin/commit_in_branch.py b/bin/commit_in_branch.py new file mode 100755 index 00000000000..e4e2edb50ab --- /dev/null +++ b/bin/commit_in_branch.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import sys + + +def print_(args: argparse.Namespace, success: bool, message: str) -> None: + """ + Print function with extra coloring when supported and/or requested, + and with a "quiet" switch + """ + + COLOR_SUCCESS = '\033[32m' + COLOR_FAILURE = '\033[31m' + COLOR_RESET = '\033[0m' + + if args.quiet: + return + + if args.color == 'auto': + use_colors = sys.stdout.isatty() + else: + use_colors = args.color == 'always' + + s = '' + if use_colors: + if success: + s += COLOR_SUCCESS + else: + s += COLOR_FAILURE + + s += message + + if use_colors: + s += COLOR_RESET + + print(s) + + +def is_commit_valid(commit: str) -> bool: + ret = subprocess.call(['git', 'cat-file', '-e', commit], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return ret == 0 + + +def branch_has_commit(upstream: str, branch: str, commit: str) -> bool: + """ + Returns True if the commit is actually present in the branch + """ + ret = subprocess.call(['git', 'merge-base', '--is-ancestor', + commit, upstream + '/' + branch], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return ret == 0 + + +def branch_has_backport_of_commit(upstream: str, branch: str, commit: str) -> str: + """ + Returns the commit hash if the commit has been backported to the branch, + or an empty string if is hasn't + """ + out = subprocess.check_output(['git', 'log', '--format=%H', + branch + '-branchpoint..' + upstream + '/' + branch, + '--grep', 'cherry picked from commit ' + commit], + stderr=subprocess.DEVNULL) + return out.decode().strip() + + +def canonicalize_commit(commit: str) -> str: + """ + Takes a commit-ish and returns a commit sha1 if the commit exists + """ + + # Make sure input is valid first + if not is_commit_valid(commit): + raise argparse.ArgumentTypeError('invalid commit identifier: ' + commit) + + out = subprocess.check_output(['git', 'rev-parse', commit], + stderr=subprocess.DEVNULL) + return out.decode().strip() + + +def validate_branch(branch: str) -> str: + if '/' not in branch: + raise argparse.ArgumentTypeError('must be in the form `remote/branch`') + + out = subprocess.check_output(['git', 'remote', '--verbose'], + stderr=subprocess.DEVNULL) + remotes = out.decode().splitlines() + (upstream, _) = branch.split('/') + valid_remote = False + for line in remotes: + if line.startswith(upstream + '\t'): + valid_remote = True + + if not valid_remote: + raise argparse.ArgumentTypeError('Invalid remote: ' + upstream) + + if not is_commit_valid(branch): + raise argparse.ArgumentTypeError('Invalid branch: ' + branch) + + return branch + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=""" + Returns 0 if the commit is present in the branch, + 1 if it's not, + and 2 if it couldn't be determined (eg. invalid commit) + """) + parser.add_argument('commit', + type=canonicalize_commit, + help='commit sha1') + parser.add_argument('branch', + type=validate_branch, + help='branch to check, in the form `remote/branch`') + parser.add_argument('--quiet', + action='store_true', + help='suppress all output; exit code can still be used') + parser.add_argument('--color', + choices=['auto', 'always', 'never'], + default='auto', + help='colorize output (default: true if stdout is a terminal)') + args = parser.parse_args() + + (upstream, branch) = args.branch.split('/') + + if branch_has_commit(upstream, branch, args.commit): + print_(args, True, 'Commit ' + args.commit + ' is in branch ' + branch) + exit(0) + + backport = branch_has_backport_of_commit(upstream, branch, args.commit) + if backport: + print_(args, True, + 'Commit ' + args.commit + ' was backported to branch ' + branch + ' as commit ' + backport) + exit(0) + + print_(args, False, 'Commit ' + args.commit + ' is NOT in branch ' + branch) + exit(1) diff --git a/bin/commit_in_branch_test.py b/bin/commit_in_branch_test.py new file mode 100644 index 00000000000..95724f2e56c --- /dev/null +++ b/bin/commit_in_branch_test.py @@ -0,0 +1,116 @@ +import argparse +import pytest # type: ignore +import subprocess + +from .commit_in_branch import ( + is_commit_valid, + branch_has_commit, + branch_has_backport_of_commit, + canonicalize_commit, + validate_branch, +) + + +def get_upstream() -> str: + # Let's assume master is bound to the upstream remote and not a fork + out = subprocess.check_output(['git', 'for-each-ref', + '--format=%(upstream)', + 'refs/heads/master'], + stderr=subprocess.DEVNULL) + return out.decode().strip().split('/')[2] + + +@pytest.mark.parametrize( + 'commit, expected', + [ + ('20.1-branchpoint', True), + ('master', True), + ('e58a10af640ba58b6001f5c5ad750b782547da76', True), + ('d043d24654c851f0be57dbbf48274b5373dea42b', True), + ('dd2bd68fa69124c86cd008b256d06f44fab8e6cd', True), + ('0000000000000000000000000000000000000000', False), + ('not-even-a-valid-commit-format', False), + ]) +def test_canonicalize_commit(commit: str, expected: bool) -> None: + if expected: + assert canonicalize_commit(commit) + else: + try: + assert canonicalize_commit(commit) + except argparse.ArgumentTypeError: + return + assert False + + +@pytest.mark.parametrize( + 'commit, expected', + [ + (get_upstream() + '/20.1', True), + (get_upstream() + '/master', True), + ('20.1', False), + ('master', False), + ('e58a10af640ba58b6001f5c5ad750b782547da76', False), + ('d043d24654c851f0be57dbbf48274b5373dea42b', False), + ('dd2bd68fa69124c86cd008b256d06f44fab8e6cd', False), + ('0000000000000000000000000000000000000000', False), + ('not-even-a-valid-commit-format', False), + ]) +def test_validate_branch(commit: str, expected: bool) -> None: + if expected: + assert validate_branch(commit) + else: + try: + assert validate_branch(commit) + except argparse.ArgumentTypeError: + return + assert False + + +@pytest.mark.parametrize( + 'commit, expected', + [ + ('master', True), + ('20.1-branchpoint', True), + ('20.1', False), + (get_upstream() + '/20.1', True), + ('e58a10af640ba58b6001f5c5ad750b782547da76', True), + ('d043d24654c851f0be57dbbf48274b5373dea42b', True), + ('dd2bd68fa69124c86cd008b256d06f44fab8e6cd', True), + ('0000000000000000000000000000000000000000', False), + ('not-even-a-valid-commit-format', False), + ]) +def test_is_commit_valid(commit: str, expected: bool) -> None: + assert is_commit_valid(commit) == expected + + +@pytest.mark.parametrize( + 'branch, commit, expected', + [ + ('20.1', '20.1-branchpoint', True), + ('20.1', '20.0', False), + ('20.1', 'master', False), + ('20.1', 'e58a10af640ba58b6001f5c5ad750b782547da76', True), + ('20.1', 'd043d24654c851f0be57dbbf48274b5373dea42b', True), + ('20.1', 'dd2bd68fa69124c86cd008b256d06f44fab8e6cd', False), + ('master', 'dd2bd68fa69124c86cd008b256d06f44fab8e6cd', True), + ('20.0', 'd043d24654c851f0be57dbbf48274b5373dea42b', False), + ]) +def test_branch_has_commit(branch: str, commit: str, expected: bool) -> None: + upstream = get_upstream() + assert branch_has_commit(upstream, branch, commit) == expected + + +@pytest.mark.parametrize( + 'branch, commit, expected', + [ + ('20.1', 'dd2bd68fa69124c86cd008b256d06f44fab8e6cd', 'd043d24654c851f0be57dbbf48274b5373dea42b'), + ('20.1', '20.1-branchpoint', ''), + ('20.1', '20.0', ''), + ('20.1', '20.2', ''), + ('20.1', 'master', ''), + ('20.1', 'd043d24654c851f0be57dbbf48274b5373dea42b', ''), + ('20.0', 'dd2bd68fa69124c86cd008b256d06f44fab8e6cd', ''), + ]) +def test_branch_has_backport_of_commit(branch: str, commit: str, expected: bool) -> None: + upstream = get_upstream() + assert branch_has_backport_of_commit(upstream, branch, commit) == expected -- 2.30.2