Skip to content

Commit 337f976

Browse files
authored
fix: handle case-insensitive path conflicts in groupTargetsByDirectoryTree (#164)
1 parent 128637c commit 337f976

File tree

8 files changed

+140
-4
lines changed

8 files changed

+140
-4
lines changed

.changeset/seven-things-marry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'vite-plugin-static-copy': patch
3+
---
4+
5+
fixes case-insensitive path conflicts causing copy failures (EEXIST error)

src/utils.bench.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { bench, describe } from 'vitest'
2+
import { groupTargetsByDirectoryTree } from './utils'
3+
4+
type TestTarget = {
5+
resolvedDest: string
6+
id: string
7+
}
8+
9+
function generateTestTargets(count: number): TestTarget[] {
10+
const targets: TestTarget[] = []
11+
const basePaths = [
12+
'/home/user/project/dist',
13+
'/home/user/project/dist/assets',
14+
'/home/user/project/dist/assets/images',
15+
'/home/user/project/dist/assets/css',
16+
'/home/user/project/dist/components',
17+
'/home/user/project/dist/components/ui',
18+
'/home/user/project/dist/pages',
19+
'/home/user/project/public',
20+
'/home/user/project/public/static',
21+
'/tmp/build'
22+
]
23+
24+
for (let i = 0; i < count; i++) {
25+
const basePath = basePaths[i % basePaths.length]
26+
const suffix = Math.floor(i / basePaths.length)
27+
targets.push({
28+
resolvedDest:
29+
suffix > 0 ? `${basePath}/file${suffix}` : `${basePath}/file`,
30+
id: `target-${i}`
31+
})
32+
}
33+
34+
return targets
35+
}
36+
37+
describe('groupTargetsByDirectoryTree benchmark', () => {
38+
bench('small dataset (10 targets)', () => {
39+
const targets = generateTestTargets(10)
40+
groupTargetsByDirectoryTree(targets)
41+
})
42+
43+
bench('medium dataset (100 targets)', () => {
44+
const targets = generateTestTargets(100)
45+
groupTargetsByDirectoryTree(targets)
46+
})
47+
48+
bench('large dataset (1000 targets)', () => {
49+
const targets = generateTestTargets(1000)
50+
groupTargetsByDirectoryTree(targets)
51+
})
52+
53+
bench('nested directory structure (deep)', () => {
54+
const targets: TestTarget[] = []
55+
const baseDepth = 10
56+
57+
for (let depth = 0; depth < baseDepth; depth++) {
58+
const pathParts = ['/home', 'user', 'project']
59+
for (let i = 0; i <= depth; i++) {
60+
pathParts.push(`level${i}`)
61+
}
62+
targets.push({
63+
resolvedDest: pathParts.join('/'),
64+
id: `nested-${depth}`
65+
})
66+
}
67+
68+
groupTargetsByDirectoryTree(targets)
69+
})
70+
71+
bench('mixed path scenarios', () => {
72+
const targets: TestTarget[] = [
73+
{ resolvedDest: '/home/user/project/dist/index.html', id: 'html1' },
74+
{ resolvedDest: '/home/user/project/dist/main.js', id: 'js1' },
75+
{ resolvedDest: '/home/user/project/dist/style.css', id: 'css1' },
76+
77+
{ resolvedDest: '/home/user/project/dist', id: 'parent' },
78+
{ resolvedDest: '/home/user/project/dist/assets', id: 'child1' },
79+
{
80+
resolvedDest: '/home/user/project/dist/assets/images',
81+
id: 'grandchild1'
82+
},
83+
84+
{ resolvedDest: '/tmp/build/output.txt', id: 'independent1' },
85+
{ resolvedDest: '/var/backup/files/data.json', id: 'independent2' },
86+
87+
{ resolvedDest: '/home/user/Project/Dist/File.txt', id: 'case1' },
88+
{ resolvedDest: '/home/user/project/dist/file.txt', id: 'case2' }
89+
]
90+
91+
groupTargetsByDirectoryTree(targets)
92+
})
93+
94+
bench('duplicate paths', () => {
95+
const targets: TestTarget[] = []
96+
for (let i = 0; i < 50; i++) {
97+
targets.push({
98+
resolvedDest: '/home/user/project/dist/output',
99+
id: `duplicate-${i}`
100+
})
101+
}
102+
groupTargetsByDirectoryTree(targets)
103+
})
104+
})

src/utils.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ describe('isSubdirectoryOrEqual', () => {
2020
['../', './', false],
2121
['../test', './', false],
2222
['../test/', './', false],
23+
// case-insensitive
24+
['./foo/bar', './FOO/BAR', true],
25+
['./FOO/BAR', './foo/bar', true],
26+
['./foo/bar', './foo/BAR', true],
27+
['./foo/bar', './foo/baz', false],
2328
...(isWindows
2429
? ([
2530
['C:/', 'C:/', true],
@@ -54,7 +59,8 @@ describe('groupTargetsByDirectoryTree', () => {
5459
defineCase(['a', 'a/b/c'], [['a', 'a/b/c']]),
5560
defineCase(['a/b', 'a/b/c'], [['a/b', 'a/b/c']]),
5661
defineCase(['a/b/c', 'a/b'], [['a/b/c', 'a/b']]),
57-
defineCase(['a', 'a/b/d'], [['a', 'a/b/d']])
62+
defineCase(['a', 'a/b/d'], [['a', 'a/b/d']]),
63+
defineCase(['foo/bar', 'FOO/BAR'], [['foo/bar', 'FOO/BAR']])
5864
] satisfies {
5965
name: string
6066
input: { resolvedDest: string }[]

src/utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,18 @@ type ResolvedTarget = SimpleTarget & {
3030
/**
3131
* Whether a is a subdirectory of b or equal to b
3232
*
33+
* Note: Uses case-insensitive comparison regardless of filesystem behavior
34+
*
3335
* @param a absolute path
3436
* @param b absolute path
3537
*/
3638
export const isSubdirectoryOrEqual = (a: string, b: string) => {
37-
return a.startsWith(b + path.sep) || a === b
39+
const normalizedA = a.toLowerCase()
40+
const normalizedB = b.toLowerCase()
41+
return (
42+
normalizedA.startsWith(normalizedB + path.sep) ||
43+
normalizedA === normalizedB
44+
)
3845
}
3946

4047
export const groupTargetsByDirectoryTree = <T extends { resolvedDest: string }>(
@@ -52,7 +59,7 @@ export const groupTargetsByDirectoryTree = <T extends { resolvedDest: string }>(
5259

5360
const groups: Record<string, (T & { order: number })[]> = {}
5461
for (const target of targetsWithOrder) {
55-
const { resolvedDest } = target
62+
const resolvedDest = target.resolvedDest.toLowerCase()
5663
const parent = Object.keys(groups).find(key =>
5764
isSubdirectoryOrEqual(key, resolvedDest)
5865
)

test/fixtures/eexist/a/1.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
foo1

test/fixtures/eexist/b/1.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
foo1

test/fixtures/vite.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ export default defineConfig({
135135
rename: filename => {
136136
return `${filename}.foo`
137137
}
138-
}
138+
},
139+
{ src: 'eexist/*', dest: 'eexist' },
140+
{ src: 'eexist/*', dest: 'Eexist' }
139141
]
140142
})
141143
]

test/testcases.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ export const testcases: Record<string, Testcase[]> = {
135135
src: 'foo.txt',
136136
dest: '/fixture12/foo.foo',
137137
contentType: ''
138+
},
139+
{
140+
name: 'parallel copy to same dir (1)',
141+
src: './eexist/a/1.txt',
142+
dest: '/eexist/a/1.txt'
143+
},
144+
{
145+
name: 'parallel copy to same dir (2)',
146+
src: './eexist/b/1.txt',
147+
dest: '/eexist/b/1.txt'
138148
}
139149
],
140150
'vite.absolute.config.ts': [

0 commit comments

Comments
 (0)