project: render worktree status from one snapshot

Use porcelain-v2 branch and path records to replace the status display
fan-out with one Git process on Git 2.11 and newer. Preserve the legacy
renderer for older clients, force rename and ahead/behind behavior where
supported, and fall back to the old divergence query only when status
cannot supply branch.ab.

Bug: 543851900
Bug: 553599402
Change-Id: Ic760360f4f48ab9320abf69dbbb7310dab4ac649
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632144
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
diff --git a/git_status.py b/git_status.py
index 2570314..473ad3d 100644
--- a/git_status.py
+++ b/git_status.py
@@ -91,8 +91,10 @@
     ]
     if branch:
         cmd.append("--branch")
-        if ahead_behind and git_require((2, 17, 0)):
-            cmd.append("--ahead-behind")
+        if git_require((2, 17, 0)):
+            cmd.append(
+                "--ahead-behind" if ahead_behind else "--no-ahead-behind"
+            )
     if git_require((2, 18, 0)):
         # Match the existing staged diff's explicit rename detection even if
         # status.renames is disabled in the user's config.
diff --git a/main.py b/main.py
index 86a01a6..49093c5 100755
--- a/main.py
+++ b/main.py
@@ -104,6 +104,11 @@
 KEYBOARD_INTERRUPT_EXIT = 128 + signal.SIGINT
 MAX_PRINT_ERRORS = 5
 
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(errors="surrogateescape")
+if hasattr(sys.stderr, "reconfigure"):
+    sys.stderr.reconfigure(errors="surrogateescape")
+
 global_options = optparse.OptionParser(
     usage="repo [-p|--paginate|--no-pager] COMMAND [ARGS]",
     add_help_option=False,
diff --git a/project.py b/project.py
index 0cf1d37..5d7f32d 100644
--- a/project.py
+++ b/project.py
@@ -29,7 +29,7 @@
 import tarfile
 import tempfile
 import time
-from typing import Dict, List, NamedTuple, Optional
+from typing import Any, Dict, List, NamedTuple, Optional, Tuple
 import urllib.parse
 
 from color import Coloring
@@ -1106,7 +1106,12 @@
         """Returns true if there are uncommitted changes."""
         return bool(self.UncommittedFiles(get_all=False))
 
-    def PrintWorkTreeStatus(self, output_redir=None, quiet=False, local=False):
+    def PrintWorkTreeStatus(
+        self,
+        output_redir: Any = None,
+        quiet: bool = False,
+        local: bool = False,
+    ) -> Optional[str]:
         """Prints the status of the repository to stdout.
 
         Args:
@@ -1125,12 +1130,103 @@
             print('  missing (run "repo sync")', file=output_redir)
             return
 
+        status = self._GetStatusSnapshot(
+            untracked_files="all",
+            branch=True,
+            ahead_behind=not quiet,
+        )
+        if status is not None:
+            ahead = status.ahead
+            behind = status.behind
+            if (
+                not quiet
+                and status.current_branch is not None
+                and not status.has_ahead_behind
+            ):
+                ahead, behind = self._GetBranchAheadBehind(
+                    status.current_branch
+                )
+            return self._RenderWorkTreeStatus(
+                status.index_changes,
+                status.worktree_changes,
+                status.untracked,
+                self.IsRebaseInProgress(),
+                status.current_branch,
+                ahead,
+                behind,
+                output_redir=output_redir,
+                quiet=quiet,
+                local=local,
+            )
+
+        return self._PrintWorkTreeStatusLegacy(
+            output_redir=output_redir, quiet=quiet, local=local
+        )
+
+    def _PrintWorkTreeStatusLegacy(
+        self, output_redir: Any = None, quiet: bool = False, local: bool = False
+    ) -> str:
+        """Render status using plumbing supported by older Git."""
         self._RefreshIndexStatCache()
         rb = self.IsRebaseInProgress()
         di = self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD)
         df = self.work_git.DiffZ("diff-files")
         do = self.work_git.LsOthers()
-        if not rb and not di and not df and not do and not self.CurrentBranch:
+        if quiet and (rb or di or df or do):
+            branch_name = None
+        else:
+            branch_name = self.CurrentBranch
+        ahead = behind = 0
+        if branch_name is not None and not quiet:
+            ahead, behind = self._GetBranchAheadBehind(branch_name)
+
+        return self._RenderWorkTreeStatus(
+            di,
+            df,
+            do,
+            rb,
+            branch_name,
+            ahead,
+            behind,
+            output_redir=output_redir,
+            quiet=quiet,
+            local=local,
+        )
+
+    def _GetBranchAheadBehind(self, branch_name: str) -> Tuple[int, int]:
+        """Return divergence when status could not supply branch.ab."""
+        ahead = behind = 0
+        branch_obj = self.GetBranch(branch_name)
+        try:
+            local_merge = branch_obj.LocalMerge
+            if local_merge:
+                left_right = self.work_git.rev_list(
+                    "--left-right",
+                    "--count",
+                    f"{local_merge}...{R_HEADS}{branch_name}",
+                )
+                left, right = left_right[0].split()
+                behind = int(left)
+                ahead = int(right)
+        except (GitError, IndexError, ValueError):
+            pass
+        return ahead, behind
+
+    def _RenderWorkTreeStatus(
+        self,
+        di: Any,
+        df: Any,
+        do: Any,
+        rb: bool,
+        branch_name: Optional[str],
+        ahead: int,
+        behind: int,
+        output_redir: Any = None,
+        quiet: bool = False,
+        local: bool = False,
+    ) -> str:
+        """Render a normalized worktree snapshot."""
+        if not rb and not di and not df and not do and branch_name is None:
             return "CLEAN"
 
         out = StatusColoring(self.config)
@@ -1142,31 +1238,16 @@
             out.nl()
             return "DIRTY"
 
-        branch_name = self.CurrentBranch
         if branch_name is None:
             out.nobranch("(*** NO BRANCH ***)")
         else:
-            branch_obj = self.GetBranch(branch_name)
             ahead_behind = ""
-            try:
-                local_merge = branch_obj.LocalMerge
-                if local_merge:
-                    left_right = self.work_git.rev_list(
-                        "--left-right",
-                        "--count",
-                        f"{local_merge}...{R_HEADS}{branch_name}",
-                    )
-                    left, right = left_right[0].split()
-                    behind = int(left)
-                    ahead = int(right)
-                    if ahead and behind:
-                        ahead_behind = f" [ahead {ahead}, behind {behind}]"
-                    elif ahead:
-                        ahead_behind = f" [ahead {ahead}]"
-                    elif behind:
-                        ahead_behind = f" [behind {behind}]"
-            except GitError:
-                pass
+            if ahead and behind:
+                ahead_behind = f" [ahead {ahead}, behind {behind}]"
+            elif ahead:
+                ahead_behind = f" [ahead {ahead}]"
+            elif behind:
+                ahead_behind = f" [behind {behind}]"
             out.branch("branch %s%s", branch_name, ahead_behind)
         out.nl()
 
@@ -1174,6 +1255,19 @@
             out.important("prior sync failed; rebase still in progress")
             out.nl()
 
+        def _SafePath(path_str: str) -> str:
+            stream = output_redir if output_redir is not None else sys.stdout
+            encoding = getattr(stream, "encoding", None) or "utf-8"
+            errors = getattr(stream, "errors", None)
+            if errors not in ("surrogateescape", "backslashreplace", "replace"):
+                try:
+                    path_str.encode(encoding)
+                except UnicodeEncodeError:
+                    return path_str.encode(encoding, "backslashreplace").decode(
+                        encoding
+                    )
+            return path_str
+
         paths = []
         paths.extend(di.keys())
         paths.extend(df.keys())
@@ -1200,12 +1294,15 @@
             else:
                 f_status = "-"
 
+            disp_p = _SafePath(p)
             if i and i.src_path:
+                disp_src = _SafePath(i.src_path)
                 line = (
-                    f" {i_status}{f_status}\t{i.src_path} => {p} ({i.level}%)"
+                    f" {i_status}{f_status}\t"
+                    f"{disp_src} => {disp_p} ({i.level}%)"
                 )
             else:
-                line = f" {i_status}{f_status}\t{p}"
+                line = f" {i_status}{f_status}\t{disp_p}"
 
             if i and not f:
                 out.added("%s", line)
diff --git a/tests/test_project.py b/tests/test_project.py
index 2a5aebf..7c57ff0 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -638,6 +638,57 @@
 
             self.assertEqual(["new", "old"], proj.UncommittedFiles())
 
+    def test_quiet_divergent_status_uses_quick_snapshot(self) -> None:
+        """Unknown quick counts do not force the legacy status pipeline."""
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = _create_mock_project(tempdir)
+            Path(tempdir, "base").write_text("base")
+            proj.work_git.add("base")
+            proj.work_git.commit("-m", "base")
+            proj.work_git.checkout("-b", "topic")
+            proj.work_git.config("branch.topic.remote", ".")
+            proj.work_git.config("branch.topic.merge", "refs/heads/main")
+            Path(tempdir, "topic").write_text("topic")
+            proj.work_git.add("topic")
+            proj.work_git.commit("-m", "topic")
+            proj.work_git.checkout("main")
+            Path(tempdir, "main").write_text("main")
+            proj.work_git.add("main")
+            proj.work_git.commit("-m", "main")
+            proj.work_git.checkout("topic")
+            proj._PrintWorkTreeStatusLegacy = mock.MagicMock(
+                side_effect=AssertionError("unexpected legacy status")
+            )
+
+            with tempfile.TemporaryFile(mode="w+") as output:
+                self.assertEqual(
+                    "DIRTY",
+                    proj.PrintWorkTreeStatus(output_redir=output, quiet=True),
+                )
+
+            proj._PrintWorkTreeStatusLegacy.assert_not_called()
+
+    def test_quiet_legacy_dirty_status_skips_branch_lookup(self) -> None:
+        """Old Git keeps the dirty fast return ahead of branch resolution."""
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = _create_mock_project(tempdir)
+            proj.work_git = mock.MagicMock()
+            proj.work_git.DiffZ.side_effect = [
+                {"tracked": mock.sentinel.change},
+                {},
+            ]
+            proj.work_git.LsOthers.return_value = []
+
+            with tempfile.TemporaryFile(mode="w+") as output:
+                self.assertEqual(
+                    "DIRTY",
+                    proj._PrintWorkTreeStatusLegacy(
+                        output_redir=output, quiet=True
+                    ),
+                )
+
+            proj.work_git.GetHead.assert_not_called()
+
     def test_has_changes_includes_rebase_from_status_snapshot(self) -> None:
         """HasChanges keeps treating an in-progress rebase as a change."""
         with utils_for_test.TempGitTree() as tempdir:
diff --git a/tests/test_subcmds_status.py b/tests/test_subcmds_status.py
index 1501b34..b820053 100644
--- a/tests/test_subcmds_status.py
+++ b/tests/test_subcmds_status.py
@@ -185,6 +185,96 @@
     assert lines[1] == " -m\tREADME"
 
 
+def test_status_staged_and_unstaged_same_path(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """A path changed on both sides of the index renders both states."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    project_worktree = topdir / project_path
+    readme = project_worktree / "README"
+    readme.write_text("staged")
+    subprocess.check_call(["git", "add", "README"], cwd=project_worktree)
+    readme.write_text("unstaged")
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert lines[1] == " Mm\tREADME"
+
+
+def test_status_forces_staged_rename_detection(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """The snapshot preserves rename scores despite user status config."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    project_worktree = topdir / project_path
+    subprocess.check_call(
+        ["git", "config", "status.renames", "false"], cwd=project_worktree
+    )
+    subprocess.check_call(
+        ["git", "mv", "README", "RENAMED"], cwd=project_worktree
+    )
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert lines[1] == " R-\tREADME => RENAMED (100%)"
+
+
+def test_detached_clean_status_is_suppressed(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """A clean detached checkout keeps returning CLEAN without output."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    subprocess.check_call(
+        ["git", "checkout", "-q", "--detach", "HEAD"],
+        cwd=topdir / project_path,
+    )
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    assert _status_lines(stdout.getvalue()) == [
+        "nothing to commit (working directory clean)"
+    ]
+
+
+def test_status_unmerged_path_matches_legacy_display(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """Porcelain-v2 unmerged records render as index U and worktree u."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    worktree = topdir / project_path
+    subprocess.check_call(
+        ["git", "checkout", "-q", "-b", "other"], cwd=worktree
+    )
+    (worktree / "README").write_text("other")
+    subprocess.check_call(["git", "commit", "-qam", "other"], cwd=worktree)
+    subprocess.check_call(["git", "checkout", "-q", "main"], cwd=worktree)
+    (worktree / "README").write_text("main")
+    subprocess.check_call(["git", "commit", "-qam", "main"], cwd=worktree)
+    merge = subprocess.run(
+        ["git", "merge", "other"],
+        cwd=worktree,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        check=False,
+    )
+    assert merge.returncode != 0
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert lines[1] == " Uu\tREADME"
+
+
 def test_status_with_orphans_and_modified_file(
     repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
 ) -> None:
@@ -305,6 +395,10 @@
 
     _setup_remote_tracking_branch(manifest, "feature")
     subprocess.check_call(
+        ["git", "config", "status.aheadBehind", "false"],
+        cwd=project_worktree,
+    )
+    subprocess.check_call(
         ["git", "commit", "-q", "--allow-empty", "-m", "c1"],
         cwd=project_worktree,
     )
@@ -451,3 +545,20 @@
     lines = _status_lines(stdout.getvalue())
     assert len(lines) == 1
     _assert_project_header(lines[0], project_path, "synced")
+
+
+def test_status_non_utf8_path(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """Non-UTF-8 pathnames render without crashing."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    project_worktree = topdir / project_path
+    bad_path = project_worktree / os.fsdecode(b"bad-\xff-name")
+    bad_path.write_bytes(b"content")
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert any("bad-" in line for line in lines)