UUUM攻殻機動隊(エンジニアブログ)

UUUMのエンジニアによる技術ブログです

rubyを通してUNIXプロセスを学ぶ

こんにちは、UUUMでエンジニアやっておりますオオハシと申します。

今までプロセスについて書籍やネットの記事の流し読みでなんとなくわかったつもりになっていたのですが
なるほどUnixプロセス - Rubyで学ぶUnixの基礎という書籍では
普段業務で取り扱っているrubyを用いてプロセスを説明されているのが特徴で
プロセスを理解する上での大きな助けとなりました。
以下、学習したことを大まかにまとめました。

全てのプロセスはID(pid)を持っている

p Process.pid

スクリプトファイルの実行やirb、各種shell、バックグラウンドプロセスなど
プログラムの実行毎に異なる値が割り振られる。

forkして子プロセスを作ってみる

fork
p Process.pid

出力される2つのpidの値が異なる
つまり2つの異なるプログラムが並列して実行されている。

親プロセスと子プロセスで処理を分けたい

p fork

親は子のプロセスIDを、子はnilを返り値にとるので
以下のように書ける

if fork
  p "親のプロセスID:#{Process.pid}"
else
  p "子のプロセスID:#{Process.pid}"
end

子プロセスの処理内容を指定したい

ブロックを渡す

fork do
  p "子プロセスのみ評価される"
end

孤児プロセスとは

親が先に終了してしまった子プロセス

fork do
  p "子プロセス開始"
  5.times do |i|
    sleep 1
    puts "#{i}秒経過"
  end
  p "子プロセス終了"
end
abort "親プロセス終了"

親プロセス終了後も子プロセスの処理が続行し、端末から<Ctrl-c>などで停止も出来ない

何故か?

→子プロセスは親プロセスを通じて間接的に端末制御されるため。
したがって親プロセスが先に終了すると子プロセスを端末による制御ができなくなる。

どうやって管理するのか?

子プロセスの終了を待つ

fork do
  p "子プロセス開始"
  5.times do |i|
    sleep 1
    puts "#{i}秒経過"
  end
  p "子プロセス終了"
end
Process.wait # 子が終了するまで親の処理が止まる。親が生きてるため端末制御も可能。
abort "親プロセス終了"

シグナルを送信する

例えば別のプロセスから孤児となったプロセスのpidを指定してシグナルを送ることでプロセス制御が可能。

fork do
  p "別プロセスから kill -INT #{Process.pid} を実行してください"
  p "子プロセス開始"
  5.times do |i|
    puts "#{i * 5}秒経過"
    sleep 5
  end
  p "子プロセス終了"
end
abort "親プロセス終了"

主要なシグナル一覧

f:id:bounce114:20210531074746p:plain

ゾンビプロセスとは

カーネルは終了した子プロセスの情報をキューに格納する。 Process.waitして子プロセスの終了ステータスを取得されないと 終了したはずの子プロセスの情報が取り除かれず、ゾンビプロセスとして取り扱われる。

ゾンビプロセス確認の仕方

pid = fork { sleep 1 }
puts "ps -ho pid,state -p #{pid}  を実行してください"
sleep 10

プロセス間で通信する

パイプ(単方向通信)

reader, writer = IO.pipe
# not opened for writing (IOError)
reader.write("読み込む側からは書き込み出来ない")

親・子のプロセス間通信をする

reader, writer = IO.pipe

fork do
  reader.close

  10.times do |i|
    # 子プロセスから書き込む
    writer.puts "#{i+1}回目の書き込み"
  end
end

writer.close

# 親プロセスで読み込む
while message = reader.gets
  $stdout.puts message
end

ソケット(双方向通信)

使用例

require 'socket'

child_socket, parent_socket = Socket.pair(:UNIX, :DGRAM, 0)
maxlen = 1000

fork do
  parent_socket.close

  2.times do |i|
    # parent_socket.sendされるまで待つ
    instruction = child_socket.recv(maxlen)
    child_socket.send("#{instruction} -> parent #{i+1}回目", 0)
  end
end
child_socket.close

2.times do
  parent_socket.send("parent -> child", 0)
end


2.times do
  # child_socket.sendされるまで待つ
  $stdout.puts parent_socket.recv(maxlen)
end

プロセスグループ・セッションについて

セッションは同一セッションIDを持つプロセスグループを束ねる
プロセスグループは同一グループIDを持つプロセスを束ねる
セッション内でセッションリーダーと呼ばれるひとつのプロセスが制御端末と接続し
セッション内全プロセスに端末から受信したシグナルを伝搬する。

終わりに

本書で解説されていたプロセスに関するごく一部を紹介してきました。
この他デーモンプロセスやシグナルの捕捉、rackその他Rescue, Unicornのプロセス管理など
プロセスに関する事項がrubyによって読みやすい形でまとめられています。
UNIXの基礎であるプロセスの知見を深めておくことは、プログラミング言語を問わず通用する技術であり
今後のエンジニアライフをより良くする上でも大変有用であると考えています。
プロセスについて理解を深めたい方は是非著書を手にとってご覧いただければと思います。

【初】UUUM攻殻機動隊オンラインハッカソンを開催しました!

こんにちは!エンジニアの中村(@chibiprogrammer)です!

去年の12月末にUUUMでオンラインハッカソンを行いました!

ハッカソンの概要

本来であれば半年に1回こんな感じで開発合宿をするのですが・・・

system.blog.uuum.jp

今回は情勢的に厳しいこともあり、 何かコロナ禍の中でも出来るイベントはないだろうかと考え、 リモートでハッカソンならできるのでは?という流れになり、 開催することが決定しました⭐️

今回のハッカソンのテーマは、「今年話題だったもの」

オンラインでの初ハッカソンなので、ハッカソンをやったことのない人も何人かいました。 なので、例えば「あつ森」「コロナ」「密」のように、

わかりやすくて、テーマの幅が広いものは何かと考え、今回は 「今年話題だったもの」というテーマにしました。

タイムスケジュール

f:id:chibiProgrammer:20210120142849p:plain
スケジュール

全体的な流れはこんな感じで、基本的にGoogleMeetを使って進めました。 最後の結果発表のみ、集まりたい人は会社に集まってそれ以外はGoogleMeetで参加する。というような形になりました。

参加チーム一覧

  • アミューズメント練馬
  • しぶえもん
  • レジャーランド六本木
  • ちびび
  • かきき
  • パワーコーダーズ
  • えんたつ

今回は全7チームに参加していただきました☺️

成果物の紹介

【周辺のショップリストをSlack Botから取得するアプリ】

f:id:chibiProgrammer:20210121120148p:plain
パワーコーダーズ

SlackBotでコマンドを打つと、周囲のショップリストを取得するアプリの作成をしていただきました! 最初はコロナの影響もあり、病院検索に限定されていましたが、要望があり他のショップも調べることができるようになっていました。 こちらはAWSとGCPのマルチクラウドで作成されていました!

また、開発中の様子がすごく楽しそうだったので個人的に好きなチームでした🤗

【あつ森チャットアプリ】

f:id:chibiProgrammer:20210120173606p:plain
アミューズメント練馬

今年かなり話題になったあつ森ですが、あつ森にはチャット機能がついており、そのチャット機能を使って他のユーザーと会話ができるのですが、 こちらアミューズメント練馬チームは、「私はPCユーザーだから、Terminalからチャットがしたい!!」というぶっ飛んだ発想であつ森チャットアプリを作ってくださいました。

グレーな感じはありますが、とにかく発想がユニークで面白かったです。

実演ではターミナルからあつ森にコメントを流したり、アクションをしたりして会場が盛り上がっていました!

【キーボード制作】

f:id:chibiProgrammer:20210120173100p:plain
えんたつ

