repoman rename — 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: Add a repoman rename <old> <new> subcommand that renames a project across every layer that encodes its name — incus container, repo bind device, host repo dir, registry entry, and (opt-in) Claude history slug — idempotently.
Architecture: A pure registry mutation (config.rename_project) mirrors the existing update_last_sync helper and is unit-tested via TDD. Four thin run_incus wrappers add the incus verbs the command needs. cli.cmd_rename orchestrates the layers using the existing cmd_remove skeleton (load → resolve → guard → confirm → log → mutate → save), inspecting each layer's state and changing only what's still on the old name.
Tech Stack: Reef 0.7.5 (reefc on PATH), incus CLI, Mercurial (hg). Build: reefc build. Test: reefc run tests/<file>.reef and make test.
Global Constraints
- Reef 0.7.5 stdlib: fallible stdlib ops return
result.Result/option.Option. Repoman keeps its ownrg.Result[T, string]convention (import core.result as rg) and converts stdliberror.Error→ string at boundaries viaerror.error_message(e). - Result construction is
@Result[T, string].Ok(...)/.Err(...)(bareResult, resolves tocore.result). - Reef syntax:
fn name(...): T ... end name;proc name(...) ... end name;mut/let;while ... end while;if ... end if; string concat with+; array allocnew [T](n);.length(); indexa[i]. - Every new source file starts with the header from
SRCHEADER.txt(fillProject: repoman,Filename:,Description:). - Incus project for all containers:
reg.defaults.incus_project(valuerepoman). - Author for commits:
Chris Tusa <chris.tusa@leafscale.com>. VCS is Mercurial — commit withhg commit -u "<author>" -m "<msg>" <files>. Nevergit. container_state(project, name)returns"RUNNING","STOPPED", the raw state string, or"MISSING"when the container does not exist.
File Structure
src/config.reef— addrename_project(registry mutation) + export. Owns registry logic.src/incus.reef— addstop,start,rename_container,device_setwrappers + exports. Owns incus CLI shelling.src/cli.reef— addcmd_rename, wire intodispatch+print_usage+ theexportblock. Owns subcommand orchestration + user I/O.tests/test_config_rename.reef— new. Unit tests forrename_project.README.md— document therenamesubcommand.
Task 1: config.rename_project registry mutation (TDD)
Files:
- Create:
tests/test_config_rename.reef - Modify:
src/config.reef(add toexportblock near line 44; add fn afterremove_project, ~line 490)
Interfaces:
-
Consumes:
config.Registry,config.Project,config.Defaults,config.Host,config.with_projects(existing). -
Produces:
fn rename_project(reg: Registry, old: string, new_name: string): rg.Result[Registry, string]— renames the project namedoldtonew_name; setsrepo = new_nameiff the oldrepo == old(coupled dirname), else leavesrepounchanged;Errifoldabsent ornew_namealready used by a different project. NOTE:newis a reserved keyword in Reef (array allocation), so the parameter isnew_name. -
Step 1: Write the failing test
Create tests/test_config_rename.reef (header from SRCHEADER.txt, Description: Tests: rename_project registry mutation):
import config
import test.framework
import core.result as rg
fn empty_defaults(): config.Defaults
return config.Defaults {
repos_root: "/r", backup_root: "/b", logdir: "/l", incus_project: "p",
default_image: "img", profiles: new [string](0)
}
end empty_defaults
fn mk_project(name: string, repo: string): config.Project
return config.Project {
name: name, repo: repo, image: "img",
profiles: new [string](0), created: "t", last_sync: "", backup: true
}
end mk_project
proc main()
let runner = new framework.TestRunner()
// Two projects: a coupled one (name==repo) and a decoupled one (name!=repo).
mut projs: [config.Project] = new [config.Project](2)
projs[0] = mk_project("driftohm", "driftohm")
projs[1] = mk_project("portal", "leafscale-infrastructure/portal.leafscale.com")
let reg0: config.Registry = config.Registry {
schema: 3, host: config.Host { lan_ip: "" }, output: "quiet",
defaults: empty_defaults(), projects: projs
}
// Coupled rename: both name and repo move.
let r1 = config.rename_project(reg0, "driftohm", "mist")
runner.assert_eq_bool(rg.is_ok(r1), true, "coupled rename ok")
if rg.is_ok(r1)
let reg1 = rg.unwrap_ok(r1)
runner.assert_eq_string(reg1.projects[0].name, "mist", "coupled: name moved")
runner.assert_eq_string(reg1.projects[0].repo, "mist", "coupled: repo moved in lockstep")
runner.assert_eq_string(reg1.output, "quiet", "output preserved through rename_project")
end if
// Decoupled rename: only name moves, repo (nested path) preserved.
let r2 = config.rename_project(reg0, "portal", "gateway")
runner.assert_eq_bool(rg.is_ok(r2), true, "decoupled rename ok")
if rg.is_ok(r2)
let reg2 = rg.unwrap_ok(r2)
runner.assert_eq_string(reg2.projects[1].name, "gateway", "decoupled: name moved")
runner.assert_eq_string(reg2.projects[1].repo, "leafscale-infrastructure/portal.leafscale.com", "decoupled: repo dirname preserved")
end if
// Collision: rename onto an existing project name fails.
let r3 = config.rename_project(reg0, "driftohm", "portal")
runner.assert_eq_bool(rg.is_err(r3), true, "rename onto existing name rejected")
// Unknown source name fails.
let r4 = config.rename_project(reg0, "ghost", "whatever")
runner.assert_eq_bool(rg.is_err(r4), true, "unknown source name rejected")
runner.report()
end main
- Step 2: Run test to verify it fails
Run: reefc run tests/test_config_rename.reef
Expected: FAIL — compile/type error rename_project is not a member of config (function not defined yet).
- Step 3: Add the export declaration
In src/config.reef, inside the export block, add after the remove_project line (~line 44):
fn rename_project(reg: Registry, old: string, new_name: string): rg.Result[Registry, string]
- Step 4: Implement
rename_project
In src/config.reef, add after remove_project (~line 490, before end module):
// Rename the project named `old` to `new`. The repo dirname moves in lockstep
// only when it was coupled to the old name (repo == old); a deliberately
// decoupled dirname (e.g. a nested monorepo path) is preserved. Errors if `old`
// is absent or `new` is already used by a different project.
fn rename_project(reg: Registry, old: string, new_name: 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 == old
found = i
end if
i = i + 1
end while
if found < 0
return @Result[Registry, string].Err("project not in registry: " + old)
end if
mut j: int = 0
while j < n
if j != found and reg.projects[j].name == new_name
return @Result[Registry, string].Err("project already exists: " + new_name)
end if
j = j + 1
end while
mut new_projects: [Project] = new [Project](n)
mut k: int = 0
while k < n
if k == found
let old_p: Project = reg.projects[k]
mut new_repo: string = old_p.repo
if old_p.repo == old
new_repo = new_name
end if
new_projects[k] = Project {
name: new_name, repo: new_repo, image: old_p.image,
profiles: old_p.profiles, created: old_p.created,
last_sync: old_p.last_sync, backup: old_p.backup
}
else
new_projects[k] = reg.projects[k]
end if
k = k + 1
end while
return @Result[Registry, string].Ok(with_projects(reg, new_projects))
end rename_project
- Step 5: Run test to verify it passes
Run: reefc run tests/test_config_rename.reef
Expected: PASS — All tests passed!, Tests failed: 0.
- Step 6: Commit
hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
-m "config: add rename_project registry mutation" \
src/config.reef tests/test_config_rename.reef
Task 2: incus verb wrappers (stop, start, rename_container, device_set)
Files:
- Modify:
src/incus.reef(add toexportblock ~line 45; add fns nearrestart~line 364)
Interfaces:
-
Consumes:
run_incus(args: [string]): rg.Result[bool, string](existing). -
Produces:
fn stop(project: string, name: string): rg.Result[bool, string]fn start(project: string, name: string): rg.Result[bool, string]fn rename_container(project: string, old: string, new_name: string): rg.Result[bool, string]fn device_set(project: string, name: string, dev: string, keyvals: [string]): rg.Result[bool, string]
-
Step 1: Add the export declarations
In src/incus.reef, inside the export block (before end export, ~line 45), add:
fn stop(project: string, name: string): rg.Result[bool, string]
fn start(project: string, name: string): rg.Result[bool, string]
fn rename_container(project: string, old: string, new_name: string): rg.Result[bool, string]
fn device_set(project: string, name: string, dev: string, keyvals: [string]): rg.Result[bool, string]
- Step 2: Implement the wrappers
In src/incus.reef, add immediately after restart (~line 364):
fn stop(project: string, name: string): rg.Result[bool, string]
return run_incus(["stop", "--project", project, name])
end stop
fn start(project: string, name: string): rg.Result[bool, string]
return run_incus(["start", "--project", project, name])
end start
fn rename_container(project: string, old: string, new_name: string): rg.Result[bool, string]
return run_incus(["rename", "--project", project, old, new_name])
end rename_container
// incus config device set <container> <device> <key>=<val> ...
fn device_set(project: string, name: string, dev: string, keyvals: [string]): rg.Result[bool, string]
let kn: int = keyvals.length()
mut args: [string] = new [string](7 + kn)
args[0] = "config"
args[1] = "device"
args[2] = "set"
args[3] = "--project"
args[4] = project
args[5] = name
args[6] = dev
mut i: int = 0
while i < kn
args[7 + i] = keyvals[i]
i = i + 1
end while
return run_incus(args)
end device_set
- Step 3: Build to verify it compiles
Run: reefc build
Expected: Build complete: build/repoman (stdlib exhaustiveness warnings are pre-existing and fine).
- Step 4: Commit
hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
-m "incus: add stop/start/rename_container/device_set wrappers" \
src/incus.reef
Task 3: cmd_rename orchestration + dispatch/usage wiring
Files:
- Modify:
src/cli.reef(add toexportblock ~line 48; addcmd_renamebeforedispatch~line 1000; addrenamebranch indispatch~line 1049; add usage line inprint_usage~line 995)
Interfaces:
-
Consumes:
config.load_or_init,config.registry_path,config.rename_project,config.save,config.Registry,config.Project(existing + Task 1);incus.validate_name,incus.container_state,incus.stop,incus.start,incus.rename_container,incus.device_set(existing + Task 2);paths.expand_home,paths.join,paths.is_dir;iofile.rename;error.error_message;log.open_log,log.write;flag.*;console.*;str.replace. -
Produces:
fn cmd_rename(argv: [string]): intwired intodispatchon subcommand"rename". -
Step 1: Add the export declaration
In src/cli.reef, inside the export block, add after the cmd_remove line (~line 46):
fn cmd_rename(argv: [string]): int
- Step 2: Implement
cmd_rename
In src/cli.reef, add before fn dispatch (~line 1000):
// Build the Claude history slug directory for a repo path: the absolute
// container-internal cwd with '/' replaced by '-' (e.g. /home/u/repos/x ->
// -home-u-repos-x), under ~/.claude/projects/.
fn claude_slug_dir(home: string, repo_path: string): string
let slug: string = str.replace(repo_path, "/", "-")
return paths.join(home, ".claude/projects/" + slug)
end claude_slug_dir
fn cmd_rename(argv: [string]): int
let parser: flag.FlagParser = flag.flag_parser_from(argv)
flag.application(parser, "repoman rename")
flag.description(parser, "Rename a project across container, registry, and repo dir")
let _y = flag.bool_flag(parser, "yes", 'y', false, "skip confirmation prompt")
let _mh = flag.bool_flag(parser, "migrate-claude-history", '\0', false, "also move the Claude history slug dir")
if not flag.parse(parser)
console.printErr("repoman: error: " + flag.error(parser))
return 2
end if
let positionals: [string] = flag.positional_args(parser)
if positionals.length() != 2
console.printErr("repoman: error: 'rename' takes exactly two arguments: <old> <new>")
return 2
end if
let old: string = positionals[0]
let new_name: string = positionals[1]
let do_history: bool = flag.get_bool(parser, "migrate-claude-history")
if old == new_name
console.printErr("repoman: error: <old> and <new> are the same: " + old)
return 2
end if
if not incus.validate_name(new_name)
console.printErr("repoman: error: invalid new name: " + new_name)
console.printErr("hint: lowercase alphanumeric + hyphens, <=63 chars, no leading hyphen")
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")
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))
return 3
end if
let reg: config.Registry = rg.unwrap_ok(reg_r)
// Resolve the target project. Idempotent: accept either the old name
// (normal) or the new name (registry layer already renamed on a re-run).
let pn: int = reg.projects.length()
mut old_idx: int = -1
mut new_idx: int = -1
mut i: int = 0
while i < pn
if reg.projects[i].name == old
old_idx = i
end if
if reg.projects[i].name == new_name
new_idx = i
end if
i = i + 1
end while
if old_idx < 0 and new_idx < 0
console.printErr("repoman: error: no such project: " + old)
return 1
end if
if old_idx >= 0 and new_idx >= 0
console.printErr("repoman: error: both '" + old + "' and '" + new_name + "' exist in the registry; refusing to merge")
return 1
end if
mut base_idx: int = old_idx
if base_idx < 0
base_idx = new_idx
end if
let proj: config.Project = reg.projects[base_idx]
// The directory tier applies only when the repo dirname was coupled to the
// name (repo == old, or == new_name on a resumed run). A decoupled dirname
// is left alone.
let coupled: bool = proj.repo == old or proj.repo == new_name
let repos_root: string = paths.expand_home(reg.defaults.repos_root)
let old_dir: string = paths.join(repos_root, old)
let new_dir: string = paths.join(repos_root, new_name)
let inc_project: string = reg.defaults.incus_project
// Confirmation (case-aware).
let auto_confirmed: bool = flag.get_bool(parser, "yes")
if not auto_confirmed
println("repoman rename " + old + " " + new_name)
println(" will move:")
println(" - incus container " + old + " -> " + new_name)
println(" - registry entry " + old + " -> " + new_name)
if coupled
println(" - repo directory " + old_dir + " -> " + new_dir)
if do_history
println(" - claude history (slug for " + new_dir + ")")
end if
println(" your source is moved intact, never deleted.")
else
println(" repo directory unchanged (" + paths.join(repos_root, proj.repo) + ")")
println(" -- this project's dir name is decoupled from its container name.")
end if
let proceed: bool = console.confirm_default_no("continue?")
if not proceed
println("aborted")
return 4
end if
end if
let _ol: bool = log.open_log(reg.defaults.logdir, new_name, "rename")
// Inspect container run-state for both names.
mut state_old: string = "MISSING"
let so_r = incus.container_state(inc_project, old)
if rg.is_ok(so_r)
state_old = rg.unwrap_ok(so_r)
end if
mut state_new: string = "MISSING"
let sn_r = incus.container_state(inc_project, new_name)
if rg.is_ok(sn_r)
state_new = rg.unwrap_ok(sn_r)
end if
if state_old != "MISSING" and state_new != "MISSING"
log.write("repoman: error: containers '" + old + "' and '" + new_name + "' both exist; refusing to merge")
return 1
end if
let was_running: bool = state_old == "RUNNING" or state_new == "RUNNING"
// Step: stop the container (whichever name it currently has) if running.
if state_old == "RUNNING"
log.write("==> incus stop " + old)
let r = incus.stop(inc_project, old)
if rg.is_err(r)
log.write("repoman: error: " + rg.unwrap_err(r))
return 1
end if
elif state_new == "RUNNING"
log.write("==> incus stop " + new_name)
let r = incus.stop(inc_project, new_name)
if rg.is_err(r)
log.write("repoman: error: " + rg.unwrap_err(r))
return 1
end if
end if
// Step: host repo dir (coupled only).
if coupled
if paths.is_dir(old_dir) and not paths.is_dir(new_dir)
log.write("==> mv " + old_dir + " -> " + new_dir)
let mv_r = iofile.rename(old_dir, new_dir)
if rg.is_err(mv_r)
log.write("repoman: error: cannot move repo dir: " + error.error_message(rg.unwrap_err(mv_r)))
log.write("hint: move it manually then re-run 'repoman rename " + old + " " + new_name + "'")
return 1
end if
elif paths.is_dir(new_dir)
log.write("==> repo dir already at " + new_dir + " (skipping)")
else
log.write("repoman: error: repo directory not found: " + old_dir)
return 1
end if
end if
// Step: rename the container.
if state_old != "MISSING"
log.write("==> incus rename " + old + " -> " + new_name)
let rn_r = incus.rename_container(inc_project, old, new_name)
if rg.is_err(rn_r)
log.write("repoman: error: " + rg.unwrap_err(rn_r))
return 1
end if
elif state_new != "MISSING"
log.write("==> container already named " + new_name + " (skipping rename)")
else
log.write("==> no incus container for '" + old + "' (skipping rename)")
end if
// Step: repoint the repo device (coupled only), on the now-new container.
if coupled and (state_old != "MISSING" or state_new != "MISSING")
log.write("==> incus config device set " + new_name + " repo source/path -> " + new_dir)
let ds_r = incus.device_set(inc_project, new_name, "repo", ["source=" + new_dir, "path=" + new_dir])
if rg.is_err(ds_r)
log.write("repoman: error: " + rg.unwrap_err(ds_r))
return 1
end if
end if
// Step: registry (skip if already renamed on a resumed run).
if old_idx >= 0
let reg2_r = config.rename_project(reg, old, new_name)
if rg.is_err(reg2_r)
log.write("repoman: error: " + rg.unwrap_err(reg2_r))
return 1
end if
let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path)
if rg.is_err(saved)
log.write("repoman: error: " + rg.unwrap_err(saved))
return 1
end if
end if
// Step: Claude history slug (coupled + opt-in).
if coupled and do_history
let old_slug: string = claude_slug_dir(home, old_dir)
let new_slug: string = claude_slug_dir(home, new_dir)
if paths.is_dir(old_slug) and not paths.is_dir(new_slug)
log.write("==> mv claude history " + old_slug + " -> " + new_slug)
let hs_r = iofile.rename(old_slug, new_slug)
if rg.is_err(hs_r)
log.write("repoman: warning: could not move claude history: " + error.error_message(rg.unwrap_err(hs_r)))
end if
elif paths.is_dir(new_slug)
log.write("==> claude history already at new slug (skipping)")
else
log.write("==> no claude history dir for old name (skipping)")
end if
end if
// Step: restart if it was running before.
if was_running
log.write("==> incus start " + new_name)
let st_r = incus.start(inc_project, new_name)
if rg.is_err(st_r)
log.write("repoman: error: " + rg.unwrap_err(st_r))
return 1
end if
end if
log.write("==> renamed '" + old + "' -> '" + new_name + "'")
return 0
end cmd_rename
- Step 3: Wire into
dispatch
In src/cli.reef fn dispatch, add after the remove branch (~line 1040):
if sub == "rename"
return cmd_rename(rest)
end if
- Step 4: Add the usage line
In src/cli.reef proc print_usage, add after the remove block (~line 994, before the shell block):
console.printErr("")
console.printErr(" rename <old> <new> [--yes | -y] [--migrate-claude-history]")
console.printErr(" Rename a project: incus container, registry, and (coupled) repo dir. Idempotent.")
- Step 5: Build to verify it compiles
Run: reefc build
Expected: Build complete: build/repoman.
- Step 6: Verify help + arg validation (no incus needed)
Run: ./build/repoman --help 2>&1 | grep rename
Expected: the rename <old> <new> ... usage line prints.
Run: ./build/repoman rename onlyone 2>&1
Expected: repoman: error: 'rename' takes exactly two arguments: <old> <new> and exit 2.
Run: ./build/repoman rename a 'bad/name' 2>&1
Expected: repoman: error: invalid new name: bad/name and exit 1.
- Step 7: Full unit-test suite still green
Run: make test
Expected: exit 0; every suite reports Tests failed: 0; the claude-launcher shell test passes.
- Step 8: Manual integration recipe (documented; run against a scratch project)
On a host with incus, using a throwaway project:
./build/repoman new scratchone— creates container +~/repos/scratchone../build/repoman rename scratchone scratchtwo— confirm at the prompt.- Assert:
incus list --project repomanshowsscratchtwo, notscratchone. - Assert:
incus config device show scratchtwo --project repoman→repodevicesource/path=~/repos/scratchtwo. - Assert:
~/repos/scratchtwoexists,~/repos/scratchonegone. - Assert:
./build/repoman listshowsscratchtwo/scratchtwo. - Assert: container run-state matches its pre-rename state.
- Assert:
- Re-run
./build/repoman rename scratchone scratchtwo --yes— asserts idempotency: succeeds as a no-op (registry hasscratchtwo, dir already moved), no errors. - Clean up:
./build/repoman remove scratchtwo --yes.
- Step 9: Commit
hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
-m "cli: add 'repoman rename' subcommand" \
src/cli.reef
Task 4: Document the rename subcommand in README
Files:
- Modify:
README.md(subcommand list / usage section)
Interfaces:
-
Consumes: nothing (docs).
-
Produces: user-facing docs for
rename. -
Step 1: Add the rename row to README's subcommand table
README.md documents subcommands as a markdown table (## Subcommands, header | Subcommand | Description |, ~line 76). Insert a new row immediately after the remove row (line 85), matching the table format:
| `rename <old> <new> [--yes | -y] [--migrate-claude-history]` | Rename a project across its incus container, registry entry, and — when the repo dirname is coupled to the name — the host repo directory and `repo` bind device. Idempotent (safe to re-run after a partial rename). `--migrate-claude-history` also moves the repo's Claude history directory. A project whose `repo` dirname is deliberately decoupled from its name has only its identity renamed; the repo directory is left untouched. |
- Step 2: Commit
hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
-m "docs: document 'repoman rename' in README" \
README.md
Notes for the implementer
- No incus/filesystem mock layer exists in repoman; existing suites (
test_config_*,test_paths,test_hermes_*) are pure-logic. Task 1 is the only fully-automated test surface. Tasks 2–3 are build-verified plus the Task 3 Step 8 manual recipe — this matches the project's established test reality; do not invent a mock harness. - Version bump is out of scope for this plan. After merge, the release is cut separately via
./scripts/bump-version.sh --patch(repoman's lockstep bumper), taking 0.7.0 → 0.7.1 — a patch bump, not a minor. Not part of feature work. - If
iofile.renamereturnsEXDEV(cross-filesystem) on the host-dir move, that surfaces as theErrin Task 3 Step 2's dir block, which already tells the user to move the dir manually and re-run — idempotency finishes the rest.