@@ -18,6 +18,7 @@ const defaultOptions = {
1818 ignoreJunk : true ,
1919 flat : false ,
2020 cwd : process . cwd ( ) ,
21+ update : false ,
2122} ;
2223
2324class 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*/
259260const 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 , {
0 commit comments