Skip to content

fix(cli): use case-insensitive path comparison on Windows in watch mode#20342

Closed
deepshekhardas wants to merge 1 commit into
tailwindlabs:mainfrom
deepshekhardas:fix/watch-case-insensitive
Closed

fix(cli): use case-insensitive path comparison on Windows in watch mode#20342
deepshekhardas wants to merge 1 commit into
tailwindlabs:mainfrom
deepshekhardas:fix/watch-case-insensitive

Conversation

@deepshekhardas

Copy link
Copy Markdown

Cherry-pick of PR #19774 with rebased conflict resolution.

Fixes #16784

On Windows (case-insensitive filesystem), the CLI --watch\ mode doesn't rebuild when the input file path casing differs from what exists on disk.

Adds a \pathsEqual()\ helper that uses case-insensitive comparison on Windows and applies it to output file identity check and full rebuild path matching.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

Safe to merge — the watch-mode fix is narrow and well-contained, and the existing infinite-loop guard and full-rebuild logic are preserved correctly.

The two call sites in build/index.ts are straightforward and correctly address the case-sensitivity problem. The main gap is that the Windows code path in the test file is never exercised on Linux/macOS CI due to a top-level platform guard, meaning the fix ships without automated verification of the Windows-specific branch. The helper also does not normalize path separators, which could leave a narrower follow-on edge case on Windows.

paths-equal.test.ts — the Windows-specific test is skipped on non-Windows CI runners. paths-equal.ts — path separator normalization is absent.

Reviews (1): Last reviewed commit: "fix(cli): use case-insensitive path comp..." | Re-trigger Greptile

Comment on lines +3 to +30
vi.mock('./paths-equal', async () => {
const actual = await vi.importActual<typeof import('./paths-equal')>('./paths-equal')
return actual
})

describe('pathsEqual', () => {
it('returns true for identical paths', async () => {
const { pathsEqual } = await import('./paths-equal')
expect(pathsEqual('/foo/bar/baz.css', '/foo/bar/baz.css')).toBe(true)
})

it('returns false for completely different paths', async () => {
const { pathsEqual } = await import('./paths-equal')
expect(pathsEqual('/foo/bar.css', '/foo/baz.css')).toBe(false)
})

if (process.platform === 'win32') {
it('returns true for paths differing only in case on Windows', async () => {
const { pathsEqual } = await import('./paths-equal')
expect(pathsEqual('C:\\src\\Input.css', 'C:\\src\\input.css')).toBe(true)
})
} else {
it('returns false for paths differing in case on non-Windows', async () => {
const { pathsEqual } = await import('./paths-equal')
expect(pathsEqual('/src/Input.css', '/src/input.css')).toBe(false)
})
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Windows tests never run in non-Windows CI

The vi.mock block (lines 3–6) re-exports the actual module unchanged — it's a no-op. More importantly, the if (process.platform === 'win32') branch at line 19 is evaluated at module-load time, so the case-insensitive Windows test case is simply skipped on every Linux/macOS CI runner. The core behavior this PR fixes (Windows case-insensitive comparison) is therefore never exercised in CI.

To test both branches on any platform, mock process.platform with vi.stubGlobal or spy on it so tests can force the Windows code path without needing a Windows runner.

Comment on lines +4 to +5
if (IS_WINDOWS) {
return a.toLowerCase() === b.toLowerCase()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Path separator mismatch not handled

toLowerCase() fixes the case problem but leaves a path-separator edge case unaddressed: on Windows, file watchers (e.g. Chokidar) sometimes emit paths with forward slashes (C:/src/input.css) while CLI arguments may use backslashes (C:\src\input.css). These represent the same path but won't be equal after toLowerCase() alone. Adding path.normalize() (or replacing \ with /) before comparing would close this gap and make the helper fully robust on Windows.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a pathsEqual utility that compares paths case-insensitively on Windows and case-sensitively elsewhere. Tests cover identical, different, and platform-dependent case comparisons. The build command uses this utility when checking whether watcher changes target the output file and when determining whether a changed file requires a full rebuild.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main Windows watch-mode path comparison fix.
Description check ✅ Passed The description matches the implemented case-insensitive Windows path comparison fix.
Linked Issues check ✅ Passed The changes address issue #16784 by fixing watch-mode rebuild behavior for casing differences on Windows.
Out of Scope Changes check ✅ Passed The PR stays focused on path comparison fixes and related tests, with no clear unrelated changes.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e8d038a4-75fa-4d96-aa16-775e8e91f008

📥 Commits

Reviewing files that changed from the base of the PR and between bdcd708 and de6e33c.

📒 Files selected for processing (3)
  • packages/@tailwindcss-cli/src/commands/build/index.ts
  • packages/@tailwindcss-cli/src/utils/paths-equal.test.ts
  • packages/@tailwindcss-cli/src/utils/paths-equal.ts

// If the only change happened to the output file, then we don't want to
// trigger a rebuild because that will result in an infinite loop.
if (files.length === 1 && files[0] === args['--output']) return
if (files.length === 1 && args['--output'] && pathsEqual(files[0], args['--output'])) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use pathsEqual for other path comparisons to prevent Windows case-mismatch bugs.

While pathsEqual correctly fixes the watcher loop for the output file here, there are two other unchanged strict path comparisons in this file that will still fail on Windows when case differs:

  1. Polling watcher (line 614):

    let files = scanner.scannedFiles.filter(
      (file) => file !== args['--output'] && file !== args['--map'],
    )

    If case differs, the output or map file will not be filtered out, potentially causing infinite loops in polling mode.

  2. Input/Output collision check (line 157):

    if (args['--input'] === args['--output'] && args['--input'] !== '-') {

    If the user provides the same file with different casing, this check will fail to catch the collision, leading to the output overwriting the input.

Consider updating these locations to use pathsEqual to fully resolve the issue across the CLI.

🛠️ Proposed fixes for the unchanged lines

For line 157:

  // Check if the input and output file paths are identical, otherwise return an
  // error to the user.
  if (
    args['--input'] &&
    args['--output'] &&
    args['--input'] !== '-' &&
    pathsEqual(args['--input'], args['--output'])
  ) {
    eprintln(

For line 614:

        let files = scanner.scannedFiles.filter(
          (file) =>
            !(args['--output'] && pathsEqual(file, args['--output'])) &&
            !(typeof args['--map'] === 'string' && pathsEqual(file, args['--map'])),
        )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npx watch is case sensitve

3 participants