Skip to content

Commit 34a9ea5

Browse files
committed
sync/git(fix[update_repo]) Wrap unprotected error paths in SyncResult
why: Several error paths in GitSync.update_repo() were not wrapped in try/except, causing failures to propagate as uncaught exceptions instead of being recorded in SyncResult. This made vcspull report false successes (e.g. "✓ Synced cpython" when git had a fatal error). what: - Wrap symbolic_ref() in try/except with check_returncode=True (detached HEAD) - Wrap get_current_remote_name() in try/except - Catch GitRemoteRefNotFound instead of bare raise; record in SyncResult - Wrap submodule.update() in try/except (non-fatal: records but doesn't abort) - Use contextlib.suppress for best-effort recovery paths (rebase --abort, etc.) - Fix GitRemoteRefNotFound.__str__ to not require cmd attribute - Add comment explaining intentional rev_list(tag) catch behavior - Update existing test assertions for new check_returncode=True kwarg - Remove xfail from three new error-path tests
1 parent 95a5861 commit 34a9ea5

2 files changed

Lines changed: 87 additions & 36 deletions

File tree

src/libvcs/sync/git.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from __future__ import annotations
1919

20+
import contextlib
2021
import dataclasses
2122
import logging
2223
import pathlib
@@ -71,9 +72,14 @@ class GitRemoteRefNotFound(exc.CommandError):
7172
"""Raised when a git remote ref (tag, branch) could not be found."""
7273

7374
def __init__(self, git_tag: str, ref_output: str, *args: object) -> None:
74-
return super().__init__(
75-
f"Could not fetch remote in refs/remotes/{git_tag}. Output: {ref_output}",
75+
self._message = (
76+
f"Could not fetch remote in refs/remotes/{git_tag}. Output: {ref_output}"
7677
)
78+
super().__init__(self._message)
79+
80+
def __str__(self) -> str:
81+
"""Return descriptive message without requiring cmd attribute."""
82+
return self._message
7783

7884

7985
@dataclasses.dataclass
@@ -419,8 +425,17 @@ def update_repo(
419425

420426
if not git_tag:
421427
self.log.debug("No git revision set, defaulting to origin/master")
422-
symref = self.cmd.symbolic_ref(name="HEAD", short=True)
423-
git_tag = symref.rstrip() if symref else "origin/master"
428+
try:
429+
symref = self.cmd.symbolic_ref(
430+
name="HEAD",
431+
short=True,
432+
check_returncode=True,
433+
)
434+
git_tag = symref.rstrip() if symref else "origin/master"
435+
except exc.CommandError as e:
436+
self.log.exception("Failed to determine current branch")
437+
result.add_error("symbolic-ref", str(e), exception=e)
438+
return result
424439
self.log.debug("git_tag: %s", git_tag)
425440

426441
self.log.info("Updating to '%s'.", git_tag)
@@ -448,7 +463,12 @@ def update_repo(
448463

449464
# show-ref output is in the form "<sha> refs/remotes/<remote>/<tag>"
450465
# we must strip the remote from the tag.
451-
git_remote_name = self.get_current_remote_name()
466+
try:
467+
git_remote_name = self.get_current_remote_name()
468+
except (exc.CommandError, GitNoBranchFound, GitRemoteSetError) as e:
469+
self.log.exception("Failed to determine remote name")
470+
result.add_error("remote-name", str(e), exception=e)
471+
return result
452472

453473
if f"refs/remotes/{git_tag}" in show_ref_output:
454474
m = re.match(
@@ -459,7 +479,17 @@ def update_repo(
459479
re.MULTILINE,
460480
)
461481
if m is None:
462-
raise GitRemoteRefNotFound(git_tag=git_tag, ref_output=show_ref_output)
482+
ref_err = GitRemoteRefNotFound(
483+
git_tag=git_tag,
484+
ref_output=show_ref_output,
485+
)
486+
self.log.error("Remote ref not found: '%s'", git_tag)
487+
result.add_error(
488+
"remote-ref-not-found",
489+
str(ref_err),
490+
exception=ref_err,
491+
)
492+
return result
463493
git_remote_name = m.group("git_remote_name")
464494
git_tag = m.group("git_tag")
465495
self.log.debug("git_remote_name: %s", git_remote_name)
@@ -485,6 +515,10 @@ def update_repo(
485515
)
486516

487517
except exc.CommandError as e:
518+
# Intentionally not recorded in SyncResult: the ref may not be
519+
# fetched yet. The error_code drives the fetch-then-checkout
520+
# logic below. Ambiguity errors are prevented by the
521+
# refs/heads/ disambiguation above.
488522
error_code = e.returncode if e.returncode is not None else 0
489523
tag_sha = ""
490524
self.log.debug("tag_sha: %s", tag_sha)
@@ -544,9 +578,11 @@ def update_repo(
544578
result.add_error("rebase", str(e), exception=e)
545579
else:
546580
# Rebase failed: Restore previous state.
547-
self.cmd.rebase(abort=True)
581+
with contextlib.suppress(exc.CommandError):
582+
self.cmd.rebase(abort=True)
548583
if need_stash:
549-
self.cmd.stash.pop(index=True, quiet=True)
584+
with contextlib.suppress(exc.CommandError):
585+
self.cmd.stash.pop(index=True, quiet=True)
550586

551587
self.log.exception(
552588
f"\nFailed to rebase in: '{self.path}'.\n"
@@ -560,13 +596,20 @@ def update_repo(
560596
process = self.cmd.stash.pop(index=True, quiet=True)
561597
except exc.CommandError:
562598
# Stash pop --index failed: Try again dropping the index
563-
self.cmd.reset(hard=True, quiet=True)
599+
with contextlib.suppress(exc.CommandError):
600+
self.cmd.reset(hard=True, quiet=True)
564601
try:
565602
process = self.cmd.stash.pop(quiet=True)
566603
except exc.CommandError as e:
567604
# Stash pop failed: Restore previous state.
568-
self.cmd.reset(pathspec=head_sha, hard=True, quiet=True)
569-
self.cmd.stash.pop(index=True, quiet=True)
605+
with contextlib.suppress(exc.CommandError):
606+
self.cmd.reset(
607+
pathspec=head_sha,
608+
hard=True,
609+
quiet=True,
610+
)
611+
with contextlib.suppress(exc.CommandError):
612+
self.cmd.stash.pop(index=True, quiet=True)
570613
self.log.exception(
571614
f"\nFailed to rebase in: '{self.path}'.\n"
572615
"You will have to resolve the "
@@ -586,7 +629,11 @@ def update_repo(
586629
result.add_error("checkout", str(e), exception=e)
587630
return result
588631

589-
self.cmd.submodule.update(recursive=True, init=True, log_in_real_time=True)
632+
try:
633+
self.cmd.submodule.update(recursive=True, init=True, log_in_real_time=True)
634+
except exc.CommandError as e:
635+
self.log.exception("Failed to update submodules")
636+
result.add_error("submodule-update", str(e), exception=e)
590637
return result
591638

592639
def remotes(self) -> GitSyncRemoteDict:

tests/sync/test_git.py

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,17 @@ def test_repo_update_handle_cases(
154154
cmd_mock = mocker.spy(git_repo.cmd, "symbolic_ref")
155155
git_repo.update_repo()
156156

157-
cmd_mock.assert_any_call(name="HEAD", short=True)
157+
cmd_mock.assert_any_call(name="HEAD", short=True, check_returncode=True)
158158

159159
cmd_mock.reset_mock()
160160

161161
# will only look up symbolic-ref if no rev specified for object
162162
git_repo.rev = "HEAD"
163163
git_repo.update_repo()
164-
assert mocker.call(name="HEAD", short=True) not in cmd_mock.mock_calls
164+
assert (
165+
mocker.call(name="HEAD", short=True, check_returncode=True)
166+
not in cmd_mock.mock_calls
167+
)
165168

166169

167170
@pytest.mark.parametrize(
@@ -225,7 +228,7 @@ def test_repo_update_stash_cases(
225228
cmd_mock = mocker.spy(git_repo.cmd, "symbolic_ref")
226229
git_repo.update_repo()
227230

228-
cmd_mock.assert_any_call(name="HEAD", short=True)
231+
cmd_mock.assert_any_call(name="HEAD", short=True, check_returncode=True)
229232

230233

231234
@pytest.mark.parametrize(
@@ -1061,17 +1064,17 @@ def test_update_repo_rev_list_head_failure_returns_sync_result(
10611064
assert isinstance(result.errors[0].exception, exc.CommandError)
10621065

10631066

1064-
@pytest.mark.xfail(strict=True, reason="submodule.update not wrapped in try/except")
10651067
def test_update_repo_submodule_failure_recorded(
10661068
create_git_remote_bare_repo: CreateRepoPytestFixtureFn,
10671069
tmp_path: pathlib.Path,
1070+
mocker: MockerFixture,
10681071
) -> None:
10691072
"""Test that submodule.update() failure is recorded in SyncResult.
10701073
1071-
When .gitmodules references a non-existent submodule URL, the
1072-
``git submodule update`` call in update_repo() fails. Currently
1073-
this is not wrapped in try/except, so the exception propagates
1074-
instead of being recorded in SyncResult.
1074+
When ``git submodule update`` fails, the error should be recorded
1075+
in SyncResult rather than propagating as an uncaught exception.
1076+
We mock the submodule.update call to raise CommandError since
1077+
triggering a real submodule failure is git-version-dependent.
10751078
"""
10761079
git_server = create_git_remote_bare_repo()
10771080
git_repo = GitSync(
@@ -1080,26 +1083,32 @@ def test_update_repo_submodule_failure_recorded(
10801083
)
10811084
git_repo.obtain()
10821085

1083-
# Make an initial commit and push so update_repo has a valid HEAD
1086+
# Make a commit and push so update_repo has a valid HEAD
10841087
initial_file = git_repo.path / "initial_file"
10851088
initial_file.write_text("content", encoding="utf-8")
10861089
git_repo.run(["add", str(initial_file)])
10871090
git_repo.run(["commit", "-m", "initial commit"])
10881091
git_repo.run(["push"])
10891092

1090-
# Add a .gitmodules file that references a non-existent submodule URL
1091-
gitmodules = git_repo.path / ".gitmodules"
1092-
gitmodules.write_text(
1093-
'[submodule "broken"]\n\tpath = broken\n\turl = file:///nonexistent/repo.git\n',
1094-
encoding="utf-8",
1095-
)
1096-
git_repo.run(["add", ".gitmodules"])
1097-
git_repo.run(["commit", "-m", "add broken submodule ref"])
1093+
# Make another commit, push, then reset to create a "behind" state
1094+
another_file = git_repo.path / "another_file"
1095+
another_file.write_text("more content", encoding="utf-8")
1096+
git_repo.run(["add", str(another_file)])
1097+
git_repo.run(["commit", "-m", "second commit"])
10981098
git_repo.run(["push"])
1099-
1100-
# Reset behind so update_repo triggers a fetch+checkout cycle
11011099
git_repo.run(["reset", "--hard", "HEAD^"])
11021100

1101+
# Mock submodule.update to raise CommandError
1102+
mocker.patch.object(
1103+
git_repo.cmd.submodule,
1104+
"update",
1105+
side_effect=exc.CommandError(
1106+
output="fatal: clone of 'file:///nonexistent' failed",
1107+
returncode=128,
1108+
cmd="git submodule update --init --recursive",
1109+
),
1110+
)
1111+
11031112
result = git_repo.update_repo()
11041113

11051114
assert isinstance(result, SyncResult)
@@ -1108,7 +1117,6 @@ def test_update_repo_submodule_failure_recorded(
11081117
assert any(e.step == "submodule-update" for e in result.errors)
11091118

11101119

1111-
@pytest.mark.xfail(strict=True, reason="symbolic_ref not wrapped in try/except")
11121120
def test_update_repo_symbolic_ref_failure_recorded(
11131121
create_git_remote_bare_repo: CreateRepoPytestFixtureFn,
11141122
tmp_path: pathlib.Path,
@@ -1149,10 +1157,6 @@ def test_update_repo_symbolic_ref_failure_recorded(
11491157
assert any(e.step == "symbolic-ref" for e in result.errors)
11501158

11511159

1152-
@pytest.mark.xfail(
1153-
strict=True,
1154-
reason="GitRemoteRefNotFound raised instead of recorded in SyncResult",
1155-
)
11561160
def test_update_repo_remote_ref_not_found_recorded(
11571161
create_git_remote_bare_repo: CreateRepoPytestFixtureFn,
11581162
tmp_path: pathlib.Path,

0 commit comments

Comments
 (0)