From 5dc227bd46e7a5a5d2e47901261435d30027f86b Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 23 May 2024 11:37:26 -0500 Subject: [PATCH 1/8] feat(wip): move scale issue --- src/shared/localShadowRepo.ts | 78 +++++++++---------- .../local/localTrackingFileMovesScale.nut.ts | 7 ++ 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/shared/localShadowRepo.ts b/src/shared/localShadowRepo.ts index 0ed52ed7..b4ff3389 100644 --- a/src/shared/localShadowRepo.ts +++ b/src/shared/localShadowRepo.ts @@ -69,32 +69,36 @@ type CommitRequest = { needsUpdatedStatus?: boolean; }; +const IS_WINDOWS = os.type() === 'Windows_NT'; + +/** do not try to add more than this many files at a time through isogit. You'll hit EMFILE: too many open files even with graceful-fs */ + +const MAX_FILE_ADD = env.getNumber( + 'SF_SOURCE_TRACKING_BATCH_SIZE', + env.getNumber('SFDX_SOURCE_TRACKING_BATCH_SIZE', IS_WINDOWS ? 8000 : 15_000) +); export class ShadowRepo { private static instanceMap = new Map(); public gitDir: string; public projectPath: string; - private packageDirs!: NamedPackageDir[]; + /** + * packageDirs converted to project-relative posix style paths + * iso-git uses relative, posix paths + * but packageDirs has already resolved / normalized them + * so we need to make them project-relative again and convert if windows + */ + private packageDirs: string[]; private status!: StatusRow[]; private logger!: Logger; - private readonly isWindows: boolean; private readonly registry: RegistryAccess; - /** do not try to add more than this many files at a time through isogit. You'll hit EMFILE: too many open files even with graceful-fs */ - private readonly maxFileAdd: number; - private constructor(options: ShadowRepoOptions) { this.gitDir = getGitDir(options.orgId, options.projectPath); this.projectPath = options.projectPath; - this.packageDirs = options.packageDirs; - this.isWindows = os.type() === 'Windows_NT'; + this.packageDirs = options.packageDirs.map(packageDirToRelativePosixPath(options.projectPath)); this.registry = options.registry; - - this.maxFileAdd = env.getNumber( - 'SF_SOURCE_TRACKING_BATCH_SIZE', - env.getNumber('SFDX_SOURCE_TRACKING_BATCH_SIZE', this.isWindows ? 8000 : 15_000) - ); } // think of singleton behavior but unique to the projectPath @@ -157,10 +161,6 @@ export class ShadowRepo { if (!this.status || noCache) { const marker = Performance.mark('@salesforce/source-tracking', 'localShadowRepo.getStatus#withoutCache'); - // iso-git uses relative, posix paths - // but packageDirs has already resolved / normalized them - // so we need to make them project-relative again and convert if windows - const pkgDirs = this.packageDirs.map(packageDirToRelativePosixPath(this.isWindows)(this.projectPath)); try { // status hasn't been initialized yet @@ -168,17 +168,9 @@ export class ShadowRepo { fs, dir: this.projectPath, gitdir: this.gitDir, - filepaths: pkgDirs, + filepaths: this.packageDirs, ignored: true, - filter: (f) => - // no hidden files - !f.includes(`${path.sep}.`) && - // no lwc tests - excludeLwcLocalOnlyTest(f) && - // no gitignore files - !f.endsWith('.gitignore') && - // isogit uses `startsWith` for filepaths so it's possible to get a false positive - pkgDirs.some(folderContainsPath(f)), + filter: fileFilter(this.packageDirs), }); // Check for moved files and update local git status accordingly @@ -194,7 +186,7 @@ export class ShadowRepo { redirectToCliRepoError(e); } // isomorphic-git stores things in unix-style tree. Convert to windows-style if necessary - if (this.isWindows) { + if (IS_WINDOWS) { this.status = this.status.map((row) => [path.normalize(row[FILE]), row[HEAD], row[WORKDIR], row[3]]); } marker?.stop(); @@ -286,8 +278,8 @@ export class ShadowRepo { if (deployedFiles.length) { const chunks = chunkArray( // these are stored in posix/style/path format. We have to convert inbound stuff from windows - [...new Set(this.isWindows ? deployedFiles.map(normalize).map(ensurePosix) : deployedFiles)], - this.maxFileAdd + [...new Set(IS_WINDOWS ? deployedFiles.map(normalize).map(ensurePosix) : deployedFiles)], + MAX_FILE_ADD ); for (const chunk of chunks) { try { @@ -310,7 +302,7 @@ export class ShadowRepo { data: e.errors.map((err) => err.message), cause: e, actions: [ - `One potential reason you're getting this error is that the number of files that source tracking is batching exceeds your user-specific file limits. Increase your hard file limit in the same session by executing 'ulimit -Hn ${this.maxFileAdd}'. Or set the 'SFDX_SOURCE_TRACKING_BATCH_SIZE' environment variable to a value lower than the output of 'ulimit -Hn'.\nNote: Don't set this environment variable too close to the upper limit or your system will still hit it. If you continue to get the error, lower the value of the environment variable even more.`, + `One potential reason you're getting this error is that the number of files that source tracking is batching exceeds your user-specific file limits. Increase your hard file limit in the same session by executing 'ulimit -Hn ${MAX_FILE_ADD}'. Or set the 'SFDX_SOURCE_TRACKING_BATCH_SIZE' environment variable to a value lower than the output of 'ulimit -Hn'.\nNote: Don't set this environment variable too close to the upper limit or your system will still hit it. If you continue to get the error, lower the value of the environment variable even more.`, ], }); } @@ -325,9 +317,7 @@ export class ShadowRepo { const deleteMarker = Performance.mark('@salesforce/source-tracking', 'localShadowRepo.commitChanges#delete', { deletedFiles: deletedFiles.length, }); - for (const filepath of [ - ...new Set(this.isWindows ? deletedFiles.map(normalize).map(ensurePosix) : deletedFiles), - ]) { + for (const filepath of [...new Set(IS_WINDOWS ? deletedFiles.map(normalize).map(ensurePosix) : deletedFiles)]) { try { // these need to be done sequentially because isogit manages file locking. Isogit remove does not support multiple files at once // eslint-disable-next-line no-await-in-loop @@ -384,7 +374,7 @@ export class ShadowRepo { gitdir: this.gitDir, trees: [targetTree], map: async (filename, [tree]) => - filenameSet.has(filename) && (await tree?.type()) === 'blob' + fileFilter(this.packageDirs)(filename) && filenameSet.has(filename) && (await tree?.type()) === 'blob' ? { filename, hash: await tree?.oid(), @@ -399,14 +389,13 @@ export class ShadowRepo { getInfo(git.WORKDIR(), new Set(addedFilenamesWithMatches)), getInfo(git.TREE({ ref: 'HEAD' }), new Set(deletedFilenamesWithMatches)), ]); - getInfoMarker?.stop(); const matchingNameAndHashes = compareHashes(await buildMaps(addedInfo, deletedInfo)); if (matchingNameAndHashes.size === 0) { return movedFilesMarker?.stop(); } - const matches = removeNonMatches(matchingNameAndHashes, this.registry, this.isWindows); + const matches = removeNonMatches(matchingNameAndHashes, this.registry, IS_WINDOWS); if (matches.size === 0) { return movedFilesMarker?.stop(); @@ -433,10 +422,9 @@ export class ShadowRepo { } const packageDirToRelativePosixPath = - (isWindows: boolean) => (projectPath: string) => (packageDir: NamedPackageDir): string => - isWindows + IS_WINDOWS ? ensurePosix(path.relative(projectPath, packageDir.fullPath)) : path.relative(projectPath, packageDir.fullPath); @@ -447,7 +435,7 @@ const ensurePosix = (filepath: string): string => filepath.split(path.sep).join( const buildMap = (info: FileInfo[]): StringMap[] => { const map: StringMap = new Map(); const ignore: StringMap = new Map(); - info.forEach((i) => { + info.map((i) => { const key = `${i.hash}#${i.basename}`; // If we find a duplicate key, we need to remove it and ignore it in the future. // Finding duplicate hash#basename means that we cannot accurately determine where it was moved to or from @@ -566,3 +554,15 @@ const removeNonMatches = (matches: StringMap, registry: RegistryAccess, isWindow }) ); }; + +const fileFilter = + (packageDirs: string[]) => + (f: string): boolean => + // no hidden files + !f.includes(`${path.sep}.`) && + // no lwc tests + excludeLwcLocalOnlyTest(f) && + // no gitignore files + !f.endsWith('.gitignore') && + // isogit uses `startsWith` for filepaths so it's possible to get a false positive + packageDirs.some(folderContainsPath(f)); diff --git a/test/nuts/local/localTrackingFileMovesScale.nut.ts b/test/nuts/local/localTrackingFileMovesScale.nut.ts index f8e34f53..0c889b41 100644 --- a/test/nuts/local/localTrackingFileMovesScale.nut.ts +++ b/test/nuts/local/localTrackingFileMovesScale.nut.ts @@ -18,6 +18,8 @@ const dirCount = 20; const classesPerDir = 50; const classCount = dirCount * classesPerDir; +const nonProjDirFiles = 100_000; + describe(`handles local files moves of ${classCount.toLocaleString()} classes (${( classCount * 2 ).toLocaleString()} files across ${dirCount.toLocaleString()} folders)`, () => { @@ -32,6 +34,11 @@ describe(`handles local files moves of ${classCount.toLocaleString()} classes ($ }, devhubAuthStrategy: 'NONE', }); + const notProjectDir = path.join(session.project.dir, 'not-project-dir'); + await fs.promises.mkdir(notProjectDir); + for (let i = 0; i < nonProjDirFiles; i++) { + fs.writeFileSync(path.join(notProjectDir, `file${i}.txt`), 'hello'); + } // create some number of files const classdir = path.join(session.project.dir, 'force-app', 'main', 'default', 'classes'); for (let d = 0; d < dirCount; d++) { From ece4d58bfd354eb5f081e6602905e03169931f77 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 23 May 2024 16:34:36 -0500 Subject: [PATCH 2/8] feat: don't walk entire project --- src/shared/local/functions.ts | 18 ++ src/shared/{ => local}/localShadowRepo.ts | 195 +++--------------- src/shared/local/moveDetection.ts | 150 ++++++++++++++ src/shared/local/types.ts | 11 + src/sourceTracking.ts | 2 +- test/nuts/local/commitPerf.nut.ts | 2 +- ...calTrackingFileMovesDecomposedChild.nut.ts | 2 +- .../local/localTrackingFileMovesImage.nut.ts | 2 +- .../local/localTrackingFileMovesScale.nut.ts | 2 +- test/nuts/local/localTrackingScale.nut.ts | 2 +- test/nuts/local/localTrackingScenario.nut.ts | 2 +- test/nuts/local/nonTopLevelIgnore.nut.ts | 2 +- test/nuts/local/pkgDirMatching.nut.ts | 2 +- test/nuts/local/relativePkgDirs.nut.ts | 2 +- test/unit/localDetectMovedFiles.test.ts | 2 +- test/unit/localShadowRepo.test.ts | 76 +------ 16 files changed, 219 insertions(+), 253 deletions(-) create mode 100644 src/shared/local/functions.ts rename src/shared/{ => local}/localShadowRepo.ts (65%) create mode 100644 src/shared/local/moveDetection.ts create mode 100644 src/shared/local/types.ts diff --git a/src/shared/local/functions.ts b/src/shared/local/functions.ts new file mode 100644 index 00000000..0a039c54 --- /dev/null +++ b/src/shared/local/functions.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import path from 'node:path'; +import { WORKDIR, HEAD } from './localShadowRepo'; +import { FILE } from './localShadowRepo'; + +import { StatusRow } from './types'; + +// filenames were normalized when read from isogit + +export const toFilenames = (rows: StatusRow[]): string[] => rows.map((row) => row[FILE]); +export const isDeleted = (status: StatusRow): boolean => status[WORKDIR] === 0; +export const isAdded = (status: StatusRow): boolean => status[HEAD] === 0 && status[WORKDIR] === 2; +export const ensureWindows = (filepath: string): string => path.win32.normalize(filepath); diff --git a/src/shared/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts similarity index 65% rename from src/shared/localShadowRepo.ts rename to src/shared/local/localShadowRepo.ts index b4ff3389..b45026c8 100644 --- a/src/shared/localShadowRepo.ts +++ b/src/shared/local/localShadowRepo.ts @@ -11,24 +11,21 @@ import * as fs from 'graceful-fs'; import { NamedPackageDir, Lifecycle, Logger, SfError } from '@salesforce/core'; import { env } from '@salesforce/kit'; // @ts-expect-error isogit has both ESM and CJS exports but node16 module/resolution identifies it as ESM -import git, { Walker } from 'isomorphic-git'; +import git from 'isomorphic-git'; import { Performance } from '@oclif/core'; -import { - RegistryAccess, - MetadataResolver, - VirtualTreeContainer, - SourceComponent, -} from '@salesforce/source-deploy-retrieve'; -import { chunkArray, excludeLwcLocalOnlyTest, folderContainsPath } from './functions'; -import { sourceComponentGuard } from './guards'; +import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; +import { chunkArray, excludeLwcLocalOnlyTest, folderContainsPath } from '../functions'; +import { getHash, getMatches } from './moveDetection'; +import { FileInfo, StatusRow } from './types'; +import { toFilenames } from './functions'; +import { isDeleted, isAdded } from './functions'; +import { compareHashes, buildMaps } from './moveDetection'; +import { removeNonMatches } from './moveDetection'; /** returns the full path to where we store the shadow repo */ const getGitDir = (orgId: string, projectPath: string): string => path.join(projectPath, '.sf', 'orgs', orgId, 'localSourceTracking'); -// filenames were normalized when read from isogit -const toFilenames = (rows: StatusRow[]): string[] => rows.map((row) => row[FILE]); - // catch isogit's `InternalError` to avoid people report CLI issues in isogit repo. // See: https://github.com/forcedotcom/cli/issues/2416 const redirectToCliRepoError = (e: unknown): never => { @@ -42,10 +39,6 @@ const redirectToCliRepoError = (e: unknown): never => { throw e; }; -type FileInfo = { filename: string; hash: string; basename: string }; -type StringMap = Map; -type AddAndDeleteMaps = { addedMap: StringMap; deletedMap: StringMap }; - type ShadowRepoOptions = { orgId: string; projectPath: string; @@ -53,13 +46,10 @@ type ShadowRepoOptions = { registry: RegistryAccess; }; -// https://isomorphic-git.org/docs/en/statusMatrix#docsNav -type StatusRow = [file: string, head: number, workdir: number, stage: number]; - // array members for status results -const FILE = 0; -const HEAD = 1; -const WORKDIR = 2; +export const FILE = 0; +export const HEAD = 1; +export const WORKDIR = 2; // We don't use STAGE (StatusRow[3]). Changes are added and committed in one step type CommitRequest = { @@ -77,6 +67,7 @@ const MAX_FILE_ADD = env.getNumber( 'SF_SOURCE_TRACKING_BATCH_SIZE', env.getNumber('SFDX_SOURCE_TRACKING_BATCH_SIZE', IS_WINDOWS ? 8000 : 15_000) ); + export class ShadowRepo { private static instanceMap = new Map(); @@ -354,6 +345,7 @@ export class ShadowRepo { } private async detectMovedFiles(): Promise { + const commonGitOptions = { fs, dir: this.projectPath, gitdir: this.gitDir }; const { addedFilenamesWithMatches, deletedFilenamesWithMatches } = getMatches(await this.getStatus()) ?? {}; if (!addedFilenamesWithMatches || !deletedFilenamesWithMatches) return; @@ -361,33 +353,26 @@ export class ShadowRepo { // Track how long it takes to gather the oid information from the git trees const getInfoMarker = Performance.mark('@salesforce/source-tracking', 'localShadowRepo.detectMovedFiles#getInfo', { - addedFiles: addedFilenamesWithMatches.length, - deletedFiles: deletedFilenamesWithMatches.length, + addedFiles: addedFilenamesWithMatches.size, + deletedFiles: deletedFilenamesWithMatches.size, }); - const getInfo = async (targetTree: Walker, filenameSet: Set): Promise => - // Unable to properly type the return value of git.walk without using "as", ignoring linter - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - git.walk({ - fs, - dir: this.projectPath, - gitdir: this.gitDir, - trees: [targetTree], - map: async (filename, [tree]) => - fileFilter(this.packageDirs)(filename) && filenameSet.has(filename) && (await tree?.type()) === 'blob' - ? { - filename, - hash: await tree?.oid(), - basename: path.basename(filename), - } - : undefined, - }); + const deleteHashGetter = getHash(this.gitDir)(this.projectPath)( + await git.resolveRef({ ...commonGitOptions, ref: 'HEAD' }) + ); + + const addHashGetter = async (filepath: string): Promise => ({ + filename: filepath, + basename: path.basename(filepath), + hash: (await git.hashBlob({ object: await fs.promises.readFile(path.join(this.projectPath, filepath), 'utf8') })) + .oid, + }); // We found file adds and deletes with the same basename // The have likely been moved, confirm by comparing their hashes (oids) const [addedInfo, deletedInfo] = await Promise.all([ - getInfo(git.WORKDIR(), new Set(addedFilenamesWithMatches)), - getInfo(git.TREE({ ref: 'HEAD' }), new Set(deletedFilenamesWithMatches)), + await Promise.all(Array.from(addedFilenamesWithMatches).map(addHashGetter)), + await Promise.all(Array.from(deletedFilenamesWithMatches).map(deleteHashGetter)), ]); getInfoMarker?.stop(); @@ -429,132 +414,8 @@ const packageDirToRelativePosixPath = : path.relative(projectPath, packageDir.fullPath); const normalize = (filepath: string): string => path.normalize(filepath); -const ensureWindows = (filepath: string): string => path.win32.normalize(filepath); const ensurePosix = (filepath: string): string => filepath.split(path.sep).join(path.posix.sep); -const buildMap = (info: FileInfo[]): StringMap[] => { - const map: StringMap = new Map(); - const ignore: StringMap = new Map(); - info.map((i) => { - const key = `${i.hash}#${i.basename}`; - // If we find a duplicate key, we need to remove it and ignore it in the future. - // Finding duplicate hash#basename means that we cannot accurately determine where it was moved to or from - if (map.has(key) || ignore.has(key)) { - map.delete(key); - ignore.set(key, i.filename); - } else { - map.set(key, i.filename); - } - }); - return [map, ignore]; -}; - -/** compare delete and adds from git.status, matching basenames of the files. returns early when there's nothing to match */ -const getMatches = ( - status: StatusRow[] -): { deletedFilenamesWithMatches: string[]; addedFilenamesWithMatches: string[] } | undefined => { - // We check for moved files in incremental steps and exit as early as we can to avoid any performance degradation - // Deleted files will be more rare than added files, so we'll check them first and exit early if there are none - const deletedFiles = status.filter(isDeleted); - if (!deletedFiles.length) return; - - const addedFiles = status.filter(isAdded); - if (!addedFiles.length) return; - - // Both arrays have contents, look for matching basenames - const addedFilenames = toFilenames(addedFiles); - const deletedFilenames = toFilenames(deletedFiles); - - // Build Sets of basenames for added and deleted files for quick lookups - const addedBasenames = new Set(addedFilenames.map((filename) => path.basename(filename))); - const deletedBasenames = new Set(deletedFilenames.map((filename) => path.basename(filename))); - - // Again, we filter over the deleted files first and exit early if there are no filename matches - const deletedFilenamesWithMatches = deletedFilenames.filter((f) => addedBasenames.has(path.basename(f))); - if (!deletedFilenamesWithMatches.length) return; - - const addedFilenamesWithMatches = addedFilenames.filter((f) => deletedBasenames.has(path.basename(f))); - if (!addedFilenamesWithMatches.length) return; - - return { addedFilenamesWithMatches, deletedFilenamesWithMatches }; -}; - -const isDeleted = (status: StatusRow): boolean => status[WORKDIR] === 0; -const isAdded = (status: StatusRow): boolean => status[HEAD] === 0 && status[WORKDIR] === 2; - -/** build maps of the add/deletes with filenames, returning the matches Logs if non-matches */ -const buildMaps = async (addedInfo: FileInfo[], deletedInfo: FileInfo[]): Promise => { - const [addedMap, addedIgnoredMap] = buildMap(addedInfo); - const [deletedMap, deletedIgnoredMap] = buildMap(deletedInfo); - - // If we detected any files that have the same basename and hash, emit a warning and send telemetry - // These files will still show up as expected in the `sf project deploy preview` output - // We could add more logic to determine and display filepaths that we ignored... - // but this is likely rare enough to not warrant the added complexity - // Telemetry will help us determine how often this occurs - if (addedIgnoredMap.size || deletedIgnoredMap.size) { - const message = 'Files were found that have the same basename and hash. Skipping the commit of these files'; - const logger = Logger.childFromRoot('ShadowRepo.compareHashes'); - logger.warn(message); - const lifecycle = Lifecycle.getInstance(); - await Promise.all([ - lifecycle.emitWarning(message), - lifecycle.emitTelemetry({ eventName: 'moveFileHashBasenameCollisionsDetected' }), - ]); - } - return { addedMap, deletedMap }; -}; - -/** builds a map of the values from both maps */ -const compareHashes = ({ addedMap, deletedMap }: AddAndDeleteMaps): StringMap => { - const matches: StringMap = new Map(); - - for (const [addedKey, addedValue] of addedMap) { - const deletedValue = deletedMap.get(addedKey); - if (deletedValue) { - matches.set(addedValue, deletedValue); - } - } - - return matches; -}; - -const resolveType = (resolver: MetadataResolver, filenames: string[]): SourceComponent[] => - filenames - .flatMap((filename) => { - try { - return resolver.getComponentsFromPath(filename); - } catch (e) { - const logger = Logger.childFromRoot('ShadowRepo.compareTypes'); - logger.warn(`unable to resolve ${filename}`); - return undefined; - } - }) - .filter(sourceComponentGuard); - -const removeNonMatches = (matches: StringMap, registry: RegistryAccess, isWindows: boolean): StringMap => { - const addedFiles = isWindows ? [...matches.keys()].map(ensureWindows) : [...matches.keys()]; - const deletedFiles = isWindows ? [...matches.values()].map(ensureWindows) : [...matches.values()]; - const resolverAdded = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(addedFiles)); - const resolverDeleted = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(deletedFiles)); - - return new Map( - [...matches.entries()].filter(([addedFile, deletedFile]) => { - // we're only ever using the first element of the arrays - const [resolvedAdded] = resolveType(resolverAdded, isWindows ? [ensureWindows(addedFile)] : [addedFile]); - const [resolvedDeleted] = resolveType(resolverDeleted, isWindows ? [ensureWindows(deletedFile)] : [deletedFile]); - return ( - // they could match, or could both be undefined (because unresolved by SDR) - resolvedAdded?.type.name === resolvedDeleted?.type.name && - // parent names match, if resolved and there are parents - resolvedAdded?.parent?.name === resolvedDeleted?.parent?.name && - // parent types match, if resolved and there are parents - resolvedAdded?.parent?.type.name === resolvedDeleted?.parent?.type.name - ); - }) - ); -}; - const fileFilter = (packageDirs: string[]) => (f: string): boolean => diff --git a/src/shared/local/moveDetection.ts b/src/shared/local/moveDetection.ts new file mode 100644 index 00000000..1bb797d6 --- /dev/null +++ b/src/shared/local/moveDetection.ts @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import path from 'node:path'; +import { Logger, Lifecycle } from '@salesforce/core'; +import { + MetadataResolver, + SourceComponent, + RegistryAccess, + VirtualTreeContainer, +} from '@salesforce/source-deploy-retrieve'; +// @ts-expect-error isogit has both ESM and CJS exports but node16 module/resolution identifies it as ESM +import git from 'isomorphic-git'; +import * as fs from 'graceful-fs'; +import { sourceComponentGuard } from '../guards'; +import { isDeleted, isAdded, ensureWindows, toFilenames } from './functions'; +import { AddAndDeleteMaps, FileInfo, StatusRow, StringMap } from './types'; + +/** compare delete and adds from git.status, matching basenames of the files. returns early when there's nothing to match */ +export const getMatches = ( + status: StatusRow[] +): { deletedFilenamesWithMatches: Set; addedFilenamesWithMatches: Set } | undefined => { + // We check for moved files in incremental steps and exit as early as we can to avoid any performance degradation + // Deleted files will be more rare than added files, so we'll check them first and exit early if there are none + const deletedFiles = status.filter(isDeleted); + if (!deletedFiles.length) return; + + const addedFiles = status.filter(isAdded); + if (!addedFiles.length) return; + + // Both arrays have contents, look for matching basenames + const addedFilenames = toFilenames(addedFiles); + const deletedFilenames = toFilenames(deletedFiles); + + // Build Sets of basenames for added and deleted files for quick lookups + const addedBasenames = new Set(addedFilenames.map((filename) => path.basename(filename))); + const deletedBasenames = new Set(deletedFilenames.map((filename) => path.basename(filename))); + + // Again, we filter over the deleted files first and exit early if there are no filename matches + const deletedFilenamesWithMatches = new Set(deletedFilenames.filter((f) => addedBasenames.has(path.basename(f)))); + if (!deletedFilenamesWithMatches.size) return; + + const addedFilenamesWithMatches = new Set(addedFilenames.filter((f) => deletedBasenames.has(path.basename(f)))); + if (!addedFilenamesWithMatches.size) return; + + return { addedFilenamesWithMatches, deletedFilenamesWithMatches }; +}; + +export const getHash = + (gitdir: string) => + (projectPath: string) => + (oid: string) => + async (filepath: string): Promise => ({ + filename: filepath, + basename: path.basename(filepath), + hash: (await git.readBlob({ fs, dir: projectPath, gitdir, filepath, oid })).oid, + }); + +/** build maps of the add/deletes with filenames, returning the matches Logs if non-matches */ +export const buildMaps = async (addedInfo: FileInfo[], deletedInfo: FileInfo[]): Promise => { + const [addedMap, addedIgnoredMap] = buildMap(addedInfo); + const [deletedMap, deletedIgnoredMap] = buildMap(deletedInfo); + + // If we detected any files that have the same basename and hash, emit a warning and send telemetry + // These files will still show up as expected in the `sf project deploy preview` output + // We could add more logic to determine and display filepaths that we ignored... + // but this is likely rare enough to not warrant the added complexity + // Telemetry will help us determine how often this occurs + if (addedIgnoredMap.size || deletedIgnoredMap.size) { + const message = 'Files were found that have the same basename and hash. Skipping the commit of these files'; + const logger = Logger.childFromRoot('ShadowRepo.compareHashes'); + logger.warn(message); + const lifecycle = Lifecycle.getInstance(); + await Promise.all([ + lifecycle.emitWarning(message), + lifecycle.emitTelemetry({ eventName: 'moveFileHashBasenameCollisionsDetected' }), + ]); + } + return { addedMap, deletedMap }; +}; + +/** builds a map of the values from both maps */ +export const compareHashes = ({ addedMap, deletedMap }: AddAndDeleteMaps): StringMap => { + const matches: StringMap = new Map(); + + for (const [addedKey, addedValue] of addedMap) { + const deletedValue = deletedMap.get(addedKey); + if (deletedValue) { + matches.set(addedValue, deletedValue); + } + } + + return matches; +}; + +export const buildMap = (info: FileInfo[]): StringMap[] => { + const map: StringMap = new Map(); + const ignore: StringMap = new Map(); + info.map((i) => { + const key = `${i.hash}#${i.basename}`; + // If we find a duplicate key, we need to remove it and ignore it in the future. + // Finding duplicate hash#basename means that we cannot accurately determine where it was moved to or from + if (map.has(key) || ignore.has(key)) { + map.delete(key); + ignore.set(key, i.filename); + } else { + map.set(key, i.filename); + } + }); + return [map, ignore]; +}; + +export const removeNonMatches = (matches: StringMap, registry: RegistryAccess, isWindows: boolean): StringMap => { + const addedFiles = isWindows ? [...matches.keys()].map(ensureWindows) : [...matches.keys()]; + const deletedFiles = isWindows ? [...matches.values()].map(ensureWindows) : [...matches.values()]; + const resolverAdded = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(addedFiles)); + const resolverDeleted = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(deletedFiles)); + + return new Map( + [...matches.entries()].filter(([addedFile, deletedFile]) => { + // we're only ever using the first element of the arrays + const [resolvedAdded] = resolveType(resolverAdded, isWindows ? [ensureWindows(addedFile)] : [addedFile]); + const [resolvedDeleted] = resolveType(resolverDeleted, isWindows ? [ensureWindows(deletedFile)] : [deletedFile]); + return ( + // they could match, or could both be undefined (because unresolved by SDR) + resolvedAdded?.type.name === resolvedDeleted?.type.name && + // parent names match, if resolved and there are parents + resolvedAdded?.parent?.name === resolvedDeleted?.parent?.name && + // parent types match, if resolved and there are parents + resolvedAdded?.parent?.type.name === resolvedDeleted?.parent?.type.name + ); + }) + ); +}; + +const resolveType = (resolver: MetadataResolver, filenames: string[]): SourceComponent[] => + filenames + .flatMap((filename) => { + try { + return resolver.getComponentsFromPath(filename); + } catch (e) { + const logger = Logger.childFromRoot('ShadowRepo.compareTypes'); + logger.warn(`unable to resolve ${filename}`); + return undefined; + } + }) + .filter(sourceComponentGuard); diff --git a/src/shared/local/types.ts b/src/shared/local/types.ts new file mode 100644 index 00000000..c1e15cff --- /dev/null +++ b/src/shared/local/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +export type FileInfo = { filename: string; hash: string; basename: string }; +export type StringMap = Map; +export type AddAndDeleteMaps = { addedMap: StringMap; deletedMap: StringMap }; // https://isomorphic-git.org/docs/en/statusMatrix#docsNav + +export type StatusRow = [file: string, head: number, workdir: number, stage: number]; diff --git a/src/sourceTracking.ts b/src/sourceTracking.ts index 9198001f..30484a05 100644 --- a/src/sourceTracking.ts +++ b/src/sourceTracking.ts @@ -30,7 +30,7 @@ import { import { filePathsFromMetadataComponent } from '@salesforce/source-deploy-retrieve/lib/src/utils/filePathGenerator'; import { Performance } from '@oclif/core'; import { RemoteSourceTrackingService, remoteChangeElementToChangeResult } from './shared/remoteSourceTrackingService'; -import { ShadowRepo } from './shared/localShadowRepo'; +import { ShadowRepo } from './shared/local/localShadowRepo'; import { throwIfConflicts, findConflictsInComponentSet, getDedupedConflictsFromChanges } from './shared/conflicts'; import { RemoteSyncInput, diff --git a/test/nuts/local/commitPerf.nut.ts b/test/nuts/local/commitPerf.nut.ts index 337fa14b..4dcd32ef 100644 --- a/test/nuts/local/commitPerf.nut.ts +++ b/test/nuts/local/commitPerf.nut.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; describe('perf testing for big commits', () => { let session: TestSession; diff --git a/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts b/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts index c9aad857..72367f6e 100644 --- a/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts +++ b/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts @@ -10,7 +10,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import * as fs from 'graceful-fs'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; /* eslint-disable no-unused-expressions */ diff --git a/test/nuts/local/localTrackingFileMovesImage.nut.ts b/test/nuts/local/localTrackingFileMovesImage.nut.ts index fa18edf0..80e5f12b 100644 --- a/test/nuts/local/localTrackingFileMovesImage.nut.ts +++ b/test/nuts/local/localTrackingFileMovesImage.nut.ts @@ -10,7 +10,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import * as fs from 'graceful-fs'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; /* eslint-disable no-unused-expressions */ diff --git a/test/nuts/local/localTrackingFileMovesScale.nut.ts b/test/nuts/local/localTrackingFileMovesScale.nut.ts index 0c889b41..5a5f1458 100644 --- a/test/nuts/local/localTrackingFileMovesScale.nut.ts +++ b/test/nuts/local/localTrackingFileMovesScale.nut.ts @@ -10,7 +10,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import * as fs from 'graceful-fs'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; /* eslint-disable no-unused-expressions */ diff --git a/test/nuts/local/localTrackingScale.nut.ts b/test/nuts/local/localTrackingScale.nut.ts index 13f6861a..9bcd5582 100644 --- a/test/nuts/local/localTrackingScale.nut.ts +++ b/test/nuts/local/localTrackingScale.nut.ts @@ -10,7 +10,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import * as fs from 'graceful-fs'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; const dirCount = 200; const classesPerDir = 500; diff --git a/test/nuts/local/localTrackingScenario.nut.ts b/test/nuts/local/localTrackingScenario.nut.ts index fbae6d03..e30fb187 100644 --- a/test/nuts/local/localTrackingScenario.nut.ts +++ b/test/nuts/local/localTrackingScenario.nut.ts @@ -11,7 +11,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import { shouldThrow } from '@salesforce/core/testSetup'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; describe('end-to-end-test for local tracking', () => { let session: TestSession; diff --git a/test/nuts/local/nonTopLevelIgnore.nut.ts b/test/nuts/local/nonTopLevelIgnore.nut.ts index 20402b2d..e18220d5 100644 --- a/test/nuts/local/nonTopLevelIgnore.nut.ts +++ b/test/nuts/local/nonTopLevelIgnore.nut.ts @@ -9,7 +9,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; import * as fs from 'graceful-fs'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; const registry = new RegistryAccess(); diff --git a/test/nuts/local/pkgDirMatching.nut.ts b/test/nuts/local/pkgDirMatching.nut.ts index 8a05886e..d7993a48 100644 --- a/test/nuts/local/pkgDirMatching.nut.ts +++ b/test/nuts/local/pkgDirMatching.nut.ts @@ -9,7 +9,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import * as fs from 'graceful-fs'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; describe('verifies exact match of pkgDirs', () => { const registry = new RegistryAccess(); diff --git a/test/nuts/local/relativePkgDirs.nut.ts b/test/nuts/local/relativePkgDirs.nut.ts index fca53426..bd05df11 100644 --- a/test/nuts/local/relativePkgDirs.nut.ts +++ b/test/nuts/local/relativePkgDirs.nut.ts @@ -9,7 +9,7 @@ import { TestSession } from '@salesforce/cli-plugins-testkit'; import * as fs from 'graceful-fs'; import { expect } from 'chai'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; describe('verifies behavior of relative pkgDirs', () => { let session: TestSession; diff --git a/test/unit/localDetectMovedFiles.test.ts b/test/unit/localDetectMovedFiles.test.ts index 0275e691..20098813 100644 --- a/test/unit/localDetectMovedFiles.test.ts +++ b/test/unit/localDetectMovedFiles.test.ts @@ -12,7 +12,7 @@ import git from 'isomorphic-git'; import { expect } from 'chai'; import sinon = require('sinon'); import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../src/shared/local/localShadowRepo'; /* eslint-disable no-unused-expressions */ diff --git a/test/unit/localShadowRepo.test.ts b/test/unit/localShadowRepo.test.ts index 5b1be6fc..36c22c48 100644 --- a/test/unit/localShadowRepo.test.ts +++ b/test/unit/localShadowRepo.test.ts @@ -12,7 +12,7 @@ import git from 'isomorphic-git'; import { expect } from 'chai'; import sinon = require('sinon'); import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; -import { ShadowRepo } from '../../src/shared/localShadowRepo'; +import { ShadowRepo } from '../../src/shared/local/localShadowRepo'; /* eslint-disable no-unused-expressions */ @@ -54,78 +54,4 @@ describe('localShadowRepo', () => { if (projectDir) await fs.promises.rm(projectDir, { recursive: true }); } }); - it('respects SFDX_SOURCE_TRACKING_BATCH_SIZE env var', async () => { - expect(process.env.SFDX_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - process.env.SFDX_SOURCE_TRACKING_BATCH_SIZE = '1'; - const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localShadowRepoTest')); - - const shadowRepo: ShadowRepo = await ShadowRepo.getInstance({ - orgId: '00D456789012345', - registry, - projectPath: projectDir, - packageDirs: [ - { - name: 'dummy', - fullPath: 'dummy', - path: path.join(projectDir, 'force-app'), - }, - ], - }); - // private property maxFileAdd - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(shadowRepo.maxFileAdd).to.equal(1); - delete process.env.SFDX_SOURCE_TRACKING_BATCH_SIZE; - expect(process.env.SFDX_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - }); - - it('respects SF_SOURCE_TRACKING_BATCH_SIZE env var', async () => { - expect(process.env.SF_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - process.env.SF_SOURCE_TRACKING_BATCH_SIZE = '1'; - const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localShadowRepoTest')); - - const shadowRepo: ShadowRepo = await ShadowRepo.getInstance({ - orgId: '00D456789012345', - registry, - projectPath: projectDir, - packageDirs: [ - { - name: 'dummy', - fullPath: 'dummy', - path: path.join(projectDir, 'force-app'), - }, - ], - }); - // private property maxFileAdd - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(shadowRepo.maxFileAdd).to.equal(1); - delete process.env.SF_SOURCE_TRACKING_BATCH_SIZE; - expect(process.env.SF_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - }); - - it('respects undefined SF_SOURCE_TRACKING_BATCH_SIZE env var and uses default', async () => { - expect(process.env.SF_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - expect(process.env.SFDX_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localShadowRepoTest')); - - const shadowRepo: ShadowRepo = await ShadowRepo.getInstance({ - orgId: '00D456789012345', - registry, - projectPath: projectDir, - packageDirs: [ - { - name: 'dummy', - fullPath: 'dummy', - path: path.join(projectDir, 'force-app'), - }, - ], - }); - // private property maxFileAdd - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(shadowRepo.maxFileAdd).to.equal(os.type() === 'Windows_NT' ? 8000 : 15_000); - expect(process.env.SF_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - expect(process.env.SFDX_SOURCE_TRACKING_BATCH_SIZE).to.be.undefined; - }); }); From 159e471d7becc8ead0367e6f275666cf5c868585 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 23 May 2024 17:41:44 -0500 Subject: [PATCH 3/8] refactor: reorganize code --- src/shared/local/localShadowRepo.ts | 47 ++------- src/shared/local/moveDetection.ts | 157 ++++++++++++++++++++-------- src/shared/local/types.ts | 2 +- 3 files changed, 120 insertions(+), 86 deletions(-) diff --git a/src/shared/local/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts index b45026c8..13f78f86 100644 --- a/src/shared/local/localShadowRepo.ts +++ b/src/shared/local/localShadowRepo.ts @@ -15,12 +15,10 @@ import git from 'isomorphic-git'; import { Performance } from '@oclif/core'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; import { chunkArray, excludeLwcLocalOnlyTest, folderContainsPath } from '../functions'; -import { getHash, getMatches } from './moveDetection'; -import { FileInfo, StatusRow } from './types'; +import { filenameMatchesToMap, getMatches } from './moveDetection'; +import { StatusRow } from './types'; import { toFilenames } from './functions'; import { isDeleted, isAdded } from './functions'; -import { compareHashes, buildMaps } from './moveDetection'; -import { removeNonMatches } from './moveDetection'; /** returns the full path to where we store the shadow repo */ const getGitDir = (orgId: string, projectPath: string): string => @@ -345,46 +343,13 @@ export class ShadowRepo { } private async detectMovedFiles(): Promise { - const commonGitOptions = { fs, dir: this.projectPath, gitdir: this.gitDir }; - const { addedFilenamesWithMatches, deletedFilenamesWithMatches } = getMatches(await this.getStatus()) ?? {}; - if (!addedFilenamesWithMatches || !deletedFilenamesWithMatches) return; + const matchingFiles = getMatches(await this.getStatus()); + if (!matchingFiles.added.size || !matchingFiles.deleted.size) return; const movedFilesMarker = Performance.mark('@salesforce/source-tracking', 'localShadowRepo.detectMovedFiles'); + const matches = await filenameMatchesToMap(IS_WINDOWS)(this.registry)(this.projectPath)(this.gitDir)(matchingFiles); - // Track how long it takes to gather the oid information from the git trees - const getInfoMarker = Performance.mark('@salesforce/source-tracking', 'localShadowRepo.detectMovedFiles#getInfo', { - addedFiles: addedFilenamesWithMatches.size, - deletedFiles: deletedFilenamesWithMatches.size, - }); - - const deleteHashGetter = getHash(this.gitDir)(this.projectPath)( - await git.resolveRef({ ...commonGitOptions, ref: 'HEAD' }) - ); - - const addHashGetter = async (filepath: string): Promise => ({ - filename: filepath, - basename: path.basename(filepath), - hash: (await git.hashBlob({ object: await fs.promises.readFile(path.join(this.projectPath, filepath), 'utf8') })) - .oid, - }); - - // We found file adds and deletes with the same basename - // The have likely been moved, confirm by comparing their hashes (oids) - const [addedInfo, deletedInfo] = await Promise.all([ - await Promise.all(Array.from(addedFilenamesWithMatches).map(addHashGetter)), - await Promise.all(Array.from(deletedFilenamesWithMatches).map(deleteHashGetter)), - ]); - getInfoMarker?.stop(); - - const matchingNameAndHashes = compareHashes(await buildMaps(addedInfo, deletedInfo)); - if (matchingNameAndHashes.size === 0) { - return movedFilesMarker?.stop(); - } - const matches = removeNonMatches(matchingNameAndHashes, this.registry, IS_WINDOWS); - - if (matches.size === 0) { - return movedFilesMarker?.stop(); - } + if (matches.size === 0) return movedFilesMarker?.stop(); this.logger.debug( [ diff --git a/src/shared/local/moveDetection.ts b/src/shared/local/moveDetection.ts index 1bb797d6..d43a4b2d 100644 --- a/src/shared/local/moveDetection.ts +++ b/src/shared/local/moveDetection.ts @@ -15,21 +15,44 @@ import { // @ts-expect-error isogit has both ESM and CJS exports but node16 module/resolution identifies it as ESM import git from 'isomorphic-git'; import * as fs from 'graceful-fs'; +import { Performance } from '@oclif/core'; import { sourceComponentGuard } from '../guards'; import { isDeleted, isAdded, ensureWindows, toFilenames } from './functions'; -import { AddAndDeleteMaps, FileInfo, StatusRow, StringMap } from './types'; +import { AddAndDeleteMaps, FilenameBasenameHash, StatusRow, StringMap } from './types'; + +type AddAndDeleteFileInfos = { addedInfo: FilenameBasenameHash[]; deletedInfo: FilenameBasenameHash[] }; +type AddedAndDeletedFilenames = { added: Set; deleted: Set }; + +/** composed functions to simplified use by the shadowRepo class */ +export const filenameMatchesToMap = + (isWindows: boolean) => + (registry: RegistryAccess) => + (projectPath: string) => + (gitDir: string) => + async ({ added, deleted }: AddedAndDeletedFilenames): Promise => + removeNonMatches(isWindows)(registry)( + compareHashes( + await buildMaps( + await toFileInfo({ + projectPath, + gitDir, + added, + deleted, + }) + ) + ) + ); /** compare delete and adds from git.status, matching basenames of the files. returns early when there's nothing to match */ -export const getMatches = ( - status: StatusRow[] -): { deletedFilenamesWithMatches: Set; addedFilenamesWithMatches: Set } | undefined => { +export const getMatches = (status: StatusRow[]): AddedAndDeletedFilenames => { // We check for moved files in incremental steps and exit as early as we can to avoid any performance degradation // Deleted files will be more rare than added files, so we'll check them first and exit early if there are none + const emptyResult = { added: new Set(), deleted: new Set() }; const deletedFiles = status.filter(isDeleted); - if (!deletedFiles.length) return; + if (!deletedFiles.length) return emptyResult; const addedFiles = status.filter(isAdded); - if (!addedFiles.length) return; + if (!addedFiles.length) return emptyResult; // Both arrays have contents, look for matching basenames const addedFilenames = toFilenames(addedFiles); @@ -41,26 +64,16 @@ export const getMatches = ( // Again, we filter over the deleted files first and exit early if there are no filename matches const deletedFilenamesWithMatches = new Set(deletedFilenames.filter((f) => addedBasenames.has(path.basename(f)))); - if (!deletedFilenamesWithMatches.size) return; + if (!deletedFilenamesWithMatches.size) return emptyResult; const addedFilenamesWithMatches = new Set(addedFilenames.filter((f) => deletedBasenames.has(path.basename(f)))); - if (!addedFilenamesWithMatches.size) return; + if (!addedFilenamesWithMatches.size) return emptyResult; - return { addedFilenamesWithMatches, deletedFilenamesWithMatches }; + return { added: addedFilenamesWithMatches, deleted: deletedFilenamesWithMatches }; }; -export const getHash = - (gitdir: string) => - (projectPath: string) => - (oid: string) => - async (filepath: string): Promise => ({ - filename: filepath, - basename: path.basename(filepath), - hash: (await git.readBlob({ fs, dir: projectPath, gitdir, filepath, oid })).oid, - }); - /** build maps of the add/deletes with filenames, returning the matches Logs if non-matches */ -export const buildMaps = async (addedInfo: FileInfo[], deletedInfo: FileInfo[]): Promise => { +const buildMaps = async ({ addedInfo, deletedInfo }: AddAndDeleteFileInfos): Promise => { const [addedMap, addedIgnoredMap] = buildMap(addedInfo); const [deletedMap, deletedIgnoredMap] = buildMap(deletedInfo); @@ -83,7 +96,7 @@ export const buildMaps = async (addedInfo: FileInfo[], deletedInfo: FileInfo[]): }; /** builds a map of the values from both maps */ -export const compareHashes = ({ addedMap, deletedMap }: AddAndDeleteMaps): StringMap => { +const compareHashes = ({ addedMap, deletedMap }: AddAndDeleteMaps): StringMap => { const matches: StringMap = new Map(); for (const [addedKey, addedValue] of addedMap) { @@ -96,7 +109,67 @@ export const compareHashes = ({ addedMap, deletedMap }: AddAndDeleteMaps): Strin return matches; }; -export const buildMap = (info: FileInfo[]): StringMap[] => { +/** given a StringMap, resolve the metadata types and return things that having matching type/parent */ +const removeNonMatches = + (isWindows: boolean) => + (registry: RegistryAccess) => + (matches: StringMap): StringMap => { + if (!matches.size) return matches; + const addedFiles = isWindows ? [...matches.keys()].map(ensureWindows) : [...matches.keys()]; + const deletedFiles = isWindows ? [...matches.values()].map(ensureWindows) : [...matches.values()]; + const resolverAdded = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(addedFiles)); + const resolverDeleted = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(deletedFiles)); + + return new Map( + [...matches.entries()].filter(([addedFile, deletedFile]) => { + // we're only ever using the first element of the arrays + const [resolvedAdded] = resolveType(resolverAdded, isWindows ? [ensureWindows(addedFile)] : [addedFile]); + const [resolvedDeleted] = resolveType( + resolverDeleted, + isWindows ? [ensureWindows(deletedFile)] : [deletedFile] + ); + return ( + // they could match, or could both be undefined (because unresolved by SDR) + resolvedAdded?.type.name === resolvedDeleted?.type.name && + // parent names match, if resolved and there are parents + resolvedAdded?.parent?.name === resolvedDeleted?.parent?.name && + // parent types match, if resolved and there are parents + resolvedAdded?.parent?.type.name === resolvedDeleted?.parent?.type.name + ); + }) + ); + }; + +/** enrich the filenames with basename and oid (hash) */ +const toFileInfo = async ({ + projectPath, + gitDir, + added, + deleted, +}: { + projectPath: string; + gitDir: string; + added: Set; + deleted: Set; +}): Promise => { + // Track how long it takes to gather the oid information from the git trees + const getInfoMarker = Performance.mark('@salesforce/source-tracking', 'localShadowRepo.detectMovedFiles#toFileInfo', { + addedFiles: added.size, + deletedFiles: deleted.size, + }); + + const headRef = await git.resolveRef({ fs, dir: projectPath, gitdir: gitDir, ref: 'HEAD' }); + const [addedInfo, deletedInfo] = await Promise.all([ + await Promise.all(Array.from(added).map(getHashForAddedFile(projectPath))), + await Promise.all(Array.from(deleted).map(getHashFromActualFileContents(gitDir)(projectPath)(headRef))), + ]); + + getInfoMarker?.stop(); + + return { addedInfo, deletedInfo }; +}; + +const buildMap = (info: FilenameBasenameHash[]): StringMap[] => { const map: StringMap = new Map(); const ignore: StringMap = new Map(); info.map((i) => { @@ -113,28 +186,13 @@ export const buildMap = (info: FileInfo[]): StringMap[] => { return [map, ignore]; }; -export const removeNonMatches = (matches: StringMap, registry: RegistryAccess, isWindows: boolean): StringMap => { - const addedFiles = isWindows ? [...matches.keys()].map(ensureWindows) : [...matches.keys()]; - const deletedFiles = isWindows ? [...matches.values()].map(ensureWindows) : [...matches.values()]; - const resolverAdded = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(addedFiles)); - const resolverDeleted = new MetadataResolver(registry, VirtualTreeContainer.fromFilePaths(deletedFiles)); - - return new Map( - [...matches.entries()].filter(([addedFile, deletedFile]) => { - // we're only ever using the first element of the arrays - const [resolvedAdded] = resolveType(resolverAdded, isWindows ? [ensureWindows(addedFile)] : [addedFile]); - const [resolvedDeleted] = resolveType(resolverDeleted, isWindows ? [ensureWindows(deletedFile)] : [deletedFile]); - return ( - // they could match, or could both be undefined (because unresolved by SDR) - resolvedAdded?.type.name === resolvedDeleted?.type.name && - // parent names match, if resolved and there are parents - resolvedAdded?.parent?.name === resolvedDeleted?.parent?.name && - // parent types match, if resolved and there are parents - resolvedAdded?.parent?.type.name === resolvedDeleted?.parent?.type.name - ); - }) - ); -}; +const getHashForAddedFile = + (projectPath: string) => + async (filepath: string): Promise => ({ + filename: filepath, + basename: path.basename(filepath), + hash: (await git.hashBlob({ object: await fs.promises.readFile(path.join(projectPath, filepath), 'utf8') })).oid, + }); const resolveType = (resolver: MetadataResolver, filenames: string[]): SourceComponent[] => filenames @@ -148,3 +206,14 @@ const resolveType = (resolver: MetadataResolver, filenames: string[]): SourceCom } }) .filter(sourceComponentGuard); + +/** where we don't have git objects to use, read the file contents to generate the hash */ +const getHashFromActualFileContents = + (gitdir: string) => + (projectPath: string) => + (oid: string) => + async (filepath: string): Promise => ({ + filename: filepath, + basename: path.basename(filepath), + hash: (await git.readBlob({ fs, dir: projectPath, gitdir, filepath, oid })).oid, + }); diff --git a/src/shared/local/types.ts b/src/shared/local/types.ts index c1e15cff..b7d7ed1f 100644 --- a/src/shared/local/types.ts +++ b/src/shared/local/types.ts @@ -4,7 +4,7 @@ * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -export type FileInfo = { filename: string; hash: string; basename: string }; +export type FilenameBasenameHash = { filename: string; hash: string; basename: string }; export type StringMap = Map; export type AddAndDeleteMaps = { addedMap: StringMap; deletedMap: StringMap }; // https://isomorphic-git.org/docs/en/statusMatrix#docsNav From fe0610cdc806c09528b7d3801614874ff1a1316c Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 23 May 2024 17:57:01 -0500 Subject: [PATCH 4/8] style: single-line imports --- src/shared/local/localShadowRepo.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shared/local/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts index 13f78f86..41f6efdb 100644 --- a/src/shared/local/localShadowRepo.ts +++ b/src/shared/local/localShadowRepo.ts @@ -17,8 +17,7 @@ import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; import { chunkArray, excludeLwcLocalOnlyTest, folderContainsPath } from '../functions'; import { filenameMatchesToMap, getMatches } from './moveDetection'; import { StatusRow } from './types'; -import { toFilenames } from './functions'; -import { isDeleted, isAdded } from './functions'; +import { isDeleted, isAdded, toFilenames } from './functions'; /** returns the full path to where we store the shadow repo */ const getGitDir = (orgId: string, projectPath: string): string => From 8895b3688971762f5299b38ac0447ebad7e8439b Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 23 May 2024 18:07:03 -0500 Subject: [PATCH 5/8] chore: bump deps for xnuts --- package.json | 4 ++-- yarn.lock | 57 +++++++++++++++------------------------------------- 2 files changed, 18 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 5ec49e2e..96ebd68d 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,9 @@ }, "dependencies": { "@oclif/core": "^3.26.6", - "@salesforce/core": "^7.3.8", + "@salesforce/core": "^7.3.9", "@salesforce/kit": "^3.1.1", - "@salesforce/source-deploy-retrieve": "^11.4.4", + "@salesforce/source-deploy-retrieve": "^11.6.2", "@salesforce/ts-types": "^2.0.9", "fast-xml-parser": "^4.3.6", "graceful-fs": "^4.2.11", diff --git a/yarn.lock b/yarn.lock index 2d645b62..bae1bc78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -598,14 +598,14 @@ strip-ansi "6.0.1" ts-retry-promise "^0.8.0" -"@salesforce/core@^7.3.1", "@salesforce/core@^7.3.5", "@salesforce/core@^7.3.8": - version "7.3.8" - resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-7.3.8.tgz#8a646b5321f08c0fb4d22e2fa8b1d60b3a20df9b" - integrity sha512-VWhXHfjwjtC3pJWYp8wt5/fnNQ5tK61ovMG5eteXzVD2oFd7og1f6YjwuAzoYIZK7kYWWv7KJfGtCsPs7Zw+Ww== +"@salesforce/core@^7.3.1", "@salesforce/core@^7.3.5", "@salesforce/core@^7.3.9": + version "7.3.9" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-7.3.9.tgz#8abe2b3e2393989d11e92b7a6b96043fc9d5b9c8" + integrity sha512-eJqDiA5b7wU50Ee/xjmGzSnHrNVJ8S77B7enfX30gm7gxU3i3M3QeBdiV6XAOPLSIL96DseofP6Tv6c+rljlKA== dependencies: "@jsforce/jsforce-node" "^3.2.0" "@salesforce/kit" "^3.1.1" - "@salesforce/schemas" "^1.7.0" + "@salesforce/schemas" "^1.9.0" "@salesforce/ts-types" "^2.0.9" ajv "^8.13.0" change-case "^4.1.2" @@ -671,15 +671,15 @@ resolved "https://registry.yarnpkg.com/@salesforce/prettier-config/-/prettier-config-0.0.3.tgz#ba648d4886bb38adabe073dbea0b3a91b3753bb0" integrity sha512-hYOhoPTCSYMDYn+U1rlEk16PoBeAJPkrdg4/UtAzupM1mRRJOwEPMG1d7U8DxJFKuXW3DMEYWr2MwAIBDaHmFg== -"@salesforce/schemas@^1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@salesforce/schemas/-/schemas-1.7.0.tgz#b7e0af3ee414ae7160bce351c0184d77ccb98fe3" - integrity sha512-Z0PiCEV55khm0PG+DsnRYCjaDmacNe3HDmsoSm/CSyYvJJm+D5vvkHKN9/PKD/gaRe8XAU836yfamIYFblLINw== +"@salesforce/schemas@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@salesforce/schemas/-/schemas-1.9.0.tgz#ba477a112653a20b4edcf989c61c57bdff9aa3ca" + integrity sha512-LiN37zG5ODT6z70sL1fxF7BQwtCX9JOWofSU8iliSNIM+WDEeinnoFtVqPInRSNt8I0RiJxIKCrqstsmQRBNvA== -"@salesforce/source-deploy-retrieve@^11.4.4": - version "11.4.4" - resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-11.4.4.tgz#c853e0888c6a5b64c4af3e705010ef1a6dac4c13" - integrity sha512-6dohRR9t6Aj2mbHzfYtbphxqxF83AwmAjkFFQPB6+Yn1ceVKmJjd0WY23fgWTTaTum+2pnw9XA35qwu4naBCVw== +"@salesforce/source-deploy-retrieve@^11.6.2": + version "11.6.2" + resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-11.6.2.tgz#2d4faf3c00cc38dad0245f07a9b978131b845a5e" + integrity sha512-GEDTo4JCgisQt2hVyRY4Tu/ivEt2u9hJRzNn7A6ZDu668/0Ufq04YhdZfmSJePXAAtrtd9OqYfj8aJO5AOk1+g== dependencies: "@salesforce/core" "^7.3.5" "@salesforce/kit" "^3.1.1" @@ -5178,16 +5178,7 @@ srcset@^5.0.0: resolved "https://registry.yarnpkg.com/srcset/-/srcset-5.0.0.tgz#9df6c3961b5b44a02532ce6ae4544832609e2e3f" integrity sha512-SqEZaAEhe0A6ETEa9O1IhSPC7MdvehZtCnTR0AftXk3QhY2UNgb+NApFOUPZILXk/YTDfFxMTNJOBpzrJsEdIA== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5246,14 +5237,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5770,7 +5754,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -5788,15 +5772,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 10a9c737113311c74afd153eb24305cb8f727741 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Fri, 24 May 2024 14:15:27 -0500 Subject: [PATCH 6/8] chore: buffer instead of utf8 encoding --- package.json | 2 +- src/shared/local/moveDetection.ts | 2 +- ...calTrackingFileMovesDecomposedChild.nut.ts | 61 +++++++++++-------- .../local/localTrackingFileMovesImage.nut.ts | 24 ++------ yarn.lock | 8 +++ 5 files changed, 49 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index f717085b..ecccb26a 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "dependencies": { "@oclif/core": "^3.26.6", "@salesforce/core": "^7.3.9", - "@salesforce/kit": "^3.1.1", + "@salesforce/kit": "^3.1.2", "@salesforce/source-deploy-retrieve": "^11.6.2", "@salesforce/ts-types": "^2.0.9", "fast-xml-parser": "^4.3.6", diff --git a/src/shared/local/moveDetection.ts b/src/shared/local/moveDetection.ts index d43a4b2d..b6d84842 100644 --- a/src/shared/local/moveDetection.ts +++ b/src/shared/local/moveDetection.ts @@ -191,7 +191,7 @@ const getHashForAddedFile = async (filepath: string): Promise => ({ filename: filepath, basename: path.basename(filepath), - hash: (await git.hashBlob({ object: await fs.promises.readFile(path.join(projectPath, filepath), 'utf8') })).oid, + hash: (await git.hashBlob({ object: await fs.promises.readFile(path.join(projectPath, filepath)) })).oid, }); const resolveType = (resolver: MetadataResolver, filenames: string[]): SourceComponent[] => diff --git a/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts b/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts index 72367f6e..7f118dc9 100644 --- a/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts +++ b/test/nuts/local/localTrackingFileMovesDecomposedChild.nut.ts @@ -15,9 +15,10 @@ import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; /* eslint-disable no-unused-expressions */ describe('ignores moved files that are children of a decomposed metadata type', () => { + const FIELD = path.join('fields', 'Account__c.field-meta.xml'); let session: TestSession; let repo: ShadowRepo; - let filesToSync: string[]; + let objectsDir: string; before(async () => { session = await TestSession.create({ @@ -26,12 +27,17 @@ describe('ignores moved files that are children of a decomposed metadata type', }, devhubAuthStrategy: 'NONE', }); + objectsDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'objects'); }); after(async () => { await session?.clean(); }); + afterEach(() => { + delete process.env.SF_BETA_TRACK_FILE_MOVES; + }); + it('initialize the local tracking', async () => { repo = await ShadowRepo.getInstance({ orgId: 'fakeOrgId', @@ -44,32 +50,12 @@ describe('ignores moved files that are children of a decomposed metadata type', it('should ignore moved child metadata', async () => { expect(process.env.SF_BETA_TRACK_FILE_MOVES).to.be.undefined; process.env.SF_BETA_TRACK_FILE_MOVES = 'true'; - // Commit the existing class files - filesToSync = await repo.getChangedFilenames(); + // Commit the existing files + const filesToSync = await repo.getChangedFilenames(); await repo.commitChanges({ deployedFiles: filesToSync }); - - // move all the classes to the new folder - const objectFieldOld = path.join( - session.project.dir, - 'force-app', - 'main', - 'default', - 'objects', - 'Order__c', - 'fields', - 'Account__c.field-meta.xml' - ); - const objectFieldNew = path.join( - session.project.dir, - 'force-app', - 'main', - 'default', - 'objects', - 'Product__c', - 'fields', - 'Account__c.field-meta.xml' - ); - // fs.mkdirSync(path.join(session.project.dir, 'force-app', 'main', 'foo'), { recursive: true }); + // move the field from one object to another + const objectFieldOld = path.join(objectsDir, 'Order__c', FIELD); + const objectFieldNew = path.join(objectsDir, 'Product__c', FIELD); fs.renameSync(objectFieldOld, objectFieldNew); await repo.getStatus(true); @@ -78,6 +64,27 @@ describe('ignores moved files that are children of a decomposed metadata type', .to.be.an('array') .with.lengthOf(2); - delete process.env.SF_BETA_TRACK_FILE_MOVES; + // put it back how it was and verify the tracking + fs.renameSync(objectFieldNew, objectFieldOld); + await repo.getStatus(true); + + expect(await repo.getChangedFilenames()) + .to.be.an('array') + .with.lengthOf(0); + }); + + it('should clear tracking when the field is moved to another dir', async () => { + const newDir = path.join(session.project.dir, 'force-app', 'other', 'objects', 'Order__c', 'fields'); + await fs.promises.mkdir(newDir, { + recursive: true, + }); + const objectFieldOld = path.join(objectsDir, 'Order__c', FIELD); + const objectFieldNew = path.join(objectsDir, 'Order__c', FIELD); + fs.renameSync(objectFieldOld, objectFieldNew); + await repo.getStatus(true); + + expect(await repo.getChangedFilenames()) + .to.be.an('array') + .with.lengthOf(0); }); }); diff --git a/test/nuts/local/localTrackingFileMovesImage.nut.ts b/test/nuts/local/localTrackingFileMovesImage.nut.ts index 80e5f12b..5b38fdb4 100644 --- a/test/nuts/local/localTrackingFileMovesImage.nut.ts +++ b/test/nuts/local/localTrackingFileMovesImage.nut.ts @@ -19,6 +19,7 @@ describe('it detects image file moves ', () => { let session: TestSession; let repo: ShadowRepo; let filesToSync: string[]; + let staticDir: string; before(async () => { session = await TestSession.create({ @@ -27,6 +28,7 @@ describe('it detects image file moves ', () => { }, devhubAuthStrategy: 'NONE', }); + staticDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'staticresources'); }); after(async () => { @@ -50,28 +52,12 @@ describe('it detects image file moves ', () => { await repo.commitChanges({ deployedFiles: filesToSync }); // move all the classes to the new folder - fs.mkdirSync(path.join(session.project.dir, 'force-app', 'main', 'default', 'staticresources', 'bike_assets_new'), { + fs.mkdirSync(path.join(staticDir, 'bike_assets_new'), { recursive: true, }); fs.renameSync( - path.join( - session.project.dir, - 'force-app', - 'main', - 'default', - 'staticresources', - 'bike_assets', - 'CyclingGrass.jpg' - ), - path.join( - session.project.dir, - 'force-app', - 'main', - 'default', - 'staticresources', - 'bike_assets_new', - 'CyclingGrass.jpg' - ) + path.join(staticDir, 'bike_assets', 'CyclingGrass.jpg'), + path.join(staticDir, 'bike_assets_new', 'CyclingGrass.jpg') ); await repo.getStatus(true); diff --git a/yarn.lock b/yarn.lock index 07e35fc7..26a89bdd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -666,6 +666,14 @@ "@salesforce/ts-types" "^2.0.9" tslib "^2.6.2" +"@salesforce/kit@^3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@salesforce/kit/-/kit-3.1.2.tgz#270741c54c70969df19ef17f8979b4ef1fa664b2" + integrity sha512-si+ddvZDgx9q5czxAANuK5xhz3pv+KGspQy1wyia/7HDPKadA0QZkLTzUnO1Ju4Mux32CNHEb2y9lw9jj+eVTA== + dependencies: + "@salesforce/ts-types" "^2.0.9" + tslib "^2.6.2" + "@salesforce/prettier-config@^0.0.3": version "0.0.3" resolved "https://registry.yarnpkg.com/@salesforce/prettier-config/-/prettier-config-0.0.3.tgz#ba648d4886bb38adabe073dbea0b3a91b3753bb0" From 8cbf42384f293682302b3bb8ed7464d29fb8e510 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Fri, 24 May 2024 14:34:59 -0500 Subject: [PATCH 7/8] docs: todo about set intersection --- src/shared/local/moveDetection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/local/moveDetection.ts b/src/shared/local/moveDetection.ts index b6d84842..a2a58d15 100644 --- a/src/shared/local/moveDetection.ts +++ b/src/shared/local/moveDetection.ts @@ -62,6 +62,7 @@ export const getMatches = (status: StatusRow[]): AddedAndDeletedFilenames => { const addedBasenames = new Set(addedFilenames.map((filename) => path.basename(filename))); const deletedBasenames = new Set(deletedFilenames.map((filename) => path.basename(filename))); + // TODO: when node 22 is everywhere, we can use Set.prototype.intersection // Again, we filter over the deleted files first and exit early if there are no filename matches const deletedFilenamesWithMatches = new Set(deletedFilenames.filter((f) => addedBasenames.has(path.basename(f)))); if (!deletedFilenamesWithMatches.size) return emptyResult; From 8e5b56966644f60f2d6cc674c2ae71ea414e649b Mon Sep 17 00:00:00 2001 From: Eric Willhoit Date: Wed, 29 May 2024 16:17:19 -0500 Subject: [PATCH 8/8] chore: deps --- package.json | 2 +- yarn.lock | 41 ++++++++--------------------------------- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 53aa3c7a..4c9827d0 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@oclif/core": "^3.26.6", "@salesforce/core": "^7.3.9", "@salesforce/kit": "^3.1.2", - "@salesforce/source-deploy-retrieve": "^11.6.2", + "@salesforce/source-deploy-retrieve": "^11.6.3", "@salesforce/ts-types": "^2.0.9", "fast-xml-parser": "^4.3.6", "graceful-fs": "^4.2.11", diff --git a/yarn.lock b/yarn.lock index c8755bb6..7a2b0839 100644 --- a/yarn.lock +++ b/yarn.lock @@ -676,12 +676,12 @@ resolved "https://registry.yarnpkg.com/@salesforce/schemas/-/schemas-1.9.0.tgz#ba477a112653a20b4edcf989c61c57bdff9aa3ca" integrity sha512-LiN37zG5ODT6z70sL1fxF7BQwtCX9JOWofSU8iliSNIM+WDEeinnoFtVqPInRSNt8I0RiJxIKCrqstsmQRBNvA== -"@salesforce/source-deploy-retrieve@^11.6.2": - version "11.6.2" - resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-11.6.2.tgz#2d4faf3c00cc38dad0245f07a9b978131b845a5e" - integrity sha512-GEDTo4JCgisQt2hVyRY4Tu/ivEt2u9hJRzNn7A6ZDu668/0Ufq04YhdZfmSJePXAAtrtd9OqYfj8aJO5AOk1+g== +"@salesforce/source-deploy-retrieve@^11.6.3": + version "11.6.3" + resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-11.6.3.tgz#24ff55a17f8d7862b1467e40968707fedaa3e624" + integrity sha512-6h/KJV8cRfzLZ3aE6PP76oP1bz28zRxL9wtCx6yD+jz4GRdIxHNjNQhiKJJI9m2Z+npWl/sjth8aHB7rrSgSrg== dependencies: - "@salesforce/core" "^7.3.5" + "@salesforce/core" "^7.3.9" "@salesforce/kit" "^3.1.1" "@salesforce/ts-types" "^2.0.9" fast-levenshtein "^3.0.0" @@ -5178,16 +5178,7 @@ srcset@^5.0.0: resolved "https://registry.yarnpkg.com/srcset/-/srcset-5.0.0.tgz#9df6c3961b5b44a02532ce6ae4544832609e2e3f" integrity sha512-SqEZaAEhe0A6ETEa9O1IhSPC7MdvehZtCnTR0AftXk3QhY2UNgb+NApFOUPZILXk/YTDfFxMTNJOBpzrJsEdIA== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5246,14 +5237,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5770,7 +5754,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -5788,15 +5772,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"