2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gitで「編集予定のファイルを先にロック」を実現する方法

Last updated at Posted at 2025-12-11

背景

Git は基本的に「ブランチを切って、コンフリクトしたらマージで解決する」世界観で動いています。
一方で、設計書や巨大な設定ファイル、Excel/バイナリなど「コンフリクトした瞬間に地獄を見る」ファイルも現場にはたくさんあります。

SVN(Subversion) にはファイルロック機能があり、

  • この設計書は今 A さんがロック中
  • 他の人は編集禁止(あるいは警告)

といった運用が簡単にできました。しかし、Git には標準で同等の仕組みがありません。

そこで、Git でファイルロックを実現するために、以下の仕組みを組み合わせて構築しました。

  • ファイル単位にロックユーザーを登録する 簡易 Web サイト
  • コミット前にロック情報をチェックする pre-commit フック

この記事では、その構成と実装、使ってみて見えてきた課題までまとめます。

やりたいこと(要件)

要件は以下のとおりです。

  • 特定のファイルだけ「ロック対象」として管理したい
  • 誰がどのファイルをロックしているかをブラウザで確認&編集したい
  • ロックされているファイルを、ロック者以外がコミットしようとしたら pre-commit で止める
  • できるだけシンプルな構成にしたい(重い DB は使わない)

全体構成

構成はとてもシンプルです。

  • ユーザー(開発者)がブラウザから index.html を開いてロック情報を編集し、保存ボタンを押す
  • 保存ボタンを押すと、server.js が protected-files.json にロック情報を保存する
  • 他のユーザ(開発者)がコミットすると、pre-commit フックが protected-files.json を読みに行き、ロック違反があればコミットを中止する

ロックサーバ(server.js)

サーバ側は Node.js + Express で書いた、かなり小さなアプリです。

役割

  • public/ ディレクトリを静的配信(index.html をここに置く)
  • public/protected-files.json を読み書きする API を提供

コード

// server.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const bodyParser = require('body-parser');
const cors = require('cors');

const app = express();
const PORT = 4000;
const FILE_PATH = path.join(__dirname, 'public', 'protected-files.json');

app.use(bodyParser.json());
app.use(cors());

// 静的ファイルを配信 (index.html 等)
app.use(express.static(path.join(__dirname, 'public')));

// ロック情報を取得
app.get('/protected-files.json', (req, res) => {
  fs.readFile(FILE_PATH, 'utf8', (err, data) => {
    if (err) {
      // 初回などファイルが無い場合は空の構造を返す
      return res.json({ files: [] });
    }
    try {
      const json = JSON.parse(data);
      res.json(json);
    } catch (e) {
      res.status(500).send('Error parsing JSON file.');
    }
  });
});

// ロック情報を更新
app.post('/files', (req, res) => {
  const fileList = req.body.files; // [{ fileName, branchName, locker, note }, ...]
  fs.writeFile(
    FILE_PATH,
    JSON.stringify({ files: fileList }, null, 2),
    'utf8',
    (err) => {
      if (err) {
        return res.status(500).send('Error writing file.');
      }
      res.send('File list updated successfully.');
    }
  );
});

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

JSON の構造はこんなイメージです。

{
  "files": [
    {
      "fileName": "specs/OrderSpec.md",
      "branchName": "main",
      "locker": "Yamada Taro",
      "note": "受注機能の画面設計修正中"
    }
  ]
}

ロック管理ページ(index.html)

フロント側は、素の HTML + JavaScript で作った簡単な管理画面です。

やっていること

  • ページ読み込み時に /protected-files.json を fetch してテーブル表示
  • 行の追加・削除
  • ファイル名の重複チェック
  • 「保存」ボタンで /files に POST して保存

ソースのイメージだけ載せておきます。

<!-- public/index.html (抜粋) -->
<table id="file-table">
  <thead>
    <tr>
      <th>ファイル名</th>
      <th>ブランチ名</th>
      <th>ロック者</th>
      <th>備考</th>
      <th></th>
    </tr>
  </thead>
  <tbody id="file-table-body">
    <!-- ここに JS で行を追加 -->
  </tbody>
</table>

<button onclick="addRow()">行を追加</button>
<button onclick="save()">保存</button>

JavaScript 側で、JSON をテーブルに流し込んだり、重複チェックしたりしています。

Protected Files Manager.png

pre-commit フックでロックを強制する

いちばん「効いている」のが pre-commit フックです。
.git/hooks/pre-commit に次のようなスクリプトを置きます(実際には chmod +x も必要)。

#!/bin/bash

