@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.
- package/README.md +24 -11
- package/bin/gwt.mjs +89 -29
- 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
|
|
137
|
-
|
|
138
|
-
|
|
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`:
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
140
|
-
if (worktreeDirectory
|
|
141
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
636
|
-
|
|
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) ===
|
|
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 ${
|
|
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(
|
|
702
|
+
writeCdDirective(targetPath)
|
|
651
703
|
} catch (error) {
|
|
652
|
-
console.error(`Setup failed; worktree retained at ${
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
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.
|
|
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`,
|