GoogleMeetやZoomを使ってオンラインミーティングがかなり増えてきてると思うんですが、 オンラインミーティングの時、他の人が話している時は自分をミュートに何回もするといちいちミュートボタンを押したりするのはだるい・・ 爆速で切り替えたい・・

という思いを解決するために誰もやっていないキーボードを作成してくれました! 指一本でスムーズにミュートが切り替えれたり、発表では実際に半田付けしている動画なども見せてくれてとても楽しかったです!

【Uberもどき】

f:id:chibiProgrammer:20210120155248p:plain
かきき

3日間でここまで実装できるのか・・すごいなぁと最初に思いました!画面も綺麗でしたし、一連の流れもできていました🤗 サーバーに載せて実際動くところまでできてたことと、データの準備もきちんとしていてとてもよかったと思います! ハッカソンだったので時間が足りなかったところもあったと思うんですけど、ぜひ作り切って欲しいと思った作品の1つでした!

【GoogleMeet × LIVE配信風リアクション】

f:id:chibiProgrammer:20210120173741p:plain
しぶえもん

GoogleMeetの会議中に何も反応がないと寂しい・・みんなミュート・・🥺という寂しい状況を打破するために、 よくLive配信であるボタンを押したら❤️が画面に飛んでいくようなchrome拡張を作ってくださいました! 技術はFirebaseのRealtimeDatabaseを使っており、実演でもきちんと動作していて凄かったです!

このイベントの後も使っている人が何人かいて、実用的でニーズのあるいい作品ができていていいなと思いました!

【CoronaGame】

f:id:chibiProgrammer:20210120173819p:plain
ちびび

病気にかかって死にそうな主人公が3日間だけ元気な時にもどり、その3日間の間に病気にならないように健康的に過ごすという内容ゲームでした。

最近コロナが流行ってきて、在宅が多くなり、ゲームばかりしていたり、運動不足の人が増えてきているのをこのゲームをきっかけに見直してみよう!というコンセプトのゲームで、エンディングまで作ることができたのでよかったと思っています!

【ニコニコ風Youtube watch party】

f:id:chibiProgrammer:20210120174311p:plain
レジャーランド六本木

オンラインでみんなでYoutubeを見て楽しむようなサイトを作ろうとしてくれました。 今回はURLを貼り付けたらYoutubeから動画をとってきて、ニコニコ動画のように動画上にコメントを流すまでをやっていただきました! Vue CLIやVue.jsを使って実装しており、実際にコメントが流れていくのを見ることができて楽しかったです! これからもっといろんな機能をつけて欲しいなと思いました☺️

発表

投票基準

  • コンセプトの着眼点
  • コンセプトの実用性(構想だけでなく、動く)
  • 技術的に面白いか
  • UI/UXの良さ

賞の種類

  • 優秀賞
  • 井原さん賞
  • 中橋さん賞
  • 新谷さん賞

優秀賞は、参加してる人たちの投票で一番評価が高かった人に与えられました。

結果発表

⭐️優秀賞 & 新谷さん賞 しぶえもん f:id:chibiProgrammer:20210120162455p:plain

⭐️中橋さん賞 アミューズメント練馬 f:id:chibiProgrammer:20210120162306j:plain

⭐️井原さん賞 ちびび f:id:chibiProgrammer:20210120162751j:plain

参加した人のコメント

今回のハッカソンではまた違うテーマでハッカソンしてほしい! 成長した!というような声をもらえたので、とても嬉しかったです!

* 初めて使う技術を用いたから成長した。
* キーマッピングは初めて触ったので可能性を感じた。
* 0からvue.jsを用いたアプリを開発した事が無かったのでとても良い経験になった。
* 1ヶ月とか長期で開催できても良いと思いました運営お疲れ様でした!

まとめ

今回初めてのオンラインハッカソンを開催しましたが、通常のハッカソンとはまた違う雰囲気作りするのが難しかったです。 ただ、みなさんオンラインでもかなり盛り上げてくれて、自分が使ったことのない技術を使ったり面白いものをたくさん作ってくれました! 本当にいろんな作品を見ることができて、楽しかったです☺️ この状況がいつまで続くかわかりませんが、今回の経験を活かしてハッカソンやそれ以外にもいろんなイベントを開催しようと 思いました!

プロダクトマネージャーカンファレンス2020に参加しました!

こんにちは。PMチームの @mokkey19930617 です。

2020/10/27にオンラインで開催されたプロダクトマネージャーカンファレンス2020に会社の制度を利用して参加させてもらったので、そのアウトプットを書こうと思います!

内容はUUUMの事業的に関連があるTikTokのPMについてのセッション①と、個人的にPMとして参考になったセッション②の2つについて、それぞれセッションの要約と個人的な学びについてです。

※以下、PMという記載はプロジェクトマネージャーではなく、プロダクトマネージャーを指しています。

セッション①

クリエイターファーストなTikTok PMの仕事

スピーカー

TikTok Japan 久保田アレクサンダー卓磨さん

TikTokのユニークな点

コンテンツのディストリビューションが他のUGCと異なる。フォローワーゼロでもコンテンツがバズる可能性がある。

プロダクトチーム体制

  • Regional PM

    • 各地域のユーザーの体験を最適化する
    • 担当地域に最適化したアルゴリズムをエンジニアと協力して作り上げる
    • 担当地域のグロース
  • Feature PM

    • 全ユーザーの共通機能を中心に担当機能ごとに開発をリードする
    • 主にアプリのクライアント機能実装など全体的なUI/UXの最適化等

各地域で検証実験を行っていき、ベストプラクティスを地域間で横展開することで、グロースを加速する。

UGCプラットフォームのグロースサイクル

  1. コンテンツ制作: クリエイターが作成
  2. 視聴体験: フィードを通じて滞在する
  3. リテンション: 視聴体験で満足すれば、ユーザーが増えていき、コンテンツの種類が多様化していく
  4. コンテンツ制作: ユーザー(クリエイター・視聴者)が増える

キーとなるのはやはりコンテンツ制作なため、徹底してクリエイターファーストで開発を行っている。

しかしグロースサイクルがうまく回らないことがあり、TIkTokではAERフレームワークを使っている。

AERフレームワーク

Attract: クリエイターにそのプラットフォームへ移る動機を作る

フォローワーがいなくてもコンテンツが視聴者に届く仕組み

クリエイターからしても、どのプラットフォームに身をおくか悩むはず、どうやって安心材料を提供するかが問題。

TikTokではフォローしているクリエイターの投稿のみ表示するというフォローベースのフィードでなく、視聴者の興味ベースのフォードにすることで、新しいクリエイターでも平等にチャンスを与える仕組みになっている。 その仕組みによりフォローへの依存をなくし、視聴者に一番面白いコンテンツが届けることができる。

とはいえ、 フォローしているクリエイターの投稿は表示される。

なので、フォローベースと視聴者の興味ベースのフォードどちらかでもなく、いいとこ取りをしているという感じ。

Enable: クリエイターのポテンシャルの引き出し、活性化する

ネタ(アイデア)の民主化でコンテンツ制作のハードルを下げる
  • 従来のコンテンツ制作の暗黙のルール: 一人で作る文化 完全なオリジナリティが求められる

  • コンテンツ制作の新しいルール: みんなで作る文化 ネタ、型をマネしやすい、MEME文化

※MEME: ネット上でユーザーがマネとアレンジを重ねて楽しみながら広がっていくコンテンツ

TikTokでは様々なツールを活用することで、みんなで作る文化、MEME文化を実現しコンテンツ制作の敷居を下げている。

Retain: クリエイターが長期に渡って活動でき、報われる環境を作る

