Skip to content

Deleting an archived thread silently orphans its git worktree #9085

Description

@mfteuscher

Summary

deleteThread only runs worktree cleanup for threads present in the main thread store. Archived threads are excluded from that store, so deleting one from Settings → Archived deletes the thread and leaves its git worktree on disk permanently, with no prompt.

Why archived threads miss the cleanup

deleteThread resolves its target via resolveThreadTargetreadThreadShell (apps/web/src/hooks/useThreadActions.ts:204). On a miss it takes an early-return branch at useThreadActions.ts:285-295:

// Thread not in main store (e.g. archived thread) — dispatch delete directly.

That returns before the worktree block at useThreadActions.ts:318-430 (getOrphanedWorktreePathForThread → confirm dialog → removeWorktree).

The main store is hydrated from getShellSnapshot, whose thread query filters archived rows — listActiveThreadRows in apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:481:

WHERE deleted_at IS NULL
  AND archived_at IS NULL

So once the client has re-hydrated, an archived thread is by construction absent from the store the delete path reads. The archived panel is fed by a separate archivedShellSnapshot (apps/web/src/lib/archivedThreadsState.ts), and its delete action calls confirmAndDeleteThread (apps/web/src/components/settings/SettingsPanels.tsx:2641) → deleteThread.

Repro

  1. Create a thread with New worktree — worktree lands in ~/.t3/worktrees/<repo>/<branch>.
  2. Archive the thread.
  3. Restart the app (or otherwise let the shell snapshot re-hydrate).
  4. Settings → Archived → delete the thread, accepting the generic Delete thread "…"? prompt.

Expected: same as deleting an unarchived thread — This thread is the only one linked to this worktree… Delete the worktree too?

Actual: no worktree prompt. The thread is deleted; the worktree directory and its git admin entry remain.

Step 3 matters. Deleting immediately after archiving within the same session can still hit the normal path, because thread.archived does not evict the thread from the client store (apps/web/src/orchestrationEventEffects.ts:49 sets clearDeletedThread: false). That's likely why this is easy to miss in manual testing.

Impact

apps/web/src/hooks/useThreadActions.ts:425 is the only call site in the app that removes a worktree, so nothing else reclaims these. Archive-then-delete is a natural workflow, and worktrees accumulate silently.

The cost isn't trivial: each is a full working tree, and if the project has a setup script it runs inside every new worktree (apps/server/src/git/GitManager.ts:2091) — so a node_modules per worktree. git worktree prune doesn't help; it only drops admin entries for directories that are already gone.

Verification

I exercised the real deleteThread with mocked dependencies (vitest, apps/web):

  • active thread with a worktree → confirm dialog shown once, removeWorktree called once
  • readThreadShell returning null (the archived case) → delete dispatched, confirm never shown, removeWorktree never called

readThreadShell was the only difference between the two cases.

Observed at commit c17d02cf.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions