paseo-dsh-direct 0.1.4
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/LICENSE +21 -0
- package/README.md +64 -0
- package/README.zh.md +64 -0
- package/dsh.svg +11 -0
- package/index.client.tsx +6 -0
- package/index.server.ts +19 -0
- package/package.json +80 -0
- package/paseo-plugin.json +10 -0
- package/scripts/build.mjs +31 -0
- package/scripts/install-lefthook.mjs +743 -0
- package/server/bridge-client.ts +416 -0
- package/server/dsh/bridge.ts +176 -0
- package/server/dsh/host.ts +204 -0
- package/server/dsh/service.ts +890 -0
- package/server/dsh-version.ts +71 -0
- package/server/plugin-version.ts +7 -0
- package/server/prompt-content.ts +120 -0
- package/server/provider.ts +1101 -0
- package/server/timeline.ts +487 -0
- package/server/turn-end.ts +41 -0
- package/server/values.ts +20 -0
- package/server/version-line.ts +50 -0
- package/shared/bridge-protocol.ts +232 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import {
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
17
|
+
import lefthookPackage from 'lefthook/package.json' with { type: 'json' }
|
|
18
|
+
|
|
19
|
+
const MINIMUM_GIT = [2, 26, 0]
|
|
20
|
+
const HOOKS_DIRECTORY = 'dsh-hooks'
|
|
21
|
+
const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
|
|
22
|
+
const OWNERSHIP_MARKER_VERSION = 1
|
|
23
|
+
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
|
|
24
|
+
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
|
|
25
|
+
const INSTALL_LOCK_TIMEOUT_MS = 30_000
|
|
26
|
+
const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 5_000
|
|
27
|
+
const INSTALL_LOCK_POLL_MS = 50
|
|
28
|
+
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
|
|
29
|
+
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
|
|
30
|
+
function errorCode(error) {
|
|
31
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
32
|
+
? error.code
|
|
33
|
+
: undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function commandFailure(command, args, result) {
|
|
37
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : ''
|
|
38
|
+
const detail = result.error?.message ?? (stderr || `exit status ${String(result.status)}`)
|
|
39
|
+
return new Error(`${command} ${args.join(' ')} failed: ${detail}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function capture(command, args, options = {}) {
|
|
43
|
+
const result = spawnSync(command, args, {
|
|
44
|
+
cwd: options.cwd,
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
env: process.env,
|
|
47
|
+
})
|
|
48
|
+
if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) {
|
|
49
|
+
throw commandFailure(command, args, result)
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function git(args, root, options = {}) {
|
|
55
|
+
return capture('git', args, { ...options, cwd: root })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nulValues(result) {
|
|
59
|
+
if (result.status !== 0) return []
|
|
60
|
+
if (result.stdout === '') return ['']
|
|
61
|
+
const output = result.stdout.endsWith('\0') ? result.stdout.slice(0, -1) : result.stdout
|
|
62
|
+
return output.split('\0')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stripGitLineTerminator(output) {
|
|
66
|
+
const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
|
|
67
|
+
return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
|
|
68
|
+
? withoutLineFeed.slice(0, -1)
|
|
69
|
+
: withoutLineFeed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function directFileConfigValues(root, configPath, key) {
|
|
73
|
+
return nulValues(git(
|
|
74
|
+
['config', '--file', configPath, '--no-includes', '--null', '--get-all', key],
|
|
75
|
+
root,
|
|
76
|
+
{ allowStatuses: [1] },
|
|
77
|
+
))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseFileConfigEntries(fields, key) {
|
|
81
|
+
if (fields.length % 2 !== 0) {
|
|
82
|
+
throw new Error(`git config returned invalid file entries for ${key}`)
|
|
83
|
+
}
|
|
84
|
+
const entries = []
|
|
85
|
+
for (let index = 0; index < fields.length; index += 2) {
|
|
86
|
+
entries.push({ origin: fields[index], value: fields[index + 1] })
|
|
87
|
+
}
|
|
88
|
+
return entries
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function includedFileConfigEntries(root, configPath, key) {
|
|
92
|
+
const fields = nulValues(git(
|
|
93
|
+
['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key],
|
|
94
|
+
root,
|
|
95
|
+
{ allowStatuses: [1] },
|
|
96
|
+
))
|
|
97
|
+
return parseFileConfigEntries(fields, key)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitConfigNameValue(field, pattern) {
|
|
101
|
+
const separator = field.indexOf('\n')
|
|
102
|
+
if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`)
|
|
103
|
+
return { name: field.slice(0, separator), value: field.slice(separator + 1) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function directFileConfigMatchingEntries(root, configPath, pattern) {
|
|
107
|
+
const fields = nulValues(git(
|
|
108
|
+
['config', '--file', configPath, '--no-includes', '--null', '--show-origin', '--get-regexp', pattern],
|
|
109
|
+
root,
|
|
110
|
+
{ allowStatuses: [1] },
|
|
111
|
+
))
|
|
112
|
+
if (fields.length % 2 !== 0) {
|
|
113
|
+
throw new Error(`git config returned invalid matching file entries for ${pattern}`)
|
|
114
|
+
}
|
|
115
|
+
const entries = []
|
|
116
|
+
for (let index = 0; index < fields.length; index += 2) {
|
|
117
|
+
entries.push({ origin: fields[index], ...splitConfigNameValue(fields[index + 1], pattern) })
|
|
118
|
+
}
|
|
119
|
+
return entries
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function effectiveConfigEntry(root, key) {
|
|
123
|
+
const fields = nulValues(git(
|
|
124
|
+
['config', '--null', '--show-scope', '--show-origin', '--get', key],
|
|
125
|
+
root,
|
|
126
|
+
{ allowStatuses: [1] },
|
|
127
|
+
))
|
|
128
|
+
if (fields.length === 0) return undefined
|
|
129
|
+
if (fields.length !== 3) {
|
|
130
|
+
throw new Error(`git config returned an invalid scoped value for ${key}`)
|
|
131
|
+
}
|
|
132
|
+
const [scope, origin, value] = fields
|
|
133
|
+
return { origin, scope, value }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseGitBoolean(value, key) {
|
|
137
|
+
const normalized = value.toLowerCase()
|
|
138
|
+
if (normalized === '' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === '1') return true
|
|
139
|
+
if (normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '0') return false
|
|
140
|
+
throw new Error(`invalid Boolean value for ${key}: ${JSON.stringify(value)}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertSingle(values, key) {
|
|
144
|
+
if (values.length > 1) throw new Error(`multiple ${key} values are not supported`)
|
|
145
|
+
return values[0]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function worktreeConfigExtensionEnabled(root, commonConfigPath) {
|
|
149
|
+
const extensionText = assertSingle(
|
|
150
|
+
directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
|
|
151
|
+
'extensions.worktreeConfig',
|
|
152
|
+
)
|
|
153
|
+
return extensionText === undefined
|
|
154
|
+
? false
|
|
155
|
+
: parseGitBoolean(extensionText, 'extensions.worktreeConfig')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasDirectConfigEntries(root, configPath) {
|
|
159
|
+
return git(['config', '--file', configPath, '--no-includes', '--null', '--list'], root).stdout !== ''
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function registeredWorktreeConfigPaths(commonDirectory) {
|
|
163
|
+
const paths = [join(commonDirectory, 'config.worktree')]
|
|
164
|
+
const linkedDirectory = join(commonDirectory, 'worktrees')
|
|
165
|
+
try {
|
|
166
|
+
const entries = readdirSync(linkedDirectory, { withFileTypes: true })
|
|
167
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
paths.push(join(linkedDirectory, entry.name, 'config.worktree'))
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (errorCode(error) !== 'ENOENT') throw error
|
|
173
|
+
}
|
|
174
|
+
return paths
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function lstatIfPresent(path) {
|
|
178
|
+
try {
|
|
179
|
+
return lstatSync(path)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (errorCode(error) === 'ENOENT') return undefined
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function assertCommonConfigFile(commonConfigPath) {
|
|
187
|
+
const configStat = lstatIfPresent(commonConfigPath)
|
|
188
|
+
if (configStat === undefined || !configStat.isFile() || configStat.isSymbolicLink()) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`refusing common repository config ${JSON.stringify(commonConfigPath)} because it is not a regular file`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertWorktreeConfigFiles(root, commonDirectory, commonConfigPath, currentConfigPath) {
|
|
196
|
+
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
|
|
197
|
+
for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) {
|
|
198
|
+
const configStat = lstatIfPresent(configPath)
|
|
199
|
+
if (configStat === undefined) continue
|
|
200
|
+
if (!configStat.isFile() || configStat.isSymbolicLink()) {
|
|
201
|
+
const state = extensionEnabled ? 'active' : 'dormant'
|
|
202
|
+
throw new Error(
|
|
203
|
+
`refusing ${state} worktree config ${JSON.stringify(configPath)} because it is not a regular file; `
|
|
204
|
+
+ 'replace it with a regular worktree config or remove it before retrying',
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
if (extensionEnabled) continue
|
|
208
|
+
if (!hasDirectConfigEntries(root, configPath)) continue
|
|
209
|
+
const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath)
|
|
210
|
+
const owner = isCurrent ? 'current' : 'sibling'
|
|
211
|
+
throw new Error(
|
|
212
|
+
`cannot enable extensions.worktreeConfig while ${owner} dormant worktree config `
|
|
213
|
+
+ `${JSON.stringify(configPath)} contains user-owned settings that enabling the extension would activate; `
|
|
214
|
+
+ 'inspect and migrate those settings, then enable the extension explicitly or remove them before retrying',
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function assertSupportedGit(root) {
|
|
220
|
+
const version = git(['--version'], root).stdout.trim()
|
|
221
|
+
const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
|
|
222
|
+
if (match === null) throw new Error(`cannot determine Git version from ${JSON.stringify(version)}`)
|
|
223
|
+
const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]
|
|
224
|
+
for (let index = 0; index < MINIMUM_GIT.length; index += 1) {
|
|
225
|
+
if (actual[index] > MINIMUM_GIT[index]) return
|
|
226
|
+
if (actual[index] < MINIMUM_GIT[index]) {
|
|
227
|
+
throw new Error(`Git 2.26 or newer is required for worktree-local hooks; found ${version}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function planWorktreeConfigMigration(root, commonConfigPath) {
|
|
233
|
+
const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion')
|
|
234
|
+
const versionText = assertSingle(versions, 'core.repositoryFormatVersion')
|
|
235
|
+
const version = Number(versionText)
|
|
236
|
+
if (!Number.isInteger(version) || version < 0) {
|
|
237
|
+
throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (version === 0) {
|
|
241
|
+
const extensionEntry = directFileConfigMatchingEntries(
|
|
242
|
+
root,
|
|
243
|
+
commonConfigPath,
|
|
244
|
+
REPOSITORY_EXTENSION_PATTERN,
|
|
245
|
+
)[0]
|
|
246
|
+
if (extensionEntry !== undefined) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`cannot upgrade core.repositoryFormatVersion from 0 while dormant repository extension `
|
|
249
|
+
+ `${extensionEntry.name} is configured (${configSource(extensionEntry)}); `
|
|
250
|
+
+ 'audit and migrate it, then set repository format 1 explicitly before retrying',
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
|
|
256
|
+
const worktreeText = assertSingle(
|
|
257
|
+
directFileConfigValues(root, commonConfigPath, 'core.worktree'),
|
|
258
|
+
'core.worktree',
|
|
259
|
+
)
|
|
260
|
+
if (worktreeText !== undefined) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`cannot enable extensions.worktreeConfig while core.worktree is in the common config `
|
|
263
|
+
+ `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); `
|
|
264
|
+
+ 'move it to the main worktree config first',
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const directBareText = assertSingle(directFileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
|
|
269
|
+
const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare')
|
|
270
|
+
if (directBare === true) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`cannot enable extensions.worktreeConfig for a common config with core.bare=true `
|
|
273
|
+
+ `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { directBare, extensionEnabled, version }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function applyWorktreeConfigMigration(root, commonConfigPath, migration) {
|
|
281
|
+
const { directBare, extensionEnabled, version } = migration
|
|
282
|
+
if (version === 0) {
|
|
283
|
+
git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root)
|
|
284
|
+
}
|
|
285
|
+
if (!extensionEnabled) {
|
|
286
|
+
git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root)
|
|
287
|
+
}
|
|
288
|
+
if (directBare === false) {
|
|
289
|
+
git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readInstallLock(lockPath) {
|
|
294
|
+
try {
|
|
295
|
+
return readFileSync(lockPath, 'utf8')
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (errorCode(error) === 'ENOENT') return undefined
|
|
298
|
+
throw error
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function installLockStat(lockPath) {
|
|
303
|
+
try {
|
|
304
|
+
return lstatSync(lockPath)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (errorCode(error) === 'ENOENT') return undefined
|
|
307
|
+
throw error
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function parseInstallLock(record) {
|
|
312
|
+
const match = /^([1-9]\d*) ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/i.exec(record)
|
|
313
|
+
if (match === null) return undefined
|
|
314
|
+
const owner = Number(match[1])
|
|
315
|
+
return Number.isSafeInteger(owner) ? owner : undefined
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function installLockRecordMayBeIncomplete(record) {
|
|
319
|
+
// Exclusive creation exposes the inode before its owner record is fully written.
|
|
320
|
+
return record === '' || (!record.endsWith('\n') && /^[1-9]\d*(?: [0-9a-f-]*)?$/i.test(record))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function lockOwnerIsAlive(owner) {
|
|
324
|
+
try {
|
|
325
|
+
process.kill(owner, 0)
|
|
326
|
+
return true
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (errorCode(error) === 'ESRCH') return false
|
|
329
|
+
if (errorCode(error) === 'EPERM') return true
|
|
330
|
+
throw error
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function manualLockRecoveryError(lockPath, condition) {
|
|
335
|
+
return new Error(
|
|
336
|
+
`${condition} Lefthook installer lock ${JSON.stringify(lockPath)}. `
|
|
337
|
+
+ 'Confirm no Lefthook installer is running, remove it manually, and retry.',
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function lockOwnershipChangedError(lockPath) {
|
|
342
|
+
return new Error(`Lefthook installer lock ownership changed for ${lockPath}; refusing to remove it`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function releaseInstallLock(lockPath, ownedRecord, ownedStat) {
|
|
346
|
+
const currentStat = installLockStat(lockPath)
|
|
347
|
+
if (
|
|
348
|
+
currentStat === undefined
|
|
349
|
+
|| !currentStat.isFile()
|
|
350
|
+
|| currentStat.isSymbolicLink()
|
|
351
|
+
|| currentStat.dev !== ownedStat.dev
|
|
352
|
+
|| currentStat.ino !== ownedStat.ino
|
|
353
|
+
|| readInstallLock(lockPath) !== ownedRecord
|
|
354
|
+
) {
|
|
355
|
+
throw lockOwnershipChangedError(lockPath)
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
unlinkSync(lockPath)
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (errorCode(error) === 'ENOENT') {
|
|
361
|
+
throw lockOwnershipChangedError(lockPath)
|
|
362
|
+
}
|
|
363
|
+
throw error
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function acquireInstallLock(commonDirectory) {
|
|
368
|
+
const lockPath = join(commonDirectory, INSTALL_LOCK)
|
|
369
|
+
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
|
|
370
|
+
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
|
|
371
|
+
let initializingLock
|
|
372
|
+
while (true) {
|
|
373
|
+
try {
|
|
374
|
+
const lockHandle = openSync(lockPath, 'wx', 0o600)
|
|
375
|
+
let ownedStat
|
|
376
|
+
try {
|
|
377
|
+
ownedStat = fstatSync(lockHandle)
|
|
378
|
+
const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0)
|
|
379
|
+
if (writeDelay > 0) {
|
|
380
|
+
await new Promise(resolveWait => setTimeout(resolveWait, writeDelay))
|
|
381
|
+
}
|
|
382
|
+
writeFileSync(lockHandle, ownedRecord)
|
|
383
|
+
} finally {
|
|
384
|
+
closeSync(lockHandle)
|
|
385
|
+
}
|
|
386
|
+
const publishedStat = installLockStat(lockPath)
|
|
387
|
+
if (
|
|
388
|
+
publishedStat === undefined
|
|
389
|
+
|| !publishedStat.isFile()
|
|
390
|
+
|| publishedStat.isSymbolicLink()
|
|
391
|
+
|| publishedStat.dev !== ownedStat.dev
|
|
392
|
+
|| publishedStat.ino !== ownedStat.ino
|
|
393
|
+
) {
|
|
394
|
+
throw lockOwnershipChangedError(lockPath)
|
|
395
|
+
}
|
|
396
|
+
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (errorCode(error) !== 'EEXIST') throw error
|
|
399
|
+
const existingStat = installLockStat(lockPath)
|
|
400
|
+
if (existingStat === undefined) continue
|
|
401
|
+
if (!existingStat.isFile() || existingStat.isSymbolicLink()) {
|
|
402
|
+
throw manualLockRecoveryError(lockPath, 'invalid')
|
|
403
|
+
}
|
|
404
|
+
const existingRecord = readInstallLock(lockPath)
|
|
405
|
+
if (existingRecord === undefined) continue
|
|
406
|
+
const verifiedStat = installLockStat(lockPath)
|
|
407
|
+
if (verifiedStat === undefined) continue
|
|
408
|
+
if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) {
|
|
409
|
+
throw manualLockRecoveryError(lockPath, 'invalid')
|
|
410
|
+
}
|
|
411
|
+
if (verifiedStat.dev !== existingStat.dev || verifiedStat.ino !== existingStat.ino) continue
|
|
412
|
+
const owner = parseInstallLock(existingRecord)
|
|
413
|
+
if (owner === undefined) {
|
|
414
|
+
if (!installLockRecordMayBeIncomplete(existingRecord)) {
|
|
415
|
+
throw manualLockRecoveryError(lockPath, 'invalid')
|
|
416
|
+
}
|
|
417
|
+
const now = Date.now()
|
|
418
|
+
if (
|
|
419
|
+
initializingLock === undefined
|
|
420
|
+
|| initializingLock.dev !== existingStat.dev
|
|
421
|
+
|| initializingLock.ino !== existingStat.ino
|
|
422
|
+
) {
|
|
423
|
+
initializingLock = {
|
|
424
|
+
deadline: now + INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS,
|
|
425
|
+
dev: existingStat.dev,
|
|
426
|
+
ino: existingStat.ino,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (now >= initializingLock.deadline) {
|
|
430
|
+
throw manualLockRecoveryError(lockPath, 'invalid')
|
|
431
|
+
}
|
|
432
|
+
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
initializingLock = undefined
|
|
436
|
+
if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
|
|
437
|
+
if (Date.now() >= deadline) {
|
|
438
|
+
throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
|
|
439
|
+
}
|
|
440
|
+
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function ownershipMarkerContent(hooksPath) {
|
|
446
|
+
return `${JSON.stringify({
|
|
447
|
+
version: OWNERSHIP_MARKER_VERSION,
|
|
448
|
+
owner: OWNERSHIP_MARKER_OWNER,
|
|
449
|
+
hooksPath,
|
|
450
|
+
})}\n`
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function parseOwnershipMarker(content) {
|
|
454
|
+
let parsed
|
|
455
|
+
try {
|
|
456
|
+
parsed = JSON.parse(content)
|
|
457
|
+
} catch {
|
|
458
|
+
return undefined
|
|
459
|
+
}
|
|
460
|
+
if (
|
|
461
|
+
typeof parsed !== 'object'
|
|
462
|
+
|| parsed === null
|
|
463
|
+
|| parsed.version !== OWNERSHIP_MARKER_VERSION
|
|
464
|
+
|| parsed.owner !== OWNERSHIP_MARKER_OWNER
|
|
465
|
+
|| typeof parsed.hooksPath !== 'string'
|
|
466
|
+
|| !isAbsolute(parsed.hooksPath)
|
|
467
|
+
) {
|
|
468
|
+
return undefined
|
|
469
|
+
}
|
|
470
|
+
return { hooksPath: parsed.hooksPath }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function inspectOwnedHooksDirectory(hooksPath) {
|
|
474
|
+
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
|
|
475
|
+
if (!existsSync(hooksPath)) return undefined
|
|
476
|
+
const hooksStat = lstatSync(hooksPath)
|
|
477
|
+
if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) {
|
|
478
|
+
throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`)
|
|
479
|
+
}
|
|
480
|
+
if (!existsSync(markerPath)) {
|
|
481
|
+
throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`)
|
|
482
|
+
}
|
|
483
|
+
const markerStat = lstatSync(markerPath)
|
|
484
|
+
const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1
|
|
485
|
+
? parseOwnershipMarker(readFileSync(markerPath, 'utf8'))
|
|
486
|
+
: undefined
|
|
487
|
+
if (marker === undefined) {
|
|
488
|
+
throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`)
|
|
489
|
+
}
|
|
490
|
+
for (const name of readdirSync(hooksPath)) {
|
|
491
|
+
if (name === OWNERSHIP_MARKER) continue
|
|
492
|
+
const entryPath = join(hooksPath, name)
|
|
493
|
+
const entryStat = lstatSync(entryPath)
|
|
494
|
+
if (!entryStat.isFile() || entryStat.isSymbolicLink() || entryStat.nlink !== 1) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`refusing to overwrite non-regular or multiply linked hook entry ${JSON.stringify(entryPath)}`,
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { markerPath, ...marker }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isRegisteredOwnedHooksPath(commonDirectory, hooksPath) {
|
|
504
|
+
const normalizedHooksPath = normalizedPath(hooksPath)
|
|
505
|
+
const isRegistered = registeredWorktreeConfigPaths(commonDirectory).some(
|
|
506
|
+
configPath => normalizedPath(join(dirname(configPath), HOOKS_DIRECTORY)) === normalizedHooksPath,
|
|
507
|
+
)
|
|
508
|
+
if (!isRegistered) return false
|
|
509
|
+
return inspectOwnedHooksDirectory(hooksPath)?.hooksPath === hooksPath
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function ensureOwnedHooksDirectory(hooksPath) {
|
|
513
|
+
const inspected = inspectOwnedHooksDirectory(hooksPath)
|
|
514
|
+
if (inspected !== undefined) return inspected
|
|
515
|
+
mkdirSync(hooksPath, { mode: 0o700 })
|
|
516
|
+
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
|
|
517
|
+
writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 })
|
|
518
|
+
return { markerPath, hooksPath }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function updateOwnershipMarker(markerPath, hooksPath) {
|
|
522
|
+
writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { mode: 0o600 })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function environmentWithoutCommandGitConfig() {
|
|
526
|
+
const env = { ...process.env }
|
|
527
|
+
for (const key of Object.keys(env)) {
|
|
528
|
+
const normalized = key.toUpperCase()
|
|
529
|
+
if (
|
|
530
|
+
normalized === 'GIT_CONFIG_PARAMETERS'
|
|
531
|
+
|| normalized === 'GIT_CONFIG_COUNT'
|
|
532
|
+
|| /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(normalized)
|
|
533
|
+
) {
|
|
534
|
+
delete env[key]
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return env
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function runLefthook(root, lefthook) {
|
|
541
|
+
const args = ['install', '--force']
|
|
542
|
+
const env = environmentWithoutCommandGitConfig()
|
|
543
|
+
// Node refuses to spawn Windows `.cmd` shims directly; the quoted path is
|
|
544
|
+
// re-parsed by cmd.exe, while POSIX can execute its extensionless shim.
|
|
545
|
+
const result = process.platform === 'win32'
|
|
546
|
+
? spawnSync(`"${lefthook}"`, args, { cwd: root, env, stdio: 'inherit', shell: true })
|
|
547
|
+
: spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' })
|
|
548
|
+
if (result.status !== 0) throw commandFailure(lefthook, args, result)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function configSource(entry) {
|
|
552
|
+
return `${entry.origin}: ${JSON.stringify(entry.value)}`
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function normalizedPath(path) {
|
|
556
|
+
const normalized = resolve(path)
|
|
557
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function configOriginPath(origin, root) {
|
|
561
|
+
if (!origin.startsWith('file:')) return undefined
|
|
562
|
+
const originPath = origin.slice('file:'.length)
|
|
563
|
+
return isAbsolute(originPath) ? originPath : resolve(root, originPath)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function originIsFile(origin, root, configPath) {
|
|
567
|
+
const originPath = configOriginPath(origin, root)
|
|
568
|
+
return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function refuseInheritedHooksPath(entry) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`refusing to replace user-owned core.hooksPath (${configSource(entry)}). `
|
|
574
|
+
+ `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, `
|
|
575
|
+
+ `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`,
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function refuseScopedHooksPath(entry) {
|
|
580
|
+
if (entry.scope === 'command') {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`refusing to replace command-scoped core.hooksPath (${configSource(entry)}); `
|
|
583
|
+
+ `${ALLOW_HOOKS_PATH_OVERRIDE} cannot override transient command configuration`,
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
if (entry.scope === 'worktree') {
|
|
587
|
+
throw new Error(
|
|
588
|
+
`refusing to replace worktree-scoped core.hooksPath (${configSource(entry)}); `
|
|
589
|
+
+ 'a worktree-specific custom path must be integrated or removed explicitly',
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
throw new Error(
|
|
593
|
+
`refusing to replace core.hooksPath from unsupported ${entry.scope} scope (${configSource(entry)})`,
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function main() {
|
|
598
|
+
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
|
|
599
|
+
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
|
|
600
|
+
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
|
601
|
+
if (probe.status !== 0) return
|
|
602
|
+
const root = stripGitLineTerminator(probe.stdout)
|
|
603
|
+
const isWindows = process.platform === 'win32'
|
|
604
|
+
const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
|
|
605
|
+
if (!existsSync(lefthook)) return
|
|
606
|
+
|
|
607
|
+
assertSupportedGit(root)
|
|
608
|
+
const gitDirectory = stripGitLineTerminator(git(['rev-parse', '--absolute-git-dir'], root).stdout)
|
|
609
|
+
const commonOutput = stripGitLineTerminator(git(['rev-parse', '--git-common-dir'], root).stdout)
|
|
610
|
+
const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput)
|
|
611
|
+
const commonConfigPath = join(commonDirectory, 'config')
|
|
612
|
+
const worktreeConfigPath = join(gitDirectory, 'config.worktree')
|
|
613
|
+
const hooksPath = join(gitDirectory, HOOKS_DIRECTORY)
|
|
614
|
+
const releaseLock = await acquireInstallLock(commonDirectory)
|
|
615
|
+
let installationError
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
assertCommonConfigFile(commonConfigPath)
|
|
619
|
+
assertWorktreeConfigFiles(
|
|
620
|
+
root,
|
|
621
|
+
commonDirectory,
|
|
622
|
+
commonConfigPath,
|
|
623
|
+
worktreeConfigPath,
|
|
624
|
+
)
|
|
625
|
+
const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath')
|
|
626
|
+
const includedWorktreeEntry = worktreeEntries.find(
|
|
627
|
+
entry => !originIsFile(entry.origin, root, worktreeConfigPath),
|
|
628
|
+
)
|
|
629
|
+
if (includedWorktreeEntry !== undefined) {
|
|
630
|
+
refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' })
|
|
631
|
+
}
|
|
632
|
+
const worktreePath = assertSingle(
|
|
633
|
+
worktreeEntries.map(entry => entry.value),
|
|
634
|
+
'worktree core.hooksPath',
|
|
635
|
+
)
|
|
636
|
+
let ownedHooksDirectory
|
|
637
|
+
let copiedWorktreePathIsOwned = false
|
|
638
|
+
if (worktreePath !== undefined && worktreePath !== hooksPath) {
|
|
639
|
+
ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath)
|
|
640
|
+
const worktreePathIsRelocated = ownedHooksDirectory?.hooksPath === worktreePath
|
|
641
|
+
copiedWorktreePathIsOwned = !worktreePathIsRelocated
|
|
642
|
+
&& isRegisteredOwnedHooksPath(commonDirectory, worktreePath)
|
|
643
|
+
if (!worktreePathIsRelocated && !copiedWorktreePathIsOwned) {
|
|
644
|
+
refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const directWorktreePathIsOwned = worktreePath !== undefined
|
|
648
|
+
&& (
|
|
649
|
+
worktreePath === hooksPath
|
|
650
|
+
|| ownedHooksDirectory?.hooksPath === worktreePath
|
|
651
|
+
|| copiedWorktreePathIsOwned
|
|
652
|
+
)
|
|
653
|
+
const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
|
|
654
|
+
if (effectiveEntry !== undefined) {
|
|
655
|
+
const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
|
|
656
|
+
&& effectiveEntry.value === worktreePath
|
|
657
|
+
&& directWorktreePathIsOwned
|
|
658
|
+
&& originIsFile(effectiveEntry.origin, root, worktreeConfigPath)
|
|
659
|
+
if (!effectivePathIsOwned) {
|
|
660
|
+
if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') {
|
|
661
|
+
refuseScopedHooksPath(effectiveEntry)
|
|
662
|
+
}
|
|
663
|
+
if (!['system', 'global', 'local'].includes(effectiveEntry.scope)) {
|
|
664
|
+
refuseScopedHooksPath(effectiveEntry)
|
|
665
|
+
}
|
|
666
|
+
if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') {
|
|
667
|
+
refuseInheritedHooksPath(effectiveEntry)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const migration = planWorktreeConfigMigration(root, commonConfigPath)
|
|
672
|
+
ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath)
|
|
673
|
+
if (
|
|
674
|
+
worktreePath !== undefined
|
|
675
|
+
&& worktreePath !== hooksPath
|
|
676
|
+
&& ownedHooksDirectory.hooksPath !== worktreePath
|
|
677
|
+
&& !copiedWorktreePathIsOwned
|
|
678
|
+
) {
|
|
679
|
+
throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`)
|
|
680
|
+
}
|
|
681
|
+
applyWorktreeConfigMigration(root, commonConfigPath, migration)
|
|
682
|
+
|
|
683
|
+
let pathChanged = false
|
|
684
|
+
try {
|
|
685
|
+
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
|
|
686
|
+
pathChanged = worktreePath !== hooksPath
|
|
687
|
+
const installedEntry = effectiveConfigEntry(root, 'core.hooksPath')
|
|
688
|
+
if (
|
|
689
|
+
installedEntry === undefined
|
|
690
|
+
|| installedEntry.scope !== 'worktree'
|
|
691
|
+
|| installedEntry.value !== hooksPath
|
|
692
|
+
|| !originIsFile(installedEntry.origin, root, worktreeConfigPath)
|
|
693
|
+
) {
|
|
694
|
+
throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value')
|
|
695
|
+
}
|
|
696
|
+
runLefthook(root, lefthook)
|
|
697
|
+
updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath)
|
|
698
|
+
} catch (error) {
|
|
699
|
+
const rollbackErrors = []
|
|
700
|
+
if (pathChanged) {
|
|
701
|
+
try {
|
|
702
|
+
if (worktreePath === undefined) {
|
|
703
|
+
git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root)
|
|
704
|
+
} else {
|
|
705
|
+
git(['config', '--worktree', 'core.hooksPath', worktreePath], root)
|
|
706
|
+
}
|
|
707
|
+
} catch (rollbackError) {
|
|
708
|
+
rollbackErrors.push(rollbackError)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (rollbackErrors.length > 0) {
|
|
712
|
+
throw new AggregateError(
|
|
713
|
+
[error, ...rollbackErrors],
|
|
714
|
+
`Lefthook installation failed: ${String(error)}; `
|
|
715
|
+
+ `worktree integration rollback also failed: ${rollbackErrors.map(String).join('; ')}`,
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
throw error
|
|
719
|
+
}
|
|
720
|
+
} catch (error) {
|
|
721
|
+
installationError = error
|
|
722
|
+
throw error
|
|
723
|
+
} finally {
|
|
724
|
+
try {
|
|
725
|
+
releaseLock()
|
|
726
|
+
} catch (releaseError) {
|
|
727
|
+
if (installationError !== undefined) {
|
|
728
|
+
throw new AggregateError(
|
|
729
|
+
[installationError, releaseError],
|
|
730
|
+
`Lefthook installation failed: ${String(installationError)}; installer lock release also failed: ${String(releaseError)}`,
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
throw releaseError
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
await main()
|
|
740
|
+
} catch (error) {
|
|
741
|
+
console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`)
|
|
742
|
+
process.exitCode = 1
|
|
743
|
+
}
|