|
root / docs / superpowers / specs / 2026-07-15-repoman-rename-design.md
2026-07-15-repoman-rename-design.md markdown 253 lines 12.7 KB

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 brainbox still 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 device source still
    pointed at the now-missing ~/repos/brainbox.
  • Claude history appeared "missing": in-container Claude runs with cwd
    /home/ctusa/repos/brainbox (the repo device path), 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 a repo (host dirname under
    repos_root). cmd_new defaults repo = name but --repo can decouple them.
  • incus container: named name, in project reg.defaults.incus_project
    (repoman).
  • repo disk device: created by cmd_new as device index 0, named "repo",
    with source = path = <repos_root>/<repo> (host path and container-internal
    path are identical). Additional override mounts are mount-1, mount-2, …
    and are unrelated to the rename.
  • Claude history: keyed by the in-container cwd, which is the repo device
    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-state device).

CLI surface

repoman rename <old> <new> [--yes] [--migrate-claude-history]
  • Exactly two positional arguments: current name, new name.
  • --yes — skip the confirmation prompt (mirrors cmd_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 dispatch and 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): registry repo field, host dir
    <repos_root>/<old> → <repos_root>/<new>, the repo device source+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.

  1. Resolve state of each layer:
    • registry: is there an entry for <old>? for <new>? (and the matched
      project's repo value)
    • container: does <old> exist? does <new> exist? current run-state?
      (A container_state probe failure — incus unreachable — aborts here
      with Err before 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?
  2. Guard rails (refuse rather than guess):
    • Nothing on either name anywhere → Err "no such project: <old>".
    • <new> fails incus.validate_nameErr.
    • <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.)
  3. 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.
  4. Open the per-invocation log (after confirmation), verb "rename".
  5. Stop the container before renaming it — only the old-named container,
    and only when it exists and is running (incus rename requires 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.
  6. 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).
  7. 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.
  8. Repo device (directory tier only): incus config device set the repo
    device source and path to <repos_root>/<new>, targeting the container by
    its current name (already <new> after step 7). Idempotent — safe even if
    already correct.
  9. Registry: config.rename_project(reg, old, new) then atomic config.save.
    If the registry already holds <new> and not <old> → skip.
  10. 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).
  11. Ensure the container ends running: re-read its current state and
    incus start it 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]
    — mirrors update_last_sync: locate the project by old; error if not found;
    error if a different entry already uses new; produce a copied Project with
    name = new and repo = new iff the old repo == old (else repo
    unchanged); rebuild via with_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]): int following the cmd_remove skeleton
    (load registry → find <old> → guard rails → confirm → log → per-layer
    mutate → save), implementing the algorithm above.
  • Wire rename into dispatch and the help/usage text.

Host filesystem moves

  • Host dir and slug renames use iofile.rename (a same-filesystem rename(2);
    both paths live under $HOME). Consistent with the codebase's existing
    iofile.rename use in config.save. No shelling out to mv.

Testing

Unit — tests/test_config_rename.reef (mirrors test_config_mutate.reef)

  • Happy path, name == repo: both name and repo become new.
  • name != repo: only name changes; repo untouched.
  • Collision: new already used by another project → Err.
  • Not found: old absent → 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:

  1. repoman new tmpname (or reuse a scratch project), note its container + dir.
  2. repoman rename tmpname tmpname2 → assert container renamed, repo device
    source/path repointed, ~/repos/tmpname2 present, registry updated,
    container back to its prior run-state.
  3. Re-run repoman rename tmpname tmpname2 → asserts idempotency (no-op success,
    no errors).
  4. 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
    repo device.
  • No cross-filesystem host moves — rename(2) within $HOME is assumed; a
    cross-device EXDEV surfaces as an Err telling 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.