mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-10-04 20:47:07 +00:00
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>
260 lines
8.2 KiB
Python
260 lines
8.2 KiB
Python
# 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.
|
|
|
|
"""Read a worktree's state from one machine-readable git status snapshot."""
|
|
|
|
from collections import OrderedDict
|
|
import os
|
|
from typing import Iterator, List, Optional, TYPE_CHECKING
|
|
|
|
from git_command import git_require
|
|
from git_command import GitCommand
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from project import Project
|
|
|
|
|
|
class StatusEntry:
|
|
"""The state of one path on one side of the index."""
|
|
|
|
def __init__(
|
|
self,
|
|
path: str,
|
|
status: str,
|
|
src_path: Optional[str] = None,
|
|
level: Optional[str] = None,
|
|
) -> None:
|
|
self.path = path
|
|
self.status = status
|
|
self.src_path = src_path
|
|
self.level = level
|
|
|
|
|
|
class StatusSnapshot:
|
|
"""A consistent view of worktree, index, and branch state."""
|
|
|
|
def __init__(self) -> None:
|
|
self.index_changes = OrderedDict()
|
|
self.worktree_changes = OrderedDict()
|
|
self.untracked = []
|
|
self.branch_oid = None
|
|
self.branch_head = None
|
|
self.upstream = None
|
|
self.ahead = 0
|
|
self.behind = 0
|
|
self.has_ahead_behind = False
|
|
self.stash_count = 0
|
|
|
|
@property
|
|
def current_branch(self) -> Optional[str]:
|
|
if self.branch_head in (None, "(detached)", "(unknown)"):
|
|
return None
|
|
return self.branch_head
|
|
|
|
def is_dirty(self, consider_untracked: bool = True) -> bool:
|
|
return bool(
|
|
self.index_changes
|
|
or self.worktree_changes
|
|
or (consider_untracked and self.untracked)
|
|
)
|
|
|
|
|
|
def GetStatus(
|
|
project: "Project",
|
|
gitdir: str,
|
|
untracked_files: str = "all",
|
|
branch: bool = False,
|
|
ahead_behind: bool = False,
|
|
show_stash: bool = False,
|
|
) -> StatusSnapshot:
|
|
"""Return one machine-readable status snapshot for |project|."""
|
|
if not git_require((2, 11, 0)):
|
|
raise UnsupportedStatusError("porcelain v2 requires Git 2.11")
|
|
cmd = [
|
|
"status",
|
|
"--porcelain=v2",
|
|
"-z",
|
|
"--ignore-submodules=all",
|
|
f"--untracked-files={untracked_files}",
|
|
]
|
|
if branch:
|
|
cmd.append("--branch")
|
|
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.
|
|
cmd.append("--renames")
|
|
if show_stash and git_require((2, 35, 0)):
|
|
cmd.append("--show-stash")
|
|
|
|
p = GitCommand(
|
|
project,
|
|
cmd,
|
|
bare=False,
|
|
gitdir=gitdir,
|
|
capture_stdout=True,
|
|
capture_stdout_bytes=True,
|
|
capture_stderr=True,
|
|
verify_command=True,
|
|
)
|
|
p.Wait()
|
|
return ParsePorcelainV2(p.stdout)
|
|
|
|
|
|
def _Path(value: bytes) -> str:
|
|
"""Decode a Git pathname without losing undecodable bytes."""
|
|
return os.fsdecode(value)
|
|
|
|
|
|
def _Status(value: int) -> str:
|
|
"""Normalize Git's unchanged markers for repo's status display."""
|
|
char = chr(value)
|
|
return "" if char == "." else char
|
|
|
|
|
|
def _Records(output: bytes) -> Iterator[bytes]:
|
|
if not output:
|
|
return iter(())
|
|
if not output.endswith(b"\0"):
|
|
raise StatusParseError("porcelain v2 output is not NUL terminated")
|
|
records = output.split(b"\0")
|
|
if not records[-1]:
|
|
records.pop()
|
|
return iter(records)
|
|
|
|
|
|
class StatusParseError(ValueError):
|
|
"""Raised when machine-readable status output is malformed."""
|
|
|
|
|
|
class UnsupportedStatusError(RuntimeError):
|
|
"""Raised when the Git client cannot produce porcelain v2."""
|
|
|
|
|
|
def _Fields(record: bytes, count: int) -> List[bytes]:
|
|
fields = record.split(b" ", count - 1)
|
|
if len(fields) != count:
|
|
raise StatusParseError(f"malformed porcelain v2 record: {record!r}")
|
|
return fields
|
|
|
|
|
|
def _AddTracked(
|
|
status: StatusSnapshot,
|
|
path: str,
|
|
xy: bytes,
|
|
src_path: Optional[str] = None,
|
|
level: Optional[str] = None,
|
|
) -> None:
|
|
index_status = _Status(xy[0])
|
|
worktree_status = _Status(xy[1])
|
|
if index_status:
|
|
status.index_changes[path] = StatusEntry(
|
|
path,
|
|
index_status,
|
|
src_path=src_path if index_status in ("R", "C") else None,
|
|
level=level if index_status in ("R", "C") else None,
|
|
)
|
|
if worktree_status:
|
|
status.worktree_changes[path] = StatusEntry(
|
|
path,
|
|
worktree_status,
|
|
src_path=src_path if worktree_status in ("R", "C") else None,
|
|
level=level if worktree_status in ("R", "C") else None,
|
|
)
|
|
|
|
|
|
def ParsePorcelainV2(output: bytes) -> StatusSnapshot:
|
|
"""Parse ``git status --porcelain=v2 -z --branch`` output."""
|
|
status = StatusSnapshot()
|
|
records = _Records(output)
|
|
for record in records:
|
|
kind = record[:1]
|
|
if kind == b"#":
|
|
try:
|
|
key, value = record[2:].split(b" ", 1)
|
|
except ValueError as e:
|
|
raise StatusParseError(
|
|
f"malformed porcelain v2 header: {record!r}"
|
|
) from e
|
|
if key == b"branch.oid":
|
|
value = value.decode("ascii")
|
|
status.branch_oid = None if value == "(initial)" else value
|
|
elif key == b"branch.head":
|
|
status.branch_head = _Path(value)
|
|
elif key == b"branch.upstream":
|
|
status.upstream = _Path(value)
|
|
elif key == b"branch.ab":
|
|
try:
|
|
value = value.decode("ascii")
|
|
ahead, behind = value.split()
|
|
if ahead != "+?" and behind != "-?":
|
|
status.ahead = int(ahead)
|
|
status.behind = -int(behind)
|
|
status.has_ahead_behind = True
|
|
except ValueError as e:
|
|
raise StatusParseError(
|
|
f"malformed porcelain v2 branch.ab record: {record!r}"
|
|
) from e
|
|
elif key == b"stash":
|
|
status.stash_count = int(value.decode("ascii"))
|
|
continue
|
|
|
|
if kind == b"1":
|
|
fields = _Fields(record, 9)
|
|
xy = fields[1]
|
|
if len(xy) != 2:
|
|
raise StatusParseError(f"invalid status pair: {xy!r}")
|
|
path = _Path(fields[8])
|
|
_AddTracked(status, path, xy)
|
|
elif kind == b"2":
|
|
fields = _Fields(record, 10)
|
|
xy = fields[1]
|
|
if len(xy) != 2:
|
|
raise StatusParseError(f"invalid status pair: {xy!r}")
|
|
score = fields[8][1:].lstrip(b"0") or b"0"
|
|
try:
|
|
src_path = _Path(next(records))
|
|
except StopIteration as e:
|
|
raise StatusParseError(
|
|
"rename record has no source path"
|
|
) from e
|
|
path = _Path(fields[9])
|
|
_AddTracked(
|
|
status,
|
|
path,
|
|
xy,
|
|
src_path=src_path,
|
|
level=score.decode("ascii"),
|
|
)
|
|
elif kind == b"u":
|
|
fields = _Fields(record, 11)
|
|
path = _Path(fields[10])
|
|
# The old diff-index/diff-files pair reported unmerged paths on
|
|
# both sides, regardless of porcelain's more specific XY pair.
|
|
status.index_changes[path] = StatusEntry(path, "U")
|
|
status.worktree_changes[path] = StatusEntry(path, "U")
|
|
elif kind == b"?":
|
|
status.untracked.append(_Path(record[2:]))
|
|
elif kind == b"!":
|
|
continue
|
|
else:
|
|
raise StatusParseError(
|
|
f"unknown porcelain v2 record type: {record!r}"
|
|
)
|
|
return status
|