Git의 기본 명령어인 init, add, commit을 유사하게 구현한 과정이다.

Git CLI
commander.js 라이브러리를 사용하여 커맨드와 옵션들을 정의하고 CLI(Command Line Interface)를 간단히 구현할 수 있었다.
초기 설정
먼저, commander.js를 설치하고 CLI 엔트리 파일(cli.js)을 생성했다.
CLI를 실행할 수 있도록 package.json의 bin 필드를 설정했다.
"bin": {
"pit": "cli.js"
}bin 필드는 CLI 도구의 이름을 정의한다.
위와 같이 설정했을 때, 예를 들어 pit init, pit add <files..>와 같은 명령어를 실행할 수 있다.
pit init
init 명령어는 새로운 .pit 저장소를 초기화한다.
디렉터리에 .pit라는 이름의 폴더를 생성하고, Git의 내부 구조를 흉내 내기 위해 objects 디렉터리를 추가로 만든다.
refs 등 다른 설정 폴더 및 파일들이 있지만 지금은 기본 객체만 구현한 상태이므로 넘어갔다.
코드
program
.command("init")
.description("Initialize a new pit repository")
.action(async () => {
const repoPath = process.cwd();
await pitInit(repoPath);
});
async function pitInit(repoPath) {
const pitDir = path.join(repoPath, ".pit");
const objectsDir = path.join(pitDir, "objects");
if (fs.existsSync(pitDir)) {
console.log("Reinitialized existing Pit repository in", pitDir);
return;
}
fs.mkdirSync(objectsDir, { recursive: true });
console.log("Initialized empty Pit repository in", pitDir);
}결과


이미 .pit 디렉터리가 존재하는 경우
pit add
add 명령어는 파일을 저장소에 추가한다. 기본적으로 파일의 내용을 읽어 객체(blob)로 변환한 후 저장소에 저장한다.
코드
program
.command("add <files...>")
.description("Add file(s) to the repository")
.action(async (files) => {
const repoPath = process.cwd();
await pitAdd(repoPath, files);
});
async function pitAdd(repoPath, filePaths) {
if (!filePaths || filePaths.length === 0) {
console.log("Add file path(s) to the repository");
return;
}
for (const fp of filePaths) {
const fullPath = path.join(repoPath, fp);
try {
const content = fs.readFileSync(fullPath, "utf8");
const blob = new Blob(content);
await blob.save(repoPath);
console.log(`file ${fp} -> object created (hash: ${blob.hash})`);
} catch (err) {
console.error(`file not found: ${fp}`, err.message);
}
}
}결과


생성된 파일들
하지만 파일을 지정해서 추가하는 경우도 있지만, 일반적으로 git add . 처럼 디렉터리 전체를 추가하도록 할 필요가 있었다.
또, gitignore처럼 ignore 파일에 정의된 규칙을 따르도록 구현해보았다.
하위 디렉터리 포함하기
코드
program
.command("add <files...>")
.description("Add file(s) to the repository")
.action(async (files) => {
const repoPath = process.cwd();
await pitAdd(repoPath, files);
});
async function pitAdd(repoPath, filePaths) {
const allFiles = new Set();
for (const fp of filePaths) {
const fullPath = path.join(repoPath, fp);
if (!fs.existsSync(fullPath)) {
console.error(`File or directory not found: ${fp}`);
continue;
}
const stat = fs.statSync(fullPath);
if (stat.isFile()) {
allFiles.add(fullPath);
} else if (stat.isDirectory()) {
await collectFiles(fullPath, allFiles);
}
}
for (const filePath of allFiles) {
const content = fs.readFileSync(filePath, "utf8");
const blob = new Blob(content);
await blob.save(repoPath);
console.log(`File ${filePath} -> Object created (hash: ${blob.hash})`);
}
}
async function collectFiles(dirPath, fileSet) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isFile()) {
fileSet.add(fullPath);
} else if (entry.isDirectory()) {
await collectFiles(fullPath, fileSet);
}
}
}결과

.pitignore 규칙 적용하기
ignore 라이브러리를 사용하여 Git의 .gitignore처럼 .pitignore 파일에 정의된 규칙을 따르도록 구현해보았다.
코드
const ignore = require("ignore");
const ig = ignore();
if (fs.existsSync(".pitignore")) {
const ignoreRules = fs.readFileSync(".pitignore", "utf8");
ig.add(ignoreRules.split("\n").filter((line) => line.trim() !== ""));
}
if (ig.ignores(filePath)) {
continue;
}pit commit
commit 명령어는 현재 디렉터리의 변경 사항을 커밋한다. 커밋 메시지를 옵션으로 입력받으며, 트리 객체와 커밋 객체를 생성하여 저장한다.
코드
program
.command("commit")
.description("Commit changes")
.option("-m, --message <msg>", "Commit message")
.action(async (options) => {
const repoPath = process.cwd();
await checkPitRepo(repoPath);
const message = options.message || "Default commit message";
await pitCommit(repoPath, message);
});
async function pitCommit(repoPath, message) {
const tree = new Tree();
const allFiles = fs.readdirSync(repoPath);
for (const file of allFiles) {
if (file === ".pit" || file === "node_modules") continue;
const filePath = path.join(repoPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
continue;
}
const content = fs.readFileSync(filePath, "utf8");
const blob = new Blob(content);
await blob.save(repoPath);
tree.addEntry(file, blob.hash, "100644");
}
const treeHash = await tree.save(repoPath);
const commit = new Commit(treeHash, message, null);
const commitHash = await commit.save(repoPath);
console.log(`commit created: ${commitHash}`);
console.log(`tree hash: ${treeHash}`);
console.log(`commit message: ${message}`);
}결과

저장소 검증하기
추가적으로, 명령어는 반드시 저장소가 초기화된 디렉터리 내에서만 실행되도록 제한했다.
코드
program
.command("add <files...>")
.description("Add file(s) to the repository")
.action(async (files) => {
const repoPath = process.cwd();
await checkPitRepo(repoPath);
await pitAdd(repoPath, files);
});
program
.command("commit")
.description("Commit changes")
.option("-m, --message <msg>", "Commit message")
.action(async (options) => {
const repoPath = process.cwd();
await checkPitRepo(repoPath);
const message = options.message || "Default commit message";
await pitCommit(repoPath, message);
});
async function checkPitRepo(repoPath) {
const pitDir = path.join(repoPath, ".pit");
if (!fs.existsSync(pitDir)) {
console.error("Not a pit repository (or any of the parent directories)");
process.exit(1);
}
}결과

NPM 배포
마지막으로, NPM에 배포해보았다.
원래는 패키지 이름을 pit으로 하려했으나, 이미 존재하는 이름이라 pit2로 변경했다.
이제 NPM을 통해 설치받아 사용할 수 있다.
npm install -g pit2로컬에서 현재 디렉터리에 있는 패키지를 전역으로 설치해서 사용할 수도 있었지만
npm install -g .NPM에 배포하는 경험도 해보고 싶었다.
결과


이 프로젝트의 모든 소스 코드는 GitHub에 공개되어 있습니다. 코드 품질 개선이나 새로운 기능 제안에 대한 피드백은 언제나 환영합니다.