diff --git a/package.json b/package.json index 7a09afca..a72b3489 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/source-tracking", "description": "API for tracking local and remote Salesforce metadata changes", - "version": "7.8.19", + "version": "7.8.20-dev.0", "author": "Salesforce", "license": "Apache-2.0", "main": "lib/index.js", diff --git a/src/shared/local/localShadowRepo.ts b/src/shared/local/localShadowRepo.ts index 625ab2fc..7e2b8630 100644 --- a/src/shared/local/localShadowRepo.ts +++ b/src/shared/local/localShadowRepo.ts @@ -141,6 +141,17 @@ export class ShadowRepo { public async getStatus(noCache = false): Promise { this.logger.trace(`start: getStatus (noCache = ${noCache})`); + if (env.getBoolean('SF_SOURCE_TRACKING_ASSUME_SYNCED')) { + if (this.status?.length !== 0) { + this.logger.warn( + 'SF_SOURCE_TRACKING_ASSUME_SYNCED is set. Skipping local status check and assuming local files match the shadow repo.' + ); + void Lifecycle.getInstance().emitTelemetry({ eventName: 'sourceTrackingAssumeSynced' }); + this.status = []; + } + return this.status; + } + if (!this.status || noCache) { try { // status hasn't been initialized yet diff --git a/test/nuts/local/assumeSynced.nut.ts b/test/nuts/local/assumeSynced.nut.ts new file mode 100644 index 00000000..91dc4306 --- /dev/null +++ b/test/nuts/local/assumeSynced.nut.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * 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. + */ +import path from 'node:path'; +import fs from 'node:fs'; +import { TestSession } from '@salesforce/cli-plugins-testkit'; +import { expect } from 'chai'; +import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; +import { ShadowRepo } from '../../../src/shared/local/localShadowRepo'; + +/* eslint-disable no-unused-expressions */ + +describe('SF_SOURCE_TRACKING_ASSUME_SYNCED (NUT)', () => { + let session: TestSession; + let repo: ShadowRepo; + const registry = new RegistryAccess(); + + before(async () => { + session = await TestSession.create({ + project: { + sourceDir: path.join('test', 'nuts', 'ebikes-lwc'), + }, + devhubAuthStrategy: 'NONE', + }); + }); + + after(async () => { + delete process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED; + await session?.clean(); + }); + + it('initializes shadow repo and sees real changes without env var', async () => { + repo = await ShadowRepo.getInstance({ + orgId: 'assumeSyncedNut', + projectPath: session.project.dir, + packageDirs: [{ path: 'force-app', name: 'force-app', fullPath: path.join(session.project.dir, 'force-app') }], + registry, + }); + + const changes = await repo.getChangedFilenames(); + expect(changes).to.be.an('array').with.length.greaterThan(50); + }); + + it('commits all files to establish a clean baseline', async () => { + const sha = await repo.commitChanges({ + deployedFiles: await repo.getChangedFilenames(), + message: 'baseline commit', + }); + expect(sha).to.be.a('string'); + expect(await repo.getChangedFilenames()).to.have.lengthOf(0); + }); + + it('with env var set, reports no changes even after file modifications', async () => { + // modify a file + const target = path.join( + session.project.dir, + 'force-app', + 'main', + 'default', + 'permissionsets', + 'ebikes.permissionset-meta.xml' + ); + const original = await fs.promises.readFile(target, 'utf8'); + await fs.promises.writeFile(target, `${original}\n`); + + // add a new file + await fs.promises.writeFile( + path.join(session.project.dir, 'force-app', 'main', 'default', 'classes', 'NewClass.cls'), + 'public class NewClass {}' + ); + + // set the env var + process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED = 'true'; + + // force a fresh status check — should still return empty + const status = await repo.getStatus(true); + expect(status).to.deep.equal([]); + expect(await repo.getChangedFilenames()).to.deep.equal([]); + expect(await repo.getDeletes()).to.deep.equal([]); + expect(await repo.getNonDeletes()).to.deep.equal([]); + expect(await repo.getAdds()).to.deep.equal([]); + expect(await repo.getModifies()).to.deep.equal([]); + }); + + it('commitChanges with explicit files still works when env var is set', async () => { + const explicitFile = path.normalize('force-app/main/default/classes/NewClass.cls'); + const sha = await repo.commitChanges({ + deployedFiles: [explicitFile], + message: 'explicit deploy with assume-synced', + }); + expect(sha).to.be.a('string'); + }); + + it('after unsetting env var, getStatus(true) reveals real changes', async () => { + delete process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED; + + const status = await repo.getStatus(true); + // the modified permissionset should show up (NewClass was already committed above) + expect(status.length).to.be.greaterThan(0); + const filenames = await repo.getChangedFilenames(); + expect(filenames.some((f) => f.includes('ebikes.permissionset-meta.xml'))).to.be.true; + }); +}); diff --git a/test/unit/localShadowRepo.test.ts b/test/unit/localShadowRepo.test.ts index 7d59a0e8..dd833c0c 100644 --- a/test/unit/localShadowRepo.test.ts +++ b/test/unit/localShadowRepo.test.ts @@ -31,6 +31,93 @@ afterEach(() => { describe('localShadowRepo', () => { const registry = new RegistryAccess(); + + describe('SF_SOURCE_TRACKING_ASSUME_SYNCED', () => { + let projectDir: string; + let shadowRepo: ShadowRepo; + + beforeEach(async () => { + process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED = 'true'; + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localShadowRepoTest')); + fs.mkdirSync(path.join(projectDir, 'force-app')); + fs.writeFileSync(path.join(projectDir, 'force-app', 'Foo.cls'), 'public class Foo {}'); + + shadowRepo = await ShadowRepo.getInstance({ + orgId: '00D000000000002', + registry, + projectPath: projectDir, + packageDirs: [ + { + name: 'force-app', + fullPath: path.join(projectDir, 'force-app'), + path: 'force-app', + }, + ], + }); + }); + + afterEach(async () => { + delete process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED; + if (projectDir) await fs.promises.rm(projectDir, { recursive: true }); + }); + + it('returns empty status without calling statusMatrix when set', async () => { + const statusMatrixSpy = sinon.spy(git, 'statusMatrix'); + const status = await shadowRepo.getStatus(true); + + expect(status).to.deep.equal([]); + expect(statusMatrixSpy.called).to.be.false; + }); + + it('returns cached empty status on subsequent calls with noCache=true', async () => { + const statusMatrixSpy = sinon.spy(git, 'statusMatrix'); + + const first = await shadowRepo.getStatus(true); + const second = await shadowRepo.getStatus(true); + const third = await shadowRepo.getStatus(false); + + expect(first).to.deep.equal([]); + expect(second).to.deep.equal([]); + expect(third).to.deep.equal([]); + expect(statusMatrixSpy.called).to.be.false; + }); + + it('downstream methods return empty results', async () => { + expect(await shadowRepo.getChangedRows()).to.deep.equal([]); + expect(await shadowRepo.getChangedFilenames()).to.deep.equal([]); + expect(await shadowRepo.getDeletes()).to.deep.equal([]); + expect(await shadowRepo.getDeleteFilenames()).to.deep.equal([]); + expect(await shadowRepo.getNonDeletes()).to.deep.equal([]); + expect(await shadowRepo.getNonDeleteFilenames()).to.deep.equal([]); + expect(await shadowRepo.getAdds()).to.deep.equal([]); + expect(await shadowRepo.getAddFilenames()).to.deep.equal([]); + expect(await shadowRepo.getModifies()).to.deep.equal([]); + expect(await shadowRepo.getModifyFilenames()).to.deep.equal([]); + }); + + it('commitChanges succeeds with no files when env var is set', async () => { + const result = await shadowRepo.commitChanges({ deployedFiles: [], deletedFiles: [] }); + expect(result).to.equal('no files to commit'); + }); + + it('resumes real scanning after env var is unset and noCache=true', async () => { + // first call with env var set — returns empty + const emptyStatus = await shadowRepo.getStatus(true); + expect(emptyStatus).to.deep.equal([]); + + // unset the env var + delete process.env.SF_SOURCE_TRACKING_ASSUME_SYNCED; + + // without noCache, returns the cached empty array + const stillCached = await shadowRepo.getStatus(false); + expect(stillCached).to.deep.equal([]); + + // with noCache=true, should actually scan and find the uncommitted file + const realStatus = await shadowRepo.getStatus(true); + expect(realStatus.length).to.be.greaterThan(0); + }); + }); + it('does not add same file multiple times', async () => { let projectDir!: string; try { diff --git a/yarn.lock b/yarn.lock index 63b8cee7..ad420d91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -586,6 +586,21 @@ node-fetch "^2.6.1" xml2js "^0.6.2" +"@jsforce/jsforce-node@^3.10.17": + version "3.10.19" + resolved "https://registry.yarnpkg.com/@jsforce/jsforce-node/-/jsforce-node-3.10.19.tgz#ccbc539c12f4f7dff9cfdcc6cfb8f07bd840f731" + integrity sha512-k7i2Tntu1fLvkMtRcKDFU64/Fr2M692ECtbwIGX6hcOh5mj+jrMa1tlvcdwffxAMl+lPYCXnY2bjErxWmP84zA== + dependencies: + "@sindresorhus/is" "^4" + base64url "^3.0.1" + csv-parse "^5.5.2" + csv-stringify "^6.6.0" + faye "^1.4.0" + form-data "^4.0.4" + multistream "^3.1.0" + undici "^8.5.0" + xml2js "^0.6.2" + "@jsonjoy.com/base64@^1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz" @@ -707,11 +722,36 @@ zod "^4.1.12" "@salesforce/core@^8.31.2": - version "8.31.2" - resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.31.2.tgz#968448f423b553f726f42c27da6b55c4ec7eb93a" - integrity sha512-naqnq7Z+gbl1LdnyNvrGrNUoeMUQtCOsnrS6DfqeuLMJTFqcL9Dq0/od+xcuqi0+l7HTyH0/gU1BQitWpd1rag== + version "8.32.3" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.32.3.tgz#e7cb6840adf59f88e7a09c8033674aa56f4a3859" + integrity sha512-xtF3SDLphcJPd1zNVtIIqqXHRhqUB1sI1lO8LyCiviFAlhd6ZVmPXZIsThLmOoZaRVTqy+ROnvEWBL00hN9dIg== dependencies: - "@jsforce/jsforce-node" "^3.10.13" + "@jsforce/jsforce-node" "^3.10.17" + "@salesforce/kit" "^3.2.4" + "@salesforce/ts-types" "^2.0.12" + ajv "^8.18.0" + change-case "^4.1.2" + fast-levenshtein "^3.0.0" + faye "^1.4.1" + form-data "^4.0.5" + js2xmlparser "^4.0.1" + jsonwebtoken "9.0.3" + jszip "3.10.1" + memfs "4.38.1" + pino "^9.7.0" + pino-abstract-transport "^1.2.0" + pino-pretty "^11.3.0" + proper-lockfile "^4.1.2" + semver "^7.8.0" + ts-retry-promise "^0.8.1" + zod "^4.1.12" + +"@salesforce/core@^8.32.2": + version "8.32.2" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.32.2.tgz#faa4d7525cebb7c46e6b576d6fb4c0624592d779" + integrity sha512-IenGtnr68o1Pg8WDA5XUDeuqTmdCbAyLCAU1hdbIcqM3kyEfvyaHgou0/KfEx6EokCxJ4MZFJ6MmjL8dY53XGQ== + dependencies: + "@jsforce/jsforce-node" "^3.10.17" "@salesforce/kit" "^3.2.4" "@salesforce/ts-types" "^2.0.12" ajv "^8.18.0" @@ -793,11 +833,11 @@ integrity sha512-FKfvtrYTcvTXE9advzS25/DEY9yJhEyLvStm++eQFtnAaX1pe4G3oGHgiQ0q55BM5+0AlCh0+0CVtQv1t4oJRA== "@salesforce/source-deploy-retrieve@^12.36.6": - version "12.36.6" - resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-12.36.6.tgz#4c1e701cff9c9fa2802e8fa7e3ba2188426d47d1" - integrity sha512-1tz3eUyHp0yIAylrz+njlxGejd08cwPys9LjfBTVeY6a+xOBkTTDgQMLA6OBBZVc0HMPqjkJMKvbtDrAD5MJBA== + version "12.37.2" + resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-12.37.2.tgz#442f3be88d91021de14af6371d9f954689157ce7" + integrity sha512-wgG0RccjQ8CaBEO8woSvvTyxc6V7KIw1GbZlME0bU+Ly+2G8pdPHAm43hrBTBml5MdYVh3J3yZwMyIeRaQxeBA== dependencies: - "@salesforce/core" "^8.31.2" + "@salesforce/core" "^8.32.2" "@salesforce/kit" "^3.2.4" "@salesforce/ts-types" "^2.0.12" "@salesforce/types" "^1.6.0" @@ -2936,17 +2976,6 @@ form-data@^4.0.4, form-data@^4.0.5: hasown "^2.0.4" mime-types "^2.1.35" -form-data@^4.0.5: - version "4.0.6" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" - integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - hasown "^2.0.4" - mime-types "^2.1.35" - fromentries@^1.2.0: version "1.3.2" resolved "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz" @@ -6282,6 +6311,11 @@ undici-types@~6.21.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +undici@^8.5.0: + version "8.7.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-8.7.0.tgz#04c5aae1db34d9867488588b44b8c749dee9baee" + integrity sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ== + unist-util-is@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz"