zshの重複コマンド履歴を自動整理する設定をzshrcに追加した

Automatically Deduplicating zsh Command History in zshrc

* 本ページはプロモーションが含まれています

日々のターミナル作業で ~/.zsh_history には同じコマンドが何度も積み上がっていく。
peco / Ctrl-Rでの履歴検索がノイズだらけになるし、ファイルも肥大化しやすい。

普段使っているzshrcは、各種dotファイルをまとめてGitで管理しているdotfilesリポジトリの一部だ。
今回はこのzshrcに手を入れて、重複を減らす設定を追加することにした。


要件を先に決める

手を動かす前に、次の仕様を決めた。

  • 重複を消すときは最新の1件を残す
  • 入力時は連続重複のみ除去する(hist_ignore_dups
  • 定期整理では履歴全体をdedupする
  • 実行タイミングはシェル起動時・1日1回
  • あわせてhist_ignore_spaceも有効化する

実行タイミングを終了時フックではなく起動時にしたのは、終了時フックは強制終了でスキップされやすく、確実に効かせたかったからだ。
頻度を1日1回に制限すれば、起動遅延もほぼ気にならないはずだと判断した。

既存の履歴設定を確認する

変更前の状態はこうだった。

  • HISTFILE=$HOME/.zsh_history
  • HISTSIZE=100000 / SAVEHIST=1000000
  • inc_append_history / share_history は有効
  • 重複除外系のsetoptは未設定

複数ターミナルで履歴を共有している前提はそのまま維持する方針にした。


入力時の制御

まず、毎回の入力に効くsetoptを追加した。

setopt hist_ignore_dups
setopt hist_ignore_space
  • hist_ignore_dups: 直前と同じコマンドは履歴に追加しない
  • hist_ignore_space: 先頭にスペースを付けたコマンドは履歴に残さない(一時的な秘密情報などに便利)

これだけでも日常の連続実行ノイズは減る。ただし、過去に溜まった重複や、時間をおいて再実行した同一コマンドまでは消えない。

全体dedup関数を書く

履歴ファイルを末尾から走査し、同じコマンドは最新だけ残して一時ファイル経由で置換する関数を書いた。
拡張履歴形式(: <time>:<elapsed>;<cmd>)にも対応させている。

zsh-history-dedup() {
  local histfile="${HISTFILE:-$HOME/.zsh_history}"
  local tmpfile
  local saved_histsize

  if [[ ! -f "$histfile" ]]; then
    print -u2 "zsh-history-dedup: HISTFILE が存在しません: histfile=$histfile"
    return 1
  fi

  tmpfile="$(mktemp "${histfile}.XXXXXX")" || return 1

  awk '
    function command_key(line,    key) {
      key = line
      if (match(key, /^: [0-9]+:[0-9]+;/)) {
        key = substr(key, RLENGTH + 1)
      }
      return key
    }
    { lines[NR] = $0 }
    END {
      for (i = NR; i >= 1; i--) {
        key = command_key(lines[i])
        if (!(key in seen)) {
          seen[key] = 1
          kept[++kept_count] = lines[i]
        }
      }
      for (i = kept_count; i >= 1; i--) print kept[i]
    }
  ' "$histfile" > "$tmpfile" || {
    rm -f "$tmpfile"
    return 1
  }

  mv -f "$tmpfile" "$histfile" || {
    rm -f "$tmpfile"
    return 1
  }

  # メモリ上の履歴も再読込(終了時の書き戻しで重複が復活しないようにする)
  saved_histsize=$HISTSIZE
  HISTSIZE=0
  HISTSIZE=$saved_histsize
  fc -R "$histfile"
}

ポイントは次の2つ。

  1. 一時ファイルへ書き出してからmvで置換する。途中で失敗しても元ファイルを壊さない
  2. ファイル整理後にHISTSIZE=0→復元→fc -Rでメモリ上の履歴も同期する

ディスクだけ綺麗にしても、メモリ上に残った古い履歴がシェル終了時に書き戻されると重複が復活してしまう。
ここを見落とすと、dedupしたはずなのに次の日にはまた重複が増えている、ということになりかねない。

1日1回の起動トリガーを実装する

対話シェルのときだけ、スタンプファイルの日付を見て実行するようにした。

if [[ -o interactive ]]; then
  typeset zsh_history_dedup_stamp_dir="${XDG_CACHE_HOME:-$HOME/.cache}/zsh"
  typeset zsh_history_dedup_stamp_file="${zsh_history_dedup_stamp_dir}/history-dedup.stamp"
  typeset zsh_history_dedup_today
  zsh_history_dedup_today="$(date +%Y-%m-%d)"

  mkdir -p "${zsh_history_dedup_stamp_dir}"
  if [[ ! -f "${zsh_history_dedup_stamp_file}" ]] \
    || [[ "$(<"${zsh_history_dedup_stamp_file}")" != "${zsh_history_dedup_today}" ]]; then
    if zsh-history-dedup; then
      print -r -- "${zsh_history_dedup_today}" >| "${zsh_history_dedup_stamp_file}"
    fi
  fi
fi

スタンプは~/.cache/zsh/history-dedup.stampに置いた。成功した日だけ日付を書き込むようにしているので、途中で失敗した場合は次回起動時にまた再試行される。

手動で整理したいときは、そのまま関数を叩けばいい。

zsh-history-dedup

動作確認の目安

設定を入れたあと、次の手順で効いているか確認するつもりだ。

  1. 新しい対話シェルを開く
  2. その日初めての起動なら~/.zsh_historyの行数が減っているか確認する
  3. ~/.cache/zsh/history-dedup.stampに今日の日付が入っているか確認する
  4. peco / Ctrl-Rで同一コマンドが散らかっていないか確認する

まとめ

  • 入力時はhist_ignore_dups / hist_ignore_spaceで日常のノイズを抑制する
  • 起動時1日1回で履歴ファイル全体を「最新優先」に整理する
  • ファイル置換後にメモリ上の履歴も再読込し、終了時の書き戻しで重複が復活する事故を防ぐ

履歴検索を快適にしたまま、zshrc側でメンテを自動化できた形になった。

関連項目