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