Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add retrievetargetdir to source:retrieve #624

Merged
merged 11 commits into from
Nov 1, 2022
965 changes: 574 additions & 391 deletions command-snapshot.json

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion messages/retrieve.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
"To retrieve all Apex classes and two specific profiles (one of which has a space in its name):\n $ sfdx force:source:retrieve -m \"ApexClass, Profile:My Profile, Profile: AnotherProfile\"",
"To retrieve all metadata components listed in a manifest:\n $ sfdx force:source:retrieve -x path/to/package.xml",
"To retrieve metadata from a package or multiple packages:\n $ sfdx force:source:retrieve -n MyPackageName\n $ sfdx force:source:retrieve -n \"Package1, PackageName With Spaces, Package3\"",
"To retrieve all metadata from a package and specific components that aren’t in the package, specify both -n | --packagenames and one other scoping parameter:\n $ sfdx force:source:retrieve -n MyPackageName -p path/to/apex/classes\n $ sfdx force:source:retrieve -n MyPackageName -m ApexClass:MyApexClass\n $ sfdx force:source:retrieve -n MyPackageName -x path/to/package.xml"
"To retrieve all metadata from a package and specific components that aren’t in the package, specify both -n | --packagenames and one other scoping parameter:\n $ sfdx force:source:retrieve -n MyPackageName -p path/to/apex/classes\n $ sfdx force:source:retrieve -n MyPackageName -m ApexClass:MyApexClass\n $ sfdx force:source:retrieve -n MyPackageName -x path/to/package.xml",
"To retrieve source files to a given directory instead of the default package directory specified in sfdx-project.json:\n $ sfdx force:source:retrieve -m \"StandardValueSet:TaskStatus\" -r path/to/unpackaged"
],
"flags": {
"retrievetargetdir": "directory root for the retrieved source files",
"sourcePath": "comma-separated list of source file paths to retrieve",
"wait": "wait time for command to finish in minutes",
"manifest": "file path for manifest (package.xml) of components to retrieve",
Expand All @@ -24,6 +26,11 @@
"forceoverwrite": "ignore conflict warnings and overwrite changes to the project"
},
"flagsLong": {
"retrievetargetdir": [
"The root of the directory structure into which the source files are retrieved.",
"If the target directory matches one of the package directories in your sfdx-project.json file, the retrieved source files are placed in the matching package directory.",
"Running the command multiple times with the same target will add new files and overwrite existing files."
peternhale marked this conversation as resolved.
Show resolved Hide resolved
],
"wait": "Number of minutes to wait for the command to complete and display results to the terminal window. If the command continues to run after the wait period, the CLI returns control of the terminal window to you.",
"manifest": [
"The complete path for the manifest (package.xml) file that specifies the components to retrieve.",
Expand Down
69 changes: 65 additions & 4 deletions src/commands/force/source/retrieve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*/

import * as os from 'os';
import { join } from 'path';
import { dirname, join, resolve } from 'path';
import * as fs from 'fs';
import { flags, FlagsConfig } from '@salesforce/command';
import { Messages, SfError, SfProject } from '@salesforce/core';
import { Duration } from '@salesforce/kit';
Expand All @@ -19,6 +20,7 @@ import {
RetrieveResultFormatter,
} from '../../../formatters/retrieveResultFormatter';
import { filterConflictsByComponentSet, trackingSetup, updateTracking } from '../../../trackingFunctions';
import { promisesQueue } from '../../../promiseQueue';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/plugin-source', 'retrieve');
Expand All @@ -30,6 +32,12 @@ export class Retrieve extends SourceCommand {
public static readonly requiresProject = true;
public static readonly requiresUsername = true;
public static readonly flagsConfig: FlagsConfig = {
retrievetargetdir: flags.directory({
char: 'r',
description: messages.getMessage('flags.retrievetargetdir'),
longDescription: messages.getMessage('flagsLong.retrievetargetdir'),
exclusive: ['packagenames', 'sourcepath'],
}),
apiversion: flags.builtin({
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-ignore force char override for backward compat
Expand Down Expand Up @@ -86,6 +94,7 @@ export class Retrieve extends SourceCommand {
await this.retrieve();
this.resolveSuccess();
await this.maybeUpdateTracking();
await this.moveResultsForRetrieveTargetDir();
return this.formatResult();
}

Expand Down Expand Up @@ -115,11 +124,11 @@ export class Retrieve extends SourceCommand {
sourcepath: this.getFlag<string[]>('sourcepath'),
manifest: this.flags.manifest && {
manifestPath: this.getFlag<string>('manifest'),
directoryPaths: this.getPackageDirs(),
directoryPaths: this.flags.retrievetargetdir ? [] : this.getPackageDirs(),
},
metadata: this.flags.metadata && {
metadataEntries: this.getFlag<string[]>('metadata'),
directoryPaths: this.getPackageDirs(),
directoryPaths: this.flags.retrievetargetdir ? [] : this.getPackageDirs(),
},
});

Expand Down Expand Up @@ -155,7 +164,7 @@ export class Retrieve extends SourceCommand {
const mdapiRetrieve = await this.componentSet.retrieve({
usernameOrConnection: this.org.getUsername(),
merge: true,
output: this.project.getDefaultPackage().fullPath,
output: this.getFlag<string>('retrievetargetdir') || this.project.getDefaultPackage().fullPath,
packageOptions: this.getFlag<string[]>('packagenames'),
});

Expand Down Expand Up @@ -220,4 +229,56 @@ export class Retrieve extends SourceCommand {
});
return hasCustomField && !hasCustomObject;
}

private async moveResultsForRetrieveTargetDir(): Promise<void> {
async function mv(src: string): Promise<string[]> {
let directories: string[] = [];
let files: string[] = [];
const srcStat = await fs.promises.stat(src);
if (srcStat.isDirectory()) {
const contents = await fs.promises.readdir(src);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can save a lot of fs ops by using the withFileTypes option on readdir
https://nodejs.org/api/fs.html#fspromisesreaddirpath-options

which gives you the full dirEnt info https://nodejs.org/api/fs.html#class-fsdirent

directories = contents.filter((c) => fs.statSync(join(src, c)).isDirectory()).map((c) => join(src, c));
files = contents.filter((c) => !fs.statSync(join(src, c)).isDirectory());
} else {
files.push(src);
}
await promisesQueue(
files,
async (file: string): Promise<string> => {
const dest = join(src.replace(join('main', 'default'), ''), file);
const destDir = dirname(dest);
await fs.promises.mkdir(destDir, { recursive: true });
await fs.promises.rename(join(src, file), dest);
return dest;
},
50
);
return directories;
}

if (!this.flags.retrievetargetdir) {
return;
}
let overlapsProject = true;
const resolvedTargetDir = resolve(this.flags.retrievetargetdir as string);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and what if I want to "peek" at the results, outside my normal project. So I say, --retrievetargetdir peek/main/default

what's going to happen? .replace(join('main', 'default') would eliminate the redundancy?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll give that a try, but my suspect that it will only remove one main/default

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As predicted, the results land in peek/main/default

overlapsProject = !!this.project.getPackageDirectories().find((pkgDir) => {
peternhale marked this conversation as resolved.
Show resolved Hide resolved
if (pkgDir.fullPath) {
return pkgDir.fullPath.includes(resolvedTargetDir);
}
return false;
});
if (overlapsProject) {
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels like a good place for a warning. Another option would be to make this "overlaps" validation a standalone function that could be used for your new flag's parse so that it doesn't go through all that retrieve work to fail this late in the game.

As is, it looks like the user would specify a targetDir and then if it overlapped, the retrieve would go to their default dir anyway without a warning?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mshanemc I think I am going to change direction on overlapping targets, fail the command if the target overlaps a package dir. There are other commands and variants of source:retrieve to get source into a package dir.

}

const moveQueue: string[] = [];
// move contents of 'main/default' to 'retrievetargetdir'
moveQueue.push(join(resolvedTargetDir, 'main', 'default'));
await promisesQueue(moveQueue, mv, 5, true);
peternhale marked this conversation as resolved.
Show resolved Hide resolved
// remove 'main/default'
await fs.promises.rmdir(join(this.flags.retrievetargetdir as string, 'main'), { recursive: true });
this.retrieveResult.getFileResponses().forEach((fileResponse) => {
fileResponse.filePath = fileResponse.filePath.replace(join('main', 'default'), '');
});
}
}
43 changes: 43 additions & 0 deletions src/promiseQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall comment...there's some tools that do this kind of thing if we find a lot more use cases for it.

https://github.com/supercharge/promise-pool is fairly small, zero-dep. The error handling and abort looks nice.

* 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
*/
/**
* Function to throttle a list of promises.
*
* @param sourceQueue - The list of items to process.
* @param producer - The function to produce a promise from an item.
* @param concurrency - The number of promises to run at a time.
* @param queueResults - Whether to queue the results of the promises.
*/
export async function promisesQueue<T>(
sourceQueue: T[],
producer: (T) => Promise<T | T[]>,
concurrency: number,
queueResults = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd omit this since it isn't used. It's not clear what it might do and at-a-glance invites an infinite loop

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mshanemc it is used in mv function. Yes it can and yes I have :). Not being able to queue results defeats the ability to accomplish the dir traversal without recursion.

): Promise<T[]> {
const results: T[] = [];
let queue = [...sourceQueue];
while (queue.length > 0) {
const next = queue.slice(0, concurrency);
queue = queue.slice(concurrency);
// eslint-disable-next-line no-await-in-loop
const nextResults = (await Promise.all(next.map(producer)))
.reduce<T[]>((acc, val) => {
if (Array.isArray(val)) {
acc.push(...val);
} else {
acc.push(val);
}
return acc;
}, [])
.filter((val) => val !== undefined);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const nextResults = (await Promise.all(ensureArray(next.map(producer)))).flatMap((val) => val);

if (queueResults) {
queue.push(...nextResults);
}
results.push(...nextResults);
}
return results;
}
Loading