Skip to content

Commit 3a4bf9c

Browse files
authored
Merge pull request #7389 from Shopify/04-23-allow_preserving_file_paths_when_including_assets_from_config_key_entry
Allow preserving file paths when including assets from config key entry
2 parents dd1278d + 1809a56 commit 3a4bf9c

3 files changed

Lines changed: 132 additions & 7 deletions

File tree

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

Lines changed: 6 additions & 3 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])
@@ -126,9 +127,10 @@ export async function executeIncludeAssetsStep(
126127
const outputDir = resolveOutputDir(extension.outputPath)
127128

128129
const aggregatedPathMap = new Map<string, string | string[]>()
129-
// Track basenames written across all configKey entries in this build to detect
130-
// true conflicts (different sources, same basename) while allowing overwrites
131-
// from previous builds.
130+
// Track basenames written across all configKey entries in this build. In
131+
// default mode `uniqueBasename` uses this to rename basename collisions; in
132+
// `preserveFilePaths` mode it's used to detect cross-entry collisions
133+
// (different sources writing the same basename to the bundle).
132134
const usedBasenames = new Set<string>()
133135

134136
// configKey entries run sequentially to avoid filesystem race conditions
@@ -147,6 +149,7 @@ export async function executeIncludeAssetsStep(
147149
context,
148150
destination: sanitizedDest,
149151
usedBasenames,
152+
preserveFilePaths: entry.preserveFilePaths,
150153
})
151154
result.pathMap.forEach((val, key) => aggregatedPathMap.set(key, val))
152155
configKeyCount += result.filesCopied

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {copyConfigKeyEntry} from './copy-config-key-entry.js'
2+
import {AbortError} from '@shopify/cli-kit/node/error'
23
import {inTemporaryDirectory, writeFile, fileExists, mkdir, readFile} from '@shopify/cli-kit/node/fs'
34
import {joinPath} from '@shopify/cli-kit/node/path'
45
import {describe, expect, test, vi, beforeEach} from 'vitest'
@@ -206,6 +207,93 @@ describe('copyConfigKeyEntry', () => {
206207
})
207208
})
208209

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

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {joinPath, basename, relativePath, extname} from '@shopify/cli-kit/node/path'
22
import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, isDirectory} from '@shopify/cli-kit/node/fs'
33
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
4+
import {AbortError} from '@shopify/cli-kit/node/error'
45
import type {BuildContext} from '../../client-steps.js'
56

67
/**
@@ -25,8 +26,17 @@ export async function copyConfigKeyEntry(config: {
2526
context: BuildContext
2627
destination?: string
2728
usedBasenames?: 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+
preserveFilePaths = false,
39+
} = config
3040
const {stdout} = context.options
3141
const value = getNestedValue(context.extension.configuration, key)
3242
let paths: string[]
@@ -75,19 +85,31 @@ export async function copyConfigKeyEntry(config: {
7585
// that may no longer exist in the source, inflating the file count and producing
7686
// stale entries in the manifest's pathMap.
7787
const sourceFiles = await glob(['**/*'], {cwd: fullPath, absolute: false})
88+
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
89+
if (preserveFilePaths) {
90+
for (const file of sourceFiles) assertNoCollision(basename(file), sourcePath, usedBasenames)
91+
}
7892
await copyDirectoryContents(fullPath, destDir)
7993
stdout.write(`Included '${sourcePath}'\n`)
80-
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
94+
for (const file of sourceFiles) usedBasenames.add(basename(file))
8195
pathMap.set(sourcePath, relFiles)
8296
filesCopied += sourceFiles.length
8397
} else {
8498
await mkdir(destDir)
8599
const filename = basename(fullPath)
86-
const destFilename = uniqueBasename(filename, usedBasenames)
100+
let destFilename: string
101+
if (preserveFilePaths) {
102+
// Strict mode: any prior entry that wrote the same basename is a collision.
103+
assertNoCollision(filename, sourcePath, usedBasenames)
104+
destFilename = filename
105+
} else {
106+
// Default mode: flat basename uniqueness across all destinations.
107+
destFilename = uniqueBasename(filename, usedBasenames)
108+
}
87109
usedBasenames.add(destFilename)
110+
const outputRelative = relativePath(outputDir, joinPath(destDir, destFilename))
88111
const destPath = joinPath(destDir, destFilename)
89112
await copyFile(fullPath, destPath)
90-
const outputRelative = relativePath(outputDir, destPath)
91113
stdout.write(`Included '${sourcePath}'\n`)
92114
pathMap.set(sourcePath, outputRelative)
93115
filesCopied += 1
@@ -98,6 +120,18 @@ export async function copyConfigKeyEntry(config: {
98120
return {filesCopied, pathMap}
99121
}
100122

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

0 commit comments

Comments
 (0)