Skip to content

Commit d862233

Browse files
vividvioletelanalynn
authored andcommitted
Allow preserving file paths when including assets from config key entry
1 parent 5deb0e5 commit d862233

3 files changed

Lines changed: 137 additions & 4 deletions

File tree

packages/app/src/cli/services/build/steps/include-assets-step.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const ConfigKeyEntrySchema = z.object({
5555
destination: z.string().optional(),
5656
anchor: z.string().optional(),
5757
groupBy: z.string().optional(),
58+
preserveFilePaths: z.boolean().default(false),
5859
})
5960

6061
const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, StaticEntrySchema, ConfigKeyEntrySchema])
@@ -130,6 +131,10 @@ export async function executeIncludeAssetsStep(
130131
// true conflicts (different sources, same basename) while allowing overwrites
131132
// from previous builds.
132133
const usedBasenames = new Set<string>()
134+
// Output-relative paths of every file written by configKey entries in this
135+
// build. Used by `preserveFilePaths` entries to detect cross-entry collisions
136+
// (e.g. two different directory sources producing the same file).
137+
const usedFiles = new Set<string>()
133138

134139
// configKey entries run sequentially to avoid filesystem race conditions
135140
// when multiple entries target the same output directory.
@@ -147,6 +152,8 @@ export async function executeIncludeAssetsStep(
147152
context,
148153
destination: sanitizedDest,
149154
usedBasenames,
155+
usedFiles,
156+
preserveFilePaths: entry.preserveFilePaths,
150157
})
151158
result.pathMap.forEach((val, key) => aggregatedPathMap.set(key, val))
152159
configKeyCount += result.filesCopied

packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,93 @@ describe('copyConfigKeyEntry', () => {
206206
})
207207
})
208208

