cli: add 'repoman rename' subcommand
Author:
Chris Tusa <chris.tusa@leafscale.com>
Date:
Jul 15, 2026 14:34
Changeset:
1963ac59274eab713561ec89b5a0ca5e0af1b7d4
Branch:
default
Changed files:
Diff
diff -r 862f1e4340a1 -r 1963ac59274e src/cli.reef --- a/src/cli.reef Wed Jul 15 14:29:56 2026 -0500 +++ b/src/cli.reef Wed Jul 15 14:34:01 2026 -0500 @@ -44,6 +44,7 @@ fn cmd_list(argv: [string]): int fn cmd_status(argv: [string]): int fn cmd_remove(argv: [string]): int + fn cmd_rename(argv: [string]): int fn cmd_shell(argv: [string]): int fn dispatch(argv: [string]): int end export @@ -967,6 +968,243 @@ return "repoman 0.7.0" end version_string +// Build the Claude history slug directory for a repo path: the absolute +// container-internal cwd with '/' replaced by '-' (e.g. /home/u/repos/x -> +// -home-u-repos-x), under ~/.claude/projects/. +fn claude_slug_dir(home: string, repo_path: string): string + let slug: string = str.replace(repo_path, "/", "-") + return paths.join(home, ".claude/projects/" + slug) +end claude_slug_dir + +fn cmd_rename(argv: [string]): int + let parser: flag.FlagParser = flag.flag_parser_from(argv) + flag.application(parser, "repoman rename") + flag.description(parser, "Rename a project across container, registry, and repo dir") + let _y = flag.bool_flag(parser, "yes", 'y', false, "skip confirmation prompt") + let _mh = flag.bool_flag(parser, "migrate-claude-history", '\0', false, "also move the Claude history slug dir") + if not flag.parse(parser) + console.printErr("repoman: error: " + flag.error(parser)) + return 2 + end if + + let positionals: [string] = flag.positional_args(parser) + if positionals.length() != 2 + console.printErr("repoman: error: 'rename' takes exactly two arguments: <old> <new>") + return 2 + end if + let old: string = positionals[0] + let new_name: string = positionals[1] + let do_history: bool = flag.get_bool(parser, "migrate-claude-history") + + if old == new_name + console.printErr("repoman: error: <old> and <new> are the same: " + old) + return 2 + end if + if not incus.validate_name(new_name) + console.printErr("repoman: error: invalid new name: " + new_name) + console.printErr("hint: lowercase alphanumeric + hyphens, <=63 chars, no leading hyphen") + return 1 + end if + + let home: string = env.get_env_or("HOME", "") + if str.length(home) == 0 + console.printErr("repoman: error: HOME is not set") + return 3 + end if + + let cfg_path: string = config.registry_path(home) + let reg_r = config.load_or_init(home) + if rg.is_err(reg_r) + console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) + return 3 + end if + let reg: config.Registry = rg.unwrap_ok(reg_r) + + // Resolve the target project. Idempotent: accept either the old name + // (normal) or the new name (registry layer already renamed on a re-run). + let pn: int = reg.projects.length() + mut old_idx: int = -1 + mut new_idx: int = -1 + mut i: int = 0 + while i < pn + if reg.projects[i].name == old + old_idx = i + end if + if reg.projects[i].name == new_name + new_idx = i + end if + i = i + 1 + end while + if old_idx < 0 and new_idx < 0 + console.printErr("repoman: error: no such project: " + old) + return 1 + end if + if old_idx >= 0 and new_idx >= 0 + console.printErr("repoman: error: both '" + old + "' and '" + new_name + "' exist in the registry; refusing to merge") + return 1 + end if + mut base_idx: int = old_idx + if base_idx < 0 + base_idx = new_idx + end if + let proj: config.Project = reg.projects[base_idx] + + // The directory tier applies only when the repo dirname was coupled to the + // name (repo == old, or == new_name on a resumed run). A decoupled dirname + // is left alone. + let coupled: bool = proj.repo == old or proj.repo == new_name + let repos_root: string = paths.expand_home(reg.defaults.repos_root) + let old_dir: string = paths.join(repos_root, old) + let new_dir: string = paths.join(repos_root, new_name) + let inc_project: string = reg.defaults.incus_project + + // Confirmation (case-aware). + let auto_confirmed: bool = flag.get_bool(parser, "yes") + if not auto_confirmed + println("repoman rename " + old + " " + new_name) + println(" will move:") + println(" - incus container " + old + " -> " + new_name) + println(" - registry entry " + old + " -> " + new_name) + if coupled + println(" - repo directory " + old_dir + " -> " + new_dir) + if do_history + println(" - claude history (slug for " + new_dir + ")") + end if + println(" your source is moved intact, never deleted.") + else + println(" repo directory unchanged (" + paths.join(repos_root, proj.repo) + ")") + println(" -- this project's dir name is decoupled from its container name.") + end if + let proceed: bool = console.confirm_default_no("continue?") + if not proceed + println("aborted") + return 4 + end if + end if + + let _ol: bool = log.open_log(reg.defaults.logdir, new_name, "rename") + + // Inspect container run-state for both names. + mut state_old: string = "MISSING" + let so_r = incus.container_state(inc_project, old) + if rg.is_ok(so_r) + state_old = rg.unwrap_ok(so_r) + end if + mut state_new: string = "MISSING" + let sn_r = incus.container_state(inc_project, new_name) + if rg.is_ok(sn_r) + state_new = rg.unwrap_ok(sn_r) + end if + if state_old != "MISSING" and state_new != "MISSING" + log.write("repoman: error: containers '" + old + "' and '" + new_name + "' both exist; refusing to merge") + return 1 + end if + let was_running: bool = state_old == "RUNNING" or state_new == "RUNNING" + + // Step: stop the container (whichever name it currently has) if running. + if state_old == "RUNNING" + log.write("==> incus stop " + old) + let r = incus.stop(inc_project, old) + if rg.is_err(r) + log.write("repoman: error: " + rg.unwrap_err(r)) + return 1 + end if + elif state_new == "RUNNING" + log.write("==> incus stop " + new_name) + let r = incus.stop(inc_project, new_name) + if rg.is_err(r) + log.write("repoman: error: " + rg.unwrap_err(r)) + return 1 + end if + end if + + // Step: host repo dir (coupled only). + if coupled + if paths.is_dir(old_dir) and not paths.is_dir(new_dir) + log.write("==> mv " + old_dir + " -> " + new_dir) + let mv_r = iofile.rename(old_dir, new_dir) + if rg.is_err(mv_r) + log.write("repoman: error: cannot move repo dir: " + error.error_message(rg.unwrap_err(mv_r))) + log.write("hint: move it manually then re-run 'repoman rename " + old + " " + new_name + "'") + return 1 + end if + elif paths.is_dir(new_dir) + log.write("==> repo dir already at " + new_dir + " (skipping)") + else + log.write("repoman: error: repo directory not found: " + old_dir) + return 1 + end if + end if + + // Step: rename the container. + if state_old != "MISSING" + log.write("==> incus rename " + old + " -> " + new_name) + let rn_r = incus.rename_container(inc_project, old, new_name) + if rg.is_err(rn_r) + log.write("repoman: error: " + rg.unwrap_err(rn_r)) + return 1 + end if + elif state_new != "MISSING" + log.write("==> container already named " + new_name + " (skipping rename)") + else + log.write("==> no incus container for '" + old + "' (skipping rename)") + end if + + // Step: repoint the repo device (coupled only), on the now-new container. + if coupled and (state_old != "MISSING" or state_new != "MISSING") + log.write("==> incus config device set " + new_name + " repo source/path -> " + new_dir) + let ds_r = incus.device_set(inc_project, new_name, "repo", ["source=" + new_dir, "path=" + new_dir]) + if rg.is_err(ds_r) + log.write("repoman: error: " + rg.unwrap_err(ds_r)) + return 1 + end if + end if + + // Step: registry (skip if already renamed on a resumed run). + if old_idx >= 0 + let reg2_r = config.rename_project(reg, old, new_name) + if rg.is_err(reg2_r) + log.write("repoman: error: " + rg.unwrap_err(reg2_r)) + return 1 + end if + let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path) + if rg.is_err(saved) + log.write("repoman: error: " + rg.unwrap_err(saved)) + return 1 + end if + end if + + // Step: Claude history slug (coupled + opt-in). + if coupled and do_history + let old_slug: string = claude_slug_dir(home, old_dir) + let new_slug: string = claude_slug_dir(home, new_dir) + if paths.is_dir(old_slug) and not paths.is_dir(new_slug) + log.write("==> mv claude history " + old_slug + " -> " + new_slug) + let hs_r = iofile.rename(old_slug, new_slug) + if rg.is_err(hs_r) + log.write("repoman: warning: could not move claude history: " + error.error_message(rg.unwrap_err(hs_r))) + end if + elif paths.is_dir(new_slug) + log.write("==> claude history already at new slug (skipping)") + else + log.write("==> no claude history dir for old name (skipping)") + end if + end if + + // Step: restart if it was running before. + if was_running + log.write("==> incus start " + new_name) + let st_r = incus.start(inc_project, new_name) + if rg.is_err(st_r) + log.write("repoman: error: " + rg.unwrap_err(st_r)) + return 1 + end if + end if + + log.write("==> renamed '" + old + "' -> '" + new_name + "'") + return 0 +end cmd_rename + proc print_usage() console.printErr("Usage: repoman <subcommand> [args]") console.printErr("") @@ -992,6 +1230,9 @@ console.printErr(" remove <name> [--yes | -y] [--keep-incus]") console.printErr(" Delete the incus container and registry entry. Prompts for confirmation unless --yes.") console.printErr("") + console.printErr(" rename <old> <new> [--yes | -y] [--migrate-claude-history]") + console.printErr(" Rename a project: incus container, registry, and (coupled) repo dir. Idempotent.") + console.printErr("") console.printErr(" shell <name> [--cwd <path>]") console.printErr(" Open a bash login shell inside the project's container.") console.printErr("") @@ -1038,6 +1279,9 @@ if sub == "remove" return cmd_remove(rest) end if + if sub == "rename" + return cmd_rename(rest) + end if if sub == "setup" return cmd_setup(rest) end if