mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
616 lines
18 KiB
TypeScript
616 lines
18 KiB
TypeScript
/*
|
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
|
* or more contributor license agreements. Licensed under the Elastic License;
|
|
* you may not use this file except in compliance with the Elastic License.
|
|
*/
|
|
|
|
/* eslint-disable @typescript-eslint/camelcase */
|
|
|
|
import {
|
|
Blame,
|
|
Commit,
|
|
Diff as NodeGitDiff,
|
|
Error as NodeGitError,
|
|
Object,
|
|
Oid,
|
|
Reference,
|
|
Repository,
|
|
Tree,
|
|
TreeEntry,
|
|
} from '@elastic/nodegit';
|
|
import Boom from 'boom';
|
|
import LruCache from 'lru-cache';
|
|
import * as Path from 'path';
|
|
import * as fs from 'fs';
|
|
|
|
import { GitBlame } from '../common/git_blame';
|
|
import { CommitDiff, Diff, DiffKind } from '../common/git_diff';
|
|
import { FileTree, FileTreeItemType, RepositoryUri, sortFileTree } from '../model';
|
|
import { CommitInfo, ReferenceInfo, ReferenceType } from '../model/commit';
|
|
import { detectLanguage } from './utils/detect_language';
|
|
|
|
export const HEAD = 'HEAD';
|
|
const REFS_HEADS = 'refs/heads/';
|
|
export const DEFAULT_TREE_CHILDREN_LIMIT = 50;
|
|
|
|
/**
|
|
* do a nodegit operation and check the results. If it throws a not found error or returns null,
|
|
* rethrow a Boom.notFound error.
|
|
* @param func the nodegit operation
|
|
* @param message the message pass to Boom.notFound error
|
|
*/
|
|
async function checkExists<R>(func: () => Promise<R>, message: string): Promise<R> {
|
|
let result: R;
|
|
try {
|
|
result = await func();
|
|
} catch (e) {
|
|
if (e.errno === NodeGitError.CODE.ENOTFOUND) {
|
|
throw Boom.notFound(message);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
if (result == null) {
|
|
throw Boom.notFound(message);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function entry2Tree(entry: TreeEntry): FileTree {
|
|
let type: FileTreeItemType;
|
|
switch (entry.filemode()) {
|
|
case TreeEntry.FILEMODE.LINK:
|
|
type = FileTreeItemType.Link;
|
|
break;
|
|
case TreeEntry.FILEMODE.COMMIT:
|
|
type = FileTreeItemType.Submodule;
|
|
break;
|
|
case TreeEntry.FILEMODE.TREE:
|
|
type = FileTreeItemType.Directory;
|
|
break;
|
|
case TreeEntry.FILEMODE.BLOB:
|
|
case TreeEntry.FILEMODE.EXECUTABLE:
|
|
type = FileTreeItemType.File;
|
|
break;
|
|
default:
|
|
// @ts-ignore
|
|
throw new Error('unreadable file');
|
|
}
|
|
return {
|
|
name: entry.name(),
|
|
path: entry.path(),
|
|
sha1: entry.sha(),
|
|
type,
|
|
};
|
|
}
|
|
|
|
export class GitOperations {
|
|
private REPO_LRU_CACHE_SIZE = 16;
|
|
private REPO_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour;
|
|
private repoRoot: string;
|
|
private repoCache: LruCache<RepositoryUri, Repository>;
|
|
|
|
constructor(repoRoot: string) {
|
|
this.repoRoot = repoRoot;
|
|
|
|
const options = {
|
|
max: this.REPO_LRU_CACHE_SIZE,
|
|
maxAge: this.REPO_MAX_AGE_MS,
|
|
dispose: (repoUri: RepositoryUri, repo: Repository) => {
|
|
// Clean up the repository before disposing this repo out of the cache.
|
|
repo.cleanup();
|
|
},
|
|
};
|
|
this.repoCache = new LruCache(options);
|
|
}
|
|
|
|
public cleanRepo(uri: RepositoryUri) {
|
|
if (this.repoCache.has(uri)) {
|
|
this.repoCache.del(uri);
|
|
}
|
|
}
|
|
|
|
public async cleanAllRepo() {
|
|
this.repoCache.reset();
|
|
}
|
|
|
|
public async fileContent(uri: RepositoryUri, path: string, revision: string = 'master') {
|
|
const commit = await this.getCommit(uri, revision);
|
|
const entry: TreeEntry = await checkExists(
|
|
() => commit.getEntry(path),
|
|
`file ${uri}/${path} not found `
|
|
);
|
|
if (entry.isFile() || entry.filemode() === TreeEntry.FILEMODE.LINK) {
|
|
return await entry.getBlob();
|
|
} else {
|
|
throw Boom.unsupportedMediaType(`${uri}/${path} is not a file.`);
|
|
}
|
|
}
|
|
|
|
public async getCommit(uri: RepositoryUri, revision: string): Promise<Commit> {
|
|
const repo = await this.openRepo(uri);
|
|
if (revision.toUpperCase() === HEAD) {
|
|
return await repo.getHeadCommit();
|
|
}
|
|
// branches and tags
|
|
const refs = [`refs/remotes/origin/${revision}`, `refs/tags/${revision}`];
|
|
const commit = await this.findCommitByRefs(repo, refs);
|
|
if (commit === null) {
|
|
return (await checkExists(
|
|
() => this.findCommit(repo, revision),
|
|
`revision or branch ${revision} not found in ${repo.path()}`
|
|
)) as Commit;
|
|
}
|
|
return commit;
|
|
}
|
|
|
|
public async getDefaultBranch(uri: RepositoryUri): Promise<string> {
|
|
const repo = await this.openRepo(uri);
|
|
const ref = await repo.getReference(HEAD);
|
|
const name = ref.name();
|
|
if (name.startsWith(REFS_HEADS)) {
|
|
return name.substr(REFS_HEADS.length);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
public async getHeadRevision(uri: RepositoryUri): Promise<string> {
|
|
const repo = await this.openRepo(uri);
|
|
const commit = await repo.getHeadCommit();
|
|
return commit.sha();
|
|
}
|
|
|
|
public async blame(uri: RepositoryUri, revision: string, path: string): Promise<GitBlame[]> {
|
|
const repo = await this.openRepo(uri);
|
|
const newestCommit = (await this.getCommit(uri, revision)).id();
|
|
const blame = await Blame.file(repo, path, { newestCommit });
|
|
const results: GitBlame[] = [];
|
|
for (let i = 0; i < blame.getHunkCount(); i++) {
|
|
const hunk = blame.getHunkByIndex(i);
|
|
// @ts-ignore wrong definition in nodegit
|
|
const commit = await repo.getCommit(hunk.finalCommitId());
|
|
results.push({
|
|
committer: {
|
|
// @ts-ignore wrong definition in nodegit
|
|
name: hunk.finalSignature().name(),
|
|
// @ts-ignore wrong definition in nodegit
|
|
email: hunk.finalSignature().email(),
|
|
},
|
|
// @ts-ignore wrong definition in nodegit
|
|
startLine: hunk.finalStartLineNumber(),
|
|
// @ts-ignore wrong definition in nodegit
|
|
lines: hunk.linesInHunk(),
|
|
commit: {
|
|
id: commit.sha(),
|
|
message: commit.message(),
|
|
date: commit.date().toISOString(),
|
|
},
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
public async openRepo(uri: RepositoryUri): Promise<Repository> {
|
|
if (this.repoCache.has(uri)) {
|
|
const repo = this.repoCache.get(uri) as Repository;
|
|
return Promise.resolve(repo);
|
|
}
|
|
|
|
const repoDir = Path.join(this.repoRoot, uri);
|
|
this.checkPath(repoDir);
|
|
const repo = await checkExists<Repository>(
|
|
() => Repository.open(repoDir),
|
|
`repo ${uri} not found`
|
|
);
|
|
this.repoCache.set(uri, repo);
|
|
return Promise.resolve(repo);
|
|
}
|
|
|
|
private checkPath(path: string) {
|
|
if (!fs.realpathSync(path).startsWith(fs.realpathSync(this.repoRoot))) {
|
|
throw new Error('invalid path');
|
|
}
|
|
}
|
|
public async countRepoFiles(uri: RepositoryUri, revision: string): Promise<number> {
|
|
const commit = await this.getCommit(uri, revision);
|
|
const tree = await commit.getTree();
|
|
let count = 0;
|
|
|
|
async function walk(t: Tree) {
|
|
for (const e of t.entries()) {
|
|
if (e.isFile() && e.filemode() !== TreeEntry.FILEMODE.LINK) {
|
|
count++;
|
|
} else if (e.isDirectory()) {
|
|
const subFolder = await e.getTree();
|
|
await walk(subFolder);
|
|
} else {
|
|
// ignore other files
|
|
}
|
|
}
|
|
}
|
|
|
|
await walk(tree);
|
|
return count;
|
|
}
|
|
|
|
public async iterateRepo(
|
|
uri: RepositoryUri,
|
|
revision: string
|
|
): Promise<AsyncIterableIterator<FileTree>> {
|
|
const commit = await this.getCommit(uri, revision);
|
|
const tree = await commit.getTree();
|
|
|
|
async function* walk(t: Tree): AsyncIterableIterator<FileTree> {
|
|
for (const e of t.entries()) {
|
|
if (e.isFile() && e.filemode() !== TreeEntry.FILEMODE.LINK) {
|
|
const blob = await e.getBlob();
|
|
// Ignore binary files
|
|
if (!blob.isBinary()) {
|
|
yield entry2Tree(e);
|
|
}
|
|
} else if (e.isDirectory()) {
|
|
const subFolder = await e.getTree();
|
|
await (yield* walk(subFolder));
|
|
} else {
|
|
// ignore other files
|
|
}
|
|
}
|
|
}
|
|
|
|
return await walk(tree);
|
|
}
|
|
|
|
/**
|
|
* Return a fileTree structure by walking the repo file tree.
|
|
* @param uri the repo uri
|
|
* @param path the start path
|
|
* @param revision the revision
|
|
* @param skip pagination parameter, skip how many nodes in each children.
|
|
* @param limit pagination parameter, limit the number of node's children.
|
|
* @param resolveParents whether the return value should always start from root
|
|
* @param childrenDepth how depth should the children walk.
|
|
*/
|
|
public async fileTree(
|
|
uri: RepositoryUri,
|
|
path: string,
|
|
revision: string = HEAD,
|
|
skip: number = 0,
|
|
limit: number = DEFAULT_TREE_CHILDREN_LIMIT,
|
|
resolveParents: boolean = false,
|
|
childrenDepth: number = 1,
|
|
flatten: boolean = false
|
|
): Promise<FileTree> {
|
|
const commit = await this.getCommit(uri, revision);
|
|
const tree = await commit.getTree();
|
|
if (path === '/') {
|
|
path = '';
|
|
}
|
|
const getRoot = async () => {
|
|
return await this.walkTree(
|
|
{
|
|
name: '',
|
|
path: '',
|
|
type: FileTreeItemType.Directory,
|
|
},
|
|
tree,
|
|
[],
|
|
skip,
|
|
limit,
|
|
childrenDepth,
|
|
flatten
|
|
);
|
|
};
|
|
if (path) {
|
|
if (resolveParents) {
|
|
return this.walkTree(
|
|
await getRoot(),
|
|
tree,
|
|
path.split('/'),
|
|
skip,
|
|
limit,
|
|
childrenDepth,
|
|
flatten
|
|
);
|
|
} else {
|
|
const entry = await checkExists(
|
|
() => Promise.resolve(tree.getEntry(path)),
|
|
`path ${path} does not exists.`
|
|
);
|
|
if (entry.isDirectory()) {
|
|
const tree1 = await entry.getTree();
|
|
return this.walkTree(entry2Tree(entry), tree1, [], skip, limit, childrenDepth, flatten);
|
|
} else {
|
|
return entry2Tree(entry);
|
|
}
|
|
}
|
|
} else {
|
|
return getRoot();
|
|
}
|
|
}
|
|
|
|
public async getCommitDiff(uri: string, revision: string): Promise<CommitDiff> {
|
|
const repo = await this.openRepo(uri);
|
|
const commit = await this.getCommit(uri, revision);
|
|
const diffs = await commit.getDiff();
|
|
|
|
const commitDiff: CommitDiff = {
|
|
commit: commitInfo(commit),
|
|
additions: 0,
|
|
deletions: 0,
|
|
files: [],
|
|
};
|
|
for (const diff of diffs) {
|
|
const patches = await diff.patches();
|
|
for (const patch of patches) {
|
|
const { total_deletions, total_additions } = patch.lineStats();
|
|
commitDiff.additions += total_additions;
|
|
commitDiff.deletions += total_deletions;
|
|
if (patch.isAdded()) {
|
|
const path = patch.newFile().path();
|
|
const modifiedCode = await this.getModifiedCode(commit, path);
|
|
const language = await detectLanguage(path, modifiedCode);
|
|
commitDiff.files.push({
|
|
language,
|
|
path,
|
|
modifiedCode,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
kind: DiffKind.ADDED,
|
|
});
|
|
} else if (patch.isDeleted()) {
|
|
const path = patch.oldFile().path();
|
|
const originCode = await this.getOriginCode(commit, repo, path);
|
|
const language = await detectLanguage(path, originCode);
|
|
commitDiff.files.push({
|
|
language,
|
|
path,
|
|
originCode,
|
|
kind: DiffKind.DELETED,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
});
|
|
} else if (patch.isModified()) {
|
|
const path = patch.newFile().path();
|
|
const modifiedCode = await this.getModifiedCode(commit, path);
|
|
const originPath = patch.oldFile().path();
|
|
const originCode = await this.getOriginCode(commit, repo, originPath);
|
|
const language = await detectLanguage(patch.newFile().path(), modifiedCode);
|
|
commitDiff.files.push({
|
|
language,
|
|
path,
|
|
originPath,
|
|
originCode,
|
|
modifiedCode,
|
|
kind: DiffKind.MODIFIED,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
});
|
|
} else if (patch.isRenamed()) {
|
|
const path = patch.newFile().path();
|
|
commitDiff.files.push({
|
|
path,
|
|
originPath: patch.oldFile().path(),
|
|
kind: DiffKind.RENAMED,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return commitDiff;
|
|
}
|
|
|
|
public async getDiff(uri: string, oldRevision: string, newRevision: string): Promise<Diff> {
|
|
const repo = await this.openRepo(uri);
|
|
const oldCommit = await this.getCommit(uri, oldRevision);
|
|
const newCommit = await this.getCommit(uri, newRevision);
|
|
const oldTree = await oldCommit.getTree();
|
|
const newTree = await newCommit.getTree();
|
|
|
|
const diff = await NodeGitDiff.treeToTree(repo, oldTree, newTree);
|
|
|
|
const res: Diff = {
|
|
additions: 0,
|
|
deletions: 0,
|
|
files: [],
|
|
};
|
|
const patches = await diff.patches();
|
|
for (const patch of patches) {
|
|
const { total_deletions, total_additions } = patch.lineStats();
|
|
res.additions += total_additions;
|
|
res.deletions += total_deletions;
|
|
if (patch.isAdded()) {
|
|
const path = patch.newFile().path();
|
|
res.files.push({
|
|
path,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
kind: DiffKind.ADDED,
|
|
});
|
|
} else if (patch.isDeleted()) {
|
|
const path = patch.oldFile().path();
|
|
res.files.push({
|
|
path,
|
|
kind: DiffKind.DELETED,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
});
|
|
} else if (patch.isModified()) {
|
|
const path = patch.newFile().path();
|
|
const originPath = patch.oldFile().path();
|
|
res.files.push({
|
|
path,
|
|
originPath,
|
|
kind: DiffKind.MODIFIED,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
});
|
|
} else if (patch.isRenamed()) {
|
|
const path = patch.newFile().path();
|
|
res.files.push({
|
|
path,
|
|
originPath: patch.oldFile().path(),
|
|
kind: DiffKind.RENAMED,
|
|
additions: total_additions,
|
|
deletions: total_deletions,
|
|
});
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
|
|
private async getOriginCode(commit: Commit, repo: Repository, path: string) {
|
|
for (const oid of commit.parents()) {
|
|
const parentCommit = await repo.getCommit(oid);
|
|
if (parentCommit) {
|
|
const entry = await parentCommit.getEntry(path);
|
|
if (entry) {
|
|
return (await entry.getBlob()).content().toString('utf8');
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
private async getModifiedCode(commit: Commit, path: string) {
|
|
const entry = await commit.getEntry(path);
|
|
return (await entry.getBlob()).content().toString('utf8');
|
|
}
|
|
|
|
private async walkTree(
|
|
fileTree: FileTree,
|
|
tree: Tree,
|
|
paths: string[],
|
|
skip: number,
|
|
limit: number,
|
|
childrenDepth: number = 1,
|
|
flatten: boolean = false
|
|
): Promise<FileTree> {
|
|
const [path, ...rest] = paths;
|
|
fileTree.childrenCount = tree.entryCount();
|
|
if (!fileTree.children) {
|
|
fileTree.children = [];
|
|
for (const e of tree.entries().slice(skip, limit)) {
|
|
const child = entry2Tree(e);
|
|
fileTree.children.push(child);
|
|
if (e.isDirectory()) {
|
|
const childChildrenCount = (await e.getTree()).entryCount();
|
|
if ((childChildrenCount === 1 && flatten) || childrenDepth > 1) {
|
|
await this.walkTree(
|
|
child,
|
|
await e.getTree(),
|
|
[],
|
|
skip,
|
|
limit,
|
|
childrenDepth - 1,
|
|
flatten
|
|
);
|
|
}
|
|
}
|
|
}
|
|
fileTree.children.sort(sortFileTree);
|
|
}
|
|
if (path) {
|
|
const entry = await checkExists(
|
|
() => Promise.resolve(tree.getEntry(path)),
|
|
`path ${fileTree.path}/${path} does not exists.`
|
|
);
|
|
let child = entry2Tree(entry);
|
|
if (entry.isDirectory()) {
|
|
child = await this.walkTree(
|
|
child,
|
|
await entry.getTree(),
|
|
rest,
|
|
skip,
|
|
limit,
|
|
childrenDepth,
|
|
flatten
|
|
);
|
|
}
|
|
const idx = fileTree.children.findIndex(c => c.name === entry.name());
|
|
if (idx >= 0) {
|
|
// replace the entry in children if found
|
|
fileTree.children[idx] = child;
|
|
} else {
|
|
fileTree.children.push(child);
|
|
}
|
|
}
|
|
|
|
return fileTree;
|
|
}
|
|
|
|
private async findCommit(repo: Repository, revision: string): Promise<Commit | null> {
|
|
try {
|
|
const obj = await Object.lookupPrefix(
|
|
repo,
|
|
Oid.fromString(revision),
|
|
revision.length,
|
|
Object.TYPE.COMMIT
|
|
);
|
|
if (obj) {
|
|
return repo.getCommit(obj.id());
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async findCommitByRefs(repo: Repository, refs: string[]): Promise<Commit | null> {
|
|
if (refs.length === 0) {
|
|
return null;
|
|
}
|
|
const [ref, ...rest] = refs;
|
|
try {
|
|
return await repo.getReferenceCommit(ref);
|
|
} catch (e) {
|
|
if (e.errno === NodeGitError.CODE.ENOTFOUND) {
|
|
return await this.findCommitByRefs(repo, rest);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function commitInfo(commit: Commit): CommitInfo {
|
|
return {
|
|
updated: commit.date(),
|
|
message: commit.message(),
|
|
committer: commit.committer().name(),
|
|
id: commit.sha().substr(0, 7),
|
|
parents: commit.parents().map(oid => oid.toString().substring(0, 7)),
|
|
};
|
|
}
|
|
const REMOTE_PREFIX = 'origin/';
|
|
|
|
export async function referenceInfo(ref: Reference): Promise<ReferenceInfo | null> {
|
|
const repository = ref.owner();
|
|
let commit: CommitInfo | undefined;
|
|
try {
|
|
const object = await ref.peel(Object.TYPE.COMMIT);
|
|
commit = commitInfo(await repository.getCommit(object.id()));
|
|
} catch {
|
|
return null;
|
|
}
|
|
let type: ReferenceType;
|
|
let name = ref.shorthand();
|
|
if (ref.isTag()) {
|
|
type = ReferenceType.TAG;
|
|
} else if (ref.isRemote()) {
|
|
if (name.startsWith(REMOTE_PREFIX)) {
|
|
name = name.substr(REMOTE_PREFIX.length);
|
|
}
|
|
type = ReferenceType.REMOTE_BRANCH;
|
|
} else if (ref.isBranch()) {
|
|
type = ReferenceType.BRANCH;
|
|
} else {
|
|
type = ReferenceType.OTHER;
|
|
}
|
|
return {
|
|
name,
|
|
reference: ref.name(),
|
|
commit,
|
|
type,
|
|
};
|
|
}
|