67
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Gitを作ってみる(開発編)

Last updated at Posted at 2023-03-24

はじめに

都内でひっそり見習いエンジニアをしている@noshishiです。
addしてcommitするプログラムの作成を通じて、Gitを内部から理解しようという記事です。

こちらは開発編の記事になります!
理解編にてデータ構造については、詳しく説明しているのでここでは、記事テンポを意識して、多く記載しないため、合わせて読んでいただければ幸いです。

理解編からはや2ヶ月、、、、

前書き

新しいプログラミング言語にも触れてみて、いろいろ学べたらと思いRustで今回挑戦しました。

著者が実際に作成したGitもどきリポジトリは、こちらです。いちよう自分が納得できるところまでは開発を進めました。
※ローカルでの一直線の開発はできそうな程度までは作成できました。コードのしょぼさはご容赦ください。もしかしたらOSによって動かない可能性があることがあります。

この記事で開発できるGitもどきは、簡略化して作成しています。そのため全てを網羅することを目的としておりませんことをご容赦ください。
また、著者は初めてRustを触るゆえにベストプラクティスな実装ではないと思います。ぜひRustの良い書き方があればコメントいただけると幸いです。

開発方針

個人で開発するので、楽しさ重視!とふざけるの大概にして、、、、
思考の過程に沿って書いていけたらと思っています。

著者の開発過程

  • コマンドラインインターフェースを実装(今回は後回し!)
  • blobオブジェクトを作る処理
    • ファイルを読み込む
    • ヘッダーをつけて、フォーマットを整える
    • Zlib圧縮する
    • 対象のリポジトリに保存する
  • 配管コマンド(hash-object、cat-file)を実装
    • 上記の処理を一つの関数にする
    • 上記の処理の逆を行う処理を関数にする
  • indexを作る処理
    • ファイルのメタ情報を読み取る
    • フォーマットを整える
    • 対象のリポジトリに保存する
  • treeオブジェクトを作る処理
    • indexを読み込む、解体する
    • ヘッダーをつけて、フォーマットを整える
    • 対象のリポジトリに保存する
  • 配管コマンド(update-index、ls-files、write-tree)を実装
    • 上記のindexを処理を関数にする
    • 上記のtreeの前半部分を関数にする
    • 上記のtreeの後半部分を関数にする
  • commitを作る処理
    • 作業のメタ情報(コミッターなど)を読み込む
    • HEADを扱う処理
    • ヘッダーをつけて、フォーマットを整える
    • 対象のリポジトリに保存する
  • オブジェクトの構造化(※記事の容量が大きくなってしまったので、今回は書きません。)
    • 構造体を使用してオブジェクトを扱う
    • 関連関数を作ってより柔軟に扱う
    • トレイトを使用して共通の振る舞いを切り分ける
  • 配管コマンド(commit-tree、update-ref)の実装
    • 上記のcommitを処理を関数にする
    • 上記のHEADを処理を関数にする
  • addcommitの実装
    • 作成した関数を連鎖して作成する

※本記事で書いたコードの全ては、nssリポジトリhow_to_makeというフォルダに入れているので、合わせてお読みください。

この記事ではRustのインストールやセットアップについては、触れません。Rust 公式サイトが優秀すぎて、ぐうの音も出ないためです。

Rustのプロジェクトの開始

好きなのプロジェクト名を付けて開始しましょう。
この名前がコマンドラインで使われる名前になるためカッコよくいきましょう。
(もちろん後から変更できますし、プロジェク名以外に設定することもできます)

% cargo new ngit --bin
% cd ngit

今回は、実行バイナリを作成したいので、--binオプションで明示的にプロジェクトの用途を指定します。

blobオブジェクトを作る

blobは、ファイルデータに対応するオブジェクトです。理解編 Blob

自動的に、srcディレクトリ配下に、main.rsが作成されています。今回はそこに直書きしていきます。

// env関係のメソッド
use std::env;
// Pathbuf sturuct
use std::path::PathBuf;
// File sturuct
use std::fs::File;
// File stuructにトレイト実装
use std::io::prelude::*; 
// sha1計算用クレート
use sha1::{Digest, Sha1};

fn main() -> std::io::Result<()> {
    let filename = "first.txt";

    // ファイルの中身を取得する
    let mut path = env::current_dir()?; // カレントディレクトリを取得する
    path.push(PathBuf::from(filename)); // 絶対パスでファイルパスを作成

    let mut f = File::open(path)?; // ファイルを開く
    let mut content = String::new(); // データを格納するバッファーを用意
    f.read_to_string(&mut content)?; // バッファに書き込み

    // objectは `header`+`\0`+`content`
    let blob_content = format!("blob {}\0{}", content.len(), content); // Rustのlen()は、文字列の文字数ではなくバイト数を返す。

    // 格納する文字列
    println!("blob content: {}", blob_content);

    // hash値を計算
    let blob_hash = Sha1::digest(blob_content.as_bytes());
    println!("blob hash: {:x}", blob_hash);
    
    Ok(())
}

ここでは、sha1という外部クレートを使用しているので、Cargo.tomlに以下のように依存関係を記載します。

Cargo.toml
[dependencies]
sha-1 = "0.9.1"

そして、ngitの直配下に以下のファイルデータを作成します。

first.txt
Hello World!
This is my original git project!

そしてコンパイルして実行すると、ハッシュ値が出てきました。

% cargo run
blob content: blob 45Hello World!
This is my original git project!
blob hash: b4aa0076e9b36b2aed8ca8a21ccdd210c905660a

本当に合っているかをgit hash-objectコマンドで確かめてみます。

% git hash-object first.txt
b4aa0076e9b36b2aed8ca8a21ccdd210c905660a

しっかしあっていましたね。では、書き込む処理を実装していきます。

// 省略...
use std::fs; //追加

// 圧縮用のクレート
use flate2::Compression; //追加
use flate2::write::ZlibEncoder; //追加

fn main() {
    // 省略...

    // 圧縮用のバッファを用意する
    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder.write_all(&blob_content.as_bytes())?; // 書き込むときはバイトで行う; 
    let compressed = encoder.finish()?;

    // hash値を文字列にする
    let hash = format!("{:x}", blob_hash);
    // hash値の2文字までがdirectory pathで、38文字がfile path
    let (dir, file) = hash.split_at(2);
 
    // 絶対パスで保存先を指定
    let mut current_path = env::current_dir()?; // path/to/ngit
    current_path.push(".git/objects"); // path/to/ngit/.git/objects
    current_path.push(dir); // path/to/ngit/.git/objects/b4

    let object_dir = current_path.clone(); // 後でもう一度使うのでここでは所有権は渡さない!

    // .git/obejects/に格納するためにディレクトリを作成する
    fs::create_dir_all(object_dir)?;

    // file pathは、hash値で決まり、、、
    current_path.push(file); // path/to/ngit/.git/objects/b4/aa0076e9b36b2aed8ca8a21ccdd210c905660a
    let object_path = current_path;
    // 中身は圧縮したコンテンツを入れる
    let mut f = File::create(object_path)?;
    f.write_all(&compressed)?;
    f.flush()?;
}

新しい外部クレートを使用したので、また、tomlファイルに追加します。
毎回tomlファイルを記述するのは省略するため、今後使用するものは先に追加しておきます。

Cargo.toml
[dependencies]
byteorder = "1.4.3" # 追加 後々使う
chrono = "0.4.24" # 追加 後々使う
flate2 = "1.0.25" # 追加
hex = "0.4.2" # 追加 後々使う
sha-1 = "0.9.1"

もう一回、コンパイルして実行してみましょう。

実行結果
% cargo run
blob content: blob 45Hello World!
This is my original git project!
blob hash: b4aa0076e9b36b2aed8ca8a21ccdd210c905660a

実行がうまくいったので、オブジェクトが格納されているか確認してみます。

% ls .git/objects/b4
aa0076e9b36b2aed8ca8a21ccdd210c905660a

% git cat-file -p aa0076e9b36b2aed8ca8a21ccdd210c905660a
Hello World!
This is my original git project!

ばっちりオブジェクトが作成できていました。


手順を押さえてしまえば簡単にblobオブジェクトが作成できます。

git-hash-objectgit-cat-fileの実装

hash-object

hash-objectは、オブジェクトを.git/objectsに書き込むコマンドです。理解編 Blob
先ほど書いたblobをつくる過程を関数にしてしまい、再利用します。

// 省略...
use std::env; // 追加

fn main() -> std::io::Result<()> {
    // コマンドライン引数をベクタで集める
    let args: Vec<String> = env::args().collect();
    
    let filename = &args[1];
    write_blob(filename);
    
    Ok(())
}

fn write_blob(filename: &str) -> std::io::Result<()> {
    // filenameの割り当てを削除
    let mut path = env::current_dir()?;
    path.push(PathBuf::from(filename));
    let mut f = File::open(path)?;

    // 省略...
}

このようにちょっと変更を加えるだけで実装できます。ちなみにこれはhash-object -wという書き込みオプションを使用した場合の挙動と一致します。もしオプションなしのコマンドを実装する場合は、ハッシュ値の計算で止めれば実装することがきます。

実行するときは以下のような感じです。cargo runのoptionではないことを示すために--を入れて、ファイル名を指定します。

% cargo run -- first.txt

cat-file

次にcat-fileを実装します。
原理的には、hash-objectと逆の流れを実装します。

use std::fs::File;
use std::io::prelude::*; 

use flate2::read::ZlibDecoder;

use std::env;

fn main() -> std::io::Result<()> {
    // コマンドライン引数をベクタで集める
    let args: Vec<String> = env::args().collect();

    let hash = &args[1];
    cat_blob(hash)?;

    Ok(())
}

fn cat_blob(hash: &str) -> std::io::Result<()> {
    // hash値の2文字までがdirectory pathで、38文字がfile path
    let (dir, file) = hash.split_at(2);

    // オブジェクトまでの絶対パスを習得する
    let mut current_path = env::current_dir()?;
    current_path.push(".git/objects");
    current_path.push(dir);
    current_path.push(file);
    let object_path = current_path;

    // オブジェクトを開いてバイナリで読み込む
    let mut compressed = Vec::new(); // 読み込み用のバッファはbyteのベクタで用意
    let mut file = File::open(object_path)?;
    file.read_to_end(&mut compressed)?;

    // 読み込んだ圧縮データを伸長する
    let mut object_content:Vec<u8> = Vec::new(); // 伸長用のバッファはbyteのベクタで用意
    let mut decoder = ZlibDecoder::new(&compressed[..]);
    decoder.read_to_end(&mut object_content)?;

    // ヌルバイトでheaderとcontentを分離する。
    let mut contents = object_content.splitn(2, |&x| x == b'\0');
    println!("header:\n{}", String::from_utf8(contents.next().unwrap().to_vec()).ok().unwrap());
    println!("file contnet:\n{}", String::from_utf8(contents.next().unwrap().to_vec()).ok().unwrap());

    Ok(())
}

ハッシュをコマンドライン引数で受け取って、ハッシュ値を元にオブジェクトまでのパスを習得します。
オブジェクトはファイルデータかつ圧縮されているので、文字列としてではなく、バイト列で読み込みます。
最後に、headerとcontentを分離して出力します。実際のcat-file -pは、中身だけの出力を行うので、後半部分を出力すればよいことになります。

実行結果
% cargo run -- b4aa0076e9b36b2aed8ca8a21ccdd210c905660a
header:
blob 45
file contnet:
Hello World!
This is my original git project!

ばっちり中身を見ることができました。


ここまで抽象化できれば、実際にgitのコマンドで作成したオブジェクトで試してみましょう。ただし、この記事で作成した関数では、blobオブジェクトしか対応できません。treecommitに対して少し対応が異なりますが、うまく構造体を使うことで同じ関数で処理することができます。

(休憩)OptuionとResultを使ってみよう

Rustでは、Option型とResult型と呼ばれるEnum(列挙型)があります。この二つは、関数の返り値として高頻度で使用されるデータ型になります。

Option型
Noneの可能性と値をセットで扱える型です。

enum Option<T> {
    None,
    Some(T),
}

Result型
エラーの可能性と値をセットで扱える型です。

enum Result<T, E> {
   Ok(T),
   Err(E),
}

関数の返り値をResultでラップすることで、呼び出した場所にエラーを伝播できるというメリットがあります。そして伝播させるときに?演算子を使用することで、短いコードを書くことができます。詳しくは、RustのOptionとResult

また、各型にはその後の処理をチェーンさせることも可能で、かなりの関数が充実しているので、次の記事も参考にしてください。Rust のエラーハンドリング

初めのうちは、ひたすらunwrapメソッドで乗り切るのはあるあるでしょうか。Pythonとか全く意識しなくていい、変数に値が束縛されているかどうかをみっちり管理するのがRustらしいというのでしょうか。著者はまだまだ使いこなせていません笑笑


Rustにおける関数作りは、OptionやResultを使うとコーディングが楽になると思います。合わせてmatchなどを使ってうまくenumの特性を活かしたコーディングができればなおよしです。

indexを作る

indexは、コミットするためファイルメタ情報のスナップショットでした。理解編 Index

ファイルの作成時間やデバイスidなんてどうやって拾うのよ、、と途方に暮れそうですが、rustにはPathあるいはPathBufというファイルのデータを扱う構造体があります。そこに、os毎に応じたトレイトを実装すると、たった数行でメタ情報が取り出せます。

ファイルメタ情報

first.txtのメタ情報を見てみましょう。

