pickman: Add next-set command to show commits to cherry-pick

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>
This commit is contained in:
Simon Glass
2025-12-15 11:13:53 -07:00
committed by Simon Glass
parent 11b1f8fdd8
commit da8d0d8028
4 changed files with 228 additions and 0 deletions

View File

@@ -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
--------

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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()