章 ▾ 第2版

8.4 Gitのカスタマイズ - Gitで強制されるポリシーの例

Gitで強制されるポリシーの例

このセクションでは、学習した内容を活かして、カスタムコミットメッセージ形式をチェックし、特定のユーザーのみがプロジェクト内の特定のサブディレクトリを変更できるようにするGitワークフローを確立します。開発者がプッシュが拒否されるかどうかを知るのに役立つクライアントスクリプトと、実際にポリシーを強制するサーバスクリプトを作成します。

ここで示すスクリプトはRubyで書かれています。その理由は、私たちの知識慣性によるものもありますが、Rubyは書けなくても読みやすいからです。しかし、どの言語でも動作します — Gitに同梱されているフックスクリプトのサンプルはすべてPerlかBashで書かれているので、サンプルを見ればこれらの言語でのフックの例もたくさん見ることができます。

サーバサイドフック

すべてのサーバサイドの作業は、あなたのhooksディレクトリにあるupdateファイルに入ります。updateフックは、プッシュされるブランチごとに1回実行され、3つの引数を取ります

  • プッシュ先の参照名

  • そのブランチがあった古いリビジョン

  • プッシュされる新しいリビジョン

プッシュがSSH経由で実行されている場合、プッシュを行っているユーザーにもアクセスできます。公開鍵認証を介して全員が単一のユーザー(「git」など)で接続できるようにしている場合、公開鍵に基づいてどのユーザーが接続しているかを判断し、それに応じて環境変数を設定するシェルラッパーをそのユーザーに与える必要があるかもしれません。ここでは、接続しているユーザーが$USER環境変数に入っていると仮定し、必要なすべての情報を収集することから更新スクリプトを開始します

#!/usr/bin/env ruby

$refname = ARGV[0]
$oldrev  = ARGV[1]
$newrev  = ARGV[2]
$user    = ENV['USER']

puts "Enforcing Policies..."
puts "(#{$refname}) (#{$oldrev[0,6]}) (#{$newrev[0,6]})"

はい、これらはグローバル変数です。批判しないでください — この方が説明しやすいのです。

特定のコミットメッセージ形式の強制

最初の課題は、各コミットメッセージが特定の形式に準拠するように強制することです。目標として、各メッセージに「ref: 1234」のような文字列が含まれている必要があると仮定します。これは、各コミットがあなたのチケット管理システムの作業項目にリンクするようにするためです。プッシュされる各コミットを調べ、その文字列がコミットメッセージに含まれているかを確認し、いずれかのコミットにその文字列がない場合は、プッシュが拒否されるようにゼロ以外の値で終了する必要があります。

プッシュされているすべてのコミットのSHA-1値のリストは、$newrev$oldrevの値を取得し、それらをgit rev-listと呼ばれるGitのプルーミングコマンドに渡すことで取得できます。これは基本的にgit logコマンドと同じですが、デフォルトではSHA-1値のみが出力され、他の情報は出力されません。したがって、あるコミットSHA-1と別のコミットSHA-1の間に追加されたすべてのコミットSHA-1のリストを取得するには、次のようなコマンドを実行できます

$ git rev-list 538c33..d14fc7
d14fc7c847ab946ec39590d87783c69b031bdfb7
9f585da4401b0a3999e84113824d15245c13f0be
234071a1be950e2a8d078e6141f5cd20c1e61ad3
dfa04c9ef3d5197182f13fb5b9b1fb7717d2222a
17716ec0f1ff5c77eff40b7fe912f9f6cfd0e475

その出力を取得し、それぞれのコミットSHA-1をループ処理し、そのメッセージを取得して、パターンを探す正規表現に対してそのメッセージをテストすることができます。

テストするために、これらの各コミットからコミットメッセージを取得する方法を把握する必要があります。生コミットデータを取得するには、git cat-fileという別のプルーミングコマンドを使用できます。これらのプルーミングコマンドについては、Gitの内側で詳しく説明しますが、今のところ、このコマンドが何をもたらすかを示します

$ git cat-file commit ca82a6
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
parent 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
author Scott Chacon <schacon@gmail.com> 1205815931 -0700
committer Scott Chacon <schacon@gmail.com> 1240030591 -0700

Change the version number

SHA-1値があるコミットからコミットメッセージを取得する簡単な方法は、最初の空行に移動し、それ以降のすべてを取得することです。Unixシステムでは、sedコマンドを使用してこれを行うことができます

