@junheep/gwt 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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -0
  3. package/bin/gwt.mjs +1459 -0
  4. package/package.json +37 -0
package/bin/gwt.mjs ADDED
@@ -0,0 +1,1459 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process"
4
+ import { createHash, randomBytes } from "node:crypto"
5
+ import {
6
+ accessSync,
7
+ chmodSync,
8
+ constants,
9
+ copyFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ realpathSync,
15
+ renameSync,
16
+ statSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs"
20
+ import { createServer } from "node:net"
21
+ import { homedir } from "node:os"
22
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
23
+ import { emitKeypressEvents } from "node:readline"
24
+ import { createInterface } from "node:readline/promises"
25
+
26
+ const VERSION = "0.1.0"
27
+ const PROJECT_CONFIG_FILE = ".gwt.json"
28
+ const PORT_MIN = 20_000
29
+ const PORT_MAX = 39_999
30
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
31
+ const DEFAULT_CONFIG = { worktreeDirectory: ".worktrees", copyFiles: [], ports: [] }
32
+
33
+ class CliError extends Error {}
34
+
35
+ function run(command, args, options = {}) {
36
+ const result = spawnSync(command, args, {
37
+ cwd: options.cwd,
38
+ encoding: "utf8",
39
+ env: options.env ?? process.env,
40
+ input: options.input,
41
+ stdio: options.stdio ?? ["pipe", "pipe", "pipe"],
42
+ })
43
+
44
+ if (result.error) throw new CliError(`${command}: ${result.error.message}`)
45
+ if (result.status !== 0 && !options.allowFailure) {
46
+ const detail = result.stderr?.trim() || result.stdout?.trim()
47
+ throw new CliError(detail || `${command} exited with status ${result.status}`)
48
+ }
49
+
50
+ return result
51
+ }
52
+
53
+ function git(args, cwd, options = {}) {
54
+ return run("git", args, { cwd, ...options })
55
+ }
56
+
57
+ function gitOutput(args, cwd) {
58
+ return git(args, cwd).stdout.trim()
59
+ }
60
+
61
+ function pathExists(path) {
62
+ try {
63
+ statSync(path)
64
+ return true
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ function canonical(path) {
71
+ return realpathSync(resolve(path))
72
+ }
73
+
74
+ function isInside(root, path) {
75
+ const pathFromRoot = relative(root, path)
76
+ return pathFromRoot === "" || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== ".." && !isAbsolute(pathFromRoot))
77
+ }
78
+
79
+ function parseWorktrees(raw) {
80
+ const worktrees = []
81
+ let current = null
82
+
83
+ for (const field of raw.split("\0")) {
84
+ if (!field) {
85
+ if (current) worktrees.push(current)
86
+ current = null
87
+ continue
88
+ }
89
+
90
+ const separator = field.indexOf(" ")
91
+ const key = separator === -1 ? field : field.slice(0, separator)
92
+ const value = separator === -1 ? true : field.slice(separator + 1)
93
+ if (key === "worktree") current = { path: value, branch: null, head: null, bare: false, detached: false, locked: false }
94
+ else if (!current) throw new CliError("Git returned an invalid worktree record")
95
+ else if (key === "HEAD") current.head = value
96
+ else if (key === "branch") current.branch = value.replace(/^refs\/heads\//, "")
97
+ else if (key === "bare") current.bare = true
98
+ else if (key === "detached") current.detached = true
99
+ else if (key === "locked") current.locked = value === true ? true : value
100
+ else if (key === "prunable") current.prunable = value === true ? true : value
101
+ }
102
+
103
+ if (current) worktrees.push(current)
104
+ return worktrees
105
+ }
106
+
107
+ function discoverRepository(cwd = process.cwd()) {
108
+ const raw = git(["worktree", "list", "--porcelain", "-z"], cwd).stdout
109
+ const worktrees = parseWorktrees(raw)
110
+ if (worktrees.length === 0) throw new CliError("No Git worktrees found")
111
+
112
+ const primary = worktrees[0]
113
+ const commonDir = resolve(cwd, gitOutput(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd))
114
+ const primaryPath = canonical(primary.path)
115
+ return { commonDir, primary: { ...primary, path: primaryPath }, primaryPath, worktrees }
116
+ }
117
+
118
+ function validateRelativePath(value, field) {
119
+ if (typeof value !== "string" || value.length === 0) throw new CliError(`${field} must be a non-empty string`)
120
+ if (isAbsolute(value)) throw new CliError(`${field} must be a relative path`)
121
+ const normalized = value.split(/[\\/]+/)
122
+ if (normalized.some((part) => part === "..")) throw new CliError(`${field} cannot contain '..'`)
123
+ return value
124
+ }
125
+
126
+ function validateConfig(parsed, label) {
127
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError(`${label} must contain an object`)
128
+ const allowed = new Set(["base", "worktreeDirectory", "copyFiles", "ports", "postCreate", "preRemove"])
129
+ for (const key of Object.keys(parsed)) {
130
+ if (!allowed.has(key)) throw new CliError(`${label} contains an unknown field: ${key}`)
131
+ }
132
+
133
+ if (parsed.base !== undefined && (typeof parsed.base !== "string" || parsed.base.length === 0)) {
134
+ throw new CliError("base must be a non-empty string")
135
+ }
136
+
137
+ const worktreeDirectory = validateRelativePath(parsed.worktreeDirectory ?? ".worktrees", "worktreeDirectory")
138
+ if (worktreeDirectory.split(/[\\/]+/).some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) {
139
+ throw new CliError("worktreeDirectory can only contain letters, digits, '.', '_', '-', and path separators")
140
+ }
141
+ if (!Array.isArray(parsed.copyFiles ?? [])) throw new CliError("copyFiles must be an array")
142
+ const copyFiles = (parsed.copyFiles ?? []).map((path, index) => validateRelativePath(path, `copyFiles[${index}]`))
143
+ if (new Set(copyFiles).size !== copyFiles.length) throw new CliError("copyFiles cannot contain duplicates")
144
+
145
+ if (!Array.isArray(parsed.ports ?? [])) throw new CliError("ports must be an array")
146
+ const ports = (parsed.ports ?? []).map((name, index) => {
147
+ if (typeof name !== "string" || !ENV_NAME.test(name)) throw new CliError(`ports[${index}] is not a valid environment variable name`)
148
+ return name
149
+ })
150
+ if (new Set(ports).size !== ports.length) throw new CliError("ports cannot contain duplicates")
151
+ if (ports.length > 100) throw new CliError("ports cannot contain more than 100 entries")
152
+
153
+ for (const hook of ["postCreate", "preRemove"]) {
154
+ if (parsed[hook] !== undefined) validateRelativePath(parsed[hook], hook)
155
+ }
156
+
157
+ return {
158
+ ...parsed,
159
+ worktreeDirectory,
160
+ copyFiles,
161
+ ports,
162
+ }
163
+ }
164
+
165
+ function configHome() {
166
+ return process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
167
+ }
168
+
169
+ function userConfigPath() {
170
+ return join(configHome(), "gwt", "config.json")
171
+ }
172
+
173
+ function projectConfigPath(repository) {
174
+ return join(repository.primaryPath, PROJECT_CONFIG_FILE)
175
+ }
176
+
177
+ function projectIdentifier(repository) {
178
+ const remotes = git(["remote"], repository.primaryPath).stdout.trim().split("\n").filter(Boolean)
179
+ const remote = remotes.includes("origin") ? "origin" : remotes[0]
180
+ if (!remote) return repository.primaryPath
181
+
182
+ const url = gitOutput(["remote", "get-url", remote], repository.primaryPath)
183
+ const scp = url.includes("://") ? null : url.match(/^(?:[^@]+@)?([^:]+):(.+)$/)
184
+ if (scp) return `${scp[1].toLowerCase()}/${scp[2].replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`
185
+
186
+ try {
187
+ const parsed = new URL(url)
188
+ if (parsed.host && parsed.pathname) {
189
+ return `${parsed.host.toLowerCase()}/${parsed.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`
190
+ }
191
+ } catch {}
192
+
193
+ return repository.primaryPath
194
+ }
195
+
196
+ function readUserConfig() {
197
+ const path = userConfigPath()
198
+ if (!existsSync(path)) return { path, raw: "", value: { projects: {} } }
199
+ const raw = readFileSync(path, "utf8")
200
+ let value
201
+ try {
202
+ value = JSON.parse(raw)
203
+ } catch (error) {
204
+ throw new CliError(`${path} is not valid JSON: ${error.message}`)
205
+ }
206
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError(`${path} must contain an object`)
207
+ for (const key of Object.keys(value)) {
208
+ if (key !== "projects") throw new CliError(`${path} contains an unknown field: ${key}`)
209
+ }
210
+ if (value.projects !== undefined && (!value.projects || typeof value.projects !== "object" || Array.isArray(value.projects))) {
211
+ throw new CliError(`${path} projects must contain an object`)
212
+ }
213
+ return { path, raw, value: { ...value, projects: value.projects ?? {} } }
214
+ }
215
+
216
+ function loadConfig(repository) {
217
+ const projectPath = projectConfigPath(repository)
218
+ if (existsSync(projectPath)) {
219
+ const raw = readFileSync(projectPath, "utf8")
220
+ let parsed
221
+ try {
222
+ parsed = JSON.parse(raw)
223
+ } catch (error) {
224
+ throw new CliError(`${PROJECT_CONFIG_FILE} is not valid JSON: ${error.message}`)
225
+ }
226
+ return {
227
+ source: "project",
228
+ requiresTrust: true,
229
+ raw,
230
+ path: projectPath,
231
+ value: validateConfig(parsed, PROJECT_CONFIG_FILE),
232
+ }
233
+ }
234
+
235
+ const user = readUserConfig()
236
+ const identifier = projectIdentifier(repository)
237
+ if (Object.hasOwn(user.value.projects, identifier)) {
238
+ const parsed = user.value.projects[identifier]
239
+ return {
240
+ source: "user",
241
+ requiresTrust: false,
242
+ raw: `${JSON.stringify(parsed)}\n`,
243
+ path: user.path,
244
+ identifier,
245
+ value: validateConfig(parsed, `projects[${JSON.stringify(identifier)}]`),
246
+ }
247
+ }
248
+
249
+ return {
250
+ source: "default",
251
+ requiresTrust: false,
252
+ raw: "",
253
+ path: null,
254
+ identifier,
255
+ value: { ...DEFAULT_CONFIG },
256
+ }
257
+ }
258
+
259
+ function metadataDirectory(repository) {
260
+ return join(repository.commonDir, "gwt", "worktrees")
261
+ }
262
+
263
+ function metadataPath(repository, id) {
264
+ return join(metadataDirectory(repository), `${id}.json`)
265
+ }
266
+
267
+ function readJson(path) {
268
+ try {
269
+ return JSON.parse(readFileSync(path, "utf8"))
270
+ } catch (error) {
271
+ throw new CliError(`Cannot read ${path}: ${error.message}`)
272
+ }
273
+ }
274
+
275
+ function writeJson(path, value, mode = 0o600) {
276
+ mkdirSync(dirname(path), { recursive: true })
277
+ const temporaryPath = `${path}.${process.pid}.tmp`
278
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode })
279
+ renameSync(temporaryPath, path)
280
+ }
281
+
282
+ function loadMetadata(repository) {
283
+ const directory = metadataDirectory(repository)
284
+ if (!existsSync(directory)) return []
285
+
286
+ const paths = readdirSync(directory, { withFileTypes: true })
287
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
288
+ .map((entry) => join(directory, entry.name))
289
+
290
+ return paths.map((path) => ({ ...readJson(path), metadataPath: path }))
291
+ }
292
+
293
+ function metadataForWorktree(repository, worktree) {
294
+ const resolvedPath = resolve(worktree.path)
295
+ return loadMetadata(repository).find((metadata) => resolve(metadata.path) === resolvedPath) ?? null
296
+ }
297
+
298
+ function currentWorktree(repository, cwd = process.cwd()) {
299
+ const resolvedCwd = canonical(cwd)
300
+ return repository.worktrees
301
+ .filter((worktree) => isInside(canonical(worktree.path), resolvedCwd))
302
+ .sort((left, right) => right.path.length - left.path.length)[0] ?? null
303
+ }
304
+
305
+ function resolveWorktree(repository, selector, options = {}) {
306
+ if (!selector) {
307
+ const current = currentWorktree(repository)
308
+ if (!current) throw new CliError("The current directory is not inside a registered worktree")
309
+ return current
310
+ }
311
+
312
+ const metadata = loadMetadata(repository)
313
+ const idMatch = metadata.find((item) => item.id === selector)
314
+ if (idMatch) {
315
+ const match = repository.worktrees.find((worktree) => resolve(worktree.path) === resolve(idMatch.path))
316
+ if (match) return match
317
+ throw new CliError(`Worktree ${selector} is no longer registered with Git`)
318
+ }
319
+
320
+ const branchMatch = repository.worktrees.find((worktree) => worktree.branch === selector)
321
+ if (branchMatch) return branchMatch
322
+
323
+ const candidatePath = resolve(options.cwd ?? process.cwd(), selector)
324
+ const pathMatch = repository.worktrees.find((worktree) => resolve(worktree.path) === candidatePath)
325
+ if (pathMatch) return pathMatch
326
+
327
+ throw new CliError(`No worktree matches '${selector}'`)
328
+ }
329
+
330
+ function generateId(repository, config) {
331
+ for (let attempt = 0; attempt < 100; attempt += 1) {
332
+ const id = randomBytes(4).toString("hex")
333
+ const target = join(repository.primaryPath, config.worktreeDirectory, id)
334
+ if (!existsSync(metadataPath(repository, id)) && !pathExists(target)) return id
335
+ }
336
+ throw new CliError("Could not generate a unique worktree ID")
337
+ }
338
+
339
+ function portIsAvailable(port) {
340
+ return new Promise((resolveAvailability, rejectAvailability) => {
341
+ const server = createServer()
342
+ server.unref()
343
+ server.once("error", (error) => {
344
+ if (error.code === "EADDRINUSE") resolveAvailability(false)
345
+ else rejectAvailability(new CliError(`Cannot check port ${port}: ${error.message}`))
346
+ })
347
+ server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
348
+ server.close(() => resolveAvailability(true))
349
+ })
350
+ })
351
+ }
352
+
353
+ async function allocatePorts(repository, id, names) {
354
+ if (names.length === 0) return {}
355
+ const reserved = new Set(loadMetadata(repository).flatMap((metadata) => Object.values(metadata.ports ?? {})))
356
+ const availableStarts = PORT_MAX - PORT_MIN - names.length + 2
357
+ const digest = createHash("sha256").update(`${repository.primaryPath}\0${id}`).digest()
358
+ const initial = PORT_MIN + (digest.readUInt32BE(0) % availableStarts)
359
+
360
+ for (let offset = 0; offset < availableStarts; offset += 1) {
361
+ const start = PORT_MIN + ((initial - PORT_MIN + offset) % availableStarts)
362
+ const candidates = names.map((_, index) => start + index)
363
+ if (candidates.some((port) => reserved.has(port))) continue
364
+ const availability = await Promise.all(candidates.map(portIsAvailable))
365
+ if (!availability.every(Boolean)) continue
366
+ return Object.fromEntries(names.map((name, index) => [name, candidates[index]]))
367
+ }
368
+
369
+ throw new CliError(`No free port block is available in ${PORT_MIN}-${PORT_MAX}`)
370
+ }
371
+
372
+ function hookPaths(configDocument, worktreePath) {
373
+ const config = configDocument.value
374
+ const root = configDocument.source === "user"
375
+ ? canonical(dirname(configDocument.path))
376
+ : canonical(worktreePath)
377
+ const location = configDocument.source === "user" ? "user config directory" : "worktree"
378
+
379
+ return ["postCreate", "preRemove"]
380
+ .filter((name) => config[name])
381
+ .map((name) => {
382
+ const configuredPath = resolve(root, config[name])
383
+ if (!isInside(root, configuredPath)) throw new CliError(`${name} must be inside the ${location}`)
384
+ if (!existsSync(configuredPath)) throw new CliError(`${name} does not exist: ${config[name]}`)
385
+ const path = canonical(configuredPath)
386
+ if (!isInside(root, path)) throw new CliError(`${name} must resolve inside the ${location}`)
387
+ if (!statSync(path).isFile()) throw new CliError(`${name} must point to a file`)
388
+ try {
389
+ accessSync(path, constants.X_OK)
390
+ } catch {
391
+ throw new CliError(`${name} is not executable: ${config[name]}`)
392
+ }
393
+ return { name, configuredPath: config[name], path }
394
+ })
395
+ }
396
+
397
+ function trustFingerprint(repository, configDocument, worktreePath) {
398
+ const hooks = hookPaths(configDocument, worktreePath)
399
+ if (hooks.length === 0) return null
400
+ const hash = createHash("sha256")
401
+ hash.update(repository.primaryPath)
402
+ hash.update("\0")
403
+ hash.update(configDocument.raw)
404
+ for (const hook of hooks) {
405
+ hash.update("\0")
406
+ hash.update(hook.name)
407
+ hash.update("\0")
408
+ hash.update(readFileSync(hook.path))
409
+ }
410
+ return hash.digest("hex")
411
+ }
412
+
413
+ function approvalsPath() {
414
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
415
+ return join(configHome, "gwt", "approvals.json")
416
+ }
417
+
418
+ function readApprovals() {
419
+ const path = approvalsPath()
420
+ return existsSync(path) ? readJson(path) : { version: 1, repositories: {} }
421
+ }
422
+
423
+ function approvalKey(repository) {
424
+ return createHash("sha256").update(repository.primaryPath).digest("hex")
425
+ }
426
+
427
+ function isTrusted(repository, fingerprint) {
428
+ if (!fingerprint) return true
429
+ return readApprovals().repositories?.[approvalKey(repository)]?.fingerprint === fingerprint
430
+ }
431
+
432
+ function saveTrust(repository, fingerprint) {
433
+ const path = approvalsPath()
434
+ const approvals = readApprovals()
435
+ approvals.version = 1
436
+ approvals.repositories ??= {}
437
+ approvals.repositories[approvalKey(repository)] = {
438
+ path: repository.primaryPath,
439
+ fingerprint,
440
+ approvedAt: new Date().toISOString(),
441
+ }
442
+ writeJson(path, approvals)
443
+ }
444
+
445
+ function revokeTrust(repository) {
446
+ const path = approvalsPath()
447
+ const approvals = readApprovals()
448
+ if (approvals.repositories) delete approvals.repositories[approvalKey(repository)]
449
+ writeJson(path, approvals)
450
+ }
451
+
452
+ async function ask(question) {
453
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false
454
+ const prompt = createInterface({ input: process.stdin, output: process.stdout })
455
+ try {
456
+ const answer = await prompt.question(question)
457
+ return /^y(?:es)?$/i.test(answer.trim())
458
+ } finally {
459
+ prompt.close()
460
+ }
461
+ }
462
+
463
+ async function ensureTrusted(repository, configDocument, worktreePath) {
464
+ if (!configDocument.requiresTrust) return
465
+ const fingerprint = trustFingerprint(repository, configDocument, worktreePath)
466
+ if (!fingerprint || isTrusted(repository, fingerprint)) return
467
+
468
+ const hooks = hookPaths(configDocument, worktreePath)
469
+ console.error("This repository wants to run:")
470
+ for (const hook of hooks) console.error(` ${hook.name}: ${hook.configuredPath}`)
471
+ const allowed = await ask("Allow and remember? [y/N] ")
472
+ if (!allowed) throw new CliError("Project hooks are not trusted. Run 'gwt trust' to approve them")
473
+ saveTrust(repository, fingerprint)
474
+ }
475
+
476
+ function hookContext(repository, worktree, metadata) {
477
+ return {
478
+ id: metadata?.id ?? "",
479
+ path: canonical(worktree.path),
480
+ primaryPath: repository.primaryPath,
481
+ branch: worktree.branch ?? "",
482
+ ports: metadata?.ports ?? {},
483
+ }
484
+ }
485
+
486
+ function runHook(name, repository, configDocument, worktree, metadata) {
487
+ const configuredPath = configDocument.value[name]
488
+ if (!configuredPath) return
489
+ const hook = hookPaths(configDocument, canonical(worktree.path)).find((item) => item.name === name)
490
+ const context = hookContext(repository, worktree, metadata)
491
+ const env = {
492
+ ...process.env,
493
+ GWT_ID: context.id,
494
+ GWT_PATH: context.path,
495
+ GWT_PRIMARY_PATH: context.primaryPath,
496
+ GWT_BRANCH: context.branch,
497
+ ...Object.fromEntries(Object.entries(context.ports).map(([key, value]) => [key, String(value)])),
498
+ }
499
+ const result = run(hook.path, [], {
500
+ cwd: context.path,
501
+ env,
502
+ input: `${JSON.stringify(context)}\n`,
503
+ allowFailure: true,
504
+ })
505
+ if (result.stdout) process.stdout.write(result.stdout)
506
+ if (result.stderr) process.stderr.write(result.stderr)
507
+ if (result.status !== 0) throw new CliError(`${name} failed with status ${result.status}`)
508
+ }
509
+
510
+ function copyConfiguredFiles(repository, config, targetPath) {
511
+ for (const relativePath of config.copyFiles) {
512
+ const source = resolve(repository.primaryPath, relativePath)
513
+ const target = resolve(targetPath, relativePath)
514
+ if (!isInside(repository.primaryPath, source) || !isInside(targetPath, target)) {
515
+ throw new CliError(`copyFiles path escapes the repository: ${relativePath}`)
516
+ }
517
+ if (!existsSync(source)) throw new CliError(`Copy source does not exist: ${relativePath}`)
518
+ if (!statSync(source).isFile()) throw new CliError(`Copy source is not a file: ${relativePath}`)
519
+ if (existsSync(target)) continue
520
+ mkdirSync(dirname(target), { recursive: true })
521
+ copyFileSync(source, target)
522
+ chmodSync(target, statSync(source).mode)
523
+ }
524
+ }
525
+
526
+ function updateMetadata(repository, metadata, update) {
527
+ const next = { ...metadata, ...update, updatedAt: new Date().toISOString() }
528
+ writeJson(metadataPath(repository, next.id), next)
529
+ return next
530
+ }
531
+
532
+ async function setupWorktree(repository, configDocument, worktree, options = {}) {
533
+ if (resolve(worktree.path) === resolve(repository.primaryPath)) throw new CliError("The primary worktree does not need setup")
534
+ const targetPath = canonical(worktree.path)
535
+ ensureCopySources(repository, configDocument.value)
536
+ let metadata = metadataForWorktree(repository, worktree)
537
+
538
+ if (!metadata) {
539
+ const id = options.id ?? generateId(repository, configDocument.value)
540
+ metadata = {
541
+ version: 1,
542
+ id,
543
+ path: targetPath,
544
+ ports: await allocatePorts(repository, id, configDocument.value.ports),
545
+ setup: "pending",
546
+ createdAt: new Date().toISOString(),
547
+ updatedAt: new Date().toISOString(),
548
+ }
549
+ writeJson(metadataPath(repository, id), metadata)
550
+ }
551
+
552
+ try {
553
+ copyConfiguredFiles(repository, configDocument.value, targetPath)
554
+ if (options.noHooks) {
555
+ metadata = updateMetadata(repository, metadata, { setup: "incomplete" })
556
+ return metadata
557
+ }
558
+ await ensureTrusted(repository, configDocument, targetPath)
559
+ runHook("postCreate", repository, configDocument, worktree, metadata)
560
+ metadata = updateMetadata(repository, metadata, { setup: "complete" })
561
+ return metadata
562
+ } catch (error) {
563
+ updateMetadata(repository, metadata, { setup: "failed", setupError: error.message })
564
+ throw error
565
+ }
566
+ }
567
+
568
+ function ensureCopySources(repository, config) {
569
+ for (const relativePath of config.copyFiles) {
570
+ const source = resolve(repository.primaryPath, relativePath)
571
+ if (!existsSync(source)) throw new CliError(`Copy source does not exist: ${relativePath}`)
572
+ if (!statSync(source).isFile()) throw new CliError(`Copy source is not a file: ${relativePath}`)
573
+ const ignored = git(["check-ignore", "--quiet", "--", relativePath], repository.primaryPath, { allowFailure: true }).status === 0
574
+ if (!ignored) throw new CliError(`Copy source must be ignored by Git: ${relativePath}`)
575
+ }
576
+ }
577
+
578
+ function ensureLocalExclude(repository, directory) {
579
+ const infoExclude = join(repository.commonDir, "info", "exclude")
580
+ const pattern = `/${directory.replaceAll("\\", "/").replace(/\/+$/, "")}/`
581
+ mkdirSync(dirname(infoExclude), { recursive: true })
582
+ const current = existsSync(infoExclude) ? readFileSync(infoExclude, "utf8") : ""
583
+ if (current.split("\n").includes(pattern)) return
584
+ const separator = current.length > 0 && !current.endsWith("\n") ? "\n" : ""
585
+ writeFileSync(infoExclude, `${current}${separator}${pattern}\n`)
586
+ }
587
+
588
+ function validateBranch(branch, cwd) {
589
+ const result = git(["check-ref-format", "--branch", branch], cwd, { allowFailure: true })
590
+ if (result.status !== 0) throw new CliError(`Invalid branch name: ${branch}`)
591
+ const exists = git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd, { allowFailure: true }).status === 0
592
+ if (exists) throw new CliError(`Branch already exists: ${branch}`)
593
+ }
594
+
595
+ function writeCdDirective(path) {
596
+ if (process.env.GWT_CD_FILE) writeFileSync(process.env.GWT_CD_FILE, path)
597
+ }
598
+
599
+ function parseOptions(args, definitions = {}) {
600
+ const options = {}
601
+ const positionals = []
602
+ for (let index = 0; index < args.length; index += 1) {
603
+ const argument = args[index]
604
+ const definition = definitions[argument]
605
+ if (!definition) {
606
+ if (argument.startsWith("-")) throw new CliError(`Unknown option: ${argument}`)
607
+ positionals.push(argument)
608
+ continue
609
+ }
610
+ if (definition === "boolean") options[argument.slice(2)] = true
611
+ else {
612
+ index += 1
613
+ if (index >= args.length) throw new CliError(`${argument} requires a value`)
614
+ options[argument.slice(2)] = args[index]
615
+ }
616
+ }
617
+ return { options, positionals }
618
+ }
619
+
620
+ async function commandNew(args) {
621
+ const { options, positionals } = parseOptions(args, { "--base": "value", "--no-hooks": "boolean" })
622
+ if (positionals.length > 1) throw new CliError("Usage: gwt new [branch] [--base <ref>] [--no-hooks]")
623
+ const repository = discoverRepository()
624
+ const configDocument = loadConfig(repository)
625
+ ensureCopySources(repository, configDocument.value)
626
+ const id = generateId(repository, configDocument.value)
627
+ const branch = positionals[0] ?? `scratch/${id}`
628
+ validateBranch(branch, repository.primaryPath)
629
+ const requestedBase = options.base ?? configDocument.value.base
630
+ const base = requestedBase
631
+ ? gitOutput(["rev-parse", "--verify", `${requestedBase}^{commit}`], repository.primaryPath)
632
+ : gitOutput(["rev-parse", "HEAD"], repository.primaryPath)
633
+ const target = join(repository.primaryPath, configDocument.value.worktreeDirectory, id)
634
+ ensureLocalExclude(repository, configDocument.value.worktreeDirectory)
635
+
636
+ git(["worktree", "add", "-b", branch, target, base], repository.primaryPath, { stdio: "inherit" })
637
+ const refreshed = discoverRepository(repository.primaryPath)
638
+ const worktree = refreshed.worktrees.find((item) => resolve(item.path) === resolve(target))
639
+
640
+ try {
641
+ const metadata = await setupWorktree(refreshed, configDocument, worktree, {
642
+ id,
643
+ noHooks: options["no-hooks"],
644
+ })
645
+ console.log(`Worktree ${metadata.id} is ready at ${target}`)
646
+ console.log(`Branch: ${branch}`)
647
+ for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
648
+ writeCdDirective(target)
649
+ } catch (error) {
650
+ console.error(`Setup failed; worktree retained at ${target}`)
651
+ console.error(`Retry: gwt setup ${id}`)
652
+ console.error(`Remove: gwt remove ${id}`)
653
+ throw error
654
+ }
655
+ }
656
+
657
+ async function chooseWorktree(repository) {
658
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new CliError("A worktree selector is required in non-interactive mode")
659
+ const current = currentWorktree(repository)
660
+ const choices = repository.worktrees.map((worktree, index) => {
661
+ const metadata = metadataForWorktree(repository, worktree)
662
+ const relativePath = relative(repository.primaryPath, worktree.path)
663
+ return {
664
+ worktree,
665
+ current: resolve(current?.path ?? "") === resolve(worktree.path),
666
+ branch: worktree.branch ?? "(detached)",
667
+ id: metadata?.id ?? (index === 0 ? "primary" : "native"),
668
+ path: relativePath === "" ? "." : relativePath.startsWith(`..${sep}`) ? worktree.path : relativePath,
669
+ }
670
+ })
671
+
672
+ return new Promise((resolveChoice, rejectChoice) => {
673
+ let query = ""
674
+ let filtering = false
675
+ let selected = Math.max(0, choices.findIndex((choice) => choice.current))
676
+ let renderedLines = 0
677
+ const wasRaw = process.stdin.isRaw
678
+ const colors = process.env.NO_COLOR === undefined
679
+ ? { cyan: "\x1b[36m", yellow: "\x1b[33m", dim: "\x1b[2m", reset: "\x1b[0m" }
680
+ : { cyan: "", yellow: "", dim: "", reset: "" }
681
+
682
+ const clear = () => {
683
+ if (renderedLines > 0) process.stdout.write(`\x1b[${renderedLines}A\r\x1b[J`)
684
+ renderedLines = 0
685
+ }
686
+
687
+ const render = () => {
688
+ const normalizedQuery = query.toLowerCase()
689
+ const filtered = choices.filter((choice) => [choice.branch, choice.id, choice.path]
690
+ .some((value) => value.toLowerCase().includes(normalizedQuery)))
691
+ if (selected >= filtered.length) selected = Math.max(0, filtered.length - 1)
692
+
693
+ const terminalWidth = Math.max(40, process.stdout.columns ?? 100)
694
+ const numberWidth = String(Math.max(1, filtered.length)).length
695
+ const idWidth = 8
696
+ const longestBranch = Math.max(12, ...filtered.map((choice) => choice.branch.length))
697
+ const branchWidth = Math.min(32, longestBranch, terminalWidth - numberWidth - idWidth - 22)
698
+ const pathWidth = Math.max(8, terminalWidth - numberWidth - branchWidth - idWidth - 10)
699
+ const fit = (value, width) => value.length > width
700
+ ? `${value.slice(0, Math.max(0, width - 1))}…`
701
+ : value.padEnd(width)
702
+ const visibleCount = Math.max(3, (process.stdout.rows ?? 24) - 5)
703
+ const start = Math.max(0, Math.min(selected - Math.floor(visibleCount / 2), filtered.length - visibleCount))
704
+ const visible = filtered.slice(start, start + visibleCount)
705
+ const lines = [
706
+ `${colors.dim}${fit("Switch worktree ↑↓/jk/C-n/C-p move · 1-9 select · / filter · Enter", terminalWidth)}${colors.reset}`,
707
+ fit(`Filter: ${filtering ? "/" : ""}${query}`, terminalWidth),
708
+ `${colors.dim} ${fit("#", numberWidth)} ${fit("BRANCH", branchWidth)} ${fit("ID", idWidth)} ${fit("PATH", pathWidth)}${colors.reset}`,
709
+ ]
710
+
711
+ if (visible.length === 0) {
712
+ lines.push(`${colors.dim} No matching worktrees${colors.reset}`)
713
+ } else {
714
+ visible.forEach((choice, visibleIndex) => {
715
+ const index = start + visibleIndex
716
+ const selection = index === selected ? `${colors.cyan}>${colors.reset}` : " "
717
+ const currentMarker = choice.current ? `${colors.yellow}@${colors.reset}` : " "
718
+ lines.push(`${selection} ${fit(String(index + 1), numberWidth)} ${currentMarker} ${fit(choice.branch, branchWidth)} ${fit(choice.id, idWidth)} ${fit(choice.path, pathWidth)}`)
719
+ })
720
+ }
721
+
722
+ clear()
723
+ process.stdout.write(`${lines.join("\n")}\n`)
724
+ renderedLines = lines.length
725
+ return filtered
726
+ }
727
+
728
+ const finish = (error, choice) => {
729
+ process.stdin.off("keypress", onKeypress)
730
+ process.stdout.off("resize", render)
731
+ if (!wasRaw) process.stdin.setRawMode(false)
732
+ process.stdin.pause()
733
+ clear()
734
+ process.stdout.write("\x1b[?25h")
735
+ if (error) rejectChoice(error)
736
+ else resolveChoice(choice.worktree)
737
+ }
738
+
739
+ const onKeypress = (text, key) => {
740
+ const filtered = choices.filter((choice) => [choice.branch, choice.id, choice.path]
741
+ .some((value) => value.toLowerCase().includes(query.toLowerCase())))
742
+
743
+ if (key.ctrl && key.name === "c") {
744
+ finish(new CliError("Selection cancelled"))
745
+ return
746
+ }
747
+ if (key.name === "escape") {
748
+ if (filtering) {
749
+ filtering = false
750
+ render()
751
+ } else {
752
+ finish(new CliError("Selection cancelled"))
753
+ }
754
+ return
755
+ }
756
+
757
+ const moveUp = key.name === "up" || (key.ctrl && key.name === "p") || (!filtering && key.name === "k")
758
+ const moveDown = key.name === "down" || (key.ctrl && key.name === "n") || (!filtering && key.name === "j")
759
+ if (moveUp && filtered.length > 0) selected = (selected - 1 + filtered.length) % filtered.length
760
+ else if (moveDown && filtered.length > 0) selected = (selected + 1) % filtered.length
761
+ else if (key.name === "return") {
762
+ if (filtered[selected]) finish(null, filtered[selected])
763
+ else process.stdout.write("\x07")
764
+ return
765
+ } else if (!filtering && text === "/") {
766
+ filtering = true
767
+ } else if (filtering && key.name === "backspace") {
768
+ query = [...query].slice(0, -1).join("")
769
+ selected = 0
770
+ } else if (!filtering && /^[1-9]$/.test(text)) {
771
+ const choice = filtered[Number(text) - 1]
772
+ if (choice) finish(null, choice)
773
+ else process.stdout.write("\x07")
774
+ return
775
+ } else if (filtering && text && !key.ctrl && !key.meta) {
776
+ query += text.replace(/[\x00-\x1f\x7f]/g, "")
777
+ selected = 0
778
+ }
779
+ render()
780
+ }
781
+
782
+ emitKeypressEvents(process.stdin)
783
+ process.stdin.on("keypress", onKeypress)
784
+ process.stdout.on("resize", render)
785
+ process.stdin.setRawMode(true)
786
+ process.stdin.resume()
787
+ process.stdout.write("\x1b[?25l")
788
+ render()
789
+ })
790
+ }
791
+
792
+ async function commandSwitch(args) {
793
+ if (args.length > 1) throw new CliError("Usage: gwt switch [id|branch|path]")
794
+ const repository = discoverRepository()
795
+ const worktree = args[0] ? resolveWorktree(repository, args[0]) : await chooseWorktree(repository)
796
+ writeCdDirective(canonical(worktree.path))
797
+ console.log(canonical(worktree.path))
798
+ }
799
+
800
+ function worktreeRows(repository) {
801
+ const current = currentWorktree(repository)
802
+ const metadata = loadMetadata(repository)
803
+ const rows = repository.worktrees.map((worktree, index) => {
804
+ const item = metadata.find((entry) => resolve(entry.path) === resolve(worktree.path))
805
+ return {
806
+ current: current && resolve(current.path) === resolve(worktree.path),
807
+ id: item?.id ?? (index === 0 ? "primary" : "-"),
808
+ branch: worktree.branch ?? "(detached)",
809
+ setup: item?.setup ?? (index === 0 ? "-" : "unmanaged"),
810
+ path: worktree.path,
811
+ }
812
+ })
813
+ const registeredPaths = new Set(repository.worktrees.map((worktree) => resolve(worktree.path)))
814
+ for (const item of metadata.filter((entry) => !registeredPaths.has(resolve(entry.path)))) {
815
+ rows.push({ current: false, id: item.id, branch: "-", setup: "stale", path: item.path })
816
+ }
817
+ return rows
818
+ }
819
+
820
+ function commandList(args) {
821
+ if (args.length > 0) throw new CliError("Usage: gwt list")
822
+ const repository = discoverRepository()
823
+ const rows = worktreeRows(repository)
824
+ const widths = {
825
+ id: Math.max(2, ...rows.map((row) => row.id.length)),
826
+ branch: Math.max(6, ...rows.map((row) => row.branch.length)),
827
+ setup: Math.max(5, ...rows.map((row) => row.setup.length)),
828
+ }
829
+ console.log(` ${"ID".padEnd(widths.id)} ${"BRANCH".padEnd(widths.branch)} ${"SETUP".padEnd(widths.setup)} PATH`)
830
+ for (const row of rows) {
831
+ console.log(`${row.current ? "*" : " "} ${row.id.padEnd(widths.id)} ${row.branch.padEnd(widths.branch)} ${row.setup.padEnd(widths.setup)} ${row.path}`)
832
+ }
833
+ }
834
+
835
+ function commandInfo(args) {
836
+ if (args.length > 1) throw new CliError("Usage: gwt info [id|branch|path]")
837
+ const repository = discoverRepository()
838
+ const worktree = resolveWorktree(repository, args[0])
839
+ const metadata = metadataForWorktree(repository, worktree)
840
+ console.log(`ID: ${metadata?.id ?? (resolve(worktree.path) === resolve(repository.primaryPath) ? "primary" : "unmanaged")}`)
841
+ console.log(`Path: ${canonical(worktree.path)}`)
842
+ console.log(`Branch: ${worktree.branch ?? "(detached)"}`)
843
+ console.log(`HEAD: ${worktree.head}`)
844
+ console.log(`Setup: ${metadata?.setup ?? "unmanaged"}`)
845
+ for (const [name, port] of Object.entries(metadata?.ports ?? {})) console.log(`${name}: ${port}`)
846
+ if (metadata?.setupError) console.log(`Setup error: ${metadata.setupError}`)
847
+ }
848
+
849
+ async function commandSetup(args) {
850
+ const { options, positionals } = parseOptions(args, { "--no-hooks": "boolean" })
851
+ if (positionals.length > 1) throw new CliError("Usage: gwt setup [id|branch|path] [--no-hooks]")
852
+ const repository = discoverRepository()
853
+ const configDocument = loadConfig(repository)
854
+ const worktree = resolveWorktree(repository, positionals[0])
855
+ const metadata = await setupWorktree(repository, configDocument, worktree, { noHooks: options["no-hooks"] })
856
+ console.log(`Setup ${metadata.setup}: ${metadata.id}`)
857
+ for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
858
+ }
859
+
860
+ async function confirmDiscard(worktree) {
861
+ console.error(`Discard all changes and delete branch '${worktree.branch ?? "(detached)"}'?`)
862
+ return ask("Type yes to continue [y/N] ")
863
+ }
864
+
865
+ async function commandRemove(args) {
866
+ const { options, positionals } = parseOptions(args, {
867
+ "--keep-branch": "boolean",
868
+ "--discard": "boolean",
869
+ "--yes": "boolean",
870
+ "--no-hooks": "boolean",
871
+ })
872
+ if (positionals.length > 1) throw new CliError("Usage: gwt remove [id|branch|path] [--keep-branch|--discard] [--yes] [--no-hooks]")
873
+ if (options["keep-branch"] && options.discard) throw new CliError("--keep-branch and --discard cannot be combined")
874
+ const repository = discoverRepository()
875
+ const worktree = resolveWorktree(repository, positionals[0])
876
+ if (resolve(worktree.path) === resolve(repository.primaryPath)) throw new CliError("The primary worktree cannot be removed")
877
+ const targetPath = canonical(worktree.path)
878
+ const metadata = metadataForWorktree(repository, worktree)
879
+ const dirty = git(["status", "--porcelain"], worktree.path).stdout.length > 0
880
+ if (dirty && !options.discard) throw new CliError("Worktree has uncommitted changes; commit them or use --discard")
881
+ if (options.discard && !options.yes && !(await confirmDiscard(worktree))) throw new CliError("Removal cancelled")
882
+ const wasCurrent = currentWorktree(repository)?.path === worktree.path
883
+
884
+ if (!options["no-hooks"]) {
885
+ const configDocument = loadConfig(repository)
886
+ await ensureTrusted(repository, configDocument, targetPath)
887
+ runHook("preRemove", repository, configDocument, worktree, metadata)
888
+ }
889
+
890
+ const removeArgs = ["worktree", "remove"]
891
+ if (options.discard) removeArgs.push("--force")
892
+ removeArgs.push(targetPath)
893
+ git(removeArgs, repository.primaryPath)
894
+ if (metadata?.metadataPath && existsSync(metadata.metadataPath)) unlinkSync(metadata.metadataPath)
895
+
896
+ let branchMessage = "No branch to delete"
897
+ if (worktree.branch && !options["keep-branch"]) {
898
+ const deleteArgs = ["branch", options.discard ? "-D" : "-d", "--", worktree.branch]
899
+ const result = git(deleteArgs, repository.primaryPath, { allowFailure: true })
900
+ branchMessage = result.status === 0 ? `Deleted branch: ${worktree.branch}` : `Kept branch: ${worktree.branch}`
901
+ } else if (worktree.branch) branchMessage = `Kept branch: ${worktree.branch}`
902
+
903
+ console.log(`Removed worktree: ${metadata?.id ?? targetPath}`)
904
+ console.log(branchMessage)
905
+ if (wasCurrent) writeCdDirective(repository.primaryPath)
906
+ }
907
+
908
+ function commandTrust(args) {
909
+ const { options, positionals } = parseOptions(args, { "--revoke": "boolean" })
910
+ if (positionals.length > 0) throw new CliError("Usage: gwt trust [--revoke]")
911
+ const repository = discoverRepository()
912
+ if (options.revoke) {
913
+ revokeTrust(repository)
914
+ console.log(`Revoked trust for ${repository.primaryPath}`)
915
+ return
916
+ }
917
+ const current = currentWorktree(repository)
918
+ const configDocument = loadConfig(repository)
919
+ if (!configDocument.requiresTrust) {
920
+ console.log(configDocument.source === "user"
921
+ ? "User config hooks are trusted automatically"
922
+ : "This repository has no project config to approve")
923
+ return
924
+ }
925
+ const fingerprint = trustFingerprint(repository, configDocument, canonical(current.path))
926
+ if (!fingerprint) {
927
+ console.log("This repository has no project hooks to approve")
928
+ return
929
+ }
930
+ saveTrust(repository, fingerprint)
931
+ console.log(`Trusted project hooks for ${repository.primaryPath}`)
932
+ }
933
+
934
+ function configScaffold() {
935
+ return { worktreeDirectory: ".worktrees", copyFiles: [], ports: [] }
936
+ }
937
+
938
+ function commandConfigCreate(args) {
939
+ const { options, positionals } = parseOptions(args, { "--project": "boolean" })
940
+ if (positionals.length > 0) throw new CliError("Usage: gwt config create [--project]")
941
+ const repository = discoverRepository()
942
+
943
+ if (options.project) {
944
+ const path = projectConfigPath(repository)
945
+ if (existsSync(path)) throw new CliError(`Project config already exists: ${path}`)
946
+ const active = loadConfig(repository)
947
+ const value = active.source === "user" ? { ...active.value } : configScaffold()
948
+ const skippedHooks = ["postCreate", "preRemove"].filter((name) => value[name])
949
+ for (const hook of skippedHooks) delete value[hook]
950
+ writeJson(path, value, 0o644)
951
+ console.log(`Created project config: ${path}`)
952
+ if (skippedHooks.length > 0) {
953
+ console.log(`Skipped user hooks: ${skippedHooks.join(", ")}. Add repository-relative hook paths explicitly.`)
954
+ }
955
+ return
956
+ }
957
+
958
+ const user = readUserConfig()
959
+ const identifier = projectIdentifier(repository)
960
+ if (Object.hasOwn(user.value.projects, identifier)) {
961
+ throw new CliError(`User config already contains project: ${identifier}`)
962
+ }
963
+ user.value.projects[identifier] = configScaffold()
964
+ writeJson(user.path, user.value)
965
+ console.log(`Created user config for ${identifier}: ${user.path}`)
966
+ }
967
+
968
+ function commandConfigShow(args) {
969
+ if (args.length > 0) throw new CliError("Usage: gwt config show")
970
+ const repository = discoverRepository()
971
+ const identifier = projectIdentifier(repository)
972
+ const user = readUserConfig()
973
+ const projectPath = projectConfigPath(repository)
974
+ const active = loadConfig(repository)
975
+ const userFileExists = existsSync(user.path)
976
+ const userConfigured = Object.hasOwn(user.value.projects, identifier)
977
+ const activeLabel = active.source === "user"
978
+ ? "user"
979
+ : active.source === "project"
980
+ ? "repository"
981
+ : "built-in defaults"
982
+
983
+ console.log(`Project: ${identifier}`)
984
+ console.log(`User config: ${userConfigured ? "configured" : userFileExists ? "project not configured" : "not created"}`)
985
+ if (userFileExists) console.log(` File: ${user.path}`)
986
+ const repositoryConfigured = existsSync(projectPath)
987
+ console.log(`Repository config: ${repositoryConfigured ? "configured" : "not created"}`)
988
+ if (repositoryConfigured) console.log(` File: ${projectPath}`)
989
+ console.log(`Active config: ${activeLabel}`)
990
+ console.log(JSON.stringify(active.value, null, 2))
991
+ }
992
+
993
+ function commandConfig(args) {
994
+ if (args[0] === "create") return commandConfigCreate(args.slice(1))
995
+ if (args[0] === "show") return commandConfigShow(args.slice(1))
996
+ throw new CliError("Usage: gwt config <create [--project]|show>")
997
+ }
998
+
999
+ function zshIntegration() {
1000
+ return `# gwt shell integration for zsh
1001
+ if command -v gwt >/dev/null 2>&1; then
1002
+ gwt() {
1003
+ local cd_file exit_code=0
1004
+ cd_file="$(mktemp)" || return
1005
+ GWT_CD_FILE="$cd_file" command gwt "$@" || exit_code=$?
1006
+ if [[ $exit_code -eq 0 && -s "$cd_file" ]]; then
1007
+ builtin cd -- "$(<"$cd_file")" || exit_code=$?
1008
+ fi
1009
+ rm -f -- "$cd_file"
1010
+ return $exit_code
1011
+ }
1012
+
1013
+ _gwt_worktrees() {
1014
+ local -a targets
1015
+ targets=("\${(@f)$(command gwt __complete worktrees 2>/dev/null)}")
1016
+ compadd -a targets
1017
+ }
1018
+
1019
+ _gwt_refs() {
1020
+ local -a refs
1021
+ refs=("\${(@f)$(command gwt __complete refs 2>/dev/null)}")
1022
+ compadd -a refs
1023
+ }
1024
+
1025
+ _gwt() {
1026
+ local -a commands
1027
+ commands=(
1028
+ 'new:Create and set up a worktree'
1029
+ 'setup:Set up an existing worktree'
1030
+ 'list:List worktrees'
1031
+ 'switch:Switch to a worktree'
1032
+ 'info:Show worktree details'
1033
+ 'remove:Remove a worktree'
1034
+ 'trust:Approve project hooks'
1035
+ 'config:Manage user and project configuration'
1036
+ 'shell:Install shell integration'
1037
+ )
1038
+
1039
+ if (( CURRENT == 2 )); then
1040
+ _describe 'command' commands
1041
+ return
1042
+ fi
1043
+
1044
+ case "$words[2]" in
1045
+ new)
1046
+ _arguments \
1047
+ '2:branch name:' \
1048
+ '--base[base Git revision]:revision:_gwt_refs' \
1049
+ '--no-hooks[skip project hooks]' \
1050
+ '(-h --help)'{-h,--help}'[show help]'
1051
+ ;;
1052
+ setup)
1053
+ _arguments \
1054
+ '2:worktree:_gwt_worktrees' \
1055
+ '--no-hooks[skip project hooks]' \
1056
+ '(-h --help)'{-h,--help}'[show help]'
1057
+ ;;
1058
+ list)
1059
+ _arguments '(-h --help)'{-h,--help}'[show help]'
1060
+ ;;
1061
+ switch|info)
1062
+ _arguments \
1063
+ '2:worktree:_gwt_worktrees' \
1064
+ '(-h --help)'{-h,--help}'[show help]'
1065
+ ;;
1066
+ remove)
1067
+ _arguments \
1068
+ '2:worktree:_gwt_worktrees' \
1069
+ '--keep-branch[keep the worktree branch]' \
1070
+ '--discard[discard uncommitted changes]' \
1071
+ '--yes[skip removal confirmation]' \
1072
+ '--no-hooks[skip project hooks]' \
1073
+ '(-h --help)'{-h,--help}'[show help]'
1074
+ ;;
1075
+ trust)
1076
+ _arguments \
1077
+ '--revoke[revoke project hook approval]' \
1078
+ '(-h --help)'{-h,--help}'[show help]'
1079
+ ;;
1080
+ config)
1081
+ _arguments \
1082
+ '2:action:(create show)' \
1083
+ '--project[create a config in the repository]' \
1084
+ '(-h --help)'{-h,--help}'[show help]'
1085
+ ;;
1086
+ shell)
1087
+ _arguments \
1088
+ '2:action:(install)' \
1089
+ '3:shell:(zsh)' \
1090
+ '--dry-run[show the change without writing]' \
1091
+ '--yes[skip installation confirmation]' \
1092
+ '(-h --help)'{-h,--help}'[show help]'
1093
+ ;;
1094
+ esac
1095
+ }
1096
+
1097
+ if (( $+functions[compdef] )); then
1098
+ compdef _gwt gwt
1099
+ fi
1100
+ fi`
1101
+ }
1102
+
1103
+ function zshConfigPath() {
1104
+ return join(process.env.ZDOTDIR ? resolve(process.env.ZDOTDIR) : homedir(), ".zshrc")
1105
+ }
1106
+
1107
+ async function installZshIntegration(args) {
1108
+ const { options, positionals } = parseOptions(args, { "--dry-run": "boolean", "--yes": "boolean" })
1109
+ if (positionals.length > 0) throw new CliError("Usage: gwt shell install zsh [--dry-run] [--yes]")
1110
+
1111
+ const path = zshConfigPath()
1112
+ const current = existsSync(path) ? readFileSync(path, "utf8") : ""
1113
+ const installed = current
1114
+ .split("\n")
1115
+ .some((line) => !line.trimStart().startsWith("#") && line.includes("gwt shell init zsh"))
1116
+ if (installed) {
1117
+ console.log(`Shell integration is already installed in ${path}`)
1118
+ return
1119
+ }
1120
+
1121
+ const line = 'eval "$(command gwt shell init zsh)"'
1122
+ console.log(`Add to ${path}:\n\n${line}`)
1123
+ if (options["dry-run"]) return
1124
+ if (!options.yes && !(await ask("Install? [y/N] "))) throw new CliError("Shell integration installation cancelled")
1125
+
1126
+ mkdirSync(dirname(path), { recursive: true })
1127
+ const separator = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n"
1128
+ writeFileSync(path, `${current}${separator}# gwt shell integration\n${line}\n`)
1129
+ console.log(`Installed shell integration in ${path}`)
1130
+ console.log("Restart zsh or run: source ~/.zshrc")
1131
+ }
1132
+
1133
+ async function commandShell(args) {
1134
+ if (args[0] === "init" && args[1] === "zsh" && args.length === 2) {
1135
+ console.log(zshIntegration())
1136
+ return
1137
+ }
1138
+ if (args[0] === "install" && args[1] === "zsh") return installZshIntegration(args.slice(2))
1139
+ throw new CliError("Usage: gwt shell install zsh [--dry-run] [--yes]")
1140
+ }
1141
+
1142
+ function commandComplete(args) {
1143
+ if (args.length !== 1) throw new CliError("Invalid completion request")
1144
+
1145
+ if (args[0] === "worktrees") {
1146
+ const repository = discoverRepository()
1147
+ const values = []
1148
+ for (const worktree of repository.worktrees) {
1149
+ const metadata = metadataForWorktree(repository, worktree)
1150
+ if (metadata?.id) values.push(metadata.id)
1151
+ if (worktree.branch) values.push(worktree.branch)
1152
+ }
1153
+ console.log([...new Set(values)].join("\n"))
1154
+ return
1155
+ }
1156
+
1157
+ if (args[0] === "refs") {
1158
+ const refs = git([
1159
+ "for-each-ref",
1160
+ "--format=%(refname:short)",
1161
+ "refs/heads",
1162
+ "refs/remotes",
1163
+ "refs/tags",
1164
+ ], process.cwd()).stdout.trim()
1165
+ console.log(["HEAD", ...refs.split("\n").filter(Boolean)].join("\n"))
1166
+ return
1167
+ }
1168
+
1169
+ throw new CliError("Invalid completion request")
1170
+ }
1171
+
1172
+ function help(command, subcommand) {
1173
+ const topic = [command, subcommand].filter(Boolean).join(" ")
1174
+ const texts = {
1175
+ "": `gwt ${VERSION} - lightweight native Git worktree workflows
1176
+
1177
+ Usage:
1178
+ gwt <command> [options]
1179
+
1180
+ Commands:
1181
+ new Create and set up a worktree
1182
+ setup Set up an existing worktree
1183
+ list List registered worktrees
1184
+ switch Switch the current shell to a worktree
1185
+ info Show worktree details and assigned ports
1186
+ remove Safely remove a worktree and optionally its branch
1187
+ trust Approve or revoke repository project hooks
1188
+ config Create or inspect configuration
1189
+ shell Install shell integration
1190
+
1191
+ Options:
1192
+ -h, --help Show help.
1193
+ -V, --version Show the gwt version.
1194
+
1195
+ Examples:
1196
+ gwt new feature/auth
1197
+ gwt switch
1198
+ gwt remove
1199
+ gwt config create
1200
+ gwt shell install zsh
1201
+
1202
+ Run 'gwt <command> --help' for command behavior and more examples.`,
1203
+ new: `Create a worktree, prepare its development environment, and switch to it.
1204
+
1205
+ Usage:
1206
+ gwt new [branch] [--base <ref>] [--no-hooks]
1207
+
1208
+ Arguments:
1209
+ branch New local branch name. Defaults to scratch/<id>.
1210
+
1211
+ Options:
1212
+ --base <ref> Start from this Git revision instead of the configured base
1213
+ or the primary worktree's current commit.
1214
+ --no-hooks Copy files and allocate ports, but skip postCreate.
1215
+ -h, --help Show help for this command.
1216
+
1217
+ Behavior:
1218
+ The worktree receives an immutable 8-character ID. gwt creates it below the
1219
+ configured worktreeDirectory, copies configured local files, assigns stable
1220
+ ports, and runs postCreate. A setup failure keeps the worktree so setup can
1221
+ be retried. With shell integration installed, the current shell moves into
1222
+ the new worktree after setup succeeds.
1223
+
1224
+ Examples:
1225
+ gwt new feature/auth
1226
+ gwt new
1227
+ gwt new hotfix/login --base origin/main
1228
+ gwt new experiment --no-hooks`,
1229
+ setup: `Prepare an existing linked worktree using the active gwt configuration.
1230
+
1231
+ Usage:
1232
+ gwt setup [id|branch|path] [--no-hooks]
1233
+
1234
+ Arguments:
1235
+ id|branch|path Worktree to set up. Defaults to the current worktree.
1236
+
1237
+ Options:
1238
+ --no-hooks Copy files and allocate ports, but skip postCreate.
1239
+ -h, --help Show help for this command.
1240
+
1241
+ Behavior:
1242
+ Use this to adopt a worktree created with native 'git worktree add' or to
1243
+ retry a failed setup. Existing copied files and assigned ports are preserved.
1244
+
1245
+ Examples:
1246
+ gwt setup
1247
+ gwt setup feature/auth
1248
+ gwt setup a1b2c3d4 --no-hooks`,
1249
+ list: `List Git worktrees together with gwt IDs and setup status.
1250
+
1251
+ Usage:
1252
+ gwt list
1253
+
1254
+ Options:
1255
+ -h, --help Show help for this command.
1256
+
1257
+ The current worktree is marked with '*'. Native worktrees that have not been
1258
+ set up by gwt are shown as unmanaged.
1259
+
1260
+ Example:
1261
+ gwt list`,
1262
+ switch: `Switch the current shell to another worktree.
1263
+
1264
+ Usage:
1265
+ gwt switch [id|branch|path]
1266
+
1267
+ Arguments:
1268
+ id|branch|path Worktree to switch to. Opens the picker when omitted.
1269
+
1270
+ Options:
1271
+ -h, --help Show help for this command.
1272
+
1273
+ Behavior:
1274
+ The picker supports arrow keys, j/k, Ctrl-n/Ctrl-p, number shortcuts, and
1275
+ '/' filtering. Shell integration must be installed for gwt to change the
1276
+ parent shell's directory; otherwise the selected path is only printed.
1277
+
1278
+ Examples:
1279
+ gwt switch
1280
+ gwt switch feature/auth
1281
+ gwt switch a1b2c3d4`,
1282
+ info: `Show a worktree's identity, Git state, setup status, and assigned ports.
1283
+
1284
+ Usage:
1285
+ gwt info [id|branch|path]
1286
+
1287
+ Arguments:
1288
+ id|branch|path Worktree to inspect. Defaults to the current worktree.
1289
+
1290
+ Options:
1291
+ -h, --help Show help for this command.
1292
+
1293
+ Examples:
1294
+ gwt info
1295
+ gwt info feature/auth`,
1296
+ remove: `Safely remove a linked worktree and, by default, its branch.
1297
+
1298
+ Usage:
1299
+ gwt remove [id|branch|path] [--keep-branch|--discard] [--yes] [--no-hooks]
1300
+
1301
+ Arguments:
1302
+ id|branch|path Worktree to remove. Defaults to the current worktree.
1303
+
1304
+ Options:
1305
+ --keep-branch Remove the worktree but retain its branch.
1306
+ --discard Allow uncommitted changes to be discarded and force-delete
1307
+ the branch.
1308
+ --yes Skip the confirmation required by --discard.
1309
+ --no-hooks Skip preRemove.
1310
+ -h, --help Show help for this command.
1311
+
1312
+ Behavior:
1313
+ Without --discard, dirty worktrees are rejected and branches are deleted only
1314
+ when 'git branch -d' considers deletion safe. The primary worktree cannot be
1315
+ removed. Removing the current worktree returns an integrated shell to the
1316
+ primary worktree.
1317
+
1318
+ Examples:
1319
+ gwt remove
1320
+ gwt remove feature/auth --keep-branch
1321
+ gwt remove a1b2c3d4 --discard --yes`,
1322
+ trust: `Approve or revoke hooks declared by the repository's .gwt.json.
1323
+
1324
+ Usage:
1325
+ gwt trust [--revoke]
1326
+
1327
+ Options:
1328
+ --revoke Remove the stored approval for this repository.
1329
+ -h, --help Show help for this command.
1330
+
1331
+ Approval is tied to the configuration and hook contents, so changing either
1332
+ requires approval again. Hooks declared in user configuration are trusted
1333
+ automatically.
1334
+
1335
+ Examples:
1336
+ gwt trust
1337
+ gwt trust --revoke`,
1338
+ config: `Create or inspect configuration for the current repository.
1339
+
1340
+ Usage:
1341
+ gwt config <create|show>
1342
+
1343
+ Commands:
1344
+ create Create a user config entry or a repository config file
1345
+ show Show config availability, source, and resolved values
1346
+
1347
+ Options:
1348
+ -h, --help Show help for this command.
1349
+
1350
+ Examples:
1351
+ gwt config create
1352
+ gwt config create --project
1353
+ gwt config show
1354
+
1355
+ Run 'gwt config <command> --help' for details.`,
1356
+ "config create": `Create configuration for the current repository.
1357
+
1358
+ Usage:
1359
+ gwt config create [--project]
1360
+
1361
+ Options:
1362
+ --project Create .gwt.json in the primary worktree instead of adding
1363
+ an entry to the user config.
1364
+ -h, --help Show help for this command.
1365
+
1366
+ By default, the project is added to the user config:
1367
+ ${userConfigPath()}
1368
+
1369
+ With --project, the active user configuration's non-hook fields are copied
1370
+ when available; otherwise a default scaffold is created. User hooks are omitted
1371
+ because repository hooks use worktree-relative paths. Repository configuration
1372
+ takes precedence and can be committed for the team.
1373
+
1374
+ Examples:
1375
+ gwt config create
1376
+ gwt config create --project`,
1377
+ "config show": `Show configuration availability and the active resolved values.
1378
+
1379
+ Usage:
1380
+ gwt config show
1381
+
1382
+ Options:
1383
+ -h, --help Show help for this command.
1384
+
1385
+ The output distinguishes a missing user config file from an existing file that
1386
+ does not configure the current project. Repository configuration takes
1387
+ precedence over user configuration.
1388
+
1389
+ Example:
1390
+ gwt config show`,
1391
+ shell: `Install shell integration for navigation and completion.
1392
+
1393
+ Usage:
1394
+ gwt shell install zsh [--dry-run] [--yes]
1395
+
1396
+ Options:
1397
+ -h, --help Show help for this command.
1398
+
1399
+ The integration lets gwt change the current shell's directory after new,
1400
+ switch, and removal of the current worktree. It also installs completion.
1401
+
1402
+ Example:
1403
+ gwt shell install zsh`,
1404
+ "shell install": `Install gwt navigation and completion in Zsh.
1405
+
1406
+ Usage:
1407
+ gwt shell install zsh [--dry-run] [--yes]
1408
+
1409
+ Options:
1410
+ --dry-run Print the .zshrc change without writing it.
1411
+ --yes Install without asking for confirmation.
1412
+ -h, --help Show help for this command.
1413
+
1414
+ The command adds one initialization line to ~/.zshrc, or to $ZDOTDIR/.zshrc
1415
+ when ZDOTDIR is set. Restart Zsh or source the file after installation.
1416
+
1417
+ Examples:
1418
+ gwt shell install zsh
1419
+ gwt shell install zsh --dry-run`,
1420
+ }
1421
+
1422
+ if (!Object.hasOwn(texts, topic)) throw new CliError(`Unknown help topic: ${topic}`)
1423
+ console.log(texts[topic])
1424
+ }
1425
+
1426
+ async function main() {
1427
+ const [command, ...args] = process.argv.slice(2)
1428
+ if (!command || command === "--help" || command === "-h") return help()
1429
+ if (command === "help") return help(args[0], args[1])
1430
+ if (command === "--version" || command === "-V") return console.log(VERSION)
1431
+ if (args.includes("--help") || args.includes("-h")) {
1432
+ const subcommand = ["config", "shell"].includes(command)
1433
+ ? args.find((argument) => !argument.startsWith("-"))
1434
+ : undefined
1435
+ return help(command, subcommand)
1436
+ }
1437
+ if (command === "new") return commandNew(args)
1438
+ if (command === "setup") return commandSetup(args)
1439
+ if (command === "list") return commandList(args)
1440
+ if (command === "switch") return commandSwitch(args)
1441
+ if (command === "info") return commandInfo(args)
1442
+ if (command === "remove") return commandRemove(args)
1443
+ if (command === "trust") return commandTrust(args)
1444
+ if (command === "config") return commandConfig(args)
1445
+ if (command === "shell") return commandShell(args)
1446
+ if (command === "__complete") return commandComplete(args)
1447
+ throw new CliError(`Unknown command: ${command}`)
1448
+ }
1449
+
1450
+ try {
1451
+ await main()
1452
+ } catch (error) {
1453
+ if (error instanceof CliError) {
1454
+ console.error(`gwt: ${error.message}`)
1455
+ process.exitCode = 1
1456
+ } else {
1457
+ throw error
1458
+ }
1459
+ }