diff --git a/project.py b/project.py index 68986d498..0cf1d376d 100644 --- a/project.py +++ b/project.py @@ -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(): diff --git a/tests/test_project.py b/tests/test_project.py index f82e1f66a..2a5aebf9c 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -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",