[Concept,06/17] pickman: Add next-set command to show commits to cherry-pick

Message ID 20251217022611.389379-7-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 command that finds the next set of commits to cherry-pick from a
source branch. It lists commits from the last cherry-picked commit up
to and including the next merge commit, which typically represents a
logical grouping (e.g., a pull request).

If no merge commit is found, it lists all remaining commits with a note
indicating this.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
---

 tools/pickman/README.rst  |   8 +++
 tools/pickman/__main__.py |   5 ++
 tools/pickman/control.py  |  94 +++++++++++++++++++++++++++++
 tools/pickman/ftest.py    | 121 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 228 insertions(+)
  

Patch

diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst
index 70f53a6e212..5cb4f51df5c 100644
--- a/tools/pickman/README.rst
+++ b/tools/pickman/README.rst
@@ -33,6 +33,14 @@  This shows:
   master branch (ci/master)
 - The last common commit between the two branches
 
+To show the next set of commits to cherry-pick from a source branch::
+
+    ./tools/pickman/pickman next-set us/next
+
+This finds commits between the last cherry-picked commit and the next merge
+commit in the source branch. It stops at the merge commit since that typically
+represents a logical grouping of commits (e.g., a pull request).
+
 Database
 --------
 
diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py
index 63930953ebb..26886f1fe1b 100755
--- a/tools/pickman/__main__.py
+++ b/tools/pickman/__main__.py
@@ -36,6 +36,11 @@  def parse_args(argv):
 
     subparsers.add_parser('compare', help='Compare branches')
     subparsers.add_parser('list-sources', help='List tracked source branches')
+
+    next_set = subparsers.add_parser('next-set',
+                                     help='Show next set of commits to cherry-pick')
+    next_set.add_argument('source', help='Source branch name')
+
     subparsers.add_parser('test', help='Run tests')
 
     return parser.parse_args(argv)
diff --git a/tools/pickman/control.py b/tools/pickman/control.py
index 5780703bfba..24453b6dd14 100644
--- a/tools/pickman/control.py
+++ b/tools/pickman/control.py
@@ -30,6 +30,10 @@  BRANCH_SOURCE = 'us/next'
 # Named tuple for commit info
 Commit = namedtuple('Commit', ['hash', 'short_hash', 'subject', 'date'])
 
+# Named tuple for commit with author
+CommitInfo = namedtuple('CommitInfo',
+                        ['hash', 'short_hash', 'subject', 'author'])
+
 
 def run_git(args):
     """Run a git command and return output."""
@@ -133,6 +137,95 @@  def do_compare(args, dbs):  # pylint: disable=unused-argument
     return 0
 
 
