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