From 2875895e2b86b9596941e2be5a7d2cc1a31dbf2e Mon Sep 17 00:00:00 2001 From: Cheng Liu Date: Thu, 23 May 2024 17:10:09 -0700 Subject: [PATCH] feat: sparo fetch & pull clean up merged branch from git configuration --- apps/sparo-lib/src/cli/commands/checkout.ts | 39 +--- apps/sparo-lib/src/cli/commands/fetch.ts | 51 +---- apps/sparo-lib/src/cli/commands/pull.ts | 15 +- .../services/GitRemoteFetchConfigService.ts | 174 ++++++++++++++++++ apps/sparo-lib/src/services/GitService.ts | 62 ++++++- .../test/GitRemoteFetchConfigService.test.ts | 28 +++ .../sparo-output-test/etc/top-level-help.txt | 2 +- common/reviews/api/sparo-lib.api.md | 4 + 8 files changed, 293 insertions(+), 82 deletions(-) create mode 100644 apps/sparo-lib/src/services/GitRemoteFetchConfigService.ts create mode 100644 apps/sparo-lib/src/services/test/GitRemoteFetchConfigService.test.ts diff --git a/apps/sparo-lib/src/cli/commands/checkout.ts b/apps/sparo-lib/src/cli/commands/checkout.ts index 52f18df..f1f78d6 100644 --- a/apps/sparo-lib/src/cli/commands/checkout.ts +++ b/apps/sparo-lib/src/cli/commands/checkout.ts @@ -2,11 +2,13 @@ import * as child_process from 'child_process'; import { inject } from 'inversify'; import { Command } from '../../decorator'; import { GitService } from '../../services/GitService'; +import { GitRemoteFetchConfigService } from '../../services/GitRemoteFetchConfigService'; import { TerminalService } from '../../services/TerminalService'; import { SparoProfileService } from '../../services/SparoProfileService'; import type { ICommand } from './base'; import type { ArgumentsCamelCase, Argv } from 'yargs'; + export interface ICheckoutCommandOptions { profile: string[]; branch?: string; @@ -27,6 +29,7 @@ export class CheckoutCommand implements ICommand { 'Updates files in the working tree to match the version in the index or the specified tree. If no pathspec was given, git checkout will also update HEAD to set the specified branch as the current branch.'; @inject(GitService) private _gitService!: GitService; + @inject(GitRemoteFetchConfigService) private _gitRemoteFetchConfigService!: GitRemoteFetchConfigService; @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; public builder(yargs: Argv<{}>): void { @@ -119,7 +122,7 @@ export class CheckoutCommand implements ICommand { /** * 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. */ - const currentBranch: string = this._getCurrentBranch(); + const currentBranch: string = this._gitService.getCurrentBranch(); let operationBranch: string = currentBranch; if (b || B) { operationBranch = startPoint || operationBranch; @@ -266,7 +269,7 @@ export class CheckoutCommand implements ICommand { args: ['branch', branch, `${remote}/${branch}`] }); - this._addRemoteBranchIfNotExists(remote, branch); + this._gitRemoteFetchConfigService.addRemoteBranchIfNotExists(remote, branch); } const branchExistsInLocal: boolean = Boolean( @@ -280,15 +283,6 @@ export class CheckoutCommand implements ICommand { return branchExistsInLocal; } - private _getCurrentBranch(): string { - const currentBranch: string = this._gitService - .executeGitCommandAndCaptureOutput({ - args: ['branch', '--show-current'] - }) - .trim(); - return currentBranch; - } - private _ensureTagInLocal(tag: string): boolean { // fetch from remote const remote: string = 'origin'; @@ -306,27 +300,4 @@ export class CheckoutCommand implements ICommand { ); return tagExistsInLocal; } - - private _addRemoteBranchIfNotExists(remote: string, branch: string): void { - const result: string | undefined = this._gitService.getGitConfig(`remote.${remote}.fetch`, { - array: true - }); - const remoteFetchGitConfig: string[] | undefined = result?.split('\n').filter(Boolean); - - if (remoteFetchGitConfig) { - const targetConfig: string = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`; - if ( - // Prevents adding remote branch if it is not single branch mode - remoteFetchGitConfig.includes(`+refs/heads/*:refs/remotes/${remote}/*`) || - // Prevents adding the same remote branch multiple times - remoteFetchGitConfig?.some((value: string) => value === targetConfig) - ) { - return; - } - } - - this._gitService.executeGitCommand({ - args: ['remote', 'set-branches', '--add', remote, branch] - }); - } } diff --git a/apps/sparo-lib/src/cli/commands/fetch.ts b/apps/sparo-lib/src/cli/commands/fetch.ts index cea31a6..4f14674 100644 --- a/apps/sparo-lib/src/cli/commands/fetch.ts +++ b/apps/sparo-lib/src/cli/commands/fetch.ts @@ -1,7 +1,7 @@ import { inject } from 'inversify'; import { Command } from '../../decorator'; import { GitService } from '../../services/GitService'; -import { GracefulShutdownService } from '../../services/GracefulShutdownService'; +import { GitRemoteFetchConfigService } from '../../services/GitRemoteFetchConfigService'; import type { Argv, ArgumentsCamelCase } from 'yargs'; import type { GitRepoInfo } from 'git-repo-info'; @@ -20,7 +20,8 @@ export class FetchCommand implements ICommand { public description: string = 'fetch remote branch to local'; @inject(GitService) private _gitService!: GitService; - @inject(GracefulShutdownService) private _gracefulShutdownService!: GracefulShutdownService; + @inject(GitRemoteFetchConfigService) private _gitRemoteFetchConfigService!: GitRemoteFetchConfigService; + public builder(yargs: Argv<{}>): void { /** * sparo fetch [--all] @@ -46,10 +47,12 @@ export class FetchCommand implements ICommand { const { all, branch = defaultBranch, remote = this._gitService.getBranchRemote(branch) } = args; const fetchArgs: string[] = ['fetch']; + await this._gitRemoteFetchConfigService.pruneRemoteBranchesInGitConfigAsync(remote || 'origin'); + let restoreSingleBranchCallback: (() => void) | undefined; if (all) { // Temporary revert single branch fetch if necessary - restoreSingleBranchCallback = this._revertSingleBranchIfNecessary(remote); + restoreSingleBranchCallback = this._gitRemoteFetchConfigService.revertSingleBranchIfNecessary(remote); fetchArgs.push('--all'); } else { @@ -64,46 +67,4 @@ export class FetchCommand implements ICommand { public getHelp(): string { return `fetch help`; } - - private _revertSingleBranchIfNecessary = (remote: string): (() => void) | undefined => { - let remoteFetchGitConfig: string[] | undefined = this._getRemoteFetchGitConfig(remote); - let callback: (() => void) | undefined; - if (remoteFetchGitConfig && !remoteFetchGitConfig.includes(`+refs/heads/*:refs/remotes/${remote}/*`)) { - this._setAllBranchFetch(remote); - - callback = () => { - if (remoteFetchGitConfig) { - this._restoreSingleBranchFetch(remote, remoteFetchGitConfig); - - // Avoid memory leaking - remoteFetchGitConfig = undefined; - this._gracefulShutdownService.unregisterCallback(callback); - } - }; - - this._gracefulShutdownService.registerCallback(callback); - } - - return callback; - }; - - private _getRemoteFetchGitConfig(remote: string): string[] | undefined { - const result: string | undefined = this._gitService.getGitConfig(`remote.${remote}.fetch`, { - array: true - }); - return result?.split('\n').filter(Boolean); - } - - private _setAllBranchFetch(remote: string): void { - this._gitService.setGitConfig(`remote.${remote}.fetch`, `+refs/heads/*:refs/remotes/${remote}/*`, { - replaceAll: true - }); - } - - private _restoreSingleBranchFetch(remote: string, remoteFetchGitConfig: string[]): void { - this._gitService.unsetGitConfig(`remote.${remote}.fetch`); - for (const value of remoteFetchGitConfig) { - this._gitService.setGitConfig(`remote.${remote}.fetch`, value, { add: true }); - } - } } diff --git a/apps/sparo-lib/src/cli/commands/pull.ts b/apps/sparo-lib/src/cli/commands/pull.ts index a1a3f90..88c7122 100644 --- a/apps/sparo-lib/src/cli/commands/pull.ts +++ b/apps/sparo-lib/src/cli/commands/pull.ts @@ -1,6 +1,7 @@ import { inject } from 'inversify'; import { Command } from '../../decorator'; import { GitService } from '../../services/GitService'; +import { GitRemoteFetchConfigService } from '../../services/GitRemoteFetchConfigService'; import { SparoProfileService } from '../../services/SparoProfileService'; import type { Argv, ArgumentsCamelCase } from 'yargs'; @@ -8,15 +9,17 @@ import type { ICommand } from './base'; import type { TerminalService } from '../../services/TerminalService'; export interface IPullCommandOptions { + remote?: string; profile?: string[]; } @Command() export class PullCommand implements ICommand { - public cmd: string = 'pull'; + public cmd: string = 'pull [remote]'; public description: string = 'Incorporates changes from a remote repository into the current branch.'; @inject(GitService) private _gitService!: GitService; + @inject(GitRemoteFetchConfigService) private _gitRemoteFetchConfigService!: GitRemoteFetchConfigService; @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; public builder = (yargs: Argv<{}>): void => { @@ -28,6 +31,9 @@ export class PullCommand implements ICommand { * sparo pull origin master */ yargs + .positional('remote', { + type: 'string' + }) .array('profile') .default('profile', []) .parserConfiguration({ 'unknown-options-as-args': true }) @@ -54,6 +60,13 @@ export class PullCommand implements ICommand { addProfilesFromArg: [] }); + const { remote } = args; + if (remote) { + pullArgs.splice(1, 0, remote); + } + + await this._gitRemoteFetchConfigService.pruneRemoteBranchesInGitConfigAsync(remote || 'origin'); + // invoke native git pull command gitService.executeGitCommand({ args: pullArgs }); diff --git a/apps/sparo-lib/src/services/GitRemoteFetchConfigService.ts b/apps/sparo-lib/src/services/GitRemoteFetchConfigService.ts new file mode 100644 index 0000000..70125b0 --- /dev/null +++ b/apps/sparo-lib/src/services/GitRemoteFetchConfigService.ts @@ -0,0 +1,174 @@ +import { inject } from 'inversify'; +import { Colorize } from '@rushstack/terminal'; + +import { Service } from '../decorator'; +import { GitService } from './GitService'; +import { TerminalService } from './TerminalService'; +import { GracefulShutdownService } from './GracefulShutdownService'; + +/** + * Helper class for git remote.origin.fetch config + * + * @alpha + */ +@Service() +export class GitRemoteFetchConfigService { + @inject(GitService) private _gitService!: GitService; + @inject(TerminalService) private _terminalService!: TerminalService; + @inject(GracefulShutdownService) private _gracefulShutdownService!: GracefulShutdownService; + + public addRemoteBranchIfNotExists(remote: string, branch: string): void { + const remoteFetchGitConfig: string[] | undefined = this._loadForRemote(remote); + + if (remoteFetchGitConfig) { + const targetConfig: string = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`; + if ( + // Prevents adding remote branch if it is not single branch mode + remoteFetchGitConfig.includes(`+refs/heads/*:refs/remotes/${remote}/*`) || + // Prevents adding the same remote branch multiple times + remoteFetchGitConfig?.some((value: string) => value === targetConfig) + ) { + return; + } + } + + this._gitService.executeGitCommand({ + args: ['remote', 'set-branches', '--add', remote, branch] + }); + } + + public pruneRemoteBranchesInGitConfigAsync = async (remote: string): Promise => { + const remoteFetchConfig: string[] | undefined = this._loadForRemote(remote); + if (!remoteFetchConfig) { + return; + } + + const invalidRemoteFetchConfig: string[] = []; + const invalidBranches: string[] = []; + const branchToValues: Map> = this.getBranchesInfoFromRemoteFetchConfig( + remoteFetchConfig + ); + const checkBranches: string[] = Array.from(branchToValues.keys()).filter((x) => x !== '*'); + + const remoteBranchExistenceInfo: Record = + await this._gitService.checkRemoteBranchesExistenceAsync(remote, checkBranches); + + for (const [branch, isExists] of Object.entries(remoteBranchExistenceInfo)) { + if (isExists) { + continue; + } + + invalidBranches.push(branch); + + const remoteFetchConfigValues: Set | undefined = branchToValues.get(branch); + if (remoteFetchConfigValues) { + invalidRemoteFetchConfig.push(...remoteFetchConfigValues); + } + } + + if (invalidRemoteFetchConfig.length) { + for (const invalidBranch of invalidBranches) { + this._terminalService.terminal.writeLine( + Colorize.gray( + `Branch "${invalidBranch}" doesn't exist remotely. It might have been merged into the main branch. Pruning this branch from the git configuration.` + ) + ); + } + const nextRemoteFetchConfigSet: Set = new Set(remoteFetchConfig); + this._terminalService.terminal.writeDebugLine( + `Pruning the following value(s) in remote.${remote}.fetch from git configuration` + ); + for (const invalidValue of invalidRemoteFetchConfig) { + this._terminalService.terminal.writeDebugLine(invalidValue); + nextRemoteFetchConfigSet.delete(invalidValue); + } + + // Restores previous git configuration if something went wrong + const callback = (): void => { + this._setRemoteFetchInGitConfig(remote, remoteFetchConfig); + this._terminalService.terminal.writeDebugLine( + `Restore previous remote.${remote}.fetch to git configuration` + ); + }; + + this._gracefulShutdownService.registerCallback(callback); + this._setRemoteFetchInGitConfig(remote, Array.from(nextRemoteFetchConfigSet)); + this._gracefulShutdownService.unregisterCallback(callback); + } + }; + + /** + * Sparo uses single branch mode as default. This function switch to all branch mode from single branch mode. + * And, it returns a callback function to go back to single branch mode with previous git configuration. + * It's used in "sparo fetch --all" command + */ + public revertSingleBranchIfNecessary = (remote: string): (() => void) | undefined => { + let remoteFetchGitConfig: string[] | undefined = this._loadForRemote(remote); + let callback: (() => void) | undefined; + if (remoteFetchGitConfig && !remoteFetchGitConfig.includes(`+refs/heads/*:refs/remotes/${remote}/*`)) { + this._setAllBranchFetch(remote); + + callback = () => { + if (remoteFetchGitConfig) { + this._setRemoteFetchInGitConfig(remote, remoteFetchGitConfig); + + // Avoid memory leaking + remoteFetchGitConfig = undefined; + this._gracefulShutdownService.unregisterCallback(callback); + } + }; + + this._gracefulShutdownService.registerCallback(callback); + } + + return callback; + }; + + /** + * Reads remote.origin.fetch from git configuration. It returns a mapping + */ + public getBranchesInfoFromRemoteFetchConfig(remoteFetchConfig: string[]): Map> { + const branchRegExp: RegExp = /^(?:\+)?refs\/heads\/([^:]+):/; + const branchToValues: Map> = new Map>(); + for (const remoteFetchConfigValue of remoteFetchConfig) { + const match: RegExpMatchArray | null = remoteFetchConfigValue.match(branchRegExp); + if (match) { + const branch: string | undefined = match[1]; + if (branch) { + let values: Set | undefined = branchToValues.get(branch); + if (!values) { + values = new Set(); + branchToValues.set(branch, values); + } + values.add(remoteFetchConfigValue); + } + } + } + return branchToValues; + } + + private _loadForRemote(remote: string): string[] | undefined { + const result: string | undefined = this._gitService.getGitConfig(`remote.${remote}.fetch`, { + array: true + }); + const remoteFetchGitConfig: string[] | undefined = result?.split('\n').filter(Boolean); + return remoteFetchGitConfig; + } + + /** + * There is no easy way to unset one branch from git configuration + * So, delete all remote.origin.fetch configuration and restores expected value + */ + private _setRemoteFetchInGitConfig(remote: string, remoteFetchGitConfig: string[]): void { + this._gitService.unsetGitConfig(`remote.${remote}.fetch`); + for (const value of remoteFetchGitConfig) { + this._gitService.setGitConfig(`remote.${remote}.fetch`, value, { add: true }); + } + } + + private _setAllBranchFetch(remote: string): void { + this._gitService.setGitConfig(`remote.${remote}.fetch`, `+refs/heads/*:refs/remotes/${remote}/*`, { + replaceAll: true + }); + } +} diff --git a/apps/sparo-lib/src/services/GitService.ts b/apps/sparo-lib/src/services/GitService.ts index 69e5cd9..1e0a7f5 100644 --- a/apps/sparo-lib/src/services/GitService.ts +++ b/apps/sparo-lib/src/services/GitService.ts @@ -1,5 +1,5 @@ import * as child_process from 'child_process'; -import { Executable } from '@rushstack/node-core-library'; +import { Async, Executable } from '@rushstack/node-core-library'; import getRepoInfo, { type GitRepoInfo } from 'git-repo-info'; import { inject } from 'inversify'; import { Service } from '../decorator'; @@ -438,6 +438,66 @@ Please specify a directory on the command line return objectType; } + public getCurrentBranch(): string { + const currentBranch: string = this.executeGitCommandAndCaptureOutput({ + args: ['branch', '--show-current'] + }).trim(); + return currentBranch; + } + + /** + * Check existence for a list of branch name + */ + public checkRemoteBranchesExistenceAsync = async ( + remote: string, + branches: string[] + ): Promise> => { + this._terminalService.terminal.writeDebugLine(`Checking branches: ${branches.join(',')}`); + const ret: Record = {}; + await Async.forEachAsync(branches, async (branch: string) => { + const isExists: boolean = await this.checkRemoteBranchExistenceAsync(remote, branch); + ret[branch] = isExists; + }); + return ret; + }; + + /** + * Check existence for one branch name. + * + * {@link checkRemoteBranchesExistenceAsync} is preferred if you are going to check a list of branch name. + */ + public checkRemoteBranchExistenceAsync = async (remote: string, branch: string): Promise => { + const gitPath: string = this.getGitPathOrThrow(); + const currentWorkingDirectory: string = this.getRepoInfo().root; + const childProcess: child_process.ChildProcess = Executable.spawn( + gitPath, + ['ls-remote', '--exit-code', remote, branch], + { + currentWorkingDirectory, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + if (!childProcess.stdout || !childProcess.stderr) { + this._terminalService.terminal.writeDebugLine(`Failed to spawn git process, fallback to spawnSync`); + const result: string = this.executeGitCommandAndCaptureOutput({ + args: ['ls-remote', remote, branch] + }).trim(); + return Promise.resolve(!!result); + } + return await new Promise((resolve, reject) => { + // Only care about exit code since specifying --exit-code + childProcess.on('close', (exitCode: number | null) => { + if (exitCode) { + this._terminalService.terminal.writeDebugLine(`Branch "${branch}" doesn't exist remotely`); + resolve(false); + } else { + this._terminalService.terminal.writeDebugLine(`Branch "${branch}" exists remotely`); + resolve(true); + } + }); + }); + }; + private _processResult(result: child_process.SpawnSyncReturns): void { if (result.error) { result.error.message += '\n' + (result.stderr ? result.stderr.toString() + '\n' : ''); diff --git a/apps/sparo-lib/src/services/test/GitRemoteFetchConfigService.test.ts b/apps/sparo-lib/src/services/test/GitRemoteFetchConfigService.test.ts new file mode 100644 index 0000000..04dd536 --- /dev/null +++ b/apps/sparo-lib/src/services/test/GitRemoteFetchConfigService.test.ts @@ -0,0 +1,28 @@ +import { getFromContainer } from '../../di/container'; +import { GitRemoteFetchConfigService } from '../GitRemoteFetchConfigService'; + +describe(GitRemoteFetchConfigService.name, () => { + const gitRemoteFetchConfigService = getFromContainer(GitRemoteFetchConfigService); + + describe(gitRemoteFetchConfigService.getBranchesInfoFromRemoteFetchConfig, () => { + it('should work', () => { + const values: string[] = [ + '+refs/heads/*:refs/remotes/origin/*', + '+refs/heads/release:refs/remotes/origin/release', + '+refs/heads/feat/abc:refs/remotes/origin/feat/abc' + ]; + + const expectedContaining: Record = { + '*': values[0], + release: values[1], + 'feat/abc': values[2] + }; + + for (const [k, v] of Object.entries( + gitRemoteFetchConfigService.getBranchesInfoFromRemoteFetchConfig(values) + )) { + expect(v).toBe(expect.arrayContaining([expectedContaining[k]])); + } + }); + }); +}); diff --git a/build-tests/sparo-output-test/etc/top-level-help.txt b/build-tests/sparo-output-test/etc/top-level-help.txt index 1386a66..0ca4aef 100644 --- a/build-tests/sparo-output-test/etc/top-level-help.txt +++ b/build-tests/sparo-output-test/etc/top-level-help.txt @@ -20,7 +20,7 @@ Commands: HEAD to set the specified branch as the current branch. sparo fetch [remote] [branch] fetch remote branch to local - sparo pull Incorporates changes from a remote + sparo pull [remote] Incorporates changes from a remote repository into the current branch. sparo git-clone original git clone command sparo git-checkout original git checkout command diff --git a/common/reviews/api/sparo-lib.api.md b/common/reviews/api/sparo-lib.api.md index e1c68ed..7a28d3c 100644 --- a/common/reviews/api/sparo-lib.api.md +++ b/common/reviews/api/sparo-lib.api.md @@ -17,6 +17,8 @@ export function getFromContainerAsync(clazz: Constructable): Promise; // @alpha export class GitService { + checkRemoteBranchesExistenceAsync: (remote: string, branches: string[]) => Promise>; + checkRemoteBranchExistenceAsync: (remote: string, branch: string) => Promise; // (undocumented) executeGitCommand({ args, workingDirectory }: IExecuteGitCommandParams): child_process.SpawnSyncReturns; // (undocumented) @@ -25,6 +27,8 @@ export class GitService { // (undocumented) getBranchRemote(branch: string): string; // (undocumented) + getCurrentBranch(): string; + // (undocumented) getGitConfig(k: string, option?: { dryRun?: boolean; global?: boolean;