introduce `commit_in_branch.py` script to help devs figure this out
authorEric Engestrom <eric@engestrom.ch>
Tue, 2 Jun 2020 22:22:33 +0000 (00:22 +0200)
committerMarge Bot <eric+marge@anholt.net>
Fri, 10 Jul 2020 20:01:32 +0000 (20:01 +0000)
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 <eric@engestrom.ch>
Part-of: <https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests/5306>

bin/commit_in_branch.py [new file with mode: 0755]
bin/commit_in_branch_test.py [new file with mode: 0644]

diff --git a/bin/commit_in_branch.py b/bin/commit_in_branch.py
new file mode 100755 (executable)
index 0000000..e4e2edb
--- /dev/null
@@ -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 (file)
index 0000000..95724f2
--- /dev/null
@@ -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