repoman v0.1 — Design Spec
Status: v0.1 design, locked
Date: 2026-04-29
Implementation language: reef-lang 0.5.10
Origin: VISION.md (intent) + bash prototype at ~/.local/bin/repoman (behavioral spec for new/sync)
Outcome: the contract for the first reef build of repoman.
Reef version target
This spec targets reef 0.5.10, which shipped the stdlib additions requested in docs/reef-feedback.md (response in docs/reef-feedback-response.md). All four feedback sprints landed in a single 0.5.10 release on 2026-04-29:
io.path.expand_home,io.path.join(canonical synonym forjoin_path).test.framework.assert_contains_string,assert_ok_int/_str/_bool,assert_err_int/_str/_bool.encoding.toml.toml_parse_status,encoding.json.json_parse_status(truncation-aware parse).io.file.rename,io.file.fsync(atomic-write primitives; parent-directory fsync still pending).sys.flag.flag_parser_from(args)for sliced argv (subcommand dispatch).core.result_generic—Result[T, E]withis_ok,is_err,unwrap_ok,unwrap_err,unwrap_or. Construction uses explicit type args:@Result[int, string].Ok(42). Cross-module use unblocked by BUG-037 fix in the same release.encoding.toml.TomlDoc+toml_parse_doc(struct-bundled parse output, replacing parallel-array threading).encoding.toml.TomlBuilderandencoding.json.JsonBuilder(streaming serializers — closes the reader-without-writer asymmetry).
Deferred to 0.5.11 (does not affect repoman v0.1):
- Cobra-style declarative
subcommand_parserAPI. Phase 1 (flag_parser_from) is sufficient for repoman's subcommand dispatch — we hand-roll the outer dispatch onargv[1], then feed each subcommand a sliced argv. expand_user("~user/..."). Currentexpand_homepasses~user/...through unchanged.
§2 (architecture's CLI parser hedge) and §3.4 (atomic-write fsync hedge) were originally written against 0.5.9 workarounds. They have been retargeted to the 0.5.10 APIs in rev 3.
1. Scope
v0.1 is "match-then-surpass": ship feature-parity with the bash prototype's two subcommands plus the one v0.1-only differentiator that's cheap to add now and expensive to retrofit (the Incus project namespace). Everything else from VISION (the remaining subcommands, REST integration, interactive wizard, JSON output mode) is deferred.
In scope:
repoman new <name> [--repo <dirname>] [--image <img>]repoman sync [name] [--no-delete] [--dry-run]repoman --version/repoman --help/repoman(no args → full help)- Central registry at
~/.config/repoman/repoman.toml - Per-project overrides at
~/.config/repoman/repos.d/<name>.toml - Incus
project repomanas the namespace for every container repoman creates
Out of scope (deferred to v0.2 or later):
- Subcommands:
setup,list,shell,remove,status,update,config,adopt - Incus REST-over-unix-socket integration (v0.1 shells out to the
incusCLI) - Interactive wizard / line-prompt mode for missing args
- Auto-rollback when a
newstep fails midway - JSON output mode for automation
- CI / public-forge release artifacts (prebuilt binaries)
- Migration from the bash prototype's containers (which live in Incus's
defaultproject) repomanconfig-by-env-var compatibility (LOCAL_REPOS/NFS_REPOS)- Incus profile management (e.g.,
repoman profile new dotfilesinteractive create/edit). v0.1 expects users to author profiles directly viaincus profile create/editand reference them in[defaults].profiles. README documents the recommendeddotfilesprofile pattern (bind-mount~/.hgrc/~/.gitconfig/etc.) for shared host config. [defaults].seed_files(copy-at-create semantics). Bind-mount via Incus profile is sufficient for the v0.1 dotfiles use case; revisit if a real copy-on-create need surfaces.
2. Architecture
Six modules under src/, no nesting:
| File | Module | Responsibility | Pure? |
|---|---|---|---|
src/main.reef |
(entry) | proc main() → cli.dispatch(sys.args.argv()) and exit. |
— |
src/cli.reef |
cli |
Subcommand routing, flag parsing, usage/version output. Returns exit codes. Hand-rolls outer dispatch on argv[1], then feeds each subcommand a sliced argv via sys.flag.flag_parser_from(args). Migrates to the declarative subcommand_parser API in 0.5.11+. |
mostly |
src/config.reef |
config |
Registry types (Defaults, Project, Registry), per-project override (Override), EffectiveConfig. Load-or-init from disk (atomic write); merge defaults+override. |
yes |
src/incus.reef |
incus |
Thin wrappers over the incus CLI via process_spawn(prog, argv) — project_ensure, container_exists, launch, bind_repo, set_env, restart. Plus pure validate_name. |
wrappers thin; validate_name pure |
src/sync.reef |
sync |
ensure_nfs_mounted (stat → mountpoint → findmnt -t nfs4 chain), pure build_rsync_args(opts), effectful run(opts) that spawns rsync and inherits stdio. |
arg builder pure |
src/path.reef |
path |
expand_home, join, exists, is_dir. Thin wrappers over io.path / io.dir. |
yes |
Boundaries. Pure logic (TOML schemas, name validation, rsync arg construction, path expansion, defaults merging) is unit-tested. Effectful wrappers (subprocess invocation, NFS mount checks, file write) are kept narrow so they can be smoke-tested via integration but don't need fakes. core.result_generic (Result[T, string]) carries errors out of every fallible function; main translates Result.Err into a stderr message + non-zero exit code. Construction syntax is @Result[T, string].Ok(value) / @Result[T, string].Err(msg) per 0.5.10 generics.
Subprocess discipline. Every external invocation goes through sys.process.process_spawn(program, argv) — never process_spawn_shell. This is non-negotiable: user-derived names (container, repo, paths) must not pass through a shell's word-splitting or globbing.
Dependency graph. main → cli → {config, incus, sync, path}. incus and sync are independent of each other. config depends only on encoding.toml, io.file, path. No cycles.
Concurrency. v0.1 is fully sequential. No Active Objects, no parallel sync. VISION's parallel-sync-across-N-projects idea is a v0.2 candidate.
3. Data shapes
3.1 Central registry — ~/.config/repoman/repoman.toml
[repoman]
schema = 1
[defaults]
repos_root = "~/repos"
backup_root = "/nfs/repos"
incus_project = "repoman"
default_image = "images:ubuntu/26.04/cloud"
profiles = ["default", "claude-share"]
[[project]]
name = "isurus"
repo = "isurus-project"
image = "images:ubuntu/26.04/cloud"
profiles = ["default", "claude-share"]
created = "2026-04-28T15:00:00Z"
last_sync = "" # "" = never synced
backup = true # false → sync skips
Fields:
[repoman].schema— registry schema version. v0.1 writes1. Loader rejects any other value with a clear error and a hint to upgrade repoman.[defaults].repos_root/backup_root—~expanded bypath.expand_homeat load time.[[project]].repo— repo dirname relative torepos_root. Defaults tonameif unspecified.[[project]].imageand[[project]].profiles— effective (post-merge) values used at container-create time, snapshotted for the registry. If overriderepos.d/<name>.tomlhad[container].profiles, those (not defaults) get stored here. Stored explicitly so v0.2list/statusdoesn't need to query Incus to display them.[[project]].created/last_sync— ISO 8601 UTC strings.""for never.[[project]].backup— opt-out flag. Defaulttrue.
3.2 Per-project override — ~/.config/repoman/repos.d/<name>.toml
User-authored before repoman new. Optional. Read by new only.
[container]
image = "images:debian/12/cloud"
profiles = ["default", "claude-share", "node-dev"]
[[mount]]
source = "~/.npm" # host path, ~ expanded
path = "/home/ctusa/.npm" # container path
[env]
NODE_ENV = "development"
Filename keys off the container name (<name>), not the repo dirname. So repoman new isurus --repo isurus-project reads repos.d/isurus.toml.
3.3 Merge semantics (new only)
Effective config priority:
| Field | Priority |
|---|---|
image |
--image flag → override.container.image → defaults.default_image |
profiles |
override.container.profiles (replace, not merge) → defaults.profiles |
mounts |
[auto repo bind] ++ override.[[mount]] (additive; the auto repo bind is always present) |
env |
override.[env] (key-by-key; empty if absent) |
Replace semantics for profiles is deliberate: VISION's example shows the override containing the full profiles list (including the defaults), and predictable replace beats subtle additive surprises.
3.4 Atomic write
Central registry is rewritten atomically on every change:
io.file.writeFile("repoman.toml.tmp", contents)in the same directory as the target.io.file.fsync("repoman.toml.tmp")to push contents to disk before the rename.io.file.rename("repoman.toml.tmp", "repoman.toml").
Parent-directory fsync is not yet exposed (per the 0.5.10 changelog), so a power-loss window for the rename itself remains. Acceptable for a homelab dev tool; revisit when the runtime adds it.
TOML serialization is via encoding.toml.TomlBuilder from 0.5.10 — explicit toml_set_string / toml_set_string_array / toml_array_append_table for [[project]] entries, then toml_render to a string.
The override file is never written by repoman in v0.1. User authors it.
3.5 Validation on registry load
[repoman].schema == 1— error otherwise.- All
[[project]].namevalues passincus.validate_name. - No duplicate
nameacross[[project]]entries. repos_rootandbackup_rootresolve to non-empty strings after~expansion.
Failure → Result.Err with a clear message including the offending field. Exit code 3 (environment).
4. Subcommand flows
4.1 repoman new <name> [--repo <dirname>] [--image <img>]
- Parse args.
namerequired; bad usage → exit 2. incus.validate_name(name)— pure check (alnum + hyphen, ≤63, no leading hyphen). Fail → exit 1.config.load_or_init()— read or create the registry with default[repoman]/[defaults].- Reject if
namealready exists in[[project]]→ exit 4 with hint. - Resolve repo path:
<defaults.repos_root>/<repo>. Error if directory doesn't exist (matches bash) → exit 3. - Read
~/.config/repoman/repos.d/<name>.tomlif present; parse intoOverride. - Compute
EffectiveConfigper the merge table in §3.3. incus.project_ensure(defaults.incus_project)— list, create if missing. Idempotent.incus.container_exists(project, name)— error if already present → exit 4 withincus delete --project <p> <name>hint.incus launch --project <p> --profile P1 --profile P2 ... <image> <name>(oneprocess_spawn).- For each effective mount (always: the auto repo bind at
<repo_path>:<repo_path>; then any override mounts),incus config device add --project <p> <name> <devname> disk source=<src> path=<dst>. Device names:repofor the auto bind,mount-N(N=1..) for overrides. - For each
[env]entry,incus config set --project <p> <name> environment.KEY=VALUE. incus restart --project <p> <name>so binds and env take effect.- Append a
[[project]]entry with all snapshot fields;config.save(reg)atomically. - Print "ready" + a correct shell-in hint using
getuid()and$HOME(not hardcoded1000//home/ctusalike bash).
Rollback policy. v0.1 does not auto-destroy on partial-failure. If launch succeeds but a downstream step fails (device-add, env-set, restart, registry write), surface the error and the exact incus delete --project <p> <name> command for manual cleanup. v0.2 candidate: --rollback-on-error.
4.2 repoman sync [name] [--no-delete] [--dry-run]
- Parse args.
config.load_or_init().sync.ensure_nfs_mounted(defaults.backup_root)— three calls:stat <backup_root>(triggers autofs),mountpoint -q <backup_root>,findmnt -t nfs4 <backup_root>. Each viaprocess_spawn. Any failure → exit 3 with a clear message identifying which step failed.- Resolve sync target:
- With
name: find in registry. Missing → exit 1 with hint.backup = false→ exit 1 (user explicitly asked, refusing is more honest than silent skip).src = <repos_root>/<repo>/,dst = <backup_root>/<repo>/. - Without
name:src = <repos_root>/,dst = <backup_root>/. Build excludes from the standard list (see §4.3) plus<repo>/for every project wherebackup = false.
- With
- Pure
sync.build_rsync_args(opts)produces argv. See §4.3 for the full set. - Print one-line tag to stderr:
==> rsync (dry-run) (additive) <src> → <dst>(tags conditional on flags). process_spawn("rsync", argv). Inherit parent stdout/stderr — user sees rsync's progress live; we don't capture or reformat.- If rsync exit 0: update
last_syncto current ISO 8601 UTC andconfig.save(reg)atomically. "Affected" = the named project (single-project mode), or every project wherebackup != false(whole-tree mode). - Exit code = rsync's exit code (passthrough). 0 = success; 23 = partial transfer; 24 = vanished files; etc.
4.3 rsync invocation
Base flags: -aHAX.
Adaptive info flags:
- Dry-run:
--dry-run --itemize-changes --info=stats2 - Interactive (TTY on stdout):
--info=stats2,progress2 - Otherwise (cron / piped):
--info=stats2
Delete: --delete unless --no-delete.
Excludes (hardcoded for v0.1 — matches bash prototype):
node_modules/
target/
build/
dist/
.next/
__pycache__/
*.pyc
.venv/
venv/
.cache/
.tox/
.pytest_cache/
.mypy_cache/
.ruff_cache/
Whole-tree mode appends --exclude=<repo>/ for every project where backup = false.
backup = false semantics:
- Whole-tree sync: that repo is excluded by rsync; not synced; its
last_syncis not touched. - Single-project sync: errors. The user explicitly named it.
5. CLI surface and error UX
5.1 Subcommands
repoman new <name> [--repo <dirname>] [--image <img>]
repoman sync [name] [--no-delete] [--dry-run]
repoman --version | -V
repoman --help | -h | help
repoman # no args → print full help
5.2 Output discipline
- All informational chatter (
==> incus launch ...,==> rsync (dry-run) ...) goes to stderr. - stdout is reserved for future machine-readable output. v0.1 produces no stdout (matches bash prototype, keeps cron logs clean).
- Errors prefixed
repoman: error: <message>to stderr. Append ahint:line where a clear next step exists.
5.3 Exit codes
| Code | Meaning |
|---|---|
0 |
success |
1 |
generic / unhandled error |
2 |
bad usage (unknown subcommand, missing required arg, bad flag) |
3 |
environment problem (NFS unreachable, repo dir missing, registry corrupt) |
4 |
state conflict (container already exists, name already in registry) |
| (rsync codes) | for sync: rsync's own exit code passes through when rsync itself fails. Pre-rsync errors use 1–4. |
The rsync passthrough is deliberate: cron consumers can distinguish "couldn't even start" (3/4) from "rsync hit a problem" (rsync's own codes).
5.4 Error UX matrix
| Class | Example | Code | What the user sees |
|---|---|---|---|
| Bad usage | repoman new |
2 | error msg + short usage hint |
| Validation | invalid container name | 1 | error msg + offending value |
| State conflict | name already in registry | 4 | error msg + cleanup hint |
| Environment | NFS not mounted | 3 | "backup_root /nfs/repos is not mounted (autofs/server unreachable)" |
| Subprocess (incus) | incus launch failed |
1 | our error msg + incus's stderr passed through |
| Subprocess (rsync) | rsync mid-transfer error | rsync's | rsync's own stderr (we don't capture) |
5.5 No interactive mode in v0.1
Missing required args produce a usage error, not a prompt. Wizard mode is v0.2.
6. Testing strategy
6.1 Unit-tested (pure logic) — full coverage expected
| Module | What's tested |
|---|---|
path |
expand_home (~/, ~, no-op cases), join (trailing-slash handling) |
incus |
validate_name — valid, leading-hyphen, too-long, dot/underscore, empty, exactly-63-char boundary |
config |
TOML round-trip (parse → serialize → reparse equals original); schema rejection (unknown schema); load_or_init against a temp XDG_CONFIG_HOME; merge_with_defaults for image/profiles/mounts/env priority cases; add_project rejects duplicates; update_last_sync on known/unknown name |
sync |
build_rsync_args covers every branch: dry-run on/off, delete on/off, TTY/non-TTY info flags, backup=false exclude generation, single-project vs whole-tree, every standard exclude present |
6.2 Smoke-tested (effectful) — manual, not automated
incus.project_ensure,launch,bind_repo,restart— need a real Incus daemon.sync.ensure_nfs_mounted,sync.run— need a real NFS mount and rsync.
Smoke test recipe (in README):
# In an existing repo dir under ~/repos:
repoman new test-foo
repoman sync test-foo --dry-run
incus delete --project repoman test-foo
Three commands, ~30 seconds, catches integration regressions before each release tag.
6.3 Test runner
Reef idiom: reefc run tests/test_<module>.reef per file, each a standalone program using test.framework's TestRunner. Documented loop in README:
for t in tests/test_*.reef; do
echo "== $t =="
reefc run "$t" || exit 1
done
6.4 Not tested in v0.1
main.reef— 5-line dispatcher, exercised by every invocation in dev.- CLI flag-parsing fine grain — exercised by manual usage during dogfooding; revisit in v0.2.
- Exact rsync exit-code passthrough — trusts rsync.
6.5 No CI in v0.1
Public-forge + CI is post-v0.1 work. Bar for v0.1: the test loop runs cleanly locally.
7. Build, install, distribution
7.1 reef.toml manifest
[package]
name = "repoman"
version = "0.1.0"
author = "Chris Tusa <christusa@gmail.com>"
description = "Per-project Incus containers + opinionated NFS/ZFS backup"
license = "MIT" # placeholder — open product question
url = "" # forge TBD
[build]
entry = "src/main.reef"
output = "repoman"
output_dir = "build"
source_dirs = ["src"]
[docs]
output = "docs/api"
include_private = false
7.2 Repo layout
~/repos/repoman/
├── reef.toml
├── Makefile # 10-line install/uninstall wrapper for packagers
├── README.md # quickstart + test loop + smoke-test recipe + recommended `dotfiles` Incus profile pattern
├── VISION.md # design intent (stays at root)
├── .hgignore # build/, *.o, etc.
├── docs/
│ ├── superpowers/specs/2026-04-29-repoman-v0.1-design.md # this file
│ └── api/ # generated by `reefc doc`
├── src/
│ ├── main.reef
│ ├── cli.reef
│ ├── config.reef
│ ├── incus.reef
│ ├── sync.reef
│ └── path.reef
├── tests/
│ ├── test_path.reef
│ ├── test_config.reef
│ ├── test_incus.reef
│ └── test_sync.reef
└── build/ # generated; hg-ignored
7.3 Build / clean / docs
Reef-native:
reefc build→build/repomanreefc cleanreefc doc→docs/api/
7.4 Tests
Documented shell loop in README (§6.3); no wrapper.
7.5 Install
10-line Makefile, install/uninstall only (packagers expect DESTDIR/PREFIX):
PREFIX ?= /usr/local
DESTDIR ?=
build/repoman:
reefc build
install: build/repoman
install -d $(DESTDIR)$(PREFIX)/bin
install -m 755 build/repoman $(DESTDIR)$(PREFIX)/bin/repoman
uninstall:
rm -f $(DESTDIR)$(PREFIX)/bin/repoman
.PHONY: install uninstall
make install PREFIX=$HOME/.local and sudo make install both work; DESTDIR honored for fakeroot staging.
7.6 Single-binary distribution
Reef compiles to C → native. build/repoman statically links libreef_runtime.a. v0.1 doesn't use TLS (no REST yet) → libreef_tls.a not pulled in. Runtime deps: libc + the external incus, rsync, findmnt, mountpoint, stat binaries.
7.7 Release artifacts (post-v0.1)
Source tarball minimum; prebuilt binaries per platform once forge is chosen. Out of scope for v0.1.
7.8 v0.1 verification checklist
reefc buildsucceeds clean.- The test loop passes for every file in
tests/. make install PREFIX=$HOME/.local(orsudo make install) putsrepomanon PATH.- Smoke test:
repoman new test-foo(against an existing test repo) →repoman sync test-foo --dry-run→ manualincus delete --project repoman test-foo. All exit 0. repoman --versionprints0.1.0.
8. Open product questions (do not block v0.1 dev)
- License. MIT placeholder in
reef.toml. Final choice (MIT vs Apache-2.0) before public release. - Forge. GitHub / Codeberg / self-hosted — affects release artifact pipeline.
- Env-var compatibility. Bash prototype reads
LOCAL_REPOS/NFS_REPOS. Reef v0.1 does not — TOML registry is canonical. Decide before the bash version is retired whether to honorREPOMAN_REPOS_ROOT/REPOMAN_BACKUP_ROOTas overrides. - Logging / observability. v0.1 = stderr only. JSON-lines mode is a v0.2 candidate.
9. Implementation pinned at scaffold-time (not now)
These are decisions deliberately deferred to the moment the code lands, because a 10-minute look at the actual reef API resolves them better than abstract debate:
- CLI parser. Hand-roll an
argv[1]switch incli.reef, then callsys.flag.flag_parser_from(argv[2..])per subcommand. The surface to the rest of the code iscli.dispatch(argv: [string]): int. Migrate tosubcommand_parserwhen 0.5.11 ships it. incus config showoutput parsing. Anywhere v0.1 needs to query Incus state (e.g.,container_exists), preferincus list --format csvor--format jsonover text scraping. Lock the format choice when implementing each call.
10. References
- VISION.md — design intent, audience, differentiators, open product questions.
~/.local/bin/repoman— bash prototype (behavioral spec fornew≈createandsync).~/reef-lang-0.5.10-source/docs/language/reference/031_PROJECT_STRUCTURE.md— reef project conventions (reefc init,reef.toml, module-to-file mapping).~/reef-lang-0.5.10-source/reef-stdlib/sys/process.reef—process_spawn/process_exec/ wait/kill API used throughout.~/reef-lang-0.5.10-source/reef-stdlib/encoding/toml.reef— TOML codec for the registry.