Skip to main content

Git Providers

Brabo works on top of a real git repository, and three backends serve that role: Local (bare repos on disk), GitHub, and GitLab. The domain code doesn't know which one is in use — it speaks to a single contract.

Decisions in ADRs 0001 through 0005 and 0028.

The contract​

GitProviderContract, in packages/shared/src/index.ts. Fifteen operations — the tenth entered in Phase 4a, with the PR gates; the 11th and 12th in PHASE 26, with the Code tab (read-only); the 13th, 14th, and 15th in PHASE 26b, the foundation of that same tab's declared pending items (blame, navigable PRs, rich branch — no UI consuming them yet):

operationreturns
createRepoGitRepo
getRepoGitRepo
createBranchGitBranch
protectBranch—
listBranchesGitBranch[]
commitFilesGitCommitResult
getFileContentstring | null — null if the file (or the branch) doesn't exist
openPullRequestGitPullRequest
mergePullRequestGitPullRequest — a PR already merged is refused (GitPullRequestAlreadyMergedError in local; GitHub refuses the repeated merge itself), RN-663. In local it is a real merge: fast-forward when the target is an ancestor, otherwise a two-parent merge commit; a conflict throws GitMergeConflictError (files listed, target ref untouched, PR stays open, HTTP 409 merge_conflict). GitHub/GitLab refuse a conflicting merge through their own API, untranslated, RN-704
commentOnPullRequest— (QA/SecOps verdict on the PR)
listTreeGitTree | null — null if the ref or the path don't exist
getPullRequestDiffGitPullRequestDiff | null — null if the PR doesn't exist
blameGitBlame | null — null if the file (or the ref) doesn't exist
listPullRequestsGitPullRequestList — summary per PR, not GitPullRequest[]
listBranchesDetailedGitBranchDetailList — ahead/behind/associated PR per branch

Two more fields: name and capabilities.

The two read operations (PHASE 26)​

listTree lists one level of the tree, never the whole tree: the Code tab navigates on demand, and asking for everything in a large repository is the traffic amplifier the phase forbids. A missing or "" path is the root; each entry carries the full path and name (the leaf).

getPullRequestDiff normalizes the diff into status (added|modified|removed|renamed), additions, deletions, and patch. The patch is string | null, and the distinction matters: null means it didn't come back (binary, or a patch too large for the response), while "" would mean "it came back empty". Collapsing the two would make the screen say "no changes" for a changed binary.

Both absences follow the vocabulary getFileContent already used — null, not an exception — so the Code tab treats "doesn't exist" in a single way.

Ceilings. Both cut off, and warn via truncated: true. The numbers live in apps/api/src/domain/git/git-read-limits.ts (1000 entries per level, 300 files per diff) and not in packages/shared, which is 100% types — an export const there survives tsc and breaks the api's boot in production (guarded by apps/api/test/packages-shared-so-tipos.spec.ts).

The three foundation operations (PHASE 26b — RN-110/111/112)​

Foundation for the three declared pending items of the Code tab (blame, rich branch dropdown, navigable PR list) — the UI for each is a later wave, in three separate agents. All three follow the absence vocabulary getFileContent/listTree/getPullRequestDiff already used: null, never an exception, when the resource doesn't exist.

blame(ref, path) annotates each line with the commit that last touched it — sha, author, date, first line of the message. It's the only operation that speaks GraphQL: GitHub's REST API has no blame, only the GraphQL API (repository.object(expression:).blame(path:)). GitLab uses RepositoryFiles.allFileBlames; Local uses git blame --porcelain. It cuts off at GIT_BLAME_LINE_LIMIT (2000 lines).

listPullRequests(state?) returns GitPullRequestSummary[] — id, number, title, author, state, branches, updatedAt — not GitPullRequest[], which is the WRITE type (open/merge) and never had a title or author. local lists from the SAME sidecar PR store from Phase 4a. It cuts off at GIT_PR_LIST_LIMIT (100, one page, no follow-up pagination).

