Skip to content

Commit 7f7ca3f

Browse files
committed
sync(feat[update_repo]) Return SyncResult from update_repo()
why: GitSync.update_repo() catches CommandError at 10+ locations and silently returns None, making it impossible for callers to detect sync failures. This causes vcspull to report "✓ Synced" even when git operations (fetch, rebase, checkout) fail. what: - Add SyncResult and SyncError dataclasses to sync/base.py - Change BaseSync.update_repo() return type from None to SyncResult - Transform GitSync.update_repo() except handlers to record errors in SyncResult while preserving existing control flow - Update SvnSync and HgSync for API consistency - Add tests for success and fetch-failure scenarios
1 parent 165747e commit 7f7ca3f

5 files changed

Lines changed: 193 additions & 36 deletions

File tree

src/libvcs/sync/base.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import dataclasses
56
import logging
67
import pathlib
78
import typing as t
@@ -14,6 +15,66 @@
1415
logger = logging.getLogger(__name__)
1516

1617

18+
@dataclasses.dataclass
19+
class SyncError:
20+
"""An error encountered during a sync step.
21+
22+
Examples
23+
--------
24+
>>> error = SyncError(step="fetch", message="remote not found")
25+
>>> error.step
26+
'fetch'
27+
>>> error.message
28+
'remote not found'
29+
>>> error.exception is None
30+
True
31+
"""
32+
33+
step: str
34+
message: str
35+
exception: Exception | None = None
36+
37+
38+
@dataclasses.dataclass
39+
class SyncResult:
40+
"""Result of a repository synchronization.
41+
42+
Examples
43+
--------
44+
>>> result = SyncResult()
45+
>>> result.ok
46+
True
47+
>>> bool(result)
48+
True
49+
50+
>>> result = SyncResult()
51+
>>> result.add_error(step="fetch", message="remote not found")
52+
>>> result.ok
53+
False
54+
>>> bool(result)
55+
False
56+
>>> result.errors[0].step
57+
'fetch'
58+
"""
59+
60+
ok: bool = True
61+
errors: list[SyncError] = dataclasses.field(default_factory=list)
62+
63+
def __bool__(self) -> bool:
64+
"""Return True if the sync succeeded without errors."""
65+
return self.ok
66+
67+
def add_error(
68+
self,
69+
step: str,
70+
message: str,
71+
exception: Exception | None = None,
72+
) -> None:
73+
"""Record an error and mark the result as failed."""
74+
self.ok = False
75+
self.errors.append(SyncError(step=step, message=message, exception=exception))
76+
77+
1778
class VCSLocation(t.NamedTuple):
1879
"""Generic VCS Location (URL and optional revision)."""
1980

@@ -200,7 +261,7 @@ def ensure_dir(self, *args: t.Any, **kwargs: t.Any) -> bool:
200261

201262
return True
202263

203-
def update_repo(self, *args: t.Any, **kwargs: t.Any) -> None:
264+
def update_repo(self, *args: t.Any, **kwargs: t.Any) -> SyncResult:
204265
"""Pull latest changes to here from remote repository."""
205266
raise NotImplementedError
206267

src/libvcs/sync/git.py

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from libvcs.cmd.git import Git
3030
from libvcs.sync.base import (
3131
BaseSync,
32+
SyncResult,
3233
VCSLocation,
3334
convert_pip_url as base_convert_pip_url,
3435
)
@@ -380,17 +381,22 @@ def update_repo(
380381
set_remotes: bool = False,
381382
*args: t.Any,
382383
**kwargs: t.Any,
383-
) -> None:
384+
) -> SyncResult:
384385
"""Pull latest changes from git remote."""
386+
result = SyncResult()
385387
self.ensure_dir()
386388

387389
if not pathlib.Path(self.path / ".git").is_dir():
388390
self.obtain()
389-
self.update_repo(set_remotes=set_remotes)
390-
return
391+
return self.update_repo(set_remotes=set_remotes)
391392

392393
if set_remotes:
393-
self.set_remotes(overwrite=True)
394+
try:
395+
self.set_remotes(overwrite=True)
396+
except exc.CommandError as e:
397+
self.log.exception("Failed to set remotes")
398+
result.add_error("set-remotes", str(e), exception=e)
399+
return result
394400

395401
# Get requested revision or tag
396402
url, git_tag = self.url, getattr(self, "rev", None)
@@ -412,7 +418,7 @@ def update_repo(
412418
)
413419
except exc.CommandError:
414420
self.log.exception("Failed to get the hash for HEAD")
415-
return
421+
return result
416422

417423
self.log.debug("head_sha: %s", head_sha)
418424

@@ -460,21 +466,23 @@ def update_repo(
460466
somethings_up = (error_code, is_remote_ref, tag_sha != head_sha)
461467
if all(not x for x in somethings_up):
462468
self.log.info("Already up-to-date.")
463-
return
469+
return result
464470

465471
try:
466472
process = self.cmd.fetch(log_in_real_time=True, check_returncode=True)
467-
except exc.CommandError:
473+
except exc.CommandError as e:
468474
self.log.exception("Failed to fetch repository '%s'", url)
469-
return
475+
result.add_error("fetch", str(e), exception=e)
476+
return result
470477

471478
if is_remote_ref:
472479
# Check if stash is needed
473480
try:
474481
process = self.cmd.status(porcelain=True, untracked_files="no")
475-
except exc.CommandError:
482+
except exc.CommandError as e:
476483
self.log.exception("Failed to get the status")
477-
return
484+
result.add_error("status", str(e), exception=e)
485+
return result
478486
need_stash = len(process) > 0
479487

480488
# If not in clean state, stash changes in order to be able
@@ -484,22 +492,25 @@ def update_repo(
484492
git_stash_save_options = "--quiet"
485493
try:
486494
process = self.cmd.stash.save(message=git_stash_save_options)
487-
except exc.CommandError:
495+
except exc.CommandError as e:
488496
self.log.exception("Failed to stash changes")
497+
result.add_error("stash-save", str(e), exception=e)
489498

490499
# Checkout the remote branch
491500
try:
492501
process = self.cmd.checkout(branch=git_tag)
493-
except exc.CommandError:
502+
except exc.CommandError as e:
494503
self.log.exception("Failed to checkout tag: '%s'", git_tag)
495-
return
504+
result.add_error("checkout", str(e), exception=e)
505+
return result
496506

497507
# Rebase changes from the remote branch
498508
try:
499509
process = self.cmd.rebase(upstream=git_remote_name + "/" + git_tag)
500510
except exc.CommandError as e:
501511
if any(msg in str(e) for msg in ["invalid_upstream", "Aborting"]):
502512
self.log.exception("Invalid upstream remote. Rebase aborted.")
513+
result.add_error("rebase", str(e), exception=e)
503514
else:
504515
# Rebase failed: Restore previous state.
505516
self.cmd.rebase(abort=True)
@@ -510,7 +521,8 @@ def update_repo(
510521
f"\nFailed to rebase in: '{self.path}'.\n"
511522
"You will have to resolve the conflicts manually",
512523
)
513-
return
524+
result.add_error("rebase", str(e), exception=e)
525+
return result
514526

515527
if need_stash:
516528
try:
@@ -520,7 +532,7 @@ def update_repo(
520532
self.cmd.reset(hard=True, quiet=True)
521533
try:
522534
process = self.cmd.stash.pop(quiet=True)
523-
except exc.CommandError:
535+
except exc.CommandError as e:
524536
# Stash pop failed: Restore previous state.
525537
self.cmd.reset(pathspec=head_sha, hard=True, quiet=True)
526538
self.cmd.stash.pop(index=True, quiet=True)
@@ -529,16 +541,19 @@ def update_repo(
529541
"You will have to resolve the "
530542
"conflicts manually",
531543
)
532-
return
544+
result.add_error("stash-pop", str(e), exception=e)
545+
return result
533546

534547
else:
535548
try:
536549
process = self.cmd.checkout(branch=git_tag)
537-
except exc.CommandError:
550+
except exc.CommandError as e:
538551
self.log.exception("Failed to checkout tag: '%s'", git_tag)
539-
return
552+
result.add_error("checkout", str(e), exception=e)
553+
return result
540554

541555
self.cmd.submodule.update(recursive=True, init=True, log_in_real_time=True)
556+
return result
542557

543558
def remotes(self) -> GitSyncRemoteDict:
544559
"""Return remotes like git remote -v.

src/libvcs/sync/hg.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
import pathlib
1616
import typing as t
1717

18+
from libvcs import exc
1819
from libvcs._internal.types import StrPath
1920
from libvcs.cmd.hg import Hg
2021

21-
from .base import BaseSync
22+
from .base import BaseSync, SyncResult
2223

2324
logger = logging.getLogger(__name__)
2425

@@ -64,11 +65,16 @@ def get_revision(self) -> str:
6465
"""Get latest revision of this mercurial repository."""
6566
return self.run(["parents", "--template={rev}"])
6667

67-
def update_repo(self, *args: t.Any, **kwargs: t.Any) -> None:
68+
def update_repo(self, *args: t.Any, **kwargs: t.Any) -> SyncResult:
6869
"""Pull changes from remote Mercurial repository into this one."""
70+
result = SyncResult()
6971
if not pathlib.Path(self.path / ".hg").exists():
7072
self.obtain()
71-
self.update_repo()
73+
return self.update_repo()
7274
else:
73-
self.cmd.update()
74-
self.cmd.pull(update=True)
75+
try:
76+
self.cmd.update()
77+
self.cmd.pull(update=True)
78+
except exc.CommandError as e:
79+
result.add_error("pull", str(e), exception=e)
80+
return result

src/libvcs/sync/svn.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@
2121
import re
2222
import typing as t
2323

24+
from libvcs import exc
2425
from libvcs._internal.types import StrPath
2526
from libvcs.cmd.svn import Svn
2627

27-
from .base import BaseSync
28+
from .base import BaseSync, SyncResult
2829

2930
logger = logging.getLogger(__name__)
3031

@@ -148,22 +149,27 @@ def update_repo(
148149
dest: str | None = None,
149150
*args: t.Any,
150151
**kwargs: t.Any,
151-
) -> None:
152+
) -> SyncResult:
152153
"""Fetch changes from SVN repository to local working copy."""
154+
result = SyncResult()
153155
self.ensure_dir()
154156
if pathlib.Path(self.path / ".svn").exists():
155-
self.cmd.checkout(
156-
url=self.url,
157-
username=self.username,
158-
password=self.password,
159-
non_interactive=True,
160-
quiet=True,
161-
check_returncode=True,
162-
**kwargs,
163-
)
157+
try:
158+
self.cmd.checkout(
159+
url=self.url,
160+
username=self.username,
161+
password=self.password,
162+
non_interactive=True,
163+
quiet=True,
164+
check_returncode=True,
165+
**kwargs,
166+
)
167+
except exc.CommandError as e:
168+
result.add_error("checkout", str(e), exception=e)
164169
else:
165170
self.obtain()
166-
self.update_repo()
171+
return self.update_repo()
172+
return result
167173

168174
@classmethod
169175
def _get_svn_url_rev(cls, location: str) -> tuple[str | None, int]:

tests/sync/test_git.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from libvcs import exc
1616
from libvcs._internal.run import run
1717
from libvcs._internal.shortcuts import create_project
18+
from libvcs.sync.base import SyncResult
1819
from libvcs.sync.git import (
1920
GitRemote,
2021
GitStatus,
@@ -923,3 +924,71 @@ def test_repo_git_remote_checkout(
923924

924925
assert git_repo_checkout_dir.exists()
925926
assert pathlib.Path(git_repo_checkout_dir / ".git").exists()
927+
928+
929+
def test_update_repo_success_returns_sync_result(
930+
create_git_remote_bare_repo: CreateRepoPytestFixtureFn,
931+
tmp_path: pathlib.Path,
932+
) -> None:
933+
"""Test that a successful update_repo() returns SyncResult with ok=True."""
934+
git_server = create_git_remote_bare_repo()
935+
git_repo = GitSync(
936+
path=tmp_path / "myrepo",
937+
url=git_server.as_uri(),
938+
)
939+
git_repo.obtain()
940+
941+
# Make an initial commit so rev-list HEAD succeeds
942+
initial_file = git_repo.path / "initial_file"
943+
initial_file.write_text("content", encoding="utf-8")
944+
git_repo.run(["add", str(initial_file)])
945+
git_repo.run(["commit", "-m", "initial commit"])
946+
git_repo.run(["push"])
947+
948+
result = git_repo.update_repo()
949+
950+
assert isinstance(result, SyncResult)
951+
assert result.ok is True
952+
assert result.errors == []
953+
assert bool(result) is True
954+
955+
956+
def test_update_repo_fetch_failure_returns_sync_result(
957+
create_git_remote_bare_repo: CreateRepoPytestFixtureFn,
958+
tmp_path: pathlib.Path,
959+
) -> None:
960+
"""Test that a fetch failure in update_repo() returns SyncResult with error."""
961+
git_server = create_git_remote_bare_repo()
962+
git_repo = GitSync(
963+
path=tmp_path / "myrepo",
964+
url=git_server.as_uri(),
965+
)
966+
git_repo.obtain()
967+
968+
# Make a commit and push so the repo has a valid HEAD
969+
initial_file = git_repo.path / "initial_file"
970+
initial_file.write_text("content", encoding="utf-8")
971+
git_repo.run(["add", str(initial_file)])
972+
git_repo.run(["commit", "-m", "a commit"])
973+
git_repo.run(["push"])
974+
975+
# Make another commit, push, then reset to create a "behind" state
976+
another_file = git_repo.path / "another_file"
977+
another_file.write_text("more content", encoding="utf-8")
978+
git_repo.run(["add", str(another_file)])
979+
git_repo.run(["commit", "-m", "second commit"])
980+
git_repo.run(["push"])
981+
git_repo.run(["reset", "--hard", "HEAD^"])
982+
983+
# Delete the remote directory to cause a fetch failure
984+
shutil.rmtree(git_server)
985+
986+
result = git_repo.update_repo()
987+
988+
assert isinstance(result, SyncResult)
989+
assert result.ok is False
990+
assert bool(result) is False
991+
assert len(result.errors) > 0
992+
assert result.errors[0].step == "fetch"
993+
assert result.errors[0].exception is not None
994+
assert isinstance(result.errors[0].exception, exc.CommandError)

0 commit comments

Comments
 (0)