javascriptでgitをリバースエンジニアリングで実装する(3) ~commit編 後編~

2020/09/29に公開

gitをjavascriptで実装する

はじめに

こんにちは!!(@hirokihello)[https://twitter.com/maxyasuda] です!!

今日も引き続きgitを実装していきます!!

前回までの記事はこちら

  1. 第一回addとindexの解説など
    https://zenn.dev/hirokihello/articles/522183114dac569dbe80

  2. commitの実装(前編)
    https://zenn.dev/hirokihello/articles/bcf4db26df4f88bf3a14

これらの記事の知識を前提で行うので、まだ読まれていない方はぜひ読んでください。

この記事で実装すること

この記事では、git logが動くようにnodejsで実装を行っていきます。

ここまでのコードの復習

さてここまでのコードを復習していきましょう。
作成したファイルは3つです。

  • add.js
  • commit.js
  • tree.js

add.js

const crypto = require('crypto');
const fs = require('fs').promises;
const zlib = require('zlib');

async function add (file) {
  const fileObj = await fs.readFile(file)
  const content = fileObj.toString()
  const header=`blob ${content.length}\0`
  const store = header + content;
  const shasum = crypto.createHash('sha1');
  shasum.update(store);
  const sha1 = shasum.digest('hex')

  zlib.deflate(store, async function (err, result) {
    dirPath = __dirname + '/.git/objects/' + sha1.substring(0,2)
    filePath = dirPath + '/' + sha1.substring(2, 40)
    await fs.mkdir(dirPath, { recursive: true }, (err) => {
      if (err) throw err;
    });
    fs.writeFile(filePath, result, function (err) {
      if (err) throw err;
      console.log('Saved!');
    })
  });
}

async function updateIndex (files) {
  const header = Buffer.alloc(12);
  const fileNum = files.length

  header.write('DIRC', 0);
  header.writeUInt32BE(2, 4);
  header.writeUInt32BE(fileNum, 8);
  const entries = await Promise.all(entriesArray(files))

  const content = [header].concat(entries).reduce((accumulator, currentValue) =>{
    const length = currentValue.length + accumulator.length
    return Buffer.concat([accumulator, currentValue], length)
  })

  const hash = crypto.createHash('sha1')
  hash.update(content);
  const sha1 = Buffer.from(hash.digest('hex'), 'hex')

  const finalObj = Buffer.concat([content, sha1], content.length + sha1.length)

  fs.writeFile(".git/index", finalObj, function (err) {
    if (err) throw err;
    console.log('Saved!');
  })
}

function entriesArray(filePathArray) {
  return filePathArray.map(async filePath =>  {
    const statInfo = await fs.stat(filePath, {bigint: true})

    const ctime = parseInt((statInfo.ctime.getTime() / 1000 ).toFixed(0))
    const ctimeNs = parseInt(statInfo.ctimeNs  % 1000000000n) // 下9桁欲しい
    const mtime = parseInt((statInfo.mtime.getTime() / 1000 ).toFixed(0))
    const mtimeNs = parseInt(statInfo.mtimeNs % 1000000000n)
    const dev = parseInt(statInfo.dev)
    const ino = parseInt(statInfo.ino)
    const mode = parseInt(statInfo.mode)
    const uid = parseInt(statInfo.uid)
    const gid = parseInt(statInfo.gid)
    const size = parseInt(statInfo.size)

    const stat = Buffer.alloc(40);
    [
      ctime,
      ctimeNs,
      mtime,
      mtimeNs,
      dev,
      ino,
      mode,
      uid,
      gid,
      size,
    ].forEach((attr, idx) => stat.writeUInt32BE(attr, idx * 4))

    const sha1String = await genBlobSha1(filePath)
    const sha1 = Buffer.from(sha1String, 'hex')

    const assumeValid = 0b0 // 1 or 0 default is 0
    const extendedFlag = 0b0 // 1 or 0 default is 0
    const optionalFlag = (((0b0 | assumeValid) << 1) | extendedFlag) << 14

    const flagRes = optionalFlag | filePath.length
    const flag = Buffer.alloc(2)
    // 16bitなのでこのメソッドを使う。writeIntメソッドもあるがrangeが-32768 < val< 32767で、assumeValid=1になった時flagは最低でも32769となり
    // エラーが出るのでwriteUInt16BEを使う。
    // ファイル名の制限は一旦なしで。
    flag.writeUInt16BE(flagRes)

    const fileName = Buffer.from(filePath)
    const length = stat.length + sha1.length + flag.length + fileName.length
    const paddingCount = 8 - (length % 8)
    const padding = Buffer.alloc(paddingCount, '\0');
    const entry = Buffer.concat([stat, sha1, flag, fileName, padding], length + paddingCount)
    return entry
  })
}


async function genBlobSha1 (filePath) {
  const file = await fs.readFile(filePath)
  const content = file.toString()
  const header=`blob ${content.length}\0`
  const store = header + content;
  const shasum = crypto.createHash('sha1');
  shasum.update(store);
  const sha1 = shasum.digest('hex')

  return sha1
}

async function porcelainAdd () {
  if (process.argv.length <= 2) return console.log("error no file was added")
  const files = process.argv.slice(2).map(file => file.replace(/^\.\//, ""))
  await files.forEach(file => add(file))
  await updateIndex(files)
}

porcelainAdd()

commit.js

const { genTree } = require('./tree.js')
const fs = require('fs').promises;
const crypto = require('crypto');

async function porcelainCommit () {
  const message = process.argv[2]
  const indexFile = await fs.readFile(".git/index")
  const header = indexFile.slice(0, 12)
  let body = indexFile.slice(12)
  const fileNum = parseInt(header.slice(8, 12).toString("hex"))
  const fileInfo = []
  console.log(fileNum)
  for (let i = 0; i < fileNum; i++) {
    const mode = parseInt(body.slice(24, 28).toString('hex'), 16).toString(8)

    const sha1 = body.slice(40, 60).toString('hex')

    const flag = body.slice(60, 62)
    const fileLength =parseInt(flag.toString("hex"), 16) & 0b0011111111111111

    const name = body.slice(62, 62+fileLength).toString()
    const zeroPadding = 8 - ((62+fileLength) % 8)
    fileInfo.push({mode, sha1, name})
    body = body.slice(62+fileLength+zeroPadding)
  }
  const treeHash = await genTree(fileInfo)
  genCommitObject(treeHash, message)
}

async function genCommitObject (treeSha1, commitMessage) {
  const commitTime = (Date.now() / 1000).toFixed(0)
  const content = `tree ${treeSha1}\n` +
    `author hirokihello <iammyeye1@gmail.com> ${commitTime} +0900\n` +
    `committer hirokihello <iammyeye1@gmail.com> ${commitTime} +0900\n` +
    "\n" +
    `${commitMessage}\n`

  const header= `commit ${content.length}\0`
  const store = header + content
  const shasum = crypto.createHash('sha1');
  shasum.update(store);
  const commitSha1 = shasum.digest('hex')

  console.log(commitSha1)
}

porcelainCommit()

tree.js

const crypto = require('crypto');
const fs = require('fs').promises;
const zlib = require('zlib');

async function genTree (fileContents=[]) {
  const content = calcContents(fileContents)
  const header= Buffer.from(`tree ${content.length}\0`)
  const store = Buffer.concat([header, content], header.length + content.length);
  const shasum = crypto.createHash('sha1');
  shasum.update(store);
  const sha1 = shasum.digest('hex')
  // zlib.deflate(store, async function (err, result) { // bufferを引数で取れる!! https://nodejs.org/api/zlib.html#zlib_class_zlib_deflate 便利!
  //   dirPath = __dirname + '/.git/objects/' + sha1.substring(0,2)
  //   filePath = dirPath + '/' + sha1.substring(2, 40)
  //   await fs.mkdir(dirPath, { recursive: true }, (err) => {
  //     if (err) throw err;
  //   });
  //   fs.writeFile(filePath, result, function (err) {
  //     if (err) throw err;
  //     console.log('Saved!');
  //   })
  // });
  return sha1;
}

function calcContents (fileContents=[]) {
  return fileContents.reduce((acc, file) => {
    const content = calcContent(file)
    return Buffer.concat([acc, content], acc.length + content.length)
  }, Buffer.alloc(0))
}

function calcContent (fileContent) {
  const fileMode = fileContent.mode //100644
  const fileName = fileContent.name // sample.js
  const fileHash = fileContent.sha1 // 52679e5d3d185546a06f54ac40c1c652e33d7842
  const hash = Buffer.from(fileHash, "hex")
  const content = Buffer.from(`${fileMode} ${fileName}\0`) // modeとnameの間に半角スペースを開ける。

  const buffer = Buffer.concat([content, hash], hash.length + content.length)
  return buffer
}

module.exports = {
  genTree
}

logsの実装

さて今回はgit logsが動くように実装を行なっていきます。

logsのドキュメントを読む

まずはlogsのドキュメントを読んでみましょう。

The command takes options applicable to the git rev-list command to control what is shown and how, and options applicable to the git diff-* commands to control how the changes each commit introduces are shown.

引用
https://git-scm.com/docs/git-log

さっぱりわかりません。具体的な仕様に関することは書いていなさそうです。
実際の挙動を確認してみましょう。

実際の挙動を確認する

ファイルがどのように更新されるか

commitするとどのファイルが更新されるでしょうか。

前回の(コミットの記事)[https://zenn.dev/hirokihello/articles/bcf4db26df4f88bf3a14] でcommit時に.gitディレクトリの中で下記のものが更新されることを確認しました。

-rw-r--r--   1 inoue_h  staff   13  9 20 16:47 COMMIT_EDITMSG
-rw-r--r--   1 inoue_h  staff  137  9 20 16:47 index
drwxr-xr-x   3 inoue_h  staff   96  9 20 16:37 info
drwxr-xr-x   4 inoue_h  staff  128  9 20 16:47 logs
drwxr-xr-x   7 inoue_h  staff  224  9 20 16:47 objects

indexファイルとobjectsが更新されるのは、最初の記事でやりました。
またrefsの更新も前回の記事で実装しています。
今回はその他のlogsがどのように更新されるかをみていきましょう。

$ ⋊> ~/g/g/h/git_test on master ⨯ rm -rf .git
⋊> ~/g/g/h/git_test on master ⨯ git init
Initialized empty Git repository in /Users/hiroki/git/github.com/hirokihello/git_test/.git/
⋊> ~/g/g/h/git_test on master ⨯ git add hoge.js
⋊> ~/g/g/h/git_test on master ⨯ ls -la .git/
total 32
drwxr-xr-x  10 hiroki  staff  320  9 26 21:51 ./
drwxr-xr-x  11 hiroki  staff  352  9 26 21:51 ../
-rw-r--r--   1 hiroki  staff   23  9 26 21:51 HEAD
-rw-r--r--   1 hiroki  staff  137  9 26 21:51 config
-rw-r--r--   1 hiroki  staff   73  9 26 21:51 description
drwxr-xr-x  14 hiroki  staff  448  9 26 21:51 hooks/
-rw-r--r--   1 hiroki  staff  104  9 26 21:51 index
drwxr-xr-x   3 hiroki  staff   96  9 26 21:51 info/
drwxr-xr-x   5 hiroki  staff  160  9 26 21:51 objects/
drwxr-xr-x   4 hiroki  staff  128  9 26 21:51 refs/
⋊> ~/g/g/h/git_test on master ⨯ git commit -m 'first commit'
[master (root-commit) d39f9e0] first commit
 1 file changed, 1 insertion(+)
 create mode 100644 hoge.js
⋊> ~/g/g/h/git_test on master ⨯ ls -la .git/
total 40
drwxr-xr-x  12 hiroki  staff  384  9 26 22:00 ./
drwxr-xr-x  11 hiroki  staff  352  9 26 21:51 ../
-rw-r--r--   1 hiroki  staff   13  9 26 22:00 COMMIT_EDITMSG
-rw-r--r--   1 hiroki  staff   23  9 26 21:51 HEAD
-rw-r--r--   1 hiroki  staff  137  9 26 21:51 config
-rw-r--r--   1 hiroki  staff   73  9 26 21:51 description
drwxr-xr-x  14 hiroki  staff  448  9 26 21:51 hooks/
-rw-r--r--   1 hiroki  staff  137  9 26 22:00 index
drwxr-xr-x   3 hiroki  staff   96  9 26 21:51 info/
drwxr-xr-x   4 hiroki  staff  128  9 26 22:00 logs/
drwxr-xr-x   7 hiroki  staff  224  9 26 22:00 objects/
drwxr-xr-x   4 hiroki  staff  128  9 26 21:51 refs/
logs
$ ls -la .git/logs
total 8
drwxr-xr-x   4 hiroki  staff  128  9 26 22:00 ./
drwxr-xr-x  12 hiroki  staff  384  9 26 22:00 ../
-rw-r--r--   1 hiroki  staff  164  9 26 22:00 HEAD
drwxr-xr-x   3 hiroki  staff   96  9 26 22:00 refs/
⋊> ~/g/g/h/git_test on master ⨯ ls -la .git/logs/refs/
total 0
drwxr-xr-x  3 hiroki  staff   96  9 26 22:00 ./
drwxr-xr-x  4 hiroki  staff  128  9 26 22:00 ../
drwxr-xr-x  3 hiroki  staff   96  9 26 22:00 heads/
⋊> ~/g/g/h/git_test on master ⨯ ls -la .git/logs/refs/heads/ 
total 8
drwxr-xr-x  3 hiroki  staff   96  9 26 22:00 ./
drwxr-xr-x  3 hiroki  staff   96  9 26 22:00 ../
-rw-r--r--  1 hiroki  staff  164  9 26 22:00 master

HEADと、refs以下にmasterというファイルが生まれました。

みてみましょう。

$ cat .git/logs/HEAD
0000000000000000000000000000000000000000 d39f9e08a3311fa03083ee2e866e4c34c593e413 hirokihello <iammyeye1@gmail.com> 1601125212 +0900    commit (initial): first commit
$ cat .git/logs/refs/heads/master
0000000000000000000000000000000000000000 d39f9e08a3311fa03083ee2e866e4c34c593e413 hirokihello <iammyeye1@gmail.com> 1601125212 +0900    commit (initial): first commit

全く同じ内容ですね。最初の0000000000000000000000000000000000000000は謎ですが、d39f9e08a3311fa03083ee2e866e4c34c593e413はcommitで生まれたhashのようです。

$ git cat-file -p d39f9e08a3311fa03083ee2e866e4c34c593e413
tree f42d359cda8f272ac85e780376812808316beeee
author hirokihello <iammyeye1@gmail.com> 1601125212 +0900
committer hirokihello <iammyeye1@gmail.com> 1601125212 +0900

first commit

d39f9e08a3311fa03083ee2e866e4c34c593e413はcommitオブジェクトを示しますね。またauthor、mail address、commit時間を表す1601125212が続き、最後にcommitメッセージが続くことがわかりました。

次に別のファイルであるfuga.jsを追加でcommitしてみましょう。

git add fuga.js
⋊> ~/g/g/h/git_test on master ⨯ git commit -m 'second commit'
[master 3b9f813] second commit
 1 file changed, 1 insertion(+)
 create mode 100644 fuga.js
⋊> ~/g/g/h/git_test on master ⨯ ls -ls .git/logs/
total 8
8 -rw-r--r--  1 hiroki  staff  319  9 26 22:07 HEAD
0 drwxr-xr-x  3 hiroki  staff   96  9 26 22:00 refs/
⋊> ~/g/g/h/git_test on master ⨯ ls -ls .git/logs/refs/heads
total 8
8 -rw-r--r--  1 hiroki  staff  319  9 26 22:07 master

HEADとrefs/masterに更新がかかっています。中を見てみましょう。

$ cat  .git/logs/HEAD
0000000000000000000000000000000000000000 d39f9e08a3311fa03083ee2e866e4c34c593e413 hirokihello <iammyeye1@gmail.com> 1601125212 +0900    commit (initial): first commit
d39f9e08a3311fa03083ee2e866e4c34c593e413 3b9f813e81da29f44453f8a20df83830b5b07156 hirokihello <iammyeye1@gmail.com> 1601125676 +0900    commit: second commit

2行目に注目してください。

最初のhashは、d39f9e08a3311fa03083ee2e866e4c34c593e413であり、最初のcommit
で生まれたcommitオブジェクトのhashとなっています。次に現れたものは、おそらく2度目のcommitオブジェクトでしょう。

$ git cat-file -p 3b9f813e81da29f44453f8a20df83830b5b07156
tree c14b28b907d4e1445a21ea86c268f9a5cfc82aa9
parent d39f9e08a3311fa03083ee2e866e4c34c593e413
author hirokihello <iammyeye1@gmail.com> 1601125676 +0900
committer hirokihello <iammyeye1@gmail.com> 1601125676 +0900

second commit

cat-fileできたので、.git/logs/HEADと.git/logs/refs/heads/masterのdiffを確認します。

$ diff <(cat .git/logs/HEAD) <(cat .git/logs/refs/heads/master)
$ 

HEADファイルと同様にrefs/heads/masterが更新されていることがわかりました。1度目のcommitの0000000000000000000000000000000000000000は親コミットが存在しない時に記載するものということがわかりました。

ここで前回実装したcommit実装のリファクタリングの必要も出て来ましたね。今の実装では、下記の部分を追記することができないので、追記するようにします。

parent d39f9e08a3311fa03083ee2e866e4c34c593e413

/logsの更新を実装する。

さてここまでで実装することをまとめます。
2. commitの挙動を修正する
3. logs以下を更新するように実装する。

この順番にしたのは、前回の実装のままだと親のcommmitを取得することができないからです。なので、refsを参照して親commitを書き込むようにします。

commitと挙動を修正する

下記は修正後のファイルとなります。addも今の段階では、全て上書きするだけとなっているので、すでにindexに追加されているものがあれば、それを残すようにします。

commit.js

const { genTree } = require('./tree.js')
const fs = require('fs').promises;
const zlib = require('zlib');

...

async function genCommitObject (treeSha1, commitMessage) {
  const parentCommitHash = await fs.readFile(".git/refs/heads/master", () => undefined)
  const parentInfo = parentCommitHash ? `parent ${parentCommitHash}\n` : undefined
  const commitTime = (Date.now() / 1000).toFixed(0)
  const content = `tree ${treeSha1}\n` +
    `${parentInfo}` +
    `author hirokihello <iammyeye1@gmail.com> ${commitTime} +0900\n` +
    `committer hirokihello <iammyeye1@gmail.com> ${commitTime} +0900\n` +
    "\n" +
    `${commitMessage}\n`

  const header= `commit ${content.length}\0`
  const store = header + content
  const shasum = crypto.createHash('sha1');
  shasum.update(store);
  const commitSha1 = shasum.digest('hex')

  zlib.deflate(store, async function (err, result) {
    dirPath = __dirname + '/.git/objects/' + commitSha1.substring(0,2)
    filePath = dirPath + '/' + commitSha1.substring(2, 40)
    await fs.mkdir(dirPath, { recursive: true }, (err) => {
      if (err) throw err;
    });
    fs.writeFile(filePath, result, function (err) {
      if (err) throw err;
      console.log('Saved!');
    })
  });
  
  const refDirPath = __dirname + '/.git/refs/heads'
  const refFilePath = refDirPath + '/master'
  await fs.mkdir(refDirPath, { recursive: true }, (err) => {
    if (err) throw err;
  });
  fs.writeFile(refFilePath, commitSha1, function (err) {
    if (err) throw err;
    console.log('ref Saved!');
  })
}

porcelainCommit()

commit.jsの修正点はgenCommitObjectの中身だけです。

具体的には、readFileで.git/refs/heads/masterで読み込み、あればparentとして書き込み、なければ書き込まないようにします。

  const parentCommitHash = await fs.readFile(".git/refs/heads/master", () => undefined)
  const parentInfo = parentCommitHash ? `parent ${parentCommitHash}\n` : undefined

またいつも通り、zlibでdeflateした物を書き込むようにします。

  zlib.deflate(store, async function (err, result) {
    dirPath = __dirname + '/.git/objects/' + commitSha1.substring(0,2)
    filePath = dirPath + '/' + commitSha1.substring(2, 40)
    await fs.mkdir(dirPath, { recursive: true }, (err) => {
      if (err) throw err;
    });
    fs.writeFile(filePath, result, function (err) {
      if (err) throw err;
      console.log('Saved!');
    })
  });

またtree.jsのコメントアウトも外しておいてください。

async function genTree (fileContents=[]) {
  const content = calcContents(fileContents)
  const header= Buffer.from(`tree ${content.length}\0`)
  const store = Buffer.concat([header, content], header.length + content.length);
  const shasum = crypto.createHash('sha1');
  shasum.update(store);
  const sha1 = shasum.digest('hex')
  zlib.deflate(store, async function (err, result) { // bufferを引数で取れる!! https://nodejs.org/api/zlib.html#zlib_class_zlib_deflate 便利!
    dirPath = __dirname + '/.git/objects/' + sha1.substring(0,2)
    filePath = dirPath + '/' + sha1.substring(2, 40)
    await fs.mkdir(dirPath, { recursive: true }, (err) => {
      if (err) throw err;
    });
    fs.writeFile(filePath, result, function (err) {
      if (err) throw err;
      console.log('Saved!');
    })
  });
  return sha1;
}

日付をfirst commitを1601125212、second commitを1601125676固定し、下記が実行できるようにやってみましょう。(ここは各自で取得した日付で検証してみてください)

$ git cat-file -p 3b9f813e81da29f44453f8a20df83830b5b07156
tree c14b28b907d4e1445a21ea86c268f9a5cfc82aa9
parent d39f9e08a3311fa03083ee2e866e4c34c593e413
author hirokihello <iammyeye1@gmail.com> 1601125676 +0900
committer hirokihello <iammyeye1@gmail.com> 1601125676 +0900

second commit
$ rm -rf .git
⋊> ~/g/g/h/git_test on master ⨯ git init
Initialized empty Git repository in /Users/hiroki/git/github.com/hirokihello/git_test/.git/
⋊> ~/g/g/h/git_test on master ⨯ git add hoge.js
⋊> ~/g/g/h/git_test on master ⨯ node commit.js "first commit"

一旦ファイルcommitの時間を1601125676に固定して、fuga.jsをaddしてcommitします。

> ~/g/g/h/git_test on master ⨯ git add fuga.js> ~/g/g/h/git_test on master ⨯ node commit.js "second commit"> ~/g/g/h/git_test on master ⨯ git cat-file -p 3b9f813e81da29f44453f8a20df83830b5b07156
tree c14b28b907d4e1445a21ea86c268f9a5cfc82aa9
parent d39f9e08a3311fa03083ee2e866e4c34c593e413
author hirokihello <iammyeye1@gmail.com> 1601125676 +0900
committer hirokihello <iammyeye1@gmail.com> 1601125676 +0900

second commit

先ほどと同じ3b9f813e81da29f44453f8a20df83830b5b07156を参照することができたので、無事にcommitが親を参照するようにできました。

logsを実装する

あとは簡単です。commit時に、logsのHEADと、refs以下の情報を更新します。先ほどどのように更新されたかというと、下記のように更新されていました。

$ cat  .git/logs/HEAD
0000000000000000000000000000000000000000 d39f9e08a3311fa03083ee2e866e4c34c593e413 hirokihello <iammyeye1@gmail.com> 1601125212 +0900    commit (initial): first commit
d39f9e08a3311fa03083ee2e866e4c34c593e413 3b9f813e81da29f44453f8a20df83830b5b07156 hirokihello <iammyeye1@gmail.com> 1601125676 +0900    commit: second commit

やるべきことはrefs/HEADに書かれているcommitオブジェクトをみにいき、そこに書かれている情報を取得し、1行づつ足していくだけです。

完成形のファイル

const { genTree } = require('./tree.js')
const fs = require('fs').promises;
const crypto = require('crypto');
const zlib = require('zlib');

async function porcelainCommit () {
  const message = process.argv[2]
  const indexFile = await fs.readFile(".git/index")
  const header = indexFile.slice(0, 12)
  let body = indexFile.slice(12)
  const fileNum = parseInt(header.slice(8, 12).toString("hex"))
  const fileInfo = []
  for (let i = 0; i < fileNum; i++) {
    const mode = parseInt(body.slice(24, 28).toString('hex'), 16).toString(8)

    const sha1 = body.slice(40, 60).toString('hex')

    const flag = body.slice(60, 62)
    const fileLength =parseInt(flag.toString("hex"), 16) & 0b0011111111111111

    const name = body.slice(62, 62+fileLength).toString()
    const zeroPadding = 8 - ((62+fileLength) % 8)
    fileInfo.push({mode, sha1, name})
    body = body.slice(62+fileLength+zeroPadding)
  }
  const treeHash = await genTree(fileInfo)
  genCommitObject(treeHash, message)
}

async function updateLogs(commitSha1, commitMessage, commitTime) {
  const userName = "hirokihello"
  const mailAddress = "iammyeye1@gmail.com"
  const refsPath = ".git/refs/heads/master"
  const lastCommitHash = fs.readFile(refsPath).catch(() => "0000000000000000000000000000000000000000")
  const isInitial =  lastCommitHash === "0000000000000000000000000000000000000000"
  const line = `${lastCommitHash} ${commitSha1} ${userName} <${mailAddress}> ${commitTime} +0900    commit${isInitial ? " (initial)" : ""}: ${commitMessage}`

  const refHeadPath = ".git/refs/heads/master"
  const headPath = ".git/HEAD"
  fs.appendFile(refHeadPath, line, function (err) {
    if (err) throw err;
    console.log('Saved!');
  })
  fs.appendFile(headPath, line, function (err) {
    if (err) throw err;
    console.log('Saved!');
  })
}

async function genCommitObject (treeSha1, commitMessage) {
  const parentCommitHash = await fs.readFile(".git/refs/heads/master").catch(() => undefined)
  const parentInfo = parentCommitHash ? `parent ${parentCommitHash.toString()}\n` : ""
  const commitTime = (Date.now() / 1000).toFixed(0)
  const content = `tree ${treeSha1}\n` +
    `${parentInfo}` +
    `author hirokihello <iammyeye1@gmail.com> ${commitTime} +0900\n` +
    `committer hirokihello <iammyeye1@gmail.com> ${commitTime} +0900\n` +
    "\n" +
    `${commitMessage}\n`

  const header= `commit ${content.length}\0`
  const store = header + content
  const shasum = crypto.createHash('sha1');
  shasum.update(store);
  const commitSha1 = shasum.digest('hex')

  zlib.deflate(store, async function (err, result) { // bufferを引数で取れる!! https://nodejs.org/api/zlib.html#zlib_class_zlib_deflate 便利!
    dirPath = __dirname + '/.git/objects/' + commitSha1.substring(0,2)
    filePath = dirPath + '/' + commitSha1.substring(2, 40)
    await fs.mkdir(dirPath, { recursive: true }, (err) => {
      if (err) throw err;
    });
    fs.writeFile(filePath, result, function (err) {
      if (err) throw err;
      console.log('Saved!');
    })
  });

  await updateLogs(commitSha1, commitMessage,  commitTime)

  const refsPath = ".git/refs/heads/master"
  fs.writeFile(refsPath, commitSha1, function (err) {
    if (err) throw err;
    console.log('Saved!');
  })
}

porcelainCommit()

解説

初回のcommitの時のみ挙動が特別なので気をつけます。

まずはlogsをupdateする関数を作成します。

commit.js

async function updateLogs(commitSha1, commitMessage, commitTime) {
  const userName = "hirokihello"
  const mailAddress = "iammyeye1@gmail.com"
  const refsPath = ".git/refs/heads/master"
  const lastCommitHash = fs.readFile(refsPath).catch(() => "0000000000000000000000000000000000000000")
  const isInitial =  lastCommitHash === "0000000000000000000000000000000000000000"
  const line = `${lastCommitHash} ${commitSha1} ${userName} <${mailAddress}> ${commitTime} +0900    commit${isInitial ? " (initial)" : ""}: ${commitMessage}`

  const refHeadPath = ".git/refs/heads/master"
  const headPath = ".git/HEAD"
  fs.appendFile(refHeadPath, line, function (err) {
    if (err) throw err;
    console.log('Saved!');
  })
  fs.appendFile(headPath, line, function (err) {
    if (err) throw err;
    console.log('Saved!');
  })
}

やっていることは単純です。基本的には同じ文字列をファイルの最後に挿入していきます。
最初のcommitの場合、parent hashを0000000000000000000000000000000000000000にして、commitの文字列の前にinitialを入れ、他では入れません。

動かしてみる。

実際にaddコミットした後のlogsファイルを見てみて、比較してみます。

git コマンドでcommitした結果。

> ~/g/g/h/git_test on master ⨯ git add hoge.js
⋊> ~/g/g/h/git_test on master ⨯ git commit -m 'first commit'
[master (root-commit) 2bdfd1c] first commit
 1 file changed, 1 insertion(+)
 create mode 100644 hoge.js
⋊> ~/g/g/h/git_test on master ⨯ cat .git/logs/HEAD
0000000000000000000000000000000000000000 2bdfd1c78824b78c6c220cbe499f845b8c144c48 hirokihello <iammyeye1@gmail.com> 1601138755 +0900    commit (initial): first commit
⋊> ~/g/g/h/git_test on master ⨯ cat .git/logs/refs/heads/master
0000000000000000000000000000000000000000 2bdfd1c78824b78c6c220cbe499f845b8c144c48 hirokihello <iammyeye1@gmail.com> 1601138755 +0900    commit (initial): first commit
⋊> ~/g/g/h/git_test on master ⨯ git log
commit 2bdfd1c78824b78c6c220cbe499f845b8c144c48 (HEAD -> master)
Author: hirokihello <iammyeye1@gmail.com>
Date:   Sun Sep 27 01:45:55 2020 +0900

    first commit
⋊> ~/g/g/h/git_test on master ⨯ git add fuga.js
⋊> ~/g/g/h/git_test on master ⨯ git commit -m 'second commit'
[master 6879aae] second commit
 1 file changed, 1 insertion(+)
 create mode 100644 fuga.js
⋊> ~/g/g/h/git_test on master ⨯ cat .git/logs/HEAD
0000000000000000000000000000000000000000 2bdfd1c78824b78c6c220cbe499f845b8c144c48 hirokihello <iammyeye1@gmail.com> 1601138755 +0900    commit (initial): first commit
2bdfd1c78824b78c6c220cbe499f845b8c144c48 6879aaef452731d06fb6afe937969c6b950fc817 hirokihello <iammyeye1@gmail.com> 1601138784 +0900    commit: second commit
⋊> ~/g/g/h/git_test on master ⨯ cat .git/logs/refs/heads/master
0000000000000000000000000000000000000000 2bdfd1c78824b78c6c220cbe499f845b8c144c48 hirokihello <iammyeye1@gmail.com> 1601138755 +0900    commit (initial): first commit
2bdfd1c78824b78c6c220cbe499f845b8c144c48 6879aaef452731d06fb6afe937969c6b950fc817 hirokihello <iammyeye1@gmail.com> 1601138784 +0900    commit: second commit
⋊> ~/g/g/h/git_test on master ⨯ git log
commit 6879aaef452731d06fb6afe937969c6b950fc817 (HEAD -> master)
Author: hirokihello <iammyeye1@gmail.com>
Date:   Sun Sep 27 01:46:24 2020 +0900

    second commit

commit 2bdfd1c78824b78c6c220cbe499f845b8c144c48
Author: hirokihello <iammyeye1@gmail.com>
Date:   Sun Sep 27 01:45:55 2020 +0900

    first commit

commitのtime、messageを初回を1601138755first commit、2回目を1601138784second commitに固定してcommitしてみます。

> ~/g/g/h/git_test on master ⨯ rm -rf .git
⋊> ~/g/g/h/git_test on master ⨯ git init
Initialized empty Git repository in /Users/hiroki/git/github.com/hirokihello/git_test/.git/
⋊> ~/g/g/h/git_test on master ⨯ git add hoge.js
⋊> ~/g/g/h/git_test on master ⨯ node commit.js "first commit"> ~/g/g/h/git_test on master ⨯ git add fuga.js
⋊> ~/g/g/h/git_test on master ⨯ node commit.js "second commit"> ~/g/g/h/git_test on master ⨯ cat .git/logs/refs/heads/master
0000000000000000000000000000000000000000 2bdfd1c78824b78c6c220cbe499f845b8c144c48 hirokihello <iammyeye1@gmail.com> 1601138755 +0900    commit (initial): first commit
2bdfd1c78824b78c6c220cbe499f845b8c144c48 6879aaef452731d06fb6afe937969c6b950fc817 hirokihello <iammyeye1@gmail.com> 1601138784 +0900    commit: second commit
$ cat .git/logs/HEAD
0000000000000000000000000000000000000000 2bdfd1c78824b78c6c220cbe499f845b8c144c48 hirokihello <iammyeye1@gmail.com> 1601138755 +0900    commit (initial): first commit
2bdfd1c78824b78c6c220cbe499f845b8c144c48 6879aaef452731d06fb6afe937969c6b950fc817 hirokihello <iammyeye1@gmail.com> 1601138784 +0900    commit: second commit
$ git log
commit 6879aaef452731d06fb6afe937969c6b950fc817 (HEAD -> master)
Author: hirokihello <iammyeye1@gmail.com>
Date:   Sun Sep 27 01:46:24 2020 +0900

    second commit

commit 2bdfd1c78824b78c6c220cbe499f845b8c144c48
Author: hirokihello <iammyeye1@gmail.com>
Date:   Sun Sep 27 01:45:55 2020 +0900

    first commit

同じ出力結果になり、無事に動かすことができました。

Discussion