Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ $ uv add libvcs --prerelease allow
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Fixes

#### Progress callback timestamps carry a time zone (#549)

The `timestamp` passed to a
{class}`~libvcs._internal.run.ProgressCallbackProtocol` is now an aware
`datetime` in UTC. It was a naive local-time value, so timestamps taken on
hosts in different zones compared and serialized as though they named the
same instant, and a DST rollover made them ambiguous on a single host.
Callbacks that format the value for display are unaffected; callbacks that
compare, subtract, or serialize it will see the offset.

### Documentation

#### Class fields describe themselves in the API reference (#548)
Expand All @@ -38,6 +50,22 @@ Workflow actions moved to their current major releases: `actions/checkout` v7,
and `dorny/paths-filter` v4. Workflow behavior is unchanged, though setup-uv no
longer prunes the uv cache, so the first run after this repopulates it.

#### Ruff moves to 0.16 (#549)

Minimum `ruff>=0.16.0` (was unpinned). Contributors need the newer formatter:
`ruff format` descends into Python code blocks inside Markdown, so
`just ruff-format` now reaches `README.md` and the design notes alongside
`src/` and `tests/`.

Linting also picks up ruff's own default rule set. The project used to name
its linters with `select`, which replaces the defaults rather than adding to
them; `extend-select` layers the same list on top instead, so `just ruff` now
enforces the rules ruff recommends out of the box as well as the ones libvcs
asks for. Rules that flag a deliberate idiom — the blind excepts that let
{class}`~libvcs._internal.query_list.QueryList` treat an unresolvable lookup
as a non-match, `subprocess.Popen`'s `preexec_fn` pass-through — are scoped
per file with the reason recorded in `pyproject.toml`.

## libvcs 0.45.1 (2026-07-11)

libvcs 0.45.1 unbreaks test suites that build their documentation before running pytest: the bundled pytest plugin no longer overrides pytest's own collection-ignore rules, so generated `_build/` output stops aborting collection. The documentation now runs every Python example as a doctest against real temporary repositories, which surfaced and corrected examples that had drifted from the API.
Expand Down
36 changes: 16 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,7 @@ from libvcs.sync.git import GitSync
repo = GitSync(
url="https://github.com/vcs-python/libvcs",
path=pathlib.Path.cwd() / "libvcs",
remotes={
'gitlab': 'https://gitlab.com/vcs-python/libvcs'
}
remotes={"gitlab": "https://gitlab.com/vcs-python/libvcs"},
)

# Clone (if not exists) or fetch & update (if exists)
Expand All @@ -89,19 +87,19 @@ import pathlib
from libvcs.cmd.git import Git

# Initialize the wrapper
git = Git(path=pathlib.Path.cwd() / 'libvcs')
git = Git(path=pathlib.Path.cwd() / "libvcs")

# Run commands directly
git.clone(url='https://github.com/vcs-python/libvcs.git')
git.checkout(ref='master')
git.clone(url="https://github.com/vcs-python/libvcs.git")
git.checkout(ref="master")

# Traverse branches with ORM-like filtering
git.branches.create('feature/new-gui')
git.branches.create("feature/new-gui")
print(git.branches.ls()) # Returns QueryList for filtering

# Target specific entities with contextual commands
git.remotes.set_url(name='origin', url='git@github.com:vcs-python/libvcs.git')
git.tags.create(name='v1.0.0', message='Release version 1.0.0')
git.remotes.set_url(name="origin", url="git@github.com:vcs-python/libvcs.git")
git.tags.create(name="v1.0.0", message="Release version 1.0.0")
```

### 3. URL Parsing
Expand All @@ -113,17 +111,17 @@ Stop writing regex for Git URLs. Let `libvcs` handle the edge cases.
from libvcs.url.git import GitURL

# Validate URLs
GitURL.is_valid(url='https://github.com/vcs-python/libvcs.git') # True
GitURL.is_valid(url="https://github.com/vcs-python/libvcs.git") # True

# Parse complex URLs
url = GitURL(url='git@github.com:vcs-python/libvcs.git')
url = GitURL(url="git@github.com:vcs-python/libvcs.git")

print(url.user) # 'git'
print(url.user) # 'git'
print(url.hostname) # 'github.com'
print(url.path) # 'vcs-python/libvcs'
print(url.path) # 'vcs-python/libvcs'

# Transform URLs
url.hostname = 'gitlab.com'
url.hostname = "gitlab.com"
print(url.to_url()) # 'git@gitlab.com:vcs-python/libvcs.git'
```

Expand All @@ -137,18 +135,16 @@ import pathlib
from libvcs.pytest_plugin import CreateRepoFn
from libvcs.sync.git import GitSync

def test_my_git_tool(
create_git_remote_repo: CreateRepoFn,
tmp_path: pathlib.Path
):

def test_my_git_tool(create_git_remote_repo: CreateRepoFn, tmp_path: pathlib.Path):
# Spin up a real, temporary Git server
git_server = create_git_remote_repo()

# Clone it to a temporary directory
checkout_path = tmp_path / "checkout"
repo = GitSync(path=checkout_path, url=f"file://{git_server}")
repo.obtain()

assert checkout_path.exists()
assert (checkout_path / ".git").is_dir()
```
Expand Down
20 changes: 10 additions & 10 deletions notes/2025-11-26-command-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,23 +558,23 @@ reflog_pattern = r"(?P<sha>[a-f0-9]+) (?P<ref>[^@]+)@\{(?P<index>\d+)\}: (?P<act
```python
class Git:
submodule: GitSubmoduleCmd
remotes: GitRemoteManager # ✓ Manager pattern
remotes: GitRemoteManager # ✓ Manager pattern
stash: GitStashCmd
branches: GitBranchManager # ✓ Manager pattern
branches: GitBranchManager # ✓ Manager pattern
```

### Planned

```python
class Git:
submodules: GitSubmoduleManager # Renamed + Manager pattern
remotes: GitRemoteManager # ✓ Already done
stash: GitStashManager # Refactored to Manager pattern
branches: GitBranchManager # ✓ Already done
tags: GitTagManager # New
worktrees: GitWorktreeManager # New
notes: GitNotesManager # New
reflog: GitReflogManager # New
submodules: GitSubmoduleManager # Renamed + Manager pattern
remotes: GitRemoteManager # ✓ Already done
stash: GitStashManager # Refactored to Manager pattern
branches: GitBranchManager # ✓ Already done
tags: GitTagManager # New
worktrees: GitWorktreeManager # New
notes: GitNotesManager # New
reflog: GitReflogManager # New
```

---
Expand Down
29 changes: 25 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ dev = [
"coverage",
"pytest-cov",
# Lint
"ruff",
"ruff>=0.16.0",
"mypy",
]

Expand All @@ -106,7 +106,7 @@ coverage =[
"pytest-cov",
]
lint = [
"ruff",
"ruff>=0.16.0",
"mypy",
]

Expand Down Expand Up @@ -176,7 +176,10 @@ exclude_lines = [
target-version = "py310"

[tool.ruff.lint]
select = [
# `select` is deliberately unset: ruff 0.16 enables a curated default rule
# set, and an explicit `select` would replace it rather than extend it.
# `extend-select` layers this project's additional linters on top.
extend-select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
Expand All @@ -193,7 +196,7 @@ select = [
"PERF", # Perflint
"RUF", # Ruff-specific rules
"D", # pydocstyle
"FA100", # future annotations
"FA100", # future annotations
]
ignore = [
"COM812", # missing trailing comma, ruff format conflict
Expand Down Expand Up @@ -227,6 +230,24 @@ convention = "numpy"
[tool.ruff.lint.per-file-ignores]
"*/__init__.py" = ["F401"]
"src/libvcs/_internal/subprocess.py" = ["A002"]
# Sphinx conf reads version metadata by exec'ing `__about__.py` rather than
# importing libvcs, so the docs build does not depend on the package being
# importable. The exec'd file is this repository's own source.
"docs/conf.py" = ["S102"]
# `run()` mirrors the full `subprocess.Popen` signature, `preexec_fn`
# included, and defaults it to None. Dropping the parameter would break
# callers who need it; whether it is safe to pass is the caller's call.
"src/libvcs/_internal/run.py" = ["PLW1509"]
# QueryList traverses and compares caller-supplied objects, whose
# `__getattr__`, properties, and `__eq__` may raise anything at all. A
# lookup that cannot be resolved is a non-match, not an error, so each
# handler degrades to None/False after logging rather than propagating.
"src/libvcs/_internal/query_list.py" = ["BLE001"]
# `SvnSync.url_rev` falls back to shelling out to `svn info --xml` and
# scraping it. Whether that path yields a URL depends on the installed
# svn, the working copy layout, and the output format; an unreadable
# working copy answers `(None, 0)` rather than raising at the caller.
"src/libvcs/sync/svn.py" = ["BLE001"]

[tool.pytest.ini_options]
addopts = [
Expand Down
6 changes: 3 additions & 3 deletions src/libvcs/_internal/query_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ObjectDoesNotExist(Exception):
def keygetter(
obj: Mapping[str, t.Any],
path: str,
) -> None | t.Any | str | list[str] | Mapping[str, str]:
) -> t.Any | str | list[str] | Mapping[str, str] | None:
"""Fetch values in objects and keys, supported nested data.

**With dictionaries**:
Expand Down Expand Up @@ -311,12 +311,12 @@ def lookup_iregex(

class PKRequiredException(Exception):
def __init__(self, *args: object) -> None:
return super().__init__("items() require a pk_key exists")
super().__init__("items() require a pk_key exists")


class OpNotFound(ValueError):
def __init__(self, op: str, *args: object) -> None:
return super().__init__(f"{op} not in LOOKUP_NAME_MAP")
super().__init__(f"{op} not in LOOKUP_NAME_MAP")


class QueryList(list[T], t.Generic[T]):
Expand Down
13 changes: 9 additions & 4 deletions src/libvcs/_internal/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,10 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None:
if callback and callable(callback) and proc.stderr is not None:
line = console_to_str(proc.stderr.read(128))
if line:
callback(output=line, timestamp=datetime.datetime.now())
callback(
output=line,
timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
)
else:
code, timeout_stdout, timeout_stderr = _wait_with_deadline(
proc,
Expand All @@ -286,7 +289,7 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None:
cmd=_stringify_command(normalized_args),
)
if callback and callable(callback):
callback(output="\r", timestamp=datetime.datetime.now())
callback(output="\r", timestamp=datetime.datetime.now(tz=datetime.timezone.utc))

if proc.stdout is not None:
raw_stdout: bytes = (
Expand Down Expand Up @@ -403,7 +406,9 @@ def _wait_with_deadline(
):
callback(
output=console_to_str(trailing),
timestamp=datetime.datetime.now(),
timestamp=datetime.datetime.now(
tz=datetime.timezone.utc
),
)
break

Expand Down Expand Up @@ -464,7 +469,7 @@ def _wait_with_deadline(
):
callback(
output=console_to_str(chunk),
timestamp=datetime.datetime.now(),
timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
)
finally:
# Restore blocking mode so any subsequent read by the caller behaves
Expand Down
6 changes: 3 additions & 3 deletions src/libvcs/_internal/shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@

class VCSNoMatchFoundForUrl(exc.LibVCSException):
def __init__(self, url: str, *args: object) -> None:
return super().__init__(f"No VCS found for url: {url}")
super().__init__(f"No VCS found for url: {url}")


class VCSMultipleMatchFoundForUrl(exc.LibVCSException):
def __init__(self, url: str, *args: object) -> None:
return super().__init__(f"Multiple VCS found for url: {url}")
super().__init__(f"Multiple VCS found for url: {url}")


class VCSNotSupported(exc.LibVCSException):
def __init__(self, url: str, vcs: str, *args: object) -> None:
return super().__init__(f"VCS '{vcs}' not supported, based on URL: {url}")
super().__init__(f"VCS '{vcs}' not supported, based on URL: {url}")


@t.overload
Expand Down
5 changes: 2 additions & 3 deletions src/libvcs/_internal/subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,14 @@

class SubprocessCheckOutputError(Exception):
def __init__(self, output: str, *args: object) -> None:
return super().__init__(f"output is not str or bytes: {output}")
super().__init__(f"output is not str or bytes: {output}")


if sys.platform == "win32":
_ENV: t.TypeAlias = Mapping[str, str]
else:
_ENV: t.TypeAlias = Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath]
_FILE: t.TypeAlias = None | int | t.IO[t.Any]
_TXT: t.TypeAlias = bytes | str
_FILE: t.TypeAlias = int | t.IO[t.Any] | None
#: Command
_CMD: t.TypeAlias = StrOrBytesPath | Sequence[StrOrBytesPath]

Expand Down
49 changes: 20 additions & 29 deletions src/libvcs/cmd/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -3482,35 +3482,26 @@ def _ls(
branch = None

# Try to get name and URL from git config
try:
name_result = self.cmd.run(
["config", "-f", ".gitmodules", f"submodule.{path}.name"],
check_returncode=False,
)
if name_result and "error" not in name_result.lower():
submodule_name = name_result.strip()
except Exception:
pass

try:
url_result = self.cmd.run(
["config", "-f", ".gitmodules", f"submodule.{path}.url"],
check_returncode=False,
)
if url_result and "error" not in url_result.lower():
url = url_result.strip()
except Exception:
pass

try:
branch_result = self.cmd.run(
["config", "-f", ".gitmodules", f"submodule.{path}.branch"],
check_returncode=False,
)
if branch_result and "error" not in branch_result.lower():
branch = branch_result.strip()
except Exception:
pass
name_result = self.cmd.run(
["config", "-f", ".gitmodules", f"submodule.{path}.name"],
check_returncode=False,
)
if name_result and "error" not in name_result.lower():
submodule_name = name_result.strip()

url_result = self.cmd.run(
["config", "-f", ".gitmodules", f"submodule.{path}.url"],
check_returncode=False,
)
if url_result and "error" not in url_result.lower():
url = url_result.strip()

branch_result = self.cmd.run(
["config", "-f", ".gitmodules", f"submodule.{path}.branch"],
check_returncode=False,
)
if branch_result and "error" not in branch_result.lower():
branch = branch_result.strip()

submodules.append(
{
Expand Down
2 changes: 1 addition & 1 deletion src/libvcs/cmd/svn.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class SvnPropsetValueOrValuePathRequired(exc.LibVCSException, TypeError):
"""Raised when required parameters are not passed."""

def __init__(self, *args: object) -> None:
return super().__init__("Must enter a value or value_path")
super().__init__("Must enter a value or value_path")


class Svn:
Expand Down
Loading
Loading