|
root / docs / superpowers / plans / 2026-04-29-repoman-v0.1.md
2026-04-29-repoman-v0.1.md markdown 2980 lines 92.3 KB

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 for new/sync)
  • Reef stdlib reference: ~/reef-lang-0.5.10-source/reef-stdlib/
    • test/framework.reef — TestRunner API (note: new framework.TestRunner(), not new TestRunner())
    • encoding/toml.reeftoml_builder(), toml_set_*, toml_array_append_table, toml_render, toml_parse_doc, TomlDoc
    • io/path.reefexpand_home, join, dirname, basename
    • io/file.reefreadFile, writeFile, fileExists, rename, fsync, deleteFile
    • io/dir.reefdir_exists, is_directory, create_dir_all
    • sys/flag.reefflag_parser_from(args), bool_flag, string_flag, parse, positional_args
    • sys/process.reefprocess_spawn(prog, [args]), process_wait(pid), process_exit_code()
    • sys/args.reefcount(), get(i), program()
    • sys/env.reefget_env(name), get_env_or(name, default)
    • core/result_generic.reefResult[T, E], construction @Result[T, E].Ok(v) / @Result[T, E].Err(e), queries is_ok(r), is_err(r), unwrap_ok(r), unwrap_err(r)
    • core/str.reeflength, 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.reef has no module declaration; it's the entry point.
  • Tests are standalone reef programs (each has its own proc main()); they import production modules from src/ (resolved automatically when run from project root).
  • All module <name> blocks must end with end 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), not path. Bare path collides with the stdlib's io.path and reef resolves to the stdlib module first, producing C-codegen errors at link time. Pluralizing avoids the collision; paths.X reads 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_profiles distinguishes "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_values are 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 (add parse_registry function + 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 (add serialize_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 (add parse_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 (add merge_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 (add load_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 stub save with 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>, else incus 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 (add ensure_nfs_mounted)

Three-step check from bash prototype lines 60-71:

  1. stat <backup_root> (triggers autofs)
  2. mountpoint -q <backup_root> (confirms it's a mount)
  3. 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 (add run)

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 (with cmd_new function 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 (add cmd_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 (add dispatch, 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:

  • --versionrepoman 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"

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 version works, daemon is running

  • /nfs/repos is 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: new flow
./build/repoman new repoman-smoke

Expected:

  • Stderr shows ==> incus project ensure repoman, ==> incus launch ..., ==> incus device add ... repo ..., ==> incus restart ..., ==> ready.

  • incus list --project repoman shows repoman-smoke running.

  • ~/.config/repoman/repoman.toml now has a [[project]] entry for repoman-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: sync dry-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: sync real 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:

  1. 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)
  2. 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. The incus.* and sync.* symbol shapes are consistent across cli.reef's call sites and the module exports.

  3. 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. created and last_sync are written as "". A time.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_sync hardcodes is_tty = false, giving cron-friendly --info=stats2. To get progress bars in interactive use, wire ui.backend.tty.is_tty(STDOUT_FD). Trivial to add.
  • No rollback on partial new failure. 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.