Skip to content

Commit 7ba53ae

Browse files
committed
feat: handle the move-then-modify scenario
1 parent da3448a commit 7ba53ae

File tree

3 files changed

+190
-59
lines changed

3 files changed

+190
-59
lines changed

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';
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

+90-48
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,
@@ -15,22 +16,29 @@ import {
1516
// @ts-expect-error isogit has both ESM and CJS exports but node16 module/resolution identifies it as ESM
1617
import git from 'isomorphic-git';
1718
import * as fs from 'graceful-fs';
18-
import { Performance } from '@oclif/core';
19+
import { Performance } from '@oclif/core/performance';
1920
import { sourceComponentGuard } 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,70 @@ 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 => {
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 => {
101119
const matches: StringMap = new Map();
102120

103-
for (const [addedKey, addedValue] of addedMap) {
121+
[...addedMap.entries()].map(([addedKey, addedValue]) => {
104122
const deletedValue = deletedMap.get(addedKey);
105123
if (deletedValue) {
124+
// these are an exact basename and hash match
106125
matches.set(addedValue, deletedValue);
126+
deletedMap.delete(addedKey);
127+
addedMap.delete(addedKey);
107128
}
108-
}
129+
});
109130

110-
return matches;
131+
if (addedMap.size && deletedMap.size) {
132+
// the remaining deletes didn't match the basename+hash of an add, and vice versa.
133+
// They *might* match the basename of an add, in which case we *could* have the "move, then edit" case.
134+
const addedBasenameMap = new Map([...addedMap.entries()].map(hashEntryToBasenameEntry));
135+
const deletedBasenameMap = new Map([...deletedMap.entries()].map(hashEntryToBasenameEntry));
136+
const deleteOnly = new Map<string, string>(
137+
Array.from(deletedBasenameMap.entries())
138+
.filter(([k]) => addedBasenameMap.has(k))
139+
.map(([k, v]) => [addedBasenameMap.get(k) as string, v])
140+
);
141+
return { fullMatches: matches, deleteOnly };
142+
}
143+
return { fullMatches: matches, deleteOnly: new Map<string, string>() };
111144
};
112145

113146
/** given a StringMap, resolve the metadata types and return things that having matching type/parent */
114-
const removeNonMatches =
147+
const excludeNonMatchingTypes =
115148
(isWindows: boolean) =>
116149
(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-
);
150+
({ fullMatches: matches, deleteOnly }: StringMapsForMatches): StringMapsForMatches => {
151+
if (!matches.size && !deleteOnly.size) return { fullMatches: matches, deleteOnly };
152+
const [resolvedAdded, resolvedDeleted] = [
153+
[...matches.keys(), ...deleteOnly.keys()], // the keys/values are only used for the resolver, so we use 1 for both add and delete
154+
[...matches.values(), ...deleteOnly.values()],
155+
]
156+
.map((filenames) => filenames.map(isWindows ? ensureWindows : stringNoOp))
157+
.map((filenames) => new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(filenames)))
158+
.map(resolveType);
159+
160+
return {
161+
fullMatches: new Map([...matches.entries()].filter(typeFilter(isWindows)(resolvedAdded, resolvedDeleted))),
162+
deleteOnly: new Map([...deleteOnly.entries()].filter(typeFilter(isWindows)(resolvedAdded, resolvedDeleted))),
163+
};
142164
};
143165

166+
const typeFilter =
167+
(isWindows: boolean) =>
168+
(resolveAdd: ReturnType<typeof resolveType>, resolveDelete: ReturnType<typeof resolveType>) =>
169+
([added, deleted]: [string, string]): boolean => {
170+
const [resolvedAdded] = resolveAdd(isWindows ? [ensureWindows(added)] : [added]);
171+
const [resolvedDeleted] = resolveDelete(isWindows ? [ensureWindows(deleted)] : [deleted]);
172+
return (
173+
resolvedAdded?.type.name === resolvedDeleted?.type.name &&
174+
resolvedAdded?.parent?.name === resolvedDeleted?.parent?.name &&
175+
resolvedAdded?.parent?.type.name === resolvedDeleted?.parent?.type.name
176+
);
177+
};
144178
/** enrich the filenames with basename and oid (hash) */
145179
const toFileInfo = async ({
146180
projectPath,
@@ -170,11 +204,12 @@ const toFileInfo = async ({
170204
return { addedInfo, deletedInfo };
171205
};
172206

207+
/** returns a map of <hash+basename, filepath>. If two items result in the same hash+basename, return that in the ignore bucket */
173208
const buildMap = (info: FilenameBasenameHash[]): StringMap[] => {
174209
const map: StringMap = new Map();
175210
const ignore: StringMap = new Map();
176211
info.map((i) => {
177-
const key = `${i.hash}#${i.basename}`;
212+
const key = `${i.hash}${JOIN_CHAR}${i.basename}`;
178213
// If we find a duplicate key, we need to remove it and ignore it in the future.
179214
// Finding duplicate hash#basename means that we cannot accurately determine where it was moved to or from
180215
if (map.has(key) || ignore.has(key)) {
@@ -195,18 +230,20 @@ const getHashForAddedFile =
195230
hash: (await git.hashBlob({ object: await fs.promises.readFile(path.join(projectPath, filepath)) })).oid,
196231
});
197232

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);
233+
const resolveType =
234+
(resolver: MetadataResolver) =>
235+
(filenames: string[]): SourceComponent[] =>
236+
filenames
237+
.flatMap((filename) => {
238+
try {
239+
return resolver.getComponentsFromPath(filename);
240+
} catch (e) {
241+
const logger = Logger.childFromRoot('ShadowRepo.compareTypes');
242+
logger.warn(`unable to resolve ${filename}`);
243+
return undefined;
244+
}
245+
})
246+
.filter(sourceComponentGuard);
210247

211248
/** where we don't have git objects to use, read the file contents to generate the hash */
212249
const getHashFromActualFileContents =
@@ -218,3 +255,8 @@ const getHashFromActualFileContents =
218255
basename: path.basename(filepath),
219256
hash: (await git.readBlob({ fs, dir: projectPath, gitdir, filepath, oid })).oid,
220257
});
258+
259+
const hashEntryToBasenameEntry = ([k, v]: [string, string]): [string, string] => [hashToBasename(k), v];
260+
const hashToBasename = (hash: string): string => hash.split(JOIN_CHAR)[1];
261+
262+
const stringNoOp = (s: string): string => s;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) 2020, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import * as path from 'node:path';
9+
import { TestSession } from '@salesforce/cli-plugins-testkit';
10+
import { expect } from 'chai';
11+
import * as fs from 'graceful-fs';
12+
import { RegistryAccess } from '@salesforce/source-deploy-retrieve';
13+
import { ProjectJson } from '@salesforce/schemas';
14+
import { NamedPackageDir, PackageDir } from '@salesforce/core';
15+
import { ShadowRepo } from '../../../src/shared/local/localShadowRepo';
16+
17+
describe('handles local files moves that also change the file', () => {
18+
let session: TestSession;
19+
let repo: ShadowRepo;
20+
let modifiedPackageDirs: PackageDir[];
21+
const NOT_PROJECT_DIR = 'not-project-dir';
22+
before(async () => {
23+
session = await TestSession.create({
24+
project: {
25+
sourceDir: path.join('test', 'nuts', 'ebikes-lwc'),
26+
},
27+
devhubAuthStrategy: 'NONE',
28+
});
29+
// create the other dir
30+
const notProjectDir = path.join(session.project.dir, NOT_PROJECT_DIR, 'main', 'default', 'classes');
31+
await fs.promises.mkdir(notProjectDir, { recursive: true });
32+
33+
// modify the project json to include the new dir
34+
const sfdxProjectJsonPath = path.join(session.project.dir, 'sfdx-project.json');
35+
const originalProject = JSON.parse(await fs.promises.readFile(sfdxProjectJsonPath, 'utf8')) as ProjectJson;
36+
modifiedPackageDirs = [...originalProject.packageDirectories, { path: NOT_PROJECT_DIR }];
37+
await fs.promises.writeFile(
38+
sfdxProjectJsonPath,
39+
JSON.stringify({
40+
...originalProject,
41+
packageDirectories: modifiedPackageDirs,
42+
} satisfies ProjectJson)
43+
);
44+
});
45+
46+
after(async () => {
47+
// await session?.clean();
48+
});
49+
50+
it('initialize the local tracking', async () => {
51+
expect(typeof process.env.SF_BETA_TRACK_FILE_MOVES).to.equal('undefined');
52+
process.env.SF_BETA_TRACK_FILE_MOVES = 'true';
53+
54+
repo = await ShadowRepo.getInstance({
55+
orgId: 'fakeOrgId',
56+
projectPath: session.project.dir,
57+
packageDirs: modifiedPackageDirs.map(
58+
(pd): NamedPackageDir => ({ ...pd, name: NOT_PROJECT_DIR, fullPath: path.join(session.project.dir, pd.path) })
59+
),
60+
registry: new RegistryAccess(),
61+
});
62+
63+
// Commit the existing status
64+
const filesToSync = await repo.getChangedFilenames();
65+
await repo.commitChanges({ deployedFiles: filesToSync });
66+
67+
expect(await repo.getChangedFilenames()).to.have.lengthOf(0);
68+
});
69+
70+
it('move a file and edit it. Only the delete is committed', async () => {
71+
// move all two classes to the new folder
72+
const classFolder = path.join('main', 'default', 'classes');
73+
['OrderController.cls', 'OrderController.cls-meta.xml', 'PagedResult.cls', 'PagedResult.cls-meta.xml'].map((f) =>
74+
fs.renameSync(
75+
path.join(session.project.dir, 'force-app', classFolder, f),
76+
path.join(session.project.dir, NOT_PROJECT_DIR, classFolder, f)
77+
)
78+
);
79+
const editedFilePath = path.join(NOT_PROJECT_DIR, classFolder, 'OrderController.cls');
80+
// edit the contents of OrderController.cls
81+
fs.appendFileSync(path.join(session.project.dir, editedFilePath), '//comment');
82+
await repo.getStatus(true);
83+
84+
// all the deletes were committed
85+
expect(await repo.getDeleteFilenames()).to.deep.equal([]);
86+
// this is still considered an "add" because the moved file was changed
87+
expect(await repo.getAddFilenames()).to.deep.equal([editedFilePath]);
88+
89+
delete process.env.SF_BETA_TRACK_FILE_MOVES;
90+
});
91+
});

0 commit comments

Comments
 (0)