Mercurialの履歴改変を覚えたい

Mercurialは基本的な操作は割と簡単なんですが、一度確定したリビジョンを変更するのは慣れてない人(私)には辛い。

そこで履歴を改変方法を学んでいきます。
間違えても対応できれば精神衛生上もいいですよね。

この記事では確認のため、共通して「graphlog」という拡張機能を使用します。
hgrcを以下を追記してください。

graphlog = 
color =

(colorは必須ではないけど指定したほうが見やすい)
これによりhg glogというコマンドが使えます。

他の拡張機能はその都度書きます。
準備が整ったので、まずは標準機能から見ていきます。

–amend

リビジョンのコミットメッセージを変更する場合、commitに–amendオプションをつけて使用します。

$ echo 'this is README' > README.txt
$ hg add README.txt 
 
# やべっ、typoした
$ hg commit -m 'initialzie'
 
# glogはgraphlog拡張機能を有効すると使える
$ hg glog
@  リビジョン:   0:bb6fa1110da9
   タグ:         tip
   要約:         initialzie
 
# --amendオプションをつけてcommitを実行する
$ hg commit --amend -m 'initialize'
バックアップのバンドルを /home/user/hgtest/.hg/strip-backup/bb6fa1110da9-amend-backup.hg に保存
 
# コミットメッセージが修正された(リビジョンIDも変わっている)
$ hg glog
@  リビジョン:   0:08c001102815
   タグ:         tip
   要約:         initialize
 
$ hg push
連携先: added 1 changesets with 1 changes to 1 files
 
# PUSH後にコミットメッセージは変更できない
$ hg commit --amend 'added -m README.txt'
中止: public フェーズのリビジョンは改変できません
 
$ touch test test2
$ hg add test
$ hg commit -m 'added test'
$ hg add test2
$ hg commit -m 'added test2'
 
$ hg glog
@  リビジョン:   2:b6d51b3b7d37
|  タグ:         tip
|  要約:         added test2
|
o  リビジョン:   1:cf76c363d4fb
|  要約:         added test
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
$ hg update 1
# 末端のリビジョンでないと変更できない
$ hg commit --amend -m 'added test1'
中止: 子リビジョンを持つリビジョンは改変できません

rollback

rollbackは直前のコミットを取り消します。

$ touch test.txt
$ hg add test.txt 
$ hg commit -m 'added test.txt'
$ touch test2.txt
$ hg add test2.txt 
$ hg commit -m 'added test2.txt'
$ ls
README.txt  test.txt  test2.txt
$ hg glog
@  リビジョン:   2:43db0b350651
|  タグ:         tip
|  要約:         added test2.txt
|
o  リビジョン:   1:9e67eccf89cd
|  要約:         added test.txt
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
$ hg rollback
tip をリビジョン 1 へと巻き戻し(commit の取り消し)
1 が作業領域の親リビジョンになりました
 
# commitが取り消されただけで追加したファイルまでは消えない
$ ls
README.txt  test.txt  test2.txt
$ hg status
A test2.txt
 
# 連続してロールバックできない
$ hg rollback
利用可能なロールバック情報がありません

rollbackはPUSH後にも行うことができます。

# 同じ情報でcommit,PUSHすると
$ hg commit -m 'added test2.txt'
$ hg push
連携先: added 2 changesets with 2 changes to 2 files
 
# 公開後でもrollback可能
$ hg rollback
tip をリビジョン 1 へと巻き戻し(commit の取り消し)
1 が作業領域の親リビジョンになりました
 
# コミット内容が作業領域に戻る
$ hg status
A test2.txt
$ hg glog
@  リビジョン:   1:9e67eccf89cd
|  タグ:         tip
|  要約:         added test.txt
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
 
# 再度commit
$ hg commit -m 're-added test2.txt'
$ hg push
中止: 新しいヘッド c3cababd90b8 が連携先に作成されます!
(取り込み+マージするか、新規ヘッド反映に関して "hg help push" を参照)
 
# PUSH先のリポジトリにはrollback前のリビジョンが残っていることが分かる
$ hg incoming
リビジョン:   3:bb7bfea5d058
タグ:         tip
親リビジョン: 1:9e67eccf89cd
要約:         added test2.txt

同じ内容で再コミットしてもリビジョンIDが異なるため別リビジョンとみなされます。
親リビジョンが同じため、マージによりマルチプルヘッドを解消しなければPUSHできないという状態です。

PUSH先のリポジトリから該当リビジョンを削除すればPUSHできるようになります。

