Add folder (non-git) repository support + harden removal pipeline (#257)
* Add folder (non-git) repository support
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.
* Refactor sidebar UI into shared SidebarItem views
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).
* Rationalize delete pipeline around a typed batch aggregator
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.
* Prune settingsFile.repositories entries when removing a repository
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.
* Harden folder + delete-pipeline edge cases
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)
* Fix tracker leaks + repo-eviction race, coalesce bulk trash alerts
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.
* Close last folder-removal gaps: alert survival, typed outcome, shared 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, preserve silent alerts, tighten removal invariants
- 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.
* Drop DA2 re-entry drain, silence reload prune, clean lint
- 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.
* Show sidebar error row when a tracked folder is missing on disk
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.
authored by