Thanks for looking. A bug report with a reproduction, or a pull request that fixes a verified problem, is the most useful contribution right now. Discuss a substantial change via an issue before making it.
How this project writes prose — README, CHANGES, release notes, commit
messages, docstrings, and source comments — is set out separately in
WRITING.md. Read that before changing any of it. The constraints
every change is held to, and the map of what is where, are in
AGENTS.md.
Install git and uv, clone the repository, then install the dependency groups:
$ uv sync --all-extras --devCI is the order of record; every gate it runs has to pass before a change is
done (see .github/workflows/tests.yml).
Format:
$ uv run ruff format .Lint:
$ uv run ruff check . --fix --show-fixesType-check:
$ uv run mypy .mypy runs in strict mode ([tool.mypy] in pyproject.toml).
Test:
$ uv run py.testDocumentation is a gate, not a courtesy. Doctests under src/vcspull,
docs/_ext, and scripts are executed by pytest; vcspull … commands
shown in docs/*.md and in the CLI's help text are checked against the real
argument parser by the same test run. README.md is verified by neither
mechanism and stays honest by hand. Which check applies to which file, and
the one edit that silently deletes a doctest, are in
WRITING.md.
Before claiming a test or a gate works, show it failing. A gate that has never been red is an assumption.
from __future__ import annotationsat the top of every file —ruff's isort configuration (required-importsin[tool.ruff.lint.isort]) enforces this; a missing one is a lint failure, not a style note.- Namespace imports for the standard library:
import pathlib, notfrom pathlib import Path. Third-party packages may use idiomaticfrom X import Yimports. import typing as t, accessed via the namespace:t.NamedTuple,t.TYPE_CHECKING, and so on.
Docstring conventions (NumPy style, doctest requirements) are in WRITING.md — they are prose policy, not a workflow step.
Tests are written as standalone functions (test_*), not grouped into
class TestFoo: blocks — use descriptive function names and file
organization instead. This applies to pytest tests, not doctests.
libvcs fixtures. The suite leans on libvcs's pytest plugin:
create_git_remote_repo, create_svn_remote_repo, create_hg_remote_repo
(factory fixtures), git_repo, svn_repo, hg_repo (pre-made repository
instances), and set_home, gitconfig, hgconfig, git_commit_envvars
(environment fixtures). Reach for these before writing a new one.
Parametrized CLI tests use typing.NamedTuple fixtures:
class CLIFixture(t.NamedTuple):
test_id: str
cli_args: list[str]
expected_exit_code: int
@pytest.mark.parametrize(
list(CLIFixture._fields),
CLI_FIXTURES,
ids=[test.test_id for test in CLI_FIXTURES],
)
def test_cli_subcommands(...):
...Mocking. monkeypatch for environment variables, globals, and
attributes; mocker (from pytest-mock) for application code. Document
every mock with a comment explaining what is mocked and why.
Configuration file tests go through the project's own helpers —
vcspull.tests.helpers.write_config or save_config_yaml — rather than a
direct yaml.dump or file.write_text.
Logging assertions read caplog.records, not caplog.text: scope
capture with caplog.at_level(logging.DEBUG, logger="vcspull.cli"), filter
records rather than index by position
([r for r in caplog.records if hasattr(r, "vcs_cmd")]), and assert on the
structured fields (record.vcs_exit_code == 0) instead of string-matching
the rendered message. caplog.record_tuples cannot see extra fields — use
caplog.records.
Runtime dependency smoke test. Verifies the published wheel runs without
the dev/test extras by importing every vcspull module and exercising each
CLI subcommand with --help in an isolated environment:
$ uvx \
--isolated \
--no-cache \
--from . \
python scripts/runtime_dep_smoketest.pyThe same check has a pytest wrapper behind a dedicated marker, and both are
network-dependent because uvx builds the package in an isolated
environment:
$ uv run pytest \
-m scripts__runtime_dep_smoketest \
scripts/test_runtime_dep_smoketest.pyDebugging a failing test. Rerun on every file change with just start
(wraps pytest-watcher). Drop
into pdb on the first failure by setting PYTEST_ADDOPTS:
$ env PYTEST_ADDOPTS="-x -s --pdb" just startWith ipython installed, use its debugger instead:
$ env PYTEST_ADDOPTS="--pdbcls=IPython.terminal.debugger:TerminalPdb" \
just startThese rules guide new and changed logging code; existing code may not yet conform.
-
logging.getLogger(__name__)in every module; aNullHandlerin library__init__.pyfiles. Never configure handlers, levels, or formatters in library code — that is the application's job. The CLI's own configuration is invcspull.log.setup_logger. -
Pass structured context via
extrarather than folding it into the message string. Core keys are stable, scalar, and safe at any level:vcs_cmd,vcs_type,vcs_url,vcs_exit_code,vcs_repo_path,vcspull_config_path. Treat them as compatibility-sensitive — downstream users build dashboards and alerts on them. Heavy keys (vcs_stdout,vcs_stderr, bothlist[str]) are DEBUG-only and should be capped or truncated. -
snake_case,vcs_-prefixed keys; prefer stable scalars over ad-hoc objects. -
Lazy formatting:
logger.debug("msg %s", val), not an f-string. This skips the interpolation entirely when the level is filtered, and keeps aggregator grouping intact (an f-string makes every call site a unique message). Guard an expensivevalwithif logger.isEnabledFor(logging.DEBUG). -
Increment
stacklevelfor each wrapper layer so%(filename)s:%(lineno)dand OTel'scode.filepathpoint at the real caller; re-check whenever call depth changes. -
For an object with stable identity (a repository, a remote, a sync run), use
LoggerAdapterinstead of repeating the sameextraon every call. -
Level by audience, not severity of code path:
Level Use for Examples DEBUGInternal mechanics, VCS I/O VCS command + stdout, URL parsing steps INFORepository lifecycle, user-visible operations Repository cloned, sync completed WARNINGRecoverable issues, deprecation, user-actionable config Deprecated VCS option, unrecognized remote ERRORFailures that stop an operation VCS command failed, invalid URL Config discovery noise belongs in
DEBUG; only a surprising or user-actionable config issue rises toWARNING. -
logger.exception()only inside anexceptblock you are not re-raising from.logger.error(..., exc_info=True)when the traceback is needed outside anexceptblock. Avoidlogger.exception()followed byraise— it duplicates the traceback. -
Avoid: f-strings/
.format()in log calls; unguarded logging in hot loops; catch-log-reraise without adding context;print()for diagnostics; logging a secret env var's value (log the key name only); non-scalar ad-hoc objects inextra; requiring customextrafields in a format string without a safe default (a missing key raisesKeyError).
Message wording — lowercase, past tense, no trailing punctuation — and the stdout/stderr split are prose policy, not a workflow step: WRITING.md.
Sphinx generates the documentation. Build it:
$ just build-docsPreview with live reload while editing:
$ cd docs$ just startjust build-docs is also the only check that catches a broken MyST
cross-reference — build the docs before committing a change under docs/.
See MyST roles for the role and
anchor conventions the build enforces.
Never create tags. Never push tags. The owner handles tagging and tag pushes, because a tag triggers the publish workflow. See Release commits.
- Update
CHANGES: add the## vcspull vX.Y.Z (YYYY-MM-DD)header below the unreleased placeholder'sEND PLACEHOLDERmarker. - Bump
versioninpyproject.tomland__version__insrc/vcspull/__about__.py— both are hardcoded and must match; neither is derived from the other. - Commit the bump, then create a signed tag:
git tag -s v<version>. - Push the branch, then push the tag:
git push --tags.
Pushing the tag is what starts the release: .github/workflows/tests.yml's
release job runs on push to a refs/tags/* ref, builds the package, and
publishes it to PyPI via trusted publishing. There is no separate manual
uv build / uv publish step.
One subject per pull request. Unrelated cleanup found along the way belongs in its own commit, and usually in its own pull request.
Discuss a substantial change via an issue before making it.
Commit format is in WRITING.md.
You may merge the pull request once you have the sign-off of one other developer. If you do not have permission to do that, request a reviewer to merge it for you.
- Participants will be tolerant of opposing views.
- Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
- When interpreting the words and actions of others, participants should always assume good intentions.
- Behaviour which can be reasonably considered harassment will not be tolerated.
Based on Ruby's Community Conduct Guideline.
Please do not open a public issue for a vulnerability. Report it privately through the repository's Security tab on GitHub.