Contributing

Development environment

Vagrant can be used to develop and test changes to otto. After installing Vagrant, run:

vagrant up            # start the dev VM and three test VMs
vagrant ssh           # connect to the `dev` VM (the default)

The dev VM is privately networked with three test VMs (test, test2, test3). Develop and test from the dev VM — unit, integration, and end-to-end tests assume connectivity to the test VMs.

Development setup

Otto uses uv for dependency management. Once the repo is cloned in the dev VM:

make dev              # runs `uv sync` and sets up git hooks
source project_env    # optional: sets up usage with test repos
uv run pytest         # run the test suite

make dev places otto at otto-sh/.venv/bin/otto.

Branching and commits

All work branches off main. main is protected — direct pushes are rejected, so every change lands via a pull request.

git checkout main
git pull --rebase
git checkout -b <type>/<short-description>

Use one of these branch prefixes:

Prefix

Use for

feature/

New functionality

fix/

Bug fixes

chore/

Tooling, deps, CI, refactors

docs/

Documentation only

Examples: feature/add-ssh-retry-logic, fix/gcda-parse-error-on-empty-file.

Keep commits focused — one logical concern per commit. Use Conventional Commit prefixes in the message subject:

Prefix

Meaning

feat:

New feature

fix:

Bug fix

chore:

No production code change

docs:

Documentation only

test:

Tests only

refactor:

Code restructuring, no behavior change

ci:

CI/CD configuration

Before pushing, run make all locally — it mirrors CI (clean-dist typecheck coverage docs build).

Keeping your branch up to date

Always rebase, never merge, so history stays linear:

git checkout main
git pull --rebase

git checkout <your-branch>
git rebase main

Resolve conflicts commit by commit during the rebase (git add <file> then git rebase --continue, or git rebase --abort to start over). Push with --force-with-lease — it refuses to clobber upstream commits you haven’t seen:

git push origin <your-branch> --force-with-lease

Pull requests

PRs target main. Link the related issue in the body using a closing keyword so it auto-closes on merge:

Closes #42

Open as a draft while work is in progress, then mark Ready for review once make all is green:

gh pr create --draft --base main --title "feat: add SSH retry logic"

A maintainer will squash and merge once approved — you do not need to squash yourself. After merge, delete the branch and pull main:

git checkout main
git pull --rebase
git branch -d <your-branch>

PR checklist

  • [ ] Commits follow the conventional commit format

  • [ ] make all passes locally

  • [ ] Branch is rebased on the latest main

  • [ ] Related issue linked (Closes #N)

  • [ ] CHANGELOG.md updated under ## [Unreleased] for user-facing changes

  • [ ] No manual edits to the version string

Version management

Versioning is owned by maintainers and driven by bump-my-version. Do not hand-edit the version field in pyproject.toml — your PR will be asked to revert the change.

For user-facing changes, add an entry to CHANGELOG.md under the ## [Unreleased] section. The format follows Keep a Changelog (Added, Changed, Fixed, Removed). When a release is cut, the maintainer runs bump-my-version to promote [Unreleased] to a numbered version and update pyproject.toml in the same commit.

Running tests

make test                     # run all tests
make test TESTS=test_host     # filter by keyword
make coverage                 # run tests and enforce coverage threshold

Cross-version testing with nox

make ci runs the unit suite under one Python (whichever uv resolves by default). To exercise the full matrix the way CI does — Python 3.10 through 3.14 — use nox:

make nox                       # full matrix: all Pythons + lint + typecheck + docs
uv run nox -s tests-3.12       # just one Python's tests
uv run nox -s tests-3.14 -- -k test_session    # forward args to pytest
uv run nox --list              # show every available session

Nox sessions are defined in noxfile.py and use uv as the venv backend via nox-uv, so each session reuses the same lockfile-resolved deps as local development. The toolchains themselves come from uv python install 3.10 3.11 3.12 3.13 3.14 (run once per machine). make all is unchanged and remains the dev-VM contract — nox covers the cross-Python axis that make all doesn’t.

Documentation

Building docs

make docs          # HTML output + doctests
make doctest       # run doctests only (from .md/.rst files)
make docs-html     # HTML only (warnings are errors)

pytest also runs doctests from Python source files automatically via --doctest-modules.

Documentation layout

docs/
├── overview.md          # Project overview
├── getting-started.md   # Installation and first steps
├── guide/               # Narrative user guides (Markdown)
├── cookbook/             # Recipes with doctest examples (Markdown)
├── contributing.md      # This page
└── api/                 # API reference (reStructuredText, auto-generated)

Narrative guides go in guide/ or as top-level Markdown files. API reference pages live in api/ and use .. automodule:: directives to pull documentation from docstrings.

Docstring rules of thumb

  • New public function or class? Add a Google-style docstring with a one-line summary, Args/Returns sections, and a >>> example if the function is pure and deterministic.

  • Changed a function’s signature or behavior? Update its docstring and any >>> examples to match. Stale doctests will fail in CI.

  • Async, I/O, or nondeterministic code? Write a docstring with >>> examples. Also test these in tests/unit/.

  • Keep doctests minimal. 2-4 lines showing the happy path is enough. Edge cases belong in unit tests.

Doctest quick reference

In Python source files (collected by pytest):

def add(a: int, b: int) -> int:
    """Add two numbers.

    >>> add(1, 2)
    3
    """
    return a + b

In Markdown documentation files (collected by Sphinx):

```{doctest}
>>> from otto.utils import Status
>>> Status.Success
<Status.Success: 0>
```

Common imports (Status, CommandStatus, LocalHost) are pre-loaded in doc-file doctests via doctest_global_setup in docs/conf.py.

Coverage reports

From pytest

make coverage

Manually

uv run coverage run --source=otto --context=manual -m otto <subcommand> [args]
uv run coverage html  # writes to reports/coverage/html (see .coveragerc)

Type checking

ty (from astral) is being trialled as a replacement for pyright. The project keeps a [tool.pyright] block for Pylance/VS Code, while [tool.ty] drives the CLI checker and the optional ty language server.

make typecheck     # run ty check against src/ with all rules at error

Config lives under [tool.ty.*] in pyproject.toml. ty is pinned to an exact version (ty==0.0.31) because its 0.0.x releases allow breaking diagnostic changes between any two versions — floating the pin would cause unannounced CI churn.

Work the count down with per-line # ty: ignore[rule-name] suppressions (justified in the surrounding context) or by fixing the underlying type. Do not silence rules globally in [tool.ty.rules] — an individual demotion there needs to be defensible in review.

Use uv run ty explain rule <name> for the full rationale and examples behind any diagnostic.

For VS Code: install the “Astral ty” extension (Ctrl+Shift+P → Extensions → search “ty”). It reads [tool.ty] from pyproject.toml, so LSP diagnostics and make typecheck stay in sync.

Performance reports

uv run pyinstrument -o profile.txt -m otto <subcommand> [args]

AI-Assisted Contributions

AI coding tools (e.g., GitHub Copilot, Claude, Cursor) are permitted for contributions to otto. If your PR contains AI-assisted code, please note it in the PR description. Regardless of how code was generated, contributors are responsible for understanding, testing, and owning what they submit.