diff --git a/.gitattributes b/.gitattributes index 9af7eec9dfd..1a39a4c8236 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,16 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# test/starter-prompt-docs.test.ts asserts the exact working-tree bytes of these +# files: the starter-prompt generator rejects a carriage return, the prompt +# assets are byte-compared against their pinned Git blobs, and +# local-credential-form.html is compared against the SHA-256 that users verify +# before they run the local credential helper. A core.autocrlf checkout would +# rewrite them and break those contracts, so pin the line endings here. +/docs/resources/starter-prompt.md text eol=lf +/docs/resources/prompt-assets/*.md text eol=lf +/docs/resources/local-credential-form.html text eol=lf + /skills/nemoclaw/** linguist-generated=true /skills/nemoclaw/**/*.md diff=markdown /tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/**/*.bundle linguist-generated=true -diff diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 867156af9ae..412016f8700 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,21 @@ Install the following before you begin. - Docker (running) - [hadolint](https://github.com/hadolint/hadolint) (Dockerfile linter — `brew install hadolint` on macOS) +### Windows Line Endings + +`.gitattributes` pins the files whose exact bytes the repository checks assert, so a new clone keeps LF even when `core.autocrlf` is `true`. + +Git does not rewrite a file that is already in your working tree. +If you cloned before that rule existed, the file keeps CRLF, `git status` reports no change, and `npm run checks:repository` reports `use LF line endings`. + +Commit or stash your work first, because the next commands discard uncommitted changes. +Then normalize the checkout once: + +```bash +git rm --cached -r . +git reset --hard +``` + ## Getting Started From the repository root, prepare the checkout with one command: diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 7351155fa2e..aa8472bde98 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; @@ -14,6 +15,7 @@ import { renderStarterPromptSnippet, runStarterPromptGenerator, STARTER_PROMPT_GENERATED_PATH, + STARTER_PROMPT_SOURCE_PATH, } from "../scripts/generate-starter-prompt.mts"; import { createGitRunner, @@ -1277,3 +1279,68 @@ describe("starter prompt docs CTA", () => { expect(promptSource).toContain("nemo-deepagents onboard"); }); }); + +describe("starter prompt checkout line endings", () => { + // Point core.attributesFile at an absent path and set GIT_ATTR_NOSYSTEM so the + // result comes from the repository .gitattributes alone. Without that, a + // contributor's global "* text=auto" would decide the outcome. + const absentAttributesFile = path.join(os.tmpdir(), "nemoclaw-absent-gitattributes"); + + function checkoutEol(relativePath: string): string { + const result = spawnSync( + "git", + [ + "-c", + `core.attributesFile=${absentAttributesFile}`, + "check-attr", + "eol", + "--", + relativePath, + ], + { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, GIT_ATTR_NOSYSTEM: "1" }, + timeout: 10_000, + }, + ); + const diagnostic = [ + `git check-attr did not run for ${relativePath}:`, + `status=${result.status}`, + `signal=${result.signal}`, + result.error?.message ?? "", + result.stderr ?? "", + ] + .join(" ") + .trim(); + expect(result.error, diagnostic).toBeUndefined(); + expect(result.status, diagnostic).toBe(0); + return result.stdout; + } + + // check-attr answers for any path, so require the file before trusting its + // attribute; otherwise a rename would leave these assertions passing. + function readCheckoutEol(relativePath: string): string { + expect(fs.existsSync(path.join(repoRoot, relativePath))).toBe(true); + return checkoutEol(relativePath); + } + + // Every file whose exact working-tree bytes this suite asserts. + const bytePinnedPaths = [ + STARTER_PROMPT_SOURCE_PATH, + ...Object.values(promptAssets).map((asset) => asset.path), + "docs/resources/local-credential-form.html", + ]; + + it.each( + bytePinnedPaths, + )("checks out %s with LF so an autocrlf clone keeps the bytes this suite asserts (#8648)", (relativePath) => { + expect(readCheckoutEol(relativePath)).toContain(`${relativePath}: eol: lf`); + }); + + // Asserts only the absence of the LF pin. Asserting "unspecified" would also + // forbid a repository-wide "* text=auto" rule, which is unrelated policy. + it("leaves a file without a byte-exact contract unpinned (#8648)", () => { + expect(readCheckoutEol("docs/resources/agent-skills.mdx")).not.toContain("eol: lf"); + }); +});