15 min readMehdi Hadeli

Multi-Repo Workspace Setup for GitHub Copilot CLI: Context Across .NET Service Boundaries

Multi-Repo Workspace Setup for GitHub Copilot CLI: Context Across .NET Service Boundaries

Part of a three-post series on GitHub Copilot CLI for multi-repo .NET work. If you want the follow-up pieces, continue with Repo Registry for GitHub Copilot CLI: Service Discovery for Multi-Repo .NET Workflows and Per-Service Copilot Instructions for GitHub Copilot CLI: Keep Service Rules Close to the Repo.

Runnable sample: samples/multi-repo-workspace. It includes a sample WORKSPACE.md, startup and helper scripts, plus a shell test that creates temporary Git repos and validates the workflow.

Most articles about AI coding tools stop at the single-repo case. That is the simple version. Real .NET systems are usually messier than that.

You might have one ASP.NET Core API in its own repository, a shared contracts package in another, a background worker somewhere else, an Angular frontend in a fourth repo, and Terraform or Bicep sitting off to the side. None of that is unusual. It is just how a lot of teams work.

The trouble starts when one change crosses all of them.

Say you add a PreferredLanguage field to a customer profile. That sounds simple. In practice it can touch the API contract repo, the Web API implementation, a shared NuGet package, an Angular client, and the deployment config that sets a new default value. If you use GitHub Copilot CLI in only one of those repositories at a time, you spend half the session restating how the system fits together.

That is the part I wanted to fix.

This post is not another monorepo argument. It is a practical setup for teams that already have separate repositories and want GitHub Copilot CLI to be more useful across them. The core idea is simple: keep related repos under one workspace root, document how they fit together, and start Copilot CLI from that shared root instead of from whichever folder happened to be open first.

The Problem Is Not Copilot CLI. It Is Missing Context

GitHub Copilot CLI is good at local tasks. Ask it to explain a controller, draft a regular expression, suggest a shell command, or help inspect a test failure, and it usually does fine. The quality drops when the task depends on facts that live outside the current repo.

That happens a lot in .NET systems because the boundaries are usually explicit:

  • Company.Contracts defines DTOs and versioned request models.
  • Company.Customers.Api implements the HTTP endpoints.
  • Company.Customers.Worker handles asynchronous jobs from the same domain.
  • Company.Web consumes the API.
  • Company.Infrastructure provisions app settings, storage, queues, and deployment wiring.

If you ask Copilot CLI to update the API in isolation, it may give you an answer that is locally correct and still wrong for the system. It might suggest a DTO change without noticing the shared package versioning rule. It might rename a field in the API but miss the worker or the frontend. It might tell you to add a config value that already exists under a different name in the infra repo.

That is not a model failure. It is an environment design failure.

A Concrete .NET Example

Here is a change request that looks small on paper:

"Add InvoiceDueDateUtc to the billing response and show it in the Angular admin portal."

In a typical .NET shop, that can mean all of the following:

  1. Update the response model in Company.Billing.Contracts.
  2. Bump the NuGet package version.
  3. Update Company.Billing.Api so the endpoint maps the new property.
  4. Update Company.Admin.Angular to consume and display it.
  5. Update tests in both repos.
  6. Check whether Company.Jobs.Worker also reads the old response shape.

If Copilot CLI only sees Company.Admin.Angular, it will treat the problem as a UI task. If it only sees Company.Billing.Api, it will treat it as a backend task. The useful answer depends on seeing the chain, not one link.

That is why I prefer a workspace layout built around the system instead of around a single repository.

The Workspace I Actually Want

My preferred layout for this kind of work looks like this:

~/workspace/platform-workspace/
├── WORKSPACE.md
├── start-copilot.sh
├── scripts/
│   ├── repo-status.sh
│   └── repo-sync.sh
├── Company.Billing.Contracts/
├── Company.Billing.Api/
├── Company.Jobs.Worker/
├── Company.Admin.Angular/
└── Company.Infrastructure/

This sample is intentionally plain. WORKSPACE.md carries the shared operating context, start-copilot.sh makes the workspace root the consistent launch point, and scripts/ holds repeatable support commands that work across the child repos. The service folders stay separate Git repositories, which is the whole point: better shared context without pretending the system is a monorepo.

Nothing fancy is happening here. Each folder is still its own Git repository. The workspace root is just a parent directory with a few support files.

What changes is how you work.

Instead of asking Copilot CLI to help inside one repo and manually pasting context from the others, I start the session at the workspace root and let the directory structure do some of that explanatory work.

The Most Important File Is Not Code

The file that matters most is WORKSPACE.md.

I do not use it as a design document. I use it as a working map. It answers the questions that usually waste the first ten minutes of an AI-assisted session:

  • Which repo owns the contract?
  • Which repo publishes the shared package?
  • Which consumers need to be checked when a contract changes?
  • Where do generated clients or integration tests live?
  • In what order should a cross-repo change happen?

Here is the actual WORKSPACE.md shape from the sample:

# Platform Workspace

## Repositories

| Repo                        | Stack               | Responsibility                          |
| --------------------------- | ------------------- | --------------------------------------- |
| `Company.Billing.Contracts` | .NET class library  | Shared DTOs and package versioning      |
| `Company.Billing.Api`       | ASP.NET Core        | Billing endpoints and validation        |
| `Company.Jobs.Worker`       | .NET Worker Service | Async processing and queue consumers    |
| `Company.Admin.Angular`     | Angular             | Internal admin UI                       |
| `Company.Infrastructure`    | Bicep / Terraform   | App config, queues, storage, deployment |

## Ownership Rules

- Request and response models live in `Company.Billing.Contracts`
- Breaking contract changes require version notes before release
- API validation rules belong in `Company.Billing.Api`
- Queue payload compatibility must be checked in `Company.Jobs.Worker`

## Cross-Repo Change Order

1. Contracts
2. API
3. Worker consumers
4. Angular UI
5. Infra only if settings, secrets, queues, or deployment behavior changed

## High-Value Paths

- `Company.Billing.Contracts/src/Invoices/`
- `Company.Billing.Api/src/Endpoints/Invoices/`
- `Company.Admin.Angular/src/app/billing/`
- `Company.Jobs.Worker/Consumers/`

What makes this sample useful is not the formatting. It is the separation of concerns inside the file. The repository table tells Copilot CLI what exists, the ownership rules tell it where edits should begin, the change order prevents random cross-repo sequencing, and the high-value paths give it concrete places to inspect first. If one of those sections is missing, the assistant has to infer more than it should.

This is enough. It does not need architecture diagrams, company slogans, or a three-page write-up about domain-driven design. It just needs to be accurate and kept up to date.

The important part is that the file is actionable. A repo table without ownership rules is just a directory listing. Ownership rules without change order still leave the assistant guessing about sequence. High-value paths turn the file from passive documentation into a practical starting map.

Start From the Workspace Root on Purpose

I also like to make session startup explicit. The sample includes a small launcher that is slightly more defensive than the stripped-down version I showed above:

#!/usr/bin/env bash
# start-copilot.sh

set -euo pipefail

workspace_root="${WORKSPACE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
repos=(
    "Company.Billing.Contracts"
    "Company.Billing.Api"
    "Company.Jobs.Worker"
    "Company.Admin.Angular"
    "Company.Infrastructure"
)

if [[ ! -d "$workspace_root" ]]; then
    echo "Workspace not found: $workspace_root" >&2
    exit 1
fi

missing=0
for repo in "${repos[@]}"; do
    repo_path="$workspace_root/$repo"
    if [[ ! -d "$repo_path" ]]; then
        echo "Missing repo: $repo_path" >&2
        missing=1
    fi
done

if [[ "$missing" -ne 0 ]]; then
    exit 1
fi

cd "$workspace_root"

if [[ "${1:-}" == "--dry-run" ]]; then
    echo "Workspace ready: $workspace_root"
    echo "Would run: gh copilot"
    exit 0
fi

if command -v gh >/dev/null 2>&1; then
    gh copilot
else
    echo "GitHub CLI is not installed. Run with --dry-run to validate the workspace layout." >&2
    exit 1
fi

This version is worth showing in full because the details matter. WORKSPACE_ROOT makes the sample portable, the missing-repo check turns partial clones into a hard failure instead of a soft surprise, and --dry-run gives you a safe way to validate the layout before you try to launch anything. The final gh check is also practical. On a clean machine, the difference between "your workspace is broken" and "the GitHub CLI is not installed" is the difference between a useful setup script and a frustrating one.

