Skip to content

Commit a148a36

Browse files
committed
fix: distributed .gitignore and loose pkgDir matching
1 parent 8b9a0b6 commit a148a36

9 files changed

+200
-18
lines changed

.mocharc.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"require": "test/init.js, ts-node/register, source-map-support/register",
33
"watch-extensions": "ts",
4+
"watch-files": ["src", "test"],
45
"recursive": true,
56
"reporter": "spec",
67
"timeout": 5000

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"pretest": "sf-compile-test",
2525
"prune:dead": "ts-prune | grep -v 'source-deploy-retrieve' | grep -v 'index.ts'",
2626
"test": "sf-test",
27-
"test:nuts": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel"
27+
"test:nuts": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel",
28+
"test:nuts:local": "mocha \"**/local*.nut.ts\" --slow 4500 --timeout 600000 --parallel"
2829
},
2930
"keywords": [
3031
"force",

src/shared/functions.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8+
import { sep } from 'path';
9+
import { isString } from '@salesforce/ts-types';
810
import { SourceComponent } from '@salesforce/source-deploy-retrieve';
911
import { RemoteChangeElement, ChangeResult } from './types';
10-
1112
export const getMetadataKey = (metadataType: string, metadataName: string): string => {
1213
return `${metadataType}__${metadataName}`;
1314
};
@@ -20,3 +21,19 @@ export const getKeyFromObject = (element: RemoteChangeElement | ChangeResult): s
2021
};
2122

2223
export const isBundle = (cmp: SourceComponent): boolean => cmp.type.strategies?.adapter === 'bundle';
24+
25+
/**
26+
* Verify that a filepath starts exactly with a complete parent path
27+
* ex: '/foo/bar-extra/baz'.startsWith('foo/bar') would be true, but this function understands that they are not in the same folder
28+
*/
29+
export const pathIsInFolder = (filePath: string, folder: string, separator = sep): boolean => {
30+
const biggerStringParts = filePath.split(separator).filter(nonEmptyStringFilter);
31+
return folder
32+
.split(separator)
33+
.filter(nonEmptyStringFilter)
34+
.every((part, index) => part === biggerStringParts[index]);
35+
};
36+
37+
const nonEmptyStringFilter = (value: string): boolean => {
38+
return isString(value) && value.length > 0;
39+
};

src/shared/localShadowRepo.ts

+44-12
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as path from 'path';
1010
import * as os from 'os';
1111
import { NamedPackageDir, Logger, fs } from '@salesforce/core';
1212
import * as git from 'isomorphic-git';
13+
import { pathIsInFolder } from './functions';
1314

1415
const gitIgnoreFileName = '.gitignore';
1516
const stashedGitIgnoreFileName = '.BAK.gitignore';
@@ -43,6 +44,9 @@ interface CommitRequest {
4344
message?: string;
4445
}
4546