fn main() -> std::io::Result<()> {
    // path名からPathBuf構造体を作成
    let mut path = env::current_dir()?;
    path.push(PathBuf::from("first.txt"));
    // トレイトでMetadata構造体が取り出せる
    let metadata = path.metadata()?;

    let ctime = metadata.ctime() as u32;
    let ctime_nsec = metadata.ctime_nsec() as u32;
    let mtime = metadata.mtime() as u32;
    let mtime_nsec = metadata.mtime_nsec() as u32;
    let dev = metadata.dev() as u32;
    let ino = metadata.ino() as u32;
    let mode = metadata.mode() as u32;
    let uid = metadata.uid() as u32;
    let gid= metadata.gid() as u32;
    let filesize = metadata.size() as u32;

    println!("ctime: {}\nctime_nsec: {}\nmtime: {}\nmtime_nsec: {}\ndev: {}\nino: {}\nmode: {}\nuid: {}\ngid: {}\nfilesize: {}",
        ctime, ctime_nsec, mtime, mtime_nsec, dev, ino, mode, uid, gid, filesize
    );

    Ok(())
}

実行するとこんな感じです。

実行結果
% cargo run 
ctime: 1679361090
ctime_nsec: 323019719
mtime: 1679361090
mtime_nsec: 323019719
dev: 16777233
ino: 9693332
mode: 33188
uid: 501
gid: 20
filesize: 45
# (※全て、u32(符号なし32bit)で表現しているので、あとはメタ情報に応じて解釈してください。)

Indexを作成する

実際に作成するindexのフォーマットを確認し、足りないものを抑えていきましょう。

Indexのフォーマット
インデックスのフォーマット
ヘッダー
    - 4 bytes   インデックスヘッダー      *DIRCという文字列
    - 4 bytes   インデックスバージョン       *基本的にVersion2が多いと思います
    - 32 bits   インデックスのエントリー数  *エントリーは各ファイルのメタ情報のこと

エントリー
    - 32 bits   作成時間
    - 32 bits   作成時間のnano単位
    - 32 bits   変更時間
    - 32 bits   変更時間のnano単位
    - 32 bits   デバイスID
    - 32 bits   inode番号
    - 32 bits   パーミッション(mode)
    - 32 bits   ユーザーID
    - 32 bits   グループID
    - 32 bits   ファイルサイズ
    - 160 bits  `blob`のハッシュ値
    - 16 bits   ファイル名のサイズ            *ファイル名の文字列のバイト数
    - ?  bytes  ファイル名            *ファイル名によって可変
    - 1-8 bytes パディング            *エントリーによって可変

... エントリの数だけ同じことが続く

残るは、「blobのハッシュ値」「ファイル名のサイズ」「ファイル名」「パディング」を求めることになります。全ての中身をみるついでに、バイト数も見ておきましょう。

fn main() -> std::io::Result<()> {
    // 省略  ... 

    // blobのハッシュ値
    // write_blobのハッシュ計算部分を使用する
    let mut f = File::open(path)?;
    let mut content = String::new(); // データを格納するバッファーを用意
    f.read_to_string(&mut content)?;
    let blob_content = format!("blob {}\0{}", content.len(), content); // Rustのlen()は、文字列の文字数ではなくバイト数を返す。
    let blob_hash = Sha1::digest(blob_content.as_bytes());
    let hash = blob_hash.as_slice();

    // ファイル名のサイズ
    let filename_size = "first.txt".len() as u16;

    // ファイル名
    let filename = "first.txt";

    // padding
    let padding_size = padding(filename_size as usize);

    // コンテンツを見る
    println!("コンテンツ!");
    println!("ctime: {}\nctime_nsec: {}\nmtime: {}\nmtime_nsec: {}\ndev: {}\nino: {}\nmode: {}\nuid: {}\ngid: {}\nfilesize: {}\nhash: {}\nfilename_size: {}\nfilename: {}\npadding_size: {}",
        ctime, ctime_nsec, mtime, mtime_nsec, dev, ino, mode, uid, gid, filesize, format!("{:x}", blob_hash), filename_size, filename, padding_size
    );
    println!("");

    // コンテンツのバイト数を見る
    println!("コンテンツのバイト数!");
    println!("ctime: {:?}\nctime_nsec: {}\nmtime: {}\nmtime_nsec: {}\ndev: {}\nino: {}\nmode: {}\nuid: {}\ngid: {}\nfilesize: {}\nhash: {}\nfilename_size: {}\nfilename: {}\npadding_size: {}",
        ctime.to_be_bytes().len(), ctime_nsec.to_be_bytes().len(), mtime.to_be_bytes().len(), mtime_nsec.to_be_bytes().len(), dev.to_be_bytes().len(), ino.to_be_bytes().len(), mode.to_be_bytes().len(), uid.to_be_bytes().len(), gid.to_be_bytes().len(), filesize.to_be_bytes().len(), hash.len(), filename_size.to_be_bytes().len(), filename.as_bytes().len(), padding_size
    );

    Ok(())
}

// padding用の関数を作成しておく
fn padding(size: usize) -> usize {
    // calclate padding size
    let floor = (size - 2) / 8;
    let target = (floor + 1) * 8 + 2;
    let padding = target - size;

    padding
}
実行結果
% cargo run 
コンテンツ!
ctime: 1679361090
ctime_nsec: 323019719
mtime: 1679361090
mtime_nsec: 323019719
dev: 16777233
ino: 9693332
mode: 33188
uid: 501
gid: 20
filesize: 45
hash: b4aa0076e9b36b2aed8ca8a21ccdd210c905660a
filename_size: 9
filename: first.txt
padding_size: 1

コンテンツのバイト数!
ctime: 4
ctime_nsec: 4
mtime: 4
mtime_nsec: 4
dev: 4
ino: 4
mode: 4
uid: 4
gid: 4
filesize: 4
hash: 20
filename_size: 2
filename: 9
padding_size: 1

ちゃんと4 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + 20 + 2 + 9 + 1 = 72で、バイト数が8の倍数になっています。


以下のようなsecond.txtで追加して二つのスナップショットを作ってみましょう。

second.txt
As I sat outside, basking in the warm sun, a sudden urge to create overwhelmed me. 
But where to start? My mind was blank, and my fingers itched to type something,
 anything. I closed my eyes and took a deep breath, searching for inspiration.
It was then that I heard the faint sound of a bird chirping in the distance.
It was as if the bird was calling out to me, urging me to put my thoughts into
words. And with that, my fingers began to move effortlessly, typing out a story 
of a bird that found its true voice and inspired others to do the same.
By ChatGPT

では、indexを作成して、書き込む部分を実装していきます。
ここでも、処理を分割するために、一つのエントリーの格納コンテンツを作成する関数create_entryを定義しています。

fn main() -> std::io::Result<()> {
    write_index(vec!["first.txt", "second.txt"])?;
    Ok(())
}

