@weotro/dx 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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +755 -0
  3. package/bin/dx-with-version-env.js +8 -0
  4. package/bin/dx.js +187 -0
  5. package/lib/artifact-deploy/artifact-builder.js +144 -0
  6. package/lib/artifact-deploy/config.js +180 -0
  7. package/lib/artifact-deploy/remote-script.js +301 -0
  8. package/lib/artifact-deploy/remote-transport.js +86 -0
  9. package/lib/artifact-deploy.js +70 -0
  10. package/lib/backend-artifact-deploy/artifact-builder.js +267 -0
  11. package/lib/backend-artifact-deploy/config.js +218 -0
  12. package/lib/backend-artifact-deploy/path-utils.js +18 -0
  13. package/lib/backend-artifact-deploy/remote-phases.js +14 -0
  14. package/lib/backend-artifact-deploy/remote-result.js +44 -0
  15. package/lib/backend-artifact-deploy/remote-script.js +507 -0
  16. package/lib/backend-artifact-deploy/remote-transport.js +123 -0
  17. package/lib/backend-artifact-deploy/rollback.js +5 -0
  18. package/lib/backend-artifact-deploy/runtime-package.js +46 -0
  19. package/lib/backend-artifact-deploy.js +91 -0
  20. package/lib/backend-package.js +674 -0
  21. package/lib/cli/args.js +38 -0
  22. package/lib/cli/command-result.js +1 -0
  23. package/lib/cli/commands/contracts.js +60 -0
  24. package/lib/cli/commands/core.js +533 -0
  25. package/lib/cli/commands/db.js +231 -0
  26. package/lib/cli/commands/deploy.js +175 -0
  27. package/lib/cli/commands/env.js +120 -0
  28. package/lib/cli/commands/export.js +39 -0
  29. package/lib/cli/commands/package.js +22 -0
  30. package/lib/cli/commands/release.js +55 -0
  31. package/lib/cli/commands/stack.js +427 -0
  32. package/lib/cli/commands/start.js +58 -0
  33. package/lib/cli/commands/worktree.js +145 -0
  34. package/lib/cli/dx-cli.js +1072 -0
  35. package/lib/cli/flags.js +123 -0
  36. package/lib/cli/help-model.js +222 -0
  37. package/lib/cli/help-renderer.js +137 -0
  38. package/lib/cli/help-schema.js +552 -0
  39. package/lib/cli/help.js +141 -0
  40. package/lib/cli/index.js +4 -0
  41. package/lib/cli/nx-command.js +13 -0
  42. package/lib/codex-initial.js +271 -0
  43. package/lib/confirm.js +213 -0
  44. package/lib/env-policy.js +134 -0
  45. package/lib/env-profile.js +435 -0
  46. package/lib/env.js +261 -0
  47. package/lib/exec.js +692 -0
  48. package/lib/logger.js +239 -0
  49. package/lib/nx-ignore.js +45 -0
  50. package/lib/run-with-version-env.js +163 -0
  51. package/lib/sdk-build.js +424 -0
  52. package/lib/start-dev.js +401 -0
  53. package/lib/telegram-webhook.js +431 -0
  54. package/lib/validate-env.js +317 -0
  55. package/lib/vercel-deploy.js +549 -0
  56. package/lib/version.js +14 -0
  57. package/lib/worktree.js +1052 -0
  58. package/package.json +45 -0
  59. package/skills/create-issue/SKILL.md +90 -0
  60. package/skills/delivering-design-handoff/SKILL.md +290 -0
  61. package/skills/doctor/SKILL.md +76 -0
  62. package/skills/gh-dependabot-cleanup/SKILL.md +54 -0
  63. package/skills/gh-dependabot-cleanup/agents/openai.yaml +7 -0
  64. package/skills/git-release/SKILL.md +194 -0
  65. package/skills/git-release/agents/openai.yaml +7 -0
  66. package/skills/online-debug-guard/SKILL.md +111 -0
  67. package/skills/ship-issue-pr/SKILL.md +676 -0
  68. package/skills/stagewise-ui-debugging/SKILL.md +48 -0