47+
// const gitIgnoreLocator = async (filepath: string) => {
48+
// ( return filepath.endsWith(gitIgnoreFileName) ? filepath : null).filter(isString);
49+
// }
4650
export class ShadowRepo {
4751
private static instance: ShadowRepo;
4852

@@ -52,6 +56,7 @@ export class ShadowRepo {
5256
private packageDirs!: NamedPackageDir[];
5357
private status!: StatusRow[];
5458
private logger!: Logger;
59+
private gitIgnoreLocations: string[] = [];
5560

5661
private constructor(options: ShadowRepoOptions) {
5762
this.gitDir = getGitDir(options.orgId, options.projectPath);
@@ -84,6 +89,26 @@ export class ShadowRepo {
8489
public async gitInit(): Promise<void> {
8590
await fs.promises.mkdir(this.gitDir, { recursive: true });
8691
await git.init({ fs, dir: this.projectPath, gitdir: this.gitDir, defaultBranch: 'main' });
92+
// set the forceIgnoreLocations so we only have to do it once
93+
// this looks through
94+
this.gitIgnoreLocations = (
95+
(await git.walk({
96+
fs,
97+
dir: this.projectPath,
98+
gitdir: this.gitDir,
99+
trees: [git.WORKDIR()],
100+
// TODO: this can be marginally faster if we limit it to pkgDirs and toplevel project files
101+
// eslint-disable-next-line @typescript-eslint/require-await
102+
map: async (filepath: string) => filepath,
103+
})) as string[]
104+
)
105+
.filter(
106+
(filepath) =>
107+
filepath.includes(gitIgnoreFileName) &&
108+
// can be top-level like '.' (no sep) OR must be in one of the package dirs
109+
(!filepath.includes(path.sep) || this.packageDirs.some((dir) => pathIsInFolder(filepath, dir.path)))
110+
)
111+
.map((ignoreFile) => path.join(this.projectPath, ignoreFile));
87112
}
88113

89114
/**
@@ -124,8 +149,13 @@ export class ShadowRepo {
124149
dir: this.projectPath,
125150
gitdir: this.gitDir,
126151
filepaths,
127-
// filter out hidden files and __tests__ patterns, regardless of gitignore
128-
filter: (f) => !f.includes(`${path.sep}.`) && !f.includes('__tests__'),
152+
// filter out hidden files and __tests__ patterns, regardless of gitignore, and the gitignore files themselves
153+
filter: (f) =>
154+
!f.includes(`${path.sep}.`) &&
155+
!f.includes('__tests__') &&
156+
![gitIgnoreFileName, stashedGitIgnoreFileName].includes(path.basename(f)) &&
157+
// isogit uses `startsWith` for filepaths so it's possible to get a false positive
158+
filepaths.some((pkgDir) => pathIsInFolder(f, pkgDir, path.posix.sep)),
129159
});
130160
// isomorphic-git stores things in unix-style tree. Convert to windows-style if necessary
131161
if (isWindows) {
@@ -246,18 +276,20 @@ export class ShadowRepo {
246276
}
247277

248278
private async stashIgnoreFile(): Promise<void> {
249-
const originalLocation = path.join(this.projectPath, gitIgnoreFileName);
250-
// another process may have already stashed the file
251-
if (fs.existsSync(originalLocation)) {
252-
await fs.promises.rename(originalLocation, path.join(this.projectPath, stashedGitIgnoreFileName));
253-
}
279+
// allSettled allows them to fail (example, the file wasn't where it was expected).
280+
await Promise.allSettled(
281+
this.gitIgnoreLocations.map((originalLocation) =>
282+
fs.promises.rename(originalLocation, originalLocation.replace(gitIgnoreFileName, stashedGitIgnoreFileName))
283+
)
284+
);
254285
}
255286

256287
private async unStashIgnoreFile(): Promise<void> {
257-
const stashedLocation = path.join(this.projectPath, stashedGitIgnoreFileName);
258-
// another process may have already un-stashed the file
259-
if (fs.existsSync(stashedLocation)) {
260-
await fs.promises.rename(stashedLocation, path.join(this.projectPath, gitIgnoreFileName));
261-
}
288+
// allSettled allows them to fail (example, the file wasn't where it was expected).
289+
await Promise.allSettled(
290+
this.gitIgnoreLocations.map((originalLocation) =>
291+
fs.promises.rename(originalLocation.replace(gitIgnoreFileName, stashedGitIgnoreFileName), originalLocation)
292+
)
293+
);
262294
}
263295
}

src/sourceTracking.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
RemoteChangeElement,
3535
} from './shared/types';
3636
import { sourceComponentGuard, metadataMemberGuard } from './shared/guards';
37-
import { getKeyFromObject, getMetadataKey, isBundle } from './shared/functions';
37+
import { getKeyFromObject, getMetadataKey, isBundle, pathIsInFolder } from './shared/functions';
3838

3939
export interface SourceTrackingOptions {
4040
org: Org;
@@ -103,8 +103,8 @@ export class SourceTracking extends AsyncCreatable {
103103
byPackageDir
104104
? this.packagesDirs.map((pkgDir) => ({
105105
path: pkgDir.name,
106-
nonDeletes: allNonDeletes.filter((f) => f.startsWith(pkgDir.name)),
107-
deletes: allDeletes.filter((f) => f.startsWith(pkgDir.name)),
106+
nonDeletes: allNonDeletes.filter((f) => pathIsInFolder(f, pkgDir.name)),
107+
deletes: allDeletes.filter((f) => pathIsInFolder(f, pkgDir.name)),
108108
}))
109109
: [
110110
{
@@ -367,7 +367,7 @@ export class SourceTracking extends AsyncCreatable {
367367
await this.localRepo.getDeleteFilenames()
368368
).filter(
369369
(deployedFile) =>
370-
bundlesWithDeletedFiles.some((bundlePath) => deployedFile.startsWith(bundlePath)) &&
370+
bundlesWithDeletedFiles.some((bundlePath) => pathIsInFolder(deployedFile, bundlePath)) &&
371371
!relativeOptions.files.includes(deployedFile)
372372
)
373373
),

test/nuts/ignoreInSubfolder

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 3dd4590a61872c00f0b385be958fab0d2af309c7
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) 2020, 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 * as path from 'path';
8+
import { TestSession } from '@salesforce/cli-plugins-testkit';
9+
import { fs } from '@salesforce/core';
10+
import { expect } from 'chai';
11+
import { ShadowRepo } from '../../src/shared/localShadowRepo';
12+
13+
describe('handles non-top-level ignore', () => {
14+
let session: TestSession;
15+
let repo: ShadowRepo;
16+
17+
before(async () => {
18+
session = await TestSession.create({
19+
project: {
20+
sourceDir: path.join('test', 'nuts', 'ignoreInSubfolder', 'nested-classes'),
21+
},
22+
authStrategy: 'NONE',
23+
});
24+
});
25+
26+
it('initialize the local tracking', async () => {
27+
repo = await ShadowRepo.getInstance({
28+
orgId: 'fakeOrgId2',
29+
projectPath: session.project.dir,
30+
packageDirs: [{ path: 'classes', name: 'classes', fullPath: path.join(session.project.dir, 'classes') }],
31+
});
32+
// verify the local tracking files/directories
33+
expect(fs.existsSync(repo.gitDir));
34+
});
35+
36+
it('should not be influenced by gitignore', async () => {
37+
expect(await repo.getChangedFilenames())
38+
.to.be.an('array')
39+
.with.length(2);
40+
});
41+
42+
after(async () => {
43+
await session?.clean();
44+
});
45+
});

test/nuts/localPkgDirMatching.nut.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) 2020, 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 * as path from 'path';
8+
import { TestSession } from '@salesforce/cli-plugins-testkit';
9+
import { fs } from '@salesforce/core';
10+
import { expect } from 'chai';
11+
import { ShadowRepo } from '../../src/shared/localShadowRepo';
12+
13+
describe('verifies exact match of pkgDirs', () => {
14+
let session: TestSession;
15+
let repo: ShadowRepo;
16+
17+
before(async () => {
18+
session = await TestSession.create({
19+
project: {
20+
sourceDir: path.join('test', 'nuts', 'ignoreInSubfolder', 'extra-classes'),
21+
},
22+
authStrategy: 'NONE',
23+
});
24+
});
25+
26+
it('initialize the local tracking', async () => {
27+
repo = await ShadowRepo.getInstance({
28+
orgId: 'fakeOrgId3',
29+
projectPath: session.project.dir,
30+
packageDirs: [{ path: 'force-app', name: 'force-app', fullPath: path.join(session.project.dir, 'force-app') }],
31+
});
32+
// verify the local tracking files/directories
33+
expect(fs.existsSync(repo.gitDir));
34+
});
35+
36+
it('should not include files from force-app-extra', async () => {
37+
const changedFilenames = await repo.getChangedFilenames();
38+
39+
// expect(changedFilenames).to.be.an('array').with.length(2);
40+
changedFilenames.map((f) => {
41+
expect(f).to.not.contain('force-app-extra');
42+
});
43+
});
44+
45+
after(async () => {
46+
await session?.clean();
47+
});
48+
});
+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (c) 2020, 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 { normalize } from 'path';
8+
import { expect } from 'chai';
9+
import { pathIsInFolder } from '../../src/shared/functions';
10+
11+
describe('stringStartsWithString', () => {
12+
it('does not misidentify partial strings', () => {
13+
expect(pathIsInFolder(normalize('/foo/bar-extra/baz'), normalize('/foo/bar'))).to.equal(false);
14+
});
15+
16+
it('does not misidentify partial strings (inverse)', () => {
17+
expect(pathIsInFolder(normalize('/foo/bar-extra/baz'), normalize('/foo/bar-extra'))).to.equal(true);
18+
});
19+
20+
it('single top-level dir is ok', () => {
21+
expect(pathIsInFolder(normalize('/foo/bar-extra/baz'), normalize('/foo'))).to.equal(true);
22+
});
23+
24+
it('no initial separator on 1st arg is ok', () => {
25+
expect(pathIsInFolder(normalize('/foo/bar-extra/baz'), 'foo')).to.equal(true);
26+
});
27+
28+
it('no initial separator on 2nd arg is ok', () => {
29+
expect(pathIsInFolder(normalize('foo/bar-extra/baz'), '/foo')).to.equal(true);
30+
});
31+
32+
it('works for deep children', () => {
33+
expect(pathIsInFolder(normalize('/foo/bar-extra/baz/some/deep/path'), normalize('/foo/bar-extra/baz'))).to.equal(
34+
true
35+
);
36+
});
37+
});

0 commit comments

Comments
 (0)