listBranchesDetailed(defaultBranch) is its own operation, not an extension of listBranches — see the capabilities table below for why. Each enriched branch gets ahead/behind (relative to defaultBranch, which the CALLER already knows and passes — asking the provider for it again would be one more call) and the open associated PR, if any. null in both numbers when the provider can't compute it (orphan branch, unrelated history) — honest degradation, never a made-up number. It cuts off at GIT_BRANCH_DETAIL_LIMIT (30 branches).

The CALL ceiling, and the CONSUMPTION ceiling (PHASE 26b)​

The two numbers above limit what a provider returns in one call. The HTTP surface that consumes them has its own ceilings, in the same file, and they answer a different question: how many CALLS a single client request can trigger. listTree is cheap once and expensive a thousand times.

What forces the distinction is the Code tab search, which is not an operation of this contract — none of the three providers has it. GitHub and GitLab have platform-level code search, with their own semantics and limits; LocalGitProvider is a bare repo and has nothing like that. Declaring it here would mean either a 13th operation with capability false on local (a tab that disappears on one provider), or a platform's vocabulary leaking into the normalized contract that exists precisely to prevent that.

So it stays composed at the application layer (application/use-cases/git/read-project-code.use-case.ts), on top of listTree + getFileContent, with three budgets — directories traversed, files opened, and matches returned — plus a short-TTL cache (domain/git/git-read-cache.ts) so browsing and searching don't repeat the same calls. Who pays is the workspace owner's credential (RN-058/RN-082), and the rate limit is the provider's — see RN-095 and ADR 0060.

Capabilities​

Not every backend does everything, and that's declared, not discovered on failure:

interface GitProviderCapabilities {
readonly protectBranch: boolean;
readonly pullRequests: boolean;
readonly listTree: boolean;
readonly pullRequestDiff: boolean;
readonly blame: boolean;
readonly pullRequestsList: boolean;
readonly branchesDetailed: boolean;
}
providerprotectBranchpullRequestslistTreepullRequestDiffblamepullRequestsListbranchesDetailed
Local❌✅✅✅✅✅✅
GitHub✅✅✅✅✅✅✅
GitLab✅✅✅✅✅✅✅

The two PHASE 26 capabilities, and the three from PHASE 26b, are true on all three because the contract suite exercises them on all three — it's the same criterion from ADRs 0041/0042, which also applies to git: a capability is only declared once proven, and without proof it's declared false and degrades. Local fulfills them with git ls-tree/git diff/git blame --porcelain/git rev-list --left-right --count on the bare repo, with no platform behind it at all — it's the only one of the three providers tested against REAL git (local-git-provider.contract.spec.ts); GitHub and GitLab run against msw's fake backends, and the real smoke tests ({github,gitlab}-provider.smoke.spec.ts) remain skipped without GITHUB_TEST_TOKEN/GITLAB_TEST_TOKEN in the environment — the same situation already documented in PHASE 13a for the LLM providers.

pullRequestsList deserves a separate note: the original assumption was that local wouldn't have PRs, "single-repository concept has no PR" — that didn't hold up. The sidecar PR store from Phase 4a (self-contained for the dev agents) is already the source, and all three local capabilities ended up true.

One declared degradation, and it's about DATA, not about the operation: GitLab doesn't bring file size in the tree listing (RepositoryTreeSchema has no such field, and asking for it per entry would cost one request per file), so size comes back null there. The operation exists and works; what's missing is a column.

The Local provider implements PRs internally (there's no server to host them, but the flow exists) and does not implement branch protection — there's no platform to apply it. Calling protectBranch on it raises GitNotSupportedError, an explicit error, never a silent no-op (RN-028).

Capability isn't the gate. Platform branch protection is defense in depth. What prevents an improper merge is the domain-level ceiling, which works the same way on all three providers, including Local, which has no protection at all (RN-006). The ADR 0028 documents the divergence between GitHub and GitLab and why it doesn't change the guarantee.

Normalized errors​

Each provider translates its own platform's error into one of these classes. The domain never sees a GitHub 422 or a git fatal::

errorwhen
GitRepoAlreadyExistsErrorcreating a repository that already exists
GitRepoNotFoundErrorrepository doesn't exist or no access
GitBranchNotFoundErrorbranch doesn't exist
GitBranchAlreadyExistsErrorcreating a branch that already exists
GitPermissionDeniedErrorvalid credential, insufficient permission
GitNotSupportedErroroperation outside that provider's capabilities
GitCredentialConnectionTestFailedErrorcredential connection test failed — doesn't reach HTTP: TestStoredCredentialUseCase catches it and returns refused (ADR 0050)
GitProviderAuthErrorinvalid or expired credential
InvalidOauthStateErrorOAuth state doesn't match

There is no common base class, and that was decided, not forgotten: the ADR 0002 records that an abstract GitError was considered and rejected for not paying for itself while no single HTTP filter needs it.

The distinction that matters most in practice is GitPermissionDeniedError versus GitProviderAuthError: the first means "the token is valid but can't do this", the second "the token doesn't work". Confusing the two sends the user to re-authenticate when the problem was scope.

Retry​

Only on reads, never on mutations (ADR 0003).

The algorithm is Full Jitter, AWS's: sleep = random(0, min(maxDelay, base · 2^attempt)), with 4 attempts by default. apps/api/src/infrastructure/git/retry.ts.

The asymmetry is the whole decision: retrying a listBranches that timed out is harmless; retrying a commitFiles that may have gone through creates a duplicate commit. When there's no way to know whether the mutation happened, the right answer is to fail and let a human look.

The contract suite​

A single suite — apps/api/test/contract/git-provider.contract.ts — runs against all three providers. It's what guarantees that "works on Local" doesn't become "works only on Local".

It's also the mechanism that keeps capabilities honest: a provider that declares protectBranch: true needs to pass the protection tests; one that declares false needs to raise GitNotSupportedError. Declaring it wrong breaks the suite in both directions.

Two guardrails accompany the suite, in apps/api/test/contract/git-provider-contract-callers.spec.ts. The first checks that the suite's header lists exactly who invokes it. The second is item 33 of PHASE 26: a contract operation with no consumer in src/ fails CI — an operation all three providers implement and nobody calls is permanent weight, and nothing proves it works on the real path.

The escape hatch for a phase to deliver a contract before the routes is narrow and named: the SEM_CONSUMIDOR_AINDA map, with the phase that will consume it written next to each operation. It closes itself — as soon as the operation gets a consumer, the entry starts to fail, forcing whoever wrote the route to delete it.

The map has been empty since PHASE 26b, and it was emptied by the mechanism, not by anyone's memory: as soon as application/use-cases/git/read-project-code.use-case.ts started calling listTree and getPullRequestDiff, the second test failed pointing at the two entries by name. Empty, and not removed — the escape hatch remains available for the next contract that's born before its consumer.

:::caution The fake has to lie the same way the remote does The suite runs against fake backends (msw) for the remote providers, and a fake that's more GENEROUS than the real API leaves the suite green while the product breaks. That's what happened with the empty repository: GitHub's fake responded 404 to a nonexistent ref, GitHub actually responds 409 Git Repository is empty, and bootstrap died on the very first step of every new GitHub project — with the whole suite green. When adding a case to the fake, check the response against the live API, not against what seems reasonable. :::

The first commit in an empty repository​

A freshly created GitHub repository has no commit at all (auto_init: false), and there the entire Git Data API responds 409 — refs, blobs, trees, commits. There's no way to assemble the first commit through it. What works is the Contents API (PUT /repos/:owner/:repo/contents/:path), which creates the file, commit, and branch all at once; it's what commitFiles uses when it detects the repo is empty.

With one file — bootstrap's case, which commits one per step — you get exactly one commit, as the contract promises. With more than one, the first is born via the Contents API (it's the one that creates the branch) and the rest go into a second commit via the normal path: two commits instead of one, a declared degradation because the alternative would be refusing the initial multi-file commit.

LocalGitProvider doesn't go through any of this: its git init --bare accepts the first commit via the usual path.

Gitflow bootstrap​

