Skip to content

Development Workflow

This page covers the day-to-day developer loop for the Cargonerds solution: prerequisites and one-time setup, the branching model, the formatting hooks (CSharpier + Prettier), the mandatory git-worktree rule, building, and running. It explains both what to do and why this repo is wired the way it is, so the pre-commit hook and CI never surprise you.

For the conceptual layout of the solution (projects, common.props, Directory.Packages.props, the .sln/.slnx/per-module solution set), see Solution Structure. For running the app, see Running Locally.

Prerequisites

The authoritative tool list is the root README.md "Pre-requirements" section, reconciled with what the code actually requires:

Tool Version Why
.NET SDK 10 (net10.0) All first-party projects target net10.0 (see common.props / each .csproj). CI provisions 10.0.x.
Node.js 22 The Next.js frontend/realtime app declares engines.node: 22.x; a pre-commit guard (.githooks/validate-node-version.mjs) enforces it. The README's "Node 18/20" note predates the realtime app.
Docker current Aspire spins up SQL Server / Redis / RabbitMQ containers for local runs, and Testcontainers spins up SQL Server / Redis / RabbitMQ / Azurite for the integration tests.
PowerShell 7 (pwsh) current Required by .githooks/install.ps1 and the deploy scripts under .scripts/. Ships natively on Windows; install PowerShell Core on Linux/macOS.
Signing certificate openiddict.pfx for production OpenIddict. See the README for the dotnet dev-certs / openssl commands.

The README and .abpsln understate the framework version

The root README.md says ".NET 9.0+" and Cargonerds.abpsln still records AbpFramework 10.0.0 / LeptonX 5.0.0 / net9.0. These are stale. The build truth is common.props: <AbpVersion>10.1.1</AbpVersion>, <LeptonXVersion>5.1.1</LeptonXVersion>, and every project's TargetFramework is net10.0. Always trust common.props and the .csproj over the README/.abpsln.

One-time setup

After cloning, run the hook installer once:

pwsh .githooks/install.ps1

.githooks/install.ps1 does four things:

  1. Sets git config core.hooksPath .githooks so the repo's own hooks are active.
  2. Writes worktree-exclusion entries to .git/info/exclude (not .gitignore — this is load-bearing; see Git worktrees).
  3. Runs dotnet tool restore to install the local .NET tools (CSharpier + Husky.Net) from .config/dotnet-tools.json.
  4. Runs npm i in frontend/ (installs Prettier and the rest of the npm workspace).

Two more steps are needed before the app will run end-to-end:

abp install-libs                 # install client-side libraries (run once after cloning)

The database is created/seeded by Cargonerds.DbMigrator. You normally don't run it by hand — the Aspire AppHost runs the migrator for you on startup. See Migrations.

What dotnet tool restore installs

The local tool manifest .config/dotnet-tools.json pins exactly two tools: csharpier 1.2.6 (with "rollForward": false, so the version is reproducible) and husky 0.7.2. They are restored into the repo, not installed globally, so every clone formats identically.

Branching & commits

  • Branch off main. main is the default branch and the base for most PRs.
  • Opening a PR against main (or develop) triggers .github/workflows/pr-validation.yml, which restores, builds Cargonerds.All.sln in Release, and runs the formatting checks (see CI parity).
  • Pushing a feature branch deploys it to an ephemeral Azure Container Apps environment named after the (sanitised) branch. main, production and staging are protected branches (PROTECTED_BRANCHES="main|production|staging" in the deploy workflow) and are never auto-torn-down. Everything else gets cleaned up by .github/workflows/teardown.yml. See Deployment Overview.

Commits are reformatted for you

You do not need to run a formatter before committing — the pre-commit hook does it and re-stages the result, so the committed bytes are already formatted. The catch: the hook only runs from a client that uses core.hooksPath (set by install.ps1). See the next section for how it behaves in GUI git clients.

Code formatting (CSharpier + Prettier)