This does two useful things.

First, it makes the workspace root the default starting point, which means relative paths in prompts stay stable. Second, it catches missing clones immediately. That sounds obvious, but it saves time. A surprising number of confusing AI sessions are really just local environment mistakes.

Why This Works Better for .NET Teams

.NET projects often have strong boundaries: solution files, class libraries, test projects, Workers, APIs, client apps, package feeds, deployment manifests. That structure is good for maintainability, but it also means changes often move in a predictable direction.

For example, if you modify a DTO in a shared class library, the likely path is:

  1. Update the contract library.
  2. Rebuild or repack the package.
  3. Update the API that references it.
  4. Update downstream consumers.
  5. Run targeted tests.

That flow is exactly the kind of thing Copilot CLI can reason about well, but only if the repos are visible together and the ownership rules are documented somewhere short and blunt.

When you give the assistant a workspace like this, your prompts can be more direct:

  • "From the workspace root, trace where InvoiceDueDateUtc should be added and list the affected repos in implementation order."
  • "Check whether Company.Jobs.Worker consumes billing contract models directly or through a separate queue message type."
  • "Compare app setting names used by Company.Billing.Api with the infra definitions and flag mismatches."
  • "After bumping Company.Billing.Contracts, show which .csproj files still reference the previous package version."

Those prompts work much better than: "I changed something in another repo, can you help me update this one?"

Git Rules Do Not Change

One thing this setup does not change is Git discipline. The workspace root is not a Git repo. It is just a directory.

So your commands should always target a concrete repository.

Bad:

git status

That command is "bad" here because it answers the wrong question. From the workspace root, there is no single repository state to inspect, so the result is either an error or a misleading habit.

Better:

git -C ./Company.Billing.Api status
git -C ./Company.Admin.Angular log --oneline -5

These examples are better because each command names the repo that owns the state you care about. git -C is a simple pattern, but it matters in multi-repo work because it keeps every check tied to a concrete repository boundary.

For daily work, I like a quick status script. Here is the one from the sample:

#!/usr/bin/env bash
# scripts/repo-status.sh

set -euo pipefail

workspace_root="${WORKSPACE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
repos=(
    "Company.Billing.Contracts"
    "Company.Billing.Api"
    "Company.Jobs.Worker"
    "Company.Admin.Angular"
    "Company.Infrastructure"
)

for repo in "${repos[@]}"; do
    path="$workspace_root/$repo"

    if [[ ! -d "$path" ]]; then
        echo "[$repo] missing"
        continue
    fi

    branch=$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
    dirty=$(git -C "$path" status --short 2>/dev/null | wc -l | tr -d ' ')

    echo "[$repo] branch=$branch dirty=$dirty"
done

The small improvement over the earlier sketch is the root calculation. By resolving the workspace relative to the script location, the command still works after the workspace moves to another machine or folder. The rest stays intentionally simple: branch plus dirty count is enough context for most cross-repo work without turning the script into a mini dashboard.

This is not glamorous, but it is genuinely useful before you ask Copilot CLI for a plan that spans multiple repos. You want to know whether you are on the right branches and whether local changes already exist.

Keep Restore, Build, and Test Close to the Repos That Own Them

One mistake I see a lot is treating multi-repo work as if all validation should happen from one command surface. That is not usually how these systems are built.

In a .NET workspace, I prefer to keep validation repo-local:

  • Run dotnet test in the repo that owns the changed project.
  • Run dotnet restore or dotnet build where package references actually changed.
  • Only run broader integration checks when a contract or config change justifies it.

That also gives Copilot CLI better boundaries. Instead of asking it vague questions like "make sure this still works," ask for focused checks:

  • "List the test projects in Company.Billing.Api that should run after this DTO change."
  • "Find all references to Company.Billing.Contracts package version 2.4.1 in this workspace."
  • "Show whether the Angular billing table binds the invoice data directly to the API response model or maps it through a client-side view model."

Those are realistic .NET maintenance questions. They usually lead to better output.

Keep a Sync Script, but Do Not Make It Too Clever

I also keep a light repo sync script. The trick is not to automate so much that it surprises you. Here is the sample version:

#!/usr/bin/env bash
# scripts/repo-sync.sh

set -euo pipefail

workspace_root="${WORKSPACE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
repos=(
    "Company.Billing.Contracts"
    "Company.Billing.Api"
    "Company.Jobs.Worker"
    "Company.Admin.Angular"
    "Company.Infrastructure"
)

for repo in "${repos[@]}"; do
    path="$workspace_root/$repo"

    if [[ ! -d "$path" ]]; then
        echo "Skipping missing repo: $repo" >&2
        continue
    fi

    if ! git -C "$path" remote get-url origin >/dev/null 2>&1; then
        echo "[$repo] no origin configured"
        continue
    fi

    echo "Fetching $repo"
    git -C "$path" fetch origin
done

The important addition here is the origin check. In real workspaces, one repo is always half-cloned, experimental, or temporarily disconnected. Skipping that case explicitly is better than letting one odd repo abort the entire refresh. The script still does only one mutating operation, fetch, which is exactly why it remains safe.

I intentionally keep this to fetch in most environments. Auto-rebasing five repos at once is the kind of convenience that turns into a cleanup job.

The goal is not to hide state from yourself. The goal is to spot drift early enough that Copilot CLI is reasoning over current code instead of stale local copies.

If you want a quick confidence check after copying these files, the sample also includes a shell test:

bash ./tests/test-workspace.sh

That test creates temporary Git repositories, marks one repo dirty, runs the helper scripts, and verifies the expected output. I like including that detail in the article because it makes the setup feel less like a blog sketch and more like something you can actually lift into your own tooling.

Where Copilot CLI Helps Most

For me, the sweet spot is not "write everything for me." It is cross-repo reasoning plus local acceleration.

It is useful for:

  • tracing a property from contract library to API to UI
  • locating all consumers of a shared options class
  • finding version mismatches across .csproj files
  • comparing configuration keys between app code and infra code
  • drafting narrow commands when you already know the repo order

It is less useful when the workspace itself is messy, undocumented, or stale. If the repos use inconsistent naming, if contract ownership is unclear, or if nobody knows whether the Worker reads queue messages from a shared package or an internal type, the assistant will mirror that confusion.

The real lesson here is operational, not magical. Better AI results usually come from a better local setup.

A Good Multi-Repo Prompt Sounds Like an Engineer, Not a Demo

I would avoid polished demo prompts and use the ones you would actually type during work.

Examples:

  • "Look at Company.Billing.Contracts and tell me whether adding a non-nullable DateTime here will break existing consumers."
  • "Search the workspace for InvoiceSummaryResponse and separate direct contract usage from manually mapped view models."
  • "Show the minimum repo sequence to add a new queue payload field without breaking the current worker."
  • "Compare appsettings.json keys in Company.Billing.Api with the infrastructure definitions for the billing service."

These work because they are grounded in actual code ownership and actual repo boundaries.

The Limitation Is Still Human Coordination

This setup helps, but it does not solve release coordination, versioning policy, or branch strategy.

If Company.Billing.Contracts publishes a new package version, somebody still has to decide when Company.Billing.Api and Company.Admin.Angular adopt it. If one repo is on a hotfix branch and another is on a feature branch, the workspace does not magically reconcile them. If the infra repo is managed by a different team, Copilot CLI can point to the necessary change, but it cannot fix your process.

Still, that is fine. The goal is not to erase coordination. The goal is to stop wasting time on avoidable context setup.

Closing Thought

If you use GitHub Copilot CLI with .NET systems that span multiple repositories, the best improvement usually is not a smarter prompt. It is a better workspace.

Put the related repos under one root. Add a short WORKSPACE.md that says who owns what. Launch Copilot CLI from there on purpose. Keep a few boring scripts around for status and sync. Then ask repo-aware questions that reflect how your system actually changes.

If you want to take the next step after the shared workspace layout, the follow-on piece is Repo Registry for GitHub Copilot CLI: Service Discovery for Multi-Repo .NET Workflows. That approach turns the workspace from a well-organized directory into a live query surface for repo status, dependency discovery, and cross-repo search.

That is enough to turn Copilot CLI from a per-folder helper into something much closer to a practical cross-repo assistant.

It is not perfect. But it is finally pointed at the real problem.