[Concept,04/29] u_boot_pylib: Add gitutil helpers for repo-aware git operations

Message ID 20260501110040.1874719-5-sjg@u-boot.org
State New
Headers
Series patman: Review-flow improvements and shared helpers |

Commit Message

Simon Glass May 1, 2026, 10:59 a.m. UTC
  From: Simon Glass <sjg@chromium.org>

review.py and other patman code paths run a fair number of git
commands directly via command.output() / command.run_one(), driving
git from a working-tree path passed as cwd. The existing gitutil
helpers mostly take an explicit --git-dir, which is a different
calling convention and so does not fit those sites cleanly.

Add a small set of wrappers that take a working-tree path (passed
through subprocess cwd, with git's discovery rules locating the
repository from there). Each one returns the obvious thing for its
operation:

- count_revs(): commits in a range as int, or None on failure.
- diff_stat(): output of 'git diff --stat <range>', or '' on failure.
- checkout_branch(): runs 'git checkout <branch>'.
- stash_save(): runs 'git stash', returns the resulting stdout.
- stash_pop(): runs 'git stash pop'.
- ref_exists(): bool indicating whether a ref resolves.
- current_branch(): name of the current branch, or 'HEAD' if detached.

No callers yet; a follow-up patch switches review.py over.

Signed-off-by: Simon Glass <sjg@chromium.org>
---

 tools/u_boot_pylib/gitutil.py      | 107 +++++++++++++++++++++++++
 tools/u_boot_pylib/test_gitutil.py | 122 +++++++++++++++++++++++++++++
 2 files changed, 229 insertions(+)
 create mode 100644 tools/u_boot_pylib/test_gitutil.py
  

Patch

diff --git a/tools/u_boot_pylib/gitutil.py b/tools/u_boot_pylib/gitutil.py
index 202afe745d3..e216fd8393f 100644
--- a/tools/u_boot_pylib/gitutil.py
+++ b/tools/u_boot_pylib/gitutil.py
@@ -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.
 
diff --git a/tools/u_boot_pylib/test_gitutil.py b/tools/u_boot_pylib/test_gitutil.py
new file mode 100644
index 00000000000..c66d68ddf1c
--- /dev/null
+++ b/tools/u_boot_pylib/test_gitutil.py
@@ -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()