@kabmo-labs/kabmo 0.7.0 → 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-0wvbca0b.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 +73 -0
- 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 +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- 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,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Repository identity, and the settings-driven location rule.
|
|
8
|
+
*
|
|
9
|
+
* Identity has one property that is easy to get wrong and expensive when it is:
|
|
10
|
+
* the derivation (`rev-list --max-parents=0 --all`, sorted, first) is NOT stable.
|
|
11
|
+
* Measured: a repository that gains a branch with unrelated history inserts a
|
|
12
|
+
* second root and the sorted-first value MOVES, so a repository would start
|
|
13
|
+
* looking for its worktrees in a different folder. That is why a stored value
|
|
14
|
+
* always wins — the behavior this file pins.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
22
|
+
import { deriveProjectId, resolveProjectId } from '@/server/lib/fs/worktree/project-id';
|
|
23
|
+
import {
|
|
24
|
+
defaultWorktreeRoot,
|
|
25
|
+
generateAvailableName,
|
|
26
|
+
resolveConfiguredWorktreeRoot,
|
|
27
|
+
resolveTargetDirectory,
|
|
28
|
+
worktreeNameCandidates,
|
|
29
|
+
} from '@/server/lib/fs/worktree/location';
|
|
30
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
31
|
+
|
|
32
|
+
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-location-'));
|
|
33
|
+
|
|
34
|
+
const GIT_ENV_FOR_TESTS = {
|
|
35
|
+
...Bun.env,
|
|
36
|
+
GIT_EDITOR: 'true',
|
|
37
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
38
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
39
|
+
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
40
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
41
|
+
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function git(cwd: string, ...args: string[]): string {
|
|
45
|
+
return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV_FOR_TESTS }).stdout.toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeRepo(name: string): string {
|
|
49
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
52
|
+
fs.writeFileSync(path.join(dir, 'f.txt'), 'a\n');
|
|
53
|
+
git(dir, 'add', '-A');
|
|
54
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
removeDir(FIXTURE_ROOT);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('deriveProjectId', () => {
|
|
63
|
+
test('two clones of one repository share the identity', async () => {
|
|
64
|
+
const bare = path.join(FIXTURE_ROOT, 'origin.git');
|
|
65
|
+
fs.mkdirSync(bare, { recursive: true });
|
|
66
|
+
git(bare, 'init', '-q', '--bare');
|
|
67
|
+
|
|
68
|
+
const first = makeRepo('clone-a');
|
|
69
|
+
git(first, 'remote', 'add', 'origin', bare);
|
|
70
|
+
const head = git(first, 'rev-parse', 'HEAD').trim();
|
|
71
|
+
git(first, 'push', '-q', 'origin', `${head}:refs/heads/main`);
|
|
72
|
+
|
|
73
|
+
const second = path.join(FIXTURE_ROOT, 'clone-b');
|
|
74
|
+
git(FIXTURE_ROOT, 'clone', '-q', bare, second);
|
|
75
|
+
|
|
76
|
+
const a = await deriveProjectId(first);
|
|
77
|
+
const b = await deriveProjectId(second);
|
|
78
|
+
expect(a).not.toBeNull();
|
|
79
|
+
expect(a).toBe(b);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an unborn repository has no identity — the `no_commits` refusal', async () => {
|
|
83
|
+
const empty = path.join(FIXTURE_ROOT, 'unborn');
|
|
84
|
+
fs.mkdirSync(empty, { recursive: true });
|
|
85
|
+
git(empty, 'init', '-q', '-b', 'main', '.');
|
|
86
|
+
expect(await deriveProjectId(empty)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a stored identity wins over a re-derivation', async () => {
|
|
90
|
+
const repo = makeRepo('identity-stored');
|
|
91
|
+
const stored = 'stored-project-id';
|
|
92
|
+
const identityFor = async () => stored;
|
|
93
|
+
expect(await resolveProjectId(await canonicalPath(repo), identityFor)).toBe(stored);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an added orphan branch changes the derivation — hence the cache', async () => {
|
|
97
|
+
const repo = makeRepo('identity-orphan');
|
|
98
|
+
const derived = await deriveProjectId(repo);
|
|
99
|
+
expect(derived).not.toBeNull();
|
|
100
|
+
|
|
101
|
+
git(repo, 'checkout', '-q', '--orphan', 'other');
|
|
102
|
+
git(repo, 'rm', '-rq', '--cached', '.');
|
|
103
|
+
git(repo, 'add', '-A');
|
|
104
|
+
git(repo, 'commit', '-q', '-m', 'other root');
|
|
105
|
+
|
|
106
|
+
// Two roots now exist, so the sorted-first value is a different question
|
|
107
|
+
// than it was — which is exactly why the stored value is consulted first.
|
|
108
|
+
const roots = git(repo, 'rev-list', '--max-parents=0', '--all')
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((line) => line.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
expect(roots.length).toBe(2);
|
|
113
|
+
|
|
114
|
+
// The stored value is consulted first, whatever the derivation now answers.
|
|
115
|
+
const stored = derived as string;
|
|
116
|
+
const later = await resolveProjectId(await canonicalPath(repo), async () => stored);
|
|
117
|
+
expect(later).toBe(stored);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('worktree location', () => {
|
|
122
|
+
// A literal POSIX spelling is not portable: `path.resolve('/repo', '.worktrees')`
|
|
123
|
+
// answers `D:\repo\.worktrees` on a Windows runner, which is the CORRECT
|
|
124
|
+
// answer for that platform and not a product bug. Every expectation is
|
|
125
|
+
// therefore built through `path`, so the rule is asserted rather than the
|
|
126
|
+
// separator.
|
|
127
|
+
const checkout = path.resolve('/repo');
|
|
128
|
+
const home = path.resolve('/home/u');
|
|
129
|
+
|
|
130
|
+
test('three spellings, and the resolved value is always the parent', () => {
|
|
131
|
+
expect(resolveConfiguredWorktreeRoot('', checkout)).toBeNull();
|
|
132
|
+
expect(resolveConfiguredWorktreeRoot(null, checkout)).toBeNull();
|
|
133
|
+
expect(resolveConfiguredWorktreeRoot(' ', checkout)).toBeNull();
|
|
134
|
+
expect(resolveConfiguredWorktreeRoot('../wt', checkout)).toBe(path.resolve(checkout, '..', 'wt'));
|
|
135
|
+
expect(resolveConfiguredWorktreeRoot('.worktrees', checkout)).toBe(path.join(checkout, '.worktrees'));
|
|
136
|
+
expect(resolveConfiguredWorktreeRoot('/abs/wt', checkout)).toBe(path.resolve(checkout, '/abs/wt'));
|
|
137
|
+
expect(resolveConfiguredWorktreeRoot('~/wt', checkout, home)).toBe(path.join(home, 'wt'));
|
|
138
|
+
expect(resolveConfiguredWorktreeRoot('~', checkout, home)).toBe(home);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the default root is keyed per repository, outside every checkout', () => {
|
|
142
|
+
expect(defaultWorktreeRoot('/data', 'abc123')).toBe(path.join('/data', 'worktrees', 'abc123'));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('the slug is tried first, then slug-word-word', () => {
|
|
146
|
+
const candidates = worktreeNameCandidates('quick-otter');
|
|
147
|
+
expect(candidates[0]).toBe('quick-otter');
|
|
148
|
+
expect(candidates[1]).toMatch(/^quick-otter-[a-z]+-[a-z]+$/);
|
|
149
|
+
expect(candidates.length).toBe(26);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a taken folder moves to the next candidate rather than refusing', async () => {
|
|
153
|
+
const root = path.join(FIXTURE_ROOT, 'collide');
|
|
154
|
+
fs.mkdirSync(path.join(root, 'quick-otter'), { recursive: true });
|
|
155
|
+
|
|
156
|
+
const target = await resolveTargetDirectory({
|
|
157
|
+
root,
|
|
158
|
+
preferredName: 'quick-otter',
|
|
159
|
+
generatedBranch: (name) => `wt/${name}`,
|
|
160
|
+
branchExists: async () => false,
|
|
161
|
+
});
|
|
162
|
+
expect(target.directory).not.toBe(path.join(root, 'quick-otter'));
|
|
163
|
+
expect(target.name.startsWith('quick-otter-')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a taken BRANCH is skipped too, so folder and branch never disagree', async () => {
|
|
167
|
+
const root = path.join(FIXTURE_ROOT, 'branch-collide');
|
|
168
|
+
fs.mkdirSync(root, { recursive: true });
|
|
169
|
+
|
|
170
|
+
const target = await resolveTargetDirectory({
|
|
171
|
+
root,
|
|
172
|
+
preferredName: 'quick-otter',
|
|
173
|
+
generatedBranch: (name) => `wt/${name}`,
|
|
174
|
+
branchExists: async (branch) => branch === 'wt/quick-otter',
|
|
175
|
+
});
|
|
176
|
+
expect(target.branch).not.toBe('wt/quick-otter');
|
|
177
|
+
expect(target.branch).toBe(`wt/${target.name}`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a typed branch is used verbatim in both modes', async () => {
|
|
181
|
+
const root = path.join(FIXTURE_ROOT, 'verbatim');
|
|
182
|
+
fs.mkdirSync(root, { recursive: true });
|
|
183
|
+
const target = await resolveTargetDirectory({
|
|
184
|
+
root,
|
|
185
|
+
preferredName: 'payments',
|
|
186
|
+
explicitBranch: 'feat/payments',
|
|
187
|
+
generatedBranch: (name) => `wt/${name}`,
|
|
188
|
+
// Even when every generated name is taken, the typed branch is used.
|
|
189
|
+
branchExists: async () => true,
|
|
190
|
+
});
|
|
191
|
+
expect(target.branch).toBe('feat/payments');
|
|
192
|
+
expect(target.name).toBe('payments');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a generated name avoids both a folder and a branch', async () => {
|
|
196
|
+
const root = path.join(FIXTURE_ROOT, 'generated');
|
|
197
|
+
fs.mkdirSync(root, { recursive: true });
|
|
198
|
+
const name = await generateAvailableName({
|
|
199
|
+
root,
|
|
200
|
+
branchExists: async (branch) => branch === 'wt/blocked',
|
|
201
|
+
attempts: 4,
|
|
202
|
+
});
|
|
203
|
+
expect(name).not.toBe('blocked');
|
|
204
|
+
expect(name).toMatch(/^[a-z]+-[a-z]+$/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a worktree lands — OpenChamber's rule, adopted as-is:
|
|
8
|
+
*
|
|
9
|
+
* root = configured directory (resolved) || <data dir>/worktrees/<projectID>
|
|
10
|
+
* path = <root>/<name>
|
|
11
|
+
*
|
|
12
|
+
* Three spellings for the setting, unchanged from theirs: a relative path
|
|
13
|
+
* resolves against the CANONICAL checkout (so a linked worktree still reads the
|
|
14
|
+
* project's own setting), an absolute path is used as-is, and `~` means the home
|
|
15
|
+
* directory. The resolved value is always the PARENT — the name is appended.
|
|
16
|
+
*
|
|
17
|
+
* A name is never refused because it is taken: candidates are tried in order, so
|
|
18
|
+
* "quick otter" in a repository that already has one becomes
|
|
19
|
+
* `quick-otter-cosmic-dolphin` rather than an error the user has to read.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
25
|
+
import {
|
|
26
|
+
generateBranchSlug,
|
|
27
|
+
slugWorktreeName,
|
|
28
|
+
WORKTREE_BRANCH_PREFIX,
|
|
29
|
+
} from '@/shared/lib/workspace/worktree/branch-name';
|
|
30
|
+
|
|
31
|
+
/** How many names to try before giving up. OpenChamber's 26, kept. */
|
|
32
|
+
export const WORKTREE_NAME_ATTEMPTS = 26;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the setting into a parent directory. `null` when unset, so the caller
|
|
36
|
+
* keeps its own default rather than resolving an empty string to the checkout.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveConfiguredWorktreeRoot(
|
|
39
|
+
configured: unknown,
|
|
40
|
+
primaryCheckout: string,
|
|
41
|
+
home: string = homedir(),
|
|
42
|
+
): string | null {
|
|
43
|
+
const trimmed = String(configured ?? '').trim();
|
|
44
|
+
if (!trimmed) return null;
|
|
45
|
+
if (trimmed === '~') return path.resolve(home);
|
|
46
|
+
if (/^~[\\/]/.test(trimmed)) return path.resolve(home, trimmed.slice(2));
|
|
47
|
+
return path.resolve(primaryCheckout, trimmed);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `<data dir>/worktrees/<projectID>` — outside every checkout, keyed per repository. */
|
|
51
|
+
export function defaultWorktreeRoot(dataDir: string, projectId: string): string {
|
|
52
|
+
return path.join(dataDir, 'worktrees', projectId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `<slug>`, then `<slug>-<adjective>-<noun>` × attempts-1. */
|
|
56
|
+
export function worktreeNameCandidates(preferredName: string, attempts = WORKTREE_NAME_ATTEMPTS): string[] {
|
|
57
|
+
const base = slugWorktreeName(preferredName);
|
|
58
|
+
if (!base) {
|
|
59
|
+
return Array.from({ length: attempts }, () => generateBranchSlug());
|
|
60
|
+
}
|
|
61
|
+
return Array.from({ length: attempts }, (_, index) =>
|
|
62
|
+
index === 0 ? base : `${base}-${generateBranchSlug()}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolvedWorktreeTarget {
|
|
67
|
+
name: string;
|
|
68
|
+
directory: string;
|
|
69
|
+
branch: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ResolveTargetInput {
|
|
73
|
+
root: string;
|
|
74
|
+
preferredName: string;
|
|
75
|
+
/** The dialog's branch, used verbatim and only the directory is checked. */
|
|
76
|
+
explicitBranch?: string | null;
|
|
77
|
+
/** The branch a generated candidate would get. */
|
|
78
|
+
generatedBranch: (name: string) => string;
|
|
79
|
+
branchExists: (branch: string) => Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pick a free name. A candidate is rejected when its directory exists OR — for a
|
|
84
|
+
* generated branch — when the branch it would get already exists, so the folder
|
|
85
|
+
* and the branch can never disagree about which one is "the name".
|
|
86
|
+
*/
|
|
87
|
+
export async function resolveTargetDirectory(input: ResolveTargetInput): Promise<ResolvedWorktreeTarget> {
|
|
88
|
+
const explicit = input.explicitBranch?.trim() || null;
|
|
89
|
+
for (const name of worktreeNameCandidates(input.preferredName)) {
|
|
90
|
+
const directory = path.join(input.root, name);
|
|
91
|
+
if (await pathExists(directory)) continue;
|
|
92
|
+
if (explicit) return { name, directory, branch: explicit };
|
|
93
|
+
const branch = input.generatedBranch(name);
|
|
94
|
+
if (await input.branchExists(branch)) continue;
|
|
95
|
+
return { name, directory, branch };
|
|
96
|
+
}
|
|
97
|
+
throw new Error('Failed to generate a unique worktree name');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A generated slug that collides with neither a folder nor a branch. */
|
|
101
|
+
export async function generateAvailableName(input: {
|
|
102
|
+
root: string;
|
|
103
|
+
branchExists: (branch: string) => Promise<boolean>;
|
|
104
|
+
attempts?: number;
|
|
105
|
+
}): Promise<string> {
|
|
106
|
+
const attempts = input.attempts ?? WORKTREE_NAME_ATTEMPTS;
|
|
107
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
108
|
+
const slug = generateBranchSlug();
|
|
109
|
+
if (await pathExists(path.join(input.root, slug))) continue;
|
|
110
|
+
if (await input.branchExists(`${WORKTREE_BRANCH_PREFIX}/${slug}`)) continue;
|
|
111
|
+
return slug;
|
|
112
|
+
}
|
|
113
|
+
throw new Error('Failed to generate a unique worktree name');
|
|
114
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Repository identity — the key a repository's worktrees live under.
|
|
8
|
+
*
|
|
9
|
+
* The derivation is OpenChamber's (`git rev-list --max-parents=0 --all`, sorted,
|
|
10
|
+
* first line: the root commit), and two clones of one repository share it.
|
|
11
|
+
*
|
|
12
|
+
* It is NOT stable on its own, which is why they cache it in
|
|
13
|
+
* `<primary>/.git/opencode`. Measured: adding a branch with unrelated history
|
|
14
|
+
* (`git checkout --orphan`) inserts a second root and MOVES the sorted-first
|
|
15
|
+
* value, so a repository that gained one would look for its worktrees in a
|
|
16
|
+
* different folder. Kabmo does not write into a user's `.git`, so the stored row
|
|
17
|
+
* is the cache and it always wins.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
21
|
+
|
|
22
|
+
/** `rev-list --max-parents=0 --all`, sorted, first. `null` for an unborn branch. */
|
|
23
|
+
export async function deriveProjectId(primaryRoot: string): Promise<string | null> {
|
|
24
|
+
const listed = await runGit(['rev-list', '--max-parents=0', '--all'], {
|
|
25
|
+
cwd: primaryRoot,
|
|
26
|
+
maxBuffer: 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
if (listed.exitCode !== 0) return null;
|
|
29
|
+
const roots = listed.stdout
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.sort((a, b) => a.localeCompare(b));
|
|
34
|
+
return roots[0] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The identity to key this repository's worktrees under.
|
|
39
|
+
*
|
|
40
|
+
* A stored value beats the derivation — that is the whole point of keeping one.
|
|
41
|
+
* Only a repository with no record derives, which is the moment it has no
|
|
42
|
+
* worktree folder to protect.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveProjectId(
|
|
45
|
+
primaryRoot: string,
|
|
46
|
+
identityFor: (primaryRoot: string) => Promise<string | null>,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const stored = await identityFor(primaryRoot);
|
|
49
|
+
if (stored) return stored;
|
|
50
|
+
const derived = await deriveProjectId(primaryRoot);
|
|
51
|
+
if (!derived) throw new Error('This repository has no commits yet.');
|
|
52
|
+
return derived;
|
|
53
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Removing a worktree, against real repositories.
|
|
8
|
+
*
|
|
9
|
+
* Three rules, each measured, each a way work is lost if it is wrong:
|
|
10
|
+
*
|
|
11
|
+
* - **Sessions are stopped BEFORE the directory goes.** The omp child holds the
|
|
12
|
+
* worktree as its cwd; a child in a deleted directory makes
|
|
13
|
+
* `resolveSpawnCwd` quietly run that session in the server's own folder.
|
|
14
|
+
* - **A dirty worktree refuses.** git exits 128 with "contains modified or
|
|
15
|
+
* untracked files" and `--force` is never passed, so the refusal is
|
|
16
|
+
* recoverable and the count is reported.
|
|
17
|
+
* - **The branch is deleted with `-d` only.** Its refusal (an unmerged branch)
|
|
18
|
+
* IS the safety net, so a branch with commits of its own survives and the
|
|
19
|
+
* answer says so rather than pretending the delete worked.
|
|
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 } from '@/server/lib/fs/worktree/create';
|
|
28
|
+
import type { CreateWorktreeDeps } from '@/server/lib/fs/worktree/create';
|
|
29
|
+
import { listWorktrees } from '@/server/lib/fs/worktree/list';
|
|
30
|
+
import { commitsAheadOf, dirtyCount, removeWorktree, type RemoveWorktreeDeps } from '@/server/lib/fs/worktree/remove';
|
|
31
|
+
import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
|
|
32
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
33
|
+
|
|
34
|
+
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-remove-'));
|
|
35
|
+
const DATA_DIR = path.join(FIXTURE_ROOT, 'data');
|
|
36
|
+
const MANAGED_ROOT = path.join(DATA_DIR, 'worktrees');
|
|
37
|
+
|
|
38
|
+
const GIT_ENV_FOR_TESTS = {
|
|
39
|
+
...Bun.env,
|
|
40
|
+
GIT_EDITOR: 'true',
|
|
41
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
42
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
43
|
+
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
44
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
45
|
+
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function git(cwd: string, ...args: string[]): string {
|
|
49
|
+
return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV_FOR_TESTS }).stdout.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeRepo(name: string): string {
|
|
53
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
56
|
+
fs.writeFileSync(path.join(dir, 'readme.md'), 'hello\n');
|
|
57
|
+
git(dir, 'add', '-A');
|
|
58
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The registry stub: the production one is two SQLite statements. */
|
|
63
|
+
function registry(rows = new Map<string, { start_ref: string }>()): RemoveWorktreeDeps & {
|
|
64
|
+
rows: Map<string, { start_ref: string }>;
|
|
65
|
+
} {
|
|
66
|
+
return {
|
|
67
|
+
rows,
|
|
68
|
+
rowFor: async (candidate) => rows.get(candidate),
|
|
69
|
+
removeRow: async (candidate) => {
|
|
70
|
+
rows.delete(candidate);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const createDeps = (rows = new Map<string, { startRef: string; projectId: string }>()): CreateWorktreeDeps => ({
|
|
76
|
+
identityFor: async () => null,
|
|
77
|
+
carryIgnored: false,
|
|
78
|
+
dataDir: DATA_DIR,
|
|
79
|
+
configuredDirectory: null,
|
|
80
|
+
record: async (row) => {
|
|
81
|
+
rows.set(await canonicalPath(row.path), { startRef: row.startRef, projectId: row.projectId });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await invalidateWorktreeTopology();
|
|
87
|
+
removeDir(FIXTURE_ROOT);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('removeWorktree', () => {
|
|
91
|
+
test('a dirty worktree refuses with the count and keeps everything', async () => {
|
|
92
|
+
const repo = makeRepo('remove-dirty');
|
|
93
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dirty' }, createDeps());
|
|
94
|
+
fs.writeFileSync(path.join(created.path, 'readme.md'), 'changed\n');
|
|
95
|
+
fs.writeFileSync(path.join(created.path, 'untracked.txt'), 'new\n');
|
|
96
|
+
|
|
97
|
+
const failure = removeWorktree(
|
|
98
|
+
{ projectPath: repo, worktreePath: created.path, managedRoot: MANAGED_ROOT },
|
|
99
|
+
registry(),
|
|
100
|
+
);
|
|
101
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'dirty', count: 2 }));
|
|
102
|
+
|
|
103
|
+
expect(fs.existsSync(created.path)).toBe(true);
|
|
104
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(true);
|
|
105
|
+
// The refusal names what it found, so the dialog can show it.
|
|
106
|
+
expect(await dirtyCount(created.path)).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a clean worktree goes, and a branch with commits of its own is kept', async () => {
|
|
110
|
+
const repo = makeRepo('remove-keep-branch');
|
|
111
|
+
const records = new Map<string, { startRef: string; projectId: string }>();
|
|
112
|
+
const created = await createWorktree(
|
|
113
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/keepme' },
|
|
114
|
+
createDeps(records),
|
|
115
|
+
);
|
|
116
|
+
fs.writeFileSync(path.join(created.path, 'work.txt'), 'done\n');
|
|
117
|
+
git(created.path, 'add', '-A');
|
|
118
|
+
git(created.path, 'commit', '-q', '-m', 'work');
|
|
119
|
+
|
|
120
|
+
const rows = new Map([[await canonicalPath(created.path), { start_ref: 'HEAD' }]]);
|
|
121
|
+
const deps = registry(rows);
|
|
122
|
+
// The dialog gates on this number: anything above zero must disable the
|
|
123
|
+
// branch checkbox, because `git branch -d` would refuse anyway.
|
|
124
|
+
expect(await commitsAheadOf(await canonicalPath(repo), 'HEAD', 'wt/keepme')).toBe(1);
|
|
125
|
+
|
|
126
|
+
const result = await removeWorktree(
|
|
127
|
+
{ projectPath: repo, worktreePath: created.path, deleteBranch: true, managedRoot: MANAGED_ROOT },
|
|
128
|
+
deps,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(result.removed).toBe(true);
|
|
132
|
+
expect(result.branchDeleted).toBe(false);
|
|
133
|
+
expect(result.branchKept).toBe(true);
|
|
134
|
+
expect(fs.existsSync(created.path)).toBe(false);
|
|
135
|
+
expect(git(repo, 'branch', '--list', 'wt/keepme').trim()).toContain('wt/keepme');
|
|
136
|
+
expect(rows.size).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a branch with nothing of its own is deleted with -d', async () => {
|
|
140
|
+
const repo = makeRepo('remove-delete-branch');
|
|
141
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/disposable' }, createDeps());
|
|
142
|
+
const rows = new Map([[await canonicalPath(created.path), { start_ref: 'HEAD' }]]);
|
|
143
|
+
|
|
144
|
+
const result = await removeWorktree(
|
|
145
|
+
{ projectPath: repo, worktreePath: created.path, deleteBranch: true, managedRoot: MANAGED_ROOT },
|
|
146
|
+
registry(rows),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(result.branchDeleted).toBe(true);
|
|
150
|
+
expect(git(repo, 'branch', '--list', 'wt/disposable').trim()).toBe('');
|
|
151
|
+
expect(rows.size).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the main checkout cannot be removed', async () => {
|
|
155
|
+
const repo = makeRepo('remove-primary');
|
|
156
|
+
const primary = (await listWorktrees(repo)).find((entry) => entry.isPrimary)?.path as string;
|
|
157
|
+
const failure = removeWorktree({ projectPath: repo, worktreePath: primary }, registry());
|
|
158
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'primary_checkout' }));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a path that is not a worktree of this repository is refused', async () => {
|
|
162
|
+
const repo = makeRepo('remove-stranger');
|
|
163
|
+
const stranger = path.join(FIXTURE_ROOT, 'stranger');
|
|
164
|
+
fs.mkdirSync(stranger, { recursive: true });
|
|
165
|
+
const failure = removeWorktree({ projectPath: repo, worktreePath: stranger }, registry());
|
|
166
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'not_a_worktree' }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a worktree whose folder was deleted outside Kabmo still removes, and prunes', async () => {
|
|
170
|
+
const repo = makeRepo('remove-vanished');
|
|
171
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/vanish' }, createDeps());
|
|
172
|
+
fs.rmSync(created.path, { recursive: true, force: true });
|
|
173
|
+
|
|
174
|
+
// It is still listed — that is what keeps its sessions on screen.
|
|
175
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(true);
|
|
176
|
+
|
|
177
|
+
const result = await removeWorktree(
|
|
178
|
+
{ projectPath: repo, worktreePath: created.path, deleteBranch: true, managedRoot: MANAGED_ROOT },
|
|
179
|
+
registry(),
|
|
180
|
+
);
|
|
181
|
+
expect(result.removed).toBe(true);
|
|
182
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('children are stopped BEFORE the directory disappears', async () => {
|
|
186
|
+
const repo = makeRepo('remove-order');
|
|
187
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/order' }, createDeps());
|
|
188
|
+
|
|
189
|
+
// A child that simply waits: `await new Promise(() => {})` never settles, so
|
|
190
|
+
// the process stays alive without a timer a test would have to wait on.
|
|
191
|
+
const child = Bun.spawn({
|
|
192
|
+
cmd: ['bun', '-e', 'await new Promise(() => {});'],
|
|
193
|
+
cwd: created.path,
|
|
194
|
+
stdin: 'ignore',
|
|
195
|
+
stdout: 'ignore',
|
|
196
|
+
stderr: 'ignore',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const order: string[] = [];
|
|
200
|
+
const result = await removeWorktree(
|
|
201
|
+
{
|
|
202
|
+
projectPath: repo,
|
|
203
|
+
worktreePath: created.path,
|
|
204
|
+
managedRoot: MANAGED_ROOT,
|
|
205
|
+
stopSessions: async (worktreePath) => {
|
|
206
|
+
order.push(`stop:${path.basename(worktreePath)}`);
|
|
207
|
+
child.kill();
|
|
208
|
+
await child.exited;
|
|
209
|
+
return ['session-1'];
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
registry(),
|
|
213
|
+
);
|
|
214
|
+
order.push('removed');
|
|
215
|
+
|
|
216
|
+
expect(order).toEqual(['stop:order', 'removed']);
|
|
217
|
+
expect(result.archived).toEqual(['session-1']);
|
|
218
|
+
expect(fs.existsSync(created.path)).toBe(false);
|
|
219
|
+
// A SIGTERM'd process reports `signalCode`, not an exit code.
|
|
220
|
+
expect(child.signalCode).toBe('SIGTERM');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('a refusal after the sessions are stopped leaves a worktree that can be retried', async () => {
|
|
224
|
+
const repo = makeRepo('remove-retry');
|
|
225
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/retry' }, createDeps());
|
|
226
|
+
fs.writeFileSync(path.join(created.path, 'dirty.txt'), 'x');
|
|
227
|
+
|
|
228
|
+
const failure = removeWorktree(
|
|
229
|
+
{
|
|
230
|
+
projectPath: repo,
|
|
231
|
+
worktreePath: created.path,
|
|
232
|
+
managedRoot: MANAGED_ROOT,
|
|
233
|
+
stopSessions: async () => ['session-1'],
|
|
234
|
+
},
|
|
235
|
+
registry(),
|
|
236
|
+
);
|
|
237
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'dirty' }));
|
|
238
|
+
|
|
239
|
+
expect(fs.existsSync(created.path)).toBe(true);
|
|
240
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
});
|