git init の代わりに使う git start コマンドを作った

git init でリポジトリをつくると、一番最初のコミットは差分が見れないんですよね。
まぁ最初のコミットを小さくすれば済む話なのですが、ツールの都合に人間が合わせるってのはできる限り避けたいので、git init の代わりとなるサブコマンドを作りました。

git start

これをパスの通ってるところに置いて、あとは git init とする代わりに git start とするだけです。
コマンド名は、git empty とか git maeda とか git maeda_ とか迷いましたが、@maeda_ (まえしぃ) が git start って言ったのでそうしました。

コンフリクトが発生しなくても壊れる場合

push したら誰かが先に push していたので失敗した。
なので pull したが、コンフリクト (競合) は発生しなかったので何も確認せずにそのまま push した。

何も問題なさそうですね。
・・・本当ですか?
例えばこんな状況を考えてみましょう。

最初の状態

A さんと B さんと C さんが登場します。
作っているのは Web ページで、コードはこんな感じ。

<html>
    <head>
        <title>hoge</title>
        <style>
            .menus {
                overflow: auto;
            }
            ul {
                margin: 0;
                padding: 0;
                list-style-type: none;
            }
            .button {
                float: left;
                width: 100px;
                margin: 0;
                padding: 10px 0;
                text-align: center;
                background-color: royalblue;
                color: white;
            }
            .button:hover {
                background-color: cornflowerblue;
            }

            h1 {
                font-size: 20px;
            }
        </style>
    </head>
    <body>
        <div class="menus">
            <ul>
                <li class="button">Home</li>
                <li class="button">Hoge</li>
                <li class="button">Piyo</li>
                <li class="button">Foo</li>
                <li class="button">Bar</li>
            </ul>
        </div>

        <div id="content">
            <h1>form</h1>
            <form action="http://example.com">
                <textarea rows="10" cols="60"></textarea>
            </form>
            <input type="submit"/>
        </div>
    </body>
</html>

ブラウザで表示すると、

こうなります。
A さんと C さんは、この状態の HTML をローカルのリポジトリ内に持っています。
B さんはまだ何も持っていないとしましょう。

A さんがクラス名を変更

A さんが自分の作業として、クラス名を変更しました。
メニューの各項目のクラス名はもともと button だったのですが、この変更で menu に変わります。

@@ -10,7 +10,7 @@
                 padding: 0;
                 list-style-type: none;
             }
-            .button {
+            .menu {
                 float: left;
                 width: 100px;
                 margin: 0;
@@ -19,7 +19,7 @@
                 background-color: royalblue;
                 color: white;
             }
-            .button:hover {
+            .menu:hover {
                 background-color: cornflowerblue;
             }
 
@@ -31,11 +31,11 @@
     <body>
         <div class="menus">
             <ul>
-                <li class="button">Home</li>
-                <li class="button">Hoge</li>
-                <li class="button">Piyo</li>
-                <li class="button">Foo</li>
-                <li class="button">Bar</li>
+                <li class="menu">Home</li>
+                <li class="menu">Hoge</li>
+                <li class="menu">Piyo</li>
+                <li class="menu">Foo</li>
+                <li class="menu">Bar</li>
             </ul>
         </div>

クラス名を変えただけなので見た目は変わりません。
問題なさそうなので A さんが push しました。

C さんが A さんの変更を取り込まずに、作業開始

A さんと C さんは同じくらいの時間に一番最初のコミットを取り込み、作業を開始したのでそもそも C さんの作業開始時には A さんは push していません。
で、C さんは 3 つのコミットをします。
最初に、フォームの説明を追加しました。

@@ -41,6 +41,7 @@
 
         <div id="content">
             <h1>form</h1>
+            <p>message:<p>
             <form action="http://example.com">
                 <textarea rows="10" cols="60"></textarea>
             </form>

そして、ページの下にもメニューを追加しました。

@@ -47,5 +47,15 @@
             </form>
             <input type="submit"/>
         </div>
+
+        <div class="menus">
+            <ul>
+                <li class="button">Home</li>
+                <li class="button">Hoge</li>
+                <li class="button">Piyo</li>
+                <li class="button">Foo</li>
+                <li class="button">Bar</li>
+            </ul>
+        </div>
     </body>
 </html>

最後に下のメニューの位置を調整しました。

             .button:hover {
                 background-color: cornflowerblue;
             }
+            #content {
+                margin-bottom: 1em;
+            }
 
             h1 {
                 font-size: 20px;

問題ないことを確認し、push しようとしたのですが、割り込みが入りそちらの作業をし始めます*1

B さんがリモートリポジトリを clone し、送信ボタンを修飾

B さんがリモートリポジトリを clone しました。
これは、A さんの変更のみが反映された状態です。
B さんは自分の作業に取り掛かり、送信ボタンの背景色を赤にし、文字色を白にしました。

@@ -26,6 +26,11 @@
             h1 {
                 font-size: 20px;
             }
+
+            .button {
+                background-color: red;
+                color: white;
+            }
         </style>
     </head>
     <body>
@@ -44,7 +49,7 @@
             <form action="http://example.com">
                 <textarea rows="10" cols="60"></textarea>
             </form>
-            <input type="submit"/>
+            <input type="submit" class="button"/>
         </div>
     </body>
 </html>

これを表示すると、

こうなります。
問題なさそうなので B さんが push しました。

C さんがやっと push、しかし・・・

割り込み作業が終わり、C さんが push しました。
が、すでに A さんと B さんの作業がリモートリポジトリに反映されており、push が reject されてしまいました。
そこで、git pull して A さんと B さんの作業を取り込むと、コンフリクトなしに pull が終了しました。
ここで最初の

push したら誰かが先に push していたので失敗した。
なので pull したが、コンフリクト (競合) は発生しなかったので何も確認せずにそのまま push した。

を思い出してください。
今この状態、確認してみると

こうなっています。
あわわわわ、壊れちゃってますね。
各作業は問題なかったのに、どこで壊れちゃったんでしょうか?
各作業の要点を抜き出してみましょう。

  • A さんが button クラスを menu クラスに変更
  • B さんが button クラスを追加 (A さんが変更する前の button クラスとは別物)
  • C さんが button クラスを使ったパーツを別の場所で使用

こんな感じです。
ここで、B さんは button クラスを「送信ボタンに割り当てられたクラス」と認識していますが、C さんは「メニューの項目に割り当てられたクラス」として認識しています。
しかし、独立した場所に対して作業を行っていたため、コンフリクトは発生せずに pull によるマージコミットが作り出されたのです。


こんなことが起こりうるので、コンフリクトしなかったとしても全体としては壊れてしまう場合がある、と言うことは肝に銘じておいてください。

誰が悪い!

ではここからは誰がどこで行ったコミットが悪かったのかを、bisect で探していきましょう。
C さんが

git bisect start master 3a9535256cd

としました。ここで、3a9535256cd というのは一番最初の状態の SHA-1 ハッシュです。
すると、自分の最後の修正である、メニュー位置の調整の状態が選択されました。
ここはうまく動いています。なので、次に進みます。

git bisect good

としました。
すると、B さんの修正である、送信ボタンの修飾のコミットが選択されました。
表示して確認する C さんですが、問題は見つかりません。次に進みます。

git bisect good

としました。
すると、「マージコミットのハッシュ値 is the first bad commit」と表示されました。
これは当然ですね。各ブランチでの作業はなんら問題なかったので、問題となるのはマージミスです。
つまり犯人は・・・Git!?

もし rebase していたら・・・

C さんはまだ push を行っていないので、マージコミットを取り消して rebase を試してみることにしました。

git bisect reset
git reset --hard master^
git rebase origin/master

こんな感じのコミットグラフになります。

S---A---B---C1---C2---C3

S が一番最初の状態で、A は A さんの変更、B は B さんの変更、Cn は C さんの n 番目の変更を表しています。
これまたコンフリクトもなしに成功しますが、bisect は何を見つけ出すでしょう?
今、master は C3 にいます。

git bisect start master S

最初に、B さんのコミットが選択されました。
確認しますが、問題ありません。次に進みます。

git bisect good

としました。
画面下にメニューを追加したコミット (C2) が選択されました。

おっと、壊れています。次に進みます。

git bisect bad

と、bad を指定しました。
すると、フォームの説明を追加したコミット (C1) が選択されました。
確認しますが、問題ありません。次に進みます。

git bisect good

としました。すると、以下のように表示されました。

e60b08cbabb09fdaca5f6e903f5c9ecf8256672a is the first bad commit
commit e60b08cbabb09fdaca5f6e903f5c9ecf8256672a
Author: C <C>
Date:   Tue Jan 17 13:15:42 2012 +0900

    画面下にもメニューを追加

:100644 100644 07f47804a33eed4acfa81559c75477b6ef39ea7a 82ca071bc6376ff00463518abaf192c0a5cd801b M      index.html

おー、このコミットがダメだったのですね。
確かに、一本化した歴史上で見てみると、画面下にメニューを追加 (C2) するよりも前にクラス名が変更 (A) されています。
なので、A の変更での意味をくみ取り、C2 のコミットで「button ではなく menu を使う」ように rebase -i して push しました。


このように、rebase して歴史を一本化することによって、マージの時よりも適切に問題個所を発見することができるようになります。
一つ注意しなければならないのは、「マージの時はこれに対応するコミットには問題はなかった」という点です。
あくまで問題だったのはマージコミットであり、マージミスなのです。
それに対して rebase では、複数のブランチの差分を一手に引き受けるようなコミットは存在しません*2し、歴史が一本化されています。
そのため、bisect でピンポイントにまずいコミットを見つけることができるのです。


rebase は歴史をきれいにすることも目的ではありますが、それだけではないのです。

ちなみに

git help git

して開いたページの一番下の SEE ALSO に、「The Git User's Manual」というリンクがあります。
これを開き、目次の「5. Rewriting history and maintaining patch series」の中の「Why bisecting merge commits can be harder than bisecting linear history」を選択してください。


書いてあるッ・・・!

*1:電話でも受けたと思ってください。

*2:squash や fixup したら別ですが

質問に答えてみる

リポジトリ見てたら、question.txt なるファイルを見つけたので、その質問に答えてみます。

hard オプションなしの git reset に何の意味があるの?

reset には、ファイルを指定する reset と、ファイルを指定しない reset の 2 種類があります。
で、hard オプションを持つのは、ファイルを指定しない方です。
まずはこちらから見ていきましょう。

ファイルを指定しない reset

ファイルを指定しない reset には、

  • soft
  • mixed
  • hard
  • merge
  • keep

と言う 5 つのモードがあります。
ここでは代表的な上三つを紹介します。
デフォルトは mixed で、これが質問にあった「hard オプションなしの git reset」です。

soft

このモードでは、HEAD のみ指定コミットの状態となり、インデックスも作業ツリーも変更されません。
これは、ベアリポジトリに対して「今のブランチを別の場所に動かしたい・・・」と言うときに使えます。
というか、その他の便利な使い方は思いつきません・・・

mixed

このモードでは、HEAD とインデックスは指定コミットの状態になりますが、作業ツリーの状態は reset 前のままとなります。
これは、「あ、さっきのコミット、add し忘れてる!」とか、「あ、さっきのコミットでテスト壊しちゃってる!」のような状況をやり直すのに最適な状態です。
なので、mixed モードは「今の状態からちょっとだけ違う状態のコミットを作り出したい」場合によく使います。
必然的に、指定するコミットは HEAD^ のような相対値を使うことが多くなるでしょう。


ひとつ前のコミットに対しては、git commit --amend があるため、あまり出番はないかもしれません。
また、更に前のコミットに対しては、rebase -i がより柔軟なため、これまたあまり出番はありません。
git-now の id:mzp (みずぴーさん) の拡張に --fixup というオプションがあって、そこで rebase -i を使わずに now コミットをまとめるために使われていたりします。

hard

このモードでは、HEAD もインデックスも作業ツリーも、全てが指定コミットの状態に変更されます。
ブランチがもともと指していたコミットに辿りつける他のブランチやタグが無かった場合、gitk で Ctrl-F5 をすると消えてしまったように見えるように、このモードではブランチを移動させます。
reflog を使うなどしてまた辿りつけるようにする (gitk で表示させる) ことは容易ですが、Git のオブジェクトモデルをあまり理解していないうちは使わない方がいいでしょう。

ファイルを指定する reset

ファイルを指定する reset は、git add の反対の動作をするコマンドです。
git add はインデックスに状態を書き込むコマンドですが、ファイルを指定する reset はインデックスへの登録をキャンセルするコマンドです。
git add 同様、-p オプションを持っていますので、「あ、git add hoge」ってしちゃったけど一部要らない修正も add されちゃった!」と言うときに、git reset -p hoge すると、対話的に reset することが可能です。

過去のコミットにしかないファイルを参照したくなったけど、どうやるの?

方法としてはいくつか考えられますが、好きな方法を使うといいでしょう。
ハッシュ値とパスを指定して checkout するか、gitk などの GUI を使うのがお手軽でいいのではないでしょうか。

過去のコミットを取り戻したくなったけど、どうやるの?

あるコミットで行った変更を、今の状態に対してもう一回行いたい、と言う場合は、cherry-pick を使いましょう。
そうではなく、作業ツリーの状態を過去のコミットの状態にしたい、と言う場合、

  • そのコミットに reset する
  • そのコミットを checkout する

の 2 通りが考えられます。
過去のコミットの状態にした後、また取り戻す前の状態に戻って作業する場合、どちらでもいいので好きな方を使うといいでしょう。
そうではなく、その過去の状態からさらにコミットしていきたい場合は、reset を使ってください。
この場合に checkout を使うと、辿りつけないコミットを伸ばしていくことになってしまいます。
ただし、reset を使うのは上でも述べたとおり、Git のオブジェクトモデルを理解していることが前提です。
そうでない場合は、そのコミットに新しくブランチを作り、それをチェックアウトするのがいいでしょう。

git checkout -b ブランチ名 ハッシュ値

のようにすると、それを一気に行ってくれます。

文字化けはどうすればいいの?

Software Design 12 月号を買いましょう!
このあたりは自分でもまとめたいところですね。

どれくらいの頻度でコミット、push すればいいの?

これは、どうやって運用しているかにもよるので一概には言えないです。
自分は、Redmine のチケットとトピックブランチを対応させ、そのトピックブランチの中でコミットを複数回やっています。
push に関しては、一つのトピックブランチが終了したら push する感じですね。

Developer's Test 勉強会に行ってきた

すごい楽しかった!
事の発端は、「SCMBC の時の資料を使わせてもらえないか?」というツイートでした。




最近大阪行ってないなー、面白そうだなー、ということで行ってきました!
得るものがたくさんあったので、行って正解でした。
資料の公開はもうちょい待ってください。


以下感想。

  • (K) ポジションペーパを Github に push してもらって、pull request を投げてもらうというのは良かった。
    • (T) ただ、マージの時にテーブルごとにディレクトリを分けてマージした方がよかったかも?
  • (K) 講演の反応は上々。これまた行ってよかったと思えてうれしいです!
    • (K) 「Git の勉強会には何回か参加したことがあるけど、今までで一番分かりやすかった」と言ってもらえた!
    • (T) gitk は紹介しといたほうがよかった。
    • (T) git status も。
  • (P) サポートの人数が少ないと、手が回らなくなる。やっぱり 1 テーブルに 1 人 TA がいる体制は素敵。
    • (P) だけどいつもそれだけの人数をそろえられるとは思えないので、どうにかしたい。
  • (P) 環境構築のサポートが不完全で、文字化けや改行コードに悩まされるグループが多かった。
    • (T) 環境構築をさくっと終わらせてくれるツールを書きたい。
  • (K) 各演習の終わりには、困ったことを話してもらうようにしたけど、これがいい感じだった。
    • (K) 1 グループ 1 分から 2 分くらいで十分。
  • (K) 最後の演習の終わりは、俺の PC に全グループのリポジトリをリモートに追加して、プロジェクタに写しながら進めたけど、これもいい感じだった。
    • (K) PC を繋ぎ直す必要がないので、トラブルで時間を食うことはないし、切り替えの時間も食うことがない。
    • (K) 自分が PC を操作するので、突っ込みを入れたり、解説入れたりできる (邪魔だったかもしれないけど)。

以下勉強会自体とは関係ない感じの感想。

  • id:kiy0taka (きよたかさん) が TDDBC 北陸に参加していたことを知った。
    • 第一回の Jenkins 勉強会の Ust を見て、すっかりファンになったんだけど、その前に会っていたという事実。
    • そのとき Git で発表してたことが印象に残っていたらしい。
  • id:coolstyle (こくぼさん) に会ってみたいという人が多かった。
    • アイコン通りの人です、と言っておいた。
  • 懇親会の豚が美味しかった。
    • TDDBC 福岡の参加者のみのテーブルができたりした。あれ、ここ大阪・・・
    • 名古屋を Dis られた・・・ような・・・?
  • id:irof (いろふさん) の部屋がすごいきれいだった。
    • 誰かさんの部屋とは大違い。
  • バナナの串カツは普通にいけた。

SCM Boot Camp 2 in Tokyo に行ってきた

今回は Git の講師として参加しました。
2 回目と言うこともあって、よりスムーズに進めることができたように思います。

個人的なもくろみ

今回参加して、SCM Boot Camp の DVCS イベントの部分はある程度パッケージ化したいと考えるようになりました。
まだ 2 回しかやっていませんが、DVCS (少なくとも Git) の効率的な習得のためのスキームというのが見えてきた感じです。
最終的には、講師が 1 人いれば小規模なイベントを開くことのできるところまで落とし込めるのでは、と考えています。


以下時系列順の雑多な感想や補足など

開催前

今回は、開催の準備として数回 Lingr によるミーティングを行いました。
資料の作成も段階的に行うことができたのでフィードバックを取り入れることができてとてもよかったです。
にも関わらず、

となってしまったのは反省点です。
次回以降やりたいのは、

  • 全てを Github で管理 (Issue とかも使って)
  • ミーティング開催までにやることをよりはっきりさせる (Milestone と Issue)
  • 発表用とは別に、配布する用の資料の作成

の 3 つです。

発表

基調講演の後に Git の入門セッションを発表したわけですが、直前まで誰が発表するかすら決まっていなかったという。


とあるように、slideshare の図が間違ってます(汗
これに関しては、Github のリポジトリから取得できるものでは修正してありますので、そちらをどうぞ。

勘違いしないでよね!
これはちゃんと指摘できるか試すためにわざと間違えておいたんだから!

・・・嘘ですごめんなさい。

入門・・・セッション・・・?

入門セッションと言いつつ、

という。
これに関しては別のところで、

とあり、まさにその通りです。
ただし、一番最初にこれを言うべきでした。
次回以降の資料にはここについても考慮したものを用意します。

ぶれいすさんのようすが・・・


・・・はい。

前日 2:30 頃まで資料作成

5:30 起床

新幹線で闇LT用の資料作成

会場で発表資料の最後の手直し

発表

という流れで、「3時間しか寝てないわー」な状況だったのです。
・・・どう見てもいいわけです。本当にごめんなさい。

全体的な話

言い訳はこのくらいにして、今回の発表資料と発表内容について。

とつぶやいたように、今回の資料は Git チーム全員で作りました。
これまで Git に関する発表資料は 1 人で作っていたんですが、今回の資料はそれらを上回るものになりました。
発表中の反応も上々で、発表中に Git のオブジェクトモデルを理解して、資料中の間違いを見つけるまでになった人がいたのは嬉しかったです。

演習

演習は、id:katzchang がいたおかげで割とスムーズに進みました。
1 テーブルに 1 人、katzchang さんが欲しいところです。
というのは冗談としても、Git の簡単な使い方を理解している人はテーブルに 1 人以上いるとスムーズですね。
自分が担当したグループでは、katzchang さんの他にももう一人 Git ユーザがいたので、俺の存在意義があばばばば

Github の準備

前回よりはよくなりましたが、やはり Github の準備に関するサポートはもうちょっと行うべきでした。
具体的には、

  • Github の簡単な使い方ガイド (Collaborators への追加など、演習に必要な範囲のみ)
  • 各環境ごとの鍵ペアの作り方と、登録方法
  • 鍵ペアの作成/登録がうまくいかない場合の HTTPS を使った方法
  • pagent の話

が書かれた資料を用意しておく、と言った感じでしょうか?

おススメ設定の提供

msysgit を使っている人は、デフォルト状態で git-completion とか現在の working tree の状態の表示とかが有効になっているのでいいのですが、他の環境ではそうではないので、そこら辺の設定を用意しておけばよかったです。
もちろん使う人にとってもやさしいですし、講師も参加者の PC を操作することや画面を見ることがありますので、講師にとってもやさしいと思うのです。


他にも、Vim の最低限の設定なんかもあったほうがよかったです。
デフォルト状態では、ファイルのエンコーディングが分からないので、cp932 でコミットメッセージを保存してしまって文字化け、という人が何人かいました。
これに関しては、How to install Git の方でカバーする話な気もします。

gitk

gitk (やそれに類するもの) は、常に立ち上げておいた方がいいです。
これは、演習で必要な操作方法を含めて入門セッションの内容に組み込んだ方がいい内容でした。

トピックブランチの導入

マージ地獄でコミットグラフがぐちゃぐちゃになる、というのはなかなか体験できないことだと思います。
なので、これは基本的に続けたいと思うんですが、途中でトピックブランチを導入してコミットグラフをきれいに保つ、という運用も今回取り入れてみました。
が、サポートが足りなかったようで、最後マージが発生してしまいました。
このあたりはよりスムーズに導入できるようにもうちょっと考慮が必要な部分です。

TortoiseSVN ユーザのための Git Bash 入門の必要性

TortoiseSVN 便利なんですけど、それしか使ったことが無い人にいきなり Git Bash 使えというのはスパルタすぎるなぁ、と思い直しました。
なので、

  • 演習に最低限必要なコマンドの説明
  • ディレクトリやオプションとかを Tab で補完できる話
    • あと「困ったらとりあえず Tab を 2 回叩く」とかそういう
  • ディレクトリ名の横に出ている (master) の部分の意味

あたりを用意しておいた方がいいかなー、と思いました。

次の一歩

Git グループずるいわー、俺らがあたふたしてるの横目で見てしてやったりとか思ってたんでしょー?

と懇親会でなじられた Git グループですがw
これも演習中だか休憩中だかに思いついたものですよ!と言うと、

でも教えてくれればよかったじゃーん

確かにw
事前に質問を集めておいてそれに答える、というスタイルは時間の制御もしやすくていい感じでした。

懇親会

懇親会はカオスでした。
なんか持ち上げられたりこわいこわい言われたり・・・こわくないよ!

闇 LT

LT と言いつつほとんどの人が時間を守らないという。みなさん時間は守りましょう!!!

花映塚

前回、神速さんと「時間があったら花映塚やりましょう!」と約束していて結局できなかったのが心残りだったのですが、今回実現しました!
隅っこの方でこっそりやろうとしていたら見つかって一時見世物状態にw
いやー、次回以降もやりたいですね!(ぇ

最後に

すごい楽しかった!
次は名古屋らしいですよ?

TDD Boot Camp のお題を C# と Git でやってみた

自分で考えたお題を自分で解くとかそれなんてマッチポンプ・・・


打ち上げ終了後のホテルと、翌日の帰りの新幹線の中で書いたコードを順番に追ってみます。
準備するものは Git で、あるといいものは Visual Studio 2010 と NUnit です。
まぁ、割と小さいコード (テストを含めても 300 行もない) だし C# を知らない人でもそれなりに雰囲気は掴めると思います。


あ、このエントリかなり長いです。

準備

Windows の場合、Git Bash を開いて、適当なフォルダに移動して

git clone git://github.com/bleis-tift/MotsunabeZombieProject.git
cd MotsunabeZombieProject

としてください。
MotsunabeZombieProject というフォルダができて、その中に Git のリポジトリとかができます。
Mac とか Linux とかでも基本そんな感じで、まずは clone してきてください。


git clone は当日説明しなかったけど、すでにあるリポジトリを丸ごと取ってくるコマンドです。
git init が空っぽのリポジトリを作るのに対して、git clone は既存のリポジトリのコピーをローカルに作ります。


clone したら、gitk --all & してみてください。
master ブランチの他に、タグがいくつかあるはずです。
これらのタグは、git now --rebase する前に打ったタグなので、どういうタイミングで git now しているかの参考にしてください。

お題

お題を簡単にまとめると、「つぶやきの種別を判定するシステム」です。
チケットとしては、

  1. 普通のつぶやきを判定
  2. ハッシュタグを含むつぶやきを判定
  3. リプライを含むつぶやきを判定
  4. メンションを含むつぶやきを判定
  5. 複数の種別を含むつぶやきを判定
  6. ネットワークからつぶやきを取得して判定
  7. 非公式 RT を含む判定
  8. 現在時刻の前後 30 分のつぶやきを最大 20 件判定
  9. URL を含むつぶやきを判定
  10. 短縮 URL の展開

の 10 個で、この中の 1 〜 6 までを実装してあります。
タグに含まれる数字と、このリストの数字が対応しています。

まずは最終形を眺める

とりあえずどんなファイルがあるか見てみましょう。

  • MotsunabeZombieProject/
    • CategorizedResult.cs
    • TweetAnalyzer.cs
    • TweetCategorizer.cs
  • MotsunabeZombieProject.Tests/
    • CategorizedResultTest.cs
    • TweetAnalyzerTest.cs
    • TweetCategorizerTest.cs

重要なファイルはこの 6 つです。
テストを除くと、クラスとしては 3 ファイルに 4 つのクラスが定義されています。

TweetCategorizer
つぶやきの種類を判定するためのクラス
CategorizedResult
判定結果などを保持するクラス
TweetAnalyzer
与えられた URL のつぶやきを判定するためのクラス
TweetProvider
つぶやきを TweetAnalyzer に与えるためのクラス

特徴的なのは、CategorizedResult を導入したところでしょうか。
このクラスを導入したため、カテゴリに対するテスト (TweetCategorizerTest.cs) が簡潔かつ分かりやすくなりました。
これは、文字列への変換を CategorizedResult 自体に持たせたことによって、TweetCategorizerTest の方では文字列の一点比較を行う必要がなくなったからです。


TweetProvider というクラス名は Java のペアが使っていたものをそのまま使わせてもらいました。
こいつはテスト用のクラスで、外部から文字列配列を渡したものを TweetAnalyzer に設定するとその文字列配列を使ってくれます。
何も設定しなければ、TweetAnalyzer はデフォルトの TweetProvider を使うため、HTTP 通信を行ってつぶやきを取得します。
この小さなクラスに通信を任せてあるため、他の部分ではネットワークを全く意識する必要がありませんし、テストも完全にオフラインで実行できるようになっています。


テストを除く各ファイルの行数を見てみると、どれも 50 行に収まっています。
メソッド内の行数を数えてみても、今のところ最長で 6 行と、非常にコンパクトに収まっています。
テストコードとプロダクションコードの行数を見てみると、テストコードが約 140 行、プロダクションコードが約 130 行と、だいたい半々になりました。


テストの総数は 23 個で、カバレッジは 87.04 % です。
カバーされていないところを見ると、

  • 実際にネットワークに接続しに行く部分 (TweetProvider.GetTweetsFromUrl メソッド) 全て
  • その呼び出し部分 (TweetProvider.GetTweets メソッド) の一部
  • Debug.Assert で前提条件を埋め込んでいる部分
  • 正規表現の一部

となっていました。最後が若干気になりますが、他は問題なさそうです。


実装時間は合計 4 時間くらいで、残っているコミット間の時間を調べてみると、

最長 最短 平均
約20分 0.4分 約4.8分

となりました。
コミット総数は 67 ですが、残していない git-now のコミットもあるため、実際にはもうちょっと増えます。

普通のつぶやきの判定

gitk は立ち上がっているでしょうか?立ち上がっていない場合、

gitk --all &

として立ち上げておいてください。


さて、ではチケット 1 の実装から見ていきましょう。
昔の状態を取ってくるのには、checkout が使えます。
でも、ここではブランチに馴染んでもらうためにコードを追うためのブランチを作って、ブランチを移動させることでコードを追いましょう。

git checkout -b hoge da1939

これでコードを追うためのブランチ hoge を作成し、そのブランチに切り替わりました。
da1939 というのは、ブランチを作成するコミットのハッシュ値の先頭 6 文字です。
Git では Subversion と違い、リビジョン番号ではなくハッシュ値によってコミットを識別します*1


gitk に移り、F5 キーを押してください。
左上の領域 (コミットグラフ) を一番下までスクロールすると、先ほど作った hoge ブランチが確認できます。
hoge が太字になっていることからもわかるように、現在の作業ディレクトリの中はさっきまでの最新のものではなく、da1939 のものになっています。


ようやくコードです。
TweetCategorizerTest を見ると、テストケースが一つだけ書かれています。

var categorizer = new TweetCategorizer();
var result = categorizer.Categorize("bleis\tほげほげ");
Assert.That(result, Is.EqualTo("Normal\tほげほげ"));

テストコードを書くときの癖なんですが、まず Assert.That(result, Is.EqualTo("Normal\tほげほげ")); までを一気に書き上げてしまいます。
そして、var result = ... と上に上に組み立てていきます*2
こうすることによって、「結果としてほしいもの」と「どうやってそれを手に入れるべきか」を同時に考える必要がなくなります。
まずは結果として欲しいものを書き下しておいて、それからどうやってそれを手に入れるかの API を考えている、ということです。
そのため、

var categorizer = new TweetCategorizer();
Assert.That(categorizer.Categorize("bleis\tほげほげ"), Is.EqualTo("Normal\tほげほげ"));

とは動作としては同じなのですが、考え方としては全く別 (こちらは API をまず考えて、その結果を後で考えている) と言えます。
もちろん、テストコードのリファクタリングを行って後者の形にすることもあります*3


次に実装コードを見てみましょう。
TweetCategorizer ですが、まだ普通のつぶやきにしか対応しないため、本文の先頭に "Normal\t" を追加するだけになっています。
特に説明する部分もありませんので、次に行きましょう。

ハッシュタグを含むつぶやきの判定

以下のコマンドを実行してください。

git reset --hard d6950

このコミットは、さっき見たコミットの次のです。
テストケースを追加し、それを満たす実装を行っています。
特に問題はないでしょう。進みます。

git reset --hard e36fb

コミットを一つ飛ばしました。
このコミットの前に、ハッシュタグの判定のケースを増やそうとしてテストメソッドを書き始めたのですが・・・

[Test]
public void ハッシュタグ付きのTweetがHashTagに判定される()
{
    var categorizer = new TweetCategorizer();
    var result = categorizer.Categorize("bleis\tほげほげ #hash");
    Assert.That(result, Is.EqualTo("HashTag\tほげほげ #hash"));
}

[Test]
public void 数字のみの場合はNormalと判定される()
{
    var categorizer = new TweetCategorizer();
    var result = categorizer.Categorize("bleis\tほげほげ #1234");
    Assert.That(result, Is.EqualTo("Normal\tほげほげ #1234"));
}

と、ほとんど同じ内容になりそうだったので、このように書かずに TestCase 属性を使うようにテストをリファクタリングしようと思いなおしました。
このコミットはその第一歩で、まず既存のテストと同じものを TestCase 属性を使って書き直しています*4

git reset --hard 4a15

さっきのコミットの次のコミットですが、TestCase 属性のテストが通ったので、既存のテストを削除しました。
これ以降、git now を使って TDD のサイクルを回して行っています (work/2 のタグを参照)。
この間の各 git now の間隔は、最長約 5 分となっています。
自分の場合は git now の間隔よりも TDD のサイクルの方が短い *5 ので、TDD のサイクルは 4 分とか 3 分とかで回している感じになります。

git reset --hard 772a

チケット 2 がとりあえず終わった状態です。
テストケースがいくつか追加されており、実装もリファクタリングによってわかりやすくなっています。
特にテストケースは TestCase 属性を導入したことによって、無駄に行数を増やさずにテストケースを追加できています。素敵。

リプライを含むつぶやきの判定

git reset --hard 7d57

この時点のテストを抜粋します。

[TestCase("bleis\tほげほげ #hash", "HashTag\tほげほげ #hash")]
[TestCase("bleis\tほげほげ #1234", "Normal\tほげほげ #1234")]
[TestCase("bleis\tほげほげa#hash", "Normal\tほげほげa#hash")]
[TestCase("bleis\tほげほげ #hash", "HashTag\tほげほげ #hash")]
[TestCase("bleis\t#hash", "HashTag\t#hash")]
[TestCase("bleis\tほげほげ#hash", "HashTag\tほげほげ#hash")]
public void ハッシュタグ付きのTweetがHashTagに判定される(string record, string expected)
{
    var categorizer = new TweetCategorizer();
    Assert.That(categorizer.Categorize(record), Is.EqualTo(expected));
}

[TestCase("bleis\t@t_wada ほげほげ", "Reply\t@t_wada ほげほげ")]
[TestCase("bleis\t@ ほげほげ", "Normal\t@ ほげほげ")]
[TestCase("bleis\t.@t_wada ほげほげ", "Normal\t.@t_wada ほげほげ")]
public void リプライ付きのTweetがReplyに判定される(string record, string expected)
{
    var categorizer = new TweetCategorizer();
    Assert.That(categorizer.Categorize(record), Is.EqualTo(expected));
}

また重複が目につくようになってきましたので、テストコードをリファクタリングすることにしました。

git reset --hard b029

ScreenName は特に使っていませんし、常に固定でも問題ありませんので、ヘルパメソッドを使うようにしました。

string _(string body) { return "bleis\t" + body; }

さらにリファクタリングを続けます。

git reset --hard d6555

さっきのリファクタリングで、テストメソッドの引数は body のみを受け取るようになりました。
ということは、期待する結果はカテゴリと body をタブ文字で連結すればいいことになります。

string _(string category, string body) { return category + "\t" + body; }

さっきのリファクタリングで追加したのと合わせ、2 つのメソッドを導入したことでテストコードはこうなりました。

[TestCase("ほげほげ #hash", "HashTag")]
[TestCase("ほげほげ #1234", "Normal")]
[TestCase("ほげほげa#hash", "Normal")]
[TestCase("ほげほげ #hash", "HashTag")]
[TestCase("#hash", "HashTag")]
[TestCase("ほげほげ#hash", "HashTag")]
public void ハッシュタグ付きのTweetがHashTagに判定される(string body, string expectedCategory)
{
    var categorizer = new TweetCategorizer();
    Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body)));
}

[TestCase("@t_wada ほげほげ", "Reply")]
[TestCase("@ ほげほげ", "Normal")]
[TestCase(".@t_wada ほげほげ", "Normal")]
public void リプライ付きのTweetがReplyに判定される(string body, string expectedCategory)
{
    var categorizer = new TweetCategorizer();
    Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body)));
}

