dotnet-gitmoji: A Gitmoji CLI for the dotnet World

CLI Tool ·

dotnet-gitmoji: A Gitmoji CLI for the dotnet World

A .NET tool that brings the gitmoji commit convention to Git, with a Husky.Net-managed prepare-commit-msg hook for teams and an interactive client mode for personal workflows.

Project Description

dotnet-gitmoji is a small .NET 10 tool that brings the gitmoji commit convention into a .NET workflow. You can install it globally for personal use or pin it in a repo’s local tool manifest for team use. From there you get two adoption paths: a prepare-commit-msg Git hook managed through Husky.Net, or dotnet-gitmoji commit as an interactive replacement for the native Git command. Either way, the tool prompts you for a gitmoji, lets you fuzzy-search the full list, and formats the final commit message.

The shape I care about most is the team workflow. When a repo commits .config/dotnet-tools.json, .husky/, and Directory.Build.targets, a teammate can clone it, run dotnet restore or open the solution in Visual Studio or Rider, and get the same gitmoji prompt on the next commit. That keeps the whole convention inside NuGet, MSBuild, and the .NET SDK instead of requiring Node.js just to share a commit hook.

Technologies Used

LayerChoiceWhy it is in the stack
Runtime.NET 10Modern target with first-class global tool support and a stable System.Text.Json baseline.
Packaging.NET toolShips through NuGet as dotnet-gitmoji, and works as either a shared local tool or a personal global install.
CLI frameworkCliFxDeclarative command and option attributes, plays well with DI without extra ceremony.
Terminal UXSpectre.ConsoleHandles selection prompts, live fuzzy pickers, and colored output.
Process invocationCliWrapWraps every git and dotnet subprocess call with a typed, test-friendly API.
CompositionMicrosoft.Extensions.DependencyInjectionKeeps services, validators, and commands wired through a single container.
HTTPMicrosoft.Extensions.HttpFetches the gitmoji list from gitmoji.dev with a typed HttpClient.
TestingxUnit, NSubstitute, coverletUnit tests for services and validators, plus an integration fixture for the end-to-end tool.

Architecture

The tool follows a standard CliFx layout. Program.cs wires the DI container, CliFx resolves the requested command from the service provider, and the command delegates the real work to a service. Services are interface-first, which is what makes the xUnit and NSubstitute tests cheap to write. The same commit path is reached from both entry points: Git’s prepare-commit-msg hook and the interactive commit command.

flowchart LR
    subgraph CLI["CliFx commands"]
        MAIN["Program.cs\nDI setup + app entry"]
        CLIENT_CMDS["commit\nlist · search\nconfig · update"]
        HOOK_CMDS["hook\ninit · remove"]
    end

    subgraph CORE["Shared core"]
        GIT["GitService\nhook detect/install\nstage changes"]
        PROMPT["PromptService\nfuzzy selector UI"]
        FUZZY["GitmojiFuzzyMatcher\nscoring & ranking"]
        CONFIG["ConfigurationService\n.gitmojirc.json → global"]
        VALIDATOR["CommitMessageValidator\nparse existing message"]
    end

    subgraph DATA["Data & presentation"]
        PROVIDER["GitmojiProvider\nAPI → cache → embedded"]
        MODELS["Gitmoji · ToolConfiguration\nEmojiFormat · ValidationResult"]
    end

    MAIN --> CLIENT_CMDS
    MAIN --> HOOK_CMDS

    CLIENT_CMDS --> PROMPT
    CLIENT_CMDS --> PROVIDER
    CLIENT_CMDS --> CONFIG

    HOOK_CMDS --> GIT
    HOOK_CMDS --> PROMPT
    HOOK_CMDS --> PROVIDER
    HOOK_CMDS --> VALIDATOR

    PROMPT --> FUZZY
    FUZZY --> MODELS
    PROVIDER --> MODELS
    CONFIG --> MODELS

The architecture diagram shows what each layer owns. The sequence diagram below shows what actually runs when you commit. Both entry points converge on PromptService, which drives the fuzzy selector and then hands off to CommitMessageService to write the result. The only difference is who calls git commit at the end: in hook mode Git already has control, so the tool just rewrites the message file; in client mode GitService shells out to Git directly.

flowchart LR
    A[git commit] --> B[prepare-commit-msg]
    B --> C[dotnet-gitmoji hook]
    C --> D[PromptService]
    D --> E[GitmojiProvider<br/>cache or embedded default]
    D --> F[CommitMessageService]
    F --> G[.git/COMMIT_EDITMSG]

    H[dotnet-gitmoji commit] --> D
    F --> I[GitService<br/>CliWrap -> git]

Key Features

Hook mode. dotnet-gitmoji init --mode shell installs a Husky.Net-managed prepare-commit-msg hook that intercepts every git commit. init --mode task-runner targets repos that already use Husky.Net’s task runner, and the hook skips merge commits, squash merges, amends, and interactive rebases so automated flows are not interrupted.

Client mode. dotnet-gitmoji commit acts as a drop-in for git commit, and also supports --title, --scope, and --message for cases where you want to skip part of the prompt. It is disabled when the hook is already installed, so the emoji never gets applied twice.

Team onboarding. The shared path lives in .config/dotnet-tools.json, .husky/, and Directory.Build.targets. In repos with a project file, teammates usually only need dotnet restore or an IDE open to restore the tools and re-establish core.hooksPath through Husky.Net.

Fuzzy search and discovery. dotnet-gitmoji search <keyword> and the live picker share the same fuzzy matcher, which searches by emoji name, shortcode, and description. dotnet-gitmoji list is the non-interactive way to inspect the full catalog.

Config surface. The interactive config wizard and repo-level .gitmojirc.json expose the same knobs: emoji versus shortcode output, optional scope and message prompts, title capitalization, custom scope suggestions, auto-stage, signed commits, and a custom gitmoji feed URL.

Config resolution chain. The tool reads .gitmojirc.json from the repo root first (walking up parent directories), then ~/.dotnet-gitmoji/config.json, then built-in defaults. Team settings live with the repo, personal overrides stay in the home directory.

Operational commands. update refreshes the cached gitmoji list, and remove handles hook teardown. For Husky.Net-managed hooks, remove prints the cleanup steps instead of silently editing .husky/ behind your back.

Local and global install parity. The tool detects whether it was installed globally or per-project, and writes the correct invocation (dotnet-gitmoji or dotnet tool run dotnet-gitmoji) into the generated hook script.

Technical challenges

Git invokes prepare-commit-msg with stdin redirected away from the terminal, so Spectre.Console’s interactive prompts refuse to draw. Without a fix, the hook fails on the very first commit.

I reopen stdin from the terminal device before any code reads Console.IsInputRedirected. The tool does this in Program.Main through a TtyConsoleInput.TryReopenStdin() helper, which is a harmless no-op when stdin is already a TTY (client mode).

public static async Task<int> Main(string[] args)
{
    TtyConsoleInput.TryReopenStdin();
    // DI container and CliFx application follow
}
A .NET tool can be installed globally or per-project, and the generated hook needs a different command in each case. Hard-coding one breaks the other.

I made InitCommand detect the installation kind at hook-generation time and write either dotnet-gitmoji hook or dotnet tool run dotnet-gitmoji hook into the script. The same logic feeds the Husky.Net shell and task-runner modes, so the three hook paths stay consistent.

// GitService.cs — detects local vs. global tool manifest at hook-generation time
private async Task<bool> IsLocalToolManifestAsync()
{
    var repoRoot = await GetRepositoryRootAsync();
    var manifestPath = Path.Combine(repoRoot, ".config", "dotnet-tools.json");

    if (!File.Exists(manifestPath))
        return false;

    var json = await File.ReadAllTextAsync(manifestPath);
    var node = JsonNode.Parse(json);
    var tools = node?["tools"] as JsonObject;
    return tools?.ContainsKey("dotnet-gitmoji") ?? false;
}

private async Task<string> BuildShellHookCommandAsync()
{
    var isLocal = await IsLocalToolManifestAsync();
    var invocation = isLocal ? "dotnet tool run dotnet-gitmoji" : "dotnet-gitmoji";
    return $"{invocation} \"$1\" \"$2\"";
}
Running both the hook and dotnet-gitmoji commit in the same repo would prefix the emoji twice. Detecting this late, at commit time, is fragile.

I disabled client mode whenever the hook is detected, at the very start of CommitCommand. The message explains why and points to remove as the opt-out. One tool, one place that writes the emoji.

// CommitCommand.cs — guard at the top of ExecuteAsync
public async ValueTask ExecuteAsync(IConsole console)
{
    if (await _gitService.IsHookInstalledAsync())
    {
        await console.Error.WriteLineAsync(
            "Error: The prepare-commit-msg hook is already configured to use dotnet-gitmoji.\n" +
            "Using both hook mode and client mode would apply the emoji twice.\n\n" +
            "Either:\n" +
            "  • Use 'git commit' and let the hook handle it (hook mode)\n" +
            "  • Remove the hook from .husky/prepare-commit-msg and use 'dotnet-gitmoji commit' (client mode)");
        throw new CommandException("Cannot use client mode while hook is installed.", 1);
    }

    // ... rest of commit flow
}

The same tool-manifest detection that powers hook generation also keeps this guard aligned with local installs, so the repo and personal paths stay consistent.

The gitmoji list lives at gitmoji.dev/api/gitmojis. Hitting the network on every commit would be slow and fragile, but shipping a stale list penalises teams that want the newest emojis.
I embedded gitmojis.default.json as a resource for the offline default, and exposed dotnet-gitmoji update to refresh a cached copy under ~/.dotnet-gitmoji/. GitmojiProvider reads cache first, falls back to the embedded default, and only hits HTTP on update.

What this project demonstrates

AreaEvidence
.NET 10 tool packagingPackAsTool, ToolCommandName, and PackageId in DotnetGitmoji.csproj. Ships to NuGet as dotnet-gitmoji and works as either a local or global tool.
CLI architectureCliFx commands resolved through DI, clean separation between Commands/, Services/, Validators/, and Models/.
Git interopCliWrap wraps every git call, the tool writes prepare-commit-msg hook scripts, and it integrates with Husky.Net's shell and task-runner modes while honoring local versus global installs.
Team onboarding automationCommitted .config/dotnet-tools.json, .husky/, and Directory.Build.targets let the hook bootstrap during restore or IDE startup instead of through manual shell setup on every machine.
Terminal UXSpectre.Console selection prompts, fuzzy search by name and shortcode, optional scope and message prompts, and client-mode flags for pre-filling commit data.
TestabilityInterface-first services, xUnit unit tests, NSubstitute for doubles, and a ToolIntegrationFixture for end-to-end coverage.
Config layeringRepo .gitmojirc.json, personal global config under ~/.dotnet-gitmoji/, and built-in defaults, resolved in that order, with a config wizard for the personal path.

Results

OutcomeEvidence
Published on NuGetThe tool is published on NuGet as dotnet-gitmoji, which keeps installation and updates inside the standard .NET toolchain.
Removes the Node.js dependencyA .NET repo that wants the gitmoji convention no longer has to provision Node on every machine. The .NET SDK is enough.
Team-friendly onboardingRepos can commit the tool manifest, Husky.Net hook, and restore target so teammates get the prompt back through restore or IDE startup instead of manual machine-by-machine setup.
Two adoption pathsTeams can enforce the prepare-commit-msg hook, while individuals can still use dotnet-gitmoji commit with flags when a repo-wide hook would be too heavy.
Config travels with the repo.gitmojirc.json at the repo root is shared through Git. Personal preferences stay under ~/.dotnet-gitmoji/, and the interactive wizard fills the same config shape.

The product read is simple: a .NET team can adopt the gitmoji convention without leaving the NuGet and MSBuild toolchain, and the same tool still works as a lighter personal CLI when a repo-wide hook would be too heavy.

  1. Repository: dotnet-gitmoji ➡️
  2. NuGet package: dotnet-gitmoji ➡️
  3. gitmoji convention: gitmoji.dev ➡️
  4. Husky.Net: Husky.Net ➡️