project: fold normal stash detection into status

When Git 2.35 or newer supplies a stash header, use the same status
snapshot for stateless dirty and stash checks. Retain rev-parse for
earlier clients or when the status snapshot is unavailable.

Bug: 543851900
Bug: 553599402
Change-Id: I6e310459d9c1a3ba7978f7907ae6b7a9bc611749
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632146
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
diff --git a/project.py b/project.py
index 5d7f32d..26e7845 100644
--- a/project.py
+++ b/project.py
@@ -930,6 +930,21 @@
         )
         return p.Wait() == 0
 
+    def _HasDirtyOrStash(self) -> bool:
+        """Check dirty and normal stash state with one status when possible."""
+        has_status_stash = git_require((2, 35, 0))
+        status = self._GetStatusSnapshot(
+            untracked_files="normal",
+            show_stash=has_status_stash,
+        )
+        if status is not None:
+            if status.is_dirty(consider_untracked=True):
+                return True
+            if has_status_stash:
+                return bool(status.stash_count)
+            return self.HasStash()
+        return self.IsDirty(consider_untracked=True) or self.HasStash()
+
     _userident_name = None
     _userident_email = None
 
@@ -1670,7 +1685,7 @@
         except (GitError, IndexError, ValueError):
             return False
 
-        if self.IsDirty(consider_untracked=True) or self.HasStash():
+        if self._HasDirtyOrStash():
             return False
 
         return True
diff --git a/tests/test_project.py b/tests/test_project.py
index 7c57ff0..e7a3bec 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -713,6 +713,49 @@
             ):
                 self.assertIsNone(proj._GetStatusSnapshot())
 
+    def test_dirty_or_stash_uses_status_stash_header(self) -> None:
+        """A normal stash is detected without a second Git process."""
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = _create_mock_project(tempdir)
+            status = git_status.StatusSnapshot()
+            status.stash_count = 1
+            proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
+            proj.HasStash = mock.MagicMock()
+
+            with mock.patch.object(project, "git_require", return_value=True):
+                self.assertTrue(proj._HasDirtyOrStash())
+
+            proj.HasStash.assert_not_called()
+
+    def test_dirty_or_stash_clean_no_stash_on_git_2_35(self) -> None:
+        """A clean tree requires no second Git process on Git 2.35+."""
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = _create_mock_project(tempdir)
+            status = git_status.StatusSnapshot()
+            proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
+            proj.HasStash = mock.MagicMock()
+
+            with mock.patch.object(project, "git_require", return_value=True):
+                self.assertFalse(proj._HasDirtyOrStash())
+
+            proj.HasStash.assert_not_called()
+
+    def test_dirty_or_stash_before_2_35_checks_stash_ref(self) -> None:
+        """Older porcelain v2 output is not assumed to contain stash data."""
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = _create_mock_project(tempdir)
+            status = git_status.StatusSnapshot()
+            proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
+            proj.HasStash = mock.MagicMock(return_value=True)
+
+            with mock.patch.object(project, "git_require", return_value=False):
+                self.assertTrue(proj._HasDirtyOrStash())
+
+            proj._GetStatusSnapshot.assert_called_once_with(
+                untracked_files="normal", show_stash=False
+            )
+            proj.HasStash.assert_called_once_with()
+
     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:
@@ -2186,7 +2229,7 @@
         """Test stateless sync skips if stash exists."""
         with utils_for_test.TempGitTree() as tempdir:
             proj = self._get_project(tempdir)
-            proj.HasStash = mock.MagicMock(return_value=True)
+            proj._HasDirtyOrStash = mock.MagicMock(return_value=True)
 
             res = proj.Sync_NetworkHalf()