Skip to content

Instantly share code, notes, and snippets.

@hxsf
Last active September 2, 2019 08:51
Show Gist options
  • Save hxsf/9743eb9709008c05550f77e4ebfe9906 to your computer and use it in GitHub Desktop.
Save hxsf/9743eb9709008c05550f77e4ebfe9906 to your computer and use it in GitHub Desktop.
util to control git binary.
import * as cp from 'child_process'
import { EventEmitter } from 'events'
import * as fs from 'fs'
import * as iconv from 'iconv-lite'
import * as os from 'os'
import * as path from 'path'
import { assign, debug, denodeify, dispose, groupBy, IDisposable, mkdirp, toDisposable, uniqBy } from './utils';
// tslint:disable:member-ordering
const readdir = denodeify<string[]>(fs.readdir)
const readfile = denodeify<string>(fs.readFile)
/**
* 抽象的 git 二进制对象
*/
export interface IGit {
path: string;
version: string;
}
/**
* 描述了文件状态
*
* | X | Y | Meaning |
* | ----------- | ------- | ---------------------------------- |
* | | [MD] | not updated |
* | M | [ MD] | updated in index |
* | A | [ MD] | added to index |
* | D | [ M] | deleted from index |
* | R | [ MD] | renamed in index |
* | C | [ MD] | copied in index |
* | [MARC] | | index and work tree matches |
* | [ MARC] | M | work tree changed since index |
* | [ MARC] | D | deleted in work tree |
* | ----------- | ------- | ---------------------------------- |
* | D | D | unmerged, both deleted |
* | A | U | unmerged, added by us |
* | U | D | unmerged, deleted by them |
* | U | A | unmerged, added by them |
* | D | U | unmerged, deleted by us |
* | A | A | unmerged, both added |
* | U | U | unmerged, both modified |
* | ----------- | ------- | ---------------------------------- |
* | ? | ? | untracked |
* | ! | ! | ignored |
* | ----------- | ------- | ---------------------------------- |
*/
export interface IFileStatus {
x: string;
y: string;
path: string;
/** 是否是重命名的 */
rename?: string;
}
/**
* 描述了每一条 commit 信息
*/
export interface IGitLog {
/** commit id */
commit: string
/** 父提交,如果是merge,会有两个,否则一个 */
parents: string[]
/** 是否由 merge 产生 */
isMerged: boolean
date: Date
/** 提交人 */
author: string
/** commit 信息 */
message: string
}
export interface IRemote {
name: string;
url: string;
}
export interface Stash {
index: number;
description: string;
}
export enum RefType {
Head,
RemoteHead,
RemoteTag,
Tag,
}
/** 可以得到唯一一个 git commit 的信息都可以作为 ref */
export interface IRef {
type?: RefType
name?: string
commit?: string
remote?: string
}
export interface IBranch extends IRef {
upstream?: string
ahead?: number
behind?: number
}
function parseVersion(raw: string): string {
return raw.replace(/^git version /, '')
}
function findSpecificGit(execPath: string): Promise<IGit> {
return new Promise((c, e) => {
const buffers: Buffer[] = []
const child = cp.spawn(execPath, ['--version'])
child.stdout.on('data', (buf: Buffer) => buffers.push(buf))
child.on('error', e)
child.on('exit', (code) => {
if (code) {
e(new Error('Not found'))
} else {
c({ path: fs.realpathSync(execPath), version: parseVersion(Buffer.concat(buffers).toString('utf8').trim()) })
}
})
})
}
function findGitDarwin(): Promise<IGit> {
return new Promise<IGit>((c, e) => {
cp.exec('which git', (err, gitPathBuffer) => {
if (err) {
return e('git not found')
}
// tslint:disable-next-line:no-shadowed-variable
const path = fs.realpathSync(gitPathBuffer.toString().trim())
// tslint:disable-next-line:no-shadowed-variable
function getVersion(path: string) {
cp.exec('git --version', (error, stdout) => {
if (error) {
return e('git not found.')
}
return c({ path, version: parseVersion(stdout.trim()) })
})
}
if (path !== '/usr/bin/git') {
return getVersion(path)
}
cp.exec('xcode-select -p', (error: any) => {
if (error && error.code === 2) {
return e('git not install')
}
getVersion(path)
})
})
})
}
function findSystemGitWin32(base: string): Promise<IGit> {
if (!base) {
return Promise.reject<IGit>('Not Found')
}
return findSpecificGit(path.join(base, 'Git', 'cmd', 'git.exe'))
}
function findGitHubGitWin32(): Promise<IGit> {
const github = path.join(process.env.LOCALAPPDATA, 'GitHub');
return readdir(github).then((children) => {
const git = children.filter((child) => /^PortableGit/.test(child))[0];
if (!git) {
return Promise.reject<IGit>('Not found');
}
return findSpecificGit(path.join(github, git, 'cmd', 'git.exe'));
});
}
function findGitWin32(): Promise<IGit> {
return findSystemGitWin32(process.env['ProgramW6432'])
.then(void 0, () => findSystemGitWin32(process.env['ProgramFiles(x86)']))
.then(void 0, () => findSystemGitWin32(process.env['ProgramFiles']))
.then(void 0, () => findSpecificGit('git'))
.then(void 0, () => findGitHubGitWin32());
}
/** 查找系统中的 git 二进制文件 */
export function findGit(hint?: string | undefined): Promise<IGit> {
const first = hint ? findSpecificGit(hint) : Promise.reject<IGit>(null)
return first.then(void 0, () => {
switch (process.platform) {
case 'darwin': return findGitDarwin()
case 'win32': return findGitWin32()
default: return findSpecificGit('git')
}
})
}
/** 运行其他二进制的返回结果 */
export interface IExecutionResult {
exitCode: number
stdout: string
stderr: string
}
/** 收集其他二进制程序运行的结果 */
async function exec(child: cp.ChildProcess, options: any = {}): Promise<IExecutionResult> {
if (!child.stdout || ! child.stderr) {
throw new GitError({
message: 'Failed to get stdout or stderr from git process.',
})
}
const disposables: IDisposable[] = []
// tslint:disable-next-line:ban-types
const once = (ee: NodeJS.EventEmitter, name: string, fn: any) => {
ee.once(name, fn)
disposables.push(toDisposable(() => ee.removeListener(name, fn)))
}
// tslint:disable-next-line:ban-types
const on = (ee: NodeJS.EventEmitter, name: string, fn: any) => {
ee.on(name, fn)
disposables.push(toDisposable(() => ee.removeListener(name, fn)))
}
let encoding = options.encoding || 'utf8'
encoding = iconv.encodingExists(encoding) ? encoding : 'utf8'
const [ exitCode, stdout, stderr ] = await Promise.all<any>([
new Promise<number>((c, e) => {
once(child, 'error', e)
once(child, 'exit', c)
}),
new Promise<string>((c) => {
const buffers: Buffer[] = []
on(child.stdout, 'data', (buf: Buffer) => buffers.push(buf))
once(child.stdout, 'close', () => c(iconv.decode(Buffer.concat(buffers), encoding)))
}),
new Promise<string>((c) => {
const buffers: Buffer[] = []
on(child.stderr, 'data', (b) => buffers.push(b))
once(child.stderr, 'close', () => c(iconv.decode(Buffer.concat(buffers), encoding)))
}),
])
dispose(disposables)
return { exitCode, stdout, stderr }
}
/** 从 js Error 或者 IExecutionResult 或者 指定的 GIT code 的集合,用于创建 GitError */
export interface IGitErrorData {
error?: Error
message?: string
stdout?: string
stderr?: string
exitCode?: number
gitErrorCode?: string
gitCommand?: string
}
/** node-git 统一抛出的错误对象 */
export class GitError {
public error?: Error
public message: string
public stdout?: string
public stderr?: string
public exitCode?: number
public gitErrorCode?: string
public gitCommand?: string
/** 从 js Error 或者 IExecutionResult 或者 指定的 GIT code 创建 GitError */
constructor(data: IGitErrorData) {
if (data.error) {
this.error = data.error
this.message = data.error.message
} else {
this.error = void 0
}
this.message = this.message || data.message || 'Git Error'
this.stdout = data.stdout
this.stderr = data.stderr
this.exitCode = data.exitCode
this.gitErrorCode = data.gitErrorCode
this.gitCommand = data.gitCommand
}
public toString(): string {
let result = this.message + ' ' + JSON.stringify({
exitCode: this.exitCode,
gitCommand: this.gitCommand,
gitErrorCode: this.gitErrorCode,
stderr: this.stderr,
stdout: this.stdout,
}, [], 2)
if (this.error) {
result += this.error.stack
}
return result;
}
}
/** 描述 git 二进制的信息 */
export interface IGitOptions {
gitPath: string
version: string
env?: any
}
/** 常见的 git 错误码 */
export const GitErrorCodes = {
AuthenticationFailed: 'AuthenticationFailed',
BadConfigFile: 'BadConfigFile',
BranchNotFullyMerged: 'BranchNotFullyMerged',
CantAccessRemote: 'CantAccessRemote',
CantCreatePipe: 'CantCreatePipe',
CantOpenResource: 'CantOpenResource',
Conflict: 'Conflict',
DirtyWorkTree: 'DirtyWorkTree',
GitNotFound: 'GitNotFound',
LocalChangesOverwritten: 'LocalChangesOverwritten',
NoLocalChanges: 'NoLocalChanges',
NoRemoteReference: 'NoRemoteReference',
NoRemoteRepositorySpecified: 'NoRemoteRepositorySpecified',
NoStashFound: 'NoStashFound',
NoUserEmailConfigured: 'NoUserEmailConfigured',
NoUserNameConfigured: 'NoUserNameConfigured',
NotAGitRepository: 'NotAGitRepository',
NotAtRepositoryRoot: 'NotAtRepositoryRoot',
PushRejected: 'PushRejected',
RemoteConnectionError: 'RemoteConnectionError',
RepositoryIsLocked: 'RepositoryIsLocked',
RepositoryNotFound: 'RepositoryNotFound',
UnmergedChanges: 'UnmergedChanges',
}
/** 从 stderr 找出 常见的 git 错误码,没找到就返回 undefined */
function getGitErrorCode(stderr: string): string | undefined {
// tslint:disable-next-line:max-line-length
if (/Another git process seems to be running in this repository|If no other git process is currently running/.test(stderr)) {
return GitErrorCodes.RepositoryIsLocked;
} else if (/Authentication failed/.test(stderr)) {
return GitErrorCodes.AuthenticationFailed;
} else if (/Not a git repository/.test(stderr)) {
return GitErrorCodes.NotAGitRepository;
} else if (/bad config file/.test(stderr)) {
return GitErrorCodes.BadConfigFile;
} else if (/cannot make pipe for command substitution|cannot create standard input pipe/.test(stderr)) {
return GitErrorCodes.CantCreatePipe;
} else if (/Repository not found/.test(stderr)) {
return GitErrorCodes.RepositoryNotFound;
} else if (/unable to access/.test(stderr)) {
return GitErrorCodes.CantAccessRemote;
} else if (/branch '.+' is not fully merged/.test(stderr)) {
return GitErrorCodes.BranchNotFullyMerged;
} else if (/Couldn\'t find remote ref/.test(stderr)) {
return GitErrorCodes.NoRemoteReference;
}
return void 0;
}
/** 对 Git 的封装 */
export class Git {
private gitPath: string
private version: string
private env: any
private _onOutput = new EventEmitter()
get onOutPut(): EventEmitter {
return this._onOutput
}
constructor(options: IGitOptions) {
this.gitPath = options.gitPath
this.version = options.version
this.env = options.env || {}
}
public open(repository: string): Repository {
return new Repository(this, repository)
}
public async init(repository: string): Promise<void> {
await this.exec(repository, ['init'])
}
public async clone(url: string, parentPath: string): Promise<string> {
const folderName = decodeURI(url).replace(/^.*\//, '').replace(/\.git$/, '') || 'repository'
const folderPath = path.join(parentPath, folderName)
await mkdirp(parentPath);
await this.exec(parentPath, ['clone', url, folderPath]);
return folderPath;
}
public async config(key: string, value: any, options: any = {}): Promise<string> {
const args = ['config']
args.push(`--global`)
args.push(key)
if (value) {
args.push(value)
}
const result = await this._exec(args, options)
return result.stdout
}
// tslint:disable-next-line:no-shadowed-variable
public async getRepositoryRoot(path: string): Promise<string> {
const result = await this.exec(path, ['rev-parse', '--show-toplevel']);
return result.stdout.trim();
}
public async exec(cwd: string, args: string[], options: any = {}): Promise<IExecutionResult> {
options = assign({ cwd }, options || {});
return await this._exec(args, options);
}
public spawn(args: string[], options: any = {}): cp.ChildProcess {
if (!this.gitPath) {
throw new Error('git could not be found in the system.');
}
if (!options) {
options = {};
}
if (!options.stdio && !options.input) {
// Unless provided, ignore stdin and leave default streams for stdout and stderr
options.stdio = ['ignore', null, null];
}
options.env = assign({}, process.env, this.env, options.env || {}, {
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
VSCODE_GIT_COMMAND: args[0],
});
if (options.log !== false) {
this.log(`git ${args.join(' ')}\n`);
}
return cp.spawn(this.gitPath, args, options);
}
public stream(cwd: string, args: string[], options: any = {}): cp.ChildProcess {
options = assign({ cwd }, options || {});
return this.spawn(args, options);
}
private async _exec(args: string[], options: any = {}): Promise<IExecutionResult> {
const child = this.spawn(args, options);
if (options.input) {
child.stdin.end(options.input, 'utf8');
}
const result = await exec(child, options);
if (options.log !== false && result.stderr.length > 0) {
this.log(`${result.stderr}\n`);
}
if (result.exitCode) {
return Promise.reject<IExecutionResult>(new GitError({
exitCode: result.exitCode,
gitCommand: args[0],
gitErrorCode: getGitErrorCode(result.stderr),
message: 'Failed to execute git',
stderr: result.stderr,
stdout: result.stdout,
}));
}
return result;
}
private log(output: string): void {
this._onOutput.emit('log', output);
}
}
/** 对 commit log 中每一个 commit 的抽象 */
export interface ICommit {
hash: string;
message: string;
}
/** 用于解析 git status 结果 */
export class GitStatusParser {
private lastRaw = '';
private result: IFileStatus[] = [];
get status(): IFileStatus[] {
return this.result;
}
public update(raw: string): void {
let i = 0;
let nextI: number | undefined;
raw = this.lastRaw + raw;
// tslint:disable-next-line:no-conditional-assignment
while ((nextI = this.parseEntry(raw, i)) !== undefined) {
i = nextI;
}
this.lastRaw = raw.substr(i);
}
private parseEntry(raw: string, i: number): number | undefined {
if (i + 4 >= raw.length) {
return;
}
let lastIndex: number;
const entry: IFileStatus = {
path: '',
rename: undefined,
x: raw.charAt(i++),
y: raw.charAt(i++),
};
// space
i++;
if (entry.x === 'R' || entry.x === 'C') {
lastIndex = raw.indexOf('\0', i);
if (lastIndex === -1) {
return;
}
entry.rename = raw.substring(i, lastIndex);
i = lastIndex + 1;
}
lastIndex = raw.indexOf('\0', i);
if (lastIndex === -1) {
return;
}
entry.path = raw.substring(i, lastIndex);
// If path ends with slash, it must be a nested git repo
if (entry.path[entry.path.length - 1] !== '/') {
this.result.push(entry);
}
return lastIndex + 1;
}
}
/** 对 Git Repo 操作的封装 */
export class Repository {
constructor(
private _git: Git,
private repositoryRoot: string,
) {}
get git(): Git {
return this._git
}
get root(): string {
return this.repositoryRoot
}
public async run(args: string[], options: any = {}): Promise<IExecutionResult> {
return await this.git.exec(this.repositoryRoot, args, options)
}
protected stream(args: string[], options: any = {}): cp.ChildProcess {
return this.git.stream(this.repositoryRoot, args, options)
}
protected spawn(args: string[], options: any = {}): cp.ChildProcess {
return this.git.spawn(args, options);
}
public init(): Promise<void> {
return this._git.init(this.repositoryRoot);
}
/**
* change git config
* @param scope [global, system, local]
* @param key config key, e.g. user.name
* @param value config value, e.g. 'zhang san'
* @param options nodejs/child_process exec option, not git option.
* @returns string stdout
*/
public async config(scope: string, key: string, value: any, options: any): Promise<string> {
const args = ['config']
if (scope) {
args.push(`--${scope}`)
}
args.push(key)
if (value) {
args.push(value)
}
const result = await this.run(args, options)
return result.stdout
}
protected async buffer(object: string, encoding: string = 'utf8'): Promise<string> {
const child = this.stream(['show', object])
if (!child.stdout) {
return Promise.reject<string>('Can\'t open file from git.')
}
const { exitCode, stdout } = await exec(child, { encoding })
if (exitCode) {
return Promise.reject<string>(new GitError({
exitCode,
message: 'Could not show object.',
}))
}
return stdout
}
public sshow(object: string): cp.ChildProcess {
return this.stream(['show', object])
}
public async show(filePath: string, treeish?: string): Promise<string> {
treeish = (!treeish || treeish === '~') ? '' : treeish;
try {
return await this.buffer(treeish + ':' + filePath)
} catch (e) {
if (e instanceof GitError) {
return '';
}
throw e;
}
}
/**
* git add
* @param paths 添加的文件路径(相对于 repo 根目录)支持文件夹或者通配符
*/
public async add(paths: string[]): Promise<void> {
const args = ['add', '-A', '--']
if (paths && paths.length) {
args.push.apply(args, paths)
} else {
args.push('.')
}
await this.run(args)
}
/**
* 不修改文件的基础上,直接将内容存进 git stage, 慎用。
* @param path
* @param data
*/
// tslint:disable-next-line:no-shadowed-variable
public async stage(path: string, data: string): Promise<void> {
const child = this.stream(['hash-object', '--stdin', '-w', '--path', path], { stdio: [null, null, null] })
child.stdin.end(data, 'utf8')
const { exitCode, stdout } = await exec(child)
if (exitCode) {
throw new GitError({
exitCode,
message: 'Could not hash object.',
})
}
await this.run(['update-index', '--cacheinfo', '100644', stdout, path])
}
/**
* git checkout, treeish 和 paths 两者中必传一个,另一个使用 ‘’/[]。 切换版本 或 检出文件
* @param treeish branch || commit || tag || 相对ref, e.g. HEAD~2
* @param paths 文件、文件夹路径或通配符路径
*/
public async checkout(treeish: string, paths: string[]): Promise<void> {
const args = ['checkout', '-q']
if (treeish) {
args.push(treeish)
}
if (paths && paths.length) {
args.push('--')
args.push.apply(args, paths)
}
try {
await this.run(args)
} catch (err) {
if (/Please, commit your changes or stash them/.test(err.stderr) || '') {
err.gitErrorCode = GitErrorCodes.DirtyWorkTree
}
throw err
}
}
/**
* commit
* @param message commit 信息
* @param opts.all 添加所有 not staged 的文件
* @param opts.amend 补充上次提交
* @param opts.signoff 署名
* @param opts.signCommit 使用 GPG 秘钥来署名
*/
// tslint:disable-next-line:max-line-length
public async commit(message: string, opts: { all?: boolean, amend?: boolean, signoff?: boolean, signCommit?: boolean } = Object.create(null)): Promise<void> {
const args = ['commit', '--quiet', '--allow-empty-message', '--file', '-'];
if (opts.all) {
args.push('--all');
}
if (opts.amend) {
args.push('--amend');
}
if (opts.signoff) {
args.push('--signoff');
}
if (opts.signCommit) {
args.push('-S');
}
try {
await this.run(args, { input: message || '' });
} catch (commitErr) {
if (/not possible because you have unmerged files/.test(commitErr.stderr || '')) {
commitErr.gitErrorCode = GitErrorCodes.UnmergedChanges;
throw commitErr;
}
try {
await this.run(['config', '--get-all', 'user.name']);
} catch (err) {
err.gitErrorCode = GitErrorCodes.NoUserNameConfigured;
throw err;
}
try {
await this.run(['config', '--get-all', 'user.email']);
} catch (err) {
err.gitErrorCode = GitErrorCodes.NoUserEmailConfigured;
throw err;
}
throw commitErr;
}
}
/**
* 新建 分支
* @param name 分支名字
* @param checkout 是否立刻切换到新分支上
*/
public async branch(name: string, checkout: boolean): Promise<void> {
const args = checkout ? ['checkout', '-q', '-b', name] : ['branch', '-q', name]
await this.run(args)
}
/**
* 删除分支,如果分支没合并到其他分支上,普通删除会抛出异常。
* @param name 分支名字
* @param force 没合并的也强制删除
*/
public async deleteBranch(name: string, force?: boolean): Promise<void> {
const args = ['branch', force ? '-D' : '-d', name];
await this.run(args);
}
/**
* 合并,遇到冲突会抛出异常
* @param ref 要合并到当前分支的 ref, 可以是 commit id || branch || tag 等
* @throws GitError { gitErrorCode: GitErrorCodes.Conflict }
*/
public async merge(ref: string): Promise<void> {
const args = ['merge', ref];
try {
await this.run(args);
} catch (err) {
if (/^CONFLICT /m.test(err.stdout || '')) {
err.gitErrorCode = GitErrorCodes.Conflict;
}
throw err;
}
}
/**
* 给当前的 HEAD 打 tag
* @param name tag 名字
* @param message tag 的 message
*/
public async tag(name: string, message?: string): Promise<void> {
let args = ['tag'];
if (message) {
args = [...args, '-a', name, '-m', message];
} else {
args = [...args, name];
}
await this.run(args);
}
/**
* Remove untracked files from the working tree
* @param paths 要移除的文件或文件夹或通配符路径
*/
public async clean(paths: string[]): Promise<void> {
const pathsByGroup = groupBy(paths, (p) => path.dirname(p));
const groups = Object.keys(pathsByGroup).map((k) => pathsByGroup[k]);
const tasks = groups.map((_paths) => () => this.run(['clean', '-f', '-q', '--'].concat(_paths)));
for (const task of tasks) {
await task();
}
}
/**
* 清理整个repo 目录下所有 未跟踪的文件 和 未暂存的修改
* 即 git clean -fd && git checkout -- .
*/
public async undo(): Promise<void> {
await this.run(['clean', '-fd']);
try {
await this.run(['checkout', '--', '.']);
} catch (err) {
if (/did not match any file\(s\) known to git\./.test(err.stderr || '')) {
return;
}
throw err;
}
}
/**
* 重置 HEAD 到指定的提交
* @param treeish branch || tag || 相对名称 || commit id
* @param hard 硬回滚,缓存区和工作目录都同步到你指定的提交,否则不影响工作目录
*/
public async reset(treeish: string, hard: boolean = false): Promise<void> {
const args = ['reset'];
if (hard) {
args.push('--hard');
}
args.push(treeish);
await this.run(args);
}
/**
* 撤销已经放入暂存区的修改
* @param treeish branch || tag || 相对名称 || commit id , repo HEAD 指向某一分支时有效,将指定ref的某些文件检出到工作区。 用于重置暂存区是使用 HEAD
* @param paths 文件、文件夹路径,支持通配符,传空数组 == ['.']
*/
public async revert(treeish: string, paths: string[]): Promise<void> {
const result = await this.run(['branch']);
let args: string[];
// In case there are no branches, we must use rm --cached
if (!result.stdout) {
args = ['rm', '--cached', '-r', '--'];
} else {
args = ['reset', '-q', treeish, '--'];
}
if (paths && paths.length) {
args.push.apply(args, paths);
} else {
args.push('.');
}
try {
await this.run(args);
} catch (err) {
// In case there are merge conflicts to be resolved, git reset will output
// some "needs merge" data. We try to get around that.
if (/([^:]+: needs merge\n)+/m.test(err.stdout || '')) {
return;
}
throw err;
}
}
/**
* 同步远端的数据到本地
*/
public async fetch(): Promise<void> {
try {
await this.run(['fetch']);
} catch (err) {
if (/No remote repository specified\./.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.NoRemoteRepositorySpecified;
} else if (/Could not read from remote repository/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.RemoteConnectionError;
}
throw err;
}
}
/**
* Fetch from and integrate with another repository or a local branch
* @param rebase 是否使用 rebase 方式合并提交
* @param remote 指定的远端,需要和 branch 同时使用
* @param branch 指定的分支,需要和 remote 同时使用
* @throws GitError Conflict || NoUserNameConfigured || RemoteConnectionError || DirtyWorkTree
*/
public async pull(rebase?: boolean, remote?: string, branch?: string): Promise<void> {
const args = ['pull'];
if (rebase) {
args.push('-r');
}
if (remote && branch) {
args.push(remote);
args.push(branch);
}
try {
await this.run(args);
} catch (err) {
if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) {
err.gitErrorCode = GitErrorCodes.Conflict;
} else if (/Please tell me who you are\./.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.NoUserNameConfigured;
} else if (/Could not read from remote repository/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.RemoteConnectionError;
// tslint:disable-next-line:max-line-length
} else if (/Pull is not possible because you have unmerged files|Cannot pull with rebase: You have unstaged changes|Your local changes to the following files would be overwritten|Please, commit your changes before you can merge/.test(err.stderr)) {
err.gitErrorCode = GitErrorCodes.DirtyWorkTree;
}
throw err;
}
}
/**
* 推送本地数据到远端
* @param remote 指定的远端
* @param name 分支名字,不指定则推送所有
* @param setUpstream 设置 upstream
* @param tags 推送 tag
* @throws GitError PushRejected || RemoteConnectionError
*/
public async push(remote?: string, name?: string, setUpstream: boolean = false, tags = false): Promise<void> {
const args = ['push'];
if (setUpstream) {
args.push('-u');
}
if (tags) {
args.push('--tags');
}
if (remote) {
args.push(remote);
}
if (name) {
args.push(name);
}
try {
await this.run(args);
} catch (err) {
if (/^error: failed to push some refs to\b/m.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.PushRejected;
} else if (/Could not read from remote repository/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.RemoteConnectionError;
}
throw err;
}
}
/**
* 创建贮存
* @param message msg
* @throws GitError NoLocalChanges
*/
public async createStash(message?: string): Promise<void> {
try {
const args = ['stash', 'save'];
if (message) {
args.push('--', message);
}
await this.run(args);
} catch (err) {
if (/No local changes to save/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.NoLocalChanges;
}
throw err;
}
}
/**
* 将贮存区贮存的修改应用到当前工作区
* @param index 贮存弹栈的层数
* @throws GitError NoStashFound || LocalChangesOverwritten
*/
public async popStash(index?: number): Promise<void> {
try {
const args = ['stash', 'pop'];
if (typeof index === 'string') {
args.push(`stash@{${index}}`);
}
await this.run(args);
} catch (err) {
if (/No stash found/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.NoStashFound;
} else if (/error: Your local changes to the following files would be overwritten/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.LocalChangesOverwritten;
}
throw err;
}
}
/**
* 查看当前工作区的文件状态
* @param limit 文件变动状态 数量限制
* @throws GitError
*/
public getStatus(limit = 5000): Promise<{ status: IFileStatus[]; didHitLimit: boolean; }> {
return new Promise<{ status: IFileStatus[]; didHitLimit: boolean; }>((c, e) => {
const parser = new GitStatusParser();
const child = this.stream(['status', '-z', '-u']);
const onExit = (exitCode) => {
if (exitCode !== 0) {
const stderr = stderrData.join('');
return e(new GitError({
exitCode,
gitCommand: 'status',
gitErrorCode: getGitErrorCode(stderr),
message: 'Failed to execute git',
stderr,
}));
}
c({ status: parser.status, didHitLimit: false });
};
const onStdoutData = (raw: string) => {
parser.update(raw);
if (parser.status.length > limit) {
child.removeListener('exit', onExit);
child.stdout.removeListener('data', onStdoutData);
child.kill();
c({ status: parser.status.slice(0, limit), didHitLimit: true });
}
};
child.stdout.setEncoding('utf8');
child.stdout.on('data', onStdoutData);
const stderrData: string[] = [];
child.stderr.setEncoding('utf8');
child.stderr.on('data', (raw) => stderrData.push(raw as string));
child.on('error', e);
child.on('exit', onExit);
});
}
/**
* 获取当前 HEAD 位置,branch name || commit id
*/
public async getHEAD(): Promise<IRef> {
try {
const result = await this.run(['symbolic-ref', '--short', 'HEAD']);
if (!result.stdout) {
throw new Error('Not in a branch');
}
return { name: result.stdout.trim(), commit: void 0, type: RefType.Head };
} catch (err) {
const result = await this.run(['rev-parse', 'HEAD']);
if (!result.stdout) {
throw new Error('Error parsing HEAD');
}
return { name: void 0, commit: result.stdout.trim(), type: RefType.Head };
}
}
/**
* 获取 ref ,包含 本地的 分支和tag,远端的分支
*/
public async getRefs(): Promise<IRef[]> {
const result = await this.run(['for-each-ref', '--format', '%(refname) %(objectname)']);
const fn = (line): IRef | null => {
let match: RegExpExecArray | null;
// tslint:disable-next-line:no-conditional-assignment
if (match = /^refs\/heads\/([^ ]+) ([0-9a-f]{40})$/.exec(line)) {
return { name: match[1], commit: match[2], type: RefType.Head };
// tslint:disable-next-line:no-conditional-assignment
} else if (match = /^refs\/remotes\/([^/]+)\/([^ ]+) ([0-9a-f]{40})$/.exec(line)) {
// tslint:disable-next-line:max-line-length
return { name: `${match[1]}/${match[2]}`, commit: match[3], type: RefType.RemoteHead, remote: match[1] };
// tslint:disable-next-line:no-conditional-assignment
} else if (match = /^refs\/tags\/([^ ]+) ([0-9a-f]{40})$/.exec(line)) {
return { name: match[1], commit: match[2], type: RefType.Tag };
}
return null;
};
return result.stdout.trim().split('\n')
.filter((line) => !!line)
.map(fn)
.filter((ref) => !!ref) as IRef[];
}
/**
* 获取贮藏区的贮藏列表
*/
public async getStashes(): Promise<Stash[]> {
const result = await this.run(['stash', 'list']);
const regex = /^stash@{(\d+)}:(.+)$/;
const rawStashes = result.stdout.trim().split('\n')
.filter((b) => !!b)
.map((line) => regex.exec(line))
.filter((g) => !!g)
.map(([, index, description]: RegExpExecArray) => ({ index: parseInt(index, 10), description }));
return rawStashes;
}
/**
* 获取 repo 的所有的远端信息
*/
public async getRemotes(): Promise<IRemote[]> {
const result = await this.run(['remote', '--verbose']);
const regex = /^([^\s]+)\s+([^\s]+)\s/;
const rawRemotes = result.stdout.trim().split('\n')
.filter((b) => !!b)
.map((line) => regex.exec(line))
.filter((g) => !!g)
.map((groups: RegExpExecArray) => ({ name: groups[1], url: groups[2] }));
return uniqBy(rawRemotes, (remote) => remote.name);
}
/**
* 获取分支信息
* @param name 分支名字
* @throws Error No such branch
*/
// TODO any bugs?
public async getBranch(name: string): Promise<IBranch> {
if (name === 'HEAD') {
return this.getHEAD();
}
const result = await this.run(['rev-parse', name]);
if (!result.stdout) {
return Promise.reject<IBranch>(new Error('No such branch'));
}
const commit = result.stdout.trim();
try {
const res2 = await this.run(['rev-parse', '--symbolic-full-name', '--abbrev-ref', name + '@{u}'])
const upstream = res2.stdout.trim();
const res3 = await this.run(['rev-list', '--left-right', name + '...' + upstream])
let ahead = 0
let behind = 0
let i = 0
while (i < res3.stdout.length) {
switch (res3.stdout.charAt(i)) {
case '<': ahead++; break;
case '>': behind++; break;
default: i++; break;
}
while (res3.stdout.charAt(i++) !== '\n') { /* no-op */ }
}
return { name, type: RefType.Head, commit, upstream, ahead, behind };
} catch (err) {
return { name, type: RefType.Head, commit };
}
}
/**
* 获取提交模版
*/
public async getCommitTemplate(): Promise<string> {
try {
const result = await this.run(['config', '--get', 'commit.template']);
if (!result.stdout) {
return '';
}
// https://github.com/git/git/blob/3a0f269e7c82aa3a87323cb7ae04ac5f129f036b/path.c#L612
const homedir = os.homedir();
let templatePath = result.stdout.trim()
.replace(/^~([^\/]*)\//, (_, user) => `${user ? path.join(path.dirname(homedir), user) : homedir}/`);
if (!path.isAbsolute(templatePath)) {
templatePath = path.join(this.repositoryRoot, templatePath);
}
const raw = await readfile(templatePath, 'utf8');
return raw.replace(/^\s*#.*$\n?/gm, '').trim();
} catch (err) {
return '';
}
}
/**
* 获取 commit id 和 message
* @param ref commit id || branch || tag || 相对HEAD
*/
public async getCommit(ref: string): Promise<ICommit> {
const result = await this.run(['show', '-s', '--format=%H\n%B', ref]);
const match = /^([0-9a-f]{40})\n([^]*)$/m.exec(result.stdout.trim());
if (!match) {
return Promise.reject<ICommit>('bad commit format');
}
return { hash: match[1], message: match[2] };
}
/**
* 获取指定版本的某个文件内容
* @param ref commit id || branch || tag || 相对HEAD
* @param path 文件路径
* @throws Error 文件不存在
*/
public async getFile(ref: string, path: string): Promise<string> {
const out = await this.buffer(`${ref}:${path}`)
return out
}
/**
*
* @param _path 指定文件、文件夹路径
* @param opts.all 获取所有分支,默认只获取当前分支的历史记录
* @param opts.limit 最大历史记录获取数量
* @param opts.encoding 传递给 node/child_process
* @throws Error || GitError Could not show logs || Can't get commit logs
*/
// tslint:disable-next-line:max-line-length
public async log(_path: string = '.', opts: { encoding?: string, all?: boolean, limit?: number } = {}): Promise<IGitLog[]> {
// tslint:disable-next-line:max-line-length
const args = ['log', '--format=####%ncommitid: %H%nparentid: %P%nauthor: %aN%ndate: %aI%nsubject: %s%n####%n']
if (opts.all) {
args.push('--all')
}
if (opts.limit) {
args.push('-n', opts.limit.toString())
}
if (_path) {
args.push(_path)
}
const child = this.stream(args)
if (!child.stdout) {
return Promise.reject<IGitLog[]>('Can\'t get commit logs.')
}
const { exitCode, stdout } = await exec(child, { encoding: opts.encoding })
if (exitCode) {
return Promise.reject<IGitLog[]>(new GitError({
exitCode,
message: 'Could not show logs.',
}))
}
const parse = new GitLogParse()
parse.update(stdout)
return parse.logs
}
/**
* 获取远端的 ref, 默认获取 branch + tag
* @param opts.branch 是否获取 branch
* @param opts.tag 是否获取 tag
* @param opts.encoding node/child_process
*/
// tslint:disable-next-line:max-line-length
public async getRemoteRefs(opts: { branch?: boolean, tag?: boolean, encoding?: string} = {encoding: 'utf8'}): Promise<IRef[]> {
const args = ['ls-remote', '--refs']
if (!opts.branch) {
args.push('--tags')
}
if (!opts.tag) {
args.push('--heads')
}
const child = this.stream(args)
if (!child.stdout) {
return Promise.reject<IRef[]>('Can\'t get remote refs.')
}
const { exitCode, stdout } = await exec(child, { encoding: opts.encoding })
const refs = []
const re = /^([0-9a-f]{40})\s+(.+)$/mg
const type_table = {
heads: RefType.RemoteHead,
tags: RefType.RemoteTag,
}
while (1) {
const res = re.exec(stdout)
if (res) {
const [_, commit, ref_str] = res
const [__, type, name] = ref_str.split('/', 3)
refs.push({
commit,
name,
type: type_table[type],
})
} else {
break
}
}
if (exitCode) {
return Promise.reject<IRef[]>(new GitError({
exitCode,
message: 'Could not show remote refs.',
}))
}
return refs
}
/**
* 获取远端的 ref, 默认获取 branch + tag
* @param opts.branch 是否获取 branch
* @param opts.tag 是否获取 tag
* @param opts.encoding node/child_process
*/
// tslint:disable-next-line:max-line-length
public async getLocalRefs(opts: { head?: boolean, branch?: boolean, tag?: boolean, encoding?: string } = {encoding: 'utf8'}): Promise<IRef[]> {
const args = ['show-ref']
if (opts.head) {
args.push('--head')
}
if (opts.branch) {
args.push('--heads')
}
if (opts.tag) {
args.push('--tags')
}
const child = this.stream(args)
if (!child.stdout) {
return Promise.reject<IRef[]>('Can\'t get local ref.')
}
const { exitCode, stdout } = await exec(child, { encoding: opts.encoding })
const refs = []
const re = /^([0-9a-f]{40})\s+(.+)$/mg
const type_table = {
heads: RefType.Head,
tags: RefType.Tag,
}
let __
let type
let name
while (1) {
const res = re.exec(stdout)
if (res) {
const [_, commit, ref_str] = res
if (ref_str === 'HEAD') {
[type, name] = ['heads', 'HEAD']
} else {
[__, type, name] = ref_str.split('/', 3)
}
refs.push({
commit,
name,
type: type_table[type],
})
} else {
break
}
}
if (exitCode) {
return Promise.reject<IRef[]>(new GitError({
exitCode,
message: 'Could not show local ref.',
}))
}
return refs
}
}
/** 用于解析 git log 结果 */
export class GitLogParse {
private result: IGitLog[]
// tslint:disable-next-line:max-line-length
private re = /####\r?\ncommitid: ([0-9a-f]{40})\r?\nparentid: ([0-9a-f]{40}) ?([0-9a-f]{40})?\r?\nauthor: (.+)\r?\ndate: (.+)\r?\nsubject: (.+)\r?\n####(?:\r?\n)*/g
private buf: string
constructor() {
this.result = []
this.buf = ''
}
public get logs() {
return this.result
}
public update(raw: string): void {
const _raw = this.buf + raw
while (1) {
const res = this.re.exec(raw)
if (res) {
const [_, commit_id, parent_id_1, parent_id_2, author, date, subject ] = res
this.result.push({
author,
commit: commit_id,
date: new Date(date),
isMerged: !!parent_id_2,
message: subject,
parents: !!parent_id_2 ? [parent_id_1, parent_id_2] : [parent_id_1],
})
} else {
this.buf = _raw.slice(this.re.lastIndex)
this.re.lastIndex = 0
break
}
}
}
}
import * as _debug from 'debug'
import * as fs from 'fs'
import { dirname } from 'path'
export const debug = _debug('IDE:main_process:git')
export interface IDisposable {
dispose(): void;
}
export function dispose<T extends IDisposable>(disposables: T[]): T[] {
disposables.forEach((d) => d.dispose());
return [];
}
export function toDisposable(dispose: () => void): IDisposable {
return { dispose };
}
export function combinedDisposable(disposables: IDisposable[]): IDisposable {
return toDisposable(() => dispose(disposables));
}
export const EmptyDisposable = toDisposable(() => null);
export function assign<T>(destination: T, ...sources: any[]): T {
for (const source of sources) {
Object.keys(source).forEach ((key) => destination[key] = source[key])
}
return destination
}
export function uniqBy<T>(arr: T[], fn: (el: T) => string): T[] {
const seen: Set<string> = new Set()
return arr.filter((el) => {
const key = fn(el)
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
export function groupBy<T>(arr: T[], fn: (el: T) => string): { [key: string]: T[] } {
return arr.reduce((result, el) => {
const key = fn(el)
result[key] = [...(result[key] || []), el]
return result
}, Object.create(null))
}
export function denodeify<R>(fn: Function): (...args) => Promise<R> {
return (...args) => new Promise<R>((c, e) => fn(...args, (err, r) => err ? e(err) : c(r)))
}
export function nfcall<R>(fn: Function, ...args): Promise<R> {
return new Promise<R>((c, e) => fn(...args, (err, r) => err ? e(err) : c(r)))
}
export async function mkdirp(path: string, mode?: number): Promise<boolean> {
if (fs.existsSync(path)) {
const stat = await nfcall<fs.Stats>(fs.stat, path)
if (stat.isDirectory()) {
return
}
throw new Error(`'${path}' exists and is not a directory.`)
}
await mkdirp(dirname(path))
await nfcall(fs.mkdir, path, mode)
}
export function uniqueFilter<T>(keyFn: (t: T) => string): (t: T) => boolean {
const seen: Set<string> = new Set()
return (element) => {
const key = keyFn(element)
if (seen.has(key)) {
return false
}
seen.add(key)
return true
}
}
export function find<T>(array: T[], fn: (t: T) => boolean): T | undefined {
let result: T | undefined
array.some((e) => {
if (fn(e)) {
result = e
return true
}
return false
})
return result
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment