UNPKG

@timothy-spaceman/multitrack-vcs

Version:

Version Control System for musicians

501 lines (420 loc) 17.1 kB
import {IStorageProvider} from "../storage/index.js" import path from "node:path" import {randomUUID} from "crypto" import {BranchList, Commit, CommitList, Difference, Item, ItemChange, ItemList} from "./types.js" export const PROJECT_DIR = ".mvcs" export const CONTENT_DIR = "contents" const CONTENT_DUMMY = "DUMMY" export type ProjectDump = { id: string authorId: string title: string description?: string branches: { [k: string]: string } defaultBranch?: string currentBranch?: string commits: { [k: string]: Commit } rootCommitId?: string currentCommitId?: string items: { [k: string]: Item } } export type ProjectDumpKey = keyof ProjectDump export const PROJECT_DUMP_KEYS: ProjectDumpKey[] = [ "id", "authorId", "title", "description", "branches", "defaultBranch", "currentBranch", "commits", "rootCommitId", "currentCommitId", "items", ] export class Project { sp: IStorageProvider id = "EMPTY_ID" authorId = "EMPTY_AUTHOR_ID" title = "EMPTY_TITLE" description?: string workingDir = "EMPTY_WORKING_DIR" branches: BranchList = new Map<string, string>() defaultBranch?: string currentBranch?: string commits: CommitList = new Map<string, Commit>() rootCommitId?: string currentCommitId?: string items: ItemList = new Map<string, Item>() private constructor(sp: IStorageProvider, workingDir: string, authorId?: string, title?: string, description?: string) { 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: string, sp: IStorageProvider): Promise<Project> { const project = new Project(sp, dirPath) await project.load() return project } static async create( sp: IStorageProvider, workingDir: string, authorId: string, title: string, description?: string ): Promise<Project> { const project = new Project(sp, workingDir, authorId, title, description) await project.save() return project } toJSON(): ProjectDump { 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: ProjectDump) { 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 as any)[key] = dump[key] } for (const mapName of mapsToImport) { (this as any)[mapName] = new Map(Object.entries(dump[mapName] as any)) } } async load() { const filePath = this.getProjectFilePath(); const projectFile = await this.sp.readFile(filePath) const projectDump: 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(): string { return path.join(this.workingDir, PROJECT_DIR, "project.json"); } getContentPath(content: string) { return path.join(this.workingDir, PROJECT_DIR, CONTENT_DIR, content) } async addContent(sourcePath: string): Promise<string> { 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: string): string { 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(): Commit | undefined { let currentCommitId: string | undefined = 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: string): Promise<ItemList> { 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: ItemList = new Map<string, Item>() 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: Commit): Commit[] { const commitChain: Commit[] = [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: Commit, resultItems: ItemList): void { 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(): Promise<string[]> { const currentFilesAndDirs = await this.sp.readDirDeep(this.workingDir, [`${PROJECT_DIR}/**`]) const currentFiles: string[] = [] await Promise.all(currentFilesAndDirs.map(async p => { if (await this.sp.isFile(p)) { currentFiles.push(p) } })) return currentFiles; } async filesToItems(files: string[]): Promise<ItemList> { const createItem = (content: string, path: string, contentHash?: string): Item => ({ id: randomUUID(), content, path, contentHash }) const items: ItemList = new Map<string, Item>() 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: string[] | undefined = undefined): Promise<Difference> { const lastItems = this.currentCommitId ? await this.getCommitItems(this.currentCommitId) : new Map<string, Item>() 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: ItemList, afterItems: ItemList): Promise<Difference> { 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: Difference = { added: new Map<string, Item>(), removed: new Map<string, Item>(), changed: new Map<string, Item>(), unchanged: new Map<string, Item>() } 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: string[], authorId: string, title: string, description: string = ""): Promise<Commit> { this.ensureValidBranchForCommit() const {added, removed, changed} = await this.status(files) const changes: ItemChange[] = [] 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: Commit = { 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(): void { 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: string) { commitId = this.matchCommitId(commitId) const commitItems = await this.getCommitItems(commitId) const currentFiles: string[] = 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: string) { 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: string) { 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: string) { 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: string, newName: string) { 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: string) { this.throwIfBranchNotFound(branchName) this.defaultBranch = branchName } throwIfBranchNotFound(branchName: string) { if (!this.branches.has(branchName)) { throw new Error(`Branch ${branchName} not found`) } } throwIfBranchFound(branchName: string) { if (this.branches.has(branchName)) { throw new Error(`Branch ${branchName} already exists`) } } }