diff --git a/src/commands/add.ts b/src/commands/add.ts index 13146a4..02daed9 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,4 +1,5 @@ -import { readFile } from "node:fs/promises"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; import { GIT_INDEX } from "../constants.js"; import { coloredLog } from "../functions/colored-log.js"; @@ -7,6 +8,36 @@ import { isValidPath } from "../functions/is-valid-path.js"; import { BlobObject } from "../models/blob-object.js"; import { GitIndex } from "../models/git-index.js"; +const processPath = async ( + filePath: string, + gitIndex: GitIndex, +): Promise => { + const stats = await stat(filePath); + + if (stats.isDirectory()) { + if (filePath === ".git") { + return; + } + const entries = await readdir(filePath); + for (const entry of entries) { + await processPath(join(filePath, entry), gitIndex); + } + } else if (stats.isFile()) { + const content = await readFile(filePath); + const blobObject = new BlobObject(content); + const hash = await blobObject.dumpBlobObject(); + + if (!gitIndex.checkDuplicate(filePath, hash)) { + await gitIndex.pushEntry(filePath, hash); + + coloredLog({ + text: `added '${filePath}'`, + color: "green", + }); + } + } +}; + export const add = async (options: Array): Promise => { const filePath = options[0]; @@ -21,7 +52,7 @@ export const add = async (options: Array): Promise => { } //ファイル名が条件を満たしていない場合の処理 - if (!isValidPath(filePath)) { + if (filePath !== "." && !isValidPath(filePath)) { coloredLog({ text: `fatal: invalid path '${filePath}'`, color: "red", @@ -38,20 +69,10 @@ export const add = async (options: Array): Promise => { return; } - // TODO: ディレクトリを指定した際などに複数回pushEntryする - const content = await readFile(filePath); - - const blobObject = new BlobObject(content); - const hash = await blobObject.dumpBlobObject(); - const gitIndex = new GitIndex(GIT_INDEX); await gitIndex.initialize(); - if (gitIndex.checkDuplicate(filePath, hash)) { - console.log("Nothing has changed."); - return; - } + await processPath(filePath, gitIndex); - await gitIndex.pushEntry(filePath, hash); await gitIndex.dumpIndex(); }; diff --git a/src/commands/commit.ts b/src/commands/commit.ts new file mode 100644 index 0000000..91891d6 --- /dev/null +++ b/src/commands/commit.ts @@ -0,0 +1,90 @@ +import { readFile, writeFile } from "fs/promises"; +import { join } from "path"; + +import { COMMIT_OPTIONS, GIT_DIR, GIT_HEAD, GIT_INDEX } from "../constants.js"; +import { coloredLog } from "../functions/colored-log.js"; +import { extractHeadHash } from "../functions/extract-head-hash.js"; +import { Commit } from "../models/commit.js"; +import { GitIndex } from "../models/git-index.js"; +import { TreeObject } from "../models/tree-object.js"; +import { add } from "./add.js"; + +export const commit = async (options: Array): Promise => { + //ファイル名指定でコミットはできない仕様とする + const option = options[0]; + const message = options[1]; + + //optionもしくはmessageが存在しない場合 + if (!(option && message)) { + coloredLog({ + text: "invalid command", + color: "red", + }); + return; + } + + //optionがあらかじめ用意したものと一致しない場合 + if (!COMMIT_OPTIONS.some((OPTION) => OPTION.name === option)) { + coloredLog({ + text: `error: unknown switch '${option}'\n`, + color: "red", + }); + console.log("Commit options:"); + COMMIT_OPTIONS.forEach((option) => { + console.log(` ${option.name} ${option.description}\n`); + }); + } + + //optionが -amだった場合は全てのファイルをaddする + if (option === "-am") { + await add(["."]); + } + + //indexからファイルパスとblobオブジェクトのhashを取得 + const gitIndex = new GitIndex(GIT_INDEX); + await gitIndex.initialize(); + const fileData = gitIndex.getFileData(); + + const treeObject = new TreeObject(fileData); + const rootTreeHash = await treeObject.dumpAllTrees(); + + //ファイルがステージングされていない場合 + if (!rootTreeHash) { + console.log( + 'nothing added to commit but untracked files present (use "git add" to track)', + ); + return; + } + + const parentHash = await extractHeadHash(); + + //コミットに含める情報をセットしてdump + const commit = new Commit(); + commit.setCommit({ + tree: rootTreeHash, + parent: parentHash, + author: "yamada taro", + email: "yamada@gmail.com", + message: message, + }); + const commitHash = await commit.dumpCommit(); + + //headを最新のコミットhashに更新しておく + const headContent = await readFile(GIT_HEAD, "utf8"); + + if (headContent.startsWith("ref: ")) { + //今回の実装ではmainブランチのみの実装とする + const branchPath = "main"; + + const branchFilePath = join(GIT_DIR, "refs/heads", branchPath); + await writeFile(branchFilePath, commitHash, "utf8"); + + console.log(`Updated ${branchPath} branch with commit hash: ${commitHash}`); + } else { + await writeFile(GIT_HEAD, commitHash, "utf8"); + + console.log( + `Updated ${parentHash ?? ""} branch with commit hash: ${commitHash}`, + ); + } +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index 634fcae..e13b794 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,8 +1,10 @@ import { add } from "./add.js"; +import { commit } from "./commit.js"; import { log } from "./log.js"; export const validCommand = { add: add, + commit: commit, log: log, help: () => { console.log("Available commands:"); diff --git a/src/commands/log.ts b/src/commands/log.ts index 8340dda..82fa9c2 100644 --- a/src/commands/log.ts +++ b/src/commands/log.ts @@ -1,40 +1,13 @@ -import { readFile } from "node:fs/promises"; -import { join } from "path"; - -import { GIT_DIR } from "../constants.js"; import { coloredLog } from "../functions/colored-log.js"; -import { exists } from "../functions/exists.js"; +import { extractHeadHash } from "../functions/extract-head-hash.js"; import { Commit, CommitFieldType } from "../models/commit.js"; -const extractHeadHash = async (): Promise => { - const headPath = join(GIT_DIR, "HEAD"); - - if (!(await exists(headPath))) { - return; - } - - const headText = await readFile(headPath).then((head) => - head.toString("utf-8"), - ); - - const refPrefix = "ref: "; - //ブランチ名かコミットハッシュのどちらをHEADに持つかを識別して出し分ける - if (headText.startsWith(refPrefix)) { - return await readFile( - join(GIT_DIR, headText.slice(refPrefix.length)).trim(), - "utf-8", - ).then((path) => path.trim()); - } else { - return headText.trim(); - } -}; - const getCommitHistory = async ( hash: string, history: Array = [], ): Promise> => { const commit = new Commit(); - await commit.setCommit(hash); + await commit.setCommitHash(hash); const commitData = commit.getCommit(); const currentHistory = [...history, commitData]; @@ -54,8 +27,7 @@ export const displayCommitHistory = ( text: `commit: ${commit.hash}`, color: "yellow", }); - console.log(`Author: ${commit.author}`); - console.log(`Committer: ${commit.committer}\n`); + console.log(`author: ${commit.author}\n`); console.log(` ${commit.message}\n`); }); }; diff --git a/src/constants.ts b/src/constants.ts index 66acc8c..d1858bb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,3 +5,15 @@ export const CWD = process.cwd(); export const GIT_DIR = join(process.cwd(), ".git"); export const GIT_OBJECTS = join(GIT_DIR, "objects"); export const GIT_INDEX = join(GIT_DIR, "index"); +export const GIT_HEAD = join(GIT_DIR, "HEAD"); + +export const COMMIT_OPTIONS = [ + { + name: "-m", + description: "commit message", + }, + { + name: "-am", + description: "add all files and commit message", + }, +]; diff --git a/src/functions/colored-log.ts b/src/functions/colored-log.ts index 87b75cf..510b703 100644 --- a/src/functions/colored-log.ts +++ b/src/functions/colored-log.ts @@ -2,6 +2,7 @@ const colors = { //https://qiita.com/shuhei/items/a61b4324fd5dbc1af79b yellow: "\u001b[33m", red: "\u001b[31m", + green: "\u001b[32m", }; export const coloredLog = ({ diff --git a/src/functions/extract-head-hash.ts b/src/functions/extract-head-hash.ts new file mode 100644 index 0000000..f038739 --- /dev/null +++ b/src/functions/extract-head-hash.ts @@ -0,0 +1,27 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; + +import { GIT_DIR } from "../constants.js"; +import { exists } from "./exists.js"; + +export const extractHeadHash = async (): Promise => { + const headPath = join(GIT_DIR, "HEAD"); + + if (!(await exists(headPath))) { + return; + } + + const headText = await readFile(headPath).then((head) => + head.toString("utf-8"), + ); + + const refPrefix = "ref: "; + //ブランチ名かコミットハッシュのどちらをHEADに持つかを識別して出し分ける + if (headText.startsWith(refPrefix)) { + const branchPath = join(GIT_DIR, headText.slice(refPrefix.length)).trim(); + if (!(await exists(branchPath))) return; + return await readFile(branchPath, "utf-8").then((path) => path.trim()); + } else { + return headText.trim(); + } +}; diff --git a/src/models/commit.ts b/src/models/commit.ts index 0cd4bc8..ae5f7fd 100644 --- a/src/models/commit.ts +++ b/src/models/commit.ts @@ -1,13 +1,18 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { deflateSync } from "node:zlib"; import { GIT_OBJECTS } from "../constants.js"; +import { exists } from "../functions/exists.js"; +import { generateObjectPath } from "../functions/path.js"; import { readGitObject } from "../functions/read-git-object.js"; export interface CommitFieldType { tree: string; parent?: string; author: string; - committer: string; + email: string; hash: string; message: string; } @@ -16,7 +21,7 @@ export class Commit { tree: string; parent?: string; author: string; - committer: string; + email: string; hash: string; message: string; @@ -24,22 +29,30 @@ export class Commit { this.tree = ""; this.author = ""; this.message = ""; - this.committer = ""; + this.email = ""; this.hash = ""; } - public setCommit = async (hash: string): Promise => { + public setCommitHash = async (hash: string): Promise => { const content = await this.getCommitContent(hash); this.parseCommit(hash, content); }; + public setCommit = (commit: Omit): void => { + this.tree = commit.tree; + this.author = commit.author; + this.message = commit.message; + this.email = commit.email; + this.parent = commit.parent; + }; + public getCommit = (): CommitFieldType => { return { tree: this.tree, author: this.author, message: this.message, - committer: this.committer, + email: this.email, hash: this.hash, }; }; @@ -82,13 +95,63 @@ export class Commit { case "parent": this.parent = value; break; - case "author": - this.author = value; - break; - case "committer": - this.committer = value; + case "author": { + const authorRegex = /^(.*?)\s+<([^>]+)>\s+(\d+\s+[+-]\d{4})$/; + const authorMatch = authorRegex.exec(value); + if (authorMatch) { + this.author = authorMatch[1] ? authorMatch[1].trim() : ""; + this.email = authorMatch[2] ?? ""; + } else { + this.author = value; // Keep the original format if parsing fails + } break; + } } } }; + + private formatCommitContent(): string { + let commitContent = `tree ${this.tree}\n`; + + if (this.parent) { + commitContent += `parent ${this.parent}\n`; + } + + commitContent += `author ${this.author} ${this.email} ${Math.floor(Date.now() / 1000).toString()} +0900\n\n`; + commitContent += this.message; + + return commitContent; + } + + // Method to dump the commit object to the .git/objects directory + public async dumpCommit(): Promise { + const content = this.formatCommitContent(); + + // Create the buffer for the commit object + const contentBuffer = Buffer.from(content); + const headerBuffer = Buffer.from( + `commit ${contentBuffer.length.toString()}\0`, + ); + const commitBuffer = Buffer.concat([ + Uint8Array.from(headerBuffer), + Uint8Array.from(contentBuffer), + ]); + + // Create the SHA-1 hash of the commit object + const commitHash = createHash("sha1") + .update(Uint8Array.from(commitBuffer)) + .digest("hex"); + + // Generate the object path and write the compressed content + const { dirPath, filePath } = generateObjectPath(commitHash); + + if (!(await exists(dirPath))) { + await mkdir(dirPath, { recursive: true }); + } + + const compressedContent = deflateSync(Uint8Array.from(commitBuffer)); + await writeFile(filePath, Uint8Array.from(compressedContent)); + + return commitHash; + } } diff --git a/src/models/git-index.ts b/src/models/git-index.ts index 33f4792..0bca7fa 100644 --- a/src/models/git-index.ts +++ b/src/models/git-index.ts @@ -36,8 +36,16 @@ export class GitIndex { this.entries = []; }; - public getFilePaths = (): Array => { - return this.entries.map((entry) => entry.filePath); + public getFileData = (): Array<{ + filePath: string; + hash: string; + }> => { + return this.entries.map((entry) => { + return { + filePath: entry.filePath, + hash: entry.hash, + }; + }); }; public checkDuplicate = (filePath: string, hash: string): boolean => { diff --git a/src/models/tree-object.ts b/src/models/tree-object.ts new file mode 100644 index 0000000..c005676 --- /dev/null +++ b/src/models/tree-object.ts @@ -0,0 +1,157 @@ +import { createHash } from "crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { deflateSync } from "zlib"; + +import { exists } from "../functions/exists.js"; +import { generateObjectPath } from "../functions/path.js"; + +interface TreeEntry { + mode: string; + hash: string; + name: string; + type: "blob" | "tree"; +} + +interface FileSystem { + [key: string]: FileSystem | { hash: string }; +} + +interface FileData { + filePath: string; + hash: string; +} + +export class TreeObject { + private fileSystem: FileSystem = {}; + private treeObjects = new Map>(); + + constructor(fileData: Array) { + this.buildFileSystem(fileData); + this.createTreeObjects(); + } + + public async dumpAllTrees(path = ""): Promise { + const entries = this.getTreeObject(path); + if (!entries || entries.length === 0) return; + + const hash = await this.dumpTree(path); + + for (const entry of entries) { + if (entry.type === "tree") { + const subPath = path ? join(path, entry.name) : entry.name; + await this.dumpAllTrees(subPath); + } + } + + return hash; + } + + //ファイルパスとハッシュからファイル構造を構築 + private buildFileSystem(fileData: Array): void { + fileData.forEach(({ filePath, hash }) => { + const parts = filePath.split("/"); + let current = this.fileSystem; + parts.forEach((part, index) => { + if (index === parts.length - 1) { + current[part] = { hash }; + } else { + if (!(part in current)) { + current[part] = {}; + } + current = current[part] as FileSystem; + } + }); + }); + } + + private createTreeObjects(): void { + this.createTreeObjectsRecursive("", this.fileSystem); + } + + private createTreeObjectsRecursive(path: string, node: FileSystem): string { + const entries: Array = []; + + for (const [name, value] of Object.entries(node)) { + if ("hash" in value) { + entries.push({ + mode: "100644", + hash: String(value.hash), + name, + type: "blob", + }); + } else { + const subPath = path ? `${path}/${name}` : name; + const hash = this.createTreeObjectsRecursive(subPath, value); + entries.push({ + mode: "040000", + hash, + name, + type: "tree", + }); + } + } + + //一意なtreeオブジェクトを生成するためにentryを名前順にsortしておく + const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name)); + + const treeHash = this.createTreeHash(sortedEntries); + this.treeObjects.set(path, sortedEntries); + + return treeHash; + } + + private createTreeHash(entries: Array): string { + const treeBuffer = this.createBufferFromEntries(entries); + + return createHash("sha1").update(Uint8Array.from(treeBuffer)).digest("hex"); + } + + private getTreeObject(path: string): Array | undefined { + return this.treeObjects.get(path); + } + + private createBufferFromEntries = (entries: Array): Buffer => { + const buffers: Array = []; + + for (const entry of entries) { + buffers.push( + Buffer.from(`${entry.mode} ${entry.name}\0`), + Buffer.from(entry.hash, "hex"), + ); + } + + const contentBuffer = Buffer.concat( + buffers.map((buffer) => Uint8Array.from(buffer)), + ); + const headerBuffer = Buffer.from( + `tree ${contentBuffer.length.toString()}\0`, + ); + const treeBuffer = Buffer.concat([ + Uint8Array.from(headerBuffer), + Uint8Array.from(contentBuffer), + ]); + + return treeBuffer; + }; + + private async dumpTree(path = ""): Promise { + const entries = this.getTreeObject(path); + if (!entries) return; + + const treeBuffer = this.createBufferFromEntries(entries); + + const treeHash = createHash("sha1") + .update(Uint8Array.from(treeBuffer)) + .digest("hex"); + + const { dirPath, filePath } = generateObjectPath(treeHash); + + if (!(await exists(dirPath))) await mkdir(dirPath, { recursive: true }); + + const compressedContent = deflateSync(Uint8Array.from(treeBuffer)); + await writeFile(filePath, Uint8Array.from(compressedContent)); + + if (path === "") return treeHash; + } +}