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/README.md +104 -21
- package/lib/client.js +1274 -487
- package/lib/index.d.ts +13 -0
- package/lib/index.js +601 -106
- 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
|
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
501
|
-
*
|
|
502
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
661
|
-
*
|
|
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
|
|
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)
|
|
680
|
-
|
|
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
|
-
|
|
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
|
|
747
|
-
|
|
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 =
|
|
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
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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,
|