Skip to content

✨ Publish Git's contextual Apis from @executablemd/git/api - #834

Merged
taras merged 2 commits into
mainfrom
agent/issue-822-git-api
Sep 21, 2026
Merged

taras merged 2 commits into
mainfrom
agent/issue-822-git-api

Conversation

@taras

@taras taras commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #831, which merged #822 without this correction.

Two commits, reviewable separately:

b02d7910 publishes the contextual Apis from /api — the reviewed correction
83064c5b renames ambientRepository() to repository()

#831 shipped @executablemd/git's first public contract with its contextual Apis on the package root. Architect review identified that as a first-publication defect and this change was reviewed and accepted against it, but it was never pushed before #831 merged — so main currently publishes the Apis in the shape the review rejected. This is that correction, unchanged, rebased onto current main.

Why

A contextual Api is a seam: a consumer reaches one to call an operation and replaces one to answer it.

// call it
const ambient = yield* RepositoryComposition.operations.repository();

// or answer it — providers install at `min` so a nested replacement wins
yield* Git.around({ *revParse(revision) { … } }, { at: "min" });

Mixed in among records, errors, parsers and component definitions on the package root, those values were indistinguishable from data. Which names were replaceable seams was something a reader had to already know.

What changes

Before: import { Git, RepositoryComposition } from "@executablemd/git" — alongside GitObjectError, parseWorktreeRecord, gitPlugin and eighty other names.

After: import { Git, RepositoryComposition } from "@executablemd/git/api" — and the root no longer publishes them at all.

Eight Apis move: Git, RepositoryComposition, RepositoryContext, GitComposition, PullRequestAPI, IssueApi, IssueTrackerContext and GitHost. Each travels with its named interface, the identity it was minted under, the base refusal it falls back to, the direct operations and accessors consumers call, and every type its interface is spelled in — an interface you cannot spell is one you cannot implement.

IssueTrackerContextApi is introduced: that context was the one anonymous exception, written inline at its createApi() call, which left the shape a consumer must answer with as something to copy out of the implementation.

How it works

@executablemd/git/api   → the seams: Apis, their interfaces, identities, refusals, operations, contract types
@executablemd/git       → the Plugin, components, workflow installation, effect ids, errors, record parsers
@executablemd/git/deno  → the host adapters that implement the seams

The Apis are removed from the root rather than published twice. A seam reachable two ways is two contracts, and the second is whichever the author happened to import. The package had not been published when this was written, so there is no compatibility alias to keep.

Ordinary data types that also appear in an Api signature stay on the root and are re-exported from /api. A record is still a record when a consumer only wants to read one.

Review guide

Start with: packages/git/api.ts

Then review:

  1. packages/git/tests/api-entrypoint.test.ts — the portable contract
  2. packages/git/tests/module-partition.test.ts — the source-boundary scan (bottom of file)
  3. packages/git/src/issue/context.ts — the newly named IssueTrackerContextApi
  4. the 25 migrated consumers

Look carefully at: the source-boundary scan judges the two routes by different rules. That asymmetry is deliberate and is the defect the first pass missed — see below.

What must stay true

  • An Api is reachable from /api and nowhere else — enforced by shape, not by a name list: anything carrying both around and operations is a createApi() value, so an Api re-exported from the root fails without anyone maintaining an inventory.
  • The subpath is not optional — the root could publish no Api while a consumer still reached one by relative path into packages/git/src, which resolves, typechecks, and quietly makes /api decorative. The source-boundary scan closes that.
  • Additive data types stay legal from the root — proven by a probe that asserts the allow case, not only the reject case.
  • Workflow still imports no Git feature — the root gains @executablemd/git as a development dependency so Workflow's tests resolve the package under Node and Bun. No production dependency is added.

How to verify it

The entrypoint test is portable because the runtimes disagree about which manifest they read. Deno resolves deno.json; Node and Bun resolve package.json. Only running all three distinguishes a subpath missing from one manifest from a subpath missing from the other — and both probes below fail on exactly one side.

Every check was run adversarially; all six discriminate:

Probe Result
./api removed from deno.json Deno exit 1
./api removed from package.json Node exit 1, Bun exit 1
GitHost omitted from /api exit 1 — names GitHost
GitHost re-exported from the root exit 1 — root: GitHost
api.ts dropped from the provider-neutral set exit 1 — names packages/git/api.ts
GitHostProvider imported from ../src/git-host/api.ts exit 1 — names the consumer

The last one is the defect the first review round caught: the scan originally flagged only names exclusive to /api, so additive contract types reached by relative path slipped through. Twelve such imports existed across nine suites. The rule is now asymmetric — the root is judged against the names it no longer publishes, a source module against everything /api does — and a seventh probe confirms the additive type is still allowed from the root, so the fix did not over-reject.

Scope

Included

  • packages/git/api.ts and ./api in both export maps
  • IssueTrackerContextApi
  • 25 consumers migrated
  • two regressions: the portable entrypoint contract and the source-boundary scan
  • architecture.md and Git module documentation
  • ambientRepository() renamed to repository() (83064c5b). "Ambient" described where the answer came from, not what was asked for, and the Api identity was already …composition.repository. Nothing durable carried the old name — no journal, fixture, snapshot or retained record — so there is no compatibility surface. Its three answers are unchanged.

Intentionally unchanged

  • GIT_HOST_EFFECT and selectedRepository keep their source-relative imports; they are not /api contracts.
  • The runtime-loaded physical copy in workspace-effect-loaded-copy.test.ts stays source-relative — the copied module's identity is the subject of that test.
  • Package-internal Apis (PullRequestOperations, IssueOperations, PullRequestReadExecution) stay internal.
  • No GitHub subpath is added.

Generated or mechanical changes

  • pnpm-lock.yaml (+3) is the link entry for the new root development dependency. deno.lock has no delta, which is expected for a workspace dependency.
  • .github/workflows/publish-packages.yml has no delta: a subpath changes neither publication membership nor order.

Evidence

Slice 6 matrix (13 suites)   17 passed (75 steps) | 0 failed
migrated suites (8)          24 passed (129 steps) | 0 failed
api-entrypoint               Deno ok · Node ok · Bun ok
deno check --frozen · deno task check · tsc --project tsconfig.node.json
deno task lint · check:jsr · git diff --check origin/main...HEAD

All re-run against this base after the rebase.

After the rename (83064c5b):

composition + entrypoint suites   23 passed (80 steps) | 0 failed
api-entrypoint                    Node ok · Bun ok
deno task check · lint · tsc node · check:jsr · diff --check

The rename is worth one note for reviewers. A first sweep reported every site renamed; deno task check then failed in packages/git/src/deno/composition/provider.ts, which contains a byte that makes grep treat the file as binary and skip it silently — plain grep exits 1 where grep -a finds the line. The typechecker caught what the search could not. Re-scanned with -a: zero remaining.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A contextual Api is a seam: a consumer reaches one to call an operation and
replaces one to answer it. Mixed in among records, errors, parsers and
component definitions on the package root, those values were
indistinguishable from data, and which names were replaceable was something a
reader had to already know.

`/api` is now that route, and the only one. Eight Apis — Git,
RepositoryComposition, RepositoryContext, GitComposition, PullRequestAPI,
IssueApi, IssueTrackerContext and GitHost — publish there with their named
interfaces, the identity each was minted under, the base refusal each falls
back to, the direct operations and accessors consumers call, and every type
those interfaces are spelled in. An interface you cannot spell is one you
cannot implement, so the types travel with them; they stay on the root too,
because a record is still a record when you only want to read one.

`IssueTrackerContextApi` is named rather than written inline at its
createApi() call. A consumer replacing that context has to spell what it
answers with, and the anonymous shape left that as something to copy out of
the implementation.

The Apis are gone from the root rather than published twice. A seam reachable
two ways is two contracts, and the second is whichever the author happened to
import. Staying put: the Plugin and its profile predicate, component
registrations and definitions, the workflow installation, durable effect
identifiers, errors, and record parsers.

Two regressions hold it. The portable one imports through the export map
under Deno, Node and Bun — the runtimes disagree about which manifest they
read, so only running all three tells a missing `deno.json` subpath from a
missing `package.json` one — and type-imports the whole contract, since an
interface has no runtime presence to check.

The source-boundary one judges the two routes by different rules, because
they are not the same offence. Importing a record from the root is ordinary,
so the root is judged only against the names it no longer publishes. A source
module is nobody's route: reaching `../src/issue/api.ts` bypasses the export
map entirely, so every name `/api` publishes is a violation there — additive
contract types included. Both sets are computed from the two entrypoints
rather than listed, so a name added to `/api` is covered the day it is added.

That second rule is what the first pass missed: the root could publish no Api
while a consumer still reached `GitHostProvider` or `IssueInput` by relative
path, which resolves, typechecks, and quietly makes the subpath optional.
Twelve such imports existed across nine suites.

The root gains @executablemd/git as a development dependency so Workflow's
tests resolve the package under Node and Bun. Workflow gains no production
dependency and still imports no Git feature.
`RepositoryComposition.operations.ambientRepository()` read as though
"ambient" were a kind of repository the caller chose. It is not: the operation
answers one question — which Repository is this element acting on when it was
written outside a lexical `<Repository>` — and "ambient" describes where the
answer came from, not what was asked for.

The three answers are unchanged: a selection when the profile has an ambient
Repository and this invocation is in one, a throw naming how to run inside one
when it has them and this invocation is not, and `undefined` when the profile
has no such thing at all.

Nothing durable carries the name. It appears in no journal, fixture, snapshot
or retained record, so this is a source rename with no compatibility surface.

One of the six sites is in a module `grep` calls binary and skips without
saying so; the typecheck is what found it.
@taras
taras merged commit 3efedc3 into main Sep 21, 2026
37 of 38 checks passed
@taras
taras deleted the agent/issue-822-git-api branch September 21, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant