From patchwork Fri May 1 10:59:56 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Simon Glass X-Patchwork-Id: 2247 Return-Path: X-Original-To: u-boot-concept@u-boot.org Delivered-To: u-boot-concept@u-boot.org DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633268; bh=618WphB3f8voSkhx170WO7Hrg8Ml65aryD4OYPllctE=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=ACxYXfZxB8mObRyoKJ+FS2PM6PIXT9zmaRuEVgrLXS3W/RAuYy+hMi91OJnkadJo7 oFesf8Qt9AL/1AZ/jF18PAld+DfTFp13zNA1yB4mJX/Z5wjyM1IqQE3m81loRx+SeX MqnUHPID9O9HAuSYugiH6TJ5DaVTY0C3uF6hQO9A= Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 532BB6A834 for ; Fri, 1 May 2026 05:01:08 -0600 (MDT) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10024) with ESMTP id Yv6exc_mmWBZ for ; Fri, 1 May 2026 05:01:08 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633268; bh=618WphB3f8voSkhx170WO7Hrg8Ml65aryD4OYPllctE=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=ACxYXfZxB8mObRyoKJ+FS2PM6PIXT9zmaRuEVgrLXS3W/RAuYy+hMi91OJnkadJo7 oFesf8Qt9AL/1AZ/jF18PAld+DfTFp13zNA1yB4mJX/Z5wjyM1IqQE3m81loRx+SeX MqnUHPID9O9HAuSYugiH6TJ5DaVTY0C3uF6hQO9A= Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 3D1226A82E for ; Fri, 1 May 2026 05:01:08 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633266; bh=jVwsQywn4NTo34iflhI54CxeaVWP3p/scPHd6jJfzg4=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=Nlefwe11KK+TruABUYkfYmYN1eB2iYJTpONpxU0Dhxz+Czk5qZyHUFOxpUH505XqF Rx690C/62AveN2SV65b2HFqewaTvz1wtehTJWqv3fHiPKRO96oA0VHeuRtB+ZEBlZh zhSEAzNfUjj1yfBcCgne2mdhjkQs8wdElZaSc1tY= Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 0CF1E6A78B; Fri, 1 May 2026 05:01:06 -0600 (MDT) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10026) with ESMTP id koN28msN-0AB; Fri, 1 May 2026 05:01:05 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633261; bh=dxXwc1Qb+DdIYu0JW43Y4GJ3yRUl1wmyj88Bcfa5uwY=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=Awy5vdnqwVdRyBXoBM4DBTIRBqCWNWTz5cPZETeeYK1WKs/Sg6+PKZwsMrNnvEQdj nzx+7KHXwpSNKnVlYBe3zVddQfE6oLs5cmcKcuQqYU/kmq5Um62HTfamZIl3yNahq6 azXja+nIWIA0B28W7pTc8958R3bFIdNLaBlNuZMU= Received: from u-boot.org (unknown [174.51.25.52]) by mail.u-boot.org (Postfix) with ESMTPSA id 627186A82E; Fri, 1 May 2026 05:01:01 -0600 (MDT) From: Simon Glass To: U-Boot Concept Date: Fri, 1 May 2026 04:59:56 -0600 Message-ID: <20260501110040.1874719-5-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20260501110040.1874719-1-sjg@u-boot.org> References: <20260501110040.1874719-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: M67G6A25IDLNY46X4P5VNTD3VDJSNXDU X-Message-ID-Hash: M67G6A25IDLNY46X4P5VNTD3VDJSNXDU X-MailFrom: sjg@u-boot.org X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Simon Glass X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 04/29] u_boot_pylib: Add gitutil helpers for repo-aware git operations List-Id: Discussion and patches related to U-Boot Concept Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Simon Glass 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 ', or '' on failure. - checkout_branch(): runs 'git checkout '. - 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 --- 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 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 ', 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 ' 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 + +"""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()