Skip to content

fix(agents): keep Windows checkouts and rendered docs platform-independent - #655

Merged
zzet merged 3 commits into
zzet:mainfrom
tiendungdev:fix/windows-checkout-and-doc-path-portability
Aug 26, 2026
Merged

fix(agents): keep Windows checkouts and rendered docs platform-independent#655
zzet merged 3 commits into
zzet:mainfrom
tiendungdev:fix/windows-checkout-and-doc-path-portability

Conversation

@tiendungdev

Copy link
Copy Markdown
Contributor

First tranche of the Windows CI work for #652. Scope is deliberately small and touches no runtime behaviour outside the rendered-document path: it removes the failures that block reading the Windows suite at all.

What is wrong

1. The repository has no .gitattributes.

Git for Windows installs with core.autocrlf=true — the GitHub windows-latest runner included — so a Windows checkout rewrites every text file to CRLF. Two things break:

  • 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. A source build on Windows therefore ships CRLF assets. TestPluginFailsOpen already detects it — it splits pluginSource on "\n}\n", which stops matching.

  • gofmt -l flags every Go file in the tree. Control on an untouched file:

    $ file -b internal/agents/aider/adapter.go
    Unicode text, UTF-8 text, with CRLF line terminators
    $ gofmt -l internal/agents/aider/adapter.go
    internal/agents/aider/adapter.go          # unformatted
    
    # same file, re-checked-out under the new .gitattributes
    $ file -b internal/agents/aider/adapter.go
    Unicode text, UTF-8 text
    $ gofmt -l internal/agents/aider/adapter.go
                                              # clean
    

    So any formatting or lint gate on the Windows leg would fail wholesale, and a Windows contributor cannot separate real findings from the noise.

The committed blobs are already LF. git add --renormalize . reports no content change — only the files this PR edits appear — so this pins the checkout and rewrites nothing.

*.ps1 keeps CRLF; *.png / *.gz are marked binary.

2. GlobalPointerBody builds the @-include with filepath.Join.

On Windows that writes

@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 file is /-spelled. shellSafeHookBinary already normalises unconditionally for exactly this reason, with the rationale written in its doc comment.

UpsertMarkedBlock keys idempotency on the start/end markers (instructions.go:203, :216), not on the path string, so an existing install has its block replaced in place on the next run — no duplicate, no orphan.

I checked for a consumer that parses the line back: the only production caller is claudecode/adapter.go:268, and it only writes.

3. normalizeRender scrubs only the native spelling.

gortexBinaryPaths() collects native paths (os.Executable, exec.LookPath), and home / root arrive native too — but a rendered manifest carries /-spelled paths by design. On Windows the ReplaceAll never matches, so TestAgentsRenderGolden leaks a machine-specific absolute into the comparison:

-@$HOME/.gortex/instructions/active.md
+@D:/tmp/gobuild/gortex-render-home-3991093423/.gortex/instructions/active.md

and it drifts for any developer who has gortex installed on PATH. This is the same class the file's own canonicalManifestKey already guards against.

Verification

windows/amd64, go1.26.6, -count=1, measured against origin/main 699f351e:

package before after
internal/agents ok ok
internal/agents/opencode 1 0
cmd/gortex 19 18
internal/agents/claudecode 2 2 — both pre-existing (TestResolveHookCommand, TestEmitPluginBundle_HookHandlerExecBit)

Newly-broken set is empty. gofmt and go vet clean on the touched files.

Each fix was sabotage-verified separately:

  • revert (1) → TestPluginFailsOpen: callHook has no closing brace this test can find
  • revert (2) → claude-code golden drifts to @$HOME\.gortex\instructions\active.md
  • revert (3) → claude-code and hermes goldens drift to absolute paths

TestAgentsRenderGolden now passes on Windows with the full PATH — i.e. with gortex actually installed — which it did not before.

Two assertions changed, and why

TestGlobalPointerBody_ShapeAndSentinel and TestGlobalInstall_FatToSlimReplacement built their expected @-include with filepath.Join. That asserts the native mangling on Windows, so it passes there whether or not the renderer normalises. They now use path.Join / filepath.ToSlash. In install_diet_test.go both spellings are kept deliberately — the @-include assertion takes the slash form, the os.ReadFile next to it keeps the native one.

Limitation, stated up front

