Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7f7ca3f
sync(feat[update_repo]) Return SyncResult from update_repo()
tony Feb 7, 2026
373770a
ruff(fix) Move A002 suppression to per-file-ignores in pyproject.toml
tony Feb 7, 2026
e082804
sync/git,tests(feat[update_repo]) Fix checkout error detection, add e…
tony Feb 7, 2026
6abe67b
sync(docs[SyncResult,update_repo]): Add NumPy-style docstrings
tony Feb 7, 2026
f7554ef
sync/git(fix[update_repo]): Return result on stash-save failure
tony Feb 7, 2026
882593c
sync(fix[update_repo]): Capture obtain() failures in SyncResult
tony Feb 7, 2026
fcbf595
sync/git(fix[update_repo]): Record rev-list HEAD failure in SyncResult
tony Feb 7, 2026
24453fe
tests/sync(test[update_repo]): Add test for rev-list HEAD failure path
tony Feb 7, 2026
b9b14a4
sync/git(fix[update_repo]) Disambiguate rev-list for local refs match…
tony Feb 7, 2026
95a5861
tests(feat[test_git]) Add xfail tests for update_repo error path gaps
tony Feb 7, 2026
34a9ea5
sync/git(fix[update_repo]) Wrap unprotected error paths in SyncResult
tony Feb 7, 2026
d258e69
tests/sync(fix[update_repo]): Remove unused type: ignore comment
tony Feb 7, 2026
ad647f6
sync/git(fix[update_repo]): Return early on invalid-upstream rebase
tony Feb 7, 2026
21965b0
cmd/git(fix[rev_list]) Reference _all parameter instead of builtin all
tony Feb 7, 2026
f79ce06
tests/sync(fix[test_hg]): Use private remote in pull-failure test
tony Feb 7, 2026
6c7a660
docs(CHANGES): Add 0.39.x entries for SyncResult and related fixes
tony Feb 7, 2026
afa0154
docs(sync[update_repo]): Document SyncResult return type in README an…
tony Feb 7, 2026
aec95fb
sync/git(style[GitRemoteRefNotFound]): Add explicit _message type ann…
tony Feb 7, 2026
30b3710
libvcs(feat[__init__]): Export SyncResult and SyncError from top-leve…
tony Feb 7, 2026
bca6778
tests/sync(feat[test_git]): Cover all 13 update_repo error steps
tony Feb 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,43 @@ $ uv add libvcs --prerelease allow
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### New features

#### sync: Return {class}`~libvcs.sync.base.SyncResult` from `update_repo()` (#514)

`update_repo()` across {class}`~libvcs.sync.git.GitSync`,
{class}`~libvcs.sync.hg.HgSync`, and {class}`~libvcs.sync.svn.SvnSync`
now returns a {class}`~libvcs.sync.base.SyncResult` instead of `None`.
Callers can inspect `result.ok` and `result.errors` to distinguish
successful syncs from failures.

- New dataclasses: {class}`~libvcs.sync.base.SyncResult` and
{class}`~libvcs.sync.base.SyncError`
- Git: 10+ silent `except CommandError: return` paths now record
structured errors with labeled steps (`fetch`, `rebase`, `checkout`,
`stash-save`, etc.)
- Hg and SVN: Wrap `obtain` and `pull`/`update` failures for API
consistency

