UNPKG

@timothy-spaceman/multitrack-vcs

Version:

Version Control System for musicians

402 lines 15.1 kB
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