$ hg push
連携先: added 1 changesets with 1 changes to 1 files

Bitbucketでは「チェンジセットの除外(Strip)」という機能を使います。

bitbucket-strip

キャプチャの注意書きを読んでみます(Bitbucket引用)。

もし誰かがチェンジセットを除外し、他の人がすでにプルしていた場合、他のすべての人がローカルで除外しない限り、もう一度リポジトリにプッシュされたら元に戻ります。

多くの場合、hg backoutで間違ったチェンジセットを打ち消す方が良いでしょう。

複数人で作業する際は、PUSH先のリポジトリのリビジョンをいじるのは推奨されません。
ローカルに用意した同じリポジトリ(hgtest,hgtest2)を並列操作して確認してみましょう。

hgtest hgtest2
$ touch test3.txt
$ hg add test3.txt 
$ hg commit -m 'added test3.txt'
$ hg push
 
 
$ hg rollback #このタイミングでPUSH先の該当リビジョン(38d9bb64b525)を削除した
 
 
 
 
 
$ hg commit -m 're-added test3.txt' #メッセージを変更して再コミット
$ hg pull #変更を取り込む
2 個のリビジョン(1 の変更を 2 ファイルに適用)を追加 (+1個のヘッド)
# rollback(strip)したリビジョン(38d9bb64b525)が復活してしまった
$ hg glog
o  リビジョン:   5:4762ae365289
|  タグ:         tip
|  要約:         added test4.txt
|
o  リビジョン:   4:38d9bb64b525
|  親リビジョン: 2:c3cababd90b8
|  要約:         added test3.txt
|
| @  リビジョン:   3:ad054ac4c371
|/   要約:         re-added test3.txt
|
o  リビジョン:   2:c3cababd90b8
|  要約:         re-added test2.txt
|
o  リビジョン:   1:9e67eccf89cd
|  要約:         added test.txt
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
 
 
 
$ hg pull # hgtest1でPUSHした内容を取り込んで
$ hg update # 適用する
 
$ touch test4.txt
$ hg add test4.txt
$ hg commit -m 'added test4.txt'
$ hg push #rollbackしたとは知らずhgtestよりも先にPUSHしてしまった
連携先: added 2 changesets with 2 changes to 2 files

このように他の人が該当リビジョンを除外(strip)せずにPUSHしてしまうとリビジョンが復活してしまいます。
複数人が利用するリポジトリでは次で説明する「backout」を使うべきです。

backout

backoutはリビジョンの「打ち消し」です。リビジョン自体が削除されるわけではなく、対象リビジョンによる変更を打ち消すようなコミットが追加されます。backoutではリポジトリ間の不整合が生じないため、PUSH後のリビジョンに対しても使用できます。

$ touch test.txt
$ hg add test.txt
$ hg commit -m 'added test.txt'
$ touch test2.txt
$ hg add test2.txt 
$ hg commit -m 'added test2.txt'
$ hg backout 2 -m 'backed out changeset 2'
test2.txt を登録除外中
リビジョン 3:ca63dc8929c6 はリビジョン 2:2ae9114f666b を打ち消します
 
$ ls
README.txt  test.txt
 
$ hg glog
@  リビジョン:   3:ca63dc8929c6
|  タグ:         tip
|  要約:         backed out changeset 2
|
o  リビジョン:   2:2ae9114f666b
|  要約:         added test2.txt
|
o  リビジョン:   1:ca7cf4ecf8c0
|  要約:         added test.txt
|
o  リビジョン:   0:08c001102815
   要約:         initialize

直前のリビジョンであればbackout直後にコミットされます。
それより前のリビジョンであってもbackout可能ですが、この場合は自分でコミットすることになります。

マージのバックアウト

注意すべきはマージリビジョンを対象とした場合のbackoutです。
マージするタイミングを間違えてしまったという設定です。

$ hg branch test
作業領域をブランチ test に設定
$ touch a
$ hg add a
$ hg commit -m 'added a'
 
$ hg update default
 
$ touch b
$ hg add b
$ hg commit -m 'added b'
 
# タイミングを間違えてマージしてしまった。なかったことにしたい
$ hg merge test
$ hg commit -m 'merge with test'
 
# testブランチで作成した「a」が取り込まれている
$ ls
README.txt  a  b
 
$ hg backout 3
中止: マージ実施リビジョンは打ち消し対象にできません
 
# マージリビジョンを対象とする際は--parentオプションの指定が必要
$ hg backout 3 --parent 2
a を登録除外中
リビジョン 4:82c3e2e2188a はリビジョン 3:5899cd2ac937 を打ち消します
$ hg glog
@  リビジョン:   4:82c3e2e2188a
|  タグ:         tip
|  要約:         Backed out changeset 5899cd2ac937
|
o    リビジョン:   3:5899cd2ac937
|\   親リビジョン: 2:11a53c20f6ad
| |  親リビジョン: 1:529c37e4547f
| |  要約:         merge with test
| |
| o  リビジョン:   2:11a53c20f6ad
| |  親リビジョン: 0:08c001102815
| |  要約:         added b
| |
o |  リビジョン:   1:529c37e4547f
|/   ブランチ:     test
|    要約:         added a
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
# backout後はマージによって取り込まれた「a」がなくなっている
$ ls
README.txt  b

この時点では何の問題もないように見えます。

では、再度マージしようとしてみます。

$ hg update test
 
$ touch c
$ hg add c
$ hg commit -m 'added c'
 
$ hg update default
 
$ hg merge test
$ hg commit -m 're-merge with test'
$ hg glog
@    リビジョン:   6:19fdadc80595
|\   タグ:         tip
| |  親リビジョン: 4:82c3e2e2188a
| |  親リビジョン: 5:8c717d3d3219
| |  要約:         re-merge with test
| |
| o  リビジョン:   5:8c717d3d3219
| |  ブランチ:     test
| |  親リビジョン: 1:529c37e4547f
| |  要約:         added c
| |
o |  リビジョン:   4:82c3e2e2188a
| |  要約:         Backed out changeset 5899cd2ac93
| |
o |  リビジョン:   3:5899cd2ac937
|\|  親リビジョン: 2:11a53c20f6ad
| |  親リビジョン: 1:529c37e4547f
| |  要約:         merge with test
| |
o |  リビジョン:   2:11a53c20f6ad
| |  親リビジョン: 0:08c001102815
| |  要約:         added b
| |
| o  リビジョン:   1:529c37e4547f
|/   ブランチ:     test
|    要約:         added a
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
$ ls
README.txt  b  c

あれ、testブランチで作成した「a」がありません。既にマージで取り込まれたことになっているためです。
同じIDのリビジョンはマージによって再度取り込まれることはありません。

backoutしたリビジョンを再度backoutすればファイルは復活しますが、それでは取り消した意味がありません。
どうやらdefaultブランチ(取り込み先)でbackoutしたのがいけなかったようです。今度はtestブランチ(取り込み元)でbackoutしてみます。
同時にファイル遷移も見てみましょう。

履歴操作 ファイル遷移
# (状態は誤マージ直後に戻してある)
$ hg update test
 
# 祖先のブランチしかbackoutできないためmergeで取り込む必要がある
$ hg merge default
$ hg commit -m 'merge with default'
 
 
 
# backoutでdefaultブランチ(2)の内容だけを残したい
$ hg backout 3 --parent 2
$ hg commit -m 'backed out 3-2'
 
 
 
# defaultブランチでtestのbackoutを取り込む
$ hg update default
$ hg merge test
$ hg commit -m 'merge with test (backed out 3-2)'
 
 
# testブランチでbackoutリビジョンを打ち消す
$ hg update test
$ hg backout 5 -m 'backed out changeset 5(backed out 3-2)'
 
 
 
# この後、defaultブランチとtestブランチをマージすると..
$ hg update default
$ hg merge test
$ hg commit -m 're-merge with test'
 
 
# 最終的なリビジョングラフは以下のようになる
$ hg glog
@    リビジョン:   8:9d4fe2d25abb
|\   タグ:         tip
| |  親リビジョン: 6:fc2db6b3a5fb
| |  親リビジョン: 7:69f0d0193d1a
| |  要約:         re-merge with test
| |
| o  リビジョン:   7:69f0d0193d1a
| |  ブランチ:     test
| |  親リビジョン: 5:67909ad407f6
| |  要約:         backed out changeset 5(backed out 3-2)
| |
o |  リビジョン:   6:fc2db6b3a5fb
|\|  親リビジョン: 3:47333c58104e
| |  親リビジョン: 5:67909ad407f6
| |  要約:         merge with test (backed out 3-2)
| |
| o  リビジョン:   5:67909ad407f6
| |  ブランチ:     test
| |  要約:         backed out 3-2
| |
| o  リビジョン:   4:55dc0da9756d
|/|  ブランチ:     test
| |  親リビジョン: 1:2eea9c9e6fc3
| |  親リビジョン: 3:47333c58104e
| |  要約:         merge with default
| |
o |  リビジョン:   3:47333c58104e
|\|  親リビジョン: 2:4f7cacb90bc0
| |  親リビジョン: 1:2eea9c9e6fc3
| |  要約:         merge with test
| |
o |  リビジョン:   2:4f7cacb90bc0
| |  親リビジョン: 0:08c001102815
| |  要約:         added b
| |
| o  リビジョン:   1:2eea9c9e6fc3
|/   ブランチ:     test
|    要約:         added a
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
 
 
# testブランチからaが取り込まれている状態
$ hg branch
test
$ ls
README.txt  a  b
 
# testブランチ(1)から取り込まれたファイル「a」がなくなっている
$ hg branch
test
$ ls
README.txt  b
 
# defaultブランチからも「a」がなくなっている
$ hg branch
default
$ ls
README.txt  b
 
# testブランチでは「a」が復活した
$ hg branch
test
$ ls
README.txt  a  b
 
# testブランチと再マージしても「a」は取り込まれる
$ hg branch
default
$ ls
README.txt  a  b

testブランチでは「b」が取り込まれているため、完全に元の状態が再現出来たわけではありませんが、これくらいなら許容範囲である場合がほとんどでしょう。

(ちょっと追記)
流れとしては

  1. testブランチからdefaultブランチを取り込む(マージ)
  2. ミスったマージリビジョンをバックアウト(parentにはdefaultブランチのリビジョンを指定)
  3. defaultブランチからバックアウト(2)を取り込む(マージ)
  4. testブランチでバックアウト(2)を更にバックアウト。この時点でtestブランチ側で消したファイルが戻る

大事なのはどちらのブランチで行うかということですね。
けっこう難しい。

backoutについてはこちらを参考にさせていただきました。ありがとうございました。

ところで、Mercurialが標準で提供するrollbackではリビジョンを削除できる範囲は「直前まで」に限られていました。
これは「履歴は神聖」という設計思想からくるものなんでしょうか。

いやいや、そうは言ってもどうしても削除したいことってありますよね。ここからは拡張機能(extension)を使用します。

strip

stripを使うとリビジョンの削除を行うことができます。
stripを使用するためにはhgrcに以下を追記します。

strip =
$ hg branch test
$ touch a
$ hg add a
$ hg commit -m 'added a'
$ touch b
$ hg add b
$ hg commit -m 'added b'
$ hg update default
$ touch c
$ hg add c
$ hg commit -m 'added c'
$ hg glog
@  リビジョン:   3:f9a32b6df29e
|  タグ:         tip
|  親リビジョン: 0:08c001102815
|  要約:         added c
|
| o  リビジョン:   2:c394c7c269dd
| |  ブランチ:     test
| |  要約:         added b
| |
| o  リビジョン:   1:3906dfe1e1f1
|/   ブランチ:     test
|    要約:         added a
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
# 子にあたるリビジョンも削除される
$ hg strip 1
バックアップのバンドルを /home/user/hgtest/.hg/strip-backup/3906dfe1e1f1-backup.hg に保存
$ hg glog
@  リビジョン:   1:f9a32b6df29e
|  タグ:         tip
|  要約:         added c
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
# unbundleコマンドにより元に戻せる
$ hg unbundle .hg/strip-backup/3906dfe1e1f1-backup.hg 
リビジョンを追加中
マニフェストを追加中
ファイルの変更を追加中
2 個のリビジョン(2 の変更を 2 ファイルに適用)を追加 (+1個のヘッド)
(ヘッド一覧表示は 'hg heads')
 
# リビジョン番号(リビジョンIDじゃなくて)が変わるためブランチが入れ替わって見える
$ hg glog
o  リビジョン:   3:c394c7c269dd
|  ブランチ:     test
|  タグ:         tip
|  要約:         added b
|
o  リビジョン:   2:3906dfe1e1f1
|  ブランチ:     test
|  親リビジョン: 0:08c001102815
|  要約:         added a
|
| @  リビジョン:   1:f9a32b6df29e
|/   要約:         added c
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
# stripでは変更内容が作業領域に残らない
$ hg status

stripによりリビジョンの削除が可能とはいえ、既に公開されているリビジョンを削除するのはrollbackと同じ理由でオススメできません。

またstripされた分だけリビジョン番号が繰り上がることにも注意が必要です。

rebase

