[Concept,01/17] pickman: Add tool to compare branch differences

Message ID 20251217022611.389379-2-sjg@u-boot.org
State New
Headers
Series pickman: Add a manager for cherry-picks |

Commit Message

Simon Glass Dec. 17, 2025, 2:25 a.m. UTC
  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
  

Patch

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