diff --git a/apps/sparo-lib/src/cli/commands/checkout.ts b/apps/sparo-lib/src/cli/commands/checkout.ts index 77a53b9..88a0dde 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 { /** @@ -69,14 +64,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. */ @@ -99,24 +90,15 @@ export class CheckoutCommand implements ICommand { } } - const targetProfileNames: Set = new Set(); - 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)); - } - - if (addProfiles.size) { - addProfiles.forEach((p) => targetProfileNames.add(p)); - } + // 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) { + const targetProfileNames: Set = new Set([...profiles, ...addProfiles]); const nonExistProfileNames: string[] = []; for (const targetProfileName of targetProfileNames) { /** @@ -165,43 +147,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) { - // TODO: policy #1: Can not sparse checkout with uncommitted changes in the cone. - 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._gitSparseCheckoutService.resolveSparoProfileAsync(profile, { - localStateUpdateAction: 'add' - }); - // for profiles, we use sparse checkout set - await this._gitSparseCheckoutService.checkoutAsync({ - selections, - includeFolders, - excludeFolders, - checkoutAction: 'set' - }); - } - for (const profile of addProfiles) { - // For each add profile we add it to local state. - const { selections, includeFolders, excludeFolders } = - await this._gitSparseCheckoutService.resolveSparoProfileAsync(profile, { - localStateUpdateAction: 'add' - }); - // for add profiles, we use sparse checkout add - await this._gitSparseCheckoutService.checkoutAsync({ - selections, - includeFolders, - excludeFolders, - checkoutAction: 'add' - }); - } - } + // sync local sparse checkout state with given profiles. + this._sparoProfileService.syncProfileState({ + profiles: isNoProfile ? null : profiles, + addProfiles + }); }; public getHelp(): string { @@ -247,53 +197,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/cmd-list.ts b/apps/sparo-lib/src/cli/commands/cmd-list.ts index ad5a3cd..b5681bd 100644 --- a/apps/sparo-lib/src/cli/commands/cmd-list.ts +++ b/apps/sparo-lib/src/cli/commands/cmd-list.ts @@ -13,6 +13,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 @@ -25,6 +26,7 @@ export const COMMAND_LIST: Constructable[] = [ CloneCommand, CheckoutCommand, FetchCommand, + PullCommand, // The commands customized by Sparo require a mirror command to Git GitCloneCommand, 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..9107dbe --- /dev/null +++ b/apps/sparo-lib/src/cli/commands/pull.ts @@ -0,0 +1,96 @@ +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 { GitRepoInfo } from 'git-repo-info'; +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 = 'pull changes from remote branch to local'; + + @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; + const repoInfo: GitRepoInfo = gitService.getRepoInfo(); + + 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) { + 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. + this._sparoProfileService.syncProfileState({ + profiles: isNoProfile ? null : profiles, + addProfiles + }); + }; + + public getHelp(): string { + return `pull help`; + } +} diff --git a/apps/sparo-lib/src/services/SparoProfileService.ts b/apps/sparo-lib/src/services/SparoProfileService.ts index 83f9842..6309564 100644 --- a/apps/sparo-lib/src/services/SparoProfileService.ts +++ b/apps/sparo-lib/src/services/SparoProfileService.ts @@ -2,9 +2,11 @@ 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 } from '../logic/LocalState'; export interface ISparoProfileServiceParams { terminalService: TerminalService; @@ -19,6 +21,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) { @@ -99,4 +103,139 @@ export class SparoProfileService { } return last; } + + /** + * 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] + */ + let 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 | null; + addProfiles: Set; + }): Promise { + /* + * 2. If profile array is specified, using `git sparse-checkout set` to set sparse checkout folders in profiles. + * 3. If add profiles is specified, using `git sparse-checkout add` to add folders in add profiles + */ + + this._localState.reset(); + 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._gitSparseCheckoutService.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._gitSparseCheckoutService.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..18a701a --- /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 command", + "type": "none" + } + ], + "packageName": "sparo" +} \ No newline at end of file