@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.
- package/README.en.md +113 -0
- package/README.md +136 -0
- package/api/app.ts +19 -0
- package/api/cli/wtree.ts +809 -0
- package/api/core/config.ts +26 -0
- package/api/core/exec.ts +55 -0
- package/api/core/git.ts +35 -0
- package/api/core/id.ts +8 -0
- package/api/core/open.ts +58 -0
- package/api/core/worktree.test.ts +33 -0
- package/api/core/worktree.ts +72 -0
- package/api/createApiApp.ts +33 -0
- package/api/index.ts +9 -0
- package/api/routes/worktrees.ts +255 -0
- package/api/server.ts +34 -0
- package/api/ui/startUiDev.ts +82 -0
- package/dist/assets/index-D9inyPb3.js +179 -0
- package/dist/assets/index-W34LSHWF.css +1 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.html +354 -0
- package/dist-node/api/app.js +17 -0
- package/dist-node/api/cli/wtree.js +722 -0
- package/dist-node/api/cli/wtui.js +722 -0
- package/dist-node/api/core/config.js +21 -0
- package/dist-node/api/core/exec.js +24 -0
- package/dist-node/api/core/git.js +24 -0
- package/dist-node/api/core/id.js +6 -0
- package/dist-node/api/core/open.js +51 -0
- package/dist-node/api/core/worktree.js +58 -0
- package/dist-node/api/core/worktree.test.js +30 -0
- package/dist-node/api/createApiApp.js +26 -0
- package/dist-node/api/routes/worktrees.js +213 -0
- package/dist-node/api/server.js +29 -0
- package/dist-node/api/ui/startUiDev.js +65 -0
- package/dist-node/shared/wtui-types.js +1 -0
- package/index.html +24 -0
- package/package.json +89 -0
- package/postcss.config.js +10 -0
- package/shared/wtui-types.ts +36 -0
- package/src/App.tsx +28 -0
- package/src/assets/react.svg +1 -0
- package/src/components/Button.tsx +34 -0
- package/src/components/Empty.tsx +8 -0
- package/src/components/Input.tsx +16 -0
- package/src/components/Modal.tsx +33 -0
- package/src/components/ToastHost.tsx +42 -0
- package/src/hooks/useTheme.ts +29 -0
- package/src/i18n/index.ts +22 -0
- package/src/i18n/locales/en.json +145 -0
- package/src/i18n/locales/zh.json +145 -0
- package/src/index.css +24 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +11 -0
- package/src/pages/CreateWorktree.tsx +181 -0
- package/src/pages/HelpPage.tsx +67 -0
- package/src/pages/Home.tsx +3 -0
- package/src/pages/SettingsPage.tsx +218 -0
- package/src/pages/Worktrees.tsx +354 -0
- package/src/stores/themeStore.ts +44 -0
- package/src/stores/toastStore.ts +29 -0
- package/src/stores/worktreeStore.ts +93 -0
- package/src/utils/api.ts +36 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +13 -0
- package/vite.config.ts +46 -0
package/api/cli/wtree.ts
ADDED
|
@@ -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
|
+
})
|