# 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_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 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`.
