@@ -16,6 +16,7 @@ General
kconfig
memory
patman
+ pickman
process
release_cycle
concept_cycle
new file mode 120000
@@ -0,0 +1 @@
+../../tools/pickman/README.rst
\ No newline at end of file
new file mode 100644
@@ -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
new file mode 100644
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2025 Canonical Ltd.
+# Written by Simon Glass <simon.glass@canonical.com>
new file mode 100755
@@ -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())
new file mode 100644
@@ -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
new file mode 100644
@@ -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()
new file mode 120000
@@ -0,0 +1 @@
+__main__.py
\ No newline at end of file