# ----------------------------------------------------------
# pre-commit: protected-files.json によるロックチェック
# - JSONパース: jq(なければ python3)で実施
# - ファイル判定: basename ではなく「相対パス」で完全一致
# ----------------------------------------------------------

# ステージされているファイル一覧(改行区切り)
files=$(git diff --cached --name-only)

# コミット対象ファイルがなければそのまま終了
if [ -z "$files" ]; then
    exit 0
fi

# Gitユーザー名を取得(空白は除去しておく)
git_user=$(git config user.name | tr -d '[:space:]')

# ロック情報JSONのURL
url='http://vodka2:4000/protected-files.json'

# JSONデータを取得
json_data=$(curl -fsS "$url" 2>/dev/null)

if [ -z "$json_data" ]; then
    echo "警告: $url からJSONデータを取得できませんでした。ロックチェックをスキップします。"
    exit 0
fi

# 一時ファイルを作成(fileName<TAB>locker の形式で保存)
temp_file=$(mktemp)

# ----------------------------------------------------------
# JSONパース部
#   - まず jq を試す
#   - jq がなければ python3 を試す
#   - どちらもなければロックチェックをスキップ
#   - fileName: リポジトリルートからの相対パスを前提
# ----------------------------------------------------------

if command -v jq >/dev/null 2>&1; then
    # jq がある場合
    printf '%s\n' "$json_data" | jq -r '
        # ルートが配列の場合: 各要素
        # ルートがオブジェクトの場合: そのまま / 値の配列化など状況に応じて調整可能
        (if type == "array" then .[] else . end)
        | select(.fileName and .locker)
        | "\(.fileName)\t\(.locker)"
    ' > "$temp_file" 2>/dev/null

    if [ $? -ne 0 ]; then
        echo "警告: JSONのパースに失敗しました(jq)。ロックチェックをスキップします。"
        rm -f "$temp_file"
        exit 0
    fi

elif command -v python3 >/dev/null 2>&1; then
    # jq がなく python3 がある場合
    printf '%s\n' "$json_data" | python3 - <<'PY' > "$temp_file" 2>/dev/null
import sys, json

try:
    data = json.load(sys.stdin)
except Exception:
    sys.exit(1)

# ルートが dict の場合と list の場合にざっくり対応
items = []
if isinstance(data, list):
    items = data
elif isinstance(data, dict):
    # 必要であれば data.values() などに変更
    items = data.get("files", data.values())

for item in items:
    if not isinstance(item, dict):
        continue
    fn = item.get("fileName")
    lk = item.get("locker")
    if fn and lk:
        # fileName <TAB> locker
        print(f"{fn}\t{lk}")
PY

    if [ $? -ne 0 ]; then
        echo "警告: JSONのパースに失敗しました(python3)。ロックチェックをスキップします。"
        rm -f "$temp_file"
        exit 0
    fi
else
    echo "警告: jq も python3 も見つからないため、JSONパースができません。ロックチェックをスキップします。"
    rm -f "$temp_file"
    exit 0
fi

# デバッグ用出力(不要ならコメントアウト)
echo "Protected Files (path -> locker):"
cat "$temp_file"

# コミットを中止するフラグ
commit_abort=0

