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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorktreeService — worktree-mode CRUD (DESIGN §5.2-§5.5, §5.8).
|
|
3
|
+
*
|
|
4
|
+
* Owns the create / list / status / cleanup lifecycle of task worktrees on
|
|
5
|
+
* top of two injected collaborators:
|
|
6
|
+
* - `git` : a GitPort (lib/git-port.js) — the ONLY git entry point;
|
|
7
|
+
* - `store` : a StateStore (lib/state-store.js) — the ONLY persistence.
|
|
8
|
+
*
|
|
9
|
+
* Ported semantics from task-weaver
|
|
10
|
+
* `packages/workspaces/src/workspace-service.ts`:
|
|
11
|
+
* - createWorktree flow (L244-360): validate name → duplicate check →
|
|
12
|
+
* resolve base → create worktree → persist record;
|
|
13
|
+
* - the mutable-HEAD prohibition (L268-284 "saved Run base ... is
|
|
14
|
+
* required; current HEAD fallback is forbidden") — the stored
|
|
15
|
+
* `baseCommit` is ALWAYS the concrete oid resolved by
|
|
16
|
+
* `git.resolveRef`, never a symbolic ref;
|
|
17
|
+
* - the physical path layout (L650-661) — re-expressed through
|
|
18
|
+
* lib/naming.js `worktreePath` (`<root>/<repoKey>/<session>/<slug>`).
|
|
19
|
+
*
|
|
20
|
+
* Error shape: every failure throws `Error('<code> — <detail>')` where code
|
|
21
|
+
* is the DESIGN §5.3/§5.8 error family (`invalid_task_slug`,
|
|
22
|
+
* `invalid_integration_branch`, `branch_exists`, `max_worktrees_reached`,
|
|
23
|
+
* `invalid_base_ref`, `worktree_creation_failed`, `worktree_not_found`,
|
|
24
|
+
* `cleanup_protected`).
|
|
25
|
+
* The tool layer (T07) prefixes the tool name; the codes stay machine
|
|
26
|
+
* matchable. Git stderr embedded into a detail is truncated to 600 chars.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomBytes } from 'node:crypto'
|
|
30
|
+
import { existsSync, mkdirSync, realpathSync } from 'node:fs'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
import {
|
|
33
|
+
integrationBranch as defaultIntegrationBranch,
|
|
34
|
+
repoIdFromRoot,
|
|
35
|
+
sanitizeBranch,
|
|
36
|
+
sessionShortOf,
|
|
37
|
+
taskBranch,
|
|
38
|
+
worktreePath,
|
|
39
|
+
} from './naming.js'
|
|
40
|
+
import { ACTIVE_JOB_STATES } from './state-store.js'
|
|
41
|
+
|
|
42
|
+
/** Truncation bound for git stderr / error details in thrown messages. */
|
|
43
|
+
const DETAIL_MAX = 600
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Worktree record states that end the record's lifecycle (DESIGN §5.2.2).
|
|
47
|
+
* A NEW record with the same branch may be created once the old record is
|
|
48
|
+
* terminal; terminal records also stop counting against maxWorktrees.
|
|
49
|
+
*/
|
|
50
|
+
const TERMINAL_WORKTREE_STATES = new Set(['merged', 'abandoned', 'vanished'])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* States counted against `config.maxWorktrees` (DESIGN §6.1: the limit is a
|
|
54
|
+
* GLOBAL count across repos). `conflicted` worktrees are retained scenes,
|
|
55
|
+
* not occupied isolation slots, so they do not count.
|
|
56
|
+
*/
|
|
57
|
+
const COUNTED_WORKTREE_STATES = new Set(['active', 'merging'])
|
|
58
|
+
|
|
59
|
+
/** Fail loud with a machine-matchable code prefix. */
|
|
60
|
+
function fail(code, detail) {
|
|
61
|
+
throw new Error(`${code} — ${detail}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Clamp a detail string to DETAIL_MAX chars with an explicit marker. */
|
|
65
|
+
function clampDetail(text, max = DETAIL_MAX) {
|
|
66
|
+
const value = String(text ?? '')
|
|
67
|
+
if (value.length <= max) return value
|
|
68
|
+
return `${value.slice(0, max)}…[truncated ${value.length - max} chars]`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** realpath() that yields null instead of throwing (missing paths probe). */
|
|
72
|
+
function realPathBestEffort(target) {
|
|
73
|
+
try {
|
|
74
|
+
return realpathSync(target)
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Fresh worktree record id: `wt_` + 8 hex chars (crypto.randomBytes). */
|
|
81
|
+
function newWorktreeId() {
|
|
82
|
+
return `wt_${randomBytes(4).toString('hex')}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create a WorktreeService.
|
|
87
|
+
*
|
|
88
|
+
* @param {{
|
|
89
|
+
* git: import('./git-port.js').NodeGitPort,
|
|
90
|
+
* store: ReturnType<import('./state-store.js').createStateStore>,
|
|
91
|
+
* config: { worktreeRoot: string, maxWorktrees?: number, defaultBaseRef?: string },
|
|
92
|
+
* }} deps
|
|
93
|
+
* `git` and `store` are injected (tests / future host seams); `config`
|
|
94
|
+
* carries the RESOLVED final values — defaults are applied here only as
|
|
95
|
+
* defensive fallbacks (DESIGN §9 defaults: maxWorktrees 16,
|
|
96
|
+
* defaultBaseRef 'HEAD').
|
|
97
|
+
* @returns {{
|
|
98
|
+
* create: (params: {
|
|
99
|
+
* task: string, repoRoot: string, repoKey?: string,
|
|
100
|
+
* baseRef?: string, integrationBranch?: string,
|
|
101
|
+
* note?: string, sessionId: string,
|
|
102
|
+
* origin?: 'tool'|'dag', correlationId?: string,
|
|
103
|
+
* }) => Promise<import('./state-store.js').WorktreeRecord>,
|
|
104
|
+
* list: (params?: { repoRoot?: string }) => Promise<{
|
|
105
|
+
* worktrees: Array<Record<string, unknown>>,
|
|
106
|
+
* queueSummary: { activeJobs: Array<Record<string, unknown>>, queuedCount: number },
|
|
107
|
+
* }>,
|
|
108
|
+
* status: (idOrPath: string) => Promise<Record<string, unknown>>,
|
|
109
|
+
* cleanup: (params: {
|
|
110
|
+
* idOrPath: string, force?: boolean, acknowledge?: boolean, keepBranch?: boolean,
|
|
111
|
+
* }) => Promise<{ id: string, removedWorktree: boolean, removedBranch: boolean, note?: string }>,
|
|
112
|
+
* findActiveByTask: (repoRoot: string, task: string) => Promise<import('./state-store.js').WorktreeRecord|null>,
|
|
113
|
+
* }}
|
|
114
|
+
*/
|
|
115
|
+
export function createWorktreeService({ git, store, config } = {}) {
|
|
116
|
+
if (!git || typeof git.createWorktree !== 'function') {
|
|
117
|
+
throw new Error('worktree-service: a GitPort must be injected (missing createWorktree)')
|
|
118
|
+
}
|
|
119
|
+
if (!store || typeof store.upsertWorktree !== 'function') {
|
|
120
|
+
throw new Error('worktree-service: a StateStore must be injected (missing upsertWorktree)')
|
|
121
|
+
}
|
|
122
|
+
if (!config || typeof config.worktreeRoot !== 'string' || config.worktreeRoot.length === 0) {
|
|
123
|
+
throw new Error('worktree-service: config.worktreeRoot (resolved) is required')
|
|
124
|
+
}
|
|
125
|
+
const maxWorktrees =
|
|
126
|
+
Number.isFinite(config.maxWorktrees) && config.maxWorktrees > 0
|
|
127
|
+
? Math.floor(config.maxWorktrees)
|
|
128
|
+
: 16
|
|
129
|
+
const defaultBaseRef =
|
|
130
|
+
typeof config.defaultBaseRef === 'string' && config.defaultBaseRef.length > 0
|
|
131
|
+
? config.defaultBaseRef
|
|
132
|
+
: 'HEAD'
|
|
133
|
+
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
// Internal helpers
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** Find a record by exact id OR exact stored path (DESIGN §5.5/§5.8 lookups). */
|
|
139
|
+
function findRecord(idOrPath) {
|
|
140
|
+
if (typeof idOrPath !== 'string' || idOrPath === '') return undefined
|
|
141
|
+
const byId = store.worktrees[idOrPath]
|
|
142
|
+
if (byId !== undefined) return byId
|
|
143
|
+
return Object.values(store.worktrees).find((record) => record.path === idOrPath)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Live worktree paths for one repo root, as a Set (git reports realpaths).
|
|
148
|
+
* `null` = the probe itself failed (repo gone / git error) — callers must
|
|
149
|
+
* NOT interpret a probe failure as orphaning.
|
|
150
|
+
*/
|
|
151
|
+
async function livePathsFor(repoRoot) {
|
|
152
|
+
try {
|
|
153
|
+
const entries = await git.listWorktrees(repoRoot)
|
|
154
|
+
return new Set(entries.map((entry) => entry.path))
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True when a stored path matches the live set (raw OR realpath form). */
|
|
161
|
+
function pathInLiveSet(storedPath, liveSet) {
|
|
162
|
+
if (liveSet.has(storedPath)) return true
|
|
163
|
+
const real = realPathBestEffort(storedPath)
|
|
164
|
+
return real !== null && liveSet.has(real)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A record is LIVE when its directory exists on disk AND git still lists
|
|
169
|
+
* it as a worktree. Anything else is vanished/orphaned (crash or manual
|
|
170
|
+
* removal) — cleanup prunes the record without touching git.
|
|
171
|
+
*/
|
|
172
|
+
async function recordIsLive(record) {
|
|
173
|
+
if (!existsSync(record.path)) return false
|
|
174
|
+
const liveSet = await livePathsFor(record.repoRoot)
|
|
175
|
+
if (liveSet === null) return true // probe failed — trust the disk
|
|
176
|
+
return pathInLiveSet(record.path, liveSet)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Minimal queue summary projection for list() (DESIGN §5.4 queue_summary).
|
|
181
|
+
* T08 (MergeQueue) owns the real semantics; this projection reads the
|
|
182
|
+
* same store, so it stays correct by construction.
|
|
183
|
+
*/
|
|
184
|
+
function queueSummary() {
|
|
185
|
+
const jobs = Object.values(store.jobs)
|
|
186
|
+
const activeJobs = jobs
|
|
187
|
+
.filter((job) => ACTIVE_JOB_STATES.has(job.state))
|
|
188
|
+
.map((job) => ({
|
|
189
|
+
jobId: job.id,
|
|
190
|
+
repoRoot: store.repos[job.repoKey]?.root ?? null,
|
|
191
|
+
integrationBranch: job.integrationBranch,
|
|
192
|
+
state: job.state,
|
|
193
|
+
}))
|
|
194
|
+
const queuedCount = jobs.filter((job) => job.state === 'queued').length
|
|
195
|
+
return { activeJobs, queuedCount }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Advisory slug suggestion for a branch_exists collision: `<slug>-2`,
|
|
200
|
+
* `<slug>-3`, … skipping branches already recorded (non-terminal) for
|
|
201
|
+
* this repo, falling back to a random suffix.
|
|
202
|
+
*/
|
|
203
|
+
function suggestSlug(baseSlug, sessionShort, takenBranches) {
|
|
204
|
+
for (let n = 2; n <= 32; n += 1) {
|
|
205
|
+
const candidate = `${baseSlug}-${n}`
|
|
206
|
+
if (!takenBranches.has(taskBranch(sessionShort, candidate))) return candidate
|
|
207
|
+
}
|
|
208
|
+
return `${baseSlug}-${randomBytes(2).toString('hex')}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -------------------------------------------------------------------------
|
|
212
|
+
// create (DESIGN §5.3)
|
|
213
|
+
// -------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Create a task worktree: validate the slug → duplicate check →
|
|
217
|
+
* maxWorktrees gate → resolve base to a CONCRETE oid → `git worktree
|
|
218
|
+
* add` → persist the record + register the repo.
|
|
219
|
+
*
|
|
220
|
+
* `repoKey` may be omitted by engine callers (the DAG seam): it is then
|
|
221
|
+
* derived from the canonical repoRoot via `repoIdFromRoot` — identical to
|
|
222
|
+
* what the repo gate computes, so tool- and engine-created records share
|
|
223
|
+
* one key. `origin` ('tool'|'dag') and `correlationId` are PERSISTED on
|
|
224
|
+
* the record (DESIGN §10 seam; the DAG reuse-ownership gate reads the
|
|
225
|
+
* record's correlationId).
|
|
226
|
+
*/
|
|
227
|
+
async function create(params = {}) {
|
|
228
|
+
const { task, repoRoot, baseRef, note, sessionId } = params
|
|
229
|
+
const explicitIntegration = params.integrationBranch
|
|
230
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
'worktree-service: create requires repoRoot (resolve it via repo-gate first)',
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
// Engine callers (the DAG seam) may omit repoKey; derive it the same
|
|
236
|
+
// way the repo gate does so both entry points agree on one key.
|
|
237
|
+
const repoKey =
|
|
238
|
+
typeof params.repoKey === 'string' && params.repoKey.length > 0
|
|
239
|
+
? params.repoKey
|
|
240
|
+
: repoIdFromRoot(realpathSync(repoRoot))
|
|
241
|
+
// Normalise the PERSISTED repoRoot to its realpath — the canonical form
|
|
242
|
+
// the tool path already stores (it routes through repo-gate, which
|
|
243
|
+
// canonicalises). A symlinked/`..` engine caller would otherwise store a
|
|
244
|
+
// RAW root whose exact-string comparison fails against the canonical
|
|
245
|
+
// findActiveByTask argument — the repoRoot asymmetry (audit finding).
|
|
246
|
+
// realpath failure falls back to the raw value, preserving the old
|
|
247
|
+
// "store verbatim" behaviour.
|
|
248
|
+
const canonicalRoot = realPathBestEffort(repoRoot) ?? repoRoot
|
|
249
|
+
|
|
250
|
+
// 1. Slug: empty or alnum-free sanitisation is unusable as a branch token.
|
|
251
|
+
// `sanitizeBranch` maps illegal chars to `_`, so an all-illegal task
|
|
252
|
+
// yields a pure `_` slug — meaningless and indistinguishable between
|
|
253
|
+
// different tasks; reject it alongside the empty case.
|
|
254
|
+
const slug = sanitizeBranch(task)
|
|
255
|
+
if (slug.length === 0 || !/[a-zA-Z0-9]/.test(slug)) {
|
|
256
|
+
fail(
|
|
257
|
+
'invalid_task_slug',
|
|
258
|
+
`task ${JSON.stringify(task)} sanitizes to an unusable branch token ${JSON.stringify(slug)}; ` +
|
|
259
|
+
'the task slug needs at least one [a-zA-Z0-9_.-] character',
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
const sessionShort = sessionShortOf(sessionId) // TypeError on bad input = caller contract
|
|
263
|
+
const branch = taskBranch(sessionShort, task)
|
|
264
|
+
|
|
265
|
+
// 2. git-level name validation. validateBranch returns a boolean (the
|
|
266
|
+
// port seam does not surface stderr), so the message names the
|
|
267
|
+
// offending branch instead of quoting git output.
|
|
268
|
+
if (!(await git.validateBranch(repoRoot, branch))) {
|
|
269
|
+
fail(
|
|
270
|
+
'invalid_task_slug',
|
|
271
|
+
`branch name "${branch}" (from task ${JSON.stringify(task)}) is rejected by ` +
|
|
272
|
+
'git check-ref-format --branch (e.g. "..", leading "-", trailing ".lock", or a lockfile-colliding name)',
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 2b. An EXPLICIT integration branch gets the SAME check-ref-format
|
|
277
|
+
// vetting (audit P1-B): the value flows unvalidated into
|
|
278
|
+
// `git branch <name> <startPoint>` (ensureBranch) and `mergeNoFf`
|
|
279
|
+
// at first merge — a flag-shaped name (`-m`, `--force`) lands in
|
|
280
|
+
// flag position there. Only the explicit form is checked: the
|
|
281
|
+
// derived default (`dsh-wt/integration/<sessionShort>`) is
|
|
282
|
+
// constructed from a sanitized session segment and needs no probe.
|
|
283
|
+
if (
|
|
284
|
+
typeof explicitIntegration === 'string'
|
|
285
|
+
&& explicitIntegration.length > 0
|
|
286
|
+
&& !(await git.validateBranch(repoRoot, explicitIntegration))
|
|
287
|
+
) {
|
|
288
|
+
fail(
|
|
289
|
+
'invalid_integration_branch',
|
|
290
|
+
`integration branch name "${explicitIntegration}" is rejected by ` +
|
|
291
|
+
'git check-ref-format --branch (legal branch names: no leading "-" or "-", no "..", ' +
|
|
292
|
+
'no ASCII control characters or spaces, no trailing ".lock", no "@{"/"@"-only, ' +
|
|
293
|
+
'no ref-component starting with "."; slashes are allowed, e.g. "dsh-wt/integration/x")',
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 3. Duplicate check — store side (non-terminal record, same repo) and
|
|
298
|
+
// git side (the branch already resolves).
|
|
299
|
+
const takenBranches = new Set(
|
|
300
|
+
Object.values(store.worktrees)
|
|
301
|
+
.filter((record) => record.repoKey === repoKey && !TERMINAL_WORKTREE_STATES.has(record.state))
|
|
302
|
+
.map((record) => record.branch),
|
|
303
|
+
)
|
|
304
|
+
const storeDuplicate = takenBranches.has(branch)
|
|
305
|
+
let gitHasBranch = false
|
|
306
|
+
try {
|
|
307
|
+
await git.resolveRef(repoRoot, branch)
|
|
308
|
+
gitHasBranch = true
|
|
309
|
+
} catch {
|
|
310
|
+
// Branch does not resolve — the normal, expected case.
|
|
311
|
+
}
|
|
312
|
+
if (storeDuplicate || gitHasBranch) {
|
|
313
|
+
const suggestion = suggestSlug(slug, sessionShort, new Set([...takenBranches, branch]))
|
|
314
|
+
fail(
|
|
315
|
+
'branch_exists',
|
|
316
|
+
`branch ${branch} already exists for repo ${repoKey}` +
|
|
317
|
+
`${storeDuplicate ? ' (active worktree record)' : ' (in git)'}` +
|
|
318
|
+
`; try task slug "${suggestion}" instead`,
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 4. maxWorktrees — global count of active/merging records (DESIGN §6.1).
|
|
323
|
+
const activeCount = Object.values(store.worktrees).filter((record) =>
|
|
324
|
+
COUNTED_WORKTREE_STATES.has(record.state),
|
|
325
|
+
).length
|
|
326
|
+
if (activeCount >= maxWorktrees) {
|
|
327
|
+
fail(
|
|
328
|
+
'max_worktrees_reached',
|
|
329
|
+
`${activeCount} active/merging worktrees already recorded (limit ${maxWorktrees}); ` +
|
|
330
|
+
'run worktree_cleanup on finished worktrees first (merged records need no force; ' +
|
|
331
|
+
'unmerged ones require BOTH force:true AND acknowledge:true)',
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 5. Base — ALWAYS resolved to a concrete oid before it reaches the
|
|
336
|
+
// record. Mutable-HEAD fallback is forbidden (task-weaver L268-284):
|
|
337
|
+
// a symbolic base would silently drift when the repo HEAD moves.
|
|
338
|
+
// `resolveRef` returns the peeled commit oid, which satisfies the
|
|
339
|
+
// invariant by construction.
|
|
340
|
+
const effectiveBaseRef = baseRef ?? defaultBaseRef
|
|
341
|
+
let baseOid
|
|
342
|
+
try {
|
|
343
|
+
baseOid = await git.resolveRef(repoRoot, effectiveBaseRef)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
fail(
|
|
346
|
+
'invalid_base_ref',
|
|
347
|
+
`cannot resolve base ref "${effectiveBaseRef}" in ${repoRoot}: ${clampDetail(error.message)}`,
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const integration =
|
|
352
|
+
typeof explicitIntegration === 'string' && explicitIntegration.length > 0
|
|
353
|
+
? explicitIntegration
|
|
354
|
+
: defaultIntegrationBranch(sessionShort)
|
|
355
|
+
|
|
356
|
+
// 6. Physical layout `<worktreeRoot>/<repoKey>/<sessionShort>/<slug>`
|
|
357
|
+
// (naming.worktreePath). The PARENT is pre-created; git requires the
|
|
358
|
+
// leaf to not exist and creates it itself (source L633-635).
|
|
359
|
+
const wtPath = worktreePath(config.worktreeRoot, repoKey, sessionShort, slug)
|
|
360
|
+
mkdirSync(path.dirname(wtPath), { recursive: true })
|
|
361
|
+
try {
|
|
362
|
+
await git.createWorktree(repoRoot, wtPath, branch, baseOid)
|
|
363
|
+
} catch (error) {
|
|
364
|
+
fail(
|
|
365
|
+
'worktree_creation_failed',
|
|
366
|
+
`git worktree add for branch ${branch} at ${wtPath} failed: ${clampDetail(error.message)}`,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 7. Persist. The repos registration is the crash-reconcile enumeration
|
|
371
|
+
// source (§5.2.2); StateStore has no upsertRepos, so the live map is
|
|
372
|
+
// written directly — persist() re-sanitizes a deep copy on flush.
|
|
373
|
+
// origin/correlationId (DESIGN §10 seam): 'tool' by default, 'dag'
|
|
374
|
+
// when the engine seam created it; correlationId is the DAG
|
|
375
|
+
// attempt id (the reuse-ownership gate's evidence).
|
|
376
|
+
const now = Date.now()
|
|
377
|
+
const origin = params.origin === 'dag' ? 'dag' : 'tool'
|
|
378
|
+
const { correlationId } = params
|
|
379
|
+
const record = store.upsertWorktree({
|
|
380
|
+
id: newWorktreeId(),
|
|
381
|
+
repoKey,
|
|
382
|
+
repoRoot: canonicalRoot,
|
|
383
|
+
branch,
|
|
384
|
+
path: wtPath,
|
|
385
|
+
baseCommit: baseOid,
|
|
386
|
+
integrationBranch: integration,
|
|
387
|
+
sessionId,
|
|
388
|
+
task,
|
|
389
|
+
state: 'active',
|
|
390
|
+
...(note !== undefined ? { note } : {}),
|
|
391
|
+
origin,
|
|
392
|
+
...(correlationId !== undefined && correlationId !== null ? { correlationId } : {}),
|
|
393
|
+
createdAt: now,
|
|
394
|
+
updatedAt: now,
|
|
395
|
+
})
|
|
396
|
+
store.repos[repoKey] = { root: canonicalRoot, lastSeenAt: Date.now() }
|
|
397
|
+
store.persist()
|
|
398
|
+
return record
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// -------------------------------------------------------------------------
|
|
402
|
+
// list (DESIGN §5.4)
|
|
403
|
+
// -------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* List worktree records (optionally filtered by exact repoRoot), each
|
|
407
|
+
* reconciled against `git worktree list`: a record whose path is absent
|
|
408
|
+
* from the live listing gets `orphaned: true` (state kept verbatim).
|
|
409
|
+
*/
|
|
410
|
+
async function list(params = {}) {
|
|
411
|
+
const { repoRoot } = params ?? {}
|
|
412
|
+
const records = Object.values(store.worktrees).filter((record) =>
|
|
413
|
+
repoRoot === undefined ? true : record.repoRoot === repoRoot,
|
|
414
|
+
)
|
|
415
|
+
const liveByRoot = new Map()
|
|
416
|
+
const out = []
|
|
417
|
+
for (const record of records) {
|
|
418
|
+
let liveSet = liveByRoot.get(record.repoRoot)
|
|
419
|
+
if (liveSet === undefined) {
|
|
420
|
+
liveSet = await livePathsFor(record.repoRoot)
|
|
421
|
+
liveByRoot.set(record.repoRoot, liveSet)
|
|
422
|
+
}
|
|
423
|
+
const orphaned = liveSet !== null && !pathInLiveSet(record.path, liveSet)
|
|
424
|
+
out.push(orphaned ? { ...record, orphaned: true } : { ...record })
|
|
425
|
+
}
|
|
426
|
+
return { worktrees: out, queueSummary: queueSummary() }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// -------------------------------------------------------------------------
|
|
430
|
+
// status (DESIGN §5.5)
|
|
431
|
+
// -------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Live status of one worktree (by id or exact path): HEAD, dirty flag,
|
|
435
|
+
* porcelain changes, and whether HEAD moved past baseCommit.
|
|
436
|
+
*/
|
|
437
|
+
async function status(idOrPath) {
|
|
438
|
+
const record = findRecord(idOrPath)
|
|
439
|
+
if (record === undefined) {
|
|
440
|
+
fail('worktree_not_found', `no worktree record matches id or path "${idOrPath}"`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Best-effort probes: a worktree that vanished (dir deleted, disk gone)
|
|
444
|
+
// returns the record with `vanished: true` instead of failing.
|
|
445
|
+
if (!existsSync(record.path)) {
|
|
446
|
+
return { ...record, vanished: true }
|
|
447
|
+
}
|
|
448
|
+
let changes
|
|
449
|
+
let head
|
|
450
|
+
try {
|
|
451
|
+
const entries = await git.status(record.path)
|
|
452
|
+
changes = entries.map((entry) => ({ path: entry.path, status: entry.status }))
|
|
453
|
+
head = (await git.resolveHead(record.path)).commit
|
|
454
|
+
} catch {
|
|
455
|
+
return { ...record, vanished: true }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// DESIGN §5.5 specifies `ahead_of_base: n` (commit count via
|
|
459
|
+
// rev-list --count). The GitPort face has no rev-list count method, so
|
|
460
|
+
// this implementation reports the BOOLEAN projection
|
|
461
|
+
// `ahead_of_base = isAncestor(baseCommit, head) && head !== baseCommit`
|
|
462
|
+
// — deliberately NOT adding countAhead to the port (DESIGN §5.8 keeps
|
|
463
|
+
// the count "留给后置"; the tool layer can extend later).
|
|
464
|
+
let aheadOfBase = false
|
|
465
|
+
if (head && head !== record.baseCommit) {
|
|
466
|
+
try {
|
|
467
|
+
aheadOfBase = await git.isAncestor(record.repoRoot, record.baseCommit, head)
|
|
468
|
+
} catch {
|
|
469
|
+
aheadOfBase = false // probe failure is not "ahead"
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return { ...record, head, dirty: changes.length > 0, changes, ahead_of_base: aheadOfBase }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// -------------------------------------------------------------------------
|
|
476
|
+
// findActiveByTask (DESIGN §10 seam — the DAG reuse probe)
|
|
477
|
+
// -------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The ACTIVE worktree record for one (repoRoot, task slug), or null.
|
|
481
|
+
*
|
|
482
|
+
* Consumer contract (dsh-dag-orchestrator lib/worktrees-seam.js /
|
|
483
|
+
* executor.js resolveWorktreeCwd): the dispatch-time reuse probe consults
|
|
484
|
+
* this BEFORE creating — a same-slug active record means a re-dispatched
|
|
485
|
+
* task resumes in its existing worktree instead of stacking a second
|
|
486
|
+
* one. The returned record carries its `correlationId` (the create-time
|
|
487
|
+
* DAG attempt id): the DAG's OWNERSHIP gate reuses the path only when
|
|
488
|
+
* that id belongs to the dispatching task's own attempt history — this
|
|
489
|
+
* side just reports the facts.
|
|
490
|
+
*
|
|
491
|
+
* Lookup key: the RAW task string recorded at create time (records store
|
|
492
|
+
* the pre-sanitisation slug verbatim).
|
|
493
|
+
*
|
|
494
|
+
* @param {string} repoRoot canonical repo root (compared to record.repoRoot)
|
|
495
|
+
* @param {string} task task slug as passed to create()
|
|
496
|
+
* @returns {Promise<import('./state-store.js').WorktreeRecord|null>}
|
|
497
|
+
*/
|
|
498
|
+
async function findActiveByTask(repoRoot, task) {
|
|
499
|
+
if (typeof repoRoot !== 'string' || typeof task !== 'string') return null
|
|
500
|
+
for (const record of Object.values(store.worktrees)) {
|
|
501
|
+
if (record.repoRoot !== repoRoot) continue
|
|
502
|
+
if (record.task !== task) continue
|
|
503
|
+
if (record.state !== 'active') continue
|
|
504
|
+
return record
|
|
505
|
+
}
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// -------------------------------------------------------------------------
|
|
510
|
+
// cleanup (DESIGN §5.8)
|
|
511
|
+
// -------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Remove one worktree (+ its branch unless keepBranch) after the
|
|
515
|
+
* unmerged-work protection gate, or prune a vanished record.
|
|
516
|
+
*/
|
|
517
|
+
async function cleanup(params = {}) {
|
|
518
|
+
const { idOrPath, force = false, acknowledge = false, keepBranch = false } = params ?? {}
|
|
519
|
+
const record = findRecord(idOrPath)
|
|
520
|
+
if (record === undefined) {
|
|
521
|
+
fail('worktree_not_found', `no worktree record matches id or path "${idOrPath}"`)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Vanished/orphaned record: the worktree directory is already gone, so
|
|
525
|
+
// there is nothing to protect (no working state) and nothing to remove.
|
|
526
|
+
// Prune the record ONLY — no git side effects, branch left untouched
|
|
527
|
+
// (deleting branches of vanished worktrees is an explicit operator
|
|
528
|
+
// decision, not cleanup's default).
|
|
529
|
+
if (!(await recordIsLive(record))) {
|
|
530
|
+
delete store.worktrees[record.id]
|
|
531
|
+
store.persist()
|
|
532
|
+
return {
|
|
533
|
+
id: record.id,
|
|
534
|
+
removedWorktree: false,
|
|
535
|
+
removedBranch: false,
|
|
536
|
+
note: 'record pruned; path already absent from git worktree list',
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Protection (safety red line 4): a non-merged record whose branch is
|
|
541
|
+
// NOT integrated into its integration branch may only be removed with
|
|
542
|
+
// the DOUBLE confirmation force && acknowledge (two independent
|
|
543
|
+
// booleans — a single force:true never passes).
|
|
544
|
+
//
|
|
545
|
+
// Two evidence arms decide "unmerged":
|
|
546
|
+
// - integration branch RESOLVES → the classic probe: sourceHead must
|
|
547
|
+
// be an ancestor of the integration head;
|
|
548
|
+
// - integration branch does NOT resolve (never bootstrapped — the
|
|
549
|
+
// pre-first-merge window, audit P1-C) → the worktree's own head is
|
|
550
|
+
// compared against the recorded baseCommit: any commit past base is
|
|
551
|
+
// work that exists nowhere else, so it is unmerged BY DEFINITION
|
|
552
|
+
// and the same double-confirmation gate fires.
|
|
553
|
+
// A worktree whose branch is gone AND whose head sits at base (or
|
|
554
|
+
// cannot move past base) has no unmerged work — clearable bare.
|
|
555
|
+
if (record.state !== 'merged' && !(force && acknowledge)) {
|
|
556
|
+
let sourceHead = null
|
|
557
|
+
let integrationHead = null
|
|
558
|
+
try {
|
|
559
|
+
sourceHead = await git.resolveRef(record.repoRoot, record.branch)
|
|
560
|
+
} catch {
|
|
561
|
+
// Branch gone — the head probe below still guards the worktree dir.
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
integrationHead = await git.resolveRef(record.repoRoot, record.integrationBranch)
|
|
565
|
+
} catch {
|
|
566
|
+
// Integration branch never bootstrapped — the P1-C arm decides.
|
|
567
|
+
}
|
|
568
|
+
if (sourceHead !== null && integrationHead !== null) {
|
|
569
|
+
let integrated
|
|
570
|
+
try {
|
|
571
|
+
integrated = await git.isAncestor(record.repoRoot, sourceHead, integrationHead)
|
|
572
|
+
} catch (error) {
|
|
573
|
+
// Cannot PROVE integration → fail closed under the same code.
|
|
574
|
+
fail(
|
|
575
|
+
'cleanup_protected',
|
|
576
|
+
`integration probe failed for worktree ${record.id}: ${clampDetail(error.message)}`,
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
if (!integrated) {
|
|
580
|
+
fail(
|
|
581
|
+
'cleanup_protected',
|
|
582
|
+
`worktree ${record.id} (task ${JSON.stringify(record.task)}) branch ${record.branch} ` +
|
|
583
|
+
`is NOT integrated: sourceHead ${sourceHead} is not an ancestor of ` +
|
|
584
|
+
`${record.integrationBranch} head ${integrationHead}; to delete unmerged work ` +
|
|
585
|
+
'pass BOTH force:true AND acknowledge:true (single force is not enough), ' +
|
|
586
|
+
'or integrate first via worktree_merge',
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
// P1-C arm: the integration branch does not resolve (or the task
|
|
591
|
+
// branch is gone while the worktree still holds commits). Unmerged
|
|
592
|
+
// work exists when the worktree holds EITHER commits past its
|
|
593
|
+
// recorded baseCommit OR uncommitted changes in its probe — both are
|
|
594
|
+
// work that exists nowhere else — so either triggers protection.
|
|
595
|
+
let worktreeHead = null
|
|
596
|
+
try {
|
|
597
|
+
worktreeHead = (await git.resolveHead(record.path)).commit
|
|
598
|
+
} catch {
|
|
599
|
+
// Unresolvable head (a live worktree per the probe above should
|
|
600
|
+
// always resolve) — cannot PROVE absence of work: fail closed.
|
|
601
|
+
fail(
|
|
602
|
+
'cleanup_protected',
|
|
603
|
+
`worktree ${record.id} (task ${JSON.stringify(record.task)}) head is unresolvable ` +
|
|
604
|
+
`at ${record.path}; cannot PROVE it holds no unmerged work, so bare cleanup is ` +
|
|
605
|
+
'refused — explicitly pass BOTH force:true AND acknowledge:true to delete, ' +
|
|
606
|
+
'or integrate first via worktree_merge',
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
// Uncommitted changes are unmerged work even when HEAD sits at base
|
|
610
|
+
// (README: "no commits past base AND no uncommitted work in the
|
|
611
|
+
// branch probe" is the clearable bare bar). A porcelain flake here
|
|
612
|
+
// cannot PROVE the tree is clean → fail closed (protect).
|
|
613
|
+
let dirty = true
|
|
614
|
+
try {
|
|
615
|
+
const entries = await git.status(record.path)
|
|
616
|
+
dirty = Array.isArray(entries) && entries.length > 0
|
|
617
|
+
} catch {
|
|
618
|
+
// status probe failure → keep the fail-closed default (dirty=true).
|
|
619
|
+
}
|
|
620
|
+
if (dirty) {
|
|
621
|
+
fail(
|
|
622
|
+
'cleanup_protected',
|
|
623
|
+
`worktree ${record.id} (task ${JSON.stringify(record.task)}) holds UNMERGED work: ` +
|
|
624
|
+
`integration branch ${record.integrationBranch} does not exist yet (no merge has ` +
|
|
625
|
+
'run) and the worktree probe has uncommitted changes present; ' +
|
|
626
|
+
'to delete unmerged work pass BOTH force:true AND acknowledge:true ' +
|
|
627
|
+
'(single force is not enough), or integrate first via worktree_merge',
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
// Ahead of base: committed work past base that exists nowhere else.
|
|
631
|
+
// head being a STRICT ANCESTOR of base (head behind base) is the ONLY
|
|
632
|
+
// safe case — a divergent/unrelated head holds unique commits, so it
|
|
633
|
+
// is unmerged. probe failure → assume ahead (fail closed).
|
|
634
|
+
const ahead =
|
|
635
|
+
worktreeHead !== record.baseCommit
|
|
636
|
+
&& !(await git
|
|
637
|
+
.isAncestor(record.repoRoot, worktreeHead, record.baseCommit)
|
|
638
|
+
.catch(() => false)) // failure → "not a strict ancestor" → ahead
|
|
639
|
+
if (ahead) {
|
|
640
|
+
fail(
|
|
641
|
+
'cleanup_protected',
|
|
642
|
+
`worktree ${record.id} (task ${JSON.stringify(record.task)}) holds UNMERGED work: ` +
|
|
643
|
+
`integration branch ${record.integrationBranch} does not exist yet (no merge has ` +
|
|
644
|
+
`run) while the worktree head ${worktreeHead} is ahead of its base ${record.baseCommit}; ` +
|
|
645
|
+
'to delete unmerged work pass BOTH force:true AND acknowledge:true ' +
|
|
646
|
+
'(single force is not enough), or integrate first via worktree_merge',
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Remove the worktree (idempotent) and — unless keepBranch — the branch.
|
|
653
|
+
await git.removeWorktree(record.repoRoot, record.path)
|
|
654
|
+
let removedBranch = false
|
|
655
|
+
if (keepBranch !== true) {
|
|
656
|
+
try {
|
|
657
|
+
await git.deleteBranch(record.repoRoot, record.branch)
|
|
658
|
+
removedBranch = true
|
|
659
|
+
} catch (error) {
|
|
660
|
+
// `error: branch 'x' not found` = already gone (idempotent prune);
|
|
661
|
+
// any other failure (e.g. branch checked out elsewhere) surfaces.
|
|
662
|
+
if (!/not found/i.test(String(error?.message ?? ''))) throw error
|
|
663
|
+
removedBranch = false
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
delete store.worktrees[record.id]
|
|
668
|
+
store.persist()
|
|
669
|
+
return { id: record.id, removedWorktree: true, removedBranch }
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return { create, list, status, cleanup, findActiveByTask }
|
|
673
|
+
}
|