🐡

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

2020/09/24に公開

どうもこんにちは!

@hirokihelloです!

前回の記事はこちら

今日も前回の記事に引き続き、gitの実装をしていきます。今回はcommitについて実装をしていきます。

前回の知識を前提に実装していくので、まだ前回の記事を読まれていない方は是非是非前回の記事も合わせて読んでみてくださいね!

環境は下記の通りです。

$ git version
git version 2.21.1 (Apple Git-122.3)
$ node -v
v14.5.0

commitとは

commitに期待される挙動

まずはcommitが何をやるコマンドなのかgitのdocumentを確認してみましょう。

Create a new commit containing the current contents of the index and the given log message describing the changes. The new commit is a direct child of HEAD, usually the tip of the current branch, and the branch is updated to point to it

引用
[https://git-scm.com/docs/git-commit:embed:cite]

ふむ。読むのがめんどくさい。

ポイントはたくさんあるのですが、今回のポイントは下記です。

  1. indexとログメッセージからcommitオブジェクトを新しく作成する。
  2. HEADにそのcommitは属するものであり、現在のブランチが新しくそのcommitを示すように更新処理をする。

このポイントを頭の片隅にいれた状態で、commitを作成すると、どのような状態になるのかについて具体的にみていきましょう。

実際にcommitの挙動を確認する

$ mkdir git_test
$ cd git_test
$ git init
$ ls -la .git
total 24
drwxr-xr-x   9 inoue_h  staff  288  9 20 16:37 ./
drwxr-xr-x  17 inoue_h  staff  544  9 20 16:37 ../
-rw-r--r--   1 inoue_h  staff   23  9 20 16:37 HEAD
-rw-r--r--   1 inoue_h  staff  137  9 20 16:37 config
-rw-r--r--   1 inoue_h  staff   73  9 20 16:37 description
drwxr-xr-x  13 inoue_h  staff  416  9 20 16:37 hooks/
drwxr-xr-x   3 inoue_h  staff   96  9 20 16:37 info/
drwxr-xr-x   4 inoue_h  staff  128  9 20 16:37 objects/
drwxr-xr-x   4 inoue_h  staff  128  9 20 16:37 refs/

先ほどポイントに出てきたHEADファイルが存在しますね。
HEADファイルは、現在最新のコミットを示すファイルです。

ここには何が格納されているのでしょうか。

$ cat .git/HEAD
ref: refs/heads/master

ref: refs/heads/masterという文字列が格納されていました。これは.git/refs/heads/masterに今作業中の最新のコミットが格納されているという意味となります。

.git/refs/heads/masterをみてみましょう。

$ cat .git/refs/heads/master
cat: .git/refs/heads/master: No such file or directory

存在しないようです。まだコミットしていないからですね。
ひとまずrefsの中身をみてみましょう。

$ ls -la .git/refs
total 0
drwxr-xr-x  4 inoue_h  staff  128  9 20 16:37 .
drwxr-xr-x  9 inoue_h  staff  288  9 20 16:42 ..
drwxr-xr-x  2 inoue_h  staff   64  9 20 16:37 heads
drwxr-xr-x  2 inoue_h  staff   64  9 20 16:37 tags
$ ls -la .git/refs/heads
total 0
drwxr-xr-x  2 inoue_h  staff   64  9 20 16:37 .
drwxr-xr-x  4 inoue_h  staff  128  9 20 16:37 ..

今のところrefs以下には何もないですね。commitしてみましょう。

$ echo 'console.log("hoge")' > sample.js
$ node sample.js
hoge
$ git add sample.js 
$ git commit -m 'first commit'
[master (root-commit) 0e95049] first commit
 1 file changed, 1 insertion(+)
 create mode 100644 sample.js

何やらメッセージが現れましたね。ファイルはどのように更新されたでしょうか。

$ ls -la .git
total 40
drwxr-xr-x  12 inoue_h  staff  384  9 20 16:47 .
drwxr-xr-x  17 inoue_h  staff  544  9 20 16:37 ..
-rw-r--r--   1 inoue_h  staff   13  9 20 16:47 COMMIT_EDITMSG
-rw-r--r--   1 inoue_h  staff   23  9 20 16:37 HEAD
-rw-r--r--   1 inoue_h  staff  137  9 20 16:37 config
-rw-r--r--   1 inoue_h  staff   73  9 20 16:37 description
drwxr-xr-x  13 inoue_h  staff  416  9 20 16:37 hooks
-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
drwxr-xr-x   4 inoue_h  staff  128  9 20 16:37 refs

16:47に更新がかかったのは以下の部分です。

-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

一見するとrefsの変更時間は変わっていません。

$ ls -la .git/refs/heads
total 8
drwxr-xr-x  3 inoue_h  staff   96  9 20 16:47 .
drwxr-xr-x  4 inoue_h  staff  128  9 20 16:37 ..
-rw-r--r--  1 inoue_h  staff   41  9 20 16:47 master

しかし、refs以下の.git/refs/headsにmasterというファイルが作られました。

$ cat .git/refs/heads/master
0e95049453fa4d33b5c1ceedb042181fa4af0c40

むむ。hashが出てきました。どうやらaddの時で作成したような、objectsを示すようですね。とりあえずcat-fileコマンドを使ってみましょう。

$ git cat-file -p 0e95049453fa4d33b5c1ceedb042181fa4af0c40
tree 161e899ffc6e06b5a8f94b77c99312c30deb9452
author hirokihello <iammyeye1@gmail.com> 1600588067 +0900
committer hirokihello <iammyeye1@gmail.com> 1600588067 +0900

first commit

cat-fileコマンドでみることができたので、objectsファイルのようです。ここで一旦先ほど更新のかかっていたディレクトリをみてみましょう。

ls -la .git/objects
total 0
drwxr-xr-x   7 inoue_h  staff  224  9 20 16:47 .
drwxr-xr-x  12 inoue_h  staff  384  9 20 16:47 ..
drwxr-xr-x   3 inoue_h  staff   96  9 20 16:47 0e
drwxr-xr-x   3 inoue_h  staff   96  9 20 16:47 16
drwxr-xr-x   3 inoue_h  staff   96  9 20 16:47 ea
drwxr-xr-x   2 inoue_h  staff   64  9 20 16:37 info
drwxr-xr-x   2 inoue_h  staff   64  9 20 16:37 pack

16:47に0e, 16, ea三つのディレクトリができています。
一つづつ確認していきます。

$ ls -la .git/objects/0e
total 8
drwxr-xr-x  3 inoue_h  staff   96  9 20 16:47 .
drwxr-xr-x  7 inoue_h  staff  224  9 20 16:47 ..
-r--r--r--  1 inoue_h  staff  128  9 20 16:47 95049453fa4d33b5c1ceedb042181fa4af0c40

0e以下にできたのは先ほど確認した、 .git/refs/heads/masterで示されていたファイルですね。次のディレクトリをみてみましょう。

$ ls -la .git/objects/16
total 8
drwxr-xr-x  3 inoue_h  staff   96  9 20 16:47 .
drwxr-xr-x  7 inoue_h  staff  224  9 20 16:47 ..
-r--r--r--  1 inoue_h  staff   54  9 20 16:47 1e899ffc6e06b5a8f94b77c99312c30deb9452

先ほど確認した0e95049453fa4d33b5c1ceedb042181fa4af0c40の一行目に記載されている、tree 161e899ffc6e06b5a8f94b77c99312c30deb9452 はこのobjectファイルを示していることがわかります。
内容を確認しましょう。

$ git cat-file -p 161e899ffc6e06b5a8f94b77c99312c30deb9452
100644 blob ea8e751d31e45830b3ace4d1238a4429f3fb18f5    sample.js

100644 blob ea8e751d31e45830b3ace4d1238a4429f3fb18f5 sample.jsという内容が格納されたファイルが存在しますね。

ea8e751d31e45830b3ace4d1238a4429f3fb18f5はどうやらhashのようですが...

$ ls -la .git/objects/ea
total 8
drwxr-xr-x  3 inoue_h  staff   96  9 20 16:47 .
drwxr-xr-x  7 inoue_h  staff  224  9 20 16:47 ..
-r--r--r--  1 inoue_h  staff   36  9 20 16:47 8e751d31e45830b3ace4d1238a4429f3fb18f5
$ git cat-file -p ea8e751d31e45830b3ace4d1238a4429f3fb18f5
console.log("hoge")

161e899ffc6e06b5a8f94b77c99312c30deb9452のhashに記載されている、ea8e751d31e45830b3ace4d1238a4429f3fb18f5はobjectファイルを示していることがわかりました。

さてここまでで出てきた情報を一旦整理します。

addの段階でできるもの

  • 8e751d31e45830b3ace4d1238a4429f3fb18f5

addしたsample.jsの内容が保存されている。

ここまでは前回やった部分です。

commitの時点でできるっぽいもの。

  • 0e95049453fa4d33b5c1ceedb042181fa4af0c40
  • 161e899ffc6e06b5a8f94b77c99312c30deb9452

0e95049453fa4d33b5c1ceedb042181fa4af0c40は、.git/refs/heads/masterで参照されているobjects。中身は下記のようになっており、161e899ffc6e06b5a8f94b77c99312c30deb9452を示しています。

$ git cat-file -p 0e95049453fa4d33b5c1ceedb042181fa4af0c40
tree 161e899ffc6e06b5a8f94b77c99312c30deb9452
author hirokihello <iammyeye1@gmail.com> 1600588067 +0900
committer hirokihello <iammyeye1@gmail.com> 1600588067 +0900

first commit

161e899ffc6e06b5a8f94b77c99312c30deb9452はobjectsファイルであり、中身は下記のようになっており、addコマンドで作成されたオブジェクトであるea8e751d31e45830b3ace4d1238a4429f3fb18f5を示しています。

$ git cat-file -p 161e899ffc6e06b5a8f94b77c99312c30deb9452
100644 blob ea8e751d31e45830b3ace4d1238a4429f3fb18f5    sample.js

なので今回commitコマンドの実装にあたり、要件としては

  1. 0e95049453fa4d33b5c1ceedb042181fa4af0c40161e899ffc6e06b5a8f94b77c99312c30deb9452の二つと同じ構造をもつobjectsを作成する
  2. .git/refs/heads/masterを書き換える

の2点となります。

今回の実装の注意として、commitコマンドと同じファイルを生成する、つまりファイルの内容から生成されるhashを生成することをゴールとし実際にファイルの書き込みは行いません。

hashが同様に生成できればファイルの中身が同じであることが証明され、あとは書き込むか書き込まないかの違いだけなためそのようにします。

理由として、gitでは同じhash objectsがあれば書き込まないハンドリング、ファイルのパーミッションなど今回のcommit実装というテーマの本質とは若干外れる部分が必須になってくるためです。

書き込みは次回の記事に回します。書き込む方法自体はこれも記事内で言及しますが、headerとcontent部分をzlibでdeflateするだけであり、addでやった通りです。
念の為コメントアウトして書いておきますので、興味のある方はやってみてください。

commitを実装してみる

前回実装したコードの知識は前提として実装していきますので、objects/index周りは前回の記事を参照してくださいね。

[https://zenn.dev/hirokihello/articles/522183114dac569dbe80]

まずは先ほどみた、

0e95049453fa4d33b5c1ceedb042181fa4af0c40161e899ffc6e06b5a8f94b77c99312c30deb9452の二つと同じ構造をもつobjectsを作成する

これを実装します。

それぞれ0e95049453fa4d33b5c1ceedb042181fa4af0c40はコミットオブジェクト、161e899ffc6e06b5a8f94b77c99312c30deb9452はtreeオブジェクトと呼ばれています。

内容をもう一度みておきましょう。

bash-3.2$ git cat-file -p 0e95049453fa4d33b5c1ceedb042181fa4af0c40
tree 161e899ffc6e06b5a8f94b77c99312c30deb9452
author hirokihello <iammyeye1@gmail.com> 1600588067 +0900
committer hirokihello <iammyeye1@gmail.com> 1600588067 +0900

first commit

bash-3.2$ git cat-file -p 161e899ffc6e06b5a8f94b77c99312c30deb9452
100644 blob ea8e751d31e45830b3ace4d1238a4429f3fb18f5    sample.js

treeオブジェクトはunixのディレクトリのような役割をしており、gitにおいてディレクトリ構造を表現するのに使用されます。

treeオブジェクトはディレクトリのように、内部にtreeオブジェクトもしくはblobオブジェクトを子として持ちます。

コミットオブジェクトは、commit時のindexファイルの一覧を示すtreeオブジェクト、author情報などを格納したファイルとなります。

それぞれtreeオブジェクト、commitオブジェクトに関しては詳細はこの記事を読み込むと良いです。

[https://git-scm.com/book/ja/v2/Gitの内側-Gitの参照:embed:cite]

今回の例でどのように使われているかというと

  1. addでsample.jsのblob objectが作成される
  2. commit時にindexファイルのentriesに記載されているobjectsの一覧が記載されたtreeオブジェクトが作成される
  3. commitオブジェクトに、2で作成したtree objectのsha1 hashやauthor情報、message("first commit"など)を記載する

といった流れで生成・使用されています。

なのでまずやるべきことは、treeファイルを作成することです。

treeファイルを生成する

最初にtreeファイルを生成する完成系のコードを乗っけておきます。

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) {  
    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
}

treeファイルのフォーマット

treeファイルはどのようなフォーマットなのでしょうか。下記のようであるとの情報があります。

tree <content length><NUL><file mode> <filename><NUL><item sha>...

引用
[https://www.dulwich.io/docs/tutorial/file-format.html:embed:cite]

必要なのは、

  1. filename
  2. mode
  3. sha

の三つですね。

これらはaddのタイミングでindexに全て書き込んでいるので、それが使えそうです。

実際にこの形式で書き込まれているのか検証してみましょう。

先ほどのsample.jsをcommitした時に生成された161e899ffc6e06b5a8f94b77c99312c30deb9452をみてみます。

$ git cat-file -p 161e899ffc6e06b5a8f94b77c99312c30deb9452
100644 blob ea8e751d31e45830b3ace4d1238a4429f3fb18f5    sample.js

$ hexdump -C .git/objects/16/1e899ffc6e06b5a8f94b77c99312c30deb9452
00000000  78 01 2b 29 4a 4d 55 30  36 67 30 34 30 30 33 31  |x.+)JMU06g040031|
00000010  51 28 4e cc 2d c8 49 d5  cb 2a 66 78 d5 57 2a 6b  |Q(N.-.I..*fx.W*k|
00000020  f8 24 c2 60 f3 9a 27 17  95 bb 5c 34 3f ff 96 f8  |.$.`..'...\4?...|
00000030  0a 00 56 72 11 e7                                 |..Vr..|
00000036

object fileはzlibで圧縮されているので解凍できるように関数ファイルを作ります。

const fs = require('fs');
const zlib = require('zlib');

async function inflate () {
  const hash = process.argv[2]
  const dirPath = __dirname + '/.git/objects/' + hash.substring(0,2)
  const filePath = dirPath + '/' + hash.substring(2, 41)

  fs.readFile(filePath, function(err, res) {
    console.log(res.toString('latin1'));

    if(err) throw err
    zlib.inflate(res, function (err, result) {
      console.log(result)
      if(err) throw err
      console.log(result.toString());
      // 最初のnull byteを見つけてくれる。
      console.log(result.indexOf('\0'))
      console.log(result.slice(result.indexOf('\0') + 1).toString())

    });
  })
}

inflate()

これでファイルをinflateした状態で確認することができます。

やっていることは単純で、20byteのhashを受け取りファイルのpathを計算し、そのファイルを読み込みzlibでinflateします。

161e899ffc6e06b5a8f94b77c99312c30deb9452を読み込ませます。

$ node inflate.js 161e899ffc6e06b5a8f94b77c99312c30deb9452
x+)JMU06g040031Q(N�-�I��*fx�W*k�$�`�'��\4?���
Vr�
<Buffer 74 72 65 65 20 33 37 00 31 30 30 36 34 34 20 73 61 6d 70 6c 65 2e 6a 73 00 ea 8e 75 1d 31 e4 58 30 b3 ac e4 d1 23 8a 44 29 f3 fb 18 f5>
tree 37100644 sample.js�u1�X0����#�D)���
7
100644 sample.js�u1�X0����#�D)���

最初の出力結果はzlibをかまさない結果です。

x+)JMU06g040031Q(N�-�I��*fx�W*k�$�`�'��\4?���
Vr�

zlibでinfaleteした結果のbufferです。

<Buffer 74 72 65 65 20 33 37 00 31 30 30 36 34 34 20 73 61 6d 70 6c 65 2e 6a 73 00 ea 8e 75 1d 31 e4 58 30 b3 ac e4 d1 23 8a 44 29 f3 fb 18 f5>

headerを取らないでinflateしたbufferをstringにした出力結果です。

tree 37100644 sample.js�u1�X0����#�D)���

今のとことはこれをみていれば良いでしょう。

先ほど確認したところ、treeのファイル構造は下記のようになっていました

tree <content length><NUL><file mode> <filename><NUL><item sha>...

inflateした結果と比べてみましょう。treeという文字列の後に37という数値、それからファイルのmodeを表す100644が来ています。その後半角スペースがあります。
おかしいですね。sha1 hashがあるはずが文字化けしています。本来ならこの結果はcat-fileした時に表示されるhashのea8e751d31e45830b3ace4d1238a4429f3fb18f5が来て欲しいのですが、ありません。

tree 37100644 sample.js�u1�X0����#�D)���

inflateした結果のbufferをみてみましょう。何か見えてきませんか?そう最後の20byteがea8e751d31e45830b3ace4d1238a4429f3fb18f5と一致していることに気づくでしょう。

<Buffer 74 72 65 65 20 33 37 00 31 30 30 36 34 34 20 73 61 6d 70 6c 65 2e 6a 73 00 ea 8e 75 1d 31 e4 58 30 b3 ac e4 d1 23 8a 44 29 f3 fb 18 f5>

hashの前の部分を文字列で作りbufferに変換して、最後にhashをそのままbufferにしたものと連結すればうまくいきそうです。

treeファイルを実装してみる。

さてとりあえず実装してみましょう。このファイルhashが同じ161e899ffc6e06b5a8f94b77c99312c30deb9452と一致するものが生成できたらクリアです。今の段階では実際に保存する必要がないので、コメントアウトしておきます。

まずこれです。これはbody部の1ファイル分のbufferを生成します。fileContentの想定される引数は、{name: "sample.js", mode: 100644, sha1: "ea8e751d31e45830b3ace4d1238a4429f3fb18f5"}です。

function calcContent (fileContent) {
  const fileMode = fileContent.mode //100644
  const fileName = fileContent.name // sample.js
  const fileHash = fileContent.sha1 // ea8e751d31e45830b3ace4d1238a4429f3fb18f5
  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
}

具体的には下記のようなtreeオブジェクトの構造のうち

tree <content length><NUL><file mode> <filename><NUL><item sha>...

このひとまとまりを生成します。

<file mode> <filename><NUL><item sha>

注意するのは下記の部分です。これは、"ea8e751d31e45830b3ace4d1238a4429f3fb18f5"<Buffer ea 8e 75 1d 31 e4 58 30 b3 ac e4 d1 23 8a 44 29 f3 fb 18 f5>に変換する為にhexオプションをつけています。それ以外は先ほどstring()メソッドを使うことで文字列にできたので、普通にbufferに変換します。
`

 const hash = Buffer.from(fileHash, "hex")

次はこの関数を足します。{name: "sample.js", mode: 100644, sha1: "ea8e751d31e45830b3ace4d1238a4429f3fb18f5"}を配列で引数にとり、先ほどのcalcContentで<file mode> <filename><NUL><item sha>を生成して、最終的に全てのbufferを連結します。

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

最後に上記二つの関数を使う関数を定義します。ここでは、calcContentsでbody部分のbufferを作った後にheader部分と連結しています。それをあとはaddで作成したようにsha1 hashを計算しています。本当ならば、headerとcontentを合わせたものをzlibで圧縮して保存するのですが、まずはこれで正しくファイルが作れているのかわからないので実行してみます。デフォルトで引数を与えているのは検証用です。

async function genTree (fileContents=[{name: "sample.js", mode: 100644, sha1: "ea8e751d31e45830b3ace4d1238a4429f3fb18f5"}]) {
  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!');
  //   })
  // });
  console.log(sha1)
  return sha1;
}

161e899ffc6e06b5a8f94b77c99312c30deb9452と同じ出力結果が返ってくる為、正しくtreeオブジェクトが作れていることがわかります。(gitのobjectはファイルの内容とheaderを足してhashを計算する為、hashが同じなら同じ内容で作れている。)

$ node tree.js
161e899ffc6e06b5a8f94b77c99312c30deb9452

commitオブジェクトの生成を実装する。

それではcommitオブジェクトを作ります。最初に完成コードを乗せます。

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
}

commit.js

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"), 16)
  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')

  // 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!');
  //   })
  // });

  console.log(commitSha1)
}

porcelainCommit()

commitオブジェクトのフォーマット

この記事によればcommitオブジェクトは下記のフォーマットとなっています。
[https://www.dulwich.io/docs/tutorial/file-format.html:embed:cite]

commit <content length><NUL>tree <tree sha>
parent <parent sha>
[parent <parent sha> if several parents from merges]
author <author name> <author e-mail> <timestamp> <timezone>
committer <author name> <author e-mail> <timestamp> <timezone>

<commit message>

さて正しいのでしょうか。先ほど作成したinflate.jsを使って実際のファイルの形式をみていきます。今回作りたいhashの値は0e95049453fa4d33b5c1ceedb042181fa4af0c40ですので、これをzlibでinflateしたバッファー、文字列などをみていきます。

$ node inflate.js 0e95049453fa4d33b5c1ceedb042181fa4af0c40
x��I                                     ��\"Z��V|p��,*|FiJ��U
"��O�33Yu��&�YD4ޣupFBT����d�k�5�f�V�'�9Y�;                    �{�k
<Buffer 63 6f 6d 6d 69 74 20 31 37 39 00 74 72 65 65 20 31 36 31 65 38 39 39 66 66 63 36 65 30 36 62 35 61 38 66 39 34 62 37 37 63 39 39 33 31 32 63 33 30 64 ... 140 more bytes>
commit 179tree 161e899ffc6e06b5a8f94b77c99312c30deb9452
author hirokihello <iammyeye1@gmail.com> 1600588067 +0900
committer hirokihello <iammyeye1@gmail.com> 1600588067 +0900

first commit

10
tree 161e899ffc6e06b5a8f94b77c99312c30deb9452
author hirokihello <iammyeye1@gmail.com> 1600588067 +0900
committer hirokihello <iammyeye1@gmail.com> 1600588067 +0900

first commit

bash-3.2$

結果を一つづつみていきましょう。

これがinflateする前のファイルです。読み込めません。

x��I                                     ��\"Z��V|p��,*|FiJ��U
"��O�33Yu��&�YD4ޣupFBT����d�k�5�f�V�'�9Y�;                    �{�k

これがzlibでinflateした後のバッファです。

<Buffer 63 6f 6d 6d 69 74 20 31 37 39 00 74 72 65 65 20 31 36 31 65 38 39 39 66 66 63 36 65 30 36 62 35 61 38 66 39 34 62 37 37 63 39 39 33 31 32 63 33 30 64 ... 140 more bytes>

これがinflateした後のバッファを無加工でstringに変換したものです。気になる点として、最後に空行が入っています。

commit 179tree 161e899ffc6e06b5a8f94b77c99312c30deb9452
author hirokihello <iammyeye1@gmail.com> 1600588067 +0900
committer hirokihello <iammyeye1@gmail.com> 1600588067 +0900

first commit

最初のnull byteの位置です。

10

null byteより前(header)を取り除いてstringにしたものです。

tree 161e899ffc6e06b5a8f94b77c99312c30deb9452
author hirokihello <iammyeye1@gmail.com> 1600588067 +0900
committer hirokihello <iammyeye1@gmail.com> 1600588067 +0900

first commit

先ほど見たフォーマットと比べてみます。

commit <content length><NUL>tree <tree sha>
parent <parent sha>
[parent <parent sha> if several parents from merges]
author <author name> <author e-mail> <timestamp> <timezone>
committer <author name> <author e-mail> <timestamp> <timezone>

<commit message>

ファイルの最後に空行がある以外は同じですね。どちらが正しいのでしょうか。

commitオブジェクトを実装してみる。

さてとりあえず上記の結果を踏まえて、commitオブジェクトを生成する部分を実装してみます。

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

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')

  // 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!');
  //   })
  // });

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

  console.log(commitSha1)
}

ここでやっていることは至極単純です。先ほどinflateした結果を文字列として突っ込みcontentとして入れ、残りはいつものobjectsを生成する手順でhashにして返しているだけです。メールアドレスとユーザー名は今回は固定にしています。

  const commitTime = (Date.now() / 1000).toFixed(0)

この部分がなぜこのような実装になるのでしょうか。現在時刻をunixタイムで取得すると下記になりました。実際のコミットオブジェクトに記載されていた日付の部分を見ると1600588067となっています。indexの実装の時もそうですが、unixtimeを記入するときはm秒以下は切り捨てるようです。

Date.now()
=> 1600622610846

一旦引数を固定値にして、先ほどのcommitオブジェクトに記載されていた日付、hash, commit messageにして同じhashが生成されるか確認してみましょう。

commit.js

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

async function genCommitObject (treeSha1="161e899ffc6e06b5a8f94b77c99312c30deb9452", commitMessage="first commit") {
  const commitTime = (Date.now() / 1000).toFixed(0)
  const content = `tree ${treeSha1}\n` +
  `author hirokihello <iammyeye1@gmail.com> 1600588067 +0900\n` +
  `committer hirokihello <iammyeye1@gmail.com> 1600588067 +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!');
  //   })
  // });

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

  console.log(commitSha1)
}

genCommitObject()

$ node commit.js
0e95049453fa4d33b5c1ceedb042181fa4af0c40

先ほどの.git/refs/heads/masterに記載されていた結果と同じhashが得られました。

よって正しそうです。

先ほど参照したドキュメントにある通り、content内部の最後に改行を入れないで実装してみます。

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

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

  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!');
  //   })
  // });

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

  console.log(commitSha1)
}

genCommitObject()

実行するとhashが期待したものと変わってしまいました。行末の改行は必須のようです。

$ node commit.js
75b4fad1f9c26fc2c0cbdb2f4f486c1262eba5ac

よってコミットオブジェクトの生成部分の実装はこのようになります。

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

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')

  // 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!');
  //   })
  // });

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

genTreeとgenCommitObjectを接続する。

さてあとはtreeSha1とcommitMessageをgenCommitObjectに渡してあげるだけです。よって下記を追加します。

一見複雑ですが、やっていることは単純です。indexファイルをreadして、中身をヘッダーとボディに分けます。

headerからcommitする必要のあるファイル数を取り出したら、あとはbodyのentriesをそのファイル数の数だけparseして情報を取り出します。それを先ほど実装したtreeを作り出す関数genTreeに渡し、その返り値のhash、そしてコミットmessageをcommitObjectを生成するgenCommitObjectに渡してあげているだけです。

indexファイルの仕様については下記の前回の記事を参照してください。

[https://zenn.dev/hirokihello/articles/522183114dac569dbe80]

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"), 16)
  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)
}

具体的にみていきます。ここは簡単ですね。indexの仕様通りに情報を取り出しています。ここら辺は前回の記事をみてください。parseInt(header.slice(8, 12).toString("hex"), 16)ここが曲者ですが、これも仕様です。ヘッダーの8バイト目から12バイト目にWriteUInt32メソッドを使って書き込んでいるので、その数値をstringにする際にそのままの文字列として取り出し、それを16進数として扱い10進数にparseIntしています。

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"), 16)
}

次はここです。ここは簡単です。エントリーの仕様に乗っ取り、ファイルの回数分loopを回してmode, sha1, nameをそれぞれ取り出して、 配列fileInfoに格納しています。情報がどのbyteにあるか、可変長であるentriesの長さとファイル名の取得などは前回の記事でやった通りなので、覚えていない方は前回の記事を参照していただけると幸いです。一つ面白い?点として、parseInt(flag.toString("hex"), 16) & 0b0011111111111111で10進数と2進数でbit演算をしているのですが、自動で結果を10進数に直してくれます。

  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)
  }

最後に先ほど実装したtree生成のコマンドを呼び出し、そのオブジェクトのhashをgenCommitObjectに渡すだけです。

  const treeHash = await genTree(fileInfo)
  genCommitObject(treeHash, message)

まとめ

これでひとまずcommitオブジェクトの生成のコアの全ての実装が終わりました。

最終的なコードは下記になります。

authorとmail addressとtimestampは、それぞれの実行タイミング・環境で異なりますので適宜変更してください。

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> 1600538469 +0900\n` +
    `committer hirokihello <iammyeye1@gmail.com> 1600538469 +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!');
  //   })
  // });

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

  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
}

今最終的にcommit.jsではそのcontentからhashを生成するようにしているので、hashの帰り値がcommitした.git/refs/heads/masterの示す0e95049453fa4d33b5c1ceedb042181fa4af0c40と一致すれば全ての内容が同じで作れていることになります。
実行してみます。

$ node commit.js 'first commit'
7c2a37f7dfc40c8d15455c9e2e1c5d6dad977ae2

あいません。genCommitObjectでcommitオブジェクトを作る際のtimestampが現在時刻を入れるようになっているので、gitコマンドを使って先ほど実際にcommitしたものとあいません。一旦timestampを1600588067で固定します。(それぞれのcommitオブジェクトに記載されているものにしてください)

node commit.js 'first commit'
0e95049453fa4d33b5c1ceedb042181fa4af0c40

treeオブジェクトの内容から作られるhashを含む、commit objectの内容から作られるcommit objectのhashがgit commandと同じものになったのでこれにて実装が正しいことが証明されました。

長い間お読みいただきありがとうございました!

これにて終了です!commitの実装(のコア)を行うことができました。

次回はlogsや、実際に保存するにあたりvalidationなど考慮すべきことを実装していきます。

Discussion