🔡

自作コーディングエージェントのためにバージョン管理システム作った

に公開

はじめに

こんにちは。
現在、私はShaftというCLI型コーディングエージェントを開発しています。

https://gitlab.com/tkithrta/shaft

開発を進める中で、エージェントが自身の行った変更を記録し、必要に応じて元に戻すためのシンプルな仕組みが必要になりました。
gitは非常に高機能で素晴らしいツールですが、エージェントが利用するには少し複雑すぎました。
例えば、「直前のファイル編集だけを取り消す」といった単純な操作のために、ステージングやコミット、ブランチといった概念をエージェントに扱わせるのは過剰だと感じたのです。

そこで、Shaftにはごく軽量なファイルベースのバージョン管理システムを内蔵させることにしました。
この記事では、そのシステムの仕組みと設計思想について解説します。

コンセプト

このバージョン管理システムは、Gitの代替を目指すものではありません。
その目的は、特定のファイル群の変更履歴をシンプルに記録し、簡単に過去のバージョンに戻すことに特化しています。

主な特徴は以下の通りです。

  • ファイルシステムベース: すべての変更履歴は、ローカルの特定のディレクトリ(デフォルトは.shaft/archives)にファイルとして保存されます。
  • シンプルな内部処理: 自動的なアーカイブと、undoコマンドによる復元という2つの主要な処理で構成されます。
  • ブランチやマージはなし: 複雑な概念を排除し、直線的な変更履歴のみを扱います。

AIエージェントによる自動操作や、実験的なコード変更を気軽に記録・復元する、といったユースケースに最適化されています。

主な内部機能

Shaftが内部で利用するバージョン管理機能は非常にシンプルです。

  • 自動アーカイブ処理:
    • taskpatchといったファイル変更を伴うコマンドが実行される際、変更前のファイル状態が自動的にスナップショットとしてアーカイブに保存されます。
      ユーザーやエージェントが明示的に保存コマンドを実行する必要はありません。
  • undoコマンドによる復元処理:
    • 指定したファイルを過去のバージョンに戻します。
    • countを指定すると、N個前のバージョンに戻せます(例:shaft undo 2 <file>で2つ前)。
    • ファイルを省略すると、直近の処理で変更されたファイルリスト(チェンジセット)が復元の対象になります。

仕組みの解説

このシステムの心臓部である、バージョンの保存と復元の仕組みを解説します。

1. アーカイブの構造

Shaftがファイルをアーカイブすると、ファイルはアーカイブディレクトリに保存されます。
このとき、どのファイルのどのバージョンなのかを管理するために、以下のような構造を採用しています。

.shaft/archives/
├── 0198...b4a9.txt  (チェンジセットファイル)
├── 2e5a...9312/      (src/main.pyの履歴)
│   ├── 0198...8b70.py (バージョン1)
│   └── 0198...9a4f.py (バージョン2)
└── b335...70f5/      (README.mdの履歴)
    └── 0198...c5d3.md (バージョン1)
  • ファイルごとの履歴ディレクトリ:

    • 各ファイルの履歴は、そのファイルパスから生成した一意な名前のディレクトリに格納されます。この名前は、以下の手順で正規化されたパス文字列のSHA256ハッシュです。
      1. 絶対パスへの変換: まず、指定されたパスを絶対パスに変換します。
      2. 相対パスへの再変換: 次に、絶対パスをカレントディレクトリからの相対パスに戻します。これにより../などが解決され、一貫した表現になります。カレントディレクトリに存在しない場合や、相対パスへの変換が困難な場合は絶対パスのまま扱います。
      3. OS標準形式への統一: 最後に、OSの標準的なパス形式(例: Windowsでの大文字/小文字の区別をなくす)に統一します。
    • この正規化処理はcalculate_path_sha256()という内部関数が担います。これにより、例えば./src/main.pysrc/main.pyのように異なる方法で同じファイルを指定しても、必ず同じハッシュ値が生成され、変更履歴が1つのディレクトリに集約されます。
  • バージョンファイル:

    • 各バージョンのファイル名はUUIDv7で生成されます。
      UUIDv7はタイムスタンプベースのため、ファイル名自体が時系列ソート可能になります。
      これにより、バージョンの新旧を簡単に判断できます。
  • チェンジセットファイル:

    • 複数のファイルを同時に変更した際、どのファイルが一緒に変更されたかを記録するために、UUIDv7.txtというファイルが作られます。
    • このファイルには、保存されたファイルのパスがリストとして記録されています。
      git commitが複数のファイルの変更を1つにまとめるのに似ています。

