diff --git a/common/changes/@rushstack/package-deps-hash/fix-skip-windows-reserved-filenames_2026-05-17-19-30.json b/common/changes/@rushstack/package-deps-hash/fix-skip-windows-reserved-filenames_2026-05-17-19-30.json new file mode 100644 index 00000000000..0fc5923aa12 --- /dev/null +++ b/common/changes/@rushstack/package-deps-hash/fix-skip-windows-reserved-filenames_2026-05-17-19-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Skip untracked files whose basename is a Windows reserved device name (e.g. `nul`, `con`, `aux`, `com1`-`com9`, `lpt1`-`lpt9`) when computing repo state on Windows. `git hash-object` cannot open such paths and otherwise aborts the entire repo-state calculation.", + "type": "patch", + "packageName": "@rushstack/package-deps-hash" + } + ], + "packageName": "@rushstack/package-deps-hash", + "email": "sshaurya914@gmail.com" +} diff --git a/libraries/package-deps-hash/src/getRepoState.ts b/libraries/package-deps-hash/src/getRepoState.ts index cdc489bf9b9..7f01cde1620 100644 --- a/libraries/package-deps-hash/src/getRepoState.ts +++ b/libraries/package-deps-hash/src/getRepoState.ts @@ -28,6 +28,29 @@ const STANDARD_GIT_OPTIONS: readonly string[] = [ 'maintenance.auto=false' ]; +// Windows reserved device names (case-insensitive). A file whose final path segment matches one +// of these (with or without an extension) cannot be opened by name on Windows, so passing it to +// `git hash-object` aborts the process. Such files are typically untracked artifacts left behind +// by tooling (e.g. stray `nul` from a shell redirect). +const WINDOWS_RESERVED_BASENAMES: ReadonlySet = new Set([ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' +]); + +/** + * Returns `true` if `filePath`'s final path segment is a Windows reserved device name + * (with or without an extension), case-insensitively. Exported for tests. + * @internal + */ +export function isWindowsReservedPath(filePath: string): boolean { + const lastSlash: number = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); + const basename: string = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath; + const dot: number = basename.indexOf('.'); + const stem: string = (dot >= 0 ? basename.slice(0, dot) : basename).toUpperCase(); + return WINDOWS_RESERVED_BASENAMES.has(stem); +} + const OBJECTMODE_SUBMODULE: '160000' = '160000'; const OBJECTMODE_SYMLINK: '120000' = '120000'; const OBJECTMODE_FILE_NONEXECUTABLE: '100644' = '100644'; @@ -480,8 +503,15 @@ export async function getDetailedRepoStateAsync( const [{ files, symlinks }, locallyModified] = await Promise.all([statePromise, locallyModifiedPromise]); + const isWindows: boolean = process.platform === 'win32'; for (const [filePath, exists] of locallyModified) { if (exists && !symlinks.has(filePath)) { + // Skip Windows reserved device names. `git hash-object` cannot open them and would abort + // the entire repo-state computation. These are almost always stray artifacts (e.g. a `nul` + // file produced by a misdirected shell redirect) rather than meaningful inputs. + if (isWindows && isWindowsReservedPath(filePath)) { + continue; + } yield filePath; } else { files.delete(filePath); diff --git a/libraries/package-deps-hash/src/test/getRepoState.test.ts b/libraries/package-deps-hash/src/test/getRepoState.test.ts index c1422367b0f..573657feb36 100644 --- a/libraries/package-deps-hash/src/test/getRepoState.test.ts +++ b/libraries/package-deps-hash/src/test/getRepoState.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { parseGitStatus, parseGitVersion } from '../getRepoState'; +import { isWindowsReservedPath, parseGitStatus, parseGitVersion } from '../getRepoState'; describe(parseGitVersion.name, () => { it('Can parse valid git version responses', () => { @@ -87,3 +87,32 @@ describe(parseGitStatus.name, () => { expect(result.get(files[2])).toEqual(true); }); }); + +describe(isWindowsReservedPath.name, () => { + it('detects bare reserved basenames', () => { + expect(isWindowsReservedPath('nul')).toBe(true); + expect(isWindowsReservedPath('NUL')).toBe(true); + expect(isWindowsReservedPath('Con')).toBe(true); + expect(isWindowsReservedPath('com1')).toBe(true); + expect(isWindowsReservedPath('LPT9')).toBe(true); + }); + + it('detects reserved basenames with an extension', () => { + expect(isWindowsReservedPath('nul.txt')).toBe(true); + expect(isWindowsReservedPath('aux.log.bak')).toBe(true); + }); + + it('matches the final segment of nested paths with either slash style', () => { + expect(isWindowsReservedPath('apps/admin/nul')).toBe(true); + expect(isWindowsReservedPath('apps\\admin\\nul')).toBe(true); + expect(isWindowsReservedPath('apps/admin/sub/CON.tmp')).toBe(true); + }); + + it('does not match non-reserved names', () => { + expect(isWindowsReservedPath('null')).toBe(false); + expect(isWindowsReservedPath('console.ts')).toBe(false); + expect(isWindowsReservedPath('com.ts')).toBe(false); + expect(isWindowsReservedPath('lpt10')).toBe(false); + expect(isWindowsReservedPath('packages/nul-suffix/index.ts')).toBe(false); + }); +});