@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.
- package/README.md +39 -39
- package/bin/gwt.mjs +561 -80
- 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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
61
|
-
`git worktree`,
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
`gwt
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
432
|
+
return repository.worktrees.find((worktree) => resolve(worktree.path) === candidatePath) ?? null
|
|
433
|
+
}
|
|
412
434
|
|
|
413
|
-
|
|
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
|
|
734
|
+
function isBranchName(branch, cwd) {
|
|
681
735
|
const result = git(["check-ref-format", "--branch", branch], cwd, { allowFailure: true })
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
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
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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(
|
|
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
|
|
864
|
+
noHooks: options.noHooks,
|
|
865
|
+
background: options.background,
|
|
866
|
+
scratchBranch: scratch ? branch : undefined,
|
|
739
867
|
})
|
|
740
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
-
|
|
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
|
|
1186
|
-
|
|
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))
|
|
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
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
|
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
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
|
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
|
|
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
|
|
1540
|
-
|
|
1944
|
+
const id = metadata?.id ?? ""
|
|
1945
|
+
const branch = worktree.branch ?? ""
|
|
1946
|
+
if (id || branch) lines.push(`${id}\t${branch}`)
|
|
1541
1947
|
}
|
|
1542
|
-
console.log(
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|