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:
@@ -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
|
||||
--------
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user