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 <carlosfsanz@meta.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Carlos Fernandez <carlosfsanz@meta.com>
Tested-by: Carlos Fernandez <carlosfsanz@meta.com>
diff --git a/man/repo-info.1 b/man/repo-info.1
index 2cb2580..b9941c7 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\,<project>\/\fR...]
+\fI\,info \/\fR[\fI\,-dl\/\fR] [\fI\,-o \/\fR[\fI\,-c\/\fR]] [\fI\,--format=<format>\/\fR] [\fI\,<project>\/\fR...]
.SH DESCRIPTION
Summary
.PP
@@ -21,6 +21,18 @@
\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 @@
.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 42c9c56..e1070cb 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_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 @@
helpSummary = (
"Get info on the manifest branch, current branch or unmerged branches"
)
- helpUsage = "%prog [-dl] [-o [-c]] [<project>...]"
+ helpUsage = "%prog [-dl] [-o [-c]] [--format=<format>] [<project>...]"
def _Options(self, p):
p.add_option(
@@ -47,6 +61,30 @@
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",
action="store_true",
@@ -72,8 +110,37 @@
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 @@
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 0000000..788ea9e
--- /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)