diff --git a/apps/sparo-lib/src/cli/commands/checkout.ts b/apps/sparo-lib/src/cli/commands/checkout.ts index 78ee3e2..c83d4ff 100644 --- a/apps/sparo-lib/src/cli/commands/checkout.ts +++ b/apps/sparo-lib/src/cli/commands/checkout.ts @@ -1,14 +1,12 @@ import * as child_process from 'child_process'; import { inject } from 'inversify'; import { Command } from '../../decorator'; -import type { ICommand } from './base'; -import { type ArgumentsCamelCase, type Argv } from 'yargs'; import { GitService } from '../../services/GitService'; import { TerminalService } from '../../services/TerminalService'; -import { ILocalStateProfiles, LocalState } from '../../logic/LocalState'; import { SparoProfileService } from '../../services/SparoProfileService'; -import { GitSparseCheckoutService } from '../../services/GitSparseCheckoutService'; +import type { ICommand } from './base'; +import type { ArgumentsCamelCase, Argv } from 'yargs'; export interface ICheckoutCommandOptions { profile: string[]; branch?: string; @@ -26,9 +24,6 @@ export class CheckoutCommand implements ICommand { @inject(GitService) private _gitService!: GitService; @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; - @inject(GitSparseCheckoutService) private _gitSparseCheckoutService!: GitSparseCheckoutService; - @inject(LocalState) private _localState!: LocalState; - @inject(TerminalService) private _terminalService!: TerminalService; public builder(yargs: Argv<{}>): void { /** @@ -77,14 +72,10 @@ export class CheckoutCommand implements ICommand { args: ArgumentsCamelCase, terminalService: TerminalService ): Promise => { - const { _gitService: gitService, _localState: localState } = this; + const { _gitService: gitService } = this; + terminalService.terminal.writeDebugLine(`got args in checkout command: ${JSON.stringify(args)}`); const { b, B, branch, startPoint } = args; - const { isNoProfile, profiles, addProfiles } = this._processProfilesFromArg({ - addProfilesFromArg: args.addProfile ?? [], - profilesFromArg: args.profile - }); - /** * Since we set up single branch by default and branch can be missing in local, we are going to fetch the branch from remote server here. */ @@ -107,28 +98,15 @@ export class CheckoutCommand implements ICommand { } } - const targetProfileNames: Set = new Set(); - const currentProfileNames: Set = new Set(); + // preprocess profile related args + const { isNoProfile, profiles, addProfiles } = await this._sparoProfileService.preprocessProfileArgs({ + addProfilesFromArg: args.addProfile ?? [], + profilesFromArg: args.profile + }); + + // check wether profiles exist in local or operation branch if (!isNoProfile) { - // Get target profile. - // 1. If profile specified from CLI parameter, preferential use it. - // 2. If none profile specified, read from existing profile from local state as default. - // 3. If add profile was specified from CLI parameter, add them to result of 1 or 2. - const localStateProfiles: ILocalStateProfiles | undefined = await localState.getProfiles(); - - if (profiles.size) { - profiles.forEach((p) => targetProfileNames.add(p)); - } else if (localStateProfiles) { - Object.keys(localStateProfiles).forEach((p) => { - targetProfileNames.add(p); - currentProfileNames.add(p); - }); - } - - if (addProfiles.size) { - addProfiles.forEach((p) => targetProfileNames.add(p)); - } - + const targetProfileNames: Set = new Set([...profiles, ...addProfiles]); const nonExistProfileNames: string[] = []; for (const targetProfileName of targetProfileNames) { /** @@ -177,42 +155,11 @@ export class CheckoutCommand implements ICommand { throw new Error(`git checkout failed`); } - // checkout profiles - localState.reset(); - - if (isNoProfile) { - // if no profile specified, purge to skeleton - await this._gitSparseCheckoutService.purgeAsync(); - } else if (targetProfileNames.size) { - let isCurrentSubsetOfTarget: boolean = true; - for (const currentProfileName of currentProfileNames) { - if (!targetProfileNames.has(currentProfileName)) { - isCurrentSubsetOfTarget = false; - break; - } - } - - // In most case, sparo need to reset the sparse checkout cone. - // Only when the current profiles are subset of target profiles, we can skip this step. - if (!isCurrentSubsetOfTarget) { - await this._gitSparseCheckoutService.purgeAsync(); - } - - // TODO: policy #1: Can not sparse checkout with uncommitted changes in the cone. - for (const profile of targetProfileNames) { - // Since we have run localState.reset() before, for each profile we just add it to local state. - const { selections, includeFolders, excludeFolders } = - await this._gitSparseCheckoutService.resolveSparoProfileAsync(profile, { - localStateUpdateAction: 'add' - }); - await this._gitSparseCheckoutService.checkoutAsync({ - selections, - includeFolders, - excludeFolders, - checkoutAction: 'add' - }); - } - } + // sync local sparse checkout state with given profiles. + await this._sparoProfileService.syncProfileState({ + profiles: isNoProfile ? undefined : profiles, + addProfiles + }); }; public getHelp(): string { @@ -258,53 +205,4 @@ export class CheckoutCommand implements ICommand { .trim(); return currentBranch; } - - private _processProfilesFromArg({ - profilesFromArg, - addProfilesFromArg - }: { - profilesFromArg: string[]; - addProfilesFromArg: string[]; - }): { - isNoProfile: boolean; - profiles: Set; - addProfiles: Set; - } { - /** - * --profile is defined as array type parameter, specifying --no-profile is resolved to false by yargs. - * - * @example --no-profile -> [false] - * @example --no-profile --profile foo -> [false, "foo"] - * @example --profile foo --no-profile -> ["foo", false] - */ - let isNoProfile: boolean = false; - const profiles: Set = new Set(); - - for (const profile of profilesFromArg) { - if (typeof profile === 'boolean' && profile === false) { - isNoProfile = true; - continue; - } - - profiles.add(profile); - } - - /** - * --add-profile is defined as array type parameter - * @example --no-profile --add-profile foo -> throw error - * @example --profile bar --add-profile foo -> current profiles = bar + foo - * @example --add-profile foo -> current profiles = current profiles + foo - */ - const addProfiles: Set = new Set(addProfilesFromArg.filter((p) => typeof p === 'string')); - - if (isNoProfile && (profiles.size || addProfiles.size)) { - throw new Error(`The "--no-profile" parameter cannot be combined with "--profile" or "--add-profile"`); - } - - return { - isNoProfile, - profiles, - addProfiles - }; - } } diff --git a/apps/sparo-lib/src/cli/commands/clone.ts b/apps/sparo-lib/src/cli/commands/clone.ts index 26b7fba..a47a4cb 100644 --- a/apps/sparo-lib/src/cli/commands/clone.ts +++ b/apps/sparo-lib/src/cli/commands/clone.ts @@ -7,6 +7,7 @@ import { GitService } from '../../services/GitService'; import { GitSparseCheckoutService } from '../../services/GitSparseCheckoutService'; import { GitCloneService, ICloneOptions } from '../../services/GitCloneService'; import { Stopwatch } from '../../logic/Stopwatch'; +import { SparoProfileService } from '../../services/SparoProfileService'; import type { ICommand } from './base'; import type { TerminalService } from '../../services/TerminalService'; @@ -15,6 +16,8 @@ export interface ICloneCommandOptions { repository: string; directory?: string; skipGitConfig?: boolean; + profile?: string[]; + addProfile?: string[]; } @Command() @@ -24,6 +27,8 @@ export class CloneCommand implements ICommand { @inject(GitService) private _gitService!: GitService; @inject(GitCloneService) private _gitCloneService!: GitCloneService; + @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; + @inject(GitSparseCheckoutService) private _GitSparseCheckoutService!: GitSparseCheckoutService; public builder(yargs: Argv<{}>): void { @@ -50,6 +55,10 @@ export class CloneCommand implements ICommand { describe: 'Specify a branch to clone', type: 'string' }) + .array('profile') + .default('profile', []) + .array('add-profile') + .default('add-profile', []) .check((argv) => { if (!argv.repository) { return 'You must specify a repository to clone.'; @@ -83,7 +92,37 @@ export class CloneCommand implements ICommand { process.chdir(directory); - await this._GitSparseCheckoutService.checkoutSkeletonAsync(); + const { profiles, addProfiles, isNoProfile } = await this._sparoProfileService.preprocessProfileArgs({ + profilesFromArg: args.profile ?? [], + addProfilesFromArg: args.addProfile ?? [] + }); + + await this._GitSparseCheckoutService.ensureSkeletonExistAndUpdated(); + + // check whether profile exist in local branch + if (!isNoProfile) { + const targetProfileNames: Set = new Set([...profiles, ...addProfiles]); + const nonExistProfileNames: string[] = []; + for (const targetProfileName of targetProfileNames) { + if (!this._sparoProfileService.hasProfileInFS(targetProfileName)) { + nonExistProfileNames.push(targetProfileName); + } + } + + if (nonExistProfileNames.length) { + throw new Error( + `Clone failed. The following profile(s) are missing in cloned repo: ${Array.from( + targetProfileNames + ).join(', ')}` + ); + } + } + + // sync local sparse checkout state with given profiles. + await this._sparoProfileService.syncProfileState({ + profiles: isNoProfile ? undefined : profiles, + addProfiles + }); // set recommended git config if (!args.skipGitConfig) { @@ -100,13 +139,18 @@ export class CloneCommand implements ICommand { terminal.writeLine(`Don't forget to change your shell path:`); terminal.writeLine(' ' + Colorize.cyan(`cd ${directory}`)); terminal.writeLine(); - terminal.writeLine('Your next step is to choose a Sparo profile for checkout.'); - terminal.writeLine('To see available profiles in this repo:'); - terminal.writeLine(' ' + Colorize.cyan('sparo list-profiles')); - terminal.writeLine('To checkout a profile:'); - terminal.writeLine(' ' + Colorize.cyan('sparo checkout --profile ')); - terminal.writeLine('To create a new profile:'); - terminal.writeLine(' ' + Colorize.cyan('sparo init-profile --profile ')); + + if (isNoProfile || (profiles.size === 0 && addProfiles.size === 0)) { + terminal.writeLine('Your next step is to choose a Sparo profile for checkout.'); + terminal.writeLine('To see available profiles in this repo:'); + terminal.writeLine(' ' + Colorize.cyan('sparo list-profiles')); + terminal.writeLine('To checkout and set profile:'); + terminal.writeLine(' ' + Colorize.cyan('sparo checkout --profile ')); + terminal.writeLine('To checkout and add profile:'); + terminal.writeLine(' ' + Colorize.cyan('sparo checkout --add-profile ')); + terminal.writeLine('To create a new profile:'); + terminal.writeLine(' ' + Colorize.cyan('sparo init-profile --profile ')); + } }; public getHelp(): string { diff --git a/apps/sparo-lib/src/cli/commands/cmd-list.ts b/apps/sparo-lib/src/cli/commands/cmd-list.ts index 204292b..8d95350 100644 --- a/apps/sparo-lib/src/cli/commands/cmd-list.ts +++ b/apps/sparo-lib/src/cli/commands/cmd-list.ts @@ -11,6 +11,7 @@ import { GitCheckoutCommand } from './git-checkout'; import { GitFetchCommand } from './git-fetch'; import { GitPullCommand } from './git-pull'; import { InitProfileCommand } from './init-profile'; +// import { PullCommand } from './pull'; // When adding new Sparo subcommands, remember to update this doc page: // https://github.com/tiktok/sparo/blob/main/apps/website/docs/pages/commands/overview.md @@ -22,6 +23,8 @@ export const COMMAND_LIST: Constructable[] = [ CloneCommand, CheckoutCommand, FetchCommand, + // Should be introduced after sparo merge|rebase + // PullCommand, // The commands customized by Sparo require a mirror command to Git GitCloneCommand, diff --git a/apps/sparo-lib/src/cli/commands/list-profiles.ts b/apps/sparo-lib/src/cli/commands/list-profiles.ts index 58159b0..20cfdb6 100644 --- a/apps/sparo-lib/src/cli/commands/list-profiles.ts +++ b/apps/sparo-lib/src/cli/commands/list-profiles.ts @@ -40,7 +40,7 @@ export class ListProfilesCommand implements ICommand = await this._sparoProfileService.getProfilesAsync(); diff --git a/apps/sparo-lib/src/cli/commands/pull.ts b/apps/sparo-lib/src/cli/commands/pull.ts new file mode 100644 index 0000000..cd16a09 --- /dev/null +++ b/apps/sparo-lib/src/cli/commands/pull.ts @@ -0,0 +1,95 @@ +import { inject } from 'inversify'; +import { Command } from '../../decorator'; +import { GitService } from '../../services/GitService'; +import { SparoProfileService } from '../../services/SparoProfileService'; + +import type { Argv, ArgumentsCamelCase } from 'yargs'; +import type { ICommand } from './base'; +import type { TerminalService } from '../../services/TerminalService'; + +export interface IPullCommandOptions { + branch?: string; + remote?: string; + profile?: string[]; + addProfile?: string[]; +} + +@Command() +export class PullCommand implements ICommand { + public cmd: string = 'pull [remote] [branch]'; + public description: string = 'Incorporates changes from a remote repository into the current branch.'; + + @inject(GitService) private _gitService!: GitService; + @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; + + public builder(yargs: Argv<{}>): void { + /** + * sparo pull [remote] [branch] --profile --add-profile --no-profile + */ + yargs + .positional('remote', { type: 'string' }) + .positional('branch', { type: 'string' }) + .string('remote') + .string('branch') + .boolean('full') + .array('profile') + .default('profile', []) + .array('add-profile') + .default('add-profile', []); + } + + public handler = async ( + args: ArgumentsCamelCase, + terminalService: TerminalService + ): Promise => { + const { _gitService: gitService, _sparoProfileService: sparoProfileService } = this; + const { terminal } = terminalService; + + terminal.writeDebugLine(`got args in pull command: ${JSON.stringify(args)}`); + const pullArgs: string[] = ['pull']; + + const { branch, remote } = args; + + if (branch && remote) { + pullArgs.push(remote, branch); + } + + const { isNoProfile, profiles, addProfiles } = await sparoProfileService.preprocessProfileArgs({ + profilesFromArg: args.profile ?? [], + addProfilesFromArg: args.addProfile ?? [] + }); + + // invoke native git pull command + gitService.executeGitCommand({ args: pullArgs }); + + // check whether profile exist in local branch + if (!isNoProfile) { + const targetProfileNames: Set = new Set([...profiles, ...addProfiles]); + const nonExistProfileNames: string[] = []; + for (const targetProfileName of targetProfileNames) { + if (!this._sparoProfileService.hasProfileInFS(targetProfileName)) { + nonExistProfileNames.push(targetProfileName); + } + } + + if (nonExistProfileNames.length) { + const { branch } = gitService.getRepoInfo(); + throw new Error( + `Pull failed. The following profile(s) are missing in local branch "${branch}": ${Array.from( + targetProfileNames + ).join(', ')}` + ); + } + } + + // sync local sparse checkout state with given profiles. + await this._sparoProfileService.syncProfileState({ + profiles: isNoProfile ? undefined : profiles, + addProfiles + }); + }; + + public getHelp(): string { + return `pull help`; + } +} diff --git a/apps/sparo-lib/src/services/GitSparseCheckoutService.ts b/apps/sparo-lib/src/services/GitSparseCheckoutService.ts index 5690091..c449f6b 100644 --- a/apps/sparo-lib/src/services/GitSparseCheckoutService.ts +++ b/apps/sparo-lib/src/services/GitSparseCheckoutService.ts @@ -2,14 +2,12 @@ import * as path from 'path'; import * as child_process from 'child_process'; import { inject } from 'inversify'; import { Service } from '../decorator'; -import { LocalState, LocalStateUpdateAction } from '../logic/LocalState'; -import { type ISelection, SparoProfile } from '../logic/SparoProfile'; import { GitService } from './GitService'; -import { SparoProfileService } from './SparoProfileService'; import { TerminalService } from './TerminalService'; import { Executable, FileSystem, JsonFile, JsonSyntax } from '@rushstack/node-core-library'; import { Stopwatch } from '../logic/Stopwatch'; +import type { ISelection } from '../logic/SparoProfile'; export interface IRushSparseCheckoutOptions { selections?: ISelection[]; includeFolders?: string[]; @@ -24,81 +22,42 @@ export interface IRushProject { projectFolder: string; } -export interface IResolveSparoProfileOptions { - localStateUpdateAction: LocalStateUpdateAction; -} - @Service() export class GitSparseCheckoutService { - @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; @inject(GitService) private _gitService!: GitService; - @inject(LocalState) private _localState!: LocalState; @inject(TerminalService) private _terminalService!: TerminalService; private _rushConfigLoaded: boolean = false; private _rushProjects: IRushProject[] = []; private _packageNames: Set = new Set(); + private _isSkeletonInitializedAndUpdated: boolean = false; + private _finalSkeletonPaths: string[] = []; - public initializeRepository(): void { - this._terminalService.terminal.writeLine('Checking out core files...'); + public ensureSkeletonExistAndUpdated(): void { + /** + * Every time sparo cli was invoked, _isInitialized will be reset to false and try to local and update skeleton if needed. + * But it is not necessary to run initializeRepository() multiple times during a given command execution, + * because there is no code changes and the result will be the same each time. + * + * @todo + * Store isInitialized in local file, similar to LocalState, and check whether need to update skeleton + * by checking if there is any code changes in rush.json, autoinstaller, or projects' package json + */ + if (this._isSkeletonInitializedAndUpdated) { + return; + } if ('true' !== this._gitService.getGitConfig('core.sparsecheckout')?.trim()) { throw new Error('Sparse checkout is not enabled in this repo.'); } - - this._loadRushConfiguration(); - this._prepareMonorepoSkeleton(); + this.initializeAndUpdateSkeleton(); } - public async resolveSparoProfileAsync( - profile: string, - options: IResolveSparoProfileOptions - ): Promise<{ - selections: ISelection[]; - includeFolders: string[]; - excludeFolders: string[]; - }> { - this.initializeRepository(); - - const sparoProfile: SparoProfile | undefined = await this._sparoProfileService.getProfileAsync(profile); - - if (!sparoProfile) { - const availableProfiles: string[] = Array.from( - (await this._sparoProfileService.getProfilesAsync()).keys() - ); - throw new Error( - `Parse sparse profile "${profile}" error. ${ - availableProfiles.length !== 0 - ? `Available profiles are: -${availableProfiles.join(',')} -` - : 'No profiles now' - }` - ); - } - - const repositoryRoot: string | null = this._gitService.getRepoInfo().root; - if (!repositoryRoot) { - throw new Error(`Running outside of the git repository folder`); - } - - const { selections, includeFolders, excludeFolders } = sparoProfile; - const { localStateUpdateAction } = options; - await this._localState.setProfiles( - { - [profile]: { - selections, - includeFolders, - excludeFolders - } - }, - localStateUpdateAction - ); - return { - selections, - includeFolders, - excludeFolders - }; + public initializeAndUpdateSkeleton(): void { + this._terminalService.terminal.writeLine('Checking out and updating core files...'); + this._loadRushConfiguration(); + this._prepareMonorepoSkeleton(); + this._isSkeletonInitializedAndUpdated = true; } public checkoutSkeletonAsync = async (): Promise => { @@ -127,6 +86,10 @@ ${availableProfiles.join(',')} await this._rushSparseCheckoutAsync({ checkoutAction: 'purge' }); } + /** + * + * @param options + */ private async _rushSparseCheckoutAsync(options: IRushSparseCheckoutOptions): Promise { const { to, @@ -151,12 +114,7 @@ ${availableProfiles.join(',')} throw new Error(`git repo not found. You should run this tool inside a git repo`); } - { - const stopwatch: Stopwatch = Stopwatch.start(); - this.initializeRepository(); - terminal.writeVerboseLine(`Initialize repo sparse checkout. (${stopwatch.toString()})`); - stopwatch.stop(); - } + this.ensureSkeletonExistAndUpdated(); const fromSelectors: Set = new Set(); const toSelectors: Set = new Set(); @@ -210,7 +168,7 @@ ${availableProfiles.join(',')} if (toSelectors.size !== 0 || fromSelectors.size !== 0) { const stopwatch: Stopwatch = Stopwatch.start(); targetFolders = this._getTargetFoldersByRushList({ toSelectors, fromSelectors }); - terminal.writeLine(`Run rush list command. (${stopwatch.toString()})`); + terminal.writeVerboseLine(`Run rush list command. (${stopwatch.toString()})`); stopwatch.stop(); } else { terminal.writeDebugLine('Skip rush list regarding the absence of from selectors and to selectors'); @@ -225,20 +183,38 @@ ${availableProfiles.join(',')} { const stopwatch: Stopwatch = Stopwatch.start(); + /** + * Perform different logic based on checkoutAction + * + * "purge" : reset repo to skeleton, will remove other paths in checkout paths list + * + * "skeleton" : checkout skeleton in repo, will only add skeleton paths to checkout paths list + * + * "set" : set checkout paths list by invoking "git sparse-checkout set", will implicitly add skeleton paths to this list. + * + * "add" : add a list of paths to checkout list by invoking "git sparse-checkout add" + */ switch (checkoutAction) { case 'purge': case 'skeleton': // re-apply the initial paths for setting up sparse repo state - this._prepareMonorepoSkeleton({ restore: checkoutAction === 'purge' }); + this._prepareMonorepoSkeleton({ + restore: checkoutAction === 'purge' + }); break; case 'add': case 'set': if (targetFolders.length === 0) { terminal.writeDebugLine(`Skip sparse checkout regarding no target folders`); } else { + // if action is set, we need to combine targetFolder with _finalSkeletonPaths + if (checkoutAction === 'set') { + targetFolders.push(...this._finalSkeletonPaths); + } terminal.writeLine( - `Performing sparse checkout ${checkoutAction} for these folders: \n${targetFolders.join('\n ')}` + `Performing sparse checkout ${checkoutAction} for these folders: \n${targetFolders.join('\n')}` ); + this._sparseCheckoutPaths(targetFolders, { action: checkoutAction }); @@ -289,9 +265,9 @@ ${availableProfiles.join(',')} private _prepareMonorepoSkeleton(options: { restore?: boolean } = {}): void { const { restore } = options; - const finalSkeletonPaths: string[] = this._getSkeletonPaths(); - this._terminalService.terminal.writeLine('Checking out skeleton...'); - this._sparseCheckoutPaths(finalSkeletonPaths, { + this._finalSkeletonPaths = this._getSkeletonPaths(); + this._terminalService.terminal.writeDebugLine(`Skeleton paths: ${this._finalSkeletonPaths.join(', ')}`); + this._sparseCheckoutPaths(this._finalSkeletonPaths, { action: restore ? 'set' : 'add' }); } diff --git a/apps/sparo-lib/src/services/SparoProfileService.ts b/apps/sparo-lib/src/services/SparoProfileService.ts index 692174b..a5208b1 100644 --- a/apps/sparo-lib/src/services/SparoProfileService.ts +++ b/apps/sparo-lib/src/services/SparoProfileService.ts @@ -2,14 +2,21 @@ import { FileSystem, Async } from '@rushstack/node-core-library'; import path from 'path'; import { inject } from 'inversify'; import { Service } from '../decorator'; -import { SparoProfile } from '../logic/SparoProfile'; +import { SparoProfile, ISelection } from '../logic/SparoProfile'; import { TerminalService } from './TerminalService'; import { GitService } from './GitService'; +import { GitSparseCheckoutService } from './GitSparseCheckoutService'; +import { LocalState, ILocalStateProfiles, type LocalStateUpdateAction } from '../logic/LocalState'; export interface ISparoProfileServiceParams { terminalService: TerminalService; sparoProfileFolder: string; } + +export interface IResolveSparoProfileOptions { + localStateUpdateAction: LocalStateUpdateAction; +} + const defaultSparoProfileFolder: string = 'common/sparo-profiles'; @Service() @@ -19,6 +26,8 @@ export class SparoProfileService { @inject(GitService) private _gitService!: GitService; @inject(TerminalService) private _terminalService!: TerminalService; + @inject(LocalState) private _localState!: LocalState; + @inject(GitSparseCheckoutService) private _gitSparseCheckoutService!: GitSparseCheckoutService; public async loadProfilesAsync(): Promise { if (!this._loadPromise) { @@ -105,4 +114,187 @@ export class SparoProfileService { } return last; } + + public async resolveSparoProfileAsync( + profile: string, + options: IResolveSparoProfileOptions + ): Promise<{ + selections: ISelection[]; + includeFolders: string[]; + excludeFolders: string[]; + }> { + this._gitSparseCheckoutService.ensureSkeletonExistAndUpdated(); + const sparoProfile: SparoProfile | undefined = await this.getProfileAsync(profile); + + if (!sparoProfile) { + const availableProfiles: string[] = Array.from((await this.getProfilesAsync()).keys()); + throw new Error( + `Parse sparse profile "${profile}" error. ${ + availableProfiles.length !== 0 + ? `Available profiles are: +${availableProfiles.join(',')} +` + : 'No profiles now' + }` + ); + } + + const repositoryRoot: string | null = this._gitService.getRepoInfo().root; + if (!repositoryRoot) { + throw new Error(`Running outside of the git repository folder`); + } + + const { selections, includeFolders, excludeFolders } = sparoProfile; + const { localStateUpdateAction } = options; + await this._localState.setProfiles( + { + [profile]: { + selections, + includeFolders, + excludeFolders + } + }, + localStateUpdateAction + ); + return { + selections, + includeFolders, + excludeFolders + }; + } + + /** + * preprocess profile related args from CLI parameter + */ + public async preprocessProfileArgs({ + profilesFromArg, + addProfilesFromArg + }: { + profilesFromArg: string[]; + addProfilesFromArg: string[]; + }): Promise<{ + isNoProfile: boolean; + profiles: Set; + addProfiles: Set; + }> { + let isNoProfile: boolean = false; + /** + * --profile is defined as array type parameter, specifying --no-profile is resolved to false by yargs. + * + * @example --no-profile -> [false] + * @example --no-profile --profile foo -> [false, "foo"] + * @example --profile foo --no-profile -> ["foo", false] + */ + const profiles: Set = new Set(); + + for (const profile of profilesFromArg) { + if (typeof profile === 'boolean' && profile === false) { + isNoProfile = true; + continue; + } + + profiles.add(profile); + } + + /** + * --add-profile is defined as array type parameter + * @example --no-profile --add-profile foo -> throw error + * @example --profile bar --add-profile foo -> current profiles = bar + foo + * @example --add-profile foo -> current profiles = current profiles + foo + */ + const addProfiles: Set = new Set(addProfilesFromArg.filter((p) => typeof p === 'string')); + + if (isNoProfile && (profiles.size || addProfiles.size)) { + throw new Error(`The "--no-profile" parameter cannot be combined with "--profile" or "--add-profile"`); + } + + // + if (!isNoProfile && profiles.size === 0) { + // Get target profile. + // 1. If profile specified from CLI parameter, preferential use it. + // 2. If none profile specified, read from existing profile from local state as default. + const localStateProfiles: ILocalStateProfiles | undefined = await this._localState.getProfiles(); + + if (localStateProfiles) { + Object.keys(localStateProfiles).forEach((p) => profiles.add(p)); + } + } + return { + isNoProfile, + profiles, + addProfiles + }; + } + + /** + * sync local sparse checkout state with specified profiles + */ + public async syncProfileState({ + profiles, + addProfiles + }: { + profiles?: Set; + addProfiles?: Set; + }): Promise { + this._localState.reset(); + this._terminalService.terminal.writeLine( + `Syncing local sparse checkout state with following specified profiles:\n${Array.from([ + ...(profiles ?? []), + ...(addProfiles ?? []) + ]).join('\n')}` + ); + this._terminalService.terminal.writeLine(); + if (!profiles || profiles.size === 0) { + // If no profile was specified, purge local state to skeleton + await this._gitSparseCheckoutService.purgeAsync(); + } else { + const allProfilesIncludeFolders: string[] = [], + allProfilesExcludeFolders: string[] = [], + allProfilesSelections: ISelection[] = []; + for (const profile of profiles) { + // Since we have run localState.reset() before, for each profile we just add it to local state. + const { selections, includeFolders, excludeFolders } = await this.resolveSparoProfileAsync(profile, { + localStateUpdateAction: 'add' + }); + // combine all profiles' selections and include/exclude folder + allProfilesSelections.push(...selections); + allProfilesIncludeFolders.push(...includeFolders); + allProfilesExcludeFolders.push(...excludeFolders); + } + // sparse-checkout set once for all profiles together + await this._gitSparseCheckoutService.checkoutAsync({ + selections: allProfilesSelections, + includeFolders: allProfilesIncludeFolders, + excludeFolders: allProfilesExcludeFolders, + checkoutAction: 'set' + }); + } + if (addProfiles?.size) { + // If add profiles is specified, using `git sparse-checkout add` to add folders in add profiles + const allAddProfilesSelections: ISelection[] = [], + allAddProfilesIncludeFolders: string[] = [], + allAddProfilesExcludeFolders: string[] = []; + for (const profile of addProfiles) { + // For each add profile we add it to local state. + const { selections, includeFolders, excludeFolders } = await this.resolveSparoProfileAsync(profile, { + localStateUpdateAction: 'add' + }); + // combine all add profiles' selections and include/exclude folder + allAddProfilesSelections.push(...selections); + allAddProfilesIncludeFolders.push(...includeFolders); + allAddProfilesExcludeFolders.push(...excludeFolders); + } + /** + * Note: + * Although we could run sparse-checkout add multiple times, + * we combine all add operations and execute once for better performance. + */ + await this._gitSparseCheckoutService.checkoutAsync({ + selections: allAddProfilesSelections, + includeFolders: allAddProfilesIncludeFolders, + excludeFolders: allAddProfilesExcludeFolders, + checkoutAction: 'add' + }); + } + } } diff --git a/common/changes/sparo/feat-support_profile_parameters_in_pull_2024-02-28-12-40.json b/common/changes/sparo/feat-support_profile_parameters_in_pull_2024-02-28-12-40.json new file mode 100644 index 0000000..c0bc945 --- /dev/null +++ b/common/changes/sparo/feat-support_profile_parameters_in_pull_2024-02-28-12-40.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "sparo", + "comment": "support profile related parameters in pull & clone command", + "type": "none" + } + ], + "packageName": "sparo" +} \ No newline at end of file