Git
Chapters ▾ 2nd Edition

8.4 Налаштування Git - Приклад політики користування виконуваної Git-ом

Приклад політики користування виконуваної Git-ом

У цьому розділі ви використаєте те, що вже вивчили для створення робочого процесу Git, який перевірятиме певний формат повідомлення коміту, а також дозволятиме лише обраним користувачам змінювати певні піддиректорії в проекті. Ви побудуєте клієнтські скрипти, які допомагатимуть розробникові перевіряти чи їхні надіслані зміни (push) будуть відхилені, і серверні скрипти, які будуть примушувати дотримуватись політики користування.

Скрипти, які ми продемонструємо, написані на Ruby; частково через нашу інтелектувальну інертність, а частково тому, що Ruby читабельна, навіть якщо ви не можете на ній нічого написати. Втім, будь-яка мова підійде – всі приклади скриптів-гаків поширювані з Git, написані або на Perl або на Bash, тому ви можете знайти багато зразків гаків, імплементованих цими мовами.

Гак серверної частини

Вся робота для серверної частини потрапить до файлу update у вашій директорії hooks. Гак update виконується одноразово для кожної гілки, коли зміни надсилаються, і приймає три аргументи:

  • Ім’я посилання, до якого надсилаються зміни

  • Стара ревізія, де ця гілка була

  • Нова ревізія, яка надсилається

У вас також є доступ до користувача, який надсилає зміни, якщо пуш відбуваєтсья через 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, яка називається git rev-list. Це фактично команда git log, але типово вона видає лише 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

changed the version number

Найпростіше отримати повідомлення коміту, якщо у вас є його SHA-1 значення, це перейти до першої порожньої стрічки і згребти все після неї. Ви можете це зробити командою 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 для керування списком користувачів

Припустимо, що ви хочете додати механізм, який використовує контрольний список доступу (КСД) (ACL - access control list), що визначає яким користувачам дозволяється надсилати зміни, і в які частини вашого проекту. Деякі користувачі мають повний доступ, а інші можуть надсилати зміни лише в певні піддерикторії чи до певних файлів. Для того щоб зробити це примусовим, ви запишете ці правила у файл, який називається acl і який живе у вашому чистому Git репозиторії на сервері. Гак update буде звірятись з цими правилами, визначати, які файли несуть зміни в усіх комітах, які надсилаються, і чи користувач, що надсилає зміни, має доступ для того, щоб оновити всі ті файли.

Перше що ви зробите, ви створите ваш ACL. Тут, ви використаєте формат дуже схожий на CVS ACL механізм: він використовує серії рядків, де перше поле це avail чи unavail, наступне поле це список користувачів, розділених комою, до яких це правило може бути застосоване, і останнє поле це шлях, до якого правило застосовується (порожнє означає відкритий доступ). Усі ці поля розділені знаком вертикальної лінії (|).

В цьому випадку, у вас є декілька адміністраторів, декілька письменників документації з доступом до директорії doc, і один розробник, який має доступ лише до директорій lib і tests, і ваш 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

Метод get_acl_access_data з файлу ACL, який ви розлянули раніше, повертає структуру даних, яка виглядає так:

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

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

Ви можете дуже просто побачити, які файли були змінені в одному коміті за допомогою опції --name-only для команди git log (згадана коротко в Основи Git):

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

README
lib/test.rb

Якщо ви використовуєте структуру ACL, яку повертає метод 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)

Пам’ятаєте, ви надрукували це на самому початку вашого скрипта оновлення. Все, що скрипт відлунює в 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, який вам повідомляє, що скрипт оновлення вийшов з не-нуль і що він відхиляє надіслані вами зміни. В результаті ви маєте це:

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

Це приблизно той самий скрипт, що і для серверної частини, але з двома важливими відмінностями. Перше, цей 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`

Проте, це тільки дві відмінності – інакше цей скрипт працює так само. Тут одне застереження: цей скрипт має бути виконаний локально тим самим користувачем, що надсилає на віддалену машину. Якщо він відрізняється, то ви мусите змінити змінну $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, то сервер вас попередить і не прийме пуш. Проте, це цікава вправа і теоретично може допомогти вам уникнути перебазування, яке необхідно буде виправити повернувшись пізніше.