自作コーディングエージェントのためにバージョン管理システム作った
はじめに
こんにちは。
現在、私はShaft
というCLI型コーディングエージェントを開発しています。
開発を進める中で、エージェントが自身の行った変更を記録し、必要に応じて元に戻すためのシンプルな仕組みが必要になりました。
git
は非常に高機能で素晴らしいツールですが、エージェントが利用するには少し複雑すぎました。
例えば、「直前のファイル編集だけを取り消す」といった単純な操作のために、ステージングやコミット、ブランチといった概念をエージェントに扱わせるのは過剰だと感じたのです。
そこで、Shaft
にはごく軽量なファイルベースのバージョン管理システムを内蔵させることにしました。
この記事では、そのシステムの仕組みと設計思想について解説します。
コンセプト
このバージョン管理システムは、Gitの代替を目指すものではありません。
その目的は、特定のファイル群の変更履歴をシンプルに記録し、簡単に過去のバージョンに戻すことに特化しています。
主な特徴は以下の通りです。
-
ファイルシステムベース: すべての変更履歴は、ローカルの特定のディレクトリ(デフォルトは
.shaft/archives
)にファイルとして保存されます。 -
シンプルな内部処理: 自動的なアーカイブと、
undo
コマンドによる復元という2つの主要な処理で構成されます。 - ブランチやマージはなし: 複雑な概念を排除し、直線的な変更履歴のみを扱います。
AIエージェントによる自動操作や、実験的なコード変更を気軽に記録・復元する、といったユースケースに最適化されています。
主な内部機能
Shaft
が内部で利用するバージョン管理機能は非常にシンプルです。
-
自動アーカイブ処理:
-
task
やpatch
といったファイル変更を伴うコマンドが実行される際、変更前のファイル状態が自動的にスナップショットとしてアーカイブに保存されます。
ユーザーやエージェントが明示的に保存コマンドを実行する必要はありません。
-
-
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ハッシュです。
- 絶対パスへの変換: まず、指定されたパスを絶対パスに変換します。
-
相対パスへの再変換: 次に、絶対パスをカレントディレクトリからの相対パスに戻します。これにより
../
などが解決され、一貫した表現になります。カレントディレクトリに存在しない場合や、相対パスへの変換が困難な場合は絶対パスのまま扱います。 - OS標準形式への統一: 最後に、OSの標準的なパス形式(例: Windowsでの大文字/小文字の区別をなくす)に統一します。
- この正規化処理は
calculate_path_sha256()
という内部関数が担います。これにより、例えば./src/main.py
とsrc/main.py
のように異なる方法で同じファイルを指定しても、必ず同じハッシュ値が生成され、変更履歴が1つのディレクトリに集約されます。
- 各ファイルの履歴は、そのファイルパスから生成した一意な名前のディレクトリに格納されます。この名前は、以下の手順で正規化されたパス文字列のSHA256ハッシュです。
-
バージョンファイル:
- 各バージョンのファイル名は
UUIDv7
で生成されます。
UUIDv7
はタイムスタンプベースのため、ファイル名自体が時系列ソート可能になります。
これにより、バージョンの新旧を簡単に判断できます。
- 各バージョンのファイル名は
-
チェンジセットファイル:
- 複数のファイルを同時に変更した際、どのファイルが一緒に変更されたかを記録するために、
UUIDv7.txt
というファイルが作られます。 - このファイルには、保存されたファイルのパスがリストとして記録されています。
git commit
が複数のファイルの変更を1つにまとめるのに似ています。
- 複数のファイルを同時に変更した際、どのファイルが一緒に変更されたかを記録するために、
2. バージョンの自動保存
Shaft
では、ユーザーがtask
やpatch
コマンドでファイルの変更を指示した際、以下の流れで自動的にバージョンが保存されます。
-
Shaft
は、まず変更対象となるファイルの現在の内容をメモリに読み込みます。 - 対象ファイルのパスを正規化してSHA256ハッシュを計算し、対応する履歴ディレクトリのパスを決定します。
- 履歴ディレクトリが存在しなければ作成します。
-
uuidv7()
でユニークなIDを生成し、これをファイル名として、変更前のファイル内容を履歴ディレクトリにコピーして保存します。 - 変更対象となったすべてのファイルパスを、チェンジセットファイル(
UUIDv7.txt
)に書き込み、アーカイブのルートに保存します。 - すべての保全処理が完了した後、
Shaft
は要求されたファイル変更をワークスペースに適用します。
このように、Shaft
はファイルが上書きされる前に、その直前の状態を自動で記録する安全機構を備えています。
undo
コマンド)
3. バージョンの復元 (Shaft
のundo
コマンドを実行すると、以下の処理が行われます。
- 対象ファイルの履歴ディレクトリを特定します(保存時と同様にパスのハッシュから)。
- そのディレクトリ内にあるバージョンファイル一覧を取得し、作成日時順(
UUIDv7
の順)にソートします。 -
count
パラメータで指定されたバージョン(デフォルトは最新)のファイルパスを取得します。 - 復元対象のバージョンファイルの内容と、現在の作業ディレクトリにあるファイルの内容との差分(Diff)を
Shaft
自身が確認し、ユーザーに提示します。 -
安全機構: ユーザーが復元を承認すると、上書きによって失われるのを防ぐため、まず現在のファイルの状態を同じ仕組みで一度アーカイブします。これにより、もし
undo
操作を取り消したくなった場合は、もう一度undo
コマンドを実行することで、元に戻すことができます。 - その後、アーカイブされていた過去のバージョンの内容で、作業ディレクトリのファイルを上書きします。
もしundo
時にファイルが指定されなかった場合は、最新のチェンジセットファイル (UUIDv7.txt
) を探し、そこに記録されているファイルリストを復元の対象とします。
shift.py
PoCとしてのスタンドアロン版:今回解説したShaft
のバージョン管理機能は、もともとShaft
本体に実装されていたものです。
その中核ロジックを、概念実証(Proof of Concept)としてわかりやすく示すために、取り急ぎshift.py
という単体のPythonスクリプトを作成しました。
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エージェントが自身の作業を記録・復元する」という特定の目的においては、そのシンプルさが強力な武器となります。
特に、ファイル変更時に自動でバージョンが記録されるため、エージェントは「保存」を意識する必要がありません。
複雑な機能を削ぎ落とし、本当に必要なものだけを実装することで、軽量で見通しの良いツールを作ることができました。
このような小さな内部ツールを自作する過程は、ソフトウェア設計の基本に立ち返る良い機会となり、多くの学びがありました。
この記事が、皆さんの自作コーディングエージェントのきっかけや参考になれば幸いです。
ちなみに
Shaft
もshift.py
もこの記事の大半もGemini CLIで書きました。
Shaft
を使ってもよかったのですが、あまり機能追加しなくなったのもあり、Gemini CLIのGemini 2.5 Proが余っていたので。
後はShaft
のメジャーアップデートで破壊的変更加えてHistory機能とMCPを追加するぐらいかな?
Discussion