repoman rename — design
Date: 2026-07-15
Status: Approved (design), pending implementation
Author: Chris Tusa chris.tusa@leafscale.com
Goal
Add a repoman rename <old> <new> subcommand that renames a project as a single
operation across every layer that encodes its name: the incus container, the
repo bind-mount device, the host repo directory, the registry entry, and
(opt-in) the Claude conversation-history slug. The command is idempotent and
resumable so it also repairs a project whose layers have drifted out of sync.
Context / motivation
This feature comes directly from a real incident. A project's codename brainbox
was renamed to the product name driftohm. The user renamed the host repo
directory (~/repos/brainbox → ~/repos/driftohm) and the Claude history slug
themselves, but the incus container and the repoman registry were left on the old
name. The result:
repoman shell brainboxstill dropped into the repo — but only because the
container was running and its bind mount was pinned to the (renamed) inode. A
restart would have broken the mount entirely, since the devicesourcestill
pointed at the now-missing~/repos/brainbox.- Claude history appeared "missing": in-container Claude runs with cwd
/home/ctusa/repos/brainbox(therepodevicepath), so it looked for the
slug-home-ctusa-repos-brainbox, which had been renamed to
-home-ctusa-repos-driftohm. Nothing was lost — just unreachable from the old
cwd.
The manual fix (stop → incus rename → repoint device → edit registry → start)
is exactly what this command should automate.
How names couple today
- Registry (
~/.config/repoman/repoman.toml): each[[project]]has a
name(project + container identity) and arepo(host dirname under
repos_root).cmd_newdefaultsrepo = namebut--repocan decouple them. - incus container: named
name, in projectreg.defaults.incus_project
(repoman). repodisk device: created bycmd_newas device index 0, named"repo",
withsource = path = <repos_root>/<repo>(host path and container-internal
path are identical). Additional override mounts aremount-1,mount-2, …
and are unrelated to the rename.- Claude history: keyed by the in-container cwd, which is the
repodevice
path=<repos_root>/<repo>. Slug = that path with/→-
(e.g.-home-ctusa-repos-driftohm). History lives on the host under
~/.claude/projects/<slug>/(bind-mounted into the container via the
claude-statedevice).
CLI surface
repoman rename <old> <new> [--yes] [--migrate-claude-history]
- Exactly two positional arguments: current name, new name.
--yes— skip the confirmation prompt (mirrorscmd_remove).--migrate-claude-history— also move
~/.claude/projects/-…-<old>→-…-<new>. Opt-in, because it reaches into
Claude's internal on-disk layout, which repoman does not otherwise depend on.- Registered in
dispatchand the help text.
What moves — the name vs repo rule
repoman's model permits repo != name. Rename therefore splits into two tiers:
- Identity layer — always moved: registry
name, incus container name. - Directory layer — moved only when
proj.repo == <old>(the coupled common
case, including the incident): registryrepofield, host dir
<repos_root>/<old> → <repos_root>/<new>, therepodevicesource+path,
and (with--migrate-claude-history) the Claude slug.
When proj.repo != <old> the user deliberately decoupled the dirname, so rename
touches only the identity layer and leaves the repo dir, device, and history
alone. This is inferred from the data — no extra flag. In that case the device
path/source and the Claude slug are derived from repo (unchanged), so
history stays valid automatically.
Decoupling is load-bearing, not a wart. It is the only way to give a nested
or awkwardly-named repo path a valid, short container identity — a container
cannot be named e.g. leafscale-infrastructure/portal.leafscale.com (slashes,
length, charset all fail incus.validate_name), so the portal project maps
that nested path to the short name portal. Enforcing repo == name was
considered and rejected: it would break this active pattern. The predictability
concern it raises is addressed by the case-aware confirmation prompt below, which
makes the two-tier behavior self-explaining at the moment it matters.
Confirmation prompt (case-aware)
The prompt states exactly which layers move and, in the decoupled case, why the
repo dir is left alone and names it explicitly:
# coupled (proj.repo == <old>):
repoman rename driftohm mist
will move:
- incus container driftohm → mist
- registry entry driftohm → mist
- repo directory ~/repos/driftohm → ~/repos/mist
your source is moved intact, never deleted.
continue? [y/N]
# decoupled (proj.repo != <old>):
repoman rename portal gateway
will move:
- incus container portal → gateway
- registry entry portal → gateway
repo directory unchanged (~/repos/leafscale-infrastructure/portal.leafscale.com)
— this project's dir name is decoupled from its container name.
continue? [y/N]
With --migrate-claude-history a - claude history <old-slug> → <new-slug>
line is added in the coupled case (and omitted, with the dir, in the decoupled
case).
Algorithm (idempotent, resumable)
Inspect every layer first, then change only what is still on the old name. The
order is chosen so a mid-sequence failure leaves a re-runnable state, and so the
container is stopped before any operation that requires it.
- Resolve state of each layer:
- registry: is there an entry for
<old>? for<new>? (and the matched
project'srepovalue) - container: does
<old>exist? does<new>exist? current run-state?
(Acontainer_stateprobe failure — incus unreachable — aborts here
withErrbefore any mutation; only a confirmed-empty"MISSING"is
treated as genuine absence.) - host dir: does
<repos_root>/<old>exist? does<repos_root>/<new>exist? - (if flag) does the
<old>slug dir exist? the<new>slug dir?
- registry: is there an entry for
- Guard rails (refuse rather than guess):
- Nothing on either name anywhere →
Err "no such project: <old>". <new>failsincus.validate_name→Err.<old>and<new>both exist as distinct live things (e.g. two
different containers, or two registry entries) →Err— rename must not
merge or clobber. (The benign "already partly renamed" states — same
project, some layers ahead — are allowed and resumed.)
- Nothing on either name anywhere →
- Confirm unless
--yes: print the case-aware plan (see Confirmation
prompt above) — the exact layers that will move, old → new, and in the
decoupled case the named repo dir that will be left untouched and why. Use
console.confirm_default_no. - Open the per-invocation log (after confirmation), verb
"rename". - Stop the container before renaming it — only the old-named container,
and only when it exists and is running (incus renamerequires it stopped).
The new-named container is never stopped: if it is already running, a prior
run completed fully (start is the last step), so a no-op re-run does not bounce
a healthy container; a container needing device repair was left stopped by the
failed run. - Host dir (directory tier only): if
<repos_root>/<old>exists and
<repos_root>/<new>does not → rename it. If<new>already exists and
<old>is gone → skip (already done). If both exist →Err(never
overwrite). If neither exists →Err "repo directory not found"(the
source is gone; do not repoint the device at a missing path). - Container: if still named
<old>→incus rename <old> <new>. If already
<new>→ skip. If neither container exists (e.g. deleted out of band) →
skip with a logged note and continue; rename still repairs the registry. - Repo device (directory tier only):
incus config device settherepo
devicesourceandpathto<repos_root>/<new>, targeting the container by
its current name (already<new>after step 7). Idempotent — safe even if
already correct. - Registry:
config.rename_project(reg, old, new)then atomicconfig.save.
If the registry already holds<new>and not<old>→ skip. - Claude slug (only with
--migrate-claude-history, directory tier): same
exists/skip rules as the host dir, but best-effort — a failed move or a
both-exist collision is a warning, not a hard error (history migration
is opt-in and never blocks the rename). - Ensure the container ends running: re-read its current state and
incus startit if it exists and is not already running. (This supersedes
the original "restart iff it was running before" — a deterministic
end-running state is resume-safe: an interrupted-then-resumed run always
brings the container back up, and repoman containers are meant to be up.)
Failure handling
- Every step logs its action (
==> …) to the per-invocation log, mirroring
cmd_new/cmd_remove. - On failure mid-sequence, report which layers completed and which remain, and
instruct the user to re-run the identical command — idempotency resumes it. - Never overwrite an existing target directory: step 6 (host dir) hard-errors on
a both-exist collision; step 10 (opt-in Claude slug) warns and leaves both in
place rather than blocking. - The most fragile external step is
incus rename(requires the container
stopped); step 5 guarantees that precondition.
New / changed code
src/config.reef
fn rename_project(reg: Registry, old: string, new: string): rg.Result[Registry, string]
— mirrorsupdate_last_sync: locate the project byold; error if not found;
error if a different entry already usesnew; produce a copiedProjectwith
name = newandrepo = newiff the oldrepo == old(elserepo
unchanged); rebuild viawith_projects.
src/incus.reef
Four thin run_incus wrappers (today only restart / delete_container /
device_add_disk[_opts] exist):
fn stop(project, name): rg.Result[bool, string]→["stop", "--project", P, name]fn start(project, name): rg.Result[bool, string]→["start", "--project", P, name]fn rename_container(project, old, new): rg.Result[bool, string]
→["rename", "--project", P, old, new]fn device_set(project, name, dev: string, keyvals: [string]): rg.Result[bool, string]
→["config", "device", "set", "--project", P, name, dev, keyvals…]
Run-state is read via the existing container_state / container_exists
helpers rather than a new probe.
src/cli.reef
fn cmd_rename(argv: [string]): intfollowing thecmd_removeskeleton
(load registry → find<old>→ guard rails → confirm → log → per-layer
mutate → save), implementing the algorithm above.- Wire
renameintodispatchand the help/usage text.
Host filesystem moves
- Host dir and slug renames use
iofile.rename(a same-filesystemrename(2);
both paths live under$HOME). Consistent with the codebase's existing
iofile.renameuse inconfig.save. No shelling out tomv.
Testing
Unit — tests/test_config_rename.reef (mirrors test_config_mutate.reef)
- Happy path,
name == repo: bothnameandrepobecomenew. name != repo: onlynamechanges;repountouched.- Collision:
newalready used by another project →Err. - Not found:
oldabsent →Err.
Integration (manual — documented recipe)
repoman has no incus/filesystem mock layer (existing suites are pure-logic:
config, paths, hermes-classify), so the incus + dir + slug steps are validated by
a documented manual recipe:
repoman new tmpname(or reuse a scratch project), note its container + dir.repoman rename tmpname tmpname2→ assert container renamed,repodevice
source/pathrepointed,~/repos/tmpname2present, registry updated,
container back to its prior run-state.- Re-run
repoman rename tmpname tmpname2→ asserts idempotency (no-op success,
no errors). - With
--migrate-claude-history, assert the slug dir moved.
Out of scope (YAGNI)
- No renaming of incus projects (
reg.defaults.incus_project) — only the
per-project container/identity. - No renaming of override mounts (
mount-1, …) or arbitrary devices — only the
repodevice. - No cross-filesystem host moves —
rename(2)within$HOMEis assumed; a
cross-deviceEXDEVsurfaces as anErrtelling the user to move the dir
manually, then re-run (idempotency handles the rest). - No history-slug migration by default — it is opt-in behind
--migrate-claude-history.