@fatdoge/wtree 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 (65) hide show
  1. package/README.en.md +113 -0
  2. package/README.md +136 -0
  3. package/api/app.ts +19 -0
  4. package/api/cli/wtree.ts +809 -0
  5. package/api/core/config.ts +26 -0
  6. package/api/core/exec.ts +55 -0
  7. package/api/core/git.ts +35 -0
  8. package/api/core/id.ts +8 -0
  9. package/api/core/open.ts +58 -0
  10. package/api/core/worktree.test.ts +33 -0
  11. package/api/core/worktree.ts +72 -0
  12. package/api/createApiApp.ts +33 -0
  13. package/api/index.ts +9 -0
  14. package/api/routes/worktrees.ts +255 -0
  15. package/api/server.ts +34 -0
  16. package/api/ui/startUiDev.ts +82 -0
  17. package/dist/assets/index-D9inyPb3.js +179 -0
  18. package/dist/assets/index-W34LSHWF.css +1 -0
  19. package/dist/favicon.svg +4 -0
  20. package/dist/index.html +354 -0
  21. package/dist-node/api/app.js +17 -0
  22. package/dist-node/api/cli/wtree.js +722 -0
  23. package/dist-node/api/cli/wtui.js +722 -0
  24. package/dist-node/api/core/config.js +21 -0
  25. package/dist-node/api/core/exec.js +24 -0
  26. package/dist-node/api/core/git.js +24 -0
  27. package/dist-node/api/core/id.js +6 -0
  28. package/dist-node/api/core/open.js +51 -0
  29. package/dist-node/api/core/worktree.js +58 -0
  30. package/dist-node/api/core/worktree.test.js +30 -0
  31. package/dist-node/api/createApiApp.js +26 -0
  32. package/dist-node/api/routes/worktrees.js +213 -0
  33. package/dist-node/api/server.js +29 -0
  34. package/dist-node/api/ui/startUiDev.js +65 -0
  35. package/dist-node/shared/wtui-types.js +1 -0
  36. package/index.html +24 -0
  37. package/package.json +89 -0
  38. package/postcss.config.js +10 -0
  39. package/shared/wtui-types.ts +36 -0
  40. package/src/App.tsx +28 -0
  41. package/src/assets/react.svg +1 -0
  42. package/src/components/Button.tsx +34 -0
  43. package/src/components/Empty.tsx +8 -0
  44. package/src/components/Input.tsx +16 -0
  45. package/src/components/Modal.tsx +33 -0
  46. package/src/components/ToastHost.tsx +42 -0
  47. package/src/hooks/useTheme.ts +29 -0
  48. package/src/i18n/index.ts +22 -0
  49. package/src/i18n/locales/en.json +145 -0
  50. package/src/i18n/locales/zh.json +145 -0
  51. package/src/index.css +24 -0
  52. package/src/lib/utils.ts +6 -0
  53. package/src/main.tsx +11 -0
  54. package/src/pages/CreateWorktree.tsx +181 -0
  55. package/src/pages/HelpPage.tsx +67 -0
  56. package/src/pages/Home.tsx +3 -0
  57. package/src/pages/SettingsPage.tsx +218 -0
  58. package/src/pages/Worktrees.tsx +354 -0
  59. package/src/stores/themeStore.ts +44 -0
  60. package/src/stores/toastStore.ts +29 -0
  61. package/src/stores/worktreeStore.ts +93 -0
  62. package/src/utils/api.ts +36 -0
  63. package/src/vite-env.d.ts +1 -0
  64. package/tailwind.config.js +13 -0
  65. package/vite.config.ts +46 -0
