feat: sparo fetch & pull clean up merged branch from git configuration

This commit is contained in:
Cheng Liu 2024-05-23 17:10:09 -07:00
parent 4dbdd16c16
commit 2875895e2b
No known key found for this signature in database
GPG key ID: EEC8452F7DB85CD6
8 changed files with 293 additions and 82 deletions

View file

@ -2,11 +2,13 @@ import * as child_process from 'child_process';
import { inject } from 'inversify'; import { inject } from 'inversify';
import { Command } from '../../decorator'; import { Command } from '../../decorator';
import { GitService } from '../../services/GitService'; import { GitService } from '../../services/GitService';
import { GitRemoteFetchConfigService } from '../../services/GitRemoteFetchConfigService';
import { TerminalService } from '../../services/TerminalService'; import { TerminalService } from '../../services/TerminalService';
import { SparoProfileService } from '../../services/SparoProfileService'; import { SparoProfileService } from '../../services/SparoProfileService';
import type { ICommand } from './base'; import type { ICommand } from './base';
import type { ArgumentsCamelCase, Argv } from 'yargs'; import type { ArgumentsCamelCase, Argv } from 'yargs';
export interface ICheckoutCommandOptions { export interface ICheckoutCommandOptions {
profile: string[]; profile: string[];
branch?: string; branch?: string;
@ -27,6 +29,7 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
'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.'; '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(GitService) private _gitService!: GitService;
@inject(GitRemoteFetchConfigService) private _gitRemoteFetchConfigService!: GitRemoteFetchConfigService;
@inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService;
public builder(yargs: Argv<{}>): void { public builder(yargs: Argv<{}>): void {
@ -119,7 +122,7 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
/** /**
* 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. * 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; let operationBranch: string = currentBranch;
if (b || B) { if (b || B) {
operationBranch = startPoint || operationBranch; operationBranch = startPoint || operationBranch;
@ -266,7 +269,7 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
args: ['branch', branch, `${remote}/${branch}`] args: ['branch', branch, `${remote}/${branch}`]
}); });
this._addRemoteBranchIfNotExists(remote, branch); this._gitRemoteFetchConfigService.addRemoteBranchIfNotExists(remote, branch);
} }
const branchExistsInLocal: boolean = Boolean( const branchExistsInLocal: boolean = Boolean(
@ -280,15 +283,6 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
return branchExistsInLocal; return branchExistsInLocal;
} }
private _getCurrentBranch(): string {
const currentBranch: string = this._gitService
.executeGitCommandAndCaptureOutput({
args: ['branch', '--show-current']
})
.trim();
return currentBranch;
}
private _ensureTagInLocal(tag: string): boolean { private _ensureTagInLocal(tag: string): boolean {
// fetch from remote // fetch from remote
const remote: string = 'origin'; const remote: string = 'origin';
@ -306,27 +300,4 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
); );
return tagExistsInLocal; 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]
});
}
} }

View file

@ -1,7 +1,7 @@
import { inject } from 'inversify'; import { inject } from 'inversify';
import { Command } from '../../decorator'; import { Command } from '../../decorator';
import { GitService } from '../../services/GitService'; import { GitService } from '../../services/GitService';
import { GracefulShutdownService } from '../../services/GracefulShutdownService'; import { GitRemoteFetchConfigService } from '../../services/GitRemoteFetchConfigService';
import type { Argv, ArgumentsCamelCase } from 'yargs'; import type { Argv, ArgumentsCamelCase } from 'yargs';
import type { GitRepoInfo } from 'git-repo-info'; import type { GitRepoInfo } from 'git-repo-info';
@ -20,7 +20,8 @@ export class FetchCommand implements ICommand<IFetchCommandOptions> {
public description: string = 'fetch remote branch to local'; public description: string = 'fetch remote branch to local';
@inject(GitService) private _gitService!: GitService; @inject(GitService) private _gitService!: GitService;
@inject(GracefulShutdownService) private _gracefulShutdownService!: GracefulShutdownService; @inject(GitRemoteFetchConfigService) private _gitRemoteFetchConfigService!: GitRemoteFetchConfigService;
public builder(yargs: Argv<{}>): void { public builder(yargs: Argv<{}>): void {
/** /**
* sparo fetch <remote> <branch> [--all] * sparo fetch <remote> <branch> [--all]
@ -46,10 +47,12 @@ export class FetchCommand implements ICommand<IFetchCommandOptions> {
const { all, branch = defaultBranch, remote = this._gitService.getBranchRemote(branch) } = args; const { all, branch = defaultBranch, remote = this._gitService.getBranchRemote(branch) } = args;
const fetchArgs: string[] = ['fetch']; const fetchArgs: string[] = ['fetch'];
await this._gitRemoteFetchConfigService.pruneRemoteBranchesInGitConfigAsync(remote || 'origin');
let restoreSingleBranchCallback: (() => void) | undefined; let restoreSingleBranchCallback: (() => void) | undefined;
if (all) { if (all) {
// Temporary revert single branch fetch if necessary // Temporary revert single branch fetch if necessary
restoreSingleBranchCallback = this._revertSingleBranchIfNecessary(remote); restoreSingleBranchCallback = this._gitRemoteFetchConfigService.revertSingleBranchIfNecessary(remote);
fetchArgs.push('--all'); fetchArgs.push('--all');
} else { } else {
@ -64,46 +67,4 @@ export class FetchCommand implements ICommand<IFetchCommandOptions> {
public getHelp(): string { public getHelp(): string {
return `fetch help`; 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 });
}
}
} }

View file

@ -1,6 +1,7 @@
import { inject } from 'inversify'; import { inject } from 'inversify';
import { Command } from '../../decorator'; import { Command } from '../../decorator';
import { GitService } from '../../services/GitService'; import { GitService } from '../../services/GitService';
import { GitRemoteFetchConfigService } from '../../services/GitRemoteFetchConfigService';
import { SparoProfileService } from '../../services/SparoProfileService'; import { SparoProfileService } from '../../services/SparoProfileService';
import type { Argv, ArgumentsCamelCase } from 'yargs'; import type { Argv, ArgumentsCamelCase } from 'yargs';
@ -8,15 +9,17 @@ import type { ICommand } from './base';
import type { TerminalService } from '../../services/TerminalService'; import type { TerminalService } from '../../services/TerminalService';
export interface IPullCommandOptions { export interface IPullCommandOptions {
remote?: string;
profile?: string[]; profile?: string[];
} }
@Command() @Command()
export class PullCommand implements ICommand<IPullCommandOptions> { export class PullCommand implements ICommand<IPullCommandOptions> {
public cmd: string = 'pull'; public cmd: string = 'pull [remote]';
public description: string = 'Incorporates changes from a remote repository into the current branch.'; public description: string = 'Incorporates changes from a remote repository into the current branch.';
@inject(GitService) private _gitService!: GitService; @inject(GitService) private _gitService!: GitService;
@inject(GitRemoteFetchConfigService) private _gitRemoteFetchConfigService!: GitRemoteFetchConfigService;
@inject(SparoProfileService) private _sparoProfileService!: SparoProfileService; @inject(SparoProfileService) private _sparoProfileService!: SparoProfileService;
public builder = (yargs: Argv<{}>): void => { public builder = (yargs: Argv<{}>): void => {
@ -28,6 +31,9 @@ export class PullCommand implements ICommand<IPullCommandOptions> {
* sparo pull origin master * sparo pull origin master
*/ */
yargs yargs
.positional('remote', {
type: 'string'
})
.array('profile') .array('profile')
.default('profile', []) .default('profile', [])
.parserConfiguration({ 'unknown-options-as-args': true }) .parserConfiguration({ 'unknown-options-as-args': true })
@ -54,6 +60,13 @@ export class PullCommand implements ICommand<IPullCommandOptions> {
addProfilesFromArg: [] addProfilesFromArg: []
}); });
const { remote } = args;
if (remote) {
pullArgs.splice(1, 0, remote);
}
await this._gitRemoteFetchConfigService.pruneRemoteBranchesInGitConfigAsync(remote || 'origin');
// invoke native git pull command // invoke native git pull command
gitService.executeGitCommand({ args: pullArgs }); gitService.executeGitCommand({ args: pullArgs });

View file

@ -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<void> => {
const remoteFetchConfig: string[] | undefined = this._loadForRemote(remote);
if (!remoteFetchConfig) {
return;
}
const invalidRemoteFetchConfig: string[] = [];
const invalidBranches: string[] = [];
const branchToValues: Map<string, Set<string>> = this.getBranchesInfoFromRemoteFetchConfig(
remoteFetchConfig
);
const checkBranches: string[] = Array.from(branchToValues.keys()).filter((x) => x !== '*');
const remoteBranchExistenceInfo: Record<string, boolean> =
await this._gitService.checkRemoteBranchesExistenceAsync(remote, checkBranches);
for (const [branch, isExists] of Object.entries(remoteBranchExistenceInfo)) {
if (isExists) {
continue;
}
invalidBranches.push(branch);
const remoteFetchConfigValues: Set<string> | 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<string> = new Set<string>(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<string, Set<string>> {
const branchRegExp: RegExp = /^(?:\+)?refs\/heads\/([^:]+):/;
const branchToValues: Map<string, Set<string>> = new Map<string, Set<string>>();
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<string> | undefined = branchToValues.get(branch);
if (!values) {
values = new Set<string>();
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
});
}
}

View file

@ -1,5 +1,5 @@
import * as child_process from 'child_process'; 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 getRepoInfo, { type GitRepoInfo } from 'git-repo-info';
import { inject } from 'inversify'; import { inject } from 'inversify';
import { Service } from '../decorator'; import { Service } from '../decorator';
@ -438,6 +438,66 @@ Please specify a directory on the command line
return objectType; 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<Record<string, boolean>> => {
this._terminalService.terminal.writeDebugLine(`Checking branches: ${branches.join(',')}`);
const ret: Record<string, boolean> = {};
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<boolean> => {
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<string>): void { private _processResult(result: child_process.SpawnSyncReturns<string>): void {
if (result.error) { if (result.error) {
result.error.message += '\n' + (result.stderr ? result.stderr.toString() + '\n' : ''); result.error.message += '\n' + (result.stderr ? result.stderr.toString() + '\n' : '');

View file

@ -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<string, string> = {
'*': 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]]));
}
});
});
});

View file

@ -20,7 +20,7 @@ Commands:
HEAD to set the specified branch as the HEAD to set the specified branch as the
current branch. current branch.
sparo fetch [remote] [branch] fetch remote branch to local 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. repository into the current branch.
sparo git-clone original git clone command sparo git-clone original git clone command
sparo git-checkout original git checkout command sparo git-checkout original git checkout command

View file

@ -17,6 +17,8 @@ export function getFromContainerAsync<T>(clazz: Constructable<T>): Promise<T>;
// @alpha // @alpha
export class GitService { export class GitService {
checkRemoteBranchesExistenceAsync: (remote: string, branches: string[]) => Promise<Record<string, boolean>>;
checkRemoteBranchExistenceAsync: (remote: string, branch: string) => Promise<boolean>;
// (undocumented) // (undocumented)
executeGitCommand({ args, workingDirectory }: IExecuteGitCommandParams): child_process.SpawnSyncReturns<string>; executeGitCommand({ args, workingDirectory }: IExecuteGitCommandParams): child_process.SpawnSyncReturns<string>;
// (undocumented) // (undocumented)
@ -25,6 +27,8 @@ export class GitService {
// (undocumented) // (undocumented)
getBranchRemote(branch: string): string; getBranchRemote(branch: string): string;
// (undocumented) // (undocumented)
getCurrentBranch(): string;
// (undocumented)
getGitConfig(k: string, option?: { getGitConfig(k: string, option?: {
dryRun?: boolean; dryRun?: boolean;
global?: boolean; global?: boolean;