@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.
Files changed (3) hide show
  1. package/README.md +54 -41
  2. package/bin/gwt.mjs +725 -91
  3. 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 DEFAULT_CONFIG = { copyFiles: [], ports: [] }
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 resolveWorktree(repository, selector, options = {}) {
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
- const pathMatch = repository.worktrees.find((worktree) => resolve(worktree.path) === candidatePath)
375
- if (pathMatch) return pathMatch
432
+ return repository.worktrees.find((worktree) => resolve(worktree.path) === candidatePath) ?? null
433
+ }
376
434
 
377
- throw new CliError(`No worktree matches '${selector}'`)
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 run:")
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 hooks are not trusted. Run 'gwt trust' to approve them")
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: metadata?.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 validateBranch(branch, cwd) {
734
+ function isBranchName(branch, cwd) {
640
735
  const result = git(["check-ref-format", "--branch", branch], cwd, { allowFailure: true })
641
- if (result.status !== 0) throw new CliError(`Invalid branch name: ${branch}`)
642
- const exists = git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd, { allowFailure: true }).status === 0
643
- if (exists) throw new CliError(`Branch already exists: ${branch}`)
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 commandNew(args) {
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 branch = positionals[0] ?? `scratch/${id}`
679
- validateBranch(branch, repository.primaryPath)
680
- const requestedBase = options.base ?? configDocument.value.base
681
- const base = requestedBase
682
- ? gitOutput(["rev-parse", "--verify", `${requestedBase}^{commit}`], repository.primaryPath)
683
- : gitOutput(["rev-parse", "HEAD"], repository.primaryPath)
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(["worktree", "add", "-b", branch, target, base], repository.primaryPath, { stdio: "inherit" })
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["no-hooks"],
864
+ noHooks: options.noHooks,
865
+ background: options.background,
866
+ scratchBranch: scratch ? branch : undefined,
698
867
  })
699
- console.log(`Worktree ${metadata.id} is ready at ${targetPath}`)
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?.setup ?? (index === 0 ? "-" : "unmanaged"),
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
- if (args.length > 1) throw new CliError("Usage: gwt switch [primary|id|branch|path]")
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 worktree = args[0] ? resolveWorktree(repository, args[0]) : await chooseWorktree(repository)
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?.setup ?? "unmanaged"}`)
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
- const metadata = await setupWorktree(repository, configDocument, worktree, { noHooks: options["no-hooks"] })
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
- await ensureTrusted(repository, configDocument, targetPath)
1017
- runHook("preRemove", repository, configDocument, worktree, metadata)
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 config hooks are trusted automatically"
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 hooks to approve")
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 hooks for ${repository.primaryPath}`)
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 targets
1160
- targets=("\${(@f)$(command gwt __complete worktrees 2>/dev/null)}")
1161
- compadd -a targets
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|info)
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
- - Hooks declared by a committed \`.gwt.json\` do not run until the repository is
1284
- approved with \`gwt trust\`. Approval is invalidated whenever the config or a
1285
- hook changes, so a repository that worked before can start asking again.
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. Read them from \`gwt info\` instead of assuming
1289
- a project default; two worktrees of the same project never share a port.
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 is destructive
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 values = ["primary"]
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
- if (metadata?.id) values.push(metadata.id)
1392
- if (worktree.branch) values.push(worktree.branch)
1944
+ const id = metadata?.id ?? ""
1945
+ const branch = worktree.branch ?? ""
1946
+ if (id || branch) lines.push(`${id}\t${branch}`)
1393
1947
  }
1394
- console.log([...new Set(values)].join("\n"))
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 topic = [command, subcommand].filter(Boolean).join(" ")
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 assigned ports
1987
+ info Show worktree details, ports, and environment
1432
1988
  remove Safely remove a worktree and optionally its branch
1433
- trust Approve or revoke repository project hooks
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 New local branch name. Defaults to scratch/<id>.
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> Start from this Git revision instead of the configured base
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 experiment --no-hooks`,
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
- Example:
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
- info: `Show a worktree's identity, Git state, setup status, and assigned ports.
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
- trust: `Approve or revoke hooks declared by the repository's .gwt.json.
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 tied to the configuration and hook contents, so changing either
1588
- requires approval again. Hooks declared in user configuration are trusted
1589
- automatically.
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 installs completion.
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