@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/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, branchFor, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
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
- return `This session is bound to git worktree "${binding.name}" (branch ${branchFor(binding.name)}).\n`
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> on branch wt/<name> '
294
- + 'and bind this session to it. After entering, address the worktree relatively from the session cwd: '
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 ([A-Za-z0-9._-], max 40). Auto-generated when omitted.' },
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
- // Directory already a registered worktree -> reuse it, no second `add`.
656
- const existing = parseWorktreeList((await this.git(repoRoot, ['worktree', 'list', '--porcelain'], signal)).stdout)
657
- .find(entry => entry.path.replace(/\\/g, '/') === dir);
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', branchFor(wtName), dir], signal);
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 wt/<name> (exit never deletes it it may
670
- // carry unmerged commits), so a re-enter after remove finds the branch present:
671
- // verify the ref and check the existing branch out instead of failing on `-b`.
672
- const branch = branchFor(wtName);
673
- const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], signal);
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, branch], signal);
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', branchFor(wtName), 'HEAD'], signal);
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: branchFor(wtName),
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 ${branchFor(wtName)} (carries its prior commits).` : ''}`,
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
- return base === undefined || (typeof base === 'string' && base.length > 0);
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
- const NAME_PATTERN = /^[A-Za-z0-9._-]{1,40}$/;
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 && NAME_PATTERN.test(raw) && raw !== '.' && raw !== '..')
81
+ if (raw !== undefined && isWorktreeName(raw))
51
82
  return raw;
52
- return `wt-${rng()}`;
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.0",
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(() => {
@@ -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
- * dsh derives one from the other `branchFor` in `src/worktree.ts` turns the
139
- * name `demo` into the branch `wt/demo` so for every worktree dsh made, the
140
- * session card was printing the same word twice, once as `wt/fixture-03` and
141
- * once as a `fixture-03` badge next to it. A worktree made outside dsh has no
142
- * such relation, and there the badge is the only thing naming the directory.
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, branchFor, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir,
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
- return `This session is bound to git worktree "${binding.name}" (branch ${branchFor(binding.name)}).\n`
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> on branch wt/<name> '
282
- + 'and bind this session to it. After entering, address the worktree relatively from the session cwd: '
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 ([A-Za-z0-9._-], max 40). Auto-generated when omitted.' },
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
- // Directory already a registered worktree -> reuse it, no second `add`.
652
- const existing = parseWorktreeList((await this.git(repoRoot, ['worktree', 'list', '--porcelain'], signal)).stdout)
653
- .find(entry => entry.path.replace(/\\/g, '/') === dir)
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', branchFor(wtName), dir], signal)
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 wt/<name> (exit never deletes it it may
665
- // carry unmerged commits), so a re-enter after remove finds the branch present:
666
- // verify the ref and check the existing branch out instead of failing on `-b`.
667
- const branch = branchFor(wtName)
668
- const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], signal)
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, branch], signal)
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', branchFor(wtName), 'HEAD'], signal)
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: branchFor(wtName),
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 ${branchFor(wtName)} (carries its prior commits).` : ''}`,
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
- return base === undefined || (typeof base === 'string' && base.length > 0)
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
- const NAME_PATTERN = /^[A-Za-z0-9._-]{1,40}$/
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 && NAME_PATTERN.test(raw) && raw !== '.' && raw !== '..') return raw
89
- return `wt-${rng()}`
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
- export function branchFor(name: string): string { return `wt/${name}` }
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