重複が消え去った上、どういったテストケースなのかが非常にわかりやすくなりました。
実装コードのリファクタリングを軽く行って、チケット 3 終了です。

メンションを含むつぶやきの判定

git reset --hard 95774

実装をとりあえず終えた段階ですが、今度はテストメソッドの中の重複が気になってきました。

var categorizer = new TweetCategorizer();
Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body)));

このコードが 3 個所で使われています。ここもリファクタリングしてしまいましょう。

git reset --hard 9b065

ここでさっきまで使っていたヘルパメソッド 2 つを削除し、新たに AssertCategory メソッドを追加しました。

void AssertCategory(string body, string expectedCategory)
{
    var categorizer = new TweetCategorizer();
    Assert.That(categorizer.Categorize("bleis\t" + body), Is.EqualTo(expectedCategory + "\t" + body));
}

これによって、テストメソッドの中はこうなりました。

AssertCategory(body, expectedCategory);

いい感じです。
更に 3 つのメソッド

  • ハッシュタグ付きのTweetがHashTagに判定される
  • リプライ付きのTweetがReplyに判定される
  • メンション付きのTweetがMentionに判定される

を一つのメソッドにまとめることもできるのですが、これらはテストケースとしては別物だと思っているので、まとめていません*6

複数の種別を含むつぶやきの判定

