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(+) 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() -- 2.43.0