my.life.logging.Blog

Удаление старых веток в Git

“Ветвиться, ветвиться и еще раз ветвиться!” – как завещал великий Линус Торвальдс. Ветвления – вот уж в чем действительно вся суть программирования. Ведь даже цикл на примитивном уровне реализуется с помощью ветвлений, не говоря уже о бинарных деревьях и прочих ветвистых структурах данных.

Так что помни, мой юный падован: применяя Git, всегда следуй максиме “Рано фиксируй, часто ответвляй”, и будет тебе счастье!..

Но если ты будешь неукоснительно выполнять заветы Линусича и компании, то очень скоро столкнешься с ситуацией, когда количество строк, выдаваемых командой git branch (не говоря уже о git branch -a) для твоего репозитория, не будет умещаться даже на нескольких экранах терминала. Мда, чтобы найти необходимую ветку среди всего этого безобразия, придется серьезно потрудиться. Или есть другой вариант – избавиться от ненужных веток. Но, согласись, удалять каждую неактуальную ветку по-отдельности – не самое благодарное занятие для разработчика, находящегося в жестких сроках проекта.

Так нельзя ли, подумал я, этот процесс каким-нибудь образом автоматизировать?

Итак, немного о выборе инструмента для автоматизации. На самом деле, единственный скриптовый язык, который присутствует у меня сразу на всех машинах – это Groovy. Почему бы не попробовать заскриптовать решение проблемы именно на нем? Во-первых, Groovy для джависта – это просто (ну, прямо, как два байта отослать!). Во-вторых, это патриотично: JVM как-никак. В-третьих, умение писать на Groovy может пригодиться и в работе над реальными проектами. В-четвертых, ну, не на старперском Bash’е же писать. В-пятых, я, наверное, увлекся. В-шестых, думаю, нам пора переходить к основному содержанию статьи.

Замечу только, что для скриптования здесь была использована последняя на момент написания статьи версия Groovy, а именно – Groovy 2.1.2.

Замыкание для выполнения команды Git

Для того, чтобы начать, нам понадобится замыкание в Groovy для выполнения команды Git в заданном репозитории. Путь до репозитория, ясное дело, нужно будет указывать в качестве аргумента скрипта.

Так как любая команда Git – это по сути консольная команда, напишем сначала замыкание для выполнения команды Shell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Шорткат для выполнения команды консоли
// shellCommand - строка, содержащая команду
// workingDir - рабочая директория
// Возвращаемое значение: консольный вывод команды
def executeShellCommand = { shellCommand, workingDir ->
  def shellCommandProc = shellCommand.execute(null, workingDir)
  
  def out = new StringBuilder()
  def err = new StringBuilder()
  shellCommandProc.waitForProcessOutput(out, err)

  if (err) {
      // вывод команды в поток ошибок использовать вряд ли получится, 
      // так что просто выводим его в консоль для информации
      System.err.print err.toString()
  }

  out.toString()
}

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

Выполнение команды Git – это не что иное, как выполнение команды Shell, только в папке репозитория:

1
2
3
4
5
6
// Шорткат для выполнения команды Git
// gitCommand - строка, содержащая команду
// Возвращаемое значение: консольный вывод команды
def executeGitCommand = { gitCommand ->
  executeShellCommand(gitCommand, repositoryDir)
}

Удаление локальных веток

Пришло время снова вcпомнить о задаче, которую мы перед собой поставили. Напомню, что звучала она так: очистить репозиторий от ненужных веток…

Стоп, стоп, стоп! Минуточку!.. Это я к тому, что мой внутренний препод по матану вдруг начал возмущаться: “Что это еще за “ненужные” ветки такие? Что за сомнительная терминология?“ OK, профессор. Пусть ветка считается “ненужной”, если:

  1. она не принадлежит к числу основных веток репозитория, таких, например, как master, develop и т.д.;
  2. все изменения, сделанные в этой ветке, были в свое время пересены в master;
  3. связанная с веткой задача считается закрытой в багтрекере1;
  4. у ветки истек “срок давности”, то есть, другими словами, последний коммит в эту ветку был сделан до некоторой даты, определяемой пользователем, или по умолчанию, скажем, больше месяца назад.

Условие 4 – дополнительное и в некотором виде страховочное, позволяющее привязать логику удаления веток к длине итерации проекта. Так, например, если итерация на проекте длится 2 недели, то удаление всех веток месячной давности (две итерации назад) может быть вполне приемлимо.

