@timothy-spaceman/multitrack-vcs
Version:
Version Control System for musicians
501 lines (420 loc) • 17.1 kB
text/typescript
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`)
}
}
}