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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,6 @@ monkeytype.sqlite3
# Generated by sphinx_fonts extension (downloaded at build time)
docs/_static/fonts/
docs/_static/css/fonts.css

# pytest-optimizer durable state
.pytest-optimizer/
28 changes: 28 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ $ uv add libvcs --prerelease allow
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Fixes

#### `VCSRegistry` no longer shares parser state across instances (#539)

{class}`~libvcs.url.registry.VCSRegistry` kept its parser map in a class
variable, so constructing any second registry mutated the shared
{data}`~libvcs.url.registry.registry`. Each registry now owns its own parser
map, leaving the global one untouched.

#### `git_repo` and `hg_repo` fixtures return isolated clones (#539)

The first consumer of the `git_repo` / `hg_repo` pytest fixtures received a live
handle to the session-cached master checkout, so mutating that checkout (adding
a remote, switching branches) polluted the cache for every later test. The
master copy is now treated as a pristine, read-only cache and every consumer —
including the first — gets its own copytree.

### Development

#### Opt-in parallel test runs (#539)

The test suite can now run in parallel via [pytest-xdist], a new development
dependency. Enable workers with `just test-parallel` or `uv run pytest -n
auto`. The plain `uv run pytest` invocation still runs serially and is
unchanged.

[pytest-xdist]: https://pytest-xdist.readthedocs.io/

## libvcs 0.44.0 (2026-06-21)

libvcs 0.44.0 returns VCS command output verbatim by default. {meth}`~libvcs.cmd.git.Git.run` and its `Hg`/`Svn` counterparts now return exactly what the VCS printed, so a captured `git diff` re-applies with `git apply` and `git cat-file blob` round-trips byte-for-byte; the previous per-line trimming corrupted whitespace-significant output. This is a breaking change — reads that want a bare value (a lone SHA, a branch name) pass the new `trim=True`. Downstream tools such as vcspull are the primary beneficiaries.
Expand Down
4 changes: 2 additions & 2 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ One call to fetch or create a working copy.
:::{grid-item-card} pytest Plugin
:link: /api/pytest-plugin
:link-type: doc
Session-scoped fixtures for Git, SVN, and Mercurial
repositories. Drop-in test isolation.
Per-test isolated Git, SVN, and Mercurial repository fixtures,
backed by session-cached remotes. Drop-in test isolation.
:::

