Reservando constantes e ganchos Git em C #

Deixe-me contar uma história. Era uma vez dois desenvolvedores: Sam e Bob. Eles trabalharam juntos em um projeto no qual havia um banco de dados. Quando o desenvolvedor quis fazer alterações, ele teve que criar um arquivo stepNNN.sql , em que NNN é um determinado número. Para evitar conflitos desses números entre diferentes desenvolvedores, eles usaram um serviço da Web simples. Cada desenvolvedor, antes de começar a gravar o arquivo SQL, precisava ir para este serviço e reservar um novo número para o arquivo da etapa.


Dessa vez, Sam e Bob precisaram fazer alterações no banco de dados. Sam obedientemente foi ao serviço e reservou o número 333. E Bob esqueceu de fazê-lo. Ele apenas usou 333 para seu arquivo de etapas. Aconteceu que desta vez Bob foi o primeiro a fazer o upload de suas alterações no sistema de controle de versão. Quando Sam estava pronto para inundar, ele descobriu que o arquivo step333.sql já existe. Ele contatou Bob, explicou-lhe que o número 333 estava reservado para ele e pediu para corrigir o conflito. Mas Bob respondeu:


- Cara, meu código já está no 'master', vários desenvolvedores já o estão usando. Além disso, ele já foi bombeado para a produção. Então basta consertar tudo o que você precisa lá.


Espero que você tenha notado o que aconteceu. A pessoa que seguiu todas as regras foi punida. Sam teve que mudar seus arquivos, editar seu banco de dados local, etc. Pessoalmente, eu odeio essas situações. Vamos ver como podemos evitá-lo.


Ideia principal


Como evitamos essas coisas? E se Bob não conseguisse preencher seu código se não reservasse o número correspondente no serviço da Web?


E podemos realmente conseguir isso. Podemos usar ganchos Git para executar código personalizado antes de cada confirmação. Este código irá verificar todas as alterações enviadas. Se eles contiverem um novo arquivo de etapas, o código entrará em contato com o serviço da Web e verificará se o número do arquivo de etapas está reservado para o desenvolvedor atual. E se o número não estiver reservado, o código proibirá o preenchimento.


Essa é a ideia principal. Vamos aos detalhes.


Ganchos Git em C #


O Git não limita você em quais idiomas você deve escrever hooks. Como desenvolvedor de C #, prefiro usar o C # familiar para esses fins. Posso fazer isso?


Sim eu posso A idéia básica foi tirada por mim deste artigo escrito por Max Hamulyák. Ele exige que usemos a ferramenta global dotnet-script . Essa ferramenta requer um .NET Core 2.1 + SDK na máquina do desenvolvedor. Acredito que este seja um requisito razoável para os envolvidos no desenvolvimento do .NET. A instalação dotnet-script muito simples:


 > dotnet tool install -g dotnet-script 

Agora podemos escrever ganchos Git em C #. Para fazer isso, vá para a pasta .git\hooks do seu projeto e crie um arquivo de pre-commit (sem nenhuma extensão):


 #!/usr/bin/env dotnet-script Console.WriteLine("Git hook"); 

A partir de agora, sempre que você fizer um git commit , verá o texto do Git hook do Git hook no seu console.


Vários manipuladores por gancho


Bem, foi iniciado. Agora podemos escrever qualquer coisa no arquivo de pre-commit . Mas eu realmente não gosto dessa idéia.


Em primeiro lugar, trabalhar com um arquivo de script não é muito conveniente. Prefiro usar meu IDE favorito com todos os seus recursos. E eu preferia poder dividir código complexo em vários arquivos.


Mas há mais uma coisa que eu não gosto. Imagine a seguinte situação. Você criou uma pre-commit com algum tipo de verificação. Mais tarde, porém, você precisava adicionar mais verificações. Você precisará abrir o arquivo, decidir onde colar o seu código, como ele irá interagir com o código antigo etc. Pessoalmente, prefiro escrever um novo código, e não procurar o antigo.


Vamos lidar com esses problemas, um de cada vez.


Chamar código externo


