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/AGENTS.md +120 -0
- package/CHANGELOG.md +266 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.zh.md +151 -0
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +30 -0
- package/docs/DESIGN.md +783 -0
- package/docs/TASKS.md +164 -0
- package/lib/config.js +126 -0
- package/lib/engine-face.js +328 -0
- package/lib/git-port.js +773 -0
- package/lib/index.js +402 -0
- package/lib/merge-queue.js +832 -0
- package/lib/naming.js +107 -0
- package/lib/repo-gate.js +202 -0
- package/lib/state-store.js +512 -0
- package/lib/tools/worktree-cleanup.js +127 -0
- package/lib/tools/worktree-create.js +212 -0
- package/lib/tools/worktree-list.js +234 -0
- package/lib/tools/worktree-merge.js +396 -0
- package/lib/tools/worktree-queue.js +330 -0
- package/lib/tools/worktree-status.js +194 -0
- package/lib/worktree-service.js +673 -0
- package/package.json +55 -0
- package/scripts/link-harness-dsh-tools.sh +95 -0
- package/scripts/lint.js +142 -0
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
|
+
|
package/lib/repo-gate.js
ADDED
|
@@ -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
|
+
}
|