作りながら覚えるGit (1)

濱野さんの「入門Git」を初めて読んだ時、いきなり 第二章から Git の構造の説明を始めてしまう構成に「Git<の使い方>入門」じゃなくて「Git<の作り方>入門」でしたかーっ(さすがメンテナですね)、、と思ったことを覚えています。

入門Git

入門Git

それは結構楽しい体験で、それまで全く思っても見なかった「Git作ってみたいな」という うずうずとした衝動がわたしに芽生えるきっかけでした。それ程に Git の設計はシンプルで 手に終えそうで、それでいて「うまくいく仕組み」が備わっている、興味深いものだったのです。まさにハックと呼ぶにふさわしいそれは、VCS全般に抱いていた「しっかり綿密に作りこんだ」ハックとは正反対のイメージからすると新鮮で、魅力的でした。

そんなこんなで いつか git クローンを作ってしまうエントリーを書こう書こうと思っていたのですが、そう思いながらどんどん月日が流れてしまうのですね。そんな「いつかはgit」を胸に抱きながら早数年、わたしのアンテナに こんな記事が飛び込んできました。

Gitの仕組み(1) - こせきの技術日記

おぉ、これはなんと素晴らしい…。

前述した濱野さんの本の内容を、もっとわかりやすく、それでいてもうちょっとだけ深く説明したような 絶妙の内容に、ちょっと感動してしまいました。

で、わたしの 心のなかの「うずうず」が この刺激に活性化して「むずむず」に進化してしまったのでした。

よぉし、作るよーっ!*1


その1. まずはGitの超入門 (知ってる人は飛ばしてね)

Git は プロジェクトのルートディレクトリ直下に作られた .git/ フォルダの中に、プロジェクトのスナップショットを ツリー構造のオブジェクトDB の形式で保存する システムです。


git のエッセンスの部分は、「保存するよ!」とした時のプロジェクト内のファイル・ディレクトリをすべてスナップショットにとって、オブジェクトDBのなかにガンガン放り込んでいくだけの超シンプルなシステムです。

このスナップショットを取る仕組みは「バージョン管理」ということは全く意識されていなくって、その上っ側をラッピングしている部分が そういうことを *結果的に* 実現させているような印象です。

さて、.git の中身はおおまかにこんなかんじになっています。

この中で、実際にスナップショットを取るのに必要なのは BLOB と TREE だけ。他に GitオブジェクトとしてCommit と Tag 、HEADを覚えておくための「リファレンス」、ステージングを行うための「インデックス」がありますが、これらはDVCSフレーバー な部分なので本エントリでは割愛します(リファレンスは別目的のため作っちゃいますが)。

BLOB がファイルで TREE が ディレクトリのスナップショットに相当します。

オブジェクトタイプ これは何? ..をzlibで圧縮したもの
BLOB ファイルのスナップショット GitObjectヘッダ + ファイルの内容
TREE ディレクトリのスナップショット GitObjectヘッダ + {ファイルタイプ + 子オブジェクトのID + ファイル名}*

秀逸なのが それらオブジェクトのユニークID として SHA-1 ハッシュ値を使っていることです。

BLOBの場合、ファイルの内容の ハッシュ値が ID になるため、同じ内容のファイルならばオブジェクトの同一性が担保されます。一文字でも変化すれば SHA-1ハッシュ値が変わるため、新たなBLOBオブジェクトとなります。逆に移動したり、ファイル名を変更したりしても 内容が一緒ならばBLOBは変化しません。

厨二病的にいうのならば、この ID は「真名」ですね。真名を唱えれば必ず 同一の内容のファイルが手に入ります。人がつけるファイル名は「仮名」に過ぎませんので、その内容(=中二的には「本質」かしら)を指し示せません。たった40文字で 本質を指し示すものこそが真名である・・というわけです。(以上、爆ぜよリアル的解釈 終了)



そして、「うまくいく仕組み」と感じたのが、TREE の仕組み。その「内容」に子Gitオブジェクト (BLOB または 子TREE) の ID を含んでいるため、ディレクトリ配下のファイルの内容が更新されたとき、そのディレクトリに対応する TREE の「内容」に含まれる ID も代わり、TREE 自体の ID も変わる。

