정책 구현하기

지금까지 배운 것을 한 번 적용해보자. 커밋 메시지 규칙 검사하고, Fast-forward Push만 허용하고, 디렉토리마다 사용자의 수정 권한을 제어하는 Git Workflow를 만들어 볼 것이다. 실질적으로 정책을 강제하려면 서버 훅으로 만들어야 하지만 개발자들이 Push할 수 없는 커밋은 아예 만들지 않도록 클라이언트 훅도 만들어 본다.

필자는 제일 좋아하는 Ruby로 만들 것이다. 필자는 독자가 슈도코드를 읽듯이 Ruby 코드를 읽을 수 있다고 생각한다. Ruby를 모르더라도 개념을 이해하기엔 충분할 것이다. 하지만, Git은 언어를 가리지 않는다. Git이 자동으로 생성해주는 예제는 모두 Perl과 Bash로 작성돼 있다. 그래서 예제를 열어 보면 Perl과 Bash로 작성된 예제를 참고 할 수 있다.

서버 훅

서버 정책은 전부 update 훅으로 만든다. 이 스크립트는 브랜치가 Push될 때마다 한 번 실행되고 해당 브랜치의 이름, 원래 브랜치가 가리키던 레퍼런스, 새 레퍼런스를 인자로 받는다. 그리고 SSH를 통해서 Push하는 것이라면 누가 Push하는 지 알 수 있다. SSH로 접근하긴 하지만 개발자 모두 계정 하나로("git" 같은) Push하고 있다면 실제로 Push하는 사람이 누구인지 판별해주는 쉘 Wrapper가 필요하다. 이 스크립트에서는 $USER 환경 변수에 현재 접속한 사용자 정보가 있다고 가정한다. update 스크립트는 필요한 정보를 수집하는 것으로 시작한다:

#!/usr/bin/env ruby

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

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

쉽게 설명하기 위해 전역 변수를 사용했다. 비판하지 말기 바란다.

커밋 메시지 규칙 만들기

커밋 메시지 규칙부터 해보자. 일단 목표가 있어야 하니까 커밋 메시지에 "ref: 1234" 같은 스트링이 포함돼 있어야 한다고 가정하자. 보통 커밋은 이슈 트래커에 있는 이슈와 관련돼 있으니 그 이슈가 뭔지 커밋 메시지에 적으면 좋다. Push할 때마다 모든 커밋 메시지에 해당 문자열이 포함돼 있는지 확인한다. 만약 스트링이 없는 커밋이 있으면 0이 아닌 값을 반환해서 Push를 거절한다.

$newrev, $oldrev 변수와 git rev-list라는 Plumbing 명령어를 이용해서 Push하는 커밋의 모든 SHA-1 값을 알 수 있다. 이것은 git log와 근본적으로 같은 명령이고 옵션을 하나도 주지 않으면 다른 정보 없이 SHA-1 값만 보여준다. 이 명령으로 Push하는 커밋이 무엇인지 알아낼 수 있다:

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

이 SHA-1 값으로 각 커밋의 메시지도 가져온다. 커밋 메시지를 가져와서 정규표현식으로 해당 패턴이 있는지 검사한다.

커밋 메시지를 얻는 방법을 알아보자. 커밋의 raw 데이터는 git cat-file이라는 Plumbing 명령어로 얻을 수 있다. 9장에서 Plumbing 명령어에 대해 자세히 다루니까 지금은 커밋 메시지 얻는 것에 집중하자:

$ 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

changed the version number

이 명령이 출력하는 메시지에서 커밋 메시지만 잘라내야 한다. 첫 번째 빈 줄 다음부터가 커밋 메시지니까 유닉스 명령어 sed로 첫 빈 줄 이후를 잘라낸다.

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

이제 커밋 메시지에서 찾는 패턴과 일치하는 문자열이 있는지 검사해서 있으면 통과시키고 없으면 거절한다. 스크립트가 종료할 때 0이 아닌 값을 반환하면 Push가 거절된다. 이 일을 하는 코드는 다음과 같다:

$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 스크립트에 넣으면 규칙을 어긴 커밋은 Push할 수 없다.

ACL로 사용자마다 다른 규칙 적용하기

진행하는 프로젝트에 모듈이 여러 개 있는데, 모듈마다 속한 사용자들만 Push할 수 있게 설정해야 한다고 가정하자. 모든 권한을 다 가진 사람들도 있고 특정 디렉토리나 파일만 Push할 수 있는 사람들도 있을 것이다. 이런 일을 하려면 먼저 서버의 Bare 저장소에 acl 이라는 파일을 만들고 거기에 규칙을 기술한다. 그리고 update 훅에서 Push하는 파일이 무엇인지 확인하고 ACL과 비교해서 Push할 수 있는지 없는지 결정한다.