209+
describe('preserveFilePaths', () => {
210+
test('throws when two directory sources produce the same output-relative file', async () => {
211+
await inTemporaryDirectory(async (tmpDir) => {
212+
const dirA = joinPath(tmpDir, 'a-assets')
213+
const dirB = joinPath(tmpDir, 'b-assets')
214+
await mkdir(dirA)
215+
await mkdir(dirB)
216+
await writeFile(joinPath(dirA, 'logo.png'), 'a')
217+
await writeFile(joinPath(dirB, 'logo.png'), 'b')
218+
219+
const outDir = joinPath(tmpDir, 'out')
220+
await mkdir(outDir)
221+
222+
const usedFiles = new Set<string>()
223+
const contextA = makeContext({dir: 'a-assets'}, mockStdout)
224+
await copyConfigKeyEntry({
225+
key: 'dir',
226+
baseDir: tmpDir,
227+
outputDir: outDir,
228+
context: contextA,
229+
usedFiles,
230+
preserveFilePaths: true,
231+
})
232+
233+
const contextB = makeContext({dir: 'b-assets'}, mockStdout)
234+
await expect(
235+
copyConfigKeyEntry({
236+
key: 'dir',
237+
baseDir: tmpDir,
238+
outputDir: outDir,
239+
context: contextB,
240+
usedFiles,
241+
preserveFilePaths: true,
242+
}),
243+
).rejects.toThrow(/File collision with preserveFilePaths enabled: 'logo\.png'/)
244+
})
245+
})
246+
247+
test('throws when two single-file sources share a basename (no renaming)', async () => {
248+
await inTemporaryDirectory(async (tmpDir) => {
249+
const dirA = joinPath(tmpDir, 'a')
250+
const dirB = joinPath(tmpDir, 'b')
251+
await mkdir(dirA)
252+
await mkdir(dirB)
253+
await writeFile(joinPath(dirA, 'schema.json'), '{"a": true}')
254+
await writeFile(joinPath(dirB, 'schema.json'), '{"b": true}')
255+
256+
const outDir = joinPath(tmpDir, 'out')
257+
await mkdir(outDir)
258+
259+
const context = makeContext({files: ['a/schema.json', 'b/schema.json']}, mockStdout)
260+
await expect(
261+
copyConfigKeyEntry({
262+
key: 'files',
263+
baseDir: tmpDir,
264+
outputDir: outDir,
265+
context,
266+
preserveFilePaths: true,
267+
}),
268+
).rejects.toThrow(/File collision with preserveFilePaths enabled: 'schema\.json'/)
269+
})
270+
})
271+
272+
test('does not throw when the same directory source is referenced twice (deduped within one call)', async () => {
273+
await inTemporaryDirectory(async (tmpDir) => {
274+
const srcDir = joinPath(tmpDir, 'assets')
275+
await mkdir(srcDir)
276+
await writeFile(joinPath(srcDir, 'foo.json'), '{}')
277+
278+
const outDir = joinPath(tmpDir, 'out')
279+
await mkdir(outDir)
280+
281+
const context = makeContext({extensions: [{targeting: [{assets: 'assets'}, {assets: 'assets'}]}]}, mockStdout)
282+
const result = await copyConfigKeyEntry({
283+
key: 'extensions[].targeting[].assets',
284+
baseDir: tmpDir,
285+
outputDir: outDir,
286+
context,
287+
preserveFilePaths: true,
288+
})
289+
290+
expect(result.filesCopied).toBe(1)
291+
await expect(fileExists(joinPath(outDir, 'foo.json'))).resolves.toBe(true)
292+
})
293+
})
294+
})
295+
209296
test('deduplicates repeated source paths — copies each unique path only once', async () => {
210297
await inTemporaryDirectory(async (tmpDir) => {
211298
await writeFile(joinPath(tmpDir, 'tools.json'), '{}')

packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,19 @@ export async function copyConfigKeyEntry(config: {
2525
context: BuildContext
2626
destination?: string
2727
usedBasenames?: Set<string>
28+
usedFiles?: Set<string>
29+
preserveFilePaths?: boolean
2830
}): Promise<{filesCopied: number; pathMap: Map<string, string | string[]>}> {
29-
const {key, baseDir, outputDir, context, destination, usedBasenames = new Set()} = config
31+
const {
32+
key,
33+
baseDir,
34+
outputDir,
35+
context,
36+
destination,
37+
usedBasenames = new Set<string>(),
38+
usedFiles = new Set<string>(),
39+
preserveFilePaths = false,
40+
} = config
3041
const {stdout} = context.options
3142
const value = getNestedValue(context.extension.configuration, key)
3243
let paths: string[]
@@ -75,19 +86,35 @@ export async function copyConfigKeyEntry(config: {
7586
// that may no longer exist in the source, inflating the file count and producing
7687
// stale entries in the manifest's pathMap.
7788
const sourceFiles = await glob(['**/*'], {cwd: fullPath, absolute: false})
89+
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
90+
if (preserveFilePaths) {
91+
for (const relFile of relFiles) assertNoCollision(relFile, sourcePath, usedFiles)
92+
}
7893
await copyDirectoryContents(fullPath, destDir)
7994
stdout.write(`Included '${sourcePath}'\n`)
80-
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
95+
for (const relFile of relFiles) usedFiles.add(relFile)
8196
pathMap.set(sourcePath, relFiles)
8297
filesCopied += sourceFiles.length
8398
} else {
8499
await mkdir(destDir)
85100
const filename = basename(fullPath)
86-
const destFilename = uniqueBasename(filename, usedBasenames)
101+
let destFilename: string
102+
let outputRelative: string
103+
if (preserveFilePaths) {
104+
// Strict mode checks output-relative paths so single-file sources can
105+
// detect collisions with files from any prior entry (including directory sources).
106+
outputRelative = relativePath(outputDir, joinPath(destDir, filename))
107+
assertNoCollision(outputRelative, sourcePath, usedFiles)
108+
destFilename = filename
109+
} else {
110+
// Default mode: flat basename uniqueness across all destinations.
111+
destFilename = uniqueBasename(filename, usedBasenames)
112+
outputRelative = relativePath(outputDir, joinPath(destDir, destFilename))
113+
}
87114
usedBasenames.add(destFilename)
88115
const destPath = joinPath(destDir, destFilename)
89116
await copyFile(fullPath, destPath)
90-
const outputRelative = relativePath(outputDir, destPath)
117+
usedFiles.add(outputRelative)
91118
stdout.write(`Included '${sourcePath}'\n`)
92119
pathMap.set(sourcePath, outputRelative)
93120
filesCopied += 1
@@ -98,6 +125,18 @@ export async function copyConfigKeyEntry(config: {
98125
return {filesCopied, pathMap}
99126
}
100127

128+
/**
129+
* Throws if `path` is already present in `used`. Shared between directory and
130+
* single-file branches so the error message and check shape stay in one place.
131+
*/
132+
function assertNoCollision(path: string, sourcePath: string, used: Set<string>): void {
133+
if (used.has(path)) {
134+
throw new Error(
135+
`File collision with preserveFilePaths enabled: '${path}' from '${sourcePath}' would overwrite a file copied from a different source. Rename or relocate the conflicting file.`,
136+
)
137+
}
138+
}
139+
101140
/**
102141
* Returns a unique filename given the set of basenames already used in this
103142
* build. If `filename` hasn't been used, returns it as-is. Otherwise appends

0 commit comments

Comments
 (0)