カスタムGitサブコマンドの作成:BashとPythonでGit CLIを拡張する

Git tutorial - IT technology blog
Git tutorial - IT technology blog

背景:深夜2時のインシデントとうっかりミスの代償

午前2時15分。決済ゲートウェイのタイムアウト障害が発生し、200件以上のトランザクションが滞留しました。チーム全員が慌ててPCを開き、緊急ホットフィックスの対応に追われていました。重い瞼と疲労困憊の指先で、私はcherry-pickコマンドを取り違えたうえ、勢い余ってgit push --forceを実行してしまったのです。結果、共有ブランチ上の重要な4つのコミットが完全に消失。チームはデータのrevertと復旧作業にさらに3時間近く費やす羽目になりました。あの夜を経て、私は一つの真理を痛感しました。深夜の人間の集中力にシステムの安全を委ねてはならない、と。

多くのエンジニアは~/.gitconfigのgit aliasを活用しているでしょう。co = checkoutやst = statusといったシンプルな操作なら、エイリアスは非常に便利です。しかし、処理が複雑になるにつれて、エイリアスの限界が浮き彫りになります。柔軟なフラグのパースや入力値のバリデーションができず、--helpヘルプ画面も用意できません。さらに、何十行ものシェルスクリプトを単一の設定ファイルに詰め込むのは、保守の観点からもまさに悪夢です。

Gitには、この課題をスマートに解決する仕組みが古くから備わっています。それがカスタムGitサブコマンド(Custom Git Subcommands)です。仕組みは非常に明快で、環境変数PATHの通ったディレクトリにgit-<コマンド名>という命名規則で実行可能ファイルを配置するだけで、正規のサブコマンドとして認識されます。例えばgit safe-pushと入力すると、Gitは自動的にgit-safe-pushを呼び出し、渡されたすべての引数をそのまま引き継ぎます。

セットアップ:解決メカニズムと最初のサブコマンド

Gitのコマンド解決は極めて直感的です。Gitコアに組み込まれていないコマンド(例: git nuke)が入力されると、Gitは環境変数$PATH内のディレクトリを走査し、git-nukeという実行可能ファイルを検索します。

ステップ1: 自作スクリプト用ディレクトリの準備

自作スクリプトをシステムの/usr/binや/binに直接放り込むのは避けましょう。バックアップやマシン移行の利便性を考慮し、ユーザーのホームディレクトリ配下に分離することをおすすめします。

mkdir -p ~/.local/bin

# ~/.bashrc または ~/.zshrc にパスを追加
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

ステップ2: 誤ったブランチへの強制プッシュを防ぐスクリプトの作成

~/.local/bin/git-safe-pushを作成します。このスクリプトは、より安全な--force-with-leaseフラグを使用し、重要な保護ブランチへの上書きプッシュを完全に遮断します。

cat << 'EOF' > ~/.local/bin/git-safe-push
#!/usr/bin/env bash
set -euo pipefail

CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || true)

if [ -z "$CURRENT_BRANCH" ]; then
  echo "[ERROR] Detached HEAD状態です。処理を中止します。" >&2
  exit 1
fi

# 上書きプッシュを禁止する保護ブランチ一覧
PROTECTED_BRANCHES=("main" "master" "production" "release")

for b in "${PROTECTED_BRANCHES[@]}"; do
  if [ "$CURRENT_BRANCH" = "$b" ]; then
    echo "[ブロック] 保護対象ブランチにチェックアウトしています: '$CURRENT_BRANCH'!" >&2
    echo "このブランチに対する safe-push の実行は禁止されています。" >&2
    exit 2
  fi
done

echo "=> 安全にプッシュを実行中: $CURRENT_BRANCH"
git push origin "$CURRENT_BRANCH" --force-with-lease
EOF

chmod +x ~/.local/bin/git-safe-push

これで、PC上の任意のローカルリポジトリからgit safe-pushが実行できるようになります。

応用編:Pythonによる高度なロジック処理

シンプルなタスクであればシェルスクリプトで十分高速です。しかし、JSONログのパースや多段階の正規表現チェック、外部APIの呼び出しなどを伴う場合、Pythonの標準ライブラリ(argparseやsubprocess)を活用した方が可読性も保守性もはるかに向上します。

リリース前チェッカーの構築:git-audit

~/.local/bin/git-auditを作成します。このスクリプトは直近のコミットを自動走査し、JiraチケットIDの記載漏れを検出するとともに、誤って機密情報ファイルを含めていないかを警告します。

#!/usr/bin/env python3
import argparse
import subprocess
import sys
import re

def run_cmd(cmd):
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"[ERROR] コマンドの実行に失敗しました: {cmd}\n{result.stderr}", file=sys.stderr)
        sys.exit(result.returncode)
    return result.stdout.strip()

def main():
    parser = argparse.ArgumentParser(
        prog="git audit",
        description="デプロイ前にコミットフォーマットを検証し、機密ファイルをスキャンします。"
    )
    parser.add_argument("-n", "--count", type=int, default=5, help="検証対象とする直近コミット数(デフォルト: 5)")
    parser.add_argument("--strict", action="store_true", help="コミットにJiraチケットIDがない場合に処理をブロックする")
    args = parser.parse_args()

    print(f"[*] 直近 {args.count} 件のコミットを検証中...")
    log_output = run_cmd(f"git log -n {args.count} --pretty=format:'%h|%an|%s'")
    
    if not log_output:
        print("[!] コミットが見つかりませんでした。")
        return

    has_warning = False
    for line in log_output.splitlines():
        sha, author, msg = line.split("|", 2)
        # メッセージ冒頭のチケットIDをチェック(例: PROJ-1024)
        ticket_pattern = r"^[A-Z]{2,10}-[0-9]+"
        if not re.search(ticket_pattern, msg):
            print(f"  [警告] コミット {sha} ({author}) にチケットIDがありません: '{msg}'")
            has_warning = True
        else:
            print(f"  [OK] {sha} - {msg}")

    # HEADコミットに機密ファイルが誤って含まれていないかチェック
    changed_files = run_cmd("git diff-tree --no-commit-id --name-only -r HEAD")
    sensitive_patterns = [".env", "credentials.json", "id_rsa", ".pem"]
    for f in changed_files.splitlines():
        if any(pat in f for pat in sensitive_patterns):
            print(f"\n[高リスク] HEADコミット内に機密ファイルを検出しました: {f}", file=sys.stderr)
            sys.exit(3)

    if has_warning and args.strict:
        print("\n[FAILED] strictモードが有効です: コミットメッセージのフォーマットが不正です。", file=sys.stderr)
        sys.exit(1)

    print("\n=> 検証完了。ソースコードはデプロイ可能な状態です!")

if __name__ == "__main__":
    main()

作成したファイルへの実行権限の付与も忘れずに行いましょう。

chmod +x ~/.local/bin/git-audit

テストとデバッグ:安定運用のための確認ポイント

スクリプトを書き上げることと、日々のワークフローで安定して動作するか検証することは別問題です。

1. Gitがサブコマンドを認識しているか確認する

ディレクトリを直接開いて確認する必要はありません。Git標準のフラグを使って、外部サブコマンドが正しく認識されているか一覧表示できます。

git --list-cmds=others | grep -E "(safe-push|audit)"

2. 終了ステータス(Exit Code)の確認

優れたサブコマンドは、適切な終了ステータスを返す必要があります。これにより、外部のシェルスクリプトやCI/CDパイプラインがエラーを正確に検知し、安全に処理を停止できるようになります。

# Pythonスクリプトのヘルプ表示を確認
git audit --help

# strictモードでテスト実行し、終了コードを確認
git audit -n 3 --strict
echo "Exit Code: $?"

終了コードが0以外であれば、スクリプトが意図通り規約違反をブロックできている証拠です。

3. “is not a git command” エラーが発生した時の対処法

  • 実行権限の付与漏れ: ls -l $(which git-safe-push)を実行し、権限が-rwxr-xr-xになっているか確認します。
  • Shebang行の誤り: 常に#!/usr/bin/env bashまたは#!/usr/bin/env python3を使用してください。#!/bin/bashのような固定パスのハードコードを避けることで、LinuxとmacOSの双方で移植性を維持できます。
  • シェルのPATHキャッシュ未更新: スクリプト作成直後にGitがコマンドを認識しない場合、Bashならhash -r、Zshならrehashを実行してコマンドのハッシュテーブルを更新してください。
Share: