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-head

The 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.