@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.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-aq0e78gv.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +74 -1
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +126 -50
  101. package/src/server/lib/updates/shell-release.ts +139 -88
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. package/dist/client/static/js/index-nzyjf92m.css +0 -1
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One rule for every name the Files panel WRITES: a path relative to the folder
8
+ * that was named, nested segments allowed, and nothing that can leave it.
9
+ *
10
+ * The panel's create and rename both used to accept a bare name only, which made
11
+ * a nested path unreachable from the UI: creating `src/lib/parse.ts` meant
12
+ * creating `src`, expanding it, then `lib`, then the file. The restriction was
13
+ * never about safety — `resolveWithinRoot` already refuses an escape — it was
14
+ * the shape of the input field, so the grammar is now stated once and both
15
+ * callers read it.
16
+ *
17
+ * What is refused, and why each one is a distinct decision:
18
+ *
19
+ * - an ABSOLUTE path (`/etc/passwd`, `C:\x`): `path.join(dir, abs)` discards the
20
+ * folder the user named, so the write would land somewhere the dialog never
21
+ * showed. `resolveWithinRoot` catches most of it, but a drive-relative `C:x`
22
+ * normalizes under the root and would silently create a file named `C:x`.
23
+ * - any `..` SEGMENT: the escape itself. `resolveWithinRoot` would refuse a
24
+ * result outside the root, but `a/../b.ts` resolves INSIDE it, so the refusal
25
+ * would be about the wrong thing — the user would get "Invalid path" for a
26
+ * path that looks fine.
27
+ * - a NUL byte: it truncates the path at the syscall boundary.
28
+ *
29
+ * A backslash is treated as a separator rather than refused: a Windows user
30
+ * types `src\lib\x.ts` into the same field, and refusing it would be a rule the
31
+ * field cannot explain. Empty and `.` segments are dropped, so `src//x.ts` and
32
+ * `./src/x.ts` name the same file as `src/x.ts`.
33
+ *
34
+ * Returns the normalized POSIX-relative path, or null when it names nothing.
35
+ */
36
+ export function normalizeRelativePath(raw: string): string | null {
37
+ const trimmed = raw.trim().replace(/\\/g, '/');
38
+ if (!trimmed || trimmed.includes('\0')) return null;
39
+ if (trimmed.startsWith('/') || /^[a-zA-Z]:/.test(trimmed)) return null;
40
+
41
+ const segments: string[] = [];
42
+ for (const segment of trimmed.split('/')) {
43
+ if (segment === '' || segment === '.') continue;
44
+ if (segment === '..') return null;
45
+ segments.push(segment);
46
+ }
47
+ return segments.length > 0 ? segments.join('/') : null;
48
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The WORKTREE branch of the scoped-root allow-list.
8
+ *
9
+ * A chat started in a linked checkout runs THERE, outside its folder's
10
+ * `project_path` — and `resolveRoot` answered the fallback for it (measured), so
11
+ * Files/Search/Git/Terminal silently showed the app root while the agent wrote
12
+ * to the worktree.
13
+ *
14
+ * The rule is deliberately narrow: the candidate's own REPOSITORY ROOT must be a
15
+ * registered project, so a registered project never becomes a filesystem grant.
16
+ * A SUBDIRECTORY of a registered checkout stays refused — the allow-list is exact
17
+ * roots, and a panel is pointed at a worktree root, never at a folder inside it.
18
+ *
19
+ * Its own file, with its own database, because it needs a REAL git repository:
20
+ * `resolveProjectRoot` only answers for a directory holding a `.git` entry, and
21
+ * faking that would test the fake.
22
+ */
23
+
24
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
25
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { join } from 'node:path';
28
+ import { resolveRoot } from '@/server/lib/fs/root';
29
+ import { getDb } from '@/server/db.server';
30
+ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
31
+ import { removeDir } from '@/test-support/remove-dir';
32
+
33
+ const GIT_ENV = { ...Bun.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' };
34
+
35
+ function git(cwd: string, ...args: string[]): string {
36
+ return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV }).stdout.toString();
37
+ }
38
+
39
+ function makeRepo(dir: string): string {
40
+ mkdirSync(dir, { recursive: true });
41
+ git(dir, 'init', '-q', '-b', 'main', '.');
42
+ git(dir, 'config', 'user.email', 'test@example.invalid');
43
+ git(dir, 'config', 'user.name', 'Test');
44
+ writeFileSync(join(dir, 'f.txt'), 'a\n');
45
+ git(dir, 'add', '-A');
46
+ git(dir, 'commit', '-q', '-m', 'base');
47
+ return dir;
48
+ }
49
+
50
+ let workRoot: string;
51
+ const saved = { SYNC_WORKSPACE: Bun.env.SYNC_WORKSPACE, MOCK: Bun.env.MOCK };
52
+
53
+ beforeEach(async () => {
54
+ await isolateDb();
55
+ Bun.env.SYNC_WORKSPACE = 'false';
56
+ Bun.env.MOCK = 'false';
57
+ workRoot = mkdtempSync(join(tmpdir(), 'kabmo-root-worktree-'));
58
+ });
59
+
60
+ afterEach(async () => {
61
+ await releaseDb();
62
+ removeDir(workRoot);
63
+ for (const [key, value] of Object.entries(saved)) {
64
+ if (value === undefined) delete Bun.env[key];
65
+ else Bun.env[key] = value;
66
+ }
67
+ });
68
+
69
+ describe('resolveRoot and worktrees', () => {
70
+ test('a linked worktree of a registered project is allowed', async () => {
71
+ const repo = makeRepo(join(workRoot, 'repo'));
72
+ const linked = join(workRoot, 'linked-wt');
73
+ git(repo, 'worktree', 'add', '-q', '-b', 'feat', linked);
74
+
75
+ const db = await getDb();
76
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
77
+
78
+ expect(await resolveRoot(linked, '/fallback')).toBe(linked);
79
+ });
80
+
81
+ test('a SUBDIRECTORY of a registered checkout is still refused', async () => {
82
+ const repo = makeRepo(join(workRoot, 'repo-sub'));
83
+ const sub = join(repo, 'src');
84
+ mkdirSync(sub, { recursive: true });
85
+
86
+ const db = await getDb();
87
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
88
+
89
+ // The allow-list is EXACT roots. Panels point at a worktree root, which is
90
+ // what the new branch admits; widening it to subdirectories would turn a
91
+ // registered project into a filesystem grant for anything beneath it.
92
+ expect(await resolveRoot(sub, '/fallback')).toBe('/fallback');
93
+ });
94
+
95
+ test('a worktree of an UNREGISTERED repository is refused', async () => {
96
+ const registered = makeRepo(join(workRoot, 'registered'));
97
+ const stranger = makeRepo(join(workRoot, 'stranger'));
98
+ const strangerWt = join(workRoot, 'stranger-wt');
99
+ git(stranger, 'worktree', 'add', '-q', '-b', 's', strangerWt);
100
+
101
+ const db = await getDb();
102
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', registered]);
103
+
104
+ expect(await resolveRoot(strangerWt, '/fallback')).toBe('/fallback');
105
+ expect(await resolveRoot(stranger, '/fallback')).toBe('/fallback');
106
+ });
107
+
108
+ test('an exact registered project path is still honored, and a missing one falls back', async () => {
109
+ const repo = makeRepo(join(workRoot, 'exact'));
110
+ const db = await getDb();
111
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
112
+
113
+ expect(await resolveRoot(repo, '/fallback')).toBe(repo);
114
+ expect(await resolveRoot(join(workRoot, 'gone'), '/fallback')).toBe('/fallback');
115
+ });
116
+ });
@@ -14,6 +14,8 @@
14
14
  import path from 'path';
15
15
  import { getDb } from '@/server/db.server';
16
16
  import { pathExists } from '@/server/lib/omp/core/paths';
17
+ import { canonicalPath } from '@/server/lib/fs/canonical';
18
+ import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
17
19
 
18
20
  const APP_ROOT = process.cwd();
19
21
 
@@ -124,8 +126,18 @@ export async function getDefaultFsRoot(mock: boolean): Promise<string> {
124
126
  * the server is allowed to operate on.
125
127
  *
126
128
  * Returns `fallback` when `rawRoot` is missing, does not exist, or is not on
127
- * the allow-list (app root / subpath / registered project_path), so existing
128
- * callers keep their previous behavior for unscoped requests.
129
+ * the allow-list (app root / subpath / registered project_path / **worktree of
130
+ * a registered project**), so existing callers keep their previous behavior for
131
+ * unscoped requests.
132
+ *
133
+ * The worktree branch is why a worktree session's panels can point anywhere:
134
+ * a chat started in a linked checkout runs there, and `resolveRoot` answered the
135
+ * fallback for it — measured, so Files/Search/Git/Terminal silently showed the
136
+ * app root while the agent wrote to the worktree. The rule is deliberately
137
+ * narrow: the candidate's own REPOSITORY ROOT must be a registered project, so a
138
+ * registered project never becomes a filesystem grant. A SUBDIRECTORY is still
139
+ * refused — the allow-list stays exact roots, and a panel is pointed at the
140
+ * worktree root, never at a folder inside it.
129
141
  */
130
142
  export async function resolveRoot(
131
143
  rawRoot: string | null | undefined,
@@ -134,22 +146,39 @@ export async function resolveRoot(
134
146
  if (!rawRoot || !rawRoot.trim()) return fallback;
135
147
 
136
148
  const resolved = path.resolve(rawRoot.trim());
149
+ if (!(await pathExists(resolved))) return fallback;
137
150
 
138
151
  // Fast path: the app root and anything beneath it are always allowed.
139
152
  if (resolved === APP_ROOT || resolved.startsWith(APP_ROOT + path.sep)) {
140
- return (await pathExists(resolved)) ? resolved : fallback;
153
+ return resolved;
141
154
  }
142
155
 
143
- // Registered workspaces are user-opted project roots — allow exact matches.
156
+ // Registered workspaces are user-opted project roots — allow exact matches,
157
+ // compared by canonical path so a `/tmp` vs `/private/tmp` spelling of one
158
+ // directory is not read as two.
144
159
  try {
145
160
  const db = await getDb();
146
- const row = await db.get(
147
- 'SELECT project_path FROM workspace_folders WHERE project_path IS NOT NULL AND project_path = ? LIMIT 1',
148
- [resolved]
149
- );
150
- if (row && (await pathExists(resolved))) return resolved;
161
+ const rows = (await db.all(
162
+ 'SELECT project_path FROM workspace_folders WHERE project_path IS NOT NULL'
163
+ )) as { project_path: string }[];
164
+ const canonical = await canonicalPath(resolved);
165
+ for (const row of rows) {
166
+ if ((await canonicalPath(row.project_path)) === canonical) return resolved;
167
+ }
168
+
169
+ // A worktree ROOT: `resolveProjectRoot` only asks git when the candidate
170
+ // holds a `.git` entry, so a linked checkout resolves to the main repository
171
+ // while an ordinary subdirectory resolves to ITSELF and stays refused. That
172
+ // is what keeps this rule at "the roots of registered repositories" instead
173
+ // of "anything beneath one".
174
+ if (rows.length > 0) {
175
+ const projectRoot = await canonicalPath(await resolveProjectRoot(resolved));
176
+ for (const row of rows) {
177
+ if ((await canonicalPath(row.project_path)) === projectRoot) return resolved;
178
+ }
179
+ }
151
180
  } catch {
152
- // Database unavailable — fall through to the safe fallback.
181
+ // Database or git unavailable — fall through to the safe fallback.
153
182
  }
154
183
 
155
184
  return fallback;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Carry-over: the one capability lost by not calling `omp worktree add`.
8
+ *
9
+ * Plain git does NOT copy ignored files — measured: a repository with
10
+ * `node_modules` and a `.gitignore` yields a worktree holding only the tracked
11
+ * files, so the agent's first `bun run dev` fails on a project that was working
12
+ * a second earlier. This copies the ignored build folders with a copy-on-write
13
+ * clone where the filesystem has one.
14
+ *
15
+ * Every path is checked with `git check-ignore`: the allow-list only narrows
16
+ * WHAT to ask about, it never substitutes for git's answer. A candidate that is
17
+ * tracked, or that does not exist, is skipped rather than guessed at.
18
+ */
19
+
20
+ import { existsSync, statSync } from 'fs';
21
+ import { copyFile, mkdir } from 'fs/promises';
22
+ import path from 'path';
23
+ import { runGit } from '@/server/lib/fs/git-run';
24
+ import { pathExists } from '@/server/lib/omp/core/paths';
25
+
26
+ /** Folders worth asking git about. A name here that git does not ignore is skipped. */
27
+ export const CARRY_CANDIDATES = [
28
+ 'node_modules',
29
+ '.venv',
30
+ 'venv',
31
+ 'target',
32
+ 'dist',
33
+ 'build',
34
+ 'out',
35
+ '.next',
36
+ '.nuxt',
37
+ '.turbo',
38
+ '.parcel-cache',
39
+ '.cache',
40
+ 'vendor',
41
+ '__pycache__',
42
+ ];
43
+
44
+ /** Files whose absence breaks a run and whose contents are not in git. */
45
+ export const CARRY_FILE_CANDIDATES = ['.env', '.env.local', '.env.development', '.env.production'];
46
+
47
+ export type CopyStrategy = 'clonefile' | 'reflink' | 'plain';
48
+
49
+ export function copyStrategy(platform: NodeJS.Platform = process.platform): CopyStrategy {
50
+ if (platform === 'darwin') return 'clonefile';
51
+ if (platform === 'linux') return 'reflink';
52
+ return 'plain';
53
+ }
54
+
55
+ export interface CarryResult {
56
+ copied: string[];
57
+ /** Candidates git does not call ignored, or that are not there. */
58
+ skipped: string[];
59
+ strategy: CopyStrategy;
60
+ }
61
+
62
+ async function isIgnored(repo: string, relative: string): Promise<boolean> {
63
+ const probe = await runGit(['check-ignore', '--quiet', '--', relative], { cwd: repo });
64
+ return probe.exitCode === 0;
65
+ }
66
+
67
+ /** `cp -c` (APFS clonefile) / `cp --reflink=auto` / `cp -R`, plus a Windows branch. */
68
+ async function copyTree(source: string, destination: string, strategy: CopyStrategy): Promise<void> {
69
+ await mkdir(path.dirname(destination), { recursive: true });
70
+ // A regular FILE (`.env`) is not a tree, and every branch below is a tree
71
+ // copy. `robocopy` takes directories: measured on a Windows runner, a `.env`
72
+ // source produced no file at the destination and no exit code the caller
73
+ // reads as a failure, so the carry reported success and the worktree had no
74
+ // env file — the one thing that path exists to prevent.
75
+ if (!statSync(source).isDirectory()) {
76
+ await copyFile(source, destination);
77
+ return;
78
+ }
79
+ if (process.platform === 'win32') {
80
+ const result = Bun.spawnSync({
81
+ cmd: ['robocopy', source, destination, '/MIR', '/NFL', '/NDL', '/NJH', '/NJS', '/NC', '/NS', '/NP'],
82
+ stdout: 'ignore',
83
+ stderr: 'pipe',
84
+ });
85
+ // robocopy reports 0–7 as success codes; anything from 8 up is a real failure.
86
+ if (result.exitCode !== null && result.exitCode >= 8) {
87
+ throw new Error(`Failed to copy ${path.basename(source)}: ${result.stderr.toString().trim()}`);
88
+ }
89
+ return;
90
+ }
91
+ const args = strategy === 'clonefile' ? ['-cR'] : strategy === 'reflink' ? ['--reflink=auto', '-R'] : ['-R'];
92
+ const result = Bun.spawnSync({ cmd: ['cp', ...args, source, destination], stdout: 'ignore', stderr: 'pipe' });
93
+ if (result.exitCode !== 0) {
94
+ throw new Error(`Failed to copy ${path.basename(source)}: ${result.stderr.toString().trim()}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Copy the project's ignored build folders and env files into a fresh worktree.
100
+ *
101
+ * A failure here is not a failed worktree: the caller reports what could not be
102
+ * carried and keeps the checkout, because a worktree without `node_modules` is
103
+ * still a worktree.
104
+ */
105
+ export async function carryIgnoredInto(input: {
106
+ primaryRoot: string;
107
+ worktreePath: string;
108
+ candidates?: string[];
109
+ }): Promise<CarryResult> {
110
+ const strategy = copyStrategy();
111
+ const copied: string[] = [];
112
+ const skipped: string[] = [];
113
+
114
+ const candidates = [...(input.candidates ?? CARRY_CANDIDATES), ...CARRY_FILE_CANDIDATES];
115
+ for (const candidate of candidates) {
116
+ const source = path.join(input.primaryRoot, candidate);
117
+ if (!existsSync(source)) continue;
118
+ if (!(await isIgnored(input.primaryRoot, candidate))) {
119
+ skipped.push(candidate);
120
+ continue;
121
+ }
122
+ const destination = path.join(input.worktreePath, candidate);
123
+ // A tracked path already checked out in the worktree, or a directory that
124
+ // appeared meanwhile, is not ours to overwrite.
125
+ if (await pathExists(destination)) {
126
+ skipped.push(candidate);
127
+ continue;
128
+ }
129
+ await copyTree(source, destination, strategy);
130
+ copied.push(candidate);
131
+ }
132
+
133
+ return { copied, skipped, strategy };
134
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Creating a worktree, against real repositories.
8
+ *
9
+ * The refusals are the point of this file. git creates the branch BEFORE it
10
+ * discovers the problem — measured on 2.56: `Preparing worktree` is printed,
11
+ * the branch exists, and only then does the command fail with "already checked
12
+ * out", "already exists", or (unborn) `object not found: HEAD`. A create that
13
+ * relied on git's own ordering would leave a branch behind for an operation the
14
+ * user was told had failed, so every check runs first and this file proves
15
+ * nothing is left over.
16
+ *
17
+ * The ignored-file carry is the second half: plain `git worktree add` does NOT
18
+ * copy `node_modules`, so a fresh worktree cannot run a project that worked a
19
+ * second earlier in the checkout.
20
+ */
21
+
22
+ import { afterAll, describe, expect, test } from 'bun:test';
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { canonicalPath } from '@/server/lib/fs/canonical';
27
+ import { createWorktree, planWorktree, type CreateWorktreeDeps } from '@/server/lib/fs/worktree/create';
28
+ import { WorktreeError } from '@/server/lib/fs/worktree/errors';
29
+ import { listWorktrees } from '@/server/lib/fs/worktree/list';
30
+ import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
31
+ import { removeDir } from '@/test-support/remove-dir';
32
+
33
+ const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-create-'));
34
+ const DATA_DIR = path.join(FIXTURE_ROOT, 'data');
35
+
36
+ function git(cwd: string, ...args: string[]): string {
37
+ const result = Bun.spawnSync({
38
+ cmd: ['git', ...args],
39
+ cwd,
40
+ env: { ...GIT_ENV_FOR_TESTS },
41
+ });
42
+ return result.stdout.toString();
43
+ }
44
+
45
+ const GIT_ENV_FOR_TESTS = {
46
+ ...Bun.env,
47
+ GIT_EDITOR: 'true',
48
+ GIT_TERMINAL_PROMPT: '0',
49
+ GIT_AUTHOR_NAME: 'Test',
50
+ GIT_AUTHOR_EMAIL: 'test@example.invalid',
51
+ GIT_COMMITTER_NAME: 'Test',
52
+ GIT_COMMITTER_EMAIL: 'test@example.invalid',
53
+ };
54
+
55
+ /** A repository with one commit, a gitignored `node_modules` and a `.env`. */
56
+ function makeRepo(name: string): string {
57
+ const dir = path.join(FIXTURE_ROOT, name);
58
+ fs.mkdirSync(path.join(dir, 'node_modules', 'dep'), { recursive: true });
59
+ fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n.env\n');
60
+ fs.writeFileSync(path.join(dir, 'node_modules', 'dep', 'index.js'), 'module.exports = {};\n');
61
+ fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1\n');
62
+ fs.writeFileSync(path.join(dir, 'readme.md'), 'hello\n');
63
+ git(dir, 'init', '-q', '-b', 'main', '.');
64
+ git(dir, 'add', '-A');
65
+ git(dir, 'commit', '-q', '-m', 'base');
66
+ return dir;
67
+ }
68
+
69
+ function makeEmptyRepo(name: string): string {
70
+ const dir = path.join(FIXTURE_ROOT, name);
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ git(dir, 'init', '-q', '-b', 'main', '.');
73
+ return dir;
74
+ }
75
+
76
+ /** The registry, as a Map — the production one is a SQLite row write. */
77
+ function depsFor(records: Map<string, { startRef: string; projectId: string }>): CreateWorktreeDeps {
78
+ return {
79
+ identityFor: async () => null,
80
+ carryIgnored: true,
81
+ dataDir: DATA_DIR,
82
+ configuredDirectory: null,
83
+ record: async (row) => {
84
+ records.set(await canonicalPath(row.path), { startRef: row.startRef, projectId: row.projectId });
85
+ },
86
+ };
87
+ }
88
+
89
+ afterAll(async () => {
90
+ await invalidateWorktreeTopology();
91
+ removeDir(FIXTURE_ROOT);
92
+ });
93
+
94
+ describe('createWorktree', () => {
95
+ test('a new branch, a folder that follows it, and our prefix stripped', async () => {
96
+ const repo = makeRepo('create-basic');
97
+ const records = new Map<string, { startRef: string; projectId: string }>();
98
+ const created = await createWorktree(
99
+ { projectPath: repo, mode: 'new', branch: 'wt/cosmic-dolphin' },
100
+ depsFor(records),
101
+ );
102
+
103
+ expect(created.branch).toBe('wt/cosmic-dolphin');
104
+ expect(path.basename(created.path)).toBe('cosmic-dolphin');
105
+ expect(fs.existsSync(created.path)).toBe(true);
106
+ expect(git(created.path, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('wt/cosmic-dolphin');
107
+ // `start_ref` is the one value git cannot answer later, so it is recorded.
108
+ expect(records.get(await canonicalPath(created.path))?.startRef).toBe('HEAD');
109
+ // The default root is keyed by the repository's identity. Canonicalized
110
+ // because the fixture root itself resolves through a symlink on macOS.
111
+ expect(created.path.startsWith(await canonicalPath(path.join(DATA_DIR, 'worktrees')))).toBe(true);
112
+ });
113
+
114
+ test('ignored build folders and .env come along, and the worktree stays clean', async () => {
115
+ const repo = makeRepo('create-carry');
116
+ const created = await createWorktree(
117
+ { projectPath: repo, mode: 'new', branch: 'wt/carry' },
118
+ depsFor(new Map()),
119
+ );
120
+
121
+ expect(fs.existsSync(path.join(created.path, 'node_modules', 'dep', 'index.js'))).toBe(true);
122
+ expect(fs.readFileSync(path.join(created.path, '.env'), 'utf8')).toContain('SECRET');
123
+ expect(created.carried?.copied).toContain('node_modules');
124
+ expect(created.carried?.strategy).toBe(
125
+ process.platform === 'darwin' ? 'clonefile' : process.platform === 'linux' ? 'reflink' : 'plain',
126
+ );
127
+ // A carry must not make the new worktree dirty: `git status` there is what a
128
+ // commit from that tree would be built on.
129
+ const status = git(created.path, 'status', '--porcelain').trim();
130
+ expect(status).toBe('');
131
+ });
132
+
133
+ test('the carry can be turned off, and turns nothing else off', async () => {
134
+ const repo = makeRepo('create-no-carry');
135
+ const created = await createWorktree(
136
+ { projectPath: repo, mode: 'new', branch: 'wt/no-carry', carryIgnored: false },
137
+ depsFor(new Map()),
138
+ );
139
+ expect(fs.existsSync(path.join(created.path, 'node_modules'))).toBe(false);
140
+ expect(created.carried).toBeNull();
141
+ });
142
+
143
+ test('an existing branch is checked out as it is, and gets no start ref', async () => {
144
+ const repo = makeRepo('create-existing');
145
+ git(repo, 'branch', 'free-branch');
146
+ const created = await createWorktree(
147
+ { projectPath: repo, mode: 'existing', branch: 'free-branch' },
148
+ depsFor(new Map()),
149
+ );
150
+
151
+ expect(created.branch).toBe('free-branch');
152
+ expect(created.createdBranch).toBe(false);
153
+ expect(created.startRef).toBe('');
154
+ expect(git(created.path, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('free-branch');
155
+ });
156
+
157
+ test('an existing branch already checked out is refused before anything is created', async () => {
158
+ const repo = makeRepo('create-in-use');
159
+ const failure = createWorktree(
160
+ { projectPath: repo, mode: 'existing', branch: 'main' },
161
+ depsFor(new Map()),
162
+ );
163
+ await expect(failure).rejects.toThrow(/already checked out/i);
164
+ expect((await listWorktrees(repo)).filter((entry) => !entry.isPrimary).length).toBe(0);
165
+ });
166
+
167
+ test('an unborn repository is refused with its own code', async () => {
168
+ const empty = makeEmptyRepo('create-unborn');
169
+ const failure = createWorktree({ projectPath: empty, mode: 'new', branch: 'wt/x' }, depsFor(new Map()));
170
+ await expect(failure).rejects.toBeInstanceOf(WorktreeError);
171
+ await failure.catch((error: WorktreeError) => expect(error.code).toBe('no_commits'));
172
+ });
173
+
174
+ test('a branch that already exists is refused, and leaves no second worktree', async () => {
175
+ const repo = makeRepo('create-dup');
176
+ const records = new Map<string, { startRef: string; projectId: string }>();
177
+ const first = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dup' }, depsFor(records));
178
+
179
+ const failure = createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dup' }, depsFor(records));
180
+ await failure.catch((error: WorktreeError) => expect(error.code).toBe('branch_exists'));
181
+
182
+ const entries = await listWorktrees(repo);
183
+ expect(entries.filter((entry) => !entry.isPrimary).length).toBe(1);
184
+ expect(entries.some((entry) => entry.path === first.path)).toBe(true);
185
+ });
186
+
187
+ test('an invalid branch name never reaches git', async () => {
188
+ const repo = makeRepo('create-bad-name');
189
+ const failure = createWorktree({ projectPath: repo, mode: 'new', branch: 'has space' }, depsFor(new Map()));
190
+ await failure.catch((error: WorktreeError) => expect(error.code).toBe('invalid_name'));
191
+ });
192
+
193
+ test('a taken folder name is suffixed, never refused', async () => {
194
+ const repo = makeRepo('create-collide');
195
+ const records = new Map<string, { startRef: string; projectId: string }>();
196
+ const first = await createWorktree(
197
+ { projectPath: repo, mode: 'new', branch: 'wt/one', worktreeName: 'same' },
198
+ depsFor(records),
199
+ );
200
+ const second = await createWorktree(
201
+ { projectPath: repo, mode: 'new', branch: 'wt/two', worktreeName: 'same' },
202
+ depsFor(records),
203
+ );
204
+
205
+ expect(path.basename(first.path)).toBe('same');
206
+ expect(path.basename(second.path)).not.toBe('same');
207
+ expect(path.basename(second.path).startsWith('same-')).toBe(true);
208
+ });
209
+
210
+ test('a non-Latin branch gets a generated folder instead of an empty one', async () => {
211
+ const repo = makeRepo('create-cjk');
212
+ const created = await createWorktree(
213
+ { projectPath: repo, mode: 'new', branch: '分支/测试' },
214
+ depsFor(new Map()),
215
+ );
216
+ expect(path.basename(created.path).length).toBeGreaterThan(0);
217
+ // The branch is what the caller typed, verbatim — only the FOLDER needed a
218
+ // generated name because the branch left no slug.
219
+ expect(created.branch).toBe('分支/测试');
220
+ });
221
+ });
222
+
223
+ describe('planWorktree', () => {
224
+ test('answers the same refusals without touching disk', async () => {
225
+ const repo = makeRepo('plan-none');
226
+ const plan = await planWorktree(
227
+ { projectPath: repo, mode: 'new', branch: 'wt/planned' },
228
+ { identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: null },
229
+ );
230
+ expect(plan.branch).toBe('wt/planned');
231
+ expect(fs.existsSync(plan.directory)).toBe(false);
232
+
233
+ const dup = planWorktree(
234
+ { projectPath: repo, mode: 'existing', branch: 'main' },
235
+ { identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: null },
236
+ );
237
+ await dup.catch((error: WorktreeError) => expect(error.code).toBe('branch_in_use'));
238
+ });
239
+
240
+ test('a configured directory wins over the data-dir default', async () => {
241
+ const repo = makeRepo('plan-configured');
242
+ const configured = path.join(FIXTURE_ROOT, 'elsewhere');
243
+ const plan = await planWorktree(
244
+ { projectPath: repo, mode: 'new', branch: 'wt/configured' },
245
+ { identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: configured },
246
+ );
247
+ expect(plan.directory.startsWith(await canonicalPath(configured))).toBe(true);
248
+ });
249
+ });