Version control: bb.vcs, bb.vcs-ui, and bb.git
The version-control domain separates a generic contract, its presentation, and its default Git provider.
Purpose
bb.vcs owns one VCS-generic service contract. The contract covers repositories, status, changed files, commits, heads, history, and diff data.
bb.vcs-ui presents that data. It does not depend on Git concepts or Git services.
bb.git is the declared default provider for bb.vcs. It also exports separate services for features that only Git has.
This split gives providers two clear lanes. A provider claims bb.vcs for generic operations. A consumer requires bb.git.* only when it needs Git semantics.
The bb.vcs domain plugin
bb.vcs is headless. It owns the public contract, repository discovery rules, repository identity, and provider routing.
The active provider returns data only. It returns no React nodes, icons, colors, panel rules, or diff components.
Surfaces
| ID | kind | replaceable | props contract sketch | notes |
|---|---|---|---|---|
| — | — | — | — | The domain plugin declares no presentation surfaces. |
Service and arbitration
| ID | kind | replaceable | version | default provider | notes |
|---|---|---|---|---|---|
bb.vcs |
single |
yes | 1.x |
bb.git |
The winner supplies the complete generic VCS service. |
// bb.vcs/bb.plugin.jsonc
{
"id": "bb.vcs",
"services": [
{
"id": "bb.vcs",
"kind": "single",
"version": "1.0.0",
"contract": "./src/contracts.ts#bbVcs",
"replaceable": true,
"defaultProvider": "bb.git",
"stability": "stable"
}
]
}The kernel stores one winner for bb.vcs. A fresh install selects bb.git because the contract declares it as the default provider.
A second provider does not replace Git without a user choice. The winner picker shows all claimants.
A winner change uses the backend replacement transaction. The new provider must become ready before the kernel cuts service calls over.
Required consumers restart with a new service handle. Optional and watched consumers observe the availability change.
The backend has no Original handle. If a selected provider fails, arbitration falls back to bb.git.
Repository discovery and routing
Every request uses an explicit repository reference. The service never reads the current route or process directory.
export type VcsRepositoryTarget =
| {
kind: "environment";
environmentId: string;
searchPaths?: readonly string[];
}
| {
kind: "host";
hostId: string;
rootPath: string;
};
export interface VcsRepositoryRef {
repositoryId: string;
environmentId: string | null;
hostId: string;
rootPath: string;
provider: {
pluginId: string;
kind: string;
generation: number;
};
}
export interface VcsRepositoryDiscoveryRequest {
target: VcsRepositoryTarget;
nested?: boolean;
signal?: AbortSignal;
}
export interface VcsRepositoryDiscoveryResult {
repositories: readonly VcsRepositoryRef[];
revision: string;
}
export interface VcsRepositoryResolveRequest {
target: VcsRepositoryTarget;
signal?: AbortSignal;
}The domain resolves an environment through bb.workspace. It sends a resolved host and path to the active provider.
The provider discovers repositories below that target. Each result records the provider and its generation.
All later calls use that reference. The router rejects a reference from an old winner generation and requests discovery again.
This rule supports workspaces that have no repository, one repository, or nested repositories. It also prevents ambient route state from selecting a repository.
Complete bb.vcs service sketch
import { defineService } from "@get-bb/plugin/server";
export const bbVcs = defineService<VcsService>("bb.vcs", "1.0");
export type Dispose = () => void | Promise<void>;
export interface VcsService {
discover(
request: VcsRepositoryDiscoveryRequest,
): Promise<VcsRepositoryDiscoveryResult>;
resolve(request: VcsRepositoryResolveRequest): Promise<VcsRepositoryRef>;
capabilities(
request: VcsRepositoryRequest,
): Promise<VcsCapabilities>;
status(request: VcsStatusRequest): Promise<VcsRepositoryStatus>;
watch(
request: VcsWatchRequest,
listener: (event: VcsRepositoryEvent) => void,
): Promise<Dispose>;
readonly changes: VcsChangesService;
readonly commits: VcsCommitsService;
readonly heads: VcsHeadsService;
readonly history: VcsHistoryService;
readonly diffs: VcsDiffsService;
}
export interface VcsChangesService {
list(request: VcsChangeListRequest): Promise<VcsChangeList>;
discard(request: VcsDiscardRequest): Promise<VcsRepositoryStatus>;
}
export interface VcsCommitsService {
create(request: VcsCommitCreateRequest): Promise<VcsCommitResult>;
}
export interface VcsHeadsService {
list(request: VcsHeadListRequest): Promise<VcsHeadList>;
create(request: VcsHeadCreateRequest): Promise<VcsHead>;
switch(request: VcsHeadSwitchRequest): Promise<VcsRepositoryStatus>;
delete(request: VcsHeadDeleteRequest): Promise<void>;
}
export interface VcsHistoryService {
get(request: VcsRevisionGetRequest): Promise<VcsRevision>;
list(request: VcsHistoryListRequest): Promise<VcsHistoryPage>;
}
export interface VcsDiffsService {
get(request: VcsDiffRequest): Promise<VcsDiff>;
file(request: VcsFileDiffRequest): Promise<VcsFileDiff>;
}The service groups related operations. Each request carries a repository and an optional abort signal.
Capabilities, status, and changed files
The capability record lets one UI adapt without a provider-specific edge.
export interface VcsRepositoryRequest {
repository: VcsRepositoryRef;
signal?: AbortSignal;
}
export interface VcsCapabilities {
headKinds: readonly VcsHeadKind[];
canCreateHead: boolean;
canDeleteHead: boolean;
canSwitchHead: boolean;
canCommitSelectedPaths: boolean;
canAmend: boolean;
canDiscard: boolean;
}
export type VcsHeadKind = "branch" | "bookmark" | "named-head";
export interface VcsObjectId {
value: string;
shortValue: string;
}
export interface VcsStatusRequest extends VcsRepositoryRequest {
includeUntracked?: boolean;
}
export interface VcsRepositoryStatus {
repository: VcsRepositoryRef;
head: {
name: string | null;
kind: VcsHeadKind | null;
target: VcsObjectId | null;
detached: boolean;
};
upstream: {
name: string;
ahead: number | null;
behind: number | null;
} | null;
clean: boolean;
conflicted: boolean;
changedFileCount: number;
revision: string;
}
export type VcsChangeKind =
| "added"
| "modified"
| "deleted"
| "renamed"
| "copied"
| "conflicted"
| "untracked";
export interface VcsChangedFile {
path: string;
oldPath: string | null;
kind: VcsChangeKind;
binary: boolean;
additions: number | null;
deletions: number | null;
}
export interface VcsChangeListRequest extends VcsRepositoryRequest {
cursor?: string;
limit?: number;
}
export interface VcsChangeList {
files: readonly VcsChangedFile[];
cursor: string | null;
revision: string;
}
export interface VcsDiscardRequest extends VcsRepositoryRequest {
paths: readonly string[];
expectedRevision?: string;
}discard() can remove user data. A UI must request clear user approval before it calls this method.
The generic changed-file model has no index or staged area. Those concepts belong to Git.
Commits, heads, and history
export interface VcsIdentity {
name: string;
email: string | null;
time: string;
}
export interface VcsRevision {
id: VcsObjectId;
parents: readonly VcsObjectId[];
author: VcsIdentity;
committer: VcsIdentity | null;
subject: string;
body: string;
heads: readonly string[];
}
export interface VcsCommitCreateRequest extends VcsRepositoryRequest {
message: string;
paths?: readonly string[];
amend?: boolean;
expectedRevision?: string;
}
export interface VcsCommitResult {
revision: VcsRevision;
status: VcsRepositoryStatus;
}
export interface VcsHead {
name: string;
kind: VcsHeadKind;
target: VcsObjectId;
current: boolean;
upstream: string | null;
ahead: number | null;
behind: number | null;
}
export interface VcsHeadListRequest extends VcsRepositoryRequest {
includeRemote?: boolean;
}
export interface VcsHeadList {
heads: readonly VcsHead[];
defaultHead: string | null;
revision: string;
}
export interface VcsHeadCreateRequest extends VcsRepositoryRequest {
name: string;
startPoint?: string;
switch?: boolean;
}
export interface VcsHeadSwitchRequest extends VcsRepositoryRequest {
name: string;
force?: boolean;
}
export interface VcsHeadDeleteRequest extends VcsRepositoryRequest {
name: string;
force?: boolean;
}
export interface VcsRevisionGetRequest extends VcsRepositoryRequest {
revision: string;
}
export interface VcsHistoryListRequest extends VcsRepositoryRequest {
revision?: string;
paths?: readonly string[];
cursor?: string;
limit?: number;
}
export interface VcsHistoryPage {
revisions: readonly VcsRevision[];
cursor: string | null;
}A provider maps VcsHead to its nearest named-head concept. Git uses branches. A jj provider can use bookmarks.
The capability record tells consumers when selected paths, amend, or head changes are unavailable.
Diff data
The contract returns structured data. bb.files-ui.diff owns the presentation.
export type VcsDiffSide =
| { kind: "working-copy" }
| { kind: "revision"; revision: string }
| { kind: "empty" };
export interface VcsDiffRequest extends VcsRepositoryRequest {
base: VcsDiffSide;
head: VcsDiffSide;
paths?: readonly string[];
contextLines?: number;
}
export interface VcsFileDiffRequest extends VcsDiffRequest {
path: string;
}
export interface VcsDiff {
repository: VcsRepositoryRef;
files: readonly VcsDiffFileSummary[];
revision: string;
}
export interface VcsDiffFileSummary {
path: string;
oldPath: string | null;
kind: VcsChangeKind;
binary: boolean;
additions: number | null;
deletions: number | null;
}
export interface VcsFileDiff extends VcsDiffFileSummary {
oldText: string | null;
newText: string | null;
hunks: readonly VcsDiffHunk[];
}
export interface VcsDiffHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: readonly VcsDiffLine[];
}
export interface VcsDiffLine {
kind: "context" | "add" | "delete";
text: string;
oldLine: number | null;
newLine: number | null;
}Events
export interface VcsWatchRequest extends VcsRepositoryRequest {
includeDiffStats?: boolean;
}
export type VcsRepositoryEvent =
| { kind: "ready"; status: VcsRepositoryStatus }
| { kind: "changed"; status: VcsRepositoryStatus }
| { kind: "repository-removed"; repository: VcsRepositoryRef }
| { kind: "resync-required"; repository: VcsRepositoryRef };Events invalidate cached data. Consumers read status, changes, or history again after an event.
Contract boundary
bb.vcs does not define staging, an index, worktrees, or stash operations.
These are not portable VCS concepts. jj and Sapling do not have a Git index or Git worktrees with the same semantics.
A generic contract must not force another provider to simulate Git. Git-only consumers use explicit bb.git.* service edges.
Exports
| module | exports | use |
|---|---|---|
@bb/vcs/contracts |
bbVcs and all Vcs* types |
Declare a service edge or implement a provider. |
@bb/vcs/testing |
contract tests and provider fixtures | Verify generic provider behavior. |
The exports contain no provider implementation and no UI component.
Host roles
bb.vcs declares no host role. Each provider owns a private role for its native executable or library.
The bb.vcs-ui plugin
bb.vcs-ui supplies the default presentation for the active bb.vcs winner.
It knows only the generic contract. It does not import bb.git code or require a bb.git.* service.
Cross-plugin surface claims
bb.vcs-ui declares no parent surface. It claims surfaces that their presentation owners declare.
| claimed surface | kind | claim key | contract | notes |
|---|---|---|---|---|
bb.thread-ui.sidePanels |
list |
vcs-changes |
ThreadSidePanelContribution |
Adds the changes panel when the active thread has a repository. |
bb.layout.statusbar |
list |
vcs-head |
StatusbarItemContribution |
Shows the current branch, bookmark, or named head. |
bb.thread-ui.view
└─ bb.thread-ui.sidePanels
└─ bb.vcs-ui claims vcs-changes
bb.app.root
└─ bb.layout.statusbar
└─ bb.vcs-ui claims vcs-headThe thread UI owns the panel location. The layout owns the statusbar location.
A replacement thread view can omit bb.thread-ui.sidePanels. The VCS service and statusbar item still work.
Changes panel and commit control
The panel resolves the current workspace repository through bb.vcs. It uses capability data to show only valid actions.
export interface VcsChangesPanelProps {
threadId: string;
environmentId: string;
repository?: VcsRepositoryRef;
}
export function VcsChangesPanel(props: VcsChangesPanelProps) {
const vcs = useService(bbVcs);
const model = useVcsChanges(vcs, props.environmentId, props.repository);
return (
<ChangesPanel
status={model.status}
files={model.files}
onCommit={(message, paths) =>
vcs.commits.create({
repository: model.repository,
message,
paths,
expectedRevision: model.status.revision,
})
}
renderDiff={(file) => (
<Surface
id="bb.files-ui.diff"
props={toFileDiffProps(model.repository, file)}
/>
)}
/>
);
}The commit control calls bb.vcs.commits.create(). It does not stage files.
The provider can support selected paths or whole-working-copy commits. The UI follows canCommitSelectedPaths.
Diff composition
bb.vcs-ui gets diff content from bb.vcs.diffs. It passes that data to the active bb.files-ui.diff implementation.
This composition keeps VCS data separate from file presentation. A diff-view replacement works for every VCS provider.
Head status item
export interface VcsHeadStatusProps {
environmentId: string;
}
export function VcsHeadStatus({ environmentId }: VcsHeadStatusProps) {
const status = useVcsStatus(environmentId);
if (!status) return null;
return (
<StatusbarItem
label={status.head.name ?? status.head.target?.shortValue ?? "No head"}
detail={formatSyncState(status.upstream)}
/>
);
}The item uses generic head data. It can show a Git branch or a jj bookmark without a code change.
Services
bb.vcs-ui declares no data service. It consumes one required service edge.
| service | edge | range | use |
|---|---|---|---|
bb.vcs |
required | ^1 |
Reads repositories, status, changes, capabilities, history, and diffs. It also creates commits. |
The kernel starts bb.vcs-ui after the selected bb.vcs provider becomes ready.
Exports
| module | exports | use |
|---|---|---|
@bb/vcs-ui/components |
CommitButton, ChangesPanel, VcsHeadStatus, HistoryView |
Import an exact first-party component. |
@bb/vcs-ui/claims |
vcsChangesPanel, vcsHeadStatus |
Reuse a complete cross-plugin claim. |
Surface claims follow their active parent implementation. Component imports select exact first-party code.
Example
// bb.plugin.jsonc
{
"id": "bb.vcs-ui",
"version": "2.0.0",
"claims": [
{ "surface": "bb.thread-ui.sidePanels" },
{ "surface": "bb.layout.statusbar" }
],
"requires": [
{ "service": "bb.vcs", "range": "^1" }
],
"artifacts": {
"app": "./dist/app.js"
}
}// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";
import { vcsChangesPanel, vcsHeadStatus } from "@bb/vcs-ui/claims";
export default definePlugin((api) => {
api.surfaces.add("bb.thread-ui.sidePanels", vcsChangesPanel);
api.surfaces.add("bb.layout.statusbar", vcsHeadStatus);
});The bb.git default provider
bb.git is an ordinary built-in provider plugin. It has no loader privilege.
It claims the bb.vcs service and implements the complete generic contract. A private host role runs Git on the selected machine.
Provider claim
// bb.plugin.jsonc
{
"id": "bb.git",
"version": "2.0.0",
"claims": [
{ "service": "bb.vcs", "version": "1.0" }
],
"services": [
{ "id": "bb.git.staging", "kind": "single", "version": "1.0.0", "replaceable": false, "contract": "./src/contracts.ts#bbGitStaging", "stability": "stable" },
{ "id": "bb.git.worktrees", "kind": "single", "version": "1.0.0", "replaceable": false, "contract": "./src/contracts.ts#bbGitWorktrees", "stability": "stable" },
{ "id": "bb.git.stash", "kind": "single", "version": "1.0.0", "replaceable": false, "contract": "./src/contracts.ts#bbGitStash", "stability": "stable" }
],
"hostRoles": [
{
"name": "git",
"contract": "./src/host-contract.ts#gitHostRole",
"scope": "environment",
"launch": { "kind": "module", "export": "git" }
}
],
"artifacts": {
"server": "./dist/server.js",
"host": "./dist/host.js"
}
}The bb.vcs owner declares bb.git as the fallback. The provider manifest only claims the contract.
// src/server.ts
export default defineServerPlugin(async (api) => {
const host = api.hostRoles.client(gitHostRole);
const workspace = await api.services.use(bbWorkspace);
const provider = createGitVcsProvider(host, workspace);
api.services.provide(bbVcs, provider);
api.services.provide(bbGitStaging, createGitStaging(host));
api.services.provide(bbGitWorktrees, createGitWorktrees(host));
api.services.provide(bbGitStash, createGitStash(host));
});The two-lane rule
Git supplies generic behavior through bb.vcs. It supplies Git-only behavior through named bb.git.* services.
| lane | contract | consumer intent |
|---|---|---|
| Generic | bb.vcs |
Work with the active VCS provider. |
| Git-specific | bb.git.staging |
Read or change the Git index. |
| Git-specific | bb.git.worktrees |
Create and manage Git worktrees. |
| Git-specific | bb.git.stash |
Create and manage Git stash entries. |
A consumer must not use bb.git.* as a test for general VCS availability. It must use bb.vcs for that test.
Git-specific service sketches
export const bbGitStaging =
defineService<GitStagingService>("bb.git.staging", "1.0");
export const bbGitWorktrees =
defineService<GitWorktreesService>("bb.git.worktrees", "1.0");
export const bbGitStash =
defineService<GitStashService>("bb.git.stash", "1.0");
export interface GitStagingService {
status(request: GitRepositoryRequest): Promise<GitIndexStatus>;
stage(request: GitPathMutationRequest): Promise<GitIndexStatus>;
unstage(request: GitPathMutationRequest): Promise<GitIndexStatus>;
}
export interface GitIndexStatus {
repository: VcsRepositoryRef;
staged: readonly VcsChangedFile[];
unstaged: readonly VcsChangedFile[];
untracked: readonly VcsChangedFile[];
conflicted: readonly VcsChangedFile[];
revision: string;
}
export interface GitPathMutationRequest {
repository: VcsRepositoryRef;
paths: readonly string[];
expectedRevision?: string;
signal?: AbortSignal;
}
export interface GitWorktreesService {
list(request: GitRepositoryRequest): Promise<readonly GitWorktree[]>;
create(request: GitWorktreeCreateRequest): Promise<GitWorktree>;
remove(request: GitWorktreeRemoveRequest): Promise<void>;
prune(request: GitRepositoryRequest): Promise<readonly string[]>;
}
export interface GitWorktree {
path: string;
head: VcsObjectId | null;
branch: string | null;
bare: boolean;
locked: boolean;
}
export interface GitWorktreeCreateRequest extends GitRepositoryRequest {
path: string;
branch?: string;
startPoint?: string;
}
export interface GitWorktreeRemoveRequest extends GitRepositoryRequest {
path: string;
force?: boolean;
}
export interface GitStashService {
list(request: GitRepositoryRequest): Promise<readonly GitStashEntry[]>;
create(request: GitStashCreateRequest): Promise<GitStashEntry>;
apply(request: GitStashMutationRequest): Promise<VcsRepositoryStatus>;
drop(request: GitStashMutationRequest): Promise<void>;
}
export interface GitStashEntry {
index: number;
ref: string;
revision: VcsObjectId;
message: string;
createdAt: string;
}
export interface GitStashCreateRequest extends GitRepositoryRequest {
message?: string;
includeUntracked?: boolean;
}
export interface GitStashMutationRequest extends GitRepositoryRequest {
ref: string;
reinstateIndex?: boolean;
}These services accept a repository reference that bb.vcs discovered. They reject a repository that the Git provider does not own.
Git host role
The private bb.git.host.git role runs native Git operations. Only the bb.git server artifact can call it.
export interface GitHost {
discover(request: GitHostDiscoverRequest): Promise<GitHostRepository[]>;
status(request: GitHostStatusRequest): Promise<GitHostStatus>;
changes(request: GitHostChangesRequest): Promise<GitHostChangeList>;
commit(request: GitHostCommitRequest): Promise<GitHostCommitResult>;
heads(request: GitHostHeadsRequest): Promise<GitHostHeadList>;
history(request: GitHostHistoryRequest): Promise<GitHostHistoryPage>;
diff(request: GitHostDiffRequest): Promise<GitHostDiff>;
stage(request: GitHostPathMutationRequest): Promise<GitHostIndexStatus>;
unstage(request: GitHostPathMutationRequest): Promise<GitHostIndexStatus>;
worktrees(request: GitHostWorktreeRequest): Promise<GitHostWorktreeResult>;
stash(request: GitHostStashRequest): Promise<GitHostStashResult>;
watch(
request: GitHostWatchRequest,
listener: (event: GitHostEvent) => void,
): Promise<Dispose>;
}The public bb.vcs service owns validation and generic result types. It does not expose this role.
Writing another provider
A jj or Sapling plugin follows the same provider path as Git.
{
"id": "acme.jj",
"version": "1.0.0",
"claims": [
{ "service": "bb.vcs", "version": "1.0" }
],
"hostRoles": [
{
"name": "jj",
"contract": "./src/host-contract.ts#jjHostRole",
"scope": "environment",
"launch": { "kind": "module", "export": "jj" }
}
]
}The provider implements all VcsService methods and passes the shared contract tests.
It maps jj bookmarks to generic heads. It maps jj revisions and working-copy state to history, status, changed files, and diffs.
It maps the generic commit action to its native describe-and-new-change flow. Its capabilities report unsupported path or amend behavior.
It does not simulate a Git index, a Git worktree, or a Git stash. It can export separate acme.jj.* services for native jj features.
The user selects acme.jj in the bb.vcs winner picker. bb.vcs-ui then uses it without a manifest or code change.
GitHub boundary
bb.vcs owns local repository concepts. It does not own pull requests, issues, checks, reviews, or forge authentication.
Those features stay in the ordinary github plugin. Its manifest requires bb.vcs for repository state.
The plugin can add optional edges to the bb.git.* services that it uses. It must hide those Git-only features when another VCS provider wins.
{
"id": "github",
"requires": [
{ "service": "bb.vcs", "range": "^1" }
],
"optional": [
{ "service": "bb.git.staging", "range": "^1" },
{ "service": "bb.git.worktrees", "range": "^1" },
{ "service": "bb.git.stash", "range": "^1" }
]
}The required edge keeps repository and diff features VCS-neutral. Each optional edge makes a Git-only feature explicit.
Covers
| old item ID | new contract/verb | note |
|---|---|---|
| — | — | No VCS-domain item moved from another page's allocation. These contracts are new. |