Merge pull request #72 from tiktok/feat-pull-remote-fetch

Feat pull remote fetch
This commit is contained in:
Cheng Liu 2024-05-24 13:46:29 -07:00 committed by GitHub
commit 08a6647636
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 303 additions and 82 deletions

View file

@ -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<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.';
@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<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.
*/
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<ICheckoutCommandOptions> {
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<ICheckoutCommandOptions> {
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<ICheckoutCommandOptions> {
);
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 { 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<IFetchCommandOptions> {
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 <remote> <branch> [--all]
@ -46,10 +47,12 @@ export class FetchCommand implements ICommand<IFetchCommandOptions> {
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<IFetchCommandOptions> {
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 });
}
}
}

View file

@ -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<IPullCommandOptions> {
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<IPullCommandOptions> {
* 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<IPullCommandOptions> {
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 });

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 { 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<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.
*
* Function "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 {
if (result.error) {
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
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

View file

@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "sparo",
"comment": "\"sparo fetch\" and \"sparo pull\" automatically clean up merged branches from the git configuration before actually invoking git fetch or pull",
"type": "none"
}
],
"packageName": "sparo"
}

View file

@ -17,6 +17,8 @@ export function getFromContainerAsync<T>(clazz: Constructable<T>): Promise<T>;
// @alpha
export class GitService {
checkRemoteBranchesExistenceAsync: (remote: string, branches: string[]) => Promise<Record<string, boolean>>;
checkRemoteBranchExistenceAsync: (remote: string, branch: string) => Promise<boolean>;
// (undocumented)
executeGitCommand({ args, workingDirectory }: IExecuteGitCommandParams): child_process.SpawnSyncReturns<string>;
// (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;