* enhance(#4377): opt-in project-relative includes for local installs A local install wrote the includes that point at GSD's own files as absolute paths — whatever the installer resolved at install time. For one checkout that is invisible. Across git worktrees it is not: each worktree gets its own .claude/ copy, but all of them point back at the checkout that ran the installer, so a worktree runs its own gsd-tools.cjs while reading workflow prose from a different checkout. Update that one checkout and every other worktree is running new instructions against an old engine, with nothing to stage the update with. --relative-includes (or GSD_RELATIVE_INCLUDES=1) makes a local install emit `@.claude/gsd-core/...`. Opt-in, and staying opt-in: absolute works for a single checkout, which is most people, and flipping the default would change every existing local install to solve a problem those users do not have. The prefix is the runtime's own localConfigDir descriptor value, never a literal — the same value resolveScope joins onto the cwd to produce the install target, and the same one the rewrite engine already uses for its ./.claude/ -> ./<dir>/ substitutions. Copilot and Antigravity have shipped this shape for local installs since they were added, with hardcoded .github/ and .agents/; this is that behavior, derived rather than written down. Six seams compute a path prefix and all six had to be threaded, which is why the opt-in travels through the environment the way --portable-hooks already does: one variable they all read cannot fall out of sync the way six signatures can. The launcher shim deliberately keeps its ABSOLUTE fallbacks. It probes gsd-tools through ${CLAUDE_CONFIG_DIR:-$HOME/.claude} and one such default per runtime; those are shell word expansions, not includes, and a relative value there resolves against the shell's cwd rather than the project. Trading an include that points at the wrong checkout for a path that points at nothing is not a fix. All three rewrite paths mask ${VAR:-default} spans before substituting and restore them after, and the mask only runs when the prefix is relative, so an absolute install is byte-for-byte unchanged. Every unexpressible case falls back to absolute: no opt-in, a global install, a missing dir name, the configHome.kind === 'none' sentinel, an absolute descriptor value, or one climbing out of the project with '..'. * chore(#4377): add changeset for project-relative local includes * fix(#4377): compare against POSIX-normalized roots in the install e2e arms The emitted prefix is POSIX-normalized by design — it is substituted into markdown @-references, which use forward slashes universally, so a backslash would leak into shipped content (#1615). The e2e arms compared against the raw temp root, which on Windows is `D:\a\...` and appears in no emitted file. That reddened the control arm on the windows shard, and it was worse than a red: the negative arm ("nothing references the checkout") was passing VACUOUSLY there, because a string that cannot occur is trivially absent. Both now go through the same normalization, so the Windows lane asserts what the Linux lane does. * fix(#4377): tolerate a resolved temp root, and make the e2e diff self-diagnosing Two changes, one confirmed and one to stop guessing. Confirmed: the emitted content carries the RESOLVED root, not the spelling mkdtemp handed back. Reproduced on Linux with a symlinked install root — 236 emitted files carry the realpath, zero carry the link path. macOS has this structurally, since /var is a symlink to /private/var. Comparisons now go through both spellings, or the negative arms pass vacuously: "nothing references the checkout" is trivially true when the string being searched for cannot occur. Not confirmed: the macOS shard reported ~every workflow file differing in the "differ ONLY" arm while the five arms around it passed, and the assertion printed a list of filenames — which says a difference exists somewhere across 236 files and leaves the reader to guess which bytes. I cannot reproduce that platform locally, and guessing turns one CI round-trip into four. The assertion now reports the first divergence as text: the file, the byte offset, and a bounded window of both sides. * fix(#4377): strip the longest root spelling first in the install e2e diff The macOS failure was my test corrupting its own comparison, not a product defect. /var/folders/…/X is a SUBSTRING of /private/var/folders/…/X, so stripping the unresolved spelling first matched inside the resolved one and left the /private prefix glued to what followed: @/private/var/…/X/.claude/gsd-core/… -> @/private.claude/gsd-core/… a string present in neither install, which is why all 236 files "differed". Sorting the spellings longest-first consumes the whole occurrence, and the short form then has nothing left to match. Proven in isolation on the exact macOS shapes: short-first yields @/private.claude/…, longest-first yields @.claude/…. The self-diagnosing assertion added in the previous commit is what found this — it named the file, the byte offset, and printed both sides, so the corrupted string was visible rather than inferred from a list of 236 filenames. Keeping it. * fix(#4377): address review findings * test(#4377): scan nested shell defaults without regex backtracking * fix(#4377): close relative include review gaps * fix(#4377): preserve root-target runtime includes * fix(#4377): guard project-root relative includes * test(#4377): normalize Cline fallback roots * fix(#4377): persist relative include style --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -553,6 +553,48 @@ diagnostic and exits non-zero when nothing resolves.
|
||||
|
||||
---
|
||||
|
||||
## Local installs across several git worktrees
|
||||
|
||||
A local (`--local`) install writes the includes that point at GSD's own installed
|
||||
files as absolute paths — the path the installer resolved at install time. For a
|
||||
single checkout that is invisible and works fine.
|
||||
|
||||
It stops working once the same repository is checked out more than once. Each git
|
||||
worktree gets its own `.claude/` copy, but every one of those copies points back at
|
||||
the checkout that ran the installer. So a worktree runs its own `gsd-tools.cjs`
|
||||
(correctly resolved through `git rev-parse --show-toplevel`) while reading its
|
||||
workflow prose out of a different checkout — and the moment you update that one
|
||||
checkout, every other worktree is executing new instructions against an old engine,
|
||||
with no way to stage the update.
|
||||
|
||||
Install with `--relative-includes` (or `GSD_RELATIVE_INCLUDES=1`) to write the
|
||||
includes relative to the project instead:
|
||||
|
||||
```bash
|
||||
npx @opengsd/gsd-core@latest --claude --local --relative-includes
|
||||
```
|
||||
|
||||
Every emitted `@` include then reads `@.claude/gsd-core/...` rather than
|
||||
`@/absolute/path/to/checkout/.claude/gsd-core/...`, so each worktree resolves its
|
||||
own copy and the worktrees become independent.
|
||||
|
||||
Notes:
|
||||
|
||||
- **It is opt-in and stays opt-in.** Absolute includes work for a single checkout,
|
||||
which is most people; the flag exists for those who need it.
|
||||
- **Global installs are unaffected.** They keep their `$HOME`-relative form, which
|
||||
is already checkout-independent.
|
||||
- **The runtime launcher keeps its absolute fallbacks.** The shell snippet that
|
||||
locates `gsd-tools.cjs` probes `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` and one such
|
||||
default per runtime; those are shell word expansions, not includes, and a relative
|
||||
value there would resolve against the current shell's directory rather than the
|
||||
project. The launcher already probes `$(git rev-parse --show-toplevel)/.claude`
|
||||
first, so it finds the current worktree before it ever reaches those defaults.
|
||||
- **`--relative-includes` with `--global` does nothing.** The flag is only consulted
|
||||
for local installs.
|
||||
|
||||
---
|
||||
|
||||
## Installing without Node.js
|
||||
|
||||
If you cannot run `npx` (for example, on a Windows machine without Node.js), you have two options.
|
||||
|
||||
Reference in New Issue
Block a user