::::
Expand Down
8 changes: 8 additions & 0 deletions docs/api/pytest-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ def setup(
) -> None:
pass
```

## Repository isolation

{fixture}`git_repo`, {fixture}`hg_repo`, and {fixture}`svn_repo` hand each test
its own clone. The remote is built once and cached for the session, then copied
for every consumer, so a test can commit, add remotes, or rewrite history
without affecting any other test — and the fixtures stay safe under parallel
runs (`pytest-xdist`).
:::

## Types
Expand Down
118 changes: 118 additions & 0 deletions docs/project/adr/0002-order-independent-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
(adr-order-independent-tests)=

# ADR 0002: Order-independent tests with opt-in parallelism

## Status

Accepted. 2026-06-28.

## Context

The test suite is subprocess-bound: it spawns real `git`, `hg`, and `svn`
processes and writes working copies to disk. Wall-time lives in process
spawning and filesystem I/O, not Python, so the single largest lever for a
faster suite is running independent tests across CPU cores.

Parallel execution (and, equivalently, shuffled execution) is only safe when
the suite is **order-independent**: every test must pass regardless of what ran
before it. The suite was not. Two pieces of shared mutable state passed in the
fixed collection order but failed once tests were reordered:

- `libvcs.url.registry.VCSRegistry` stored its `parser_map` in a class
variable, so constructing any second registry mutated the module-level
`registry` singleton — whichever test built a custom registry first changed
URL detection for every later test.
- The `git_repo` / `hg_repo` pytest fixtures handed the first consumer a live
handle to a session-cached master checkout. The first test to use the fixture
could mutate that cache (add a remote, switch a branch), leaking state into
every later test that copied it.

These were invisible under the default order and surfaced only when a
shuffled run (`pytest-randomly`) or a parallel run (`pytest-xdist`) changed
which tests ran together and in what sequence. They are also genuine bugs for
downstream consumers (e.g. vcspull) that build on `VCSRegistry` and the
fixtures, independent of how the tests run.

## Decision

Two coupled commitments — the invariant is the prerequisite for the mechanism.

### Tests are order-independent

No shared mutable state may leak across tests. Coupling is fixed at the source,
never hidden behind a fixed order or a co-locating scheduler:

- `VCSRegistry.parser_map` is a per-instance attribute; each registry is
independent and the global `registry` is never mutated by constructing
another.
- The `git_repo` / `hg_repo` / `svn_repo` fixtures treat the master checkout as
a pristine, read-only cache and hand every consumer — including the first —
its own copy. A test may mutate its checkout freely without affecting any
other.

The expectation is documented in {ref}`workflow` so contributors keep new tests
order-independent, with a shuffled-run check (`uv run --with pytest-randomly
py.test -p randomly`).

### Parallelism is opt-in, not the default

`pytest-xdist` is a development dependency exposed via `just test-parallel`
(`uv run py.test -n auto`). The default `uv run py.test` stays serial.

- The worker count is **not** hardcoded. `-n auto` adapts to the machine; the
operator caps it when needed. A subprocess-bound suite oversubscribes on
high-core machines, so a fixed count committed to `addopts` would be wrong
somewhere.
- The default scheduler (`load`) is used. A co-locating scheduler (`loadfile`)
was trialled as a workaround for the order-coupling, but once the coupling is
fixed it is unnecessary, so it is not committed.

## Alternatives considered

| Approach | order-safe | default unchanged | portable | chosen |
|----------|:----------:|:-----------------:|:--------:|:------:|
| Fix coupling at source + opt-in `-n auto` (`load`) | yes | yes | yes | **yes** |
| Keep `--dist=loadfile` workaround, leave coupling | masks it | yes | yes | no |
| `-n auto` in `addopts` (always parallel) | needs fix | no | no | no |
| Hardcode a worker count (e.g. `-n 12`) | needs fix | no | no | no |
| `pytest-randomly` as a committed CI gate | detects only | yes | yes | deferred |

The decisive point: a co-locating scheduler only *hides* order-coupling, and it
was measured to be no faster than the default scheduler once the coupling was
fixed — so the coupling is fixed and the workaround dropped. `pytest-randomly`
detects order-coupling but does not parallelize; it is used transiently for
checks (`uv run --with pytest-randomly`) rather than committed.

## Consequences

### Positive

- The suite passes under serial, shuffled, and parallel (`-n auto`) execution.
- Two real isolation bugs in shipped code (`VCSRegistry`, the repo fixtures)
are fixed, benefiting downstream consumers regardless of parallelism.
- A faster opt-in run is available without changing the stable default.

### Tradeoffs

- Parallel wall-time varies with machine load, and `-n auto` can oversubscribe
a subprocess-bound suite on high-core machines — the operator picks the
worker count.
- Contributors must keep new tests order-independent (reset global state in
teardown; isolate per-test resources).

### Risks

- A future order-coupling could reintroduce flakiness that appears only under
parallel or shuffled runs. Mitigation: the order-independence expectation is
documented with a shuffled-run check, so the regression is reproducible.

## Prior art

- `pytest-xdist` provides the `load`, `loadscope`, and `loadfile` schedulers;
its guidance is that tests must be independent for `load` to be safe.
- `pytest-randomly` randomizes order specifically to surface inter-test
coupling.
- The broader convention across test suites: parallel execution requires
order-independent tests, and shared mutable state (global registries, cached
fixtures handed out by reference) is the usual cause of order-dependent
failures.
1 change: 1 addition & 0 deletions docs/project/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ Significant design decisions and their rationale.
:maxdepth: 1

0001-faithful-subprocess-output-capture
0002-order-independent-tests
```
26 changes: 26 additions & 0 deletions docs/project/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@ $ uv run py.test

Helpers: `just test` Rerun tests on file change: `just watch-test` (requires [entr(1)])

### Running tests in parallel

The suite spawns real `git`, `hg`, and `svn` processes, so on a multi-core
machine it runs faster across workers with [pytest-xdist] (a dev dependency):

```console
$ just test-parallel
```

This runs `uv run py.test -n auto`, where `auto` sizes the worker pool to the
machine's cores. Parallelism is opt-in — `just test` and `uv run py.test` stay
serial by default.

### Order independence

Tests must pass regardless of the order they run in. Parallel and shuffled runs
spread tests across workers, so any hidden coupling — shared global state, or a
fixture that leaks into a later test — surfaces as a failure. Keep fixtures
self-contained and reset any global state in teardown. Check locally with a
shuffled run:

```console
$ uv run --with pytest-randomly py.test -p randomly
```

## Documentation

Default preview server: http://localhost:8068
Expand Down Expand Up @@ -220,6 +245,7 @@ Update `__version__` in `__about__.py` and `pyproject.toml`::
uv publish

[uv]: https://github.com/astral-sh/uv
[pytest-xdist]: https://pytest-xdist.readthedocs.io/
[entr(1)]: http://eradman.com/entrproject/
[`entr(1)`]: http://eradman.com/entrproject/
[ruff format]: https://docs.astral.sh/ruff/formatter/
Expand Down
12 changes: 11 additions & 1 deletion docs/url/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Detect VCS from `git`, `hg`, and `svn` URLs.
```python
>>> import dataclasses
>>> from libvcs.url.base import Rule, RuleMap
>>> from libvcs.url.registry import ParserMatch, VCSRegistry
>>> from libvcs.url.registry import ParserMatch, VCSRegistry, registry
>>> from libvcs.url.git import GitURL

This will match `github:org/repo`:
Expand Down Expand Up @@ -68,6 +68,10 @@ Prefix for KDE infrastructure, `kde:group/repository`:
... }
... )

Subclassing with its own ``RuleMap`` keeps these rules local. Registering on
``GitURL.rule_map`` instead would mutate the shared class-level map and change
``GitURL`` for every caller in the process.

>>> my_parsers: "ParserLazyMap" = {
... "git": MyGitURLParser,
... "hg": "libvcs.url.hg.HgURL",
Expand All @@ -76,6 +80,12 @@ Prefix for KDE infrastructure, `kde:group/repository`:

>>> vcs_matcher = VCSRegistry(parsers=my_parsers)

Each registry owns its parsers, so building a custom one leaves the
module-level ``registry`` untouched -- it still resolves git to ``GitURL``:

>>> registry.match('git@invent.kde.org:plasma/plasma-sdk.git')
[ParserMatch(vcs='git', match=GitURL(...))]

>>> vcs_matcher.match('git@invent.kde.org:plasma/plasma-sdk.git')
[ParserMatch(vcs='git', match=MyGitURLParser(...)),
ParserMatch(vcs='hg', match=HgURL(...)),
Expand Down
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ default:
test *args:
uv run py.test {{ args }}

# Run tests in parallel (pytest-xdist)
[group: 'test']
test-parallel *args:
uv run py.test -n auto {{ args }}

# Run tests then start continuous testing with pytest-watcher
[group: 'test']
start:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ dev = [
"pytest-rerunfailures",
"pytest-mock",
"pytest-watcher",
"pytest-xdist",
# Coverage
"codecov",
"coverage",
Expand All @@ -97,6 +98,7 @@ testing = [
"pytest-rerunfailures",
"pytest-mock",
"pytest-watcher",
"pytest-xdist",
]
coverage =[
"codecov",
Expand Down
Loading