Skip to content

Commit 28175cb

Browse files
committed
Add base option for path resolution
Fixes #113
1 parent 734f340 commit 28175cb

File tree

7 files changed

+292
-13
lines changed

7 files changed

+292
-13
lines changed

glob-pattern.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export default class GlobPattern {
3636
}
3737

3838
get normalizedPath() {
39-
const segments = this.originalPath.split('/');
39+
const normalizedPattern = this.path.startsWith('!') && !this.path.startsWith('!(')
40+
? this.path.slice(1)
41+
: this.path;
42+
const segments = normalizedPattern.split('/');
4043
const magicIndex = segments.findIndex(item => item ? isDynamicPattern(item) : false);
4144
const normalized = segments.slice(0, magicIndex).join('/');
4245

index.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ export type Options = {
9191
*/
9292
readonly cwd?: string;
9393

94+
/**
95+
Choose how destination paths are calculated for patterns. By default, globs are resolved relative to their parent and explicit paths are resolved relative to `cwd`. Set to `'pattern'` to make explicit paths behave like globs, or `'cwd'` to make globs behave like explicit paths.
96+
97+
@default undefined
98+
*/
99+
readonly base?: 'cwd' | 'pattern';
100+
94101
/**
95102
Flatten directory tree.
96103

index.js

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,33 @@ const relativizeWithin = (base, file) => {
246246
return relativePath;
247247
};
248248

249+
const resolveRelativePath = (bases, file) => {
250+
for (const base of bases) {
251+
const relativePath = relativizeWithin(base, file);
252+
if (relativePath !== undefined) {
253+
return relativePath;
254+
}
255+
}
256+
257+
return path.basename(file);
258+
};
259+
260+
const resolveCwdRelativePathForGlob = (entry, options) => {
261+
const relativeToCwd = relativizeWithin(options.cwd, entry.path);
262+
if (relativeToCwd !== undefined) {
263+
return relativeToCwd;
264+
}
265+
266+
const globParent = entry.pattern.normalizedPath;
267+
const relativeToGlobParent = relativizeWithin(globParent, entry.path);
268+
if (relativeToGlobParent !== undefined) {
269+
const globParentBasename = path.basename(globParent);
270+
return globParentBasename === '' ? relativeToGlobParent : path.join(globParentBasename, relativeToGlobParent);
271+
}
272+
273+
return path.basename(entry.path);
274+
};
275+
249276
const computeToForGlob = ({entry, destination, options}) => {
250277
if (options.flat) {
251278
return path.isAbsolute(destination)
@@ -256,9 +283,11 @@ const computeToForGlob = ({entry, destination, options}) => {
256283
// Prefer glob-parent behavior to match existing semantics,
257284
// but defend against self-copy / traversal (#114).
258285
const from = path.resolve(entry.path);
259-
const baseA = entry.pattern.normalizedPath; // Glob parent inside cwd
286+
const baseA = entry.pattern.normalizedPath; // Glob parent path
260287
const baseB = options.cwd;
261-
const relativePath = relativizeWithin(baseA, entry.path) ?? relativizeWithin(baseB, entry.path) ?? path.basename(entry.path);
288+
const relativePath = options.base === 'cwd'
289+
? resolveCwdRelativePathForGlob(entry, options)
290+
: resolveRelativePath([baseA, baseB], entry.path);
262291
let toPath = path.join(destination, relativePath);
263292

264293
// Guard: never copy a file into itself (can truncate under concurrency).
@@ -281,9 +310,18 @@ const computeToForNonGlob = ({entry, destination, options}) => {
281310
? destination
282311
: path.join(options.cwd, destination);
283312

284-
const insideCwd = !path.relative(options.cwd, entry.path).startsWith('..');
313+
if (options.base === 'pattern') {
314+
const patternBase = entry.pattern.isDirectory
315+
? path.resolve(options.cwd, entry.pattern.originalPath)
316+
: path.resolve(options.cwd, path.dirname(entry.pattern.originalPath));
317+
const relativePath = resolveRelativePath([patternBase], entry.path);
318+
return path.join(baseDestination, relativePath);
319+
}
285320

286-
// TODO: This check will not work correctly if `options.cwd` and `entry.path` are on different partitions on Windows, see: https://github.com/sindresorhus/import-local/pull/12
321+
const relativeToCwd = relativizeWithin(options.cwd, entry.path);
322+
const insideCwd = relativeToCwd !== undefined;
323+
324+
// This check should treat different partitions on Windows as outside the cwd.
287325
if (entry.pattern.isDirectory && !insideCwd) {
288326
const originalDir = path.resolve(options.cwd, entry.pattern.originalPath);
289327
return path.join(baseDestination, path.basename(originalDir), path.relative(originalDir, entry.path));
@@ -301,7 +339,11 @@ const computeToForNonGlob = ({entry, destination, options}) => {
301339
return path.join(baseDestination, entry.name);
302340
}
303341

304-
return path.join(baseDestination, path.relative(options.cwd, entry.path));
342+
if (relativeToCwd === undefined) {
343+
return path.join(baseDestination, entry.name);
344+
}
345+
346+
return path.join(baseDestination, relativeToCwd);
305347
};
306348

307349
const preprocessDestinationPath = ({entry, destination, options}) => (
@@ -407,6 +449,10 @@ export default function cpy(
407449
const promise = (async () => {
408450
options.signal?.throwIfAborted();
409451

452+
if (options.base !== undefined && options.base !== 'cwd' && options.base !== 'pattern') {
453+
throw new TypeError('`base` must be "cwd" or "pattern"');
454+
}
455+
410456
/**
411457
@type {Entry[]}
412458
*/

index.test-d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {
1818
},
1919
}));
2020
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {cwd: '/'}));
21+
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {base: 'pattern'}));
22+
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {base: 'cwd'}));
2123
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {flat: true}));
2224
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {overwrite: false}));
2325
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {concurrency: 2}));

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cpy",
3-
"version": "12.1.0",
3+
"version": "12.2.0",
44
"description": "Copy files",
55
"license": "MIT",
66
"repository": "sindresorhus/cpy",

readme.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,6 @@ Default: `process.cwd()`
7878

7979
Working directory to find source files.
8080

81-
> [!NOTE]
82-
> Globs and explicit paths preserve paths differently.
83-
> Globs keep paths **relative to the glob’s parent** (`source/*.md``distribution/readme.md`).
84-
> Explicit paths keep paths **relative to `cwd`** (`source/file.js``distribution/source/file.js`).
85-
> Use a single glob or set `cwd` so all patterns share the same base.
86-
8781
##### overwrite
8882

8983
Type: `boolean`\
@@ -106,6 +100,13 @@ await cpy('src/**/*.js', 'destination', {
106100
});
107101
```
108102

103+
##### base
104+
105+
Type: `'cwd' | 'pattern'`\
106+
Default: `undefined`
107+
108+
Choose how destination paths are calculated for patterns. By default, globs are resolved relative to their parent and explicit paths are resolved relative to `cwd`. Set to `'pattern'` to make explicit paths behave like globs, or `'cwd'` to make globs behave like explicit paths.
109+
109110
##### rename
110111

111112
Type: `string | Function`

test.js

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ test('throws on invalid concurrency value', async () => {
7979
await assert.rejects(cpy(['license', 'package.json'], context.tmp, {concurrency: 'foo'}));
8080
});
8181

82+
test('throws on invalid base option', async () => {
83+
await assert.rejects(
84+
cpy(['license'], context.tmp, {base: 'invalid'}),
85+
expectError(TypeError, /`base` must be/),
86+
);
87+
});
88+
8289
test('copy array of files with filter', async () => {
8390
await cpy(['license', 'package.json'], context.tmp, {
8491
filter(file) {
@@ -238,6 +245,219 @@ test('path structure', async () => {
238245
);
239246
});
240247

248+
test('base option: pattern aligns explicit paths with globs', async () => {
249+
fs.mkdirSync(path.join(context.tmp, 'src'), {recursive: true});
250+
fs.writeFileSync(path.join(context.tmp, 'src/hello-world.js'), 'console.log("hello");');
251+
fs.writeFileSync(path.join(context.tmp, 'src/README.md'), 'readme');
252+
253+
await cpy(['src/*.md', 'src/hello-world.js'], 'dist', {
254+
cwd: context.tmp,
255+
base: 'pattern',
256+
});
257+
258+
assert.strictEqual(
259+
read(context.tmp, 'src/README.md'),
260+
read(context.tmp, 'dist/README.md'),
261+
);
262+
assert.strictEqual(
263+
read(context.tmp, 'src/hello-world.js'),
264+
read(context.tmp, 'dist/hello-world.js'),
265+
);
266+
});
267+
268+
test('base option: cwd preserves nested glob structure from cwd', async () => {
269+
writeFiles(context.tmp, {
270+
'resources/views/app.edge': 'app',
271+
'resources/views/nested/baz.edge': 'baz',
272+
});
273+
274+
const buildDirectory = path.join(context.tmp, 'build');
275+
276+
await cpy(['resources/views/**/*.edge'], buildDirectory, {
277+
cwd: context.tmp,
278+
base: 'cwd',
279+
});
280+
281+
assert.strictEqual(
282+
read(context.tmp, 'resources', 'views', 'app.edge'),
283+
read(buildDirectory, 'resources', 'views', 'app.edge'),
284+
);
285+
assert.strictEqual(
286+
read(context.tmp, 'resources', 'views', 'nested', 'baz.edge'),
287+
read(buildDirectory, 'resources', 'views', 'nested', 'baz.edge'),
288+
);
289+
});
290+
291+
test('base option: cwd aligns globs outside cwd with explicit paths', async () => {
292+
const root = context.dir;
293+
const cwd = path.join(root, 'cwd');
294+
const src = path.join(root, 'src');
295+
const out = path.join(cwd, 'out');
296+
fs.mkdirSync(cwd, {recursive: true});
297+
fs.mkdirSync(src, {recursive: true});
298+
writeFiles(root, {
299+
'src/root.js': 'root',
300+
'src/nested/inner.js': 'inner',
301+
'src/nested/README.md': 'readme',
302+
});
303+
304+
await cpy(['../src/**/*.js'], 'out', {
305+
cwd,
306+
base: 'cwd',
307+
});
308+
309+
assert.strictEqual(read(src, 'root.js'), read(out, 'src/root.js'));
310+
assert.strictEqual(read(src, 'nested/inner.js'), read(out, 'src/nested/inner.js'));
311+
assert.ok(!fs.existsSync(path.join(out, 'src/nested/README.md')));
312+
});
313+
314+
test('base option: cwd aligns absolute globs outside cwd', async () => {
315+
const root = context.dir;
316+
const cwd = path.join(root, 'cwd');
317+
const src = path.join(root, 'src');
318+
const out = path.join(cwd, 'out');
319+
fs.mkdirSync(cwd, {recursive: true});
320+
fs.mkdirSync(path.join(src, 'nested'), {recursive: true});
321+
fs.writeFileSync(path.join(src, 'nested/inner.js'), 'inner');
322+
323+
const pattern = path.join(src, '**/*.js');
324+
await cpy([pattern], 'out', {
325+
cwd,
326+
base: 'cwd',
327+
});
328+
329+
assert.strictEqual(read(src, 'nested/inner.js'), read(out, 'src/nested/inner.js'));
330+
});
331+
332+
test('base option: cwd aligns outside globs with absolute destination', async () => {
333+
const root = context.dir;
334+
const cwd = path.join(root, 'cwd');
335+
const src = path.join(root, 'src');
336+
const out = path.join(root, 'out');
337+
fs.mkdirSync(cwd, {recursive: true});
338+
fs.mkdirSync(path.join(src, 'nested'), {recursive: true});
339+
fs.writeFileSync(path.join(src, 'nested/inner.js'), 'inner');
340+
341+
await cpy(['../src/**/*.js'], out, {
342+
cwd,
343+
base: 'cwd',
344+
});
345+
346+
assert.strictEqual(read(src, 'nested/inner.js'), read(out, 'src/nested/inner.js'));
347+
});
348+
349+
test('base option: cwd aligns outside globs with symlinked parent', async () => {
350+
const root = context.dir;
351+
const cwd = path.join(root, 'cwd');
352+
const src = path.join(root, 'src');
353+
const alias = path.join(root, 'alias');
354+
const out = path.join(cwd, 'out');
355+
fs.mkdirSync(cwd, {recursive: true});
356+
fs.mkdirSync(path.join(src, 'nested'), {recursive: true});
357+
fs.writeFileSync(path.join(src, 'nested/inner.js'), 'inner');
358+
fs.symlinkSync(src, alias, 'dir');
359+
360+
await cpy(['../alias/**/*.js'], 'out', {
361+
cwd,
362+
base: 'cwd',
363+
});
364+
365+
assert.strictEqual(read(src, 'nested/inner.js'), read(out, 'alias/nested/inner.js'));
366+
});
367+
368+
test('base option: cwd does not escape destination for dynamic absolute globs', {skip: process.platform === 'win32'}, async () => {
369+
const root = context.dir;
370+
const cwd = path.join(root, 'nested', 'cwd');
371+
const source = path.join(root, 'source');
372+
const out = path.join(root, 'dest', 'nested');
373+
fs.mkdirSync(cwd, {recursive: true});
374+
fs.mkdirSync(path.join(source, 'nested'), {recursive: true});
375+
fs.writeFileSync(path.join(source, 'nested', 'file.js'), 'content');
376+
377+
const rootSegments = root.split(path.sep).filter(Boolean);
378+
if (rootSegments.length < 2) {
379+
return;
380+
}
381+
382+
const pattern = path.posix.join('/', '*', ...rootSegments.slice(1), 'source', '**/*.js');
383+
await cpy([pattern], out, {
384+
cwd,
385+
base: 'cwd',
386+
});
387+
388+
assert.strictEqual(read(source, 'nested/file.js'), read(out, 'file.js'));
389+
assert.ok(!fs.existsSync(path.join(root, 'dest', 'source', 'nested', 'file.js')));
390+
});
391+
392+
test('base option: cwd aligns parent directory globs outside cwd', async () => {
393+
const root = context.dir;
394+
const cwd = path.join(root, 'cwd');
395+
const out = path.join(cwd, 'out');
396+
fs.mkdirSync(cwd, {recursive: true});
397+
writeFiles(root, {
398+
'root.js': 'root',
399+
'nested/inner.js': 'inner',
400+
});
401+
402+
await cpy(['../**/*.js'], 'out', {
403+
cwd,
404+
base: 'cwd',
405+
});
406+
407+
const rootName = path.basename(root);
408+
assert.strictEqual(read(root, 'root.js'), read(out, rootName, 'root.js'));
409+
assert.strictEqual(read(root, 'nested/inner.js'), read(out, rootName, 'nested/inner.js'));
410+
});
411+
412+
test('base option: cwd keeps distinct outside glob parents', async () => {
413+
const root = context.dir;
414+
const cwd = path.join(root, 'cwd');
415+
const first = path.join(root, 'first');
416+
const second = path.join(root, 'second');
417+
const out = path.join(cwd, 'out');
418+
fs.mkdirSync(cwd, {recursive: true});
419+
fs.mkdirSync(first, {recursive: true});
420+
fs.mkdirSync(second, {recursive: true});
421+
writeFiles(root, {
422+
'first/index.js': 'first',
423+
'first/nested/shared.js': 'shared-1',
424+
'second/index.js': 'second',
425+
'second/nested/shared.js': 'shared-2',
426+
});
427+
428+
await cpy(['../first/**/*.js', '../second/**/*.js'], 'out', {
429+
cwd,
430+
base: 'cwd',
431+
});
432+
433+
assert.strictEqual(read(first, 'index.js'), read(out, 'first/index.js'));
434+
assert.strictEqual(read(first, 'nested/shared.js'), read(out, 'first/nested/shared.js'));
435+
assert.strictEqual(read(second, 'index.js'), read(out, 'second/index.js'));
436+
assert.strictEqual(read(second, 'nested/shared.js'), read(out, 'second/nested/shared.js'));
437+
});
438+
439+
test('base option: pattern with directory source copies contents without top-level directory', async () => {
440+
writeFiles(context.tmp, {
441+
'source/file.txt': 'content',
442+
'source/nested/inner.txt': 'inner',
443+
});
444+
445+
await cpy(['source'], 'destination', {
446+
cwd: context.tmp,
447+
base: 'pattern',
448+
});
449+
450+
assert.strictEqual(
451+
read(context.tmp, 'source/file.txt'),
452+
read(context.tmp, 'destination/file.txt'),
453+
);
454+
assert.strictEqual(
455+
read(context.tmp, 'source/nested/inner.txt'),
456+
read(context.tmp, 'destination/nested/inner.txt'),
457+
);
458+
assert.ok(!fs.existsSync(path.join(context.tmp, 'destination/source')));
459+
});
460+
241461
test('directory source outside cwd preserves structure under destination', async () => {
242462
const root = temporaryDirectory();
243463
const cwd = path.join(root, 'cwd');

0 commit comments

Comments
 (0)