Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Enforce LF line endings for files that must not have CRLF.
# Without this, Windows `git checkout` converts to CRLF, which breaks bash
# scripts with errors like: ': invalid option namesh: line 2: set: pipefail'
*.sh text eol=lf
Dockerfile text eol=lf
Dockerfile.* text eol=lf
Makefile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,35 @@ jobs:
}

Write-Host "Windows smoke test passed: project generated and verified successfully"
- name: Verify shell scripts have LF line endings
shell: pwsh
run: |
$project = Join-Path $env:RUNNER_TEMP "ci-test-project"
$failed = $false

# Check all .sh files for CRLF
Get-ChildItem -Path $project -Recurse -Filter "*.sh" | ForEach-Object {
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
if ($content -match "`r`n") {
Write-Error "$($_.Name) has CRLF line endings - this breaks bash on Windows"
$failed = $true
}
}

# Check Dockerfile
$dockerfile = Join-Path $project "Dockerfile"
if (Test-Path $dockerfile) {
$bytes = [System.IO.File]::ReadAllBytes($dockerfile)
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
if ($content -match "`r`n") {
Write-Error "Dockerfile has CRLF line endings - this breaks Docker builds"
$failed = $true
}
}

if ($failed) { exit 1 }
Write-Host "All shell scripts and Dockerfile have correct LF line endings"
- name: Setup WSL with Docker
shell: bash
run: |
Expand Down
25 changes: 25 additions & 0 deletions hooks/post_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,34 @@ def opportunistically_install_zenable_tools() -> None:
print("=" * 70 + "\n")


def normalize_line_endings() -> None:
"""Normalize CRLF to LF in shell scripts and Dockerfiles.

On Windows, cookiecutter's template rendering may write CRLF line endings
even when the source files have LF. This breaks bash with errors like:
': invalid option namesh: line 2: set: pipefail'

Uses only stdlib — no new dependencies required.
"""
project_root = Path(".")
patterns = ["**/*.sh", "Dockerfile", "Dockerfile.*"]
for pattern in patterns:
for filepath in project_root.glob(pattern):
if not filepath.is_file():
continue
raw = filepath.read_bytes()
if b"\r\n" in raw:
filepath.write_bytes(raw.replace(b"\r\n", b"\n"))
LOG.debug("Normalized CRLF -> LF in %s", filepath)


def run_post_gen_hook():
"""Run post generation hook"""
try:
# Normalize line endings before anything else — bash scripts must have
# LF endings or they fail on Windows with Git's CRLF conversion
normalize_line_endings()

# Sort and unique the generated dictionary.txt file
dictionary: Path = Path("./.github/etc/dictionary.txt")
sorted_uniqued_dictionary: list[str] = sorted(set(dictionary.read_text("utf-8").split("\n")))
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cookiecutter.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,100 @@ def test_autofix_hook(cookies, context):
pytest.fail(f"stdout: {error.stdout.decode('utf-8')}, stderr: {error.stderr.decode('utf-8')}")


@pytest.mark.unit
def test_gitattributes_exists(cookies):
"""
Test that generated projects include a .gitattributes file
to enforce LF line endings for shell scripts and Dockerfiles.
"""
os.environ["RUN_POST_HOOK"] = "false"

result = cookies.bake()

assert result.exit_code == 0
assert result.exception is None

gitattributes = result.project_path / ".gitattributes"
assert gitattributes.is_file(), ".gitattributes file must exist in generated project"

content = gitattributes.read_text(encoding="utf-8")
assert "*.sh" in content, ".gitattributes must enforce line endings for shell scripts"
assert "Dockerfile" in content, ".gitattributes must enforce line endings for Dockerfiles"


@pytest.mark.unit
def test_shell_scripts_have_lf_line_endings(cookies):
"""
Test that all shell scripts in generated projects have LF line endings,
not CRLF. CRLF line endings break bash on Windows with errors like:
': invalid option namesh: line 2: set: pipefail'
"""
os.environ["RUN_POST_HOOK"] = "false"

result = cookies.bake()

assert result.exit_code == 0
assert result.exception is None

sh_files = list(result.project_path.glob("**/*.sh"))
assert sh_files, "Expected at least one .sh file in generated project"

for sh_file in sh_files:
raw_content = sh_file.read_bytes()
assert b"\r\n" not in raw_content, f"{sh_file.name} contains CRLF line endings — this breaks bash on Windows"


@pytest.mark.unit
def test_dockerfile_has_lf_line_endings(cookies):
"""
Test that the Dockerfile in generated projects has LF line endings.
CRLF line endings cause Docker build failures.
"""
os.environ["RUN_POST_HOOK"] = "false"

result = cookies.bake()

assert result.exit_code == 0
assert result.exception is None

dockerfile = result.project_path / "Dockerfile"
assert dockerfile.is_file(), "Dockerfile must exist in generated project"

raw_content = dockerfile.read_bytes()
assert b"\r\n" not in raw_content, "Dockerfile contains CRLF line endings — this breaks Docker builds"


@pytest.mark.unit
def test_no_dead_shell_scripts(cookies):
"""
Test that all shell scripts in the generated project are referenced
by at least one other file (Taskfile.yml, CI workflows, etc.).
"""
os.environ["RUN_POST_HOOK"] = "false"

result = cookies.bake()

assert result.exit_code == 0
assert result.exception is None

sh_files = list(result.project_path.glob("scripts/*.sh"))
assert sh_files, "Expected at least one .sh file in generated project"

# Collect all non-.sh file content to search for references
all_content = ""
for f in result.project_path.rglob("*"):
if f.is_file() and f.suffix != ".sh" and ".git/" not in str(f):
try:
all_content += f.read_text(encoding="utf-8", errors="ignore")
except (IsADirectoryError, PermissionError):
pass

for sh_file in sh_files:
assert sh_file.name in all_content, (
f"scripts/{sh_file.name} is dead code — not referenced by any other file in the project"
)


@pytest.mark.unit
@pytest.mark.parametrize(
"invalid_name",
Expand Down
9 changes: 9 additions & 0 deletions {{cookiecutter.project_name}}/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Enforce LF line endings for files that must not have CRLF.
# Without this, Windows `git checkout` converts to CRLF, which breaks bash
# scripts with errors like: ': invalid option namesh: line 2: set: pipefail'
*.sh text eol=lf
Dockerfile text eol=lf
Dockerfile.* text eol=lf
Makefile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
8 changes: 0 additions & 8 deletions {{cookiecutter.project_name}}/scripts/get_os.sh

This file was deleted.

Loading