ファンエンゲージメントとマネタイズしやすい仕組みを設ける
  • ファンエンゲージメント 動画で返信できる機能、ライブ配信機能をすることでファンを集める

  • マネタイズ ライブ配信でギフティング、クリエイターマーケットプレイス(ブランドとクリエイターのコラボ(プラットフォーム)

まとめ

クリエイターファーストの仕組みづくり(TikTokではAER活用)をすることで、グロースを最適化する。

個人的な学び・所感とか

会社・部門内でベストプラクティスを横展開はやった方がいい

プロダクトチーム体制のところで担当地域でのベストプラクティスを会社で横展開して、他の地域に応用するという仕組みは参考になるため、PMに限らず部署内でこう言った知見の共有は積極的に行った方が良いと感じた。

MEME文化は使えそう

MEME文化を取り入れ、動画のネタや型をテンプレ化してクリエイターに提供する仕組みは、UUUMが提供しているYouTuber向けのサービス: CREAS などのクリエイター向けの各種サービスを通じで実現可能であり、プロダクトの価値を高めることにつながるのではと思う。

フォローワー0でも評価されるコンテンツディストリビューション

また、コンテンツディストリビュートの方法が他のUGCと異なり、興味・関心ベースでフィードが表示される点や、そこにクリエイター全員に平等な機会を与えるという背景がある点も興味深かった。 UUUMが最近買収したREC.などでは応用する機会がありそう。将来的にUGCプロダクトに関わることがあれば、是非参考にしたと思えるセッションだった。

セッション②

いま、PMに求められる本質的な力 ~満足度No.1プロダクトを生む組織、3つの条件~

スピーカー

エン・ジャパン株式会社 岡田康豊さん

PMの成長に重要なこと2つ

  • 土台となるテクニカルスキル
  • 本質的なスキル

本質的なスキルを伸ばすには組織が重要なファクターである。

エン・ジャパンの企画開発部門にあった3つの課題

  • あいつぐPM・エンジニアの退職
  • 低いテクニカルスキル
  • 外部パートナー1社の開発体制

そこでPMに必要なテクニカルスキルを6つの領域に分けた PM SkillChart HEX(ヘクス)を作成した。

チャート化して俯瞰することで現状把握し、キャリアを考え、やることを明確化

こちらのチャートを組織として打ち出し、PMの自己研鑽を組織的に促進することで、各PMの現状把握・必要なスキル・キャリアが明確になり、スキルアップをしていった。PJマネージメント能力も高まった。(ここ数年でHEXスコアが150%UP)

e.g. 自己研鑽して、統計スキルを身に付けデータ分析能力を磨いたり、プログラミングを学び自ら開発に参加したり、デザインから派生してフロントエンド開発を行ったり。

テクニカルスキルは重要だが、それだけでは不十分

テクニカルスキルはPMとしてとても重要だが、それだけでは不十分で、全てうまくいくわけではない。 加えて、テクニカルスキルの寿命は短く進化も早い。

必要なのは本質的な力。 また、その本質的な力を鍛える場がその組織にあるかどうかがとても重要。

本質的な力を養うことができる組織が押さえている3つのポイント

1. 抽象スキルの共通認識がある

PMには具体的なテクニカルスキルを持った上で、物事を抽象的に捉えて他者とコミュニケートする能力が必要。

Katz ModelでいうテクニカルスキルはHEXに定義されているスキル。 PMとしてはKatz Modelでいうヒューマンスキルやコンセプチュアルスキルがより重要である。

※Katz Model: 自分の職位が上がれば上がるほど、ヒューマンスキルやコンセプチュアルスキルが重要であるというキャリアの指針を示したモデル。

それらの抽象スキルを組織として定義し、共通認識とすることが重要。

e.g. エンジャパンのヒューマンスキルとコンセプチュアルスキルの抽象スキルのフレームワーク

  • 考え方

    • 自己変革性
    • 目標必達性
    • 多様受容性
    • 周辺変革性
    • 主観正義牲
    • 自発利他性
    • Enjoy-Thinking
  • 能力

    • 対人関係力
    • 発想力
    • 論理思考力
    • 組織貢献力

上記の項目にそれぞれ定義があり、例えば自己変革性だと「自己の現状に満足せず、学習や研鑽に努め、自分自身の改善、革新に挑戦している」と定義されている。

ただ、これらのスキルは独学で学ぶことは難しいため、共通見解を持った上で相互干渉を積極的に行うことが必要。

2. 答えより問いを重視する

PMというのはプロダクト価値を提供するマーケットに対して問いを投げかけ続けなければならない。 向き合うべきは上司ではなく、ユーザーやマーケットである。

達成したい事象に対して、どれだけ多く、確度の高い問いを投げかけられるかが重要。 上司は答えを聞く相手ではなく、共に問いを探求する相談相手。

3. その組織がフィードバックループを回し続ける

大事なのは質より量。 e.g. エンジャパンでは毎日個人がデイリーレポートを書いている。また毎週水曜に朝会を行っており、毎回そこで誰かが自分の成果を共有する15分のピッチを行っている。

自身が他者に晒される機会を作って、フィードバックをもらいやすい環境を整えることで、承認・称賛に繋がり、個人の成長を促すことができる。

+X

私たちPMは、なぜ、何のために働くのか?と言った、仕事の価値観が結局重要。 最終的には個々のそう言った価値観がプロダクトに反映されていく。

Inner Calling的な価値観。 ※Inner Callingとは動物的な本能である利己と、相手のことを思いやる利他の両面を持っていることらしい。

https://corp.en-japan.com/profile/message.html

まとめ(岡田さんからのメッセージ)

プロダクトを通じて社会を変革できるPMこそが日本をよくすることができる人たちだと思うので頑張りましょう。

個人的な学び・所感とか

HEX(ヘクス)わかりやすい

まず、従来のプロダクトマネジメントトライアングルとエンジャパンのPM SkillChart HEX(ヘクス)を比べると大分わかりやすく、僕のような駆け出しPMとしては理解しやすく感じた。

実際に自分を評価してみたら全く足りないチャートがあって悲しかったので、頑張ります。

UUUMでもこう言ったチャートを使って能力評価を行ってもいいかもしれない。前回評価の時からチャートが広がってたらなんか楽しそう。

本質的を力を養う組織が押さえている3つのポイントについて

マネージメント層の方々、こちらの整備よろしくお願いします🙇笑‍

個人的には答えより問いを重視するが結構胸に刺さるポイントだった。 正直に振り返ると、仕様や機能を検討する際に、「まーあの人がああ言ってて意見変えなそうだし、正直微妙だけどもうこの仕様でいいか」という感じで決めてしまった経験がある。 やはり、現状で何がベストなのかを考え、PMとして納得した上で決断しないといけないと思った。

また、これはどちらかというと組織というより、個人の意識の問題な気もするので、PMであれば誰でも絶対意識した方がいいと感じた。

抽象スキル

めちゃめちゃどうでも話ですが、 岡田さんがPMに求められる抽象スキルを「具体的なテクニカルスキルを持った上で、物事を抽象的に捉えて他者とコミュニケートする能力」 とおっしゃっていて、表現自体が過不足なくめちゃきれいな表現だなーと思い感動しました。 こんな適切な言葉を使えるPMになりたいと思いました。笑

エンジャパン、岡田さんの資料かなり有益、ありがたい

岡田さんのnoteをはじめ、エンジャパンからいろいろな資料が無料公開されているのがありがたい。

岡田さん note

エンジャパン これからの時代に必要な入社後活躍力(仕事に関する価値観や本質的な力についてまとまっています)

終わりに

以上でプロダクトマネージャーカンファレンス2020から学んだこととかのアウトプットになります。 一日、会社のお金で学ばせていただき、ありがとうございました!

addから始めるgit入門 (2)~gitをjsで実装してみよう commit編~

どうもこんにちは!

インターンの@hirokihelloです!

前回書いたこちらの記事が予想外に好評で非常に嬉しいです!

system.blog.uuum.jp

というわけで今日も前回の記事に引き続き、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

引用 git-scm.com

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

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

  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周りは前回の記事を参照してくださいね。

system.blog.uuum.jp

まずは先ほどみた、

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オブジェクトに関しては詳細はこの記事を読み込むと良いです。

git-scm.com

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

  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>...

引用 www.dulwich.io

必要なのは、 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 getFileInfoFromIndex () {
  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)
}