As in #646, none of this can fail on the linux/macos matrix: filepath.ToSlash is a no-op on POSIX, and the CRLF conversion never happens there. The windows-latest leg from #652 is the only place these bind. I have not added a selector step — after your note on #652 that narrow selectors can miss the regression they are meant to protect, that seems like the wrong direction.

On the rest of #652

I ran the full suite on origin/main 699f351e (windows/amd64, go1.26.6, no -race): 173 failing tests across 28 packages, of which 2 are my machine, not the repointernal/analysis TestMapGitDiff* and internal/releases leave a .git/ai/working_logs/ directory behind that a local git fork creates, and os.RemoveAll then fails. I will post the full classification on #652 with the buckets and the three questions that need your call before I can fix them.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: two Windows-only regressions are not protected by hosted CI.

  1. P2 — Exercise the LF checkout guarantee on Windows (.gitattributes:14)

    The Windows job runs go test ./internal/agents, which does not recurse into internal/agents/opencode; it therefore never runs TestPluginFailsOpen, the regression detector for CRLF-embedded gortex.js. Removing this attribute would leave hosted checks green while Windows source builds could ship CRLF assets again. Please add the OpenCode package test under a CRLF-prone Windows checkout, or an equivalent explicit attribute/byte assertion.

  2. P2 — Run the render golden on Windows (internal/agents/render.go:357)

    This second replacement only changes behavior on Windows because filepath.ToSlash is a no-op in the Linux/macOS jobs. TestAgentsRenderGolden lives in cmd/gortex, while the Windows job tests only ./internal/agents, so reverting this line would leave hosted checks green. Please run go test -timeout=5m -count=1 -run "^TestAgentsRenderGolden$" ./cmd/gortex on windows-latest, or add a direct Windows-binding unit test.

The implementation otherwise looks correct; I found no security or dead-code defects.

…ndent

