@bongos/core 1.19.120 → 1.19.121
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/.bongos-core.json +42 -17
- package/.claude/skills/builder-end/SKILL.md +3 -3
- package/docs/file-map.md +2 -0
- package/docs/recipes/ops-gotchas.md +2 -0
- package/docs/recipes/windows-builders.md +103 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api.js +12 -2
- package/scripts/gds/cli-lib.js +29 -0
- package/scripts/gds/ship.js +52 -7
- package/scripts/gds/worktree.js +252 -0
- package/src/module-api.js +3 -1
- package/tests/api_msys_path_repair.mjs +104 -0
- package/tests/ship_post_land_verify.mjs +114 -0
- package/tests/worktree_helper.mjs +145 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.121",
|
|
6
|
+
"core_contract": "1.19.121",
|
|
7
|
+
"source_commit": "5bc8620889777dfaeebdc0bef12a6ac24c036e61",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-14T20:
|
|
9
|
+
"built_at": "2026-08-14T20:40:48.360Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 303,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1243,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1567,
|
|
20
|
+
"tree_sha256": "c642320ac42a83f1863a48c74b443b0c761d83a85ad45114720ee0a9f60bc850",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
{
|
|
58
58
|
"path": ".claude/skills/builder-end/SKILL.md",
|
|
59
59
|
"mode": "0000644",
|
|
60
|
-
"sha256": "
|
|
60
|
+
"sha256": "79afe09324f8b9ac27e0500972af701020d502f4a2502323b821ff5234216f70"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": ".claude/skills/builder-exit/SKILL.md",
|
|
@@ -1467,7 +1467,7 @@
|
|
|
1467
1467
|
{
|
|
1468
1468
|
"path": "docs/file-map.md",
|
|
1469
1469
|
"mode": "0000644",
|
|
1470
|
-
"sha256": "
|
|
1470
|
+
"sha256": "f76f816dbbab7668c7dbd1ff7406dee095e043f23262a2c6afd900ff9c59b337"
|
|
1471
1471
|
},
|
|
1472
1472
|
{
|
|
1473
1473
|
"path": "docs/handoff-template.md",
|
|
@@ -1642,7 +1642,7 @@
|
|
|
1642
1642
|
{
|
|
1643
1643
|
"path": "docs/recipes/ops-gotchas.md",
|
|
1644
1644
|
"mode": "0000644",
|
|
1645
|
-
"sha256": "
|
|
1645
|
+
"sha256": "6374ff40b5b054d57761d25bf7b21059c8b2d8d35354120cc221431edd3260a2"
|
|
1646
1646
|
},
|
|
1647
1647
|
{
|
|
1648
1648
|
"path": "docs/recipes/overnight-builder.md",
|
|
@@ -1699,6 +1699,11 @@
|
|
|
1699
1699
|
"mode": "0000644",
|
|
1700
1700
|
"sha256": "732f3b01b7d2e6cc238dd5f48d5ed774f6c2c27ecc737710047758bf85f2d9e5"
|
|
1701
1701
|
},
|
|
1702
|
+
{
|
|
1703
|
+
"path": "docs/recipes/windows-builders.md",
|
|
1704
|
+
"mode": "0000644",
|
|
1705
|
+
"sha256": "2e24454839aa02d1f3205dd92a6856254e59ca04f88573a1f42641ddb5924e32"
|
|
1706
|
+
},
|
|
1702
1707
|
{
|
|
1703
1708
|
"path": "docs/recipes/work-on-a-task-from-your-dev-box.md",
|
|
1704
1709
|
"mode": "0000644",
|
|
@@ -4162,12 +4167,12 @@
|
|
|
4162
4167
|
{
|
|
4163
4168
|
"path": "package-lock.json",
|
|
4164
4169
|
"mode": "0000644",
|
|
4165
|
-
"sha256": "
|
|
4170
|
+
"sha256": "f4b51e6942ce9cd2f52b077f67e671feb4ac93e03d08a2fc487981205fc362b9"
|
|
4166
4171
|
},
|
|
4167
4172
|
{
|
|
4168
4173
|
"path": "package.json",
|
|
4169
4174
|
"mode": "0000644",
|
|
4170
|
-
"sha256": "
|
|
4175
|
+
"sha256": "1d7889d13a0d5ab2930528a5b8efdde5e9b28cb1467f5f0b29dec2e60ce722f0"
|
|
4171
4176
|
},
|
|
4172
4177
|
{
|
|
4173
4178
|
"path": "public-docs/index.html",
|
|
@@ -4507,7 +4512,7 @@
|
|
|
4507
4512
|
{
|
|
4508
4513
|
"path": "scripts/gds/api.js",
|
|
4509
4514
|
"mode": "0000644",
|
|
4510
|
-
"sha256": "
|
|
4515
|
+
"sha256": "96d6b69e9440dac3ed81c490e460a832d385714de0b379650af80817f89ded0f"
|
|
4511
4516
|
},
|
|
4512
4517
|
{
|
|
4513
4518
|
"path": "scripts/gds/api.ps1",
|
|
@@ -4657,7 +4662,7 @@
|
|
|
4657
4662
|
{
|
|
4658
4663
|
"path": "scripts/gds/cli-lib.js",
|
|
4659
4664
|
"mode": "0000644",
|
|
4660
|
-
"sha256": "
|
|
4665
|
+
"sha256": "83d27d14600c4c8ec8912c7ad8ce771e0e34903ccc9b567dcc27cb95aeb3c37d"
|
|
4661
4666
|
},
|
|
4662
4667
|
{
|
|
4663
4668
|
"path": "scripts/gds/code.js",
|
|
@@ -5307,7 +5312,7 @@
|
|
|
5307
5312
|
{
|
|
5308
5313
|
"path": "scripts/gds/ship.js",
|
|
5309
5314
|
"mode": "0000644",
|
|
5310
|
-
"sha256": "
|
|
5315
|
+
"sha256": "7ae14021dbae94ffc5a70cf05081e645ca2dcf9685ccf135658538cb44e79dde"
|
|
5311
5316
|
},
|
|
5312
5317
|
{
|
|
5313
5318
|
"path": "scripts/gds/smoke-batch-claim.sh",
|
|
@@ -5384,6 +5389,11 @@
|
|
|
5384
5389
|
"mode": "0000644",
|
|
5385
5390
|
"sha256": "32e0c1f2a955d4cabeae00d3749b283abd70f05f34b5fcbd84be3f35e2e655c8"
|
|
5386
5391
|
},
|
|
5392
|
+
{
|
|
5393
|
+
"path": "scripts/gds/worktree.js",
|
|
5394
|
+
"mode": "0000644",
|
|
5395
|
+
"sha256": "74fc8349287498ede068156f904aaef3f7dbeafce2b40942dd5a8a2a0208564a"
|
|
5396
|
+
},
|
|
5387
5397
|
{
|
|
5388
5398
|
"path": "scripts/hall-preview/README.md",
|
|
5389
5399
|
"mode": "0000644",
|
|
@@ -5742,7 +5752,7 @@
|
|
|
5742
5752
|
{
|
|
5743
5753
|
"path": "src/module-api.js",
|
|
5744
5754
|
"mode": "0000644",
|
|
5745
|
-
"sha256": "
|
|
5755
|
+
"sha256": "180b8c272ae0895dc57e67f1a0558c603bb811e6bda5d0dee4d1547239851728"
|
|
5746
5756
|
},
|
|
5747
5757
|
{
|
|
5748
5758
|
"path": "src/module-loader/catalog.js",
|
|
@@ -5854,6 +5864,11 @@
|
|
|
5854
5864
|
"mode": "0000644",
|
|
5855
5865
|
"sha256": "3a54640e1707b27916b9f0168e1ca12141f34148be975d7cad74f02aac166977"
|
|
5856
5866
|
},
|
|
5867
|
+
{
|
|
5868
|
+
"path": "tests/api_msys_path_repair.mjs",
|
|
5869
|
+
"mode": "0000644",
|
|
5870
|
+
"sha256": "ebb0a0183ca30ebe5207c4703e85e49c32e958778d76373f597553cf259feb39"
|
|
5871
|
+
},
|
|
5857
5872
|
{
|
|
5858
5873
|
"path": "tests/api_pagination_contract.mjs",
|
|
5859
5874
|
"mode": "0000644",
|
|
@@ -7609,6 +7624,11 @@
|
|
|
7609
7624
|
"mode": "0000644",
|
|
7610
7625
|
"sha256": "e236bc3d6882bc6ddf5d389371a92a96aec62769f14c37bbca31810bcbea0d61"
|
|
7611
7626
|
},
|
|
7627
|
+
{
|
|
7628
|
+
"path": "tests/ship_post_land_verify.mjs",
|
|
7629
|
+
"mode": "0000644",
|
|
7630
|
+
"sha256": "6df2fb6fde081c9d98baab346a794946a51e4e51e70d154d41d9e94939180d52"
|
|
7631
|
+
},
|
|
7612
7632
|
{
|
|
7613
7633
|
"path": "tests/ship_preflight.mjs",
|
|
7614
7634
|
"mode": "0000644",
|
|
@@ -7824,6 +7844,11 @@
|
|
|
7824
7844
|
"mode": "0000644",
|
|
7825
7845
|
"sha256": "aa1aab27bdfc5761586514fa07dacfad125fb1d5dcc7f01c060cbe3556985454"
|
|
7826
7846
|
},
|
|
7847
|
+
{
|
|
7848
|
+
"path": "tests/worktree_helper.mjs",
|
|
7849
|
+
"mode": "0000644",
|
|
7850
|
+
"sha256": "367e4c9a63bc8752b94d0fc7af171d34390034adc2a75c8ec15f00b335c077ca"
|
|
7851
|
+
},
|
|
7827
7852
|
{
|
|
7828
7853
|
"path": "tests/write_validation_gaps.mjs",
|
|
7829
7854
|
"mode": "0000644",
|
|
@@ -13,7 +13,7 @@ Composes operations that already exist, in a safe order, with the destructive on
|
|
|
13
13
|
|
|
14
14
|
1. **Resolve the claim** — `/builder-ship` when there's committed, shippable work; `/builder-release` when there isn't. (It calls the sibling skills; it does not re-implement shipping.)
|
|
15
15
|
2. **Clean-git verify** — refuse to go further if any *tracked* file is uncommitted. This is the same guard `scripts/gds/ship.js` enforces (`trackedDirty` in [`src/bongos/ship-preflight.js`](../../../src/bongos/ship-preflight.js)): untracked scratch is ignored, but a modified/staged/deleted tracked file means real work isn't committed — stop before anything is lost.
|
|
16
|
-
3. **Worktree teardown** — from the main checkout,
|
|
16
|
+
3. **Worktree teardown** — from the main checkout, remove this session's linked worktree via `node scripts/gds/worktree.js remove` (junction-aware), `prune`, and delete the now-merged `claude/<name>` branch.
|
|
17
17
|
4. **Box close-out (dev box only)** — if running on a dev box (`/etc/otb/box.env` exists), offer to `close-box` so the box stops billing. Optional and gated on a yes; declining just lets the box idle-park. On a laptop this step is skipped — there's no box to close.
|
|
18
18
|
|
|
19
19
|
It never pushes to `main`, never `ssh`-es, never runs a deploy — landing is `/builder-ship`'s job (server-mediated). It only ever touches the caller's **own** claim, **own** worktree, and **own** box.
|
|
@@ -46,7 +46,7 @@ Proceed only when **all** hold: we're in a linked worktree, the claim is resolve
|
|
|
46
46
|
You can't remove the worktree you're standing in, so run the removal **from the main checkout** — resolve its path the way `ship.js` does (`findMainWorktree`: parse `git worktree list --porcelain` for the block whose `branch` is `main`). From there:
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
|
|
49
|
+
node scripts/gds/worktree.js remove <this-worktree-path> # junction-aware wrapper: unlinks a node_modules junction FIRST, then runs `git worktree remove` (which still refuses a dirty tree; do not --force)
|
|
50
50
|
git worktree prune
|
|
51
51
|
git branch -d claude/<worktree-name> # -d (not -D): git refuses an unmerged branch, by design
|
|
52
52
|
```
|
|
@@ -69,7 +69,7 @@ Skip this entirely if step 0 found no `/etc/otb/box.env` (a laptop — there's n
|
|
|
69
69
|
## Constraints
|
|
70
70
|
|
|
71
71
|
- **Own-scoped.** Only this session's claim, this session's worktree, and the caller's own box — never another builder's claim, never another worktree, never `git worktree remove` a path you didn't create, never another builder's box.
|
|
72
|
-
- **Never destroy uncommitted work.** The clean-git verify gates teardown; `git worktree remove`
|
|
72
|
+
- **Never destroy uncommitted work.** The clean-git verify gates teardown; the dirty-tree refusal in `git worktree remove` and `git branch -d` (not `-D`) are the load-bearing safety nets — keep them as written. But note: that refusal does NOT protect a Windows worktree carrying a `node_modules` junction — node_modules is gitignored, so the clean check passes and `git worktree remove` (even without `--force`) follows the junction and deletes the SHARED node_modules (task 1002809). That is why teardown runs through `node scripts/gds/worktree.js remove`, which unlinks the junction first.
|
|
73
73
|
- **Box close is opt-in and destructive.** On a box, never `close-box` without an explicit yes — it deprovisions the box and any unshipped `/workspace` work is gone. Declining is always fine; the box idle-parks on its own.
|
|
74
74
|
- **Never push / ssh / deploy.** Landing goes through `/builder-ship` (server-mediated); this skill stays local to the builder's machine (its one possible API write is the opt-in own-scoped `close-box`).
|
|
75
75
|
- **The destructive steps (teardown, box-close) never run unattended without a clear go.** Ship vs. release can be inferred from state (commits ⇒ ship, none ⇒ release) and proceed; removing the worktree ends the session and closing the box destroys it, so wait for an explicit yes before steps 3 and 4.
|
package/docs/file-map.md
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
│ ├── recipes/ ← long-form operational guides (formerly learnings/) — Postgres, Caddy, the pixel-art pipeline, etc.
|
|
57
57
|
│ │ ├── README.md
|
|
58
58
|
│ │ ├── ops-gotchas.md ← Postgres, Caddy, SSH, Mac toolchain recipes
|
|
59
|
+
│ │ ├── windows-builders.md ← (task 1002809) the Windows + Git Bash builder loop: worktree junction trap, api.js MSYS auto-repair, --body-file pattern, local test baseline
|
|
59
60
|
│ │ ├── pixel-art-pipeline.md
|
|
60
61
|
│ │ ├── overnight-builder.md ← (#358) operator runbook for the autonomous overnight session-runner
|
|
61
62
|
│ │ ├── builder-box-lifecycle.md ← (#598/ADR 0031) operator runbook: provision/park/wake/deprovision dev boxes, the two sweeps, config env, going-live steps
|
|
@@ -170,6 +171,7 @@
|
|
|
170
171
|
│ ├── smoke-box.sh ← (#598) hermetic box-lifecycle smoke (no DB/DO/network): runs tests/boxes.mjs + tests/cf_tunnel.mjs (#771 named tunnel) + route-rank + module-load
|
|
171
172
|
│ ├── recall.js ← `/recall` CLI: two-call progressive-disclosure search (summary → `--expand <id>`) over the GDS retrieval layer (ADR 0060); `node scripts/gds/recall.js <query>`
|
|
172
173
|
│ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills
|
|
174
|
+
│ ├── worktree.js ← (task 1002809) junction-safe worktree add/remove/list — outside-repo worktrees get a node_modules junction via node (no shell quoting), removed BEFORE `git worktree remove`
|
|
173
175
|
│ ├── learning-capture.js ← file a `learnings` row (DB-backed short-form rule capture, replacing hand-edited recipe files for a single gotcha)
|
|
174
176
|
│ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
|
|
175
177
|
│ ├── gen-diagrams.js ← generates the 5 onboarding diagrams (`.mmd`/`.svg`/`.png` + `assertions.json`) from templates in this file — never hand-edit a `.mmd`/assertions.json; runs on deploy
|
|
@@ -149,6 +149,8 @@ Organized by topic, not chronology. New entries go in the matching section; crea
|
|
|
149
149
|
|
|
150
150
|
## Windows / local dev (PowerShell)
|
|
151
151
|
|
|
152
|
+
> Git Bash builder-loop gotchas (worktree junctions, api.js path mangling, local test baseline) live in their own recipe: [windows-builders.md](windows-builders.md).
|
|
153
|
+
|
|
152
154
|
### PowerShell 5.1 turns a clean `node scripts/gds/*.js` exit into a false failure
|
|
153
155
|
|
|
154
156
|
- **Symptom:** Running `node scripts/gds/api.js GET /api/gds/me 2>&1` (or the same on `claim.js`/`ship.js`) inside a PowerShell 5.1 session reports failure — `$?` is `$false` and the stderr line surfaces as a `NativeCommandError` — even though node exited 0 and the JSON on stdout is exactly right.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
track: internal
|
|
3
|
+
scope: builder tooling — the Windows + Git Bash builder loop (worktrees, api.js, ship.js, local tests)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building on Windows (Git Bash) — the gotchas, and what the tooling now does for you
|
|
7
|
+
|
|
8
|
+
Everything here was paid for at least once on a real Windows box (task 1002548's learnings, the
|
|
9
|
+
HANDOFF-apex-ui session, task 1002809). Most of it is now **fixed in the tooling** — this recipe
|
|
10
|
+
records what changed, what you still have to know, and the one destructive trap to respect.
|
|
11
|
+
PowerShell-specific gotchas (stderr false-failures, BOM) live in
|
|
12
|
+
[ops-gotchas.md → Windows / local dev](ops-gotchas.md#windows--local-dev-powershell).
|
|
13
|
+
|
|
14
|
+
## api.js: MSYS path mangling is auto-repaired (no more `MSYS_NO_PATHCONV=1`)
|
|
15
|
+
|
|
16
|
+
- **Was:** Git Bash rewrites a leading-slash argument before node sees it — `node scripts/gds/api.js GET /tasks/123`
|
|
17
|
+
arrived as `GET C:/Program Files/Git/tasks/123` and hit the usage error, unless every call was
|
|
18
|
+
prefixed `MSYS_NO_PATHCONV=1`.
|
|
19
|
+
- **Now (task 1002809):** `api.js` detects the mangled prefix via `EXEPATH` and strips it itself
|
|
20
|
+
(`repairMsysMangledPath` in `cli-lib.js`). Call it plain; the old env prefix is harmless if you
|
|
21
|
+
still type it out of habit.
|
|
22
|
+
- **Still true:** judge success by the JSON + status line, not folklore about exit codes — and if
|
|
23
|
+
you ever see the libuv `UV_HANDLE_CLOSING` assertion after the JSON printed, that teardown class
|
|
24
|
+
of bug is tracked in [scripts/gds/CLAUDE.md](../../scripts/gds/CLAUDE.md) (task 1002734's
|
|
25
|
+
`cliExit` rule).
|
|
26
|
+
|
|
27
|
+
## api.js: inline `--body` loses its quotes — use `--body-file`
|
|
28
|
+
|
|
29
|
+
Shells on Windows strip/re-split inline JSON quoting. The durable pattern for any POST/PATCH body:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# write the JSON with a real editor/tool, then:
|
|
33
|
+
node scripts/gds/api.js POST /tasks --body-file C:/tmp/body.json
|
|
34
|
+
# or pipe it (no temp file):
|
|
35
|
+
node scripts/gds/api.js POST /claims --body-file - <<'EOF'
|
|
36
|
+
{"task_id": 123, "worktree_name": "task-123"}
|
|
37
|
+
EOF
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`api.js` now points you here when an inline `--body` fails to parse (task 1002809).
|
|
41
|
+
|
|
42
|
+
## Worktrees: prefer inside-repo; outside-repo needs the junction helper
|
|
43
|
+
|
|
44
|
+
Every claim gets its own real worktree (owner directive — see the `/builder-claim` skill). Two
|
|
45
|
+
placements work on Windows:
|
|
46
|
+
|
|
47
|
+
- **Default — `.claude/worktrees/<name>` (inside the repo).** No `node_modules` setup at all:
|
|
48
|
+
node's resolution walks up to the main checkout's copy. This is what the harness `EnterWorktree`
|
|
49
|
+
primitive creates.
|
|
50
|
+
- **Outside the repo (e.g. `C:/tmp/task-N`)** — useful because OneDrive briefly locks in-repo
|
|
51
|
+
dirs. An outside worktree can't walk up to `node_modules`, so it needs a **junction** — and this
|
|
52
|
+
is where the destructive trap lives. Use the helper, never hand-rolled `mklink`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node scripts/gds/worktree.js add <task-id> --outside # worktree + junction, no shell quoting
|
|
56
|
+
node scripts/gds/worktree.js remove task-<id> # unlinks the junction FIRST
|
|
57
|
+
node scripts/gds/worktree.js list # shows which worktrees carry junctions
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### ⚠ The trap (why the helper exists — task 1002809)
|
|
61
|
+
|
|
62
|
+
- `cmd //c mklink /J` from Git Bash **mangles quoted targets containing spaces** (the target grows
|
|
63
|
+
a leading `\`), producing a junction that resolves to nothing. The helper uses node's
|
|
64
|
+
`fs.symlinkSync(target, link, 'junction')` — no shell, no quoting, no elevation.
|
|
65
|
+
- `git worktree remove` (and `--force`) **follows a junction into its target** — with a hand-made
|
|
66
|
+
junction in place it deletes the **shared `node_modules` of the main checkout**. The helper
|
|
67
|
+
always unlinks the junction (link only) before calling git. If you ever bypass the helper:
|
|
68
|
+
remove the junction yourself first (`fs.rmdirSync` / `rmdir` on the link), then
|
|
69
|
+
`git worktree remove`.
|
|
70
|
+
|
|
71
|
+
## `Cannot find module 'pg'` (or any dep) inside a worktree
|
|
72
|
+
|
|
73
|
+
Run `npm install` in the **main checkout**, never in the worktree. Worktrees share the main
|
|
74
|
+
checkout's `node_modules` (by walk-up inside the repo, by junction outside); a second
|
|
75
|
+
`node_modules` inside a worktree only creates drift.
|
|
76
|
+
|
|
77
|
+
## OneDrive locks `git worktree remove` — the first attempt usually still worked
|
|
78
|
+
|
|
79
|
+
`git worktree remove` on an in-repo worktree often errors `Permission denied` / `Device or
|
|
80
|
+
resource busy` because OneDrive briefly locks the directory. The failed attempt usually still
|
|
81
|
+
**deregisters** the worktree (`git worktree list` to confirm — the helper's `remove` does this
|
|
82
|
+
check for you); the empty husk directory deletes on its own later. Don't fight it with retries.
|
|
83
|
+
|
|
84
|
+
## Local unit tests: baseline first, CI is truth
|
|
85
|
+
|
|
86
|
+
The local `tests/*.mjs` suite has a **large stable failure baseline on Windows boxes** (mostly
|
|
87
|
+
DB-backed tests with no local Postgres — ~184 failures at last count, and the count drifts).
|
|
88
|
+
Before blaming your change: run the suite once *before* touching anything (or on a clean
|
|
89
|
+
checkout), and compare against that. The CI `unit` check on the PR is the authoritative verdict.
|
|
90
|
+
|
|
91
|
+
## Never pipe `ship.js` through `head`/`tail`
|
|
92
|
+
|
|
93
|
+
When the pager closes the pipe mid-run, node dies on EPIPE and the land chain aborts silently —
|
|
94
|
+
the task strands at `confirmed` (hit on task 1001860). Run it unpiped or `> file`; a stranded
|
|
95
|
+
confirmed task re-lands by re-running `node scripts/gds/ship.js <id>` (resume is idempotent).
|
|
96
|
+
|
|
97
|
+
## ship.js: a failed post-land ship call may be a success — now auto-verified
|
|
98
|
+
|
|
99
|
+
The final `POST /tasks/<id>/ship` after a successful merge can be refused (`task_not_confirmed`)
|
|
100
|
+
because the server already landed the task first (reconcile-on-read or the ~5-min reconciler).
|
|
101
|
+
`ship.js` now normalizes both API error shapes and **verifies the live task status with a GET
|
|
102
|
+
before reporting failure** (task 1002809) — a verified `shipped` prints as the success it is. If
|
|
103
|
+
you see a ship-call failure from older tooling, check `GET /tasks/<id>` before reacting.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.121",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.121",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.121",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/api.js
CHANGED
|
@@ -18,12 +18,15 @@
|
|
|
18
18
|
// on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
|
|
19
19
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
|
-
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit } = require('./cli-lib');
|
|
21
|
+
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit, repairMsysMangledPath } = require('./cli-lib');
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
(async () => {
|
|
25
25
|
const method = (process.argv[2] || '').toUpperCase();
|
|
26
|
-
|
|
26
|
+
// task 1002809: Git Bash converts the leading-slash path arg into a Windows
|
|
27
|
+
// path under the Git install root before node sees it; undo that so builders
|
|
28
|
+
// don't need the MSYS_NO_PATHCONV=1 prefix.
|
|
29
|
+
const urlPath = repairMsysMangledPath(process.argv[3]);
|
|
27
30
|
if (!method || !urlPath || !urlPath.startsWith('/')) {
|
|
28
31
|
console.error("usage: node scripts/gds/api.js <METHOD> <path> [--body '<json>' | --body-file <file|->] [--quiet]");
|
|
29
32
|
process.exit(2);
|
|
@@ -46,6 +49,13 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
|
|
|
46
49
|
if (raw != null && raw.trim()) body = JSON.parse(raw);
|
|
47
50
|
} catch (e) {
|
|
48
51
|
console.error(`api: could not read/parse JSON body: ${e.message}`);
|
|
52
|
+
// task 1002809: inline --body is the usual culprit — shells (especially on
|
|
53
|
+
// Windows) strip or re-split the quotes before node sees them. Teach the
|
|
54
|
+
// durable pattern instead of leaving a bare parse error.
|
|
55
|
+
if (bodyFile == null && bodyInline != null) {
|
|
56
|
+
console.error(' Inline --body often loses its quoting through the shell. Durable pattern:');
|
|
57
|
+
console.error(' write the JSON to a file and pass --body-file <path>, or pipe it via --body-file -');
|
|
58
|
+
}
|
|
49
59
|
process.exit(2);
|
|
50
60
|
}
|
|
51
61
|
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -771,6 +771,34 @@ function parseSessionRequest(text) {
|
|
|
771
771
|
return { timeBudgetMin, mode, parallel };
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
// task 1002809: Git Bash (MSYS) rewrites a leading-slash argument into a Windows
|
|
775
|
+
// path under the Git install root BEFORE node sees it — `/tasks/123` arrives as
|
|
776
|
+
// `C:/Program Files/Git/tasks/123` — unless the builder remembers
|
|
777
|
+
// MSYS_NO_PATHCONV=1. Undo it here so no one needs the env prefix. EXEPATH (set
|
|
778
|
+
// by Git Bash) points INSIDE the install (`…\Git\bin`), while the mangled prefix
|
|
779
|
+
// is the install root (`…\Git`), so we strip the LONGEST ancestor of EXEPATH
|
|
780
|
+
// that prefixes the arg — never the bare drive root (that would "repair"
|
|
781
|
+
// arbitrary Windows paths). Case-insensitive, both slash styles. Pure + exported
|
|
782
|
+
// for tests/api_msys_path_repair.mjs; no-op off win32 or without EXEPATH.
|
|
783
|
+
function repairMsysMangledPath(raw, { exepath = process.env.EXEPATH, platform = process.platform } = {}) {
|
|
784
|
+
if (!raw || raw.startsWith('/')) return raw;
|
|
785
|
+
if (platform !== 'win32' || !exepath) return raw;
|
|
786
|
+
const rawFwd = String(raw).replace(/\\/g, '/');
|
|
787
|
+
const rawLower = rawFwd.toLowerCase();
|
|
788
|
+
let ancestor = String(exepath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
789
|
+
let best = null;
|
|
790
|
+
while (ancestor.includes('/')) {
|
|
791
|
+
// Stop before the bare drive root ("C:" after the last slice).
|
|
792
|
+
if (/^[a-z]:$/i.test(ancestor)) break;
|
|
793
|
+
if (rawLower.startsWith(ancestor.toLowerCase() + '/') && (best === null || ancestor.length > best.length)) {
|
|
794
|
+
best = ancestor;
|
|
795
|
+
}
|
|
796
|
+
ancestor = ancestor.slice(0, ancestor.lastIndexOf('/'));
|
|
797
|
+
}
|
|
798
|
+
if (best === null) return raw;
|
|
799
|
+
return rawFwd.slice(best.length);
|
|
800
|
+
}
|
|
801
|
+
|
|
774
802
|
module.exports = {
|
|
775
803
|
API_BASE,
|
|
776
804
|
hallBase,
|
|
@@ -784,6 +812,7 @@ module.exports = {
|
|
|
784
812
|
arg,
|
|
785
813
|
hasFlag,
|
|
786
814
|
stripBom,
|
|
815
|
+
repairMsysMangledPath,
|
|
787
816
|
resolveQuiet,
|
|
788
817
|
readSessionToken,
|
|
789
818
|
apiCall,
|
package/scripts/gds/ship.js
CHANGED
|
@@ -3122,6 +3122,40 @@ function playShipBell() {
|
|
|
3122
3122
|
playSound(shipWav);
|
|
3123
3123
|
}
|
|
3124
3124
|
|
|
3125
|
+
// task 1002809: normalize an API error body across BOTH wire shapes — the live
|
|
3126
|
+
// ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
|
|
3127
|
+
// `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
|
|
3128
|
+
// cli-lib:isBoxScope; pure + exported for tests/ship_post_land_verify.mjs.
|
|
3129
|
+
function normalizeApiError(data) {
|
|
3130
|
+
const errRaw = data ? data.error : undefined;
|
|
3131
|
+
const isEnvelope = errRaw !== null && typeof errRaw === 'object';
|
|
3132
|
+
return {
|
|
3133
|
+
code: isEnvelope ? errRaw.code : errRaw,
|
|
3134
|
+
message: isEnvelope ? errRaw.message : undefined,
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// task 1002809: classify a FAILED post-land POST /tasks/<id>/ship. The merge
|
|
3139
|
+
// already succeeded, so the server may simply have landed the task first
|
|
3140
|
+
// (reconcile-on-read — task 1049 — or the ~5-min reconciler racing this call).
|
|
3141
|
+
// The old check compared `data.error === 'task_not_confirmed'` — dead against
|
|
3142
|
+
// the envelope shape, so the already-landed SUCCESS rendered as a failure with
|
|
3143
|
+
// an "[object Object]" reason (the HANDOFF-apex-ui "400 after a successful
|
|
3144
|
+
// land" gotcha). liveStatus is the task's status from a verify GET (null when
|
|
3145
|
+
// that GET itself failed): 'shipped' is authoritative success whatever the
|
|
3146
|
+
// error said; task_not_confirmed with no live status keeps the task-1049
|
|
3147
|
+
// benefit of the doubt; any KNOWN non-shipped status stays a loud failure.
|
|
3148
|
+
// Pure + exported for tests/ship_post_land_verify.mjs.
|
|
3149
|
+
function postLandShipOutcome({ code, liveStatus }) {
|
|
3150
|
+
if (liveStatus === 'shipped') {
|
|
3151
|
+
return { alreadyShipped: true, why: 'the server landed it first — the failed ship call lost that race, nothing to fix' };
|
|
3152
|
+
}
|
|
3153
|
+
if (liveStatus == null && code === 'task_not_confirmed') {
|
|
3154
|
+
return { alreadyShipped: true, why: 'the server reconciled it from the merged PR' };
|
|
3155
|
+
}
|
|
3156
|
+
return { alreadyShipped: false };
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3125
3159
|
// Step 3 of the lifecycle: confirmed → shipped. Merge the branch into main,
|
|
3126
3160
|
// deploy, flip the task to shipped, and fire the Discord broadcast (only when
|
|
3127
3161
|
// the change actually deploys — #94 / ADR 0032; best-effort). Side-effecting;
|
|
@@ -3146,17 +3180,26 @@ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOn
|
|
|
3146
3180
|
|
|
3147
3181
|
const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: {} });
|
|
3148
3182
|
if (!ship.ok) {
|
|
3149
|
-
// task 1049: the server
|
|
3150
|
-
//
|
|
3151
|
-
//
|
|
3152
|
-
|
|
3153
|
-
|
|
3183
|
+
// task 1049 / 1002809: the server may have already flipped this task to
|
|
3184
|
+
// shipped (reconcile-on-read, or the reconciler racing this call). Verify
|
|
3185
|
+
// the LIVE status before treating the refusal as a failure — the old
|
|
3186
|
+
// flat-shape string check never matched the R01 envelope, so this success
|
|
3187
|
+
// path was dead and every reconciled land reported as a failed ship.
|
|
3188
|
+
const { code, message } = normalizeApiError(ship.data);
|
|
3189
|
+
let liveStatus = null;
|
|
3190
|
+
try {
|
|
3191
|
+
const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
|
|
3192
|
+
if (t.ok && t.data && t.data.task) liveStatus = t.data.task.status;
|
|
3193
|
+
} catch (_) { /* verify is best-effort; fall back to the code-based call */ }
|
|
3194
|
+
const outcome = postLandShipOutcome({ code, liveStatus });
|
|
3195
|
+
if (outcome.alreadyShipped) {
|
|
3196
|
+
console.log(`Task #${taskId} is now: shipped (${outcome.why}).`);
|
|
3154
3197
|
} else {
|
|
3155
|
-
|
|
3198
|
+
const e = message || code || `HTTP ${ship.status}`;
|
|
3199
|
+
console.error(` /tasks/${taskId}/ship failed: ${e}${liveStatus ? ` (task is at '${liveStatus}')` : ''}`);
|
|
3156
3200
|
// The merge itself succeeded — only the status flip failed, so a re-run
|
|
3157
3201
|
// resumes cleanly (task 941). Report it rather than letting the card claim
|
|
3158
3202
|
// the land is in progress.
|
|
3159
|
-
const e = (ship.data && (ship.data.message || ship.data.error)) || `HTTP ${ship.status}`;
|
|
3160
3203
|
return landBailed(`merged, but the ship call failed — ${e}`, `node scripts/gds/ship.js ${taskId}`);
|
|
3161
3204
|
}
|
|
3162
3205
|
} else {
|
|
@@ -4418,6 +4461,8 @@ module.exports = {
|
|
|
4418
4461
|
// task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
|
|
4419
4462
|
// 'pending' (success), not failure. Exported so the semantics are unit-tested.
|
|
4420
4463
|
classifyLandOutcome,
|
|
4464
|
+
normalizeApiError,
|
|
4465
|
+
postLandShipOutcome,
|
|
4421
4466
|
// task 1718: pure poll-path decision — enable-failure polls the SERVER (not a
|
|
4422
4467
|
// dead-end strand at confirmed), enable-success watches GitHub. Exported so the
|
|
4423
4468
|
// regression test locks in that a worktree enable-failure still waits for the land.
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/worktree.js — junction-safe worktree lifecycle (task 1002809).
|
|
3
|
+
//
|
|
4
|
+
// The trap this kills: a worktree OUTSIDE the repo root (C:/tmp/wt-N — used to
|
|
5
|
+
// dodge OneDrive file locks) can't resolve node_modules by walking up, so
|
|
6
|
+
// Windows builders hand-made junctions with `cmd //c mklink` from Git Bash —
|
|
7
|
+
// which mangles quoted targets containing spaces (dead junction) — and then
|
|
8
|
+
// `git worktree remove` FOLLOWED the junction and deleted the SHARED
|
|
9
|
+
// node_modules of the main checkout. This helper creates junctions with
|
|
10
|
+
// fs.symlinkSync(..., 'junction') (no shell, no quoting) and always removes the
|
|
11
|
+
// junction BEFORE git worktree remove. Worktrees INSIDE the repo
|
|
12
|
+
// (.claude/worktrees/<name>, the project default) need no junction at all.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]
|
|
16
|
+
// node scripts/gds/worktree.js remove <name|path> [--force]
|
|
17
|
+
// node scripts/gds/worktree.js list
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { execFileSync } = require('node:child_process');
|
|
22
|
+
const { arg, hasFlag } = require('./cli-lib');
|
|
23
|
+
|
|
24
|
+
// -- pure helpers (exported for tests/worktree_helper.mjs) --------------------
|
|
25
|
+
|
|
26
|
+
// A numeric task id becomes the conventional task-<id>; an explicit name passes
|
|
27
|
+
// through; anything with path separators or dot-traversal is rejected (null).
|
|
28
|
+
function worktreeNameFor(raw) {
|
|
29
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
30
|
+
if (!s || s === '.' || s === '..') return null;
|
|
31
|
+
if (/^\d+$/.test(s)) return `task-${s}`;
|
|
32
|
+
if (!/^[A-Za-z0-9._-]+$/.test(s)) return null;
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Is the worktree OUTSIDE the repo root? Inside the repo, node's resolution
|
|
37
|
+
// walks up to the main checkout's node_modules; outside it can't. Pure string
|
|
38
|
+
// compare (case-insensitive, both slash styles); callers pass absolute paths.
|
|
39
|
+
function isOutsideRepo({ wtPath, repoRoot }) {
|
|
40
|
+
const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
41
|
+
const wt = norm(wtPath);
|
|
42
|
+
const root = norm(repoRoot);
|
|
43
|
+
return !(wt === root || wt.startsWith(root + '/'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Only a win32 worktree outside the repo root gets a junction (the OneDrive
|
|
47
|
+
// dodge); posix symlink semantics differ and aren't the trap this fixes.
|
|
48
|
+
function needsJunction({ wtPath, repoRoot, platform }) {
|
|
49
|
+
if (platform !== 'win32') return false;
|
|
50
|
+
return isOutsideRepo({ wtPath, repoRoot });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ordered removal steps. The junction MUST go before git worktree remove (git
|
|
54
|
+
// follows it into the shared node_modules); a REAL node_modules dir is left for
|
|
55
|
+
// git to refuse — this tool never deletes real directories.
|
|
56
|
+
function removalPlan({ nodeModulesState }) {
|
|
57
|
+
return nodeModulesState === 'junction'
|
|
58
|
+
? ['unlink-junction', 'git-worktree-remove']
|
|
59
|
+
: ['git-worktree-remove'];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- CLI ----------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function git(args, opts = {}) {
|
|
65
|
+
return execFileSync('git', args, { encoding: 'utf8', ...opts }).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Main checkout root from any worktree — same derivation as merge-lock.js.
|
|
69
|
+
function mainWorktreeRoot() {
|
|
70
|
+
const common = git(['rev-parse', '--git-common-dir']);
|
|
71
|
+
const abs = path.isAbsolute(common) ? common : path.resolve(process.cwd(), common);
|
|
72
|
+
return path.dirname(abs);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listWorktrees(root) {
|
|
76
|
+
const out = git(['worktree', 'list', '--porcelain'], { cwd: root });
|
|
77
|
+
return out.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice('worktree '.length));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function nodeModulesState(wtPath) {
|
|
81
|
+
const p = path.join(wtPath, 'node_modules');
|
|
82
|
+
let st;
|
|
83
|
+
try { st = fs.lstatSync(p); } catch (_) { return 'absent'; }
|
|
84
|
+
return st.isSymbolicLink() ? 'junction' : 'real-dir';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveWorktreePath(root, nameOrPath) {
|
|
88
|
+
const all = listWorktrees(root);
|
|
89
|
+
const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
90
|
+
const byPath = all.find((w) => norm(w) === norm(nameOrPath));
|
|
91
|
+
if (byPath) return byPath;
|
|
92
|
+
// Case-insensitive, matching the filesystem (and the byPath compare above).
|
|
93
|
+
const byName = all.filter((w) => path.basename(w).toLowerCase() === String(nameOrPath).toLowerCase());
|
|
94
|
+
if (byName.length === 1) return byName[0];
|
|
95
|
+
if (byName.length > 1) {
|
|
96
|
+
console.error(`worktree: "${nameOrPath}" matches ${byName.length} worktrees — pass the full path:`);
|
|
97
|
+
for (const w of byName) console.error(` ${w}`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cmdAdd(root, argv) {
|
|
104
|
+
const name = worktreeNameFor(argv[1]);
|
|
105
|
+
if (!name) {
|
|
106
|
+
console.error('worktree add: pass a task id (→ task-<id>) or a simple name (letters/digits/._-).');
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
const explicitPath = arg('--path', argv);
|
|
110
|
+
const outside = hasFlag('--outside', argv);
|
|
111
|
+
// --outside exists to dodge OneDrive file locks — a Windows problem. On posix
|
|
112
|
+
// an outside-repo worktree would just lose node_modules resolution (no
|
|
113
|
+
// junction is created there), so refuse rather than hand back a silently
|
|
114
|
+
// broken tree; the in-repo default works everywhere.
|
|
115
|
+
if (outside && process.platform !== 'win32') {
|
|
116
|
+
console.error('worktree add: --outside is a Windows OneDrive dodge; use the default in-repo location here');
|
|
117
|
+
console.error(' (an outside-repo worktree on this platform cannot resolve the shared node_modules).');
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
const wtPath = path.resolve(explicitPath
|
|
121
|
+
|| (outside ? path.join('C:/tmp', name)
|
|
122
|
+
: path.join(root, '.claude', 'worktrees', name)));
|
|
123
|
+
// Default base origin/main so the branch starts current; best-effort fetch
|
|
124
|
+
// first (offline still works off the local ref).
|
|
125
|
+
const base = arg('--base', argv) || 'origin/main';
|
|
126
|
+
try { execFileSync('git', ['fetch', 'origin', 'main'], { cwd: root, stdio: 'ignore', timeout: 15000 }); } catch (_) { /* offline is fine */ }
|
|
127
|
+
|
|
128
|
+
console.log(`worktree add: ${wtPath} (branch ${name}, base ${base})`);
|
|
129
|
+
try {
|
|
130
|
+
execFileSync('git', ['worktree', 'add', wtPath, '-b', name, base], { cwd: root, stdio: 'inherit' });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error(`worktree add failed — ${e.message}`);
|
|
133
|
+
console.error(' (a branch of that name may already exist: remove it or pick another name)');
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (needsJunction({ wtPath, repoRoot: root, platform: process.platform })) {
|
|
138
|
+
const target = path.join(root, 'node_modules');
|
|
139
|
+
if (!fs.existsSync(target)) {
|
|
140
|
+
console.error(`⚠ ${target} does not exist — run npm install in the MAIN checkout first`);
|
|
141
|
+
console.error(' (worktrees never get their own node_modules; they share the main one).');
|
|
142
|
+
} else {
|
|
143
|
+
// fs.symlinkSync with type 'junction' needs no shell and no elevation —
|
|
144
|
+
// this is the fix for the mangled `cmd //c mklink` quoting trap.
|
|
145
|
+
fs.symlinkSync(target, path.join(wtPath, 'node_modules'), 'junction');
|
|
146
|
+
console.log(` node_modules junction → ${target}`);
|
|
147
|
+
console.log(' (remove this worktree with `node scripts/gds/worktree.js remove` — it unlinks the junction first;');
|
|
148
|
+
console.log(' a bare `git worktree remove` would follow the junction into the SHARED node_modules.)');
|
|
149
|
+
}
|
|
150
|
+
} else if (isOutsideRepo({ wtPath, repoRoot: root })) {
|
|
151
|
+
// Explicit --path outside the repo on posix: no junction is created here,
|
|
152
|
+
// so say plainly that node_modules won't resolve rather than fail later.
|
|
153
|
+
console.log(' ⚠ this worktree is outside the repo — node_modules will NOT resolve from here on this platform.');
|
|
154
|
+
}
|
|
155
|
+
console.log('');
|
|
156
|
+
console.log('Next:');
|
|
157
|
+
console.log(` cd "${wtPath}"`);
|
|
158
|
+
console.log(' node scripts/gds/claim.js <task-id> (worktree auto-detected)');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cmdRemove(root, argv) {
|
|
162
|
+
const target = argv[1];
|
|
163
|
+
if (!target) {
|
|
164
|
+
console.error('usage: node scripts/gds/worktree.js remove <name|path> [--force]');
|
|
165
|
+
process.exit(2);
|
|
166
|
+
}
|
|
167
|
+
const wtPath = resolveWorktreePath(root, target);
|
|
168
|
+
if (!wtPath) {
|
|
169
|
+
console.error(`worktree remove: "${target}" is not a registered worktree (see: git worktree list).`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
if (path.resolve(wtPath).toLowerCase() === path.resolve(root).toLowerCase()) {
|
|
173
|
+
console.error('worktree remove: refusing to remove the MAIN checkout.');
|
|
174
|
+
process.exit(2);
|
|
175
|
+
}
|
|
176
|
+
const state = nodeModulesState(wtPath);
|
|
177
|
+
for (const step of removalPlan({ nodeModulesState: state })) {
|
|
178
|
+
if (step === 'unlink-junction') {
|
|
179
|
+
// rmdir on a junction removes the LINK only, never the target's contents.
|
|
180
|
+
fs.rmdirSync(path.join(wtPath, 'node_modules'));
|
|
181
|
+
console.log(' removed node_modules junction (link only — the shared node_modules is untouched)');
|
|
182
|
+
} else {
|
|
183
|
+
const args = ['worktree', 'remove', ...(hasFlag('--force', argv) ? ['--force'] : []), wtPath];
|
|
184
|
+
try {
|
|
185
|
+
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
|
|
186
|
+
console.log(`worktree removed: ${wtPath}`);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// OneDrive briefly locks the dir; the failed attempt usually still
|
|
189
|
+
// DEREGISTERS the worktree — verify instead of fighting it with retries.
|
|
190
|
+
const still = listWorktrees(root).some((w) => path.resolve(w).toLowerCase() === path.resolve(wtPath).toLowerCase());
|
|
191
|
+
if (!still) {
|
|
192
|
+
console.log(`worktree deregistered: ${wtPath}`);
|
|
193
|
+
console.log(' (the directory husk is briefly locked — OneDrive — and deletes on its own later)');
|
|
194
|
+
} else {
|
|
195
|
+
console.error(`worktree remove failed — ${e.message}`);
|
|
196
|
+
// The worktree is still live but its junction is already unlinked —
|
|
197
|
+
// restore it so the tree stays usable (node_modules keeps resolving)
|
|
198
|
+
// instead of stranding the builder back at hand-rolled mklink.
|
|
199
|
+
if (state === 'junction') {
|
|
200
|
+
try {
|
|
201
|
+
fs.symlinkSync(path.join(root, 'node_modules'), path.join(wtPath, 'node_modules'), 'junction');
|
|
202
|
+
console.error(' node_modules junction restored — the worktree remains fully usable.');
|
|
203
|
+
} catch (restoreErr) {
|
|
204
|
+
console.error(` could not restore the node_modules junction (${restoreErr.message}) —`);
|
|
205
|
+
console.error(' re-create it: node -e "require(\'fs\').symlinkSync(\'<main>/node_modules\', \'<wt>/node_modules\', \'junction\')"');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (state === 'real-dir') {
|
|
209
|
+
console.error(' node_modules here is a REAL directory (not a junction) — if it and the other');
|
|
210
|
+
console.error(' untracked files are disposable, re-run with --force.');
|
|
211
|
+
} else {
|
|
212
|
+
console.error(' re-run with --force if the leftover untracked files are disposable.');
|
|
213
|
+
}
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
console.log(` (branch "${path.basename(wtPath)}" left in place — the land/ship pipeline owns branch cleanup)`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function cmdList(root) {
|
|
223
|
+
const all = listWorktrees(root);
|
|
224
|
+
for (const w of all) {
|
|
225
|
+
const isMain = path.resolve(w).toLowerCase() === path.resolve(root).toLowerCase();
|
|
226
|
+
const tag = isMain ? 'main checkout' : `node_modules: ${nodeModulesState(w)}`;
|
|
227
|
+
console.log(`${w} [${tag}]`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function main() {
|
|
232
|
+
const argv = process.argv.slice(2);
|
|
233
|
+
const cmd = argv[0];
|
|
234
|
+
let root;
|
|
235
|
+
try {
|
|
236
|
+
root = mainWorktreeRoot();
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.error('worktree: not a git repo (cannot resolve the main checkout):', e.message);
|
|
239
|
+
process.exit(2);
|
|
240
|
+
}
|
|
241
|
+
if (cmd === 'add') return cmdAdd(root, argv);
|
|
242
|
+
if (cmd === 'remove') return cmdRemove(root, argv);
|
|
243
|
+
if (cmd === 'list') return cmdList(root);
|
|
244
|
+
console.error('usage: node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]');
|
|
245
|
+
console.error(' node scripts/gds/worktree.js remove <name|path> [--force]');
|
|
246
|
+
console.error(' node scripts/gds/worktree.js list');
|
|
247
|
+
process.exit(2);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (require.main === module) main();
|
|
251
|
+
|
|
252
|
+
module.exports = { worktreeNameFor, needsJunction, removalPlan, nodeModulesState };
|
package/src/module-api.js
CHANGED
|
@@ -667,8 +667,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
667
667
|
// landed since 1.19.118 with no explicit bump. run 31835957309. (task 1002620)
|
|
668
668
|
// 1.19.120 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
669
669
|
// landed since 1.19.119 with no explicit bump. run 31836959112. (task 1002620)
|
|
670
|
+
// 1.19.121 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
671
|
+
// landed since 1.19.120 with no explicit bump. run 31838955115. (task 1002620)
|
|
670
672
|
// ---------------------------------------------------------------------------
|
|
671
|
-
const CORE_VERSION = '1.19.
|
|
673
|
+
const CORE_VERSION = '1.19.121'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
672
674
|
|
|
673
675
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
674
676
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// tests/api_msys_path_repair.mjs
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for repairMsysMangledPath (task 1002809). Git Bash (MSYS) rewrites
|
|
4
|
+
// a leading-slash argument into a Windows path under the Git install root before
|
|
5
|
+
// node ever sees it — `/tasks/123` arrives as `C:/Program Files/Git/tasks/123` —
|
|
6
|
+
// unless the builder remembers MSYS_NO_PATHCONV=1. api.js undoes that mangling
|
|
7
|
+
// itself so no builder needs the env prefix.
|
|
8
|
+
//
|
|
9
|
+
// Contract proved here:
|
|
10
|
+
// 1. A path that already starts with '/' is returned untouched (the repair is
|
|
11
|
+
// a no-op everywhere the shell behaved).
|
|
12
|
+
// 2. A mangled path is repaired by stripping the LONGEST ancestor of EXEPATH
|
|
13
|
+
// that prefixes it — EXEPATH is `<git-root>\bin` while the msys root that
|
|
14
|
+
// prefixes mangled args is `<git-root>`, so ancestor-walking is required.
|
|
15
|
+
// 3. The drive root alone is never treated as the msys root (stripping `C:/`
|
|
16
|
+
// would "repair" any Windows path into nonsense).
|
|
17
|
+
// 4. Non-win32, missing EXEPATH, and unrelated Windows paths pass through.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/api_msys_path_repair.mjs
|
|
20
|
+
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { repairMsysMangledPath } = require('../scripts/gds/cli-lib.js');
|
|
27
|
+
|
|
28
|
+
let passed = 0;
|
|
29
|
+
let failed = 0;
|
|
30
|
+
function test(name, fn) {
|
|
31
|
+
try {
|
|
32
|
+
fn();
|
|
33
|
+
passed++;
|
|
34
|
+
console.log(` ok ${name}`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
failed++;
|
|
37
|
+
console.log(` FAIL ${name}`);
|
|
38
|
+
console.log(` ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function eq(actual, expected, msg) {
|
|
42
|
+
if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const GIT_BASH = { exepath: 'C:\\Program Files\\Git\\bin', platform: 'win32' };
|
|
46
|
+
|
|
47
|
+
test('already-correct path is untouched', () => {
|
|
48
|
+
eq(repairMsysMangledPath('/api/gds/inbox', GIT_BASH), '/api/gds/inbox');
|
|
49
|
+
eq(repairMsysMangledPath('/tasks/1002809', GIT_BASH), '/tasks/1002809');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('mangled path is repaired via EXEPATH ancestor (forward slashes)', () => {
|
|
53
|
+
eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1002809', GIT_BASH), '/tasks/1002809');
|
|
54
|
+
eq(repairMsysMangledPath('C:/Program Files/Git/api/gds/inbox', GIT_BASH), '/api/gds/inbox');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('mangled path with backslashes is repaired and normalized', () => {
|
|
58
|
+
eq(repairMsysMangledPath('C:\\Program Files\\Git\\claims', GIT_BASH), '/claims');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('prefix match is case-insensitive (Windows paths are)', () => {
|
|
62
|
+
eq(repairMsysMangledPath('c:/program files/git/tasks/5', GIT_BASH), '/tasks/5');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('longest matching ancestor wins (a legit /Git/... path survives)', () => {
|
|
66
|
+
// Original arg /Git/tasks/9 mangles to <root>/Git/tasks/9 — stripping the
|
|
67
|
+
// git root must give back /Git/tasks/9, not /tasks/9.
|
|
68
|
+
eq(repairMsysMangledPath('C:/Program Files/Git/Git/tasks/9', GIT_BASH), '/Git/tasks/9');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('drive root alone is never stripped', () => {
|
|
72
|
+
// Root-level install: EXEPATH C:\Git\bin → msys root C:/Git is strippable...
|
|
73
|
+
eq(repairMsysMangledPath('C:/Git/tasks/7', { exepath: 'C:\\Git\\bin', platform: 'win32' }), '/tasks/7');
|
|
74
|
+
// ...but C:/ by itself is not a valid msys root — an unrelated Windows path
|
|
75
|
+
// must pass through unchanged, not get "repaired" into /tmp/x.json.
|
|
76
|
+
eq(repairMsysMangledPath('C:/tmp/x.json', GIT_BASH), 'C:/tmp/x.json');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('non-win32 platform passes through', () => {
|
|
80
|
+
eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1', { exepath: 'C:\\Program Files\\Git\\bin', platform: 'linux' }), 'C:/Program Files/Git/tasks/1');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('missing EXEPATH passes through (empty = not a Git Bash shell)', () => {
|
|
84
|
+
eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1', { exepath: '', platform: 'win32' }), 'C:/Program Files/Git/tasks/1');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('empty/undefined input passes through', () => {
|
|
88
|
+
eq(repairMsysMangledPath('', GIT_BASH), '');
|
|
89
|
+
eq(repairMsysMangledPath(undefined, GIT_BASH), undefined);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Static wiring check (the cli_exit_no_abort.mjs pattern): the pure helper being
|
|
93
|
+
// correct is worthless if api.js stops applying it to its positional path arg —
|
|
94
|
+
// that exact mutation survived every behavioural test here.
|
|
95
|
+
test('api.js applies the repair to process.argv[3] (static)', () => {
|
|
96
|
+
const src = fs.readFileSync(fileURLToPath(new URL('../scripts/gds/api.js', import.meta.url)), 'utf8');
|
|
97
|
+
if (!/repairMsysMangledPath\(\s*process\.argv\[3\]\s*\)/.test(src)) {
|
|
98
|
+
throw new Error('api.js no longer wraps process.argv[3] in repairMsysMangledPath(...)');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log(`api_msys_path_repair: ${passed} passed, ${failed} failed`);
|
|
104
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// tests/ship_post_land_verify.mjs
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for the post-land ship-call outcome (task 1002809). After a
|
|
4
|
+
// successful merge, ship.js POSTs /tasks/<id>/ship; the server may have landed
|
|
5
|
+
// the task first (reconcile-on-read, or the ~5-min reconciler racing this call),
|
|
6
|
+
// in which case it refuses with task_not_confirmed. The old code compared
|
|
7
|
+
// `ship.data.error === 'task_not_confirmed'` — the legacy FLAT shape — but the
|
|
8
|
+
// live server emits the ADR 0116 (R01) envelope `{ error: { code, … } }`, so the
|
|
9
|
+
// string test never matched and an already-landed SUCCESS rendered as
|
|
10
|
+
// "merged, but the ship call failed — [object Object]" (the HANDOFF-apex-ui
|
|
11
|
+
// '400 task_not_confirmed AFTER a successful land' gotcha). Same bug class as
|
|
12
|
+
// claim.js task 1476 / cli-lib isBoxScope task 1002162.
|
|
13
|
+
//
|
|
14
|
+
// What we prove here (pure helpers only — the live GET verify is exercised by
|
|
15
|
+
// every real ship):
|
|
16
|
+
// 1. normalizeApiError reads BOTH body shapes (envelope + flat) and survives
|
|
17
|
+
// missing/odd bodies.
|
|
18
|
+
// 2. postLandShipOutcome: a live status of 'shipped' is SUCCESS whatever the
|
|
19
|
+
// error code or HTTP status was (the GET verify is authoritative).
|
|
20
|
+
// 3. task_not_confirmed with the verify GET unavailable keeps the task-1049
|
|
21
|
+
// benefit-of-the-doubt success (the server reconciled it).
|
|
22
|
+
// 4. A known live status that is NOT shipped stays a loud failure — never
|
|
23
|
+
// paper over a genuinely stranded task.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/ship_post_land_verify.mjs
|
|
26
|
+
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ship = require('../scripts/gds/ship.js');
|
|
31
|
+
const { normalizeApiError, postLandShipOutcome } = ship;
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
let failed = 0;
|
|
35
|
+
function test(name, fn) {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
passed++;
|
|
39
|
+
console.log(` ok ${name}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
failed++;
|
|
42
|
+
console.log(` FAIL ${name}`);
|
|
43
|
+
console.log(` ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function eq(actual, expected, msg) {
|
|
47
|
+
if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- 1. normalizeApiError: both wire shapes ----
|
|
51
|
+
|
|
52
|
+
test('normalizeApiError reads the R01 envelope', () => {
|
|
53
|
+
const { code, message } = normalizeApiError({ error: { code: 'task_not_confirmed', message: 'not at confirmed' } });
|
|
54
|
+
eq(code, 'task_not_confirmed');
|
|
55
|
+
eq(message, 'not at confirmed');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('normalizeApiError reads the legacy flat shape', () => {
|
|
59
|
+
const { code, message } = normalizeApiError({ error: 'task_not_confirmed' });
|
|
60
|
+
eq(code, 'task_not_confirmed');
|
|
61
|
+
eq(message, undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('normalizeApiError survives missing/odd bodies', () => {
|
|
65
|
+
eq(normalizeApiError(null).code, undefined);
|
|
66
|
+
eq(normalizeApiError({}).code, undefined);
|
|
67
|
+
eq(normalizeApiError({ error: { message: 'no code' } }).message, 'no code');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('normalizeApiError survives { error: null } (typeof null is object)', () => {
|
|
71
|
+
eq(normalizeApiError({ error: null }).code, null);
|
|
72
|
+
eq(normalizeApiError({ error: null }).message, undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---- 2. live status 'shipped' is success regardless of code/status ----
|
|
76
|
+
|
|
77
|
+
test('verified shipped ⇒ success even on an unexpected code', () => {
|
|
78
|
+
const out = postLandShipOutcome({ code: 'internal_error', liveStatus: 'shipped' });
|
|
79
|
+
eq(out.alreadyShipped, true);
|
|
80
|
+
if (!out.why || typeof out.why !== 'string') throw new Error('expected a human why-line');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('verified shipped ⇒ success on task_not_confirmed (409 or 400 alike)', () => {
|
|
84
|
+
eq(postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'shipped' }).alreadyShipped, true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---- 3. verify GET unavailable: keep the task-1049 reconcile success ----
|
|
88
|
+
|
|
89
|
+
test('task_not_confirmed with no live status keeps the reconciled success', () => {
|
|
90
|
+
const out = postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: null });
|
|
91
|
+
eq(out.alreadyShipped, true);
|
|
92
|
+
// The why feeds `shipped (${why})` directly — an absent why prints
|
|
93
|
+
// "shipped (undefined)", the same garbage-output class this task fixes.
|
|
94
|
+
if (!out.why || typeof out.why !== 'string') throw new Error('expected a human why-line');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an unknown code with no live status is a failure (no blind success)', () => {
|
|
98
|
+
eq(postLandShipOutcome({ code: 'internal_error', liveStatus: null }).alreadyShipped, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---- 4. a known non-shipped status stays a loud failure ----
|
|
102
|
+
|
|
103
|
+
test('task_not_confirmed but live status confirmed ⇒ failure, not success', () => {
|
|
104
|
+
const out = postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'confirmed' });
|
|
105
|
+
eq(out.alreadyShipped, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('task_not_confirmed but live status completed ⇒ failure', () => {
|
|
109
|
+
eq(postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'completed' }).alreadyShipped, false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(`ship_post_land_verify: ${passed} passed, ${failed} failed`);
|
|
114
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// tests/worktree_helper.mjs
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for the pure decision helpers behind scripts/gds/worktree.js
|
|
4
|
+
// (task 1002809) — the junction-safe worktree lifecycle for Windows builders.
|
|
5
|
+
//
|
|
6
|
+
// The trap this tooling kills: a worktree OUTSIDE the repo root (C:/tmp/wt-N,
|
|
7
|
+
// used to dodge OneDrive locks) needs a node_modules junction back to the main
|
|
8
|
+
// checkout; `cmd //c mklink` from Git Bash mangles quoted targets with spaces
|
|
9
|
+
// (dead junction), and `git worktree remove` FOLLOWS the junction and deletes
|
|
10
|
+
// the SHARED node_modules. The helper creates junctions with
|
|
11
|
+
// fs.symlinkSync(..., 'junction') (no shell involved) and always removes the
|
|
12
|
+
// junction BEFORE git worktree remove.
|
|
13
|
+
//
|
|
14
|
+
// What we prove here:
|
|
15
|
+
// 1. worktreeNameFor: a numeric task id becomes task-<id>; an explicit name
|
|
16
|
+
// passes through; junk is rejected.
|
|
17
|
+
// 2. needsJunction: only win32 AND a path outside the repo root.
|
|
18
|
+
// 3. removalPlan: a junction is unlinked BEFORE git worktree remove; a real
|
|
19
|
+
// node_modules dir is never touched by us (git refuses; we surface that);
|
|
20
|
+
// absent node_modules goes straight to git worktree remove.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/worktree_helper.mjs
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const { worktreeNameFor, needsJunction, removalPlan, nodeModulesState } = require('../scripts/gds/worktree.js');
|
|
31
|
+
|
|
32
|
+
let passed = 0;
|
|
33
|
+
let failed = 0;
|
|
34
|
+
function test(name, fn) {
|
|
35
|
+
try {
|
|
36
|
+
fn();
|
|
37
|
+
passed++;
|
|
38
|
+
console.log(` ok ${name}`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
failed++;
|
|
41
|
+
console.log(` FAIL ${name}`);
|
|
42
|
+
console.log(` ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function eq(actual, expected, msg) {
|
|
46
|
+
if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
47
|
+
}
|
|
48
|
+
function eqArr(actual, expected, msg) {
|
|
49
|
+
eq(JSON.stringify(actual), JSON.stringify(expected), msg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- 1. worktreeNameFor ----
|
|
53
|
+
|
|
54
|
+
test('numeric task id becomes task-<id>', () => {
|
|
55
|
+
eq(worktreeNameFor('1002809'), 'task-1002809');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('an explicit task-N name passes through', () => {
|
|
59
|
+
eq(worktreeNameFor('task-77'), 'task-77');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a plain name passes through', () => {
|
|
63
|
+
eq(worktreeNameFor('spike-tiles'), 'spike-tiles');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('junk (path separators, empty) is rejected with null', () => {
|
|
67
|
+
eq(worktreeNameFor('a/b'), null);
|
|
68
|
+
eq(worktreeNameFor('..'), null);
|
|
69
|
+
eq(worktreeNameFor(''), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---- 2. needsJunction ----
|
|
73
|
+
|
|
74
|
+
const REPO = 'C:\\Users\\rocke\\OneDrive\\Desktop\\Cloud Bongos\\cloud-bongos';
|
|
75
|
+
|
|
76
|
+
test('outside-repo worktree on win32 needs a junction', () => {
|
|
77
|
+
eq(needsJunction({ wtPath: 'C:\\tmp\\task-9', repoRoot: REPO, platform: 'win32' }), true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('inside-repo worktree resolves node_modules by walking up — no junction', () => {
|
|
81
|
+
eq(needsJunction({ wtPath: REPO + '\\.claude\\worktrees\\task-9', repoRoot: REPO, platform: 'win32' }), false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('non-win32 never gets a junction (symlink semantics differ; not the trap)', () => {
|
|
85
|
+
eq(needsJunction({ wtPath: '/tmp/task-9', repoRoot: '/home/u/repo', platform: 'linux' }), false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a sibling dir that merely shares the repo prefix is still outside', () => {
|
|
89
|
+
eq(needsJunction({ wtPath: REPO + '-other\\task-9', repoRoot: REPO, platform: 'win32' }), true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('path compare is case-insensitive (Windows filesystems are)', () => {
|
|
93
|
+
eq(needsJunction({ wtPath: REPO.toUpperCase() + '\\.CLAUDE\\WORKTREES\\TASK-9', repoRoot: REPO, platform: 'win32' }), false);
|
|
94
|
+
eq(needsJunction({ wtPath: 'c:\\TMP\\task-9', repoRoot: REPO, platform: 'win32' }), true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- 2b. nodeModulesState — the classifier that picks the removal order ----
|
|
98
|
+
// Behavioural, against a real scratch dir: a dir-symlink stands in for the
|
|
99
|
+
// junction (node's lstat reports both as symbolic links, on every platform).
|
|
100
|
+
|
|
101
|
+
test('nodeModulesState: absent / real-dir / junction classified from disk', () => {
|
|
102
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-helper-test-'));
|
|
103
|
+
try {
|
|
104
|
+
eq(nodeModulesState(path.join(base, 'wt-absent')), 'absent');
|
|
105
|
+
|
|
106
|
+
const wtReal = path.join(base, 'wt-real');
|
|
107
|
+
fs.mkdirSync(path.join(wtReal, 'node_modules'), { recursive: true });
|
|
108
|
+
eq(nodeModulesState(wtReal), 'real-dir');
|
|
109
|
+
|
|
110
|
+
const target = path.join(base, 'shared-nm');
|
|
111
|
+
fs.mkdirSync(target);
|
|
112
|
+
fs.writeFileSync(path.join(target, 'canary.txt'), 'x');
|
|
113
|
+
const wtJct = path.join(base, 'wt-junction');
|
|
114
|
+
fs.mkdirSync(wtJct);
|
|
115
|
+
const link = path.join(wtJct, 'node_modules');
|
|
116
|
+
fs.symlinkSync(target, link, 'junction');
|
|
117
|
+
eq(nodeModulesState(wtJct), 'junction');
|
|
118
|
+
|
|
119
|
+
// Removing the link (the helper's unlink step) must not touch the target.
|
|
120
|
+
// win32 junctions need rmdir; a posix dir-symlink needs unlink — cover both
|
|
121
|
+
// so this file passes on Linux CI and the real Windows box alike.
|
|
122
|
+
try { fs.rmdirSync(link); } catch (_) { fs.unlinkSync(link); }
|
|
123
|
+
eq(fs.existsSync(path.join(target, 'canary.txt')), true, 'target survives link removal');
|
|
124
|
+
} finally {
|
|
125
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---- 3. removalPlan — junction-first ordering is the whole point ----
|
|
130
|
+
|
|
131
|
+
test('junction node_modules: unlink FIRST, then git worktree remove', () => {
|
|
132
|
+
eqArr(removalPlan({ nodeModulesState: 'junction' }), ['unlink-junction', 'git-worktree-remove']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('absent node_modules: straight to git worktree remove', () => {
|
|
136
|
+
eqArr(removalPlan({ nodeModulesState: 'absent' }), ['git-worktree-remove']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a REAL node_modules dir is left for git to refuse — we never rm it', () => {
|
|
140
|
+
eqArr(removalPlan({ nodeModulesState: 'real-dir' }), ['git-worktree-remove']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(`worktree_helper: ${passed} passed, ${failed} failed`);
|
|
145
|
+
process.exit(failed > 0 ? 1 : 0);
|