@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.
Files changed (3) hide show
  1. package/README.md +43 -17
  2. package/bin/gwt.mjs +267 -54
  3. 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 for commands,
33
- options, worktrees, and Git refs. It only changes directories; it does not load
34
- environment variables or run project hooks.
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 beneath
137
- `.worktrees`, use the primary worktree's current commit as their base, and run
138
- no setup actions.
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`: Repository-relative directory for managed worktrees.
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
- The worktree directory is added to `.git/info/exclude`; tracked project files
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
- Hooks in user config are trusted because the user added them directly. Hooks
180
- from a committed `.gwt.json` require explicit trust because they execute
181
- repository code:
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 DEFAULT_CONFIG = { worktreeDirectory: ".worktrees", copyFiles: [], ports: [] }
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
- const worktreeDirectory = validateRelativePath(parsed.worktreeDirectory ?? ".worktrees", "worktreeDirectory")
140
- if (worktreeDirectory.split(/[\\/]+/).some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) {
141
- throw new CliError("worktreeDirectory can only contain letters, digits, '.', '_', '-', and path separators")
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
- return {
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(repository.primaryPath, config.worktreeDirectory, id)
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 run:")
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 hooks are not trusted. Run 'gwt trust' to approve them")
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: metadata?.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.primaryPath, configDocument.value.worktreeDirectory, id)
636
- ensureLocalExclude(repository, configDocument.value.worktreeDirectory)
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) === resolve(target))
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 ${target}`)
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(target)
743
+ writeCdDirective(targetPath)
651
744
  } catch (error) {
652
- console.error(`Setup failed; worktree retained at ${target}`)
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 config hooks are trusted automatically"
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 hooks to approve")
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 hooks for ${repository.primaryPath}`)
1116
+ console.log(`Trusted project configuration for ${repository.primaryPath}`)
1024
1117
  }
1025
1118
 
1026
1119
  function configScaffold() {
1027
- return { worktreeDirectory: ".worktrees", copyFiles: [], ports: [] }
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
- - Hooks declared by a committed \`.gwt.json\` do not run until the repository is
1231
- approved with \`gwt trust\`. Approval is invalidated whenever the config or a
1232
- hook changes, so a repository that worked before can start asking again.
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. Read them from \`gwt info\` instead of assuming
1236
- a project default; two worktrees of the same project never share a port.
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
- if (metadata?.id) values.push(metadata.id)
1339
- if (worktree.branch) values.push(worktree.branch)
1539
+ const selector = worktree.branch ?? metadata?.id
1540
+ if (selector) values.push(selector)
1340
1541
  }
1341
- console.log([...new Set(values)].join("\n"))
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 hooks
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
- configured worktreeDirectory, copies configured local files, assigns stable
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
- id|branch|path Worktree to switch to. Opens the picker when omitted.
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
- The picker supports arrow keys, j/k, Ctrl-n/Ctrl-p, and '/' filtering. Shell
1470
- integration must be installed for gwt to change the parent shell's directory;
1471
- otherwise the selected path is only printed.
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
- id|branch|path Worktree to inspect. Defaults to the current worktree.
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 hooks declared by the repository's .gwt.json.
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 tied to the configuration and hook contents, so changing either
1528
- requires approval again. Hooks declared in user configuration are trusted
1529
- automatically.
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. Repository configuration takes
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 installs completion.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junheep/gwt",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Lightweight native Git worktree workflows",
5
5
  "license": "MIT",
6
6
  "author": "Junhee Park",