Skip to content

Commit f8c3d86

Browse files
authored
Merge pull request #601 from forcedotcom/sm/file-moves-again
feat: handle file move then edit scenario
2 parents 8754d62 + 78ac637 commit f8c3d86

12 files changed

+466
-770
lines changed

CHANGELOG.md

+249-689
Large diffs are not rendered by default.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"devDependencies": {
6363
"@salesforce/cli-plugins-testkit": "^5.3.8",
6464
"@salesforce/dev-scripts": "^10.1.0",
65+
"@salesforce/schemas": "^1.9.0",
6566
"@types/graceful-fs": "^4.1.9",
6667
"eslint-plugin-sf-plugin": "^1.18.5",
6768
"ts-node": "^10.9.2",

src/shared/guards.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import {
8-
SourceComponent,
98
MetadataMember,
109
FileResponse,
1110
ComponentStatus,
@@ -15,9 +14,6 @@ import {
1514
import { ChangeResult } from './types';
1615
import { ChangeResultWithNameAndType } from './types';
1716

18-
export const sourceComponentGuard = (input: SourceComponent | undefined): input is SourceComponent =>
19-
input instanceof SourceComponent;
20-
2117
export const metadataMemberGuard = (
2218
input: MetadataMember | undefined | Partial<MetadataMember>
2319
): input is MetadataMember =>
@@ -42,3 +38,5 @@ export const FileResponseHasPath = (
4238

4339
export const isChangeResultWithNameAndType = (cr?: ChangeResult): cr is ChangeResultWithNameAndType =>
4440
typeof cr === 'object' && typeof cr.name === 'string' && typeof cr.type === 'string';
41+
42+
export const isDefined = <T>(x: T | undefined): x is T => x !== undefined;

src/shared/local/localShadowRepo.ts

+9-11
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import git from 'isomorphic-git';
1515
import { Performance } from '@oclif/core/performance';
1616
import { RegistryAccess } from '@salesforce/source-deploy-retrieve';
1717
import { chunkArray, excludeLwcLocalOnlyTest, folderContainsPath } from '../functions';
18-
import { filenameMatchesToMap, getMatches } from './moveDetection';
18+
import { filenameMatchesToMap, getLogMessage, getMatches } from './moveDetection';
1919
import { StatusRow } from './types';
2020
import { isDeleted, isAdded, toFilenames } from './functions';
2121

@@ -348,21 +348,19 @@ export class ShadowRepo {
348348
const movedFilesMarker = Performance.mark('@salesforce/source-tracking', 'localShadowRepo.detectMovedFiles');
349349
const matches = await filenameMatchesToMap(IS_WINDOWS)(this.registry)(this.projectPath)(this.gitDir)(matchingFiles);
350350

351-
if (matches.size === 0) return movedFilesMarker?.stop();
351+
if (matches.deleteOnly.size === 0 && matches.fullMatches.size === 0) return movedFilesMarker?.stop();
352352

353-
this.logger.debug(
354-
[
355-
'Files have moved. Committing moved files:',
356-
[...matches.entries()].map(([add, del]) => `- File ${del} was moved to ${add}`).join(os.EOL),
357-
].join(os.EOL)
358-
);
353+
this.logger.debug(getLogMessage(matches));
359354

360-
movedFilesMarker?.addDetails({ filesMoved: matches.size });
355+
movedFilesMarker?.addDetails({
356+
filesMoved: matches.fullMatches.size,
357+
filesMovedAndEdited: matches.deleteOnly.size,
358+
});
361359

362360
// Commit the moved files and refresh the status
363361
await this.commitChanges({
364-
deletedFiles: [...matches.values()],
365-
deployedFiles: [...matches.keys()],
362+
deletedFiles: [...matches.fullMatches.values(), ...matches.deleteOnly.values()],
363+
deployedFiles: [...matches.fullMatches.keys()],
366364
message: 'Committing moved files',
367365
});
368366

src/shared/local/moveDetection.ts

+96-53
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import path from 'node:path';
8+
import { EOL } from 'node:os';
89
import { Logger, Lifecycle } from '@salesforce/core';
910
import {
1011
MetadataResolver,
@@ -16,21 +17,28 @@ import {
1617
import git from 'isomorphic-git';
1718
import * as fs from 'graceful-fs';
1819
import { Performance } from '@oclif/core/performance';
19-
import { sourceComponentGuard } from '../guards';
20+
import { isDefined } from '../guards';
2021
import { isDeleted, isAdded, ensureWindows, toFilenames } from './functions';
2122
import { AddAndDeleteMaps, FilenameBasenameHash, StatusRow, StringMap } from './types';
2223

24+
const JOIN_CHAR = '#__#'; // the __ makes it unlikely to be used in metadata names
2325
type AddAndDeleteFileInfos = { addedInfo: FilenameBasenameHash[]; deletedInfo: FilenameBasenameHash[] };
2426
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+
};
2533

2634
/** composed functions to simplified use by the shadowRepo class */
2735
export const filenameMatchesToMap =
2836
(isWindows: boolean) =>
2937
(registry: RegistryAccess) =>
3038
(projectPath: string) =>
3139
(gitDir: string) =>
32-
async ({ added, deleted }: AddedAndDeletedFilenames): Promise<StringMap> =>
33-
removeNonMatches(isWindows)(registry)(
40+
async ({ added, deleted }: AddedAndDeletedFilenames): Promise<StringMapsForMatches> =>
41+
excludeNonMatchingTypes(isWindows)(registry)(
3442
compareHashes(
3543
await buildMaps(
3644
await toFileInfo({
@@ -73,7 +81,14 @@ export const getMatches = (status: StatusRow[]): AddedAndDeletedFilenames => {
7381
return { added: addedFilenamesWithMatches, deleted: deletedFilenamesWithMatches };
7482
};
7583

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 */
7792
const buildMaps = async ({ addedInfo, deletedInfo }: AddAndDeleteFileInfos): Promise<AddAndDeleteMaps> => {
7893
const [addedMap, addedIgnoredMap] = buildMap(addedInfo);
7994
const [deletedMap, deletedIgnoredMap] = buildMap(deletedInfo);
@@ -96,51 +111,72 @@ const buildMaps = async ({ addedInfo, deletedInfo }: AddAndDeleteFileInfos): Pro
96111
return { addedMap, deletedMap };
97112
};
98113

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+
);
102132

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 };
108144
}
109-
110-
return matches;
145+
return { fullMatches: matches, deleteOnly: new Map<string, string>() };
111146
};
112147

113148
/** given a StringMap, resolve the metadata types and return things that having matching type/parent */
114-
const removeNonMatches =
149+
const excludeNonMatchingTypes =
115150
(isWindows: boolean) =>
116151
(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+
};
142166
};
143167

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+
};
144180
/** enrich the filenames with basename and oid (hash) */
145181
const toFileInfo = async ({
146182
projectPath,
@@ -170,11 +206,12 @@ const toFileInfo = async ({
170206
return { addedInfo, deletedInfo };
171207
};
172208

209+
/** returns a map of <hash+basename, filepath>. If two items result in the same hash+basename, return that in the ignore bucket */
173210
const buildMap = (info: FilenameBasenameHash[]): StringMap[] => {
174211
const map: StringMap = new Map();
175212
const ignore: StringMap = new Map();
176213
info.map((i) => {
177-
const key = `${i.hash}#${i.basename}`;
214+
const key = `${i.hash}${JOIN_CHAR}${i.basename}`;
178215
// If we find a duplicate key, we need to remove it and ignore it in the future.
179216
// Finding duplicate hash#basename means that we cannot accurately determine where it was moved to or from
180217
if (map.has(key) || ignore.has(key)) {
@@ -195,18 +232,20 @@ const getHashForAddedFile =
195232
hash: (await git.hashBlob({ object: await fs.promises.readFile(path.join(projectPath, filepath)) })).oid,
196233
});
197234

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);
210249

211250
/** where we don't have git objects to use, read the file contents to generate the hash */
212251
const getHashFromActualFileContents =
@@ -218,3 +257,7 @@ const getHashFromActualFileContents =
218257
basename: path.basename(filepath),
219258
hash: (await git.readBlob({ fs, dir: projectPath, gitdir, filepath, oid })).oid,
220259
});
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;

src/shared/localComponentSetArray.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
DestructiveChangesType,
1515
RegistryAccess,
1616
} from '@salesforce/source-deploy-retrieve';
17-
import { sourceComponentGuard } from './guards';
17+
import { isDefined } from './guards';
1818
import { supportsPartialDelete, pathIsInFolder } from './functions';
1919

2020
type GroupedFileInput = {
@@ -83,7 +83,7 @@ export const getComponentSets = ({
8383

8484
grouping.deletes
8585
.flatMap((filename) => resolverForDeletes.getComponentsFromPath(filename))
86-
.filter(sourceComponentGuard)
86+
.filter(isDefined)
8787
.map((component) => {
8888
// if the component supports partial delete AND there are files that are not deleted,
8989
// set the component for deploy, not for delete.
@@ -92,7 +92,7 @@ export const getComponentSets = ({
9292
try {
9393
resolverForNonDeletes
9494
.getComponentsFromPath(resolve(component.content))
95-
.filter(sourceComponentGuard)
95+
.filter(isDefined)
9696
.map((nonDeletedComponent) => componentSet.add(nonDeletedComponent));
9797
} catch (e) {
9898
logger.warn(
@@ -113,7 +113,7 @@ export const getComponentSets = ({
113113
return undefined;
114114
}
115115
})
116-
.filter(sourceComponentGuard)
116+
.filter(isDefined)
117117
.map((component) => componentSet.add(component));
118118
// there may have been ignored files, but componentSet.add doesn't automatically track them.
119119
// We'll manually set the ignored paths from what the resolver has been tracking

src/shared/populateTypesAndNames.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
RegistryAccess,
1414
} from '@salesforce/source-deploy-retrieve';
1515
import { ChangeResult } from './types';
16-
import { isChangeResultWithNameAndType, sourceComponentGuard } from './guards';
16+
import { isChangeResultWithNameAndType, isDefined } from './guards';
1717
import {
1818
ensureRelative,
1919
excludeLwcLocalOnlyTest,
@@ -68,7 +68,7 @@ export const populateTypesAndNames =
6868
return undefined;
6969
}
7070
})
71-
.filter(sourceComponentGuard);
71+
.filter(isDefined);
7272

7373
logger.debug(` matching SourceComponents have ${sourceComponents.length} items from local`);
7474

src/sourceTracking.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ import {
4646
FileResponseIsDeleted,
4747
FileResponseIsNotDeleted,
4848
isChangeResultWithNameAndType,
49+
isDefined,
4950
isSdrSuccess,
50-
sourceComponentGuard,
5151
} from './shared/guards';
5252
import { removeIgnored } from './shared/remoteChangeIgnoring';
5353
import {
@@ -309,7 +309,7 @@ export class SourceTracking extends AsyncCreatable {
309309
return undefined;
310310
}
311311
})
312-
.filter(sourceComponentGuard);
312+
.filter(isDefined);
313313
}
314314
}
315315

0 commit comments

Comments
 (0)