Skip to content

Auto-delete archived worktrees after a configurable period#214

Merged
sbertix merged 1 commit intomainfrom
sbertix/auto-delete-archives
Apr 2, 2026
Merged

Auto-delete archived worktrees after a configurable period#214
sbertix merged 1 commit intomainfrom
sbertix/auto-delete-archives

Conversation

@sbertix
Copy link
Copy Markdown
Collaborator

@sbertix sbertix commented Apr 2, 2026

Summary

  • Migrate archived worktree tracking from [Worktree.ID] to [Worktree.ID: Date] with automatic legacy migration
  • Introduce AutoDeletePeriod enum (1/3/7/14/30 days) replacing raw Int? to make illegal states unrepresentable
  • Auto-delete expired archived worktrees on repository load and when the setting changes
  • Show confirmation alert when shortening the auto-delete window would immediately delete existing worktrees
  • Route archived dates reads through RepositoryPersistenceClient instead of direct @Shared access in SettingsFeature

Test plan

  • Verify expired worktrees are deleted on repository load
  • Verify non-expired, main, and in-progress worktrees are skipped
  • Verify settings alert flow (shorten with affected, shorten without, widen, disable, enable, cancel, confirm, plural wording)
  • Verify AutoDeletePeriod decoding rejects invalid values (0 in release, negative, unrecognized)
  • Verify normalizeDictionaryKeys handles collisions, empty keys, and path resolution
  • Verify legacy migration from archivedWorktreeIDs to archivedWorktreeDates
  • Verify openRepositoriesFinished triggers auto-delete symmetrically with repositoriesLoaded

Closes #212

Migrate archived worktree tracking from a flat ID list to a
[Worktree.ID: Date] dictionary so each archive timestamp is preserved.
Add a new AutoDeletePeriod enum (1/3/7/14/30 days) to replace the raw
Int? setting, making illegal values unrepresentable. Expired worktrees
are automatically deleted on repository load or when the setting is
changed. Shortening the window shows a confirmation alert when existing
worktrees would be immediately affected.
@sbertix sbertix merged commit 666d440 into main Apr 2, 2026
1 check passed
@sbertix sbertix deleted the sbertix/auto-delete-archives branch April 2, 2026 21:29
sbertix added a commit that referenced this pull request Apr 18, 2026
…erve first-load curation

- Migrator gates idempotency on `schemaVersion >= 1` (not just file
  existence) and stamps `1` on successful write. Failed writes leave
  legacy sources intact so the next launch retries instead of latching
  on an empty mutation-written file.
- Migrator seeds sections from `settingsFile.repositoryRoots` as the
  baseline and applies `repositoryOrderIDs` as a move-to-top override,
  so repos with only a main worktree still get an entry in sidebar.json
  and the user-visible repo order is preserved.
- All legacy-path inputs flow through `RepositoryPathNormalizer.normalize(_:)`
  so migrated IDs match the canonical `URL(fileURLWithPath:).standardizedFileURL`
  shape that live `Repository.id` / `Worktree.id` use.
- New 3-source worktree-base resolver: given `legacyRoots`, the migrator
  builds candidate paths covering the root itself, the global
  `settingsFile.global.defaultWorktreeBaseDirectoryPath` override, and
  the per-repo `<root>/supacode.json` override (read synchronously via
  `RepositoryLocalSettingsStorage`). Longest-prefix match returns the
  owning root, so pinned/archived worktrees placed under any of the
  three bases resolve correctly.
- Migrator reads pre-#214 `archivedWorktreeIDs: [String]` in addition
  to `archivedWorktreeDates: [String: Date]`, stamping `Date.now` on
  each ID-only entry. Direct upgraders from pre-#214 builds no longer
  lose archive status.
- Silent-drop warnings became named info-level log lines so dropped
  orphans are greppable.
- `reconcileSidebarState` (renamed from `pruneSidebarState`) gains
  `pruneLivenessAgainstRoster`; on the first `.repositoriesLoaded`
  (`isInitialLoadComplete == false`) the liveness prune is skipped so
  migrated curation survives a transient roster view. Always preserves
  `.archived` + `.pinned` as stripped tombstones for repos that drop
  out of `availableRepoIDs`.
- `SettingsFeature` auto-delete preflight routes through
  `@Shared(.sidebar).archivedWorktrees` via an
  `unimplemented(placeholder: [])`-guarded client shim. Forgetting the
  override fails loud in debug/tests and falls back safely in release.
- Drop the redundant first branch of `isWorktreePinned`.
- Test coverage: pre-#214 archive migration, baseline order from
  repositoryRoots, legacyOrder override, non-canonical path
  normalization, first-load reconcile preservation, default/global/
  per-repo worktree-base resolution, pinned/archived under
  `~/.supacode/repos/<name>/`. Existing tests keep hybrid `.off` +
  post-hoc `#expect` on the noisy archive chain.
sbertix added a commit that referenced this pull request Apr 18, 2026
* Scaffold bucketed SidebarState + SharedKey

Introduce SidebarState — a nested OrderedDictionary<Repository.ID,
Section> where each Section owns buckets: OrderedDictionary<Bucket.ID,
Bucket>, each Bucket owns items: OrderedDictionary<Worktree.ID, Item>,
and each Item carries only an optional archivedAt timestamp.
Bucket.ID is an enum with .pinned / .unpinned / .archived; readers
access everything via keyed lookup, so bucket iteration order is
never a correctness contract.

Mutations go through typed primitives that take full coordinates
(repo + worktree + source bucket) so every helper is O(1) by
construction. move, insert, archive, unarchive, remove, reorder,
plus `currentBucket(of:in:)` + `removeAnywhere(worktree:in:)` for
the "don't know the bucket" callers (archive, delete). Callers
always know the source bucket from their reducer context, which
keeps the helper layer tiny and eliminates defensive scans.

Persisted atomically to `~/.supacode/sidebar.json` via SidebarKey
mirroring LayoutsKey. The persisted URL goes through the new
`\.sidebarFileURL` dependency so tests can point the SharedKey at
a temp-directory URL instead of the user's real home path. Decode
failures rename the corrupt file to `sidebar.json.corrupt-<ISO8601>`
at warning log level before falling back to empty, so the next save
can't silently overwrite recoverable bytes.

SidebarStateTests (16) pin mutation + Codable semantics + the
on-disk bucket key strings. SidebarPersistenceKeyTests exercise
the corrupt-file rename path against an isolated temp dir.

* Add boot-time migrator from legacy sidebar keys to sidebar.json

Fold the six legacy sources — three UserDefaults appStorage blobs
(sidebarCollapsedRepositoryIDs, repositoryOrderIDs,
worktreeOrderByRepository, lastFocusedWorktreeID), one additional
appStorage dict (archivedWorktreeDates), and settingsFile
.pinnedWorktreeIDs — into the bucketed SidebarState on first launch
of the new schema.

Idempotency gates solely on whether sidebar.json exists. The
migrator writes sidebar.json first via the atomic
SettingsFileStorage, then clears legacy UserDefaults blobs. A
crash before the write lands leaves legacy sources intact for the
next launch to retry. A crash between write and clear leaves
orphan blobs that no live reader touches (the file gate short-
circuits before any legacy read runs), so they're inert.

Orphan pinned / archived worktrees whose owning repo isn't in the
legacy row-order data are placed by prefix-matching worktree paths
against settingsFile.repositoryRoots (longest match wins, trailing
slashes stripped so legacy roots like "/tmp/repo-a/" still match
the worktree path). Unplaceable IDs log a warning with the drop
count instead of disappearing silently. The file-exists check is
a closure parameter so tests can stub it without subclassing
FileManager.

SidebarPersistenceMigratorTests cover happy-path, noop-when-file-
exists, fresh install, orphan-pinned rescue via prefix match,
longest-nested-root picks, non-parent-prefix rejection, trailing-
slash root handling, and the translate() scheme guard.

* Wire reducer + views onto bucketed SidebarState

Retire the five legacy sidebar-state slices — three
@shared(.appStorage) blobs (sidebarCollapsedRepositoryIDs,
repositoryOrderIDs, worktreeOrderByRepository, lastFocusedWorktreeID),
one SettingsFile slice (pinnedWorktreeIDs via PinnedWorktreeIDsKey),
and one standalone store (archivedWorktreeDates via
ArchivedWorktreeDatesClient) — in favour of a single @shared(.sidebar)
that owns them all.

Every co-mutating reducer action folds into one state.$sidebar.withLock
so the SharedKey emits a single atomic file update. Archive, pin,
and unpin use `currentBucket(of:in:)` to resolve the source bucket
at the action boundary and pass it into the O(1) mutation API; the
worktree-deleted cleanup uses `removeAnywhere(worktree:in:)` since
the worktree is going away entirely. unarchiveWorktree reads the
owning repository via `repositoryID(containing:)` and forwards to
`sidebar.unarchive`, which drops from `.archived` and reinserts at
the top of `.unpinned`.

pruneSidebarState runs on every `.repositoriesLoaded`: rebuilds
`sections` once, drops vanished/main worktrees from `.pinned` /
`.unpinned`, preserves `.archived` items unconditionally (they ARE
the archive record), and seeds a default `.unpinned` entry for
every live non-main worktree not yet curated. An equality gate
short-circuits the withLock + atomic file write when the rebuilt
`sections` matches the current one — branch-flutter reloads no
longer thrash the disk.

AppFeature's two saveLastFocusedWorktreeID call-sites now write
directly to `$sidebar.withLock { $0.focusedWorktreeID = ... }` via
the sibling scope on RepositoriesFeature.State.

WorktreeRowsView no longer filters rows by linked status — no such
concept on this branch — so the pin / archive / delete affordances
apply to every worktree row.

Tests covering the retired persistence client paths are removed;
reducer tests are rewritten to assert against the bucketed shape
(state.sidebar.sections[repo]?.buckets[.pinned]?.items[wt]),
flipping to exhaustivity-off where the @shared(.sidebar) diff would
otherwise drown out the per-action assertions.

* Harden sidebar migration: seed from roots, normalize, tombstone, preserve first-load curation

- Migrator gates idempotency on `schemaVersion >= 1` (not just file
  existence) and stamps `1` on successful write. Failed writes leave
  legacy sources intact so the next launch retries instead of latching
  on an empty mutation-written file.
- Migrator seeds sections from `settingsFile.repositoryRoots` as the
  baseline and applies `repositoryOrderIDs` as a move-to-top override,
  so repos with only a main worktree still get an entry in sidebar.json
  and the user-visible repo order is preserved.
- All legacy-path inputs flow through `RepositoryPathNormalizer.normalize(_:)`
  so migrated IDs match the canonical `URL(fileURLWithPath:).standardizedFileURL`
  shape that live `Repository.id` / `Worktree.id` use.
- New 3-source worktree-base resolver: given `legacyRoots`, the migrator
  builds candidate paths covering the root itself, the global
  `settingsFile.global.defaultWorktreeBaseDirectoryPath` override, and
  the per-repo `<root>/supacode.json` override (read synchronously via
  `RepositoryLocalSettingsStorage`). Longest-prefix match returns the
  owning root, so pinned/archived worktrees placed under any of the
  three bases resolve correctly.
- Migrator reads pre-#214 `archivedWorktreeIDs: [String]` in addition
  to `archivedWorktreeDates: [String: Date]`, stamping `Date.now` on
  each ID-only entry. Direct upgraders from pre-#214 builds no longer
  lose archive status.
- Silent-drop warnings became named info-level log lines so dropped
  orphans are greppable.
- `reconcileSidebarState` (renamed from `pruneSidebarState`) gains
  `pruneLivenessAgainstRoster`; on the first `.repositoriesLoaded`
  (`isInitialLoadComplete == false`) the liveness prune is skipped so
  migrated curation survives a transient roster view. Always preserves
  `.archived` + `.pinned` as stripped tombstones for repos that drop
  out of `availableRepoIDs`.
- `SettingsFeature` auto-delete preflight routes through
  `@Shared(.sidebar).archivedWorktrees` via an
  `unimplemented(placeholder: [])`-guarded client shim. Forgetting the
  override fails loud in debug/tests and falls back safely in release.
- Drop the redundant first branch of `isWorktreePinned`.
- Test coverage: pre-#214 archive migration, baseline order from
  repositoryRoots, legacyOrder override, non-canonical path
  normalization, first-load reconcile preservation, default/global/
  per-repo worktree-base resolution, pinned/archived under
  `~/.supacode/repos/<name>/`. Existing tests keep hybrid `.off` +
  post-hoc `#expect` on the noisy archive chain.
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.

Cleanup: delete worktrees after N days.

1 participant