Главное ноу-хау состоит в том, что получить список всех локальных веток, залитых в мастер, можно с помощью команды git branch --merged master. Я думаю, кусок кода будет стоить тысячи слов:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def mergedBranchesOutput = executeGitCommand('git branch --merged master')
mergedBranchesOutput.eachLine { branchLine ->
      // Определение имени локальной ветки с помощью регулярного выражения
      def matcher = branchLine =~ /^\s*\*?\s*([^\s]*)$/
      if (!matcher) {
          return
      }
      def branch = matcher[0][1]

      if (// Если вообще можно удалять ветку с таким именем
          !BRANCHES_TO_KEEP_ANYWAY.contains(branch) &&
              // в ветке не было активности после указанной даты
              getLastCommitDate(branch) < removeBeforeDate &&
              // и задача закрыта в багтрекере
              isTaskClosed(branch)) {

          // Удаление локальной ветки
          print executeGitCommand("git branch -d $branch")
      }
  }

Замыкания getLastCommitDate(branch) и isTaskClosed(branch) мы рассмотрим в следующих двух разделах.

Замыкание для получения даты последнего коммита в ветке

В деле определения даты последнего коммита в ветке нам поможет привычная команда git log. Нет ничего проще:

1
2
3
4
5
6
7
// Получение даты последнего коммита в указанной ветке
// branchName - имя ветки
// Возвращаемое значение: дата последнего коммита
def getLastCommitDate = { branchName ->
  def lastCommitDate = executeGitCommand("git log $branchName -1 --pretty=format:%ci")
  Date.parse('yyyy-M-d H:m:s Z', lastCommitDate)
}

Замыкание для определения, закрыта ли задача в багтрекере

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

1
def isTaskClosed = { branchName -> true }

Мы все же рассмотрим более развернутый вариант реализации этого замыкания для багтрекера Mantis. Будем предполагать, что имя ветки, которая связана с задачей, имеет вид “issue-${номер задачи}”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Определяет, была ли закрыта в багтрекере задача, связанная с веткой
// branchName - имя ветки
// Возвращаемое значение: true, если задача была закрыта, или с веткой не связано никакой задачи
def isTaskClosed = { branchName ->
  // считаем, что имя ветки имеет вид "issue-${номер задачи}"
  def matcher = branchName =~ /^issue-(\d*).*$/

  if (!matcher) {
      return true
  }

  def issueId = matcher[0][1]

  try {
      // обращение к Mantis SOAP API
      def client = new SOAPClient('http://mantisbt.org/bugs/api/soap/mantisconnect.php')
      def response = client.send(SOAPAction:'http://www.mantisbt.org/bugs/api/soap/mantisconnect.php/mc_issue_get') {
          body {
              mc_issue_get('xmlns':'http://soap.amazon.com/') {
                  username('login')
                  password('p@$$w0rd')
                  issue_id(issueId)
              }
          }
      }
      return response.mc_issue_getResponse.return.status.name == 'closed'
  } catch(e) {
      return true
  }
}

Если произойдет страшное, и номер задачи, которому соответствует ветка, установить не удастся, или, о ужас, случится какая-то ошибка при обращении к Mantis SOAP API, то замыкание все равно вернет true (задача закрыта) в качестве результата. Будем в этом случае надеяться, что от удаления большинства интересных веток нас спасет четвертое страховочное условие “срока давности”.

Удаление веток удаленного репозитория

Удаление ненужных веток из origin во многом аналогично удалению локальных веток, ну, кроме того, что в этом процессе используются немного другие команды (параметры команд) Git. Так, для получения списка веток удаленного репозитория, изменения из которых были перенесены в master, нужно выполнить команду git branch -r --merged master. Удалять же remote-ветку следует так (обрати внимание на использование двоеточия перед именем ветки): git push origin :имя_ветки.

И еще, так как удаление веток из origin – гораздо более серьезная авантюра, чем удаление веток локального репозитория, жестом доброй воли будет2 запросить у пользователя подтвеждение, показав ему список веток, подготовленных к удалению.

После удаления скриптом веток из remote-репозитория, другие члены команды смогут автоматически удалить все эти ветки у себя, выполнив команду git remote prune origin для своих локальных репозиториев.

Итоговый скрипт

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

RmOldGitBranches.groovy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import groovy.time.TimeCategory
// Получение groovy-wslite для работы с Mantis API
@Grab(group='com.github.groovy-wslite', module='groovy-wslite', version='0.7.2')
import wslite.soap.*

// Список веток, которые не нужно удалять в любом случае
final BRANCHES_TO_KEEP_ANYWAY = ['master', 'develop', 'HEAD']

if (args.size() < 1) {
  println "Usage: RmOldGitBranches repository-dir [remove-before-date]"
  return
}

// Параметр: папка репозитория Git
def repositoryDir = new File(args[0])

// Параметр: дата "срока давности" в формате dd-mm-yyyy - ветки будут удалены, 
// если в них не было никакой активности после этой даты 
def removeBeforeDate
if (args.size() > 1) {
  try {
      removeBeforeDate = Date.parse('dd-MM-yyyy', args[1])
  } catch (e) {
  }
}
// Если дата "срока давности" не была передана, 
// считаем ее равной дате, которая была месяц тому назад
if (!removeBeforeDate) {
  use(TimeCategory) {
      def now = new Date()
      removeBeforeDate = now - 1.month
  }
}