+def get_next_commits(dbs, source):
+    """Get the next set of commits to cherry-pick from a source
+
+    Finds commits between the last cherry-picked commit and the next merge
+    commit in the source branch.
+
+    Args:
+        dbs (Database): Database instance
+        source (str): Source branch name
+
+    Returns:
+        tuple: (commits, merge_found, error_msg) where:
+            commits: list of CommitInfo tuples
+            merge_found: bool, True if stopped at a merge commit
+            error_msg: str or None, error message if failed
+    """
+    # Get the last cherry-picked commit from database
+    last_commit = dbs.source_get(source)
+
+    if not last_commit:
+        return None, False, f"Source '{source}' not found in database"
+
+    # Get commits between last_commit and source HEAD (oldest first)
+    # Format: hash|short_hash|author|subject|parents
+    # Using | as separator since subject may contain colons
+    log_output = run_git([
+        'log', '--reverse', '--format=%H|%h|%an|%s|%P',
+        f'{last_commit}..{source}'
+    ])
+
+    if not log_output:
+        return [], False, None
+
+    commits = []
+    merge_found = False
+
+    for line in log_output.split('\n'):
+        if not line:
+            continue
+        parts = line.split('|')
+        commit_hash = parts[0]
+        short_hash = parts[1]
+        author = parts[2]
+        subject = '|'.join(parts[3:-1])  # Subject may contain separator
+        parents = parts[-1].split()
+
+        commits.append(CommitInfo(commit_hash, short_hash, subject, author))
+
+        # Check if this is a merge commit (has multiple parents)
+        if len(parents) > 1:
+            merge_found = True
+            break
+
+    return commits, merge_found, None
+
+
+def do_next_set(args, dbs):
+    """Show the next set of commits to cherry-pick from a source
+
+    Args:
+        args (Namespace): Parsed arguments with 'source' attribute
+        dbs (Database): Database instance
+
+    Returns:
+        int: 0 on success, 1 if source not found
+    """
+    source = args.source
+    commits, merge_found, error = get_next_commits(dbs, source)
+
+    if error:
+        tout.error(error)
+        return 1
+
+    if not commits:
+        tout.info('No new commits to cherry-pick')
+        return 0
+
+    if merge_found:
+        tout.info(f'Next set from {source} ({len(commits)} commits):')
+    else:
+        tout.info(f'Remaining commits from {source} ({len(commits)} commits, '
+                  'no merge found):')
+
+    for commit in commits:
+        tout.info(f'  {commit.short_hash} {commit.subject}')
+
+    return 0
+
+
 def do_test(args, dbs):  # pylint: disable=unused-argument
     """Run tests for this module.
 
@@ -156,6 +249,7 @@  COMMANDS = {
     'add-source': do_add_source,
     'compare': do_compare,
     'list-sources': do_list_sources,
+    'next-set': do_next_set,
     'test': do_test,
 }
 
diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py
index 91a003b649c..a6331d21c5f 100644
--- a/tools/pickman/ftest.py
+++ b/tools/pickman/ftest.py
@@ -358,5 +358,126 @@  class TestListSources(unittest.TestCase):
         self.assertIn('us/next: abc123def456', output)
 
 
+class TestNextSet(unittest.TestCase):
+    """Tests for next-set command."""
+
+    def setUp(self):
+        """Set up test fixtures."""
+        fd, self.db_path = tempfile.mkstemp(suffix='.db')
+        os.close(fd)
+        os.unlink(self.db_path)
+        self.old_db_fname = control.DB_FNAME
+        control.DB_FNAME = self.db_path
+        database.Database.instances.clear()
+
+    def tearDown(self):
+        """Clean up test fixtures."""
+        control.DB_FNAME = self.old_db_fname
+        if os.path.exists(self.db_path):
+            os.unlink(self.db_path)
+        database.Database.instances.clear()
+        command.TEST_RESULT = None
+
+    def test_next_set_source_not_found(self):
+        """Test next-set with unknown source"""
+        # Create empty database first
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.close()
+
+        database.Database.instances.clear()
+
+        args = argparse.Namespace(cmd='next-set', source='unknown')
+        with terminal.capture() as (_, stderr):
+            ret = control.do_pickman(args)
+        self.assertEqual(ret, 1)
+        # Error goes to stderr
+        self.assertIn("Source 'unknown' not found", stderr.getvalue())
+
+    def test_next_set_no_commits(self):
+        """Test next-set with no new commits"""
+        # Add source to database
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'abc123')
+            dbs.commit()
+            dbs.close()
+
+        database.Database.instances.clear()
+
+        # Mock git log returning empty
+        command.TEST_RESULT = command.CommandResult(stdout='')
+
+        args = argparse.Namespace(cmd='next-set', source='us/next')
+        with terminal.capture() as (stdout, _):
+            ret = control.do_pickman(args)
+        self.assertEqual(ret, 0)
+        self.assertIn('No new commits to cherry-pick', stdout.getvalue())
+
+    def test_next_set_with_merge(self):
+        """Test next-set finding commits up to merge"""
+        # Add source to database
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'abc123')
+            dbs.commit()
+            dbs.close()
+
+        database.Database.instances.clear()
+
+        # Mock git log with commits including a merge
+        log_output = (
+            'aaa111|aaa111a|Author 1|First commit|abc123\n'
+            'bbb222|bbb222b|Author 2|Second commit|aaa111\n'
+            'ccc333|ccc333c|Author 3|Merge branch feature|bbb222 ddd444\n'
+            'eee555|eee555e|Author 4|After merge|ccc333\n'
+        )
+        command.TEST_RESULT = command.CommandResult(stdout=log_output)
+
+        args = argparse.Namespace(cmd='next-set', source='us/next')
+        with terminal.capture() as (stdout, _):
+            ret = control.do_pickman(args)
+        self.assertEqual(ret, 0)
+        output = stdout.getvalue()
+        self.assertIn('Next set from us/next (3 commits):', output)
+        self.assertIn('aaa111a First commit', output)
+        self.assertIn('bbb222b Second commit', output)
+        self.assertIn('ccc333c Merge branch feature', output)
+        # Should not include commits after the merge
+        self.assertNotIn('eee555e', output)
+
+    def test_next_set_no_merge(self):
+        """Test next-set with no merge commit found"""
+        # Add source to database
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'abc123')
+            dbs.commit()
+            dbs.close()
+
+        database.Database.instances.clear()
+
+        # Mock git log without merge commits
+        log_output = (
+            'aaa111|aaa111a|Author 1|First commit|abc123\n'
+            'bbb222|bbb222b|Author 2|Second commit|aaa111\n'
+        )
+        command.TEST_RESULT = command.CommandResult(stdout=log_output)
+
+        args = argparse.Namespace(cmd='next-set', source='us/next')
+        with terminal.capture() as (stdout, _):
+            ret = control.do_pickman(args)
+        self.assertEqual(ret, 0)
+        output = stdout.getvalue()
+        self.assertIn('Remaining commits from us/next (2 commits, '
+                      'no merge found):', output)
+        self.assertIn('aaa111a First commit', output)
+        self.assertIn('bbb222b Second commit', output)
+
+
 if __name__ == '__main__':
     unittest.main()