@junheep/gwt 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -41
- package/bin/gwt.mjs +725 -91
- package/package.json +1 -1
package/bin/gwt.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto"
|
|
5
5
|
import {
|
|
6
6
|
accessSync,
|
|
7
7
|
chmodSync,
|
|
8
|
+
closeSync,
|
|
8
9
|
constants,
|
|
9
10
|
copyFileSync,
|
|
10
11
|
existsSync,
|
|
11
12
|
mkdirSync,
|
|
13
|
+
openSync,
|
|
12
14
|
readFileSync,
|
|
13
15
|
readdirSync,
|
|
14
16
|
realpathSync,
|
|
@@ -27,8 +29,10 @@ const PROJECT_CONFIG_FILE = ".gwt.json"
|
|
|
27
29
|
const PORT_MIN = 20_000
|
|
28
30
|
const PORT_MAX = 39_999
|
|
29
31
|
const PICKER_ESCAPE_CODE_TIMEOUT_MS = 50
|
|
32
|
+
const JOB_START_GRACE_MS = 30_000
|
|
30
33
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
31
|
-
const
|
|
34
|
+
const SHELL_ENV_STATE = "GWT_SHELL_ENV_STATE"
|
|
35
|
+
const DEFAULT_CONFIG = { copyFiles: [], ports: [], env: {} }
|
|
32
36
|
const SKILL_DIRECTORIES = { claude: ".claude", codex: ".agents" }
|
|
33
37
|
const SKILL_USAGE = `Usage: gwt skill install <${Object.keys(SKILL_DIRECTORIES).join("|")}> [--project] [--dry-run] [--yes]`
|
|
34
38
|
|
|
@@ -127,7 +131,7 @@ function validateRelativePath(value, field) {
|
|
|
127
131
|
|
|
128
132
|
function validateConfig(parsed, label) {
|
|
129
133
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError(`${label} must contain an object`)
|
|
130
|
-
const allowed = new Set(["base", "worktreeDirectory", "copyFiles", "ports", "postCreate", "preRemove"])
|
|
134
|
+
const allowed = new Set(["base", "worktreeDirectory", "copyFiles", "ports", "env", "postCreate", "preRemove"])
|
|
131
135
|
for (const key of Object.keys(parsed)) {
|
|
132
136
|
if (!allowed.has(key)) throw new CliError(`${label} contains an unknown field: ${key}`)
|
|
133
137
|
}
|
|
@@ -150,11 +154,33 @@ function validateConfig(parsed, label) {
|
|
|
150
154
|
if (!Array.isArray(parsed.ports ?? [])) throw new CliError("ports must be an array")
|
|
151
155
|
const ports = (parsed.ports ?? []).map((name, index) => {
|
|
152
156
|
if (typeof name !== "string" || !ENV_NAME.test(name)) throw new CliError(`ports[${index}] is not a valid environment variable name`)
|
|
157
|
+
if (name.startsWith("GWT_")) throw new CliError(`ports[${index}] cannot use the reserved GWT_ prefix`)
|
|
153
158
|
return name
|
|
154
159
|
})
|
|
155
160
|
if (new Set(ports).size !== ports.length) throw new CliError("ports cannot contain duplicates")
|
|
156
161
|
if (ports.length > 100) throw new CliError("ports cannot contain more than 100 entries")
|
|
157
162
|
|
|
163
|
+
if (!parsed.env || typeof parsed.env !== "object" || Array.isArray(parsed.env)) {
|
|
164
|
+
if (parsed.env !== undefined) throw new CliError("env must be an object")
|
|
165
|
+
}
|
|
166
|
+
const envEntries = Object.entries(parsed.env ?? {})
|
|
167
|
+
if (envEntries.length > 100) throw new CliError("env cannot contain more than 100 entries")
|
|
168
|
+
const env = Object.fromEntries(envEntries.map(([name, value]) => {
|
|
169
|
+
if (!ENV_NAME.test(name)) throw new CliError(`env.${name} is not a valid environment variable name`)
|
|
170
|
+
if (name.startsWith("GWT_")) throw new CliError(`env.${name} cannot use the reserved GWT_ prefix`)
|
|
171
|
+
if (ports.includes(name)) throw new CliError(`env.${name} conflicts with a configured port`)
|
|
172
|
+
if (typeof value !== "string") throw new CliError(`env.${name} must be a string`)
|
|
173
|
+
|
|
174
|
+
const remainder = value.replace(/\$\{([^}]*)\}/g, (_, reference) => {
|
|
175
|
+
if (!ENV_NAME.test(reference) || !ports.includes(reference)) {
|
|
176
|
+
throw new CliError(`env.${name} references unknown port ${reference || "(empty)"}`)
|
|
177
|
+
}
|
|
178
|
+
return ""
|
|
179
|
+
})
|
|
180
|
+
if (remainder.includes("${")) throw new CliError(`env.${name} contains an invalid port reference`)
|
|
181
|
+
return [name, value]
|
|
182
|
+
}))
|
|
183
|
+
|
|
158
184
|
for (const hook of ["postCreate", "preRemove"]) {
|
|
159
185
|
if (parsed[hook] !== undefined) validateRelativePath(parsed[hook], hook)
|
|
160
186
|
}
|
|
@@ -163,11 +189,24 @@ function validateConfig(parsed, label) {
|
|
|
163
189
|
...parsed,
|
|
164
190
|
copyFiles,
|
|
165
191
|
ports,
|
|
192
|
+
env,
|
|
166
193
|
}
|
|
167
194
|
if (worktreeDirectory !== undefined) config.worktreeDirectory = worktreeDirectory
|
|
168
195
|
return config
|
|
169
196
|
}
|
|
170
197
|
|
|
198
|
+
function resolveConfiguredEnv(config, ports) {
|
|
199
|
+
return Object.fromEntries(Object.entries(config.env).map(([name, template]) => [
|
|
200
|
+
name,
|
|
201
|
+
template.replace(/\$\{([^}]*)\}/g, (_, reference) => {
|
|
202
|
+
if (!Object.hasOwn(ports, reference)) {
|
|
203
|
+
throw new CliError(`Cannot resolve env.${name}: this worktree has no assigned ${reference}`)
|
|
204
|
+
}
|
|
205
|
+
return String(ports[reference])
|
|
206
|
+
}),
|
|
207
|
+
]))
|
|
208
|
+
}
|
|
209
|
+
|
|
171
210
|
function configHome() {
|
|
172
211
|
return process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
|
173
212
|
}
|
|
@@ -312,6 +351,25 @@ function metadataPath(repository, id) {
|
|
|
312
351
|
return join(metadataDirectory(repository), `${id}.json`)
|
|
313
352
|
}
|
|
314
353
|
|
|
354
|
+
function setupLogPath(repository, id) {
|
|
355
|
+
return join(repository.commonDir, "gwt", "logs", `${id}.log`)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function jobStatus(metadata) {
|
|
359
|
+
if (metadata?.setup !== "running") return metadata?.setup
|
|
360
|
+
const pid = metadata.job?.pid
|
|
361
|
+
if (!pid) {
|
|
362
|
+
const startedAt = Date.parse(metadata.job?.startedAt ?? "")
|
|
363
|
+
return Number.isNaN(startedAt) || Date.now() - startedAt < JOB_START_GRACE_MS ? "running" : "interrupted"
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
process.kill(pid, 0)
|
|
367
|
+
return "running"
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return error.code === "EPERM" ? "running" : "interrupted"
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
315
373
|
function readJson(path) {
|
|
316
374
|
try {
|
|
317
375
|
return JSON.parse(readFileSync(path, "utf8"))
|
|
@@ -346,11 +404,11 @@ function metadataForWorktree(repository, worktree) {
|
|
|
346
404
|
function currentWorktree(repository, cwd = process.cwd()) {
|
|
347
405
|
const resolvedCwd = canonical(cwd)
|
|
348
406
|
return repository.worktrees
|
|
349
|
-
.filter((worktree) => isInside(canonical(worktree.path), resolvedCwd))
|
|
407
|
+
.filter((worktree) => pathExists(worktree.path) && isInside(canonical(worktree.path), resolvedCwd))
|
|
350
408
|
.sort((left, right) => right.path.length - left.path.length)[0] ?? null
|
|
351
409
|
}
|
|
352
410
|
|
|
353
|
-
function
|
|
411
|
+
function findWorktree(repository, selector, options = {}) {
|
|
354
412
|
if (!selector) {
|
|
355
413
|
const current = currentWorktree(repository)
|
|
356
414
|
if (!current) throw new CliError("The current directory is not inside a registered worktree")
|
|
@@ -371,10 +429,13 @@ function resolveWorktree(repository, selector, options = {}) {
|
|
|
371
429
|
if (branchMatch) return branchMatch
|
|
372
430
|
|
|
373
431
|
const candidatePath = resolve(options.cwd ?? process.cwd(), selector)
|
|
374
|
-
|
|
375
|
-
|
|
432
|
+
return repository.worktrees.find((worktree) => resolve(worktree.path) === candidatePath) ?? null
|
|
433
|
+
}
|
|
376
434
|
|
|
377
|
-
|
|
435
|
+
function resolveWorktree(repository, selector, options = {}) {
|
|
436
|
+
const worktree = findWorktree(repository, selector, options)
|
|
437
|
+
if (!worktree) throw new CliError(`No worktree matches '${selector}'`)
|
|
438
|
+
return worktree
|
|
378
439
|
}
|
|
379
440
|
|
|
380
441
|
function generateId(repository, config) {
|
|
@@ -447,7 +508,7 @@ function hookPaths(configDocument, worktreePath) {
|
|
|
447
508
|
|
|
448
509
|
function trustFingerprint(repository, configDocument, worktreePath) {
|
|
449
510
|
const hooks = hookPaths(configDocument, worktreePath)
|
|
450
|
-
if (hooks.length === 0) return null
|
|
511
|
+
if (hooks.length === 0 && configDocument.value.ports.length === 0 && Object.keys(configDocument.value.env).length === 0) return null
|
|
451
512
|
const hash = createHash("sha256")
|
|
452
513
|
hash.update(repository.primaryPath)
|
|
453
514
|
hash.update("\0")
|
|
@@ -517,20 +578,24 @@ async function ensureTrusted(repository, configDocument, worktreePath) {
|
|
|
517
578
|
if (!fingerprint || isTrusted(repository, fingerprint)) return
|
|
518
579
|
|
|
519
580
|
const hooks = hookPaths(configDocument, worktreePath)
|
|
520
|
-
console.error("This repository wants to
|
|
581
|
+
console.error("This repository wants to configure your development environment:")
|
|
521
582
|
for (const hook of hooks) console.error(` ${hook.name}: ${hook.configuredPath}`)
|
|
583
|
+
for (const name of configDocument.value.ports) console.error(` port: ${name}`)
|
|
584
|
+
for (const name of Object.keys(configDocument.value.env)) console.error(` env: ${name}`)
|
|
522
585
|
const allowed = await ask("Allow and remember? [y/N] ")
|
|
523
|
-
if (!allowed) throw new CliError("Project
|
|
586
|
+
if (!allowed) throw new CliError("Project configuration is not trusted. Run 'gwt trust' to approve it")
|
|
524
587
|
saveTrust(repository, fingerprint)
|
|
525
588
|
}
|
|
526
589
|
|
|
527
|
-
function hookContext(repository, worktree, metadata) {
|
|
590
|
+
function hookContext(repository, config, worktree, metadata) {
|
|
591
|
+
const ports = metadata?.ports ?? {}
|
|
528
592
|
return {
|
|
529
593
|
id: metadata?.id ?? "",
|
|
530
594
|
path: canonical(worktree.path),
|
|
531
595
|
primaryPath: repository.primaryPath,
|
|
532
596
|
branch: worktree.branch ?? "",
|
|
533
|
-
ports
|
|
597
|
+
ports,
|
|
598
|
+
environment: resolveConfiguredEnv(config, ports),
|
|
534
599
|
}
|
|
535
600
|
}
|
|
536
601
|
|
|
@@ -538,7 +603,7 @@ function runHook(name, repository, configDocument, worktree, metadata) {
|
|
|
538
603
|
const configuredPath = configDocument.value[name]
|
|
539
604
|
if (!configuredPath) return
|
|
540
605
|
const hook = hookPaths(configDocument, canonical(worktree.path)).find((item) => item.name === name)
|
|
541
|
-
const context = hookContext(repository, worktree, metadata)
|
|
606
|
+
const context = hookContext(repository, configDocument.value, worktree, metadata)
|
|
542
607
|
const env = {
|
|
543
608
|
...process.env,
|
|
544
609
|
GWT_ID: context.id,
|
|
@@ -546,6 +611,7 @@ function runHook(name, repository, configDocument, worktree, metadata) {
|
|
|
546
611
|
GWT_PRIMARY_PATH: context.primaryPath,
|
|
547
612
|
GWT_BRANCH: context.branch,
|
|
548
613
|
...Object.fromEntries(Object.entries(context.ports).map(([key, value]) => [key, String(value)])),
|
|
614
|
+
...context.environment,
|
|
549
615
|
}
|
|
550
616
|
console.log(`Running ${name}...`)
|
|
551
617
|
const result = run(hook.path, [], {
|
|
@@ -580,6 +646,33 @@ function updateMetadata(repository, metadata, update) {
|
|
|
580
646
|
return next
|
|
581
647
|
}
|
|
582
648
|
|
|
649
|
+
function startBackgroundHook(repository, metadata) {
|
|
650
|
+
const path = setupLogPath(repository, metadata.id)
|
|
651
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
652
|
+
|
|
653
|
+
const started = updateMetadata(repository, metadata, {
|
|
654
|
+
setup: "running",
|
|
655
|
+
setupError: undefined,
|
|
656
|
+
job: { logPath: path, startedAt: new Date().toISOString() },
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
const descriptor = openSync(path, "w")
|
|
660
|
+
const environment = { ...process.env }
|
|
661
|
+
delete environment.GWT_CD_FILE
|
|
662
|
+
try {
|
|
663
|
+
const child = spawn(process.execPath, [import.meta.filename, "__run-hook", "postCreate", started.id], {
|
|
664
|
+
cwd: repository.primaryPath,
|
|
665
|
+
detached: true,
|
|
666
|
+
env: environment,
|
|
667
|
+
stdio: ["ignore", descriptor, descriptor],
|
|
668
|
+
})
|
|
669
|
+
child.unref()
|
|
670
|
+
} finally {
|
|
671
|
+
closeSync(descriptor)
|
|
672
|
+
}
|
|
673
|
+
return started
|
|
674
|
+
}
|
|
675
|
+
|
|
583
676
|
async function setupWorktree(repository, configDocument, worktree, options = {}) {
|
|
584
677
|
if (resolve(worktree.path) === resolve(repository.primaryPath)) throw new CliError("The primary worktree does not need setup")
|
|
585
678
|
const targetPath = canonical(worktree.path)
|
|
@@ -597,6 +690,7 @@ async function setupWorktree(repository, configDocument, worktree, options = {})
|
|
|
597
690
|
createdAt: new Date().toISOString(),
|
|
598
691
|
updatedAt: new Date().toISOString(),
|
|
599
692
|
}
|
|
693
|
+
if (options.scratchBranch) metadata.scratchBranch = options.scratchBranch
|
|
600
694
|
writeJson(metadataPath(repository, id), metadata)
|
|
601
695
|
}
|
|
602
696
|
|
|
@@ -607,11 +701,12 @@ async function setupWorktree(repository, configDocument, worktree, options = {})
|
|
|
607
701
|
return metadata
|
|
608
702
|
}
|
|
609
703
|
await ensureTrusted(repository, configDocument, targetPath)
|
|
704
|
+
if (options.background && configDocument.value.postCreate) return startBackgroundHook(repository, metadata)
|
|
610
705
|
runHook("postCreate", repository, configDocument, worktree, metadata)
|
|
611
|
-
metadata = updateMetadata(repository, metadata, { setup: "complete" })
|
|
706
|
+
metadata = updateMetadata(repository, metadata, { setup: "complete", setupError: undefined, job: undefined })
|
|
612
707
|
return metadata
|
|
613
708
|
} catch (error) {
|
|
614
|
-
updateMetadata(repository, metadata, { setup: "failed", setupError: error.message })
|
|
709
|
+
updateMetadata(repository, metadata, { setup: "failed", setupError: error.message, job: undefined })
|
|
615
710
|
throw error
|
|
616
711
|
}
|
|
617
712
|
}
|
|
@@ -636,11 +731,73 @@ function ensureLocalExclude(repository, directory) {
|
|
|
636
731
|
writeFileSync(infoExclude, `${current}${separator}${pattern}\n`)
|
|
637
732
|
}
|
|
638
733
|
|
|
639
|
-
function
|
|
734
|
+
function isBranchName(branch, cwd) {
|
|
640
735
|
const result = git(["check-ref-format", "--branch", branch], cwd, { allowFailure: true })
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
736
|
+
return result.status === 0 && result.stdout.trim() === branch
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function validateBranchName(branch, cwd) {
|
|
740
|
+
if (!isBranchName(branch, cwd)) throw new CliError(`Invalid branch name: ${branch}`)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function branchState(repository, branch) {
|
|
744
|
+
const remoteNames = git(["remote"], repository.primaryPath).stdout.split("\n").filter(Boolean)
|
|
745
|
+
const localRef = `refs/heads/${branch}`
|
|
746
|
+
const remoteRefs = new Map(remoteNames.map((name) => [`refs/remotes/${name}/${branch}`, name]))
|
|
747
|
+
const raw = git([
|
|
748
|
+
"for-each-ref",
|
|
749
|
+
"--format=%(refname)%09%(worktreepath)",
|
|
750
|
+
localRef,
|
|
751
|
+
...remoteRefs.keys(),
|
|
752
|
+
], repository.primaryPath).stdout
|
|
753
|
+
|
|
754
|
+
let local = null
|
|
755
|
+
const remotes = []
|
|
756
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
757
|
+
const [refname, worktreePath = ""] = line.split("\t")
|
|
758
|
+
if (refname === localRef) local = { branch, worktreePath: worktreePath || null }
|
|
759
|
+
else if (remoteRefs.has(refname)) remotes.push(remoteRefs.get(refname))
|
|
760
|
+
}
|
|
761
|
+
return { local, remotes: [...new Set(remotes)].sort() }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function localBranch(repository, branch) {
|
|
765
|
+
return branchState(repository, branch).local
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function resolveBranchTarget(repository, branch, explicitBase) {
|
|
769
|
+
validateBranchName(branch, repository.primaryPath)
|
|
770
|
+
const { local, remotes } = branchState(repository, branch)
|
|
771
|
+
|
|
772
|
+
if (local) {
|
|
773
|
+
if (local.worktreePath) {
|
|
774
|
+
throw new CliError(`Branch '${branch}' is already checked out at ${local.worktreePath}\nSwitch to it with: gwt switch ${branch}`)
|
|
775
|
+
}
|
|
776
|
+
if (explicitBase) {
|
|
777
|
+
throw new CliError(`Branch '${branch}' already exists, so --base would be ignored\nDrop --base to create a worktree for the existing branch`)
|
|
778
|
+
}
|
|
779
|
+
return { origin: "existing", branch }
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (explicitBase) return { origin: "new", branch }
|
|
783
|
+
|
|
784
|
+
if (remotes.length === 1) return { origin: "remote", branch, remoteRef: `${remotes[0]}/${branch}` }
|
|
785
|
+
if (remotes.length > 1) {
|
|
786
|
+
throw new CliError(`Branch '${branch}' exists on several remotes: ${remotes.join(", ")}\nChoose one with: gwt new ${branch} --base ${remotes[0]}/${branch}`)
|
|
787
|
+
}
|
|
788
|
+
return { origin: "new", branch }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function worktreeAddArguments(target, resolution, base) {
|
|
792
|
+
if (resolution.origin === "existing") return ["worktree", "add", target, resolution.branch]
|
|
793
|
+
if (resolution.origin === "remote") return ["worktree", "add", "--track", "-b", resolution.branch, target, resolution.remoteRef]
|
|
794
|
+
return ["worktree", "add", "-b", resolution.branch, target, base]
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function branchSummary(resolution) {
|
|
798
|
+
if (resolution.origin === "existing") return `Branch: ${resolution.branch} (existing)`
|
|
799
|
+
if (resolution.origin === "remote") return `Branch: ${resolution.branch} (new, tracking ${resolution.remoteRef})`
|
|
800
|
+
return `Branch: ${resolution.branch}`
|
|
644
801
|
}
|
|
645
802
|
|
|
646
803
|
function writeCdDirective(path) {
|
|
@@ -668,25 +825,35 @@ function parseOptions(args, definitions = {}) {
|
|
|
668
825
|
return { options, positionals }
|
|
669
826
|
}
|
|
670
827
|
|
|
671
|
-
async function
|
|
672
|
-
const { options, positionals } = parseOptions(args, { "--base": "value", "--no-hooks": "boolean" })
|
|
673
|
-
if (positionals.length > 1) throw new CliError("Usage: gwt new [branch] [--base <ref>] [--no-hooks]")
|
|
674
|
-
const repository = discoverRepository()
|
|
675
|
-
const configDocument = loadConfig(repository)
|
|
828
|
+
async function createWorktree(repository, configDocument, options = {}) {
|
|
676
829
|
ensureCopySources(repository, configDocument.value)
|
|
677
830
|
const id = generateId(repository, configDocument.value)
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
831
|
+
const scratch = !options.branch
|
|
832
|
+
const branch = options.branch ?? `scratch/${id}`
|
|
833
|
+
|
|
834
|
+
let resolution
|
|
835
|
+
if (scratch) {
|
|
836
|
+
validateBranchName(branch, repository.primaryPath)
|
|
837
|
+
if (localBranch(repository, branch)) throw new CliError(`Branch already exists: ${branch}`)
|
|
838
|
+
resolution = { origin: "new", branch }
|
|
839
|
+
} else {
|
|
840
|
+
resolution = resolveBranchTarget(repository, branch, options.base)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
let base = null
|
|
844
|
+
if (resolution.origin === "new") {
|
|
845
|
+
const requestedBase = options.base ?? configDocument.value.base
|
|
846
|
+
base = requestedBase
|
|
847
|
+
? gitOutput(["rev-parse", "--verify", `${requestedBase}^{commit}`], repository.primaryPath)
|
|
848
|
+
: gitOutput(["rev-parse", "HEAD"], repository.primaryPath)
|
|
849
|
+
}
|
|
850
|
+
|
|
684
851
|
const target = join(resolveWorktreeDirectory(repository, configDocument.value), id)
|
|
685
852
|
if (configDocument.value.worktreeDirectory) {
|
|
686
853
|
ensureLocalExclude(repository, configDocument.value.worktreeDirectory)
|
|
687
854
|
}
|
|
688
855
|
|
|
689
|
-
git(
|
|
856
|
+
git(worktreeAddArguments(target, resolution, base), repository.primaryPath, { stdio: "inherit" })
|
|
690
857
|
const targetPath = canonical(target)
|
|
691
858
|
const refreshed = discoverRepository(repository.primaryPath)
|
|
692
859
|
const worktree = refreshed.worktrees.find((item) => resolve(item.path) === targetPath)
|
|
@@ -694,12 +861,11 @@ async function commandNew(args) {
|
|
|
694
861
|
try {
|
|
695
862
|
const metadata = await setupWorktree(refreshed, configDocument, worktree, {
|
|
696
863
|
id,
|
|
697
|
-
noHooks: options
|
|
864
|
+
noHooks: options.noHooks,
|
|
865
|
+
background: options.background,
|
|
866
|
+
scratchBranch: scratch ? branch : undefined,
|
|
698
867
|
})
|
|
699
|
-
|
|
700
|
-
console.log(`Branch: ${branch}`)
|
|
701
|
-
for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
|
|
702
|
-
writeCdDirective(targetPath)
|
|
868
|
+
return { metadata, targetPath, resolution }
|
|
703
869
|
} catch (error) {
|
|
704
870
|
console.error(`Setup failed; worktree retained at ${targetPath}`)
|
|
705
871
|
console.error(`Retry: gwt setup ${id}`)
|
|
@@ -708,6 +874,35 @@ async function commandNew(args) {
|
|
|
708
874
|
}
|
|
709
875
|
}
|
|
710
876
|
|
|
877
|
+
function reportWorktree({ metadata, targetPath, resolution }) {
|
|
878
|
+
console.log(`Worktree ${metadata.id} is ready at ${targetPath}`)
|
|
879
|
+
console.log(branchSummary(resolution))
|
|
880
|
+
for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
|
|
881
|
+
if (metadata.setup === "running") {
|
|
882
|
+
console.log(`Setup is running in the background; log: ${metadata.job.logPath}`)
|
|
883
|
+
}
|
|
884
|
+
writeCdDirective(targetPath)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function commandNew(args) {
|
|
888
|
+
const { options, positionals } = parseOptions(args, {
|
|
889
|
+
"--base": "value",
|
|
890
|
+
"--no-hooks": "boolean",
|
|
891
|
+
"--background": "boolean",
|
|
892
|
+
})
|
|
893
|
+
if (positionals.length > 1) throw new CliError("Usage: gwt new [branch] [--base <ref>] [--no-hooks] [--background]")
|
|
894
|
+
if (options.background && options["no-hooks"]) throw new CliError("--background and --no-hooks cannot be combined")
|
|
895
|
+
const repository = discoverRepository()
|
|
896
|
+
const configDocument = loadConfig(repository)
|
|
897
|
+
|
|
898
|
+
reportWorktree(await createWorktree(repository, configDocument, {
|
|
899
|
+
branch: positionals[0],
|
|
900
|
+
base: options.base,
|
|
901
|
+
noHooks: options["no-hooks"],
|
|
902
|
+
background: options.background,
|
|
903
|
+
}))
|
|
904
|
+
}
|
|
905
|
+
|
|
711
906
|
function linkedWorktreeRows(repository, metadata = loadMetadata(repository)) {
|
|
712
907
|
const current = currentWorktree(repository)
|
|
713
908
|
return repository.worktrees.map((worktree, index) => {
|
|
@@ -717,7 +912,7 @@ function linkedWorktreeRows(repository, metadata = loadMetadata(repository)) {
|
|
|
717
912
|
current: current && resolve(current.path) === resolve(worktree.path),
|
|
718
913
|
id: item?.id ?? (index === 0 ? "primary" : "-"),
|
|
719
914
|
branch: worktree.branch ?? "(detached)",
|
|
720
|
-
setup: item
|
|
915
|
+
setup: jobStatus(item) ?? (index === 0 ? "-" : "unmanaged"),
|
|
721
916
|
path: worktree.path,
|
|
722
917
|
}
|
|
723
918
|
})
|
|
@@ -857,10 +1052,40 @@ async function chooseWorktree(repository) {
|
|
|
857
1052
|
})
|
|
858
1053
|
}
|
|
859
1054
|
|
|
1055
|
+
function looksLikePath(selector) {
|
|
1056
|
+
return isAbsolute(selector) || selector.startsWith(".")
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function createMissingWorktree(repository, selector, force) {
|
|
1060
|
+
const unmatched = new CliError(`No worktree matches '${selector}'`)
|
|
1061
|
+
if (looksLikePath(selector) || !isBranchName(selector, repository.primaryPath)) throw unmatched
|
|
1062
|
+
|
|
1063
|
+
const resolution = resolveBranchTarget(repository, selector, undefined)
|
|
1064
|
+
if (!force) {
|
|
1065
|
+
const question = resolution.origin === "new"
|
|
1066
|
+
? `Branch '${selector}' does not exist. Create it and a worktree? [y/N] `
|
|
1067
|
+
: `No worktree for branch '${selector}'. Create one? [y/N] `
|
|
1068
|
+
if (!(await ask(question))) {
|
|
1069
|
+
throw new CliError(`No worktree matches '${selector}'\nCreate one with: gwt new ${selector}`)
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return createWorktree(repository, loadConfig(repository), { branch: selector })
|
|
1074
|
+
}
|
|
1075
|
+
|
|
860
1076
|
async function commandSwitch(args) {
|
|
861
|
-
|
|
1077
|
+
const { options, positionals } = parseOptions(args, { "--create": "boolean" })
|
|
1078
|
+
if (positionals.length > 1) throw new CliError("Usage: gwt switch [primary|id|branch|path] [--create]")
|
|
862
1079
|
const repository = discoverRepository()
|
|
863
|
-
const
|
|
1080
|
+
const selector = positionals[0]
|
|
1081
|
+
if (!selector && options.create) throw new CliError("--create requires a branch name")
|
|
1082
|
+
|
|
1083
|
+
const worktree = selector ? findWorktree(repository, selector) : await chooseWorktree(repository)
|
|
1084
|
+
if (!worktree) {
|
|
1085
|
+
reportWorktree(await createMissingWorktree(repository, selector, options.create))
|
|
1086
|
+
return
|
|
1087
|
+
}
|
|
1088
|
+
|
|
864
1089
|
writeCdDirective(canonical(worktree.path))
|
|
865
1090
|
console.log(canonical(worktree.path))
|
|
866
1091
|
}
|
|
@@ -968,23 +1193,45 @@ function commandInfo(args) {
|
|
|
968
1193
|
const worktree = resolveWorktree(repository, args[0])
|
|
969
1194
|
const metadata = metadataForWorktree(repository, worktree)
|
|
970
1195
|
console.log(`ID: ${metadata?.id ?? (resolve(worktree.path) === resolve(repository.primaryPath) ? "primary" : "unmanaged")}`)
|
|
971
|
-
console.log(`Path: ${canonical(worktree.path)}`)
|
|
1196
|
+
console.log(`Path: ${pathExists(worktree.path) ? canonical(worktree.path) : resolve(worktree.path)}`)
|
|
972
1197
|
console.log(`Branch: ${worktree.branch ?? "(detached)"}`)
|
|
973
1198
|
console.log(`HEAD: ${worktree.head}`)
|
|
974
|
-
console.log(`Setup: ${metadata
|
|
1199
|
+
console.log(`Setup: ${jobStatus(metadata) ?? "unmanaged"}`)
|
|
975
1200
|
for (const [name, port] of Object.entries(metadata?.ports ?? {})) console.log(`${name}: ${port}`)
|
|
1201
|
+
if (metadata) {
|
|
1202
|
+
try {
|
|
1203
|
+
const environment = worktreeEnvironment(repository, worktree)
|
|
1204
|
+
for (const [name, value] of Object.entries(environment.values)) {
|
|
1205
|
+
if (!Object.hasOwn(metadata.ports ?? {}, name)) console.log(`${name}: ${value}`)
|
|
1206
|
+
}
|
|
1207
|
+
if (environment.reason) console.log(`Environment: ${environment.reason}`)
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
console.log(`Environment: ${error.message}`)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
976
1212
|
if (metadata?.setupError) console.log(`Setup error: ${metadata.setupError}`)
|
|
977
1213
|
}
|
|
978
1214
|
|
|
979
1215
|
async function commandSetup(args) {
|
|
980
|
-
const { options, positionals } = parseOptions(args, { "--no-hooks": "boolean" })
|
|
981
|
-
if (positionals.length > 1) throw new CliError("Usage: gwt setup [id|branch|path] [--no-hooks]")
|
|
1216
|
+
const { options, positionals } = parseOptions(args, { "--no-hooks": "boolean", "--background": "boolean" })
|
|
1217
|
+
if (positionals.length > 1) throw new CliError("Usage: gwt setup [id|branch|path] [--no-hooks] [--background]")
|
|
1218
|
+
if (options.background && options["no-hooks"]) throw new CliError("--background and --no-hooks cannot be combined")
|
|
982
1219
|
const repository = discoverRepository()
|
|
983
1220
|
const configDocument = loadConfig(repository)
|
|
984
1221
|
const worktree = resolveWorktree(repository, positionals[0])
|
|
985
|
-
|
|
1222
|
+
|
|
1223
|
+
const existing = metadataForWorktree(repository, worktree)
|
|
1224
|
+
if (jobStatus(existing) === "running") {
|
|
1225
|
+
throw new CliError(`Setup is already running for ${existing.id} (pid ${existing.job?.pid ?? "starting"})`)
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const metadata = await setupWorktree(repository, configDocument, worktree, {
|
|
1229
|
+
noHooks: options["no-hooks"],
|
|
1230
|
+
background: options.background,
|
|
1231
|
+
})
|
|
986
1232
|
console.log(`Setup ${metadata.setup}: ${metadata.id}`)
|
|
987
1233
|
for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
|
|
1234
|
+
if (metadata.setup === "running") console.log(`Log: ${metadata.job.logPath}`)
|
|
988
1235
|
}
|
|
989
1236
|
|
|
990
1237
|
async function confirmDiscard(worktree) {
|
|
@@ -992,6 +1239,31 @@ async function confirmDiscard(worktree) {
|
|
|
992
1239
|
return ask("Type yes to continue [y/N] ")
|
|
993
1240
|
}
|
|
994
1241
|
|
|
1242
|
+
function scratchBranchIsContained(repository, metadata, worktree) {
|
|
1243
|
+
const scratch = metadata?.scratchBranch
|
|
1244
|
+
if (!scratch || scratch === worktree.branch || !worktree.head) return false
|
|
1245
|
+
return git(["merge-base", "--is-ancestor", scratch, worktree.head], repository.primaryPath, { allowFailure: true }).status === 0
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
async function removeScratchBranch(repository, metadata, worktree, options, contained) {
|
|
1249
|
+
const scratch = metadata?.scratchBranch
|
|
1250
|
+
if (!scratch || scratch === worktree.branch || options["keep-branch"]) return null
|
|
1251
|
+
if (!localBranch(repository, scratch)) return null
|
|
1252
|
+
|
|
1253
|
+
const deleteArgs = ["branch", options.discard || contained ? "-D" : "-d", "--", scratch]
|
|
1254
|
+
if (git(deleteArgs, repository.primaryPath, { allowFailure: true }).status === 0) {
|
|
1255
|
+
return `Deleted scratch branch: ${scratch}`
|
|
1256
|
+
}
|
|
1257
|
+
if (!options.discard && !options.yes) {
|
|
1258
|
+
console.log(`Scratch branch '${scratch}' could not be deleted safely.`)
|
|
1259
|
+
if (await ask("Force-delete the scratch branch? [y/N] ")) {
|
|
1260
|
+
git(["branch", "-D", "--", scratch], repository.primaryPath)
|
|
1261
|
+
return `Deleted scratch branch: ${scratch}`
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return `Kept scratch branch: ${scratch}\nDelete later: git branch -D -- ${scratch}`
|
|
1265
|
+
}
|
|
1266
|
+
|
|
995
1267
|
async function commandRemove(args) {
|
|
996
1268
|
const { options, positionals } = parseOptions(args, {
|
|
997
1269
|
"--keep-branch": "boolean",
|
|
@@ -1006,6 +1278,10 @@ async function commandRemove(args) {
|
|
|
1006
1278
|
if (resolve(worktree.path) === resolve(repository.primaryPath)) throw new CliError("The primary worktree cannot be removed")
|
|
1007
1279
|
const targetPath = canonical(worktree.path)
|
|
1008
1280
|
const metadata = metadataForWorktree(repository, worktree)
|
|
1281
|
+
if (jobStatus(metadata) === "running" && !options.discard) {
|
|
1282
|
+
throw new CliError(`Setup is still running for ${metadata.id} (pid ${metadata.job?.pid ?? "starting"})\nWait for it to finish, or force removal with --discard`)
|
|
1283
|
+
}
|
|
1284
|
+
const scratchContained = scratchBranchIsContained(repository, metadata, worktree)
|
|
1009
1285
|
const dirty = git(["status", "--porcelain"], worktree.path).stdout.length > 0
|
|
1010
1286
|
if (dirty && !options.discard) throw new CliError("Worktree has uncommitted changes; commit them or use --discard")
|
|
1011
1287
|
if (options.discard && !options.yes && !(await confirmDiscard(worktree))) throw new CliError("Removal cancelled")
|
|
@@ -1013,8 +1289,10 @@ async function commandRemove(args) {
|
|
|
1013
1289
|
|
|
1014
1290
|
if (!options["no-hooks"]) {
|
|
1015
1291
|
const configDocument = loadConfig(repository)
|
|
1016
|
-
|
|
1017
|
-
|
|
1292
|
+
if (configDocument.value.preRemove) {
|
|
1293
|
+
await ensureTrusted(repository, configDocument, targetPath)
|
|
1294
|
+
runHook("preRemove", repository, configDocument, worktree, metadata)
|
|
1295
|
+
}
|
|
1018
1296
|
}
|
|
1019
1297
|
|
|
1020
1298
|
const removeArgs = ["worktree", "remove"]
|
|
@@ -1023,6 +1301,10 @@ async function commandRemove(args) {
|
|
|
1023
1301
|
console.log(`Removing worktree ${metadata?.id ?? targetPath}...`)
|
|
1024
1302
|
git(removeArgs, repository.primaryPath)
|
|
1025
1303
|
if (metadata?.metadataPath && existsSync(metadata.metadataPath)) unlinkSync(metadata.metadataPath)
|
|
1304
|
+
if (metadata?.id) {
|
|
1305
|
+
const log = setupLogPath(repository, metadata.id)
|
|
1306
|
+
if (existsSync(log)) unlinkSync(log)
|
|
1307
|
+
}
|
|
1026
1308
|
|
|
1027
1309
|
let branchMessage = "No branch to delete"
|
|
1028
1310
|
if (worktree.branch && !options["keep-branch"]) {
|
|
@@ -1046,9 +1328,87 @@ async function commandRemove(args) {
|
|
|
1046
1328
|
|
|
1047
1329
|
console.log(`Removed worktree: ${metadata?.id ?? targetPath}`)
|
|
1048
1330
|
console.log(branchMessage)
|
|
1331
|
+
const scratchMessage = await removeScratchBranch(repository, metadata, worktree, options, scratchContained)
|
|
1332
|
+
if (scratchMessage) console.log(scratchMessage)
|
|
1049
1333
|
if (wasCurrent) writeCdDirective(repository.primaryPath)
|
|
1050
1334
|
}
|
|
1051
1335
|
|
|
1336
|
+
function branchContainedElsewhere(repository, branch) {
|
|
1337
|
+
const result = git([
|
|
1338
|
+
"for-each-ref",
|
|
1339
|
+
"--format=%(refname:short)",
|
|
1340
|
+
"--contains",
|
|
1341
|
+
`refs/heads/${branch}`,
|
|
1342
|
+
"refs/heads",
|
|
1343
|
+
"refs/remotes",
|
|
1344
|
+
], repository.primaryPath, { allowFailure: true })
|
|
1345
|
+
if (result.status !== 0) return []
|
|
1346
|
+
return result.stdout.split("\n").filter(Boolean).filter((reference) => reference !== branch)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function pruneCandidates(repository) {
|
|
1350
|
+
const metadata = loadMetadata(repository)
|
|
1351
|
+
const prunable = repository.worktrees.filter((worktree) => worktree.prunable)
|
|
1352
|
+
const live = new Set(repository.worktrees
|
|
1353
|
+
.filter((worktree) => !worktree.prunable)
|
|
1354
|
+
.map((worktree) => resolve(worktree.path)))
|
|
1355
|
+
|
|
1356
|
+
const stale = []
|
|
1357
|
+
const running = []
|
|
1358
|
+
for (const item of metadata) {
|
|
1359
|
+
if (live.has(resolve(item.path))) continue
|
|
1360
|
+
if (jobStatus(item) === "running") running.push(item)
|
|
1361
|
+
else stale.push(item)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const branches = stale
|
|
1365
|
+
.filter((item) => item.scratchBranch && localBranch(repository, item.scratchBranch))
|
|
1366
|
+
.map((item) => ({ branch: item.scratchBranch, containedIn: branchContainedElsewhere(repository, item.scratchBranch) }))
|
|
1367
|
+
|
|
1368
|
+
const retained = new Set(metadata.filter((item) => !stale.includes(item)).map((item) => item.id))
|
|
1369
|
+
const directory = join(repository.commonDir, "gwt", "logs")
|
|
1370
|
+
const logs = existsSync(directory)
|
|
1371
|
+
? readdirSync(directory)
|
|
1372
|
+
.filter((name) => name.endsWith(".log") && !retained.has(basename(name, ".log")))
|
|
1373
|
+
.map((name) => join(directory, name))
|
|
1374
|
+
: []
|
|
1375
|
+
|
|
1376
|
+
return { prunable, stale, running, branches, logs }
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
async function commandPrune(args) {
|
|
1380
|
+
const { options, positionals } = parseOptions(args, { "--dry-run": "boolean", "--yes": "boolean" })
|
|
1381
|
+
if (positionals.length > 0) throw new CliError("Usage: gwt prune [--dry-run] [--yes]")
|
|
1382
|
+
const repository = discoverRepository()
|
|
1383
|
+
const { prunable, stale, running, branches, logs } = pruneCandidates(repository)
|
|
1384
|
+
const removable = branches.filter((item) => item.containedIn.length > 0)
|
|
1385
|
+
const retained = branches.filter((item) => item.containedIn.length === 0)
|
|
1386
|
+
const total = prunable.length + stale.length + removable.length + logs.length
|
|
1387
|
+
|
|
1388
|
+
for (const item of retained) {
|
|
1389
|
+
console.log(`Keeping scratch branch ${item.branch}: no other branch contains its commits`)
|
|
1390
|
+
}
|
|
1391
|
+
for (const item of running) console.log(`Keeping metadata ${item.id}: setup is still running`)
|
|
1392
|
+
if (total === 0) {
|
|
1393
|
+
console.log("Nothing to prune")
|
|
1394
|
+
return
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
for (const worktree of prunable) console.log(`Worktree registration: ${worktree.path}`)
|
|
1398
|
+
for (const item of stale) console.log(`Metadata: ${item.id}`)
|
|
1399
|
+
for (const item of removable) console.log(`Scratch branch: ${item.branch} (contained in ${item.containedIn[0]})`)
|
|
1400
|
+
for (const path of logs) console.log(`Setup log: ${basename(path)}`)
|
|
1401
|
+
|
|
1402
|
+
if (options["dry-run"]) return
|
|
1403
|
+
if (!options.yes && !(await ask("Prune? [y/N] "))) throw new CliError("Prune cancelled")
|
|
1404
|
+
|
|
1405
|
+
if (prunable.length > 0) git(["worktree", "prune"], repository.primaryPath)
|
|
1406
|
+
for (const item of stale) if (existsSync(item.metadataPath)) unlinkSync(item.metadataPath)
|
|
1407
|
+
for (const item of removable) git(["branch", "-D", "--", item.branch], repository.primaryPath)
|
|
1408
|
+
for (const path of logs) if (existsSync(path)) unlinkSync(path)
|
|
1409
|
+
console.log(`Pruned ${total} item${total === 1 ? "" : "s"}`)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1052
1412
|
function commandTrust(args) {
|
|
1053
1413
|
const { options, positionals } = parseOptions(args, { "--revoke": "boolean" })
|
|
1054
1414
|
if (positionals.length > 0) throw new CliError("Usage: gwt trust [--revoke]")
|
|
@@ -1062,21 +1422,21 @@ function commandTrust(args) {
|
|
|
1062
1422
|
const configDocument = loadConfig(repository)
|
|
1063
1423
|
if (!configDocument.requiresTrust) {
|
|
1064
1424
|
console.log(configDocument.source === "user"
|
|
1065
|
-
? "User
|
|
1425
|
+
? "User configuration is trusted automatically"
|
|
1066
1426
|
: "This repository has no project config to approve")
|
|
1067
1427
|
return
|
|
1068
1428
|
}
|
|
1069
1429
|
const fingerprint = trustFingerprint(repository, configDocument, canonical(current.path))
|
|
1070
1430
|
if (!fingerprint) {
|
|
1071
|
-
console.log("This repository has no project
|
|
1431
|
+
console.log("This repository has no project configuration that requires approval")
|
|
1072
1432
|
return
|
|
1073
1433
|
}
|
|
1074
1434
|
saveTrust(repository, fingerprint)
|
|
1075
|
-
console.log(`Trusted project
|
|
1435
|
+
console.log(`Trusted project configuration for ${repository.primaryPath}`)
|
|
1076
1436
|
}
|
|
1077
1437
|
|
|
1078
1438
|
function configScaffold() {
|
|
1079
|
-
return { copyFiles: [], ports: [] }
|
|
1439
|
+
return { copyFiles: [], ports: [], env: {} }
|
|
1080
1440
|
}
|
|
1081
1441
|
|
|
1082
1442
|
function commandConfigCreate(args) {
|
|
@@ -1141,6 +1501,104 @@ function commandConfig(args) {
|
|
|
1141
1501
|
throw new CliError("Usage: gwt config <create [--project]|show>")
|
|
1142
1502
|
}
|
|
1143
1503
|
|
|
1504
|
+
function worktreeEnvironment(repository, worktree) {
|
|
1505
|
+
if (resolve(worktree.path) === resolve(repository.primaryPath)) return { applied: false, reason: null, values: {} }
|
|
1506
|
+
|
|
1507
|
+
const metadata = metadataForWorktree(repository, worktree)
|
|
1508
|
+
if (!metadata) return { applied: false, reason: null, values: {} }
|
|
1509
|
+
|
|
1510
|
+
const configDocument = loadConfig(repository)
|
|
1511
|
+
if (configDocument.value.ports.length === 0 && Object.keys(configDocument.value.env).length === 0) {
|
|
1512
|
+
return { applied: true, reason: null, values: {} }
|
|
1513
|
+
}
|
|
1514
|
+
if (configDocument.requiresTrust) {
|
|
1515
|
+
const fingerprint = trustFingerprint(repository, configDocument, canonical(worktree.path))
|
|
1516
|
+
if (!isTrusted(repository, fingerprint)) {
|
|
1517
|
+
return { applied: false, reason: "not applied until 'gwt trust' approves this repository's configuration", values: {} }
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const ports = Object.fromEntries(configDocument.value.ports.map((name) => {
|
|
1522
|
+
if (!Object.hasOwn(metadata.ports ?? {}, name)) {
|
|
1523
|
+
throw new CliError(`This worktree has no assigned ${name}; recreate it after changing ports`)
|
|
1524
|
+
}
|
|
1525
|
+
return [name, String(metadata.ports[name])]
|
|
1526
|
+
}))
|
|
1527
|
+
return { applied: true, reason: null, values: { ...ports, ...resolveConfiguredEnv(configDocument.value, metadata.ports ?? {}) } }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function configuredShellEnvironment() {
|
|
1531
|
+
const insideRepository = git(["rev-parse", "--is-inside-work-tree"], process.cwd(), { allowFailure: true })
|
|
1532
|
+
if (insideRepository.status !== 0) return {}
|
|
1533
|
+
|
|
1534
|
+
const repository = discoverRepository()
|
|
1535
|
+
const worktree = currentWorktree(repository)
|
|
1536
|
+
if (!worktree) return {}
|
|
1537
|
+
return worktreeEnvironment(repository, worktree).values
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
function readShellEnvironmentState() {
|
|
1541
|
+
const encoded = process.env[SHELL_ENV_STATE]
|
|
1542
|
+
if (!encoded) return { originals: {} }
|
|
1543
|
+
|
|
1544
|
+
try {
|
|
1545
|
+
const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"))
|
|
1546
|
+
if (!parsed || typeof parsed.originals !== "object" || Array.isArray(parsed.originals)) throw new Error()
|
|
1547
|
+
const originals = Object.fromEntries(Object.entries(parsed.originals).map(([name, original]) => {
|
|
1548
|
+
if (!ENV_NAME.test(name) || name.startsWith("GWT_")) throw new Error()
|
|
1549
|
+
if (!original || typeof original !== "object" || typeof original.present !== "boolean") throw new Error()
|
|
1550
|
+
if (original.present && typeof original.value !== "string") throw new Error()
|
|
1551
|
+
return [name, original.present ? { present: true, value: original.value } : { present: false }]
|
|
1552
|
+
}))
|
|
1553
|
+
return { originals }
|
|
1554
|
+
} catch {
|
|
1555
|
+
return { originals: {} }
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function quoteZsh(value) {
|
|
1560
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function shellEnvironmentCommands(environment) {
|
|
1564
|
+
const previous = readShellEnvironmentState()
|
|
1565
|
+
const names = new Set([...Object.keys(previous.originals), ...Object.keys(environment)])
|
|
1566
|
+
const originals = {}
|
|
1567
|
+
const commands = []
|
|
1568
|
+
|
|
1569
|
+
for (const name of names) {
|
|
1570
|
+
const original = previous.originals[name] ?? (Object.hasOwn(process.env, name)
|
|
1571
|
+
? { present: true, value: process.env[name] }
|
|
1572
|
+
: { present: false })
|
|
1573
|
+
|
|
1574
|
+
if (Object.hasOwn(environment, name)) {
|
|
1575
|
+
originals[name] = original
|
|
1576
|
+
commands.push(`export ${name}=${quoteZsh(environment[name])}`)
|
|
1577
|
+
} else if (original.present) {
|
|
1578
|
+
commands.push(`export ${name}=${quoteZsh(original.value)}`)
|
|
1579
|
+
} else {
|
|
1580
|
+
commands.push(`unset ${name}`)
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
if (Object.keys(originals).length === 0) {
|
|
1585
|
+
commands.push(`unset ${SHELL_ENV_STATE}`)
|
|
1586
|
+
} else {
|
|
1587
|
+
const state = Buffer.from(JSON.stringify({ originals })).toString("base64url")
|
|
1588
|
+
commands.push(`export ${SHELL_ENV_STATE}=${quoteZsh(state)}`)
|
|
1589
|
+
}
|
|
1590
|
+
return commands.join("\n")
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function commandShellEnvironment(args) {
|
|
1594
|
+
if (args.length !== 1 || args[0] !== "zsh") throw new CliError("Invalid shell environment request")
|
|
1595
|
+
let environment = {}
|
|
1596
|
+
try {
|
|
1597
|
+
environment = configuredShellEnvironment()
|
|
1598
|
+
} catch {}
|
|
1599
|
+
console.log(shellEnvironmentCommands(environment))
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1144
1602
|
function zshIntegration() {
|
|
1145
1603
|
return `# gwt shell integration for zsh
|
|
1146
1604
|
if command -v gwt >/dev/null 2>&1; then
|
|
@@ -1152,13 +1610,34 @@ if command -v gwt >/dev/null 2>&1; then
|
|
|
1152
1610
|
builtin cd -- "$(<"$cd_file")" || exit_code=$?
|
|
1153
1611
|
fi
|
|
1154
1612
|
rm -f -- "$cd_file"
|
|
1613
|
+
if [[ $exit_code -eq 0 ]]; then
|
|
1614
|
+
_gwt_sync_env
|
|
1615
|
+
fi
|
|
1155
1616
|
return $exit_code
|
|
1156
1617
|
}
|
|
1157
1618
|
|
|
1619
|
+
_gwt_sync_env() {
|
|
1620
|
+
local commands
|
|
1621
|
+
commands="$(command gwt __shell_env zsh 2>/dev/null)" || return 0
|
|
1622
|
+
[[ -n "$commands" ]] && eval "$commands"
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1158
1625
|
_gwt_worktrees() {
|
|
1159
|
-
local -a
|
|
1160
|
-
|
|
1161
|
-
|
|
1626
|
+
local -a lines selectors aliases
|
|
1627
|
+
local line id branch tab=$'\\t'
|
|
1628
|
+
lines=("\${(@f)$(command gwt __complete worktrees 2>/dev/null)}")
|
|
1629
|
+
for line in $lines; do
|
|
1630
|
+
id="\${line%%\${tab}*}"
|
|
1631
|
+
branch="\${line#*\${tab}}"
|
|
1632
|
+
if [[ -n "$branch" ]]; then
|
|
1633
|
+
selectors+=("\${branch}:\${id:-unmanaged}")
|
|
1634
|
+
[[ -n "$id" ]] && aliases+=("$id")
|
|
1635
|
+
elif [[ -n "$id" ]]; then
|
|
1636
|
+
selectors+=("\${id}:detached")
|
|
1637
|
+
fi
|
|
1638
|
+
done
|
|
1639
|
+
(( $#selectors )) && _describe -t worktrees 'worktree' selectors
|
|
1640
|
+
[[ -n "$PREFIX" ]] && (( $#aliases )) && compadd -n -a aliases
|
|
1162
1641
|
}
|
|
1163
1642
|
|
|
1164
1643
|
_gwt_refs() {
|
|
@@ -1173,9 +1652,11 @@ if command -v gwt >/dev/null 2>&1; then
|
|
|
1173
1652
|
'new:Create and set up a worktree'
|
|
1174
1653
|
'setup:Set up an existing worktree'
|
|
1175
1654
|
'list:List worktrees'
|
|
1655
|
+
'ls:List worktrees'
|
|
1176
1656
|
'switch:Switch to a worktree'
|
|
1177
1657
|
'info:Show worktree details'
|
|
1178
1658
|
'remove:Remove a worktree'
|
|
1659
|
+
'prune:Clean up records of removed worktrees'
|
|
1179
1660
|
'trust:Approve project hooks'
|
|
1180
1661
|
'config:Manage user and project configuration'
|
|
1181
1662
|
'shell:Install shell integration'
|
|
@@ -1190,21 +1671,29 @@ if command -v gwt >/dev/null 2>&1; then
|
|
|
1190
1671
|
case "$words[2]" in
|
|
1191
1672
|
new)
|
|
1192
1673
|
_arguments \
|
|
1193
|
-
'2:branch name:' \
|
|
1674
|
+
'2:branch name:_gwt_refs' \
|
|
1194
1675
|
'--base[base Git revision]:revision:_gwt_refs' \
|
|
1195
1676
|
'--no-hooks[skip project hooks]' \
|
|
1677
|
+
'--background[run postCreate in the background]' \
|
|
1196
1678
|
'(-h --help)'{-h,--help}'[show help]'
|
|
1197
1679
|
;;
|
|
1198
1680
|
setup)
|
|
1199
1681
|
_arguments \
|
|
1200
1682
|
'2:worktree:_gwt_worktrees' \
|
|
1201
1683
|
'--no-hooks[skip project hooks]' \
|
|
1684
|
+
'--background[run postCreate in the background]' \
|
|
1202
1685
|
'(-h --help)'{-h,--help}'[show help]'
|
|
1203
1686
|
;;
|
|
1204
|
-
list)
|
|
1687
|
+
list|ls)
|
|
1205
1688
|
_arguments '(-h --help)'{-h,--help}'[show help]'
|
|
1206
1689
|
;;
|
|
1207
|
-
switch
|
|
1690
|
+
switch)
|
|
1691
|
+
_arguments \
|
|
1692
|
+
'2:worktree:_gwt_worktrees' \
|
|
1693
|
+
'--create[create a worktree for a branch without one]' \
|
|
1694
|
+
'(-h --help)'{-h,--help}'[show help]'
|
|
1695
|
+
;;
|
|
1696
|
+
info)
|
|
1208
1697
|
_arguments \
|
|
1209
1698
|
'2:worktree:_gwt_worktrees' \
|
|
1210
1699
|
'(-h --help)'{-h,--help}'[show help]'
|
|
@@ -1218,6 +1707,12 @@ if command -v gwt >/dev/null 2>&1; then
|
|
|
1218
1707
|
'--no-hooks[skip project hooks]' \
|
|
1219
1708
|
'(-h --help)'{-h,--help}'[show help]'
|
|
1220
1709
|
;;
|
|
1710
|
+
prune)
|
|
1711
|
+
_arguments \
|
|
1712
|
+
'--dry-run[show what would be pruned]' \
|
|
1713
|
+
'--yes[skip the confirmation]' \
|
|
1714
|
+
'(-h --help)'{-h,--help}'[show help]'
|
|
1715
|
+
;;
|
|
1221
1716
|
trust)
|
|
1222
1717
|
_arguments \
|
|
1223
1718
|
'--revoke[revoke project hook approval]' \
|
|
@@ -1252,6 +1747,12 @@ if command -v gwt >/dev/null 2>&1; then
|
|
|
1252
1747
|
if (( $+functions[compdef] )); then
|
|
1253
1748
|
compdef _gwt gwt
|
|
1254
1749
|
fi
|
|
1750
|
+
|
|
1751
|
+
typeset -ga chpwd_functions
|
|
1752
|
+
if (( ! \${chpwd_functions[(I)_gwt_sync_env]} )); then
|
|
1753
|
+
chpwd_functions+=(_gwt_sync_env)
|
|
1754
|
+
fi
|
|
1755
|
+
_gwt_sync_env
|
|
1255
1756
|
fi`
|
|
1256
1757
|
}
|
|
1257
1758
|
|
|
@@ -1280,23 +1781,41 @@ stay accurate across versions.
|
|
|
1280
1781
|
|
|
1281
1782
|
## What the help does not make obvious
|
|
1282
1783
|
|
|
1283
|
-
-
|
|
1284
|
-
approved with \`gwt trust\`. Approval
|
|
1285
|
-
hook changes, so a repository that
|
|
1784
|
+
- Ports, environment variables, and hooks declared by a committed \`.gwt.json\`
|
|
1785
|
+
are not applied until the repository is approved with \`gwt trust\`. Approval
|
|
1786
|
+
is invalidated whenever the config or a hook changes, so a repository that
|
|
1787
|
+
worked before can start asking again.
|
|
1788
|
+
- \`gwt new <branch>\` reuses a branch that already exists locally, or creates one
|
|
1789
|
+
tracking the remote when the name exists on exactly one remote, so there is no
|
|
1790
|
+
need to check first. It refuses only a branch already checked out in another
|
|
1791
|
+
worktree, which means switching to that worktree instead.
|
|
1286
1792
|
- A failed setup keeps the worktree and records the failure. Retry it with
|
|
1287
1793
|
\`gwt setup <id>\` rather than removing and recreating the worktree.
|
|
1288
|
-
- Ports are assigned per worktree.
|
|
1289
|
-
|
|
1794
|
+
- Ports are assigned per worktree. With shell integration installed, assigned
|
|
1795
|
+
ports and configured environment variables load automatically. Read both from
|
|
1796
|
+
\`gwt info\` instead of assuming a project default; two worktrees of the same
|
|
1797
|
+
project never share a port.
|
|
1798
|
+
- \`--background\` returns before \`postCreate\` finishes, so the worktree is not
|
|
1799
|
+
ready yet. Do not use it when the next step needs installed dependencies;
|
|
1800
|
+
without it, setup is complete when the command returns.
|
|
1290
1801
|
- \`gwt switch\` changes the shell's directory only when the shell integration is
|
|
1291
1802
|
installed. Otherwise it just prints the path.
|
|
1292
1803
|
- \`gwt switch\` with no target opens an interactive picker, so always pass an
|
|
1293
1804
|
explicit target when running non-interactively.
|
|
1805
|
+
- \`gwt switch <branch>\` offers to create a worktree when that branch has none,
|
|
1806
|
+
and needs \`--create\` to do it without asking. Only pass \`--create\` for a
|
|
1807
|
+
branch name the user gave you: a typo silently becomes a new branch.
|
|
1294
1808
|
|
|
1295
|
-
## Removal
|
|
1809
|
+
## Removal and pruning are destructive
|
|
1296
1810
|
|
|
1297
1811
|
\`gwt remove\` deletes the worktree and, by default, its branch. Confirm with the
|
|
1298
1812
|
user before running it, and never pass \`--discard --yes\` on your own: together
|
|
1299
1813
|
they discard uncommitted changes and force-delete an unmerged branch.
|
|
1814
|
+
|
|
1815
|
+
\`gwt prune\` deletes records of worktrees that are already gone, including the
|
|
1816
|
+
scratch branch such a worktree recorded. Bare, it reports what it found and
|
|
1817
|
+
asks first, which is the only form to run unprompted; \`--yes\` skips that
|
|
1818
|
+
question, so leave it to the user.
|
|
1300
1819
|
`
|
|
1301
1820
|
}
|
|
1302
1821
|
|
|
@@ -1380,18 +1899,53 @@ async function commandShell(args) {
|
|
|
1380
1899
|
throw new CliError("Usage: gwt shell install zsh [--dry-run] [--yes]")
|
|
1381
1900
|
}
|
|
1382
1901
|
|
|
1902
|
+
function commandRunHook(args) {
|
|
1903
|
+
if (args.length !== 2 || args[0] !== "postCreate") throw new CliError("Invalid hook request")
|
|
1904
|
+
const repository = discoverRepository()
|
|
1905
|
+
const metadata = loadMetadata(repository).find((item) => item.id === args[1])
|
|
1906
|
+
if (!metadata) throw new CliError(`No worktree metadata for ${args[1]}`)
|
|
1907
|
+
const worktree = repository.worktrees.find((item) => resolve(item.path) === resolve(metadata.path))
|
|
1908
|
+
if (!worktree) throw new CliError(`Worktree ${metadata.id} is no longer registered with Git`)
|
|
1909
|
+
|
|
1910
|
+
const configDocument = loadConfig(repository)
|
|
1911
|
+
const claimed = updateMetadata(repository, metadata, { job: { ...metadata.job, pid: process.pid } })
|
|
1912
|
+
const finish = (update) => {
|
|
1913
|
+
if (!existsSync(metadataPath(repository, claimed.id))) return
|
|
1914
|
+
updateMetadata(repository, claimed, {
|
|
1915
|
+
...update,
|
|
1916
|
+
job: { ...claimed.job, finishedAt: new Date().toISOString() },
|
|
1917
|
+
})
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
try {
|
|
1921
|
+
if (configDocument.requiresTrust) {
|
|
1922
|
+
const fingerprint = trustFingerprint(repository, configDocument, canonical(worktree.path))
|
|
1923
|
+
if (!isTrusted(repository, fingerprint)) {
|
|
1924
|
+
throw new CliError("Project configuration is not trusted. Run 'gwt trust' to approve it")
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
runHook("postCreate", repository, configDocument, worktree, claimed)
|
|
1928
|
+
finish({ setup: "complete", setupError: undefined })
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
finish({ setup: "failed", setupError: error.message })
|
|
1931
|
+
throw error
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1383
1935
|
function commandComplete(args) {
|
|
1384
1936
|
if (args.length !== 1) throw new CliError("Invalid completion request")
|
|
1385
1937
|
|
|
1386
1938
|
if (args[0] === "worktrees") {
|
|
1387
1939
|
const repository = discoverRepository()
|
|
1388
|
-
const
|
|
1940
|
+
const lines = [`primary\t${repository.primary.branch ?? ""}`]
|
|
1389
1941
|
for (const worktree of repository.worktrees) {
|
|
1942
|
+
if (resolve(worktree.path) === resolve(repository.primaryPath)) continue
|
|
1390
1943
|
const metadata = metadataForWorktree(repository, worktree)
|
|
1391
|
-
|
|
1392
|
-
|
|
1944
|
+
const id = metadata?.id ?? ""
|
|
1945
|
+
const branch = worktree.branch ?? ""
|
|
1946
|
+
if (id || branch) lines.push(`${id}\t${branch}`)
|
|
1393
1947
|
}
|
|
1394
|
-
console.log(
|
|
1948
|
+
console.log(lines.join("\n"))
|
|
1395
1949
|
return
|
|
1396
1950
|
}
|
|
1397
1951
|
|
|
@@ -1416,7 +1970,9 @@ function version() {
|
|
|
1416
1970
|
}
|
|
1417
1971
|
|
|
1418
1972
|
function help(command, subcommand) {
|
|
1419
|
-
const
|
|
1973
|
+
const aliases = { ls: "list" }
|
|
1974
|
+
const requested = [command, subcommand].filter(Boolean).join(" ")
|
|
1975
|
+
const topic = aliases[requested] ?? requested
|
|
1420
1976
|
const texts = {
|
|
1421
1977
|
"": `gwt ${version()} - lightweight native Git worktree workflows
|
|
1422
1978
|
|
|
@@ -1426,11 +1982,12 @@ Usage:
|
|
|
1426
1982
|
Commands:
|
|
1427
1983
|
new Create and set up a worktree
|
|
1428
1984
|
setup Set up an existing worktree
|
|
1429
|
-
list List registered worktrees
|
|
1985
|
+
list List registered worktrees (alias: ls)
|
|
1430
1986
|
switch Switch the current shell to a worktree
|
|
1431
|
-
info Show worktree details and
|
|
1987
|
+
info Show worktree details, ports, and environment
|
|
1432
1988
|
remove Safely remove a worktree and optionally its branch
|
|
1433
|
-
|
|
1989
|
+
prune Clean up leftovers from worktrees that are already gone
|
|
1990
|
+
trust Approve or revoke repository project configuration
|
|
1434
1991
|
config Create or inspect configuration
|
|
1435
1992
|
shell Install shell integration
|
|
1436
1993
|
skill Install the gwt skill for coding agents
|
|
@@ -1443,6 +2000,7 @@ Examples:
|
|
|
1443
2000
|
gwt new feature/auth
|
|
1444
2001
|
gwt switch
|
|
1445
2002
|
gwt remove
|
|
2003
|
+
gwt prune
|
|
1446
2004
|
gwt config create
|
|
1447
2005
|
gwt shell install zsh
|
|
1448
2006
|
gwt skill install claude
|
|
@@ -1451,15 +2009,20 @@ Run 'gwt <command> --help' for command behavior and more examples.`,
|
|
|
1451
2009
|
new: `Create a worktree, prepare its development environment, and switch to it.
|
|
1452
2010
|
|
|
1453
2011
|
Usage:
|
|
1454
|
-
gwt new [branch] [--base <ref>] [--no-hooks]
|
|
2012
|
+
gwt new [branch] [--base <ref>] [--no-hooks] [--background]
|
|
1455
2013
|
|
|
1456
2014
|
Arguments:
|
|
1457
|
-
branch
|
|
2015
|
+
branch Branch to check out. An existing local branch is reused, a
|
|
2016
|
+
branch that exists on exactly one remote is checked out with
|
|
2017
|
+
tracking, and anything else is created. Defaults to
|
|
2018
|
+
scratch/<id>.
|
|
1458
2019
|
|
|
1459
2020
|
Options:
|
|
1460
|
-
--base <ref>
|
|
1461
|
-
or the primary worktree's current commit.
|
|
2021
|
+
--base <ref> Create the branch from this Git revision instead of the
|
|
2022
|
+
configured base or the primary worktree's current commit.
|
|
2023
|
+
Rejected when the branch already exists locally.
|
|
1462
2024
|
--no-hooks Copy files and allocate ports, but skip postCreate.
|
|
2025
|
+
--background Run postCreate detached so the shell returns immediately.
|
|
1463
2026
|
-h, --help Show help for this command.
|
|
1464
2027
|
|
|
1465
2028
|
Behavior:
|
|
@@ -1472,53 +2035,68 @@ Behavior:
|
|
|
1472
2035
|
repository hash is added to the directory name only when needed to avoid a
|
|
1473
2036
|
name collision.
|
|
1474
2037
|
|
|
2038
|
+
A branch already checked out in another worktree is refused; switch to that
|
|
2039
|
+
worktree instead. With --background, files are copied, ports are assigned,
|
|
2040
|
+
and project configuration is approved before postCreate is detached, so the
|
|
2041
|
+
worktree is usable right away. 'gwt list' shows it as running until the
|
|
2042
|
+
hook finishes, and its output is written to .git/gwt/logs/<id>.log.
|
|
2043
|
+
|
|
1475
2044
|
Examples:
|
|
1476
2045
|
gwt new feature/auth
|
|
1477
2046
|
gwt new
|
|
1478
2047
|
gwt new hotfix/login --base origin/main
|
|
1479
|
-
gwt new
|
|
2048
|
+
gwt new feature/from-remote
|
|
2049
|
+
gwt new heavy-project --background`,
|
|
1480
2050
|
setup: `Prepare an existing linked worktree using the active gwt configuration.
|
|
1481
2051
|
|
|
1482
2052
|
Usage:
|
|
1483
|
-
gwt setup [id|branch|path] [--no-hooks]
|
|
2053
|
+
gwt setup [id|branch|path] [--no-hooks] [--background]
|
|
1484
2054
|
|
|
1485
2055
|
Arguments:
|
|
1486
2056
|
id|branch|path Worktree to set up. Defaults to the current worktree.
|
|
1487
2057
|
|
|
1488
2058
|
Options:
|
|
1489
2059
|
--no-hooks Copy files and allocate ports, but skip postCreate.
|
|
2060
|
+
--background Run postCreate detached so the shell returns immediately.
|
|
1490
2061
|
-h, --help Show help for this command.
|
|
1491
2062
|
|
|
1492
2063
|
Behavior:
|
|
1493
2064
|
Use this to adopt a worktree created with native 'git worktree add' or to
|
|
1494
2065
|
retry a failed setup. Existing copied files and assigned ports are preserved.
|
|
2066
|
+
Setup refuses to start while a background setup is already running for the
|
|
2067
|
+
same worktree.
|
|
1495
2068
|
|
|
1496
2069
|
Examples:
|
|
1497
2070
|
gwt setup
|
|
1498
2071
|
gwt setup feature/auth
|
|
1499
|
-
gwt setup a1b2c3d4 --no-hooks
|
|
2072
|
+
gwt setup a1b2c3d4 --no-hooks
|
|
2073
|
+
gwt setup a1b2c3d4 --background`,
|
|
1500
2074
|
list: `List Git worktrees together with gwt IDs and setup status.
|
|
1501
2075
|
|
|
1502
2076
|
Usage:
|
|
1503
2077
|
gwt list
|
|
2078
|
+
gwt ls
|
|
1504
2079
|
|
|
1505
2080
|
Options:
|
|
1506
2081
|
-h, --help Show help for this command.
|
|
1507
2082
|
|
|
1508
2083
|
The current worktree is marked with '*'. Native worktrees that have not been
|
|
1509
|
-
set up by gwt are shown as unmanaged.
|
|
2084
|
+
set up by gwt are shown as unmanaged. 'gwt ls' is an alias for 'gwt list'.
|
|
1510
2085
|
|
|
1511
|
-
|
|
1512
|
-
gwt list
|
|
2086
|
+
Examples:
|
|
2087
|
+
gwt list
|
|
2088
|
+
gwt ls`,
|
|
1513
2089
|
switch: `Switch the current shell to another worktree.
|
|
1514
2090
|
|
|
1515
2091
|
Usage:
|
|
1516
|
-
gwt switch [primary|id|branch|path]
|
|
2092
|
+
gwt switch [primary|id|branch|path] [--create]
|
|
1517
2093
|
|
|
1518
2094
|
Arguments:
|
|
1519
2095
|
selector Worktree to switch to. Opens the picker when omitted.
|
|
1520
2096
|
|
|
1521
2097
|
Options:
|
|
2098
|
+
--create Create the worktree without asking when the selector names a
|
|
2099
|
+
branch that has none.
|
|
1522
2100
|
-h, --help Show help for this command.
|
|
1523
2101
|
|
|
1524
2102
|
Behavior:
|
|
@@ -1527,12 +2105,18 @@ Behavior:
|
|
|
1527
2105
|
j/k, Ctrl-n/Ctrl-p, and '/' filtering. Shell integration must be installed for
|
|
1528
2106
|
gwt to change the parent shell's directory; otherwise the path is only printed.
|
|
1529
2107
|
|
|
2108
|
+
When nothing matches and the selector is a valid branch name, gwt offers to
|
|
2109
|
+
create the worktree, reusing an existing branch or tracking a remote one just
|
|
2110
|
+
like 'gwt new'. Selectors that look like paths are never treated as branch
|
|
2111
|
+
names. Non-interactive use requires --create.
|
|
2112
|
+
|
|
1530
2113
|
Examples:
|
|
1531
2114
|
gwt switch
|
|
1532
2115
|
gwt switch primary
|
|
1533
2116
|
gwt switch feature/auth
|
|
1534
|
-
gwt switch a1b2c3d4
|
|
1535
|
-
|
|
2117
|
+
gwt switch a1b2c3d4
|
|
2118
|
+
gwt switch feature/review --create`,
|
|
2119
|
+
info: `Show a worktree's identity, Git state, setup status, and environment.
|
|
1536
2120
|
|
|
1537
2121
|
Usage:
|
|
1538
2122
|
gwt info [primary|id|branch|path]
|
|
@@ -1544,6 +2128,13 @@ Arguments:
|
|
|
1544
2128
|
Options:
|
|
1545
2129
|
-h, --help Show help for this command.
|
|
1546
2130
|
|
|
2131
|
+
Behavior:
|
|
2132
|
+
Assigned ports and configured environment variables are listed exactly as an
|
|
2133
|
+
integrated shell loads them, so what is printed is what the worktree gets.
|
|
2134
|
+
Values declared by a repository .gwt.json are withheld until 'gwt trust'
|
|
2135
|
+
approves the configuration, and a variable that cannot be resolved is
|
|
2136
|
+
reported instead of failing the command.
|
|
2137
|
+
|
|
1547
2138
|
Examples:
|
|
1548
2139
|
gwt info
|
|
1549
2140
|
gwt info primary
|
|
@@ -1571,11 +2162,47 @@ Behavior:
|
|
|
1571
2162
|
use keeps it. The primary worktree cannot be removed. Removing the current
|
|
1572
2163
|
worktree returns an integrated shell to the primary worktree.
|
|
1573
2164
|
|
|
2165
|
+
A worktree created without a branch argument records its scratch/<id> branch.
|
|
2166
|
+
If the worktree later moved to another branch, removal deletes that leftover
|
|
2167
|
+
scratch branch when the branch it moved to already contains every scratch
|
|
2168
|
+
commit, so nothing is lost. A scratch branch holding commits that were left
|
|
2169
|
+
behind is kept and reported instead. --keep-branch keeps it either way.
|
|
2170
|
+
Removal refuses to run while a background setup is still in progress; use
|
|
2171
|
+
--discard to force it. Configuration approval is requested only when
|
|
2172
|
+
preRemove is configured, because removal applies nothing else from the
|
|
2173
|
+
configuration.
|
|
2174
|
+
|
|
1574
2175
|
Examples:
|
|
1575
2176
|
gwt remove
|
|
1576
2177
|
gwt remove feature/auth --keep-branch
|
|
1577
2178
|
gwt remove a1b2c3d4 --discard --yes`,
|
|
1578
|
-
|
|
2179
|
+
prune: `Clean up records left behind by worktrees that are already gone.
|
|
2180
|
+
|
|
2181
|
+
Usage:
|
|
2182
|
+
gwt prune [--dry-run] [--yes]
|
|
2183
|
+
|
|
2184
|
+
Options:
|
|
2185
|
+
--dry-run Print what would be pruned without changing anything.
|
|
2186
|
+
--yes Prune without asking for confirmation.
|
|
2187
|
+
-h, --help Show help for this command.
|
|
2188
|
+
|
|
2189
|
+
Behavior:
|
|
2190
|
+
Prune reports what it would remove and asks before touching anything, so
|
|
2191
|
+
running it with no options is safe. It unregisters worktrees whose directory
|
|
2192
|
+
is gone, deletes metadata and setup logs with no worktree left, and deletes
|
|
2193
|
+
the scratch branch such a worktree recorded.
|
|
2194
|
+
|
|
2195
|
+
A scratch branch is deleted only when another local or remote branch already
|
|
2196
|
+
contains its commits, so nothing is lost. One holding commits no other branch
|
|
2197
|
+
contains is reported and kept. Only branches gwt recorded at creation are
|
|
2198
|
+
considered; other branches are never touched. Metadata for a setup that is
|
|
2199
|
+
still running is left alone.
|
|
2200
|
+
|
|
2201
|
+
Examples:
|
|
2202
|
+
gwt prune
|
|
2203
|
+
gwt prune --dry-run
|
|
2204
|
+
gwt prune --yes`,
|
|
2205
|
+
trust: `Approve or revoke active configuration declared by the repository's .gwt.json.
|
|
1579
2206
|
|
|
1580
2207
|
Usage:
|
|
1581
2208
|
gwt trust [--revoke]
|
|
@@ -1584,9 +2211,10 @@ Options:
|
|
|
1584
2211
|
--revoke Remove the stored approval for this repository.
|
|
1585
2212
|
-h, --help Show help for this command.
|
|
1586
2213
|
|
|
1587
|
-
Approval is
|
|
1588
|
-
|
|
1589
|
-
|
|
2214
|
+
Approval is required before repository-defined ports or environment variables
|
|
2215
|
+
are applied, or repository hooks run. It is tied to the configuration and hook
|
|
2216
|
+
contents, so changing either requires approval again. User configuration is
|
|
2217
|
+
trusted automatically.
|
|
1590
2218
|
|
|
1591
2219
|
Examples:
|
|
1592
2220
|
gwt trust
|
|
@@ -1653,7 +2281,8 @@ Options:
|
|
|
1653
2281
|
-h, --help Show help for this command.
|
|
1654
2282
|
|
|
1655
2283
|
The integration lets gwt change the current shell's directory after new,
|
|
1656
|
-
switch, and removal of the current worktree. It also
|
|
2284
|
+
switch, and removal of the current worktree. It also loads the worktree's
|
|
2285
|
+
assigned ports and configured environment, and installs completion.
|
|
1657
2286
|
|
|
1658
2287
|
Example:
|
|
1659
2288
|
gwt shell install zsh`,
|
|
@@ -1668,7 +2297,9 @@ Options:
|
|
|
1668
2297
|
-h, --help Show help for this command.
|
|
1669
2298
|
|
|
1670
2299
|
The command adds one initialization line to ~/.zshrc, or to $ZDOTDIR/.zshrc
|
|
1671
|
-
when ZDOTDIR is set. Restart Zsh or source the file after installation.
|
|
2300
|
+
when ZDOTDIR is set. Restart Zsh or source the file after installation. The
|
|
2301
|
+
integration updates the environment when Zsh starts or changes directory and
|
|
2302
|
+
restores previous values after leaving a managed worktree.
|
|
1672
2303
|
|
|
1673
2304
|
Examples:
|
|
1674
2305
|
gwt shell install zsh
|
|
@@ -1736,15 +2367,18 @@ async function main() {
|
|
|
1736
2367
|
}
|
|
1737
2368
|
if (command === "new") return commandNew(args)
|
|
1738
2369
|
if (command === "setup") return commandSetup(args)
|
|
1739
|
-
if (command === "list") return commandList(args)
|
|
2370
|
+
if (command === "list" || command === "ls") return commandList(args)
|
|
1740
2371
|
if (command === "switch") return commandSwitch(args)
|
|
1741
2372
|
if (command === "info") return commandInfo(args)
|
|
1742
2373
|
if (command === "remove") return commandRemove(args)
|
|
2374
|
+
if (command === "prune") return commandPrune(args)
|
|
1743
2375
|
if (command === "trust") return commandTrust(args)
|
|
1744
2376
|
if (command === "config") return commandConfig(args)
|
|
1745
2377
|
if (command === "shell") return commandShell(args)
|
|
1746
2378
|
if (command === "skill") return commandSkill(args)
|
|
1747
2379
|
if (command === "__complete") return commandComplete(args)
|
|
2380
|
+
if (command === "__run-hook") return commandRunHook(args)
|
|
2381
|
+
if (command === "__shell_env") return commandShellEnvironment(args)
|
|
1748
2382
|
throw new CliError(`Unknown command: ${command}`)
|
|
1749
2383
|
}
|
|
1750
2384
|
|