summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBrian Gan <brgan@google.com>2026-09-14 14:14:24 +0000
committergerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2026-09-14 13:27:19 -0700
commitc2c330ba4a92c915f0180457f0d7ee29031bb253 (patch)
treebb3ec8743022d0c220b0093d0f42c4fb6c384abf
parentcc88be34d214418511c13f2284ecdb22245fb650 (diff)
downloadgit-repo-c2c330ba4a92c915f0180457f0d7ee29031bb253.tar.gz
project: report why refreshing the index failed
"repo status" aborts the whole tree when one project fails, without saying why. The three callers that refresh the index stat cache pass -q, which suppresses git's diagnosis when it cannot create the index lock; git then exits 128 with both streams empty. A stale lock, a permissions problem on the gitdir and a full disk all reach that same silent exit, yet each needs a different remedy. Route the three identical call sites through one helper that keeps -q for the refresh itself. When that fails, it repeats the command without -q to obtain git's diagnosis. The command still fails, which is correct, but the user is now told why. Keeping -q on the common path matters: without it git exits 1 whenever paths hold uncommitted changes, which would report a failure to telemetry for every modified project, even if we treat it as benign in the code. The retry accepts that 1, since --unmerged makes git skip conflicted paths rather than count them, so nothing else yields it. The similar call in subcmds/sync.py is unchanged: it passes different flags, without --unmerged, so exit 1 cannot be assumed benign there. Bug: 560289756 Change-Id: I2e1024c84454f86076081f2d7f155728b1aa35c6 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/630641 Tested-by: Brian Gan <brgan@google.com> Reviewed-by: Gavin Mak <gavinmak@google.com> Commit-Queue: Brian Gan <brgan@google.com>
-rw-r--r--project.py36
-rw-r--r--tests/test_project.py68
2 files changed, 95 insertions, 9 deletions
diff --git a/project.py b/project.py
index 2f72bb02d..a084d8db8 100644
--- a/project.py
+++ b/project.py
@@ -45,6 +45,7 @@ from error import UploadError
45import fetch 45import fetch
46from git_command import git_require 46from git_command import git_require
47from git_command import GitCommand 47from git_command import GitCommand
48from git_command import GitCommandError
48from git_config import GetSchemeFromUrl 49from git_config import GetSchemeFromUrl
49from git_config import GetUrlCookieFile 50from git_config import GetUrlCookieFile
50from git_config import GitConfig 51from git_config import GitConfig
@@ -825,11 +826,32 @@ class Project:
825 _git("rebase", "--abort") 826 _git("rebase", "--abort")
826 _git("am", "--abort") 827 _git("am", "--abort")
827 828
829 def _RefreshIndexStatCache(self) -> None:
830 """Refresh the index's cached stat information."""
831 args = ["--unmerged", "--ignore-missing", "--refresh"]
832
833 # Run twice because -q is needed and unhelpful in equal measure. It
834 # keeps git quiet about uncommitted changes, which would otherwise
835 # exit 1 and report a failure to telemetry for every modified project,
836 # but it also suppresses the reason a refresh failed, leaving a bare
837 # exit 128. So refresh with it, and repeat without it only to get
838 # the reason.
839 try:
840 self.work_git.update_index("-q", *args, log_as_error=False)
841 return
842 except GitError:
843 pass
844
845 # Exit code 1 means there are modified files, which is okay.
846 try:
847 self.work_git.update_index(*args)
848 except GitCommandError as e:
849 if e.git_rc != 1:
850 raise
851
828 def IsDirty(self, consider_untracked=True): 852 def IsDirty(self, consider_untracked=True):
829 """Is the working directory modified in some way?""" 853 """Is the working directory modified in some way?"""
830 self.work_git.update_index( 854 self._RefreshIndexStatCache()
831 "-q", "--unmerged", "--ignore-missing", "--refresh"
832 )
833 if self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD): 855 if self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD):
834 return True 856 return True
835 if self.work_git.DiffZ("diff-files"): 857 if self.work_git.DiffZ("diff-files"):
@@ -954,9 +976,7 @@ class Project:
954 uncommitted files is detected. 976 uncommitted files is detected.
955 """ 977 """
956 details = [] 978 details = []
957 self.work_git.update_index( 979 self._RefreshIndexStatCache()
958 "-q", "--unmerged", "--ignore-missing", "--refresh"
959 )
960 if self.IsRebaseInProgress(): 980 if self.IsRebaseInProgress():
961 details.append("rebase in progress") 981 details.append("rebase in progress")
962 if not get_all: 982 if not get_all:
@@ -1007,9 +1027,7 @@ class Project:
1007 print(' missing (run "repo sync")', file=output_redir) 1027 print(' missing (run "repo sync")', file=output_redir)
1008 return 1028 return
1009 1029
1010 self.work_git.update_index( 1030 self._RefreshIndexStatCache()
1011 "-q", "--unmerged", "--ignore-missing", "--refresh"
1012 )
1013 rb = self.IsRebaseInProgress() 1031 rb = self.IsRebaseInProgress()
1014 di = self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD) 1032 di = self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD)
1015 df = self.work_git.DiffZ("diff-files") 1033 df = self.work_git.DiffZ("diff-files")
diff --git a/tests/test_project.py b/tests/test_project.py
index af296c2fb..911bf7285 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -28,6 +28,7 @@ import pytest
28import utils_for_test 28import utils_for_test
29 29
30import error 30import error
31import git_command
31import git_config 32import git_config
32import git_trace2_event_log 33import git_trace2_event_log
33import manifest_xml 34import manifest_xml
@@ -1794,6 +1795,73 @@ def test_metaproject_has_changes_bounds_revision_walk() -> None:
1794 meta._revlist.assert_called_once_with("-1", "^HEAD", "remote") 1795 meta._revlist.assert_called_once_with("-1", "^HEAD", "remote")
1795 1796
1796 1797
1798@pytest.mark.parametrize(
1799 "state,expect_failure",
1800 [
1801 ("clean", False),
1802 # -q makes git exit 0 here rather than 1, so the repeat is never
1803 # reached. That is the point of it: an ordinary modified tree must
1804 # not be reported as a failure.
1805 ("modified", False),
1806 ("locked", True),
1807 ],
1808)
1809def test_refresh_index_stat_cache(state: str, expect_failure: bool) -> None:
1810 """Refreshing tolerates local changes but reports a real failure."""
1811 with utils_for_test.TempGitTree() as tempdir:
1812 proj = _create_mock_project(tempdir)
1813 readme = Path(tempdir) / "readme"
1814 readme.write_text("hello")
1815 proj.work_git.add("readme")
1816 proj.work_git.commit("-m", "initial commit")
1817
1818 if state == "modified":
1819 readme.write_text("different contents")
1820 elif state == "locked":
1821 # git only needs the lock when the index has to be rewritten, so
1822 # age the cached stat data while leaving the contents alone.
1823 os.utime(readme, (1, 1))
1824 (Path(proj.gitdir) / "index.lock").write_text("")
1825
1826 if not expect_failure:
1827 proj._RefreshIndexStatCache()
1828 return
1829
1830 with pytest.raises(error.GitError) as excinfo:
1831 proj._RefreshIndexStatCache()
1832 # Omitting -q is what lets git name the file it could not create.
1833 assert "index.lock" in str(excinfo.value)
1834
1835
1836@pytest.mark.parametrize(
1837 "repeat_rc,expect_raise",
1838 [
1839 # Whatever blocked the quiet attempt cleared in between, leaving the
1840 # repeat to report nothing worse than uncommitted changes.
1841 (1, False),
1842 (128, True),
1843 ],
1844)
1845def test_refresh_index_stat_cache_repeat(
1846 repeat_rc: int, expect_raise: bool
1847) -> None:
1848 """The repeat re-raises unless it failed with an ordinary exit 1."""
1849 proj = mock.MagicMock()
1850 proj.work_git.update_index.side_effect = [
1851 git_command.GitCommandError("quiet attempt failed", git_rc=128),
1852 git_command.GitCommandError("repeat failed", git_rc=repeat_rc),
1853 ]
1854
1855 if expect_raise:
1856 with pytest.raises(git_command.GitCommandError):
1857 project.Project._RefreshIndexStatCache(proj)
1858 else:
1859 project.Project._RefreshIndexStatCache(proj)
1860
1861 # The quiet attempt ran, then the repeat that explains it.
1862 assert proj.work_git.update_index.call_count == 2
1863
1864
1797def _create_mock_project( 1865def _create_mock_project(
1798 tempdir, 1866 tempdir,
1799 use_local_gitdirs=False, 1867 use_local_gitdirs=False,