diff options
| author | Brian Gan <brgan@google.com> | 2026-09-14 14:14:24 +0000 |
|---|---|---|
| committer | gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2026-09-14 13:27:19 -0700 |
| commit | c2c330ba4a92c915f0180457f0d7ee29031bb253 (patch) | |
| tree | bb3ec8743022d0c220b0093d0f42c4fb6c384abf | |
| parent | cc88be34d214418511c13f2284ecdb22245fb650 (diff) | |
| download | git-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.py | 36 | ||||
| -rw-r--r-- | tests/test_project.py | 68 |
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 | |||
| 45 | import fetch | 45 | import fetch |
| 46 | from git_command import git_require | 46 | from git_command import git_require |
| 47 | from git_command import GitCommand | 47 | from git_command import GitCommand |
| 48 | from git_command import GitCommandError | ||
| 48 | from git_config import GetSchemeFromUrl | 49 | from git_config import GetSchemeFromUrl |
| 49 | from git_config import GetUrlCookieFile | 50 | from git_config import GetUrlCookieFile |
| 50 | from git_config import GitConfig | 51 | from 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 | |||
| 28 | import utils_for_test | 28 | import utils_for_test |
| 29 | 29 | ||
| 30 | import error | 30 | import error |
| 31 | import git_command | ||
| 31 | import git_config | 32 | import git_config |
| 32 | import git_trace2_event_log | 33 | import git_trace2_event_log |
| 33 | import manifest_xml | 34 | import 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 | ) | ||
| 1809 | def 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 | ) | ||
| 1845 | def 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 | |||
| 1797 | def _create_mock_project( | 1865 | def _create_mock_project( |
| 1798 | tempdir, | 1866 | tempdir, |
| 1799 | use_local_gitdirs=False, | 1867 | use_local_gitdirs=False, |