Three defects that only surface on a Windows checkout, found while
enumerating the failures for the windows-latest matrix leg (zzet#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 zzet#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.
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
@tiendungdev
tiendungdev force-pushed the fix/windows-checkout-and-doc-path-portability branch from a789a01 to fc56f87 Compare August 26, 2026 01:15
@tiendungdev

Copy link
Copy Markdown
Contributor Author

Both right, and both for the same reason: neither fix was reachable from hosted CI, so reverting either would have left every check green. Pushed fc56f87e, rebased onto 6347e7f2.

1 — the OpenCode package never ran

Confirmed: go list ./internal/agents returns one package, so the step never recursed into internal/agents/opencode and TestPluginFailsOpen never executed on Windows. The step now names it explicitly.

But naming it only helps if the runner actually converts line endings, and I would rather not have the guard depend on that. So I took the second option you offered as well — an explicit assertion — and split it in two, because the halves fail in different situations:

  • byte half — reads the embedded assets and rejects a \r\n. Fires on any checkout that already converted.
  • attribute half — asks git check-attr eol and requires lf. 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 also covers internal/agents/pi/extension/index.ts, which is embedded the same way and had nothing watching it.

2 — the golden is in the wrong package to bind

Confirmed: TestAgentsRenderGolden is in cmd/gortex and the Windows job only builds that. Rather than add a step for a single test, I put the binding assertion next to the code, in internal/agents, which the job already runs. TestNormalizeRenderScrubsTheSlashSpelledPath hands the function a native home and root with a /-spelled body — the shape a renderer actually emits — and requires both to be scrubbed.

Happy to add the -run "^TestAgentsRenderGolden$" ./cmd/gortex step instead if you would rather have the end-to-end version on the runner; it is one line either way.

Sabotage, each independently — windows/amd64, go1.26.6

reverted result
the ToSlash pass in normalizeRender all three assertions fail: home survived, root survived, machine-specific absolute still present
eol=lf dropped, files left LF on disk eol attribute is "unspecified", want "lf" — byte half stays silent, which is exactly the case it exists for
eol=lf dropped, staged, asset re-checked-out CRLF line ending at byte 40, plus the attribute half

One of those was wrong the first time and it is worth recording: removing .gitattributes from the working tree alone proves nothing, because git falls back to the staged copy — the asset came back LF and the assertion passed. The attribute has to be removed from the index before the sabotage is real. I had used the working-tree-only version earlier in this PR and got a valid result then only because the file was still untracked at that point.

internal/agents           ok
internal/agents/opencode  ok

gofmt and go vet clean.

@tiendungdev

Copy link
Copy Markdown
Contributor Author

build-linux-static is red on infrastructure, not on this diff. It needs a re-run, which I cannot trigger from a fork.

The failing step is Run it where the dynamic build could not — the one that pulls debian:11 and alpine:3:

docker: Error response from daemon: received unexpected HTTP status: 502 Bad Gateway
##[error]Process completed with exit code 125

A registry 502 on docker run. Everything before it in the same job passed, including the actual subject of that job:

success  Build with the release link flags
success  Verify the binary is self-contained
success  Test the static-only code paths
failure  Run it where the dynamic build could not

This PR touches .gitattributes, two files under internal/agents, and the build-windows block of ci.yml — nothing that reaches the static link flags or the docker step.

The other eleven checks are green, and build-windows ran the new step:

success  Test Windows agent integrations      # now ./internal/agents ./internal/agents/opencode
success  Test native-separator store path comparisons

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head fc56f87 still requires changes.

  1. P2 — the new EOL test breaks nested or copied source trees

repoRoot assumes that git rev-parse --show-toplevel is the Gortex module root. This was reproduced by placing the exact-head source under an enclosing Git repository at /src: the focused test attempted to open /internal/agents/opencode/plugin/gortex.js instead of /src/internal/agents/opencode/plugin/gortex.js and failed. That makes valid copied, vendored, monorepo, and packaging layouts fail go test.

Please locate the module or source root independently. Use Git attributes only after verifying that the discovered Git top-level is the same checkout; otherwise continue the byte assertion from the module root and skip only the attribute half. Add a nested-parent-worktree regression case.

  1. P2 — executable-path normalization is still not guarded on Windows

normalizeRender now applies slash-aware scrubbing to gortexBinaryPaths(), but TestNormalizeRenderScrubsTheSlashSpelledPath puts only home and root paths in its input. Reverting only the binary-path loop change leaves the hosted Windows package tests green. Linux and macOS cannot distinguish this branch because filepath.ToSlash is a no-op there, while Windows builds but does not run TestAgentsRenderGolden.

Please include a slash-spelled executable path in the direct Windows-run test and require it to normalize to gortex, or run the end-to-end render golden on windows-latest.

The previous review findings are otherwise fixed, all hosted checks are green, and no security or dead-code issue was found. These remaining test-suite regression and coverage gap block merge.

…crub

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 <outer-git>/src, the test opened
   <outer-git>/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 (<nil>);
     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
@tiendungdev

Copy link
Copy Markdown
Contributor Author

Both reproduced before fixing. Pushed 91bef013.

1 — the EOL test broke nested trees

Reproduced exactly as you described. Source copied to <outer-git>/src, .git removed from the copy:

embedded_asset_eol_test.go:43: internal/agents/opencode/plugin/gortex.js:
  open D:\tmp\nested\outer\internal\agents\opencode\plugin\gortex.js:
  The system cannot find the path specified.

git rev-parse --show-toplevel answered for the enclosing work tree, and the test then addressed files that were not there. Vendored copies, monorepo subdirectories and packaging staging trees all have that shape, so I broke go test in every one of them.

The root now comes from the nearest go.mod above the test's working directory, and the two halves are separated as you suggested:

  • the byte assertion always runs, from the module root
  • the attribute assertion runs only once the discovered git work tree is confirmed to be that same directory, and otherwise stands down with a logged reason

The identity check is os.SameFile, not string equality — git answers with forward slashes on Windows while filepath produces backslashes, and either side can arrive through a symlink or a different case.

Same nested reproduction after the fix:

--- PASS: TestEmbeddedTextAssetsArePinnedToLF
    module root D:\tmp\nested\outer2\src is not its own git work tree (<nil>);
    asserted the embedded bytes only, skipped the attribute half

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.

2 — the binary-path scrub was unguarded

Right: the test fed only home and root, so the third scrub target was never exercised and reverting that loop alone kept Windows green.

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 happens to be installed on the machine running it; no test in this package calls t.Parallel, so the swap cannot race one.

Sabotage, reverting only that loop to the native strings.ReplaceAll:

render_test.go:290: the slash-spelled executable survived normalizeRender
render_test.go:296: machine-specific absolute "/tools/bin/gortex" is still in the manifest
internal/agents           ok
internal/agents/opencode  ok

gofmt clean.

Separately: build-linux-static was red on the previous head from a Docker Hub 502 in the debian:11 pull, not from this diff — details in the comment above, in case the re-run is still needed.

@zzet
zzet merged commit 06939fc into zzet:main Aug 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants