@young1lin/dsh-ui-gitworkbench 0.1.16 → 0.1.17
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 +10 -0
- package/CHANGELOG_EN.md +10 -0
- package/README.md +26 -2
- package/README_EN.md +1 -1
- package/lib/client.js +397 -358
- package/lib/index.js +42 -12
- package/lib/worktree.js +50 -0
- package/package.json +1 -1
- package/src/client/DiffViews.tsx +7 -3
- package/src/client/GitWorkbenchPanel.tsx +14 -0
- package/src/client/diff-model.ts +20 -0
- package/src/client/locales.ts +6 -2
- package/src/index.ts +41 -13
- package/src/worktree.ts +56 -0
package/lib/index.js
CHANGED
|
@@ -98,7 +98,7 @@ import { LOG_FORMAT, parseLog } from './git-log.js';
|
|
|
98
98
|
import { emptyLogFilter, logFilterArgs } from './log-filter.js';
|
|
99
99
|
import { parseShortlog } from './shortlog.js';
|
|
100
100
|
import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
|
|
101
|
-
import { bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
101
|
+
import { bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, resolveEnterBranch, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
102
102
|
/** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
|
|
103
103
|
const DIFF_CHAR_CAP = 400_000;
|
|
104
104
|
/** Untracked files larger than this are listed + counted but never diffed. */
|
|
@@ -376,20 +376,22 @@ let GitWorkbenchService = (() => {
|
|
|
376
376
|
ctx.tools.register(defineTool({
|
|
377
377
|
name: 'worktree_enter',
|
|
378
378
|
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name> — the directory is '
|
|
379
|
-
+ 'always derived from the name (there is no dir parameter), the branch
|
|
379
|
+
+ 'always derived from the name (there is no dir parameter), the branch defaults to the name '
|
|
380
|
+
+ '(or pass branch to choose one — unlike the name it may contain slashes, e.g. feature/foo), '
|
|
380
381
|
+ 'and the session is bound to it. After entering, address the worktree relatively from the session cwd: '
|
|
381
382
|
+ 'for shell commands pass workdir ".agents/worktrees/<name>" (per-call workdir is supported and resolved '
|
|
382
383
|
+ 'against the session cwd); for file tools use paths prefixed with .agents/worktrees/<name>/. '
|
|
383
384
|
+ 'Call with no name to auto-generate one. Use worktree_exit to leave.',
|
|
384
385
|
parameters: {
|
|
385
|
-
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name
|
|
386
|
+
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name doubles as the default branch — pass branch to split them (no prefix is added either way). 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.' },
|
|
387
|
+
branch: { type: 'string', description: 'Optional branch for a NEW worktree; defaults to the name. Unlike the name it may contain slashes (feature/foo) — the one spelling a directory name cannot express. Validated and REFUSED when illegal (never auto-generated); set aside — the worktree keeps its own branch, the hint says so — when the target directory already holds a registered worktree.' },
|
|
386
388
|
},
|
|
387
389
|
output: output(OP_SCHEMA),
|
|
388
390
|
execute: async (args, exec) => {
|
|
389
391
|
const session = exec.agent?.session;
|
|
390
392
|
if (session === undefined)
|
|
391
393
|
return { ok: false, error: 'worktree tools require a calling session' };
|
|
392
|
-
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, exec.signal);
|
|
394
|
+
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, args?.branch, exec.signal);
|
|
393
395
|
},
|
|
394
396
|
presentCall: () => ({ card: 'generic', title: 'Enter worktree', kind: 'other' }),
|
|
395
397
|
}));
|
|
@@ -572,6 +574,13 @@ let GitWorkbenchService = (() => {
|
|
|
572
574
|
const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal);
|
|
573
575
|
if (tracked.stdout.trim().length > 0)
|
|
574
576
|
return { diff: tracked.stdout };
|
|
577
|
+
// Empty for a TRACKED file is real, not a missing diff: the line-ending
|
|
578
|
+
// phantom (autocrlf / eol attributes make the stat check and the clean
|
|
579
|
+
// filter disagree) lists such files modified forever while git itself
|
|
580
|
+
// finds no content difference. Synthesizing a new-file segment for one
|
|
581
|
+
// would paint a whole-file addition git does not see.
|
|
582
|
+
if (!await this.isUntracked(root, path, signal))
|
|
583
|
+
return { diff: '' };
|
|
575
584
|
return { diff: await untrackedSegment(root, path) ?? '' };
|
|
576
585
|
}
|
|
577
586
|
/**
|
|
@@ -1277,8 +1286,25 @@ let GitWorkbenchService = (() => {
|
|
|
1277
1286
|
? { worktreePath: null, name: null, inherited: false }
|
|
1278
1287
|
: { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited };
|
|
1279
1288
|
}
|
|
1280
|
-
/**
|
|
1281
|
-
|
|
1289
|
+
/**
|
|
1290
|
+
* Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/`
|
|
1291
|
+
* and bind the session to it.
|
|
1292
|
+
*
|
|
1293
|
+
* The name is the identity — the directory — and the default branch.
|
|
1294
|
+
* branchName (optional) splits the two for the one spelling the name can
|
|
1295
|
+
* never express: a slash branch (`feature/foo` is a legal ref and an
|
|
1296
|
+
* impossible Windows directory). It applies to a FRESH create only — a
|
|
1297
|
+
* reused worktree keeps its own branch, and the hint says so when an
|
|
1298
|
+
* explicit request was set aside. An illegal branchName is refused, never
|
|
1299
|
+
* substituted: a branch is semantic in a way a directory label is not.
|
|
1300
|
+
* @param sessionId - session to bind to the worktree.
|
|
1301
|
+
* @param repoPath - caller's directory, used to locate the repository.
|
|
1302
|
+
* @param name - worktree name (directory + default branch), sanitized;
|
|
1303
|
+
* auto-generated when omitted or illegal.
|
|
1304
|
+
* @param branchName - branch for a fresh create; defaults to the name.
|
|
1305
|
+
* @param signal - abort signal.
|
|
1306
|
+
*/
|
|
1307
|
+
async worktreeEnter(sessionId, repoPath, name, branchName, signal) {
|
|
1282
1308
|
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
1283
1309
|
return { ok: false, error: 'sessionId is required' };
|
|
1284
1310
|
const cwd = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd();
|
|
@@ -1305,13 +1331,17 @@ let GitWorkbenchService = (() => {
|
|
|
1305
1331
|
// started. Reuse paths recover it with merge-base instead (theirs is historical).
|
|
1306
1332
|
const headBefore = (await this.git(repoRoot, ['rev-parse', 'HEAD'], signal)).stdout.trim();
|
|
1307
1333
|
// The branch the session actually lands on: the reused worktree's own branch,
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1334
|
+
// branchName when the caller asked for one (slashes allowed — a branch can
|
|
1335
|
+
// spell what a directory cannot), else the name VERBATIM — no forced prefix.
|
|
1336
|
+
const choice = resolveEnterBranch(wtName, branchName, existing?.branch);
|
|
1337
|
+
if (!choice.ok)
|
|
1338
|
+
return { ok: false, error: choice.error };
|
|
1339
|
+
const branch = choice.branch;
|
|
1310
1340
|
let reusedWorktree = false;
|
|
1311
1341
|
let reusedBranch = false;
|
|
1312
1342
|
let baseCommit;
|
|
1313
1343
|
if (existing === undefined) {
|
|
1314
|
-
const add = await this.git(repoRoot, ['worktree', 'add', '-b',
|
|
1344
|
+
const add = await this.git(repoRoot, ['worktree', 'add', '-b', branch, dir], signal);
|
|
1315
1345
|
if (add.exitCode === 0) {
|
|
1316
1346
|
baseCommit = headBefore.length > 0 ? headBefore : undefined;
|
|
1317
1347
|
}
|
|
@@ -1320,11 +1350,11 @@ let GitWorkbenchService = (() => {
|
|
|
1320
1350
|
// commits), so a re-enter after remove finds the name present as a
|
|
1321
1351
|
// branch: verify the ref and check the existing branch out instead
|
|
1322
1352
|
// of failing on `-b`.
|
|
1323
|
-
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${
|
|
1353
|
+
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], signal);
|
|
1324
1354
|
if (verified.exitCode !== 0) {
|
|
1325
1355
|
return { ok: false, error: `git worktree add failed (exit ${add.exitCode})${add.stderr.length > 0 ? `: ${add.stderr}` : ''}` };
|
|
1326
1356
|
}
|
|
1327
|
-
const retry = await this.git(repoRoot, ['worktree', 'add', dir,
|
|
1357
|
+
const retry = await this.git(repoRoot, ['worktree', 'add', dir, branch], signal);
|
|
1328
1358
|
if (retry.exitCode !== 0) {
|
|
1329
1359
|
return { ok: false, error: `git worktree add failed (exit ${retry.exitCode})${retry.stderr.length > 0 ? `: ${retry.stderr}` : ''}` };
|
|
1330
1360
|
}
|
|
@@ -1355,7 +1385,7 @@ let GitWorkbenchService = (() => {
|
|
|
1355
1385
|
const rel = `.agents/worktrees/${wtName}`;
|
|
1356
1386
|
return {
|
|
1357
1387
|
ok: true, worktreePath: dir, branch,
|
|
1358
|
-
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).` : ''}`,
|
|
1388
|
+
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.` : ''}${choice.branchOverridden ? ` Note: requested branch ${branchName} was not used; the reused worktree keeps its branch ${branch}.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
1359
1389
|
};
|
|
1360
1390
|
}
|
|
1361
1391
|
/** Unbind the session's worktree; with remove=true, delete a clean worktree from disk. */
|
package/lib/worktree.js
CHANGED
|
@@ -138,6 +138,56 @@ export function isRefName(ref) {
|
|
|
138
138
|
&& !ref.includes('..')
|
|
139
139
|
&& REF_CHARS.test(ref);
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* The extra rules an ENTER branch must satisfy beyond {@link isRefName}.
|
|
143
|
+
*
|
|
144
|
+
* isRefName guards untrusted refs that reach git as positional arguments
|
|
145
|
+
* (leading `-`, `..`, alien characters). A branch worktreeEnter CREATES has
|
|
146
|
+
* two more classes of trouble: spellings check-ref-format refuses that
|
|
147
|
+
* REF_CHARS happens to pass (a leading or trailing dot, a `.lock` ending),
|
|
148
|
+
* and `head` — a legal ref on Linux that collides with HEAD on the
|
|
149
|
+
* case-insensitive filesystems the host runs on.
|
|
150
|
+
*/
|
|
151
|
+
function isEnterBranch(branch) {
|
|
152
|
+
return isRefName(branch)
|
|
153
|
+
&& !branch.startsWith('.')
|
|
154
|
+
&& !branch.endsWith('.')
|
|
155
|
+
&& !branch.endsWith('.lock')
|
|
156
|
+
&& branch.toLowerCase() !== 'head';
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Decide the branch a worktreeEnter call lands on.
|
|
160
|
+
*
|
|
161
|
+
* The worktree NAME is the identity knob — the directory under
|
|
162
|
+
* `.agents/worktrees/` — and doubles as the branch by default (the old
|
|
163
|
+
* contract, kept for callers that pass no branchName). branchName splits the
|
|
164
|
+
* two for the one thing the name can never express: a SLASH branch
|
|
165
|
+
* (`feature/foo` is a legal ref and an impossible Windows directory).
|
|
166
|
+
*
|
|
167
|
+
* Reuse keeps the registered worktree's own branch, request or no request;
|
|
168
|
+
* an explicit branchName it displaces is reported as branchOverridden so the
|
|
169
|
+
* hint can say so — refusing there would break enter's idempotency (the same
|
|
170
|
+
* call re-issued must rebind, not explode).
|
|
171
|
+
*
|
|
172
|
+
* An illegal branchName is REFUSED, never substituted: the worktree name may
|
|
173
|
+
* be auto-generated because a directory label is arbitrary, but a branch is
|
|
174
|
+
* semantic — silently renaming it lands work on the wrong branch.
|
|
175
|
+
* @param wtName - sanitized worktree name (the default branch).
|
|
176
|
+
* @param branchName - caller-requested branch, or undefined for the default.
|
|
177
|
+
* @param existingBranch - the registered worktree's own branch when the
|
|
178
|
+
* target directory already holds one, else undefined.
|
|
179
|
+
* @returns the branch to create or keep, plus whether an explicit request
|
|
180
|
+
* was set aside — or the refusal error.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveEnterBranch(wtName, branchName, existingBranch) {
|
|
183
|
+
if (branchName !== undefined && !isEnterBranch(branchName)) {
|
|
184
|
+
return { ok: false, error: 'branchName is not a valid branch name' };
|
|
185
|
+
}
|
|
186
|
+
if (existingBranch !== undefined) {
|
|
187
|
+
return { ok: true, branch: existingBranch, branchOverridden: branchName !== undefined && branchName !== existingBranch };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, branch: branchName ?? wtName, branchOverridden: false };
|
|
190
|
+
}
|
|
141
191
|
export function worktreeDir(repoRoot, name) {
|
|
142
192
|
return `${repoRoot.replace(/\/+$/, '')}/.agents/worktrees/${name}`;
|
|
143
193
|
}
|
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.17",
|
|
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",
|
package/src/client/DiffViews.tsx
CHANGED
|
@@ -241,7 +241,7 @@ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactN
|
|
|
241
241
|
* (history and compare keep it unconditionally), with a notice — a silently
|
|
242
242
|
* different view reads as a broken one, not a guarded one.
|
|
243
243
|
*/
|
|
244
|
-
export function SideBySideView({ t, path, palette, wrap, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
|
|
244
|
+
export function SideBySideView({ t, path, palette, wrap, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, phantomListed, onBlockAction, onSaved, onDirtyChange }: {
|
|
245
245
|
t: Translate
|
|
246
246
|
path: string
|
|
247
247
|
palette: string
|
|
@@ -262,6 +262,10 @@ export function SideBySideView({ t, path, palette, wrap, statsPath, fetchSides,
|
|
|
262
262
|
* refresh generations — this is how the poll reaches a dirty buffer. */
|
|
263
263
|
fallbackSegment: string
|
|
264
264
|
fallbackLoading: boolean
|
|
265
|
+
/** The open file is listed modified while its whole-file diff is empty —
|
|
266
|
+
* the line-ending phantom. The empty states then explain themselves
|
|
267
|
+
* instead of showing the generic "no text changes" a broken pane shows. */
|
|
268
|
+
phantomListed: boolean
|
|
265
269
|
/** Run one block action; a discard routes to the drawer's confirmation. */
|
|
266
270
|
onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
|
|
267
271
|
/** After a successful save: refresh the tree and the pane together. */
|
|
@@ -724,7 +728,7 @@ export function SideBySideView({ t, path, palette, wrap, statsPath, fetchSides,
|
|
|
724
728
|
/** The pane the drawer had before this view existed, notice included. */
|
|
725
729
|
const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
|
|
726
730
|
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} wrap={wrap} />
|
|
727
|
-
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
|
|
731
|
+
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : phantomListed ? t('phantomNotice') : t('noTextDiff')}</div>
|
|
728
732
|
|
|
729
733
|
if (failed) return unifiedFallback()
|
|
730
734
|
if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
|
|
@@ -923,7 +927,7 @@ export function SideBySideView({ t, path, palette, wrap, statsPath, fetchSides,
|
|
|
923
927
|
layout only decides how much width each side gets. */}
|
|
924
928
|
<div ref={scrollRef} className={css.sideScroll}>
|
|
925
929
|
{bodyState.kind === 'empty' ? (
|
|
926
|
-
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
930
|
+
<div className={css.empty}>{phantomListed ? t('phantomNotice') : t('noTextDiff')}</div>
|
|
927
931
|
) : (
|
|
928
932
|
<>
|
|
929
933
|
<div
|
|
@@ -76,6 +76,7 @@ import { NO_IGNORED_READS, type DirRead, type IgnoredCache } from './ignored-cac
|
|
|
76
76
|
import { useIdleValue } from './idle-value.ts'
|
|
77
77
|
import { emptyQueryFilter, parseLogQuery, serializeLogQuery } from './log-filter-query.ts'
|
|
78
78
|
import { nextAfterPlan, type DiscardAnswer, type DiscardPreview } from './discard-flow.ts'
|
|
79
|
+
import { isPhantomModified } from './diff-model.ts'
|
|
79
80
|
import { NO_PATHS, preferredFile } from './active-file.ts'
|
|
80
81
|
import type { LogFilter } from '../log-filter.ts'
|
|
81
82
|
import type { AuthorEntry } from '../shortlog.ts'
|
|
@@ -1554,6 +1555,11 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1554
1555
|
// content in the working tree and in every commit.
|
|
1555
1556
|
const activeKey = active === null ? null : `${viewKey}\x1f${active}`
|
|
1556
1557
|
const segment = bundled.length > 0 ? bundled : activeKey === null ? '' : fetched.get(activeKey) ?? ''
|
|
1558
|
+
/** The CRLF phantom: listed modified, whole-file diff empty (see
|
|
1559
|
+
* {@link isPhantomModified}). Changes-tab only — history and compare list
|
|
1560
|
+
* files from real ref diffs, where an empty segment means a failed fetch,
|
|
1561
|
+
* and the phantom notice would mislead. */
|
|
1562
|
+
const phantomListed = tab === 'changes' && isPhantomModified(activeFile?.status, segment)
|
|
1557
1563
|
|
|
1558
1564
|
// On-demand diff for files absent from the bundled payload (cap-truncated
|
|
1559
1565
|
// untracked files, oversize paths).
|
|
@@ -2134,6 +2140,13 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2134
2140
|
) : activeFile !== null && activeFile.previousPath !== undefined ? (
|
|
2135
2141
|
<div className={css.renameLine}>{t('renamedFrom')} <code>{activeFile.previousPath}</code></div>
|
|
2136
2142
|
) : null}
|
|
2143
|
+
{/* An empty compare result is usually the three-dot DIRECTION, not
|
|
2144
|
+
"no differences": A...B diffs from the fork point up to B, so a
|
|
2145
|
+
B that never moved past the fork shows nothing at all. Say so —
|
|
2146
|
+
an empty tree with no word reads as a broken one. */}
|
|
2147
|
+
{tab === 'compare' && comparable && shown !== null && body.files.length === 0 ? (
|
|
2148
|
+
<div className={css.empty}>{t('compareEmptyHint', { base: baseRef ?? '', head: headRef ?? '' })}</div>
|
|
2149
|
+
) : null}
|
|
2137
2150
|
{(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
|
|
2138
2151
|
: activeFile !== null && activeFile.binary ? (
|
|
2139
2152
|
<div className={css.empty}>{t('binaryFile')}</div>
|
|
@@ -2150,6 +2163,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2150
2163
|
gen={gen}
|
|
2151
2164
|
fallbackSegment={segment}
|
|
2152
2165
|
fallbackLoading={loading && segment.length === 0}
|
|
2166
|
+
phantomListed={phantomListed}
|
|
2153
2167
|
onBlockAction={askBlockAction}
|
|
2154
2168
|
onSaved={onRefresh}
|
|
2155
2169
|
onDirtyChange={onSideDirty}
|
package/src/client/diff-model.ts
CHANGED
|
@@ -19,6 +19,26 @@ export interface RowWithRanges extends Row {
|
|
|
19
19
|
ranges?: Array<readonly [number, number]>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Whether an empty per-file diff is the line-ending phantom.
|
|
24
|
+
*
|
|
25
|
+
* autocrlf / `eol` attributes make git's stat check and its clean filter
|
|
26
|
+
* disagree, so a file can sit in the tree as modified forever while every
|
|
27
|
+
* content diff comes back empty — the classic always-modified file of a
|
|
28
|
+
* Windows checkout. Opening it must explain that, not fall to the generic
|
|
29
|
+
* "no text changes" a broken pane also shows.
|
|
30
|
+
*
|
|
31
|
+
* Only `modified` + an empty WHOLE-FILE segment is the phantom: a fully
|
|
32
|
+
* staged file has an empty unstaged layer but a non-empty HEAD-diff (the
|
|
33
|
+
* reader is one click from the content), and every other status always has
|
|
34
|
+
* a diff or a synthesized one.
|
|
35
|
+
* @param status - the tree row's status for the open file.
|
|
36
|
+
* @param segment - the whole-file diff text (HEAD vs worktree).
|
|
37
|
+
*/
|
|
38
|
+
export function isPhantomModified(status: string | undefined, segment: string): boolean {
|
|
39
|
+
return status === 'modified' && segment.length === 0
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
/**
|
|
23
43
|
* Parse a unified diff segment into typed rows, tracking line numbers.
|
|
24
44
|
* @param segment - one file's `diff --git` text (headers are skipped).
|
package/src/client/locales.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type WorkbenchKey =
|
|
|
39
39
|
| 'filterDate' | 'filterToday' | 'filterLast7' | 'filterLast30' | 'filterAfter' | 'filterBefore'
|
|
40
40
|
| 'filterPaths' | 'filterPathsMore' | 'allBranches' | 'filterPathSearch'
|
|
41
41
|
| 'filterCalendarSets' | 'filterSelected' | 'filterLocale'
|
|
42
|
-
| 'compareBase' | 'compareHead' | 'comparePick' | 'compareCommits' | 'loadingCompare' | 'noBranches'
|
|
42
|
+
| 'compareBase' | 'compareHead' | 'comparePick' | 'compareCommits' | 'compareEmptyHint' | 'loadingCompare' | 'noBranches'
|
|
43
43
|
| 'refSearch' | 'refNone' | 'refCount' | 'refTruncated' | 'refWorktrees' | 'refBranches' | 'historyRefLabel'
|
|
44
44
|
| 'settings' | 'themeMode' | 'themePalette' | 'themeScope' | 'themeBackground' | 'themeCss'
|
|
45
45
|
| 'modeSystem' | 'modeLight' | 'modeDark'
|
|
@@ -66,7 +66,7 @@ export type WorkbenchKey =
|
|
|
66
66
|
// side-by-side editing: arm the editor, save, revert, the stale/conflict
|
|
67
67
|
// banner, the CRLF refusal notice, and the unsaved-changes prompt that
|
|
68
68
|
// guards every gesture dropping the buffer (tab, file, close)
|
|
69
|
-
| 'editFile' | 'fileSave' | 'fileRevert' | 'editingNotice' | 'crlfNotice' | 'encodingNotice'
|
|
69
|
+
| 'editFile' | 'fileSave' | 'fileRevert' | 'editingNotice' | 'crlfNotice' | 'encodingNotice' | 'phantomNotice'
|
|
70
70
|
// blame gutter on the working-tree column
|
|
71
71
|
| 'blameToggle' | 'blameHint' | 'blameUncommitted' | 'blameFailed' | 'blameTruncated'
|
|
72
72
|
| 'saveFailed' | 'saveUnavailable' | 'saveRetry'
|
|
@@ -118,6 +118,7 @@ export const zh: Record<WorkbenchKey, string> = {
|
|
|
118
118
|
compareHead: '对比',
|
|
119
119
|
comparePick: '选择两个不同的分支进行对比',
|
|
120
120
|
compareCommits: '{count} 个提交(自共同祖先起)',
|
|
121
|
+
compareEmptyHint: '{base}...{head} 比较的是自分叉点到 {head} 的变化,没有找到差异;想看另一方向的变化请交换两端。',
|
|
121
122
|
loadingCompare: '加载对比…',
|
|
122
123
|
noBranches: '没有可对比的分支',
|
|
123
124
|
refSearch: '筛选分支…',
|
|
@@ -299,6 +300,7 @@ export const zh: Record<WorkbenchKey, string> = {
|
|
|
299
300
|
blameTruncated: '文件过长,追溯信息只显示了前面一部分。',
|
|
300
301
|
crlfNotice: '这个文件的行尾是 CRLF,暂不支持在线编辑(编辑框会把行尾统一成 LF,保存时整份文件都会被改写);建议把行尾统一成 LF。差异里的回车已用 ␍ 标出;查看和按块暂存/撤回不受影响。',
|
|
301
302
|
encodingNotice: '这个文件不是 UTF-8 编码(可能是 GBK、Shift JIS 之类),暂不支持在线编辑:页面上看到的文字是一次有损解码,保存回去会把文件里每一个非 ASCII 字节都改写掉,包括你没动过的行。查看和按块暂存/撤回不受影响。',
|
|
303
|
+
phantomNotice: 'git 把这个文件列为已修改,但行尾归一化(CRLF / autocrlf / eol 属性)之后没有内容差异——这是 Windows 检出常见的「永远已修改」幻影,不是显示故障。统一行尾(建议把行尾统一成 LF)或让 git 重新比对后,这一条目就会消失。',
|
|
302
304
|
saveFailed: '保存失败',
|
|
303
305
|
saveUnavailable: '当前宿主还不支持保存(需要重启 dsh web 加载新版宿主端)。',
|
|
304
306
|
saveRetry: '重试保存',
|
|
@@ -363,6 +365,7 @@ export const en: Record<WorkbenchKey, string> = {
|
|
|
363
365
|
compareHead: 'Compare',
|
|
364
366
|
comparePick: 'Pick two different branches to compare',
|
|
365
367
|
compareCommits: '{count} commits since they diverged',
|
|
368
|
+
compareEmptyHint: '{base}...{head} compares from the fork point up to {head} and found no differences; to see what changed on the other side, swap the two ends.',
|
|
366
369
|
loadingCompare: 'Loading comparison…',
|
|
367
370
|
noBranches: 'No branches to compare',
|
|
368
371
|
refSearch: 'Filter branches…',
|
|
@@ -529,6 +532,7 @@ export const en: Record<WorkbenchKey, string> = {
|
|
|
529
532
|
blameTruncated: 'The file is long, so blame is shown for the first part only.',
|
|
530
533
|
crlfNotice: 'This file has CRLF line endings, which the editor does not support yet (the edit box would turn every ending into LF, so a save rewrites the whole file); normalising the endings to LF is recommended. Carriage returns are marked ␍ in the diff; viewing and block staging/rolling back still work.',
|
|
531
534
|
encodingNotice: 'This file is not UTF-8 (GBK, Shift JIS or similar), so the editor is unavailable: the text shown is a lossy decode of it, and saving that back would rewrite every non-ASCII byte in the file, including lines you never touched. Viewing and block staging/rolling back still work.',
|
|
535
|
+
phantomNotice: 'git lists this file as modified, but after line-ending normalisation (CRLF / autocrlf / eol attributes) there is no content difference — the classic "modified forever" phantom of a Windows checkout, not a broken view. Once the endings agree (normalising the endings to LF is recommended) the entry disappears.',
|
|
532
536
|
saveFailed: 'Save failed',
|
|
533
537
|
saveUnavailable: 'This host does not support saving yet — restart dsh web to load the new host half.',
|
|
534
538
|
saveRetry: 'Retry save',
|
package/src/index.ts
CHANGED
|
@@ -80,7 +80,7 @@ import {
|
|
|
80
80
|
type StyleEntry, type StyleFile,
|
|
81
81
|
} from './style-store.js'
|
|
82
82
|
import {
|
|
83
|
-
bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, sanitizeName, saveBindings, worktreeDir,
|
|
83
|
+
bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, resolveEnterBranch, sanitizeName, saveBindings, worktreeDir,
|
|
84
84
|
type BindingsFile, type WorktreeBinding, type WorktreeEntry, type WorktreeOpResult,
|
|
85
85
|
} from './worktree.js'
|
|
86
86
|
|
|
@@ -408,19 +408,21 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
408
408
|
ctx.tools.register(defineTool({
|
|
409
409
|
name: 'worktree_enter',
|
|
410
410
|
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name> — the directory is '
|
|
411
|
-
+ 'always derived from the name (there is no dir parameter), the branch
|
|
411
|
+
+ 'always derived from the name (there is no dir parameter), the branch defaults to the name '
|
|
412
|
+
+ '(or pass branch to choose one — unlike the name it may contain slashes, e.g. feature/foo), '
|
|
412
413
|
+ 'and the session is bound to it. After entering, address the worktree relatively from the session cwd: '
|
|
413
414
|
+ 'for shell commands pass workdir ".agents/worktrees/<name>" (per-call workdir is supported and resolved '
|
|
414
415
|
+ 'against the session cwd); for file tools use paths prefixed with .agents/worktrees/<name>/. '
|
|
415
416
|
+ 'Call with no name to auto-generate one. Use worktree_exit to leave.',
|
|
416
417
|
parameters: {
|
|
417
|
-
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name
|
|
418
|
+
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name doubles as the default branch — pass branch to split them (no prefix is added either way). 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.' },
|
|
419
|
+
branch: { type: 'string', description: 'Optional branch for a NEW worktree; defaults to the name. Unlike the name it may contain slashes (feature/foo) — the one spelling a directory name cannot express. Validated and REFUSED when illegal (never auto-generated); set aside — the worktree keeps its own branch, the hint says so — when the target directory already holds a registered worktree.' },
|
|
418
420
|
},
|
|
419
421
|
output: output(OP_SCHEMA),
|
|
420
|
-
execute: async (args: { name?: string }, exec: ToolRunContext) => {
|
|
422
|
+
execute: async (args: { name?: string; branch?: string }, exec: ToolRunContext) => {
|
|
421
423
|
const session = exec.agent?.session
|
|
422
424
|
if (session === undefined) return { ok: false, error: 'worktree tools require a calling session' }
|
|
423
|
-
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, exec.signal)
|
|
425
|
+
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, args?.branch, exec.signal)
|
|
424
426
|
},
|
|
425
427
|
presentCall: () => ({ card: 'generic', title: 'Enter worktree', kind: 'other' }),
|
|
426
428
|
}))
|
|
@@ -603,6 +605,12 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
603
605
|
}
|
|
604
606
|
const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal)
|
|
605
607
|
if (tracked.stdout.trim().length > 0) return { diff: tracked.stdout }
|
|
608
|
+
// Empty for a TRACKED file is real, not a missing diff: the line-ending
|
|
609
|
+
// phantom (autocrlf / eol attributes make the stat check and the clean
|
|
610
|
+
// filter disagree) lists such files modified forever while git itself
|
|
611
|
+
// finds no content difference. Synthesizing a new-file segment for one
|
|
612
|
+
// would paint a whole-file addition git does not see.
|
|
613
|
+
if (!await this.isUntracked(root, path, signal)) return { diff: '' }
|
|
606
614
|
return { diff: await untrackedSegment(root, path) ?? '' }
|
|
607
615
|
}
|
|
608
616
|
|
|
@@ -1333,9 +1341,26 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1333
1341
|
: { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited }
|
|
1334
1342
|
}
|
|
1335
1343
|
|
|
1336
|
-
/**
|
|
1344
|
+
/**
|
|
1345
|
+
* Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/`
|
|
1346
|
+
* and bind the session to it.
|
|
1347
|
+
*
|
|
1348
|
+
* The name is the identity — the directory — and the default branch.
|
|
1349
|
+
* branchName (optional) splits the two for the one spelling the name can
|
|
1350
|
+
* never express: a slash branch (`feature/foo` is a legal ref and an
|
|
1351
|
+
* impossible Windows directory). It applies to a FRESH create only — a
|
|
1352
|
+
* reused worktree keeps its own branch, and the hint says so when an
|
|
1353
|
+
* explicit request was set aside. An illegal branchName is refused, never
|
|
1354
|
+
* substituted: a branch is semantic in a way a directory label is not.
|
|
1355
|
+
* @param sessionId - session to bind to the worktree.
|
|
1356
|
+
* @param repoPath - caller's directory, used to locate the repository.
|
|
1357
|
+
* @param name - worktree name (directory + default branch), sanitized;
|
|
1358
|
+
* auto-generated when omitted or illegal.
|
|
1359
|
+
* @param branchName - branch for a fresh create; defaults to the name.
|
|
1360
|
+
* @param signal - abort signal.
|
|
1361
|
+
*/
|
|
1337
1362
|
@Remote('worktreeEnter')
|
|
1338
|
-
async worktreeEnter(sessionId: string, repoPath: string, name: string | undefined, signal: AbortSignal): Promise<WorktreeOpResult> {
|
|
1363
|
+
async worktreeEnter(sessionId: string, repoPath: string, name: string | undefined, branchName: string | undefined, signal: AbortSignal): Promise<WorktreeOpResult> {
|
|
1339
1364
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return { ok: false, error: 'sessionId is required' }
|
|
1340
1365
|
const cwd = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
|
|
1341
1366
|
const repoRoot = await this.repoRootOf(cwd, signal)
|
|
@@ -1365,13 +1390,16 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1365
1390
|
// started. Reuse paths recover it with merge-base instead (theirs is historical).
|
|
1366
1391
|
const headBefore = (await this.git(repoRoot, ['rev-parse', 'HEAD'], signal)).stdout.trim()
|
|
1367
1392
|
// The branch the session actually lands on: the reused worktree's own branch,
|
|
1368
|
-
//
|
|
1369
|
-
|
|
1393
|
+
// branchName when the caller asked for one (slashes allowed — a branch can
|
|
1394
|
+
// spell what a directory cannot), else the name VERBATIM — no forced prefix.
|
|
1395
|
+
const choice = resolveEnterBranch(wtName, branchName, existing?.branch)
|
|
1396
|
+
if (!choice.ok) return { ok: false, error: choice.error }
|
|
1397
|
+
const branch = choice.branch
|
|
1370
1398
|
let reusedWorktree = false
|
|
1371
1399
|
let reusedBranch = false
|
|
1372
1400
|
let baseCommit: string | undefined
|
|
1373
1401
|
if (existing === undefined) {
|
|
1374
|
-
const add = await this.git(repoRoot, ['worktree', 'add', '-b',
|
|
1402
|
+
const add = await this.git(repoRoot, ['worktree', 'add', '-b', branch, dir], signal)
|
|
1375
1403
|
if (add.exitCode === 0) {
|
|
1376
1404
|
baseCommit = headBefore.length > 0 ? headBefore : undefined
|
|
1377
1405
|
} else {
|
|
@@ -1379,11 +1407,11 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1379
1407
|
// commits), so a re-enter after remove finds the name present as a
|
|
1380
1408
|
// branch: verify the ref and check the existing branch out instead
|
|
1381
1409
|
// of failing on `-b`.
|
|
1382
|
-
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${
|
|
1410
|
+
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], signal)
|
|
1383
1411
|
if (verified.exitCode !== 0) {
|
|
1384
1412
|
return { ok: false, error: `git worktree add failed (exit ${add.exitCode})${add.stderr.length > 0 ? `: ${add.stderr}` : ''}` }
|
|
1385
1413
|
}
|
|
1386
|
-
const retry = await this.git(repoRoot, ['worktree', 'add', dir,
|
|
1414
|
+
const retry = await this.git(repoRoot, ['worktree', 'add', dir, branch], signal)
|
|
1387
1415
|
if (retry.exitCode !== 0) {
|
|
1388
1416
|
return { ok: false, error: `git worktree add failed (exit ${retry.exitCode})${retry.stderr.length > 0 ? `: ${retry.stderr}` : ''}` }
|
|
1389
1417
|
}
|
|
@@ -1412,7 +1440,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1412
1440
|
const rel = `.agents/worktrees/${wtName}`
|
|
1413
1441
|
return {
|
|
1414
1442
|
ok: true, worktreePath: dir, branch,
|
|
1415
|
-
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).` : ''}`,
|
|
1443
|
+
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.` : ''}${choice.branchOverridden ? ` Note: requested branch ${branchName} was not used; the reused worktree keeps its branch ${branch}.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
1416
1444
|
}
|
|
1417
1445
|
}
|
|
1418
1446
|
|
package/src/worktree.ts
CHANGED
|
@@ -183,6 +183,62 @@ export function isRefName(ref: string): boolean {
|
|
|
183
183
|
&& REF_CHARS.test(ref)
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The extra rules an ENTER branch must satisfy beyond {@link isRefName}.
|
|
188
|
+
*
|
|
189
|
+
* isRefName guards untrusted refs that reach git as positional arguments
|
|
190
|
+
* (leading `-`, `..`, alien characters). A branch worktreeEnter CREATES has
|
|
191
|
+
* two more classes of trouble: spellings check-ref-format refuses that
|
|
192
|
+
* REF_CHARS happens to pass (a leading or trailing dot, a `.lock` ending),
|
|
193
|
+
* and `head` — a legal ref on Linux that collides with HEAD on the
|
|
194
|
+
* case-insensitive filesystems the host runs on.
|
|
195
|
+
*/
|
|
196
|
+
function isEnterBranch(branch: string): boolean {
|
|
197
|
+
return isRefName(branch)
|
|
198
|
+
&& !branch.startsWith('.')
|
|
199
|
+
&& !branch.endsWith('.')
|
|
200
|
+
&& !branch.endsWith('.lock')
|
|
201
|
+
&& branch.toLowerCase() !== 'head'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Decide the branch a worktreeEnter call lands on.
|
|
206
|
+
*
|
|
207
|
+
* The worktree NAME is the identity knob — the directory under
|
|
208
|
+
* `.agents/worktrees/` — and doubles as the branch by default (the old
|
|
209
|
+
* contract, kept for callers that pass no branchName). branchName splits the
|
|
210
|
+
* two for the one thing the name can never express: a SLASH branch
|
|
211
|
+
* (`feature/foo` is a legal ref and an impossible Windows directory).
|
|
212
|
+
*
|
|
213
|
+
* Reuse keeps the registered worktree's own branch, request or no request;
|
|
214
|
+
* an explicit branchName it displaces is reported as branchOverridden so the
|
|
215
|
+
* hint can say so — refusing there would break enter's idempotency (the same
|
|
216
|
+
* call re-issued must rebind, not explode).
|
|
217
|
+
*
|
|
218
|
+
* An illegal branchName is REFUSED, never substituted: the worktree name may
|
|
219
|
+
* be auto-generated because a directory label is arbitrary, but a branch is
|
|
220
|
+
* semantic — silently renaming it lands work on the wrong branch.
|
|
221
|
+
* @param wtName - sanitized worktree name (the default branch).
|
|
222
|
+
* @param branchName - caller-requested branch, or undefined for the default.
|
|
223
|
+
* @param existingBranch - the registered worktree's own branch when the
|
|
224
|
+
* target directory already holds one, else undefined.
|
|
225
|
+
* @returns the branch to create or keep, plus whether an explicit request
|
|
226
|
+
* was set aside — or the refusal error.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveEnterBranch(
|
|
229
|
+
wtName: string,
|
|
230
|
+
branchName: string | undefined,
|
|
231
|
+
existingBranch: string | undefined,
|
|
232
|
+
): { ok: true; branch: string; branchOverridden: boolean } | { ok: false; error: string } {
|
|
233
|
+
if (branchName !== undefined && !isEnterBranch(branchName)) {
|
|
234
|
+
return { ok: false, error: 'branchName is not a valid branch name' }
|
|
235
|
+
}
|
|
236
|
+
if (existingBranch !== undefined) {
|
|
237
|
+
return { ok: true, branch: existingBranch, branchOverridden: branchName !== undefined && branchName !== existingBranch }
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, branch: branchName ?? wtName, branchOverridden: false }
|
|
240
|
+
}
|
|
241
|
+
|
|
186
242
|
export function worktreeDir(repoRoot: string, name: string): string {
|
|
187
243
|
return `${repoRoot.replace(/\/+$/, '')}/.agents/worktrees/${name}`
|
|
188
244
|
}
|