diff --git a/.circleci/config.yml b/.circleci/config.yml index 5c793a277..cd7933607 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,6 +71,7 @@ workflows: - windows command: - 'yarn test:nuts:convert' + - 'yarn test:nuts:commands:other' - 'yarn test:nuts:delete' - 'yarn test:nuts:deploy' - 'yarn test:nuts:deploy:async' diff --git a/command-snapshot.json b/command-snapshot.json index 9e4622016..b8721f288 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -31,11 +31,6 @@ "plugin": "@salesforce/plugin-source", "flags": ["apiversion", "jobid", "json", "loglevel", "targetusername", "verbose", "wait"] }, - { - "command": "force:mdapi:deploy:cancel", - "plugin": "@salesforce/plugin-source", - "flags": ["apiversion", "jobid", "json", "loglevel", "targetusername", "wait"] - }, { "command": "force:mdapi:beta:retrieve", "plugin": "@salesforce/plugin-source", @@ -71,6 +66,11 @@ "zipfilename" ] }, + { + "command": "force:mdapi:deploy:cancel", + "plugin": "@salesforce/plugin-source", + "flags": ["apiversion", "jobid", "json", "loglevel", "targetusername", "wait"] + }, { "command": "force:mdapi:describemetadata", "plugin": "@salesforce/plugin-source", @@ -162,6 +162,11 @@ "plugin": "@salesforce/plugin-source", "flags": ["apiversion", "jobid", "json", "loglevel", "targetusername", "verbose", "wait"] }, + { + "command": "force:source:ignored:list", + "plugin": "@salesforce/plugin-source", + "flags": ["json", "loglevel", "sourcepath"] + }, { "command": "force:source:manifest:create", "plugin": "@salesforce/plugin-source", diff --git a/messages/ignored_list.json b/messages/ignored_list.json new file mode 100644 index 000000000..0420461ec --- /dev/null +++ b/messages/ignored_list.json @@ -0,0 +1,8 @@ +{ + "description": "check your local project package directories for forceignored files", + "examples": ["$ sfdx force:source:ignored", "$ sfdx force:source:ignored --sourcepath force-app"], + "flags": { + "sourcepath": "file or directory of files that the command checks for foreceignored files" + }, + "invalidSourcePath": "File or directory '%s' doesn't exist in your project. Specify one that exists and rerun the command." +} diff --git a/package.json b/package.json index d9806869a..fba6ba2f5 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "test:command-reference": "./bin/run commandreference:generate --erroronwarnings", "test:deprecation-policy": "./bin/run snapshot:compare", "test:nuts": "ts-node ./test/nuts/generateNuts.ts && nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel --retries 0", + "test:nuts:commands:other": "mocha \"test/nuts/open.nut.ts\" \"test/nuts/ignored_list.nut.ts\" --slow 4500 --timeout 600000 --retries 0 --parallel", "test:nuts:convert": "cross-env PLUGIN_SOURCE_SEED_FILTER=convert ts-node ./test/nuts/generateNuts.ts && mocha \"test/nuts/generated/*.nut.ts\" --slow 4500 --timeout 600000 --parallel --retries 0", "test:nuts:delete": "mocha \"test/nuts/delete.nut.ts\" --slow 4500 --timeout 600000 --retries 0", "test:nuts:deploy": "cross-env PLUGIN_SOURCE_SEED_FILTER=deploy PLUGIN_SOURCE_SEED_EXCLUDE=async ts-node ./test/nuts/generateNuts.ts && mocha \"test/nuts/generated/*.nut.ts\" --slow 4500 --timeout 600000 --parallel --retries 0", diff --git a/src/commands/force/source/ignored/list.ts b/src/commands/force/source/ignored/list.ts new file mode 100644 index 000000000..07425f5b8 --- /dev/null +++ b/src/commands/force/source/ignored/list.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022, 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 * as path from 'path'; +import { flags, FlagsConfig, SfdxCommand } from '@salesforce/command'; +import { fs as fsCore, Messages, SfdxError } from '@salesforce/core'; +import { ForceIgnore } from '@salesforce/source-deploy-retrieve'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/plugin-source', 'ignored_list'); + +export type SourceIgnoredResults = { + ignoredFiles: string[]; +}; + +export class SourceIgnoredCommand extends SfdxCommand { + public static readonly description = messages.getMessage('description'); + public static readonly requiresProject = true; + + public static readonly flagsConfig: FlagsConfig = { + sourcepath: flags.filepath({ + char: 'p', + description: messages.getMessage('flags.sourcepath'), + }), + }; + + private forceIgnore: ForceIgnore; + /** + * Outputs all forceignored files from package directories of a project, + * or based on a sourcepath param that points to a specific file or directory. + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async run(): Promise { + try { + this.forceIgnore = ForceIgnore.findAndCreate(this.project.getPath()); + const sourcepaths = this.flags.sourcepath + ? [this.flags.sourcepath as string] + : this.project.getUniquePackageDirectories().map((pDir) => pDir.path); + + const ignoredFiles = (await Promise.all(sourcepaths.map((sp) => this.statIgnored(sp.trim())))).flat(); + + // Command output + if (ignoredFiles.length) { + this.ux.log('Found the following ignored files:'); + ignoredFiles.forEach((filepath) => this.ux.log(filepath)); + } else { + this.ux.log('No ignored files found in paths:'); + sourcepaths.forEach((sp) => this.ux.log(sp)); + } + + return { ignoredFiles }; + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (err.code === 'ENOENT') { + throw SfdxError.create('@salesforce/plugin-source', 'ignored_list', 'invalidSourcePath', [ + this.flags.sourcepath, + ]); + } + throw SfdxError.wrap(err); + } + } + + // Stat the filepath. Test if a file, recurse if a directory. + private async statIgnored(filepath: string): Promise { + const stats = await fsCore.stat(filepath); + if (stats.isDirectory()) { + return (await Promise.all(await this.findIgnored(filepath))).flat(); + } else { + return this.isIgnored(filepath) ? [filepath] : []; + } + } + + // Recursively search a directory for source files to test. + private async findIgnored(dir: string): Promise>> { + this.logger.debug(`Searching dir: ${dir}`); + return (await fsCore.readdir(dir)).map((filename) => this.statIgnored(path.join(dir, filename))); + } + + // Test if a source file is denied, adding any ignored files to + // the ignoredFiles array for output. + private isIgnored(filepath: string): boolean { + if (this.forceIgnore.denies(filepath)) { + this.logger.debug(`[DENIED]: ${filepath}`); + return true; + } + this.logger.debug(`[ACCEPTED]: ${filepath}`); + return false; + } +} diff --git a/test/nuts/ignored_list.nut.ts b/test/nuts/ignored_list.nut.ts new file mode 100644 index 000000000..0f52d0988 --- /dev/null +++ b/test/nuts/ignored_list.nut.ts @@ -0,0 +1,155 @@ +/* + * 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 * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import { expect } from 'chai'; +import { AuthStrategy } from '@salesforce/cli-plugins-testkit/lib/hubAuth'; +import { SourceIgnoredResults } from '../../src/commands/force/source/ignored/list'; + +describe('force:source:ignored:list', () => { + let session: TestSession; + let forceIgnorePath: string; + let originalForceIgnore; + + const pathToIgnoredFile1 = path.join('foo-bar', 'app', 'classes', 'FooBar.cls'); + const pathToIgnoredFile2 = path.join('foo-bar', 'app', 'classes', 'FooBar.cls-meta.xml'); + + before(async () => { + session = await TestSession.create({ + project: { + gitClone: 'https://github.com/salesforcecli/sample-project-multiple-packages', + }, + authStrategy: AuthStrategy.NONE, + }); + forceIgnorePath = path.join(session.project.dir, '.forceignore'); + originalForceIgnore = await fs.promises.readFile(forceIgnorePath, 'utf8'); + }); + + after(async () => { + await session?.clean(); + }); + + describe('no forceignore', () => { + before(async () => { + await fs.promises.rm(forceIgnorePath); + }); + after(async () => { + await fs.promises.writeFile(forceIgnorePath, originalForceIgnore); + }); + it('default PkgDir', () => { + const result = execCmd('force:source:ignored:list --json', { ensureExitCode: 0 }).jsonOutput + .result; + expect(result.ignoredFiles).to.deep.equal([]); + }); + it('specified sourcePath', () => { + const result2 = execCmd('force:source:ignored:list --json -p foo-bar', { + ensureExitCode: 0, + }).jsonOutput.result; + expect(result2.ignoredFiles).to.deep.equal([]); + }); + }); + + describe('no files are ignored (empty forceignore)', () => { + before(async () => { + await fs.promises.writeFile(forceIgnorePath, ''); + }); + after(async () => { + await fs.promises.writeFile(forceIgnorePath, originalForceIgnore); + }); + it('default PkgDir', () => { + const result = execCmd('force:source:ignored:list --json', { ensureExitCode: 0 }).jsonOutput + .result; + expect(result.ignoredFiles).to.deep.equal([]); + }); + it('specified sourcePath', () => { + const result2 = execCmd('force:source:ignored:list --json -p foo-bar', { + ensureExitCode: 0, + }).jsonOutput.result; + expect(result2.ignoredFiles).to.deep.equal([]); + }); + }); + + describe('returns an ignored class using specified path in forceignore', () => { + before(async () => { + // forceignore uses a library that wants ignore rules in posix format. + await fs.promises.appendFile( + forceIgnorePath, + `${path.normalize(pathToIgnoredFile1).split(path.sep).join(path.posix.sep)}${os.EOL}` + ); + await fs.promises.appendFile( + forceIgnorePath, + `${path.normalize(pathToIgnoredFile2).split(path.sep).join(path.posix.sep)}${os.EOL}` + ); + }); + after(async () => { + await fs.promises.writeFile(forceIgnorePath, originalForceIgnore); + }); + it('default PkgDir', () => { + const result = execCmd('force:source:ignored:list --json', { ensureExitCode: 0 }).jsonOutput + .result; + expect(result.ignoredFiles).to.include(pathToIgnoredFile1); + expect(result.ignoredFiles).to.include(pathToIgnoredFile2); + }); + it('specified sourcePath', () => { + const result2 = execCmd('force:source:ignored:list --json -p foo-bar', { + ensureExitCode: 0, + }).jsonOutput.result; + expect(result2.ignoredFiles).to.include(pathToIgnoredFile1); + expect(result2.ignoredFiles).to.include(pathToIgnoredFile2); + }); + }); + + describe('returns an ignored class using wildcards', () => { + before(async () => { + await fs.promises.appendFile(forceIgnorePath, '**/FooBar.*'); + }); + after(async () => { + await fs.promises.writeFile(forceIgnorePath, originalForceIgnore); + }); + + it('default PkgDir', () => { + const result = execCmd('force:source:ignored:list --json', { ensureExitCode: 0 }).jsonOutput + .result; + expect(result.ignoredFiles).to.include(pathToIgnoredFile1); + expect(result.ignoredFiles).to.include(pathToIgnoredFile2); + }); + it('specified sourcePath', () => { + const result2 = execCmd('force:source:ignored:list --json -p foo-bar', { + ensureExitCode: 0, + }).jsonOutput.result; + expect(result2.ignoredFiles).to.include(pathToIgnoredFile1); + expect(result2.ignoredFiles).to.include(pathToIgnoredFile2); + }); + }); + + describe('returns an ignored non-metadata component', () => { + const lwcDir = path.join('foo-bar', 'app', 'lwc'); + const lwcConfigPath = path.join(lwcDir, 'jsconfig.json'); + + before(async () => { + await fs.promises.mkdir(path.join(session.project.dir, lwcDir), { recursive: true }); + await fs.promises.writeFile(path.join(session.project.dir, lwcConfigPath), '{}'); + }); + after(async () => { + await fs.promises.writeFile(forceIgnorePath, originalForceIgnore); + }); + + it('default PkgDir', () => { + const result = execCmd('force:source:ignored:list --json', { ensureExitCode: 0 }).jsonOutput + .result; + expect(result.ignoredFiles).to.include(lwcConfigPath); + }); + it('specified sourcePath', () => { + const result2 = execCmd('force:source:ignored:list --json -p foo-bar', { + ensureExitCode: 0, + }).jsonOutput.result; + expect(result2.ignoredFiles).to.include(lwcConfigPath); + }); + }); +});