|

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