Déjame contarte una historia. Había una vez dos desarrolladores: Sam y Bob. Trabajaron juntos en un proyecto en el que había una base de datos. Cuando el desarrollador quiso hacer cambios, tuvo que crear un archivo stepNNN.sql
, donde NNN es un número determinado. Para evitar conflictos de estos números entre diferentes desarrolladores, utilizaron un servicio web simple. Cada desarrollador, antes de comenzar a escribir el archivo SQL, tenía que ir a este servicio y reservar un nuevo número para el archivo de pasos.
Esta vez, Sam y Bob necesitaban hacer cambios en la base de datos. Sam obedientemente fue al servicio y reservó el número 333. Y Bob se olvidó de hacerlo. Acaba de usar 333 para su archivo de pasos. Dio la casualidad de que esta vez Bob fue el primero en subir sus cambios al sistema de control de versiones. Cuando Sam estaba listo para inundarse, descubrió que el archivo step333.sql
ya existe. Se puso en contacto con Bob, le explicó que el número 333 estaba reservado para él y le pidió que solucionara el conflicto. Pero Bob respondió:
- Amigo, mi código ya está en el 'maestro', un grupo de desarrolladores ya lo están usando. Además, ya se ha bombeado a producción. Así que arregla todo lo que necesitas allí.
Espero que hayas notado lo que pasó. La persona que siguió todas las reglas fue castigada. Sam tuvo que cambiar sus archivos, editar su base de datos local, etc. Personalmente, odio tales situaciones. Veamos cómo podemos evitarlo.
Idea principal
¿Cómo evitamos tales cosas? ¿Qué pasa si Bob no puede completar su código si no reserva el número correspondiente en el servicio web?
Y en realidad podemos lograr esto. Podemos usar ganchos Git para ejecutar código personalizado antes de cada confirmación. Este código verificará todos los cambios introducidos. Si contienen un nuevo archivo de pasos, el código se pondrá en contacto con el servicio web y comprobará si el número del archivo de pasos está reservado para el desarrollador actual. Y si el número no está reservado, el código prohibirá el relleno.
Esa es la idea principal. Pasemos a los detalles.
Git ganchos en C #
Git no te limita en qué idiomas debes escribir ganchos. Como desarrollador de C #, prefiero usar el familiar C # para estos fines. ¿Puedo hacer esto?
Si puedo. La idea básica fue tomada por mí de este artículo escrito por Max Hamulyák. Requiere que usemos la herramienta global dotnet-script . Esta herramienta requiere un SDK .NET Core 2.1 + en la máquina del desarrollador. Creo que este es un requisito razonable para aquellos involucrados en el desarrollo de .NET. Instalar dotnet-script
muy simple:
> dotnet tool install -g dotnet-script
Ahora podemos escribir ganchos Git en C #. Para hacer esto, vaya a la carpeta .git\hooks
de su proyecto y cree un archivo de pre-commit
(sin ninguna extensión):
#!/usr/bin/env dotnet-script Console.WriteLine("Git hook");
De ahora en adelante, cada vez que hagas un git commit
, verás el texto Git hook
en tu consola.
Múltiples manejadores por gancho
Bueno, se ha comenzado. Ahora podemos escribir cualquier cosa en el archivo pre-commit
. Pero realmente no me gusta esta idea.
En primer lugar, trabajar con un archivo de script no es muy conveniente. Prefiero usar mi IDE favorito con todas sus características. Y preferiría poder dividir el código complejo en varios archivos.
Pero hay una cosa más que no me gusta. Imagina la siguiente situación. Creó una pre-commit
con algún tipo de comprobaciones. Pero luego necesitabas agregar más cheques. Deberá abrir el archivo, decidir dónde pegar el código, cómo interactuará con el código anterior, etc. Personalmente, prefiero escribir un código nuevo y no profundizar en el antiguo.
Tratemos estos problemas uno a la vez.
Llamar código externo
Esto es lo que haremos. gitHookAssemblies
una carpeta separada (por ejemplo, gitHookAssemblies
). En esta carpeta, pondré el ensamblado .NET Core (por ejemplo, GitHooks
). Mi script en el archivo de pre-commit
solo llamará a algún método de este ensamblado.
public class RunHooks { public static void RunPreCommitHook() { Console.WriteLine("Git hook from assembly"); } }
Puedo crear este ensamblaje en mi IDE favorito y usar cualquier herramienta.
Ahora en el archivo pre-commit
puedo escribir:
#!/usr/bin/env dotnet-script #r "../../gitHookAssemblies/GitHooks.dll" GitHooks.RunHooks.RunPreCommitHook();
Genial, ¿no es así? Ahora solo puedo hacer cambios en mi compilación GitHooks
. El código del archivo de pre-commit
nunca cambiará. Cuando necesite agregar alguna verificación, cambiaré el código del método RunPreCommitHook
, reconstruiré el ensamblaje y lo gitHookAssemblies
en la carpeta gitHookAssemblies
. ¡Y eso es todo!
Bueno, en realidad no.
Luchando contra el caché
Intentemos seguir nuestro proceso. Cambie el mensaje en Console.WriteLine
a otra cosa, reconstruya el ensamblaje y coloque el resultado en la carpeta gitHookAssemblies
. Después de eso, llame a git commit
nuevamente. Que vamos a ver Post antiguo Nuestros cambios no se entendieron. Por qué
Deje que, para su definición, su proyecto se ubique en la carpeta c:\project
. Esto significa que los scripts de gancho de Git se encuentran en la carpeta c:\project\.git\hooks
. Ahora, si está utilizando Windows 10, vaya a c:\Users\<UserName>\AppData\Local\Temp\scripts\c\project\.git\hooks\
. Aquí <UserName>
es el nombre de su usuario actual. ¿Qué veremos aquí? Cuando ejecutamos el script pre-commit
, se crea una versión compilada de este script en esta carpeta. Aquí puede encontrar todos los ensamblados a los que hace referencia el script (incluido nuestro GitHooks.dll
). Y en la subcarpeta de execution-cache
puede encontrar el archivo SHA256. Puedo suponer que contiene el hash SHA256 de nuestro archivo de pre-commit
. En el momento en que ejecutamos el script, el tiempo de ejecución compara el hash actual del archivo con el hash almacenado. Si son iguales, se utilizará la versión guardada del script compilado.
Esto significa que dado que nunca cambiamos el archivo pre-commit
, los cambios en GitHooks.dll
nunca llegarán a la caché y nunca se utilizarán.
¿Qué podemos hacer en esta situación? Bueno, la reflexión nos ayudará. Reescribiré mi script para que use Reflection en lugar de hacer referencia directa al ensamblaje GitHooks
. Así se verá nuestro archivo de pre-commit
después de esto:
#!/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]);
Ahora podemos actualizar GitHook.dll
en nuestra carpeta gitHookAssemblies
en cualquier momento, y todos los cambios serán recogidos por el mismo script. Modificar el script en sí ya no es necesario.
Todo esto suena genial, pero hay otro problema que debe resolverse antes de continuar. Estoy hablando de ensamblados a los que hace referencia nuestro código.
Montajes usados
Todo funciona bien, siempre que lo único que RunHooks.RunPreCommitHook
método RunHooks.RunPreCommitHook
sea enviar la cadena a la consola. Pero, francamente, mostrar texto en la pantalla generalmente no es de interés. Necesitamos hacer cosas más complejas. Y para esto necesitamos usar otros ensamblajes y paquetes NuGet. Veamos cómo hacer esto.
RunHooks.RunPreCommitHook
para que use el paquete LibGit2Sharp
:
public static void RunPreCommitHook() { using var repo = new Repository(Environment.CurrentDirectory); Console.WriteLine(repo.Info.WorkingDirectory); }
Ahora, si ejecuto git commit
, obtendré el siguiente mensaje de error:
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, necesitamos alguna forma de asegurarnos de que los ensambles a los que nos referimos estén cargados. La idea básica aquí es. Pondré todo el código de ensamblaje requerido para ejecutar el código en la misma carpeta gitHookAssemblies
junto con mi GitHooks.dll
. Para obtener todos los ensamblajes necesarios, puede usar el comando de dotnet publish
. En nuestro caso, debemos colocar LibGit2Sharp.dll
y git2-7ce88e6.dll
en esta carpeta.
También tenemos que cambiar el pre-commit
. Le agregaremos el siguiente código:
#!/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; }; ...
Este código intentará cargar todos los ensamblados que el gitHookAssemblies
ejecución no pudo encontrar por sí solo desde la carpeta gitHookAssemblies
.
Ahora puede ejecutar git commit
y se ejecutará sin problemas.
Mejora de extensibilidad
Nuestro archivo de pre-commit
está completo. Ya no necesitamos cambiarlo. Pero si necesita hacer cambios, tendremos que cambiar el método RunHooks.RunPreCommitHook
. Así que acabamos de trasladar el problema a otro nivel. Personalmente, preferiría tener algún tipo de sistema de complemento. Cada vez que necesite agregar alguna acción que deba realizarse antes de completar el código, simplemente escribiré un nuevo complemento y no será necesario cambiar nada. ¿Qué tan difícil es lograr esto?
Para nada difícil. Usemos MEF . Así es como funciona.
Primero necesitamos definir una interfaz para nuestros controladores de gancho:
public interface IPreCommitHook { bool Process(IList<string> args); }
Cada controlador puede recibir algunos argumentos de cadena de Git. Estos argumentos se pasarán a través del parámetro args
. El método de Process
devolverá true
si permite verter cambios. De lo contrario, se devolverá false
.
Se pueden definir interfaces similares para todos los ganchos, pero en este artículo nos centraremos solo en la confirmación previa.
Ahora necesita escribir una implementación de esta interfaz:
[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; } }
Dichas clases se pueden crear en diferentes ensamblajes si así lo desea. Literalmente no hay restricciones. El atributo Export
se toma del paquete System.ComponentModel.Composition
NuGet.
Además, IPreCommitHook
un método auxiliar que recopile todas las implementaciones de interfaz IPreCommitHook
marcadas con el atributo Export
, las ejecute todas y devuelva información sobre si todas permitieron el relleno. Puse mi controlador en un ensamblaje GitHooksCollector
separado, pero esto no es tan 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 también usa el paquete System.ComponentModel.Composition
NuGet. Primero, decimos que veremos todos los ensamblados cuyo nombre coincide con la plantilla *Hooks.dll
en la carpeta del directory
. Puedes usar cualquier plantilla que quieras aquí. Luego recopilamos todas las implementaciones exportadas de la interfaz IPreCommitHook
en un objeto PreCommitHooks
. Y finalmente, comenzamos todos los controladores de gancho y recopilamos el resultado de su ejecución.
Lo último que debemos hacer es un pequeño cambio en el archivo 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);
Y no olvide poner todos los ensamblados involucrados en la carpeta gitHookAssemblies
.
Sí, fue una larga introducción. Pero ahora tenemos una solución completamente confiable para crear controladores de gancho Git en C #. Todo lo que se requiere de nosotros es cambiar el contenido de la carpeta gitHookAssemblies
. Su contenido puede colocarse en un sistema de control de versiones y, por lo tanto, distribuirse entre todos los desarrolladores.
En cualquier caso, es hora de que regresemos a nuestro problema original.
Servicio web para reservas constantes
Queríamos asegurarnos de que los desarrolladores no pudieran completar ciertos cambios si olvidaran reservar la constante correspondiente en el servicio web. Creemos un servicio web simple para que pueda trabajar con él. Estoy usando el servicio web ASP.NET Core con autenticación de Windows. Pero, de hecho, hay varias opciones.
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); } } }
Aquí, para fines de prueba, utilicé la clase Lists
estáticas como mecanismo para almacenar listas. Cada lista tendrá un identificador entero. Cada lista contendrá valores enteros e información sobre las personas para quienes estos valores están reservados. El método GetOwner
de la clase GetOwner
permite obtener el identificador de la persona para quien está reservado este elemento de la lista.
Validación de archivos de paso SQL
Ahora estamos listos para verificar si podemos cargar un nuevo archivo de pasos o no. Para mayor claridad, supongamos que almacenamos archivos de pasos de la siguiente manera. La carpeta raíz de nuestro proyecto tiene un directorio sql
. En él, cada desarrollador puede crear una carpeta verXXX
, donde XXX
es un número determinado que debería reservarse previamente en el servicio web. Dentro del directorio verXXX
, puede haber uno o más archivos .sql
que contienen instrucciones para modificar la base de datos. No discutiremos el problema de garantizar el orden de ejecución de estos archivos .sql
aquí. Esto no es importante para nuestra discusión. Solo queremos hacer lo siguiente. Si un desarrollador está intentando cargar un nuevo archivo contenido en la sql/verXXX
, debemos verificar si la constante XXX
reservada para este desarrollador.
Así es como se ve el código para el controlador de gancho Git correspondiente:
[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; } }
Aquí usamos la clase Repository
del paquete LibGit2Sharp
. La variable de items
contendrá todos los archivos nuevos en el índice de Git que se encuentran dentro de la carpeta sql
. Puede mejorar el procedimiento de búsqueda de dichos archivos si lo desea. En la variable de versions
, recopilamos varias constantes XXX
de las carpetas verXXX
. Finalmente, el método ListItemOwnerChecker.DoesCurrentUserOwnListItem
verifica si estas versiones están registradas con el usuario actual en el servicio web en la lista 1.
La implementación de ListItemOwnerChecker.DoesCurrentUserOwnListItem
bastante simple:
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; } }
Aquí le pedimos al servicio web el identificador del usuario que registró la constante especificada (método GetListItemOwner
). Luego, el resultado se compara con el nombre del usuario actual de Windows. Esta es solo una de las muchas formas posibles de implementar esta funcionalidad. Por ejemplo, puede usar el nombre de usuario o el correo electrónico de la configuración de Git.
Eso es todo Simplemente compile el ensamblaje apropiado y colóquelo en la carpeta gitHookAssemblies
junto con todas sus dependencias. Y todo funcionará automáticamente.
Comprobación de valores de enumeración
¡Esto es genial! Ahora nadie podrá cargar cambios en la base de datos sin haber reservado previamente la constante correspondiente en el servicio web. Pero se puede usar un método similar en otros lugares donde se requiere una reserva constante.
Por ejemplo, en algún lugar del código del proyecto tiene enumeración. Cada desarrollador puede agregarle nuevos miembros con valores enteros asignados:
enum Constants { Val1 = 1, Val2 = 2, Val3 = 3 }
Queremos evitar una colisión de valores para los miembros de esta enumeración. Por lo tanto, requerimos una reserva preliminar de las constantes correspondientes en el servicio web. ¿Qué tan difícil es implementar la verificación de tal reserva?
Aquí está el código para el nuevo controlador de gancho 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; } ... }
Primero verificamos si el archivo que contiene nuestra enumeración ha sido modificado. Luego extraemos el contenido de este archivo de la última versión cargada y del índice Git usando los GetIndexContent
GetInitialContent
y GetIndexContent
. Aquí está su implementación:
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-.
. . , .
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
.
dotnet-script
. .NET Core, .
, . , gitHookAssemblies
, , . , LibGit2Sharp
. git2-7ce88e6.dll
, Win-x64. , .
Web-. Windows-, . Web- UI .
, Git hook' . , .
Conclusión
Git hook` .NET. , .
, . Buena suerte
PS GitHub .