@junheep/gwt 0.4.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 +39 -39
  2. package/bin/gwt.mjs +561 -80
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,10 +29,11 @@ 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 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.
32
+ confirmation. The integration also provides Zsh completion, which lists one
33
+ row per worktree and completes worktree IDs as well as the branch names it
34
+ shows. It also automatically loads assigned ports and configured environment
35
+ variables when Zsh enters a managed worktree. Previous values are restored
36
+ when Zsh leaves it. Normal environment synchronization produces no output.
36
37
 
37
38
  ## Coding agents
38
39
 
@@ -57,14 +58,10 @@ the installed skill is identical:
57
58
  `--project` writes into the primary worktree so the skill can be committed for
58
59
  the team.
59
60
 
60
- The skill covers what `gwt --help` does not: that gwt is preferred over native
61
- `git worktree`, that project hooks need `gwt trust`, that a failed setup is
62
- retried rather than recreated, that ports come from `gwt info`, and that
63
- removal is destructive. It points at `gwt <command> --help` for command
64
- details instead of repeating them, so it does not go stale as gwt changes.
65
- Reinstall after upgrading to pick up a revised skill; an unchanged file is
66
- reported as already installed, and a modified one is replaced only after
67
- confirmation.
61
+ The skill covers what the help does not: when to prefer gwt over native
62
+ `git worktree`, which commands are destructive, and the traps worth knowing. It
63
+ points at `gwt <command> --help` for command details rather than repeating
64
+ them. Reinstall after upgrading gwt to pick up a revised skill.
68
65
 
69
66
  ## Configuration
70
67
 
@@ -208,17 +205,21 @@ approval because they execute code:
208
205
  gwt trust
209
206
  ```
210
207
 
211
- Approval is invalidated when `.gwt.json` or either hook changes.
208
+ Approval is invalidated when `.gwt.json` or either hook changes. `gwt remove`
209
+ asks for approval only when `preRemove` is configured, because removal applies
210
+ nothing else from the configuration.
212
211
 
213
212
  ## Commands
214
213
 
215
214
  ```sh
216
- gwt new [branch] [--base <ref>] [--no-hooks]
217
- gwt setup [id|branch|path] [--no-hooks]
215
+ gwt new [branch] [--base <ref>] [--no-hooks] [--background]
216
+ gwt setup [id|branch|path] [--no-hooks] [--background]
218
217
  gwt list
219
- gwt switch [primary|id|branch|path]
218
+ gwt ls
219
+ gwt switch [primary|id|branch|path] [--create]
220
220
  gwt info [primary|id|branch|path]
221
221
  gwt remove [id|branch|path] [--keep-branch|--discard] [--yes] [--no-hooks]
222
+ gwt prune [--dry-run] [--yes]
222
223
  gwt trust [--revoke]
223
224
  gwt config create [--project]
224
225
  gwt config show
@@ -226,26 +227,25 @@ gwt shell install zsh [--dry-run] [--yes]
226
227
  gwt skill install <claude|codex> [--project] [--dry-run] [--yes]