git reset --hard 6ff2

とりあえず書いてみたものの、テストメソッドが微妙です。

[TestCase("@t_wada ほげほげ#hash", new[] { "Reply,HashTag", "HashTag,Reply" })]
public void 複数の種類を含むTweetがカンマ区切りで連結される(string body, string[] expectedCategories)
{
    var categorizer = new TweetCategorizer();
    var result = categorizer.Categorize("bleis\t" + body);
    // 順番はどうでもいい
    foreach (var exCat in expectedCategories)
    {
        if (result.StartsWith(exCat + "\t"))
        {
            Assert.Pass();
            return;
        }
    }
    Assert.Fail(string.Format("expected starts with [{0}] but [{1}].", string.Join(" or ", expectedCategories), result));
}

パッと見てこれがなんのテストなのか全くわかりません。
これはダメだと思い、判定結果を表すクラスを導入してみることにしました。

git reset --hard 27190

CategorizedResult というクラスを導入しました。
つぶやき本体と判定されたカテゴリを持つだけの単純なクラスです。

git reset --hard 706b

CategorizedResult に ToString を追加しました。
これによって、結果の文字列が等しいことを確認するテストと、判定されたカテゴリが正しいことを確認するテストを分離することができそうです。

git reset --hard f7cc

ここからコードを置き換えていきます。Git があるので、安心して置き換えを試してみることができます。
まずは普通のつぶやきの判定を行うテストを、CategorizedResult を使って書き直してみました。

var categorizer = new TweetCategorizer();
var result = categorizer.Categorize("bleis\tほげほげ");
Assert.That(result.Categories, Is.EqualTo(new[] { "Normal" }));

もちろん、categorizer.Categorize("bleis\tほげほげ") の戻り値の型は string なので、まだコンパイルは通りません。

git reset --hard 43592

Green バーが見たいので、CategorizedResult を返すように Categorize メソッドを書き換えます。
これでコンパイルが通ると思ったんですが、今度は Categorize メソッドの戻り値の型を string として扱っているテストが残っているため、コンパイルが通りませんでした。

git reset --hard 5b5f

面倒なので #if false 〜 #endif で無効化しちゃいました。コンパイルは通りますし、テストも Green です。
あとはテストを書き換えていって、無効化した範囲を狭めていきます。
テストコードのリファクタリングを行って、重複部分をかなり削っていたため、置き換えは非常に簡単に行うことができました。


CategorizedResult 導入のきっかけとなったテストが、CategorizedResult を導入することでどうなったか見てみましょう。

git reset --hard 498a
[TestCase("@t_wada ほげほげ#hash", new[] { "Reply", "HashTag" })]
[TestCase("@t_wada ほげほげ#hash", new[] { "HashTag", "Reply" }, Description="順不同")]
public void 複数の種類を含むTweetの判定結果に含まれるすべての種類が存在する(string body, string[] expectedCategories)
{
    var categorizer = new TweetCategorizer();
    Assert.That(categorizer.Categorize("bleis\t" + body).Categories, Is.EquivalentTo(expectedCategories));
}

