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
2026-09-21 13:37:38 -07:00
committed by gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com
parent c1566487a8
commit 530258d08e
2 changed files with 159 additions and 1 deletions
+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():
+79
View File
@@ -30,6 +30,7 @@ import utils_for_test
import error
import git_command
import git_config
import git_status
import git_trace2_event_log
import manifest_xml
import platform_utils
@@ -602,6 +603,84 @@ class ProjectTests(unittest.TestCase):
proj.bare_git.SetHead.assert_called_once_with("refs/heads/manifest")
proj.bare_git.DetachHead.assert_called_once_with(revision)
def test_dirty_status_snapshot(self) -> None:
"""Dirty checks cover staged, unstaged, and optional untracked files."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "tracked").write_text("initial")
proj.work_git.add("tracked")
proj.work_git.commit("-m", "initial")
self.assertFalse(proj.IsDirty())
Path(tempdir, "tracked").write_text("staged")
proj.work_git.add("tracked")
self.assertTrue(proj.IsDirty(consider_untracked=False))
Path(tempdir, "tracked").write_text("unstaged")
self.assertEqual(
["tracked", "tracked"], proj.UncommittedFiles(get_all=True)
)
proj.work_git.reset("--hard", "HEAD")
Path(tempdir, "untracked").write_text("new")
self.assertTrue(proj.IsDirty())
self.assertFalse(proj.IsDirty(consider_untracked=False))
def test_uncommitted_files_preserve_staged_rename_paths(self) -> None:
"""The snapshot returns both paths reported by legacy diff-index."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "old").write_text("tracked")
proj.work_git.add("old")
proj.work_git.commit("-m", "initial")
proj.work_git.mv("old", "new")
self.assertEqual(["new", "old"], proj.UncommittedFiles())
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:
proj = _create_mock_project(tempdir)
status = git_status.StatusSnapshot()
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
proj.IsRebaseInProgress = mock.MagicMock(return_value=True)
self.assertTrue(proj.HasChanges())
self.assertEqual(
["rebase in progress"], proj.UncommittedFiles(get_all=False)
)
def test_get_status_snapshot_handles_unsupported_status_error(self) -> None:
"""UnsupportedStatusError triggers the legacy status fallback."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
with mock.patch.object(
git_status,
"GetStatus",
side_effect=git_status.UnsupportedStatusError,
):
self.assertIsNone(proj._GetStatusSnapshot())
def test_old_git_dirty_check_uses_legacy_plumbing(self) -> None:
"""Git clients before 2.11 retain the existing dirty-check path."""
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}]
with mock.patch.object(project, "git_require", return_value=False):
self.assertTrue(proj.IsDirty())
proj.work_git.update_index.assert_called_once_with(
"-q",
"--unmerged",
"--ignore-missing",
"--refresh",
log_as_error=False,
)
self.assertEqual(2, proj.work_git.DiffZ.call_count)
@unittest.skipUnless(
utils_for_test.supports_reftable(),
"git reftable support is required for this test",