Merge pull request #60 from tiktok/feat-sparo-checkout

sparo checkout can handle branch, tag, commit SHA and file path
This commit is contained in:
Cheng Liu 2024-03-28 14:08:18 -07:00 committed by GitHub
commit dbfe9c5094
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 148 additions and 27 deletions

View file

@ -16,6 +16,8 @@ export interface ICheckoutCommandOptions {
addProfile?: string[];
}
type ICheckoutTargetKind = 'branch' | 'tag' | 'commit' | 'filePath';
@Command()
export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
public cmd: string = 'checkout [branch] [start-point]';
@ -74,7 +76,28 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
): Promise<void> => {
const { _gitService: gitService } = this;
terminalService.terminal.writeDebugLine(`got args in checkout command: ${JSON.stringify(args)}`);
const { b, B, branch, startPoint } = args;
const { b, B, startPoint } = args;
let branch: string | undefined = args.branch;
/**
* Special case: "sparo checkout -"
*
* "git checkout -" is a shortcut that checks out the previously checked out branch.
* yargs can not handle this, patch this case here.
*/
if (!branch) {
const checkoutIndex: number = process.argv.findIndex((value: string) => value === 'checkout');
if (checkoutIndex >= 0 && process.argv[checkoutIndex + 1] === '-') {
branch = '-';
// FIXME: supports "sparo checkout -"
throw new Error(
`Git's "-" token is not yet supported. If this feature is important for your work, please let us know by creating a GitHub issue.`
);
}
}
let checkoutTargetKind: ICheckoutTargetKind = 'branch';
/**
* 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.
@ -91,9 +114,25 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
throw new Error(`Failed to get branch ${operationBranch}`);
} else {
if (operationBranch !== currentBranch) {
const isSynced: boolean = this._ensureBranchInLocal(operationBranch);
if (!isSynced) {
throw new Error(`Failed to sync ${operationBranch} from remote server`);
// 1. First, Sparo needs to see the branch matches any branch name
const isBranchSynced: boolean = this._ensureBranchInLocal(operationBranch);
if (isBranchSynced) {
checkoutTargetKind = 'branch';
} else {
// 2. If not, try tag names
const isTagSynced: boolean = this._ensureTagInLocal(operationBranch);
if (isTagSynced) {
checkoutTargetKind = 'tag';
} else {
// 3. If not, try commit SHA
const isCommitSHA: boolean = this._gitService.getObjectType(operationBranch) === 'commit';
if (isCommitSHA) {
checkoutTargetKind = 'commit';
} else {
// 4. Otherwise, treat it as file path
checkoutTargetKind = 'filePath';
}
}
}
}
}
@ -104,8 +143,11 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
profilesFromArg: args.profile
});
// check wether profiles exist in local or operation branch
if (!isNoProfile) {
// Check wether profiles exist in local or operation branch
// Skip check in the following cases:
// 1. No profile
// 2. The target kind is file path
if (!isNoProfile && checkoutTargetKind !== 'filePath') {
const targetProfileNames: Set<string> = new Set([...profiles, ...addProfiles]);
const nonExistProfileNames: string[] = [];
for (const targetProfileName of targetProfileNames) {
@ -147,6 +189,13 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
if (startPoint) {
checkoutArgs.push(startPoint);
}
if (Array.isArray(args['--'])) {
checkoutArgs.push('--');
checkoutArgs.push(...args['--']);
} else if (process.argv.includes('--')) {
// "sparo checkout --" works
checkoutArgs.push('--');
}
const result: child_process.SpawnSyncReturns<string> = gitService.executeGitCommand({
args: checkoutArgs
});
@ -167,6 +216,26 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
}
private _ensureBranchInLocal(branch: string): boolean {
// fetch from remote
const remote: string = this._gitService.getBranchRemote(branch);
const fetchResult: child_process.SpawnSyncReturns<string> = this._gitService.executeGitCommand({
args: [
'fetch',
remote,
`+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
// Follows the recommended config fetch.showForcedUpdates false
'--no-show-forced-updates'
]
});
if (fetchResult.status === 0) {
// create local branch from remote branch
this._gitService.executeGitCommand({
args: ['branch', branch, `${remote}/${branch}`]
});
}
const branchExistsInLocal: boolean = Boolean(
this._gitService
.executeGitCommandAndCaptureOutput({
@ -174,27 +243,8 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
})
.trim()
);
if (!branchExistsInLocal) {
// fetch from remote
const remote: string = this._gitService.getBranchRemote(branch);
const fetchResult: child_process.SpawnSyncReturns<string> = this._gitService.executeGitCommand({
args: ['fetch', remote, `refs/heads/${branch}:refs/remotes/${remote}/${branch}`]
});
if (fetchResult.status !== 0) {
return false;
}
// create local branch from remote branch
const createBranchResult: child_process.SpawnSyncReturns<string> = this._gitService.executeGitCommand({
args: ['branch', branch, `${remote}/${branch}`]
});
if (createBranchResult.status !== 0) {
return false;
}
}
return true;
return branchExistsInLocal;
}
private _getCurrentBranch(): string {
@ -205,4 +255,22 @@ export class CheckoutCommand implements ICommand<ICheckoutCommandOptions> {
.trim();
return currentBranch;
}
private _ensureTagInLocal(tag: string): boolean {
// fetch from remote
const remote: string = 'origin';
this._gitService.executeGitCommand({
args: ['fetch', remote, 'tag', tag, '--force']
});
const tagExistsInLocal: boolean = Boolean(
this._gitService
.executeGitCommandAndCaptureOutput({
args: ['tag', '--list', tag]
})
.trim()
);
return tagExistsInLocal;
}
}

View file

@ -2,7 +2,7 @@ export { Sparo, type ILaunchOptions } from './api/Sparo';
export { getFromContainerAsync } from './di/container';
export { GitService, type IExecuteGitCommandParams } from './services/GitService';
export { GitService, type IExecuteGitCommandParams, type IObjectType } from './services/GitService';
export { TerminalService, type ITerminal } from './services/TerminalService';
export type { ICollectTelemetryFunction, ITelemetryData } from './services/TelemetryService';

View file

@ -26,6 +26,9 @@ export class ArgvService {
* which prints help text instead of proxy args to git commit.
*/
this._parsed = await this.yargsArgv
.parserConfiguration({
'populate--': true
})
// --debug
.boolean('debug')
// --verbose

View file

@ -15,6 +15,11 @@ export interface IExecuteGitCommandParams {
workingDirectory?: string;
}
/**
* @alpha
*/
export type IObjectType = 'blob' | 'tag' | 'commit' | 'tree';
/**
* Help class for git operations
*
@ -390,6 +395,18 @@ Please specify a directory on the command line
return Boolean(result);
}
public getObjectType(object: string): IObjectType | undefined {
let objectType: IObjectType | undefined;
try {
objectType = this.executeGitCommandAndCaptureOutput({
args: ['cat-file', '-t', object]
}).trim() as IObjectType;
} catch (e) {
// query an unknown type
}
return objectType;
}
private _processResult(result: child_process.SpawnSyncReturns<string>): void {
if (result.error) {
result.error.message += '\n' + (result.stderr ? result.stderr.toString() + '\n' : '');

View file

@ -23,4 +23,22 @@ describe(GitService.name, () => {
'%252E%252E%252Fsparo'
);
});
it('should get commit object type', async () => {
const gitService = await getFromContainerAsync(GitService);
// Shallow clone is used in CI builds, so getting the current commit SHA of HEAD
const commitSHA: string = gitService
.executeGitCommandAndCaptureOutput({
args: ['rev-parse', 'HEAD']
})
.trim();
expect(commitSHA).toBeTruthy();
const objectType = gitService.getObjectType(commitSHA);
expect(objectType).toBe('commit');
});
it('should get unknown object type', async () => {
const gitService = await getFromContainerAsync(GitService);
const objectType = gitService.getObjectType('foo');
expect(objectType).toBeUndefined();
});
});

View file

@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "sparo",
"comment": "Checkout can handle branch, tag, commit SHA and file path",
"type": "none"
}
],
"packageName": "sparo"
}

View file

@ -40,6 +40,8 @@ export class GitService {
// (undocumented)
getIsSparseCheckoutMode(): boolean | undefined;
// (undocumented)
getObjectType(object: string): IObjectType | undefined;
// (undocumented)
getRepoInfo(): GitRepoInfo;
get gitPath(): string | undefined;
// (undocumented)
@ -83,6 +85,9 @@ export interface ILaunchOptions {
collectTelemetryAsync?: ICollectTelemetryFunction;
}
// @alpha (undocumented)
export type IObjectType = 'blob' | 'tag' | 'commit' | 'tree';
// @alpha (undocumented)
export interface ITelemetryData {
readonly args: string[];