Skip to content

Windows checkouts write nested hook shims with CRLF, which breaks them on Linux (the .gitattributes half of #3032 never landed) #3110

Closed

Description

@Mr-B-1

What happens

Git for Windows sets core.autocrlf=true in its system gitconfig. gstack's .gitattributes pins bin/* to LF, but a pattern that contains a slash is anchored to the repository root, so it covers only the top-level bin/. On upstream main (1.91.68, 20eb6202), 15 tracked shell entry points have no eol rule and check out with CRLF on Windows:

  1. autoplan/bin/phase-publication-hook
  2. plan-ceo-review/bin/mode-handoff-hook
  3. hosts/claude/hooks/: auq-error-fallback-hook, memorable-user-prompt-hook, question-log-hook, question-preference-hook, timeline-stop-hook
  4. browse/bin/find-browse, browse/bin/remote-slug
  5. lib/cso/images/: entrypoint, postgresql-ready, run-app, run-postgresql
  6. scripts/app/gstack-browser
  7. browse/test/fixtures/mock-claude/claude (a test fixture)

Git Bash accepts CRLF, so nothing fails on Windows itself. The failure appears when a Linux bash runs the bytes of a Windows checkout. In my setup the gstack tree is synced from a Windows 11 machine to an Ubuntu 24.04 server, and Claude Code runs from it on both. On the Linux side:

$ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | bash autoplan/bin/phase-publication-hook
autoplan/bin/phase-publication-hook: line 3: set: pipefail: invalid option name
$ echo $?
2

The hook prints nothing and exits 2. A PreToolUse hook that exits 2 blocks the call, so every /autoplan phase entry is denied on that host. test/autoplan-publication-hook.test.ts there: 11 pass, 6 fail (the hook-transport tests). I have not tried WSL on a Windows-drive checkout or a container with a bind mount, but both would read the same bytes.

bin/gstack-hook-check does not catch this, because it runs on the machine that makes the checkout. Git Bash's bash -n (5.2.37) accepts these CRLF shims, so the gate passes on Windows. Linux bash -n rejects some of them (phase-publication-hook and remote-slug fail with a syntax error; timeline-stop-hook and question-log-hook parse), but the gate never runs on the Linux side of a shared checkout.

Existing installs keep their CRLF copies

Seven more scripts already have an LF rule (*.sh, *.ts, **/scripts/*) but were still CRLF in my 1.91.19 install, with no content change. Most likely they were checked out before those rules existed: git pull never rewrites a file whose blob did not change. So a .gitattributes change alone does not repair an existing install.

The fix was already written in #3032

#3032 added these rules and scripts/heal-eol.sh for stale copies. The Oct 6 fix wave rewrote #3032 as the lean parse gate (#3057, v1.91.32.0), and the EOL half was dropped. I suggest landing that half:

hosts/*/hooks/*                     text eol=lf
**/bin/*                            text eol=lf
lib/cso/images/*                    text eol=lf
scripts/app/gstack-browser          text eol=lf
browse/test/fixtures/mock-claude/*  text eol=lf

The first four lines have #3032's patterns and attributes. The fifth covers the one fixture they miss. With all five, git check-attr --source=origin/main eol reports lf for every tracked file on main that starts with #!. The patterns match 157 files on main (141 on 1.91.19), all LF text with no binary attribute.

One gap in #3032's heal script: after it rewrites a file, the index still records the CRLF copy's size. Git treats a size change as a modification without re-hashing, so git update-index --refresh cannot clear it, and the healed files stay "modified" (a /gstack-upgrade autostash would then carry them). Re-staging each healed path with git update-index -- <path> stores the same blob and clears it.

What I run in the meantime

The five rules in .git/info/attributes, which is per clone, so upgrades never conflict with it. After each upgrade, a heal step uses #3032's method (git cat-file --filters into a cp -p temp copy, then one mv), touches only unmodified tracked files, and re-stages them as above, checking that each index entry keeps its blob.

Results on the Linux host after the heal (21 files rewritten, the same blobs in the index):

  1. bash autoplan/bin/phase-publication-hook with a non-Read input prints one JSON line ({}) and exits 0.
  2. test/autoplan-publication-hook.test.ts: 17 pass, 0 fail (was 11 pass, 6 fail).
  3. The 65 test files that touch these scripts: 1,755 pass, 201 fail (was 1,619 pass, 337 fail). By test name, 136 tests are fixed and none newly fail. The fixed groups include timeline-stop-hook, the question-log-hook and question-preference-hook suites, the gstack-slug and remote-slug parity tests, and the v1.1.3.0 migration guard.

Environment

  1. Windows 11, Git for Windows 2.53.0 (core.autocrlf=true from C:/Program Files/Git/etc/gitconfig), gstack 1.91.19.
  2. Ubuntu 24.04.4, bash 5.2.21, bun 1.3.14, same tree through Syncthing.
  3. Checked against upstream main 1.91.68 (20eb6202): same .gitattributes, same files affected, plus the new plan-ceo-review/bin/mode-handoff-hook.

Related, but a different defect: #3088 and #3100 (MSYS path spelling in gstack-hook-check).

Activity

  1. capy-ai commented on Oct 11, 2026

    @capy-ai
    Contributor

    Fixed in v1.91.90.0 (6e25040, #3142). .gitattributes now pins nested bin/*, the hook shims, lib/cso/images/* and scripts/app/* to LF. ./setup also repairs files an older Windows checkout left with CRLF: it re-checks them out in one batch and leaves locally edited files alone with a warning (docs/troubleshooting.md#setup-crlf-repair). Thanks @Mr-B-1 for tracing it back to the missing half of #3032. You're credited as co-author on the merged commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions