diff --git a/architecture.md b/architecture.md index eaf3086c..1609bc6b 100644 --- a/architecture.md +++ b/architecture.md @@ -34,6 +34,10 @@ Existing documents and code get aligned to this section retroactively. | definition base | the Git revision supplied to choose a workflow definition's pinned commit | | Repository base | the optional Git revision from which one named Repository initializes its primary checkout | | Repository selection | plain structural composition data naming the repository one component invocation acts on: an opaque provider-minted selection identifier, the display name, the credential-free repository identity, and the selected checkout path. It carries no credential, provider handle, lock, database, run ID or authority — the installed provider authenticates every selection against private state before it touches Git or a service, so a copied, replaced or rebuilt one can misname a target and be refused but can never reach one | +| ambient Repository | the repository an ordinary document execution was started inside, discovered once before root expansion from the invocation's starting directory. Its identity is the canonical common Git directory and its selected checkout is the canonical checkout root, so starting in a linked worktree names the same repository as starting in the primary checkout while Git operations still act on the worktree. A workflow run has none | +| managed checkout | a Repository or Worktree an ordinary document execution created under the host root `~/.xmd/repositories`, addressed by a digest of its whole identity, described by a closed version 1 sidecar written beside it, and held for one document execution by an exclusive non-blocking advisory lock. It survives every execution: nothing deletes, resets, cleans, fetches or repairs one | +| ordinary Git identity | the invoking user's effective Git author and committer name and email, captured once from the trusted host's own environment and configuration before a document expands and kept in the provider's closure. It is used for an ordinary `` and nothing else, because that commit lands in the caller's own checkout; a workflow run keeps its one fixed identity, whose whole purpose is that retained Git state does not depend on whose machine made it. It is not a prop, a Context value, a component result or a middleware answer, and nothing else about the environment is borrowed with it — hooks, file-system monitors, signing programs and repository-supplied credential helpers stay disabled by the same fixed command-line configuration. A host where Git can name no identity refuses `` with an actionable sentence rather than substituting the workflow one; every other component is unaffected | +| ordinary invocation identity | a fresh opaque random value an ordinary document execution's repository provider mints for itself and keeps in its own closure. It is not a prop, a Context value, a component result, a middleware answer, a lifecycle ID or a retained record, and it is neither addressable nor reusable; live Issue and pull-request idempotency and reconciliation keys are derived from it together with the engine's own expansion identity | | pinned commit | the commit obtained by resolving a base once; it remains the workflow run's starting repository state even as the run creates descendant commits | | document target | an addressable static heading in a root document's own Markdown flow, named by the canonical path of heading labels that reaches it; selecting one executes the preamble, each ancestor's own content, and that heading's complete subtree | | Prompt | a person's original request, in ordinary natural language. `xmd plan` takes exactly one | @@ -2384,9 +2388,10 @@ A Repository selection is composition data and is therefore replaceable: a document may bind one, render one, hand one to a child, and construct one that looks exactly like it. Nothing a repository provider does is authorized by the value it was handed. What stays provider-owned, in the provider's own closure, -is everything a selection is *not*: the canonical Git identity each selection -resolves to, the retained rows behind it, and the credentials and locators used -to reach a service. A selection that the provider did not mint, or one whose +is everything a selection is *not*: the advisory locks on managed checkouts, +the canonical Git identity each selection resolves to, an ordinary run's live +Push evidence, its invocation identity, and every reconciliation key derived +from them. A selection that the provider did not mint, or one whose name, checkout path or identity was edited after it did, is refused before Git or a service is touched. diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index 1e356dbc..3ab0e119 100644 --- a/packages/workflow/deno.ts +++ b/packages/workflow/deno.ts @@ -141,3 +141,14 @@ export type { SuspensionControllerOptions, SuspensionNotice, } from "./src/deno/suspension.ts"; +/** + * The ordinary run's repository provider. + * + * The installer alone, and the options a trusted entrypoint supplies to it. + * What the provider holds — the leases, the credential assembly, the selection + * registry, the live Push evidence and the metadata writer — stays inside it: + * a package that could reach one of those could authorize a publication this + * execution never made. + */ +export { useRunComposition } from "./src/deno/run-composition/provider.ts"; +export type { RunCompositionOptions } from "./src/deno/run-composition/provider.ts"; diff --git a/packages/workflow/src/deno/run-composition/ambient.ts b/packages/workflow/src/deno/run-composition/ambient.ts new file mode 100644 index 00000000..489246dd --- /dev/null +++ b/packages/workflow/src/deno/run-composition/ambient.ts @@ -0,0 +1,168 @@ +/** + * The repository the person running the document is standing in. + * + * A workflow document names every repository it touches, because a workflow is + * a program that runs somewhere else. An ordinary `xmd run` is a command + * somebody typed in a checkout, so the checkout is the obvious subject — and + * making it the default is what lets a document say + * + * ```md + * + * ``` + * + * and mean the repository the command was run in. + * + * ## Two identities, and they are not the same + * + * The **common Git directory** identifies the repository; the **checkout root** + * identifies which of its checkouts this invocation is in. They differ exactly + * when the caller is standing in a linked worktree — where `.git` is a file + * naming the primary repository's administration — and keeping them apart is + * what makes starting XMD in a worktree produce the same Repository identity as + * starting it in the primary checkout, while Git operations still act on the + * worktree the command was actually run in. + * + * ## Discovery is not an operation the document asked for + * + * It happens once, before root expansion, from the invocation's starting + * directory. Being outside a repository is not a startup failure: a document + * that never asks for a Repository-dependent operation runs exactly as it would + * anywhere else, and only an element that needs one refuses. + * + * The `origin` is read the same way, and its absence is likewise not a failure. + * A repository with no origin is a perfectly good Repository for a Worktree, a + * Switch, an Add and a Commit; it is only Push and PullRequest that need a + * destination, and each of those checks for one before it opens a credential. + */ + +import { realpath } from "node:fs/promises"; +import { basename } from "node:path"; +import { until, type Operation } from "effection"; +import type { GitObjectFormat } from "../../composition/records.ts"; +import { admitLocator, locatorFingerprint } from "../composition/locator.ts"; +import { currentBranch, readObjectFormat, resolveCommit } from "../composition/git.ts"; +import type { GitSession } from "../composition/git.ts"; + +/** What one Git checkout on this host turned out to be. */ +export interface AmbientRepository { + /** The display name a document sees: the checkout directory's own name. */ + readonly name: string; + /** The canonical root of the checkout the invocation started in. */ + readonly checkoutRoot: string; + /** The canonical common Git directory, which identifies the repository. */ + readonly commonDirectory: string; + readonly objectFormat: GitObjectFormat; + /** The commit HEAD named when this invocation started. */ + readonly head: string; + /** The locally recorded, admitted `origin`, or `undefined` when there is none. */ + readonly origin: string | undefined; + readonly originFingerprint: string | undefined; + /** + * The branch a `` defaults its base to. + * + * `refs/remotes/origin/HEAD` when the checkout records one, and the branch + * this invocation started on otherwise. Nothing is asked of a remote for it: + * a default branch this run had to fetch would make an ordinary document + * reach the network before it did anything. + */ + readonly defaultBranch: string; +} + +/** + * The canonical directory this path resolves to, or `undefined`. + * + * Canonicalization matters more than usual here. `/var` on macOS is + * `/private/var`, and Git writes the resolved path into a linked worktree's + * administration — so a comparison against an unresolved path would report a + * worktree as belonging to no repository. + */ +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return undefined; + } +} + +/** + * Discover the ambient repository from this directory, or answer `undefined`. + * + * Every step is a local Git question. Nothing here contacts a remote, opens a + * credential or writes anything. + */ +export function* discoverAmbientRepository( + git: GitSession, + from: string, +): Operation { + const reportedRoot = yield* git.read(["rev-parse", "--show-toplevel"], from); + if (reportedRoot === undefined) { + return undefined; + } + const checkoutRoot = yield* canonical(reportedRoot); + if (checkoutRoot === undefined) { + return undefined; + } + + const reportedCommon = yield* git.read(["rev-parse", "--git-common-dir"], checkoutRoot); + if (reportedCommon === undefined) { + return undefined; + } + // Without `--path-format=absolute`, which not every supported Git has: a + // linked worktree already answers absolutely, and a primary checkout answers + // `.git` relative to itself. + const commonDirectory = yield* canonical( + reportedCommon.startsWith("/") ? reportedCommon : `${checkoutRoot}/${reportedCommon}`, + ); + if (commonDirectory === undefined) { + return undefined; + } + + const objectFormat = yield* readObjectFormat(git, checkoutRoot); + const head = yield* resolveCommit(git, checkoutRoot, "HEAD"); + if (objectFormat === undefined || head === undefined) { + // A directory Git recognizes but cannot say the shape of is not a + // repository this provider will act on. Refusing here is the same answer as + // being outside one, and for the same reason: nothing has been read that + // could name a checkout. + return undefined; + } + + const branch = yield* currentBranch(git, checkoutRoot); + const recorded = yield* git.read(["config", "--get", "remote.origin.url"], checkoutRoot); + // Admitted on the way in, not on the way out: what is not a locator this + // provider would hand to Git is a repository with no usable origin, which is + // a state Push and PullRequest already know how to refuse. + const origin = recorded === undefined ? undefined : admitLocator(recorded); + + const recordedDefault = yield* git.read( + ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], + checkoutRoot, + ); + const defaultBranch = defaultFrom(recordedDefault, branch); + + return Object.freeze({ + name: basename(checkoutRoot), + checkoutRoot, + commonDirectory, + objectFormat, + head, + origin, + originFingerprint: origin === undefined ? undefined : locatorFingerprint(origin), + defaultBranch, + }); +} + +/** + * The default branch, from what the checkout records. + * + * `refs/remotes/origin/HEAD` reads as `origin/main`, and what a base names is + * `main`. A detached HEAD with no recorded remote default leaves nothing to + * name, and the empty string is what a `` then has to be given a + * `base` for. + */ +function defaultFrom(recorded: string | undefined, branch: string | undefined): string { + if (recorded !== undefined && recorded.startsWith("origin/")) { + return recorded.slice("origin/".length); + } + return recorded ?? branch ?? ""; +} diff --git a/packages/workflow/src/deno/run-composition/checkouts.ts b/packages/workflow/src/deno/run-composition/checkouts.ts new file mode 100644 index 00000000..b9e8052d --- /dev/null +++ b/packages/workflow/src/deno/run-composition/checkouts.ts @@ -0,0 +1,631 @@ +/** + * Managed checkouts: creating one, reusing one, and refusing everything else. + * + * A workflow Repository is created once and restored from a journal. A managed + * one is created once and then *found again* by later executions that have no + * journal at all, so everything a run needs to trust about it has to be + * re-established from what is on disk, every time, under the slot's lock. + * + * ## Reuse checks creation identity, and only creation identity + * + * What is compared is what the checkout was made from: the immutable request, + * the recorded creation facts, and the Git identity of the directory. HEAD, the + * current branch, the index, the working tree and dirtiness are all deliberately + * unchecked — they are the mutable work the checkout exists to preserve, and a + * reuse that required them to match creation would refuse every checkout + * anybody had actually used. + * + * A conflict is a refusal, never a repair. Nothing here resets, switches, + * cleans, fetches, moves, replaces or deletes; a slot that does not match is + * left byte for byte as it was found and the sentence says what it is. + * + * ## An interrupted creation is adopted only when it can be proved + * + * A process killed between cloning and writing the sidecar leaves a checkout + * nothing describes. Deleting it would be destroying work; using it blindly + * would be trusting a directory this provider cannot account for. So it is + * adopted only after the *stricter* pre-exposure state is proved — the exact + * owner and locator, the branch and base this request resolves to, the creation + * commit still being HEAD, the object format, and nothing in the slot but the + * checkout — and the sidecar is then written atomically. Anything less refuses. + */ + +import { open, rename } from "node:fs/promises"; +import { ensureDir, exists, readTextFile, readdir } from "@effectionx/fs"; +import { realpath } from "node:fs/promises"; +import { ensure, scoped, until, type Operation } from "effection"; +import { randomUUID } from "node:crypto"; +import type { GitObjectFormat } from "../../composition/records.ts"; +import { admitLocator, locatorFingerprint } from "../composition/locator.ts"; +import { + addWorktree, + branchExists, + checkoutPrimary, + clone, + commonDirectory, + currentBranch, + objectFormat as readFormat, + originLocator, + readObjectFormat, + resolveBaseCommit, + resolveCommit, + resolveRepositoryStart, +} from "../composition/git.ts"; +import type { GitSession } from "../composition/git.ts"; +import { useGitAuthentication, type RepositoryHost } from "../composition/host.ts"; +import { repositoryRefused, worktreeRefused } from "../composition/refusals.ts"; +import { ManagedCheckoutError } from "./errors.ts"; +import { + METADATA_VERSION, + metadataBytes, + parseRepositoryMetadata, + parseWorktreeMetadata, + type ManagedMetadata, + type ManagedRepositoryMetadata, + type ManagedWorktreeMetadata, +} from "./metadata.ts"; +import { CHECKOUT, checkoutOf, METADATA, metadataOf } from "./placement.ts"; + +/** One managed checkout, once this execution is entitled to work in it. */ +export interface ManagedCheckout { + readonly checkout: string; + readonly commonDirectory: string; + readonly objectFormat: GitObjectFormat; + readonly creationCommit: string; +} + +export interface ManagedRepository extends ManagedCheckout { + readonly metadata: ManagedRepositoryMetadata; +} + +export interface ManagedWorktree extends ManagedCheckout { + readonly metadata: ManagedWorktreeMetadata; +} + +function conflict(reason: "incompatible-reuse" | "partial-creation", sentence: string): never { + throw new ManagedCheckoutError(reason, sentence); +} + +function unusable(sentence: string): never { + throw new ManagedCheckoutError("unusable-checkout", sentence); +} + +/** + * Write a sidecar by exclusive temporary sibling plus atomic rename. + * + * Exclusive because the temporary name must never be one another process is + * already writing; atomic because a reader under the same lock must see either + * the whole sidecar or none of it, and a partially written one would describe a + * checkout nobody made. + */ +function* writeMetadata(slot: string, metadata: ManagedMetadata): Operation { + const temporary = `${metadataOf(slot)}.${randomUUID()}`; + yield* scoped(function* () { + const handle = yield* until(open(temporary, "wx")); + // Registered before the write, so a halt closes the descriptor: closing is + // asynchronous, and a `finally` that suspended would not be guaranteed to + // finish. + yield* ensure(() => until(handle.close())); + yield* until(handle.writeFile(metadataBytes(metadata), "utf8")); + }); + yield* until(rename(temporary, metadataOf(slot))); +} + +/** The sidecar this slot holds, or `undefined` when it holds none. */ +function* readMetadata(slot: string): Operation { + const path = metadataOf(slot); + if (!(yield* exists(path))) { + return undefined; + } + const bytes = yield* readTextFile(path); + try { + return JSON.parse(bytes); + } catch { + return null; + } +} + +/** The canonical directory this path resolves to, or `undefined`. */ +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return undefined; + } +} + +/** + * The Git facts a slot's checkout reports, once it reports a whole set. + * + * Read as one group because a comparison needs all of it: a checkout that can + * answer where its objects are but not which repository it belongs to is not + * one this provider can decide anything about. + */ +interface CheckoutFacts { + readonly root: string; + readonly commonDirectory: string; + readonly objectFormat: GitObjectFormat; + readonly head: string; +} + +function* readCheckoutFacts( + git: GitSession, + directory: string, +): Operation { + const reportedRoot = yield* git.read(["rev-parse", "--show-toplevel"], directory); + if (reportedRoot === undefined) { + return undefined; + } + const root = yield* canonical(reportedRoot); + const common = yield* commonDirectory(git, directory); + const objectFormat = yield* readObjectFormat(git, directory); + const head = yield* resolveCommit(git, directory, "HEAD"); + if (root === undefined || common === undefined || objectFormat === undefined) { + return undefined; + } + const commonCanonical = yield* canonical(common); + if (commonCanonical === undefined || head === undefined) { + return undefined; + } + return { root, commonDirectory: commonCanonical, objectFormat, head }; +} + +/** Whether the slot holds nothing but its own checkout directory. */ +function* slotHoldsOnlyCheckout(slot: string): Operation { + const entries = yield* readdir(slot); + return entries.length === 1 && entries[0] === CHECKOUT; +} + +/** + * Select the managed Repository this request names. + * + * The lease is already held by the caller, so everything below is this + * execution's alone until the execution ends. + */ +export function* selectManagedRepository( + git: GitSession, + host: RepositoryHost, + slot: string, + request: { readonly name: string; readonly locator: string; readonly base: string | undefined }, +): Operation { + const locator = admitLocator(request.locator); + if (locator === undefined) { + repositoryRefused(request.name, "invalid-locator"); + } + const fingerprint = locatorFingerprint(locator); + const checkout = checkoutOf(slot); + const requestedBase = request.base ?? null; + + const stored = yield* readMetadata(slot); + if (stored !== undefined) { + const metadata = parseRepositoryMetadata(stored); + if (metadata === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(request.name)} carries a ` + + `${METADATA} this version cannot read, so what it holds cannot be decided. Nothing ` + + "was changed.", + ); + } + if ( + metadata.name !== request.name || + metadata.locator !== locator || + metadata.locatorFingerprint !== fingerprint || + metadata.requestedBase !== requestedBase + ) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(request.name)} was created from a ` + + "different url or base than this invocation asks for. Nothing was reset, fetched or " + + "replaced; ask for a different name to get a checkout of your own.", + ); + } + const facts = yield* verifyRepository(git, checkout, metadata, request.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + if (yield* exists(checkout)) { + const metadata = yield* adoptRepository(git, slot, checkout, { + name: request.name, + locator, + fingerprint, + requestedBase, + base: request.base, + }); + const facts = yield* verifyRepository(git, checkout, metadata, request.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + return yield* createManagedRepository(git, host, slot, checkout, { + name: request.name, + locator, + fingerprint, + requestedBase, + base: request.base, + }); +} + +interface RepositoryCreation { + readonly name: string; + readonly locator: string; + readonly fingerprint: string; + readonly requestedBase: string | null; + readonly base: string | undefined; +} + +/** + * What a compatible reuse re-establishes about a repository's checkout. + * + * Five facts, and each of them is something an edited or replaced directory + * would fail: it is a canonical checkout at exactly this path, it belongs to + * the common directory recorded for it, it names its objects the same way, its + * `origin` still names the recorded locator, and the commit it was created at + * is still present. None of it is about where HEAD is now. + */ +function* verifyRepository( + git: GitSession, + checkout: string, + metadata: ManagedRepositoryMetadata, + name: string, +): Operation<{ commonDirectory: string; objectFormat: GitObjectFormat }> { + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined) { + unusable( + `the managed checkout for repository ${JSON.stringify(name)} is no longer a readable Git ` + + "checkout. Nothing was changed; move it aside or ask for a different name.", + ); + } + if (facts.root !== expected || facts.commonDirectory !== metadata.commonDirectory) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} belongs to a different Git ` + + "repository than the one recorded for it. Nothing was changed.", + ); + } + if (facts.objectFormat !== metadata.objectFormat) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} names its objects with a ` + + "different algorithm than the one recorded for it. Nothing was changed.", + ); + } + if ((yield* originLocator(git, checkout)) !== metadata.locator) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} no longer has the origin it ` + + "was cloned from. Nothing was changed.", + ); + } + if ((yield* resolveCommit(git, checkout, metadata.creationCommit)) === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} no longer holds the commit it ` + + "was created at. Nothing was fetched or repaired.", + ); + } + return { commonDirectory: facts.commonDirectory, objectFormat: facts.objectFormat }; +} + +/** + * Adopt an interrupted repository creation, or refuse and change nothing. + * + * The proof is stricter than a reuse's, because there is no record to compare + * against: the checkout has to still be in exactly the state creation would + * have left it in — nothing but the checkout in the slot, the recorded origin, + * the branch and commit this request resolves to, and HEAD still on them. + */ +function* adoptRepository( + git: GitSession, + slot: string, + checkout: string, + creation: RepositoryCreation, +): Operation { + function refuse(): never { + conflict( + "partial-creation", + `the managed checkout for repository ${JSON.stringify(creation.name)} holds an interrupted ` + + "creation this version cannot account for. Every byte was left where it was: look at it, " + + "or ask for a different name.", + ); + } + + if (!(yield* slotHoldsOnlyCheckout(slot))) { + refuse(); + } + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined || facts.root !== expected) { + refuse(); + } + if ((yield* originLocator(git, checkout)) !== creation.locator) { + refuse(); + } + const start = yield* resolveRepositoryStart(git, checkout, creation.base); + if (start.commit !== facts.head) { + refuse(); + } + if ((yield* currentBranch(git, checkout)) !== start.primaryBranch) { + refuse(); + } + + const metadata: ManagedRepositoryMetadata = Object.freeze({ + kind: "repository" as const, + version: METADATA_VERSION, + name: creation.name, + locator: creation.locator, + locatorFingerprint: creation.fingerprint, + requestedBase: creation.requestedBase, + creationCommit: start.commit, + primaryBranch: start.primaryBranch, + objectFormat: facts.objectFormat, + commonDirectory: facts.commonDirectory, + }); + yield* writeMetadata(slot, metadata); + return metadata; +} + +function* createManagedRepository( + git: GitSession, + host: RepositoryHost, + slot: string, + checkout: string, + creation: RepositoryCreation, +): Operation { + yield* ensureDir(slot); + // One session for this clone, opened after the locator was admitted and + // released with the scope this operation runs in. + const session = yield* useGitAuthentication(host, creation.locator); + yield* clone(git, creation.locator, checkout, slot, session); + const start = yield* resolveRepositoryStart(git, checkout, creation.base); + yield* checkoutPrimary(git, checkout, start); + const format = yield* readFormat(git, checkout); + const common = yield* commonDirectory(git, checkout); + const commonCanonical = common === undefined ? undefined : yield* canonical(common); + if (commonCanonical === undefined) { + unusable( + `the checkout just cloned for repository ${JSON.stringify(creation.name)} does not report ` + + "the Git directory it belongs to.", + ); + } + + const metadata: ManagedRepositoryMetadata = Object.freeze({ + kind: "repository" as const, + version: METADATA_VERSION, + name: creation.name, + locator: creation.locator, + locatorFingerprint: creation.fingerprint, + requestedBase: creation.requestedBase, + creationCommit: start.commit, + primaryBranch: start.primaryBranch, + objectFormat: format, + commonDirectory: commonCanonical, + }); + // After the checkout is complete and verified, never before: a sidecar that + // existed beside a half-made checkout would make the next execution reuse it. + yield* writeMetadata(slot, metadata); + return { + checkout, + commonDirectory: commonCanonical, + objectFormat: format, + creationCommit: start.commit, + metadata, + }; +} + +/** What a Worktree selection asks for, once its owner is known. */ +export interface WorktreeCreation { + readonly name: string; + readonly branch: string; + readonly base: string | undefined; + /** The canonical common Git directory of the repository it belongs to. */ + readonly owner: string; + /** A checkout of that repository, which is where `worktree add` runs. */ + readonly ownerCheckout: string; +} + +export function* selectManagedWorktree( + git: GitSession, + slot: string, + creation: WorktreeCreation, +): Operation { + const checkout = checkoutOf(slot); + const requestedBase = creation.base ?? null; + + const stored = yield* readMetadata(slot); + if (stored !== undefined) { + const metadata = parseWorktreeMetadata(stored); + if (metadata === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(creation.name)} carries a ` + + `${METADATA} this version cannot read. Nothing was changed.`, + ); + } + if ( + metadata.name !== creation.name || + metadata.owner !== creation.owner || + metadata.requestedBranch !== creation.branch || + metadata.requestedBase !== requestedBase + ) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(creation.name)} was created for a ` + + "different repository, branch or base than this invocation asks for. Nothing was " + + "reset or replaced; ask for a different name to get a worktree of your own.", + ); + } + const facts = yield* verifyWorktree(git, checkout, metadata, creation.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + if (yield* exists(checkout)) { + const metadata = yield* adoptWorktree(git, slot, checkout, creation); + const facts = yield* verifyWorktree(git, checkout, metadata, creation.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + return yield* createManagedWorktree(git, slot, checkout, creation); +} + +/** + * What a compatible reuse re-establishes about a worktree's checkout. + * + * The owner relationship is the one that matters and the one a plain "is this a + * checkout" question cannot see: a linked worktree's common directory is the + * repository it belongs to, so comparing it is what proves this checkout is + * still a worktree *of that repository* rather than an unrelated clone left at + * the same path. + */ +function* verifyWorktree( + git: GitSession, + checkout: string, + metadata: ManagedWorktreeMetadata, + name: string, +): Operation<{ commonDirectory: string; objectFormat: GitObjectFormat }> { + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined) { + unusable( + `the managed checkout for worktree ${JSON.stringify(name)} is no longer a readable Git ` + + "checkout. Nothing was changed.", + ); + } + if (facts.root !== expected || facts.commonDirectory !== metadata.owner) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(name)} is no longer a linked checkout ` + + "of the repository it belongs to. Nothing was changed.", + ); + } + if (facts.objectFormat !== metadata.objectFormat) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(name)} names its objects with a ` + + "different algorithm than the one recorded for it. Nothing was changed.", + ); + } + if ((yield* resolveCommit(git, checkout, metadata.creationCommit)) === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(name)} no longer holds the commit it ` + + "was created at. Nothing was fetched or repaired.", + ); + } + return { commonDirectory: facts.commonDirectory, objectFormat: facts.objectFormat }; +} + +function* adoptWorktree( + git: GitSession, + slot: string, + checkout: string, + creation: WorktreeCreation, +): Operation { + function refuse(): never { + conflict( + "partial-creation", + `the managed checkout for worktree ${JSON.stringify(creation.name)} holds an interrupted ` + + "creation this version cannot account for. Every byte was left where it was.", + ); + } + + if (!(yield* slotHoldsOnlyCheckout(slot))) { + refuse(); + } + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined || facts.root !== expected) { + refuse(); + } + // Registration as a linked worktree of exactly this repository, which is what + // makes an unrelated clone at the same path fail rather than be adopted. + if (facts.commonDirectory !== creation.owner) { + refuse(); + } + if ((yield* currentBranch(git, checkout)) !== creation.branch) { + refuse(); + } + const start = yield* worktreeStart(git, creation); + if (start === undefined || start !== facts.head) { + refuse(); + } + + const metadata: ManagedWorktreeMetadata = Object.freeze({ + kind: "worktree" as const, + version: METADATA_VERSION, + owner: creation.owner, + name: creation.name, + requestedBranch: creation.branch, + requestedBase: creation.base ?? null, + creationCommit: start, + objectFormat: facts.objectFormat, + }); + yield* writeMetadata(slot, metadata); + return metadata; +} + +/** + * The commit an adoption expects this worktree to have started at. + * + * The branch is the answer when it already exists — `worktree add + * ` checks it out where it is — and the base, or the owner checkout's + * own commit, when it had to be created. + */ +function* worktreeStart( + git: GitSession, + creation: WorktreeCreation, +): Operation { + if (yield* branchExists(git, creation.ownerCheckout, creation.branch)) { + return yield* resolveCommit(git, creation.ownerCheckout, `refs/heads/${creation.branch}`); + } + return creation.base === undefined + ? yield* resolveCommit(git, creation.ownerCheckout, "HEAD") + : yield* resolveBaseCommit(git, creation.ownerCheckout, creation.base); +} + +function* createManagedWorktree( + git: GitSession, + slot: string, + checkout: string, + creation: WorktreeCreation, +): Operation { + yield* ensureDir(slot); + const added = yield* addWorktree( + git, + creation.ownerCheckout, + checkout, + creation.branch, + creation.base, + ); + const facts = yield* readCheckoutFacts(git, checkout); + if (facts === undefined || facts.commonDirectory !== creation.owner) { + unusable( + `the worktree just created for ${JSON.stringify(creation.name)} does not report the ` + + "repository it belongs to.", + ); + } + + const metadata: ManagedWorktreeMetadata = Object.freeze({ + kind: "worktree" as const, + version: METADATA_VERSION, + owner: creation.owner, + name: creation.name, + requestedBranch: creation.branch, + requestedBase: creation.base ?? null, + creationCommit: added.commit, + objectFormat: facts.objectFormat, + }); + yield* writeMetadata(slot, metadata); + return { + checkout, + commonDirectory: facts.commonDirectory, + objectFormat: facts.objectFormat, + creationCommit: added.commit, + metadata, + }; +} + +/** The refusal a Worktree request reports when native Git refuses it. */ +export function worktreeRefusal(name: string, reason: string): never { + worktreeRefused(name, reason); +} diff --git a/packages/workflow/src/deno/run-composition/errors.ts b/packages/workflow/src/deno/run-composition/errors.ts new file mode 100644 index 00000000..c474b4b4 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/errors.ts @@ -0,0 +1,118 @@ +/** + * What an ordinary run refuses, and in whose words. + * + * Two of the three reuse the vocabulary the components already speak, because + * they are the same conditions: a locator this provider will not use, a base + * that names no commit, a branch another checkout holds. What is new here is + * only what a shared host root adds — a slot another process is working in, and + * a slot whose contents do not match what it would be reused as — so those get + * words of their own. + * + * None of these deletes, resets or repairs anything. A managed checkout is + * somebody's work; a refusal leaves every byte where it was and says what to + * look at. + */ + +import { StaleInputError } from "@executablemd/durable-streams"; + +/** A word from the fixed vocabulary a managed-checkout refusal is reported under. */ +export type ManagedCheckoutReason = + /** Another process holds this slot's lock right now. */ + | "in-use" + /** The slot holds a checkout that is not what this request would reuse. */ + | "incompatible-reuse" + /** The slot holds an interrupted creation this provider cannot prove. */ + | "partial-creation" + /** The slot holds something that is not a readable Git checkout. */ + | "unusable-checkout"; + +/** + * A managed checkout this run may not use. + * + * An ordinary Error rather than a stale-state one: every condition here is + * something the person running the document can act on — wait for the other + * process, look at what is in the slot, ask for a different name — so an + * authored `` region may decide what to do about it, exactly as it + * may for a Repository refusal. + */ +export class ManagedCheckoutError extends Error { + override name = "ManagedCheckoutError"; + + readonly reason: ManagedCheckoutReason; + + constructor(reason: ManagedCheckoutReason, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** + * An element that needs a repository, written where the host is not in one. + * + * A `StaleInputError`, because it is not a refusal a document asked for: a + * document that goes on running past this would run later siblings as though a + * branch had moved. The sentence names the two ways to fix it, because both are + * ordinary — write a ``, or run from inside a checkout. + */ +export class NoAmbientRepositoryError extends StaleInputError { + override name = "NoAmbientRepositoryError"; + + constructor(operation: string) { + super( + `${operation} needs a repository, and it is written outside a in a directory ` + + "that is not inside a Git checkout. Run xmd from inside one, or write " + + ' around it.', + ); + } +} + +/** + * This host cannot say who a commit would be by. + * + * A refusal a document can act on, in the same vocabulary `` + * already speaks: it names the two commands that fix it. Substituting the + * workflow identity instead would write a name nobody in this repository + * recognizes, which is exactly what an ordinary run must not do — and it would + * do it silently. + * + * It reaches `` alone. Repository, Worktree, Dir, Switch, Add, + * Push, Issue and PullRequest write no commit object and are unaffected. + */ +export class UnresolvedGitIdentityError extends Error { + override name = "UnresolvedGitIdentityError"; + + readonly reason = "unresolved-identity"; + + constructor() { + super( + " cannot record who this commit is by: this host's Git reports no author or " + + 'committer identity. Set one with `git config --global user.name "Your Name"` and ' + + "`git config --global user.email you@example.com`, or export GIT_AUTHOR_NAME, " + + "GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL. Nothing was committed, " + + "and no other identity was substituted for yours.", + ); + } +} + +/** + * A branch this run has not published, or has published somewhere else. + * + * The ordinary run's counterpart to the journal scan a workflow run performs. + * The evidence it reads is this provider instance's own record of a verified + * ``; nothing a document, a Context value or a previous `--journal` + * file holds is admissible, which is why an execution that did not push refuses + * here rather than observing the Git host. + */ +export class LivePushEvidenceError extends StaleInputError { + override name = "LivePushEvidenceError"; + + readonly reason: "missing-push-evidence" | "conflicting-push-evidence"; + + constructor(reason: "missing-push-evidence" | "conflicting-push-evidence", sentence: string) { + super( + ` is not authorized by what this execution published: ${sentence} Nothing was ` + + "observed at the Git host, and no pull request was created.", + ); + this.reason = reason; + } +} diff --git a/packages/workflow/src/deno/run-composition/identity.ts b/packages/workflow/src/deno/run-composition/identity.ts new file mode 100644 index 00000000..a295a972 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/identity.ts @@ -0,0 +1,140 @@ +/** + * Who an ordinary run's commits are by. + * + * A workflow run's Git state must not depend on whose machine it was created + * on, so its provider commits under one fixed identity and builds Git's + * environment from nothing. An ordinary run is the opposite case: the commit + * lands in a person's own checkout, on a branch they will look at tomorrow, and + * attributing it to `Executable.md workflow` would put a name in their history + * that nobody there recognizes. + * + * So the invoking user's effective identity is captured once, before the + * document expands, and used for `` alone. + * + * ## Captured from the trusted host, and nowhere else + * + * `git var GIT_AUTHOR_IDENT` and `GIT_COMMITTER_IDENT` are exactly what native + * Git would use: the `GIT_*_NAME`/`GIT_*_EMAIL` variables, then `user.name` and + * `user.email` from the configuration Git itself resolves, then whatever the + * host can auto-detect. Reading it takes the caller's own environment and the + * directory the command was run in, which is why it happens here — at the + * trusted entrypoint's provider construction, before any document code exists. + * + * It is not a prop, a Context value, a component result or a middleware answer, + * and no document can read it, replace it or ask for a different one. + * + * ## Only the identity is borrowed + * + * The commands that run afterwards keep every other protection: hooks, + * file-system monitors, signing programs and repository-supplied credential + * helpers stay disabled by the same fixed command-line configuration a workflow + * run uses, and `HOME` still points at a disposable directory. What crosses + * from the caller's environment is four strings. + * + * ## An unresolvable identity refuses, and refuses narrowly + * + * A host where Git cannot say who the user is is a host that cannot commit, and + * substituting the workflow identity would be writing somebody else's name into + * a person's repository to avoid saying so. `` reports it and names + * the two commands that fix it. Every other component — Repository, Worktree, + * Dir, Switch, Add, Push, Issue, PullRequest — is unaffected: none of them + * writes a commit object. + */ + +import type { Operation } from "effection"; +import process from "node:process"; +import { runProcess } from "../composition/subprocess.ts"; + +/** The four strings a commit object records about who made it. */ +export interface GitCommitIdentity { + readonly authorName: string; + readonly authorEmail: string; + readonly committerName: string; + readonly committerEmail: string; +} + +/** + * What one `git var …_IDENT` answer says, or `undefined` when it says nothing + * usable. + * + * The shape is `Name `, and the timestamp is + * deliberately discarded: when a commit is made is the operation's own decision, + * captured at the moment it runs. + */ +export function parseGitIdent(reported: string): { name: string; email: string } | undefined { + const opened = reported.lastIndexOf(" <"); + const closed = reported.indexOf(">", opened); + if (opened <= 0 || closed < 0) { + return undefined; + } + const name = reported.slice(0, opened).trim(); + const email = reported.slice(opened + 2, closed).trim(); + return name === "" || email === "" ? undefined : { name, email }; +} + +/** How this module asks Git a question. Substituted whole by a suite. */ +export type IdentityReader = (variable: string) => Operation; + +/** + * The reader the trusted entrypoint uses: native Git, the caller's own + * environment, and the directory the command was run in. + * + * The environment is inherited rather than built, which is the one place in + * this provider that is true — the whole question being asked is what the + * caller's environment and configuration say. + */ +export function denoIdentityReader(cwd: string): IdentityReader { + return function* (variable: string): Operation { + const outcome = yield* runProcess({ + command: "git", + args: ["var", variable], + cwd, + env: { ...inherited(), LC_ALL: "C" }, + }); + if (outcome.code !== 0) { + return undefined; + } + const reported = outcome.stdout.trim(); + return reported === "" ? undefined : reported; + }; +} + +function inherited(): Record { + const environment: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined) { + environment[name] = value; + } + } + return environment; +} + +/** + * The identity ordinary commits are made under, or `undefined` when this host + * cannot say. + * + * Both idents are asked for, because Git resolves them separately and a host + * may know one and not the other. Either one missing leaves the whole answer + * absent: a commit whose author this run knows and whose committer it guessed + * would be exactly the substitution this exists to prevent. + */ +export function* captureCommitIdentity( + read: IdentityReader, +): Operation { + const authored = yield* read("GIT_AUTHOR_IDENT"); + const committed = yield* read("GIT_COMMITTER_IDENT"); + if (authored === undefined || committed === undefined) { + return undefined; + } + const author = parseGitIdent(authored); + const committer = parseGitIdent(committed); + if (author === undefined || committer === undefined) { + return undefined; + } + return Object.freeze({ + authorName: author.name, + authorEmail: author.email, + committerName: committer.name, + committerEmail: committer.email, + }); +} diff --git a/packages/workflow/src/deno/run-composition/leases.ts b/packages/workflow/src/deno/run-composition/leases.ts new file mode 100644 index 00000000..27d35380 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/leases.ts @@ -0,0 +1,103 @@ +/** + * The locks an ordinary run holds on the slots it is working in. + * + * A workflow run owns its Workspace outright, so nothing coordinates with + * anything. A managed host root is shared: two `xmd run` processes on one + * machine may name the same Repository at the same moment, and one of them + * cloning into the directory the other is committing in would corrupt both. + * + * So a slot is entered under an exclusive kernel-backed advisory lock, taken + * without waiting. Refusing rather than waiting is what makes the answer + * useful: "somebody else is working in this checkout" is something the person + * running the document can act on, and a hang is not. The kernel is also what + * makes it survive a lost host — a killed process runs no cleanup and the + * operating system releases its locks anyway, which is the only evidence this + * accepts that a previous holder is gone. + * + * ## The hold is execution-wide, deliberately + * + * The lease is acquired into the scope the provider was installed in rather + * than into the component invocation that asked for it, so it outlives the + * element. A self-closing `` binds a path that a + * later sibling `` and an interactive `` inside it + * go on using; a lease that ended with the Worktree element would leave both + * working in a slot another process could take. + * + * Selecting the same slot twice in one execution reuses the lease already held. + * It is the same process and the same provider: taking a second exclusive lock + * on a file this process already holds is not a coordination question. + * + * Nothing here unlinks a lock file. Unlinking a locked path lets the next + * caller create and lock a different file at the same name while this lock is + * still held, so the sidecar is created if absent and then left, empty. + */ + +import { race, suspend, useScope, withResolvers, type Operation, type Scope } from "effection"; +import { useAdvisoryLock } from "../advisory-lock.ts"; +import type { AdvisoryLockFile } from "../advisory-lock.ts"; +import { ManagedCheckoutError } from "./errors.ts"; +import { lockOf } from "./placement.ts"; + +export type SlotKind = "repository" | "worktree"; + +export interface Leases { + /** + * Hold this slot for the rest of the execution, or refuse. + * + * Idempotent per slot: a second call for a slot this execution already holds + * returns without asking the operating system anything. + */ + hold(kind: SlotKind, slot: string, subject: string): Operation; +} + +export function* useLeases(root: string): Operation { + // The scope the provider is installed in, captured once. Every lease is + // acquired into it, so all of them are released — by the kernel and by the + // resource's own teardown — when the document execution ends, on success, + // failure and cancellation alike. + const owner: Scope = yield* useScope(); + const held = new Set(); + + return { + *hold(kind: SlotKind, slot: string, subject: string): Operation { + const path = lockOf(root, kind, slot); + if (held.has(path)) { + return; + } + // The acquisition runs in the provider's scope and then *suspends*, which + // is what makes the hold last as long as the provider does. A task that + // returned the handle would complete, and completing releases everything + // the task acquired — so the lock would be gone the moment the element + // that asked for it finished, and a second process could take the slot + // out from under an interactive Session still working in it. + const acquired = withResolvers(); + const failed = withResolvers(); + owner.run(function* () { + let file: AdvisoryLockFile | undefined; + try { + file = yield* useAdvisoryLock(path); + } catch (error) { + failed.reject(error instanceof Error ? error : new Error(String(error))); + return; + } + acquired.resolve(file); + if (file === undefined) { + // Refused. There is nothing to hold open, so this task ends rather + // than suspending for the rest of the execution over a lock it never + // took. + return; + } + yield* suspend(); + }); + const file = yield* race([acquired.operation, failed.operation]); + if (file === undefined) { + throw new ManagedCheckoutError( + "in-use", + `another process is working in the managed checkout for ${subject}. Nothing was read ` + + "and nothing was changed; the other process releases it when it ends.", + ); + } + held.add(path); + }, + }; +} diff --git a/packages/workflow/src/deno/run-composition/metadata.ts b/packages/workflow/src/deno/run-composition/metadata.ts new file mode 100644 index 00000000..bd683c49 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/metadata.ts @@ -0,0 +1,165 @@ +/** + * The sidecar that says what a managed slot is, and what it was created from. + * + * A workflow run answers that question from its database. An ordinary run has + * none, so the answer lives beside the checkout — and because it lives on a + * filesystem several processes share, everything about it is defensive: the + * shape is closed and versioned, every member is parsed, and the file is only + * ever written by an exclusive temporary sibling plus an atomic rename, after + * the checkout it describes is complete and verified. + * + * ## Paths are derived, never read back + * + * A slot's checkout path is a function of the root and the identity. It is + * deliberately absent from the metadata: a path read out of a file somebody + * could edit would be a path this provider then joined and followed, and the + * whole point of digesting authored strings into slots is that no authored + * string decides where anything is. + * + * ## What compatibility means + * + * Every member here is *creation* state: what was asked for, and what was true + * the moment the checkout came into being. None of it is current state. HEAD, + * the current branch, the index and the working tree are the mutable work the + * checkout exists to preserve, and a reuse that required them to match creation + * would refuse every checkout anybody had used. + */ + +import { members, optionalText, text } from "../../composition/parse.ts"; +import { parseObjectFormat, type GitObjectFormat } from "../../composition/records.ts"; + +/** The one shape this version writes and the only one it reads. */ +export const METADATA_VERSION = 1; + +export interface ManagedRepositoryMetadata { + readonly kind: "repository"; + readonly version: typeof METADATA_VERSION; + readonly name: string; + /** The admitted, credential-free locator this checkout was cloned from. */ + readonly locator: string; + readonly locatorFingerprint: string; + readonly requestedBase: string | null; + readonly creationCommit: string; + readonly primaryBranch: string; + readonly objectFormat: GitObjectFormat; + /** The canonical common Git directory this checkout's own worktrees key on. */ + readonly commonDirectory: string; +} + +export interface ManagedWorktreeMetadata { + readonly kind: "worktree"; + readonly version: typeof METADATA_VERSION; + /** The canonical common Git directory of the repository this belongs to. */ + readonly owner: string; + readonly name: string; + readonly requestedBranch: string; + readonly requestedBase: string | null; + readonly creationCommit: string; + readonly objectFormat: GitObjectFormat; +} + +export type ManagedMetadata = ManagedRepositoryMetadata | ManagedWorktreeMetadata; + +const REPOSITORY_MEMBERS = [ + "kind", + "version", + "name", + "locator", + "locatorFingerprint", + "requestedBase", + "creationCommit", + "primaryBranch", + "objectFormat", + "commonDirectory", +] as const; + +const WORKTREE_MEMBERS = [ + "kind", + "version", + "owner", + "name", + "requestedBranch", + "requestedBase", + "creationCommit", + "objectFormat", +] as const; + +/** The repository sidecar this value describes, or `undefined` when it is none. */ +export function parseRepositoryMetadata(value: unknown): ManagedRepositoryMetadata | undefined { + const record = members(value, REPOSITORY_MEMBERS); + if (record === undefined || record.kind !== "repository" || record.version !== METADATA_VERSION) { + return undefined; + } + const name = text(record.name); + const locator = text(record.locator); + const locatorFingerprint = text(record.locatorFingerprint); + const requestedBase = optionalText(record.requestedBase); + const creationCommit = text(record.creationCommit); + const primaryBranch = text(record.primaryBranch); + const objectFormat = parseObjectFormat(record.objectFormat); + const commonDirectory = text(record.commonDirectory); + if ( + name === undefined || + locator === undefined || + locatorFingerprint === undefined || + !/^[0-9a-f]{64}$/.test(locatorFingerprint) || + requestedBase === undefined || + creationCommit === undefined || + primaryBranch === undefined || + objectFormat === undefined || + commonDirectory === undefined + ) { + return undefined; + } + return Object.freeze({ + kind: "repository" as const, + version: METADATA_VERSION, + name, + locator, + locatorFingerprint, + requestedBase, + creationCommit, + primaryBranch, + objectFormat, + commonDirectory, + }); +} + +/** The worktree sidecar this value describes, or `undefined` when it is none. */ +export function parseWorktreeMetadata(value: unknown): ManagedWorktreeMetadata | undefined { + const record = members(value, WORKTREE_MEMBERS); + if (record === undefined || record.kind !== "worktree" || record.version !== METADATA_VERSION) { + return undefined; + } + const owner = text(record.owner); + const name = text(record.name); + const requestedBranch = text(record.requestedBranch); + const requestedBase = optionalText(record.requestedBase); + const creationCommit = text(record.creationCommit); + const objectFormat = parseObjectFormat(record.objectFormat); + if ( + owner === undefined || + name === undefined || + requestedBranch === undefined || + requestedBase === undefined || + creationCommit === undefined || + objectFormat === undefined + ) { + return undefined; + } + return Object.freeze({ + kind: "worktree" as const, + version: METADATA_VERSION, + owner, + name, + requestedBranch, + requestedBase, + creationCommit, + objectFormat, + }); +} + +/** The bytes one sidecar is written as. Member order is an implementation detail. */ +export function metadataBytes(metadata: ManagedMetadata): string { + return `${JSON.stringify(metadata, null, 2)}\n`; +} diff --git a/packages/workflow/src/deno/run-composition/operations.ts b/packages/workflow/src/deno/run-composition/operations.ts new file mode 100644 index 00000000..dfdbdaa0 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/operations.ts @@ -0,0 +1,449 @@ +/** + * What an ordinary run does to a checkout, and what it remembers about it. + * + * The three local operations are the workflow provider's own performers, run + * against a real directory instead of an exported materialization. That is + * deliberate reuse rather than a parallel implementation: `` refuses + * a branch another checkout holds, `` stages exactly the pathspecs a + * document wrote, and `` records the index and nothing else — and + * those are the authored semantics, not a workflow detail. + * + * What is different is everything around them. There is no Workspace + * transaction to enclose a person's own repository in, so a failure rolls back + * nothing and this makes no such claim; there is no journal, so nothing is + * retained and nothing replays; and the commit lands in the checkout the person + * is standing in rather than in a root the run owns. + * + * ## Push, and the evidence it leaves + * + * Push keeps the shared observe/adopt/fast-forward/refuse rules exactly. What + * it does *not* keep is the Git-host reconciliation record, because there is no + * history to reconcile against. Instead a verified publication leaves one entry + * in this provider instance's own closure, and that entry is the only thing + * that authorizes a later ``. + * + * The entry is not a Context value, a component result, a middleware answer or + * a journal event. It cannot be copied into another execution, because another + * execution constructs a new provider with an empty list — which is what makes + * "this run published that branch" mean this run. + */ + +import type { Operation } from "effection"; +import { + GitOperationAuthorityError, + GitOperationInfrastructureError, +} from "../../composition/errors.ts"; +import type { + GitAddResult, + GitCheckoutIdentity, + GitCheckoutState, + GitCommitMessageSource, + GitCommitResult, + GitSwitchResult, +} from "../../composition/git-records.ts"; +import { + ANCESTOR, + destinationRefFor, + PUSH_REMOTE, + refspecFor, + type GitPushInputs, + type GitPushOutcome, + type GitPushPreState, +} from "../../composition/git-push-records.ts"; +import { beneath } from "../../composition/parse.ts"; +import { sameRepositoryIdentity, type RepositoryIdentity } from "../../composition/selection.ts"; +import { + GitHostAmbiguousError, + GitHostConflictError, + GitHostUnavailableError, +} from "../../git-host/errors.ts"; +import { ADD } from "../../composition/components/GitAdd.ts"; +import { COMMIT } from "../../composition/components/GitCommit.ts"; +import { PUSH } from "../../composition/components/GitPush.ts"; +import { SWITCH } from "../../composition/components/GitSwitch.ts"; +import { + addPaths, + commitPresent, + currentBranch, + observeRemoteRef, + pushRefspec, + resolveCommit, +} from "../composition/git.ts"; +import type { GitSession } from "../composition/git.ts"; +import { checkoutState, type GitCheckout } from "../composition/operations.ts"; +import { performSwitch } from "../composition/switch.ts"; +import { gitCommitMessageEvidence, performCommit } from "../composition/commit.ts"; +import { + useGitAuthentication, + type GitCommitIdentity, + type RepositoryHost, +} from "../composition/host.ts"; +import { gitRefused } from "../composition/refusals.ts"; +import { LivePushEvidenceError, UnresolvedGitIdentityError } from "./errors.ts"; + +/** + * One checkout this execution may act in. + * + * Registered when a Repository or Worktree is selected, and the ambient one + * when there is one. `identity` is the *repository's*, so every checkout of one + * repository carries the same identity and a working directory selects among + * them by path. + */ +export interface RegisteredCheckout { + /** The canonical host path of the checkout root. */ + readonly root: string; + readonly identity: RepositoryIdentity; + /** The Repository's display name, as a document wrote it or the host found it. */ + readonly repositoryName: string; + /** The Worktree's name, or `null` for a repository's own checkout. */ + readonly worktreeName: string | null; + /** The admitted origin this checkout publishes to, when it has one. */ + readonly origin: string | undefined; +} + +/** What one verified publication proved, held in the provider's closure. */ +export interface PushEvidence { + readonly identity: RepositoryIdentity; + readonly checkoutRoot: string; + readonly origin: string; + readonly branch: string; + readonly destinationRef: string; + readonly commit: string; +} + +/** + * Which registered checkout this repository and working directory select. + * + * The same two observations a workflow operation makes, decided the same way: + * the repository says which checkouts are candidates, and the working directory + * says which of them the element was written in. The longest match wins, so a + * `` inside a linked worktree selects the worktree rather than the + * repository it belongs to. + * + * ## A candidate is a candidate only under the whole identity + * + * "The repository" means every member of the identity, compared by + * `sameRepositoryIdentity`. A locator fingerprint alone names *where a + * repository came from*, and two Repositories selected from one locator under + * different names are different repositories with separate leases, separate + * placements and separate Push evidence. Admitting on the fingerprint would let + * a `` into the second one carry the first one's authority — the element + * would be authenticated against the Repository in scope and then act in a + * checkout that Repository never selected. + * + * Every checkout of one repository still carries that repository's identity — + * a Worktree registers under its owner's — so requiring the whole identity + * narrows nothing a document can legitimately reach. + */ +export function selectCheckout( + registered: readonly RegisteredCheckout[], + identity: RepositoryIdentity, + workingDirectory: string, + operation: string, +): RegisteredCheckout { + let selected: RegisteredCheckout | undefined; + for (const candidate of registered) { + if (!sameRepositoryIdentity(candidate.identity, identity)) { + continue; + } + if (!beneath(candidate.root, workingDirectory)) { + continue; + } + if (selected === undefined || candidate.root.length > selected.root.length) { + selected = candidate; + } + } + if (selected === undefined) { + throw new GitOperationAuthorityError( + operation, + "the directory it was written in is inside none of the checkouts this execution selected " + + "for the repository in scope", + ); + } + return selected; +} + +/** The `GitCheckout` the shared performers act on, for a live directory. */ +export function liveCheckout( + git: GitSession, + checkout: RegisteredCheckout, + workingDirectory: string, +): GitCheckout { + const identity: GitCheckoutIdentity = Object.freeze({ + repositoryName: checkout.repositoryName, + worktreeName: checkout.worktreeName, + checkoutPath: checkout.root, + }); + return { + git, + directory: checkout.root, + repositoryDirectory: checkout.root, + workingDirectory, + identity, + }; +} + +export function* liveSwitch( + checkout: GitCheckout, + branch: string, + base: string | undefined, +): Operation { + const before = yield* checkoutState(checkout.git, checkout.directory, SWITCH); + const performed = yield* performSwitch(checkout, branch, base); + const after = yield* checkoutState(checkout.git, checkout.directory, SWITCH); + return Object.freeze({ + checkout: checkout.identity, + requestedBranch: branch, + resolvedBranch: after.branch, + requestedBase: base ?? null, + resolvedBase: performed.resolvedBase, + before, + after, + }); +} + +export function* liveAdd(checkout: GitCheckout, paths: readonly string[]): Operation { + const before = yield* checkoutState(checkout.git, checkout.directory, ADD); + yield* addPaths(checkout.git, { + operation: ADD, + workingDirectory: checkout.workingDirectory, + paths, + }); + const after = yield* checkoutState(checkout.git, checkout.directory, ADD); + return Object.freeze({ checkout: checkout.identity, paths, before, after }); +} + +export function* liveCommit( + checkout: GitCheckout, + message: string, + messageSource: GitCommitMessageSource, + identity: GitCommitIdentity | undefined, +): Operation { + // Before the index is read and long before an object is written: a host that + // cannot say who a commit is by cannot make one, and saying so first means + // nothing was staged, moved or written for a commit that was never going to + // exist. + if (identity === undefined) { + throw new UnresolvedGitIdentityError(); + } + const evidence = gitCommitMessageEvidence(message); + const before: GitCheckoutState = yield* checkoutState(checkout.git, checkout.directory, COMMIT); + const performed = yield* performCommit(checkout, before, message, evidence, identity); + const after = yield* checkoutState(checkout.git, checkout.directory, COMMIT); + return Object.freeze({ + checkout: checkout.identity, + messageSource, + messageDigest: evidence.digest, + messageLength: evidence.length, + parent: performed.parent, + tree: performed.tree, + commit: performed.commit, + committedAt: performed.committedAt, + before, + after, + }); +} + +/** Whether the observed commit is somewhere in the source commit's ancestry. */ +function* provenAncestor( + git: GitSession, + directory: string, + observed: string, + desired: string, +): Operation { + if (!(yield* commitPresent(git, directory, observed))) { + return false; + } + const outcome = yield* git.run(["merge-base", "--is-ancestor", observed, desired], directory); + if (outcome.code === 0) { + return true; + } + if (outcome.code === 1) { + return false; + } + throw new GitOperationInfrastructureError( + PUSH, + "native Git could not decide whether the branch already holds an earlier commit", + ); +} + +/** + * Publish this checkout's current branch, and say what happened. + * + * The same rules the reconciled effect follows, minus the reconciliation. A + * destination that already names this exact commit is adopted rather than + * pushed again; a proven-absent one, and one holding an ancestor of this + * commit, are published once with an exact non-force refspec; anything else is + * a conflict. A host that could not answer proves nothing and is never read as + * absence. + */ +export function* livePush( + host: RepositoryHost, + git: GitSession, + checkout: RegisteredCheckout, +): Operation<{ outcome: GitPushOutcome; evidence: PushEvidence }> { + if (checkout.origin === undefined) { + throw new GitOperationAuthorityError( + PUSH, + "the checkout it selected records no usable origin, so there is nowhere for this branch " + + "to be published to. No credential was read and nothing was contacted", + ); + } + const branch = yield* currentBranch(git, checkout.root); + if (branch === undefined) { + gitRefused(PUSH, "unnamed-branch"); + } + const sourceCommit = yield* resolveCommit(git, checkout.root, "HEAD"); + if (sourceCommit === undefined) { + throw new GitOperationInfrastructureError( + PUSH, + "the checkout it ran in did not report the commit its branch holds", + ); + } + const destinationRef = destinationRefFor(branch); + const inputs: GitPushInputs = Object.freeze({ + repository: checkout.identity, + remote: PUSH_REMOTE, + branch, + destinationRef, + sourceCommit, + }); + + // One session for this publication, opened after the local checks above and + // released with the scope this operation runs in. + const session = yield* useGitAuthentication(host, checkout.origin); + const observed = yield* observeRemoteRef( + git, + checkout.root, + checkout.origin, + destinationRef, + checkout.identity.objectFormat, + session, + ); + if (observed.state === "unreachable") { + // Not absence. A host that could not answer has proven nothing, and + // offering silence as absence is what would authorize a duplicate push. + throw new GitHostUnavailableError(); + } + if (observed.state === "ambiguous") { + throw new GitHostAmbiguousError(); + } + + const evidence: PushEvidence = Object.freeze({ + identity: checkout.identity, + checkoutRoot: checkout.root, + origin: checkout.origin, + branch, + destinationRef, + commit: sourceCommit, + }); + + if (observed.state === "present" && observed.commit === sourceCommit) { + return { + outcome: { + decision: "adopted", + result: resultOf(inputs, observed.commit), + }, + evidence, + }; + } + + const preState: GitPushPreState = + observed.state === "absent" + ? { remoteCommit: null } + : (yield* provenAncestor(git, checkout.root, observed.commit, sourceCommit)) + ? { remoteCommit: observed.commit, relation: ANCESTOR } + : { remoteCommit: observed.commit }; + if (preState.remoteCommit !== null && !("relation" in preState)) { + // The destination names a commit this branch does not contain. Publishing + // over it would replace somebody's work rather than advance the branch. + throw new GitHostConflictError(); + } + + const accepted = yield* pushRefspec( + git, + checkout.root, + checkout.origin, + refspecFor(sourceCommit, destinationRef), + session, + ); + if (!accepted) { + throw new GitHostUnavailableError(); + } + // One exact observation afterwards decides the outcome, never the status of + // the command: what a push left at the destination is a question about the + // destination. + const settled = yield* observeRemoteRef( + git, + checkout.root, + checkout.origin, + destinationRef, + checkout.identity.objectFormat, + session, + ); + if (settled.state !== "present" || settled.commit !== sourceCommit) { + throw new GitHostUnavailableError(); + } + return { + outcome: { decision: "performed", result: resultOf(inputs, settled.commit) }, + evidence, + }; +} + +function resultOf(inputs: GitPushInputs, observedRemoteCommit: string) { + return Object.freeze({ + repository: inputs.repository, + remote: inputs.remote, + branch: inputs.branch, + destinationRef: inputs.destinationRef, + refspec: refspecFor(inputs.sourceCommit, inputs.destinationRef), + sourceCommit: inputs.sourceCommit, + observedRemoteCommit, + }); +} + +/** + * That this execution published the branch a pull request would name. + * + * Every member has to match, and the *last* entry for a destination is the one + * that decides: a loop that commits, pushes, commits and pushes again leaves a + * sequence, and what a pull request is opened against is where that sequence + * ended. A push of another checkout, repository, origin, destination, branch or + * commit is irrelevant rather than disagreement. + */ +export function admitLivePushEvidence( + held: readonly PushEvidence[], + expected: Omit & { readonly commit: string }, +): void { + let published: "this head" | "another commit" | undefined; + for (const entry of held) { + if ( + entry.identity.locatorFingerprint !== expected.identity.locatorFingerprint || + entry.checkoutRoot !== expected.checkoutRoot || + entry.origin !== expected.origin || + entry.branch !== expected.branch || + entry.destinationRef !== expected.destinationRef + ) { + continue; + } + published = entry.commit === expected.commit ? "this head" : "another commit"; + } + if (published === "this head") { + return; + } + if (published === undefined) { + throw new LivePushEvidenceError( + "missing-push-evidence", + "this execution holds no successful result for the branch and commit it would " + + "open a pull request from. Write before : a pull request names " + + "work this execution published, and publishing it is an explicit act.", + ); + } + throw new LivePushEvidenceError( + "conflicting-push-evidence", + "this execution published that branch at a different commit than the one the checkout is on " + + "now, so a pull request opened from it would name a head this execution never published.", + ); +} + +export type { GitCheckout }; diff --git a/packages/workflow/src/deno/run-composition/placement.ts b/packages/workflow/src/deno/run-composition/placement.ts new file mode 100644 index 00000000..a9aecdfc --- /dev/null +++ b/packages/workflow/src/deno/run-composition/placement.ts @@ -0,0 +1,83 @@ +/** + * Where an ordinary run keeps the repositories and worktrees it manages. + * + * A workflow run's checkouts live inside the run's own Workspace, so their + * placement is Workspace-relative and their lifetime is the run's. An ordinary + * run has no Workspace and no run. Its checkouts are the person's work — a + * branch they will look at tomorrow, a worktree an agent is still editing — so + * they live under one host root, they survive every execution, and nothing here + * ever deletes one. + * + * ## Every authored string is a digest + * + * A document may name a Repository `../etc`, and a locator may be anything Git + * accepts. Neither reaches a path: a slot is a SHA-256 digest of the whole + * identity and nothing else, so no arrangement of authored characters can name + * another slot or escape the root. That costs legibility — the layout is not + * browsable by name — and buys the one property a shared host root has to have. + * + * The encoding under each digest is length-prefixed for the same reason the + * durable Git-operation fingerprint is: any character may appear in a name or a + * locator, so a separator scheme would let one pair of values produce the slot + * that belongs to another pair. + */ + +import { createHash } from "node:crypto"; + +/** The layout, relative to whichever root the entrypoint chose. */ +export const REPOSITORIES = "repositories"; +export const WORKTREES = "worktrees"; +export const LOCKS = "locks"; + +/** What every slot holds: the checkout itself, and the sidecar describing it. */ +export const CHECKOUT = "checkout"; +export const METADATA = "metadata.json"; + +function digest(...values: readonly string[]): string { + const canonical = values.map((value) => `${value.length}:${value}`).join(""); + return createHash("sha256").update(canonical, "utf8").digest("hex"); +} + +/** + * A managed Repository's slot, named by the locator and the name together. + * + * Both, because both are identity: two documents that name the same url + * `project` and `review` are asking for two checkouts, and two that name + * different urls `project` are asking for two more. + */ +export function repositorySlot(root: string, locator: string, name: string): string { + return `${root}/${REPOSITORIES}/${digest(locator, name)}`; +} + +/** + * A Worktree's slot, named by the repository it belongs to and its own name. + * + * The owner is identified by its canonical common directory rather than by its + * locator, so a Worktree of the repository the caller is standing in and a + * Worktree of a managed clone of the same url are different slots — as they + * must be, since they are linked checkouts of different `.git` directories. + */ +export function worktreeSlot(root: string, commonDirectory: string, name: string): string { + return `${root}/${WORKTREES}/${digest(commonDirectory)}/${digest(name)}`; +} + +/** The checkout inside a slot. */ +export function checkoutOf(slot: string): string { + return `${slot}/${CHECKOUT}`; +} + +/** The metadata sidecar inside a slot. */ +export function metadataOf(slot: string): string { + return `${slot}/${METADATA}`; +} + +/** + * The lock sidecar for one slot. + * + * Outside the slot, because a lock file inside a directory this provider may be + * about to create would be part of the thing it is protecting. Different slots + * never share one: the digest is of the whole slot identity, kind included. + */ +export function lockOf(root: string, kind: "repository" | "worktree", slot: string): string { + return `${root}/${LOCKS}/${kind}/${digest(slot)}.lock`; +} diff --git a/packages/workflow/src/deno/run-composition/provider.ts b/packages/workflow/src/deno/run-composition/provider.ts new file mode 100644 index 00000000..e3e8a913 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/provider.ts @@ -0,0 +1,548 @@ +/** + * The ordinary run's repository provider: what `xmd run` installs under Deno + * and inside the compiled binary. + * + * It answers the same four Apis the workflow provider answers, so a document + * writes the same thirteen components either way. What differs is everything + * about lifetime and authority. + * + * A workflow run's checkouts are rows in its own database, restored from + * retained history under a WorkflowRun the document must never be able to name. + * An ordinary run's checkouts are directories: the one the person is standing + * in, and the ones under the managed root, each held for the execution by an + * advisory lock and each surviving it. Nothing is journaled, nothing replays, + * and a second `xmd run` is a second question rather than a resumption. + * + * ## What this instance holds, and what a document can reach + * + * Four private things, all in this closure: a fresh opaque invocation identity, + * the selection registry, the checkouts this execution registered, and the + * evidence of every publication it verified. None of them is a prop, a Context + * value, a middleware answer, a component result or a journal event, and none + * survives the execution. That is what makes "this run pushed that branch" mean + * this run — a `--journal` file, a copied binding and a previous execution's + * output all grant exactly nothing. + * + * The engine's own `Expansion.id` names the authored site inside that one + * invocation, which is what the live Issue and pull-request idempotency keys + * are built from. + * + * ## Discovery costs nothing until something asks + * + * The ambient repository is discovered once, before root expansion, from the + * directory the command was run in. Being outside a repository is not a startup + * failure: it is remembered as "there is none", and only an element that needs + * one refuses. + */ + +import type { Operation } from "effection"; +import { randomUUID } from "node:crypto"; +import { getExpansion } from "@executablemd/core"; +import { RepositoryComposition } from "../../composition/api.ts"; +import type { RepositoryRequest, WorktreeRequest } from "../../composition/api.ts"; +import { GitComposition } from "../../composition/git-api.ts"; +import type { + GitAddInvocation, + GitCommitInvocation, + GitPushInvocation, + GitSwitchInvocation, +} from "../../composition/git-api.ts"; +import { GitOperationAuthorityError, RepositorySelectionError } from "../../composition/errors.ts"; +import type { + GitAddResult, + GitCommitResult, + GitSwitchResult, +} from "../../composition/git-records.ts"; +import { destinationRefFor, type GitPushOutcome } from "../../composition/git-push-records.ts"; +import { admitPathspecs } from "../../composition/components/GitAdd.ts"; +import { admitCommitMessage } from "../../composition/components/GitCommit.ts"; +import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; +import { PullRequestAPI } from "../../composition/pull-request-api.ts"; +import { + PullRequestOperations, + type PullRequestReadInvocation, + type PullRequestUpsertInvocation, +} from "../../composition/pull-request-operations.ts"; +import type { PullRequestReadResult } from "../../composition/pull-request-read-records.ts"; +import type { + PullRequestInputs, + PullRequestResult, +} from "../../composition/pull-request-records.ts"; +import { PullRequestAuthorityError } from "../../composition/errors.ts"; +import type { RepositoryIdentity, RepositorySelection } from "../../composition/selection.ts"; +import { IssueApi } from "../../issue/api.ts"; +import type { IssueDetails, IssueReference } from "../../issue/api.ts"; +import { + IssueOperations, + type IssueReadInvocation, + type IssueUpsertInvocation, +} from "../../issue/operations.ts"; +import { issueIdempotencyKey, parseIssueDetails, parseIssueRecord } from "../../issue/records.ts"; +import { IssueProtocolError } from "../../issue/errors.ts"; +import { locatorFingerprint } from "../composition/locator.ts"; +import { currentBranch, gitSession, resolveCommit, type GitSession } from "../composition/git.ts"; +import { denoRepositoryHost, type RepositoryHost } from "../composition/host.ts"; +import type { GitAuthentication } from "../composition/authentication.ts"; +import type { HelperAssembly } from "../composition/credential-helper.ts"; +import { denoGitHubSource, type GitHubSource } from "../composition/github.ts"; +import { + useGitHubPullRequestReads, + type GitHubPullRequestsOptions, +} from "../composition/pull-request-reads.ts"; +import { useGitHubIssues, type GitHubIssuesOptions } from "../issue/github.ts"; +import { selectionRegistry } from "../selections.ts"; +import { discoverAmbientRepository, type AmbientRepository } from "./ambient.ts"; +import { captureCommitIdentity, denoIdentityReader, type IdentityReader } from "./identity.ts"; +import { selectManagedRepository, selectManagedWorktree } from "./checkouts.ts"; +import { NoAmbientRepositoryError } from "./errors.ts"; +import { useLeases } from "./leases.ts"; +import { liveUpsertPullRequest } from "./pull-request.ts"; +import { + admitLivePushEvidence, + liveAdd, + liveCheckout, + liveCommit, + livePush, + liveSwitch, + selectCheckout, + type PushEvidence, + type RegisteredCheckout, +} from "./operations.ts"; +import { repositorySlot, worktreeSlot } from "./placement.ts"; +import { realpath } from "node:fs/promises"; +import { ensureDir } from "@effectionx/fs"; +import { until } from "effection"; + +/** The canonical directory this path resolves to, or the path as written. */ +function* canonicalPath(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return path; + } +} + +export interface RunCompositionOptions { + /** Where managed checkouts live. Production passes `~/.xmd/repositories`. */ + readonly root: string; + /** The directory the command was run in, which ambient discovery starts from. */ + readonly cwd: string; + readonly host?: RepositoryHost; + readonly authentication?: GitAuthentication; + readonly helper?: HelperAssembly; + /** + * How this host reads the invoking user's effective Git identity. + * + * Absent uses native Git with the caller's own environment and starting + * directory, which is the whole question. A suite substitutes it to say what + * this host knows — including that it knows nothing. + */ + readonly identity?: IdentityReader; + /** What GitHub issue handling this host installs, and what it may reach. */ + readonly gitHubIssues?: GitHubIssuesOptions; + /** The pull-request destinations this host allows a document to read. */ + readonly gitHubPullRequests?: GitHubPullRequestsOptions; +} + +/** What a Repository selection names: its checkout, and how to publish from it. */ +interface SelectedRepository { + readonly checkout: RegisteredCheckout; + /** The repository checkout `worktree add` runs in. */ + readonly ownerCheckout: string; + /** The canonical common Git directory this repository's worktrees key on. */ + readonly commonDirectory: string; +} + +/** + * Install the ordinary repository vocabulary for the current scope and below. + * + * One call rather than four, because the four Apis share this instance's + * private state and installing some without the rest would leave a document + * committing in a checkout no `` could be authorized against. + */ +export function* useRunComposition(options: RunCompositionOptions): Operation { + const host = + options.host ?? + denoRepositoryHost({ + ...(options.authentication === undefined ? {} : { authentication: options.authentication }), + ...(options.helper === undefined ? {} : { helper: options.helper }), + }); + // The Git session's root is also `HOME`, so Git reads no configuration + // belonging to whoever is running the command — the same isolation a workflow + // run gets, applied to a repository the caller owns. + const home = yield* host.useDirectory(); + const git: GitSession = gitSession(host, home); + + // Created before it is canonicalized, and canonicalized once. A path that + // does not exist yet resolves to itself, so a root canonicalized before it + // was made would be one spelling on the execution that created it and another + // on every execution afterwards — two spellings, two digests, two slots for + // one Repository, and no lock between them. + yield* ensureDir(options.root); + const root = yield* canonicalPath(options.root); + const leases = yield* useLeases(root); + const selections = selectionRegistry(); + const registered: RegisteredCheckout[] = []; + const evidence: PushEvidence[] = []; + // Fresh, opaque and never derived from anything a document wrote. It names + // this execution to a service; it is not addressable, reusable or observable. + const invocation = randomUUID(); + + // Once, before root expansion, from the trusted host: the four strings a + // commit records about who made it. A host that cannot say is remembered as + // not saying, and only `` refuses. + const identity = yield* captureCommitIdentity( + options.identity ?? denoIdentityReader(options.cwd), + ); + + // Once, before root expansion. A repository this command was not run inside + // is remembered as absent rather than refused, so a document that never asks + // for one runs exactly as it would anywhere else. + const ambient = yield* discoverAmbientRepository(git, options.cwd); + const ambientSelection = + ambient === undefined ? undefined : registerAmbient(ambient, selections, registered); + + yield* RepositoryComposition.around( + { + *selectRepository([request]: [RepositoryRequest]): Operation { + const slot = repositorySlot(root, request.locator, request.name); + yield* leases.hold("repository", slot, `repository ${JSON.stringify(request.name)}`); + const managed = yield* selectManagedRepository(git, host, slot, request); + const identity: RepositoryIdentity = Object.freeze({ + name: request.name, + locatorFingerprint: managed.metadata.locatorFingerprint, + requestedBase: managed.metadata.requestedBase, + creationCommit: managed.creationCommit, + primaryBranch: managed.metadata.primaryBranch, + objectFormat: managed.objectFormat, + }); + const checkout: RegisteredCheckout = Object.freeze({ + root: managed.checkout, + identity, + repositoryName: request.name, + worktreeName: null, + origin: managed.metadata.locator, + }); + register(registered, checkout); + return selections.mint(slot, request.name, identity, managed.checkout, { + checkout, + ownerCheckout: managed.checkout, + commonDirectory: managed.commonDirectory, + }); + }, + + *selectWorktree([repository, request]: [ + RepositorySelection, + WorktreeRequest, + ]): Operation { + const owner = selections.authenticate( + repository, + () => new RepositorySelectionError(""), + ); + const slot = worktreeSlot(root, owner.commonDirectory, request.name); + yield* leases.hold("worktree", slot, `worktree ${JSON.stringify(request.name)}`); + const managed = yield* selectManagedWorktree(git, slot, { + name: request.name, + branch: request.branch, + base: request.base, + owner: owner.commonDirectory, + ownerCheckout: owner.ownerCheckout, + }); + // The owner's identity, because that is the repository this checkout + // belongs to; the worktree's own name and path, because that is which + // checkout of it this selection points at. + const checkout: RegisteredCheckout = Object.freeze({ + root: managed.checkout, + identity: owner.checkout.identity, + repositoryName: owner.checkout.repositoryName, + worktreeName: request.name, + origin: owner.checkout.origin, + }); + register(registered, checkout); + return selections.mint(slot, request.name, owner.checkout.identity, managed.checkout, { + checkout, + ownerCheckout: owner.ownerCheckout, + commonDirectory: owner.commonDirectory, + }); + }, + + // deno-lint-ignore require-yield + *ambientRepository(): Operation { + if (ambientSelection === undefined) { + // This profile *has* ambient repositories; this invocation is not in + // one. The refusal says how to run inside one rather than reporting + // an absent provider, which is what Node and Bun report instead. + throw new NoAmbientRepositoryError("this element"); + } + return ambientSelection; + }, + }, + { at: "min" }, + ); + + function* place( + invocationRepository: RepositorySelection, + workingDirectory: string, + operation: string, + ): Operation { + const selected = selections.authenticate( + invocationRepository, + () => + new GitOperationAuthorityError( + operation, + "the Repository in scope is not one this execution selected, so it names no checkout", + ), + ); + // Canonicalized before it is matched. A checkout root is the path Git + // resolved, and a working directory reached through a symbolic link — a + // temporary directory under macOS `/var`, a home directory somebody linked + // — is the same place under another name. Comparing the two as written + // would report a document standing in its own checkout as standing in none. + return selectCheckout( + registered, + selected.checkout.identity, + yield* canonicalPath(workingDirectory), + operation, + ); + } + + yield* GitComposition.around( + { + *switchBranch([invocation_]: [GitSwitchInvocation]): Operation { + const checkout = yield* place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); + return yield* liveSwitch( + liveCheckout(git, checkout, invocation_.workingDirectory), + invocation_.branch, + invocation_.base, + ); + }, + + *addPaths([invocation_]: [GitAddInvocation]): Operation { + const checkout = yield* place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); + // Admitted where a request enters, exactly as the retained provider + // admits it: the Api is public, and a caller reaching it directly is + // subject to the same boundary. + return yield* liveAdd( + liveCheckout(git, checkout, invocation_.workingDirectory), + admitPathspecs(invocation_.paths), + ); + }, + + *commitIndex([invocation_]: [GitCommitInvocation]): Operation { + const checkout = yield* place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); + return yield* liveCommit( + liveCheckout(git, checkout, invocation_.workingDirectory), + admitCommitMessage(invocation_.message), + invocation_.messageSource, + identity, + ); + }, + + *pushCurrentBranch([invocation_]: [GitPushInvocation]): Operation { + const checkout = yield* place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); + const published = yield* livePush(host, git, checkout); + // Only after the provider has verified a performed or adopted + // publication. A refused or unreadable one leaves no entry, so nothing + // it did authorizes a pull request. + evidence.push(published.evidence); + return published.outcome; + }, + }, + { at: "min" }, + ); + + // The transport middlewares both profiles share, installed beneath the + // ordinary lifecycle below. Absent configuration installs no matching + // provider, and a document naming one then reaches the surface's own error. + if (options.gitHubIssues !== undefined) { + yield* useGitHubIssues(options.gitHubIssues); + } + yield* useGitHubPullRequestReads(options.gitHubPullRequests ?? {}); + + yield* IssueOperations.around( + { + *read([request]: [IssueReadInvocation]): Operation { + const answered = yield* IssueApi.operations.read(request.url, { + ...(request.provider === undefined ? {} : { provider: request.provider }), + }); + const details = parseIssueDetails(answered); + if (details === undefined) { + throw new IssueProtocolError( + "the issue provider answered a read with something that is not an issue's shared " + + "fields", + ); + } + return details; + }, + + *upsert([request]: [IssueUpsertInvocation]): Operation { + const expansion = yield* getExpansion(); + const answered = yield* IssueApi.operations.upsert(request.issue, { + url: request.target, + ...(request.provider === undefined ? {} : { provider: request.provider }), + // This execution and this authored site. A provider carries it + // wherever its service can hold a mark, which is how "already + // created" is answered inside one run without a local record. + idempotencyKey: issueIdempotencyKey( + { runId: invocation, expansionId: expansion.id }, + "upsert", + request.target, + ), + }); + const record = parseIssueRecord(answered); + if (record === undefined) { + throw new IssueProtocolError( + "the issue provider answered an upsert with something that is not a URL", + ); + } + return record; + }, + }, + { at: "min" }, + ); + + const source: GitHubSource = + options.gitHubPullRequests?.access ?? + (options.gitHubPullRequests?.endpoint === undefined + ? denoGitHubSource() + : denoGitHubSource(options.gitHubPullRequests.endpoint)); + + yield* PullRequestOperations.around( + { + // Afresh, every execution. There is nothing to retain a read in and + // nothing that would replay one, so what a document binds is what the + // pull request holds now. + *read([request]: [PullRequestReadInvocation]): Operation { + return yield* PullRequestAPI.operations.read(request.url, { + kind: request.kind, + ...(request.provider === undefined ? {} : { provider: request.provider }), + }); + }, + + *upsert([request]: [PullRequestUpsertInvocation]): Operation { + const checkout = yield* place( + request.repository, + request.workingDirectory, + PULL_REQUEST_ELEMENT, + ); + if (checkout.origin === undefined) { + throw new PullRequestAuthorityError( + "no-repository-context", + "the checkout it selected records no usable origin, so there is no repository at a " + + "Git host for a pull request to be opened in.", + ); + } + const headBranch = yield* currentBranch(git, checkout.root); + if (headBranch === undefined) { + throw new PullRequestAuthorityError( + "unnamed-branch", + "the checkout it selected has no branch checked out, so there is no head branch to " + + "open a pull request from — and a detached HEAD is not something this run could " + + "have published.", + ); + } + const headSha = yield* resolveCommit(git, checkout.root, "HEAD"); + if (headSha === undefined) { + throw new PullRequestAuthorityError( + "unnamed-branch", + "the checkout it selected did not report the commit its branch holds.", + ); + } + // Before a credential is read and before anything is sent. What + // authorizes a pull request is this execution's own record of + // publishing the branch. + admitLivePushEvidence(evidence, { + identity: checkout.identity, + checkoutRoot: checkout.root, + origin: checkout.origin, + branch: headBranch, + destinationRef: destinationRefFor(headBranch), + commit: headSha, + }); + + const inputs: PullRequestInputs = Object.freeze({ + repository: checkout.identity, + number: request.pullRequest.number, + title: request.pullRequest.title, + body: request.pullRequest.body, + draft: request.pullRequest.draft, + headBranch, + headSha, + baseBranch: request.pullRequest.base, + }); + const access = yield* source.open(); + return yield* liveUpsertPullRequest(access, checkout.origin, inputs); + }, + }, + { at: "min" }, + ); +} + +/** Register a checkout, replacing an earlier registration of the same root. */ +function register(registered: RegisteredCheckout[], checkout: RegisteredCheckout): void { + const existing = registered.findIndex((entry) => entry.root === checkout.root); + if (existing < 0) { + registered.push(checkout); + return; + } + registered[existing] = checkout; +} + +/** + * The ambient repository, as a selection every element outside a `` + * receives. + * + * Its identity is the origin when it records one and its own Git directory + * otherwise, because that is what identifies a repository with no remote. The + * creation commit is the commit HEAD named when this execution started: it is + * the instant this identity was pinned at, and — like every other member — it + * says nothing about where HEAD is now. + */ +function registerAmbient( + ambient: AmbientRepository, + selections: ReturnType>, + registered: RegisteredCheckout[], +): RepositorySelection { + const identity: RepositoryIdentity = Object.freeze({ + name: ambient.name, + locatorFingerprint: ambient.originFingerprint ?? locatorFingerprint(ambient.commonDirectory), + requestedBase: null, + creationCommit: ambient.head, + primaryBranch: ambient.defaultBranch, + objectFormat: ambient.objectFormat, + }); + const checkout: RegisteredCheckout = Object.freeze({ + root: ambient.checkoutRoot, + identity, + repositoryName: ambient.name, + // The checkout the command was run in, whether that is the repository's + // primary one or a linked worktree somebody made by hand. Either way it is + // the repository's own checkout as far as this execution is concerned. + worktreeName: null, + origin: ambient.origin, + }); + register(registered, checkout); + return selections.mint( + `ambient ${ambient.commonDirectory} ${ambient.checkoutRoot}`, + ambient.name, + identity, + ambient.checkoutRoot, + { checkout, ownerCheckout: ambient.checkoutRoot, commonDirectory: ambient.commonDirectory }, + ); +} diff --git a/packages/workflow/src/deno/run-composition/pull-request.ts b/packages/workflow/src/deno/run-composition/pull-request.ts new file mode 100644 index 00000000..204c967a --- /dev/null +++ b/packages/workflow/src/deno/run-composition/pull-request.ts @@ -0,0 +1,153 @@ +/** + * `` under an ordinary run: the same reconciliation, no history. + * + * A workflow run reconciles a pull request through the shared Git-host state + * machine, which exists so that an interrupted attempt is adopted on the next + * *execution of the same run*. An ordinary run has no next execution: a second + * `xmd run` is a second question, not a resumption. So the state machine's + * durability has nothing to be durable about, and what is left is the part that + * was always about GitHub — observe once, adopt what already says this, create + * or update once, and decide by one exact observation afterwards. + * + * That part is not reimplemented here. `gitHubPullRequests()` is the same + * adapter the workflow provider drives, with the same filtered listing, the same + * refusal of an unreadable candidate, and the same normalization; what this + * module owns is the ordering above it. + * + * ## Interruption, said plainly + * + * Inside one execution an attempt is made at most once. Across a process + * interruption there is no exactly-once claim at all: GitHub may have accepted a + * change for which this run recorded no result, and the next run observes what + * is there and decides from that. Nothing pretends otherwise, and nothing is + * retained that would let it. + */ + +import type { Operation } from "effection"; +import { GitOperationInfrastructureError } from "../../composition/errors.ts"; +import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; +import { + pullRequestAgrees, + pullRequestResultOf, + type PullRequestInputs, + type PullRequestResult, + type PullRequestSnapshot, +} from "../../composition/pull-request-records.ts"; +import { + GitHostAmbiguousError, + GitHostConflictError, + GitHostUnavailableError, +} from "../../git-host/errors.ts"; +import { GitHostProviderError } from "../../git-host/errors.ts"; +import { gitHubPullRequests, parseGitHubRepository } from "../composition/github.ts"; +import type { GitHubAccess } from "../composition/github.ts"; + +function unusable(reason: string): never { + throw new GitOperationInfrastructureError(PULL_REQUEST_ELEMENT, reason); +} + +/** + * Bring exactly one pull request to what this invocation says, once. + * + * The caller has already proved that this execution published the head branch; + * everything here is about the pull request itself. + */ +export function* liveUpsertPullRequest( + access: GitHubAccess, + locator: string, + inputs: PullRequestInputs, +): Operation { + const name = parseGitHubRepository(locator); + if (name === undefined) { + throw new GitHostProviderError( + "this Git host adapter opens pull requests only for repositories on github.com", + ); + } + const pulls = gitHubPullRequests(access, name, inputs.repository.objectFormat); + + const observed = yield* pulls.observe(inputs); + if (observed.state === "unavailable") { + // Not absence. A host that could not answer has proven nothing, and + // offering silence as absence is what would open a second pull request or + // rewrite one this invocation never saw. + throw new GitHostUnavailableError(); + } + if (observed.state === "ambiguous") { + throw new GitHostAmbiguousError(); + } + if (observed.state === "conflict") { + throw new GitHostConflictError(); + } + if (observed.state === "absent") { + // Only an unnumbered request can reach this: a number that named nothing + // provable is unavailable rather than absent, above. + if (inputs.number !== null) { + unusable("a numbered pull request cannot be created"); + } + return yield* created(pulls, inputs); + } + + const found = observed.pullRequest; + if (pullRequestAgrees(found, inputs)) { + // Everything this invocation asks for is already true — the no-op an + // unchanged document means, and the adoption an interrupted earlier attempt + // leaves behind. + return pullRequestResultOf(inputs, found); + } + if (inputs.number === null) { + // One open pull request for this branch pair, saying something else. An + // unnumbered request asks for one to exist, not for whatever is there to + // become this. + throw new GitHostConflictError(); + } + if (found.number !== inputs.number) { + unusable("the pull request this attempt would update is not the one it observed"); + } + return yield* updated(pulls, inputs, found); +} + +type Adapter = ReturnType; + +/** One creation, and one observation if its outcome is uncertain. */ +function* created(pulls: Adapter, inputs: PullRequestInputs): Operation { + const attempt = yield* pulls.create(inputs); + if (attempt.state === "settled") { + if (!pullRequestAgrees(attempt.pullRequest, inputs)) { + unusable("the Git host created a pull request other than the one it was asked for"); + } + return pullRequestResultOf(inputs, attempt.pullRequest); + } + if (attempt.state === "unreadable") { + unusable("the Git host answered the creation with something this boundary cannot read"); + } + + // A race, a rejection or a failure with no word for it: what happened is + // decided by observing once, never by a second attempt to create. + const observed = yield* pulls.observe(inputs); + if (observed.state === "found" && pullRequestAgrees(observed.pullRequest, inputs)) { + return pullRequestResultOf(inputs, observed.pullRequest); + } + throw new GitHostUnavailableError(); +} + +/** The required mutations, once each, and the one observation that decides. */ +function* updated( + pulls: Adapter, + inputs: PullRequestInputs, + before: PullRequestSnapshot, +): Operation { + const attempt = yield* pulls.update(inputs, before); + if (attempt.state === "unreadable") { + unusable("the Git host answered the update with something this boundary cannot read"); + } + if (attempt.state === "uncertain" || !pullRequestAgrees(attempt.pullRequest, inputs)) { + // A rejected mutation, a partial multi-call update and a host that could + // not be read afterwards are one answer: this attempt did not reach the + // requested state. Nothing is repeated here. + throw new GitHostUnavailableError(); + } + if (attempt.pullRequest.number !== before.number) { + unusable("the Git host answered with a pull request other than the one being updated"); + } + return pullRequestResultOf(inputs, attempt.pullRequest); +} diff --git a/packages/workflow/tests/run-composition-ambient.test.ts b/packages/workflow/tests/run-composition-ambient.test.ts new file mode 100644 index 00000000..d544bd3c --- /dev/null +++ b/packages/workflow/tests/run-composition-ambient.test.ts @@ -0,0 +1,530 @@ +/** + * Tier ORC — ambient discovery and local Git under an ordinary `xmd run`. + * + * What repository an ordinary run is *in*, and what it may do to it without + * leaving it: the primary checkout, a linked worktree, what an `origin` does + * and does not authorize, lexical working directories, and the local Git + * operations that write to the caller's own tree. + * + * The claims here are about a filesystem rather than a database. There is no + * WorkflowRun, no Workspace, no journal and nothing to replay. Every repository + * is real, every Git command is real, and the managed root is a temporary + * directory of the suite's own — no test ever touches the user's + * `~/.xmd/repositories`. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import { ensureDir, exists, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { until } from "effection"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { UnresolvedGitIdentityError } from "../src/deno/run-composition/errors.ts"; +import { useBareRemote } from "./support/git-remotes.ts"; +import { gitHubSource } from "../src/deno/composition/github.ts"; +import { + causedBy, + commonDirectoryOf, + countingOrdinaryHost, + haltAtGate, + statedIdentity, + subcommands, + raised, + runOrdinaryDocument, + recordingAccess, + useHostCheckout, + useManagedRoot, + useOriginlessCheckout, + worktreeSlotOf, +} from "./support/run-composition.ts"; +import { + GITHUB_LOCATOR, + REMOTE, + isMissingAmbient, + reviewRoutes, +} from "./support/run-composition-tier.ts"; + +describe("ORC3 — the ambient primary checkout", () => { + it("switches, stages and commits in the repository the command was run in", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument( + [ + ``, + `ordinary`, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ); + + // The person's own checkout moved, and it is what a later `git` sees. + expect(checkout.run("rev-parse", "--abbrev-ref", "HEAD")).toBe("feature"); + expect(checkout.run("log", "-1", "--pretty=%s")).toBe("Write notes"); + expect(checkout.run("show", "--pretty=", "--name-only", "HEAD")).toContain("notes.md"); + }); + + it("refuses every root element that needs a repository outside a Git checkout", function* () { + const root = yield* useManagedRoot(); + // A directory that is not inside any Git checkout. + const elsewhere = yield* useManagedRoot(); + + const outside = [ + ``, + ``, + ``, + ``, + ``, + ``, + ]; + for (const source of outside) { + const counting = countingOrdinaryHost(); + const failure = yield* raised( + runOrdinaryDocument(source, { + root, + cwd: elsewhere, + host: counting.host, + }), + ); + const refusal = causedBy(failure, isMissingAmbient); + // The element travels in the message, so a failure says which of the six + // reported something else. + expect(`${source} ${refusal?.name}`).toBe(`${source} NoAmbientRepositoryError`); + expect(String(refusal)).toContain("Run xmd from inside one"); + // Discovery asked Git where it was and stopped. Nothing published, nothing + // authenticated, no transport. + expect(counting.counters.sessions).toEqual([]); + expect(subcommands(counting.counters)).not.toContain("push"); + expect(subcommands(counting.counters)).not.toContain("ls-remote"); + } + }); +}); + +describe("ORC4 — the ambient linked worktree", () => { + it("follows the common directory for identity and the worktree root for work", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const primary = yield* useHostCheckout(remote.locator); + // A linked worktree made by hand, exactly as a person would. + const linked = `${primary.root}-linked`; + primary.run("worktree", "add", "-b", "sidecar", linked); + + const before = primary.run("rev-parse", "HEAD"); + + yield* scoped(function* () { + yield* runOrdinaryDocument( + [ + `here`, + ``, + ``, + ].join("\n"), + // The command is run *in the linked worktree*. + { root, cwd: linked }, + ); + }); + + // The worktree advanced; the primary checkout did not. + expect(primary.run("rev-parse", "HEAD")).toBe(before); + expect(primary.run("log", "-1", "--pretty=%s", "sidecar")).toBe("In the worktree"); + }); +}); + +describe("ORC5 — origin is not local authority", () => { + it("does local work with no origin, and refuses to publish before reaching anything", function* () { + const root = yield* useManagedRoot(); + const solo = yield* useOriginlessCheckout(); + + // Worktree, Switch, Add and Commit all work without an origin: none of them + // has anywhere to go. + const bound = yield* runOrdinaryDocument( + [ + ``, + "", + ``, + `no remote`, + ``, + ``, + "", + ].join("\n"), + { root, cwd: solo.root }, + ); + expect(typeof bound).toBe("string"); + expect(solo.run("log", "-1", "--pretty=%s", "feature-two")).toBe("Local only"); + + // Push and PullRequest each refuse, and each refuses before a credential is + // read, a session is opened or a byte leaves for a Git host. + for (const source of [``, ``]) { + const counting = countingOrdinaryHost(); + const github = recordingAccess({}); + const failure = yield* raised( + runOrdinaryDocument(source, { + root, + cwd: solo.root, + host: counting.host, + gitHubPullRequests: { access: gitHubSource(github.access) }, + gitHubIssues: { ceiling: [GITHUB_LOCATOR], access: gitHubSource(github.access) }, + }), + ); + expect(`${source} ${String(failure)}`).toContain("no usable origin"); + // No authentication session was opened for any locator. + expect(counting.counters.sessions).toEqual([]); + // No transport ran: neither observation nor publication. + expect(subcommands(counting.counters)).not.toContain("ls-remote"); + expect(subcommands(counting.counters)).not.toContain("push"); + // And nothing was asked of a Git host — no credential, no request. + expect(github.credentials).toBe(0); + expect(github.requests).toEqual([]); + } + }); + + it("opens a session and transports when there is an origin, so the counters can fail", function* () { + // The same counters, on a repository that *does* have an origin. Without + // this, every assertion above would pass on a counter that can never be + // incremented — which is the one way "nothing was reached" lies. + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const counting = countingOrdinaryHost(); + + yield* runOrdinaryDocument([``, ``].join("\n"), { + root, + cwd: checkout.root, + host: counting.host, + }); + + expect(counting.counters.sessions).toEqual([remote.locator]); + expect(subcommands(counting.counters)).toContain("ls-remote"); + expect(subcommands(counting.counters)).toContain("push"); + }); + + it("reaches a Git host when one is configured, so those counters can fail too", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const endpoint = "https://api.github.test"; + const github = recordingAccess(reviewRoutes(endpoint), endpoint); + + yield* runOrdinaryDocument( + ``, + { + root, + cwd: checkout.root, + gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access: gitHubSource(github.access) }, + }, + ); + + expect(github.credentials).toBeGreaterThan(0); + expect(github.requests.length).toBeGreaterThan(0); + }); +}); + +describe("ORC6 — lexical working directories", () => { + it("restores the enclosing directory after a Worktree body and a Dir body", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // A relative `` path resolves against the contextual working + // directory, so where each one lands is where the document was standing. + const common = commonDirectoryOf(checkout); + yield* runOrdinaryDocument( + [ + `outer`, + ``, + ``, + `within`, + "", + "", + `bound`, + "", + `after`, + ].join("\n"), + { root, cwd: checkout.root }, + ); + + const lexical = worktreeSlotOf(root, common, "lexical"); + const bound = worktreeSlotOf(root, common, "inner"); + expect(yield* exists(`${checkout.root}/outer.md`)).toBe(true); + // Each body observed its own checkout. + expect(yield* exists(`${lexical.checkout}/within.md`)).toBe(true); + expect(yield* exists(`${bound.checkout}/bound.md`)).toBe(true); + // Restored: the sibling after both is back in the enclosing directory. + expect(yield* exists(`${checkout.root}/after.md`)).toBe(true); + expect(yield* exists(`${lexical.checkout}/after.md`)).toBe(false); + expect(yield* exists(`${bound.checkout}/after.md`)).toBe(false); + }); + + it("restores the enclosing directory when the body fails", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // The refusal is printed rather than fatal, so the document goes on — and + // what it goes on in is the directory the Worktree body was installed over. + yield* runOrdinaryDocument( + [ + "", + ``, + ``, + "", + "", + `after`, + ].join("\n"), + { root, cwd: checkout.root }, + ); + expect(yield* exists(`${checkout.root}/after.md`)).toBe(true); + const failing = worktreeSlotOf(root, commonDirectoryOf(checkout), "failing"); + expect(yield* exists(`${failing.checkout}/after.md`)).toBe(false); + }); + + it("restores the enclosing directory when the body is cancelled", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "halted"); + + // The document is torn down from outside with `` still in flight, + // inside the Worktree body. The installation lives on the invocation's own + // scope, so unwinding it is what restores the enclosing directory. + yield* haltAtGate( + [ + ``, + `written before the halt`, + "", + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + // The Worktree's own file is where the body was standing, and the enclosing + // checkout never received it. + expect(yield* exists(`${slot.checkout}/written.md`)).toBe(true); + expect(yield* exists(`${checkout.root}/written.md`)).toBe(false); + + // And the enclosing directory is usable again: a later execution writes at + // the ambient checkout, not inside the Worktree. + yield* runOrdinaryDocument(`after`, { + root, + cwd: checkout.root, + }); + expect(yield* exists(`${checkout.root}/after.md`)).toBe(true); + expect(yield* exists(`${slot.checkout}/after.md`)).toBe(false); + }); +}); + +describe("ORC13 — live local Git", () => { + it("makes real, non-transactional changes and claims no rollback on failure", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `staged before the failure`, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + expect(failure).toBeInstanceOf(Error); + + // The switch and the first Add really happened, and nothing took them back. + expect(checkout.run("rev-parse", "--abbrev-ref", "HEAD")).toBe("partway"); + expect(checkout.run("diff", "--cached", "--name-only")).toContain("staged.md"); + }); + + it("keeps what a cancelled document had already done, and claims no rollback", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* haltAtGate( + [ + ``, + `staged before the halt`, + ``, + "", + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ); + + // Both transitions really happened, and nothing took them back. + expect(checkout.run("rev-parse", "--abbrev-ref", "HEAD")).toBe("interrupted"); + expect(checkout.run("diff", "--cached", "--name-only")).toContain("staged.md"); + // And the commit the document never reached was never made. + expect(checkout.run("log", "-1", "--pretty=%s")).not.toBe("never reached"); + }); + + it("commits as the invoking user, not as the workflow identity", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument( + [ + `mine`, + ``, + ``, + ].join("\n"), + { + root, + cwd: checkout.root, + identity: statedIdentity("Ada Lovelace 1 +0000"), + }, + ); + + expect(checkout.run("log", "-1", "--pretty=%an|%ae|%cn|%ce")).toBe( + "Ada Lovelace|ada@example.test|Ada Lovelace|ada@example.test", + ); + expect(checkout.run("log", "-1", "--pretty=%an")).not.toBe("Executable.md workflow"); + }); + + it("takes author and committer separately when the host resolves them apart", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument( + [ + `pair`, + ``, + ``, + ].join("\n"), + { + root, + cwd: checkout.root, + identity: statedIdentity( + "Ada Lovelace 1 +0000", + "Grace Hopper 1 +0000", + ), + }, + ); + + expect(checkout.run("log", "-1", "--pretty=%an|%ae|%cn|%ce")).toBe( + "Ada Lovelace|ada@example.test|Grace Hopper|grace@example.test", + ); + }); + + it("refuses to commit when the host cannot say who the commit is by", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const before = checkout.run("rev-parse", "HEAD"); + + const failure = yield* raised( + runOrdinaryDocument( + [ + `orphan`, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root, identity: statedIdentity(undefined) }, + ), + ); + + expect(failure).toBeInstanceOf(UnresolvedGitIdentityError); + expect(String(failure)).toContain("git config --global user.name"); + // Nothing was committed, and no identity was substituted. + expect(checkout.run("rev-parse", "HEAD")).toBe(before); + // The staging that came before it still happened: this refuses the commit, + // not the document that led to it. + expect(checkout.run("diff", "--cached", "--name-only")).toContain("orphan.md"); + }); + + it("leaves every other component usable when no identity resolves", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // Repository, Worktree, Dir, Switch and Add all work: none of them writes a + // commit object, so none of them needs to know who anybody is. + const rendered = yield* runOrdinaryDocument( + [ + ``, + ``, + "", + ``, + `fine`, + ``, + "", + "", + "ran", + ].join("\n"), + { root, cwd: checkout.root, identity: statedIdentity(undefined) }, + ); + expect(String(rendered)).toContain("ran"); + }); + + it("keeps hooks, monitors, signing and repository helpers disabled", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const outside = yield* useTempDirectory("xmd-ordinary-hooks-"); + const marks = { pre: `${outside}/pre-commit`, post: `${outside}/post-commit` }; + + // A repository that does everything it can to run a program of its own: two + // hooks, a signing program, a file-system monitor and a credential helper. + for (const [hook, mark] of [ + ["pre-commit", marks.pre], + ["post-commit", marks.post], + ] as const) { + yield* ensureDir(`${checkout.root}/.githooks`); + yield* writeTextFile( + `${checkout.root}/.githooks/${hook}`, + `#!/bin/sh\nprintf ran > ${mark}\n`, + ); + yield* until(chmod(`${checkout.root}/.githooks/${hook}`, 0o755)); + } + checkout.run("config", "core.hooksPath", ".githooks"); + checkout.run("config", "commit.gpgSign", "true"); + checkout.run("config", "gpg.program", `${outside}/absent-signer`); + checkout.run("config", "core.fsmonitor", `${outside}/absent-monitor`); + checkout.run("config", "credential.helper", `!${outside}/absent-helper`); + + yield* runOrdinaryDocument( + [ + `safe`, + ``, + ``, + ].join("\n"), + { + root, + cwd: checkout.root, + identity: statedIdentity("Ada Lovelace 1 +0000"), + }, + ); + + // The identity is the only thing borrowed. Neither hook ran, the commit is + // unsigned, and the monitor and helper programs — which do not exist — + // never had to. + expect({ + pre: yield* exists(marks.pre), + post: yield* exists(marks.post), + }).toEqual({ pre: false, post: false }); + expect(checkout.run("log", "-1", "--pretty=%G?")).toBe("N"); + expect(checkout.run("log", "-1", "--pretty=%an")).toBe("Ada Lovelace"); + }); + + it("refuses a branch another checkout of the same repository holds", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }); + + const failure = yield* raised( + runOrdinaryDocument(``, { root, cwd: checkout.root }), + ); + expect(String(failure)).toContain("branch-checked-out-elsewhere"); + }); +}); diff --git a/packages/workflow/tests/run-composition-managed.test.ts b/packages/workflow/tests/run-composition-managed.test.ts new file mode 100644 index 00000000..2ae2e1d4 --- /dev/null +++ b/packages/workflow/tests/run-composition-managed.test.ts @@ -0,0 +1,631 @@ +/** + * Tier ORC — managed checkouts under an ordinary `xmd run`. + * + * What makes a checkout this execution's is an advisory lock, and what makes it + * the same checkout tomorrow is the sidecar beside it. So this is where + * persistence, compatible reuse, non-mutating conflict refusal, adoption of an + * interrupted creation, and exclusive ownership across real processes are + * asked. + * + * Every repository is real, every Git command is real, every lock is taken from + * the operating system, and the managed root is a temporary directory of the + * suite's own — no test ever touches the user's `~/.xmd/repositories`. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, type Operation } from "effection"; +import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { git, useBareRemote } from "./support/git-remotes.ts"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { spawn, withResolvers } from "effection"; +import { registerComponents } from "@executablemd/core"; +import type { ChildOutcome } from "./support/run-composition-child.ts"; +import { + causedBy, + commonDirectoryOf, + countingOrdinaryHost, + fingerprintTree, + gitStateOf, + haltAtGate, + subcommands, + raised, + readSidecar, + repositorySlotOf, + runOrdinaryDocument, + gateComponent, + useHostCheckout, + useManagedRoot, + worktreeSlotOf, + type HostCheckout, +} from "./support/run-composition.ts"; +import { CHILD, REMOTE, entriesOf, isManagedRefusal } from "./support/run-composition-tier.ts"; + +describe("ORC8 — managed checkouts are persistent", () => { + it("leaves the checkout, its metadata and its working files after the run", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const common = commonDirectoryOf(checkout); + const slot = worktreeSlotOf(root, common, "kept"); + + yield* runOrdinaryDocument( + [ + ``, + `unfinished`, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + expect(yield* exists(slot.checkout)).toBe(true); + expect(yield* exists(`${slot.checkout}/draft.md`)).toBe(true); + const sidecar = yield* readSidecar(slot); + expect(sidecar).toMatchObject({ + kind: "worktree", + version: 1, + name: "kept", + requestedBranch: "kept", + requestedBase: null, + owner: common, + }); + }); + + it("leaves a managed Repository, its metadata and its files after the run", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + yield* runOrdinaryDocument( + [ + ``, + `unfinished`, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + expect(yield* exists(`${slot.checkout}/draft.md`)).toBe(true); + expect(yield* readSidecar(slot)).toMatchObject({ + kind: "repository", + version: 1, + name: "project", + locator: remote.locator, + requestedBase: null, + }); + }); + + it("issues no Git or delete command for a checkout while tearing down", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const counting = countingOrdinaryHost(); + + yield* runOrdinaryDocument( + [ + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root, host: counting.host }, + ); + + // Nothing that could undo a checkout ever ran — not while the document was + // expanding, and not on the way out. + const issued = subcommands(counting.counters); + for (const undoing of ["reset", "clean", "restore", "prune", "gc", "fetch"]) { + expect(`${undoing} ${issued.includes(undoing)}`).toBe(`${undoing} false`); + } + expect(counting.counters.commands.some((args) => args.includes("--force"))).toBe(false); + expect( + counting.counters.commands.some((args) => args[0] === "worktree" && args[1] === "remove"), + ).toBe(false); + }); + + it("keeps both kinds of checkout after a cancellation", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const repository = repositorySlotOf(root, remote.locator, "project"); + const worktree = worktreeSlotOf(root, commonDirectoryOf(checkout), "surviving"); + + yield* haltAtGate( + [ + ``, + ``, + `written before the halt`, + "", + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + for (const slot of [repository, worktree]) { + expect(yield* exists(slot.checkout)).toBe(true); + expect(yield* readSidecar(slot)).not.toBe(undefined); + } + expect(yield* exists(`${worktree.checkout}/in-flight.md`)).toBe(true); + }); + + it("keeps a managed Repository after an authored failure inside its body", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "surviving"); + + yield* raised( + runOrdinaryDocument( + [ + ``, + `written before the failure`, + ``, + ``, + ``, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + + // The path, the sidecar, the Git state and the working file all survive. + expect(yield* exists(slot.checkout)).toBe(true); + expect(yield* readSidecar(slot)).toMatchObject({ + kind: "repository", + version: 1, + name: "surviving", + locator: remote.locator, + }); + expect(yield* readTextFile(`${slot.checkout}/kept.md`)).toBe("written before the failure"); + // The branch the document switched to and the staging it did are both still + // there: nothing rolled back, and nothing was cleaned up on the way out. + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], slot.checkout, checkout.home)).toBe( + "in-progress", + ); + expect(git(["diff", "--cached", "--name-only"], slot.checkout, checkout.home)).toContain( + "kept.md", + ); + }); + + it("keeps the checkout after an authored failure inside the Worktree body", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "survivor"); + + yield* raised( + runOrdinaryDocument( + [ + ``, + `written before the failure`, + ``, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + + expect(yield* exists(`${slot.checkout}/kept.md`)).toBe(true); + expect(yield* readSidecar(slot)).toMatchObject({ kind: "worktree" }); + }); +}); + +describe("ORC9 — compatible reuse", () => { + it("reuses the same checkout and preserves the work the first run left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + const document = ``; + + const first = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const created = yield* readSidecar(slot); + + // Work a person would do between two runs: a new branch and a commit. + git(["switch", "-c", "later"], slot.checkout, checkout.home); + git(["commit", "--allow-empty", "-m", "moved on"], slot.checkout, checkout.home); + const moved = git(["rev-parse", "HEAD"], slot.checkout, checkout.home); + + // And uncommitted work: one tracked file edited, one untracked file added. + yield* writeTextFile(`${slot.checkout}/which.txt`, "edited by hand\n"); + yield* writeTextFile(`${slot.checkout}/scratch.md`, "not committed\n"); + const dirty = git(["status", "--porcelain"], slot.checkout, checkout.home); + expect(dirty).toContain("which.txt"); + expect(dirty).toContain("scratch.md"); + + const second = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + + expect(second).toBe(first); + // Reuse revalidated the identity — owner, origin, object format and creation + // commit — and recorded nothing new. + expect(yield* readSidecar(slot)).toEqual(created); + // Neither the branch it is on nor the commit it holds was reset. + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], slot.checkout, checkout.home)).toBe("later"); + expect(git(["rev-parse", "HEAD"], slot.checkout, checkout.home)).toBe(moved); + // And the working tree is exactly as dirty as it was left. + expect(git(["status", "--porcelain"], slot.checkout, checkout.home)).toBe(dirty); + expect(yield* readTextFile(`${slot.checkout}/which.txt`)).toBe("edited by hand\n"); + expect(yield* readTextFile(`${slot.checkout}/scratch.md`)).toBe("not committed\n"); + }); + + it("revalidates the identity it reuses rather than trusting the sidecar", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + const document = ``; + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const counting = countingOrdinaryHost(); + yield* runOrdinaryDocument(document, { + root, + cwd: checkout.root, + host: counting.host, + }); + + // The second selection asked the checkout itself who it is, rather than + // reading the sidecar and believing it. + const issued = counting.counters.commands.map((args) => args.join(" ")); + expect(issued.some((command) => command.includes("rev-parse --show-toplevel"))).toBe(true); + expect(issued.some((command) => command.includes("rev-parse --git-common-dir"))).toBe(true); + expect(issued.some((command) => command.includes("rev-parse --show-object-format"))).toBe(true); + expect(issued.some((command) => command.includes("config --get remote.origin.url"))).toBe(true); + // And it cloned nothing. + expect(subcommands(counting.counters)).not.toContain("clone"); + }); +}); + +describe("ORC10 — a conflict changes nothing", () => { + /** + * One refusal, fingerprinted on both sides. + * + * The claim is not "it failed" but "it failed and changed nothing", so the + * slot's complete byte fingerprint and the checkout's own Git state are taken + * before the refusal and compared after it. A reset, a fetch, a switch or a + * rewritten sidecar would all show up here. + */ + function* refusesWithoutMutating( + slot: ReturnType, + checkout: HostCheckout, + run: () => Operation, + reason: string, + ): Operation { + const bytes = yield* fingerprintTree(slot.slot); + const state = gitStateOf(checkout, slot.checkout); + + const failure = yield* raised(run()); + expect(`${reason}: ${causedBy(failure, isManagedRefusal)?.reason}`).toBe( + `${reason}: incompatible-reuse`, + ); + + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); + expect(gitStateOf(checkout, slot.checkout)).toEqual(state); + } + + it("refuses every changed Repository fact and leaves the slot byte-identical", function* () { + const remote = yield* useBareRemote(REMOTE); + const other = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + const document = ``; + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const created = yield* readSidecar(slot); + + // A changed base. Same name, same url, different creation identity. + yield* refusesWithoutMutating( + slot, + checkout, + () => + runOrdinaryDocument( + ``, + { root, cwd: checkout.root }, + ), + "changed base", + ); + + // A sidecar somebody edited. The object format is the member the checkout + // itself can contradict, so this is the object-format comparison too. + yield* writeTextFile( + slot.metadata, + `${JSON.stringify({ ...(created as object), objectFormat: "sha256" }, null, 2)}\n`, + ); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "object format", + ); + + // A sidecar naming another repository's creation commit. + yield* writeTextFile( + slot.metadata, + `${JSON.stringify({ ...(created as object), creationCommit: "0".repeat(40) }, null, 2)}\n`, + ); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "metadata", + ); + + // An origin that no longer names what the checkout was cloned from. + yield* writeTextFile(slot.metadata, `${JSON.stringify(created, null, 2)}\n`); + git(["remote", "set-url", "origin", other.locator], slot.checkout, checkout.home); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "origin", + ); + git(["remote", "set-url", "origin", remote.locator], slot.checkout, checkout.home); + + // A common directory belonging to a different repository: the slot now + // holds an unrelated clone at exactly the recorded path. + const shadow = `${slot.slot}/shadow`; + git(["clone", "--", other.locator, shadow], slot.slot, checkout.home); + yield* rm(slot.checkout, { recursive: true }); + git(["clone", "--", other.locator, slot.checkout], slot.slot, checkout.home); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "common directory", + ); + }); + + it("refuses a Worktree asked for on a different branch or base, and changes nothing", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "review"); + + yield* runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }); + const created = yield* readSidecar(slot); + + for (const [reason, source] of [ + ["branch", ``], + ["base", ``], + ] as const) { + const bytes = yield* fingerprintTree(slot.slot); + const state = gitStateOf(checkout, slot.checkout); + const failure = yield* raised(runOrdinaryDocument(source, { root, cwd: checkout.root })); + expect(`${reason}: ${causedBy(failure, isManagedRefusal)?.reason}`).toBe( + `${reason}: incompatible-reuse`, + ); + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); + expect(gitStateOf(checkout, slot.checkout)).toEqual(state); + expect(yield* readSidecar(slot)).toEqual(created); + } + }); + + it("refuses a Worktree whose checkout stopped belonging to its owner", function* () { + const remote = yield* useBareRemote(REMOTE); + const other = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const common = commonDirectoryOf(checkout); + const slot = worktreeSlotOf(root, common, "owned"); + + yield* runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }); + + // An unrelated clone at exactly the recorded path. It is a perfectly good + // Git checkout; what it is not is a linked worktree of the owner. + yield* rm(slot.checkout, { recursive: true }); + git(["clone", "--", other.locator, slot.checkout], slot.slot, checkout.home); + + const bytes = yield* fingerprintTree(slot.slot); + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }), + ); + expect(causedBy(failure, isManagedRefusal)?.reason).toBe("incompatible-reuse"); + expect(String(failure)).toContain("linked checkout"); + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); + }); +}); + +describe("ORC11 — an interrupted creation", () => { + it("adopts a metadata-free checkout that is exactly what creation would have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + const document = ``; + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const written = yield* readSidecar(slot); + // Exactly the state an interruption between the clone and the sidecar + // leaves: the checkout, and nothing describing it. + yield* rm(slot.metadata); + expect(yield* readSidecar(slot)).toBe(undefined); + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + expect(yield* readSidecar(slot)).toEqual(written); + }); + + it("refuses a slot holding something creation would never have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + const document = ``; + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + yield* rm(slot.metadata); + // An unexplained entry beside the checkout. + yield* writeTextFile(`${slot.slot}/stray.txt`, "who put this here\n"); + const beforeEntries = yield* entriesOf(slot.slot); + + const failure = yield* raised(runOrdinaryDocument(document, { root, cwd: checkout.root })); + expect(causedBy(failure, isManagedRefusal)?.reason).toBe("partial-creation"); + expect(yield* entriesOf(slot.slot)).toEqual(beforeEntries); + expect(yield* readSidecar(slot)).toBe(undefined); + }); + + it("adopts a metadata-free Worktree that is exactly what creation would have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "resumed"); + const document = ``; + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const written = yield* readSidecar(slot); + yield* rm(slot.metadata); + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + expect(yield* readSidecar(slot)).toEqual(written); + }); + + it("refuses a metadata-free Worktree that creation would never have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "moved"); + const document = ``; + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + yield* rm(slot.metadata); + // The branch it is on is no longer the branch this request names, so this + // is not the state creation would have left behind. + git(["switch", "-c", "somewhere-else"], slot.checkout, checkout.home); + + const bytes = yield* fingerprintTree(slot.slot); + const failure = yield* raised(runOrdinaryDocument(document, { root, cwd: checkout.root })); + expect(causedBy(failure, isManagedRefusal)?.reason).toBe("partial-creation"); + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); + expect(yield* readSidecar(slot)).toBe(undefined); + }); +}); + +describe("ORC12 — exclusive ownership across processes", () => { + /** One second process, run to completion, and what it reported. */ + function* elsewhere(root: string, cwd: string, source: string): Operation { + const outcome = spawnSync( + process.execPath, + ["run", "--allow-all", "--frozen", CHILD, root, cwd, source], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + const printed = outcome.stdout.trim().split("\n").at(-1) ?? ""; + if (printed === "") { + throw new Error(`the child printed nothing: ${outcome.stderr}`); + } + return JSON.parse(printed) as ChildOutcome; + } + + it("refuses a second process the slot a first is holding, and changes nothing", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const held = worktreeSlotOf(root, commonDirectoryOf(checkout), "contended"); + const free = worktreeSlotOf(root, commonDirectoryOf(checkout), "uncontended"); + // The child renders what it bound, so the parent can read the path back. + const document = `\n\n{w}`; + + const opened = withResolvers(); + let reached = false; + const holder = yield* spawn(() => + scoped(function* () { + yield* registerComponents([ + gateComponent(() => { + if (!reached) { + reached = true; + opened.resolve(); + } + }), + ]); + yield* runOrdinaryDocument( + [ + ``, + `held by the first process`, + "", + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + }), + ); + yield* opened.operation; + + // While the first process holds it, a real second process is refused — + // without waiting, and with a word the person running it can act on. + const bytes = yield* fingerprintTree(held.slot); + const refused = yield* elsewhere(root, checkout.root, document); + expect(refused.kind).toBe("refused"); + expect(refused.reason).toBe("in-use"); + expect(refused.message).toContain("another process is working in"); + // And nothing under the slot moved. + expect(yield* fingerprintTree(held.slot)).toEqual(bytes); + + // A different slot is not contended, and succeeds while the first is still + // held: the lock is per-slot, not per-root. + const other = yield* elsewhere( + root, + checkout.root, + `\n\n{w}`, + ); + expect(other.kind).toBe("selected"); + expect(other.bound).toBe(free.checkout); + + // The first process is cancelled. The kernel releases what it held, and the + // checkout it made is still there. + yield* holder.halt(); + expect(yield* exists(`${held.checkout}/held.md`)).toBe(true); + + const afterCancellation = yield* elsewhere(root, checkout.root, document); + expect(afterCancellation.kind).toBe("selected"); + expect(afterCancellation.bound).toBe(held.checkout); + // It reused the very checkout the cancelled process left, contents and all. + expect(yield* exists(`${held.checkout}/held.md`)).toBe(true); + }); + + it("hands a slot on after a normal release, with the checkout intact", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "serial"); + const document = `\n\n{w}`; + + // A first execution completes normally and releases. + const bound = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + expect(String(bound).trim()).toBe(slot.checkout); + + // A real second process then takes it, and finds the same checkout. + const later = yield* elsewhere(root, checkout.root, document); + expect(later.kind).toBe("selected"); + expect(later.bound).toBe(slot.checkout); + expect(yield* readSidecar(slot)).toMatchObject({ kind: "worktree", name: "serial" }); + }); +}); + +/** Two executions in one process must not share a checkout registry. */ /** Two executions in one process must not share a checkout registry. */ + +describe("ORC12 — one process reuses the lease it already holds", () => { + it("selects the same slot twice in one execution without asking twice", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout: HostCheckout = yield* useHostCheckout(remote.locator); + + const rendered = yield* runOrdinaryDocument( + [ + ``, + ``, + "", + "{first === second ? 'same' : 'different'}", + ].join("\n"), + { root, cwd: checkout.root }, + ); + expect(String(rendered)).toContain("same"); + }); +}); diff --git a/packages/workflow/tests/run-composition-remote.test.ts b/packages/workflow/tests/run-composition-remote.test.ts new file mode 100644 index 00000000..4af5ea3d --- /dev/null +++ b/packages/workflow/tests/run-composition-remote.test.ts @@ -0,0 +1,943 @@ +/** + * Tier ORC — live remotes under an ordinary `xmd run`. + * + * Publishing, and what publishing authorizes. An ordinary run retains nothing, + * so the only thing that can authorize a pull request is evidence this + * execution's own provider instance is holding — which is why most of this file + * is about what does *not* grant it: another run, a copied value, a trace file, + * a Push of a different destination. + * + * Every repository is real and every Git command is real; the Git host is + * modeled, because what is under test is which requests are made and which are + * refused before one is. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation } from "effection"; +import { admitLivePushEvidence } from "../src/deno/run-composition/operations.ts"; +import { GitComposition } from "../src/composition/git-api.ts"; +import type { GitPushOutcome } from "../src/composition/git-push-records.ts"; +import { LivePushEvidenceError } from "../src/deno/run-composition/errors.ts"; +import { git, remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; +import type { BareRemote } from "./support/git-remotes.ts"; +import { selectedRepository } from "../src/composition/context.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; +import { gitHubSource } from "../src/deno/composition/github.ts"; +import { + creations, + fakeGitHubAccess, + gitHubStore, + issueCreations, + patches, +} from "./support/github.ts"; +import { + causedBy, + countingOrdinaryHost, + fingerprintTree, + gitStateOf, + subcommands, + raised, + runOrdinaryDocument, + recordingAccess, + rewritingHost, + statedIdentity, + useHostCheckout, + useManagedRoot, + useNamedOriginCheckout, +} from "./support/run-composition.ts"; +import { + GITHUB_LOCATOR, + HEAD, + REMOTE, + TOKEN, + evidenceRoutes, + isAuthorityFailure, +} from "./support/run-composition-tier.ts"; + +describe("ORC14 — live Push evidence", () => { + it("records a performed publication and lets exactly that head be authorized", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `published`, + ``, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + + // The branch really is at the remote, at the commit this execution made. + expect(remoteBranch(remote, "published")).toBe(checkout.run("rev-parse", "HEAD")); + // And the pull request got past the evidence gate: what stopped it is the + // adapter declining a locator that is not a github.com repository, which is + // the step *after* the local authorization this criterion is about. + expect(String(failure)).toContain("only for repositories on github.com"); + expect(String(failure)).not.toContain("holds no successful result"); + }); + + it("records an already-equal publication the same way it records a performed one", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // The first execution performs the publication. + yield* runOrdinaryDocument( + [ + ``, + `equal`, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ); + const published = checkout.run("rev-parse", "HEAD"); + expect(remoteBranch(remote, "equal")).toBe(published); + + // The second finds the destination already naming this exact commit and + // adopts it — pushing nothing — and the adopted publication is evidence. + const counting = countingOrdinaryHost(); + const failure = yield* raised( + runOrdinaryDocument( + [``, ``].join("\n"), + { root, cwd: checkout.root, host: counting.host }, + ), + ); + expect(subcommands(counting.counters)).toContain("ls-remote"); + expect(subcommands(counting.counters)).not.toContain("push"); + expect(String(failure)).toContain("only for repositories on github.com"); + expect(String(failure)).not.toContain("holds no successful result"); + }); + + it("refuses when the Push named another branch, checkout, origin or destination", function* () { + const remote = yield* useBareRemote(REMOTE); + const other = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // Another branch and therefore another destination ref: the Push is real + // and irrelevant. + const branch = yield* raised( + runOrdinaryDocument( + [ + ``, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + expect(String(branch)).toContain("holds no successful result"); + expect(remoteBranch(remote, "unpublished")).toBe(undefined); + + // Another repository entirely: a managed Repository publishes, and the + // ambient one asks. + const repository = yield* raised( + runOrdinaryDocument( + [ + ``, + ``, + ``, + "", + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + expect(String(repository)).toContain("holds no successful result"); + }); + + it("admits one head only when every dimension of the publication matches", function* () { + const identity = { + name: "project", + locatorFingerprint: "a".repeat(64), + requestedBase: null, + creationCommit: "b".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }; + const held = { + identity, + checkoutRoot: "/checkouts/project", + origin: "https://github.com/octo/project", + branch: "feature", + destinationRef: "refs/heads/feature", + commit: "c".repeat(40), + }; + + // The exact publication authorizes. + admitLivePushEvidence([held], held); + + // Every single dimension, changed on its own, does not. Git forbids two + // checkouts of one repository on one branch, so the checkout dimension is + // unreachable through a document — and it is exactly as load-bearing as the + // others, which is why it is asked here rather than left unasked. + const wrong: readonly [string, typeof held][] = [ + ["repository", { ...held, identity: { ...identity, locatorFingerprint: "d".repeat(64) } }], + ["checkout", { ...held, checkoutRoot: "/checkouts/elsewhere" }], + ["origin", { ...held, origin: "https://github.com/octo/other" }], + ["branch", { ...held, branch: "other" }], + ["destination", { ...held, destinationRef: "refs/heads/other" }], + ["commit", { ...held, commit: "e".repeat(40) }], + ]; + for (const [dimension, expected] of wrong) { + let refused: unknown; + try { + admitLivePushEvidence([held], expected); + } catch (error) { + refused = error; + } + expect(`${dimension}: ${refused instanceof LivePushEvidenceError}`).toBe( + `${dimension}: true`, + ); + } + + // A changed commit on the same destination is disagreement, not absence. + let conflicting: unknown; + try { + admitLivePushEvidence([held], { ...held, commit: "e".repeat(40) }); + } catch (error) { + conflicting = error; + } + expect((conflicting as LivePushEvidenceError).reason).toBe("conflicting-push-evidence"); + + // And the last publication of a destination is the one that decides. + const superseded = { ...held, commit: "f".repeat(40) }; + admitLivePushEvidence([held, superseded], superseded); + let stale: unknown; + try { + admitLivePushEvidence([held, superseded], held); + } catch (error) { + stale = error; + } + expect((stale as LivePushEvidenceError).reason).toBe("conflicting-push-evidence"); + }); + + it("lets the latest publication of a destination decide", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // Two publications of one destination, at successive commits, and then a + // third commit nothing published. + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `one`, + ``, + ``, + ``, + `two`, + ``, + ``, + ``, + `three`, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + // The second publication superseded the first, and the head has moved past + // both — so this is a conflict rather than an absence. + expect(String(failure)).toContain("published that branch at a different commit"); + expect(remoteBranch(remote, "moving")).toBe(checkout.run("rev-parse", "HEAD~1")); + }); + + it("authorizes at the second publication's commit, not the first's", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `one`, + ``, + ``, + ``, + `two`, + ``, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + // Past the gate: the latest publication names the head the pull request + // would open from. + expect(String(failure)).toContain("only for repositories on github.com"); + expect(remoteBranch(remote, "latest")).toBe(checkout.run("rev-parse", "HEAD")); + }); +}); + +describe("ORC15 — evidence cannot cross runs", () => { + it("refuses a PullRequest in an execution that published nothing", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }), + ); + expect(String(failure)).toContain("holds no successful result"); + }); + + it("does not let one execution's real publication authorize the next", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + store.resolveHead = (branch) => remoteRefs(remote).get(`refs/heads/${branch}`); + const root = yield* useManagedRoot(); + const checkout = yield* useNamedOriginCheckout(remote, GITHUB_LOCATOR); + const options = { + root, + cwd: checkout.root, + host: rewritingHost(GITHUB_LOCATOR, remote.locator), + gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, + }; + + // One execution publishes and opens a pull request. This is the real thing: + // a branch at the remote and a pull request at the modeled GitHub. + yield* runOrdinaryDocument( + [ + ``, + `crossing`, + ``, + ``, + ``, + ``, + ].join("\n"), + options, + ); + expect(creations(store)).toBe(1); + const published = checkout.run("rev-parse", "HEAD"); + expect(remoteBranch(remote, "crossing")).toBe(published); + + // A second, ordinary execution. The branch is still at the remote, the + // checkout is still on it, and the pull request still exists — and none of + // that is this execution's evidence. + const second = yield* raised( + runOrdinaryDocument(``, options), + ); + expect(String(second)).toContain("holds no successful result"); + + // The refusal never reached GitHub at all. + expect(creations(store)).toBe(1); + expect(patches(store)).toBe(0); + }); + + it("grants nothing to a Push result middleware handed back without performing one", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // One execution really publishes, and the suite keeps the exact outcome the + // provider answered with — the whole successful `GitPushOutcome`. + let published: GitPushOutcome | undefined; + yield* runOrdinaryDocument( + [ + ``, + `copied`, + ``, + ``, + ``, + "", + ].join("\n"), + { + root, + cwd: checkout.root, + around: function* () { + yield* GitComposition.around({ + *pushCurrentBranch([invocation], next): Operation { + published = yield* next(invocation); + return published; + }, + }); + }, + components: [ + { + name: "Capture", + origin: "test", + props: { type: "object", additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return ""; + }, + }, + ], + }, + ); + expect(published).toBeDefined(); + expect(published?.decision).toBe("performed"); + const head = checkout.run("rev-parse", "HEAD"); + + // A new execution whose `` is answered by middleware handing that + // exact successful outcome back. The provider underneath never runs, so it + // never verifies a publication and never records evidence — and a result is + // not evidence. + let delegated = 0; + const failure = yield* raised( + runOrdinaryDocument([``, ``].join("\n"), { + root, + cwd: checkout.root, + around: function* () { + yield* GitComposition.around({ + // deno-lint-ignore require-yield + *pushCurrentBranch([_invocation], _next): Operation { + delegated += 1; + if (published === undefined) { + throw new Error("the suite captured no publication to hand back"); + } + return published; + }, + }); + }, + }), + ); + + // The middleware answered, so the component saw a successful Push. + expect(delegated).toBe(1); + // The branch really is still published at that commit, so nothing about the + // world contradicts the copied result. + expect(remoteBranch(remote, "copied")).toBe(head); + // And the pull request is refused anyway: what authorizes it is what this + // provider verified, not what anything handed it. + expect(String(failure)).toContain("holds no successful result"); + }); + + it("grants nothing to a copied selection, a copied result or a previous trace", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // One execution publishes, and hands its own Repository selection and the + // rendered result of the Push out to the suite. + let carried: RepositorySelection | undefined; + yield* runOrdinaryDocument( + [ + ``, + `carried`, + ``, + ``, + ``, + "", + ].join("\n"), + { + root, + cwd: checkout.root, + components: [ + { + name: "Capture", + origin: "test", + props: { type: "object", additionalProperties: false }, + *fn(): Operation { + carried = yield* selectedRepository(); + return ""; + }, + }, + ], + }, + ); + expect(carried).toBeDefined(); + + // A new execution, handed the exact selection the first one minted and the + // path it bound, installed as its contextual Repository. + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + contextualRepository: carried, + }), + ); + // The selection is not one this provider minted, so it names no checkout — + // and the evidence it would have needed does not exist here either. + expect(String(failure)).toContain("not one this execution selected"); + }); +}); + +describe("ORC16 — live Issues", () => { + it("reads and files through the configured transport, keyed to this execution", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ + token: TOKEN, + issues: [ + { + number: 7, + nodeId: "I_7", + state: "open", + title: "an existing issue", + body: "described", + labels: [], + assignee: null, + }, + ], + }); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const options = { + root, + cwd: checkout.root, + gitHubIssues: { + ceiling: [GITHUB_LOCATOR], + access: gitHubSource(fakeGitHubAccess(store)), + }, + }; + + const rendered = yield* runOrdinaryDocument( + [ + ``, + "", + "read {found.title}", + "", + ``, + ``, + "the description", + "", + "", + ].join("\n"), + options, + ); + expect(String(rendered)).toContain("read an existing issue"); + expect(issueCreations(store)).toBe(1); + + // A second execution is a new question, not a resumption: the identity it + // presents is its own, so the provider is asked again. + yield* runOrdinaryDocument( + [ + ``, + ``, + "the description", + "", + "", + ].join("\n"), + options, + ); + expect(issueCreations(store)).toBe(2); + }); + + it("sends no credential and no request for a target outside the ceiling", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + gitHubIssues: { + ceiling: [GITHUB_LOCATOR], + access: gitHubSource(fakeGitHubAccess(store)), + }, + }), + ); + expect(failure).toBeInstanceOf(Error); + expect(store.requests).toHaveLength(0); + }); + + it("installs no matching provider when nothing is configured", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }), + ); + expect(String(failure)).toContain("no issue provider handles"); + }); +}); + +describe("ORC17 — live PullRequests", () => { + it("opens a pull request the run published, through the configured transport", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + store.resolveHead = (branch) => remoteRefs(remote).get(`refs/heads/${branch}`); + const root = yield* useManagedRoot(); + const checkout = yield* useNamedOriginCheckout(remote, GITHUB_LOCATOR); + + const rendered = yield* runOrdinaryDocument( + [ + ``, + `opened`, + ``, + ``, + ``, + ``, + "the body", + "", + "", + "number {pullRequest.number} state {pullRequest.state}", + ].join("\n"), + { + root, + cwd: checkout.root, + host: rewritingHost(GITHUB_LOCATOR, remote.locator), + gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, + }, + ); + expect(creations(store)).toBe(1); + expect(String(rendered)).toContain("state open"); + // The evidence it bound names the repository this run acted on. + expect(String(rendered)).toContain("number 1"); + }); + + it("reads all three collections, each from its own route", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const endpoint = "https://api.github.test"; + const recording = recordingAccess(evidenceRoutes(endpoint), endpoint); + const access = gitHubSource(recording.access); + + // All three, in one document, under the ordinary provider. + const rendered = yield* runOrdinaryDocument( + [ + ``, + ``, + ``, + "", + "counts {reviews.length} {comments.length} {checks.length}", + "", + "", + "", + "", + "", + "", + ].join("\n"), + { + root, + cwd: checkout.root, + gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access }, + }, + ); + + // One review, two comments of both kinds, and two checks of both kinds. + expect(String(rendered)).toContain("counts 1 2 2"); + // Each collection carries the existing normalized contract. + expect(String(rendered)).toContain('"state": "approved"'); + expect(String(rendered)).toContain('"author": "reviewer"'); + expect(String(rendered)).toContain('"kind": "conversation"'); + expect(String(rendered)).toContain('"kind": "review"'); + expect(String(rendered)).toContain('"diffHunk"'); + expect(String(rendered)).toContain('"kind": "check-run"'); + expect(String(rendered)).toContain('"conclusion": "failure"'); + expect(String(rendered)).toContain('"kind": "commit-status"'); + expect(String(rendered)).toContain('"state": "error"'); + + // Each read reached the route its own collection lives at. + const asked = recording.requests.map((request) => new URL(request.url).pathname); + for (const route of [ + "/repos/octo/project/pulls/4/reviews", + "/repos/octo/project/issues/4/comments", + "/repos/octo/project/pulls/4/comments", + `/repos/octo/project/commits/${HEAD}/check-runs`, + `/repos/octo/project/commits/${HEAD}/status`, + ]) { + expect(`${route}: ${asked.includes(route)}`).toBe(`${route}: true`); + } + expect(recording.requests.every((request) => request.authorized)).toBe(true); + + // Outside the ceiling: refused before anything is sent. + const sent = recording.requests.length; + const failure = yield* raised( + runOrdinaryDocument( + ``, + { root, cwd: checkout.root, gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access } }, + ), + ); + expect(String(failure)).toContain("has not authorized"); + expect(recording.requests).toHaveLength(sent); + + // And with nothing allowed, no read this host performs exists at all. + const unconfigured = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + gitHubPullRequests: { access }, + }), + ); + expect(String(unconfigured)).toContain("no pull-request provider handles"); + expect(recording.requests).toHaveLength(sent); + }); + + it("refuses an unpublished head before a credential or a request", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + const root = yield* useManagedRoot(); + const checkout = yield* useNamedOriginCheckout(remote, GITHUB_LOCATOR); + const counting = countingOrdinaryHost(rewritingHost(GITHUB_LOCATOR, remote.locator)); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + host: counting.host, + gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, + }), + ); + expect(String(failure)).toContain("holds no successful result"); + expect(store.requests).toHaveLength(0); + expect(counting.counters.sessions).toEqual([]); + }); +}); + +/** + * A checkout is authority only under the whole repository identity. + * + * Two `` elements naming one url under two names are two + * repositories: two slots, two advisory leases, two lines of Push evidence. + * Every member of their identities is equal except `name`, which is exactly the + * case a comparison that stops at the locator fingerprint cannot see — and a + * `` into the second, written where the first is the Repository in scope, + * would carry the first's authority into a checkout it never selected. + * + * The refusal is asked for at the three surfaces that reach different things: a + * local mutation, a publication, and a Git host. The two cases after it are the + * controls — one that the same document shape does reach every one of those + * boundaries when the identity matches, and one that a Worktree of the + * Repository in scope is still reachable, so the comparison is of identities + * and not of checkout paths or worktree names. + */ +/** + * Every ref the bare remote holds, as one comparable value. + * + * `sort()` rather than `toSorted()`: the Node typecheck targets ES2022, where + * the latter does not exist, and the array being sorted is the one `map` just + * made — so there is nothing of anyone else's to mutate. + */ +function refsOf(remote: BareRemote): string[] { + return [...remoteRefs(remote)].map(([name, commit]) => `${name} ${commit}`).sort(); +} + +describe("checkout authority is the whole repository identity", () => { + /** Deterministic, so no case depends on who this host says its user is. */ + const IDENT = "Tester 0 +0000"; + + it("refuses Git, Push and PullRequest in a same-locator Repository the scope did not select", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const here = yield* useHostCheckout(remote.locator); + const rewriting = () => rewritingHost(GITHUB_LOCATOR, remote.locator); + + // Created once and reused afterwards: what is under test is an operation + // written in B, not the creation of either repository. + const arranged = String( + yield* runOrdinaryDocument( + [ + ``, + ``, + "", + "alpha {alpha}", + "", + "beta {beta}", + ].join("\n"), + { root, cwd: here.root, host: rewriting() }, + ), + ); + const alphaPath = /alpha (\S+)/.exec(arranged)?.[1] ?? ""; + const betaPath = /beta (\S+)/.exec(arranged)?.[1] ?? ""; + expect(alphaPath).not.toBe(""); + expect(betaPath).not.toBe(""); + // Two names, one locator: different slots, and neither inside the other. + expect(betaPath).not.toBe(alphaPath); + + // Entering B is not the act under test. Running it once here settles B's + // index, so the comparisons below are against a checkout that has already + // been selected and stood in. + yield* runOrdinaryDocument( + [ + ``, + ``, + "", + "nothing is asked of Git here", + "", + "", + ].join("\n"), + { root, cwd: here.root, host: rewriting() }, + ); + + for (const [element, written] of [ + ["", ``], + ["", ``], + ["", ``], + ] as const) { + const counting = countingOrdinaryHost(rewriting()); + // The credential-counting access, so "no credential was read" is a + // measurement rather than an inference from where the refusal sits. + const recording = recordingAccess({}); + // Observed rather than inferred: the branch, the head, the index and the + // working tree B holds, and the refs its remote holds. + const state = gitStateOf(here, betaPath); + const tree = yield* fingerprintTree(betaPath); + const refs = refsOf(remote); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + ``, + "", + written, + "", + "", + ].join("\n"), + { + root, + cwd: here.root, + host: counting.host, + identity: statedIdentity(IDENT), + gitHubPullRequests: { access: gitHubSource(recording.access) }, + }, + ), + ); + + // The checkout-authority refusal, named for the element that was written + // — not a later failure that happens to stop the same document. + const authority = causedBy(failure, isAuthorityFailure); + expect(`${element} ${authority?.operation}`).toBe(`${element} ${element}`); + expect(`${element} ${String(failure)}`).toContain( + "is inside none of the checkouts this execution selected for the repository in scope", + ); + + // B was not mutated ... + expect(`${element} ${gitStateOf(here, betaPath).join("|")}`).toBe( + `${element} ${state.join("|")}`, + ); + expect(yield* fingerprintTree(betaPath)).toEqual(tree); + // ... nothing was published ... + expect(refsOf(remote)).toEqual(refs); + expect(subcommands(counting.counters)).not.toContain("push"); + expect(subcommands(counting.counters)).not.toContain("ls-remote"); + // ... no authentication session was opened for any locator ... + expect(`${element} ${counting.counters.sessions.join(",")}`).toBe(`${element} `); + // ... and no Git host was reached for a credential or a request. + expect(`${element} ${recording.credentials}`).toBe(`${element} 0`); + expect(recording.requests).toEqual([]); + } + }); + + it("reaches the mutation, the publication and the Git host when the identity is the same one", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const here = yield* useHostCheckout(remote.locator); + const store = gitHubStore({ token: TOKEN }); + store.resolveHead = (branch) => remoteRefs(remote).get(`refs/heads/${branch}`); + const counting = countingOrdinaryHost(rewritingHost(GITHUB_LOCATOR, remote.locator)); + + // The same two repositories, the same `` into B's own path. The one + // thing that differs from the refusal above is which of them is the + // Repository in scope, so nothing else can be what decides. + const rendered = String( + yield* runOrdinaryDocument( + [ + ``, + ``, + ``, + "", + ``, + `control`, + ``, + ``, + ``, + ``, + "the body", + "", + "", + "", + "", + "state {pullRequest.state}", + ].join("\n"), + { + root, + cwd: here.root, + host: counting.host, + identity: statedIdentity(IDENT), + gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, + }, + ), + ); + + // Every boundary the refusal stops short of is reached here: the branch + // moved, the publication ran, and a pull request was opened. + expect(subcommands(counting.counters)).toContain("push"); + // The same counter the refusal above requires to be empty, non-empty here + // under the same host and the same fixtures — so that "no session was + // opened" is a measurement and not a counter that never moves. + expect(counting.counters.sessions).toContain(GITHUB_LOCATOR); + expect(remoteRefs(remote).has("refs/heads/control")).toBe(true); + expect(creations(store)).toBe(1); + expect(rendered).toContain("state open"); + }); + + it("operates in a Worktree of the Repository in scope, entered by its returned path", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const here = yield* useHostCheckout(remote.locator); + const rewriting = rewritingHost(GITHUB_LOCATOR, remote.locator); + + const arranged = String( + yield* runOrdinaryDocument( + [ + ``, + "", + "alpha {alpha}", + ].join("\n"), + { root, cwd: here.root, host: rewriting }, + ), + ); + const alphaPath = /alpha (\S+)/.exec(arranged)?.[1] ?? ""; + expect(alphaPath).not.toBe(""); + const head = git(["rev-parse", "HEAD"], alphaPath, here.home); + const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], alphaPath, here.home); + + const rendered = String( + yield* runOrdinaryDocument( + [ + ``, + ``, + "", + `written here`, + ``, + ``, + "", + "", + "", + "side {side}", + ].join("\n"), + { root, cwd: here.root, host: rewriting, identity: statedIdentity(IDENT) }, + ), + ); + const sidePath = /side (\S+)/.exec(rendered)?.[1] ?? ""; + // A different checkout of the same repository, under a different root. + expect(sidePath).not.toBe(""); + expect(sidePath).not.toBe(alphaPath); + + // The commit landed in the Worktree — whose identity is its owner's, and + // whose root and name are its own — and the Repository's own checkout did + // not move. + expect(git(["log", "-1", "--pretty=%s", "side"], alphaPath, here.home)).toBe("In the worktree"); + expect(git(["rev-parse", "HEAD"], alphaPath, here.home)).toBe(head); + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], alphaPath, here.home)).toBe(branch); + }); +}); diff --git a/packages/workflow/tests/support/run-composition-child.ts b/packages/workflow/tests/support/run-composition-child.ts new file mode 100644 index 00000000..fade190d --- /dev/null +++ b/packages/workflow/tests/support/run-composition-child.ts @@ -0,0 +1,82 @@ +/** + * A second process asking for the same managed checkout. + * + * Exclusive ownership is a claim about two operating-system processes, so it is + * proved by two operating-system processes. This one constructs its own + * ordinary repository provider — its own leases, its own invocation identity — + * against a managed root and a starting directory the parent names, selects + * what it was told to select, and writes one JSON line saying what happened. + * + * It prints and exits. Everything it holds is released by the kernel when it + * does, which is the other half of what the parent asserts. + */ + +import { main } from "effection"; +import process from "node:process"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { collect, execute, inlineSource } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { useCompositionComponents } from "../../src/composition/installation.ts"; +import { useRunComposition } from "../../src/deno/run-composition/provider.ts"; +import { ManagedCheckoutError } from "../../src/deno/run-composition/errors.ts"; + +/** What the parent reads back off this process's stdout. */ +export interface ChildOutcome { + readonly kind: "selected" | "refused" | "failed"; + /** The path a selection bound, when it made one. */ + readonly bound?: string; + /** The fixed word a managed-checkout refusal is reported under. */ + readonly reason?: string; + readonly message?: string; +} + +await main(function* () { + const [root, cwd, source] = process.argv.slice(2); + if (root === undefined || cwd === undefined || source === undefined) { + throw new Error("the child needs a managed root, a starting directory and a document"); + } + + let outcome: ChildOutcome; + try { + const rendered = yield* (function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return cwd; + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); + yield* useCompositionComponents(); + yield* useRunComposition({ root, cwd }); + return yield* collect( + yield* execute({ ...inlineSource(source), stream: new InMemoryStream() }), + ); + })(); + outcome = { kind: "selected", bound: String(rendered).trim() }; + } catch (error) { + const refusal = managedRefusal(error); + outcome = + refusal === undefined + ? { kind: "failed", message: String(error) } + : { kind: "refused", reason: refusal.reason, message: refusal.message }; + } + + process.stdout.write(`${JSON.stringify(outcome)}\n`); +}); + +/** The managed-checkout refusal in this error's chain, if there is one. */ +function managedRefusal(error: unknown): ManagedCheckoutError | undefined { + let current: unknown = error; + const seen = new Set(); + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (current instanceof ManagedCheckoutError) { + return current; + } + current = current instanceof Error ? current.cause : undefined; + } + return undefined; +} diff --git a/packages/workflow/tests/support/run-composition-tier.ts b/packages/workflow/tests/support/run-composition-tier.ts new file mode 100644 index 00000000..def787d0 --- /dev/null +++ b/packages/workflow/tests/support/run-composition-tier.ts @@ -0,0 +1,157 @@ +/** + * Fixtures shared by the ordinary-provider tier suites. + * + * The tier is split by capability — ambient and local Git, managed checkouts, + * live remotes — because one file describing all of it was long enough that + * finding the case a failure belongs to was its own step. What every part of it + * shares lives here, so the split does not become three drifting copies. + */ + +import { exists, readdir } from "@effectionx/fs"; +import type { Operation } from "effection"; +import { fileURLToPath } from "node:url"; +import { GitOperationAuthorityError } from "../../src/composition/errors.ts"; +import { + ManagedCheckoutError, + NoAmbientRepositoryError, +} from "../../src/deno/run-composition/errors.ts"; + +/** The github.com repository the modeled store answers for. */ +export const GITHUB_LOCATOR = "https://github.com/octo/project"; + +/** The head every modeled pull request in this file is opened from. */ +export const HEAD = "a".repeat(40); + +/** + * The routes one modeled pull request answers a reviews read on. + * + * The pull request itself is one of them: an answer is authenticated against + * the subject it claims, so a collection with no pull request behind it is + * refused rather than bound. + */ +export function reviewRoutes(endpoint: string): Record { + return { + "/repos/octo/project/pulls/4": JSON.stringify({ + number: 4, + head: { sha: HEAD }, + base: { repo: { full_name: "octo/project" } }, + }), + "/repos/octo/project/pulls/4/reviews": JSON.stringify([ + { + id: 10, + user: { login: "reviewer" }, + state: "APPROVED", + body: "looks right", + submitted_at: "2026-01-01T00:00:00Z", + commit_id: HEAD, + html_url: "https://github.test/pr/4#r10", + pull_request_url: `${endpoint}/repos/octo/project/pulls/4`, + }, + ]), + }; +} + +/** Every route the three collections are read from, each answering its own. */ +export function evidenceRoutes(endpoint: string): Record { + const subject = `${endpoint}/repos/octo/project/pulls/4`; + return { + ...reviewRoutes(endpoint), + "/repos/octo/project/issues/4/comments": JSON.stringify([ + { + id: 20, + user: { login: "watcher" }, + body: "a conversation comment", + created_at: "2026-01-01T01:00:00Z", + updated_at: "2026-01-01T01:00:00Z", + html_url: "https://github.test/pr/4#c20", + issue_url: `${endpoint}/repos/octo/project/issues/4`, + }, + ]), + "/repos/octo/project/pulls/4/comments": JSON.stringify([ + { + id: 21, + pull_request_review_id: 10, + user: { login: "reviewer" }, + body: "an inline comment", + created_at: "2026-01-01T02:00:00Z", + updated_at: "2026-01-01T02:00:00Z", + html_url: "https://github.test/pr/4#d21", + path: "packages/core/mod.ts", + diff_hunk: "@@ -1 +1 @@\n-old\n+new", + commit_id: HEAD, + original_commit_id: HEAD, + line: 12, + side: "RIGHT", + start_line: null, + start_side: null, + in_reply_to_id: null, + pull_request_url: subject, + }, + ]), + [`/repos/octo/project/commits/${HEAD}/check-runs`]: JSON.stringify({ + total_count: 1, + check_runs: [ + { + id: 30, + head_sha: HEAD, + name: "test-deno", + status: "completed", + conclusion: "failure", + html_url: "https://github.test/run/30", + started_at: "2026-01-01T03:00:00Z", + completed_at: "2026-01-01T03:10:00Z", + output: { title: "1 failed", summary: "a summary", text: null }, + }, + ], + }), + [`/repos/octo/project/commits/${HEAD}/status`]: JSON.stringify({ + sha: HEAD, + statuses: [ + { + id: 31, + context: "deploy", + state: "error", + description: "a description", + target_url: null, + created_at: "2026-01-01T04:00:00Z", + updated_at: "2026-01-01T04:00:00Z", + }, + ], + }), + }; +} + +/** The second process every exclusive-ownership case runs. */ +export const CHILD = fileURLToPath(new URL("./run-composition-child.ts", import.meta.url)); +export const TOKEN = "test-token"; + +export const REMOTE = { + commits: [ + { message: "first", entries: [{ path: "which.txt", content: "main\n" }] }, + { + message: "release", + branch: "release", + entries: [{ path: "which.txt", content: "release\n" }], + }, + ], +} as const; + +export function isManagedRefusal(value: unknown): value is ManagedCheckoutError { + return value instanceof ManagedCheckoutError; +} + +export function isAuthorityFailure(value: unknown): value is GitOperationAuthorityError { + return value instanceof GitOperationAuthorityError; +} + +export function isMissingAmbient(value: unknown): value is NoAmbientRepositoryError { + return value instanceof NoAmbientRepositoryError; +} + +/** Every entry a slot holds, sorted, so a byte-level comparison is stable. */ +export function* entriesOf(path: string): Operation { + if (!(yield* exists(path))) { + return []; + } + return [...(yield* readdir(path))].sort(); +} diff --git a/packages/workflow/tests/support/run-composition.ts b/packages/workflow/tests/support/run-composition.ts new file mode 100644 index 00000000..b6b2d5a0 --- /dev/null +++ b/packages/workflow/tests/support/run-composition.ts @@ -0,0 +1,506 @@ +/** + * The harness the ordinary-run repository suites drive. + * + * Everything here is real: a real bare remote, a real working checkout the + * command is "run in", real `git`, real advisory locks and a real managed root + * in a temporary directory. What is substituted is only what a claim needs to + * be deterministic about — the managed root, so no suite ever touches the + * user's own `~/.xmd/repositories`, and the Git subprocess where a suite counts + * invocations. + * + * There is no database, no journal and no WorkflowRun anywhere in this file. + * That is the point of the profile: an ordinary run has none of them. + */ + +import { scoped, spawn, suspend, until, withResolvers, type Operation } from "effection"; +import { ensureDir, exists, lstat, readdir, readTextFile } from "@effectionx/fs"; +import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; +import type { ComponentRegistration } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { useCompositionComponents } from "../../src/composition/installation.ts"; +import { RepositoryContext } from "../../src/composition/context.ts"; +import type { RepositorySelection } from "../../src/composition/selection.ts"; +import { denoRepositoryHost } from "../../src/deno/composition/host.ts"; +import type { GitInvocation, GitOutcome, RepositoryHost } from "../../src/deno/composition/host.ts"; +import { UNAUTHENTICATED } from "../../src/deno/composition/authentication.ts"; +import type { + GitAuthentication, + GitAuthenticationSession, +} from "../../src/deno/composition/authentication.ts"; +import type { IdentityReader } from "../../src/deno/run-composition/identity.ts"; +import type { GitHubAccess, GitHubHttpResponse } from "../../src/deno/composition/github.ts"; +import { useRunComposition } from "../../src/deno/run-composition/provider.ts"; +import type { RunCompositionOptions } from "../../src/deno/run-composition/provider.ts"; +import { + checkoutOf, + metadataOf, + repositorySlot, + worktreeSlot, +} from "../../src/deno/run-composition/placement.ts"; +import { git } from "./git-remotes.ts"; +import type { BareRemote } from "./git-remotes.ts"; + +/** A working checkout on this host, as if somebody had cloned it by hand. */ +export interface HostCheckout { + /** The canonical root of the checkout. */ + readonly root: string; + /** The home Git runs with when this fixture drives it directly. */ + readonly home: string; + /** Run a Git command in this checkout and answer what it printed. */ + run(...args: string[]): string; +} + +/** + * Clone `locator` into a directory the acquiring scope owns. + * + * Acquired in the caller's scope rather than a bounded one: the checkout is + * what the whole test runs against, and a `scoped()` around this would remove + * it before the first assertion. + */ +export function* useHostCheckout(locator: string, branch?: string): Operation { + const home = yield* useTempDirectory("xmd-run-composition-"); + const parent = yield* useTempDirectory("xmd-host-"); + const resolved = yield* until(realpath(parent)); + const root = join(resolved, "checkout"); + git(["clone", "--", locator, root], resolved, home); + if (branch !== undefined) { + git(["checkout", "-B", branch], root, home); + } + return { + root, + home, + run(...args: string[]): string { + return git(args, root, home); + }, + }; +} + +/** A Git checkout with no remote at all, made here rather than cloned. */ +export function* useOriginlessCheckout(): Operation { + const home = yield* useTempDirectory("xmd-run-composition-"); + const parent = yield* useTempDirectory("xmd-solo-"); + const resolved = yield* until(realpath(parent)); + const root = join(resolved, "checkout"); + git(["init", "--initial-branch=main", root], resolved, home); + git(["commit", "--allow-empty", "-m", "first"], root, home); + return { + root, + home, + run(...args: string[]): string { + return git(args, root, home); + }, + }; +} + +/** A managed root of this suite's own, removed when the scope ends. */ +export function* useManagedRoot(): Operation { + const created = yield* useTempDirectory("xmd-run-composition-"); + const root = join(yield* until(realpath(created)), "repositories"); + yield* ensureDir(root); + return root; +} + +/** What one ordinary execution reached, at the boundaries a claim is made at. */ +export interface OrdinaryCounters { + /** Every Git command, in order, as its argument list. */ + readonly commands: string[][]; + /** Every authentication session this host was asked to open, by locator. */ + readonly sessions: string[]; +} + +export interface CountingOrdinaryHost { + readonly host: RepositoryHost; + readonly counters: OrdinaryCounters; +} + +/** + * The production host, counted. + * + * Both leaves are wrapped rather than replaced: what a suite needs to know is + * *whether* a session was opened and *whether* a transport ran, and the only + * honest way to answer is to let the real one happen and watch. + * + * The session counter is on `useAuthentication` rather than on a separate + * `authentication` option, because that option only reaches the *default* host + * — a suite that supplies its own has already replaced the thing a session + * would be opened by. A counter installed there would never be incremented, + * and every "no session was opened" assertion made against it would pass + * without ever having been able to fail. + */ +export function countingOrdinaryHost( + inner: RepositoryHost = denoRepositoryHost(), +): CountingOrdinaryHost { + const counters: OrdinaryCounters = { commands: [], sessions: [] }; + return { + counters, + host: { + *git(invocation: GitInvocation): Operation { + counters.commands.push([...invocation.args]); + return yield* inner.git(invocation); + }, + useDirectory: inner.useDirectory, + *useAuthentication(locator: string): Operation { + counters.sessions.push(locator); + return inner.useAuthentication === undefined + ? UNAUTHENTICATED + : yield* inner.useAuthentication(locator); + }, + }, + }; +} + +/** The Git subcommands one execution issued, in order. */ +export function subcommands(counters: OrdinaryCounters): string[] { + return counters.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +} + +/** The identity this tier commits under when a case does not choose one. */ +export const TIER_COMMIT_IDENT = "Tester 0 +0000"; + +/** + * An identity reader that answers whatever a suite says this host knows. + * + * `undefined` for either variable is a host that cannot say who a commit would + * be by, which is the one condition `` refuses on. + */ +export function statedIdentity( + author: string | undefined, + committer: string | undefined = author, +): IdentityReader { + // deno-lint-ignore require-yield + return function* (variable: string): Operation { + return variable === "GIT_AUTHOR_IDENT" ? author : committer; + }; +} + +export interface RunOptions extends Omit { + /** The managed root this execution uses. */ + readonly root: string; + /** The directory the command is run in, which ambient discovery starts from. */ + readonly cwd: string; + /** Props the document is executed with. */ + readonly props?: Record; + /** Extra components this execution registers, for a suite's own probes. */ + readonly components?: readonly ComponentRegistration[]; + /** + * A Repository selection installed as the contextual one, ahead of the + * document. + * + * The only thing a document could replace, and therefore what a suite hands + * over to prove that replacing it buys nothing. + */ + readonly contextualRepository?: RepositorySelection; + /** + * Middleware installed after the provider and before the document. + * + * The nearest handler at the same depth, which is what a document's own + * composition would be: a suite uses it to answer an operation the provider + * would otherwise perform, and to prove that answering one grants nothing. + */ + readonly around?: () => Operation; +} + +/** + * Execute one document under the ordinary repository provider. + * + * The contextual working directory is installed to `cwd` first, exactly as a + * runtime entrypoint's host filesystem provider would leave it, so a document's + * root-level element is written "in" that directory. + * + * ## Who a commit is by, unless a case says + * + * A fixed identity, because the production default reads the *host's* Git + * configuration — and a machine with no `user.name` set makes `` + * refuse. Every CI runner is such a machine, so leaving it to the host turns + * "this document committed" into a claim about who ran the suite: green on a + * developer's laptop, red on every shard. A case that is *about* identity + * resolution passes its own `identity` and overrides this. + */ +export function runOrdinaryDocument(source: string, options: RunOptions): Operation { + return scoped(function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return options.cwd; + }, + }, + { at: "min" }, + ); + // What a runtime entrypoint installs beside the provider: `API.Files` has + // no host default, and a document that writes `` must reach the + // caller's own filesystem exactly as `xmd run` leaves it. + yield* useHostFiles(); + yield* useCompositionComponents(); + const { root, cwd, props: _props, components, contextualRepository, ...rest } = options; + // Spread last, so a case that supplies its own reader still wins. + yield* useRunComposition({ root, cwd, identity: statedIdentity(TIER_COMMIT_IDENT), ...rest }); + if (components !== undefined) { + yield* registerComponents([...components]); + } + if (contextualRepository !== undefined) { + yield* RepositoryContext.around({ current: () => contextualRepository }, { at: "min" }); + } + if (options.around !== undefined) { + yield* options.around(); + } + return yield* collect( + yield* execute({ + ...inlineSource(source), + stream: new InMemoryStream(), + ...(options.props === undefined ? {} : { props: options.props }), + }), + ); + }); +} + +/** What a suite reads back about one managed slot. */ +export interface ManagedSlot { + readonly slot: string; + readonly checkout: string; + readonly metadata: string; +} + +export function repositorySlotOf(root: string, locator: string, name: string): ManagedSlot { + const slot = repositorySlot(root, locator, name); + return { slot, checkout: checkoutOf(slot), metadata: metadataOf(slot) }; +} + +export function worktreeSlotOf(root: string, commonDirectory: string, name: string): ManagedSlot { + const slot = worktreeSlot(root, commonDirectory, name); + return { slot, checkout: checkoutOf(slot), metadata: metadataOf(slot) }; +} + +/** The parsed sidecar at this slot, or `undefined` when it holds none. */ +export function* readSidecar(slot: ManagedSlot): Operation { + if (!(yield* exists(slot.metadata))) { + return undefined; + } + return JSON.parse(yield* readTextFile(slot.metadata)); +} + +/** The canonical common Git directory of this checkout. */ +export function commonDirectoryOf(checkout: HostCheckout): string { + const reported = checkout.run("rev-parse", "--git-common-dir"); + const absolute = reported.startsWith("/") ? reported : join(checkout.root, reported); + // Synchronous so a test body can name a slot in an ordinary expression, the + // way it already names one from `git()`. Nothing is in flight to lose: this + // is a fixture reading its own directory before an execution exists. + // oxlint-disable-next-line local/no-sync-filesystem + return realpathSync(absolute); +} + +/** Whatever this operation raised, as a value. */ +export function* raised(operation: Operation): Operation { + try { + yield* operation; + } catch (error) { + return error; + } + throw new Error("the operation did not fail"); +} + +/** The first cause in this error's chain that `is` accepts. */ +export function causedBy(error: unknown, is: (value: unknown) => value is T): T | undefined { + let current: unknown = error; + const seen = new Set(); + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (is(current)) { + return current; + } + current = current instanceof Error ? current.cause : undefined; + } + return undefined; +} + +/** + * Everything a directory holds, as one comparable value. + * + * Paths, kinds and content digests, sorted. A refusal that claims to change + * nothing has to survive this: a comparison of "the checkout is still there" + * would pass while a file inside it had been rewritten. + */ +export function* fingerprintTree(root: string): Operation { + if (!(yield* exists(root))) { + return []; + } + const entries: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop() as string; + for (const name of yield* readdir(directory)) { + const path = `${directory}/${name}`; + const info = yield* lstat(path); + const relative = path.slice(root.length + 1); + if (info.isDirectory()) { + entries.push(`d ${relative}`); + pending.push(path); + continue; + } + if (info.isSymbolicLink()) { + entries.push(`l ${relative}`); + continue; + } + const bytes = yield* until(readFile(path)); + entries.push(`f ${relative} ${createHash("sha256").update(bytes).digest("hex")}`); + } + } + return entries.sort(); +} + +/** + * What Git says this checkout holds right now. + * + * Deliberately the mutable half — HEAD, the branch, every ref, and the working + * tree's own dirtiness — because that is what a refusal must not touch and what + * a compatible reuse must preserve. + */ +export function gitStateOf(checkout: HostCheckout, directory: string = checkout.root): string[] { + return [ + `head ${git(["rev-parse", "HEAD"], directory, checkout.home)}`, + `branch ${git(["rev-parse", "--abbrev-ref", "HEAD"], directory, checkout.home)}`, + `status ${git(["status", "--porcelain"], directory, checkout.home)}`, + `refs ${git(["for-each-ref", "--format=%(refname) %(objectname)"], directory, checkout.home)}`, + ]; +} + +/** A component that suspends forever, so a suite can halt an execution inside it. */ +export function gateComponent(reached: () => void): ComponentRegistration { + return { + name: "Gate", + origin: "test", + props: { type: "object", additionalProperties: false }, + *fn(): Operation { + reached(); + yield* suspend(); + return ""; + }, + }; +} + +/** + * Run a document that reaches ``, then halt it there. + * + * The halt is the cancellation every persistence claim is made against: the + * execution is torn down from outside, mid-document, with a component still in + * flight. + */ +export function* haltAtGate(source: string, options: RunOptions): Operation { + const opened = withResolvers(); + let reached = false; + const task = yield* spawn(() => + scoped(function* () { + yield* registerComponents([ + gateComponent(() => { + if (!reached) { + reached = true; + opened.resolve(); + } + }), + ]); + yield* runOrdinaryDocument(source, options); + }), + ); + yield* opened.operation; + yield* task.halt(); +} + +/** + * A host that answers for one locator while Git works against another. + * + * The ambient checkout records a `github.com` origin, because that is what the + * pull-request adapter parses a repository out of; native Git is handed the + * local bare repository instead, and what it prints is translated back. Exactly + * one string moves in each direction — the same substitution the workflow + * pull-request suites already run on. + */ +export function rewritingHost( + named: string, + actual: string, + inner: RepositoryHost = denoRepositoryHost(), +): RepositoryHost { + return { + *git(invocation: GitInvocation): Operation { + const outcome = yield* inner.git({ + ...invocation, + args: invocation.args.map((argument) => (argument === named ? actual : argument)), + }); + return { ...outcome, stdout: outcome.stdout.split(actual).join(named) }; + }, + useDirectory: inner.useDirectory, + }; +} + +/** A checkout of `remote` that records `named` as its origin. */ +export function* useNamedOriginCheckout( + remote: BareRemote, + named: string, +): Operation { + const checkout = yield* useHostCheckout(remote.locator); + checkout.run("remote", "set-url", "origin", named); + return checkout; +} + +/** One request a recording access received. */ +export interface RecordedRequest { + readonly method: string; + readonly url: string; + readonly authorized: boolean; +} + +export interface RecordingAccess { + readonly access: GitHubAccess; + readonly requests: RecordedRequest[]; + /** How many times this access was asked for a credential. */ + readonly credentials: number; +} + +/** + * A GitHub access that answers a fixed route table and records every request. + * + * Enough to say whether the transport was reached and with what, which is the + * whole of what an ordinary-profile read has to prove: what a body normalizes + * to belongs to the shared adapter's own suite. + */ +export function recordingAccess( + bodies: Readonly>, + endpoint = "https://api.github.test", + token: string | undefined = "test-token", +): RecordingAccess { + const requests: RecordedRequest[] = []; + let credentials = 0; + return { + requests, + get credentials(): number { + return credentials; + }, + access: { + endpoint, + // deno-lint-ignore require-yield + *token(): Operation { + credentials += 1; + return token; + }, + // deno-lint-ignore require-yield + *send(request): Operation { + const path = new URL(request.url).pathname; + requests.push({ + method: request.method, + url: request.url, + authorized: request.headers?.Authorization !== undefined, + }); + const body = bodies[path]; + return body === undefined ? { status: 404, body: "{}" } : { status: 200, body }; + }, + }, + }; +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 69c1e50e..1f0f4313 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -558,6 +558,35 @@ const COMPILED_BINARY: RuntimeExclusion[] = [ * unflagged and Bun has not, so folding these into the shared list would drop * coverage Node is currently giving. */ +/** + * Tests whose subject is the ordinary repository provider. + * + * It holds a managed checkout with a kernel-released exclusive advisory lock, + * which this repository reaches through the Deno runtime and which Node and Bun + * expose no equivalent of. The provider is not reachable from any entrypoint + * yet — these suites install it themselves through the trusted test installer. + */ +const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ + { + path: "packages/workflow/tests/run-composition-ambient.test.ts", + reason: + "the subject is the ordinary run's repository provider, installed directly by the suite; it discovers an ambient repository and writes to a real checkout through the Deno runtime", + issue: DERIVED_SCOPE, + }, + { + path: "packages/workflow/tests/run-composition-managed.test.ts", + reason: + "the same provider, holding managed checkouts under a kernel-released exclusive advisory lock taken through the Deno runtime; Node and Bun expose no equivalent", + issue: DERIVED_SCOPE, + }, + { + path: "packages/workflow/tests/run-composition-remote.test.ts", + reason: + "the same provider, publishing and reconciling against a modeled Git host; the transport and its evidence are Deno-only for the same reason the rest of the provider is", + issue: DERIVED_SCOPE, + }, +]; + const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ { path: "packages/workflow/tests/xmd-artifact.test.ts", @@ -569,6 +598,11 @@ const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ export const exclusions: Record = { deno: COMPILED_BINARY, - node: [...DENO_ONLY_TOOLING, ...COMPILED_BINARY], - bun: [...DENO_ONLY_TOOLING, ...COMPILED_BINARY, ...BUN_MISSING_NODE_SQLITE], + node: [...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, ...COMPILED_BINARY], + bun: [ + ...DENO_ONLY_TOOLING, + ...DENO_ONLY_REPOSITORY_PROVIDER, + ...COMPILED_BINARY, + ...BUN_MISSING_NODE_SQLITE, + ], };