project: use one status snapshot for dirty checks

On Git 2.11 and newer, replace update-index, diff-index, diff-files, and
ls-files fan-out with one porcelain-v2 snapshot for IsDirty,
UncommittedFiles, and HasChanges. Preserve category ordering and
duplicate staged-plus-unstaged paths, and keep the exact legacy path for
older Git or a failed snapshot.

Bug: 543851900
Bug: 553599402
Change-Id: I309e39977e1f1d4ee6115f488d6aabb83ec3d83c
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632143
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
This commit is contained in:
Gavin Mak authored and gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com committed 2026-09-21 13:37:38 -07:00
1 parent c1566487a8
commit 530258d08e
2 files changed
+159 -1

No files matched your search

+80 -1
View File
@@ -57,6 +57,7 @@ from git_refs import R_M
from git_refs import R_PUB
from git_refs import R_TAGS
from git_refs import R_WORKTREE_M
import git_status
import git_superproject
from git_trace2_event_log import EventLog
import platform_utils
@@ -868,8 +869,46 @@ class Project:
if e.git_rc != 1:
raise
def IsDirty(self, consider_untracked=True):
def IsDirty(self, consider_untracked: bool = True) -> bool:
"""Is the working directory modified in some way?"""
status = self._GetStatusSnapshot(
untracked_files="normal" if consider_untracked else "no"
)
if status is not None:
return status.is_dirty(consider_untracked=consider_untracked)
return self._IsDirtyLegacy(consider_untracked=consider_untracked)
def _GetStatusSnapshot(
self,
untracked_files: str = "all",
branch: bool = False,
ahead_behind: bool = False,
show_stash: bool = False,
) -> Optional[git_status.StatusSnapshot]:
"""Read one porcelain-v2 snapshot, or select the legacy path."""
if not git_require((2, 11, 0)):
return None
try:
return git_status.GetStatus(
self,
self.gitdir,
untracked_files=untracked_files,
branch=branch,
ahead_behind=ahead_behind,
show_stash=show_stash,
)
except (GitError, ValueError, git_status.UnsupportedStatusError) as e:
logger.warning(
"project %s: porcelain v2 status failed; using legacy "
"status: %s",
self.RelPath(local=False),
e,
)
return None
def _IsDirtyLegacy(self, consider_untracked: bool = True) -> bool:
"""Check dirty state with plumbing supported by older Git."""
self._RefreshIndexStatCache()
if self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD):
return True
@@ -994,6 +1033,46 @@ class Project:
uncommitted files. If False - return as soon as any kind of
uncommitted files is detected.
"""
status = self._GetStatusSnapshot(untracked_files="all")
if status is not None:
return self._UncommittedFilesFromStatus(status, get_all=get_all)
return self._UncommittedFilesLegacy(get_all=get_all)
def _UncommittedFilesFromStatus(
self, status: git_status.StatusSnapshot, get_all: bool = True
) -> List[str]:
"""Format uncommitted paths from a porcelain-v2 snapshot."""
details = []
if self.IsRebaseInProgress():
details.append("rebase in progress")
if not get_all:
return details
changes = []
for path, entry in status.index_changes.items():
changes.append(path)
# The legacy diff-index call did not enable rename detection, so
# it reported a staged rename as delete(source) plus add(target).
if entry.status == "R" and entry.src_path:
changes.append(entry.src_path)
changes.sort()
if changes:
details.extend(changes)
if not get_all:
return details
changes = list(status.worktree_changes)
if changes:
details.extend(changes)
if not get_all:
return details
details.extend(status.untracked)
return details
def _UncommittedFilesLegacy(self, get_all: bool = True) -> List[str]:
"""List uncommitted paths with plumbing supported by older Git."""
details = []
self._RefreshIndexStatCache()
if self.IsRebaseInProgress():