Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 206 additions & 1 deletion claudearium.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ param(
[string]$Branch,
[string]$BaseBranch,
[string]$HostCheckout,
[string]$To,
[switch]$DiscardDirty,
[string]$HostPath,
[string]$Guest,
[string]$Mode,
Expand Down Expand Up @@ -1582,6 +1584,208 @@ function Invoke-ProjectRemove {
Write-Host "Project '$name' removed." -ForegroundColor Green
}

function Invoke-ProjectMove {
# Migrate a project entry between distroProject and hostProject in place.
# This is lossy: every session of the project is torn down (worktrees on
# one side don't translate to the other — different filesystems, path
# conventions, toolchain), so uncommitted work is the user's problem.
# The profile entry survives; only the fields that mean different things
# for distro vs host get rewritten (remote <-> hostCheckout / hostShadows;
# `type` toggles). `tabColor`, `defaultBranch`, `enabled`, `hostMounts`,
# `claudeSettings`, `claudeFile` are preserved verbatim.
if (-not $Arg) { throw "project move requires a project name." }
if (-not $To) { throw "project move requires -To <host|distro>." }
$toType = $To.ToLowerInvariant()
if ($toType -notin @('host','distro')) {
throw "project move -To must be 'host' or 'distro' (got '$To')."
}

$distro = Resolve-DistroForOps
$name = $Arg
$profilePathLocal = Resolve-ProfilePath

$spec = Read-ProfileIfPresent
if (-not $spec -or -not $spec.ContainsKey('projects') -or -not $spec.projects) {
throw "No projects in the profile to move."
}
$entry = @(@($spec.projects) | Where-Object { [string]$_.name -eq $name })[0]
if (-not $entry) { throw "Project '$name' not found in profile." }

$fromType = Get-ProjectType -ProjectSpec $entry
if ($fromType -eq $toType) {
throw "Project '$name' is already type '$fromType'; nothing to move."
}

# Validate destination args + derive smart defaults from the existing
# entry where possible.
$targetHostCheckout = $null
$targetRemote = $null
if ($toType -eq 'host') {
if (-not $HostCheckout) {
throw "Moving '$name' to a hostProject requires -HostCheckout (the Windows path of the user's main checkout)."
}
if (-not (Test-HostCheckout -HostCheckout $HostCheckout)) {
throw "HostCheckout '$HostCheckout' does not exist or is not a git working tree (no .git)."
}
$targetHostCheckout = $HostCheckout
}
else {
# host -> distro. Derive the remote from the existing hostCheckout if
# the user didn't pass -Remote explicitly.
$targetRemote = $Remote
if (-not $targetRemote) {
$hc = [string]$entry.hostCheckout
$auto = if ($hc) { Resolve-SmartRemote -HostCheckout $hc } else { $null }
if ($auto) {
Write-Host " smart-detected remote from hostCheckout: $auto" -ForegroundColor DarkGray
$targetRemote = $auto
}
}
if (-not $targetRemote) {
throw "Moving '$name' to a distroProject requires -Remote (couldn't smart-detect 'origin' from the hostCheckout)."
}
}

# Dirty-session check. The same teardown a real `project remove` would
# do is about to happen, so warn loudly if any session has uncommitted
# work. -DiscardDirty (or -Force) is the explicit opt-in to lose it.
$dirtySessions = @()
if (Test-State -DistroName $distro) {
$state = Read-State -DistroName $distro
foreach ($s in (Get-Sessions -State $state -Project $name)) {
$sessionType = Get-SessionType -Session $s
$dirty = 0
if ($sessionType -eq 'host') {
# Host worktrees aren't reachable through Get-SessionDirtyFileCount
# (which looks under /home/claude/projects); the path on the
# Windows side is hostWorktreePath. Quick git porcelain via host.
$hostWt = if ($s.ContainsKey('hostWorktreePath')) { [string]$s.hostWorktreePath } else { $null }
if ($hostWt -and (Test-Path -LiteralPath $hostWt)) {
try {
$out = & git -C $hostWt status --porcelain 2>$null
if ($LASTEXITCODE -eq 0) { $dirty = @($out).Count }
} catch {}
}
}
else {
$dirty = Get-SessionDirtyFileCount -DistroName $distro -Project $name -Name ([string]$s.name)
}
if ($dirty -gt 0) {
$dirtySessions += [PSCustomObject]@{ Name = [string]$s.name; Dirty = $dirty }
}
}
}
if ($dirtySessions.Count -gt 0 -and -not $DiscardDirty -and -not $Force) {
Write-Host " Sessions with uncommitted work:" -ForegroundColor Yellow
foreach ($d in $dirtySessions) {
Write-Host (" {0,-22} {1} file(s)" -f $d.Name, $d.Dirty) -ForegroundColor Yellow
}
throw "Refusing to move '$name' — commit/stash the above sessions first, or pass -DiscardDirty to lose the work."
}

# Preview.
$sessionCount = if (Test-State -DistroName $distro) {
@(Get-Sessions -State (Read-State -DistroName $distro) -Project $name).Count
} else { 0 }
Write-Host ''
Write-Host "Move '$name': $fromType -> $toType" -ForegroundColor Cyan
Write-Host " Sessions to remove: $sessionCount"
if ($fromType -eq 'distro') {
Write-Host " Old infrastructure: /home/claude/mirrors/$name.git (bare mirror)"
Write-Host " New infrastructure: /home/claude/host-projects/$name/ (per-project bin dir)"
Write-Host " Host checkout: $targetHostCheckout"
if ($entry.ContainsKey('hostTools') -and $entry.hostTools -and @($entry.hostTools).Count -gt 0) {
Write-Host " Will drop hostTools[] from the entry (not allowed for hostProjects; use hostShadows instead)." -ForegroundColor Yellow
}
}
else {
Write-Host " Old infrastructure: /home/claude/host-projects/$name/ (per-project bin dir + host worktrees)"
Write-Host " New infrastructure: /home/claude/mirrors/$name.git (bare mirror)"
Write-Host " Remote: $targetRemote"
}
Write-Host " Profile snapshot: <profile>.bak-<timestamp> next to claudearium.profile.json"

if (-not $Force) {
$ok = Read-YesNo -Prompt "Apply this move?" -Default $false -NonInteractive:$NonInteractive
if (-not $ok) { Write-Host 'Aborted.' -ForegroundColor Yellow; return }
}

# Profile snapshot for hand-recovery. Millisecond precision so back-to-back
# moves (rapid retries, scripted runs) don't silently clobber an earlier
# snapshot — Copy-Item overwrites without error.
$stamp = (Get-Date).ToString('yyyyMMdd-HHmmss-fff')
$backupPath = "$profilePathLocal.bak-$stamp"
Copy-Item -LiteralPath $profilePathLocal -Destination $backupPath -ErrorAction Stop
Write-Host " Snapshot saved: $(Split-Path -Leaf $backupPath)" -ForegroundColor DarkGray

# ---- Teardown ----
if ($fromType -eq 'host') {
if (Test-State -DistroName $distro) {
$state = Read-State -DistroName $distro
$sessionNames = @()
foreach ($s in (Get-Sessions -State $state -Project $name)) {
if ($s -is [hashtable] -and $s.ContainsKey('name')) { $sessionNames += [string]$s.name }
}
foreach ($sname in $sessionNames) {
try {
Remove-HostSession -State $state -ProjectSpec $entry -Name $sname -Force
} catch {
Write-Host " warn: could not remove session '$sname': $_" -ForegroundColor Yellow
}
}
Remove-SessionsForProject -State $state -Project $name
Write-State -DistroName $distro -State $state
if (Test-DistroExists -Name $distro) {
Invoke-MergedMountsApply -DistroName $distro
Remove-HostShadowsForProject -DistroName $distro -ProjectName $name
}
}
}
else {
if ((Test-DistroExists -Name $distro) -and (Test-ProjectMirrorExists -DistroName $distro -ProjectName $name)) {
Write-Host " Removing bare mirror /home/claude/mirrors/$name.git ..."
Remove-ProjectMirror -DistroName $distro -ProjectName $name
}
if (Test-State -DistroName $distro) {
$state = Read-State -DistroName $distro
Remove-SessionsForProject -State $state -Project $name
Write-State -DistroName $distro -State $state
}
}

# ---- Profile mutation ----
if ($toType -eq 'host') {
# `(if ...)` as an inline argument isn't valid PowerShell — the parser
# treats `if` as a command name in that position. Bind to a variable
# first.
$shadowOverride = $null
if ($Script:RootBoundParams.ContainsKey('HostShadows')) { $shadowOverride = $HostShadows }
Move-ProjectInProfile -ProfilePath $profilePathLocal -Name $name -ToType 'host' `
-HostCheckout $targetHostCheckout -HostShadows $shadowOverride
}
else {
Move-ProjectInProfile -ProfilePath $profilePathLocal -Name $name -ToType 'distro' -Remote $targetRemote
}
Write-Host " Profile entry rewritten." -ForegroundColor Green

# ---- Re-provision new side ----
if (-not (Test-DistroExists -Name $distro)) {
Write-Host " Distro '$distro' doesn't exist yet — new infrastructure will be created on next setup/reconcile." -ForegroundColor Yellow
return
}
$freshSpec = Read-ProfileIfPresent
$freshEntry = @(@($freshSpec.projects) | Where-Object { [string]$_.name -eq $name })[0]
if ($toType -eq 'host') {
Write-Host " Resolving host shadows + deploying bin dir ..."
Invoke-HostProjectApply -DistroName $distro -ProjectSpec $freshEntry
}
else {
Write-Host " Cloning $targetRemote -> /home/claude/mirrors/$name.git ..."
New-ProjectMirror -DistroName $distro -ProjectName $name -Remote $targetRemote
}
Write-Host "Project '$name' moved ($fromType -> $toType). Run 'session new' to create sessions on the new side." -ForegroundColor Green
}

function Invoke-ProjectDashboard {
$distro = Resolve-DistroForOps
Clear-Host
Expand Down Expand Up @@ -1652,10 +1856,11 @@ function Invoke-Project {
'add' { Invoke-ProjectAdd }
'list' { Invoke-ProjectList }
'remove' { Invoke-ProjectRemove }
'move' { Invoke-ProjectMove }
'show' { Invoke-ProjectShow }
default {
Write-Host "Unknown project subverb: $SubVerb" -ForegroundColor Red
Write-Host "Subverbs: add | list | remove | show (or bare 'project' for the dashboard)"
Write-Host "Subverbs: add | list | remove | move | show (or bare 'project' for the dashboard)"
exit 64
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ exit 0 # avoid $LASTEXITCODE leak from internal `command -v` probes
|---|---|
| Lifecycle | `setup`, `status`, `nuke`, `update {check\|apply\|status}`, `diagnostics` |
| Declarative | `reconcile`, `profile {validate\|export\|edit\|show}` |
| Repo work | `project {add\|list\|remove\|show}` (+ bare dashboard), `session {new\|list\|remove}` (+ bare dashboard) |
| Repo work | `project {add\|list\|remove\|move\|show}` (+ bare dashboard), `session {new\|list\|remove}` (+ bare dashboard) |
| Distro plumbing | `mount {add\|list\|remove\|sync}` (+ bare dashboard) |
| Toolchain | `tools {list\|install\|enable\|disable\|sync\|attach}` (+ bare dashboard) |
| Identity | `login {claude\|gh\|glab\|acli-jira\|acli-confluence}` (+ bare menu) |
Expand Down
25 changes: 25 additions & 0 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,31 @@ Cleanup:
# Per-project bin dir is gone. C:\GitHub\Claudearium itself is untouched.
```

### Convert a project between distro-resident and host-resident

When a repo's needs change — say, you originally cloned it as a `distroProject`
but it turns out to need host PowerShell for the test suite — flip the type
without re-typing the configuration:

```powershell
# distroProject -> hostProject: provide the Windows checkout the worktrees will sit beside.
.\claudearium.ps1 project move acme -To host -HostCheckout C:\src\acme

# hostProject -> distroProject: -Remote is auto-detected from the existing
# hostCheckout's `origin` URL, but you can override it.
.\claudearium.ps1 project move acme -To distro
.\claudearium.ps1 project move acme -To distro -Remote git@gitlab.example.com:acme/acme.git
```

Move tears down every session of the project (worktrees on one side don't
translate to the other — different filesystems, different paths, different
toolchain), so commit / stash anything you care about first or pass
`-DiscardDirty`. The profile entry survives the swap: `tabColor`,
`defaultBranch`, `enabled`, `hostMounts`, `claudeSettings`, and `claudeFile`
all carry over. A timestamped `claudearium.profile.json.bak-<stamp>` is
written next to the live profile before the mutation, so a hand-rollback is
always available.

### Temporarily disable a project without losing its config

When a project is dormant — say, a release is shipped and the worktrees + bare
Expand Down
44 changes: 44 additions & 0 deletions docs/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,47 @@ clean recreate, not a re-attach.
sees the per-project remove line in the rendered preview and has to
confirm (or pass `-Force` on a scripted reconcile run). Same gate as
deleting the entry outright.

## 24. `project move`: lossy by design

**Decision:** the `project move` verb migrates a project between
`distroProject` and `hostProject` in place. The profile entry is
rewritten — `type` toggles, `remote` ⇄ `hostCheckout` / `hostShadows`,
type-forbidden fields are dropped — but `tabColor`, `defaultBranch`,
`enabled`, `hostMounts`, `claudeSettings`, and `claudeFile` carry over.
Sessions of the project are torn down (worktrees, fstab entries, state
records) and the materialized side (bare mirror or per-project bin dir)
is recreated for the new type. The verb refuses if any session has
uncommitted work, unless `-DiscardDirty` (or `-Force`) is set.

**Why not preserve sessions across the move:** a distroProject session's
worktree lives at `/home/claude/projects/<p>/sessions/<s>` inside the
distro; its hostProject equivalent lives at `<hostCheckout>-sessions\<s>`
on the Windows filesystem. There is no useful way to translate one to
the other — different filesystem semantics, different path syntaxes,
different toolchain assumptions (a distro session expects `git` from
`/usr/bin`; a host session can expect host PowerShell on PATH). Trying
to keep sessions alive across the boundary would mean re-cloning each
one with a fresh `git worktree add` on the destination, which is exactly
what `session new` already does — but with more failure surface around
detached HEADs, branch-already-checked-out collisions, and dirty-tracking
state. Better to tear down cleanly and have the user run `session new`
per branch on the new side.

**Profile snapshot before mutation:** the verb copies
`claudearium.profile.json` to `claudearium.profile.json.bak-<stamp>`
before it touches anything. Move is multi-step (teardown → mutation →
re-provision) and any step can fail (`git clone --mirror` of a transient
URL, network glitch, file permissions); a backup means hand-recovery is
"copy the .bak file back over the live one" rather than "reconstruct the
old entry from memory."

**Smart-detect of `-Remote` on host → distro:** when the user runs
`project move acme -To distro` without `-Remote`, the verb reads the
existing `hostCheckout`'s `origin` URL via `Resolve-SmartRemote` (same
helper `project add -HostCheckout` already uses). If `origin` isn't set,
the verb errors out and asks for an explicit `-Remote` rather than
silently producing a remote-less distroProject (which would fail the
schema). distro → host has no symmetric inference — the user must pass
`-HostCheckout` because we can't synthesize a Windows checkout that
didn't exist before.
7 changes: 7 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ Smart defaults pull from `-HostCheckout`'s `origin` URL (or the current working

**`project remove <name>`** — deletes the bare mirror (distroProject) or the per-project bin dir (hostProject), every session of the project, and the profile entry. For hostProjects, the `hostCheckout` itself is **never** deleted. Asks for confirmation unless `-Force`.

**`project move <name> -To <host|distro> [-HostCheckout <path>] [-Remote <url>] [-DiscardDirty] [-Force]`** — convert an existing project to the other type in place. Tears down the materialized side (bare mirror for distroProject, per-project bin dir for hostProject) plus every session, mutates the profile entry to the new type (preserving `tabColor`, `defaultBranch`, `enabled`, `hostMounts`, `claudeSettings`, etc.), then re-provisions the new side. Required args by direction:

- `-To host` requires `-HostCheckout` (Windows path of your main checkout). `hostShadows` defaults to `pwsh,git` and can be overridden with `-HostShadows`.
- `-To distro` requires `-Remote`, but will auto-detect the existing `hostCheckout`'s `origin` URL when omitted.

Refuses if any session has uncommitted work, unless `-DiscardDirty` (or `-Force`) is set. A timestamped profile snapshot (`claudearium.profile.json.bak-<stamp>`) is written next to the profile before any mutation, for hand recovery.

**Disabling a project without removing it.** Edit the profile entry to add `"enabled": false`, or use the dashboard's `t <n>` toggle. The next `reconcile` tears down the materialized infrastructure (mirror or per-project bin dir + every session of the project), but leaves the profile entry alone so the `tabColor`, `defaultBranch`, `hostShadows`, etc. survive. Flip back to `"enabled": true` (or remove the field) and re-reconcile to recreate everything. Disable is destructive for sessions exactly like a full remove — uncommitted work in worktrees is lost.

## `session <subverb?>`
Expand Down
Loading
Loading