@jayree/sfdx-plugin-manifest
Version:
A powerful Salesforce CLI plugin and Node.js library to effortlessly generate, clean up, and manage package.xml and destructiveChanges.xml manifests directly from your Salesforce orgs or from Git changes in your SF projects. Unlock faster, safer, and smar
327 lines • 14.1 kB
JavaScript
/*
* Copyright 2026, 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 { 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, 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';
const ensureWindows = (filepath) => path.win32.normalize(filepath);
const parseGitConfigOriginFile = (origin) => {
const file = origin.startsWith('file:') ? origin.slice('file:'.length) : origin.split(':').slice(1).join(':');
return file.startsWith('"') && file.endsWith('"') ? file.slice(1, -1).replaceAll('\\\\', '\\') : file;
};
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) {
const files = ref
? await _listFiles({ fs, dir: this.dir, ref, cache: this.cache })
: await fs.readdir(this.dir, { recursive: true });
const normalize = IS_WINDOWS ? ensureWindows : (p) => p;
return files.map((p) => normalize(path.join(this.dir, p)));
}
async readBlob(filepath, oid) {
const relativePath = IS_WINDOWS
? ensurePosix(path.relative(this.dir, filepath))
: path.relative(this.dir, filepath);
return oid
? Buffer.from((await _readBlob({ fs, dir: this.dir, oid, filepath: relativePath, cache: this.cache })).blob)
: fs.readFile(filepath);
}
async readOid(filepath, oid) {
const relativePath = IS_WINDOWS
? ensurePosix(path.relative(this.dir, filepath))
: path.relative(this.dir, filepath);
const { oid: blobOid } = await _readBlob({
fs,
dir: this.dir,
oid,
filepath: relativePath,
cache: this.cache,
});
return blobOid;
}
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) {
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
this.status = this.status.map((row) => [
IS_WINDOWS ? path.normalize(path.join(this.dir, row[0])) : path.join(this.dir, row[0]),
row[HEAD],
row[WORKDIR],
row[STAGE],
]);
await this.detectMovedFiles();
await this.emitStatusWarnings();
}
catch (e) {
redirectToCliRepoError(e);
}
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 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));
}
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 file = parseGitConfigOriginFile(origin);
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) => {
const normalizedPath = ensurePosix(f);
const pathSegments = normalizedPath.split(path.posix.sep);
return (
// no hidden files
!pathSegments.some((segment) => segment.startsWith('.')) &&
// no node_modules (e.g. uiBundle packages inside force-app)
!pathSegments.includes('node_modules') &&
// 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