2. バージョンの自動保存

Shaftでは、ユーザーがtaskpatchコマンドでファイルの変更を指示した際、以下の流れで自動的にバージョンが保存されます。

  1. Shaftは、まず変更対象となるファイルの現在の内容をメモリに読み込みます。
  2. 対象ファイルのパスを正規化してSHA256ハッシュを計算し、対応する履歴ディレクトリのパスを決定します。
  3. 履歴ディレクトリが存在しなければ作成します。
  4. uuidv7()でユニークなIDを生成し、これをファイル名として、変更前のファイル内容を履歴ディレクトリにコピーして保存します。
  5. 変更対象となったすべてのファイルパスを、チェンジセットファイル(UUIDv7.txt)に書き込み、アーカイブのルートに保存します。
  6. すべての保全処理が完了した後、Shaftは要求されたファイル変更をワークスペースに適用します。

このように、Shaftはファイルが上書きされる前に、その直前の状態を自動で記録する安全機構を備えています。

3. バージョンの復元 (undoコマンド)

Shaftundoコマンドを実行すると、以下の処理が行われます。

  1. 対象ファイルの履歴ディレクトリを特定します(保存時と同様にパスのハッシュから)。
  2. そのディレクトリ内にあるバージョンファイル一覧を取得し、作成日時順(UUIDv7の順)にソートします。
  3. countパラメータで指定されたバージョン(デフォルトは最新)のファイルパスを取得します。
  4. 復元対象のバージョンファイルの内容と、現在の作業ディレクトリにあるファイルの内容との差分(Diff)をShaft自身が確認し、ユーザーに提示します。
  5. 安全機構: ユーザーが復元を承認すると、上書きによって失われるのを防ぐため、まず現在のファイルの状態を同じ仕組みで一度アーカイブします。これにより、もしundo操作を取り消したくなった場合は、もう一度undoコマンドを実行することで、元に戻すことができます。
  6. その後、アーカイブされていた過去のバージョンの内容で、作業ディレクトリのファイルを上書きします。

もしundo時にファイルが指定されなかった場合は、最新のチェンジセットファイル (UUIDv7.txt) を探し、そこに記録されているファイルリストを復元の対象とします。

PoCとしてのスタンドアロン版:shift.py

今回解説したShaftのバージョン管理機能は、もともとShaft本体に実装されていたものです。
その中核ロジックを、概念実証(Proof of Concept)としてわかりやすく示すために、取り急ぎshift.pyという単体のPythonスクリプトを作成しました。

https://tkithrta.gitlab.io/u/shift.py

shift.pyは以下のコマンドでダウンロード、または直接実行できます。

# ダウンロード
curl -O http://tkithrta.gitlab.io/u/shift.py

# 直接実行
uv run https://tkithrta.gitlab.io/u/shift.py
# shift.py の利用イメージ

# ファイルをアーカイブ
python shift.py shi src/main.py

# 1つ前のバージョンに戻す
python shift.py undo src/main.py

# 2つ前のバージョンに戻す
python shift.py undo 2 src/main.py

このスクリプトは、Shaftに搭載されている機能のコア部分を抜き出したものです。
そのため、Shaft本体とは一部動作が異なります。例えば、Shaftではtaskコマンド実行時に自動でアーカイブが行われるため、shift.pyにあるようなshi(アーカイブ)コマンドは存在しません。

おわりに

Shaftに搭載されたバージョン管理システムは、汎用的なVCSとしてはgitに遠く及びません。
しかし、「AIエージェントが自身の作業を記録・復元する」という特定の目的においては、そのシンプルさが強力な武器となります。

特に、ファイル変更時に自動でバージョンが記録されるため、エージェントは「保存」を意識する必要がありません。
複雑な機能を削ぎ落とし、本当に必要なものだけを実装することで、軽量で見通しの良いツールを作ることができました。
このような小さな内部ツールを自作する過程は、ソフトウェア設計の基本に立ち返る良い機会となり、多くの学びがありました。

この記事が、皆さんの自作コーディングエージェントのきっかけや参考になれば幸いです。

ちなみに

Shaftshift.pyもこの記事の大半もGemini CLIで書きました。
Shaftを使ってもよかったのですが、あまり機能追加しなくなったのもあり、Gemini CLIのGemini 2.5 Proが余っていたので。
後はShaftのメジャーアップデートで破壊的変更加えてHistory機能とMCPを追加するぐらいかな?

Discussion