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)