@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/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 is the name VERBATIM, '
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 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.' },
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
- /** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
1281
- async worktreeEnter(sessionId, repoPath, name, signal) {
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
- // or the name VERBATIM for a fresh createno forced prefix.
1309
- let branch = existing?.branch ?? wtName;
1334
+ // branchName when the caller asked for one (slashes alloweda 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', wtName, dir], signal);
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/${wtName}`], signal);
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, wtName], signal);
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.16",
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",
@@ -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}
@@ -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).
@@ -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 is the name VERBATIM, '
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 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.' },
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
- /** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
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
- // or the name VERBATIM for a fresh createno forced prefix.
1369
- let branch = existing?.branch ?? wtName
1393
+ // branchName when the caller asked for one (slashes alloweda 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', wtName, dir], signal)
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/${wtName}`], signal)
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, wtName], signal)
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
  }