@@ -0,0 +1,809 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import inquirer from 'inquirer'
6
+ import chalk from 'chalk'
7
+ import { execSync } from 'node:child_process'
8
+ import fs from 'node:fs'
9
+ import { getRepoRoot } from '../core/git.js'
10
+ import { git, gitOrThrow } from '../core/git.js'
11
+ import { listWorktrees, parseWorktreePorcelain } from '../core/worktree.js'
12
+ import { openPath } from '../core/open.js'
13
+ import { readConfig, writeConfig, getConfigPaths } from '../core/config.js'
14
+ import { startUiDevServer } from '../ui/startUiDev.js'
15
+
16
+ type Ctx = {
17
+ rootDir: string
18
+ }
19
+
20
+ type SourceSelection =
21
+ | { type: 'recent'; branch: string }
22
+ | { type: 'local' }
23
+ | { type: 'remote' }
24
+ | { type: 'default' }
25
+ | { type: 'input' }
26
+
27
+ type SourceType = SourceSelection['type']
28
+ type CommandType = 'list' | 'create' | 'delete' | 'open' | 'config' | 'help' | 'interactive' | 'prune' | 'lock' | 'unlock'
29
+
30
+ function errMsg(e: unknown) {
31
+ return e instanceof Error ? e.message : String(e)
32
+ }
33
+
34
+ function parseArgs(argv: string[]) {
35
+ const args = [...argv]
36
+ const flags = {
37
+ ui: false,
38
+ noOpen: false,
39
+ repo: '',
40
+ port: undefined as number | undefined,
41
+ }
42
+ const positional: string[] = []
43
+
44
+ while (args.length) {
45
+ const a = args.shift() as string
46
+ if (a === '--ui' || a === 'ui') {
47
+ flags.ui = true
48
+ continue
49
+ }
50
+ if (a === '--no-open') {
51
+ flags.noOpen = true
52
+ continue
53
+ }
54
+ if (a === '--repo') {
55
+ flags.repo = String(args.shift() || '')
56
+ continue
57
+ }
58
+ if (a === '--port') {
59
+ const v = Number(args.shift())
60
+ if (Number.isFinite(v)) flags.port = v
61
+ continue
62
+ }
63
+ if (a.startsWith('--')) continue
64
+ positional.push(a)
65
+ }
66
+
67
+ return { flags, positional }
68
+ }
69
+
70
+ function parseCommand(positional: string[]) {
71
+ const first = positional[0]
72
+ if (first === 'list' || first === 'ls') {
73
+ return { command: 'list' as CommandType, rest: positional.slice(1) }
74
+ }
75
+ if (first === 'create' || first === 'add') {
76
+ return { command: 'create' as CommandType, rest: positional.slice(1) }
77
+ }
78
+ if (first === 'delete' || first === 'remove' || first === 'rm') {
79
+ return { command: 'delete' as CommandType, rest: positional.slice(1) }
80
+ }
81
+ if (first === 'open') {
82
+ return { command: 'open' as CommandType, rest: positional.slice(1) }
83
+ }
84
+ if (first === 'config') {
85
+ return { command: 'config' as CommandType, rest: positional.slice(1) }
86
+ }
87
+ if (first === 'help' || first === '--help' || first === '-h') {
88
+ return { command: 'help' as CommandType, rest: positional.slice(1) }
89
+ }
90
+ if (first === 'prune') {
91
+ return { command: 'prune' as CommandType, rest: positional.slice(1) }
92
+ }
93
+ if (first === 'lock') {
94
+ return { command: 'lock' as CommandType, rest: positional.slice(1) }
95
+ }
96
+ if (first === 'unlock') {
97
+ return { command: 'unlock' as CommandType, rest: positional.slice(1) }
98
+ }
99
+ return { command: 'interactive' as CommandType, rest: positional }
100
+ }
101
+
102
+ function printWorktreeList(rootDir: string) {
103
+ const items = listWorktrees(rootDir)
104
+ if (items.length === 0) {
105
+ console.info('未读取到 worktree。')
106
+ return
107
+ }
108
+ console.info('Worktrees:')
109
+ for (const wt of items) {
110
+ const flags = [wt.isMain ? 'Main' : null, wt.isLocked ? 'Locked' : null].filter(Boolean).join(',')
111
+ const label = flags ? ` [${flags}]` : ''
112
+ console.info(`- ${wt.branch || 'HEAD'}${label} ${wt.path}`)
113
+ }
114
+ }
115
+
116
+ function printHelp() {
117
+ console.info('wtree 命令:')
118
+ console.info(' wtree')
119
+ console.info(' wtree list')
120
+ console.info(' wtree create [branch]')
121
+ console.info(' wtree delete')
122
+ console.info(' wtree open [path|branch]')
123
+ console.info(' wtree lock [path|branch]')
124
+ console.info(' wtree unlock [path|branch]')
125
+ console.info(' wtree prune')
126
+ console.info(' wtree config')
127
+ console.info(' wtree config get <key>')
128
+ console.info(' wtree config set <key> <value>')
129
+ console.info(' wtree --ui [--repo <path>] [--no-open] [--port <number>]')
130
+ console.info('')
131
+ console.info('可用配置 key: baseDir, openCommand, editorCommand')
132
+ }
133
+
134
+ function resolveWorktree(rootDir: string, key: string) {
135
+ const items = listWorktrees(rootDir)
136
+ const byBranch = items.find((x) => x.branch === key)
137
+ if (byBranch) return byBranch
138
+ const resolved = path.isAbsolute(key) ? key : path.resolve(rootDir, key)
139
+ const byPath = items.find((x) => path.resolve(x.path) === path.resolve(resolved))
140
+ if (byPath) return byPath
141
+ const byBase = items.find((x) => path.basename(x.path) === key)
142
+ return byBase
143
+ }
144
+
145
+ async function openWorktree(rootDir: string, key?: string) {
146
+ const items = listWorktrees(rootDir)
147
+ if (items.length === 0) {
148
+ console.info('未读取到 worktree。')
149
+ return
150
+ }
151
+
152
+ let target = key ? resolveWorktree(rootDir, key) : undefined
153
+ if (!target) {
154
+ const { wt } = await inquirer.prompt([
155
+ {
156
+ type: 'list',
157
+ name: 'wt',
158
+ message: '请选择要打开的 Worktree:',
159
+ choices: items.map((x) => ({
160
+ name: `${x.branch || 'HEAD'} (${path.relative(rootDir, x.path)})`,
161
+ value: x.id,
162
+ })),
163
+ },
164
+ ])
165
+ target = items.find((x) => x.id === wt)
166
+ }
167
+
168
+ if (!target) {
169
+ console.error(chalk.red('未找到对应的 worktree。'))
170
+ process.exit(1)
171
+ }
172
+
173
+ const cfg = readConfig()
174
+ const ok = openPath(target.path, cfg.openCommand)
175
+ if (!ok) {
176
+ console.error(chalk.red('打开失败。'))
177
+ process.exit(1)
178
+ }
179
+ console.info(chalk.green('已打开。'))
180
+ }
181
+
182
+ function printConfig() {
183
+ const cfg = readConfig()
184
+ const paths = getConfigPaths()
185
+ console.info(`config: ${paths.path}`)
186
+ console.info(JSON.stringify(cfg, null, 2))
187
+ }
188
+
189
+ function getConfigKeyValue(key: string) {
190
+ const cfg = readConfig()
191
+ const v = (cfg as Record<string, unknown>)[key]
192
+ if (typeof v === 'undefined') {
193
+ console.info('')
194
+ return
195
+ }
196
+ console.info(String(v))
197
+ }
198
+
199
+ function setConfigKeyValue(key: string, value: string) {
200
+ const allowed = new Set(['baseDir', 'openCommand', 'editorCommand'])
201
+ if (!allowed.has(key)) {
202
+ console.error(chalk.red(`不支持的配置项: ${key}`))
203
+ process.exit(1)
204
+ }
205
+ const cfg = readConfig()
206
+ const next = { ...cfg, [key]: value }
207
+ writeConfig(next)
208
+ console.info(chalk.green('已保存。'))
209
+ }
210
+
211
+ async function lockWorktree(rootDir: string, key?: string) {
212
+ const items = listWorktrees(rootDir)
213
+ if (items.length === 0) {
214
+ console.info('未读取到 worktree。')
215
+ return
216
+ }
217
+
218
+ let target = key ? resolveWorktree(rootDir, key) : undefined
219
+ if (!target) {
220
+ const { wt } = await inquirer.prompt([
221
+ {
222
+ type: 'list',
223
+ name: 'wt',
224
+ message: '请选择要锁定的 Worktree:',
225
+ choices: items.filter(x => !x.isLocked && !x.isMain).map((x) => ({
226
+ name: `${x.branch || 'HEAD'} (${path.relative(rootDir, x.path)})`,
227
+ value: x.id,
228
+ })),
229
+ },
230
+ ])
231
+ target = items.find((x) => x.id === wt)
232
+ }
233
+
234
+ if (!target) {
235
+ console.error(chalk.red('未找到可锁定的 worktree。'))
236
+ process.exit(1)
237
+ }
238
+
239
+ try {
240
+ gitOrThrow(rootDir, ['worktree', 'lock', target.path], 'WORKTREE_LOCK')
241
+ console.info(chalk.green(`已锁定: ${target.path}`))
242
+ } catch (e: unknown) {
243
+ console.error(chalk.red(`锁定失败: ${errMsg(e)}`))
244
+ }
245
+ }
246
+
247
+ async function unlockWorktree(rootDir: string, key?: string) {
248
+ const items = listWorktrees(rootDir)
249
+ if (items.length === 0) {
250
+ console.info('未读取到 worktree。')
251
+ return
252
+ }
253
+
254
+ let target = key ? resolveWorktree(rootDir, key) : undefined
255
+ if (!target) {
256
+ const { wt } = await inquirer.prompt([
257
+ {
258
+ type: 'list',
259
+ name: 'wt',
260
+ message: '请选择要解锁的 Worktree:',
261
+ choices: items.filter(x => x.isLocked).map((x) => ({
262
+ name: `${x.branch || 'HEAD'} (${path.relative(rootDir, x.path)})`,
263
+ value: x.id,
264
+ })),
265
+ },
266
+ ])
267
+ target = items.find((x) => x.id === wt)
268
+ }
269
+
270
+ if (!target) {
271
+ console.error(chalk.red('未找到可解锁的 worktree。'))
272
+ process.exit(1)
273
+ }
274
+
275
+ try {
276
+ gitOrThrow(rootDir, ['worktree', 'unlock', target.path], 'WORKTREE_UNLOCK')
277
+ console.info(chalk.green(`已解锁: ${target.path}`))
278
+ } catch (e: unknown) {
279
+ console.error(chalk.red(`解锁失败: ${errMsg(e)}`))
280
+ }
281
+ }
282
+
283
+ async function pruneWorktrees(rootDir: string) {
284
+ try {
285
+ gitOrThrow(rootDir, ['worktree', 'prune'], 'WORKTREE_PRUNE')
286
+ console.info(chalk.green('已清理无效的 worktree 记录。'))
287
+ } catch (e: unknown) {
288
+ console.error(chalk.red(`清理失败: ${errMsg(e)}`))
289
+ }
290
+ }
291
+
292
+ async function main() {
293
+ const { flags, positional } = parseArgs(process.argv.slice(2))
294
+ const cwd = flags.repo ? path.resolve(flags.repo) : process.cwd()
295
+ const rootDir = getRepoRoot(cwd)
296
+
297
+ if (flags.ui) {
298
+ console.info(chalk.blue(`Repo: ${rootDir}`))
299
+ const handle = await startUiDevServer({
300
+ repoRoot: rootDir,
301
+ uiPort: flags.port,
302
+ open: !flags.noOpen,
303
+ })
304
+ console.info(chalk.green(`UI 已启动: ${handle.uiUrl}`))
305
+
306
+ const close = async () => {
307
+ await handle.close()
308
+ process.exit(0)
309
+ }
310
+ process.on('SIGINT', close)
311
+ process.on('SIGTERM', close)
312
+ return
313
+ }
314
+
315
+ console.info(chalk.blue(`检测到git repo根目录 ${rootDir},将在这里运行git命令`))
316
+
317
+ const { command, rest } = parseCommand(positional)
318
+ if (command === 'list') {
319
+ printWorktreeList(rootDir)
320
+ return
321
+ }
322
+
323
+ if (command === 'create') {
324
+ await createWorktree({ rootDir }, rest[0])
325
+ return
326
+ }
327
+
328
+ if (command === 'delete') {
329
+ await deleteWorktree({ rootDir })
330
+ return
331
+ }
332
+
333
+ if (command === 'open') {
334
+ await openWorktree(rootDir, rest[0])
335
+ return
336
+ }
337
+
338
+ if (command === 'config') {
339
+ const [sub, key, value] = rest
340
+ if (!sub) {
341
+ printConfig()
342
+ return
343
+ }
344
+ if (sub === 'get' && key) {
345
+ getConfigKeyValue(key)
346
+ return
347
+ }
348
+ if (sub === 'set' && key && typeof value === 'string') {
349
+ setConfigKeyValue(key, value)
350
+ return
351
+ }
352
+ console.error(chalk.red('用法: wtree config | wtree config get <key> | wtree config set <key> <value>'))
353
+ process.exit(1)
354
+ }
355
+
356
+ if (command === 'help') {
357
+ printHelp()
358
+ return
359
+ }
360
+
361
+ if (command === 'lock') {
362
+ await lockWorktree(rootDir, rest[0])
363
+ return
364
+ }
365
+
366
+ if (command === 'unlock') {
367
+ await unlockWorktree(rootDir, rest[0])
368
+ return
369
+ }
370
+
371
+ if (command === 'prune') {
372
+ await pruneWorktrees(rootDir)
373
+ return
374
+ }
375
+
376
+ const directBranch = rest[0]
377
+
378
+ const action = await getUserAction(directBranch)
379
+ const ctx: Ctx = { rootDir }
380
+ if (action === 'create') {
381
+ await createWorktree(ctx, directBranch)
382
+ } else if (action === 'delete') {
383
+ await deleteWorktree(ctx)
384
+ } else if (action === 'open') {
385
+ await openWorktree(rootDir)
386
+ } else if (action === 'lock') {
387
+ await lockWorktree(rootDir)
388
+ } else if (action === 'unlock') {
389
+ await unlockWorktree(rootDir)
390
+ } else if (action === 'prune') {
391
+ await pruneWorktrees(rootDir)
392
+ } else {
393
+ printWorktreeList(rootDir)
394
+ }
395
+ }
396
+
397
+ async function getUserAction(directBranch?: string) {
398
+ if (directBranch) return 'create'
399
+ const { action } = await inquirer.prompt([
400
+ {
401
+ type: 'list',
402
+ name: 'action',
403
+ message: '请选择操作:',
404
+ choices: [
405
+ { name: '创建 Worktree (Create)', value: 'create' },
406
+ { name: '删除 Worktree (Delete)', value: 'delete' },
407
+ { name: '查看 Worktree 列表 (List)', value: 'list' },
408
+ { name: '打开 Worktree (Open)', value: 'open' },
409
+ { name: '锁定 Worktree (Lock)', value: 'lock' },
410
+ { name: '解锁 Worktree (Unlock)', value: 'unlock' },
411
+ { name: '清理无效 Worktree (Prune)', value: 'prune' },
412
+ ],
413
+ },
414
+ ])
415
+ return action as 'create' | 'delete' | 'list' | 'open' | 'lock' | 'unlock' | 'prune'
416
+ }
417
+
418
+ async function createWorktree(ctx: Ctx, directBranch?: string) {
419
+ const { rootDir } = ctx
420
+ const defaultBranch =
421
+ git(rootDir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).stdout
422
+ .replace(/^origin\//, '')
423
+ .trim() || 'master'
424
+
425
+ const { sourceType, selection } = await selectSource(rootDir, directBranch, defaultBranch)
426
+ const { targetBranch, baseRef, isNewBranch } = await resolveBranchInfo(
427
+ rootDir,
428
+ sourceType,
429
+ selection,
430
+ directBranch,
431
+ defaultBranch,
432
+ )
433
+ const { targetDir, dirName } = await selectTargetDir(rootDir, targetBranch)
434
+
435
+ console.info(chalk.green(`\n准备创建 Worktree:`))
436
+ console.info(` 分支: ${targetBranch}`)
437
+ console.info(` 目录: ${targetDir}`)
438
+ console.info(` 来源: ${baseRef || 'Existing Local'}`)
439
+
440
+ await createGitWorktree(
441
+ rootDir,
442
+ targetDir,
443
+ targetBranch,
444
+ baseRef,
445
+ isNewBranch,
446
+ sourceType,
447
+ defaultBranch,
448
+ )
449
+
450
+ await setupWorktreeEnv(rootDir, targetDir, dirName)
451
+ await installDependencies(targetDir)
452
+ await openInIDE(targetDir)
453
+ }
454
+
455
+ async function selectSource(rootDir: string, directBranch: string | undefined, defaultBranch: string) {
456
+ let sourceType: SourceType
457
+ let selection: SourceSelection = { type: 'input' }
458
+
459
+ if (directBranch) {
460
+ sourceType = 'input'
461
+ } else {
462
+ const recentBranches = gitOrThrow(rootDir, [
463
+ 'for-each-ref',
464
+ '--sort=-committerdate',
465
+ 'refs/heads/',
466
+ '--format=%(refname:short)',
467
+ '--count=6',
468
+ ]).stdout
469
+ .split('\n')
470
+ .map((b) => b.trim())
471
+ .filter(Boolean)
472
+
473
+ const choices = [
474
+ new inquirer.Separator('--- 最近编辑 ---'),
475
+ ...recentBranches.map((b) => ({ name: b, value: { type: 'recent', branch: b } })),
476
+ new inquirer.Separator('--- 其他 ---'),
477
+ { name: '更多本地分支', value: { type: 'local' } },
478
+ { name: '远程分支', value: { type: 'remote' } },
479
+ { name: `从默认分支 (${defaultBranch}) 创建新分支`, value: { type: 'default' } },
480
+ { name: '手动输入分支名', value: { type: 'input' } },
481
+ ]
482
+
483
+ const { selection: userSelection } = await inquirer.prompt([
484
+ {
485
+ type: 'list',
486
+ name: 'selection',
487
+ message: '请选择分支来源:',
488
+ choices,
489
+ pageSize: 15,
490
+ },
491
+ ])
492
+
493
+ selection = userSelection as SourceSelection
494
+ sourceType = selection.type
495
+ }
496
+
497
+ return { sourceType, selection }
498
+ }
499
+
500
+ async function resolveBranchInfo(
501
+ rootDir: string,
502
+ sourceType: SourceType,
503
+ selection: SourceSelection,
504
+ directBranch: string | undefined,
505
+ defaultBranch: string,
506
+ ) {
507
+ let targetBranch = ''
508
+ let baseRef = ''
509
+ let isNewBranch = false
510
+
511
+ if (sourceType === 'recent') {
512
+ const b = (selection as { type: 'recent'; branch: string }).branch
513
+ targetBranch = b
514
+ baseRef = b
515
+ } else if (sourceType === 'input') {
516
+ let inputBranch = directBranch
517
+ if (!inputBranch) {
518
+ const r = await inquirer.prompt([
519
+ {
520
+ type: 'input',
521
+ name: 'inputBranch',
522
+ message: '请输入分支名称:',
523
+ validate: (input: string) => (input ? true : '分支名不能为空'),
524
+ },
525
+ ])
526
+ inputBranch = r.inputBranch
527
+ }
528
+
529
+ targetBranch = String(inputBranch).trim()
530
+ const localExists = git(rootDir, ['rev-parse', '--verify', targetBranch]).ok
531
+ if (localExists) {
532
+ baseRef = targetBranch
533
+ } else {
534
+ try {
535
+ execSync(`git fetch origin ${targetBranch}`, { cwd: rootDir, stdio: 'ignore' })
536
+ } catch (e: unknown) {
537
+ void e
538
+ }
539
+ const remoteExists = git(rootDir, ['rev-parse', '--verify', `origin/${targetBranch}`]).ok
540
+ if (remoteExists) {
541
+ baseRef = `origin/${targetBranch}`
542
+ isNewBranch = true
543
+ } else {
544
+ const { createNew } = await inquirer.prompt([
545
+ {
546
+ type: 'confirm',
547
+ name: 'createNew',
548
+ message: `分支 ${targetBranch} 不存在。是否基于 ${defaultBranch} 创建新分支?`,
549
+ default: true,
550
+ },
551
+ ])
552
+ if (createNew) {
553
+ baseRef = defaultBranch
554
+ isNewBranch = true
555
+ } else {
556
+ process.exit(1)
557
+ }
558
+ }
559
+ }
560
+ } else if (sourceType === 'default') {
561
+ const { newBranchName } = await inquirer.prompt([
562
+ {
563
+ type: 'input',
564
+ name: 'newBranchName',
565
+ message: `基于 ${defaultBranch} 创建新分支,请输入新分支名称:`,
566
+ validate: (input: string) => (input ? true : '分支名不能为空'),
567
+ },
568
+ ])
569
+ targetBranch = newBranchName
570
+ baseRef = defaultBranch
571
+ isNewBranch = true
572
+ } else if (sourceType === 'local') {
573
+ const branches = gitOrThrow(rootDir, ['branch', '--format=%(refname:short)']).stdout
574
+ .split('\n')
575
+ .map((b) => b.trim())
576
+ .filter(Boolean)
577
+
578
+ if (branches.length === 0) {
579
+ process.exit(0)
580
+ }
581
+
582
+ const { branch } = await inquirer.prompt([
583
+ { type: 'list', name: 'branch', message: '请选择本地分支:', choices: branches },
584
+ ])
585
+ targetBranch = branch
586
+ baseRef = branch
587
+ } else if (sourceType === 'remote') {
588
+ try {
589
+ execSync('git fetch origin', { stdio: 'ignore', cwd: rootDir })
590
+ } catch (e: unknown) {
591
+ void e
592
+ }
593
+
594
+ const branches = gitOrThrow(rootDir, ['branch', '-r', '--format=%(refname:short)']).stdout
595
+ .split('\n')
596
+ .map((b) => b.trim())
597
+ .filter((b) => b && !b.includes('/HEAD'))
598
+ .map((b) => b.replace(/^origin\//, ''))
599
+
600
+ if (branches.length === 0) {
601
+ process.exit(0)
602
+ }
603
+
604
+ const { branch } = await inquirer.prompt([
605
+ { type: 'list', name: 'branch', message: '请选择远程分支:', choices: branches },
606
+ ])
607
+ targetBranch = branch
608
+ baseRef = `origin/${branch}`
609
+ isNewBranch = true
610
+ }
611
+
612
+ return { targetBranch, baseRef, isNewBranch }
613
+ }
614
+
615
+ async function selectTargetDir(rootDir: string, targetBranch: string) {
616
+ const defaultDirName = `worktrees/${targetBranch.split('/').join('-')}`
617
+ const { dirName } = await inquirer.prompt([
618
+ {
619
+ type: 'input',
620
+ name: 'dirName',
621
+ message: `请输入 Worktree 目录路径 (相对于 Git 根目录, 默认: ${defaultDirName}):`,
622
+ default: defaultDirName,
623
+ },
624
+ ])
625
+
626
+ const targetDir = path.resolve(rootDir, dirName)
627
+ if (fs.existsSync(targetDir)) {
628
+ console.error(chalk.red(`目录 ${targetDir} 已存在!`))
629
+ process.exit(1)
630
+ }
631
+
632
+ return { targetDir, dirName }
633
+ }
634
+
635
+ async function createGitWorktree(
636
+ rootDir: string,
637
+ targetDir: string,
638
+ targetBranch: string,
639
+ baseRef: string,
640
+ isNewBranch: boolean,
641
+ sourceType: SourceType,
642
+ defaultBranch: string,
643
+ ) {
644
+ const localExists = git(rootDir, ['rev-parse', '--verify', targetBranch]).ok
645
+
646
+ if (!localExists && isNewBranch) {
647
+ try {
648
+ git(rootDir, ['fetch', 'origin', `${defaultBranch}:${defaultBranch}`])
649
+ } catch (e: unknown) {
650
+ void e
651
+ }
652
+ gitOrThrow(rootDir, ['branch', targetBranch, baseRef], 'BRANCH_CREATE')
653
+ }
654
+
655
+ if (!localExists && !isNewBranch) {
656
+ console.error(chalk.red(`分支 ${targetBranch} 不存在!`))
657
+ process.exit(1)
658
+ }
659
+
660
+ if (localExists && isNewBranch && (sourceType === 'default' || sourceType === 'remote')) {
661
+ gitOrThrow(rootDir, ['worktree', 'add', targetDir, targetBranch], 'WORKTREE_ADD')
662
+ return
663
+ }
664
+
665
+ gitOrThrow(rootDir, ['worktree', 'add', targetDir, targetBranch], 'WORKTREE_ADD')
666
+ }
667
+
668
+ async function setupWorktreeEnv(rootDir: string, targetDir: string, dirName: string) {
669
+ const copyFiles = ['.env', 'apps/platform-node/.development.env']
670
+ for (const file of copyFiles) {
671
+ const src = path.join(rootDir, file)
672
+ const dest = path.join(targetDir, file)
673
+ if (fs.existsSync(src)) {
674
+ try {
675
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
676
+ fs.copyFileSync(src, dest)
677
+ console.info(`复制文件 ${file} -> ${dirName}/${file}`)
678
+ } catch (e: unknown) {
679
+ void e
680
+ }
681
+ }
682
+ }
683
+ }
684
+
685
+ async function installDependencies(targetDir: string) {
686
+ if (!fs.existsSync(path.join(targetDir, 'package.json'))) return
687
+ try {
688
+ execSync('pnpm --version', { stdio: 'ignore' })
689
+ console.info(chalk.blue('检测到 pnpm,正在安装依赖...'))
690
+ execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' })
691
+ } catch {
692
+ console.warn(chalk.yellow('未检测到 pnpm 或安装失败,跳过依赖安装。'))
693
+ }
694
+ }
695
+
696
+ function hasCommand(cmd: string) {
697
+ try {
698
+ execSync(`command -v ${cmd}`, { stdio: 'ignore' })
699
+ return true
700
+ } catch {
701
+ return false
702
+ }
703
+ }
704
+
705
+ async function openInIDE(targetDir: string) {
706
+ const editors: { name: string; value: string }[] = []
707
+ if (hasCommand('trae')) editors.push({ name: `在 Trae 中打开 (trae ${targetDir})`, value: 'trae' })
708
+ if (hasCommand('cursor')) editors.push({ name: `在 Cursor 中打开 (cursor ${targetDir})`, value: 'cursor' })
709
+ if (hasCommand('code')) editors.push({ name: `在 VS Code 中打开 (code ${targetDir})`, value: 'code' })
710
+
711
+ if (editors.length === 0) return
712
+ editors.unshift({ name: '暂不打开', value: 'none' })
713
+
714
+ const { openWith } = await inquirer.prompt([
715
+ {
716
+ type: 'list',
717
+ name: 'openWith',
718
+ message: 'Worktree 创建成功,是否在 IDE 中打开?',
719
+ choices: editors,
720
+ default: editors[0]?.value,
721
+ },
722
+ ])
723
+
724
+ if (openWith === 'none') return
725
+ try {
726
+ execSync(`${openWith} "${targetDir}"`, { stdio: 'ignore' })
727
+ } catch (e: unknown) {
728
+ void e
729
+ }
730
+ }
731
+
732
+ async function deleteWorktree(ctx: Ctx) {
733
+ const worktrees = getWorktreeList(ctx.rootDir)
734
+ const choices = getDeletableWorktrees(ctx.rootDir, worktrees)
735
+ if (choices.length === 0) {
736
+ console.warn(chalk.yellow('没有可删除的 Worktree (除了主 Worktree)'))
737
+ return
738
+ }
739
+
740
+ const { targetPaths } = await inquirer.prompt([
741
+ {
742
+ type: 'checkbox',
743
+ name: 'targetPaths',
744
+ message: '请选择要删除的 Worktree:',
745
+ choices,
746
+ validate: (answer: string[]) => (answer.length > 0 ? true : '请至少选择一个'),
747
+ },
748
+ ])
749
+
750
+ const { confirmDelete } = await inquirer.prompt([
751
+ {
752
+ type: 'confirm',
753
+ name: 'confirmDelete',
754
+ message: `确定要删除这 ${targetPaths.length} 个 Worktree 吗?`,
755
+ default: false,
756
+ },
757
+ ])
758
+ if (!confirmDelete) return
759
+ for (const targetPath of targetPaths) {
760
+ await deleteSingleWorktree(ctx.rootDir, targetPath)
761
+ }
762
+ }
763
+
764
+ function getWorktreeList(rootDir: string) {
765
+ const output = gitOrThrow(rootDir, ['worktree', 'list', '--porcelain'], 'WORKTREE_LIST').stdout
766
+ const raw = parseWorktreePorcelain(output)
767
+ return raw.map((r) => ({
768
+ path: r.path,
769
+ branch: r.branch,
770
+ }))
771
+ }
772
+
773
+ function getDeletableWorktrees(rootDir: string, worktrees: { path: string; branch?: string }[]) {
774
+ return worktrees
775
+ .filter((wt) => path.resolve(wt.path) !== path.resolve(rootDir))
776
+ .map((wt) => {
777
+ const relativePath = path.relative(rootDir, wt.path)
778
+ return { name: `${wt.branch || 'HEAD'} (${relativePath})`, value: wt.path }
779
+ })
780
+ }
781
+
782
+ async function deleteSingleWorktree(rootDir: string, targetPath: string) {
783
+ try {
784
+ gitOrThrow(rootDir, ['worktree', 'remove', targetPath], 'WORKTREE_REMOVE')
785
+ console.info(chalk.green(`成功删除: ${targetPath}`))
786
+ } catch (e: unknown) {
787
+ console.error(chalk.red(`删除失败: ${errMsg(e)}`))
788
+ const { force } = await inquirer.prompt([
789
+ {
790
+ type: 'confirm',
791
+ name: 'force',
792
+ message: '删除失败 (可能有未提交的更改). 强制删除吗?',
793
+ default: false,
794
+ },
795
+ ])
796
+ if (!force) return
797
+ try {
798
+ gitOrThrow(rootDir, ['worktree', 'remove', '--force', targetPath], 'WORKTREE_REMOVE_FORCE')
799
+ console.info(chalk.green(`成功强制删除: ${targetPath}`))
800
+ } catch (forceErr: unknown) {
801
+ console.error(chalk.red(`强制删除也失败了: ${errMsg(forceErr)}`))
802
+ }
803
+ }
804
+ }
805
+
806
+ main().catch((e: unknown) => {
807
+ console.error(chalk.red(errMsg(e)))
808
+ process.exit(1)
809
+ })