非常にすっきりしたテストになりました。
NUnit の EquivalentTo は順番を考慮しない比較を行ってくれます。
置き換え前はひとつのテストケースに結果の組み合わせを人力で記述していましたが、CategorizedResult を導入したことによりその必要がなくなりました。
そのため、テストの追加が非常に簡単にできるようにもなっています。


いくつかテストを追加して、チケット 5 は終了です。

ネットワークからつぶやきを取得して判定

TDD Boot Camp 福岡では、ここで入力形式の変更という仕様変更が入りました。
今までは ScreenName と Body がタブ文字で区切られた形式だったのですが、ここからは yyyy/MM/dd HH:mm:ss 形式の日付が先頭にくっつき、ScreenName との間にタブ文字が置かれます。
つまり、日時と ScreenName と Body がタブ文字で区切られた形式です。


この変更に対応するために、とりあえずテストをひとつ修正してみます。

git reset --hard 4b14

テストを実行してみると、Green です。
・・・あれ?仕様変更の影響を受けていない?と思いつつ、ほかのテストも新形式に修正してみました。

git reset --hard 4efe

落ちました。リプライ判定用の正規表現で、先頭にマッチという条件が含まれていました。
新形式をそのまま既存の Categorize メソッドに渡すと、日付の部分が ScreenName として、ScreenName 以降が Body として認識されてしまうため、Reply を期待しているテストがことごとく落ちました。

