info: reuse branch snapshots and combine history walks Derive the current branch from GetBranches so attached HEAD and refs are resolved once, including overview reporting. Replace the two complementary rev-list calls in --all output with one --left-right symmetric-difference walk and partition its records in memory. Bug: 553599402 Change-Id: I01d5db804cbf591ecc0e08d6605971f0f5a38538 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/634005 Tested-by: Gavin Mak <gavinmak@google.com> Reviewed-by: Brian Gan <brgan@google.com> Commit-Queue: Gavin Mak <gavinmak@google.com>
diff --git a/subcmds/info.py b/subcmds/info.py index 594e633..e120cae 100644 --- a/subcmds/info.py +++ b/subcmds/info.py
@@ -18,7 +18,7 @@ import json import optparse import sys -from typing import Any, Dict, List, NamedTuple +from typing import Any, Dict, List, NamedTuple, Optional, Tuple from color import Coloring from command import DEFAULT_LOCAL_JOBS @@ -189,18 +189,26 @@ "superproject_revision": srev, } + @staticmethod + def _GetCurrentBranch(branches: Dict[str, Any]) -> Optional[str]: + """Return the name of the current branch from a GetBranches mapping.""" + return next( + (name for name, branch in branches.items() if branch.current), None + ) + @classmethod def _getProjectData(cls, project) -> Dict[str, Any]: """Gather project data as a dict.""" + branches = project.GetBranches() + currentBranch = cls._GetCurrentBranch(branches) data = { "name": project.name, "mount_path": project.worktree, "current_revision": project.GetHeadRevisionId() or project.GetRevisionId(), "manifest_revision": project.revisionExpr, - "local_branches": list(project.GetBranches()), + "local_branches": list(branches), } - currentBranch = project.CurrentBranch if currentBranch: data["current_branch"] = currentBranch return data @@ -285,6 +293,9 @@ text = out.nofmt_printer("text") dimtext = out.printer("dimtext", attr="dim") + branches = project.GetBranches() + currentBranch = cls._GetCurrentBranch(branches) + heading("Project: ") headtext(project.name) out.nl() @@ -297,7 +308,6 @@ headtext(project.GetHeadRevisionId() or project.GetRevisionId()) out.nl() - currentBranch = project.CurrentBranch if currentBranch: heading("Current branch: ") headtext(currentBranch) @@ -307,7 +317,7 @@ headtext(project.revisionExpr) out.nl() - localBranches = list(project.GetBranches().keys()) + localBranches = list(branches) heading("Local Branches: ") redtext(str(len(localBranches))) if localBranches: @@ -327,25 +337,10 @@ branch = branch[len(R_HEADS) :] logTarget = R_M + branch - bareTmp = project.bare_git._bare - project.bare_git._bare = False - localCommits = project.bare_git.rev_list( - "--abbrev=8", - "--abbrev-commit", - "--pretty=oneline", - logTarget + "..", - "--", + localCommits, originCommits = cls._GetDiffCommits( + project, logTarget ) - originCommits = project.bare_git.rev_list( - "--abbrev=8", - "--abbrev-commit", - "--pretty=oneline", - ".." + logTarget, - "--", - ) - project.bare_git._bare = bareTmp - heading("Local Commits: ") redtext(str(len(localCommits))) dimtext(" (on current branch)") @@ -375,6 +370,35 @@ return buf.getvalue() + @classmethod + def _GetDiffCommits( + cls, project: Any, log_target: str + ) -> Tuple[List[str], List[str]]: + """Return local-only and remote-only commits from one history walk.""" + git = getattr(project, "work_git", None) or getattr( + project, "bare_git", None + ) + if git is None: + return [], [] + commits = git.rev_list( + "--left-right", + "--abbrev=8", + "--abbrev-commit", + "--pretty=oneline", + f"HEAD...{log_target}", + "--", + ) + if isinstance(commits, str): + commits = commits.splitlines() + local = [] + remote = [] + for commit in commits: + if commit.startswith("<"): + local.append(commit[1:]) + elif commit.startswith(">"): + remote.append(commit[1:]) + return local, remote + def _printDiffInfo(self, opt, args): projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only) @@ -404,10 +428,18 @@ project = cls.get_parallel_context()["projects"][project_idx] branches = [] - br = [project.GetUploadableBranch(x) for x in project.GetBranches()] - br = [x for x in br if x] + local_branches = project.GetBranches() + current_branch = cls._GetCurrentBranch(local_branches) if opt.current_branch: - br = [x for x in br if x.name == project.CurrentBranch] + candidate_branches = ( + [current_branch] + if current_branch and current_branch in local_branches + else [] + ) + else: + candidate_branches = local_branches + br = [project.GetUploadableBranch(x) for x in candidate_branches] + br = [x for x in br if x] for b in br: branches.append( @@ -416,7 +448,7 @@ name=b.name, commits=b.commits, date=b.date, - is_current=b.name == project.CurrentBranch, + is_current=b.name == current_branch, ) ) return branches
diff --git a/tests/test_subcmds_info.py b/tests/test_subcmds_info.py index 922b108..aa69b7b 100644 --- a/tests/test_subcmds_info.py +++ b/tests/test_subcmds_info.py
@@ -208,7 +208,7 @@ project.name = "foo" project.worktree = "/path/to/foo" project.revisionExpr = "refs/heads/main" - project.GetBranches.return_value = [] + project.GetBranches.return_value = {} # GetHeadRevisionId() returns a SHA, it should be used. project.GetHeadRevisionId.return_value = "head_sha_12345" @@ -235,7 +235,9 @@ project.name = "foo" project.worktree = "/path/to/foo" project.revisionExpr = "refs/heads/main" - project.GetBranches.return_value = {"branch1": mock.MagicMock()} + branch = mock.MagicMock() + branch.current = True + project.GetBranches.return_value = {"branch1": branch} project.GetHeadRevisionId.return_value = "head_sha_12345" project.CurrentBranch = "branch1" @@ -253,3 +255,115 @@ assert project_data["manifest_revision"] == "refs/heads/main" assert project_data["local_branches"] == ["branch1"] assert project_data["current_branch"] == "branch1" + + +def test_diff_commits_uses_one_left_right_walk() -> None: + """Local and remote commits are partitioned from one rev-list.""" + project = mock.MagicMock() + project.work_git.rev_list.return_value = [ + "<11111111 local commit", + ">22222222 remote commit", + ] + + local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main") + + assert local == ["11111111 local commit"] + assert remote == ["22222222 remote commit"] + project.work_git.rev_list.assert_called_once_with( + "--left-right", + "--abbrev=8", + "--abbrev-commit", + "--pretty=oneline", + "HEAD...refs/remotes/m/main", + "--", + ) + + +def test_diff_commits_falls_back_to_bare_git_when_no_worktree() -> None: + """Bare or worktree-less projects fall back to bare_git for history walk.""" + project = mock.MagicMock() + project.work_git = None + project.bare_git.rev_list.return_value = [ + "<11111111 local commit", + ">22222222 remote commit", + ] + + local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main") + + assert local == ["11111111 local commit"] + assert remote == ["22222222 remote commit"] + project.bare_git.rev_list.assert_called_once_with( + "--left-right", + "--abbrev=8", + "--abbrev-commit", + "--pretty=oneline", + "HEAD...refs/remotes/m/main", + "--", + ) + + +def test_diff_commits_empty_output() -> None: + """Empty rev-list output produces empty local and remote commit lists.""" + project = mock.MagicMock() + project.work_git.rev_list.return_value = [] + + local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main") + + assert local == [] + assert remote == [] + + +def test_get_current_branch() -> None: + """_GetCurrentBranch identifies the branch with current=True.""" + b1 = mock.MagicMock(current=False) + b2 = mock.MagicMock(current=True) + assert info.Info._GetCurrentBranch({"b1": b1, "b2": b2}) == "b2" + assert info.Info._GetCurrentBranch({"b1": b1}) is None + assert info.Info._GetCurrentBranch({}) is None + + +def test_overview_helper_current_branch_filters_before_uploadable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_OverviewHelper only checks uploadable state for the current branch.""" + project = mock.MagicMock() + project.RelPath.return_value = "proj" + b1 = mock.MagicMock(current=False) + b2 = mock.MagicMock(current=True) + project.GetBranches.return_value = {"b1": b1, "b2": b2} + uploadable = mock.MagicMock(commits=["c1"], date="2026-09-21") + uploadable.name = "b2" + project.GetUploadableBranch.return_value = uploadable + monkeypatch.setattr( + info.Info, + "get_parallel_context", + lambda: {"projects": [project]}, + ) + opt = mock.MagicMock(current_branch=True, this_manifest_only=False) + + result = info.Info._OverviewHelper(0, opt) + + project.GetUploadableBranch.assert_called_once_with("b2") + assert len(result) == 1 + assert result[0].name == "b2" + assert result[0].is_current is True + + +def test_overview_helper_current_branch_detached_head_skips_uploadable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_OverviewHelper skips GetUploadableBranch when detached with -b.""" + project = mock.MagicMock() + b1 = mock.MagicMock(current=False) + project.GetBranches.return_value = {"b1": b1} + monkeypatch.setattr( + info.Info, + "get_parallel_context", + lambda: {"projects": [project]}, + ) + opt = mock.MagicMock(current_branch=True, this_manifest_only=False) + + result = info.Info._OverviewHelper(0, opt) + + project.GetUploadableBranch.assert_not_called() + assert result == []