@junheep/gwt 0.2.2 → 0.3.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 +24 -11
  2. package/bin/gwt.mjs +89 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,7 +82,6 @@ Repositories without a remote use their canonical path.
82
82
  {
83
83
  "projects": {
84
84
  "github.com/owner/repository": {
85
- "worktreeDirectory": ".worktrees",
86
85
  "copyFiles": [
87
86
  "apps/server/.env",
88
87
  "apps/web/.env"
@@ -114,7 +113,6 @@ The project file contains the configuration fields directly:
114
113
  ```json
115
114
  {
116
115
  "base": "origin/main",
117
- "worktreeDirectory": ".worktrees",
118
116
  "copyFiles": [
119
117
  "apps/server/.env",
120
118
  "apps/web/.env"
@@ -133,12 +131,16 @@ The two files are not merged. Run `gwt config show` to see whether user and
133
131
  repository configuration is available, the location of each existing config
134
132
  file, the active source, and its resolved value.
135
133
 
136
- All fields are optional. Without either config, worktrees are created beneath
137
- `.worktrees`, use the primary worktree's current commit as their base, and run
138
- no setup actions.
134
+ All fields are optional. Without either config, worktrees are created outside
135
+ the repository beneath `~/.gwt/worktrees`. Set `GWT_HOME` to an absolute path
136
+ to use a different gwt home directory. A repository normally uses a directory
137
+ named after it. If another repository already uses that name, gwt adds a short
138
+ hash derived from the canonical path. Worktrees use the primary worktree's
139
+ current commit as their base and run no setup actions.
139
140
 
140
141
  - `base`: Git revision used when `--base` is omitted.
141
- - `worktreeDirectory`: Repository-relative directory for managed worktrees.
142
+ - `worktreeDirectory`: Optional repository-relative directory for managed
143
+ worktrees. Setting it opts out of the external default.
142
144
  - `copyFiles`: Ignored local files copied from the primary worktree without
143
145
  overwriting an existing destination.
144
146
  - `ports`: Environment variable names assigned stable ports in the range
@@ -146,8 +148,17 @@ no setup actions.
146
148
  - `postCreate`: Executable run after files and ports are prepared.
147
149
  - `preRemove`: Executable run before removal.
148
150
 
149
- The worktree directory is added to `.git/info/exclude`; tracked project files
150
- are not modified.
151
+ An explicitly configured repository-relative worktree directory is added to
152
+ `.git/info/exclude`; tracked project files are not modified.
153
+
154
+ For example, an existing configuration can retain the previous in-repository
155
+ layout explicitly:
156
+
157
+ ```json
158
+ {
159
+ "worktreeDirectory": ".worktrees"
160
+ }
161
+ ```
151
162
 
152
163
  ## Hooks
153
164
 
@@ -192,8 +203,8 @@ Approval is invalidated when `.gwt.json` or either hook changes.
192
203
  gwt new [branch] [--base <ref>] [--no-hooks]
193
204
  gwt setup [id|branch|path] [--no-hooks]
194
205
  gwt list
195
- gwt switch [id|branch|path]
196
- gwt info [id|branch|path]
206
+ gwt switch [primary|id|branch|path]
207
+ gwt info [primary|id|branch|path]
197
208
  gwt remove [id|branch|path] [--keep-branch|--discard] [--yes] [--no-hooks]
198
209
  gwt trust [--revoke]
199
210
  gwt config create [--project]
@@ -216,7 +227,9 @@ Setup failures retain the worktree and record the failure. Retry with
216
227
  Run `gwt switch` without a target to open the interactive picker. Use the
217
228
  arrow keys, `j`/`k`, or Ctrl-n/Ctrl-p to move; press `/` to filter by branch,
218
229
  ID, or path. Enter switches to the selected worktree. Escape leaves filter
219
- mode or cancels the picker.
230
+ mode or cancels the picker. `primary` is a reserved ID for the repository's
231
+ primary worktree, so `gwt switch primary` returns to it from any linked
232
+ worktree.
220
233
 
221
234
  `gwt remove` refuses dirty worktrees and first tries to delete the branch with
222
235
  `git branch -d`. If Git rejects safe deletion, an interactive terminal asks
package/bin/gwt.mjs CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  } from "node:fs"
20
20
  import { createServer } from "node:net"
21
21
  import { homedir } from "node:os"
22
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
22
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
23
23
  import { emitKeypressEvents } from "node:readline"
24
24
  import { createInterface } from "node:readline/promises"
25
25
 
@@ -28,7 +28,7 @@ const PORT_MIN = 20_000
28
28
  const PORT_MAX = 39_999
29
29
  const PICKER_ESCAPE_CODE_TIMEOUT_MS = 50
30
30
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
31
- const DEFAULT_CONFIG = { worktreeDirectory: ".worktrees", copyFiles: [], ports: [] }
31
+ const DEFAULT_CONFIG = { copyFiles: [], ports: [] }
32
32
  const SKILL_DIRECTORIES = { claude: ".claude", codex: ".agents" }
33
33
  const SKILL_USAGE = `Usage: gwt skill install <${Object.keys(SKILL_DIRECTORIES).join("|")}> [--project] [--dry-run] [--yes]`
34
34
 
@@ -136,9 +136,12 @@ function validateConfig(parsed, label) {
136
136
  throw new CliError("base must be a non-empty string")
137
137
  }
138
138
 
139
- const worktreeDirectory = validateRelativePath(parsed.worktreeDirectory ?? ".worktrees", "worktreeDirectory")
140
- if (worktreeDirectory.split(/[\\/]+/).some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) {
141
- throw new CliError("worktreeDirectory can only contain letters, digits, '.', '_', '-', and path separators")
139
+ let worktreeDirectory
140
+ if (parsed.worktreeDirectory !== undefined) {
141
+ worktreeDirectory = validateRelativePath(parsed.worktreeDirectory, "worktreeDirectory")
142
+ if (worktreeDirectory.split(/[\\/]+/).some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) {
143
+ throw new CliError("worktreeDirectory can only contain letters, digits, '.', '_', '-', and path separators")
144
+ }
142
145
  }
143
146
  if (!Array.isArray(parsed.copyFiles ?? [])) throw new CliError("copyFiles must be an array")
144
147
  const copyFiles = (parsed.copyFiles ?? []).map((path, index) => validateRelativePath(path, `copyFiles[${index}]`))
@@ -156,18 +159,61 @@ function validateConfig(parsed, label) {
156
159
  if (parsed[hook] !== undefined) validateRelativePath(parsed[hook], hook)
157
160
  }
158
161
 
159
- return {
162
+ const config = {
160
163
  ...parsed,
161
- worktreeDirectory,
162
164
  copyFiles,
163
165
  ports,
164
166
  }
167
+ if (worktreeDirectory !== undefined) config.worktreeDirectory = worktreeDirectory
168
+ return config
165
169
  }
166
170
 
167
171
  function configHome() {
168
172
  return process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
169
173
  }
170
174
 
175
+ function gwtHome() {
176
+ const path = process.env.GWT_HOME || join(homedir(), ".gwt")
177
+ if (!isAbsolute(path)) throw new CliError("GWT_HOME must be an absolute path")
178
+ return path
179
+ }
180
+
181
+ function repositoryUsesDirectory(repository, directory) {
182
+ const resolvedDirectory = pathExists(directory) ? canonical(directory) : resolve(directory)
183
+ return repository.worktrees.some((worktree) => {
184
+ if (!pathExists(worktree.path)) return false
185
+ const worktreePath = canonical(worktree.path)
186
+ return worktreePath !== repository.primaryPath && dirname(worktreePath) === resolvedDirectory
187
+ })
188
+ }
189
+
190
+ function directoryIsEmpty(path) {
191
+ try {
192
+ return statSync(path).isDirectory() && readdirSync(path).length === 0
193
+ } catch {
194
+ return false
195
+ }
196
+ }
197
+
198
+ function defaultWorktreeDirectory(repository) {
199
+ const root = join(gwtHome(), "worktrees")
200
+ const name = basename(repository.primaryPath)
201
+ const namedDirectory = join(root, name)
202
+ const digest = createHash("sha256").update(repository.primaryPath).digest("hex").slice(0, 8)
203
+ const disambiguatedDirectory = join(root, `${name}-${digest}`)
204
+
205
+ if (repositoryUsesDirectory(repository, disambiguatedDirectory)) return disambiguatedDirectory
206
+ if (repositoryUsesDirectory(repository, namedDirectory)) return namedDirectory
207
+ if (!pathExists(namedDirectory) || directoryIsEmpty(namedDirectory)) return namedDirectory
208
+ return disambiguatedDirectory
209
+ }
210
+
211
+ function resolveWorktreeDirectory(repository, config) {
212
+ return config.worktreeDirectory
213
+ ? resolve(repository.primaryPath, config.worktreeDirectory)
214
+ : defaultWorktreeDirectory(repository)
215
+ }
216
+
171
217
  function userConfigPath() {
172
218
  return join(configHome(), "gwt", "config.json")
173
219
  }
@@ -311,6 +357,8 @@ function resolveWorktree(repository, selector, options = {}) {
311
357
  return current
312
358
  }
313
359
 
360
+ if (selector === "primary") return repository.primary
361
+
314
362
  const metadata = loadMetadata(repository)
315
363
  const idMatch = metadata.find((item) => item.id === selector)
316
364
  if (idMatch) {
@@ -330,9 +378,10 @@ function resolveWorktree(repository, selector, options = {}) {
330
378
  }
331
379
 
332
380
  function generateId(repository, config) {
381
+ const directory = resolveWorktreeDirectory(repository, config)
333
382
  for (let attempt = 0; attempt < 100; attempt += 1) {
334
383
  const id = randomBytes(4).toString("hex")
335
- const target = join(repository.primaryPath, config.worktreeDirectory, id)
384
+ const target = join(directory, id)
336
385
  if (!existsSync(metadataPath(repository, id)) && !pathExists(target)) return id
337
386
  }
338
387
  throw new CliError("Could not generate a unique worktree ID")
@@ -632,24 +681,27 @@ async function commandNew(args) {
632
681
  const base = requestedBase
633
682
  ? gitOutput(["rev-parse", "--verify", `${requestedBase}^{commit}`], repository.primaryPath)
634
683
  : gitOutput(["rev-parse", "HEAD"], repository.primaryPath)
635
- const target = join(repository.primaryPath, configDocument.value.worktreeDirectory, id)
636
- ensureLocalExclude(repository, configDocument.value.worktreeDirectory)
684
+ const target = join(resolveWorktreeDirectory(repository, configDocument.value), id)
685
+ if (configDocument.value.worktreeDirectory) {
686
+ ensureLocalExclude(repository, configDocument.value.worktreeDirectory)
687
+ }
637
688
 
638
689
  git(["worktree", "add", "-b", branch, target, base], repository.primaryPath, { stdio: "inherit" })
690
+ const targetPath = canonical(target)
639
691
  const refreshed = discoverRepository(repository.primaryPath)
640
- const worktree = refreshed.worktrees.find((item) => resolve(item.path) === resolve(target))
692
+ const worktree = refreshed.worktrees.find((item) => resolve(item.path) === targetPath)
641
693
 
642
694
  try {
643
695
  const metadata = await setupWorktree(refreshed, configDocument, worktree, {
644
696
  id,
645
697
  noHooks: options["no-hooks"],
646
698
  })
647
- console.log(`Worktree ${metadata.id} is ready at ${target}`)
699
+ console.log(`Worktree ${metadata.id} is ready at ${targetPath}`)
648
700
  console.log(`Branch: ${branch}`)
649
701
  for (const [name, port] of Object.entries(metadata.ports)) console.log(`${name}: ${port}`)
650
- writeCdDirective(target)
702
+ writeCdDirective(targetPath)
651
703
  } catch (error) {
652
- console.error(`Setup failed; worktree retained at ${target}`)
704
+ console.error(`Setup failed; worktree retained at ${targetPath}`)
653
705
  console.error(`Retry: gwt setup ${id}`)
654
706
  console.error(`Remove: gwt remove ${id}`)
655
707
  throw error
@@ -806,7 +858,7 @@ async function chooseWorktree(repository) {
806
858
  }
807
859
 
808
860
  async function commandSwitch(args) {
809
- if (args.length > 1) throw new CliError("Usage: gwt switch [id|branch|path]")
861
+ if (args.length > 1) throw new CliError("Usage: gwt switch [primary|id|branch|path]")
810
862
  const repository = discoverRepository()
811
863
  const worktree = args[0] ? resolveWorktree(repository, args[0]) : await chooseWorktree(repository)
812
864
  writeCdDirective(canonical(worktree.path))
@@ -911,7 +963,7 @@ function commandList(args) {
911
963
  }
912
964
 
913
965
  function commandInfo(args) {
914
- if (args.length > 1) throw new CliError("Usage: gwt info [id|branch|path]")
966
+ if (args.length > 1) throw new CliError("Usage: gwt info [primary|id|branch|path]")
915
967
  const repository = discoverRepository()
916
968
  const worktree = resolveWorktree(repository, args[0])
917
969
  const metadata = metadataForWorktree(repository, worktree)
@@ -1024,7 +1076,7 @@ function commandTrust(args) {
1024
1076
  }
1025
1077
 
1026
1078
  function configScaffold() {
1027
- return { worktreeDirectory: ".worktrees", copyFiles: [], ports: [] }
1079
+ return { copyFiles: [], ports: [] }
1028
1080
  }
1029
1081
 
1030
1082
  function commandConfigCreate(args) {
@@ -1079,6 +1131,7 @@ function commandConfigShow(args) {
1079
1131
  console.log(`Repository config: ${repositoryConfigured ? "configured" : "not created"}`)
1080
1132
  if (repositoryConfigured) console.log(` File: ${projectPath}`)
1081
1133
  console.log(`Active config: ${activeLabel}`)
1134
+ console.log(`Worktree directory: ${resolveWorktreeDirectory(repository, active.value)}`)
1082
1135
  console.log(JSON.stringify(active.value, null, 2))
1083
1136
  }
1084
1137
 
@@ -1332,7 +1385,7 @@ function commandComplete(args) {
1332
1385
 
1333
1386
  if (args[0] === "worktrees") {
1334
1387
  const repository = discoverRepository()
1335
- const values = []
1388
+ const values = ["primary"]
1336
1389
  for (const worktree of repository.worktrees) {
1337
1390
  const metadata = metadataForWorktree(repository, worktree)
1338
1391
  if (metadata?.id) values.push(metadata.id)
@@ -1411,10 +1464,13 @@ Options:
1411
1464
 
1412
1465
  Behavior:
1413
1466
  The worktree receives an immutable 8-character ID. gwt creates it below the
1414
- configured worktreeDirectory, copies configured local files, assigns stable
1467
+ resolved worktree directory, copies configured local files, assigns stable
1415
1468
  ports, and runs postCreate. A setup failure keeps the worktree so setup can
1416
1469
  be retried. With shell integration installed, the current shell moves into
1417
- the new worktree after setup succeeds.
1470
+ the new worktree after setup succeeds. Without worktreeDirectory, the default
1471
+ is $GWT_HOME/worktrees or ~/.gwt/worktrees when GWT_HOME is not set. A short
1472
+ repository hash is added to the directory name only when needed to avoid a
1473
+ name collision.
1418
1474
 
1419
1475
  Examples:
1420
1476
  gwt new feature/auth
@@ -1457,36 +1513,40 @@ Example:
1457
1513
  switch: `Switch the current shell to another worktree.
1458
1514
 
1459
1515
  Usage:
1460
- gwt switch [id|branch|path]
1516
+ gwt switch [primary|id|branch|path]
1461
1517
 
1462
1518
  Arguments:
1463
- id|branch|path Worktree to switch to. Opens the picker when omitted.
1519
+ selector Worktree to switch to. Opens the picker when omitted.
1464
1520
 
1465
1521
  Options:
1466
1522
  -h, --help Show help for this command.
1467
1523
 
1468
1524
  Behavior:
1469
- The picker supports arrow keys, j/k, Ctrl-n/Ctrl-p, and '/' filtering. Shell
1470
- integration must be installed for gwt to change the parent shell's directory;
1471
- otherwise the selected path is only printed.
1525
+ 'primary' is the reserved ID for the primary worktree. Other worktrees can be
1526
+ selected by ID, exact branch name, or path. The picker supports arrow keys,
1527
+ j/k, Ctrl-n/Ctrl-p, and '/' filtering. Shell integration must be installed for
1528
+ gwt to change the parent shell's directory; otherwise the path is only printed.
1472
1529
 
1473
1530
  Examples:
1474
1531
  gwt switch
1532
+ gwt switch primary
1475
1533
  gwt switch feature/auth
1476
1534
  gwt switch a1b2c3d4`,
1477
1535
  info: `Show a worktree's identity, Git state, setup status, and assigned ports.
1478
1536
 
1479
1537
  Usage:
1480
- gwt info [id|branch|path]
1538
+ gwt info [primary|id|branch|path]
1481
1539
 
1482
1540
  Arguments:
1483
- id|branch|path Worktree to inspect. Defaults to the current worktree.
1541
+ selector 'primary', an ID, an exact branch name, or a path. Defaults
1542
+ to the current worktree.
1484
1543
 
1485
1544
  Options:
1486
1545
  -h, --help Show help for this command.
1487
1546
 
1488
1547
  Examples:
1489
1548
  gwt info
1549
+ gwt info primary
1490
1550
  gwt info feature/auth`,
1491
1551
  remove: `Safely remove a linked worktree and, by default, its branch.
1492
1552
 
@@ -1579,8 +1639,8 @@ Options:
1579
1639
  -h, --help Show help for this command.
1580
1640
 
1581
1641
  The output distinguishes a missing user config file from an existing file that
1582
- does not configure the current project. Repository configuration takes
1583
- precedence over user configuration.
1642
+ does not configure the current project. It also shows the resolved worktree
1643
+ directory. Repository configuration takes precedence over user configuration.
1584
1644
 
1585
1645
  Example:
1586
1646
  gwt config show`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junheep/gwt",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Lightweight native Git worktree workflows",
5
5
  "license": "MIT",
6
6
  "author": "Junhee Park",