$ git cat-file commit ca82a6 | sed '1,/^$/d'
Change the version number

その呪文を使って、プッシュしようとしている各コミットからコミットメッセージを取得し、一致しないものを見つけた場合は終了できます。スクリプトを終了し、プッシュを拒否するには、ゼロ以外の値で終了します。全体の方法は次のようになります

$regex = /\[ref: (\d+)\]/

# enforced custom commit message format
def check_message_format
  missed_revs = `git rev-list #{$oldrev}..#{$newrev}`.split("\n")
  missed_revs.each do |rev|
    message = `git cat-file commit #{rev} | sed '1,/^$/d'`
    if !$regex.match(message)
      puts "[POLICY] Your message is not formatted correctly"
      exit 1
    end
  end
end
check_message_format

それをupdateスクリプトに記述すると、ルールに準拠しないメッセージを含むコミットを含む更新は拒否されます。

ユーザーベースのACLシステムの強制

プロジェクトのどの部分にどのユーザーが変更をプッシュすることを許可されているかを指定するアクセス制御リスト(ACL)を使用するメカニズムを追加したいとします。一部のユーザーはフルアクセス権を持ち、他のユーザーは特定のサブディレクトリまたは特定のファイルへの変更のみをプッシュできます。これを強制するために、これらのルールをサーバ上のベアリポジトリにあるaclというファイルに書き込みます。updateフックにこれらのルールを調べさせ、プッシュされるすべてのコミットでどのファイルが導入されているかを確認し、プッシュを行っているユーザーがそれらすべてのファイルを更新するアクセス権を持っているかどうかを判断させます。

最初に行うことは、ACLを作成することです。ここではCVSのACLメカニズムと非常によく似た形式を使用します。これは一連の行を使用し、最初のフィールドはavailまたはunavail、次のフィールドはルールが適用されるユーザーのコンマ区切りリスト、最後のフィールドはルールが適用されるパス(空白はオープンアクセスを意味します)です。これらのすべてのフィールドはパイプ(|)文字で区切られます。

この場合、数人の管理者、docディレクトリへのアクセス権を持つ数人のドキュメント作成者、およびlibtestsディレクトリへのアクセス権のみを持つ一人の開発者がいます。あなたのACLファイルは次のようになります

avail|nickh,pjhyett,defunkt,tpw
avail|usinclair,cdickens,ebronte|doc
avail|schacon|lib
avail|schacon|tests

まず、このデータを再利用可能な構造に読み込みます。この例をシンプルにするため、ここではavailディレクティブのみを強制します。以下に示すメソッドは、キーがユーザー名で、値がユーザーが書き込みアクセス権を持つパスの配列である連想配列を提供します

def get_acl_access_data(acl_file)
  # read in ACL data
  acl_file = File.read(acl_file).split("\n").reject { |line| line == '' }
  access = {}
  acl_file.each do |line|
    avail, users, path = line.split('|')
    next unless avail == 'avail'
    users.split(',').each do |user|
      access[user] ||= []
      access[user] << path
    end
  end
  access
end

前に見たACLファイルでは、このget_acl_access_dataメソッドは次のようなデータ構造を返します

{"defunkt"=>[nil],
 "tpw"=>[nil],
 "nickh"=>[nil],
 "pjhyett"=>[nil],
 "schacon"=>["lib", "tests"],
 "cdickens"=>["doc"],
 "usinclair"=>["doc"],
 "ebronte"=>["doc"]}

権限が整理できたので、プッシュされるコミットがどのパスを変更したかを判断し、プッシュしているユーザーがそれらすべてにアクセス権を持っていることを確認する必要があります。

git logコマンドの--name-onlyオプション(Gitの基本で簡単に触れられています)を使用すると、単一のコミットでどのファイルが変更されたかをかなり簡単に確認できます

$ git log -1 --name-only --pretty=format:'' 9f585d

README
lib/test.rb

get_acl_access_dataメソッドから返されるACL構造を使用し、それを各コミットにリストされたファイルと照合することで、ユーザーがすべてのコミットをプッシュするアクセス権を持っているかどうかを判断できます

# only allows certain users to modify certain subdirectories in a project
def check_directory_perms
  access = get_acl_access_data('acl')

  # see if anyone is trying to push something they can't
  new_commits = `git rev-list #{$oldrev}..#{$newrev}`.split("\n")
  new_commits.each do |rev|
    files_modified = `git log -1 --name-only --pretty=format:'' #{rev}`.split("\n")
    files_modified.each do |path|
      next if path.size == 0
      has_file_access = false
      access[$user].each do |access_path|
        if !access_path  # user has access to everything
           || (path.start_with? access_path) # access to this path
          has_file_access = true
        end
      end
      if !has_file_access
        puts "[POLICY] You do not have access to push to #{path}"
        exit 1
      end
    end
  end
end

check_directory_perms

git rev-listを使用して、サーバにプッシュされている新しいコミットのリストを取得します。その後、それらの各コミットについて、どのファイルが変更されているかを調べ、プッシュしているユーザーが変更されているすべてのパスへのアクセス権を持っていることを確認します。

これで、ユーザーは不正な形式のメッセージを含むコミットや、指定されたパス外の変更されたファイルを含むコミットをプッシュできなくなります。

試してみる

もし、このすべてのコードを配置したファイルであるchmod u+x .git/hooks/updateを実行し、その後に非準拠のメッセージでコミットをプッシュしようとすると、次のような結果が得られます

$ git push -f origin master
Counting objects: 5, done.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 323 bytes, done.
Total 3 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
Enforcing Policies...
(refs/heads/master) (8338c5) (c5b616)
[POLICY] Your message is not formatted correctly
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master
To git@gitserver:project.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'

ここにいくつか興味深い点があります。まず、フックが実行を開始する場所でこれを確認できます。

Enforcing Policies...
(refs/heads/master) (fb8c72) (c56860)

更新スクリプトの冒頭でそれを表示したことを思い出してください。スクリプトがstdoutに出力する内容はすべてクライアントに転送されます。

次に気づくのはエラーメッセージです。

[POLICY] Your message is not formatted correctly
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master

最初の行はあなたが出力したもので、残りの2行はGitが更新スクリプトがゼロ以外の値で終了し、それがプッシュを拒否していることを示しています。最後に、これがあります

To git@gitserver:project.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'

フックが拒否した参照ごとにリモート拒否メッセージが表示され、フックの失敗が原因で具体的に拒否されたことが示されます。

さらに、誰かがアクセス権のないファイルを編集し、それを含むコミットをプッシュしようとすると、同様のメッセージが表示されます。たとえば、ドキュメント作成者がlibディレクトリ内の何かを変更するコミットをプッシュしようとすると、次のように表示されます

[POLICY] You do not have access to push to lib/test.rb

これからは、そのupdateスクリプトが存在し、実行可能である限り、あなたのリポジトリにはあなたのパターンを含まないコミットメッセージは決して含まれず、ユーザーはサンドボックス化されます。

クライアントサイドフック

このアプローチの欠点は、ユーザーのコミットプッシュが拒否されたときに必然的に発生する不満です。苦労して作成した作業が土壇場で拒否されることは、非常に苛立たしく、混乱を招きます。さらに、それを修正するために履歴を編集しなければならず、これは常に簡単なことではありません。

このジレンマの解決策は、ユーザーがサーバによって拒否される可能性のある操作を行っているときに通知するために実行できるクライアントサイドフックを提供することです。そうすれば、コミットする前、そして問題が修正しにくくなる前に、問題を修正できます。フックはプロジェクトのクローンと一緒に転送されないため、これらのスクリプトを別の方法で配布し、ユーザーに.git/hooksディレクトリにコピーさせて実行可能にする必要があります。これらのフックはプロジェクト内または別のプロジェクトで配布できますが、Gitはそれらを自動的にセットアップしません。

まず、各コミットが記録される直前にコミットメッセージをチェックし、不正な形式のコミットメッセージが原因でサーバが変更を拒否しないようにする必要があります。これを行うには、commit-msgフックを追加できます。最初の引数として渡されたファイルからメッセージを読み込み、それをパターンと比較するように設定すると、一致しない場合にGitにコミットを中止させることができます

#!/usr/bin/env ruby
message_file = ARGV[0]
message = File.read(message_file)

$regex = /\[ref: (\d+)\]/

if !$regex.match(message)
  puts "[POLICY] Your message is not formatted correctly"
  exit 1
end

そのスクリプトが(.git/hooks/commit-msgに)配置され、実行可能であり、適切にフォーマットされていないメッセージでコミットすると、次のように表示されます

$ git commit -am 'Test'
[POLICY] Your message is not formatted correctly

そのインスタンスではコミットは完了しませんでした。しかし、メッセージが適切なパターンを含んでいる場合、Gitはコミットを許可します

$ git commit -am 'Test [ref: 132]'
[master e05c914] Test [ref: 132]
 1 file changed, 1 insertions(+), 0 deletions(-)

次に、ACLの範囲外のファイルを変更していないことを確認する必要があります。プロジェクトの.gitディレクトリに以前使用したACLファイルのコピーが含まれている場合、次のpre-commitスクリプトがこれらの制約を強制します

#!/usr/bin/env ruby

$user    = ENV['USER']

# [ insert acl_access_data method from above ]

# only allows certain users to modify certain subdirectories in a project
def check_directory_perms
  access = get_acl_access_data('.git/acl')

  files_modified = `git diff-index --cached --name-only HEAD`.split("\n")
  files_modified.each do |path|
    next if path.size == 0
    has_file_access = false
    access[$user].each do |access_path|
    if !access_path || (path.index(access_path) == 0)
      has_file_access = true
    end
    if !has_file_access
      puts "[POLICY] You do not have access to push to #{path}"
      exit 1
    end
  end
end

check_directory_perms

これはサーバサイドのスクリプトとほぼ同じですが、2つの重要な違いがあります。まず、ACLファイルが異なる場所にあります。なぜなら、このスクリプトはあなたのワーキングディレクトリから実行されるからです。.gitディレクトリからではありません。ACLファイルへのパスを次のように変更する必要があります

access = get_acl_access_data('acl')

からこれに

access = get_acl_access_data('.git/acl')

もう一つの重要な違いは、変更されたファイルのリストを取得する方法です。サーバサイドの方法はコミットログを参照しますが、この時点ではコミットはまだ記録されていないため、代わりにステージングエリアからファイルリストを取得する必要があります。次のようにする代わりに

files_modified = `git log -1 --name-only --pretty=format:'' #{ref}`

このように使用する必要があります

files_modified = `git diff-index --cached --name-only HEAD`

しかし、これらが唯一の2つの違いです — それ以外は、スクリプトは同じように動作します。1つの注意点としては、リモートマシンにプッシュするのと同じユーザーとしてローカルで実行していることを想定しています。もしそれが異なる場合は、$user変数を手動で設定する必要があります。

ここでできるもう一つのことは、ユーザーが非ファストフォワード参照をプッシュしないようにすることです。ファストフォワードではない参照を取得するには、すでにプッシュしたコミットをリベースするか、異なるローカルブランチを同じリモートブランチにプッシュしてみる必要があります。

おそらく、サーバはすでにreceive.denyDeletesreceive.denyNonFastForwardsでこのポリシーを強制するように設定されているため、偶発的に捕捉できる唯一のことは、すでにプッシュされたコミットのリベースです。

これがそれをチェックするプリリベーススクリプトの例です。これは、あなたが書き換えようとしているすべてのコミットのリストを取得し、それらがあなたのリモート参照のいずれかに存在するかどうかをチェックします。もしあなたのリモート参照のいずれかから到達可能なものを見つけた場合、リベースを中止します。

#!/usr/bin/env ruby

base_branch = ARGV[0]
if ARGV[1]
  topic_branch = ARGV[1]
else
  topic_branch = "HEAD"
end

target_shas = `git rev-list #{base_branch}..#{topic_branch}`.split("\n")
remote_refs = `git branch -r`.split("\n").map { |r| r.strip }

target_shas.each do |sha|
  remote_refs.each do |remote_ref|
    shas_pushed = `git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`
    if shas_pushed.split("\n").include?(sha)
      puts "[POLICY] Commit #{sha} has already been pushed to #{remote_ref}"
      exit 1
    end
  end
end

このスクリプトは、リビジョンの選択では扱われなかった構文を使用しています。次を実行することで、すでにプッシュされたコミットのリストを取得できます

`git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`

SHA^@構文は、そのコミットのすべての親に解決されます。あなたは、リモートの最後のコミットから到達可能であり、プッシュしようとしているSHA-1のどの親からも到達できないコミットを探しています — これはファストフォワードを意味します。

このアプローチの主な欠点は、非常に遅くなる可能性があり、多くの場合不要であることです — -fで強制プッシュしようとしない限り、サーバは警告を発し、プッシュを受け入れません。しかし、これは興味深い演習であり、理論的には後で修正しなければならないかもしれないリベースを回避するのに役立ちます。

scroll-to-top