@timothy-spaceman/multitrack-vcs
Version:
Version Control System for musicians
402 lines • 15.1 kB
JavaScript
import path from "node:path";
import { randomUUID } from "crypto";
export const PROJECT_DIR = ".mvcs";
export const CONTENT_DIR = "contents";
const CONTENT_DUMMY = "DUMMY";
export const PROJECT_DUMP_KEYS = [
"id",
"authorId",
"title",
"description",
"branches",
"defaultBranch",
"currentBranch",
"commits",
"rootCommitId",
"currentCommitId",
"items",
];
export class Project {
sp;
id = "EMPTY_ID";
authorId = "EMPTY_AUTHOR_ID";
title = "EMPTY_TITLE";
description;
workingDir = "EMPTY_WORKING_DIR";
branches = new Map();
defaultBranch;
currentBranch;
commits = new Map();
rootCommitId;
currentCommitId;
items = new Map();
constructor(sp, workingDir, authorId, title, description) {
this.sp = sp;
this.workingDir = path.resolve(workingDir);
if (authorId && title) {
this.id = randomUUID();
this.authorId = authorId;
this.title = title;
this.description = description;
}
}
static async fromFile(dirPath, sp) {
const project = new Project(sp, dirPath);
await project.load();
return project;
}
static async create(sp, workingDir, authorId, title, description) {
const project = new Project(sp, workingDir, authorId, title, description);
await project.save();
return project;
}
toJSON() {
return {
id: this.id,
authorId: this.authorId,
title: this.title,
description: this.description,
branches: Object.fromEntries(this.branches),
defaultBranch: this.defaultBranch,
currentBranch: this.currentBranch,
commits: Object.fromEntries(this.commits),
rootCommitId: this.rootCommitId,
currentCommitId: this.currentCommitId,
items: Object.fromEntries(this.items)
};
}
fromJSON(dump) {
const keysToImport = PROJECT_DUMP_KEYS.filter(k => k in dump);
const mapNames = ["branches", "commits", "items"];
const mapsToImport = keysToImport.filter(k => mapNames.includes(k));
for (const key of keysToImport.filter(k => !mapsToImport.includes(k))) {
this[key] = dump[key];
}
for (const mapName of mapsToImport) {
this[mapName] = new Map(Object.entries(dump[mapName]));
}
}
async load() {
const filePath = this.getProjectFilePath();
const projectFile = await this.sp.readFile(filePath);
const projectDump = JSON.parse((await projectFile.readData()).toString());
this.fromJSON(projectDump);
}
async save() {
const filePath = this.getProjectFilePath();
if (!await this.sp.exists(filePath)) {
await this.sp.createFile(filePath, Buffer.from("{}"));
}
const file = await this.sp.readFile(filePath);
await file.writeData(Buffer.from(JSON.stringify(this.toJSON())));
}
getProjectFilePath() {
return path.join(this.workingDir, PROJECT_DIR, "project.json");
}
getContentPath(content) {
return path.join(this.workingDir, PROJECT_DIR, CONTENT_DIR, content);
}
async addContent(sourcePath) {
const file = await this.sp.readFile(sourcePath);
const hash = await file.getDataHash();
for (const item of this.items.values()) {
const candidatePath = this.getContentPath(item.content);
const candidateHash = await (await this.sp.readFile(candidatePath)).getDataHash();
if (candidateHash === hash) {
return item.content;
}
}
const contentPath = randomUUID();
await this.sp.copyFile(sourcePath, this.getContentPath(contentPath));
return contentPath;
}
matchCommitId(idPart) {
if (idPart.length < 7) {
throw new Error("You must specify at least 7 symbols of ID");
}
const candidates = [...this.commits.keys()].filter(id => id.startsWith(idPart));
if (candidates.length === 0) {
throw new Error(`No ID candidate for ${idPart} found`);
}
if (candidates.length > 1) {
throw new Error(`Multiple ID candidates were found for ${idPart}`);
}
return candidates.pop();
}
getCurrentCommit() {
let currentCommitId = this.currentCommitId;
if (!currentCommitId && this.commits.size > 0) {
throw new Error("No current commit");
}
if (currentCommitId && !this.commits.has(currentCommitId)) {
throw new Error("Current commit not found in the Commit List");
}
return currentCommitId ? this.commits.get(currentCommitId) : undefined;
}
async getCommitItems(commitId) {
commitId = this.matchCommitId(commitId);
const targetCommit = this.commits.get(commitId);
if (!targetCommit) {
throw new Error(`Commit ${commitId} not found in the Commit List`);
}
const commitChain = this.buildCommitChain(targetCommit);
const commitItems = new Map();
for (const commit of commitChain) {
this.applyCommitChanges(commit, commitItems);
}
for (const item of commitItems.values()) {
const file = await this.sp.readFile(this.getContentPath(item.content));
item.contentHash = await file.getDataHash();
}
return commitItems;
}
buildCommitChain(targetCommit) {
const commitChain = [targetCommit];
while (commitChain[0] && commitChain[0].id !== this.rootCommitId) {
const commit = commitChain[0];
if (!commit.parent) {
break;
}
const parentCommit = this.commits.get(commit.parent);
if (!parentCommit) {
throw new Error(`Parent commit ${commit.parent} not found in the Commit List`);
}
commitChain.unshift(parentCommit);
}
return commitChain;
}
applyCommitChanges(commit, resultItems) {
for (const change of commit.changes) {
if (change.to) {
const itemId = change.to;
if (!this.items.has(itemId)) {
throw new Error(`Item ${itemId} not found in the Items List`);
}
resultItems.set(itemId, this.items.get(itemId));
}
if (change.from) {
resultItems.delete(change.from);
}
}
}
async getCurrentFiles() {
const currentFilesAndDirs = await this.sp.readDirDeep(this.workingDir, [`${PROJECT_DIR}/**`]);
const currentFiles = [];
await Promise.all(currentFilesAndDirs.map(async (p) => {
if (await this.sp.isFile(p)) {
currentFiles.push(p);
}
}));
return currentFiles;
}
async filesToItems(files) {
const createItem = (content, path, contentHash) => ({
id: randomUUID(),
content,
path,
contentHash
});
const items = new Map();
for (const filePath of files) {
if (filePath.includes(PROJECT_DIR))
continue;
const file = await this.sp.readFile(filePath);
const item = createItem(CONTENT_DUMMY, filePath, await file.getDataHash());
items.set(item.id, item);
}
return items;
}
async status(files = undefined) {
const lastItems = this.currentCommitId ? await this.getCommitItems(this.currentCommitId) : new Map();
if (!files) {
files = await this.getCurrentFiles();
}
else {
for (const item of lastItems.values()) {
if (!files.includes(item.path)) {
lastItems.delete(item.id);
}
}
}
const existingFiles = [];
for (const file of files) {
if (await this.sp.isFile(file)) {
existingFiles.push(file);
}
}
const fileItems = await this.filesToItems(existingFiles);
return await this.diff(lastItems, fileItems);
}
async diff(beforeItems, afterItems) {
const before = new Map(Array.from(beforeItems.entries()).map(([_, i]) => [i.path, i]));
const after = new Map(Array.from(afterItems.entries()).map(([_, i]) => [i.path, i]));
const paths = new Set([...before.keys(), ...after.keys()]);
const result = {
added: new Map(),
removed: new Map(),
changed: new Map(),
unchanged: new Map()
};
for (const filePath of paths) {
const inBefore = before.has(filePath), inAfter = after.has(filePath);
if (!inBefore && inAfter) {
result.added.set(filePath, after.get(filePath));
}
else if (inBefore && !inAfter) {
result.removed.set(filePath, before.get(filePath));
}
else if (inBefore && inAfter) {
const beforeItem = before.get(filePath);
const afterItem = after.get(filePath);
if (beforeItem.contentHash !== afterItem.contentHash) {
result.changed.set(filePath, afterItem);
}
else {
result.unchanged.set(filePath, beforeItem);
}
}
}
return result;
}
async commit(files, authorId, title, description = "") {
this.ensureValidBranchForCommit();
const { added, removed, changed } = await this.status(files);
const changes = [];
for (const item of removed.values()) {
changes.push({ from: item.id });
}
for (const item of added.values()) {
if (item.content === CONTENT_DUMMY) {
item.content = await this.addContent(item.path);
}
this.items.set(item.id, item);
changes.push({ to: item.id });
}
const lastCommit = this.currentCommitId ? await this.getCommitItems(this.currentCommitId) : null;
const lastItems = lastCommit ? Array.from(lastCommit.values()) : [];
for (const item of changed.values()) {
if (item.content === CONTENT_DUMMY) {
item.content = await this.addContent(item.path);
}
this.items.set(item.id, item);
const prevItem = lastItems.find(i => i.path === item.path);
changes.push({ from: prevItem.id, to: item.id });
}
const newCommit = {
id: randomUUID(),
parent: this.getCurrentCommit()?.id,
children: [],
authorId,
title,
description,
date: (new Date()).toISOString(),
changes
};
if (this.commits.size === 0) {
this.rootCommitId = newCommit.id;
this.currentBranch = this.currentBranch ?? "main";
this.defaultBranch = this.currentBranch;
}
this.commits.set(newCommit.id, newCommit);
this.branches.set(this.currentBranch, newCommit.id);
this.currentCommitId = newCommit.id;
return newCommit;
}
ensureValidBranchForCommit() {
if (this.commits.size === 0)
return;
const err = new Error("Cannot commit when not at the branch");
if (!this.currentBranch) {
throw err;
}
if (!this.branches.has(this.currentBranch)) {
throw err;
}
if (this.branches.get(this.currentBranch) !== this.currentCommitId) {
throw err;
}
}
async checkout(commitId) {
commitId = this.matchCommitId(commitId);
const commitItems = await this.getCommitItems(commitId);
const currentFiles = await this.getCurrentFiles();
const commitFiles = [...commitItems.values()].map(i => i.path);
for (const filePath of currentFiles) {
if (!commitFiles.includes(filePath)) {
await this.sp.deleteFileOrDir(filePath);
}
}
for (const item of commitItems.values()) {
const itemContentPath = this.getContentPath(item.content);
const itemContent = await this.sp.readFile(itemContentPath);
const itemHash = await itemContent.getDataHash();
if (currentFiles.includes(item.path)) {
const currentContent = await this.sp.readFile(item.path);
const currentHash = await currentContent.getDataHash();
if (itemHash === currentHash)
continue;
}
await this.sp.copyFile(itemContentPath, item.path);
}
this.currentCommitId = commitId;
}
async checkoutBranch(branchName) {
this.throwIfBranchNotFound(branchName);
const branchCommit = this.branches.get(branchName);
if (!this.commits.has(branchCommit)) {
throw new Error(`Commit ${branchCommit} (branch ${branchName}) not found`);
}
await this.checkout(branchCommit);
this.currentBranch = branchName;
}
createBranch(branchName) {
if (!this.currentCommitId && this.commits.size > 0) {
throw new Error("No current commit");
}
this.throwIfBranchFound(branchName);
this.branches.set(branchName, this.currentCommitId);
if (!this.defaultBranch) {
this.defaultBranch = branchName;
}
}
deleteBranch(branchName) {
this.throwIfBranchNotFound(branchName);
if (this.branches.size === 1) {
throw new Error(`Cannot delete the only branch in the project`);
}
if (this.currentBranch === branchName) {
throw new Error(`Cannot delete the branch you"re currently on`);
}
if (this.defaultBranch === branchName) {
throw new Error(`Cannot delete default branch`);
}
this.branches.delete(branchName);
}
renameBranch(oldName, newName) {
this.throwIfBranchNotFound(oldName);
this.throwIfBranchFound(newName);
this.branches.set(newName, this.branches.get(oldName));
if (this.currentBranch === oldName) {
this.currentBranch = newName;
}
if (this.defaultBranch === oldName) {
this.defaultBranch = newName;
}
this.branches.delete(oldName);
}
setDefaultBranch(branchName) {
this.throwIfBranchNotFound(branchName);
this.defaultBranch = branchName;
}
throwIfBranchNotFound(branchName) {
if (!this.branches.has(branchName)) {
throw new Error(`Branch ${branchName} not found`);
}
}
throwIfBranchFound(branchName) {
if (this.branches.has(branchName)) {
throw new Error(`Branch ${branchName} already exists`);
}
}
}
//# sourceMappingURL=project.js.map