Skip to content

Commit b525feb

Browse files
fix: apply .forceignore after converting to source format
1 parent 298b93d commit b525feb

File tree

5 files changed

+319
-7
lines changed

5 files changed

+319
-7
lines changed

src/client/retrieveExtract.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,39 @@ export const extract = async ({
5959
fsPaths: [pkg.zipTreeLocation],
6060
registry,
6161
tree,
62+
useFsForceIgnore: false,
6263
})
6364
.getSourceComponents()
6465
.toArray();
66+
67+
// this is intentional sequential
68+
// eslint-disable-next-line no-await-in-loop
69+
const convertResult = await converter.convert(retrievedComponents, 'source', outputConfig);
70+
71+
// now that the components are in source format we can apply ForceIgnore
72+
const convertedComponents = convertResult?.converted ?? [];
73+
const filteredComponents = convertedComponents.filter((component) => {
74+
// Only apply ForceIgnore if component has an xml path
75+
if (!component.xml) {
76+
return true; // Include components without xml path
77+
}
78+
79+
const forceIgnore = component.getForceIgnore();
80+
const shouldInclude = !forceIgnore.denies(component.xml);
81+
if (!shouldInclude) {
82+
logger.debug(`Component ${component.xml} excluded by .forceignore`);
83+
}
84+
return shouldInclude;
85+
});
86+
87+
components.push(...filteredComponents);
88+
6589
if (merge) {
6690
partialDeleteFileResponses.push(
67-
...handlePartialDeleteMerges({ retrievedComponents, tree, mainComponents, logger })
91+
...handlePartialDeleteMerges({ retrievedComponents: filteredComponents, tree, mainComponents, logger })
6892
);
6993
}
7094

71-
// this is intentional sequential
72-
// eslint-disable-next-line no-await-in-loop
73-
const convertResult = await converter.convert(retrievedComponents, 'source', outputConfig);
74-
components.push(...(convertResult?.converted ?? []));
7595
// additional partialDelete logic for decomposed types are handled in the transformer
7696
partialDeleteFileResponses.push(...(convertResult?.deleted ?? []));
7797
}

src/collections/componentSet.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,9 @@ export class ComponentSet extends LazyCollection<MetadataComponent> {
168168
return { fsPaths: [given] };
169169
}
170170
};
171-
const { fsPaths, registry, tree, include, fsDeletePaths = [] } = parseFromSourceInputs(input);
171+
const { fsPaths, registry, tree, include, fsDeletePaths = [], useFsForceIgnore = true } = parseFromSourceInputs(input);
172172

