@junheep/gwt 0.2.2 → 0.4.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 +43 -17
- package/bin/gwt.mjs +267 -54
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,9 +29,10 @@ gwt shell install zsh
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
The installer shows the line it will add to `~/.zshrc` and asks for
|
|
32
|
-
confirmation. The integration also provides Zsh completion
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
confirmation. The integration also provides Zsh completion and automatically
|
|
33
|
+
loads assigned ports and configured environment variables when Zsh enters a
|
|
34
|
+
managed worktree. Previous values are restored when Zsh leaves it. Normal
|
|
35
|
+
environment synchronization produces no output.
|
|
35
36
|
|
|
36
37
|
## Coding agents
|
|
37
38
|
|
|
@@ -82,7 +83,6 @@ Repositories without a remote use their canonical path.
|
|
|
82
83
|
{
|
|
83
84
|
"projects": {
|
|
84
85
|
"github.com/owner/repository": {
|
|
85
|
-
"worktreeDirectory": ".worktrees",
|
|
86
86
|
"copyFiles": [
|
|
87
87
|
"apps/server/.env",
|
|
88
88
|
"apps/web/.env"
|
|
@@ -91,6 +91,9 @@ Repositories without a remote use their canonical path.
|
|
|
91
91
|
"WEB_PORT",
|
|
92
92
|
"SERVER_PORT"
|
|
93
93
|
],
|
|
94
|
+
"env": {
|
|
95
|
+
"NEXT_PUBLIC_API_ENDPOINT": "http://127.0.0.1:${SERVER_PORT}"
|
|
96
|
+
},
|
|
94
97
|
"postCreate": "hooks/worktree-setup",
|
|
95
98
|
"preRemove": "hooks/worktree-cleanup"
|
|
96
99
|
}
|
|
@@ -114,7 +117,6 @@ The project file contains the configuration fields directly:
|
|
|
114
117
|
```json
|
|
115
118
|
{
|
|
116
119
|
"base": "origin/main",
|
|
117
|
-
"worktreeDirectory": ".worktrees",
|
|
118
120
|
"copyFiles": [
|
|
119
121
|
"apps/server/.env",
|
|
120
122
|
"apps/web/.env"
|
|
@@ -123,6 +125,9 @@ The project file contains the configuration fields directly:
|
|
|
123
125
|
"WEB_PORT",
|
|
124
126
|
"SERVER_PORT"
|
|
125
127
|
],
|
|
128
|
+
"env": {
|
|
129
|
+
"NEXT_PUBLIC_API_ENDPOINT": "http://127.0.0.1:${SERVER_PORT}"
|
|
130
|
+
},
|
|
126
131
|
"postCreate": "./scripts/worktree-setup",
|
|
127
132
|
"preRemove": "./scripts/worktree-cleanup"
|
|
128
133
|
}
|
|
@@ -133,21 +138,38 @@ The two files are not merged. Run `gwt config show` to see whether user and
|
|
|
133
138
|
repository configuration is available, the location of each existing config
|
|
134
139
|
file, the active source, and its resolved value.
|
|
135
140
|
|
|
136
|
-
All fields are optional. Without either config, worktrees are created
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
All fields are optional. Without either config, worktrees are created outside
|
|
142
|
+
the repository beneath `~/.gwt/worktrees`. Set `GWT_HOME` to an absolute path
|
|
143
|
+
to use a different gwt home directory. A repository normally uses a directory
|
|
144
|
+
named after it. If another repository already uses that name, gwt adds a short
|
|
145
|
+
hash derived from the canonical path. Worktrees use the primary worktree's
|
|
146
|
+
current commit as their base and run no setup actions.
|
|
139
147
|
|
|
140
148
|
- `base`: Git revision used when `--base` is omitted.
|
|
141
|
-
- `worktreeDirectory`:
|
|
149
|
+
- `worktreeDirectory`: Optional repository-relative directory for managed
|
|
150
|
+
worktrees. Setting it opts out of the external default.
|
|
142
151
|
- `copyFiles`: Ignored local files copied from the primary worktree without
|
|
143
152
|
overwriting an existing destination.
|
|
144
153
|
- `ports`: Environment variable names assigned stable ports in the range
|
|
145
154
|
20000–39999.
|
|
155
|
+
- `env`: Environment variables loaded alongside assigned ports. Values are
|
|
156
|
+
literal strings with optional `${PORT_NAME}` references to names declared in
|
|
157
|
+
`ports`. Shell expressions and references to arbitrary process variables are
|
|
158
|
+
not evaluated.
|
|
146
159
|
- `postCreate`: Executable run after files and ports are prepared.
|
|
147
160
|
- `preRemove`: Executable run before removal.
|
|
148
161
|
|
|
149
|
-
|
|
150
|
-
are not modified.
|
|
162
|
+
An explicitly configured repository-relative worktree directory is added to
|
|
163
|
+
`.git/info/exclude`; tracked project files are not modified.
|
|
164
|
+
|
|
165
|
+
For example, an existing configuration can retain the previous in-repository
|
|
166
|
+
layout explicitly:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"worktreeDirectory": ".worktrees"
|
|
171
|
+
}
|
|
172
|
+
```
|
|
151
173
|
|
|
152
174
|
## Hooks
|
|
153
175
|
|
|
@@ -159,6 +181,7 @@ GWT_PATH
|
|
|
159
181
|
GWT_PRIMARY_PATH
|
|
160
182
|
GWT_BRANCH
|
|
161
183
|
<each name declared in ports>
|
|
184
|
+
<each name declared in env>
|
|
162
185
|
```
|
|
163
186
|
|
|
164
187
|
Example `postCreate` hook:
|
|
@@ -176,9 +199,10 @@ Hook paths in user config are resolved relative to the directory containing
|
|
|
176
199
|
worktree. Both run with the target worktree as their working directory, and
|
|
177
200
|
their standard output and errors are streamed directly to the terminal.
|
|
178
201
|
|
|
179
|
-
|
|
180
|
-
from a committed `.gwt.json` require explicit trust
|
|
181
|
-
repository
|
|
202
|
+
User configuration is trusted because the user added it directly. Ports and
|
|
203
|
+
environment variables from a committed `.gwt.json` require explicit trust
|
|
204
|
+
because they automatically change the shell; repository hooks require the same
|
|
205
|
+
approval because they execute code:
|
|
182
206
|
|
|
183
207
|
```sh
|
|
184
208
|
gwt trust
|
|
@@ -192,8 +216,8 @@ Approval is invalidated when `.gwt.json` or either hook changes.
|
|
|
192
216
|
gwt new [branch] [--base <ref>] [--no-hooks]
|
|
193
217
|
gwt setup [id|branch|path] [--no-hooks]
|
|
194
218
|
gwt list
|
|
195
|
-
gwt switch [id|branch|path]
|
|
196
|
-
gwt info [id|branch|path]
|
|
219
|
+
gwt switch [primary|id|branch|path]
|
|
220
|
+
gwt info [primary|id|branch|path]
|
|
197
221
|
gwt remove [id|branch|path] [--keep-branch|--discard] [--yes] [--no-hooks]
|
|
198
222
|
gwt trust [--revoke]
|
|
199
223
|
gwt config create [--project]
|
|
@@ -216,7 +240,9 @@ Setup failures retain the worktree and record the failure. Retry with
|
|
|
216
240
|
Run `gwt switch` without a target to open the interactive picker. Use the
|
|
217
241
|
arrow keys, `j`/`k`, or Ctrl-n/Ctrl-p to move; press `/` to filter by branch,
|
|
218
242
|
ID, or path. Enter switches to the selected worktree. Escape leaves filter
|
|
219
|
-
mode or cancels the picker.
|
|
243
|
+
mode or cancels the picker. `primary` is a reserved ID for the repository's
|
|
244
|
+
primary worktree, so `gwt switch primary` returns to it from any linked
|
|
245
|
+
worktree.
|
|
220
246
|
|
|
221
247
|
`gwt remove` refuses dirty worktrees and first tries to delete the branch with
|
|
222
248
|
`git branch -d`. If Git rejects safe deletion, an interactive terminal asks
|
package/bin/gwt.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "node:fs"
|
|
20
20
|
import { createServer } from "node:net"
|
|
21
21
|
import { homedir } from "node:os"
|
|
22
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
22
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
23
23
|
import { emitKeypressEvents } from "node:readline"
|
|
24
24
|
import { createInterface } from "node:readline/promises"
|
|
25
25
|
|
|
@@ -28,7 +28,8 @@ const PORT_MIN = 20_000
|
|
|
28
28
|
const PORT_MAX = 39_999
|
|
29
29
|
const PICKER_ESCAPE_CODE_TIMEOUT_MS = 50
|
|
30
30
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
31
|
-
const
|
|
31
|
+
const SHELL_ENV_STATE = "GWT_SHELL_ENV_STATE"
|
|
32
|
+
const DEFAULT_CONFIG = { copyFiles: [], ports: [], env: {} }
|
|
32
33
|
const SKILL_DIRECTORIES = { claude: ".claude", codex: ".agents" }
|
|
33
34
|
const SKILL_USAGE = `Usage: gwt skill install <${Object.keys(SKILL_DIRECTORIES).join("|")}> [--project] [--dry-run] [--yes]`
|
|
34
35
|
|
|
@@ -127,7 +128,7 @@ function validateRelativePath(value, field) {
|
|
|
127
128
|
|
|
128
129
|
function validateConfig(parsed, label) {
|
|
129
130
|
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"])
|
|
131
|
+
const allowed = new Set(["base", "worktreeDirectory", "copyFiles", "ports", "env", "postCreate", "preRemove"])
|
|
131
132
|
for (const key of Object.keys(parsed)) {
|
|
132
133
|
if (!allowed.has(key)) throw new CliError(`${label} contains an unknown field: ${key}`)
|
|
133
134
|
}
|
|
@@ -136,9 +137,12 @@ function validateConfig(parsed, label) {
|
|
|
136
137
|
throw new CliError("base must be a non-empty string")
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
if (worktreeDirectory
|
|
141
|
-
|
|
140
|
+
let worktreeDirectory
|
|
141
|
+
if (parsed.worktreeDirectory !== undefined) {
|
|
142
|
+
worktreeDirectory = validateRelativePath(parsed.worktreeDirectory, "worktreeDirectory")
|
|
143
|
+
if (worktreeDirectory.split(/[\\/]+/).some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) {
|
|
144
|
+
throw new CliError("worktreeDirectory can only contain letters, digits, '.', '_', '-', and path separators")
|
|
145
|
+
}
|
|
142
146
|
}
|
|
143
147
|
if (!Array.isArray(parsed.copyFiles ?? [])) throw new CliError("copyFiles must be an array")
|
|
144
148
|
const copyFiles = (parsed.copyFiles ?? []).map((path, index) => validateRelativePath(path, `copyFiles[${index}]`))
|
|
@@ -147,27 +151,105 @@ function validateConfig(parsed, label) {
|
|
|
147
151
|
if (!Array.isArray(parsed.ports ?? [])) throw new CliError("ports must be an array")
|
|
148
152
|
const ports = (parsed.ports ?? []).map((name, index) => {
|
|
149
153
|
if (typeof name !== "string" || !ENV_NAME.test(name)) throw new CliError(`ports[${index}] is not a valid environment variable name`)
|
|
154
|
+
if (name.startsWith("GWT_")) throw new CliError(`ports[${index}] cannot use the reserved GWT_ prefix`)
|
|
150
155
|
return name
|
|
151
156
|
})
|
|
152
157
|
if (new Set(ports).size !== ports.length) throw new CliError("ports cannot contain duplicates")
|
|
153
158
|
if (ports.length > 100) throw new CliError("ports cannot contain more than 100 entries")
|
|
154
159
|
|
|
160
|
+
if (!parsed.env || typeof parsed.env !== "object" || Array.isArray(parsed.env)) {
|
|
161
|
+
if (parsed.env !== undefined) throw new CliError("env must be an object")
|
|
162
|
+
}
|
|
163
|
+
const envEntries = Object.entries(parsed.env ?? {})
|
|
164
|
+
if (envEntries.length > 100) throw new CliError("env cannot contain more than 100 entries")
|
|
165
|
+
const env = Object.fromEntries(envEntries.map(([name, value]) => {
|
|
166
|
+
if (!ENV_NAME.test(name)) throw new CliError(`env.${name} is not a valid environment variable name`)
|
|
167
|
+
if (name.startsWith("GWT_")) throw new CliError(`env.${name} cannot use the reserved GWT_ prefix`)
|
|
168
|
+
if (ports.includes(name)) throw new CliError(`env.${name} conflicts with a configured port`)
|
|
169
|
+
if (typeof value !== "string") throw new CliError(`env.${name} must be a string`)
|
|
170
|
+
|
|
171
|
+
const remainder = value.replace(/\$\{([^}]*)\}/g, (_, reference) => {
|
|
172
|
+
if (!ENV_NAME.test(reference) || !ports.includes(reference)) {
|
|
173
|
+
throw new CliError(`env.${name} references unknown port ${reference || "(empty)"}`)
|
|
174
|
+
}
|
|
175
|
+
return ""
|
|
176
|
+
})
|
|
177
|
+
if (remainder.includes("${")) throw new CliError(`env.${name} contains an invalid port reference`)
|
|
178
|
+
return [name, value]
|
|
179
|
+
}))
|
|
180
|
+
|
|
155
181
|
for (const hook of ["postCreate", "preRemove"]) {
|
|
156
182
|
if (parsed[hook] !== undefined) validateRelativePath(parsed[hook], hook)
|
|
157
183
|
}
|
|
158
184
|
|
|
159
|
-
|
|
185
|
+
const config = {
|
|
160
186
|
...parsed,
|
|
161
|
-
worktreeDirectory,
|
|
162
187
|
copyFiles,
|
|
163
188
|
ports,
|
|
189
|
+
env,
|
|
164
190
|
}
|
|
191
|
+
if (worktreeDirectory !== undefined) config.worktreeDirectory = worktreeDirectory
|
|
192
|
+
return config
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveConfiguredEnv(config, ports) {
|
|
196
|
+
return Object.fromEntries(Object.entries(config.env).map(([name, template]) => [
|
|
197
|
+
name,
|
|
198
|
+
template.replace(/\$\{([^}]*)\}/g, (_, reference) => {
|
|
199
|
+
if (!Object.hasOwn(ports, reference)) {
|
|
200
|
+
throw new CliError(`Cannot resolve env.${name}: this worktree has no assigned ${reference}`)
|
|
201
|
+
}
|
|
202
|
+
return String(ports[reference])
|
|
203
|
+
}),
|
|
204
|
+
]))
|
|
165
205
|
}
|
|
166
206
|
|
|
167
207
|
function configHome() {
|
|
168
208
|
return process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
|
169
209
|
}
|
|
170
210
|
|
|
211
|
+
function gwtHome() {
|
|
212
|
+
const path = process.env.GWT_HOME || join(homedir(), ".gwt")
|
|
213
|
+
if (!isAbsolute(path)) throw new CliError("GWT_HOME must be an absolute path")
|
|
214
|
+
return path
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function repositoryUsesDirectory(repository, directory) {
|
|
218
|
+
const resolvedDirectory = pathExists(directory) ? canonical(directory) : resolve(directory)
|
|
219
|
+
return repository.worktrees.some((worktree) => {
|
|
220
|
+
if (!pathExists(worktree.path)) return false
|
|
221
|
+
const worktreePath = canonical(worktree.path)
|
|
222
|
+
return worktreePath !== repository.primaryPath && dirname(worktreePath) === resolvedDirectory
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function directoryIsEmpty(path) {
|
|
227
|
+
try {
|
|
228
|
+
return statSync(path).isDirectory() && readdirSync(path).length === 0
|
|
229
|
+
} catch {
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function defaultWorktreeDirectory(repository) {
|
|
235
|
+
const root = join(gwtHome(), "worktrees")
|
|
236
|
+
const name = basename(repository.primaryPath)
|
|
237
|
+
const namedDirectory = join(root, name)
|
|
238
|
+
const digest = createHash("sha256").update(repository.primaryPath).digest("hex").slice(0, 8)
|
|
239
|
+
const disambiguatedDirectory = join(root, `${name}-${digest}`)
|
|
240
|
+
|
|
241
|
+
if (repositoryUsesDirectory(repository, disambiguatedDirectory)) return disambiguatedDirectory
|
|
242
|
+
if (repositoryUsesDirectory(repository, namedDirectory)) return namedDirectory
|
|
243
|
+
if (!pathExists(namedDirectory) || directoryIsEmpty(namedDirectory)) return namedDirectory
|
|
244
|
+
return disambiguatedDirectory
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveWorktreeDirectory(repository, config) {
|
|
248
|
+
return config.worktreeDirectory
|
|
249
|
+
? resolve(repository.primaryPath, config.worktreeDirectory)
|
|
250
|
+
: defaultWorktreeDirectory(repository)
|
|
251
|
+
}
|
|
252
|
+
|
|
171
253
|
function userConfigPath() {
|
|
172
254
|
return join(configHome(), "gwt", "config.json")
|
|
173
255
|
}
|
|
@@ -311,6 +393,8 @@ function resolveWorktree(repository, selector, options = {}) {
|
|
|
311
393
|
return current
|
|
312
394
|
}
|
|
313
395
|
|
|
396
|
+
if (selector === "primary") return repository.primary
|
|
397
|
+
|
|
314
398
|
const metadata = loadMetadata(repository)
|
|
315
399
|
const idMatch = metadata.find((item) => item.id === selector)
|
|
316
400
|
if (idMatch) {
|
|
@@ -330,9 +414,10 @@ function resolveWorktree(repository, selector, options = {}) {
|
|
|
330
414
|
}
|
|
331
415
|
|
|
332
416
|
function generateId(repository, config) {
|
|
417
|
+
const directory = resolveWorktreeDirectory(repository, config)
|
|
333
418
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
334
419
|
const id = randomBytes(4).toString("hex")
|
|
335
|
-
const target = join(
|
|
420
|
+
const target = join(directory, id)
|
|
336
421
|
if (!existsSync(metadataPath(repository, id)) && !pathExists(target)) return id
|
|
337
422
|
}
|
|
338
423
|
throw new CliError("Could not generate a unique worktree ID")
|
|
@@ -398,7 +483,7 @@ function hookPaths(configDocument, worktreePath) {
|
|
|
398
483
|
|
|
399
484
|
function trustFingerprint(repository, configDocument, worktreePath) {
|
|
400
485
|
const hooks = hookPaths(configDocument, worktreePath)
|
|
401
|
-
if (hooks.length === 0) return null
|
|
486
|
+
if (hooks.length === 0 && configDocument.value.ports.length === 0 && Object.keys(configDocument.value.env).length === 0) return null
|
|
402
487
|
const hash = createHash("sha256")
|
|
403
488
|
hash.update(repository.primaryPath)
|
|
404
489
|
hash.update("\0")
|
|
@@ -468,20 +553,24 @@ async function ensureTrusted(repository, configDocument, worktreePath) {
|
|
|
468
553
|
if (!fingerprint || isTrusted(repository, fingerprint)) return
|
|
469
554
|
|
|
470
555
|
const hooks = hookPaths(configDocument, worktreePath)
|
|
471
|
-
console.error("This repository wants to
|
|
556
|
+
console.error("This repository wants to configure your development environment:")
|
|
472
557
|
for (const hook of hooks) console.error(` ${hook.name}: ${hook.configuredPath}`)
|
|
558
|
+
for (const name of configDocument.value.ports) console.error(` port: ${name}`)
|
|
559
|
+
for (const name of Object.keys(configDocument.value.env)) console.error(` env: ${name}`)
|
|
473
560
|
const allowed = await ask("Allow and remember? [y/N] ")
|
|
474
|
-
if (!allowed) throw new CliError("Project
|
|
561
|
+
if (!allowed) throw new CliError("Project configuration is not trusted. Run 'gwt trust' to approve it")
|
|
475
562
|
saveTrust(repository, fingerprint)
|
|
476
563
|
}
|
|
477
564
|
|
|
478
|
-
function hookContext(repository, worktree, metadata) {
|
|
565
|
+
function hookContext(repository, config, worktree, metadata) {
|
|
566
|
+
const ports = metadata?.ports ?? {}
|
|
479
567
|
return {
|
|
480
568
|
id: metadata?.id ?? "",
|
|
481
569
|
path: canonical(worktree.path),
|
|
482
570
|
primaryPath: repository.primaryPath,
|
|
483
571
|
branch: worktree.branch ?? "",
|
|
484
|
-
ports
|
|
572
|
+
ports,
|
|
573
|
+
environment: resolveConfiguredEnv(config, ports),
|
|
485
574
|
}
|
|
486
575
|
}
|
|
487
576
|
|
|
@@ -489,7 +578,7 @@ function runHook(name, repository, configDocument, worktree, metadata) {
|
|
|
489
578
|
const configuredPath = configDocument.value[name]
|
|
490
579
|
if (!configuredPath) return
|
|
491
580
|
const hook = hookPaths(configDocument, canonical(worktree.path)).find((item) => item.name === name)
|
|
492
|
-
const context = hookContext(repository, worktree, metadata)
|
|
581
|
+
const context = hookContext(repository, configDocument.value, worktree, metadata)
|
|
493
582
|
const env = {
|
|
494
583
|
...process.env,
|
|
495
584
|
GWT_ID: context.id,
|
|
@@ -497,6 +586,7 @@ function runHook(name, repository, configDocument, worktree, metadata) {
|
|
|
497
586
|
GWT_PRIMARY_PATH: context.primaryPath,
|
|
498
587
|
GWT_BRANCH: context.branch,
|
|
499
588
|
...Object.fromEntries(Object.entries(context.ports).map(([key, value]) => [key, String(value)])),
|
|
589
|
+
...context.environment,
|
|
500
590
|
}
|
|
501
591
|
console.log(`Running ${name}...`)
|
|
502
592
|
const result = run(hook.path, [], {
|
|
@@ -632,24 +722,27 @@ async function commandNew(args) {
|
|
|
632
722
|
const base = requestedBase
|
|
633
723
|
? gitOutput(["rev-parse", "--verify", `${requestedBase}^{commit}`], repository.primaryPath)
|
|
634
724
|
: gitOutput(["rev-parse", "HEAD"], repository.primaryPath)
|
|
635
|
-
const target = join(repository
|
|
636
|
-
|
|
725
|
+
const target = join(resolveWorktreeDirectory(repository, configDocument.value), id)
|
|
726
|
+
if (configDocument.value.worktreeDirectory) {
|
|
727
|
+
ensureLocalExclude(repository, configDocument.value.worktreeDirectory)
|
|
728
|
+
}
|
|
637
729
|
|
|
638
730
|
git(["worktree", "add", "-b", branch, target, base], repository.primaryPath, { stdio: "inherit" })
|
|
731
|
+
const targetPath = canonical(target)
|
|
639
732
|
const refreshed = discoverRepository(repository.primaryPath)
|
|
640
|
-
const worktree = refreshed.worktrees.find((item) => resolve(item.path) ===
|
|
733
|
+
const worktree = refreshed.worktrees.find((item) => resolve(item.path) === targetPath)
|
|
641
734
|
|
|
642
735
|
try {
|
|
643
736
|
const metadata = await setupWorktree(refreshed, configDocument, worktree, {
|
|
644
737
|
id,
|
|
645
738
|
noHooks: options["no-hooks"],
|
|
646
739
|
})
|
|
647
|
-
console.log(`Worktree ${metadata.id} is ready at ${
|
|
740
|
+
console.log(`Worktree ${metadata.id} is ready at ${targetPath}`)
|
|
648
741
|
console.log(`Branch: ${branch}`)
|
|
649
742
|
for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
|
|
650
|
-
writeCdDirective(
|
|
743
|
+
writeCdDirective(targetPath)
|
|
651
744
|
} catch (error) {
|
|
652
|
-
console.error(`Setup failed; worktree retained at ${
|
|
745
|
+
console.error(`Setup failed; worktree retained at ${targetPath}`)
|
|
653
746
|
console.error(`Retry: gwt setup ${id}`)
|
|
654
747
|
console.error(`Remove: gwt remove ${id}`)
|
|
655
748
|
throw error
|
|
@@ -806,7 +899,7 @@ async function chooseWorktree(repository) {
|
|
|
806
899
|
}
|
|
807
900
|
|
|
808
901
|
async function commandSwitch(args) {
|
|
809
|
-
if (args.length > 1) throw new CliError("Usage: gwt switch [id|branch|path]")
|
|
902
|
+
if (args.length > 1) throw new CliError("Usage: gwt switch [primary|id|branch|path]")
|
|
810
903
|
const repository = discoverRepository()
|
|
811
904
|
const worktree = args[0] ? resolveWorktree(repository, args[0]) : await chooseWorktree(repository)
|
|
812
905
|
writeCdDirective(canonical(worktree.path))
|
|
@@ -911,7 +1004,7 @@ function commandList(args) {
|
|
|
911
1004
|
}
|
|
912
1005
|
|
|
913
1006
|
function commandInfo(args) {
|
|
914
|
-
if (args.length > 1) throw new CliError("Usage: gwt info [id|branch|path]")
|
|
1007
|
+
if (args.length > 1) throw new CliError("Usage: gwt info [primary|id|branch|path]")
|
|
915
1008
|
const repository = discoverRepository()
|
|
916
1009
|
const worktree = resolveWorktree(repository, args[0])
|
|
917
1010
|
const metadata = metadataForWorktree(repository, worktree)
|
|
@@ -1010,21 +1103,21 @@ function commandTrust(args) {
|
|
|
1010
1103
|
const configDocument = loadConfig(repository)
|
|
1011
1104
|
if (!configDocument.requiresTrust) {
|
|
1012
1105
|
console.log(configDocument.source === "user"
|
|
1013
|
-
? "User
|
|
1106
|
+
? "User configuration is trusted automatically"
|
|
1014
1107
|
: "This repository has no project config to approve")
|
|
1015
1108
|
return
|
|
1016
1109
|
}
|
|
1017
1110
|
const fingerprint = trustFingerprint(repository, configDocument, canonical(current.path))
|
|
1018
1111
|
if (!fingerprint) {
|
|
1019
|
-
console.log("This repository has no project
|
|
1112
|
+
console.log("This repository has no project configuration that requires approval")
|
|
1020
1113
|
return
|
|
1021
1114
|
}
|
|
1022
1115
|
saveTrust(repository, fingerprint)
|
|
1023
|
-
console.log(`Trusted project
|
|
1116
|
+
console.log(`Trusted project configuration for ${repository.primaryPath}`)
|
|
1024
1117
|
}
|
|
1025
1118
|
|
|
1026
1119
|
function configScaffold() {
|
|
1027
|
-
return {
|
|
1120
|
+
return { copyFiles: [], ports: [], env: {} }
|
|
1028
1121
|
}
|
|
1029
1122
|
|
|
1030
1123
|
function commandConfigCreate(args) {
|
|
@@ -1079,6 +1172,7 @@ function commandConfigShow(args) {
|
|
|
1079
1172
|
console.log(`Repository config: ${repositoryConfigured ? "configured" : "not created"}`)
|
|
1080
1173
|
if (repositoryConfigured) console.log(` File: ${projectPath}`)
|
|
1081
1174
|
console.log(`Active config: ${activeLabel}`)
|
|
1175
|
+
console.log(`Worktree directory: ${resolveWorktreeDirectory(repository, active.value)}`)
|
|
1082
1176
|
console.log(JSON.stringify(active.value, null, 2))
|
|
1083
1177
|
}
|
|
1084
1178
|
|
|
@@ -1088,6 +1182,94 @@ function commandConfig(args) {
|
|
|
1088
1182
|
throw new CliError("Usage: gwt config <create [--project]|show>")
|
|
1089
1183
|
}
|
|
1090
1184
|
|
|
1185
|
+
function configuredShellEnvironment() {
|
|
1186
|
+
const insideRepository = git(["rev-parse", "--is-inside-work-tree"], process.cwd(), { allowFailure: true })
|
|
1187
|
+
if (insideRepository.status !== 0) return {}
|
|
1188
|
+
|
|
1189
|
+
const repository = discoverRepository()
|
|
1190
|
+
const worktree = currentWorktree(repository)
|
|
1191
|
+
if (!worktree || resolve(worktree.path) === resolve(repository.primaryPath)) return {}
|
|
1192
|
+
|
|
1193
|
+
const metadata = metadataForWorktree(repository, worktree)
|
|
1194
|
+
if (!metadata) return {}
|
|
1195
|
+
|
|
1196
|
+
const configDocument = loadConfig(repository)
|
|
1197
|
+
if (configDocument.requiresTrust) {
|
|
1198
|
+
const fingerprint = trustFingerprint(repository, configDocument, canonical(worktree.path))
|
|
1199
|
+
if (!isTrusted(repository, fingerprint)) return {}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const ports = Object.fromEntries(configDocument.value.ports.map((name) => {
|
|
1203
|
+
if (!Object.hasOwn(metadata.ports ?? {}, name)) {
|
|
1204
|
+
throw new CliError(`This worktree has no assigned ${name}; recreate it after changing ports`)
|
|
1205
|
+
}
|
|
1206
|
+
return [name, String(metadata.ports[name])]
|
|
1207
|
+
}))
|
|
1208
|
+
return { ...ports, ...resolveConfiguredEnv(configDocument.value, metadata.ports ?? {}) }
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function readShellEnvironmentState() {
|
|
1212
|
+
const encoded = process.env[SHELL_ENV_STATE]
|
|
1213
|
+
if (!encoded) return { originals: {} }
|
|
1214
|
+
|
|
1215
|
+
try {
|
|
1216
|
+
const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"))
|
|
1217
|
+
if (!parsed || typeof parsed.originals !== "object" || Array.isArray(parsed.originals)) throw new Error()
|
|
1218
|
+
const originals = Object.fromEntries(Object.entries(parsed.originals).map(([name, original]) => {
|
|
1219
|
+
if (!ENV_NAME.test(name) || name.startsWith("GWT_")) throw new Error()
|
|
1220
|
+
if (!original || typeof original !== "object" || typeof original.present !== "boolean") throw new Error()
|
|
1221
|
+
if (original.present && typeof original.value !== "string") throw new Error()
|
|
1222
|
+
return [name, original.present ? { present: true, value: original.value } : { present: false }]
|
|
1223
|
+
}))
|
|
1224
|
+
return { originals }
|
|
1225
|
+
} catch {
|
|
1226
|
+
return { originals: {} }
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function quoteZsh(value) {
|
|
1231
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function shellEnvironmentCommands(environment) {
|
|
1235
|
+
const previous = readShellEnvironmentState()
|
|
1236
|
+
const names = new Set([...Object.keys(previous.originals), ...Object.keys(environment)])
|
|
1237
|
+
const originals = {}
|
|
1238
|
+
const commands = []
|
|
1239
|
+
|
|
1240
|
+
for (const name of names) {
|
|
1241
|
+
const original = previous.originals[name] ?? (Object.hasOwn(process.env, name)
|
|
1242
|
+
? { present: true, value: process.env[name] }
|
|
1243
|
+
: { present: false })
|
|
1244
|
+
|
|
1245
|
+
if (Object.hasOwn(environment, name)) {
|
|
1246
|
+
originals[name] = original
|
|
1247
|
+
commands.push(`export ${name}=${quoteZsh(environment[name])}`)
|
|
1248
|
+
} else if (original.present) {
|
|
1249
|
+
commands.push(`export ${name}=${quoteZsh(original.value)}`)
|
|
1250
|
+
} else {
|
|
1251
|
+
commands.push(`unset ${name}`)
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (Object.keys(originals).length === 0) {
|
|
1256
|
+
commands.push(`unset ${SHELL_ENV_STATE}`)
|
|
1257
|
+
} else {
|
|
1258
|
+
const state = Buffer.from(JSON.stringify({ originals })).toString("base64url")
|
|
1259
|
+
commands.push(`export ${SHELL_ENV_STATE}=${quoteZsh(state)}`)
|
|
1260
|
+
}
|
|
1261
|
+
return commands.join("\n")
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function commandShellEnvironment(args) {
|
|
1265
|
+
if (args.length !== 1 || args[0] !== "zsh") throw new CliError("Invalid shell environment request")
|
|
1266
|
+
let environment = {}
|
|
1267
|
+
try {
|
|
1268
|
+
environment = configuredShellEnvironment()
|
|
1269
|
+
} catch {}
|
|
1270
|
+
console.log(shellEnvironmentCommands(environment))
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1091
1273
|
function zshIntegration() {
|
|
1092
1274
|
return `# gwt shell integration for zsh
|
|
1093
1275
|
if command -v gwt >/dev/null 2>&1; then
|
|
@@ -1099,9 +1281,18 @@ if command -v gwt >/dev/null 2>&1; then
|
|
|
1099
1281
|
builtin cd -- "$(<"$cd_file")" || exit_code=$?
|
|
1100
1282
|
fi
|
|
1101
1283
|
rm -f -- "$cd_file"
|
|
1284
|
+
if [[ $exit_code -eq 0 ]]; then
|
|
1285
|
+
_gwt_sync_env
|
|
1286
|
+
fi
|
|
1102
1287
|
return $exit_code
|
|
1103
1288
|
}
|
|
1104
1289
|
|
|
1290
|
+
_gwt_sync_env() {
|
|
1291
|
+
local commands
|
|
1292
|
+
commands="$(command gwt __shell_env zsh 2>/dev/null)" || return 0
|
|
1293
|
+
[[ -n "$commands" ]] && eval "$commands"
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1105
1296
|
_gwt_worktrees() {
|
|
1106
1297
|
local -a targets
|
|
1107
1298
|
targets=("\${(@f)$(command gwt __complete worktrees 2>/dev/null)}")
|
|
@@ -1199,6 +1390,12 @@ if command -v gwt >/dev/null 2>&1; then
|
|
|
1199
1390
|
if (( $+functions[compdef] )); then
|
|
1200
1391
|
compdef _gwt gwt
|
|
1201
1392
|
fi
|
|
1393
|
+
|
|
1394
|
+
typeset -ga chpwd_functions
|
|
1395
|
+
if (( ! \${chpwd_functions[(I)_gwt_sync_env]} )); then
|
|
1396
|
+
chpwd_functions+=(_gwt_sync_env)
|
|
1397
|
+
fi
|
|
1398
|
+
_gwt_sync_env
|
|
1202
1399
|
fi`
|
|
1203
1400
|
}
|
|
1204
1401
|
|
|
@@ -1227,13 +1424,16 @@ stay accurate across versions.
|
|
|
1227
1424
|
|
|
1228
1425
|
## What the help does not make obvious
|
|
1229
1426
|
|
|
1230
|
-
-
|
|
1231
|
-
approved with \`gwt trust\`. Approval
|
|
1232
|
-
hook changes, so a repository that
|
|
1427
|
+
- Ports, environment variables, and hooks declared by a committed \`.gwt.json\`
|
|
1428
|
+
are not applied until the repository is approved with \`gwt trust\`. Approval
|
|
1429
|
+
is invalidated whenever the config or a hook changes, so a repository that
|
|
1430
|
+
worked before can start asking again.
|
|
1233
1431
|
- A failed setup keeps the worktree and records the failure. Retry it with
|
|
1234
1432
|
\`gwt setup <id>\` rather than removing and recreating the worktree.
|
|
1235
|
-
- Ports are assigned per worktree.
|
|
1236
|
-
|
|
1433
|
+
- Ports are assigned per worktree. With shell integration installed, assigned
|
|
1434
|
+
ports and configured environment variables load automatically. Read ports
|
|
1435
|
+
from \`gwt info\` instead of assuming a project default; two worktrees of the
|
|
1436
|
+
same project never share a port.
|
|
1237
1437
|
- \`gwt switch\` changes the shell's directory only when the shell integration is
|
|
1238
1438
|
installed. Otherwise it just prints the path.
|
|
1239
1439
|
- \`gwt switch\` with no target opens an interactive picker, so always pass an
|
|
@@ -1332,13 +1532,14 @@ function commandComplete(args) {
|
|
|
1332
1532
|
|
|
1333
1533
|
if (args[0] === "worktrees") {
|
|
1334
1534
|
const repository = discoverRepository()
|
|
1335
|
-
const values = []
|
|
1535
|
+
const values = ["primary"]
|
|
1336
1536
|
for (const worktree of repository.worktrees) {
|
|
1537
|
+
if (resolve(worktree.path) === resolve(repository.primaryPath)) continue
|
|
1337
1538
|
const metadata = metadataForWorktree(repository, worktree)
|
|
1338
|
-
|
|
1339
|
-
if (
|
|
1539
|
+
const selector = worktree.branch ?? metadata?.id
|
|
1540
|
+
if (selector) values.push(selector)
|
|
1340
1541
|
}
|
|
1341
|
-
console.log(
|
|
1542
|
+
console.log(values.join("\n"))
|
|
1342
1543
|
return
|
|
1343
1544
|
}
|
|
1344
1545
|
|
|
@@ -1377,7 +1578,7 @@ Commands:
|
|
|
1377
1578
|
switch Switch the current shell to a worktree
|
|
1378
1579
|
info Show worktree details and assigned ports
|
|
1379
1580
|
remove Safely remove a worktree and optionally its branch
|
|
1380
|
-
trust Approve or revoke repository project
|
|
1581
|
+
trust Approve or revoke repository project configuration
|
|
1381
1582
|
config Create or inspect configuration
|
|
1382
1583
|
shell Install shell integration
|
|
1383
1584
|
skill Install the gwt skill for coding agents
|
|
@@ -1411,10 +1612,13 @@ Options:
|
|
|
1411
1612
|
|
|
1412
1613
|
Behavior:
|
|
1413
1614
|
The worktree receives an immutable 8-character ID. gwt creates it below the
|
|
1414
|
-
|
|
1615
|
+
resolved worktree directory, copies configured local files, assigns stable
|
|
1415
1616
|
ports, and runs postCreate. A setup failure keeps the worktree so setup can
|
|
1416
1617
|
be retried. With shell integration installed, the current shell moves into
|
|
1417
|
-
the new worktree after setup succeeds.
|
|
1618
|
+
the new worktree after setup succeeds. Without worktreeDirectory, the default
|
|
1619
|
+
is $GWT_HOME/worktrees or ~/.gwt/worktrees when GWT_HOME is not set. A short
|
|
1620
|
+
repository hash is added to the directory name only when needed to avoid a
|
|
1621
|
+
name collision.
|
|
1418
1622
|
|
|
1419
1623
|
Examples:
|
|
1420
1624
|
gwt new feature/auth
|
|
@@ -1457,36 +1661,40 @@ Example:
|
|
|
1457
1661
|
switch: `Switch the current shell to another worktree.
|
|
1458
1662
|
|
|
1459
1663
|
Usage:
|
|
1460
|
-
gwt switch [id|branch|path]
|
|
1664
|
+
gwt switch [primary|id|branch|path]
|
|
1461
1665
|
|
|
1462
1666
|
Arguments:
|
|
1463
|
-
|
|
1667
|
+
selector Worktree to switch to. Opens the picker when omitted.
|
|
1464
1668
|
|
|
1465
1669
|
Options:
|
|
1466
1670
|
-h, --help Show help for this command.
|
|
1467
1671
|
|
|
1468
1672
|
Behavior:
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1673
|
+
'primary' is the reserved ID for the primary worktree. Other worktrees can be
|
|
1674
|
+
selected by ID, exact branch name, or path. The picker supports arrow keys,
|
|
1675
|
+
j/k, Ctrl-n/Ctrl-p, and '/' filtering. Shell integration must be installed for
|
|
1676
|
+
gwt to change the parent shell's directory; otherwise the path is only printed.
|
|
1472
1677
|
|
|
1473
1678
|
Examples:
|
|
1474
1679
|
gwt switch
|
|
1680
|
+
gwt switch primary
|
|
1475
1681
|
gwt switch feature/auth
|
|
1476
1682
|
gwt switch a1b2c3d4`,
|
|
1477
1683
|
info: `Show a worktree's identity, Git state, setup status, and assigned ports.
|
|
1478
1684
|
|
|
1479
1685
|
Usage:
|
|
1480
|
-
gwt info [id|branch|path]
|
|
1686
|
+
gwt info [primary|id|branch|path]
|
|
1481
1687
|
|
|
1482
1688
|
Arguments:
|
|
1483
|
-
|
|
1689
|
+
selector 'primary', an ID, an exact branch name, or a path. Defaults
|
|
1690
|
+
to the current worktree.
|
|
1484
1691
|
|
|
1485
1692
|
Options:
|
|
1486
1693
|
-h, --help Show help for this command.
|
|
1487
1694
|
|
|
1488
1695
|
Examples:
|
|
1489
1696
|
gwt info
|
|
1697
|
+
gwt info primary
|
|
1490
1698
|
gwt info feature/auth`,
|
|
1491
1699
|
remove: `Safely remove a linked worktree and, by default, its branch.
|
|
1492
1700
|
|
|
@@ -1515,7 +1723,7 @@ Examples:
|
|
|
1515
1723
|
gwt remove
|
|
1516
1724
|
gwt remove feature/auth --keep-branch
|
|
1517
1725
|
gwt remove a1b2c3d4 --discard --yes`,
|
|
1518
|
-
trust: `Approve or revoke
|
|
1726
|
+
trust: `Approve or revoke active configuration declared by the repository's .gwt.json.
|
|
1519
1727
|
|
|
1520
1728
|
Usage:
|
|
1521
1729
|
gwt trust [--revoke]
|
|
@@ -1524,9 +1732,10 @@ Options:
|
|
|
1524
1732
|
--revoke Remove the stored approval for this repository.
|
|
1525
1733
|
-h, --help Show help for this command.
|
|
1526
1734
|
|
|
1527
|
-
Approval is
|
|
1528
|
-
|
|
1529
|
-
|
|
1735
|
+
Approval is required before repository-defined ports or environment variables
|
|
1736
|
+
are applied, or repository hooks run. It is tied to the configuration and hook
|
|
1737
|
+
contents, so changing either requires approval again. User configuration is
|
|
1738
|
+
trusted automatically.
|
|
1530
1739
|
|
|
1531
1740
|
Examples:
|
|
1532
1741
|
gwt trust
|
|
@@ -1579,8 +1788,8 @@ Options:
|
|
|
1579
1788
|
-h, --help Show help for this command.
|
|
1580
1789
|
|
|
1581
1790
|
The output distinguishes a missing user config file from an existing file that
|
|
1582
|
-
does not configure the current project.
|
|
1583
|
-
precedence over user configuration.
|
|
1791
|
+
does not configure the current project. It also shows the resolved worktree
|
|
1792
|
+
directory. Repository configuration takes precedence over user configuration.
|
|
1584
1793
|
|
|
1585
1794
|
Example:
|
|
1586
1795
|
gwt config show`,
|
|
@@ -1593,7 +1802,8 @@ Options:
|
|
|
1593
1802
|
-h, --help Show help for this command.
|
|
1594
1803
|
|
|
1595
1804
|
The integration lets gwt change the current shell's directory after new,
|
|
1596
|
-
switch, and removal of the current worktree. It also
|
|
1805
|
+
switch, and removal of the current worktree. It also loads the worktree's
|
|
1806
|
+
assigned ports and configured environment, and installs completion.
|
|
1597
1807
|
|
|
1598
1808
|
Example:
|
|
1599
1809
|
gwt shell install zsh`,
|
|
@@ -1608,7 +1818,9 @@ Options:
|
|
|
1608
1818
|
-h, --help Show help for this command.
|
|
1609
1819
|
|
|
1610
1820
|
The command adds one initialization line to ~/.zshrc, or to $ZDOTDIR/.zshrc
|
|
1611
|
-
when ZDOTDIR is set. Restart Zsh or source the file after installation.
|
|
1821
|
+
when ZDOTDIR is set. Restart Zsh or source the file after installation. The
|
|
1822
|
+
integration updates the environment when Zsh starts or changes directory and
|
|
1823
|
+
restores previous values after leaving a managed worktree.
|
|
1612
1824
|
|
|
1613
1825
|
Examples:
|
|
1614
1826
|
gwt shell install zsh
|
|
@@ -1685,6 +1897,7 @@ async function main() {
|
|
|
1685
1897
|
if (command === "shell") return commandShell(args)
|
|
1686
1898
|
if (command === "skill") return commandSkill(args)
|
|
1687
1899
|
if (command === "__complete") return commandComplete(args)
|
|
1900
|
+
if (command === "__shell_env") return commandShellEnvironment(args)
|
|
1688
1901
|
throw new CliError(`Unknown command: ${command}`)
|
|
1689
1902
|
}
|
|
1690
1903
|
|