# ----------------------------------------------------------
# コミット対象ファイルごとにロックチェック
#   - file: git diff --cached --name-only の相対パス
#   - temp_file: fileName<TAB>locker
#   - fileName と file を「完全一致(相対パス)」で比較
# ----------------------------------------------------------
while read -r file; do
    [ -z "$file" ] && continue

    # temp_file から完全一致するパスの locker を取得
    locker=$(awk -F'\t' -v target="$file" '$1 == target {print $2}' "$temp_file" | head -n 1)

    # ロック対象でなければ次へ
    if [ -z "$locker" ]; then
        continue
    fi

    # ロック者文字列(例: "wang-meng、miyagawa-t, yakou")
    lock_str="$locker"

    # 日本語の読点「、」をカンマに変換
    locker_replaced=${lock_str//、/,}

    # カンマ区切りで分割
    IFS=',' read -ra lockers <<< "$locker_replaced"

    user_found=0
    for l in "${lockers[@]}"; do
        # 前後の空白や改行を削除
        l_trimmed=$(echo "$l" | tr -d '[:space:]')
        [ -z "$l_trimmed" ] && continue

        if [ "$git_user" = "$l_trimmed" ]; then
            user_found=1
            break
        fi
    done

    if [ $user_found -eq 0 ]; then
        echo "エラー: ファイル '$file' は '$locker' によりロックされています。"
        commit_abort=1
    fi

done <<< "$files"

# 一時ファイルを削除
rm -f "$temp_file"

# ロック違反があればコミット中止
if [ $commit_abort -eq 1 ]; then
    echo "コミットを中止します。"
    echo "詳細は以下のURLをご確認ください:"
    echo "http://localhost:4000/"
    exit 1
fi

exit 0

ロジックの流れ

  1. git diff --cached --name-only で、ステージされているファイルだけ を取得
  2. curl でロックサーバから protected-files.json を取得
  3. jq または python3 で fileNamelocker を一時ファイルに抽出
  4. ステージされたファイルを 1 行ずつ見ていき、
    • ファイルの相対パスでロック情報を完全一致で検索
    • ロック情報があれば locker を取得
    • git config user.namelocker を比較
  5. ロック者に自分が含まれていなければエラーを表示し、コミット中止

実際の運用イメージ

  1. ロックしたい設計書や設定ファイルを index.html から登録

    • ファイル名
    • ブランチ名
    • ロック者(Git の user.name と合わせておく)
    • 備考(作業内容など)
  2. ロックした人が作業してコミット → pre-commit でロック者と一致するのでそのまま通過

  3. 別の人が同じファイルをいじってコミットしようとすると…

    • pre-commit で「このファイルは ○○ によりロックされています」と怒られてコミット中止
  4. ロック者の作業が終わったら、index.html からロックを解除/ロック者を変更

SVN のような「サーバにロック状態があって、クライアント側で強制力が働く」感じを、Git でもそれっぽく再現できています。

作ってみて分かった課題と改善案

実際に動くものを作ってみると、「ここはもう少しちゃんとしたいな」というポイントが見えてきます。いくつか挙げます。

1. 認証・なりすまし

今は「ロック者」は単なる文字列であり、ブラウザから誰でも自由に書き換えられます。
同じように、Git 側も git config user.name はローカルで書き換え放題です。

社内ツールであれば「悪意あるなりすまし」はあまり想定しないかもしれませんが、誤入力や名前表記ゆれ(苗字だけ / フルネーム / 漢字 vs ローマ字)などでも判定ミスが起こり得ます。

対策案:

  • ロック管理サイトを社内の SSO などで認証し、「ログインユーザ ID」をロック者として登録

  • pre-commit 側も、その ID を使ってサーバとやり取りする

    • 例:メールアドレス・社員番号など

2. サーバダウン時のポリシー

今は「ロックサーバから JSON が取得できなかったら exit 0(コミット許可)」にしています。
これは「サーバが止まって開発も止まる」のを避けたいという判断です。

一方で、「必ずロックチェックが通っている前提で運用したい」チームだと、

  • サーバ unreachable のときは commit も禁止してしまう(exit 1)
  • どうしても必要なら --no-verify で明示的に pre-commit をスキップする

というポリシーにした方が安心、という考え方もあります。

どちらも一長一短なので、チームで方針を決めて、スクリプトにもコメントとして残しておく とよさそうです。

3. ロックの期限・強制解除

SVN でもよくある問題ですが、

  • ロックした人が長期休暇に入った
  • ロックしたまま退職してしまった
  • ロックしたこと自体を忘れている

といったケースでロックが延々と残り続けます。

今の JSON には lockedAt のような情報がないので、「明らかに古すぎるロック」を判定することができません。

対策案:

  • ロック構造に lockedAt / updatedAt を追加
  • UI に「最終更新日時」を表示
  • 一定日数を超えたロックを警告表示したり、管理者が強制解除できるようにする

4. JSON ファイルひとつの更新競合

ロック情報は protected-files.json ひとつにまとまっているので、

  • A さん・B さんがそれぞれ画面を開いて編集
  • A さんが保存
  • その後 B さんが保存

という順序になると、A さんの変更が B さんの保存で上書きされてしまいます。

対策案:

  • JSON に version を持たせ、POST 時に一致しているかチェックする(楽観ロック)
  • 本格的にやるなら SQLite/PostgreSQL 等で 1 行 = 1 ファイルロックとして管理する

まとめ

Git には SVN のようなロック機能はありませんが、以下の組み合わせで、「実用的なファイルロック」を実現できました

  • ロック情報を管理する 簡易 Web サーバ
  • コミット前にロックをチェックする pre-commit フック
     
    実装自体はシンプル(Node.js + JSON + シェルスクリプト)なので、小さく試してチームに合うかどうか検証しやすい構成になっています

ただし、以下ような課題があり、改善していく余地があります

  • 認証・なりすまし
  • サーバダウン時のポリシー
  • ロックの期限・強制解除

「Git だけど、ここのファイルはどうしてもロックしたい」という現場はまだまだ多いと思うので、似たような課題を抱えている方の参考になればうれしいです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?