É isso que faremos. Vamos criar uma pasta separada (por exemplo, gitHookAssemblies ). Nesta pasta, colocarei o assembly .NET Core (por exemplo, GitHooks ). Meu script no arquivo de pre-commit apenas chamará algum método deste assembly.


 public class RunHooks { public static void RunPreCommitHook() { Console.WriteLine("Git hook from assembly"); } } 

Posso criar esse assembly no meu IDE favorito e usar qualquer ferramenta.


Agora, no arquivo pre-commit , posso escrever:


 #!/usr/bin/env dotnet-script #r "../../gitHookAssemblies/GitHooks.dll" GitHooks.RunHooks.RunPreCommitHook(); 

Ótimo, não é? Agora só posso fazer alterações na minha compilação do GitHooks . O código do arquivo pre-commit nunca será alterado. Quando precisar adicionar alguma verificação, alterarei o código do método RunPreCommitHook , reconstruir o assembly e colocá-lo na pasta gitHookAssemblies . E é isso aí!


Bem, na verdade não.


Lutando contra o cache


Vamos tentar seguir o nosso processo. Altere a mensagem no Console.WriteLine para outra coisa, recrie o assembly e coloque o resultado na pasta gitHookAssemblies . Depois disso, chame git commit novamente. O que vamos ver? Post antigo. Nossas mudanças não foram detectadas. Porque


Permita que seu projeto seja localizado na pasta c:\project . Isso significa que os scripts de gancho do Git estão localizados na pasta c:\project\.git\hooks . Agora, se você estiver usando o Windows 10, vá para a pasta c:\Users\<UserName>\AppData\Local\Temp\scripts\c\project\.git\hooks\ . Aqui <UserName> é o nome do seu usuário atual. O que veremos aqui? Quando executamos o script de pre-commit , uma versão compilada desse script é criada nesta pasta. Aqui você pode encontrar todos os assemblies referenciados pelo script (incluindo nosso GitHooks.dll ). E na subpasta de execution-cache você pode encontrar o arquivo SHA256. Posso assumir que ele contém o hash SHA256 do nosso arquivo de pre-commit . No momento em que executamos o script, o tempo de execução compara o hash atual do arquivo com o hash armazenado. Se forem iguais, a versão salva do script compilado será usada.


Isso significa que, como nunca GitHooks.dll arquivo de pre-commit , as alterações no GitHooks.dll nunca atingirão o cache e nunca serão usadas.


O que podemos fazer nessa situação? Bem, a reflexão nos ajudará. Vou reescrever meu script para que ele use o Reflection em vez de referenciar diretamente o assembly GitHooks . A seguir, como será nosso arquivo de pre-commit :


 #!/usr/bin/env dotnet-script #r "nuget: System.Runtime.Loader, 4.3.0" using System.IO; using System.Runtime.Loader; var hooksDirectory = Path.Combine(Environment.CurrentDirectory, "gitHookAssemblies"); var assemblyPath = Path.Combine(hooksDirectory, "GitHooks.dll"); var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); if(assembly == null) { Console.WriteLine($"Can't load assembly from '{assemblyPath}'."); } var collectorsType = assembly.GetType("GitHooks.RunHooks"); if(collectorsType == null) { Console.WriteLine("Can't find entry type."); } var method = collectorsType.GetMethod("RunPreCommitHook", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if(method == null) { Console.WriteLine("Can't find method for pre-commit hooks."); } method.Invoke(null, new object[0]); 

Agora podemos atualizar o GitHook.dll em nossa pasta gitHookAssemblies a qualquer momento, e todas as alterações serão captadas pelo mesmo script. A modificação do próprio script não é mais necessária.


Tudo isso parece ótimo, mas há outro problema que precisa ser resolvido antes de prosseguir. Estou falando de assemblies referenciados pelo nosso código.


Montagens usadas


Tudo funciona bem, desde que a única coisa que o método RunHooks.RunPreCommitHook seja a saída da string no console. Mas, francamente, geralmente exibir texto na tela não é de interesse. Precisamos fazer coisas mais complexas. E para isso, precisamos usar outros assemblies e pacotes NuGet. Vamos ver como fazer isso.


RunHooks.RunPreCommitHook para que ele use o pacote LibGit2Sharp :


 public static void RunPreCommitHook() { using var repo = new Repository(Environment.CurrentDirectory); Console.WriteLine(repo.Info.WorkingDirectory); } 

Agora, se eu executar o git commit , receberei a seguinte mensagem de erro:


 System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.IO.FileLoadException: Could not load file or assembly 'LibGit2Sharp, Version=0.26.0.0, Culture=neutral, PublicKeyToken=7cbde695407f0333'. General Exception (0x80131500) 

Claramente, precisamos de alguma maneira de garantir que os conjuntos a que nos referimos sejam carregados. A idéia básica aqui é. Vou colocar todo o código do assembly necessário para executar o código na mesma pasta gitHookAssemblies junto com o meu GitHooks.dll . Para obter todos os assemblies necessários, você pode usar o comando dotnet publish . No nosso caso, precisamos colocar o LibGit2Sharp.dll e o git2-7ce88e6.dll nessa pasta.


Também temos que mudar o pre-commit . Nós adicionaremos o seguinte código a ele:


 #!/usr/bin/env dotnet-script #r "nuget: System.Runtime.Loader, 4.3.0" using System.IO; using System.Runtime.Loader; var hooksDirectory = Path.Combine(Environment.CurrentDirectory, "gitHookAssemblies"); var assemblyPath = Path.Combine(hooksDirectory, "GitHooks.dll"); AssemblyLoadContext.Default.Resolving += (context, assemblyName) => { var assemblyPath = Path.Combine(hooksDirectory, $"{assemblyName.Name}.dll"); if(File.Exists(assemblyPath)) { return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); } return null; }; ... 

Esse código tentará carregar todos os assemblies que o tempo de execução não pôde encontrar sozinho na pasta gitHookAssemblies .


Agora você pode executar o git commit e ele será executado sem problemas.


Melhoria da extensibilidade


Nosso arquivo de pre-commit está completo. Não precisamos mais mudar isso. Mas se você precisar fazer alterações, precisaremos alterar o método RunHooks.RunPreCommitHook . Então, acabamos de mudar o problema para outro nível. Pessoalmente, eu preferiria ter algum tipo de sistema de plugins. Toda vez que eu precisar adicionar alguma ação que precise ser executada antes de preencher o código, apenas escreverei um novo plug-in e nada precisará ser alterado. Quão difícil é conseguir isso?


Nem um pouco difícil. Vamos usar o MEF . É assim que funciona.


Primeiro, precisamos definir uma interface para nossos manipuladores de gancho:


 public interface IPreCommitHook { bool Process(IList<string> args); } 

Cada manipulador pode receber alguns argumentos de string do Git. Esses argumentos serão passados ​​pelo parâmetro args . O método Process retornará true se permitir derramar alterações. Caso contrário, false será retornado.


Interfaces semelhantes podem ser definidas para todos os ganchos, mas neste artigo focaremos apenas na pré-confirmação.


Agora você precisa escrever uma implementação desta interface:


 [Export(typeof(IPreCommitHook))] public class MessageHook : IPreCommitHook { public bool Process(IList<string> args) { Console.WriteLine("Message hook..."); if(args != null) { Console.WriteLine("Arguments are:"); foreach(var arg in args) { Console.WriteLine(arg); } } return true; } } 

Essas classes podem ser criadas em diferentes montagens, se você desejar. Não há literalmente restrições. O atributo Export é obtido do pacote NuGet System.ComponentModel.Composition .


Além disso, vamos criar um método auxiliar que colete todas as implementações da interface IPreCommitHook marcadas com o atributo Export , execute todas elas e retorne informações sobre se todas elas permitiram o preenchimento. Coloquei meu manipulador em um assembly GitHooksCollector separado, mas isso não é tão importante:


 public class Collectors { private class PreCommitHooks { [ImportMany(typeof(IPreCommitHook))] public IPreCommitHook[] Hooks { get; set; } } public static int RunPreCommitHooks(IList<string> args, string directory) { var catalog = new DirectoryCatalog(directory, "*Hooks.dll"); var container = new CompositionContainer(catalog); var obj = new PreCommitHooks(); container.ComposeParts(obj); bool success = true; foreach(var hook in obj.Hooks) { success &= hook.Process(args); } return success ? 0 : 1; } } 

Este código também usa o pacote NuGet System.ComponentModel.Composition . Primeiro, dizemos que *Hooks.dll todos os assemblies cujo nome corresponde ao modelo *Hooks.dll na pasta do directory Você pode usar qualquer modelo que desejar aqui. Em seguida, coletamos todas as implementações exportadas da interface IPreCommitHook em um objeto PreCommitHooks . E, finalmente, iniciamos todos os manipuladores de gancho e coletamos o resultado de sua execução.


A última coisa que precisamos fazer é uma pequena alteração no arquivo de pre-commit :


 #!/usr/bin/env dotnet-script #r "nuget: System.Runtime.Loader, 4.3.0" using System.IO; using System.Runtime.Loader; var hooksDirectory = Path.Combine(Environment.CurrentDirectory, "gitHookAssemblies"); var assemblyPath = Path.Combine(hooksDirectory, "GitHooksCollector.dll"); AssemblyLoadContext.Default.Resolving += (context, assemblyName) => { var assemblyPath = Path.Combine(hooksDirectory, $"{assemblyName.Name}.dll"); if(File.Exists(assemblyPath)) { return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); } return null; }; var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); if(assembly == null) { Console.WriteLine($"Can't load assembly from '{assemblyPath}'."); } var collectorsType = assembly.GetType("GitHooksCollector.Collectors"); if(collectorsType == null) { Console.WriteLine("Can't find collector's type."); } var method = collectorsType.GetMethod("RunPreCommitHooks", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if(method == null) { Console.WriteLine("Can't find collector's method for pre-commit hooks."); } int exitCode = (int) method.Invoke(null, new object[] { Args, hooksDirectory }); Environment.Exit(exitCode); 

E não esqueça de colocar todos os assemblies envolvidos na pasta gitHookAssemblies .


Sim, foi uma longa introdução. Mas agora temos uma solução totalmente confiável para criar manipuladores de gancho Git em C #. Tudo o que é necessário para nós é alterar o conteúdo da pasta gitHookAssemblies . Seu conteúdo pode ser colocado em um sistema de controle de versão e, assim, distribuído entre todos os desenvolvedores.


De qualquer forma, é hora de voltarmos ao nosso problema original.


Serviço da Web para reservas constantes


Queríamos garantir que os desenvolvedores não pudessem preencher determinadas alterações se esquecessem de reservar a constante correspondente no serviço da Web. Vamos criar um serviço da Web simples para que você possa trabalhar com ele. Estou usando o serviço ASP.NET Core Web com autenticação do Windows. Mas, de fato, existem várias opções.


 using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace ListsService.Controllers { public sealed class ListItem<T> { public ListItem(T value, string owner) { Value = value; Owner = owner; } public T Value { get; } public string Owner { get; } } public static class Lists { public static List<ListItem<int>> SqlVersions = new List<ListItem<int>> { new ListItem<int>(1, @"DOMAIN\Iakimov") }; public static Dictionary<int, List<ListItem<int>>> AllLists = new Dictionary<int, List<ListItem<int>>> { {1, SqlVersions} }; } [Authorize] public class ListsController : Controller { [Route("/api/lists/{listId}/ownerOf/{itemId}")] [HttpGet] public IActionResult GetOwner(int listId, int itemId) { if (!Lists.AllLists.ContainsKey(listId)) return NotFound(); var item = Lists.AllLists[listId].FirstOrDefault(li => li.Value == itemId); if(item == null) return NotFound(); return Json(item.Owner); } } } 

Aqui, para fins de teste, usei a classe Lists estática como um mecanismo para armazenar listas. Cada lista terá um identificador inteiro. Cada lista conterá valores inteiros e informações sobre as pessoas para quem esses valores estão reservados. O método GetOwner da classe GetOwner permite obter o identificador da pessoa para quem esse item de lista está reservado.


Validando arquivos de etapa SQL


Agora estamos prontos para verificar se podemos fazer upload de um novo arquivo de etapas ou não. Por definição, suponha que armazenemos arquivos de etapas da seguinte maneira. A pasta raiz do nosso projeto possui um diretório sql . Nele, cada desenvolvedor pode criar uma pasta verXXX , onde XXX é um determinado número que deve ser reservado anteriormente no serviço da Web. Dentro do diretório verXXX , pode haver um ou mais arquivos .sql contendo instruções para modificar o banco de dados. Não discutiremos o problema de garantir a ordem de execução desses arquivos .sql aqui. Isso não é importante para a nossa discussão. Nós apenas queremos fazer o seguinte. Se um desenvolvedor estiver tentando fazer upload de um novo arquivo contido na sql/verXXX , devemos verificar se a constante XXX reservada para esse desenvolvedor.


Aqui está a aparência do código para o manipulador de gancho Git correspondente:


 [Export(typeof(IPreCommitHook))] public class SqlStepsHook : IPreCommitHook { private static readonly Regex _expr = new Regex("\\bver(\\d+)\\b"); public bool Process(IList<string> args) { using var repo = new Repository(Environment.CurrentDirectory); var items = repo.RetrieveStatus() .Where(i => !i.State.HasFlag(FileStatus.Ignored)) .Where(i => i.State.HasFlag(FileStatus.NewInIndex)) .Where(i => i.FilePath.StartsWith(@"sql")); var versions = new HashSet<int>( items .Select(i => _expr.Match(i.FilePath)) .Where(m => m.Success) .Select(m => m.Groups[1].Value) .Select(d => int.Parse(d)) ); foreach(var version in versions) { if (!ListItemOwnerChecker.DoesCurrentUserOwnListItem(1, version)) return false; } return true; } } 

Aqui usamos a classe Repository do pacote LibGit2Sharp . A variável items conterá todos os novos arquivos no índice Git que estão localizados dentro da pasta sql . Você pode melhorar o procedimento de pesquisa para esses arquivos, se desejar. Na variável de versions , coletamos várias constantes XXX das pastas verXXX . E, finalmente, o método ListItemOwnerChecker.DoesCurrentUserOwnListItem verifica se essas versões estão registradas para o usuário atual no serviço da Web na lista 1.


A implementação do ListItemOwnerChecker.DoesCurrentUserOwnListItem bastante simples:


 class ListItemOwnerChecker { public static string GetListItemOwner(int listId, int itemId) { var handler = new HttpClientHandler { UseDefaultCredentials = true }; var client = new HttpClient(handler); var response = client.GetAsync($"https://localhost:44389/api/lists/{listId}/ownerOf/{itemId}") .ConfigureAwait(false) .GetAwaiter() .GetResult(); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } var owner = response.Content .ReadAsStringAsync() .ConfigureAwait(false) .GetAwaiter() .GetResult(); return JsonConvert.DeserializeObject<string>(owner); } public static bool DoesCurrentUserOwnListItem(int listId, int itemId) { var owner = GetListItemOwner(listId, itemId); if (owner == null) { Console.WriteLine($"There is no item '{itemId}' in the list '{listId}' registered on the lists service."); return false; } if (owner != WindowsIdentity.GetCurrent().Name) { Console.WriteLine($"Item '{itemId}' in the list '{listId}' registered by '{owner}' and you are '{WindowsIdentity.GetCurrent().Name}'."); return false; } return true; } } 

Aqui, solicitamos ao serviço da Web o identificador do usuário que registrou a constante especificada (método GetListItemOwner ). Em seguida, o resultado é comparado com o nome do usuário atual do Windows. Essa é apenas uma das muitas maneiras possíveis de implementar essa funcionalidade. Por exemplo, você pode usar o nome de usuário ou email da configuração do Git.


Isso é tudo. Apenas compile o assembly apropriado e coloque-o na pasta gitHookAssemblies junto com todas as suas dependências. E tudo funcionará automaticamente.


Verificando valores de enumeração


Isso é ótimo! Agora ninguém poderá fazer upload de alterações no banco de dados sem antes ter reservado para si a constante correspondente no serviço da Web. Mas um método semelhante pode ser usado em outros lugares onde é necessária reserva constante.


Por exemplo, em algum lugar do código do projeto você tem uma enumeração. Cada desenvolvedor pode adicionar novos membros a ele com valores inteiros atribuídos:


 enum Constants { Val1 = 1, Val2 = 2, Val3 = 3 } 

Queremos evitar uma colisão de valores para membros dessa enumeração. Portanto, exigimos uma reserva preliminar das constantes correspondentes no serviço da Web. Quão difícil é implementar a verificação dessa reserva?


Aqui está o código para o novo manipulador de ganchos Git:


 [Export(typeof(IPreCommitHook))] public class ConstantValuesHook : IPreCommitHook { public bool Process(IList<string> args) { using var repo = new Repository(Environment.CurrentDirectory); var constantsItem = repo.RetrieveStatus() .Staged .FirstOrDefault(i => i.FilePath == @"src/GitInteraction/Constants.cs"); if (constantsItem == null) return true; if (!constantsItem.State.HasFlag(FileStatus.NewInIndex) && !constantsItem.State.HasFlag(FileStatus.ModifiedInIndex)) return true; var initialContent = GetInitialContent(repo, constantsItem); var indexContent = GetIndexContent(repo, constantsItem); var initialConstantValues = GetConstantValues(initialContent); var indexConstantValues = GetConstantValues(indexContent); indexConstantValues.ExceptWith(initialConstantValues); if (indexConstantValues.Count == 0) return true; foreach (var version in indexConstantValues) { if (!ListItemOwnerChecker.DoesCurrentUserOwnListItem(2, version)) return false; } return true; } ... } 

Primeiro, verificamos se o arquivo que contém nossa enumeração foi modificado. Em seguida, extraímos o conteúdo desse arquivo da versão mais recente carregada e do índice Git usando os GetIndexContent e GetIndexContent . Aqui está a sua implementação:


 private string GetInitialContent(Repository repo, StatusEntry item) { var blob = repo.Head.Tip[item.FilePath]?.Target as Blob; if (blob == null) return null; using var content = new StreamReader(blob.GetContentStream(), Encoding.UTF8); return content.ReadToEnd(); } private string GetIndexContent(Repository repo, StatusEntry item) { var id = repo.Index[item.FilePath]?.Id; if (id == null) return null; var itemBlob = repo.Lookup<Blob>(id); if (itemBlob == null) return null; using var content = new StreamReader(itemBlob.GetContentStream(), Encoding.UTF8); return content.ReadToEnd(); } 

. GetConstantValues . Roslyn . NuGet- Microsoft.CodeAnalysis.CSharp .


 private ISet<int> GetConstantValues(string fileContent) { if (string.IsNullOrWhiteSpace(fileContent)) return new HashSet<int>(); var tree = CSharpSyntaxTree.ParseText(fileContent); var root = tree.GetCompilationUnitRoot(); var enumDeclaration = root .DescendantNodes() .OfType<EnumDeclarationSyntax>() .FirstOrDefault(e => e.Identifier.Text == "Constants"); if(enumDeclaration == null) return new HashSet<int>(); var result = new HashSet<int>(); foreach (var member in enumDeclaration.Members) { if(int.TryParse(member.EqualsValue.Value.ToString(), out var value)) { result.Add(value); } } return result; } 

Roslyn . , , Microsoft.CodeAnalysis.CSharp 3.4.0 . gitHookAssemblies , , . . , dotnet-script Roslyn . , - Microsoft.CodeAnalysis.CSharp . 3.3.1 . NuGet-, .


, , Process hook`, Web-.



. . , .


  1. pre-commit , , .git\hooks . --template git init . - :


     git config init.templatedir git_template_dir git init 

    core.hooksPath Git, Git 2.9 :


     git config core.hooksPath git_template_dir 

    .


  2. dotnet-script . .NET Core, .


  3. , . , gitHookAssemblies , , . , LibGit2Sharp . git2-7ce88e6.dll , Win-x64. , .


  4. Web-. Windows-, . Web- UI .


  5. , Git hook' . , .



Conclusão


Git hook` .NET. , .


, . Boa sorte


PS GitHub .

Source: https://habr.com/ru/post/pt485218/


All Articles