From 27d2232eb3cc4ec91fe6143b5d5c0d6f72861248 Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Fri, 3 Apr 2026 08:00:35 -0700 Subject: [PATCH] info: add --format and --include-summary/--include-projects options Add --format={text,json} to produce machine-readable output, and boolean options to control which sections are displayed: --include-summary / --no-include-summary (default: on) --include-projects / --no-include-projects (default: on) The JSON output respects the include flags, so callers can request only the fields they need (e.g. `repo info --format=json --no-include-projects` for manifest metadata only). Change-Id: I9641bc4023b630d9c61c5170eb86e5f3b787236f Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/569203 Commit-Queue: Carlos Fernandez Reviewed-by: Mike Frysinger Reviewed-by: Gavin Mak Reviewed-by: Carlos Fernandez Tested-by: Carlos Fernandez --- man/repo-info.1 | 19 +++- subcmds/info.py | 166 +++++++++++++++++++++++++----- tests/test_subcmds_info.py | 201 +++++++++++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 29 deletions(-) create mode 100644 tests/test_subcmds_info.py diff --git a/man/repo-info.1 b/man/repo-info.1 index 2cb25809e..b9941c7e9 100644 --- a/man/repo-info.1 +++ b/man/repo-info.1 @@ -1,10 +1,10 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man. -.TH REPO "1" "July 2022" "repo info" "Repo Manual" +.TH REPO "1" "May 2026" "repo info" "Repo Manual" .SH NAME repo \- repo info - manual page for repo info .SH SYNOPSIS .B repo -\fI\,info \/\fR[\fI\,-dl\/\fR] [\fI\,-o \/\fR[\fI\,-c\/\fR]] [\fI\,\/\fR...] +\fI\,info \/\fR[\fI\,-dl\/\fR] [\fI\,-o \/\fR[\fI\,-c\/\fR]] [\fI\,--format=\/\fR] [\fI\,\/\fR...] .SH DESCRIPTION Summary .PP @@ -21,6 +21,18 @@ branches \fB\-o\fR, \fB\-\-overview\fR show overview of all local commits .TP +\fB\-\-include\-summary\fR +include manifest summary (default: true) +.TP +\fB\-\-no\-include\-summary\fR +exclude manifest summary +.TP +\fB\-\-include\-projects\fR +include project details (default: true) +.TP +\fB\-\-no\-include\-projects\fR +exclude project details +.TP \fB\-c\fR, \fB\-\-current\-branch\fR consider only checked out branches .TP @@ -29,6 +41,9 @@ consider all local branches .TP \fB\-l\fR, \fB\-\-local\-only\fR disable all remote operations +.TP +\fB\-\-format\fR=\fI\,FORMAT\/\fR +output format: text, json (default: text) .SS Logging options: .TP \fB\-v\fR, \fB\-\-verbose\fR diff --git a/subcmds/info.py b/subcmds/info.py index 42c9c563e..e1070cb35 100644 --- a/subcmds/info.py +++ b/subcmds/info.py @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import enum +import json import optparse +import sys +from typing import Any, Dict from color import Coloring from command import PagedCommand @@ -20,6 +24,16 @@ from git_refs import R_HEADS from git_refs import R_M +class OutputFormat(enum.Enum): + """Type for the requested output format.""" + + # Human-readable text output. + TEXT = enum.auto() + + # Machine-readable JSON output. + JSON = enum.auto() + + class _Coloring(Coloring): def __init__(self, config): Coloring.__init__(self, config, "status") @@ -30,7 +44,7 @@ class Info(PagedCommand): helpSummary = ( "Get info on the manifest branch, current branch or unmerged branches" ) - helpUsage = "%prog [-dl] [-o [-c]] [...]" + helpUsage = "%prog [-dl] [-o [-c]] [--format=] [...]" def _Options(self, p): p.add_option( @@ -46,6 +60,30 @@ class Info(PagedCommand): action="store_true", help="show overview of all local commits", ) + p.add_option( + "--include-summary", + action="store_true", + default=True, + help="include manifest summary (default: true)", + ) + p.add_option( + "--no-include-summary", + dest="include_summary", + action="store_false", + help="exclude manifest summary", + ) + p.add_option( + "--include-projects", + action="store_true", + default=True, + help="include project details (default: true)", + ) + p.add_option( + "--no-include-projects", + dest="include_projects", + action="store_false", + help="exclude project details", + ) p.add_option( "-c", "--current-branch", @@ -72,8 +110,37 @@ class Info(PagedCommand): action="store_true", help="disable all remote operations", ) + formats = tuple(x.lower() for x in OutputFormat.__members__.keys()) + p.add_option( + "--format", + default=OutputFormat.TEXT.name.lower(), + choices=formats, + help=f"output format: {', '.join(formats)} (default: %default)", + ) + + def WantPager(self, opt): + return OutputFormat[opt.format.upper()] == OutputFormat.TEXT + + def ValidateOptions(self, opt, args): + output_format = OutputFormat[opt.format.upper()] + if output_format == OutputFormat.JSON: + if opt.all: + self.OptionParser.error("--diff is not supported with JSON") + if opt.overview: + self.OptionParser.error("--overview is not supported with JSON") def Execute(self, opt, args): + if not opt.this_manifest_only: + self.manifest = self.manifest.outer_client + + output_format = OutputFormat[opt.format.upper()] + if output_format == OutputFormat.JSON: + self._ExecuteJson(opt, args) + else: + self._ExecuteText(opt, args) + + def _ExecuteText(self, opt, args) -> None: + """Output info as human-readable text.""" self.out = _Coloring(self.client.globalConfig) self.heading = self.out.printer("heading", attr="bold") self.headtext = self.out.nofmt_printer("headtext", fg="yellow") @@ -84,37 +151,82 @@ class Info(PagedCommand): self.opt = opt - if not opt.this_manifest_only: - self.manifest = self.manifest.outer_client - manifestConfig = self.manifest.manifestProject.config - mergeBranch = manifestConfig.GetBranch("default").merge - manifestGroups = self.manifest.GetManifestGroupsStr() + if opt.include_summary: + self._printSummary() - self.heading("Manifest branch: ") - if self.manifest.default.revisionExpr: - self.headtext(self.manifest.default.revisionExpr) - self.out.nl() - self.heading("Manifest merge branch: ") - # The manifest might not have a merge branch if it isn't in a git repo, - # e.g. if `repo init --standalone-manifest` is used. - self.headtext(mergeBranch or "") - self.out.nl() - self.heading("Manifest groups: ") - self.headtext(manifestGroups) - self.out.nl() - sp = self.manifest.superproject - srev = sp.commit_id if sp and sp.commit_id else "None" - self.heading("Superproject revision: ") - self.headtext(srev) - self.out.nl() - - self.printSeparator() - - if not opt.overview: + if not opt.include_projects: + return + elif not opt.overview: self._printDiffInfo(opt, args) else: self._printCommitOverview(opt, args) + def _getSummaryData(self) -> Dict[str, Any]: + """Gather manifest summary data as a dict.""" + manifestConfig = self.manifest.manifestProject.config + mergeBranch = manifestConfig.GetBranch("default").merge + manifestGroups = self.manifest.GetManifestGroupsStr() + sp = self.manifest.superproject + srev = sp.commit_id if sp and sp.commit_id else None + return { + "manifest_branch": self.manifest.default.revisionExpr or "", + "manifest_merge_branch": mergeBranch or "", + "manifest_groups": manifestGroups, + "superproject_revision": srev, + } + + def _getProjectData(self, project) -> Dict[str, Any]: + """Gather project data as a dict.""" + data = { + "name": project.name, + "mount_path": project.worktree, + "current_revision": project.GetRevisionId(), + "manifest_revision": project.revisionExpr, + "local_branches": list(project.GetBranches()), + } + currentBranch = project.CurrentBranch + if currentBranch: + data["current_branch"] = currentBranch + return data + + def _ExecuteJson(self, opt, args) -> None: + """Output info as JSON.""" + result = {} + if opt.include_summary: + result["summary"] = self._getSummaryData() + if opt.include_projects: + projs = self.GetProjects( + args, all_manifests=not opt.this_manifest_only + ) + result["projects"] = [self._getProjectData(p) for p in projs] + + json_settings = { + # JSON style guide says Unicode characters are fully allowed. + "ensure_ascii": False, + # We use 2 space indent to match JSON style guide. + "indent": 2, + "separators": (",", ": "), + "sort_keys": True, + } + sys.stdout.write(json.dumps(result, **json_settings) + "\n") + + def _printSummary(self) -> None: + """Print manifest summary in text format.""" + data = self._getSummaryData() + self.heading("Manifest branch: ") + self.headtext(data["manifest_branch"]) + self.out.nl() + self.heading("Manifest merge branch: ") + self.headtext(data["manifest_merge_branch"]) + self.out.nl() + self.heading("Manifest groups: ") + self.headtext(data["manifest_groups"]) + self.out.nl() + self.heading("Superproject revision: ") + self.headtext(data["superproject_revision"] or "None") + self.out.nl() + self.printSeparator() + def printSeparator(self): self.text("----------------------------") self.out.nl() diff --git a/tests/test_subcmds_info.py b/tests/test_subcmds_info.py new file mode 100644 index 000000000..788ea9e26 --- /dev/null +++ b/tests/test_subcmds_info.py @@ -0,0 +1,201 @@ +# Copyright (C) 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unittests for the subcmds/info.py module.""" + +import json +from unittest import mock + +import pytest + +from subcmds import info + + +def _get_cmd() -> info.Info: + """Build a mock-backed Info command for testing.""" + manifest = mock.MagicMock() + manifest.default.revisionExpr = "refs/heads/main" + manifest.manifestProject.config.GetBranch.return_value.merge = ( + "refs/heads/main" + ) + manifest.GetManifestGroupsStr.return_value = "all" + manifest.superproject = None + manifest.outer_client = manifest + + client = mock.MagicMock() + git_event_log = mock.MagicMock() + + return info.Info( + manifest=manifest, + client=client, + git_event_log=git_event_log, + ) + + +def test_include_options_default_true() -> None: + """Both include options should default to True.""" + opts, _ = _get_cmd().OptionParser.parse_args([]) + assert opts.include_summary + assert opts.include_projects + + +def test_no_include_summary_parses() -> None: + """--no-include-summary should set include_summary to False.""" + opts, _ = _get_cmd().OptionParser.parse_args(["--no-include-summary"]) + assert not opts.include_summary + + +def test_no_include_projects_parses() -> None: + """--no-include-projects should set include_projects to False.""" + opts, _ = _get_cmd().OptionParser.parse_args(["--no-include-projects"]) + assert not opts.include_projects + + +def test_format_default_text() -> None: + """Default format should be text.""" + opts, _ = _get_cmd().OptionParser.parse_args([]) + assert opts.format == "text" + + +def test_format_json_parses() -> None: + """--format=json should be accepted.""" + opts, _ = _get_cmd().OptionParser.parse_args(["--format=json"]) + assert opts.format == "json" + + +def test_no_include_projects_skips_projects() -> None: + """--no-include-projects should skip project iteration.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args(["--no-include-projects"]) + + with mock.patch.object( + cmd, "_printDiffInfo" + ) as mock_diff, mock.patch.object( + cmd, "_printCommitOverview" + ) as mock_overview: + cmd.Execute(opts, args) + mock_diff.assert_not_called() + mock_overview.assert_not_called() + + +def test_no_include_summary_skips_summary() -> None: + """--no-include-summary should not query or print manifest metadata.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args(["--no-include-summary"]) + + with mock.patch.object(cmd, "_printDiffInfo"): + cmd.Execute(opts, args) + cmd.manifest.GetManifestGroupsStr.assert_not_called() + + +def test_default_calls_diff_info() -> None: + """Default options should call _printDiffInfo.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args([]) + + with mock.patch.object( + cmd, "_printDiffInfo" + ) as mock_diff, mock.patch.object( + cmd, "_printCommitOverview" + ) as mock_overview: + cmd.Execute(opts, args) + mock_diff.assert_called_once_with(opts, args) + mock_overview.assert_not_called() + + +def test_overview_calls_commit_overview() -> None: + """--overview should call _printCommitOverview, not _printDiffInfo.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args(["--overview"]) + + with mock.patch.object( + cmd, "_printDiffInfo" + ) as mock_diff, mock.patch.object( + cmd, "_printCommitOverview" + ) as mock_overview: + cmd.Execute(opts, args) + mock_diff.assert_not_called() + mock_overview.assert_called_once_with(opts, args) + + +def test_no_include_projects_with_overview() -> None: + """--no-include-projects should take priority over --overview.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args( + ["--no-include-projects", "--overview"] + ) + + with mock.patch.object( + cmd, "_printDiffInfo" + ) as mock_diff, mock.patch.object( + cmd, "_printCommitOverview" + ) as mock_overview: + cmd.Execute(opts, args) + mock_diff.assert_not_called() + mock_overview.assert_not_called() + + +def test_json_summary_only(capsys) -> None: + """--format=json --no-include-projects should emit only summary.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args( + ["--format=json", "--no-include-projects"] + ) + cmd.Execute(opts, args) + data = json.loads(capsys.readouterr().out) + assert "summary" in data + assert "projects" not in data + assert data["summary"]["manifest_branch"] == "refs/heads/main" + assert data["summary"]["manifest_groups"] == "all" + + +def test_json_no_summary(capsys) -> None: + """--format=json --no-include-summary should omit summary.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args( + ["--format=json", "--no-include-summary", "--no-include-projects"] + ) + cmd.Execute(opts, args) + data = json.loads(capsys.readouterr().out) + assert "summary" not in data + + +def test_json_rejects_diff() -> None: + """--format=json --diff should be rejected.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args(["--format=json", "--diff"]) + with pytest.raises(SystemExit): + cmd.ValidateOptions(opts, args) + + +def test_json_rejects_overview() -> None: + """--format=json --overview should be rejected.""" + cmd = _get_cmd() + opts, args = cmd.OptionParser.parse_args(["--format=json", "--overview"]) + with pytest.raises(SystemExit): + cmd.ValidateOptions(opts, args) + + +def test_json_disables_pager() -> None: + """--format=json should disable the pager.""" + cmd = _get_cmd() + opts, _ = cmd.OptionParser.parse_args(["--format=json"]) + assert not cmd.WantPager(opts) + + +def test_text_enables_pager() -> None: + """Default text format should enable the pager.""" + cmd = _get_cmd() + opts, _ = cmd.OptionParser.parse_args([]) + assert cmd.WantPager(opts)