この仕組みのお陰で、プロジェクトのどこかで何かの内容が変化すると、それは必ずルートディレクトリに対応する TREE (ルートTREE)の更新まで ばよえ〜ん と連鎖します。

で、その ときどきの ルートTREE を覚えていれば、GitオブジェクトDB からその ときどき のプロジェクトのスナップショットがいつでも取り出せると、そういう塩梅になっています。

すごいね!

その2. BLOB の作成

前置きはさておき、Git っぽいのを作ってみます。言語は Python。名前は mgit (minekoa git あるいは modoki git)。

まずは BLOB。イメージとしては

db   = GitDB()

blob = GitBLOB()
blob.putAway( open('target_file_name','r')  ) # ファイルをBLOBの中にしまっちゃうよっ!

db.save(blob)

的なことが出来るようなのを目的にします。

セーブ
  • mgitlib.py
import zlib
import hashlib
import struct

class GitBLOB(GitObject):
    '''
    BLOB の オンメモリ上でのオブジェクト

    <生BLOB> := <ヘッダ>\0<ボディ>
    <ヘッダ> := blob <サイズ>
    <ボディ> := (対象のファイルの内容)
    <サイズ> := (十進数文字列)
    '''

    def __init__(self, id=None):
        self.id       = id
        self.contents = None

    def putAway(self, targetfile):
        '''対象ファイルをBLOBにしまい込む'''

        dat = targetfile.read()
        hdr = 'blob %d\0' % len(dat)
        raw_contents = struct.pack('> %ds %ds' % (len(hdr), len(dat)), 
                                  hdr, dat)
        self.id       = self.genId(raw_contents)
        self.contents = zlib.compress(raw_contents)

これはもう、仕様通り。 genId() は、この後作る TREE でも使うので スーパークラスに持たせます。で、このスーパークラス GitObject は何を担うかと言うと、db.save(object) みたいなのをするための仕組みとして GitObject(共通インターフェイス)を GitDB(オブジェクトDB読み書きクラス) に提供します。

  • mgitlib.py
import os.path

class GitObject(object):
    '''
    id       : オブジェクトの内容を SHA-1 ハッシュした値
    contents : オブジェクトの内容を zlib圧縮したもの

    '''
    def getId(self):      return self.id
    def getContents(self): return self.contents

    def setId(self, obj_id):       self.id = obj_id
    def setContents(self, contents): self.contents = contents

    def genId(self, raw_contents):
        return hashlib.sha1(raw_contents).hexdigest()

class GitDB(object):
    def save(self, git_obj):
        path = os.path.join('.mgit/objects', git_obj.getId())
        with open(path, "wb") as f:
            f.write(git_obj.getContents())

    def load(self, git_obj_id, git_obj):
        path = os.path.join('.mgit/objects', git_obj_id)
        with open(path, "rb") as f;
            git_obj.setId(git_obj_id)
            git_obj.setContents(f.read())

最後にコマンドインターフェイススクリプト savefile を作成します。

  • savefile.py
from mgitlib import *
import sys

if __name__ == '__main__':
    target = sys.argv[1]

    db = GitDB()

    blob = GitBLOB()
    blob.putAway(open(target, 'r'))

    db.save(blob)

    print 'id    :', blob.getId()                # for debug             
    print '--------------------'                 # for debug
    print binascii.b2a_hex(blob.getContents())   # for debug
    print '--------------------'                 # for debug

使ってみるとこんな感じです。

$ mkdir .mgit/objects -p
$ ../mgit/savefile.py doc/iroha.txt
id    : 2b9f260ea6b730065084acfe40a13fcfb9a2aefa
--------------------
789c55915b4ec25010867d3eab700b3e98b81e9e4ddc4267a40d2d10948b5cc4000a1551103048b1892ce6a787b20be71c08ea5b9bf9e6ccf7cf642eaf32a7e7671727a02cb8087a07bd826250041ac271403d70007a03e7c165052e81e42b07ea800ba0070bf54163d008ec831d05f2c037a009485a4be02ce8db72b7a005a80292fe503806d54045b03410680daa1b8e859b83a57a0f6a822b4aa5fe5cec92025bb517a174ede99f9ace49576113dda5a3e9de2b89a25f29ddf6b7fc2552c96090cc6652dec481ae0fd2cfe0a0a317aeb824fdc734cc831a42ecdcea1f919652bad115781736cd4c93639206335337d2925216125a2fa976ed423db5e7c44b56bbad766cdcbc71745cfb72c16499469b956be4c68da43d043ddb538ce42c07f1347e4cfabe19b85e9a631d737059fa8f212493b2af7a7ad9134ed2c8af0dd4321b59c5fafa43446c32cfaa0d2d6f47fd00b88d56ac
--------------------

