@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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/bin/dx-with-version-env.js +8 -0
- package/bin/dx.js +86 -0
- package/lib/backend-package.js +664 -0
- package/lib/cli/args.js +19 -0
- package/lib/cli/commands/core.js +233 -0
- package/lib/cli/commands/db.js +239 -0
- package/lib/cli/commands/deploy.js +76 -0
- package/lib/cli/commands/export.js +34 -0
- package/lib/cli/commands/package.js +22 -0
- package/lib/cli/commands/stack.js +451 -0
- package/lib/cli/commands/start.js +83 -0
- package/lib/cli/commands/worktree.js +149 -0
- package/lib/cli/dx-cli.js +864 -0
- package/lib/cli/flags.js +96 -0
- package/lib/cli/help.js +209 -0
- package/lib/cli/index.js +4 -0
- package/lib/confirm.js +213 -0
- package/lib/env.js +296 -0
- package/lib/exec.js +643 -0
- package/lib/logger.js +188 -0
- package/lib/run-with-version-env.js +173 -0
- package/lib/sdk-build.js +424 -0
- package/lib/start-dev.js +401 -0
- package/lib/telegram-webhook.js +134 -0
- package/lib/validate-env.js +284 -0
- package/lib/vercel-deploy.js +237 -0
- package/lib/worktree.js +1032 -0
- package/package.json +34 -0
package/lib/worktree.js
ADDED
|
@@ -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()
|