git reset --hard ee2e

Body 部分の取得方法を修正し、新形式への対応完了です。
しかし、GetCategory メソッド内部でコレクションをカンマでくっつけて文字列化し、それを呼び出し側で Split するという部分が目についたので、リファクタリングを行うことにしました。
この際、チケットとして分けるのが面倒なので同じチケット内でやってしまうことが多いです。

git reset --hard 04e1

さらに LINQ を使って内部を書き直したところで、チケット 6 本来の作業に入ります。

git reset --hard d4d8

このコミットで TweetAnalyzer を導入していますが、名前の付け方に迷い、20 分くらい使っています。
結局、最終的にはつぶやきの分析を行うことになりそうなストーリーだったので*7、TweetAnalyzer としました。
更に、TweetAnalyzer では Categorize するのが目的ではないという判断から、Categorize メソッドは public ではなく internal にしました。

git reset --hard c0554

ここまでのコミットは、API を決めるために右往左往して、最終的に残ったコミットです。
TweetProvider クラスを導入して、データの取得先をこのクラスに隠ぺいすることにしました。
ここからもあぁでもないこうでもないと試行錯誤しながら、最終的な実装に落ち着きました。

git reset --hard work/6

ここでタイムアップ、新幹線が名古屋に着いたようです。
家で git now --rebase して、ゴミの除去とテストを軽くリファクタリングしたものを GitHub に公開しました。