エクスプローラで .mgit/objects の下を覗くと BLOBファイルができているはずです。


本物のGitだと「ファイルがいっぱい出来ると困るアル」の対策として、ハッシュIDの先頭二桁の名前のフォルダを作り、その中に残り38桁を名前とした GitObject のファイルを作るのですが、今回はそこら辺は手抜きしてます(^^;

ロード

セーブができたら今度はロード。BLOB に putAway の逆関数 takeOut を追加します。

  • mgitlib.py
class GitBLOB(GitObject):
    def takeOut(self):
        '''BLOBにしまい込んだファイルを取り出す'''
        deco_dat = zlib.decompress(self.contents)

        # ヘッダを解析
        hdr_end_idx = deco_dat.find('\0')
        print hdr_end_idx
        otype = deco_dat[0:4]
        size  = deco_dat[4:hdr_end_idx]

        print 'type:', ''.join(otype)   # for debug
        print 'size:', size             # for debug

        # データを取り出し
        return deco_dat[hdr_end_idx+1:]

まぁ、こちらは説明不要ですね。そしてコマンドインターフェイススクリプト cat-file.py を作成して完成です。

  • cat-file.py
from mgitlib import *
import sys

if __name__ == '__main__':
    obj_id = sys.argv[1]

    db = GitDB()

    blob = GitBLOB()
    db.load(obj_id, blob)

    fdat = blob.takeOut()

    print 'id  :', blob.getId()
    print '-----------------------'
    print fdat
    print '-----------------------'

では、左記ほど putAway した BLOB から 中身を取り出してみましょう。

$ ../mgit/cat-file.py 2b9f260ea6b730065084acfe40a13fcfb9a2aefa
type: blob
size:  518
id  : 2b9f260ea6b730065084acfe40a13fcfb9a2aefa
-----------------------
いろはにほへと ちりぬるを
わかよたれそ つねならむ
うゐのおくやま けふこえて
あさきゆめみし ゑひもせすん

色は匂へど 散りぬるを
我が世誰そ 常ならむ
有為の奥山 今日越えて
浅き夢見じ 酔ひもせず

映え香るこの花も やがて散るだろう
この世に生きる誰々もが 永久の存在ではない
有為転変の迷いの奥山を 越えて今
もう淡い夢も見ず 幻想に酔うこともない

-----------------------

その3. TREE の作成

ディレクトリ構造のスナップショットを記憶する GitTreeオブジェクトを作ります。

fname → (fileattr, git_obj)

な辞書でディレクトリ構造を覚えておき、GitDB に規定の書式で書きだす・・というシンプルな構造です。

セーブ

まずはセーブ。前述のとおり構造じたいはシンプルですが、自身のセーブ時には、子オブジェクトの ID 確定している必要があるので、再帰呼び出し で 子ツリーの ID を確定しながら rootに登っていく構造にします。

  • mgitlib.py
import binascii

class GitTree(GitObject):
    '''
    TREE の オンメモリ上でのオブジェクト

    <生TREE>      := <ヘッダ>\0<ボディ>
    <ヘッダ>      := tree <サイズ>
    <ボディ>      := <ボディ1件>*
    <ボディ1件>   := <ファイル種別> <ファイル名>\0<ハッシュ値>
    <サイズ>      := (十進数文字列)
    <ファイル種別>:= (8進数文字列, 最大6桁)
    <ハッシュ値>  := (バイナリ、20byte)
    '''
    def __init__( self, id=None):
        self.id       = id
        self.contents = None
        self.childlen = {}

    def appendChild( self, gitobj, f_name, f_attr ):
        self.childlen[f_name] = (gitobj, f_attr)
                                     
    def pack(self, db):
        '''GitObjectツリーに対する再帰セーブ'''

        # contents の作成
        body = ''
        for f_name, (gitobj, f_attr) in self.childlen.items():
            # 子オブジェクトのパック (ID を確定するため)
            gitobj.pack(db)

            # オブジェクトリスト行 追加 
            finfo = '%s %s' % (f_attr.asString(), f_name)
            body += struct.pack('>%ds c 20s' % len(finfo),
                                finfo, '\0', 
                                binascii.a2b_hex(gitobj.getId()))
        hdr = 'tree %d\0' % len(body)

        raw_contents = struct.pack('> %ds %ds\n' % (len(hdr), len(body)), 
                                  hdr, body)

        self.contents = zlib.compress(raw_contents)
        self.id       = self.genId(raw_contents)

        # セーブ
        db.save(self)

class FileAttr(object):
    TYPE_DIR      = 0o40   # ファイル種別: ディレクトリ
    TYPE_FILE     = 0o100  # ファイル種別: ファイル
    TYPE_SLNK     = 0o120  # ファイル種別: シンボリックリンク
    EXECUTABLE_OK = 0o644  # パーミッション: 実行可能 (※TYPE=FILEのみ指定可)
    EXECUTABLE_NG = 0o755  # パーミッション: 実行不可 (※TYPE=FILEのみ指定可)

    def __init__(self, ftype=0, fexec=0):
        self.ftype = ftype
        self.fexec = fexec

    def parseString(self, finfo_str):
        fnum  = int(finfo_str,8)
        self.fexec = fnum % 0o1000
        self.ftype = fnum / 0o1000
        return self

    def asString(self):
        return '%o%03o' % (self.ftype,
                           self.fexec if self.ftype == FileAttr.TYPE_FILE else 0)

    def __str__(self): return self.asString()
    def isDirectory(self): return self.ftype == FileAttr.TYPE_DIR

BLOB の場合は サイズを数字文字列で出力したりと ヘッダと 中身の区切り文字が \0 (ナル文字) であることを除けばそんなにバイナリバイナリしていないのですが、TREE の場合 は オブジェクトのハッシュID を 16進数文字列ではなく 20バイトの バイナリで出力するため、ちょっとだけ面倒です。gitをほどよく真似する程度でよいのであれば、文字列で出力する手抜きをしても、仕組み的には問題ないです。


子オブジェクトの記録は

ファイルの種類 ファイル名\0ハッシュ値

になっています。

ファイルの種類は

コード 意味
40000 ディレクト
100ooo ファイル ooo=755(実行可) or 644(実行不可)
120000 シンボリックリンク

になっています。まぁ mode なのですが、パーミッションは 000 or 755 or 644 で固定です。ソース上では 一応 8進数で扱ってますが、今回作る範疇だと常に文字列シリアライズされているので気分の問題です。

さて、再帰呼び出しのために、BLOB にも pack / unpack を追加します。unpack は先取りですね。 db.save(obj) / db.load(id, obj) のラッパーです。

class GitBLOB(GitObject):
    def pack(self, db):
        '''GitObjectツリーに対する再帰セーブ'''
        db.save(self)

    def unpack(self, db, obj_id):
        '''GitObjectツリーに対する再帰ロード'''
        db.load(obj_id, self)
        return self


コマンドインターフェイススクリプトを作成。今回はちょっと大きいかな。

明示的に追加するファイルを選べるようにすべきなのですが、めんどくさいので この save.py は問答無用で全部追加することにします。あとパーミッションは手抜きで固定値にしています*2。実行ファイル来ちゃったらどうしよう(^^;

  • save.py
#-*- coding: shift_jis -*-
from mgitlib import *
import sys
import os
import os.path

if __name__ == '__main__':
    target = '.'
    tmp_dict = {}

    # 1. オブジェクトツリーの作成 -------------------------------
    for d_path, d_names, f_names in os.walk(target):
        if not tmp_dict.has_key(d_path):
            tmp_dict[d_path] = GitTree()
        curtree = tmp_dict[d_path]
        print '<%s>' % d_path           # for debug

        # mgit 管理フォルダはもちろん除外
        d_names[:] = [d for d in d_names if not d == '.mgit']

        # 子ファイル
        for f_name in f_names:
            f = open(os.path.join(d_path,f_name), 'r')
            blob = GitBLOB()
            blob.putAway(f)
            f.close()
            curtree.appendChild(blob, f_name, 
                                FileAttr( FileAttr.TYPE_FILE, FileAttr.EXECUTABLE_NG))
            print '  -%s' % f_name      # for debug

        # 子ディレクトリ
        for d_name in d_names:
            path = os.path.join(d_path, d_name)
            if not tmp_dict.has_key(path):
                tmp_dict[path] = GitTree()
            tree = tmp_dict[path]

            curtree.appendChild(tree, d_name, 
                                FileAttr( FileAttr.TYPE_DIR ) )
            print '  -%s/' % d_name    # for debug


    # 2. .mgit/objects にObjDBを保存 ----------------------------
    db = GitDB()

    root_tree = tmp_dict[target]
    root_tree.pack(db)

    print "ROOT_KEY:", root_tree.getId()

結構汚いコードになってしまいました。さて、これを実行してみると

$ ../mgit/save.py
<.>
  -readme.txt
  -doc/
  -src/
  -tmp/
<.\doc>
  -iroha.txt
<.\src>
  -hello.pl
  -hello.py
  -hello.st
<.\tmp>
ROOT_KEY: ad402e445452cad3cd4825df8bf6ec01c3a957e7

な感じになります。.mgit/objects の下を覗くとこれらオブジェクトファイルができているはずです。

ロード

今度はロードです。左記ほどBLOBの追加コードにちらっと出てましたが unpack メソッドを追加します

class GitTree(GitObject):
    def unpack(self, db, obj_id):
        '''GitObjectツリーに対する再帰ロード'''
        db.load(obj_id, self)
        deco_dat = zlib.decompress(self.contents)

        # ヘッダを解析
        hdr_end_idx = deco_dat.find('\0')
        otype = deco_dat[0:4]
        size  = deco_dat[4:hdr_end_idx]

        # データを取り出し
        self.childlen = {}

        work = deco_dat[hdr_end_idx+1:]
        while True:
            if len(work.strip()) == 0: break

            f_type, f_name, obj_id, adv_len = self.parseOneChild(work)

            f_attr  = FileAttr().parseString(f_type)
            git_obj = GitTree() if f_attr.isDirectory() else GitBLOB()
            git_obj.unpack(db, binascii.b2a_hex(obj_id))

            self.appendChild(git_obj, f_name, f_attr)

            # advance next
            work = work[adv_len:]
        return self

    def parseOneChild(self, workstr):
        ftype_end = workstr.index(' ')
        fname_end = workstr.index('\0', ftype_end+1)
        objid_end = fname_end +1 +20

        ftype   = workstr[:ftype_end]
        fname   = workstr[ftype_end+1:fname_end]
        objid   = workstr[fname_end+1:objid_end]

        return (ftype, fname, objid, objid_end)

オブジェクトツリーの概要を表示する コマンドインターフェイススクリプトを書きます。

  • ls.py
from mgitlib import *
import sys

def ls_tree(tree, indent):
    for f_name, (git_obj, f_attr) in tree.childlen.items():
        dispname = '%s/' % f_name if f_attr.isDirectory() else f_name

        print ' ' * indent, '+--' , dispname,
        print ' ' * (28 - len(dispname) - indent),
        print git_obj.getId()

        if f_attr.isDirectory():
            ls_tree(git_obj, indent + 2)

if __name__ == '__main__':
    db     = GitDB()
    target_id = sys.argv[1]

    tree = GitTree()
    tree.unpack(db, target_id)

    print '+-- /', ' ' * (28), tree.getId()
    ls_tree(tree, 1)

使い方ですが、save の時に出力されたルートツリーのID を引数に与えてあげます。でないと、.mgit/objects の中にあるファイルの どれがルートツリーだか判別する方法がありません。

$ ../mgit/ls.py ad402e445452cad3cd4825df8bf6ec01c3a957e7
+-- /                              ad402e445452cad3cd4825df8bf6ec01c3a957e7
  +-- tmp/                         4b825dc642cb6eb9a060e54bf8d69288fbee4904
  +-- doc/                         31af261e2cb58228a11ad0d0f417d082b8552d1b
    +-- iroha.txt                  2b9f260ea6b730065084acfe40a13fcfb9a2aefa
  +-- readme.txt                   6d5924c6fc3313f2b5fc0c668a19c80ee5cdf44c
  +-- src/                         847fb8f92b17923ebc9b3e5d181a94918b081adb
    +-- hello.pl                   c371d47cbd86018a0b7c68c57d8c80c286cb2b9a
    +-- hello.py                   d15e721afc14d26260a5896e8152ba4c0b9130c7
    +-- hello.st                   1f48dc045dfdf789f8947b1dfaaa87524158fc54

こんな感じ、あとは見たいファイルがあったら 前に作った cat-file.py で見てあげればいいでしょう。


おまけ. リファレンスの作成

ここまでで、オブジェクトDB の仕組みはだいたいできました。今回はここまで!……でも良いのですが、しまった情報を取り出すのにいちいち 40桁の16進数を入力するのはかなり面倒臭いです。また、save するときに画面に表示されたわざわざ「ROOT_KEY」をメモって覚えて置かなければいけないのも、なんとも理不尽です。

そんな面倒をどうにかするために、リファレンス も作っちゃいましょう。

リファレンスとはGitオブジェクトに任意の名前をつける機能です。……といっても、「任意の名前」のファイル名をしたファイルに指し示したい GitオブジェクトのハッシュID が書いてあるだけの超シンプルなしくみです。

リファレンスは通常は .git/refs/ の下につくられます。ただし、リファレンスへのリファレンスを記述することができて、これらはその限りじゃないみたい。たとえば、Git にて 'HEAD' は .git/HEAD というファイルになります。この'HEAD'ファイルには .git/refs/heads/master へのパスが書かれていて、実際のGitオブジェクトのID はこれに書かれています。つまり masterブランチの head が ワークスペースの HEAD ということを表した構造なわけです。

では早速このリファレンスを実現して、面倒なハッシュ値をメモするお仕事から開放されましょう。

import re

class GitDB(object):
    def dereference(self, ref_name):
        '''
        reference の指し示す obj_id を取得する。
        reference のreference 対応のため、末端referenceもペアで返す
        @return (terminal_ref_name, obj_id)
        '''
        path = os.path.join('.mgit', ref_name)

        if not os.path.exists(path):
            return (ref_name, None)

        f   = open(path, 'r')
        dat = f.read()
        matobj = re.match(r"ref: (.*)", dat)
        f.close()

        if matobj != None:
            return self.dereference(matobj.group(1))
        else:
            return (ref_name, dat.strip())

    def updateReference(self, ref_name, git_obj_id):
        ref_path, old_obj_id = self.dereference(ref_name)

        # 変更がない場合はなにもしない
        if old_obj_id != None and old_obj_id == git_obj_id:
            return

        # リファレンスの参照先を更新
        path = os.path.join('.mgit', ref_path)
        f = open(path, 'w')
        f.write(git_obj_id)
        f.close()

まぁこんな感じ。リファレンスのリファレンス対応で、dereference メソッドは (末端リファレンスファイルのパス名、そこに書かれている値) のペアを返すようにしてます(ちょっとスマートじゃないですね/ GitReferenceオブジェクトとか作るべきだったかも)。

で、前に作った save.py に1行 updateReference() の呼び出しを追加します。

  • save.py
                  ・
                  ・
                  ・

    # 2. .mgit/objects にObjDBを保存 ----------------------------
    db = GitDB()

    root_tree = tmp_dict[target]
    root_tree.pack(db)

    print "ROOT_KEY:", root_tree.getId()


    # 3. HEADの情報を残す ---------------------------------------
    db.updateReference('HEAD', root_tree.getId())

これで、ls.py で前回のROOT_KEY を渡す手間から開放されます。

  • ls.py
                  ・
                  ・
                  ・

if __name__ == '__main__':
    db     = GitDB()
    ref_path, target_id = db.dereference('HEAD')

    tree = GitTree()
    tree.unpack(db, target_id)

    print '+-- /', ' ' * (30), tree.getId()
    ls_tree(tree, 1)


仮に引数で渡すにしても 'HEAD' とかのわかりやすい値で渡せれば、人間にとってフレンドリーですね。



さて、ここで、ちょっと補足。本物の git だと リファレンスが指すのは本当はルートTREE ではなくって Commit オブジェクトになります。で、このCommitオブジェクトが ルートTREE を持つ仕組みなってます。今回は Commit オブジェクトを作らないのでこんなアレンジ。

おまけのおまけ. ログの作成

今日のの最後は ログ機能です。セーブが更新されるたびに歴代の ルートツリーの ID がわかれば、何時でもセーブポイントに戻せるという塩梅。

git のログは リファレンスの変遷を .git/logs の下に残す機能です。.git/以下のパス構造そのままにlogsの下にログファイルが作られます。というわけでわたしも リファレンスの更新に寄生する形で実装します。

import datetime

class GitDB(object):
    def updateReference(self, ref_name, git_obj_id):
        ref_path, old_obj_id = self.dereference(ref_name)

        # 変更がない場合はなにもしない
        if old_obj_id != None and old_obj_id == git_obj_id:
            return

        # リファレンスの参照先を更新
        path = os.path.join('.mgit', ref_path)
        f = open(path, 'w')
        f.write(git_obj_id)
        f.close()

        # ログを残す
        self._writeRefUpdateLog(ref_path, old_obj_id, git_obj_id )


    def _writeRefUpdateLog(self, ref_path, old_obj_id, git_obj_id):

        logpath = os.path.join('.mgit/logs', ref_path)
        if not os.path.exists(os.path.dirname(logpath)):
            os.makedirs(os.path.dirname(logpath))

        old_obj_id_str = old_obj_id if old_obj_id != None else '0'*40

        logfile = open(logpath, 'a')
        logfile.write( '%s %s %s\n' % (old_obj_id_str, git_obj_id,
                                       datetime.datetime.today().strftime("%Y%m%dT%H%M%S")))

ちょっと注意。

この「ログ」は git でのフォーマットをまったく踏襲してません。というのもCommitオブジェクトを作ってないので出すべき情報が揃ってないからです。なので今回作った他のモノと違って、これはかなりの「なんちゃって」です*3

おしまいに

ファイルとディレクトリツリーを記録するシステムに 内容のユニークさでオブジェクトの同一性を満たす工夫を加えただけで、ついでにDVCS の要件も満たしてしまう。Git の仕組みを見ていると、DVCSとしての運用は単なる一つの応用例に見えてきます。

この部分が Git の肝であり、ファイルやディレクトリの差異に 本質的に時間(差分の積み重ね情報)という概念を持ってないことが、DVCS に好都合なのでしょう。ここらへんの「ある名前のオブジェクトはいつ何時でも同一」というあたりは 参照透明性 ...変数の再代入禁止 にまつわるアレコレ...を連想させますね。


さてさて、今回は興味ある部分だけを偏食気味に作ってみた mgit ですが、これ、意外に使えますね・・・。

「よし、バージョン管理するぞ!」と思わなくても気軽に「現在のスナップショット」が取れるのは 想像以上によい塩梅です。よくやる「節目節目でフォルダ毎コピー」してバックアップをとるアレみたいに気軽につかえますし、それでいてコマンド一発で 事故なく低容量でバックアップが作れる旨味もあります。こういう方向性でUIを磨けば、これは意外に フツーの人向けのツールとしてのニッチがあるかな?、と思いました。

メンタルモデルとしてはゲームのセーブに近いのも、また使いやすいです。

*1:というわけでタイトルは釣りです。「こんなタイトル ライトな技術書にありそう」ってだけでつけましたが、「git覚える」とか全然意識してないです。単に作りたかっただけです

*2:どうせそのうち git add (indexの作成)を作るときに作りなおしますし..

*3:あと "HEAD"リファレンスのログも logs/refs/heads/master と同じ内容を出す必要があります