| # 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 |