@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,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a worktree, in the order that matters.
|
|
8
|
+
*
|
|
9
|
+
* Every refusal git would only raise mid-way is checked FIRST, because git
|
|
10
|
+
* creates the branch before it discovers the problem. Measured on git 2.56:
|
|
11
|
+
* `git worktree add` prints `Preparing worktree`, creates the branch, and only
|
|
12
|
+
* then fails with "already checked out", "already exists", or for an unborn
|
|
13
|
+
* branch `object not found: HEAD` — each leaving a branch behind for a create
|
|
14
|
+
* the user was told had failed.
|
|
15
|
+
*
|
|
16
|
+
* Two deliberate absences:
|
|
17
|
+
*
|
|
18
|
+
* - **no `--force`**, so git never overwrites a directory or steals a branch
|
|
19
|
+
* another worktree holds;
|
|
20
|
+
* - **no carry of uncommitted changes.** Their edits are indistinguishable from
|
|
21
|
+
* another session's in the same checkout, and a `stash`+`apply` is a
|
|
22
|
+
* repository-wide operation that can take them. The dialog says the changes
|
|
23
|
+
* stay put, and this is what makes that true.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { mkdir } from 'fs/promises';
|
|
28
|
+
import { getDataDir } from '@/server/lib/lifecycle/paths';
|
|
29
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
30
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
31
|
+
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
32
|
+
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
33
|
+
import {
|
|
34
|
+
folderNameForBranch,
|
|
35
|
+
isPlausibleBranchName,
|
|
36
|
+
normalizeBranchName,
|
|
37
|
+
slugWorktreeName,
|
|
38
|
+
WORKTREE_BRANCH_PREFIX,
|
|
39
|
+
} from '@/shared/lib/workspace/worktree/branch-name';
|
|
40
|
+
import type { WorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
41
|
+
import { carryIgnoredInto, type CarryResult } from '@/server/lib/fs/worktree/carry';
|
|
42
|
+
import { WorktreeError } from '@/server/lib/fs/worktree/errors';
|
|
43
|
+
import {
|
|
44
|
+
defaultWorktreeRoot,
|
|
45
|
+
resolveConfiguredWorktreeRoot,
|
|
46
|
+
resolveTargetDirectory,
|
|
47
|
+
} from '@/server/lib/fs/worktree/location';
|
|
48
|
+
import { findBranchInUse, refExists } from '@/server/lib/fs/worktree/list';
|
|
49
|
+
import { resolveProjectId } from '@/server/lib/fs/worktree/project-id';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `-c` flags a create carries.
|
|
53
|
+
*
|
|
54
|
+
* `core.longpaths` is not cosmetic: the data-dir prefix plus a nested repository
|
|
55
|
+
* path exceeds Windows' 260-character MAX_PATH, and git then fails during the
|
|
56
|
+
* checkout with "Filename too long" and leaves a half-populated worktree.
|
|
57
|
+
* `core.hooksPath` keeps a repository's hooks out of a checkout Kabmo makes.
|
|
58
|
+
*/
|
|
59
|
+
export const WORKTREE_ADD_CONFIG = [
|
|
60
|
+
'core.longpaths=true',
|
|
61
|
+
`core.hooksPath=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export interface CreateWorktreeInput {
|
|
65
|
+
/** Absolute path of the project (or any directory inside the repository). */
|
|
66
|
+
projectPath: string;
|
|
67
|
+
mode: 'new' | 'existing';
|
|
68
|
+
/** The branch: free text in `new`, a pick in `existing`. Empty generates one. */
|
|
69
|
+
branch?: string;
|
|
70
|
+
/** The folder name from the dialog; empty follows the branch. */
|
|
71
|
+
worktreeName?: string;
|
|
72
|
+
startRef?: string;
|
|
73
|
+
/** Overrides the stored setting, which is what the dialog's Quick path does. */
|
|
74
|
+
carryIgnored?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CreatedWorktree extends WorktreeInfo {
|
|
78
|
+
projectId: string;
|
|
79
|
+
primaryRoot: string;
|
|
80
|
+
carried: CarryResult | null;
|
|
81
|
+
/** False for `existing`, where the branch already belonged to the repository. */
|
|
82
|
+
createdBranch: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CreateWorktreeDeps {
|
|
86
|
+
/** The identity cache, in production `worktreeIdentityFor(getDb(), …)`. */
|
|
87
|
+
identityFor: (primaryRoot: string) => Promise<string | null>;
|
|
88
|
+
/** The stored carry default; the request may override it. */
|
|
89
|
+
carryIgnored: boolean;
|
|
90
|
+
dataDir?: string;
|
|
91
|
+
configuredDirectory?: string | null;
|
|
92
|
+
/** Called with the finished row — the registry write, injected for tests. */
|
|
93
|
+
record: (row: {
|
|
94
|
+
path: string;
|
|
95
|
+
primaryRoot: string;
|
|
96
|
+
projectId: string;
|
|
97
|
+
branch: string;
|
|
98
|
+
startRef: string;
|
|
99
|
+
createdAt: string;
|
|
100
|
+
}) => Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function gitOk(args: string[], cwd: string): Promise<string> {
|
|
104
|
+
const result = await runGit(args, { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
105
|
+
if (result.error) throw result.error;
|
|
106
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `git exited ${result.exitCode}`);
|
|
107
|
+
return result.stdout;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve the destination the create would use, without touching disk. */
|
|
111
|
+
export async function planWorktree(
|
|
112
|
+
input: CreateWorktreeInput,
|
|
113
|
+
deps: Pick<CreateWorktreeDeps, 'identityFor' | 'dataDir' | 'configuredDirectory'>,
|
|
114
|
+
): Promise<{ primaryRoot: string; projectId: string; root: string; name: string; directory: string; branch: string; startRef: string }> {
|
|
115
|
+
const projectPath = path.resolve(input.projectPath);
|
|
116
|
+
const insideWorkTree = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
|
|
117
|
+
if (insideWorkTree.exitCode !== 0 || insideWorkTree.stdout.trim() !== 'true') {
|
|
118
|
+
throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const primaryRoot = await resolveProjectRoot(projectPath);
|
|
122
|
+
const head = await runGit(['rev-parse', '--verify', 'HEAD'], { cwd: primaryRoot });
|
|
123
|
+
if (head.exitCode !== 0) {
|
|
124
|
+
throw new WorktreeError('no_commits', 'This repository has no commits yet.');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const existing = input.mode === 'existing';
|
|
128
|
+
// The RAW name is validated, then normalized only by stripping a ref prefix.
|
|
129
|
+
// Normalizing first was a silent rewrite: `normalizeBranchName` turns spaces
|
|
130
|
+
// into dashes, so a typed `has space` became the valid `has-space` and the
|
|
131
|
+
// dialog created a branch the user never asked for.
|
|
132
|
+
const typed = (input.branch ?? '').trim();
|
|
133
|
+
const requested = normalizeBranchName(typed);
|
|
134
|
+
if (existing && !requested) {
|
|
135
|
+
throw new WorktreeError('invalid_name', 'Pick a branch to check out.', { field: 'branch' });
|
|
136
|
+
}
|
|
137
|
+
if (typed && !isPlausibleBranchName(typed)) {
|
|
138
|
+
throw new WorktreeError('invalid_name', `"${typed}" is not a valid branch name.`, { field: 'branch' });
|
|
139
|
+
}
|
|
140
|
+
if (requested) {
|
|
141
|
+
const exists = await refExists(primaryRoot, `refs/heads/${requested}`);
|
|
142
|
+
if (existing && !exists) {
|
|
143
|
+
throw new WorktreeError('branch_exists', `Branch ${requested} does not exist.`, { field: 'branch' });
|
|
144
|
+
}
|
|
145
|
+
if (!existing && exists) {
|
|
146
|
+
throw new WorktreeError('branch_exists', `Branch ${requested} already exists.`, { field: 'branch' });
|
|
147
|
+
}
|
|
148
|
+
const inUse = await findBranchInUse(primaryRoot, requested);
|
|
149
|
+
if (inUse) {
|
|
150
|
+
throw new WorktreeError('branch_in_use', `${requested} is already checked out in ${inUse.path}.`, {
|
|
151
|
+
field: 'branch',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const projectId = await resolveProjectId(primaryRoot, deps.identityFor);
|
|
157
|
+
const dataDir = deps.dataDir ?? getDataDir();
|
|
158
|
+
// Canonical from the start: the directory is compared against git's own paths
|
|
159
|
+
// and stored as a primary key, and two spellings of one folder must be one
|
|
160
|
+
// row (macOS: `/tmp` typed, `/private/tmp` reported).
|
|
161
|
+
const root = await canonicalPath(
|
|
162
|
+
resolveConfiguredWorktreeRoot(deps.configuredDirectory ?? null, primaryRoot)
|
|
163
|
+
?? defaultWorktreeRoot(dataDir, projectId),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// The folder follows the branch unless the dialog named one; a non-Latin
|
|
167
|
+
// branch leaves no slug, so a generated name stands in rather than an empty
|
|
168
|
+
// folder.
|
|
169
|
+
const candidateName = input.worktreeName?.trim() || folderNameForBranch(requested) || slugWorktreeName(requested);
|
|
170
|
+
const target = await resolveTargetDirectory({
|
|
171
|
+
root,
|
|
172
|
+
preferredName: candidateName,
|
|
173
|
+
// The branch the user typed is used VERBATIM in both modes; only an empty
|
|
174
|
+
// one generates. Passing it as generated for `new` renamed a typed
|
|
175
|
+
// `分支/测试` to `wt/lucid-toucan` — measured, and the caller never sees the
|
|
176
|
+
// branch it asked for.
|
|
177
|
+
explicitBranch: requested || null,
|
|
178
|
+
generatedBranch: (name) => `${WORKTREE_BRANCH_PREFIX}/${name}`,
|
|
179
|
+
branchExists: async (branch) => Boolean(await findBranchInUse(primaryRoot, branch)) || refExists(primaryRoot, `refs/heads/${branch}`),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const startRef = input.startRef?.trim() || 'HEAD';
|
|
183
|
+
return { primaryRoot, projectId, root, name: target.name, directory: target.directory, branch: target.branch, startRef };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Create the worktree, carry the ignored build folders, and record the row. */
|
|
187
|
+
export async function createWorktree(
|
|
188
|
+
input: CreateWorktreeInput,
|
|
189
|
+
deps: CreateWorktreeDeps,
|
|
190
|
+
): Promise<CreatedWorktree> {
|
|
191
|
+
const plan = await planWorktree(input, deps);
|
|
192
|
+
const existing = input.mode === 'existing';
|
|
193
|
+
const carry = input.carryIgnored ?? deps.carryIgnored;
|
|
194
|
+
|
|
195
|
+
await mkdir(plan.root, { recursive: true });
|
|
196
|
+
|
|
197
|
+
// git's grammar is `worktree add [--no-checkout] [-b <new>] <path> [<commit-ish>]`:
|
|
198
|
+
// an EXISTING branch is the commit-ish and therefore follows the path.
|
|
199
|
+
// Measured: putting the branch first is read as the path and git answers
|
|
200
|
+
// `fatal: invalid reference: <the path we meant to use>`.
|
|
201
|
+
// ONE `-c` per pair: `['-c', a, b, 'worktree']` makes git read `b` as the
|
|
202
|
+
// SUBCOMMAND, and it answers `git: 'core.hooksPath=/dev/null' is not a git
|
|
203
|
+
// command` — measured, and every create failed on it.
|
|
204
|
+
const configArgs = WORKTREE_ADD_CONFIG.flatMap((pair) => ['-c', pair]);
|
|
205
|
+
const args = [...configArgs, 'worktree', 'add', '--no-checkout'];
|
|
206
|
+
if (existing) {
|
|
207
|
+
args.push(plan.directory, plan.branch);
|
|
208
|
+
} else {
|
|
209
|
+
args.push('-b', plan.branch, plan.directory);
|
|
210
|
+
if (plan.startRef !== 'HEAD') args.push(plan.startRef);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await gitOk(args, plan.primaryRoot);
|
|
215
|
+
// `--no-checkout` keeps the add cheap; the same flags then populate it, so a
|
|
216
|
+
// path that cannot be checked out fails HERE and is reported.
|
|
217
|
+
await gitOk([...configArgs, 'checkout'], plan.directory);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// A failed create leaves nothing behind: the branch was ours, so it goes.
|
|
220
|
+
if (!existing) {
|
|
221
|
+
await runGit(['branch', '-D', plan.branch], { cwd: plan.primaryRoot });
|
|
222
|
+
}
|
|
223
|
+
throw new WorktreeError('create_failed', error instanceof Error ? error.message : String(error));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let carried: CarryResult | null = null;
|
|
227
|
+
if (carry) {
|
|
228
|
+
try {
|
|
229
|
+
carried = await carryIgnoredInto({ primaryRoot: plan.primaryRoot, worktreePath: plan.directory });
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Reported, not fatal: a worktree without node_modules is still a worktree.
|
|
232
|
+
carried = { copied: [], skipped: [], strategy: 'plain' };
|
|
233
|
+
void error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const createdAt = new Date().toISOString();
|
|
238
|
+
await deps.record({
|
|
239
|
+
path: plan.directory,
|
|
240
|
+
primaryRoot: plan.primaryRoot,
|
|
241
|
+
projectId: plan.projectId,
|
|
242
|
+
branch: plan.branch,
|
|
243
|
+
startRef: existing ? '' : plan.startRef,
|
|
244
|
+
createdAt,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
path: plan.directory,
|
|
249
|
+
branch: plan.branch,
|
|
250
|
+
head: (await runGit(['rev-parse', 'HEAD'], { cwd: plan.directory })).stdout.trim(),
|
|
251
|
+
detached: false,
|
|
252
|
+
missing: !(await pathExists(plan.directory)),
|
|
253
|
+
isPrimary: false,
|
|
254
|
+
dirty: 0,
|
|
255
|
+
aheadOfStart: 0,
|
|
256
|
+
startRef: existing ? '' : plan.startRef,
|
|
257
|
+
sessions: 0,
|
|
258
|
+
projectId: plan.projectId,
|
|
259
|
+
primaryRoot: plan.primaryRoot,
|
|
260
|
+
carried,
|
|
261
|
+
createdBranch: !existing,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A worktree refusal, carrying the code and the field it belongs to.
|
|
8
|
+
*
|
|
9
|
+
* The route answers `{ error, code, field }` from this, so the dialog can mark
|
|
10
|
+
* the input that caused it instead of printing one sentence under the buttons.
|
|
11
|
+
* Codes are stable; messages are for people.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { WorktreeErrorCode, WorktreeFailure } from '@/shared/lib/workspace/worktree/types';
|
|
15
|
+
|
|
16
|
+
export class WorktreeError extends Error {
|
|
17
|
+
readonly code: WorktreeErrorCode;
|
|
18
|
+
readonly field?: WorktreeFailure['field'];
|
|
19
|
+
readonly count?: number;
|
|
20
|
+
|
|
21
|
+
constructor(code: WorktreeErrorCode, message: string, options: { field?: WorktreeFailure['field']; count?: number } = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'WorktreeError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.field = options.field;
|
|
26
|
+
this.count = options.count;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
toFailure(): WorktreeFailure {
|
|
30
|
+
return {
|
|
31
|
+
code: this.code,
|
|
32
|
+
message: this.message,
|
|
33
|
+
...(this.field ? { field: this.field } : {}),
|
|
34
|
+
...(this.count !== undefined ? { count: this.count } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Turn anything thrown into a failure, so an unexpected error is still typed. */
|
|
40
|
+
export function asWorktreeFailure(error: unknown): WorktreeFailure {
|
|
41
|
+
if (error instanceof WorktreeError) return error.toFailure();
|
|
42
|
+
return { code: 'create_failed', message: error instanceof Error ? error.message : String(error) };
|
|
43
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worktree listing, against real repositories.
|
|
8
|
+
*
|
|
9
|
+
* Three answers here cannot be faked by a hand-written porcelain string, and
|
|
10
|
+
* each is a case that has to keep working:
|
|
11
|
+
*
|
|
12
|
+
* - a linked worktree is NOT the primary, and a deleted directory is `missing`
|
|
13
|
+
* while its entry stays in the list (the sessions that lived there must keep
|
|
14
|
+
* their row);
|
|
15
|
+
* - two spellings of one directory reconcile — on macOS git prints
|
|
16
|
+
* `/private/tmp/...` for a path the caller typed as `/tmp/...`, and a
|
|
17
|
+
* comparison that ignored that decided a worktree was not inside itself;
|
|
18
|
+
* - the session → worktree lookup picks the INNER worktree, because that is
|
|
19
|
+
* the directory the agent writes to.
|
|
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, pathHolds } from '@/server/lib/fs/canonical';
|
|
27
|
+
import { findBranchInUse, listWorktrees, parseWorktreePorcelain } from '@/server/lib/fs/worktree/list';
|
|
28
|
+
import { invalidateWorktreeTopology, readProjectWorktrees, worktreeForCwd } from '@/server/lib/fs/worktree/topology';
|
|
29
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
30
|
+
|
|
31
|
+
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-list-'));
|
|
32
|
+
|
|
33
|
+
function git(cwd: string, ...args: string[]): string {
|
|
34
|
+
const result = Bun.spawnSync({
|
|
35
|
+
cmd: ['git', ...args],
|
|
36
|
+
cwd,
|
|
37
|
+
env: { ...Bun.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' },
|
|
38
|
+
});
|
|
39
|
+
return result.stdout.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeRepo(name: string): string {
|
|
43
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
46
|
+
git(dir, 'config', 'user.email', 'test@example.invalid');
|
|
47
|
+
git(dir, 'config', 'user.name', 'Test');
|
|
48
|
+
fs.writeFileSync(path.join(dir, 'f.txt'), 'a\n');
|
|
49
|
+
git(dir, 'add', '-A');
|
|
50
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await invalidateWorktreeTopology();
|
|
56
|
+
removeDir(FIXTURE_ROOT);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('parseWorktreePorcelain', () => {
|
|
60
|
+
const PORCELAIN = [
|
|
61
|
+
'worktree /repo',
|
|
62
|
+
'HEAD aaa',
|
|
63
|
+
'branch refs/heads/main',
|
|
64
|
+
'',
|
|
65
|
+
'worktree /wt/detached',
|
|
66
|
+
'HEAD bbb',
|
|
67
|
+
'detached',
|
|
68
|
+
'',
|
|
69
|
+
'worktree /wt/gone',
|
|
70
|
+
'HEAD ccc',
|
|
71
|
+
'branch refs/heads/feat',
|
|
72
|
+
'prunable gitdir file points to non-existent location',
|
|
73
|
+
'',
|
|
74
|
+
'worktree /bare',
|
|
75
|
+
'bare',
|
|
76
|
+
'',
|
|
77
|
+
].join('\n');
|
|
78
|
+
|
|
79
|
+
test('reads the branch, a detached HEAD, a prunable entry, and drops a bare repo', () => {
|
|
80
|
+
const entries = parseWorktreePorcelain(PORCELAIN);
|
|
81
|
+
expect(entries.map((entry) => entry.path)).toEqual(['/repo', '/wt/detached', '/wt/gone']);
|
|
82
|
+
expect(entries[0]?.branch).toBe('main');
|
|
83
|
+
expect(entries[1]?.detached).toBe(true);
|
|
84
|
+
expect(entries[1]?.branch).toBe('');
|
|
85
|
+
expect(entries[2]?.missing).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a bare repository is not a checkout anything can run in', () => {
|
|
89
|
+
expect(parseWorktreePorcelain(PORCELAIN).some((entry) => entry.path === '/bare')).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('listWorktrees', () => {
|
|
94
|
+
test('flags the primary and a linked worktree apart', async () => {
|
|
95
|
+
const repo = makeRepo('list-basic');
|
|
96
|
+
const linked = path.join(FIXTURE_ROOT, 'list-basic-wt');
|
|
97
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'feat', linked);
|
|
98
|
+
|
|
99
|
+
const entries = await listWorktrees(repo);
|
|
100
|
+
const primary = entries.find((entry) => entry.isPrimary);
|
|
101
|
+
const other = entries.find((entry) => !entry.isPrimary);
|
|
102
|
+
expect(primary?.path).toBe(await canonicalPath(repo));
|
|
103
|
+
expect(other?.branch).toBe('feat');
|
|
104
|
+
expect(other?.missing).toBe(false);
|
|
105
|
+
// Paths come back canonical, whichever spelling git was handed.
|
|
106
|
+
expect(other?.path).toBe(await canonicalPath(linked));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a directory deleted outside git stays listed as missing', async () => {
|
|
110
|
+
const repo = makeRepo('list-missing');
|
|
111
|
+
const linked = path.join(FIXTURE_ROOT, 'list-missing-wt');
|
|
112
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'gone', linked);
|
|
113
|
+
fs.rmSync(linked, { recursive: true, force: true });
|
|
114
|
+
|
|
115
|
+
const entries = await listWorktrees(repo);
|
|
116
|
+
const entry = entries.find((candidate) => !candidate.isPrimary);
|
|
117
|
+
expect(entry?.missing).toBe(true);
|
|
118
|
+
// And the missing path still canonicalizes, so it can be matched at all.
|
|
119
|
+
expect(entry?.path).toBe(await canonicalPath(linked));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('findBranchInUse names the worktree that holds a branch', async () => {
|
|
123
|
+
const repo = makeRepo('list-in-use');
|
|
124
|
+
const linked = path.join(FIXTURE_ROOT, 'list-in-use-wt');
|
|
125
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'held', linked);
|
|
126
|
+
|
|
127
|
+
const hit = await findBranchInUse(repo, 'held');
|
|
128
|
+
expect(hit?.path).toBe(await canonicalPath(linked));
|
|
129
|
+
expect(await findBranchInUse(repo, 'free')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('canonical paths', () => {
|
|
134
|
+
test('a sibling sharing a prefix is not inside', async () => {
|
|
135
|
+
const base = path.join(FIXTURE_ROOT, 'holds');
|
|
136
|
+
fs.mkdirSync(path.join(base, 'repo', 'src'), { recursive: true });
|
|
137
|
+
fs.mkdirSync(path.join(base, 'repo-other'), { recursive: true });
|
|
138
|
+
|
|
139
|
+
expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo'))).toBe(true);
|
|
140
|
+
expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo', 'src'))).toBe(true);
|
|
141
|
+
expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo-other'))).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a MISSING path resolves through its nearest existing ancestor', async () => {
|
|
145
|
+
const base = path.join(FIXTURE_ROOT, 'canonical-missing');
|
|
146
|
+
fs.mkdirSync(base, { recursive: true });
|
|
147
|
+
const missing = path.join(base, 'gone', 'deeper');
|
|
148
|
+
expect(await canonicalPath(missing)).toBe(path.join(await canonicalPath(base), 'gone', 'deeper'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('`..` cannot escape', async () => {
|
|
152
|
+
const base = path.join(FIXTURE_ROOT, 'canonical-escape');
|
|
153
|
+
fs.mkdirSync(base, { recursive: true });
|
|
154
|
+
expect(await pathHolds(base, path.join(base, '..', 'elsewhere'))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('session → worktree', () => {
|
|
159
|
+
test('an inner worktree wins over the checkout that contains it', async () => {
|
|
160
|
+
const repo = makeRepo('topology');
|
|
161
|
+
const outer = path.join(FIXTURE_ROOT, 'topology-outer');
|
|
162
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'outer', outer);
|
|
163
|
+
fs.mkdirSync(path.join(outer, 'sub'), { recursive: true });
|
|
164
|
+
|
|
165
|
+
const entries = await listWorktrees(repo);
|
|
166
|
+
const primary = entries.find((entry) => entry.isPrimary)?.path as string;
|
|
167
|
+
|
|
168
|
+
const inner = await worktreeForCwd(entries, path.join(outer, 'sub'), primary);
|
|
169
|
+
expect(inner?.path).toBe(await canonicalPath(outer));
|
|
170
|
+
expect(inner?.branch).toBe('outer');
|
|
171
|
+
expect(inner?.status).toBe('ready');
|
|
172
|
+
|
|
173
|
+
// A session in the checkout itself has no badge — it is not a worktree session.
|
|
174
|
+
expect(await worktreeForCwd(entries, primary, primary)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a missing worktree still answers, with status `missing`', async () => {
|
|
178
|
+
const repo = makeRepo('topology-missing');
|
|
179
|
+
const linked = path.join(FIXTURE_ROOT, 'topology-missing-wt');
|
|
180
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'vanish', linked);
|
|
181
|
+
fs.rmSync(linked, { recursive: true, force: true });
|
|
182
|
+
|
|
183
|
+
const entries = await listWorktrees(repo);
|
|
184
|
+
const primary = entries.find((entry) => entry.isPrimary)?.path as string;
|
|
185
|
+
const badge = await worktreeForCwd(entries, linked, primary);
|
|
186
|
+
expect(badge?.status).toBe('missing');
|
|
187
|
+
expect(badge?.branch).toBe('vanish');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('the topology read is cached, and invalidation drops it', async () => {
|
|
191
|
+
const repo = makeRepo('topology-cache');
|
|
192
|
+
const first = await readProjectWorktrees(repo);
|
|
193
|
+
const second = await readProjectWorktrees(repo);
|
|
194
|
+
expect(second).toBe(first); // same array identity — a real cache hit
|
|
195
|
+
|
|
196
|
+
const linked = path.join(FIXTURE_ROOT, 'topology-cache-wt');
|
|
197
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'new-one', linked);
|
|
198
|
+
// Cached: the new worktree is not visible yet.
|
|
199
|
+
expect((await readProjectWorktrees(repo)).length).toBe(1);
|
|
200
|
+
|
|
201
|
+
// AWAITED: the invalidation resolves the canonical key asynchronously, so an
|
|
202
|
+
// un-awaited call races the read below — measured on both CI legs, where the
|
|
203
|
+
// read won and the cache still answered one entry.
|
|
204
|
+
await invalidateWorktreeTopology(await canonicalPath(repo));
|
|
205
|
+
expect((await readProjectWorktrees(repo)).length).toBe(2);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `git worktree list --porcelain`, parsed into rows.
|
|
8
|
+
*
|
|
9
|
+
* The two markers a naive parse loses, and both matter to a reader:
|
|
10
|
+
*
|
|
11
|
+
* - **no `branch` line** → detached HEAD, which the row shows as `detached @
|
|
12
|
+
* <sha>` and the delete dialog treats as not-a-branch;
|
|
13
|
+
* - **`prunable`** → the directory is gone or the gitdir pointer is broken. The
|
|
14
|
+
* entry STAYS in the list: the sessions that lived there must keep their row,
|
|
15
|
+
* and dropping it would hide them with no way back.
|
|
16
|
+
*
|
|
17
|
+
* Paths are canonicalized here, once, for every consumer — git echoes the
|
|
18
|
+
* spelling it was given while the repository root resolves through symlinks, and
|
|
19
|
+
* two spellings of one directory must not read as two worktrees.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'fs';
|
|
23
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
24
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
25
|
+
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
26
|
+
|
|
27
|
+
export interface WorktreeEntry {
|
|
28
|
+
path: string;
|
|
29
|
+
/** `refs/heads/` stripped; `''` when detached. */
|
|
30
|
+
branch: string;
|
|
31
|
+
head: string;
|
|
32
|
+
detached: boolean;
|
|
33
|
+
/** git reports it prunable, or the directory is not there. */
|
|
34
|
+
missing: boolean;
|
|
35
|
+
/** The checkout the worktree was created from — never removable. */
|
|
36
|
+
isPrimary: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Split on blank lines; a bare repository is not a checkout anything can run in. */
|
|
40
|
+
export function parseWorktreePorcelain(stdout: string): Omit<WorktreeEntry, 'isPrimary'>[] {
|
|
41
|
+
interface Pending {
|
|
42
|
+
path: string;
|
|
43
|
+
branch?: string;
|
|
44
|
+
head?: string;
|
|
45
|
+
missing: boolean;
|
|
46
|
+
bare: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const entries: Omit<WorktreeEntry, 'isPrimary'>[] = [];
|
|
50
|
+
let current: Pending | null = null;
|
|
51
|
+
|
|
52
|
+
const flush = () => {
|
|
53
|
+
if (!current?.path || current.bare) {
|
|
54
|
+
current = null;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const branch = current.branch ?? '';
|
|
58
|
+
const head = current.head ?? '';
|
|
59
|
+
entries.push({
|
|
60
|
+
path: current.path,
|
|
61
|
+
branch,
|
|
62
|
+
head,
|
|
63
|
+
detached: !branch && Boolean(head),
|
|
64
|
+
missing: current.missing,
|
|
65
|
+
});
|
|
66
|
+
current = null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for (const rawLine of stdout.split('\n')) {
|
|
70
|
+
const line = rawLine.trimEnd();
|
|
71
|
+
if (!line) {
|
|
72
|
+
flush();
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (line.startsWith('worktree ')) {
|
|
76
|
+
flush();
|
|
77
|
+
current = { path: line.slice('worktree '.length).trim(), missing: false, bare: false };
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!current) continue;
|
|
81
|
+
if (line.startsWith('HEAD ')) {
|
|
82
|
+
current.head = line.slice('HEAD '.length).trim();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (line.startsWith('branch ')) {
|
|
86
|
+
current.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (line === 'bare') current.bare = true;
|
|
90
|
+
if (line === 'prunable' || line.startsWith('prunable ')) current.missing = true;
|
|
91
|
+
}
|
|
92
|
+
flush();
|
|
93
|
+
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every worktree of the repository `cwd` belongs to, primary first. */
|
|
98
|
+
export async function listWorktrees(cwd: string): Promise<WorktreeEntry[]> {
|
|
99
|
+
const primaryRoot = await resolveProjectRoot(cwd);
|
|
100
|
+
const listed = await runGit(['worktree', 'list', '--porcelain'], { cwd: primaryRoot, maxBuffer: 1024 * 1024 });
|
|
101
|
+
if (listed.exitCode !== 0) throw new Error(listed.stderr.trim() || 'Failed to list git worktrees');
|
|
102
|
+
|
|
103
|
+
const canonicalPrimary = await canonicalPath(primaryRoot);
|
|
104
|
+
const entries: WorktreeEntry[] = [];
|
|
105
|
+
for (const entry of parseWorktreePorcelain(listed.stdout)) {
|
|
106
|
+
const canonical = await canonicalPath(entry.path);
|
|
107
|
+
entries.push({
|
|
108
|
+
...entry,
|
|
109
|
+
path: canonical,
|
|
110
|
+
isPrimary: canonical === canonicalPrimary,
|
|
111
|
+
// A directory removed without `prune` answers `existsSync` false, and both
|
|
112
|
+
// signals mean the same thing to a reader.
|
|
113
|
+
missing: entry.missing || !existsSync(canonical),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether `branch` is checked out in any worktree of this repository, and where. */
|
|
120
|
+
export async function findBranchInUse(cwd: string, branch: string): Promise<{ path: string } | null> {
|
|
121
|
+
const entries = await listWorktrees(cwd);
|
|
122
|
+
const hit = entries.find((entry) => entry.branch === branch);
|
|
123
|
+
return hit ? { path: hit.path } : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function refExists(cwd: string, ref: string): Promise<boolean> {
|
|
127
|
+
const probe = await runGit(['show-ref', '--verify', '--quiet', ref], { cwd });
|
|
128
|
+
return probe.exitCode === 0;
|
|
129
|
+
}
|