Companion change: [vcspull#512](https://github.com/vcs-python/vcspull/pull/512)

### Bug Fixes

- cmd: Fix `Git.rev_list()` referencing builtin `all` instead of `_all`
parameter (#514)

- sync: Disambiguate `rev-list` when a local branch name collides with a
filesystem path by using fully-qualified `refs/heads/` refs (#514)

- sync: Return early with error on invalid-upstream rebase instead of
falling through to stash-pop (#514)

### Tests

- sync: Fix `test_update_repo_pull_failure_returns_sync_result` (hg)
destroying the session-scoped `hg_remote_repo` fixture, which broke
downstream tests like `test_hg_url` (#514)

## libvcs 0.38.6 (2026-01-27)

### Bug Fixes
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,13 @@ repo = GitSync(
)

# Clone (if not exists) or fetch & update (if exists)
repo.update_repo()
result = repo.update_repo()

print(f"Current revision: {repo.get_revision()}")
if result.ok:
print(f"Current revision: {repo.get_revision()}")
else:
for error in result.errors:
print(f"Sync failed at {error.step}: {error.message}")
```

### 2. Command Abstraction
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ convention = "numpy"

[tool.ruff.lint.per-file-ignores]
"*/__init__.py" = ["F401"]
"src/libvcs/_internal/subprocess.py" = ["A002"]

[tool.pytest.ini_options]
addopts = [
Expand Down
4 changes: 3 additions & 1 deletion src/libvcs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import logging

from ._internal.run import CmdLoggingAdapter
from .sync.base import BaseSync
from .sync.base import BaseSync, SyncError, SyncResult
from .sync.git import GitSync
from .sync.hg import HgSync
from .sync.svn import SvnSync
Expand All @@ -16,6 +16,8 @@
"GitSync",
"HgSync",
"SvnSync",
"SyncError",
"SyncResult",
]

logger = logging.getLogger(__name__)
1 change: 0 additions & 1 deletion src/libvcs/_internal/subprocess.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# ruff: NOQA: A002
r"""Invocable :mod:`subprocess` wrapper.

Defer running a subprocess, such as by handing to an executor.
Expand Down
4 changes: 2 additions & 2 deletions src/libvcs/cmd/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -2193,7 +2193,7 @@ def rev_list(

for flag, shell_flag in [
# Limiting output
(all, "--all"),
(_all, "--all"),
(author, "--author"),
(committer, "--committer"),
(grep, "--grep"),
Expand All @@ -2212,7 +2212,7 @@ def rev_list(
(first_parent, "--first-parent"),
(exclude_first_parent_only, "--exclude-first-parent-only"),
(_not, "--not"),
(all, "--all"),
(_all, "--all"),
(exclude, "--exclude"),
(reflog, "--reflog"),
(alternative_refs, "--alternative-refs"),
Expand Down
87 changes: 85 additions & 2 deletions src/libvcs/sync/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import dataclasses
import logging
import pathlib
import typing as t
Expand All @@ -14,6 +15,82 @@
logger = logging.getLogger(__name__)


@dataclasses.dataclass
class SyncError:
"""An error encountered during a sync step.

Examples
--------
>>> error = SyncError(step="fetch", message="remote not found")
>>> error.step
'fetch'
>>> error.message
'remote not found'
>>> error.exception is None
True
"""

step: str
message: str
exception: Exception | None = None


@dataclasses.dataclass
class SyncResult:
"""Result of a repository synchronization.

Examples
--------
>>> result = SyncResult()
>>> result.ok
True
>>> bool(result)
True

>>> result = SyncResult()
>>> result.add_error(step="fetch", message="remote not found")
>>> result.ok
False
>>> bool(result)
False
>>> result.errors[0].step
'fetch'
"""

ok: bool = True
errors: list[SyncError] = dataclasses.field(default_factory=list)

def __bool__(self) -> bool:
"""Return True if the sync succeeded without errors.

Returns
-------
bool
True if no errors were recorded, False otherwise.
"""
return self.ok

def add_error(
self,
step: str,
message: str,
exception: Exception | None = None,
) -> None:
"""Record an error and mark the result as failed.

Parameters
----------
step : str
Name of the sync step that failed (e.g. ``"fetch"``, ``"checkout"``).
message : str
Human-readable description of the error.
exception : Exception or None, optional
The underlying exception, if available.
"""
self.ok = False
self.errors.append(SyncError(step=step, message=message, exception=exception))


class VCSLocation(t.NamedTuple):
"""Generic VCS Location (URL and optional revision)."""

Expand Down Expand Up @@ -200,8 +277,14 @@ def ensure_dir(self, *args: t.Any, **kwargs: t.Any) -> bool:

return True

def update_repo(self, *args: t.Any, **kwargs: t.Any) -> None:
"""Pull latest changes to here from remote repository."""
def update_repo(self, *args: t.Any, **kwargs: t.Any) -> SyncResult:
"""Pull latest changes to here from remote repository.

Returns
-------
SyncResult
Result of the sync operation, with any errors recorded.
"""
raise NotImplementedError

def obtain(self, *args: t.Any, **kwargs: t.Any) -> None:
Expand Down
Loading