1- import { mkdir , writeFile } from "node:fs/promises" ;
1+ import { createHash , randomUUID } from "node:crypto" ;
2+ import { link , mkdir , rename , rm , writeFile } from "node:fs/promises" ;
23import { dirname , resolve } from "node:path" ;
34import { join as joinPosix } from "node:path/posix" ;
45
@@ -56,6 +57,8 @@ export class InMemoryReportFiles implements ReportFiles {
5657
5758export class FileSystemReportFiles implements ReportFiles {
5859 readonly #output: string ;
60+ readonly #contentHashToPath = new Map < string , string > ( ) ;
61+ readonly #pathToContentHash = new Map < string , string > ( ) ;
5962
6063 constructor ( output : string ) {
6164 this . #output = resolve ( output ) ;
@@ -64,10 +67,68 @@ export class FileSystemReportFiles implements ReportFiles {
6467 addFile = async ( path : string , data : Buffer ) : Promise < string > => {
6568 const targetPath = resolve ( this . #output, path ) ;
6669 const targetDirPath = dirname ( targetPath ) ;
70+ const contentHash = createHash ( "sha256" ) . update ( data ) . digest ( "hex" ) ;
71+ const targetPathHash = this . #pathToContentHash. get ( targetPath ) ;
72+ const canonicalPath = this . #contentHashToPath. get ( contentHash ) ;
6773
6874 await mkdir ( targetDirPath , { recursive : true } ) ;
69- await writeFile ( targetPath , data , { encoding : "utf-8" } ) ;
75+
76+ if ( targetPathHash === contentHash ) {
77+ return targetPath ;
78+ }
79+
80+ if ( canonicalPath && canonicalPath !== targetPath ) {
81+ try {
82+ await this . #replaceWithHardlink( canonicalPath , targetPath ) ;
83+ this . #pathToContentHash. set ( targetPath , contentHash ) ;
84+ return targetPath ;
85+ } catch ( error ) {
86+ if ( ! this . #isRecoverableHardlinkError( error ) ) {
87+ throw error ;
88+ }
89+ }
90+ }
91+
92+ await this . #replaceWithFile( targetPath , data ) ;
93+ this . #contentHashToPath. set ( contentHash , targetPath ) ;
94+ this . #pathToContentHash. set ( targetPath , contentHash ) ;
7095
7196 return targetPath ;
7297 } ;
98+
99+ #replaceWithFile = async ( targetPath : string , data : Buffer ) : Promise < void > => {
100+ const tempPath = `${ targetPath } .${ randomUUID ( ) } .tmp` ;
101+
102+ try {
103+ await writeFile ( tempPath , data , { encoding : "utf-8" } ) ;
104+ await rename ( tempPath , targetPath ) ;
105+ } finally {
106+ await rm ( tempPath , { force : true } ) ;
107+ }
108+ } ;
109+
110+ #replaceWithHardlink = async ( canonicalPath : string , targetPath : string ) : Promise < void > => {
111+ const tempPath = `${ targetPath } .${ randomUUID ( ) } .tmp` ;
112+
113+ try {
114+ await link ( canonicalPath , tempPath ) ;
115+ await rename ( tempPath , targetPath ) ;
116+ } finally {
117+ await rm ( tempPath , { force : true } ) ;
118+ }
119+ } ;
120+
121+ #isRecoverableHardlinkError = ( error : unknown ) : boolean => {
122+ if ( ! error || typeof error !== "object" || ! ( "code" in error ) ) {
123+ return false ;
124+ }
125+
126+ return (
127+ error . code === "EXDEV" ||
128+ error . code === "EPERM" ||
129+ error . code === "EEXIST" ||
130+ error . code === "ENOENT" ||
131+ error . code === "EACCES"
132+ ) ;
133+ } ;
73134}
0 commit comments