docs: spec for repoman rename subcommand
Author:
Chris Tusa <chris.tusa@leafscale.com>
Date:
Jul 15, 2026 13:15
Changeset:
34780448f6f98ca6e274cf8a4a07cc704ed65bd5
Branch:
default
Changed files:
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`.