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)