diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7d56059 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e30011..dc91bad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 1a93f37..a7281b4 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -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"))) diff --git a/tests/test_cookiecutter.py b/tests/test_cookiecutter.py index 71cf765..a7f0276 100755 --- a/tests/test_cookiecutter.py +++ b/tests/test_cookiecutter.py @@ -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", diff --git a/{{cookiecutter.project_name}}/.gitattributes b/{{cookiecutter.project_name}}/.gitattributes new file mode 100644 index 0000000..7d56059 --- /dev/null +++ b/{{cookiecutter.project_name}}/.gitattributes @@ -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 diff --git a/{{cookiecutter.project_name}}/scripts/get_os.sh b/{{cookiecutter.project_name}}/scripts/get_os.sh deleted file mode 100755 index 6a8572d..0000000 --- a/{{cookiecutter.project_name}}/scripts/get_os.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -case "$(uname -s)" in - Darwin) echo "darwin" ;; - Linux) echo "linux" ;; - *) echo "Unsupported OS" && exit 1 ;; -esac