UNPKG

@jayree/sfdx-plugin-manifest

Version:

A Salesforce CLI plugin containing commands for creating manifest files from Salesforce orgs or git commits of sfdx projects.

316 lines 13.5 kB
/* * Copyright 2025, jayree * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // https://github.com/forcedotcom/source-tracking/blob/main/src/shared/local/localShadowRepo.ts import util from 'node:util'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; import git, { readBlob as _readBlob, listFiles as _listFiles } from 'isomorphic-git'; import { Performance } from '@oclif/core/performance'; import { Lifecycle, SfError } from '@salesforce/core'; import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; import { excludeLwcLocalOnlyTest, folderContainsPath } from '@salesforce/source-tracking/lib/shared/functions.js'; import { HEAD, WORKDIR, IS_WINDOWS, ensureWindows, ensurePosix, toFilenames, } from '@salesforce/source-tracking/lib/shared/local/functions.js'; import { getMatches } from '@salesforce/source-tracking/lib/shared/local/moveDetection.js'; import { parseMetadataXml } from '@salesforce/source-deploy-retrieve/lib/src/utils/index.js'; import { statusMatrix } from '../../api/statusMatrix.js'; import { filenameMatchesToMap, getLogMessage } from './moveDetection.js'; export const STAGE = 3; const redirectToCliRepoError = (e) => { if (e instanceof git.Errors.InternalError) { const error = new SfError(`An internal error caused this command to fail. isomorphic-git error:${os.EOL}${e.data.message}`, e.name); throw error; } throw e; }; export class GitRepo { static instanceMap = new Map(); dir; packageDirs; status; cache; lifecycle = Lifecycle.getInstance(); registry; constructor(options) { this.dir = options.dir; this.packageDirs = options.packageDirs?.map(packageDirToRelativePosixPath(options.dir)) ?? []; this.registry = options.registry ?? new RegistryAccess(); this.cache = {}; } static getInstance(options) { if (!GitRepo.instanceMap.has(options.dir)) { const newInstance = new GitRepo(options); GitRepo.instanceMap.set(options.dir, newInstance); } return GitRepo.instanceMap.get(options.dir); } async resolveRef(ref) { return ref ? git.resolveRef({ fs, dir: this.dir, ref }) : undefined; } async getConfig(p) { return (await git.getConfig({ fs, dir: this.dir, path: p })); } // eslint-disable-next-line class-methods-use-this async hashBlob(object) { return (await git.hashBlob({ object })).oid; } async listFiles(ref) { return ref ? (await _listFiles({ fs, dir: this.dir, ref, cache: this.cache })).map((p) => path.join(IS_WINDOWS ? ensureWindows(p) : p)) : (await fs.readdir(this.dir, { recursive: true })).map((p) => path.join(IS_WINDOWS ? ensureWindows(p) : p)); } async readBlob(filepath, oid) { return oid ? Buffer.from((await _readBlob({ fs, dir: this.dir, oid, filepath: IS_WINDOWS ? ensurePosix(filepath) : filepath, cache: this.cache, })).blob) : fs.readFile(path.resolve(filepath)); } async readOid(filepath, oid) { return (await _readBlob({ fs, dir: this.dir, oid, filepath: IS_WINDOWS ? ensurePosix(filepath) : filepath, cache: this.cache, })).oid; } async resolveMultiRefString(ref) { const a = ref.split('.'); let ref1; let ref2; if (a.length === 3 || a.length === 4) { ref1 = a[0]; ref2 = a[a.length - 1]; } else if (a.length === 1) { ref1 = a[0]; } else { throw new Error(`Ambiguous ${util.format('argument%s', ref.length === 1 ? '' : 's')}: ${ref} See more help with --help`); } if (a.length === 4 && ref2) { ref1 = (await git.findMergeBase({ fs, dir: this.dir, oids: [ref2, ref1], cache: this.cache, }))[0]; } else { ref1 = await this.resolveSingleRefString(ref1); } ref2 = await this.resolveSingleRefString(ref2); return { ref1, ref2 }; } async resolveSingleRefString(ref) { if (ref === undefined) { return ''; } if (!['~', '^'].some((el) => ref.includes(el))) { return (await this.getCommitLog(ref)).oid; } const firstIndex = [ref.indexOf('^'), ref.indexOf('~')].filter((a) => a >= 0).reduce((a, b) => Math.min(a, b)); let ipath = ref.substring(firstIndex); let resolvedRef = ref.substring(0, firstIndex); while (ipath.length && resolvedRef !== undefined) { if (ipath.startsWith('^')) { ipath = ipath.substring(1); let next = Number(ipath.substring(0, 1)); ipath = next ? ipath.substring(1) : ipath; next = next ? next : 1; // eslint-disable-next-line no-await-in-loop resolvedRef = (await this.getCommitLog(resolvedRef)).parents[next - 1]; } else if (ipath.startsWith('~')) { ipath = ipath.substring(1); let next = Number(ipath.substring(0, 1)); ipath = next ? ipath.substring(1) : ipath; next = next ? next : 1; for (let index = 0; index <= next - 1; index++) { // eslint-disable-next-line no-await-in-loop resolvedRef = (await this.getCommitLog(resolvedRef)).parents[0]; } } else { resolvedRef = undefined; } } if (resolvedRef === undefined) { throw new Error(`ambiguous argument '${ref}': unknown revision or path not in the working tree.`); } return resolvedRef; } getAdds() { return this.status.filter((file) => file[HEAD] === 0 && file[WORKDIR] === 2); } getAddFilenames() { return toFilenames(this.getAdds()); } getModifies() { return this.status.filter((file) => file[HEAD] === 1 && file[WORKDIR] === 2); } getModifyFilenames() { return toFilenames(this.getModifies()); } getDeletes() { return this.status.filter((file) => file[HEAD] === 1 && file[WORKDIR] === 0); } getDeleteFilenames() { return toFilenames(this.getDeletes()); } async getStatus(ref1, ref2) { const marker = Performance.mark('@jayree/sfdx-plugin-manifest', 'localGitRepo.getStatus'); await this.checkLocalGitAutocrlfConfig(); try { this.status = await statusMatrix({ dir: this.dir, cache: this.cache, ref1, ref2, filepaths: this.packageDirs, ignored: true, filter: fileFilter(this.packageDirs), }); // isomorphic-git stores things in unix-style tree. Convert to windows-style if necessary if (IS_WINDOWS) { this.status = this.status.map((row) => [path.normalize(row[0]), row[HEAD], row[WORKDIR], row[STAGE]]); } await this.detectMovedFiles(); await this.emitStatusWarnings(); } catch (e) { redirectToCliRepoError(e); } marker?.stop(); return this.status; } async emitStatusWarnings() { const warningPatterns = [ [0, 2, 3], // added, staged, with unstaged changes [1, 0, 3], // modified, staged, with unstaged deletion [1, 2, 3], // modified, staged, with unstaged changes [1, 1, 0], // deleted, staged, with unstaged original file [1, 2, 0], // deleted, staged, with unstaged changes [0, 0, 3], // added, staged, with unstaged deletion [1, 1, 3], // modified, staged, with unstaged original file [1, 2, 1], // modified, unstaged [1, 0, 1], // deleted, unstaged [0, 2, 0], // new, untracked ]; // prettier-ignore const warningMessages = [ { filter: [[0, 2, 3], [1, 0, 3], [1, 2, 3], [1, 2, 0], [0, 0, 3]], message: 'The staged file with unstaged changes %s was processed.' }, { filter: [[1, 1, 3], [1, 1, 0]], message: 'The staged file with unstaged changes %s was ignored.' }, { filter: [[1, 2, 1], [1, 0, 1]], message: 'The unstaged file %s was processed.' }, { filter: [[0, 2, 0]], message: 'The untracked file %s was processed.' }, ]; const matchesPattern = (row, patterns) => patterns.some((pattern) => pattern.every((val, i) => val === row[i + 1])); const filteredRows = this.status.filter((row) => matchesPattern(row, warningPatterns)); if (filteredRows.length === 0) return; const getWarningPromises = (warnings) => warnings.flatMap((warning) => { const filesToWarn = filteredRows .filter((row) => matchesPattern(row, warning.filter)) .map((row) => (IS_WINDOWS ? ensureWindows(row[0]) : row[0])); return filesToWarn.map((file) => this.lifecycle.emitWarning(util.format(warning.message, file))); }); await Promise.all(getWarningPromises(warningMessages)); } async detectMovedFiles() { const matchingFiles = getMatches(this.status); if (!matchingFiles.added.size || !matchingFiles.deleted.size) return; const movedFilesMarker = Performance.mark('@jayree/sfdx-plugin-manifest', 'localGitRepo.detectMovedFiles'); const sourceBehaviorOptionsBetaMatches = new Map(); for (const deletedFilePath of matchingFiles.deleted) { const fullName = parseMetadataXml(deletedFilePath)?.fullName; if (fullName) { const addedFilePath = path.join(path.dirname(deletedFilePath), fullName, path.basename(deletedFilePath)); if (matchingFiles.added.has(addedFilePath)) { matchingFiles.deleted.delete(deletedFilePath); matchingFiles.added.delete(addedFilePath); sourceBehaviorOptionsBetaMatches.set(deletedFilePath, addedFilePath); } } } const matches = await filenameMatchesToMap(this.registry)(this.dir)(matchingFiles); sourceBehaviorOptionsBetaMatches.forEach((key, value) => { matches.fullMatches.set(key, value); }); if (matches.deleteOnly.size === 0 && matches.fullMatches.size === 0) return; await Promise.all(getLogMessage(matches).map((message) => this.lifecycle.emitWarning(message))); const removeFiles = [ ...matches.fullMatches.values(), ...matches.fullMatches.keys(), ...matches.deleteOnly.values(), ]; this.status = this.status.filter((file) => (removeFiles.includes(file[0]) ? false : true)); movedFilesMarker?.stop(); } async getCommitLog(ref) { try { const [log] = await git.log({ fs, dir: this.dir, ref, depth: 1, cache: this.cache, }); return { oid: log.oid, parents: log.commit.parent }; } catch { throw new Error(`ambiguous argument '${ref}': unknown revision or path not in the working tree. See more help with --help`); } } async checkLocalGitAutocrlfConfig() { try { const stdout = execSync('git config --show-origin core.autocrlf', { cwd: this.dir }).toString().trim(); if (stdout) { const [origin, value] = stdout.split('\t'); const [, ...rest] = origin.split(':'); const file = rest.join(':') || ''; if (file !== '.git/config') { await this.lifecycle.emitWarning(`You have currently set core.autocrlf to ${value} in ${file}. To optimize performance, please execute 'git config --local core.autocrlf ${value}'.`); } } } catch { // if the command fails, autocrlf is not set } } } const packageDirToRelativePosixPath = (projectPath) => (packageDir) => IS_WINDOWS ? ensurePosix(path.relative(projectPath, packageDir.fullPath)) : path.relative(projectPath, packageDir.fullPath); const fileFilter = (packageDirs) => (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 packageDirs.some(folderContainsPath(f)); //# sourceMappingURL=localGitRepo.js.map