Auto-delete archived worktrees after a configurable period#214
Merged
Conversation
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
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.
7 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
[Worktree.ID]to[Worktree.ID: Date]with automatic legacy migrationAutoDeletePeriodenum (1/3/7/14/30 days) replacing rawInt?to make illegal states unrepresentableRepositoryPersistenceClientinstead of direct@Sharedaccess in SettingsFeatureTest plan
AutoDeletePerioddecoding rejects invalid values (0 in release, negative, unrecognized)normalizeDictionaryKeyshandles collisions, empty keys, and path resolutionarchivedWorktreeIDstoarchivedWorktreeDatesopenRepositoriesFinishedtriggers auto-delete symmetrically withrepositoriesLoadedCloses #212