From: Simon Glass <simon.glass@canonical.com> Add a tool to check the number of commits in a source branch (us/next) that are not in the master branch (ci/master), and find the last common commit between them. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- doc/develop/index.rst | 1 + doc/develop/pickman.rst | 1 + tools/pickman/README.rst | 39 +++++++++++++++ tools/pickman/__init__.py | 4 ++ tools/pickman/__main__.py | 26 ++++++++++ tools/pickman/control.py | 74 ++++++++++++++++++++++++++++ tools/pickman/ftest.py | 101 ++++++++++++++++++++++++++++++++++++++ tools/pickman/pickman | 1 + 8 files changed, 247 insertions(+) create mode 120000 doc/develop/pickman.rst create mode 100644 tools/pickman/README.rst create mode 100644 tools/pickman/__init__.py create mode 100755 tools/pickman/__main__.py create mode 100644 tools/pickman/control.py create mode 100644 tools/pickman/ftest.py create mode 120000 tools/pickman/pickman diff --git a/doc/develop/index.rst b/doc/develop/index.rst index c40ada5899f..4b55d65de75 100644 --- a/doc/develop/index.rst +++ b/doc/develop/index.rst @@ -16,6 +16,7 @@ General kconfig memory patman + pickman process release_cycle concept_cycle diff --git a/doc/develop/pickman.rst b/doc/develop/pickman.rst new file mode 120000 index 00000000000..84816e57626 --- /dev/null +++ b/doc/develop/pickman.rst @@ -0,0 +1 @@ +../../tools/pickman/README.rst \ No newline at end of file diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst new file mode 100644 index 00000000000..299f2cac699 --- /dev/null +++ b/tools/pickman/README.rst @@ -0,0 +1,39 @@ +.. SPDX-License-Identifier: GPL-2.0+ +.. +.. Copyright 2025 Canonical Ltd. +.. Written by Simon Glass <simon.glass@canonical.com> + +Pickman - Cherry-pick Manager +============================= + +Pickman is a tool to help manage cherry-picking commits between branches. + +Usage +----- + +To compare branches and show commits that need to be cherry-picked:: + + ./tools/pickman/pickman + +This shows: + +- The number of commits in the source branch (us/next) that are not in the + master branch (ci/master) +- The last common commit between the two branches + +Configuration +------------- + +The branches to compare are configured as constants at the top of the script: + +- ``BRANCH_MASTER``: The main branch to compare against (default: ci/master) +- ``BRANCH_SOURCE``: The source branch with commits to cherry-pick + (default: us/next) + +Testing +------- + +To run the functional tests:: + + cd tools/pickman + python3 -m pytest ftest.py -v diff --git a/tools/pickman/__init__.py b/tools/pickman/__init__.py new file mode 100644 index 00000000000..96e553681aa --- /dev/null +++ b/tools/pickman/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Canonical Ltd. +# Written by Simon Glass <simon.glass@canonical.com> diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py new file mode 100755 index 00000000000..eb0d6e226cc --- /dev/null +++ b/tools/pickman/__main__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Canonical Ltd. +# Written by Simon Glass <simon.glass@canonical.com> +# +"""Entry point for pickman - dispatches to control module.""" + +import os +import sys + +# Allow 'from pickman import xxx' to work via symlink +our_path = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, os.path.join(our_path, '..')) + +# pylint: disable=wrong-import-position,import-error +from pickman import control + + +def main(): + """Main function.""" + return control.do_pickman() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/pickman/control.py b/tools/pickman/control.py new file mode 100644 index 00000000000..990fa1b0729 --- /dev/null +++ b/tools/pickman/control.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Canonical Ltd. +# Written by Simon Glass <simon.glass@canonical.com> +# +"""Control module for pickman - handles the main logic.""" + +from collections import namedtuple +import os +import sys + +# Allow 'from pickman import xxx' to work via symlink +our_path = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, os.path.join(our_path, '..')) + +# pylint: disable=wrong-import-position,import-error +from u_boot_pylib import command +from u_boot_pylib import tout + +# Branch names to compare +BRANCH_MASTER = 'ci/master' +BRANCH_SOURCE = 'us/next' + +# Named tuple for commit info +Commit = namedtuple('Commit', ['hash', 'short_hash', 'subject', 'date']) + + +def run_git(args): + """Run a git command and return output.""" + return command.output('git', *args).strip() + + +def compare_branches(master, source): + """Compare two branches and return commit difference info. + + Args: + master (str): Main branch to compare against + source (str): Source branch to check for unique commits + + Returns: + tuple: (count, Commit) where count is number of commits and Commit + is the last common commit + """ + # Find commits in source that are not in master + count = int(run_git(['rev-list', '--count', f'{master}..{source}'])) + + # Find the merge base (last common commit) + base = run_git(['merge-base', master, source]) + + # Get details about the merge-base commit + info = run_git(['log', '-1', '--format=%H%n%h%n%s%n%ci', base]) + full_hash, short_hash, subject, date = info.split('\n') + + return count, Commit(full_hash, short_hash, subject, date) + + +def do_pickman(): + """Main entry point for pickman. + + Returns: + int: 0 on success + """ + tout.init(tout.INFO) + + count, base = compare_branches(BRANCH_MASTER, BRANCH_SOURCE) + + tout.info(f'Commits in {BRANCH_SOURCE} not in {BRANCH_MASTER}: {count}') + tout.info('') + tout.info('Last common commit:') + tout.info(f' Hash: {base.short_hash}') + tout.info(f' Subject: {base.subject}') + tout.info(f' Date: {base.date}') + + return 0 diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py new file mode 100644 index 00000000000..7b34a260659 --- /dev/null +++ b/tools/pickman/ftest.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Canonical Ltd. +# Written by Simon Glass <simon.glass@canonical.com> +# +"""Tests for pickman.""" + +import os +import sys +import unittest + +# Allow 'from pickman import xxx' to work via symlink +our_path = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, os.path.join(our_path, '..')) + +# pylint: disable=wrong-import-position,import-error +from u_boot_pylib import command + +from pickman import control + + +class TestCommit(unittest.TestCase): + """Tests for the Commit namedtuple.""" + + def test_commit_fields(self): + """Test Commit namedtuple has correct fields.""" + commit = control.Commit( + 'abc123def456', + 'abc123d', + 'Test commit subject', + '2024-01-15 10:30:00 -0600' + ) + self.assertEqual(commit.hash, 'abc123def456') + self.assertEqual(commit.short_hash, 'abc123d') + self.assertEqual(commit.subject, 'Test commit subject') + self.assertEqual(commit.date, '2024-01-15 10:30:00 -0600') + + +class TestRunGit(unittest.TestCase): + """Tests for run_git function.""" + + def test_run_git(self): + """Test run_git returns stripped output.""" + result = command.CommandResult(stdout=' output with spaces \n') + command.TEST_RESULT = result + try: + out = control.run_git(['status']) + self.assertEqual(out, 'output with spaces') + finally: + command.TEST_RESULT = None + + +class TestCompareBranches(unittest.TestCase): + """Tests for compare_branches function.""" + + def test_compare_branches(self): + """Test compare_branches returns correct count and commit.""" + results = iter([ + '42', # rev-list --count + 'abc123def456789', # merge-base + 'abc123def456789\nabc123d\nTest subject\n2024-01-15 10:30:00 -0600', + ]) + + def handle_command(**_): + return command.CommandResult(stdout=next(results)) + + command.TEST_RESULT = handle_command + try: + count, commit = control.compare_branches('master', 'source') + + self.assertEqual(count, 42) + self.assertEqual(commit.hash, 'abc123def456789') + self.assertEqual(commit.short_hash, 'abc123d') + self.assertEqual(commit.subject, 'Test subject') + self.assertEqual(commit.date, '2024-01-15 10:30:00 -0600') + finally: + command.TEST_RESULT = None + + def test_compare_branches_zero_commits(self): + """Test compare_branches with zero commit difference.""" + results = iter([ + '0', + 'def456abc789', + 'def456abc789\ndef456a\nMerge commit\n2024-02-20 14:00:00 -0500', + ]) + + def handle_command(**_): + return command.CommandResult(stdout=next(results)) + + command.TEST_RESULT = handle_command + try: + count, commit = control.compare_branches('branch1', 'branch2') + + self.assertEqual(count, 0) + self.assertEqual(commit.short_hash, 'def456a') + finally: + command.TEST_RESULT = None + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/pickman/pickman b/tools/pickman/pickman new file mode 120000 index 00000000000..5a427d19424 --- /dev/null +++ b/tools/pickman/pickman @@ -0,0 +1 @@ +__main__.py \ No newline at end of file -- 2.43.0