@1yefuwang1/dsh-worktrees 0.2.13 → 0.2.18
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/CHANGELOG.md +112 -0
- package/README.md +54 -24
- package/client.js +259 -87
- package/dist/context.js +6 -2
- package/dist/git.d.ts +4 -1
- package/dist/git.js +123 -19
- package/dist/index.js +1 -1
- package/dist/project-store.d.ts +2 -2
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +19 -4
- package/dist/service.d.ts +1 -1
- package/dist/service.js +45 -13
- package/dist/store.d.ts +2 -0
- package/dist/store.js +1 -0
- package/dist/types.d.ts +31 -5
- package/package.json +7 -2
package/dist/context.js
CHANGED
|
@@ -14,11 +14,15 @@ export function renderWorktreeContext(record, state) {
|
|
|
14
14
|
'Mode: isolated linked checkout.',
|
|
15
15
|
`Execution directory: ${quote(record.effectiveCwd)}`,
|
|
16
16
|
`Original local checkout: ${quote(record.repoRoot)}`,
|
|
17
|
-
|
|
17
|
+
record.branchSource === 'local'
|
|
18
|
+
? `Local base branch: ${quote(record.localBranch ?? record.remoteBranch)}`
|
|
19
|
+
: `Fetched base branch: ${quote(`${record.remote}/${record.remoteBranch}`)}`,
|
|
18
20
|
`Starting commit: ${quote(record.baseOid || 'not established')}`,
|
|
19
21
|
`Checkout last known: ${quote(state === undefined ? 'unknown; inspect status before Git mutations' : state.branch === null ? 'detached HEAD' : state.branch)}`,
|
|
20
22
|
...(state === undefined ? [] : [`HEAD last known: ${quote(state.head)}`]),
|
|
21
|
-
|
|
23
|
+
record.branchSource === 'local'
|
|
24
|
+
? 'The base is a creation-time local commit snapshot pinned without fetching, not continuous synchronization. Git refs and repository metadata remain shared.'
|
|
25
|
+
: 'The base is a creation-time fetched snapshot, not continuous synchronization. Git refs and repository metadata remain shared.',
|
|
22
26
|
'Work in the current session directory. Preserve the local checkout unless an explicit handoff is requested. Historical paths may refer to an earlier checkout; verify before writing.',
|
|
23
27
|
].join('\n');
|
|
24
28
|
}
|
package/dist/git.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CheckoutState, CreateCheckoutInput, CreatedCheckout, GitExecutor, RemoteBranches, RemoteInfo, RepositoryInfo, SnapshotInput, WorktreeConfig, WorktreePreview, WorktreeRecord } from './types.js';
|
|
1
|
+
import type { CheckoutState, CreateCheckoutInput, CreatedCheckout, GitExecutor, LocalBranches, RemoteBranches, RemoteInfo, RepositoryInfo, SnapshotInput, WorktreeConfig, WorktreePreview, WorktreeRecord } from './types.js';
|
|
2
2
|
/** Strict Git engine. Its executor must enforce byte bounds, deadlines and caller sandbox access. */
|
|
3
3
|
export declare class GitOperations {
|
|
4
4
|
private readonly execute;
|
|
@@ -14,6 +14,9 @@ export declare class GitOperations {
|
|
|
14
14
|
private remote;
|
|
15
15
|
remotes(repoRoot: string, signal?: AbortSignal): Promise<RemoteInfo[]>;
|
|
16
16
|
branches(repoRoot: string, remote: string, identity?: string, signal?: AbortSignal): Promise<RemoteBranches>;
|
|
17
|
+
/** One bounded local ref generation; no remote configuration or network commands. */
|
|
18
|
+
localBranches(repoRoot: string, signal?: AbortSignal): Promise<LocalBranches>;
|
|
19
|
+
private localBase;
|
|
17
20
|
private destination;
|
|
18
21
|
private store;
|
|
19
22
|
private removeStore;
|
package/dist/git.js
CHANGED
|
@@ -145,8 +145,11 @@ export class GitOperations {
|
|
|
145
145
|
const headResult = await this.run(root, ['rev-parse', '--verify', 'HEAD^{commit}'], signal, { allowed: [0, 128] });
|
|
146
146
|
if (headResult.exitCode !== 0)
|
|
147
147
|
fail('UNBORN_REPOSITORY', 'The repository does not have a committed HEAD.');
|
|
148
|
-
const branchResult = await this.run(root, ['symbolic-ref', '--quiet', '
|
|
149
|
-
|
|
148
|
+
const branchResult = await this.run(root, ['symbolic-ref', '--quiet', 'HEAD'], signal, { allowed: [0, 1] });
|
|
149
|
+
const branchRef = branchResult.exitCode === 0 ? line(branchResult.stdout) : null;
|
|
150
|
+
if (branchRef !== null && !branchRef.startsWith('refs/heads/'))
|
|
151
|
+
fail('UNSUPPORTED_REF', 'HEAD must identify a local branch or a detached commit.');
|
|
152
|
+
return { root, commonDir, projectSubdir: path.relative(root, project).split(path.sep).join('/'), head: oid(line(headResult.stdout)), branch: branchRef === null ? null : branchRef.slice(11) };
|
|
150
153
|
}
|
|
151
154
|
async ref(root, value, signal) {
|
|
152
155
|
if (value.includes('\0') || value.includes('\n') || value.startsWith('-'))
|
|
@@ -253,6 +256,83 @@ export class GitOperations {
|
|
|
253
256
|
fail('REMOTE_CHANGED', 'The remote identity changed during branch discovery.');
|
|
254
257
|
return { remote, remoteIdentity: selected.identity, defaultBranch, branches: branches.sort((a, b) => a.name.localeCompare(b.name)), observedAt: Date.now() };
|
|
255
258
|
}
|
|
259
|
+
/** One bounded local ref generation; no remote configuration or network commands. */
|
|
260
|
+
async localBranches(repoRoot, signal) {
|
|
261
|
+
const repository = await this.discover(repoRoot, signal);
|
|
262
|
+
const result = await this.run(repository.root, ['for-each-ref', '--format=%(refname)%00%(objectname)%00%(objecttype)%00%(symref)%00', 'refs/heads/'], signal);
|
|
263
|
+
const output = text(result.stdout);
|
|
264
|
+
if (output && !output.endsWith('\n'))
|
|
265
|
+
fail('TRUNCATED_OUTPUT', 'The local branch generation was truncated.');
|
|
266
|
+
const branches = [];
|
|
267
|
+
const seen = new Set();
|
|
268
|
+
for (const row of output ? output.slice(0, -1).split('\n') : []) {
|
|
269
|
+
const parts = row.split('\0');
|
|
270
|
+
if (parts.length !== 5 || parts[4] !== '' || !parts[0]?.startsWith('refs/heads/'))
|
|
271
|
+
fail('INVALID_GIT_OUTPUT', 'Git returned an invalid local reference.');
|
|
272
|
+
await this.ref(repository.root, parts[0], signal);
|
|
273
|
+
if (parts[2] !== 'commit' || parts[3] !== '')
|
|
274
|
+
fail('UNSUPPORTED_REF', 'Local branches must directly identify commits, not symbolic refs or other objects.');
|
|
275
|
+
const name = parts[0].slice(11);
|
|
276
|
+
if (name === 'HEAD' || seen.has(name))
|
|
277
|
+
fail('UNSUPPORTED_REF', 'The local branch generation contains an ambiguous branch.');
|
|
278
|
+
seen.add(name);
|
|
279
|
+
branches.push({ name, oid: oid(parts[1]) });
|
|
280
|
+
if (branches.length > this.config.maxFiles)
|
|
281
|
+
fail('REF_LIMIT', 'The local branch generation exceeds the configured count limit.');
|
|
282
|
+
}
|
|
283
|
+
const after = await this.discover(repoRoot, signal);
|
|
284
|
+
if (after.root !== repository.root || after.commonDir !== repository.commonDir || after.head !== repository.head || after.branch !== repository.branch)
|
|
285
|
+
fail('BRANCH_CHANGED', 'HEAD changed during local branch discovery. Reload its first page.');
|
|
286
|
+
if (repository.branch === null)
|
|
287
|
+
branches.push({ name: 'HEAD', oid: repository.head });
|
|
288
|
+
else if (branches.find(branch => branch.name === repository.branch)?.oid !== repository.head)
|
|
289
|
+
fail('BRANCH_CHANGED', 'The current branch changed during local branch discovery.');
|
|
290
|
+
if (branches.length > this.config.maxFiles)
|
|
291
|
+
fail('REF_LIMIT', 'The local branch generation exceeds the configured count limit.');
|
|
292
|
+
return { branchSource: 'local', currentBranch: repository.branch, defaultBranch: repository.branch ?? 'HEAD', branches: branches.sort((a, b) => a.name.localeCompare(b.name)), observedAt: Date.now() };
|
|
293
|
+
}
|
|
294
|
+
async localBase(repository, input) {
|
|
295
|
+
const { signal, localBranch, localOid } = input;
|
|
296
|
+
if (!localBranch || localBranch.startsWith('-'))
|
|
297
|
+
fail('INVALID_REF', 'The selected local branch name is invalid.');
|
|
298
|
+
if (localOid !== undefined && !oidPattern.test(localOid))
|
|
299
|
+
fail('INVALID_REF', 'The selected local commit identity is invalid.');
|
|
300
|
+
const ref = localBranch === 'HEAD' ? 'HEAD' : `refs/heads/${localBranch}`;
|
|
301
|
+
if (localBranch === 'HEAD') {
|
|
302
|
+
if (localOid === undefined)
|
|
303
|
+
fail('INVALID_REF', 'Detached HEAD creation requires an expected local commit.');
|
|
304
|
+
if (repository.branch !== null)
|
|
305
|
+
fail('BRANCH_CHANGED', 'HEAD is no longer detached. Reload the local branch list.');
|
|
306
|
+
}
|
|
307
|
+
else
|
|
308
|
+
await this.ref(repository.root, ref, signal);
|
|
309
|
+
const symbolic = await this.run(repository.root, ['symbolic-ref', '--quiet', ref], signal, { allowed: [0, 1] });
|
|
310
|
+
if (symbolic.exitCode === 0)
|
|
311
|
+
fail('UNSUPPORTED_REF', 'The selected local reference must directly identify a commit.');
|
|
312
|
+
const resolved = await this.run(repository.root, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], signal, { allowed: [0, 128] });
|
|
313
|
+
if (resolved.exitCode !== 0)
|
|
314
|
+
fail('LOCAL_BRANCH_NOT_FOUND', 'The selected local branch has no commit.');
|
|
315
|
+
const baseOid = oid(line(resolved.stdout));
|
|
316
|
+
const direct = oid(line((await this.run(repository.root, ['rev-parse', '--verify', '--end-of-options', ref], signal)).stdout));
|
|
317
|
+
if (direct !== baseOid)
|
|
318
|
+
fail('UNSUPPORTED_REF', 'The selected local branch must directly identify a commit.');
|
|
319
|
+
if (localOid !== undefined && localOid !== baseOid)
|
|
320
|
+
fail('BRANCH_CHANGED', 'The selected local branch moved. Reload its branch list.');
|
|
321
|
+
// Compare the source under its ref lock and create (never overwrite/dereference)
|
|
322
|
+
// the managed base in the same transaction. No source ref/index/file is changed.
|
|
323
|
+
const pin = await this.run(repository.root, ['update-ref', '--no-deref', '--stdin'], signal, {
|
|
324
|
+
stdin: `start\nverify ${ref} ${baseOid}\ncreate ${input.baseRef} ${baseOid}\nprepare\ncommit\n`, allowed: [0, 128],
|
|
325
|
+
});
|
|
326
|
+
if (pin.exitCode !== 0)
|
|
327
|
+
fail('BRANCH_CHANGED', 'The local branch or managed base changed before pinning; no checkout was created.');
|
|
328
|
+
// Git's no-deref verify may still compare a symbolic ref's resolved OID.
|
|
329
|
+
// Reject replacement explicitly before any checkout, retaining the exact base
|
|
330
|
+
// as a recovery receipt rather than ever writing through the source reference.
|
|
331
|
+
const after = await this.run(repository.root, ['symbolic-ref', '--quiet', ref], signal, { allowed: [0, 1] });
|
|
332
|
+
if (after.exitCode === 0)
|
|
333
|
+
fail('BRANCH_CHANGED', 'The selected local branch became symbolic during pinning; the exact base may remain but no checkout was created.', { refsMayHaveChanged: true });
|
|
334
|
+
return baseOid;
|
|
335
|
+
}
|
|
256
336
|
async destination(root, requested) {
|
|
257
337
|
const absolute = path.resolve(requested);
|
|
258
338
|
const parent = await realpath(path.dirname(absolute)).catch(() => fail('MISSING_PATH', 'The managed checkout parent directory is unavailable.'));
|
|
@@ -315,27 +395,39 @@ export class GitOperations {
|
|
|
315
395
|
if (repository.root !== input.repository.root || repository.commonDir !== input.repository.commonDir)
|
|
316
396
|
fail('REPOSITORY_CHANGED', 'The source repository identity changed.');
|
|
317
397
|
const destination = await this.destination(repository.root, input.destination);
|
|
318
|
-
if (!input.remoteBranch || input.remoteBranch.startsWith('-'))
|
|
319
|
-
fail('INVALID_REF', 'The selected branch name is invalid.');
|
|
320
|
-
await this.ref(repository.root, `refs/heads/${input.remoteBranch}`, signal);
|
|
321
|
-
await this.ref(repository.root, `refs/remotes/${input.remote}/${input.remoteBranch}`, signal);
|
|
322
398
|
await this.ref(repository.root, input.baseRef, signal);
|
|
323
399
|
if (!input.baseRef.startsWith('refs/dsh-worktrees/'))
|
|
324
400
|
fail('INVALID_REF', 'The persistent base ref must use the managed worktree namespace.');
|
|
325
401
|
const prior = await this.run(repository.root, ['show-ref', '--verify', '--quiet', input.baseRef], signal, { allowed: [0, 1] });
|
|
326
402
|
if (prior.exitCode === 0)
|
|
327
403
|
fail('BASE_REF_EXISTS', 'The persistent base reference is already in use.');
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
404
|
+
let baseOid;
|
|
405
|
+
if (input.branchSource === 'local') {
|
|
406
|
+
if (input.remote !== undefined || input.remoteIdentity !== undefined || input.remoteBranch !== undefined)
|
|
407
|
+
fail('INVALID_REQUEST', 'Local source does not accept remote fields.');
|
|
408
|
+
baseOid = await this.localBase(repository, input);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
if (input.branchSource !== undefined && input.branchSource !== 'remote')
|
|
412
|
+
fail('INVALID_REQUEST', 'The branch source is invalid.');
|
|
413
|
+
if (input.localBranch !== undefined || input.localOid !== undefined)
|
|
414
|
+
fail('INVALID_REQUEST', 'Remote source does not accept local fields.');
|
|
415
|
+
if (!input.remoteBranch || input.remoteBranch.startsWith('-'))
|
|
416
|
+
fail('INVALID_REF', 'The selected branch name is invalid.');
|
|
417
|
+
await this.ref(repository.root, `refs/heads/${input.remoteBranch}`, signal);
|
|
418
|
+
await this.ref(repository.root, `refs/remotes/${input.remote}/${input.remoteBranch}`, signal);
|
|
419
|
+
const remote = await this.remote(repository.root, input.remote, signal);
|
|
420
|
+
if (remote.identity !== input.remoteIdentity)
|
|
421
|
+
fail('REMOTE_CHANGED', 'The selected remote identity has changed.');
|
|
422
|
+
checkoutProgress(input, 'fetching');
|
|
423
|
+
await this.run(repository.root, ['fetch', '--atomic', '--refmap=', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--no-auto-maintenance', '--no-write-commit-graph', '--no-prune', '--upload-pack=git-upload-pack', '--', input.remote, `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remote}/${input.remoteBranch}`, `refs/heads/${input.remoteBranch}:${input.baseRef}`], signal, { fetch: true });
|
|
424
|
+
if ((await this.remote(repository.root, input.remote, signal)).identity !== remote.identity)
|
|
425
|
+
fail('REMOTE_CHANGED', 'The remote changed during fetch; fetched refs may remain.', { refsMayHaveChanged: true });
|
|
426
|
+
baseOid = oid(line((await this.run(repository.root, ['rev-parse', '--verify', `${input.baseRef}^{commit}`], signal)).stdout));
|
|
427
|
+
}
|
|
336
428
|
const entries = await this.tree(repository.root, baseOid, signal);
|
|
337
429
|
if (repository.projectSubdir && !entries.some(entry => entry.path.startsWith(`${repository.projectSubdir}/`)))
|
|
338
|
-
fail('PROJECT_SUBDIR_MISSING', 'The project subdirectory is absent from the
|
|
430
|
+
fail('PROJECT_SUBDIR_MISSING', 'The project subdirectory is absent from the selected base tree.');
|
|
339
431
|
// Attribute validation uses an isolated index and object store, never the source index.
|
|
340
432
|
const privateStore = await this.store(path.dirname(destination), repository.commonDir);
|
|
341
433
|
try {
|
|
@@ -344,7 +436,7 @@ export class GitOperations {
|
|
|
344
436
|
for (const entry of entries.filter(value => value.mode === '120000')) {
|
|
345
437
|
const target = text((await this.run(repository.root, ['cat-file', 'blob', entry.oid], signal)).stdout);
|
|
346
438
|
if (!target || path.isAbsolute(target) || !inside(destination, path.resolve(destination, path.dirname(entry.path), target)))
|
|
347
|
-
fail('UNSAFE_SYMLINK', 'The
|
|
439
|
+
fail('UNSAFE_SYMLINK', 'The selected base tree contains an escaping symlink.');
|
|
348
440
|
}
|
|
349
441
|
}
|
|
350
442
|
finally {
|
|
@@ -354,7 +446,7 @@ export class GitOperations {
|
|
|
354
446
|
try {
|
|
355
447
|
await this.run(repository.root, ['worktree', 'add', '--detach', '--', destination, baseOid], signal);
|
|
356
448
|
const checkoutRoot = await realpath(destination);
|
|
357
|
-
const effectiveCwd = await realpath(path.join(checkoutRoot, repository.projectSubdir)).catch(() => fail('PROJECT_SUBDIR_MISSING', 'The
|
|
449
|
+
const effectiveCwd = await realpath(path.join(checkoutRoot, repository.projectSubdir)).catch(() => fail('PROJECT_SUBDIR_MISSING', 'The selected base project directory is unavailable.'));
|
|
358
450
|
if (checkoutRoot !== destination || !inside(checkoutRoot, effectiveCwd))
|
|
359
451
|
fail('UNSAFE_PATH', 'The created checkout has an unsafe project mapping.');
|
|
360
452
|
return { checkoutRoot, effectiveCwd, baseOid, baseRef: input.baseRef, fetchedAt: Date.now() };
|
|
@@ -407,7 +499,16 @@ export class GitOperations {
|
|
|
407
499
|
}
|
|
408
500
|
const registration = nul((await this.run(repositoryRoot, ['worktree', 'list', '--porcelain', '-z'], signal)).stdout);
|
|
409
501
|
const roots = registration.filter(value => value.startsWith('worktree ')).map(value => value.slice(9));
|
|
410
|
-
|
|
502
|
+
// Git reports these machine-formatted, so a raw comparison against the canonical
|
|
503
|
+
// root fails wherever the two forms differ: Windows mixes separators and may
|
|
504
|
+
// report a short name, and a symlinked ancestor appears in one form only. Match
|
|
505
|
+
// canonically, and leave an entry that cannot be resolved unmatched rather than
|
|
506
|
+
// turning a stale registration into a failure to enumerate.
|
|
507
|
+
const matches = await Promise.all(roots.map(async (value) => {
|
|
508
|
+
const canonical = await realpath(value).catch(() => undefined);
|
|
509
|
+
return canonical === undefined ? value === info.root : canonical === info.root;
|
|
510
|
+
}));
|
|
511
|
+
if (matches.filter(Boolean).length !== 1)
|
|
411
512
|
fail('CHECKOUT_IDENTITY_CHANGED', 'The checkout is not uniquely registered with its source repository.');
|
|
412
513
|
}
|
|
413
514
|
async verify(record, signal) {
|
|
@@ -699,7 +800,6 @@ export class GitOperations {
|
|
|
699
800
|
await this.run(source.root, ['add', '-A', '--', '.'], signal, { env: privateStore.env });
|
|
700
801
|
const finalTree = oid(line((await this.run(source.root, ['write-tree'], signal, { env: privateStore.env })).stdout));
|
|
701
802
|
const finalEntries = await this.tree(source.root, finalTree, signal, privateStore.env);
|
|
702
|
-
await this.exactContents(source.root, finalEntries, sourceFiles.paths, signal);
|
|
703
803
|
await this.attributes(source.root, finalEntries.map(entry => entry.path), signal, privateStore.env, true);
|
|
704
804
|
const files = await this.patchFiles(source.root, input.baseOid, finalTree, privateStore, signal);
|
|
705
805
|
for (const file of files) {
|
|
@@ -707,6 +807,10 @@ export class GitOperations {
|
|
|
707
807
|
if (symlinks.has(file.path) || symlinks.has(original))
|
|
708
808
|
fail('UNSAFE_SYMLINK', 'Changed symlinks are not supported by handoff snapshots.');
|
|
709
809
|
}
|
|
810
|
+
// Refuse changed symlinks before comparing native readlink bytes with Git blobs:
|
|
811
|
+
// Windows normalizes link separators, but these changes are unsupported either way.
|
|
812
|
+
// Supported files must still match their exact working bytes and modes.
|
|
813
|
+
await this.exactContents(source.root, finalEntries, sourceFiles.paths, signal);
|
|
710
814
|
await this.collisions(target.root, files, base);
|
|
711
815
|
const patch = (await this.run(source.root, ['diff', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', '--find-renames', '--src-prefix=a/', '--dst-prefix=b/', input.baseOid, finalTree, '--'], signal, { env: privateStore.env, maxBytes: this.config.maxSnapshotBytes })).stdout;
|
|
712
816
|
if (await this.fingerprint(source.root, signal) !== sourceFingerprint)
|
package/dist/index.js
CHANGED
|
@@ -118,7 +118,7 @@ export async function apply(ctx, config) {
|
|
|
118
118
|
});
|
|
119
119
|
ctx.tools.register({
|
|
120
120
|
name: 'git_worktree',
|
|
121
|
-
description: 'List or create isolated Git worktree threads from a freshly fetched remote branch; preview/export or hand off changes to a clean Local checkout. Cross-root Git mutations require existing full access. Handoff refuses running source/target sessions and never resets or deletes checkouts.',
|
|
121
|
+
description: 'List or create isolated Git worktree threads from an exact local branch without network access or a freshly fetched remote branch; preview/export or hand off changes to a clean Local checkout. Cross-root Git mutations require existing full access. Handoff refuses running source/target sessions and never resets or deletes checkouts.',
|
|
122
122
|
parameters: parameterSchema,
|
|
123
123
|
output: { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
|
|
124
124
|
isConcurrencySafe: args => {
|
package/dist/project-store.d.ts
CHANGED
|
@@ -24,8 +24,8 @@ export declare const projectBindingSchema: z.ZodObject<{
|
|
|
24
24
|
projectId: z.ZodString;
|
|
25
25
|
folderId: z.ZodString;
|
|
26
26
|
mode: z.ZodEnum<{
|
|
27
|
-
worktree: "worktree";
|
|
28
27
|
local: "local";
|
|
28
|
+
worktree: "worktree";
|
|
29
29
|
}>;
|
|
30
30
|
effectiveCwd: z.ZodString;
|
|
31
31
|
worktreeId: z.ZodOptional<z.ZodString>;
|
|
@@ -85,7 +85,7 @@ export declare const projectDomain: {
|
|
|
85
85
|
sessionId: string;
|
|
86
86
|
projectId: string;
|
|
87
87
|
folderId: string;
|
|
88
|
-
mode: "
|
|
88
|
+
mode: "local" | "worktree";
|
|
89
89
|
effectiveCwd: string;
|
|
90
90
|
worktreeId?: string | undefined;
|
|
91
91
|
}>;
|
package/dist/schema.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export declare const requestSchema: z.ZodObject<{
|
|
|
18
18
|
repoPath: z.ZodOptional<z.ZodString>;
|
|
19
19
|
id: z.ZodOptional<z.ZodString>;
|
|
20
20
|
operationId: z.ZodOptional<z.ZodString>;
|
|
21
|
+
branchSource: z.ZodOptional<z.ZodEnum<{
|
|
22
|
+
local: "local";
|
|
23
|
+
remote: "remote";
|
|
24
|
+
}>>;
|
|
25
|
+
localBranch: z.ZodOptional<z.ZodString>;
|
|
26
|
+
localOid: z.ZodOptional<z.ZodString>;
|
|
21
27
|
remote: z.ZodOptional<z.ZodString>;
|
|
22
28
|
remoteIdentity: z.ZodOptional<z.ZodString>;
|
|
23
29
|
remoteBranch: z.ZodOptional<z.ZodString>;
|
package/dist/schema.js
CHANGED
|
@@ -6,6 +6,9 @@ const text = z.string().min(1).max(4096);
|
|
|
6
6
|
const id = z.string().uuid();
|
|
7
7
|
export const requestSchema = z.object({
|
|
8
8
|
action: z.enum(ACTIONS), repoPath: text.optional(), id: id.optional(), operationId: id.optional(),
|
|
9
|
+
branchSource: z.enum(['local', 'remote']).describe('Branch source; omitted preserves remote behavior.').optional(),
|
|
10
|
+
localBranch: text.describe('Exact local branch name, or HEAD for a pinned detached HEAD.').optional(),
|
|
11
|
+
localOid: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u).describe('Expected local commit; fail if the selected branch moved. Required for detached HEAD.').optional(),
|
|
9
12
|
remote: text.optional(), remoteIdentity: text.optional(), remoteBranch: text.optional(),
|
|
10
13
|
sourceSessionId: z.string().min(1).max(256).optional(), sessionMode: z.enum(['new', 'continue']).optional(),
|
|
11
14
|
requireBlankSource: z.boolean().optional(), settingsHash: z.string().max(128).optional(),
|
|
@@ -19,8 +22,8 @@ export const requestSchema = z.object({
|
|
|
19
22
|
const fields = {
|
|
20
23
|
list: ['repoPath', 'cursor', 'limit', 'includeArchived'],
|
|
21
24
|
status: ['repoPath', 'id', 'operationId'],
|
|
22
|
-
branches: ['repoPath', 'remote', 'remoteIdentity', 'query', 'cursor', 'limit'],
|
|
23
|
-
create: ['repoPath', 'operationId', 'remote', 'remoteIdentity', 'remoteBranch', 'sourceSessionId', 'sessionMode', 'requireBlankSource', 'settingsHash', 'firstPrompt', 'projectId', 'folderId'],
|
|
25
|
+
branches: ['repoPath', 'branchSource', 'remote', 'remoteIdentity', 'query', 'cursor', 'limit'],
|
|
26
|
+
create: ['repoPath', 'operationId', 'branchSource', 'localBranch', 'localOid', 'remote', 'remoteIdentity', 'remoteBranch', 'sourceSessionId', 'sessionMode', 'requireBlankSource', 'settingsHash', 'firstPrompt', 'projectId', 'folderId'],
|
|
24
27
|
start: ['id', 'sourceSessionId', 'sessionMode'],
|
|
25
28
|
branch: ['id', 'name'], protect: ['id', 'protected'],
|
|
26
29
|
preview: ['id', 'sourceSessionId', 'targetPath'], export: ['previewId'],
|
|
@@ -35,12 +38,24 @@ export function parseRequest(value) {
|
|
|
35
38
|
if (key !== 'action' && !fields[request.action].includes(key))
|
|
36
39
|
throw new WorktreeError('INVALID_REQUEST', `${request.action} does not accept ${key}.`);
|
|
37
40
|
const required = {
|
|
38
|
-
create: ['operationId'
|
|
39
|
-
preview: ['id'], export: ['previewId'], handoff: ['id', 'previewId', 'operationId'], archive: ['id'],
|
|
41
|
+
create: ['operationId'], start: ['id'], branch: ['id', 'name'], protect: ['id', 'protected'],
|
|
42
|
+
preview: ['id'], export: ['previewId'], handoff: ['id', 'previewId', 'operationId'], archive: ['id'],
|
|
40
43
|
};
|
|
41
44
|
for (const key of required[request.action] ?? [])
|
|
42
45
|
if (request[key] === undefined)
|
|
43
46
|
throw new WorktreeError('INVALID_REQUEST', `${request.action} requires ${key}.`);
|
|
47
|
+
if (request.action === 'create' || request.action === 'branches') {
|
|
48
|
+
const local = request.branchSource === 'local';
|
|
49
|
+
const sourceFields = local ? (request.action === 'create' ? ['localBranch'] : []) : (request.action === 'create' ? ['remote', 'remoteBranch'] : ['remote']);
|
|
50
|
+
for (const key of sourceFields)
|
|
51
|
+
if (request[key] === undefined)
|
|
52
|
+
throw new WorktreeError('INVALID_REQUEST', `${request.action} requires ${key}.`);
|
|
53
|
+
for (const key of local ? ['remote', 'remoteIdentity', 'remoteBranch'] : ['localBranch', 'localOid'])
|
|
54
|
+
if (request[key] !== undefined)
|
|
55
|
+
throw new WorktreeError('INVALID_REQUEST', `${local ? 'Local' : 'Remote'} source does not accept ${key}.`);
|
|
56
|
+
if (local && request.localBranch === 'HEAD' && request.localOid === undefined)
|
|
57
|
+
throw new WorktreeError('INVALID_REQUEST', 'Detached HEAD creation requires localOid.');
|
|
58
|
+
}
|
|
44
59
|
if (request.action === 'status' && request.id !== undefined && request.operationId !== undefined)
|
|
45
60
|
throw new WorktreeError('INVALID_REQUEST', 'Status accepts either id or operationId, not both.');
|
|
46
61
|
if ((request.projectId === undefined) !== (request.folderId === undefined))
|
package/dist/service.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface Invocation {
|
|
|
16
16
|
/** Internal admission snapshot only; execute always replaces caller-supplied values. */
|
|
17
17
|
capturedRoot?: string;
|
|
18
18
|
}
|
|
19
|
-
type GitApi = Pick<GitOperations, 'discover' | 'remotes' | 'branches' | 'create' | 'verify' | 'status' | 'createBranch' | 'renameBranch' | 'fingerprint' | 'snapshot' | 'apply'>;
|
|
19
|
+
type GitApi = Pick<GitOperations, 'discover' | 'remotes' | 'branches' | 'localBranches' | 'create' | 'verify' | 'status' | 'createBranch' | 'renameBranch' | 'fingerprint' | 'snapshot' | 'apply'>;
|
|
20
20
|
type BootstrapApi = Pick<SessionBootstrap, 'actor' | 'isLive' | 'settings' | 'assertBlank' | 'create' | 'close'>;
|
|
21
21
|
export interface ControllerDependencies {
|
|
22
22
|
git?: (actor: Agent, signal: AbortSignal, guard: () => void) => GitApi;
|
package/dist/service.js
CHANGED
|
@@ -478,6 +478,24 @@ export class WorktreeController {
|
|
|
478
478
|
return { repository, projectPath: path, ...(projectBinding === undefined ? {} : { projectBinding }), remotes: await git.remotes(repository.root, invocation.signal), settingsHash: settings.hash, defaults: { remote: this.config.defaultRemote, branch: this.config.defaultBranch }, namingConfig: namingConfig(this.config) };
|
|
479
479
|
}
|
|
480
480
|
async branches(request, invocation, git, repository) {
|
|
481
|
+
if (request.branchSource === 'local') {
|
|
482
|
+
let generation = this.generation;
|
|
483
|
+
if (request.cursor === undefined) {
|
|
484
|
+
this.generation = undefined;
|
|
485
|
+
const value = await git.localBranches(repository.root, invocation.signal);
|
|
486
|
+
generation = { id: randomUUID(), repository: hashValue([repository.root, repository.commonDir]), branchSource: 'local', value: { ...value, branches: [...value.branches].sort((a, b) => a.name.localeCompare(b.name)) } };
|
|
487
|
+
this.generation = generation;
|
|
488
|
+
}
|
|
489
|
+
// HEAD/default is checkout-specific even when linked checkouts share refs.
|
|
490
|
+
if (generation === undefined || generation.branchSource !== 'local' || generation.repository !== hashValue([repository.root, repository.commonDir]))
|
|
491
|
+
throw new WorktreeError('STALE_CURSOR', 'The local branch generation expired. Reload its first page.');
|
|
492
|
+
const query = request.query ?? '';
|
|
493
|
+
const items = generation.value.branches.filter(branch => branch.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
|
494
|
+
const key = hashValue({ generation: generation.id, query });
|
|
495
|
+
const offset = this.cursor(key, request.cursor);
|
|
496
|
+
const limit = request.limit ?? 50;
|
|
497
|
+
return { branchSource: 'local', currentBranch: generation.value.currentBranch, defaultBranch: generation.value.defaultBranch, items: items.slice(offset, offset + limit), observedAt: generation.value.observedAt, ...(offset + limit < items.length ? { nextCursor: this.nextCursor(key, offset + limit) } : {}) };
|
|
498
|
+
}
|
|
481
499
|
const remote = requiredText(request.remote, 'remote');
|
|
482
500
|
const identity = (await git.remotes(repository.root, invocation.signal)).find(r => r.name === remote)?.identity;
|
|
483
501
|
if (identity === undefined)
|
|
@@ -488,10 +506,10 @@ export class WorktreeController {
|
|
|
488
506
|
if (request.cursor === undefined) {
|
|
489
507
|
this.generation = undefined;
|
|
490
508
|
const value = await git.branches(repository.root, remote, identity, invocation.signal);
|
|
491
|
-
generation = { id: randomUUID(), repository: repository.commonDir, value: { ...value, branches: [...value.branches].sort((a, b) => a.name.localeCompare(b.name)) } };
|
|
509
|
+
generation = { id: randomUUID(), repository: repository.commonDir, branchSource: 'remote', value: { ...value, branches: [...value.branches].sort((a, b) => a.name.localeCompare(b.name)) } };
|
|
492
510
|
this.generation = generation;
|
|
493
511
|
}
|
|
494
|
-
if (generation === undefined || generation.repository !== repository.commonDir || generation.value.remote !== remote || generation.value.remoteIdentity !== identity)
|
|
512
|
+
if (generation === undefined || generation.branchSource !== 'remote' || generation.repository !== repository.commonDir || generation.value.remote !== remote || generation.value.remoteIdentity !== identity)
|
|
495
513
|
throw new WorktreeError('STALE_CURSOR', 'The branch generation expired. Reload its first page.');
|
|
496
514
|
const query = request.query ?? '';
|
|
497
515
|
const items = generation.value.branches.filter(branch => branch.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
|
@@ -560,6 +578,9 @@ export class WorktreeController {
|
|
|
560
578
|
if (request.requireBlankSource === true)
|
|
561
579
|
await this.bootstrap.assertBlank(source);
|
|
562
580
|
let repository = await git.discover(path, invocation.signal);
|
|
581
|
+
// Local HEAD is specific to the selected execution checkout, not its logical
|
|
582
|
+
// original Local folder (which may be attached to a different branch).
|
|
583
|
+
const selectedRepository = repository;
|
|
563
584
|
let project = this.projectSelection(path, request);
|
|
564
585
|
if (project === undefined && this.dependencies.projects !== undefined) {
|
|
565
586
|
// An explicit agent repoPath may not have had a native Workspace yet.
|
|
@@ -595,13 +616,20 @@ export class WorktreeController {
|
|
|
595
616
|
return this.queued(repository.commonDir, invocation.signal, async () => {
|
|
596
617
|
this.assertProject(project);
|
|
597
618
|
await this.sameSettings(source, settings, request, invocation);
|
|
598
|
-
const
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
619
|
+
const branchSource = request.branchSource ?? 'remote';
|
|
620
|
+
const remote = branchSource === 'local' ? 'local' : requiredText(request.remote, 'remote');
|
|
621
|
+
const remoteBranch = branchSource === 'local' ? requiredText(request.localBranch, 'localBranch') : requiredText(request.remoteBranch, 'remoteBranch');
|
|
622
|
+
let identity;
|
|
623
|
+
if (branchSource === 'local')
|
|
624
|
+
identity = hashValue(repository.commonDir);
|
|
625
|
+
else {
|
|
626
|
+
const selected = (await git.remotes(repository.root, invocation.signal)).find(r => r.name === remote)?.identity;
|
|
627
|
+
if (selected === undefined)
|
|
628
|
+
throw new WorktreeError('REMOTE_NOT_FOUND', 'The selected remote is not configured.');
|
|
629
|
+
if (request.remoteIdentity !== undefined && request.remoteIdentity !== selected)
|
|
630
|
+
throw new WorktreeError('REMOTE_CHANGED', 'The remote configuration changed. Reload its branch list.');
|
|
631
|
+
identity = selected;
|
|
632
|
+
}
|
|
605
633
|
const root = resolveWorktreeRoot(await canonicalFuture(resolveWorktreeRoot(invocation.capturedRoot)));
|
|
606
634
|
if (contained(repository.root, root))
|
|
607
635
|
throw new WorktreeError('UNSAFE_ROOT', 'The worktree root must be outside the source checkout.');
|
|
@@ -609,14 +637,15 @@ export class WorktreeController {
|
|
|
609
637
|
const directoryRoot = resolve(root, hashValue(repository.commonDir).slice(0, 24), id);
|
|
610
638
|
const initialBranch = `worktree/${id}`;
|
|
611
639
|
const destination = resolve(directoryRoot, `worktree-${id}`);
|
|
612
|
-
const record = { id, operationId, repoRoot: repository.root, commonDir: repository.commonDir, projectSubdir: repository.projectSubdir, checkoutRoot: destination, effectiveCwd: resolve(destination, repository.projectSubdir), remote, remoteIdentity: identity, remoteBranch, baseOid: '', baseRef: `refs/dsh-worktrees/${id}/base`, fetchedAt: 0, createdAt: Date.now(), sessionIds: [], workspaceId: null, branch: null, ...(project === undefined ? {} : { projectId: project.projectId, folderId: project.folderId }), protected: false, archived: false, state: 'creating', error: null };
|
|
640
|
+
const record = { id, operationId, repoRoot: repository.root, commonDir: repository.commonDir, projectSubdir: repository.projectSubdir, checkoutRoot: destination, effectiveCwd: resolve(destination, repository.projectSubdir), remote, remoteIdentity: identity, remoteBranch, branchSource, ...(branchSource === 'local' ? { localBranch: remoteBranch } : {}), baseOid: '', baseRef: `refs/dsh-worktrees/${id}/base`, fetchedAt: 0, createdAt: Date.now(), sessionIds: [], workspaceId: null, branch: null, ...(project === undefined ? {} : { projectId: project.projectId, folderId: project.folderId }), protected: false, archived: false, state: 'creating', error: null };
|
|
613
641
|
let operation = this.newOperation(operationId, 'create', requestHash, invocation.agent, record, source, settings, request.sessionMode ?? 'new');
|
|
614
642
|
let effects = false;
|
|
615
643
|
try {
|
|
616
644
|
await this.putOperation(operation, request, invocation);
|
|
617
645
|
await this.putRecord(record, request, invocation);
|
|
618
646
|
await this.sameSettings(source, settings, request, invocation);
|
|
619
|
-
|
|
647
|
+
if (branchSource === 'remote')
|
|
648
|
+
operation = await this.phase(operation, 'fetching', request, invocation);
|
|
620
649
|
effects = true;
|
|
621
650
|
this.guard(request, invocation);
|
|
622
651
|
await mkdir(dirname(destination), { recursive: true });
|
|
@@ -624,9 +653,12 @@ export class WorktreeController {
|
|
|
624
653
|
throw new WorktreeError('PATH_CHANGED', 'The destination ancestry changed during preparation.');
|
|
625
654
|
this.guard(request, invocation);
|
|
626
655
|
const progressOperation = Object.freeze({ id: operationId, worktreeId: id });
|
|
627
|
-
const
|
|
656
|
+
const sourceInput = {
|
|
657
|
+
id, repository: branchSource === 'local' ? selectedRepository : repository, destination, baseRef: record.baseRef, signal: invocation.signal,
|
|
628
658
|
onProgress: stage => this.progress(invocation, progressOperation, stage),
|
|
629
|
-
|
|
659
|
+
...(branchSource === 'local' ? { branchSource: 'local', localBranch: remoteBranch, ...(request.localOid === undefined ? {} : { localOid: request.localOid }) } : { branchSource: 'remote', remote, remoteIdentity: identity, remoteBranch }),
|
|
660
|
+
};
|
|
661
|
+
const checkout = await git.create(sourceInput);
|
|
630
662
|
let created = { ...record, ...checkout };
|
|
631
663
|
await this.store.worktrees.put(created.id, clone(created));
|
|
632
664
|
operation = await this.receipt(operation, 'checkout-created');
|
package/dist/store.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export declare const worktreeDomain: {
|
|
|
26
26
|
archived: boolean;
|
|
27
27
|
state: "creating" | "ready" | "recovery-required" | "missing";
|
|
28
28
|
error: string | null;
|
|
29
|
+
branchSource?: "local" | "remote" | undefined;
|
|
30
|
+
localBranch?: string | undefined;
|
|
29
31
|
displayName?: string | undefined;
|
|
30
32
|
naming?: {
|
|
31
33
|
title: string;
|
package/dist/store.js
CHANGED
|
@@ -11,6 +11,7 @@ const namingSchema = z.object({
|
|
|
11
11
|
const worktreeSchema = z.object({
|
|
12
12
|
id: z.string().uuid(), operationId: z.string().uuid(), repoRoot: str, commonDir: str, projectSubdir: str,
|
|
13
13
|
checkoutRoot: str, effectiveCwd: str, remote: str, remoteIdentity: str, remoteBranch: str,
|
|
14
|
+
branchSource: z.enum(['local', 'remote']).optional(), localBranch: str.optional(),
|
|
14
15
|
baseOid: str, baseRef: str, fetchedAt: z.number().int().nonnegative(), createdAt: z.number().int().nonnegative(),
|
|
15
16
|
sessionIds: z.array(str), workspaceId: str.nullable(), branch: str.nullable(), protected: z.boolean(), archived: z.boolean(),
|
|
16
17
|
state: z.enum(['creating', 'ready', 'recovery-required', 'missing']), error: str.nullable(),
|
package/dist/types.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export interface RepositoryInfo {
|
|
|
50
50
|
head: string;
|
|
51
51
|
branch: string | null;
|
|
52
52
|
}
|
|
53
|
+
export type BranchSource = 'local' | 'remote';
|
|
53
54
|
export interface RemoteInfo {
|
|
54
55
|
name: string;
|
|
55
56
|
identity: string;
|
|
@@ -58,6 +59,13 @@ export interface RemoteBranch {
|
|
|
58
59
|
name: string;
|
|
59
60
|
oid: string;
|
|
60
61
|
}
|
|
62
|
+
export interface LocalBranches {
|
|
63
|
+
branchSource: 'local';
|
|
64
|
+
currentBranch: string | null;
|
|
65
|
+
defaultBranch: string | null;
|
|
66
|
+
branches: RemoteBranch[];
|
|
67
|
+
observedAt: number;
|
|
68
|
+
}
|
|
61
69
|
export interface RemoteBranches {
|
|
62
70
|
remote: string;
|
|
63
71
|
remoteIdentity: string;
|
|
@@ -79,18 +87,30 @@ export interface WorktreeSetupProgress {
|
|
|
79
87
|
readonly operationId: string;
|
|
80
88
|
readonly worktreeId?: string;
|
|
81
89
|
}
|
|
82
|
-
export
|
|
90
|
+
export type CreateCheckoutInput = {
|
|
83
91
|
id: string;
|
|
84
92
|
repository: RepositoryInfo;
|
|
85
93
|
destination: string;
|
|
86
|
-
remote: string;
|
|
87
|
-
remoteIdentity: string;
|
|
88
|
-
remoteBranch: string;
|
|
89
94
|
baseRef: string;
|
|
90
95
|
signal?: AbortSignal;
|
|
91
96
|
/** Observer failures are ignored; never used to authorize or change Git effects. */
|
|
92
97
|
onProgress?: (stage: 'fetching' | 'creating') => void;
|
|
93
|
-
}
|
|
98
|
+
} & ({
|
|
99
|
+
branchSource: 'local';
|
|
100
|
+
localBranch: string;
|
|
101
|
+
localOid?: string;
|
|
102
|
+
remote?: never;
|
|
103
|
+
remoteIdentity?: never;
|
|
104
|
+
remoteBranch?: never;
|
|
105
|
+
} | {
|
|
106
|
+
/** Omission preserves freshly fetched remote behavior. */
|
|
107
|
+
branchSource?: 'remote';
|
|
108
|
+
remote: string;
|
|
109
|
+
remoteIdentity: string;
|
|
110
|
+
remoteBranch: string;
|
|
111
|
+
localBranch?: never;
|
|
112
|
+
localOid?: never;
|
|
113
|
+
});
|
|
94
114
|
export interface CreatedCheckout {
|
|
95
115
|
checkoutRoot: string;
|
|
96
116
|
effectiveCwd: string;
|
|
@@ -155,6 +175,9 @@ export interface WorktreeRecord {
|
|
|
155
175
|
remote: string;
|
|
156
176
|
remoteIdentity: string;
|
|
157
177
|
remoteBranch: string;
|
|
178
|
+
/** Absent on legacy records means remote, never inferred from remote's value. */
|
|
179
|
+
branchSource?: BranchSource;
|
|
180
|
+
localBranch?: string;
|
|
158
181
|
baseOid: string;
|
|
159
182
|
baseRef: string;
|
|
160
183
|
fetchedAt: number;
|
|
@@ -219,6 +242,9 @@ export interface WorktreeRequest {
|
|
|
219
242
|
repoPath?: string;
|
|
220
243
|
id?: string;
|
|
221
244
|
operationId?: string;
|
|
245
|
+
branchSource?: BranchSource;
|
|
246
|
+
localBranch?: string;
|
|
247
|
+
localOid?: string;
|
|
222
248
|
remote?: string;
|
|
223
249
|
remoteIdentity?: string;
|
|
224
250
|
remoteBranch?: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1yefuwang1/dsh-worktrees",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Multi-folder projects with Local and isolated Git worktree threads, freshly fetched remote branches and guarded local handoff.",
|
|
3
|
+
"version": "0.2.18",
|
|
4
|
+
"description": "Multi-folder projects with Local and isolated Git worktree threads, local or freshly fetched remote starting branches and guarded local handoff.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
],
|
|
43
43
|
"icon": "./icon.svg",
|
|
44
44
|
"license": "MIT",
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"jsdom": "^30.1.2",
|
|
47
|
+
"react": "^19.3.0",
|
|
48
|
+
"react-dom": "^19.3.0"
|
|
49
|
+
},
|
|
45
50
|
"publishConfig": {
|
|
46
51
|
"access": "public",
|
|
47
52
|
"registry": "https://registry.npmjs.org/"
|