diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index e73adfd1d369..21c54003dde2 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -26147,10 +26147,11 @@ var Git = class _Git { * @param fromRef - The starting reference (commit, branch, tag, etc.) * @param toRef - The ending reference (defaults to working directory if not provided) * @param filePaths - Optional specific file path(s) to diff (relative to git repo root) + * @param untrackedFileExtensions - Optional extensions (e.g. ['.ts', '.tsx']) to restrict which untracked files get read; avoids reading unrelated untracked files (media, snapshots, etc.) in full * @returns Structured diff result with line numbers and change information * @throws Error when git command fails (invalid refs, not a git repo, file not found, etc.) */ - static diff(fromRef, toRef, filePaths, shouldIncludeUntrackedFiles = false) { + static diff(fromRef, toRef, filePaths, shouldIncludeUntrackedFiles = false, untrackedFileExtensions) { let command = `git diff -U0 -M ${fromRef}`; if (toRef) { command += ` ${toRef}`; @@ -26163,7 +26164,10 @@ var Git = class _Git { const diffOutput = execSync(command); const diffResult = _Git.parseDiff(diffOutput); if (!toRef && shouldIncludeUntrackedFiles) { - const untrackedFiles = _Git.getUntrackedFiles(filePaths); + let untrackedFiles = _Git.getUntrackedFiles(filePaths); + if (untrackedFileExtensions) { + untrackedFiles = untrackedFiles.filter((file) => untrackedFileExtensions.some((ext) => file.endsWith(ext))); + } const untrackedFileDiffs = _Git.createFileDiffsForUntrackedFiles(untrackedFiles); if (untrackedFileDiffs.length > 0) { diffResult.files.push(...untrackedFileDiffs); @@ -26451,7 +26455,7 @@ var Git = class _Git { * In CI, uses the GitHub API with pagination for accuracy. * Locally, uses git diff against the provided ref. */ - static async getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles = false) { + static async getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles = false, untrackedFileExtensions) { if (IS_CI) { const files = await GithubUtils_default.paginate(GithubUtils_default.octokit.pulls.listFiles, { owner: CONST_default.GITHUB_OWNER, @@ -26467,7 +26471,7 @@ var Git = class _Git { previousFilename: file.previous_filename })); } - const diffResult = this.diff(fromRef, toRef, void 0, shouldIncludeUntrackedFiles); + const diffResult = this.diff(fromRef, toRef, void 0, shouldIncludeUntrackedFiles, untrackedFileExtensions); return diffResult.files.map((file) => ({ filename: file.filePath, status: file.diffType, @@ -26486,13 +26490,13 @@ var Git = class _Git { */ static getUntrackedFiles(filePaths) { try { - const untrackedOutput = execSync("git ls-files --others --exclude-standard", { + const untrackedOutput = execSync("git ls-files -z --others --exclude-standard", { stdio: "pipe" }); - if (!untrackedOutput.trim()) { + if (!untrackedOutput) { return []; } - let untrackedFiles = untrackedOutput.trim().split("\n").filter((file) => file.length > 0); + let untrackedFiles = untrackedOutput.split("\0").filter((file) => file.length > 0); if (filePaths) { const pathsArray = Array.isArray(filePaths) ? filePaths : [filePaths]; const normalizedPaths = pathsArray.map((p) => path.normalize(p)); diff --git a/CLAUDE.md b/CLAUDE.md index afaa65aac0de..c136517d4260 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ Do not use `useMemo`, `useCallback`, or `React.memo` in components or hooks that 1. **ESLint**: Run `npm run lint-changed` to catch lint errors early. 2. **TypeScript**: Run `npm run typecheck` after changes that may affect typing (types, interfaces, or function signatures). It runs the TypeScript 7 native compiler and is the required merge gate in CI. 3. **React Compiler**: If you added new React components/hooks or modified existing ones, run `npm run react-compiler-compliance-check check-changed` to verify they compile with React Compiler. This applies the same rules as CI, evaluated against BOTH the Babel and OXC compilers: new components/hooks must compile, existing compiled files must not regress, and changes must not introduce new memoization divergence (one compiler memoizing a file while the other does not). See `contributingGuides/REACT_COMPILER.md` for details and common fixes. -4. **Spelling**: Run `npm run spell-changed -- ` to catch spelling errors (cspell needs an explicit file list). CI validates with cspell, which remains the required merge gate. +4. **Spelling**: Run `npm run spell-changed` to catch spelling errors (it discovers changed files itself; pass an explicit file list only if you want to check specific files instead). CI validates with cspell, which remains the required merge gate. ### Testing diff --git a/cspell.json b/cspell.json index e125e93d7579..0a93d3309820 100644 --- a/cspell.json +++ b/cspell.json @@ -178,6 +178,7 @@ "HSBCSGS", "Handelsbanken", "Handtool", + "Heapsnapshot", "Heathrow", "HiBob", "Highfive", @@ -439,6 +440,7 @@ "WDYR", "Wallester", "Warchoł", + "Webp", "Wintrust", "Woohoo", "Wooo", @@ -910,6 +912,7 @@ "QAPR", "QBWC", "qrcode", + "quotepath", "rach", "reactnative", "reactnativebackgroundtask", diff --git a/package.json b/package.json index c3700dae229d..e7cf7591e094 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "knip-changed": "./scripts/knip-changed.sh", "shellcheck": "./scripts/shellCheck.sh", "spell": "cspell --color **/*", - "spell-changed": "cspell --color --no-must-find-files", + "spell-changed": "./scripts/spellChanged.sh", "fmt": "oxfmt --write .", "fmt-watch": "onchange \"**/*.{js,mjs,ts,tsx,yml,yaml}\" -- oxfmt --write {{changed}}", "print-version": "echo $npm_package_version", diff --git a/scripts/knip-changed.sh b/scripts/knip-changed.sh index a7577c4cff40..29faf775c5df 100755 --- a/scripts/knip-changed.sh +++ b/scripts/knip-changed.sh @@ -1,17 +1,19 @@ #!/bin/bash # -# Run the same knip delta check the CI workflow runs, against your local main. -# Generates knip reports for the current branch and main, then compares them -# with scripts/compareKnipReports.ts. +# Run the same knip delta check the CI workflow runs, against the merge base +# with origin/main. Generates knip reports for the current branch and the +# merge base, then compares them with scripts/compareKnipReports.ts. # # Uses a temporary git worktree so your working directory is untouched. # Reuses your current node_modules (symlinked) — fine for static analysis as -# long as dependencies haven't changed dramatically. If main has drifted -# locally, run `git pull --rebase origin main` first; this script will not -# fetch on your behalf. +# long as dependencies haven't changed dramatically. # set -euo pipefail +TOP="$(realpath "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)/..")" +readonly TOP +source "${TOP}/scripts/shellUtils.sh" + WORKTREE_DIR=$(mktemp -d /tmp/knip-main-worktree.XXXXXX) CURRENT_REPORT=$(mktemp /tmp/knip-current.XXXXXX.json) MAIN_REPORT=$(mktemp /tmp/knip-main.XXXXXX.json) @@ -22,11 +24,15 @@ cleanup() { } trap cleanup EXIT +info "Fetching origin/main" +MERGE_BASE_SHA_HASH="$(get_merge_base_with_main)" +readonly MERGE_BASE_SHA_HASH + echo "Running knip on current branch..." npm run knip:json > "$CURRENT_REPORT" -echo "Running knip on main..." -git worktree add --detach "$WORKTREE_DIR" main >/dev/null +echo "Running knip on merge base (${MERGE_BASE_SHA_HASH})..." +git worktree add --detach "$WORKTREE_DIR" "$MERGE_BASE_SHA_HASH" >/dev/null ln -s "$PWD/node_modules" "$WORKTREE_DIR/node_modules" (cd "$WORKTREE_DIR" && npm run knip:json) > "$MAIN_REPORT" diff --git a/scripts/lintChanged.sh b/scripts/lintChanged.sh index 997e2b7bc3fb..59176eba28a9 100755 --- a/scripts/lintChanged.sh +++ b/scripts/lintChanged.sh @@ -8,29 +8,23 @@ TOP="$(realpath "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) readonly TOP source "${TOP}/scripts/shellUtils.sh" -# Fetch the commit history to include the merge-base commit info "Fetching origin/main" -git fetch origin main --no-tags - -MERGE_BASE_SHA_HASH="$(git merge-base origin/main HEAD)" +MERGE_BASE_SHA_HASH="$(get_merge_base_with_main)" readonly MERGE_BASE_SHA_HASH -# Check if output is empty or malformed -if [[ -z "$MERGE_BASE_SHA_HASH" ]] || ! [[ "$MERGE_BASE_SHA_HASH" =~ ^[a-fA-F0-9]{40}$ ]]; then - error "git merge-base returned unexpected output: $MERGE_BASE_SHA_HASH" - exit 1 -fi - -# Get the diff output and check status -if ! GIT_DIFF_OUTPUT="$(git diff --diff-filter=AMR --name-only "$MERGE_BASE_SHA_HASH" HEAD -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs')"; then - error "git diff failed - output: $GIT_DIFF_OUTPUT" - exit 1 +# Diffs against the working tree (not HEAD) and includes untracked files, so +# committed, staged, unstaged and untracked changes are all linted +CHANGED_FILES_OUTPUT="$(get_changed_files "$MERGE_BASE_SHA_HASH" '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs')" +declare -a ALL_CHANGED_FILES=() +if [[ -n "$CHANGED_FILES_OUTPUT" ]]; then + while IFS= read -r file; do + ALL_CHANGED_FILES+=("$file") + done <<< "$CHANGED_FILES_OUTPUT" fi # Run eslint on the changed files, forwarding any user-provided flags -if [[ -n "$GIT_DIFF_OUTPUT" ]] ; then - # shellcheck disable=SC2086 # For multiple files in variable - exec bun "${TOP}/scripts/lint.ts" "$@" $GIT_DIFF_OUTPUT +if [[ "${#ALL_CHANGED_FILES[@]}" -gt 0 ]]; then + exec bun "${TOP}/scripts/lint.ts" "$@" "${ALL_CHANGED_FILES[@]}" else info "No lintable files changed" fi diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 0dfba4dfa7de..461793cbb652 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -248,7 +248,7 @@ function getMainSource(ref: string, mainPath: string): string | undefined { */ async function checkChangedFiles(remote: string, verbose: boolean, checkOxc: OxcChecker): Promise { const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); - const changedFiles = await Git.getChangedFilesWithStatus(mainBaseCommitHash); + const changedFiles = await Git.getChangedFilesWithStatus(mainBaseCommitHash, undefined, true, FILE_EXTENSIONS); const reactFiles = changedFiles.filter((f) => FILE_EXTENSIONS.some((ext) => f.filename.endsWith(ext)) && f.status !== 'removed'); diff --git a/scripts/shellUtils.sh b/scripts/shellUtils.sh index 58638d243756..9097988c9938 100644 --- a/scripts/shellUtils.sh +++ b/scripts/shellUtils.sh @@ -26,24 +26,24 @@ if [ -z "${RESET+x}" ]; then fi function success { - echo -e "🎉 $GREEN$1$RESET" + echo -e "🎉 $GREEN$1$RESET" >&2 } function error { - echo -e "💥 $RED$1$RESET" + echo -e "💥 $RED$1$RESET" >&2 } function info { - echo -e "$BLUE$1$RESET" + echo -e "$BLUE$1$RESET" >&2 } function title { - printf "\n%s%s%s\n" "$TITLE" "$1" "$RESET" + printf "\n%s%s%s\n" "$TITLE" "$1" "$RESET" >&2 } # Function to clear the last printed line clear_last_line() { - echo -ne "\033[1A\033[K" + echo -ne "\033[1A\033[K" >&2 } # Function to check if Cloudflare WARP is installed and running @@ -126,8 +126,47 @@ get_abs_path() { echo "$abs_path" } -# Function to read lines from standard input into an array using a temporary file. -# This is a bash 3 polyfill for readarray. +# Fetches origin/main and prints the merge-base SHA between it and HEAD. +# This is the single definition of "base" shared by the *-changed scripts +# (lintChanged.sh, knip-changed.sh, spellChanged.sh) so they all agree on +# what "changed" means. +# Usage: get_merge_base_with_main +get_merge_base_with_main() { + if ! git fetch origin main --no-tags >&2; then + error "git fetch origin main failed" + return 1 + fi + + local merge_base_sha_hash + merge_base_sha_hash="$(git merge-base origin/main HEAD)" || { + error "git merge-base failed" + return 1 + } + + if ! [[ "$merge_base_sha_hash" =~ ^[a-fA-F0-9]{40}$ ]]; then + error "git merge-base returned unexpected output: $merge_base_sha_hash" + return 1 + fi + + echo "$merge_base_sha_hash" +} + +# Prints files changed relative to a base commit, diffed against the working +# tree (so committed, staged and unstaged changes are all included), plus +# any untracked files. Excludes deletions. +# Usage: get_changed_files [path spec...] +get_changed_files() { + local base_sha="$1" + shift + + git -c core.quotepath=false -c diff.relative=false diff --diff-filter=AMR --name-only "$base_sha" -- "$@" || return 1 + git -c core.quotepath=false ls-files --full-name --others --exclude-standard -- "$@" +} + +# Function to read lines from standard input into an array. +# This is a bash 3 polyfill for readarray. Uses printf -v (not eval) on each +# line so special shell characters in the input (e.g. from git-derived filenames) +# aren't executed. # Arguments: # $1: Name of the array variable to store the lines # Usage: @@ -135,7 +174,10 @@ get_abs_path() { read_lines_into_array() { local array_name="$1" local line + local index + eval "index=\${#${array_name}[@]}" while IFS= read -r line || [ -n "$line" ]; do - eval "$array_name+=(\"$line\")" + printf -v "${array_name}[$index]" '%s' "$line" + index=$((index + 1)) done } diff --git a/scripts/spellChanged.sh b/scripts/spellChanged.sh new file mode 100755 index 000000000000..90dee2fec07f --- /dev/null +++ b/scripts/spellChanged.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Spell-checks files that have changed in this branch. If file paths are +# passed as arguments (e.g. by CI, which gets its file list from the PR +# API), those are checked instead and no change discovery happens. + +set -eu + +TOP="$(realpath "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)/..")" +readonly TOP +source "${TOP}/scripts/shellUtils.sh" + +if [[ "$#" -gt 0 ]]; then + exec "${TOP}/node_modules/.bin/cspell" --color --no-must-find-files "$@" +fi + +info "Fetching origin/main" +MERGE_BASE_SHA_HASH="$(get_merge_base_with_main)" +readonly MERGE_BASE_SHA_HASH + +# Excludes common binary/media file types since spell-checking them is pointless and wasteful +CHANGED_FILES_OUTPUT="$(get_changed_files "$MERGE_BASE_SHA_HASH" ':!*.png' ':!*.jpg' ':!*.jpeg' ':!*.gif' ':!*.webp' ':!*.ico' ':!*.mp4' ':!*.mov' ':!*.zip' ':!*.tar.gz' ':!*.heapsnapshot' ':!*.pdf')" +declare -a ALL_CHANGED_FILES=() +if [[ -n "$CHANGED_FILES_OUTPUT" ]]; then + # Excludes any path starting with "." (dotfiles and top-level dot-directories like .github/), matching CI's filter. A nested dot-directory, e.g. docs/.hidden/config.ts, is still checked. + while IFS= read -r file; do + if [[ "$file" != .* ]]; then + ALL_CHANGED_FILES+=("$file") + fi + done <<< "$CHANGED_FILES_OUTPUT" +fi +readonly -a ALL_CHANGED_FILES + +if [[ "${#ALL_CHANGED_FILES[@]}" -gt 0 ]]; then + exec "${TOP}/node_modules/.bin/cspell" --color --no-must-find-files "${ALL_CHANGED_FILES[@]}" +else + info "No changed files to spell check" +fi diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 36bc8c7fcd4d..48dcbb6ccebb 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -117,10 +117,11 @@ class Git { * @param fromRef - The starting reference (commit, branch, tag, etc.) * @param toRef - The ending reference (defaults to working directory if not provided) * @param filePaths - Optional specific file path(s) to diff (relative to git repo root) + * @param untrackedFileExtensions - Optional extensions (e.g. ['.ts', '.tsx']) to restrict which untracked files get read; avoids reading unrelated untracked files (media, snapshots, etc.) in full * @returns Structured diff result with line numbers and change information * @throws Error when git command fails (invalid refs, not a git repo, file not found, etc.) */ - static diff(fromRef: string, toRef?: string, filePaths?: string | string[], shouldIncludeUntrackedFiles = false): DiffResult { + static diff(fromRef: string, toRef?: string, filePaths?: string | string[], shouldIncludeUntrackedFiles = false, untrackedFileExtensions?: string[]): DiffResult { // Build git diff command (with 0 context lines for easier parsing, -M for rename detection) let command = `git diff -U0 -M ${fromRef}`; if (toRef) { @@ -139,7 +140,10 @@ class Git { // Include untracked files when diffing against working directory if (!toRef && shouldIncludeUntrackedFiles) { - const untrackedFiles = Git.getUntrackedFiles(filePaths); + let untrackedFiles = Git.getUntrackedFiles(filePaths); + if (untrackedFileExtensions) { + untrackedFiles = untrackedFiles.filter((file) => untrackedFileExtensions.some((ext) => file.endsWith(ext))); + } const untrackedFileDiffs = Git.createFileDiffsForUntrackedFiles(untrackedFiles); // Merge untracked files into the diff result @@ -502,7 +506,7 @@ class Git { * In CI, uses the GitHub API with pagination for accuracy. * Locally, uses git diff against the provided ref. */ - static async getChangedFilesWithStatus(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false): Promise { + static async getChangedFilesWithStatus(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false, untrackedFileExtensions?: string[]): Promise { if (IS_CI) { const files = await GitHubUtils.paginate(GitHubUtils.octokit.pulls.listFiles, { owner: CONST.GITHUB_OWNER, @@ -520,7 +524,7 @@ class Git { })); } - const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles); + const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles, untrackedFileExtensions); return diffResult.files.map((file) => ({ filename: file.filePath, status: file.diffType, @@ -541,19 +545,16 @@ class Git { */ static getUntrackedFiles(filePaths?: string | string[]): string[] { try { - // Get all untracked files - const untrackedOutput = execSync('git ls-files --others --exclude-standard', { + // -z avoids git C-quoting non-ASCII/special-character paths, so the raw path is preserved for later filtering + const untrackedOutput = execSync('git ls-files -z --others --exclude-standard', { stdio: 'pipe', }); - if (!untrackedOutput.trim()) { + if (!untrackedOutput) { return []; } - let untrackedFiles = untrackedOutput - .trim() - .split('\n') - .filter((file) => file.length > 0); + let untrackedFiles = untrackedOutput.split('\0').filter((file) => file.length > 0); // Filter by filePaths if provided if (filePaths) { diff --git a/tests/tooling/Git.test.ts b/tests/tooling/Git.test.ts index 7b6a8b372535..39cfa57b2b1e 100644 --- a/tests/tooling/Git.test.ts +++ b/tests/tooling/Git.test.ts @@ -1195,12 +1195,12 @@ describe('Git', () => { describe('getUntrackedFiles', () => { it('returns array of untracked file paths', () => { - mockExecSync.mockReturnValue('src/new-file.ts\nsrc/another-file.tsx\n'); + mockExecSync.mockReturnValue('src/new-file.ts\0src/another-file.tsx\0'); const result = Git.getUntrackedFiles(); expect(result).toEqual(['src/new-file.ts', 'src/another-file.tsx']); - expect(mockExecSync).toHaveBeenCalledWith('git ls-files --others --exclude-standard', { + expect(mockExecSync).toHaveBeenCalledWith('git ls-files -z --others --exclude-standard', { maxBuffer: 1024 * 1024 * 200, encoding: 'utf8', cwd: process.cwd(), @@ -1208,8 +1208,14 @@ describe('Git', () => { }); }); + it('preserves non-ASCII/special-character paths that git would otherwise C-quote', () => { + mockExecSync.mockReturnValue('src/café.tsx\0'); + + expect(Git.getUntrackedFiles()).toEqual(['src/café.tsx']); + }); + it('filters untracked files by file paths (single and multiple)', () => { - mockExecSync.mockReturnValue('src/file1.ts\nsrc/file2.tsx\nsrc/components/Button.tsx\n'); + mockExecSync.mockReturnValue('src/file1.ts\0src/file2.tsx\0src/components/Button.tsx\0'); // Single file path expect(Git.getUntrackedFiles('src/file1.ts')).toEqual(['src/file1.ts']); @@ -1276,7 +1282,7 @@ describe('Git', () => { }); it('includes untracked files when shouldIncludeUntrackedFiles is true and toRef is undefined', () => { - mockExecSync.mockImplementation(createMockExecSync('', `${UNTRACKED_FILE_PATH}\n`)); + mockExecSync.mockImplementation(createMockExecSync('', `${UNTRACKED_FILE_PATH}\0`)); mockReadFileSync.mockReturnValue(MOCK_COMPONENT_CONTENT); const result = Git.diff('main', undefined, undefined, true); @@ -1302,7 +1308,7 @@ describe('Git', () => { +new `); - mockExecSync.mockImplementation(createMockExecSync(mockDiffOutput, `${UNTRACKED_FILE_PATH}\n`)); + mockExecSync.mockImplementation(createMockExecSync(mockDiffOutput, `${UNTRACKED_FILE_PATH}\0`)); mockReadFileSync.mockReturnValue(MOCK_COMPONENT_CONTENT); const result = Git.diff('main', undefined, undefined, true); @@ -1318,7 +1324,7 @@ describe('Git', () => { }); it('filters untracked files by filePaths parameter', () => { - mockExecSync.mockImplementation(createMockExecSync('', 'src/file1.tsx\nsrc/file2.tsx\nsrc/file3.tsx\n')); + mockExecSync.mockImplementation(createMockExecSync('', 'src/file1.tsx\0src/file2.tsx\0src/file3.tsx\0')); mockReadFileSync.mockReturnValue(MOCK_COMPONENT_CONTENT); const result = Git.diff('main', undefined, ['src/file1.tsx', 'src/file3.tsx'], true); @@ -1329,8 +1335,21 @@ describe('Git', () => { expect(result.files.some((f) => f.filePath === 'src/file2.tsx')).toBe(false); }); + it('filters untracked files by untrackedFileExtensions before reading them', () => { + mockExecSync.mockImplementation(createMockExecSync('', 'src/file1.tsx\0src/screenshot.png\0src/file2.ts\0')); + mockReadFileSync.mockReturnValue(MOCK_COMPONENT_CONTENT); + + const result = Git.diff('main', undefined, undefined, true, ['.ts', '.tsx']); + + expect(result.files).toHaveLength(2); + expect(result.files.some((f) => f.filePath === 'src/file1.tsx')).toBe(true); + expect(result.files.some((f) => f.filePath === 'src/file2.ts')).toBe(true); + expect(result.files.some((f) => f.filePath === 'src/screenshot.png')).toBe(false); + expect(mockReadFileSync).not.toHaveBeenCalledWith(expect.stringContaining('screenshot.png'), 'utf8'); + }); + it('handles multi-line untracked files correctly', () => { - mockExecSync.mockImplementation(createMockExecSync('', `${UNTRACKED_FILE_PATH}\n`)); + mockExecSync.mockImplementation(createMockExecSync('', `${UNTRACKED_FILE_PATH}\0`)); mockReadFileSync.mockReturnValue(MOCK_MULTI_LINE_COMPONENT_CONTENT); const result = Git.diff('main', undefined, undefined, true); @@ -1342,7 +1361,7 @@ describe('Git', () => { }); it('skips untracked files that do not exist or cannot be read', () => { - mockExecSync.mockImplementation(createMockExecSync('', 'src/nonexistent.tsx\n')); + mockExecSync.mockImplementation(createMockExecSync('', 'src/nonexistent.tsx\0')); // File does not exist mockExistsSync.mockReturnValue(false);