Add folder (non-git) repository support + harden removal pipeline#257
Add folder (non-git) repository support + harden removal pipeline#257
Conversation
Classify repositories as git or folder at load time via `Repository.isGitRepository(at:)` (checks `.git` dir/file and the `.bare` / `.git` root-name conventions). Classification runs through `GitClientDependency.isGitRepository` so tests can override without touching the filesystem. A folder-kind repository has exactly one synthesized "main" `Worktree` with `id = "folder:" + path` (see `Repository.folderWorktreeID(for:)`), `workingDirectory == rootURL`. Selection and terminal binding reuse the standard `SidebarSelection.worktree(id)` machinery — nothing git-specific runs for folders. Folder-remove alert offers three buttons: "Remove from Supacode" (stop managing, disk untouched), "Delete from disk" (routes through `FileManager.trashItem`), and "Cancel". Both routes flow through the existing `.requestDeleteWorktree` → `.deleteWorktreeConfirmed` → `.deleteScriptCompleted` pipeline; handlers branch on intent so `gitClient.removeWorktree` is never called for folders, and `removingRepositoryIDs: [Repository.ID: RemovalIntent]` carries the routing intent even if a `git init` flips `isGitRepository` mid-delete (the kind flip surfaces a user-visible error). Settings hides Setup and Archive script sections for folders; Delete Script and user-defined scripts stay. `openRepositorySettings` (context menu + deeplink) routes folders to `.repositoryScripts` since there is no general pane for them. Guards added where git-only behavior would be wrong: `worktreesForInfoWatcher()` filters out folders; command palette dedupes folder rows to `Foo` instead of `Foo / Foo`; worktree deeplinks (`.archive`, `.unarchive`, `.pin`, `.unpin`) reject folder targets with an explanatory alert; `createRandomWorktreeInRepository` / `createWorktreeInRepository` / `.repoWorktreeNew` deeplink reject folders up front. `repository_removed` analytics is tagged with `kind`. UI copy flipped to "Add Repository or Folder" across menus, buttons, tooltips, and shortcut help. Tests cover classification, immediate appearance after `.openRepositories`, mixed git+folder load, watcher filter, both deletion paths (`.requestRemoveRepository` and `.requestDeleteWorktree`), blocking-script success + failure + cancellation, the kind-flip race, the folder-guard on new-worktree creation, the unified removal intent, and the Delete-from-disk path. `AGENTS.md` documents the invariants.
Route folder repositories through the same `SidebarItemView` /
`SidebarItemsView` / `SidebarItemContextMenu` stack as git
worktrees by introducing `SidebarItemModel.Kind` (.git / .folder).
Folders inherit the standard shimmer, running-script ping dot,
notification badge, and archiving/deleting icon flips for free;
`SidebarItemView` short-circuits on `.folder` to render an SF
`folder` icon at `.fontWeight(.semibold)` and skip branch-name /
PR / check-badge computation.
Rename `WorktreeRowModel` / `WorktreeRow` / `WorktreeRowsView` /
`WorktreeRowGroupView` / `WorktreeRowContainer` /
`WorktreeContextMenu` / `WorktreeRowSections` to
`SidebarItemModel` / `SidebarItemView` / `SidebarItemsView` /
`SidebarItemGroupView` / `SidebarItemContainer` /
`SidebarItemContextMenu` / `SidebarItemSections`, and promote
`SidebarRepositorySectionView` to `SidebarSectionView` behind a
new `SidebarRootView` dispatcher. The context menu renders
folder-specific copy inline ("Remove Folder…", "Archive Folder…"
disabled, "Folder Settings…") and drops "Copy as Branch Name".
Folder sidebar sections wrap `SidebarItemsView` in a `Section { … }
header: { EmptyView() }` so the sidebar applies the same
inter-group padding used between git sections; the explicit
`header:` form keeps NSOutlineView's row counts consistent in
sidebar-style Lists (headerless `Section { … }` overload crashed
during the AutoLayout commit).
Before this commit, deleting a single item mostly worked but bulk deletes — especially folder-row bulk unlink and section-level "Remove Repository" on a git repo — were a patchwork of partially-overlapping actions (`.requestDeleteWorktree`, `.confirmDeleteSidebarItem`, `.confirmRemoveRepository`, `.repositoryRemoved`, `folderRemovalEffect`). Each per-target removal fired its own `.repositoryRemoved(id)` which triggered a full async reload with `.cancellable(cancelInFlight: true)`, so siblings cancelled each other mid-flight and only one repo actually disappeared from the sidebar. This commit redesigns the pipeline around four things: 1. **One closed-sum disposition**. `DeleteDisposition` (`.gitWorktreeDelete | .gitRepositoryUnlink | .folderUnlink | .folderTrash`) replaces the former 2×2 split between user `DeleteAction` and recorded `RemovalIntent`, which could encode impossible combinations like `(git worktree, .unlink)` and gave `.delete` two different meanings depending on target kind. 2. **Batch aggregator keyed by id**. `activeRemovalBatches: [BatchID: ActiveRemovalBatch]` lets overlapping flows (e.g. a folder-trash still awaiting `FileManager.trashItem` while the user confirms a git-section remove on a different repo) each complete independently. `removingRepositoryIDs: [Repository.ID: RepositoryRemovalRecord]` couples the per-repo disposition with its owning batch id so lookups and draining stay in lockstep. Bulk and single both go through the same plural entry points (single = batch-of-1); section-level removes seed a batch-of-1 too. 3. **One verb, one terminal**. The public surface collapses to `.requestDeleteSidebarItems` → `.confirmDeleteSidebarItems(_, disposition:)` → per-target `.deleteSidebarItemConfirmed` → `.repositoryRemovalCompleted` (aggregator drain) → a single `.repositoriesRemoved([ids])` terminal per batch. Partial failures drain the batch without producing a terminal for the failed targets; the aggregator clears `removingRepositoryIDs` for failed targets so the sidebar row becomes clickable again. `signalFolderRemovalFailure` helper collapses the three duplicated failure tails in `.deleteScriptCompleted` (cancel / non-zero exit / success-kind-flip) into one site. 4. **Loud invariants**. Orphan `.repositoryRemovalCompleted` arrivals (no matching record) call `reportIssue` and defensively clear per-worktree trackers so a future regression can't silently leak state. Kind / disposition mismatches (e.g. `.unlink` against a git target) also `reportIssue` instead of dropping silently. UX improvements: - Single-target main-worktree delete (palette, hotkey, context-menu) now surfaces the same "Delete not allowed" alert the deeplink path already shows instead of silently no-opping. Bulk selections that mix main + other worktrees keep silently filtering so the rest of the batch proceeds. - Folder-removal confirmation copy fixed: singular branch now reads "…to stop managing the folder (it stays on disk)…" matching the plural branch; previous build shipped a grammatically broken "…to stop the folder…". Tests (840 passing, 11 new): - `concurrentFolderAndSectionBatchesEachCompleteIndependently` pins the overlap invariant — folder batch in-flight + concurrent section remove on a different repo must each produce their own `.repositoriesRemoved`. - `orphanCompletionReportsIssueAndFiresSoloTerminal` verifies `reportIssue` fires and worktree trackers get defensively cleared. - `requestDeleteMainWorktreeShowsNotAllowedAlertForSingleTarget` and `requestDeleteMainWorktreeInBulkRemainsSilentlyFiltered` split the old "silently filtered" assertion into the two behaviors we actually want. - `bulkFolderUnlinkTerminatesWithEmptyState` (renamed from the misnamed original — it asserts termination, not the race). - Exhaustivity-off tests restored key `store.receive(\.repositoriesRemoved)` / `\.delegate.selectedWorktreeChanged` assertions so future drops of the delegate fan-out don't pass silently. - `State.seedRemovalBatch(pending:)` helper wires record + batch for tests that drop straight into `.deleteSidebarItemConfirmed`. Also refreshed `AGENTS.md` folder-pipeline notes and stale `.repositoryRemoved` references in tests after the verb rename.
Removing a repo from Supacode used to leave its per-repo config block (scripts, run command, open action) orphaned in `settings.json` under `repositories[<path>]` — only the `repositoryRoots` array got pruned. Users who added and removed folders while exploring saw the dict accumulate dead entries forever. Add `RepositoryPersistenceClient.pruneRepositoryConfigs(_ ids:)` that drops the given ids from `settingsFile.repositories`, and call it from both removal paths: `.repositoriesRemoved` (normal removal) and `.removeFailedRepository` (failed-to-load cleanup). The two side-effects in the aggregator terminal are chained inside the same `.run` so they share the `CancelID.persistRoots` cancellation scope. Regression test: `folderRemovalPrunesRootsAndConfigsFromSettings` asserts both `saveRoots` (pruned root list) AND `pruneRepositoryConfigs` (removed repo id) fire on folder removal.
Tightens places where the new folder kind meets `isMainWorktree` geometry or async invariant boundaries. - Folder deeplink delete used to route into `deeplinkDeleteWorktreeEffect`'s `isMainWorktree` gate because folders have a synthetic main-worktree (`workingDirectory == rootURL`). CLI users got a misleading "main worktree not allowed" alert and couldn't remove folders via deeplink at all. Route folder targets through `.requestDeleteSidebarItems([target])` so the 3-button folder alert handles the confirmation. - `folderRemovalEffect` swallowed `FileManager.trashItem` errors and still reported `succeeded: true`, so a failed "Delete from disk" silently removed the folder from Supacode while leaving the on-disk content in place. On catch, dispatch `succeeded: false` and present a "Delete from disk failed" alert with the error description. - `.deleteScriptCompleted` derived `owningRepo` from `state.repositories.first(...)`; under a reload / `.removeFailedRepository` race the live repo could be gone and `signalFolderRemovalFailure` would bail out, orphaning the batch record forever. Rewrite the helper to resolve the repo id from `state.removingRepositoryIDs` (authoritative across reloads) using the `"folder:" + path` id convention, and drop the `owningRepo` parameter from call sites. - `.deleteSidebarItemConfirmed` now `reportIssue`s when a folder target arrives without a seeded `RepositoryRemovalRecord` — turns the accidental gate the deeplink's isMainWorktree check used to provide into a load-bearing guard that survives the deeplink fix above. - Folder archive / pin / unpin hotkeys used to silently no-op because the synthetic main-worktree satisfied `isMainWorktree` geometrically. Surface the same "Action not available" alert the deeplink layer already shows for these actions so hotkeys and the deeplink match. - Git section-removal confirmation message no longer says "Worktrees and the main repository folder stay on disk" — the word "folder" is now overloaded with the folder-repo concept. Rewrote to "The repository and its worktrees stay on disk." - Auto-delete of expired archived worktrees now `reportIssue`s and skips folder-synthetic worktree ids (`"folder:" + path`). Folders can't be archived by any user-reachable path today, but an archived-entry for a folder would hit the git delete path and fail confusingly — flag the invariant breach loudly. Tests (845 passing, 4 new): - `deleteFolderDeeplinkRoutesToFolderAlertPipeline` - `requestArchiveWorktreeForFolderShowsActionNotAvailable` (covers archive / pin / unpin) - `folderTrashFailureSurfacesAlertAndKeepsRepo` - `orphanCompletionSucceededFiresSoloTerminalAndRemovesRepo` (companion to the existing succeeded=false test)
Two real bugs plus three polish items in the folder-removal
pipeline. Everything here lives in the delete flow that this
branch introduces; nothing touches repositories outside that
path.
- `.repositoryRemovalCompleted(succeeded: false)` cleared
`removingRepositoryIDs` but left the per-worktree trackers
(`deletingWorktreeIDs` / `deleteScriptWorktreeIDs`) populated.
The empty-script folder-trash branch at
RepositoriesFeature.swift:1855 seeds `deletingWorktreeIDs`
before the effect fires; on trash failure the row rendered
`.deleting(inTerminal: false)` forever. Aggregator now mirrors
the orphan-path cleanup in its hot failure branch.
- `.deleteScriptCompleted` looked up `owningRepo` via
`state.repositories.first(...)` — a concurrent reload /
`.removeFailedRepository` pruning mid-script left the batch
record orphaned and sibling folder targets hung forever. The
exit=0 no-`owningRepo` branch now probes
`removingRepositoryIDs[String(worktreeID.dropFirst("folder:".count))]`
and routes through `signalFolderRemovalFailure` when a folder
record is still there, so the batch drains even when the repo
vanished from `state.repositories`.
- `.autoDeleteExpiredArchivedWorktrees` used to `reportIssue` on
folder-prefixed archived ids and `continue` without purging,
so every `.repositoriesLoaded` re-fired the same issue forever.
Now collects stray folder-prefixed entries upfront, reports
once, and purges them via `$sidebar.withLock` so the invariant
self-heals on first encounter.
- Bulk folder-trash failures used to each dispatch
`.presentAlert` and clobber `state.alert` in a last-write-wins
race. `.repositoryRemovalCompleted` now carries an optional
`failureMessage`; the aggregator collects them in
`ActiveRemovalBatch.failureMessagesByRepositoryID` and surfaces
one consolidated alert when the batch drains — single-target
keeps the existing UX, multi-target lists every failed folder
by name so users can see which stayed on disk.
- Four inlined "Action not available" alert constructions
(RepositoriesFeature.swift archive/pin/unpin + AppFeature.swift
deeplink) collapsed into one `folderIncompatibleAlert(action:)`
helper on the reducer, plus a `FolderIncompatibleAction` enum
that drives per-action copy (`"Archive not available"` /
`"Archive only applies to git repositories."`) so the user knows
which action they just tried.
- Removal-pipeline types + helpers moved to a new
`RepositoriesFeature+Removal.swift` extension —
`DeleteDisposition`, `RepositoryRemovalRecord`,
`ActiveRemovalBatch`, `BatchID`, `FolderIncompatibleAction`,
`seedRemovalBatch`, `folderRemovalEffect`,
`signalFolderRemovalFailure`, `folderIncompatibleAlert`,
`consolidatedTrashFailureAlert`,
`confirmationAlertForRepositoryRemoval`, and `messageAlert`.
The reducer body stays in the main file (Swift reducers can't
split), but the main file drops from 4,613 to 4,488 lines and
the removal domain is now self-contained in a 293-line file.
Tests (847 passing, +2):
- `bulkFolderTrashFailuresCoalesceIntoSingleAlert` — asserts the
consolidated alert names both failed folders.
- `deleteScriptCompletedDrainsBatchWhenOwningRepoVanished` —
exercises the repo-eviction race by seeding a batch for a repo
that doesn't live in `state.repositories`.
- `folderTrashFailureSurfacesAlertAndKeepsRepo` strengthened to
assert `deletingWorktreeIDs` / `deleteScriptWorktreeIDs` clear
on failure.
- `requestArchiveWorktreeForFolderShowsActionNotAvailable`
updated to match the new per-action copy.
… helpers A consolidated trash-failure alert could silently vanish on the same tick because downstream reducer actions unconditionally cleared `state.alert`. Rooting that out turned up two synergistic clear sites; both now leave the alert alone and let confirmation-style alerts be cleared by their own confirm handlers. - Alert clobber: dropped the unconditional `state.alert = nil` from `.deleteSidebarItemConfirmed` and `.reloadRepositories`. `.confirmDeleteSidebarItems` already clears its own confirm alert at entry, and `.reloadRepositories` is a data-layer refresh that has no business wiping whatever alert a parallel flow just set. Regression test `deleteSidebarItemConfirmedDoesNotClobberTerminalAlert` pins the new contract: a programmatic `.deleteSidebarItemConfirmed` (the shape `.autoDeleteExpiredArchivedWorktrees` uses) must leave a pre-existing terminal alert intact. - Cross-feature alert duplication: `FolderIncompatibleAction` grew a `.unarchive` case and the deeplink path at `AppFeature.swift:1029-1048` now builds its alert title/body from the shared `displayName`, so the inline switch that used to duplicate the copy word-for-word is gone. - Prefix coupling: `Repository.folderWorktreeIDPrefix` + `repositoryID(fromFolderWorktreeID:)` + `isFolderWorktreeID(_:)` on `Repository` replace four hand-parsed `"folder:"` prefix tests scattered across the reducer and `+Removal.swift`. - Typed outcome: `(succeeded: Bool, failureMessage: String?)` on `.repositoryRemovalCompleted` collapsed into a single `RemovalOutcome` sum (`.success | .failure(message: String?)`). The action can no longer express "success + failure message" and the aggregator reads `outcome.succeeded` / `outcome.failureMessage` directly. - Alert fallback + fragile lookup: `consolidatedTrashFailureAlert` now takes a pre-resolved `namesByRepositoryID` map that the aggregator snapshots from `state.repositories` at drain time (before `.repositoriesRemoved` prunes anyone). Single- and multi-target copy share one `displayName(for:)` helper that falls back to `URL(fileURLWithPath: id).lastPathComponent` instead of the inconsistent "the folder" / raw-path split. - Orphan cleanup scope: the orphan-path and hot-failure cleanups no longer iterate every worktree in `repository.worktrees` — they clear only the folder-synthetic worktree id derived from the repo id. The old wider sweep was safe today (only folder dispositions reach `.failure`) but would have quietly clobbered sibling git worktree trackers if a future caller ever fed a git repo id through this path. - Test hygiene: `State.seedRemovalBatch(pending:id:)` is now `#if DEBUG`-only so production callers can't accidentally corrupt the removal state machine. Tests (848 passing, +1). Build green.
- Split RemovalOutcome.failure into .failureSilent / .failureWithMessage(String) so the aggregator can't clobber a caller-owned alert (script failure, user cancel, kind-flip) with the consolidated trash-failure alert. When the batch drains with both a silent failure and trash messages, preserve the caller's alert and log the trash errors via repositoriesLogger.warning instead of overwriting. - Use switch outcome at the aggregator and orphan-completion call sites instead of if outcome.succeeded / if let outcome.failureMessage, restoring exhaustiveness at the critical drain path. - Drain the delete re-entry guard as a silent failure when the guard trips for a folder that has already been seeded into the batch aggregator, preventing the batch from hanging forever on a pending target. - Prune removingRepositoryIDs + activeRemovalBatches in applyRepositories, symmetric with the seven other trackers intersected against the live roster; report the invariant break via reportIssue so tests catch future regressions. - Log ambiguous .git-suffixed directories (missing HEAD / objects / refs) in Repository.isGitRepository(at:) so damaged bare clones are observable in telemetry without widening folder classification. - Dedup the folder-incompatible alert copy via FolderIncompatibleAction.alertCopy so AppFeature's deeplink handler and the reducer's folderIncompatibleAlert share one source of truth. - Move the seedRemovalBatch test helper out of #if DEBUG in the main module and into the test target, where @testable import supacode already gives it access to the internal state fields. - Add .help() tooltip to the Folder Settings context-menu button.
- Revert the delete re-entry guard drain introduced in the previous commit. The guard correctly short-circuits rapid second taps whose first-tap `.repositoryRemovalCompleted` is already going to drain the batch; emitting `.failureSilent` here double-drained pending and orphaned the first completion into reportIssue, breaking the idempotency regression test. - Silence `prunedRemovalTrackers` in `applyRepositories`. Firing reportIssue on every reload-during-removal flow also caught the synchronous `.gitRepositoryUnlink` path that tests seed deliberately, producing false failures. The symmetric prune still keeps state consistent with the seven other trackers intersected against the live roster; the orphan-completion branch in `.repositoryRemovalCompleted` already handles real liveness. - Extract the prune into `prunedRemovalTrackers` so applyRepositories stays under the 100-line function-body limit enforced by swiftlint. - Wrap the delete-confirmation subject line into a local binding so it fits within the 120-character line limit. - Delete the unused `makeFolderFixture` test helper; its tuple return tripped the large-tuple lint rule and nothing actually called it. - Rewrite the damaged-bare-clone HEAD write in `damagedBareCloneClassifiesAsFolderAndWarns` to use the non-optional `Data(_:)` initializer per the `non_optional_string_data_conversion` lint rule.
A folder-kind root that got deleted / moved / unmounted while Supacode was running silently became an empty folder repository in the sidebar: gitClient.isGitRepository returned false for the missing path (the FileManager checks inside it all miss), loadRepositoriesData took the non-git branch, and synthesized a folder row with no indication that the directory was gone. Git repos already surface this via loadFailuresByID → SidebarFailedRepositoryRow; folders did not. Add a `rootDirectoryExists` closure to GitClientDependency so the loader can distinguish "directory is gone" (failure row) from "directory exists but isn't a git repo" (folder row). The live implementation uses FileManager.fileExists on the standardized path; testValue defaults to `true` so fixtures with fake /tmp paths keep exercising the classification branches they were written for. Route the missing-directory case through the same LoadFailure pipeline git failures use, so the sidebar renders the familiar error row with a "this directory may have been moved or deleted" message. Covered by a new regression test that stubs rootDirectoryExists to return false and asserts the loader emits a loadFailuresByID entry instead of a synthesized folder repository.
|
After this update Supacode stopped detecting my bare repo as such and just shows a folder, and after the restart I lost the layout and panes I was working and stuck back to Zellij and old plain Ghostty 🙏 😢 |
Sorry to hear that. Could you give me some more info on the structure of the repo so I can add some more regression testing? @bjufre
When repos are rendered as folders they don't lose their metadata, they just hide all info which is no longer relevant, so in theory if you haven't made any other changes it should be back once it's fixed. |
|
I've started a new release extending the name convention checks we used before, to rely more on the folder structure. |
Summary
SidebarSelection/WorktreeTerminalManagerplumbing. Git-only actions (archive, pin, unpin, new worktree) surface a folder-incompatible alert instead of silently no-op-ing.SidebarItem*views (WorktreeRow→SidebarItemView,WorktreeRowsView→SidebarItemsView) so git and folder rows share rendering.ActiveRemovalBatch,RepositoryRemovalRecord,DeleteDisposition). Bulk deletes now fire one terminal.repositoriesRemovedper batch, coalesce parallel trash failures into a single alert, and can't clobber concurrent git-section and folder batches.RemovalOutcome.failureinto.failureSilent(caller owns the alert — blocking-script, cancel, kind-flip) and.failureWithMessage(String)(aggregator owns the alert —trashItemerrors). Makes the alert-clobber scenario unrepresentable: a script-failure alert sharing a batch with a trash failure is preserved; the trash errors are logged viarepositoriesLogger.warninginstead of overwriting the UI.removingRepositoryIDs+activeRemovalBatchesinapplyRepositoriessymmetric with the seven other reload-intersected trackers.LoadFailure→SidebarFailedRepositoryRowpipeline git repos already use, via a newgitClient.rootDirectoryExistsdependency. A folder deleted from Finder while Supacode was running now shows the error row instead of an empty folder..gitbut HEAD/objects/refs trio is incomplete) inRepository.isGitRepository(at:)so the otherwise-silent misclassification is observable in telemetry without widening the classifier and creating false positives.folderIncompatibleAlertcopy across the reducer helper and theAppFeaturedeeplink handler viaFolderIncompatibleAction.alertCopy.seedRemovalBatchtest helper out of#if DEBUGin the main module and into the test target where@testable import supacodealready exposes the internal state fields..help()to the Folder Settings context-menu button to match CLAUDE.md's tooltip mandate.settingsFile.repositoriesentries when a repository is removed so the script / run-config / open-action dicts don't leak forever.Test plan
make check(swift-format + swiftlint, strict mode)make test— 849 tests pass, 12 known issues unchanged from the branch baselineCloses #252