Skip to content

✨ Make Git and GitHub available in XMD runs and workflows - #831

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

taras merged 9 commits into
mainfrom
agent/issue-822-git-plugin

Conversation

@taras

@taras taras commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Closes #822.

Why

Repository collaboration — <Repository>, <Worktree>, <Dir>, the Git.* operations, pull requests and issues — lived inside @executablemd/workflow, so an ordinary xmd run could not use any of it, and the workflow package carried a Git dependency it had no business owning. Authors had to know that repository vocabulary was a workflow-only privilege.

#822 was amended after the second slice: Git and GitHub ship together as one package and one Plugin. There is no separate git-host, scm or GitHub package, Plugin or selector.

What changes

Before:

  • Repository vocabulary resolved only inside a workflow run; an ordinary document got Cannot resolve component: Repository.
  • @executablemd/workflow owned the Git capability, the GitHub provider and the Git-host reconciliation engine.
  • xmd run reached repository behavior through two direct composition bootstraps in cli.ts and syntax.ts.

After:

  • @executablemd/git is a package and one complete Plugin, bundled into every run-profile command: run, plan, syntax, and workflow start/resume/fork. No --plugin selection is needed for the existing vocabulary.
  • @executablemd/workflow names no Git feature in an import, in any form.
  • The bundled Plugin is the only route. Both production bootstraps are gone, so omitting Git leaves Git-owned names genuinely unresolved.

How it works

xmd <command> → assembleRunProfile() → [bundled @executablemd/git, ...--plugin values] → executeInstalled()

The production dependency direction is @executablemd/cli → @executablemd/git → @executablemd/workflow.

assembleRunProfile() prefixes the statically imported Plugin value and appends authored --plugin specifiers in the order written, which fixes middleware composition with the bundled value outermost. Whether a command carries it is asked of the Plugin itself (gitPluginDeclaresFor) rather than restated by the CLI, so the profile cannot drift from what the Plugin would declare.

Review guide

Start with: packages/cli/src/run-profile.ts

Then review:

  1. packages/git/src/plugin.ts — which commands declare Git, read through the host's own pre-command grammar
  2. packages/git/src/deno/run-composition/provider.ts — lazily() and the six capabilities behind it
  3. packages/git/src/composition/definitions.ts — gitDirectoryEntry() and the preserved released origin
  4. packages/workflow/mod.ts and packages/workflow/src/run.ts — what Workflow still owns

Look carefully at:

  • nestedRunProfile() filters by object identity, not by name. Filtering by name would let an impostor disappear instead of collide.
  • lazily() holds each acquired capability by suspension. A task that returned would release the temporary directory and the lease it acquired.

What must stay true

  • Workflow imports no Git feature — enforced by the package split, checked by packages/workflow/tests/public-entrypoint.test.ts ("names Git in no workflow production module, in any import form"). This caught a real regression during review: a doc-comment code fence containing a Git import is an import as far as the boundary is concerned.
  • Installing Git costs nothing — enforced by lazily(), checked by packages/git/tests/run-composition-lazy.test.ts, which counts three acquisition kinds independently and asserts every count is zero for installation, a plain document and a bare <Dir>.
  • Released durable origins are unchanged — <Dir> still identifies as @executablemd/workflow/composition/dir-v2#Dir. Ownership and compatibility identity are deliberately distinct: the origin records where released history wrote the entry, not what owns it today.
  • The outer xmd test root gets no implicit Git — a harness must not claim names its children may shadow; a nested <Execution host="run"> child assembles the profile for itself.
  • The exact selector git is idempotent — it names the bundled value, resolves without loading a module, and repeating it changes nothing. --plugin @executablemd/git is an ordinary module specifier and meets admitPlugins()'s duplicate-name refusal.

How to verify it

  • packages/cli/tests/run-profile.test.ts drives the assembler with a loader that throws, so "the selector was consumed, not resolved" is proven by the absence of a load, not by an assertion after the fact.
  • packages/cli/tests/test-root-profile.test.ts runs the same <Dir> element through the real binary in three places: resolving for xmd run, failing at the xmd test root with Cannot resolve component: Dir, and resolving again inside a nested run child.
  • packages/git/tests/github-activation.test.ts installs boundaries that throw rather than counters that tally, so an unwanted configuration read or credential open is refused where it happens.
  • packages/git/tests/module-partition.test.ts declares three explicit non-empty sets — provider-neutral, GitHub implementation, Deno adapter — so an unclassified module fails rather than being silently absorbed.

