repoman v0.1 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a reef-lang implementation of repoman new <name> and repoman sync [name] with feature parity to the bash prototype at ~/.local/bin/repoman, plus the v0.1-only differentiator: containers live under an Incus project namespace.
Architecture: Six modules under src/, no nesting: main (entry), cli (subcommand dispatch + flag parsing), config (TOML-backed registry + per-project overrides), incus (subprocess wrappers around the incus CLI), sync (NFS mount check + rsync invocation), paths (path helpers — pluralized to avoid colliding with stdlib's io.path). Pure logic lives in config.merge_with_defaults, sync.build_rsync_args, incus.validate_name, paths.expand_home — these get full unit-test coverage. Effectful wrappers (subprocess, file I/O, NFS check) are kept narrow and smoke-tested manually.
Tech Stack: reef-lang 0.5.10 (compiles to C → native binary), encoding.toml (TomlBuilder + TomlDoc), core.result_generic (Result[T, string] for errors), sys.process.process_spawn (argv-list subprocess — never the shell variant), sys.flag.flag_parser_from (sliced argv per subcommand), test.framework.TestRunner (active object with assert_eq_*, assert_contains_string).
Reference: spec and source
- Design spec:
docs/superpowers/specs/2026-04-29-repoman-v0.1-design.md(rev 3, locked) - Bash prototype:
~/.local/bin/repoman(behavioral source of truth fornew/sync) - Reef stdlib reference:
~/reef-lang-0.5.10-source/reef-stdlib/test/framework.reef— TestRunner API (note:new framework.TestRunner(), notnew TestRunner())encoding/toml.reef—toml_builder(),toml_set_*,toml_array_append_table,toml_render,toml_parse_doc,TomlDocio/path.reef—expand_home,join,dirname,basenameio/file.reef—readFile,writeFile,fileExists,rename,fsync,deleteFileio/dir.reef—dir_exists,is_directory,create_dir_allsys/flag.reef—flag_parser_from(args),bool_flag,string_flag,parse,positional_argssys/process.reef—process_spawn(prog, [args]),process_wait(pid),process_exit_code()sys/args.reef—count(),get(i),program()sys/env.reef—get_env(name),get_env_or(name, default)core/result_generic.reef—Result[T, E], construction@Result[T, E].Ok(v)/@Result[T, E].Err(e), queriesis_ok(r),is_err(r),unwrap_ok(r),unwrap_err(r)core/str.reef—length,equals,contains,starts_with,ends_with,substring,split,join
File structure
~/repos/repoman/
├── reef.toml # package manifest
├── Makefile # install/uninstall wrapper for distro packagers
├── README.md # quickstart + test loop + smoke recipe
├── VISION.md # (already exists) design intent
├── .hgignore # (already exists) build/, *.o, etc.
├── docs/ # (already exists) specs + reef feedback
├── src/
│ ├── main.reef # proc main() → cli.dispatch + exit
│ ├── cli.reef # outer dispatch on argv[1]; per-subcommand parsers
│ ├── config.reef # types + parse + serialize + load_or_init + atomic save
│ ├── incus.reef # subprocess wrappers + validate_name (pure)
│ ├── sync.reef # ensure_nfs_mounted + build_rsync_args (pure) + run
│ └── path.reef # expand_home, join, exists, is_dir wrappers
└── tests/
├── test_path.reef
├── test_incus_validate.reef
├── test_config_parse.reef
├── test_config_serialize.reef
├── test_config_roundtrip.reef
├── test_config_merge.reef
├── test_sync_args.reef
├── test_config_io.reef # uses temp dir
└── test_config_save.reef # uses temp dir
Module boundary rules:
- Each module file declares
module <name>matching its filename. main.reefhas nomoduledeclaration; it's the entry point.- Tests are standalone reef programs (each has its own
proc main()); they import production modules fromsrc/(resolved automatically when run from project root). - All
module <name>blocks must end withend module.
Task 1: Scaffold project
Files:
-
Create:
reef.toml,src/main.reef,tests/.keep -
Modify:
.hgignore(add reef build artifacts) -
Step 1: Initialize reef project in existing repo
The repoman directory already exists with VISION.md and docs/. Run reefc init to add a reef.toml without disturbing existing files:
cd ~/repos/repoman
reefc init
Expected stdout: Initialized Reef project in current directory and Created reef.toml.
- Step 2: Replace generated reef.toml with the repoman manifest
The default reef.toml from reefc init is generic. Overwrite with this exact content:
[package]
name = "repoman"
version = "0.1.0"
author = "Chris Tusa <christusa@gmail.com>"
description = "Per-project Incus containers + opinionated NFS/ZFS backup"
license = "MIT"
url = ""
[build]
entry = "src/main.reef"
output = "repoman"
output_dir = "build"
source_dirs = ["src"]
[docs]
output = "docs/api"
include_private = false
Note: tests/ is intentionally NOT in source_dirs — tests are standalone programs run via reefc run tests/test_*.reef, not bundled into the production binary.
- Step 3: Create src/ and tests/ directories with placeholder main
mkdir -p src tests
Create src/main.reef with this exact content (it will be replaced in Task 19, but we want a buildable skeleton now):
proc main()
println("repoman 0.1.0 — not yet implemented")
end main
Create tests/.keep (empty file) so the directory is committed:
touch tests/.keep
- Step 4: Update .hgignore for reef build artifacts
Append to .hgignore:
build/
*.c
*.o
*.so
*.exe
docs/api/
(The docs/api/ line covers reef's generated API docs.)
- Step 5: Build and verify the skeleton runs
Run from ~/repos/repoman/:
reefc build
./build/repoman
Expected output: repoman 0.1.0 — not yet implemented. Build directory should appear at ~/repos/repoman/build/ and contain repoman.
- Step 6: Commit
hg add reef.toml src/main.reef tests/.keep
hg commit -m "scaffold: reef project skeleton + manifest"
Task 2: paths module (expand_home, join, exists, is_dir)
Naming note: the local module is named
paths(plural), notpath. Barepathcollides with the stdlib'sio.pathand reef resolves to the stdlib module first, producing C-codegen errors at link time. Pluralizing avoids the collision;paths.Xreads naturally at call sites.
Files:
- Create:
src/paths.reef - Test:
tests/test_paths.reef
The path module is a thin facade over io.path and io.dir, named to keep call sites short. expand_home and join are direct re-exports of stdlib; exists and is_dir wrap io.dir. We test the re-exports too — they're trivial but anchor the test framework + module-import chain end-to-end before we write more.
- Step 1: Write the failing test
Create tests/test_paths.reef:
import paths
import test.framework
import sys.env
proc main()
let runner = new framework.TestRunner()
// expand_home
env.set_env("HOME", "/home/test")
runner.assert_eq_string(paths.expand_home("~/foo"), "/home/test/foo", "~/foo expands")
runner.assert_eq_string(paths.expand_home("~"), "/home/test", "bare ~ expands")
runner.assert_eq_string(paths.expand_home("/abs/path"), "/abs/path", "abs path passthrough")
runner.assert_eq_string(paths.expand_home("relative"), "relative", "relative passthrough")
runner.assert_eq_string(paths.expand_home(""), "", "empty passthrough")
// join
runner.assert_eq_string(paths.join("/a", "b"), "/a/b", "join basic")
runner.assert_eq_string(paths.join("/a/", "b"), "/a/b", "join trailing slash")
runner.assert_eq_string(paths.join("/a", "/b"), "/a/b", "join leading slash")
// exists / is_dir against a known dir
runner.assert_eq_bool(paths.exists("/tmp"), true, "/tmp exists")
runner.assert_eq_bool(paths.is_dir("/tmp"), true, "/tmp is dir")
runner.assert_eq_bool(paths.exists("/this-does-not-exist-zzz"), false, "missing path → false")
runner.report()
end main
- Step 2: Run test to verify it fails
reefc run tests/test_paths.reef
Expected: compile error (module path not found).
- Step 3: Write minimal implementation
Create src/paths.reef:
module paths
import io.path as iopath
import io.dir as iodir
import io.file as iofile
export
fn expand_home(p: string): string
fn join(a: string, b: string): string
fn exists(p: string): bool
fn is_dir(p: string): bool
end export
fn expand_home(p: string): string
return iopaths.expand_home(p)
end expand_home
fn join(a: string, b: string): string
return iopaths.join(a, b)
end join
fn exists(p: string): bool
if iofile.fileExists(p)
return true
end if
return iodir.dir_exists(p)
end exists
fn is_dir(p: string): bool
return iodir.is_directory(p)
end is_dir
end module
- Step 4: Run test to verify it passes
reefc run tests/test_paths.reef
Expected: Tests passed: 9, Tests failed: 0, All tests passed!.
- Step 5: Commit
hg add src/paths.reef tests/test_paths.reef
hg commit -m "path: expand_home/join/exists/is_dir wrappers + tests"
Task 3: incus.validate_name
Files:
- Create:
src/incus.reef - Test:
tests/test_incus_validate.reef
validate_name is the pure half of the incus module. Container names follow Incus rules: lowercase alphanumeric + hyphens, must not start with a hyphen, ≤63 chars, must be non-empty. We populate the rest of the module (subprocess wrappers) in Task 13.
- Step 1: Write the failing test
Create tests/test_incus_validate.reef:
import incus
import test.framework
proc main()
let runner = new framework.TestRunner()
runner.assert_eq_bool(incus.validate_name("foo"), true, "simple name")
runner.assert_eq_bool(incus.validate_name("foo-bar"), true, "hyphenated")
runner.assert_eq_bool(incus.validate_name("foo123"), true, "trailing digits")
runner.assert_eq_bool(incus.validate_name("a"), true, "single char")
runner.assert_eq_bool(incus.validate_name(""), false, "empty rejected")
runner.assert_eq_bool(incus.validate_name("-foo"), false, "leading hyphen rejected")
runner.assert_eq_bool(incus.validate_name("foo_bar"), false, "underscore rejected")
runner.assert_eq_bool(incus.validate_name("foo.bar"), false, "dot rejected")
runner.assert_eq_bool(incus.validate_name("Foo"), false, "uppercase rejected")
runner.assert_eq_bool(incus.validate_name("foo bar"), false, "space rejected")
// 63-char boundary (exactly 63 = ok, 64 = reject)
let s63 = "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabc"
let s64 = "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcd"
runner.assert_eq_int(str.length(s63), 63, "s63 setup check")
runner.assert_eq_int(str.length(s64), 64, "s64 setup check")
runner.assert_eq_bool(incus.validate_name(s63), true, "63 chars accepted")
runner.assert_eq_bool(incus.validate_name(s64), false, "64 chars rejected")
runner.report()
end main
Note: this test imports core.str implicitly via str.length — add import core.str at the top:
import incus
import test.framework
import core.str
- Step 2: Run test to verify it fails
reefc run tests/test_incus_validate.reef
Expected: compile error (module incus not found).
- Step 3: Write minimal implementation
Create src/incus.reef:
module incus
import core.str
export
fn validate_name(name: string): bool
end export
fn is_lower_alnum_or_hyphen(c: char): bool
if c >= 'a' and c <= 'z'
return true
end if
if c >= '0' and c <= '9'
return true
end if
if c == '-'
return true
end if
return false
end is_lower_alnum_or_hyphen
fn validate_name(name: string): bool
let n: int = str.length(name)
if n == 0
return false
end if
if n > 63
return false
end if
if name[0] == '-'
return false
end if
mut i: int = 0
while i < n
if not is_lower_alnum_or_hyphen(name[i])
return false
end if
i = i + 1
end while
return true
end validate_name
end module
- Step 4: Run test to verify it passes
reefc run tests/test_incus_validate.reef
Expected: all 14 assertions pass.
- Step 5: Commit
hg add src/incus.reef tests/test_incus_validate.reef
hg commit -m "incus: validate_name + boundary tests"
Task 4: config types
Files:
- Create:
src/config.reef(skeleton with type definitions only)
This task lays down the data types that subsequent tasks will populate. No functions yet — just the structs. We don't write a test for type definitions; the next task's test will exercise them.
- Step 1: Create the config module skeleton
Create src/config.reef:
module config
import core.str
import core.result_generic as rg
import encoding.toml as toml
import io.file as iofile
import io.dir as iodir
import io.path as iopath
import paths
export
type Defaults
type Project
type Override
type Mount
type Registry
type EffectiveConfig
end export
type Defaults = struct
repos_root: string
backup_root: string
incus_project: string
default_image: string
profiles: [string]
end Defaults
type Project = struct
name: string
repo: string
image: string
profiles: [string]
created: string
last_sync: string
backup: bool
end Project
type Mount = struct
source: string
path: string
end Mount
type Override = struct
image: string
profiles: [string]
has_profiles: bool
mounts: [Mount]
env_keys: [string]
env_values: [string]
end Override
type Registry = struct
schema: int
defaults: Defaults
projects: [Project]
end Registry
type EffectiveConfig = struct
name: string
repo: string
repo_path: string
image: string
profiles: [string]
mounts: [Mount]
env_keys: [string]
env_values: [string]
end EffectiveConfig
end module
Notes on the Override shape:
-
has_profilesdistinguishes "user authored an empty profiles list" from "user didn't author profiles" (the merge in Task 8 needs this to decide whether override replaces defaults). -
env_keys/env_valuesare parallel arrays. Reef doesn't have a native dict; this matches how TomlDoc surfaces parsed key-value entries. -
Step 2: Verify it compiles
reefc --check src/config.reef
Expected: no errors. (--check is reefc's type-check-only mode, useful for module-level smoke checks before there's a proc main().)
If --check doesn't accept module files standalone, a quick alternative: run reefc build and confirm it still produces build/repoman. The new module is unused but should compile cleanly.
- Step 3: Commit
hg add src/config.reef
hg commit -m "config: type skeleton (Registry/Defaults/Project/Override/EffectiveConfig)"
Task 5: config.parse_registry
Files:
- Modify:
src/config.reef(addparse_registryfunction + helpers) - Create:
tests/test_config_parse.reef
Parses a TOML string into a Registry. Uses the new TomlDoc API from 0.5.10 — much cleaner than threading (keys, values, count). Returns Result[Registry, string].
- Step 1: Write the failing test
Create tests/test_config_parse.reef:
import config
import test.framework
import core.result_generic as rg
proc main()
let runner = new framework.TestRunner()
let toml_input: string = "[repoman]\nschema = 1\n\n[defaults]\nrepos_root = \"/home/u/repos\"\nbackup_root = \"/nfs/repos\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\", \"claude-share\"]\n\n[[project]]\nname = \"isurus\"\nrepo = \"isurus-project\"\nimage = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\ncreated = \"2026-04-28T15:00:00Z\"\nlast_sync = \"\"\nbackup = true\n"
let r = config.parse_registry(toml_input)
runner.assert_eq_bool(rg.is_ok(r), true, "parse succeeds")
if rg.is_ok(r)
let reg = rg.unwrap_ok(r)
runner.assert_eq_int(reg.schema, 1, "schema = 1")
runner.assert_eq_string(reg.defaults.repos_root, "/home/u/repos", "repos_root")
runner.assert_eq_string(reg.defaults.backup_root, "/nfs/repos", "backup_root")
runner.assert_eq_string(reg.defaults.incus_project, "repoman", "incus_project")
runner.assert_eq_string(reg.defaults.default_image, "images:ubuntu/26.04/cloud", "default_image")
runner.assert_eq_int(reg.defaults.profiles.length(), 2, "defaults.profiles count")
runner.assert_eq_string(reg.defaults.profiles[0], "default", "defaults.profiles[0]")
runner.assert_eq_string(reg.defaults.profiles[1], "claude-share", "defaults.profiles[1]")
runner.assert_eq_int(reg.projects.length(), 1, "1 project")
let p = reg.projects[0]
runner.assert_eq_string(p.name, "isurus", "project.name")
runner.assert_eq_string(p.repo, "isurus-project", "project.repo")
runner.assert_eq_string(p.image, "images:ubuntu/26.04/cloud", "project.image")
runner.assert_eq_int(p.profiles.length(), 1, "project.profiles count")
runner.assert_eq_string(p.profiles[0], "default", "project.profiles[0]")
runner.assert_eq_string(p.last_sync, "", "project.last_sync")
runner.assert_eq_bool(p.backup, true, "project.backup")
end if
// schema rejection
let bad_schema: string = "[repoman]\nschema = 99\n[defaults]\nrepos_root = \"/r\"\nbackup_root = \"/b\"\nincus_project = \"x\"\ndefault_image = \"y\"\nprofiles = []\n"
let r2 = config.parse_registry(bad_schema)
runner.assert_eq_bool(rg.is_err(r2), true, "schema 99 rejected")
runner.report()
end main
- Step 2: Run test to verify it fails
reefc run tests/test_config_parse.reef
Expected: compile error (parse_registry not defined in config).
- Step 3: Add parse_registry + helpers to src/config.reef
In src/config.reef, add to the export block:
fn parse_registry(toml_text: string): rg.Result[Registry, string]
Add these functions before end module:
// Parse a comma-separated TOML inline array of strings: `["a", "b"]`.
// Tolerates whitespace and missing brackets; returns empty if input is empty.
fn parse_string_array(raw: string): [string]
let n: int = str.length(raw)
if n == 0
return new [string](0)
end if
// Strip leading [ and trailing ]
mut start: int = 0
mut end_idx: int = n
if n > 0 and raw[0] == '['
start = 1
end if
if end_idx > start and raw[end_idx - 1] == ']'
end_idx = end_idx - 1
end if
let inner: string = str.substring(raw, start, end_idx - start)
// Split on commas, then trim quotes and whitespace from each element.
let inner_len: int = str.length(inner)
if inner_len == 0
return new [string](0)
end if
// Reef doesn't have a dynamic split-and-collect; use str.split with a max.
mut parts: [string] = new [string](64)
let count: int = str.split(inner, ',', parts, 64)
mut result: [string] = new [string](count)
mut i: int = 0
while i < count
let p: string = str.trim_ws(parts[i])
// Strip surrounding double quotes if present
let pl: int = str.length(p)
if pl >= 2 and p[0] == '"' and p[pl - 1] == '"'
result[i] = str.substring(p, 1, pl - 2)
else
result[i] = p
end if
i = i + 1
end while
return result
end parse_string_array
fn parse_registry(toml_text: string): rg.Result[Registry, string]
let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text)
if doc.truncated
return @rg.Result[Registry, string].Err("registry too large (>1024 entries)")
end if
let schema: int = toml.toml_get_int_doc(doc, "repoman.schema")
if schema != 1
return @rg.Result[Registry, string].Err("unsupported schema (expected 1)")
end if
let defaults: Defaults = Defaults {
repos_root: toml.toml_get_doc(doc, "defaults.repos_root"),
backup_root: toml.toml_get_doc(doc, "defaults.backup_root"),
incus_project: toml.toml_get_doc(doc, "defaults.incus_project"),
default_image: toml.toml_get_doc(doc, "defaults.default_image"),
profiles: parse_string_array(toml.toml_get_doc(doc, "defaults.profiles"))
}
let project_count: int = toml.toml_array_count(doc.keys, doc.count, "project")
mut projects: [Project] = new [Project](project_count)
mut i: int = 0
while i < project_count
let p: Project = Project {
name: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "name"),
repo: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "repo"),
image: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "image"),
profiles: parse_string_array(toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "profiles")),
created: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "created"),
last_sync: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "last_sync"),
backup: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "backup") != "false"
}
projects[i] = p
i = i + 1
end while
let reg: Registry = Registry {
schema: schema,
defaults: defaults,
projects: projects
}
return @rg.Result[Registry, string].Ok(reg)
end parse_registry
Note on the backup boolean parse: toml_get_bool exists, but the table-array helper toml_array_get returns a string. We default-true unless the literal string is "false". Tighter handling can come later.
- Step 4: Run test to verify it passes
reefc run tests/test_config_parse.reef
Expected: 19 assertions pass.
- Step 5: Commit
hg add tests/test_config_parse.reef
hg commit -m "config: parse_registry via TomlDoc + tests"
Task 6: config.serialize_registry
Files:
- Modify:
src/config.reef(addserialize_registry) - Create:
tests/test_config_serialize.reef
Renders a Registry to a TOML string using TomlBuilder. The output must round-trip cleanly through parse_registry (we test that explicitly in Task 7).
- Step 1: Write the failing test
Create tests/test_config_serialize.reef:
import config
import test.framework
proc main()
let runner = new framework.TestRunner()
let defaults: config.Defaults = config.Defaults {
repos_root: "/home/u/repos",
backup_root: "/nfs/repos",
incus_project: "repoman",
default_image: "images:ubuntu/26.04/cloud",
profiles: ["default", "claude-share"]
}
mut projects: [config.Project] = new [config.Project](1)
projects[0] = config.Project {
name: "isurus",
repo: "isurus-project",
image: "images:ubuntu/26.04/cloud",
profiles: ["default"],
created: "2026-04-28T15:00:00Z",
last_sync: "",
backup: true
}
let reg: config.Registry = config.Registry {
schema: 1,
defaults: defaults,
projects: projects
}
let out: string = config.serialize_registry(reg)
runner.assert_contains_string(out, "[repoman]", "has [repoman] header")
runner.assert_contains_string(out, "schema = 1", "has schema field")
runner.assert_contains_string(out, "[defaults]", "has [defaults] header")
runner.assert_contains_string(out, "repos_root = \"/home/u/repos\"", "has repos_root field")
runner.assert_contains_string(out, "[[project]]", "has [[project]] header")
runner.assert_contains_string(out, "name = \"isurus\"", "has project.name")
runner.assert_contains_string(out, "backup = true", "has backup = true")
runner.assert_contains_string(out, "profiles = [\"default\", \"claude-share\"]", "defaults.profiles array")
runner.report()
end main
- Step 2: Run test to verify it fails
reefc run tests/test_config_serialize.reef
Expected: compile error (serialize_registry undefined).
- Step 3: Add serialize_registry to src/config.reef
Add to the export block:
fn serialize_registry(reg: Registry): string
Add the implementation before end module:
fn serialize_registry(reg: Registry): string
let b: toml.TomlBuilder = toml.toml_builder()
toml.toml_begin_table(b, "repoman")
toml.toml_set_int(b, "schema", reg.schema)
toml.toml_begin_table(b, "defaults")
toml.toml_set_string(b, "repos_root", reg.defaults.repos_root)
toml.toml_set_string(b, "backup_root", reg.defaults.backup_root)
toml.toml_set_string(b, "incus_project", reg.defaults.incus_project)
toml.toml_set_string(b, "default_image", reg.defaults.default_image)
toml.toml_set_string_array(b, "profiles", reg.defaults.profiles)
let pn: int = reg.projects.length()
mut i: int = 0
while i < pn
let p: Project = reg.projects[i]
toml.toml_array_append_table(b, "project")
toml.toml_set_string(b, "name", p.name)
toml.toml_set_string(b, "repo", p.repo)
toml.toml_set_string(b, "image", p.image)
toml.toml_set_string_array(b, "profiles", p.profiles)
toml.toml_set_string(b, "created", p.created)
toml.toml_set_string(b, "last_sync", p.last_sync)
toml.toml_set_bool(b, "backup", p.backup)
i = i + 1
end while
return toml.toml_render(b)
end serialize_registry
- Step 4: Run test to verify it passes
reefc run tests/test_config_serialize.reef
Expected: 8 assertions pass.
- Step 5: Commit
hg add tests/test_config_serialize.reef
hg commit -m "config: serialize_registry via TomlBuilder + tests"
Task 7: config round-trip test
Files:
- Create:
tests/test_config_roundtrip.reef
Confirms that parse(serialize(reg)) == reg. This is the canonical test for any encoder pair — without it the two halves can drift.
- Step 1: Write the failing test
Create tests/test_config_roundtrip.reef:
import config
import test.framework
import core.result_generic as rg
proc main()
let runner = new framework.TestRunner()
let defaults: config.Defaults = config.Defaults {
repos_root: "/home/u/repos",
backup_root: "/nfs/repos",
incus_project: "repoman",
default_image: "images:ubuntu/26.04/cloud",
profiles: ["default", "claude-share"]
}
mut projects: [config.Project] = new [config.Project](2)
projects[0] = config.Project {
name: "isurus",
repo: "isurus-project",
image: "images:ubuntu/26.04/cloud",
profiles: ["default"],
created: "2026-04-28T15:00:00Z",
last_sync: "",
backup: true
}
projects[1] = config.Project {
name: "tools",
repo: "tools",
image: "images:debian/12/cloud",
profiles: ["default", "claude-share"],
created: "2026-04-29T10:00:00Z",
last_sync: "2026-04-29T11:00:00Z",
backup: false
}
let reg: config.Registry = config.Registry {
schema: 1, defaults: defaults, projects: projects
}
let serialized: string = config.serialize_registry(reg)
let parsed_r = config.parse_registry(serialized)
runner.assert_eq_bool(rg.is_ok(parsed_r), true, "round-trip parse succeeds")
if rg.is_ok(parsed_r)
let reg2 = rg.unwrap_ok(parsed_r)
runner.assert_eq_int(reg2.schema, 1, "schema preserved")
runner.assert_eq_string(reg2.defaults.repos_root, reg.defaults.repos_root, "defaults.repos_root preserved")
runner.assert_eq_int(reg2.defaults.profiles.length(), 2, "defaults.profiles len preserved")
runner.assert_eq_int(reg2.projects.length(), 2, "project count preserved")
runner.assert_eq_string(reg2.projects[0].name, "isurus", "project[0].name")
runner.assert_eq_string(reg2.projects[1].name, "tools", "project[1].name")
runner.assert_eq_bool(reg2.projects[0].backup, true, "project[0].backup")
runner.assert_eq_bool(reg2.projects[1].backup, false, "project[1].backup")
runner.assert_eq_string(reg2.projects[1].last_sync, "2026-04-29T11:00:00Z", "last_sync preserved")
end if
runner.report()
end main
- Step 2: Run test
reefc run tests/test_config_roundtrip.reef
Expected: 9 assertions pass. If any fail, the parse and serialize sides have drifted — fix whichever is wrong.
- Step 3: Commit
hg add tests/test_config_roundtrip.reef
hg commit -m "config: round-trip test (serialize → parse equals original)"
Task 8: config.parse_override
Files:
- Modify:
src/config.reef(addparse_override) - Create:
tests/test_config_override.reef
Parses a per-project override TOML ([container], [[mount]], [env] sections). Returns Result[Override, string]. Override files are read-only — repoman never writes them — so we only need parse, not serialize.
- Step 1: Write the failing test
Create tests/test_config_override.reef:
import config
import test.framework
import core.result_generic as rg
proc main()
let runner = new framework.TestRunner()
let toml_input: string = "[container]\nimage = \"images:debian/12/cloud\"\nprofiles = [\"default\", \"claude-share\", \"node-dev\"]\n\n[[mount]]\nsource = \"~/.npm\"\npath = \"/home/ctusa/.npm\"\n\n[[mount]]\nsource = \"~/.cache/yarn\"\npath = \"/home/ctusa/.cache/yarn\"\n\n[env]\nNODE_ENV = \"development\"\nDEBUG = \"1\"\n"
let r = config.parse_override(toml_input)
runner.assert_eq_bool(rg.is_ok(r), true, "override parse succeeds")
if rg.is_ok(r)
let ov = rg.unwrap_ok(r)
runner.assert_eq_string(ov.image, "images:debian/12/cloud", "override.image")
runner.assert_eq_bool(ov.has_profiles, true, "has_profiles set")
runner.assert_eq_int(ov.profiles.length(), 3, "profiles count")
runner.assert_eq_string(ov.profiles[2], "node-dev", "profiles[2]")
runner.assert_eq_int(ov.mounts.length(), 2, "mount count")
runner.assert_eq_string(ov.mounts[0].source, "~/.npm", "mount[0].source")
runner.assert_eq_string(ov.mounts[0].path, "/home/ctusa/.npm", "mount[0].path")
runner.assert_eq_int(ov.env_keys.length(), 2, "env count")
// env keys are not order-guaranteed by TOML; check both possibilities
let k0: string = ov.env_keys[0]
runner.assert_eq_bool(k0 == "NODE_ENV" or k0 == "DEBUG", true, "env_keys[0] is one of expected")
end if
// empty override
let r2 = config.parse_override("")
runner.assert_eq_bool(rg.is_ok(r2), true, "empty override is valid")
if rg.is_ok(r2)
let ov2 = rg.unwrap_ok(r2)
runner.assert_eq_string(ov2.image, "", "empty override.image")
runner.assert_eq_bool(ov2.has_profiles, false, "no profiles")
runner.assert_eq_int(ov2.mounts.length(), 0, "no mounts")
runner.assert_eq_int(ov2.env_keys.length(), 0, "no env")
end if
runner.report()
end main
- Step 2: Run test to verify it fails
reefc run tests/test_config_override.reef
Expected: compile error (parse_override undefined).
- Step 3: Add parse_override to src/config.reef
Add to the export block:
fn parse_override(toml_text: string): rg.Result[Override, string]
Implementation (place before end module):
fn parse_override(toml_text: string): rg.Result[Override, string]
let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text)
if doc.truncated
return @rg.Result[Override, string].Err("override too large")
end if
let image: string = toml.toml_get_doc(doc, "container.image")
let profiles_raw: string = toml.toml_get_doc(doc, "container.profiles")
let has_profiles: bool = toml.toml_has_key_doc(doc, "container.profiles")
let profiles: [string] = parse_string_array(profiles_raw)
let mount_count: int = toml.toml_array_count(doc.keys, doc.count, "mount")
mut mounts: [Mount] = new [Mount](mount_count)
mut i: int = 0
while i < mount_count
mounts[i] = Mount {
source: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "source"),
path: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "path")
}
i = i + 1
end while
// Walk doc.keys for entries starting with "env."
mut env_keys_buf: [string] = new [string](128)
mut env_vals_buf: [string] = new [string](128)
mut env_count: int = 0
mut k: int = 0
while k < doc.count
let key: string = doc.keys[k]
if str.starts_with(key, "env.")
if env_count < 128
env_keys_buf[env_count] = str.substring(key, 4, str.length(key) - 4)
env_vals_buf[env_count] = doc.values[k]
env_count = env_count + 1
end if
end if
k = k + 1
end while
mut env_keys: [string] = new [string](env_count)
mut env_vals: [string] = new [string](env_count)
mut j: int = 0
while j < env_count
env_keys[j] = env_keys_buf[j]
env_vals[j] = env_vals_buf[j]
j = j + 1
end while
let ov: Override = Override {
image: image,
profiles: profiles,
has_profiles: has_profiles,
mounts: mounts,
env_keys: env_keys,
env_values: env_vals
}
return @rg.Result[Override, string].Ok(ov)
end parse_override
- Step 4: Run test to verify it passes
reefc run tests/test_config_override.reef
Expected: all assertions pass (12 in this test).
- Step 5: Commit
hg add tests/test_config_override.reef
hg commit -m "config: parse_override (container/mount/env) + tests"
Task 9: config.merge_with_defaults
Files:
- Modify:
src/config.reef(addmerge_with_defaults) - Create:
tests/test_config_merge.reef
Pure function: takes a name, repo dirname, optional --image flag, optional Override, and Defaults. Returns an EffectiveConfig. Implements the merge table from spec §3.3:
| Field | Priority |
|---|---|
image |
flag → override.image → defaults.default_image |
profiles |
override.profiles (replace, when has_profiles) → defaults.profiles |
mounts |
always [auto repo bind] ++ override.mounts |
env |
override.env entries (or empty) |
- Step 1: Write the failing test
Create tests/test_config_merge.reef:
import config
import test.framework
fn make_defaults(): config.Defaults
return config.Defaults {
repos_root: "/home/u/repos",
backup_root: "/nfs/repos",
incus_project: "repoman",
default_image: "images:ubuntu/26.04/cloud",
profiles: ["default", "claude-share"]
}
end make_defaults
fn empty_override(): config.Override
return config.Override {
image: "",
profiles: new [string](0),
has_profiles: false,
mounts: new [config.Mount](0),
env_keys: new [string](0),
env_values: new [string](0)
}
end empty_override
proc main()
let runner = new framework.TestRunner()
let d = make_defaults()
// 1. flag wins over override and defaults
mut ov1: config.Override = empty_override()
ov1.image = "images:debian/12/cloud"
let e1 = config.merge_with_defaults("isurus", "isurus-project", "images:custom/x", ov1, d)
runner.assert_eq_string(e1.image, "images:custom/x", "flag wins")
runner.assert_eq_string(e1.repo_path, "/home/u/repos/isurus-project", "repo_path computed")
// 2. override.image wins over defaults when no flag
let e2 = config.merge_with_defaults("isurus", "isurus-project", "", ov1, d)
runner.assert_eq_string(e2.image, "images:debian/12/cloud", "override.image wins")
// 3. defaults when no flag, no override.image
let e3 = config.merge_with_defaults("isurus", "isurus-project", "", empty_override(), d)
runner.assert_eq_string(e3.image, "images:ubuntu/26.04/cloud", "defaults.image wins")
// 4. profiles: override replaces defaults when has_profiles
mut ov2: config.Override = empty_override()
ov2.profiles = ["default", "claude-share", "node-dev"]
ov2.has_profiles = true
let e4 = config.merge_with_defaults("isurus", "isurus-project", "", ov2, d)
runner.assert_eq_int(e4.profiles.length(), 3, "override profiles count")
runner.assert_eq_string(e4.profiles[2], "node-dev", "override profiles[2]")
// 5. profiles fall back to defaults
let e5 = config.merge_with_defaults("isurus", "isurus-project", "", empty_override(), d)
runner.assert_eq_int(e5.profiles.length(), 2, "defaults profiles count")
// 6. mounts: auto bind always present, override appended
mut m1: [config.Mount] = new [config.Mount](1)
m1[0] = config.Mount { source: "~/.npm", path: "/home/u/.npm" }
mut ov3: config.Override = empty_override()
ov3.mounts = m1
let e6 = config.merge_with_defaults("isurus", "isurus-project", "", ov3, d)
runner.assert_eq_int(e6.mounts.length(), 2, "auto bind + 1 override mount")
runner.assert_eq_string(e6.mounts[0].source, "/home/u/repos/isurus-project", "auto bind source")
runner.assert_eq_string(e6.mounts[0].path, "/home/u/repos/isurus-project", "auto bind dest")
runner.assert_eq_string(e6.mounts[1].source, "~/.npm", "override mount preserved")
// 7. env: passed through
mut keys: [string] = ["NODE_ENV"]
mut vals: [string] = ["development"]
mut ov4: config.Override = empty_override()
ov4.env_keys = keys
ov4.env_values = vals
let e7 = config.merge_with_defaults("isurus", "isurus-project", "", ov4, d)
runner.assert_eq_int(e7.env_keys.length(), 1, "env_keys count")
runner.assert_eq_string(e7.env_keys[0], "NODE_ENV", "env_keys[0]")
runner.assert_eq_string(e7.env_values[0], "development", "env_values[0]")
runner.report()
end main
- Step 2: Run test to verify it fails
reefc run tests/test_config_merge.reef
Expected: compile error (merge_with_defaults undefined).
- Step 3: Add merge_with_defaults to src/config.reef
Add to export block:
fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig
Implementation:
fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig
let repos_root_expanded: string = paths.expand_home(d.repos_root)
let repo_path: string = paths.join(repos_root_expanded, repo)
// Image priority: flag → override → defaults
mut image: string = d.default_image
if str.length(ov.image) > 0
image = ov.image
end if
if str.length(image_flag) > 0
image = image_flag
end if
// Profiles: override replaces defaults when has_profiles, else defaults
mut profiles: [string] = d.profiles
if ov.has_profiles
profiles = ov.profiles
end if
// Mounts: [auto repo bind] ++ override.mounts
let ov_mount_count: int = ov.mounts.length()
mut mounts: [Mount] = new [Mount](1 + ov_mount_count)
mounts[0] = Mount { source: repo_path, path: repo_path }
mut i: int = 0
while i < ov_mount_count
// Expand ~ in mount source for host paths
let m: Mount = ov.mounts[i]
mounts[i + 1] = Mount {
source: paths.expand_home(m.source),
path: m.path
}
i = i + 1
end while
return EffectiveConfig {
name: name,
repo: repo,
repo_path: repo_path,
image: image,
profiles: profiles,
mounts: mounts,
env_keys: ov.env_keys,
env_values: ov.env_values
}
end merge_with_defaults
- Step 4: Run test to verify it passes
reefc run tests/test_config_merge.reef
Expected: 14 assertions pass.
- Step 5: Commit
hg add tests/test_config_merge.reef
hg commit -m "config: merge_with_defaults + tests"
Task 10: config.add_project + config.update_last_sync
Files:
- Modify:
src/config.reef(add the two registry mutators) - Modify:
tests/test_config_merge.reef(add cases) — actually new file for clarity: - Create:
tests/test_config_mutate.reef
Two pure functions that build a new Registry from an existing one. They never write to disk — that's save's job (Task 12).
- Step 1: Write the failing test
Create tests/test_config_mutate.reef:
import config
import test.framework
import core.result_generic as rg
fn empty_defaults(): config.Defaults
return config.Defaults {
repos_root: "/r", backup_root: "/b", incus_project: "p",
default_image: "img", profiles: new [string](0)
}
end empty_defaults
proc main()
let runner = new framework.TestRunner()
let reg0: config.Registry = config.Registry {
schema: 1, defaults: empty_defaults(), projects: new [config.Project](0)
}
let p1: config.Project = config.Project {
name: "isurus", repo: "isurus", image: "img",
profiles: new [string](0), created: "t", last_sync: "", backup: true
}
let r1 = config.add_project(reg0, p1)
runner.assert_eq_bool(rg.is_ok(r1), true, "add new project ok")
if rg.is_ok(r1)
let reg1 = rg.unwrap_ok(r1)
runner.assert_eq_int(reg1.projects.length(), 1, "1 project after add")
runner.assert_eq_string(reg1.projects[0].name, "isurus", "project added")
// duplicate add fails
let r2 = config.add_project(reg1, p1)
runner.assert_eq_bool(rg.is_err(r2), true, "duplicate name rejected")
// update_last_sync
let r3 = config.update_last_sync(reg1, "isurus", "2026-04-29T12:00:00Z")
runner.assert_eq_bool(rg.is_ok(r3), true, "update existing ok")
if rg.is_ok(r3)
let reg3 = rg.unwrap_ok(r3)
runner.assert_eq_string(reg3.projects[0].last_sync, "2026-04-29T12:00:00Z", "last_sync updated")
end if
// update unknown name fails
let r4 = config.update_last_sync(reg1, "nope", "t")
runner.assert_eq_bool(rg.is_err(r4), true, "unknown name rejected")
end if
runner.report()
end main
- Step 2: Run test to verify it fails
reefc run tests/test_config_mutate.reef
Expected: compile error.
- Step 3: Add functions to src/config.reef
Export:
fn add_project(reg: Registry, p: Project): rg.Result[Registry, string]
fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string]
Implementations:
fn add_project(reg: Registry, p: Project): rg.Result[Registry, string]
let n: int = reg.projects.length()
mut i: int = 0
while i < n
if reg.projects[i].name == p.name
return @rg.Result[Registry, string].Err("project already exists: " + p.name)
end if
i = i + 1
end while
mut new_projects: [Project] = new [Project](n + 1)
mut k: int = 0
while k < n
new_projects[k] = reg.projects[k]
k = k + 1
end while
new_projects[n] = p
return @rg.Result[Registry, string].Ok(Registry {
schema: reg.schema,
defaults: reg.defaults,
projects: new_projects
})
end add_project
fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string]
let n: int = reg.projects.length()
mut found: int = -1
mut i: int = 0
while i < n
if reg.projects[i].name == name
found = i
end if
i = i + 1
end while
if found < 0
return @rg.Result[Registry, string].Err("project not in registry: " + name)
end if
mut new_projects: [Project] = new [Project](n)
mut k: int = 0
while k < n
if k == found
let old: Project = reg.projects[k]
new_projects[k] = Project {
name: old.name, repo: old.repo, image: old.image,
profiles: old.profiles, created: old.created,
last_sync: ts, backup: old.backup
}
else
new_projects[k] = reg.projects[k]
end if
k = k + 1
end while
return @rg.Result[Registry, string].Ok(Registry {
schema: reg.schema,
defaults: reg.defaults,
projects: new_projects
})
end update_last_sync
- Step 4: Run test to verify it passes
reefc run tests/test_config_mutate.reef
Expected: 7 assertions pass.
- Step 5: Commit
hg add tests/test_config_mutate.reef
hg commit -m "config: add_project + update_last_sync + tests"
Task 11: config.load_or_init (file I/O)
Files:
- Modify:
src/config.reef(addload_or_init,default_registry_for,registry_path) - Create:
tests/test_config_io.reef(uses temp dir under/tmp)
load_or_init(home_dir) is the main entry point: returns the registry, creating an initial one if none exists. We use home_dir as a parameter (not implicit $HOME) so the test can drive it.
- Step 1: Write the failing test
Create tests/test_config_io.reef:
import config
import test.framework
import core.result_generic as rg
import io.dir as iodir
import io.file as iofile
import sys.process as proc
proc main()
let runner = new framework.TestRunner()
// Set up a fresh temp dir as fake $HOME
let pid_str: string = "12345" // process id stand-in for uniqueness; reuse same dir is fine
let tmp: string = "/tmp/repoman-test-load-init"
// Wipe and recreate
let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))
let _: bool = iodir.create_dir_all(tmp)
// First call: no .config/repoman/repoman.toml exists → init writes default
let r1 = config.load_or_init(tmp)
runner.assert_eq_bool(rg.is_ok(r1), true, "load_or_init creates default")
if rg.is_ok(r1)
let reg = rg.unwrap_ok(r1)
runner.assert_eq_int(reg.schema, 1, "default schema = 1")
runner.assert_eq_int(reg.projects.length(), 0, "default has no projects")
runner.assert_eq_string(reg.defaults.incus_project, "repoman", "default incus_project")
end if
// The file should now exist on disk.
let cfg_path: string = tmp + "/.config/repoman/repoman.toml"
runner.assert_eq_bool(iofile.fileExists(cfg_path), true, "registry file written")
// Second call: should load the existing file.
let r2 = config.load_or_init(tmp)
runner.assert_eq_bool(rg.is_ok(r2), true, "second load reads existing")
// Cleanup
let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))
runner.report()
end main
- Step 2: Run test to verify it fails
reefc run tests/test_config_io.reef
Expected: compile error.
- Step 3: Add load_or_init + helpers to src/config.reef
Export:
fn registry_path(home_dir: string): string
fn default_registry(home_dir: string): Registry
fn load_or_init(home_dir: string): rg.Result[Registry, string]
Implementation:
fn registry_path(home_dir: string): string
let cfg_dir: string = paths.join(home_dir, ".config/repoman")
return paths.join(cfg_dir, "repoman.toml")
end registry_path
fn default_registry(home_dir: string): Registry
let repos_root: string = paths.join(home_dir, "repos")
return Registry {
schema: 1,
defaults: Defaults {
repos_root: repos_root,
backup_root: "/nfs/repos",
incus_project: "repoman",
default_image: "images:ubuntu/26.04/cloud",
profiles: ["default", "claude-share"]
},
projects: new [Project](0)
}
end default_registry
fn load_or_init(home_dir: string): rg.Result[Registry, string]
let cfg_path: string = registry_path(home_dir)
let cfg_dir: string = iopath.dirname(cfg_path)
// Ensure ~/.config/repoman/ exists
if not iodir.dir_exists(cfg_dir)
if not iodir.create_dir_all(cfg_dir)
return @rg.Result[Registry, string].Err("cannot create config dir: " + cfg_dir)
end if
end if
if iofile.fileExists(cfg_path)
let contents: string = iofile.readFile(cfg_path)
return parse_registry(contents)
end if
// Init: write default registry.
let reg: Registry = default_registry(home_dir)
let saved_r = save(reg, cfg_path)
if rg.is_err(saved_r)
return @rg.Result[Registry, string].Err(rg.unwrap_err(saved_r))
end if
return @rg.Result[Registry, string].Ok(reg)
end load_or_init
Note: this references save which is the next task. Stub it out for now so this file compiles:
fn save(reg: Registry, cfg_path: string): rg.Result[bool, string]
let _: bool = iofile.writeFile(cfg_path, serialize_registry(reg))
return @rg.Result[bool, string].Ok(true)
end save
The stub will be replaced in Task 12 with the proper atomic write.
- Step 4: Run test to verify it passes
reefc run tests/test_config_io.reef
Expected: 5 assertions pass; /tmp/repoman-test-load-init is cleaned up at the end.
- Step 5: Commit
hg add tests/test_config_io.reef
hg commit -m "config: load_or_init with stub save (init flow + tests)"
Task 12: config.save (atomic write with fsync + rename)
Files:
- Modify:
src/config.reef(replace stubsavewith atomic version) - Create:
tests/test_config_save.reef
Atomic write recipe per spec §3.4: write to <path>.tmp, fsync the tmp, rename over the target. If any step fails, return Err with the failing step.
- Step 1: Write the failing test
Create tests/test_config_save.reef:
import config
import test.framework
import core.result_generic as rg
import io.dir as iodir
import io.file as iofile
import sys.process as proc
proc main()
let runner = new framework.TestRunner()
let tmp: string = "/tmp/repoman-test-save"
let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))
let _: bool = iodir.create_dir_all(tmp)
let cfg_path: string = tmp + "/repoman.toml"
let reg: config.Registry = config.default_registry("/home/u")
let r1 = config.save(reg, cfg_path)
runner.assert_eq_bool(rg.is_ok(r1), true, "save returns Ok")
runner.assert_eq_bool(iofile.fileExists(cfg_path), true, "target file exists")
runner.assert_eq_bool(iofile.fileExists(cfg_path + ".tmp"), false, "tmp removed after rename")
// Round-trip: read what we wrote
let contents: string = iofile.readFile(cfg_path)
let r2 = config.parse_registry(contents)
runner.assert_eq_bool(rg.is_ok(r2), true, "saved file parses")
if rg.is_ok(r2)
let reg2 = rg.unwrap_ok(r2)
runner.assert_eq_int(reg2.schema, 1, "schema preserved on disk")
end if
let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))
runner.report()
end main
- Step 2: Run test to verify the stub passes one assertion but isn't atomic-safe
The stub save from Task 11 will pass the basic assertions (it does write the file), but doesn't go through .tmp and doesn't fsync. We're going to replace it.
reefc run tests/test_config_save.reef
Expected: passes (the stub is correct enough for these assertions; we replace it for crash safety, not behavior).
- Step 3: Replace the stub save with the atomic version
In src/config.reef, find the stub save from Task 11 and replace it with:
// Atomic write: writeFile(.tmp) → fsync(.tmp) → rename(.tmp, target).
// If any step fails, returns Err naming the failing step.
fn save(reg: Registry, cfg_path: string): rg.Result[bool, string]
let serialized: string = serialize_registry(reg)
let tmp_path: string = cfg_path + ".tmp"
if not iofile.writeFile(tmp_path, serialized)
return @rg.Result[bool, string].Err("write failed: " + tmp_path)
end if
if not iofile.fsync(tmp_path)
// Best effort: clean up tmp
let _: bool = iofile.deleteFile(tmp_path)
return @rg.Result[bool, string].Err("fsync failed: " + tmp_path)
end if
if not iofile.rename(tmp_path, cfg_path)
let _: bool = iofile.deleteFile(tmp_path)
return @rg.Result[bool, string].Err("rename failed: " + tmp_path + " → " + cfg_path)
end if
return @rg.Result[bool, string].Ok(true)
end save
Add save to the export block:
fn save(reg: Registry, cfg_path: string): rg.Result[bool, string]
- Step 4: Run test to verify it passes
reefc run tests/test_config_save.reef
Expected: 5 assertions pass; the .tmp file is gone after rename.
- Step 5: Commit
hg add tests/test_config_save.reef
hg commit -m "config: atomic save (writeFile→fsync→rename)"
Task 13: incus subprocess wrappers
Files:
- Modify:
src/incus.reef
Subprocess wrappers around the incus CLI. These are NOT unit-tested — they need a live Incus daemon; the smoke recipe in Task 21 covers them. We do test that the argv-list construction is correct by inspection — read the bash prototype lines 105-115 for the canonical incantations.
Wrappers needed:
project_ensure(project: string)—incus project list --format csv -c name | grep -qx <p>, elseincus project create <p>container_exists(project: string, name: string)—incus list --project <p> --format csv -c n | grep -qx <name>launch(project, name, image, profiles)—incus launch --project <p> [--profile P]+ <image> <name>device_add_disk(project, name, dev, source, dst)—incus config device add --project <p> <name> <dev> disk source=<src> path=<dst>set_env(project, name, key, val)—incus config set --project <p> <name> environment.<key>=<val>restart(project, name)—incus restart --project <p> <name>
Each wrapper calls process_spawn(prog, argv) then process_wait(pid) and returns Result[bool, string] (Ok(true) on exit 0, Err with stderr-summary on non-zero). Stderr passes through to the user's terminal — we don't capture and reformat.
- Step 1: Add wrappers to src/incus.reef
Add to src/incus.reef:
import sys.process as p
import core.result_generic as rg
Add to the export block:
fn project_ensure(project: string): rg.Result[bool, string]
fn container_exists(project: string, name: string): rg.Result[bool, string]
fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string]
fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string]
fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string]
fn restart(project: string, name: string): rg.Result[bool, string]
Implementations (place before end module):
// Run `incus <args>`. Returns Ok(true) on exit 0, Err with a brief diagnostic
// on non-zero. Stderr inherits the parent terminal — incus's own message
// reaches the user without us reformatting.
fn run_incus(args: [string]): rg.Result[bool, string]
let pid: int = p.process_spawn("incus", args)
if pid < 0
return @rg.Result[bool, string].Err("failed to spawn 'incus' (is it installed?)")
end if
let exit: int = p.process_wait(pid)
if exit == 0
return @rg.Result[bool, string].Ok(true)
end if
return @rg.Result[bool, string].Err("incus exited with code " + int_to_str_simple(exit))
end run_incus
fn int_to_str_simple(n: int): string
if n == 0
return "0"
end if
mut value: int = n
mut neg: bool = false
if value < 0
neg = true
value = -value
end if
mut digits: string = ""
while value > 0
let d: int = value % 10
let dc: char = '0'
unsafe
dc = d + 48
end unsafe
let one: string = ""
let appended: string = append_char_local(one, dc)
digits = appended + digits
value = value / 10
end while
if neg
return "-" + digits
end if
return digits
end int_to_str_simple
fn append_char_local(s: string, c: char): string
let n: int = str.length(s)
mut out: string = ""
mut i: int = 0
while i < n
out = out + char_to_string(s[i])
i = i + 1
end while
return out + char_to_string(c)
end append_char_local
fn char_to_string(c: char): string
let buf: string = ""
// Quick char-to-string via concatenation: rely on str.concat handling chars
// (Reef strings are buffer-backed; if str.concat fails we'd need an FFI).
mut tmp: string = " "
tmp[0] = c
return tmp
end char_to_string
// project_ensure: list, create if missing.
fn project_ensure(project: string): rg.Result[bool, string]
// `incus project show <name>` exits 0 if it exists, non-0 otherwise.
let pid: int = p.process_spawn("incus", ["project", "show", project])
let exit: int = p.process_wait(pid)
if exit == 0
return @rg.Result[bool, string].Ok(true)
end if
// Create
return run_incus(["project", "create", project])
end project_ensure
fn container_exists(project: string, name: string): rg.Result[bool, string]
// `incus info --project <p> <name>` exits 0 if it exists.
let pid: int = p.process_spawn("incus", ["info", "--project", project, name])
let exit: int = p.process_wait(pid)
if exit == 0
return @rg.Result[bool, string].Ok(true)
end if
return @rg.Result[bool, string].Ok(false)
end container_exists
fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string]
let pn: int = profiles.length()
// Compute argv length: ["launch", "--project", project, ...profile args (2*pn), image, name]
mut args: [string] = new [string](3 + 2 * pn + 2)
args[0] = "launch"
args[1] = "--project"
args[2] = project
mut i: int = 0
while i < pn
args[3 + i * 2] = "--profile"
args[3 + i * 2 + 1] = profiles[i]
i = i + 1
end while
args[3 + 2 * pn] = image
args[3 + 2 * pn + 1] = name
return run_incus(args)
end launch
fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string]
return run_incus([
"config", "device", "add",
"--project", project,
name,
dev,
"disk",
"source=" + src,
"path=" + dst
])
end device_add_disk
fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string]
return run_incus([
"config", "set",
"--project", project,
name,
"environment." + key + "=" + val
])
end set_env_var
fn restart(project: string, name: string): rg.Result[bool, string]
return run_incus(["restart", "--project", project, name])
end restart
Note on the int-to-string and char-to-string helpers: reef-stdlib does have these (encoding.toml has int_to_str; core.str has helpers) but they aren't all exported uniformly. To minimize surprise, the helpers above are local and self-contained. If a clean stdlib import works, prefer it — but verify with reefc --check before relying on it.
- Step 2: Compile-check the module
reefc build
Expected: builds cleanly. (No tests for this module — they require a live Incus.)
- Step 3: Commit
hg commit -m "incus: subprocess wrappers (project_ensure/launch/device_add/set_env/restart)"
Task 14: sync.build_rsync_args (pure)
Files:
- Create:
src/sync.reef - Create:
tests/test_sync_args.reef
Pure function that builds the rsync argv from (src, dst, dry_run, no_delete, is_tty, excluded_repos). The standard exclude list is hardcoded to match the bash prototype line by line. excluded_repos is the per-project backup = false skip list, applied only in whole-tree mode.
- Step 1: Write the failing test
Create tests/test_sync_args.reef:
import sync
import test.framework
proc main()
let runner = new framework.TestRunner()
// Basic: no flags
let a1: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, false, new [string](0))
runner.assert_eq_bool(contains_str(a1, "-aHAX"), true, "has -aHAX")
runner.assert_eq_bool(contains_str(a1, "--info=stats2"), true, "has --info=stats2")
runner.assert_eq_bool(contains_str(a1, "--delete"), true, "delete on by default")
runner.assert_eq_bool(contains_str(a1, "--exclude=node_modules/"), true, "node_modules excluded")
runner.assert_eq_bool(contains_str(a1, "--exclude=.cache/"), true, ".cache excluded")
runner.assert_eq_bool(last_two(a1, "/src/", "/dst/"), true, "src and dst end positional")
// Dry run: --dry-run + --itemize-changes + --info=stats2 (NOT progress2)
let a2: [string] = sync.build_rsync_args("/src/", "/dst/", true, false, true, new [string](0))
runner.assert_eq_bool(contains_str(a2, "--dry-run"), true, "dry-run flag")
runner.assert_eq_bool(contains_str(a2, "--itemize-changes"), true, "itemize-changes flag")
runner.assert_eq_bool(contains_str(a2, "--info=stats2"), true, "info stats2")
runner.assert_eq_bool(contains_str(a2, "--info=stats2,progress2"), false, "no progress in dry-run")
// No delete
let a3: [string] = sync.build_rsync_args("/src/", "/dst/", false, true, false, new [string](0))
runner.assert_eq_bool(contains_str(a3, "--delete"), false, "no --delete with no_delete")
// TTY interactive: stats2,progress2
let a4: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, true, new [string](0))
runner.assert_eq_bool(contains_str(a4, "--info=stats2,progress2"), true, "tty progress")
// Excluded repos in whole-tree mode
let a5: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, false, ["repo-A", "repo-B"])
runner.assert_eq_bool(contains_str(a5, "--exclude=repo-A/"), true, "excluded repo A")
runner.assert_eq_bool(contains_str(a5, "--exclude=repo-B/"), true, "excluded repo B")
runner.report()
end main
fn contains_str(arr: [string], target: string): bool
let n: int = arr.length()
mut i: int = 0
while i < n
if arr[i] == target
return true
end if
i = i + 1
end while
return false
end contains_str
fn last_two(arr: [string], a: string, b: string): bool
let n: int = arr.length()
if n < 2
return false
end if
return arr[n - 2] == a and arr[n - 1] == b
end last_two
- Step 2: Run test to verify it fails
reefc run tests/test_sync_args.reef
Expected: compile error (sync module not found).
- Step 3: Create src/sync.reef
module sync
import core.str
import core.result_generic as rg
import sys.process as p
export
fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string]
end export
// Hardcoded excludes matching bash prototype line 26-41.
fn standard_excludes(): [string]
return [
"node_modules/",
"target/",
"build/",
"dist/",
".next/",
"__pycache__/",
"*.pyc",
".venv/",
"venv/",
".cache/",
".tox/",
".pytest_cache/",
".mypy_cache/",
".ruff_cache/"
]
end standard_excludes
fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string]
let std: [string] = standard_excludes()
let std_n: int = std.length()
let ex_n: int = excluded_repos.length()
// Estimate capacity: 1 (-aHAX) + up to 3 info flags + 1 (--delete) + std_n excludes + ex_n excludes + 2 positionals
let cap: int = 1 + 3 + 1 + std_n + ex_n + 2
mut buf: [string] = new [string](cap)
mut k: int = 0
buf[k] = "-aHAX"
k = k + 1
if dry_run
buf[k] = "--dry-run"
k = k + 1
buf[k] = "--itemize-changes"
k = k + 1
buf[k] = "--info=stats2"
k = k + 1
elif is_tty
buf[k] = "--info=stats2,progress2"
k = k + 1
else
buf[k] = "--info=stats2"
k = k + 1
end if
if not no_delete
buf[k] = "--delete"
k = k + 1
end if
mut i: int = 0
while i < std_n
buf[k] = "--exclude=" + std[i]
k = k + 1
i = i + 1
end while
mut j: int = 0
while j < ex_n
buf[k] = "--exclude=" + excluded_repos[j] + "/"
k = k + 1
j = j + 1
end while
buf[k] = src
k = k + 1
buf[k] = dst
k = k + 1
// Trim to actual size
mut out: [string] = new [string](k)
mut m: int = 0
while m < k
out[m] = buf[m]
m = m + 1
end while
return out
end build_rsync_args
end module
- Step 4: Run test to verify it passes
reefc run tests/test_sync_args.reef
Expected: 13 assertions pass.
- Step 5: Commit
hg add src/sync.reef tests/test_sync_args.reef
hg commit -m "sync: build_rsync_args (pure) + tests covering every branch"
Task 15: sync.ensure_nfs_mounted (subprocess)
Files:
- Modify:
src/sync.reef(addensure_nfs_mounted)
Three-step check from bash prototype lines 60-71:
stat <backup_root>(triggers autofs)mountpoint -q <backup_root>(confirms it's a mount)findmnt -t nfs4 <backup_root>(confirms NFSv4)
Each fails fast with a different message. No unit test — needs a real NFS mount; smoke test will exercise it.
- Step 1: Add ensure_nfs_mounted to src/sync.reef
Add to export block:
fn ensure_nfs_mounted(backup_root: string): rg.Result[bool, string]
Implementation:
fn ensure_nfs_mounted(backup_root: string): rg.Result[bool, string]
// Step 1: stat triggers autofs
let pid1: int = p.process_spawn("stat", [backup_root])
if pid1 < 0
return @rg.Result[bool, string].Err("cannot spawn stat")
end if
if p.process_wait(pid1) != 0
return @rg.Result[bool, string].Err("cannot stat " + backup_root + " — autofs misconfigured or server unreachable")
end if
// Step 2: mountpoint
let pid2: int = p.process_spawn("mountpoint", ["-q", backup_root])
if pid2 < 0
return @rg.Result[bool, string].Err("cannot spawn mountpoint")
end if
if p.process_wait(pid2) != 0
return @rg.Result[bool, string].Err(backup_root + " exists but is not a mount — NFS server unreachable?")
end if
// Step 3: findmnt -t nfs4
let pid3: int = p.process_spawn("findmnt", ["-t", "nfs4", backup_root])
if pid3 < 0
return @rg.Result[bool, string].Err("cannot spawn findmnt")
end if
if p.process_wait(pid3) != 0
return @rg.Result[bool, string].Err(backup_root + " is mounted but not as nfs4 — check /etc/auto.nfs")
end if
return @rg.Result[bool, string].Ok(true)
end ensure_nfs_mounted
- Step 2: Compile-check
reefc build
Expected: builds cleanly.
- Step 3: Commit
hg commit -m "sync: ensure_nfs_mounted (stat → mountpoint → findmnt)"
Task 16: sync.run
Files:
- Modify:
src/sync.reef(addrun)
Spawns rsync with the args from build_rsync_args and returns its exit code. Inherits parent stdio so the user sees rsync's progress live.
- Step 1: Add run to src/sync.reef
Add to export block:
fn run(args: [string]): int
Implementation:
// Spawn rsync with the given argv. Inherits parent stdio (no capture).
// Returns rsync's exit code, or -1 if spawn failed.
fn run(args: [string]): int
let pid: int = p.process_spawn("rsync", args)
if pid < 0
return -1
end if
return p.process_wait(pid)
end run
- Step 2: Compile-check
reefc build
Expected: builds cleanly.
- Step 3: Commit
hg commit -m "sync: run (spawn rsync, inherit stdio, return exit code)"
Task 17: cli.cmd_new (orchestration)
Files:
- Create:
src/cli.reef(withcmd_newfunction and supporting flag-parsing)
cmd_new walks through every step of spec §4.1: validate name, load registry, reject duplicates, resolve repo path, parse override, merge, ensure project, ensure no container conflict, launch, attach mounts, set env, restart, write registry, print ready hint. No unit test — orchestration is smoke-tested.
- Step 1: Create src/cli.reef with cmd_new
module cli
import core.str
import core.result_generic as rg
import io.console as console
import io.file as iofile
import sys.flag as flag
import sys.env as env
import sys.args as args
import config
import incus
import sync
import paths
export
fn cmd_new(argv: [string]): int
fn cmd_sync(argv: [string]): int
fn dispatch(argv: [string]): int
end export
// argv passed in is the slice past argv[1] (i.e., excludes program + subcommand).
fn cmd_new(argv: [string]): int
let parser: flag.FlagParser = flag.flag_parser_from(argv)
flag.application(parser, "repoman new")
flag.description(parser, "Create a new container + repo bind")
let _ = flag.string_flag(parser, "repo", '\0', "", "repo dirname (defaults to <name>)")
let _ = flag.string_flag(parser, "image", '\0', "", "container image (overrides default)")
if not flag.parse(parser)
console.printErr("repoman: error: " + flag.error(parser) + "\n")
return 2
end if
let positionals: [string] = flag.positional_args(parser)
if positionals.length() != 1
console.printErr("repoman: error: 'new' takes exactly one positional argument: <name>\n")
return 2
end if
let name: string = positionals[0]
let repo_flag: string = flag.get_string(parser, "repo")
let image_flag: string = flag.get_string(parser, "image")
if not incus.validate_name(name)
console.printErr("repoman: error: invalid container name: " + name + "\n")
console.printErr("hint: lowercase alphanumeric + hyphens, ≤63 chars, no leading hyphen\n")
return 1
end if
let home: string = env.get_env_or("HOME", "")
if str.length(home) == 0
console.printErr("repoman: error: HOME is not set\n")
return 3
end if
let cfg_path: string = config.registry_path(home)
let reg_r = config.load_or_init(home)
if rg.is_err(reg_r)
console.printErr("repoman: error: " + rg.unwrap_err(reg_r) + "\n")
return 3
end if
let reg: config.Registry = rg.unwrap_ok(reg_r)
// Reject duplicate name
let pn: int = reg.projects.length()
mut i: int = 0
while i < pn
if reg.projects[i].name == name
console.printErr("repoman: error: project '" + name + "' already in registry\n")
console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + " ; then remove from " + cfg_path + "\n")
return 4
end if
i = i + 1
end while
// Resolve repo path
mut repo: string = repo_flag
if str.length(repo) == 0
repo = name
end if
let repos_root: string = paths.expand_home(reg.defaults.repos_root)
let repo_path: string = paths.join(repos_root, repo)
if not paths.is_dir(repo_path)
console.printErr("repoman: error: no repo at " + repo_path + "\n")
return 3
end if
// Read override (optional)
let override_path: string = paths.join(home, ".config/repoman/repos.d/" + name + ".toml")
mut override: config.Override = config.Override {
image: "", profiles: new [string](0), has_profiles: false,
mounts: new [config.Mount](0),
env_keys: new [string](0), env_values: new [string](0)
}
if iofile.fileExists(override_path)
let ov_r = config.parse_override(iofile.readFile(override_path))
if rg.is_err(ov_r)
console.printErr("repoman: error: bad override " + override_path + ": " + rg.unwrap_err(ov_r) + "\n")
return 3
end if
override = rg.unwrap_ok(ov_r)
end if
let eff: config.EffectiveConfig = config.merge_with_defaults(name, repo, image_flag, override, reg.defaults)
// Ensure incus project
console.printErr("==> incus project ensure " + reg.defaults.incus_project + "\n")
let pe = incus.project_ensure(reg.defaults.incus_project)
if rg.is_err(pe)
console.printErr("repoman: error: " + rg.unwrap_err(pe) + "\n")
return 1
end if
// Reject if container exists already
let ce = incus.container_exists(reg.defaults.incus_project, name)
if rg.is_err(ce)
console.printErr("repoman: error: " + rg.unwrap_err(ce) + "\n")
return 1
end if
if rg.unwrap_ok(ce)
console.printErr("repoman: error: container '" + name + "' already exists in project '" + reg.defaults.incus_project + "'\n")
console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + "\n")
return 4
end if
// Launch
console.printErr("==> incus launch " + eff.image + " " + name + "\n")
let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles)
if rg.is_err(lr)
console.printErr("repoman: error: " + rg.unwrap_err(lr) + "\n")
return 1
end if
// Mounts: device names "repo" for the auto bind, "mount-1", "mount-2", ...
let mn: int = eff.mounts.length()
mut k: int = 0
while k < mn
let m: config.Mount = eff.mounts[k]
let dev_name: string = "repo"
if k > 0
dev_name = "mount-" + int_to_str_simple(k)
end if
console.printErr("==> incus device add " + name + " " + dev_name + " " + m.source + ":" + m.path + "\n")
let dr = incus.device_add_disk(reg.defaults.incus_project, name, dev_name, m.source, m.path)
if rg.is_err(dr)
console.printErr("repoman: error: " + rg.unwrap_err(dr) + "\n")
console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + "\n")
return 1
end if
k = k + 1
end while
// Env
let en: int = eff.env_keys.length()
mut e: int = 0
while e < en
let er = incus.set_env_var(reg.defaults.incus_project, name, eff.env_keys[e], eff.env_values[e])
if rg.is_err(er)
console.printErr("repoman: error: " + rg.unwrap_err(er) + "\n")
return 1
end if
e = e + 1
end while
// Restart so binds + env take effect
console.printErr("==> incus restart " + name + "\n")
let rr = incus.restart(reg.defaults.incus_project, name)
if rg.is_err(rr)
console.printErr("repoman: error: " + rg.unwrap_err(rr) + "\n")
return 1
end if
// Build new project entry and write registry
let new_p: config.Project = config.Project {
name: name,
repo: repo,
image: eff.image,
profiles: eff.profiles,
created: "", // v0.1: leave timestamp blank (no time stdlib used yet)
last_sync: "",
backup: true
}
let reg2_r = config.add_project(reg, new_p)
if rg.is_err(reg2_r)
console.printErr("repoman: error: " + rg.unwrap_err(reg2_r) + "\n")
return 1
end if
let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path)
if rg.is_err(saved)
console.printErr("repoman: error: " + rg.unwrap_err(saved) + "\n")
return 1
end if
// Ready hint — use $UID and $HOME for shell expansion (correct on any host)
console.printErr("==> ready\n")
console.printErr("\n")
console.printErr(" shell in: incus exec --project " + reg.defaults.incus_project + " --user $UID --cwd " + repo_path + " --env HOME=$HOME " + name + " -- bash -l\n")
console.printErr(" run claude: incus exec --project " + reg.defaults.incus_project + " " + name + " -- claude\n")
return 0
end cmd_new
// Local int-to-str helper (decimal, non-negative integers expected)
fn int_to_str_simple(n: int): string
if n == 0
return "0"
end if
mut value: int = n
mut digits: string = ""
while value > 0
let d: int = value % 10
let dc: char = '0'
unsafe
dc = d + 48
end unsafe
let one_buf: string = " "
one_buf[0] = dc
digits = one_buf + digits
value = value / 10
end while
return digits
end int_to_str_simple
end module
- Step 2: Compile-check
reefc build
Expected: builds cleanly.
- Step 3: Commit
hg add src/cli.reef
hg commit -m "cli: cmd_new orchestration (validate→load→merge→incus→save)"
Task 18: cli.cmd_sync (orchestration)
Files:
- Modify:
src/cli.reef(addcmd_sync)
cmd_sync walks spec §4.2: parse flags, load registry, ensure NFS mount, resolve target (single project vs whole tree), build args, run rsync, on success update last_sync. Exit code is rsync's, with pre-rsync errors using 1-4.
- Step 1: Add cmd_sync to src/cli.reef
Add the implementation (anywhere before end module):
fn cmd_sync(argv: [string]): int
let parser: flag.FlagParser = flag.flag_parser_from(argv)
flag.application(parser, "repoman sync")
flag.description(parser, "rsync local repos → NFS backup")
let _ = flag.bool_flag(parser, "no-delete", '\0', false, "additive only — no deletions on the destination")
let _ = flag.bool_flag(parser, "dry-run", '\0', false, "preview changes without writing")
if not flag.parse(parser)
console.printErr("repoman: error: " + flag.error(parser) + "\n")
return 2
end if
let positionals: [string] = flag.positional_args(parser)
if positionals.length() > 1
console.printErr("repoman: error: 'sync' takes at most one positional argument: [name]\n")
return 2
end if
let no_delete: bool = flag.get_bool(parser, "no-delete")
let dry_run: bool = flag.get_bool(parser, "dry-run")
let home: string = env.get_env_or("HOME", "")
if str.length(home) == 0
console.printErr("repoman: error: HOME is not set\n")
return 3
end if
let reg_r = config.load_or_init(home)
if rg.is_err(reg_r)
console.printErr("repoman: error: " + rg.unwrap_err(reg_r) + "\n")
return 3
end if
let reg: config.Registry = rg.unwrap_ok(reg_r)
let cfg_path: string = config.registry_path(home)
let backup_root: string = paths.expand_home(reg.defaults.backup_root)
let repos_root: string = paths.expand_home(reg.defaults.repos_root)
// ensure_nfs_mounted
let mr = sync.ensure_nfs_mounted(backup_root)
if rg.is_err(mr)
console.printErr("repoman: error: " + rg.unwrap_err(mr) + "\n")
return 3
end if
// Resolve target
mut src: string = ""
mut dst: string = ""
mut excluded: [string] = new [string](0)
mut single_target: string = ""
if positionals.length() == 1
let name: string = positionals[0]
// Find in registry
let pn: int = reg.projects.length()
mut found: int = -1
mut i: int = 0
while i < pn
if reg.projects[i].name == name
found = i
end if
i = i + 1
end while
if found < 0
console.printErr("repoman: error: '" + name + "' not in registry\n")
console.printErr("hint: repoman new " + name + "\n")
return 1
end if
let proj: config.Project = reg.projects[found]
if not proj.backup
console.printErr("repoman: error: '" + name + "' has backup = false; refusing single-target sync\n")
return 1
end if
src = paths.join(repos_root, proj.repo) + "/"
dst = paths.join(backup_root, proj.repo) + "/"
single_target = name
else
// whole tree
src = repos_root + "/"
dst = backup_root + "/"
// Build excludes for backup=false projects
let pn: int = reg.projects.length()
mut buf: [string] = new [string](pn)
mut count: int = 0
mut i: int = 0
while i < pn
if not reg.projects[i].backup
buf[count] = reg.projects[i].repo
count = count + 1
end if
i = i + 1
end while
mut tight: [string] = new [string](count)
mut j: int = 0
while j < count
tight[j] = buf[j]
j = j + 1
end while
excluded = tight
end if
// Build args + log + run
let is_tty: bool = false // v0.1: assume non-TTY (cron-friendly defaults).
// TTY detection via ui.backend.tty.is_tty(STDOUT_FD) is a v0.2 niceness.
let rsync_args: [string] = sync.build_rsync_args(src, dst, dry_run, no_delete, is_tty, excluded)
mut tags: string = ""
if dry_run
tags = tags + "(dry-run) "
end if
if no_delete
tags = tags + "(additive) "
end if
console.printErr("==> rsync " + tags + src + " → " + dst + "\n")
let exit: int = sync.run_rsync(rsync_args)
if exit < 0
console.printErr("repoman: error: failed to spawn rsync\n")
return 1
end if
if exit != 0
return exit
end if
// Success: update last_sync. Skip in dry-run mode (nothing changed).
if not dry_run
let now: string = "" // v0.1: timestamp blank; time stdlib integration is a follow-on
if str.length(single_target) > 0
let upd = config.update_last_sync(reg, single_target, now)
if rg.is_ok(upd)
let _ = config.save(rg.unwrap_ok(upd), cfg_path)
end if
else
mut cur: config.Registry = reg
let pn: int = cur.projects.length()
mut i: int = 0
while i < pn
if cur.projects[i].backup
let upd = config.update_last_sync(cur, cur.projects[i].name, now)
if rg.is_ok(upd)
cur = rg.unwrap_ok(upd)
end if
end if
i = i + 1
end while
let _ = config.save(cur, cfg_path)
end if
end if
return 0
end cmd_sync
- Step 2: Compile-check
reefc build
Expected: builds cleanly.
- Step 3: Commit
hg commit -m "cli: cmd_sync orchestration (NFS check → rsync → last_sync update)"
Task 19: cli.dispatch (outer router)
Files:
- Modify:
src/cli.reef(adddispatch,usage)
Outer dispatch on argv[1] per spec §5. Handles new, sync, --version/-V, --help/-h/help, no args.
- Step 1: Add dispatch + usage to src/cli.reef
Add to the top of the implementations (before cmd_new):
fn version_string(): string
return "repoman 0.1.0"
end version_string
proc print_usage()
console.printErr("Usage: repoman <subcommand> [args]\n")
console.printErr("\n")
console.printErr("Subcommands\n")
console.printErr(" new <name> [--repo <dirname>] [--image <image>]\n")
console.printErr(" Launch a container in the 'repoman' Incus project; bind ~/repos/<dirname>.\n")
console.printErr("\n")
console.printErr(" sync [name] [--no-delete] [--dry-run]\n")
console.printErr(" Mirror local repos to NFS backup (rsync --delete by default).\n")
console.printErr("\n")
console.printErr(" --version | -V\n")
console.printErr(" --help | -h | help\n")
end print_usage
Add dispatch (place after cmd_sync):
fn dispatch(argv: [string]): int
// argv is the full process argv: [program, subcommand, ...]
let n: int = argv.length()
if n < 2
print_usage()
return 0
end if
let sub: string = argv[1]
if sub == "--version" or sub == "-V"
console.printErr(version_string() + "\n")
return 0
end if
if sub == "--help" or sub == "-h" or sub == "help"
print_usage()
return 0
end if
// Slice argv[2..] for the subcommand parser
mut rest: [string] = new [string](n - 2)
mut i: int = 0
while i < n - 2
rest[i] = argv[i + 2]
i = i + 1
end while
if sub == "new"
return cmd_new(rest)
end if
if sub == "sync"
return cmd_sync(rest)
end if
console.printErr("repoman: error: unknown subcommand: " + sub + "\n")
console.printErr("hint: try 'repoman --help'\n")
return 2
end dispatch
- Step 2: Compile-check
reefc build
Expected: builds cleanly.
- Step 3: Commit
hg commit -m "cli: dispatch + usage (subcommand routing, --version, --help)"
Task 20: main.reef entry point
Files:
- Modify:
src/main.reef
Tiny entry that collects argv, calls cli.dispatch, exits with the returned code.
- Step 1: Replace src/main.reef
Overwrite src/main.reef with:
import cli
import sys.args as args
import sys.process as p
proc main()
let n: int = args.count()
mut argv: [string] = new [string](n)
mut i: int = 0
while i < n
argv[i] = args.get(i)
i = i + 1
end while
let code: int = cli.dispatch(argv)
p.exit_now(code)
end main
- Step 2: Build and smoke-test the help output
reefc build
./build/repoman --version
./build/repoman --help
./build/repoman
./build/repoman bogus-subcommand
Expected:
--version→repoman 0.1.0, exit 0.--help→ usage, exit 0.- no args → usage, exit 0.
- bogus → error message, exit 2.
(Run echo $? after each to check exit codes.)
- Step 3: Commit
hg commit -m "main: argv collection + dispatch"
Task 21: README + Makefile
Files:
- Create:
README.md - Create:
Makefile
README documents quickstart, build, test loop, smoke recipe, install. Makefile provides make/make install/make uninstall/make clean/make test for distro packagers — reefc does the actual building.
- Step 1: Create README.md
# repoman
Per-project Incus containers + opinionated NFS/ZFS backup. v0.1.
## Build
```bash
reefc build
Produces ./build/repoman.
Test
for t in tests/test_*.reef; do
echo "== $t =="
reefc run "$t" || exit 1
done
Install
System-wide via Makefile (uses reefc build under the hood):
make
sudo make install # installs to /usr/local/bin/repoman
Quickstart
# First run creates ~/.config/repoman/repoman.toml with sane defaults.
repoman --help
repoman new isurus --repo isurus-project
repoman sync --dry-run
Smoke test (requires Incus + NFS)
# In an existing repo dir under ~/repos:
repoman new test-foo
repoman sync test-foo --dry-run
incus delete --project repoman test-foo
Configuration
Central registry: ~/.config/repoman/repoman.toml (managed; do not edit while repoman is running).
Per-project overrides: ~/.config/repoman/repos.d/<container-name>.toml (user-authored). Example:
[container]
image = "images:debian/12/cloud"
profiles = ["default", "claude-share", "node-dev"]
[[mount]]
source = "~/.npm"
path = "/home/ctusa/.npm"
[env]
NODE_ENV = "development"
Recommended Incus profile: claude-share
For the agent-friendly setup repoman is built around, create a shared profile that exposes the user's Claude state:
# (one-time)
incus profile create claude-share
incus profile edit claude-share # add your bind-mounts for ~/.claude, etc.
repoman uses profiles default and claude-share by default; override per-project in repos.d/<name>.toml.
- [ ] **Step 2: Create Makefile**
```makefile
PREFIX ?= /usr/local
BINDIR = $(PREFIX)/bin
DESTDIR ?=
.PHONY: all build test clean install uninstall
all: build
build:
reefc build
test:
@for t in tests/test_*.reef; do \
echo "== $$t =="; \
reefc run "$$t" || exit 1; \
done
clean:
reefc clean
install: build
install -d $(DESTDIR)$(BINDIR)
install -m 0755 build/repoman $(DESTDIR)$(BINDIR)/repoman
uninstall:
rm -f $(DESTDIR)$(BINDIR)/repoman
- Step 3: Verify make targets work
make clean
make build
ls build/repoman
make test
Expected: make build produces the binary, make test runs every tests/test_*.reef and reports pass.
- Step 4: Commit
hg add README.md Makefile
hg commit -m "docs: README + Makefile (quickstart, install, test loop)"
Task 22: End-to-end smoke test
Files: none — operational verification
Run the smoke recipe from README against a real Incus + NFS environment. This is the v0.1 acceptance gate.
- Step 1: Pre-flight
Confirm:
-
incus versionworks, daemon is running -
/nfs/reposis autofs-mounted (autofs config covers it) -
A dir exists under
~/repos/<some-name>for the smoke (e.g.,mkdir ~/repos/repoman-smoke; cd ~/repos/repoman-smoke; hg init) -
Step 2: First-run init
rm -rf ~/.config/repoman # clean slate; you may want to back up first if you've been using it
./build/repoman --help
ls ~/.config/repoman/repoman.toml # should NOT exist yet (help doesn't init)
./build/repoman sync --dry-run # this triggers init
cat ~/.config/repoman/repoman.toml # should show schema=1, no projects
Expected: registry created on first command that touches it.
- Step 3:
newflow
./build/repoman new repoman-smoke
Expected:
-
Stderr shows
==> incus project ensure repoman,==> incus launch ...,==> incus device add ... repo ...,==> incus restart ...,==> ready. -
incus list --project repomanshowsrepoman-smokerunning. -
~/.config/repoman/repoman.tomlnow has a[[project]]entry forrepoman-smoke. -
The shell-in hint is printed.
-
Step 4: Verify the bind mount
incus exec --project repoman repoman-smoke -- ls /home/ctusa/repos/repoman-smoke
Expected: contents of the host repo are visible inside the container.
- Step 5:
syncdry-run
./build/repoman sync repoman-smoke --dry-run
Expected: rsync prints itemize-changes lines (mostly cd+++++++++ for new dirs), exits 0, the registry's last_sync for repoman-smoke is NOT updated (because dry-run).
- Step 6:
syncreal run
./build/repoman sync repoman-smoke
Expected: rsync runs, exits 0, registry's last_sync for repoman-smoke is updated to a non-empty value (or stays empty if you haven't wired the timestamp yet — that's a v0.2 task per spec).
- Step 7: Cleanup
incus delete --project repoman --force repoman-smoke
# Manually remove the entry from ~/.config/repoman/repoman.toml (no `repoman remove` in v0.1)
- Step 8: Tag the release
hg tag -m "v0.1.0 ships" v0.1.0
If the smoke test surfaced bugs, fix them before tagging — open follow-up commits as needed.
Self-review checklist (run after writing all tasks)
After completing the plan, verify:
-
Spec coverage:
- §1 scope:
new,sync, registry, override, project namespace ✓ (Tasks 17, 18, 11, 8, 17) - §2 architecture: 6 modules ✓ (Tasks 2, 3, 4-12, 13, 14-16, 17-19)
- §3 data shapes: registry, override, merge, atomic write, validation ✓ (Tasks 4-9, 11, 12)
- §4 flows:
new,sync✓ (Tasks 17, 18) - §5 CLI: subcommands, exit codes, error UX ✓ (Tasks 17-19)
- §6 testing: pure-logic units, smoke recipe ✓ (Tasks 2, 3, 5-12, 14, 22)
- §7 build/install: reef.toml, Makefile, README ✓ (Tasks 1, 21)
- §1 scope:
-
Type consistency: function names and signatures match between definitions and call sites —
parse_registry,serialize_registry,parse_override,merge_with_defaults,add_project,update_last_sync,load_or_init,save,registry_path,default_registry. Theincus.*andsync.*symbol shapes are consistent acrosscli.reef's call sites and the module exports. -
Placeholder scan: no TODO, no "implement later", no "similar to Task N". Code blocks are concrete.
Known v0.1 simplifications (intentional, not gaps)
These are tracked for v0.2, deliberately omitted from the plan:
- Timestamps blank.
createdandlast_syncare written as"". Atime.now_iso8601()integration is straightforward but the time stdlib hasn't been audited yet; a dedicated follow-up task can add it without breaking the schema. - TTY detection always false.
cmd_synchardcodesis_tty = false, giving cron-friendly--info=stats2. To get progress bars in interactive use, wireui.backend.tty.is_tty(STDOUT_FD). Trivial to add. - No rollback on partial
newfailure. Per spec §4.1: if launch succeeds but a downstream step fails, the user sees the error and the manual cleanup hint. v0.2 candidate:--rollback-on-error. - No CI. Per spec §6.5. Test loop runs locally via
make test.