Five steps that prepare the project's repository: permanent branches (dev, qa, main), protections where the provider supports them, and base files.

There used to be six: there was a create_rc_branch step, which created the rc rung between qa and main. The rung left the policy via ADR 0030 and the step left bootstrap afterward, once the mismatch was noticed — the product was creating, protecting, and documenting in the user's repository a four-rung ladder it had itself abandoned. The create_rc_branch value remains in the database's bootstrap_step enum: old bootstraps have rows with it, and a step that actually happened doesn't get erased.

Two properties, both tested (ADR 0005, RN-029):

Idempotent — each step checks before acting. Running it twice doesn't duplicate anything.

Resumable — failed on step 4? The resume starts at 4, not at 1.

Each step emits its own event, and there are five possible outcomes:

eventmeans
bootstrap.step_startedstarted
bootstrap.step_completeddid the work
bootstrap.step_skippedwas already done — this is success
bootstrap.step_degradedcompleted without a capability (branch protection on Local)
bootstrap.step_failedfailed; the resume starts here

skipped and degraded exist separately from completed on purpose: a bootstrap that skipped everything because the repository was already ready is a different outcome from one that did everything, and one that ran without branch protection is different from both. Collapsing all three into "ok" would lose exactly the information someone will want later.

Adopting an existing repository (Phase 12a)​

A project can point to a repository that already exists, instead of creating one. project_repositories.origin says which of the two it was (RN-046).

Adoption uses only what the contract already had: getRepo validates access — it had existed since Phase 2 and no use case called it — and the diagnostic uses listBranches and getFileContent. No new method entered the contract, and the contract suite was left untouched.

Bootstrap does NOT run on adoption. What comes out is a plan: the serialized list of what it would do, obtained by calling each step's check() — the same one that gives idempotency — without executing anything. The user then either approves the whole plan, or adopts as-is and dismisses bootstrap (RN-045).

eventmeans
bootstrap.repository_adoptedthe repository became the project's; nothing in it was changed
bootstrap.plan_approvedthe user approved it — only from here does bootstrap run on an adopted repo
bootstrap.adopted_as_isthe user dismissed bootstrap; the plan stays stored as evidence of what wasn't applied

Divergent protection is presence × absence, and only that. The contract exposes GitBranch.protected as a boolean, and ADR 0028 deferred a normalized ProtectionPolicy — so the plan can say "qa is unprotected → would apply" and "main is already protected → won't touch", but it can't say that the existing protection requires two reviewers and ours would require one. A branch with PARTIAL protection counts as unprotected, and can be overwritten — always within an approved plan.

A branch the template doesn't know about (develop, release/*) becomes an informational diagnostic and is never touched: an adopted repository has whatever policy it has.

Decision in ADR 0044.

Credentials​

Git tokens are user secrets: encrypted with envelope encryption, one DEK per record, never in plaintext in the database or in logs. The table is project_git_connections, and it takes part in the master key rotation alongside LLM credentials — see the runbook.

Registration uses a dedicated database enum (credential_provider), joined with the LLM providers only at the TypeScript type level, without mixing the enums (ADR 0004).

Two connection paths: PAT (pasted token) and OAuth (GitHub and GitLab). OAuth requires GITHUB_OAUTH_CLIENT_ID/_SECRET or the GitLab pair configured; without them, PAT only (configuration).

What doesn't exist​

Bitbucket and a GenericGitProvider are out of scope — they aren't forgotten backlog, they're a decision. Adding a new provider means implementing all fifteen operations, honestly declaring the capabilities, and passing the contract suite.

Writing from the Code tab also doesn't exist. The seven read operations (listTree, getPullRequestDiff, blame, listPullRequests, listBranchesDetailed, plus getFileContent and the composed search) are read-only, and that's all. Saving a file from the tab is a future phase, and when it comes, writing is an external effect: it's born as a proposed_action, like every git mutation. What makes this verifiable instead of just an intention is that CodeController doesn't have a single write verb — no @Post, no @Put, no @Patch, no @Delete — even after PHASE 26b added three routes to it.