@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/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
- `Fetched base branch: ${quote(`${record.remote}/${record.remoteBranch}`)}`,
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
- 'The base is a creation-time fetched snapshot, not continuous synchronization. Git refs and repository metadata remain shared.',
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', '--short', 'HEAD'], signal, { allowed: [0, 1] });
149
- return { root, commonDir, projectSubdir: path.relative(root, project).split(path.sep).join('/'), head: oid(line(headResult.stdout)), branch: branchResult.exitCode === 0 ? line(branchResult.stdout) : null };
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
- const remote = await this.remote(repository.root, input.remote, signal);
329
- if (remote.identity !== input.remoteIdentity)
330
- fail('REMOTE_CHANGED', 'The selected remote identity has changed.');
331
- checkoutProgress(input, 'fetching');
332
- 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 });
333
- if ((await this.remote(repository.root, input.remote, signal)).identity !== remote.identity)
334
- fail('REMOTE_CHANGED', 'The remote changed during fetch; fetched refs may remain.', { refsMayHaveChanged: true });
335
- const baseOid = oid(line((await this.run(repository.root, ['rev-parse', '--verify', `${input.baseRef}^{commit}`], signal)).stdout));
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 freshly fetched tree.');
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 fetched tree contains an escaping symlink.');
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 fetched project directory is unavailable.'));
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
- if (roots.filter(value => value === info.root).length !== 1)
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 => {
@@ -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: "worktree" | "local";
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', 'remote', 'remoteBranch'], start: ['id'], branch: ['id', 'name'], protect: ['id', 'protected'],
39
- preview: ['id'], export: ['previewId'], handoff: ['id', 'previewId', 'operationId'], archive: ['id'], branches: ['remote'],
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 remote = requiredText(request.remote, 'remote');
599
- const remoteBranch = requiredText(request.remoteBranch, 'remoteBranch');
600
- const identity = (await git.remotes(repository.root, invocation.signal)).find(r => r.name === remote)?.identity;
601
- if (identity === undefined)
602
- throw new WorktreeError('REMOTE_NOT_FOUND', 'The selected remote is not configured.');
603
- if (request.remoteIdentity !== undefined && request.remoteIdentity !== identity)
604
- throw new WorktreeError('REMOTE_CHANGED', 'The remote configuration changed. Reload its branch list.');
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
- operation = await this.phase(operation, 'fetching', request, invocation);
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 checkout = await git.create({ id, repository, destination, remote, remoteIdentity: identity, remoteBranch, baseRef: record.baseRef, signal: invocation.signal,
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 interface CreateCheckoutInput {
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.13",
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/"