AGENTS.md
This file provides guidance to AI agents when working with code in this repository.
Oxen is a fast, unstructured data version control system written in Rust. It's designed to version large machine learning datasets efficiently and provides both a CLI tool and server implementation.
The Cargo workspace lives at the repository root, with crates under crates/:
crates/liboxen/- Core shared library (liboxen)crates/oxen-cli/- Command-line interface binary (oxen)crates/oxen-server/- HTTP server binary (oxen-server)crates/oxen-py/- Python bindings (Rust source foroxen-python)oxen-python/- Python package source, tests, andpyproject.toml
The project follows a workspace structure with crates in the crates/ directory:
liboxen/- Core shared library (liboxen) containing all the business logicoxen-cli/- Command-line interface binary (oxen)oxen-server/- HTTP server binary (oxen-server)
The CLI and server both depend on the shared library to avoid code duplication. All core functionality should be implemented in the lib first, then exposed through appropriate interfaces in the CLI and server.
core/- Core data structures and database operations (RocksDB for metadata, DuckDB for tabular data)model/- Data structures representing commits, branches, entries, diffs, etc.repositories/- Repository operations (init, clone, add, commit, push, pull, etc.) - Most high-level operations start hereview/- Response/view models for API endpointsstorage/- Storage backends (local filesystem, S3)
- Uses RocksDB for metadata and version control information
- Uses DuckDB for tabular data processing and querying
- Implements Merkle trees for efficient change detection
IMPORTANT: Our codebase assumes cargo commands are run on the whole workspace, from the workspace root, NOT on specific packages.
GOOD: cargo check --workspace
BAD: cargo check --package liboxen
cargo build --workspace # Debug build
cargo build --workspace --bins --tests # Debug build that a later `cargo test` reusesA cargo build that a cargo test follows, in a script or a CI job, builds --bins --tests with the same packages and features the test command selects, as bin/test and the Rust Tests jobs do. Dev-dependencies add features to crates such as hyper and getrandom, so a plain cargo build resolves a different feature set, and the cargo test after it compiles a second copy of every dependency above them, DuckDB, RocksDB, polars and the AWS SDK included.
Use the bin/test script to run the tests — it is the standard, supported path. The script builds the workspace, raises the file-handle limit, sets up a ramdisk for test data, starts oxen-server on a free port, exports the environment the tests expect, runs the suite with cargo test --workspace --no-fail-fast, and tears everything down on exit. Its full usage is documented in a comment at the top of the script. Running cargo test directly is supported only if you first reproduce that setup by hand (a running oxen-server on the default host/port, user config, a raised file-handle limit, and the env vars the script exports — see the "Manual Test Setup" section of crates/liboxen/README.md); without that setup the tests fail, so prefer bin/test.
bin/test # Build and run all Rust tests
bin/test test_function_name # Run only Rust tests matching test_function_name
bin/test --timings test_name # Report each matching test's duration
bin/test -p # Run the Python test suite (via pytest + maturin)
bin/test -p -k test_init # Run Python tests matching test_init- The script starts
oxen-serveritself, so you do not need to start it separately. - It does not install prerequisites by default. If a dependency is missing, run
bin/install-prereqs(or re-run withbin/test --install-deps). - If the ramdisk cannot be mounted, pass
--no-ramdiskto run against the regular filesystem. --timingsprints each test's duration next to its result; plaincargo testreports only a per-binary total. Doctests are skipped. When comparing numbers, discard the first run after a build (a cold run reads two to three times high) and keep both sides on the same base commit (main moves fast enough that an older base is a different suite).- Run both sides of a timing comparison from fresh
git worktreecheckouts, and alternate them. A long-lived working tree reports the same unchanged tests markedly slower than a worktree of the same commit does (a 23-test set summed 14.8s in one against 11.5s in a worktree, with identical code), so a working-tree-against-worktree comparison charges that gap to whichever side the working tree is on. Alternating the arms covers the other half: run them in a fixed order and a drifting machine shows up as a change in whichever side always runs second. Sanity-check every result by summing the tests the change did not touch, which should match across arms to a percent or two, and distrust the comparison when they do not. - Measure what a test costs with
--test-threads=1. A duration from a parallel run includes the time a test spent waiting on its neighbors, even when the filter selects a single module: the 49repositories::commits::tests summed 12.7s run together and 2.7s run one at a time. A change that removes tests therefore makes the tests it left alone look faster in a parallel run, which fails the untouched-tests check above for a reason that has nothing to do with the change. - Arguments after the script's own flags are forwarded after
--to the libtest binaries (orpytestunder-p). - Output the terminal doesn't show goes to two log files, both overwritten per run:
./data/test/oxen-server.log(server subprocess stdout+stderr) and./data/test/cargo-test.log(cargo test's stderr — indicatif progress bars, and anytracingoutput emitted from tokio worker orspawn_blockingthreads that don't inherit libtest's per-thread capture). Check these first when the terminal report doesn't explain a failure.
env RUST_LOG=warn,liboxen=debug,integration_test=debug bin/test --no-capture test_namecargo fmt --all # Format code
cargo clippy --workspace --no-deps -- -D warnings # Lint code
pre-commit run --all-files # Run pre-commit hooks (runs format and lint)The liboxen::test helper module is gated behind the test-utils feature so it isn't compiled
into release builds. Benches that consume it (download, fetch, push, workspace_add)
declare required-features = ["test-utils"] and are skipped unless the feature is enabled:
cargo bench --features liboxen/test-utils # Run all benches
cargo bench --features liboxen/test-utils --bench pushDownstream crates (oxen-cli, oxen-server) enable test-utils via dev-dependencies, so their
own tests can call liboxen::test::* as before.
ulimit -n 10240 # Increase file handles before running the server
bacon server # Start server with live reloadexport PATH="$PATH:/path/to/Oxen/target/debug"
oxen init # Initialize repository
oxen status # Check status
oxen add images/ # Add files
oxen commit -m "message" # Commit changes
oxen push origin main # Push to remote- We define module exports in a
<module_name>.rsfile at the same level as the correspondingmodule_name/directory and NOT the oldermod.rspattern. - Prefer importing bare items (structs, enums, traits, functions, constants, macros) and referring to them unqualified, rather than importing a parent module and qualifying at each use site — e.g.
use std::time::Duration;thenDuration::from_secs(5), notstd::time::Duration::from_secs(5). Exception: keep enough of the path to disambiguate when a bare import would be ambiguous or misleading, such as two same-named items from different modules, or where a module qualifier is the established idiom (the classic case isuse std::fmt;thenfmt::Resultto avoid clashing with the preludeResult); reach for anasalias when that reads better than a module qualifier. - Take the concrete type, and make a generic earn its place. When you write or change a function, give each parameter the type its callers pass:
&str,&Path,&OsStr,&[T],String,PathBuf. Deref coercion already turns&String,&PathBuf,&OsString, and&Vec<T>into the borrowed form, so a conversion bound (impl AsRef<_>,impl Into<_>,impl Borrow<_>) or a type parameter buys those callers nothing. It only adds monomorphization cost and.as_ref()/.into()noise in the body.- Add a generic, or keep an existing one, only when the function must accept whatever type its caller has, such as one taking any iterator (
impl Iterator<Item = T>) or any reader (impl Read). - Callers passing different types is not a reason, even when coercion can't unify them: if some pass a
&strand others a&PathBuf, take&Pathand let the&strcallers writePath::new(s). Nor is a lone""/String::new()caller: let it write.to_string(), or take&strif no ownership is needed. Nor is matching the surrounding functions, even though much of this codebase usesimpl AsRef<str>andimpl AsRef<Path>. - Only reconsider a function's generics if you're already changing that function for the task at hand. Leave generics in unrelated functions alone, even if you'd write them differently.
- Add a generic, or keep an existing one, only when the function must accept whatever type its caller has, such as one taking any iterator (
- Give every new item the narrowest visibility that compiles, and widen only when a real caller at that distance appears. Order of preference: private →
pub(super)/pub(crate)→pub. Inliboxen,pubcarries a specific meaning — this is intended foroxen-server,oxen-cli, or the Python bindings to call. An item that ispubmerely because nobody chose otherwise is unearned public API that future refactors have to preserve. Applies to every newfn,struct,enum,trait,const, type alias, field, and module, and to each name added to apub usere-export (a re-export is public surface too). The asymmetry is the reason: widening later (private →pub) is backward-compatible and cheap, narrowing later (pub→ private) is a breaking change. Two things that catch people out: a child module can already read its ancestors' private items throughsuper, so a helper shared between a parent and its children needs nopubat all; and apub fnreturning a third-party type leaks that dependency into the wider API even when the module otherwise contains it. When an item genuinely is the intended surface,pubis correct — the rule is against breadth by default, not breadth by intent.
- Read docs/error_handling.md before writing or changing any error handling, and follow it. Not a reference to consult when stuck — read it up front whenever a change adds an
OxenErrororOxenHttpErrorvariant, changes how one is raised or classified, alters an HTTP status or a log level, or toucheshint,is_not_found,is_fatal_for_retry, orerror_response. It carries a checklist for the parts that are routinely missed, because every one of them fails silently: classification lives inmatcharms that fall through to a default, so a variant missing from a list still compiles, still passes the tests, and is simply classified wrong at runtime. The compiler will not catch these for you, and neither will the existing suite. - After adding a variant, walk that checklist rather than assuming the default is right: the wrong HTTP status, a missing hint, an absent not-found or fatal-for-retry classification, and a variable interpolated into a log message each have a specific and non-obvious cost spelled out there.
- Use the result type (
Result<T, Error>) when an operation could fail. - Never use
.unwrap()or.expect()on aResultor on anOption.- Exception: In test-only code, it is ok to use use
.expect(<descriptive explanation of invariant that was violated>)since we want to fail fast and have good stack traces for failing test cases. - This rule still applies when the panic feels "guaranteed unreachable" because of an upstream invariant. If the enclosing function returns a
Result, propagate it with?— a panic in production code is never preferable to a clean error path, no matter how confident you are the case can't happen.
- Exception: In test-only code, it is ok to use use
- If an error type is acted upon in code, then use as specific of an error type as possible. Don't use a wider type unless it's necessary. When making modules and related pieces of code, try to use a locally-defined error enum for them if they all have similar errors.
- liboxen uses
OxenErroras the top-level type for errors. Unify different error types underOxenError. Fallible functions should returnResult<T, OxenError>. Do not create or use additional error types outside ofOxenErrorif it can be avoided. If an error is never inspected internally and cannot be returned to a caller of the liboxen library through a public API, then useOxenError::InternalErrorwith a formatted string. If an error is inspected or can be returned to a caller of the liboxen library through a public API, make a structured error variant on theOxenErrorenum. - oxen-server uses
OxenHttpErroras the top-level type for errors. AllOxenErrorvariants that we want to differentiate to the caller of the oxen-server API should be mapped to specificOxenHttpErrorvariants. Otherwise they should be mapped toOxenHttpError::InternalServerError. Do not create or use additional error types outside ofOxenHttpErrorif it can be avoided. - Implement proper error propagation through the
?operator.
- This repository is public. Do not mention Oxen's private/internal repositories — by name or description — in code comments, doc-comments, error messages, commit messages, PR titles or descriptions, or any other code or documentation committed here. Keep references to private repos out of public artifacts entirely; if internal context is genuinely needed, point to the relevant Linear issue rather than inlining private-repo details. This also bars describing a private/internal system's behavior as the justification for a change — even when the system is not named (e.g. "a downstream client loops forever without this"); describe what the code in this repo does and guarantees instead.
- When changing something that is documented in nearby code, or appears in any markdown files in the repository, update the affected documentation.
- The
oxenclient (the CLI and other user-facing client behavior) is documented on the public docs site at docs.oxen.ai, maintained in the separatedocsrepo (Mintlify/MDX — the CLI guides live undergetting-started/command-line/). After any change to the client — a new or changed command, flag, default, or user-visible output — check whether a docs page needs updating, and update it (likely as a separatedocs-repo change) or call out the gap. - The Python API pages on docs.oxen.ai are generated from the
oxen-pythondocstrings bygenerate-python-docs.shin thedocsrepo, so a docstring change is not published until those pages are regenerated. After editing a docstring underoxen-python/python/oxen/, or adding a method or property to one of those classes, regenerate them as a separatedocs-repo change by running<docs-checkout>/generate-python-docs.sh <docs-checkout>/fromoxen-python/. The script rewrites all of its pages and has no per-page flag, so that change carries every page whose docstrings moved. Write each docstring for what pydoc-markdown does with it: an indentedword: valueline inside an argument's description starts a new argument on the generated page, so aDefault: Falsesitting on its own line publishes aDefaultargument that does not exist. Keep such a sentence on the same line as the description it belongs to. - When prompted to always do something a certain way in general, add an entry to this section of the AGENTS.md file.
- Server-global state lives directly under
$SYNC_DIR, never under a$SYNC_DIR/.oxen/directory. A file goes at$SYNC_DIR/<name>and a set of files in$SYNC_DIR/<dir>/, alongsidelast_migration.txtand thename_table/index. A.oxendirectory means "this is a repository" everywhere else in the product, so one at the top of the sync dir makes the whole data directory answer toutil::fs::get_repo_root, which the CLI walks up from the working directory to find. Two things a new top-level entry has to do: add its name toSERVER_OWNED_DIRSincrates/liboxen/src/sync_dir.rs, sincenamespaces::listandnamespaces::getotherwise report the directory as a namespace onGET /api/namespacesandGET /api/namespaces/<name>, andsync_dir::namespace_dirs, which every walk over the server's repositories starts from, treats it as one, and leave it out ofSelfHosting.md, which covers what an operator configures and sees rather than the server's internals (last_migration.txtis likewise absent) — document there only the operator-visible behavior the state creates, if any. - Until 1.0,
SelfHosting.mdcovers the current state of hosting, not how it got there. Leave out past behavior, legacy on-disk forms, upgrade notes, and migrations (a step a CLI user or self-hosted operator runs on upgrading goes indocs/migrations.md) — an operator reading it wants to know what to configure and what the server does now. When a current behavior carries a caveat that applies only to repositories an older release created, drop the caveat rather than explaining the older release in order to justify it. - A RocksDB database a repository opens by path goes through a
WeakDbCacheregistry (crates/liboxen/src/core/db/weak_cache.rs), the way refs, staged, the workspace name index, and the commit count caches do: aLazyLock<WeakDbCache<_>>static with its own warm capacity, opening throughget_or_open. Never hold a process-wide lock acrossDB::open, which makes every repository's open wait on every other's, and never reopen the database on every call. Kept-warm handles hold the database's files open after the last caller drops them, so code that moves or removes a directory holding one evicts through the store'sremove_*_from_cache_with_childrenboth before and after the filesystem call, capturing the call's result and propagating it after the second eviction, since a handle opened while the move or removal runs keeps the old files open. A new registry also gets an eviction call in the test cleanup helpers that remove repository directories (maybe_cleanup_repoin liboxen,cleanup_sync_dirin oxen-server), which evict every registry first. - When calling
get_staged_db_manager, follow the doc comment on that function: drop the returnedStagedDBManageras soon as possible (via a block scope or explicitdrop()) to avoid holding the shared database handle longer than necessary. - When altering the
OxenErrorenum, consider whether a hint needs to be added or updated in thehintmethod. - Instead of using
cargo testto test Rust code, use thebin/testscript. The script usage is documented in a comment at the top of its file. - If the ram disk is not able to be mounted in
bin/test, then use the--no-ramdiskoption. - The
bin/testscript does not install prerequisites by default. If any dependencies turn out to be missing, prompt the user to runbin/install-prereqs(or re-runbin/test --install-deps). - Prefer using inline code over creating a new function when the function would only be called once and the function body would be less than 15 lines.
- Do not use "out parameters" (functions that take an
&mut Vec/&mut HashMap/ etc. for the callee to fill). Return the value directly instead. Exceptions: the user explicitly asks for an out parameter, or the caller genuinely needs to reuse a pre-allocated buffer across many calls to avoid allocation churn in a measured hot path. - Don't add type annotations the compiler doesn't need. Let inference do its job and annotate only where the code won't compile (or won't resolve to the type you intend) without it — e.g. a
collect()/parse()/sum()whose target type is otherwise ambiguous, or an integer literal that needs pinning. Redundant annotations on bindings whose type is already obvious from the right-hand side just add noise and rot when the expression changes. - Version-gated deprecation checks must inline the triggering version as a literal right at the comparison site, with a brief comment naming what it gates and pointing to
docs/deprecations.md. Do not hoist it into a named constant defined in another module/crate (e.g.crate::constants::SOME_VERSION): the point of the check is to see which version trips it while reading the check, and the indirection defeats that. (This is distinct from broad, widely-reused version floors likeMIN_OXEN_VERSION, which legitimately stay named constants.) - When adding server endpoints that supersede existing ones, introduce them additively alongside the old endpoints rather than replacing them. Switch the client to the new path, register the old endpoints in
docs/deprecations.mdwith a removal target ~5 minor versions out, and remove them in a later tech-debt PR. Avoids flag-day breakage for older clients still in the field. - Preserve code comments whenever possible. Comments that were written by someone other than Claude should always be preserved or updated if possible.
- The set of merkle nodes + version blobs reachable from a commit is the reachable objects / reachable set — do not call it the "closure" or "object closure." Applies to code identifiers, comments, error messages, commit messages, and issue text. (The word "closure" is fine in its unrelated senses: Rust closures.)
- Function doc-comments describe what the function does, not how it does it — unless the "how" is caller-relevant (ordering constraints like "call before
commit()", cross-filesystem rules like "src and target must live on the same filesystem", thread-safety invariants like "never hold a guard across.await"). The body shows the implementation; the doc-comment's job is to convey the contract callers rely on, which rots if it duplicates internal mechanism. Trim "stamps the temp file with mtime before the rename" to "the published file carries mtime"; trim "uses the Channel hand-off helper" to whatever visible behavior the caller observes. Applies to public and private doc-comments alike, and to inline comments that risk narrating implementation details rather than describing current behavior. - Before manually serializing a value (hand-built
serde_json::json!({...}), a one-off request/response struct, format-string assembly), check whether an existing type or helper already serializes it. Most domain types already deriveSerialize/Deserialize(and oftenutoipa::ToSchema), and there are usually conversion helpers between them (e.g.UserConfig::to_user()yields aUserthat serializes to the exact{name, email}the merge endpoint expects). Reuse the existing type with.json(&value)/serde_json::to_*and deserialize back into that same type rather than re-describing its shape by hand — the manual version silently drifts from the canonical type when fields change. Introduce a new serialization struct only when no existing type fits. - The Python project calls into the Rust project. Whenever changing the Rust code, check to see if the Python code needs to be updated.
- Always update the corresponding Python interface when a Rust model or view gains a field.
PyWorkspace,PyCommit,PyRemoteRepo, and the othercrates/oxen-py/src/py_*.rswrappers each mirror a Rust type, and so do theiroxen-python/python/oxen/*.pycounterparts. A new field on the Rust side is not done until both layers expose it — do not conclude "Python doesn't need this" merely because the crate still compiles and no test fails. Follow the existing precedent for the field's type (PyCommit::timestampreturns aString, so a timestamp crosses as an RFC 3339String, not a native object), add the getter to the#[pymethods]block, and add the matching@propertywith a docstring to the Python class. - After changing any Rust or Python code, verify that Rust tests pass with
bin/testand Python tests pass withbin/test -p - When updating a dependency, prefer updating to the latest stable version.
- When adding or updating GitHub Actions in
.github/workflows/, pin them per the org-wide policy in docs/github_actions_pinning.md — version tag for first-party/high-trust orgs, full commit SHA (with a# vX.Y.Zcomment) for third-party. Re-run failed jobsnever works for the release/build jobs that run on self-hosted EC2 runners (Windows, Linux, and Docker jobs inrelease_*.ymlandbuild_wheels_*.yml). Those runners are provisioned per-attempt by a siblingstart-self-hosted-runnerjob and terminated by astop-self-hosted-runnerjob at the end of the attempt. A re-run of only the failed job reuses the succeeded start job's cached output — aruns-onlabel whose instance no longer exists — so the job sits queued until GitHub times it out (~24h) with no error. UseRe-run all jobs, which re-executes the start job and provisions a fresh runner, or push a new tag. Diagnose it by comparing the start job'sstarted_atagainst the failed job's: a start job timestamped from the earlier attempt is the tell.- Code that touches IO follows the sync-core / async-edge policy in docs/async_policy.md. Summary: public APIs are
async fn; network IO (AWS SDK, reqwest, etc.) uses native async APIs; filesystem and sync-DB IO (std::fs, RocksDB, LMDB, DuckDB) runs insidetokio::task::spawn_blockingat operation granularity, not per syscall; DB transactions live entirely inside onespawn_blockingclosure (never spanning.await); CPU-parallel batch work usesrayoninside onespawn_blocking, notFuturesUnordered<spawn_blocking>; streaming sync↔network IO uses a long-livedspawn_blockingtask paired with the async side viatokio::sync::mpsc. Do not reach fortokio::fsin hot loops — it dispatches each syscall through the blocking pool and pays per-call overhead. See the doc for the full patterns (Bracket, Sandwich, Channel hand-off) and anti-patterns. - In
oxen-server, move work off the request thread withcrate::tasks::spawn_blocking(blocking closures) orcrate::tasks::inherit_hub(a future, beforetokio::spawn/JoinSet::spawn) instead oftokio::task::spawn_blocking,tokio::spawn, oractix_web::web::block. The per-request Sentry hub and the current tracing span are thread-locals bound only around polls of the request future, so a task spawned the bare way reports a panic with no route and no request, and its spans are exported detached from the request's trace. Usecrate::tasks::spawn_blocking_per_itemfor blocking work dispatched once per item in a loop — same context, but no span of its own, because a bulk endpoint handling thousands of files per request would otherwise emit a span per file. Recognize it by asking whether the response is still in flight: if the handler awaits the task, or the task produces the response body, it needs the request's context — and a response-body future must be bound at construction time inside the handler, since the middleware's binding is gone by the time a body is polled. Tasks that deliberately outlive their response (a background delete, a cleanup at stream EOF, a process-lifetime loop) keep using tokio's own spawn. This isoxen-serveronly: liboxen calls tokio directly because it must not depend onsentry. When auditing, rememberactix_web::web::blockis aspawn_blockingthat nospawn_blockingsearch will find. Full rationale and the checklist: docs/async_policy.md. - Streamed IO (anything reading or writing through an
AsyncRead/AsyncWritewhose total length isn't bounded ahead of time) must use a large buffer rather than rely ontokio::io::copy's 8 KB default. Wrap the write side intokio::io::BufWriter::with_capacity(10 * 1024 * 1024, ...)(and the read side inBufReaderif the source isn't already buffered), and remember to explicitlyflush().await?theBufWriterbefore any downstreamsync_all/rename/checksum step —BufWriter'sDropdoes not auto-flush, so unflushed bytes are silently dropped. The canonical example is the S3 store's local-cache path incrates/liboxen/src/storage/s3.rs(copy_version_to_path). See docs/async_policy.md for the broader async/sync context this fits into. - All production filesystem writes go through
AtomicFile(crates/liboxen/src/util/fs.rs) — neverstd::fs::write/tokio::fs::write/File::createto a canonical path directly.AtomicFilewrites to a sibling temp file and atomically renames over the target, so a crash mid-write can never leave a torn or partially-written canonical file. Chain.with_hash(h)for content-addressed writes and.with_mtime(t)when publishing into the working tree. The non-atomic writersutil::fs::writeandutil::fs::write_to_pathare gated to test /test-utilsbuilds for fixture setup only. Detailed contract on theAtomicFilestruct rustdoc. - Prefer
bytes::Bytesover&[u8]andVec<u8>for byte payloads that cross a module, trait,spawn_blocking, or external-SDK boundary.Bytesis'static + Clone + Sendand refcounted:Bytes::from(Vec<u8>)reuses the allocation (zero copy),Bytes::from_static(b"...")is compile-time (zero cost), and cloning aBytesis a refcount bump rather than a memcpy. Most async IO ecosystems we touch (axumrequest bodies,reqwest::Response::bytes_stream,aws_sdk_s3::primitives::ByteStream) speakBytesnatively — handing them aVec<u8>costs an allocation and handing them a&[u8]costs an allocation and a memcpy. The canonical example isVersionStore::store_versionincrates/liboxen/src/storage/version_store.rs: the trait signature went fromdata: &[u8]todata: Bytesso the in-memory writers can move bytes across thespawn_blockingboundary as a refcount transfer and the S3 impl can pass through toByteStream::fromwithout a.to_vec()copy.&[u8]is still the right type for transient borrows that don't cross a boundary (hashing once and discarding, inline inspection); useBytesMutfor growable buffers andfreeze()intoByteswhen handing them off (e.g. theread_buf+split().freeze()pattern inAtomicFile::stream_async). - Stream a file's content rather than buffering the whole thing into memory: a function that reads or writes file data should pass it incrementally, using whatever streaming primitive fits the layer — an
AsyncRead/AsyncWrite, a byte-chunkStream(e.g. theBoxedByteStreamalias the version store and client return), a chunked reader, an HTTP body — instead of materializing a whole-fileVec<u8>/Bytes. Every path that handles file data needs to eventually handle files larger than memory, so whole-file reads don't scale. Where a caller genuinely needs the entire payload in memory, collect it inline at the call site (a visiblewhile let Some(chunk) = stream.next().await { buf.extend_from_slice(&chunk?); }drain, orread_to_endfor anAsyncRead) rather than hiding it behind a shared collect helper — keeping each buffering site apparent and greppable for future migration to full streaming. (This rule is about whether to hold the whole payload at all; theBytes-over-&[u8]rule just above is about which type to use for payloads you do hold.) - When computing the number of fixed-size chunks needed to cover a total byte count, use
total_size.div_ceil(chunk_size), not(total_size / chunk_size) + 1. The+1form overshoots by one whentotal_sizeis an exact multiple ofchunk_size, producing a spurious zero-byte chunk request that the server rejects with HTTP 500 "beyond end of file" — see the bug fixed indownload_large_entryfor what this looks like in production. - oxen-server operations should never touch a local checkout on disk when doing operations initiated by its API.
- When traversing a repository's working tree, use
metadata.is_dir()instead ofpath.is_dir().path.is_dir()follows symlinks, which Oxen does not track — using it risks descending into directories outside the working tree (or into cycles via cyclic links). This applies to the working tree only: walks over oxen-server's sync dir and over a repository's.oxeninternals follow symlinks, as the server does when it serves a repository. - Oxen does not track symlinks. New code that traverses the working tree should check
metadata.is_symlink()and skip rather than resolve, follow, or record symlinks. - Never use
unsafecode when a safe alternative would meet our needs. Reach forunsafeonly when there is a concrete reason no safe construct will do (e.g. FFI, a measured performance requirement that safe code cannot satisfy, no clear alternative); in that case, justify the choice in a comment at theunsafesite. - Any
unsafeblock orunsafe fnmust be preceded by a// SAFETY:comment that explains why the operation is sound — which invariants the caller relies on, why they hold here, and what would break if they didn't. Follow the Rust style guide: https://std-dev-guide.rust-lang.org/policy/safety-comments.html. For anunsafe fn, document the caller's obligations with a# Safetysection in the doc comment, and pair each call site with its own// SAFETY:comment justifying that those obligations are met.
- Use the test helpers in
crates/liboxen/src/test.rs(e.g.,run_empty_local_repo_test) for unit tests in the lib code. - When picking a helper from
crates/liboxen/src/test.rs, choose the lightest one that meets your test's actual needs. Rough cost order, cheapest first:run_empty_dir_test_async→run_empty_local_repo_test_async→run_one_commit_local_repo_test_async→run_readme_remote_repo_test(remote with a single README pushed) →run_one_commit_sync_repo_test(local + remote, one inline commit) →run_training_data_repo_test_no_commits_async(training files written, not committed) →run_training_data_repo_test_fully_committed_async(training files + 6 commits, no remote) →run_training_data_fully_sync_remote(training files + 6 commits + remote + push; ~1–2s per test). Reach for the training-data helpers only when your assertions depend on the specific tree structure (paths undernlp/classification/,annotations/test/, etc.). A test that just modifies, renames, or asserts aboutREADME.mdbelongs onrun_readme_remote_repo_test, notrun_training_data_fully_sync_remote. A test whose only training-tree file isannotations/train/bounding_box.csvbelongs onrun_bounding_box_csv_repo_test_fully_committed_async(local, one commit) orrun_remote_repo_test_bounding_box_csv_pushed(pushed to a remote), which write that file with the same rows the training tree does. - A helper that hands a test back a
(LocalRepository, RemoteRepository)pair has already attached that repo to that remote, so a test needing two actors takes that repo as one of them and clones only the other. Opening with|_, remote_repo|and then cloning twice builds a repo the helper already built. Which role it plays depends on the scenario: it is the user who advances the remote when the test is about a clone that has fallen behind, and it is the user who pushes first when the test is about the second clone hitting a conflict. - Add assertions to a test that already builds the right fixture rather than writing a new test, and go looking for that host before writing the test rather than after review finds it. A new test pays for its whole fixture again, priced by the list above. A test's name only has to name something important the test checks, not everything it checks, so a name that fits less well after the addition is not a reason to build a second fixture — put the rest in the assertion message, as in
assert_eq!(mergeability.conflicts.len(), 1, "both branches rewrote README.md"). Write a separate test only when carrying the assertion would need a tear-down and re-setup partway through the host, when the host's fixture data decides the expected value (asserting against a value computed from the fixture usually dissolves that), or when the host becomes hard to follow. Two tests in one file that open the same helper and reach the same state before their first assertion are one test, and so are two whose scenarios are prefixes of one another: assert at the earlier state, then keep building to the later one and assert again. The prefix case holds across files too: a test in one module that stops partway through a scenario another module's test runs to completion (a subtree clone and commit that a push test also clones, commits, and then pushes) is already covered, so search the other modules' tests for the scenario before writing it. Tests that open the same remote helper and then each clone it their own way (a subtree, a depth,--all, a branch) are one test as well, since a clone only reads the remote: clone into a separate directory per case under the one fixture, and do any step that changes the remote, such as pushing a new branch, after the clones that expect it unchanged. - A change that merges or deletes tests keeps executing every line of non-test code the old tests executed. Much of that never shows in an assertion (a server branch a fixture's setup happens to reach, an error path a misnamed test hits by accident), and reading the code cannot settle which lines a test reaches, so measure it. In a fresh worktree of the base commit and one of the change, run
eval "$(cargo llvm-cov show-env --sh)",cargo llvm-cov clean --workspace,bin/test, andcargo llvm-cov report --lcov --output-path <arm>.lcov. That environment instruments theoxen-serverthatbin/teststarts as well, which writes its counts whenbin/teststops it. Compare the non-test lines the two runs executed, matching lines by file and source text, since edits shift line numbers. Each line only the base run reached either runs again, by choosing the merged test's paths, data, or loop entries so that it does, or by adding a call and an assertion to a test whose fixture already reaches it, or belongs to code the change deletes on purpose. A line reached only by tests the change left alone is run-to-run variation, so confirm each candidate by running just the touched modules under coverage on the base worktree. - Bound a verification loop to the cases that separate a pass from a failure, because every iteration is a full execution of the code under test. A sweep over every ordered pair drawn from N commits, or one call repeated 200 times to check that the answer never varies, reads as thoroughness while costing more than the rest of its module. Ask what the loop recomputes before writing it: a reference value derived from N distinct inputs belongs outside the loop, walked once and shared across the cases, which keeps every case and every assertion. A repeated call that samples for nondeterminism the code can no longer produce covers nothing that one call does not.
- Tests that build the same fixture and then each edit it their own way are one test when each case can work in its own corner of it: give each case its own workspace under the one fixture, and its own branch when it commits, as the
repositories::workspaces::data_framestests do. Only a step that leaves the fixture unusable for the cases after it, such as deleting a commit's node, has to come last. - Decide whether merging tests pays from each test's own duration, taken with
--test-threads=1as the Testing section above describes, never from a module total taken in a full run (a module reported at 29s there sums to 1.6s alone). Dozens of tests at a few hundredths of a second that each build the same fixture spend most of that time in the fixture, and a case moved into a merged test costs only its own operations, so the merge recovers most of their summed time: 42 suchrepositories::workspaces::data_framestests summing to 1.4s became 6 summing to 0.66s. - Never sleep in a test. A commit records its time to below a second, so two commits made back to back already sort by date, and work that runs in the background gets polled at a 2ms interval against a bounded deadline rather than waited out.
- Files under
data/test/exist to serve specific tests. When you delete or rewrite a test, audit whether any fixture file underdata/test/is now unreferenced and delete the orphan in the same PR. Never add a fixture file speculatively or "for a future test" — add it only when the test that uses it already exists. - When a test needs a small directory of placeholder text files, call
test::populate_dir_with_txt_files(dir, prefix, count)instead of open-codingutil::fs::create_dir_all+ a loop overwrite_txt_file_to_path. - Never add a
```ignoredoctest. Anignorefence is neither compiled nor run, so the example rots silently while still looking maintained. Write it so it runs withoutoxen-server—cargo test --dochas to pass on its own, so an example may use the filesystem but must not need a running server or a reachable remote.liboxen::test's helpers are available to doctests:test::run_empty_dir_testfor a sync example,test::run_empty_dir_test_asyncwith a hidden# #[tokio::main]line for an async one; both make a unique temp directory and clean it up. When an example genuinely needs a server or a remote, useno_runwith a line above it saying so, or don't write the example. A doctest on a private item can never run, since it compiles as an external crate. - Only copy real fixture images into a test repo (
test::test_img_file_with_name+util::fs::copy) when an assertion depends on image content or metadata — dimensions, mime type, resize behavior. When the test just needs files that exist, get added, get modified, or get counted, write small inline text files instead:util::fs::write_to_path(dir.join(format!("cat_{i}.jpg")), format!("cat {i}")), ortest::populate_dir_with_txt_files. Change a file's content withtest::modify_txt_filerather than re-encoding an image throughutil::image::resize_and_save.add/commit/status/rmnever decode file content, so a.jpgholding text exercises the same code paths without the fixture I/O or the image encode. - Test runs override
OXEN_STREAM_SEGMENT_SIZEto 128 KiB viabin/test, so any file larger than ~128 KiB exercises the streamed-transfer / chunked-download code paths. When writing a test that needs to exercise that path, size the file viastream_segment_size() + N(where N is small — anywhere from 1 byte to ~1 MiB), not a hardcoded multi-MB value. A 1.1 MiB test file exercises the same chunked-transfer code as a 100 MB production file at a fraction of the cost. - When possible, put tests in the higher-level
repositoriesmodule rather than the lower-level, version-specific implementation.- e.g., Tests should go in
repositories/commits.rsrather thancore/v_latest/commits.rs.
- e.g., Tests should go in
- Tests create unique temporary directories and clean up automatically
- Under
cargo testeach test target compiles to its own test binary and runs as a separate process — the lib's unit tests are one binary, each file undertests/is another, and doctests are another — so process-global state (env vars,OnceLock/LazyLock/OnceCellsingletons, cached HTTP clients / connection pools, static caches keyed by name) is shared only among the tests within a single binary, where it can leak from one test into the next. New tests must not depend on per-process state that isn't explicitly re-initialized: don'tstd::env::set_varin a test body without a scoped restore (or aOnce-guarded write of a constant), don't cache anything at process scope that's keyed on values a sibling test might reuse (e.g. bucket names, workspace names, repo names — use UUID-derived values), and don't buildLazyLocks whose init reads state a sibling test might have already read at a different value. When a test genuinely needs exclusive access to a process-wide singleton (a DB-cache flush, a global counter), gate it with#[serial_test::serial(named_key)]— pair every test that touches the same singleton on the same key.serial_testonly serializes tests within the same binary (its lock is process-local); it cannot coordinate tests in different binaries — and process-global statics don't cross a binary boundary anyway (each binary is its own process with its own copy), so the hazard it guards is always intra-binary. Canonical examples: the s3 cloud_reads tests mint UUID-tagged buckets insetup()sopolars-io's cloud-store cache can't collide; thedf_dbflush tests share#[serial_test::serial(df_db_cache)].