우선 ACL부터 작성한다. CVS에서 사용하는 것과 비슷한 ACL을 만들어 사용할 것이다. 규칙은 한 줄에 하나씩 기술한다. 각 줄의 첫 번째 필드는 avail이나 unavail이고 두 번째 필드는 규칙을 적용할 사용자들의 목록을 CSV(Comma-Separated Values) 형식으로 적는다. 마지막 필드엔 규칙을 적용할 경로를 적는다. 만약 마지막 필드가 비워져 있으면 모든 경로를 의미한다. 이 필드들은 파이프(|) 문자로 구분한다.

관리자도 여러 명이고, doc 디렉토리에서 문서를 만드는 사람도 여러 명이고, libtests 디렉토리에 접근하는 사람은 한 명이다. 이런 상황을 ACL로 만들면 다음과 같다:

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

이 ACL 정보는 스크립트에서 읽어 사용한다. 설명을 쉽게 하고자 여기서는 avail만 처리한다. 다음 메소드는 Associative Array를 반환하는데, 키는 사용자이름이고 값은 사용자가 Push할 수 있는 경로의 목록이다:

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 파일을 처리하고 나서 반환하는 결과는 다음과 같다:

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

바로 사용할 수 있는 권한 정보를 만들었다. 이제 Push하는 커밋에 있는 파일을 그 사용자가 Push할 수 있는지 없는지 알아내야 한다.

git log 명령에 --name-only 옵션을 주면 해당 커밋에서 수정된 파일이 뭔지 알려준다. git log 명령은 2장에서 다뤘었다:

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

README
lib/test.rb

get_acl_access_data 메소드를 호출해서 ACL 정보를 구하고, 각 커밋에 들어 있는 파일 목록도 얻은 다음에, 사용자가 모든 커밋을 Push할 수 있는지 판단한다:

# 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.index(access_path) == 0) # 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 명령으로 서버에 Push하려는 커밋이 무엇인지 알아낸다. 그리고 각 커밋에서 수정한 파일이 어떤 것들이 있는지 찾고, 해당 사용자가 모든 파일에 권한을 가졌는지 확인한다. Rubyism 철학에 따르면 path.index(access_path) == 0이란 표현은 불명확하다. 이 표현은 해당 파일의 경로가 access_path로 시작할 때 참이라는 뜻이다. 그러니까 access_path가 단순히 허용된 파일 하나를 의미하는 것이 아니라 access_path로 시작하는 모든 파일을 의미하는 것이다.

이제 사용자는 메시지 규칙을 어겼거나 권한이 없는 파일을 수정한 커밋은 어떤 것도 Push하지 못한다.

Fast-Forward Push만 허용하기

이제 Fast-forward Push가 아니면 거절해보자. Git 1.6부터 receive.denyDeletesreceive.denyNonFastForwards 설정으로 간단하게 사용할 수도 있다. 하지만, 그 이전 버전에는 꼭 훅으로 구현해야 했다. 게다가 특정 사용자만 제한하거나 허용하는 것을 하려면 훅으로 구현해야 한다.

새로 Push하는 커밋에서는 찾을 수 없고(aren't reachable) 예전 커밋에서만 찾을 수 있는 커밋이 있는지 확인하면 Fast-forward Push인지를 검사할 수 있다. 하나라도 있으면 거절하고 없으면 Fast-forward Push이므로 그대로 둔다:

# enforces fast-forward only pushes 
def check_fast_forward
  missed_refs = `git rev-list #{$newrev}..#{$oldrev}`
  missed_ref_count = missed_refs.split("\n").size
  if missed_ref_count > 0
    puts "[POLICY] Cannot push a non fast-forward reference"
    exit 1
  end
end

check_fast_forward

이 정책을 다 구현해서 update 스크립트에 넣고 chmod u+x .git/hooks/update 명령으로 실행 권한을 준다. 그리고 나서 -f 옵션을 주고 강제로 Push하면 다음과 같이 실패할 것이다:

$ 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] Cannot push a non-fast-forward reference
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] Cannot push a non fast-forward reference
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master

첫 번째 줄은 스크립트에서 직접 출력한 것이고 나머지 두 2줄은 Git이 출력해 주는 것이다. 이 메시지는 update 스크립트에서 0이 아닌 값이 반환했기 때문에 Push할 수 없다고 말하는 것이다. 그리고 마지막 메시지를 보자:

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

이 메시지는 훅에서 거절된 것이라고 말해주는 것이고 브랜치가 거부될 때마다 하나씩 출력된다.

게다가 Push하는 커밋에 커밋 메시지 규칙을 지키지 않은 것이 하나라도 있으면 다음과 같은 에러 메시지를 보여준다:

[POLICY] Your message is not formatted correctly

그리고 누군가 권한이 없는 파일을 수정해서 Push하면 에러 메시지를 출력한다. 예를 들어 문서 담당자가 lib 디렉토리에 있는 파일을 수정해서 커밋하면 다음과 같은 메시지가 출력된다:

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