173-
const resolver = new MetadataResolver(registry, tree);
173+
const resolver = new MetadataResolver(registry, tree, useFsForceIgnore);
174174
const set = new ComponentSet([], registry);
175175
const buildComponents = (paths: string[], destructiveType?: DestructiveChangesType): void => {
176176
for (const path of paths) {

src/collections/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export type FromSourceOptions = {
4343
* File paths or directory paths of deleted components, i.e., destructive changes.
4444
*/
4545
fsDeletePaths?: string[];
46+
/**
47+
* Whether to use filesystem-based ForceIgnore during component resolution.
48+
*/
49+
useFsForceIgnore?: boolean;
4650
} & OptionalTreeRegistryOptions;
4751

4852
export type FromManifestOptions = {
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { expect } from 'chai';
8+
import { createSandbox } from 'sinon';
9+
import { Logger } from '@salesforce/core';
10+
import { SourceComponent } from '../../src/resolve/sourceComponent';
11+
import { ComponentSet } from '../../src/collections/componentSet';
12+
import { RegistryAccess } from '../../src/registry/registryAccess';
13+
import { ZipTreeContainer } from '../../src/resolve/treeContainers';
14+
import { MetadataConverter } from '../../src/convert/metadataConverter';
15+
import { extract } from '../../src/client/retrieveExtract';
16+
import { MetadataApiRetrieveOptions } from '../../src/client/types';
17+
18+
19+
describe('retrieveExtract Integration', () => {
20+
const sandbox = createSandbox();
21+
let logger: Logger;
22+
let registry: RegistryAccess;
23+
24+
beforeEach(() => {
25+
logger = Logger.childFromRoot('test');
26+
registry = new RegistryAccess();
27+
sandbox.stub(logger, 'debug');
28+
});
29+
30+
afterEach(() => {
31+
sandbox.restore();
32+
});
33+
34+
describe('ForceIgnore behavior during retrieve', () => {
35+
it('should disable ForceIgnore during initial processing (useFsForceIgnore: false)', async () => {
36+
// Mock zip buffer and tree
37+
const mockZipBuffer = Buffer.from('mock zip content');
38+
const mockTree = {} as ZipTreeContainer;
39+
40+
// Mock ComponentSet.fromSource to verify useFsForceIgnore is false
41+
const mockComponentSet = new ComponentSet([], registry);
42+
sandbox.stub(mockComponentSet, 'getSourceComponents').returns({ toArray: () => [] } as never);
43+
const fromSourceStub = sandbox.stub(ComponentSet, 'fromSource').returns(mockComponentSet);
44+
45+
// Mock ZipTreeContainer.create
46+
sandbox.stub(ZipTreeContainer, 'create').resolves(mockTree);
47+
48+
// Mock MetadataConverter
49+
sandbox.stub(MetadataConverter.prototype, 'convert').resolves({
50+
converted: [],
51+
deleted: []
52+
});
53+
54+
const options: MetadataApiRetrieveOptions = {
55+
output: '/test/output',
56+
registry,
57+
merge: false,
58+
usernameOrConnection: 'test@example.com'
59+
};
60+
61+
await extract({
62+
zip: mockZipBuffer,
63+
options,
64+
logger,
65+
});
66+
67+
// Verify that ComponentSet.fromSource was called with useFsForceIgnore: false
68+
expect(fromSourceStub.calledOnce).to.be.true;
69+
const fromSourceArgs = fromSourceStub.firstCall.args[0];
70+
expect(fromSourceArgs).to.have.property('useFsForceIgnore', false);
71+
});
72+
73+
it('should apply ForceIgnore filtering after conversion to source format', async () => {
74+
const mockZipBuffer = Buffer.from('mock zip content');
75+
const mockTree = {} as ZipTreeContainer;
76+
77+
sandbox.stub(ZipTreeContainer, 'create').resolves(mockTree);
78+
79+
const mockComponentSet = new ComponentSet([], registry);
80+
sandbox.stub(mockComponentSet, 'getSourceComponents').returns({ toArray: () => [] } as never);
81+
sandbox.stub(ComponentSet, 'fromSource').returns(mockComponentSet);
82+
83+
84+
// Create real SourceComponent instances for testing
85+
const allowedComponent = new SourceComponent({
86+
name: 'AllowedClass',
87+
type: registry.getTypeByName('ApexClass'),
88+
xml: '/test/force-app/main/default/classes/AllowedClass.cls-meta.xml',
89+
content: '/test/force-app/main/default/classes/AllowedClass.cls'
90+
});
91+
92+
const ignoredComponent = new SourceComponent({
93+
name: 'IgnoredClass',
94+
type: registry.getTypeByName('ApexClass'),
95+
xml: '/test/force-app/main/default/classes/IgnoredClass.cls-meta.xml',
96+
content: '/test/force-app/main/default/classes/IgnoredClass.cls'
97+
});
98+
99+
const noXmlComponent = new SourceComponent({
100+
name: 'NoXmlComponent',
101+
type: registry.getTypeByName('StaticResource'),
102+
content: '/test/force-app/main/default/staticresources/test.resource'
103+
});
104+
105+
// Mock getForceIgnore to control filtering behavior with proper typing
106+
sandbox.stub(allowedComponent, 'getForceIgnore').returns({
107+
denies: () => false, // This should NOT be ignored
108+
accepts: () => true
109+
} as unknown as ReturnType<SourceComponent['getForceIgnore']>);
110+
111+
sandbox.stub(ignoredComponent, 'getForceIgnore').returns({
112+
denies: (filePath: string) => filePath.includes('IgnoredClass'), // This SHOULD be ignored
113+
accepts: (filePath: string) => !filePath.includes('IgnoredClass')
114+
} as unknown as ReturnType<SourceComponent['getForceIgnore']>);
115+
116+
sandbox.stub(noXmlComponent, 'getForceIgnore').returns({
117+
denies: () => false,
118+
accepts: () => true
119+
} as unknown as ReturnType<SourceComponent['getForceIgnore']>);
120+
121+
// Mock converter to return test data that will trigger the filtering logic
122+
sandbox.stub(MetadataConverter.prototype, 'convert').resolves({
123+
converted: [allowedComponent, ignoredComponent, noXmlComponent],
124+
deleted: []
125+
});
126+
127+
128+
const options: MetadataApiRetrieveOptions = {
129+
output: '/test/output',
130+
registry,
131+
merge: false,
132+
usernameOrConnection: 'test@example.com'
133+
};
134+
135+
const result = await extract({
136+
zip: mockZipBuffer,
137+
options,
138+
logger,
139+
});
140+
141+
142+
expect(result.componentSet).to.be.instanceOf(ComponentSet);
143+
expect(result.componentSet.getSourceComponents().toArray()).to.have.length(2);
144+
expect(result.partialDeleteFileResponses).to.be.an('array');
145+
146+
});
147+
148+
it('should handle merge operations with forceIgnoredPaths', async () => {
149+
const mockForceIgnoredPaths = new Set(['/ignored/path1', '/ignored/path2']);
150+
const mainComponents = new ComponentSet([], registry);
151+
mainComponents.forceIgnoredPaths = mockForceIgnoredPaths;
152+
153+
const mockZipBuffer = Buffer.from('mock zip content');
154+
const mockTree = {} as ZipTreeContainer;
155+
156+
sandbox.stub(ZipTreeContainer, 'create').resolves(mockTree);
157+
158+
const mockComponentSet = new ComponentSet([], registry);
159+
sandbox.stub(mockComponentSet, 'getSourceComponents').returns({ toArray: () => [] } as never);
160+
sandbox.stub(ComponentSet, 'fromSource').returns(mockComponentSet);
161+
162+
const convertStub = sandbox.stub(MetadataConverter.prototype, 'convert').resolves({
163+
converted: [],
164+
deleted: []
165+
});
166+
167+
const options: MetadataApiRetrieveOptions = {
168+
output: '/test/output',
169+
registry,
170+
merge: true,
171+
usernameOrConnection: 'test@example.com'
172+
};
173+
174+
await extract({
175+
zip: mockZipBuffer,
176+
options,
177+
logger,
178+
mainComponents
179+
});
180+
181+
// Verify that outputConfig was called correctly
182+
expect(convertStub.calledOnce).to.be.true;
183+
const outputConfig = convertStub.firstCall.args[2];
184+
expect(outputConfig).to.have.property('type', 'merge');
185+
});
186+
});
187+
});

test/collections/componentSet.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,4 +1475,105 @@ describe('ComponentSet', () => {
14751475
]);
14761476
});
14771477
});
1478+
1479+
describe('useFsForceIgnore option', () => {
1480+
it('should accept useFsForceIgnore option in fromSource without throwing errors', () => {
1481+
const registry = new RegistryAccess();
1482+
1483+
// Test that the option is accepted without throwing compilation errors
1484+
expect(() => {
1485+
ComponentSet.fromSource({
1486+
fsPaths: ['nonexistent'],
1487+
registry,
1488+
useFsForceIgnore: false,
1489+
});
1490+
}).to.throw();
1491+
1492+
expect(() => {
1493+
ComponentSet.fromSource({
1494+
fsPaths: ['nonexistent'],
1495+
registry,
1496+
useFsForceIgnore: true,
1497+
});
1498+
}).to.throw();
1499+
});
1500+
1501+
it('should pass useFsForceIgnore to MetadataResolver correctly', () => {
1502+
const registry = new RegistryAccess();
1503+
1504+
let capturedUseFsForceIgnore: boolean | undefined;
1505+
1506+
$$.SANDBOX.stub(MetadataResolver.prototype, 'getComponentsFromPath').callsFake(function(this: MetadataResolver) {
1507+
// Access the private useFsForceIgnore property through reflection
1508+
capturedUseFsForceIgnore = (this as any).useFsForceIgnore;
1509+
return [];
1510+
});
1511+
1512+
// Test with useFsForceIgnore: false
1513+
ComponentSet.fromSource({
1514+
fsPaths: ['.'],
1515+
registry,
1516+
tree: manifestFiles.TREE,
1517+
useFsForceIgnore: false,
1518+
});
1519+
1520+
expect(capturedUseFsForceIgnore).to.be.false;
1521+
1522+
capturedUseFsForceIgnore = undefined;
1523+
1524+
ComponentSet.fromSource({
1525+
fsPaths: ['.'],
1526+
registry,
1527+
tree: manifestFiles.TREE,
1528+
useFsForceIgnore: true,
1529+
});
1530+
1531+
expect(capturedUseFsForceIgnore).to.be.true;
1532+
});
1533+
1534+
it('should preserve forceIgnoredPaths when useFsForceIgnore is false', () => {
1535+
const registry = new RegistryAccess();
1536+
1537+
// Create a mock resolver that sets some ignored paths
1538+
const mockIgnoredPaths = new Set(['/test/ignored1', '/test/ignored2']);
1539+
const getComponentsStub = $$.SANDBOX.stub(MetadataResolver.prototype, 'getComponentsFromPath').returns([]);
1540+
1541+
const originalFromSource = ComponentSet.fromSource;
1542+
$$.SANDBOX.stub(ComponentSet, 'fromSource').callsFake((options) => {
1543+
const result = originalFromSource.call(ComponentSet, options);
1544+
result.forceIgnoredPaths = mockIgnoredPaths;
1545+
return result;
1546+
});
1547+
1548+
const componentSet = ComponentSet.fromSource({
1549+
fsPaths: ['.'],
1550+
registry,
1551+
tree: manifestFiles.TREE,
1552+
useFsForceIgnore: false,
1553+
});
1554+
1555+
expect(componentSet.forceIgnoredPaths).to.equal(mockIgnoredPaths);
1556+
expect(getComponentsStub.called).to.be.true;
1557+
});
1558+
1559+
it('should default useFsForceIgnore to true when not specified', () => {
1560+
const registry = new RegistryAccess();
1561+
1562+
let capturedUseFsForceIgnore: boolean | undefined;
1563+
1564+
$$.SANDBOX.stub(MetadataResolver.prototype, 'getComponentsFromPath').callsFake(function(this: MetadataResolver) {
1565+
// Access the private useFsForceIgnore property through reflection
1566+
capturedUseFsForceIgnore = (this as any).useFsForceIgnore;
1567+
return [];
1568+
});
1569+
1570+
ComponentSet.fromSource({
1571+
fsPaths: ['.'],
1572+
registry,
1573+
tree: manifestFiles.TREE,
1574+
// useFsForceIgnore not specified, should default to true
1575+
});
1576+
expect(capturedUseFsForceIgnore).to.be.true;
1577+
});
1578+
});
14781579
});

0 commit comments

Comments
 (0)