Skip to content

Commit 499d11d

Browse files
committed
Add update option
1 parent 3b98caf commit 499d11d

5 files changed

Lines changed: 405 additions & 50 deletions

File tree

index.d.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ export type Entry = {
3838
readonly extension: string;
3939
};
4040

41+
export type FilterContext = {
42+
/**
43+
Resolved destination path for the file.
44+
45+
@example '/tmp/dir/foo.js'
46+
*/
47+
readonly destinationPath: string;
48+
};
49+
4150
export type RenameFile = {
4251
/**
4352
Resolved path to the file.
@@ -105,6 +114,15 @@ export type Options = {
105114
*/
106115
readonly flat?: boolean;
107116

117+
/**
118+
Only overwrite when the source is newer, or when sizes differ with the same modification time.
119+
120+
Ignored when `overwrite` is false.
121+
122+
@default false
123+
*/
124+
readonly update?: boolean;
125+
108126
/**
109127
Filename or function used to rename every file in `source`. Use a two-argument function to receive a frozen source file object and a mutable destination file object. The destination path must stay within the original destination directory. The legacy single-argument form is deprecated, emits a warning, and will be removed in the next major release.
110128
@@ -144,7 +162,7 @@ export type Options = {
144162
/**
145163
Function to filter files to copy.
146164
147-
Receives a source file object as the first argument.
165+
Receives a source file object and a context object with the resolved destination path.
148166
149167
Return true to include, false to exclude. You can also return a Promise that resolves to true or false.
150168
@@ -153,11 +171,11 @@ export type Options = {
153171
import cpy from 'cpy';
154172
155173
await cpy('foo', 'destination', {
156-
filter: file => file.extension !== 'nocopy'
174+
filter: (file, {destinationPath}) => file.extension !== 'nocopy'
157175
});
158176
```
159177
*/
160-
readonly filter?: (file: Entry) => boolean | Promise<boolean>;
178+
readonly filter?: (file: Entry, context: FilterContext) => boolean | Promise<boolean>;
161179

162180
/**
163181
The given function is called whenever there is measurable progress.

index.js

Lines changed: 172 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const defaultOptions = {
1818
ignoreJunk: true,
1919
flat: false,
2020
cwd: process.cwd(),
21+
update: false,
2122
};
2223

2324
class Entry {
@@ -252,9 +253,9 @@ const expandPatternsWithBraceExpansion = patterns => patterns.flatMap(pattern =>
252253
));
253254

254255
/**
255-
@param {string} from - Source path
256-
@param {string} to - Destination path
257-
@returns {boolean}
256+
@param {string} filePath
257+
@param {string} cwd
258+
@returns {string}
258259
*/
259260
const resolveCopyPath = (filePath, cwd) => path.isAbsolute(filePath)
260261
? path.normalize(filePath)
@@ -360,18 +361,10 @@ const computeToForNonGlob = ({entry, destination, options}) => {
360361
return path.join(baseDestination, path.basename(originalDir), path.relative(originalDir, entry.path));
361362
}
362363

363-
if (!entry.pattern.isDirectory && entry.path === entry.relativePath) {
364-
return path.join(baseDestination, path.basename(entry.pattern.originalPath), path.relative(entry.pattern.originalPath, entry.path));
365-
}
366-
367364
if (!entry.pattern.isDirectory && options.flat) {
368365
return path.join(baseDestination, path.basename(entry.pattern.originalPath));
369366
}
370367

371-
if (!entry.pattern.isDirectory && !insideCwd) {
372-
return path.join(baseDestination, entry.name);
373-
}
374-
375368
if (relativeToCwd === undefined) {
376369
return path.join(baseDestination, entry.name);
377370
}
@@ -454,19 +447,25 @@ export default function cpy(
454447
...defaultOptions,
455448
...options,
456449
};
450+
if (options.update !== undefined && typeof options.update !== 'boolean') {
451+
throw new TypeError('`update` must be a boolean');
452+
}
453+
457454
if (typeof options.cwd !== 'string') {
458455
throw new TypeError('`cwd` must be a string');
459456
}
460457

461458
options.cwd = path.resolve(options.cwd);
462459

460+
const shouldUseUpdate = options.update === true && options.overwrite !== false;
461+
463462
/**
464463
@param {GlobPattern[]} patterns
465464
@param {string[]} ignorePatterns
466465
@returns {Promise<Entry[]>}
467466
*/
468467
const getEntries = async (patterns, ignorePatterns) => {
469-
let entries = [];
468+
const entries = [];
470469

471470
for (const pattern of patterns) {
472471
/**
@@ -490,13 +489,6 @@ export default function cpy(
490489
}
491490
}
492491

493-
if (options.filter !== undefined) {
494-
entries = await pFilter(entries, options.filter, {
495-
concurrency: 1024,
496-
signal: options.signal,
497-
});
498-
}
499-
500492
return entries;
501493
};
502494

@@ -539,6 +531,159 @@ export default function cpy(
539531

540532
entries = await getEntries(patterns, ignore);
541533

534+
const destinationPathByEntry = new Map();
535+
const resolveDestinationPaths = entry => {
536+
const cachedPaths = destinationPathByEntry.get(entry);
537+
if (cachedPaths) {
538+
return cachedPaths;
539+
}
540+
541+
let destinationPath = preprocessDestinationPath({
542+
entry,
543+
destination,
544+
options,
545+
});
546+
547+
// Apply rename after computing the base destination path
548+
destinationPath = renameFile({
549+
source: entry.path,
550+
destination: destinationPath,
551+
rename: options.rename,
552+
});
553+
554+
// Check for self-copy after rename has been applied
555+
if (isSelfCopy(entry.path, destinationPath, options.cwd)) {
556+
throw new CpyError(`Refusing to copy to itself: \`${entry.path}\``);
557+
}
558+
559+
const resolvedDestinationPath = resolveCopyPath(destinationPath, options.cwd);
560+
const paths = {destinationPath, resolvedDestinationPath};
561+
destinationPathByEntry.set(entry, paths);
562+
return paths;
563+
};
564+
565+
const createFilterContext = entry => ({
566+
get destinationPath() {
567+
return resolveDestinationPaths(entry).resolvedDestinationPath;
568+
},
569+
});
570+
571+
if (options.filter !== undefined) {
572+
const filterFunction = options.filter;
573+
entries = await pFilter(entries, entry => filterFunction(entry, createFilterContext(entry)), {
574+
concurrency: 1024,
575+
signal: options.signal,
576+
});
577+
}
578+
579+
if (shouldUseUpdate && entries.length > 0) {
580+
const createUpdateError = (entry, destinationPath, error) => {
581+
const reason = error?.message ?? String(error);
582+
return new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${destinationPath}\`: ${reason}`, {cause: error});
583+
};
584+
585+
const entryDataList = entries.map((entry, index) => {
586+
const {destinationPath, resolvedDestinationPath} = resolveDestinationPaths(entry);
587+
return {
588+
entry,
589+
destinationPath,
590+
resolvedDestinationPath,
591+
index,
592+
};
593+
});
594+
595+
const entryByDestinationPath = new Map();
596+
for (const entryData of entryDataList) {
597+
if (!entryByDestinationPath.has(entryData.resolvedDestinationPath)) {
598+
entryByDestinationPath.set(entryData.resolvedDestinationPath, entryData.entry);
599+
}
600+
}
601+
602+
const getDestinationState = async destinationPath => {
603+
const entry = entryByDestinationPath.get(destinationPath);
604+
605+
let stats;
606+
try {
607+
stats = await fs.stat(destinationPath);
608+
} catch (error) {
609+
if (error.code === 'ENOENT') {
610+
return {exists: false};
611+
}
612+
613+
throw createUpdateError(entry, destinationPath, error);
614+
}
615+
616+
if (!stats.isFile()) {
617+
return {nonFile: true};
618+
}
619+
620+
return {
621+
exists: true,
622+
mtimeMs: stats.mtimeMs,
623+
size: stats.size,
624+
};
625+
};
626+
627+
const destinationPaths = [...new Set(entryDataList.map(({resolvedDestinationPath}) => resolvedDestinationPath))];
628+
const destinationStates = await Promise.all(destinationPaths.map(async destinationPath => [destinationPath, await getDestinationState(destinationPath)]));
629+
const destinationStateByPath = new Map(destinationStates);
630+
631+
const entriesWithStats = await Promise.all(entryDataList.map(async entryData => {
632+
let sourceStats;
633+
try {
634+
sourceStats = await fs.stat(entryData.entry.path);
635+
} catch (error) {
636+
throw createUpdateError(entryData.entry, entryData.destinationPath, error);
637+
}
638+
639+
return {...entryData, sourceStats};
640+
}));
641+
642+
const bestCandidateByDestination = new Map();
643+
const registerNonFileCandidate = entryData => {
644+
if (!bestCandidateByDestination.has(entryData.resolvedDestinationPath)) {
645+
bestCandidateByDestination.set(entryData.resolvedDestinationPath, {
646+
entry: entryData.entry,
647+
index: entryData.index,
648+
mtimeMs: Number.NEGATIVE_INFINITY,
649+
});
650+
}
651+
};
652+
653+
for (const entryData of entriesWithStats) {
654+
const destinationState = destinationStateByPath.get(entryData.resolvedDestinationPath);
655+
if (destinationState?.nonFile) {
656+
registerNonFileCandidate(entryData);
657+
continue;
658+
}
659+
660+
const shouldCopy = !destinationState?.exists
661+
|| entryData.sourceStats.mtimeMs > destinationState.mtimeMs
662+
|| (entryData.sourceStats.mtimeMs === destinationState.mtimeMs && entryData.sourceStats.size !== destinationState.size);
663+
664+
if (!shouldCopy) {
665+
continue;
666+
}
667+
668+
const existingCandidate = bestCandidateByDestination.get(entryData.resolvedDestinationPath);
669+
const isNewerCandidate = !existingCandidate
670+
|| entryData.sourceStats.mtimeMs > existingCandidate.mtimeMs
671+
|| (entryData.sourceStats.mtimeMs === existingCandidate.mtimeMs && entryData.index > existingCandidate.index);
672+
if (!isNewerCandidate) {
673+
continue;
674+
}
675+
676+
bestCandidateByDestination.set(entryData.resolvedDestinationPath, {
677+
entry: entryData.entry,
678+
index: entryData.index,
679+
mtimeMs: entryData.sourceStats.mtimeMs,
680+
});
681+
}
682+
683+
const selectedEntries = new Set([...bestCandidateByDestination.values()].map(candidate => candidate.entry));
684+
entries = entries.filter(entry => selectedEntries.has(entry));
685+
}
686+
542687
options.signal?.throwIfAborted();
543688

544689
if (entries.length === 0) {
@@ -601,48 +746,31 @@ export default function cpy(
601746
};
602747

603748
const copyFileMapper = async entry => {
604-
let to = preprocessDestinationPath({
605-
entry,
606-
destination,
607-
options,
608-
});
609-
610-
// Apply rename after computing the base destination path
611-
to = renameFile({
612-
source: entry.path,
613-
destination: to,
614-
rename: options.rename,
615-
});
616-
617-
// Check for self-copy after rename has been applied
618-
if (isSelfCopy(entry.path, to, options.cwd)) {
619-
throw new CpyError(`Refusing to copy to itself: \`${entry.path}\``);
620-
}
749+
const {destinationPath, resolvedDestinationPath} = resolveDestinationPaths(entry);
621750

622751
if (options.dryRun) {
623752
completedFiles++;
624-
const resolvedDestination = resolveCopyPath(to, options.cwd);
625753
const progressData = {
626754
totalFiles: entries.length,
627755
percent: completedFiles / entries.length,
628756
completedFiles,
629757
completedSize,
630758
sourcePath: entry.path,
631-
destinationPath: resolvedDestination,
759+
destinationPath: resolvedDestinationPath,
632760
};
633761

634762
if (options.onProgress) {
635763
options.onProgress(progressData);
636764
}
637765

638766
progressEmitter.emit('progress', progressData);
639-
return resolvedDestination;
767+
return resolvedDestinationPath;
640768
}
641769

642-
const statusKey = `${entry.path}\0${to}`;
770+
const statusKey = `${entry.path}\0${destinationPath}`;
643771

644772
try {
645-
await copyFile(entry.path, to, {
773+
await copyFile(entry.path, destinationPath, {
646774
...options,
647775
onProgress(event) {
648776
fileProgressHandler(statusKey, event);
@@ -652,14 +780,14 @@ export default function cpy(
652780
if (options.preserveTimestamps) {
653781
options.signal?.throwIfAborted();
654782
const stats = await fs.stat(entry.path);
655-
await fs.utimes(to, stats.atime, stats.mtime);
783+
await fs.utimes(destinationPath, stats.atime, stats.mtime);
656784
}
657785
} catch (error) {
658786
options.signal?.throwIfAborted();
659-
throw new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${to}\`: ${error.message}`, {cause: error});
787+
throw new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${destinationPath}\`: ${error.message}`, {cause: error});
660788
}
661789

662-
return to;
790+
return destinationPath;
663791
};
664792

665793
return pMap(entries, copyFileMapper, {

index.test-d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,19 @@ expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {ba
2222
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {base: 'cwd'}));
2323
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {flat: true}));
2424
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {overwrite: false}));
25+
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {update: true}));
2526
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {concurrency: 2}));
2627

2728
expectType<Promise<string[]> & ProgressEmitter>(cpy('foo.js', 'destination', {
28-
filter(file) {
29+
filter(file, {destinationPath}) {
2930
expectType<Entry>(file);
3031

3132
expectType<string>(file.path);
3233
expectType<string>(file.relativePath);
3334
expectType<string>(file.name);
3435
expectType<string>(file.nameWithoutExtension);
3536
expectType<string>(file.extension);
37+
expectType<string>(destinationPath);
3638
return true;
3739
},
3840
}));

0 commit comments

Comments
 (0)