@young1lin/dsh-ui-gitworkbench 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/CHANGELOG_EN.md +28 -0
- package/README.md +10 -5
- package/README_EN.md +5 -0
- package/lib/client.js +355 -247
- package/lib/index.js +32 -19
- package/lib/worktree.js +69 -5
- package/package.json +3 -1
- package/src/client/GitWorkbenchPanel.tsx +38 -1
- package/src/client/highlight.ts +12 -0
- package/src/client/worktree-view.ts +37 -6
- package/src/index.ts +35 -19
- package/src/worktree.ts +73 -5
package/lib/index.js
CHANGED
|
@@ -76,7 +76,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
76
76
|
* @module @young1lin/dsh-ui-gitworkbench
|
|
77
77
|
*/
|
|
78
78
|
import { randomBytes } from 'node:crypto';
|
|
79
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
79
|
+
import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
|
|
80
80
|
import { homedir } from 'node:os';
|
|
81
81
|
import { join } from 'node:path';
|
|
82
82
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
@@ -86,7 +86,7 @@ import { CommitPayloadCache, cacheKey } from './commit-cache.js';
|
|
|
86
86
|
import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, fetchArgv, isBinaryPrefix, isNoMergeBaseError, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
|
|
87
87
|
import { LOG_FORMAT, parseLog } from './git-log.js';
|
|
88
88
|
import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
|
|
89
|
-
import { bindingsPath,
|
|
89
|
+
import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
90
90
|
/** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
|
|
91
91
|
const DIFF_CHAR_CAP = 400_000;
|
|
92
92
|
/** Untracked files larger than this are listed + counted but never diffed. */
|
|
@@ -242,7 +242,8 @@ let GitWorkbenchService = (() => {
|
|
|
242
242
|
if (binding === undefined)
|
|
243
243
|
return '';
|
|
244
244
|
const rel = `.agents/worktrees/${binding.name}`;
|
|
245
|
-
|
|
245
|
+
const branchNote = binding.branch === undefined ? '' : ` (branch ${binding.branch})`;
|
|
246
|
+
return `This session is bound to git worktree "${binding.name}"${branchNote}.\n`
|
|
246
247
|
+ 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
|
|
247
248
|
+ `- shell commands: pass workdir "${rel}"\n`
|
|
248
249
|
+ `- file tools: prefix every path with ${rel}/\n`
|
|
@@ -290,13 +291,14 @@ let GitWorkbenchService = (() => {
|
|
|
290
291
|
};
|
|
291
292
|
ctx.tools.register(defineTool({
|
|
292
293
|
name: 'worktree_enter',
|
|
293
|
-
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name>
|
|
294
|
-
+ '
|
|
294
|
+
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name> — the directory is '
|
|
295
|
+
+ 'always derived from the name (there is no dir parameter), the branch is the name VERBATIM, '
|
|
296
|
+
+ 'and the session is bound to it. After entering, address the worktree relatively from the session cwd: '
|
|
295
297
|
+ 'for shell commands pass workdir ".agents/worktrees/<name>" (per-call workdir is supported and resolved '
|
|
296
298
|
+ 'against the session cwd); for file tools use paths prefixed with .agents/worktrees/<name>/. '
|
|
297
299
|
+ 'Call with no name to auto-generate one. Use worktree_exit to leave.',
|
|
298
300
|
parameters: {
|
|
299
|
-
name: { type: 'string', description: 'Optional worktree name
|
|
301
|
+
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name is used VERBATIM as the branch — no prefix is added. If the target directory already holds a registered worktree (e.g. one made by another tool), it is reused as-is with its own branch. Auto-generated when omitted or illegal.' },
|
|
300
302
|
},
|
|
301
303
|
output: output(OP_SCHEMA),
|
|
302
304
|
execute: async (args, exec) => {
|
|
@@ -652,39 +654,49 @@ let GitWorkbenchService = (() => {
|
|
|
652
654
|
// on disk — otherwise a stale entry passes for a reusable worktree and the
|
|
653
655
|
// session binds to a directory that no longer exists.
|
|
654
656
|
await this.git(repoRoot, ['worktree', 'prune'], signal);
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
657
|
+
// A registered worktree already lives at the target directory -> reuse it,
|
|
658
|
+
// whatever made it. The match is on real paths, not strings: junctions
|
|
659
|
+
// (`.agents/worktrees` pointing at `.claude/worktrees`) and the spelling a
|
|
660
|
+
// foreign tool registered under all collapse to the same directory, and
|
|
661
|
+
// the worktree keeps ITS OWN branch — only the binding is new.
|
|
662
|
+
const existing = await findRegisteredWorktree(parseWorktreeList((await this.git(repoRoot, ['worktree', 'list', '--porcelain'], signal)).stdout), dir, realpath);
|
|
658
663
|
// Branch point, read BEFORE `add` so a fresh worktree records exactly where it
|
|
659
664
|
// started. Reuse paths recover it with merge-base instead (theirs is historical).
|
|
660
665
|
const headBefore = (await this.git(repoRoot, ['rev-parse', 'HEAD'], signal)).stdout.trim();
|
|
666
|
+
// The branch the session actually lands on: the reused worktree's own branch,
|
|
667
|
+
// or the name VERBATIM for a fresh create — no forced prefix.
|
|
668
|
+
let branch = existing?.branch ?? wtName;
|
|
669
|
+
let reusedWorktree = false;
|
|
661
670
|
let reusedBranch = false;
|
|
662
671
|
let baseCommit;
|
|
663
672
|
if (existing === undefined) {
|
|
664
|
-
const add = await this.git(repoRoot, ['worktree', 'add', '-b',
|
|
673
|
+
const add = await this.git(repoRoot, ['worktree', 'add', '-b', wtName, dir], signal);
|
|
665
674
|
if (add.exitCode === 0) {
|
|
666
675
|
baseCommit = headBefore.length > 0 ? headBefore : undefined;
|
|
667
676
|
}
|
|
668
677
|
else {
|
|
669
|
-
// `git worktree remove` keeps branch
|
|
670
|
-
//
|
|
671
|
-
// verify the ref and check the existing branch out instead
|
|
672
|
-
|
|
673
|
-
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${
|
|
678
|
+
// `git worktree remove` keeps the branch (it may carry unmerged
|
|
679
|
+
// commits), so a re-enter after remove finds the name present as a
|
|
680
|
+
// branch: verify the ref and check the existing branch out instead
|
|
681
|
+
// of failing on `-b`.
|
|
682
|
+
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${wtName}`], signal);
|
|
674
683
|
if (verified.exitCode !== 0) {
|
|
675
684
|
return { ok: false, error: `git worktree add failed (exit ${add.exitCode})${add.stderr.length > 0 ? `: ${add.stderr}` : ''}` };
|
|
676
685
|
}
|
|
677
|
-
const retry = await this.git(repoRoot, ['worktree', 'add', dir,
|
|
686
|
+
const retry = await this.git(repoRoot, ['worktree', 'add', dir, wtName], signal);
|
|
678
687
|
if (retry.exitCode !== 0) {
|
|
679
688
|
return { ok: false, error: `git worktree add failed (exit ${retry.exitCode})${retry.stderr.length > 0 ? `: ${retry.stderr}` : ''}` };
|
|
680
689
|
}
|
|
681
690
|
reusedBranch = true;
|
|
682
691
|
}
|
|
683
692
|
}
|
|
693
|
+
else {
|
|
694
|
+
reusedWorktree = true;
|
|
695
|
+
}
|
|
684
696
|
if (baseCommit === undefined) {
|
|
685
697
|
// Reused worktree or branch: its real branch point is historical, so take the
|
|
686
698
|
// merge base with the repo's current HEAD. A failure leaves the field absent.
|
|
687
|
-
const merged = await this.git(repoRoot, ['merge-base',
|
|
699
|
+
const merged = await this.git(repoRoot, ['merge-base', branch, 'HEAD'], signal);
|
|
688
700
|
if (merged.exitCode === 0)
|
|
689
701
|
baseCommit = merged.stdout.trim() || undefined;
|
|
690
702
|
}
|
|
@@ -692,6 +704,7 @@ let GitWorkbenchService = (() => {
|
|
|
692
704
|
const file = await io.load();
|
|
693
705
|
const binding = {
|
|
694
706
|
repoRoot, worktreePath: dir, name: wtName, enteredAt: new Date().toISOString(),
|
|
707
|
+
branch,
|
|
695
708
|
...baseCommit === undefined ? {} : { baseCommit },
|
|
696
709
|
};
|
|
697
710
|
file.bindings[sessionId] = binding;
|
|
@@ -700,8 +713,8 @@ let GitWorkbenchService = (() => {
|
|
|
700
713
|
});
|
|
701
714
|
const rel = `.agents/worktrees/${wtName}`;
|
|
702
715
|
return {
|
|
703
|
-
ok: true, worktreePath: dir, branch
|
|
704
|
-
hint: `Session bound to worktree "${wtName}" at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedBranch ? ` Note: reused existing branch ${
|
|
716
|
+
ok: true, worktreePath: dir, branch,
|
|
717
|
+
hint: `Session bound to worktree "${wtName}" (branch ${branch}) at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedWorktree ? ` Note: reused the worktree already registered there; its branch ${branch} was kept.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
705
718
|
};
|
|
706
719
|
}
|
|
707
720
|
/** Unbind the session's worktree; with remove=true, delete a clean worktree from disk. */
|
package/lib/worktree.js
CHANGED
|
@@ -12,7 +12,9 @@ function isBinding(value) {
|
|
|
12
12
|
// Absent is normal; present-but-malformed is corruption, and dropping the
|
|
13
13
|
// whole record beats trusting half of it.
|
|
14
14
|
const base = record['baseCommit'];
|
|
15
|
-
|
|
15
|
+
const branch = record['branch'];
|
|
16
|
+
return (base === undefined || (typeof base === 'string' && base.length > 0))
|
|
17
|
+
&& (branch === undefined || (typeof branch === 'string' && branch.length > 0));
|
|
16
18
|
}
|
|
17
19
|
export function bindingsPath(home) {
|
|
18
20
|
return join(home, '.dsh', 'gitworkbench-worktree-bindings.json').replace(/\\/g, '/');
|
|
@@ -45,13 +47,75 @@ export async function saveBindings(ensureDir, writeText, rename, path, file) {
|
|
|
45
47
|
await saveJsonAtomic(ensureDir, writeText, rename, path, file);
|
|
46
48
|
}
|
|
47
49
|
// ---- Task 2: worktree name/branch/path derivation + porcelain parsing ----
|
|
48
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The charset a worktree name may use: the intersection of what git accepts
|
|
52
|
+
* in a ref component and what survives as a Windows directory name.
|
|
53
|
+
*
|
|
54
|
+
* The name is used verbatim as BOTH the directory under `.agents/worktrees/`
|
|
55
|
+
* and the branch — there is NO forced prefix, the name the caller asks for is
|
|
56
|
+
* the branch it gets — so every character must be legal in both worlds:
|
|
57
|
+
*
|
|
58
|
+
* - git (check-ref-format): rejects `..`, a trailing dot, a `.lock` ending,
|
|
59
|
+
* control characters, space and `~ ^ : ? * [ \`. `+` is LEGAL — the
|
|
60
|
+
* earlier allowlist `[A-Za-z0-9._-]` rejected it and silently renamed
|
|
61
|
+
* `feature+20260810-...` to a generated `wt-<hex>`.
|
|
62
|
+
* - Windows (NTFS): rejects `< > : " | ? *` (git already covers those) and
|
|
63
|
+
* the reserved device names CON/PRN/AUX/NUL/COM1-9/LPT1-9 — even before
|
|
64
|
+
* the first dot, case-insensitive — which git never objects to.
|
|
65
|
+
* - argv: a leading `-` would read as an option; a leading `.` both hides
|
|
66
|
+
* the directory and starts the dot-component git rejects. So: start
|
|
67
|
+
* alphanumeric.
|
|
68
|
+
*/
|
|
69
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
|
|
70
|
+
/** Windows device names that are legal git branches but catastrophic dirs. */
|
|
71
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
72
|
+
function isWorktreeName(raw) {
|
|
73
|
+
return NAME_PATTERN.test(raw)
|
|
74
|
+
&& !raw.includes('..')
|
|
75
|
+
&& !raw.endsWith('.')
|
|
76
|
+
&& !raw.endsWith('.lock')
|
|
77
|
+
&& raw.toLowerCase() !== 'head'
|
|
78
|
+
&& !WINDOWS_RESERVED.test(raw.split('.')[0] ?? raw);
|
|
79
|
+
}
|
|
49
80
|
export function sanitizeName(raw, rng) {
|
|
50
|
-
if (raw !== undefined &&
|
|
81
|
+
if (raw !== undefined && isWorktreeName(raw))
|
|
51
82
|
return raw;
|
|
52
|
-
|
|
83
|
+
// No `wt-` here either: the caller's name is the identity everywhere, so a
|
|
84
|
+
// generated fallback gets a neutral, self-describing one instead.
|
|
85
|
+
return `worktree-${rng()}`;
|
|
86
|
+
}
|
|
87
|
+
async function resolveReal(resolve, path) {
|
|
88
|
+
try {
|
|
89
|
+
return (await resolve(path.replace(/\\/g, '/'))).replace(/\\/g, '/');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Find the registered worktree that IS `dir`, if any.
|
|
97
|
+
*
|
|
98
|
+
* String comparison is not enough: `.agents/worktrees` may be a junction to
|
|
99
|
+
* `.claude/worktrees`, and git lists the path a worktree was REGISTERED
|
|
100
|
+
* under, not the spelling this session would type. Both sides go through a
|
|
101
|
+
* real-path resolution, so junctions, symlinks and separator styles collapse
|
|
102
|
+
* to the same directory — and a worktree made by another tool (checked out
|
|
103
|
+
* under its own branch, no `wt/` anywhere) is recognized and reused instead
|
|
104
|
+
* of colliding with `git worktree add`.
|
|
105
|
+
* @param entries - parsed `git worktree list --porcelain`.
|
|
106
|
+
* @param dir - the worktree directory the caller wants.
|
|
107
|
+
* @param resolve - real-path resolver (node:fs/promises realpath in the host).
|
|
108
|
+
*/
|
|
109
|
+
export async function findRegisteredWorktree(entries, dir, resolve) {
|
|
110
|
+
const target = await resolveReal(resolve, dir);
|
|
111
|
+
if (target === null)
|
|
112
|
+
return undefined;
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (await resolveReal(resolve, entry.path) === target)
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
53
118
|
}
|
|
54
|
-
export function branchFor(name) { return `wt/${name}`; }
|
|
55
119
|
/** Longest ref name accepted — well past any real branch, short of a payload. */
|
|
56
120
|
const REF_MAX_LENGTH = 200;
|
|
57
121
|
/** The character set git allows in a branch or tag name. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@young1lin/dsh-ui-gitworkbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"scripts/install.ps1",
|
|
19
19
|
"README.md",
|
|
20
20
|
"README_EN.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"CHANGELOG_EN.md",
|
|
21
23
|
"AGENTS.md",
|
|
22
24
|
"LICENSE",
|
|
23
25
|
"cordis.patch.yml"
|
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
type CheckState, type Tick, type TickAction,
|
|
63
63
|
} from './stage-tree.ts'
|
|
64
64
|
import { grammarLoadCount, highlightForRows, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
65
|
-
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, probesClosedBinding, samePath, showsPending, splitPath, viewedPath } from './worktree-view.ts'
|
|
65
|
+
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
|
|
66
66
|
import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
|
|
67
67
|
import type { WorkbenchKey } from './locales.ts'
|
|
68
68
|
import css from './GitWorkbenchPanel.module.css'
|
|
@@ -630,6 +630,43 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
630
630
|
return () => { alive = false; clearInterval(id) }
|
|
631
631
|
}, [open, agentRunning, sessionId, worktreePath, fetchSessionBinding, fetchWorktreeStatus])
|
|
632
632
|
|
|
633
|
+
/** The agent's `running` on the previous render. The flag itself says
|
|
634
|
+
* whether a turn is in flight; only the EDGE of it says the turn has
|
|
635
|
+
* ended, and the edge is what the effect below keys on. */
|
|
636
|
+
const wasRunningRef = useRef<boolean | undefined>(undefined)
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Refresh the SHUT chip's stats when a turn ends.
|
|
640
|
+
*
|
|
641
|
+
* The keyed fetch below runs on mount, on source switches and on gen bumps,
|
|
642
|
+
* and the 3-15s poll starts at `if (!open) return` — so while the drawer was
|
|
643
|
+
* shut, an agent that wrote files all turn left the header counting the tree
|
|
644
|
+
* as it stood before the turn. Opening the drawer was the only thing that
|
|
645
|
+
* refreshed it, and an indicator you must open to read is not an indicator.
|
|
646
|
+
*
|
|
647
|
+
* `running` is mirrored live by the sessions store, and a turn boundary is
|
|
648
|
+
* when agent-caused side effects have settled ({@link turnSettled}), so one
|
|
649
|
+
* fetch per turn buys the chip the numbers the turn just made true — ahead
|
|
650
|
+
* counts included, which ride along in the same payload. The write follows
|
|
651
|
+
* the poll's discipline exactly: guarded on the source so a retired worktree
|
|
652
|
+
* cannot repaint the tree, touching neither `gen` (which would reset tree
|
|
653
|
+
* expansion) nor `statsLoading` (which would swap the header totals for a
|
|
654
|
+
* `—` while a good answer is still on screen).
|
|
655
|
+
*
|
|
656
|
+
* An open drawer skips it — the poll is running there and the open itself
|
|
657
|
+
* bumped gen. Like the probe's full refetch above, the fetch is left to land
|
|
658
|
+
* guarded rather than aborted: a cleanup fired for an unrelated dep (the
|
|
659
|
+
* drawer opening mid-flight) must not cancel the only fetch this turn gets.
|
|
660
|
+
*/
|
|
661
|
+
useEffect(() => {
|
|
662
|
+
const settled = turnSettled(wasRunningRef.current, agentRunning)
|
|
663
|
+
wasRunningRef.current = agentRunning
|
|
664
|
+
if (!settled || open) return
|
|
665
|
+
fetchStats(statsPath, new AbortController().signal)
|
|
666
|
+
.then(value => { if (value !== null && statsPathRef.current === statsPath) setStats(value) })
|
|
667
|
+
.catch(() => {})
|
|
668
|
+
}, [agentRunning, open, statsPath, fetchStats])
|
|
669
|
+
|
|
633
670
|
// Stats for the active source: on mount, on source change and on gen bumps
|
|
634
671
|
// (manual refresh / source switch). Cleanup aborts a superseded in-flight fetch.
|
|
635
672
|
useEffect(() => {
|
package/src/client/highlight.ts
CHANGED
|
@@ -48,6 +48,14 @@ const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
|
|
|
48
48
|
['java', () => import('@shikijs/langs/java')],
|
|
49
49
|
['c', () => import('@shikijs/langs/c')],
|
|
50
50
|
['cpp', () => import('@shikijs/langs/cpp')],
|
|
51
|
+
// sql/xml were the loud gap: schema dumps and pom/config diffs rendered as
|
|
52
|
+
// plain text. ini and diff ride along — small grammars, common in repos.
|
|
53
|
+
// Every entry lands in client.js (inlineDynamicImports), so additions stay
|
|
54
|
+
// deliberate, not encyclopedic.
|
|
55
|
+
['sql', () => import('@shikijs/langs/sql')],
|
|
56
|
+
['xml', () => import('@shikijs/langs/xml')],
|
|
57
|
+
['ini', () => import('@shikijs/langs/ini')],
|
|
58
|
+
['diff', () => import('@shikijs/langs/diff')],
|
|
51
59
|
])
|
|
52
60
|
|
|
53
61
|
const LANG_ALIASES = new Map<string, string>([
|
|
@@ -68,6 +76,10 @@ const LANG_ALIASES = new Map<string, string>([
|
|
|
68
76
|
['java', 'java'],
|
|
69
77
|
['c', 'c'],
|
|
70
78
|
['cpp', 'cpp'], ['h', 'c'], ['hpp', 'cpp'],
|
|
79
|
+
['sql', 'sql'],
|
|
80
|
+
['xml', 'xml'], ['xsl', 'xml'], ['xsd', 'xml'], ['svg', 'xml'],
|
|
81
|
+
['ini', 'ini'], ['properties', 'ini'], ['conf', 'ini'], ['cfg', 'ini'],
|
|
82
|
+
['diff', 'diff'], ['patch', 'diff'],
|
|
71
83
|
])
|
|
72
84
|
|
|
73
85
|
/** Drawer `data-gs-theme` → a loaded Shiki theme name. */
|
|
@@ -109,6 +109,36 @@ export function bindingChanged(
|
|
|
109
109
|
return (probe.name ?? '') !== (shown?.name ?? '')
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Whether a turn that was in flight has just come to an end.
|
|
114
|
+
*
|
|
115
|
+
* The shut chip's stats freeze on mount: the fetch that carries them re-runs
|
|
116
|
+
* only on source switches and gen bumps, and the 3-15s poll starts at
|
|
117
|
+
* `if (!open) return`. So an agent that wrote files all turn left the header
|
|
118
|
+
* counting the tree as it was before the turn — until someone opened the
|
|
119
|
+
* drawer, which is the one act that already refreshes everything.
|
|
120
|
+
*
|
|
121
|
+
* `running` is mirrored live by the sessions store, and a turn boundary is
|
|
122
|
+
* when agent-caused side effects have stopped accumulating — the one instant
|
|
123
|
+
* a shut chip should pay for fresh numbers. One fetch per turn, and none for
|
|
124
|
+
* an idle session, which is the cost rule this panel lives under (it mounts
|
|
125
|
+
* in every session header).
|
|
126
|
+
*
|
|
127
|
+
* The transition is read strictly: `false → true` is a turn STARTING, whose
|
|
128
|
+
* numbers are seconds away from being rewritten, and a missing `next` counts
|
|
129
|
+
* as ended because a turn in flight cannot still be in flight once the store
|
|
130
|
+
* stops saying so.
|
|
131
|
+
*
|
|
132
|
+
* @param prevRunning - whether the agent had a turn in flight last render.
|
|
133
|
+
* @param nextRunning - whether it has one now.
|
|
134
|
+
*/
|
|
135
|
+
export function turnSettled(
|
|
136
|
+
prevRunning: boolean | undefined,
|
|
137
|
+
nextRunning: boolean | undefined,
|
|
138
|
+
): boolean {
|
|
139
|
+
return prevRunning === true && nextRunning !== true
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
/**
|
|
113
143
|
* Whether a view should say "pending" rather than show what it has.
|
|
114
144
|
*
|
|
@@ -135,17 +165,18 @@ export function showsPending(loading: boolean, fileCount: number): boolean {
|
|
|
135
165
|
/**
|
|
136
166
|
* Whether a worktree's badge would only repeat the branch chip beside it.
|
|
137
167
|
*
|
|
138
|
-
*
|
|
139
|
-
* name
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
168
|
+
* The plugin derives one from the other: a worktree it entered has the
|
|
169
|
+
* binding's name as its branch VERBATIM (and legacy bindings from the
|
|
170
|
+
* `wt/<name>` era derive it just as directly) — so for those, the session
|
|
171
|
+
* card would print the same word twice, once as the branch chip and once as
|
|
172
|
+
* the badge. A worktree made outside the plugin has no such relation, and
|
|
173
|
+
* there the badge is the only thing naming the directory.
|
|
143
174
|
*
|
|
144
175
|
* @param branch - the branch checked out there.
|
|
145
176
|
* @param name - the worktree's name, as the binding records it.
|
|
146
177
|
*/
|
|
147
178
|
export function badgeRepeatsBranch(branch: string, name: string): boolean {
|
|
148
|
-
return branch.length > 0 && branch === `wt/${name}`
|
|
179
|
+
return branch.length > 0 && (branch === name || branch === `wt/${name}`)
|
|
149
180
|
}
|
|
150
181
|
|
|
151
182
|
/**
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* @module @young1lin/dsh-ui-gitworkbench
|
|
43
43
|
*/
|
|
44
44
|
import { randomBytes } from 'node:crypto'
|
|
45
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
45
|
+
import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'
|
|
46
46
|
import { homedir } from 'node:os'
|
|
47
47
|
import { join } from 'node:path'
|
|
48
48
|
import type { Readable } from 'node:stream'
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
type StyleEntry, type StyleFile,
|
|
66
66
|
} from './style-store.js'
|
|
67
67
|
import {
|
|
68
|
-
bindingsPath,
|
|
68
|
+
bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir,
|
|
69
69
|
type BindingsFile, type WorktreeBinding, type WorktreeEntry, type WorktreeOpResult,
|
|
70
70
|
} from './worktree.js'
|
|
71
71
|
|
|
@@ -228,7 +228,8 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
228
228
|
const binding = sessionId === undefined ? undefined : this.bindingMirror.get(sessionId)
|
|
229
229
|
if (binding === undefined) return ''
|
|
230
230
|
const rel = `.agents/worktrees/${binding.name}`
|
|
231
|
-
|
|
231
|
+
const branchNote = binding.branch === undefined ? '' : ` (branch ${binding.branch})`
|
|
232
|
+
return `This session is bound to git worktree "${binding.name}"${branchNote}.\n`
|
|
232
233
|
+ 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
|
|
233
234
|
+ `- shell commands: pass workdir "${rel}"\n`
|
|
234
235
|
+ `- file tools: prefix every path with ${rel}/\n`
|
|
@@ -278,13 +279,14 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
278
279
|
|
|
279
280
|
ctx.tools.register(defineTool({
|
|
280
281
|
name: 'worktree_enter',
|
|
281
|
-
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name>
|
|
282
|
-
+ '
|
|
282
|
+
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name> — the directory is '
|
|
283
|
+
+ 'always derived from the name (there is no dir parameter), the branch is the name VERBATIM, '
|
|
284
|
+
+ 'and the session is bound to it. After entering, address the worktree relatively from the session cwd: '
|
|
283
285
|
+ 'for shell commands pass workdir ".agents/worktrees/<name>" (per-call workdir is supported and resolved '
|
|
284
286
|
+ 'against the session cwd); for file tools use paths prefixed with .agents/worktrees/<name>/. '
|
|
285
287
|
+ 'Call with no name to auto-generate one. Use worktree_exit to leave.',
|
|
286
288
|
parameters: {
|
|
287
|
-
name: { type: 'string', description: 'Optional worktree name
|
|
289
|
+
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name is used VERBATIM as the branch — no prefix is added. If the target directory already holds a registered worktree (e.g. one made by another tool), it is reused as-is with its own branch. Auto-generated when omitted or illegal.' },
|
|
288
290
|
},
|
|
289
291
|
output: output(OP_SCHEMA),
|
|
290
292
|
execute: async (args: { name?: string }, exec: ToolRunContext) => {
|
|
@@ -648,44 +650,58 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
648
650
|
// on disk — otherwise a stale entry passes for a reusable worktree and the
|
|
649
651
|
// session binds to a directory that no longer exists.
|
|
650
652
|
await this.git(repoRoot, ['worktree', 'prune'], signal)
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
653
|
+
// A registered worktree already lives at the target directory -> reuse it,
|
|
654
|
+
// whatever made it. The match is on real paths, not strings: junctions
|
|
655
|
+
// (`.agents/worktrees` pointing at `.claude/worktrees`) and the spelling a
|
|
656
|
+
// foreign tool registered under all collapse to the same directory, and
|
|
657
|
+
// the worktree keeps ITS OWN branch — only the binding is new.
|
|
658
|
+
const existing = await findRegisteredWorktree(
|
|
659
|
+
parseWorktreeList((await this.git(repoRoot, ['worktree', 'list', '--porcelain'], signal)).stdout),
|
|
660
|
+
dir,
|
|
661
|
+
realpath,
|
|
662
|
+
)
|
|
654
663
|
// Branch point, read BEFORE `add` so a fresh worktree records exactly where it
|
|
655
664
|
// started. Reuse paths recover it with merge-base instead (theirs is historical).
|
|
656
665
|
const headBefore = (await this.git(repoRoot, ['rev-parse', 'HEAD'], signal)).stdout.trim()
|
|
666
|
+
// The branch the session actually lands on: the reused worktree's own branch,
|
|
667
|
+
// or the name VERBATIM for a fresh create — no forced prefix.
|
|
668
|
+
let branch = existing?.branch ?? wtName
|
|
669
|
+
let reusedWorktree = false
|
|
657
670
|
let reusedBranch = false
|
|
658
671
|
let baseCommit: string | undefined
|
|
659
672
|
if (existing === undefined) {
|
|
660
|
-
const add = await this.git(repoRoot, ['worktree', 'add', '-b',
|
|
673
|
+
const add = await this.git(repoRoot, ['worktree', 'add', '-b', wtName, dir], signal)
|
|
661
674
|
if (add.exitCode === 0) {
|
|
662
675
|
baseCommit = headBefore.length > 0 ? headBefore : undefined
|
|
663
676
|
} else {
|
|
664
|
-
// `git worktree remove` keeps branch
|
|
665
|
-
//
|
|
666
|
-
// verify the ref and check the existing branch out instead
|
|
667
|
-
|
|
668
|
-
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${
|
|
677
|
+
// `git worktree remove` keeps the branch (it may carry unmerged
|
|
678
|
+
// commits), so a re-enter after remove finds the name present as a
|
|
679
|
+
// branch: verify the ref and check the existing branch out instead
|
|
680
|
+
// of failing on `-b`.
|
|
681
|
+
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${wtName}`], signal)
|
|
669
682
|
if (verified.exitCode !== 0) {
|
|
670
683
|
return { ok: false, error: `git worktree add failed (exit ${add.exitCode})${add.stderr.length > 0 ? `: ${add.stderr}` : ''}` }
|
|
671
684
|
}
|
|
672
|
-
const retry = await this.git(repoRoot, ['worktree', 'add', dir,
|
|
685
|
+
const retry = await this.git(repoRoot, ['worktree', 'add', dir, wtName], signal)
|
|
673
686
|
if (retry.exitCode !== 0) {
|
|
674
687
|
return { ok: false, error: `git worktree add failed (exit ${retry.exitCode})${retry.stderr.length > 0 ? `: ${retry.stderr}` : ''}` }
|
|
675
688
|
}
|
|
676
689
|
reusedBranch = true
|
|
677
690
|
}
|
|
691
|
+
} else {
|
|
692
|
+
reusedWorktree = true
|
|
678
693
|
}
|
|
679
694
|
if (baseCommit === undefined) {
|
|
680
695
|
// Reused worktree or branch: its real branch point is historical, so take the
|
|
681
696
|
// merge base with the repo's current HEAD. A failure leaves the field absent.
|
|
682
|
-
const merged = await this.git(repoRoot, ['merge-base',
|
|
697
|
+
const merged = await this.git(repoRoot, ['merge-base', branch, 'HEAD'], signal)
|
|
683
698
|
if (merged.exitCode === 0) baseCommit = merged.stdout.trim() || undefined
|
|
684
699
|
}
|
|
685
700
|
await this.withBindings(async io => {
|
|
686
701
|
const file = await io.load()
|
|
687
702
|
const binding: WorktreeBinding = {
|
|
688
703
|
repoRoot, worktreePath: dir, name: wtName, enteredAt: new Date().toISOString(),
|
|
704
|
+
branch,
|
|
689
705
|
...baseCommit === undefined ? {} : { baseCommit },
|
|
690
706
|
}
|
|
691
707
|
file.bindings[sessionId] = binding
|
|
@@ -694,8 +710,8 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
694
710
|
})
|
|
695
711
|
const rel = `.agents/worktrees/${wtName}`
|
|
696
712
|
return {
|
|
697
|
-
ok: true, worktreePath: dir, branch
|
|
698
|
-
hint: `Session bound to worktree "${wtName}" at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedBranch ? ` Note: reused existing branch ${
|
|
713
|
+
ok: true, worktreePath: dir, branch,
|
|
714
|
+
hint: `Session bound to worktree "${wtName}" (branch ${branch}) at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedWorktree ? ` Note: reused the worktree already registered there; its branch ${branch} was kept.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
699
715
|
}
|
|
700
716
|
}
|
|
701
717
|
|
package/src/worktree.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface WorktreeBinding {
|
|
|
7
7
|
readonly worktreePath: string
|
|
8
8
|
readonly name: string
|
|
9
9
|
readonly enteredAt: string
|
|
10
|
+
/**
|
|
11
|
+
* The branch checked out in the bound worktree. Optional on purpose:
|
|
12
|
+
* bindings written before this field existed stay valid; a consumer without
|
|
13
|
+
* it falls back to reading the branch off the worktree list.
|
|
14
|
+
*/
|
|
15
|
+
readonly branch?: string
|
|
10
16
|
/**
|
|
11
17
|
* Commit the worktree's branch started from. Optional on purpose: bindings
|
|
12
18
|
* written before this field existed stay valid, and a reuse path can fail to
|
|
@@ -29,7 +35,9 @@ function isBinding(value: unknown): value is WorktreeBinding {
|
|
|
29
35
|
// Absent is normal; present-but-malformed is corruption, and dropping the
|
|
30
36
|
// whole record beats trusting half of it.
|
|
31
37
|
const base = record['baseCommit']
|
|
32
|
-
|
|
38
|
+
const branch = record['branch']
|
|
39
|
+
return (base === undefined || (typeof base === 'string' && base.length > 0))
|
|
40
|
+
&& (branch === undefined || (typeof branch === 'string' && branch.length > 0))
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export function bindingsPath(home: string): string {
|
|
@@ -80,16 +88,76 @@ export interface WorktreeOpResult {
|
|
|
80
88
|
|
|
81
89
|
// ---- Task 2: worktree name/branch/path derivation + porcelain parsing ----
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The charset a worktree name may use: the intersection of what git accepts
|
|
93
|
+
* in a ref component and what survives as a Windows directory name.
|
|
94
|
+
*
|
|
95
|
+
* The name is used verbatim as BOTH the directory under `.agents/worktrees/`
|
|
96
|
+
* and the branch — there is NO forced prefix, the name the caller asks for is
|
|
97
|
+
* the branch it gets — so every character must be legal in both worlds:
|
|
98
|
+
*
|
|
99
|
+
* - git (check-ref-format): rejects `..`, a trailing dot, a `.lock` ending,
|
|
100
|
+
* control characters, space and `~ ^ : ? * [ \`. `+` is LEGAL — the
|
|
101
|
+
* earlier allowlist `[A-Za-z0-9._-]` rejected it and silently renamed
|
|
102
|
+
* `feature+20260810-...` to a generated `wt-<hex>`.
|
|
103
|
+
* - Windows (NTFS): rejects `< > : " | ? *` (git already covers those) and
|
|
104
|
+
* the reserved device names CON/PRN/AUX/NUL/COM1-9/LPT1-9 — even before
|
|
105
|
+
* the first dot, case-insensitive — which git never objects to.
|
|
106
|
+
* - argv: a leading `-` would read as an option; a leading `.` both hides
|
|
107
|
+
* the directory and starts the dot-component git rejects. So: start
|
|
108
|
+
* alphanumeric.
|
|
109
|
+
*/
|
|
110
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/
|
|
111
|
+
/** Windows device names that are legal git branches but catastrophic dirs. */
|
|
112
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
|
|
113
|
+
|
|
114
|
+
function isWorktreeName(raw: string): boolean {
|
|
115
|
+
return NAME_PATTERN.test(raw)
|
|
116
|
+
&& !raw.includes('..')
|
|
117
|
+
&& !raw.endsWith('.')
|
|
118
|
+
&& !raw.endsWith('.lock')
|
|
119
|
+
&& raw.toLowerCase() !== 'head'
|
|
120
|
+
&& !WINDOWS_RESERVED.test(raw.split('.')[0] ?? raw)
|
|
121
|
+
}
|
|
84
122
|
|
|
85
123
|
export interface WorktreeEntry { readonly path: string; readonly head: string; readonly branch: string }
|
|
86
124
|
|
|
87
125
|
export function sanitizeName(raw: string | undefined, rng: () => string): string {
|
|
88
|
-
if (raw !== undefined &&
|
|
89
|
-
|
|
126
|
+
if (raw !== undefined && isWorktreeName(raw)) return raw
|
|
127
|
+
// No `wt-` here either: the caller's name is the identity everywhere, so a
|
|
128
|
+
// generated fallback gets a neutral, self-describing one instead.
|
|
129
|
+
return `worktree-${rng()}`
|
|
90
130
|
}
|
|
91
131
|
|
|
92
|
-
|
|
132
|
+
/** Resolves a path or fails — the failure is the caller's "does not exist". */
|
|
133
|
+
export type PathResolver = (path: string) => Promise<string>
|
|
134
|
+
|
|
135
|
+
async function resolveReal(resolve: PathResolver, path: string): Promise<string | null> {
|
|
136
|
+
try { return (await resolve(path.replace(/\\/g, '/'))).replace(/\\/g, '/') } catch { return null }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find the registered worktree that IS `dir`, if any.
|
|
141
|
+
*
|
|
142
|
+
* String comparison is not enough: `.agents/worktrees` may be a junction to
|
|
143
|
+
* `.claude/worktrees`, and git lists the path a worktree was REGISTERED
|
|
144
|
+
* under, not the spelling this session would type. Both sides go through a
|
|
145
|
+
* real-path resolution, so junctions, symlinks and separator styles collapse
|
|
146
|
+
* to the same directory — and a worktree made by another tool (checked out
|
|
147
|
+
* under its own branch, no `wt/` anywhere) is recognized and reused instead
|
|
148
|
+
* of colliding with `git worktree add`.
|
|
149
|
+
* @param entries - parsed `git worktree list --porcelain`.
|
|
150
|
+
* @param dir - the worktree directory the caller wants.
|
|
151
|
+
* @param resolve - real-path resolver (node:fs/promises realpath in the host).
|
|
152
|
+
*/
|
|
153
|
+
export async function findRegisteredWorktree(entries: readonly WorktreeEntry[], dir: string, resolve: PathResolver): Promise<WorktreeEntry | undefined> {
|
|
154
|
+
const target = await resolveReal(resolve, dir)
|
|
155
|
+
if (target === null) return undefined
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (await resolveReal(resolve, entry.path) === target) return entry
|
|
158
|
+
}
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
93
161
|
|
|
94
162
|
/** Longest ref name accepted — well past any real branch, short of a payload. */
|
|
95
163
|
const REF_MAX_LENGTH = 200
|