@@ -0,0 +1,1052 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path'
4
+ import fs from 'node:fs'
5
+ import { execSync } from 'node:child_process'
6
+ import { logger } from './logger.js'
7
+ import { confirmManager } from './confirm.js'
8
+ //
9
+
10
+ const DEFAULT_COPY_TARGETS = [
11
+ {
12
+ path: 'node_modules',
13
+ label: '根依赖 node_modules',
14
+ required: false,
15
+ category: 'deps',
16
+ linkable: false,
17
+ skip: true,
18
+ },
19
+ {
20
+ path: 'apps/backend/node_modules',
21
+ label: '后端依赖 node_modules',
22
+ required: false,
23
+ category: 'deps',
24
+ linkable: false,
25
+ skip: true,
26
+ },
27
+ {
28
+ path: 'apps/front/node_modules',
29
+ label: '用户前端依赖 node_modules',
30
+ required: false,
31
+ category: 'deps',
32
+ linkable: false,
33
+ skip: true,
34
+ },
35
+ {
36
+ path: 'apps/admin-front/node_modules',
37
+ label: '管理后台依赖 node_modules',
38
+ required: false,
39
+ category: 'deps',
40
+ linkable: false,
41
+ skip: true,
42
+ },
43
+ {
44
+ path: 'packages/shared/node_modules',
45
+ label: '共享包依赖 node_modules',
46
+ required: false,
47
+ category: 'deps',
48
+ linkable: false,
49
+ skip: true,
50
+ },
51
+ {
52
+ path: 'apps/sdk/node_modules',
53
+ label: 'SDK 依赖 node_modules',
54
+ required: false,
55
+ category: 'deps',
56
+ linkable: false,
57
+ skip: true,
58
+ },
59
+ {
60
+ path: 'apps/sdk/src',
61
+ label: 'SDK 生成源码',
62
+ required: false,
63
+ category: 'sdk',
64
+ linkable: false,
65
+ },
66
+ {
67
+ path: 'apps/sdk/dist',
68
+ label: 'SDK 构建输出',
69
+ required: false,
70
+ category: 'build',
71
+ linkable: true,
72
+ copyMode: 'archive',
73
+ },
74
+ ]
75
+
76
+ const shellEscape = value => {
77
+ if (!value) return '""'
78
+ return `"${value.replace(/(["\\$`])/g, '\\$1')}"`
79
+ }
80
+
81
+ const ensureTrailingSlash = value => (value.endsWith('/') ? value : `${value}/`)
82
+ const ISSUE_NUMBER_PATTERN = /^\d+$/
83
+
84
+ class WorktreeManager {
85
+ constructor() {
86
+ this.repoRoot = process.cwd()
87
+ this.baseDir = path.resolve(this.repoRoot, '..')
88
+ // 使用仓库根目录名称作为前缀
89
+ const repoName = path.basename(this.repoRoot)
90
+ this.prefix = `${repoName}_issue_`
91
+ this.assetCopyTargets = DEFAULT_COPY_TARGETS
92
+ const rsyncInfo = this.detectRsync()
93
+ this.hasFastCopy = rsyncInfo.available
94
+ this.rsyncSupportsProgress2 = rsyncInfo.supportsProgress2
95
+ this.hasTar = this.detectTar()
96
+ }
97
+
98
+ detectRsync() {
99
+ try {
100
+ const output = execSync('rsync --version', { encoding: 'utf8' })
101
+ const versionMatch = output.match(/version\s+(\d+)\.(\d+)\.(\d+)/i)
102
+ if (!versionMatch) {
103
+ return { available: true, supportsProgress2: false }
104
+ }
105
+ const [, major, minor] = versionMatch.map(Number)
106
+ const supportsProgress2 = major > 3 || (major === 3 && minor >= 1)
107
+ return { available: true, supportsProgress2 }
108
+ } catch {
109
+ return { available: false, supportsProgress2: false }
110
+ }
111
+ }
112
+
113
+ detectTar() {
114
+ try {
115
+ execSync('tar --version', { stdio: 'ignore' })
116
+ return true
117
+ } catch {
118
+ return false
119
+ }
120
+ }
121
+
122
+ copyDirectoryWithNode(sourcePath, destinationPath) {
123
+ if (fs.existsSync(destinationPath)) {
124
+ fs.rmSync(destinationPath, { recursive: true, force: true })
125
+ }
126
+ fs.mkdirSync(destinationPath, { recursive: true })
127
+ fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true })
128
+ }
129
+
130
+ copyDirectoryWithRsync(sourcePath, destinationPath) {
131
+ fs.mkdirSync(destinationPath, { recursive: true })
132
+ const sourceArg = shellEscape(ensureTrailingSlash(sourcePath))
133
+ const destinationArg = shellEscape(ensureTrailingSlash(destinationPath))
134
+ const progressFlag = this.rsyncSupportsProgress2 ? '--info=progress2' : '--progress'
135
+ const command = `rsync -a --delete --human-readable ${progressFlag} ${sourceArg} ${destinationArg}`
136
+ execSync(command, { stdio: 'inherit' })
137
+ }
138
+
139
+ copyDirectoryWithTar(sourcePath, destinationPath) {
140
+ const sourceParent = path.dirname(sourcePath)
141
+ const sourceName = path.basename(sourcePath)
142
+ const destinationParent = path.dirname(destinationPath)
143
+
144
+ if (fs.existsSync(destinationPath)) {
145
+ fs.rmSync(destinationPath, { recursive: true, force: true })
146
+ }
147
+ fs.mkdirSync(destinationParent, { recursive: true })
148
+
149
+ const sourceParentArg = shellEscape(sourceParent)
150
+ const sourceNameArg = shellEscape(sourceName)
151
+ const destinationParentArg = shellEscape(destinationParent)
152
+ const command = `tar -C ${sourceParentArg} -cf - ${sourceNameArg} | tar -C ${destinationParentArg} -xf -`
153
+ execSync(command, { stdio: 'inherit' })
154
+ }
155
+
156
+ copyFile(sourcePath, destinationPath) {
157
+ const dir = path.dirname(destinationPath)
158
+ fs.mkdirSync(dir, { recursive: true })
159
+ fs.copyFileSync(sourcePath, destinationPath)
160
+ }
161
+
162
+ linkAsset(sourcePath, destinationPath) {
163
+ const stats = fs.lstatSync(sourcePath)
164
+
165
+ if (fs.existsSync(destinationPath)) {
166
+ fs.rmSync(destinationPath, { recursive: true, force: true })
167
+ }
168
+
169
+ const parentDir = path.dirname(destinationPath)
170
+ fs.mkdirSync(parentDir, { recursive: true })
171
+
172
+ const isDir = stats.isDirectory()
173
+ const linkType = this.getSymlinkType(isDir)
174
+ fs.symlinkSync(sourcePath, destinationPath, linkType)
175
+
176
+ return isDir ? 'link-dir' : 'link-file'
177
+ }
178
+
179
+ getSymlinkType(isDir) {
180
+ if (process.platform === 'win32') {
181
+ return isDir ? 'junction' : 'file'
182
+ }
183
+ return isDir ? 'dir' : 'file'
184
+ }
185
+
186
+ copyAsset(sourcePath, destinationPath, target = {}) {
187
+ const stats = fs.lstatSync(sourcePath)
188
+ const copyMode = target.copyMode || 'auto'
189
+
190
+ if (stats.isDirectory()) {
191
+ if (copyMode === 'archive' && this.hasTar) {
192
+ try {
193
+ this.copyDirectoryWithTar(sourcePath, destinationPath)
194
+ return 'tar'
195
+ } catch (error) {
196
+ logger.warn(`⚠️ tar 复制失败,回退到常规复制: ${error.message}`)
197
+ }
198
+ }
199
+
200
+ if (this.hasFastCopy) {
201
+ try {
202
+ this.copyDirectoryWithRsync(sourcePath, destinationPath)
203
+ return 'rsync'
204
+ } catch (error) {
205
+ this.hasFastCopy = false
206
+ logger.warn(`⚠️ rsync 复制失败,回退到 Node.js 复制: ${error.message}`)
207
+ }
208
+ }
209
+ this.copyDirectoryWithNode(sourcePath, destinationPath)
210
+ return 'node'
211
+ }
212
+
213
+ this.copyFile(sourcePath, destinationPath)
214
+ return 'file'
215
+ }
216
+
217
+ // 获取 worktree 路径
218
+ getWorktreePath(issueNumber) {
219
+ return path.join(this.baseDir, `${this.prefix}${issueNumber}`)
220
+ }
221
+
222
+ isStrictIssueNumber(issueNumber) {
223
+ return typeof issueNumber === 'string' && ISSUE_NUMBER_PATTERN.test(issueNumber)
224
+ }
225
+
226
+ extractIssueNumberFromPath(worktreePath) {
227
+ const baseName = path.basename(worktreePath)
228
+ if (!baseName.startsWith(this.prefix)) {
229
+ return null
230
+ }
231
+
232
+ const issueNumber = baseName.slice(this.prefix.length)
233
+ return this.isStrictIssueNumber(issueNumber) ? issueNumber : null
234
+ }
235
+
236
+ // 创建 worktree
237
+ async make(issueNumber, options = {}) {
238
+ if (!this.isStrictIssueNumber(issueNumber)) {
239
+ logger.error('issue 编号必须为纯数字字符串')
240
+ return false
241
+ }
242
+
243
+ const worktreePath = this.getWorktreePath(issueNumber)
244
+ const branchName = `issue-${issueNumber}`
245
+ const baseBranch = (options.baseBranch || 'main').trim()
246
+ const linkStrategy = options.linkStrategy || 'deps'
247
+
248
+ logger.info(`\n${'='.repeat(50)}`)
249
+ logger.info(`🔧 创建 Worktree: ${branchName}`)
250
+ logger.info(`基础分支: ${baseBranch}`)
251
+ logger.info('模式: 不再自动同步 node_modules,请在新 worktree 中自行安装依赖(例如: pnpm install)')
252
+ logger.info('='.repeat(50))
253
+
254
+ // 检查目标目录是否存在
255
+ if (fs.existsSync(worktreePath)) {
256
+ logger.error(`Worktree 目录已存在: ${worktreePath}`)
257
+ logger.info('提示: 如需重建,请先删除现有 worktree')
258
+ return false
259
+ }
260
+
261
+ try {
262
+ // 获取当前分支
263
+ const currentBranch = execSync('git branch --show-current').toString().trim()
264
+ logger.info(`当前分支: ${currentBranch}`)
265
+
266
+ // 确保基础分支是最新的
267
+ logger.step(`更新 ${baseBranch} 分支...`)
268
+ try {
269
+ if (currentBranch === baseBranch) {
270
+ execSync(`git pull origin ${baseBranch}`, { stdio: 'inherit' })
271
+ } else {
272
+ execSync(`git fetch origin ${baseBranch}:${baseBranch}`, { stdio: 'inherit' })
273
+ }
274
+ } catch (e) {
275
+ logger.warn(`更新 ${baseBranch} 分支失败,尝试兜底同步`)
276
+ try {
277
+ execSync('git fetch --all --prune', { stdio: 'inherit' })
278
+ try {
279
+ execSync(`git show-ref --verify --quiet refs/heads/${baseBranch}`)
280
+ } catch (_) {
281
+ execSync(`git branch ${baseBranch} origin/${baseBranch}`, { stdio: 'inherit' })
282
+ }
283
+ execSync(`git branch -f ${baseBranch} origin/${baseBranch}`, { stdio: 'inherit' })
284
+ } catch (e2) {
285
+ logger.error(`无法更新基础分支 ${baseBranch}: ${e2.message}`)
286
+ throw e2
287
+ }
288
+ }
289
+
290
+ // 创建 worktree
291
+ logger.step(`创建 worktree 到 ${worktreePath}`)
292
+
293
+ // 检查分支是否已存在
294
+ let branchExists = false
295
+ try {
296
+ execSync(`git show-ref --verify --quiet refs/heads/${branchName}`)
297
+ branchExists = true
298
+ logger.info(`分支 ${branchName} 已存在,将使用现有分支`)
299
+ } catch (e) {
300
+ // 分支不存在,这是正常的
301
+ }
302
+
303
+ if (branchExists) {
304
+ // 使用现有分支
305
+ execSync(`git worktree add "${worktreePath}" ${branchName}`, { stdio: 'inherit' })
306
+ } else {
307
+ // 从基础分支创建新分支
308
+ execSync(`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, {
309
+ stdio: 'inherit',
310
+ })
311
+ }
312
+
313
+ logger.success(`✅ Worktree 创建成功: ${worktreePath}`)
314
+
315
+ // 复制环境变量文件
316
+ logger.step('复制环境变量文件到新 worktree...')
317
+ await this.copyEnvFiles(worktreePath)
318
+
319
+ // 同步主目录中的依赖与构建产物
320
+ logger.info('\n📦 同步依赖与构建产物...')
321
+ await this.buildWorktree(worktreePath, { linkStrategy })
322
+
323
+ // 提供快速切换命令
324
+ logger.info('\n快速切换到新 worktree:')
325
+ logger.info(` $ cd ${worktreePath}`)
326
+
327
+ return true
328
+ } catch (error) {
329
+ logger.error(`创建 worktree 失败: ${error.message}`)
330
+ return false
331
+ }
332
+ }
333
+
334
+ // 复制环境变量文件
335
+ async copyEnvFiles(worktreePath) {
336
+ const sourceRoot = this.repoRoot
337
+
338
+ // 定义需要复制的环境变量文件
339
+ const envFiles = [
340
+ '.env.development.local',
341
+ '.env.production.local',
342
+ '.env.test.local',
343
+ '.env.e2e.local',
344
+ 'apps/backend/.env.e2e.local',
345
+ ]
346
+
347
+ let copiedCount = 0
348
+ let skippedCount = 0
349
+
350
+ for (const envFile of envFiles) {
351
+ const sourcePath = path.join(sourceRoot, envFile)
352
+ const targetPath = path.join(worktreePath, envFile)
353
+
354
+ try {
355
+ // 检查源文件是否存在
356
+ if (fs.existsSync(sourcePath)) {
357
+ // 确保目标目录存在
358
+ const targetDir = path.dirname(targetPath)
359
+ if (!fs.existsSync(targetDir)) {
360
+ fs.mkdirSync(targetDir, { recursive: true })
361
+ }
362
+
363
+ // 复制文件
364
+ fs.copyFileSync(sourcePath, targetPath)
365
+ logger.info(` ✅ 复制: ${envFile}`)
366
+ copiedCount++
367
+ } else {
368
+ logger.info(` ⚠️ 跳过: ${envFile} (源文件不存在)`)
369
+ skippedCount++
370
+ }
371
+ } catch (error) {
372
+ logger.error(` ❌ 复制失败: ${envFile} - ${error.message}`)
373
+ skippedCount++
374
+ }
375
+ }
376
+
377
+ if (copiedCount > 0) {
378
+ logger.success(`✅ 环境变量文件复制完成: ${copiedCount} 个文件已复制`)
379
+ }
380
+ if (skippedCount > 0) {
381
+ logger.info(`📝 跳过 ${skippedCount} 个文件`)
382
+ }
383
+ }
384
+
385
+ getGitRoot() {
386
+ try {
387
+ const output = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
388
+ return output ? path.resolve(output) : null
389
+ } catch {
390
+ return null
391
+ }
392
+ }
393
+
394
+ getMainWorktreeRoot() {
395
+ try {
396
+ const output = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim()
397
+ if (!output) return null
398
+ const commonDir = path.isAbsolute(output) ? output : path.resolve(process.cwd(), output)
399
+ return path.dirname(commonDir)
400
+ } catch {
401
+ return null
402
+ }
403
+ }
404
+
405
+ syncEnvFilesFromMainRoot(options = {}) {
406
+ const { onlyMissing = true } = options
407
+ const currentRoot = this.getGitRoot()
408
+ if (!currentRoot) return false
409
+
410
+ const mainRoot = this.getMainWorktreeRoot()
411
+ if (!mainRoot) return false
412
+ if (currentRoot === mainRoot) return false
413
+
414
+ let entries = []
415
+ try {
416
+ entries = fs.readdirSync(mainRoot, { withFileTypes: true })
417
+ } catch (error) {
418
+ logger.warn(`读取主工作区目录失败: ${error.message}`)
419
+ return false
420
+ }
421
+
422
+ const envFiles = entries
423
+ .filter(entry => entry.isFile())
424
+ .map(entry => entry.name)
425
+ .filter(name => name.startsWith('.env.') && name.endsWith('.local') && name !== '.env.local')
426
+
427
+ if (envFiles.length === 0) return false
428
+
429
+ let copiedCount = 0
430
+ let skippedCount = 0
431
+ const copiedFiles = []
432
+ const skippedFiles = []
433
+
434
+ logger.step('检测到 worktree,自动同步 .env.*.local 文件...')
435
+
436
+ for (const envFile of envFiles) {
437
+ const sourcePath = path.join(mainRoot, envFile)
438
+ const targetPath = path.join(currentRoot, envFile)
439
+
440
+ if (onlyMissing && fs.existsSync(targetPath)) {
441
+ skippedCount++
442
+ skippedFiles.push(envFile)
443
+ continue
444
+ }
445
+
446
+ try {
447
+ fs.copyFileSync(sourcePath, targetPath)
448
+ copiedCount++
449
+ copiedFiles.push(envFile)
450
+ } catch (error) {
451
+ logger.warn(`复制 ${envFile} 失败: ${error.message}`)
452
+ }
453
+ }
454
+
455
+ if (copiedCount > 0) {
456
+ logger.success(`已同步 ${copiedCount} 个 env 文件到当前 worktree: ${copiedFiles.join(', ')}`)
457
+ }
458
+ if (skippedCount > 0) {
459
+ logger.info(`已跳过 ${skippedCount} 个已存在的 env 文件: ${skippedFiles.join(', ')}`)
460
+ }
461
+
462
+ return copiedCount > 0
463
+ }
464
+
465
+ // 判定当前资源是否允许按照指定策略创建软链接
466
+ shouldLinkTarget(target, linkStrategy) {
467
+ if (!target?.linkable) return false
468
+ if (linkStrategy === 'all') return true
469
+ if (linkStrategy === 'deps') return target?.category === 'deps'
470
+ return false
471
+ }
472
+
473
+ // 同步 worktree 所需依赖与构建产物
474
+ async buildWorktree(worktreePath, options = {}) {
475
+ const linkStrategy = options.linkStrategy || 'deps'
476
+
477
+ const targets = this.assetCopyTargets
478
+ const totalTargets = targets.length
479
+
480
+ logger.info(`\n${'='.repeat(50)}`)
481
+ logger.info('📦 同步 Worktree 依赖与构建产物')
482
+ logger.info('说明: 所有 node_modules 目录已不再自动同步,请在新 worktree 中手动安装依赖。')
483
+ if (linkStrategy === 'all') {
484
+ logger.info('模式: 仅非依赖型可缓存目录仍可能使用软链接')
485
+ }
486
+ logger.info('='.repeat(50))
487
+
488
+ const summary = {
489
+ copied: [],
490
+ linked: [],
491
+ skipped: [],
492
+ failed: [],
493
+ }
494
+
495
+ targets.forEach((target, index) => {
496
+ const label = target.label || target.path
497
+ const sourcePath = path.join(this.repoRoot, target.path)
498
+ const destinationPath = path.join(worktreePath, target.path)
499
+ const required = Boolean(target.required)
500
+ const shouldLink = this.shouldLinkTarget(target, linkStrategy)
501
+ const actionVerb = shouldLink ? '链接' : '复制'
502
+
503
+ // node_modules 等显式跳过的目标:不再做任何同步,只给一条提示
504
+ if (target.skip) {
505
+ logger.progress(`[${index + 1}/${totalTargets}] 跳过 ${label}`)
506
+ logger.progressDone()
507
+ logger.info(` 📝 ${label} 已不再自动同步,请在新 worktree 中自行安装对应依赖。`)
508
+ summary.skipped.push({ label, reason: 'skip-config', required: false })
509
+ return
510
+ }
511
+
512
+ if (!fs.existsSync(sourcePath)) {
513
+ logger.progress(`[${index + 1}/${totalTargets}] 检查 ${label}`)
514
+ logger.progressDone()
515
+ logger.info(` ⚠️ 源路径不存在,跳过 ${label}`)
516
+ summary.skipped.push({ label, reason: 'missing', required })
517
+ if (required) {
518
+ summary.failed.push({ label, reason: '源路径不存在', required: true })
519
+ }
520
+ return
521
+ }
522
+
523
+ logger.progress(`[${index + 1}/${totalTargets}] ${actionVerb} ${label}`)
524
+ try {
525
+ const method = shouldLink
526
+ ? this.linkAsset(sourcePath, destinationPath)
527
+ : this.copyAsset(sourcePath, destinationPath, target)
528
+ const methodNote = shouldLink
529
+ ? '(软链接)'
530
+ : method === 'rsync'
531
+ ? '(rsync)'
532
+ : method === 'tar'
533
+ ? '(tar 打包传输)'
534
+ : method === 'node'
535
+ ? '(Node.js 复制)'
536
+ : ''
537
+
538
+ logger.info(` ✅ 已${actionVerb} ${label}${methodNote}`)
539
+ if (shouldLink) {
540
+ summary.linked.push(label)
541
+ } else {
542
+ summary.copied.push(label)
543
+ }
544
+ } catch (error) {
545
+ summary.failed.push({ label, reason: error.message, required })
546
+ logger.error(` ❌ ${actionVerb} ${label} 失败: ${error.message}`)
547
+ } finally {
548
+ logger.progressDone()
549
+ }
550
+ })
551
+
552
+ if (summary.linked.length > 0) {
553
+ logger.info(`🔗 已软链接 ${summary.linked.length} 项资源`)
554
+ }
555
+
556
+ if (summary.copied.length > 0) {
557
+ logger.info(`📁 已复制 ${summary.copied.length} 项资源`)
558
+ }
559
+
560
+ if (summary.skipped.length > 0) {
561
+ const skippedLabels = summary.skipped.map(item => item.label).join(', ')
562
+ logger.info(`📝 跳过: ${skippedLabels}`)
563
+ }
564
+
565
+ if (summary.failed.length > 0) {
566
+ const requiredFailed = summary.failed.filter(item => item.required)
567
+ if (requiredFailed.length > 0) {
568
+ logger.error('❌ 必需资源同步失败,请先在主目录完成依赖安装或构建后再尝试创建 worktree。')
569
+ requiredFailed.forEach(item => {
570
+ logger.error(` - ${item.label}: ${item.reason}`)
571
+ })
572
+ process.exitCode = 1
573
+ }
574
+
575
+ const optionalFailed = summary.failed.filter(item => !item.required)
576
+ if (optionalFailed.length > 0) {
577
+ logger.warn('⚠️ 部分非必需资源同步失败,可在新 worktree 中按需重新构建。')
578
+ optionalFailed.forEach(item => {
579
+ logger.warn(` - ${item.label}: ${item.reason}`)
580
+ })
581
+ }
582
+ } else {
583
+ const syncedCount = summary.copied.length + summary.linked.length
584
+ logger.success(`✅ Worktree 依赖与构建产物同步完成(同步 ${syncedCount} 项)`)
585
+ }
586
+ }
587
+
588
+ // 删除 worktree(支持批量删除)
589
+ async del(issueNumbers, options = {}) {
590
+ if (!Array.isArray(issueNumbers)) {
591
+ logger.error('请提供 issue 编号数组')
592
+ return false
593
+ }
594
+
595
+ if (issueNumbers.length === 0) {
596
+ logger.error('请至少提供一个 issue 编号')
597
+ return false
598
+ }
599
+
600
+ if (issueNumbers.some(issueNumber => !this.isStrictIssueNumber(issueNumber))) {
601
+ logger.error('issue 编号必须为纯数字字符串')
602
+ return false
603
+ }
604
+
605
+ const totalCount = issueNumbers.length
606
+ const isBatch = totalCount > 1
607
+
608
+ logger.info(`\n${'='.repeat(60)}`)
609
+ if (isBatch) {
610
+ logger.info(`🗑️ 批量删除 Worktree: ${totalCount} 个`)
611
+ logger.info(`Issue 编号: ${issueNumbers.join(', ')}`)
612
+ } else {
613
+ logger.info(`🗑️ 删除 Worktree: issue-${issueNumbers[0]}`)
614
+ }
615
+ logger.info('='.repeat(60))
616
+
617
+ let successCount = 0
618
+ let failedCount = 0
619
+ const results = []
620
+
621
+ for (let i = 0; i < issueNumbers.length; i++) {
622
+ const issueNumber = issueNumbers[i]
623
+ const isLast = i === issueNumbers.length - 1
624
+
625
+ if (isBatch) {
626
+ logger.info(`\n[${i + 1}/${totalCount}] 处理 issue-${issueNumber}...`)
627
+ }
628
+
629
+ const result = await this.delSingle(issueNumber, options, isBatch, isLast)
630
+ results.push({ issueNumber, success: result })
631
+
632
+ if (result) {
633
+ successCount++
634
+ } else {
635
+ failedCount++
636
+ }
637
+
638
+ // 批量模式下,在每个删除操作之间添加短暂延迟
639
+ if (isBatch && !isLast) {
640
+ await new Promise(resolve => setTimeout(resolve, 500))
641
+ }
642
+ }
643
+
644
+ // 显示批量操作总结
645
+ if (isBatch) {
646
+ logger.info(`\n${'='.repeat(60)}`)
647
+ logger.info('📊 批量删除总结')
648
+ logger.info('='.repeat(60))
649
+ logger.info(`总计: ${totalCount} 个 worktree`)
650
+ logger.info(`成功: ${successCount} 个`)
651
+ if (failedCount > 0) {
652
+ logger.info(`失败: ${failedCount} 个`)
653
+
654
+ const failedIssues = results.filter(r => !r.success).map(r => r.issueNumber)
655
+ logger.warn(`失败的 issue: ${failedIssues.join(', ')}`)
656
+ }
657
+
658
+ if (successCount === totalCount) {
659
+ logger.success('✅ 所有 worktree 删除成功')
660
+ } else if (successCount > 0) {
661
+ logger.warn('⚠️ 部分 worktree 删除成功')
662
+ } else {
663
+ logger.error('❌ 所有 worktree 删除失败')
664
+ }
665
+ }
666
+
667
+ return successCount === totalCount
668
+ }
669
+
670
+ // 删除单个 worktree 的内部方法
671
+ async delSingle(issueNumber, options = {}, isBatch = false, isLast = false) {
672
+ const worktreePath = this.getWorktreePath(issueNumber)
673
+ const branchName = `issue-${issueNumber}`
674
+
675
+ if (!isBatch) {
676
+ logger.info(`\n${'='.repeat(50)}`)
677
+ logger.info(`🗑️ 删除 Worktree: ${branchName}`)
678
+ logger.info('='.repeat(50))
679
+ }
680
+
681
+ // 检查 worktree 是否存在
682
+ if (!fs.existsSync(worktreePath)) {
683
+ const message = `Worktree 不存在: ${worktreePath}`
684
+ if (isBatch) {
685
+ logger.warn(`⚠️ ${message}`)
686
+ } else {
687
+ logger.error(message)
688
+ }
689
+ return false
690
+ }
691
+
692
+ try {
693
+ // 检查是否有未提交的更改
694
+ const originalCwd = process.cwd()
695
+ process.chdir(worktreePath)
696
+
697
+ let hasUncommittedChanges = false
698
+ let hasUnpushedCommits = false
699
+
700
+ try {
701
+ // 检查未提交的更改(静默错误输出,避免噪声)
702
+ const gitStatus = execSync('git status --porcelain 2>/dev/null').toString()
703
+ if (gitStatus.trim()) {
704
+ hasUncommittedChanges = true
705
+ if (!isBatch || !options.force) {
706
+ logger.warn('⚠️ 检测到未提交的更改:')
707
+ console.log(gitStatus)
708
+ }
709
+ }
710
+
711
+ // 检查未推送的提交
712
+ const unpushed = execSync(
713
+ `git log origin/${branchName}..${branchName} --oneline 2>/dev/null || git log origin/HEAD..${branchName} --oneline`,
714
+ ).toString()
715
+ if (unpushed.trim()) {
716
+ hasUnpushedCommits = true
717
+ if (!isBatch || !options.force) {
718
+ logger.warn('⚠️ 检测到未推送的提交:')
719
+ console.log(unpushed)
720
+ }
721
+ }
722
+ } catch (error) {
723
+ // 可能是新分支还没有推送到远程
724
+ if (!isBatch || !options.force) {
725
+ logger.info('提示: 无法检查远程状态,可能是新分支')
726
+ }
727
+ }
728
+
729
+ process.chdir(originalCwd)
730
+
731
+ // 如果有未提交或未推送的更改,询问用户(除非是强制模式)
732
+ if ((hasUncommittedChanges || hasUnpushedCommits) && !options.force) {
733
+ logger.warn('\n⚠️ 警告: 检测到未保存的工作')
734
+ if (hasUncommittedChanges) {
735
+ logger.warn(' - 有未提交的更改')
736
+ }
737
+ if (hasUnpushedCommits) {
738
+ logger.warn(' - 有未推送的提交')
739
+ }
740
+
741
+ const confirmMessage = isBatch
742
+ ? `\n是否强制删除 issue-${issueNumber}?(这将丢失所有未保存的工作)`
743
+ : '\n是否强制删除?(这将丢失所有未保存的工作)'
744
+
745
+ const forceDelete = await confirmManager.confirm(confirmMessage)
746
+
747
+ if (!forceDelete) {
748
+ if (isBatch) {
749
+ logger.warn(`⚠️ 跳过 issue-${issueNumber}`)
750
+ } else {
751
+ logger.info('已取消删除操作')
752
+ logger.info('\n建议操作:')
753
+ if (hasUncommittedChanges) {
754
+ logger.info(' $ git add . && git commit -m "Save work"')
755
+ }
756
+ if (hasUnpushedCommits) {
757
+ logger.info(` $ git push origin ${branchName}`)
758
+ }
759
+ }
760
+ return false
761
+ }
762
+ }
763
+
764
+ // 删除 worktree
765
+ if (isBatch) {
766
+ logger.step(`删除 worktree: ${branchName}`)
767
+ } else {
768
+ logger.step('删除 worktree...')
769
+ }
770
+ try {
771
+ // 使用 pipe 捕获错误信息,便于识别损坏场景并做降级处理
772
+ execSync(`git worktree remove "${worktreePath}" --force`)
773
+ } catch (err) {
774
+ const msg = `${String(err?.message || '')}\n${String(err?.stderr || '')}\n${String(
775
+ err?.stdout || '',
776
+ )}`
777
+ // 当 worktree 的 .git 指针损坏或主仓库路径变更时,git 无法移除,降级为物理删除
778
+ const suspectedBroken = /not a \.git file|not a git repository|validation failed/i.test(msg)
779
+ if (suspectedBroken) {
780
+ const prompt = `检测到损坏的 worktree(可能更换过主仓库路径)。是否直接删除目录 ${worktreePath} ?(不可恢复)`
781
+ let doFsRemove = !!options.force
782
+ if (!doFsRemove) {
783
+ doFsRemove = await confirmManager.confirm(prompt)
784
+ }
785
+ if (!doFsRemove) {
786
+ return false
787
+ }
788
+ try {
789
+ // 优先使用 Node API 删除,失败再回退到 shell rm -rf
790
+ fs.rmSync(worktreePath, { recursive: true, force: true })
791
+ logger.info(`已物理删除目录: ${worktreePath}`)
792
+ } catch (rmErr) {
793
+ try {
794
+ execSync(`rm -rf "${worktreePath}"`)
795
+ logger.info(`已物理删除目录(回退): ${worktreePath}`)
796
+ } catch (rmErr2) {
797
+ logger.error(`物理删除目录失败: ${rmErr2.message}`)
798
+ return false
799
+ }
800
+ }
801
+ } else {
802
+ // 其他错误直接抛出
803
+ throw err
804
+ }
805
+ }
806
+
807
+ // 询问是否删除分支(批量模式下根据 force 选项决定)
808
+ let deleteBranch = false
809
+ if (options.force) {
810
+ // 非交互模式:默认删除分支
811
+ deleteBranch = true
812
+ } else {
813
+ const confirmMessage = isBatch
814
+ ? `是否同时删除本地分支 ${branchName}?`
815
+ : `是否同时删除本地分支 ${branchName}?`
816
+ deleteBranch = await confirmManager.confirm(confirmMessage)
817
+ }
818
+
819
+ if (deleteBranch) {
820
+ try {
821
+ execSync(`git branch -D ${branchName}`, { stdio: 'inherit' })
822
+ if (isBatch) {
823
+ logger.info(`✅ 分支 ${branchName} 已删除`)
824
+ } else {
825
+ logger.success(`✅ 分支 ${branchName} 已删除`)
826
+ }
827
+ } catch (error) {
828
+ const emsg = String(error?.message || '')
829
+ logger.warn(`无法删除分支: ${error.message}`)
830
+ // 若提示分支在某 worktree 已检出,先 prune 再重试一次
831
+ if (/checked out at/i.test(emsg)) {
832
+ try {
833
+ execSync('git worktree prune -v', { stdio: 'inherit' })
834
+ execSync(`git branch -D ${branchName}`, { stdio: 'inherit' })
835
+ logger.info(`✅ 分支 ${branchName} 已删除(二次尝试) `)
836
+ } catch (_) {
837
+ // 忽略重试失败
838
+ }
839
+ }
840
+ }
841
+ }
842
+
843
+ // 清理 worktree 列表(只在最后一个或单个删除时执行)
844
+ if (!isBatch || isLast) {
845
+ logger.step('清理 worktree 列表...')
846
+ try {
847
+ execSync('git worktree prune', { stdio: 'inherit' })
848
+ } catch (_) {
849
+ // 忽略 prune 错误(在损坏场景下可能无记录)
850
+ }
851
+ }
852
+
853
+ if (isBatch) {
854
+ logger.success(`✅ issue-${issueNumber} 删除成功`)
855
+ } else {
856
+ logger.success(`✅ Worktree 删除成功: ${worktreePath}`)
857
+ }
858
+ return true
859
+ } catch (error) {
860
+ const message = `删除 worktree 失败: ${error.message}`
861
+ if (isBatch) {
862
+ logger.error(`❌ issue-${issueNumber}: ${message}`)
863
+ } else {
864
+ logger.error(message)
865
+ }
866
+ return false
867
+ }
868
+ }
869
+
870
+ // 列出所有 worktree
871
+ async list() {
872
+ logger.info(`\n${'='.repeat(50)}`)
873
+ logger.info('📋 Worktree 列表')
874
+ logger.info('='.repeat(50))
875
+
876
+ try {
877
+ // 获取 git worktree 列表
878
+ const worktreeList = execSync('git worktree list --porcelain').toString()
879
+ const worktrees = this.parseWorktreeList(worktreeList)
880
+
881
+ // 过滤出 issue 相关的 worktree
882
+ const issueWorktrees = worktrees
883
+ .map(wt => ({ ...wt, issueNumber: this.extractIssueNumberFromPath(wt.path) }))
884
+ .filter(wt => wt.issueNumber)
885
+
886
+ if (issueWorktrees.length === 0) {
887
+ logger.info('没有找到 issue 相关的 worktree')
888
+ logger.info(`\n提示: 使用 'dx worktree make <issue_number>' 创建新的 worktree`)
889
+ return
890
+ }
891
+
892
+ // 显示列表
893
+ console.log('\n📁 Issue Worktrees:\n')
894
+ console.log('编号\t分支\t\t路径\t\t\t状态')
895
+ console.log('----\t----\t\t----\t\t\t----')
896
+
897
+ for (const wt of issueWorktrees) {
898
+ // 检查状态
899
+ let status = '正常'
900
+ if (wt.locked) {
901
+ status = '锁定'
902
+ } else if (wt.prunable) {
903
+ status = '可清理'
904
+ }
905
+
906
+ // 尝试快速检测 .git 指针是否损坏,避免噪声
907
+ try {
908
+ const dotgitPath = path.join(wt.path, '.git')
909
+ let broken = false
910
+ if (fs.existsSync(dotgitPath)) {
911
+ const stat = fs.lstatSync(dotgitPath)
912
+ if (stat.isFile()) {
913
+ const content = fs.readFileSync(dotgitPath, 'utf8')
914
+ const m = content.match(/gitdir:\s*(.*)/i)
915
+ if (m && m[1]) {
916
+ const target = m[1].trim()
917
+ // 相对路径转绝对
918
+ const abs = path.isAbsolute(target) ? target : path.resolve(wt.path, target)
919
+ if (!fs.existsSync(abs)) broken = true
920
+ }
921
+ }
922
+ } else {
923
+ broken = true
924
+ }
925
+
926
+ if (broken) {
927
+ status = '无法访问'
928
+ } else {
929
+ // 检查是否有未提交的更改(静默错误输出)
930
+ const originalCwd = process.cwd()
931
+ process.chdir(wt.path)
932
+ const gitStatus = execSync('git status --porcelain 2>/dev/null').toString()
933
+ if (gitStatus.trim()) {
934
+ status += ' (有更改)'
935
+ }
936
+ process.chdir(originalCwd)
937
+ }
938
+ } catch (e) {
939
+ status = '无法访问'
940
+ }
941
+
942
+ console.log(`#${wt.issueNumber}\t${wt.branch || 'detached'}\t${wt.path}\t${status}`)
943
+ }
944
+
945
+ // 显示统计
946
+ console.log(`\n总计: ${issueWorktrees.length} 个 worktree`)
947
+
948
+ // 显示可用命令
949
+ console.log('\n可用命令:')
950
+ console.log(' dx worktree make <number> - 创建新的 worktree')
951
+ console.log(' dx worktree del <number> - 删除 worktree')
952
+ console.log(' dx worktree clean - 清理无效的 worktree')
953
+ } catch (error) {
954
+ logger.error(`获取 worktree 列表失败: ${error.message}`)
955
+ logger.info('提示: 确保在 git 仓库中运行此命令')
956
+ }
957
+ }
958
+
959
+ // 解析 worktree 列表
960
+ parseWorktreeList(output) {
961
+ const worktrees = []
962
+ const lines = output.split('\n')
963
+ let current = {}
964
+
965
+ for (const line of lines) {
966
+ if (line.startsWith('worktree ')) {
967
+ if (current.path) {
968
+ worktrees.push(current)
969
+ }
970
+ current = { path: line.substring(9) }
971
+ } else if (line.startsWith('HEAD ')) {
972
+ current.head = line.substring(5)
973
+ } else if (line.startsWith('branch ')) {
974
+ current.branch = line.substring(7)
975
+ } else if (line.startsWith('locked')) {
976
+ current.locked = true
977
+ } else if (line.startsWith('prunable')) {
978
+ current.prunable = true
979
+ } else if (line === '') {
980
+ if (current.path) {
981
+ worktrees.push(current)
982
+ current = {}
983
+ }
984
+ }
985
+ }
986
+
987
+ if (current.path) {
988
+ worktrees.push(current)
989
+ }
990
+
991
+ return worktrees
992
+ }
993
+
994
+ // 获取所有 issue 相关的 worktree 编号
995
+ async getAllIssueWorktrees() {
996
+ try {
997
+ // 获取 git worktree 列表
998
+ const worktreeList = execSync('git worktree list --porcelain').toString()
999
+ const worktrees = this.parseWorktreeList(worktreeList)
1000
+
1001
+ // 过滤出 issue 相关的 worktree 并提取编号
1002
+ const issueNumbers = []
1003
+ for (const wt of worktrees) {
1004
+ // 优先基于分支名识别 issue(更可靠)
1005
+ if (wt.branch) {
1006
+ // 匹配 refs/heads/issue-<num> 格式
1007
+ const branchMatch = wt.branch.match(/^refs\/heads\/issue-(\d+)$/)
1008
+ if (branchMatch && branchMatch[1]) {
1009
+ issueNumbers.push(branchMatch[1])
1010
+ continue
1011
+ }
1012
+ }
1013
+
1014
+ const issueNumber = this.extractIssueNumberFromPath(wt.path)
1015
+ if (issueNumber) {
1016
+ issueNumbers.push(issueNumber)
1017
+ }
1018
+ }
1019
+
1020
+ return issueNumbers
1021
+ } catch (error) {
1022
+ logger.error(`获取 worktree 列表失败: ${error.message}`)
1023
+ return []
1024
+ }
1025
+ }
1026
+
1027
+ // 清理无效的 worktree
1028
+ async clean() {
1029
+ logger.info(`\n${'='.repeat(50)}`)
1030
+ logger.info('🧹 清理无效的 Worktree')
1031
+ logger.info('='.repeat(50))
1032
+
1033
+ try {
1034
+ logger.step('检查并清理无效的 worktree...')
1035
+ const output = execSync('git worktree prune -v').toString()
1036
+
1037
+ if (output.trim()) {
1038
+ console.log(output)
1039
+ logger.success('✅ 清理完成')
1040
+ } else {
1041
+ logger.info('没有需要清理的 worktree')
1042
+ }
1043
+
1044
+ // 列出剩余的 worktree
1045
+ await this.list()
1046
+ } catch (error) {
1047
+ logger.error(`清理失败: ${error.message}`)
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ export default new WorktreeManager()