This repo enforces formatting through a Husky.Net pre-commit hook. Two formatters run:

  • CSharpier — formats all C# files (**/*.cs).
  • Prettier — formats staged frontend/ files (ts, tsx, js, jsx, mjs, cjs, json, css, md).

How the hook is wired

The hook entry point is .githooks/pre-commit (a POSIX sh script). It does three things:

# .githooks/pre-commit (abridged)
# 1. GUI git clients (Rider, Fork, etc.) don't always inherit shell PATH — prepend dotnet locations.
PATH="$HOME/.dotnet:$HOME/.dotnet/tools:/usr/local/share/dotnet:...:$PATH"

# 2. Capture the originally-staged files, then run the Husky task group.
STAGED=$(git diff --cached --name-only --diff-filter=ACMR)
dotnet husky run --group pre-commit

# 3. Re-stage everything that was originally staged (picks up csharpier + prettier mutations).
echo "$STAGED" | while IFS= read -r f; do [ -n "$f" ] && git add -- "$f"; done

dotnet husky run --group pre-commit reads .githooks/task-runner.json, which defines three tasks in the pre-commit group:

Task Command Runs against
csharpier dotnet csharpier format . **/*.cs (whole solution)
prettier node node_modules/prettier/bin/prettier.cjs --write ${staged} (cwd frontend) staged frontend/** of the listed extensions
guard-node-version node .githooks/validate-node-version.mjs runs when frontend/realtime/Dockerfile, its package.json, or pr-validation.yml is staged
flowchart TD
    A[git commit] --> B[".githooks/pre-commit (sh)"]
    B --> C[Prepend dotnet to PATH]
    C --> D[Record staged file list]
    D --> E["dotnet husky run --group pre-commit"]
    E --> F["csharpier: dotnet csharpier format ."]
    E --> G["prettier --write \${staged}"]
    E --> H["guard-node-version (conditional)"]
    F --> I[Re-stage originally staged files]
    G --> I
    H --> I
    I --> J[Commit created with formatted bytes]

CSharpier configuration

CSharpier reads two files at the repo root:

  • .csharpierrc.yaml — the style:

    printWidth: 120
    useTabs: false
    tabWidth: 4
    endOfLine: auto
    
  • .csharpierignore — paths CSharpier skips, so scaffolded/non-code files are left alone:

    **/Migrations/       # EF Core migrations are scaffolded — formatting them is noise
    **/*.config
    **/*.csproj
    **/*.xml
    **/*.props
    **/*.Config
    

Running the formatters manually

dotnet csharpier format .                    # format all C#
cd frontend && npx prettier --write .        # format all frontend files

GUI git clients and PATH

GUI clients (Rider, Fork, GitHub Desktop, …) often launch hooks without your shell PATH, so dotnet may not be found. .githooks/pre-commit prepends the common .NET install locations to work around this and aborts with a clear message if dotnet is still missing. If your SDK lives somewhere unusual, add that path to the PATH= line in .githooks/pre-commit.

CSharpier honours .gitignore — which is why worktrees go in .git/info/exclude

CSharpier skips anything matched by .gitignore. This single fact is the reason for the worktree rule below: a worktree under a .gitignore-d path would be invisible to CSharpier, so unformatted C# could slip through and break CI. Keep reading.

Git worktrees (mandatory rule)

When you (or an AI agent) create a git worktree, place it only under one of:

  • .claude/worktrees/<name> — preferred (Claude Code's default)
  • .worktrees/<name>

Do not create worktrees anywhere else, and never add a worktree path to .gitignore. This is documented for agents in AGENTS.md (referenced from CLAUDE.md).

Why it is mandatory, not a preference

These directories are excluded from git per-clone via .git/info/exclude, written by .githooks/install.ps1:

# .githooks/install.ps1 (abridged)
$gitCommonDir = (git rev-parse --git-common-dir).Trim()
$excludeFile  = Join-Path $gitCommonDir "info/exclude"
foreach ($entry in @("/.claude", ".worktrees")) {
    if ($existing -notcontains $entry) { Add-Content -Path $excludeFile -Value $entry }
}

The distinction between .git/info/exclude and .gitignore is the whole point:

Tracked by git? Seen by CSharpier?
Worktree under .claude/worktrees/ or .worktrees/ (excluded via .git/info/exclude) No (stays untracked) Yes — C# inside it still gets formatted
Worktree under a path you added to .gitignore No No — CSharpier goes blind

Because CSharpier honours .gitignore but ignores .git/info/exclude, a worktree in the right place is invisible to git and visible to CSharpier. Put a worktree elsewhere and one of two things breaks:

  1. Someone adds its path to .gitignore → CSharpier reports Formatted 0 files, unformatted C# is committed, and CI's dotnet csharpier check . fails.
  2. The path is neither ignored nor excluded → it pollutes git status and invites accidental commits of working-copy files.

Never .gitignore a worktree

If you add a brand-new worktree root, add it to .git/info/exclude and to the list in .githooks/install.ps1 — never to .gitignore.

Building

The repo ships several solution files describing different slices of the same projects (see Solution Structure for the full breakdown):

Solution Scope Typical use
Cargonerds.sln main app only (src/ + test/ + Aspire) day-to-day app work in an IDE
Cargonerds.All.sln / Cargonerds.All.slnx everything incl. Hub + Pricing modules CI and full test runs
modules/hub/Hub.sln Hub module only isolated Hub work
modules/pricing/Pricing.sln Pricing module only isolated Pricing work
# restore + build the full solution the way CI does
dotnet restore Cargonerds.All.sln
dotnet build   Cargonerds.All.sln --configuration Release

No version numbers on <PackageReference>

The repo uses Central Package Management: versions live in Directory.Packages.props, and the ABP/Aspire/LeptonX version variables ($(AbpVersion), etc.) live in common.props. Bumping ABP is a one-line edit to AbpVersion in common.props. Don't add Version="…" to a PackageReference.

Running locally

The day-to-day inner loop:

  1. Start everything with Aspire:

    dotnet run --project src/Cargonerds.AppHost
    

    The AppHost provisions SQL Server / Redis / RabbitMQ as containers, runs db-migrator, then starts auth, api, admin (Blazor) and the realtime (Next.js) frontend, and opens the Aspire dashboard. It fails fast with a clear message if Docker isn't running. See Running Locally for the dashboard, run-mode ports and the Azure-Spark-DB option.

  2. Make your changes.

  3. Run the relevant tests (dotnet test, or npm run test in frontend/realtime). See Testing.
  4. Commit — the pre-commit hook formats your code automatically.

Working with ABP Studio

The solution is fully described to ABP Studio (and the ABP CLI), so you can manage it from Studio's UI as well as the command line:

  • Cargonerds.abpsln — the ABP Studio solution descriptor: module list, an Aspire runProfile, a k8sProfile, Helm/Kubernetes commands, and the original createCommand (abp new Cargonerds -t app --tiered --ui-framework blazor …).
  • Cargonerds.abpmdl (and modules/hub/Hub.abpmdl, modules/pricing/Pricing.abpmdl) — per-package module maps that tell Studio which .csproj belongs to which ABP package.
  • etc/abp-studio/run-profiles/Default.abprun.json — the run profile listing each host project, its launch URL and execution.order (so Studio starts them in the right sequence).
  • etc/abp-studio/k8s-profiles/local.abpk8s.json — the local Kubernetes profile.
  • The host projects (Cargonerds.AuthServer, Cargonerds.HttpApi.Host, Cargonerds.Web.Public) reference the Volo.Abp.Studio.Client.AspNetCore package so they integrate with Studio's monitoring while running.

ABP Studio analysis mode

Several modules guard Redis/SQL-touching configuration behind AbpStudioAnalyzeHelper.IsInAnalyzeMode (e.g. CargonerdsEntityFrameworkCoreModule, CargonerdsHttpApiHostModule, CargonerdsAuthServerModule, CargonerdsWebPublicModule). This lets ABP Studio statically analyze the solution without connecting to live infrastructure. If you add config that opens a Redis/SQL connection at module-config time, add the same guard or Studio analysis will try to connect and fail.

ABP Studio is also the GUI front-end for the abp CLI used in Code Generation (API/proxy generation). For reading framework internals, abpSrc/abp-get-all-src.ps1 fetches the ABP/LeptonX source locally via abp get-source; that tree is git-ignored and is not referenced by any project.

Running tests

The integration tests boot the real ABP application against real infrastructure started in Docker via Testcontainers (SQL Server, Redis, RabbitMQ, Azurite) — so a running Docker daemon is required.

# whole suite, the way CI runs it
dotnet test "Cargonerds.All.sln" --configuration Release --no-build --nologo -- --ignore-exit-code 8

# a single test project
dotnet test test/Cargonerds.Application.Tests

# filter by test name
dotnet test --filter "FullyQualifiedName~SampleRepositoryTests"

Why -- --ignore-exit-code 8

The test projects use xunit.v3 with Microsoft.Testing.Platform (TestingPlatformDotnetTestSupport=true in common.props), so each builds a self-contained test executable. Arguments after -- go to that executable. Cargonerds.All.sln contains projects with no tests (and a console helper app); MTP returns exit code 8 ("no tests ran") for those, so --ignore-exit-code 8 keeps the overall run green. Don't remove it blindly.

See Testing for the full harness (DockerFixture, SharedTestContainers, the SparkEnvironment("local") trick, Respawn, and what is mocked vs. run for real).

Database migrations

Cargonerds.DbMigrator is the startup project for EF Core tooling. Add a migration with:

dotnet ef migrations add MyMigration \
  -p src/Cargonerds.EntityFrameworkCore \
  -s src/Cargonerds.DbMigrator \
  -c CargonerdsDbContext

Then run the migrator (or restart the AppHost, which runs it for you). CargonerdsDbContext is the "Default" database; the Hub and Pricing modules have their own contexts and connection strings. See Migrations and Entity Framework.

Versioning

The app version is held in the root common.props (<Version>, currently 3.1.4.0). CI bumps it via .scripts/UpdateVersion.ps1, producing the chore: bump version to … commits — you normally don't edit it by hand.

Module versions are independent

modules/hub/common.props and modules/pricing/common.props carry their own <Version>1.0.0</Version>, separate from the app's 3.1.4.0. Only the root common.props <Version> is what UpdateVersion.ps1 bumps.

SPARK environment selection

Which Hub database the app talks to is chosen by the SPARK_ENVIRONMENT variable (dev / test / prod / local), which picks the matching appsettings.spark.<env>.json. For local development against your own Hub DB, create the git-ignored override src/Cargonerds.HttpApi.Host/appsettings.spark.local.user.json (see the README "Local Development Setup" and Configuration).

CI parity: what the pipeline checks

.github/workflows/pr-validation.yml runs on pull requests to main and develop. To match it locally before pushing:

CI step Command Local equivalent
Restore dotnet restore Cargonerds.All.sln same
Build dotnet build Cargonerds.All.sln --no-restore --configuration Release same
Restore tools dotnet tool restore done by install.ps1; re-run if the manifest changed
Check C# formatting dotnet csharpier check . dotnet csharpier format . (then commit)
Check frontend formatting npx prettier --check . (in frontend/) cd frontend && npx prettier --write .

check vs format

CI runs CSharpier/Prettier in check mode (fails on any diff, changes nothing). The pre-commit hook runs them in write mode. If you commit through the hook, the check passes; the only way to fail it is to bypass the hook (e.g. git commit --no-verify or a client that isn't using core.hooksPath).