まとめ

  • 最初のテストは Assert First (Assert から書く)
    • 結果を決めてから API を設計する (ゴールを見える状態にしておくのは重要)
    • 上へ上へ書いていくので、VsVim が便利
    • Assert.That 可愛いよ Assert.That
  • テストケースのリファクタリング
    • ヘルパメソッドの導入
    • TestCase 属性
    • TestCaseSource 属性
    • テストケースを簡潔に書くためにクラスを導入
  • git
    • git now は TDD との相性がいい
    • git now --rebase する前にタグを打つことで、自分の傾向を知ることができる
    • git のブランチはとても軽いので気軽に使える*8

*1:なんでハッシュ値なんてわけわからないものを使ってるの?という疑問を持った方は、入門 Git (大文字の方) か実用 Git をどうぞ。[http://d.hatena.ne.jp/bleis-tift/20100922/1285140344#_commitid:title=リビジョン番号がない話]あたりもどうぞ

*2:VsVim を使っている場合、O (ラージオー)を使うのが便利

*3:なので、テスト対象のメソッド呼び出しの後からメソッドチェインでつなげていくのは TDD とは相性が悪いです。もちろん、既存のコードに対してテストを追加する場合はそういうテスティングフレームワークは強力でしょうが、NUnit の Assert は TDD と相性がいいのです。一部 C#er からは Assert.That ないわー、とか言われちゃってますけど、俺はアリ派です。Assert.That 万歳!

*4:この際 result 変数を除去していますが、ある程度 API が固まったと感じたために assert first を一旦やめました。API が固まっているのに assert first するのにあまりメリットを感じないからです。この辺りは人によって考え方が違いそう。

*5:VS 上で git の操作ができないというのが git now の間隔と TDD のサイクルが一致しない主な原因だと思います。テストを実行する手間は TestDriven.NET によってほとんどないですが、git のコマンドを実行するためにはウィンドウを切り替える必要がある

*6:ただ、メンションのテストとリプライのテストに完全に同じものがあるので、これは消した方がいいのかも。

*7:後で何らかの形でお題が公開されると思いますので、参加者以外の方はそれまでよくわからないかもしれませんが、そんな感じだったのです。

*8:今回ブランチを使って過去のコードを取り出していましたが、どうでしょう?全然遅くなかったのではないでしょうか?

TDD Boot Camp 福岡 2.5 日目 〜 3 日目?

後日譚てきな。

終電とか考えずに打ち上げに参加したので、もう一泊していくことになりました。
こんなこともあろうかと 3 泊分の用意をしてきたのでした。
で、ホテルを探すんだけどどこも満室・・・
みずぴーさん (id:mzp) だったか末次さん (id:suer) だったかが見つけたホテルが空いていたのでそこに決定。一人 5,000 円でエレベータの狭さを除けば快適でした。


で、もちろんハッカソン状態になりました。
2 日目に和田さん (id:t-wada) が課題をやってみようとしていた (色々あって途中まで) ので、自分でもやってみることにしました。
言語は C# と F# で迷ったんですが、Java チームが第一勢力だったことも考えて、より Java に近い C# にしました。
これは帰りの新幹線の中でもやって、6 つやったところでタイムアップ。


bleis-tift/MotsunabeZombieProject · GitHub


ログを見ると、3/20 23:22 に最初のコミットがあり、3/21 00:33 の次のコミットが 3/21 9:55 となっているので、ホテルでは 1 時間くらいやっていたようです。
新幹線では 9:55 から12:37 までやっていたようで、合わせると大体 4 時間くらいになります。
コミットとコミットとの間にどれくらいの時間をかけているかですが、最大約 20.0 分、最小 0.4 分、平均約 4.8 分となりました。


内容の解説はまた後日。