読者です 読者をやめる 読者になる 読者になる

クフでダローバルな日記

タフでもグローバルもない

gitに新機能を追加してgit masterを目指す(応用編)

前回の記事の続きとなります。
今回はcommitに新機能を追加していきます。

前回まで

前回、git addを手探って、git add .とするように変更しました。

前回記事のコメントでも指摘されたように、このような変更は簡単にプラグインで対応できます。
従って、今回の変更点はプラグインやシェルの設定では実現しがたいものとなるようにします。

変更内容

git commit --amend をしようとした際に、直前のcommitがpush済みであったら中止する

目的

この変更の必要性がわかりづらいと思われるので、丁寧に説明してみます。

git commit --amendは、直前のcommitを修正できるコマンドです。
commit messageなどを修正したい時には便利なのですが、もし既にpushしたcommitを修正しようとすると面倒なことになります。

例えば直前のcommit A がpush済みであるとします。
この時、remoteにも A が存在します。

この状態でAをamendし、A'とするとしましょう。

そうすると、pushした際にremoteでAとA'がconflictするため、修正がかなり面倒なことになってしまうわけです。

実際、gitのマニュアルでも、以下の様な強い口調で、push済みのcommitを修正することが非難されています。

公開リポジトリにプッシュしたコミットをリベースしてはいけない

この指針に従っている限り、すべてはうまく進みます。もしこれを守らなければ、あなたは嫌われ者となり、友人や家族からも軽蔑されることになるでしょう。
Git - リベース

また、gitのメーリングリストでもこの問題については既に話し合われていたようで、SmallProjectsIdeas - Git SCM Wiki に以下の様な記述がありました。

Commands like "git rebase", "git rebase -i", "git reset", "git filter-branch", "git commit --amend" can be very powerful to rewrite local history before publishing it. On the other hand, they can be very dangerous if used on an already published history. Git could relatively easily detect that one is rewriting a commit that is an ancestor of a remote-tracking branch, and warn the user (perhaps giving a way to "git reset --hard" back to the original state).

以上のことから、commitをamendする際に、それがpush済みであるか否かを判別するのが必要であるということがわかると思います。

手探りの手順

手探りの手順、といってもだいたいのことは前回のadd編と同じです。

しかし、今回の最大の問題点は 「あるcommitがpushされているのかどうかをいかにして判別するか?」 というものだったので、以下の様な手順を踏みました。

  1. localのcommitとremoteのcommitの関連性を探す
  2. 全探索
  3. git statusを手探る

というものです。以下順に解説していきます。

localのcommitとremoteのcommitの関連性を探す

git pushした際に、「pushした」という情報がどこかに保存されていれば、そのフラグを見ることで実現できるだろうと予想して、まずはこの関連性を探すところから始めました。

具体的には、最初にgit pushの動きを追いました。 もし「pushした」という情報をどこかに保存するのであれば、その処理はpushをした際になされるだろうという予測をした為です。
しかし、pushの際には処理が多くていまいち全容が把握できず、多分そういった情報は保存していないだろうという予測はできたのですが、確信を持てずにいました。
この原因はcommitの仕組みについても殆ど理解していなかったことにあると考え、一度git addgit commitgit pushが何をやっているのかをボトムアップ式に学んでみることにしました。

主に参考にしたのはGit - Gitの内側 と、Git の仕組み (1) - こせきの技術日記です。

前者は公式の情報でありかなり詳細なのですが、gitは用語が難しく、概念としてイメージしづらいという難点があります。
そこで、後者の図が豊富なブログで一度大雑把に理解し、もう一度公式のチュートリアルを読むことにしました。
gitの内部構造について知りたいのであればこれら2つを読めばかなり理解が捗るのではないかと思います。

これらを読んで理解した、私達の目的に直接携わる部分は以下の様にまとめられます。

  1. commit同士の関係はparentによって辿る片方向リスト
  2. branchについての情報は先頭のreferenceと上記の片方向リストでしかわからない
  3. 従って、localとremoteのcommitのどちらが進んでいるかを保存しているものはない

1つ目については、gitのソースのcommit.hを読めばcommitが構造体として定義されているため一応知ることが出来ます。しかし、全体像を把握するためには大量のソースを読む必要があるので、こういった「まとめ」を読むことが有効なのではないかと思います。
考え方が先、実装は後ですね。

全探索

上記のことが分かったので、commit同士の関係性を知る方法が定まりました。 すなわち、 remoteとlocalのbranchの先頭のreferenceからparentをたどっていき、一致した時の状態によって判断する というものです。

しかし、この手順を実装するのは若干面倒であることがわかりました。というのも、二つのcommitの関係はただの直線関係だけではないからです。

その関係のパターンについて、図を用いて説明します。
図中、左にあるノードのcommitがparentであるとします。

パターン1 直線上の場合

関係を探るためには、localとremoteそれぞれのheadからparentをたどって行き、どちらかのheadに到達すれば分かるので容易です。

以下の3パターンの内、localがremoteより進んでいるもののみがamend可能であるべきです。

f:id:SWIMATH2:20151103213443p:plain:w300 f:id:SWIMATH2:20151103213447p:plain:w300 f:id:SWIMATH2:20151103213452p:plain:w300

パターン2 分岐している場合

これは手順が面倒で、localとremoteそれぞれのheadからparentをたどっていき、お互いのheadに到達せずに一致する所があったら分岐している、というアルゴリズムが考えられます。

f:id:SWIMATH2:20151103213606p:plain:w300

この場合はremoteのheadのcommitがlocalのheadのcommitに依存していないので、amend出来るようにします。

これらに加えて、mergeしている場合などにparentが複数あるパターンがあるので、全探索の手順はかなり複雑なものになりそうです。
また、gitを使っていて生じうるパターンを数多く考えるのはかなり困難を極めていました。

git statusを手探る

全探索の実装を始める前に冷静になって考えなおしてみたところ、git statusをした際に表示される画面では、localのheadがaheadなのかbehindなのか表示されるため、この情報を取得することができれば良いのではないかという予測を立てることが出来ました。

ということで、git statusした際の動きを前回同様手探ってみたところ、remote.cの中にあるstat_tracking_info()という関数によってbranch同士の情報を得ていることがわかりました。
すなわち、commit --amendした際にこの関数を呼べば、push済みか否かのチェックが出来るということです。

結局、施した変更は以下のとおりです。

diff --git a/builtin/commit.c b/builtin/commit.c
index 63772d0..8a9a5b3 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -32,6 +32,7 @@
 #include "sequencer.h"
 #include "notes-utils.h"
 #include "mailmap.h"
+#include "remote.h"

 static const char * const builtin_commit_usage[] = {
        N_("git commit [<options>] [--] <pathspec>..."),
@@ -1125,6 +1126,9 @@ static int parse_and_validate_options(int argc, const char *argv[],
                                      struct wt_status *s)
 {
        int f = 0;
+       int ours, theirs;
+       const char *full_base;
+       struct branch *branch = branch_get(NULL);

        argc = parse_options(argc, argv, prefix, options, usage, 0);
        finalize_deferred_config(s);
@@ -1149,6 +1153,12 @@ static int parse_and_validate_options(int argc, const char *argv[],
                else if (whence == FROM_CHERRY_PICK)
                        die(_("You are in the middle of a cherry-pick -- cannot amend."));
        }
+
+       stat_tracking_info(branch, &ours, &theirs, &full_base);
+       if (amend && ours == 0) {
+               die(_("You've already pushed this branch."));
+       }
+
        if (fixup_message && squash_message)
                die(_("Options --squash and --fixup cannot be used together"));
        if (use_message)

この関数を発見する事ができたことで、変更がかなり容易なものとなりました。 面倒な処理を実装するときには、「その実装が既に為されているのではないか?」と考え、似たような処理をしてそうな部分を探してみるのが有用な手段の一つなのではないかと思います。

git mailing listへの投稿と今後の課題

目的を達成できた私たちは、git本体にマージされることは出来ないかと考えgitのメーリングリストに投稿してみることにしました。

チームメンバーのtkk君が英語で投稿してくれたのですが、2時間後には他の開発者からコメントの返信が来たのでそのスピードに驚きました。
とはいってもコメントはかなり要求水準が高く、以下の様な修正点を挙げられました。

  1. remoteにブランチがあることを確認できていない
  2. 後方互換性が弱いので、オプションで指定できるようにするべき
  3. テストを書くべき

1については簡単に修正ができるのですが、オプションで指定するとなるとまた別の部分を手探る必要が生じ、さらにテストを書くとなると全く別のシェルスクリプトを書かねばならないため、実験のタイムリミットを考慮して修正せずに終えることにしました。

テストや後方互換性の重要性については理解しているつもりでしたが、実際に多くの人に使われるソフトウェアに携わると考えると、このように厳しくチェックされることでより再認識できたように感じます。

今回は時間の制約上諦めることになりましたが、今後の課題としてこれらの改善点を記しておくことにします。

謝辞

ご指導頂いた先生とTAの方々やチームメンバーの二人のおかげでどうにか開発が進められました。ありがとうございました。