Scope

Included

  • The @executablemd/git package and Plugin, owning both provider-neutral Git contracts and package-internal GitHub implementations.
  • The bundled run profile and the reserved git selector.
  • Lazy acquisition of all six host capabilities.
  • Documentation across architecture.md, four specs and package docs.

Intentionally unchanged

Generated or mechanical changes

  • 97 of the 267 changed files are pure renames (R100) from moving Workflow's repository modules into packages/git/.
  • .github/workflows/publish-packages.yml is regenerated by deno task gen:publish-workflow. The publication order gains git after workflow, matching the new dependency direction.
  • test-weights.json is committed byte-for-byte from the Measure test weights artifact, with provenance naming the measured head.

Risks and limitations

  • A distribution now carries the Git Plugin's documentation, which makes it the documentation gate's business; scripts/validate-documentation.ts assembles the run profile accordingly.
  • One flake was observed and recorded rather than absorbed: 🧪 dnt's npm install aborts with exit 134 during esbuild postinstall in cli-npm-bin #830, npm install aborting with exit 134 inside dnt during esbuild's postinstall. It passed alone and in a repeated full matrix on the identical revision.

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.

Three trusted host boundaries replace the private cross-feature coupling that
`@executablemd/workflow` still carries, so a package outside it can state what
its own run is, perform one Workspace-coordinated durable mutation, and read
the Workspace without performing one — none of them holding the authority
underneath.

`createWorkflowRunInstallation(preparation)` is the generic constructor both
existing installations are now built on. A host supplies the durable
description, whether a successful record is required, how the run is allocated
when nothing is recorded yet, and what a recorded run has to agree with;
Workflow keeps the installation slot, root admission timing, the durable record
parser and the current-run context.

`createWorkflowWorkspaceEffect(database, description, mutate)` runs a mutation
inside the existing lease, savepoint, capture/publication, journal enlistment
and rollback boundary. The callback receives the Workspace filesystem, a
storage view with parsed `get`/`all` reads and parameterized `run` writes, and
a nested `savepoint()` bound to that transaction rather than resolved from the
scope — no connection, lease, journal route or transaction token.

`readWorkflowWorkspace(database, options, inspect)` opens the same
authenticated short transaction for attachment and export without creating a
durable effect. `options.rootId` names a retained root, which Workflow
materializes inside a rollback-only savepoint and always takes back.
Repository and Worktree attachment, `<Git.Push>`'s retained-root export and
`<PullRequest>`'s export now go through it, so no source moving to
`@executablemd/git` needs a private transaction, a root restoration or a
writable filesystem to export a checkout.

A read-only view is proven read-only rather than narrowed by its interface.
`INSERT … RETURNING` is a statement that returns rows, so `all()` would run it
and keep the insertion; the inspection's storage therefore compiles every
statement under a SQLite authorizer admitting only `SQLITE_SELECT`,
`SQLITE_READ` and `SQLITE_FUNCTION`, and an adapter without an authorizer is
refused a read view rather than given one that cannot enforce what it says.

Every capability handed to either callback is revoked when that callback
returns, and each one that hands back an operation rechecks when the operation
begins — the mutation's filesystem included, so a retained writer cannot write
while the transaction is still capturing and publishing its root.

Workflow also publishes the generic journaled-failure base and predicate that
decide whether a failure is the effect's durable result or the run's, and names
its Workspace filesystem types so a package outside this one can spell them.

The repository and worktree mutations are reimplemented through these seams
with the same SQL and the same journal output. The generated-fragment profile
takes its directory write entry from a new captured `directory` option;
`GeneratedEvaluationOptions.writes` keeps its released additive meaning.

No public behavior changes: the same components, records, journal values,
identities and write-table order as before.
Repositories, worktrees, Git operations, pull requests and issues are now
`@executablemd/git`, a package of their own whose default export is the Plugin
named `@executablemd/git`. `@executablemd/workflow` keeps run lifecycle,
storage, replay, schema recognition, artifact handling and Workspace
coordination, and imports none of it.

The whole graph moves as one: the provider-neutral components, APIs, records,
effects and errors; the local Git subprocess, materialization, run-composition
and Git-host adapters; the repository and worktree rows; the credential helper;
and `workflowInstallation({ base })`, which is now built on Workflow's generic
`createWorkflowRunInstallation()`.

Nothing durable moves with the source. Component names, forms, props,
documentation and registration order are unchanged, and every released origin
is preserved exactly — including `@executablemd/workflow/composition` and the
`@executablemd/workflow/composition/dir-v2#Dir` alias. Effect types, journal
records, SQLite tables and artifact bytes are untouched, so an existing run
replays as it did.

Three seams replace what Workflow used to reach directly:

- `WorkflowWorkspaceOptions.attachments` lets a host name the features that
  install into a run's Workspace. `gitWorkspaceAttachment()` is this package's,
  installed in the same position and the same order the providers always were.
- `gitIdentityInstallation()` carries the per-execution Git-host and Issue
  identity queues that used to sit beside the run. It is an
  `ExecutionInstallation` rather than part of the Plugin because a Plugin
  installs once per command while those queues belong to one execution.
- Workflow publishes the version-1 run description and its two refusals, the
  advisory lock, and its Workspace attachment types, because a host that states
  what its own run is still needs what that statement is compared against.

Two places in Workflow classify retained rows a feature it no longer owns
wrote. The generated-fragment profile states the released `<Dir>` origin, and
fork classification recognizes a completed Git-host reconciliation by its exact
declared shape. Both are written out as this package's own compatibility data,
the way every other retained-record string in those files already is.

The publish workflow is regenerated from the manifests: Workflow publishes
before Git, and Git before the CLI.
A Workspace attachment owns this run's providers and durable state. It does
not own the names: `<Repository>` and the twelve components beside it are the
Plugin's, installed once per command in the scope that encloses everything the
command does. Declaring them again inside the attachment registered the same
names a second time, one scope deeper.

The test host had the same confusion in a sharper form. It asked the Plugin
for its contribution from inside the callback where a case installs a
shadowing registration, so the Plugin's declarations landed in the case's own
scope and collided with the shadow — which is what made
`pull-request.test.ts`'s nested-shadow case fail. The Plugin is now installed
where a command installs it, once, enclosing both the attachment and the
document.

Duplicate-registration refusal and shadowing are unchanged: this removes a
declaration that should not have been made, and narrows nothing about what
happens when two are.
GitHub ships inside `@executablemd/git`, and now it sits behind a boundary
rather than merely inside a directory.

The two CLI configuration modules move into the package and are read when an
invoked GitHub-backed operation needs them, never at startup. Matching a
target reads nothing outside the process, so a request bound elsewhere passes
this adapter without its configuration being consulted, and an unauthorized
destination reaches the surface's own base error exactly as installing no
provider used to.

`composition/github.ts` keeps the protocol — parsing, headers, pagination,
normalization, provider behaviour — and `composition/github-host.ts` takes the
environment, the credential-helper subprocess, the temporary directory and the
concrete `fetch`. The implementation no longer reaches for the platform: it is
handed an inert transport factory, and refuses in its own established
vocabulary when it has neither that nor an injected access.

`upsertPullRequest` terminates both Workflow reads — the Workspace export and
the journal's push evidence — before the GitHub half is handed a locator,
admitted inputs and a session. `useGitHubPullRequests` joins that orchestration
so no module belongs to two sets.

Three regressions hold the arrangement up: a partition scan that classifies
every production module by three declared lists and fails on one named in
none; an entrypoint scan separating the GitHub contracts a host needs from the
package-local seams that must not escape; and an activation scan pinning the
order installation → target → configuration → credential → transport, with
negative surfaces that throw rather than count.
XMD ships one Plugin and activates it for every command that executes or
describes a document. `assembleRunProfile()` prefixes the statically imported
`@executablemd/git`, resolves the reserved `git` selector to that value without
loading a module, and appends authored specifiers in order. `installPlugins()`
and `NO_PLUGINS` are untouched: which Plugins a command runs with is the
caller's business, and an empty list still installs nothing.

