@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,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
|
|
128
|
-
* callers keep their previous behavior for
|
|
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
|
|
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
|
|
147
|
-
'SELECT project_path FROM workspace_folders WHERE project_path IS NOT NULL
|
|
148
|
-
|
|
149
|
-
);
|
|
150
|
-
|
|
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
|
+
});
|