背景
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 をテーブルに流し込んだり、重複チェックしたりしています。
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
ロジックの流れ
-
git diff --cached --name-onlyで、ステージされているファイルだけ を取得 -
curlでロックサーバからprotected-files.jsonを取得 - jq または python3 で fileNamelocker を一時ファイルに抽出
- ステージされたファイルを 1 行ずつ見ていき、
- ファイルの相対パスでロック情報を完全一致で検索
- ロック情報があれば
lockerを取得 -
git config user.nameとlockerを比較
- ロック者に自分が含まれていなければエラーを表示し、コミット中止
実際の運用イメージ
-
ロックしたい設計書や設定ファイルを
index.htmlから登録- ファイル名
- ブランチ名
- ロック者(Git の user.name と合わせておく)
- 備考(作業内容など)
-
ロックした人が作業してコミット → pre-commit でロック者と一致するのでそのまま通過
-
別の人が同じファイルをいじってコミットしようとすると…
- pre-commit で「このファイルは ○○ によりロックされています」と怒られてコミット中止
-
ロック者の作業が終わったら、
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 だけど、ここのファイルはどうしてもロックしたい」という現場はまだまだ多いと思うので、似たような課題を抱えている方の参考になればうれしいです。