getFileInfoFromIndex()

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

この記事によればcommitオブジェクトは下記のフォーマットとなっています。 www.dulwich.io

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ファイルの仕様については下記の前回の記事を参照してください。

system.blog.uuum.jp

async function getFileInfoFromIndex () {
  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 getFileInfoFromIndex () {
  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 getFileInfoFromIndex () {
  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)
}

getFileInfoFromIndex()

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など考慮すべきことを実装していきます。

addから始めるgit入門~gitをjsで実装してみよう~

こんにちは〜!! インターンの@hirokihelloです!!!

季節は早いものでuuumで働き出して、そしてエンジニアとして1年半が経とうとしています。早いですね〜〜〜。

開発していると当たり前に使っているgitですが、みなさんgitについてきちんと理解していますでしょうか?

先日自分のtwitterのTLにgitを実装してみたとのツイートが流れてきて、そういえば使い方はわかるけどどんな仕組みかちゃんと知らないなあと気づきました。

そんなわけで今日はgitのaddコマンドって?仕様は?実装は?などまとめてみます!

  1. addコマンドの挙動を探る
  2. javascriptでaddコマンド(正確にはコアの部分)を実装

の流れになっています。

gitの使い方の記事ではないのでご注意を!

今回は下記の環境で実装・検証を行っていきます。

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

最終的なコードはここにあります!

github.com

addコマンドって何してるの?

docを読んでみる

まずaddコマンドは実際なにを行うコマンドなのでしょうか。

c言語のgit本体の実装を読むのが早いのですが、 めんどくさいので 今回は公式のサイトを取り上げてみましょう。

This command updates the index using the current content found in the working tree, to prepare the content staged for the next commit. It typically adds the current content of existing paths as a whole, but with some options it can also be used to add content with only part of the changes made to the working tree files applied, or remove paths that do not exist in the working tree anymore.

The "index" holds a snapshot of the content of the working tree, and it is this snapshot that is taken as the contents of the next commit. Thus after making any changes to the working tree, and before running the commit command, you must use the add command to add any new or modified files to the index.

This command can be performed multiple times before a commit. It only adds the content of the specified file(s) at the time the add command is run; if you want subsequent changes included in the next commit, then you must run git add again to add the new content to the index.

引用 git-scm.com

ふーむなるほど。(わからん)

注目して欲しいのは、以下の2点です。

  • This command updates the index using the current content found in the working tree
  • The "index" holds a snapshot of the content of the working tree, and it is this snapshot that is taken as the contents of the next commit

addコマンドがindexファイルを更新するコマンドであること、indexファイルが次のコミット用のファイルのスナップショットであることが述べられています。

挙動を確認する

実際にどんな風にファイルが書き換わっているのかみてみましょう。

$ mkdir test_git
$ cd test_git
$ git init
$ ls -la .git/
total 24
drwxr-xr-x   9 hirokihello  staff  288  9 15 20:45 ./
drwxr-xr-x  10 hirokihello  staff  320  9 15 20:45 ../
-rw-r--r--   1 hirokihello  staff   23  9 15 20:45 HEAD
-rw-r--r--   1 hirokihello  staff  137  9 15 20:45 config
-rw-r--r--   1 hirokihello  staff   73  9 15 20:45 description
drwxr-xr-x  13 hirokihello  staff  416  9 15 20:45 hooks/
drwxr-xr-x   3 hirokihello  staff   96  9 15 20:45 info/
drwxr-xr-x   4 hirokihello  staff  128  9 15 20:45 objects/
drwxr-xr-x   4 hirokihello  staff  128  9 15 20:45 refs/

indexというファイルはここでは見当たりませんね。

適当なファイルを作成してみます。

$ cat <<EOF > sample.js
console.log("hoge");
console.log("fuga");
EOF
$ node sample.js
hoge
fuga

この時点では何も追加されませんし更新は行われません。

$ ls -la .git/
total 24
drwxr-xr-x   9 hirokihello  staff  288  9 15 20:45 ./
drwxr-xr-x  10 hirokihello  staff  320  9 15 20:45 ../
-rw-r--r--   1 hirokihello  staff   23  9 15 20:45 HEAD
-rw-r--r--   1 hirokihello  staff  137  9 15 20:45 config
-rw-r--r--   1 hirokihello  staff   73  9 15 20:45 description
drwxr-xr-x  13 hirokihello  staff  416  9 15 20:45 hooks/
drwxr-xr-x   3 hirokihello  staff   96  9 15 20:45 info/
drwxr-xr-x   4 hirokihello  staff  128  9 15 20:45 objects/
drwxr-xr-x   4 hirokihello  staff  128  9 15 20:45 refs/

addコマンドを打ってみます。

$ git add sample.js

先ほどの.gitディレクトリをみてみましょう。

$ ls -la ./.git
total 32
drwxr-xr-x  10 hirokihello  staff  320  9 15 20:57 .
drwxr-xr-x  11 hirokihello  staff  352  9 15 20:55 ..
-rw-r--r--   1 hirokihello  staff   23  9 15 20:45 HEAD
-rw-r--r--   1 hirokihello  staff  137  9 15 20:45 config
-rw-r--r--   1 hirokihello  staff   73  9 15 20:45 description
drwxr-xr-x  13 hirokihello  staff  416  9 15 20:45 hooks
-rw-r--r--   1 hirokihello  staff  104  9 15 20:57 index
drwxr-xr-x   3 hirokihello  staff   96  9 15 20:45 info
drwxr-xr-x   5 hirokihello  staff  160  9 15 20:57 objects
drwxr-xr-x   4 hirokihello  staff  128  9 15 20:45 refs

indexファイルができました!またobjectsディレクトリも更新されていますね。 中身をみてみましょう。

$ cat ./.git/index
DIRC_`��#�_`��#���;���*{���

読み込めませんね。 このファイルを読み込む、 ls-filesというgitコマンドが公式で用意されているので使ってみます。

$ git ls-files --stage
100644 7b96e6fb0a0744f5d01bb735f1622f275b440d85 0       sample.js

謎の文字列100644, 7b96e6fb0a0744f5d01bb735f1622f275b440d85,そして数値の0、そして先ほど作成してaddしたsample.jsが見えます。

更新のあった.git/objectsディレクトリもみてみしょう。

$ ls -la .git/objects/
total 0
drwxr-xr-x   5 hirokihello  staff  160  9 15 20:57 .
drwxr-xr-x  10 hirokihello  staff  320  9 15 20:57 ..
drwxr-xr-x   3 hirokihello  staff   96  9 15 20:57 7b
drwxr-xr-x   2 hirokihello  staff   64  9 15 20:45 info
drwxr-xr-x   2 hirokihello  staff   64  9 15 20:45 pack

20:57(git addを行った時間)に7bが追加されていますね。7bの中身をみてみましょう。

bash-3.2$ ls -la .git/objects/7b/
total 8
drwxr-xr-x  3 hirokihello  staff   96  9 15 20:57 .
drwxr-xr-x  5 hirokihello  staff  160  9 15 20:57 ..
-r--r--r--  1 hirokihello  staff   45  9 15 20:57 96e6fb0a0744f5d01bb735f1622f275b440d85

むむ。ディレクトリ名の7bと中身の96e6fb0a0744f5d01bb735f1622f275b440d85を足すと、先ほどのgit ls-filesで出た結果の文字列と一致します。

$ git ls-files --stage
100644 7b96e6fb0a0744f5d01bb735f1622f275b440d85 0       sample.js

それでは.git/objects/7b/96e6fb0a0744f5d01bb735f1622f275b440d85をみてみましょう。

$ cat .git/objects/7b/96e6fb0a0744f5d01bb735f1622f275b440d85
xK��OR01bH��+��I���O�P��OOUҴ�BL+MO      ��

文字化けしていますね。

これを見るコマンドがgitには用意されています。

$ git cat-file -p 7b96e6fb0a0744f5d01bb735f1622f275b440d85
console.log("hoge");
console.log("fuga");

先ほど追加した、sample.jsがこの7b96e6fb0a0744f5d01bb735f1622f275b440d85に格納されているということがわかりました。

ここでsample.jsを変更してaddするとどうなるでしょうか。

$ echo 'console.log("hogefuga");' >> sample.js
$ git add sample.js
$ git ls-files --stage
100644 a9e94074dc086aec661591147de3e821fa87fb36 0       sample.js

hashが変わっていますね。

$ ls -la .git/objects/
total 0
drwxr-xr-x   6 hirokihello  staff  192  9 16 00:20 ./
drwxr-xr-x  10 hirokihello  staff  320  9 16 00:20 ../
drwxr-xr-x   3 hirokihello  staff   96  9 15 20:57 7b/
drwxr-xr-x   3 hirokihello  staff   96  9 16 00:20 ed/
drwxr-xr-x   2 hirokihello  staff   64  9 15 20:45 info/
drwxr-xr-x   2 hirokihello  staff   64  9 15 20:45 pack/

$ ls -la .git/objects/7b
-r--r--r--  1 hirokihello  staff  45  9 15 20:57 .git/objects/7b/96e6fb0a0744f5d01bb735f1622f275b440d85

$ git cat-file -p 7b96e6fb0a0744f5d01bb735f1622f275b440d85
console.log("hoge");
console.log("fuga");

$ ls -la .git/objects/a9/e94074dc086aec661591147de3e821fa87fb36
-r--r--r--  1 hirokihello  staff  51  9 16 00:20 .git/objects/a9/e94074dc086aec661591147de3e821fa87fb36

$ git cat-file -p a9e94074dc086aec661591147de3e821fa87fb36
console.log("hoge");
console.log("fuga");
console.log("hogefuga");

先ほど作られたhashはそのままで、新しくaddした時点のファイル情報を持ったobjectが作られました。

このようにgitでは、addコマンドを打つごとにobjectsにそのファイルのコピーが作られ、indexがそのobjects以下に作られたものを示すように更新されることがわかりました。

今回はこのobjectsの生成とindexの作成・更新部分のコアを実装することにします。(treeなどディレクトリ構造の保存については範囲外とします)

addコマンドを実装してみる

それでは具体的にどのように実装すればいいのでしょうか。

具体的なindexとobjectsに作られるファイルの仕様は下記に具体的に記述されています。

git-scm.com

github.com

今回は通常のファイル(blobと呼ばれています)で考えます。blobではないものは、シンボリックリンクファイルなどが該当しますが、これをgitに保存するのは通常のweb開発に置いてあまり多くないと思われますので今回はblob一本でいきます。

objects作成部分の実装

まずはじめに、objectsを作成できるようにadd.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 porcelainAdd () {
  if (process.argv.length <= 2) return console.log("error no file was added")
  await files.forEach(file => add(file))
}

porcelainAdd()

add.jsのporcelainAdd()では、引数のファイルのオブジェクトをadd関数を使って作成することができます。

使い方としては、

$ node add.js test.js

のように使います。

ここからは具体的なコードの解説をします。

まずobjectの仕様についてですが、

  1. ヘッダとファイルの中身からsha-1チェックサムの生成
  2. zlibを用いてこの新しいコンテンツを圧縮
  3. 1で求めたSHA-1ハッシュ値の最初の2文字をディレクトリ名で、残りの38文字はそのディレクトリ内のファイル名として2で求めた圧縮したコンテンツを保存

のようになっています。

ヘッダとファイルの中身からsha-1チェックサムの生成

まずヘッダは、 1. オブジェクトのタイプを表す文字列(blob) 2. スペースに続いてコンテンツのサイズ 3. 最後にヌルバイト

の三つからなっています。

sample.jsで考えてみます。sample.jsはこのようになっています。

console.log("hoge");
console.log("fuga");
console.log("hogefuga");

このファイルのコンテンツのサイズは

const fs = require('fs').promises;
async function add () {
  const file = await fs.readFile("sample.js")
  const content = file.toString()
  return content.length
}

で求めることができます。 よってheader部分の作成コードは

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

async function add () {
  const file = await fs.readFile("sample.js")
  const content = file.toString()
  const header=`blob ${content.length}\0`
}

となります。

ここまできたら、ヘッダーとファイルの中身を結合させます。ファイルの中身は先ほど読み込んだconst content = file.toString()にあるので、足すだけで大丈夫です。

  const store = header + content;

sha-1ハッシュを求めるには下記のようにします。

  const crypto = require('crypto');

  const shasum = crypto.createHash('sha1');
  shasum.update(VARIABLE_YOU_NEED_TO_HASH);
  const sha1 = shasum.digest('hex')

よってここまでの最終的なコードは下記のようになります。

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

async function add () {
  const file = await fs.readFile("sample.js")
  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')
}

圧縮

圧縮ですが、zlibを用いてヘッダーとファイルの中身を足したものを圧縮します。 nodejsではzlibライブラリが標準で提供されています。

このように使います。

const zlib = require('zlib');

zlib.deflate(VARIABLE_YOU_NEED_DEFLATE, function (err, result) {})

よってこのようになります。

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

async function add () {
  const file = await fs.readFile("sample.js")
  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')

  zlib.deflate(store, async function (err, result) {
    // 具体的な処理
});
}

.git/objectsへの書き込み

ここでは先ほどdeflateした結果を、最初に求めたsha-1hash値の最初の2文字をディレクトリ名、残りの38文字をファイル名にして保存するだけです。

zlibの処理から書きます。

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

簡単ですね。

最終的にはsample.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 porcelainAdd () {
  if (process.argv.length <= 2) return console.log("error no file was added")
  await files.forEach(file => add(file))
}

porcelainAdd()

実際に同じものができているか検証してみましょう。add.jsを呼び出す時に、内部に関数の実行を忘れずに。

先ほどaddした時に作られたobjectsのhash値はa9e94074dc086aec661591147de3e821fa87fb36でした。同じhashになれば同じheader, contentsとして保存されており、cat-fileで中身が見れればきちんとdeflateができているということになります。

$ rm -rf .git
$ git init
$ node add.js sample.js
$ ls -la .git/objects/a9
-rw-r--r--  1 hirokihello  staff  50  9 16 02:02 .git/objects/a9/e94074dc086aec661591147de3e821fa87fb36
$ git cat-file -p a9e94074dc086aec661591147de3e821fa87fb36
console.log("hoge");
console.log("fuga");
console.log("hogefuga");

うまくいきましたね。これでobjectsを保存することができました。

index更新部分の実装

さて最後にして今回の山場です。

まずこいつらがどのようになっているのかについてみてみましょう。

.git/indexの中身

先ほど.git/indexを確認した時はバイナリファイルだったので、hexdumpしてみることにします。これは皆さんの環境で実行結果が異なります。

$ hexdump -C .git/index | head -n 50
00000000  44 49 52 43 00 00 00 02  00 00 00 01 5f 61 c1 fd  |DIRC........_a..|
00000010  08 f1   c6 d9  5f  61  c1 fd   08 f1 c6 d9 01 00 00 04  |...._a..........|
00000020  05 d5 ea 3b 00 00 81  a4  00 00 01 f5 00 00 00 14  |...;............|
00000030  00 00 00 43 a9 e9 40 74  dc 08 6a ec 66 15 91 14  |...C..@t..j.f...|
00000040  7d e3 e8 21   fa 87  fb  36 00 09 73 61 6d 70 6c 65  |}..!...6..sample|
00000050  2e 6a 73 00 79 e5 e8 a6   c3 81 2e 7f 61 20 cc 5a  |.js.y.......a .Z|
00000060  0f 15 b4 ae 37 ec 52 ec                           |....7.R.|
00000068