이제 서버 훅은 다 했다. 앞으로 update 스크립트가 항상 실행될 것이기 때문에 저장소를 되감을 수 없고, 커밋 메시지도 규칙대로 작성해야 하고, 권한이 있는 파일만 Push할 수 있다.

클라이언트 훅

서버 훅의 단점은 Push할 때까지 Push할 수 있는지 없는지 알 수 없다는 것이다. 기껏 공들여 정성껏 구현했는데 막상 Push할 수 없으면 곤혹스러울 것이다. 게다가 히스토리를 제대로 고치는 일은 정신건강에 해롭다.

이 문제는 클라이언트 훅으로 해결할 수 있다. 사용자는 클라이언트 훅으로 서버가 거부할지 말지 검사할 수 있다. 즉 사람들은 커밋하기 전에, 그러니까 시간이 지나 고치기 어려워지기 전에 문제를 해결할 수 있다. Clone할 때 이 훅은 전송되지 않기 때문에 다른 방법으로 동료에게 배포해야 한다. 그 훅을 가져다 .git/hooks 디렉토리에 복사하고 실행할 수 있게 만든다. 이 훅 파일을 프로젝트에 넣어서 배포해도 되고 전용 Git 프로젝트를 만들어서 배포해도 된다. 하지만, 자동으로 설치되도록 할 방법은 없다.

커밋 메시지부터 검사해보자. 이 훅이 있으면 나중에 커밋 메시지가 구리다고 서버가 거절하지 않을 것이다. 이것은 commit-msg 훅으로 구현한다. 이 훅은 첫 번째 인자로 커밋 메시지가 저장된 파일을 입력받는다. 그 파일을 읽어 패턴을 검사한다. 필요한 패턴이 없으면 커밋을 중단시킨다:

#!/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 commit -am 'test [ref: 132]'
[master e05c914] test [ref: 132]
 1 files changed, 1 insertions(+), 0 deletions(-)

그리고 아예 권한이 없는 파일을 수정할 수 없게 하려면 pre-commit 훅을 이용한다. 사전에 .git 디렉토리 안에 ACL 파일을 가져다 놓고 다음과 같이 작성한다:

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

내용은 서버 훅과 똑같지만 두 가지가 다르다. 첫째, 클라이언트 훅은 Git 디렉토리가 아니라 Working Directory에서 실행하기 때문에 ACL 파일 위치가 다르다. 그래서 ACL 파일 경로를 수정해야 한다:

access = get_acl_access_data('acl')

이 부분을 다음과 같이 바꾼다:

access = get_acl_access_data('.git/acl')

두 번째 차이점은 파일 목록을 얻는 방법이다. 서버 훅에서는 커밋에 있는 파일을 모두 찾았지만 여기서는 아직 커밋하지도 않았다. 그래서 Stage 영역의 파일 목록을 이용한다:

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

이 부분을 다음과 같이 바꾼다:

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

이 두 가지 점만 다르고 나머지는 똑같다. 보통은 리모트 저장소의 계정과 로컬의 계정도 같다. 하지만, 다른 계정을 사용한다면 $user 환경변수에 누군지 알려야 한다.

Fast-forward Push인지 확인하는 일이 남았다. 보통은 Fast-forward가 아닌 Push는 그 자체가 드문 일이다. Fast-forward가 아닌 Push를 하려면 Rebase로 이미 Push한 커밋을 바꿔 버렸거나 전혀 다른 로컬 브랜치를 Push해야 한다.

어쨌든 이 서버는 Fast-forward Push만 허용하기 때문에 이미 Push한 커밋을 수정했다면 그건 아마 실수일 것이다. 이 실수를 막는 훅을 살펴보자.

다음은 이미 Push한 커밋을 Rebase하지 못하게 하는 pre-rebase 스크립트다. 이 스크립트는 대상 커밋 목록을 얻어서 리모트 레퍼런스/브랜치에 들어 있는지 확인한다. 커밋이 한 개라도 리모트 레퍼런스/브랜치에 들어 있으면 Rebase할 수 없다:

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

이 스크립트는 6장 '리비전 조회하기' 절에서 설명하지 않은 표현을 사용한다. 이미 Push한 커밋 목록을 얻어오는 부분은 다음과 같다:

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

SHA^@은 해당 커밋의 모든 부모를 가리킨다. 그러니까 이 명령은 지금 Push하려는 커밋에서 리모트 저장소의 커밋에 도달할 수 있는지 확인하는 것이다. 즉, Fast-forward인지 확인하는 것이다.

이 방법의 문제는 매우 느리고 보통은 필요 없다는 것이다. 어차피 Fast-forward가 아닌 Push은 -f 옵션을 주어야 Push할 수 있다. 하지만, 이 예제는 이론적으로 문제가 될만한 Rebase는 방지할 수 있다는 것을 보여준다.