Edited at

Git にコンテキストスイッチを実装した

More than 1 year has passed since last update.


概要

大きなプロジェクトで Git を使っていると、大量のローカルブランチが溜まっていってつらいことがあります。


  • 作りかけの新機能

  • hotfix

  • レビュー中のブランチ

  • 雑に作って手元で温めてる差分

  • ...

マージされたブランチは git branch --merged とかでシュッと消してしまえばいいのですが、必要な(になりそうな)差分だとそういうわけにもいきません。

そこで、1つの Git リポジトリに複数のブランチ・stash のリスト(かっこつけて context と呼ぶことにしました)を持たせて、切り替えながら使えるように Git を拡張してみました。


デモ


  1. 適当にブランチや stash を作成

  2. 新しい context bar を作ってスイッチ (1. で作ったブランチは見えなくなる)

  3. 適当にブランチや stash を作成


  4. default context に戻る (3. で作ったブランチは消えて、1. のブランチがまた見える)

  5. もう一度 bar に戻る (また 3. のブランチが見えるようになる)


ダウンロード

https://github.com/zk-phi/git-context-switch

実行ファイルを 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/ に退避します。HEADstash も同様に、 .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 は頭を切り替える能力を手に入れました。

個人開発とかでカジュアルに使う機能ではないですが、大規模開発だと便利かもしれません :)