Skip to content

Commit 4b3aaff

Browse files
authored
fix(platform): add .gitattributes for Windows CRLF line ending issues (#93)
1 parent 6078d29 commit 4b3aaff

File tree

6 files changed

+166
-8
lines changed

6 files changed

+166
-8
lines changed

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Enforce LF line endings for files that must not have CRLF.
2+
# Without this, Windows `git checkout` converts to CRLF, which breaks bash
3+
# scripts with errors like: ': invalid option namesh: line 2: set: pipefail'
4+
*.sh text eol=lf
5+
Dockerfile text eol=lf
6+
Dockerfile.* text eol=lf
7+
Makefile text eol=lf
8+
*.yml text eol=lf
9+
*.yaml text eol=lf

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,35 @@ jobs:
200200
}
201201
202202
Write-Host "Windows smoke test passed: project generated and verified successfully"
203+
- name: Verify shell scripts have LF line endings
204+
shell: pwsh
205+
run: |
206+
$project = Join-Path $env:RUNNER_TEMP "ci-test-project"
207+
$failed = $false
208+
209+
# Check all .sh files for CRLF
210+
Get-ChildItem -Path $project -Recurse -Filter "*.sh" | ForEach-Object {
211+
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
212+
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
213+
if ($content -match "`r`n") {
214+
Write-Error "$($_.Name) has CRLF line endings - this breaks bash on Windows"
215+
$failed = $true
216+
}
217+
}
218+
219+
# Check Dockerfile
220+
$dockerfile = Join-Path $project "Dockerfile"
221+
if (Test-Path $dockerfile) {
222+
$bytes = [System.IO.File]::ReadAllBytes($dockerfile)
223+
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
224+
if ($content -match "`r`n") {
225+
Write-Error "Dockerfile has CRLF line endings - this breaks Docker builds"
226+
$failed = $true
227+
}
228+
}
229+
230+
if ($failed) { exit 1 }
231+
Write-Host "All shell scripts and Dockerfile have correct LF line endings"
203232
- name: Setup WSL with Docker
204233
shell: bash
205234
run: |

hooks/post_gen_project.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,34 @@ def opportunistically_install_zenable_tools() -> None:
313313
print("=" * 70 + "\n")
314314

315315

316+
def normalize_line_endings() -> None:
317+
"""Normalize CRLF to LF in shell scripts and Dockerfiles.
318+
319+
On Windows, cookiecutter's template rendering may write CRLF line endings
320+
even when the source files have LF. This breaks bash with errors like:
321+
': invalid option namesh: line 2: set: pipefail'
322+
323+
Uses only stdlib — no new dependencies required.
324+
"""
325+
project_root = Path(".")
326+
patterns = ["**/*.sh", "Dockerfile", "Dockerfile.*"]
327+
for pattern in patterns:
328+
for filepath in project_root.glob(pattern):
329+
if not filepath.is_file():
330+
continue
331+
raw = filepath.read_bytes()
332+
if b"\r\n" in raw:
333+
filepath.write_bytes(raw.replace(b"\r\n", b"\n"))
334+
LOG.debug("Normalized CRLF -> LF in %s", filepath)
335+
336+
316337
def run_post_gen_hook():
317338
"""Run post generation hook"""
318339
try:
340+
# Normalize line endings before anything else — bash scripts must have
341+
# LF endings or they fail on Windows with Git's CRLF conversion
342+
normalize_line_endings()
343+
319344
# Sort and unique the generated dictionary.txt file
320345
dictionary: Path = Path("./.github/etc/dictionary.txt")
321346
sorted_uniqued_dictionary: list[str] = sorted(set(dictionary.read_text("utf-8").split("\n")))

tests/test_cookiecutter.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,100 @@ def test_autofix_hook(cookies, context):
211211
pytest.fail(f"stdout: {error.stdout.decode('utf-8')}, stderr: {error.stderr.decode('utf-8')}")
212212

213213

