insomnia/packages/insomnia-app/app/sync/git/git-vcs.ts
2021-07-23 11:04:56 +12:00

416 lines
11 KiB
TypeScript

import * as git from 'isomorphic-git';
import path from 'path';
import { trackEvent } from '../../common/analytics';
import { httpClient } from './http-client';
import { convertToOsSep, convertToPosixSep } from './path-sep';
import { gitCallbacks } from './utils';
export interface GitAuthor {
name: string;
email: string;
}
export interface GitRemoteConfig {
remote: string;
url: string;
}
interface GitCredentialsPassword {
username: string;
password: string;
}
interface GitCredentialsToken {
username: string;
token: string;
}
export type GitCredentials = GitCredentialsPassword | GitCredentialsToken;
export type GitHash = string;
export type GitRef = GitHash | string;
export interface GitTimestamp {
timezoneOffset: number;
timestamp: number;
}
export interface GitLogEntry {
oid: string;
commit: {
message: string;
tree: GitRef;
author: GitAuthor & GitTimestamp;
committer: GitAuthor & GitTimestamp;
parent: GitRef[];
};
payload: string;
}
interface InitOptions {
directory: string;
fs: git.FsClient;
gitDirectory?: string;
}
interface InitFromCloneOptions {
url: string;
gitCredentials?: GitCredentials | null;
directory: string;
fs: git.FsClient;
gitDirectory: string;
}
/**
* isomorphic-git internally will default an empty ('') clone directory to '.'
*
* Ref: https://github.com/isomorphic-git/isomorphic-git/blob/4e66704d05042624bbc78b85ee5110d5ee7ec3e2/src/utils/normalizePath.js#L10
*
* We should set this explicitly (even if set to an empty string), because we have other code (such as fs clients and unit tests) that depend on the clone directory.
*/
export const GIT_CLONE_DIR = '.';
const gitInternalDirName = 'git';
export const GIT_INSOMNIA_DIR_NAME = '.insomnia';
export const GIT_INTERNAL_DIR = path.join(GIT_CLONE_DIR, gitInternalDirName);
export const GIT_INSOMNIA_DIR = path.join(GIT_CLONE_DIR, GIT_INSOMNIA_DIR_NAME);
interface BaseOpts {
dir: string;
gitdir?: string;
fs: git.CallbackFsClient | git.PromiseFsClient;
http: git.HttpClient;
onMessage: (message: string) => void;
onAuthFailure: (message: string) => void;
onAuthSuccess: (message: string) => void;
onAuth: () => void;
}
export class GitVCS {
// @ts-expect-error -- TSCONVERSION not initialized with required properties
_baseOpts: BaseOpts = gitCallbacks();
initialized: boolean;
constructor() {
this.initialized = false;
}
async init({ directory, fs, gitDirectory }: InitOptions) {
this._baseOpts = {
...this._baseOpts,
dir: directory,
gitdir: gitDirectory,
fs,
http: httpClient,
};
if (await this.repoExists()) {
console.log(`[git] Opened repo for ${gitDirectory}`);
} else {
console.log(`[git] Initialized repo in ${gitDirectory}`);
await git.init(this._baseOpts);
}
this.initialized = true;
}
async initFromClone({ url, gitCredentials, directory, fs, gitDirectory }: InitFromCloneOptions) {
this._baseOpts = {
...this._baseOpts,
...gitCallbacks(gitCredentials),
dir: directory,
gitdir: gitDirectory,
fs,
http: httpClient,
};
const cloneParams = { ...this._baseOpts, url, singleBranch: true };
await git.clone(cloneParams);
console.log(`[git] Clones repo to ${gitDirectory} from ${url}`);
this.initialized = true;
}
isInitialized() {
return this.initialized;
}
async listFiles() {
console.log('[git] List files');
const files = await git.listFiles({ ...this._baseOpts });
return files.map(convertToOsSep);
}
async getBranch() {
const branch = await git.currentBranch({ ...this._baseOpts });
if (typeof branch !== 'string') {
throw new Error('No active branch');
}
return branch;
}
async listBranches() {
const branch = await this.getBranch();
const branches = await git.listBranches({ ...this._baseOpts });
// For some reason, master isn't in branches on fresh repo (no commits)
if (!branches.includes(branch)) {
branches.push(branch);
}
return GitVCS.sortBranches(branches);
}
async listRemoteBranches() {
const branches = await git.listBranches({ ...this._baseOpts, remote: 'origin' });
// Don't care about returning remote HEAD
return GitVCS.sortBranches(branches.filter(b => b !== 'HEAD'));
}
async status(filepath: string) {
return git.status({ ...this._baseOpts, filepath: convertToPosixSep(filepath) });
}
async add(relPath: string) {
relPath = convertToPosixSep(relPath);
console.log(`[git] Add ${relPath}`);
return git.add({ ...this._baseOpts, filepath: relPath });
}
async remove(relPath: string) {
relPath = convertToPosixSep(relPath);
console.log(`[git] Remove relPath=${relPath}`);
return git.remove({ ...this._baseOpts, filepath: relPath });
}
async addRemote(url: string) {
console.log(`[git] Add Remote url=${url}`);
await git.addRemote({ ...this._baseOpts, remote: 'origin', url, force: true });
const config = await this.getRemote('origin');
if (config === null) {
// Should never happen but it's here to make Flow happy
throw new Error('Remote not found remote=origin');
}
return config;
}
async listRemotes(): Promise<GitRemoteConfig[]> {
return git.listRemotes({ ...this._baseOpts });
}
async getAuthor() {
const name = await git.getConfig({ ...this._baseOpts, path: 'user.name' });
const email = await git.getConfig({ ...this._baseOpts, path: 'user.email' });
return {
name: name || '',
email: email || '',
} as GitAuthor;
}
async setAuthor(name: string, email: string) {
await git.setConfig({ ...this._baseOpts, path: 'user.name', value: name });
await git.setConfig({ ...this._baseOpts, path: 'user.email', value: email });
}
async getRemote(name: string): Promise<GitRemoteConfig | null> {
const remotes = await this.listRemotes();
return remotes.find(r => r.remote === name) || null;
}
async commit(message: string) {
console.log(`[git] Commit "${message}"`);
trackEvent('Git', 'Commit');
return git.commit({ ...this._baseOpts, message });
}
/**
* Check to see whether remote is different than local. This is here because
* when pushing with isomorphic-git, if the HEAD of local is equal the HEAD
* of remote, it will fail with a non-fast-forward message.
*
* @param gitCredentials
* @returns {Promise<boolean>}
*/
async canPush(gitCredentials?: GitCredentials | null) {
const branch = await this.getBranch();
const remote = await this.getRemote('origin');
if (!remote) {
throw new Error('Remote not configured');
}
const remoteInfo = await git.getRemoteInfo({
...this._baseOpts,
...gitCallbacks(gitCredentials),
forPush: true,
url: remote.url,
});
const logs = (await this.log(1)) || [];
const localHead = logs[0].oid;
const remoteRefs = remoteInfo.refs || {};
const remoteHeads = remoteRefs.heads || {};
const remoteHead = remoteHeads[branch];
if (localHead === remoteHead) {
return false;
}
return true;
}
async push(gitCredentials?: GitCredentials | null, force = false) {
console.log(`[git] Push remote=origin force=${force ? 'true' : 'false'}`);
trackEvent('Git', 'Push');
// eslint-disable-next-line no-unreachable
const response: git.PushResult = await git.push({
...this._baseOpts,
...gitCallbacks(gitCredentials),
remote: 'origin',
force,
});
// @ts-expect-error -- TSCONVERSION git errors are not handled correctly
if (response.errors?.length) {
console.log('[git] Push rejected', response);
// @ts-expect-error -- TSCONVERSION git errors are not handled correctly
const errorsString = JSON.stringify(response.errors);
throw new Error(
`Push rejected with errors: ${errorsString}.\n\nGo to View > Toggle DevTools > Console for more information.`,
);
}
}
async pull(gitCredentials?: GitCredentials | null) {
console.log('[git] Pull remote=origin', await this.getBranch());
trackEvent('Git', 'Pull');
return git.pull({
...this._baseOpts,
...gitCallbacks(gitCredentials),
remote: 'origin',
singleBranch: true,
});
}
async merge(theirBranch: string) {
const ours = await this.getBranch();
console.log(`[git] Merge ${ours} <-- ${theirBranch}`);
trackEvent('Git', 'Merge');
return git.merge({
...this._baseOpts,
ours,
theirs: theirBranch,
});
}
async fetch(
singleBranch: boolean,
depth: number,
gitCredentials?: GitCredentials | null,
) {
console.log('[git] Fetch remote=origin');
return git.fetch({
...this._baseOpts,
...gitCallbacks(gitCredentials),
singleBranch,
remote: 'origin',
depth,
prune: true,
pruneTags: true,
});
}
async log(depth?: number) {
try {
return await git.log({ ...this._baseOpts, depth });
} catch (error) {
if (error.code === 'NotFoundError') {
return [];
}
throw error;
}
}
async branch(branch: string, checkout = false) {
trackEvent('Git', 'Create Branch');
// @ts-expect-error -- TSCONVERSION remote doesn't exist as an option
await git.branch({ ...this._baseOpts, ref: branch, checkout, remote: 'origin' });
}
async deleteBranch(branch: string) {
trackEvent('Git', 'Delete Branch');
await git.deleteBranch({ ...this._baseOpts, ref: branch });
}
async checkout(branch: string) {
console.log('[git] Checkout', {
branch,
});
const branches = await this.listBranches();
if (branches.includes(branch)) {
trackEvent('Git', 'Checkout Branch');
await git.checkout({ ...this._baseOpts, ref: branch, remote: 'origin' });
} else {
await this.branch(branch, true);
}
}
async undoPendingChanges(fileFilter?: String[]) {
console.log('[git] Undo pending changes');
await git.checkout({
...this._baseOpts,
ref: await this.getBranch(),
remote: 'origin',
force: true,
filepaths: fileFilter?.map(convertToPosixSep),
});
}
async readObjFromTree(treeOid: string, objPath: string) {
try {
const obj = await git.readObject({
...this._baseOpts,
oid: treeOid,
filepath: convertToPosixSep(objPath),
encoding: 'utf8',
});
return obj.object;
} catch (err) {
return null;
}
}
async repoExists() {
try {
await git.getConfig({ ...this._baseOpts, path: '' });
} catch (err) {
return false;
}
return true;
}
getFs() {
return this._baseOpts.fs;
}
static sortBranches(branches: string[]) {
const newBranches = [...branches];
newBranches.sort((a: string, b: string) => {
if (a === 'master') {
return -1;
} else if (b === 'master') {
return 1;
} else {
return b > a ? -1 : 1;
}
});
return newBranches;
}
}