// entryをバイトに変換する処理を関数に
fn create_entry(filename: &str) -> std::io::Result<Vec<u8>> {
    // path名からPathBuf構造体を作成
    let mut path = env::current_dir()?;
    path.push(PathBuf::from(filename));

    // 省略 ...

    // padding
    let padding_size = padding(filename_size as usize);
    let padding = vec![b'\0'; padding_size]; // パティングするバイト数0を埋めを行う

    // 全てのコンテンツをバイトで繋ぐ
    let entry_meta = [ctime.to_be_bytes(), ctime_nsec.to_be_bytes(),
        mtime.to_be_bytes(), mtime_nsec.to_be_bytes(), dev.to_be_bytes(),
        ino.to_be_bytes(), mode.to_be_bytes(), uid.to_be_bytes(),
        gid.to_be_bytes(), filesize.to_be_bytes()].concat();
    
    let filemeta_vec = [entry_meta, hash.to_vec(), Vec::from(filename_size.to_be_bytes()),
        filename.as_bytes().to_vec(), padding].concat();

    Ok(filemeta_vec)
}

fn padding(size: usize) -> usize {
    // calclate padding size
    let floor = (size - 2) / 8;
    let target = (floor + 1) * 8 + 2;
    let padding = target - size;

    padding
}

// indexを作成する関数
fn write_index(filenames: Vec<&str>) -> std::io::Result<()> {
    // コンテンツの中身を入れる変数を束縛
    let mut content:Vec<Vec<u8>> = vec![];

    // header部分をバイトで集める
    let index_header = b"DIRC";
    let index_version = 2 as u32;
    let entry_num = filenames.len() as u32; 
    let header = [*index_header, index_version.to_be_bytes(), entry_num.to_be_bytes()].concat();
    content.push(header);

    // entry部分をバイトで集める
    for filename in filenames {
        let entry = create_entry(filename)?;
        content.push(entry)
    }
    let mut path = env::current_dir()?;
    path.push(PathBuf::from((".git/index"));
    let mut file = File::create(path)?;
    file.write_all(content.concat().as_slice())?;
    file.flush()?;

    Ok(())
}
実行結果
% cargo run 
% ls .git/
HEAD            description     index           objects
config          hooks           info            refs

# indexの中身を見るコマンド
% git ls-files 
first.txt
second.txt

ちゃんとgitに認識されるようなindexが作成できました。ちなみに、vscodeでgitの拡張機能を見ると、実行後にはちゃんとstaging areaにファイルが移動しているのが確認できます。


これで、addするパーツは、整いました。ここまで来ると、vscodeのgitの拡張機能にも認識され、作ってる感が出てきます。あと一息です。
※もし、rustと同じプロジェクトで実行している場合は、gitのコマンドと比較するために、rust関係のディレクトリを全てgitignoreする形にしておきましょう。

treeを作る

treeは、ディレクトリデータに対応するオブジェクトです。理解編 tree

indexを解体する

treeオブジェクトは、commitに必要なファイルだけをディレクトリとしてまとめているのでIndexをもとに作ることになります。つまり、indexを解体する処理から作っています。

ただし、必要な要素は、ファイルのmode,hash,filenameの3つです。

fn main() -> std::io::Result<()> {
    let mut path = env::current_dir()?;
    path.push(PathBuf::from(".git/index"));
    let mut file = File::open(path)?;

    // indexをバイトで読み込む
    let mut buf: Vec<u8> = Vec::new();
    file.read_to_end(&mut buf)?;

    // まずはエントリー数を確認
    let entry_num = BigEndian::read_u32(&buf[8..12]) as usize;
    // どの位置からエントリーの情報があるかを特定、はじめは13バイト目から
    let mut start_size = 12 as usize;

    for _ in 0..entry_num {
        // スタート位置から24-27にかけてファイルのmode
        let mode = BigEndian::read_u32(&buf[(start_size+24)..(start_size+28)]) as u32;
        // スタート位置から40-60にかけてファイル(blob)のハッシュ値
        let hash = (&buf[(start_size+40)..(start_size+60)]).to_vec();
        // スタート位置から60-61にかけてファイル名のサイズ
        let filename_size = BigEndian::read_u16(&buf[(start_size+60)..(start_size+62)]) as u16;
        // スタート位置から62-?にかけてファイル名
        let filename = (&buf[(start_size+62)..(start_size+62+filename_size as usize)]).to_vec();

        // paddingを計算して、次のエントリのバイト数を特定する
        let padding_size = padding(filename_size as usize);
        start_size = start_size + 62 + filename_size as usize + padding_size; 

        println!("mode: {:0>6o}, hash: {}, filename: {}", mode,  hex::encode(hash), String::from_utf8(filename).ok().unwrap());
    }

    Ok(())
}
実行結果
% cargo run 
mode: 100644, hash: b4aa0076e9b36b2aed8ca8a21ccdd210c905660a, filename: first.txt
mode: 100644, hash: 5b070ca4e0eb35b336c6378d4541acffb4d26d8b, filename: second.txt

いい感じに取り出せました。

treeを作成する

うまく取り出せたので、treeで使用されるようにフォーマットを整えていき、blobと同じように保存していきます。
少し長くなったので、もう先に関数にしています。

fn main() -> std::io::Result<()> {
    write_tree()?;
    
    Ok(())
}

/// `tree`を作成する関数
fn write_tree() -> std::io::Result<()> {
    // indexをバイトで読み込む
    let mut path = env::current_dir()?;
    path.push(PathBuf::from(".git/index"));
    let mut file = File::open(path)?;
    let mut buf: Vec<u8> = Vec::new();
    file.read_to_end(&mut buf)?;

    // まずはエントリー数を確認
    let entry_num = BigEndian::read_u32(&buf[8..12]) as usize;
    // どの位置からエントリーの情報があるかを特定、はじめは13バイト目から
    let mut start_size = 12 as usize;

    let mut entries: Vec<Vec<u8>> = vec![];
    for _ in 0..entry_num {
        // スタート位置から24-27にかけてファイルのmode
        let mode = BigEndian::read_u32(&buf[(start_size+24)..(start_size+28)]) as u32;
        // スタート位置から40-60にかけてファイル(blob)のハッシュ値
        let hash = (&buf[(start_size+40)..(start_size+60)]).to_vec();
        // スタート位置から60-61にかけてファイル名のサイズ
        let filename_size = BigEndian::read_u16(&buf[(start_size+60)..(start_size+62)]) as u16;
        // スタート位置から62-?にかけてファイル名
        let filename = (&buf[(start_size+62)..(start_size+62+filename_size as usize)]).to_vec();

        // paddingを計算して、次のエントリのバイト数を特定する
        let padding_size = padding(filename_size as usize);
        start_size = start_size + 62 + filename_size as usize + padding_size; 

        // 各entryの構造は、`mode filename\0hash` 
        // ただし、modeだけ8進法として格納する
        let entry_header  = format!("{:0>6o} {}\0", mode, String::from_utf8(filename).ok().unwrap());
        // hashはバイトのまま打ち込む
        let entry = [entry_header.as_bytes(), &hash].concat();

        entries.push(entry);
    }

    // entryを一つにまとめて、treeのheaderと合わせます
    let content = entries.concat();
    let header = format!("tree {}\0", content.len());
    let tree_content = [header.as_bytes().to_vec(), content].concat();
    
    // 圧縮用のバッファを用意する
    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder.write_all(&tree_content)?; // 書き込むときはバイトで行う; 
    let compressed = encoder.finish()?;

    // hash値を計算
    let tree_hash = Sha1::digest(&tree_content);
    println!("tree hash: {:x}", tree_hash);
    // hash値を文字列にする
    let hash = format!("{:x}", tree_hash);
    // hash値の2文字までがdirectory pathで、38文字がfile path
    let (dir, file) = hash.split_at(2);
 
    // 全てのOSのパスを表現するためにPathBufを使用します。
    let mut current_path = env::current_dir()?; // path/to/ngit
    current_path.push(".git/objects"); // path/to/ngit/.git/objects
    current_path.push(dir); // path/to/ngit/.git/objects/b4

    let object_dir = current_path.clone(); // 後でもう一度使うのでここでは所有権は渡さない!

    // .git/obejects/に格納するためにディレクトリを作成する
    fs::create_dir_all(object_dir)?;

    // file pathは、hash値で決まり、、、
    current_path.push(file); // path/to/ngit/.git/objects/b4/aa0076e9b36b2aed8ca8a21ccdd210c905660a
    let object_path = current_path;
    // 中身は圧縮したコンテンツを入れる
    let mut f = File::create(object_path)?;
    f.write_all(&compressed)?;
    f.flush()?;

    Ok(())
}
実行結果
% cargo run 
tree hash: cb0f82cf7252804cf62c51f9e33c8085ce315bb0

% git cat-file -p cb0f82cf7252804cf62c51f9e33c8085ce315bb0
100644 blob b4aa0076e9b36b2aed8ca8a21ccdd210c905660a    first.txt
100644 blob 5b070ca4e0eb35b336c6378d4541acffb4d26d8b    second.txt
100644 blob 30d74d258442c7c65512eafab474568dd706c430    third.txt

ちゃんとtree objectが作成できて、git cat-fileで中身を確認できているので成功です。

ついにtreeまで完成させることができましたね。作成したtreeは、git addしてgit write-treeしか結果と全く同じなるので、感動が湧き上げてきます。ただし、ディレクトリの中にディレクトリがある場合は対応していません。もし実装する場合は、再起的に処理する実装が必要になります。
※ちなみに、gitコマンドで作られるオブジェクトは、読み込み専用です。その辺りのパーミッションは、今回は実装していません。

git-update-indexgit-ls-filesgit-write-treeの実装

update-index

update-indexは、indexファイルを操作するコマンドです。

--addオプションによりindexにファイルを追加できます。write_index関数では全てのファイルをindexに追加することを想定しましたが、このコマンドはもっと細かい操作になります。

一つのファイル毎にindexファイルに追加するか更新するかを行う必要があります。

少し非効率ですが、もし一致したファイル名があれば、再度write_indexを呼んでindexを作成し、もし新しいファイルであれば、ファイル群に追加して、write_indexを呼んでindexを作成します。

fn main() -> std::io::Result<()> {
    // コマンドライン引数をベクタで集める
    let args: Vec<String> = env::args().collect();

    let filename = &args[1];
    
    update_index(filename)?;

    Ok(())
}

fn update_index(file_path: &str) -> std::io::Result<()> {
    // ファイルのパスは絶対パスで指定
    let mut path = env::current_dir()?;
    path.push(PathBuf::from(".git/index"));

    // ファイルが存在すれば中身を取り出す
    let buf = match File::open(path.clone()) {
        Ok(mut f) => {
            let mut buf: Vec<u8> = vec![];
            f.read_to_end(&mut buf)?;
            buf
        },
        Err(_) => {
            vec![]
        } 
    };

    if buf == vec![] {
        write_index(vec![file_path])?;
    } else {
        // indexの中にあるファイル名を収集
        let mut file_paths: Vec<String> = vec![];

        // まずはエントリー数を確認
        let entry_num = BigEndian::read_u32(&buf[8..12]) as usize;
        // どの位置からエントリーの情報があるかを特定、はじめは13バイト目から
        let mut start_size = 12 as usize;
        for _ in 0..entry_num {
            // スタート位置から60-61にかけてファイル名のサイズ
            let filename_size = BigEndian::read_u16(&buf[(start_size+60)..(start_size+62)]) as u16;
            // スタート位置から62-?にかけてファイル名
            let filename = (&buf[(start_size+62)..(start_size+62+filename_size as usize)]).to_vec();
    
            // paddingを計算して、次のエントリのバイト数を特定する
            let padding_size = padding(filename_size as usize);
            start_size = start_size + 62 + filename_size as usize + padding_size;
            
            let filename = String::from_utf8(filename).ok().unwrap();
            file_paths.push(filename);

        }
        // すでにファイルがあるかどうか、なければ新しく追加する
        if !file_paths.iter().any(|e| e==&file_path) {
            file_paths.push(file_path.to_owned())
        } 
        // ファイル名順にindexを作成するためにsortする
        file_paths.sort();
        // 新しい対象ファイル群でindexを作成する。
        write_index(file_paths.iter().map(|s| &**s).collect())?;
    }

    Ok(())
}

実際にあたらしいファイルを追加して、実行してみましょう。

実行結果
third.txt
test
% cargo run -- third.txt
% git ls-files -s 
100644 b4aa0076e9b36b2aed8ca8a21ccdd210c905660a 0       first.txt
100644 5b070ca4e0eb35b336c6378d4541acffb4d26d8b 0       second.txt
100644 30d74d258442c7c65512eafab474568dd706c430 0       third.txt

しっかり追加できていますね。

ls-files

ls-filesは、indexのファイルを出力するコマンドです。

コミットの実装そのものには、関係しませんが、すでにパーツが揃っているので実装してみます。具体的には、treeの実装をした時のindexの解体処理を再利用して関数にしてしまいます。

fn main() -> std::io::Result<()> {    
    ls_files()?;

    Ok(())
}

fn ls_files() -> std::io::Result<()> {

    // 省略...

    for _ in 0..entry_num {
        // 省略...

        // no オプション
        println!("{}", String::from_utf8(filename).ok().unwrap());
        // -s オプション
        // println!("{:0>6o} {} 0\t{}", mode,  hex::encode(hash), String::from_utf8(filename).ok().unwrap());
    }

    Ok(())
}

実行してみると、、、

実行結果
% cargo run
first.txt
second.txt
third.txt

git ls-filesと遜色ない実行結果です。

write-tree

write-treeは、treeオブジェクトを作成するコマンドです。
これは、大丈夫ですね。treeの章ですでに関数を作成しているので、それを代用しましょう。


ここまで来ると自分の実装で、コマンドの実行を結果を確認できるようになります。例えば、update-indexしてls-filesするとか。ただし、ls-files -sのようなオプションでさらに綺麗に表示するためにはより工夫が必要になります。

(休憩)開発での失敗談

gitには、checkoutというワーキングディレクトリとインデックスを特定のコミット時点のものに反映するコマンドがありますよね。このcheckoutでひょいひょい移動できるからこそ、バージョン管理システムなわけです。
なので、checkoutも作ってやると意気込んで取り組んでいた時の話です。

Gitを作成する上で、vscode上では.gitを表示するように設定しています。挙動を把握するため、
このcheckoutの挙動を作ったとき、cargo runポチ、、、、、
エラ....ん...??? なんか.gitがねえ、、、しかもほとんどのファイルが消えている?????

無視する対象のディレクトリに.gitを入れずに実行してしまったんですよね。<= ポンコツ
どう足掻いても戻れない泣

今回は幸いにも、親ディレクトリでもgit管理していたので、ファイル自体は復元することができましたが、当該ディレクトリのgit履歴がなくなってしまったので、ブランチで作業したいた内容がオジャンになりました。

皆さんもcheckoutを作成するときは要注意です。そもそもエラーが出たらロールバックできるような処理を書かなければならない気がしますが、そこまで実装できなかったので、ぜひ教えていただきたいです!

commit(オブジェクト)を作る

commit、リポジトリディレクトリのtreeをメタ情報と共に格納したオブジェクトでした。理解編 commit

commitオブジェクトに必要な要素は、以下のとおりです。

  1. treeオブジェクトのハッシュ値 ... リポジトリディレクトリのオブジェクト
    これは、write_tree関数の返り値で取得できそうです。

  2. commitオブジェクトのハッシュ値 ... HEADが指す直前のcommitオブジェクト
    HEADがポイントするハッシュ値を取得することになりそうです。その過程で、HEADの中にbranchが入っている可能性があるので、さらにブランチを読み込む必要があります。

  3. authorとcommiter ... コードを書いた人とコミットした人
    configファイルに入っている情報から読み出すことになります。

  4. コメント ... -m '<comment>'でつける文字列
    コマンドラインの引数として取得できます。

fn main() -> std::io::Result<()> {
    // コマンドライン引数をベクタで集める
    let args: Vec<String> = env::args().collect();
    let message = &args[1];

    let tree_hash = write_tree()?;
    let commit_hash = commit_tree(&tree_hash, message)?;

    println!("{}", commit_hash);

    Ok(())
}

/// treeのハッシュ値を返すように修正
fn write_tree() -> std::io::Result<String> {
    // 省略 ...
    Ok(hash)
}

fn commit_tree(tree_hash: &str, message: &str) -> std::io::Result<Option<String>> {

    // Build commit object
    // 1. tree
    let tree_hash = format!("tree {}", tree_hash);

    // 3. authorとcommitter
    // 本来はconfigファイルから取り出すべきですが、今回は固定値
    let author = format!("author {} <{}> {} +0900", 
        "noshishi",
        "nopeNoshishi@nope.noshishi",
        Utc::now().timestamp()
    );
    let committer = format!("committer {} <{}> {} +0900", 
        "noshishi",
        "nopeNoshishi@nope.noshishi",
        Utc::now().timestamp()
    );
    
    // HEADに入っているコミットが親コミットになるので、読み込む
    // そのための補助関数としてread_head()を下で定義
    let commit_content = match read_head()? {
        // 2. parent
        Some(h) => {
            let parent = format!("parent {}", h);
            let content = format!("{}\n{}\n{}\n{}\n\n{}\n", 
                tree_hash, parent, author, committer, message);
            // commitの中身
            format!("commit {}\0{}", content.len(), content).as_bytes().to_vec()
        },
        _ => {
            let content = format!("{}\n{}\n{}\n\n{}\n", 
                tree_hash, author, committer, message);
            // commitの中身
            format!("commit {}\0{}", content.len(), content).as_bytes().to_vec()
        }
    };

    // 圧縮用のバッファを用意する
    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder.write_all(&commit_content)?; // 書き込むときはバイトで行う; 
    let compressed = encoder.finish()?;

    // hash値を計算
    let commit_hash = Sha1::digest(&commit_content);
    // println!("commit hash: {:x}", commit_hash);
    // hash値を文字列にする
    let hash = format!("{:x}", commit_hash);
    // hash値の2文字までがdirectory pathで、38文字がfile path
    let (dir, file) = hash.split_at(2);
 
    // 全てのOSのパスを表現するためにPathBufを使用します。
    let mut current_path = env::current_dir()?; // path/to/ngit
    current_path.push(".git/objects"); // path/to/ngit/.git/objects
    current_path.push(dir); // path/to/ngit/.git/objects/b4

    let object_dir = current_path.clone(); // 後でもう一度使うのでここでは所有権は渡さない!

    // .git/obejects/に格納するためにディレクトリを作成する
    fs::create_dir_all(object_dir)?;

    // file pathは、hash値で決まり、、、
    current_path.push(file); // path/to/ngit/.git/objects/b4/aa0076e9b36b2aed8ca8a21ccdd210c905660a
    let object_path = current_path;
    // 中身は圧縮したコンテンツを入れる
    let mut f = File::create(object_path)?;
    f.write_all(&compressed)?;
    f.flush()?; 

    Ok(Some(hash))
}

/// HEADを読む場合に、一番最初のコミットだけハッシュ値をさしていないので、
/// 返り値はOption<String>>型でおいておく。
fn read_head() -> std::io::Result<Option<String>> {
    // HEADの構造は、"refs: <branch-path> or <hash>""
    let mut path = env::current_dir()?;
    path.push(PathBuf::from(".git/HEAD"));
    let mut file = File::open(path)?;
    let mut referece = String::new();
    file.read_to_string(&mut referece)?;

    let prefix_path = referece.split(' ').collect::<Vec<&str>>();

    // HEADにブランチ名が入っている場合は、ブランチの中身のハッシュ値がHEADのさすハッシュ値になる
    if prefix_path[1].contains("/") {
        // branchのパスは、".git/refs/heads/<branch-name>"
        let mut branch_path = env::current_dir()?;
        branch_path.push(PathBuf::from(".git"));
        branch_path.push(prefix_path[1].replace("\n", "")); // 改行が含まれているので置換
        // println!("{:?}", prefix_path[1]);

        match File::open(branch_path) {
            Ok(mut f) => {
                let mut hash = String::new();
                f.read_to_string(&mut hash)?;
                return Ok(Some(hash.replace("\n", ""))) // 改行が含まれているので置換
            },
            // 一番最初はブランチがないのでハッシュ値はなし
            Err(_e) => return Ok(None)
        }
    }

    // 直接ハッシュ値が格納されていた場合
    Ok(Some(prefix_path[1].replace("\n", "").to_owned())) // 改行が含まれているので置換
}

/// `commit`ハッシュから`tree`ハッシュを読み出す
fn cat_commit_tree(commit_hash: &str) -> std::io::Result<String> {
    // hash値の2文字までがdirectory pathで、38文字がfile path
    let (dir, file) = commit_hash.split_at(2);

    // オブジェクトまでのパスを習得する
    let mut current_path = env::current_dir()?;
    current_path.push(".git/objects");
    current_path.push(dir);
    current_path.push(file);
    let object_path = current_path;

    // オブジェクトを開いてバイナリで読み込む
    let mut compressed = Vec::new(); // 読み込み用のバッファはbyteのベクタで用意
    let mut file = File::open(object_path)?;
    file.read_to_end(&mut compressed)?;

    // 読み込んだ圧縮データを伸長する
    let mut object_content:Vec<u8> = Vec::new(); // 伸長用のバッファはbyteのベクタで用意
    let mut decoder = ZlibDecoder::new(&compressed[..]);
    decoder.read_to_end(&mut object_content)?;

    // ヌルバイトでheaderとcontentを分離する。
    let mut contents = object_content.splitn(2, |&x| x == b'\0');

    let _header = contents.next().unwrap();

    // Rustっぽい書き方してみた
    let tree = contents.next().and_then(|c| {
        c.split(|&x| x == b'\n')
            .filter(|x| !x.is_empty())
            .map(|x| String::from_utf8_lossy(x).to_string())
            .find_map(|x| x.split_whitespace().nth(1).map(|x| x.to_string()))
    });

    Ok(tree.unwrap())
}

実際に実行してみると

実行結果
% cargo run 'initial'
commit hash: 34f46f43dcf5179d34f82c183eded4c47882bdf1

% git cat-file -p 34f46f43dcf5179d34f82c183eded4c47882bdf1
tree cb0f82cf7252804cf62c51f9e33c8085ce315bb0
author noshishi <<nopeNoshishi@nope.noshishi> 1679670591 +0900
committer noshishi <<nopeNoshishi@nope.noshishi> 1679670591 +0900

initial

以前作成したtreeのハッシュ値を格納したcommitを作成できました。

commitオブジェクトは、これまでのデータ(treeやindexなど)をフルに使用して作られることがわかったと思います。

git-commit-treegit-update-refの実装

commit-tree

commit-treeは、treeのハッシュ値を引数にcommitを作成するコマンドです。
ほとんどcommitを作成する章で実装したコードが活かせるので、特に記述することはないですが、少し踏み込んで「直前に行った同じコミットは行えない」処理を加えましょう。

同じコミットとは、同じtreeオブジェクトを持つcommitのことを指します。「引数のtreeのハッシュ値」と「HEADから読み取ったcommitの中にあるtreeのハッシュ値」が一致しているか確認する処理を追加します。

fn commit_tree(tree_hash: &str, message: &str) -> std::io::Result<Option<String>> {
    // 省略 ...
    let commit_content = match read_head()? {
        // 2. parent
        Some(h) => {
            // 直前(HEAD)の`commit`に含まれる`tree`と同じであればコミットしない
            if tree_hash.as_str() != cat_commit_tree(h.as_str())? {
                let parent = format!("parent {}", h);
                let content = format!("{}\n{}\n{}\n{}\n\n{}\n", 
                    tree_hash, parent, author, committer, message);
                // commitの中身
                format!("commit {}\0{}", content.len(), content).as_bytes().to_vec()
            } else {
                println!("Nothing to commit");
                return  Ok(None);
            }
        },
    // 省略 ...

}

/// `commit`ハッシュから`tree`ハッシュを読み出す
fn cat_commit_tree(commit_hash: &str) -> std::io::Result<String> {
    // hash値の2文字までがdirectory pathで、38文字がfile path
    let (dir, file) = commit_hash.split_at(2);

    // オブジェクトまでのパスを習得する
    let mut current_path = env::current_dir()?;
    current_path.push(".git/objects");
    current_path.push(dir);
    current_path.push(file);
    let object_path = current_path;

    // オブジェクトを開いてバイナリで読み込む
    let mut compressed = Vec::new(); // 読み込み用のバッファはbyteのベクタで用意
    let mut file = File::open(object_path)?;
    file.read_to_end(&mut compressed)?;

    // 読み込んだ圧縮データを伸長する
    let mut object_content:Vec<u8> = Vec::new(); // 伸長用のバッファはbyteのベクタで用意
    let mut decoder = ZlibDecoder::new(&compressed[..]);
    decoder.read_to_end(&mut object_content)?;

    // ヌルバイトでheaderとcontentを分離する。
    let mut contents = object_content.splitn(2, |&x| x == b'\0');

    let _header = contents.next().unwrap();

    // Rustっぽい書き方してみた
    let tree = contents.next().and_then(|c| {
        c.split(|&x| x == b'\n')
            .filter(|x| !x.is_empty())
            .map(|x| String::from_utf8_lossy(x).to_string())
            .find_map(|x| x.split_whitespace().nth(1).map(|x| x.to_string()))
    });

    Ok(tree.unwrap())
}

一旦ここでは、もし一致したらNoneを返すことにし、呼び出し先にその後の処理は任せることにしましょう。

update-ref

update-refは、HEADが指しているcommitのハッシュ値を更新するコマンドです。

HEADは、自分が今なんのコミットをベースに作業(ワーキングディレクトリ)をしているかを示す指標になるので、コミットした時点で移動してあげるとワーキングディレクトリの状態とコミットの中身が一致するようになります。理解するGit ポインタ

fn update_ref(commit_hash: &str) -> std::io::Result<()> {

    let mut path = env::current_dir()?;
    path.push(PathBuf::from(".git/HEAD"));
    let mut file_head = File::open(path)?;
    let mut referece = String::new();
    file_head.read_to_string(&mut referece)?;

    let prefix_path = referece.split(' ').collect::<Vec<&str>>();  

    // HEADにブランチ名が入っている場合は、ブランチの中身のハッシュ値がHEADのさすハッシュ値になる
    if prefix_path[1].contains("/") {
        // branchのパスは、".git/refs/heads/<branch-name>"
        let mut branch_path = env::current_dir()?;
        branch_path.push(PathBuf::from(".git"));
        branch_path.push(prefix_path[1].replace("\n", ""));


        let mut file_branch = File::create(branch_path)?;
        file_branch.write_all(commit_hash.as_bytes())?;
        file_branch.flush()?;
    }

    // 直接ハッシュ値が格納されていたHEADに直接書き込み
    let head_content = format!("refs: {}", commit_hash);
    file_head.write_all(&head_content.as_bytes())?;
    file_head.flush()?;

    Ok(())
}

これがうまくいけば、git-log履歴を追うことができるようになります。

(休憩)コマンドラインインターフェイスを作ろう

私は形から入るタイプなので、コマンドラインインターフェースから実装しました。

Rustでは、clapというコマンドライン引数解析クレートがあります。これを使用することで簡単にCLIチックなプログラムを作成できます。
早速、tomlファイルにクレートを使用して、実装してみましょう。

Cargo.toml
[dependencies]
byteorder = "1.4.3"
chrono = "0.4.24"
clap = "4.0.32" # 追加
flate2 = "1.0.25"
hex = "0.4.2"
sha-1 = "0.9.1"

ちょっと公式サイトを見ることで、以下のような実装ができます。今回はBuilderパターンで作成しましたが、deriveパターンでも作成できるのでかなり自由度があります。

// clapクレート
use clap::{Command, Arg, ArgAction};

fn main() -> std::io::Result<()> {
    let cmd = Command::new("ngit")
        .about("This is Original Git") 
        .version("0.1.0")
        .author("Noshishi. <noshishi@noshishi.com>")
        .arg_required_else_help(true)
        .allow_external_subcommands(false)
        .subcommand(Command::new("add")
            .about("Snapshot latest working directory")
            .arg(Arg::new("file")
            .value_parser(clap::builder::NonEmptyStringValueParser::new())
            .required(false)
            .value_name("file")))
        .subcommand(Command::new("commit")
            .about("Register snapshot(tree object) as commit object in local repository")
            .arg(Arg::new("message")
            .short('m')
            .value_parser(clap::builder::NonEmptyStringValueParser::new())
            .help("Add message to commit object ")
            .required(true)));

    match cmd.get_matches().subcommand() {
        Some(("add", sub_m)) => {
            let filename: Option<&String>  = sub_m.get_one("file");
            match filename {
                Some(f) => println!("filename: {}", f),
                None => panic!("Required file path"),
            }
        },
        Some(("commit", sub_m)) => {
            let message: Option<&String> = sub_m.get_one("message");
            match message {
                Some(m) => println!("message: {}", m),
                None => panic!("Required message"),
            }
        },
        _ => {},
    }
    
    Ok(())
}

少しの実装で以下のような実行結果を得れます。

実行結果
% cargo run
This is Original Git

Usage: ngit [COMMAND]

Commands:
  add     Snapshot latest working directory
  commit  Register snapshot(tree object) as commit object in local repository
  help    Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version

何もしなくてもhelpメッセージが実装されています。そして、arg_required_else_help(true)としているので、引数がなければhelpを実行されます。

% cargo run add first.txt
filename: first.txt

% cargo run commit -m 'initial'
message: initial

そして、サブコマンドは上記のように、うまく分岐されその後に続くprintが実行されています。

実際の運用は、このcmd.get_matches()で取り出されるArgMatch構造体をmatchで区別しながら、実装した関数へと繋げていきます。ここでは、長くなるので、記載しませんが、リポジトリに記載しているので興味ある方はみてください。

コマンドライン引数を解析できれば、半人前のCLIの完成です。Clapには、充実した構造体と関数があるので、引数に受けるデータ型を確定させることもできます。うまく活用して、コマンドラインの段階でエラーハンドリングしましょう。今回は、addとcommitしかとしていませんが、実装過程で作成した配管コマンドを入れるとちょっとリッチな実装にできます。

addcommit(コマンド)

さあ最後の仕上げです。これまでの関数を繋げることでこの二つのコマンドを実現します。
各コマンドと配管コマンドの関係は、理解編 コマンドの裏で起こっていることを参照してください。
もうあとは繋ぐだけ、上で作成した関数群とCLIを使ってさらっと実装していきます。

add

fn main() -> std::io::Result<()> {

    // 省略 ...

    match cmd.get_matches().subcommand() {
        Some(("add", sub_m)) => {
            let filename: Option<&String>  = sub_m.get_one("file");
            match filename {
                Some(f) => add(f)?, // 変更したところ
                None => panic!("Required file path"),
            }
        },
        // 省略 ...
    }
    
    Ok(())
}

fn add(file_path: &str) -> std::io::Result<()> {
    write_blob(file_path)?;
    update_index(file_path)?;

    Ok(())
}

commit(コマンド)

fn main() -> std::io::Result<()> {

    // 省略 ...

    match cmd.get_matches().subcommand() {
        // 省略 ...
        Some(("commit", sub_m)) => {
            let message: Option<&String> = sub_m.get_one("message");
            match message {
                Some(m) => commit(m)?,
                None => panic!("Required message"),
            }
        },
        // 省略 ...
    }
    
    Ok(())
}

fn commit(message: &str) -> std::io::Result<()> {
    let tree_hash = write_tree()?;
    // 直前のコミットと被るとNoneが帰ってくるので、その時は標準出力でお知らせしましょう。
    match commit_tree(&tree_hash, message)? {
        Some(c) => update_ref(&c)?,
        _ => println!("Nothing to commit")
    };

    Ok(())
}

さあここまで来たらCLIっぽく実行してみましょう。

# 実行ファイル(release version)を作成して、
% cargo build --release

# 作成した実行ファイルまでのPATHを通す
% export PATH=$PATH:/<path></to>/ngit/target/release

# 場所を移動してみて新しいディレクトリとgitのリポジトリとファイルを追加する
% mkdir test && cd test && git init && echo MY GIT! >> test.txt

# さあ実行してみよう
% ngit add test.txt
% ngit commit -m 'initial'
% git log
commit 5e44f5969522e7054a3a8cbf328ad6df172995ad (HEAD -> master)
Author: noshishi <nopeNoshishi@nope.noshishi>
Date:   Sat Mar 25 02:28:15 2023 +0900

    initial

===================================================================
% echo '{"kotarou": 3}' >> test2.json
% ngit add test2.json
% ngit commit -m 'second'
% git log
commit 429a91ba5e0dd61d7a8b6460243cbe24ebfb27fb
Author: noshishi <nopeNoshishi@nope.noshishi>
Date:   Sat Mar 25 02:42:34 2023 +0900

    second

commit 5e44f5969522e7054a3a8cbf328ad6df172995ad
Author: noshishi <nopeNoshishi@nope.noshishi>
Date:   Sat Mar 25 02:28:15 2023 +0900

    initial

ついに完成!最終コードは、こちらに乗っけてます。煮るなり焼くなり、お好きにお使いください。

最後に

最後までご覧くださりありがとうございます。
ここまでくると、log見たいし、branch作りたいし、checkoutも作りたい、あわよくばmergeも!と欲深くなると思います。(そんなことはないか笑)

ただ、今回作成したGitもどきは、まだまだ不完全です。コマンドの幅がないことやディレクトリがネストした場合に対応できない、パスの問題、強力なRustのツール(Traitなど)を使えてないし、コードが非効率などなど。改良・改善の余地が多分に含まれます。

ぜひ皆さんも好きな言語や新しい言語を使ってGitを作ってみてはどうでしょうか?
皆さんのGitへの理解を深めることのお手伝いができていれば幸いです。

ありがとうございました!

あとがき

そして、やっと書き終えた!!!!!自分お疲れと言いたい!笑笑

Rustを使用して、超簡単なGitを作成してみました。理解してるけど作ってみるというのは、やっぱり全然違いますね。自分の浅はかさを理解するいいきっかけになりました。また、Rustという言語に触れることで、普段意識しないプログラムのことについて少しはわかったような気もします。

次は、Webアプリケーションを開発しながら、Rustのメモリ管理などに触れていけたらと考えています。

ちなみにRustのモクモク勉強会なるものを開催しています。(著者が弱々エンジニアで大変申し訳ないですが、)ゆっくり自分のペースでRustを学ぶことにしています。参加される方がツヨツヨすぎるので、主催者なのに無茶苦茶質問してます。よければぜひご参加ください。connpass

67
66
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
67
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?