チャプター ▾ 第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」のような文字列を含める必要があると仮定します。これは、各コミットをチケットシステムの作業項目にリンクさせたいからです。プッシュされる各コミットを調べ、その文字列がコミットメッセージに含まれているかを確認し、いずれかのコミットに文字列が含まれていない場合、プッシュを拒否するために0以外の値を返します。

プッシュされるすべてのコミットの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) を使用するメカニズムを追加したいとします。一部のユーザーはフルアクセスを持ち、他のユーザーは特定のサブディレクトリや特定のファイルにのみ変更をプッシュできます。これを強制するために、これらのルールをサーバー上のベアGitリポジトリに存在するaclという名前のファイルに書き込みます。updateフックはこれらのルールを調べ、プッシュされているすべてのコミットで導入されているファイルを確認し、プッシュを実行しているユーザーがそれらのすべてのファイルを更新するアクセス権を持っているかどうかを判断します。

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

この場合、管理者数名、`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コマンドに--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でサーバーにプッシュされる新しいコミットのリストを取得します。次に、それらのコミットごとに、どのファイルが変更されたかを調べ、プッシュしているユーザーが変更されているすべてのパスにアクセスできることを確認します。

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

テストしてみる

このすべてのコードを配置したファイルである.git/hooks/updatechmod 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')

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

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

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

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

しかし、それらが唯一の違いです。それ以外は、スクリプトは同じように機能します。注意点としては、リモートマシンにプッシュするのと同じユーザーとしてローカルで実行していることを想定していることです。もし異なる場合は、$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