DIRCという文字列、sample.jsという文字列があることがわかります。

このindexについての仕様はgit公式のgithubにあります。

github.com

indexは大きく三つに分かれます。

  1. ヘッダー(12byte)
  2. エントリー(可変)
  3. sha-1チェックサム(20byte)

今回の例でいうと、 ヘッダー

44 49 52 43 00 00 00 02  00 00 00 01

エントリー

                                     5f 61 c1 fd
08 f1 c6 d9 5f 61 c1 fd  08 f1 c6 d9 01 00 00 04
05 d5 ea 3b 00 00 81 a4  00 00 01 f5 00 00 00 14
00 00 00 43 a9 e9 40 74  dc 08 6a ec 66 15 91 14
7d e3 e8 21 fa 87 fb 36  00 09 73 61 6d 70 6c 65
2e 6a 73 00

sha-1チェックサム

             a6  c3 81 2e 7f 61 20 cc 5a
0f 15 b4 ae 37 ec 52 ec

となります。

この最終的な実装は先ほど実装したobjectsの実装と合わせて下記となります。

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

ヘッダー

ヘッダー部分の仕様は下記のようになっています。

  1. 4-byte DIRCの文字列
  2. 4-byte バージョン(今回は2)
  3. 32-bit entriesの数

これだけです。

