@@ -206,6 +206,113 @@ def count_commits_in_range(git_dir, range_expr):
return patch_count, None
+def count_revs(git_dir, range_expr):
+ """Count revisions in a range using 'git rev-list --count'.
+
+ Args:
+ git_dir (str): Directory containing git repo, or None for the
+ current working directory
+ range_expr (str): Range to count, e.g. 'upstream..branch'
+ Return:
+ int: Number of revisions in the range, or None if the range is
+ invalid (e.g. one of the refs does not exist).
+ """
+ result = command.run_one('git', 'rev-list', '--count', range_expr,
+ cwd=git_dir, capture=True,
+ capture_stderr=True, raise_on_error=False)
+ if result.return_code:
+ return None
+ try:
+ return int(result.stdout.strip())
+ except ValueError:
+ return None
+
+
+def diff_stat(commit_range, git_dir=None):
+ """Get a 'git diff --stat' summary for a commit range.
+
+ Args:
+ commit_range (str): Range to diff (e.g. 'upstream..HEAD')
+ git_dir (str): Directory containing git repo, or None for the
+ current working directory
+ Return:
+ str: Output of 'git diff --stat <range>', or '' on failure
+ """
+ result = command.run_one('git', 'diff', '--stat', commit_range,
+ cwd=git_dir, capture=True,
+ capture_stderr=True, raise_on_error=False)
+ if result.return_code:
+ return ''
+ return result.stdout
+
+
+def checkout_branch(branch, git_dir=None):
+ """Run 'git checkout <branch>' from a working tree.
+
+ Args:
+ branch (str): Branch name to check out
+ git_dir (str): Directory containing git repo, or None for the
+ current working directory
+ """
+ command.output('git', 'checkout', branch, cwd=git_dir)
+
+
+def stash_save(git_dir=None, include_untracked=False):
+ """Save the working tree to a new stash.
+
+ Args:
+ git_dir (str): Directory containing git repo, or None for the
+ current working directory
+ include_untracked (bool): True to also stash untracked files
+ ('git stash -u'), False for the default tracked-only behaviour
+ Return:
+ str: Output from 'git stash'
+ """
+ cmd = ['git', 'stash']
+ if include_untracked:
+ cmd.append('-u')
+ return command.output(*cmd, cwd=git_dir)
+
+
+def stash_pop(git_dir=None):
+ """Pop the most-recent stash back into the working tree.
+
+ Args:
+ git_dir (str): Directory containing git repo, or None for the
+ current working directory
+ """
+ command.output('git', 'stash', 'pop', cwd=git_dir)
+
+
+def ref_exists(ref, git_dir=None):
+ """Check whether a git ref resolves.
+
+ Args:
+ ref (str): Ref to check (branch name, tag, commit, etc.)
+ git_dir (str): Directory containing git repo, or None for the
+ current working directory
+ Return:
+ bool: True if the ref resolves, False otherwise
+ """
+ result = command.run_one('git', 'rev-parse', '--verify', ref,
+ cwd=git_dir, capture=True,
+ capture_stderr=True, raise_on_error=False)
+ return result.return_code == 0
+
+
+def current_branch(git_dir=None):
+ """Get the name of the currently checked-out branch.
+
+ Args:
+ git_dir (str): Directory containing git repo, or None for the
+ current working directory
+ Return:
+ str: Branch name, or 'HEAD' if the worktree is detached
+ """
+ return command.output('git', 'rev-parse', '--abbrev-ref', 'HEAD',
+ cwd=git_dir).strip()
+
+
def count_commits_in_branch(git_dir, branch, include_upstream=False):
"""Returns the number of commits in the given branch.
new file mode 100644
@@ -0,0 +1,122 @@
+# SPDX-License-Identifier: GPL-2.0+
+# Copyright 2026 Simon Glass <sjg@chromium.org>
+
+"""Tests for the cwd-style helpers in u_boot_pylib.gitutil"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+from u_boot_pylib import gitutil
+
+
+def _git(*args, cwd):
+ """Run git silently in cwd, raising on failure."""
+ subprocess.run(['git', *args], cwd=cwd, check=True,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+
+def _commit(cwd, fname, body, msg):
+ """Add a tracked file and commit it."""
+ with open(os.path.join(cwd, fname), 'w') as f:
+ f.write(body)
+ _git('add', fname, cwd=cwd)
+ _git('-c', 'user.email=t@e', '-c', 'user.name=T',
+ 'commit', '-m', msg, cwd=cwd)
+
+
+class TestGitutilHelpers(unittest.TestCase):
+ """Tests for the gitutil helpers introduced for review.py."""
+
+ def setUp(self):
+ self.tmpdir = tempfile.mkdtemp(prefix='gitutil-test-')
+ _git('init', '-q', '-b', 'main', cwd=self.tmpdir)
+ _commit(self.tmpdir, 'a', 'first', 'first')
+
+ def tearDown(self):
+ shutil.rmtree(self.tmpdir, ignore_errors=True)
+
+ def test_count_revs_empty_range(self):
+ """count_revs() returns 0 when the range contains no commits."""
+ self.assertEqual(0, gitutil.count_revs(self.tmpdir, 'main..main'))
+
+ def test_count_revs_with_commits(self):
+ """count_revs() returns the number of commits in the range."""
+ _git('checkout', '-q', '-b', 'work', cwd=self.tmpdir)
+ _commit(self.tmpdir, 'b', 'second', 'second')
+ _commit(self.tmpdir, 'c', 'third', 'third')
+ self.assertEqual(2, gitutil.count_revs(self.tmpdir, 'main..work'))
+
+ def test_count_revs_invalid_range(self):
+ """count_revs() returns None when one side of the range is bad."""
+ self.assertIsNone(
+ gitutil.count_revs(self.tmpdir, 'no-such-ref..main'))
+
+ def test_diff_stat(self):
+ """diff_stat() reports a stat summary for a valid range."""
+ _git('checkout', '-q', '-b', 'work', cwd=self.tmpdir)
+ _commit(self.tmpdir, 'b', 'second', 'second')
+ out = gitutil.diff_stat('main..work', self.tmpdir)
+ self.assertIn('b', out)
+ self.assertIn('insert', out)
+
+ def test_diff_stat_invalid(self):
+ """diff_stat() returns '' when the range is invalid."""
+ self.assertEqual(
+ '', gitutil.diff_stat('no-such-ref..main', self.tmpdir))
+
+ def test_ref_exists(self):
+ """ref_exists() resolves valid refs and rejects missing ones."""
+ self.assertTrue(gitutil.ref_exists('main', self.tmpdir))
+ self.assertFalse(gitutil.ref_exists('does-not-exist', self.tmpdir))
+
+ def test_current_branch(self):
+ """current_branch() returns the active branch name."""
+ self.assertEqual('main', gitutil.current_branch(self.tmpdir))
+ _git('checkout', '-q', '-b', 'feature', cwd=self.tmpdir)
+ self.assertEqual('feature', gitutil.current_branch(self.tmpdir))
+
+ def test_checkout_branch(self):
+ """checkout_branch() swaps the active branch."""
+ _git('branch', 'other', cwd=self.tmpdir)
+ gitutil.checkout_branch('other', self.tmpdir)
+ self.assertEqual('other', gitutil.current_branch(self.tmpdir))
+
+ def test_stash_save_pop(self):
+ """stash_save() saves a tracked-file change; stash_pop() restores it."""
+ with open(os.path.join(self.tmpdir, 'a'), 'w') as f:
+ f.write('changed')
+ out = gitutil.stash_save(self.tmpdir)
+ self.assertNotIn('No local changes', out)
+ with open(os.path.join(self.tmpdir, 'a')) as f:
+ self.assertEqual('first', f.read())
+ gitutil.stash_pop(self.tmpdir)
+ with open(os.path.join(self.tmpdir, 'a')) as f:
+ self.assertEqual('changed', f.read())
+
+ def test_stash_save_clean_tree(self):
+ """stash_save() reports no changes when nothing is modified."""
+ out = gitutil.stash_save(self.tmpdir)
+ self.assertIn('No local changes', out)
+
+ def test_stash_save_include_untracked(self):
+ """stash_save(include_untracked=True) covers untracked files too."""
+ path = os.path.join(self.tmpdir, 'extra')
+ with open(path, 'w') as f:
+ f.write('untracked')
+
+ # Default: untracked files are not stashed.
+ out = gitutil.stash_save(self.tmpdir)
+ self.assertIn('No local changes', out)
+ self.assertTrue(os.path.exists(path))
+
+ # With include_untracked, the file is stashed away.
+ out = gitutil.stash_save(self.tmpdir, include_untracked=True)
+ self.assertNotIn('No local changes', out)
+ self.assertFalse(os.path.exists(path))
+
+
+if __name__ == '__main__':
+ unittest.main()