diff --git a/claudearium.ps1 b/claudearium.ps1 index 663a2c0..747b58d 100644 --- a/claudearium.ps1 +++ b/claudearium.ps1 @@ -22,6 +22,8 @@ param( [string]$Branch, [string]$BaseBranch, [string]$HostCheckout, + [string]$To, + [switch]$DiscardDirty, [string]$HostPath, [string]$Guest, [string]$Mode, @@ -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 ." } + $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: .bak- 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 @@ -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 } } diff --git a/docs/architecture.md b/docs/architecture.md index 2f00f71..6603499 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) | diff --git a/docs/cookbook.md b/docs/cookbook.md index 115d69c..737c100 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -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-` 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 diff --git a/docs/design-decisions.md b/docs/design-decisions.md index 3bd94f7..7ec00fb 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -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/

/sessions/` inside the +distro; its hostProject equivalent lives at `-sessions\` +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-` +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. diff --git a/docs/usage.md b/docs/usage.md index dbc1579..256ca82 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -85,6 +85,13 @@ Smart defaults pull from `-HostCheckout`'s `origin` URL (or the current working **`project remove `** — 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 -To [-HostCheckout ] [-Remote ] [-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-`) 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 ` 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 ` diff --git a/modules/Projects.psm1 b/modules/Projects.psm1 index b8612e0..e5b375b 100644 --- a/modules/Projects.psm1 +++ b/modules/Projects.psm1 @@ -25,8 +25,11 @@ # Test-HostCheckout -HostCheckout — is it a directory containing a .git dir/file? # Get-ProjectType -ProjectSpec — 'distro' (default) / 'host' # Profile mutation -# Add-ProjectToProfile -ProfilePath -ProjectSpec -# Remove-ProjectFromProfile -ProfilePath -Name +# Add-ProjectToProfile -ProfilePath -ProjectSpec +# Remove-ProjectFromProfile -ProfilePath -Name +# Set-ProjectEnabledInProfile -ProfilePath -Name -Enabled — toggle `enabled` field, preserves %ENV% +# Move-ProjectInProfile -ProfilePath -Name -ToType +# [-Remote -HostCheckout -HostShadows] — distro<->host in-place mutation Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -258,6 +261,64 @@ function Set-ProjectEnabledInProfile { return $true } +function Move-ProjectInProfile { + # Cross-type migration mutation — swaps a project entry between + # distroProject (mirror inside distro) and hostProject (sibling worktrees + # on the Windows host) without losing the user-facing fields that survive + # both forms: tabColor, defaultBranch, enabled, hostMounts, claudeSettings, + # claudeFile. Forbidden-for-target fields are dropped (e.g. `hostTools` is + # legal on a distroProject but rejected on a hostProject; this helper + # quietly strips it on a distro->host move). + # + # This is just the profile mutation half of `project move`; the verb owns + # the dirty-session check, the materialized teardown, and the post-mutation + # re-provision (clone mirror or install bin dir). + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProfilePath, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][ValidateSet('distro','host')][string]$ToType, + [string]$Remote, # required for ToType=distro + [string]$HostCheckout, # required for ToType=host + [AllowNull()][object[]]$HostShadows + ) + if (-not (Test-Path -LiteralPath $ProfilePath)) { throw "Profile not found: $ProfilePath" } + $spec = Read-Profile -Path $ProfilePath -Raw + if (-not $spec.ContainsKey('projects') -or -not $spec.projects) { + throw "Profile has no projects[] array." + } + $existing = @($spec.projects) + $entry = $null + foreach ($p in $existing) { + if ($p -is [hashtable] -and [string]$p.name -eq $Name) { $entry = $p; break } + } + if (-not $entry) { throw "Project '$Name' not found in profile." } + + # Strip all type-specific fields from the entry so we can rewrite cleanly + # below. Anything not in this list (tabColor, defaultBranch, enabled, + # hostMounts, claudeSettings, claudeFile, ...) is left untouched. + foreach ($k in @('type','remote','hostCheckout','hostShadows','hostTools')) { + if ($entry.ContainsKey($k)) { [void]$entry.Remove($k) } + } + + if ($ToType -eq 'host') { + if (-not $HostCheckout) { throw "Move-ProjectInProfile -ToType host requires -HostCheckout." } + $entry['type'] = 'host' + $entry['hostCheckout'] = $HostCheckout + $shadows = if ($HostShadows) { @($HostShadows) } else { @('pwsh', 'git') } + $entry['hostShadows'] = $shadows + } + else { + if (-not $Remote) { throw "Move-ProjectInProfile -ToType distro requires -Remote." } + # No `type` key — distro is the documented default and the existing + # 'add' wizard also omits it for distro entries. + $entry['remote'] = $Remote + } + + $spec.projects = $existing + Write-Profile -Path $ProfilePath -Spec $spec +} + Export-ModuleMember -Function ` Resolve-SmartRemote, ` Resolve-SmartDefaultBranch, ` @@ -271,4 +332,5 @@ Export-ModuleMember -Function ` Get-ProjectType, ` Add-ProjectToProfile, ` Remove-ProjectFromProfile, ` - Set-ProjectEnabledInProfile + Set-ProjectEnabledInProfile, ` + Move-ProjectInProfile diff --git a/tests/distro/Project.Tests.ps1 b/tests/distro/Project.Tests.ps1 index 1adb92b..f68b014 100644 --- a/tests/distro/Project.Tests.ps1 +++ b/tests/distro/Project.Tests.ps1 @@ -112,6 +112,115 @@ Describe 'project enable / disable round-trip via reconcile' -Tag 'distro' { } } +Describe 'project move (distro -> host -> distro round-trip)' -Tag 'distro' { + # End-to-end: start as distroProject, move to host (mirror gone, bin dir + # appears, profile entry rewrites), move back (bin dir gone, mirror + # reappears, profile entry rewrites again). Asserts the user-facing fields + # survive the round trip. + BeforeAll { + $script:moveProj = 'distrotest-move' + $script:moveBase = Join-Path ([System.IO.Path]::GetTempPath()) ("move-test-" + [Guid]::NewGuid().ToString('N')) + $script:hostCheck = Join-Path $script:moveBase 'checkout' + [void][System.IO.Directory]::CreateDirectory($script:hostCheck) + Push-Location $script:hostCheck + try { + & git init -q -b master + & git config user.email t@t + & git config user.name t + Set-Content -LiteralPath (Join-Path $script:hostCheck 'README.md') -Value 'hi' -Encoding UTF8 + & git add README.md + & git commit -qm init + } finally { Pop-Location } + + # Seed the distro side as a distroProject with a tabColor we can + # assert is preserved across both moves. + Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath -Args @{ + Verb='project'; SubVerb='add'; Arg=$script:moveProj + Remote=$script:remoteUrl; DefaultBranch='master' + } + # Inject a tabColor directly — project add doesn't prompt for it in + # non-interactive mode. Read raw, mutate, write back. + $raw = Get-Content -LiteralPath $script:profilePath -Raw | ConvertFrom-Json -AsHashtable + foreach ($p in @($raw.projects)) { + if ($p -is [hashtable] -and $p.name -eq $script:moveProj) { $p['tabColor'] = '#abc123' } + } + ($raw | ConvertTo-Json -Depth 32) | Set-Content -LiteralPath $script:profilePath -Encoding UTF8 + } + + AfterAll { + # Best-effort cleanup: try the verb first, then nuke residual dirs. + try { + Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath ` + -Args @{ Verb='project'; SubVerb='remove'; Arg=$script:moveProj; Force=$true } -AllowFail | Out-Null + } catch {} + $sessionsDir = $script:hostCheck + '-sessions' + foreach ($p in @($sessionsDir, $script:moveBase)) { + if ($p -and (Test-Path -LiteralPath $p)) { + try { Remove-Item -LiteralPath $p -Recurse -Force } catch {} + } + } + # Also delete any .bak- snapshots the verb produced. + $bakGlob = "$script:profilePath.bak-*" + Get-ChildItem -LiteralPath (Split-Path -Parent $script:profilePath) -Filter (Split-Path -Leaf $bakGlob) -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + } + + It 'moves distroProject -> hostProject: mirror gone, bin dir present, profile rewrites' { + Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath -Args @{ + Verb='project'; SubVerb='move'; Arg=$script:moveProj + To='host'; HostCheckout=$script:hostCheck; Force=$true + } + + $r = Invoke-InDistro -Name $script:distro -User 'claude' -CaptureOutput -AllowFail ` + -Command "test -d /home/claude/mirrors/$($script:moveProj).git && echo present || echo gone" + ($r.Output -join "`n").Trim() | Should -Be 'gone' + + $r2 = Invoke-InDistro -Name $script:distro -User 'claude' -CaptureOutput -AllowFail ` + -Command "test -d /home/claude/host-projects/$($script:moveProj)/bin && echo present || echo gone" + ($r2.Output -join "`n").Trim() | Should -Be 'present' + + $spec = Get-Content -LiteralPath $script:profilePath -Raw | ConvertFrom-Json -AsHashtable + $entry = @(@($spec.projects) | Where-Object { $_.name -eq $script:moveProj })[0] + $entry | Should -Not -BeNullOrEmpty + [string]$entry.type | Should -Be 'host' + [string]$entry.hostCheckout | Should -Be $script:hostCheck + # tabColor must survive the mutation. + [string]$entry.tabColor | Should -Be '#abc123' + # remote must be gone — a hostProject with a `remote` is a schema error. + $entry.ContainsKey('remote') | Should -BeFalse + } + + It 'writes a timestamped .bak snapshot next to the profile during the move' { + $bakName = (Split-Path -Leaf $script:profilePath) + '.bak-*' + $baks = Get-ChildItem -LiteralPath (Split-Path -Parent $script:profilePath) -Filter $bakName -ErrorAction SilentlyContinue + @($baks).Count | Should -BeGreaterThan 0 + } + + It 'moves hostProject -> distroProject: bin dir gone, mirror back, profile rewrites' { + Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath -Args @{ + Verb='project'; SubVerb='move'; Arg=$script:moveProj + To='distro'; Remote=$script:remoteUrl; Force=$true + } + + $r = Invoke-InDistro -Name $script:distro -User 'claude' -CaptureOutput -AllowFail ` + -Command "test -d /home/claude/host-projects/$($script:moveProj) && echo present || echo gone" + ($r.Output -join "`n").Trim() | Should -Be 'gone' + + $r2 = Invoke-InDistro -Name $script:distro -User 'claude' -CaptureOutput -AllowFail ` + -Command "test -d /home/claude/mirrors/$($script:moveProj).git && echo present || echo gone" + ($r2.Output -join "`n").Trim() | Should -Be 'present' + + $spec = Get-Content -LiteralPath $script:profilePath -Raw | ConvertFrom-Json -AsHashtable + $entry = @(@($spec.projects) | Where-Object { $_.name -eq $script:moveProj })[0] + $entry | Should -Not -BeNullOrEmpty + $entry.ContainsKey('type') | Should -BeFalse # distro = default + $entry.ContainsKey('hostCheckout') | Should -BeFalse + $entry.ContainsKey('hostShadows') | Should -BeFalse + [string]$entry.remote | Should -Be $script:remoteUrl + [string]$entry.tabColor | Should -Be '#abc123' + } +} + Describe 'project remove' -Tag 'distro' { It 'deletes the bare mirror and drops the profile entry' { Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath ` diff --git a/tests/lib/TestRegistry.psm1 b/tests/lib/TestRegistry.psm1 index d8575d8..a3c4168 100644 --- a/tests/lib/TestRegistry.psm1 +++ b/tests/lib/TestRegistry.psm1 @@ -179,6 +179,17 @@ $Script:Manifest = @( EstSeconds = 2 Description = 'Get-LocalVersion parse, Test-IsOurRepo permissive matching, update-check state round-trip + throttle math, Get-LatestReleaseInfo via mocked Invoke-RestMethod, manifest-diff helper' }, + @{ + Id = 'pure/Projects' + File = 'tests/pure/Projects.Tests.ps1' + Group = 'pure' + SubGroup = 'Projects' + Kind = 'auto' + NeedsDistro = $false + NeedsVpnReal = $false + EstSeconds = 2 + Description = 'Profile-mutation helpers: Move-ProjectInProfile distro <-> host preserves tabColor/defaultBranch/enabled, drops type-mismatched fields, validates after round-trip; required-arg errors for missing HostCheckout / Remote' + }, @{ Id = 'pure/Sessions' File = 'tests/pure/Sessions.Tests.ps1' diff --git a/tests/pure/Projects.Tests.ps1 b/tests/pure/Projects.Tests.ps1 new file mode 100644 index 0000000..f7edd8c --- /dev/null +++ b/tests/pure/Projects.Tests.ps1 @@ -0,0 +1,193 @@ +# Projects.Tests.ps1 — pure tests for the profile-mutation helpers in +# modules/Projects.psm1. Worktree / mirror lifecycle that touches a real +# distro lives under tests/distro/. + +BeforeAll { + $repoRoot = if ($env:CLAUDEARIUM_REPO_ROOT) { + $env:CLAUDEARIUM_REPO_ROOT + } else { + Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + } + Import-Module (Join-Path $repoRoot 'modules\Profile.psm1') -Force + Import-Module (Join-Path $repoRoot 'modules\Projects.psm1') -Force + + # Pester 5 runs each `It` in its own scope; helpers must be defined inside + # `BeforeAll` to be visible. A free-standing function at file scope is + # discovered but unreachable from the It blocks. + function New-TempProfile { + param([Parameter(Mandatory)][hashtable]$Spec) + $p = Join-Path ([System.IO.Path]::GetTempPath()) ("claudearium-move-test-" + [Guid]::NewGuid().ToString('N') + '.json') + Write-Profile -Path $p -Spec $Spec + return $p + } +} + +Describe 'Move-ProjectInProfile' { + It 'rewrites a distroProject as a hostProject and preserves unrelated fields' { + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ + name = 'p1' + remote = 'git@host:org/p1.git' + defaultBranch = 'master' + tabColor = '#0078D7' + enabled = $true + }) + } + try { + Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'host' ` + -HostCheckout 'C:\dev\p1' + + $spec = Read-Profile -Path $path -Raw + $e = @($spec.projects | Where-Object { $_.name -eq 'p1' })[0] + [string]$e.type | Should -Be 'host' + [string]$e.hostCheckout | Should -Be 'C:\dev\p1' + @($e.hostShadows) | Should -Contain 'pwsh' + @($e.hostShadows) | Should -Contain 'git' + # remote stripped — must not survive on a hostProject. + $e.ContainsKey('remote') | Should -BeFalse + # preserved fields: + [string]$e.defaultBranch | Should -Be 'master' + [string]$e.tabColor | Should -Be '#0078D7' + [bool]$e.enabled | Should -BeTrue + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + + It 'rewrites a hostProject as a distroProject and preserves unrelated fields' { + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ + name = 'p1' + type = 'host' + hostCheckout = 'C:\dev\p1' + hostShadows = @('pwsh', 'git') + defaultBranch = 'main' + tabColor = '#FFAA00' + }) + } + try { + Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'distro' ` + -Remote 'git@host:org/p1.git' + + $spec = Read-Profile -Path $path -Raw + $e = @($spec.projects | Where-Object { $_.name -eq 'p1' })[0] + [string]$e.remote | Should -Be 'git@host:org/p1.git' + $e.ContainsKey('type') | Should -BeFalse # distro = default, omit + $e.ContainsKey('hostCheckout') | Should -BeFalse + $e.ContainsKey('hostShadows') | Should -BeFalse + # preserved: + [string]$e.defaultBranch | Should -Be 'main' + [string]$e.tabColor | Should -Be '#FFAA00' + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + + It 'drops hostTools when moving distro -> host (forbidden for hostProjects)' { + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ + name = 'p1' + remote = 'git@host:org/p1.git' + hostTools = @(@{ name = 'foo'; windowsExe = 'C:\foo.exe'; guestCommand = 'foo' }) + }) + } + try { + Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'host' -HostCheckout 'C:\dev\p1' + $spec = Read-Profile -Path $path -Raw + $e = @($spec.projects | Where-Object { $_.name -eq 'p1' })[0] + $e.ContainsKey('hostTools') | Should -BeFalse + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + + It 'accepts a custom -HostShadows list (overrides the default pwsh/git pair)' { + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ name = 'p1'; remote = 'git@host:org/p1.git' }) + } + try { + Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'host' ` + -HostCheckout 'C:\dev\p1' -HostShadows @('pwsh') + $spec = Read-Profile -Path $path -Raw + $e = @($spec.projects | Where-Object { $_.name -eq 'p1' })[0] + @($e.hostShadows).Count | Should -Be 1 + @($e.hostShadows)[0] | Should -Be 'pwsh' + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + + It 'throws when the project is not in the profile' { + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ name = 'p1'; remote = 'r' }) + } + try { + { Move-ProjectInProfile -ProfilePath $path -Name 'nope' -ToType 'host' -HostCheckout 'C:\x' } | + Should -Throw "*'nope' not found*" + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + + It 'throws when ToType=host without -HostCheckout' { + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ name = 'p1'; remote = 'r' }) + } + try { + { Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'host' } | + Should -Throw '*requires -HostCheckout*' + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + + It 'throws when ToType=distro without -Remote' { + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ name = 'p1'; type = 'host'; hostCheckout = 'C:\x' }) + } + try { + { Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'distro' } | + Should -Throw '*requires -Remote*' + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + + It 'produces an entry that passes Test-Profile after a distro -> host round-trip' { + # Regression: the mutation must keep the result schema-valid, otherwise + # the next reconcile would refuse to read the profile. + $path = New-TempProfile -Spec @{ + schemaVersion = 1 + distro = @{ name = 'x'; base = 'debian-12'; installPath = 'C:\x' } + projects = @(@{ name = 'p1'; remote = 'git@host:org/p1.git'; tabColor = '#112233' }) + } + try { + Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'host' -HostCheckout 'C:\dev\p1' + $spec1 = Read-Profile -Path $path -Raw + (Test-Profile -Spec $spec1).IsValid | Should -BeTrue + + Move-ProjectInProfile -ProfilePath $path -Name 'p1' -ToType 'distro' -Remote 'git@host:org/p1.git' + $spec2 = Read-Profile -Path $path -Raw + (Test-Profile -Spec $spec2).IsValid | Should -BeTrue + # tabColor must survive the round trip. + $e = @($spec2.projects | Where-Object { $_.name -eq 'p1' })[0] + [string]$e.tabColor | Should -Be '#112233' + } finally { + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } +}