🐼

.zsh_historyファイルをイジる

に公開

tl;dr

  • zshのコマンド履歴を保存するファイル.zsh_historyをイジりたい
  • エスケープ処理が行われているので、雑にイジると壊れる
  • イジっても壊れないようにするためのCLIとNeovim/Vim Pluginを作った

.zsh_history ファイルとは

.zsh_history は、ご存じ zsh のコマンド履歴を保存するファイルです。
コマンドを実行した履歴が .zsh_history に保存されます。

$ echo "foo"
$ cat ~/.zsh_history
...
: 1745283180:0;echo "foo"

zshのコマンド履歴は、様々な方法で呼び出して再利用できるのも周知のことでしょう。

  • Ctrl + pCtrl + n での履歴の前後移動
  • historyコマンドやそのエイリアス先であるfcコマンドでの参照・再利用
  • !!!n といったヒストリ展開
  • Ctrl + r でのインクリメンタルサーチ

などなど。
これらの機能は、zshが .zsh_history を利用して実現されています。

historyをイジる

コマンド履歴を利用する方法は色々あるのですが、ときに

  • 秘匿情報が残ったので消したい
  • 再利用性の高いコマンドに書き換えたい

など、これを加工したいと考えることがあります。

そこで~/.zsh_historyをエディタで開くと、文字化けのような[1]内容に悩まされることがあるかもしれません。

: 1745283180:0;echo "foo"
: 1745283185:0;echo "ほぃ�"   # echo "ほげ" を実行したはずなのに…

これを、雑にエディタで加工・保存すると、時に履歴が壊れてしまうことがあります。

$ echo "ほぃ    # 過去に実行した`echo "ほげ"`を呼び出したいのに、履歴が壊れて正しく呼び出せなくなってしまう

なぜ壊れるのか

.zsh_historyは、zshが独自に定義したフォーマットで保存されています。
man zshoptionsEXTENDED_HISTORYには以下のように説明されています。

EXTENDED_HISTORY <C>
Save each command's beginning timestamp (in seconds since the epoch) and the duration (in seconds) to the history file. The format
of this prefixed data is:

  `: <beginning time>:<elapsed seconds>;<command>`.

先の節の例を見ると、

  • : 1745283180:0; の部分は、コマンドを実行時刻(unix epoc)と、コマンドの実行時間(seconds)
  • echo "foo" の部分は、実行したコマンド

になっているのが分かります。

そして、この時 コマンドの内容は、メタ文字を含む場合、エスケープされて保存されています。 [2]
このエスケープ処理が、zshの独自フォーマットで行われているため、雑にエディタで加工・保存すると履歴が壊れる原因になりえるのです。[3]

どう回避するか

端的に言えば、.zsh_hisotryファイルをイジる場合には、これらの特殊なエスケープ処理を回避する必要があります。

  • 編集前にエスケープ処理を除去し
  • 編集し
  • 編集後にエスケープ処理を施す

エスケープ処理の内容

どのようなエスケープ処理が行われているかは、マニュアル上では見つけられませんでしたが、
zshのソースコードにはそれらしいものが見つかります。[2:1]
metafy/unmetafyという関数で、それぞれエスケープ/除去相当の処理をしているようです。

metafy: https://github.com/zsh-users/zsh/blob/dd21cda278a64d1949c284282d7305ea22564052/Src/utils.c#L4820-L4905
unmetafy: https://github.com/zsh-users/zsh/blob/dd21cda278a64d1949c284282d7305ea22564052/Src/utils.c#L4930-L4953

メモリ確保の処理やポインタ演算やマクロが多くちょっと読みにくいですが、要は

  • metafyは、0x00または0x83-0xa20x20とxorして0x83を前置する
  • unmetafyは、0x83を見つけたらこれを除去して続く1バイトを0x20とxorする

という処理になっています(当然ですが、両者は逆の処理になっています)。

解決策を作った

このmetafy/unmetafyを処理するものを作って、安全に編集できるようにしました。[4]

CLIは zshist encode でエスケープ処理を施し、 zshist decode でエスケープ処理を除去することができます。
Vim/Neovim Pluginは、.zsh_historyを開いたときに自動でエスケープ処理を除去し、保存時にエスケープ処理を施すようにしています。

補足1: ヒストリの書き換え→再利用

実のところ、再利用時に実行するコマンドを書き換えるだけであればfcコマンドで置換をかけたり編集したりといった方法で解決できるので、
永続的に書き換えたいと思うシーンは多くないかもしれません。

$ echo "foo"
foo
$ fc -s foo=bar
echo "bar"
bar
$ fc -l
 1  echo "foo"
 2  echo "bar"

man zshbuiltinsには、fcコマンドの詳細が書かれています。

補足2: EXTENDED_HISTORYオプション

引用したEXTENDED_HISTORYはオプションになっているので、これを有効にしていない場合、コマンドの実行時刻や実行時間は保存されません。

$ unsetopt EXTENDED_HISTORY
$ echo "foo"
$ cat ~/.zsh_history
echo "foo"

今回作ったものは、EXTENDED_HISTORYオプションが有効な場合にのみ対応しています。
今のzshはデフォルトで有効になっているので、特に意識する必要はないかもしれません。

脚注
  1. 厳密には文字化けではないのですが、エディタに文字として認識できない表示が行われる、という意味で広く ↩︎

  2. 実際のところ、エスケープ処理をソースから見つけたわけではなく、Redditなどで「metafy/unmetafyという処理がされてるよ」というコメントを見つけたので、そこからソースを辿っていきました。 ↩︎ ↩︎

  3. ファイルの中で正しく文字として認識できない部分は除去する、などは意外と意識しないところで処理されていることがあります ↩︎

  4. 今回作ったものは、EXTENDED_HISTORYオプションが有効(デフォルト)な場合にのみ対応しています ↩︎

Discussion