aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHampusM <hampus@hampusmat.com>2021-06-24 22:50:38 +0200
committerHampusM <hampus@hampusmat.com>2021-06-24 22:50:38 +0200
commita13786d6cc185822f5940582efde2349ef729145 (patch)
tree7d4f49b50fc30ced65c5661b22b027456b79948e
parent01e5d215dbc152e34ecd005111171457f87c235d (diff)
Refactored the backend yet again
-rw-r--r--packages/server/.eslintrc.js2
-rw-r--r--packages/server/src/api/git.ts512
-rw-r--r--packages/server/src/api/git_types.d.ts111
-rw-r--r--packages/server/src/api/util.ts9
-rw-r--r--packages/server/src/api/v1/index.ts59
-rw-r--r--packages/server/src/api/v1/repo/branches.ts33
-rw-r--r--packages/server/src/api/v1/repo/index.ts79
-rw-r--r--packages/server/src/api/v1/repo/log.ts64
-rw-r--r--packages/server/src/app.ts27
-rw-r--r--packages/server/src/fastify_types.ts6
-rw-r--r--packages/server/src/git/blob.ts13
-rw-r--r--packages/server/src/git/branch.ts24
-rw-r--r--packages/server/src/git/commit.ts69
-rw-r--r--packages/server/src/git/diff.ts63
-rw-r--r--packages/server/src/git/http.ts84
-rw-r--r--packages/server/src/git/misc.ts36
-rw-r--r--packages/server/src/git/patch.ts113
-rw-r--r--packages/server/src/git/reference.ts18
-rw-r--r--packages/server/src/git/repository.ts105
-rw-r--r--packages/server/src/git/tag.ts80
-rw-r--r--packages/server/src/git/tree.ts39
-rw-r--r--packages/server/src/git/tree_entry.ts40
22 files changed, 893 insertions, 693 deletions
diff --git a/packages/server/.eslintrc.js b/packages/server/.eslintrc.js
index fb8ecfa..5d89f0c 100644
--- a/packages/server/.eslintrc.js
+++ b/packages/server/.eslintrc.js
@@ -182,7 +182,7 @@ module.exports = {
"no-throw-literal": "error",
"no-undef-init": "error",
"no-undefined": "error",
- "no-underscore-dangle": "error",
+ "no-underscore-dangle": [ "error", { "allowAfterThis": true } ],
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unreachable-loop": "error",
diff --git a/packages/server/src/api/git.ts b/packages/server/src/api/git.ts
deleted file mode 100644
index 49ef906..0000000
--- a/packages/server/src/api/git.ts
+++ /dev/null
@@ -1,512 +0,0 @@
-import {
- Branch, Commit,
- Hunk, Hunks,
- LatestCommit, LogCommit,
- Patch, PatchHeaderData,
- Repository, RequestInfo,
- ShortBranch, Tag,
- Tree, TreeEntry
-} from "./git_types";
-import { FastifyReply, FastifyRequest } from "fastify";
-import { Pack, pack } from "tar-stream";
-import { join, parse } from "path";
-import { readFile, readdir } from "fs";
-import { IncomingMessage } from "http";
-import { Route } from "../fastify_types";
-import { URL } from "whatwg-url";
-import { createGzip } from "zlib";
-import nodegit from "nodegit";
-import { pipeline } from "stream";
-import { spawn } from "child_process";
-import { verifyGitRequest } from "./util";
-
-function getFullRepositoryName(repo_name: string) {
- return repo_name.endsWith(".git") ? repo_name : `${repo_name}.git`;
-}
-
-function getHunkContent(hunk: string[]) {
- interface Lines {
- new_lines: number[],
- deleted_lines: number[]
- }
-
- const lines = hunk.reduce((result: Lines, line, index) => {
- if(line.charAt(0) === "+") {
- hunk[index] = line.slice(1);
- result.new_lines.push(index);
- }
- else if(line.charAt(0) === "-") {
- hunk[index] = line.slice(1);
- result.deleted_lines.push(index);
- }
- return result;
- }, { new_lines: [], deleted_lines: [] });
-
- return { ...lines, hunk: hunk.join("\n") };
-}
-
-function getPatchHeaderData(patch_headers: string[], all_patches: string[]) {
- return patch_headers.reduce((patch_header_data, line, index) => {
- // The start of a patch header
- if((/^diff --git/u).test(line)) {
- patch_header_data.indexes.push(all_patches.indexOf(line));
-
- if(patch_header_data.last !== null) {
- patch_header_data.lengths.push(patch_headers.slice(patch_header_data.last, index).length);
- }
- patch_header_data.last = index;
- }
-
- // Include the last patch header when the end is reached
- if(index === patch_headers.length - 1 && patch_header_data.last !== null) {
- patch_header_data.lengths.push(patch_headers.slice(patch_header_data.last, index).length);
- }
-
- return patch_header_data;
- }, <PatchHeaderData>{ indexes: [], lengths: [], last: null });
-}
-
-function getHunks(hunks: nodegit.ConvenientHunk[], patch_content: string[]) {
- return hunks.reduce((hunks_data: Hunks, hunk, hunk_index) => {
- const hunk_header = hunk.header();
- const hunk_header_index = patch_content.indexOf(hunk_header.replace(/\n/gu, ""));
-
- if(hunks_data.prev !== null) {
- const prev_hunk = hunks[hunk_index - 1];
- hunks_data.hunks.push({
- new_start: prev_hunk.newStart(),
- new_lines_cnt: prev_hunk.newLines(),
- old_start: prev_hunk.oldStart(),
- old_lines_cnt: prev_hunk.oldLines(),
- ...getHunkContent(patch_content.slice(hunks_data.prev, hunk_header_index))
- });
- }
-
- hunks_data.prev = hunk_header_index;
- return hunks_data;
- }, { prev: null, hunks: [] });
-}
-
-function getPatch(patch: nodegit.ConvenientPatch, too_large: boolean, hunks?: Hunk[]): Patch {
- return {
- from: patch.oldFile().path(),
- to: patch.newFile().path(),
- additions: patch.lineStats()["total_additions"],
- deletions: patch.lineStats()["total_deletions"],
- too_large: too_large,
- hunks: hunks || null
- };
-}
-
-interface Request extends FastifyRequest {
- params: Route["Params"],
-}
-
-function getRequestInfo(req: Request): RequestInfo {
- const repo = req.params.repo + ".git";
- const url_path = req.url.replace(req.params.repo, repo);
-
- const parsed_url = new URL(`${req.protocol}://${req.hostname}${url_path}`);
- const url_path_parts = parsed_url.pathname.split("/");
-
- const is_discovery = (/\/info\/refs$/u).test(parsed_url.pathname);
-
- const service = is_discovery ? parsed_url.searchParams.get("service") : url_path_parts[url_path_parts.length - 1];
-
- const content_type = `application/x-${service}-${is_discovery ? "advertisement" : "result"}`;
-
- return {
- repo,
- url_path,
- parsed_url,
- is_discovery,
- url_path_parts,
- service,
- content_type
- };
-}
-
-async function getTreeEntryLastCommit(repo: nodegit.Repository, tree_entry: nodegit.TreeEntry) {
- const walker = nodegit.Revwalk.create(repo);
- walker.pushHead();
-
- const raw_commits = await walker.getCommitsUntil(() => true);
-
- return raw_commits.reduce((acc, commit) => {
- return acc.then(result => {
- if(result.id === null) {
- return commit.getDiff().then(diffs => diffs[0].patches().then(patches => {
- let matching_path_patch = null;
- if(tree_entry.isBlob()) {
- matching_path_patch = patches.find(patch => patch.newFile().path() === tree_entry.path());
- }
- else {
- matching_path_patch = patches.find(patch => parse(patch.newFile().path()).dir.startsWith(tree_entry.path()));
- }
-
- if(matching_path_patch) {
- result.id = commit.sha();
- result.message = commit.message().replace(/\n/gu, "");
- result.date = commit.time();
- }
- return result;
- }));
- }
-
- return result;
- });
- }, Promise.resolve(<LatestCommit>{ id: null, message: null, date: null }));
-}
-
-function readDirectory(directory: string) {
- return new Promise<string[]>(resolve => {
- readdir(directory, (err, dir_content) => {
- if(err) {
- resolve([]);
- }
-
- resolve(dir_content);
- });
- });
-}
-
-async function addArchiveEntries(entries: nodegit.TreeEntry[], repo_name: string, archive: Pack) {
- for(const tree_entry of entries) {
- if(tree_entry.isBlob()) {
- const blob = await tree_entry.getBlob();
- archive.entry({ name: `${repo_name}/${tree_entry.path()}` }, blob.content().toString());
- }
- else if(tree_entry.isTree()) {
- const tree = await tree_entry.getTree();
- addArchiveEntries(tree.entries(), repo_name, archive);
- }
- }
-}
-
-function getTreeEntries(repo: nodegit.Repository, entries: nodegit.TreeEntry[]) {
- return entries.reduce((acc, entry) => {
- return acc.then(result => {
- return getTreeEntryLastCommit(repo, entry).then(last_commit => {
- result.push({
- name: parse(entry.path()).base,
- id: entry.sha(),
- type: entry.isBlob() ? "blob" : "tree",
- latest_commit: {
- id: last_commit.id,
- message: last_commit.message,
- date: last_commit.date
- }
- });
- return result;
- });
- });
- }, Promise.resolve(<TreeEntry[]>[]));
-}
-
-export class GitAPI {
- base_dir: string;
-
- constructor(base_dir: string) {
- this.base_dir = base_dir;
- }
-
- openRepository(repo_name: string): Promise<nodegit.Repository> {
- return nodegit.Repository.openBare(`${this.base_dir}/${getFullRepositoryName(repo_name)}`);
- }
-
- async getLog(repo_name: string): Promise<LogCommit[]> {
- const repo = await this.openRepository(repo_name);
-
- const walker: nodegit.Revwalk = nodegit.Revwalk.create(repo);
- walker.pushHead();
-
- const raw_commits = await walker.getCommitsUntil(() => true);
-
- return Promise.all(raw_commits.map(async commit => <LogCommit>{
- id: commit.sha(),
- author: {
- name: commit.author().name(),
- email: commit.author().email()
- },
- date: commit.time(),
- message: commit.message(),
- insertions: <number>(await (await commit.getDiff())[0].getStats()).insertions(),
- deletions: <number>(await (await commit.getDiff())[0].getStats()).deletions(),
- files_changed: <number>(await (await commit.getDiff())[0].getStats()).filesChanged()
- }));
- }
-
- async getRepositoryLastCommit(repo_name: string): Promise<number> {
- const repo = await this.openRepository(repo_name);
-
- const master_commit = await repo.getMasterCommit();
-
- return master_commit.time();
- }
-
- getRepositoryFile(repo_name: string, file: string): Promise<string | null> {
- return new Promise(resolve => {
- const full_repo_name = getFullRepositoryName(repo_name);
- readFile(`${this.base_dir}/${full_repo_name}/${file}`, (err, content) => {
- if(err) {
- resolve(null);
- return;
- }
- resolve(content.toString().replace(/\n/gu, ""));
- });
- });
- }
-
- async getRepositories(): Promise<Repository[] | null> {
- const dir_content = await readDirectory(this.base_dir);
-
- if(dir_content.length === 0) {
- return null;
- }
-
- return dir_content.filter(repo => repo.endsWith(".git")).reduce((acc, repo) => {
- return acc.then(repos => {
- return this.getRepositoryFile(repo, "description").then(description => {
- return this.getRepositoryFile(repo, "owner").then(owner => {
- return this.getRepositoryLastCommit(repo).then(last_commit_date => {
- repos.push({
- name: repo.slice(0, -4),
- description: description,
- owner: owner,
- last_updated: last_commit_date
- });
- return repos;
- });
- });
- });
- });
- }, Promise.resolve(<Repository[]>[]));
- }
-
- async getCommit(repo_name: string, commit_oid: string): Promise<Commit> {
- const repo = await this.openRepository(repo_name);
- const commit = await repo.getCommit(commit_oid);
- const diff = (await commit.getDiff())[0];
- const all_patches = (await diff.toBuf(1)).toString().split("\n");
- const patch_header_data = getPatchHeaderData((await diff.toBuf(2)).toString().split("\n"), all_patches);
-
- const parsed_patches = (await diff.patches()).reduce((acc, patch, patch_index) => {
- return acc.then(arr => patch.hunks().then(hunks => {
- const patch_start = patch_header_data.indexes[patch_index] + patch_header_data.lengths[patch_index];
- const patch_end = (typeof patch_header_data.indexes[patch_index + 1] === "undefined") ? all_patches.length - 1 : patch_header_data.indexes[patch_index + 1];
- const patch_content = all_patches.slice(patch_start, patch_end);
- const line_lengths = patch_content.map(line => line.length).reduce((result, length) => result + length);
-
- if(patch_content.length > 5000 || line_lengths > 5000) {
- console.log("Too large!");
-
- arr.push(getPatch(patch, true));
- return arr;
- }
-
- const hunks_data = getHunks(hunks, patch_content);
-
- const prev_hunk = hunks[hunks.length - 1];
- hunks_data.hunks.push({
- new_start: prev_hunk.newStart(),
- new_lines_cnt: prev_hunk.newLines(),
- old_start: prev_hunk.oldStart(),
- old_lines_cnt: prev_hunk.oldLines(),
- ...getHunkContent(patch_content.slice(<number>hunks_data.prev, patch_end))
- });
-
- arr.push(getPatch(patch, false, hunks_data.hunks));
-
- return arr;
- }));
- }, Promise.resolve(<Patch[]>[]));
-
- return {
- id: commit.sha(),
- author: {
- name: commit.author().name(),
- email: commit.author().email()
- },
- message: commit.message(),
- date: commit.time(),
- patches: await parsed_patches
- };
- }
-
- connectToGitHTTPBackend(req: Request, reply: FastifyReply): void {
- const request_info = getRequestInfo(req);
-
- const valid_request = verifyGitRequest(request_info);
- if(valid_request.success === false && valid_request.code) {
- reply.header("Content-Type", request_info.content_type);
- reply.code(valid_request.code).send(valid_request.message);
- return;
- }
-
- reply.raw.writeHead(200, { "Content-Type": request_info.content_type });
-
- const spawn_args = [ "--stateless-rpc", join(this.base_dir, request_info.repo) ];
- if(request_info.is_discovery) {
- spawn_args.push("--advertise-refs");
- }
-
- const git_pack = spawn(<string>request_info.service, spawn_args);
-
- if(request_info.is_discovery) {
- const s = "# service=" + request_info.service + "\n";
- const n = (4 + s.length).toString(16);
- reply.raw.write(Buffer.from((Array(4 - n.length + 1).join("0") + n + s) + "0000"));
- }
- else {
- const request_body: IncomingMessage = req.raw;
-
- request_body.on("data", data => git_pack.stdin.write(data));
- request_body.on("close", () => git_pack.stdin.end());
- }
-
- git_pack.on("error", err => console.log(err));
-
- git_pack.stderr.on("data", (stderr: Buffer) => console.log(stderr.toString()));
- git_pack.stdout.on("data", data => reply.raw.write(data));
-
- git_pack.on("close", () => reply.raw.end());
- }
-
- async getTree(repo_name: string, tree_path: string | null): Promise<Tree | null> {
- const repo = await this.openRepository(repo_name);
- const master_commit = await repo.getMasterCommit();
-
- const tree = await master_commit.getTree();
-
- if(tree_path) {
- const path_entry = await tree.getEntry(tree_path)
- .catch(() => null);
-
- if(!path_entry) {
- return null;
- }
-
- if(path_entry.isBlob()) {
- return { type: "blob", content: (await path_entry.getBlob()).content().toString() };
- }
-
- const path_entry_tree = await path_entry.getTree();
- return { type: "tree", content: await getTreeEntries(repo, path_entry_tree.entries()) };
- }
-
- return { type: "tree", content: await getTreeEntries(repo, tree.entries()) };
- }
-
- async doesObjectExist(repo_name: string, id: string): Promise<boolean> {
- const repo = await this.openRepository(repo_name);
-
- return nodegit.Object.lookup(repo, nodegit.Oid.fromString(id), nodegit.Object.TYPE.ANY)
- .then(() => true)
- .catch(() => false);
- }
-
- async doesReadmeExist(repo_name: string): Promise<boolean> {
- const repo = await this.openRepository(repo_name);
-
- const master_commit = await repo.getMasterCommit();
- const tree = await master_commit.getTree();
-
- return tree.getEntry("README.md").catch(() => null)
- .then(() => true)
- .catch(() => false);
- }
-
- async getBranches(repo_name: string): Promise<ShortBranch[]> {
- const repo = await this.openRepository(repo_name);
-
- const references = await repo.getReferences();
-
- return references.filter(ref => ref.isBranch()).map(ref => {
- return {
- id: ref.target().tostrS(),
- name: ref.shorthand()
- };
- });
- }
-
- async getBranch(repo_name: string, branch_id: string): Promise<Branch | null> {
- const repo = await this.openRepository(repo_name);
-
- const references = await repo.getReferences();
- const branches = references.filter(ref => ref.isBranch());
-
- const branch = branches.find(_branch => _branch.target().tostrS() === branch_id);
- if(!branch) {
- return null;
- }
-
- const latest_commit = await repo.getBranchCommit(branch);
-
- return {
- id: branch.target().tostrS(),
- name: branch.shorthand(),
- latest_commit: {
- id: latest_commit.sha(),
- message: latest_commit.message(),
- date: latest_commit.time()
- }
- };
- }
-
- async getTags(repo_name: string): Promise<Tag[]> {
- const repo = await this.openRepository(repo_name);
-
- const references = await repo.getReferences();
-
- return Promise.all(references.filter(ref => ref.isTag()).map(async ref => {
- const tagger = (await nodegit.Tag.lookup(repo, ref.target())).tagger();
-
- return {
- name: ref.shorthand(),
- author: {
- name: tagger.name(),
- email: tagger.email()
- },
- date: tagger.when().time()
- };
- }));
- }
-
- async downloadTagArchive(repo_name: string, tag_name: string, reply: FastifyReply): Promise<void> {
- const repo = await this.openRepository(repo_name);
-
- const reference = await repo.getReference(tag_name)
- .catch(() => {
- reply.code(404).send("Tag not found!");
- return null;
- });
- if(!reference) {
- return;
- }
-
- const commit = await nodegit.Commit.lookup(repo, (await reference.peel(nodegit.Object.TYPE.COMMIT)).id());
- const tree = await commit.getTree();
-
- const archive = pack();
- const gzip = createGzip();
-
- reply.raw.writeHead(200, {
- "Content-Encoding": "gzip",
- "Content-Type": "application/gzip",
- "Content-Disposition": `attachment; filename="${repo_name}-${tag_name}.tar.gz"`
- });
-
- pipeline(archive, gzip, reply.raw, () => reply.raw.end());
-
- gzip.on("close", () => reply.raw.end());
- gzip.on("error", () => reply.raw.end());
- archive.on("error", () => reply.raw.end());
-
- addArchiveEntries(tree.entries(), repo_name, archive)
- .then(() => archive.finalize())
- .catch(() => {
- archive.finalize();
- reply.raw.end();
- });
- }
-} \ No newline at end of file
diff --git a/packages/server/src/api/git_types.d.ts b/packages/server/src/api/git_types.d.ts
deleted file mode 100644
index 8aff698..0000000
--- a/packages/server/src/api/git_types.d.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- Types
-*/
-
-// General
-export type Author = {
- name: string,
- email: string
-}
-export type LatestCommit = {
- id: string | null,
- message: string | null,
- date: number | null
-}
-
-// Repository
-export type Repository = {
- name: string,
- description: string | null,
- owner: string | null,
- last_updated: number
-}
-
-// Diff
-export type Hunk = {
- new_start: number,
- new_lines_cnt: number,
- old_start: number,
- old_lines_cnt: number,
- new_lines: number[],
- deleted_lines: number[],
- hunk: string
-}
-export type Patch = {
- from: string,
- to: string,
- additions: number,
- deletions: number,
- too_large: boolean,
- hunks: Hunk[] | null
-}
-export type Hunks = {
- prev: null | number,
- hunks: Hunk[]
-}
-export type PatchHeaderData = {
- indexes: number[],
- lengths: number[],
- last: number | null
-}
-
-// Request
-export type RequestInfo = {
- repo: string,
- url_path: string,
- parsed_url: URL,
- url_path_parts: string[],
- is_discovery: boolean,
- service: string | null,
- content_type: string
-}
-
-// Tree
-export type TreeEntry = {
- name: string,
- id: string,
- type: "blob" | "tree",
- latest_commit: LatestCommit
-}
-export type Tree = {
- type: "blob" | "tree",
- content: string | TreeEntry[]
-}
-
-// Log
-export type LogCommit = {
- id: string,
- author: Author,
- date: number,
- message: string,
- insertions: number,
- deletions: number,
- files_changed: number
-}
-export type Commit = {
- id: string,
- author: Author,
- message: string,
- date: number,
- patches: Patch[]
-}
-
-// Tag
-export type Tag = {
- name: string,
- date: number,
- author: Author
-}
-
-/*
- Interfaces
-*/
-
-// Branch
-export interface ShortBranch {
- id: string,
- name: string
-}
-export interface Branch extends ShortBranch {
- latest_commit: LatestCommit
-} \ No newline at end of file
diff --git a/packages/server/src/api/util.ts b/packages/server/src/api/util.ts
index b05ebb7..e1eaa2c 100644
--- a/packages/server/src/api/util.ts
+++ b/packages/server/src/api/util.ts
@@ -1,5 +1,6 @@
-import { GitAPI } from "./git";
-import { RequestInfo } from "./git_types";
+import { Commit } from "../git/commit";
+import { Repository } from "../git/repository";
+import { RequestInfo } from "../git/http";
import { readdir } from "fs";
type VerificationResultType = "SUCCESS" | "NOT_FOUND" | "INVALID" | "ACCESS_DENIED";
@@ -51,12 +52,12 @@ export function verifyRepoName(base_dir: string, repo_name: string): Promise<Ver
});
}
-export async function verifySHA(git: GitAPI, repo_name: string, sha: string): Promise<VerificationResult> {
+export async function verifySHA(repository: Repository, sha: string): Promise<VerificationResult> {
if(!(/^[a-fA-F0-9]+$/u).test(sha)) {
return new VerificationResult("INVALID", "sha");
}
- const object_exists = await git.doesObjectExist(repo_name, sha);
+ const object_exists = await Commit.lookupExists(repository, sha);
if(!object_exists) {
return new VerificationResult("NOT_FOUND", "object");
diff --git a/packages/server/src/api/v1/index.ts b/packages/server/src/api/v1/index.ts
index 00652a8..a6ab918 100644
--- a/packages/server/src/api/v1/index.ts
+++ b/packages/server/src/api/v1/index.ts
@@ -1,12 +1,10 @@
import { FastifyInstance, FastifyPluginOptions } from "fastify";
-import { GitAPI } from "../git";
+import { Repository } from "../../git/repository";
import { Route } from "../../fastify_types";
import repo from "./repo";
import { verifyRepoName } from "../util";
-export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
- const git = new GitAPI(opts.config.settings.base_dir);
-
+function setHandlers(fastify: FastifyInstance): void {
fastify.setErrorHandler((err, req, reply) => {
console.log(err);
reply.code(500).send({ error: "Internal server error!" });
@@ -14,21 +12,29 @@ export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, do
fastify.setNotFoundHandler((req, reply) => {
reply.code(404).send({ error: "Endpoint not found!" });
});
+}
- fastify.route({
- method: "GET",
- url: "/info",
- handler: (req, reply) => {
- reply.send({ data: { title: opts.config.settings.title, about: opts.config.settings.about } });
- }
- });
+function reposEndpoints(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
fastify.route({
method: "GET",
url: "/repos",
handler: async(req, reply) => {
- const repos = await git.getRepositories();
+ const repos = await Repository.openAll(opts.config.settings.base_dir);
+
+ if(!repos) {
+ reply.send({ data: [] });
+ return;
+ }
- reply.send({ data: repos });
+ reply.send({
+ data: await Promise.all(repos.map(async repository => {
+ return {
+ name: repository.name.short,
+ description: repository.description,
+ last_updated: (await repository.latestCommit()).date
+ };
+ }))
+ });
}
});
@@ -39,15 +45,36 @@ export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, do
const repo_verification = await verifyRepoName(opts.config.settings.base_dir, req.params.repo);
if(repo_verification.success === false && repo_verification.code) {
reply.code(repo_verification.code).send({ error: repo_verification.message });
+ return;
}
- const desc = await git.getRepositoryFile(req.params.repo, "description");
+ const repository = await Repository.open(opts.config.settings.base_dir, req.params.repo);
- reply.send({ data: { name: req.params.repo, description: desc, has_readme: await git.doesReadmeExist(req.params.repo) } });
+ reply.send({
+ data: {
+ name: repository.name.short,
+ description: repository.description,
+ has_readme: await (await repository.tree()).findExists("README.md")
+ }
+ });
+ }
+ });
+ done();
+}
+
+export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
+ setHandlers(fastify);
+
+ fastify.route({
+ method: "GET",
+ url: "/info",
+ handler: (req, reply) => {
+ reply.send({ data: { title: opts.config.settings.title, about: opts.config.settings.about } });
}
});
- fastify.register(repo, { prefix: "/repos/:repo", config: { git: git, settings: opts.config.settings } });
+ fastify.register(reposEndpoints, { config: { settings: opts.config.settings } });
+ fastify.register(repo, { prefix: "/repos/:repo", config: { settings: opts.config.settings } });
done();
} \ No newline at end of file
diff --git a/packages/server/src/api/v1/repo/branches.ts b/packages/server/src/api/v1/repo/branches.ts
index fd8056a..fe962aa 100644
--- a/packages/server/src/api/v1/repo/branches.ts
+++ b/packages/server/src/api/v1/repo/branches.ts
@@ -1,18 +1,22 @@
import { FastifyInstance, FastifyPluginOptions } from "fastify";
-import { GitAPI } from "../../git";
+import { Branch } from "../../../git/branch";
import { Route } from "../../../fastify_types";
-import { verifySHA } from "../../util";
export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
- const git: GitAPI = opts.config.git;
-
fastify.route<Route>({
method: "GET",
url: "/branches",
handler: async(req, reply) => {
- const branches = await git.getBranches(req.params.repo);
-
- reply.send({ data: branches });
+ const branches = await (await req.repository).branches();
+
+ reply.send({
+ data: branches.map(branch => {
+ return {
+ id: branch.id,
+ name: branch.name
+ };
+ })
+ });
}
});
@@ -20,19 +24,20 @@ export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, do
method: "GET",
url: "/branches/:branch",
handler: async(req, reply) => {
- const branch_verification = await verifySHA(git, req.params.repo, req.params.branch);
- if(branch_verification.success === false && branch_verification.code) {
- reply.code(branch_verification.code).send({ error: branch_verification.message });
- }
-
- const branch = await git.getBranch(req.params.repo, req.params.branch);
+ const branch = await Branch.lookup(await req.repository, req.params.branch);
if(!branch) {
reply.code(404).send({ error: "Branch not found!" });
return;
}
- reply.send({ data: branch });
+ reply.send({
+ data: {
+ id: branch.id,
+ name: branch.name,
+ latest_commit: await branch.latestCommit()
+ }
+ });
}
});
diff --git a/packages/server/src/api/v1/repo/index.ts b/packages/server/src/api/v1/repo/index.ts
index 86e5d10..c13e173 100644
--- a/packages/server/src/api/v1/repo/index.ts
+++ b/packages/server/src/api/v1/repo/index.ts
@@ -1,37 +1,86 @@
-import { FastifyInstance, FastifyPluginOptions, FastifyRequest } from "fastify";
-import { GitAPI } from "../../git";
-import { Route } from "../../../fastify_types";
+import { CoolFastifyRequest, Route } from "../../../fastify_types";
+import { FastifyInstance, FastifyPluginOptions } from "fastify";
+import { Blob } from "../../../git/blob";
+import { Repository } from "../../../git/repository";
+import { Tag } from "../../../git/tag";
+import { TreeEntry } from "../../../git/tree_entry";
import branches from "./branches";
import log from "./log";
import { verifyRepoName } from "../../util";
-export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
- const git: GitAPI = opts.config.git;
+declare module "fastify" {
+ interface FastifyRequest {
+ repository: Promise<Repository>,
+ }
+}
+
+function addHooks(fastify: FastifyInstance, opts: FastifyPluginOptions): void {
+ fastify.addHook("preHandler", (req: CoolFastifyRequest, reply, hookDone) => {
+ req.repository = Repository.open(opts.config.settings.base_dir, req.params.repo);
+
+ hookDone();
+ });
- fastify.addHook("onRequest", async(req: FastifyRequest<Route>, reply) => {
+ fastify.addHook("onRequest", async(req: CoolFastifyRequest, reply) => {
const repo_verification = await verifyRepoName(opts.config.settings.base_dir, req.params.repo);
if(repo_verification.success === false && repo_verification.code) {
reply.code(repo_verification.code).send({ error: repo_verification.message });
}
+
});
+}
+async function treeEntryMap(entry: TreeEntry) {
+ const latest_commit = await entry.latestCommit();
+ return {
+ path: entry.path,
+ latest_commit: {
+ id: latest_commit.id,
+ message: latest_commit.message,
+ date: latest_commit.date
+ }
+ };
+}
- fastify.register(log, { config: { git: git } });
- fastify.register(branches, { config: { git: git } });
+async function tagMap(tag: Tag) {
+ const author = await tag.author();
+ return {
+ name: tag.name,
+ author: { name: author.name, email: author.email },
+ date: await tag.date()
+ };
+}
+
+export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
+ addHooks(fastify, opts);
+
+ fastify.register(log);
+ fastify.register(branches);
fastify.route<Route>({
method: "GET",
url: "/tree",
handler: async(req, reply) => {
+ const tree = await (await req.repository).tree();
+
const tree_path = (Object.keys(req.query).length !== 0 && req.query.path) ? req.query.path : null;
+ if(tree_path) {
+ const tree_found = await tree.find(tree_path);
+
+ if(!tree_found) {
+ reply.code(404).send({ error: "Tree path not found!" });
+ return;
+ }
- const tree = await git.getTree(req.params.repo, tree_path);
+ reply.send({
+ data: tree_found instanceof Blob
+ ? { type: "blob", content: await tree_found.content() }
+ : { type: "tree", content: await Promise.all(tree_found.entries().map(treeEntryMap)) }
+ });
- if(!tree) {
- reply.code(404).send({ error: "Path not found" });
return;
}
- reply.send({ data: tree });
+ reply.send({ data: await Promise.all(tree.entries().map(treeEntryMap)) });
}
});
@@ -39,8 +88,10 @@ export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, do
method: "GET",
url: "/tags",
handler: async(req, reply) => {
- const refs = await git.getTags(req.params.repo);
- reply.send({ data: refs });
+ const tags = await (await req.repository).tags();
+ reply.send({
+ data: await Promise.all(tags.map(tagMap))
+ });
}
});
diff --git a/packages/server/src/api/v1/repo/log.ts b/packages/server/src/api/v1/repo/log.ts
index f692b00..d21cfa8 100644
--- a/packages/server/src/api/v1/repo/log.ts
+++ b/packages/server/src/api/v1/repo/log.ts
@@ -1,23 +1,46 @@
import { FastifyInstance, FastifyPluginOptions } from "fastify";
-import { GitAPI } from "../../git";
+import { Commit } from "../../../git/commit";
+import { Patch } from "../../../git/patch";
import { Route } from "../../../fastify_types";
import { verifySHA } from "../../util";
-export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
- const git: GitAPI = opts.config.git;
+async function commitMap(commit: Commit) {
+ const stats = await commit.stats();
+ return {
+ id: commit.id,
+ author: {
+ name: commit.author.name,
+ email: commit.author.email
+ },
+ message: commit.message,
+ date: commit.date,
+ insertions: stats.insertions,
+ deletions: stats.deletions,
+ files_changed: stats.files_changed
+ };
+}
+
+async function patchMap(patch: Patch) {
+ return {
+ additions: patch.additions,
+ deletions: patch.deletions,
+ from: patch.from,
+ to: patch.to,
+ too_large: patch.too_large,
+ hunks: await patch.getHunks()
+ };
+}
+export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, done: (err?: Error) => void): void {
fastify.route<Route>({
method: "GET",
url: "/log",
handler: async(req, reply) => {
- const log = await git.getLog(req.params.repo);
+ const commits = (await (await req.repository).commits());
- if(log.length === 0) {
- reply.code(500).send({ error: "Internal server error!" });
- return;
- }
-
- reply.send({ data: log });
+ reply.send({
+ data: await Promise.all(commits.map(commitMap))
+ });
}
});
@@ -25,14 +48,29 @@ export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, do
method: "GET",
url: "/log/:commit",
handler: async(req, reply) => {
- const commit_verification = await verifySHA(git, req.params.repo, req.params.commit);
+ const commit_verification = await verifySHA(await req.repository, req.params.commit);
if(commit_verification.success === false && commit_verification.code) {
reply.code(commit_verification.code).send({ error: commit_verification.message });
}
- const commit = await git.getCommit(req.params.repo, req.params.commit);
+ const commit = await Commit.lookup(await req.repository, req.params.commit);
+
+ const stats = await commit.stats();
- reply.send({ data: commit });
+ reply.send({
+ data: {
+ message: commit.message,
+ author: {
+ name: commit.author.name,
+ email: commit.author.email
+ },
+ date: commit.date,
+ insertions: stats.insertions,
+ deletions: stats.deletions,
+ files_changed: stats.files_changed,
+ diff: await Promise.all((await (await commit.diff()).getPatches()).map(patchMap))
+ }
+ });
}
});
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 717f106..7bc66b2 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -1,6 +1,7 @@
import { readFileSync, readdirSync } from "fs";
-import { GitAPI } from "./api/git";
+import { Repository } from "./git/repository";
import { Route } from "./fastify_types";
+import { Tag } from "./git/tag";
import api from "./api/v1";
import { exit } from "process";
import { fastify as fastifyFactory } from "fastify";
@@ -62,7 +63,11 @@ if(settings.production) {
}
const fastify = fastifyFactory();
-const git = new GitAPI(settings.base_dir);
+
+fastify.setErrorHandler((err, req, reply) => {
+ console.log(err);
+ reply.code(500).send("Internal server error!");
+});
fastify.setNotFoundHandler({}, function(req, reply) {
reply.code(404).send("Page not found!");
@@ -112,7 +117,8 @@ fastify.route<Route>({
return;
}
- git.connectToGitHTTPBackend(req, reply);
+ const repository = await Repository.open(settings.base_dir, req.params.repo);
+ repository.HTTPconnect(req, reply);
}
});
@@ -127,7 +133,8 @@ fastify.route<Route>({
reply.code(repo_verification.code).send(repo_verification.message);
}
- git.connectToGitHTTPBackend(req, reply);
+ const repository = await Repository.open(settings.base_dir, req.params.repo);
+ repository.HTTPconnect(req, reply);
}
});
@@ -143,8 +150,16 @@ fastify.route({
fastify.route<Route>({
method: "GET",
url: "/:repo([a-zA-Z0-9\\.\\-_]+)/refs/tags/:tag",
- handler: (req, reply) => {
- git.downloadTagArchive(req.params.repo, req.params.tag, reply);
+ handler: async(req, reply) => {
+ const repository = await Repository.open(settings.base_dir, req.params.repo);
+ const tag = await Tag.lookup(repository, req.params.tag);
+
+ if(!tag) {
+ reply.code(404).send("Tag not found!");
+ return;
+ }
+
+ tag.downloadTarball(reply);
}
});
diff --git a/packages/server/src/fastify_types.ts b/packages/server/src/fastify_types.ts
index 986fdf2..baed761 100644
--- a/packages/server/src/fastify_types.ts
+++ b/packages/server/src/fastify_types.ts
@@ -1,5 +1,5 @@
+import { FastifyRequest, RequestGenericInterface } from "fastify";
import { ReplyGenericInterface } from "fastify/types/reply";
-import { RequestGenericInterface } from "fastify";
export interface Request extends RequestGenericInterface {
Params: {
@@ -10,4 +10,6 @@ export interface Request extends RequestGenericInterface {
}
}
-export interface Route extends Request, ReplyGenericInterface {} \ No newline at end of file
+export interface Route extends Request, ReplyGenericInterface {}
+
+export type CoolFastifyRequest = FastifyRequest<Route>; \ No newline at end of file
diff --git a/packages/server/src/git/blob.ts b/packages/server/src/git/blob.ts
new file mode 100644
index 0000000..aa3f9ad
--- /dev/null
+++ b/packages/server/src/git/blob.ts
@@ -0,0 +1,13 @@
+import { TreeEntry as NodeGitTreeEntry } from "nodegit";
+
+export class Blob {
+ private _ng_tree_entry: NodeGitTreeEntry;
+
+ constructor(entry: NodeGitTreeEntry) {
+ this._ng_tree_entry = entry;
+ }
+
+ async content(): Promise<string> {
+ return this._ng_tree_entry.isBlob() ? (await this._ng_tree_entry.getBlob()).toString() : "";
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/branch.ts b/packages/server/src/git/branch.ts
new file mode 100644
index 0000000..2142724
--- /dev/null
+++ b/packages/server/src/git/branch.ts
@@ -0,0 +1,24 @@
+import { CommitSummary } from "./commit";
+import { Reference } from "./reference";
+import { Repository } from "./repository";
+
+export class Branch extends Reference {
+ async latestCommit(): Promise<CommitSummary> {
+ const latest_commit = this._owner.nodegitRepository.getBranchCommit(this._ng_reference);
+ return {
+ id: (await latest_commit).sha(),
+ message: (await latest_commit).message(),
+ date: (await latest_commit).time()
+ };
+ }
+
+ static async lookup(owner: Repository, branch: string): Promise<Branch | null> {
+ const reference = await owner.nodegitRepository.getBranch(branch).catch(err => {
+ if(err.errno === -3) {
+ return null;
+ }
+ throw(err);
+ });
+ return reference ? new Branch(owner, reference) : null;
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/commit.ts b/packages/server/src/git/commit.ts
new file mode 100644
index 0000000..64bae4d
--- /dev/null
+++ b/packages/server/src/git/commit.ts
@@ -0,0 +1,69 @@
+import { Commit as NodeGitCommit, Oid as NodeGitOid } from "nodegit";
+import { Author } from "./misc";
+import { Diff } from "./diff";
+import { Repository } from "./repository";
+import { Tree } from "./tree";
+
+export type CommitSummary = {
+ id: string | null,
+ message: string | null,
+ date: number | null
+}
+
+type DiffStats = {
+ insertions: number,
+ deletions: number,
+ files_changed: number
+}
+
+export class Commit {
+ private _ng_commit: NodeGitCommit;
+ private _owner: Repository;
+
+ public id: string;
+ public author: Author;
+ public date: number;
+ public message: string;
+
+ constructor(owner: Repository, commit: NodeGitCommit) {
+ this._ng_commit = commit;
+ this._owner = owner;
+
+ this.id = commit.sha();
+ this.author = {
+ name: commit.author().name(),
+ email: commit.author().email()
+ };
+ this.date = commit.time();
+ this.message = commit.message();
+ }
+
+ async diff(): Promise<Diff> {
+ return Diff.get((await this._ng_commit.getDiff())[0]);
+ }
+
+ async stats(): Promise<DiffStats> {
+ const stats = await (await this._ng_commit.getDiff())[0].getStats();
+
+ return {
+ insertions: <number>stats.insertions(),
+ deletions: <number>stats.deletions(),
+ files_changed: <number>stats.filesChanged()
+ };
+ }
+
+ async tree(): Promise<Tree> {
+ return new Tree(this._owner, await this._ng_commit.getTree());
+ }
+
+ static async lookup(repository: Repository, id: string | NodeGitOid): Promise<Commit> {
+ const commit = await NodeGitCommit.lookup(repository.nodegitRepository, id instanceof NodeGitOid ? id : NodeGitOid.fromString(id));
+ return new Commit(repository, commit);
+ }
+
+ static lookupExists(repository: Repository, id: string): Promise<boolean> {
+ return NodeGitCommit.lookup(repository.nodegitRepository, NodeGitOid.fromString(id))
+ .then(() => true)
+ .catch(() => false);
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/diff.ts b/packages/server/src/git/diff.ts
new file mode 100644
index 0000000..a3ea375
--- /dev/null
+++ b/packages/server/src/git/diff.ts
@@ -0,0 +1,63 @@
+import { Diff as NodeGitDiff } from "nodegit";
+import { Patch } from "./patch";
+
+type PatchHeaderData = {
+ indexes: number[],
+ lengths: number[],
+ last: number | null
+}
+
+type DiffConstructorData = {
+ patch_buf: string,
+ patch_header_buf: string
+}
+
+export class Diff {
+ private _ng_diff: NodeGitDiff;
+
+ public raw_patches: string;
+ public patch_header_indexes: number[];
+ public patch_header_lengths: number[];
+
+ constructor(diff: NodeGitDiff, data: DiffConstructorData) {
+ this._ng_diff = diff;
+ this.raw_patches = data.patch_buf;
+
+ const raw_patches_arr = this.raw_patches.split("\n");
+ const patch_headers = data.patch_header_buf.split("\n");
+
+ const patch_header_data = patch_headers.reduce((result, line, index) => {
+ // The start of a patch header
+ if((/^diff --git/u).test(line)) {
+ result.indexes.push(raw_patches_arr.indexOf(line));
+
+ if(result.last !== null) {
+ result.lengths.push(patch_headers.slice(result.last, index).length);
+ }
+ result.last = index;
+ }
+
+ // Include the last patch header when the end is reached
+ if(index === patch_headers.length - 1 && result.last !== null) {
+ result.lengths.push(patch_headers.slice(result.last, index).length);
+ }
+
+ return result;
+ }, <PatchHeaderData>{ indexes: [], lengths: [], last: null });
+
+ this.patch_header_indexes = patch_header_data.indexes;
+ this.patch_header_lengths = patch_header_data.lengths;
+
+ }
+
+ async getPatches(): Promise<Patch[]> {
+ return (await this._ng_diff.patches()).map((patch, index) => new Patch(this, patch, index));
+ }
+
+ static async get(diff: NodeGitDiff): Promise<Diff> {
+ return new Diff(diff, {
+ patch_buf: String((await diff.toBuf(1))),
+ patch_header_buf: String((await diff.toBuf(2)))
+ });
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/http.ts b/packages/server/src/git/http.ts
new file mode 100644
index 0000000..2d707cb
--- /dev/null
+++ b/packages/server/src/git/http.ts
@@ -0,0 +1,84 @@
+import { FastifyReply, FastifyRequest } from "fastify";
+import { IncomingMessage } from "http";
+import { Repository } from "./repository";
+import { Route } from "../fastify_types";
+import { join } from "path";
+import { spawn } from "child_process";
+import { verifyGitRequest } from "../api/util";
+
+export type RequestInfo = {
+ repo: string,
+ url_path: string,
+ parsed_url: URL,
+ url_path_parts: string[],
+ is_discovery: boolean,
+ service: string | null,
+ content_type: string
+}
+
+export interface Request extends FastifyRequest {
+ params: Route["Params"],
+}
+
+function getRequestInfo(req: Request): RequestInfo {
+ const repo = req.params.repo + ".git";
+ const url_path = req.url.replace(req.params.repo, repo);
+
+ const parsed_url = new URL(`${req.protocol}://${req.hostname}${url_path}`);
+ const url_path_parts = parsed_url.pathname.split("/");
+
+ const is_discovery = (/\/info\/refs$/u).test(parsed_url.pathname);
+
+ const service = is_discovery ? parsed_url.searchParams.get("service") : url_path_parts[url_path_parts.length - 1];
+
+ const content_type = `application/x-${service}-${is_discovery ? "advertisement" : "result"}`;
+
+ return {
+ repo,
+ url_path,
+ parsed_url,
+ is_discovery,
+ url_path_parts,
+ service,
+ content_type
+ };
+}
+
+export function connect(repository: Repository, req: Request, reply: FastifyReply): void {
+ const request_info = getRequestInfo(req);
+
+ const valid_request = verifyGitRequest(request_info);
+ if(valid_request.success === false && valid_request.code) {
+ reply.header("Content-Type", request_info.content_type);
+ reply.code(valid_request.code).send(valid_request.message);
+ return;
+ }
+
+ reply.raw.writeHead(200, { "Content-Type": request_info.content_type });
+
+ const spawn_args = [ "--stateless-rpc", join(repository.base_dir, request_info.repo) ];
+ if(request_info.is_discovery) {
+ spawn_args.push("--advertise-refs");
+ }
+
+ const git_pack = spawn(<string>request_info.service, spawn_args);
+
+ if(request_info.is_discovery) {
+ const s = "# service=" + request_info.service + "\n";
+ const n = (4 + s.length).toString(16);
+ reply.raw.write(Buffer.from((Array(4 - n.length + 1).join("0") + n + s) + "0000"));
+ }
+ else {
+ const request_body: IncomingMessage = req.raw;
+
+ request_body.on("data", data => git_pack.stdin.write(data));
+ request_body.on("close", () => git_pack.stdin.end());
+ }
+
+ git_pack.on("error", err => console.log(err));
+
+ git_pack.stderr.on("data", (stderr: Buffer) => console.log(stderr.toString()));
+ git_pack.stdout.on("data", data => reply.raw.write(data));
+
+ git_pack.on("close", () => reply.raw.end());
+} \ No newline at end of file
diff --git a/packages/server/src/git/misc.ts b/packages/server/src/git/misc.ts
new file mode 100644
index 0000000..fcfaf29
--- /dev/null
+++ b/packages/server/src/git/misc.ts
@@ -0,0 +1,36 @@
+import { readFile, readdir } from "fs";
+
+export async function findAsync<T>(arr: T[], callback: (t: T) => Promise<boolean>): Promise<T> {
+ const results = await Promise.all(arr.map(callback));
+ const index = results.findIndex(result => result);
+ return arr[index];
+}
+
+export type Author = {
+ name: string,
+ email: string
+}
+
+export function getFile(base_dir: string, repository: string, file: string): Promise<string | null> {
+ return new Promise(resolve => {
+ readFile(`${base_dir}/${repository}/${file}`, (err, content) => {
+ if(err) {
+ resolve(null);
+ return;
+ }
+ resolve(content.toString().replace(/\n/gu, ""));
+ });
+ });
+}
+
+export function getDirectory(directory: string): Promise<string[]> {
+ return new Promise<string[]>(resolve => {
+ readdir(directory, (err, dir_content) => {
+ if(err) {
+ resolve([]);
+ }
+
+ resolve(dir_content);
+ });
+ });
+} \ No newline at end of file
diff --git a/packages/server/src/git/patch.ts b/packages/server/src/git/patch.ts
new file mode 100644
index 0000000..71d9193
--- /dev/null
+++ b/packages/server/src/git/patch.ts
@@ -0,0 +1,113 @@
+import { Diff } from "./diff";
+import { ConvenientPatch as NodeGitPatch } from "nodegit";
+
+type Hunk = {
+ new_start: number,
+ new_lines_cnt: number,
+ old_start: number,
+ old_lines_cnt: number,
+ new_lines: number[],
+ deleted_lines: number[],
+ hunk: string
+}
+
+type Hunks = {
+ prev: null | number,
+ hunks: Hunk[]
+}
+
+function getHunkContent(hunk: string[]) {
+ interface Lines {
+ new_lines: number[],
+ deleted_lines: number[]
+ }
+
+ const lines = hunk.reduce((result: Lines, line, index) => {
+ if(line.charAt(0) === "+") {
+ hunk[index] = line.slice(1);
+ result.new_lines.push(index);
+ }
+ else if(line.charAt(0) === "-") {
+ hunk[index] = line.slice(1);
+ result.deleted_lines.push(index);
+ }
+ return result;
+ }, { new_lines: [], deleted_lines: [] });
+
+ return { ...lines, hunk: hunk.join("\n") };
+}
+
+export class Patch {
+ private _ng_patch: NodeGitPatch;
+
+ public from: string;
+ public to: string;
+ public additions: number;
+ public deletions: number;
+ public too_large = false;
+ public content: string | null = null;
+
+ constructor(diff: Diff, patch: NodeGitPatch, index: number) {
+ this._ng_patch = patch;
+
+ this.from = patch.oldFile().path();
+ this.to = patch.newFile().path();
+ this.additions = patch.lineStats()["total_additions"];
+ this.deletions = patch.lineStats()["total_deletions"];
+
+ const raw_patches_arr = diff.raw_patches.split("\n");
+ const start = diff.patch_header_indexes[index] + diff.patch_header_lengths[index];
+ const end = (typeof diff.patch_header_indexes[index + 1] === "undefined") ? raw_patches_arr.length - 1 : diff.patch_header_indexes[index + 1];
+
+ const patch_content = raw_patches_arr.slice(start, end);
+
+ if(patch_content.length !== 0) {
+ this.content = patch_content.join("\n");
+
+ const line_lengths = patch_content.map(line => line.length).reduce((result, length) => result + length);
+
+ if(patch_content.length > 5000 || line_lengths > 5000) {
+ this.too_large = true;
+ }
+ }
+ }
+
+ async getHunks(): Promise<Hunk[] | null> {
+ if(!this.content) {
+ return null;
+ }
+
+ const content = this.content.split("\n");
+ const hunks = await this._ng_patch.hunks();
+
+ const hunks_data = hunks.reduce((result: Hunks, hunk, hunk_index) => {
+ const hunk_header = hunk.header();
+ const hunk_header_index = content.indexOf(hunk_header.replace(/\n/gu, ""));
+
+ if(result.prev !== null) {
+ const prev_hunk = hunks[hunk_index - 1];
+ result.hunks.push({
+ new_start: prev_hunk.newStart(),
+ new_lines_cnt: prev_hunk.newLines(),
+ old_start: prev_hunk.oldStart(),
+ old_lines_cnt: prev_hunk.oldLines(),
+ ...getHunkContent(content.slice(result.prev, hunk_header_index))
+ });
+ }
+
+ result.prev = hunk_header_index;
+ return result;
+ }, { prev: null, hunks: [] });
+
+ const prev_hunk = hunks[hunks.length - 1];
+ hunks_data.hunks.push({
+ new_start: prev_hunk.newStart(),
+ new_lines_cnt: prev_hunk.newLines(),
+ old_start: prev_hunk.oldStart(),
+ old_lines_cnt: prev_hunk.oldLines(),
+ ...getHunkContent(content.slice(<number>hunks_data.prev))
+ });
+
+ return hunks_data.hunks;
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/reference.ts b/packages/server/src/git/reference.ts
new file mode 100644
index 0000000..910fa7d
--- /dev/null
+++ b/packages/server/src/git/reference.ts
@@ -0,0 +1,18 @@
+import { Reference as NodeGitReference } from "nodegit";
+import { Repository } from "./repository";
+
+export abstract class Reference {
+ protected _ng_reference: NodeGitReference;
+ protected _owner: Repository;
+
+ id: string;
+ name: string;
+
+ constructor(owner: Repository, reference: NodeGitReference) {
+ this._ng_reference = reference;
+ this._owner = owner;
+
+ this.id = reference.target().tostrS();
+ this.name = reference.shorthand();
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/repository.ts b/packages/server/src/git/repository.ts
new file mode 100644
index 0000000..ac0927a
--- /dev/null
+++ b/packages/server/src/git/repository.ts
@@ -0,0 +1,105 @@
+import { Object as NodeGitObject, Oid as NodeGitOid, Repository as NodeGitRepository, Revwalk as NodeGitRevwalk } from "nodegit";
+import { Request, connect } from "./http";
+import { basename, dirname } from "path";
+import { getDirectory, getFile } from "./misc";
+import { Branch } from "./branch";
+import { Commit } from "./commit";
+import { FastifyReply } from "fastify";
+import { Tag } from "./tag";
+import { Tree } from "./tree";
+
+function getFullRepositoryName(repo_name: string) {
+ return repo_name.endsWith(".git") ? repo_name : `${repo_name}.git`;
+}
+
+type RepositoryName = {
+ short: string,
+ full: string,
+}
+
+type RepositoryConstructorData = {
+ description: string | null,
+ owner: string | null
+}
+
+export class Repository {
+ private _ng_repository: NodeGitRepository;
+
+ public name: RepositoryName;
+ public base_dir: string;
+ public description: string | null;
+ public owner: string | null;
+
+ constructor(repository: NodeGitRepository, data: RepositoryConstructorData) {
+ this._ng_repository = repository;
+ this.name = {
+ short: basename(repository.path()).slice(0, -4),
+ full: basename(repository.path())
+ };
+ this.base_dir = dirname(repository.path());
+ this.description = data.description;
+ this.owner = data.owner;
+ }
+
+ async commits(): Promise<Commit[]> {
+ const walker = NodeGitRevwalk.create(this._ng_repository);
+ walker.pushHead();
+
+ return Promise.all((await walker.getCommitsUntil(() => true)).map(commit => new Commit(this, commit)));
+ }
+
+ async tree(): Promise<Tree> {
+ const master_commit = await this._ng_repository.getMasterCommit();
+ const tree = await master_commit.getTree();
+ return new Tree(this, tree);
+ }
+
+ lookupExists(id: string): Promise<boolean> {
+ return NodeGitObject.lookup(this._ng_repository, NodeGitOid.fromString(id), NodeGitObject.TYPE.ANY)
+ .then(() => true)
+ .catch(() => false);
+ }
+
+ async branches(): Promise<Branch[]> {
+ const references = await this._ng_repository.getReferences();
+ return references.filter(ref => ref.isBranch()).map(branch => new Branch(this, branch));
+ }
+
+ async tags(): Promise<Tag[]> {
+ const references = await this._ng_repository.getReferences();
+ return references.filter(ref => ref.isTag()).map(tag => new Tag(this, tag));
+ }
+
+ async latestCommit(): Promise<Commit> {
+ return new Commit(this, await this._ng_repository.getMasterCommit());
+ }
+
+ HTTPconnect(req: Request, reply: FastifyReply): void {
+ connect(this, req, reply);
+ }
+
+ get nodegitRepository(): NodeGitRepository {
+ return this._ng_repository;
+ }
+
+ static async open(base_dir: string, repository: string): Promise<Repository> {
+ const ng_repository = await NodeGitRepository.openBare(`${base_dir}/${getFullRepositoryName(repository)}`);
+
+ return new Repository(ng_repository, {
+ description: await getFile(base_dir, getFullRepositoryName(repository), "description"),
+ owner: await getFile(base_dir, getFullRepositoryName(repository), "owner")
+ });
+ }
+
+ static async openAll(base_dir: string): Promise<Repository[] | null> {
+ const dir_content = await getDirectory(base_dir);
+
+ if(dir_content.length === 0) {
+ return null;
+ }
+
+ const repositories = dir_content.filter(dir_entry => dir_entry.endsWith(".git"));
+
+ return Promise.all(repositories.map(repository => this.open(base_dir, repository)));
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/tag.ts b/packages/server/src/git/tag.ts
new file mode 100644
index 0000000..ffda9c4
--- /dev/null
+++ b/packages/server/src/git/tag.ts
@@ -0,0 +1,80 @@
+import { Object as NodeGitObject, Tag as NodeGitTag } from "nodegit";
+import { Pack, pack } from "tar-stream";
+import { Author } from "./misc";
+import { Blob } from "./blob";
+import { Commit } from "./commit";
+import { FastifyReply } from "fastify";
+import { Reference } from "./reference";
+import { Repository } from "./repository";
+import { Tree } from "./tree";
+import { TreeEntry } from "./tree_entry";
+import { createGzip } from "zlib";
+import { pipeline } from "stream";
+
+async function addArchiveEntries(entries: TreeEntry[], repository: string, archive: Pack) {
+ for(const tree_entry of entries) {
+ const peeled = (await tree_entry.peel());
+
+ if(tree_entry.type === "blob") {
+ if(peeled instanceof Blob) {
+ archive.entry({ name: `${repository}/${tree_entry.path}` }, await peeled.content());
+ }
+ }
+ else if(peeled instanceof Tree) {
+ addArchiveEntries(peeled.entries(), repository, archive);
+ }
+ }
+}
+
+export class Tag extends Reference {
+ async author(): Promise<Author> {
+ const tagger = (await NodeGitTag.lookup(this._owner.nodegitRepository, this._ng_reference.target())).tagger();
+ return {
+ name: tagger.name(),
+ email: tagger.email()
+ };
+ }
+
+ async date(): Promise<number> {
+ return (await NodeGitTag.lookup(this._owner.nodegitRepository, this._ng_reference.target())).tagger().when()
+ .time();
+ }
+
+ async downloadTarball(reply: FastifyReply): Promise<void> {
+ const commit = await Commit.lookup(this._owner, (await this._ng_reference.peel(NodeGitObject.TYPE.COMMIT)).id());
+ const tree = await commit.tree();
+
+ const archive = pack();
+ const gzip = createGzip();
+
+ reply.raw.writeHead(200, {
+ "Content-Encoding": "gzip",
+ "Content-Type": "application/gzip",
+ "Content-Disposition": `attachment; filename="${this._owner.name.short}-${this._owner.name.short}.tar.gz"`
+ });
+
+ pipeline(archive, gzip, reply.raw, () => reply.raw.end());
+
+ gzip.on("close", () => reply.raw.end());
+ gzip.on("error", () => reply.raw.end());
+ archive.on("error", () => reply.raw.end());
+
+ addArchiveEntries(await tree.entries(), this._owner.name.short, archive)
+ .then(() => archive.finalize())
+ .catch(() => {
+ archive.finalize();
+ reply.raw.end();
+ });
+ }
+
+ static async lookup(owner: Repository, tag: string): Promise<Tag | null> {
+ const reference = await owner.nodegitRepository.getReference(tag).catch(err => {
+ if(err.errno === -3) {
+ return null;
+ }
+
+ throw(err);
+ });
+ return reference ? new Tag(owner, reference) : null;
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/tree.ts b/packages/server/src/git/tree.ts
new file mode 100644
index 0000000..b9bd142
--- /dev/null
+++ b/packages/server/src/git/tree.ts
@@ -0,0 +1,39 @@
+import { Blob } from "./blob";
+import { Tree as NodeGitTree } from "nodegit";
+import { Repository } from "./repository";
+import { TreeEntry } from "./tree_entry";
+
+export class Tree {
+ private _ng_tree: NodeGitTree;
+ private _owner: Repository;
+
+ constructor(owner: Repository, tree: NodeGitTree) {
+ this._ng_tree = tree;
+ this._owner = owner;
+ }
+
+ entries(): TreeEntry[] {
+ return this._ng_tree.entries().map(entry => new TreeEntry(this._owner, entry));
+ }
+
+ async find(path: string): Promise<Blob | Tree | null> {
+ const entry = await this._ng_tree.getEntry(path).catch(err => {
+ if(err.errno === -3) {
+ return null;
+ }
+ throw(err);
+ });
+
+ if(!entry) {
+ return null;
+ }
+
+ return entry.isBlob() ? new Blob(entry) : new Tree(this._owner, await entry.getTree());
+ }
+
+ findExists(path: string): Promise<boolean> {
+ return this._ng_tree.getEntry(path)
+ .then(() => true)
+ .catch(() => false);
+ }
+} \ No newline at end of file
diff --git a/packages/server/src/git/tree_entry.ts b/packages/server/src/git/tree_entry.ts
new file mode 100644
index 0000000..3bcf10e
--- /dev/null
+++ b/packages/server/src/git/tree_entry.ts
@@ -0,0 +1,40 @@
+import { Blob } from "./blob";
+import { Commit } from "./commit";
+import { TreeEntry as NodeGitTreeEntry } from "nodegit";
+import { Repository } from "./repository";
+import { Tree } from "./tree";
+import { dirname } from "path";
+import { findAsync } from "./misc";
+
+export class TreeEntry {
+ private _ng_tree_entry: NodeGitTreeEntry;
+ private _owner: Repository;
+
+ public path: string;
+ public type: "blob" | "tree";
+
+ constructor(owner: Repository, entry: NodeGitTreeEntry) {
+ this._ng_tree_entry = entry;
+ this._owner = owner;
+
+ this.path = entry.path();
+ this.type = entry.isBlob() ? "blob" : "tree";
+ }
+
+ async latestCommit(): Promise<Commit> {
+ const commits = await this._owner.commits();
+
+ return findAsync(commits, async commit => {
+ const diff = await commit.diff();
+ const patches = await diff.getPatches();
+
+ return Boolean(this.type === "blob"
+ ? patches.find(patch => patch.to === this.path)
+ : patches.find(patch => dirname(patch.to).startsWith(this.path)));
+ });
+ }
+
+ async peel(): Promise<Blob | Tree> {
+ return this.type === "blob" ? new Blob(this._ng_tree_entry) : new Tree(this._owner, await this._ng_tree_entry.getTree());
+ }
+} \ No newline at end of file