diff options
| -rw-r--r-- | subcmds/info.py | 84 | ||||
| -rw-r--r-- | tests/test_subcmds_info.py | 118 |
2 files changed, 174 insertions, 28 deletions
diff --git a/subcmds/info.py b/subcmds/info.py index 594e633a3..e120cae33 100644 --- a/subcmds/info.py +++ b/subcmds/info.py | |||
| @@ -18,7 +18,7 @@ import io | |||
| 18 | import json | 18 | import json |
| 19 | import optparse | 19 | import optparse |
| 20 | import sys | 20 | import sys |
| 21 | from typing import Any, Dict, List, NamedTuple | 21 | from typing import Any, Dict, List, NamedTuple, Optional, Tuple |
| 22 | 22 | ||
| 23 | from color import Coloring | 23 | from color import Coloring |
| 24 | from command import DEFAULT_LOCAL_JOBS | 24 | from command import DEFAULT_LOCAL_JOBS |
| @@ -189,18 +189,26 @@ class Info(PagedCommand): | |||
| 189 | "superproject_revision": srev, | 189 | "superproject_revision": srev, |
| 190 | } | 190 | } |
| 191 | 191 | ||
| 192 | @staticmethod | ||
| 193 | def _GetCurrentBranch(branches: Dict[str, Any]) -> Optional[str]: | ||
| 194 | """Return the name of the current branch from a GetBranches mapping.""" | ||
| 195 | return next( | ||
| 196 | (name for name, branch in branches.items() if branch.current), None | ||
| 197 | ) | ||
| 198 | |||
| 192 | @classmethod | 199 | @classmethod |
| 193 | def _getProjectData(cls, project) -> Dict[str, Any]: | 200 | def _getProjectData(cls, project) -> Dict[str, Any]: |
| 194 | """Gather project data as a dict.""" | 201 | """Gather project data as a dict.""" |
| 202 | branches = project.GetBranches() | ||
| 203 | currentBranch = cls._GetCurrentBranch(branches) | ||
| 195 | data = { | 204 | data = { |
| 196 | "name": project.name, | 205 | "name": project.name, |
| 197 | "mount_path": project.worktree, | 206 | "mount_path": project.worktree, |
| 198 | "current_revision": project.GetHeadRevisionId() | 207 | "current_revision": project.GetHeadRevisionId() |
| 199 | or project.GetRevisionId(), | 208 | or project.GetRevisionId(), |
| 200 | "manifest_revision": project.revisionExpr, | 209 | "manifest_revision": project.revisionExpr, |
| 201 | "local_branches": list(project.GetBranches()), | 210 | "local_branches": list(branches), |
| 202 | } | 211 | } |
| 203 | currentBranch = project.CurrentBranch | ||
| 204 | if currentBranch: | 212 | if currentBranch: |
| 205 | data["current_branch"] = currentBranch | 213 | data["current_branch"] = currentBranch |
| 206 | return data | 214 | return data |
| @@ -285,6 +293,9 @@ class Info(PagedCommand): | |||
| 285 | text = out.nofmt_printer("text") | 293 | text = out.nofmt_printer("text") |
| 286 | dimtext = out.printer("dimtext", attr="dim") | 294 | dimtext = out.printer("dimtext", attr="dim") |
| 287 | 295 | ||
| 296 | branches = project.GetBranches() | ||
| 297 | currentBranch = cls._GetCurrentBranch(branches) | ||
| 298 | |||
| 288 | heading("Project: ") | 299 | heading("Project: ") |
| 289 | headtext(project.name) | 300 | headtext(project.name) |
| 290 | out.nl() | 301 | out.nl() |
| @@ -297,7 +308,6 @@ class Info(PagedCommand): | |||
| 297 | headtext(project.GetHeadRevisionId() or project.GetRevisionId()) | 308 | headtext(project.GetHeadRevisionId() or project.GetRevisionId()) |
| 298 | out.nl() | 309 | out.nl() |
| 299 | 310 | ||
| 300 | currentBranch = project.CurrentBranch | ||
| 301 | if currentBranch: | 311 | if currentBranch: |
| 302 | heading("Current branch: ") | 312 | heading("Current branch: ") |
| 303 | headtext(currentBranch) | 313 | headtext(currentBranch) |
| @@ -307,7 +317,7 @@ class Info(PagedCommand): | |||
| 307 | headtext(project.revisionExpr) | 317 | headtext(project.revisionExpr) |
| 308 | out.nl() | 318 | out.nl() |
| 309 | 319 | ||
| 310 | localBranches = list(project.GetBranches().keys()) | 320 | localBranches = list(branches) |
| 311 | heading("Local Branches: ") | 321 | heading("Local Branches: ") |
| 312 | redtext(str(len(localBranches))) | 322 | redtext(str(len(localBranches))) |
| 313 | if localBranches: | 323 | if localBranches: |
| @@ -327,25 +337,10 @@ class Info(PagedCommand): | |||
| 327 | branch = branch[len(R_HEADS) :] | 337 | branch = branch[len(R_HEADS) :] |
| 328 | logTarget = R_M + branch | 338 | logTarget = R_M + branch |
| 329 | 339 | ||
| 330 | bareTmp = project.bare_git._bare | 340 | localCommits, originCommits = cls._GetDiffCommits( |
| 331 | project.bare_git._bare = False | 341 | project, logTarget |
| 332 | localCommits = project.bare_git.rev_list( | ||
| 333 | "--abbrev=8", | ||
| 334 | "--abbrev-commit", | ||
| 335 | "--pretty=oneline", | ||
| 336 | logTarget + "..", | ||
| 337 | "--", | ||
| 338 | ) | 342 | ) |
| 339 | 343 | ||
| 340 | originCommits = project.bare_git.rev_list( | ||
| 341 | "--abbrev=8", | ||
| 342 | "--abbrev-commit", | ||
| 343 | "--pretty=oneline", | ||
| 344 | ".." + logTarget, | ||
| 345 | "--", | ||
| 346 | ) | ||
| 347 | project.bare_git._bare = bareTmp | ||
| 348 | |||
| 349 | heading("Local Commits: ") | 344 | heading("Local Commits: ") |
| 350 | redtext(str(len(localCommits))) | 345 | redtext(str(len(localCommits))) |
| 351 | dimtext(" (on current branch)") | 346 | dimtext(" (on current branch)") |
| @@ -375,6 +370,35 @@ class Info(PagedCommand): | |||
| 375 | 370 | ||
| 376 | return buf.getvalue() | 371 | return buf.getvalue() |
| 377 | 372 | ||
| 373 | @classmethod | ||
| 374 | def _GetDiffCommits( | ||
| 375 | cls, project: Any, log_target: str | ||
| 376 | ) -> Tuple[List[str], List[str]]: | ||
| 377 | """Return local-only and remote-only commits from one history walk.""" | ||
| 378 | git = getattr(project, "work_git", None) or getattr( | ||
| 379 | project, "bare_git", None | ||
| 380 | ) | ||
| 381 | if git is None: | ||
| 382 | return [], [] | ||
| 383 | commits = git.rev_list( | ||
| 384 | "--left-right", | ||
| 385 | "--abbrev=8", | ||
| 386 | "--abbrev-commit", | ||
| 387 | "--pretty=oneline", | ||
| 388 | f"HEAD...{log_target}", | ||
| 389 | "--", | ||
| 390 | ) | ||
| 391 | if isinstance(commits, str): | ||
| 392 | commits = commits.splitlines() | ||
| 393 | local = [] | ||
| 394 | remote = [] | ||
| 395 | for commit in commits: | ||
| 396 | if commit.startswith("<"): | ||
| 397 | local.append(commit[1:]) | ||
| 398 | elif commit.startswith(">"): | ||
| 399 | remote.append(commit[1:]) | ||
| 400 | return local, remote | ||
| 401 | |||
| 378 | def _printDiffInfo(self, opt, args): | 402 | def _printDiffInfo(self, opt, args): |
| 379 | projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only) | 403 | projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only) |
| 380 | 404 | ||
| @@ -404,10 +428,18 @@ class Info(PagedCommand): | |||
| 404 | project = cls.get_parallel_context()["projects"][project_idx] | 428 | project = cls.get_parallel_context()["projects"][project_idx] |
| 405 | 429 | ||
| 406 | branches = [] | 430 | branches = [] |
| 407 | br = [project.GetUploadableBranch(x) for x in project.GetBranches()] | 431 | local_branches = project.GetBranches() |
| 408 | br = [x for x in br if x] | 432 | current_branch = cls._GetCurrentBranch(local_branches) |
| 409 | if opt.current_branch: | 433 | if opt.current_branch: |
| 410 | br = [x for x in br if x.name == project.CurrentBranch] | 434 | candidate_branches = ( |
| 435 | [current_branch] | ||
| 436 | if current_branch and current_branch in local_branches | ||
| 437 | else [] | ||
| 438 | ) | ||
| 439 | else: | ||
| 440 | candidate_branches = local_branches | ||
| 441 | br = [project.GetUploadableBranch(x) for x in candidate_branches] | ||
| 442 | br = [x for x in br if x] | ||
| 411 | 443 | ||
| 412 | for b in br: | 444 | for b in br: |
| 413 | branches.append( | 445 | branches.append( |
| @@ -416,7 +448,7 @@ class Info(PagedCommand): | |||
| 416 | name=b.name, | 448 | name=b.name, |
| 417 | commits=b.commits, | 449 | commits=b.commits, |
| 418 | date=b.date, | 450 | date=b.date, |
| 419 | is_current=b.name == project.CurrentBranch, | 451 | is_current=b.name == current_branch, |
| 420 | ) | 452 | ) |
| 421 | ) | 453 | ) |
| 422 | return branches | 454 | return branches |
diff --git a/tests/test_subcmds_info.py b/tests/test_subcmds_info.py index 922b1088c..aa69b7b48 100644 --- a/tests/test_subcmds_info.py +++ b/tests/test_subcmds_info.py | |||
| @@ -208,7 +208,7 @@ def test_get_project_data_uses_head_revision() -> None: | |||
| 208 | project.name = "foo" | 208 | project.name = "foo" |
| 209 | project.worktree = "/path/to/foo" | 209 | project.worktree = "/path/to/foo" |
| 210 | project.revisionExpr = "refs/heads/main" | 210 | project.revisionExpr = "refs/heads/main" |
| 211 | project.GetBranches.return_value = [] | 211 | project.GetBranches.return_value = {} |
| 212 | 212 | ||
| 213 | # GetHeadRevisionId() returns a SHA, it should be used. | 213 | # GetHeadRevisionId() returns a SHA, it should be used. |
| 214 | project.GetHeadRevisionId.return_value = "head_sha_12345" | 214 | project.GetHeadRevisionId.return_value = "head_sha_12345" |
| @@ -235,7 +235,9 @@ def test_json_with_projects(capsys) -> None: | |||
| 235 | project.name = "foo" | 235 | project.name = "foo" |
| 236 | project.worktree = "/path/to/foo" | 236 | project.worktree = "/path/to/foo" |
| 237 | project.revisionExpr = "refs/heads/main" | 237 | project.revisionExpr = "refs/heads/main" |
| 238 | project.GetBranches.return_value = {"branch1": mock.MagicMock()} | 238 | branch = mock.MagicMock() |
| 239 | branch.current = True | ||
| 240 | project.GetBranches.return_value = {"branch1": branch} | ||
| 239 | project.GetHeadRevisionId.return_value = "head_sha_12345" | 241 | project.GetHeadRevisionId.return_value = "head_sha_12345" |
| 240 | project.CurrentBranch = "branch1" | 242 | project.CurrentBranch = "branch1" |
| 241 | 243 | ||
| @@ -253,3 +255,115 @@ def test_json_with_projects(capsys) -> None: | |||
| 253 | assert project_data["manifest_revision"] == "refs/heads/main" | 255 | assert project_data["manifest_revision"] == "refs/heads/main" |
| 254 | assert project_data["local_branches"] == ["branch1"] | 256 | assert project_data["local_branches"] == ["branch1"] |
| 255 | assert project_data["current_branch"] == "branch1" | 257 | assert project_data["current_branch"] == "branch1" |
| 258 | |||
| 259 | |||
| 260 | def test_diff_commits_uses_one_left_right_walk() -> None: | ||
| 261 | """Local and remote commits are partitioned from one rev-list.""" | ||
| 262 | project = mock.MagicMock() | ||
| 263 | project.work_git.rev_list.return_value = [ | ||
| 264 | "<11111111 local commit", | ||
| 265 | ">22222222 remote commit", | ||
| 266 | ] | ||
| 267 | |||
| 268 | local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main") | ||
| 269 | |||
| 270 | assert local == ["11111111 local commit"] | ||
| 271 | assert remote == ["22222222 remote commit"] | ||
| 272 | project.work_git.rev_list.assert_called_once_with( | ||
| 273 | "--left-right", | ||
| 274 | "--abbrev=8", | ||
| 275 | "--abbrev-commit", | ||
| 276 | "--pretty=oneline", | ||
| 277 | "HEAD...refs/remotes/m/main", | ||
| 278 | "--", | ||
| 279 | ) | ||
| 280 | |||
| 281 | |||
| 282 | def test_diff_commits_falls_back_to_bare_git_when_no_worktree() -> None: | ||
| 283 | """Bare or worktree-less projects fall back to bare_git for history walk.""" | ||
| 284 | project = mock.MagicMock() | ||
| 285 | project.work_git = None | ||
| 286 | project.bare_git.rev_list.return_value = [ | ||
| 287 | "<11111111 local commit", | ||
| 288 | ">22222222 remote commit", | ||
| 289 | ] | ||
| 290 | |||
| 291 | local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main") | ||
| 292 | |||
| 293 | assert local == ["11111111 local commit"] | ||
| 294 | assert remote == ["22222222 remote commit"] | ||
| 295 | project.bare_git.rev_list.assert_called_once_with( | ||
| 296 | "--left-right", | ||
| 297 | "--abbrev=8", | ||
| 298 | "--abbrev-commit", | ||
| 299 | "--pretty=oneline", | ||
| 300 | "HEAD...refs/remotes/m/main", | ||
| 301 | "--", | ||
| 302 | ) | ||
| 303 | |||
| 304 | |||
| 305 | def test_diff_commits_empty_output() -> None: | ||
| 306 | """Empty rev-list output produces empty local and remote commit lists.""" | ||
| 307 | project = mock.MagicMock() | ||
| 308 | project.work_git.rev_list.return_value = [] | ||
| 309 | |||
| 310 | local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main") | ||
| 311 | |||
| 312 | assert local == [] | ||
| 313 | assert remote == [] | ||
| 314 | |||
| 315 | |||
| 316 | def test_get_current_branch() -> None: | ||
| 317 | """_GetCurrentBranch identifies the branch with current=True.""" | ||
| 318 | b1 = mock.MagicMock(current=False) | ||
| 319 | b2 = mock.MagicMock(current=True) | ||
| 320 | assert info.Info._GetCurrentBranch({"b1": b1, "b2": b2}) == "b2" | ||
| 321 | assert info.Info._GetCurrentBranch({"b1": b1}) is None | ||
| 322 | assert info.Info._GetCurrentBranch({}) is None | ||
| 323 | |||
| 324 | |||
| 325 | def test_overview_helper_current_branch_filters_before_uploadable( | ||
| 326 | monkeypatch: pytest.MonkeyPatch, | ||
| 327 | ) -> None: | ||
| 328 | """_OverviewHelper only checks uploadable state for the current branch.""" | ||
| 329 | project = mock.MagicMock() | ||
| 330 | project.RelPath.return_value = "proj" | ||
| 331 | b1 = mock.MagicMock(current=False) | ||
| 332 | b2 = mock.MagicMock(current=True) | ||
| 333 | project.GetBranches.return_value = {"b1": b1, "b2": b2} | ||
| 334 | uploadable = mock.MagicMock(commits=["c1"], date="2026-09-21") | ||
| 335 | uploadable.name = "b2" | ||
| 336 | project.GetUploadableBranch.return_value = uploadable | ||
| 337 | monkeypatch.setattr( | ||
| 338 | info.Info, | ||
| 339 | "get_parallel_context", | ||
| 340 | lambda: {"projects": [project]}, | ||
| 341 | ) | ||
| 342 | opt = mock.MagicMock(current_branch=True, this_manifest_only=False) | ||
| 343 | |||
| 344 | result = info.Info._OverviewHelper(0, opt) | ||
| 345 | |||
| 346 | project.GetUploadableBranch.assert_called_once_with("b2") | ||
| 347 | assert len(result) == 1 | ||
| 348 | assert result[0].name == "b2" | ||
| 349 | assert result[0].is_current is True | ||
| 350 | |||
| 351 | |||
| 352 | def test_overview_helper_current_branch_detached_head_skips_uploadable( | ||
| 353 | monkeypatch: pytest.MonkeyPatch, | ||
| 354 | ) -> None: | ||
| 355 | """_OverviewHelper skips GetUploadableBranch when detached with -b.""" | ||
| 356 | project = mock.MagicMock() | ||
| 357 | b1 = mock.MagicMock(current=False) | ||
| 358 | project.GetBranches.return_value = {"b1": b1} | ||
| 359 | monkeypatch.setattr( | ||
| 360 | info.Info, | ||
| 361 | "get_parallel_context", | ||
| 362 | lambda: {"projects": [project]}, | ||
| 363 | ) | ||
| 364 | opt = mock.MagicMock(current_branch=True, this_manifest_only=False) | ||
| 365 | |||
| 366 | result = info.Info._OverviewHelper(0, opt) | ||
| 367 | |||
| 368 | project.GetUploadableBranch.assert_not_called() | ||
| 369 | assert result == [] | ||