node標準のbufferクラスはhexバイナリを扱うことができるので、今回はbufferクラスで実装していきます。

実装はこのようになります。

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

ヘッダー用の12byteをBuffer.allocで確保します。fileNumは今回は一つとしますが、可変にしても構いません。

  const header = Buffer.alloc(12);
  const fileNum = files.length

bufferクラスのインスタンスのwriteメソッドとwriteUInt32BEで、それぞれ1バイト目から4byte, 4byte目からバージョンの2を4byte分(32bit=4byte)、8バイト目からファイル数を書き込みます。

  header.write('DIRC', 0);
  header.writeUInt32BE(2, 4);
  header.writeUInt32BE(fileNum, 8);

先ほど見たヘッダー部分のバイナリと一致するか確認しましょう。

先ほど見たヘッダー

44 49 52 43 00 00 00 02  00 00 00 01

ここまでのコード

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

updateIndex(process.argv[2])
$ node add.js sample.js
<Buffer 44 49 52 43 00 00 00 02 00 00 00 01>

一致することがわかります。

エントリー部分。

エントリー部分は下記のようになっています。

  - 32-bit ctime(stat(2) data)
  - 32-bit ctime nanosecond fractions(stat(2) data)
  - 32-bit mtime seconds(stat(2) data)
  - 32-bit mtime nanosecond fractions(stat(2) data)
  - 32-bit dev(stat(2) data)
  - 32-bit ino(stat(2) data)
  - 32-bit mode, split into (high to low bits)
    - 4-bit object type valid values in binary are 1000 (regular file), 1010 (symbolic link) and 1110 (gitlink)
    - 3-bit unused
    - 9-bit unix permission.
  - 32-bit uid(stat(2) data)
  - 32-bit gid(stat(2) data)
  - 32-bit file size(stat(2) data)
  - 160-bit SHA-1 for the represented object
  - A 16-bit 'flags' field split into (high to low bits)
    - 1-bit assume-valid flag
    - 1-bit extended flag (must be zero in version 2)
    - 2-bit stage (during merge)
    - 12-bit name length
  -   Entry path name
  -  1-8 nul bytes

基本的には、c言語で用意されているstatという構造体の通りに実装すれば大丈夫です。

nodeでは標準で、fsライブラリが提供されており、fs.statでファイル情報を参照することができます。

気になるのは、色々書いているmodeですが通常ファイルを今回は考えるのでfs.stat.modeをそのままstatの結果を入力すれば大丈夫です。

実装はこのようになります。

ファイルpathが複数渡されても良いように、arrayを受け取るようにしています。

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)
    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;
  shasum.update(store);
  const sha1 = shasum.digest('hex')

  return sha1
}

ここではbigintオプションを渡しctimeなどのnano sec fractionsを取得できるようにします。

    const statInfo = await fs.stat(filePath, {bigint: true})

ここでstatのデータを取得し、加工しています。注意として、ctimeなどはm秒以下を切り捨て、それぞれ4byteに収める必要がある部分です。(デフォルトだとm秒以下もfs.stat.ctimeで取得されますが4byteに収まらないので切り捨て、m秒以下はnano sec fractionsで表現します)

    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)

statで取得したデータを4byteづつ書き込んでいきます。

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

その後先ほどobjectsを生成するときの方法と同じで算出したsha1 hashをそのままhexにします。 例えば、先ほど作成したsample.jsのobjectsの.git/objects/a9/e94074dc086aec661591147de3e821fa87fb36ですが、

エントリーのhexdumpに

                                     5f 60 db b7
08 f1 c6 d9  5f  61  c1 fd   08 f1 c6 d9 01 00 00 04
05 d5 ea 3b 00 00 81 a4  00 00 01 f5 00 00 00 14
00 00 00 43 a9 e9 40 74  dc 08 6a ec 66 15 91 14
7d e3 e8 21  fa  87  fb  36  00 09 73 61 6d 70 6c 65
2e 6a 73 00

hash名がそのままhexに変換されているのがわかると思います。

a6  c3 81 2e 7f 61 20 cc 5a
0f 15 b4 ae 37 ec 52 ec

コードはこのようになります。先ほどとほとんど同じなので解説はほとんどいらないでしょう。

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

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

  return sha1
}

Buffer.fromにhexオプションを渡すことで、文字列をそのままhexにしています。(ex. "aa" => )

Buffer.from(sha1String, 'hex')

あと少しです。

フラグを実装します。フラグはbit単位で指定がありbit演算が必要です。

    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)
    flag.writeUInt16BE(flagRes)

これで最後です。

file名をbufferにして、今まで作ったバッファーと合わせた全体のエントリーの長さを計算します。 8の倍数になるように、null byteでパディングを行います。(8の倍数の場合、8byteのnull byteを足す。)

    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

最後に全てのbufferをくっつけてreturnすれば完了です。

sha-1チェックサム

ここでは、最終的にヘッダーと先ほど作ったエントリーを組み合わせてsha-1hashを作ります。そのhashをそのままhexにして、それを保存すれば終了です。

ここでは全体としてconcatメソッドを使ってbufferを結合しています。

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

完成

それでは実際に動かしてみましょう。

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

コマンドを実行してみます。