The profile is now the only route. `cli.ts` and `syntax.ts` no longer bootstrap
the repository vocabulary, and the workflow command no longer builds Git's
admissions a second time — the profile carries them, so what used to be a
duplicate installation is one. `xmd test` is not a run profile and loses the
vocabulary; a nested `host="run"` child assembles it for itself, prefixing the
bundled value by *identity* so an impostor claiming the name still collides.

Installing the repository provider now costs nothing. Every capability — the
Git session, the managed root, the leases, the invocation identity, the commit
identity and ambient discovery — is acquired by the first operation that needs
it, single-flight, owned by the installation scope and held by suspension. A
run that touches no repository creates no managed root.

`<Dir>`'s write-table entry moves to the package that owns the component.
Workflow states none: a host that supplies no directory capability grants none,
and the XMD workflow profile supplies Git's at the released position between
the file write and the file delete, under the identity retained history holds.
The documentation said XMD ships no Plugin. It ships one.

`architecture.md` and the specs now state the profile as it is: one bundled
`@executablemd/git`, statically imported, first in the active list, active for
the commands that execute or describe a document and absent from `xmd test`,
`upgrade` and a workflow management action. `git` is described as a reserved
host selector — it names the value the profile already holds, loads nothing,
and repeating it changes nothing — while a module claiming the same Plugin name
is an ordinary duplicate. Tier PL gains three cases for the selector, the
name-versus-selector distinction, and test isolation.

Workflow is no longer credited with owning Git. Its spec's §7 says where the
capability went, `@executablemd/workflow`'s module doc drops the Git-host
section describing functions it no longer exports, and the repository
vocabulary is attributed to the Plugin that declares it rather than to a
Workspace attachment, which owns a run's providers and durable state and
declares nothing.

Eager acquisition is described as what it became: ambient discovery on the
first ambient request, a commit identity on the first commit, GitHub
configuration when an invoked GitHub-backed operation needs it, and installation
costing nothing at all. `deno-repositories.ts`'s own comment said the opposite
of the code above it.

Released durable origin strings are untouched: `<Dir>`'s entry is named as the
Plugin's while it still states `@executablemd/workflow/composition`, because
that string identifies retained history rather than current source ownership.

Documentation only. Both TypeScript diffs are comment-only, and no executable
behavior, type, export, manifest, fixture, generated file or measured weight
changes.
`packages/git` was the only workspace member missing from
`tsconfig.node.json`'s `include`, so the package this stack extracted was
invisible to the Node typecheck — the check whose whole job is catching the
places where Node's lib trails Deno's.

Adding it surfaced the second `toSorted` immediately. Both are replaced with
`sort`, which ES2022 has: `map` already returned a fresh array in the CLI
case, and the partition case copies first because it reads the array again.

Node execution was never affected — discovery walks the workspace, not the
tsconfig — so the gap was typecheck-only and silent.
The previous commit put `packages/git` back in `tsconfig.node.json`. Nothing
kept it there: once the two ES2023 calls were gone, dropping the member again
would have typechecked clean and run green, because shard discovery walks the
workspace rather than the tsconfig — an unchecked package still executes.

So the list is asserted against the filesystem instead of against itself.
Every `packages/*` that ships a `deno.json` must appear in `include`, and
adding a package is what fails here rather than remembering to add it.

Read through TypeScript's own config reader, since the file carries comments
that `JSON.parse` refuses.
The regression looked for `packages/*/deno.json`, which is not what a
workspace member is: `test-support` is internal and ships only
`package.json`. It was therefore never examined — an existing member left
permanently unchecked by the case whose whole subject is members left
unchecked.

Both manifests now, deduplicated, with `test-support` asserted beside `git`
and `workflow` so the union is covered rather than assumed.

The two type assertions go with it. The suite already parses unknown JSON
through `object()` and `strings()`, which name the member that was wrong
instead of asserting it was right.
@taras
taras merged commit 85b5d2e into main Sep 21, 2026
37 of 39 checks passed
@taras
taras deleted the agent/issue-822-git-plugin branch September 21, 2026 21:23
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.

Make Git and GitHub available in XMD runs and workflows

1 participant