227
228
  ```
228
229
 
229
- Run `gwt --help` for the command overview, or `gwt <command> --help` for
230
- behavior, options, and practical examples. Nested commands such as
231
- `gwt config create --help` have their own help as well.
232
-
233
- `gwt new` creates `scratch/<id>` when no branch is provided. The immutable ID,
234
- assigned ports, and setup status are stored under the repository's common Git
235
- directory at `.git/gwt/worktrees/`.
236
-
237
- Setup failures retain the worktree and record the failure. Retry with
238
- `gwt setup <id>` or remove it explicitly.
239
-
240
- Run `gwt switch` without a target to open the interactive picker. Use the
241
- arrow keys, `j`/`k`, or Ctrl-n/Ctrl-p to move; press `/` to filter by branch,
242
- ID, or path. Enter switches to the selected worktree. Escape leaves filter
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.
246
-
247
- `gwt remove` refuses dirty worktrees and first tries to delete the branch with
248
- `git branch -d`. If Git rejects safe deletion, an interactive terminal asks
249
- whether to force-delete the branch; non-interactive use keeps it and prints a
250
- command for deleting it later. `--discard --yes` explicitly allows dirty
251
- worktree removal and forced branch deletion.
230
+ Run `gwt --help` for the overview, or `gwt <command> --help` for arguments,
231
+ options, behavior, and examples. Nested commands such as `gwt config create
232
+ --help` have their own help.
233
+
234
+ What the listing does not show:
235
+
236
+ - **Branches.** `gwt new` without an argument creates `scratch/<id>`. With one,
237
+ it reuses an existing local branch, or creates a branch tracking the remote
238
+ when the name exists on exactly one remote; `--base` always means "new
239
+ branch". `gwt switch <branch>` offers to create a worktree for a branch that
240
+ has none, and `--create` skips the question when running non-interactively.
241
+ - **Background setup.** `--background` detaches `postCreate` and returns once
242
+ files are copied and ports assigned, logging to `.git/gwt/logs/<id>.log`.
243
+ `gwt list` reports the worktree as `running`, or `interrupted` if the process
244
+ disappears.
245
+ - **Cleanup.** `gwt remove` also deletes the `scratch/<id>` branch a worktree
246
+ moved off, but only when the branch it moved to already contains those
247
+ commits. `gwt prune` clears records left by worktrees removed outside gwt,
248
+ including their scratch branches; it reports what it found and asks first.
249
+ - **State.** IDs, assigned ports, and setup status live in
250
+ `.git/gwt/worktrees/`. `gwt info` prints a worktree's ports and environment
251
+ exactly as the shell integration loads them.
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,6 +29,7 @@ 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
34
  const SHELL_ENV_STATE = "GWT_SHELL_ENV_STATE"
32
35
  const DEFAULT_CONFIG = { copyFiles: [], ports: [], env: {} }
@@ -348,6 +351,25 @@ function metadataPath(repository, id) {
348
351
  return join(metadataDirectory(repository), `${id}.json`)
349
352
  }
350
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
+
351
373
  function readJson(path) {
352
374
  try {
353
375
  return JSON.parse(readFileSync(path, "utf8"))
@@ -382,11 +404,11 @@ function metadataForWorktree(repository, worktree) {
382
404
  function currentWorktree(repository, cwd = process.cwd()) {
383
405
  const resolvedCwd = canonical(cwd)
384
406
  return repository.worktrees
385
- .filter((worktree) => isInside(canonical(worktree.path), resolvedCwd))
407
+ .filter((worktree) => pathExists(worktree.path) && isInside(canonical(worktree.path), resolvedCwd))
386
408
  .sort((left, right) => right.path.length - left.path.length)[0] ?? null
387
409
  }
388
410
 
389
- function resolveWorktree(repository, selector, options = {}) {
411
+ function findWorktree(repository, selector, options = {}) {
390
412
  if (!selector) {
391
413
  const current = currentWorktree(repository)
392
414
  if (!current) throw new CliError("The current directory is not inside a registered worktree")
@@ -407,10 +429,13 @@ function resolveWorktree(repository, selector, options = {}) {
407
429
  if (branchMatch) return branchMatch
408
430
 
409
431
  const candidatePath = resolve(options.cwd ?? process.cwd(), selector)
410
- const pathMatch = repository.worktrees.find((worktree) => resolve(worktree.path) === candidatePath)
411
- if (pathMatch) return pathMatch
432
+ return repository.worktrees.find((worktree) => resolve(worktree.path) === candidatePath) ?? null
433
+ }
412
434
 
413
- 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
414
439
  }
415
440
 
416
441
  function generateId(repository, config) {
@@ -621,6 +646,33 @@ function updateMetadata(repository, metadata, update) {
621
646
  return next
622
647
  }
623
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
+
624
676
  async function setupWorktree(repository, configDocument, worktree, options = {}) {
625
677
  if (resolve(worktree.path) === resolve(repository.primaryPath)) throw new CliError("The primary worktree does not need setup")
626
678
  const targetPath = canonical(worktree.path)
@@ -638,6 +690,7 @@ async function setupWorktree(repository, configDocument, worktree, options = {})
638
690
  createdAt: new Date().toISOString(),
639
691
  updatedAt: new Date().toISOString(),
640
692
  }
693
+ if (options.scratchBranch) metadata.scratchBranch = options.scratchBranch
641
694
  writeJson(metadataPath(repository, id), metadata)
642
695
  }
643
696
 
@@ -648,11 +701,12 @@ async function setupWorktree(repository, configDocument, worktree, options = {})
648
701
  return metadata
649
702
  }
650
703
  await ensureTrusted(repository, configDocument, targetPath)
704
+ if (options.background && configDocument.value.postCreate) return startBackgroundHook(repository, metadata)
651
705
  runHook("postCreate", repository, configDocument, worktree, metadata)
652
- metadata = updateMetadata(repository, metadata, { setup: "complete" })
706
+ metadata = updateMetadata(repository, metadata, { setup: "complete", setupError: undefined, job: undefined })
653
707
  return metadata
654
708
  } catch (error) {
655
- updateMetadata(repository, metadata, { setup: "failed", setupError: error.message })
709
+ updateMetadata(repository, metadata, { setup: "failed", setupError: error.message, job: undefined })
656
710
  throw error
657
711
  }
658
712
  }
@@ -677,11 +731,73 @@ function ensureLocalExclude(repository, directory) {
677
731
  writeFileSync(infoExclude, `${current}${separator}${pattern}\n`)
678
732
  }
679
733
 
680
- function validateBranch(branch, cwd) {
734
+ function isBranchName(branch, cwd) {
681
735
  const result = git(["check-ref-format", "--branch", branch], cwd, { allowFailure: true })
682
- if (result.status !== 0) throw new CliError(`Invalid branch name: ${branch}`)
683
- const exists = git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd, { allowFailure: true }).status === 0
684
- 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}`
685
801
  }
686
802
 
687
803
  function writeCdDirective(path) {
@@ -709,25 +825,35 @@ function parseOptions(args, definitions = {}) {
709
825
  return { options, positionals }
710
826
  }
711
827
 
712
- async function commandNew(args) {
713
- const { options, positionals } = parseOptions(args, { "--base": "value", "--no-hooks": "boolean" })
714
- if (positionals.length > 1) throw new CliError("Usage: gwt new [branch] [--base <ref>] [--no-hooks]")
715
- const repository = discoverRepository()
716
- const configDocument = loadConfig(repository)
828
+ async function createWorktree(repository, configDocument, options = {}) {
717
829
  ensureCopySources(repository, configDocument.value)
718
830
  const id = generateId(repository, configDocument.value)
719
- const branch = positionals[0] ?? `scratch/${id}`
720
- validateBranch(branch, repository.primaryPath)
721
- const requestedBase = options.base ?? configDocument.value.base
722
- const base = requestedBase
723
- ? gitOutput(["rev-parse", "--verify", `${requestedBase}^{commit}`], repository.primaryPath)
724
- : 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
+
725
851
  const target = join(resolveWorktreeDirectory(repository, configDocument.value), id)
726
852
  if (configDocument.value.worktreeDirectory) {
727
853
  ensureLocalExclude(repository, configDocument.value.worktreeDirectory)
728
854
  }
729
855
 
730
- git(["worktree", "add", "-b", branch, target, base], repository.primaryPath, { stdio: "inherit" })
856
+ git(worktreeAddArguments(target, resolution, base), repository.primaryPath, { stdio: "inherit" })
731
857
  const targetPath = canonical(target)
732
858
  const refreshed = discoverRepository(repository.primaryPath)
733
859
  const worktree = refreshed.worktrees.find((item) => resolve(item.path) === targetPath)
@@ -735,12 +861,11 @@ async function commandNew(args) {
735
861
  try {
736
862
  const metadata = await setupWorktree(refreshed, configDocument, worktree, {
737
863
  id,
738
- noHooks: options["no-hooks"],
864
+ noHooks: options.noHooks,
865
+ background: options.background,
866
+ scratchBranch: scratch ? branch : undefined,
739
867
  })
740
- console.log(`Worktree ${metadata.id} is ready at ${targetPath}`)
741
- console.log(`Branch: ${branch}`)
742
- for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
743
- writeCdDirective(targetPath)
868
+ return { metadata, targetPath, resolution }
744
869
  } catch (error) {
745
870
  console.error(`Setup failed; worktree retained at ${targetPath}`)
746
871
  console.error(`Retry: gwt setup ${id}`)
@@ -749,6 +874,35 @@ async function commandNew(args) {
749
874
  }
750
875
  }
751
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
+
752
906
  function linkedWorktreeRows(repository, metadata = loadMetadata(repository)) {
753
907
  const current = currentWorktree(repository)
754
908
  return repository.worktrees.map((worktree, index) => {
@@ -758,7 +912,7 @@ function linkedWorktreeRows(repository, metadata = loadMetadata(repository)) {
758
912
  current: current && resolve(current.path) === resolve(worktree.path),
759
913
  id: item?.id ?? (index === 0 ? "primary" : "-"),
760
914
  branch: worktree.branch ?? "(detached)",
761
- setup: item?.setup ?? (index === 0 ? "-" : "unmanaged"),
915
+ setup: jobStatus(item) ?? (index === 0 ? "-" : "unmanaged"),
762
916
  path: worktree.path,
763
917
  }
764
918
  })
@@ -898,10 +1052,40 @@ async function chooseWorktree(repository) {
898
1052
  })
899
1053
  }
900
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
+
901
1076
  async function commandSwitch(args) {
902
- 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]")
903
1079
  const repository = discoverRepository()
904
- 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
+
905
1089
  writeCdDirective(canonical(worktree.path))
906
1090
  console.log(canonical(worktree.path))
907
1091
  }
@@ -1009,23 +1193,45 @@ function commandInfo(args) {
1009
1193
  const worktree = resolveWorktree(repository, args[0])
1010
1194
  const metadata = metadataForWorktree(repository, worktree)
1011
1195
  console.log(`ID: ${metadata?.id ?? (resolve(worktree.path) === resolve(repository.primaryPath) ? "primary" : "unmanaged")}`)
1012
- console.log(`Path: ${canonical(worktree.path)}`)
1196
+ console.log(`Path: ${pathExists(worktree.path) ? canonical(worktree.path) : resolve(worktree.path)}`)
1013
1197
  console.log(`Branch: ${worktree.branch ?? "(detached)"}`)
1014
1198
  console.log(`HEAD: ${worktree.head}`)
1015
- console.log(`Setup: ${metadata?.setup ?? "unmanaged"}`)
1199
+ console.log(`Setup: ${jobStatus(metadata) ?? "unmanaged"}`)
1016
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
+ }
1017
1212
  if (metadata?.setupError) console.log(`Setup error: ${metadata.setupError}`)
1018
1213
  }
1019
1214
 
1020
1215
  async function commandSetup(args) {
1021
- const { options, positionals } = parseOptions(args, { "--no-hooks": "boolean" })
1022
- 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")
1023
1219
  const repository = discoverRepository()
1024
1220
  const configDocument = loadConfig(repository)
1025
1221
  const worktree = resolveWorktree(repository, positionals[0])
1026
- 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
+ })
1027
1232
  console.log(`Setup ${metadata.setup}: ${metadata.id}`)