$ rm -rf .git
$ git init
$ node add.js sample.js 
$ git ls-files --stage
100644 a9e94074dc086aec661591147de3e821fa87fb36 0       sample.js
$ git cat-file -p a9e94074dc086aec661591147de3e821fa87fb36
console.log("hoge");
console.log("fuga");
console.log("hogefuga");
$ hexdump -C .git/index | head -n 50
00000000  44 49 52 43 00 00 00 02  00 00 00 01 5f 61 c1 fd  |DIRC........_a..|
00000010  08 f1 c6 d9 5f 61 c1 fd        08 f1 c6 d9 01 00 00 04  |...._a..........|
00000020  05 d5 ea 3b 00 00 81 a4    00 00 01 f5 00 00 00 14  |...;............|
00000030  00 00 00 43 a9 e9 40 74   dc 08 6a ec 66 15 91 14  |...C..@t..j.f...|
00000040  7d e3 e8 21 fa 87 fb 36      00 09 73 61 6d 70 6c 65  |}..!...6..sample|
00000050  2e 6a 73 00 79 e5 e8 a6    c3 81 2e 7f 61 20 cc 5a  |.js.y.......a .Z|
00000060  0f 15 b4 ae 37 ec 52 ec                           |....7.R.|
00000068

gitコマンドでaddするのと全く同じようにindex, objectsファイルを作れました!

これで終わりです!

今後はバリデーション部分やコミットの部分も作っていきたいですね!

ここまで読んでくださりありがとうございました!

reference

github.com

パケットキャプチャに入門しました

システムユニットの5n4wasa6です。

UUUMではゲームの運用も行なっており、最近アプリのNW対応をしていたエンジニアがおり、普段業務で、触ることのないパケットキャプチャの勉強をし他ので綴る。

※キャッシュ(CDN)対応をしていたので、パケチャは関係ないのだが...

UUUMには、福利厚生の一環でエンタメ費として10,000円まで支給される制度があり、攻殻機動隊員は、主に技術書を購入している(と思う)。

業務では、なかなか触れない領域の知識を身に着けることも非常に大事であるため、この制度を利用してインプットをしている。

詳しくはこちら

はじめに

私は、前々職でキャリアの法人営業をしており、

800程拠点のある運送業様のNW(主にWAN)を担当し、NWエンジニアと毎週謝りに手厚いサービスを提供していた。

一番記憶に残っているのは、3週連続土曜日に同拠点同区間で障害がおきたことで、原因は小動物(ネズミ)によるケーブル断線(地下埋設)。

解決策は、ケーブルに唐辛子(カプサイシン)を練りこんだカバーをおおうことで、障害はおさまった(笑)

マスタリングTCP/IPは数回読んだが、パケットキャプチャを使ったことがほぼなかったの今回はこちらを購入して実際にパケットキャプチャしてみた。

パケットキャプチャの教科書

パケチャ本まとめ (第3章まで)

第1章 パケットキャプチャの流れ

パケットキャプチャとは…

  • パケット => NWを介してやりとりされたデータ(小包)
  • キャプチャ => 取得する、捕捉する ex)画面キャプチャ

どんな時にパケットキャプチャするか?

[インフラシステム構築の6フェーズ]

①要件定義 -> ②基本設計 -> ③詳細設計 -> ④構築 -> ⑤試験 -> ⑥運用管理

  • パケットキャプチャが活躍するフェーズ
    • 試験フェーズ => 単体試験、正常試験、障害(冗長化)試験など各試験を実施するフェーズ

      • 試験にパスしなかった場合に、失敗原因を追求
      • 試験結果に関わらず、お客様にエビデンス提出することも有
    • 運用管理フェーズ => サービスインしたサービスを継続的かつ安定的に維持する

      • 設定変更
      • トラブル対応 ※パケットキャプチャが役立つのがこちら

パケットキャプチャツール

  • Wireshark

  • tcpdump → CLIでしか使用できない環境で利用 → パケットキャプチャした後に、SCPやFTPで移動して、Wireshark環境のあるPCで解析することもある)

  • Micsoft Message Analyzer → 本番商用環境等で、OSSを安易にインストールできない環境で利用 → tcpdumpと同様、キャプチャしたデータをWiresharkで解析することも可能

パケットキャプチャ手法の検討

  • パケットキャプチャ端末自身がやりとりしているパケットをキャプチャする
    • パケットキャプチャツールは、パケットキャプチャ端末自身が持っているNICを通過するパケットをソフトウェア的にインターセプト(傍受、盗み見)してキャプチャする。
    • パケットキャプチャの処理不可は気を付ける点であり、フィルタ条件を指定することで対象のパケットを絞り込むなどして、処理負荷の軽減を図る。
  • パケットキャプチャ端末とは別の端末がやりとりしているパケットをキャプチャする
    • この場合、自分のNICにパケットを流し込むためにの仕掛けが必要 リピータハブミラーポート(圧倒的にこちらが多い)

    リピータハブ… 受け取ったパケットのコピーを、そのままその他全てのポートに転送するネットワーク機器。(バカハブ)

    ミラーポート… 指定したポートでやりとりしているパケットを別のポートにリアルタイムにコピー。(SPAN)

最近では、ミラーポート機能を備えたスイッチが安価に入手できるため、ミラーポートが圧倒的に多いようです。 コストにかなりシビアで機能を求めていない企業様はまだ、バカハブを使っていると思われる。(数年前は結構使っている企業あった)

第2章 Wiresharkの使い方

Wiresharkのインストール https://www.wireshark.org/#download

wiresharkの見方や便利機能はこちらの記事を参考にしました。

物理アドレス解決(MAC)ネットワークアドレス解決(IP)の設定をしておくと、直感的でわかりやすい。

こんな感じですね。

【設定前】 設定前

【設定後】 設定後

第3章 Layer2プロトコル

  • レイヤー2プロトコル… 現代のNWで使用されているのは、Ethernet(有線LAN)とIEEE802.11(無線LAN)のどちらか

  • Ethernetプロトコル Ethernetネットワークを流れるパケットのことをEthernetフレームという

  • Ethernet Ⅱのフレームワークフォーマット

プリアンブル,宛先/送信元MACアドレス,タイプ,Ethernetペイロード,FCSの5つのフィールドで構成。 宛先/送信元MACアドレス,タイプを合わせてEthernetヘッダーと言う。

プリアンブル 「これからEhernetを送りますよー」と言う合図を意味する64ビット(8バイト)の特別なビットパターン。先頭から「1010101010」が7つ送られ、最後に「10101011」が1つ送られる。 受信側はこの特別なビットパターンを見て、「これからEhternetフレームが届くんだな」と判断。 ※Wiresharkでキャプチャする前に取り外される。

宛先/送信元MACアドレス Ehernetネットワークにおける住所のようなもの。 Ehernetネットワークに接続されている端末を表す48ビット(6バイト)の識別ID。

タイプ ネットワークそうでどんなプロトコルを使用しているかを表す16ビット(2バイト)の識別IDです。IPv4だったら「0x0800」、ARPだったら「0x0806」など使用するプロトコルによって値が決まっています。

Ethernetペイロード 上位層のデータそのもの。 IPだったら「IPパケット」、APRだったら「ARPフレーム」 データのサイズは、デフォルトで46バイトから1500バイトまでで、この範囲に収めなければいけない。 46バイトに足りない場合、「パディング」と呼ばれるダミーのデータを付加して強引に46バイトに。 1500バイト以上の場合、上位層でデータを分割して1500バイトに収める。 Ethernetフレームに入るデータの最大サイズをMTU(Maximum Transmission Unitと言う)

FCS (Frame Check Sequence) Ethernetペイロードが壊れていないかどうかを確認する32ビット(4バイト)フィールド。 Ethernetペイロードに対して一定の計算(チェックサム計算、CRC)を行い、その結果をFCSとしてフレームの最後に付与。 受信側の端末がフレームを受け取ると、同じ計算を行い、その値がFCSと同じだったら正しいEthernetフレームと判断。 FCSは送信側から見て、Wiresharkでキャプチャした後に付与され、受信側から見てキャプチャキャプチャする前に取り外される。 フレーム長としても換算されない。

