Git
Chapters ▾ 2nd Edition

8.4 Настройка Git - Пример принудительной политики Git

Пример принудительной политики Git

В этом разделе вы сможете применить полученные знания для создания рабочего процесса Git, при котором будет проверяться формат сообщения коммита и определенным пользователям будет разрешено изменять содержимое заданных директорий проекта. Вы создадите клиентские скрипты, которые помогут разработчикам понять, когда их изменения будут отклонены, а также серверные скрипты, которые обеспечат выполнение заданных политик.

Скрипты, которые будут приведены ниже, написаны на Ruby; отчасти по причине нашей интеллектуальной инерции, но также и потому, что Ruby легко читать, даже если вы не пишите на нём. К слову, любой язык будет работать — все примеры хуков, распространяемые с Git, написаны на Perl или Bash; с ними вы можете ознакомиться, посмотрев примеры.

Серверный Хук

На стороне сервера вся работа производится в файле update из директории hooks. Хук update запускается однократно для каждой отправляемой ветки и принимает три параметра:

  • Ссылка на ветку, в которую производится отправка

  • Текущая ревизия ветки назначения

  • Отправляемая ревизия

Так же можно получить имя пользователя, производящего отправку, если действия выполняются по протоколу SSH. Если вы настроили аутентификацию по публичному ключу используя одного пользователя (например, “git”), то вам потребуется использовать дополнительную обёртку командной оболочки, чтобы определить реального пользователя по его публичному ключу и правильно установить переменную окружения $USER. Далее предполагается, что переменная $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”, так как вы хотите связать каждый коммит с соответствующим элементом в вашей системе управления задачами. Для этого вам понадобиться проверять каждый получаемый коммит, искать в сообщении заданную подстроку и, в случае её отсутствия в сообщении любого из коммитов, прекращать обработку с ненулевым кодом, что приведёт к отклонению отправки целиком.

Вы можете получить список SHA-1 значений всех отправляемых коммитов передав значения $newrev и $oldrev низкоуровневой команде Git под названием git rev-list. В действительности, это команда git log, которая по умолчанию выводит только список значений SHA-1 и ничего больше. Поэтому, для получения списка SHA-1 хешей коммитов, находящихся между двумя заданными, вам следует выполнить, например, следующую команду:

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

Для каждого SHA-1 хеша из полученного результата можно получить соответствующее сообщение коммита и с помощью регулярного выражения проверить наличие искомой подстроки.

Осталось выяснить как получить сообщение коммита, зная его SHA-1 хеш. Чтобы получить содержимое коммита, следует использовать другую низкоуровневую команду git cat-file. Более детально мы рассмотрим эти низкоуровневые команды в разделе [ch10-git-internals]; а сейчас покажем, что эта команда вам даёт:

$ 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

Самый простой способ извлечь сообщение коммита — это найти первую пустую строку и взять всё, что идёт после неё. В системах Unix и Mac это можно сделать с помощью программы sed:

$ git cat-file commit ca82a6 | sed '1,/^$/d'
changed 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, находящийся в репозитории на сервере. Затем обновить хук update, чтобы он использовал эти правила при просмотре списка файлов в отправляемых коммитах для определения наличия прав доступа ко всем этим файлам у отправляющего пользователя.

Первое, что надо сделать — это создать список контроля доступа. Здесь следует использовать формат, который очень похож на CVS и представляет собой список строк, в каждой из которых первое поле имеет значение avail или unavail, второе поле содержит список пользователей, разделённых запятой, а третье поле — это путь к файлу или директории, для которого применяется это правило (пустое значение подразумевает отсутствие ограничения). В качестве разделителя для этих полей применяется вертикальная черта (|).

В случае, когда у вас есть группа администраторов, несколько технических писателей с доступом к директории doc и один разработчик, у которого есть доступ только к директориям lib и tests, файл со списком контроля доступа будет выглядеть так:

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

Для представленного ранее файла списка контроля доступа этот метод вернёт следующую структуру данных:

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

Теперь, когда вопрос с правами доступа решён, необходимо извлечь список путей, изменения по которым присутствуют в отправляемых коммитах, чтобы убедиться в наличии доступа к ним у отправляющего пользователя.

Список файлов одного коммита можно легко получить, используя опцию --name-only команды git log (кратко рассматривалось в Главе 2):

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

README
lib/test.rb

Если воспользоваться структурой данных, полученной методом get_acl_access_data, и проверить соответствие путей из каждого коммита на соответствие ей, то можно определить наличие прав доступа у пользователя на отправку всех коммитов:

# 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

Первая строка — это ваше сообщение, две другие добавляет Git сообщая, что скрипт update завершился с ненулевым кодом, что привело к отклонению отправки. Ну и наконец, у вас есть вот это:

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 commit -am 'Test [ref: 132]'
[master e05c914] Test [ref: 132]
 1 file changed, 1 insertions(+), 0 deletions(-)

Далее, следует убедиться, что внесенные изменения соответствуют вашим правам доступа. Если в директории .git содержится файл списка контроля доступа, который использовался ранее, то следующий 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

Этот скрипт практически такой же как и серверный, за исключением двух важных отличий. Во первых, файл списка контроля доступа находится в другом месте, так как скрипт запускается из рабочей директории, а не из директории .git. Поэтому необходимо изменить путь к файлу с:

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`

Вот и все отличия — в остальном скрипт работает одинаково. Так же предполагается, что локально скрипт будет запускаться от имени того же пользователя, что и на удалённом сервере. Если имя вашего локального пользователя не совпадает с именем пользователя на сервере, то следует задать значение переменной $user вручную.

Ещё одна вещь, которую можно здесь сделать, это убедиться, что пользователь не отправляет ветки, которые не могут быть обновлены простым смещением вперёд. Чтобы создать такую ситуацию, вам нужно либо перебазировать уже отправленный коммит, либо попытаться отправить другую локальную ветку в ту же удалённую.

Предположим, что на сервере уже включены опции receive.denyDeletes и receive.denyNonFastForwards для обеспечения политики, поэтому воспроизвести ситуацию можно только перебазировав отправленные коммиты.

Ниже представлен скрипт pre-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

Скрипт использует синтаксис, который не был рассмотрен в разделе Выбор ревизии. Получить список коммитов, которые уже были отправлены, можно с помощью команды:

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

Синтаксис SHA^@ позволяет получить список всех родителей коммита. Вы ищите любой коммит, который доступен относительно последнего коммита на удалённом сервере, но недоступен относительно любого родителя отправляемых SHA-1, что определяет простое смещение вперёд.

Основной недостаток этого подхода в том, что он может быть очень медленным и не всегда необходим — если вы не форсируете отправку опцией -f, то сервер отклонит её с соответствующим предупреждением. Тем не менее, это интересная задача, которая теоретически может вам помочь избежать перебазирования, которое в будущем, возможно, придётся исправлять.