1028
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}`)
1029
1235
  }
1030
1236
 
1031
1237
  async function confirmDiscard(worktree) {
@@ -1033,6 +1239,31 @@ async function confirmDiscard(worktree) {
1033
1239
  return ask("Type yes to continue [y/N] ")
1034
1240
  }
1035
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
+
1036
1267
  async function commandRemove(args) {
1037
1268
  const { options, positionals } = parseOptions(args, {
1038
1269
  "--keep-branch": "boolean",
@@ -1047,6 +1278,10 @@ async function commandRemove(args) {
1047
1278
  if (resolve(worktree.path) === resolve(repository.primaryPath)) throw new CliError("The primary worktree cannot be removed")
1048
1279
  const targetPath = canonical(worktree.path)
1049
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)
1050
1285
  const dirty = git(["status", "--porcelain"], worktree.path).stdout.length > 0
1051
1286
  if (dirty && !options.discard) throw new CliError("Worktree has uncommitted changes; commit them or use --discard")
1052
1287
  if (options.discard && !options.yes && !(await confirmDiscard(worktree))) throw new CliError("Removal cancelled")
@@ -1054,8 +1289,10 @@ async function commandRemove(args) {
1054
1289
 
1055
1290
  if (!options["no-hooks"]) {
1056
1291
  const configDocument = loadConfig(repository)
1057
- await ensureTrusted(repository, configDocument, targetPath)
1058
- 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
+ }
1059
1296
  }
1060
1297
 
1061
1298
  const removeArgs = ["worktree", "remove"]
@@ -1064,6 +1301,10 @@ async function commandRemove(args) {
1064
1301
  console.log(`Removing worktree ${metadata?.id ?? targetPath}...`)
1065
1302
  git(removeArgs, repository.primaryPath)
1066
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
+ }
1067
1308
 
1068
1309
  let branchMessage = "No branch to delete"
1069
1310
  if (worktree.branch && !options["keep-branch"]) {
@@ -1087,9 +1328,87 @@ async function commandRemove(args) {
1087
1328
 
1088
1329
  console.log(`Removed worktree: ${metadata?.id ?? targetPath}`)
1089
1330
  console.log(branchMessage)
1331
+ const scratchMessage = await removeScratchBranch(repository, metadata, worktree, options, scratchContained)
1332
+ if (scratchMessage) console.log(scratchMessage)
1090
1333
  if (wasCurrent) writeCdDirective(repository.primaryPath)
1091
1334
  }
1092
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
+
1093
1412
  function commandTrust(args) {
1094
1413
  const { options, positionals } = parseOptions(args, { "--revoke": "boolean" })
1095
1414
  if (positionals.length > 0) throw new CliError("Usage: gwt trust [--revoke]")
@@ -1182,21 +1501,21 @@ function commandConfig(args) {
1182
1501
  throw new CliError("Usage: gwt config <create [--project]|show>")
1183
1502
  }
1184
1503
 
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 {}
1504
+ function worktreeEnvironment(repository, worktree) {
1505
+ if (resolve(worktree.path) === resolve(repository.primaryPath)) return { applied: false, reason: null, values: {} }
1192
1506
 
1193
1507
  const metadata = metadataForWorktree(repository, worktree)
1194
- if (!metadata) return {}
1508
+ if (!metadata) return { applied: false, reason: null, values: {} }
1195
1509
 
1196
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
+ }
1197
1514
  if (configDocument.requiresTrust) {
1198
1515
  const fingerprint = trustFingerprint(repository, configDocument, canonical(worktree.path))
1199
- if (!isTrusted(repository, fingerprint)) return {}
1516
+ if (!isTrusted(repository, fingerprint)) {
1517
+ return { applied: false, reason: "not applied until 'gwt trust' approves this repository's configuration", values: {} }
1518
+ }
1200
1519
  }
1201
1520
 
1202
1521
  const ports = Object.fromEntries(configDocument.value.ports.map((name) => {
@@ -1205,7 +1524,17 @@ function configuredShellEnvironment() {
1205
1524
  }
1206
1525
  return [name, String(metadata.ports[name])]
1207
1526
  }))
1208
- return { ...ports, ...resolveConfiguredEnv(configDocument.value, metadata.ports ?? {}) }
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
1209
1538
  }
1210
1539
 
1211
1540
  function readShellEnvironmentState() {
@@ -1294,9 +1623,21 @@ if command -v gwt >/dev/null 2>&1; then
1294
1623
  }
1295
1624
 
1296
1625
  _gwt_worktrees() {
1297
- local -a targets
1298
- targets=("\${(@f)$(command gwt __complete worktrees 2>/dev/null)}")
1299
- 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
1300
1641
  }
1301
1642
 
1302
1643
  _gwt_refs() {
@@ -1311,9 +1652,11 @@ if command -v gwt >/dev/null 2>&1; then
1311
1652
  'new:Create and set up a worktree'
1312
1653
  'setup:Set up an existing worktree'
1313
1654
  'list:List worktrees'
1655
+ 'ls:List worktrees'
1314
1656
  'switch:Switch to a worktree'
1315
1657
  'info:Show worktree details'
1316
1658
  'remove:Remove a worktree'
1659
+ 'prune:Clean up records of removed worktrees'
1317
1660
  'trust:Approve project hooks'
1318
1661
  'config:Manage user and project configuration'
1319
1662
  'shell:Install shell integration'
@@ -1328,21 +1671,29 @@ if command -v gwt >/dev/null 2>&1; then
1328
1671
  case "$words[2]" in
1329
1672
  new)
1330
1673
  _arguments \
1331
- '2:branch name:' \
1674
+ '2:branch name:_gwt_refs' \
1332
1675
  '--base[base Git revision]:revision:_gwt_refs' \
1333
1676
  '--no-hooks[skip project hooks]' \
1677
+ '--background[run postCreate in the background]' \
1334
1678
  '(-h --help)'{-h,--help}'[show help]'
1335
1679
  ;;
1336
1680
  setup)
1337
1681
  _arguments \
1338
1682
  '2:worktree:_gwt_worktrees' \
1339
1683
  '--no-hooks[skip project hooks]' \
1684
+ '--background[run postCreate in the background]' \
1340
1685
  '(-h --help)'{-h,--help}'[show help]'
1341
1686
  ;;
1342
- list)
1687
+ list|ls)
1343
1688
  _arguments '(-h --help)'{-h,--help}'[show help]'
1344
1689
  ;;
1345
- 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)
1346
1697
  _arguments \
1347
1698
  '2:worktree:_gwt_worktrees' \
1348
1699
  '(-h --help)'{-h,--help}'[show help]'
@@ -1356,6 +1707,12 @@ if command -v gwt >/dev/null 2>&1; then
1356
1707
  '--no-hooks[skip project hooks]' \
1357
1708
  '(-h --help)'{-h,--help}'[show help]'
1358
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
+ ;;
1359
1716
  trust)
1360
1717
  _arguments \
1361
1718
  '--revoke[revoke project hook approval]' \
@@ -1428,22 +1785,37 @@ stay accurate across versions.
1428
1785
  are not applied until the repository is approved with \`gwt trust\`. Approval
1429
1786
  is invalidated whenever the config or a hook changes, so a repository that
1430
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.
1431
1792
  - A failed setup keeps the worktree and records the failure. Retry it with
1432
1793
  \`gwt setup <id>\` rather than removing and recreating the worktree.
1433
1794
  - 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.
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.
1437
1801
  - \`gwt switch\` changes the shell's directory only when the shell integration is
1438
1802
  installed. Otherwise it just prints the path.
1439
1803
  - \`gwt switch\` with no target opens an interactive picker, so always pass an
1440
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.
1441
1808
 
1442
- ## Removal is destructive
1809
+ ## Removal and pruning are destructive
1443
1810
 
1444
1811
  \`gwt remove\` deletes the worktree and, by default, its branch. Confirm with the
1445
1812
  user before running it, and never pass \`--discard --yes\` on your own: together
1446
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.
1447
1819
  `
