dsh-plugin-worktrees 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/naming.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Naming — pure naming helpers for branch names and worktree paths.
3
+ *
4
+ * No IO. All functions are pure and deterministic. Layout and naming follow
5
+ * DESIGN.md §5.2.1:
6
+ * - task branch : `dsh-wt/<session-short>/<task-slug>`
7
+ * - integration : `dsh-wt/integration/<session-short>`
8
+ * - worktree path : `<root>/<repoKey>/<session-short>/<task-slug>`
9
+ * - integration worktree path: `<root>/<repoKey>/.integration/<branch-sanitized>-<jobId>`
10
+ *
11
+ * `repoIdFromRoot` is a line-for-line port of `repoIdFromRoot` in
12
+ * task-weaver `packages/workspaces/src/workspace-service.ts` L682-690
13
+ * (FNV-1a to an 8-hex string; backslash normalisation + trailing-slash strip;
14
+ * empty-path fallback = the empty FNV-1a result `811c9dc5`).
15
+ */
16
+
17
+ import path from 'node:path'
18
+
19
+ const BRANCH_MAX_LEN = 48
20
+ const SESSION_SHORT_LEN = 8
21
+
22
+ /**
23
+ * Sanitise a task slug into a branch-safe token.
24
+ * `[^a-zA-Z0-9_.-]` → `_`, then truncate to {@link BRANCH_MAX_LEN} chars.
25
+ * Semantics mirror the char-whitelist idea of merge-queue.ts `sanitize`
26
+ * (L174-176), length-tuned for branch names (DESIGN §5.2.1).
27
+ *
28
+ * @param {string} task raw task slug
29
+ * @returns {string} sanitised, truncated token
30
+ */
31
+ export function sanitizeBranch(task) {
32
+ const tokens = String(task).replace(/[^a-zA-Z0-9_.-]/g, '_')
33
+ return tokens.slice(0, BRANCH_MAX_LEN)
34
+ }
35
+
36
+ /** Short stable segment of a session id: the last 8 chars. */
37
+ export function sessionShortOf(sessionId) {
38
+ if (typeof sessionId !== 'string') {
39
+ throw new TypeError(`sessionId must be a string, got ${typeof sessionId}`)
40
+ }
41
+ return sessionId.slice(-SESSION_SHORT_LEN)
42
+ }
43
+
44
+ /** Per-task work branch: `dsh-wt/<sessionShort>/<sanitized task>`. */
45
+ export function taskBranch(sessionShort, task) {
46
+ return `dsh-wt/${sessionShort}/${sanitizeBranch(task)}`
47
+ }
48
+
49
+ /** Per-session integration branch: `dsh-wt/integration/<sessionShort>`. */
50
+ export function integrationBranch(sessionShort) {
51
+ return `dsh-wt/integration/${sessionShort}`
52
+ }
53
+
54
+ /**
55
+ * Physical worktree path: `<root>/<repoKey>/<sessionShort>/<taskSlug>`.
56
+ * Built entirely with node:path.join (DESIGN red line 2).
57
+ *
58
+ * @param {string} worktreeRoot base directory (e.g. `~/.dsh/worktrees/`)
59
+ * @param {string} repoKey stable repo id from {@link repoIdFromRoot}
60
+ * @param {string} sessionShort short session segment
61
+ * @param {string} taskSlug branch-safe task slug (already sanitised)
62
+ * @returns {string} joined path
63
+ */
64
+ export function worktreePath(worktreeRoot, repoKey, sessionShort, taskSlug) {
65
+ return path.join(worktreeRoot, repoKey, sessionShort, taskSlug)
66
+ }
67
+
68
+ /**
69
+ * Temporary integration worktree path for one apply:
70
+ * `<root>/<repoKey>/.integration/<branchSanitized>-<jobId>`.
71
+ * Uniqueness borrows the `Date.now().toString(36) + random` idea of the source
72
+ * worktreePath (L638-641) but uses the deterministic jobId (DESIGN §5.2.1).
73
+ */
74
+ export function integrationWorktreePath(
75
+ worktreeRoot,
76
+ repoKey,
77
+ branchSanitized,
78
+ jobId,
79
+ ) {
80
+ return path.join(worktreeRoot, repoKey, '.integration', `${branchSanitized}-${jobId}`)
81
+ }
82
+
83
+ /**
84
+ * Derive a short stable repo id from an absolute project root. Uses a
85
+ * truncated FNV-1a hash of the normalised path so the worktree parent
86
+ * directory is stable across runs but does not leak the full path.
87
+ *
88
+ * Ported verbatim from task-weaver
89
+ * `packages/workspaces/src/workspace-service.ts` L682-690. Backslashes are
90
+ * normalised to `/`, a single trailing slash is stripped, then FNV-1a
91
+ * (init `0x811c9dc5`, prime `0x01000193`) over UTF-16 char codes, truncated
92
+ * to 8 lowercase hex chars. Empty/whitespace-only path yields the empty FNV-1a
93
+ * initial value `811c9dc5`.
94
+ *
95
+ * @param {string} root absolute root path
96
+ * @returns {string} 8-char hex repo id
97
+ */
98
+ export function repoIdFromRoot(root) {
99
+ const normalised = root.replace(/\\/g, '/').replace(/\/+$/, '')
100
+ let hash = 0x811c9dc5
101
+ for (let i = 0; i < normalised.length; i++) {
102
+ hash ^= normalised.charCodeAt(i)
103
+ hash = (hash * 0x01000193) >>> 0
104
+ }
105
+ return hash.toString(16).padStart(8, '0')
106
+ }
107
+
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Repo root resolution + registration gate (DESIGN.md §5.2.0).
3
+ *
4
+ * SECURITY-CRITICAL: every worktree tool funnels the caller-supplied repo
5
+ * root through {@link resolveRepoRoot} before touching git. The gate
6
+ * canonicalises the path (realpath — symlinks and `..` segments collapse to
7
+ * the real on-disk location, same normalization as the aionui-panel
8
+ * workspace gate `gate.ts` L57-60), verifies it is a git work tree via the
9
+ * injected GitPort, and only then admits it when it lives inside the parent
10
+ * session cwd subtree, a registered workspace path, or an explicitly
11
+ * configured allowed root. Everything else fails closed.
12
+ *
13
+ * There is NO any-root switch by design (DESIGN §5.2.0: fail closed is the
14
+ * default; widening the gate must go through the explicit `allowedRoots`
15
+ * list).
16
+ */
17
+
18
+ import { realpath } from 'node:fs/promises'
19
+ import path from 'node:path'
20
+ import { repoIdFromRoot } from './naming.js'
21
+
22
+ /** Separator used after normalization (win32 backslashes fold onto this). */
23
+ const SEP = '/'
24
+
25
+ /**
26
+ * Canonical prefix check: `child` must live inside (or equal) `parent`.
27
+ *
28
+ * Ported from the aionui-panel workspace gate (`gate.ts` `isPathInside`
29
+ * L28-41) with the DESIGN §5.2.0 adjustment: both sides go through
30
+ * `path.normalize` first, then trailing separators are stripped, and on
31
+ * win32 backslashes fold onto `/` (path.join yields backslashes while git
32
+ * `rev-parse --show-toplevel` may yield forward slashes) and the comparison
33
+ * is case-insensitive (the Windows filesystem is case-insensitive). On POSIX
34
+ * the backslash is a legal filename character, so it is compared literally —
35
+ * the fold only happens on win32.
36
+ *
37
+ * Boundary semantics:
38
+ * - `parent === child` → true (repo root == cwd is a legal scenario);
39
+ * - a prefix match must land on a directory boundary — `/a/b` does NOT
40
+ * contain `/a/bc` (sibling), only `/a/b/...`;
41
+ * - trailing separators are normalized on both sides (`/a/b/` ≡ `/a/b`).
42
+ *
43
+ * @param {string} parent candidate container path
44
+ * @param {string} child candidate contained path
45
+ * @returns {boolean} true when child is parent or lives under parent
46
+ */
47
+ export function isPathInside(parent, child) {
48
+ if (typeof parent !== 'string' || typeof child !== 'string') {
49
+ return false
50
+ }
51
+ if (parent === '' || child === '') return false
52
+ const norm = (value) => {
53
+ let n = path.normalize(value)
54
+ if (process.platform === 'win32') {
55
+ n = n.replaceAll('\\', SEP)
56
+ }
57
+ return n.replace(/\/+$/, '')
58
+ }
59
+ const p = norm(parent)
60
+ const c = norm(child)
61
+ if (process.platform === 'win32') {
62
+ const a = p.toLowerCase()
63
+ const b = c.toLowerCase()
64
+ if (b === a) return true
65
+ return b.startsWith(a + SEP)
66
+ }
67
+ if (c === p) return true
68
+ return c.startsWith(p + SEP)
69
+ }
70
+
71
+ /**
72
+ * realpath that never throws: an unresolvable root NEVER grants access
73
+ * (fail closed), it is simply skipped as a gate candidate.
74
+ *
75
+ * @param {unknown} p candidate path (type-vetted)
76
+ * @returns {Promise<string|null>} canonical path, or null when unresolvable
77
+ */
78
+ async function realpathBestEffort(p) {
79
+ if (typeof p !== 'string' || p === '') return null
80
+ try {
81
+ return await realpath(p)
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Accept an array or a single value; anything else becomes an empty list.
89
+ *
90
+ * @template T
91
+ * @param {T | T[] | undefined | null} v
92
+ * @returns {T[]}
93
+ */
94
+ function toList(v) {
95
+ if (v === undefined || v === null) return []
96
+ if (Array.isArray(v)) return v
97
+ return [v]
98
+ }
99
+
100
+ /**
101
+ * Resolve and gate a repo root (DESIGN §5.2.0).
102
+ *
103
+ * Order of checks — each failure mode has a stable error-code prefix the
104
+ * tool layer matches on:
105
+ * 1. `repoArg` missing → fall back to `sessionCwd`; both missing →
106
+ * `repo_unresolved` (demand an explicit repo_root);
107
+ * 2. `realpath(repoArg)` failure → `repo_unknown` (path does not resolve
108
+ * on disk);
109
+ * 3. `git.isGitRepo(canonical)` false → `not_a_git_repo` (checked BEFORE
110
+ * the gate so a plain directory inside an allowed subtree is still
111
+ * rejected);
112
+ * 4. gate, in order, any pass admits:
113
+ * a. canonical inside the `sessionCwd` subtree,
114
+ * b. canonical inside any `workspacePaths` entry,
115
+ * c. canonical inside any `allowedRoots` entry;
116
+ * 5. no pass → `repo_not_registered` (report canonical + guidance:
117
+ * register a workspace or configure allowedRoots).
118
+ *
119
+ * Git access goes ONLY through the injected `git` port (`isGitRepo`); this
120
+ * module never spawns git itself.
121
+ *
122
+ * @param {{
123
+ * repoArg?: string,
124
+ * sessionCwd?: string,
125
+ * workspacePaths?: string[],
126
+ * allowedRoots?: string[],
127
+ * git?: { isGitRepo: (cwd: string) => Promise<boolean> },
128
+ * }} opts
129
+ * @returns {Promise<{ canonical: string, repoKey: string }>} canonical repo
130
+ * root + stable 8-hex repo key (same repo → same key across runs;
131
+ * worktrees share the parent repo key)
132
+ * @throws {Error} message starts with `repo_unresolved` / `repo_unknown` /
133
+ * `not_a_git_repo` / `repo_not_registered`
134
+ */
135
+ export async function resolveRepoRoot(opts = {}) {
136
+ const { repoArg, sessionCwd, workspacePaths, allowedRoots, git } = opts
137
+
138
+ // 1. repoArg 缺省 → sessionCwd;仍缺 → repo_unresolved。
139
+ let target = repoArg
140
+ if (target === undefined || target === null || target === '') {
141
+ target = sessionCwd
142
+ }
143
+ if (target === undefined || target === null || target === '') {
144
+ throw new Error(
145
+ 'repo_unresolved: no repo_root argument and no session cwd available; ' +
146
+ 'pass an explicit repo_root (the repository root path)',
147
+ )
148
+ }
149
+
150
+ // 2. 符号链接 / `..` 规范化;磁盘上不存在 → repo_unknown。
151
+ let canonical
152
+ try {
153
+ canonical = await realpath(target)
154
+ } catch {
155
+ throw new Error(`repo_unknown: path does not resolve on disk: ${target}`)
156
+ }
157
+
158
+ // git port 是唯一 git 入口;缺席即编程错误,fail closed。
159
+ if (!git || typeof git.isGitRepo !== 'function') {
160
+ throw new Error(
161
+ 'repo_gate: a git port with isGitRepo(cwd) must be injected',
162
+ )
163
+ }
164
+
165
+ // 3. 门禁前先查 git:普通目录即使落在允许子树内也要拒绝。
166
+ const isRepo = await git.isGitRepo(canonical)
167
+ if (!isRepo) {
168
+ throw new Error(`not_a_git_repo: ${canonical} is not inside a git work tree`)
169
+ }
170
+
171
+ // 4. 门禁按序判定(任一通过即可)。
172
+ // a. 父会话 cwd 子树(cwd 同样 realpath,best-effort)。
173
+ if (typeof sessionCwd === 'string' && sessionCwd !== '') {
174
+ const cwdCanonical = await realpathBestEffort(sessionCwd)
175
+ if (cwdCanonical !== null && isPathInside(cwdCanonical, canonical)) {
176
+ return { canonical, repoKey: repoIdFromRoot(canonical) }
177
+ }
178
+ }
179
+
180
+ // b. 任一注册 workspace 路径包含 canonical。
181
+ for (const workspacePath of toList(workspacePaths)) {
182
+ const rootCanonical = await realpathBestEffort(workspacePath)
183
+ if (rootCanonical !== null && isPathInside(rootCanonical, canonical)) {
184
+ return { canonical, repoKey: repoIdFromRoot(canonical) }
185
+ }
186
+ }
187
+
188
+ // c. 任一 allowedRoots 根包含 canonical。
189
+ for (const allowedRoot of toList(allowedRoots)) {
190
+ const rootCanonical = await realpathBestEffort(allowedRoot)
191
+ if (rootCanonical !== null && isPathInside(rootCanonical, canonical)) {
192
+ return { canonical, repoKey: repoIdFromRoot(canonical) }
193
+ }
194
+ }
195
+
196
+ // 5. 全不通过 → fail closed(无 any-root 开关)。
197
+ throw new Error(
198
+ `repo_not_registered: ${canonical} is not inside the session cwd ` +
199
+ 'subtree, a registered workspace path, or an allowed root; register ' +
200
+ 'the repository as a workspace or add its parent to allowedRoots',
201
+ )
202
+ }