214+
@pytest.mark.unit
215+
def test_gitattributes_exists(cookies):
216+
"""
217+
Test that generated projects include a .gitattributes file
218+
to enforce LF line endings for shell scripts and Dockerfiles.
219+
"""
220+
os.environ["RUN_POST_HOOK"] = "false"
221+
222+
result = cookies.bake()
223+
224+
assert result.exit_code == 0
225+
assert result.exception is None
226+
227+
gitattributes = result.project_path / ".gitattributes"
228+
assert gitattributes.is_file(), ".gitattributes file must exist in generated project"
229+
230+
content = gitattributes.read_text(encoding="utf-8")
231+
assert "*.sh" in content, ".gitattributes must enforce line endings for shell scripts"
232+
assert "Dockerfile" in content, ".gitattributes must enforce line endings for Dockerfiles"
233+
234+
235+
@pytest.mark.unit
236+
def test_shell_scripts_have_lf_line_endings(cookies):
237+
"""
238+
Test that all shell scripts in generated projects have LF line endings,
239+
not CRLF. CRLF line endings break bash on Windows with errors like:
240+
': invalid option namesh: line 2: set: pipefail'
241+
"""
242+
os.environ["RUN_POST_HOOK"] = "false"
243+
244+
result = cookies.bake()
245+
246+
assert result.exit_code == 0
247+
assert result.exception is None
248+
249+
sh_files = list(result.project_path.glob("**/*.sh"))
250+
assert sh_files, "Expected at least one .sh file in generated project"
251+
252+
for sh_file in sh_files:
253+
raw_content = sh_file.read_bytes()
254+
assert b"\r\n" not in raw_content, f"{sh_file.name} contains CRLF line endings — this breaks bash on Windows"
255+
256+
257+
@pytest.mark.unit
258+
def test_dockerfile_has_lf_line_endings(cookies):
259+
"""
260+
Test that the Dockerfile in generated projects has LF line endings.
261+
CRLF line endings cause Docker build failures.
262+
"""
263+
os.environ["RUN_POST_HOOK"] = "false"
264+
265+
result = cookies.bake()
266+
267+
assert result.exit_code == 0
268+
assert result.exception is None
269+
270+
dockerfile = result.project_path / "Dockerfile"
271+
assert dockerfile.is_file(), "Dockerfile must exist in generated project"
272+
273+
raw_content = dockerfile.read_bytes()
274+
assert b"\r\n" not in raw_content, "Dockerfile contains CRLF line endings — this breaks Docker builds"
275+
276+
277+
@pytest.mark.unit
278+
def test_no_dead_shell_scripts(cookies):
279+
"""
280+
Test that all shell scripts in the generated project are referenced
281+
by at least one other file (Taskfile.yml, CI workflows, etc.).
282+
"""
283+
os.environ["RUN_POST_HOOK"] = "false"
284+
285+
result = cookies.bake()
286+
287+
assert result.exit_code == 0
288+
assert result.exception is None
289+
290+
sh_files = list(result.project_path.glob("scripts/*.sh"))
291+
assert sh_files, "Expected at least one .sh file in generated project"
292+
293+
# Collect all non-.sh file content to search for references
294+
all_content = ""
295+
for f in result.project_path.rglob("*"):
296+
if f.is_file() and f.suffix != ".sh" and ".git/" not in str(f):
297+
try:
298+
all_content += f.read_text(encoding="utf-8", errors="ignore")
299+
except (IsADirectoryError, PermissionError):
300+
pass
301+
302+
for sh_file in sh_files:
303+
assert sh_file.name in all_content, (
304+
f"scripts/{sh_file.name} is dead code — not referenced by any other file in the project"
305+
)
306+
307+
214308
@pytest.mark.unit
215309
@pytest.mark.parametrize(
216310
"invalid_name",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Enforce LF line endings for files that must not have CRLF.
2+
# Without this, Windows `git checkout` converts to CRLF, which breaks bash
3+
# scripts with errors like: ': invalid option namesh: line 2: set: pipefail'
4+
*.sh text eol=lf
5+
Dockerfile text eol=lf
6+
Dockerfile.* text eol=lf
7+
Makefile text eol=lf
8+
*.yml text eol=lf
9+
*.yaml text eol=lf

{{cookiecutter.project_name}}/scripts/get_os.sh

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)