Skip to content

fix(extensions): complete legacy layout migration on repo upgrade (swamp-club#160)#1224

Merged
stack72 merged 2 commits intomainfrom
worktree-valiant-inventing-origami
Apr 24, 2026
Merged

fix(extensions): complete legacy layout migration on repo upgrade (swamp-club#160)#1224
stack72 merged 2 commits intomainfrom
worktree-valiant-inventing-origami

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 24, 2026

Summary

Closes swamp-club#160.

swamp repo upgrade was silently deleting user working-tree files when it detected extensions tracked at a legacy on-disk layout. The two-phase migration added in #1186 renamed gen-1 paths to gen-2, then deleted the gen-2 files expecting the user to run swamp extension install afterward to restore them. No warning, no prompt, no automatic follow-up.

The fix collapses the split between the two primitives that owned "make extension state correct":

  • extensionInstall now treats legacy-layout entries as needing re-pull (not just missing files), installs them into the per-extension subtree, and sweeps the original legacy paths only after a successful install.
  • repo upgrade invokes extensionInstall directly as its final step.
  • migrateExtensionLayout and its two phase methods are deleted (~280 LOC) along with ExtensionLayoutMigrationSummary, pruneEmptyDirsUp, and the extensionMigration result field.

Net LOC: −170 despite adding the install-pass wiring, a test seam, a shared factory, and several new tests.

User-visible behavior

  • swamp repo upgrade now completes the migration end-to-end. No manual swamp extension install follow-up required for the common case.
  • On install failure (network down, extension yanked, auth missing), legacy files are preserved and the error names the failed extensions with a recovery command.
  • swamp extension install also migrates legacy-layout repos. Previously a silent no-op on legacy state — positive side effect of unifying the two primitives.

Latent bug caught during verification

Running an end-to-end manual test against @swamp/aws/s3 surfaced a second bug: empty _lib/ directories were being stranded after the sweep. Root cause: resolveRepoDir returns "." by default, which propagated through join(".", "path") → "path" (relative), and cleanupEmptyParentDirs's startsWith(stopAtDir) check silently failed against relative paths. The factory now absolutizes repoDir up front so downstream filesystem helpers always see absolute paths. Added a regression test asserting nested parent dirs are pruned.

This one wouldn't have been caught by unit tests alone — my initial unit tests used absolute tmpDir from Deno.makeTempDir, which masked the relative-path issue. Running against a real extension with a nested _lib/ found it immediately.

Test plan

  • deno check — clean
  • deno lint — clean (1081 files)
  • deno fmt — clean (1199 files)
  • deno run test — 4728 pass, 0 fail, 1 ignored (added 8 new tests)
  • deno run compile — binary produced
  • Manual E2E happy path: @swamp/aws/s3 seeded as gen-1 legacy, swamp repo upgrade run; all 11 model files migrated to .swamp/pulled-extensions/@swamp/aws/s3/models/…, filesChecksum anchor matches original pull byte-for-byte (a9378bf483…), legacy paths (including nested _lib/) fully swept.
  • Manual E2E failure path: lockfile referencing @me/launchd-daemon (not in registry); swamp repo upgrade exits non-zero with clear error naming the extension + recovery command; all 4 legacy files preserved on disk.
  • Manual E2E via extension install: same legacy seed; swamp extension install also migrates successfully (positive side effect verified).

Notes for reviewers

  • The swamp extension install behavior change (no-op → auto-migrate on legacy repos) is intentional; documented in the swamp-repo skill.
  • There is no CHANGELOG.md in this repo, so the user-visible release notes live in this PR description.
  • I skipped the integration test against a real/fake registry as out of scope for this PR; unit coverage + manual verification cover the critical paths. A follow-up would be reasonable.
  • Private/auth-gated extensions are not directly verified end-to-end — the factory uses the same ExtensionApiClient as swamp extension install, so coupling should hold, but worth spot-checking if a reviewer has a private extension in a test lockfile.

🤖 Generated with Claude Code

…amp-club#160)

`swamp repo upgrade` was silently deleting user working-tree files when
it detected extensions tracked at a legacy on-disk layout. The two-phase
migration added in PR #1186 renamed gen-1 paths (`extensions/<type>/…`)
to gen-2 paths, then deleted the gen-2 files expecting the user to run
`swamp extension install` afterward to restore them. No warning, no
prompt, no automatic follow-up — just files gone and a terse info log.

The fix collapses the split between the two primitives that owned
"make extension state correct." Previously `migrateExtensionLayout`
knew about layouts but not about pulling, and `extensionInstall` knew
about pulling but not about layouts. This gap is where the user's files
fell. Now `extensionInstall` widens its "needs install" predicate to
also treat legacy-layout entries as needing re-pull, installs them into
the per-extension subtree, and sweeps the original legacy paths only
after a successful install. `repo upgrade` invokes `extensionInstall`
directly as its final step.

## User-visible behavior

- `swamp repo upgrade` now completes the migration end-to-end. No manual
  `swamp extension install` follow-up required for the common case.
- On install failure (network down, extension yanked, auth missing),
  legacy files are **preserved** and the error names the failed
  extensions with a recovery command.
- `swamp extension install` now also migrates legacy-layout repos
  (previously a silent no-op on legacy state). This is the positive
  side-effect of unifying the two primitives.

## Architecture

- `migrateExtensionLayout`, `migrateExtensionLayoutPhase1`,
  `migrateExtensionLayoutPhase2`, `pruneEmptyDirsUp`,
  `ExtensionLayoutMigrationSummary`, and the `extensionMigration`
  result field are deleted (~280 LOC). Their responsibility moves
  into `extensionInstall` where it naturally belongs.
- `RepoUpgradeEvent` gains an `{ kind: "extensions"; event: ... }`
  envelope; the upgrade renderer delegates that envelope to the
  existing install renderer so output stays consistent with
  `swamp extension install` directly.
- New `InstallExtensionFn` test seam on `ExtensionInstallDeps` lets
  unit tests exercise the success path without a real tar archive.
- New `createExtensionInstallDeps(repoDir, logger)` shared factory,
  used by both `repo upgrade` and `extension install`.
- `needsInstallOrMigration` and `sweepLegacyPaths` are exported for
  direct unit testing.

## Latent bug caught during verification

While running an end-to-end manual test against `@swamp/aws/s3`,
empty `_lib/` directories were being stranded after the sweep. Root
cause: `resolveRepoDir` returns `"."` by default, which propagated
through `join(".", "path") → "path"` (relative), and
`cleanupEmptyParentDirs`'s `startsWith(stopAtDir)` check failed
against relative paths. The factory now absolutizes `repoDir` up
front so downstream filesystem helpers see absolute paths. Added a
regression test asserting nested parent dirs are pruned.

## Testing

- 4728 unit/integration tests pass (up from 4727; added 8 new tests)
- Type-check, lint, fmt all clean
- Manual end-to-end verification against `@swamp/aws/s3`:
  all 11 model files migrated to per-extension subtree, `filesChecksum`
  anchor matches original pull byte-for-byte, legacy paths (including
  nested `_lib/`) fully swept.
- Failure-mode verification: with a lockfile referencing an extension
  not in the registry, all legacy files are preserved and the user
  sees a clear error naming the extension + recovery command.

## Notes for reviewers

- `swamp extension install` behavior change (no-op → auto-migrate on
  legacy repos) is intentional and noted in the `swamp-repo` skill.
- There is no `CHANGELOG.md` in this repo — release notes belong in
  the PR description.
- Plan step for an integration test against a real/fake registry was
  skipped as out of scope; unit coverage + manual verification cover
  the critical paths. Filing as a follow-up would be reasonable.

Closes swamp-club#160

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI UX Review

Blocking

  1. swamp repo upgrade --json emits two JSON objectssrc/presentation/renderers/repo_init.ts

    JsonRepoUpgradeRenderer delegates extensions events to installHandlers, which is the JSON extension-install renderer. That renderer unconditionally calls console.log(JSON.stringify(e.data, null, 2)) when it sees completed. Then the upgrade completed handler prints a second JSON object for the upgrade result.

    On every swamp repo upgrade --json run the output is now two separate objects:

    {
      "entries": [...],
      "installed": 0,
      "migrated": 0,
      "upToDate": 2,
      "failed": 0
    }
    {
      "path": "...",
      "previousVersion": "...",
      ...
    }

    This is invalid JSON and breaks any script that was parsing the output (e.g. swamp repo upgrade --json | jq .path now errors). Before this PR swamp repo upgrade --json emitted exactly one object.

    Suggested fix: In JsonRepoUpgradeRenderer, suppress the install-completed output by not forwarding the inner completed event to the install renderer — or forward it only when there were actual installs/migrations, and fold the counts into the upgrade JSON object instead. The simplest fix is to skip the completed dispatch in dispatchInstallEvent when called from the upgrade JSON renderer (e.g. pass a { skip: ['completed'] } option, or just omit the completed case from the JSON renderer's dispatchInstallEvent call).

Suggestions

  1. Log mode: "All extensions up to date." appears before the upgrade summarysrc/presentation/renderers/repo_init.ts

    On a healthy repo with no migrations needed, swamp repo upgrade now prints:

    All extensions up to date.
    Repository upgraded: /path/to/repo
      Version: 1.0.0 → 2.0.0
      ...
    

    The extension-install summary fires before the upgrade summary and lacks any contextual framing (no "Extension layout:" prefix or similar). Users who don't know that upgrade now runs an install pass will be confused by the extra leading line. Similarly, repos with no extensions print "No extensions in lockfile." before the upgrade summary, which is surprising.

    Consider suppressing the no-op install completion message (when installed === 0 && migrated === 0 && failed === 0) within the upgrade log renderer, or prefixing it with something like Extensions: so it reads as a sub-step of the upgrade.

Verdict

NEEDS CHANGES — swamp repo upgrade --json now emits two JSON objects, breaking machine-readable consumers. The log-mode output is cosmetically noisy but functional.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Blocking Issues

  1. Libswamp import boundary violation in src/cli/create_extension_install_deps.ts:27: imports ExtensionInstallDeps from the internal path ../libswamp/extensions/install.ts instead of ../libswamp/mod.ts. Per CLAUDE.md: "CLI commands and presentation renderers must import libswamp types and functions from src/libswamp/mod.ts — never from internal module paths." The type is already re-exported from mod.ts (line 689), so this is a one-line fix:

    -import type { ExtensionInstallDeps } from "../libswamp/extensions/install.ts";
    +import type { ExtensionInstallDeps } from "../libswamp/mod.ts";

Suggestions

  1. JSON mode outputs two documents for swamp repo upgrade --json: The extensions event dispatches to the install JSON renderer, which calls console.log(JSON.stringify(e.data)) on the completed event. Then the upgrade's own completed handler emits a second JSON document. Pre-PR, the extension migration data was embedded in RepoUpgradeData, so JSON consumers got one document. Now they get two (extension install data, then upgrade data). Consider either (a) suppressing the install completed output in the JSON upgrade renderer, or (b) documenting this as an intentional change.

  2. dispatchInstallEvent lacks an exhaustiveness check (src/presentation/renderers/repo_init.ts:152): The switch on event.kind has no default case with a never assertion. If a new event kind is added to ExtensionInstallEvent, the compiler won't flag the missing branch. Adding default: { const _: never = event; } is a one-liner that prevents silent drops.

What looks good

  • Collapsing the split between migration and install into a single extensionInstall call is a clean DDD improvement — "bring extension state into alignment with the lockfile" is one domain concept, not two. The removal of ~280 LOC from repo_service.ts and the corresponding test migration to install_test.ts keeps responsibility where it belongs (the application/libswamp layer orchestrates, not the domain service).
  • The sweepLegacyPathsneedsInstallOrMigration separation gives each function a single job with clean test coverage (8 new tests covering install, migration, failure-preserves-legacy, NotFound tolerance, mixed lockfiles, and empty-dir pruning).
  • The resolve(repoDir) absolutization fix in createExtensionInstallDeps addresses a real latent bug with relative-path startsWith comparisons, and the regression test for nested parent dir pruning locks it down.
  • Test seam via installExtensionFn is well-designed — avoids needing a real registry/tar in unit tests while still exercising the full migrate-then-sweep flow.
  • Renderer delegation via dispatchInstallEvent keeps upgrade output consistent with standalone swamp extension install output.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

  1. src/presentation/renderers/repo_init.ts:131-136repo upgrade --json emits two JSON objects, breaking the --json contract

    The JsonRepoUpgradeRenderer now dispatches extension install events to JsonExtensionInstallRenderer, whose completed handler does console.log(JSON.stringify(e.data, null, 2)). Then the upgrade's own completed handler also does console.log(JSON.stringify(e.data, null, 2)). This produces two top-level JSON objects on stdout:

    { "entries": [...], "installed": 0, "migrated": 0, ... }
    { "path": "...", "previousVersion": "...", "newVersion": "...", ... }

    Any downstream tool doing swamp repo upgrade --json | jq . or parsing the output as a single JSON value will break. The old renderer only ever emitted one object (the upgrade data, with extensionMigration embedded in it).

    Breaking example: swamp repo upgrade --json on any repo with a lockfile (even if all extensions are up-to-date), then piping to jq or any single-document JSON parser.

    Suggested fix: Either (a) suppress the extension install JSON output during upgrade by passing a no-op renderer, or (b) accumulate the extension install data and embed it in the RepoUpgradeData object so --json still emits a single document, or (c) for the JSON upgrade renderer, replace the extensions handler with a no-op (only emit the consolidated result in completed).

Medium

  1. src/libswamp/extensions/install.ts:152-175sweepLegacyPaths failure after successful installExtension creates unrecoverable orphaned files

    If installExtension succeeds (new files written, lockfile rewritten to current-layout paths) but sweepLegacyPaths throws on a non-NotFound error (e.g., permission denied), the catch block marks the entry as "failed". In repoUpgrade (init.ts:275-289), this triggers the error path with message "Legacy files have been preserved. Re-run 'swamp repo upgrade'."

    But the lockfile was already rewritten by installExtension — its files[] now points at the new per-extension subtree. On re-run, needsInstallOrMigration reads the updated lockfile, finds all files present at current-layout paths, returns "up_to_date", and the orphaned legacy files are never swept. The error message's advice to re-run is a dead end.

    Breaking example: Extension @org/ext has a legacy file at extensions/models/thing.ts owned by root. installExtension succeeds (writes .swamp/pulled-extensions/@org/ext/models/thing.ts, rewrites lockfile). Deno.remove("extensions/models/thing.ts") fails with permission denied. Entry is "failed", user told to re-run. Re-run skips it because lockfile says current layout, all files present.

    Suggested fix: Either (a) snapshot the lockfile before calling installExtension and restore it on sweep failure so the next run retries the migration, or (b) catch sweep failures separately from install failures and report them as a non-fatal warning (the migration succeeded, just the cleanup didn't).

  2. src/cli/commands/repo_init.ts:146-149 — Extension install deps built from pre-upgrade marker, stale if --tool changes

    createExtensionInstallDeps(repoDir, ...) is called before repoUpgrade() runs. It reads the current marker to derive skillsDir via resolveSkillsDir(repoDir, marker.tool). If the user runs swamp repo upgrade --tool cursor on a repo that was previously tool: "claude", the upgrade changes the marker to cursor, but the install deps still have skillsDir computed from "claude". Extension skills get installed to the wrong tool directory.

    Breaking example: swamp repo upgrade --tool cursor on a claude-initialized repo with extensions that provide skills.

    Suggested fix: Build extensionInstallDeps lazily inside repoUpgrade after deps.upgrade() completes, or re-read the marker post-upgrade to compute the correct skillsDir.

Low

  1. src/libswamp/extensions/install.ts:199-219needsInstallOrMigration does not short-circuit on first legacy classification

    The function continues iterating and stat-ing all remaining files after finding the first legacy file, when it could return "migrate" early (similar to how it short-circuits on the first missing file). Not a correctness issue — just unnecessary I/O on large extension file lists with legacy paths.

Verdict

FAIL — The --json output regression (#1) is a contract-breaking change that will silently corrupt machine-readable output for all repo upgrade --json consumers. Fix that, and the remaining findings are addressable or acceptable.

…port path

Two blocking issues from CI review on PR #1224:

1. `swamp repo upgrade --json` emitted two top-level JSON objects because
   `JsonRepoUpgradeRenderer` delegated the `extensions` envelope to
   `JsonExtensionInstallRenderer`, whose `completed` handler does its own
   `console.log(JSON.stringify(...))`. Downstream parsers that read stdout
   as a single document broke.

   Fix: JSON mode captures the install summary internally and folds it
   into the upgrade's final `completed` object as `extensionInstall`. The
   log renderer's delegation pattern is unchanged (it uses `logger.info`,
   not `console.log`, so no double-emit risk).

2. `create_extension_install_deps.ts` imported `ExtensionInstallDeps`
   from the internal path `../libswamp/extensions/install.ts` instead of
   `../libswamp/mod.ts`. CLAUDE.md explicitly requires CLI imports from
   `mod.ts`. The type was already re-exported there.

Added a regression test that runs the JSON renderer through a full event
sequence and asserts the stdout parses as exactly one JSON object, with
the install summary present under `extensionInstall`.

Verified end-to-end against `@swamp/aws/s3`: `swamp repo upgrade --json`
emits a single parseable JSON object with `extensionInstall.migrated: 1`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Well-structured PR that correctly consolidates the two-phase legacy migration into the existing extensionInstall primitive. The approach — detect legacy layout, re-pull via the normal install path, then sweep orphaned files — is cleaner than the deleted migrateExtensionLayout approach and eliminates the "silent delete + manual follow-up" UX problem.

Blocking Issues

None.

Suggestions

  1. Stale doc comment on PULLED_TYPE_DIRS (src/libswamp/extensions/layout.ts:42-46): The JSDoc still references "the phase-two migration in RepoService" which was deleted in this PR. Worth updating the comment to reference sweepLegacyPaths / classifyExtensionFile as the remaining consumers.

  2. Shared alreadyPulled set: Each createInstallContext call in createExtensionInstallDeps creates a fresh new Set(). If two lockfile entries share a transitive dependency, it will be pulled twice. This is pre-existing behavior (the old extension_install.ts command had the same pattern), so not a regression — but worth noting as a potential optimization for a follow-up.

  3. dispatchInstallEvent exhaustiveness: The switch statement handles all current event kinds but lacks a default: { const _: never = event; } guard. TypeScript's narrowing would catch a new unhandled variant at deno check time anyway, so this is minor — an explicit exhaustiveness check would make the intent clearer.

What looks good

  • DDD alignment: The composition root (createExtensionInstallDeps) lives correctly in the CLI layer, wiring infrastructure (API client, marker repo) into a domain-facing deps interface. The domain service (RepoService) is properly slimmed — migration logic moved to the application service (extensionInstall) where it belongs as cross-aggregate orchestration.
  • Import boundary: All CLI and presentation imports go through src/libswamp/mod.ts. The libswamp/repo/init.ts../extensions/install.ts import is internal to libswamp, which is correct.
  • Test coverage: 8 new tests covering needsInstallOrMigration, sweepLegacyPaths, the full extensionInstall migration flow (happy path, mixed lockfile, failure preservation), and the JSON renderer's single-object invariant. The test seam (InstallExtensionFn) avoids coupling tests to a real registry.
  • Error handling: Legacy files are preserved on install failure, the error surfaces extension names and a recovery command, and NotFound is tolerated for interrupted-retry scenarios.
  • Relative path bug fix: Absolutizing repoDir in the factory prevents the startsWith comparison failure in cleanupEmptyParentDirs. The regression test for nested _lib/ directories covers this.
  • JSON output invariant: The JsonRepoUpgradeRenderer correctly captures the install summary and folds it into the single completed payload instead of delegating to JsonExtensionInstallRenderer (which would emit a second JSON object mid-stream).
  • License headers: Present on both new files.
  • Net -170 LOC with better behavior — good cleanup.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI UX Review

Blocking

None.

Suggestions

  1. Extra log noise on every repo upgradesrc/presentation/renderers/repo_init.ts delegates to the extension install renderer unconditionally, so every upgrade now prints Reading lockfile... and either All extensions up to date. or No extensions in lockfile. even when no migration is needed. These lines are already appropriate for swamp extension install, but they read oddly as part of swamp repo upgrade on a clean repo. Consider suppressing resolving/completed events in LogRepoUpgradeRenderer when migrated === 0 and failed === 0, or adding a guard in dispatchInstallEvent to skip no-op results.

  2. Upgrade success is silent when extension migration fails — in src/libswamp/repo/init.ts the completed event (which prints Upgraded swamp repository: 0.1.0 → 0.1.1) is never yielded when any extension fails to migrate. The base upgrade (marker, skills, settings) DID succeed, but the user only sees the extension error. They have no log confirmation that the repo itself was upgraded. A line like Upgraded swamp repository: X → Y; extension migration incomplete — re-run to finish. before the error would close that gap.

  3. swamp extension install failure summary omits a recovery commandsrc/presentation/renderers/extension_install.ts:78 warns {count} extension(s) failed to install. with names and errors, but gives no next-step command. swamp repo upgrade error message correctly says Re-run 'swamp repo upgrade'; parity here would be Re-run 'swamp extension install' once the issue is resolved. (Pre-existing gap, surfaced by this PR's unification — low priority.)

Verdict

PASS — JSON output is correct (single top-level object, extensionInstall folded in cleanly), error messages are actionable and name the affected extensions with a recovery command, log-mode output is consistent with existing patterns. Suggestions above are all cosmetic.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

None found. The core install→sweep sequence is correct: legacy paths are snapshotted before installExtension rewrites the lockfile, the sweep only runs after a successful install (preserving legacy files on failure), and classifyExtensionFile prevents current-layout paths from being deleted. Error handling is clean — per-entry failures are captured without aborting the loop, and infrastructure errors bubble up through the generator's try/catch.

Medium

  1. cleanupEmptyParentDirs silently fails on relative repoDir — patched at callsite only (src/cli/create_extension_install_deps.ts:49, src/infrastructure/persistence/directory_cleanup.ts:37). The factory calls resolve(repoDir) to absolutize, which fixes the immediate bug (empty _lib/ dirs stranded). But cleanupEmptyParentDirs's guard currentDir.startsWith(stopAtDir) still silently no-ops for relative paths. Any future caller of sweepLegacyPaths that passes a non-absolute repoDir (e.g. "." from resolveRepoDir's default) would reintroduce the stranded-directory bug. Consider adding resolve() inside sweepLegacyPaths or cleanupEmptyParentDirs itself as a follow-up.

Low

  1. sweepLegacyPaths calls cleanupEmptyParentDirs unconditionally after Deno.remove NotFound (src/libswamp/extensions/install.ts:248). When the file was already gone, the cleanup walks parent dirs that may themselves not exist. cleanupEmptyParentDirs handles this gracefully (its own NotFound catch), so this is just unnecessary I/O, not a bug.

  2. dispatchInstallEvent routes error events to install renderer which throws UserError (src/presentation/renderers/repo_init.ts:183). Currently harmless because extensionInstall never yields error events (per-entry failures are captured in completed). But if a future refactor adds error events to extensionInstall, the throw inside the extensions handler would bypass the upgrade renderer's own error handler, producing an uncontrolled exception path.

  3. createExtensionInstallDeps reads marker before upgrade runs (src/cli/commands/repo_init.ts:111-114). The factory snapshots modelsDir and tool from the pre-upgrade marker. If upgrade() modifies these fields, the extension install pass uses stale values. In practice modelsDir doesn't change during upgrade, making this theoretical only.

Verdict

PASS. The migration logic is sound: install-before-sweep ordering prevents data loss, the failure path preserves legacy files, and the JSON single-object invariant is tested. The relative-path fragility in cleanupEmptyParentDirs is worth a defensive follow-up but doesn't block — the production callsite is correctly absolutized. Net code reduction with better behavior; well tested.

@stack72 stack72 merged commit bd275a8 into main Apr 24, 2026
10 checks passed
@stack72 stack72 deleted the worktree-valiant-inventing-origami branch April 24, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant