Иногда бывает полезно отображать некоторую информацию из Git-репозитория прямо в приложении. В статье мы воспользуемся преимуществом встроенной в NodeJS функции execSync и будем показывать в приложении три версии мастер-ветки: версию мастера в текущей ветке, в локальном мастере и удалённую в репозитории.

Предупреждение! Не используйте execSync для пользовательского ввода. Я надеюсь, вы вряд ли будете использовать dev-бандл приложения в качестве production ready, а значит и сторонние данные не попадут в эту функцию. Если каким-то образом вы всё же используете эту сборку в эксплуатации и неведомым образом пользовательский ввод сможет попасть в execSync, пожалуйста, валидируйте введённые данные.

Приступим. Прежде всего, нам нужна функция, которая будет использовать execSync и пытаться исполнять git-команду, которую мы получаем в качестве аргумента-строки.

run-git-command.js:

const execSync = require('child_process').execSync
 
module.exports = function(command) {
 return String(execSync('git ' + command));
}

Мы используем приведение к строке, потому что execSync возвращает буфер. Эта функция может принимать любую команду в виде строки, которую Git попытается исполнить.

Далее нам нужен объект со всеми нашими переменными окружения для вебпаковского DefinePlugin. В первом приближении я получил нечто подобное:

const branchName = runGitCommand('branch --show-current');
const workingVersion = runGitCommand(
  `log --format=%B -n 1 $(git merge-base master ${branchName})`
);
 
const runtimeVariables = {
  'process.env.WORKING_VERSION': workingVersion,
}
 
const webpackConfig = {
  //...,
  plugins: [
    new DefinePlugin(runtimeVariables),
  ],
  //...,
}

Разберем каждую команду по отдельности.

  • git branch --show-current вернёт название текущей ветки;

  • git merge-base master ${branchName} вернёт хэш коммита, который является последним общим коммитом между текущей веткой и мастером;

  • git log --format=%B commithash вернёт тело и тему коммита по заданному хэшу (также можно получить только тему коммита — %s).

Всё это прекрасно работает, но только ровно один раз за сборку приложения. Затем я наткнулся на runtimeValue («мгновенное значение»/значение среды выполнения):

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

Это вполне подходящее решение описанной выше проблемы: мы получим значение не один раз в начале сборки приложения, а при каждом изменении файла в проекте. Однако есть небольшой недостаток: если разработчик зайдёт в приложение через некоторое время, не внося локально изменений в код проекта, он столкнётся с устаревшей информацией. Я не смог найти лучшего решения этой проблемы, чем запуск пересборки. К тому же по сравнению с получаемыми преимуществами это незначительное неудобство. Если у кого-то в комментариях есть идея, как сделать лучше, буду рад услышать!

Теперь взгляните на следующее приближение:

const runtimeVariables = {
  'process.env.WORKING_VERSION': DefinePlugin.runtimeValue(() => {
    const branchName = runGitCommand('branch --show-current');
    const masterVersion = runGitCommand(
      `log --format=%B -n 1 $(git merge-base master ${branchName})`
    );
 
    return JSON.stringify(masterVersion)
  }, true),
}
 
const webpackConfig = {
  //...,
  plugins: [
    new DefinePlugin(runtimeVariables),
  ],
  //...,
}

Мы получим строку с сообщением Merge branch 'X' into 'master' и теперь нужно всего лишь извлечь название ветки X, чтобы показать его в приложении. Настал черёд несложной регулярки:

module.exports = function(string) {
   const version = string.match(/(rc\/[0-9.]+)/g);
   return version ? version[0] : null;
}

Так как внутри проекта для предварительных версий используется паттерн rc/x.xxx.x, это регулярное выражение вполне справится со своей задачей. В любом другом случае вам понадобится написать свою регулярку, чтобы вытащить название предварительной версии или ветки.

Соберём всё вместе:

const runTimeVariables = {
   'process.env.WORKING_VERSION': DefinePlugin.runtimeValue(() => {
       const branchName = runGitCommand('branch --show-current');
       const command = `log --format=%B -n 1 $(git merge-base master ${branchName})`;
       const version = extractVersion(runGitCommand(command));
 
       return JSON.stringify(version);
   }, true),
}

Если вы хотите показать версию вашей мастер-ветки в локальном репозитории, то можно просто выполнить команду git log -n 1 master --format=%B:

const runTimeVariables = {
   //...,
   'process.env.LOCAL_VERSION': DefinePlugin.runtimeValue(() => {
       const localVersion = extractVersion(runGitCommand('log -n 1 master --format=%B'));
 
       return JSON.stringify(localVersion);
   }, true),
}

И если мы хотим показать версию удалённой мастер-ветки, чтобы знать, когда нужно обновить локальный репозиторий, то нужно с помощью git ls-remote получить список всех указателей — заголовков, тэгов и merge request’ов. Нам интересны только заголовки, неплохо было бы также указать удалённый репозиторий — origin. Чтобы получить список всех своих удалённых репозиториев, можно выполнить команду git remote:

git ls-remote --heads origin

Таким образом мы получим список всех заголовков, но нам нужны только те, что начинаются на rc:

git ls-remote --heads origin rc*

Мы получили заголовки для предварительных версий. Так как стандартная сортировка работает на основе сравнения строк (в нашем случае указателей), а значит 0.100.10 идёт сразу после 0.100.1, то неплохо было бы отсортировать полученные указатели иначе. Например, по дате:

git ls-remote --sort=committerdate --heads origin rc*

Теперь всё в порядке, и нам нужно лишь забрать последний указатель из списка: 

git ls-remote --sort=committerdate --heads origin rc* | tail -n1

Замечательно! Мы получили хэш коммита и его указатель: 

…ccf55a1d        refs/heads/rc/0.100.10

Суммируя вышесказанное, мы можем получить последнюю версию мастер-ветки в удалённом репозитории:

const runTimeVariables = {
   //...,
   'process.env.REMOTE_VERSION': DefinePlugin.runtimeValue(() => {
       const remoteVersion = extractVersion(runGitCommand('ls-remote --sort=committerdate --heads --quiet origin rc\* | tail -n1'));
 
       return JSON.stringify(process.env.REMOTE_VERSION);
   }, true),
}

Но есть одна проблема. Эта команда использует подключение к удалённому репозиторию, а значит это занимает определённое время. И в некоторых ситуациях (при VPN или плохом соединении) это может занять больше времени, чем обычно; например, 5-10 секунд задержки.

В результате мне пришла идея небольшого фикса. Объявим константу для задержки в секундах и пустую переменную:

const remoteUpdInterval = 300; // в секундах
let remoteUpdTime = null;

Затем внутри обратного вызова для REMOTE_VERSION проверим, что remoteUpdTime — пустое (первый расчёт) или текущее время в миллисекундах — при пересборке превышает предыдущий «таймер», чтобы мы наконец могли обновить значение версии:

const runTimeVariables = {
   //...,
   'process.env.REMOTE_VERSION': DefinePlugin.runtimeValue(() => {
       if (remoteUpdTime == null || (Date.now() > (remoteUpdTime + remoteUpdInterval*1000))) {
           remoteUpdTime = Date.now();
           process.env.REMOTE_VERSION = extractVersion(runGitCommand('ls-remote --sort=committerdate --heads --quiet origin rc\* | tail -n1'));
       }
 
       return JSON.stringify(process.env.REMOTE_VERSION);
   }, true),
}

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

Заключение

Теперь внутри React-приложения можно получить доступ к переменным REMOTE_VERSION, LOCAL_VERSION и WORKING_VERSION, как к любой другой переменной окружения

В нашем приложении мы используем их для двух сценариев:

  1. Разработчик может нажать Shift+V в любой части приложения и увидеть всплывающее окно со всеми тремя версиями.

  2. В настройках есть панель статуса, которая показывает, нужно ли получить свежие изменения из репозитория или просто применить изменения к текущей ветке.

Комментарии (5)


  1. skeevy
    18.02.2022 10:41

    Можно пойти еще дальше и принудительно обновлять ветки, если локальный мастер отстает от ремоута :)


    1. nebsehemvi Автор
      18.02.2022 11:23

      Да, это хорошая идея)

      Можно даже пойти дальше и автоматически свежий мастер подмерживать в рабочую ветку, если нет конфликтов


  1. inoyakaigor
    18.02.2022 12:09

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

    посмотреть на npmjs


  1. Alexandroppolus
    18.02.2022 20:36

    Мы используем приведение к строке, потому что execSync возвращает буфер.

    const result = execSync(command, {encoding: 'utf8'});

    Вообще, для работы с гитом есть, например, https://www.npmjs.com/package/simple-git


    1. nebsehemvi Автор
      18.02.2022 22:05

      Да, уже после публикации заметил, что можно было к строке привести так.

      Век живи - век учись :)

      Спасибо за ссылку.
      Да, если брать готовую библиотеку, все равно нужно дружить уже библиотеку с вебпаком. Плюс лично мне проще записать команду строкой, чем "матчить" свои знания git с API автора.