概要
大きなプロジェクトで Git を使っていると、大量のローカルブランチが溜まっていってつらいことがあります。
- 作りかけの新機能
- hotfix
- レビュー中のブランチ
- 雑に作って手元で温めてる差分
- ...
マージされたブランチは git branch --merged
とかでシュッと消してしまえばいいのですが、必要な(になりそうな)差分だとそういうわけにもいきません。
そこで、1つの Git リポジトリに複数のブランチ・stash のリスト(かっこつけて context と呼ぶことにしました)を持たせて、切り替えながら使えるように Git を拡張してみました。
デモ
- 適当にブランチや stash を作成
- 新しい context
bar
を作ってスイッチ (1. で作ったブランチは見えなくなる) - 適当にブランチや stash を作成
-
default
context に戻る (3. で作ったブランチは消えて、1. のブランチがまた見える) - もう一度
bar
に戻る (また 3. のブランチが見えるようになる)
ダウンロード
実行ファイルを PATH の通ったディレクトリにコピーして、権限を与えます。
$ cp git-context-switch.el /usr/local/bin/git-context-switch
$ chmod +x /usr/local/bin/git-context-switch
必要に応じて ~/.gitconfig
で alias を設定します。
[alias]
context = "!f() { git-context-switch $*; }; f"
※ Emacs Lisp で実装されているので、実行に Emacs が必要です (スクリプト言語として使っているだけなので、コマンドを叩くたびに大げさな GUI が出てきたりはしません)
使い方
今のところ、以下の機能があります。増減しているかもしれないので最新の Readme を参照してください mm
-
git context
,git context list
... コンテキストの一覧を表示 -
git context create <name>
... コンテキストを作る -
git context delete <name>
... コンテキストを消す -
git context switch <name>
... 指定したコンテキストにスイッチする -
git context show <name>
... 指定したコンテキストのブランチを一覧 -
git context mv <branch> <context>
... ブランチをコンテキスト間移動 -
git context cp <branch> <context>
..ブランチをコンテキスト間コピー
しくみ ... の前に
しくみを解説する前に、 Git の内部構造について必要な知識を復習していきます:
そもそもブランチってなんだっけ
Git のブランチの実体は、たんにコミットハッシュが書かれただけのファイルです。こういうファイルを、「コミットへのポインタ (reference)」という意味で ref と呼びます。
たとえば master
ブランチは .git/refs/heads/master
にあります。開いてみると、単に master
の先頭のコミットハッシュが書いてあるだけなのがわかります。
$ cat ./.git/refs/heads/master
a203f31c8eaf2909f0cc1d4f412f7b1d4fd25074
$ git log --oneline master
a203f31 Bugfix / Fix 'too few arguments for format string' error on move / copy
5db26a9 Update doc
dbf68b7 Improve message for too-few/too-many arguments error
...
git commit
を実行すると、新しいコミットが保存された後、 その時 checkout していたブランチ のコミットハッシュが新しく作ったコミットのもので上書きされるわけです。
master
以外のブランチも同様に .git/refs/heads/
の中に置かれています。
$ git branch
* master
foo
bar
$ ls ./.git/refs/heads
master foo bar
余談ですが、ブランチ名に /
が含まれていると、そこは (.git/refs/heads/
内での) ディレクトリの区切りになります。なので、たとえば一度 hoge/piyo
というブランチを作ってしまうと、 hoge
というブランチはもう作れません。hoge
はディレクトリだからです。
$ git branch hoge/piyo
$ git branch hoge
fatal: cannot lock ref 'refs/heads/hoge'
今 checkout しているブランチ: HEAD
では「今 checkout しているブランチ」はどこに記録されているのかというと、これは .git/HEAD
にあります。
$ cat ./.git/HEAD
ref: refs/heads/master
HEAD
には、 ref: <ref のパス>
という形式で ref 名が書かれているか、他の ref と同じようにコミットハッシュが書かれているかのどちらかです。
HEAD
にコミットハッシュが直接書かれている状況は、コミットを直接 checkout すると作れます。
$ git checkout a203f31c8eaf2909f0cc1d4f412f7b1d4fd25074
$ cat ./.git/HEAD
a203f31c8eaf2909f0cc1d4f412f7b1d4fd25074
$ git branch
* (HEAD detached at a203f31)
master
foo
bar
この状況に陥った Git リポジトリは detached HEAD と呼ばれます。どのブランチも checkout していない変な状態で、あまり気持ちいいものではないので、 master
に戻っておきましょう。
$ git checkout master
$ cat ./.git/HEAD
ref: refs/heads/master
さて、コミットの代わりに他の ref を指すこともできる点で、 HEAD
は他の ref とは一味違います。これを symbolic ref と呼びます (が、覚えておいても使うことはないでしょう)。
ref の log だから reflog
さて、 Git をある程度使っている人は、 git reflog
というコマンドに出会ったことがあると思います。これは今までのリポジトリ上での作業履歴を見るコマンドです。
$ git reflog
a203f31 HEAD@{0}: commit (amend): Bugfix / Fix 'too few arguments for format string' error on move / copy
a988ac1 HEAD@{1}: rebase: aborting
39c72cf HEAD@{2}: rebase -i (start): checkout HEAD~4
a988ac1 HEAD@{3}: commit: bugfix
...
rebase やら amend やら治安の悪い文字列が見えますが、ともあれ reflog をみると作業履歴がモロバレです (しかもこれ、 rebase を途中で諦めてますね…)。
この reflog コマンド、実は git reflog HEAD
の省略です。実際、 HEAD
以外の ref 名を指定すると、「そのブランチ内での」作業履歴(「その ref がどのコミットを指してきたか」の歴史)がみられます。 ref の log だから reflog なわけです。
$ git reflog master
a203f31 refs/heads/master@{0}: commit (amend): Bugfix / Fix 'too few arguments for format string' error on move / copy
a988ac1 refs/heads/master@{1}: commit: bugfix
5db26a9 refs/heads/master@{2}: commit: Update doc
...
(こちらからは rebase の記録がみえません。 rebase 中は detached HEAD になるから、「master
で作業した」ことにはならないのですね)
さて、 reflog は .git/logs/
に記録されていきます。たとえば、 master
ブランチすなわち .git/refs/heads/master
の reflog は、 .git/logs/refs/heads/master
に記録されていきます。 refs/
の前に logs/
を追加すれば reflog がみられるわけです。
$ cat ./.git/logs/refs/heads/master
... (some 仰々しい出力)
生の reflog は、git reflog
でみる場合と違って、綺麗に整形されていないので読みづらいです。普段は素直に git reflog
からみるべきですね。
stash ってなんだっけ
最後に、僕らが何気なく(何気なく…?)使っている stash の正体です。
薄々気づいている人もいるかもしれませんが、 stash の正体も実は ref で、こいつは .git/refs/stash
にいます(stash が空の時はいません)。
$ cat ./.git/refs/stash
779826a514c4823c58beff57444ced84bf820c2a
さて、 ref は本来一つのコミットを指すものでした。でも実際には、 stash は複数作ることもできます。
どうしてそんなことができるかというと、それは stash を更新するたびに過去の stash が reflog に残っていくからです。
$ git reflog stash
779826a stash@{0}: WIP on master: hoge
e927026 stash@{1}: WIP on master: piyo
git stash list
は実は git reflog stash
のことだったんですね。
$ git stash list
779826a stash@{0}: WIP on master: hoge
e927026 stash@{1}: WIP on master: piyo
僕らが git stash drop
とか何の気なしに叩いている時、実は中では refs/stash
の reflog がゴリゴリ書き換えられていたわけです。筋肉って大切。
おまけ: packed-refs
Git には、内部データを整理して動作を軽快にたもつための git gc
というコマンドがあります。明示的に叩かなくても、内部で適宜実行されています。
これを実行すると、 gc
の名前の通り、いらなくなったコミット類が削除されるのですが、こいつはついでに ref たちを packed-refs
という一つのファイルにまとめます。
$ git branch
* master
foo
bar
$ ls ./.git/refs/heads
master foo bar
$ git gc
$ ls ./.git/refs/heads # 何も出ない!
$ git branch # ブランチは生き残っている
* master
foo
bar
$ cat ./.git/packed-refs # ここにいた!
3402e1d68f40825d687125a6c5434e057bf977c5 refs/heads/bar
58c779b4df7c20e96f2c47c38cbe80b8a9a37172 refs/heads/foo
a203f31c8eaf2909f0cc1d4f412f7b1d4fd25074 refs/heads/master
コンテキストスイッチを実装する時はこいつらのことも気にしてあげたほうがよさそうです。
しくみ
本題です。「コンテキストスイッチ」をどうやって実装したか簡単に紹介します。といっても、 Git のブランチの仕組みがわかっていれば簡単です。
方針
現在アクティブなコンテキストの名前を .git/context
に記録します。
アクティブなコンテキストのブランチはいつも通り .git/refs/heads/
に置き、それ以外のコンテキストのブランチは .git/refs/contexts/<context 名>/heads/
に退避します。HEAD
や stash
も同様に、 .git/refs/contexts/<context 名>/HEAD
や .git/refs/contexts/<context 名>/stash
に退避します。
それにあわせて、 reflog も .git/logs/refs/contexts/<context 名>/...
に移動します。
複数のコンテキストを持つ git リポジトリの refs
ディレクトリはこんな見た目になります:
refs -+- stash
|
+- heads -+- master
| |
| +- foo-branch
| |
| +- bar-branch
| |
| +- ...
|
+- contexts -+- WIPs -+- HEAD
| |
| +- heads -+- my-wip-branch
| |
| +- another-wip-branch
|
+- hoge-proj -+- HEAD
| |
| +- stash
| |
| +- heads -+- hogehoge-feature1
| |
| +- hogehoge-feature2
|
+- ...
※ active なコンテキストの HEAD は .git 直下 (.git/HEAD)
ref を移動するユーティリティ
まずは、 ref をある場所から別の場所に移動するユーティリティをざっくりみてみます。まずと言っても、これがコンテキストスイッチの肝だったりします。
一部の本質的でないコードは省略しながら紹介するので、本物のコードと若干違ってもびっくりしないでください。
ref を移動するユーティリティは rename-ref!
にあります。
(defun rename-ref! (from &optional to copy)
...)
from
が移動元の ref, to
が移動先の ref (省略時は削除), copy
が「移動ではなくてコピーする」フラグです。from
がディレクトリ的なもの (refs/heads
とか) だった場合には、丸ごと移動 (削除、コピー) します。
やってることはざっくり、もし移動先のディレクトリがまだなかったら作って (reflog の分もちゃんと作ってあげます)、
(when to
(maybe-make-directory! (file-name-directory to-ref))
(maybe-make-directory! (file-name-directory to-log)))
もし移動元の ref や reflog が存在したら、それを移動 (削除、コピー) します。
(when (file-exists-p from-ref)
(cond ((null to)
(trash-file-or-directory! from-ref))
((not copy)
(rename-file! from-ref to-ref))
(t
(copy-file-or-directory! from-ref to-ref))))
(when (file-exists-p from-log)
(cond ((null to)
(trash-file-or-directory! from-log))
((not copy)
(rename-file! from-log to-log))
(t
(copy-file-or-directory! from-log to-log))))
なぜ「存在したら」かというと、 packed-refs
にまとめられてしまっている場合は存在しないかもしれないからです。
最後に、もし packed-refs
が存在して、かつ移動元の ref が packed-refs
の中にいた場合、正規表現でゴリゴリ書き換えて移動 (コピー、削除) したことにします。
(when (file-exists-p "./.git/packed-refs")
(with-temp-buffer
(insert-file-contents "./.git/packed-refs")
(set-buffer-file-coding-system 'utf-8-unix)
(let ((regex (concat "^\\([0-9a-f]* \\)" (regexp-quote from) "\\(.*\\)\n")))
(while (search-forward-regexp regex nil t)
(let ((oldstr (match-string 0))
(newstr (and to (concat (match-string 1) to (match-string 2) "\n"))))
(cond ((null to)
(replace-match ""))
((not copy)
(replace-match newstr t t))
(t
(insert newstr))))))
(write-file-silently! "./.git/packed-refs")))
.git
内のファイルの改行コードはすべて Unix 形式にしておかないとリポジトリが壊れてしまうので、気をつけます (CRLF とかで保存すると、 CR までがブランチ名の一部だと思われてしまう)。
symbolic ref を操作するユーティリティ
(defun parse-symbolic-ref (ref)
"Returns (REF . DETACHED_P) of symbolic ref REF."
(with-temp-buffer
(insert-file-contents (concat "./.git/" ref))
(if (search-forward-regexp "^ref: refs/\\(.*\\)$" nil t)
(cons (match-string 1) nil)
(cons (buffer-substring (point-at-bol) (point-at-eol)) t))))
parse-symbolic-ref
は symbolic ref (HEAD
とか) のファイルの中身を正規表現でマッチして、
- ブランチを指していたら
(<ブランチ名> . nil)
- コミットを指していたら
(<コミットハッシュ> . t)
を返します (Lisp では nil
が false, t
が true を表すために使われます)。
(defun update-symbolic-ref-context! (ref &optional to)
"Update symbolic ref REF to refer context TO. When TO is
omitted or nil, REF will refer the active context."
(with-temp-buffer
(let ((file (concat "./.git/" ref)))
(insert-file-contents file)
(when (search-forward-regexp "^ref: \\(refs/\\(?:contexts/[^/]+/\\)?\\)" nil t)
(replace-match (context-prefix to) t t nil 1))
(write-file-silently! file))))
update-symbolic-ref-context!
は symbolic ref (ref
) を指定したコンテキスト (to
) を指すように書き換えます。
たとえば
ref: refs/heads/master
という symbolic ref に対して
(update-symbolic-ref-context! "HEAD" "hoge")
とかやると、
ref: refs/contexts/hoge/heads/master
に書き換わります。あとで説明しますが、これは zsh の git プラグインが壊れることが発覚したために、 ad-hoc に追加したものです (なのでちょっと汚くてもゆるしてネ)。
各コマンドの実装
ここからは個々のサブコマンドの実装です。
といっても、本質的なのは create
, delete
, switch
なのでここではそれだけを説明します。
今までに作ったユーティリティ関数を適当に組み合わせるだけなので難しくありません。
create
コマンド
create
コマンドは Git リポジトリに新しいコンテキストを追加します。
現在 checkout しているブランチだけが、新しく作るコンテキストに port されます (git branch
で新しいブランチを作る時にその時 checkout していたコミットが引き継がれるのに倣いました)。
(defun command/create (context-name)
(let* ((head-ref (parse-symbolic-ref "HEAD"))
(detatched-p (cdr head-ref)) ; detached HEAD かどうか
(head-ref (car head-ref)) ; HEAD の指しているブランチ or コミット
(context-prefix (context-prefix context-name)) ; 文字列 "/refs/contexts/<context-name>/"
(newhead (concat context-prefix "HEAD"))) ; 文字列 "/refs/contexts/<context-name>/HEAD"
(rename-ref! "HEAD" newhead t)
(unless detatched-p
(rename-ref! (concat "refs/" head-ref) (concat context-prefix head-ref) t)
(update-symbolic-ref-context! newhead context-name))))
ちょっとややこしいので、ざっくり疑似コードにするとこんなことをしています。
function create (<context>) :
HEAD を refs/contexts/<context>/HEAD にコピー;
if HEAD がブランチ refs/heads/<branch> を指している (= detached HEAD でない)
ブランチ refs/heads/<branch> を refs/contexts/<context>/heads/<branch> にコピー;
refs/contexts/<context>/HEAD を refs/contexts/<context>/heads/<branch> を指すように更新;
fi
delete
コマンド
delete
コマンドは既存のコンテキストを削除します。
(defun command/delete (context-name)
(rename-ref! (context-prefix context-name)))
これは単純で、 ./.git/refs/<contexts>
以下の ref をまるっと全削除しています。
switch
コマンド
これがコンテキストスイッチの要です (といってもさほど難しくありません)。
(defun command/switch (context-name)
(let* ((active-prefix (context-prefix active-context)) ; "refs/contexts/<現在のコンテキスト>"
(to-prefix (context-prefix context-name)) ; "refs/contexts/<スイッチ先のコンテキスト>"
(to-head (car (parse-symbolic-ref (concat to-prefix "HEAD"))))) ; スイッチ先の HEAD が指すコミット
;; ワーキングディレクトリをスイッチ先の状態にしておく
(rename-ref! "HEAD" "HEAD_tmp" t)
(shell-command-to-string-noerror (concat "git checkout --detach " to-head))
(rename-ref! "HEAD")
;; .git/HEAD, .git/heads, .git/stash を refs/contexts/<active-context> 以下にしまう
(update-symbolic-ref-context! "HEAD_tmp" active-context)
(rename-ref! "HEAD_tmp" (concat active-prefix "HEAD"))
(rename-ref! "refs/heads" (concat active-prefix "heads"))
(when (file-exists-p "./.git/refs/stash")
(rename-ref! "refs/stash" (concat active-prefix "stash")))
;; refs/contexts/<context> 以下にしまってあったコンテキストを展開
(rename-ref! (concat to-prefix "HEAD") "HEAD")
(update-symbolic-ref-context! "HEAD")
(rename-ref! (concat to-prefix "heads") "refs/heads")
(when (file-exists-p (concat ".git/" to-prefix "stash"))
(rename-ref! (concat to-prefix "stash") "refs/stash"))
;; ごみディレクトリを削除
(trash-file-or-directory! (concat "./.git/" to-prefix))
(trash-file-or-directory! (concat "./.git/logs/" to-prefix)))
(update-active-context! context-name))
ざっくりこんなことをしています。
// コンテキストを <active-context> から <context> にスイッチする
function switch (<context>) :
// ワーキングディレクトリをスイッチ先の状態にしておく
HEAD を HEAD_tmp にバックアップ; // checkout するときに更新されてしまうため
/refs/contexts/<context>/HEAD の指すコミットを checkout;
HEAD を削除;
// .git/HEAD, .git/heads, .git/stash を refs/contexts/<active-context> 以下にしまう
退避してあった HEAD_tmp を refs/contexts/<active-context>/heads/ を指すように更新;
HEAD_tmp を refs/contexts/<active-context>/HEAD に移動;
refs/heads 以下の ref をまとめて /refs/contexts/<active-context>/heads に移動;
if refs/stash がある
refs/contexts/<active-context>/stash に移動;
fi
// refs/contexts/<context> 以下にしまってあったコンテキストを展開
refs/contexts/<context>/HEAD を HEAD に移動;
HEAD を refs/heads/ を直接指すように更新;
refs/contexts/<context>/heads/ 以下の ref をまとめて refs/heads/ に移動
if refs/contexts/<context>/stash がある
refs/stash に移動;
fi
// ごみディレクトリを削除
refs/contexts/<context> と logs/refs/contexts/<context> を削除;
<active-context> ← <context>;
もともとは、symbolic ref の向き先更新作業はしていませんでした。が、一時的に存在しない ref を指す変な symbolic ref ができてしまい、 zsh のブランチ名補完が TAB を押すたびに warning を吐くようになってしまったので直すようにしました。
まとめ
そんなこんなで、僕の Git は頭を切り替える能力を手に入れました。
個人開発とかでカジュアルに使う機能ではないですが、大規模開発だと便利かもしれません :)