dsh-worktree-switch 0.3.1 → 0.5.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/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
- import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
- import { lstat, realpath, stat } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { lstat, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
4
  // src/host/paths.ts
5
5
  /**
6
6
  * Path predicates the host half shares between the verbs, the scope hint, and
@@ -280,6 +280,243 @@ async function changedFileCount(ctx, directory, signal) {
280
280
  "--untracked-files=normal"
281
281
  ], { signal })).stdout.split("\n").filter((line) => line.trim() !== "").length;
282
282
  }
283
+ /**
284
+ * Resolve the whereabouts of one checkout's Git administration directory.
285
+ *
286
+ * A linked worktree's `.git` is a file pointing at
287
+ * `<common>/worktrees/<name>`; this is the directory Git owns for that one
288
+ * checkout, which is where the switch records its claim on the copy.
289
+ * @param ctx - Host context carrying the subprocess provider.
290
+ * @param checkout - absolute directory of the checkout to ask.
291
+ * @param signal - caller cancellation.
292
+ * @returns the absolute admin directory Git reports.
293
+ * @throws {GitFailure} when the directory is not inside a Git working tree.
294
+ */
295
+ async function absoluteGitDir(ctx, checkout, signal) {
296
+ const result = await runGit(ctx, checkout, ["rev-parse", "--absolute-git-dir"], { signal });
297
+ const path = result.stdout.replace(/\r?\n$/, "");
298
+ if (path === "") throw new GitFailure(["rev-parse", "--absolute-git-dir"], result);
299
+ return path;
300
+ }
301
+ /**
302
+ * Read the branch one checkout has checked out.
303
+ * @param ctx - Host context carrying the subprocess provider.
304
+ * @param checkout - absolute directory of the checkout to ask.
305
+ * @param signal - caller cancellation.
306
+ * @returns the branch name, or null when the checkout is on a detached HEAD.
307
+ */
308
+ async function currentBranch(ctx, checkout, signal) {
309
+ const result = await runGit(ctx, checkout, [
310
+ "symbolic-ref",
311
+ "--short",
312
+ "--quiet",
313
+ "HEAD"
314
+ ], {
315
+ signal,
316
+ allowedExitCodes: [0, 1]
317
+ });
318
+ const name = result.stdout.replace(/\r?\n$/, "");
319
+ return result.exitCode === 0 && name !== "" ? name : null;
320
+ }
321
+ /**
322
+ * Resolve one revision to the commit id it names.
323
+ * @param ctx - Host context carrying the subprocess provider.
324
+ * @param directory - checkout the command runs in.
325
+ * @param revision - revision to resolve; `HEAD` or a full id are the usual values.
326
+ * @param signal - caller cancellation.
327
+ * @returns the commit id, or null when the revision does not resolve.
328
+ */
329
+ async function revParse(ctx, directory, revision, signal) {
330
+ const result = await runGit(ctx, directory, [
331
+ "rev-parse",
332
+ "--verify",
333
+ "--quiet",
334
+ revision
335
+ ], {
336
+ signal,
337
+ allowedExitCodes: [
338
+ 0,
339
+ 1,
340
+ 128
341
+ ]
342
+ });
343
+ const id = result.stdout.replace(/\r?\n$/, "");
344
+ return result.exitCode === 0 && id !== "" ? id : null;
345
+ }
346
+ /**
347
+ * Count the commits in one revision range.
348
+ * @param ctx - Host context carrying the subprocess provider.
349
+ * @param directory - checkout the command runs in.
350
+ * @param range - the range to count, e.g. `HEAD..refs/heads/<branch>`.
351
+ * @param signal - caller cancellation.
352
+ * @returns the number of commits in the range.
353
+ * @throws {GitFailure} when the range does not resolve.
354
+ */
355
+ async function revListCount(ctx, directory, range, signal) {
356
+ const result = await runGit(ctx, directory, [
357
+ "rev-list",
358
+ "--count",
359
+ range
360
+ ], { signal });
361
+ const count = Number.parseInt(result.stdout.trim(), 10);
362
+ return Number.isFinite(count) ? count : 0;
363
+ }
364
+ /**
365
+ * Parse `git for-each-ref` output produced with the NUL-separated format below.
366
+ * @param stdout - the command's stdout.
367
+ * @returns one entry per local branch, in Git's order.
368
+ */
369
+ function parseBranchRefs(stdout) {
370
+ const refs = [];
371
+ for (const line of stdout.split("\n")) {
372
+ if (line === "") continue;
373
+ const [name, commit, seconds, subject] = line.split("\0");
374
+ if (name === void 0 || commit === void 0 || seconds === void 0 || subject === void 0) continue;
375
+ const committedAt = Number.parseInt(seconds, 10) * 1e3;
376
+ refs.push({
377
+ name,
378
+ commit,
379
+ committedAt: Number.isFinite(committedAt) ? committedAt : 0,
380
+ subject
381
+ });
382
+ }
383
+ return refs;
384
+ }
385
+ /**
386
+ * List the local branches of one repository.
387
+ * @param ctx - Host context carrying the subprocess provider.
388
+ * @param root - repository root the command runs in.
389
+ * @param signal - caller cancellation.
390
+ * @returns every local branch with its tip commit.
391
+ */
392
+ async function listBranchRefs(ctx, root, signal) {
393
+ return parseBranchRefs((await runGit(ctx, root, [
394
+ "for-each-ref",
395
+ "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)",
396
+ "refs/heads"
397
+ ], { signal })).stdout);
398
+ }
399
+ // src/host/base.ts
400
+ /** Whole-command deadline for the plugin's one network call. */
401
+ const FETCH_TIMEOUT_MS = 5e3;
402
+ /** Local HEAD, used by `head` mode and by every silent failure. */
403
+ const LOCAL_HEAD = {
404
+ from: "HEAD",
405
+ base: { kind: "head" }
406
+ };
407
+ /**
408
+ * Resolve the default branch `origin` advertises, if it has one.
409
+ *
410
+ * `origin/HEAD` is the clone's own answer; clones that lost it still commonly
411
+ * default to `main` or `master`, so those two are probed before giving up. A
412
+ * repository with no `origin` is a normal local-only project, not a failure:
413
+ * the caller skips fetching entirely and leaves no notice behind.
414
+ * @param ctx - Host context carrying the subprocess provider.
415
+ * @param root - canonical repository root to ask.
416
+ * @param signal - caller cancellation.
417
+ * @returns the branch name without its `origin/` prefix, or null.
418
+ */
419
+ async function defaultBranch(ctx, root, signal) {
420
+ const remote = await runGit(ctx, root, [
421
+ "remote",
422
+ "get-url",
423
+ "origin"
424
+ ], {
425
+ signal,
426
+ allowedExitCodes: [
427
+ 0,
428
+ 1,
429
+ 2
430
+ ]
431
+ });
432
+ if (remote.exitCode !== 0 || remote.stdout.trim() === "") return null;
433
+ const symbolic = await runGit(ctx, root, [
434
+ "symbolic-ref",
435
+ "--short",
436
+ "refs/remotes/origin/HEAD"
437
+ ], {
438
+ signal,
439
+ allowedExitCodes: [
440
+ 0,
441
+ 1,
442
+ 128
443
+ ]
444
+ });
445
+ if (symbolic.exitCode === 0) {
446
+ const name = symbolic.stdout.trim();
447
+ if (name.startsWith("origin/") && name.length > 7) return name.slice(7);
448
+ }
449
+ for (const candidate of ["main", "master"]) {
450
+ const known = await runGit(ctx, root, [
451
+ "rev-parse",
452
+ "--verify",
453
+ "--quiet",
454
+ `refs/remotes/origin/${candidate}`
455
+ ], {
456
+ signal,
457
+ allowedExitCodes: [0, 1]
458
+ });
459
+ if (known.exitCode === 0 && known.stdout.trim() !== "") return candidate;
460
+ }
461
+ return null;
462
+ }
463
+ /**
464
+ * Choose the base commit for a copy that is about to be created.
465
+ *
466
+ * `head` mode never touches the network. `fresh` mode fetches only the default
467
+ * branch, with an explicit refspec so a rewritten `remote.origin.fetch` cannot
468
+ * silently skip the update, and returns the fetched commit itself rather than
469
+ * the ref name: another process may advance the ref between the fetch and the
470
+ * worktree service's checkout.
471
+ * @param ctx - Host context carrying the subprocess provider.
472
+ * @param root - canonical repository root the fetch runs in.
473
+ * @param baseRef - configured base point.
474
+ * @param signal - caller cancellation; distinct from the fetch deadline.
475
+ * @returns the revision to create from and the outcome to report.
476
+ * @throws when the caller cancels; every Git failure is contained.
477
+ */
478
+ async function resolveBase(ctx, root, baseRef, signal) {
479
+ if (baseRef === "head") return LOCAL_HEAD;
480
+ let branch;
481
+ try {
482
+ branch = await defaultBranch(ctx, root, signal);
483
+ } catch {
484
+ signal?.throwIfAborted();
485
+ return LOCAL_HEAD;
486
+ }
487
+ if (branch === null) return LOCAL_HEAD;
488
+ try {
489
+ await runGit(ctx, root, [
490
+ "fetch",
491
+ "--no-tags",
492
+ "--quiet",
493
+ "origin",
494
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`
495
+ ], {
496
+ signal,
497
+ timeoutMs: FETCH_TIMEOUT_MS
498
+ });
499
+ const from = (await runGit(ctx, root, [
500
+ "rev-parse",
501
+ "--verify",
502
+ `refs/remotes/origin/${branch}^{commit}`
503
+ ], { signal })).stdout.trim();
504
+ if (from === "") throw new Error(`origin/${branch} has no commit after fetching it`);
505
+ return {
506
+ from,
507
+ base: {
508
+ kind: "remote",
509
+ branch
510
+ }
511
+ };
512
+ } catch {
513
+ signal?.throwIfAborted();
514
+ return {
515
+ from: "HEAD",
516
+ base: { kind: "fallback" }
517
+ };
518
+ }
519
+ }
283
520
  // src/host/naming.ts
284
521
  /**
285
522
  * Names for the copy a session gets: lowercase words and a short suffix, so a
@@ -367,6 +604,19 @@ function worktreeName(random = Math.random) {
367
604
  }
368
605
  /** A pool entry name is exactly one path segment, as `git worktree` names it. */
369
606
  const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
607
+ /** The shape this plugin generates: two lowercase words and four hex digits. */
608
+ const GENERATED_PATTERN = /^[a-z]+-[a-z]+-[0-9a-f]{4}$/;
609
+ /**
610
+ * Whether a branch name looks like one this plugin generated.
611
+ *
612
+ * Leftover-branch cleanup only offers names of this shape: an ordinary feature
613
+ * branch is never a candidate, even when no checkout holds it.
614
+ * @param value - candidate branch name.
615
+ * @returns true for the generated-name format.
616
+ */
617
+ function isGeneratedName(value) {
618
+ return GENERATED_PATTERN.test(value);
619
+ }
370
620
  /**
371
621
  * Validate a worktree name received from the browser before it reaches Git.
372
622
  * @param value - candidate name.
@@ -375,9 +625,66 @@ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
375
625
  function isWorktreeName(value) {
376
626
  return NAME_PATTERN.test(value) && !value.endsWith(".lock");
377
627
  }
628
+ // src/host/ownership.ts
629
+ /**
630
+ * The switch's claim on a copy.
631
+ *
632
+ * "This copy was created by the switch for this session" used to live only in
633
+ * host memory, so a host restart turned every copy into a stranger and the
634
+ * leave path could never prove it was safe to undo. The claim is now stored in
635
+ * the checkout's own Git administration directory: it travels with the copy,
636
+ * vanishes when the copy is deleted, never appears in the working tree, and
637
+ * survives a restart. A missing or unparseable marker means "not owned" — the
638
+ * plugin does not guess, because guessing wrong deletes work.
639
+ * @module dsh-worktree-switch/host/ownership
640
+ */
641
+ /** File name inside the checkout's admin directory. */
642
+ const MARKER_FILE = "dsh-worktree-switch.json";
643
+ /**
644
+ * Read one checkout's ownership marker.
645
+ *
646
+ * Every failure — no admin directory, no marker, invalid JSON, a shape this
647
+ * version does not understand — reads as "no claim". The leave path treats
648
+ * that as a reason to keep the copy, never as a reason to take it.
649
+ * @param ctx - Host context carrying the subprocess provider.
650
+ * @param checkout - absolute directory of the copy.
651
+ * @param signal - caller cancellation.
652
+ * @returns the recorded claim, or null when there is none.
653
+ */
654
+ async function readOwnership(ctx, checkout, signal) {
655
+ try {
656
+ const directory = await absoluteGitDir(ctx, checkout, signal);
657
+ const raw = await readFile(join(directory, MARKER_FILE), "utf8");
658
+ const parsed = JSON.parse(raw);
659
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
660
+ const { sessionId, createdAt, baseCommit } = parsed;
661
+ if (typeof sessionId !== "string" || sessionId === "") return null;
662
+ if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return null;
663
+ if (typeof baseCommit !== "string" || baseCommit === "") return null;
664
+ return {
665
+ sessionId,
666
+ createdAt,
667
+ baseCommit
668
+ };
669
+ } catch {
670
+ return null;
671
+ }
672
+ }
673
+ /**
674
+ * Record the switch's claim on a freshly created copy.
675
+ * @param ctx - Host context carrying the subprocess provider.
676
+ * @param checkout - absolute directory of the copy.
677
+ * @param ownership - the claim to write.
678
+ * @param signal - caller cancellation.
679
+ * @throws when the admin directory cannot be resolved or the marker cannot be written.
680
+ */
681
+ async function writeOwnership(ctx, checkout, ownership, signal) {
682
+ const directory = await absoluteGitDir(ctx, checkout, signal);
683
+ await writeFile(join(directory, MARKER_FILE), `${JSON.stringify(ownership)}\n`, "utf8");
684
+ }
378
685
  // src/host/handlers.ts
379
686
  /**
380
- * The plugin's host Remote face: the five verbs the browser half can call,
687
+ * The plugin's host Remote face: the verbs the browser half can call,
381
688
  * expressed against a live session and the checkouts on disk.
382
689
  *
383
690
  * This module is the seam the host tests drive directly. Everything below it —
@@ -404,6 +711,16 @@ var WorktreeToggleError = class extends Error {
404
711
  this.name = "WorktreeToggleError";
405
712
  }
406
713
  };
714
+ /**
715
+ * The failure one refused branch delete reports.
716
+ * @param branch - branch the verb tried to delete.
717
+ * @param args - the Git command that refused, for the message.
718
+ * @param result - its captured outcome.
719
+ * @returns the error the browser half switches on.
720
+ */
721
+ function branchDeleteFailure(branch, args, result) {
722
+ return new WorktreeToggleError("branch-delete-failed", new GitFailure(args, result).message, { branch });
723
+ }
407
724
  /** Normalize a browser-supplied path before any filesystem work. */
408
725
  function absolutePath(value) {
409
726
  const trimmed = value.trim();
@@ -418,11 +735,13 @@ function absolutePath(value) {
418
735
  * from any configured pool path. The pool belongs to the harness worktree
419
736
  * service, so this plugin never needs a second copy of that setting: moving the
420
737
  * pool in the service's row config keeps every verb working.
738
+ * @param ctx - host context carrying `subprocess`.
421
739
  * @param current - the session's committed working directory.
422
740
  * @param repositoryRoot - canonical root of the project's repository.
741
+ * @param signal - caller cancellation.
423
742
  * @returns the copy's identity, or null when the directory is not one.
424
743
  */
425
- async function worktreeAt(current, repositoryRoot) {
744
+ async function worktreeAt(ctx, current, repositoryRoot, signal) {
426
745
  const path = await canonicalPath(current);
427
746
  if (path === repositoryRoot) return null;
428
747
  const inside = relative(repositoryRoot, path);
@@ -437,7 +756,7 @@ async function worktreeAt(current, repositoryRoot) {
437
756
  return {
438
757
  name,
439
758
  path,
440
- branch: name
759
+ branch: await currentBranch(ctx, path, signal)
441
760
  };
442
761
  }
443
762
  /**
@@ -477,7 +796,7 @@ async function detectSessionScope(ctx, agent, signal) {
477
796
  signal?.throwIfAborted();
478
797
  let probe = current;
479
798
  for (;;) {
480
- const copy = await worktreeAt(probe, root);
799
+ const copy = await worktreeAt(ctx, probe, root, signal);
481
800
  if (copy !== null) return {
482
801
  worktree: copy,
483
802
  repositoryRoot: root
@@ -488,21 +807,111 @@ async function detectSessionScope(ctx, agent, signal) {
488
807
  }
489
808
  }
490
809
  /**
810
+ * The copy rows of one repository, newest first.
811
+ *
812
+ * A row whose checkout directory is gone is still a copy: Git keeps the
813
+ * registration until it is pruned, and the panel offers the same discard for
814
+ * it, marked as missing. A detached copy keeps `branch: null` so no verb can
815
+ * ever mistake the directory name for a branch to delete.
816
+ * @param ctx - host context carrying `subprocess`.
817
+ * @param root - canonical repository root.
818
+ * @param records - `git worktree list` records for the repository.
819
+ * @param signal - caller cancellation.
820
+ * @returns one row per copy under the project.
821
+ */
822
+ async function copyItems(ctx, root, records, signal) {
823
+ const items = [];
824
+ for (const record of records) {
825
+ signal?.throwIfAborted();
826
+ const path = await canonicalPath(record.path);
827
+ if (path === root) continue;
828
+ const name = basename(path);
829
+ if (!isWorktreeName(name)) continue;
830
+ let createdAt = 0;
831
+ let prunable = true;
832
+ try {
833
+ const info = await stat(path);
834
+ createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
835
+ prunable = false;
836
+ } catch {}
837
+ const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
838
+ items.push({
839
+ name,
840
+ path,
841
+ branch: record.branch ?? null,
842
+ createdAt,
843
+ changedFiles,
844
+ prunable
845
+ });
846
+ }
847
+ items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
848
+ return items;
849
+ }
850
+ /**
851
+ * Generated-name branches that no checkout holds.
852
+ *
853
+ * Deliberately narrow: only the names this plugin generates qualify, so an
854
+ * ordinary feature branch never appears in a list whose only action is delete.
855
+ * The commit count is what the source checkout's HEAD does not contain — a
856
+ * branch whose work is fully merged reads as 0, which is the one safe signal.
857
+ * @param ctx - host context carrying `subprocess`.
858
+ * @param root - canonical repository root.
859
+ * @param records - `git worktree list` records for the repository.
860
+ * @param signal - caller cancellation.
861
+ * @returns the leftover branches, newest commit first.
862
+ */
863
+ async function orphanBranches(ctx, root, records, signal) {
864
+ const checkedOut = /* @__PURE__ */ new Set();
865
+ for (const record of records) if (record.branch !== void 0) checkedOut.add(record.branch);
866
+ const orphans = [];
867
+ for (const ref of await listBranchRefs(ctx, root, signal)) {
868
+ signal?.throwIfAborted();
869
+ if (!isGeneratedName(ref.name) || checkedOut.has(ref.name)) continue;
870
+ let commits = 0;
871
+ try {
872
+ commits = await revListCount(ctx, root, `HEAD..${ref.commit}`, signal);
873
+ } catch {}
874
+ orphans.push({
875
+ branch: ref.name,
876
+ commits,
877
+ subject: ref.subject,
878
+ committedAt: ref.committedAt
879
+ });
880
+ }
881
+ orphans.sort((left, right) => right.committedAt - left.committedAt || left.branch.localeCompare(right.branch));
882
+ return orphans;
883
+ }
884
+ /** The wire identity of one listed copy; a detached copy keeps its null branch. */
885
+ function copyRef(item) {
886
+ return {
887
+ name: item.name,
888
+ path: item.path,
889
+ branch: item.branch
890
+ };
891
+ }
892
+ /**
491
893
  * Build the host face.
492
894
  * @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.
493
895
  * @param options - resolved row config.
494
- * @returns the five verbs.
896
+ * @returns the verbs above.
495
897
  */
496
898
  function createWorktreeToggle(ctx, options) {
497
899
  /** One in-flight preparation per session, so a double mount cannot create twice. */
498
900
  const pending = /* @__PURE__ */ new Map();
499
901
  /**
500
- * Copies this host instance created for a session, by session id. Turning the
501
- * switch off may only delete what the switch itself just made: a copy that
502
- * existed before this instance started is left for the panel's explicit
503
- * discard, because it may carry work the switch never saw.
902
+ * Copies this host instance kept when the switch was turned off, by
903
+ * repository root. A host restart forgets them; the panel's count then
904
+ * honestly drops back to leftovers only.
504
905
  */
505
- const createdBySession = /* @__PURE__ */ new Map();
906
+ const keptCopies = /* @__PURE__ */ new Map();
907
+ const keep = (root, name) => {
908
+ const names = keptCopies.get(root) ?? /* @__PURE__ */ new Set();
909
+ names.add(name);
910
+ keptCopies.set(root, names);
911
+ };
912
+ const forget = (root, name) => {
913
+ keptCopies.get(root)?.delete(name);
914
+ };
506
915
  const sessionOf = async (sessionId, signal) => {
507
916
  signal?.throwIfAborted();
508
917
  if (sessionId === "") throw new WorktreeToggleError("bad-request", "a session id is required");
@@ -533,7 +942,7 @@ function createWorktreeToggle(ctx, options) {
533
942
  const current = ctx.workingDirectory.get(agent.session);
534
943
  signal?.throwIfAborted();
535
944
  const sourceDirty = await changedFileCount(ctx, root, signal);
536
- const copy = await worktreeAt(current, root);
945
+ const copy = await worktreeAt(ctx, current, root, signal);
537
946
  options.onScope?.(request.sessionId, copy === null ? null : {
538
947
  worktree: copy,
539
948
  repositoryRoot: root
@@ -567,6 +976,7 @@ function createWorktreeToggle(ctx, options) {
567
976
  repositoryRoot: current.repositoryRoot,
568
977
  workingProject
569
978
  });
979
+ const base = await resolveBase(ctx, current.repositoryRoot, options.baseRef, signal);
570
980
  let lastFailure;
571
981
  for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt += 1) {
572
982
  signal?.throwIfAborted();
@@ -574,14 +984,21 @@ function createWorktreeToggle(ctx, options) {
574
984
  try {
575
985
  const created = await ctx.worktrees.create(agent, {
576
986
  name,
577
- from: "HEAD"
987
+ from: base.from
578
988
  }, signal);
579
989
  const worktree = {
580
990
  name: created.branch,
581
991
  path: created.path,
582
992
  branch: created.branch
583
993
  };
584
- createdBySession.set(request.sessionId, worktree);
994
+ const baseCommit = await revParse(ctx, created.path, "HEAD");
995
+ if (baseCommit !== null) try {
996
+ await writeOwnership(ctx, created.path, {
997
+ sessionId: request.sessionId,
998
+ createdAt: Date.now(),
999
+ baseCommit
1000
+ });
1001
+ } catch {}
585
1002
  options.onScope?.(request.sessionId, {
586
1003
  worktree,
587
1004
  repositoryRoot: created.repositoryRoot
@@ -590,7 +1007,8 @@ function createWorktreeToggle(ctx, options) {
590
1007
  state: "created",
591
1008
  worktree,
592
1009
  repositoryRoot: created.repositoryRoot,
593
- sourceDirty: current.sourceDirty
1010
+ sourceDirty: current.sourceDirty,
1011
+ base: base.base
594
1012
  };
595
1013
  } catch (error) {
596
1014
  lastFailure = error;
@@ -611,6 +1029,22 @@ function createWorktreeToggle(ctx, options) {
611
1029
  if (pending.get(request.sessionId) === operation) pending.delete(request.sessionId);
612
1030
  }
613
1031
  };
1032
+ const scope = async (request, signal) => {
1033
+ const agent = await sessionOf(request.sessionId, signal);
1034
+ const current = ctx.workingDirectory.get(agent.session);
1035
+ const known = options.readScope?.(request.sessionId) ?? null;
1036
+ const found = known !== null && contains(known.worktree.path, current) ? known : await detectSessionScope(ctx, agent, signal);
1037
+ if (found === null) return { inWorktree: false };
1038
+ signal?.throwIfAborted();
1039
+ const changedFiles = await changedFileCount(ctx, found.worktree.path, signal);
1040
+ options.onScope?.(request.sessionId, found);
1041
+ return {
1042
+ inWorktree: true,
1043
+ worktree: found.worktree,
1044
+ repositoryRoot: found.repositoryRoot,
1045
+ changedFiles
1046
+ };
1047
+ };
614
1048
  const list = async (request, signal) => {
615
1049
  const directory = absolutePath(request.path);
616
1050
  let root;
@@ -624,50 +1058,84 @@ function createWorktreeToggle(ctx, options) {
624
1058
  throw error;
625
1059
  }
626
1060
  const records = await listWorktreeRecords(ctx, root, signal);
627
- const items = [];
628
- for (const record of records) {
629
- signal?.throwIfAborted();
630
- const path = await canonicalPath(record.path);
631
- if (path === root) continue;
632
- const name = basename(path);
633
- if (!isWorktreeName(name)) continue;
634
- const branch = record.branch ?? name;
635
- let createdAt = 0;
636
- let prunable = true;
637
- try {
638
- const info = await stat(path);
639
- createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
640
- prunable = false;
641
- } catch {}
642
- const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
643
- items.push({
644
- name,
645
- path,
646
- branch,
647
- createdAt,
648
- changedFiles,
649
- prunable
650
- });
651
- }
652
- items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
1061
+ const items = await copyItems(ctx, root, records, signal);
1062
+ const orphans = await orphanBranches(ctx, root, records, signal);
1063
+ const kept = keptCopies.get(root);
1064
+ const keptCopiesStillHere = kept === void 0 ? 0 : items.filter((item) => kept.has(item.name)).length;
653
1065
  return {
654
1066
  supported: true,
655
1067
  repositoryRoot: root,
656
- items
1068
+ items,
1069
+ orphans,
1070
+ keptCopies: keptCopiesStillHere
657
1071
  };
658
1072
  };
659
1073
  /**
660
- * Delete one clean copy and its branch. Both the panel's discard and the
661
- * switch's own undo go through here, so the dirty guard has one home.
1074
+ * Delete one branch by name.
1075
+ * @param root - canonical repository root.
1076
+ * @param branch - branch to delete; already validated by the caller.
1077
+ * @param signal - caller cancellation.
1078
+ * @throws {WorktreeToggleError} when Git refused.
1079
+ */
1080
+ const deleteBranch = async (root, branch, signal) => {
1081
+ const deleted = await runGit(ctx, root, [
1082
+ "branch",
1083
+ "-D",
1084
+ "--",
1085
+ branch
1086
+ ], {
1087
+ signal,
1088
+ allowedExitCodes: [0, 1]
1089
+ });
1090
+ const gone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
1091
+ if (deleted.exitCode !== 0 && !gone) throw branchDeleteFailure(branch, [
1092
+ "branch",
1093
+ "-D",
1094
+ branch
1095
+ ], deleted);
1096
+ };
1097
+ /**
1098
+ * Delete a branch a prunable copy still holds.
1099
+ *
1100
+ * `git branch -D` refuses while any worktree registration names the branch,
1101
+ * even a registration whose directory is gone, so the branch is removed
1102
+ * through the ref itself. The registration still pins Git's bookkeeping, but
1103
+ * nothing can have the branch checked out any more: the directory is missing.
1104
+ * @param root - canonical repository root.
1105
+ * @param branch - branch to delete; already validated by the caller.
1106
+ * @param signal - caller cancellation.
1107
+ * @throws {WorktreeToggleError} when Git refused (for example a locked ref).
1108
+ */
1109
+ const deleteBranchByRef = async (root, branch, signal) => {
1110
+ const deleted = await runGit(ctx, root, [
1111
+ "update-ref",
1112
+ "-d",
1113
+ `refs/heads/${branch}`
1114
+ ], {
1115
+ signal,
1116
+ allowedExitCodes: [0, 1]
1117
+ });
1118
+ if (deleted.exitCode !== 0) throw branchDeleteFailure(branch, [
1119
+ "update-ref",
1120
+ "-d",
1121
+ branch
1122
+ ], deleted);
1123
+ };
1124
+ /**
1125
+ * Delete one clean copy and, unless it is detached, its branch.
1126
+ *
1127
+ * Both the panel's discard and the switch's own undo go through here, so the
1128
+ * dirty guard and the delete order have one home. A prunable copy — its
1129
+ * directory was deleted by hand — deletes the branch first and prunes the
1130
+ * registration only once that succeeded, so a refused delete cannot leave the
1131
+ * registration already gone.
662
1132
  * @param root - canonical repository root.
663
1133
  * @param name - copy name; already validated by the caller.
664
1134
  * @param signal - caller cancellation.
665
1135
  * @returns the removed copy's identity.
666
1136
  */
667
1137
  const removeCopy = async (root, name, signal) => {
668
- const listed = await list({ path: root }, signal);
669
- if (!listed.supported) throw new WorktreeToggleError("not-a-git-repository", "the project is not inside a Git working tree");
670
- const item = listed.items.find((entry) => entry.name === name);
1138
+ const item = (await copyItems(ctx, root, await listWorktreeRecords(ctx, root, signal), signal)).find((entry) => entry.name === name);
671
1139
  if (item === void 0) throw new WorktreeToggleError("worktree-not-found", `no worktree named ${name} in ${root}`, {
672
1140
  name,
673
1141
  repositoryRoot: root
@@ -676,8 +1144,10 @@ function createWorktreeToggle(ctx, options) {
676
1144
  name: item.name,
677
1145
  changedFiles: item.changedFiles
678
1146
  });
679
- if (item.prunable) await runGit(ctx, root, ["worktree", "prune"], { signal });
680
- else {
1147
+ if (item.prunable) {
1148
+ if (item.branch !== null) await deleteBranchByRef(root, item.branch, signal);
1149
+ await runGit(ctx, root, ["worktree", "prune"], { signal });
1150
+ } else {
681
1151
  const removed = await runGit(ctx, root, [
682
1152
  "worktree",
683
1153
  "remove",
@@ -694,26 +1164,9 @@ function createWorktreeToggle(ctx, options) {
694
1164
  name: item.name,
695
1165
  path: item.path
696
1166
  });
1167
+ if (item.branch !== null) await deleteBranch(root, item.branch, signal);
697
1168
  }
698
- const deleted = await runGit(ctx, root, [
699
- "branch",
700
- "-D",
701
- "--",
702
- item.branch
703
- ], {
704
- signal,
705
- allowedExitCodes: [0, 1]
706
- });
707
- const branchGone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
708
- if (deleted.exitCode !== 0 && !branchGone) throw new WorktreeToggleError("worktree-remove-failed", new GitFailure([
709
- "branch",
710
- "-D",
711
- item.branch
712
- ], deleted).message, {
713
- name: item.name,
714
- branch: item.branch
715
- });
716
- for (const [sessionId, copy] of createdBySession) if (copy.path === item.path) createdBySession.delete(sessionId);
1169
+ forget(root, item.name);
717
1170
  return {
718
1171
  removed: true,
719
1172
  name: item.name,
@@ -727,6 +1180,17 @@ function createWorktreeToggle(ctx, options) {
727
1180
  const root = await repositoryOf(ctx, directory, signal);
728
1181
  return await removeCopy(root, request.name, signal);
729
1182
  };
1183
+ const discardBranch = async (request, signal) => {
1184
+ const directory = absolutePath(request.path);
1185
+ if (!isGeneratedName(request.branch)) throw new WorktreeToggleError("branch-name-invalid", `not a branch this plugin generated: ${request.branch}`);
1186
+ const root = await repositoryOf(ctx, directory, signal);
1187
+ if ((await listWorktreeRecords(ctx, root, signal)).some((record) => record.branch === request.branch)) throw new WorktreeToggleError("branch-in-use", `${request.branch} is checked out by a worktree; discard that copy instead`, { branch: request.branch });
1188
+ if (new Set((await listBranchRefs(ctx, root, signal)).map((ref) => ref.name)).has(request.branch)) await deleteBranch(root, request.branch, signal);
1189
+ return {
1190
+ removed: true,
1191
+ branch: request.branch
1192
+ };
1193
+ };
730
1194
  const release = async (request, signal) => {
731
1195
  const running = pending.get(request.sessionId);
732
1196
  if (running !== void 0) try {
@@ -743,27 +1207,20 @@ function createWorktreeToggle(ctx, options) {
743
1207
  if (error instanceof GitFailure) throw new WorktreeToggleError("not-a-git-repository", "the session project is not inside a Git working tree");
744
1208
  throw error;
745
1209
  }
746
- const listed = await list({ path: anchor }, signal);
747
- if (!listed.supported) return {
748
- directory: current,
749
- removed: null,
750
- kept: null
751
- };
1210
+ const records = await listWorktreeRecords(ctx, root, signal);
1211
+ const items = await copyItems(ctx, root, records, signal);
752
1212
  const path = await canonicalPath(current);
753
- const item = listed.items.find((entry) => contains(entry.path, path));
1213
+ const item = items.find((entry) => contains(entry.path, path));
754
1214
  if (item === void 0) {
755
1215
  options.onScope?.(request.sessionId, null);
756
1216
  return {
757
1217
  directory: current,
758
1218
  removed: null,
759
- kept: null
1219
+ kept: null,
1220
+ keptReason: null
760
1221
  };
761
1222
  }
762
- const copy = {
763
- name: item.name,
764
- path: item.path,
765
- branch: item.branch
766
- };
1223
+ const copy = copyRef(item);
767
1224
  signal?.throwIfAborted();
768
1225
  let directory;
769
1226
  try {
@@ -775,38 +1232,58 @@ function createWorktreeToggle(ctx, options) {
775
1232
  });
776
1233
  }
777
1234
  options.onScope?.(request.sessionId, null);
778
- const mine = createdBySession.get(request.sessionId);
779
- if (mine === void 0 || mine.path !== copy.path) return {
780
- directory,
781
- removed: null,
782
- kept: copy
783
- };
784
- try {
785
- const removed = await removeCopy(root, copy.name, signal);
786
- return {
1235
+ const keepCopy = (reason, error) => {
1236
+ keep(root, item.name);
1237
+ return error === void 0 ? {
787
1238
  directory,
788
- removed: {
789
- name: removed.name,
790
- path: removed.path,
791
- branch: removed.branch
792
- },
793
- kept: null
794
- };
795
- } catch (error) {
796
- if (signal?.aborted === true) throw error;
797
- return {
1239
+ removed: null,
1240
+ kept: copy,
1241
+ keptReason: reason
1242
+ } : {
798
1243
  directory,
799
1244
  removed: null,
800
- kept: copy
1245
+ kept: copy,
1246
+ keptReason: reason,
1247
+ error
801
1248
  };
1249
+ };
1250
+ const ownership = await readOwnership(ctx, item.path, signal);
1251
+ if (ownership === null || ownership.sessionId !== request.sessionId) return keepCopy("not-owned");
1252
+ if (item.changedFiles > 0) return keepCopy("dirty", `${item.changedFiles} uncommitted change(s)`);
1253
+ const tip = await revParse(ctx, item.path, "HEAD", signal);
1254
+ if (tip === null || tip !== ownership.baseCommit) {
1255
+ let commits = 0;
1256
+ try {
1257
+ commits = await revListCount(ctx, item.path, `${ownership.baseCommit}..HEAD`, signal);
1258
+ } catch {}
1259
+ return keepCopy("dirty", `${commits} new commit(s)`);
802
1260
  }
1261
+ let removed;
1262
+ try {
1263
+ removed = await removeCopy(root, item.name, signal);
1264
+ } catch (error) {
1265
+ keep(root, item.name);
1266
+ throw error;
1267
+ }
1268
+ return {
1269
+ directory,
1270
+ removed: {
1271
+ name: removed.name,
1272
+ path: removed.path,
1273
+ branch: removed.branch
1274
+ },
1275
+ kept: null,
1276
+ keptReason: null
1277
+ };
803
1278
  };
804
1279
  return {
805
1280
  status,
806
1281
  prepare,
807
1282
  release,
1283
+ scope,
808
1284
  list,
809
- discard
1285
+ discard,
1286
+ discardBranch
810
1287
  };
811
1288
  }
812
1289
  // src/host/routes.ts
@@ -819,8 +1296,10 @@ const ENDPOINTS = [
819
1296
  "status",
820
1297
  "prepare",
821
1298
  "release",
1299
+ "scope",
822
1300
  "list",
823
- "discard"
1301
+ "discard",
1302
+ "discardBranch"
824
1303
  ];
825
1304
  /** A payload object with the named string field, or the failure to send back. */
826
1305
  function requiredString(payload, field) {
@@ -890,6 +1369,11 @@ async function dispatch(toggle, endpoint, payload, signal) {
890
1369
  if ("failure" in sessionId) throw sessionId.failure;
891
1370
  return ok(await toggle.release({ sessionId: sessionId.value }, signal));
892
1371
  }
1372
+ if (endpoint === "scope") {
1373
+ const sessionId = requiredString(payload, "sessionId");
1374
+ if ("failure" in sessionId) throw sessionId.failure;
1375
+ return ok(await toggle.scope({ sessionId: sessionId.value }, signal));
1376
+ }
893
1377
  if (endpoint === "list") {
894
1378
  const path = requiredString(payload, "path");
895
1379
  if ("failure" in path) throw path.failure;
@@ -897,6 +1381,14 @@ async function dispatch(toggle, endpoint, payload, signal) {
897
1381
  }
898
1382
  const path = requiredString(payload, "path");
899
1383
  if ("failure" in path) throw path.failure;
1384
+ if (endpoint === "discardBranch") {
1385
+ const branch = requiredString(payload, "branch");
1386
+ if ("failure" in branch) throw branch.failure;
1387
+ return ok(await toggle.discardBranch({
1388
+ path: path.value,
1389
+ branch: branch.value
1390
+ }, signal));
1391
+ }
900
1392
  const name = requiredString(payload, "name");
901
1393
  if ("failure" in name) throw name.failure;
902
1394
  return ok(await toggle.discard({
@@ -1063,7 +1555,8 @@ const inject = [
1063
1555
  /** Schemastery schema for {@link Config}. */
1064
1556
  const Config = Schema.object({
1065
1557
  defaultEnabled: Schema.boolean().default(true).description("Start a new session in a worktree copy until the user turns the switch off for that project."),
1066
- guardSourceCheckout: Schema.boolean().default(true).description("While a session is in a copy, deny shell commands that run Git against the project checkout.")
1558
+ guardSourceCheckout: Schema.boolean().default(true).description("While a session is in a copy, deny shell commands that run Git against the project checkout."),
1559
+ baseRef: Schema.union([Schema.const("fresh"), Schema.const("head")]).default("fresh").description("Where a new copy starts: 'fresh' fetches origin's default branch first; 'head' starts at local HEAD without network access.")
1067
1560
  });
1068
1561
  /**
1069
1562
  * Mount the host half.
@@ -1074,10 +1567,12 @@ function apply(ctx, config) {
1074
1567
  const scopes = new SessionScopeRegistry();
1075
1568
  const toggle = createWorktreeToggle(ctx, {
1076
1569
  defaultEnabled: config.defaultEnabled,
1570
+ baseRef: config.baseRef,
1077
1571
  onScope: (sessionId, scope) => {
1078
1572
  if (scope === null) scopes.forget(sessionId);
1079
1573
  else scopes.remember(sessionId, scope);
1080
- }
1574
+ },
1575
+ readScope: (sessionId) => scopes.get(sessionId)
1081
1576
  });
1082
1577
  installWorktreeScope(ctx, {
1083
1578
  registry: scopes,