dsh-worktree-switch 0.4.0 → 0.5.1
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/README.md +90 -20
- package/lib/client.js +1537 -644
- package/lib/index.js +492 -103
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
@@ -103,6 +103,22 @@ function installSourceCheckoutGuard(ctx, registry) {
|
|
|
103
103
|
};
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
|
+
// src/types.ts
|
|
107
|
+
/**
|
|
108
|
+
* Whether a leave that kept a copy left a 保留副本 behind.
|
|
109
|
+
*
|
|
110
|
+
* Not every copy a leave leaves on disk is a leftover: only `dirty` is. A copy
|
|
111
|
+
* kept because this session never owned it (`not-owned`) is the ordinary result
|
|
112
|
+
* of leaving someone else's copy — explained in the switch's hover, never
|
|
113
|
+
* reported as cleanup. The host registers a kept copy for the panel count and
|
|
114
|
+
* the browser half raises the one-shot notice only when this returns true, so
|
|
115
|
+
* the two halves cannot drift apart on the rule.
|
|
116
|
+
* @param reason - why the host kept the copy, or null when it kept nothing.
|
|
117
|
+
* @returns whether the kept copy is a leftover this host has to clean up.
|
|
118
|
+
*/
|
|
119
|
+
function isKeptLeftover(reason) {
|
|
120
|
+
return reason === "dirty";
|
|
121
|
+
}
|
|
106
122
|
/** Per-stream capture cap; worktree listings and status lines are small. */
|
|
107
123
|
const MAX_OUTPUT_BYTES = 1048576;
|
|
108
124
|
/**
|
|
@@ -280,6 +296,122 @@ async function changedFileCount(ctx, directory, signal) {
|
|
|
280
296
|
"--untracked-files=normal"
|
|
281
297
|
], { signal })).stdout.split("\n").filter((line) => line.trim() !== "").length;
|
|
282
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Resolve the whereabouts of one checkout's Git administration directory.
|
|
301
|
+
*
|
|
302
|
+
* A linked worktree's `.git` is a file pointing at
|
|
303
|
+
* `<common>/worktrees/<name>`; this is the directory Git owns for that one
|
|
304
|
+
* checkout, which is where the switch records its claim on the copy.
|
|
305
|
+
* @param ctx - Host context carrying the subprocess provider.
|
|
306
|
+
* @param checkout - absolute directory of the checkout to ask.
|
|
307
|
+
* @param signal - caller cancellation.
|
|
308
|
+
* @returns the absolute admin directory Git reports.
|
|
309
|
+
* @throws {GitFailure} when the directory is not inside a Git working tree.
|
|
310
|
+
*/
|
|
311
|
+
async function absoluteGitDir(ctx, checkout, signal) {
|
|
312
|
+
const result = await runGit(ctx, checkout, ["rev-parse", "--absolute-git-dir"], { signal });
|
|
313
|
+
const path = result.stdout.replace(/\r?\n$/, "");
|
|
314
|
+
if (path === "") throw new GitFailure(["rev-parse", "--absolute-git-dir"], result);
|
|
315
|
+
return path;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Read the branch one checkout has checked out.
|
|
319
|
+
* @param ctx - Host context carrying the subprocess provider.
|
|
320
|
+
* @param checkout - absolute directory of the checkout to ask.
|
|
321
|
+
* @param signal - caller cancellation.
|
|
322
|
+
* @returns the branch name, or null when the checkout is on a detached HEAD.
|
|
323
|
+
*/
|
|
324
|
+
async function currentBranch(ctx, checkout, signal) {
|
|
325
|
+
const result = await runGit(ctx, checkout, [
|
|
326
|
+
"symbolic-ref",
|
|
327
|
+
"--short",
|
|
328
|
+
"--quiet",
|
|
329
|
+
"HEAD"
|
|
330
|
+
], {
|
|
331
|
+
signal,
|
|
332
|
+
allowedExitCodes: [0, 1]
|
|
333
|
+
});
|
|
334
|
+
const name = result.stdout.replace(/\r?\n$/, "");
|
|
335
|
+
return result.exitCode === 0 && name !== "" ? name : null;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Resolve one revision to the commit id it names.
|
|
339
|
+
* @param ctx - Host context carrying the subprocess provider.
|
|
340
|
+
* @param directory - checkout the command runs in.
|
|
341
|
+
* @param revision - revision to resolve; `HEAD` or a full id are the usual values.
|
|
342
|
+
* @param signal - caller cancellation.
|
|
343
|
+
* @returns the commit id, or null when the revision does not resolve.
|
|
344
|
+
*/
|
|
345
|
+
async function revParse(ctx, directory, revision, signal) {
|
|
346
|
+
const result = await runGit(ctx, directory, [
|
|
347
|
+
"rev-parse",
|
|
348
|
+
"--verify",
|
|
349
|
+
"--quiet",
|
|
350
|
+
revision
|
|
351
|
+
], {
|
|
352
|
+
signal,
|
|
353
|
+
allowedExitCodes: [
|
|
354
|
+
0,
|
|
355
|
+
1,
|
|
356
|
+
128
|
|
357
|
+
]
|
|
358
|
+
});
|
|
359
|
+
const id = result.stdout.replace(/\r?\n$/, "");
|
|
360
|
+
return result.exitCode === 0 && id !== "" ? id : null;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Count the commits in one revision range.
|
|
364
|
+
* @param ctx - Host context carrying the subprocess provider.
|
|
365
|
+
* @param directory - checkout the command runs in.
|
|
366
|
+
* @param range - the range to count, e.g. `HEAD..refs/heads/<branch>`.
|
|
367
|
+
* @param signal - caller cancellation.
|
|
368
|
+
* @returns the number of commits in the range.
|
|
369
|
+
* @throws {GitFailure} when the range does not resolve.
|
|
370
|
+
*/
|
|
371
|
+
async function revListCount(ctx, directory, range, signal) {
|
|
372
|
+
const result = await runGit(ctx, directory, [
|
|
373
|
+
"rev-list",
|
|
374
|
+
"--count",
|
|
375
|
+
range
|
|
376
|
+
], { signal });
|
|
377
|
+
const count = Number.parseInt(result.stdout.trim(), 10);
|
|
378
|
+
return Number.isFinite(count) ? count : 0;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Parse `git for-each-ref` output produced with the NUL-separated format below.
|
|
382
|
+
* @param stdout - the command's stdout.
|
|
383
|
+
* @returns one entry per local branch, in Git's order.
|
|
384
|
+
*/
|
|
385
|
+
function parseBranchRefs(stdout) {
|
|
386
|
+
const refs = [];
|
|
387
|
+
for (const line of stdout.split("\n")) {
|
|
388
|
+
if (line === "") continue;
|
|
389
|
+
const [name, commit, seconds, subject] = line.split("\0");
|
|
390
|
+
if (name === void 0 || commit === void 0 || seconds === void 0 || subject === void 0) continue;
|
|
391
|
+
const committedAt = Number.parseInt(seconds, 10) * 1e3;
|
|
392
|
+
refs.push({
|
|
393
|
+
name,
|
|
394
|
+
commit,
|
|
395
|
+
committedAt: Number.isFinite(committedAt) ? committedAt : 0,
|
|
396
|
+
subject
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return refs;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* List the local branches of one repository.
|
|
403
|
+
* @param ctx - Host context carrying the subprocess provider.
|
|
404
|
+
* @param root - repository root the command runs in.
|
|
405
|
+
* @param signal - caller cancellation.
|
|
406
|
+
* @returns every local branch with its tip commit.
|
|
407
|
+
*/
|
|
408
|
+
async function listBranchRefs(ctx, root, signal) {
|
|
409
|
+
return parseBranchRefs((await runGit(ctx, root, [
|
|
410
|
+
"for-each-ref",
|
|
411
|
+
"--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)",
|
|
412
|
+
"refs/heads"
|
|
413
|
+
], { signal })).stdout);
|
|
414
|
+
}
|
|
283
415
|
// src/host/base.ts
|
|
284
416
|
/** Whole-command deadline for the plugin's one network call. */
|
|
285
417
|
const FETCH_TIMEOUT_MS = 5e3;
|
|
@@ -488,6 +620,19 @@ function worktreeName(random = Math.random) {
|
|
|
488
620
|
}
|
|
489
621
|
/** A pool entry name is exactly one path segment, as `git worktree` names it. */
|
|
490
622
|
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
623
|
+
/** The shape this plugin generates: two lowercase words and four hex digits. */
|
|
624
|
+
const GENERATED_PATTERN = /^[a-z]+-[a-z]+-[0-9a-f]{4}$/;
|
|
625
|
+
/**
|
|
626
|
+
* Whether a branch name looks like one this plugin generated.
|
|
627
|
+
*
|
|
628
|
+
* Leftover-branch cleanup only offers names of this shape: an ordinary feature
|
|
629
|
+
* branch is never a candidate, even when no checkout holds it.
|
|
630
|
+
* @param value - candidate branch name.
|
|
631
|
+
* @returns true for the generated-name format.
|
|
632
|
+
*/
|
|
633
|
+
function isGeneratedName(value) {
|
|
634
|
+
return GENERATED_PATTERN.test(value);
|
|
635
|
+
}
|
|
491
636
|
/**
|
|
492
637
|
* Validate a worktree name received from the browser before it reaches Git.
|
|
493
638
|
* @param value - candidate name.
|
|
@@ -496,9 +641,66 @@ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
496
641
|
function isWorktreeName(value) {
|
|
497
642
|
return NAME_PATTERN.test(value) && !value.endsWith(".lock");
|
|
498
643
|
}
|
|
644
|
+
// src/host/ownership.ts
|
|
645
|
+
/**
|
|
646
|
+
* The switch's claim on a copy.
|
|
647
|
+
*
|
|
648
|
+
* "This copy was created by the switch for this session" used to live only in
|
|
649
|
+
* host memory, so a host restart turned every copy into a stranger and the
|
|
650
|
+
* leave path could never prove it was safe to undo. The claim is now stored in
|
|
651
|
+
* the checkout's own Git administration directory: it travels with the copy,
|
|
652
|
+
* vanishes when the copy is deleted, never appears in the working tree, and
|
|
653
|
+
* survives a restart. A missing or unparseable marker means "not owned" — the
|
|
654
|
+
* plugin does not guess, because guessing wrong deletes work.
|
|
655
|
+
* @module dsh-worktree-switch/host/ownership
|
|
656
|
+
*/
|
|
657
|
+
/** File name inside the checkout's admin directory. */
|
|
658
|
+
const MARKER_FILE = "dsh-worktree-switch.json";
|
|
659
|
+
/**
|
|
660
|
+
* Read one checkout's ownership marker.
|
|
661
|
+
*
|
|
662
|
+
* Every failure — no admin directory, no marker, invalid JSON, a shape this
|
|
663
|
+
* version does not understand — reads as "no claim". The leave path treats
|
|
664
|
+
* that as a reason to keep the copy, never as a reason to take it.
|
|
665
|
+
* @param ctx - Host context carrying the subprocess provider.
|
|
666
|
+
* @param checkout - absolute directory of the copy.
|
|
667
|
+
* @param signal - caller cancellation.
|
|
668
|
+
* @returns the recorded claim, or null when there is none.
|
|
669
|
+
*/
|
|
670
|
+
async function readOwnership(ctx, checkout, signal) {
|
|
671
|
+
try {
|
|
672
|
+
const directory = await absoluteGitDir(ctx, checkout, signal);
|
|
673
|
+
const raw = await readFile(join(directory, MARKER_FILE), "utf8");
|
|
674
|
+
const parsed = JSON.parse(raw);
|
|
675
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
676
|
+
const { sessionId, createdAt, baseCommit } = parsed;
|
|
677
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
678
|
+
if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return null;
|
|
679
|
+
if (typeof baseCommit !== "string" || baseCommit === "") return null;
|
|
680
|
+
return {
|
|
681
|
+
sessionId,
|
|
682
|
+
createdAt,
|
|
683
|
+
baseCommit
|
|
684
|
+
};
|
|
685
|
+
} catch {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Record the switch's claim on a freshly created copy.
|
|
691
|
+
* @param ctx - Host context carrying the subprocess provider.
|
|
692
|
+
* @param checkout - absolute directory of the copy.
|
|
693
|
+
* @param ownership - the claim to write.
|
|
694
|
+
* @param signal - caller cancellation.
|
|
695
|
+
* @throws when the admin directory cannot be resolved or the marker cannot be written.
|
|
696
|
+
*/
|
|
697
|
+
async function writeOwnership(ctx, checkout, ownership, signal) {
|
|
698
|
+
const directory = await absoluteGitDir(ctx, checkout, signal);
|
|
699
|
+
await writeFile(join(directory, MARKER_FILE), `${JSON.stringify(ownership)}\n`, "utf8");
|
|
700
|
+
}
|
|
499
701
|
// src/host/handlers.ts
|
|
500
702
|
/**
|
|
501
|
-
* The plugin's host Remote face: the
|
|
703
|
+
* The plugin's host Remote face: the verbs the browser half can call,
|
|
502
704
|
* expressed against a live session and the checkouts on disk.
|
|
503
705
|
*
|
|
504
706
|
* This module is the seam the host tests drive directly. Everything below it —
|
|
@@ -525,6 +727,16 @@ var WorktreeToggleError = class extends Error {
|
|
|
525
727
|
this.name = "WorktreeToggleError";
|
|
526
728
|
}
|
|
527
729
|
};
|
|
730
|
+
/**
|
|
731
|
+
* The failure one refused branch delete reports.
|
|
732
|
+
* @param branch - branch the verb tried to delete.
|
|
733
|
+
* @param args - the Git command that refused, for the message.
|
|
734
|
+
* @param result - its captured outcome.
|
|
735
|
+
* @returns the error the browser half switches on.
|
|
736
|
+
*/
|
|
737
|
+
function branchDeleteFailure(branch, args, result) {
|
|
738
|
+
return new WorktreeToggleError("branch-delete-failed", new GitFailure(args, result).message, { branch });
|
|
739
|
+
}
|
|
528
740
|
/** Normalize a browser-supplied path before any filesystem work. */
|
|
529
741
|
function absolutePath(value) {
|
|
530
742
|
const trimmed = value.trim();
|
|
@@ -539,11 +751,13 @@ function absolutePath(value) {
|
|
|
539
751
|
* from any configured pool path. The pool belongs to the harness worktree
|
|
540
752
|
* service, so this plugin never needs a second copy of that setting: moving the
|
|
541
753
|
* pool in the service's row config keeps every verb working.
|
|
754
|
+
* @param ctx - host context carrying `subprocess`.
|
|
542
755
|
* @param current - the session's committed working directory.
|
|
543
756
|
* @param repositoryRoot - canonical root of the project's repository.
|
|
757
|
+
* @param signal - caller cancellation.
|
|
544
758
|
* @returns the copy's identity, or null when the directory is not one.
|
|
545
759
|
*/
|
|
546
|
-
async function worktreeAt(current, repositoryRoot) {
|
|
760
|
+
async function worktreeAt(ctx, current, repositoryRoot, signal) {
|
|
547
761
|
const path = await canonicalPath(current);
|
|
548
762
|
if (path === repositoryRoot) return null;
|
|
549
763
|
const inside = relative(repositoryRoot, path);
|
|
@@ -558,7 +772,7 @@ async function worktreeAt(current, repositoryRoot) {
|
|
|
558
772
|
return {
|
|
559
773
|
name,
|
|
560
774
|
path,
|
|
561
|
-
branch:
|
|
775
|
+
branch: await currentBranch(ctx, path, signal)
|
|
562
776
|
};
|
|
563
777
|
}
|
|
564
778
|
/**
|
|
@@ -598,7 +812,7 @@ async function detectSessionScope(ctx, agent, signal) {
|
|
|
598
812
|
signal?.throwIfAborted();
|
|
599
813
|
let probe = current;
|
|
600
814
|
for (;;) {
|
|
601
|
-
const copy = await worktreeAt(probe, root);
|
|
815
|
+
const copy = await worktreeAt(ctx, probe, root, signal);
|
|
602
816
|
if (copy !== null) return {
|
|
603
817
|
worktree: copy,
|
|
604
818
|
repositoryRoot: root
|
|
@@ -609,21 +823,114 @@ async function detectSessionScope(ctx, agent, signal) {
|
|
|
609
823
|
}
|
|
610
824
|
}
|
|
611
825
|
/**
|
|
826
|
+
* The copy rows of one repository, newest first.
|
|
827
|
+
*
|
|
828
|
+
* A row whose checkout directory is gone is still a copy: Git keeps the
|
|
829
|
+
* registration until it is pruned, and the panel offers the same discard for
|
|
830
|
+
* it, marked as missing. A detached copy keeps `branch: null` so no verb can
|
|
831
|
+
* ever mistake the directory name for a branch to delete.
|
|
832
|
+
* @param ctx - host context carrying `subprocess`.
|
|
833
|
+
* @param root - canonical repository root.
|
|
834
|
+
* @param records - `git worktree list` records for the repository.
|
|
835
|
+
* @param signal - caller cancellation.
|
|
836
|
+
* @returns one row per copy under the project.
|
|
837
|
+
*/
|
|
838
|
+
async function copyItems(ctx, root, records, signal) {
|
|
839
|
+
const items = [];
|
|
840
|
+
for (const record of records) {
|
|
841
|
+
signal?.throwIfAborted();
|
|
842
|
+
const path = await canonicalPath(record.path);
|
|
843
|
+
if (path === root) continue;
|
|
844
|
+
const name = basename(path);
|
|
845
|
+
if (!isWorktreeName(name)) continue;
|
|
846
|
+
let createdAt = 0;
|
|
847
|
+
let prunable = true;
|
|
848
|
+
try {
|
|
849
|
+
const info = await stat(path);
|
|
850
|
+
createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
|
|
851
|
+
prunable = false;
|
|
852
|
+
} catch {}
|
|
853
|
+
const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
|
|
854
|
+
items.push({
|
|
855
|
+
name,
|
|
856
|
+
path,
|
|
857
|
+
branch: record.branch ?? null,
|
|
858
|
+
createdAt,
|
|
859
|
+
changedFiles,
|
|
860
|
+
prunable
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
|
|
864
|
+
return items;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Generated-name branches that no checkout holds.
|
|
868
|
+
*
|
|
869
|
+
* Deliberately narrow: only the names this plugin generates qualify, so an
|
|
870
|
+
* ordinary feature branch never appears in a list whose only action is delete.
|
|
871
|
+
* The commit count is what the source checkout's HEAD does not contain — a
|
|
872
|
+
* branch whose work is fully merged reads as 0, which is the one safe signal.
|
|
873
|
+
* @param ctx - host context carrying `subprocess`.
|
|
874
|
+
* @param root - canonical repository root.
|
|
875
|
+
* @param records - `git worktree list` records for the repository.
|
|
876
|
+
* @param signal - caller cancellation.
|
|
877
|
+
* @returns the leftover branches, newest commit first.
|
|
878
|
+
*/
|
|
879
|
+
async function orphanBranches(ctx, root, records, signal) {
|
|
880
|
+
const checkedOut = /* @__PURE__ */ new Set();
|
|
881
|
+
for (const record of records) if (record.branch !== void 0) checkedOut.add(record.branch);
|
|
882
|
+
const orphans = [];
|
|
883
|
+
for (const ref of await listBranchRefs(ctx, root, signal)) {
|
|
884
|
+
signal?.throwIfAborted();
|
|
885
|
+
if (!isGeneratedName(ref.name) || checkedOut.has(ref.name)) continue;
|
|
886
|
+
let commits = 0;
|
|
887
|
+
try {
|
|
888
|
+
commits = await revListCount(ctx, root, `HEAD..${ref.commit}`, signal);
|
|
889
|
+
} catch {}
|
|
890
|
+
orphans.push({
|
|
891
|
+
branch: ref.name,
|
|
892
|
+
commits,
|
|
893
|
+
subject: ref.subject,
|
|
894
|
+
committedAt: ref.committedAt
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
orphans.sort((left, right) => right.committedAt - left.committedAt || left.branch.localeCompare(right.branch));
|
|
898
|
+
return orphans;
|
|
899
|
+
}
|
|
900
|
+
/** The wire identity of one listed copy; a detached copy keeps its null branch. */
|
|
901
|
+
function copyRef(item) {
|
|
902
|
+
return {
|
|
903
|
+
name: item.name,
|
|
904
|
+
path: item.path,
|
|
905
|
+
branch: item.branch
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
612
909
|
* Build the host face.
|
|
613
910
|
* @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.
|
|
614
911
|
* @param options - resolved row config.
|
|
615
|
-
* @returns the
|
|
912
|
+
* @returns the verbs above.
|
|
616
913
|
*/
|
|
617
914
|
function createWorktreeToggle(ctx, options) {
|
|
618
915
|
/** One in-flight preparation per session, so a double mount cannot create twice. */
|
|
619
916
|
const pending = /* @__PURE__ */ new Map();
|
|
620
917
|
/**
|
|
621
|
-
* Copies this host instance
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
918
|
+
* Copies this host instance kept when the switch was turned off because they
|
|
919
|
+
* had content (or because deleting them failed), by repository root. A copy
|
|
920
|
+
* left only because another session owns it is not one of these: it is still
|
|
921
|
+
* listed as a copy, but it is not this host's leftover to clean up. A host
|
|
922
|
+
* restart forgets the set; the panel's count then honestly drops back to
|
|
923
|
+
* leftovers only.
|
|
625
924
|
*/
|
|
626
|
-
const
|
|
925
|
+
const keptCopies = /* @__PURE__ */ new Map();
|
|
926
|
+
const keep = (root, name) => {
|
|
927
|
+
const names = keptCopies.get(root) ?? /* @__PURE__ */ new Set();
|
|
928
|
+
names.add(name);
|
|
929
|
+
keptCopies.set(root, names);
|
|
930
|
+
};
|
|
931
|
+
const forget = (root, name) => {
|
|
932
|
+
keptCopies.get(root)?.delete(name);
|
|
933
|
+
};
|
|
627
934
|
const sessionOf = async (sessionId, signal) => {
|
|
628
935
|
signal?.throwIfAborted();
|
|
629
936
|
if (sessionId === "") throw new WorktreeToggleError("bad-request", "a session id is required");
|
|
@@ -654,7 +961,7 @@ function createWorktreeToggle(ctx, options) {
|
|
|
654
961
|
const current = ctx.workingDirectory.get(agent.session);
|
|
655
962
|
signal?.throwIfAborted();
|
|
656
963
|
const sourceDirty = await changedFileCount(ctx, root, signal);
|
|
657
|
-
const copy = await worktreeAt(current, root);
|
|
964
|
+
const copy = await worktreeAt(ctx, current, root, signal);
|
|
658
965
|
options.onScope?.(request.sessionId, copy === null ? null : {
|
|
659
966
|
worktree: copy,
|
|
660
967
|
repositoryRoot: root
|
|
@@ -703,7 +1010,14 @@ function createWorktreeToggle(ctx, options) {
|
|
|
703
1010
|
path: created.path,
|
|
704
1011
|
branch: created.branch
|
|
705
1012
|
};
|
|
706
|
-
|
|
1013
|
+
const baseCommit = await revParse(ctx, created.path, "HEAD");
|
|
1014
|
+
if (baseCommit !== null) try {
|
|
1015
|
+
await writeOwnership(ctx, created.path, {
|
|
1016
|
+
sessionId: request.sessionId,
|
|
1017
|
+
createdAt: Date.now(),
|
|
1018
|
+
baseCommit
|
|
1019
|
+
});
|
|
1020
|
+
} catch {}
|
|
707
1021
|
options.onScope?.(request.sessionId, {
|
|
708
1022
|
worktree,
|
|
709
1023
|
repositoryRoot: created.repositoryRoot
|
|
@@ -734,6 +1048,22 @@ function createWorktreeToggle(ctx, options) {
|
|
|
734
1048
|
if (pending.get(request.sessionId) === operation) pending.delete(request.sessionId);
|
|
735
1049
|
}
|
|
736
1050
|
};
|
|
1051
|
+
const scope = async (request, signal) => {
|
|
1052
|
+
const agent = await sessionOf(request.sessionId, signal);
|
|
1053
|
+
const current = ctx.workingDirectory.get(agent.session);
|
|
1054
|
+
const known = options.readScope?.(request.sessionId) ?? null;
|
|
1055
|
+
const found = known !== null && contains(known.worktree.path, current) ? known : await detectSessionScope(ctx, agent, signal);
|
|
1056
|
+
if (found === null) return { inWorktree: false };
|
|
1057
|
+
signal?.throwIfAborted();
|
|
1058
|
+
const changedFiles = await changedFileCount(ctx, found.worktree.path, signal);
|
|
1059
|
+
options.onScope?.(request.sessionId, found);
|
|
1060
|
+
return {
|
|
1061
|
+
inWorktree: true,
|
|
1062
|
+
worktree: found.worktree,
|
|
1063
|
+
repositoryRoot: found.repositoryRoot,
|
|
1064
|
+
changedFiles
|
|
1065
|
+
};
|
|
1066
|
+
};
|
|
737
1067
|
const list = async (request, signal) => {
|
|
738
1068
|
const directory = absolutePath(request.path);
|
|
739
1069
|
let root;
|
|
@@ -747,50 +1077,84 @@ function createWorktreeToggle(ctx, options) {
|
|
|
747
1077
|
throw error;
|
|
748
1078
|
}
|
|
749
1079
|
const records = await listWorktreeRecords(ctx, root, signal);
|
|
750
|
-
const items =
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
if (path === root) continue;
|
|
755
|
-
const name = basename(path);
|
|
756
|
-
if (!isWorktreeName(name)) continue;
|
|
757
|
-
const branch = record.branch ?? name;
|
|
758
|
-
let createdAt = 0;
|
|
759
|
-
let prunable = true;
|
|
760
|
-
try {
|
|
761
|
-
const info = await stat(path);
|
|
762
|
-
createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
|
|
763
|
-
prunable = false;
|
|
764
|
-
} catch {}
|
|
765
|
-
const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
|
|
766
|
-
items.push({
|
|
767
|
-
name,
|
|
768
|
-
path,
|
|
769
|
-
branch,
|
|
770
|
-
createdAt,
|
|
771
|
-
changedFiles,
|
|
772
|
-
prunable
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
|
|
1080
|
+
const items = await copyItems(ctx, root, records, signal);
|
|
1081
|
+
const orphans = await orphanBranches(ctx, root, records, signal);
|
|
1082
|
+
const kept = keptCopies.get(root);
|
|
1083
|
+
const keptCopiesStillHere = kept === void 0 ? 0 : items.filter((item) => kept.has(item.name)).length;
|
|
776
1084
|
return {
|
|
777
1085
|
supported: true,
|
|
778
1086
|
repositoryRoot: root,
|
|
779
|
-
items
|
|
1087
|
+
items,
|
|
1088
|
+
orphans,
|
|
1089
|
+
keptCopies: keptCopiesStillHere
|
|
780
1090
|
};
|
|
781
1091
|
};
|
|
782
1092
|
/**
|
|
783
|
-
* Delete one
|
|
784
|
-
*
|
|
1093
|
+
* Delete one branch by name.
|
|
1094
|
+
* @param root - canonical repository root.
|
|
1095
|
+
* @param branch - branch to delete; already validated by the caller.
|
|
1096
|
+
* @param signal - caller cancellation.
|
|
1097
|
+
* @throws {WorktreeToggleError} when Git refused.
|
|
1098
|
+
*/
|
|
1099
|
+
const deleteBranch = async (root, branch, signal) => {
|
|
1100
|
+
const deleted = await runGit(ctx, root, [
|
|
1101
|
+
"branch",
|
|
1102
|
+
"-D",
|
|
1103
|
+
"--",
|
|
1104
|
+
branch
|
|
1105
|
+
], {
|
|
1106
|
+
signal,
|
|
1107
|
+
allowedExitCodes: [0, 1]
|
|
1108
|
+
});
|
|
1109
|
+
const gone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
|
|
1110
|
+
if (deleted.exitCode !== 0 && !gone) throw branchDeleteFailure(branch, [
|
|
1111
|
+
"branch",
|
|
1112
|
+
"-D",
|
|
1113
|
+
branch
|
|
1114
|
+
], deleted);
|
|
1115
|
+
};
|
|
1116
|
+
/**
|
|
1117
|
+
* Delete a branch a prunable copy still holds.
|
|
1118
|
+
*
|
|
1119
|
+
* `git branch -D` refuses while any worktree registration names the branch,
|
|
1120
|
+
* even a registration whose directory is gone, so the branch is removed
|
|
1121
|
+
* through the ref itself. The registration still pins Git's bookkeeping, but
|
|
1122
|
+
* nothing can have the branch checked out any more: the directory is missing.
|
|
1123
|
+
* @param root - canonical repository root.
|
|
1124
|
+
* @param branch - branch to delete; already validated by the caller.
|
|
1125
|
+
* @param signal - caller cancellation.
|
|
1126
|
+
* @throws {WorktreeToggleError} when Git refused (for example a locked ref).
|
|
1127
|
+
*/
|
|
1128
|
+
const deleteBranchByRef = async (root, branch, signal) => {
|
|
1129
|
+
const deleted = await runGit(ctx, root, [
|
|
1130
|
+
"update-ref",
|
|
1131
|
+
"-d",
|
|
1132
|
+
`refs/heads/${branch}`
|
|
1133
|
+
], {
|
|
1134
|
+
signal,
|
|
1135
|
+
allowedExitCodes: [0, 1]
|
|
1136
|
+
});
|
|
1137
|
+
if (deleted.exitCode !== 0) throw branchDeleteFailure(branch, [
|
|
1138
|
+
"update-ref",
|
|
1139
|
+
"-d",
|
|
1140
|
+
branch
|
|
1141
|
+
], deleted);
|
|
1142
|
+
};
|
|
1143
|
+
/**
|
|
1144
|
+
* Delete one clean copy and, unless it is detached, its branch.
|
|
1145
|
+
*
|
|
1146
|
+
* Both the panel's discard and the switch's own undo go through here, so the
|
|
1147
|
+
* dirty guard and the delete order have one home. A prunable copy — its
|
|
1148
|
+
* directory was deleted by hand — deletes the branch first and prunes the
|
|
1149
|
+
* registration only once that succeeded, so a refused delete cannot leave the
|
|
1150
|
+
* registration already gone.
|
|
785
1151
|
* @param root - canonical repository root.
|
|
786
1152
|
* @param name - copy name; already validated by the caller.
|
|
787
1153
|
* @param signal - caller cancellation.
|
|
788
1154
|
* @returns the removed copy's identity.
|
|
789
1155
|
*/
|
|
790
1156
|
const removeCopy = async (root, name, signal) => {
|
|
791
|
-
const
|
|
792
|
-
if (!listed.supported) throw new WorktreeToggleError("not-a-git-repository", "the project is not inside a Git working tree");
|
|
793
|
-
const item = listed.items.find((entry) => entry.name === name);
|
|
1157
|
+
const item = (await copyItems(ctx, root, await listWorktreeRecords(ctx, root, signal), signal)).find((entry) => entry.name === name);
|
|
794
1158
|
if (item === void 0) throw new WorktreeToggleError("worktree-not-found", `no worktree named ${name} in ${root}`, {
|
|
795
1159
|
name,
|
|
796
1160
|
repositoryRoot: root
|
|
@@ -799,8 +1163,10 @@ function createWorktreeToggle(ctx, options) {
|
|
|
799
1163
|
name: item.name,
|
|
800
1164
|
changedFiles: item.changedFiles
|
|
801
1165
|
});
|
|
802
|
-
if (item.prunable)
|
|
803
|
-
|
|
1166
|
+
if (item.prunable) {
|
|
1167
|
+
if (item.branch !== null) await deleteBranchByRef(root, item.branch, signal);
|
|
1168
|
+
await runGit(ctx, root, ["worktree", "prune"], { signal });
|
|
1169
|
+
} else {
|
|
804
1170
|
const removed = await runGit(ctx, root, [
|
|
805
1171
|
"worktree",
|
|
806
1172
|
"remove",
|
|
@@ -817,26 +1183,9 @@ function createWorktreeToggle(ctx, options) {
|
|
|
817
1183
|
name: item.name,
|
|
818
1184
|
path: item.path
|
|
819
1185
|
});
|
|
1186
|
+
if (item.branch !== null) await deleteBranch(root, item.branch, signal);
|
|
820
1187
|
}
|
|
821
|
-
|
|
822
|
-
"branch",
|
|
823
|
-
"-D",
|
|
824
|
-
"--",
|
|
825
|
-
item.branch
|
|
826
|
-
], {
|
|
827
|
-
signal,
|
|
828
|
-
allowedExitCodes: [0, 1]
|
|
829
|
-
});
|
|
830
|
-
const branchGone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
|
|
831
|
-
if (deleted.exitCode !== 0 && !branchGone) throw new WorktreeToggleError("worktree-remove-failed", new GitFailure([
|
|
832
|
-
"branch",
|
|
833
|
-
"-D",
|
|
834
|
-
item.branch
|
|
835
|
-
], deleted).message, {
|
|
836
|
-
name: item.name,
|
|
837
|
-
branch: item.branch
|
|
838
|
-
});
|
|
839
|
-
for (const [sessionId, copy] of createdBySession) if (copy.path === item.path) createdBySession.delete(sessionId);
|
|
1188
|
+
forget(root, item.name);
|
|
840
1189
|
return {
|
|
841
1190
|
removed: true,
|
|
842
1191
|
name: item.name,
|
|
@@ -850,6 +1199,17 @@ function createWorktreeToggle(ctx, options) {
|
|
|
850
1199
|
const root = await repositoryOf(ctx, directory, signal);
|
|
851
1200
|
return await removeCopy(root, request.name, signal);
|
|
852
1201
|
};
|
|
1202
|
+
const discardBranch = async (request, signal) => {
|
|
1203
|
+
const directory = absolutePath(request.path);
|
|
1204
|
+
if (!isGeneratedName(request.branch)) throw new WorktreeToggleError("branch-name-invalid", `not a branch this plugin generated: ${request.branch}`);
|
|
1205
|
+
const root = await repositoryOf(ctx, directory, signal);
|
|
1206
|
+
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 });
|
|
1207
|
+
if (new Set((await listBranchRefs(ctx, root, signal)).map((ref) => ref.name)).has(request.branch)) await deleteBranch(root, request.branch, signal);
|
|
1208
|
+
return {
|
|
1209
|
+
removed: true,
|
|
1210
|
+
branch: request.branch
|
|
1211
|
+
};
|
|
1212
|
+
};
|
|
853
1213
|
const release = async (request, signal) => {
|
|
854
1214
|
const running = pending.get(request.sessionId);
|
|
855
1215
|
if (running !== void 0) try {
|
|
@@ -866,27 +1226,20 @@ function createWorktreeToggle(ctx, options) {
|
|
|
866
1226
|
if (error instanceof GitFailure) throw new WorktreeToggleError("not-a-git-repository", "the session project is not inside a Git working tree");
|
|
867
1227
|
throw error;
|
|
868
1228
|
}
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
directory: current,
|
|
872
|
-
removed: null,
|
|
873
|
-
kept: null
|
|
874
|
-
};
|
|
1229
|
+
const records = await listWorktreeRecords(ctx, root, signal);
|
|
1230
|
+
const items = await copyItems(ctx, root, records, signal);
|
|
875
1231
|
const path = await canonicalPath(current);
|
|
876
|
-
const item =
|
|
1232
|
+
const item = items.find((entry) => contains(entry.path, path));
|
|
877
1233
|
if (item === void 0) {
|
|
878
1234
|
options.onScope?.(request.sessionId, null);
|
|
879
1235
|
return {
|
|
880
1236
|
directory: current,
|
|
881
1237
|
removed: null,
|
|
882
|
-
kept: null
|
|
1238
|
+
kept: null,
|
|
1239
|
+
keptReason: null
|
|
883
1240
|
};
|
|
884
1241
|
}
|
|
885
|
-
const copy =
|
|
886
|
-
name: item.name,
|
|
887
|
-
path: item.path,
|
|
888
|
-
branch: item.branch
|
|
889
|
-
};
|
|
1242
|
+
const copy = copyRef(item);
|
|
890
1243
|
signal?.throwIfAborted();
|
|
891
1244
|
let directory;
|
|
892
1245
|
try {
|
|
@@ -898,38 +1251,58 @@ function createWorktreeToggle(ctx, options) {
|
|
|
898
1251
|
});
|
|
899
1252
|
}
|
|
900
1253
|
options.onScope?.(request.sessionId, null);
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
removed: null,
|
|
905
|
-
kept: copy
|
|
906
|
-
};
|
|
907
|
-
try {
|
|
908
|
-
const removed = await removeCopy(root, copy.name, signal);
|
|
909
|
-
return {
|
|
1254
|
+
const keepCopy = (reason, error) => {
|
|
1255
|
+
if (isKeptLeftover(reason)) keep(root, item.name);
|
|
1256
|
+
return error === void 0 ? {
|
|
910
1257
|
directory,
|
|
911
|
-
removed:
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
},
|
|
916
|
-
kept: null
|
|
917
|
-
};
|
|
918
|
-
} catch (error) {
|
|
919
|
-
if (signal?.aborted === true) throw error;
|
|
920
|
-
return {
|
|
1258
|
+
removed: null,
|
|
1259
|
+
kept: copy,
|
|
1260
|
+
keptReason: reason
|
|
1261
|
+
} : {
|
|
921
1262
|
directory,
|
|
922
1263
|
removed: null,
|
|
923
|
-
kept: copy
|
|
1264
|
+
kept: copy,
|
|
1265
|
+
keptReason: reason,
|
|
1266
|
+
error
|
|
924
1267
|
};
|
|
1268
|
+
};
|
|
1269
|
+
const ownership = await readOwnership(ctx, item.path, signal);
|
|
1270
|
+
if (ownership === null || ownership.sessionId !== request.sessionId) return keepCopy("not-owned");
|
|
1271
|
+
if (item.changedFiles > 0) return keepCopy("dirty", `${item.changedFiles} uncommitted change(s)`);
|
|
1272
|
+
const tip = await revParse(ctx, item.path, "HEAD", signal);
|
|
1273
|
+
if (tip === null || tip !== ownership.baseCommit) {
|
|
1274
|
+
let commits = 0;
|
|
1275
|
+
try {
|
|
1276
|
+
commits = await revListCount(ctx, item.path, `${ownership.baseCommit}..HEAD`, signal);
|
|
1277
|
+
} catch {}
|
|
1278
|
+
return keepCopy("dirty", `${commits} new commit(s)`);
|
|
1279
|
+
}
|
|
1280
|
+
let removed;
|
|
1281
|
+
try {
|
|
1282
|
+
removed = await removeCopy(root, item.name, signal);
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
keep(root, item.name);
|
|
1285
|
+
throw error;
|
|
925
1286
|
}
|
|
1287
|
+
return {
|
|
1288
|
+
directory,
|
|
1289
|
+
removed: {
|
|
1290
|
+
name: removed.name,
|
|
1291
|
+
path: removed.path,
|
|
1292
|
+
branch: removed.branch
|
|
1293
|
+
},
|
|
1294
|
+
kept: null,
|
|
1295
|
+
keptReason: null
|
|
1296
|
+
};
|
|
926
1297
|
};
|
|
927
1298
|
return {
|
|
928
1299
|
status,
|
|
929
1300
|
prepare,
|
|
930
1301
|
release,
|
|
1302
|
+
scope,
|
|
931
1303
|
list,
|
|
932
|
-
discard
|
|
1304
|
+
discard,
|
|
1305
|
+
discardBranch
|
|
933
1306
|
};
|
|
934
1307
|
}
|
|
935
1308
|
// src/host/routes.ts
|
|
@@ -942,8 +1315,10 @@ const ENDPOINTS = [
|
|
|
942
1315
|
"status",
|
|
943
1316
|
"prepare",
|
|
944
1317
|
"release",
|
|
1318
|
+
"scope",
|
|
945
1319
|
"list",
|
|
946
|
-
"discard"
|
|
1320
|
+
"discard",
|
|
1321
|
+
"discardBranch"
|
|
947
1322
|
];
|
|
948
1323
|
/** A payload object with the named string field, or the failure to send back. */
|
|
949
1324
|
function requiredString(payload, field) {
|
|
@@ -1013,6 +1388,11 @@ async function dispatch(toggle, endpoint, payload, signal) {
|
|
|
1013
1388
|
if ("failure" in sessionId) throw sessionId.failure;
|
|
1014
1389
|
return ok(await toggle.release({ sessionId: sessionId.value }, signal));
|
|
1015
1390
|
}
|
|
1391
|
+
if (endpoint === "scope") {
|
|
1392
|
+
const sessionId = requiredString(payload, "sessionId");
|
|
1393
|
+
if ("failure" in sessionId) throw sessionId.failure;
|
|
1394
|
+
return ok(await toggle.scope({ sessionId: sessionId.value }, signal));
|
|
1395
|
+
}
|
|
1016
1396
|
if (endpoint === "list") {
|
|
1017
1397
|
const path = requiredString(payload, "path");
|
|
1018
1398
|
if ("failure" in path) throw path.failure;
|
|
@@ -1020,6 +1400,14 @@ async function dispatch(toggle, endpoint, payload, signal) {
|
|
|
1020
1400
|
}
|
|
1021
1401
|
const path = requiredString(payload, "path");
|
|
1022
1402
|
if ("failure" in path) throw path.failure;
|
|
1403
|
+
if (endpoint === "discardBranch") {
|
|
1404
|
+
const branch = requiredString(payload, "branch");
|
|
1405
|
+
if ("failure" in branch) throw branch.failure;
|
|
1406
|
+
return ok(await toggle.discardBranch({
|
|
1407
|
+
path: path.value,
|
|
1408
|
+
branch: branch.value
|
|
1409
|
+
}, signal));
|
|
1410
|
+
}
|
|
1023
1411
|
const name = requiredString(payload, "name");
|
|
1024
1412
|
if ("failure" in name) throw name.failure;
|
|
1025
1413
|
return ok(await toggle.discard({
|
|
@@ -1202,7 +1590,8 @@ function apply(ctx, config) {
|
|
|
1202
1590
|
onScope: (sessionId, scope) => {
|
|
1203
1591
|
if (scope === null) scopes.forget(sessionId);
|
|
1204
1592
|
else scopes.remember(sessionId, scope);
|
|
1205
|
-
}
|
|
1593
|
+
},
|
|
1594
|
+
readScope: (sessionId) => scopes.get(sessionId)
|
|
1206
1595
|
});
|
|
1207
1596
|
installWorktreeScope(ctx, {
|
|
1208
1597
|
registry: scopes,
|