5
5
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
6
*/
7
7
import path from 'node:path' ;
8
+ import { EOL } from 'node:os' ;
8
9
import { Logger , Lifecycle } from '@salesforce/core' ;
9
10
import {
10
11
MetadataResolver ,
@@ -16,21 +17,28 @@ import {
16
17
import git from 'isomorphic-git' ;
17
18
import * as fs from 'graceful-fs' ;
18
19
import { Performance } from '@oclif/core/performance' ;
19
- import { sourceComponentGuard } from '../guards' ;
20
+ import { isDefined } from '../guards' ;
20
21
import { isDeleted , isAdded , ensureWindows , toFilenames } from './functions' ;
21
22
import { AddAndDeleteMaps , FilenameBasenameHash , StatusRow , StringMap } from './types' ;
22
23
24
+ const JOIN_CHAR = '#__#' ; // the __ makes it unlikely to be used in metadata names
23
25
type AddAndDeleteFileInfos = { addedInfo : FilenameBasenameHash [ ] ; deletedInfo : FilenameBasenameHash [ ] } ;
24
26
type AddedAndDeletedFilenames = { added : Set < string > ; deleted : Set < string > } ;
27
+ type StringMapsForMatches = {
28
+ /** these matches filename=>basename, metadata type/name, and git object hash */
29
+ fullMatches : StringMap ;
30
+ /** these did not match the hash. They *probably* are matches where the "add" is also modified */
31
+ deleteOnly : StringMap ;
32
+ } ;
25
33
26
34
/** composed functions to simplified use by the shadowRepo class */
27
35
export const filenameMatchesToMap =
28
36
( isWindows : boolean ) =>
29
37
( registry : RegistryAccess ) =>
30
38
( projectPath : string ) =>
31
39
( gitDir : string ) =>
32
- async ( { added, deleted } : AddedAndDeletedFilenames ) : Promise < StringMap > =>
33
- removeNonMatches ( isWindows ) ( registry ) (
40
+ async ( { added, deleted } : AddedAndDeletedFilenames ) : Promise < StringMapsForMatches > =>
41
+ excludeNonMatchingTypes ( isWindows ) ( registry ) (
34
42
compareHashes (
35
43
await buildMaps (
36
44
await toFileInfo ( {
@@ -73,7 +81,14 @@ export const getMatches = (status: StatusRow[]): AddedAndDeletedFilenames => {
73
81
return { added : addedFilenamesWithMatches , deleted : deletedFilenamesWithMatches } ;
74
82
} ;
75
83
76
- /** build maps of the add/deletes with filenames, returning the matches Logs if non-matches */
84
+ export const getLogMessage = ( matches : StringMapsForMatches ) : string =>
85
+ [
86
+ 'Files have moved. Committing moved files:' ,
87
+ ...[ ...matches . fullMatches . entries ( ) ] . map ( ( [ add , del ] ) => `- File ${ del } was moved to ${ add } ` ) ,
88
+ ...[ ...matches . deleteOnly . entries ( ) ] . map ( ( [ add , del ] ) => `- File ${ del } was moved to ${ add } and modified` ) ,
89
+ ] . join ( EOL ) ;
90
+
91
+ /** build maps of the add/deletes with filenames, returning the matches Logs if we can't make a match because buildMap puts them in the ignored bucket */
77
92
const buildMaps = async ( { addedInfo, deletedInfo } : AddAndDeleteFileInfos ) : Promise < AddAndDeleteMaps > => {
78
93
const [ addedMap , addedIgnoredMap ] = buildMap ( addedInfo ) ;
79
94
const [ deletedMap , deletedIgnoredMap ] = buildMap ( deletedInfo ) ;
@@ -96,51 +111,72 @@ const buildMaps = async ({ addedInfo, deletedInfo }: AddAndDeleteFileInfos): Pro
96
111
return { addedMap, deletedMap } ;
97
112
} ;
98
113
99
- /** builds a map of the values from both maps */
100
- const compareHashes = ( { addedMap, deletedMap } : AddAndDeleteMaps ) : StringMap => {
101
- const matches : StringMap = new Map ( ) ;
114
+ /**
115
+ * builds a map of the values from both maps
116
+ * side effect: mutates the passed-in maps!
117
+ */
118
+ const compareHashes = ( { addedMap, deletedMap } : AddAndDeleteMaps ) : StringMapsForMatches => {
119
+ const matches = new Map < string , string > (
120
+ [ ...addedMap . entries ( ) ]
121
+ . map ( ( [ addedKey , addedValue ] ) => {
122
+ const deletedValue = deletedMap . get ( addedKey ) ;
123
+ if ( deletedValue ) {
124
+ // these are an exact basename and hash match
125
+ deletedMap . delete ( addedKey ) ;
126
+ addedMap . delete ( addedKey ) ;
127
+ return [ addedValue , deletedValue ] as const ;
128
+ }
129
+ } )
130
+ . filter ( isDefined )
131
+ ) ;
102
132
103
- for ( const [ addedKey , addedValue ] of addedMap ) {
104
- const deletedValue = deletedMap . get ( addedKey ) ;
105
- if ( deletedValue ) {
106
- matches . set ( addedValue , deletedValue ) ;
107
- }
133
+ if ( addedMap . size && deletedMap . size ) {
134
+ // the remaining deletes didn't match the basename+hash of an add, and vice versa.
135
+ // They *might* match the basename of an add, in which case we *could* have the "move, then edit" case.
136
+ const addedBasenameMap = new Map ( [ ...addedMap . entries ( ) ] . map ( hashEntryToBasenameEntry ) ) ;
137
+ const deletedBasenameMap = new Map ( [ ...deletedMap . entries ( ) ] . map ( hashEntryToBasenameEntry ) ) ;
138
+ const deleteOnly = new Map < string , string > (
139
+ Array . from ( deletedBasenameMap . entries ( ) )
140
+ . filter ( ( [ k ] ) => addedBasenameMap . has ( k ) )
141
+ . map ( ( [ k , v ] ) => [ addedBasenameMap . get ( k ) as string , v ] )
142
+ ) ;
143
+ return { fullMatches : matches , deleteOnly } ;
108
144
}
109
-
110
- return matches ;
145
+ return { fullMatches : matches , deleteOnly : new Map < string , string > ( ) } ;
111
146
} ;
112
147
113
148
/** given a StringMap, resolve the metadata types and return things that having matching type/parent */
114
- const removeNonMatches =
149
+ const excludeNonMatchingTypes =
115
150
( isWindows : boolean ) =>
116
151
( registry : RegistryAccess ) =>
117
- ( matches : StringMap ) : StringMap => {
118
- if ( ! matches . size ) return matches ;
119
- const addedFiles = isWindows ? [ ...matches . keys ( ) ] . map ( ensureWindows ) : [ ...matches . keys ( ) ] ;
120
- const deletedFiles = isWindows ? [ ...matches . values ( ) ] . map ( ensureWindows ) : [ ...matches . values ( ) ] ;
121
- const resolverAdded = new MetadataResolver ( registry , VirtualTreeContainer . fromFilePaths ( addedFiles ) ) ;
122
- const resolverDeleted = new MetadataResolver ( registry , VirtualTreeContainer . fromFilePaths ( deletedFiles ) ) ;
123
-
124
- return new Map (
125
- [ ...matches . entries ( ) ] . filter ( ( [ addedFile , deletedFile ] ) => {
126
- // we're only ever using the first element of the arrays
127
- const [ resolvedAdded ] = resolveType ( resolverAdded , isWindows ? [ ensureWindows ( addedFile ) ] : [ addedFile ] ) ;
128
- const [ resolvedDeleted ] = resolveType (
129
- resolverDeleted ,
130
- isWindows ? [ ensureWindows ( deletedFile ) ] : [ deletedFile ]
131
- ) ;
132
- return (
133
- // they could match, or could both be undefined (because unresolved by SDR)
134
- resolvedAdded ?. type . name === resolvedDeleted ?. type . name &&
135
- // parent names match, if resolved and there are parents
136
- resolvedAdded ?. parent ?. name === resolvedDeleted ?. parent ?. name &&
137
- // parent types match, if resolved and there are parents
138
- resolvedAdded ?. parent ?. type . name === resolvedDeleted ?. parent ?. type . name
139
- ) ;
140
- } )
141
- ) ;
152
+ ( { fullMatches : matches , deleteOnly } : StringMapsForMatches ) : StringMapsForMatches => {
153
+ if ( ! matches . size && ! deleteOnly . size ) return { fullMatches : matches , deleteOnly } ;
154
+ const [ resolvedAdded , resolvedDeleted ] = [
155
+ [ ...matches . keys ( ) , ...deleteOnly . keys ( ) ] , // the keys/values are only used for the resolver, so we use 1 for both add and delete
156
+ [ ...matches . values ( ) , ...deleteOnly . values ( ) ] ,
157
+ ]
158
+ . map ( ( filenames ) => filenames . map ( isWindows ? ensureWindows : stringNoOp ) )
159
+ . map ( ( filenames ) => new MetadataResolver ( registry , VirtualTreeContainer . fromFilePaths ( filenames ) ) )
160
+ . map ( resolveType ) ;
161
+
162
+ return {
163
+ fullMatches : new Map ( [ ...matches . entries ( ) ] . filter ( typeFilter ( isWindows ) ( resolvedAdded , resolvedDeleted ) ) ) ,
164
+ deleteOnly : new Map ( [ ...deleteOnly . entries ( ) ] . filter ( typeFilter ( isWindows ) ( resolvedAdded , resolvedDeleted ) ) ) ,
165
+ } ;
142
166
} ;
143
167
168
+ const typeFilter =
169
+ ( isWindows : boolean ) =>
170
+ ( resolveAdd : ReturnType < typeof resolveType > , resolveDelete : ReturnType < typeof resolveType > ) =>
171
+ ( [ added , deleted ] : [ string , string ] ) : boolean => {
172
+ const [ resolvedAdded ] = resolveAdd ( isWindows ? [ ensureWindows ( added ) ] : [ added ] ) ;
173
+ const [ resolvedDeleted ] = resolveDelete ( isWindows ? [ ensureWindows ( deleted ) ] : [ deleted ] ) ;
174
+ return (
175
+ resolvedAdded ?. type . name === resolvedDeleted ?. type . name &&
176
+ resolvedAdded ?. parent ?. name === resolvedDeleted ?. parent ?. name &&
177
+ resolvedAdded ?. parent ?. type . name === resolvedDeleted ?. parent ?. type . name
178
+ ) ;
179
+ } ;
144
180
/** enrich the filenames with basename and oid (hash) */
145
181
const toFileInfo = async ( {
146
182
projectPath,
@@ -170,11 +206,12 @@ const toFileInfo = async ({
170
206
return { addedInfo, deletedInfo } ;
171
207
} ;
172
208
209
+ /** returns a map of <hash+basename, filepath>. If two items result in the same hash+basename, return that in the ignore bucket */
173
210
const buildMap = ( info : FilenameBasenameHash [ ] ) : StringMap [ ] => {
174
211
const map : StringMap = new Map ( ) ;
175
212
const ignore : StringMap = new Map ( ) ;
176
213
info . map ( ( i ) => {
177
- const key = `${ i . hash } # ${ i . basename } ` ;
214
+ const key = `${ i . hash } ${ JOIN_CHAR } ${ i . basename } ` ;
178
215
// If we find a duplicate key, we need to remove it and ignore it in the future.
179
216
// Finding duplicate hash#basename means that we cannot accurately determine where it was moved to or from
180
217
if ( map . has ( key ) || ignore . has ( key ) ) {
@@ -195,18 +232,20 @@ const getHashForAddedFile =
195
232
hash : ( await git . hashBlob ( { object : await fs . promises . readFile ( path . join ( projectPath , filepath ) ) } ) ) . oid ,
196
233
} ) ;
197
234
198
- const resolveType = ( resolver : MetadataResolver , filenames : string [ ] ) : SourceComponent [ ] =>
199
- filenames
200
- . flatMap ( ( filename ) => {
201
- try {
202
- return resolver . getComponentsFromPath ( filename ) ;
203
- } catch ( e ) {
204
- const logger = Logger . childFromRoot ( 'ShadowRepo.compareTypes' ) ;
205
- logger . warn ( `unable to resolve ${ filename } ` ) ;
206
- return undefined ;
207
- }
208
- } )
209
- . filter ( sourceComponentGuard ) ;
235
+ const resolveType =
236
+ ( resolver : MetadataResolver ) =>
237
+ ( filenames : string [ ] ) : SourceComponent [ ] =>
238
+ filenames
239
+ . flatMap ( ( filename ) => {
240
+ try {
241
+ return resolver . getComponentsFromPath ( filename ) ;
242
+ } catch ( e ) {
243
+ const logger = Logger . childFromRoot ( 'ShadowRepo.compareTypes' ) ;
244
+ logger . warn ( `unable to resolve ${ filename } ` ) ;
245
+ return undefined ;
246
+ }
247
+ } )
248
+ . filter ( isDefined ) ;
210
249
211
250
/** where we don't have git objects to use, read the file contents to generate the hash */
212
251
const getHashFromActualFileContents =
@@ -218,3 +257,7 @@ const getHashFromActualFileContents =
218
257
basename : path . basename ( filepath ) ,
219
258
hash : ( await git . readBlob ( { fs, dir : projectPath , gitdir, filepath, oid } ) ) . oid ,
220
259
} ) ;
260
+
261
+ const hashEntryToBasenameEntry = ( [ k , v ] : [ string , string ] ) : [ string , string ] => [ hashToBasename ( k ) , v ] ;
262
+ const hashToBasename = ( hash : string ) : string => hash . split ( JOIN_CHAR ) [ 1 ] ;
263
+ const stringNoOp = ( s : string ) : string => s ;
0 commit comments