1448
1820
  }
1449
1821
 
@@ -1527,19 +1899,53 @@ async function commandShell(args) {
1527
1899
  throw new CliError("Usage: gwt shell install zsh [--dry-run] [--yes]")
1528
1900
  }
1529
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
+
1530
1935
  function commandComplete(args) {
1531
1936
  if (args.length !== 1) throw new CliError("Invalid completion request")
1532
1937
 
1533
1938
  if (args[0] === "worktrees") {
1534
1939
  const repository = discoverRepository()
1535
- const values = ["primary"]
1940
+ const lines = [`primary\t${repository.primary.branch ?? ""}`]
1536
1941
  for (const worktree of repository.worktrees) {
1537
1942
  if (resolve(worktree.path) === resolve(repository.primaryPath)) continue
1538
1943
  const metadata = metadataForWorktree(repository, worktree)
1539
- const selector = worktree.branch ?? metadata?.id
1540
- if (selector) values.push(selector)
1944
+ const id = metadata?.id ?? ""
1945
+ const branch = worktree.branch ?? ""
1946
+ if (id || branch) lines.push(`${id}\t${branch}`)
1541
1947
  }
1542
- console.log(values.join("\n"))
1948
+ console.log(lines.join("\n"))
1543
1949
  return
1544
1950
  }
1545
1951
 
@@ -1564,7 +1970,9 @@ function version() {
1564
1970
  }
1565
1971
 
1566
1972
  function help(command, subcommand) {
1567
- 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
1568
1976
  const texts = {
1569
1977
  "": `gwt ${version()} - lightweight native Git worktree workflows
1570
1978
 
@@ -1574,10 +1982,11 @@ Usage:
1574
1982
  Commands:
1575
1983
  new Create and set up a worktree
1576
1984
  setup Set up an existing worktree
1577
- list List registered worktrees
1985
+ list List registered worktrees (alias: ls)
1578
1986
  switch Switch the current shell to a worktree
1579
- info Show worktree details and assigned ports
1987
+ info Show worktree details, ports, and environment
1580
1988
  remove Safely remove a worktree and optionally its branch
1989
+ prune Clean up leftovers from worktrees that are already gone
1581
1990
  trust Approve or revoke repository project configuration
1582
1991
  config Create or inspect configuration
1583
1992
  shell Install shell integration
@@ -1591,6 +2000,7 @@ Examples:
1591
2000
  gwt new feature/auth
1592
2001
  gwt switch
1593
2002
  gwt remove
2003
+ gwt prune
1594
2004
  gwt config create
1595
2005
  gwt shell install zsh
1596
2006
  gwt skill install claude
@@ -1599,15 +2009,20 @@ Run 'gwt <command> --help' for command behavior and more examples.`,
1599
2009
  new: `Create a worktree, prepare its development environment, and switch to it.
1600
2010
 
1601
2011
  Usage:
1602
- gwt new [branch] [--base <ref>] [--no-hooks]
2012
+ gwt new [branch] [--base <ref>] [--no-hooks] [--background]
1603
2013
 
1604
2014
  Arguments:
1605
- 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>.
1606
2019
 
1607
2020
  Options:
1608
- --base <ref> Start from this Git revision instead of the configured base
1609
- 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.
1610
2024
  --no-hooks Copy files and allocate ports, but skip postCreate.
2025
+ --background Run postCreate detached so the shell returns immediately.
1611
2026
  -h, --help Show help for this command.
1612
2027
 
1613
2028
  Behavior:
@@ -1620,53 +2035,68 @@ Behavior:
1620
2035
  repository hash is added to the directory name only when needed to avoid a
1621
2036
  name collision.
1622
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
+
1623
2044
  Examples:
1624
2045
  gwt new feature/auth
1625
2046
  gwt new
1626
2047
  gwt new hotfix/login --base origin/main
1627
- gwt new experiment --no-hooks`,
2048
+ gwt new feature/from-remote
2049
+ gwt new heavy-project --background`,
1628
2050
  setup: `Prepare an existing linked worktree using the active gwt configuration.
1629
2051
 
1630
2052
  Usage:
1631
- gwt setup [id|branch|path] [--no-hooks]
2053
+ gwt setup [id|branch|path] [--no-hooks] [--background]
1632
2054
 
1633
2055
  Arguments:
1634
2056
  id|branch|path Worktree to set up. Defaults to the current worktree.
1635
2057
 
1636
2058
  Options:
1637
2059
  --no-hooks Copy files and allocate ports, but skip postCreate.
2060
+ --background Run postCreate detached so the shell returns immediately.
1638
2061
  -h, --help Show help for this command.
1639
2062
 
1640
2063
  Behavior:
1641
2064
  Use this to adopt a worktree created with native 'git worktree add' or to
1642
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.
1643
2068
 
1644
2069
  Examples:
1645
2070
  gwt setup
1646
2071
  gwt setup feature/auth
1647
- gwt setup a1b2c3d4 --no-hooks`,
2072
+ gwt setup a1b2c3d4 --no-hooks
2073
+ gwt setup a1b2c3d4 --background`,
1648
2074
  list: `List Git worktrees together with gwt IDs and setup status.
1649
2075
 
1650
2076
  Usage:
1651
2077
  gwt list
2078
+ gwt ls
1652
2079
 
1653
2080
  Options:
1654
2081
  -h, --help Show help for this command.
1655
2082
 
1656
2083
  The current worktree is marked with '*'. Native worktrees that have not been
1657
- 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'.
1658
2085
 
1659
- Example:
1660
- gwt list`,
2086
+ Examples:
2087
+ gwt list
2088
+ gwt ls`,
1661
2089
  switch: `Switch the current shell to another worktree.
1662
2090
 
1663
2091
  Usage:
1664
- gwt switch [primary|id|branch|path]
2092
+ gwt switch [primary|id|branch|path] [--create]
1665
2093
 
1666
2094
  Arguments:
1667
2095
  selector Worktree to switch to. Opens the picker when omitted.
1668
2096
 
1669
2097
  Options:
2098
+ --create Create the worktree without asking when the selector names a
2099
+ branch that has none.
1670
2100
  -h, --help Show help for this command.
1671
2101
 
1672
2102
  Behavior:
@@ -1675,12 +2105,18 @@ Behavior:
1675
2105
  j/k, Ctrl-n/Ctrl-p, and '/' filtering. Shell integration must be installed for
1676
2106
  gwt to change the parent shell's directory; otherwise the path is only printed.
1677
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
+
1678
2113
  Examples:
1679
2114
  gwt switch
1680
2115
  gwt switch primary
1681
2116
  gwt switch feature/auth
1682
- gwt switch a1b2c3d4`,
1683
- 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.
1684
2120
 
1685
2121
  Usage:
1686
2122
  gwt info [primary|id|branch|path]
@@ -1692,6 +2128,13 @@ Arguments:
1692
2128
  Options:
1693
2129
  -h, --help Show help for this command.
1694
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
+
1695
2138
  Examples:
1696
2139
  gwt info
1697
2140
  gwt info primary
@@ -1719,10 +2162,46 @@ Behavior:
1719
2162
  use keeps it. The primary worktree cannot be removed. Removing the current
1720
2163
  worktree returns an integrated shell to the primary worktree.
1721
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
+
1722
2175
  Examples:
1723
2176
  gwt remove
1724
2177
  gwt remove feature/auth --keep-branch
1725
2178
  gwt remove a1b2c3d4 --discard --yes`,
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`,
1726
2205
  trust: `Approve or revoke active configuration declared by the repository's .gwt.json.
1727
2206
 
1728
2207
  Usage:
@@ -1888,15 +2367,17 @@ async function main() {
1888
2367
  }
1889
2368
  if (command === "new") return commandNew(args)
1890
2369
  if (command === "setup") return commandSetup(args)
1891
- if (command === "list") return commandList(args)
2370
+ if (command === "list" || command === "ls") return commandList(args)
1892
2371
  if (command === "switch") return commandSwitch(args)
1893
2372
  if (command === "info") return commandInfo(args)
1894
2373
  if (command === "remove") return commandRemove(args)
2374
+ if (command === "prune") return commandPrune(args)
1895
2375
  if (command === "trust") return commandTrust(args)
1896
2376
  if (command === "config") return commandConfig(args)
1897
2377
  if (command === "shell") return commandShell(args)
1898
2378
  if (command === "skill") return commandSkill(args)
1899
2379
  if (command === "__complete") return commandComplete(args)
2380
+ if (command === "__run-hook") return commandRunHook(args)
1900
2381
  if (command === "__shell_env") return commandShellEnvironment(args)
1901
2382
  throw new CliError(`Unknown command: ${command}`)
1902
2383
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junheep/gwt",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Lightweight native Git worktree workflows",
5
5
  "license": "MIT",
6
6
  "author": "Junhee Park",