@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.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/gwt.mjs +1459 -0
- 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
|
+
}
|