Git
目次 ▾ 第2版

8.4 Gitのカスタマイズ - Gitによるポリシー適用例

Gitによるポリシー適用例

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

ここで紹介するスクリプトはRubyで記述されています。これは、主に慣性によるものですが、Rubyは読みやすく、たとえ記述できなくても理解しやすいからです。しかし、どの言語でも機能します。Gitに付属するサンプルフックスクリプトのほとんどはPerlまたはBashで記述されているため、これらの言語でのフックの例も多数確認できます。

サーバーサイドフック

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

  • プッシュ先の参照の名前

  • そのブランチが存在した古いリビジョン

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

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

#!/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」のような文字列を含める必要があると仮定します。これは、各コミットがチケットシステムの作業項目にリンクされるようにするためです。プッシュされる各コミットを確認し、その文字列がコミットメッセージに含まれているかどうかを確認し、文字列がコミットのいずれかに存在しない場合は、ゼロ以外の値で終了してプッシュを拒否します。

`git rev-list`と呼ばれるGit内部コマンドに`$newrev`と`$oldrev`の値を渡すことで、プッシュされるすべてのコミットのSHA-1値のリストを取得できます。これは基本的に`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)を使用するメカニズムを追加したいとします。一部のユーザーは完全なアクセス権を持ち、他のユーザーは特定のサブディレクトリまたは特定のファイルへの変更のみをプッシュできます。これを適用するために、サーバー上のベアGitリポジトリにある`acl`というファイルにこれらのルールを記述します。`update`フックはこれらのルールを確認し、プッシュされているすべてのコミットで導入されているファイルを確認し、プッシュしているユーザーがそれらのすべてのファイルを更新するアクセス権を持っているかどうかを判断します。

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

この例では、管理者が2名、`doc`ディレクトリにアクセスできるドキュメント作成者が数名、`lib`および`tests`ディレクトリにのみアクセスできる開発者が1名おり、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`コマンド(Gitの基本で簡単に説明)の`--name-only`オプションを使用すると、単一のコミットで変更されたファイルを確認できます。

$ 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)

これは、`update`スクリプトの最初の場所で出力したことを覚えておいてください。スクリプトが`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つは、`update`スクリプトがゼロ以外の値で終了したことを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つの重要な違いがあります。まず、このスクリプトは`.git`ディレクトリではなく作業ディレクトリから実行されるため、ACLファイルの場所が異なります。ACLファイルのパスを以下から

access = get_acl_access_data('acl')

以下に変更する必要があります。

access = get_acl_access_data('.git/acl')

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

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

以下を使用する必要があります。

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

しかし、これら2つの違いだけです。それ以外は、スクリプトは同じように機能します。ただし、リモートマシンにプッシュするユーザーと同じユーザーとしてローカルで実行していることが前提です。異なる場合は、`$user`変数を手動で設定する必要があります。

ここでできるもう1つのことは、ユーザーが高速転送ではない参照をプッシュしないようにすることです。高速転送ではない参照を取得するには、既にプッシュしたコミットを超えてリベースするか、異なるローカルブランチを同じリモートブランチにプッシュしようとします。

おそらく、サーバーは既に`receive.denyDeletes`と`receive.denyNonFastForwards`を使用してこのポリシーを適用しているので、偶然にキャッチできるのは、既にプッシュされているコミットをリベースすることだけです。

これが、それを確認する`pre-rebase`スクリプトの例です。書き直そうとしているすべてのコミットのリストを取得し、それらがリモート参照のいずれかに存在するかどうかを確認します。リモート参照の1つから到達可能で、プッシュしようとしているSHA-1の親のいずれからも到達できないものが見つかった場合(高速転送ではない場合)、リベースを中止します。

#!/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