@kabmo-labs/kabmo 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-aq0e78gv.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +74 -1
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +126 -50
- package/src/server/lib/updates/shell-release.ts +139 -88
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `/api/worktrees` routes, end to end over a real repository.
|
|
8
|
+
*
|
|
9
|
+
* What a unit test on the modules cannot show, and what these cases are for:
|
|
10
|
+
*
|
|
11
|
+
* - the LIST payload carries every fact a row draws, from one read;
|
|
12
|
+
* - a create answers with the worktree it made, and the next list shows it —
|
|
13
|
+
* the topology cache invalidation is what makes "next list" true;
|
|
14
|
+
* - a DELETE takes the path in its BODY (a path has separators) and answers the
|
|
15
|
+
* branch verdict rather than a bare success;
|
|
16
|
+
* - MOCK refuses, because a demo install has no repository to make one in.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { getDb } from '@/server/db.server';
|
|
24
|
+
import { loader as listLoader } from '@/server/routes/worktrees/list';
|
|
25
|
+
import { action as createAction, validateAction } from '@/server/routes/worktrees/create';
|
|
26
|
+
import { action as removeAction } from '@/server/routes/worktrees/remove';
|
|
27
|
+
import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
|
|
28
|
+
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
29
|
+
import { isolateDb, releaseDb } from '@/test-support/isolated-db';
|
|
30
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
31
|
+
|
|
32
|
+
const GIT_ENV = {
|
|
33
|
+
...Bun.env,
|
|
34
|
+
GIT_EDITOR: 'true',
|
|
35
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
36
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
37
|
+
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
38
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
39
|
+
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function git(cwd: string, ...args: string[]): string {
|
|
43
|
+
return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV }).stdout.toString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let workRoot: string;
|
|
47
|
+
let repo: string;
|
|
48
|
+
/** Where the session files live, so a count can be observed without omp. */
|
|
49
|
+
let sessionsRoot: string;
|
|
50
|
+
const saved = {
|
|
51
|
+
MOCK: Bun.env.MOCK,
|
|
52
|
+
SYNC_WORKSPACE: Bun.env.SYNC_WORKSPACE,
|
|
53
|
+
agentDir: Bun.env.PI_CODING_AGENT_DIR,
|
|
54
|
+
xdg: Bun.env.XDG_DATA_HOME,
|
|
55
|
+
dataDir: Bun.env.KABMO_DATA_DIR,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Call a route module the way the adapter does — with `params`, which every
|
|
60
|
+
* Remix-shaped handler declares even when its path has no parameter.
|
|
61
|
+
*/
|
|
62
|
+
async function call(
|
|
63
|
+
handler: (args: { request: Request; params: Record<string, string> }) => unknown,
|
|
64
|
+
url: string,
|
|
65
|
+
init?: RequestInit,
|
|
66
|
+
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
67
|
+
const response = (await handler({ request: new Request(url, init), params: {} })) as Response;
|
|
68
|
+
return { status: response.status, body: (await response.json()) as Record<string, unknown> };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Put an env var back, or unset it when the file started without one. */
|
|
72
|
+
function restoreEnv(name: string, value: string | undefined): void {
|
|
73
|
+
if (value === undefined) delete Bun.env[name];
|
|
74
|
+
else Bun.env[name] = value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One session file whose cwd is `cwd`, as the agent would record it. */
|
|
78
|
+
function writeSession(id: string, cwd: string): void {
|
|
79
|
+
const lines = [
|
|
80
|
+
JSON.stringify({ type: 'session', id, cwd, timestamp: '2026-10-23T00:00:00.000Z' }),
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
type: 'message',
|
|
83
|
+
id: `${id}-m1`,
|
|
84
|
+
parentId: null,
|
|
85
|
+
timestamp: '2026-10-23T00:00:01.000Z',
|
|
86
|
+
message: { role: 'user', content: 'where am I' },
|
|
87
|
+
}),
|
|
88
|
+
];
|
|
89
|
+
writeFileSync(join(sessionsRoot, 'project', `${id}.jsonl`), `${lines.join('\n')}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `worktrees` array of a list payload, narrowed once. */
|
|
93
|
+
function worktreesOf(body: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
94
|
+
const list = body.worktrees;
|
|
95
|
+
return Array.isArray(list) ? (list as Array<Record<string, unknown>>) : [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const base = (): string => `http://localhost/api/worktrees?root=${encodeURIComponent(repo)}`;
|
|
99
|
+
|
|
100
|
+
beforeAll(() => {
|
|
101
|
+
workRoot = mkdtempSync(join(tmpdir(), 'kabmo-worktree-routes-'));
|
|
102
|
+
repo = join(workRoot, 'repo');
|
|
103
|
+
mkdirSync(repo, { recursive: true });
|
|
104
|
+
writeFileSync(join(repo, '.gitignore'), 'node_modules\n');
|
|
105
|
+
mkdirSync(join(repo, 'node_modules', 'dep'), { recursive: true });
|
|
106
|
+
writeFileSync(join(repo, 'node_modules', 'dep', 'index.js'), 'module.exports = {};\n');
|
|
107
|
+
writeFileSync(join(repo, 'readme.md'), 'hello\n');
|
|
108
|
+
git(repo, 'init', '-q', '-b', 'main', '.');
|
|
109
|
+
git(repo, 'add', '-A');
|
|
110
|
+
git(repo, 'commit', '-q', '-m', 'base');
|
|
111
|
+
|
|
112
|
+
// A sessions root of this file's own: the worktree count reads it, and
|
|
113
|
+
// without the override it would scan the developer's real `~/.omp/agent`.
|
|
114
|
+
const agent = join(workRoot, 'agent');
|
|
115
|
+
sessionsRoot = join(agent, 'sessions');
|
|
116
|
+
mkdirSync(join(sessionsRoot, 'project'), { recursive: true });
|
|
117
|
+
Bun.env.PI_CODING_AGENT_DIR = agent;
|
|
118
|
+
// XDG would redirect the sessions dir to $XDG_DATA_HOME/omp when it exists.
|
|
119
|
+
delete Bun.env.XDG_DATA_HOME;
|
|
120
|
+
// A worktree's default location is `<data>/worktrees/<projectId>/<name>`, so
|
|
121
|
+
// without this the suite writes into the developer's own `~/.kabmo`.
|
|
122
|
+
Bun.env.KABMO_DATA_DIR = join(workRoot, 'data');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
beforeEach(async () => {
|
|
126
|
+
await isolateDb();
|
|
127
|
+
Bun.env.MOCK = 'false';
|
|
128
|
+
Bun.env.SYNC_WORKSPACE = 'false';
|
|
129
|
+
const db = await getDb();
|
|
130
|
+
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(async () => {
|
|
134
|
+
await invalidateWorktreeTopology();
|
|
135
|
+
// The session-file caches are keyed on `globalThis` and this file writes
|
|
136
|
+
// session files, so a case that leaves one behind would be counted by the next.
|
|
137
|
+
clearSessionFileCaches();
|
|
138
|
+
await releaseDb();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
afterAll(() => {
|
|
142
|
+
removeDir(workRoot);
|
|
143
|
+
// Explicitly mapped: the record's keys are not the env names, and an
|
|
144
|
+
// `Object.entries` restore would silently leave `PI_CODING_AGENT_DIR` pointed
|
|
145
|
+
// at a directory this file just deleted.
|
|
146
|
+
restoreEnv('MOCK', saved.MOCK);
|
|
147
|
+
restoreEnv('SYNC_WORKSPACE', saved.SYNC_WORKSPACE);
|
|
148
|
+
restoreEnv('PI_CODING_AGENT_DIR', saved.agentDir);
|
|
149
|
+
restoreEnv('XDG_DATA_HOME', saved.xdg);
|
|
150
|
+
restoreEnv('KABMO_DATA_DIR', saved.dataDir);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('GET /api/worktrees', () => {
|
|
154
|
+
test('lists the checkout with the facts a row draws', async () => {
|
|
155
|
+
const { status, body } = await call(listLoader, base());
|
|
156
|
+
expect(status).toBe(200);
|
|
157
|
+
expect(body.isRepo).toBe(true);
|
|
158
|
+
expect(String(body.base)).toContain('worktrees');
|
|
159
|
+
const primary = worktreesOf(body)[0] as Record<string, unknown>;
|
|
160
|
+
expect(primary.isPrimary).toBe(true);
|
|
161
|
+
expect(primary.branch).toBe('main');
|
|
162
|
+
expect(String(primary.head).length).toBeGreaterThan(0);
|
|
163
|
+
expect(primary.dirty).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('counts a session running in a worktree that lives OUTSIDE the repository', async () => {
|
|
167
|
+
const created = await call(createAction, base(), {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'content-type': 'application/json' },
|
|
170
|
+
body: JSON.stringify({ mode: 'new', branch: 'wt/route-counted' }),
|
|
171
|
+
});
|
|
172
|
+
const path = String((created.body.worktree as Record<string, unknown>).path);
|
|
173
|
+
// The default location is under the data directory, not inside the checkout
|
|
174
|
+
// — which is the case a `primaryRoot` prefix test counted zero for.
|
|
175
|
+
expect(path.startsWith(repo)).toBe(false);
|
|
176
|
+
|
|
177
|
+
writeSession('route-counted-session', path);
|
|
178
|
+
|
|
179
|
+
const listed = await call(listLoader, base());
|
|
180
|
+
const row = worktreesOf(listed.body).find((entry) => entry.path === path);
|
|
181
|
+
// The number the delete dialog warns with: a chat is running here.
|
|
182
|
+
expect(row?.sessions).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('a folder with no project binding is refused, not answered for the app root', async () => {
|
|
186
|
+
const { status, body } = await call(listLoader, 'http://localhost/api/worktrees');
|
|
187
|
+
expect(status).toBe(400);
|
|
188
|
+
expect(body.code).toBe('not_a_repo');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('MOCK refuses — a demo install has no repository to read', async () => {
|
|
192
|
+
Bun.env.MOCK = 'true';
|
|
193
|
+
const { status, body } = await call(listLoader, base());
|
|
194
|
+
expect(status).toBe(409);
|
|
195
|
+
expect(body.code).toBe('mock_mode');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('POST /api/worktrees', () => {
|
|
200
|
+
test('creates one, carries the ignored build folder, and the next list shows it', async () => {
|
|
201
|
+
const created = await call(createAction, base(), {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'content-type': 'application/json' },
|
|
204
|
+
body: JSON.stringify({ mode: 'new', branch: 'wt/route-created' }),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(created.status).toBe(200);
|
|
208
|
+
const worktree = created.body.worktree as Record<string, unknown>;
|
|
209
|
+
expect(worktree.branch).toBe('wt/route-created');
|
|
210
|
+
// The carry is the one capability git does not provide; without it the new
|
|
211
|
+
// checkout cannot run the project.
|
|
212
|
+
const carried = worktree.carried as { copied: string[] };
|
|
213
|
+
expect(carried.copied).toContain('node_modules');
|
|
214
|
+
|
|
215
|
+
const listed = await call(listLoader, base());
|
|
216
|
+
const row = worktreesOf(listed.body).find((entry) => entry.branch === 'wt/route-created');
|
|
217
|
+
expect(row).toBeDefined();
|
|
218
|
+
expect(row?.isPrimary).toBe(false);
|
|
219
|
+
expect(row?.startRef).toBe('HEAD');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('a refusal answers its code and field, so the dialog can mark the input', async () => {
|
|
223
|
+
const refused = await call(createAction, base(), {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'content-type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({ mode: 'existing', branch: 'main' }),
|
|
227
|
+
});
|
|
228
|
+
expect(refused.status).toBe(400);
|
|
229
|
+
expect(refused.body.code).toBe('branch_in_use');
|
|
230
|
+
expect(refused.body.field).toBe('branch');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('validate answers the same refusal without creating anything', async () => {
|
|
234
|
+
const result = await call(validateAction, base(), {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'content-type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({ mode: 'new', branch: 'has space' }),
|
|
238
|
+
});
|
|
239
|
+
expect(result.status).toBe(400);
|
|
240
|
+
expect(result.body.ok).toBe(false);
|
|
241
|
+
const failures = result.body.failures as Array<{ code: string }>;
|
|
242
|
+
expect(failures[0]?.code).toBe('invalid_name');
|
|
243
|
+
|
|
244
|
+
// Nothing was created: the fixture repository is shared by this file, so the
|
|
245
|
+
// assertion names the branch rather than a count (a count would also pin how
|
|
246
|
+
// many earlier tests happened to leave behind).
|
|
247
|
+
const listed = await call(listLoader, base());
|
|
248
|
+
expect(worktreesOf(listed.body).some((entry) => entry.branch === 'has-space')).toBe(false);
|
|
249
|
+
expect(worktreesOf(listed.body).some((entry) => entry.branch === 'has space')).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('DELETE /api/worktrees', () => {
|
|
254
|
+
test('removes the worktree and its branch, and the list no longer shows it', async () => {
|
|
255
|
+
const created = await call(createAction, base(), {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'content-type': 'application/json' },
|
|
258
|
+
body: JSON.stringify({ mode: 'new', branch: 'wt/route-remove' }),
|
|
259
|
+
});
|
|
260
|
+
const path = String((created.body.worktree as Record<string, unknown>).path);
|
|
261
|
+
|
|
262
|
+
const removed = await call(removeAction, base(), {
|
|
263
|
+
method: 'DELETE',
|
|
264
|
+
headers: { 'content-type': 'application/json' },
|
|
265
|
+
body: JSON.stringify({ path, deleteBranch: true }),
|
|
266
|
+
});
|
|
267
|
+
expect(removed.status).toBe(200);
|
|
268
|
+
expect(removed.body.removed).toBe(true);
|
|
269
|
+
expect(removed.body.branchDeleted).toBe(true);
|
|
270
|
+
|
|
271
|
+
const listed = await call(listLoader, base());
|
|
272
|
+
expect(worktreesOf(listed.body).some((entry) => entry.path === path)).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('a missing path is refused by name, not by a crash', async () => {
|
|
276
|
+
const refused = await call(removeAction, base(), {
|
|
277
|
+
method: 'DELETE',
|
|
278
|
+
headers: { 'content-type': 'application/json' },
|
|
279
|
+
body: JSON.stringify({}),
|
|
280
|
+
});
|
|
281
|
+
expect(refused.status).toBe(400);
|
|
282
|
+
expect(refused.body.code).toBe('invalid_name');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('a dirty worktree is refused with 409 and its count, so a caller can tell a retry from a bad request', async () => {
|
|
286
|
+
const created = await call(createAction, base(), {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: { 'content-type': 'application/json' },
|
|
289
|
+
body: JSON.stringify({ mode: 'new', branch: 'wt/route-dirty' }),
|
|
290
|
+
});
|
|
291
|
+
const path = String((created.body.worktree as Record<string, unknown>).path);
|
|
292
|
+
writeFileSync(join(path, 'scratch.txt'), 'not committed\n');
|
|
293
|
+
|
|
294
|
+
const refused = await call(removeAction, base(), {
|
|
295
|
+
method: 'DELETE',
|
|
296
|
+
headers: { 'content-type': 'application/json' },
|
|
297
|
+
body: JSON.stringify({ path, deleteBranch: true }),
|
|
298
|
+
});
|
|
299
|
+
// 409: the request is well-formed and the tree is the obstacle — the same
|
|
300
|
+
// distinction `mock_mode` draws. A 400 here reads as "fix your request".
|
|
301
|
+
expect(refused.status).toBe(409);
|
|
302
|
+
expect(refused.body.code).toBe('dirty');
|
|
303
|
+
expect(Number(refused.body.count)).toBeGreaterThan(0);
|
|
304
|
+
|
|
305
|
+
// A refusal is not a partial removal.
|
|
306
|
+
const listed = await call(listLoader, base());
|
|
307
|
+
expect(worktreesOf(listed.body).some((entry) => entry.path === path)).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The worktree routes' shared plumbing: resolve the project, refuse in MOCK, and
|
|
8
|
+
* turn a `WorktreeError` into the JSON envelope the client already understands.
|
|
9
|
+
*
|
|
10
|
+
* Root resolution goes through `resolveRoot`, the security boundary every fs
|
|
11
|
+
* route uses — a client-supplied `root` is only honoured when it is the app
|
|
12
|
+
* root, a registered `project_path`, or (this feature) a worktree of one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { json } from '@/server/lib/remix-compat';
|
|
16
|
+
import { isMockMode } from '@/server/mock.server';
|
|
17
|
+
import { getDb } from '@/server/db.server';
|
|
18
|
+
import { resolveRoot } from '@/server/lib/fs/root';
|
|
19
|
+
import { asWorktreeFailure } from '@/server/lib/fs/worktree/errors';
|
|
20
|
+
import type { WorktreeFailure } from '@/shared/lib/workspace/worktree/types';
|
|
21
|
+
|
|
22
|
+
/** A registered workspace's `project_path`, or null when the folder is unbound. */
|
|
23
|
+
export async function projectPathForFolder(folderId: number | string | null): Promise<string | null> {
|
|
24
|
+
if (folderId === null || folderId === '') return null;
|
|
25
|
+
try {
|
|
26
|
+
const db = await getDb();
|
|
27
|
+
const row = await db.get<{ project_path: string | null }>(
|
|
28
|
+
'SELECT project_path FROM workspace_folders WHERE id = ?',
|
|
29
|
+
[Number(folderId)],
|
|
30
|
+
);
|
|
31
|
+
return row?.project_path ?? null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The project a request names: its own `root`, else the folder's binding. */
|
|
38
|
+
export async function resolveWorktreeProject(
|
|
39
|
+
rawRoot: string | null | undefined,
|
|
40
|
+
folderId: string | null,
|
|
41
|
+
): Promise<{ root: string | null; error?: Response }> {
|
|
42
|
+
const fromFolder = await projectPathForFolder(folderId);
|
|
43
|
+
const root = await resolveRoot(rawRoot || fromFolder, '');
|
|
44
|
+
if (!root) {
|
|
45
|
+
return {
|
|
46
|
+
root: null,
|
|
47
|
+
error: json(
|
|
48
|
+
{
|
|
49
|
+
error: 'Pick a workspace bound to a Git repository first.',
|
|
50
|
+
code: 'not_a_repo',
|
|
51
|
+
},
|
|
52
|
+
{ status: 400 },
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { root };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A worktree mutation in demo mode is refused rather than simulated: MOCK has no
|
|
61
|
+
* repository on disk, so a "successful" create would promise a folder that does
|
|
62
|
+
* not exist.
|
|
63
|
+
*/
|
|
64
|
+
export function mockRefusal(): Response {
|
|
65
|
+
return json(
|
|
66
|
+
{ error: 'Worktrees need a real Git repository; Kabmo is running in demo mode.', code: 'mock_mode' },
|
|
67
|
+
{ status: 409 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A refusal's HTTP status, from what the refusal MEANS.
|
|
73
|
+
*
|
|
74
|
+
* `dirty` is a conflict with the tree's current state rather than a malformed
|
|
75
|
+
* request — the same distinction `session_not_running` and `mock_mode` draw with
|
|
76
|
+
* 409 — and a caller that retries after committing needs to tell the two apart
|
|
77
|
+
* without parsing the message.
|
|
78
|
+
*/
|
|
79
|
+
const REFUSAL_STATUS: Partial<Record<WorktreeFailure['code'], number>> = {
|
|
80
|
+
dirty: 409,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function worktreeFailureResponse(error: unknown, status = 400): Response {
|
|
84
|
+
const failure = asWorktreeFailure(error);
|
|
85
|
+
return json({ error: failure.message, ...failure }, { status: REFUSAL_STATUS[failure.code] ?? status });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isMock(): boolean {
|
|
89
|
+
return isMockMode();
|
|
90
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The tree edits that let a create and a rename skip a reload.
|
|
8
|
+
*
|
|
9
|
+
* Each rule here is a way the rendered tree would disagree with the disk:
|
|
10
|
+
*
|
|
11
|
+
* 1. **A nested entry brings the chain that leads to it.** `lib/parse.ts` names
|
|
12
|
+
* a folder the tree does not carry yet; without the chain the row is placed
|
|
13
|
+
* under a key no branch reaches and simply never renders.
|
|
14
|
+
* 2. **A collapsed folder is not guessed at.** Its children are not this
|
|
15
|
+
* tree's knowledge, so an entry created inside one is deliberately NOT
|
|
16
|
+
* inserted — expanding it reads the real directory, which by then holds the
|
|
17
|
+
* new entry. Inserting into a guess would show a listing the server never
|
|
18
|
+
* produced.
|
|
19
|
+
* 3. **A rename re-points every descendant.** A folder's children carry its
|
|
20
|
+
* path as a prefix; moving the folder without rewriting them leaves rows
|
|
21
|
+
* whose `path` no longer resolves and an expansion set keyed by names that
|
|
22
|
+
* are gone.
|
|
23
|
+
* 4. **A rename never loses the moved entry's own loaded children.** Its cache
|
|
24
|
+
* entry is rekeyed with it, so a folder renamed while open stays open with
|
|
25
|
+
* its contents.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, test } from 'bun:test';
|
|
29
|
+
import type { FsNode } from '@/shared/types';
|
|
30
|
+
import {
|
|
31
|
+
baseNameOf,
|
|
32
|
+
parentPathOf,
|
|
33
|
+
placeEntry,
|
|
34
|
+
rekeyCache,
|
|
35
|
+
rekeyExpansion,
|
|
36
|
+
renameEntryAt,
|
|
37
|
+
sortEntries,
|
|
38
|
+
takeEntry,
|
|
39
|
+
} from '@/shared/lib/fs/file-tree';
|
|
40
|
+
|
|
41
|
+
const file = (path: string): FsNode => ({ name: baseNameOf(path), path, type: 'file' });
|
|
42
|
+
const folder = (path: string, children?: FsNode[]): FsNode =>
|
|
43
|
+
children ? { name: baseNameOf(path), path, type: 'folder', children } : { name: baseNameOf(path), path, type: 'folder' };
|
|
44
|
+
|
|
45
|
+
describe('placeEntry', () => {
|
|
46
|
+
test('a top-level entry lands in the root, folders first', () => {
|
|
47
|
+
const placed = placeEntry([folder('src'), file('z.ts')], {}, file('a.ts'));
|
|
48
|
+
|
|
49
|
+
expect(placed.nodes.map((node) => node.path)).toEqual(['src', 'a.ts', 'z.ts']);
|
|
50
|
+
expect(placed.cache).toEqual({});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a nested entry creates the chain that leads to it', () => {
|
|
54
|
+
const placed = placeEntry([], {}, file('lib/deep/parse.ts'));
|
|
55
|
+
|
|
56
|
+
expect(placed.nodes.map((node) => node.path)).toEqual(['lib']);
|
|
57
|
+
expect(placed.cache.lib?.map((node) => node.path)).toEqual(['lib/deep']);
|
|
58
|
+
expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/parse.ts']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an existing chain is extended, not duplicated', () => {
|
|
62
|
+
const placed = placeEntry([folder('lib')], { lib: [folder('lib/deep')], 'lib/deep': [] }, file('lib/deep/x.ts'));
|
|
63
|
+
|
|
64
|
+
expect(placed.nodes).toHaveLength(1);
|
|
65
|
+
expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/x.ts']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an entry inside a COLLAPSED folder is not inserted', () => {
|
|
69
|
+
// `src` is listed with `children: null` — the tree has never read it, so
|
|
70
|
+
// there is nothing to place the entry into and no guess to make.
|
|
71
|
+
const tree = [folder('src')];
|
|
72
|
+
const placed = placeEntry(tree, {}, file('src/hidden.ts'));
|
|
73
|
+
|
|
74
|
+
expect(placed.nodes).toBe(tree);
|
|
75
|
+
expect(placed.cache).toEqual({});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('an empty folder is LOADED, and can receive an entry', () => {
|
|
79
|
+
const placed = placeEntry([folder('src')], { src: [] }, file('src/new.ts'));
|
|
80
|
+
|
|
81
|
+
expect(placed.cache.src?.map((node) => node.path)).toEqual(['src/new.ts']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a created folder is a folder in the cache too, so it can be expanded', () => {
|
|
85
|
+
const placed = placeEntry([], {}, folder('packages'));
|
|
86
|
+
|
|
87
|
+
expect(placed.nodes[0].type).toBe('folder');
|
|
88
|
+
expect(placed.nodes[0].children).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('takeEntry', () => {
|
|
93
|
+
test('removes a root entry and hands it back', () => {
|
|
94
|
+
const taken = takeEntry([file('a.ts'), file('b.ts')], {}, 'a.ts');
|
|
95
|
+
|
|
96
|
+
expect(taken.entry?.path).toBe('a.ts');
|
|
97
|
+
expect(taken.nodes.map((node) => node.path)).toEqual(['b.ts']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('removes a nested entry from its parent cache', () => {
|
|
101
|
+
const taken = takeEntry([folder('src')], { src: [file('src/a.ts'), file('src/b.ts')] }, 'src/a.ts');
|
|
102
|
+
|
|
103
|
+
expect(taken.entry?.path).toBe('src/a.ts');
|
|
104
|
+
expect(taken.cache.src?.map((node) => node.path)).toEqual(['src/b.ts']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a path the tree does not carry reports no entry and changes nothing', () => {
|
|
108
|
+
const cache = { src: [file('src/a.ts')] };
|
|
109
|
+
const taken = takeEntry([folder('src')], cache, 'src/hidden.ts');
|
|
110
|
+
|
|
111
|
+
expect(taken.entry).toBeNull();
|
|
112
|
+
expect(taken.cache).toBe(cache);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('renameEntryAt', () => {
|
|
117
|
+
test('re-points the node and every descendant', () => {
|
|
118
|
+
const tree = [folder('src', [folder('src/lib', [file('src/lib/a.ts')])])];
|
|
119
|
+
const moved = renameEntryAt(tree, 'src', 'lib');
|
|
120
|
+
|
|
121
|
+
expect(moved[0].path).toBe('lib');
|
|
122
|
+
expect(moved[0].name).toBe('lib');
|
|
123
|
+
expect(moved[0].children?.[0].path).toBe('lib/lib');
|
|
124
|
+
expect(moved[0].children?.[0].children?.[0].path).toBe('lib/lib/a.ts');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a sibling whose path merely shares a prefix is left alone', () => {
|
|
128
|
+
const tree = [file('src.ts'), file('src/a.ts')];
|
|
129
|
+
const moved = renameEntryAt(tree, 'src', 'lib');
|
|
130
|
+
|
|
131
|
+
expect(moved[0]).toBe(tree[0]);
|
|
132
|
+
expect(moved[1].path).toBe('lib/a.ts');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a file rename updates its own name', () => {
|
|
136
|
+
const moved = renameEntryAt([file('a.ts')], 'a.ts', 'nested/b.ts');
|
|
137
|
+
|
|
138
|
+
expect(moved[0].path).toBe('nested/b.ts');
|
|
139
|
+
expect(moved[0].name).toBe('b.ts');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('rekeyCache', () => {
|
|
144
|
+
test('moves the folder key and every key beneath it', () => {
|
|
145
|
+
const cache = { src: [folder('src/lib')], 'src/lib': [file('src/lib/a.ts')], other: [file('other/x.ts')] };
|
|
146
|
+
const moved = rekeyCache(cache, 'src', 'app');
|
|
147
|
+
|
|
148
|
+
expect(Object.keys(moved).sort()).toEqual(['app', 'app/lib', 'other']);
|
|
149
|
+
expect(moved['app/lib']?.[0].path).toBe('app/lib/a.ts');
|
|
150
|
+
expect(moved.other).toBe(cache.other);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('rekeyExpansion', () => {
|
|
155
|
+
test('an open folder and its open children stay open under their new paths', () => {
|
|
156
|
+
const next = rekeyExpansion(new Set(['src', 'src/lib', 'docs']), 'src', 'app');
|
|
157
|
+
|
|
158
|
+
expect([...next].sort()).toEqual(['app', 'app/lib', 'docs']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a path that only shares a prefix is untouched', () => {
|
|
162
|
+
const next = rekeyExpansion(new Set(['src.ts']), 'src', 'app');
|
|
163
|
+
|
|
164
|
+
expect([...next]).toEqual(['src.ts']);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('sortEntries', () => {
|
|
169
|
+
test('folders first, then by name — the server’s own order', () => {
|
|
170
|
+
const sorted = sortEntries([file('b.ts'), folder('z'), file('a.ts'), folder('a')]);
|
|
171
|
+
|
|
172
|
+
expect(sorted.map((node) => node.path)).toEqual(['a', 'z', 'a.ts', 'b.ts']);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('path helpers', () => {
|
|
177
|
+
test('parentPathOf and baseNameOf split a nested path', () => {
|
|
178
|
+
expect(parentPathOf('lib/deep/a.ts')).toBe('lib/deep');
|
|
179
|
+
expect(parentPathOf('a.ts')).toBe('.');
|
|
180
|
+
expect(baseNameOf('lib/deep/a.ts')).toBe('a.ts');
|
|
181
|
+
expect(baseNameOf('a.ts')).toBe('a.ts');
|
|
182
|
+
});
|
|
183
|
+
});
|