/** ------------------------------------------------- **/
/** Замыкания, использованные в скрипте.              **/
/** Код замыканий был опущен в скрипте для краткости, **/
/** смотрите его в соответствующих разделах           **/

// Шорткат для выполнения команды консоли
def executeShellCommand = { shellCommand, workingDir ->
  // ...
}

// Шорткат для выполнения команды Git
def executeGitCommand = { gitCommand ->
  executeShellCommand(gitCommand, repositoryDir)
}

// Получение даты последнего коммита в указанной ветке
def getLastCommitDate = { branchName ->
  // ...
}

// Была ли закрыта в багтрекере задача, связанная с веткой
def isTaskClosed = { branchName ->
  // ... 
}

/** ------------------------------------------------- **/

// На всякий случай, проверяем, существует ли вообще репозиторий Git по переданному пути
if (!repositoryDir.exists() || executeGitCommand('git rev-parse --git-dir').trim() != '.git') {
  System.err.println "Folder ${repositoryDir} is not valid Git repository"
  return
}

// Скрипт нужно выполнять из ветки 'master'
print executeGitCommand('git checkout master')

// Обновляем список удаленных веток
print executeGitCommand('git fetch')
print executeGitCommand('git remote prune origin')

// Удаление локальных веток
def mergedBranchesOutput = executeGitCommand('git branch --merged master')
mergedBranchesOutput.eachLine { branchLine ->
      // Определение имени локальной ветки
      def matcher = branchLine =~ /^\s*\*?\s*([^\s]*)$/
      if (!matcher) {
          return
      }
      def branch = matcher[0][1]

      if (// Если вообще можно удалять ветку с таким именем
          !BRANCHES_TO_KEEP_ANYWAY.contains(branch) &&
              // в ветке не было активности после указанной даты
              getLastCommitDate(branch) < removeBeforeDate &&
              // и задача закрыта в багтрекере
              isTaskClosed(branch)) {

          print executeGitCommand("git branch -d $branch")
      }
  }

// Удаление веток удаленного репозитория с подтверждением
println 'The following remote branches will be removed:'

def remoteMergedBranchesOutput = executeGitCommand('git branch -r --merged master')
def remoteBranchesToRemove = []
remoteMergedBranchesOutput.eachLine { remoteBranchLine ->
      // Определение имени ветки удаленного репозитория

      // XXX Регулярное выражение /^\s*origin\/([^\s]*)$/ представлено здесь в виде строки,
      // так как оно оказалось не под силу парсеру Groovy кода, используемому Octopress 
      def matcher = remoteBranchLine =~ '^\\s*origin/([^\\s]*)$'
      if (!matcher) {
          return
      }
      def branch = matcher[0][1]
      def remoteBranch = "origin/$branch"

      if(!BRANCHES_TO_KEEP_ANYWAY.contains(branch) &&
              getLastCommitDate(remoteBranch) < removeBeforeDate &&
              isTaskClosed(remoteBranch)) {

          remoteBranchesToRemove.add(branch)
          // вывод имени ветки удаленного репозитория в качестве информации для пользователя
          println branch
      }
  }

if (!remoteBranchesToRemove) {
  println 'No remote branches to remove'
  return
}

if (System.console().readLine('Are you sure (y/n)?').charAt(0) == 'y') {
  remoteBranchesToRemove.each({ branch ->
          print executeGitCommand("git push origin :$branch")
      })
}

Запуск скрипта

Для вызова скрипта нужно набрать в консольке3 следующее:

1
groovy RmOldGitBranches.groovy /path/to/local/git/repo [ removeBeforeDate ]

Здесь:

  • /path/to/local/git/repo – само собой, путь до локального репозитория Git, который нужно очистить от ненужных веток,
  • removeBeforeDate – дата “срока давности” в формате dd-MM-yyyy (необязательный параметр).

Что касается операционных систем семейства Windows, то там данный скрипт вполне может быть выполнен в cmd или PowerShell, если во время установки msysGit вы добавляли поддержку Git для командной строки Windows:

Git Setup: Run from Windows Command Prompt

Если же нет, то несложно будет выполнить скрипт просто в Git Bash.

  1. Надеюсь, ты в курсе, что один из заветов великого вождя всех времен и луноходов гласит: “Cоздавай отдельную ветку для каждой новой задачи”.

  2. Изменение списка веток удаленного репозитория так или иначе будет касаться всех локальных репозиториев, связанных с данным удаленным. Да и вообще, может быть, пользователю хочется почистить от хлама только список локальных веток.

  3. Предполагается, что к этому моменту на компьютере была произведена полностью корректная установка Groovy.

Comments