From a99aec779ce663f419ef5ec9bdbb451d9b388193 Mon Sep 17 00:00:00 2001 From: Tien Dung Dao Date: Mon, 24 Aug 2026 08:47:25 +0700 Subject: [PATCH 1/3] fix(agents): keep Windows checkouts and rendered docs platform-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects that only surface on a Windows checkout, found while enumerating the failures for the windows-latest matrix leg (#652). 1. No .gitattributes, so a Windows checkout rewrites every text file to CRLF (Git for Windows installs with core.autocrlf=true, the GitHub windows runner included). Two consequences: - `internal/agents/opencode/plugin/gortex.js` and `internal/agents/pi/extension/index.ts` are go:embed'd and written verbatim into a user's project, so a source build on Windows ships CRLF assets. TestPluginFailsOpen already catches this: it splits pluginSource on "\n}\n", which no longer matches. - `gofmt -l` flags every Go file in the tree, so a formatting gate on the Windows leg fails wholesale and a Windows developer cannot tell real findings from the noise. The committed blobs are already LF — `git add --renormalize .` reports no content change — so this pins the checkout and rewrites nothing. 2. GlobalPointerBody built the @-include with filepath.Join, embedding `@C:\Users\me\.gortex\instructions\active.md` into ~/.claude/CLAUDE.md. That line is document content, not a filesystem call, and every other path in the same file is '/'-spelled. shellSafeHookBinary already normalises for exactly this reason. UpsertMarkedBlock keys idempotency on the markers, not on the path, so an existing install has its block rewritten in place on the next run. 3. normalizeRender scrubbed only the native spelling of HOME, the repo root and the resolved gortex binary, but a rendered manifest carries '/'-spelled paths by design. On Windows the substitution missed, so TestAgentsRenderGolden leaked a machine-specific absolute into the comparison and drifted for any developer with gortex on PATH. Verification (windows/amd64, go1.26.6, -count=1): internal/agents ok (was ok) internal/agents/opencode 1 -> 0 failures cmd/gortex 19 -> 18 failures internal/agents/claudecode 2 -> 2 failures (both pre-existing: TestResolveHookCommand, TestEmitPluginBundle_HookHandlerExecBit) Newly-broken set is empty. Each fix was sabotage-verified on its own: reverting (1) fails TestPluginFailsOpen, reverting (2) drifts the claude-code golden, reverting (3) drifts claude-code and hermes. The two updated assertions built their expected @-include with filepath.Join, which asserts the native mangling on Windows and passes there whether or not the renderer normalises; they now use path.Join / filepath.ToSlash. As in #646, none of this can fail on the linux/macos matrix — filepath.ToSlash is a no-op on POSIX — so the Windows runner is the only place these bind. --- .gitattributes | 21 ++++++++++++++++++ .../agents/claudecode/install_diet_test.go | 9 ++++++-- internal/agents/instructions.go | 8 ++++++- internal/agents/instructions_test.go | 8 +++++-- internal/agents/render.go | 22 ++++++++++++++++--- 5 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..bdf8ccf7a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +# Line endings are a property of the repository, not of the checkout. +# +# Several text assets are consumed byte-for-byte rather than line-by-line: +# `internal/agents/opencode/plugin/gortex.js` and +# `internal/agents/pi/extension/index.ts` are compiled into the binary with +# go:embed and written verbatim into a user's project, and the golden files +# under `cmd/gortex/testdata/` are compared against rendered output. Git for +# Windows installs with `core.autocrlf=true` — the GitHub windows runner +# included — so without this file a Windows checkout rewrites those assets to +# CRLF, which both changes what the binary ships and breaks the comparisons. +# +# The committed blobs are already LF, so this pins the checkout; it does not +# rewrite history or content. +* text=auto eol=lf + +# PowerShell is the one place a CRLF checkout is still the convention. +*.ps1 text eol=crlf + +# Never guess at these. +*.png binary +*.gz binary diff --git a/internal/agents/claudecode/install_diet_test.go b/internal/agents/claudecode/install_diet_test.go index 8116400b4..e35e560ab 100644 --- a/internal/agents/claudecode/install_diet_test.go +++ b/internal/agents/claudecode/install_diet_test.go @@ -133,9 +133,14 @@ func TestGlobalInstall_FatToSlimReplacement(t *testing.T) { // The block is now the thin pointer: it @-includes the active // profile copy from the hermetic instructions dir… + // Two spellings of one file, and the difference is the point: the + // @-include is document content and stays '/'-spelled on every OS, + // while the ReadFile below is a real filesystem call and needs the + // native path. activePath := filepath.Join(env.InstructionsDir, profiles.ActiveFileName) - if !strings.Contains(text, "@"+activePath) { - t.Errorf("re-installed block does not @-include the active profile (%s)", activePath) + includedPath := filepath.ToSlash(activePath) + if !strings.Contains(text, "@"+includedPath) { + t.Errorf("re-installed block does not @-include the active profile (%s)", includedPath) } if !strings.Contains(text, "gortex instructions switch") { t.Error("re-installed block lost the switch verb") diff --git a/internal/agents/instructions.go b/internal/agents/instructions.go index 7ac95d667..e31b86850 100644 --- a/internal/agents/instructions.go +++ b/internal/agents/instructions.go @@ -63,7 +63,13 @@ const ( // CLAUDE.md. The heading stays here as the idempotency sentinel and as // a functional minimum for readers that do not expand @-includes. func GlobalPointerBody(instructionsDir string) string { - active := filepath.Join(instructionsDir, profiles.ActiveFileName) + // The @-include is document content, not a filesystem call: it is + // written into ~/.claude/CLAUDE.md and read back as markdown. Native + // separators would leak a `@C:\Users\me\.gortex\...` line into a file + // whose every other path is '/'-spelled, and the same rendered body + // is compared against a golden that cannot be platform-specific. This + // mirrors shellSafeHookBinary, which normalises for the same reason. + active := filepath.ToSlash(filepath.Join(instructionsDir, profiles.ActiveFileName)) return "## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob\n\n" + "The machine-wide Gortex rules load from the active instruction profile, imported below:\n\n" + "@" + active + "\n\n" + diff --git a/internal/agents/instructions_test.go b/internal/agents/instructions_test.go index 406d9ad19..3c5e9212f 100644 --- a/internal/agents/instructions_test.go +++ b/internal/agents/instructions_test.go @@ -1,7 +1,7 @@ package agents import ( - "path/filepath" + "path" "strings" "testing" @@ -82,7 +82,11 @@ func TestBashInstructionsBodyUsesOnlyExplicitCLIMirror(t *testing.T) { func TestGlobalPointerBody_ShapeAndSentinel(t *testing.T) { const dir = "/home/user/.gortex/instructions" body := GlobalPointerBody(dir) - activePath := filepath.Join(dir, "active.md") + // path.Join, not filepath.Join: the @-include is document content and + // stays '/'-spelled on every OS. Building the expectation with + // filepath.Join would assert the native mangling on Windows and pass + // there whether or not the renderer normalises. + activePath := path.Join(dir, "active.md") if !strings.Contains(body, InstructionsSentinel) { t.Error("pointer block lost the idempotency sentinel heading") diff --git a/internal/agents/render.go b/internal/agents/render.go index a3abc3f03..d32ad6522 100644 --- a/internal/agents/render.go +++ b/internal/agents/render.go @@ -359,10 +359,26 @@ func canonicalManifestKey(key string) string { // HOME / repo root and the resolved gortex binary path — with stable // placeholders so the manifest is identical on every machine. func normalizeRender(s, home, root string) string { - s = strings.ReplaceAll(s, home, "$HOME") - s = strings.ReplaceAll(s, root, "$ROOT") + // The values to scrub are native paths — os.Executable, exec.LookPath, + // the sandbox HOME — but a rendered manifest deliberately carries + // '/'-spelled paths even on Windows: shellSafeHookBinary normalises + // unconditionally, and an @-include is document content rather than a + // filesystem call. Replacing only the native spelling therefore leaves + // a machine-specific absolute in the manifest on Windows and the + // golden drifts for every developer who has gortex installed. + // filepath.ToSlash is a no-op on POSIX, so the second pass collapses + // into the first everywhere else. + scrub := func(s, from, to string) string { + if from == "" { + return s + } + s = strings.ReplaceAll(s, from, to) + return strings.ReplaceAll(s, filepath.ToSlash(from), to) + } + s = scrub(s, home, "$HOME") + s = scrub(s, root, "$ROOT") for _, p := range gortexBinaryPaths() { - s = strings.ReplaceAll(s, p, "gortex") + s = scrub(s, p, "gortex") } return s } From fc56f87efbea4625f4d9f3be0e100bdc82f85732 Mon Sep 17 00:00:00 2001 From: Tien Dung Dao Date: Wed, 26 Aug 2026 08:14:59 +0700 Subject: [PATCH 2/3] test(agents): bind both Windows fixes on a runner that can fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Both findings were right: neither fix was reachable from hosted CI, so reverting either would have left every check green. 1. `./internal/agents` is one package and does not recurse, so TestPluginFailsOpen — the only detector for a CRLF-embedded gortex.js — never ran on Windows. The step now names ./internal/agents/opencode explicitly. That still leaves the guard dependent on the runner converting line endings, so TestEmbeddedTextAssetsArePinnedToLF states the contract directly instead, in two halves. The byte half reads the embedded assets and rejects a CRLF sequence: it fires on any checkout that already converted. The attribute half asks git for the eol attribute and requires "lf": it fires on the removal itself, and is the half that still binds on a runner configured not to convert, where a byte assertion would pass whatever .gitattributes said. It covers the pi extension too, which nothing covered before. 2. TestAgentsRenderGolden lives in cmd/gortex, which the Windows job only builds. Rather than add a step for one test, the binding assertion now lives next to the code in internal/agents, which that job already runs: TestNormalizeRenderScrubsTheSlashSpelledPath feeds the function a native home and root with a '/'-spelled body — the shape a renderer actually emits — and requires both to be scrubbed. Sabotage-verified, each independently, on windows/amd64 go1.26.6: - revert the ToSlash pass in normalizeRender -> "the slash-spelled home survived normalizeRender" -> "the slash-spelled root survived normalizeRender" -> "a machine-specific absolute is still in the manifest" - drop `eol=lf` from .gitattributes, leave the files LF on disk -> eol attribute is "unspecified", want "lf" (byte half silent, which is exactly the case it exists for) - drop `eol=lf`, stage it, re-check out the asset -> CRLF line ending at byte 40, and the attribute half too The first attempt at that last one was invalid and is worth recording: removing .gitattributes from the working tree alone changes nothing, because git falls back to the staged copy, so the asset came back LF and the assertion passed. The attribute has to be removed from the index for the sabotage to be real. internal/agents ok internal/agents/opencode ok --- .github/workflows/ci.yml | 10 ++- internal/agents/embedded_asset_eol_test.go | 74 ++++++++++++++++++++++ internal/agents/render_test.go | 36 +++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 internal/agents/embedded_asset_eol_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec479a9c1..35413835b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,16 @@ jobs: - name: Build all packages run: go build ./... + # ./internal/agents is one package and does not recurse, so the + # opencode adapter is named explicitly. Its plugin asset is embedded + # verbatim with go:embed and TestPluginFailsOpen parses it by + # splitting on "\n}\n", which is the closest thing the suite has to a + # detector for a CRLF checkout of an embedded file — and it can only + # fire on a runner that converts line endings. - name: Test Windows agent integrations - run: go test -timeout=5m -count=1 ./internal/agents + run: > + go test -timeout=5m -count=1 + ./internal/agents ./internal/agents/opencode # Symlink confinement keeps out-of-repo files out of the index, and it # reads differently on Windows: a symlink reparse point maps to diff --git a/internal/agents/embedded_asset_eol_test.go b/internal/agents/embedded_asset_eol_test.go new file mode 100644 index 000000000..c144c70d0 --- /dev/null +++ b/internal/agents/embedded_asset_eol_test.go @@ -0,0 +1,74 @@ +package agents + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// embeddedTextAssets are the text files this tree hands to a user +// verbatim: go:embed compiles them into the binary and the adapters write +// them into a project unchanged. Their bytes are therefore a shipping +// artefact, not a source detail, and a checkout that rewrites their line +// endings changes what users receive. +var embeddedTextAssets = []string{ + "internal/agents/opencode/plugin/gortex.js", + "internal/agents/pi/extension/index.ts", +} + +// TestEmbeddedTextAssetsArePinnedToLF guards the `* text=auto eol=lf` line +// in .gitattributes from both directions. +// +// The byte half catches a checkout that already converted: Git for Windows +// installs with core.autocrlf=true, so without the attribute these files +// arrive CRLF and the binary ships CRLF. The attribute half catches the +// removal itself, and is the half that still binds on a runner configured +// not to convert — where a byte assertion would pass no matter what +// .gitattributes says. +// +// TestPluginFailsOpen detects the same corruption, but only as a side +// effect of splitting the plugin source on "\n}\n"; this states the +// contract directly and covers the pi extension too. +func TestEmbeddedTextAssetsArePinnedToLF(t *testing.T) { + root, err := repoRoot() + if err != nil { + t.Skipf("not a git work tree, nothing to assert: %v", err) + } + + for _, rel := range embeddedTextAssets { + body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + t.Errorf("%s: %v", rel, err) + continue + } + if i := strings.Index(string(body), "\r\n"); i >= 0 { + t.Errorf("%s: embedded asset has a CRLF line ending at byte %d — "+ + "this checkout converted it and the binary would ship it that way; "+ + "check the `* text=auto eol=lf` line in .gitattributes", rel, i) + } + + out, err := exec.Command("git", "-C", root, "check-attr", "eol", "--", rel).Output() + if err != nil { + t.Errorf("%s: git check-attr: %v", rel, err) + continue + } + // `: eol: `; unset attributes report "unspecified". + line := string(out) + if got := strings.TrimSpace(line[strings.LastIndex(line, ":")+1:]); got != "lf" { + t.Errorf("%s: eol attribute is %q, want \"lf\" — a Windows checkout "+ + "will convert this embedded asset to CRLF", rel, got) + } + } +} + +// repoRoot locates the work tree so the test can address assets by their +// repo-relative path from whatever directory `go test` runs it in. +func repoRoot() (string, error) { + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/agents/render_test.go b/internal/agents/render_test.go index 1dfcb391e..332586a04 100644 --- a/internal/agents/render_test.go +++ b/internal/agents/render_test.go @@ -242,3 +242,39 @@ func TestRenderRestoresUnsetEnvironment(t *testing.T) { t.Errorf("CLAUDE_CONFIG_DIR leaked out of the render as %q; it was unset before", v) } } + +// TestNormalizeRenderScrubsTheSlashSpelledPath binds the second +// replacement in normalizeRender, which nothing else can. +// +// A rendered manifest carries '/'-spelled paths even on Windows — +// shellSafeHookBinary normalises unconditionally, and an @-include is +// document content rather than a filesystem call — while the values handed +// to normalizeRender are native. Scrubbing only the native spelling +// therefore leaves a machine-specific absolute in the manifest, and +// TestAgentsRenderGolden catches it only in cmd/gortex, which the Windows +// job does not test. +// +// On linux/macos both spellings are the same string and this passes with +// either implementation; on Windows it fails without the ToSlash pass. +// That asymmetry is the point, and it is why the assertion lives in +// internal/agents, which the Windows job already runs. +func TestNormalizeRenderScrubsTheSlashSpelledPath(t *testing.T) { + home := filepath.Join(string(filepath.Separator)+"sandbox", "home") + root := filepath.Join(string(filepath.Separator)+"sandbox", "repo") + + // The body is what a renderer emits: forward slashes, whatever the OS. + body := "@" + filepath.ToSlash(home) + "/.gortex/instructions/active.md\n" + + "root=" + filepath.ToSlash(root) + "\n" + + got := normalizeRender(body, home, root) + + if !strings.Contains(got, "@$HOME/.gortex/instructions/active.md") { + t.Errorf("the slash-spelled home survived normalizeRender:\n%s", got) + } + if !strings.Contains(got, "root=$ROOT") { + t.Errorf("the slash-spelled root survived normalizeRender:\n%s", got) + } + if strings.Contains(got, filepath.ToSlash(home)) || strings.Contains(got, filepath.ToSlash(root)) { + t.Errorf("a machine-specific absolute is still in the manifest:\n%s", got) + } +} From 91bef013f2886f5b1cb0c00f37f6c4df9e736a53 Mon Sep 17 00:00:00 2001 From: Tien Dung Dao Date: Wed, 26 Aug 2026 15:05:57 +0700 Subject: [PATCH 3/3] test(agents): root the EOL guard in the module, and bind the binary scrub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up; both findings reproduced before fixing. 1. The EOL test located the tree with `git rev-parse --show-toplevel` and assumed that was the module. Reproduced exactly as described: with the source at /src, the test opened /internal/agents/opencode/plugin/gortex.js and failed. Vendored copies, monorepo subdirectories and packaging staging trees all have that shape, so `go test` broke in every one of them. The root now comes from the nearest go.mod above the test's working directory. The byte half always runs from there. The attribute half runs only once the discovered git work tree is confirmed to be that same directory — compared with os.SameFile, not string equality, since git answers with forward slashes on Windows — and otherwise logs why it stood down rather than asserting about some other repository. TestModuleRootFromIgnoresAnEnclosingCheckout pins it, with an enclosing work tree *and* an enclosing go.mod so neither a git lookup nor a careless upward walk passes by accident. Re-running the original nested reproduction now passes with: module root ...\outer2\src is not its own git work tree (); asserted the embedded bytes only, skipped the attribute half 2. TestNormalizeRenderScrubsTheSlashSpelledPath only fed home and root, so reverting the gortexBinaryPaths loop on its own left the Windows package tests green — the third scrub target was unguarded. The test now pins gortexBinaryPaths to a known path and requires the slash-spelled executable to normalize to bare "gortex". Pinning keeps the assertion about the scrub rather than about whatever gortex is installed on the machine; no test in this package calls t.Parallel, so the swap cannot race one. Sabotage, reverting only that loop: the slash-spelled executable survived normalizeRender machine-specific absolute "/tools/bin/gortex" is still in the manifest internal/agents ok internal/agents/opencode ok --- internal/agents/embedded_asset_eol_test.go | 100 +++++++++++++++++++-- internal/agents/render_test.go | 25 +++++- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/internal/agents/embedded_asset_eol_test.go b/internal/agents/embedded_asset_eol_test.go index c144c70d0..9e5ff33d4 100644 --- a/internal/agents/embedded_asset_eol_test.go +++ b/internal/agents/embedded_asset_eol_test.go @@ -1,6 +1,7 @@ package agents import ( + "fmt" "os" "os/exec" "path/filepath" @@ -32,11 +33,23 @@ var embeddedTextAssets = []string{ // effect of splitting the plugin source on "\n}\n"; this states the // contract directly and covers the pi extension too. func TestEmbeddedTextAssetsArePinnedToLF(t *testing.T) { - root, err := repoRoot() + wd, err := os.Getwd() if err != nil { - t.Skipf("not a git work tree, nothing to assert: %v", err) + t.Fatalf("getwd: %v", err) + } + root, err := moduleRootFrom(wd) + if err != nil { + t.Fatalf("locating the module root: %v", err) } + // The attribute half needs the assets to belong to the checkout whose + // attributes it is about to read. A copied, vendored or nested tree + // can sit inside some *other* work tree, and asking that one about + // these paths answers a different question — or, as this test did + // before, addresses files that are not there at all. + gitRoot, gitErr := gitTopLevel(root) + attributesApply := gitErr == nil && sameDir(gitRoot, root) + for _, rel := range embeddedTextAssets { body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) if err != nil { @@ -49,6 +62,9 @@ func TestEmbeddedTextAssetsArePinnedToLF(t *testing.T) { "check the `* text=auto eol=lf` line in .gitattributes", rel, i) } + if !attributesApply { + continue + } out, err := exec.Command("git", "-C", root, "check-attr", "eol", "--", rel).Output() if err != nil { t.Errorf("%s: git check-attr: %v", rel, err) @@ -61,14 +77,86 @@ func TestEmbeddedTextAssetsArePinnedToLF(t *testing.T) { "will convert this embedded asset to CRLF", rel, got) } } + + if !attributesApply { + t.Logf("module root %s is not its own git work tree (%v); "+ + "asserted the embedded bytes only, skipped the attribute half", root, gitErr) + } } -// repoRoot locates the work tree so the test can address assets by their -// repo-relative path from whatever directory `go test` runs it in. -func repoRoot() (string, error) { - out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() +// TestModuleRootFromIgnoresAnEnclosingCheckout is the regression case for +// the layout that broke this file: the source placed under some other +// repository, as a vendored copy, a monorepo subdirectory or a packaging +// staging tree. `git rev-parse --show-toplevel` answers for the enclosing +// work tree, which is the wrong tree and often does not contain these +// paths at all, so the root has to come from the module instead. +func TestModuleRootFromIgnoresAnEnclosingCheckout(t *testing.T) { + outer := t.TempDir() + inner := filepath.Join(outer, "src") + start := filepath.Join(inner, "internal", "agents") + + // An enclosing work tree *and* an enclosing module, so neither a git + // lookup nor a sloppy upward walk can accidentally pass. + for _, dir := range []string{filepath.Join(outer, ".git"), start} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + for _, f := range []string{ + filepath.Join(outer, "go.mod"), + filepath.Join(inner, "go.mod"), + } { + if err := os.WriteFile(f, []byte("module x\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + got, err := moduleRootFrom(start) + if err != nil { + t.Fatalf("moduleRootFrom(%s): %v", start, err) + } + if !sameDir(got, inner) { + t.Errorf("moduleRootFrom(%s) = %s, want the nearest module root %s", start, got, inner) + } +} + +// moduleRootFrom walks up from dir to the nearest directory holding a +// go.mod. That is the module this test belongs to, whatever repository +// happens to contain it. +func moduleRootFrom(dir string) (string, error) { + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("no go.mod at or above %s", dir) + } + dir = parent + } +} + +// gitTopLevel reports the work tree dir owns, if any. +func gitTopLevel(dir string) (string, error) { + out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } + +// sameDir compares two directories by identity rather than by spelling. +// git answers with forward slashes on Windows while filepath produces +// backslashes, and either side may arrive through a symlink or a +// different case, so string equality is the wrong test. +func sameDir(a, b string) bool { + fa, err := os.Stat(a) + if err != nil { + return false + } + fb, err := os.Stat(b) + if err != nil { + return false + } + return os.SameFile(fa, fb) +} diff --git a/internal/agents/render_test.go b/internal/agents/render_test.go index 332586a04..145b38718 100644 --- a/internal/agents/render_test.go +++ b/internal/agents/render_test.go @@ -261,10 +261,22 @@ func TestRenderRestoresUnsetEnvironment(t *testing.T) { func TestNormalizeRenderScrubsTheSlashSpelledPath(t *testing.T) { home := filepath.Join(string(filepath.Separator)+"sandbox", "home") root := filepath.Join(string(filepath.Separator)+"sandbox", "repo") + exe := filepath.Join(string(filepath.Separator)+"tools", "bin", "gortex") + + // The binary path is the third thing normalizeRender scrubs, and the + // one an adapter is most likely to have written through + // shellSafeHookBinary. Pinning gortexBinaryPaths keeps the assertion + // about the scrub rather than about whatever gortex happens to be + // installed on the machine running the test. No test in this package + // calls t.Parallel, so the swap cannot race one. + restore := gortexBinaryPaths + t.Cleanup(func() { gortexBinaryPaths = restore }) + gortexBinaryPaths = func() []string { return []string{exe} } // The body is what a renderer emits: forward slashes, whatever the OS. body := "@" + filepath.ToSlash(home) + "/.gortex/instructions/active.md\n" + - "root=" + filepath.ToSlash(root) + "\n" + "root=" + filepath.ToSlash(root) + "\n" + + `"command": "` + filepath.ToSlash(exe) + ` hook"` + "\n" got := normalizeRender(body, home, root) @@ -274,7 +286,14 @@ func TestNormalizeRenderScrubsTheSlashSpelledPath(t *testing.T) { if !strings.Contains(got, "root=$ROOT") { t.Errorf("the slash-spelled root survived normalizeRender:\n%s", got) } - if strings.Contains(got, filepath.ToSlash(home)) || strings.Contains(got, filepath.ToSlash(root)) { - t.Errorf("a machine-specific absolute is still in the manifest:\n%s", got) + if !strings.Contains(got, `"command": "gortex hook"`) { + t.Errorf("the slash-spelled executable survived normalizeRender:\n%s", got) + } + for _, leaked := range []string{ + filepath.ToSlash(home), filepath.ToSlash(root), filepath.ToSlash(exe), + } { + if strings.Contains(got, leaked) { + t.Errorf("machine-specific absolute %q is still in the manifest:\n%s", leaked, got) + } } }