🐼

:terminal in Neovimでpre-commitフックを使って未保存のバッファをチェックする

に公開

Vim駅伝、やってます

この記事はVim駅伝の2025-09-17向け記事です。
驚くべきことに、Vim駅伝400本目の記事になります

@thincaさんの提案により2023年3月1日から2年半に渡って週に3回のペースで続いた営みです。
一人の参加者としてときおり記事を寄稿させていただいていましたが、400本という節目に立ち会えたこと、大変光栄に思うとともに、普段より運営に携わっていただいている皆様に最大限の感謝を述べたいと思います。

この記事に辿り着いたみなさんも、是非気軽に参加して記事を寄せてみてください。
参加条件等は上記リンクより見られますので、まずはご一読くださいませ。

今日のテーマ: hidden bufferのコミット漏れ

立ち上げっぱなしのNeovimで作業をしていると、編集中のバッファをどこかに残したままGitにコミットしてしまって、編集内容の一部がコミットから漏れてしまう、という経験は割合よくあることでしょう。

そこで、gitのpre-commitフックを使って、コミット前に編集したまま保存されていないバッファが無いかをチェックする仕組みを考えました。

基本的な仕組み

今回考えた仕組みは

  • Denopsを使って起動時にHTTPサーバーを起動
  • gitのpre-commitフックでそのサーバーにリクエストしてバッファの状況を確認
  • 問題なければコミットを続ける

というものです。
サーバーのアドレスはDenopsからNeovim側に環境変数としてExportし、:terminalに引き継がれた値をpre-commitフックで参照します。

サーバー側の処理

サーバー側では、以下の様な処理を行います。

  • リクエストのBodyからパラメータを受け取る
  • 編集中でGit管理下にあるbufferを列挙する
  • ユーザーに確認を促す
  • 確認結果でコミットを継続して良いかどうかレスポンスで返す
import * as vars from "jsr:@denops/std@~7.6.0/variable";

import * as fn from "jsr:@denops/std@~7.6.0/function";
import type { Denops } from "jsr:@denops/std@~7.6.0";

// The global variable name to store the pre-commit server address.
// It maybe used in git hook script.
//
// See: git/hooks/pre-commit
const PRECOMMIT_ADDRESS = "PRECOMMIT_ADDRESS";

// Start a HTTP server to handle pre-commit hook requests.
// The server listens on a random free port and sets the address
// to the global variable `PRECOMMIT_ADDRESS`.
// The server checks for dirty buffers in the specified directory
// and prompts the user to save or ignore them before committing.
export async function main(denops: Denops): Promise<void> {
  const { finished } = Deno.serve({
    hostname: "127.0.0.1",
    port: 0, // Automatically select free port
    handler: async (req, _info) => {
      if (req.body === null) {
        return new Response("No body", { status: 400 });
      }
      const params = await parseRequestBody(req.body);
      const bufs = await findDirtyBuffers(denops, params.dir);
      if (bufs.length === 0) {
        return new Response("ok");
      }
      const msg = createConfirmMessage(bufs);
      switch (
        await fn.confirm(denops, msg, "&Yes\n&No")
      ) {
        case 1: // Yes (Ignore them)
          return new Response("ok");
        case 2: // No (Suspend)
          return new Response("skip");
      }
      return new Response("unsupported status", { status: 500 });
    },
    onListen: async ({ hostname, port }) => {
      await vars.e.set(
        denops,
        PRECOMMIT_ADDRESS,
        `${hostname}:${port}`,
      );
    },
  });
  await finished;
}

// Create a confirmation message listing dirty buffers.
function createConfirmMessage(
  bufs: { name: string; bufnr: number; buftype: unknown }[],
) {
  const files = bufs.map((buf) => buf.name);
  const msg = [
    bufs.length > 1 ? "There're dirty buffers." : "There's a dirty buffer.",
    "If you need, you should save them before commit:",
    "",
    ...files,
    "",
    "Ignore them and continue?",
  ].join("\n");
  return msg;
}

// Parse the request body as JSON.
async function parseRequestBody(body: ReadableStream<Uint8Array>) {
  const lines = [];
  for await (const line of body.pipeThrough(new TextDecoderStream()).values()) {
    lines.push(line);
  }
  const params = JSON.parse(lines.join("\n"));
  return params;
}

// Find dirty buffers in the specified directory.
async function findDirtyBuffers(denops: Denops, dir: string) {
  const bufinfos = await fn.getbufinfo(denops, { bufmodified: true });
  const bufs = (await Promise.all(bufinfos.map(async (bufinfo) => {
    const buftype = await fn.getbufvar(denops, bufinfo.bufnr, "&buftype");
    return {
      name: bufinfo.name,
      bufnr: bufinfo.bufnr,
      buftype,
    };
  })))
    .filter((buf) =>
      buf.buftype === "" && buf.name !== "" &&
      buf.name.startsWith(dir)
    );
  return bufs;
}

サーバーアドレスは環境変数 PRECOMMIT_ADDRESS にセットしています。

pre-commitフックの処理

こちらはそう難しくありません。環境変数 PRECOMMIT_ADDRESS に値がセットされていれば、Git管理のディレクトリをサーバーに渡してレスポンスに応じてexit codeをセットします。

#!/bin/zsh

if [ -n "${PRECOMMIT_ADDRESS}" ]; then
  exec < /dev/tty
  dir="$(git rev-parse --show-toplevel | jq -R)"

  # Timeout : 5s
  ret="$(curl -XPOST -sSL --max-time 5 "${PRECOMMIT_ADDRESS}" -d '{"dir":'"${dir}"'}' 2>/dev/null)"
  curl_exit_code=$?

  case "${ret}" in
    "ok")
      : # noop
      ;;
    *)
      echo "Commit cancelled: unsaved buffers detected (${curl_exit_code}: ${ret})"
      exit 1
      ;;
  esac
fi

設定方法

1. Denopsプラグインのセットアップ

上記のTypeScriptコードを ~/.config/nvim/denops/pre-commit/main.ts として保存します。runtimepath配下にあるため、Denopsが自動的に読み込みます。

2. pre-commitフックの設置

上記のシェルスクリプトを .git/hooks/pre-commit として保存し、実行権限を付与します:

chmod +x .git/hooks/pre-commit

グローバルに適用したい場合は、core.hooksPath を使用できます:

# グローバルフックディレクトリの設定
git config --global core.hooksPath ~/.config/git/hooks
# フックスクリプトを配置
cp pre-commit ~/.config/git/hooks/

注意事項

スコープと制限

環境変数 PRECOMMIT_ADDRESS はプロセス単位で管理されるため:

  • チェック対象はそのNeovimインスタンス内で開いているバッファのみ
  • そのインスタンス内の :terminal から実行したgitコマンドでのみ動作
  • 他のNeovimインスタンスや別のターミナルから実行したgitコマンドには影響しません

つまり、他のNeovimインスタンスで編集中のバッファはチェックできません。これは環境変数の仕組み上の制限です。

終わりに

編集中のバッファをコミットしそびれるミスは気づきにくく、Git管理下にない状態でファイルをロストする事故にもつながります。
同じ事故に心当たりのある方に、少しでも役に立てれば幸いです。

Discussion