@@ -68,6 +68,13 @@ 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).
+To show the next N merges that will be applied::
+
+ ./tools/pickman/pickman next-merges us/next
+
+This shows the upcoming merge commits on the first-parent chain, useful for
+seeing what's coming up. Use ``-c`` to specify the count (default 10).
+
To apply the next set of commits using a Claude agent::
./tools/pickman/pickman apply us/next
@@ -56,6 +56,12 @@ def parse_args(argv):
subparsers.add_parser('compare', help='Compare branches')
subparsers.add_parser('list-sources', help='List tracked source branches')
+ next_merges = subparsers.add_parser('next-merges',
+ help='Show next N merges to be applied')
+ next_merges.add_argument('source', help='Source branch name')
+ next_merges.add_argument('-c', '--count', type=int, default=10,
+ help='Number of merges to show (default: 10)')
+
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')
@@ -248,6 +248,56 @@ def do_next_set(args, dbs):
return 0
+def do_next_merges(args, dbs):
+ """Show the next N merges to be applied from a source
+
+ Args:
+ args (Namespace): Parsed arguments with 'source' and 'count' attributes
+ dbs (Database): Database instance
+
+ Returns:
+ int: 0 on success, 1 if source not found
+ """
+ source = args.source
+ count = args.count
+
+ # Get the last cherry-picked commit from database
+ last_commit = dbs.source_get(source)
+
+ if not last_commit:
+ tout.error(f"Source '{source}' not found in database")
+ return 1
+
+ # Find merge commits on the first-parent chain
+ out = run_git([
+ 'log', '--reverse', '--first-parent', '--merges',
+ '--format=%H|%h|%s',
+ f'{last_commit}..{source}'
+ ])
+
+ if not out:
+ tout.info('No merges remaining')
+ return 0
+
+ merges = []
+ for line in out.split('\n'):
+ if not line:
+ continue
+ parts = line.split('|', 2)
+ commit_hash = parts[0]
+ short_hash = parts[1]
+ subject = parts[2] if len(parts) > 2 else ''
+ merges.append((commit_hash, short_hash, subject))
+ if len(merges) >= count:
+ break
+
+ tout.info(f'Next {len(merges)} merges from {source}:')
+ for i, (_, short_hash, subject) in enumerate(merges, 1):
+ tout.info(f' {i}. {short_hash} {subject}')
+
+ return 0
+
+
HISTORY_FILE = '.pickman-history'
@@ -786,6 +836,7 @@ COMMANDS = {
'commit-source': do_commit_source,
'compare': do_compare,
'list-sources': do_list_sources,
+ 'next-merges': do_next_merges,
'next-set': do_next_set,
'poll': do_poll,
'review': do_review,
@@ -904,6 +904,107 @@ class TestNextSet(unittest.TestCase):
self.assertIn('bbb222b Second commit', output)
+class TestNextMerges(unittest.TestCase):
+ """Tests for next-merges 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_merges(self):
+ """Test next-merges shows upcoming merges"""
+ # 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 merge commits
+ log_output = (
+ 'aaa111|aaa111a|Merge branch feature-1\n'
+ 'bbb222|bbb222b|Merge branch feature-2\n'
+ 'ccc333|ccc333c|Merge branch feature-3\n'
+ )
+ command.TEST_RESULT = command.CommandResult(stdout=log_output)
+
+ args = argparse.Namespace(cmd='next-merges', source='us/next', count=10)
+ with terminal.capture() as (stdout, _):
+ ret = control.do_pickman(args)
+ self.assertEqual(ret, 0)
+ output = stdout.getvalue()
+ self.assertIn('Next 3 merges from us/next:', output)
+ self.assertIn('1. aaa111a Merge branch feature-1', output)
+ self.assertIn('2. bbb222b Merge branch feature-2', output)
+ self.assertIn('3. ccc333c Merge branch feature-3', output)
+
+ def test_next_merges_with_count(self):
+ """Test next-merges respects count parameter"""
+ # 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 merge commits
+ log_output = (
+ 'aaa111|aaa111a|Merge branch feature-1\n'
+ 'bbb222|bbb222b|Merge branch feature-2\n'
+ 'ccc333|ccc333c|Merge branch feature-3\n'
+ )
+ command.TEST_RESULT = command.CommandResult(stdout=log_output)
+
+ args = argparse.Namespace(cmd='next-merges', source='us/next', count=2)
+ with terminal.capture() as (stdout, _):
+ ret = control.do_pickman(args)
+ self.assertEqual(ret, 0)
+ output = stdout.getvalue()
+ self.assertIn('Next 2 merges from us/next:', output)
+ self.assertIn('1. aaa111a', output)
+ self.assertIn('2. bbb222b', output)
+ self.assertNotIn('3. ccc333c', output)
+
+ def test_next_merges_no_merges(self):
+ """Test next-merges with no merges remaining"""
+ # 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()
+
+ command.TEST_RESULT = command.CommandResult(stdout='')
+
+ args = argparse.Namespace(cmd='next-merges', source='us/next', count=10)
+ with terminal.capture() as (stdout, _):
+ ret = control.do_pickman(args)
+ self.assertEqual(ret, 0)
+ self.assertIn('No merges remaining', stdout.getvalue())
+
+
class TestGetNextCommits(unittest.TestCase):
"""Tests for get_next_commits function."""