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 == []