--- /dev/null
+#!/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)
--- /dev/null
+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