Gitのrebaseとほとんど同じなわけですがMercurialユーザにとっては、あまり聞き慣れない操作です。
rebaseは「ブランチの移植」を行います。マージと違いリビジョンIDが変わるため、公開されたリビジョンをrebaseすることはご法度とされています。

rebaseを使用するためにはhgrcに以下を追記します。

rebase =

Gitでは「リビジョングラフが綺麗になる」という理由でよく使われる、rebaseですが今回は以下のシナリオでやってみましょう。

  • 「a」ブランチ直下に「b」ブランチを作ってしまった
  • 本当は「default」ブランチに移したい
  • 既に公開済み

stripと標準機能のgraftという機能を利用することによって、ブランチに所属するリビジョンを一つずつ移動することが可能なようですが、結構大変そうなのでrebaseを使っていくことにします。

$ hg branch a
$ touch a
$ hg add a
$ hg commit -m 'added a'
 
# ブランチ「b」は「a」の子ブランチになってしまった
$ hg branch b
$ touch b
$ hg add b
$ hg commit -m 'added b'
 
$ touch bb
$ hg add bb
$ hg commit -m 'added bb'
 
$ touch bbb
$ hg add bbb
$ hg commit -m 'added bbb'
 
$ hg update default
$ touch c
$ hg add c
# リビジョングラフがわかりにくいと思って追加しただけ
$ hg commit -m 'added c'
 
$ hg push
 
# ここで「b」ブランチの場所を間違えたことに気づいた!
$ hg glog
@  リビジョン:   5:29c3df7c7d81
|  タグ:         tip
|  親リビジョン: 0:08c001102815
|  要約:         added c
|
| o  リビジョン:   4:a5ed5ce9e07a
| |  ブランチ:     b
| |  要約:         added bbb
| |
| o  リビジョン:   3:c4c3e1b5093f
| |  ブランチ:     b
| |  要約:         added bb
| |
| o  リビジョン:   2:19d7b7866f82
| |  ブランチ:     b
| |  要約:         added b
| |
| o  リビジョン:   1:d9f14cb0f27a
|/   ブランチ:     a
|    要約:         added a
|
o  リビジョン:   0:08c001102815
   要約:         initialize
 
# bブランチのリビジョンをb2ブランチに移動することにしよう
$ hg update default
$ hg branch b2
$ hg commit -m 'make new branch'
 
# --sourceに指定したリビジョン以降(子孫)が移動される
# --destに指定したリビジョンの後に追加される
# --keepを指定しないと元ブランチが削除される(公開済の場合は指定が必須らしい)
$ hg rebase --source 2 --dest b2 --keep
 
# 古いブランチをcloseするかどうかはお好みで
$ hg update 4
$ hg commit --close-branch -m 'close b(old)'
 
$ hg update b2
$ hg glog
o  リビジョン:   10:a2586e47c1e8
|  ブランチ:     b
|  タグ:         tip
|  親リビジョン: 4:65edb8de61cb
|  要約:         close b(old)
|
| @  リビジョン:   9:4a73393adfbf
| |  ブランチ:     b2
| |  要約:         added bbb
| |
| o  リビジョン:   8:b5917df63e10
| |  ブランチ:     b2
| |  要約:         added bb
| |
| o  リビジョン:   7:01cb8a84c9e0
| |  ブランチ:     b2
| |  要約:         added b
| |
| o  リビジョン:   6:bd9e53cce698
| |  ブランチ:     b2
| |  要約:         made new branch
| |
| o  リビジョン:   5:444f9f0a58e5
| |  親リビジョン: 0:08c001102815
| |  要約:         added c
| |
o |  リビジョン:   4:65edb8de61cb
| |  ブランチ:     b
| |  要約:         added bbb
| |
o |  リビジョン:   3:cc56bfcd87ed
| |  ブランチ:     b
| |  要約:         added bb
| |
o |  リビジョン:   2:4b6006001388
| |  ブランチ:     b
| |  要約:         added b
| |
o |  リビジョン:   1:6d9571b2d597
|/   ブランチ:     a
|    要約:         added a
|
o  リビジョン:   0:08c001102815
   要約:         initialize

公開していないのであれば次のようにすればよいと思います。

$ hg rebase --source 2 --dest default --keepbranches

–keepオプションが指定されなければ元のブランチは削除されます。
–keepbranchesオプションが指定されていると元のブランチ名を保持します。–keepと共に使うとマルチプルヘッドになる(はずな)ので注意しましょう。

mqはいつかやります。