MACアドレス Ehernetネットワークに接続している各端末(ノード)の識別IDです。 NICを製造するときにROMに書き込まれる。 48ビットで構成され、16進数で表記。

先頭から8ビット目にあるI/Gビット (Individual/Groupビット)と 7ビット目にあるU/Lビット (Unique/Localビット)が特別な意味を持つビット。

I/Gビット (Individual/Groupビット) 1:1通信のためのユニキャストアドレスか、1:n通信のためのマルチキャストアドレスかを表す。 0の場合、各端末に個別に割り当てられているMACアドレス 1の場合、複数の端末のグループに割り当てられているMACアドレス ※同じEthernet上にいる全ての端末を表すブロードキャストアドレスは、全てのビットが1の「ff:ff:ff:ff:ff:ff」の特別なMACアドレスが割り当てられる。

U/Lビット (Unique/Localビット) そのMACアドレスがグローバルアドレスローカルアドレスかを表している。 Wiresharkでは、LG bitと表記されます。 0の場合、IEEEから割り当てられた世界で唯一のMACアドレス 1の場合、管理者が独自に割り当てたMACアドレス

3種類のMACアドレス

通信の種類 送信元:宛先 送信元MACアドレス 宛先MACアドレス
ユニキャスト 1:1 送信元端末のMACアドレス 宛先端末のMACアドレス
ブロードキャスト 1:n(同Ethernet上の全端末) 送信元端末のMACアドレス ff-ff-ff-ff-ff-ff
マルチキャスト 1:n(特定Groupの端末) 送信元端末のMACアドレス I/Gビットが「1」のMACアドレス

PPPoE (Point to Point over Ethernet) ポイントとポイントを1:1に繋ぐためのに繋ぐためのレイヤー2プロトコル

ARP (Address Resolution Protocol) 実際のデータ通信に先立って,IPアドレスからMACアドレスを求めるもの

終わりに

パケットキャプチャのまとめと言うよりも、TCP/IP基礎のまとめになってしまったため、 レイヤー2までとして、次回以降(たぶんない)見せ方を工夫せねば... (次回は、業務のお話を書こう)

弊社では、ネットワークに長けているアプリケーションエンジニアも募集しております。

www.wantedly.com

EmacsLispでBrainfuckのMajor Modeとインタプリタを作る

こんにちは、最近 Ubuntu 20.04 から Manjaro 19.0 に乗り換えた @takeokunn です。

非常にサクサク動くようになって快適になりました。Linux詳しくなりたいです。

はじめに

brainfuckは実行できる命令が8種類しかないシンプルな言語です。brainfuckの記事は世の中に転がっているのでそちらを参照ください。

知り合いのエンジニアとガストで喋ってたときに「emacs詳しくなりたいならbrainfuckとか良いんじゃね?作ろうよー」と言われたので作ってみました。

できたもの

repo: https://github.com/takeokunn/brainfuck.el

f:id:bararararatty:20200324160118p:plain

  • syntax highlightできるようにした
  • tokenのdocをmodelineに出すようにした
  • interpreterを作ってbuffer内のstringを取得して結果を出すようにした

Major Mode

(defvar bf-syntax-table
  (let ((table (make-syntax-table)))
    (modify-syntax-entry ?\" "." table)
    table))

;;;###autoload
(define-derived-mode brainfuck-mode prog-mode "Brainfuck"
  :syntax-table bf-syntax-table
  (bf--add-keywords)
  (bf--help-doc-fun))

;;;###autoload
(add-to-list 'auto-mode-alist '("\\.bf" . brainfuck-mode))

(defvar brainfuck-mode-map nil "Keymap for brainfuck-mode.")

(defun bf--add-keywords ()
  (font-lock-add-keywords
   nil
   (list (cons (rx (any "[" "]")) font-lock-keyword-face)
         (cons (rx (any ">" "<" "+" "-" "." ",")) font-lock-function-name-face)
         (cons (rx (not (any "[" "]" ">" "<" "+" "-" "." ","))) font-lock-comment-face))))

今回は prog-mode から派生させてみました。

token 8種類をハイライトするだけなので、 keyword を正規表現でmatchさせて [ ] はkeyword, 他6種類のtokenは function, それ以外の文字列は comment として表示するように書いてみました。

DocをModeLineに表示

(defun bf--help-sym-called-at-point ()
  (unless (eobp)
    (buffer-substring-no-properties (point) (1+ (point)))))

(defun bf--help-lookup-doc (sym)
  "Return document string for SYM."
  (pcase sym
    (">" "Increment the pointer.")
    ("<" "Decrement the pointer.")
    ("+" "Increment the value indicated by the pointer.")
    ("-" "Decrement the value indicated by the pointer.")
    ("." "Print the value indicated by the pointer.")
    ("," "Read one byte from input and store it in the indicated value.")
    ("[" "Jump to the matching `]' if the indicated value is zero.")
    ("]" "Jump to the matching `[' if the indicated value is not zero.")))

(defun bf--help-summerize-doc (sym doc)
  (concat sym " : " (car (split-string doc "[\n\r]+"))))

(defun bf-help-minibuffer-help-string ()
  (interactive)
  (let* ((sym (bf--help-sym-called-at-point))
         (doc (when sym (bf--help-lookup-doc sym))))
    (when doc (bf--help-summerize-doc sym doc))))

(defun bf--help-doc-fun ()
  (make-local-variable 'eldoc-documentation-function)
  (setq eldoc-documentation-function
        'bf-help-minibuffer-help-string))

自分の現在のカーソルの文字を取得し、それにあたるdocを取得、 eldoc に流し込むように実装しました。

インタープリタと実行処理

(defun bf-interpreter (input)
  (interactive)
  (let* ((input-list (-map #'char-to-string (coerce input 'list)))
         (ptr 0)
         (mem (make-vector 30000 0))
         (braces (make-vector (length input-list) 0))
         (braces-stack '()))
    (dotimes (outer (length input-list))
      (if (string-equal (nth outer input-list) "[")
          (let ((cnt 0))
            (progn
              (do ((inner 0 (1+ inner)))
                  ((< (length (nthcdr outer input-list)) inner))
                (cond ((string-equal (nth (+ outer inner) input-list) "[") (push t braces-stack))
                      ((string-equal (nth (+ outer inner) input-list) "]") (pop braces-stack)))
                (if (zerop (length braces-stack))
                    (setq inner (length (nthcdr outer input-list)))
                    (incf cnt)))
              (aset braces outer (+ outer cnt))
              (aset braces (+ outer cnt) outer)))))
    (do ((index 0 (1+ index)))
        ((< (length input-list) index))
      (pcase (nth index input-list)
        (">" (incf ptr))
        ("<" (decf ptr))
        ("+" (aset mem ptr (incf (aref mem ptr))))
        ("-" (aset mem ptr (decf (aref mem ptr))))
        ("." (princ (char-to-string (aref mem ptr))))
        ("," (aset mem ptr (read-char)))
        ("[" (if (zerop (aref mem ptr)) (setq index (incf (aref braces index)))))
        ("]" (unless (zerop (aref mem ptr)) (setq index (aref braces index))))))))

(defun bf-exec ()
  (interactive)
  (let ((str (buffer-string)))
    (bf-interpreter str)))

Loopの処理(括弧の対応)が非常に大変だったが、 JavascriptでBrainfuckのインタプリタを実装してみた。 を参考にしたらいけた。

M-x bf-exec と叩くとバッファ内の文字列を取得し、インタープリタで処理をし、結果を吐き出すようにしました。

参考サイト

終わりに

次はEmacsLispでC compilerを作りたいです。

弊社ではemacsのpluginを作れるプログラマを募集しています。

www.wantedly.com