diff --git a/src/index.ts b/src/index.ts index 2c756293..5babd290 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,4 +7,12 @@ export * from './sourceTracking'; export * from './compatibility'; -export { RemoteSyncInput } from './shared/types'; +export { + RemoteSyncInput, + ChangeOptionType, + ChangeOptions, + LocalUpdateOptions, + ChangeResult, + ConflictError, +} from './shared/types'; +export { getKeyFromObject } from './shared/functions'; diff --git a/src/shared/functions.ts b/src/shared/functions.ts new file mode 100644 index 00000000..048c86fd --- /dev/null +++ b/src/shared/functions.ts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2020, 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 { RemoteChangeElement, ChangeResult } from './types'; + +export const getMetadataKey = (metadataType: string, metadataName: string): string => { + return `${metadataType}__${metadataName}`; +}; + +export const getKeyFromObject = (element: RemoteChangeElement | ChangeResult): string => { + if (element.type && element.name) { + return getMetadataKey(element.type, element.name); + } + throw new Error(`unable to complete key from ${JSON.stringify(element)}`); +}; diff --git a/src/shared/guards.ts b/src/shared/guards.ts new file mode 100644 index 00000000..21512624 --- /dev/null +++ b/src/shared/guards.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2020, 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 { SourceComponent } from '@salesforce/source-deploy-retrieve'; + +export const stringGuard = (input: string | undefined): input is string => { + return typeof input === 'string'; +}; + +export const sourceComponentGuard = (input: SourceComponent | undefined): input is SourceComponent => { + return input instanceof SourceComponent; +}; diff --git a/src/shared/metadataKeys.ts b/src/shared/metadataKeys.ts index 799f0b65..d7773cae 100644 --- a/src/shared/metadataKeys.ts +++ b/src/shared/metadataKeys.ts @@ -6,7 +6,7 @@ */ import * as path from 'path'; import { RemoteSyncInput } from './types'; -import { getMetadataKey } from './remoteSourceTrackingService'; +import { getMetadataKey } from './functions'; // LWC can have child folders (ex: dynamic templates like /templates/noDataIllustration.html const pathAfterFullName = (fileResponse: RemoteSyncInput): string => diff --git a/src/shared/remoteSourceTrackingService.ts b/src/shared/remoteSourceTrackingService.ts index 7d55fc2a..76ec5720 100644 --- a/src/shared/remoteSourceTrackingService.ts +++ b/src/shared/remoteSourceTrackingService.ts @@ -13,8 +13,9 @@ import { ConfigFile, Logger, Org, SfdxError, Messages, fs } from '@salesforce/co import { ComponentStatus } from '@salesforce/source-deploy-retrieve'; import { Dictionary, Optional } from '@salesforce/ts-types'; import { env, toNumber } from '@salesforce/kit'; -import { RemoteSyncInput } from '../shared/types'; +import { RemoteSyncInput, RemoteChangeElement } from '../shared/types'; import { getMetadataKeyFromFileResponse } from './metadataKeys'; +import { getMetadataKey } from './functions'; export type MemberRevision = { serverRevisionCounter: number; @@ -30,13 +31,6 @@ export type SourceMember = { RevisionCounter: number; }; -export type RemoteChangeElement = { - name: string; - type: string; - deleted?: boolean; - modified?: boolean; -}; - // represents the contents of the config file stored in 'maxRevision.json' interface Contents { serverMaxRevisionCounter: number; @@ -52,10 +46,6 @@ export namespace RemoteSourceTrackingService { } } -export const getMetadataKey = (metadataType: string, metadataName: string): string => { - return `${metadataType}__${metadataName}`; -}; - /** * This service handles source tracking of metadata between a local project and an org. * Source tracking state is persisted to .sfdx/orgs//maxRevision.json. diff --git a/src/shared/types.ts b/src/shared/types.ts index 4a51e8aa..a66ed633 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,6 +5,46 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { FileResponse } from '@salesforce/source-deploy-retrieve'; +import { FileResponse, SourceComponent } from '@salesforce/source-deploy-retrieve'; +import { getMetadataKey } from '../shared/functions'; + +export interface ChangeOptions { + origin: 'local' | 'remote'; + state: 'add' | 'delete' | 'modify' | 'nondelete'; + format: 'ChangeResult' | 'SourceComponent' | 'string' | 'ChangeResultWithPaths'; +} export type RemoteSyncInput = Pick; + +export type StatusOutputRow = Pick & { + conflict?: boolean; + ignored?: boolean; +} & Pick; + +export interface LocalUpdateOptions { + files?: string[]; + deletedFiles?: string[]; +} + +export type RemoteChangeElement = { + name: string; + type: string; + deleted?: boolean; + modified?: boolean; +}; + +/** + * Summary type that supports both local and remote change types + */ +export type ChangeResult = Partial & { + origin: 'local' | 'remote'; + filenames?: string[]; +}; + +export interface ConflictError { + message: string; + name: 'conflict'; + conflicts: ChangeResult[]; +} + +export type ChangeOptionType = ChangeResult | SourceComponent | string; diff --git a/src/sourceTracking.ts b/src/sourceTracking.ts index 239f07f4..abdc9e49 100644 --- a/src/sourceTracking.ts +++ b/src/sourceTracking.ts @@ -14,49 +14,26 @@ import { ComponentStatus, SourceComponent, FileResponse, + ForceIgnore, + RegistryAccess, } from '@salesforce/source-deploy-retrieve'; +import { MetadataTransformerFactory } from '@salesforce/source-deploy-retrieve/lib/src/convert/transformers/metadataTransformerFactory'; +import { ConvertContext } from '@salesforce/source-deploy-retrieve/lib/src/convert/convertContext'; -import { RemoteSourceTrackingService, RemoteChangeElement, getMetadataKey } from './shared/remoteSourceTrackingService'; +import { RemoteSourceTrackingService } from './shared/remoteSourceTrackingService'; import { ShadowRepo } from './shared/localShadowRepo'; import { filenamesToVirtualTree } from './shared/filenamesToVirtualTree'; -import { RemoteSyncInput } from './shared/types'; - -export const getKeyFromObject = (element: RemoteChangeElement | ChangeResult): string => { - if (element.type && element.name) { - return getMetadataKey(element.type, element.name); - } - throw new Error(`unable to complete key from ${JSON.stringify(element)}`); -}; - -// external users of SDR might need to convert a fileResponse to a key -export const getKeyFromStrings = getMetadataKey; - -export type ChangeOptionType = ChangeResult | SourceComponent | string; - -export interface ChangeOptions { - origin: 'local' | 'remote'; - state: 'add' | 'delete' | 'modify' | 'nondelete'; - format: 'ChangeResult' | 'SourceComponent' | 'string' | 'ChangeResultWithPaths'; -} - -export interface LocalUpdateOptions { - files?: string[]; - deletedFiles?: string[]; -} - -/** - * Summary type that supports both local and remote change types - */ -export type ChangeResult = Partial & { - origin: 'local' | 'remote'; - filenames?: string[]; -}; - -export interface ConflictError { - message: string; - name: 'conflict'; - conflicts: ChangeResult[]; -} +import { + RemoteSyncInput, + StatusOutputRow, + ChangeOptions, + ChangeResult, + ChangeOptionType, + LocalUpdateOptions, + RemoteChangeElement, +} from './shared/types'; +import { stringGuard, sourceComponentGuard } from './shared/guards'; +import { getKeyFromObject, getMetadataKey } from './shared/functions'; export interface SourceTrackingOptions { org: Org; @@ -80,6 +57,9 @@ export class SourceTracking extends AsyncCreatable { // remote and local tracking may not exist if not initialized private localRepo!: ShadowRepo; private remoteSourceTrackingService!: RemoteSourceTrackingService; + private forceIgnore!: ForceIgnore; + private registry!: RegistryAccess; + private transformerFactory!: MetadataTransformerFactory; public constructor(options: SourceTrackingOptions) { super(options); @@ -132,10 +112,48 @@ export class SourceTracking extends AsyncCreatable { return componentSet; } + /** + * Does most of the work for the force:source:status command. + * Outputs need a bit of massage since this aims to provide nice json. + * + * @param local you want local status + * @param remote you want remote status + * @returns StatusOutputRow[] + */ + + public async getStatus({ local, remote }: { local: boolean; remote: boolean }): Promise { + let results: StatusOutputRow[] = []; + if (local) { + results = results.concat(await this.getLocalStatusRows()); + } + if (remote) { + await this.ensureRemoteTracking(true); + const [remoteDeletes, remoteModifies] = await Promise.all([ + this.getChanges({ origin: 'remote', state: 'delete', format: 'ChangeResult' }), + this.getChanges({ origin: 'remote', state: 'nondelete', format: 'ChangeResultWithPaths' }), + ]); + results = results.concat( + ( + await Promise.all(remoteDeletes.concat(remoteModifies).map((item) => this.remoteChangesToOutputRows(item))) + ).flat(1) + ); + } + if (local && remote) { + // keys like ApexClass__MyClass.cls + const conflictFiles = (await this.getConflicts()).flatMap((conflict) => conflict.filenames).filter(stringGuard); + results = results.map((row) => ({ + ...row, + conflict: !!row.filePath && conflictFiles.includes(row.filePath), + })); + } + return results; + } + /** * Get metadata changes made locally and in the org. * * @returns local and remote changed metadata + * */ public async getChanges(options?: ChangeOptions): Promise { if (options?.origin === 'local') { @@ -409,7 +427,8 @@ export class SourceTracking extends AsyncCreatable { if (filename && elementMap.has(filename)) { // add the type/name from the componentSet onto the element elementMap.set(filename, { - ...(elementMap.get(filename) as ChangeResult), + origin: 'remote', + ...elementMap.get(filename), type: matchingComponent.type.name, name: matchingComponent.fullName, }); @@ -422,23 +441,32 @@ export class SourceTracking extends AsyncCreatable { : Array.from(new Set(elementMap.values())); } + /** + * Compares local and remote changes to detect conflicts + */ public async getConflicts(): Promise { // we're going to need have both initialized await Promise.all([this.ensureRemoteTracking(), this.ensureLocalTracking()]); + // Strategy: check local changes first (since it'll be faster) to avoid callout + // early return if either local or remote is empty const localChanges = await this.getChanges({ state: 'nondelete', origin: 'local', format: 'ChangeResult', }); - + if (localChanges.length === 0) { + return []; + } const remoteChanges = await this.getChanges({ origin: 'remote', state: 'nondelete', // remote adds won't have a filename, so we ask for it to be resolved format: 'ChangeResultWithPaths', }); - + if (remoteChanges.length === 0) { + return []; + } // index them by filename const fileNameIndex = new Map(); remoteChanges.map((change) => { @@ -460,6 +488,33 @@ export class SourceTracking extends AsyncCreatable { return Array.from(conflicts); } + private async getLocalStatusRows(): Promise { + await this.ensureLocalTracking(); + let results: StatusOutputRow[] = []; + const localDeletes = this.populateTypesAndNames({ + elements: await this.getChanges({ origin: 'local', state: 'delete', format: 'ChangeResult' }), + excludeUnresolvable: true, + resolveDeleted: true, + }); + + const localAdds = this.populateTypesAndNames({ + elements: await this.getChanges({ origin: 'local', state: 'add', format: 'ChangeResult' }), + excludeUnresolvable: true, + }); + + const localModifies = this.populateTypesAndNames({ + elements: await this.getChanges({ origin: 'local', state: 'modify', format: 'ChangeResult' }), + excludeUnresolvable: true, + }); + + results = results.concat( + localAdds.flatMap((item) => this.localChangesToOutputRow(item, 'add')), + localModifies.flatMap((item) => this.localChangesToOutputRow(item, 'modify')), + localDeletes.flatMap((item) => this.localChangesToOutputRow(item, 'delete')) + ); + return results; + } + /** * uses SDR to translate remote metadata records into local file paths */ @@ -507,10 +562,11 @@ export class SourceTracking extends AsyncCreatable { matchingComponent.xml } and maybe ${matchingComponent.walkContent().toString()}` ); - const key = getKeyFromStrings(matchingComponent.type.name, matchingComponent.fullName); + const key = getMetadataKey(matchingComponent.type.name, matchingComponent.fullName); elementMap.set(key, { - ...(elementMap.get(key) as ChangeResult), + ...elementMap.get(key), modified: true, + origin: 'remote', filenames: [matchingComponent.xml as string, ...matchingComponent.walkContent()].filter( (filename) => filename ), @@ -540,15 +596,74 @@ export class SourceTracking extends AsyncCreatable { } throw new Error(`unable to get local changes for state ${state as string}`); } -} -export const stringGuard = (input: string | undefined): input is string => { - return typeof input === 'string'; -}; + private localChangesToOutputRow(input: ChangeResult, localType: 'delete' | 'modify' | 'add'): StatusOutputRow[] { + this.logger.debug('converting ChangeResult to a row', input); + this.forceIgnore = this.forceIgnore ?? new ForceIgnore(); -const sourceComponentGuard = (input: SourceComponent | undefined): input is SourceComponent => { - return input instanceof SourceComponent; -}; + const baseObject = { + type: input.type ?? '', + origin: 'local', + state: localType, + fullName: input.name ?? '', + }; + + if (input.filenames) { + return input.filenames.map((filename) => ({ + ...baseObject, + filePath: filename, + ignored: this.forceIgnore.denies(filename), + origin: 'local', + })); + } + throw new Error('no filenames found for local ChangeResult'); + } + + private async remoteChangesToOutputRows(input: ChangeResult): Promise { + this.logger.debug('converting ChangeResult to a row', input); + this.forceIgnore = this.forceIgnore ?? new ForceIgnore(); + const baseObject: StatusOutputRow = { + type: input.type ?? '', + origin: input.origin, + state: stateFromChangeResult(input), + fullName: input.name ?? '', + }; + // it's easy to check ignores if the filePaths exist locally + if (input.filenames?.length) { + return input.filenames.map((filename) => ({ + ...baseObject, + filePath: filename, + ignored: this.forceIgnore.denies(filename), + })); + } + // when the file doesn't exist locally, there are no filePaths. + // we can determine where the filePath *would* go using SDR's transformers stuff + const fakeFilePaths = await this.filesPathFromNonLocalSourceComponent({ + fullName: baseObject.fullName, + typeName: baseObject.type, + }); + return [{ ...baseObject, ignored: fakeFilePaths.some((filePath) => this.forceIgnore.denies(filePath)) }]; + } + + // TODO: This goes in SDR on SourceComponent + // we don't have a local copy of the component + // this uses SDR's approach to determine what the filePath would be if the component were written locally + private async filesPathFromNonLocalSourceComponent({ + fullName, + typeName, + }: { + fullName: string; + typeName: string; + }): Promise { + this.registry = this.registry ?? new RegistryAccess(); + const component = new SourceComponent({ name: fullName, type: this.registry.getTypeByName(typeName) }); + this.transformerFactory = + this.transformerFactory ?? new MetadataTransformerFactory(this.registry, new ConvertContext()); + const transformer = this.transformerFactory.getTransformer(component); + const writePaths = await transformer.toSourceFormat(component); + return writePaths.map((writePath) => writePath.output); + } +} const remoteFilterByState = { add: (change: RemoteChangeElement): boolean => !change.deleted && !change.modified, @@ -556,3 +671,13 @@ const remoteFilterByState = { delete: (change: RemoteChangeElement): boolean => change.deleted === true, nondelete: (change: RemoteChangeElement): boolean => !change.deleted, }; + +const stateFromChangeResult = (input: ChangeResult): 'add' | 'delete' | 'modify' => { + if (input.deleted) { + return 'delete'; + } + if (input.modified) { + return 'modify'; + } + return 'add'; +}; diff --git a/test/nuts/localTrackingScenario.nut.ts b/test/nuts/localTrackingScenario.nut.ts index 5157fbaa..b191e649 100644 --- a/test/nuts/localTrackingScenario.nut.ts +++ b/test/nuts/localTrackingScenario.nut.ts @@ -54,6 +54,15 @@ describe('end-to-end-test for local tracking', () => { ).to.be.a('string'); }); + it('commits no changes when there are none to commit', async () => { + expect( + await repo.commitChanges({ + deployedFiles: await repo.getChangedFilenames(), + message: 'test commit message', + }) + ).to.equal('no files to commit'); + }); + it('should see no changes after commit (and reconnect to repo)', async () => { // verify the local tracking files/directories expect(fs.existsSync(repo.gitDir)); @@ -69,6 +78,7 @@ describe('end-to-end-test for local tracking', () => { await fs.writeFile(filePath, newContent); await repo.getStatus(true); expect(await repo.getChangedRows()).to.have.lengthOf(1); + expect(await repo.getModifyFilenames()).to.deep.equal([filename]); expect(await repo.getChangedFilenames()).to.deep.equal([path.normalize(filename)]); expect(await repo.getNonDeletes()).to.have.lengthOf(1); expect(await repo.getNonDeleteFilenames()).to.deep.equal([path.normalize(filename)]); @@ -95,6 +105,8 @@ describe('end-to-end-test for local tracking', () => { expect(await repo.getChangedRows()).to.have.lengthOf(3); expect(await repo.getChangedFilenames()).to.include(path.normalize(filename)); expect(await repo.getDeletes()).to.have.lengthOf(1); + expect(await repo.getAdds()).to.have.lengthOf(1); + expect(await repo.getAddFilenames()).to.deep.equals([filename]); }); it('changes remain after bad commit (simulate a failed deploy)', async () => { @@ -120,4 +132,10 @@ describe('end-to-end-test for local tracking', () => { expect(await repo.getChangedRows()).to.have.lengthOf(0); }); + + it('can delete the local change files', async () => { + const deleteResult = await repo.delete(); + expect(deleteResult).to.equal(repo.gitDir); + expect(fs.existsSync(repo.gitDir)).to.equal(false); + }); });