|

docs: spec for repoman rename subcommand

Author: Chris Tusa <chris.tusa@leafscale.com>
Date: Jul 15, 2026 13:15
Changeset: 34780448f6f98ca6e274cf8a4a07cc704ed65bd5
Branch: default

Diff

diff -r 722dce3622b2 -r 34780448f6f9 docs/superpowers/specs/2026-07-15-repoman-rename-design.md
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/superpowers/specs/2026-07-15-repoman-rename-design.md	Wed Jul 15 13:15:35 2026 -0500
@@ -0,0 +1,198 @@
+# 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.
+
+## 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?
+   - 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_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.)
+3. **Confirm** unless `--yes`: print the concrete plan (which layers will move,
+   old → new), and state plainly that the source repository contents are moved
+   intact, never deleted. Use `console.confirm_default_no`.
+4. Open the per-invocation log (after confirmation), verb `"rename"`.
+5. **Stop** the container if running; remember the prior run-state.
+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/no-clobber rules as the host dir.
+11. **Start** the container iff it was running at step 5.
+
+### 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 (steps 6 and 10 hard-error on a
+  both-exist collision).
+- 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`.