Skip to content

feat(storage): add task worktree storage management#2686

Open
rabanspiegel wants to merge 1 commit into
mainfrom
emdash/space-on-disk-10onu
Open

feat(storage): add task worktree storage management#2686
rabanspiegel wants to merge 1 commit into
mainfrom
emdash/space-on-disk-10onu

Conversation

@rabanspiegel

Copy link
Copy Markdown
Contributor

Summary

Adds a Storage settings page for reviewing task worktree disk usage and deleting stale tasks from one place.

This version:

  • shows task worktree size grouped by project
  • shows selected size before deletion
  • deletes selected tasks and their owned worktrees
  • keeps branch deletion out of this flow

Why

Worktrees can accumulate and take meaningful disk space, especially across old tasks. Users need a direct way to see which tasks are taking space and remove them without manually inspecting ~/emdash/worktrees.

This also gives us a clean foundation for future storage work without putting disk-scanning logic directly in the renderer or duplicating task deletion behavior.

Implementation

Adds a small @emdash/core/storage package for filesystem measurement. This keeps the disk-size logic independent from Electron/main-process UI concerns and makes it easier to reuse later in a workspace-server direction.

Adds a typed main-process storage RPC namespace:

  • storage.listTaskStorageUsage
  • storage.deleteTasks

The Storage service queries tasks/workspaces, measures local worktree usage, and returns Storage-specific view data to the renderer.

Deletion intentionally reuses the canonical task deletion path:

taskService.deleteTask(projectId, taskId, {
  deleteWorktree: true,
  deleteBranch: false,
});

So Storage does not directly delete task DB rows, view state, telemetry, or task events. That stays owned by the task lifecycle code.

The task deletion path now also has a local owned-path fallback: if the mounted project provider cannot remove the worktree, deletion can still remove the DB-owned local worktree path, while refusing to delete the project root.

UI

Adds Settings -> Storage with:

  • total measured task worktree size
  • selected size
  • project-grouped task rows
  • refresh
  • delete selected tasks

Regular sidebar/task-list deletes stay quiet on success, but now show an error toast if deletion fails. Storage keeps delete feedback because bulk deletion can partially fail.

Non-goals

This does not add:

  • generated/cache cleanup
  • branch deletion
  • pnpm store pruning
  • low-disk prompts
  • background cleanup policies
  • telemetry for Storage page usage

Testing

  • pnpm run typecheck
  • pnpm run lint
  • focused task deletion lifecycle tests
  • core storage measurement tests

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a Storage settings area for task worktree cleanup. The main changes are:

  • A new main-process storage RPC namespace for listing usage and deleting tasks.
  • Core storage measurement helpers in @emdash/core/storage.
  • A Storage settings UI with grouped task usage, selection totals, refresh, and bulk delete.
  • Task deletion fallback cleanup for owned local worktree paths.
  • Renderer task deletion events to remove deleted tasks from local stores.

Confidence Score: 4/5

The batch task deletion event path needs a fix before merging.

  • A partial batch delete can remove tasks in the main process without notifying the renderer.
  • The renderer can then restore deleted tasks during its optimistic rollback.
  • The storage service and worktree cleanup paths otherwise follow the intended task deletion flow.

apps/emdash-desktop/src/main/core/tasks/task-service.ts

Important Files Changed

Filename Overview
apps/emdash-desktop/src/main/core/storage/storage-service.ts Adds the main storage service for querying task/workspace rows, measuring local worktrees, grouping results, and delegating bulk deletion through the task service.
apps/emdash-desktop/src/main/core/tasks/task-service.ts Adds task deletion event emission, but the batch delete path can skip notifications for successful deletes when another delete fails.
apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.ts Adds local owned-worktree fallback removal with local/remote checks, project-root refusal, sibling-task checks, and git worktree pruning.
apps/emdash-desktop/src/renderer/features/settings/components/StorageSettingsPage.tsx Adds the Storage settings UI for scanning usage, selecting tasks, confirming bulk deletion, and showing per-operation feedback.
apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts Subscribes task managers to task deletion events and adds delete failure toast feedback.
packages/core/src/storage/measurement.ts Adds recursive filesystem measurement with apparent and reclaimable byte accounting plus scan errors.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
  participant UI as Storage Settings UI
  participant RPC as storage RPC
  participant Service as Storage Service
  participant Tasks as Task Service
  participant DB as Task/Workspace DB
  participant FS as Worktree Filesystem

  UI->>RPC: listTaskStorageUsage()
  RPC->>Service: listTaskStorageUsage(projectId?)
  Service->>DB: read tasks, projects, workspaces
  Service->>FS: measure local worktree paths
  Service-->>UI: grouped usage

  UI->>RPC: deleteTasks(taskIds)
  RPC->>Service: deleteStorageTasks(taskIds)
  loop each selected task
    Service->>Tasks: "deleteTask(projectId, taskId, deleteWorktree=true)"
    Tasks->>DB: remove task row and cleanup state
    Tasks->>FS: remove owned local worktree when unused
    Tasks-->>UI: taskDeleted event
  end
  Service-->>UI: per-task delete result
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
  participant UI as Storage Settings UI
  participant RPC as storage RPC
  participant Service as Storage Service
  participant Tasks as Task Service
  participant DB as Task/Workspace DB
  participant FS as Worktree Filesystem

  UI->>RPC: listTaskStorageUsage()
  RPC->>Service: listTaskStorageUsage(projectId?)
  Service->>DB: read tasks, projects, workspaces
  Service->>FS: measure local worktree paths
  Service-->>UI: grouped usage

  UI->>RPC: deleteTasks(taskIds)
  RPC->>Service: deleteStorageTasks(taskIds)
  loop each selected task
    Service->>Tasks: "deleteTask(projectId, taskId, deleteWorktree=true)"
    Tasks->>DB: remove task row and cleanup state
    Tasks->>FS: remove owned local worktree when unused
    Tasks-->>UI: taskDeleted event
  end
  Service-->>UI: per-task delete result
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/emdash-desktop/src/main/core/tasks/task-service.ts:182-189
**Partial Deletes Stay Visible**

When one task in `deleteTasks` fails after another has already been deleted, `Promise.all` rejects before this notification loop runs. No `taskDeletedChannel` event is emitted for the successful deletion, so the task list catch path can restore every optimistic row and leave a deleted task visible in the renderer.

```suggestion
  async deleteTasks(
    projectId: string,
    taskIds: string[],
    options?: DeleteTaskOptions
  ): Promise<void> {
    const results = await Promise.allSettled(taskIds.map((id) => deleteTask(projectId, id, options)));
    const failures: unknown[] = [];

    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        this.notifyTaskDeleted(taskIds[index]!, projectId);
      } else {
        failures.push(result.reason);
      }
    });

    if (failures.length > 0) {
      throw failures[0];
    }
  }
```

Reviews (1): Last reviewed commit: "feat(storage): add task worktree storage..." | Re-trigger Greptile

Comment on lines 182 to 189
@@ -176,7 +185,7 @@ export class TaskService implements Hookable<TaskLifecycleHooks> {
options?: DeleteTaskOptions
): Promise<void> {
await Promise.all(taskIds.map((id) => deleteTask(projectId, id, options)));
taskIds.forEach((id) => this._hooks.callHookBackground('task:deleted', id, projectId));
taskIds.forEach((id) => this.notifyTaskDeleted(id, projectId));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial Deletes Stay Visible

When one task in deleteTasks fails after another has already been deleted, Promise.all rejects before this notification loop runs. No taskDeletedChannel event is emitted for the successful deletion, so the task list catch path can restore every optimistic row and leave a deleted task visible in the renderer.

Suggested change
async deleteTasks(
projectId: string,
taskIds: string[],
options?: DeleteTaskOptions
): Promise<void> {
const results = await Promise.allSettled(taskIds.map((id) => deleteTask(projectId, id, options)));
const failures: unknown[] = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
this.notifyTaskDeleted(taskIds[index]!, projectId);
} else {
failures.push(result.reason);
}
});
if (failures.length > 0) {
throw failures[0];
}
}

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/main/core/tasks/task-service.ts
Line: 182-189

Comment:
**Partial Deletes Stay Visible**

When one task in `deleteTasks` fails after another has already been deleted, `Promise.all` rejects before this notification loop runs. No `taskDeletedChannel` event is emitted for the successful deletion, so the task list catch path can restore every optimistic row and leave a deleted task visible in the renderer.

```suggestion
  async deleteTasks(
    projectId: string,
    taskIds: string[],
    options?: DeleteTaskOptions
  ): Promise<void> {
    const results = await Promise.allSettled(taskIds.map((id) => deleteTask(projectId, id, options)));
    const failures: unknown[] = [];

    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        this.notifyTaskDeleted(taskIds[index]!, projectId);
      } else {
        failures.push(result.reason);
      }
    });

    if (failures.length > 0) {
      throw failures[0];
    }
  }
```

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/emdash/github/generalaction/emdash/-/custom-context?memory=39946a11-2903-4cb1-9b64-bdbe746b20d1))

How can I resolve this? If you propose a fix, please make it concise.

@arnestrickmann arnestrickmann requested a review from jschwxrz June 26, 2026 13:59
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.

1 participant