✨ Make Git and GitHub available in XMD runs and workflows - #831
Merged
Merged
Conversation
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.
This was referenced Sep 21, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #822.
Why
Repository collaboration —
<Repository>,<Worktree>,<Dir>, theGit.*operations, pull requests and issues — lived inside@executablemd/workflow, so an ordinaryxmd runcould 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,scmor GitHub package, Plugin or selector.What changes
Before:
Cannot resolve component: Repository.@executablemd/workflowowned the Git capability, the GitHub provider and the Git-host reconciliation engine.xmd runreached repository behavior through two direct composition bootstraps incli.tsandsyntax.ts.After:
@executablemd/gitis a package and one complete Plugin, bundled into every run-profile command:run,plan,syntax, andworkflow start/resume/fork. No--pluginselection is needed for the existing vocabulary.@executablemd/workflownames no Git feature in an import, in any form.How it works
The production dependency direction is
@executablemd/cli→@executablemd/git→@executablemd/workflow.assembleRunProfile()prefixes the statically imported Plugin value and appends authored--pluginspecifiers 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.tsThen review:
packages/git/src/plugin.ts— which commands declare Git, read through the host's own pre-command grammarpackages/git/src/deno/run-composition/provider.ts—lazily()and the six capabilities behind itpackages/git/src/composition/definitions.ts—gitDirectoryEntry()and the preserved released originpackages/workflow/mod.tsandpackages/workflow/src/run.ts— what Workflow still ownsLook 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
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.lazily(), checked bypackages/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>.<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.xmd testroot 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.gitis idempotent — it names the bundled value, resolves without loading a module, and repeating it changes nothing.--plugin @executablemd/gitis an ordinary module specifier and meetsadmitPlugins()'s duplicate-name refusal.How to verify it
packages/cli/tests/run-profile.test.tsdrives 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.tsruns the same<Dir>element through the real binary in three places: resolving forxmd run, failing at thexmd testroot withCannot resolve component: Dir, and resolving again inside a nested run child.packages/git/tests/github-activation.test.tsinstalls 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.tsdeclares three explicit non-empty sets — provider-neutral, GitHub implementation, Deno adapter — so an unclassified module fails rather than being silently absorbed.Scope
Included
@executablemd/gitpackage and Plugin, owning both provider-neutral Git contracts and package-internal GitHub implementations.gitselector.architecture.md, four specs and package docs.Intentionally unchanged
installPlugins()andNO_PLUGINSremain generic primitives: which Plugins a command runs with is the CLI's business, and an empty list installs nothing.Generated or mechanical changes
R100) from moving Workflow's repository modules intopackages/git/..github/workflows/publish-packages.ymlis regenerated bydeno task gen:publish-workflow. The publication order gainsgitafterworkflow, matching the new dependency direction.test-weights.jsonis committed byte-for-byte from the Measure test weights artifact, with provenance naming the measured head.Risks and limitations
scripts/validate-documentation.tsassembles the run profile accordingly.npm installaborting with exit 134 inside dnt during esbuild's postinstall. It passed alone and in a repeated full matrix on the identical revision.Scope confirmation