@caddis/cli 0.4.28 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.116",
3
+ "version": "1.3.117",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -74,6 +74,13 @@ facts - services, scheduled tasks, web sites - can only be read on the box itsel
74
74
  flag yourself.** A credential that was ever committed must be ROTATED, not only deleted.
75
75
  - **What runs it.** If nothing on the host references the folder and the app is live, it runs
76
76
  somewhere you have not looked. Find out where before planning anything.
77
+ - **COMMITS NO PORT WOULD CARRY.** A worktree is an extra folder, not an extra repository, so
78
+ its branch travels with a clone like any other. A **detached HEAD** does not: a clone copies
79
+ `refs/heads/*` and `refs/tags/*`, and a detached HEAD is neither. Nothing points at those
80
+ commits, so git may also collect them. If this section appears, fix it before anything else -
81
+ the report prints the exact `git branch` command, it changes nothing else, and it is the
82
+ difference between porting the work and losing it. Stashes are the same problem: `refs/stash`
83
+ is not copied by a clone or a push.
77
84
 
78
85
  Junctions and symlinks are reported and never followed. A junction inside a tree that git manages
79
86
  is a data-loss hazard: git's recursive remove follows it and deletes the target's contents.
@@ -1,6 +1,6 @@
1
1
  # The adoption standard
2
2
 
3
- `Test-AppConformance.ps1` (phase 3) measures an app against 20 items, in the order below - the
3
+ `Test-AppConformance.ps1` (phase 3) measures an app against 21 items, in the order below - the
4
4
  same order the script adds them in. Each one gets a verdict, never a guess:
5
5
 
6
6
  | Verdict | Means |
@@ -247,7 +247,40 @@ run it - should not require finding and interrupting someone.
247
247
  **Why it is on the list:** without it, every coding agent that touches the app re-derives its
248
248
  non-obvious rules from scratch, and different agents derive them differently.
249
249
 
250
- ### 19. `no-junctions` - No junction or symlink inside the tree git manages
250
+ ### 19. `work-is-in-a-ref` - Every commit is reachable from a branch or a tag
251
+
252
+ **Blocking.**
253
+
254
+ **Decided by:** `GAP` when a worktree is on a DETACHED HEAD that no branch and no tag contains.
255
+ `ASK` when there are no unreachable commits but there are stashes, or worktrees with
256
+ uncommitted files. `ASK` when git could not be run. `PASS` otherwise.
257
+
258
+ **Why it is on the list.** A port copies refs. A clone takes `refs/heads/*` and `refs/tags/*`,
259
+ so a worktree's BRANCH travels with it like any other - a worktree is an extra folder, not an
260
+ extra repository. A **detached HEAD is neither a branch nor a tag**, so its commits are not
261
+ copied, and because nothing points at them git is free to collect them. The work is not
262
+ protected by being on disk; it is protected by being in a ref.
263
+
264
+ Measured on a real app: two detached worktrees under a temporary path that the app's own
265
+ documents called a "recurring external cleanup hazard", with three recorded incidents. They
266
+ held **200 commits** of certified work. `branch --contains` returned 0 for both. Every other
267
+ check passed; a port would have carried 127 branches and left behind the only copy of the part
268
+ that mattered.
269
+
270
+ **The fix costs nothing** and the report prints it per worktree:
271
+
272
+ ```
273
+ git -C <repo> branch rescue/<worktree-name> <sha>
274
+ ```
275
+
276
+ It creates a branch name and changes nothing else. After it, `branch --contains` returns 1 and
277
+ the commits port like anything else.
278
+
279
+ Stashes are the same class of problem for a smaller amount of work: `refs/stash` is not copied
280
+ by a clone or a push. Uncommitted files in a worktree are invisible to the main checkout's
281
+ `git status`, which is why the inventory reports each worktree's own count.
282
+
283
+ ### 20. `no-junctions` - No junction or symlink inside the tree git manages
251
284
 
252
285
  **Not blocking.**
253
286
 
@@ -261,7 +294,7 @@ Ordinary Windows delete operations (`Remove-Item -Recurse`, `cmd /c rmdir /s /q`
261
294
  this trap easy to miss: a delete you tested yourself can look completely safe right up until git
262
295
  does the deleting instead.
263
296
 
264
- ### 20. `profile` - The stack is declared, not assumed
297
+ ### 21. `profile` - The stack is declared, not assumed
265
298
 
266
299
  **Blocking.**
267
300
 
@@ -308,9 +341,9 @@ what it really is.
308
341
  | `1` | At least one blocking item is `GAP` and not waived. Checked first - if both a blocking `GAP` and a blocking unanswered `ASK` exist at once, exit code `1` wins. |
309
342
  | `2` | No blocking `GAP` remains, but at least one blocking item is still an unanswered `ASK` and not waived. |
310
343
 
311
- The eight blocking items, as the code actually enforces them, are: `git-repo`,
312
- `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `run-declared`, `owner`,
313
- and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
344
+ The nine blocking items, as the code actually enforces them, are: `git-repo`,
345
+ `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `work-is-in-a-ref`,
346
+ `run-declared`, `owner` and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
314
347
  item, and not by the project config. An earlier draft of the config carried a `blockingItems`
315
348
  list that nothing read and that had drifted out of step with this set; it was removed from the
316
349
  code and from `config.example.json` together, rather than documented as a thing that looks
@@ -196,7 +196,8 @@ $script:AdoptScanBlock = {
196
196
  # a key that is missing because one git call did not run.
197
197
  $g = @{ isRepo = $true; gitAvailable = $gitAvailable; gitError = $gitError; commits = $null; branch = $null
198
198
  localBranches = 0; remoteBranches = 0; tags = 0; dirtyFiles = $null
199
- remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null }
199
+ remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null
200
+ worktrees = @(); unreachableCommits = @(); stashes = 0 }
200
201
  $result.git = $g
201
202
  if (-not $gitAvailable) {
202
203
  $result.partial = $true
@@ -231,6 +232,63 @@ $script:AdoptScanBlock = {
231
232
  if ($ahead) { $g.unpushed = [int]"$ahead" }
232
233
  }
233
234
  else { $g.upstream = $null; $g.unpushed = $null }
235
+
236
+ # ---- worktrees, and the commits a port would silently leave behind ----------
237
+ #
238
+ # A worktree is an extra FOLDER, not an extra repository: its branch lives in
239
+ # this same .git, so a mirror clone carries it. What a mirror does NOT carry is
240
+ # a DETACHED HEAD, because a mirror copies refs/heads/* and refs/tags/* and a
241
+ # detached HEAD is neither. Nothing points at those commits, so git is also free
242
+ # to collect them.
243
+ #
244
+ # This is not hypothetical. A real app had two detached worktrees on a temp path
245
+ # its own documents called a "recurring cleanup hazard", holding 200 commits of
246
+ # certified work. `branches containing it` was 0 for both. One `git worktree add`
247
+ # months earlier, and a port would have taken everything except the part that
248
+ # mattered.
249
+ $g.worktrees = @()
250
+ $g.unreachableCommits = @()
251
+ $wtOut = & $runGit @('worktree', 'list', '--porcelain')
252
+ if ($wtOut) {
253
+ $cur = $null
254
+ foreach ($line in @($wtOut)) {
255
+ $s = "$line"
256
+ if ($s -match '^worktree\s+(.+)$') {
257
+ if ($cur) { $g.worktrees += $cur }
258
+ $cur = @{ path = $Matches[1]; branch = $null; head = $null; detached = $false; dirtyFiles = $null; isMain = $false }
259
+ }
260
+ elseif ($s -match '^HEAD\s+([0-9a-f]{40})$' -and $cur) { $cur.head = $Matches[1] }
261
+ elseif ($s -match '^branch\s+refs/heads/(.+)$' -and $cur) { $cur.branch = $Matches[1] }
262
+ elseif ($s -eq 'detached' -and $cur) { $cur.detached = $true }
263
+ }
264
+ if ($cur) { $g.worktrees += $cur }
265
+ }
266
+
267
+ foreach ($w in @($g.worktrees)) {
268
+ $w.isMain = ($w.path.TrimEnd('\', '/') -eq $root)
269
+ # Its OWN uncommitted files. A worktree has a separate working tree, so the
270
+ # main checkout's `git status` says nothing about it.
271
+ if (Test-Path -LiteralPath $w.path) {
272
+ $st = & git -c safe.directory=* -C $w.path status --porcelain 2>&1
273
+ if ($LASTEXITCODE -eq 0) { $w.dirtyFiles = @($st | Where-Object { "$_" -ne '' }).Count }
274
+ }
275
+ # The finding that matters: is this HEAD reachable from any branch or tag?
276
+ if ($w.detached -and $w.head) {
277
+ $containing = @(& git -c safe.directory=* -C $root branch --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
278
+ $tagged = @(& git -c safe.directory=* -C $root tag --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
279
+ if ($containing.Count -eq 0 -and $tagged.Count -eq 0) {
280
+ $ahead = & git -c safe.directory=* -C $root rev-list --count "$($w.head)" 2>&1
281
+ $g.unreachableCommits += @{
282
+ path = $w.path; head = $w.head
283
+ commits = $(if ($LASTEXITCODE -eq 0) { [int]"$ahead" } else { $null })
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ # A stash is a ref, but `refs/stash` is not copied by a normal clone or push.
290
+ $stash = & $runGit @('stash', 'list')
291
+ $g.stashes = @($stash | Where-Object { "$_" -ne '' }).Count
234
292
  }
235
293
 
236
294
  # ---- which files git actually tracks ---------------------------------------------
@@ -166,6 +166,45 @@ elseif ($inventory.git.isRepo) {
166
166
  }
167
167
  else { Write-Host " repo : NO - there is no history to port; adoption starts with one import commit" -ForegroundColor Yellow }
168
168
 
169
+ if ($inventory.git.isRepo) {
170
+ $wts = @(Get-AdoptProp $inventory.git 'worktrees' @())
171
+ $stashes = [int](Get-AdoptProp $inventory.git 'stashes' 0)
172
+ if ($wts.Count -gt 1 -or $stashes -gt 0) {
173
+ Write-AdoptHeading 'Worktrees and stashes'
174
+ foreach ($w in $wts) {
175
+ $what = if ($w.branch) { "on $($w.branch)" } else { "DETACHED at $("$($w.head)".Substring(0,9))" }
176
+ $dirty = if ($null -eq $w.dirtyFiles) { 'unreadable' } else { "$($w.dirtyFiles) uncommitted" }
177
+ $tag = if ($w.isMain) { ' (the main checkout)' } else { '' }
178
+ Write-Host (" {0,-58} {1,-34} {2}{3}" -f $w.path, $what, $dirty, $tag)
179
+ }
180
+ if ($stashes -gt 0) {
181
+ Write-Host " $stashes stash entr(ies) - `refs/stash` is NOT copied by a clone or a push." -ForegroundColor Yellow
182
+ }
183
+ }
184
+
185
+ # THE ONE THAT LOSES WORK. A mirror copies refs/heads/* and refs/tags/*; a detached
186
+ # HEAD is neither, so its commits are not carried - and with nothing pointing at them,
187
+ # git may collect them. Found on a real app: 200 commits of certified work on two
188
+ # detached worktrees, under a path its own docs called a recurring cleanup hazard.
189
+ $unreachable = @(Get-AdoptProp $inventory.git 'unreachableCommits' @())
190
+ if ($unreachable.Count -gt 0) {
191
+ Write-AdoptHeading 'COMMITS NO PORT WOULD CARRY'
192
+ Write-Host " These worktrees are on a DETACHED HEAD that no branch and no tag points at." -ForegroundColor Red
193
+ Write-Host " A clone copies branches and tags. These are neither, so they would be left" -ForegroundColor Red
194
+ Write-Host " behind - and git is free to delete them." -ForegroundColor Red
195
+ foreach ($u in $unreachable) {
196
+ $n = if ($u.commits) { "$($u.commits) commits" } else { 'unknown depth' }
197
+ Write-Host (" {0}`n HEAD {1} ({2})" -f $u.path, "$($u.head)".Substring(0,9), $n) -ForegroundColor Red
198
+ }
199
+ Write-Host ""
200
+ Write-Host " FIX IT BEFORE PORTING - one command each, and it changes nothing else:" -ForegroundColor Yellow
201
+ foreach ($u in $unreachable) {
202
+ $leaf = (Split-Path $u.path -Leaf) -replace '[^A-Za-z0-9._-]', '-'
203
+ Write-Host " git -C <repo> branch rescue/$leaf $("$($u.head)".Substring(0,9))" -ForegroundColor Yellow
204
+ }
205
+ }
206
+ }
207
+
169
208
  # Neither committed nor ignored. This is the state that turns one careless `git add -A`
170
209
  # into gigabytes of runtime data in the history for ever, and no filename pattern finds
171
210
  # it - only git can tell an undeclared data tree from a source folder.
@@ -254,6 +254,22 @@ Add-Item 'agent-rules' 'A rules file the coding agents read' $false `
254
254
  $(if ($hasAgentRules) { (@($inv.docs | Where-Object { $_ -match '(?i)^(agents|claude)\.md$' }) -join ', ') } else { 'none' }) `
255
255
  '' 'Without it every agent re-derives the project rules, and each one derives them differently.'
256
256
 
257
+ # A port copies refs. Work that is in no ref is work a port silently drops.
258
+ $unreachable = @(Get-AdoptProp $inv.git 'unreachableCommits' @())
259
+ $stashCount = [int](Get-AdoptProp $inv.git 'stashes' 0)
260
+ $wtDirty = @(@(Get-AdoptProp $inv.git 'worktrees' @()) | Where-Object { $_.dirtyFiles -and $_.dirtyFiles -gt 0 })
261
+ Add-Item 'work-is-in-a-ref' 'Every commit is reachable from a branch or a tag' $true `
262
+ $(if (-not $gitUsable) { 'ASK' } elseif ($unreachable.Count -gt 0) { 'GAP' } elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) { 'ASK' } else { 'PASS' }) `
263
+ $(if ($unreachable.Count -gt 0) {
264
+ "DETACHED and unreferenced: " + ((@($unreachable | ForEach-Object { "$($_.path) ($($_.commits) commits)" })) -join '; ')
265
+ }
266
+ elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) {
267
+ "no unreachable commits, but $stashCount stash(es) and $($wtDirty.Count) worktree(s) with uncommitted files - none of that is in a ref either"
268
+ }
269
+ else { 'every worktree is on a branch; no stashes' }) `
270
+ 'For each stash and each uncommitted file: does that work matter? Anything that does needs a branch before the port.' `
271
+ 'A clone copies refs/heads/* and refs/tags/*. A detached HEAD is neither, so its commits are not carried - and nothing points at them, so git may collect them. `git branch <name> <sha>` costs nothing and fixes it.'
272
+
257
273
  Add-Item 'no-junctions' 'No junction or symlink inside the tree git manages' $false `
258
274
  $(if ($junctionsInTree.Count -eq 0) { 'PASS' } else { 'GAP' }) `
259
275
  $(if ($junctionsInTree.Count -eq 0) { 'none outside dependency folders' } else { "$($junctionsInTree.Count): $((@($junctionsInTree | Select-Object -First 3 | ForEach-Object { $_.path })) -join ', ')" }) `
@@ -74,6 +74,13 @@ facts - services, scheduled tasks, web sites - can only be read on the box itsel
74
74
  flag yourself.** A credential that was ever committed must be ROTATED, not only deleted.
75
75
  - **What runs it.** If nothing on the host references the folder and the app is live, it runs
76
76
  somewhere you have not looked. Find out where before planning anything.
77
+ - **COMMITS NO PORT WOULD CARRY.** A worktree is an extra folder, not an extra repository, so
78
+ its branch travels with a clone like any other. A **detached HEAD** does not: a clone copies
79
+ `refs/heads/*` and `refs/tags/*`, and a detached HEAD is neither. Nothing points at those
80
+ commits, so git may also collect them. If this section appears, fix it before anything else -
81
+ the report prints the exact `git branch` command, it changes nothing else, and it is the
82
+ difference between porting the work and losing it. Stashes are the same problem: `refs/stash`
83
+ is not copied by a clone or a push.
77
84
 
78
85
  Junctions and symlinks are reported and never followed. A junction inside a tree that git manages
79
86
  is a data-loss hazard: git's recursive remove follows it and deletes the target's contents.
@@ -1,6 +1,6 @@
1
1
  # The adoption standard
2
2
 
3
- `Test-AppConformance.ps1` (phase 3) measures an app against 20 items, in the order below - the
3
+ `Test-AppConformance.ps1` (phase 3) measures an app against 21 items, in the order below - the
4
4
  same order the script adds them in. Each one gets a verdict, never a guess:
5
5
 
6
6
  | Verdict | Means |
@@ -247,7 +247,40 @@ run it - should not require finding and interrupting someone.
247
247
  **Why it is on the list:** without it, every coding agent that touches the app re-derives its
248
248
  non-obvious rules from scratch, and different agents derive them differently.
249
249
 
250
- ### 19. `no-junctions` - No junction or symlink inside the tree git manages
250
+ ### 19. `work-is-in-a-ref` - Every commit is reachable from a branch or a tag
251
+
252
+ **Blocking.**
253
+
254
+ **Decided by:** `GAP` when a worktree is on a DETACHED HEAD that no branch and no tag contains.
255
+ `ASK` when there are no unreachable commits but there are stashes, or worktrees with
256
+ uncommitted files. `ASK` when git could not be run. `PASS` otherwise.
257
+
258
+ **Why it is on the list.** A port copies refs. A clone takes `refs/heads/*` and `refs/tags/*`,
259
+ so a worktree's BRANCH travels with it like any other - a worktree is an extra folder, not an
260
+ extra repository. A **detached HEAD is neither a branch nor a tag**, so its commits are not
261
+ copied, and because nothing points at them git is free to collect them. The work is not
262
+ protected by being on disk; it is protected by being in a ref.
263
+
264
+ Measured on a real app: two detached worktrees under a temporary path that the app's own
265
+ documents called a "recurring external cleanup hazard", with three recorded incidents. They
266
+ held **200 commits** of certified work. `branch --contains` returned 0 for both. Every other
267
+ check passed; a port would have carried 127 branches and left behind the only copy of the part
268
+ that mattered.
269
+
270
+ **The fix costs nothing** and the report prints it per worktree:
271
+
272
+ ```
273
+ git -C <repo> branch rescue/<worktree-name> <sha>
274
+ ```
275
+
276
+ It creates a branch name and changes nothing else. After it, `branch --contains` returns 1 and
277
+ the commits port like anything else.
278
+
279
+ Stashes are the same class of problem for a smaller amount of work: `refs/stash` is not copied
280
+ by a clone or a push. Uncommitted files in a worktree are invisible to the main checkout's
281
+ `git status`, which is why the inventory reports each worktree's own count.
282
+
283
+ ### 20. `no-junctions` - No junction or symlink inside the tree git manages
251
284
 
252
285
  **Not blocking.**
253
286
 
@@ -261,7 +294,7 @@ Ordinary Windows delete operations (`Remove-Item -Recurse`, `cmd /c rmdir /s /q`
261
294
  this trap easy to miss: a delete you tested yourself can look completely safe right up until git
262
295
  does the deleting instead.
263
296
 
264
- ### 20. `profile` - The stack is declared, not assumed
297
+ ### 21. `profile` - The stack is declared, not assumed
265
298
 
266
299
  **Blocking.**
267
300
 
@@ -308,9 +341,9 @@ what it really is.
308
341
  | `1` | At least one blocking item is `GAP` and not waived. Checked first - if both a blocking `GAP` and a blocking unanswered `ASK` exist at once, exit code `1` wins. |
309
342
  | `2` | No blocking `GAP` remains, but at least one blocking item is still an unanswered `ASK` and not waived. |
310
343
 
311
- The eight blocking items, as the code actually enforces them, are: `git-repo`,
312
- `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `run-declared`, `owner`,
313
- and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
344
+ The nine blocking items, as the code actually enforces them, are: `git-repo`,
345
+ `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `work-is-in-a-ref`,
346
+ `run-declared`, `owner` and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
314
347
  item, and not by the project config. An earlier draft of the config carried a `blockingItems`
315
348
  list that nothing read and that had drifted out of step with this set; it was removed from the
316
349
  code and from `config.example.json` together, rather than documented as a thing that looks
@@ -196,7 +196,8 @@ $script:AdoptScanBlock = {
196
196
  # a key that is missing because one git call did not run.
197
197
  $g = @{ isRepo = $true; gitAvailable = $gitAvailable; gitError = $gitError; commits = $null; branch = $null
198
198
  localBranches = 0; remoteBranches = 0; tags = 0; dirtyFiles = $null
199
- remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null }
199
+ remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null
200
+ worktrees = @(); unreachableCommits = @(); stashes = 0 }
200
201
  $result.git = $g
201
202
  if (-not $gitAvailable) {
202
203
  $result.partial = $true
@@ -231,6 +232,63 @@ $script:AdoptScanBlock = {
231
232
  if ($ahead) { $g.unpushed = [int]"$ahead" }
232
233
  }
233
234
  else { $g.upstream = $null; $g.unpushed = $null }
235
+
236
+ # ---- worktrees, and the commits a port would silently leave behind ----------
237
+ #
238
+ # A worktree is an extra FOLDER, not an extra repository: its branch lives in
239
+ # this same .git, so a mirror clone carries it. What a mirror does NOT carry is
240
+ # a DETACHED HEAD, because a mirror copies refs/heads/* and refs/tags/* and a
241
+ # detached HEAD is neither. Nothing points at those commits, so git is also free
242
+ # to collect them.
243
+ #
244
+ # This is not hypothetical. A real app had two detached worktrees on a temp path
245
+ # its own documents called a "recurring cleanup hazard", holding 200 commits of
246
+ # certified work. `branches containing it` was 0 for both. One `git worktree add`
247
+ # months earlier, and a port would have taken everything except the part that
248
+ # mattered.
249
+ $g.worktrees = @()
250
+ $g.unreachableCommits = @()
251
+ $wtOut = & $runGit @('worktree', 'list', '--porcelain')
252
+ if ($wtOut) {
253
+ $cur = $null
254
+ foreach ($line in @($wtOut)) {
255
+ $s = "$line"
256
+ if ($s -match '^worktree\s+(.+)$') {
257
+ if ($cur) { $g.worktrees += $cur }
258
+ $cur = @{ path = $Matches[1]; branch = $null; head = $null; detached = $false; dirtyFiles = $null; isMain = $false }
259
+ }
260
+ elseif ($s -match '^HEAD\s+([0-9a-f]{40})$' -and $cur) { $cur.head = $Matches[1] }
261
+ elseif ($s -match '^branch\s+refs/heads/(.+)$' -and $cur) { $cur.branch = $Matches[1] }
262
+ elseif ($s -eq 'detached' -and $cur) { $cur.detached = $true }
263
+ }
264
+ if ($cur) { $g.worktrees += $cur }
265
+ }
266
+
267
+ foreach ($w in @($g.worktrees)) {
268
+ $w.isMain = ($w.path.TrimEnd('\', '/') -eq $root)
269
+ # Its OWN uncommitted files. A worktree has a separate working tree, so the
270
+ # main checkout's `git status` says nothing about it.
271
+ if (Test-Path -LiteralPath $w.path) {
272
+ $st = & git -c safe.directory=* -C $w.path status --porcelain 2>&1
273
+ if ($LASTEXITCODE -eq 0) { $w.dirtyFiles = @($st | Where-Object { "$_" -ne '' }).Count }
274
+ }
275
+ # The finding that matters: is this HEAD reachable from any branch or tag?
276
+ if ($w.detached -and $w.head) {
277
+ $containing = @(& git -c safe.directory=* -C $root branch --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
278
+ $tagged = @(& git -c safe.directory=* -C $root tag --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
279
+ if ($containing.Count -eq 0 -and $tagged.Count -eq 0) {
280
+ $ahead = & git -c safe.directory=* -C $root rev-list --count "$($w.head)" 2>&1
281
+ $g.unreachableCommits += @{
282
+ path = $w.path; head = $w.head
283
+ commits = $(if ($LASTEXITCODE -eq 0) { [int]"$ahead" } else { $null })
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ # A stash is a ref, but `refs/stash` is not copied by a normal clone or push.
290
+ $stash = & $runGit @('stash', 'list')
291
+ $g.stashes = @($stash | Where-Object { "$_" -ne '' }).Count
234
292
  }
235
293
 
236
294
  # ---- which files git actually tracks ---------------------------------------------
@@ -166,6 +166,45 @@ elseif ($inventory.git.isRepo) {
166
166
  }
167
167
  else { Write-Host " repo : NO - there is no history to port; adoption starts with one import commit" -ForegroundColor Yellow }
168
168
 
169
+ if ($inventory.git.isRepo) {
170
+ $wts = @(Get-AdoptProp $inventory.git 'worktrees' @())
171
+ $stashes = [int](Get-AdoptProp $inventory.git 'stashes' 0)
172
+ if ($wts.Count -gt 1 -or $stashes -gt 0) {
173
+ Write-AdoptHeading 'Worktrees and stashes'
174
+ foreach ($w in $wts) {
175
+ $what = if ($w.branch) { "on $($w.branch)" } else { "DETACHED at $("$($w.head)".Substring(0,9))" }
176
+ $dirty = if ($null -eq $w.dirtyFiles) { 'unreadable' } else { "$($w.dirtyFiles) uncommitted" }
177
+ $tag = if ($w.isMain) { ' (the main checkout)' } else { '' }
178
+ Write-Host (" {0,-58} {1,-34} {2}{3}" -f $w.path, $what, $dirty, $tag)
179
+ }
180
+ if ($stashes -gt 0) {
181
+ Write-Host " $stashes stash entr(ies) - `refs/stash` is NOT copied by a clone or a push." -ForegroundColor Yellow
182
+ }
183
+ }
184
+
185
+ # THE ONE THAT LOSES WORK. A mirror copies refs/heads/* and refs/tags/*; a detached
186
+ # HEAD is neither, so its commits are not carried - and with nothing pointing at them,
187
+ # git may collect them. Found on a real app: 200 commits of certified work on two
188
+ # detached worktrees, under a path its own docs called a recurring cleanup hazard.
189
+ $unreachable = @(Get-AdoptProp $inventory.git 'unreachableCommits' @())
190
+ if ($unreachable.Count -gt 0) {
191
+ Write-AdoptHeading 'COMMITS NO PORT WOULD CARRY'
192
+ Write-Host " These worktrees are on a DETACHED HEAD that no branch and no tag points at." -ForegroundColor Red
193
+ Write-Host " A clone copies branches and tags. These are neither, so they would be left" -ForegroundColor Red
194
+ Write-Host " behind - and git is free to delete them." -ForegroundColor Red
195
+ foreach ($u in $unreachable) {
196
+ $n = if ($u.commits) { "$($u.commits) commits" } else { 'unknown depth' }
197
+ Write-Host (" {0}`n HEAD {1} ({2})" -f $u.path, "$($u.head)".Substring(0,9), $n) -ForegroundColor Red
198
+ }
199
+ Write-Host ""
200
+ Write-Host " FIX IT BEFORE PORTING - one command each, and it changes nothing else:" -ForegroundColor Yellow
201
+ foreach ($u in $unreachable) {
202
+ $leaf = (Split-Path $u.path -Leaf) -replace '[^A-Za-z0-9._-]', '-'
203
+ Write-Host " git -C <repo> branch rescue/$leaf $("$($u.head)".Substring(0,9))" -ForegroundColor Yellow
204
+ }
205
+ }
206
+ }
207
+
169
208
  # Neither committed nor ignored. This is the state that turns one careless `git add -A`
170
209
  # into gigabytes of runtime data in the history for ever, and no filename pattern finds
171
210
  # it - only git can tell an undeclared data tree from a source folder.
@@ -254,6 +254,22 @@ Add-Item 'agent-rules' 'A rules file the coding agents read' $false `
254
254
  $(if ($hasAgentRules) { (@($inv.docs | Where-Object { $_ -match '(?i)^(agents|claude)\.md$' }) -join ', ') } else { 'none' }) `
255
255
  '' 'Without it every agent re-derives the project rules, and each one derives them differently.'
256
256
 
257
+ # A port copies refs. Work that is in no ref is work a port silently drops.
258
+ $unreachable = @(Get-AdoptProp $inv.git 'unreachableCommits' @())
259
+ $stashCount = [int](Get-AdoptProp $inv.git 'stashes' 0)
260
+ $wtDirty = @(@(Get-AdoptProp $inv.git 'worktrees' @()) | Where-Object { $_.dirtyFiles -and $_.dirtyFiles -gt 0 })
261
+ Add-Item 'work-is-in-a-ref' 'Every commit is reachable from a branch or a tag' $true `
262
+ $(if (-not $gitUsable) { 'ASK' } elseif ($unreachable.Count -gt 0) { 'GAP' } elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) { 'ASK' } else { 'PASS' }) `
263
+ $(if ($unreachable.Count -gt 0) {
264
+ "DETACHED and unreferenced: " + ((@($unreachable | ForEach-Object { "$($_.path) ($($_.commits) commits)" })) -join '; ')
265
+ }
266
+ elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) {
267
+ "no unreachable commits, but $stashCount stash(es) and $($wtDirty.Count) worktree(s) with uncommitted files - none of that is in a ref either"
268
+ }
269
+ else { 'every worktree is on a branch; no stashes' }) `
270
+ 'For each stash and each uncommitted file: does that work matter? Anything that does needs a branch before the port.' `
271
+ 'A clone copies refs/heads/* and refs/tags/*. A detached HEAD is neither, so its commits are not carried - and nothing points at them, so git may collect them. `git branch <name> <sha>` costs nothing and fixes it.'
272
+
257
273
  Add-Item 'no-junctions' 'No junction or symlink inside the tree git manages' $false `
258
274
  $(if ($junctionsInTree.Count -eq 0) { 'PASS' } else { 'GAP' }) `
259
275
  $(if ($junctionsInTree.Count -eq 0) { 'none outside dependency folders' } else { "$($junctionsInTree.Count): $((@($junctionsInTree | Select-Object -First 3 | ForEach-Object { $_.path })) -join ', ')" }) `
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.116",
2
+ "poolVersion": "1.3.117",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.116",
4
+ "antigravity-plugin": "1.3.117",
5
5
  "antigravity-plugin-extras": "1.3.36",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",