diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 76809d287..42ddd1d70 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,7 +11,7 @@ "ignorePatterns": [ "**/*.md", "scripts/tests/fixtures/**", - "packages/git/tests/fixtures/retained-repository-history/**", + "packages/cli/tests/fixtures/retained-repository-history/**", "**/npm/**", "packages/workflow/vendor/cloudflare-computer-dofs/**", "packages/acp/vendor/acpx/**", diff --git a/architecture.md b/architecture.md index e91b2e364..30d69dd44 100644 --- a/architecture.md +++ b/architecture.md @@ -5248,10 +5248,20 @@ A **Plugin** is trusted code installed before an execution imports a root document. XMD bundles exactly one, `@executablemd/git`, and an operator adds more with a repeatable `--plugin `. The bundled value is statically imported rather than resolved, so a distribution carries it whole and nothing can -substitute it; it is activated for `run`, `plan`, `syntax` and the workflow -actions that execute a document — `start`, `resume` and `fork` — and for nothing -else. `xmd test`, `upgrade` and a workflow management action carry no Plugin -they did not ask for. +substitute it; it is activated for `run`, `plan`, `syntax`, `repl` and the +workflow actions that execute a document — `start`, `resume` and `fork` — and +for nothing else. `xmd test`, `upgrade` and a workflow management action carry +no Plugin they did not ask for. + +`repl` is the one command that assembles the bundled value rather than installing +it directly. Git contributes the vocabulary and its two domain admissions; what +an entry wrote down about the checkout it was standing in is that command's own +record, so the admission that reads it back is contributed by the command, from +inside the same contribution — because an admission is applied within each +execution's own journal read and cannot be attached from outside one. The +assembly keeps the bundled Plugin's name, its identity-based deduplication and +its place in an operator's own Plugin order, and every other command is +delegated untouched. `git` is a reserved *host selector*, not a package name: writing `--plugin git` names the value the profile already holds, resolves without loading a module, @@ -5397,15 +5407,17 @@ A host that retains nothing named after an invocation runs the same bodies over the public feature operations, with no identity authenticated, because there is nothing for one to name. Only an operation a host retains *above* the public chain, named after an invocation it authenticates, takes its operation as an -argument, and that is `Git.Push` and `Issue`. A workflow run retains -`PullRequest` and its three evidence reads too — the upsert through the Git-host -reconciliation beneath it, each read as policy on `PullRequestReadExecution` — -but it installs that retention under their feature Api instead of owning a -boundary over it, and no REPL entry owns one either, so those four ask their Api -directly as any other component does. A REPL entry retains the four Git reads -the same way — `GitQuery` middleware at its own implementation slot — which is -what lets those four stay ordinary registrations under a host that retains every -page they answer with. That is what makes an ordinary run, a workflow run, a +argument, and that is `Git.Push`, `Issue`, `PullRequest` and its three evidence +reads. A workflow run retains `PullRequest` and those reads too — the upsert +through the Git-host reconciliation beneath it, each read as policy on +`PullRequestReadExecution` — but it installs that retention under their feature +Api instead of owning a boundary over it, so under a workflow run those four ask +their Api directly as any other component does. A REPL entry does own a boundary +over all six, because each of its records is named after the occurrence that +asked for it. A REPL entry retains the four Git reads +differently — `GitQuery` middleware at its own implementation slot — which is +what lets those four stay ordinary registrations under a host that retains +every page they answer with. That is what makes an ordinary run, a workflow run, a provider-absent runtime and a REPL entry four policies rather than four components. @@ -7298,13 +7310,15 @@ persistence failure with its first cause: no success binding is offered, no later sibling runs as though the commit succeeded, and no compensating Close is appended. -A pull request is not here: `` stays declared where it already is -and refuses through its own unchanged missing-provider default, and a local -result confers no publication evidence. Publication itself is the next -subsection's subject, and `` the section's after it — the first because -it belongs to a checkout and to a remote at once, the second because it belongs -to no checkout at all. Nothing inventories the managed root, snapshots a -checkout's contents or restores one. +A pull request is here too, with its own protocol: `` is retained +as a saved request and a Git-host outcome at two positions, and a local result +confers no publication evidence for it. What admits one is this entry's own last +acknowledged publication of the branch it names, which the subsection after next +is about. Its three evidence reads retain the provider's snapshot and nothing +above it. Publication itself is the next subsection's subject, and `` the +section's after it — the first because it belongs to a checkout and to a remote +at once, the second because it belongs to no checkout at all. Nothing +inventories the managed root, snapshots a checkout's contents or restores one. #### What one entry asked to publish, and what the Git host did about it diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index 660c74470..b64c4eeb2 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -29,7 +29,9 @@ import type { Plugin, PluginInstallRequest, PluginInstallation } from "@executab import type { Operation } from "effection"; import { cwd } from "@executablemd/runtime"; import { gitPlugin } from "@executablemd/git"; -import { replRepositoryInstallation, useRunComposition } from "@executablemd/git/deno"; +import { useRunComposition } from "@executablemd/git/deno"; +import { replRepositoryInstallation } from "./repl/repositories/deno/provider.ts"; +import { replRepositoryPlugin } from "./repl/repositories/plugin.ts"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts"; import { ReplEntryHost } from "./repl-entry-installation.ts"; @@ -62,6 +64,10 @@ export function denoRunRepositories( // selection the command already made. The caller's own object is neither // mutated nor frozen — this is a copy, and the copy is what is used. const configured = snapshot(helper); + // The same CLI assembly the default selection uses, constructed once here, so + // a `repl` request reaches this command's entry-history admission through + // this configured variant as well. + const bundled = replRepositoryPlugin(gitPlugin); return { // The bundled name, because this *is* the bundled Plugin: an operator // selecting `--plugin git` is naming this value, and two values under one @@ -72,8 +78,8 @@ export function denoRunRepositories( // bundled Plugin rather than reimplementing it, so the vocabulary, the // commands it declares for and the admissions it contributes are the ones // that package decides. - const install = gitPlugin.install; - const declared = install === undefined ? undefined : yield* install(request); + const install = bundled.install; + const declared = install === undefined ? undefined : yield* install.call(bundled, request); if (declared === undefined) { return undefined; } diff --git a/packages/git/src/retained-repository/admission.ts b/packages/cli/src/repl/repositories/admission.ts similarity index 100% rename from packages/git/src/retained-repository/admission.ts rename to packages/cli/src/repl/repositories/admission.ts diff --git a/packages/git/src/deno/retained-repository/files.ts b/packages/cli/src/repl/repositories/deno/files.ts similarity index 99% rename from packages/git/src/deno/retained-repository/files.ts rename to packages/cli/src/repl/repositories/deno/files.ts index 74a7a033d..95fced6d2 100644 --- a/packages/git/src/deno/retained-repository/files.ts +++ b/packages/cli/src/repl/repositories/deno/files.ts @@ -74,7 +74,7 @@ import type { ReplLocalOperation, ReplLocalRequest, ReplLocalValue, -} from "../../retained-repository/records.ts"; +} from "../records.ts"; /** * One managed checkout this entry selected, as file routing needs it. diff --git a/packages/git/src/deno/retained-repository/git.ts b/packages/cli/src/repl/repositories/deno/git.ts similarity index 92% rename from packages/git/src/deno/retained-repository/git.ts rename to packages/cli/src/repl/repositories/deno/git.ts index 82fa79928..12159d1f4 100644 --- a/packages/git/src/deno/retained-repository/git.ts +++ b/packages/cli/src/repl/repositories/deno/git.ts @@ -67,22 +67,14 @@ import type { GitApi, GitCommitInvocation, GitSwitchInvocation, -} from "../../composition/git-api.ts"; -import { GitOperationAdmissionError } from "../../composition/errors.ts"; -import type { - GitAddResult, - GitCommitResult, - GitSwitchResult, -} from "../../composition/git-records.ts"; -import { ADD, admitPathspecs } from "../../composition/components/GitAdd.ts"; -import { - admitCommitMessage, - admitMessageSource, - COMMIT, -} from "../../composition/components/GitCommit.ts"; -import { SWITCH } from "../../composition/components/GitSwitch.ts"; -import { sameRepositoryIdentity } from "../../composition/selection.ts"; -import type { RepositorySelection } from "../../composition/selection.ts"; +} from "@executablemd/git/api"; +import { GitOperationAdmissionError } from "@executablemd/git"; +import type { GitAddResult, GitCommitResult, GitSwitchResult } from "@executablemd/git"; +import { ADD, admitPathspecs } from "@executablemd/git"; +import { admitCommitMessage, admitMessageSource, COMMIT } from "@executablemd/git"; +import { SWITCH } from "@executablemd/git"; +import { sameRepositoryIdentity } from "@executablemd/git"; +import type { RepositorySelection } from "@executablemd/git/api"; import { parseReplGitRecord, replGitFingerprint, @@ -91,7 +83,7 @@ import { replGitRefusal, ReplGitRefusal, REPL_REPOSITORY_GIT, -} from "../../retained-repository/git-records.ts"; +} from "../git-records.ts"; import type { ReplGitAddRequest, ReplGitCommitRequest, @@ -102,24 +94,16 @@ import type { ReplGitRequest, ReplGitSwitchRequest, ReplGitValue, -} from "../../retained-repository/git-records.ts"; -import { gitCommitMessageEvidence } from "../composition/commit.ts"; -import { GitRefusal } from "../composition/git.ts"; -import type { GitSession } from "../composition/git.ts"; -import { CompositionRefusal, refusalReason, restoredGitRefusal } from "../composition/refusals.ts"; -import type { GitCommitIdentity } from "../run-composition/identity.ts"; -import { UnresolvedGitIdentityError } from "../run-composition/errors.ts"; -import { - liveAdd, - liveCheckout, - liveCommit, - liveSwitch, - selectCheckout, - type GitCheckout, - type RegisteredCheckout, -} from "../run-composition/operations.ts"; -import type { SlotKind } from "../run-composition/leases.ts"; -import type { SelectionRegistry } from "../selections.ts"; +} from "../git-records.ts"; +import { gitCommitMessageEvidence } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { GitRefusal } from "@executablemd/git/deno"; +import { CompositionRefusal, refusalReason, restoredGitRefusal } from "@executablemd/git/deno"; +import type { GitCommitIdentity } from "@executablemd/git/deno"; +import { UnresolvedGitIdentityError } from "@executablemd/git/deno"; +import { selectCheckout, type RegisteredCheckout } from "@executablemd/git/deno"; +import type { SlotKind } from "@executablemd/git/deno"; +import type { SelectionRegistry } from "@executablemd/git/deno"; import { holdManagedPlacement } from "./leases.ts"; import { retainedRecordAhead } from "./local.ts"; import { nativeGitTurn, placementSubject } from "./selections.ts"; @@ -152,8 +136,8 @@ export interface ReplGitCheckout { /** What recorded local Git needs from the entry that owns it. */ export interface ReplGitEntry { - /** This entry's Git session, acquired once into the entry's own scope. */ - git(): Operation; + /** The native repository work this entry asks for, credential-free. */ + readonly operations: DenoRepositoryOperations; /** * Who an ordinary commit in this entry is by, or `undefined` when this host * cannot say. @@ -216,7 +200,9 @@ function* recordedAdd(entry: ReplGitEntry, invocation: GitAddInvocation): Operat ...place(entry, ADD, invocation), paths, }); - const value = yield* recorded(entry, ADD, request, (checkout) => liveAdd(checkout, paths)); + const value = yield* recorded(entry, ADD, request, (checkout, workingDirectory) => + entry.operations.add(checkout, workingDirectory, paths), + ); if (!("paths" in value)) { throw mismatch(ADD); } @@ -240,8 +226,8 @@ function* recordedSwitch( branch, base: base ?? null, }); - const value = yield* recorded(entry, SWITCH, request, (checkout) => - liveSwitch(checkout, branch, base), + const value = yield* recorded(entry, SWITCH, request, (checkout, workingDirectory) => + entry.operations.switch(checkout, workingDirectory, branch, base), ); if (!("requestedBranch" in value)) { throw mismatch(SWITCH); @@ -263,11 +249,17 @@ function* recordedCommit( messageDigest: evidence.digest, messageLength: evidence.length, }); - const value = yield* recorded(entry, COMMIT, request, function* (checkout) { + const value = yield* recorded(entry, COMMIT, request, function* (checkout, workingDirectory) { // Read here, because this is the operation that needs it: an entry that // commits nothing asks the host nothing about who it is, and a completed // Commit never reaches this callback at all. - return yield* liveCommit(checkout, message, messageSource, yield* entry.identity()); + return yield* entry.operations.commit( + checkout, + workingDirectory, + message, + messageSource, + yield* entry.identity(), + ); }); if (!("commit" in value)) { throw mismatch(COMMIT); @@ -320,7 +312,7 @@ function* recorded( entry: ReplGitEntry, element: string, request: ReplGitRequest, - perform: (checkout: GitCheckout) => Operation, + perform: (checkout: RegisteredCheckout, workingDirectory: string) => Operation, ): Operation { const fingerprint = yield* replGitFingerprint(request); const name = replGitRecordName({ operation: request.operation, fingerprint }); @@ -342,9 +334,7 @@ function* recorded( const target = yield* routing(); yield* own(entry, target); const outcome = yield* attempted(request.operation, function* () { - return yield* perform( - liveCheckout(yield* entry.git(), target.checkout, request.workingDirectory), - ); + return yield* perform(target.checkout, request.workingDirectory); }); const payload = replGitRecordJson({ fingerprint, request, outcome }); // Read back before the coordinator publishes it. A payload this build diff --git a/packages/git/src/deno/retained-repository/issues.ts b/packages/cli/src/repl/repositories/deno/issues.ts similarity index 90% rename from packages/git/src/deno/retained-repository/issues.ts rename to packages/cli/src/repl/repositories/deno/issues.ts index 2f3464fbd..f9fa274cf 100644 --- a/packages/git/src/deno/retained-repository/issues.ts +++ b/packages/cli/src/repl/repositories/deno/issues.ts @@ -21,32 +21,31 @@ import type { ExecutionInstallation, JournalAdmission } from "@executablemd/core import type { DurableEvent } from "@executablemd/durable-streams"; import { StaleInputError } from "@executablemd/durable-streams"; import type { Json } from "@executablemd/durable-streams"; -import { issueDeclaration } from "../../composition/components/Issue.ts"; -import type { BindOperations } from "../../composition/invocation-binding.ts"; -import { IssueOperations } from "../../issue/operations.ts"; +import { issueDeclaration } from "@executablemd/git"; +import type { BindOperations } from "@executablemd/git"; +import { IssueOperations } from "@executablemd/git/api"; import type { IssueOperationsApi, IssueReadInvocation, IssueUpsertInvocation, -} from "../../issue/operations.ts"; -import { ISSUE_EFFECT } from "../../issue/effect-type.ts"; +} from "@executablemd/git/api"; +import { ISSUE_EFFECT } from "@executablemd/git"; import { issueRequestFingerprint, parseIssueDetails, parseIssueRecord, parseIssueRequest, -} from "../../issue/records.ts"; -import type { IssueRequest } from "../../issue/records.ts"; +} from "@executablemd/git"; +import type { IssueRequest } from "@executablemd/git"; import { liveIssueRead, liveIssueUpsert, ownedUpsertKey, readOwnedIssue, upsertOwnedIssue, -} from "../../issue/owned.ts"; -import { useGitHubIssues } from "../issue/github.ts"; -import type { GitHubIssuesOptions } from "../issue/github.ts"; -import { denoGitHubSource } from "../composition/github-host.ts"; +} from "@executablemd/git"; +import { useDenoGitHubIssues } from "@executablemd/git/deno"; +import type { GitHubIssuesOptions } from "@executablemd/git/deno"; import type { Around } from "@effectionx/context-api"; /** Why one entry's retained issue history cannot be continued. */ @@ -101,14 +100,15 @@ export function replIssueInstallation( } /** - * The platform's transport, handed to an adapter that knows the protocol and - * not the platform. + * The platform's transport, installed without this entry naming its source. * - * Naming it is not building one: it is a factory the adapter calls if and when - * a request turns out to be its own. + * Naming it is not building one: the adapter opens a session if and when a + * request turns out to be its own. Which concrete transport that is, and the + * credential behind it, stay inside the domain — this entry states only what a + * caller overrode, and overrides nothing itself. */ export function useEntryIssueTransport(options: GitHubIssuesOptions): Operation { - return useGitHubIssues({ host: denoGitHubSource, ...options }); + return useDenoGitHubIssues(options); } /** diff --git a/packages/git/src/deno/retained-repository/leases.ts b/packages/cli/src/repl/repositories/deno/leases.ts similarity index 95% rename from packages/git/src/deno/retained-repository/leases.ts rename to packages/cli/src/repl/repositories/deno/leases.ts index 47f9af069..b22c18d60 100644 --- a/packages/git/src/deno/retained-repository/leases.ts +++ b/packages/cli/src/repl/repositories/deno/leases.ts @@ -21,9 +21,9 @@ import type { Operation } from "effection"; import { StaleInputError } from "@executablemd/durable-streams"; import { useAdvisoryLock } from "@executablemd/workflow/deno"; -import { commonDirectoryLock } from "../run-composition/checkout-layout.ts"; -import { ManagedCheckoutError } from "../run-composition/errors.ts"; -import type { ManagedCheckoutReason } from "../run-composition/errors.ts"; +import { commonDirectoryLock } from "@executablemd/git/deno"; +import { ManagedCheckoutError } from "@executablemd/git/deno"; +import type { ManagedCheckoutReason } from "@executablemd/git/deno"; /** * A checkout another live entry or another process is already working in. diff --git a/packages/git/src/deno/retained-repository/local.ts b/packages/cli/src/repl/repositories/deno/local.ts similarity index 99% rename from packages/git/src/deno/retained-repository/local.ts rename to packages/cli/src/repl/repositories/deno/local.ts index 46c6881ca..581ad2bbe 100644 --- a/packages/git/src/deno/retained-repository/local.ts +++ b/packages/cli/src/repl/repositories/deno/local.ts @@ -53,13 +53,13 @@ import { REPL_REPOSITORY_LOCAL, REPL_REPOSITORY_SELECTION, retainableRefusal, -} from "../../retained-repository/records.ts"; +} from "../records.ts"; import type { ReplAmbientCheckout, ReplLocalOperation, ReplLocalRequest, ReplLocalValue, -} from "../../retained-repository/records.ts"; +} from "../records.ts"; /** * A saved record standing where this operation belongs that it cannot read. diff --git a/packages/git/src/deno/retained-repository/observations.ts b/packages/cli/src/repl/repositories/deno/observations.ts similarity index 94% rename from packages/git/src/deno/retained-repository/observations.ts rename to packages/cli/src/repl/repositories/deno/observations.ts index 2b765b9ea..8605a8b59 100644 --- a/packages/git/src/deno/retained-repository/observations.ts +++ b/packages/cli/src/repl/repositories/deno/observations.ts @@ -51,14 +51,14 @@ import type { Result as ProtocolResult, } from "@executablemd/durable-streams"; import type { Around } from "@effectionx/context-api"; -import { GitObservationError, GitOperationAdmissionError } from "../../composition/errors.ts"; -import type { GitQueryApi } from "../../git.ts"; +import { GitObservationError, GitOperationAdmissionError } from "@executablemd/git"; +import type { GitQueryApi } from "@executablemd/git/api"; import { admitChangesInvocation, admitDiffInvocation, admitFileInvocation, admitFilesInvocation, -} from "../../composition/observation-api.ts"; +} from "@executablemd/git"; import type { GitChangesInvocation, GitChangesResult, @@ -70,15 +70,10 @@ import type { GitFilesResult, GitObservationRequest, GitObservationValue, -} from "../../composition/observation-api.ts"; -import { - CHANGES_ELEMENT, - DIFF_ELEMENT, - FILE_ELEMENT, - FILES_ELEMENT, -} from "../../composition/components/GitReads.ts"; -import { sameRepositoryIdentity } from "../../composition/selection.ts"; -import type { RepositorySelection } from "../../composition/selection.ts"; +} from "@executablemd/git/api"; +import { CHANGES_ELEMENT, DIFF_ELEMENT, FILE_ELEMENT, FILES_ELEMENT } from "@executablemd/git"; +import { sameRepositoryIdentity } from "@executablemd/git"; +import type { RepositorySelection } from "@executablemd/git/api"; import { elementOf, parseReplObservationRecord, @@ -88,19 +83,18 @@ import { replObservationRefusalOf, ReplObservationRefusal, REPL_REPOSITORY_OBSERVATION, -} from "../../retained-repository/observation-records.ts"; +} from "../observation-records.ts"; import type { ReplObservationCheckout, ReplObservationRecord, ReplObservationRequest, -} from "../../retained-repository/observation-records.ts"; -import { performGitObservation } from "../composition/observations.ts"; -import type { ObservationPlace } from "../composition/observations.ts"; -import type { RepositoryHost } from "../composition/host.ts"; -import { selectCheckout } from "../run-composition/operations.ts"; +} from "../observation-records.ts"; +import type { ObservationPlace } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { selectCheckout } from "@executablemd/git/deno"; import { nativeGitTurn } from "./selections.ts"; -import type { SlotKind } from "../run-composition/leases.ts"; -import type { SelectionRegistry } from "../selections.ts"; +import type { SlotKind } from "@executablemd/git/deno"; +import type { SelectionRegistry } from "@executablemd/git/deno"; import { holdManagedPlacement } from "./leases.ts"; import { retainedRecordAhead } from "./local.ts"; import { placementSubject } from "./selections.ts"; @@ -120,8 +114,8 @@ export class ReplObservationRecordError extends StaleInputError { /** What recorded local Git reads need from the entry that owns them. */ export interface ReplObservationEntry { - /** The native Git host one contained observation performs through. */ - readonly host: RepositoryHost; + /** The native repository work this entry asks for, credential-free. */ + readonly operations: DenoRepositoryOperations; /** Hold a managed slot for the entry, single-flight per root and slot. */ holdSlot(root: string, kind: SlotKind, path: string, subject: string): Operation; /** Run live native Git for one shared Git directory in order within this entry. */ @@ -261,8 +255,7 @@ function* recorded( yield* own(entry, target); const checkout = describing(target); const outcome = yield* attempted(element, function* () { - return yield* performGitObservation( - entry.host, + return yield* entry.operations.observe( element, invocation.request, placeOf(request, target), diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/cli/src/repl/repositories/deno/provider.ts similarity index 82% rename from packages/git/src/deno/retained-repository/provider.ts rename to packages/cli/src/repl/repositories/deno/provider.ts index 4e21f6351..77a04e850 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/cli/src/repl/repositories/deno/provider.ts @@ -26,19 +26,19 @@ * ``, and, for an entry whose capability was built per entry below, * the publication an authored `` asks for. * - * A pull request is not here: it has its own protocol and its own evidence, and - * a local result and a publication authorize neither. `` and its - * three evidence reads are declared with the ordinary policy beneath them, so - * they refuse through their own unchanged missing-provider default — and what - * is installed here is not the ordinary all-feature provider, which would - * enable them. + * It also records what that entry asked a Git host for: the pull request an + * authored `` opens or brings up to date, and the snapshot each of + * its three evidence reads was answered with. Those have their own protocol and + * their own admission — a local result and a publication authorize neither, and + * a pull request is admitted by this entry's own acknowledged publication of + * the branch it names. * * ## Installing it reaches nothing * * No directory, no lease, no `git`, no filesystem read, no commit identity, no - * managed root, no credential and no remote. The ambient checkout is discovered by the first mutation that - * needs to know whether it is inside one, and an entry that mutates nothing - * never looks. The managed root is created by the first selection, and an entry + * managed root, no configuration, no credential and no remote. The ambient + * checkout is discovered by the first mutation that needs to know whether it is + * inside one, and an entry that mutates nothing never looks. The managed root is created by the first selection, and an entry * that selects nothing never makes one. The invoking person's author and * committer are read by the first live ``, and an entry that * commits nothing — or replays a completed commit — asks the host nothing about @@ -52,27 +52,23 @@ import type { Operation, Scope } from "effection"; import { realpath } from "node:fs/promises"; import { ensureDir } from "@effectionx/fs"; import { cwd, Files } from "@executablemd/runtime"; -import { denoRepositoryHost } from "../composition/host.ts"; -import type { RepositoryHost } from "../composition/host.ts"; -import { gitSession } from "../composition/git.ts"; -import type { GitSession } from "../composition/git.ts"; -import type { HelperAssembly } from "../composition/credential-helper.ts"; -import type { GitAuthentication } from "../composition/authentication.ts"; -import { Repository } from "../../composition/api.ts"; -import type { RepositoryRequest, WorktreeRequest } from "../../composition/api.ts"; -import type { RepositorySelection } from "../../composition/selection.ts"; -import { Git } from "../../composition/git-api.ts"; -import { discoverAmbientCheckout, discoverAmbientRepository } from "../run-composition/ambient.ts"; -import type { AmbientRepository } from "../run-composition/ambient.ts"; -import { captureCommitIdentity, denoIdentityReader } from "../run-composition/identity.ts"; -import type { GitCommitIdentity, IdentityReader } from "../run-composition/identity.ts"; -import { lazily } from "../run-composition/lazy.ts"; -import { useLeases } from "../run-composition/leases.ts"; -import type { Leases, SlotKind } from "../run-composition/leases.ts"; -import type { RegisteredCheckout } from "../run-composition/operations.ts"; -import { selectionRegistry } from "../selections.ts"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; +import { denoPullRequestOperations, useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoPullRequestOperations, DenoRepositoryOperations } from "@executablemd/git/deno"; +import { Repository } from "@executablemd/git/api"; +import type { RepositoryRequest, WorktreeRequest } from "@executablemd/git/api"; +import type { RepositorySelection } from "@executablemd/git/api"; +import { Git } from "@executablemd/git/api"; +import type { AmbientRepository } from "@executablemd/git/deno"; +import { captureCommitIdentity, denoIdentityReader } from "@executablemd/git/deno"; +import type { GitCommitIdentity, IdentityReader } from "@executablemd/git/deno"; +import { lazily } from "@executablemd/git/deno"; +import { useLeases } from "@executablemd/git/deno"; +import type { Leases, SlotKind } from "@executablemd/git/deno"; +import type { RegisteredCheckout } from "@executablemd/git/deno"; +import { selectionRegistry } from "@executablemd/git/deno"; import { holdManagedPlacement, useCommonDirectoryLease } from "./leases.ts"; -import type { ReplAmbientCheckout } from "../../retained-repository/records.ts"; +import type { ReplAmbientCheckout } from "../records.ts"; import { currentCoroutine, selectedCheckout } from "./local.ts"; import { replFileMiddleware } from "./files.ts"; import type { ReplLocalRecorder, ReplManagedPlacement } from "./files.ts"; @@ -80,12 +76,17 @@ import { replGitMiddleware } from "./git.ts"; import type { ReplGitCheckout, ReplGitEntry } from "./git.ts"; import { replObservationMiddleware } from "./observations.ts"; import type { ReplObservationEntry } from "./observations.ts"; -import { GitQuery } from "../../git.ts"; +import { GitQuery } from "@executablemd/git/api"; import { replPushAdmission, replPushPolicy } from "./push.ts"; import type { ReplPushEntry } from "./push.ts"; import { replIssueAdmission, replIssuePolicy, useEntryIssueTransport } from "./issues.ts"; -import { pushDeclaration } from "../../composition/components/GitPush.ts"; -import { issueDeclaration } from "../../composition/components/Issue.ts"; +import { replPullRequestPolicy } from "./pull-request.ts"; +import { replPullRequestReadAdmission, replPullRequestReadPolicy } from "./pull-request-reads.ts"; +import { pushDeclaration } from "@executablemd/git"; +import { issueDeclaration } from "@executablemd/git"; +import { pullRequestDeclaration } from "@executablemd/git"; +import { pullRequestReadDeclaration } from "@executablemd/git"; +import { PULL_REQUEST_READ_KINDS } from "@executablemd/git"; import { Execution } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; import type { IdentityComponent, JournalAdmission } from "@executablemd/core/host"; @@ -101,12 +102,15 @@ import type { ReplPlacementRegistration, ReplSelectionEntry } from "./selections /** What a caller may state about one entry's recording. */ export interface ReplRecordingOptions { /** - * The host native Git runs through. + * How this entry performs native repository work. * - * Production states none and gets this platform's. A suite states one so it - * can drive this exact assembly without a `git` subprocess. + * Production states none and gets the platform's own, built from the helper + * below. A suite states one so it can count, hold or refuse the twelve + * credential-free methods without a `git` subprocess — and without ever + * holding a session, an attachment or a credential, none of which crosses + * this boundary any more. */ - readonly host?: RepositoryHost; + readonly operations?: () => Operation; /** * Where managed checkouts live. * @@ -118,8 +122,6 @@ export interface ReplRecordingOptions { readonly root?: string; /** How this host writes and starts its own credential helper for a clone. */ readonly helper?: HelperAssembly; - /** What this host lends a clone that needs to prove who it is. */ - readonly authentication?: GitAuthentication; /** * How this host reads the invoking person's effective Git identity. * @@ -201,22 +203,45 @@ export function* replRepositoryInstallation( }, }); - // The two names this entry declares, and only those. Every name this + // This entry's own pull-request transport state, built once and inert: naming + // it reads no configuration, opens no session and acquires nothing. Its + // configuration is the existing one this deployment already states, which is + // why no recording option carries it. + const pullRequests: DenoPullRequestOperations = denoPullRequestOperations(); + const access = () => (usable ? held.entry : undefined); + + // The six names this entry declares, and only those. Every name this // vocabulary has is already registered in the command's scope; what an entry - // adds here is the policy it owns, attached to those same two bodies from + // adds here is the policy it owns, attached to those same shared bodies from // inside its own execution. // // Retaining an operation is not by itself a reason to declare its name. This // entry retains the pages its four Git reads answer with and the file work // beneath them too, and both are middleware under the component rather than a // policy named after an invocation — so they keep the registered default and - // appear nowhere here. These two need the invocation: a durable boundary that - // has to enclose the public chain cannot be installed inside it. + // appear nowhere here. These six need the invocation: a durable boundary that + // has to enclose the public chain cannot be installed inside it, and each + // evidence read is named after the occurrence that asked for it. const components: readonly IdentityComponent[] = [ - pushDeclaration(replPushPolicy(owner, () => (usable ? held.entry : undefined))), + pushDeclaration(replPushPolicy(owner, access)), issueDeclaration(replIssuePolicy(owner)), + pullRequestDeclaration(replPullRequestPolicy(owner, access, () => pullRequests)), + ...PULL_REQUEST_READ_KINDS.map((kind) => + pullRequestReadDeclaration( + kind, + replPullRequestReadPolicy(owner, () => pullRequests), + ), + ), + ]; + // The publication walk admits this entry's saved pull-request requests and + // their outcomes beside its Push ones, so it is not asked twice. The read + // admission is its own question: a retained read is named by a question + // rather than paired with an outcome. + const admissions: JournalAdmission[] = [ + replIssueAdmission(owner), + replPushAdmission(owner), + replPullRequestReadAdmission(owner), ]; - const admissions: JournalAdmission[] = [replIssueAdmission(owner), replPushAdmission(owner)]; return { components, admissions, @@ -245,12 +270,6 @@ function* initializeReplRepository( options: ReplRecordingOptions, deliver?: (entry: ReplPushEntry) => void, ): Operation { - const host = - options.host ?? - denoRepositoryHost({ - ...(options.authentication === undefined ? {} : { authentication: options.authentication }), - ...(options.helper === undefined ? {} : { helper: options.helper }), - }); const directory = yield* cwd(); const configuredRoot = options.root; @@ -261,12 +280,16 @@ function* initializeReplRepository( // The Git session's root is also `HOME`, so Git reads no configuration // belonging to whoever is running the command. - const useGit = lazily(entry, function* (): Operation { - return gitSession(host, yield* host.useDirectory()); - }); + // The native work this entry asks for, built once in the entry's own scope. + // Building it acquires nothing: no directory, no lock, no Git child, no + // identity, no configuration and no credential. Whatever it lazily acquires + // belongs to this scope, which is the execution that owns the entry. + const operations = yield* ( + options.operations ?? (() => useDenoRepositoryOperations(options.helper)) + )(); const discover = lazily(entry, function* (): Operation { - return yield* discoverAmbientCheckout(yield* useGit(), directory); + return yield* operations.ambientCheckout(directory); }); // One lazy acquisition per shared Git directory, so the first mutation takes @@ -381,7 +404,7 @@ function* initializeReplRepository( const useAmbientRepository = lazily(entry, function* (): Operation< AmbientRepository | undefined > { - return yield* discoverAmbientRepository(yield* useGit(), directory); + return yield* operations.ambientRepository(directory); }); // One lazy acquisition per managed slot, so a slot taken by a live selection @@ -412,8 +435,7 @@ function* initializeReplRepository( const serialize = slotTurns(); const selectionEntry: ReplSelectionEntry = { - host, - git: useGit, + operations, root: useRoot, ambient: useAmbientRepository, holdSlot, @@ -496,7 +518,7 @@ function* initializeReplRepository( }); const gitEntry: ReplGitEntry = { - git: useGit, + operations, identity: useIdentity, holdSlot, holdCommon: (commonDirectory: string) => holdCommon(commonDirectory), @@ -535,7 +557,7 @@ function* initializeReplRepository( // native turn, and take no mutation lease: a read changes nothing, so there // is nothing for one to protect. const observationEntry: ReplObservationEntry = { - host, + operations, holdSlot, serialize, selections, @@ -549,8 +571,7 @@ function* initializeReplRepository( // which is what leaves `` the same missing-provider refusal it // reaches when nothing selected a checkout. deliver?.({ - host, - git: useGit, + operations, holdSlot, serialize, selections, diff --git a/packages/cli/src/repl/repositories/deno/pull-request-reads.ts b/packages/cli/src/repl/repositories/deno/pull-request-reads.ts new file mode 100644 index 000000000..478bf53b3 --- /dev/null +++ b/packages/cli/src/repl/repositories/deno/pull-request-reads.ts @@ -0,0 +1,383 @@ +/** + * How one REPL entry reads a pull request's evidence, and what it retains. + * + * An entry retains the provider's snapshot and nothing above it. Current URL + * matching, the operator's read ceiling and target parsing still happen on + * every read, live, because an operator who withdrew permission for a URL has + * withdrawn it for the continuation too — and the semantic and transport + * middleware outside retention still run, so an outer handler may transform + * what a restored snapshot becomes. What is restored is what the host answered; + * what the document binds is what the whole composition finished with. + * + * ## Each occurrence is its own + * + * Every invocation installs its own transport descriptor under the stable Api + * name, in a scope of that one read's own, and that descriptor's default closes + * over this invocation's owner and site. The shared middleware chain still + * composes around it, and the chain ends in this read's own admitted callback + * rather than in a shared one. Nothing is read from a "current read" variable, + * a Context, a registry or a public read option: a nested read started by a + * handler and two concurrent `` reads each carry their own callback, + * their own site and their own coroutine position, so none of them can consume + * another's. + * + * A wholly synthetic outer answer is ordinary composition. It reaches no + * admitted transport, so it creates no snapshot and retains nothing — and the + * shared body still validates it completely before an array binds. + * + * ## What is installed, and when + * + * The adapter's configuration and transport resolve once per entry and stay + * inert until something needs a session. Installing them reads no + * configuration and opens nothing, so an entry that reads nothing asks the host + * nothing — and an entry with no managed root can still read an allowed URL, + * because a read needs no Repository and no checkout. + */ + +import { createApi } from "@effectionx/context-api"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { getExpansion } from "@executablemd/core"; +import type { JournalAdmission } from "@executablemd/core/host"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { StaleInputError } from "@executablemd/durable-streams"; +import type { BindOperations } from "@executablemd/git"; +import { PULL_REQUEST_API } from "@executablemd/git/api"; +import type { + PullRequestApi, + PullRequestInput, + PullRequestReadOptions, + PullRequestUpsertOptions, +} from "@executablemd/git/api"; +import { NoPullRequestProvider } from "@executablemd/git/api"; +import { PULL_REQUEST_OPERATIONS } from "@executablemd/git/api"; +import type { + PullRequestOperationsApi, + PullRequestReadInvocation, + PullRequestUpsertInvocation, +} from "@executablemd/git/api"; +import { GitCompositionProviderError } from "@executablemd/git"; +import { PULL_REQUEST_ELEMENT } from "@executablemd/git"; +import { + parseCompletePullRequestReadResult, + PULL_REQUEST_READ_KINDS, + readRequest, +} from "@executablemd/git"; +import type { PullRequestReadKind, PullRequestReadResult } from "@executablemd/git/api"; +import type { PullRequestResult } from "@executablemd/git"; +import { canonicalPullRequestUrl, pullRequestProviderName } from "@executablemd/git"; +import type { DenoPullRequestOperations } from "@executablemd/git/deno"; +import { PULL_REQUEST_READ, retainPullRequestRead } from "@executablemd/git/deno"; +import { gitOperationFingerprint } from "@executablemd/git/deno"; + +/** Why a retained read history cannot be continued. */ +export type ReplPullRequestReadRefusal = + /** A retained read this host cannot read back as the question it names. */ + | "malformed-record" + /** A retained read written under another entry. */ + | "foreign-owner" + /** A retained read whose stored question is not the question it is named by. */ + | "request-fingerprint" + /** A retained success holding something other than the evidence it asked for. */ + | "malformed-evidence"; + +/** + * A retained read history this entry cannot continue from. + * + * Fatal by construction: it extends `StaleInputError`, so it ends the attempt + * rather than becoming a value a document can print away and carry on past. An + * entry that cannot account for evidence it already bound must not bind it + * again as though it could. + */ +export class ReplPullRequestReadHistoryError extends StaleInputError { + override name = "ReplPullRequestReadHistoryError"; + + readonly reason: ReplPullRequestReadRefusal; + + constructor(reason: ReplPullRequestReadRefusal, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** The complete question one retained read states. */ +interface RetainedReadQuestion { + readonly owner: string; + readonly site: string; + readonly url: string; + readonly provider: string | null; + readonly kind: PullRequestReadKind; +} + +export function replPullRequestReadAdmission(owner: string): JournalAdmission { + return (retained) => admitReplPullRequestReads(owner, retained); +} + +/** + * Hold every retained read to the question it was made under. + * + * Runs with this entry's other trusted admissions, before replay. Each record + * states its complete question, names this entry's owner, and is journaled + * under the name that exact question produces — recomputed here through the + * delivered recipe rather than compared against whatever the record carries. A + * success additionally holds the evidence of the collection it asked for, with + * every check item naming its envelope's head; a failed read has the same + * complete question and no array, and validating the question without inventing + * an outcome is the whole of it. + */ +function* admitReplPullRequestReads( + owner: string, + retained: readonly DurableEvent[], +): Operation { + for (const event of retained) { + if (event.type !== "yield" || event.description.type !== PULL_REQUEST_READ) { + continue; + } + const question = parseRetainedQuestion(event.description.input); + if (question === undefined) { + throw new ReplPullRequestReadHistoryError( + "malformed-record", + "a retained pull-request read in this entry's history does not state the complete " + + "question it was made under, so what it asked the Git host is unknown.", + ); + } + if (question.owner !== owner) { + throw new ReplPullRequestReadHistoryError( + "foreign-owner", + "a retained pull-request read in this entry's history was made under another owner, so " + + "the evidence it holds was not read by this entry.", + ); + } + const configuration = readConfiguration(question); + if ( + event.description.configuration !== configuration || + event.description.name !== `${question.site}:${configuration}` + ) { + throw new ReplPullRequestReadHistoryError( + "request-fingerprint", + "a retained pull-request read is journaled under a name its own question does not " + + "produce, so the record and the read it describes do not match.", + ); + } + if (event.result.status !== "ok") { + continue; + } + if (parseCompletePullRequestReadResult(event.result.value, question.kind) === undefined) { + throw new ReplPullRequestReadHistoryError( + "malformed-evidence", + "a retained pull-request read holds a value that is not the evidence the collection it " + + "asked for produces, so what it says the Git host answered is unknown.", + ); + } + } +} + +/** + * The exact configuration fingerprint one question produces. + * + * The delivered recipe, unchanged: the operation, the canonical URL, the + * explicit discriminator or its absence, and the collection. The owner and the + * site are deliberately outside it, which is what keeps a Workflow fork's + * inherited read the same effect — so this reader recomputes it rather than + * believing a stored value, and an empty one is no admission at all. + */ +function readConfiguration(question: RetainedReadQuestion): string { + return gitOperationFingerprint(["read", question.url, question.provider, question.kind]); +} + +/** The complete question one retained read states, or `undefined`. */ +function parseRetainedQuestion(value: unknown): RetainedReadQuestion | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + // Totally: every read of a journal-controlled value is the value's own to + // refuse, and a refusal of any of them is the same answer. + try { + const operation = Reflect.get(value, "operation"); + const url = Reflect.get(value, "url"); + const kind = Reflect.get(value, "kind"); + const owner = Reflect.get(value, "runId"); + const site = Reflect.get(value, "expansionId"); + const provider = Reflect.get(value, "provider"); + if (operation !== "read" || typeof owner !== "string" || owner === "") { + return undefined; + } + if (typeof site !== "string" || site === "") { + return undefined; + } + if (typeof url !== "string" || canonicalPullRequestUrl(url) !== url) { + return undefined; + } + const collection = PULL_REQUEST_READ_KINDS.find((known) => known === kind); + if (collection === undefined) { + return undefined; + } + if ( + provider !== null && + (typeof provider !== "string" || pullRequestProviderName(provider) !== provider) + ) { + return undefined; + } + return { owner, site, url, provider, kind: collection }; + } catch { + return undefined; + } +} + +/** + * How this entry performs its three evidence reads. + * + * The policy the common declarations are built with. Each is called once per + * invocation, with the site the binding authenticated, and what it answers with + * is that invocation's own read operation. + */ +export function replPullRequestReadPolicy( + owner: string, + pullRequests: () => DenoPullRequestOperations, +): BindOperations { + return (site: string) => (invocation: PullRequestReadInvocation) => + read(owner, site, pullRequests, invocation); +} + +/** + * One read, through both ordinary chains and this invocation's own transport. + * + * Two layers, because the public vocabulary has two and an entry read composes + * through the same ones every other profile composes through. The semantic + * `PullRequestOperations` chain decides *whether* this host answers at all and + * owns lifecycle and admission; the `PullRequestAPI` chain beneath it is + * transport. A document or a plugin may compose a handler at either, and both + * see this read exactly as they would see an ordinary run's. + * + * Each layer's terminal is this invocation's own and is installed in a scope of + * this read's own, so what either establishes does not outlive the invocation + * that made it. The transport terminal is the adapter's own handler — matching, + * the host ceiling and target parsing unchanged — and what it is handed is a + * callback closed over this entry's owner and this invocation's site, so the + * question a read is named by is this entry's while the adapter stays as free + * of owner and site as it is of a run and an expansion. + * + * Nothing is reached below the semantic layer until the semantic layer + * delegates. A handler there that answers without delegating has answered the + * document; it opens no session, admits no read and retains nothing — which is + * what makes a synthetic semantic answer ordinary composition rather than a + * second way to write history. + */ +function* read( + owner: string, + site: string, + pullRequests: () => DenoPullRequestOperations, + invocation: PullRequestReadInvocation, +): Operation { + return yield* scoped(function* (): Operation { + // One invocation-local default under the stable semantic name: the + // document's own lifecycle handlers still compose around this read, and the + // chain ends in this invocation's own admitted dispatch rather than in the + // public no-profile default. + const local = createApi(PULL_REQUEST_OPERATIONS, { + read: (request: PullRequestReadInvocation) => admitted(owner, site, pullRequests, request), + // The upsert keeps a refusal. It is unreachable through this descriptor — + // the only call made on it is the read below — and declaring it any other + // way would make this read's private terminal answer for an operation it + // owns nothing about. + upsert: refusingUpsertOperation(), + }); + return yield* local.operations.read(invocation); + }); +} + +/** + * The read the semantic chain delegated, through this invocation's transport. + * + * Reached only once the layer above has decided this host answers. The + * configuration and the position are read here rather than above, so a read the + * semantic chain answered itself asks this entry's transport nothing. + */ +function* admitted( + owner: string, + site: string, + pullRequests: () => DenoPullRequestOperations, + invocation: PullRequestReadInvocation, +): Operation { + // Built once per entry and inert until something needs a session, so there is + // always one here: an entry with no managed root still reads an allowed URL, + // because a read needs no Repository and no checkout. + const domain = pullRequests(); + const expansion = yield* getExpansion(); + + return yield* scoped(function* (): Operation { + // One invocation-local descriptor under the stable transport name: the + // document's own transport handlers still compose around this read, and the + // chain ends in this invocation's own admitted callback rather than a + // shared one. What the domain is handed is that callback; what it keeps is + // the configuration, the endpoint and the credential. + // + // This descriptor is the end of the chain, so a read this host declines has + // nothing below it to continue into and the public surface's own answer is + // what it gets. That answer is raised from here rather than by asking + // `PullRequestAPI.operations.read` again: re-entering the chain from the + // top would run every outer handler a second time before the refusal, and + // an outer handler that counts or rewrites the URL would see one read + // twice. + const local = createApi(PULL_REQUEST_API, { + read: (url: string, options: PullRequestReadOptions) => + domain.read( + url, + options, + (admittedRead, perform) => + retainPullRequestRead( + readRequest(admittedRead.url, admittedRead.kind, admittedRead.provider, owner, site), + expansion.position, + perform, + ), + declinedRead, + ), + upsert: refusingUpsert(), + }); + return yield* local.operations.read(invocation.url, { + kind: invocation.kind, + ...(invocation.provider === undefined ? {} : { provider: invocation.provider }), + }); + }); +} + +/** The same refusal the upsert's own descriptor gives the member it never routes. */ +function refusingUpsertOperation(): ( + invocation: PullRequestUpsertInvocation, +) => Operation { + // deno-lint-ignore require-yield + return function* (): Operation { + throw new GitCompositionProviderError(PULL_REQUEST_ELEMENT); + }; +} + +/** + * The public surface's own answer, for a read this host does not handle. + * + * The same error `PullRequestAPI`'s no-profile default raises, raised where the + * chain actually ends. Nothing is retained and nothing was contacted: a URL + * this adapter declined was refused before a session or a request existed. + */ +function declinedRead( + url: string, + options: PullRequestReadOptions, +): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + throw new NoPullRequestProvider("read", url, options.provider); + })(); +} + +/** The public default's own refusal, for the member this descriptor never routes. */ +function refusingUpsert(): ( + pullRequest: PullRequestInput, + options: PullRequestUpsertOptions, +) => Operation { + // deno-lint-ignore require-yield + return function* ( + _pullRequest: PullRequestInput, + options: PullRequestUpsertOptions, + ): Operation { + throw new NoPullRequestProvider("upsert", options.repository.name, options.provider); + }; +} diff --git a/packages/cli/src/repl/repositories/deno/pull-request.ts b/packages/cli/src/repl/repositories/deno/pull-request.ts new file mode 100644 index 000000000..90cffdb58 --- /dev/null +++ b/packages/cli/src/repl/repositories/deno/pull-request.ts @@ -0,0 +1,676 @@ +/** + * How one REPL entry opens or updates a pull request, and what it retains. + * + * Two records at two positions, for the reason the entry's publication keeps + * two: the request is written down *before* the Git host is contacted, and the + * host's own outcome is reconciled afterwards under the shared Git-host + * contract. Reopen the entry between them and the saved request is what the + * next attempt reconciles — the exact title, body, head and destination it + * originally named, however far the checkout has moved since. + * + * ## What authorizes it + * + * A pull request is a statement about a branch that already exists at the Git + * host, so this entry's own acknowledged publication of that branch is what + * admits it. That proof is read from the paired history admission already + * performed, before a request is written, before a credential is read and + * before anything is contacted. Another checkout's publication, another entry's, + * a synthetic middleware answer and a failed or pending Push authorize nothing. + * + * ## What it takes, and what it does not + * + * The managed slot alone, which says this entry owns the checkout it reads the + * branch and head from. No mutation lease: a pull request changes no checkout, + * no index, no branch and no tracking configuration, so an entry holding that + * lease elsewhere does not stop this one. A detached HEAD, an absent origin and + * an unregistered selection each refuse before a request, a credential or + * remote contact exists. + * + * ## Recovery reads what was saved + * + * At a saved-request position the record is restored and reconciled as it + * stands. Nothing rereads HEAD, the branch, the title or the body from today's + * checkout, and nothing retargets it through middleware it already passed: the + * head the host was asked about is the head that was resolved then. A completed + * outcome returns before native state, a slot, a provider or a credential is + * acquired, and a pending one opens only the live attempt needed to reconcile + * the inputs already saved. + */ + +import { createApi } from "@effectionx/context-api"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { realpath } from "node:fs/promises"; +import { createDurableOperation } from "@executablemd/durable-streams"; +import type { + ActivateDurabilityFailure, + Json, + LiveDurableOperationCoordinator, + Result as ProtocolResult, +} from "@executablemd/durable-streams"; +import { PULL_REQUEST_ELEMENT } from "@executablemd/git"; +import { + GitCompositionProviderError, + GitOperationAdmissionError, + GitOperationInfrastructureError, + GitOperationProtocolError, +} from "@executablemd/git"; +import type { BindOperations } from "@executablemd/git"; +import { PULL_REQUEST_OPERATIONS } from "@executablemd/git/api"; +import type { + PullRequestOperationsApi, + PullRequestReadInvocation, + PullRequestUpsertInvocation, +} from "@executablemd/git/api"; +import { + parsePullRequestRecord, + type PullRequestInputs, + type PullRequestResult, +} from "@executablemd/git"; +import { locatorFingerprint } from "@executablemd/git/deno"; +import { destinationRefFor } from "@executablemd/git"; +import type { RepositorySelection } from "@executablemd/git/api"; +import { reconcileOwnedGitHostEffect } from "@executablemd/git/api"; +import type { GitHostProvider } from "@executablemd/git"; +import type { GitHostReconciliationRecord } from "@executablemd/git"; +import { + replPullRequestEffectRequest, + replPullRequestPlacesHeld, + type ReplPushPlacement, +} from "../push-admission.ts"; +import { proveReplPullRequestPublication } from "../pull-request-admission.ts"; +import { + parseReplPullRequestRequestRecord, + replPullRequestRequestJson, + replPullRequestRequestName, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, + sameReplPullRequestAsked, + type ReplPullRequestAsked, + type ReplPullRequestContent, + type ReplPullRequestPlace, + type ReplPullRequestRequestRecord, + type ReplPullRequestResolution, +} from "../pull-request-records.ts"; +import type { DenoPullRequestOperations } from "@executablemd/git/deno"; +import { gitRefusal } from "@executablemd/git/deno"; +import { selectCheckout } from "@executablemd/git/deno"; +import { holdManagedPlacement } from "./leases.ts"; +import type { ReplGitCheckout } from "./git.ts"; +import { retainedRecordAhead } from "./local.ts"; +import { nativeGitTurn, placementSubject } from "./selections.ts"; +import type { ReplPlacementRegistration } from "./selections.ts"; +import type { ReplPushAccess, ReplPushEntry } from "./push.ts"; + +/** + * How this entry performs one ``. + * + * The policy the common declaration is built with. It is called once per + * invocation, with the site the binding authenticated, and what it answers with + * is that invocation's own upsert operation. + * + * The site reaches no public input and no journal name of its own. Its parsed + * facts are what the saved request carries, because the opaque identifier this + * entry minted names nothing once the entry ends. + */ +export function replPullRequestPolicy( + owner: string, + access: ReplPushAccess, + pullRequests: () => DenoPullRequestOperations, +): BindOperations { + return (site: string) => (request: PullRequestUpsertInvocation) => + upsert({ + owner, + site, + access, + pullRequests, + invocation: request, + }); +} + +interface Invocation { + readonly owner: string; + readonly site: string; + readonly access: ReplPushAccess; + /** This entry's pull-request domain, inert until something needs a session. */ + readonly pullRequests: () => DenoPullRequestOperations; + readonly invocation: PullRequestUpsertInvocation; +} + +/** + * Upsert this invocation's pull request, or reconcile the request it already + * made. + * + * The saved request is looked for first, and found without consuming anything: + * a position holding this invocation's own request is answered from that + * request, and only an invocation with nothing saved dispatches, resolves, + * proves its publication and writes one down. + */ +function* upsert(call: Invocation): Operation { + const entry = held(call.access); + // Read once, before anything suspends: the invocation is the caller's own + // object, and a value read again later could leave the record naming one + // title and the host asked about another. + const registration = authenticated(entry, call.invocation.repository); + const requested: ReplPullRequestAsked = Object.freeze({ + place: placeOf(registration, call.invocation.workingDirectory), + pullRequest: contentOf(call.invocation), + }); + const name = yield* replPullRequestRequestName({ + owner: call.owner, + site: call.site, + requested, + }); + + if (yield* retainedRecordAhead(REPL_REPOSITORY_PULL_REQUEST_REQUEST, name)) { + const saved = yield* restored(entry, call, requested, name); + return yield* reconcile(call, saved); + } + + // One operation-local default under the stable Api name: the shared + // middleware chain applies, and the chain ends in this invocation's own + // terminal rather than in the public refusing default. Nothing but this + // dispatch holds it, and it is revoked below. + const live: Dispatch = { open: true, delegated: false }; + const local = createApi(PULL_REQUEST_OPERATIONS, { + upsert: (request: PullRequestUpsertInvocation): Operation => + delegated(entry, call, requested, name, live, request), + // The read keeps the public refusal. It is unreachable through this + // descriptor — the only call made on it is the upsert below — and declaring + // it any other way would make this invocation's private terminal answer for + // an operation it owns nothing about. + read: refusingRead(), + }); + try { + // What a handler answers with is not read as evidence. A synthetic answer + // has answered the document; it is not a receipt, it saves no request and + // no outcome, and it grants no publication. + return yield* local.operations.upsert(call.invocation); + } finally { + live.open = false; + } +} + +/** What one invocation's own default has done, where only it can reach. */ +interface Dispatch { + /** Whether the invocation that captured this default is still running. */ + open: boolean; + /** Whether a delegation has already reached it. */ + delegated: boolean; +} + +/** + * The one delegation this invocation's default accepts. + * + * It snapshots the received request once, authenticates it, resolves the + * complete inputs, proves this entry published the branch, publishes the + * request and reconciles it. A second use refuses before another request or + * another worker call: the first delegation may have already opened a pull + * request, and nothing here cancels or repeats acknowledged external work. + */ +function* delegated( + entry: ReplPushEntry, + call: Invocation, + requested: ReplPullRequestAsked, + name: string, + live: Dispatch, + request: PullRequestUpsertInvocation, +): Operation { + if (!live.open) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the upsert it belongs to has finished, so this dispatch names no invocation. Nothing was " + + "asked of the Git host", + ); + } + if (live.delegated) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "this pull request was already dispatched once. A second delegation of one " + + "would ask the Git host again about work this invocation has already acknowledged, so " + + "nothing was asked of it", + ); + } + live.delegated = true; + + // Read once, here, for the reason the question above is read once. + const receivedSelection = request.repository; + const receivedDirectory = request.workingDirectory; + const registration = authenticated(entry, receivedSelection); + const received: ReplPullRequestAsked = Object.freeze({ + place: placeOf(registration, receivedDirectory), + pullRequest: contentOf(request), + }); + + const resolved = yield* resolve(entry, received); + + // Before the request exists, before a credential and before the host is + // contacted: what admits a pull request is this entry's own acknowledged + // publication of the branch it names. + yield* proveReplPullRequestPublication(call.owner, { + repository: received.place.repository, + origin: resolved.origin, + branch: resolved.inputs.headBranch, + destinationRef: destinationRefFor(resolved.inputs.headBranch), + checkoutRoot: resolved.checkoutRoot, + commonDirectory: resolved.commonDirectory, + headSha: resolved.inputs.headSha, + }); + + const record = yield* publishRequest(entry, call, requested, received, resolved, name); + return yield* reconcile(call, record); +} + +/** + * The complete inputs this invocation asks for, resolved from the checkout. + * + * Everything a later step trusts is proven before it: the checkout covering the + * received directory, the origin this repository belongs to, the managed slot + * that says this entry owns that checkout, and only then — inside the entry's + * own native turn — the branch it is on and the commit that branch holds. + * + * Nothing contacts a remote and nothing reads a credential. + */ +function* resolve( + entry: ReplPushEntry, + received: ReplPullRequestAsked, +): Operation { + const routed = yield* route(entry, received); + const placement = routed.registration.placement; + const origin = yield* destination(routed.registration); + yield* own(entry, routed); + + // Serialized with this entry's own native Git, because reading HEAD beside a + // `` of the same repository would describe a moment neither of + // them is at. The turn is held for these two short reads and released before + // anything is contacted. + const read = yield* entry.serialize(nativeGitTurn(placement.commonDirectory), function* () { + const directory = routed.checkout.root; + const branch = yield* entry.operations.currentBranch(directory); + if (branch === undefined) { + throw gitRefusal(PULL_REQUEST_ELEMENT, "unnamed-branch"); + } + const headSha = yield* entry.operations.resolveCommit(directory, "HEAD"); + if (headSha === undefined) { + throw new GitOperationInfrastructureError( + PULL_REQUEST_ELEMENT, + "the checkout it ran in did not report the commit its branch holds", + ); + } + return { branch, headSha }; + }); + + const content = received.pullRequest; + const inputs: PullRequestInputs = Object.freeze({ + repository: received.place.repository, + number: content.number, + title: content.title, + body: content.body, + draft: content.draft, + headBranch: read.branch, + headSha: read.headSha, + baseBranch: content.base, + }); + return Object.freeze({ + checkoutRoot: routed.checkout.root, + commonDirectory: placement.commonDirectory, + origin, + inputs, + }); +} + +/** + * Which origin this checkout belongs to, as this entry can see it now. + * + * The locator the entry admitted, held to the fingerprint the Repository + * identity carries. A checkout that records no usable origin belongs to no + * remote repository a pull request could be opened on, and one whose origin + * does not name this repository is not somewhere this invocation may act — + * both before a request exists and before anything is contacted. + */ +function* destination(registration: ReplPlacementRegistration): Operation { + const origin = yield* registration.origin(); + if (origin === undefined) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the checkout it selected records no usable origin, so there is no remote repository for " + + "a pull request to be opened in. No credential was read and nothing was contacted", + ); + } + if (locatorFingerprint(origin) !== registration.placement.identity.locatorFingerprint) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the origin this checkout records is not the one the Repository in scope was selected " + + "from, so a pull request opened against it would name somewhere this entry never " + + "authenticated", + ); + } + return origin; +} + +/** + * Write this request down, and read it back before it is acknowledged. + * + * The request is one durable position and the reconciliation below is another: + * two inline operations rather than one wrapped in the other, because a request + * that was acknowledged and an outcome that was not is exactly the history a + * continuation has to be able to read. + */ +function* publishRequest( + entry: ReplPushEntry, + call: Invocation, + requested: ReplPullRequestAsked, + received: ReplPullRequestAsked, + resolved: ReplPullRequestResolution, + name: string, +): Operation { + const stored: unknown = yield createDurableOperation( + { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, + function* () { + const payload = replPullRequestRequestJson({ + owner: call.owner, + site: call.site, + requested, + received, + resolved, + }); + readback(entry, call, requested, payload); + return payload; + }, + { coordinator: requestCoordinator }, + ); + return readback(entry, call, requested, stored); +} + +/** + * The saved request standing at this position, read back for this invocation. + * + * Reached only when the cursor already holds this invocation's own request. The + * callback below is the engine's to run and this position's history says it + * will not: a live attempt here would publish a request nothing resolved, so it + * refuses rather than inventing one. + */ +function* restored( + entry: ReplPushEntry, + call: Invocation, + requested: ReplPullRequestAsked, + name: string, +): Operation { + const stored: unknown = yield createDurableOperation( + { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, + // deno-lint-ignore require-yield + function* (): Operation { + throw new GitOperationInfrastructureError( + PULL_REQUEST_ELEMENT, + "its retained history named a saved pull-request request at this position and then had " + + "none, so what this entry asked the Git host for is unknown", + ); + }, + { coordinator: requestCoordinator }, + ); + return readback(entry, call, requested, stored); +} + +/** + * The record this value is for this invocation, or the fatal failure it is not. + * + * The same five questions on both paths, which is the point: a resolved request + * is held to them before it is published, and a restored one before it is + * reconciled. The payload parses, it is this entry's and this invocation's, it + * asks the question this position is asking, every place it names is one this + * entry's own registry holds, and the origin it says it belongs to is the one + * the Repository identity in it was fingerprinted from. + */ +function readback( + entry: ReplPushEntry, + call: Invocation, + requested: ReplPullRequestAsked, + stored: unknown, +): ReplPullRequestRequestRecord { + const record = parseReplPullRequestRequestRecord(stored); + if (record === undefined) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + if ( + record.owner !== call.owner || + record.site !== call.site || + !sameReplPullRequestAsked(record.requested, requested) + ) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + if (!replPullRequestPlacesHeld(record, placements(entry))) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + if ( + locatorFingerprint(record.resolved.origin) !== + record.resolved.inputs.repository.locatorFingerprint + ) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + return record; +} + +/** The places this entry's reconstructed registry holds, as parsed facts. */ +function placements(entry: ReplPushEntry): readonly ReplPushPlacement[] { + return entry.checkouts().map((checkout) => ({ + identity: checkout.registration.placement.identity, + checkoutRoot: checkout.registration.placement.checkoutRoot, + commonDirectory: checkout.registration.placement.commonDirectory, + })); +} + +/** + * Reconcile this request's external effect, and hold the result to the request. + * + * A completed outcome comes back before any native work, any resource and any + * credential: the shared worker replays it without constructing a provider at + * all. A pending one reaches the live provider below. + */ +function* reconcile( + call: Invocation, + record: ReplPullRequestRequestRecord, +): Operation { + const inputs = record.resolved.inputs; + const reconciliation = yield* reconcileOwnedGitHostEffect( + { + owner: call.owner, + site: call.site, + request: replPullRequestEffectRequest(inputs), + }, + { + provider: () => upsertProvider(call, record), + validate: (produced: GitHostReconciliationRecord) => { + if (parsePullRequestRecord(produced, inputs) === undefined) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + }, + }, + ); + const outcome = parsePullRequestRecord(reconciliation, inputs); + if (outcome === undefined) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + return outcome.result; +} + +/** + * The provider that answers this exact upsert, built inside the attempt. + * + * Reached only by a live attempt, and the access session it opens belongs to + * that attempt's own scope — so the observation and the mutation it decided go + * out under one identity, and it is closed and joined before publication may + * acknowledge a result. + * + * A pending pull request needs no checkout and no object source: its original + * publication was already admitted and its saved remote inputs are sufficient. + * + * The record arrives having passed {@link readback} on both paths, so the saved + * origin has already been authenticated against the saved identity and every + * place it names has already been held to this entry's registry. Asking either + * again here would ask it of the same frozen record and the same registry — + * membership is rebuilt by selections that already happened and nothing drops + * one, so neither answer can have changed — and the second refusal carried its + * own error class for a verdict that cannot differ from the first. + */ +function* upsertProvider( + call: Invocation, + record: ReplPullRequestRequestRecord, +): Operation { + const resolved = record.resolved; + const inputs = resolved.inputs; + // Opened inside the owned attempt that asked for a live provider, which is + // what keeps the session's lifetime that attempt's rather than this entry's. + return yield* call.pullRequests().provider(resolved.origin, inputs); +} + +/** + * Which checkout this invocation's pull request reads its branch from. + * + * The entry's own registry decides, restricted by the whole authenticated + * Repository identity and resolving the received directory first. The longest + * covering checkout wins, so a subdirectory, a linked worktree and a captured + * path entered through `` each select the checkout their own paths belong + * to. + */ +function* route(entry: ReplPushEntry, received: ReplPullRequestAsked): Operation { + const registered = entry.checkouts(); + const selected = selectCheckout( + registered.map((checkout) => checkout.checkout), + received.place.repository, + yield* canonical(received.place.workingDirectory), + PULL_REQUEST_ELEMENT, + ); + const found = registered.find((checkout) => checkout.checkout.root === selected.root); + if (found === undefined) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the checkout it selected is not one this entry holds a placement for", + ); + } + return found; +} + +/** + * Take ownership of the checkout this pull request reads from. + * + * The managed slot alone, which says which entry owns a managed checkout. The + * mutation lease says which entry is *changing* a repository, and a pull + * request changes none of it. An ambient target has no slot: it is the checkout + * the person was already standing in. + */ +function* own(entry: ReplPushEntry, target: ReplGitCheckout): Operation { + const registration = target.registration; + const slot = registration.slot; + const root = registration.placement.managedRoot; + if (slot === undefined || root === undefined) { + return; + } + yield* holdManagedPlacement(() => + entry.holdSlot(root, slot.kind, slot.path, placementSubject(registration.placement)), + ); +} + +function placeOf( + registration: ReplPlacementRegistration, + workingDirectory: string, +): ReplPullRequestPlace { + return Object.freeze({ + repository: registration.placement.identity, + selectionCheckout: registration.placement.checkoutRoot, + commonDirectory: registration.placement.commonDirectory, + // Exactly as received. Resolving it is live routing, which a continuation + // does not do, so the name cannot depend on it. + workingDirectory, + }); +} + +function contentOf(request: PullRequestUpsertInvocation): ReplPullRequestContent { + const pullRequest = request.pullRequest; + return Object.freeze({ + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + draft: pullRequest.draft, + base: pullRequest.base, + }); +} + +/** + * The canonical directory this path resolves to, or the path as written. + * + * Only live routing resolves anything: a saved request's roots are + * authenticated against parsed placements instead. + */ +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return path; + } +} + +/** The public default's own refusal, for the member this descriptor never routes. */ +function refusingRead(): (invocation: PullRequestReadInvocation) => Operation { + // deno-lint-ignore require-yield + return function* (): Operation { + throw new GitCompositionProviderError(PULL_REQUEST_ELEMENT); + }; +} + +/** The private entry state this execution holds, or the ordinary refusal. */ +function held(access: ReplPushAccess): ReplPushEntry { + const entry = access(); + if (entry === undefined) { + // The same failure the public Api's own default raises, because that is + // what this condition is: no local capability was established for this + // execution, so nothing performed anything. + throw new GitCompositionProviderError(PULL_REQUEST_ELEMENT); + } + return entry; +} + +/** What this selection names in this entry's registry, or an admission refusal. */ +function authenticated( + entry: ReplPushEntry, + selection: RepositorySelection, +): ReplPlacementRegistration { + return entry.selections.authenticate( + selection, + () => + new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the Repository in scope is not one this entry selected, so it names no checkout", + ), + ); +} + +/** + * The live boundary one published request is written through. + * + * The default coordinator would serialize whatever was thrown and publish it as + * this operation's outcome, which is the one thing that must not happen here: a + * request published for work nobody resolved would send a continuation to + * reconcile a pull request this entry never described. + * + * A throw therefore activates the run's existing fail-stop state and is + * re-raised with its identity intact, publishing nothing. A valid request is + * published once and returned only after the append is acknowledged, which is + * what makes "no remote work before the request exists" a property of the + * ordering rather than a promise. + */ +const requestCoordinator: LiveDurableOperationCoordinator = { + *run( + execute: () => Operation, + publish: (result: ProtocolResult) => Operation, + activateFailure: ActivateDurabilityFailure, + ): Operation { + let value: T; + try { + value = yield* execute(); + } catch (error) { + throw activateFailure(error); + } + const result: ProtocolResult = { status: "ok", value }; + yield* publish(result); + return result; + }, +}; diff --git a/packages/git/src/deno/retained-repository/push.ts b/packages/cli/src/repl/repositories/deno/push.ts similarity index 91% rename from packages/git/src/deno/retained-repository/push.ts rename to packages/cli/src/repl/repositories/deno/push.ts index abbdd71fa..68f304b9e 100644 --- a/packages/git/src/deno/retained-repository/push.ts +++ b/packages/cli/src/repl/repositories/deno/push.ts @@ -67,16 +67,16 @@ import type { Result as ProtocolResult, } from "@executablemd/durable-streams"; import type { JournalAdmission } from "@executablemd/core/host"; -import { PUSH } from "../../composition/components/GitPush.ts"; +import { PUSH } from "@executablemd/git"; import { GitCompositionProviderError, GitOperationAdmissionError, GitOperationInfrastructureError, GitOperationProtocolError, -} from "../../composition/errors.ts"; -import { GIT_API } from "../../composition/git-api.ts"; -import type { GitApi, GitPushInvocation } from "../../composition/git-api.ts"; -import type { BindOperations } from "../../composition/invocation-binding.ts"; +} from "@executablemd/git"; +import { GIT_API } from "@executablemd/git/api"; +import type { GitApi, GitPushInvocation } from "@executablemd/git/api"; +import type { BindOperations } from "@executablemd/git"; import { destinationRefFor, parseGitPushRecord, @@ -84,19 +84,20 @@ import { PUSH_REMOTE, type GitPushInputs, type GitPushOutcome, -} from "../../composition/git-push-records.ts"; -import { locatorFingerprint } from "../composition/locator.ts"; -import { sameRepositoryIdentity } from "../../composition/selection.ts"; -import type { RepositorySelection } from "../../composition/selection.ts"; -import { reconcileOwnedGitHostEffect } from "../../git-host/effect.ts"; -import type { GitHostProvider } from "../../git-host/api.ts"; -import type { GitHostReconciliationRecord } from "../../git-host/records.ts"; +} from "@executablemd/git"; +import { locatorFingerprint } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { sameRepositoryIdentity } from "@executablemd/git"; +import type { RepositorySelection } from "@executablemd/git/api"; +import { reconcileOwnedGitHostEffect } from "@executablemd/git/api"; +import type { GitHostProvider } from "@executablemd/git"; +import type { GitHostReconciliationRecord } from "@executablemd/git"; import { admitReplPushHistory, replPushEffectRequest, replPushPlacesHeld, type ReplPushPlacement, -} from "../../retained-repository/push-admission.ts"; +} from "../push-admission.ts"; import { parseReplPushRequestRecord, replPushRequestJson, @@ -105,19 +106,12 @@ import { type ReplPushPlace, type ReplPushRequestRecord, type ReplPushResolution, -} from "../../retained-repository/push-records.ts"; -import { currentBranch, commitPresent, resolveCommit } from "../composition/git.ts"; -import type { GitSession } from "../composition/git.ts"; -import { useGitAuthentication } from "../composition/host.ts"; -import type { RepositoryHost } from "../composition/host.ts"; -import type { GitAuthenticationSession } from "../composition/authentication.ts"; -import { objectSourceAt } from "../composition/object-source.ts"; -import { pushProvider } from "../composition/push.ts"; -import { gitRefusal } from "../composition/refusals.ts"; -import { lazily } from "../run-composition/lazy.ts"; -import type { SlotKind } from "../run-composition/leases.ts"; -import { liveCheckout, selectCheckout } from "../run-composition/operations.ts"; -import type { SelectionRegistry } from "../selections.ts"; +} from "../push-records.ts"; +import { gitRefusal } from "@executablemd/git/deno"; +import { lazily } from "@executablemd/git/deno"; +import type { SlotKind } from "@executablemd/git/deno"; +import { selectCheckout } from "@executablemd/git/deno"; +import type { SelectionRegistry } from "@executablemd/git/deno"; import { holdManagedPlacement } from "./leases.ts"; import type { ReplGitCheckout } from "./git.ts"; import { retainedRecordAhead } from "./local.ts"; @@ -139,10 +133,8 @@ import type { ReplPlacementRegistration } from "./selections.ts"; * entry from changing a repository. */ export interface ReplPushEntry { - /** The native Git host, which also lends a locator's authentication. */ - readonly host: RepositoryHost; - /** This entry's Git session, acquired once into the entry's own scope. */ - git(): Operation; + /** The native repository work this entry asks for, credential-free. */ + readonly operations: DenoRepositoryOperations; /** Hold a managed slot for the entry, single-flight per root and slot. */ holdSlot(root: string, kind: SlotKind, path: string, subject: string): Operation; /** Run live native Git for one shared Git directory in order within this entry. */ @@ -350,18 +342,17 @@ function* resolve(entry: ReplPushEntry, received: ReplPushPlace): Operation` of the same repository would describe a moment neither of // them is at. The turn is held for these two short reads and released before // anything is published: a network round trip never holds it. const read = yield* entry.serialize(nativeGitTurn(placement.commonDirectory), function* () { - const checkout = liveCheckout(git, routed.checkout, received.workingDirectory); - const branch = yield* currentBranch(checkout.git, checkout.directory); + const directory = routed.checkout.root; + const branch = yield* entry.operations.currentBranch(directory); if (branch === undefined) { throw gitRefusal(PUSH, "unnamed-branch"); } - const sourceCommit = yield* resolveCommit(checkout.git, checkout.directory, "HEAD"); + const sourceCommit = yield* entry.operations.resolveCommit(directory, "HEAD"); if (sourceCommit === undefined) { throw new GitOperationInfrastructureError( PUSH, @@ -625,11 +616,10 @@ function* publication( // native use of it. A restored placement took none when it replayed. yield* own(entry, routed); - const git = yield* entry.git(); // The exact object this request named, which has to still be readable. A // missing one fails rather than being fetched: fetching it would manufacture // the very thing the publication is about. - if (!(yield* commitPresent(git, resolved.checkoutRoot, inputs.sourceCommit))) { + if (!(yield* entry.operations.commitPresent(resolved.checkoutRoot, inputs.sourceCommit))) { throw new GitOperationInfrastructureError( PUSH, "the commit its saved request published is no longer readable in that checkout, so this " + @@ -637,20 +627,10 @@ function* publication( ); } - const attempt: Scope = yield* useScope(); - const source = objectSourceAt( - entry.host, - yield* entry.host.useDirectory(), - resolved.commonDirectory, - inputs.repository.objectFormat, - PUSH, - ); - // One session for this whole attempt, opened by the first phase that needs - // it and released with the attempt's own scope. - const session = lazily(attempt, function* (): Operation { - return yield* useGitAuthentication(entry.host, resolved.origin); - }); - return pushProvider(source, resolved.origin, inputs, session); + // Asked for from inside the owned attempt, so the control repository and the + // credential session it opens belong to that attempt and are joined when it + // unwinds. Neither reaches this entry. + return yield* entry.operations.pushProvider(resolved.commonDirectory, resolved.origin, inputs); } /** diff --git a/packages/git/src/deno/retained-repository/selections.ts b/packages/cli/src/repl/repositories/deno/selections.ts similarity index 96% rename from packages/git/src/deno/retained-repository/selections.ts rename to packages/cli/src/repl/repositories/deno/selections.ts index 90b074abc..405777a0c 100644 --- a/packages/git/src/deno/retained-repository/selections.ts +++ b/packages/cli/src/repl/repositories/deno/selections.ts @@ -65,17 +65,15 @@ import type { LiveDurableOperationCoordinator, Result as ProtocolResult, } from "@executablemd/durable-streams"; -import type { RepositoryRequest, WorktreeRequest } from "../../composition/api.ts"; +import type { RepositoryRequest, WorktreeRequest } from "@executablemd/git/api"; import { RepositoryCompositionError, RepositorySelectionError, WorktreeCompositionError, -} from "../../composition/errors.ts"; -import { - sameRepositoryIdentity, - type RepositoryIdentity, - type RepositorySelection, -} from "../../composition/selection.ts"; +} from "@executablemd/git"; +import { type RepositorySelection } from "@executablemd/git/api"; +import { sameRepositoryIdentity } from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT, @@ -87,22 +85,22 @@ import { type ReplPlacement, type ReplPlacementRefusalData, type ReplPlacementRequest, -} from "../../retained-repository/placement-records.ts"; -import { admitLocator, locatorFingerprint } from "../composition/locator.ts"; -import { GitRefusal, type GitSession } from "../composition/git.ts"; -import type { RepositoryHost } from "../composition/host.ts"; +} from "../placement-records.ts"; +import { admitLocator } from "@executablemd/git"; +import { locatorFingerprint } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { GitRefusal } from "@executablemd/git/deno"; import { CompositionRefusal, refusalReason, repositoryRefusal, worktreeRefusal, -} from "../composition/refusals.ts"; -import { selectManagedRepository, selectManagedWorktree } from "../run-composition/checkouts.ts"; -import { ManagedCheckoutError, NoAmbientRepositoryError } from "../run-composition/errors.ts"; -import type { SlotKind } from "../run-composition/leases.ts"; -import { checkoutOf, repositorySlot, worktreeSlot } from "../run-composition/checkout-layout.ts"; -import type { AmbientRepository } from "../run-composition/ambient.ts"; -import type { SelectionRegistry } from "../selections.ts"; +} from "@executablemd/git/deno"; +import { ManagedCheckoutError, NoAmbientRepositoryError } from "@executablemd/git/deno"; +import type { SlotKind } from "@executablemd/git/deno"; +import { checkoutOf, repositorySlot, worktreeSlot } from "@executablemd/git/deno"; +import type { AmbientRepository } from "@executablemd/git/deno"; +import type { SelectionRegistry } from "@executablemd/git/deno"; /** * A saved placement standing where this selection belongs that it cannot read. @@ -148,10 +146,8 @@ export interface ReplPlacementRegistration { /** What a recorded selection needs from the entry that owns it. */ export interface ReplSelectionEntry { - /** The native Git host, which is also what lends clone authentication. */ - readonly host: RepositoryHost; - /** This entry's Git session, acquired once into the entry's own scope. */ - git(): Operation; + /** The native repository work this entry asks for, credential-free. */ + readonly operations: DenoRepositoryOperations; /** The canonical managed root, created before it is canonicalized, once. */ root(): Operation; /** The repository this invocation started in, discovered at most once. */ @@ -210,7 +206,7 @@ export function* selectReplRepository( entry.holdSlot(root, "repository", slot, `repository ${JSON.stringify(request.name)}`), *perform(root, slot) { { - const managed = yield* selectManagedRepository(yield* entry.git(), entry.host, slot, { + const managed = yield* entry.operations.selectRepository(slot, { name: request.name, locator, base: request.base, @@ -286,7 +282,7 @@ export function* selectReplWorktree( }, *perform(root, slot) { { - const managed = yield* selectManagedWorktree(yield* entry.git(), slot, { + const managed = yield* entry.operations.selectWorktree(slot, { name: request.name, branch: request.branch, base: request.base, diff --git a/packages/git/src/retained-repository/git-records.ts b/packages/cli/src/repl/repositories/git-records.ts similarity index 99% rename from packages/git/src/retained-repository/git-records.ts rename to packages/cli/src/repl/repositories/git-records.ts index 2790d39ae..f1277034b 100644 --- a/packages/git/src/retained-repository/git-records.ts +++ b/packages/cli/src/repl/repositories/git-records.ts @@ -51,7 +51,7 @@ import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { canonicalJson } from "@executablemd/workflow"; -import { members, optionalText, text } from "../composition/parse.ts"; +import { members, optionalText, text } from "@executablemd/git"; import { gitAddResultJson, gitCommitResultJson, @@ -64,12 +64,9 @@ import { type GitCommitMessageSource, type GitCommitResult, type GitSwitchResult, -} from "../composition/git-records.ts"; -import { - parseRepositoryIdentity, - repositoryIdentityJson, - type RepositoryIdentity, -} from "../composition/selection.ts"; +} from "@executablemd/git"; +import { parseRepositoryIdentity, repositoryIdentityJson } from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; import { digest, RECORD_PREFIX, REPL_REPOSITORY_VERSION } from "./records.ts"; /** The durable type one completed local Git operation is journaled under. */ diff --git a/packages/git/src/retained-repository/observation-records.ts b/packages/cli/src/repl/repositories/observation-records.ts similarity index 97% rename from packages/git/src/retained-repository/observation-records.ts rename to packages/cli/src/repl/repositories/observation-records.ts index b7190ca5c..b719a7165 100644 --- a/packages/git/src/retained-repository/observation-records.ts +++ b/packages/cli/src/repl/repositories/observation-records.ts @@ -41,27 +41,24 @@ import type { Result } from "effection"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { canonicalJson } from "@executablemd/workflow"; -import { members, optionalText, text } from "../composition/parse.ts"; -import { GitObservationError } from "../composition/errors.ts"; -import type { GitObservationReason } from "../composition/errors.ts"; +import { members, optionalText, text } from "@executablemd/git"; +import { GitObservationError } from "@executablemd/git"; +import type { GitObservationReason } from "@executablemd/git"; import { parseRepositoryIdentity, repositoryIdentityJson, sameRepositoryIdentity, - type RepositoryIdentity, -} from "../composition/selection.ts"; +} from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; import type { ReplPlacement } from "./placement-records.ts"; import { - GIT_COMPARE_MODES, type GitComparisonRequest, type GitObservationKind, type GitObservationRequest, type GitObservationValue, -} from "../composition/observation-api.ts"; -import { - gitObservationValueJson, - parseGitObservationValue, -} from "../composition/observation-records.ts"; +} from "@executablemd/git/api"; +import { GIT_COMPARE_MODES } from "@executablemd/git"; +import { gitObservationValueJson, parseGitObservationValue } from "@executablemd/git"; import { digest, RECORD_PREFIX, REPL_REPOSITORY_VERSION } from "./records.ts"; /** The durable type one completed local Git observation is journaled under. */ diff --git a/packages/git/src/retained-repository/placement-records.ts b/packages/cli/src/repl/repositories/placement-records.ts similarity index 98% rename from packages/git/src/retained-repository/placement-records.ts rename to packages/cli/src/repl/repositories/placement-records.ts index 62d0b0984..228a4e082 100644 --- a/packages/git/src/retained-repository/placement-records.ts +++ b/packages/cli/src/repl/repositories/placement-records.ts @@ -58,13 +58,10 @@ import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { canonicalJson } from "@executablemd/workflow"; -import { members, optionalText, text } from "../composition/parse.ts"; -import { - parseRepositoryIdentity, - repositoryIdentityJson, - type RepositoryIdentity, -} from "../composition/selection.ts"; -import type { RepositoryFailureReason, WorktreeFailureReason } from "../composition/errors.ts"; +import { members, optionalText, text } from "@executablemd/git"; +import { parseRepositoryIdentity, repositoryIdentityJson } from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; +import type { RepositoryFailureReason, WorktreeFailureReason } from "@executablemd/git"; import { digest, RECORD_PREFIX, REPL_REPOSITORY_VERSION } from "./records.ts"; /** The durable type one completed Repository or Worktree selection is journaled under. */ diff --git a/packages/cli/src/repl/repositories/plugin.ts b/packages/cli/src/repl/repositories/plugin.ts new file mode 100644 index 000000000..a6348b22b --- /dev/null +++ b/packages/cli/src/repl/repositories/plugin.ts @@ -0,0 +1,56 @@ +/** + * The bundled Git Plugin, with this command's own entry-history admission. + * + * Git contributes the vocabulary and its two domain admissions. What an entry + * retained about the checkout it was standing in is this command's record, so + * the admission that reads it back belongs here — and it has to be *inside* the + * contribution, because canonical core applies an admission within each + * execution's own journal read. + * + * This wrapper is the one place that knows a `repl` request is different. Every + * other command is delegated untouched, so the bundled Plugin keeps its name, + * its deduplication and its place in the operator's own Plugin order. + * + * It is provider-neutral on purpose. The grammar an entry wrote down has to be + * readable before any of it is replayed, on every runtime — a Node session that + * retained nothing has nothing here to refuse, and nothing in this path reaches + * the Deno adapter. + */ + +import type { Operation } from "effection"; +import type { Plugin, PluginInstallRequest, PluginInstallation } from "@executablemd/core/api"; +import { gitCompositionInstallation } from "@executablemd/git"; +import { admitReplRepositoryHistory } from "./admission.ts"; + +/** + * Whether this wrapper, rather than the value it wraps, answers for a command. + * + * The one place the `repl` special case is written down. `run-profile.ts` asks + * it too, because a command this wrapper declares for carries the bundled + * value whether or not Git's own profile declares for it — and two spellings of + * that question would be two answers the moment either moved. + */ +export function replRepositoryPluginDeclaresFor(command: string): boolean { + return command === "repl"; +} + +/** This command's assembly of one bundled Git Plugin. */ +export function replRepositoryPlugin(bundled: Plugin): Plugin { + return { + ...bundled, + *install(request: PluginInstallRequest): Operation { + if (!replRepositoryPluginDeclaresFor(request.command)) { + const install = bundled.install; + return install === undefined ? undefined : yield* install.call(bundled, request); + } + const contributed = yield* gitCompositionInstallation(); + return { + ...contributed, + admissions: [ + ...(contributed.admissions ?? []), + (retained) => admitReplRepositoryHistory(retained), + ], + }; + }, + }; +} diff --git a/packages/cli/src/repl/repositories/pull-request-admission.ts b/packages/cli/src/repl/repositories/pull-request-admission.ts new file mode 100644 index 000000000..e2f7815b6 --- /dev/null +++ b/packages/cli/src/repl/repositories/pull-request-admission.ts @@ -0,0 +1,150 @@ +/** + * What authorizes one REPL entry to open or update a pull request. + * + * A pull request is a statement about a branch that already exists at the Git + * host, so an entry may only ask for one over a branch *this entry* published. + * Proving that takes the saved Push request together with its acknowledged + * outcome: the receipt alone says what the host holds and says nothing about + * which checkout asked for it, and a pull request opened on somebody else's + * branch would be publishing their work under this entry's name. + * + * The pairing is not performed here. `push-admission.ts` already walks this + * entry's segment, validates every saved request against its outcome and + * refuses a history it cannot account for; this reads what that walk validated. + * A second walk would be a second pairing algorithm that could disagree with + * the one admission already performed. + * + * ## What counts, and what does not + * + * The last successful acknowledged pair for this Repository, admitted origin, + * branch and destination ref decides, in physical journal outcome order. That + * pair must name the same resolved checkout root, the same common directory and + * the same commit the pull request resolved. Another checkout's publication + * cannot authorize it even at the same commit, another entry's cannot be + * borrowed because the walk refuses a foreign owner before this reader sees it, + * a failed outcome and a trailing pending request prove nothing, and an earlier + * exact pair cannot override a later publication of the same destination. + * + * Sibling coroutines restoring in another order change none of it: this is a + * reading of the acknowledged stream, not of a schedule, and no evidence list + * is accumulated in memory as execution proceeds. + * + * A saved pull-request request is evidence this proof already succeeded for + * that exact request. Recovering one validates its historical provenance rather + * than today's checkout, which is why recovery does not come back through here. + */ + +import type { Operation } from "effection"; +import { DurableContext } from "@executablemd/durable-streams"; +import { PullRequestAdmissionError } from "@executablemd/git"; +import { sameRepositoryIdentity } from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; +import { walkReplRetainedPublications, type ReplAcknowledgedPush } from "./push-admission.ts"; + +/** The branch, destination and commit a pull request needs a publication of. */ +export interface ReplPullRequestPublicationQuestion { + /** The authenticated Repository identity the upsert belongs to. */ + readonly repository: RepositoryIdentity; + /** The admitted credential-free locator the branch was published to. */ + readonly origin: string; + /** The named branch the head commit is on. */ + readonly branch: string; + /** The full destination ref the publication named. */ + readonly destinationRef: string; + /** The checkout the upsert resolved from. */ + readonly checkoutRoot: string; + /** That checkout's canonical common Git directory. */ + readonly commonDirectory: string; + /** The exact commit the pull request will name as its head. */ + readonly headSha: string; +} + +/** + * Prove this entry published the branch, or refuse before anything is opened. + * + * Reads this entry's own acknowledged history through the durable stream it is + * already executing against. Nothing is contacted, no checkout is inspected and + * no credential is read: this decides whether a request may be saved at all. + */ +export function* proveReplPullRequestPublication( + owner: string, + question: ReplPullRequestPublicationQuestion, +): Operation { + const context = yield* DurableContext.expect(); + + let publications: readonly ReplAcknowledgedPush[]; + try { + const retained = yield* context.stream.readAll(); + publications = (yield* walkReplRetainedPublications(owner, retained)).pushes; + } catch (error) { + // The walk refuses a history it cannot account for, and a history that + // cannot be read is not one a publication can be proven from. Nothing of + // what it held travels into this sentence. + // + // Which question the history failed does travel, as the cause. The walk's + // own refusals name conditions — `duplicate-outcome`, `foreign-owner` — + // and never the records they were decided from, so a reader learns what + // went wrong without the sentence carrying any of the history's content. + const refused = new PullRequestAdmissionError( + "unreadable-push-evidence", + "this entry's retained publication history could not be read, so whether the branch this " + + "pull request names was published by this entry is unknown.", + ); + refused.cause = error; + throw refused; + } + + // The last one wins. A supervised entry publishes one destination repeatedly, + // so an earlier exact pair followed by a later publication of the same + // destination describes a branch that has since moved on. + const relevant = publications.filter((pair) => addresses(pair, question)); + const last = relevant.at(-1); + if (last === undefined) { + throw new PullRequestAdmissionError( + "missing-push-evidence", + "this entry has no acknowledged publication of that branch to this repository's origin, " + + "so there is no branch at the Git host this entry may open a pull request over. Write " + + " for it in this entry first.", + ); + } + if (!produced(last, question)) { + throw new PullRequestAdmissionError( + "conflicting-push-evidence", + "this entry's most recent acknowledged publication of that branch names a different " + + "checkout or a different commit than this pull request resolved, so the two describe " + + "different work. Publish this checkout's current commit before asking for a pull " + + "request over it.", + ); + } +} + +function addresses( + pair: ReplAcknowledgedPush, + question: ReplPullRequestPublicationQuestion, +): boolean { + const inputs = pair.request.resolved.inputs; + return ( + sameRepositoryIdentity(inputs.repository, question.repository) && + pair.request.resolved.origin === question.origin && + inputs.branch === question.branch && + inputs.destinationRef === question.destinationRef + ); +} + +/** + * Whether that publication is the one this pull request's head came from. + * + * The checkout provenance and the commit together. A publication of the same + * destination from another checkout is somebody else's work in this entry, and + * one of an earlier commit describes a branch this head has moved past. + */ +function produced( + pair: ReplAcknowledgedPush, + question: ReplPullRequestPublicationQuestion, +): boolean { + return ( + pair.request.resolved.checkoutRoot === question.checkoutRoot && + pair.request.resolved.commonDirectory === question.commonDirectory && + pair.request.resolved.inputs.sourceCommit === question.headSha + ); +} diff --git a/packages/cli/src/repl/repositories/pull-request-records.ts b/packages/cli/src/repl/repositories/pull-request-records.ts new file mode 100644 index 000000000..4c3c03190 --- /dev/null +++ b/packages/cli/src/repl/repositories/pull-request-records.ts @@ -0,0 +1,416 @@ +/** + * What one REPL entry's `` asked for, on the wire. + * + * An entry that opens or updates a pull request writes this down *before* the + * Git host is contacted, and the external outcome is reconciled afterwards + * under the shared Git-host contract. Those are two records at two positions: + * this one says what was asked, and the Git-host record beside it says what the + * host did about it. Reopen the entry and the saved request is what the next + * attempt reconciles — the exact title, body, head and destination it + * originally named, however far the checkout has moved since. + * + * It is a request publication and nothing else. A record here is not evidence + * that anything was opened: an entry interrupted between this append and the + * host leaves exactly this record and no outcome, which is the window the + * reconciliation exists to close. It is also not publication evidence — what + * authorizes an upsert is this entry's own matching acknowledged Push, proven + * before this record is written. + * + * ## Three readings of one request + * + * `requested` is what the invocation observed: the place it authenticated and + * the content the document wrote, normalized. `received` is the same pair as it + * stood when the live dispatch reached this provider, so an intentional + * middleware transformation is visible rather than hidden — when nothing + * transformed them the two readings are equal and both are still written. + * `resolved` is the answer: the covering checkout, its common directory, the + * admitted credential-free origin and the complete pull-request inputs, + * including the head commit this attempt resolved. + * + * Only `requested` names the record. A replay matches the question, not the + * answer, so `received` and `resolved` are read back and held to each other + * rather than re-resolved: resolving them again would ask today's checkout what + * a saved request meant, and the head it answered with then is the head the + * host was asked about. + * + * ## What is deliberately absent + * + * No credential, endpoint, Git output, object source, selection token or + * publication flag. The body is verbatim and may be empty, because an entry + * that publishes a reflowed body publishes something nobody wrote. Host paths + * are kept as the bytes they arrived as, for the reason the Push request keeps + * them: an entry's paths are places on somebody's disk. + */ + +import type { Operation } from "effection"; +import type { Json } from "@executablemd/durable-streams"; +import { canonicalJson } from "@executablemd/workflow"; +import { members, text } from "@executablemd/git"; +import { admitLocator } from "@executablemd/git"; +import { + parsePullRequestInputs, + pullRequestInputsJson, + type PullRequestInputs, +} from "@executablemd/git"; +import { + parseRepositoryIdentity, + repositoryIdentityJson, + sameRepositoryIdentity, +} from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; +import { digest, REPL_REPOSITORY_VERSION } from "./records.ts"; + +export const REPL_REPOSITORY_PULL_REQUEST_REQUEST = "repl_repository_pull_request_request"; + +const PULL_REQUEST_REQUEST_PREFIX = "executablemd.repl.pull-request-request"; + +/** + * Where one pull request was asked for, as one reading of it. + * + * The same four facts the Push request carries, and for the same reason: the + * upsert belongs to the Repository the invocation authenticated, to the + * checkout that selection named, to the repository that checkout shares, and to + * the directory the element was written in. + */ +export interface ReplPullRequestPlace { + /** All six members of the authenticated Repository identity. */ + readonly repository: RepositoryIdentity; + /** The checkout root the authenticated selection named. */ + readonly selectionCheckout: string; + /** That selection's canonical common Git directory. */ + readonly commonDirectory: string; + /** The working directory exactly as it was received, uncanonicalized. */ + readonly workingDirectory: string; +} + +/** + * What the document asked for, normalized. + * + * `number` is a positive integer when the document named one and `null` when it + * asked for a pull request to exist — absence is `null` from here on, because a + * member that is sometimes missing is a second shape. `base` is the destination + * branch the invocation settled on, which is the Repository's pinned primary + * branch unless the document wrote one. + */ +export interface ReplPullRequestContent { + readonly number: number | null; + readonly title: string; + /** Verbatim, and may be empty. */ + readonly body: string; + readonly draft: boolean; + readonly base: string; +} + +/** One reading of a pull-request request: where, and what. */ +export interface ReplPullRequestAsked { + readonly place: ReplPullRequestPlace; + readonly pullRequest: ReplPullRequestContent; +} + +/** What routing answered, and the complete request it produced. */ +export interface ReplPullRequestResolution { + /** The checkout actually covering the received working directory. */ + readonly checkoutRoot: string; + /** That checkout's canonical common Git directory. */ + readonly commonDirectory: string; + /** The admitted credential-free locator this pull request belongs to. */ + readonly origin: string; + /** The complete inputs the Git-host effect acts on, head commit included. */ + readonly inputs: PullRequestInputs; +} + +/** One entry pull-request request, as a continuation reads it back. */ +export interface ReplPullRequestRequestRecord { + readonly version: typeof REPL_REPOSITORY_VERSION; + /** The entry whose retained work this is. */ + readonly owner: string; + /** The invocation inside that entry, as canonical core minted it. */ + readonly site: string; + readonly requested: ReplPullRequestAsked; + readonly received: ReplPullRequestAsked; + readonly resolved: ReplPullRequestResolution; +} + +/** What names one request: the owner, the site and the question it asked. */ +export interface ReplPullRequestRequestName { + readonly owner: string; + readonly site: string; + readonly requested: ReplPullRequestAsked; +} + +const RECORD_MEMBERS = ["version", "owner", "site", "requested", "received", "resolved"] as const; + +const ASKED_MEMBERS = ["place", "pullRequest"] as const; + +const PLACE_MEMBERS = [ + "repository", + "selectionCheckout", + "commonDirectory", + "workingDirectory", +] as const; + +const CONTENT_MEMBERS = ["number", "title", "body", "draft", "base"] as const; + +const RESOLUTION_MEMBERS = ["checkoutRoot", "commonDirectory", "origin", "inputs"] as const; + +/** + * The stable name one pull-request request is published under. + * + * A digest of the version, the owner, the site and every member of the + * question — the place that was authenticated and the content the document + * wrote. Two equal questions at two positions produce one name, which is + * allowed: telling them apart is the durable engine's own positional matching, + * and nothing here searches history by name. + * + * The freshly resolved head takes no part in it, and neither does anything else + * routing answered. A name derived from the answer would have to be recomputed + * to find the record, which means resolving a saved request again against a + * checkout that has since moved. + */ +export function replPullRequestRequestName(request: ReplPullRequestRequestName): Operation { + return digest([ + PULL_REQUEST_REQUEST_PREFIX, + REPL_REPOSITORY_VERSION, + request.owner, + request.site, + askedJson(request.requested), + ]); +} + +export function replPullRequestRequestJson(record: { + readonly owner: string; + readonly site: string; + readonly requested: ReplPullRequestAsked; + readonly received: ReplPullRequestAsked; + readonly resolved: ReplPullRequestResolution; +}): Json { + return { + version: REPL_REPOSITORY_VERSION, + owner: record.owner, + site: record.site, + requested: askedJson(record.requested), + received: askedJson(record.received), + resolved: { + checkoutRoot: record.resolved.checkoutRoot, + commonDirectory: record.resolved.commonDirectory, + origin: record.resolved.origin, + inputs: pullRequestInputsJson(record.resolved.inputs), + }, + }; +} + +/** + * The pull-request request this saved value describes, or `undefined`. + * + * Total, and exact about every member. Beyond the shape it refuses a request + * whose origin is not a locator this host would hand to Git, whose resolved + * inputs belong to a Repository other than the one the live dispatch received, + * and whose received content disagrees with the inputs that were resolved from + * it. Each of those would make a saved request name work this entry cannot + * account for. + * + * Whether the saved roots are places this entry holds is not asked here. That + * is a question about parsed placements and about this entry's own registry, + * and it is answered by the admission and the readback beside this codec — as + * is whether the admitted origin is the one the Repository was fingerprinted + * from, which needs a digest the runtime-named half of this package owns. + */ +export function parseReplPullRequestRequestRecord( + value: unknown, +): ReplPullRequestRequestRecord | undefined { + // Totally: enumeration and every property read are the value's own to + // refuse, and a refusal of any of them is the same answer. A revoked proxy + // and a throwing getter both mean this value describes no saved request. + try { + return readRequestRecord(value); + } catch { + return undefined; + } +} + +function readRequestRecord(value: unknown): ReplPullRequestRequestRecord | undefined { + const read = members(value, RECORD_MEMBERS); + if (read === undefined || read["version"] !== REPL_REPOSITORY_VERSION) { + return undefined; + } + const owner = text(read["owner"]); + const site = text(read["site"]); + const requested = parseAsked(read["requested"]); + const received = parseAsked(read["received"]); + if ( + owner === undefined || + site === undefined || + requested === undefined || + received === undefined + ) { + return undefined; + } + const resolved = parseResolution(read["resolved"], received); + if (resolved === undefined) { + return undefined; + } + return Object.freeze({ + version: REPL_REPOSITORY_VERSION, + owner, + site, + requested, + received, + resolved, + }); +} + +function parseAsked(value: unknown): ReplPullRequestAsked | undefined { + const read = members(value, ASKED_MEMBERS); + if (read === undefined) { + return undefined; + } + const place = parsePlace(read["place"]); + const pullRequest = parseContent(read["pullRequest"]); + if (place === undefined || pullRequest === undefined) { + return undefined; + } + return Object.freeze({ place, pullRequest }); +} + +function parsePlace(value: unknown): ReplPullRequestPlace | undefined { + const read = members(value, PLACE_MEMBERS); + if (read === undefined) { + return undefined; + } + const repository = parseRepositoryIdentity(read["repository"]); + const selectionCheckout = text(read["selectionCheckout"]); + const commonDirectory = text(read["commonDirectory"]); + const workingDirectory = text(read["workingDirectory"]); + if ( + repository === undefined || + selectionCheckout === undefined || + commonDirectory === undefined || + workingDirectory === undefined + ) { + return undefined; + } + return Object.freeze({ repository, selectionCheckout, commonDirectory, workingDirectory }); +} + +/** + * The content one reading asked for, or `undefined`. + * + * `body` crosses as itself, empty included, which is why it is read as a string + * rather than as nonempty text. `number` is a positive integer or `null` and + * nothing else: zero, a fraction and a negative all name no pull request. + */ +function parseContent(value: unknown): ReplPullRequestContent | undefined { + const read = members(value, CONTENT_MEMBERS); + if (read === undefined) { + return undefined; + } + const number = requestedNumber(read["number"]); + const title = text(read["title"]); + const body = read["body"]; + const draft = read["draft"]; + const base = text(read["base"]); + if (number === undefined || title === undefined || base === undefined) { + return undefined; + } + if (typeof body !== "string" || typeof draft !== "boolean") { + return undefined; + } + return Object.freeze({ number, title, body, draft, base }); +} + +/** A positive integer, or the `null` that asks for a pull request to exist. */ +function requestedNumber(value: unknown): number | null | undefined { + if (value === null) { + return null; + } + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + return undefined; + } + return value; +} + +/** + * What routing resolved, held to the reading it resolved from. + * + * The origin is admitted through the same closed grammar a document's locator + * passes, so a saved origin this host would not hand to Git is refused here. + * The inputs are held to the received reading twice over: to its Repository, + * because inputs naming another repository describe another entry's work, and + * to its content, because a saved request whose title, body, draft, number or + * destination disagrees with what was resolved from it would reconcile + * something the document never asked for. + */ +function parseResolution( + value: unknown, + received: ReplPullRequestAsked, +): ReplPullRequestResolution | undefined { + const read = members(value, RESOLUTION_MEMBERS); + if (read === undefined) { + return undefined; + } + const checkoutRoot = text(read["checkoutRoot"]); + const commonDirectory = text(read["commonDirectory"]); + const origin = text(read["origin"]); + const inputs = parsePullRequestInputs(read["inputs"]); + if ( + checkoutRoot === undefined || + commonDirectory === undefined || + origin === undefined || + inputs === undefined + ) { + return undefined; + } + if (admitLocator(origin) !== origin) { + return undefined; + } + if (!sameRepositoryIdentity(inputs.repository, received.place.repository)) { + return undefined; + } + if (!sameRequestedContent(received.pullRequest, inputs)) { + return undefined; + } + return Object.freeze({ checkoutRoot, commonDirectory, origin, inputs }); +} + +function sameRequestedContent(content: ReplPullRequestContent, inputs: PullRequestInputs): boolean { + return ( + content.number === inputs.number && + content.title === inputs.title && + content.body === inputs.body && + content.draft === inputs.draft && + content.base === inputs.baseBranch + ); +} + +function placeJson(place: ReplPullRequestPlace): Json { + return { + repository: repositoryIdentityJson(place.repository), + selectionCheckout: place.selectionCheckout, + commonDirectory: place.commonDirectory, + workingDirectory: place.workingDirectory, + }; +} + +function contentJson(content: ReplPullRequestContent): Json { + return { + number: content.number, + title: content.title, + body: content.body, + draft: content.draft, + base: content.base, + }; +} + +function askedJson(asked: ReplPullRequestAsked): Json { + return { + place: placeJson(asked.place), + pullRequest: contentJson(asked.pullRequest), + }; +} + +export function sameReplPullRequestAsked( + left: ReplPullRequestAsked, + right: ReplPullRequestAsked, +): boolean { + return canonicalJson(askedJson(left)) === canonicalJson(askedJson(right)); +} diff --git a/packages/cli/src/repl/repositories/push-admission.ts b/packages/cli/src/repl/repositories/push-admission.ts new file mode 100644 index 000000000..2509273b2 --- /dev/null +++ b/packages/cli/src/repl/repositories/push-admission.ts @@ -0,0 +1,707 @@ +/** + * Whether one entry's retained external history may be continued at all. + * + * An entry retains two external protocols: the branch it published, and the + * pull request it asked for over that branch. One walk admits both, because the + * two interleave in one stream and a second reader would be a second pairing + * algorithm that could disagree with this one. + * + * Three questions, and only three. **Integrity**: every saved request parses, + * states this version, and is published under the name its own question + * produces. **Ownership**: every request and every outcome names this entry's + * owner, because the owner is what the durable name is derived from — a record + * written under another one would replay under a name this entry never + * presented. **Pairing**: an outcome stands at the position after the request it + * answers, carries that request's own fingerprint, and reads back as the result + * that kind of work produces. + * + * Nothing else. No checkout is inspected, no origin is contacted, no object is + * read, no path is resolved and no result is handed out: replaying a completed + * operation is the durable engine's own positional matching, and this runs + * before any of it. Whether the branch a record names is still where it was is + * not this reader's question, and not a question a continuation asks. + * + * ## A trailing request is history, not damage + * + * An entry interrupted between publishing its request and obtaining the host's + * answer leaves exactly one unanswered request. That is the state this whole + * feature exists for, so it is admitted: the continuation reconciles that saved + * request rather than resolving a new one. What is refused is an outcome with no + * request before it, a request answered twice, and a receipt this build cannot + * read — each of which would let a continuation believe something was published + * that this history cannot account for. + * + * ## Coroutines are independent + * + * Pairing is per coroutine, because a position belongs to one. Two `` + * branches each publishing a branch interleave in the stream and neither one's + * outcome answers the other's request. Nothing is matched by searching the + * stream for a name: equal requests at two positions are two occurrences, and a + * reader that deduplicated them would refuse a document that is allowed. + */ + +import { StaleInputError } from "@executablemd/durable-streams"; +import type { DurableEvent, Json, Result, Yield } from "@executablemd/durable-streams"; +import type { Operation } from "effection"; +import { canonicalJson } from "@executablemd/workflow"; +import { + GIT_PUSH, + gitPushInputsJson, + gitPushNaturalKeyJson, + parseGitPushRecord, + pushExpectation, + type GitPushInputs, +} from "@executablemd/git"; +import { sameRepositoryIdentity } from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; +import { GIT_HOST_EFFECT } from "@executablemd/git"; +import { gitHostRequestFingerprint, parseGitHostReconciliationRecord } from "@executablemd/git"; +import type { GitHostReconciliationRecord } from "@executablemd/git"; +import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT } from "./placement-records.ts"; +import { + parseReplPushRequestRecord, + replPushRequestName, + REPL_REPOSITORY_PUSH_REQUEST, + type ReplPushRequestRecord, +} from "./push-records.ts"; +import { + parseReplPullRequestRequestRecord, + replPullRequestRequestName, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, + type ReplPullRequestRequestRecord, +} from "./pull-request-records.ts"; +import { + PULL_REQUEST, + parsePullRequestRecord, + pullRequestInputsJson, + pullRequestNaturalKey, + pullRequestNaturalKeyJson, + type PullRequestInputs, +} from "@executablemd/git"; +import { REPL_REPOSITORY_VERSION, statedReplRecordVersion } from "./records.ts"; + +/** Why a publication history cannot be continued. */ +export type ReplPushRefusal = + /** A Push record of this feature's type that this version cannot read. */ + | "malformed-record" + /** A record stating a version this host does not implement. */ + | "unknown-version" + /** A record whose name is not the name of the request it carries. */ + | "request-fingerprint" + /** A record naming a history that is not this entry's. */ + | "foreign-owner" + /** A saved request naming a checkout this entry holds no placement for. */ + | "unknown-placement" + /** A Git-host outcome standing where no request asked for one. */ + | "orphan-outcome" + /** A second outcome for a request one already answered. */ + | "duplicate-outcome" + /** A settled outcome that is not the result a Push produces. */ + | "malformed-receipt"; + +/** + * A retained publication history this entry cannot continue from. + * + * Fatal by construction: it extends `StaleInputError`, so it ends the attempt + * rather than becoming a value a document can print away and carry on past. + */ +export class ReplPushHistoryError extends StaleInputError { + override name = "ReplPushHistoryError"; + + readonly reason: ReplPushRefusal; + + constructor(reason: ReplPushRefusal, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** + * One place this entry holds, as parsed facts alone. + * + * What a saved request is checked against, on both paths and with the same + * arithmetic: during admission these come from the placement records the + * snapshot holds, and at an invocation they come from the private registry this + * execution rebuilt. Neither reading resolves a path or opens anything. + */ +export interface ReplPushPlacement { + readonly identity: RepositoryIdentity; + readonly checkoutRoot: string; + readonly commonDirectory: string; +} + +/** + * One place a saved request names: a Repository, and the checkout it sits in. + * + * Three of these are read off every saved request, and each record kind reads + * them out of its own shape. What is done with them is {@link placesHeld}. + */ +type ReplRequestPlace = readonly [RepositoryIdentity, string, string]; + +/** + * Whether every place a request named is one these placements hold. + * + * One placement rule, over whichever readings a record supplied: a place is + * held when some placement sits at that exact checkout root and common + * directory *and* is a placement of the Repository the record names. Neither + * reading resolves a path nor opens anything. + */ +function placesHeld( + places: readonly ReplRequestPlace[], + placements: readonly ReplPushPlacement[], +): boolean { + return places.every(([identity, checkoutRoot, commonDirectory]) => + placements.some( + (placement) => + placement.checkoutRoot === checkoutRoot && + placement.commonDirectory === commonDirectory && + sameRepositoryIdentity(placement.identity, identity), + ), + ); +} + +/** + * Whether every place a saved request names is one these placements hold. + * + * Three readings and three questions. The selection the invocation + * authenticated, the selection the live dispatch received, and the checkout + * routing resolved all have to be placements of the Repository the record + * names — a request whose saved root belongs to nothing this entry holds does + * not say where the publication was asked for. + */ +export function replPushPlacesHeld( + record: ReplPushRequestRecord, + placements: readonly ReplPushPlacement[], +): boolean { + const places: readonly ReplRequestPlace[] = [ + [ + record.requested.repository, + record.requested.selectionCheckout, + record.requested.commonDirectory, + ], + [ + record.received.repository, + record.received.selectionCheckout, + record.received.commonDirectory, + ], + [record.received.repository, record.resolved.checkoutRoot, record.resolved.commonDirectory], + ]; + return placesHeld(places, placements); +} + +/** The Git-host request one saved Push request asks the worker to reconcile. */ +export function replPushEffectRequest(inputs: GitPushInputs): { + readonly kind: string; + readonly inputs: Json; + readonly naturalKey: Json; +} { + return { + kind: GIT_PUSH, + inputs: gitPushInputsJson(inputs), + naturalKey: gitPushNaturalKeyJson({ + repository: inputs.repository, + remote: inputs.remote, + destinationRef: inputs.destinationRef, + }), + }; +} + +/** The name the worker publishes one saved request's outcome under. */ +export function replPushOutcomeName(record: ReplPushRequestRecord): Operation { + return gitHostRequestFingerprint({ + identity: { runId: record.owner, expansionId: record.site }, + ...replPushEffectRequest(record.resolved.inputs), + }); +} + +/** The Git-host request one saved pull-request request asks to reconcile. */ +export function replPullRequestEffectRequest(inputs: PullRequestInputs): { + readonly kind: string; + readonly inputs: Json; + readonly naturalKey: Json; +} { + return { + kind: PULL_REQUEST, + inputs: pullRequestInputsJson(inputs), + naturalKey: pullRequestNaturalKeyJson(pullRequestNaturalKey(inputs)), + }; +} + +/** The name the worker publishes one saved pull-request outcome under. */ +export function replPullRequestOutcomeName( + record: ReplPullRequestRequestRecord, +): Operation { + return gitHostRequestFingerprint({ + identity: { runId: record.owner, expansionId: record.site }, + ...replPullRequestEffectRequest(record.resolved.inputs), + }); +} + +/** + * One saved request waiting for the host's answer, of either retained kind. + * + * An entry retains two external protocols now, and a failed Git-host envelope + * names neither: a refusal retains no request of its own. So the queue holds + * what was actually asked, in order, and an outcome is paired with the request + * standing before it rather than with whichever protocol a reader assumed. + */ +type ReplPendingRequest = + | { readonly kind: "push"; readonly record: ReplPushRequestRecord } + | { readonly kind: "pull-request"; readonly record: ReplPullRequestRequestRecord }; + +/** The name the worker would publish this pending request's outcome under. */ +function pendingOutcomeName(pending: ReplPendingRequest): Operation { + return pending.kind === "push" + ? replPushOutcomeName(pending.record) + : replPullRequestOutcomeName(pending.record); +} + +/** The Git-host kind this pending request asks the worker to reconcile. */ +function pendingKind(pending: ReplPendingRequest): string { + return pending.kind === "push" ? GIT_PUSH : PULL_REQUEST; +} + +/** The site the pending request was made at. */ +function pendingSite(pending: ReplPendingRequest): string { + return pending.record.site; +} + +/** + * One acknowledged successful publication, with the provenance that produced it. + * + * The pair rather than the receipt alone, because a receipt says what the host + * holds and says nothing about which checkout asked for it. What authorizes an + * entry's pull request is the saved request beside the outcome: its resolved + * checkout root, common directory and exact head commit. + */ +export interface ReplAcknowledgedPush { + readonly request: ReplPushRequestRecord; + readonly record: GitHostReconciliationRecord; +} + +/** + * What one entry's retained history holds, once it has been admitted. + * + * `pushes` is in physical journal outcome order, which is the order the + * publications actually acknowledged in. Sibling coroutines restoring in + * another order cannot change it, because this is a reading of the stream + * rather than of a schedule. + */ +export interface ReplRetainedPublications { + readonly pushes: readonly ReplAcknowledgedPush[]; +} + +/** The three words a Push outcome may fail under, as the journal holds them. */ +const CLOSED_FAILURES: ReadonlySet = new Set([ + "GitHostConflictError", + "GitHostAmbiguousError", + "GitHostUnavailableError", +]); + +/** + * Hold this entry's retained publication history to what this host can read. + * + * `retained` is the exact snapshot canonical execution admitted, which for an + * entry is its own segment. Records of other features and other durable types + * are not this reader's business and are passed over. + */ +export function* admitReplPushHistory( + owner: string, + retained: readonly DurableEvent[], +): Operation { + yield* walkReplRetainedPublications(owner, retained); +} + +/** + * The same admission, answering with the publications it validated. + * + * One walk serves both consumers deliberately. An entry's pull request is + * authorized by its own acknowledged Push, and a second reader of this history + * would be a second pairing algorithm that could disagree with the one + * admission already performed. So the proof reads what admission validated, + * and nothing is paired twice. + */ +export function* walkReplRetainedPublications( + owner: string, + retained: readonly DurableEvent[], +): Operation { + /** Every place a successful placement record before this point described. */ + const placements: ReplPushPlacement[] = []; + /** Per coroutine, the requests no outcome has answered yet. */ + const pending = new Map(); + /** Per coroutine, whether an outcome has already answered something. */ + const answered = new Map(); + /** Acknowledged successful publications, in physical outcome order. */ + const pushes: ReplAcknowledgedPush[] = []; + + for (const event of retained) { + if (event.type !== "yield") { + continue; + } + const type = event.description.type; + if (type === REPL_REPOSITORY_PLACEMENT) { + remember(placements, event); + continue; + } + if (type === REPL_REPOSITORY_PUSH_REQUEST) { + const record = yield* admitRequest(owner, event, placements); + queue(pending, event.coroutineId).push({ kind: "push", record }); + continue; + } + if (type === REPL_REPOSITORY_PULL_REQUEST_REQUEST) { + const record = yield* admitPullRequestRequest(owner, event, placements); + queue(pending, event.coroutineId).push({ kind: "pull-request", record }); + continue; + } + if (type !== GIT_HOST_EFFECT) { + continue; + } + const acknowledged = yield* admitOutcome(owner, event, pending, answered); + if (acknowledged !== undefined) { + pushes.push(acknowledged); + } + } + return { pushes }; +} + +/** Remember one successful placement's parsed facts, or pass over the record. */ +function remember(placements: ReplPushPlacement[], event: DurableEvent): void { + if (event.type !== "yield" || event.result.status !== "ok") { + return; + } + // The placement admission beside this one owns whether a placement record is + // readable at all. What is wanted here is only the places a readable one + // describes, so an unreadable record contributes nothing and is refused + // there rather than twice. + const record = parseReplPlacementRecord(event.result.value); + if (record === undefined || !record.outcome.ok) { + return; + } + const placement = record.outcome.value; + placements.push({ + identity: placement.identity, + checkoutRoot: placement.checkoutRoot, + commonDirectory: placement.commonDirectory, + }); +} + +function queue( + queues: Map, + coroutineId: string, +): ReplPendingRequest[] { + const existing = queues.get(coroutineId); + if (existing !== undefined) { + return existing; + } + const created: ReplPendingRequest[] = []; + queues.set(coroutineId, created); + return created; +} + +/** One saved request, held to this version, this owner, its name and its places. */ +function* admitRequest( + owner: string, + event: Yield, + placements: readonly ReplPushPlacement[], +): Operation { + if (event.result.status !== "ok") { + // A request publication has no failure side: its outcome is the appended + // record itself, so a failed envelope under this type is not a record this + // host ever wrote. + throw malformed(); + } + const payload = event.result.value; + const stated = statedReplRecordVersion(payload); + if (stated !== undefined && stated !== REPL_REPOSITORY_VERSION) { + throw refusal( + "unknown-version", + `a ${REPL_REPOSITORY_PUSH_REQUEST} record states version ${stated}, and this host ` + + `implements version ${REPL_REPOSITORY_VERSION} only. Nothing is assumed about what the ` + + "rest of it means.", + ); + } + const record = parseReplPushRequestRecord(payload); + if (record === undefined) { + throw malformed(); + } + if (record.owner !== owner) { + throw refusal( + "foreign-owner", + "a saved publication request in this entry's history was written under another owner, so " + + "the branch it asked to publish was not asked for by this entry and its request is not " + + "one this entry can present again.", + ); + } + if (event.description.name !== (yield* replPushRequestName(record))) { + throw refusal( + "request-fingerprint", + "a saved publication request is journaled under a name that is not the name its own " + + "request produces, so the record and the publication it claims to be do not match.", + ); + } + if (!replPushPlacesHeld(record, placements)) { + throw refusal( + "unknown-placement", + "a saved publication request names a checkout no selection before it placed, so this " + + "history does not say where the branch it published came from.", + ); + } + return record; +} + +/** + * One saved pull-request request, on the same terms as a saved Push. + * + * Its own name, this owner, this version and places this entry holds. The codec + * beside it has already held the saved content to the inputs that were resolved + * from it; what is asked here is whether this history says where the upsert was + * asked for. + */ +function* admitPullRequestRequest( + owner: string, + event: Yield, + placements: readonly ReplPushPlacement[], +): Operation { + if (event.result.status !== "ok") { + // A request publication has no failure side, for the reason a saved Push + // has none: its outcome is the appended record itself. + throw pullRequestMalformed(); + } + const payload = event.result.value; + const stated = statedReplRecordVersion(payload); + if (stated !== undefined && stated !== REPL_REPOSITORY_VERSION) { + throw refusal( + "unknown-version", + `a ${REPL_REPOSITORY_PULL_REQUEST_REQUEST} record states version ${stated}, and this host ` + + `implements version ${REPL_REPOSITORY_VERSION} only. Nothing is assumed about what the ` + + "rest of it means.", + ); + } + const record = parseReplPullRequestRequestRecord(payload); + if (record === undefined) { + throw pullRequestMalformed(); + } + if (record.owner !== owner) { + throw refusal( + "foreign-owner", + "a saved pull-request request in this entry's history was written under another owner, so " + + "what it asked the Git host for was not asked for by this entry.", + ); + } + if (event.description.name !== (yield* replPullRequestRequestName(record))) { + throw refusal( + "request-fingerprint", + "a saved pull-request request is journaled under a name that is not the name its own " + + "request produces, so the record and the request it describes do not match.", + ); + } + if (!replPullRequestPlacesHeld(record, placements)) { + throw refusal( + "unknown-placement", + "a saved pull-request request names a checkout no selection before it placed, so this " + + "history does not say where the pull request was asked for.", + ); + } + return record; +} + +/** + * Whether every place a saved pull-request request names is one this entry + * holds. + * + * The same three readings the Push request is held to: what the invocation + * authenticated, what the live dispatch received, and what routing resolved. + */ +export function replPullRequestPlacesHeld( + record: ReplPullRequestRequestRecord, + placements: readonly ReplPushPlacement[], +): boolean { + const places: readonly ReplRequestPlace[] = [ + [ + record.requested.place.repository, + record.requested.place.selectionCheckout, + record.requested.place.commonDirectory, + ], + [ + record.received.place.repository, + record.received.place.selectionCheckout, + record.received.place.commonDirectory, + ], + [ + record.received.place.repository, + record.resolved.checkoutRoot, + record.resolved.commonDirectory, + ], + ]; + return placesHeld(places, placements); +} + +/** + * One Git-host outcome, paired with the request it answers. + * + * Only this entry's own retained external work is this reader's business. A + * successful Git-host record of a kind this entry does not retain belongs to a + * protocol this feature does not own and is passed over; one naming another + * owner is refused, because an entry's segment holds its own work and borrowing + * somebody else's receipt is exactly what must not be possible. + * + * **A failed envelope names no kind, so the request before it says what it + * answers.** A refusal retains no request of its own, and an entry now retains + * two external protocols — so reading every failure as a publication would + * consume a pull request's refusal as a Push. The pending request standing at + * this coroutine's position is what the failure answers, whichever kind it is, + * and the name it is journaled under still has to be the one that request + * produces. A successful envelope is held to its own stated kind as well: an + * answer of one kind standing where the other was asked for describes neither. + * + * Answers with the acknowledged pair when this outcome is a successful + * publication, because that is what authorizes an entry's pull request. + */ +function* admitOutcome( + owner: string, + event: Yield, + pending: Map, + answered: Map, +): Operation { + const settled = event.result.status === "ok" ? successOf(event.result.value) : undefined; + if (event.result.status === "ok" && settled === undefined) { + // A successful Git-host envelope this host cannot read as a reconciliation + // record says nothing trustworthy about external work. + throw refusal( + "malformed-receipt", + "a Git-host record in this entry's history is not one this host can read, so what it says " + + "about work already published is unknown.", + ); + } + if ( + settled !== undefined && + settled.request.kind !== GIT_PUSH && + settled.request.kind !== PULL_REQUEST + ) { + // Another external protocol's record. This reader owns the two kinds an + // entry retains, and a well-formed record of any other passes over. + return undefined; + } + if (settled !== undefined && settled.request.identity.runId !== owner) { + throw refusal( + "foreign-owner", + "a Git-host outcome in this entry's history was reconciled under another owner, so the " + + "work it describes was not asked for by this entry.", + ); + } + + const queued = pending.get(event.coroutineId) ?? []; + const request = queued.pop(); + if (request === undefined) { + const spent = answered.get(event.coroutineId) ?? []; + throw spent.length > 0 + ? refusal( + "duplicate-outcome", + "a saved request in this entry's history is answered by more than one Git-host " + + "outcome, so this history does not say which of them describes what was done.", + ) + : refusal( + "orphan-outcome", + "a Git-host outcome in this entry's history stands where no saved request asked for " + + "one, so this history does not say what was asked of the Git host.", + ); + } + queue(answered, event.coroutineId).push(request); + + if (settled !== undefined && settled.request.kind !== pendingKind(request)) { + throw refusal( + "malformed-receipt", + "a Git-host outcome describes a different kind of external work than the saved request " + + "standing before it asked for, so the two do not describe one effect.", + ); + } + if (event.description.name !== (yield* pendingOutcomeName(request))) { + throw refusal( + "request-fingerprint", + "a Git-host outcome is journaled under a name that is not the name the request before " + + "it produces, so the outcome and the request it answers do not match.", + ); + } + if (settled === undefined) { + const failed = failureOf(event.result); + if (failed === undefined || !CLOSED_FAILURES.has(failed)) { + throw refusal( + "malformed-receipt", + "a refused Git-host effect is recorded under a condition this host has no word for, so " + + "what it says about the work is unknown.", + ); + } + return undefined; + } + if (settled.request.identity.expansionId !== pendingSite(request)) { + throw refusal( + "foreign-owner", + "a Git-host outcome names an invocation other than the one the request before it was " + + "made at, so the two describe different effects.", + ); + } + const asked = + request.kind === "push" + ? replPushEffectRequest(request.record.resolved.inputs) + : replPullRequestEffectRequest(request.record.resolved.inputs); + if ( + canonicalJson(settled.request.inputs) !== canonicalJson(asked.inputs) || + canonicalJson(settled.request.naturalKey) !== canonicalJson(asked.naturalKey) + ) { + throw refusal( + "malformed-receipt", + "a Git-host outcome describes work other than the one the request before it asked for, " + + "so this history does not say what was done.", + ); + } + if (request.kind === "push") { + if ( + parseGitPushRecord(settled, pushExpectation(request.record.resolved.inputs)) === undefined + ) { + throw refusal( + "malformed-receipt", + "a publication outcome holds a result that is not the one a Push of that branch " + + "produces, so what it says was published is unknown.", + ); + } + return { request: request.record, record: settled }; + } + if (parsePullRequestRecord(settled, request.record.resolved.inputs) === undefined) { + throw refusal( + "malformed-receipt", + "a pull-request outcome holds a result that is not the one an upsert of those inputs " + + "produces, so what it says about the pull request is unknown.", + ); + } + return undefined; +} + +/** The reconciliation record this retained value describes, or `undefined`. */ +function successOf(value: unknown): GitHostReconciliationRecord | undefined { + return parseGitHostReconciliationRecord(value); +} + +/** The closed name a failed envelope carries, or `undefined` when it carries none. */ +function failureOf(result: Result): string | undefined { + return result.status === "err" ? result.error.name : undefined; +} + +function pullRequestMalformed(): ReplPushHistoryError { + return refusal( + "malformed-record", + `a ${REPL_REPOSITORY_PULL_REQUEST_REQUEST} record in this entry's history is not one this ` + + "host can read, so what it says about work already asked for is unknown.", + ); +} + +function malformed(): ReplPushHistoryError { + return refusal( + "malformed-record", + `a ${REPL_REPOSITORY_PUSH_REQUEST} record in this entry's history is not one this host can ` + + "read, so what it says about work already asked for is unknown.", + ); +} + +function refusal(reason: ReplPushRefusal, sentence: string): ReplPushHistoryError { + return new ReplPushHistoryError(reason, sentence); +} diff --git a/packages/git/src/retained-repository/push-records.ts b/packages/cli/src/repl/repositories/push-records.ts similarity index 97% rename from packages/git/src/retained-repository/push-records.ts rename to packages/cli/src/repl/repositories/push-records.ts index 7a9ae85f4..80c8ac905 100644 --- a/packages/git/src/retained-repository/push-records.ts +++ b/packages/cli/src/repl/repositories/push-records.ts @@ -45,19 +45,15 @@ import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { canonicalJson } from "@executablemd/workflow"; -import { members, text } from "../composition/parse.ts"; -import { admitLocator } from "../composition/locator.ts"; -import { - gitPushInputsJson, - parseGitPushInputs, - type GitPushInputs, -} from "../composition/git-push-records.ts"; +import { members, text } from "@executablemd/git"; +import { admitLocator } from "@executablemd/git"; +import { gitPushInputsJson, parseGitPushInputs, type GitPushInputs } from "@executablemd/git"; import { parseRepositoryIdentity, repositoryIdentityJson, sameRepositoryIdentity, - type RepositoryIdentity, -} from "../composition/selection.ts"; +} from "@executablemd/git"; +import { type RepositoryIdentity } from "@executablemd/git"; import { digest, REPL_REPOSITORY_VERSION } from "./records.ts"; /** The durable type one entry Push request is published under. */ diff --git a/packages/git/src/retained-repository/records.ts b/packages/cli/src/repl/repositories/records.ts similarity index 99% rename from packages/git/src/retained-repository/records.ts rename to packages/cli/src/repl/repositories/records.ts index 2890b4bb7..e9f328074 100644 --- a/packages/git/src/retained-repository/records.ts +++ b/packages/cli/src/repl/repositories/records.ts @@ -55,7 +55,7 @@ import { parseFileWriteSuccess, } from "@executablemd/runtime"; import type { FilesErrorData, FileWriteSuccess } from "@executablemd/runtime"; -import { members } from "../composition/parse.ts"; +import { members } from "@executablemd/git"; /** The record shape this host writes and the only one it reads. */ export const REPL_REPOSITORY_VERSION = 1; diff --git a/packages/cli/src/run-profile.ts b/packages/cli/src/run-profile.ts index 52afe6bb5..f80ee8929 100644 --- a/packages/cli/src/run-profile.ts +++ b/packages/cli/src/run-profile.ts @@ -31,6 +31,10 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; import { cwd } from "@executablemd/runtime"; import { gitPlugin, gitPluginDeclaresFor } from "@executablemd/git"; +import { + replRepositoryPlugin, + replRepositoryPluginDeclaresFor, +} from "./repl/repositories/plugin.ts"; import type { Plugin } from "@executablemd/core/api"; import { loadPlugins, type PluginModuleLoader } from "./plugin-loader.ts"; import type { PluginSelection } from "./plugin-selection.ts"; @@ -45,7 +49,7 @@ import type { PluginSelection } from "./plugin-selection.ts"; export const BUNDLED_SELECTOR = "git"; /** The Plugin every run-profile command begins with, where the host states none. */ -export const BUNDLED_PLUGIN: Plugin = gitPlugin; +export const BUNDLED_PLUGIN: Plugin = replRepositoryPlugin(gitPlugin); /** * The bundled Plugin this host runs with. @@ -88,7 +92,12 @@ export const BundledRunPlugin: Context = createContext( * itself rather than inheriting it. */ export function carriesBundledPlugin(selection: PluginSelection): boolean { - return gitPluginDeclaresFor({ command: selection.command, args: selection.args }); + // `repl` is this command's own: Git no longer declares for it, because what a + // REPL entry retains is this package's record rather than that package's. + return ( + replRepositoryPluginDeclaresFor(selection.command) || + gitPluginDeclaresFor({ command: selection.command, args: selection.args }) + ); } /** diff --git a/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json b/packages/cli/tests/fixtures/retained-repository-history/h1-push-completed/journal.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json rename to packages/cli/tests/fixtures/retained-repository-history/h1-push-completed/journal.json diff --git a/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/source.md b/packages/cli/tests/fixtures/retained-repository-history/h1-push-completed/source.md similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h1-push-completed/source.md rename to packages/cli/tests/fixtures/retained-repository-history/h1-push-completed/source.md diff --git a/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json b/packages/cli/tests/fixtures/retained-repository-history/h2-push-pending/journal.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json rename to packages/cli/tests/fixtures/retained-repository-history/h2-push-pending/journal.json diff --git a/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/source.md b/packages/cli/tests/fixtures/retained-repository-history/h2-push-pending/source.md similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h2-push-pending/source.md rename to packages/cli/tests/fixtures/retained-repository-history/h2-push-pending/source.md diff --git a/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json b/packages/cli/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json rename to packages/cli/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json diff --git a/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md b/packages/cli/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md rename to packages/cli/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md diff --git a/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json b/packages/cli/tests/fixtures/retained-repository-history/h4-push-failed/journal.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json rename to packages/cli/tests/fixtures/retained-repository-history/h4-push-failed/journal.json diff --git a/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/source.md b/packages/cli/tests/fixtures/retained-repository-history/h4-push-failed/source.md similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h4-push-failed/source.md rename to packages/cli/tests/fixtures/retained-repository-history/h4-push-failed/source.md diff --git a/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json b/packages/cli/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json rename to packages/cli/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json diff --git a/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/source.md b/packages/cli/tests/fixtures/retained-repository-history/h5-issue-completed/source.md similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/source.md rename to packages/cli/tests/fixtures/retained-repository-history/h5-issue-completed/source.md diff --git a/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json b/packages/cli/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json rename to packages/cli/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json diff --git a/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md b/packages/cli/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md rename to packages/cli/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md diff --git a/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json b/packages/cli/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json rename to packages/cli/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json diff --git a/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/source.md b/packages/cli/tests/fixtures/retained-repository-history/h7-issue-failed/source.md similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/source.md rename to packages/cli/tests/fixtures/retained-repository-history/h7-issue-failed/source.md diff --git a/packages/git/tests/fixtures/retained-repository-history/receipt.json b/packages/cli/tests/fixtures/retained-repository-history/receipt.json similarity index 100% rename from packages/git/tests/fixtures/retained-repository-history/receipt.json rename to packages/cli/tests/fixtures/retained-repository-history/receipt.json diff --git a/packages/cli/tests/repl-boundaries.test.ts b/packages/cli/tests/repl-boundaries.test.ts index b532c26e9..9c3971f94 100644 --- a/packages/cli/tests/repl-boundaries.test.ts +++ b/packages/cli/tests/repl-boundaries.test.ts @@ -815,3 +815,107 @@ describe("REPL command: what runs before the host is reached", () => { expect(ran.installed).toBe(1); }); }); + +/** + * Where this command's own repository policy may reach Git. + * + * The relocation put the entry's grammar, owner policy and record codecs on + * this side of a package boundary. What makes that boundary real is that the + * code here — production *and* the fixtures that drive it — reaches Git only + * through its published entrypoints. A relative path into `packages/git/src` + * resolves and typechecks, which is precisely why it has to be checked: it + * would quietly restore the dependency the move exists to remove, and would + * hand a fixture the private host, session and credential surfaces the + * amendment withheld. + * + * Test *support* on Git's own side is a different thing and is allowed: a + * module under `packages/git/tests/` is that package's own fixture code, free + * to reach its own implementation, and a caller consuming it is consuming a + * fixture rather than an implementation. + */ +const PUBLISHED_ROUTES: readonly string[] = [ + "@executablemd/git", + "@executablemd/git/api", + "@executablemd/git/deno", + "@executablemd/git/credential-helper", +]; + +/** Every module of this command's repository policy, production and fixture. */ +function* repositoryModules(): Operation { + const found: string[] = []; + const walk = function* (directory: string): Operation { + for (const entry of yield* until(readdir(directory, { withFileTypes: true }))) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + yield* walk(path); + continue; + } + if (entry.name.endsWith(".ts")) { + found.push(path); + } + } + }; + yield* walk(join(REPL, "repositories")); + for (const name of yield* until(readdir(join(CLI, "tests")))) { + // This checker itself is excluded: it holds a deliberate counterexample of + // the import it forbids, which is what keeps the rule from going vacuous. + if (name === "repl-boundaries.test.ts") { + continue; + } + if (name.startsWith("retained-repository-") && name.endsWith(".ts")) { + found.push(join(CLI, "tests", name)); + } + if (name.startsWith("repl-") && name.endsWith(".test.ts")) { + found.push(join(CLI, "tests", name)); + } + } + found.push(join(CLI, "tests", "support", "repl-observations.ts")); + return found.sort(); +} + +/** Every Git specifier one module imports from. */ +function gitSpecifiers(source: string): string[] { + const found: string[] = []; + for (const statement of source.matchAll(/from\s*"([^"]+)"/g)) { + const specifier = statement[1] ?? ""; + if (specifier.includes("@executablemd/git") || /(^|\/)git\/src\//.test(specifier)) { + found.push(specifier); + } + } + return found; +} + +describe("REPL boundaries: how this command's repository policy reaches Git", () => { + it("S6: reaches Git only through its published entrypoints", function* () { + const modules = yield* repositoryModules(); + // Non-vacuity: an empty walk would make every module below innocent, and a + // corpus that reached Git nowhere at all would too. + expect(modules.length > 20).toBe(true); + let reached = 0; + const violations: string[] = []; + for (const path of modules) { + for (const specifier of gitSpecifiers(yield* code(path))) { + reached += 1; + if (!PUBLISHED_ROUTES.includes(specifier)) { + violations.push(`${path.replace(CLI, "")} imports ${specifier}`); + } + } + } + expect(reached > 20).toBe(true); + expect(violations).toEqual([]); + }); + + it("S6: the checker rejects a relative reach into the package's source", function* () { + // The same walk over a module that does what this rule forbids, so a rule + // that stopped catching anything is a failure here rather than a silent + // pass above. + const planted = 'import { gitSession } from "../../git/src/deno/composition/git.ts";'; + expect(gitSpecifiers(planted)).toEqual(["../../git/src/deno/composition/git.ts"]); + expect(gitSpecifiers(planted).every((one) => PUBLISHED_ROUTES.includes(one))).toBe(false); + // And it still admits the four published routes and Git's own fixtures. + for (const route of PUBLISHED_ROUTES) { + expect(gitSpecifiers(`import { thing } from "${route}";`)).toEqual([route]); + } + expect(gitSpecifiers('import { probe } from "../../git/tests/support/github.ts";')).toEqual([]); + }); +}); diff --git a/packages/cli/tests/repl-git-observations.test.ts b/packages/cli/tests/repl-git-observations.test.ts index 2f06a7826..81026c1a3 100644 --- a/packages/cli/tests/repl-git-observations.test.ts +++ b/packages/cli/tests/repl-git-observations.test.ts @@ -30,19 +30,14 @@ import { API, useHostFiles } from "@executablemd/runtime"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; import { Execution } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; -import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; -import type { - GitBytes, - GitInvocation, - GitOutcome, - RepositoryHost, -} from "../../git/src/deno/composition/host.ts"; -import { UNAUTHENTICATED } from "../../git/src/deno/composition/authentication.ts"; -import type { GitAuthenticationSession } from "../../git/src/deno/composition/authentication.ts"; -import { gitSession } from "../../git/src/deno/composition/git.ts"; -import { useReplRepositoryRecording } from "../../git/src/deno/retained-repository/provider.ts"; -import { gitPlugin } from "../../git/src/plugin.ts"; -import { REPL_REPOSITORY_OBSERVATION } from "../../git/src/retained-repository/observation-records.ts"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import { runGit } from "./support/fixture-git.ts"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import type { GitObservationRequest } from "@executablemd/git/api"; +import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import { gitPlugin } from "@executablemd/git"; +import { replRepositoryPlugin } from "../src/repl/repositories/plugin.ts"; +import { REPL_REPOSITORY_OBSERVATION } from "../src/repl/repositories/observation-records.ts"; import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; import { ReplHost } from "../src/repl/host.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; @@ -81,78 +76,61 @@ function useFixture(): Operation { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-git-reads-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); const checkout = join(root, "checkout"); - const git = gitSession(denoRepositoryHost(), root); - yield* git.run(["init", "-b", "main", checkout], root); - yield* git.run(["config", "user.name", "Fixture"], checkout); - yield* git.run(["config", "user.email", "fixture@example.invalid"], checkout); + yield* runGit(root, ["init", "-b", "main", checkout], { home: root }); yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); yield* until(writeFile(join(checkout, "notes.md"), "first notes\n")); - yield* git.run(["add", "--", "seed.md", "notes.md"], checkout); - yield* git.run(["commit", "-m", "first"], checkout); - const head = yield* git.read(["rev-parse", "HEAD"], checkout); + yield* runGit(checkout, ["add", "--", "seed.md", "notes.md"], { home: root }); + yield* runGit(checkout, ["commit", "-m", "first"], { home: root }); + const head = yield* runGit(checkout, ["rev-parse", "HEAD"], { home: root }); yield* provide({ root, checkout, journals: join(root, "journals"), - first: head ?? "", - *run(...args: string[]): Operation { - const outcome = yield* git.run([...args], checkout); - if (outcome.code !== 0) { - throw new Error(`git ${args.join(" ")} exited ${outcome.code}: ${outcome.stderr}`); - } - return outcome.stdout.trim(); + first: head, + run(...args: string[]): Operation { + return runGit(checkout, args, { home: root }); }, }); }); } interface Counted { - /** Every `git` invocation, as its argument list. */ - readonly commands: string[][]; - /** How many scratch directories the reads acquired. */ - directories: number; - /** Every authentication locator a session was opened for. */ - readonly sessions: string[]; + /** What each observation this entry performed asked for, in order. */ + readonly reads: GitObservationRequest[]; } function counters(): Counted { - return { commands: [], directories: 0, sessions: [] }; + return { reads: [] }; } -/** The native reads these entries performed, by exact subcommand. */ -const READS: readonly string[] = ["ls-files", "diff", "cat-file", "ls-tree"]; - +/** + * The reads these entries performed, by the kind each one asked for. + * + * The kind the element named, not the Git subcommand underneath it: two + * different reads share `git diff`, so a subcommand could not tell which of + * them ran. + */ function reads(counted: Counted): string[] { - return counted.commands - .map((args) => args.find((arg) => READS.includes(arg))) - .filter((name): name is string => name !== undefined); + return counted.reads.map((request) => request.kind); } -/** The production host, counted at each leaf a read could reach. */ -function countingHost(counted: Counted): RepositoryHost { - const host = denoRepositoryHost(); - return { - *git(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); - return yield* host.git(invocation); - }, - *gitBytes(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); - if (host.gitBytes === undefined) { - throw new Error("the production host lends no byte capture"); - } - return yield* host.gitBytes(invocation); - }, - *useDirectory(): Operation { - counted.directories += 1; - return yield* host.useDirectory(); - }, - *useAuthentication(locator: string): Operation { - counted.sessions.push(locator); - return host.useAuthentication === undefined - ? UNAUTHENTICATED - : yield* host.useAuthentication(locator); - }, +/** + * The production handle, with every observation it is asked to perform counted. + * + * The real method is delegated to unchanged, so what a row watches is a read + * that really happened rather than one a substitute agreed to. + */ +function countingOperations(counted: Counted): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + const wrapped: DenoRepositoryOperations = { + ...operations, + *observe(element, request, place) { + counted.reads.push(request); + return yield* operations.observe(element, request, place); + }, + }; + return wrapped; }; } @@ -164,13 +142,18 @@ function countingHost(counted: Counted): RepositoryHost { * admission that reads this feature's records travels with the vocabulary. */ function* replInstallations(): Operation { - const install = gitPlugin.install; + // The command's own assembly of the bundled Plugin, which is what a profile + // carries: the vocabulary and the domain admissions come from Git, and the + // admission that reads an entry's own repository history back belongs to this + // command, so asking the bundled Plugin alone would declare nothing here. + const bundled = replRepositoryPlugin(gitPlugin); + const install = bundled.install; if (install === undefined) { - throw new Error("the Git Plugin installs nothing"); + throw new Error("the bundled Plugin installs nothing"); } - const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + const installed = yield* install.call(bundled, { command: "repl", args: [] }); if (installed === undefined) { - throw new Error("the Git Plugin declared nothing for the repl command"); + throw new Error("the bundled Plugin declared nothing for the repl command"); } return [{ admissions: [...(installed.admissions ?? [])] }]; } @@ -206,13 +189,12 @@ function useWorkingDirectory(directory: string): Operation { { at: "min" }, ); } - function useCountedRecording(counted: Counted, root: string): Operation { // The same seam the per-entry factory registers on, because what this suite // is about is what an entry records rather than which host assembled it. return Execution.around({ *init(_args, next): Operation { - yield* useReplRepositoryRecording({ host: countingHost(counted), root }); + yield* useReplRepositoryRecording({ operations: countingOperations(counted), root }); yield* next(); }, }); @@ -297,7 +279,7 @@ describe("RGO1 — a reopened entry answers from what it recorded", () => { }); expect(first.output).toContain("Read 2 paths."); - expect(reads(live)).toContain("ls-files"); + expect(reads(live)).toContain("files"); const recorded = yield* readRecords(first.path); expect(observationCount(recorded)).toBe(4); @@ -357,10 +339,11 @@ describe("RGO1 — a reopened entry answers from what it recorded", () => { expect(reopened.output).toContain("Paths: 2"); expect(reopened.output).toContain(`"${fixture.first}"`); expect(reopened.output).toContain('"compare": "unstaged"'); - // And nothing live happened for any of it, with the checkout unavailable. + // And nothing live happened for any of it, with the checkout unavailable: + // not one observation was performed, so nothing beneath one was acquired + // either. That public construction acquires nothing is proved in + // `packages/git/tests/run-composition-lazy.test.ts`. expect(reads(cold)).toEqual([]); - expect(cold.directories).toBe(0); - expect(cold.sessions).toEqual([]); }); }); @@ -428,7 +411,7 @@ describe("RGO2 — an unfinished entry restores its prefix and reads today", () expect(settled).toContain("Before: 1"); expect(settled).toContain("After: 2, answered go."); // Exactly one fresh read for the suffix. - expect(reads(resumed)).toEqual(["ls-files"]); + expect(reads(resumed)).toEqual(["files"]); const after = yield* readRecords(executionPath(fixture.journals, "execution-1")); expect(observationCount(after)).toBe(2); }); @@ -534,7 +517,7 @@ describe("RGO4 — two identical entries are two questions", () => { // against its own segment, so the second one read rather than replayed. expect(outputs[0]).toContain("Paths: 1"); expect(outputs[1]).toContain("Paths: 2"); - expect(reads(counted).filter((name) => name === "ls-files")).toHaveLength(2); + expect(reads(counted).filter((kind) => kind === "files")).toHaveLength(2); const records = yield* readRecords(executionPath(fixture.journals, "execution-1")); expect(observationCount(records)).toBe(2); }); @@ -575,7 +558,7 @@ describe("RGO5 — the secret gate still decides what reaches the journal", () = }); // The read really happened, so the gate had something to refuse. - expect(reads(counted)).toContain("cat-file"); + expect(reads(counted)).toContain("file"); // And nothing it carried reached the file on disk. const journal = yield* readTextFile(executionPath(fixture.journals, "execution-1")); expect(journal).not.toContain(CANARY); diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index 6ea275e97..a40525fcf 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -32,30 +32,31 @@ import type { Json } from "@executablemd/core"; import { Component, Execution } from "@executablemd/core/api"; import type { ComponentDefinition, FunctionComponentDefinition } from "@executablemd/core"; import type { ExecutionInstallation } from "@executablemd/core/host"; -import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; -import { gitSession } from "../../git/src/deno/composition/git.ts"; -import { useReplRepositoryRecording } from "../../git/src/deno/retained-repository/provider.ts"; -import type { GitOutcome, RepositoryHost } from "../../git/src/deno/composition/host.ts"; -import { gitPlugin } from "../../git/src/plugin.ts"; -import { ISSUE_EFFECT } from "../../git/src/issue/effect-type.ts"; -import { issueIdempotencyKey, parseIssueRequest } from "../../git/src/issue/records.ts"; -import { issueOriginMarker } from "../../git/src/deno/issue/github.ts"; -import { IssueOperations } from "../../git/src/issue/operations.ts"; +import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { runGit } from "./support/fixture-git.ts"; +import { gitPlugin } from "@executablemd/git"; +import { replRepositoryPlugin } from "../src/repl/repositories/plugin.ts"; +import { ISSUE_EFFECT } from "@executablemd/git"; +import { parseIssueRequest } from "@executablemd/git"; +import { IssueOperations } from "@executablemd/git/api"; import type { IssueOperationsApi, IssueReadInvocation, IssueUpsertInvocation, -} from "../../git/src/issue/operations.ts"; +} from "@executablemd/git/api"; import { IssueApi } from "@executablemd/git/api"; import type { IssueDetails, IssueReference } from "@executablemd/git/api"; -import { replIssueInstallation } from "../../git/src/deno/retained-repository/issues.ts"; -import { gitHubSource } from "../../git/src/deno/composition/github.ts"; +import { replIssueInstallation } from "../src/repl/repositories/deno/issues.ts"; import { useIssueTrackerServer } from "../../git/tests/support/issue-tracker-server.ts"; import type { IssueTrackerServer, ServedIssue, } from "../../git/tests/support/issue-tracker-server.ts"; -import { gitHubAccessFor } from "../../git/tests/support/issue-providers.ts"; +import { + expectedOriginMarker, + gitHubFixtureSource, +} from "../../git/tests/support/issue-providers.ts"; import type { GitHubIssuesOptions } from "@executablemd/git/deno"; import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; import { assembleReplProfile } from "../src/repl-profile.ts"; @@ -63,7 +64,7 @@ import type { ReplExecutionProfile } from "../src/repl-profile.ts"; import { resolveAgentStack } from "../src/agent-stack.ts"; import { NO_PLUGINS } from "../src/plugin-host.ts"; import { selectReplEntryHost } from "../src/deno-repositories.ts"; -import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { ReplHost } from "../src/repl/host.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; @@ -106,13 +107,18 @@ function useReplRepositories(root: string): Operation { * through that registry, with no component catalog supplied beside it. */ function* replInstallations(): Operation { - const install = gitPlugin.install; + // The command's own assembly of the bundled Plugin, which is what a profile + // carries: the vocabulary and the domain admissions come from Git, and the + // admission that reads an entry's own repository history back belongs to this + // command, so asking the bundled Plugin alone would declare nothing here. + const bundled = replRepositoryPlugin(gitPlugin); + const install = bundled.install; if (install === undefined) { - throw new Error("the Git Plugin installs nothing"); + throw new Error("the bundled Plugin installs nothing"); } - const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + const installed = yield* install.call(bundled, { command: "repl", args: [] }); if (installed === undefined) { - throw new Error("the Git Plugin declared nothing for the repl command"); + throw new Error("the bundled Plugin declared nothing for the repl command"); } return [{ admissions: [...(installed.admissions ?? [])] }]; } @@ -131,8 +137,7 @@ function useFixture( const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-issues-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); const checkout = join(root, "checkout"); - const git = gitSession(denoRepositoryHost(), root); - yield* git.run(["init", "-b", "main", checkout], root); + yield* runGit(root, ["init", "-b", "main", checkout], { home: root }); yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); const server = yield* useIssueTrackerServer({ issues, @@ -202,7 +207,7 @@ function entryHost( return function* (owner: string): Operation { seen.owners.push(owner); return replIssueInstallation(owner, { - access: gitHubSource(gitHubAccessFor(fixture.server.url, { credential: "valid" })), + access: gitHubFixtureSource(fixture.server.url, { credential: "valid" }), configuration: { ceiling: [TRACKER] }, ...options, }); @@ -563,32 +568,67 @@ interface Acquisitions { } /** - * A repository host that fails the moment anything is acquired through it. + * A repository handle that fails the moment anything is asked of it. * * Issue work reaches no checkout, so every one of these is a failure rather * than a cost: a row installing this and still filing an issue has shown that - * nothing about an entry's issues goes looking for a repository. + * nothing about an entry's issues goes looking for a repository. Constructing + * the handle is not asking it anything — that public construction acquires + * nothing is proved in `packages/git/tests/run-composition-lazy.test.ts`. */ -function failingHost(acquired: Acquisitions): RepositoryHost { - return { - // deno-lint-ignore require-yield - *git(): Operation { - acquired.count += 1; - throw new Error("this row's host runs no git"); - }, - // deno-lint-ignore require-yield - *useDirectory(): Operation { - acquired.count += 1; - throw new Error("this row's host owns no isolation directory"); - }, +function failingOperations(acquired: Acquisitions): () => Operation { + function* refuse(method: string): Operation { + acquired.count += 1; + throw new Error(`an entry's issue work reached the repository through ${method}`); + } + // deno-lint-ignore require-yield + return function* (): Operation { + return { + *ambientCheckout() { + return yield* refuse("ambientCheckout"); + }, + *ambientRepository() { + return yield* refuse("ambientRepository"); + }, + *selectRepository() { + return yield* refuse("selectRepository"); + }, + *selectWorktree() { + return yield* refuse("selectWorktree"); + }, + *switch() { + return yield* refuse("switch"); + }, + *add() { + return yield* refuse("add"); + }, + *commit() { + return yield* refuse("commit"); + }, + *currentBranch() { + return yield* refuse("currentBranch"); + }, + *resolveCommit() { + return yield* refuse("resolveCommit"); + }, + *commitPresent() { + return yield* refuse("commitPresent"); + }, + *observe() { + return yield* refuse("observe"); + }, + *pushProvider() { + return yield* refuse("pushProvider"); + }, + }; }; } -/** The production entry recording, over a host that refuses to be acquired. */ +/** The production entry recording, over a handle that refuses every method. */ function failingRecording(acquired: Acquisitions, root: string): Operation { return Execution.around({ *init(_args, next): Operation { - yield* useReplRepositoryRecording({ host: failingHost(acquired), root }); + yield* useReplRepositoryRecording({ operations: failingOperations(acquired), root }); yield* next(); }, }); @@ -739,12 +779,10 @@ describe("how a REPL entry gets its issues", () => { // And the mark the provider carried is the one this record's own identity // and the authored target produce — not anything the middleware supplied. expect(bodies(fixture.server)[0]).toContain( - issueOriginMarker( - issueIdempotencyKey( - asked === undefined ? { runId: "", expansionId: "" } : asked.identity, - "upsert", - TRACKER, - ), + expectedOriginMarker( + asked === undefined ? { runId: "", expansionId: "" } : asked.identity, + "upsert", + TRACKER, ), ); }); @@ -1489,10 +1527,15 @@ describe("how a REPL entry gets its issues", () => { expect(absent.entryInstallation).toBe(undefined); // Selecting reaches nothing, and what it selected declares exactly the - // operations an entry retains — publication and filing, and neither - // `` nor its reads, which stay the defaults the command's - // scope registered. An installation is built per entry, and this command - // has run none. + // operations an entry retains: publication, filing, opening a pull request + // and its three evidence reads. Each of those six needs the invocation — + // a durable boundary enclosing the public chain cannot be installed inside + // it, and each evidence read is named after the occurrence that asked for + // it. The pages an entry's four Git reads answer with, and the file work + // beneath them, are middleware under the component rather than a policy + // named after an invocation, so they keep the registered default and appear + // nowhere here. An installation is built per entry, and this command has + // run none. const built = yield* scoped(function* (): Operation { if (factory === undefined) { throw new Error("the production selector selected no entry capability"); @@ -1502,6 +1545,10 @@ describe("how a REPL entry gets its issues", () => { expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", + "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", ]); }); diff --git a/packages/cli/tests/repl-push-pty.test.ts b/packages/cli/tests/repl-push-pty.test.ts index ba92698f2..7e4a9f188 100644 --- a/packages/cli/tests/repl-push-pty.test.ts +++ b/packages/cli/tests/repl-push-pty.test.ts @@ -18,14 +18,19 @@ * that matters, which is that `` there refuses before it contacts * anything. Neither branch silently skips its own behavior. * - * The journey itself is `support/repl-push-journey.ts`, shared with the - * compiled row so the two cannot drift. + * The journeys themselves are `support/repl-push-journey.ts` and + * `support/repl-pull-request-journey.ts`, each shared with its compiled row so + * the two cannot drift. */ import { describe, it } from "@executablemd/test-support/bdd"; import { cliRuntime, cliShellCommand } from "@executablemd/test-support/launch"; import type { Operation } from "effection"; import { replPushJourney, replUnsupportedPush } from "./support/repl-push-journey.ts"; +import { + replPullRequestJourney, + replUnsupportedPullRequest, +} from "./support/repl-pull-request-journey.ts"; const JOURNEY = cliRuntime() === "deno"; @@ -43,4 +48,15 @@ describe("a REPL entry's publication, across leaving and coming back", () => { yield* JOURNEY ? replPushJourney(sourceCommand) : replUnsupportedPush(sourceCommand); }, ); + + it( + JOURNEY + ? "B9: opens a pull request, answers its evidence again on reopening, and reads afresh after" + : "B10: this runtime refuses and its reads before it contacts anything", + function* (): Operation { + yield* JOURNEY + ? replPullRequestJourney(sourceCommand) + : replUnsupportedPullRequest(sourceCommand); + }, + ); }); diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts index 4e6eb1cf5..fd0905e44 100644 --- a/packages/cli/tests/repl-push.test.ts +++ b/packages/cli/tests/repl-push.test.ts @@ -28,13 +28,13 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { Execution } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; -import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; -import { gitSession } from "../../git/src/deno/composition/git.ts"; -import { gitPlugin } from "../../git/src/plugin.ts"; -import { replRepositoryInstallation } from "../../git/src/deno/retained-repository/provider.ts"; -import { REPL_REPOSITORY_PUSH_REQUEST } from "../../git/src/retained-repository/push-records.ts"; -import { GIT_HOST_EFFECT } from "../../git/src/git-host/effect-type.ts"; -import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; +import { gitPlugin } from "@executablemd/git"; +import { runGit } from "./support/fixture-git.ts"; +import { replRepositoryPlugin } from "../src/repl/repositories/plugin.ts"; +import { replRepositoryInstallation } from "../src/repl/repositories/deno/provider.ts"; +import { REPL_REPOSITORY_PUSH_REQUEST } from "../src/repl/repositories/push-records.ts"; +import { GIT_HOST_EFFECT } from "@executablemd/git"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { selectReplEntryHost } from "../src/deno-repositories.ts"; import { assembleReplProfile } from "../src/repl-profile.ts"; import type { ReplExecutionProfile } from "../src/repl-profile.ts"; @@ -65,15 +65,14 @@ function useFixture(): Operation { return resource(function* (provide) { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-push-cli-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); - const git = gitSession(denoRepositoryHost(), root); const checkout = join(root, "checkout"); const origin = join(root, "origin.git"); - yield* git.run(["init", "--bare", "-b", "main", origin], root); - yield* git.run(["init", "-b", "main", checkout], root); + yield* runGit(root, ["init", "--bare", "-b", "main", origin], { home: root }); + yield* runGit(root, ["init", "-b", "main", checkout], { home: root }); yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); - yield* git.run(["add", "seed.md"], checkout); - yield* git.run(["commit", "-m", "seed"], checkout); - yield* git.run(["push", origin, "main:refs/heads/main"], checkout); + yield* runGit(checkout, ["add", "seed.md"], { home: root }); + yield* runGit(checkout, ["commit", "-m", "seed"], { home: root }); + yield* runGit(checkout, ["push", origin, "main:refs/heads/main"], { home: root }); yield* provide({ root, checkout, origin, managed: join(root, "repositories") }); }); } @@ -142,13 +141,18 @@ function useWorkingDirectory(directory: string): Operation { * this harness supplies no component catalog of its own. */ function* replInstallations(): Operation { - const install = gitPlugin.install; + // The command's own assembly of the bundled Plugin, which is what a profile + // carries: the vocabulary and the domain admissions come from Git, and the + // admission that reads an entry's own repository history back belongs to this + // command, so asking the bundled Plugin alone would declare nothing here. + const bundled = replRepositoryPlugin(gitPlugin); + const install = bundled.install; if (install === undefined) { - throw new Error("the Git Plugin installs nothing"); + throw new Error("the bundled Plugin installs nothing"); } - const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + const installed = yield* install.call(bundled, { command: "repl", args: [] }); if (installed === undefined) { - throw new Error("the Git Plugin declared nothing for the repl command"); + throw new Error("the bundled Plugin declared nothing for the repl command"); } return [{ admissions: [...(installed.admissions ?? [])] }]; } @@ -227,7 +231,6 @@ function submit( entryInstallation: (owner: string) => { seen.owners.push(owner); return replRepositoryInstallation(owner, { - host: denoRepositoryHost(), identity: statedIdentity, ...(managed === undefined ? {} : { root: managed }), }); @@ -266,7 +269,6 @@ function reopen( entryInstallation: (owner: string) => { seen.owners.push(owner); return replRepositoryInstallation(owner, { - host: denoRepositoryHost(), identity: statedIdentity, ...(managed === undefined ? {} : { root: managed }), }); @@ -403,7 +405,7 @@ describe("how a REPL entry gets its publication capability", () => { expect(types(ran.events)).not.toContain(REPL_REPOSITORY_PUSH_REQUEST); }); - it("C6: the production selector carries one factory for both capabilities", function* () { + it("C6: the production selector carries one factory for every retained capability", function* () { const fixture = yield* useFixture(); const flags = { agentProvider: "acpx", @@ -430,17 +432,28 @@ describe("how a REPL entry gets its publication capability", () => { throw new Error("the production selector selected no entry capability"); } - // Building one reaches nothing — no directory, no `git`, no credential and - // no managed root — and what it declares is the two operations this entry - // retains, under names the command's scope has already registered. + // Building one reaches nothing — no directory, no `git`, no credential, no + // managed root and no pull-request session — and what it declares is the six + // operations this entry retains, under names the command's scope has + // already registered. The three evidence reads are named after their own + // occurrences, which is why each is declared rather than left to the + // registered default. const built = yield* scoped(function* (): Operation { return yield* factory('["executablemd.repl.issue-owner",1,"execution-cli-6","1"]'); }); expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", + "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", ]); - expect((built.admissions ?? []).length).toBe(2); + // Three admissions: the Issue history, the one walk that admits this + // entry's saved Push and pull-request requests beside their outcomes, and + // the retained reads, which are named by a question rather than paired with + // an outcome. + expect((built.admissions ?? []).length).toBe(3); expect(yield* nothingUnder(fixture.managed)).toBe(true); }); }); diff --git a/packages/cli/tests/repl-repositories.test.ts b/packages/cli/tests/repl-repositories.test.ts index cd44efeae..d81f54a57 100644 --- a/packages/cli/tests/repl-repositories.test.ts +++ b/packages/cli/tests/repl-repositories.test.ts @@ -29,24 +29,24 @@ import { join } from "node:path"; import { API, useHostFiles } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; -import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; -import { gitSession } from "../../git/src/deno/composition/git.ts"; import { parseReplLocalRecord, REPL_REPOSITORY_LOCAL, REPL_REPOSITORY_SELECTION, -} from "../../git/src/retained-repository/records.ts"; +} from "../src/repl/repositories/records.ts"; import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT, -} from "../../git/src/retained-repository/placement-records.ts"; +} from "../src/repl/repositories/placement-records.ts"; import { Execution } from "@executablemd/core/api"; import type { Json } from "@executablemd/core"; import type { ExecutionInstallation } from "@executablemd/core/host"; import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; -import { useReplRepositoryRecording } from "../../git/src/deno/retained-repository/provider.ts"; -import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; -import { gitPlugin } from "../../git/src/plugin.ts"; +import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; +import { runGit } from "./support/fixture-git.ts"; +import { gitPlugin } from "@executablemd/git"; +import { replRepositoryPlugin } from "../src/repl/repositories/plugin.ts"; import { ReplHost } from "../src/repl/host.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import { entryInitialBindings, projectRepl } from "../src/repl/model.ts"; @@ -56,7 +56,8 @@ import { openExecutionFile, readRecords, } from "../src/repl/journal.ts"; -import type { RepositoryHost } from "../../git/src/deno/composition/host.ts"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; import type { ReplSession } from "../src/repl/session.ts"; import type { ReplExecution } from "../src/repl/journal.ts"; import type { ReplModel } from "../src/repl/model.ts"; @@ -75,13 +76,18 @@ function* replInstallations(): Operation { // scope, which is what an entry needs to resolve ``: the Plugin is one // contribution, and taking half of it would be assembling something no // command assembles. - const install = gitPlugin.install; + // The command's own assembly of the bundled Plugin, which is what a profile + // carries: the vocabulary and the domain admissions come from Git, and the + // admission that reads an entry's own repository history back belongs to this + // command, so asking the bundled Plugin alone would declare nothing here. + const bundled = replRepositoryPlugin(gitPlugin); + const install = bundled.install; if (install === undefined) { - throw new Error("the Git Plugin installs nothing"); + throw new Error("the bundled Plugin installs nothing"); } - const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + const installed = yield* install.call(bundled, { command: "repl", args: [] }); if (installed === undefined) { - throw new Error("the Git Plugin declared nothing for the repl command"); + throw new Error("the bundled Plugin declared nothing for the repl command"); } return [{ admissions: [...(installed.admissions ?? [])] }]; } @@ -99,8 +105,7 @@ function useFixture(): Operation { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-cli-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); const checkout = join(root, "checkout"); - const git = gitSession(denoRepositoryHost(), root); - yield* git.run(["init", "-b", "main", checkout], root); + yield* runGit(root, ["init", "-b", "main", checkout], { home: root }); yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); yield* provide({ root, checkout }); }); @@ -194,11 +199,10 @@ function managedRoot(fixture: Fixture): string { */ function* useOrigin(fixture: Fixture): Operation { const origin = join(fixture.root, "origin"); - const git = gitSession(denoRepositoryHost(), fixture.root); - yield* git.run(["init", "-b", "main", origin], fixture.root); + yield* runGit(fixture.root, ["init", "-b", "main", origin], { home: fixture.root }); yield* until(writeFile(join(origin, "README.md"), "origin\n")); - yield* git.run(["add", "README.md"], origin); - yield* git.run(["commit", "-m", "first"], origin); + yield* runGit(origin, ["add", "README.md"], { home: fixture.root }); + yield* runGit(origin, ["commit", "-m", "first"], { home: fixture.root }); return origin; } @@ -708,23 +712,34 @@ interface Committed { } /** - * The real repository host, with each `git commit` it is asked to run counted. + * The real repository handle, with each `commit` it is asked to perform counted. * - * Counted at the host rather than at the `Git` Api: the provider's own - * implementation of `commit` consults the journal, so it is entered on a - * replay and answers from the record. What a replay must not do is reach the - * repository, and this is where reaching it happens. + * Counted below the `Git` Api rather than at it: the provider's own + * implementation of `commit` consults the journal, so it is entered on a replay + * and answers from the record. What a replay must not do is reach the + * repository, and this method is where reaching it happens. The real one is + * delegated to unchanged, so the commit and the refs it writes are real. */ -function countingHost(committed: Committed): RepositoryHost { - const host = denoRepositoryHost(); - return { - ...host, - git(invocation) { - if (invocation.args.includes("commit")) { +function countingCommits( + committed: Committed, + helper: HelperAssembly, +): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(helper); + const wrapped: DenoRepositoryOperations = { + ...operations, + *commit(checkout, workingDirectory, message, messageSource, identity) { committed.commits += 1; - } - return host.git(invocation); - }, + return yield* operations.commit( + checkout, + workingDirectory, + message, + messageSource, + identity, + ); + }, + }; + return wrapped; }; } @@ -733,10 +748,9 @@ function* useCountedCommits(committed: Committed, fixture: Fixture): Operation { yield* useReplRepositoryRecording({ - helper: SUITE_HELPER, root: managedRoot(fixture), identity: statedIdentity, - host: countingHost(committed), + operations: countingCommits(committed, SUITE_HELPER), }); yield* next(); }, @@ -752,17 +766,10 @@ function* useCountedCommits(committed: Committed, fixture: Fixture): Operation { const checkout = join(fixture.root, "committed"); - const git = gitSession(denoRepositoryHost(), fixture.root); - yield* git.run(["init", "-b", "main", checkout], fixture.root); + yield* runGit(fixture.root, ["init", "-b", "main", checkout], { home: fixture.root }); yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); - yield* git.run( - ["-c", "user.name=Suite", "-c", "user.email=suite@example.test", "add", "seed.md"], - checkout, - ); - yield* git.run( - ["-c", "user.name=Suite", "-c", "user.email=suite@example.test", "commit", "-m", "first"], - checkout, - ); + yield* runGit(checkout, ["add", "seed.md"], { home: fixture.root }); + yield* runGit(checkout, ["commit", "-m", "first"], { home: fixture.root }); yield* until(writeFile(join(checkout, "note.md"), "note\n")); return checkout; } diff --git a/packages/git/tests/retained-repository-git.test.ts b/packages/cli/tests/retained-repository-git.test.ts similarity index 83% rename from packages/git/tests/retained-repository-git.test.ts rename to packages/cli/tests/retained-repository-git.test.ts index b344a0368..417b4e79c 100644 --- a/packages/git/tests/retained-repository-git.test.ts +++ b/packages/cli/tests/retained-repository-git.test.ts @@ -45,9 +45,9 @@ import { withResolvers, } from "effection"; import type { Operation } from "effection"; +import { runGit } from "./support/fixture-git.ts"; import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; -import { chmod, mkdir, mkdtemp, realpath, symlink, unlink, writeFile } from "node:fs/promises"; -import process from "node:process"; +import { mkdir, mkdtemp, realpath, symlink, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { API, hostFilesHandler } from "@executablemd/runtime"; @@ -63,18 +63,16 @@ import { Git } from "@executablemd/git/api"; // package publishes, and reaching it by relative path would bypass the export // map the partition test holds every consumer to. import type { GitSwitchResult } from "@executablemd/git"; -import { denoRepositoryHost } from "../src/deno/composition/host.ts"; -import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; -import { gitSession } from "../src/deno/composition/git.ts"; -import type { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; -import { UNAUTHENTICATED } from "../src/deno/composition/authentication.ts"; -import { useReplRepositoryRecording } from "../src/deno/retained-repository/provider.ts"; -import { useCommonDirectoryLease } from "../src/deno/retained-repository/leases.ts"; -import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; -import { useCompositionComponents } from "../src/composition/installation.ts"; -import { parseReplGitRecord, REPL_REPOSITORY_GIT } from "../src/retained-repository/git-records.ts"; -import type { ReplGitRecord } from "../src/retained-repository/git-records.ts"; -import { worktreeSlot } from "../src/deno/run-composition/checkout-layout.ts"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { GitOperationInfrastructureError } from "@executablemd/git"; +import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import { useCommonDirectoryLease } from "../src/repl/repositories/deno/leases.ts"; +import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; +import { useCompositionComponents } from "@executablemd/git"; +import { parseReplGitRecord, REPL_REPOSITORY_GIT } from "../src/repl/repositories/git-records.ts"; +import type { ReplGitRecord } from "../src/repl/repositories/git-records.ts"; +import { worktreeSlot } from "@executablemd/git/deno"; interface Fixture { /** The scenario's own directory: checkouts, managed root and HOME. */ @@ -101,18 +99,17 @@ function useFixture(): Operation { return resource(function* (provide) { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-git-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); - const git = gitSession(denoRepositoryHost(), root); const made: string[] = []; for (const name of ["checkout", "elsewhere", "origin"]) { const directory = join(root, name); yield* until(mkdir(directory, { recursive: true })); - yield* git.run(["init", "-b", "main", directory], root); + yield* runGit(root, ["init", "-b", "main", directory], { home: root }); yield* until(writeFile(join(directory, "seed.md"), "seed\n")); // Committed, because a Repository identity is pinned at the commit HEAD // named when the entry opened, and a Commit needs a parent to be a commit // of the index rather than a root commit. - yield* git.run(["add", "seed.md"], directory); - yield* git.run(["commit", "-m", "seed"], directory); + yield* runGit(directory, ["add", "seed.md"], { home: root }); + yield* runGit(directory, ["commit", "-m", "seed"], { home: root }); made.push(directory); } const link = join(root, "entered"); @@ -141,38 +138,23 @@ function* settled(turns = 8): Operation { } } -/** Wait until this path is there, or say what never appeared. */ -function* appears(path: string): Operation { - const deadline = Date.now() + 30_000; - while (!(yield* exists(path))) { - if (Date.now() > deadline) { - throw new Error(`${path} never appeared`); - } - yield* sleep(25); - } -} - -/** What the host and the composition beneath the recorder were asked to do. */ +/** What the handle and the composition beneath the recorder were asked to do. */ interface Counters { - /** Every `git` invocation this entry ran, as its argument list. */ - commands: string[][]; - /** How many isolation directories this entry's Git session acquired. */ - directories: number; + /** Every repository-handle method this entry called, by name, in order. */ + methods: string[]; /** Every identity variable this entry asked the host for. */ identity: string[]; - /** Every authentication locator a session was opened for. */ - sessions: string[]; writes: string[]; reads: string[]; } function counters(): Counters { - return { commands: [], directories: 0, identity: [], sessions: [], writes: [], reads: [] }; + return { methods: [], identity: [], writes: [], reads: [] }; } -/** The Git subcommands one entry issued, in order. */ -function subcommands(counted: Counters): string[] { - return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +/** The handle methods one entry called, in order. */ +function methodsCalled(counted: Counters): string[] { + return [...counted.methods]; } /** The host filesystem, with the document's own writes and reads counted. */ @@ -195,43 +177,99 @@ function countingFiles(counted: Counters): FilesHandler { }; } -/** What a row may do to one `git` invocation before the real one runs. */ -type GitBarrier = (invocation: GitInvocation) => Operation; +/** What a row may do to one handle method before the real one runs. */ +type MethodBarrier = (method: string) => Operation; + +/** What a row may make one handle method do instead of its real work. */ +type MethodRefusal = (method: string) => Error | undefined; /** - * The production host, counted, and interruptible at a named subcommand. + * The production handle, counted, and interruptible at a named method. * - * Every leaf is wrapped rather than replaced: what these rows need to know is + * Every method is wrapped rather than replaced: what these rows need to know is * whether a performer ran at all, and the only honest way to answer is to let - * the real `switch`, the real `add` and the real `commit-tree` happen and - * watch. A barrier runs *before* the real invocation is delegated to, so a row - * that halts there halts with the native work still to come — and one that - * delegates first halts with a real child in flight. + * the real `switch`, the real `add` and the real `commit` happen and watch. A + * barrier runs *before* the real method is delegated to, so a row that halts + * there halts with the work still to come. + * + * The real handle is acquired through its own public constructor, inside the + * factory the recording calls, so its resources have the owner production + * gives them rather than this suite's. */ -function countingHost( +function countingOperations( counted: Counters, - barrier?: GitBarrier, - intercept?: (invocation: GitInvocation) => Partial | undefined, -): RepositoryHost { - const host = denoRepositoryHost(); - return { - *git(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); + barrier?: MethodBarrier, + refuse?: MethodRefusal, +): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + function* note(method: string): Operation { + counted.methods.push(method); if (barrier !== undefined) { - yield* barrier(invocation); + yield* barrier(method); } - return yield* host.git({ ...invocation, ...(intercept?.(invocation) ?? {}) }); - }, - *useDirectory(): Operation { - counted.directories += 1; - return yield* host.useDirectory(); - }, - *useAuthentication(locator: string): Operation { - counted.sessions.push(locator); - return host.useAuthentication === undefined - ? UNAUTHENTICATED - : yield* host.useAuthentication(locator); - }, + const refusal = refuse?.(method); + if (refusal !== undefined) { + throw refusal; + } + } + const wrapped: DenoRepositoryOperations = { + *ambientCheckout(from) { + yield* note("ambientCheckout"); + return yield* operations.ambientCheckout(from); + }, + *ambientRepository(from) { + yield* note("ambientRepository"); + return yield* operations.ambientRepository(from); + }, + *selectRepository(slot, request) { + yield* note("selectRepository"); + return yield* operations.selectRepository(slot, request); + }, + *selectWorktree(slot, creation) { + yield* note("selectWorktree"); + return yield* operations.selectWorktree(slot, creation); + }, + *switch(checkout, workingDirectory, branch, base) { + yield* note("switch"); + return yield* operations.switch(checkout, workingDirectory, branch, base); + }, + *add(checkout, workingDirectory, paths) { + yield* note("add"); + return yield* operations.add(checkout, workingDirectory, paths); + }, + *commit(checkout, workingDirectory, message, messageSource, identity) { + yield* note("commit"); + return yield* operations.commit( + checkout, + workingDirectory, + message, + messageSource, + identity, + ); + }, + *currentBranch(directory) { + yield* note("currentBranch"); + return yield* operations.currentBranch(directory); + }, + *resolveCommit(directory, revision) { + yield* note("resolveCommit"); + return yield* operations.resolveCommit(directory, revision); + }, + *commitPresent(directory, commit) { + yield* note("commitPresent"); + return yield* operations.commitPresent(directory, commit); + }, + *observe(element, request, place) { + yield* note("observe"); + return yield* operations.observe(element, request, place); + }, + *pushProvider(commonDirectory, origin, inputs) { + yield* note("pushProvider"); + return yield* operations.pushProvider(commonDirectory, origin, inputs); + }, + }; + return wrapped; }; } @@ -260,72 +298,6 @@ const STATED_IDENTITY: Record = { GIT_COMMITTER_IDENT: "Entry Committer 1700000000 +0000", }; -/** What a watched `git` child reported about its own lifetime. */ -interface WatchedChildren { - /** Resolves once a watched child has been spawned. */ - readonly spawned: Operation; - /** Handed to the host, which forwards it to the one place a child exists. */ - readonly observe: NonNullable; - observed(): number; - /** Whether every observed child has closed both of its pipes. */ - closed(): boolean; - /** Whether any observed process is still running. */ - alive(): boolean; -} - -/** - * Watch the real children a row's chosen invocation spawns. - * - * The production host forwards this to `runProcess`, which calls it with the - * child as it is spawned — the only point at which a row can see the process a - * command really started. A wrapper around the host sees the call and its answer - * and never the child between them, so observing residue afterwards cannot tell - * an interrupted command from one that never began. - */ -function watchChildren(): WatchedChildren { - const pids: number[] = []; - let count = 0; - let closes = 0; - const first = withResolvers(); - return { - spawned: first.operation, - observe(child): void { - count += 1; - if (child.pid !== undefined) { - pids.push(child.pid); - } - child.on("close", () => { - closes += 1; - }); - first.resolve(); - }, - observed: () => count, - closed: () => count > 0 && closes === count, - alive: () => - pids.some((pid) => { - try { - // Signal 0 asks the operating system whether the process is there. - process.kill(pid, 0); - return true; - } catch { - return false; - } - }), - }; -} - -/** - * A script that reports it ran and then waits. - * - * What makes a child cut deterministic: the marker says the command really - * reached this point, and the wait keeps it there until the halt arrives. The - * process group teardown is what ends it. - */ -function* waitingScript(path: string, marker: string): Operation { - yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); - yield* until(chmod(path, 0o755)); -} - interface Run { readonly output: string; readonly events: readonly DurableEvent[]; @@ -338,10 +310,10 @@ interface RunOptions { readonly cwd?: string; /** What this row installs between the host and the recording. */ readonly between?: () => Operation; - /** A barrier inside one `git` invocation. */ - readonly barrier?: GitBarrier; - /** What this row changes about one invocation before the real host runs it. */ - readonly intercept?: (invocation: GitInvocation) => Partial | undefined; + /** A barrier inside one handle method. */ + readonly barrier?: MethodBarrier; + /** What this row makes one handle method raise instead of its real work. */ + readonly refuse?: MethodRefusal; /** Whether this entry installs a managed root at all. */ readonly root?: string | undefined; /** What this host says about who a commit is by. Absent states the usual pair. */ @@ -393,7 +365,7 @@ function run( yield* Execution.around({ *init(_args, next): Operation { yield* useReplRepositoryRecording({ - host: countingHost(counted, options.barrier, options.intercept), + operations: countingOperations(counted, options.barrier, options.refuse), identity: identityReader( counted, "identity" in options ? options.identity : STATED_IDENTITY, @@ -528,26 +500,21 @@ function switched(record: ReplGitRecord | undefined): string | undefined { return "resolvedBranch" in value ? value.resolvedBranch : undefined; } -/** What one checkout holds right now, read with a session of this suite's own. */ +/** What one checkout holds right now, read with a command of this suite's own. */ function* observed( fixture: Fixture, checkout: string, ): Operation<{ branch: string; head: string; staged: string }> { - const git = gitSession(denoRepositoryHost(), fixture.root); - const branch = yield* git.run(["rev-parse", "--abbrev-ref", "HEAD"], checkout); - const head = yield* git.run(["rev-parse", "HEAD"], checkout); - const staged = yield* git.run(["write-tree"], checkout); return { - branch: branch.stdout.trim(), - head: head.stdout.trim(), - staged: staged.stdout.trim(), + branch: yield* fixtureGit(fixture, ["rev-parse", "--abbrev-ref", "HEAD"], checkout), + head: yield* fixtureGit(fixture, ["rev-parse", "HEAD"], checkout), + staged: yield* fixtureGit(fixture, ["write-tree"], checkout), }; } -/** Run one fixture command with a session of this suite's own, not the entry's. */ -function* fixtureGit(fixture: Fixture, args: string[], cwd: string): Operation { - const outcome = yield* gitSession(denoRepositoryHost(), fixture.root).run(args, cwd); - return outcome.stdout.trim(); +/** Run one fixture command outside the entry, in this scenario's own home. */ +function fixtureGit(fixture: Fixture, args: string[], cwd: string): Operation { + return runGit(cwd, args, { home: fixture.root }); } /** The canonical common Git directory the repository owning this checkout has. */ @@ -596,9 +563,9 @@ describe("one entry's local Add, Switch and Commit", () => { // bound. expect((yield* observed(fixture, fixture.checkout)).head).toBe(commit); expect(live.identity).toEqual(["GIT_AUTHOR_IDENT", "GIT_COMMITTER_IDENT"]); - expect(subcommands(live)).toContain("switch"); - expect(subcommands(live)).toContain("add"); - expect(subcommands(live)).toContain("commit"); + expect(methodsCalled(live)).toContain("switch"); + expect(methodsCalled(live)).toContain("add"); + expect(methodsCalled(live)).toContain("commit"); // The checkout and the record now disagree, which the REPL's execution // model allows: branch, index and HEAD all move behind the completed work. @@ -630,10 +597,9 @@ describe("one entry's local Add, Switch and Commit", () => { // The saved commit, not today's HEAD. expect(again.output).toContain(`Recorded commit: ${commit}`); expect(again.output).not.toContain(theirs.head); - // Zero Git, zero isolation directories, zero identity reads, and no new + // Not one operation asked of the handle, no identity read, and no new // local-Git record for the prefix. - expect(replayed.commands).toEqual([]); - expect(replayed.directories).toBe(0); + expect(methodsCalled(replayed)).toEqual([]); expect(replayed.identity).toEqual([]); expect(gitRecords(again.events).length).toBe(recorded.length); // The suffix after the answered question ran exactly once, and the external @@ -688,9 +654,8 @@ describe("one entry's local Add, Switch and Commit", () => { expect(again.refusal).toBe(undefined); expect(again.output).toContain(`Recorded commit: ${commit}`); - // Nothing was run, cloned, added, recreated or repaired. - expect(replayed.commands).toEqual([]); - expect(replayed.sessions).toEqual([]); + // Nothing was selected, cloned, added, recreated or repaired. + expect(methodsCalled(replayed)).toEqual([]); expect(replayed.identity).toEqual([]); expect(yield* exists(where?.checkoutPath ?? "")).toBe(false); }); @@ -786,7 +751,7 @@ describe("one entry's local Add, Switch and Commit", () => { // Fixing the checkout after the refusal was acknowledged does not turn the // historical answer into success, and nothing was run to find out. expect(again.refusal).toBe(undefined); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); expect((yield* observed(fixture, fixture.checkout)).branch).toBe("main"); // A new invocation, in a new entry, does succeed. @@ -897,7 +862,7 @@ describe("one entry's local Add, Switch and Commit", () => { ); expect(again.output).toContain("cannot record who this commit is by"); expect(replayed.identity).toEqual([]); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); }); it("E3: switches to the branch it recorded when the caller's value changes", function* () { @@ -956,7 +921,7 @@ describe("one entry's local Add, Switch and Commit", () => { }, }); expect(again.refusal).toBe(undefined); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); // The middleware above is still reached and still delegates; what it // delegates into is the saved result rather than a native switch. expect(later).toBe(1); @@ -1006,7 +971,7 @@ describe("one entry's local Add, Switch and Commit", () => { }, }); expect(again.refusal).toBe(undefined); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); }); it("E3: keeps a native ignored-path Add refusal native, with no rollback claim", function* () { @@ -1060,7 +1025,7 @@ describe("one entry's local Add, Switch and Commit", () => { replayed, ); expect(again.output).toContain("git refused: ignored-pathspec"); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); }); it("E4: records two equal Adds at two positions and consumes two results", function* () { @@ -1079,14 +1044,14 @@ describe("one entry's local Add, Switch and Commit", () => { const names = gitNames(prefix); expect(names.length).toBe(2); expect(names[0]).toBe(names[1]); - expect(subcommands(live).filter((word) => word === "add").length).toBe(2); + expect(methodsCalled(live).filter((word) => word === "add").length).toBe(2); const replayed = counters(); const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { between: () => answered(), }); expect(again.refusal).toBe(undefined); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); expect(gitRecords(again.events).length).toBe(2); }); @@ -1100,8 +1065,8 @@ describe("one entry's local Add, Switch and Commit", () => { // commands from overlapping. let inside = 0; let overlapped = 0; - const barrier: GitBarrier = function* (invocation): Operation { - if (!invocation.args.includes("add")) { + const barrier: MethodBarrier = function* (method): Operation { + if (method !== "add") { return; } inside += 1; @@ -1133,9 +1098,6 @@ describe("one entry's local Add, Switch and Commit", () => { expect(records.length).toBe(2); expect(gitNames(performed.events)[0]).not.toBe(gitNames(performed.events)[1]); expect(overlapped).toBe(1); - // One isolation directory for the entry, taken once however many operations - // asked for it. - expect(counted.directories).toBe(1); const staged = yield* fixtureGit( fixture, ["diff", "--cached", "--name-only"], @@ -1259,7 +1221,7 @@ describe("one entry's local Add, Switch and Commit", () => { // Each branch replayed its own saved staging and performed its own suffix // once: four records in all, and no cursor or result shared between them. expect(gitRecords(again.events).length).toBe(4); - expect(subcommands(replayed).filter((word) => word === "add").length).toBe(2); + expect(methodsCalled(replayed).filter((word) => word === "add").length).toBe(2); }); it("E4: keeps equal sources in two journals independent", function* () { @@ -1278,8 +1240,8 @@ describe("one entry's local Add, Switch and Commit", () => { // executes against its own segment, so a name another entry wrote is never // inside the range this one replays. Both performed. expect(gitNames(one.events)).toEqual(gitNames(two.events)); - expect(subcommands(first)).toContain("add"); - expect(subcommands(second)).toContain("add"); + expect(methodsCalled(first)).toContain("add"); + expect(methodsCalled(second)).toContain("add"); }); it("E6: leaves no completion when a finished Commit's append is cancelled", function* () { @@ -1325,7 +1287,7 @@ describe("one entry's local Add, Switch and Commit", () => { // The commit object exists in the checkout, and nothing says it does. const after = yield* observed(fixture, fixture.checkout); - expect(subcommands(counted)).toContain("commit"); + expect(methodsCalled(counted)).toContain("commit"); expect(recordsOf(events, REPL_REPOSITORY_GIT)).toEqual([]); expect(yield* exists(join(fixture.checkout, "sentinel.md"))).toBe(false); @@ -1365,7 +1327,7 @@ describe("one entry's local Add, Switch and Commit", () => { between: () => answered(), }); expect(again.output).toContain(`Commit: ${commit}`); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); expect(replayed.identity).toEqual([]); }); @@ -1403,48 +1365,60 @@ describe("one entry's local Add, Switch and Commit", () => { // The commit happened; the durability failure is fatal; no success binding // was offered; and nothing after it ran, printing region or not. - expect(subcommands(counted)).toContain("commit"); + expect(methodsCalled(counted)).toContain("commit"); const reported = performed.refusal ?? performed.output; expect(reported).not.toContain("sentinel"); expect(recordsOf(events, REPL_REPOSITORY_GIT)).toEqual([]); expect(yield* exists(join(fixture.checkout, "sentinel.md"))).toBe(false); }); - it("E6: joins an interrupted Add child's close before the entry settles", function* () { + it("E6: records no outcome for an Add it was interrupted inside", function* () { const fixture = yield* useFixture(); const counted = counters(); yield* writeTextFile(join(fixture.checkout, "note.md"), "note\n"); - const watched = watchChildren(); - const marker = join(fixture.root, "add-ran"); - const script = join(fixture.root, "waiting-hook"); - yield* waitingScript(script, marker); - - // `git add` runs no hook, so the child that waits is made to wait by a - // `core.fsmonitor` program: it is consulted before the index is written, - // and it holds the pipes the parent inherited. - const intercept = (invocation: GitInvocation): Partial | undefined => - invocation.args.includes("add") - ? { - args: ["-c", `core.fsmonitor=${script}`, ...invocation.args], - observe: watched.observe, - } - : undefined; + const entered = withResolvers(); + + // Held at the method the operation is performed by, so the halt lands with + // the Add started and nothing decided about it. What the native child does + // between the call and its answer is not visible from here and is proved in + // `packages/git/tests/git-add.test.ts`. + const barrier: MethodBarrier = function* (method): Operation { + if (method !== "add") { + return; + } + entered.resolve(); + yield* suspend(); + }; + const events: DurableEvent[] = []; + const journal: DurableStream = { + *readAll(): Operation { + return [...events]; + }, + // deno-lint-ignore require-yield + *append(event: DurableEvent): Operation { + events.push(event); + }, + }; yield* scoped(function* () { const task = yield* spawn(() => - run(fixture, '', new InMemoryStream(), counted, { intercept }), + run( + fixture, + ['', 'sentinel'].join("\n"), + journal, + counted, + { barrier }, + ), ); - // The marker says the descendant really ran and is holding the pipes. - yield* appears(marker); - yield* watched.spawned; + yield* entered.operation; yield* task.halt(); }); - // The child this entry started closed before the entry settled, and no - // outcome was invented for the work it never finished. - expect(watched.observed() > 0).toBe(true); - expect(watched.closed()).toBe(true); - expect(watched.alive()).toBe(false); + // The Add was entered, no outcome was invented for the work it never + // finished, and nothing after it ran. + expect(methodsCalled(counted)).toContain("add"); + expect(recordsOf(events, REPL_REPOSITORY_GIT)).toEqual([]); + expect(yield* exists(join(fixture.checkout, "sentinel.md"))).toBe(false); }); it("E6: keeps an unclassified performer failure fatal and unrecorded", function* () { @@ -1452,12 +1426,17 @@ describe("one entry's local Add, Switch and Commit", () => { const counted = counters(); yield* writeTextFile(join(fixture.checkout, "note.md"), "note\n"); - // A `git add` that fails in a way this provider has no word for. The - // condition is infrastructure, so it publishes no completion and a printing - // region cannot carry on past it. - const intercept = (invocation: GitInvocation): Partial | undefined => - invocation.args.includes("add") - ? { args: ["add", "--this-option-does-not-exist", "--", "note.md"] } + // The failure this provider has no word for, raised by the operation that + // raises it. The condition is infrastructure, so it publishes no completion + // and a printing region cannot carry on past it. That a *native* add really + // does classify to this condition is proved where the classification is, + // in `packages/git/tests/git-add.test.ts`. + const refuse: MethodRefusal = (method) => + method === "add" + ? new GitOperationInfrastructureError( + "", + "native Git refused it in a way this provider has no word for", + ) : undefined; const performed = yield* run( @@ -1470,7 +1449,7 @@ describe("one entry's local Add, Switch and Commit", () => { ].join("\n"), new InMemoryStream(), counted, - { intercept }, + { refuse }, ); expect(recordsOf(performed.events, REPL_REPOSITORY_GIT)).toEqual([]); @@ -1502,7 +1481,7 @@ describe("one entry's local Add, Switch and Commit", () => { // beside it is ordinary work and runs. expect(performed.refusal).toBe(undefined); expect(performed.output).toContain("an element written outside a "); - expect(subcommands(counted)).not.toContain("add"); + expect(methodsCalled(counted)).not.toContain("add"); expect(recordsOf(performed.events, REPL_REPOSITORY_GIT)).toEqual([]); expect(counted.writes).toEqual(["after.md"]); }); diff --git a/packages/git/tests/retained-repository-history.test.ts b/packages/cli/tests/retained-repository-history.test.ts similarity index 85% rename from packages/git/tests/retained-repository-history.test.ts rename to packages/cli/tests/retained-repository-history.test.ts index 63c1ccf5b..0258e6e59 100644 --- a/packages/git/tests/retained-repository-history.test.ts +++ b/packages/cli/tests/retained-repository-history.test.ts @@ -62,24 +62,23 @@ import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-st import { collect, Elicitation, retainedSource } from "@executablemd/core"; import type { RetainedRootDocument } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; -import { gitPlugin } from "../src/plugin.ts"; -import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; -import { replIssueInstallation } from "../src/deno/retained-repository/issues.ts"; -import type { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; -import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; -import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; -import { ISSUE_EFFECT } from "../src/issue/effect-type.ts"; -import { gitHubSource } from "../src/deno/composition/github.ts"; +import { gitPlugin } from "@executablemd/git"; +import { replRepositoryPlugin } from "../src/repl/repositories/plugin.ts"; +import { replRepositoryInstallation } from "../src/repl/repositories/deno/provider.ts"; +import { replIssueInstallation } from "../src/repl/repositories/deno/issues.ts"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { GIT_HOST_EFFECT } from "@executablemd/git"; +import { ISSUE_EFFECT } from "@executablemd/git"; // From the subpath that publishes them rather than from the source module: // these are public, and reaching them by relative path would bypass the export // map the partition test holds every consumer to. import { IssueApi } from "@executablemd/git/api"; -import type { IssueDetails, IssueReference } from "@executablemd/git/api"; -import { IssueOperations } from "../src/issue/operations.ts"; -import { parseIssueDetails, parseIssueRecord } from "../src/issue/records.ts"; -import { useIssueTrackerServer } from "./support/issue-tracker-server.ts"; -import type { ServedIssue } from "./support/issue-tracker-server.ts"; -import { gitHubAccessFor } from "./support/issue-providers.ts"; +import type { IssueDetails, IssueReference } from "@executablemd/git"; +import { IssueOperations } from "@executablemd/git/api"; +import { parseIssueDetails, parseIssueRecord } from "@executablemd/git"; +import { useIssueTrackerServer } from "../../git/tests/support/issue-tracker-server.ts"; +import type { ServedIssue } from "../../git/tests/support/issue-tracker-server.ts"; +import { gitHubFixtureSource } from "../../git/tests/support/issue-providers.ts"; /** Where the immutable fixtures live, relative to this file. */ const FIXTURES = new URL("./fixtures/retained-repository-history/", import.meta.url); @@ -251,44 +250,69 @@ function invocationFiles(temporary: string): InvocationFiles { return { handler, written, directories }; } -/** What a continuation asked of its substituted leaves. */ +/** What a continuation asked of its substituted handle. */ interface Asked { - /** Every native invocation, in order, as its argument list. */ - readonly commands: string[][]; - /** Every isolation directory acquired. */ - directories: number; - /** Every authentication session opened, by locator. */ - readonly sessions: string[]; + /** Every repository operation it reached for, by method name, in order. */ + readonly methods: string[]; } function asked(): Asked { - return { commands: [], directories: 0, sessions: [] }; + return { methods: [] }; } /** - * A native host that answers nothing and records everything. + * A repository handle that answers nothing and records everything. * - * What the four provider-free rows below need: any invocation at all defeats - * what those rows assert, so the refusal says so rather than pretending to be - * `git`. + * What the four provider-free rows below need: any operation at all defeats + * what those rows assert, so the refusal says which one was reached rather than + * pretending to perform it. Nothing beneath a method can be acquired without + * entering one, so recording the twelve is recording the whole surface. */ -function refusingHost(seen: Asked): RepositoryHost { - return { - // deno-lint-ignore require-yield - *git(invocation: GitInvocation): Operation { - seen.commands.push([...invocation.args]); - throw new Error(`a restored history ran git ${invocation.args.join(" ")}`); - }, - // deno-lint-ignore require-yield - *useDirectory(): Operation { - seen.directories += 1; - throw new Error("a restored history acquired an isolation directory"); - }, - // deno-lint-ignore require-yield - *useAuthentication(locator: string): Operation { - seen.sessions.push(locator); - throw new Error("a restored history opened an authentication session"); - }, +function refusingOperations(seen: Asked): () => Operation { + function* refuse(method: string): Operation { + seen.methods.push(method); + throw new Error(`a restored history reached the repository through ${method}`); + } + // deno-lint-ignore require-yield + return function* (): Operation { + return { + *ambientCheckout() { + return yield* refuse("ambientCheckout"); + }, + *ambientRepository() { + return yield* refuse("ambientRepository"); + }, + *selectRepository() { + return yield* refuse("selectRepository"); + }, + *selectWorktree() { + return yield* refuse("selectWorktree"); + }, + *switch() { + return yield* refuse("switch"); + }, + *add() { + return yield* refuse("add"); + }, + *commit() { + return yield* refuse("commit"); + }, + *currentBranch() { + return yield* refuse("currentBranch"); + }, + *resolveCommit() { + return yield* refuse("resolveCommit"); + }, + *commitPresent() { + return yield* refuse("commitPresent"); + }, + *observe() { + return yield* refuse("observe"); + }, + *pushProvider() { + return yield* refuse("pushProvider"); + }, + }; }; } @@ -302,8 +326,8 @@ interface Continued { /** What one row arranges beneath a continuation. */ interface Arrangement { - /** The native host, for a history whose continuation may reach one. */ - readonly host?: RepositoryHost; + /** The repository handle, for a history whose continuation may reach one. */ + readonly operations?: () => Operation; /** The entry's issue capability, for a history whose work was issues. */ readonly issues?: { readonly endpoint?: string }; /** The answer a question in this history receives. */ @@ -374,18 +398,18 @@ function continueHistory( yield* arrangement.between(); } const contributed = yield* replContribution(); - const host = arrangement.host; + const operations = arrangement.operations; const endpoint = arrangement.issues?.endpoint; const installation = - host === undefined + operations === undefined ? replIssueInstallation(scenario.owner, { ...(endpoint === undefined ? {} - : { access: gitHubSource(gitHubAccessFor(endpoint, { credential: "valid" })) }), + : { access: gitHubFixtureSource(endpoint, { credential: "valid" }) }), configuration: { ceiling: [TRACKER] }, }) : yield* replRepositoryInstallation(scenario.owner, { - host, + operations, identity: statedIdentity, root: scenario.paths.managedRoot, }); @@ -420,13 +444,18 @@ function continueHistory( function* replContribution(): Operation<{ readonly admissions: readonly ((retained: readonly DurableEvent[]) => Operation)[]; }> { - const install = gitPlugin.install; + // The command's own assembly of the bundled Plugin, which is what a profile + // carries: the vocabulary and the domain admissions come from Git, and the + // admission that reads an entry's own repository history back belongs to this + // command, so asking the bundled Plugin alone would declare nothing here. + const bundled = replRepositoryPlugin(gitPlugin); + const install = bundled.install; if (install === undefined) { - throw new Error("the Git Plugin installs nothing"); + throw new Error("the bundled Plugin installs nothing"); } - const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + const installed = yield* install.call(bundled, { command: "repl", args: [] }); if (installed === undefined) { - throw new Error("the Git Plugin declared nothing for the repl command"); + throw new Error("the bundled Plugin declared nothing for the repl command"); } return { admissions: [...(installed.admissions ?? [])] }; } @@ -550,6 +579,22 @@ describe("histories the merged implementation wrote, held to their bytes and con // The owner every record in it was written under is the one the receipt // names, which is what a continuation presents again. expect(scenario.owner).toContain("executablemd.repl.issue-owner"); + + // And the admission a `repl` entry performs *today* accepts it, byte for + // byte, under that recorded owner: the Plugin's own contribution plus + // every admission the final per-entry capability installs — the issue + // walk, the one walk that now admits publications and pull requests + // together, and the retained evidence reads. A dispatch that had learned + // to refuse an older envelope, or a new record type that changed how an + // old name is read, is caught here rather than by the person who reopens + // the entry. + yield* scoped(function* (): Operation { + const contributed = yield* replContribution(); + const installation = yield* replRepositoryInstallation(scenario.owner, {}); + for (const admit of [...contributed.admissions, ...(installation.admissions ?? [])]) { + yield* admit(events); + } + }); } }); @@ -561,16 +606,14 @@ describe("histories the merged implementation wrote, held to their bytes and con const stream = new InMemoryStream([...inherited]); const again = yield* continueHistory(scenario, text, stream, { - host: refusingHost(seen), + operations: refusingOperations(seen), answer: { choice: "continue" }, }); // The failure the old implementation acknowledged is the failure this run // reports, and it reaches no provider to find that out. expect(again.refusal).not.toBe(undefined); - expect(seen.commands).toEqual([]); - expect(seen.directories).toBe(0); - expect(seen.sessions).toEqual([]); + expect(seen.methods).toEqual([]); expect(prefix(again.events, inherited.length)).toEqual(inherited); const failed = outcomes(again.events); expect(failed).toHaveLength(1); @@ -698,7 +741,7 @@ describe("histories the merged implementation wrote, held to their bytes and con // One issue at the tracker, not two: the continuation presented the key the // interrupted attempt presented, and the adapter found its own mark. expect(server.issues).toHaveLength(1); - expect(server.requests.some((request) => request.method === "POST")).toBe(false); + expect(server.requests.some((served) => served.method === "POST")).toBe(false); const records = issues(again.events); expect(records).toHaveLength(1); }); diff --git a/packages/git/tests/retained-repository-lifecycle.test.ts b/packages/cli/tests/retained-repository-lifecycle.test.ts similarity index 92% rename from packages/git/tests/retained-repository-lifecycle.test.ts rename to packages/cli/tests/retained-repository-lifecycle.test.ts index f4e1e16ad..8f13a016a 100644 --- a/packages/git/tests/retained-repository-lifecycle.test.ts +++ b/packages/cli/tests/retained-repository-lifecycle.test.ts @@ -31,8 +31,11 @@ import { withResolvers, } from "effection"; import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; import { mkdir, mkdtemp, realpath, symlink, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { runGit } from "./support/fixture-git.ts"; import { join } from "node:path"; import { API, hostFilesHandler } from "@executablemd/runtime"; import type { FilePathInput, FilesHandler, FileWriteInput } from "@executablemd/runtime"; @@ -42,26 +45,18 @@ import { collect, Elicitation } from "@executablemd/core"; import { Execution } from "@executablemd/core/api"; import { executeInstalled } from "@executablemd/core/host"; import { useAdvisoryLock } from "@executablemd/workflow/deno"; -import { denoRepositoryHost } from "../src/deno/composition/host.ts"; -import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; -import { gitSession } from "../src/deno/composition/git.ts"; -import { useReplRepositoryRecording } from "../src/deno/retained-repository/provider.ts"; -import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; +import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; import { CommonDirectoryLeaseError, useCommonDirectoryLease, -} from "../src/deno/retained-repository/leases.ts"; -import { - commonDirectoryLock, - lockOf, - repositorySlot, - worktreeSlot, -} from "../src/deno/run-composition/checkout-layout.ts"; -import { useLeases } from "../src/deno/run-composition/leases.ts"; -import { useCompositionComponents } from "../src/composition/installation.ts"; -import { REPL_REPOSITORY_LOCAL } from "../src/retained-repository/records.ts"; -import { REPL_REPOSITORY_PLACEMENT } from "../src/retained-repository/placement-records.ts"; -import { REPL_REPOSITORY_GIT } from "../src/retained-repository/git-records.ts"; +} from "../src/repl/repositories/deno/leases.ts"; +import { commonDirectoryLock, repositorySlot, worktreeSlot } from "@executablemd/git/deno"; +import { useLeases } from "@executablemd/git/deno"; +import { useCompositionComponents } from "@executablemd/git"; +import { REPL_REPOSITORY_LOCAL } from "../src/repl/repositories/records.ts"; +import { REPL_REPOSITORY_PLACEMENT } from "../src/repl/repositories/placement-records.ts"; +import { REPL_REPOSITORY_GIT } from "../src/repl/repositories/git-records.ts"; /** Let every ready task run, so "reached its wait" is about turns. */ function* settled(turns = 8): Operation { @@ -81,12 +76,12 @@ function useFixture(): Operation { return resource(function* (provide) { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-lease-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); - const git = gitSession(denoRepositoryHost(), root); + const made: string[] = []; for (const name of ["checkout", "elsewhere"]) { const directory = join(root, name); yield* until(mkdir(directory, { recursive: true })); - yield* git.run(["init", "-b", "main", directory], root); + yield* runGit(root, ["init", "-b", "main", directory]); yield* writeTextFile(join(directory, "seed.md"), "seed\n"); made.push(directory); } @@ -114,37 +109,57 @@ interface Counters { writes: string[]; deletes: string[]; /** Every `git` invocation this entry ran, as its argument list. */ - commands: string[][]; + /** Each domain method a live operation called, in order. */ + methods: string[]; } function counters(): Counters { - return { reads: [], writes: [], deletes: [], commands: [] }; + return { reads: [], writes: [], deletes: [], methods: [] }; } /** - * The production host, with every `git` it runs counted. + * The platform's own repository work, with every method these rows name counted. + * + * What a row here asks is which operations an entry performed and in what + * order, which is a question about methods rather than about subcommands. The + * locks and descriptors these rows probe are this command's own, so no Git + * invocation observer is needed to hold or inspect them. * * Wrapped rather than replaced: what the Git rows below need to know is whether * a performer ran at all, and the only honest way to answer is to let the real * `add` happen and watch. */ -function countingHost(counted: Counters): RepositoryHost { - const host = denoRepositoryHost(); - return { - *git(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); - return yield* host.git(invocation); - }, - useDirectory: () => host.useDirectory(), - ...(host.useAuthentication === undefined - ? {} - : { useAuthentication: host.useAuthentication.bind(host) }), +function countingOperations(counted: Counters): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + const note = (name: string, method: (...args: A) => Operation) => + function* (...args: A): Operation { + counted.methods.push(name); + return yield* method(...args); + }; + return { + ...operations, + ambientCheckout: note("ambientCheckout", (from: string) => operations.ambientCheckout(from)), + selectRepository: note("selectRepository", (slot, request) => + operations.selectRepository(slot, request), + ), + selectWorktree: note("selectWorktree", (slot, creation) => + operations.selectWorktree(slot, creation), + ), + add: note("add", (checkout, directory, paths) => operations.add(checkout, directory, paths)), + switch: note("switch", (checkout, directory, branch, base) => + operations.switch(checkout, directory, branch, base), + ), + commit: note("commit", (checkout, directory, message, source, identity) => + operations.commit(checkout, directory, message, source, identity), + ), + }; }; } /** The Git subcommands one entry issued, in order. */ -function subcommands(counted: Counters): string[] { - return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +function methodsCalled(counted: Counters): string[] { + return [...counted.methods]; } function countingFiles(counted: Counters): FilesHandler { @@ -240,7 +255,7 @@ function entry( yield* Execution.around({ *init(_args, next): Operation { yield* useReplRepositoryRecording({ - host: countingHost(counted), + operations: countingOperations(counted), ...(root === undefined ? {} : { root }), }); yield* next(); @@ -1070,7 +1085,7 @@ describe("an entry's hold on the checkout it is working in", () => { // Refused before the performer and before any append: no `git add` ran, // and nothing was written down for an operation that never happened. expect(refused.refusal).toContain("another process is working in the managed checkout"); - expect(subcommands(blocked)).not.toContain("add"); + expect(methodsCalled(blocked)).not.toContain("add"); expect(gitRecords(refused.events)).toBe(0); release.resolve(); @@ -1088,7 +1103,7 @@ describe("an entry's hold on the checkout it is working in", () => { managed, ); expect(later.refusal).toBe(undefined); - expect(subcommands(after)).toContain("add"); + expect(methodsCalled(after)).toContain("add"); expect(gitRecords(later.events)).toBe(1); }); @@ -1118,16 +1133,16 @@ describe("an entry's hold on the checkout it is working in", () => { const first = counters(); const prefix = yield* leftEntry(origin, source, first, managed); expect(gitRecords(prefix)).toBe(1); - expect(subcommands(first)).toContain("add"); + expect(methodsCalled(first)).toContain("add"); // Derived from the canonical root the entry created and canonicalized, so // these are the two sidecars the provider itself opens. const canonical = yield* until(realpath(managed)); - const slotLock = lockOf( - canonical, - "worktree", - worktreeSlot(canonical, commonDirectory, "observed"), - ); + // Only the shared directory's own lock is named here. Where the slot's + // sidecar lives is the domain's business; what this row is about is the + // order the two are taken in, which the public one is enough to see. + const slot = worktreeSlot(canonical, commonDirectory, "observed"); + expect(slot.startsWith(canonical)).toBe(true); yield* scoped(function* (): Operation { // A different linked checkout of the same repository, held by another @@ -1171,14 +1186,16 @@ describe("an entry's hold on the checkout it is working in", () => { // of any kind on this continuation — refused under the holder's mutation // lease, before it ran and before it appended anything. expect(refused.refusal).toContain("Another XMD entry or process holds this checkout"); - expect(subcommands(blocked)).not.toContain("add"); + expect(methodsCalled(blocked)).not.toContain("add"); expect(blocked.writes).toEqual([]); expect(gitRecords(refused.events)).toBe(gitRecords(prefix)); const opened = observed.opened(); // The slot came back first and the shared directory second, which is the - // order the live path takes them in. - expect(opened.indexOf(slotLock) >= 0).toBe(true); - expect(opened.indexOf(commonLock) > opened.indexOf(slotLock)).toBe(true); + // order the live path takes them in: the shared lock is not the first + // lock opened, and a different one precedes it. + const tookCommon = opened.indexOf(commonLock); + expect(tookCommon > 0).toBe(true); + expect(opened.slice(0, tookCommon).every((path) => path !== commonLock)).toBe(true); release.resolve(); }); @@ -1194,7 +1211,7 @@ describe("an entry's hold on the checkout it is working in", () => { managed, ); expect(later.refusal).toBe(undefined); - expect(subcommands(after)).toContain("add"); + expect(methodsCalled(after)).toContain("add"); expect(gitRecords(later.events)).toBe(gitRecords(prefix) + 1); }); }); @@ -1208,11 +1225,10 @@ describe("an entry's hold on the checkout it is working in", () => { function* useOrigin(root: string, name: string): Operation { const origin = join(root, name); yield* until(mkdir(origin, { recursive: true })); - const git = gitSession(denoRepositoryHost(), root); - yield* git.run(["init", "-b", "main", origin], root); + yield* runGit(root, ["init", "-b", "main", origin]); yield* writeTextFile(join(origin, "README.md"), "origin\n"); - yield* git.run(["add", "README.md"], origin); - yield* git.run(["commit", "-m", "first"], origin); + yield* runGit(origin, ["add", "README.md"]); + yield* runGit(origin, ["commit", "-m", "first"]); return origin; } diff --git a/packages/git/tests/retained-repository-local.test.ts b/packages/cli/tests/retained-repository-local.test.ts similarity index 95% rename from packages/git/tests/retained-repository-local.test.ts rename to packages/cli/tests/retained-repository-local.test.ts index 13fcfa735..5abba7ad2 100644 --- a/packages/git/tests/retained-repository-local.test.ts +++ b/packages/cli/tests/retained-repository-local.test.ts @@ -36,6 +36,7 @@ import type { Operation, Result } from "effection"; import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; import { mkdtemp, realpath, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { runGit } from "./support/fixture-git.ts"; import { join } from "node:path"; import { API, @@ -51,17 +52,17 @@ import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-st import { Execution } from "@executablemd/core/api"; import { executeInstalled } from "@executablemd/core/host"; import { collect, Elicitation } from "@executablemd/core"; -import { denoRepositoryHost } from "../src/deno/composition/host.ts"; -import { gitSession } from "../src/deno/composition/git.ts"; -import { useReplRepositoryRecording } from "../src/deno/retained-repository/provider.ts"; -import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; -import { useCompositionComponents } from "../src/composition/installation.ts"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; +import { useCompositionComponents } from "@executablemd/git"; import { REPL_REPOSITORY_LOCAL, REPL_REPOSITORY_SELECTION, parseReplLocalRecord, -} from "../src/retained-repository/records.ts"; -import type { ReplLocalValue } from "../src/retained-repository/records.ts"; +} from "../src/repl/repositories/records.ts"; +import type { ReplLocalValue } from "../src/repl/repositories/records.ts"; interface Fixture { readonly root: string; @@ -74,8 +75,7 @@ function useFixture(): Operation { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-local-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); const checkout = join(root, "checkout"); - const git = gitSession(denoRepositoryHost(), root); - yield* git.run(["init", "-b", "main", checkout], root); + yield* runGit(root, ["init", "-b", "main", checkout]); yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); yield* provide({ root, checkout }); }); @@ -88,13 +88,14 @@ interface Counters { deletes: string[]; ensures: string[]; /** Every `git` subcommand this entry's session ran. */ - commands: string[]; + /** Each directory a live operation discovered a checkout from. */ + discoveries: string[]; /** Every working directory the recorder resolved through the filesystem. */ resolved: number; } function counters(): Counters { - return { reads: [], writes: [], deletes: [], ensures: [], commands: [], resolved: 0 }; + return { reads: [], writes: [], deletes: [], ensures: [], discoveries: [], resolved: 0 }; } /** @@ -130,14 +131,25 @@ function countingFiles(counted: Counters): FilesHandler { } /** A repository host that counts the `git` this entry ran. */ -function countingHost(counted: Counters) { - const host = denoRepositoryHost(); - return { - git(invocation: Parameters[0]) { - counted.commands.push(invocation.args.join(" ")); - return host.git(invocation); - }, - useDirectory: () => host.useDirectory(), +/** + * The platform's own repository work, with the one call these rows count. + * + * `ambientCheckout` is what a live file operation asks before it knows whether + * it is standing in a checkout, so counting it is the measurement these rows + * want: a completed operation asks nothing. Every other method delegates + * untouched, and no session, attachment or credential crosses this wrapper — + * what it wraps is the twelve credential-free methods and nothing else. + */ +function countingOperations(counted: Counters): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + return { + ...operations, + *ambientCheckout(from: string) { + counted.discoveries.push(from); + return yield* operations.ambientCheckout(from); + }, + }; }; } @@ -195,7 +207,7 @@ function run( // what establishes the recording, inside the scope the invocation owns. yield* Execution.around({ *init(_args, next): Operation { - yield* useReplRepositoryRecording({ host: countingHost(counted) }); + yield* useReplRepositoryRecording({ operations: countingOperations(counted) }); yield* next(); }, }); @@ -353,7 +365,7 @@ describe("one entry's local file work", () => { expect(replayed.ensures).toEqual([]); expect(replayed.writes).toEqual([]); expect(replayed.deletes).toEqual([]); - expect(replayed.commands).toEqual([]); + expect(replayed.discoveries).toEqual([]); }); it("answers a completed operation from its record, against a changed file", function* () { @@ -376,7 +388,7 @@ describe("one entry's local file work", () => { expect(again.refusal).toBe(undefined); // Nothing was performed and nothing was resolved: no write, and no `git`. expect(replayed.writes).toEqual([]); - expect(replayed.commands).toEqual([]); + expect(replayed.discoveries).toEqual([]); // And the file the person changed is still theirs. expect(yield* readTextFile(join(fixture.checkout, "note.md"))).toBe("changed by somebody else"); }); @@ -538,7 +550,7 @@ describe("one entry's local file work", () => { expect(again.refusal).toBe(undefined); expect(replayed.writes).toEqual([]); expect(replayed.ensures).toEqual([]); - expect(replayed.commands).toEqual([]); + expect(replayed.discoveries).toEqual([]); }); it("continues a prefix each concurrent branch saved its own routing in", function* () { @@ -632,7 +644,7 @@ describe("one entry's local file work", () => { // Discovery stays shared. The suffix is new work in a coroutine that saved // no routing, so it does look — once, for the whole continuation, rather // than once per coroutine that asks. - expect(replayed.commands).toEqual(["rev-parse --show-toplevel", "rev-parse --git-common-dir"]); + expect(replayed.discoveries).toHaveLength(1); }); it("records what the composition answered, without performing it itself", function* () { @@ -793,7 +805,7 @@ describe("one entry's local file work", () => { expect(replayed.deletes).toEqual([]); expect(replayed.ensures).toEqual([]); // No discovery, and no canonicalization of a directory that is gone. - expect(replayed.commands).toEqual([]); + expect(replayed.discoveries).toEqual([]); // And the binding a continuation offers is the text the read answered with // the first time, from a file that no longer exists. expect(again.output).toContain("first"); diff --git a/packages/git/tests/retained-repository-observations-deno.test.ts b/packages/cli/tests/retained-repository-observations-deno.test.ts similarity index 85% rename from packages/git/tests/retained-repository-observations-deno.test.ts rename to packages/cli/tests/retained-repository-observations-deno.test.ts index df1fa935e..ce1bd56b2 100644 --- a/packages/git/tests/retained-repository-observations-deno.test.ts +++ b/packages/cli/tests/retained-repository-observations-deno.test.ts @@ -24,23 +24,23 @@ import type { Operation } from "effection"; import { exists } from "@effectionx/fs"; import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; -import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; import { registerComponents } from "@executablemd/core"; import { parseReplObservationRecord, REPL_REPOSITORY_OBSERVATION, -} from "../src/retained-repository/observation-records.ts"; -import { useCommonDirectoryLease } from "../src/deno/retained-repository/leases.ts"; -import { useObservationFixture, writeFixtureFile } from "./support/observations.ts"; +} from "../src/repl/repositories/observation-records.ts"; +import { useCommonDirectoryLease } from "../src/repl/repositories/deno/leases.ts"; +import { useObservationFixture, writeFixtureFile } from "../../git/tests/support/observations.ts"; import { entryCounters, - entrySubcommands, + methodsCalled, + readKinds, repoint, runEntry, useEntryPlace, } from "./support/repl-observations.ts"; -import type { EntryCounters } from "./support/repl-observations.ts"; -import { commonDirectoryOf } from "./support/run-composition.ts"; +import { commonDirectoryOf } from "../../git/tests/support/run-composition.ts"; function collecting( events: DurableEvent[], @@ -59,18 +59,6 @@ function collecting( }; } -/** The native reads one entry performed, which is what a read's `git` is. */ -const READ_SUBCOMMANDS: readonly string[] = ["ls-files", "diff", "cat-file", "ls-tree"]; - -function reads(counted: EntryCounters): string[] { - // By exact argument rather than by "the first word that is not an option": - // every read states `-c ` pre-command options, and a setting's value - // is not an option either. - return counted.commands - .map((args) => args.find((arg) => READ_SUBCOMMANDS.includes(arg))) - .filter((name): name is string => name !== undefined); -} - function observations(events: readonly DurableEvent[]) { return events .filter( @@ -122,7 +110,7 @@ describe("ROB5 — cancellation, persistence and ownership, on a native host", ( }); // The read ran, and nothing says it did. - expect(reads(counted)).toContain("diff"); + expect(readKinds(counted)).toContain("diff"); expect(observations(events)).toEqual([]); expect(yield* exists(join(fixture.root, "sentinel.md"))).toBe(false); @@ -131,7 +119,7 @@ describe("ROB5 — cancellation, persistence and ownership, on a native host", ( const again = entryCounters(); const continued = yield* runEntry(place, source, new InMemoryStream([...events]), again); expect(continued.refusal).toBe(undefined); - expect(reads(again)).toContain("diff"); + expect(readKinds(again)).toContain("diff"); const recorded = observations(continued.events); expect(recorded).toHaveLength(1); const outcome = recorded[0]?.record.outcome; @@ -182,15 +170,15 @@ describe("ROB5 — cancellation, persistence and ownership, on a native host", ( }, ]); }, - *barrier(invocation): Operation { - if (invocation.args.includes("add")) { + *barrier(method): Operation { + if (method === "add") { if (holding) { enteredWhileHeld = true; } mutationEntered.resolve(); return; } - if (invocation.args.includes("diff") && !holding) { + if (method === "observe" && !holding) { holding = true; readHeld.resolve(); // Long enough that a mutation on a queue of its own would have @@ -224,8 +212,8 @@ describe("ROB5 — cancellation, persistence and ownership, on a native host", ( // and the mutation did run once the read released it. expect(enteredWhileHeld).toBe(false); expect(settled.refusal).toBe(undefined); - expect(reads(counted)).toContain("diff"); - expect(entrySubcommands(counted)).toContain("add"); + expect(readKinds(counted)).toContain("diff"); + expect(methodsCalled(counted)).toContain("add"); }); it("answers while another holder has the checkout's mutation lease", function* () { @@ -249,7 +237,7 @@ describe("ROB5 — cancellation, persistence and ownership, on a native host", ( return yield* runEntry(place, source, new InMemoryStream(), counted); }); expect(live.refusal).toBe(undefined); - expect(reads(counted)).toContain("diff"); + expect(readKinds(counted)).toContain("changes"); // And a completed replay takes neither the lease nor anything else: it // answers with the lease still held and the checkout gone. @@ -260,6 +248,6 @@ describe("ROB5 — cancellation, persistence and ownership, on a native host", ( return yield* runEntry(place, source, new InMemoryStream([...live.events]), replayed); }); expect(again.output).toBe(live.output); - expect(entrySubcommands(replayed)).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); }); }); diff --git a/packages/git/tests/retained-repository-observations.test.ts b/packages/cli/tests/retained-repository-observations.test.ts similarity index 80% rename from packages/git/tests/retained-repository-observations.test.ts rename to packages/cli/tests/retained-repository-observations.test.ts index d65aa8452..7265bdf43 100644 --- a/packages/git/tests/retained-repository-observations.test.ts +++ b/packages/cli/tests/retained-repository-observations.test.ts @@ -29,17 +29,16 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { Err, Ok, race, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; +import { Err, Ok } from "effection"; import type { Operation } from "effection"; -import { exists, rm, writeTextFile } from "@effectionx/fs"; -import { chmod } from "node:fs/promises"; +import { exists, rm } from "@effectionx/fs"; import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; import { GitQuery } from "@executablemd/git/api"; import type { GitDiffInvocation, GitDiffResult, GitObservationValue } from "@executablemd/git/api"; -import { parseGitObservationValue } from "../src/composition/observation-records.ts"; -import { GitObservationError } from "../src/composition/errors.ts"; +import { parseGitObservationValue } from "@executablemd/git"; +import { GitObservationError } from "@executablemd/git"; import { elementOf, parseReplObservationRecord, @@ -47,38 +46,24 @@ import { replObservationRecordJson, replObservationRecordName, REPL_REPOSITORY_OBSERVATION, -} from "../src/retained-repository/observation-records.ts"; +} from "../src/repl/repositories/observation-records.ts"; import type { ReplObservationCheckout, ReplObservationRequest, -} from "../src/retained-repository/observation-records.ts"; -import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; -import { Elicitation, registerComponents } from "@executablemd/core"; -import { useObservationFixture, writeFixtureFile } from "./support/observations.ts"; -import type { EntryCounters } from "./support/repl-observations.ts"; +} from "../src/repl/repositories/observation-records.ts"; +import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; +import { Elicitation } from "@executablemd/core"; +import { useObservationFixture, writeFixtureFile } from "../../git/tests/support/observations.ts"; import { - appears, entryCounters, - entrySubcommands, + methodsCalled, + readKinds, leftEntry, repoint, - running, runEntry, useEntryPlace, } from "./support/repl-observations.ts"; -import type { GitInvocation } from "../src/deno/composition/host.ts"; -import { useCommonDirectoryLease } from "../src/deno/retained-repository/leases.ts"; -import { commonDirectoryOf } from "./support/run-composition.ts"; - -/** One journal event standing for a saved observation record. */ -function recordEvent(name: string, payload: Json): DurableEvent { - return { - type: "yield", - coroutineId: "root", - description: { type: REPL_REPOSITORY_OBSERVATION, name }, - result: { status: "ok", value: payload }, - }; -} +import { commonDirectoryOf } from "../../git/tests/support/run-composition.ts"; /** This payload as an object a case can change one member of. */ function objectOf(payload: Json): Record { @@ -169,59 +154,6 @@ function collecting( }; } -/** What a watched `git` child reported about its own lifetime. */ -interface WatchedChildren { - readonly spawned: Operation; - readonly observe: NonNullable; - observed(): number; - closed(): boolean; - alive(): boolean; -} - -/** - * Watch the real children one invocation spawns. - * - * The production host forwards this to the one place a child exists, which is - * the only point at which a row can see the process a command really started. - * A wrapper around the host sees the call and its answer and never the child - * between them, so observing residue afterwards cannot tell an interrupted - * command from one that never began. - */ -function watchChildren(): WatchedChildren { - const pids: number[] = []; - let count = 0; - let closes = 0; - const first = withResolvers(); - return { - spawned: first.operation, - observe(child): void { - count += 1; - if (child.pid !== undefined) { - pids.push(child.pid); - } - child.on("close", () => { - closes += 1; - }); - first.resolve(); - }, - observed: () => count, - closed: () => count > 0 && closes === count, - alive: () => pids.some((pid) => running(pid)), - }; -} - -/** - * A script that reports it ran and then waits. - * - * What makes a child cut deterministic: the marker says the command really - * reached this point, and the wait keeps it there until the halt arrives. The - * process group teardown is what ends it. - */ -function* waitingScript(path: string, marker: string): Operation { - yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); - yield* until(chmod(path, 0o755)); -} - /** The one observation payload this history holds, as an object. */ function soundPayload(events: readonly DurableEvent[]): Json { for (const event of events) { @@ -269,18 +201,6 @@ function replacingObservation( }); } -/** The native reads one entry performed, which is what a read's `git` is. */ /** The native reads one entry performed, which is what a read's `git` is. */ -const READ_SUBCOMMANDS: readonly string[] = ["ls-files", "diff", "cat-file", "ls-tree"]; - -function reads(counted: EntryCounters): string[] { - // By exact argument rather than by "the first word that is not an option": - // every read states `-c ` pre-command options, and a setting's value - // is not an option either. - return counted.commands - .map((args) => args.find((arg) => READ_SUBCOMMANDS.includes(arg))) - .filter((name): name is string => name !== undefined); -} - /** The records of this feature's own observation type, parsed. */ function observations(events: readonly DurableEvent[]) { return events @@ -376,10 +296,10 @@ describe("ROB1 — a completed read answers from its record", () => { const again = yield* runEntry(place, source, new InMemoryStream([...live.events]), second); expect(again.refusal).toBe(undefined); expect(again.output).toBe(live.output); - // Not one `git`, no scratch directory, and no session. - expect(entrySubcommands(second)).toEqual([]); - expect(second.directories).toBe(0); - expect(second.sessions).toEqual([]); + // The handle was never asked for anything: no discovery, no read, and so + // no scratch directory and no session underneath either. + expect(methodsCalled(second)).toEqual([]); + expect(readKinds(second)).toEqual([]); }); }); @@ -609,8 +529,7 @@ describe("ROB2 — the record is closed, and named by the request it carries", ( expect(replayed.output).not.toContain("src/a.ts"); // And decided from the history alone: nothing opened a checkout, resolved // a path or ran `git` to find out. - expect(`${named}: ${entrySubcommands(counted).join(",")}`).toBe(`${named}: `); - expect(counted.directories).toBe(0); + expect(`${named}: ${methodsCalled(counted).join(",")}`).toBe(`${named}: `); } }); @@ -630,7 +549,7 @@ describe("ROB2 — the record is closed, and named by the request it carries", ( const again = yield* runEntry(place, source, new InMemoryStream([...live.events]), counted); expect(again.refusal).toBe(undefined); expect(again.output).toBe(live.output); - expect(entrySubcommands(counted)).toEqual([]); + expect(methodsCalled(counted)).toEqual([]); }); it("refuses a failed envelope under this type, and leaves older histories alone", function* () { @@ -700,7 +619,7 @@ describe("ROB3 — ordinary middleware keeps its ordinary meaning", () => { expect(observations(run.events)).toEqual([]); // Ambient discovery still happened — the component asks which repository is // in scope before it asks anything of it — but not one native *read* ran. - expect(reads(counted)).toEqual([]); + expect(readKinds(counted)).toEqual([]); }); it("records the request a delegating replacement actually passed down", function* () { @@ -761,7 +680,7 @@ describe("ROB4 — a saved refusal replays, and a new read sees today", () => { const replayed = entryCounters(); const again = yield* runEntry(place, source, new InMemoryStream([...live.events]), replayed); expect(again.output).toBe(live.output); - expect(entrySubcommands(replayed)).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); }); it("restores a prefix and performs the first unrecorded read against new data", function* () { @@ -816,62 +735,6 @@ describe("ROB4 — a saved refusal replays, and a new read sees today", () => { }); describe("ROB5 — cancellation, persistence and ownership", () => { - it("joins an interrupted read's child and removes its scratch before settling", function* () { - const fixture = yield* useObservationFixture(); - const place = yield* useEntryPlace(fixture); - const counted = entryCounters(); - yield* writeFixtureFile(fixture.root, "src/a.ts", "export const a = 11;\n"); - const watched = watchChildren(); - const marker = join(place.root, "read-ran"); - const script = join(place.root, "waiting-fsmonitor"); - yield* waitingScript(script, marker); - - // The comparison refreshes the index before it answers, and a file-system - // monitor is consulted while it does. This one reports that it ran and then - // waits, holding the pipes the parent inherited — which is what makes the - // cut land on a real child rather than between commands. - const intercept = (invocation: GitInvocation): Partial | undefined => - invocation.args.includes("diff") - ? { - args: ["-c", `core.fsmonitor=${script}`, ...invocation.args], - observe: watched.observe, - } - : undefined; - - const events: DurableEvent[] = []; - const journal = collecting(events); - yield* scoped(function* () { - const task = yield* spawn(() => - runEntry( - place, - [ - '', - 'sentinel', - ].join("\n"), - journal, - counted, - { intercept }, - ), - ); - // The marker says the descendant really ran and is holding the pipes. - yield* appears(marker); - yield* watched.spawned; - yield* task.halt(); - }); - - // The child is gone and both its pipes have ended; the scratch the read - // owned is gone with it; and nothing after the read ran. - expect(watched.observed() > 0).toBe(true); - expect(watched.closed()).toBe(true); - expect(watched.alive()).toBe(false); - expect(counted.scratch.length > 0).toBe(true); - for (const directory of counted.scratch) { - expect(yield* exists(directory)).toBe(false); - } - expect(observations(events)).toEqual([]); - expect(yield* exists(join(fixture.root, "sentinel.md"))).toBe(false); - }); - it("keeps a failed page append fatal, with no binding and nothing after", function* () { const fixture = yield* useObservationFixture(); const place = yield* useEntryPlace(fixture); @@ -899,7 +762,7 @@ describe("ROB5 — cancellation, persistence and ownership", () => { // The read happened; the durability failure is fatal; no success binding // was offered; and nothing after it ran, printing region or not. - expect(reads(counted)).toContain("diff"); + expect(readKinds(counted)).toContain("diff"); const reported = performed.refusal ?? performed.output; expect(reported).not.toContain("sentinel"); expect(observations(events)).toEqual([]); diff --git a/packages/cli/tests/retained-repository-pull-request-records.test.ts b/packages/cli/tests/retained-repository-pull-request-records.test.ts new file mode 100644 index 000000000..5e1719b39 --- /dev/null +++ b/packages/cli/tests/retained-repository-pull-request-records.test.ts @@ -0,0 +1,1073 @@ +/** + * Tier RPR — what a REPL entry's saved pull-request request says, and what a + * history holding one beside a publication is allowed to look like. + * + * Two boundaries, both provider-neutral and both pure. The **codec** decides + * what a saved request is: the closed shape, the closed origin grammar, and the + * facts the received reading and the resolved inputs have to agree about. The + * **admission** decides whether a whole history may be continued once two + * external protocols interleave in it — a Push and a pull request, each paired + * with the outcome that answers it. + * + * Nothing here opens a checkout, resolves a path or contacts anything, because + * neither boundary may: a continuation whose managed worktree was deleted while + * the person was away reconciles what it already wrote down. + * + * These cases carry B6's provider-neutral half: the saved request, a history + * holding both protocols, and the question one retained read states together + * with the name and configuration that question produces. The runtime origin + * readback, recomputing that name against a real retained description, and + * actual registration before replay are the native entry's, so nothing here + * stands in for them — and nothing here substitutes synthetic ordering for + * B2's native two-publisher evidence. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Ok } from "effection"; +import type { Operation } from "effection"; +import { serializeError } from "@executablemd/durable-streams"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { + destinationRefFor, + gitPushObservationsJson, + gitPushPreStateJson, + gitPushResultJson, + PUSH_REMOTE, + refspecFor, + type GitPushInputs, +} from "@executablemd/git"; +import { locatorFingerprint } from "@executablemd/git/deno"; +import { gitOperationFingerprint } from "@executablemd/git/deno"; +import { + parseCompletePullRequestReadResult, + PULL_REQUEST_READ_KINDS, + readRequest, +} from "@executablemd/git"; +import type { RepositoryIdentity } from "@executablemd/git"; +import { GIT_HOST_EFFECT } from "@executablemd/git"; +import { gitHostReconciliationRecordJson, gitHostRequestFingerprint } from "@executablemd/git"; +import { + pullRequestObservationsJson, + pullRequestPreStateJson, + pullRequestResultJson, + type PullRequestInputs, + type PullRequestSnapshot, +} from "@executablemd/git"; +import { + admitReplPushHistory, + replPullRequestEffectRequest, + replPullRequestOutcomeName, + replPullRequestPlacesHeld, + replPushEffectRequest, + replPushOutcomeName, + ReplPushHistoryError, + walkReplRetainedPublications, + type ReplPushPlacement, +} from "../src/repl/repositories/push-admission.ts"; +import { + parseReplPullRequestRequestRecord, + replPullRequestRequestJson, + replPullRequestRequestName, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, + sameReplPullRequestAsked, + type ReplPullRequestAsked, + type ReplPullRequestContent, + type ReplPullRequestPlace, + type ReplPullRequestRequestRecord, +} from "../src/repl/repositories/pull-request-records.ts"; +import { + parseReplPushRequestRecord, + replPushRequestJson, + replPushRequestName, + REPL_REPOSITORY_PUSH_REQUEST, + type ReplPushPlace, + type ReplPushRequestRecord, +} from "../src/repl/repositories/push-records.ts"; +import { + replPlacementFingerprint, + replPlacementRecordJson, + replPlacementRecordName, + REPL_REPOSITORY_PLACEMENT, + type ReplPlacement, +} from "../src/repl/repositories/placement-records.ts"; + +const OWNER = '["executablemd.repl.issue-owner",1,"execution-4","entry-1"]'; +const SITE = "expansion-5a7"; +const OTHER_SITE = "expansion-9b2"; + +/** The pull request one entry's evidence reads name. */ +const READ_URL = "https://github.com/octo/project/pull/7"; + +const ORIGIN = "/srv/remotes/project.git"; +const CHECKOUT = "/home/dev/.xmd/repositories/project/checkout"; +const COMMON = "/home/dev/.xmd/repositories/project/checkout/.git"; +const BRANCH = "review/push"; +const COMMIT = "9fceb02d8c5a1b4e7f03a6d9c2b8e5174a0f3b6d"; +const BASE_COMMIT = "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d"; + +/** A sha256 object id, for the width the other format does not cover. */ +const WIDE_COMMIT = "b".repeat(64); +const WIDE_BASE = "c".repeat(64); + +const IDENTITY: RepositoryIdentity = Object.freeze({ + name: "project", + locatorFingerprint: locatorFingerprint(ORIGIN), + requestedBase: null, + creationCommit: "c0ffee00000000000000000000000000deadbeef", + primaryBranch: "main", + objectFormat: "sha1", +}); + +const WIDE_IDENTITY: RepositoryIdentity = Object.freeze({ + ...IDENTITY, + creationCommit: "a".repeat(64), + objectFormat: "sha256", +}); + +const PLACE: ReplPullRequestPlace = Object.freeze({ + repository: IDENTITY, + selectionCheckout: CHECKOUT, + commonDirectory: COMMON, + workingDirectory: CHECKOUT, +}); + +const CONTENT: ReplPullRequestContent = Object.freeze({ + number: null, + title: "Prepare release", + body: "Ready for review.", + draft: false, + base: "main", +}); + +const ASKED: ReplPullRequestAsked = Object.freeze({ place: PLACE, pullRequest: CONTENT }); + +const INPUTS: PullRequestInputs = Object.freeze({ + repository: IDENTITY, + number: null, + title: CONTENT.title, + body: CONTENT.body, + draft: CONTENT.draft, + headBranch: BRANCH, + headSha: COMMIT, + baseBranch: CONTENT.base, +}); + +const PUSH_INPUTS: GitPushInputs = Object.freeze({ + repository: IDENTITY, + remote: PUSH_REMOTE, + branch: BRANCH, + destinationRef: destinationRefFor(BRANCH), + sourceCommit: COMMIT, +}); + +const PUSH_PLACE: ReplPushPlace = Object.freeze({ + repository: IDENTITY, + selectionCheckout: CHECKOUT, + commonDirectory: COMMON, + workingDirectory: CHECKOUT, +}); + +const HELD: readonly ReplPushPlacement[] = [ + { identity: IDENTITY, checkoutRoot: CHECKOUT, commonDirectory: COMMON }, +]; + +const PLACEMENT: ReplPlacement = Object.freeze({ + kind: "repository", + managedRoot: "/home/dev/.xmd/repositories", + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + identity: IDENTITY, + displayName: "project", + ownerCheckout: CHECKOUT, +}); + +/** One saved pull-request request, with whatever a case wants different. */ +function request( + overrides: { + readonly owner?: string; + readonly site?: string; + readonly requested?: ReplPullRequestAsked; + readonly received?: ReplPullRequestAsked; + readonly resolved?: { + readonly checkoutRoot?: string; + readonly commonDirectory?: string; + readonly origin?: string; + readonly inputs?: PullRequestInputs; + }; + } = {}, +): Json { + return replPullRequestRequestJson({ + owner: overrides.owner ?? OWNER, + site: overrides.site ?? SITE, + requested: overrides.requested ?? ASKED, + received: overrides.received ?? ASKED, + resolved: { + checkoutRoot: overrides.resolved?.checkoutRoot ?? CHECKOUT, + commonDirectory: overrides.resolved?.commonDirectory ?? COMMON, + origin: overrides.resolved?.origin ?? ORIGIN, + inputs: overrides.resolved?.inputs ?? INPUTS, + }, + }); +} + +function parsed(payload: Json): ReplPullRequestRequestRecord { + const record = parseReplPullRequestRequestRecord(payload); + if (record === undefined) { + throw new Error("the saved request this case builds does not parse"); + } + return record; +} + +/** The journal event one published pull-request request is held by. */ +function* requestEvent( + payload: Json, + options: { readonly coroutineId?: string; readonly name?: string } = {}, +): Operation { + const record = parseReplPullRequestRequestRecord(payload); + const name = + options.name ?? + (record === undefined ? "unreadable" : yield* replPullRequestRequestName(record)); + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, + result: { status: "ok", value: payload }, + }; +} + +/** One snapshot of the pull request an outcome finished at. */ +function snapshot(inputs: PullRequestInputs, baseSha: string): PullRequestSnapshot { + return { + providerId: "PR_kwDO", + number: inputs.number ?? 7, + url: "https://github.com/octo/project/pull/7", + state: "open", + title: inputs.title, + body: inputs.body, + draft: inputs.draft, + headBranch: inputs.headBranch, + headSha: inputs.headSha, + baseBranch: inputs.baseBranch, + baseSha, + }; +} + +/** The successful Git-host outcome one saved pull-request request produces. */ +function* pullRequestOutcomeEvent( + record: ReplPullRequestRequestRecord, + options: { + readonly coroutineId?: string; + readonly name?: string; + readonly inputs?: PullRequestInputs; + readonly baseSha?: string; + } = {}, +): Operation { + const inputs = options.inputs ?? record.resolved.inputs; + const baseSha = options.baseSha ?? BASE_COMMIT; + const complete = { + identity: { runId: record.owner, expansionId: record.site }, + ...replPullRequestEffectRequest(inputs), + }; + const observed = snapshot(inputs, baseSha); + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: GIT_HOST_EFFECT, + name: options.name ?? (yield* gitHostRequestFingerprint(complete)), + }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: { + identity: complete.identity, + kind: complete.kind, + inputs: complete.inputs, + naturalKey: complete.naturalKey, + }, + preState: pullRequestPreStateJson({ pullRequest: null }), + observations: pullRequestObservationsJson({ pullRequest: observed }), + decision: "performed", + result: pullRequestResultJson({ + repository: inputs.repository, + providerId: observed.providerId, + number: observed.number, + url: observed.url, + state: "open", + headSha: inputs.headSha, + baseSha, + }), + }), + }, + }; +} + +/** The failed Git-host outcome a refused pull request leaves. */ +function* refusedPullRequestEvent( + record: ReplPullRequestRequestRecord, + name: string, + options: { readonly coroutineId?: string; readonly journaled?: string } = {}, +): Operation { + const failure = new Error("refused"); + failure.name = name; + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: GIT_HOST_EFFECT, + name: options.journaled ?? (yield* replPullRequestOutcomeName(record)), + }, + result: { status: "err", error: serializeError(failure) }, + }; +} + +/** One saved Push request, for the histories that interleave both protocols. */ +function pushRequest(site: string = SITE): Json { + return replPushRequestJson({ + owner: OWNER, + site, + requested: PUSH_PLACE, + received: PUSH_PLACE, + resolved: { + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + origin: ORIGIN, + inputs: PUSH_INPUTS, + }, + }); +} + +function pushParsed(payload: Json): ReplPushRequestRecord { + const record = parseReplPushRequestRecord(payload); + if (record === undefined) { + throw new Error("the saved Push request this case builds does not parse"); + } + return record; +} + +function* pushRequestEvent( + payload: Json, + options: { readonly coroutineId?: string } = {}, +): Operation { + const record = pushParsed(payload); + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: REPL_REPOSITORY_PUSH_REQUEST, + name: yield* replPushRequestName(record), + }, + result: { status: "ok", value: payload }, + }; +} + +function* pushOutcomeEvent( + record: ReplPushRequestRecord, + options: { readonly coroutineId?: string; readonly inputs?: GitPushInputs } = {}, +): Operation { + const inputs = options.inputs ?? record.resolved.inputs; + const complete = { + identity: { runId: record.owner, expansionId: record.site }, + ...replPushEffectRequest(inputs), + }; + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: GIT_HOST_EFFECT, + name: yield* gitHostRequestFingerprint(complete), + }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: { + identity: complete.identity, + kind: complete.kind, + inputs: complete.inputs, + naturalKey: complete.naturalKey, + }, + preState: gitPushPreStateJson({ remoteCommit: null }), + observations: gitPushObservationsJson({ remoteCommit: inputs.sourceCommit }), + decision: "performed", + result: gitPushResultJson({ + repository: inputs.repository, + remote: inputs.remote, + branch: inputs.branch, + destinationRef: inputs.destinationRef, + refspec: refspecFor(inputs.sourceCommit, inputs.destinationRef), + sourceCommit: inputs.sourceCommit, + observedRemoteCommit: inputs.sourceCommit, + }), + }), + }, + }; +} + +/** The placement record a selection of this checkout leaves behind. */ +function* placementEvent(placement: ReplPlacement = PLACEMENT): Operation { + const selection = { + kind: "repository" as const, + name: placement.displayName, + locatorFingerprint: placement.identity.locatorFingerprint, + base: null, + }; + const fingerprint = yield* replPlacementFingerprint(selection); + return { + type: "yield", + coroutineId: "root", + description: { + type: REPL_REPOSITORY_PLACEMENT, + name: replPlacementRecordName({ kind: "repository", fingerprint }), + }, + result: { + status: "ok", + value: replPlacementRecordJson({ + fingerprint, + request: selection, + outcome: Ok(placement), + }), + }, + }; +} + +/** What admitting this history refused, or `undefined` when it admitted it. */ +function* refusalOf(events: readonly DurableEvent[]): Operation { + try { + yield* admitReplPushHistory(OWNER, events); + return undefined; + } catch (error) { + if (error instanceof ReplPushHistoryError) { + return error; + } + throw error; + } +} + +describe("Tier RPR — a REPL entry's saved pull-request request", () => { + it("RPR1: holds the question, the received reading and what routing resolved", function* () { + const record = parsed(request()); + + expect(record.version).toBe(1); + expect(record.owner).toBe(OWNER); + expect(record.site).toBe(SITE); + + // Both readings of the ask, whole: where it was authenticated and what the + // document wrote. Equal readings are still written twice. + expect(record.requested.place.selectionCheckout).toBe(CHECKOUT); + expect(record.requested.place.commonDirectory).toBe(COMMON); + expect(record.requested.place.workingDirectory).toBe(CHECKOUT); + expect(record.requested.pullRequest).toEqual(CONTENT); + expect(sameReplPullRequestAsked(record.requested, record.received)).toBe(true); + + // And the answer, including the head this attempt resolved. + expect(record.resolved.checkoutRoot).toBe(CHECKOUT); + expect(record.resolved.commonDirectory).toBe(COMMON); + expect(record.resolved.origin).toBe(ORIGIN); + expect(record.resolved.inputs.headBranch).toBe(BRANCH); + expect(record.resolved.inputs.headSha).toBe(COMMIT); + + // No credential, endpoint, object source, selection token or publication + // flag crosses this boundary. + const text = JSON.stringify(request()); + for (const absent of ["token", "credential", "endpoint", "objectSource", "published"]) { + expect(text.includes(absent)).toBe(false); + } + }); + + it("RPR2: the name covers the question alone, and every member of it", function* () { + const base = yield* replPullRequestRequestName({ owner: OWNER, site: SITE, requested: ASKED }); + + // Equal questions at two positions are one name: telling them apart is the + // durable engine's positional matching, not a search by name. + expect(yield* replPullRequestRequestName({ owner: OWNER, site: SITE, requested: ASKED })).toBe( + base, + ); + + // The owner and the site are part of it. + expect( + yield* replPullRequestRequestName({ owner: OWNER, site: OTHER_SITE, requested: ASKED }), + ).not.toBe(base); + expect( + yield* replPullRequestRequestName({ owner: "another", site: SITE, requested: ASKED }), + ).not.toBe(base); + + // Every member of the content is, because each of them changes what is + // being asked for. + for (const changed of [ + { ...CONTENT, number: 7 }, + { ...CONTENT, title: "Something else" }, + { ...CONTENT, body: "" }, + { ...CONTENT, draft: true }, + { ...CONTENT, base: "release" }, + ]) { + expect( + yield* replPullRequestRequestName({ + owner: OWNER, + site: SITE, + requested: { place: PLACE, pullRequest: changed }, + }), + ).not.toBe(base); + } + + // And so is every member of the place. + expect( + yield* replPullRequestRequestName({ + owner: OWNER, + site: SITE, + requested: { + place: { ...PLACE, workingDirectory: `${CHECKOUT}/docs` }, + pullRequest: CONTENT, + }, + }), + ).not.toBe(base); + + // What routing answered takes no part in it: a name derived from the answer + // would have to be recomputed against a checkout that has since moved. + const moved = parsed( + request({ resolved: { inputs: { ...INPUTS, headSha: WIDE_COMMIT.slice(0, 40) } } }), + ); + expect(yield* replPullRequestRequestName(moved)).toBe(base); + }); + + it("RPR3: refuses an unknown version, a hostile value and a missing member", function* () { + expect(parseReplPullRequestRequestRecord(undefined)).toBeUndefined(); + expect(parseReplPullRequestRequestRecord(null)).toBeUndefined(); + expect(parseReplPullRequestRequestRecord("a saved request")).toBeUndefined(); + + const payload = request(); + expect(parseReplPullRequestRequestRecord({ ...Object(payload), version: 2 })).toBeUndefined(); + + // One member more than the contract names, and one fewer. + expect( + parseReplPullRequestRequestRecord({ ...Object(payload), extra: "detail" }), + ).toBeUndefined(); + const { resolved: _dropped, ...missing } = Object(payload); + expect(parseReplPullRequestRequestRecord(missing)).toBeUndefined(); + + // A value whose own reads refuse describes no saved request, and answering + // that is what keeps its text from travelling out through an exception. + const hostile = new Proxy(Object(payload), { + get(): never { + throw new Error("this value refuses to be read"); + }, + }); + expect(parseReplPullRequestRequestRecord(hostile)).toBeUndefined(); + }); + + it("RPR4: refuses a number, a title or a body that is not what was asked for", function* () { + const payload = Object(request()); + + // Absence is `null`, and a number is a positive integer or nothing. + for (const number of [0, -1, 1.5, "7", undefined]) { + const content = { ...CONTENT, number }; + expect( + parseReplPullRequestRequestRecord({ + ...payload, + requested: { place: Object(payload).requested.place, pullRequest: content }, + }), + ).toBeUndefined(); + } + + // An empty body is a body somebody wrote, and it round-trips verbatim. + const empty = parsed( + request({ + requested: { place: PLACE, pullRequest: { ...CONTENT, body: "" } }, + received: { place: PLACE, pullRequest: { ...CONTENT, body: "" } }, + resolved: { inputs: { ...INPUTS, body: "" } }, + }), + ); + expect(empty.requested.pullRequest.body).toBe(""); + expect(empty.resolved.inputs.body).toBe(""); + + // A body with its own whitespace crosses unchanged: an entry that reflowed + // one would publish something nobody wrote. + const verbatim = " Two spaces, a\ttab, and a trailing newline.\n"; + const kept = parsed( + request({ + requested: { place: PLACE, pullRequest: { ...CONTENT, body: verbatim } }, + received: { place: PLACE, pullRequest: { ...CONTENT, body: verbatim } }, + resolved: { inputs: { ...INPUTS, body: verbatim } }, + }), + ); + expect(kept.received.pullRequest.body).toBe(verbatim); + }); + + it("RPR5: refuses an origin this host would not hand to Git", function* () { + for (const origin of [ + "https://token@github.com/octo/project.git", + "https://github.com/octo/project.git?upload-pack=x", + "", + " ", + ]) { + expect(parseReplPullRequestRequestRecord(request({ resolved: { origin } }))).toBeUndefined(); + } + }); + + it("RPR6: holds the received content and Repository to what was resolved", function* () { + // Content the dispatch received but the inputs do not describe: a saved + // request whose title, body, draft, number or destination disagrees with + // what was resolved from it would reconcile something nobody asked for. + for (const inputs of [ + { ...INPUTS, title: "Another title" }, + { ...INPUTS, body: "Another body" }, + { ...INPUTS, draft: true }, + { ...INPUTS, number: 7 }, + { ...INPUTS, baseBranch: "release" }, + ]) { + expect(parseReplPullRequestRequestRecord(request({ resolved: { inputs } }))).toBeUndefined(); + } + + // And inputs belonging to another Repository than the one received. + const elsewhere: RepositoryIdentity = { ...IDENTITY, name: "other" }; + expect( + parseReplPullRequestRequestRecord( + request({ resolved: { inputs: { ...INPUTS, repository: elsewhere } } }), + ), + ).toBeUndefined(); + }); + + it("RPR7: both object-id formats round-trip", function* () { + const wide: PullRequestInputs = { + ...INPUTS, + repository: WIDE_IDENTITY, + headSha: WIDE_COMMIT, + }; + const widePlace: ReplPullRequestPlace = { ...PLACE, repository: WIDE_IDENTITY }; + const asked: ReplPullRequestAsked = { place: widePlace, pullRequest: CONTENT }; + + const record = parsed( + request({ requested: asked, received: asked, resolved: { inputs: wide } }), + ); + expect(record.resolved.inputs.headSha).toBe(WIDE_COMMIT); + expect(record.resolved.inputs.repository.objectFormat).toBe("sha256"); + + // And the narrow format still round-trips beside it. + expect(parsed(request()).resolved.inputs.headSha).toBe(COMMIT); + + // A head of the wrong width for the format it declares is refused. + expect( + parseReplPullRequestRequestRecord( + request({ resolved: { inputs: { ...wide, headSha: COMMIT } } }), + ), + ).toBeUndefined(); + expect(WIDE_BASE.length).toBe(64); + }); + + it("RPR8: a saved place has to be one a placement before it described", function* () { + const record = parsed(request()); + expect(replPullRequestPlacesHeld(record, HELD)).toBe(true); + + // Another checkout entirely. + expect( + replPullRequestPlacesHeld(record, [ + { identity: IDENTITY, checkoutRoot: "/elsewhere", commonDirectory: "/elsewhere/.git" }, + ]), + ).toBe(false); + + // The right roots under another Repository identity. + expect( + replPullRequestPlacesHeld(record, [ + { + identity: { ...IDENTITY, name: "other" }, + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + }, + ]), + ).toBe(false); + + // And a resolved checkout no placement covers, with the selection held. + const resolvedElsewhere = parsed( + request({ resolved: { checkoutRoot: "/elsewhere", commonDirectory: "/elsewhere/.git" } }), + ); + expect(replPullRequestPlacesHeld(resolvedElsewhere, HELD)).toBe(false); + }); + + it("RPR9: admits a request answered by its own outcome, and one still unanswered", function* () { + const payload = request(); + const record = parsed(payload); + + const answered: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record), + ]; + expect(yield* refusalOf(answered)).toBeUndefined(); + + // A trailing pending request is the state this protocol exists for. + const pending: DurableEvent[] = [yield* placementEvent(), yield* requestEvent(payload)]; + expect(yield* refusalOf(pending)).toBeUndefined(); + + // A definitive refusal is an answer too, under the three closed names. + for (const name of [ + "GitHostConflictError", + "GitHostAmbiguousError", + "GitHostUnavailableError", + ]) { + const refused: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(payload), + yield* refusedPullRequestEvent(record, name), + ]; + expect(yield* refusalOf(refused)).toBeUndefined(); + } + + // A condition this host has no word for is not one of them. + const unknown: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(payload), + yield* refusedPullRequestEvent(record, "SomethingElseError"), + ]; + expect((yield* refusalOf(unknown))?.reason).toBe("malformed-receipt"); + }); + + it("RPR10: refuses a request that is misnamed, foreign or unplaced", function* () { + const payload = request(); + + const misnamed: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(payload, { name: "not-the-name-its-question-produces" }), + ]; + expect((yield* refusalOf(misnamed))?.reason).toBe("request-fingerprint"); + + const foreign = request({ owner: "another-entry" }); + const borrowed: DurableEvent[] = [yield* placementEvent(), yield* requestEvent(foreign)]; + expect((yield* refusalOf(borrowed))?.reason).toBe("foreign-owner"); + + // No placement before it described the checkout it names. + const unplaced: DurableEvent[] = [yield* requestEvent(payload)]; + expect((yield* refusalOf(unplaced))?.reason).toBe("unknown-placement"); + + // A version this host does not implement, and a payload it cannot read. + const future: DurableEvent[] = [ + yield* placementEvent(), + { + type: "yield", + coroutineId: "root", + description: { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name: "whatever" }, + result: { status: "ok", value: { ...Object(payload), version: 2 } }, + }, + ]; + expect((yield* refusalOf(future))?.reason).toBe("unknown-version"); + + const unreadable: DurableEvent[] = [ + yield* placementEvent(), + { + type: "yield", + coroutineId: "root", + description: { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name: "whatever" }, + result: { status: "ok", value: { version: 1 } }, + }, + ]; + expect((yield* refusalOf(unreadable))?.reason).toBe("malformed-record"); + }); + + it("RPR11: refuses an orphan outcome, a duplicate pairing and a mismatched one", function* () { + const payload = request(); + const record = parsed(payload); + + const orphan: DurableEvent[] = [ + yield* placementEvent(), + yield* pullRequestOutcomeEvent(record), + ]; + expect((yield* refusalOf(orphan))?.reason).toBe("orphan-outcome"); + + const duplicate: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record), + yield* pullRequestOutcomeEvent(record), + ]; + expect((yield* refusalOf(duplicate))?.reason).toBe("duplicate-outcome"); + + // An outcome describing a pull request other than the one asked for. + const elsewhere: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record, { + inputs: { ...INPUTS, title: "Another title" }, + name: yield* replPullRequestOutcomeName(record), + }), + ]; + expect((yield* refusalOf(elsewhere))?.reason).toBe("malformed-receipt"); + + // And one journaled under a name its request does not produce. + const misnamed: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record, { name: "not-the-expected-outcome-name" }), + ]; + expect((yield* refusalOf(misnamed))?.reason).toBe("request-fingerprint"); + }); + + it("RPR12: a failed envelope answers the request standing before it, not a Push", function* () { + // The load-bearing case. A refusal retains no request of its own and names + // no kind, so a reader that treated every failure as a publication would + // consume this pull request's refusal as the Push's. + const pushPayload = pushRequest(); + const pushRecord = pushParsed(pushPayload); + const prPayload = request(); + const prRecord = parsed(prPayload); + + const mixed: DurableEvent[] = [ + yield* placementEvent(), + yield* pushRequestEvent(pushPayload), + yield* pushOutcomeEvent(pushRecord), + yield* requestEvent(prPayload), + yield* refusedPullRequestEvent(prRecord, "GitHostConflictError"), + ]; + expect(yield* refusalOf(mixed)).toBeUndefined(); + + // The same failure journaled under the *Push's* expected outcome name is + // refused: it does not answer the request standing at this position. + const asPush: DurableEvent[] = [ + yield* placementEvent(), + yield* pushRequestEvent(pushPayload), + yield* pushOutcomeEvent(pushRecord), + yield* requestEvent(prPayload), + yield* refusedPullRequestEvent(prRecord, "GitHostConflictError", { + journaled: yield* replPushOutcomeName(pushRecord), + }), + ]; + expect((yield* refusalOf(asPush))?.reason).toBe("request-fingerprint"); + + // And a successful answer of the other kind standing where this one was + // asked for describes neither. + const crossed: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(prPayload), + yield* pushOutcomeEvent(pushRecord), + ]; + expect((yield* refusalOf(crossed))?.reason).toBe("malformed-receipt"); + }); + + it("RPR13: both protocols interleave, and the walk exposes the publications", function* () { + const pushPayload = pushRequest(); + const pushRecord = pushParsed(pushPayload); + const prPayload = request(); + const prRecord = parsed(prPayload); + + const history: DurableEvent[] = [ + yield* placementEvent(), + yield* pushRequestEvent(pushPayload), + yield* pushOutcomeEvent(pushRecord), + yield* requestEvent(prPayload), + yield* pullRequestOutcomeEvent(prRecord), + ]; + expect(yield* refusalOf(history)).toBeUndefined(); + + // What the already-planned publication consumer reads: the acknowledged + // successful Push pairs, in physical outcome order, with the provenance the + // receipt alone does not carry. + const walked = yield* walkReplRetainedPublications(OWNER, history); + expect(walked.pushes).toHaveLength(1); + expect(walked.pushes[0]?.request.resolved.checkoutRoot).toBe(CHECKOUT); + expect(walked.pushes[0]?.request.resolved.commonDirectory).toBe(COMMON); + expect(walked.pushes[0]?.request.resolved.inputs.sourceCommit).toBe(COMMIT); + expect(walked.pushes[0]?.record.decision).toBe("performed"); + + // A pull request is not a publication, and a refused Push is not one + // either: neither appears among what grants publication authority. + const refusedPush: DurableEvent[] = [ + yield* placementEvent(), + yield* pushRequestEvent(pushPayload), + { + type: "yield", + coroutineId: "root", + description: { + type: GIT_HOST_EFFECT, + name: yield* replPushOutcomeName(pushRecord), + }, + result: { + status: "err", + error: serializeError( + Object.assign(new Error("refused"), { name: "GitHostConflictError" }), + ), + }, + }, + yield* requestEvent(prPayload), + yield* pullRequestOutcomeEvent(prRecord), + ]; + expect(yield* refusalOf(refusedPush)).toBeUndefined(); + expect((yield* walkReplRetainedPublications(OWNER, refusedPush)).pushes).toEqual([]); + + // A trailing pending Push grants none either. + const pendingPush: DurableEvent[] = [ + yield* placementEvent(), + yield* pushRequestEvent(pushPayload), + ]; + expect((yield* walkReplRetainedPublications(OWNER, pendingPush)).pushes).toEqual([]); + }); + + it("RPR14: coroutines pair independently across both protocols", function* () { + const pushPayload = pushRequest(); + const pushRecord = pushParsed(pushPayload); + const prPayload = request({ site: OTHER_SITE }); + const prRecord = parsed(prPayload); + + // Two spawned branches, one publishing and one asking for a pull request. + // Their outcomes interleave in the stream and neither answers the other's + // request, because a position belongs to one coroutine. + const interleaved: DurableEvent[] = [ + yield* placementEvent(), + yield* pushRequestEvent(pushPayload, { coroutineId: "root.0" }), + yield* requestEvent(prPayload, { coroutineId: "root.1" }), + yield* pullRequestOutcomeEvent(prRecord, { coroutineId: "root.1" }), + yield* pushOutcomeEvent(pushRecord, { coroutineId: "root.0" }), + ]; + expect(yield* refusalOf(interleaved)).toBeUndefined(); + + // An outcome in a coroutine that asked for nothing is still an orphan. + const stray: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(prPayload, { coroutineId: "root.1" }), + yield* pullRequestOutcomeEvent(prRecord, { coroutineId: "root.2" }), + ]; + expect((yield* refusalOf(stray))?.reason).toBe("orphan-outcome"); + }); + + it("RPR15: passes over a well-formed success of a kind an entry does not retain", function* () { + const prPayload = request(); + const prRecord = parsed(prPayload); + + // An Issue effect's own record belongs to a protocol this reader does not + // own. It is passed over rather than consuming the pending request. + const unrelated: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { type: GIT_HOST_EFFECT, name: "some-other-effect" }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: { + identity: { runId: OWNER, expansionId: SITE }, + kind: "something-else", + inputs: { nothing: true }, + naturalKey: { nothing: true }, + }, + preState: { nothing: null }, + observations: { nothing: true }, + decision: "performed", + result: { nothing: true }, + }), + }, + }; + + const history: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(prPayload), + unrelated, + yield* pullRequestOutcomeEvent(prRecord), + ]; + expect(yield* refusalOf(history)).toBeUndefined(); + + // An unreadable success still refuses: what it says about external work is + // unknown, and passing over it would be assuming it was somebody else's. + const damaged: DurableEvent[] = [ + yield* placementEvent(), + yield* requestEvent(prPayload), + { + type: "yield", + coroutineId: "root", + description: { type: GIT_HOST_EFFECT, name: "whatever" }, + result: { status: "ok", value: { not: "a reconciliation record" } }, + }, + ]; + expect((yield* refusalOf(damaged))?.reason).toBe("malformed-receipt"); + }); +}); + +/** + * The question one entry's evidence read is named by. + * + * The record half of the same boundary, and provider-neutral for the same + * reason the request codec is: what a read asked, and what naming it by that + * question commits the host to, is decided without a transport. Which host + * recomputes it and refuses a history it cannot account for is the native + * entry's, in `retained-repository-pull-request.test.ts`. + */ +describe("the question a retained read states", () => { + it("RPR16: the name carries the site and the configuration carries neither", function* () { + for (const kind of PULL_REQUEST_READ_KINDS) { + const asked = readRequest(READ_URL, kind, undefined, OWNER, SITE); + // Exactly the six members, in the delivered spellings: the owner and the + // site travel as `runId` and `expansionId` because that is what the + // shipped request calls them. Read through the value this command hands + // to retention, which is what it depends on; serializing it is the + // domain's own and is covered where that codec lives. + expect({ ...asked }).toEqual({ + operation: "read", + url: READ_URL, + provider: null, + kind, + runId: OWNER, + expansionId: SITE, + }); + + // The configuration is the operation, the URL, the discriminator or its + // absence, and the collection. The owner and the site are outside it, + // which is what keeps a Workflow fork's inherited read the same effect — + // and the name is what makes it this occurrence's. + const configuration = gitOperationFingerprint(["read", READ_URL, null, kind]); + const elsewhere = readRequest(READ_URL, kind, undefined, '["another",1,"x","y"]', OTHER_SITE); + expect(gitOperationFingerprint(["read", elsewhere.url, elsewhere.provider, kind])).toBe( + configuration, + ); + expect(`${asked.expansionId}:${configuration}`).not.toBe( + `${elsewhere.expansionId}:${configuration}`, + ); + + // A named discriminator is part of the question, so it is part of the + // configuration: the same URL read through a stated provider is a + // different effect from the same URL matched by shape. + const named = readRequest(READ_URL, kind, "github", OWNER, SITE); + expect(named.provider).toBe("github"); + expect(gitOperationFingerprint(["read", READ_URL, "github", kind])).not.toBe(configuration); + } + }); + + it("RPR17: a success is held to the collection it asked for, item by item", function* () { + const head = "d".repeat(40); + const checks = { + kind: "checks", + headSha: head, + items: [ + { + kind: "check-run", + id: "1", + headSha: head, + name: "build", + status: "completed", + conclusion: "success", + url: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + }, + ], + }; + expect(parseCompletePullRequestReadResult(checks, "checks")).not.toBeUndefined(); + // The collection it asked for, and no other: a well-formed envelope of the + // wrong kind is not this question's answer. + expect(parseCompletePullRequestReadResult(checks, "reviews")).toBeUndefined(); + expect( + parseCompletePullRequestReadResult({ kind: "reviews", items: [] }, "checks"), + ).toBeUndefined(); + + // Every check item names the envelope's own head. One naming another + // commit is evidence about something else, and it binds no array. + const moved = { + ...checks, + items: [{ ...checks.items[0], headSha: "e".repeat(40) }], + }; + expect(parseCompletePullRequestReadResult(moved, "checks")).toBeUndefined(); + + // One extra member is a provider detail reaching a binding, so the whole + // envelope is refused rather than narrowed to what parsed. + expect( + parseCompletePullRequestReadResult({ ...checks, extra: true }, "checks"), + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/tests/retained-repository-pull-request.test.ts b/packages/cli/tests/retained-repository-pull-request.test.ts new file mode 100644 index 000000000..d5c213ed8 --- /dev/null +++ b/packages/cli/tests/retained-repository-pull-request.test.ts @@ -0,0 +1,2663 @@ +/** + * What one REPL entry's `` and its three evidence reads ask for, + * do once, and answer from afterwards. + * + * Every row runs a document through canonical execution with the production + * entry capability installed, against a real checkout, a real bare remote, and + * a GitHub model behind a real loopback listener the shipped transport reaches + * over HTTP. Nothing between those two host boundaries is substituted: the + * authored components ask the composition Api, the request is written down, the + * shared Git-host engine observes, the modeled service is contacted at most + * once, and what it holds is read back from the store rather than from what the + * run believes. + * + * The contract these rows hold the host to is the REPL's execution model, and + * it differs from publication in the one way that decides most of this file. A + * pull request publishes nothing: it is a statement about a branch that already + * exists at the Git host, so what admits one is *this* entry's own acknowledged + * publication of that branch. The rows that matter most are therefore the ones + * where some other publication is available — another checkout's, another + * entry's, a synthetic middleware answer, a superseded one — and the upsert + * still has to refuse before a request, a credential or a call exists. + * + * These rows carry B1's retained half, B2, B3, B4, B5, B7, and B10's assembly, + * rootless-read and absent-upsert cases. B6's portable foundation is in + * `retained-repository-pull-request-records.test.ts`; the public terminal + * journey is B9's and is not demonstrated here. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + each, + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, WithResolvers } from "effection"; +import { fromReadable } from "@effectionx/node"; +import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { chmod, mkdir, mkdtemp, realpath, symlink } from "node:fs/promises"; +import process from "node:process"; +import { readGit, runGit } from "./support/fixture-git.ts"; +import { gitShim } from "./support/repl-push-journey.ts"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { API, hostFilesHandler } from "@executablemd/runtime"; +import type { FilePathInput, FilesHandler, FileWriteInput } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; +import { collect, Elicitation } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import { Git } from "@executablemd/git/api"; +import type { GitPushInvocation } from "@executablemd/git/api"; +import type { GitPushOutcome } from "@executablemd/git"; +import { PullRequestAPI } from "@executablemd/git/api"; +import { createApi } from "@effectionx/context-api"; +import { PULL_REQUEST_OPERATIONS } from "@executablemd/git/api"; +import type { PullRequestOperationsApi } from "@executablemd/git/api"; +import type { PullRequestResult } from "@executablemd/git"; +import { parseCompletePullRequestReadResult } from "@executablemd/git"; +import type { PullRequestReadResult } from "@executablemd/git/api"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import type { GitHostProvider } from "@executablemd/git"; +import { replRepositoryInstallation } from "../src/repl/repositories/deno/provider.ts"; +import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; +import { useCompositionComponents } from "@executablemd/git"; +import { GIT_HOST_EFFECT } from "@executablemd/git"; +import { parseGitHostReconciliationRecord } from "@executablemd/git"; +import { parsePullRequestInputs } from "@executablemd/git"; +import { + parseReplPullRequestRequestRecord, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, +} from "../src/repl/repositories/pull-request-records.ts"; +import type { ReplPullRequestRequestRecord } from "../src/repl/repositories/pull-request-records.ts"; +import { + parseReplPushRequestRecord, + REPL_REPOSITORY_PUSH_REQUEST, +} from "../src/repl/repositories/push-records.ts"; +import type { ReplPushRequestRecord } from "../src/repl/repositories/push-records.ts"; +import { PULL_REQUEST_READ } from "@executablemd/git/deno"; +import { gitHubStore, mutations, respond } from "../../git/tests/support/github.ts"; +import { stored } from "../../git/tests/support/pull-requests.ts"; +import type { GitHubStore } from "../../git/tests/support/github.ts"; + +/** + * The variable a deployment writes its pull-request configuration into. + * + * Stated as data rather than imported: the name is part of the released + * configuration contract, and a fixture that reached the adapter's own private + * constant to spell it would be asserting the adapter against itself. + */ +const PULL_REQUEST_CONFIGURATION = "XMD_WORKFLOW_GITHUB_PULL_REQUESTS"; + +/** One request this suite's modeled GitHub receives, as its transport sees it. */ +interface GitHubHttpRequest { + readonly method: "GET" | "POST" | "PATCH"; + readonly url: string; + readonly headers: Readonly>; + readonly body?: string; +} + +/** One answer it gives back. */ +interface GitHubHttpResponse { + readonly status: number; + readonly body: string; + /** The `Link` header, when there is one. Pagination is read from it. */ + readonly link?: string; +} + +/** + * The semantic pull-request chain, as a composing caller reaches it. + * + * Built from the published name and interface, which is what makes a + * contextual Api composable: a descriptor created under the same stable name + * joins the same chain the entry's own invocation-local terminal sits at the + * end of. The value Git builds internally is private and is not what a caller + * composes onto — this suite is a caller. + * + * Its defaults refuse, because a row installing middleware here always + * delegates to the live chain below and never wants this object to answer. + */ +const PullRequestOperations = createApi(PULL_REQUEST_OPERATIONS, { + *read(): Operation { + throw new Error("this suite composes onto the read chain and never terminates it"); + }, + *upsert(): Operation { + throw new Error("this suite composes onto the upsert chain and never terminates it"); + }, +}); + +/** The owner the CLI derives for an entry, as this suite states one. */ +const OWNER = '["executablemd.repl.issue-owner",1,"execution-pull-request","1"]'; + +/** A second entry's owner, for the history that is not this entry's. */ +const FOREIGN_OWNER = '["executablemd.repl.issue-owner",1,"execution-pull-request","2"]'; + +/** + * The repository every document names, and the one the modeled GitHub holds. + * + * `owner/repository` because that is the pull request URL the shared GitHub + * model reports, and a read's identity *is* its URL: the container that admits + * the three evidence reads has to be the one the created pull request says it + * lives in. Git is handed the bare repository beside the test instead, through + * the one substitution below. + */ +const LOCATOR = "https://github.com/owner/repository"; + +const BRANCH = "review/1.4"; +const TITLE = "Prepare 1.4"; +const BODY = "Ready for review."; + +/** A credential no journal, result or read record may ever hold. */ +const TOKEN = "github-credential-for-this-test"; + +const STATED_IDENTITY: Record = { + GIT_AUTHOR_IDENT: "Entry Author 1700000000 +0000", + GIT_COMMITTER_IDENT: "Entry Committer 1700000000 +0000", +}; + +/** That identity, as the host reads one. */ +// deno-lint-ignore require-yield +function* statedIdentity(variable: string): Operation { + return STATED_IDENTITY[variable]; +} + +interface Fixture { + /** The scenario's own directory: checkouts, managed root and HOME. */ + readonly root: string; + /** The bare repository every managed Repository is cloned from. */ + readonly origin: string; + /** Where this scenario's managed checkouts live. */ + readonly managed: string; + /** A symbolic link an entry is opened through. */ + readonly link: string; + /** The GitHub this scenario's entries reach, and what it was asked. */ + readonly store: GitHubStore; + /** The API base the shipped transport is configured with. */ + readonly endpoint: string; + /** The evidence this GitHub reports for the three collections. */ + readonly evidence: Evidence; + /** Every `git` the shim has seen, so a row may read what really ran. */ + readonly commands: string; + /** What a row makes this GitHub do before it answers a request. */ + readonly gate: { hold?: (request: GitHubHttpRequest) => Operation }; +} + +/** What the modeled GitHub answers the three evidence collections with. */ +interface Evidence { + /** The reviews the pull request holds. */ + reviews: readonly Record[]; + /** The conversation comments on its issue. */ + conversation: readonly Record[]; + /** The inline review comments on its diff. */ + inline: readonly Record[]; + /** The check runs for its head. */ + runs: readonly Record[]; + /** The commit statuses for its head. */ + statuses: readonly Record[]; + /** A status every evidence route answers with instead of its collection. */ + refuses?: number; +} + +function evidence(): Evidence { + return { reviews: [], conversation: [], inline: [], runs: [], statuses: [] }; +} + +/** + * One bare remote with a commit in it, one GitHub, and this scenario's root. + * + * The listener is a real `node:http` server on an ephemeral port, so the + * shipped transport builds real URLs, reads a real credential and parses a real + * response — the entry assembly offers no seam to inject an access through, and + * pointing a configured endpoint at a loopback is how a deployment reaches + * something other than GitHub's own. + */ +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-pr-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const origin = join(root, "origin.git"); + const seed = join(root, "seed"); + yield* until(mkdir(seed, { recursive: true })); + yield* runGit(root, ["init", "--bare", "-b", "main", origin]); + yield* runGit(root, ["init", "-b", "main", seed]); + yield* writeTextFile(join(seed, "seed.md"), "seed\n"); + yield* runGit(seed, ["add", "seed.md"]); + yield* runGit(seed, ["commit", "-m", "seed"]); + yield* runGit(seed, ["push", origin, "main:refs/heads/main"]); + + const checkout = join(root, "checkout"); + yield* runGit(root, ["clone", origin, checkout]); + const link = join(root, "entered"); + yield* until(symlink(checkout, link)); + + // Where the logical GitHub locator this suite's documents write actually + // goes. Mapped at the executable leaf rather than inside the entry: the + // production host pins every configuration *file* off, so only an + // invocation-local `-c` reaches its Git — and this `url..insteadOf` + // is applied at transport time, which leaves `remote.origin.url` holding + // the locator the document wrote. `protocol.allow=never` with `file` + // allowed is what makes a missed mapping a refusal rather than a request + // to the real service. + const shim = yield* gitShim(root, { map: { from: LOCATOR, to: origin } }); + const shimmed = join(root, "git-shim"); + const beforePath = process.env.PATH; + process.env.PATH = `${shimmed}:${beforePath ?? ""}`; + yield* ensure(function* (): Operation { + if (beforePath === undefined) { + delete process.env.PATH; + return; + } + process.env.PATH = beforePath; + }); + + const store = gitHubStore({ owner: "owner", repository: "repository", token: TOKEN }); + const held = evidence(); + const gate: { hold?: (request: GitHubHttpRequest) => Operation } = {}; + const endpoint = yield* useGitHub(store, held, gate); + + // The credential the shipped transport reads. Set for this file's process + // and restored on teardown, because `denoGitHubAccess` asks the invoking + // environment for one and a machine login is not something a suite may + // reach: an unset variable would send this test to the host's own `gh`. + const before = process.env["GH_TOKEN"]; + process.env["GH_TOKEN"] = TOKEN; + yield* ensure(function* (): Operation { + if (before === undefined) { + delete process.env["GH_TOKEN"]; + return; + } + process.env["GH_TOKEN"] = before; + }); + + yield* provide({ + root, + origin, + managed: join(root, "repositories"), + link, + store, + endpoint, + evidence: held, + gate, + commands: shim.log, + }); + }); +} + +/** + * The modeled GitHub, behind a listener the shipped transport can reach. + * + * The pull-request and issue routes are the shared model's own. The five + * evidence routes are this suite's, because the shared model does not answer + * them — and they are built from the live endpoint, since a review that does + * not name the pull request it belongs to is refused by the adapter before it + * becomes an item. + */ +function useGitHub( + store: GitHubStore, + held: Evidence, + gate: { hold?: (request: GitHubHttpRequest) => Operation }, +): Operation { + return resource(function* (provide) { + const base = { endpoint: "" }; + const scope = yield* useScope(); + const server = createServer((incoming: IncomingMessage, outgoing: ServerResponse) => { + scope.run(function* (): Operation { + const chunks: Uint8Array[] = []; + for (const chunk of yield* each(fromReadable(incoming))) { + chunks.push(chunk); + yield* each.next(); + } + const headers: Record = {}; + for (const [name, value] of Object.entries(incoming.headers)) { + headers[name === "authorization" ? "Authorization" : name] = String(value); + } + const request: GitHubHttpRequest = { + method: + incoming.method === "POST" ? "POST" : incoming.method === "PATCH" ? "PATCH" : "GET", + url: `${base.endpoint}${incoming.url ?? "/"}`, + headers, + body: Buffer.concat(chunks).toString("utf8"), + }; + if (gate.hold !== undefined) { + // This listener answers from its own task, so a row that holds a + // request here leaves it genuinely in flight: the entry is waiting on + // a call this GitHub has received and not answered. + yield* gate.hold(request); + } + let answer: GitHubHttpResponse; + try { + answer = evidenceAnswer(store, held, base.endpoint, request) ?? respond(store, request); + } catch { + outgoing.destroy(); + return; + } + outgoing.writeHead(answer.status, { + "content-type": "application/json", + ...(answer.link === undefined ? {} : { link: answer.link }), + }); + outgoing.end(answer.body); + }); + }); + + yield* until(new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()))); + yield* ensure(function* (): Operation { + server.closeAllConnections(); + yield* until(new Promise((resolve) => server.close(() => resolve()))); + }); + base.endpoint = `http://127.0.0.1:${listeningPort(server.address())}`; + yield* provide(base.endpoint); + }); +} + +/** The port a listening server reports, or the failure that it reports none. */ +function listeningPort(address: string | AddressInfo | null): number { + if (address === null || typeof address === "string") { + throw new Error("this listener reported no address with a port"); + } + return address.port; +} + +/** This suite's answer for one of the five evidence routes, or nothing. */ +function evidenceAnswer( + store: GitHubStore, + held: Evidence, + endpoint: string, + request: GitHubHttpRequest, +): GitHubHttpResponse | undefined { + const url = new URL(request.url); + const prefix = `/repos/${store.owner}/${store.repository}`; + if (!url.pathname.startsWith(`${prefix}/`)) { + return undefined; + } + store.requests.push(request); + store.observe?.(request); + if (store.token !== undefined && request.headers["Authorization"] !== `Bearer ${store.token}`) { + return { status: 401, body: JSON.stringify({ message: "Bad credentials" }) }; + } + const rest = url.pathname.slice(prefix.length); + if (held.refuses !== undefined && !/^\/pulls\/[1-9][0-9]*$/.test(rest)) { + return { status: held.refuses, body: JSON.stringify({ message: "Server Error" }) }; + } + const reviews = /^\/pulls\/([1-9][0-9]*)\/reviews$/.exec(rest); + if (reviews !== null) { + return page( + held.reviews.map((item) => ({ + ...subjectOf(endpoint, store, "pulls", reviews[1] ?? ""), + ...item, + })), + ); + } + const conversation = /^\/issues\/([1-9][0-9]*)\/comments$/.exec(rest); + if (conversation !== null) { + return page( + held.conversation.map((item) => ({ + issue_url: `${endpoint}/repos/${store.owner}/${store.repository}/issues/${conversation[1]}`, + ...item, + })), + ); + } + const inline = /^\/pulls\/([1-9][0-9]*)\/comments$/.exec(rest); + if (inline !== null) { + return page( + held.inline.map((item) => ({ + ...subjectOf(endpoint, store, "pulls", inline[1] ?? ""), + ...item, + })), + ); + } + const runs = /^\/commits\/([0-9a-f]+)\/check-runs$/.exec(rest); + if (runs !== null) { + const head = runs[1] ?? ""; + return { + status: 200, + body: JSON.stringify({ + total_count: held.runs.length, + check_runs: held.runs.map((item) => ({ head_sha: head, ...item })), + }), + }; + } + const combined = /^\/commits\/([0-9a-f]+)\/status$/.exec(rest); + if (combined !== null) { + return { + status: 200, + body: JSON.stringify({ sha: combined[1] ?? "", statuses: held.statuses }), + }; + } + // Not an evidence route. Take the request back off the log so the shared + // model records it exactly once. + store.requests.pop(); + return undefined; +} + +/** One page of evidence, as every collection route answers with one. */ +function page(items: readonly unknown[]): GitHubHttpResponse { + return { status: 200, body: JSON.stringify(items) }; +} + +/** The subject URL an item names its pull request by. */ +function subjectOf( + endpoint: string, + store: GitHubStore, + collection: string, + number: string, +): Record { + return { + pull_request_url: `${endpoint}/repos/${store.owner}/${store.repository}/${collection}/${number}`, + }; +} + +/** One review, as GitHub reports one. */ +function review(id: number, state: string, body: string): Record { + return { + id, + user: { login: "reviewer" }, + state, + body, + submitted_at: "2026-10-20T00:00:00Z", + commit_id: "0".repeat(40), + html_url: `https://github.com/owner/repository/pull/1#r${id}`, + }; +} + +/** One review, as the boundary that validates one has already read it. */ +function reviewEvidence( + id: number, + state: "approved" | "changes-requested" | "commented" | "dismissed" | "pending", +) { + return { + id: String(id), + author: "reviewer", + state, + body: "added by a handler", + submittedAt: null, + commitSha: null, + url: `https://github.com/owner/repository/pull/1#r${id}`, + } as const; +} + +/** One conversation comment, as GitHub reports one. */ +function comment(id: number, body: string): Record { + return { + id, + user: { login: "commenter" }, + body, + created_at: "2026-10-20T00:00:00Z", + updated_at: "2026-10-20T00:00:00Z", + html_url: `https://github.com/owner/repository/pull/1#c${id}`, + }; +} + +/** One check run, as GitHub reports one. */ +function checkRun(id: number, name: string): Record { + return { + id, + name, + status: "completed", + conclusion: "success", + html_url: `https://github.com/owner/repository/runs/${id}`, + started_at: "2026-10-20T00:00:00Z", + completed_at: "2026-10-20T00:01:00Z", + output: { title: name, summary: "ok", text: null }, + }; +} + +/** What a substituted credential broker reported about its own lifetime. */ +interface BlockingLogin { + /** Resolves once the broker is running and has answered nothing. */ + readonly started: Operation; + /** Whether the broker's process is still there. */ + alive(): Operation; +} + +/** + * A Git-host login command that blocks, on this entry's own path. + * + * The leaf this host asks for a credential when neither explicit variable + * names one. Substituting the command rather than the source keeps the entry + * assembly exactly as production builds it: nothing here is an option the + * factory grew, and the operation under test still opens the shipped transport. + */ +function useBlockingLogin(fixture: Fixture): Operation { + return resource(function* (provide) { + const directory = join(fixture.root, "broker"); + const marks = join(fixture.root, "broker-marks"); + yield* ensureDir(directory); + yield* ensureDir(marks); + yield* writeTextFile( + join(directory, "gh"), + [ + "#!/bin/sh", + `echo $$ > ${JSON.stringify(join(marks, "pid"))}`, + `: > ${JSON.stringify(join(marks, "started"))}`, + "while true; do sleep 0.05; done", + "", + ].join("\n"), + ); + yield* until(chmod(join(directory, "gh"), 0o755)); + + // No explicit credential, so the broker is what this host asks. Both + // variables and the path are restored with this resource. + const held = { + path: process.env["PATH"], + token: process.env["GH_TOKEN"], + other: process.env["GITHUB_TOKEN"], + }; + process.env["PATH"] = `${directory}:${held.path ?? ""}`; + delete process.env["GH_TOKEN"]; + delete process.env["GITHUB_TOKEN"]; + yield* ensure(function* (): Operation { + restoreVariable("PATH", held.path); + restoreVariable("GH_TOKEN", held.token); + restoreVariable("GITHUB_TOKEN", held.other); + }); + + const reached = withResolvers(); + const watching = yield* spawn(function* (): Operation { + while (!(yield* exists(join(marks, "started")))) { + yield* sleep(10); + } + reached.resolve(); + }); + yield* ensure(() => watching.halt()); + + yield* provide({ + started: reached.operation, + *alive(): Operation { + if (!(yield* exists(join(marks, "pid")))) { + return false; + } + const pid = Number((yield* readTextFile(join(marks, "pid"))).trim()); + try { + // Signal 0 asks the operating system whether the process is there. + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + }); + }); +} + +/** Put one environment variable back exactly as it was, absence included. */ +function restoreVariable(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +/** What the host beneath the recorder was asked to do. */ +interface Counters { + /** Every repository-handle method this entry called, by name, in order. */ + methods: string[]; + /** Every configuration variable this entry read, in order. */ + env: string[]; +} + +function counters(): Counters { + return { methods: [], env: [] }; +} + +/** The handle methods one entry called, in order. */ +function methodsCalled(counted: Counters): string[] { + return [...counted.methods]; +} + +/** The host filesystem, as the production one. */ +function hostFiles(): FilesHandler { + const host = hostFilesHandler(); + return { + checkFilePath: (input: FilePathInput) => host.checkFilePath(input), + readTextFile: (input: FilePathInput) => host.readTextFile(input), + globFiles: (input) => host.globFiles(input), + temporaryDirectory: () => host.temporaryDirectory(), + writeTextFile: (input: FileWriteInput) => host.writeTextFile(input), + deleteFile: (input) => host.deleteFile(input), + ensureDirectory: (input) => host.ensureDirectory(input), + }; +} + +/** + * The production handle, counted, with the modeled host kept in step. + * + * Every method is wrapped rather than replaced: what these rows need to know is + * whether a publication really happened and what the pull request's head really + * is, and the only honest way to answer is to let the real provider publish and + * then read the remote. + * + * The logical GitHub locator the document writes is not swapped here. It is + * mapped at the executable leaf the fixture installs, so the rewrite is + * invocation-local, every protocol but `file` is refused, and what Git stores + * as `remote.origin.url` stays the locator the document wrote. + * + * The returned provider is decorated so the modeled GitHub learns what the real + * remote now holds: after a delegated stage succeeds, this reads that branch's + * bare ref with the fixture's own executable and refreshes the model from the + * value it finds. The delegated `Result` is returned unchanged — nothing here + * invents a publication or populates a head from what was merely asked for. + */ +function countingOperations( + fixture: Fixture, + counted: Counters, + options: RunOptions = {}, +): () => Operation { + if (options.refuseNative === true) { + return refusingOperations(counted); + } + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + const wrapped: DenoRepositoryOperations = { + ...operations, + *currentBranch(directory) { + counted.methods.push("currentBranch"); + return yield* operations.currentBranch(directory); + }, + *resolveCommit(directory, revision) { + counted.methods.push("resolveCommit"); + return yield* operations.resolveCommit(directory, revision); + }, + *commitPresent(directory, commit) { + counted.methods.push("commitPresent"); + return yield* operations.commitPresent(directory, commit); + }, + *pushProvider(commonDirectory, origin, inputs) { + counted.methods.push("pushProvider"); + const provider = yield* operations.pushProvider(commonDirectory, origin, inputs); + const learned: GitHostProvider = { + *observe(...given) { + const observed = yield* provider.observe(...given); + yield* learnHead(fixture, inputs.branch); + return observed; + }, + *perform(...given) { + const performed = yield* provider.perform(...given); + yield* learnHead(fixture, inputs.branch); + return performed; + }, + }; + return learned; + }, + }; + return wrapped; + }; +} + +/** A handle whose every method records the unexpected call and refuses it. */ +function refusingOperations(counted: Counters): () => Operation { + function* refuse(method: string): Operation { + counted.methods.push(method); + throw new Error(`this row's repository handle must not be reached: ${method}`); + } + // deno-lint-ignore require-yield + return function* (): Operation { + return { + *ambientCheckout() { + return yield* refuse("ambientCheckout"); + }, + *ambientRepository() { + return yield* refuse("ambientRepository"); + }, + *selectRepository() { + return yield* refuse("selectRepository"); + }, + *selectWorktree() { + return yield* refuse("selectWorktree"); + }, + *switch() { + return yield* refuse("switch"); + }, + *add() { + return yield* refuse("add"); + }, + *commit() { + return yield* refuse("commit"); + }, + *currentBranch() { + return yield* refuse("currentBranch"); + }, + *resolveCommit() { + return yield* refuse("resolveCommit"); + }, + *commitPresent() { + return yield* refuse("commitPresent"); + }, + *observe() { + return yield* refuse("observe"); + }, + *pushProvider() { + return yield* refuse("pushProvider"); + }, + }; + }; +} + +/** + * Tell the modeled GitHub what the real remote now holds for one branch. + * + * Read rather than inferred: the value comes from the bare repository the + * publication actually wrote to, so a model head is never populated from what + * somebody asked for. An absent ref deletes the entry, because "not there" is + * an answer the model has to be able to hold. + */ +function* learnHead(fixture: Fixture, branch: string): Operation { + const held = yield* readGit(fixture.origin, ["rev-parse", "--verify", `refs/heads/${branch}`]); + if (held === undefined) { + fixture.store.heads.delete(branch); + return; + } + fixture.store.heads.set(branch, held); +} + +interface Run { + readonly output: string; + readonly events: readonly DurableEvent[]; + readonly refusal: string | undefined; +} + +interface RunOptions { + /** The directory the entry is opened in. Defaults to the fixture's link. */ + readonly cwd?: string; + /** What this row installs between the host and the recording. */ + readonly between?: () => Operation; + /** Whether this entry's repository handle refuses every method. */ + readonly refuseNative?: boolean; + /** Whether this entry installs a managed root at all. */ + readonly root?: string | undefined; + /** The owner this entry's work is retained under. */ + readonly owner?: string; + /** The answer a question in this entry receives, when a row supplies one. */ + readonly answer?: Json; + /** The containers this deployment allows pull-request reads in. */ + readonly allowed?: readonly string[]; + /** Whether this deployment states any pull-request configuration at all. */ + readonly configured?: boolean; +} + +/** One entry, over one journal, in one checkout. */ +function run( + fixture: Fixture, + source: string, + stream: DurableStream, + counted: Counters, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const path = join(fixture.root, "entry.md"); + yield* writeTextFile(path, source); + const working = options.cwd ?? fixture.link; + const stated = + options.configured === false + ? undefined + : JSON.stringify({ + allowed: options.allowed ?? [LOCATOR], + endpoint: fixture.endpoint, + }); + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return working; + }, + // deno-lint-ignore require-yield + *env([name]: [string]): Operation { + counted.env.push(name); + return name === PULL_REQUEST_CONFIGURATION ? stated : undefined; + }, + }, + { at: "min" }, + ); + const files = hostFiles(); + yield* API.Files.around( + { + checkFilePath: ([input]) => files.checkFilePath(input), + readTextFile: ([input]) => files.readTextFile(input), + globFiles: ([input]) => files.globFiles(input), + temporaryDirectory: () => files.temporaryDirectory(), + writeTextFile: ([input]) => files.writeTextFile(input), + deleteFile: ([input]) => files.deleteFile(input), + ensureDirectory: ([input]) => files.ensureDirectory(input), + }, + { at: "min" }, + ); + yield* useCompositionComponents(); + if (options.answer !== undefined) { + const answer = options.answer; + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit(): Operation { + return answer; + }, + }, + { at: "min" }, + ); + } + if (options.between !== undefined) { + yield* options.between(); + } + const managed = "root" in options ? options.root : fixture.managed; + // Built the way the session builds it: one per-entry factory, called in the + // entry's own task, for the owner the CLI derived. + const installation = yield* replRepositoryInstallation(options.owner ?? OWNER, { + operations: countingOperations(fixture, counted, options), + identity: statedIdentity, + ...(managed === undefined ? {} : { root: managed }), + }); + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled({ path, stream, includes: [fixture.root] }, [ + { admissions: [(retained) => admitReplRepositoryHistory(retained)] }, + installation, + ]), + ), + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { output, events: yield* stream.readAll(), refusal }; + }); +} + +/** + * The prefix an entry left behind when it was left at a question. + * + * A real unfinished history, not a complete one with its closes removed: a + * journal whose run finished replays every position it holds and runs no + * component, so a continuation with a new suffix would never reach it. + */ +function left( + fixture: Fixture, + source: string, + counted: Counters, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const stream = new InMemoryStream(); + const reached = withResolvers(); + const task = yield* spawn(() => + run(fixture, source, stream, counted, { + ...options, + *between(): Operation { + if (options.between !== undefined) { + yield* options.between(); + } + yield* Elicitation.around({ + *elicit(): Operation { + reached.resolve(); + return yield* suspend(); + }, + }); + }, + }), + ); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(60_000); + throw new Error("the entry never reached its question"); + })(), + ]); + const prefix = [...(yield* stream.readAll())]; + yield* task.halt(); + return prefix; + }); +} + +/** + * The history an entry left behind when it was cut at a named request. + * + * The honest shape of an interrupted pull request: the modeled service receives + * the call and never answers it, so the entry is halted with the request in + * flight and what the journal holds is whatever had already been acknowledged. + * The cut is the halt below, not a fabricated prefix. + */ +function cut( + fixture: Fixture, + source: string, + counted: Counters, + at: (request: GitHubHttpRequest) => boolean, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const stream = new InMemoryStream(); + const reached = withResolvers(); + fixture.gate.hold = function* (request: GitHubHttpRequest): Operation { + if (at(request)) { + reached.resolve(); + yield* suspend(); + } + }; + yield* ensure(() => { + delete fixture.gate.hold; + }); + const task = yield* spawn(() => run(fixture, source, stream, counted, options)); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(60_000); + throw new Error("the entry never reached the request it was cut at"); + })(), + ]); + const prefix = [...(yield* stream.readAll())]; + yield* task.halt(); + return prefix; + }); +} + +/** Every saved pull-request request this history holds, parsed. */ +function requests(events: readonly DurableEvent[]): ReplPullRequestRequestRecord[] { + const found: ReplPullRequestRequestRecord[] = []; + for (const event of events) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + if (event.description.type !== REPL_REPOSITORY_PULL_REQUEST_REQUEST) { + continue; + } + const record = parseReplPullRequestRequestRecord(event.result.value); + if (record !== undefined) { + found.push(record); + } + } + return found; +} + +/** Every saved publication request this history holds, parsed. */ +function publications(events: readonly DurableEvent[]): ReplPushRequestRecord[] { + const found: ReplPushRequestRecord[] = []; + for (const event of events) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + if (event.description.type !== REPL_REPOSITORY_PUSH_REQUEST) { + continue; + } + const record = parseReplPushRequestRecord(event.result.value); + if (record !== undefined) { + found.push(record); + } + } + return found; +} + +/** Every Git-host outcome this history holds, as the journal holds it. */ +function outcomes(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === GIT_HOST_EFFECT, + ); +} + +/** Every retained evidence read this history holds, in append order. */ +function reads(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === PULL_REQUEST_READ, + ); +} + +/** + * What each retained read holds, sorted so a schedule cannot decide the answer. + * + * The collection and its size, read back through the delivered parser: two + * concurrent reads are two complete envelopes, and which of them was appended + * first is a fact about when they finished rather than about what each asked. + */ +function collections( + retained: readonly DurableEvent[], +): { readonly kind: string; readonly items: number }[] { + const held: { readonly kind: string; readonly items: number }[] = []; + for (const event of retained) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + for (const kind of ["reviews", "comments", "checks"] as const) { + const parsed = parseCompletePullRequestReadResult(event.result.value, kind); + if (parsed !== undefined) { + held.push({ kind, items: parsed.items.length }); + } + } + } + return [...held].sort((first, second) => first.kind.localeCompare(second.kind)); +} + +/** Where each durable position of one type stands in this history. */ +function positionsOf(events: readonly DurableEvent[], type: string): number[] { + const found: number[] = []; + events.forEach((event, index) => { + if (event.type === "yield" && event.description.type === type) { + found.push(index); + } + }); + return found; +} + +/** Every event but the root's own close, which is what an unfinished run has. */ +function terminal(event: DurableEvent): boolean { + return !(event.type === "close" && event.coroutineId === "root"); +} + +/** Whether the root settled, which a durability failure leaves unwritten. */ +function closedRoot(events: readonly DurableEvent[]): boolean { + return events.some((event) => event.type === "close" && event.coroutineId === "root"); +} + +/** A journal that refuses every append of one durable type. */ +function refusing(inner: InMemoryStream, type: string): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === type) { + throw new Error("the journal backend refused this append"); + } + yield* inner.append(event); + }, + }; +} + +/** + * A journal that refuses one durable type's appends after the first few. + * + * What a pull request's own acknowledgement window needs: an entry publishes a + * branch and then opens a pull request, and both reconcile through the one + * shared Git-host position. Refusing every append of that type would stop the + * entry at its publication, long before the window this is about. + */ +function refusingAfter(inner: InMemoryStream, type: string, allow: number): DurableStream { + let seen = 0; + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === type) { + seen += 1; + if (seen > allow) { + throw new Error("the journal backend refused this append"); + } + } + yield* inner.append(event); + }, + }; +} + +/** A journal that holds every append of one durable type, and says when it did. */ +function holdingAppend( + inner: InMemoryStream, + type: string, + reached: WithResolvers, +): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === type) { + reached.resolve(); + yield* suspend(); + } + yield* inner.append(event); + }, + }; +} + +/** The managed checkout a `` row selected. */ +function checkoutOf(events: readonly DurableEvent[], branch: string): string { + const saved = publications(events).find((record) => record.resolved.inputs.branch === branch); + if (saved === undefined) { + throw new Error("this history holds no publication of that branch"); + } + return saved.resolved.checkoutRoot; +} + +/** Commit one more file in a managed checkout, as a person at a shell would. */ +function* commitThere(checkout: string, name: string): Operation { + yield* writeTextFile(join(checkout, name), `${name}\n`); + yield* runGit(checkout, ["add", name]); + yield* runGit(checkout, [ + "-c", + "user.name=Shell", + "-c", + "user.email=shell@example.test", + "commit", + "-m", + `outside ${name}`, + ]); + return yield* runGit(checkout, ["rev-parse", "HEAD"]); +} + +/** Move a checkout back to an earlier commit, as a person at a shell would. */ +function* resetThere(checkout: string, commit: string): Operation { + yield* runGit(checkout, ["reset", "--hard", commit]); +} + +/** Leave the published branch behind for another one, as a person would. */ +function* switchThere(checkout: string, branch: string): Operation { + yield* runGit(checkout, ["checkout", "-b", branch]); +} + +/** The pull-request inputs reconciliation was actually asked about. */ +function reconciledInputs(events: readonly DurableEvent[]): Record | undefined { + for (const event of outcomes(events)) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + const record = parseGitHostReconciliationRecord(event.result.value); + const inputs = record === undefined ? undefined : parsePullRequestInputs(record.request.inputs); + if (inputs !== undefined) { + return { + number: inputs.number, + title: inputs.title, + body: inputs.body, + draft: inputs.draft, + headBranch: inputs.headBranch, + headSha: inputs.headSha, + baseBranch: inputs.baseBranch, + }; + } + } + return undefined; +} + +/** What the shared engine decided at each reconciled position, in order. */ +function decisionsOf(events: readonly DurableEvent[]): string[] { + const found: string[] = []; + for (const event of outcomes(events)) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + const record = parseGitHostReconciliationRecord(event.result.value); + if (record !== undefined) { + found.push(record.decision); + } + } + return found; +} + +/** A question with a file after it, so a continuation has a suffix to run. */ +const QUESTION = [ + ` `, + `The pull request is open. Continue?`, + ` `, + ` continued`, +].join("\n"); + +/** The element that opens or updates this suite's pull request. */ +function upsert(...attributes: string[]): string[] { + return [ + ` `, + BODY, + ` `, + "", + `opened {pullRequest.number} at {pullRequest.url} as {pullRequest.state}`, + "", + ]; +} + +/** All three evidence reads of whatever the upsert bound, and their sizes. */ +const READS = [ + ` `, + ` `, + ` `, + "", + `evidence {reviews.length} {comments.length} {checks.length}`, + "", +]; + +/** + * A document that publishes a branch in a managed Repository, and then does + * whatever this row is about. + */ +function entry( + options: { + readonly name?: string; + readonly branch?: string; + readonly file?: string; + readonly lines?: readonly string[]; + } = {}, +): string { + const branch = options.branch ?? BRANCH; + const file = options.file ?? "notes.md"; + return [ + ``, + ` `, + ` ready`, + ` `, + ` `, + ` `, + ...(options.lines ?? []), + ``, + "", + ].join("\n"); +} + +describe("one entry's pull request", () => { + it("Q1: opens it once, and answers a completed one with no Git and no call", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + fixture.evidence.conversation = [comment(21, "thanks")]; + fixture.evidence.runs = [checkRun(31, "build")]; + const first = counters(); + const stream = new InMemoryStream(); + const source = entry({ lines: [...upsert(), ...READS] }); + + const ran = yield* run(fixture, source, stream, first); + expect(ran.refusal).toBe(undefined); + + // One pull request, created once, and the arrays the document bound are + // what the modeled service holds. + expect(ran.output).toContain("opened 1 at https://github.com/owner/repository/pull/1 as open"); + expect(ran.output).toContain("evidence 1 1 1"); + expect(mutations(fixture.store)).toEqual(["create"]); + const saved = requests(ran.events); + expect(saved).toHaveLength(1); + expect(saved[0]?.owner).toBe(OWNER); + expect(saved[0]?.resolved.origin).toBe(LOCATOR); + expect(saved[0]?.requested.pullRequest.number).toBe(null); + expect(saved[0]?.requested.pullRequest.title).toBe(TITLE); + expect(saved[0]?.requested.pullRequest.draft).toBe(false); + expect(saved[0]?.requested.pullRequest.base).toBe("main"); + // The head is the commit the published branch holds, not a value anything + // told this entry. + expect(saved[0]?.resolved.inputs.headSha).toBe(fixture.store.heads.get(BRANCH)); + expect(saved[0]?.resolved.inputs.headBranch).toBe(BRANCH); + // A publication and a pull request are two outcomes at two positions, and + // the three reads are three separate retained collections. + expect(outcomes(ran.events)).toHaveLength(2); + expect(reads(ran.events)).toHaveLength(3); + // The credential reached the service and nothing else. + expect(fixture.store.requests.length).toBeGreaterThan(0); + expect(JSON.stringify(ran.events)).not.toContain(TOKEN); + + // The same entry again, over the history it left. Nothing native happens, + // nothing is asked of the service, and the arrays are the retained ones. + const second = counters(); + const sent = fixture.store.requests.length; + const replayed = new InMemoryStream([...ran.events].filter(terminal)); + const again = yield* run(fixture, source, replayed, second); + expect(again.refusal).toBe(undefined); + expect(again.output).toContain( + "opened 1 at https://github.com/owner/repository/pull/1 as open", + ); + expect(again.output).toContain("evidence 1 1 1"); + expect(methodsCalled(second)).toEqual([]); + expect(fixture.store.requests.length).toBe(sent); + const settled = yield* replayed.readAll(); + expect(requests(settled)).toHaveLength(1); + expect(outcomes(settled)).toHaveLength(2); + expect(reads(settled)).toHaveLength(3); + }); + + it("Q2: a superseding publication of the same branch decides the head", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + // Two publications of one destination in one entry, then the pull request. + // The second is what the branch at the host holds, so it is the one the + // pull request may be opened over. + const source = [ + ``, + ` `, + ` one`, + ` `, + ` `, + ` `, + ` two`, + ` `, + ` `, + ` `, + ...upsert(), + ``, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, stream, counted); + expect(ran.refusal).toBe(undefined); + const published = publications(ran.events); + expect(published).toHaveLength(2); + const latest = published[1]?.resolved.inputs.sourceCommit; + expect(latest).not.toBe(published[0]?.resolved.inputs.sourceCommit); + expect(requests(ran.events)[0]?.resolved.inputs.headSha).toBe(latest); + expect(mutations(fixture.store)).toEqual(["create"]); + }); + + it("Q3: another checkout's publication of the same commit authorizes nothing", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + // One entry, two Repositories of the same remote. The first publishes the + // branch; the second fetches and stands on the very same commit, and asks + // for the pull request there. + const source = [ + entry().trimEnd(), + ``, + ` `, + ...upsert(), + ``, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, stream, counted); + expect(ran.refusal).not.toBe(undefined); + expect(String(ran.refusal)).toContain("publication"); + // The branch really is at the host, and really is at that commit. What is + // missing is this checkout's own acknowledged publication of it. + expect(publications(ran.events)).toHaveLength(1); + expect(requests(ran.events)).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + }); + + it("Q4: a publication retained under another entry authorizes nothing", function* () { + const fixture = yield* useFixture(); + const foreign = counters(); + const published = yield* left(fixture, entry({ lines: [QUESTION] }), foreign, { + owner: FOREIGN_OWNER, + }); + expect(publications(published)).toHaveLength(1); + + // The same journal, continued by an entry of its own. The history is valid + // and names a publication of exactly this branch — under somebody else. + const counted = counters(); + const stream = new InMemoryStream([...published]); + const ran = yield* run(fixture, entry({ lines: [...upsert(), QUESTION] }), stream, counted, { + answer: { choice: "continue" }, + }); + expect(ran.refusal).not.toBe(undefined); + expect(requests(yield* stream.readAll())).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + }); + + it("Q5: a synthetic publication answer authorizes nothing", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + let answered = 0; + const ran = yield* run(fixture, entry({ lines: [...upsert()] }), stream, counted, { + *between(): Operation { + // A handler above the entry's own policy that answers the + // publication itself. The document is satisfied; nothing was + // published and nothing was retained, so there is no acknowledged + // pair for a pull request to be admitted by. + yield* Git.around({ + // deno-lint-ignore require-yield + *push([invocation]: [GitPushInvocation]): Operation { + answered += 1; + return { + decision: "performed", + result: { + repository: invocation.repository.identity, + remote: "origin", + branch: BRANCH, + destinationRef: `refs/heads/${BRANCH}`, + refspec: `${"0".repeat(40)}:refs/heads/${BRANCH}`, + sourceCommit: "0".repeat(40), + observedRemoteCommit: "0".repeat(40), + }, + }; + }, + }); + }, + }); + expect(answered).toBe(1); + expect(ran.refusal).not.toBe(undefined); + expect(publications(ran.events)).toEqual([]); + expect(requests(ran.events)).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + }); + + it("Q6: a head the last publication did not put there refuses before contact", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = entry({ lines: [QUESTION.split("\n").slice(0, 4).join("\n"), ...upsert()] }); + + // Published, then left at the question. This pair is exact for the branch + // it names, and it is the only one. + const prefix = yield* left(fixture, source, counted); + const published = publications(prefix); + expect(published).toHaveLength(1); + const checkout = checkoutOf(prefix, BRANCH); + + // Somebody advances the managed checkout while the entry is away, without + // publishing it. The branch at the host still holds the published commit, + // so the head this pull request would name was never published by anyone. + const moved = yield* commitThere(checkout, "later.md"); + expect(moved).not.toBe(published[0]?.resolved.inputs.sourceCommit); + + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + }); + expect(again.refusal).not.toBe(undefined); + expect(String(again.refusal)).toContain("different"); + expect(requests(yield* stream.readAll())).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + }); + + it("Q7a: an exact earlier pair superseded by a later one refuses the earlier head", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = [ + ``, + ` `, + ` one`, + ` `, + ` `, + ` `, + ` two`, + ` `, + ` `, + ` `, + QUESTION.split("\n").slice(0, 4).join("\n"), + ...upsert(), + ``, + "", + ].join("\n"); + + // Both publications are this entry's own and both are exact for their own + // commit. The earlier one is a real acknowledged pair for this destination. + const prefix = yield* left(fixture, source, counted); + const pairs = publications(prefix); + expect(pairs).toHaveLength(2); + const earlier = pairs[0]?.resolved.inputs.sourceCommit ?? ""; + const later = pairs[1]?.resolved.inputs.sourceCommit ?? ""; + expect(earlier).not.toBe(later); + + // The person moves the checkout back to the earlier commit while away, so + // the pull request would name the head the *earlier* pair published. + yield* resetThere(checkoutOf(prefix, BRANCH), earlier); + + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + }); + expect(again.refusal).not.toBe(undefined); + // The last acknowledged publication of that destination names a different + // commit, so the two describe different work and this refuses before a + // request is written, a credential is read or anything is contacted. + expect(String(again.refusal)).toContain("different"); + expect(requests(yield* stream.readAll())).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + }); +}); + +describe("what a bound pull-request body does first", () => { + it("Q26: a Push child and an admitted read both acknowledge before the parent's request", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + const stream = new InMemoryStream(); + // The publication and the evidence read are children of the pull request + // itself. All of the content finishes before the parent resolves, which is + // what makes a `` written inside the body the publication the + // parent is then admitted by. + const source = [ + ``, + ` `, + ` ready`, + ` `, + ` `, + ` `, + ``, + ``, + BODY, + ` `, + "", + `opened {pullRequest.number} over {inner.length}`, + "", + ``, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, stream, counted); + expect(ran.refusal).toBe(undefined); + expect(ran.output).toContain("opened 1 over 1"); + + // One of each, and the children stand before the parent at every position + // the parent has: its request and its reconciled outcome alike. + const publication = positionsOf(ran.events, REPL_REPOSITORY_PUSH_REQUEST); + const snapshot = positionsOf(ran.events, PULL_REQUEST_READ); + const asked = positionsOf(ran.events, REPL_REPOSITORY_PULL_REQUEST_REQUEST); + const reconciled = positionsOf(ran.events, GIT_HOST_EFFECT); + expect(publication).toHaveLength(1); + expect(snapshot).toHaveLength(1); + expect(asked).toHaveLength(1); + expect(reconciled).toHaveLength(2); + const parent = asked[0] ?? -1; + expect(publication[0] ?? -1).toBeLessThan(parent); + expect(snapshot[0] ?? -1).toBeLessThan(parent); + // The publication's own outcome too: the parent is admitted by an + // acknowledged pair, not by a request still in flight. + expect(reconciled[0] ?? -1).toBeLessThan(parent); + expect(reconciled[1] ?? -1).toBeGreaterThan(parent); + expect(mutations(fixture.store)).toEqual(["create"]); + }); + + it("Q27: a child that refuses leaves the parent with no request and nothing contacted", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + // The same body, with one child reading a container this deployment never + // allowed. The parent never resolves, so it asks for nothing. + const source = [ + ``, + ` `, + ` ready`, + ` `, + ` `, + ` `, + ``, + ``, + BODY, + ` `, + "", + `opened {pullRequest.number}`, + "", + ``, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, stream, counted); + expect(ran.refusal).not.toBe(undefined); + expect(String(ran.refusal)).toContain("has not authorized"); + const held = yield* stream.readAll(); + // The publication child did run and is history; the parent's own request + // was never written and the Git host was never asked to open anything. + expect(publications(held)).toHaveLength(1); + expect(requests(held)).toEqual([]); + expect(reads(held)).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + expect(ran.output).not.toContain("opened 1"); + }); + + it("Q28: a repository-local component of either name answers instead, and retains nothing", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + // Repository-local Markdown of both names, which the resolver chooses + // ahead of any registered default — including the one an entry declares in + // its own scope. Declaring policy under a shared body reserves neither + // name against a document that ships its own component for it. + const local = ["---", "props:", " type: object", " additionalProperties: true", "---", ""]; + yield* ensureDir(join(fixture.root, "PullRequest")); + yield* writeTextFile( + join(fixture.root, "PullRequest.md"), + [...local, "local pull request", ""].join("\n"), + ); + yield* writeTextFile( + join(fixture.root, "PullRequest", "Reviews.md"), + [...local, "local reviews", ""].join("\n"), + ); + + const source = [ + ``, + ` `, + ` ready`, + ` `, + ` `, + ` `, + ` `, + BODY, + ` `, + ` `, + ``, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, stream, counted); + expect(ran.refusal).toBe(undefined); + // Both local components answered, so neither name reached this entry's + // policy — no `as` was required of either, because the metadata resolved + // is the local component's own. + // Both names resolved to the local components, which is what the absence + // below says: the entry's own bodies cannot run and leave no request, no + // snapshot and no call behind them. + const held = yield* stream.readAll(); + expect(requests(held)).toEqual([]); + expect(reads(held)).toEqual([]); + expect(fixture.store.requests).toEqual([]); + // The publication beside them is untouched, which is what says this is + // ordinary resolution rather than a disabled entry. + expect(publications(held)).toHaveLength(1); + }); +}); + +describe("resuming a pull request", () => { + it("Q8: an interrupted create resumes the request it saved, not today's head", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = entry({ lines: [...upsert(), QUESTION] }); + + // Cut with the request acknowledged and the service holding an observation + // it never answered: nothing was created, and the journal holds what this + // entry asked for. + const prefix = yield* cut( + fixture, + source, + counted, + (request) => request.method === "GET" && new URL(request.url).pathname.endsWith("/pulls"), + ); + const saved = requests(prefix); + expect(saved).toHaveLength(1); + expect(outcomes(prefix).filter((event) => event.result.status === "ok")).toHaveLength(1); + expect(fixture.store.pullRequests).toEqual([]); + const asked = saved[0]?.resolved.inputs; + expect(asked?.number).toBe(null); + + // Somebody advances the managed checkout while the entry is away. The + // continuation reconciles the request it already made, at the head that + // request named. + const checkout = checkoutOf(prefix, BRANCH); + const moved = yield* commitThere(checkout, "later.md"); + expect(moved).not.toBe(asked?.headSha); + + let retargeted = 0; + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + *between(): Operation { + // A supported upsert handler installed after the cut, which would + // demonstrably retarget fresh work: it rewrites the title every + // invocation it is given. A saved request is reconciled as it stands, + // so it never reaches the chain this handler is part of. + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + retargeted += 1; + return yield* next({ + ...invocation, + pullRequest: { ...invocation.pullRequest, title: "Retargeted by middleware" }, + }); + }, + }); + }, + }); + expect(again.refusal).toBe(undefined); + // Never called, so the saved request did not come back through fresh + // dispatch at all. + expect(retargeted).toBe(0); + + // One request, one creation, and every member of it is the one that was + // written down — including the verbatim body and the original head. + const settled = yield* stream.readAll(); + expect(requests(settled)).toHaveLength(1); + expect(mutations(fixture.store)).toEqual(["create"]); + const created = fixture.store.pullRequests[0]; + expect(created?.title).toBe(TITLE); + expect(created?.body).toBe(asked?.body); + expect(created?.draft).toBe(false); + expect(created?.baseRef).toBe("main"); + expect(created?.headRef).toBe(BRANCH); + expect(created?.headSha).toBe(asked?.headSha); + expect(created?.headSha).not.toBe(moved); + }); + + it("Q9: an interrupted numbered update adopts the update it already made", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + // One pull request this GitHub already holds, saying something else. + fixture.store.pullRequests.push( + stored({ number: 1, title: "Stale title", body: "stale", headRef: BRANCH }), + ); + const source = entry({ lines: [...upsert(" number={1}"), QUESTION] }); + + // The service accepts the update and the journal refuses to record the + // outcome, which is the window this protocol exists for. + const backing = new InMemoryStream(); + const ran = yield* run(fixture, source, refusingAfter(backing, GIT_HOST_EFFECT, 1), counted, { + answer: { choice: "continue" }, + }); + expect(ran.refusal).not.toBe(undefined); + const prefix = yield* backing.readAll(); + expect(requests(prefix)).toHaveLength(1); + // The publication's outcome stands; the pull request's never arrived. + expect(outcomes(prefix)).toHaveLength(1); + expect(closedRoot(prefix)).toBe(false); + expect(mutations(fixture.store)).toEqual(["patch"]); + expect(fixture.store.pullRequests[0]?.title).toBe(TITLE); + + // The saved request, as it stands, is what a continuation reconciles. + const asked = requests(prefix)[0]?.resolved.inputs; + expect(asked?.number).toBe(1); + + // The checkout moves on while the entry is away: a new commit, and the + // branch it was published from is left behind for another one. + const checkout = checkoutOf(prefix, BRANCH); + const moved = yield* commitThere(checkout, "later.md"); + yield* switchThere(checkout, "someone/else"); + expect(moved).not.toBe(asked?.headSha); + + // The continuation observes once, finds the pull request already saying + // what this entry asked for, and adopts it. Nothing is patched twice. + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + }); + expect(again.refusal).toBe(undefined); + expect(again.output).toContain("opened 1 at"); + expect(mutations(fixture.store)).toEqual(["patch"]); + const settled = yield* stream.readAll(); + expect(requests(settled)).toHaveLength(1); + expect(outcomes(settled)).toHaveLength(2); + + // Every member of the request reconciliation was asked about is the one + // written down before the cut — the original number, title, verbatim body, + // draft, base, head and destination — and none of it is today's checkout. + const presented = reconciledInputs(settled); + expect(presented).toEqual({ + number: 1, + title: TITLE, + body: asked?.body, + draft: false, + headBranch: BRANCH, + headSha: asked?.headSha, + baseBranch: "main", + }); + expect(presented?.headSha).not.toBe(moved); + // The adoption is what settled it, rather than a second update. + expect(decisionsOf(settled)).toEqual(["performed", "adopted"]); + }); + + it("Q10: a cut before the request is acknowledged leaves nothing to resume", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = entry({ lines: [...upsert(), QUESTION] }); + + const backing = new InMemoryStream(); + const prefix = yield* scoped(function* (): Operation { + const reached = withResolvers(); + const task = yield* spawn(() => + run( + fixture, + source, + holdingAppend(backing, REPL_REPOSITORY_PULL_REQUEST_REQUEST, reached), + counted, + { answer: { choice: "continue" } }, + ), + ); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(60_000); + throw new Error("the entry never reached its pull-request request"); + })(), + ]); + const held = [...(yield* backing.readAll())]; + yield* task.halt(); + return held; + }); + // The request was in flight and is not history, so there is nothing for a + // continuation to restore — and nothing was asked of the service. + expect(requests(prefix)).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + }); + expect(again.refusal).toBe(undefined); + expect(requests(yield* stream.readAll())).toHaveLength(1); + expect(mutations(fixture.store)).toEqual(["create"]); + }); +}); + +describe("what a reopened entry restores", () => { + it("Q11: a completed upsert and three snapshots restore with refusing leaves", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + fixture.evidence.conversation = [comment(21, "thanks")]; + fixture.evidence.runs = [checkRun(31, "build")]; + const counted = counters(); + const source = entry({ lines: [...upsert(), ...READS, QUESTION] }); + + const prefix = yield* left(fixture, source, counted); + expect(requests(prefix)).toHaveLength(1); + expect(reads(prefix)).toHaveLength(3); + const sent = fixture.store.requests.length; + + // The checkout is gone, and the service now says something else entirely. + yield* rm(checkoutOf(prefix, BRANCH), { recursive: true, force: true }); + fixture.evidence.reviews = [ + review(12, "CHANGES_REQUESTED", "not yet"), + review(13, "COMMENTED", "a note"), + ]; + fixture.evidence.conversation = []; + fixture.evidence.runs = [checkRun(32, "build"), checkRun(33, "lint")]; + const held = fixture.store.pullRequests[0]; + if (held !== undefined) { + held.title = "Renamed by somebody else"; + } + + // Resumed with both leaves refusing: native Git raises, and this GitHub + // raises on any request at all. + fixture.store.observe = () => { + throw new Error("this GitHub must not be contacted by a continuation"); + }; + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + refuseNative: true, + }); + delete fixture.store.observe; + expect(again.refusal).toBe(undefined); + + // The historical pull request and the three historical arrays, exactly. + expect(again.output).toContain( + "opened 1 at https://github.com/owner/repository/pull/1 as open", + ); + expect(again.output).toContain("evidence 1 1 1"); + // Nothing native, nothing sent, and no second request, outcome or read. + expect(methodsCalled(resumed)).toEqual([]); + expect(fixture.store.requests.length).toBe(sent); + const settled = yield* stream.readAll(); + // The suffix after the question ran and the entry finished. + expect(closedRoot(settled)).toBe(true); + expect(requests(settled)).toHaveLength(1); + expect(outcomes(settled)).toHaveLength(2); + expect(reads(settled)).toHaveLength(3); + }); + + it("Q12: a completed upsert alone restores without reading configuration", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = entry({ lines: [...upsert()] }); + const ran = yield* run(fixture, source, new InMemoryStream(), counted); + expect(ran.refusal).toBe(undefined); + + const resumed = counters(); + const replayed = new InMemoryStream([...ran.events].filter(terminal)); + const again = yield* run(fixture, source, replayed, resumed, { refuseNative: true }); + expect(again.refusal).toBe(undefined); + expect(again.output).toContain("opened 1 at"); + // No transport was needed, so this deployment's pull-request configuration + // was never read and no source was ever built. + expect(resumed.env).toEqual([]); + expect(methodsCalled(resumed)).toEqual([]); + }); + + it("Q13: an authored read after the question observes today's evidence", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + const source = entry({ + lines: [ + ...upsert(), + ` `, + "", + `historical {reviews.length}`, + "", + QUESTION.split("\n").slice(0, 4).join("\n"), + ` `, + "", + `fresh {fresh.length}`, + "", + ], + }); + + const prefix = yield* left(fixture, source, counted); + expect(reads(prefix)).toHaveLength(1); + + // The service gains two more reviews while the entry is away. + fixture.evidence.reviews = [ + review(11, "APPROVED", "ship it"), + review(12, "COMMENTED", "a note"), + review(13, "COMMENTED", "another"), + ]; + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + }); + expect(again.refusal).toBe(undefined); + // The first read answers historically; the one after the question is a new + // occurrence and reaches today's transport. + expect(again.output).toContain("historical 1"); + expect(again.output).toContain("fresh 3"); + expect(reads(yield* stream.readAll())).toHaveLength(2); + }); +}); + +/** + * The two pull requests the read rows name directly. + * + * A read needs no Repository and no checkout, so these rows write the URL the + * way a document that already has one does — and the entry they run in has no + * managed root at all, which is also B10's rootless case. + */ +const FIRST_URL = `${LOCATOR}/pull/1`; +const SECOND_URL = `${LOCATOR}/pull/2`; + +describe("evidence reads inside one entry", () => { + it("Q14: a semantic and a transport handler both compose over a restored snapshot", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + const source = [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, new InMemoryStream(), counted, { root: undefined }); + expect(ran.refusal).toBe(undefined); + expect(ran.output).toContain("reviews 1"); + const retained = reads(ran.events); + expect(retained).toHaveLength(1); + const snapshot = JSON.stringify(retained[0]?.type === "yield" ? retained[0].result : undefined); + const sent = fixture.store.requests.length; + + // One handler at each of the two ordinary layers an entry read composes + // through: the semantic lifecycle chain, and the transport chain beneath + // it. Each transforms what it is answered with. Nothing below either is + // reached on replay, because the snapshot is history. + const order: string[] = []; + const resumed = counters(); + const replayed = new InMemoryStream([...ran.events].filter(terminal)); + const again = yield* run(fixture, source, replayed, resumed, { + root: undefined, + *between(): Operation { + yield* PullRequestOperations.around({ + *read([invocation], next): Operation { + order.push("semantic"); + const answered = yield* next(invocation); + return answered.kind === "reviews" + ? { kind: "reviews", items: [...answered.items, reviewEvidence(91, "dismissed")] } + : answered; + }, + }); + yield* PullRequestAPI.around({ + *read([url, options], next): Operation { + order.push("transport"); + const answered = yield* next(url, options); + return answered.kind === "reviews" + ? { kind: "reviews", items: [...answered.items, reviewEvidence(92, "pending")] } + : answered; + }, + }); + }, + }); + expect(again.refusal).toBe(undefined); + // What the document bound is what the whole composition finished with, and + // both layers contributed to it. + expect(again.output).toContain("reviews 3"); + // The semantic layer is outside the transport layer, so it sees the read + // first and transforms the answer last. + expect(order).toEqual(["semantic", "transport"]); + expect(fixture.store.requests.length).toBe(sent); + const settled = reads(yield* replayed.readAll()); + expect(settled).toHaveLength(1); + expect(JSON.stringify(settled[0]?.type === "yield" ? settled[0].result : undefined)).toBe( + snapshot, + ); + }); + + it("Q14a: a read this host declines runs each outer transport handler once", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + // A discriminator no installed adapter answers under. The GitHub adapter + // declines on the request alone, before it reads what this deployment + // authorized and before a session, a credential or a request exists. + const source = [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"); + + // One outer handler, counting what reaches it. + let seen = 0; + const stream = new InMemoryStream(); + const ran = yield* run(fixture, source, stream, counted, { + root: undefined, + *between(): Operation { + yield* PullRequestAPI.around({ + *read([url, options], next): Operation { + seen += 1; + return yield* next(url, options); + }, + }); + }, + }); + + // The document is refused by the public surface's own answer: nothing is + // installed under the provider it named. + expect(ran.refusal).not.toBe(undefined); + expect(String(ran.refusal)).toContain("no pull-request provider is installed under elsewhere"); + expect(ran.output).not.toContain("reviews 1"); + + // Once. An entry read that re-entered the chain from the top on the way to + // that refusal would run this handler a second time, and a handler that + // counted or rewrote the URL would see one read twice. + expect(seen).toBe(1); + + // And declining costs nothing: no request was sent, and a question this + // host never answered retained no read. + expect(fixture.store.requests).toEqual([]); + expect(reads(yield* stream.readAll())).toEqual([]); + }); + + it("Q15: a semantic answer that never delegates admits and retains nothing", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + const source = [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"); + + // An ordinary lifecycle handler that answers the read itself. This is the + // supported surface, and what it answers with has answered the document. + let asked = 0; + const stream = new InMemoryStream(); + const ran = yield* run(fixture, source, stream, counted, { + root: undefined, + *between(): Operation { + yield* PullRequestOperations.around({ + // deno-lint-ignore require-yield + *read(): Operation { + asked += 1; + return { + kind: "reviews", + items: [reviewEvidence(81, "commented"), reviewEvidence(82, "approved")], + }; + }, + }); + }, + }); + expect(ran.refusal).toBe(undefined); + expect(asked).toBe(1); + // The handler's own two items, validated by the shared body like any other + // final answer. + expect(ran.output).toContain("reviews 2"); + // Nothing beneath it was reached: no transport was admitted, no + // configuration was read, no session opened, and this entry retained no + // read at all. + expect(reads(yield* stream.readAll())).toEqual([]); + expect(fixture.store.requests).toEqual([]); + expect(counted.env).toEqual([]); + }); + + it("Q16: a URL this deployment no longer allows refuses before the snapshot", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + const source = [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, new InMemoryStream(), counted, { root: undefined }); + expect(ran.refusal).toBe(undefined); + const sent = fixture.store.requests.length; + + // The operator withdrew permission for that container while the entry was + // away. A snapshot this entry already holds is not permission to bind it. + const resumed = counters(); + const replayed = new InMemoryStream([...ran.events].filter(terminal)); + const again = yield* run(fixture, source, replayed, resumed, { + root: undefined, + allowed: ["https://github.com/owner/elsewhere"], + }); + expect(again.refusal).not.toBe(undefined); + expect(String(again.refusal)).toContain("has not authorized"); + expect(again.output).not.toContain("reviews 1"); + expect(fixture.store.requests.length).toBe(sent); + expect(reads(yield* replayed.readAll())).toHaveLength(1); + }); + + it("Q17: two concurrent reads completing in reverse order keep their own questions", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + fixture.evidence.conversation = [comment(21, "one"), comment(22, "two")]; + const counted = counters(); + // Each binding is rendered inside the branch that made it, because a + // `` child's binding belongs to its own branch rather than the root. + const source = [ + "", + ``, + "", + `branch a {a.length}`, + "", + "", + ``, + "", + `branch b {b.length}`, + "", + "", + "", + "", + ].join("\n"); + + // A readiness gate rather than elapsed time: the reviews call is released + // exactly when the comments snapshot is already history, so the two + // occurrences complete in the opposite order from the one they were + // written in and the order is arranged rather than hoped for. + const appended: string[] = []; + const retained = withResolvers(); + const stream = new InMemoryStream(); + stream.onAppend = (event: DurableEvent) => { + if (event.type !== "yield" || event.description.type !== PULL_REQUEST_READ) { + return; + } + const kind = Reflect.get(Object(event.description.input), "kind"); + appended.push(String(kind)); + if (kind === "comments") { + retained.resolve(); + } + }; + fixture.gate.hold = function* (request: GitHubHttpRequest): Operation { + if (new URL(request.url).pathname.endsWith("/reviews")) { + yield* retained.operation; + } + }; + const ran = yield* run(fixture, source, stream, counted, { root: undefined }); + expect(ran.refusal).toBe(undefined); + // The written order is reviews then comments; the acknowledged order is + // the reverse of it. + expect(appended).toEqual(["comments", "reviews"]); + // Each occurrence bound its own collection, from its own URL. + expect(ran.output).toContain("branch a 1"); + expect(ran.output).toContain("branch b 2"); + + // Two retained reads, each holding the collection its own question asked + // for, each named by that question, each standing in its own coroutine. + const held = reads(ran.events); + expect(held).toHaveLength(2); + expect(collections(held)).toEqual([ + { kind: "comments", items: 2 }, + { kind: "reviews", items: 1 }, + ]); + const names = held.map((event) => (event.type === "yield" ? event.description.name : "")); + expect(new Set(names).size).toBe(2); + const coroutines = new Set( + held.map((event) => (event.type === "yield" ? event.coroutineId : "")), + ); + expect(coroutines.size).toBe(2); + expect(coroutines.has("root")).toBe(false); + + // Replayed, each occurrence consumes its own snapshot and nothing else. + const sent = fixture.store.requests.length; + delete fixture.gate.hold; + const resumed = counters(); + const replayed = new InMemoryStream([...ran.events].filter(terminal)); + const again = yield* run(fixture, source, replayed, resumed, { root: undefined }); + expect(again.refusal).toBe(undefined); + expect(again.output).toContain("branch a 1"); + expect(again.output).toContain("branch b 2"); + expect(fixture.store.requests.length).toBe(sent); + expect(reads(yield* replayed.readAll())).toHaveLength(2); + }); + + it("Q18: a read a handler starts for itself reaches no occurrence's callback", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + fixture.evidence.conversation = [comment(21, "one")]; + const counted = counters(); + const source = [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"); + + let started = 0; + let refused: string | undefined; + const ran = yield* run(fixture, source, new InMemoryStream(), counted, { + root: undefined, + *between(): Operation { + yield* PullRequestAPI.around({ + *read([url, options], next): Operation { + // A read this handler starts for itself, from inside the dispatch + // of an occurrence that asked for something else. There is no + // ambient callback to find: the occurrence's own terminal belongs + // to the descriptor it dispatched through, and this is not it. + if (options.kind === "reviews" && started === 0) { + started += 1; + try { + yield* PullRequestAPI.operations.read(SECOND_URL, { kind: "comments" }); + } catch (error) { + refused = error instanceof Error ? error.message : String(error); + } + } + return yield* next(url, options); + }, + }); + }, + }); + expect(started).toBe(1); + expect(refused).not.toBe(undefined); + expect(String(refused)).toContain("no pull-request provider handles"); + // The occurrence that was actually written is unaffected, and exactly one + // read was retained: the handler's own question consumed nothing. + expect(ran.refusal).toBe(undefined); + expect(ran.output).toContain("reviews 1"); + expect(reads(ran.events)).toHaveLength(1); + }); +}); + +describe("acknowledgement and failure", () => { + it("Q19: a request this journal refused asks the Git host nothing", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const backing = new InMemoryStream(); + const ran = yield* run( + fixture, + entry({ lines: [...upsert()] }), + refusing(backing, REPL_REPOSITORY_PULL_REQUEST_REQUEST), + counted, + ); + expect(ran.refusal).not.toBe(undefined); + const held = yield* backing.readAll(); + // The publication stands. The pull request was never written down, so + // nothing authorized a call — and no completion was invented for it. + expect(publications(held)).toHaveLength(1); + expect(requests(held)).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + expect(closedRoot(held)).toBe(false); + }); + + it("Q20: a created pull request whose outcome was refused is adopted, never made twice", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = entry({ lines: [...upsert(), QUESTION] }); + const backing = new InMemoryStream(); + + const ran = yield* run(fixture, source, refusingAfter(backing, GIT_HOST_EFFECT, 1), counted, { + answer: { choice: "continue" }, + }); + expect(ran.refusal).not.toBe(undefined); + const prefix = yield* backing.readAll(); + // The service holds the pull request and this end never learned that it + // does: one saved request, the publication's outcome only, no binding and + // no close. + expect(requests(prefix)).toHaveLength(1); + expect(outcomes(prefix)).toHaveLength(1); + expect(mutations(fixture.store)).toEqual(["create"]); + expect(closedRoot(prefix)).toBe(false); + expect(ran.output).not.toContain("opened 1 at"); + + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { answer: { choice: "continue" } }); + expect(again.refusal).toBe(undefined); + expect(again.output).toContain("opened 1 at"); + // Observed once and adopted. Nothing was created a second time. + expect(mutations(fixture.store)).toEqual(["create"]); + expect(fixture.store.pullRequests).toHaveLength(1); + const settled = yield* stream.readAll(); + expect(requests(settled)).toHaveLength(1); + expect(outcomes(settled)).toHaveLength(2); + }); + + it("Q21: an entry cancelled with its call in flight retains the request alone", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const prefix = yield* cut( + fixture, + entry({ lines: [...upsert(), QUESTION] }), + counted, + (request) => request.method === "POST", + ); + // The creation was received and never answered, so the entry was halted + // with its own attempt joined. What it retained is the request it had + // already acknowledged, and nothing about an outcome. + expect(requests(prefix)).toHaveLength(1); + expect(outcomes(prefix)).toHaveLength(1); + expect(fixture.store.pullRequests).toEqual([]); + expect(closedRoot(prefix)).toBe(false); + expect(reads(prefix)).toEqual([]); + }); +}); + +describe("the credential one pull-request attempt acquires", () => { + it("Q31: cancelling during acquisition joins the broker and retains the request alone", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + // The credential leaf this host really asks when no variable names one: + // the Git-host login command. Substituted with a broker that reports its + // own process, blocks, and records its own exit, so what a cancellation + // does to it is observable rather than assumed. + const broker = yield* useBlockingLogin(fixture); + + const stream = new InMemoryStream(); + const prefix = yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, entry({ lines: [...upsert(), QUESTION] }), stream, counted, { + answer: { choice: "continue" }, + }), + ); + yield* race([ + broker.started, + (function* (): Operation { + yield* sleep(60_000); + throw new Error("the entry never reached its credential acquisition"); + })(), + ]); + // Acquisition is genuinely pending here: the broker is running and has + // answered nothing. + expect(yield* broker.alive()).toBe(true); + const held = [...(yield* stream.readAll())]; + yield* task.halt(); + return held; + }); + + // The attempt's cleanup was joined rather than merely signalled. The + // broker's whole group is signalled and then *waited for*, so by the time + // the halt above has returned the process is gone rather than on its way + // out — which is what keeps a cancelled entry from leaving a credential + // broker running behind it. + expect(yield* broker.alive()).toBe(false); + + // The request this entry had already acknowledged, and nothing else. No + // outcome was invented for work that never happened, the root never + // settled, and the Git host was never reached — a credential was still + // being asked for when this was cancelled. + expect(requests(prefix)).toHaveLength(1); + expect(outcomes(prefix)).toHaveLength(1); + expect(closedRoot(prefix)).toBe(false); + expect(fixture.store.requests).toEqual([]); + const settled = yield* stream.readAll(); + expect(requests(settled)).toHaveLength(1); + expect(outcomes(settled)).toHaveLength(1); + }); +}); + +describe("what an entry's assembly reaches", () => { + it("Q22: building the capability acquires nothing", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + yield* scoped(function* (): Operation { + const installation = yield* replRepositoryInstallation(OWNER, { + operations: countingOperations(fixture, counted, { refuseNative: true }), + identity: statedIdentity, + root: fixture.managed, + }); + // Which six names this declares, and that it declares three admissions, + // is asserted against the production selector in `repl-push.test.ts` C6. + // What this row is for is the cost of building it, over leaves that + // count what they are asked for. + expect((installation.components ?? []).length).toBe(6); + }); + // Naming it read no configuration, ran no Git, took no directory and + // opened no session. + expect(methodsCalled(counted)).toEqual([]); + expect(counted.env).toEqual([]); + expect(fixture.store.requests).toEqual([]); + }); + + it("Q23: an entry with no managed root reads an allowed URL and opens nothing", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + const reading = yield* run( + fixture, + [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"), + new InMemoryStream(), + counted, + { root: undefined }, + ); + expect(reading.refusal).toBe(undefined); + expect(reading.output).toContain("reviews 1"); + // A read needs no Repository, so nothing local was discovered for it. + expect(methodsCalled(counted)).toEqual([]); + expect(reads(reading.events)).toHaveLength(1); + + // The upsert in the same entry keeps the absent local capability: with no + // managed root there is no selection to publish from and none to open a + // pull request over, and nothing is contacted about it. + const absent = counters(); + const stream = new InMemoryStream(); + const opened = yield* run(fixture, entry({ lines: [...upsert()] }), stream, absent, { + root: undefined, + }); + expect(opened.refusal).not.toBe(undefined); + expect(requests(yield* stream.readAll())).toEqual([]); + expect(mutations(fixture.store)).toEqual([]); + }); +}); + +describe("a retained record this entry cannot account for", () => { + it("Q24: every tampered read description refuses before anything replays", function* () { + const fixture = yield* useFixture(); + fixture.evidence.reviews = [review(11, "APPROVED", "ship it")]; + const counted = counters(); + const source = [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, new InMemoryStream(), counted, { root: undefined }); + expect(ran.refusal).toBe(undefined); + const history = [...ran.events].filter(terminal); + const retained = history.findIndex( + (event) => event.type === "yield" && event.description.type === PULL_REQUEST_READ, + ); + expect(retained).toBeGreaterThan(-1); + const original = history[retained]; + if (original === undefined || original.type !== "yield") { + throw new Error("that run retained no evidence read"); + } + const sent = fixture.store.requests.length; + + // Six ways one retained read can fail to be this entry's own question, and + // one answer to all of them: the history is not continued, nothing is + // asked of the service, and the document binds nothing. + const tampered: Record = { + "a name its own question does not produce": { + ...original, + description: { ...original.description, name: "something-else" }, + }, + "an empty configuration": { + ...original, + description: { ...original.description, configuration: "" }, + }, + "a question made under another owner": { + ...original, + description: { + ...original.description, + input: { ...Object(original.description.input), runId: FOREIGN_OWNER }, + }, + }, + "a question whose collection is not one of the three": { + ...original, + description: { + ...original.description, + input: { ...Object(original.description.input), kind: "everything" }, + }, + }, + "a question whose URL is not canonical": { + ...original, + description: { + ...original.description, + input: { ...Object(original.description.input), url: `${FIRST_URL}?token=hunter2` }, + }, + }, + "a success holding another collection": { + ...original, + result: { status: "ok", value: { kind: "comments", items: [] } }, + }, + }; + + for (const [what, event] of Object.entries(tampered)) { + const replacement = [...history]; + replacement[retained] = event; + const resumed = counters(); + const stream = new InMemoryStream(replacement); + const again = yield* run(fixture, source, stream, resumed, { root: undefined }); + // `what` travels into the comparison so a failure names which tampering + // was admitted rather than only that one of six was. + expect([what, again.refusal === undefined]).toEqual([what, false]); + expect([what, again.output.includes("reviews 1")]).toEqual([what, false]); + expect([what, fixture.store.requests.length]).toEqual([what, sent]); + expect([what, methodsCalled(resumed)]).toEqual([what, []]); + } + }); + + it("Q29: a retained checks envelope whose item names another head refuses", function* () { + const fixture = yield* useFixture(); + fixture.evidence.runs = [checkRun(31, "build")]; + // A checks read asks this pull request which commit its head is before it + // asks about that commit, so this GitHub has to hold one. + fixture.store.pullRequests.push( + stored({ number: 1, headRef: BRANCH, headSha: "d".repeat(40) }), + ); + const counted = counters(); + const source = [ + ``, + "", + `checks {checks.length}`, + "", + ].join("\n"); + + const ran = yield* run(fixture, source, new InMemoryStream(), counted, { root: undefined }); + expect(ran.refusal).toBe(undefined); + expect(ran.output).toContain("checks 1"); + const history = [...ran.events].filter(terminal); + const at = history.findIndex( + (event) => event.type === "yield" && event.description.type === PULL_REQUEST_READ, + ); + const original = history[at]; + if (original === undefined || original.type !== "yield" || original.result.status !== "ok") { + throw new Error("that run retained no completed checks read"); + } + const envelope = Object(original.result.value); + const items = Array.isArray(envelope.items) ? envelope.items : []; + expect(items).toHaveLength(1); + const sent = fixture.store.requests.length; + + // One item moved onto another commit. A check that does not describe this + // envelope's own head is evidence about something else, so the history is + // not continued and no replacement snapshot is written. + const replacement = [...history]; + replacement[at] = { + ...original, + result: { + status: "ok", + value: { + ...envelope, + items: [{ ...Object(items[0]), headSha: "e".repeat(40) }], + }, + }, + }; + const resumed = counters(); + const stream = new InMemoryStream(replacement); + const again = yield* run(fixture, source, stream, resumed, { root: undefined }); + expect(again.refusal).not.toBe(undefined); + expect(again.output).not.toContain("checks 1"); + expect(fixture.store.requests.length).toBe(sent); + expect(reads(yield* stream.readAll())).toHaveLength(1); + }); + + it("Q30: a saved request whose origin is not its Repository's refuses at readback", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = entry({ lines: [...upsert()] }); + + const ran = yield* run(fixture, source, new InMemoryStream(), counted); + expect(ran.refusal).toBe(undefined); + const history = [...ran.events].filter(terminal); + const at = history.findIndex( + (event) => + event.type === "yield" && event.description.type === REPL_REPOSITORY_PULL_REQUEST_REQUEST, + ); + const original = history[at]; + if (original === undefined || original.type !== "yield" || original.result.status !== "ok") { + throw new Error("that run retained no saved pull-request request"); + } + const saved = Object(original.result.value); + const sent = fixture.store.requests.length; + + // A locator this host would hand to Git, and a well-formed record — but + // not the one the Repository in it was fingerprinted from. The question + // this position is named by is unchanged, so the record is found and + // restored, and what refuses it is the readback that holds a saved request + // to the origin it says it belongs to. + const replacement = [...history]; + replacement[at] = { + ...original, + result: { + status: "ok", + value: { + ...saved, + resolved: { ...Object(saved.resolved), origin: "https://github.com/owner/elsewhere" }, + }, + }, + }; + const resumed = counters(); + const stream = new InMemoryStream(replacement); + const again = yield* run(fixture, source, stream, resumed, { refuseNative: true }); + expect(again.refusal).not.toBe(undefined); + expect(again.output).not.toContain("opened 1 at"); + // Refused before reuse: nothing was contacted and no second request or + // outcome was written. + expect(fixture.store.requests.length).toBe(sent); + const settled = yield* stream.readAll(); + expect(requests(settled)).toHaveLength(1); + expect(outcomes(settled)).toHaveLength(2); + }); + + it("Q25: an ordinary failed read is admitted, and replays as the failure it was", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + // The service cannot answer, so this read is an ordinary failure rather + // than a success — and an ordinary failure is history like any other. + fixture.evidence.refuses = 500; + const source = [ + ``, + "", + `reviews {reviews.length}`, + "", + ].join("\n"); + const backing = new InMemoryStream(); + const ran = yield* run(fixture, source, backing, counted, { root: undefined }); + expect(ran.refusal).not.toBe(undefined); + const history = yield* backing.readAll(); + const retained = reads(history); + expect(retained).toHaveLength(1); + expect(retained[0]?.type === "yield" ? retained[0].result.status : "ok").not.toBe("ok"); + + // Continued: the failed read is admitted with its complete question, and + // what it replays as is the same ordinary failure. Nothing is asked again. + delete fixture.evidence.refuses; + const sent = fixture.store.requests.length; + const resumed = counters(); + const stream = new InMemoryStream([...history].filter(terminal)); + const again = yield* run(fixture, source, stream, resumed, { root: undefined }); + expect(again.refusal).not.toBe(undefined); + expect(fixture.store.requests.length).toBe(sent); + expect(reads(yield* stream.readAll())).toHaveLength(1); + }); +}); diff --git a/packages/git/tests/retained-repository-push-records.test.ts b/packages/cli/tests/retained-repository-push-records.test.ts similarity index 98% rename from packages/git/tests/retained-repository-push-records.test.ts rename to packages/cli/tests/retained-repository-push-records.test.ts index 1c2d4f97f..984a39b1b 100644 --- a/packages/git/tests/retained-repository-push-records.test.ts +++ b/packages/cli/tests/retained-repository-push-records.test.ts @@ -30,14 +30,11 @@ import { PUSH_REMOTE, refspecFor, type GitPushInputs, -} from "../src/composition/git-push-records.ts"; -import { locatorFingerprint } from "../src/deno/composition/locator.ts"; -import type { RepositoryIdentity } from "../src/composition/selection.ts"; -import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; -import { - gitHostReconciliationRecordJson, - gitHostRequestFingerprint, -} from "../src/git-host/records.ts"; +} from "@executablemd/git"; +import { locatorFingerprint } from "@executablemd/git/deno"; +import type { RepositoryIdentity } from "@executablemd/git"; +import { GIT_HOST_EFFECT } from "@executablemd/git"; +import { gitHostReconciliationRecordJson, gitHostRequestFingerprint } from "@executablemd/git"; import { admitReplPushHistory, replPushEffectRequest, @@ -45,7 +42,7 @@ import { replPushPlacesHeld, ReplPushHistoryError, type ReplPushPlacement, -} from "../src/retained-repository/push-admission.ts"; +} from "../src/repl/repositories/push-admission.ts"; import { parseReplPushRequestRecord, replPushRequestJson, @@ -54,14 +51,14 @@ import { sameReplPushPlace, type ReplPushPlace, type ReplPushRequestRecord, -} from "../src/retained-repository/push-records.ts"; +} from "../src/repl/repositories/push-records.ts"; import { replPlacementFingerprint, replPlacementRecordJson, replPlacementRecordName, REPL_REPOSITORY_PLACEMENT, type ReplPlacement, -} from "../src/retained-repository/placement-records.ts"; +} from "../src/repl/repositories/placement-records.ts"; const OWNER = '["executablemd.repl.issue-owner",1,"execution-4","entry-1"]'; const SITE = "expansion-5a7"; diff --git a/packages/git/tests/retained-repository-push.test.ts b/packages/cli/tests/retained-repository-push.test.ts similarity index 76% rename from packages/git/tests/retained-repository-push.test.ts rename to packages/cli/tests/retained-repository-push.test.ts index 8db915d17..e3ff3316b 100644 --- a/packages/git/tests/retained-repository-push.test.ts +++ b/packages/cli/tests/retained-repository-push.test.ts @@ -38,53 +38,48 @@ import type { Operation, WithResolvers } from "effection"; import { exists, rm, writeTextFile } from "@effectionx/fs"; import { chmod, mkdir, mkdtemp, realpath, symlink, unlink, writeFile } from "node:fs/promises"; import process from "node:process"; +import { readGit, runGit } from "./support/fixture-git.ts"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { API, hostFilesHandler } from "@executablemd/runtime"; import type { FilePathInput, FilesHandler, FileWriteInput } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; -import { Execution } from "@executablemd/core/api"; import { executeInstalled } from "@executablemd/core/host"; import { collect, Elicitation, registerComponents } from "@executablemd/core"; import type { ElicitationRequest } from "@executablemd/core"; import { cwd } from "@executablemd/runtime"; -import { Git, GitHost } from "@executablemd/git/api"; -import { denoRepositoryHost } from "../src/deno/composition/host.ts"; -import type { - GitInvocation, - GitOutcome, - RepositoryHost, - RepositoryHostOptions, -} from "../src/deno/composition/host.ts"; -import { gitSession } from "../src/deno/composition/git.ts"; -import type { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; -import { UNAUTHENTICATED } from "../src/deno/composition/authentication.ts"; -import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; -import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; -import { useCompositionComponents } from "../src/composition/installation.ts"; -import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; -import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; +import { GitHost } from "@executablemd/git/api"; +import { Git } from "@executablemd/git/api"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import type { GitHostProvider } from "@executablemd/git"; +import { replRepositoryInstallation } from "../src/repl/repositories/deno/provider.ts"; +import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; +import { useCompositionComponents } from "@executablemd/git"; +import { GIT_HOST_EFFECT } from "@executablemd/git"; +import { parseGitHostReconciliationRecord } from "@executablemd/git"; import { parseReplPushRequestRecord, REPL_REPOSITORY_PUSH_REQUEST, -} from "../src/retained-repository/push-records.ts"; +} from "../src/repl/repositories/push-records.ts"; import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT, -} from "../src/retained-repository/placement-records.ts"; -import type { ReplPushRequestRecord } from "../src/retained-repository/push-records.ts"; -import { parseGitPushInputs } from "../src/composition/git-push-records.ts"; -import { useLeases } from "../src/deno/run-composition/leases.ts"; -import { useCommonDirectoryLease } from "../src/deno/retained-repository/leases.ts"; -import type { SlotKind } from "../src/deno/run-composition/leases.ts"; -import { repositorySlot } from "../src/deno/run-composition/checkout-layout.ts"; -import type { GitPushInputs } from "../src/composition/git-push-records.ts"; -import { selectedRepository } from "../src/composition/context.ts"; +} from "../src/repl/repositories/placement-records.ts"; +import type { ReplPushRequestRecord } from "../src/repl/repositories/push-records.ts"; +import { parseGitPushInputs } from "@executablemd/git"; +import { useLeases } from "@executablemd/git/deno"; +import { useCommonDirectoryLease } from "../src/repl/repositories/deno/leases.ts"; +import type { SlotKind } from "@executablemd/git/deno"; +import { repositorySlot } from "@executablemd/git/deno"; +import type { GitPushInputs } from "@executablemd/git"; // From the subpath that publishes it rather than from the source module: this // result type is public, and reaching it by relative path would bypass the // export map the partition test holds every consumer to. -import type { GitPushInvocation, GitPushOutcome } from "@executablemd/git/api"; +import type { GitPushOutcome } from "@executablemd/git"; +import type { GitPushInvocation } from "@executablemd/git/api"; +import { currentRepository } from "@executablemd/git/api"; /** The owner the CLI derives for an entry, as this suite states one. */ const OWNER = '["executablemd.repl.issue-owner",1,"execution-push","1"]'; @@ -107,19 +102,18 @@ function useFixture(): Operation { return resource(function* (provide) { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-push-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); - const git = gitSession(denoRepositoryHost(), root); const origin = join(root, "origin.git"); const seed = join(root, "seed"); yield* until(mkdir(seed, { recursive: true })); - yield* git.run(["init", "--bare", "-b", "main", origin], root); - yield* git.run(["init", "-b", "main", seed], root); + yield* runGit(root, ["init", "--bare", "-b", "main", origin]); + yield* runGit(root, ["init", "-b", "main", seed]); yield* until(writeFile(join(seed, "seed.md"), "seed\n")); - yield* git.run(["add", "seed.md"], seed); - yield* git.run(["commit", "-m", "seed"], seed); - yield* git.run(["push", origin, "main:refs/heads/main"], seed); + yield* runGit(seed, ["add", "seed.md"]); + yield* runGit(seed, ["commit", "-m", "seed"]); + yield* runGit(seed, ["push", origin, "main:refs/heads/main"]); const checkout = join(root, "checkout"); - yield* git.run(["clone", origin, checkout], root); + yield* runGit(root, ["clone", origin, checkout]); const link = join(root, "entered"); yield* until(symlink(checkout, link)); yield* provide({ root, origin, checkout, managed: join(root, "repositories"), link }); @@ -132,20 +126,19 @@ function* repoint(fixture: Fixture, target: string): Operation { yield* until(symlink(target, fixture.link)); } -/** What the host beneath the recorder was asked to do. */ +/** What the handle beneath the recorder was asked to do. */ interface Counters { - commands: string[][]; - directories: number; - sessions: string[]; + /** Every handle method this entry called, by name, in order. */ + methods: string[]; } function counters(): Counters { - return { commands: [], directories: 0, sessions: [] }; + return { methods: [] }; } -/** The Git subcommands one entry issued, in order. */ -function subcommands(counted: Counters): string[] { - return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +/** The handle methods one entry called, in order. */ +function methodsCalled(counted: Counters): string[] { + return [...counted.methods]; } /** @@ -185,44 +178,74 @@ function hostFiles(): FilesHandler { }; } -/** What a row may do to one `git` invocation before the real one runs. */ -type GitBarrier = (invocation: GitInvocation) => Operation; +/** What a row may do to one handle method before the real one runs. */ +type MethodBarrier = (method: string) => Operation; + +/** What a row may do to a provider's own result before the caller sees it. */ +type ProviderGate = (stage: "observe" | "perform") => Operation; /** - * The production host, counted, and interruptible at a named subcommand. + * The production handle, counted, and interruptible at a named method. * - * Every leaf is wrapped rather than replaced: what these rows need to know is + * Every method is wrapped rather than replaced: what these rows need to know is * whether a publication really happened, and the only honest way to answer is - * to let the real `ls-remote` and the real `push` happen and then read the - * remote. + * to let the real provider do it and then read the remote. + * + * `pushProvider` is wrapped twice over. A barrier on the method itself holds + * *before* a provider is built, which is the window an acknowledged request + * that contacted nothing lives in. A gate on the returned provider holds after + * a real `observe` or `perform` has finished and before its result is handed + * back — the window a remote-completed, unacknowledged outcome lives in. */ -function countingHost(counted: Counters, options: RunOptions = {}): RepositoryHost { - const host = denoRepositoryHost(options.hostOptions ?? {}); - const barrier = options.barrier; - return { - *git(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); +function countingOperations( + counted: Counters, + barrier?: MethodBarrier, + gate?: ProviderGate, +): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + function* note(method: string): Operation { + counted.methods.push(method); if (barrier !== undefined) { - yield* barrier(invocation); + yield* barrier(method); } - return yield* host.git({ ...invocation, ...(options.intercept?.(invocation) ?? {}) }); - }, - *useDirectory(): Operation { - counted.directories += 1; - if (options.directories !== undefined) { - return yield* options.directories(); - } - return yield* host.useDirectory(); - }, - *useAuthentication(locator: string): Operation { - counted.sessions.push(locator); - if (options.sessions !== undefined) { - return yield* options.sessions(locator); - } - return host.useAuthentication === undefined - ? UNAUTHENTICATED - : yield* host.useAuthentication(locator); - }, + } + const wrapped: DenoRepositoryOperations = { + ...operations, + *currentBranch(directory) { + yield* note("currentBranch"); + return yield* operations.currentBranch(directory); + }, + *resolveCommit(directory, revision) { + yield* note("resolveCommit"); + return yield* operations.resolveCommit(directory, revision); + }, + *commitPresent(directory, commit) { + yield* note("commitPresent"); + return yield* operations.commitPresent(directory, commit); + }, + *pushProvider(commonDirectory, origin, inputs) { + yield* note("pushProvider"); + const provider = yield* operations.pushProvider(commonDirectory, origin, inputs); + if (gate === undefined) { + return provider; + } + const gated: GitHostProvider = { + *observe(...given) { + const observed = yield* provider.observe(...given); + yield* gate("observe"); + return observed; + }, + *perform(...given) { + const performed = yield* provider.perform(...given); + yield* gate("perform"); + return performed; + }, + }; + return gated; + }, + }; + return wrapped; }; } @@ -237,16 +260,10 @@ interface RunOptions { readonly cwd?: string; /** What this row installs between the host and the recording. */ readonly between?: () => Operation; - /** A barrier inside one `git` invocation. */ - readonly barrier?: GitBarrier; - /** What this row changes about one invocation before the real host runs it. */ - readonly intercept?: (invocation: GitInvocation) => Partial | undefined; - /** What this row states about the native host it drives. */ - readonly hostOptions?: RepositoryHostOptions; - /** What this row does instead of acquiring an isolation directory. */ - readonly directories?: () => Operation; - /** What this row does instead of opening an authentication session. */ - readonly sessions?: (locator: string) => Operation; + /** A barrier inside one handle method, before it performs anything. */ + readonly barrier?: MethodBarrier; + /** A gate after a real provider stage finished and before its result returns. */ + readonly gate?: ProviderGate; /** The journal this entry runs over, when a row seeds one. */ readonly stream?: DurableStream; /** Whether this entry installs a managed root at all. */ @@ -311,7 +328,7 @@ function run( // Built the way the session builds it: one per-entry factory, called in the // entry's own task, for the owner the CLI derived. const installation = yield* replRepositoryInstallation(options.owner ?? OWNER, { - host: countingHost(counted, options), + operations: countingOperations(counted, options.barrier, options.gate), identity: statedIdentity, ...(managed === undefined ? {} : { root: managed }), }); @@ -392,7 +409,7 @@ function interrupted( fixture: Fixture, source: string, counted: Counters, - at: (invocation: GitInvocation) => boolean, + at: (method: string) => boolean, options: RunOptions = {}, ): Operation { return scoped(function* () { @@ -401,11 +418,11 @@ function interrupted( const task = yield* spawn(() => run(fixture, source, stream, counted, { ...options, - *barrier(invocation): Operation { + *barrier(method): Operation { if (options.barrier !== undefined) { - yield* options.barrier(invocation); + yield* options.barrier(method); } - if (at(invocation)) { + if (at(method)) { reached.resolve(); yield* suspend(); } @@ -427,40 +444,32 @@ function interrupted( /** The commit a ref on the bare remote holds, or `undefined` for absence. */ function* remoteCommit(fixture: Fixture, branch: string): Operation { - const git = gitSession(denoRepositoryHost(), fixture.root); - const outcome = yield* git.run(["rev-parse", "--verify", `refs/heads/${branch}`], fixture.origin); - return outcome.code === 0 ? outcome.stdout.trim() : undefined; + return yield* readGit(fixture.origin, ["rev-parse", "--verify", `refs/heads/${branch}`]); } /** The commit a checkout's branch holds. */ function* localCommit(fixture: Fixture, checkout: string, revision = "HEAD"): Operation { - const git = gitSession(denoRepositoryHost(), fixture.root); - const outcome = yield* git.run(["rev-parse", revision], checkout); - if (outcome.code !== 0) { + const resolved = yield* readGit(checkout, ["rev-parse", revision]); + if (resolved === undefined) { throw new Error(`${revision} does not resolve in ${checkout}`); } - return outcome.stdout.trim(); + return resolved; } /** Commit one more file in a managed checkout, as a person at a shell would. */ function* commitThere(checkout: string, name: string): Operation { - const git = gitSession(denoRepositoryHost(), checkout); yield* writeTextFile(join(checkout, name), `${name}\n`); - yield* git.run(["add", name], checkout); - yield* git.run( - [ - "-c", - "user.name=Shell", - "-c", - "user.email=shell@example.test", - "commit", - "-m", - `outside ${name}`, - ], - checkout, - ); - const outcome = yield* git.run(["rev-parse", "HEAD"], checkout); - return outcome.stdout.trim(); + yield* runGit(checkout, ["add", name]); + yield* runGit(checkout, [ + "-c", + "user.name=Shell", + "-c", + "user.email=shell@example.test", + "commit", + "-m", + `outside ${name}`, + ]); + return yield* runGit(checkout, ["rev-parse", "HEAD"]); } /** Hold one managed slot's own lock from outside, as another entry would. */ @@ -506,70 +515,6 @@ function* settled(turns = 8): Operation { } } -/** What a watched `git` child reported about its own lifetime. */ -interface WatchedChildren { - /** Resolves once a watched child has been spawned. */ - readonly spawned: Operation; - /** Handed to the host, which forwards it to the one place a child exists. */ - readonly observe: NonNullable; - observed(): number; - /** Whether every observed child has closed both of its pipes. */ - closed(): boolean; - /** Whether any observed process is still running. */ - alive(): boolean; -} - -/** - * Watch the real children a row's chosen invocation spawns. - * - * The production host forwards this to the one point at which a row can see the - * process a command really started. A wrapper around the host sees the call and - * its answer and never the child between them, so observing residue afterwards - * could not tell an interrupted command from one that never began. - */ -function watchChildren(): WatchedChildren { - const pids: number[] = []; - let count = 0; - let closes = 0; - const first = withResolvers(); - return { - spawned: first.operation, - observe(child): void { - count += 1; - if (child.pid !== undefined) { - pids.push(child.pid); - } - child.on("close", () => { - closes += 1; - }); - first.resolve(); - }, - observed: () => count, - closed: () => count > 0 && closes === count, - alive: () => - pids.some((pid) => { - try { - // Signal 0 asks the operating system whether the process is there. - process.kill(pid, 0); - return true; - } catch { - return false; - } - }), - }; -} - -/** - * A script that reports it ran and then waits. - * - * What makes a child cut deterministic: the marker says the command really - * reached this point, and the wait keeps it there until the halt arrives. - */ -function* waitingScript(path: string, marker: string): Operation { - yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); - yield* until(chmod(path, 0o755)); -} - /** Wait until this path is there, or say what never appeared. */ function* appears(path: string): Operation { const deadline = Date.now() + 30_000; @@ -668,7 +613,7 @@ describe("one entry's publication", () => { expect(saved[0]?.resolved.inputs.sourceCommit).toBe(published); expect(saved[0]?.resolved.origin).toBe(fixture.origin); expect(yield* remoteCommit(fixture, "review/push")).toBe(published); - expect(subcommands(first)).toContain("push"); + expect(methodsCalled(first)).toContain("pushProvider"); // The same entry again, over the history it left. Nothing native happens: // no `git`, no isolation directory, no authentication session. @@ -681,9 +626,7 @@ describe("one entry's publication", () => { second, ); expect(again.refusal).toBe(undefined); - expect(second.commands).toEqual([]); - expect(second.directories).toBe(0); - expect(second.sessions).toEqual([]); + expect(methodsCalled(second)).toEqual([]); expect(requests(yield* replayed.readAll())).toHaveLength(1); expect(outcomes(yield* replayed.readAll())).toHaveLength(1); }); @@ -698,7 +641,7 @@ describe("one entry's publication", () => { fixture, publishing(fixture, "review/pending", "notes.md", QUESTION), counted, - (invocation) => invocation.args.includes("ls-remote"), + (method) => method === "pushProvider", ); expect(requests(prefix)).toHaveLength(1); expect(outcomes(prefix)).toEqual([]); @@ -749,10 +692,15 @@ describe("one entry's publication", () => { expect(outcomes(prefix)).toEqual([]); const asked = requests(prefix)[0]?.resolved.inputs.sourceCommit; expect(yield* remoteCommit(fixture, "review/adopt")).toBe(asked); - expect(subcommands(first).filter((name) => name === "push")).toHaveLength(1); + expect(methodsCalled(first).filter((name) => name === "pushProvider")).toHaveLength(1); // The continuation observes the destination, finds this exact commit, and - // adopts it. Nothing is published a second time. + // adopts it. "Nothing is published a second time" is the decision it + // recorded and the ref it left: a provider is built either way, because + // adopting is something it has to look to decide. That an adoption runs no + // `git push` is proved where the invocation is, by `git-push.test.ts`'s + // "adopts a destination that already holds the commit, and pushes nothing + // more". const second = counters(); const stream = new InMemoryStream([...prefix]); const again = yield* run( @@ -763,7 +711,6 @@ describe("one entry's publication", () => { { answer: { choice: "continue" } }, ); expect(again.refusal).toBe(undefined); - expect(subcommands(second).filter((name) => name === "push")).toEqual([]); const events = yield* stream.readAll(); expect(requests(events)).toHaveLength(1); const settled = outcomes(events)[0]; @@ -803,11 +750,10 @@ describe("one entry's publication", () => { // Somebody else publishes an unrelated commit over that branch. const elsewhere = join(fixture.root, "elsewhere"); - const git = gitSession(denoRepositoryHost(), fixture.root); - yield* git.run(["clone", fixture.origin, elsewhere], fixture.root); - yield* git.run(["checkout", "-B", "review/advance"], elsewhere); + yield* runGit(fixture.root, ["clone", fixture.origin, elsewhere]); + yield* runGit(elsewhere, ["checkout", "-B", "review/advance"]); yield* commitThere(elsewhere, "theirs.md"); - yield* git.run(["push", "--force", fixture.origin, "review/advance"], elsewhere); + yield* runGit(elsewhere, ["push", "--force", fixture.origin, "review/advance"]); const theirs = yield* remoteCommit(fixture, "review/advance"); const refused = counters(); @@ -820,8 +766,8 @@ describe("one entry's publication", () => { ); expect(third.refusal).not.toBe(undefined); // Nothing was published, and the destination is exactly where the other - // party left it. - expect(subcommands(refused).filter((name) => name === "push")).toEqual([]); + // party left it — which is what "nothing was published" means here. The + // conflict is discovered by observing, so a provider is built to find it. expect(yield* remoteCommit(fixture, "review/advance")).toBe(theirs); const events = yield* conflicted.readAll(); expect(requests(events)).toHaveLength(1); @@ -838,7 +784,7 @@ describe("one entry's publication", () => { replaying, ); expect(fourth.refusal).not.toBe(undefined); - expect(replaying.commands).toEqual([]); + expect(methodsCalled(replaying)).toEqual([]); }); it("P5: public middleware composes, and only a delegation retains anything", function* () { @@ -871,7 +817,7 @@ describe("one entry's publication", () => { const events = yield* stream.readAll(); expect(requests(events)).toEqual([]); expect(outcomes(events)).toEqual([]); - expect(subcommands(synthetic).filter((name) => name === "push")).toEqual([]); + expect(methodsCalled(synthetic).filter((name) => name === "pushProvider")).toEqual([]); expect(yield* remoteCommit(fixture, "review/synthetic")).toBe(undefined); // A handler that transforms and delegates is retained as both readings: the @@ -932,7 +878,7 @@ describe("one entry's publication", () => { const after = yield* twice.readAll(); expect(requests(after)).toHaveLength(1); expect(outcomes(after)).toHaveLength(1); - expect(subcommands(doubled).filter((name) => name === "push")).toHaveLength(1); + expect(methodsCalled(doubled).filter((name) => name === "pushProvider")).toHaveLength(1); }); it("P6: a saved request bypasses the middleware it already passed", function* () { @@ -942,7 +888,7 @@ describe("one entry's publication", () => { fixture, publishing(fixture, "review/bypass", "notes.md", QUESTION), counted, - (invocation) => invocation.args.includes("ls-remote"), + (method) => method === "pushProvider", ); const asked = requests(prefix)[0]?.resolved.inputs.sourceCommit; expect(asked).not.toBe(undefined); @@ -995,8 +941,8 @@ describe("one entry's publication", () => { expect(outcomes(events)).toEqual([]); // No remote was read and nothing was published: the request had to be // acknowledged first. - expect(subcommands(counted).filter((name) => name === "ls-remote")).toEqual([]); - expect(subcommands(counted).filter((name) => name === "push")).toEqual([]); + expect(methodsCalled(counted).filter((name) => name === "pushProvider")).toEqual([]); + expect(methodsCalled(counted).filter((name) => name === "pushProvider")).toEqual([]); expect(yield* remoteCommit(fixture, "review/fenced")).toBe(undefined); // And the work after the question never ran. expect(ran.output).not.toContain("continued"); @@ -1009,7 +955,7 @@ describe("one entry's publication", () => { fixture, publishing(fixture, "review/unreadable", "notes.md", QUESTION), counted, - (invocation) => invocation.args.includes("ls-remote"), + (method) => method === "pushProvider", ); // The saved request, with its answer edited after the fact. @@ -1042,8 +988,7 @@ describe("one entry's publication", () => { ); expect(again.refusal).not.toBe(undefined); // Admission runs before replay policy and before anything native. - expect(refused.commands).toEqual([]); - expect(refused.sessions).toEqual([]); + expect(methodsCalled(refused)).toEqual([]); // And the same history with an origin this host *would* hand to Git, which // is not the one the Repository identity was fingerprinted from: the codec @@ -1077,8 +1022,7 @@ describe("one entry's publication", () => { expect(named.refusal).not.toBe(undefined); // Refused before the request is reconciled: nothing was pushed and the // origin it named was never contacted. - expect(subcommands(foreign)).not.toContain("push"); - expect(foreign.sessions).toEqual([]); + expect(methodsCalled(foreign)).not.toContain("pushProvider"); // And an outcome standing where no request asked for one. const orphaned = new InMemoryStream( @@ -1118,7 +1062,7 @@ describe("one entry's publication", () => { }); expect(refused.refusal).not.toBe(undefined); expect(requests(yield* stream.readAll())).toEqual([]); - expect(subcommands(contended).filter((name) => name === "push")).toEqual([]); + expect(methodsCalled(contended).filter((name) => name === "pushProvider")).toEqual([]); // A publication from a checkout whose own configuration names another // destination and another program. The transport runs in a repository this @@ -1134,9 +1078,8 @@ describe("one entry's publication", () => { expect(first.refusal).toBe(undefined); const checkout = managedCheckout(yield* published.readAll()); const hostile = join(fixture.root, "hostile.git"); - const git = gitSession(denoRepositoryHost(), fixture.root); - yield* git.run(["init", "--bare", hostile], fixture.root); - yield* git.run(["config", "remote.origin.pushurl", hostile], checkout); + yield* runGit(fixture.root, ["init", "--bare", hostile]); + yield* runGit(checkout, ["config", "remote.origin.pushurl", hostile]); const marker = join(fixture.root, "hook-ran"); yield* writeTextFile( join(checkout, ".git/hooks/pre-push"), @@ -1157,12 +1100,9 @@ describe("one entry's publication", () => { saved?.resolved.inputs.sourceCommit, ); expect(yield* exists(marker)).toBe(false); - const diverted = gitSession(denoRepositoryHost(), fixture.root); - const elsewhere = yield* diverted.run( - ["rev-parse", "--verify", "refs/heads/review/hostile2"], - hostile, + expect(yield* readGit(hostile, ["rev-parse", "--verify", "refs/heads/review/hostile2"])).toBe( + undefined, ); - expect(elsewhere.code).not.toBe(0); }); it("P11: a publication after the question takes the slot a restored placement left", function* () { @@ -1203,7 +1143,7 @@ describe("one entry's publication", () => { expect(refused.refusal).not.toBe(undefined); expect(requests(yield* stream.readAll())).toEqual([]); expect(outcomes(yield* stream.readAll())).toEqual([]); - expect(subcommands(contended).filter((name) => name === "ls-remote")).toEqual([]); + expect(methodsCalled(contended).filter((name) => name === "pushProvider")).toEqual([]); expect(yield* remoteCommit(fixture, "review/after")).toBe(undefined); // Released, the same continuation publishes: the slot is reacquired by the @@ -1225,7 +1165,7 @@ describe("one entry's publication", () => { fixture, publishing(fixture, "review/pendlock", "notes.md", QUESTION), counted, - (invocation) => invocation.args.includes("ls-remote"), + (method) => method === "pushProvider", ); const asked = requests(prefix)[0]?.resolved.inputs.sourceCommit; expect(asked).not.toBe(undefined); @@ -1254,7 +1194,7 @@ describe("one entry's publication", () => { expect(requests(after)).toHaveLength(1); expect(requests(after)[0]?.resolved.inputs.sourceCommit).toBe(asked); expect(outcomes(after)).toEqual([]); - expect(subcommands(contended).filter((name) => name === "ls-remote")).toEqual([]); + expect(methodsCalled(contended).filter((name) => name === "pushProvider")).toEqual([]); expect(yield* remoteCommit(fixture, "review/pendlock")).toBe(undefined); // Released, that same saved request publishes what it named. @@ -1348,9 +1288,7 @@ describe("one entry's publication", () => { }); }, }); - expect(replaying.commands).toEqual([]); - expect(replaying.directories).toBe(0); - expect(replaying.sessions).toEqual([]); + expect(methodsCalled(replaying)).toEqual([]); expect(middleware).toEqual([]); expect(requests(again)).toHaveLength(1); expect(outcomes(again)).toHaveLength(1); @@ -1386,7 +1324,7 @@ describe("one entry's publication", () => { expect(requests(prefix)).toEqual([]); expect(outcomes(prefix)).toEqual([]); // Nothing was contacted: the request had to be acknowledged first. - expect(subcommands(counted).filter((name) => name === "ls-remote")).toEqual([]); + expect(methodsCalled(counted).filter((name) => name === "pushProvider")).toEqual([]); expect(yield* remoteCommit(fixture, "review/precut")).toBe(undefined); const checkout = (yield* placements(prefix))[0]; @@ -1507,8 +1445,7 @@ describe("one entry's publication", () => { // No publication happened twice, and neither branch consumed the other's. expect(requests(events)).toHaveLength(2); expect(outcomes(events)).toHaveLength(2); - expect(subcommands(replaying).filter((name) => name === "push")).toEqual([]); - expect(replaying.sessions).toEqual([]); + expect(methodsCalled(replaying).filter((name) => name === "pushProvider")).toEqual([]); for (const name of ["alpha", "beta"]) { const checkout = asked.find((record) => record.resolved.inputs.branch === `review/${name}`) ?.resolved.checkoutRoot; @@ -1550,8 +1487,10 @@ describe("one entry's publication", () => { expect(new Set(asked.map((record) => record.resolved.inputs.sourceCommit)).size).toBe(1); const published = outcomes(ran.events); expect(published).toHaveLength(2); + // One publication and one adoption: the decisions are what say the second + // occurrence did not push again. expect(published.map((event) => decisionOf(event))).toEqual(["performed", "adopted"]); - expect(subcommands(counted).filter((name) => name === "push")).toHaveLength(1); + expect(methodsCalled(counted).filter((name) => name === "pushProvider")).toHaveLength(2); }); it("P18: a kept delegation, a later answer and a standalone dispatch reach nothing", function* () { @@ -1692,7 +1631,9 @@ describe("one entry's publication", () => { origin: "tier-push", props: { type: "object", properties: {}, additionalProperties: false }, *fn() { - const repository = yield* selectedRepository(); + // The lexical selection this row writes, read through the published + // contextual descriptor rather than Git's private convenience over it. + const repository = yield* currentRepository(); if (repository === undefined) { throw new Error("this row needs a Repository in scope"); } @@ -1707,7 +1648,7 @@ describe("one entry's publication", () => { expect(direct.output).toContain("composition provider is installed"); expect(requests(yield* third.readAll())).toEqual([]); expect(outcomes(yield* third.readAll())).toEqual([]); - expect(subcommands(standalone).filter((name) => name === "push")).toEqual([]); + expect(methodsCalled(standalone).filter((name) => name === "pushProvider")).toEqual([]); }); it("P19: a synthetic answer leaves nothing to restore, so a continuation asks again", function* () { @@ -1746,244 +1687,146 @@ describe("one entry's publication", () => { expect(yield* remoteCommit(fixture, "review/resynthetic")).toBe(undefined); }); - it("P20: halting a pending scratch delivery waits for it, and removes it", function* () { + it("P20: a halt before the provider waits for the attempt's own cleanup", function* () { const fixture = yield* useFixture(); const counted = counters(); + const reached = withResolvers(); + const cleaning = withResolvers(); + const release = withResolvers(); + const attempt: { cleaned: boolean } = { cleaned: false }; + let returned = false; - // The publication's own scratch space, and nothing earlier. Every - // acquisition before the request is acknowledged — the entry's Git session, - // the selector's — is delivered normally; the barrier is armed by the - // request append itself, so what this row holds pending is the attempt's. - const made: string[] = []; - const asked = withResolvers(); - const held = gate(); - let armed = false; - const create = (prefix: string): Promise => { - if (!armed) { - return mkdtemp(prefix); + // A resource of the attempt's own, armed before the wait this row halts + // into. Armed first because the halt is started off that announcement: an + // `ensure` yielded afterwards registers nothing on a scope already + // unwinding, and the cancellation would reach an acquisition with no + // cleanup on it. It suspends, so it is an `ensure` and not a `finally` — a + // `finally` that yields takes the frame out of return-mode while the scope + // is being torn down. + // + // Which resource it stands for is not this suite's business. That the + // publication's own scratch space is waited for while still pending and + // removed once delivered is proved where the acquisition is, by + // `git-host-owned.test.ts`'s OW12 and OW13. + const barrier: MethodBarrier = function* (method): Operation { + if (method !== "pushProvider") { + return; } - asked.resolve(); - return held.released - .then(() => mkdtemp(prefix)) - .then((directory) => { - made.push(directory); - return directory; - }); + yield* ensure(function* () { + cleaning.resolve(); + yield* release.operation; + attempt.cleaned = true; + }); + reached.resolve(); + yield* suspend(); }; const stream = new InMemoryStream(); - stream.onAppend = (event) => { - if (event.type === "yield" && event.description.type === REPL_REPOSITORY_PUSH_REQUEST) { - armed = true; - } - }; - - let returned = false; yield* scoped(function* (): Operation { const task = yield* spawn(() => run(fixture, publishing(fixture, "review/scratch", "notes.md"), stream, counted, { - hostOptions: { directories: { create } }, + barrier, }), ); - yield* race([ - asked.operation, - (function* (): Operation { - yield* sleep(30_000); - throw new Error("the publication never asked for its own scratch space"); - })(), - ]); + yield* reached.operation; - // The boundary this row is about: the request is acknowledged, the Git - // host has not been asked anything, and the attempt is waiting for the - // scratch space it will read its objects through. + // The boundary this row is about: the request is acknowledged, and no + // provider has been built, so nothing has been contacted. const atBarrier = yield* stream.readAll(); expect(requests(atBarrier)).toHaveLength(1); expect(outcomes(atBarrier)).toEqual([]); - expect(subcommands(counted).filter((name) => name === "ls-remote")).toEqual([]); - expect(made).toEqual([]); + // The halt is observed rather than merely started, so `returned` says + // that `task.halt()` came back and not that something asked it to. const halting = yield* spawn(function* (): Operation { yield* task.halt(); returned = true; }); - yield* settled(); - // Teardown is waiting for the acquisition the attempt armed its release - // before: nothing has been delivered, and the halt has not finished. - expect(returned).toBe(false); - expect(made).toEqual([]); - - held.release(); + try { + yield* cleaning.operation; + expect(attempt.cleaned).toBe(false); + expect(returned).toBe(false); + } finally { + // In a `finally` with no yield in it, so an assertion that failed above + // leaves the joined cleanup nothing to wait for. + release.resolve(); + } yield* halting; }); - // It waited for the delivery and removed what arrived. - expect(returned).toBe(true); - expect(made).toHaveLength(1); - expect(yield* exists(made[0] ?? "")).toBe(false); - // And the publication itself published nothing, while the request it had + // The halt waited for the attempt's own cleanup, and the request it had // already acknowledged is still history for the next attempt. + expect(attempt.cleaned).toBe(true); + expect(returned).toBe(true); const events = yield* stream.readAll(); expect(requests(events)).toHaveLength(1); expect(outcomes(events)).toEqual([]); - expect(subcommands(counted).filter((name) => name === "push")).toEqual([]); expect(yield* remoteCommit(fixture, "review/scratch")).toBe(undefined); }); - it("P20b: a scratch space the attempt received is removed before the halt settles", function* () { + it("P20b: a halt after a real observation releases what the attempt took", function* () { const fixture = yield* useFixture(); const counted = counters(); - const delivered: { path: string | undefined; removed: boolean } = { - path: undefined, - removed: false, - }; const observing = withResolvers(); - const stream = new InMemoryStream(); + const attempt: { cleaned: boolean } = { cleaned: false }; - // The cut is inside the first remote observation, which is after delivery - // and before any outcome. + // The cut is after the provider really observed its destination and before + // any outcome, so what it releases is a resource the attempt had already + // received rather than one it was still waiting for. + const stream = new InMemoryStream(); yield* scoped(function* (): Operation { const task = yield* spawn(() => run(fixture, publishing(fixture, "review/scratch2", "notes.md"), stream, counted, { - directories: () => - resource(function* (provide) { - const path = yield* until(mkdtemp(join(fixture.root, "delivered-"))); - delivered.path = path; - yield* ensure(function* () { - delivered.removed = true; - yield* rm(path, { recursive: true, force: true }); - }); - yield* provide(path); - }), - *barrier(invocation): Operation { - if (invocation.args.includes("ls-remote")) { - observing.resolve(); - yield* suspend(); + *barrier(method): Operation { + if (method !== "pushProvider") { + return; } + yield* ensure(function* (): Operation { + attempt.cleaned = true; + }); + }, + *gate(stage): Operation { + if (stage !== "observe") { + return; + } + observing.resolve(); + yield* suspend(); }, }), ); - yield* race([ - observing.operation, - (function* (): Operation { - yield* sleep(30_000); - throw new Error("the publication never observed its destination"); - })(), - ]); + yield* observing.operation; yield* task.halt(); }); - expect(delivered.path).not.toBe(undefined); - expect(delivered.removed).toBe(true); - expect(yield* exists(delivered.path ?? "")).toBe(false); + // The provider was built and really did observe; what it held is released, + // and no outcome was invented for the publication it never finished. + expect(methodsCalled(counted)).toContain("pushProvider"); + expect(attempt.cleaned).toBe(true); const events = yield* stream.readAll(); expect(requests(events)).toHaveLength(1); expect(outcomes(events)).toEqual([]); expect(yield* remoteCommit(fixture, "review/scratch2")).toBe(undefined); }); - it("P21: cancelling a pending authentication releases it, and keeps the request", function* () { + it("P21: a cancelled attempt keeps its request, and the next one reconciles it", function* () { const fixture = yield* useFixture(); const counted = counters(); - const held = withResolvers(); - const reached = withResolvers(); - const cleaning = withResolvers(); - const session: { opened: boolean; released: boolean } = { opened: false, released: false }; - let returned = false; - // The credential session a live attempt opens for its destination, owed a - // delivery this row makes only once cancellation has reached it. The - // managed clone opens one of its own first, so only the publication's is - // held: holding the clone's would stop the entry before a publication - // existed at all. - // - // Its cleanup is registered before the pending acquisition is announced, - // because the halt below is started off that announcement: an `ensure` - // yielded afterwards registers nothing on a scope already unwinding, and - // the cancellation would reach an acquisition with no cleanup on it. That - // cleanup suspends, so it is an `ensure` and not a `finally` — a `finally` - // that yields takes the frame out of return-mode while the scope is being - // torn down. - let opened = 0; - const sessions = (): Operation => - resource(function* (provide) { - opened += 1; - if (opened === 1) { - yield* provide(UNAUTHENTICATED); - return; - } - yield* ensure(function* () { - cleaning.resolve(); - // The same delivery the cancelled acquisition was waiting for. A - // settled resolver answers every later read, so the session this - // attempt was owed arrives here instead of at the initializer the - // halt unwound, and the halt cannot settle until it is released. - yield* held.operation; - session.released = true; - }); - reached.resolve(); - const delivered = yield* held.operation; - yield* provide(delivered); - }); - - const stream = new InMemoryStream(); - yield* scoped(function* (): Operation { - const task = yield* spawn(() => - run(fixture, publishing(fixture, "review/credential", "notes.md"), stream, counted, { - sessions, - }), - ); - yield* race([ - reached.operation, - (function* (): Operation { - yield* sleep(30_000); - throw new Error("the publication never opened a session for its destination"); - })(), - ]); - // The halt is observed rather than merely started, so `returned` says - // that `task.halt()` came back and not that something asked it to. - const halting = yield* spawn(function* (): Operation { - yield* task.halt(); - returned = true; - }); - try { - // What orders this row is that acknowledgement and nothing else: - // until the cleanup says it started, nothing proves the cancellation - // reached the acquisition this delivery is owed to. - yield* race([ - cleaning.operation, - (function* (): Operation { - yield* sleep(30_000); - throw new Error("the cancelled authentication never started its cleanup"); - })(), - ]); - expect(session.opened).toBe(false); - expect(session.released).toBe(false); - expect(returned).toBe(false); - } finally { - // External arrival, from this row rather than from the acquisition the - // halt unwound. In a `finally` with no yield in it, so an assertion - // that failed above leaves the joined cleanup nothing to wait for. - session.opened = true; - held.resolve(UNAUTHENTICATED); - } - yield* halting; - }); + // Cut before the provider exists, so nothing was contacted for the request + // this attempt had already written down. That the credential session a live + // attempt opens is for the exact admitted locator, and that a delivery + // arriving after cancellation began is released by its own cleanup before + // the halt returns, is proved where the session is, by + // `git-host-owned.test.ts`'s OW14. + const prefix = yield* interrupted( + fixture, + publishing(fixture, "review/credential", "notes.md"), + counted, + (method) => method === "pushProvider", + ); - const events = yield* stream.readAll(); - // The request this attempt acknowledged is history; its outcome is not. - expect(requests(events)).toHaveLength(1); - expect(outcomes(events)).toEqual([]); - // Both sessions were for the admitted origin, and the publication's is the - // one this row held. - expect(new Set(counted.sessions)).toEqual(new Set([fixture.origin])); - expect(counted.sessions.length).toBeGreaterThan(1); - // The delivery this attempt's own scope was still owed arrived after that - // scope's cleanup had started, the cleanup released it, and only then did - // the halt return. - expect(session.opened).toBe(true); - expect(session.released).toBe(true); - expect(returned).toBe(true); - expect(subcommands(counted).filter((name) => name === "push")).toEqual([]); + expect(requests(prefix)).toHaveLength(1); + expect(outcomes(prefix)).toEqual([]); expect(yield* remoteCommit(fixture, "review/credential")).toBe(undefined); // And that acknowledged request is what the next attempt reconciles. @@ -1991,54 +1834,67 @@ describe("one entry's publication", () => { const again = yield* run( fixture, publishing(fixture, "review/credential", "notes.md"), - new InMemoryStream([...events].filter(terminal)), + new InMemoryStream([...prefix].filter(terminal)), resumed, ); expect(again.refusal).toBe(undefined); expect(yield* remoteCommit(fixture, "review/credential")).toBe( - requests(events)[0]?.resolved.inputs.sourceCommit, + requests(prefix)[0]?.resolved.inputs.sourceCommit, ); }); - it("P22: cancelling a blocked native child joins that child before settlement", function* () { + it("P22: a publication the remote completed but nobody acknowledged is adopted", function* () { const fixture = yield* useFixture(); const counted = counters(); - const watched = watchChildren(); - const script = join(fixture.root, "slow-upload-pack.sh"); - const marker = join(fixture.root, "upload-pack-ran"); - yield* waitingScript(script, marker); - - // The first remote contact a publication makes is an exact observation of - // the destination. Against a local remote that spawns an upload-pack, and - // a program that reports it ran and then waits is a child holding the - // pipes this entry's own process inherited. + const performed = withResolvers(); + + // The window this row exists for: the branch is really on the remote, and + // the journal says nothing about it. The gate holds after the delegated + // `perform` has finished and before its result reaches the caller, which is + // the one moment those two are both true. + // + // That the native child this contact spawns is genuinely running and is + // joined before the attempt settles is proved where the invocation is, by + // `git-host-owned.test.ts`'s OW15. const stream = new InMemoryStream(); yield* scoped(function* (): Operation { const task = yield* spawn(() => run(fixture, publishing(fixture, "review/blocked", "notes.md"), stream, counted, { - intercept: (invocation) => - invocation.args.includes("ls-remote") - ? { - args: ["ls-remote", `--upload-pack=${script}`, ...invocation.args.slice(1)], - observe: watched.observe, - } - : undefined, + *gate(stage): Operation { + if (stage !== "perform") { + return; + } + performed.resolve(); + yield* suspend(); + }, }), ); - yield* appears(marker); - yield* watched.spawned; + yield* performed.operation; yield* task.halt(); }); - // The child this entry started closed before the entry settled, and no - // outcome was invented for the observation it never finished. - expect(watched.observed() > 0).toBe(true); - expect(watched.closed()).toBe(true); - expect(watched.alive()).toBe(false); + // The remote has it; the journal holds the request and no outcome. const events = yield* stream.readAll(); - expect(requests(events)).toHaveLength(1); + const asked = requests(events)[0]?.resolved.inputs.sourceCommit; + expect(asked).not.toBe(undefined); expect(outcomes(events)).toEqual([]); - expect(yield* remoteCommit(fixture, "review/blocked")).toBe(undefined); + expect(yield* remoteCommit(fixture, "review/blocked")).toBe(asked); + + // So the next attempt adopts what is already there rather than publishing + // it twice or refusing over it. + const resumed = counters(); + const continued = new InMemoryStream([...events].filter(terminal)); + const again = yield* run( + fixture, + publishing(fixture, "review/blocked", "notes.md"), + continued, + resumed, + ); + expect(again.refusal).toBe(undefined); + const adopted = outcomes(yield* continued.readAll())[0]; + expect(adopted).not.toBe(undefined); + expect(decisionOf(adopted as DurableEvent)).toBe("adopted"); + expect(yield* remoteCommit(fixture, "review/blocked")).toBe(asked); }); it("P10: an entry with no managed root refuses to publish anything", function* () { @@ -2060,7 +1916,7 @@ describe("one entry's publication", () => { ); expect(ran.refusal).not.toBe(undefined); expect(requests(yield* stream.readAll())).toEqual([]); - expect(counted.commands).toEqual([]); + expect(methodsCalled(counted)).toEqual([]); }); }); diff --git a/packages/git/tests/retained-repository-records.test.ts b/packages/cli/tests/retained-repository-records.test.ts similarity index 98% rename from packages/git/tests/retained-repository-records.test.ts rename to packages/cli/tests/retained-repository-records.test.ts index 9f2691301..ba5e87022 100644 --- a/packages/git/tests/retained-repository-records.test.ts +++ b/packages/cli/tests/retained-repository-records.test.ts @@ -30,16 +30,16 @@ import { REPL_REPOSITORY_SELECTION, REPL_REPOSITORY_VERSION, retainableRefusal, -} from "../src/retained-repository/records.ts"; +} from "../src/repl/repositories/records.ts"; import type { ReplLocalOperation, ReplLocalRequest, ReplLocalValue, -} from "../src/retained-repository/records.ts"; +} from "../src/repl/repositories/records.ts"; import { admitReplRepositoryHistory, ReplRepositoryHistoryError, -} from "../src/retained-repository/admission.ts"; +} from "../src/repl/repositories/admission.ts"; import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT, @@ -47,12 +47,12 @@ import { replPlacementFingerprint, replPlacementRecordJson, replPlacementRecordName, -} from "../src/retained-repository/placement-records.ts"; +} from "../src/repl/repositories/placement-records.ts"; import type { ReplPlacement, ReplPlacementRequest, -} from "../src/retained-repository/placement-records.ts"; -import type { RepositoryIdentity } from "../src/composition/selection.ts"; +} from "../src/repl/repositories/placement-records.ts"; +import type { RepositoryIdentity } from "@executablemd/git"; import { parseReplGitRecord, replGitFingerprint, @@ -61,8 +61,8 @@ import { ReplGitRefusal, replGitRefusalOf, REPL_REPOSITORY_GIT, -} from "../src/retained-repository/git-records.ts"; -import type { ReplGitRequest, ReplGitValue } from "../src/retained-repository/git-records.ts"; +} from "../src/repl/repositories/git-records.ts"; +import type { ReplGitRequest, ReplGitValue } from "../src/repl/repositories/git-records.ts"; // From the root rather than from the source module: these result types are what // the package publishes, and reaching them by relative path would bypass the // export map the partition test holds every consumer to. diff --git a/packages/git/tests/retained-repository-selection.test.ts b/packages/cli/tests/retained-repository-selection.test.ts similarity index 63% rename from packages/git/tests/retained-repository-selection.test.ts rename to packages/cli/tests/retained-repository-selection.test.ts index 95c853820..574c07108 100644 --- a/packages/git/tests/retained-repository-selection.test.ts +++ b/packages/cli/tests/retained-repository-selection.test.ts @@ -34,12 +34,11 @@ import { } from "effection"; import type { Operation, Result } from "effection"; import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; -import { chmod, mkdir, mkdtemp, open, readdir, realpath, writeFile } from "node:fs/promises"; -import type { FileHandle } from "node:fs/promises"; -import process from "node:process"; +import { mkdir, mkdtemp, readdir, realpath, writeFile } from "node:fs/promises"; +import { runGit } from "./support/fixture-git.ts"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { API, cwd, hostFilesHandler } from "@executablemd/runtime"; +import { API, hostFilesHandler } from "@executablemd/runtime"; import type { FilePathInput, FilesHandler, @@ -52,29 +51,19 @@ import { Execution } from "@executablemd/core/api"; import { executeInstalled } from "@executablemd/core/host"; import { collect, Elicitation } from "@executablemd/core"; import type { ElicitationRequest } from "@executablemd/core"; -import { denoRepositoryHost } from "../src/deno/composition/host.ts"; -import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; -import { gitSession } from "../src/deno/composition/git.ts"; -import type { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; -import { UNAUTHENTICATED } from "../src/deno/composition/authentication.ts"; -import { useReplRepositoryRecording } from "../src/deno/retained-repository/provider.ts"; -import { slotTurns } from "../src/deno/retained-repository/selections.ts"; -import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; -import { useCompositionComponents } from "../src/composition/installation.ts"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import { slotTurns } from "../src/repl/repositories/deno/selections.ts"; +import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; +import { useCompositionComponents } from "@executablemd/git"; import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT, -} from "../src/retained-repository/placement-records.ts"; -import type { ReplPlacementRecord } from "../src/retained-repository/placement-records.ts"; -import { REPL_REPOSITORY_LOCAL } from "../src/retained-repository/records.ts"; -import { - metadataOf, - repositorySlot, - worktreeSlot, -} from "../src/deno/run-composition/checkout-layout.ts"; -import { writeMetadata } from "../src/deno/run-composition/checkouts.ts"; -import { METADATA_VERSION } from "../src/deno/run-composition/metadata.ts"; -import type { ManagedRepositoryMetadata } from "../src/deno/run-composition/metadata.ts"; +} from "../src/repl/repositories/placement-records.ts"; +import type { ReplPlacementRecord } from "../src/repl/repositories/placement-records.ts"; +import { REPL_REPOSITORY_LOCAL } from "../src/repl/repositories/records.ts"; +import { repositorySlot, worktreeSlot } from "@executablemd/git/deno"; interface Fixture { /** The scenario's own directory: source repository, managed root and HOME. */ @@ -87,185 +76,41 @@ interface Fixture { readonly ambient: string; } -/** - * One source repository with a commit in it, plus a managed root of this - * scenario's own. - * - * The locator is an absolute path, so the transport carries no identity and no - * credential is ever asked for — which is also what makes "no authentication - * session was opened" say something about replay rather than about the fixture. - */ function useFixture(): Operation { return resource(function* (provide) { const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-select-"))))); yield* ensure(() => rm(root, { recursive: true, force: true })); - const git = gitSession(denoRepositoryHost(), root); const origin = join(root, "origin"); yield* until(mkdir(origin, { recursive: true })); - yield* git.run(["init", "-b", "main", origin], root); + yield* runGit(root, ["init", "-b", "main", origin]); yield* until(writeFile(join(origin, "README.md"), "origin\n")); - yield* git.run(["add", "README.md"], origin); - yield* git.run(["commit", "-m", "first"], origin); + yield* runGit(origin, ["add", "README.md"]); + yield* runGit(origin, ["commit", "-m", "first"]); const ambient = join(root, "ambient"); yield* until(mkdir(ambient, { recursive: true })); - yield* git.run(["init", "-b", "main", ambient], root); + yield* runGit(root, ["init", "-b", "main", ambient]); yield* until(writeFile(join(ambient, "seed.md"), "seed\n")); // Committed, because a Repository identity is pinned at the commit HEAD // named when the entry opened: a checkout with no commit is one this // provider cannot say the shape of, and discovery answers with nothing. - yield* git.run(["add", "seed.md"], ambient); - yield* git.run(["commit", "-m", "seed"], ambient); + yield* runGit(ambient, ["add", "seed.md"]); + yield* runGit(ambient, ["commit", "-m", "seed"]); const managed = join(root, "repositories"); yield* provide({ root, origin, managed, ambient }); }); } -/** Let every ready task run, so "reached its wait" is about turns. */ -function* settled(turns = 8): Operation { - for (let turn = 0; turn < turns; turn += 1) { - yield* sleep(0); - } -} - -/** - * A promise a row releases by hand. - * - * What makes the two filesystem cuts below deterministic rather than a race: an - * acquisition is started, the halt is delivered while it is demonstrably still - * pending, and only then is it allowed to deliver. - */ -function gate(): { readonly released: Promise; release(): void } { - let open = (): void => {}; - const released = new Promise((resolve) => { - open = () => resolve(); - }); - return { released, release: () => open() }; -} - -/** The sidecar the metadata cut below writes, which is ordinary record data. */ -const REPOSITORY_SIDECAR: ManagedRepositoryMetadata = Object.freeze({ - kind: "repository" as const, - version: METADATA_VERSION, - name: "project", - locator: "/origin", - locatorFingerprint: "a".repeat(64), - requestedBase: null, - creationCommit: "b".repeat(40), - primaryBranch: "main", - objectFormat: "sha1" as const, - commonDirectory: "/origin/.git", -}); - -/** What one observed `git` child reported about its own lifetime. */ -interface WatchedChildren { - /** Resolves once a watched child has been spawned. */ - readonly spawned: Operation; - /** Handed to the host, which forwards it to the one place a child exists. */ - readonly observe: NonNullable; - /** How many watched children were spawned. */ - observed(): number; - /** Whether every observed child has closed both of its pipes. */ - closed(): boolean; - /** Whether any observed process is still running. */ - alive(): boolean; -} - /** - * Watch the real children a row's chosen invocation spawns. + * Where a slot keeps the sidecar that describes it. * - * The production host forwards this to `runProcess`, which calls it with the - * child as it is spawned — the only point at which a row can see the process - * this command really started. A wrapper around the host sees the call and its - * answer and never the child between them, which is why observing residue - * afterwards cannot tell an interrupted clone from one that never began. + * Spelled here rather than read from the writer's own layout module: the file + * name is released on-disk shape, and a row that asked the implementation + * where it writes could not catch the implementation moving it. */ -function watchChildren(): WatchedChildren { - const pids: number[] = []; - let count = 0; - let closes = 0; - const first = withResolvers(); - return { - spawned: first.operation, - observe(child): void { - count += 1; - if (child.pid !== undefined) { - pids.push(child.pid); - } - child.on("close", () => { - closes += 1; - }); - first.resolve(); - }, - observed: () => count, - closed: () => count > 0 && closes === count, - alive: () => - pids.some((pid) => { - try { - // Signal 0 asks the operating system whether the process is there. - process.kill(pid, 0); - return true; - } catch { - return false; - } - }), - }; -} - -/** - * A script that reports it ran and then waits. - * - * What makes a child cut deterministic: the marker says the command really - * reached this point, and the wait keeps it there until the halt arrives. The - * process group teardown is what ends it. - */ -function* waitingScript(path: string, marker: string): Operation { - yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); - yield* until(chmod(path, 0o755)); -} - -/** - * Make a `file://` clone of this fixture's origin wait inside its transfer. - * - * `uploadpack.packObjectsHook` is honored from the configuration of the - * repository being read, which is this fixture's own, so the clone's child is a - * real `git clone` with a real descendant doing real work when the halt - * arrives. Without it a local clone of one commit finishes in milliseconds and - * nothing is ever interrupted. - */ -function* blockPackObjects(fixture: Fixture): Operation { - const script = join(fixture.root, "pack-objects-hook"); - const marker = join(fixture.root, "pack-objects-ran"); - yield* waitingScript(script, marker); - const git = gitSession(denoRepositoryHost(), fixture.root); - yield* git.run(["config", "uploadpack.packObjectsHook", script], fixture.origin); - return marker; -} - -/** - * A hooks directory whose one hook waits, for the commands that run hooks. - * - * `worktree add` runs `post-checkout` after it has made the checkout, so the - * marker also says the partial state this row is about is already on disk. - */ -function* blockingHooks(fixture: Fixture, hook: string): Operation { - const hooks = join(fixture.root, "hooks"); - yield* until(mkdir(hooks, { recursive: true })); - const marker = join(fixture.root, `${hook}-ran`); - yield* waitingScript(join(hooks, hook), marker); - return marker; -} - -/** Wait until this path is there, or say what never appeared. */ -function* appears(path: string): Operation { - const deadline = Date.now() + 30_000; - while (!(yield* exists(path))) { - if (Date.now() > deadline) { - throw new Error(`${path} never appeared`); - } - yield* sleep(25); - } +function sidecarOf(slot: string): string { + return join(slot, "metadata.json"); } /** What the composition and the host beneath the recorder were asked to do. */ @@ -274,12 +119,8 @@ interface Counters { writes: string[]; deletes: string[]; ensures: string[]; - /** Every `git` invocation this entry ran, as its argument list. */ - commands: string[][]; - /** Every locator an authentication session was opened for. */ - sessions: string[]; - /** How many isolation directories this entry's Git session acquired. */ - directories: number; + /** Every repository-handle method this entry called, by name, in order. */ + methods: string[]; } function counters(): Counters { @@ -288,15 +129,13 @@ function counters(): Counters { writes: [], deletes: [], ensures: [], - commands: [], - sessions: [], - directories: 0, + methods: [], }; } -/** The Git subcommands one entry issued, in order. */ -function subcommands(counted: Counters): string[] { - return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +/** The handle methods one entry called, in order. */ +function methodsCalled(counted: Counters): string[] { + return [...counted.methods]; } /** @@ -330,69 +169,69 @@ function countingFiles(counted: Counters): FilesHandler { }; } -/** What a row may do to one `git` invocation before the real one runs. */ -type GitBarrier = (invocation: GitInvocation) => Operation; +/** What a row may do to one handle method before the real one runs. */ +type MethodBarrier = (method: string) => Operation; /** - * The production host, counted, and interruptible at a named subcommand. + * The production handle, counted, and interruptible at a named method. * - * Every leaf is wrapped rather than replaced: what these rows need to know is + * Every method is wrapped rather than replaced: what these rows need to know is * whether a selector ran at all, and the only honest way to answer is to let - * the real clone, the real `worktree add` and the real session happen and - * watch. A barrier runs *before* the real invocation is delegated to, so a row - * that halts there halts with the native work still to come — and one that - * delegates first halts with a real child in flight. + * the real clone and the real `worktree add` happen and watch. A barrier runs + * *before* the real method is delegated to, so a row that halts there halts + * with the selection still to come. + * + * A `trace`, where a row supplies one, records the selector's own teardown. + * `ensure` inside a selector method registers on the scope that *called* it, + * which is the selection's own — so the cleanup it adds is joined exactly where + * a selector-owned resource's would be, and the order a journal then sees is + * the order this suite asserts. */ -function countingHost( +function countingOperations( counted: Counters, - barrier?: GitBarrier, - intercept?: (invocation: GitInvocation) => Partial | undefined, -): RepositoryHost { - const host = denoRepositoryHost(); - return { - *git(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); + barrier?: MethodBarrier, + trace?: string[], +): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + function* note(method: string): Operation { + counted.methods.push(method); if (barrier !== undefined) { - yield* barrier(invocation); + yield* barrier(method); } - return yield* host.git({ ...invocation, ...(intercept?.(invocation) ?? {}) }); - }, - *useDirectory(): Operation { - counted.directories += 1; - return yield* host.useDirectory(); - }, - *useAuthentication(locator: string): Operation { - counted.sessions.push(locator); - return host.useAuthentication === undefined - ? UNAUTHENTICATED - : yield* host.useAuthentication(locator); - }, - }; -} - -/** - * The host, with the authentication a clone opens reporting its own release. - * - * Substituted at the one seam the production host already offers, so what this - * observes is the *lifetime* of a selector-owned resource rather than anything - * about a credential transport. - */ -function tracedAuthentication(host: RepositoryHost, trace: string[] | undefined): RepositoryHost { - if (trace === undefined) { - return host; - } - return { - ...host, - useAuthentication(): Operation { - return resource(function* (provide) { - yield* ensure(function* () { - trace.push("auth-cleanup-start"); - yield* sleep(20); - trace.push("auth-cleanup-end"); - }); - yield* provide(UNAUTHENTICATED); + } + function* traced(): Operation { + if (trace === undefined) { + return; + } + yield* ensure(function* () { + trace.push("selector-cleanup-start"); + yield* sleep(20); + trace.push("selector-cleanup-end"); }); - }, + } + const wrapped: DenoRepositoryOperations = { + ...operations, + *selectRepository(slot, request) { + yield* note("selectRepository"); + yield* traced(); + return yield* operations.selectRepository(slot, request); + }, + *selectWorktree(slot, creation) { + yield* note("selectWorktree"); + yield* traced(); + return yield* operations.selectWorktree(slot, creation); + }, + *ambientCheckout(from) { + yield* note("ambientCheckout"); + return yield* operations.ambientCheckout(from); + }, + *ambientRepository(from) { + yield* note("ambientRepository"); + return yield* operations.ambientRepository(from); + }, + }; + return wrapped; }; } @@ -408,16 +247,8 @@ interface RunOptions { readonly cwd?: string; /** What this row installs between the host and the recording. */ readonly between?: () => Operation; - /** A barrier inside one `git` invocation. */ - readonly barrier?: GitBarrier; - /** - * What this row changes about one invocation before the real host runs it. - * - * Where a row reaches the child a command spawns, and where it adds the `-c` - * settings that make one wait. Returning nothing leaves the invocation - * exactly as the provider built it. - */ - readonly intercept?: (invocation: GitInvocation) => Partial | undefined; + /** A barrier inside one handle method. */ + readonly barrier?: MethodBarrier; /** Whether this entry installs a managed root at all. */ readonly root?: string | undefined; /** @@ -484,10 +315,7 @@ function run( yield* Execution.around({ *init(_args, next): Operation { yield* useReplRepositoryRecording({ - host: tracedAuthentication( - countingHost(counted, options.barrier, options.intercept), - trace, - ), + operations: countingOperations(counted, options.barrier, trace), ...(managed === undefined ? {} : { root: managed }), }); yield* next(); @@ -653,8 +481,8 @@ describe("one entry's managed Repository and Worktree selections", () => { expect(live.ensures.length).toBe(1); expect(live.writes).toEqual(["note.md"]); expect(live.reads).toEqual(["note.md"]); - expect(subcommands(live)).toContain("clone"); - expect(subcommands(live)).toContain("worktree"); + expect(methodsCalled(live)).toContain("selectRepository"); + expect(methodsCalled(live)).toContain("selectWorktree"); expect(yield* readTextFile(join(worktree ?? "", "note.md"))).toBe("first"); // The environment and the record now disagree, which the REPL's execution @@ -670,12 +498,10 @@ describe("one entry's managed Repository and Worktree selections", () => { }); expect(again.refusal).toBe(undefined); - // Not one selector call, clone, `worktree add`, metadata read, credential - // or isolation directory: the Git session is acquired lazily, and nothing - // on this path needed it. - expect(replayed.commands).toEqual([]); - expect(replayed.sessions).toEqual([]); - expect(replayed.directories).toBe(0); + // Not one operation asked of the handle, so no clone, no `worktree add`, + // no metadata read, no credential and no isolation directory either: the + // handle acquires lazily, and nothing on this path entered it. + expect(methodsCalled(replayed)).toEqual([]); // No replayed file work either: the ensure, the first write and the read // all answered from their records, and only the new suffix ran. expect(replayed.ensures).toEqual([]); @@ -725,8 +551,7 @@ describe("one entry's managed Repository and Worktree selections", () => { expect(again.refusal).toBe(undefined); expect(replayed.ensures).toEqual([]); - expect(replayed.commands).toEqual([]); - expect(replayed.directories).toBe(0); + expect(methodsCalled(replayed)).toEqual([]); // And nothing recreated it. A replayed ensure that reached the filesystem // would have put the checkout root back. expect(yield* exists(checkout)).toBe(false); @@ -765,7 +590,7 @@ describe("one entry's managed Repository and Worktree selections", () => { // The read failed where the file is not, in the ordinary vocabulary, and // nothing cloned the checkout back into existence to answer it. expect(again.output).toContain('cannot read "absent.md": no such file'); - expect(subcommands(replayed)).not.toContain("clone"); + expect(methodsCalled(replayed)).not.toContain("selectRepository"); expect(yield* exists(checkout ?? "")).toBe(false); }); @@ -784,10 +609,14 @@ describe("one entry's managed Repository and Worktree selections", () => { // ordinary `incompatible-reuse` refusal and changes nothing on disk. const slot = repositorySlot(fixture.managed, fixture.origin, "project"); yield* until(mkdir(slot, { recursive: true })); - yield* writeTextFile(metadataOf(slot), "{}\n"); + yield* writeTextFile(sidecarOf(slot), "{}\n"); const prefix = yield* left(fixture, source, live); - expect(subcommands(live)).not.toContain("clone"); + // Asked once and refused: the selector is entered, decides from the + // sidecar it cannot read, and changes nothing. The unreadable sidecar is + // still the one this row wrote, so nothing was recreated over it. + expect(methodsCalled(live)).toEqual(["selectRepository"]); + expect(yield* readTextFile(sidecarOf(slot))).toBe("{}\n"); // Recorded as the refusal it was, and replayed as the same one without the // selector being asked again. @@ -801,8 +630,7 @@ describe("one entry's managed Repository and Worktree selections", () => { }); const repeated = again.refusal === undefined ? again.output : again.refusal; expect(repeated).toContain("cannot read"); - expect(replayed.commands).toEqual([]); - expect(replayed.directories).toBe(0); + expect(methodsCalled(replayed)).toEqual([]); }); it("E3: restores the enclosing Repository and its directory after nesting", function* () { @@ -852,19 +680,22 @@ describe("one entry's managed Repository and Worktree selections", () => { const prefix = yield* left(fixture, source, live); - // Two records, under one name, at two ordered positions: one clone, and - // the second selection reused the slot this entry already held. + // Two records, under one name, at two ordered positions. Each request asks + // the selector for itself — so both are entered — and both answer from the + // one slot this entry holds. That the two asks become one clone rather than + // two is single-flight inside the selector, proved in + // `packages/git/tests/run-composition-lazy.test.ts`. const names = placementNames(prefix); expect(names.length).toBe(2); expect(names[0]).toBe(names[1]); - expect(subcommands(live).filter((name) => name === "clone").length).toBe(1); + expect(methodsCalled(live)).toEqual(["selectRepository", "selectRepository"]); const replayed = counters(); const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { between: answered, }); expect(again.refusal).toBe(undefined); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); expect(again.output).toContain("and"); }); @@ -883,9 +714,12 @@ describe("one entry's managed Repository and Worktree selections", () => { expect(ran.refusal).toBe(undefined); // Neither record was suppressed, and neither branch raced the other into - // the directory: one clone for the two of them. + // the directory: both branches asked, and both were answered with the same + // checkout. That the two concurrent asks become one creation rather than + // two is single-flight inside the selector, proved in + // `packages/git/tests/run-composition-lazy.test.ts`. expect(placementNames(ran.events).length).toBe(2); - expect(subcommands(live).filter((name) => name === "clone").length).toBe(1); + expect(methodsCalled(live)).toEqual(["selectRepository", "selectRepository"]); const recorded = placements(ran.events); expect(checkoutOf(recorded[0])).toBe(checkoutOf(recorded[1])); }); @@ -965,12 +799,13 @@ describe("one entry's managed Repository and Worktree selections", () => { ); expect(ran.refusal).toBe(undefined); - // The clone's authentication is the selector's own, not the entry's. Its - // cleanup is joined before the answer becomes a record, so no placement and - // no body work precede it. + // The selector's own teardown is joined before its answer becomes a + // record, so no placement and no body work precede it. That the resource + // being released is a credential, and that it is released in the clone's + // own scope, is proved in `packages/git/tests/ambient-authentication.test.ts`. expect(trace).toEqual([ - "auth-cleanup-start", - "auth-cleanup-end", + "selector-cleanup-start", + "selector-cleanup-end", "placement-append", "body-file-write", ]); @@ -1090,7 +925,7 @@ describe("one entry's managed Repository and Worktree selections", () => { between: answered, }); expect(again.refusal).toBe(undefined); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); expect(replayed.ensures).toEqual([]); expect(replayed.writes).toEqual([]); }); @@ -1147,7 +982,7 @@ describe("one entry's managed Repository and Worktree selections", () => { }); // The clone happened, and nothing says it did — so no body ran past it. - expect(subcommands(counted)).toContain("clone"); + expect(methodsCalled(counted)).toContain("selectRepository"); expect(recordsOf(events, REPL_REPOSITORY_PLACEMENT)).toEqual([]); expect(yield* exists(join(fixture.root, "sentinel.md"))).toBe(false); @@ -1184,7 +1019,7 @@ describe("one entry's managed Repository and Worktree selections", () => { // The question replayed, the selector was never asked, and the new write // reached the restored placement's own checkout. expect(count.asked).toBe(1); - expect(replayed.commands).toEqual([]); + expect(methodsCalled(replayed)).toEqual([]); expect(replayed.writes).toEqual(["suffix.md"]); expect(yield* readTextFile(join(checkout, "suffix.md"))).toBe("new suffix"); }); @@ -1226,81 +1061,51 @@ describe("one entry's managed Repository and Worktree selections", () => { expect(yield* exists(join(fixture.root, "sentinel.md"))).toBe(false); }); - it("E5: joins an actually running clone child and its blocked descendant", function* () { + it("E5: publishes no placement for a selection it was interrupted inside", function* () { const fixture = yield* useFixture(); const counted = counters(); - // A clone that is genuinely running when the halt arrives. The transport - // command is a script that reports it was reached and then waits, so the - // child is a real `git clone` holding a real descendant open — not a - // command that had yet to be delegated to. - // - // Git creates the destination only once the connection has succeeded, so - // this cut leaves no bytes behind. What it discriminates is the teardown: - // whether the process this entry started, and the process that one - // started, are joined before the owner finishes. The worktree cut below is - // the one that interrupts a command *after* it has written something, and - // proves the ordinary handling of what it left. - const reached = join(fixture.root, "transport-ran"); - const transport = join(fixture.root, "transport"); - yield* waitingScript(transport, reached); - const locator = "ssh://example.invalid/repo.git"; + const entered = withResolvers(); + // Held at the method the selection is performed by, so the halt lands with + // the selection started and nothing decided about it. What the native clone + // and its own descendant do between the call and its answer is proved where + // the invocation is, in `packages/git/tests/repository-replay.test.ts`. + const barrier: MethodBarrier = function* (method): Operation { + if (method !== "selectRepository") { + return; + } + entered.resolve(); + yield* suspend(); + }; const source = [ - ``, + ``, 'sentinel', ].join("\n"); - const watched = watchChildren(); const journal = new InMemoryStream(); yield* scoped(function* () { - const task = yield* spawn(() => - run(fixture, source, journal, counted, { - intercept: (invocation) => - invocation.args.includes("clone") - ? { - observe: watched.observe, - args: ["-c", `core.sshCommand=${transport}`, ...invocation.args], - } - : undefined, - }), - ); - yield* watched.spawned; - // Not merely spawned: inside its own transport, with a descendant of its - // own waiting. Halting before the delegation would interrupt a command - // that had done nothing, which is the hole this row closes. - yield* appears(reached); - // The guard against a vacuous row: a clone that had already ended would - // make everything below true without anything being interrupted. - expect(watched.closed()).toBe(false); - // Teardown kills the process group and waits for `close`, so this does - // not return until the child and its descendant are gone. + const task = yield* spawn(() => run(fixture, source, journal, counted, { barrier })); + yield* entered.operation; yield* task.halt(); }); - expect(watched.observed()).toBe(1); - expect(watched.closed()).toBe(true); - expect(watched.alive()).toBe(false); - - // No placement was published, and no body ran after it. + // The selection was entered, nothing was published for it, and the body + // after it never ran. + expect(methodsCalled(counted)).toContain("selectRepository"); expect(placements(yield* journal.readAll())).toEqual([]); expect(yield* exists(join(fixture.root, "sentinel.md"))).toBe(false); - // The isolation directory the session took is gone with the entry. - expect(counted.directories).toBe(1); - - // And nothing was published for a creation that never reached a checkout. - const slot = repositorySlot(fixture.managed, locator, "project"); - expect(yield* exists(metadataOf(slot))).toBe(false); - expect(yield* temporarySiblings(slot)).toEqual([]); }); - it("E5: joins an actually running worktree-add child and adopts what it left", function* () { + it("E5: keeps the repository placement and publishes no worktree when cut inside one", function* () { const fixture = yield* useFixture(); const counted = counters(); - // `worktree add` runs the `post-checkout` hook in the checkout it just - // made, so a hook that waits holds the real child open after it has done - // its work — which is the partial state the next attempt has to account - // for. - const checkedOut = yield* blockingHooks(fixture, "post-checkout"); - const hooks = join(fixture.root, "hooks"); + const entered = withResolvers(); + const barrier: MethodBarrier = function* (method): Operation { + if (method !== "selectWorktree") { + return; + } + entered.resolve(); + yield* suspend(); + }; const source = [ ``, '', @@ -1308,145 +1113,23 @@ describe("one entry's managed Repository and Worktree selections", () => { "", ].join("\n"); - const watched = watchChildren(); const journal = new InMemoryStream(); yield* scoped(function* () { - const task = yield* spawn(() => - run(fixture, source, journal, counted, { - intercept: (invocation) => - invocation.args.includes("worktree") - ? { - observe: watched.observe, - args: ["-c", `core.hooksPath=${hooks}`, ...invocation.args], - } - : undefined, - }), - ); - yield* watched.spawned; - // The hook runs after the checkout, so this says the worktree is on disk - // and the command is still the live owner of it. - yield* appears(checkedOut); - expect(watched.closed()).toBe(false); + const task = yield* spawn(() => run(fixture, source, journal, counted, { barrier })); + yield* entered.operation; yield* task.halt(); }); - expect(watched.observed()).toBe(1); - expect(watched.closed()).toBe(true); - expect(watched.alive()).toBe(false); + // The enclosing repository is a record because its own selection finished; + // the worktree is not, and nothing after it ran. That the interrupted + // `worktree add` joins its own hook, leaves a checkout no metadata + // describes, and is adopted rather than recreated next time is proved in + // `packages/git/tests/repository-replay.test.ts`. + expect(methodsCalled(counted)).toContain("selectWorktree"); expect(placements(yield* journal.readAll()).map((record) => record.kind)).toEqual([ "repository", ]); expect(yield* exists(join(fixture.root, "sentinel.md"))).toBe(false); - - // The worktree slot holds a checkout nothing describes yet. - const owner = yield* commonOf(fixture, "project"); - const slot = worktreeSlot(fixture.managed, owner, "experiment"); - expect(yield* exists(join(slot, "checkout"))).toBe(true); - expect(yield* exists(metadataOf(slot))).toBe(false); - - // The next attempt proves it under the ordinary stricter proof and adopts - // it, which is what keeps the bytes somebody's work rather than something - // to recreate. - const again = counters(); - const continued = yield* run(fixture, source, new InMemoryStream(), again); - expect(continued.refusal).toBe(undefined); - expect(yield* readTextFile(metadataOf(slot))).toContain('"kind": "worktree"'); - }); - - it("E5: waits for a pending metadata open and closes what it delivers", function* () { - const fixture = yield* useFixture(); - const slot = join(fixture.root, "metadata-slot"); - yield* until(mkdir(slot, { recursive: true })); - - const asked = withResolvers(); - const held = gate(); - let closed = false; - let opened = false; - // The open is started and then held, so the halt below lands in exactly the - // window this ownership discipline exists for: after the acquisition began - // and before its descriptor arrived. - const openExclusive = (path: string): Promise => { - asked.resolve(path); - return held.released - .then(() => open(path, "wx")) - .then((handle) => { - opened = true; - const close = handle.close.bind(handle); - return Object.assign(handle, { - close: (): Promise => { - closed = true; - return close(); - }, - }); - }); - }; - - let returned = false; - yield* scoped(function* (): Operation { - const writing = yield* spawn(() => writeMetadata(slot, REPOSITORY_SIDECAR, openExclusive)); - const temporary = yield* asked.operation; - const halting = yield* spawn(function* (): Operation { - yield* writing.halt(); - returned = true; - }); - yield* settled(); - // Teardown is waiting for the acquisition it armed its release before. - expect(returned).toBe(false); - expect(opened).toBe(false); - - held.release(); - yield* halting; - expect(temporary.startsWith(`${metadataOf(slot)}.`)).toBe(true); - }); - - // It waited for delivery, and closed what was delivered. - expect(returned).toBe(true); - expect(opened).toBe(true); - expect(closed).toBe(true); - // And published nothing: a sidecar is renamed into place only by a write - // that finished. - expect(yield* exists(metadataOf(slot))).toBe(false); - }); - - it("E5: waits for a pending isolation directory and removes what it delivers", function* () { - const made: string[] = []; - const asked = withResolvers(); - const held = gate(); - const create = (prefix: string): Promise => { - asked.resolve(); - return held.released - .then(() => mkdtemp(prefix)) - .then((directory) => { - made.push(directory); - return directory; - }); - }; - const host = denoRepositoryHost({ directories: { create } }); - - let returned = false; - yield* scoped(function* (): Operation { - const taking = yield* spawn(function* (): Operation { - yield* host.useDirectory(); - yield* suspend(); - }); - yield* asked.operation; - const halting = yield* spawn(function* (): Operation { - yield* taking.halt(); - returned = true; - }); - yield* settled(); - // The creation is still pending, and teardown has not finished over it. - expect(returned).toBe(false); - expect(made).toEqual([]); - - held.release(); - yield* halting; - }); - - expect(returned).toBe(true); - // It waited for the directory to arrive, and removed it. - expect(made.length).toBe(1); - expect(yield* exists(made[0] ?? "")).toBe(false); }); }); @@ -1455,18 +1138,3 @@ function* commonOf(fixture: Fixture, name: string): Operation { const slot = repositorySlot(fixture.managed, fixture.origin, name); return yield* until(realpath(join(slot, "checkout", ".git"))); } - -/** Every orphan metadata sibling this slot holds. */ -function* temporarySiblings(slot: string): Operation { - if (!(yield* exists(slot))) { - return []; - } - const entries = yield* until(readdir(slot)); - return entries.filter((entry) => entry.startsWith("metadata.json.")).sort(); -} - -/** The isolation directories this host's sessions have left in the temporary root. */ -function* isolationDirectories(): Operation { - const entries = yield* until(readdir(tmpdir())); - return entries.filter((entry) => entry.startsWith("xmd-workflow-git-")).sort(); -} diff --git a/packages/cli/tests/run-profile.test.ts b/packages/cli/tests/run-profile.test.ts index c9d5c1b07..c80203cb8 100644 --- a/packages/cli/tests/run-profile.test.ts +++ b/packages/cli/tests/run-profile.test.ts @@ -32,6 +32,7 @@ import { } from "../src/run-profile.ts"; import { installPlugins } from "../src/plugin-host.ts"; import { Plugin } from "@executablemd/core/api"; +import type { CommandPlugins } from "../src/plugin-host.ts"; import { scoped } from "effection"; import { selectPlugins } from "../src/plugin-selection.ts"; @@ -81,6 +82,30 @@ describe("the bundled run profile", () => { } }); + it("carries it for the REPL, with this command's own entry-history admission", function* () { + // The REPL runs a document a person typed, so the Git vocabulary is + // available there exactly as it is to `run` — and it arrives through this + // command's own assembly of the bundled value rather than from the bundled + // Plugin's own predicate, which declines `repl`. The names are registered + // by the contribution, as they are for every other command; what is this + // command's own is the admission that reads back what an entry wrote down + // about the checkout it was standing in. + expect(`repl: ${(yield* profile(["repl"])).join(",")}`).toBe(`repl: ${GIT}`); + + const admissions = function* (command: string): Operation { + const installed = yield* scoped(function* (): Operation { + return yield* installPlugins([BUNDLED_PLUGIN], { command, args: [command] }); + }); + return installed.installations.flatMap((one) => [...(one.admissions ?? [])]).length; + }; + // Both sides, so the third admission is specifically this command's rather + // than something every command happens to carry: `run` gets Git's two + // domain admissions, and `repl` gets those two with the entry-history one + // beside them, from inside the same contribution. + expect(`run: ${yield* admissions("run")}`).toBe("run: 2"); + expect(`repl: ${yield* admissions("repl")}`).toBe("repl: 3"); + }); + it("carries it for the workflow actions that execute a document", function* () { for (const action of ["start", "resume", "fork"]) { const args = ["workflow", action, "flow.md"]; diff --git a/packages/cli/tests/support/fixture-git.ts b/packages/cli/tests/support/fixture-git.ts new file mode 100644 index 000000000..a33b8329a --- /dev/null +++ b/packages/cli/tests/support/fixture-git.ts @@ -0,0 +1,113 @@ +/** + * One `git`, run the way a person at a shell runs it. + * + * What a fixture needs from Git is a repository arranged on disk, and the + * ordinary executable is the whole of that. These suites deliberately do not + * reach the package's own host or session to do it: those are private + * implementation on the other side of the boundary this command now owns, and + * a fixture that imported them would make the partition untrue in the one + * place that is supposed to prove it. + * + * The child belongs to the scope that asked for it. `exec()` is the process + * owner this repository already has, and this helper delegates the child's + * whole lifetime to it rather than managing one. What that delegation buys is + * the ordinary guarantee a resource owner gives: the child is released with the + * scope that asked for it instead of outliving it. A hand-built promise around + * `execFile` would stop *waiting* on cancellation while leaving the command + * running and still writing into a fixture whose scope had already gone. + * + * No control here measures that ordering; it is the owner's contract, proved + * where that owner is, and these suites rely on it the way the rest of the + * repository's fixtures do. + * + * The environment is the isolation the entry's own Git has — no global or + * system configuration, and a home of the scenario's own — with a stated + * identity, because a runner holding no `user.name` would otherwise turn a + * fixture commit into a refusal. It is layered over the caller's own because + * `env` replaces rather than extends: a command spawned without `PATH` could + * not find `git` at all. + */ + +import type { Operation } from "effection"; +import { exec } from "@effectionx/process"; +import type { ProcessResult } from "@effectionx/process"; +import process from "node:process"; + +/** What every fixture command runs with, over the caller's own environment. */ +function environment(home: string | undefined): Record { + const inherited: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined) { + inherited[name] = value; + } + } + return { + ...inherited, + ...(home === undefined ? {} : { HOME: home }), + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "Fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }; +} + +/** What a fixture may say about one command. */ +export interface FixtureGitOptions { + /** The home this command runs with, when the scenario owns one. */ + readonly home?: string; +} + +/** + * One `git`, run to completion under the asking scope. + * + * `join()` rather than `expect()`, because both callers below decide what a + * non-zero exit means for themselves — one raises it with what Git printed, + * and the other reads it as an answer. + */ +function ran( + cwd: string, + args: readonly string[], + options: FixtureGitOptions, +): Operation { + return exec("git", { + arguments: [...args], + cwd, + env: environment(options.home), + }).join(); +} + +/** + * One fixture `git` that must succeed, so a broken fixture is loud. + * + * Answers trimmed standard output, which is what every caller reads. + */ +export function* runGit( + cwd: string, + args: readonly string[], + options: FixtureGitOptions = {}, +): Operation { + const result = yield* ran(cwd, args, options); + if (result.code !== 0) { + throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +/** + * One fixture `git` whose failure is an answer rather than a fault. + * + * Any non-zero exit reads as "nothing": a ref that is not there is what most + * of these reads are asking about, and Git spells its absence differently + * depending on the question. + */ +export function* readGit( + cwd: string, + args: readonly string[], + options: FixtureGitOptions = {}, +): Operation { + const result = yield* ran(cwd, args, options); + return result.code === 0 ? result.stdout.trim() : undefined; +} diff --git a/packages/cli/tests/support/pty.ts b/packages/cli/tests/support/pty.ts index 567b59689..93d90bdeb 100644 --- a/packages/cli/tests/support/pty.ts +++ b/packages/cli/tests/support/pty.ts @@ -225,6 +225,19 @@ interface Pty { status(): Operation; } +/** + * The data root every allocated terminal runs with. + * + * Stated here because `allocate()` is what exports it into the shell below, and + * a caller that needs to find what the command wrote has to look where the + * command was told to write. On a platform that reads `XDG_DATA_HOME` this is + * that directory; on one that does not, the product's own resolver ignores it + * and answers with the platform's place under this same isolated home. + */ +export function terminalDataHome(home: string): string { + return join(home, "share"); +} + /** Whichever allocator this host can drive. */ function* allocate(line: string, home: string): Operation { // One shell line, run by whichever allocator's own `sh -c`: the data root is @@ -234,7 +247,7 @@ function* allocate(line: string, home: string): Operation { // be read off the screen. const inside = [ `export HOME=${shellQuote(home)}`, - `export XDG_DATA_HOME=${shellQuote(join(home, "share"))}`, + `export XDG_DATA_HOME=${shellQuote(terminalDataHome(home))}`, `stty rows ${WINDOW.rows} cols ${WINDOW.columns}`, line, `printf '${SENTINEL}%s\\n' "$?"`, diff --git a/packages/cli/tests/support/pull-request-server.ts b/packages/cli/tests/support/pull-request-server.ts index 0021897b7..f2eab14a3 100644 --- a/packages/cli/tests/support/pull-request-server.ts +++ b/packages/cli/tests/support/pull-request-server.ts @@ -1,11 +1,15 @@ /** * A loopback GitHub the compiled CLI can actually reach. * - * The fork evidence runs the real binary as a child process, so there is no + * The terminal evidence runs the real binary as a child process, so there is no * seam to inject a transport through: what the adapter talks to has to be a - * socket. This is that socket — the three review endpoints and the pull request - * itself, answering the shapes the adapter parses, and recording every request - * so a fork that performed one cannot report that it restored instead. + * socket. This is that socket — the pull request itself, the upsert that opens + * one, and the three evidence collections — answering the shapes the adapter + * parses and recording every request, so a run that performed one cannot report + * that it restored instead. + * + * The pull requests it holds are mutable, because a journey that opens one has + * to find it afterwards. A caller that seeds them reads the same list back. */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; @@ -24,13 +28,27 @@ export interface PullRequestServer { readonly url: string; /** Every request, in order. Cleared between forks by the caller. */ readonly requests: ServedRequest[]; + /** The pull requests this GitHub holds, including any it opened. */ + readonly pullRequests: ServedPullRequest[]; + /** The commit each branch holds, as a publication put it there. */ + readonly heads: Map; } export interface ServedPullRequest { readonly number: number; readonly headSha: string; /** The review bodies this pull request holds. */ - readonly reviews: readonly string[]; + reviews: readonly string[]; + /** The conversation comment bodies on its issue. */ + comments?: readonly string[]; + /** The check-run names for its head. */ + checks?: readonly string[]; + /** What the upsert asked for, once one has opened this pull request. */ + title?: string; + body?: string | null; + draft?: boolean; + headRef?: string; + baseRef?: string; } export interface ServerOptions { @@ -38,6 +56,16 @@ export interface ServerOptions { readonly repository?: string; readonly token?: string; readonly pullRequests?: readonly ServedPullRequest[]; + /** + * Where this GitHub reads a branch's commit when it is asked to open a pull + * request over one. + * + * The real service knows because the publication put the branch there, and it + * reads its own copy. A journey that publishes to a bare repository on disk + * supplies the same reading of that repository, so a creation names the + * commit that was actually published rather than one a caller asserted. + */ + readonly resolveHead?: (branch: string) => Operation; } function review(store: Store, pull: number, index: number, body: string) { @@ -60,8 +88,10 @@ export function usePullRequestServer(options: ServerOptions = {}): Operation(); + const resolveHead = options.resolveHead; let origin = ""; // Each request body is read by a task of this server's own scope, so @@ -72,7 +102,9 @@ export function usePullRequestServer(options: ServerOptions = {}): Operation { scope.run(function* () { - for (const _chunk of yield* each(fromReadable(incoming))) { + const chunks: Uint8Array[] = []; + for (const chunk of yield* each(fromReadable(incoming))) { + chunks.push(chunk); yield* each.next(); } @@ -81,9 +113,22 @@ export function usePullRequestServer(options: ServerOptions = {}): Operation; + readonly resolveHead?: (branch: string) => Operation; +} + +/** One call this GitHub was asked to answer. */ +interface Asked { + readonly method: string; + readonly url: URL; + readonly body: string; +} + +/** The whole payload one pull request is reported as. */ +function payload(store: Store, pull: ServedPullRequest): Record { + const home = `${store.owner}/${store.repository}`; + return { + node_id: `PR_node_${pull.number}`, + number: pull.number, + html_url: `https://github.com/${home}/pull/${pull.number}`, + state: "open", + title: pull.title ?? "Served pull request", + body: pull.body ?? null, + draft: pull.draft ?? false, + head: { ref: pull.headRef ?? "review/served", sha: pull.headSha, repo: { full_name: home } }, + base: { ref: pull.baseRef ?? "main", sha: "0".repeat(40), repo: { full_name: home } }, + user: { login: "octocat" }, + }; +} + +/** One conversation comment on this pull request's issue. */ +function comment(store: Store, pull: number, index: number, body: string) { + const home = `${store.owner}/${store.repository}`; + return { + id: pull * 1000 + index, + user: { login: "commenter" }, + body, + created_at: "2026-10-20T00:00:00Z", + updated_at: "2026-10-20T00:00:00Z", + html_url: `https://github.com/${home}/pull/${pull}#c${index}`, + // Absolute against this server's own origin, for the reason a review's is. + issue_url: `${store.origin}/repos/${home}/issues/${pull}`, + }; +} + +/** One check run for this pull request's head. */ +function checkRun(headSha: string, index: number, name: string) { + return { + id: index + 1, + head_sha: headSha, + name, + status: "completed", + conclusion: "success", + html_url: null, + started_at: "2026-10-20T00:00:00Z", + completed_at: "2026-10-20T00:01:00Z", + output: { title: name, summary: "ok", text: null }, + }; } -function respond( +function* respond( store: Store, - url: URL, + asked: Asked, authorization: string | undefined, -): { status: number; body: string } { +): Operation<{ status: number; body: string }> { if (authorization !== `Bearer ${store.token}`) { return { status: 401, body: JSON.stringify({ message: "Bad credentials" }) }; } - const prefix = `/repos/${store.owner}/${store.repository}/pulls/`; - if (!url.pathname.startsWith(prefix)) { - return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; + const home = `${store.owner}/${store.repository}`; + const pulls = `/repos/${home}/pulls`; + + // The collection itself: the observation a fresh upsert makes, and the + // creation it performs when that observation proved absence. + if (asked.url.pathname === pulls) { + return asked.method === "POST" ? yield* created(store, asked) : listed(store, asked.url); + } + // The conversation half of a comments read, which GitHub answers on the + // issue rather than on the pull request. + const issue = /^\/repos\/(.+)\/issues\/([1-9][0-9]*)\/comments$/.exec(asked.url.pathname); + if (issue !== null && issue[1] === home) { + const pull = store.served.find((entry) => String(entry.number) === issue[2]); + return pull === undefined + ? missing() + : answered( + (pull.comments ?? []).map((body, index) => comment(store, pull.number, index, body)), + ); + } + // The two halves of a checks read, both keyed by the head rather than by the + // pull request. + const checks = /^\/repos\/(.+)\/commits\/([0-9a-f]+)\/(check-runs|status)$/.exec( + asked.url.pathname, + ); + if (checks !== null && checks[1] === home) { + const head = checks[2] ?? ""; + const pull = store.served.find((entry) => entry.headSha === head); + if (checks[3] === "status") { + return answered({ sha: head, statuses: [] }); + } + const names = pull?.checks ?? []; + return answered({ + total_count: names.length, + check_runs: names.map((name, index) => checkRun(head, index, name)), + }); + } + + const prefix = `${pulls}/`; + if (!asked.url.pathname.startsWith(prefix)) { + return missing(); } - const rest = url.pathname.slice(prefix.length); + const rest = asked.url.pathname.slice(prefix.length); const [written, collection] = rest.split("/"); const pull = store.served.find((entry) => String(entry.number) === written); if (pull === undefined) { - return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; + return missing(); } if (collection === "reviews") { - return { - status: 200, - body: JSON.stringify( - pull.reviews.map((body, index) => review(store, pull.number, index, body)), - ), - }; + return answered(pull.reviews.map((body, index) => review(store, pull.number, index, body))); + } + // The inline half of a comments read. This GitHub holds none, and an empty + // page is a complete answer rather than an absent one. + if (collection === "comments") { + return answered([]); } if (collection === undefined) { + // The three members the adapter authenticates a lookup by, plus everything + // an adopted completion is read from. + return answered(payload(store, pull)); + } + return missing(); +} + +function answered(value: unknown): { status: number; body: string } { + return { status: 200, body: JSON.stringify(value) }; +} + +function missing(): { status: number; body: string } { + return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; +} + +/** The open pull requests matching the head and base an observation named. */ +function listed(store: Store, url: URL): { status: number; body: string } { + const head = url.searchParams.get("head") ?? ""; + const base = url.searchParams.get("base") ?? ""; + const matching = store.served.filter( + (entry) => + head === `${store.owner}:${entry.headRef ?? ""}` && base === (entry.baseRef ?? "main"), + ); + return answered(matching.map((entry) => payload(store, entry))); +} + +/** + * Open one pull request, exactly as it was asked for. + * + * The head commit comes from this server's own reading of the branch — put + * there by the publication that preceded the upsert — because a pull request + * reports the commit the host holds rather than one the request asserted. + */ +function* created(store: Store, asked: Asked): Operation<{ status: number; body: string }> { + const body: unknown = JSON.parse(asked.body === "" ? "{}" : asked.body); + const read = (name: string): string | undefined => { + const held = Reflect.get(Object(body), name); + return typeof held === "string" ? held : undefined; + }; + const headRef = read("head") ?? ""; + const baseRef = read("base") ?? "main"; + // The model's own heads first, then the fixture's reader. That reader suspends + // now, which is why this and `respond` are operations: the per-request task + // below is an Effection task, so a read that waits is ordinary work there. + const known = store.heads.get(headRef); + const headSha = + known ?? (store.resolveHead === undefined ? undefined : yield* store.resolveHead(headRef)); + if (headSha === undefined) { + return { status: 422, body: JSON.stringify({ message: "no such branch" }) }; + } + if (store.served.some((entry) => entry.headRef === headRef && entry.baseRef === baseRef)) { return { - status: 200, + status: 422, body: JSON.stringify({ - number: pull.number, - head: { sha: pull.headSha }, - base: { repo: { full_name: `${store.owner}/${store.repository}` } }, + message: "Validation Failed", + errors: [{ message: "already exists" }], }), }; } - return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; + const written = read("body"); + const opened: ServedPullRequest = { + number: store.served.length + 1, + headSha, + reviews: [], + comments: [], + checks: [], + title: read("title") ?? "", + body: written === undefined || written === "" ? null : written, + draft: Reflect.get(Object(body), "draft") === true, + headRef, + baseRef, + }; + store.served.push(opened); + return { status: 201, body: JSON.stringify(payload(store, opened)) }; } diff --git a/packages/git/tests/support/repl-observations.ts b/packages/cli/tests/support/repl-observations.ts similarity index 57% rename from packages/git/tests/support/repl-observations.ts rename to packages/cli/tests/support/repl-observations.ts index ee02255da..38571e129 100644 --- a/packages/git/tests/support/repl-observations.ts +++ b/packages/cli/tests/support/repl-observations.ts @@ -4,9 +4,13 @@ * The same assembly the REPL host installs: core initialization establishes the * entry's recording inside the scope the invocation owns, the Git Plugin's own * trusted admission is attached, and the components are the shipped ones. What - * a suite substitutes is only what it has to observe — the Git host, so native - * commands can be counted, barriered and watched, and the filesystem, so the - * document's own writes can be. + * a suite substitutes is only what it has to observe — the repository handle, + * so the operations an entry performs can be counted, barriered and held, and + * the filesystem, so the document's own writes can be. + * + * The handle is the published twelve-method one, which carries no session, no + * attachment and no credential. A row that needs to watch an actual `git` + * child instead belongs to the composition suite that owns the native layer. */ import { @@ -21,105 +25,128 @@ import { withResolvers, } from "effection"; import type { Operation } from "effection"; -import { exists, rm, writeTextFile } from "@effectionx/fs"; +import { rm, writeTextFile } from "@effectionx/fs"; import { mkdtemp, realpath, symlink, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import process from "node:process"; import { API, useHostFiles } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; import { Execution } from "@executablemd/core/api"; import { executeInstalled } from "@executablemd/core/host"; import { collect, Elicitation } from "@executablemd/core"; -import { denoRepositoryHost } from "../../src/deno/composition/host.ts"; -import type { - GitBytes, - GitInvocation, - GitOutcome, - RepositoryHost, -} from "../../src/deno/composition/host.ts"; -import { UNAUTHENTICATED } from "../../src/deno/composition/authentication.ts"; -import type { GitAuthenticationSession } from "../../src/deno/composition/authentication.ts"; -import { useReplRepositoryRecording } from "../../src/deno/retained-repository/provider.ts"; -import { admitReplRepositoryHistory } from "../../src/retained-repository/admission.ts"; -import { useCompositionComponents } from "../../src/composition/installation.ts"; -import type { ObservationFixture } from "./observations.ts"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import type { GitObservationRequest } from "@executablemd/git/api"; +import { useCompositionComponents } from "@executablemd/git"; +import { useReplRepositoryRecording } from "../../src/repl/repositories/deno/provider.ts"; +import { admitReplRepositoryHistory } from "../../src/repl/repositories/admission.ts"; +import type { ObservationFixture } from "../../../git/tests/support/observations.ts"; -/** What the host beneath one entry was asked to do. */ +/** What the repository handle beneath one entry was asked to do. */ export interface EntryCounters { - /** Every `git` invocation this entry ran, as its argument list. */ - commands: string[][]; - /** How many scratch directories this entry's reads acquired. */ - directories: number; - /** Every scratch directory, by path, so its removal is observable. */ - scratch: string[]; - /** Every authentication locator a session was opened for. */ - sessions: string[]; + /** Every handle method this entry called, by name, in order. */ + methods: string[]; + /** What each observation asked for, in order. */ + reads: GitObservationRequest[]; } export function entryCounters(): EntryCounters { - return { commands: [], directories: 0, scratch: [], sessions: [] }; + return { methods: [], reads: [] }; +} + +/** The handle methods one entry called, in order. */ +export function methodsCalled(counted: EntryCounters): string[] { + return [...counted.methods]; } -/** The Git subcommands one entry issued, in order. */ -export function entrySubcommands(counted: EntryCounters): string[] { - return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +/** What one entry's observations asked for, by kind, in order. */ +export function readKinds(counted: EntryCounters): string[] { + return counted.reads.map((request) => request.kind); } -/** What a row may do to one `git` invocation before the real one runs. */ -export type GitBarrier = (invocation: GitInvocation) => Operation; +/** What a row may do to one handle method before the real one runs. */ +export type MethodBarrier = (method: string) => Operation; /** - * The production host, counted, and interruptible at a named subcommand. + * The production handle, counted, and interruptible at a named method. * - * Every leaf is wrapped rather than replaced: what a row needs to know is + * Every method is wrapped rather than replaced: what a row needs to know is * whether a read ran at all, and the only honest way to answer is to let the - * real `ls-files`, the real `diff` and the real `cat-file` happen and watch. A - * barrier runs *before* the real invocation is delegated to, so a row that - * halts there halts with the native work still to come — and one that delegates - * first halts with a real child in flight. + * real observation happen and watch. A barrier runs *before* the real method is + * delegated to, so a row that halts there halts with the native work still to + * come — and one that delegates first halts with the work in flight. */ -export function countingEntryHost( +export function countingEntryOperations( counted: EntryCounters, - barrier?: GitBarrier, - intercept?: (invocation: GitInvocation) => Partial | undefined, -): RepositoryHost { - const host = denoRepositoryHost(); - const watched = (invocation: GitInvocation): GitInvocation => ({ - ...invocation, - ...(intercept?.(invocation) ?? {}), - }); - return { - *git(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); + barrier?: MethodBarrier, +): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + function* note(method: string): Operation { + counted.methods.push(method); if (barrier !== undefined) { - yield* barrier(invocation); - } - return yield* host.git(watched(invocation)); - }, - *gitBytes(invocation: GitInvocation): Operation { - counted.commands.push([...invocation.args]); - if (barrier !== undefined) { - yield* barrier(invocation); - } - if (host.gitBytes === undefined) { - throw new Error("the production host lends no byte capture"); + yield* barrier(method); } - return yield* host.gitBytes(watched(invocation)); - }, - *useDirectory(): Operation { - counted.directories += 1; - const directory = yield* host.useDirectory(); - counted.scratch.push(directory); - return directory; - }, - *useAuthentication(locator: string): Operation { - counted.sessions.push(locator); - return host.useAuthentication === undefined - ? UNAUTHENTICATED - : yield* host.useAuthentication(locator); - }, + } + const wrapped: DenoRepositoryOperations = { + *ambientCheckout(from) { + yield* note("ambientCheckout"); + return yield* operations.ambientCheckout(from); + }, + *ambientRepository(from) { + yield* note("ambientRepository"); + return yield* operations.ambientRepository(from); + }, + *selectRepository(slot, request) { + yield* note("selectRepository"); + return yield* operations.selectRepository(slot, request); + }, + *selectWorktree(slot, creation) { + yield* note("selectWorktree"); + return yield* operations.selectWorktree(slot, creation); + }, + *switch(checkout, workingDirectory, branch, base) { + yield* note("switch"); + return yield* operations.switch(checkout, workingDirectory, branch, base); + }, + *add(checkout, workingDirectory, paths) { + yield* note("add"); + return yield* operations.add(checkout, workingDirectory, paths); + }, + *commit(checkout, workingDirectory, message, messageSource, identity) { + yield* note("commit"); + return yield* operations.commit( + checkout, + workingDirectory, + message, + messageSource, + identity, + ); + }, + *currentBranch(directory) { + yield* note("currentBranch"); + return yield* operations.currentBranch(directory); + }, + *resolveCommit(directory, revision) { + yield* note("resolveCommit"); + return yield* operations.resolveCommit(directory, revision); + }, + *commitPresent(directory, commit) { + yield* note("commitPresent"); + return yield* operations.commitPresent(directory, commit); + }, + *observe(element, request, place) { + counted.reads.push(request); + yield* note("observe"); + return yield* operations.observe(element, request, place); + }, + *pushProvider(commonDirectory, origin, inputs) { + yield* note("pushProvider"); + return yield* operations.pushProvider(commonDirectory, origin, inputs); + }, + }; + return wrapped; }; } @@ -135,14 +162,12 @@ export interface EntryRunOptions { readonly cwd?: string; /** What this row installs between the host and the recording. */ readonly between?: () => Operation; - /** A barrier inside one `git` invocation. */ - readonly barrier?: GitBarrier; - /** What this row changes about one invocation before the real host runs it. */ - readonly intercept?: (invocation: GitInvocation) => Partial | undefined; + /** A barrier inside one handle method. */ + readonly barrier?: MethodBarrier; /** Whether this entry installs a managed root at all. */ readonly root?: string | undefined; - /** The host this entry runs through, when a row supplies its own. */ - readonly host?: RepositoryHost; + /** The handle this entry runs through, when a row supplies its own. */ + readonly operations?: () => Operation; } /** Where one scenario's entries are opened, and what they can reach. */ @@ -212,7 +237,7 @@ export function runEntry( yield* Execution.around({ *init(_args, next): Operation { yield* useReplRepositoryRecording({ - host: options.host ?? countingEntryHost(counted, options.barrier, options.intercept), + operations: options.operations ?? countingEntryOperations(counted, options.barrier), ...(managed === undefined ? {} : { root: managed }), }); yield* next(); @@ -284,24 +309,3 @@ export function leftEntry( return prefix; }); } - -/** Wait until this path is there, or say what never appeared. */ -export function* appears(path: string): Operation { - const deadline = Date.now() + 30_000; - while (!(yield* exists(path))) { - if (Date.now() > deadline) { - throw new Error(`${path} never appeared`); - } - yield* sleep(25); - } -} - -/** Whether this process is still running, asked of the operating system. */ -export function running(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} diff --git a/packages/cli/tests/support/repl-pull-request-journey.ts b/packages/cli/tests/support/repl-pull-request-journey.ts new file mode 100644 index 000000000..2e5bb2dc9 --- /dev/null +++ b/packages/cli/tests/support/repl-pull-request-journey.ts @@ -0,0 +1,647 @@ +/** + * The one real-terminal journey an entry's pull requests rest on. + * + * Shared by two rows that differ in exactly one thing: which `xmd` runs. The + * Deno source row proves the entry capability this repository builds; the + * compiled row proves the binary carries it. Writing the journey twice would + * let the two drift, and a compiled row asserting something slightly weaker + * than the source row is how a packaging defect survives. + * + * Every other row of this feature's evidence installs its own host. None of + * them can show what a person actually does: type an entry that publishes a + * branch, opens a pull request and reads its feedback, walk away at a question, + * let the checkout and the service both move on, and reopen the printed + * location. So this launches the production entrypoint on a pty and reads the + * answer off the screen, off the checkout, off the bare remote and off the + * service's own request log. + * + * ## The two leaves, and nothing else + * + * A bare repository on disk stands in for the remote, and a loopback listener + * stands in for GitHub. The document still names `github.com`, because that is + * what selects the adapter and what a person would write. + * + * Git is pointed at the bare repository by the shim this journey already needs + * for counting invocations: it delegates to the real Git with one + * invocation-local destination mapping. A configuration file could not say so — + * the host pins global and system configuration off, deliberately — and that + * restriction is left exactly as it is. What the entry admits and retains is + * the URL the document wrote, and real Git makes every checkout, object, commit + * and remote ref underneath it. + */ + +import { expect } from "@executablemd/test-support/expect"; +import { shellQuote } from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { scoped, sleep, until } from "effection"; +import type { Operation } from "effection"; +import { exists, readTextFile, writeTextFile } from "@effectionx/fs"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; +import { sep } from "node:path"; +import { decodeLocation } from "../../src/repl/route.ts"; +import { readRecords } from "../../src/repl/journal.ts"; +import { pairedLocation } from "../../src/repl/sidekick/history.ts"; +import { platformDataRoot } from "../../src/repl-assembly.ts"; +import { REPL_DIRECTORY } from "../../src/repl/storage.ts"; +import { + parseReplPullRequestRequestRecord, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, +} from "../../src/repl/repositories/pull-request-records.ts"; +import type { ReplPullRequestRequestRecord } from "../../src/repl/repositories/pull-request-records.ts"; +import { + GIT_HOST_EFFECT, + parseCompletePullRequestReadResult, + parseGitHostReconciliationRecord, + PULL_REQUEST, +} from "@executablemd/git"; +import type { GitHostReconciliationRecord } from "@executablemd/git"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { allocate, showing, showingAcross, terminalDataHome } from "./pty.ts"; +import type { Pty } from "./pty.ts"; +import { + ACT_MS, + ended, + git, + gitShim, + gitSaid, + holding, + logged, + managedCheckout, + printedLocation, + pushes, + reach, + REACH_MS, + remoteHolding, + remoteRef, + click, +} from "./repl-push-journey.ts"; +import type { ReplCommand } from "./repl-push-journey.ts"; +import { usePullRequestServer } from "./pull-request-server.ts"; +import type { PullRequestServer } from "./pull-request-server.ts"; + +/** The repository the document names, and the one the modeled service holds. */ +const OWNER = "octo"; +const REPOSITORY = "project"; +const LOCATOR = `https://github.com/${OWNER}/${REPOSITORY}`; + +/** The branch this journey publishes, which is not the repository's primary one. */ +const BRANCH = "review/pull-request"; + +const TITLE = "Prepare the release"; +const BODY = "Ready for review."; + +/** The credential this journey's operator configured. A journal may never hold it. */ +const TOKEN = "pull-request-server-credential"; + +/** + * The question, which renders everything this entry has learned so far. + * + * Every value in it is a binding: the pull request's own three facts and the + * length of each evidence array. That is what makes the reopened dialog + * evidence of *restoration* — the text is evaluated again when the entry is + * projected, so a reopening that had lost a binding could not print it, and one + * that resolved it afresh would print what the service holds now instead of + * what this entry recorded. + */ +const QUESTION = + 'Opened {pullRequest.number} at {pullRequest.url} is ' + + "{pullRequest.state} with {reviews.length}/{comments.length}/{checks.length} " + + "feedback. Continue this experiment?"; + +const ASKED = `Opened 1 at ${LOCATOR}/pull/1 is open with 0/0/0 feedback.`; + +/** + * The entry this journey types, as the specification shows it. + * + * A managed Repository, a Worktree whose captured path a `` renders at, a + * commit inside that linked checkout, the publication, the pull request over + * it, and its three evidence collections. Then the question, the write after + * it, and one explicitly authored later read — which is a new occurrence and + * therefore new work, however much of what precedes it is history. + */ +function entry(): string { + return [ + ``, + ``, + "", + 'ready', + '', + '', + "", + ``, + BODY, + "", + '', + '', + '', + // Written down rather than rendered: the Transcript scrolls a long source + // out of view, and what a binding held is read back from the file it was + // written into whatever the screen happens to be showing. + '{pullRequest.number} {pullRequest.url} {pullRequest.state}', + '{reviews.length} {comments.length} {checks.length}', + QUESTION, + 'continued', + '', + '{fresh.length}', + "", + "", + ].join("\n"); +} + +/** + * The entry history the printed location addresses, as the command stores it. + * + * Read through the command's own recipes rather than by guessing at a path: + * `decodeLocation` says which execution the location names, the per-user data + * root and `pairedLocation` say where that execution's two files live, and + * `readRecords` is the same reader the command opens a retained history with. + * Nothing here adds a storage interface or a second parser. + */ +function* history(home: string, location: string): Operation { + const route = decodeLocation(location); + if (!route.ok) { + throw route.error; + } + // The environment the terminal was given, not this process's own. A platform + // that reads `XDG_DATA_HOME` resolves the command's root from it, and the + // harness exports it into every allocated shell; passing the parent's + // environment instead would look under the default place while the command + // wrote under the isolated one. A platform that ignores it answers the same + // either way. + const root = join( + platformDataRoot(process.platform, home, { XDG_DATA_HOME: terminalDataHome(home) }, sep), + ...REPL_DIRECTORY, + ); + return yield* readRecords(pairedLocation(root, route.value.execution).entries); +} + +function held(events: readonly DurableEvent[], type: string): DurableEvent[] { + return events.filter((event) => event.type === "yield" && event.description.type === type); +} + +/** + * What each retained evidence read holds, by collection. + * + * Read back through the delivered parser, so a snapshot that is not complete + * contributes nothing rather than being counted as one. + */ +function snapshots( + events: readonly DurableEvent[], + read: string, +): { kind: string; items: number }[] { + const found: { kind: string; items: number }[] = []; + for (const event of held(events, read)) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + for (const kind of ["reviews", "comments", "checks"] as const) { + const parsed = parseCompletePullRequestReadResult(event.result.value, kind); + if (parsed !== undefined) { + found.push({ kind, items: parsed.items.length }); + } + } + } + return found; +} + +/** + * The Git-host outcomes of one kind this history holds, parsed. + * + * Selected by the kind the reconciled request names, because publication and + * the pull request over it are both `git_host_effect` records: counting the + * type alone would mix the two. + */ +function reconciled(events: readonly DurableEvent[], kind: string): GitHostReconciliationRecord[] { + const found: GitHostReconciliationRecord[] = []; + for (const event of held(events, GIT_HOST_EFFECT)) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + const record = parseGitHostReconciliationRecord(event.result.value); + if (record?.request.kind === kind) { + found.push(record); + } + } + return found; +} + +function asked(events: readonly DurableEvent[]): ReplPullRequestRequestRecord[] { + const found: ReplPullRequestRequestRecord[] = []; + for (const event of held(events, REPL_REPOSITORY_PULL_REQUEST_REQUEST)) { + if (event.type === "yield" && event.result.status === "ok") { + const record = parseReplPullRequestRequestRecord(event.result.value); + if (record !== undefined) { + found.push(record); + } + } + } + return found; +} + +/** + * Run the whole journey, and answer with nothing. + * + * Takes the command rather than building one, so the only difference between + * the two rows that call this is which `xmd` they name. + */ +export function replPullRequestJourney(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + // Loaded here rather than at the top of this module, because this file is + // also on the import graph of `replUnsupportedPullRequest` below. The Deno + // entrypoint reaches Workflow's storage adapter and so `node:sqlite`, which + // Bun does not have; a static import is evaluated before either journey is + // chosen, so it would turn "this runtime installs no entry provider" from + // something that suite observes into a module it cannot even load. + const { PULL_REQUEST_READ: read } = yield* until(import("@executablemd/git/deno")); + const home = yield* useTempDirectory("xmd-repl-pull-request-pty-"); + // Named before it exists, because the shim's mapping is what makes the + // document's locator reach it. + const origin = join(home, "origin.git"); + const shim = yield* gitShim(home, { map: { from: LOCATOR, to: origin } }); + const service = yield* usePullRequestServer({ + owner: OWNER, + repository: REPOSITORY, + token: TOKEN, + // This GitHub reads the branch out of the bare repository the + // publication actually pushed to, which is how the real one knows. + resolveHead: (branch) => bareHead(origin, branch), + }); + yield* seedOrigin(home, origin); + const away = join(home, "away"); + yield* until(mkdir(away, { recursive: true })); + const open = (args: readonly string[]): string => + `cd ${shellQuote(away)} && ${shim.prefix} ${configured(service)} ${repl(args)}`; + + const first = yield* allocate(open(["repl", "--deny-all"]), home); + yield* showing(first, "[exit]", REACH_MS); + yield* first.type(entry()); + yield* sleep(500); + yield* first.type("\r"); + + // Waited for first, so a prefix that refused is reported as the sentence on + // the screen rather than as an absent managed checkout. What Git was asked + // travels with that report, because the sentence alone cannot say which + // locator a clone actually attempted. + yield* reached(first, "waiting for an answer", shim.log, service); + const working = yield* managedCheckout(home, "worktrees"); + + // The prefix ran: the file is there, the commit was made, and the branch + // reached the remote before anything was asked of the service. + yield* holding(join(working, "notes.md"), "ready"); + const published = yield* gitSaid(working, ["rev-parse", "HEAD"], home); + expect(published).toMatch(/^[0-9a-f]{40}$/); + yield* remoteHolding(origin, BRANCH, published, home); + + // The pull request opened once, over the branch this entry published, and + // its three collections each bound their own array. These two files are + // *first-run* evidence: they were written here, and their surviving bytes + // afterwards prove only that nothing rewrote them. What a reopening + // restored is read off the re-evaluated dialog instead. + yield* holding(join(working, "opened.md"), `1 ${LOCATOR}/pull/1 open`); + yield* holding(join(working, "evidence.md"), "0 0 0"); + expect(creations(service)).toBe(1); + const opened = service.pullRequests.find((held) => held.number === 1); + expect(opened?.title).toBe(TITLE); + expect(opened?.headRef).toBe(BRANCH); + expect(opened?.headSha).toBe(published); + expect(opened?.baseRef).toBe("main"); + // The credential reached the service and nothing reached it unauthorized. + expect(service.requests.every((asked) => asked.authorization === `Bearer ${TOKEN}`)).toBe(true); + expect(collections(service)).toEqual(["reviews", "comments", "checks"]); + // Neither the suffix write nor the later read has happened. + expect(yield* exists(join(working, "after.md"))).toBe(false); + expect(yield* exists(join(working, "fresh.md"))).toBe(false); + expect(reviewReads(service)).toBe(1); + + yield* reach(first, "[exit]"); + yield* first.type("\r"); + expect(yield* ended(first)).toBe(0); + const location = printedLocation((yield* first.shows()).join("\n")); + // The screen never showed the credential. + expect((yield* first.shows()).join("\n")).not.toContain(TOKEN); + + // And this is what it wrote down. One saved pull-request request, naming + // the branch and head this entry published; one successful Git-host + // outcome for it; and one complete snapshot for each of the three + // collections. Read out of the file the printed location addresses. + const left = yield* history(home, location); + const leftAsked = asked(left); + expect(leftAsked).toHaveLength(1); + expect(leftAsked[0]?.resolved.inputs.headBranch).toBe(BRANCH); + expect(leftAsked[0]?.resolved.inputs.headSha).toBe(published); + expect(leftAsked[0]?.resolved.inputs.title).toBe(TITLE); + // The body is the verbatim child content, which is what a resumed update + // has to present again rather than re-derive. + expect(leftAsked[0]?.resolved.inputs.body).toContain(BODY); + // Asking for one to exist, not for a number. + expect(leftAsked[0]?.resolved.inputs.number).toBe(null); + // One pull-request outcome, and it performed. The publication beside it is + // its own reconciliation and is not counted here. + const leftOutcomes = reconciled(left, PULL_REQUEST); + expect(leftOutcomes).toHaveLength(1); + expect(leftOutcomes[0]?.decision).toBe("performed"); + expect([...snapshots(left, read)].sort((a, b) => a.kind.localeCompare(b.kind))).toEqual([ + { kind: "checks", items: 0 }, + { kind: "comments", items: 0 }, + { kind: "reviews", items: 0 }, + ]); + // The credential is in none of it. + expect(JSON.stringify(left)).not.toContain(TOKEN); + + // Both sides move on while the person is away. The checkout advances, and + // the service gains feedback this entry has never seen. + yield* writeTextFile(join(working, "later.md"), "later\n"); + yield* gitSaid(working, ["add", "later.md"], home); + yield* gitSaid(working, ["commit", "-m", "theirs"], home); + const advanced = yield* gitSaid(working, ["rev-parse", "HEAD"], home); + expect(advanced).not.toBe(published); + if (opened !== undefined) { + opened.reviews = ["ship it", "one note"]; + opened.comments = ["thanks"]; + opened.checks = ["build"]; + } + + // Both logs forgotten here, so what they hold afterwards is what + // *reopening* did. + yield* writeTextFile(shim.log, ""); + service.requests.length = 0; + + const second = yield* allocate(open(["repl", "--deny-all", location]), home); + yield* showing(second, "[exit]", REACH_MS); + yield* showing(second, "waiting for an answer", ACT_MS); + + // And the two first-run files are still exactly as that run left them: + // these bytes say nothing was rewritten, which is a different claim from + // the restored bindings above. + yield* holding(join(working, "opened.md"), `1 ${LOCATOR}/pull/1 open`); + yield* holding(join(working, "evidence.md"), "0 0 0"); + + // The selected records are the same ones, neither changed nor written + // again. Compared by selection rather than by whole-file bytes, because a + // reopening legitimately appends to the history as it projects. + const reopened = yield* history(home, location); + expect(asked(reopened)).toEqual(leftAsked); + expect(reconciled(reopened, PULL_REQUEST)).toEqual(leftOutcomes); + expect([...snapshots(reopened, read)].sort((a, b) => a.kind.localeCompare(b.kind))).toEqual([ + { kind: "checks", items: 0 }, + { kind: "comments", items: 0 }, + { kind: "reviews", items: 0 }, + ]); + expect(service.requests).toEqual([]); + expect(yield* logged(shim.log)).toEqual([]); + expect(creations(service)).toBe(0); + expect(yield* remoteRef(origin, BRANCH, home)).toBe(published); + expect(yield* gitSaid(working, ["rev-parse", "HEAD"], home)).toBe(advanced); + expect(yield* exists(join(working, "after.md"))).toBe(false); + + yield* reach(second, "[answer]"); + yield* second.type("\r"); + // Everything acknowledged answers historically, and the proof is the + // question itself: its text is evaluated again as this entry is projected, + // so the number, the URL, the state and all three counts on this screen are + // restored bindings. The service now holds two reviews, one comment and one + // check, so a reopening that resolved any of it again would print `2/1/1` + // here instead. Read before the answer is given. + // + // Only here, and not on the first run: opening this drawer changes which + // controls the footer offers, and the first run has to walk to `[exit]` + // afterwards. What the first run resolved is already established by + // opened.md and evidence.md, written from the same bindings. + yield* showingAcross(second, ASKED, ACT_MS); + yield* click(second, "( ) continue"); + yield* showing(second, "[ok] entry-1", ACT_MS); + + // The suffix executed for the first time on the answer: the write landed, + // and the read written after the question is a new occurrence, so it + // reaches the service and sees what is there now. + yield* holding(join(working, "after.md"), "continued"); + // The read written after the question saw what is there now. + yield* holding(join(working, "fresh.md"), "2"); + expect(reviewReads(service)).toBe(1); + // And answering published nothing and opened nothing further. + expect(yield* pushes(shim.log)).toEqual([]); + expect(creations(service)).toBe(0); + expect(yield* remoteRef(origin, BRANCH, home)).toBe(published); + // And the historical arrays are still the historical ones. + yield* holding(join(working, "evidence.md"), "0 0 0"); + + // The history now holds one more reviews snapshot — the fresh occurrence, + // which saw the two reviews the service gained — beside the three the first + // run retained. Nothing opened a second pull request, and the saved request + // is still the one request. + const answered = yield* history(home, location); + expect(asked(answered)).toEqual(leftAsked); + expect(reconciled(answered, PULL_REQUEST)).toEqual(leftOutcomes); + expect([...snapshots(answered, read)].sort((a, b) => a.kind.localeCompare(b.kind))).toEqual([ + { kind: "checks", items: 0 }, + { kind: "comments", items: 0 }, + { kind: "reviews", items: 0 }, + { kind: "reviews", items: 2 }, + ]); + expect(JSON.stringify(answered)).not.toContain(TOKEN); + + yield* reach(second, "[exit]"); + yield* second.type("\r"); + expect(yield* ended(second)).toBe(0); + }); +} + +/** + * What a pull request does on a runtime with no entry capability. + * + * Node and Bun assemble none — the Deno adapter's module graph reaches + * `node:sqlite`, which Bun has not got — so `` and its evidence + * reads there say no provider is installed rather than reaching anything, and + * say it before a credential is read or a managed root is made. + */ +export function replUnsupportedPullRequest(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-pull-request-pty-"); + const origin = join(home, "origin.git"); + const shim = yield* gitShim(home, { map: { from: LOCATOR, to: origin } }); + const service = yield* usePullRequestServer({ + owner: OWNER, + repository: REPOSITORY, + token: TOKEN, + pullRequests: [{ number: 1, headSha: "a".repeat(40), reviews: ["ship it"] }], + }); + yield* seedOrigin(home, origin); + yield* writeTextFile(shim.log, ""); + + const pty = yield* allocate( + `cd ${shellQuote(home)} && ${shim.prefix} ${configured(service)} ${repl(["repl", "--deny-all"])}`, + home, + ); + yield* showing(pty, "[exit]", REACH_MS); + yield* pty.type( + [ + "", + ``, + ``, + BODY, + "", + "", + "", + "", + ``, + "", + 'continued', + ].join("\n"), + ); + yield* sleep(500); + yield* pty.type("\r"); + + // Both refusals are sentences, wrapped where the Transcript's own width + // cuts them, so they are read across those rows. + yield* showingAcross(pty, "composition provider is installed", ACT_MS); + yield* showingAcross(pty, "no pull-request provider", ACT_MS); + yield* holding(join(home, "after.md"), "continued"); + // Nothing was contacted, no credential was read, and no managed root was + // made: the refusal is upstream of all three. + expect(service.requests).toEqual([]); + expect(yield* logged(shim.log)).toEqual([]); + expect(yield* exists(join(home, ".xmd", "repositories"))).toBe(false); + + yield* reach(pty, "[exit]"); + yield* pty.type("\r"); + expect(yield* ended(pty)).toBe(0); + }); +} + +/** + * Wait for the question, reporting what Git was asked if it never arrives. + * + * A refusal prints one sentence, and the sentence for a transport that could + * not authenticate looks the same whether the locator was unreachable or the + * credential was missing. The invocations the shim saw distinguish them. + */ +function* reached( + pty: Pty, + text: string, + log: string, + service: PullRequestServer, +): Operation { + try { + yield* showing(pty, text, ACT_MS); + } catch (error) { + throw yield* described(error, log, service); + } +} + +/** What was asked of Git and of the service, beside whatever went wrong. */ +function* described(error: unknown, log: string, service: PullRequestServer): Operation { + const asked = yield* logged(log); + return new Error( + `${error instanceof Error ? error.message : String(error)}\ngit=${JSON.stringify( + asked.map((line) => line.split(" ").slice(-6).join(" ")), + )}\nservice=${JSON.stringify(service.requests)}`, + ); +} + +/** + * The commit a branch holds in a bare repository, as Git wrote it. + * + * The loose ref first, then the packed table. Both reads suspend: the service + * answers each request inside an Effection task of its own, so there is no + * synchronous correctness requirement here — only a value the response needs + * before it is written. + * + * A branch that is in neither place answers `undefined`, which is how the + * service reports a head it has never seen. + */ +function* bareHead(origin: string, branch: string): Operation { + const ref = `refs/heads/${branch}`; + const loose = join(origin, ref); + if (yield* exists(loose)) { + return (yield* readTextFile(loose)).trim(); + } + const packed = join(origin, "packed-refs"); + if (!(yield* exists(packed))) { + // No packed table either, so this branch is not there. + return undefined; + } + for (const line of (yield* readTextFile(packed)).split("\n")) { + const [commit, named] = line.split(" "); + if (named === ref && commit !== undefined) { + return commit; + } + } + return undefined; +} + +/** The two variables an operator sets to point this host at that service. */ +function configured(service: PullRequestServer): string { + const stated = JSON.stringify({ allowed: [LOCATOR], endpoint: service.url }); + return `GH_TOKEN=${shellQuote(TOKEN)} XMD_WORKFLOW_GITHUB_PULL_REQUESTS=${shellQuote(stated)}`; +} + +/** How many pull requests this service was asked to open. */ +function creations(service: PullRequestServer): number { + return service.requests.filter( + (asked) => asked.method === "POST" && asked.path.endsWith("/pulls"), + ).length; +} + +/** How many times it was asked for a pull request's reviews. */ +function reviewReads(service: PullRequestServer): number { + return service.requests.filter((asked) => asked.path.endsWith("/reviews")).length; +} + +/** Which evidence collections it was asked for, in order, once each. */ +function collections(service: PullRequestServer): string[] { + const seen: string[] = []; + for (const asked of service.requests) { + const found = /\/(reviews|comments|check-runs)$/.exec(asked.path); + const named = found?.[1] === "check-runs" ? "checks" : found?.[1]; + if (named !== undefined && !seen.includes(named)) { + seen.push(named); + } + } + return seen; +} + +/** + * A bare repository with one commit, and the credential its locator needs. + * + * An `https://` locator carries an identity, so this host asks Git for a + * credential for it before it clones. The helper below is the operator's own + * credential machinery answering — the same `git credential fill` a person's + * keychain answers — with a synthetic value that depends on no account. The + * local transport the shim maps to never uses what it hands back. + * + * What it answers with is bound before it is written into the line, the same + * way `packages/git/tests/support/credential-home.ts` binds it. Spelled + * inline, the line reads to a scanner meeting this file as a credential-named + * field with a long value — which it is not, and which refuses this + * repository's own review of it. + * + * No URL rewrite is written here. The product reads neither global nor system + * configuration, by design, so a rewrite in this file would do nothing; the + * mapping lives in the shim's delegation instead. + */ +function* seedOrigin(home: string, origin: string): Operation { + const seed = join(home, "seed"); + const proof = "journey-git-credential"; + yield* until(mkdir(seed, { recursive: true })); + yield* writeTextFile( + join(home, ".gitconfig"), + [ + "[user]", + "\tname = Journey Author", + "\temail = journey-author@example.test", + "[credential]", + `\thelper = "!f() { echo username=journey; echo password=${proof}; }; f"`, + "", + ].join("\n"), + ); + yield* git(home, ["init", "--bare", "-b", "main", origin]); + yield* git(home, ["init", "-b", "main", seed]); + yield* writeTextFile(join(seed, "README.md"), "origin\n"); + yield* git(seed, ["add", "README.md"], home); + yield* git(seed, ["commit", "-m", "first"], home); + yield* git(seed, ["push", origin, "main:refs/heads/main"], home); +} diff --git a/packages/cli/tests/support/repl-push-journey.ts b/packages/cli/tests/support/repl-push-journey.ts index ecd966a52..1f1ff417b 100644 --- a/packages/cli/tests/support/repl-push-journey.ts +++ b/packages/cli/tests/support/repl-push-journey.ts @@ -39,8 +39,8 @@ import type { Pty } from "./pty.ts"; * Reaching the screen compiles or loads an entrypoint, which is slow and * uninteresting. Everything after that is the subject. */ -const REACH_MS = 180_000; -const ACT_MS = 60_000; +export const REACH_MS = 180_000; +export const ACT_MS = 60_000; /** How this row's `xmd` is invoked, as one shell command. */ export type ReplCommand = (args: readonly string[]) => string; @@ -250,7 +250,7 @@ export function replUnsupportedPush(repl: ReplCommand): Operation { } /** A bare repository with one commit on its primary branch. */ -function* seedBareOrigin(home: string): Operation { +export function* seedBareOrigin(home: string): Operation { const origin = join(home, "origin.git"); const seed = join(home, "seed"); yield* until(mkdir(seed, { recursive: true })); @@ -264,7 +264,7 @@ function* seedBareOrigin(home: string): Operation { } /** The commit a ref on the bare remote holds, or the empty string for absence. */ -function* remoteRef(origin: string, branch: string, home: string): Operation { +export function* remoteRef(origin: string, branch: string, home: string): Operation { const shown = yield* gitSaid(origin, ["for-each-ref", "--format=%(refname)|%(objectname)"], home); for (const line of shown.split("\n")) { const [name, commit] = line.split("|"); @@ -276,7 +276,7 @@ function* remoteRef(origin: string, branch: string, home: string): Operation { +export function* pushes(log: string): Operation { return (yield* logged(log)).filter((line) => line.split(" ").includes("push")); } +/** Where a journey's logical locator actually is, for this run only. */ +export interface GitFixtureMapping { + /** The locator the document writes, which is also what the entry admits. */ + readonly from: string; + /** The absolute bare repository on disk that stands in for it. */ + readonly to: string; +} + +export interface GitShimOptions { + /** + * One destination mapping, applied to every delegation. + * + * Absent — which is what the publication journey uses — forwards the argument + * vector unchanged. + */ + readonly map?: GitFixtureMapping; +} + /** * A `git` on `PATH` that logs every invocation and forwards to the real one. * @@ -311,8 +329,28 @@ function* pushes(log: string): Operation { * suite can substitute. Only the `xmd` invocation gets it on `PATH`; this * journey's own fixture commands keep the host's own Git, so the log holds what * the document did and nothing this harness did. + * + * ## The optional destination mapping + * + * A journey whose document names a service by its real name — which is what + * selects a Git-host adapter — still has to reach a repository on this disk. A + * configuration *file* cannot say so: the host pins `GIT_CONFIG_GLOBAL`, + * `GIT_CONFIG_SYSTEM` and `GIT_CONFIG_NOSYSTEM` precisely so a checkout or a + * person cannot name a program for Git to run. Git's invocation-local `-c` + * route is open, and it is the route the host itself uses, so a mapping belongs + * here at the executable leaf where the rest of the substitution already lives. + * + * With a mapping, every delegation carries the rewrite and a file-only protocol + * restriction. The restriction is what makes a missed mapping a refusal rather + * than a real request to the real service. Nothing about it reaches a product + * module, an operator's configuration or a journal: real Git still makes the + * checkout, the objects, the commits and the remote refs, and what is retained + * is still the locator the document wrote. */ -function* gitShim(home: string): Operation<{ readonly prefix: string; readonly log: string }> { +export function* gitShim( + home: string, + options: GitShimOptions = {}, +): Operation<{ readonly prefix: string; readonly log: string }> { const real = yield* until( new Promise((resolve, reject) => { execFile("sh", ["-c", "command -v git"], (error, stdout) => @@ -327,9 +365,27 @@ function* gitShim(home: string): Operation<{ readonly prefix: string; readonly l yield* until(mkdir(directory, { recursive: true })); const log = join(home, "git-commands.log"); const shim = join(directory, "git"); + // Separately quoted words, before the original vector: Git reads every `-c` + // ahead of the subcommand, and the vector itself is passed through untouched + // so the delegation's own exit status is the one this shim exits with. + const mapped = + options.map === undefined + ? "" + : ` ${[ + "-c", + `url.${options.map.to}.insteadOf=${options.map.from}`, + "-c", + "protocol.allow=never", + "-c", + "protocol.file.allow=always", + ] + .map((word) => shellQuote(word)) + .join(" ")}`; yield* writeTextFile( shim, - `#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(log)}\nexec ${shellQuote(real)} "$@"\n`, + // Logged before the delegation, so a reopening that ran a command which + // then failed is still observed as having run one. + `#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(log)}\nexec ${shellQuote(real)}${mapped} "$@"\n`, ); yield* until(chmod(shim, 0o755)); yield* writeTextFile(log, ""); @@ -337,7 +393,7 @@ function* gitShim(home: string): Operation<{ readonly prefix: string; readonly l } /** Every `git` the shim has seen since it was last forgotten. */ -function* logged(log: string): Operation { +export function* logged(log: string): Operation { const held = (yield* exists(log)) ? yield* readTextFile(log) : ""; return held.split("\n").filter((line) => line !== ""); } @@ -351,7 +407,7 @@ function* seedIdentity(home: string): Operation { } /** One `git` command, in this journey's own isolated home. */ -function git(cwd: string, args: readonly string[], home = cwd): Operation { +export function git(cwd: string, args: readonly string[], home = cwd): Operation { return until( new Promise((resolve, reject) => { execFile("git", [...args], { cwd, env: environment(home) }, (error) => @@ -362,7 +418,7 @@ function git(cwd: string, args: readonly string[], home = cwd): Operation } /** What one `git` command printed, for a fixture that reads a repository. */ -function gitSaid(cwd: string, args: readonly string[], home: string): Operation { +export function gitSaid(cwd: string, args: readonly string[], home: string): Operation { return until( new Promise((resolve, reject) => { execFile("git", [...args], { cwd, env: environment(home) }, (error, stdout) => @@ -372,7 +428,7 @@ function gitSaid(cwd: string, args: readonly string[], home: string): Operation< ); } -function environment(home: string): Record { +export function environment(home: string): Record { return { ...process.env, HOME: home, @@ -392,7 +448,10 @@ function environment(home: string): Record { * `~/.xmd/repositories` is from the home it was given — so looking for the one * checkout beneath that layout is what asks the question the person asks. */ -function* managedCheckout(home: string, kind: "repositories" | "worktrees"): Operation { +export function* managedCheckout( + home: string, + kind: "repositories" | "worktrees", +): Operation { const base = join(home, ".xmd", "repositories", kind); const depth = kind === "repositories" ? 1 : 2; const deadline = Date.now() + ACT_MS; @@ -429,7 +488,7 @@ function* slotsBeneath(base: string, depth: number): Operation { } /** Walk the focus ring until this control is the marked one. */ -function* reach(pty: Pty, label: string): Operation { +export function* reach(pty: Pty, label: string): Operation { const deadline = Date.now() + ACT_MS; while (!(yield* pty.shows()).some((line) => line.includes(`> ${label}`))) { if (Date.now() > deadline) { @@ -444,7 +503,7 @@ function* reach(pty: Pty, label: string): Operation { } /** Activate the control holding this text with a pointer. */ -function* click(pty: Pty, text: string): Operation { +export function* click(pty: Pty, text: string): Operation { const screen = yield* pty.shows(); const row = screen.findIndex((line) => line.includes(text)); if (row < 0) { @@ -460,7 +519,7 @@ function* click(pty: Pty, text: string): Operation { } /** Wait until this file holds this exact text. */ -function* holding(path: string, content: string): Operation { +export function* holding(path: string, content: string): Operation { const deadline = Date.now() + ACT_MS; while (true) { if ((yield* exists(path)) && (yield* readTextFile(path)) === content) { @@ -477,7 +536,7 @@ function* holding(path: string, content: string): Operation { } /** Wait until this command finished, and answer with its exit status. */ -function* ended(pty: Pty): Operation { +export function* ended(pty: Pty): Operation { const deadline = Date.now() + ACT_MS; while (true) { const status = yield* pty.status(); @@ -495,7 +554,7 @@ function* ended(pty: Pty): Operation { } /** The one location this command printed for a caller to come back with. */ -function printedLocation(screen: string): string { +export function printedLocation(screen: string): string { const found = screen.match(/xmd repl '(xmd:\/\/repl\/[^']+)'/); if (found === null || found[1] === undefined) { throw new Error( diff --git a/packages/git/api.ts b/packages/git/api.ts index 27fb093e7..d14463795 100644 --- a/packages/git/api.ts +++ b/packages/git/api.ts @@ -182,3 +182,18 @@ export type { GitHostCompletion, GitHostObservation, } from "./src/git-host/records.ts"; + +export { GIT_API } from "./src/composition/git-api.ts"; +export { PULL_REQUEST_OPERATIONS } from "./src/composition/pull-request-operations.ts"; +export type { + PullRequestOperationsApi, + PullRequestReadInvocation, + PullRequestUpsertInvocation, +} from "./src/composition/pull-request-operations.ts"; +export { reconcileOwnedGitHostEffect } from "./src/git-host/effect.ts"; +export { IssueOperations } from "./src/issue/operations.ts"; +export type { + IssueOperationsApi, + IssueReadInvocation, + IssueUpsertInvocation, +} from "./src/issue/operations.ts"; diff --git a/packages/git/credential-helper.ts b/packages/git/credential-helper.ts index 9c3e38620..9a6458da0 100644 --- a/packages/git/credential-helper.ts +++ b/packages/git/credential-helper.ts @@ -10,3 +10,5 @@ */ export * from "./src/deno/composition/credential-helper.ts"; + +export type { HelperAssembly } from "./src/deno/composition/credential-helper.ts"; diff --git a/packages/git/deno.ts b/packages/git/deno.ts index 562e85940..c532ced46 100644 --- a/packages/git/deno.ts +++ b/packages/git/deno.ts @@ -48,26 +48,50 @@ export { gitWorkspaceAttachment } from "./src/deno/attachment.ts"; export type { GitWorkspaceOptions } from "./src/deno/attachment.ts"; export { useRunComposition } from "./src/deno/run-composition/provider.ts"; export type { RunCompositionOptions } from "./src/deno/run-composition/provider.ts"; -export { useReplRepositoryRecording } from "./src/deno/retained-repository/provider.ts"; -export type { ReplRecordingOptions } from "./src/deno/retained-repository/provider.ts"; -/** - * One REPL entry's repository capability, as the per-entry factory the CLI - * calls inside that entry's own task. - * - * The factory alone. What it establishes — the recorder, the selection - * registry, the registered checkouts, the managed slots, the native turn and - * the publication's own request records — stays inside the closure it delivers - * to: a package that could reach one of those could publish a branch under an - * entry it does not own, or read back the receipt of one. - */ -export { replRepositoryInstallation } from "./src/deno/retained-repository/provider.ts"; -/** - * One REPL entry's issue capability, as the inert value the CLI selects. - * - * The installation alone. What it builds — the common `` declaration, - * the policy behind it, the admission over its history and the - * transport-facing default inside a live attempt — stays inside it: a package - * that could reach one of those could file an issue under an entry it does not - * own. - */ -export { replIssueInstallation } from "./src/deno/retained-repository/issues.ts"; + +export { gitCommitMessageEvidence } from "./src/deno/composition/commit.ts"; +export { GitRefusal } from "./src/deno/composition/git.ts"; +export type { GitCommitIdentity } from "./src/deno/composition/host.ts"; +export { locatorFingerprint } from "./src/deno/composition/locator.ts"; +export type { ObservationPlace } from "./src/deno/composition/observations.ts"; +export { gitOperationFingerprint } from "./src/deno/composition/operations.ts"; +export type { AdmittedReadExecution } from "./src/deno/composition/pull-request-reads.ts"; +export { denoPullRequestOperations } from "./src/deno/composition/pull-request-host.ts"; +export type { DenoPullRequestOperations } from "./src/deno/composition/pull-request-host.ts"; +export { + PULL_REQUEST_READ, + retainPullRequestRead, +} from "./src/deno/composition/pull-request-read-retention.ts"; +export { + CompositionRefusal, + gitRefusal, + refusalReason, + repositoryRefusal, + restoredGitRefusal, + worktreeRefusal, +} from "./src/deno/composition/refusals.ts"; +export { useDenoGitHubIssues } from "./src/deno/issue/github-host.ts"; +export { useDenoRepositoryOperations } from "./src/deno/repository-operations.ts"; +export type { DenoRepositoryOperations } from "./src/deno/repository-operations.ts"; +export type { AmbientRepository } from "./src/deno/run-composition/ambient.ts"; +export { + checkoutOf, + commonDirectoryLock, + repositorySlot, + worktreeSlot, +} from "./src/deno/run-composition/checkout-layout.ts"; +export { + ManagedCheckoutError, + NoAmbientRepositoryError, + UnresolvedGitIdentityError, +} from "./src/deno/run-composition/errors.ts"; +export type { ManagedCheckoutReason } from "./src/deno/run-composition/errors.ts"; +export { captureCommitIdentity, denoIdentityReader } from "./src/deno/run-composition/identity.ts"; +export type { IdentityReader } from "./src/deno/run-composition/identity.ts"; +export { lazily } from "./src/deno/run-composition/lazy.ts"; +export { useLeases } from "./src/deno/run-composition/leases.ts"; +export type { Leases, SlotKind } from "./src/deno/run-composition/leases.ts"; +export { selectCheckout } from "./src/deno/run-composition/operations.ts"; +export type { RegisteredCheckout } from "./src/deno/run-composition/operations.ts"; +export { selectionRegistry } from "./src/deno/selections.ts"; +export type { SelectionRegistry } from "./src/deno/selections.ts"; diff --git a/packages/git/mod.ts b/packages/git/mod.ts index d709ab2b4..59e510b8f 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -66,7 +66,7 @@ */ export { default } from "./src/plugin.ts"; -export { gitPlugin } from "./src/plugin.ts"; +export { gitCompositionInstallation, gitPlugin } from "./src/plugin.ts"; /** * ``'s entry in a workflow run's generated-XMD write table. * @@ -233,6 +233,13 @@ export type { PullRequestSnapshot, PullRequestUpdateKey, } from "./src/composition/pull-request-records.ts"; +export { admitLocator } from "./src/composition/locator.ts"; +export { + admitChangesInvocation, + admitDiffInvocation, + admitFileInvocation, + admitFilesInvocation, +} from "./src/composition/observation-api.ts"; export { admitPushEvidence } from "./src/composition/push-evidence.ts"; export { COMPOSITION_REGISTRATIONS, @@ -305,3 +312,61 @@ export { sameRepositoryIdentity, } from "./src/composition/selection.ts"; export type { RepositoryIdentity } from "./src/composition/selection.ts"; + +export { ADD, admitPathspecs } from "./src/composition/components/GitAdd.ts"; +export { + COMMIT, + admitCommitMessage, + admitMessageSource, +} from "./src/composition/components/GitCommit.ts"; +export { PUSH, pushDeclaration } from "./src/composition/components/GitPush.ts"; +export { + CHANGES_ELEMENT, + DIFF_ELEMENT, + FILES_ELEMENT, + FILE_ELEMENT, +} from "./src/composition/components/GitReads.ts"; +export { SWITCH } from "./src/composition/components/GitSwitch.ts"; +export { issueDeclaration } from "./src/composition/components/Issue.ts"; +export { + PULL_REQUEST_ELEMENT, + pullRequestDeclaration, +} from "./src/composition/components/PullRequest.ts"; +export { pullRequestReadDeclaration } from "./src/composition/components/PullRequestReads.ts"; +export { + GitObservationError, + GitOperationAdmissionError, + GitOperationInfrastructureError, + RepositorySelectionError, +} from "./src/composition/errors.ts"; +export type { GitObservationReason } from "./src/composition/errors.ts"; +export { parseGitObjectId } from "./src/composition/git-records.ts"; +export type { BindOperations } from "./src/composition/invocation-binding.ts"; +export { + gitObservationValueJson, + parseGitObservationValue, +} from "./src/composition/observation-records.ts"; +export { members, optionalText, text } from "./src/composition/parse.ts"; +export { + PULL_REQUEST_READ_KINDS, + parseCompletePullRequestReadResult, + readRequest, +} from "./src/composition/pull-request-read-records.ts"; +export { gitHostRequestFingerprint } from "./src/git-host/records.ts"; +export { + liveIssueRead, + liveIssueUpsert, + ownedUpsertKey, + readOwnedIssue, + upsertOwnedIssue, +} from "./src/issue/owned.ts"; + +export { + issueRequestFingerprint, + parseIssueDetails, + parseIssueRecord, + parseIssueRequest, +} from "./src/issue/records.ts"; +export type { IssueRequest } from "./src/issue/records.ts"; + +export { GIT_COMPARE_MODES } from "./src/composition/observation-api.ts"; diff --git a/packages/git/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts index e5ad6110a..b4eadd065 100644 --- a/packages/git/src/composition/components/PullRequest.ts +++ b/packages/git/src/composition/components/PullRequest.ts @@ -34,17 +34,20 @@ import { content, documented, hasContent } from "@executablemd/core"; import type { ComponentRegistration, FirstPartyDocumentation, - FunctionComponent, PropsSchema, ReturnsSchema, } from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { PullRequestOperations } from "../pull-request-operations.ts"; +import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { PullRequestAdmissionError } from "../errors.ts"; import { pullRequestResultJson } from "../pull-request-records.ts"; +import { boundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; export const PULL_REQUEST_ELEMENT = ""; @@ -111,8 +114,9 @@ export const documentation: FirstPartyDocumentation = { context: "The pull request's body.", }; -const body: FunctionComponent = function* PullRequest( +export const body: ComponentBody = function* PullRequest( props: Record, + upsert: PullRequestOperationsApi["upsert"], ): Operation { // Before a Repository is observed, a checkout selected or retained Push // evidence read: a body that never finished rendering must not become one @@ -129,7 +133,7 @@ const body: FunctionComponent = function* PullRequest( ); } - const result = yield* PullRequestOperations.operations.upsert({ + const result = yield* upsert({ pullRequest: { // Absence is `null` from here on: the durable request is JSON, and a // member that is sometimes missing would be a second shape. @@ -161,4 +165,24 @@ const METADATA = { }; /** An ordinary registered default, over whichever provider the host installed. */ -export const pullRequestRegistration: ComponentRegistration = { ...METADATA, fn: body }; +export const pullRequestRegistration: ComponentRegistration = { + ...METADATA, + fn: (props) => body(props, PullRequestOperations.operations.upsert), +}; + +/** + * A declaration, for an execution that owns this operation's policy. + * + * The site is authenticated in the frame the engine entered, before the body + * reaches any content of its own — so a projecting ancestor is ineligible for + * one, and the children this element encloses still finish at their own journal + * positions before the upsert is asked for. + */ +export function pullRequestDeclaration( + bind: BindOperations, +): IdentityComponent { + return { + ...METADATA, + factory: (claim: IdentityClaimant) => boundComponent(claim, bind, body), + }; +} diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index 22c5bc072..abd2bb795 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -23,6 +23,7 @@ import type { PropsSchema, ReturnsSchema, } from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; @@ -34,6 +35,9 @@ import { import { canonicalPullRequestUrl, pullRequestProviderName } from "../pull-request-target.ts"; import type { PullRequestReadKind } from "../pull-request-read-records.ts"; import { PullRequestOperations } from "../pull-request-operations.ts"; +import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; +import { boundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; export const REVIEWS_ELEMENT = ""; export const COMMENTS_ELEMENT = ""; @@ -164,8 +168,14 @@ export const checksReturns: ReturnsSchema = array({ }), ], }); -function read(kind: PullRequestReadKind, element: string): FunctionComponent { - return function* PullRequestRead(props: Record): Operation { +function read( + kind: PullRequestReadKind, + element: string, +): ComponentBody { + return function* PullRequestRead( + props: Record, + performRead: PullRequestOperationsApi["read"], + ): Operation { // Canonicalized before any provider sees it, so the component, the durable // request and every adapter read one answer. const url = canonicalPullRequestUrl(props.url); @@ -187,7 +197,7 @@ function read(kind: PullRequestReadKind, element: string): FunctionComponent { ); } - const result = yield* PullRequestOperations.operations.read({ url, kind, provider }); + const result = yield* performRead({ url, kind, provider }); if (result.kind !== kind) { throw new PullRequestReadError( "protocol", @@ -296,7 +306,34 @@ function metadata(kind: PullRequestReadKind) { /** An ordinary registered default, over whichever provider the host installed. */ export function pullRequestReadRegistration(kind: PullRequestReadKind): ComponentRegistration { const element = ELEMENTS[kind]; - return { ...metadata(kind), fn: formDispatcher(declare(read(kind, element), element)) }; + const body = read(kind, element); + return { + ...metadata(kind), + fn: formDispatcher( + declare((props) => body(props, PullRequestOperations.operations.read), element), + ), + }; +} + +/** + * A declaration, for an execution that owns this collection's read policy. + * + * The canonical self-closing dispatcher stays outermost, so which form the + * element was written as still comes from the scan rather than from a + * contextual answer — and the site is authenticated inside it, in the frame the + * engine entered. + */ +export function pullRequestReadDeclaration( + kind: PullRequestReadKind, + bind: BindOperations, +): IdentityComponent { + const element = ELEMENTS[kind]; + const body = read(kind, element); + return { + ...metadata(kind), + factory: (claim: IdentityClaimant) => + formDispatcher(declare(boundComponent(claim, bind, body), element)), + }; } export const PULL_REQUEST_READ_KINDS: readonly PullRequestReadKind[] = Object.freeze([ diff --git a/packages/git/src/composition/invocation-binding.ts b/packages/git/src/composition/invocation-binding.ts index db62b7a1d..9e2d23a12 100644 --- a/packages/git/src/composition/invocation-binding.ts +++ b/packages/git/src/composition/invocation-binding.ts @@ -1,17 +1,19 @@ /** * How a declaring execution attaches its own policy to a shared component body. * - * `` and `` are the two operations a REPL entry retains, so - * their bodies take the operation they ask as an argument: an ordinary - * registration passes the feature Api's own operations, and the entry's - * declaration passes an envelope that encloses that Api. A body that reached - * the Api itself could not be enclosed by a durable boundary above it, which is - * what retention here means. The other four — `` and its three - * evidence reads — are retained as well, but underneath their Api rather than - * above it: a workflow run makes the upsert durable in the Git-host - * reconciliation beneath it, and each read durable as policy on - * `PullRequestReadExecution`. No host owns a boundary over the chain for them, - * so they need no binding of their own here and ask their Api directly. + * The six operations that reach something no local transaction encloses — + * ``, ``, `` and its three evidence reads — + * take the operation they ask as an argument. An ordinary registration passes + * the feature Api's own operations, and a retaining host's declaration passes + * an envelope that encloses that Api. A body that reached the Api itself could + * not be enclosed by a durable boundary above it, which is what retention here + * means. + * + * A host may retain beneath the Api instead, and a workflow run does: it makes + * the upsert durable in the Git-host reconciliation below, and each read + * durable as policy on `PullRequestReadExecution`. That needs no binding. What + * needs one is a boundary *over* the chain, which is why all six are bound + * rather than only the two whose hosts happened to want one first. * * Either way the body receives semantic operations and nothing else: no * authenticator, no owner, no site, no native access and no record codec. The diff --git a/packages/git/src/deno/composition/pull-request-host.ts b/packages/git/src/deno/composition/pull-request-host.ts new file mode 100644 index 000000000..890525aa5 --- /dev/null +++ b/packages/git/src/deno/composition/pull-request-host.ts @@ -0,0 +1,82 @@ +/** + * The pull-request work a retaining host asks for, and nothing it owns. + * + * Two operations. One performs an admitted evidence read, handing the host its + * own retention callback for the moment the read is admitted and no earlier. + * The other answers with a provider for one upsert attempt. Both keep this + * adapter's configuration, its endpoint and its credential on this side of the + * boundary: a caller never receives a source, an access session or a token, and + * never a configuration reader it could point somewhere else. + * + * ## Why the read takes a callback rather than returning a session + * + * Matching, URL validation and the operator's read ceiling are this adapter's + * decisions and happen before anything is opened, so a target this host never + * authorized leaves no trace. What happens to an *admitted* read is the host's + * — an ordinary run performs it afresh, a workflow run retains it per run, a + * REPL entry retains it per occurrence. Handing the host a transport instead + * would make it decide admission too, and would put the credential in its + * hands to do it with. + * + * ## One handle, one memoization, no stored callback + * + * The configuration and the transport resolve once per handle and stay inert + * until something needs a session, which is the existing behavior. Neither the + * callback nor the continuation a call supplies is kept on the handle: they + * belong to that one call, so two concurrent reads cannot reach each other's. + */ + +import type { Operation } from "effection"; +import { + gitHubPullRequestReadHandler, + gitHubPullRequestReadResolver, +} from "./pull-request-reads.ts"; +import type { AdmittedReadExecution } from "./pull-request-reads.ts"; +import { denoGitHubSource } from "./github-host.ts"; +import { pullRequestProvider } from "./github-pull-request.ts"; +import type { PullRequestReadOptions } from "../../composition/pull-request-api.ts"; +import type { PullRequestReadResult } from "../../composition/pull-request-read-records.ts"; +import type { PullRequestInputs } from "../../composition/pull-request-records.ts"; +import type { GitHostProvider } from "../../git-host/api.ts"; + +export interface DenoPullRequestOperations { + read( + url: string, + options: PullRequestReadOptions, + execute: AdmittedReadExecution, + next: (url: string, options: PullRequestReadOptions) => Operation, + ): Operation; + + provider(origin: string, inputs: PullRequestInputs): Operation; +} + +/** + * One inert handle per installation. + * + * Naming it reads no configuration and opens nothing; the shipped platform + * source is constructed on first use, inside this closure, and reaches no + * caller. + */ +export function denoPullRequestOperations(): DenoPullRequestOperations { + const resolved = gitHubPullRequestReadResolver({ host: denoGitHubSource }); + + return { + read( + url: string, + options: PullRequestReadOptions, + execute: AdmittedReadExecution, + next: (url: string, options: PullRequestReadOptions) => Operation, + ): Operation { + // Built for this call, so what is admitted reaches this caller's own + // callback and nothing on the shared handle holds it. + return gitHubPullRequestReadHandler(resolved, execute)([url, options], next); + }, + + *provider(origin: string, inputs: PullRequestInputs): Operation { + // Opened inside the attempt that asked, so the session ends with it. The + // recipe is the adapter's own — one memoization, shared with the read. + const access = yield* (yield* resolved.access()).open(); + return pullRequestProvider(access, origin, inputs); + }, + }; +} diff --git a/packages/git/src/deno/composition/pull-request-reads.ts b/packages/git/src/deno/composition/pull-request-reads.ts index 0333d315f..06df4245e 100644 --- a/packages/git/src/deno/composition/pull-request-reads.ts +++ b/packages/git/src/deno/composition/pull-request-reads.ts @@ -43,7 +43,9 @@ import type { PullRequestReadResult, } from "../../composition/pull-request-read-records.ts"; import { PullRequestAPI } from "../../composition/pull-request-api.ts"; +import type { PullRequestReadOptions } from "../../composition/pull-request-api.ts"; import { PullRequestReadExecution } from "../../composition/pull-request-read-execution.ts"; +import type { AdmittedPullRequestRead } from "../../composition/pull-request-read-execution.ts"; import type { RepositoryRecord } from "../../composition/records.ts"; import type { SelectionRegistry } from "../selections.ts"; import type { GitHubRepositoryName, GitHubSource } from "./github.ts"; @@ -237,11 +239,7 @@ function transports( export function gitHubPullRequestAccess( options: GitHubPullRequestsOptions = {}, ): () => Operation { - const authorized = resolver(options); - const transport = transports(options); - return function* (): Operation { - return transport((yield* authorized()) ?? {}); - }; + return gitHubPullRequestReadResolver(options).access; } function resolver( @@ -271,90 +269,149 @@ function resolver( export function* useGitHubPullRequestReads( options: GitHubPullRequestsOptions = {}, ): Operation { + yield* PullRequestAPI.around({ + read: gitHubPullRequestReadHandler( + gitHubPullRequestReadResolver(options), + (admitted, perform) => PullRequestReadExecution.operations.perform(admitted, perform), + ), + }); +} + +/** + * What an admitted read is handed to, once this adapter owns the answer. + * + * The ordinary and workflow installer hands it the profile-neutral execution + * seam, whose default performs afresh and whose workflow middleware retains. + * A REPL entry hands it a callback closed over that invocation's own owner and + * site, because an entry's retention is named after the invocation and there is + * nothing on this surface for it to be read from. + */ +export type AdmittedReadExecution = ( + admitted: AdmittedPullRequestRead, + perform: () => Operation, +) => Operation; + +/** + * This adapter's lazily memoized configuration and transport, resolved once. + * + * Built once per installation — or, for an entry, once per entry — and inert + * until something actually needs a session. Naming it reads no configuration + * and opens nothing. + */ +export function gitHubPullRequestReadResolver(options: GitHubPullRequestsOptions = {}): { + readonly authorized: () => Operation; + readonly transport: (configuration: GitHubPullRequestsConfiguration) => GitHubSource; + /** The two above in the order a caller that just wants a session needs them. */ + readonly access: () => Operation; +} { const authorized = resolver(options); const transport = transports(options); + return { + authorized, + transport, + *access(): Operation { + return transport((yield* authorized()) ?? {}); + }, + }; +} - yield* PullRequestAPI.around({ - *read([url, read], next): Operation { - // Matched by discriminator, or — with no discriminator — by URL. The - // match is decided from the request alone, so a URL that is not this - // adapter's reads no configuration on its way past. - const mine = - read.provider === undefined - ? recognizesGitHubPullRequestUrl(url) - : read.provider === GITHUB; - if (!mine) { - return yield* next(url, read); - } - - // The URL is this adapter's, so now — and only now — what this deployment - // authorized is read. With nothing allowed there is no URL read this host - // performs, so the request passes to whatever else is installed and, - // finding nothing, reaches the surface's own base error. Upsert is - // untouched by this: it is handled below whether or not any URL is - // allowed. - const configuration = yield* authorized(); - if (configuration?.allowed === undefined || configuration.allowed.length === 0) { - return yield* next(url, read); - } +/** + * The one admitted-read handler, over whichever execution the host supplies. + * + * Matching, the host ceiling and target parsing are this adapter's own and stay + * exactly where they were — before a session, a credential or a request exists, + * so a refusal leaves no trace. What happens to an admitted read is the + * profile's, and that is the only thing this is parameterized by. + */ +export function gitHubPullRequestReadHandler( + resolved: { + readonly authorized: () => Operation; + readonly transport: (configuration: GitHubPullRequestsConfiguration) => GitHubSource; + }, + execute: AdmittedReadExecution, +) { + const authorized = resolved.authorized; + const transport = resolved.transport; + return function* ( + [url, read]: [string, PullRequestReadOptions], + next: (url: string, read: PullRequestReadOptions) => Operation, + ): Operation { + // Matched by discriminator, or — with no discriminator — by URL. The + // match is decided from the request alone, so a URL that is not this + // adapter's reads no configuration on its way past. + const mine = + read.provider === undefined ? recognizesGitHubPullRequestUrl(url) : read.provider === GITHUB; + if (!mine) { + return yield* next(url, read); + } - const element = ELEMENT[read.kind]; - // From here this middleware owns the answer, and what is allowed is asked - // before anything is built: a URL a document wrote is not a place this - // host authorized until the configuration says so. - if (!pullRequestAllowed(configuration.allowed, url)) { - throw new PullRequestReadError( - "unavailable", - element, - "this host has not authorized the pull request that URL names.", - ); - } - const name = parseGitHubPullRequestUrl(url); - if (name === undefined) { - throw new PullRequestReadError( - "invalid-url", - element, - "that URL does not name a pull request this adapter can read.", - ); - } + // The URL is this adapter's, so now — and only now — what this deployment + // authorized is read. With nothing allowed there is no URL read this host + // performs, so the request passes to whatever else is installed and, + // finding nothing, reaches the surface's own base error. Upsert is + // untouched by this: it is handled below whether or not any URL is + // allowed. + const configuration = yield* authorized(); + if (configuration?.allowed === undefined || configuration.allowed.length === 0) { + return yield* next(url, read); + } - // Admitted. Everything above decided whether this host may answer at all, - // and none of it is retained: a target outside the ceiling, or one this - // adapter cannot name, leaves no record of a question that was never - // permitted. - // - // What happens from here is the profile's, not this adapter's. Both - // profiles install this same middleware, so it asks rather than decides: - // an ordinary run performs the transport afresh, and a workflow run wraps - // it in one durable effect. Neither a WorkflowRun nor an expansion is - // reachable from here, which is what keeps that true. - return yield* PullRequestReadExecution.operations.perform( - { url, kind: read.kind, provider: read.provider }, - function* (): Operation { - // After the ceiling, never before: a session opened first would be an - // identity established for a target this host had not authorized. - const access = yield* transport(configuration).open(); - const reading = yield* readEvidence(access, name, name.number, read.kind); - if (reading.state === "unavailable") { - throw new PullRequestReadError( - "unavailable", - element, - "the Git host did not answer with the complete collection. None of what it did " + - "answer is evidence that there is nothing there.", - ); - } - if (reading.state === "protocol-invalid") { - throw new PullRequestReadError( - "protocol", - element, - "the Git host answered about a different subject, or with an item outside the " + - "evidence contract. A well-formed answer to another question is still the wrong " + - "answer.", - ); - } - return reading.result; - }, + const element = ELEMENT[read.kind]; + // From here this middleware owns the answer, and what is allowed is asked + // before anything is built: a URL a document wrote is not a place this + // host authorized until the configuration says so. + if (!pullRequestAllowed(configuration.allowed, url)) { + throw new PullRequestReadError( + "unavailable", + element, + "this host has not authorized the pull request that URL names.", ); - }, - }); + } + const name = parseGitHubPullRequestUrl(url); + if (name === undefined) { + throw new PullRequestReadError( + "invalid-url", + element, + "that URL does not name a pull request this adapter can read.", + ); + } + + // Admitted. Everything above decided whether this host may answer at all, + // and none of it is retained: a target outside the ceiling, or one this + // adapter cannot name, leaves no record of a question that was never + // permitted. + // + // What happens from here is the profile's, not this adapter's. Both + // profiles install this same middleware, so it asks rather than decides: + // an ordinary run performs the transport afresh, and a workflow run wraps + // it in one durable effect. Neither a WorkflowRun nor an expansion is + // reachable from here, which is what keeps that true. + return yield* execute( + { url, kind: read.kind, provider: read.provider }, + function* (): Operation { + // After the ceiling, never before: a session opened first would be an + // identity established for a target this host had not authorized. + const access = yield* transport(configuration).open(); + const reading = yield* readEvidence(access, name, name.number, read.kind); + if (reading.state === "unavailable") { + throw new PullRequestReadError( + "unavailable", + element, + "the Git host did not answer with the complete collection. None of what it did " + + "answer is evidence that there is nothing there.", + ); + } + if (reading.state === "protocol-invalid") { + throw new PullRequestReadError( + "protocol", + element, + "the Git host answered about a different subject, or with an item outside the " + + "evidence contract. A well-formed answer to another question is still the wrong " + + "answer.", + ); + } + return reading.result; + }, + ); + }; } diff --git a/packages/git/src/deno/issue/github-host.ts b/packages/git/src/deno/issue/github-host.ts new file mode 100644 index 000000000..a15988a87 --- /dev/null +++ b/packages/git/src/deno/issue/github-host.ts @@ -0,0 +1,23 @@ +/** + * The issue transport this platform ships, installed without naming its source. + * + * `useGitHubIssues` knows the protocol and not the platform, so a host hands it + * the concrete transport and the credential behind it. This is that host's half, + * and it is a wrapper rather than an option on the public installer for one + * reason: the shipped source is merged into a private object here, so it is + * never assigned into a caller's own options and never reachable from one. + * + * A caller may still substitute its own `access`, `host` or `configuration`, as + * it already could — a supplied host callback receives the endpoint and answers + * with the caller's own source. With no override, only this module closes over + * the shipped one. Nothing is returned at all. + */ + +import type { Operation } from "effection"; +import { useGitHubIssues } from "./github.ts"; +import type { GitHubIssuesOptions } from "./github.ts"; +import { denoGitHubSource } from "../composition/github-host.ts"; + +export function useDenoGitHubIssues(options: GitHubIssuesOptions = {}): Operation { + return useGitHubIssues({ host: denoGitHubSource, ...options }); +} diff --git a/packages/git/src/deno/repository-operations.ts b/packages/git/src/deno/repository-operations.ts new file mode 100644 index 000000000..aeb6c152a --- /dev/null +++ b/packages/git/src/deno/repository-operations.ts @@ -0,0 +1,242 @@ +/** + * The native repository work a retaining host asks for, and nothing it owns. + * + * A host that retains what an entry did needs real Git: it has to discover the + * checkout somebody started in, select a managed one, switch, stage and commit + * in it, read a branch and a commit back, answer the four local reads, and hand + * the shared Git-host engine a provider for one publication. None of that is + * retention, and none of it is this module's to retain. + * + * ## Why this is a handle and not the host + * + * The shipped `RepositoryHost` runs arbitrary Git with a built environment, and + * a `GitSession` and an authentication session carry an identity. Handing any of + * those to a caller would put a credential, a command runner and a configuration + * override in a public return graph, where the only thing protecting them would + * be nobody looking. So what crosses this boundary is twelve operations that + * take and answer domain data — paths, identities, object ids, normalized + * results — and one provider whose own surface is `observe` and `perform`. + * + * ## What it acquires, and when + * + * Nothing, until something asks. Building the handle constructs the shipped host + * and remembers how to open a session; it opens no directory, takes no lock, + * runs no Git, reads no identity, no configuration and no credential. The + * session is acquired on first use into the scope that built the handle, which + * is the execution that owns it — the same lifetime the initializer gives it + * today. + * + * One operation is deliberately different. `pushProvider` acquires in its + * *caller's* scope rather than this one, because the scratch repository and the + * credential session it needs belong to one owned attempt: they have to stay + * alive across that attempt's observe and perform, and be joined when it + * unwinds. Capturing the entry's scope for them would keep a credential open + * for the rest of the entry. + */ + +import { useScope } from "effection"; +import type { Operation } from "effection"; +import { denoRepositoryHost, useGitAuthentication } from "./composition/host.ts"; +import type { RepositoryHost } from "./composition/host.ts"; +import { commitPresent, currentBranch, gitSession, resolveCommit } from "./composition/git.ts"; +import type { GitSession } from "./composition/git.ts"; +import type { HelperAssembly } from "./composition/credential-helper.ts"; +import { objectSourceAt } from "./composition/object-source.ts"; +import { pushProvider } from "./composition/push.ts"; +import { performGitObservation } from "./composition/observations.ts"; +import type { ObservationPlace } from "./composition/observations.ts"; +import { discoverAmbientCheckout, discoverAmbientRepository } from "./run-composition/ambient.ts"; +import type { AmbientCheckout, AmbientRepository } from "./run-composition/ambient.ts"; +import { selectManagedRepository, selectManagedWorktree } from "./run-composition/checkouts.ts"; +import type { + ManagedRepository, + ManagedWorktree, + WorktreeCreation, +} from "./run-composition/checkouts.ts"; +import { lazily } from "./run-composition/lazy.ts"; +import { liveAdd, liveCheckout, liveCommit, liveSwitch } from "./run-composition/operations.ts"; +import type { RegisteredCheckout } from "./run-composition/operations.ts"; +import type { GitCommitIdentity } from "./run-composition/identity.ts"; +import { PUSH } from "../composition/components/GitPush.ts"; +import type { GitPushInputs } from "../composition/git-push-records.ts"; +import type { + GitAddResult, + GitCommitMessageSource, + GitCommitResult, + GitSwitchResult, +} from "../composition/git-records.ts"; +import type { GitObservationRequest, GitObservationValue } from "../composition/observation-api.ts"; +import type { GitHostProvider } from "../git-host/api.ts"; + +/** + * Twelve native operations over domain data. + * + * No options bag, no host or session getter, no arbitrary command runner, no + * environment, no observer, no authentication override and no raw attachment. + */ +export interface DenoRepositoryOperations { + ambientCheckout(from: string): Operation; + ambientRepository(from: string): Operation; + + selectRepository( + slot: string, + request: { + readonly name: string; + readonly locator: string; + readonly base: string | undefined; + }, + ): Operation; + selectWorktree(slot: string, creation: WorktreeCreation): Operation; + + switch( + checkout: RegisteredCheckout, + workingDirectory: string, + branch: string, + base: string | undefined, + ): Operation; + add( + checkout: RegisteredCheckout, + workingDirectory: string, + paths: readonly string[], + ): Operation; + commit( + checkout: RegisteredCheckout, + workingDirectory: string, + message: string, + messageSource: GitCommitMessageSource, + identity: GitCommitIdentity | undefined, + ): Operation; + + currentBranch(directory: string): Operation; + resolveCommit(directory: string, revision: string): Operation; + commitPresent(directory: string, commit: string): Operation; + observe( + element: string, + request: GitObservationRequest, + place: ObservationPlace, + ): Operation; + + pushProvider( + commonDirectory: string, + origin: string, + inputs: GitPushInputs, + ): Operation; +} + +/** + * One handle for the execution that asked for it. + * + * The helper is the deployment's own credential-helper assembly, which is what + * a clone of an authenticated locator needs; it is not a credential and not a + * callback over one. + */ +export function useDenoRepositoryOperations( + helper?: HelperAssembly, +): Operation { + // The one shipped host this package builds, and the only place it is built + // for this handle. Everything below is the builder, so what the public path + // does and what a package-local caller can observe are the same code. + return repositoryOperationsOver(denoRepositoryHost(helper === undefined ? {} : { helper })); +} + +/** + * The same handle, over a host the caller already has. + * + * Package-private, and deliberately absent from every entrypoint: handing a + * `RepositoryHost` across the package boundary is the surface the amendment + * withheld. What it exists for is this package's own tests — the acquisition + * windows inside `pushProvider`, and the inertness of construction itself, are + * only observable at the leaves a host owns, and a control that substituted a + * *different* host would be watching something the public factory never calls. + */ +export function* repositoryOperationsOver( + host: RepositoryHost, +): Operation { + // The scope that asked for the handle, which is the execution that owns it. + const owner = yield* useScope(); + const useGit = lazily(owner, function* (): Operation { + return gitSession(host, yield* host.useDirectory()); + }); + + return { + *ambientCheckout(from: string): Operation { + return yield* discoverAmbientCheckout(yield* useGit(), from); + }, + *ambientRepository(from: string): Operation { + return yield* discoverAmbientRepository(yield* useGit(), from); + }, + + *selectRepository(slot, request): Operation { + return yield* selectManagedRepository(yield* useGit(), host, slot, request); + }, + *selectWorktree(slot, creation): Operation { + return yield* selectManagedWorktree(yield* useGit(), slot, creation); + }, + + *switch(checkout, workingDirectory, branch, base): Operation { + const git = yield* useGit(); + return yield* liveSwitch(liveCheckout(git, checkout, workingDirectory), branch, base); + }, + *add(checkout, workingDirectory, paths): Operation { + const git = yield* useGit(); + return yield* liveAdd(liveCheckout(git, checkout, workingDirectory), paths); + }, + *commit( + checkout, + workingDirectory, + message, + messageSource, + identity, + ): Operation { + const git = yield* useGit(); + return yield* liveCommit( + liveCheckout(git, checkout, workingDirectory), + message, + messageSource, + identity, + ); + }, + + *currentBranch(directory: string): Operation { + return yield* currentBranch(yield* useGit(), directory); + }, + *resolveCommit(directory: string, revision: string): Operation { + return yield* resolveCommit(yield* useGit(), directory, revision); + }, + *commitPresent(directory: string, commit: string): Operation { + return yield* commitPresent(yield* useGit(), directory, commit); + }, + observe( + element: string, + request: GitObservationRequest, + place: ObservationPlace, + ): Operation { + // The existing joined child scope is this operation's own, so it is + // handed through rather than wrapped in a second one. + return performGitObservation(host, element, request, place); + }, + + *pushProvider(commonDirectory, origin, inputs): Operation { + // The caller's attempt, not the entry: the scratch repository and the + // credential session below belong to one owned attempt and are joined + // when it unwinds. + const attempt = yield* useScope(); + const source = objectSourceAt( + host, + yield* host.useDirectory(), + commonDirectory, + inputs.repository.objectFormat, + PUSH, + ); + const provider = pushProvider( + source, + origin, + inputs, + lazily(attempt, () => useGitAuthentication(host, origin)), + ); + // Only the two phases. A source, a session or a scratch path on this + // object would be the credential graph this boundary exists to close. + return { observe: provider.observe, perform: provider.perform }; + }, + }; +} diff --git a/packages/git/src/plugin.ts b/packages/git/src/plugin.ts index cbbe2fc2e..0db203fd4 100644 --- a/packages/git/src/plugin.ts +++ b/packages/git/src/plugin.ts @@ -34,7 +34,6 @@ import type { PluginInstallation, } from "@executablemd/core/api"; import { useCompositionComponents } from "./composition/installation.ts"; -import { admitReplRepositoryHistory } from "./retained-repository/admission.ts"; import { gitHostIdentityAdmission, issueIdentityAdmission } from "./identities.ts"; /** @@ -46,11 +45,17 @@ import { gitHostIdentityAdmission, issueIdentityAdmission } from "./identities.t * Which workflow *action* was asked for decides it, and * {@link executesDocument} is what reads that off the argv. * - * `repl` is one of them: it runs an entry a person typed, under the same - * ordinary profile `run` uses, so a document opened there can write the same - * Git vocabulary a document run anywhere else can. + * `repl` is not one of them, although it does run a document a person typed. + * What an entry wrote down about the checkout it was standing in is that + * command's own record, and the admission that reads it back has to be inside + * the same contribution as the vocabulary, because canonical core applies an + * admission within each execution's own journal read. So a host that retains + * entry work assembles the contribution itself and adds its admission beside + * this package's — {@link gitCompositionInstallation} is what it assembles from + * — and this predicate declines `repl` rather than declaring a vocabulary with + * no admission next to it. */ -const DOCUMENT_COMMANDS: ReadonlySet = new Set(["run", "plan", "syntax", "repl"]); +const DOCUMENT_COMMANDS: ReadonlySet = new Set(["run", "plan", "syntax"]); /** The workflow actions that execute a document. */ const EXECUTING_ACTIONS: ReadonlySet = new Set(["start", "resume", "fork"]); @@ -187,47 +192,35 @@ export function declaresFor(request: PluginInstallRequest): boolean { return request.command === "workflow" && executesDocument(request.args); } +/** + * This package's own vocabulary contribution, without a command in it. + * + * The registration and the two domain admissions, which is everything a host + * needs from Git to carry this vocabulary. It takes no command and no entry + * identity: a host that retains entry work adds its own admission beside these, + * and deciding *that* is the host's question rather than this package's. + * + * Declarations only. Every name here is a name and a description; the provider + * that performs what they name is attached by the host, lazily, when a run + * actually has a Workspace to perform it in. + */ +export function* gitCompositionInstallation(): Operation { + yield* useCompositionComponents(); + return { admissions: [gitHostIdentityAdmission(), issueIdentityAdmission()] }; +} + export const gitPlugin: Plugin = Plugin({ name: "@executablemd/git", *install(request: PluginInstallRequest): Operation { if (!declaresFor(request)) { return undefined; } - // Declarations only. Every one of these is a name and a description; the - // provider that performs what they name is attached by the host, lazily, - // when a run actually has a Workspace to perform it in. - // - // Every command that carries this vocabulary carries all of it, `repl` - // included: what a name means is the same wherever it is typed, and an - // execution that retains one of these operations attaches its policy to - // this same body from inside its own scope. - yield* useCompositionComponents(); - return { - admissions: [ - gitHostIdentityAdmission(), - issueIdentityAdmission(), - // The REPL's own: what an entry retained about the checkout it was - // standing in has to be readable before any of it is replayed. It - // belongs to this contribution because an admission is applied inside - // each execution's journal read, and this is the trusted path that - // already declares for the command. It holds no native state and names - // no runtime: a Node session that retained nothing has nothing here to - // refuse. - ...(request.command === "repl" ? [replHistoryAdmission()] : []), - ], - }; + // Every command that carries this vocabulary carries all of it: what a name + // means is the same wherever it is typed, and an execution that retains one + // of these operations attaches its policy to this same body from inside its + // own scope. + return yield* gitCompositionInstallation(); }, }); -/** - * What a REPL entry requires of the history it is about to continue. - * - * Provider-neutral, and about integrity alone: every record of this feature's - * own types parses and is named by the request it carries. Which entry a record - * belongs to is the stream segment's question, not this one's. - */ -function replHistoryAdmission(): JournalAdmission { - return (retained) => admitReplRepositoryHistory(retained); -} - export default gitPlugin; diff --git a/packages/git/src/retained-repository/push-admission.ts b/packages/git/src/retained-repository/push-admission.ts deleted file mode 100644 index a28017213..000000000 --- a/packages/git/src/retained-repository/push-admission.ts +++ /dev/null @@ -1,434 +0,0 @@ -/** - * Whether one entry's retained publication history may be continued at all. - * - * Three questions, and only three. **Integrity**: every saved Push request - * parses, states this version, and is published under the name its own question - * produces. **Ownership**: every request and every Push outcome names this - * entry's owner, because the owner is what the durable name is derived from — a - * record written under another one would replay under a name this entry never - * presented. **Pairing**: an outcome stands at the position after the request it - * answers, carries that request's own fingerprint, and reads back as a Push of - * exactly what was asked. - * - * Nothing else. No checkout is inspected, no origin is contacted, no object is - * read, no path is resolved and no result is handed out: replaying a completed - * operation is the durable engine's own positional matching, and this runs - * before any of it. Whether the branch a record names is still where it was is - * not this reader's question, and not a question a continuation asks. - * - * ## A trailing request is history, not damage - * - * An entry interrupted between publishing its request and obtaining the host's - * answer leaves exactly one unanswered request. That is the state this whole - * feature exists for, so it is admitted: the continuation reconciles that saved - * request rather than resolving a new one. What is refused is an outcome with no - * request before it, a request answered twice, and a receipt this build cannot - * read — each of which would let a continuation believe something was published - * that this history cannot account for. - * - * ## Coroutines are independent - * - * Pairing is per coroutine, because a position belongs to one. Two `` - * branches each publishing a branch interleave in the stream and neither one's - * outcome answers the other's request. Nothing is matched by searching the - * stream for a name: equal requests at two positions are two occurrences, and a - * reader that deduplicated them would refuse a document that is allowed. - */ - -import { StaleInputError } from "@executablemd/durable-streams"; -import type { DurableEvent, Json, Result, Yield } from "@executablemd/durable-streams"; -import type { Operation } from "effection"; -import { canonicalJson } from "@executablemd/workflow"; -import { - GIT_PUSH, - gitPushInputsJson, - gitPushNaturalKeyJson, - parseGitPushRecord, - pushExpectation, - type GitPushInputs, -} from "../composition/git-push-records.ts"; -import { sameRepositoryIdentity, type RepositoryIdentity } from "../composition/selection.ts"; -import { GIT_HOST_EFFECT } from "../git-host/effect-type.ts"; -import { - gitHostRequestFingerprint, - parseGitHostReconciliationRecord, -} from "../git-host/records.ts"; -import type { GitHostReconciliationRecord } from "../git-host/records.ts"; -import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT } from "./placement-records.ts"; -import { - parseReplPushRequestRecord, - replPushRequestName, - REPL_REPOSITORY_PUSH_REQUEST, - type ReplPushRequestRecord, -} from "./push-records.ts"; -import { REPL_REPOSITORY_VERSION, statedReplRecordVersion } from "./records.ts"; - -/** Why a publication history cannot be continued. */ -export type ReplPushRefusal = - /** A Push record of this feature's type that this version cannot read. */ - | "malformed-record" - /** A record stating a version this host does not implement. */ - | "unknown-version" - /** A record whose name is not the name of the request it carries. */ - | "request-fingerprint" - /** A record naming a history that is not this entry's. */ - | "foreign-owner" - /** A saved request naming a checkout this entry holds no placement for. */ - | "unknown-placement" - /** A Git-host outcome standing where no request asked for one. */ - | "orphan-outcome" - /** A second outcome for a request one already answered. */ - | "duplicate-outcome" - /** A settled outcome that is not the result a Push produces. */ - | "malformed-receipt"; - -/** - * A retained publication history this entry cannot continue from. - * - * Fatal by construction: it extends `StaleInputError`, so it ends the attempt - * rather than becoming a value a document can print away and carry on past. - */ -export class ReplPushHistoryError extends StaleInputError { - override name = "ReplPushHistoryError"; - - readonly reason: ReplPushRefusal; - - constructor(reason: ReplPushRefusal, sentence: string) { - super(sentence); - this.reason = reason; - } -} - -/** - * One place this entry holds, as parsed facts alone. - * - * What a saved request is checked against, on both paths and with the same - * arithmetic: during admission these come from the placement records the - * snapshot holds, and at an invocation they come from the private registry this - * execution rebuilt. Neither reading resolves a path or opens anything. - */ -export interface ReplPushPlacement { - readonly identity: RepositoryIdentity; - readonly checkoutRoot: string; - readonly commonDirectory: string; -} - -/** - * Whether every place a saved request names is one these placements hold. - * - * Three readings and three questions. The selection the invocation - * authenticated, the selection the live dispatch received, and the checkout - * routing resolved all have to be placements of the Repository the record - * names — a request whose saved root belongs to nothing this entry holds does - * not say where the publication was asked for. - */ -export function replPushPlacesHeld( - record: ReplPushRequestRecord, - placements: readonly ReplPushPlacement[], -): boolean { - const places: readonly (readonly [RepositoryIdentity, string, string])[] = [ - [ - record.requested.repository, - record.requested.selectionCheckout, - record.requested.commonDirectory, - ], - [ - record.received.repository, - record.received.selectionCheckout, - record.received.commonDirectory, - ], - [record.received.repository, record.resolved.checkoutRoot, record.resolved.commonDirectory], - ]; - return places.every(([identity, checkoutRoot, commonDirectory]) => - placements.some( - (placement) => - placement.checkoutRoot === checkoutRoot && - placement.commonDirectory === commonDirectory && - sameRepositoryIdentity(placement.identity, identity), - ), - ); -} - -/** The Git-host request one saved Push request asks the worker to reconcile. */ -export function replPushEffectRequest(inputs: GitPushInputs): { - readonly kind: string; - readonly inputs: Json; - readonly naturalKey: Json; -} { - return { - kind: GIT_PUSH, - inputs: gitPushInputsJson(inputs), - naturalKey: gitPushNaturalKeyJson({ - repository: inputs.repository, - remote: inputs.remote, - destinationRef: inputs.destinationRef, - }), - }; -} - -/** The name the worker publishes one saved request's outcome under. */ -export function replPushOutcomeName(record: ReplPushRequestRecord): Operation { - return gitHostRequestFingerprint({ - identity: { runId: record.owner, expansionId: record.site }, - ...replPushEffectRequest(record.resolved.inputs), - }); -} - -/** The three words a Push outcome may fail under, as the journal holds them. */ -const CLOSED_FAILURES: ReadonlySet = new Set([ - "GitHostConflictError", - "GitHostAmbiguousError", - "GitHostUnavailableError", -]); - -/** - * Hold this entry's retained publication history to what this host can read. - * - * `retained` is the exact snapshot canonical execution admitted, which for an - * entry is its own segment. Records of other features and other durable types - * are not this reader's business and are passed over. - */ -export function* admitReplPushHistory( - owner: string, - retained: readonly DurableEvent[], -): Operation { - /** Every place a successful placement record before this point described. */ - const placements: ReplPushPlacement[] = []; - /** Per coroutine, the requests no outcome has answered yet. */ - const pending = new Map(); - /** Per coroutine, the requests an outcome already answered. */ - const answered = new Map(); - - for (const event of retained) { - if (event.type !== "yield") { - continue; - } - const type = event.description.type; - if (type === REPL_REPOSITORY_PLACEMENT) { - remember(placements, event); - continue; - } - if (type === REPL_REPOSITORY_PUSH_REQUEST) { - const record = yield* admitRequest(owner, event, placements); - queue(pending, event.coroutineId).push(record); - continue; - } - if (type !== GIT_HOST_EFFECT) { - continue; - } - yield* admitOutcome(owner, event, pending, answered); - } -} - -/** Remember one successful placement's parsed facts, or pass over the record. */ -function remember(placements: ReplPushPlacement[], event: DurableEvent): void { - if (event.type !== "yield" || event.result.status !== "ok") { - return; - } - // The placement admission beside this one owns whether a placement record is - // readable at all. What is wanted here is only the places a readable one - // describes, so an unreadable record contributes nothing and is refused - // there rather than twice. - const record = parseReplPlacementRecord(event.result.value); - if (record === undefined || !record.outcome.ok) { - return; - } - const placement = record.outcome.value; - placements.push({ - identity: placement.identity, - checkoutRoot: placement.checkoutRoot, - commonDirectory: placement.commonDirectory, - }); -} - -function queue( - queues: Map, - coroutineId: string, -): ReplPushRequestRecord[] { - const existing = queues.get(coroutineId); - if (existing !== undefined) { - return existing; - } - const created: ReplPushRequestRecord[] = []; - queues.set(coroutineId, created); - return created; -} - -/** One saved request, held to this version, this owner, its name and its places. */ -function* admitRequest( - owner: string, - event: Yield, - placements: readonly ReplPushPlacement[], -): Operation { - if (event.result.status !== "ok") { - // A request publication has no failure side: its outcome is the appended - // record itself, so a failed envelope under this type is not a record this - // host ever wrote. - throw malformed(); - } - const payload = event.result.value; - const stated = statedReplRecordVersion(payload); - if (stated !== undefined && stated !== REPL_REPOSITORY_VERSION) { - throw refusal( - "unknown-version", - `a ${REPL_REPOSITORY_PUSH_REQUEST} record states version ${stated}, and this host ` + - `implements version ${REPL_REPOSITORY_VERSION} only. Nothing is assumed about what the ` + - "rest of it means.", - ); - } - const record = parseReplPushRequestRecord(payload); - if (record === undefined) { - throw malformed(); - } - if (record.owner !== owner) { - throw refusal( - "foreign-owner", - "a saved publication request in this entry's history was written under another owner, so " + - "the branch it asked to publish was not asked for by this entry and its request is not " + - "one this entry can present again.", - ); - } - if (event.description.name !== (yield* replPushRequestName(record))) { - throw refusal( - "request-fingerprint", - "a saved publication request is journaled under a name that is not the name its own " + - "request produces, so the record and the publication it claims to be do not match.", - ); - } - if (!replPushPlacesHeld(record, placements)) { - throw refusal( - "unknown-placement", - "a saved publication request names a checkout no selection before it placed, so this " + - "history does not say where the branch it published came from.", - ); - } - return record; -} - -/** - * One Git-host outcome, paired with the request it answers. - * - * Only this entry's own Push effects are this reader's business. A successful - * Git-host record of another kind belongs to a protocol this feature does not - * retain and is passed over; one naming another owner is refused, because an - * entry's segment holds its own work and borrowing somebody else's receipt is - * exactly what must not be possible. - * - * A failed envelope names no kind — a refusal retains no request of its own — - * so every one of them in an entry's segment is read as a publication. That is - * exactly what they are: publication is the one external protocol an entry - * retains through this boundary, and the name the failure is journaled under - * still has to be the one the request before it produces. - */ -function* admitOutcome( - owner: string, - event: Yield, - pending: Map, - answered: Map, -): Operation { - const settled = event.result.status === "ok" ? successOf(event.result.value) : undefined; - if (event.result.status === "ok" && settled === undefined) { - // A successful Git-host envelope this host cannot read as a reconciliation - // record says nothing trustworthy about external work. - throw refusal( - "malformed-receipt", - "a Git-host record in this entry's history is not one this host can read, so what it says " + - "about work already published is unknown.", - ); - } - if (settled !== undefined && settled.request.kind !== GIT_PUSH) { - // Another external protocol's record. This reader owns publication only. - return; - } - if (settled !== undefined && settled.request.identity.runId !== owner) { - throw refusal( - "foreign-owner", - "a publication outcome in this entry's history was reconciled under another owner, so the " + - "work it describes was not published by this entry.", - ); - } - - const queued = pending.get(event.coroutineId) ?? []; - const request = queued.pop(); - if (request === undefined) { - const spent = answered.get(event.coroutineId) ?? []; - throw spent.length > 0 - ? refusal( - "duplicate-outcome", - "a publication request in this entry's history is answered by more than one Git-host " + - "outcome, so this history does not say which of them describes what was published.", - ) - : refusal( - "orphan-outcome", - "a publication outcome in this entry's history stands where no saved request asked for " + - "one, so this history does not say what was asked of the Git host.", - ); - } - queue(answered, event.coroutineId).push(request); - - if (event.description.name !== (yield* replPushOutcomeName(request))) { - throw refusal( - "request-fingerprint", - "a publication outcome is journaled under a name that is not the name the request before " + - "it produces, so the outcome and the request it claims to answer do not match.", - ); - } - if (settled === undefined) { - const failed = failureOf(event.result); - if (failed === undefined || !CLOSED_FAILURES.has(failed)) { - throw refusal( - "malformed-receipt", - "a refused publication is recorded under a condition this host has no word for, so what " + - "it says about the branch is unknown.", - ); - } - return; - } - if (settled.request.identity.expansionId !== request.site) { - throw refusal( - "foreign-owner", - "a publication outcome names an invocation other than the one the request before it was " + - "made at, so the two describe different publications.", - ); - } - const asked = replPushEffectRequest(request.resolved.inputs); - if ( - canonicalJson(settled.request.inputs) !== canonicalJson(asked.inputs) || - canonicalJson(settled.request.naturalKey) !== canonicalJson(asked.naturalKey) - ) { - throw refusal( - "malformed-receipt", - "a publication outcome describes a push other than the one the request before it asked " + - "for, so this history does not say what was published.", - ); - } - if (parseGitPushRecord(settled, pushExpectation(request.resolved.inputs)) === undefined) { - throw refusal( - "malformed-receipt", - "a publication outcome holds a result that is not the one a Push of that branch produces, " + - "so what it says was published is unknown.", - ); - } -} - -/** The reconciliation record this retained value describes, or `undefined`. */ -function successOf(value: unknown): GitHostReconciliationRecord | undefined { - return parseGitHostReconciliationRecord(value); -} - -/** The closed name a failed envelope carries, or `undefined` when it carries none. */ -function failureOf(result: Result): string | undefined { - return result.status === "err" ? result.error.name : undefined; -} - -function malformed(): ReplPushHistoryError { - return refusal( - "malformed-record", - `a ${REPL_REPOSITORY_PUSH_REQUEST} record in this entry's history is not one this host can ` + - "read, so what it says about work already asked for is unknown.", - ); -} - -function refusal(reason: ReplPushRefusal, sentence: string): ReplPushHistoryError { - return new ReplPushHistoryError(reason, sentence); -} diff --git a/packages/git/tests/api-entrypoint.test.ts b/packages/git/tests/api-entrypoint.test.ts index 3f2938fab..020b112a7 100644 --- a/packages/git/tests/api-entrypoint.test.ts +++ b/packages/git/tests/api-entrypoint.test.ts @@ -114,6 +114,12 @@ const CONTEXTUAL_APIS: readonly string[] = [ "GitHost", "GitQuery", "IssueApi", + // The semantic issue chain, published for the host that composes onto it. + // Its minted name is not published beside it, unlike `ISSUE_API` and + // `PULL_REQUEST_API` below: a caller composes this one through the descriptor + // it is handed, and nothing outside this package mints a second Api under + // that name. + "IssueOperations", "IssueTrackerContext", "PullRequestAPI", "Repository", @@ -132,6 +138,7 @@ const OPERATIONS: readonly (readonly [string, readonly string[]])[] = [ ["GitHost", ["route"]], ["GitQuery", ["changes", "diff", "file", "files", "format", "read", "resolve", "root"]], ["IssueApi", ["read", "upsert"]], + ["IssueOperations", ["read", "upsert"]], ["IssueTrackerContext", ["current"]], ["PullRequestAPI", ["read", "upsert"]], ["Repository", ["ambient", "select", "worktree"]], diff --git a/packages/git/tests/composition-component-binding.test.ts b/packages/git/tests/composition-component-binding.test.ts index 8d54a9bc8..dffdcba94 100644 --- a/packages/git/tests/composition-component-binding.test.ts +++ b/packages/git/tests/composition-component-binding.test.ts @@ -40,17 +40,27 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import { COMPOSITION_REGISTRATIONS } from "../src/composition/installation.ts"; import { pushDeclaration } from "../src/composition/components/GitPush.ts"; import { issueDeclaration } from "../src/composition/components/Issue.ts"; +import { pullRequestDeclaration } from "../src/composition/components/PullRequest.ts"; +import { pullRequestReadDeclaration } from "../src/composition/components/PullRequestReads.ts"; +import { PULL_REQUEST_READ_KINDS } from "../src/composition/pull-request-read-records.ts"; import type { BindOperations } from "../src/composition/invocation-binding.ts"; // From the subpath that publishes them rather than from the source modules: // these are public types, and reaching them by relative path would bypass the // export map the partition test holds every consumer to. import type { IssueDetails, IssueReference } from "@executablemd/git/api"; -import type { IssueOperationsApi } from "../src/issue/operations.ts"; -import type { PullRequestOperationsApi } from "../src/composition/pull-request-operations.ts"; +import type { IssueOperationsApi } from "@executablemd/git/api"; +import type { PullRequestOperationsApi } from "@executablemd/git/api"; import { COMPOSITION_ORIGIN } from "../src/composition/definitions.ts"; -/** The two operations A's retained entry owns, by the names a document writes. */ -const OWNED: readonly string[] = ["Git.Push", "Issue"]; +/** Every operation a retained entry owns, by the names a document writes. */ +const OWNED: readonly string[] = [ + "Git.Push", + "Issue", + "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", +]; /** The six operations no local transaction encloses, by the names a document writes. */ const OPERATIONS: readonly string[] = [ @@ -174,7 +184,7 @@ function useRoot(): Operation { } /** - * One declaration of each operation A's retained entry owns. + * One declaration of each operation a retained entry owns. * * Built with policy nothing reaches: these rows are about what a declaration * says, and a constructor that had to perform in order to describe would be @@ -182,13 +192,20 @@ function useRoot(): Operation { */ function ownedDeclarations(): readonly IdentityComponent[] { return [ - pushDeclaration(() => () => { - throw new Error("a described declaration performed"); - }), + pushDeclaration(inert()), issueDeclaration(fakeIssuePolicy(watched())), + pullRequestDeclaration(inert()), + ...PULL_REQUEST_READ_KINDS.map((kind) => pullRequestReadDeclaration(kind, inert())), ]; } +/** A policy a described declaration never reaches. */ +function inert(): BindOperations { + return () => { + throw new Error("a described declaration performed"); + }; +} + /** The registration and the owned declaration of one name, for comparison. */ function paired(name: string): { registration: ComponentRegistration | undefined; @@ -232,8 +249,10 @@ describe("one registry for every repository operation, and the policy an executi } // What an owning execution adds is policy for the operations it retains, // under names this registry already has. It establishes no name of its own. + // Sorted, because which names an owning execution declares is the claim + // here and the order it builds them in is its own business. const owned = ownedDeclarations().map((entry) => entry.name); - expect(owned).toEqual([...OWNED]); + expect([...owned].sort()).toEqual([...OWNED]); for (const name of owned) { expect(registered).toContain(name); } diff --git a/packages/git/tests/git-add.test.ts b/packages/git/tests/git-add.test.ts index f8d2860fc..ae56ac225 100644 --- a/packages/git/tests/git-add.test.ts +++ b/packages/git/tests/git-add.test.ts @@ -16,10 +16,13 @@ import { collect, execute, inlineSource } from "@executablemd/core"; import type { ComponentRegistration } from "@executablemd/core"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { Json } from "@executablemd/durable-streams"; -import { scoped } from "effection"; +import { scoped, sleep, spawn, until, withResolvers } from "effection"; import { tmpdir } from "node:os"; import type { Operation } from "effection"; -import { readTextFile, writeTextFile } from "@effectionx/fs"; +import { exists, readTextFile, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; import { pathToFileURL } from "node:url"; import { cwd } from "@executablemd/runtime"; import { useTempDirectory } from "@executablemd/test-support/temp"; @@ -27,6 +30,7 @@ import { GitCompositionProviderError, GitOperationAdmissionError, GitOperationError, + GitOperationInfrastructureError, } from "../src/composition/errors.ts"; import { currentRepository, RepositoryContext } from "@executablemd/git/api"; import { Git } from "@executablemd/git/api"; @@ -1041,3 +1045,173 @@ describe("workflow Git.Add request ownership", () => { }); }); }); + +/** What a watched `git` child reported about its own lifetime. */ +interface WatchedChildren { + /** Resolves once a watched child has been spawned. */ + readonly spawned: Operation; + /** Handed to the host, which forwards it to the one place a child exists. */ + readonly observe: NonNullable; + observed(): number; + /** Whether every observed child has closed both of its pipes. */ + closed(): boolean; + /** Whether any observed process is still running. */ + alive(): boolean; +} + +/** + * Watch the real children one invocation spawns. + * + * The production host forwards this to `runProcess`, which calls it with the + * child as it is spawned — the only point at which a case can see the process a + * command really started. A wrapper around the host sees the call and its + * answer and never the child between them, so residue observed afterwards + * cannot tell an interrupted command from one that never began. + */ +function watchChildren(): WatchedChildren { + const pids: number[] = []; + let count = 0; + let closes = 0; + const first = withResolvers(); + return { + spawned: first.operation, + observe(child): void { + count += 1; + if (child.pid !== undefined) { + pids.push(child.pid); + } + child.on("close", () => { + closes += 1; + }); + first.resolve(); + }, + observed: () => count, + closed: () => count > 0 && closes === count, + alive: () => + pids.some((pid) => { + try { + // Signal 0 asks the operating system whether the process is there. + process.kill(pid, 0); + return true; + } catch { + return false; + } + }), + }; +} + +/** + * A script that reports it ran and then waits. + * + * What makes a child cut deterministic: the marker says the command really + * reached this point, and the wait keeps it there until the halt arrives. The + * process group teardown is what ends it. + */ +function* waitingScript(path: string, marker: string): Operation { + yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); + yield* until(chmod(path, 0o755)); +} + +/** Wait until this path is there, or say what never appeared. */ +function* appears(path: string): Operation { + const deadline = Date.now() + 30_000; + while (!(yield* exists(path))) { + if (Date.now() > deadline) { + throw new Error(`${path} never appeared`); + } + yield* sleep(25); + } +} + +/** + * What a native `add` costs, and what happens when it is cut. + * + * Both subjects need the invocation itself: one asks which condition an exit + * this provider has no word for is classified as, and the other asks what + * became of the process that exit would have come from. Neither is visible to a + * caller holding the published repository handle, which sees a method and its + * answer and never the child between them. + */ +describe("what a native Add classifies to, and what an interrupted one leaves", () => { + it("classifies an exit it has no word for as an infrastructure condition", function* () { + const root = yield* useStorageRoot(); + const remote = yield* useBareRemote(REMOTE); + const source = document(remote.locator, ``); + + yield* withStorage(root, function* () { + const database = yield* createRun(); + const inner = denoRepositoryHost(); + // An exit with none of the diagnostics this provider reads, which is + // exactly the case a classification must not invent a refusal for. + const unclassified = { + *git(invocation: GitInvocation): Operation { + if (invocation.args[0] === "add") { + return { code: 128, stdout: "", stderr: "fatal: something else entirely\n" }; + } + return yield* inner.git(invocation); + }, + useDirectory: inner.useDirectory, + }; + + const error = yield* raised( + runDocument(database, source, { composition: { host: unclassified } }), + ); + // Named by what this provider was doing, and nothing Git printed travels + // with it — so a reader is told the run is untouched rather than given a + // refusal the condition does not support. + expect(error instanceof GitOperationInfrastructureError).toBe(true); + expect(String(error)).toContain("no word for"); + expect(String(error)).not.toContain("something else entirely"); + expect(yield* gitEvents(database)).toHaveLength(0); + }); + }); + + it("joins an interrupted Add child's close before the operation settles", function* () { + const root = yield* useStorageRoot(); + const remote = yield* useBareRemote(REMOTE); + const home = yield* useTempDirectory("xmd-add-interrupt-"); + const marker = join(home, "add-ran"); + const script = join(home, "waiting-hook"); + yield* waitingScript(script, marker); + const watched = watchChildren(); + const source = document(remote.locator, ``); + + yield* withStorage(root, function* () { + const database = yield* createRun(); + const inner = denoRepositoryHost(); + // `git add` runs no hook, so the child that waits is made to wait by a + // `core.fsmonitor` program: it is consulted before the index is written, + // and it holds the pipes the parent inherited. + const held = { + *git(invocation: GitInvocation): Operation { + if (invocation.args[0] === "add") { + return yield* inner.git({ + ...invocation, + args: ["-c", `core.fsmonitor=${script}`, ...invocation.args], + observe: watched.observe, + }); + } + return yield* inner.git(invocation); + }, + useDirectory: inner.useDirectory, + }; + + yield* scoped(function* () { + const task = yield* spawn(() => + runDocument(database, source, { composition: { host: held } }), + ); + // The marker says the descendant really ran and is holding the pipes. + yield* appears(marker); + yield* watched.spawned; + yield* task.halt(); + }); + + // The child this operation started closed before it settled, and no + // outcome was retained for work it never finished. + expect(watched.observed() > 0).toBe(true); + expect(watched.closed()).toBe(true); + expect(watched.alive()).toBe(false); + expect(yield* gitEvents(database)).toHaveLength(0); + }); + }); +}); diff --git a/packages/git/tests/git-host-owned.test.ts b/packages/git/tests/git-host-owned.test.ts index de894032a..ff36be210 100644 --- a/packages/git/tests/git-host-owned.test.ts +++ b/packages/git/tests/git-host-owned.test.ts @@ -19,13 +19,45 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { Err, ensure, Ok, scoped } from "effection"; +import { + Err, + ensure, + Ok, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import { exists, writeTextFile } from "@effectionx/fs"; +import { chmod, mkdtemp } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +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 { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; +import { repositoryOperationsOver } from "../src/deno/repository-operations.ts"; +import { + GIT_PUSH, + gitPushInputsJson, + gitPushNaturalKeyJson, +} from "../src/composition/git-push-records.ts"; +import type { GitPushInputs } from "../src/composition/git-push-records.ts"; +import { useBareRemote } from "./support/git-remotes.ts"; +import { useHostCheckout } from "./support/run-composition.ts"; +import { REMOTE } from "./support/run-composition-tier.ts"; import type { Operation, Result } from "effection"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; import { collect, getExpansion, inlineSource, registerComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; -import { GIT_HOST_EFFECT, reconcileOwnedGitHostEffect } from "../src/git-host/effect.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; +import { reconcileOwnedGitHostEffect } from "@executablemd/git/api"; import type { OwnedGitHostLive } from "../src/git-host/effect.ts"; import { GitHostConflictError, @@ -145,6 +177,14 @@ interface OwnedOptions { readonly validate?: (record: GitHostReconciliationRecord) => void; /** Acquired inside the attempt's own scope, around the whole reconciliation. */ readonly resource?: () => Operation; + /** + * The provider this attempt constructs, when a case builds a real one. + * + * Reached in the same place and the same scope as `provider` above, so a case + * that constructs the actual native provider is exercising the construction + * the engine joins rather than a copy of it. + */ + readonly construct?: () => Operation; readonly seen?: Attempt; /** Invocations the one `` makes, which is one by default. */ readonly calls?: number; @@ -215,6 +255,9 @@ function live(options: OwnedOptions, seen: Attempt): OwnedGitHostLive { if (options.resource !== undefined) { yield* options.resource(); } + if (options.construct !== undefined) { + return yield* options.construct(); + } const provider = options.provider; if (provider === undefined) { throw new Error("the attempt constructed a provider where none may be constructed"); @@ -443,7 +486,7 @@ describe("Tier OW — owner-specified external Git-host reconciliation", () => { expect(second.constructions).toBe(0); }); - it("OW6: attempt resources survive both phases and close before publication", function* () { + it("OW6: attempt resources survive both phases, and a held cleanup holds publication", function* () { const stream = new InMemoryStream(); const order: string[] = []; stream.onAppend = (event) => { @@ -464,26 +507,58 @@ describe("Tier OW — owner-specified external Git-host reconciliation", () => { }, ); - const seen = yield* runOwned({ - stream, - provider: host.provider, - *resource(): Operation { - order.push("opened"); - yield* ensure(function* () { - order.push("closed"); - }); - }, - validate: () => { - order.push(`validate:${order.includes("closed") ? "closed" : "open"}`); - }, + // Two explicit signals and no elapsed time anywhere: the attempt says when + // its teardown has begun, and this row says when it may finish. + const began = withResolvers(); + const release = withResolvers(); + // Armed before anything waits on it, so an assertion that fails below + // cannot leave the attempt stranded in a cleanup nobody will release. + yield* ensure(() => release.resolve()); + + const seen = attempt(); + let completed = false; + const caller = yield* spawn(function* (): Operation { + yield* runOwned({ + stream, + seen, + provider: host.provider, + *resource(): Operation { + order.push("opened"); + yield* ensure(function* (): Operation { + began.resolve(); + yield* release.operation; + order.push("closed"); + }); + }, + validate: () => { + order.push(`validate:${order.includes("closed") ? "closed" : "open"}`); + }, + }); + completed = true; }); + yield* began.operation; + + // Held here, after the whole reconciliation and before its teardown has + // finished. Nothing has been published and the caller has not come back: + // acknowledgement is downstream of cleanup rather than racing it, so a + // reader of this journal can never see a completion whose resources were + // still closing. + expect(gitHostYields(stream.snapshot())).toEqual([]); + expect(order).toEqual(["opened", "observe:open", "perform:open", "validate:open"]); + expect(completed).toBe(false); + expect(seen.records).toEqual([]); + + release.resolve(); + yield* caller; + + expect(completed).toBe(true); expect(seen.failures).toEqual([]); + expect(seen.records).toHaveLength(1); // One resource, opened once around a whole reconciliation: what observe // opened is still open when perform runs, and the validation that decides // whether anything may be published runs while it is too. Teardown is - // joined before the append, so publication acknowledges only work whose - // resources have finished. + // joined before the append, and the caller is answered only after it. expect(order).toEqual([ "opened", "observe:open", @@ -632,3 +707,431 @@ describe("Tier OW — owner-specified external Git-host reconciliation", () => { expect(gitHostYields(stream.snapshot())).toEqual([]); }); }); + +/** Let every ready task run, so "reached its wait" is about turns. */ +function* settled(turns = 8): Operation { + for (let turn = 0; turn < turns; turn += 1) { + yield* sleep(0); + } +} + +/** + * A promise a case releases by hand. + * + * What makes these cuts deterministic rather than a race: an acquisition is + * started, the halt is delivered while it is demonstrably still pending, and + * only then is it allowed to deliver. + */ +function gate(): { readonly released: Promise; release(): void } { + let open = (): void => {}; + const released = new Promise((resolve) => { + open = () => resolve(); + }); + return { released, release: () => open() }; +} + +/** A script that reports it ran and then waits, holding its pipes open. */ +function* waitingScript(path: string, marker: string): Operation { + yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); + yield* until(chmod(path, 0o755)); +} + +/** Wait until this path is there, or say what never appeared. */ +function* appears(path: string): Operation { + const deadline = Date.now() + 30_000; + while (!(yield* exists(path))) { + if (Date.now() > deadline) { + throw new Error(`${path} never appeared`); + } + yield* sleep(25); + } +} + +/** What a watched `git` child reported about its own lifetime. */ +interface WatchedChildren { + readonly spawned: Operation; + readonly observe: NonNullable; + observed(): number; + closed(): boolean; + alive(): boolean; +} + +/** Watch the real children one invocation spawns. */ +function watchChildren(): WatchedChildren { + const pids: number[] = []; + let count = 0; + let closes = 0; + const first = withResolvers(); + return { + spawned: first.operation, + observe(child): void { + count += 1; + if (child.pid !== undefined) { + pids.push(child.pid); + } + child.on("close", () => { + closes += 1; + }); + first.resolve(); + }, + observed: () => count, + closed: () => count > 0 && closes === count, + alive: () => + pids.some((pid) => { + try { + // Signal 0 asks the operating system whether the process is there. + process.kill(pid, 0); + return true; + } catch { + return false; + } + }), + }; +} + +/** + * The request the real Push provider admits, from one publication's inputs. + * + * The suite's own `PUSH` constant is a generic effect request: the real + * provider re-admits what it is handed and refuses anything that is not its + * own, so a case driving the actual provider has to state the real thing. + */ +function pushRequest(branch: string, commit: string): GitHostEffectRequest { + const inputs = pushInputs(branch, commit); + return Object.freeze({ + kind: GIT_PUSH, + inputs: gitPushInputsJson(inputs), + naturalKey: gitPushNaturalKeyJson({ + repository: inputs.repository, + remote: inputs.remote, + destinationRef: inputs.destinationRef, + }), + }); +} + +/** One publication's inputs against a real bare remote. */ +function pushInputs(branch: string, commit: string): GitPushInputs { + return Object.freeze({ + repository: { + name: "project", + locatorFingerprint: "a".repeat(64), + requestedBase: null, + creationCommit: commit, + primaryBranch: "main", + objectFormat: "sha1" as const, + }, + remote: "origin", + branch, + destinationRef: `refs/heads/${branch}`, + sourceCommit: commit, + }); +} + +/** + * What a cancelled attempt does to the resources its own provider acquired. + * + * These four windows live inside `pushProvider`: the scratch repository it + * opens over the checkout's object database, and the credential session it + * opens for the destination. Neither is reachable from above — the published + * handle answers with two phases and nothing else, which is the whole point of + * that boundary — so each case builds the *same* handle this package's public + * factory builds, over a host whose leaves it can hold, and reconciles through + * the real engine so the teardown it watches is the one publication waits for. + * + * The generic attempt-resource controls above (OW6, OW7) prove that a held + * cleanup holds publication and that a failing cleanup publishes nothing. They + * say nothing about which resource, when it was asked for, or whether a + * delivery that arrived after cancellation began was released — which is what + * these four are. + */ +describe("Tier OW — what a cancelled owned attempt's own native resources do", () => { + it("OW12: waits for a pending scratch delivery, then removes what arrived", function* () { + const remote = yield* useBareRemote(REMOTE); + const checkout = yield* useHostCheckout(remote.locator); + const head = checkout.run("rev-parse", "HEAD").trim(); + const common = checkout.run("rev-parse", "--path-format=absolute", "--git-common-dir").trim(); + + const made: string[] = []; + const asked = withResolvers(); + const held = gate(); + // Armed before anything waits on it, so an assertion that fails below + // cannot leave the attempt stranded in a delivery nobody releases. + yield* ensure(() => held.release()); + const create = (prefix: string): Promise => { + asked.resolve(); + return held.released + .then(() => mkdtemp(prefix)) + .then((directory) => { + made.push(directory); + return directory; + }); + }; + const host = denoRepositoryHost({ directories: { create } }); + + const stream = new InMemoryStream(); + const seen = attempt(); + let returned = false; + yield* scoped(function* (): Operation { + const running = yield* spawn(() => + runOwned({ + stream, + seen, + request: pushRequest("ow12", head), + *construct(): Operation { + const operations = yield* repositoryOperationsOver(host); + return yield* operations.pushProvider(common, remote.locator, pushInputs("ow12", head)); + }, + }), + ); + yield* asked.operation; + // The window: the provider asked for its scratch space and has not been + // handed one, so nothing has been observed and nothing published. + expect(made).toEqual([]); + expect(gitHostYields(stream.snapshot())).toEqual([]); + + const halting = yield* spawn(function* (): Operation { + yield* running.halt(); + returned = true; + }); + yield* settled(); + // Teardown is waiting for the acquisition it armed its release before: + // nothing delivered, and the halt has not come back. + expect(returned).toBe(false); + expect(made).toEqual([]); + + held.release(); + yield* halting; + }); + + // It waited for the delivery and removed exactly what arrived. + expect(returned).toBe(true); + expect(made).toHaveLength(1); + expect(yield* exists(made[0] ?? "")).toBe(false); + // And nothing was published for the publication it never finished. + expect(gitHostYields(stream.snapshot())).toEqual([]); + expect(seen.records).toEqual([]); + }); + + it("OW13: removes a scratch space the attempt had already received", function* () { + const remote = yield* useBareRemote(REMOTE); + const checkout = yield* useHostCheckout(remote.locator); + const head = checkout.run("rev-parse", "HEAD").trim(); + const common = checkout.run("rev-parse", "--path-format=absolute", "--git-common-dir").trim(); + + const delivered: string[] = []; + const built = withResolvers(); + const create = (prefix: string): Promise => + mkdtemp(prefix).then((directory) => { + delivered.push(directory); + return directory; + }); + const host = denoRepositoryHost({ directories: { create } }); + + const stream = new InMemoryStream(); + const seen = attempt(); + yield* scoped(function* (): Operation { + const running = yield* spawn(() => + runOwned({ + stream, + seen, + request: pushRequest("ow13", head), + *construct(): Operation { + const operations = yield* repositoryOperationsOver(host); + const provider = yield* operations.pushProvider( + common, + remote.locator, + pushInputs("ow13", head), + ); + // The other half of OW12's distinction: the scratch space has been + // delivered and the provider exists, and the cut lands here — + // before anything is observed, so what the halt releases is a + // resource the attempt already held rather than one it was owed. + built.resolve(); + yield* suspend(); + return provider; + }, + }), + ); + yield* built.operation; + // The positive control: a scratch space really was delivered, and it is + // on disk at the moment of the cut. + expect(delivered).toHaveLength(1); + expect(yield* exists(delivered[0] ?? "")).toBe(true); + yield* running.halt(); + }); + + // Gone with the attempt, and nothing invented for the publication. + expect(yield* exists(delivered[0] ?? "")).toBe(false); + expect(gitHostYields(stream.snapshot())).toEqual([]); + expect(seen.records).toEqual([]); + }); + + it("OW14: releases a credential session delivered after cancellation began", function* () { + const remote = yield* useBareRemote(REMOTE); + const checkout = yield* useHostCheckout(remote.locator); + const head = checkout.run("rev-parse", "HEAD").trim(); + const common = checkout.run("rev-parse", "--path-format=absolute", "--git-common-dir").trim(); + + const opened: string[] = []; + const held = withResolvers(); + const reached = withResolvers(); + const cleaning = withResolvers(); + const session = { delivered: false, released: false }; + yield* ensure(() => held.resolve(UNAUTHENTICATED)); + + const inner = denoRepositoryHost(); + const host: RepositoryHost = { + ...inner, + useAuthentication(locator: string): Operation { + opened.push(locator); + return resource(function* (provide) { + // Registered before the pending acquisition is announced, because the + // halt below is started off that announcement: an `ensure` yielded + // afterwards registers nothing on a scope already unwinding, and the + // cancellation would reach an acquisition with no cleanup on it. It + // suspends, so it is an `ensure` and not a `finally` — a `finally` + // that yields takes the frame out of return-mode mid-teardown. + yield* ensure(function* (): Operation { + cleaning.resolve(); + // The same delivery the cancelled acquisition was owed. A settled + // resolver answers every later read, so what this attempt was owed + // arrives here instead of at the initializer the halt unwound. + yield* held.operation; + session.released = true; + }); + reached.resolve(); + yield* provide(yield* held.operation); + }); + }, + }; + + const stream = new InMemoryStream(); + const seen = attempt(); + let returned = false; + yield* scoped(function* (): Operation { + const running = yield* spawn(() => + runOwned({ + stream, + seen, + request: pushRequest("ow14", head), + *construct(): Operation { + const operations = yield* repositoryOperationsOver(host); + return yield* operations.pushProvider(common, remote.locator, pushInputs("ow14", head)); + }, + }), + ); + // Raced against the attempt finishing, so an attempt that never asked + // for a credential is a diagnosis here rather than a hang. + yield* race([ + reached.operation, + (function* (): Operation { + yield* running; + throw new Error( + `the attempt settled without opening a session: ${seen.failures + .map((one) => String(one)) + .join(" | ")}`, + ); + })(), + ]); + const halting = yield* spawn(function* (): Operation { + yield* running.halt(); + returned = true; + }); + try { + // Ordered by acknowledgement and nothing else: until the cleanup says + // it started, nothing proves the cancellation reached the acquisition + // this delivery is owed to. + yield* cleaning.operation; + expect(session.delivered).toBe(false); + expect(session.released).toBe(false); + expect(returned).toBe(false); + } finally { + // External arrival, from this case rather than from the acquisition the + // halt unwound. In a `finally` with no yield in it, so an assertion + // that failed above leaves the joined cleanup nothing to wait for. + session.delivered = true; + held.resolve(UNAUTHENTICATED); + } + yield* halting; + }); + + // The session was opened for the exact destination this publication names, + // the delivery it was still owed arrived after its own cleanup had started, + // that cleanup released it, and only then did the halt return. + expect(new Set(opened)).toEqual(new Set([remote.locator])); + expect(session.delivered).toBe(true); + expect(session.released).toBe(true); + expect(returned).toBe(true); + expect(gitHostYields(stream.snapshot())).toEqual([]); + expect(seen.records).toEqual([]); + }); + + it("OW15: joins the running child its own observation spawned", function* () { + const remote = yield* useBareRemote(REMOTE); + const checkout = yield* useHostCheckout(remote.locator); + const head = checkout.run("rev-parse", "HEAD").trim(); + const common = checkout.run("rev-parse", "--path-format=absolute", "--git-common-dir").trim(); + + const home = yield* useTempDirectory("xmd-owned-child-"); + const marker = join(home, "upload-pack-ran"); + const script = join(home, "slow-upload-pack"); + yield* waitingScript(script, marker); + const watched = watchChildren(); + + // The first remote contact a publication makes is an exact observation of + // the destination. Against a local remote that spawns an upload-pack, and a + // program that reports it ran and then waits is a child holding the pipes + // this process inherited. + const inner = denoRepositoryHost(); + const host: RepositoryHost = { + ...inner, + *git(invocation: GitInvocation): Operation { + if (invocation.args.includes("ls-remote")) { + return yield* inner.git({ + ...invocation, + args: [ + "ls-remote", + `--upload-pack=${script}`, + ...invocation.args.slice(invocation.args.indexOf("ls-remote") + 1), + ], + observe: watched.observe, + }); + } + return yield* inner.git(invocation); + }, + }; + + const stream = new InMemoryStream(); + const seen = attempt(); + yield* scoped(function* (): Operation { + const running = yield* spawn(() => + runOwned({ + stream, + seen, + request: pushRequest("ow15", head), + *construct(): Operation { + const operations = yield* repositoryOperationsOver(host); + return yield* operations.pushProvider(common, remote.locator, pushInputs("ow15", head)); + }, + }), + ); + yield* watched.spawned; + // Not merely spawned: inside its own transport, with a descendant of its + // own waiting. Halting before that would interrupt a command which had + // done nothing, which is the hole this row closes. + yield* appears(marker); + // The guard against a vacuous row: a child that had already ended would + // make everything below true without anything being interrupted. + expect(watched.closed()).toBe(false); + yield* running.halt(); + }); + + // The child this attempt's own observation started closed before the + // attempt settled, and no outcome was invented for it. + expect(watched.observed() > 0).toBe(true); + expect(watched.closed()).toBe(true); + expect(watched.alive()).toBe(false); + expect(gitHostYields(stream.snapshot())).toEqual([]); + expect(seen.records).toEqual([]); + }); +}); diff --git a/packages/git/tests/git-observations-deno.test.ts b/packages/git/tests/git-observations-deno.test.ts index 9ad900813..67a29ba03 100644 --- a/packages/git/tests/git-observations-deno.test.ts +++ b/packages/git/tests/git-observations-deno.test.ts @@ -1,6 +1,8 @@ /** * Tier GO9, the half that needs a runtime which operates repositories. * + * Two subjects, both of which need a real `git` under a real checkout. + * * One case, kept apart from `git-observations.test.ts` because its subject is a * *managed* selection: it clones a remote into a managed root and adds a * worktree, which this repository holds under a kernel-released exclusive @@ -13,15 +15,41 @@ * and the foreign and edited selections refused before a host is reached. What * only this file can observe is that an ambient checkout, a lexical Repository, * a Worktree and a `` inside one each answer from their own checkout. + * + * The second subject is what an interrupted read leaves behind. That belongs + * here rather than above the provider: what it establishes is about a process + * the native host started and a scratch directory it owned, and nothing stated + * in terms of the published repository handle can see either. A wrapper around the handle + * sees a call and its answer and never the child between them, so residue + * observed afterwards could not tell an interrupted read from one that never + * began. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; +import { scoped, sleep, spawn, until, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { exists, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; import type { Json } from "@executablemd/durable-streams"; import { capturingProbe, writeFixtureFile } from "./support/observations.ts"; -import { runOrdinaryDocument, useHostCheckout, useManagedRoot } from "./support/run-composition.ts"; +import { + runOrdinaryDocument, + useHostCheckout, + useManagedRoot, + useOriginlessCheckout, +} from "./support/run-composition.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { REMOTE } from "./support/run-composition-tier.ts"; +import { denoRepositoryHost } from "../src/deno/composition/host.ts"; +import type { + GitBytes, + GitInvocation, + GitOutcome, + RepositoryHost, +} from "../src/deno/composition/host.ts"; /** The one bound value a case's document projected, as an object. */ function object(value: Json | undefined): Record { @@ -93,3 +121,158 @@ describe("GO9 — which checkout answers, through a managed selection", () => { expect(paths(seen[3])).toEqual(["which.txt"]); }); }); + +/** What a watched `git` child reported about its own lifetime. */ +interface WatchedChildren { + readonly spawned: Operation; + readonly observe: NonNullable; + observed(): number; + closed(): boolean; + alive(): boolean; +} + +/** Whether this process is still running, asked of the operating system. */ +function running(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Watch the real children one invocation spawns. + * + * The production host forwards this to the one place a child exists, which is + * the only point at which a case can see the process a command really started. + */ +function watchChildren(): WatchedChildren { + const pids: number[] = []; + let count = 0; + let closes = 0; + const first = withResolvers(); + return { + spawned: first.operation, + observe(child): void { + count += 1; + if (child.pid !== undefined) { + pids.push(child.pid); + } + child.on("close", () => { + closes += 1; + }); + first.resolve(); + }, + observed: () => count, + closed: () => count > 0 && closes === count, + alive: () => pids.some((pid) => running(pid)), + }; +} + +/** + * A script that reports it ran and then waits. + * + * What makes a child cut deterministic: the marker says the command really + * reached this point, and the wait keeps it there until the halt arrives. The + * process group teardown is what ends it. + */ +function* waitingScript(path: string, marker: string): Operation { + yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); + yield* until(chmod(path, 0o755)); +} + +/** Wait until this path is there, or say what never appeared. */ +function* appears(path: string): Operation { + const deadline = Date.now() + 30_000; + while (!(yield* exists(path))) { + if (Date.now() > deadline) { + throw new Error(`${path} never appeared`); + } + yield* sleep(25); + } +} + +/** The production host, with every scratch directory recorded on the way out. */ +function watchingHost( + scratch: string[], + intercept: (invocation: GitInvocation) => Partial | undefined, +): RepositoryHost { + const host = denoRepositoryHost(); + const watched = (invocation: GitInvocation): GitInvocation => ({ + ...invocation, + ...(intercept(invocation) ?? {}), + }); + return { + *git(invocation: GitInvocation): Operation { + return yield* host.git(watched(invocation)); + }, + *gitBytes(invocation: GitInvocation): Operation { + if (host.gitBytes === undefined) { + throw new Error("the production host lends no byte capture"); + } + return yield* host.gitBytes(watched(invocation)); + }, + *useDirectory(): Operation { + const directory = yield* host.useDirectory(); + scratch.push(directory); + return directory; + }, + }; +} + +describe("GO9 — what an interrupted native read leaves behind", () => { + it("joins the read's own child and removes its scratch before settling", function* () { + const checkout = yield* useOriginlessCheckout(); + const root = yield* useManagedRoot(); + yield* writeFixtureFile(checkout.root, "src/a.ts", "export const a = 1;\n"); + checkout.run("add", "--", "src/a.ts"); + checkout.run("commit", "-m", "a"); + yield* writeFixtureFile(checkout.root, "src/a.ts", "export const a = 11;\n"); + + const watched = watchChildren(); + const scratch: string[] = []; + const marker = join(checkout.home, "read-ran"); + const script = join(checkout.home, "waiting-fsmonitor"); + yield* waitingScript(script, marker); + + // The comparison refreshes the index before it answers, and a file-system + // monitor is consulted while it does. This one reports that it ran and then + // waits, holding the pipes the parent inherited — which is what makes the + // cut land on a real child rather than between commands. + const intercept = (invocation: GitInvocation): Partial | undefined => + invocation.args.includes("diff") + ? { + args: ["-c", `core.fsmonitor=${script}`, ...invocation.args], + observe: watched.observe, + } + : undefined; + + yield* scoped(function* () { + const task = yield* spawn(() => + runOrdinaryDocument( + [ + '', + 'sentinel', + ].join("\n"), + { root, cwd: checkout.root, host: watchingHost(scratch, intercept) }, + ), + ); + // The marker says the descendant really ran and is holding the pipes. + yield* appears(marker); + yield* watched.spawned; + yield* task.halt(); + }); + + // The child is gone and both its pipes have ended; the scratch the read + // owned is gone with it; and nothing after the read ran. + expect(watched.observed() > 0).toBe(true); + expect(watched.closed()).toBe(true); + expect(watched.alive()).toBe(false); + expect(scratch.length > 0).toBe(true); + for (const directory of scratch) { + expect(yield* exists(directory)).toBe(false); + } + expect(yield* exists(join(checkout.root, "sentinel.md"))).toBe(false); + }); +}); diff --git a/packages/git/tests/github-issues.test.ts b/packages/git/tests/github-issues.test.ts index 9d5dc5c44..534891ec0 100644 --- a/packages/git/tests/github-issues.test.ts +++ b/packages/git/tests/github-issues.test.ts @@ -10,7 +10,15 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { Ok, type Operation } from "effection"; +import { Ok, scoped, type Operation } from "effection"; +import { API } from "@executablemd/runtime"; +import { useDenoGitHubIssues } from "../src/deno/issue/github-host.ts"; +import { IssueApi } from "@executablemd/git/api"; +import type { IssueDetails } from "@executablemd/git/api"; +import { gitHubFixtureSource } from "./support/issue-providers.ts"; +import { useIssueTrackerServer } from "./support/issue-tracker-server.ts"; +import type { GitHubAccess } from "../src/deno/composition/github.ts"; +import type { GitHubIssuesOptions } from "../src/deno/issue/github.ts"; import { issueBodyFor, issueOriginMarker, @@ -196,5 +204,106 @@ describe("workflow GitHub issue payloads", () => { }); }); +/** + * What installing the published issue transport does, and what it keeps. + * + * A REPL entry installs this and names no source. What the wrapper adds is the + * shipped platform source, merged into a private object so it is never written + * into the caller's own options and never reachable from one — and a caller + * that does supply a source still outranks it. + */ +describe("what the published issue transport installs", () => { + it("installs the platform source when a caller names none, and nothing else", function* () { + const asked: string[] = []; + yield* scoped(function* (): Operation { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *env([name]: [string]): Operation { + asked.push(name); + return undefined; + }, + }, + { at: "min" }, + ); + // Installing returns nothing at all, so there is no value for a caller + // to reach a session, a transport or a credential through. + const installed = yield* useDenoGitHubIssues(); + expect(installed).toBe(undefined); + }); + // And installing reads no configuration: the adapter resolves it when an + // invoked operation turns out to be its own. + expect(asked).toEqual([]); + }); + + it("leaves a caller's own options untouched", function* () { + const supplied: { -readonly [K in keyof GitHubIssuesOptions]: GitHubIssuesOptions[K] } = { + configuration: { ceiling: [TARGET] }, + }; + yield* scoped(function* (): Operation { + yield* useDenoGitHubIssues(supplied); + }); + // The shipped source is merged into a private object rather than assigned + // into the caller's: an options object that came back carrying a `host` + // would have handed the caller this platform's transport factory. + expect("host" in supplied).toBe(false); + expect(Object.keys(supplied)).toEqual(["configuration"]); + expect(Object.isFrozen(supplied)).toBe(false); + }); + + it("sends an invoked read to the source a caller named, not the shipped one", function* () { + const served = yield* useIssueTrackerServer({ + issues: [{ number: 4, title: "Named", body: "", state: "open", labels: [], assignee: null }], + }); + // A source this case names, against a tracker it can answer from. The + // configuration points the *shipped* source somewhere nothing answers, so + // a wrapper whose default outranked the caller would fail this read rather + // than pass it: the two paths are told apart by which one can succeed. + const named = gitHubFixtureSource(served.url, { credential: "valid" }); + + const read = yield* scoped(function* (): Operation { + yield* useDenoGitHubIssues({ + access: named, + configuration: { ceiling: [TARGET], endpoint: "https://issues.invalid/api/v3" }, + }); + // An invoked operation, which is the only thing that reaches a source at + // all: installing resolves nothing, and a control that installed and + // stopped would observe neither source. + return yield* IssueApi.operations.read(`${TARGET}/issues/4`, {}); + }); + + expect(read.title).toBe("Named"); + // And the caller's tracker is the one that was asked. + expect(served.requests.length > 0).toBe(true); + expect(served.requests.every((asked) => asked.method === "GET")).toBe(true); + }); + + it("refuses a destination outside the ceiling before any source is opened", function* () { + const asked: string[] = []; + const named = { + endpoint: "https://issues.invalid/api/v3", + *open(): Operation { + asked.push("named"); + throw new Error("a refused destination must not reach a source"); + }, + }; + + const refusal = yield* scoped(function* (): Operation { + yield* useDenoGitHubIssues({ access: named, configuration: { ceiling: [TARGET] } }); + try { + yield* IssueApi.operations.read("https://github.com/other/project/issues/1", {}); + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }); + + // Both halves: the read was refused, and the refusal happened before the + // source this case supplied was opened at all. + expect(refusal).not.toBe(""); + expect(asked).toEqual([]); + }); +}); + /** One issue a fixture holds, kept for the shape's sake. */ export type { StoredIssue }; diff --git a/packages/git/tests/github-pull-requests.test.ts b/packages/git/tests/github-pull-requests.test.ts index 8fcda9654..e14622a38 100644 --- a/packages/git/tests/github-pull-requests.test.ts +++ b/packages/git/tests/github-pull-requests.test.ts @@ -11,6 +11,10 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import process from "node:process"; +import { scoped } from "effection"; +import { API } from "@executablemd/runtime"; +import { denoPullRequestOperations } from "../src/deno/composition/pull-request-host.ts"; +import { GITHUB_PULL_REQUESTS_ENV } from "../src/deno/composition/pull-request-configuration.ts"; import type { Operation } from "effection"; import type { RepositoryIdentity } from "../src/composition/selection.ts"; import { @@ -742,3 +746,80 @@ describe("workflow GitHub creation", () => { ).toBe(422); }); }); + +/** + * What the published pull-request facade is, before anybody reads through it. + * + * Two operations and nothing else. The host that holds one is a REPL entry, so + * what matters is that holding it costs nothing, that it carries no session + * between the attempts that borrow it, and that each read's retention callback + * is the asking caller's own rather than one the shared value kept. + */ +describe("what the published pull-request facade is, and what it keeps", () => { + it("hands back exactly read and provider, and reads nothing to do it", function* () { + const asked: string[] = []; + yield* scoped(function* (): Operation { + // The seam the adapter reads its configuration through, answered here so + // a read is observable and the process environment is left alone. + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *env([name]: [string]): Operation { + asked.push(name); + return undefined; + }, + }, + { at: "min" }, + ); + const operations = denoPullRequestOperations(); + // Two members and nothing else is handed over, so there is nowhere for a + // session, a transport or a credential to be reached through this value. + const values = Object.entries(operations).map(([name, held]) => `${name}: ${typeof held}`); + expect([...values].sort()).toEqual(["provider: function", "read: function"]); + }); + // Construction names a transport rather than building one: the resolver + // behind it is lazy, so nothing is read and nothing is opened. + expect(asked).toEqual([]); + }); + + it("gives each read its own retention callback rather than keeping one", function* () { + const operations = denoPullRequestOperations(); + const reached: string[] = []; + // Admitted, so the read is this adapter's own and reaches a callback at + // all. Stated through the configuration seam rather than written into the + // process this case is running in. + const stated = JSON.stringify({ allowed: ["https://github.com/owner/repository"] }); + // Two reads through one facade, each with a callback of its own. A facade + // that stored the first would hand the second read the first caller's + // retention, which is how an entry would retain another entry's evidence. + const refusing = function* (): Operation { + throw new Error("this control reaches no transport"); + }; + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *env([name]: [string]): Operation { + return name === GITHUB_PULL_REQUESTS_ENV ? stated : undefined; + }, + }, + { at: "min" }, + ); + for (const name of ["first", "second"]) { + try { + yield* operations.read( + "https://github.com/owner/repository/pull/1", + { kind: "reviews" }, + // deno-lint-ignore require-yield + function* (): Operation { + reached.push(name); + throw new Error(`${name} reached its own callback`); + }, + refusing, + ); + } catch (error) { + expect(String(error)).toContain(name); + } + } + expect(reached).toEqual(["first", "second"]); + }); +}); diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 0f2cb8702..61468e88b 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -97,13 +97,6 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/issue/records.ts", "src/issue/tracker.ts", "src/plugin.ts", - "src/retained-repository/admission.ts", - "src/retained-repository/git-records.ts", - "src/retained-repository/observation-records.ts", - "src/retained-repository/placement-records.ts", - "src/retained-repository/push-admission.ts", - "src/retained-repository/push-records.ts", - "src/retained-repository/records.ts", ]; /** @@ -145,6 +138,7 @@ const DENO_ADAPTER: readonly string[] = [ "credential-helper.ts", "src/deno/attachment.ts", "src/deno/repositories.ts", + "src/deno/repository-operations.ts", "src/deno/selections.ts", "src/deno/composition/add.ts", "src/deno/composition/authentication.ts", @@ -153,6 +147,8 @@ const DENO_ADAPTER: readonly string[] = [ "src/deno/composition/effects.ts", "src/deno/composition/git.ts", "src/deno/composition/github-host.ts", + "src/deno/composition/pull-request-host.ts", + "src/deno/issue/github-host.ts", "src/deno/composition/host.ts", "src/deno/composition/identity.ts", "src/deno/composition/locator.ts", @@ -171,15 +167,6 @@ const DENO_ADAPTER: readonly string[] = [ "src/deno/composition/subprocess.ts", "src/deno/composition/switch.ts", "src/deno/composition/worktree.ts", - "src/deno/retained-repository/files.ts", - "src/deno/retained-repository/git.ts", - "src/deno/retained-repository/issues.ts", - "src/deno/retained-repository/leases.ts", - "src/deno/retained-repository/local.ts", - "src/deno/retained-repository/observations.ts", - "src/deno/retained-repository/provider.ts", - "src/deno/retained-repository/push.ts", - "src/deno/retained-repository/selections.ts", "src/deno/run-composition/ambient.ts", "src/deno/run-composition/checkouts.ts", "src/deno/run-composition/errors.ts", @@ -561,3 +548,118 @@ describe("the three halves of @executablemd/git", () => { expect(violations).toEqual([]); }); }); + +/** + * The 27 names the credential-encapsulated amendment removed, and the one it + * added. + * + * Fenced by source text rather than by importing the entrypoint, because a + * `export type { … }` row disappears at runtime: `Object.keys()` on a loaded + * module cannot see it, which is exactly how two of these rows survived an + * entrypoint control that only read values. The amendment removed the public + * host, session, authentication, source, access and raw-operation surface; a + * row reappearing here is a credential-bearing seam becoming reachable from a + * bare package specifier again. + */ +const WITHHELD: readonly string[] = [ + "GitAuthentication", + "GitAuthenticationSession", + "GitCheckout", + "GitHubSource", + "GitSession", + "RepositoryHost", + "commitPresent", + "currentBranch", + "denoGitHubSource", + "denoRepositoryHost", + "discoverAmbientCheckout", + "discoverAmbientRepository", + "gitHubPullRequestReadHandler", + "gitHubPullRequestReadResolver", + "gitSession", + "liveAdd", + "liveCheckout", + "liveCommit", + "liveSwitch", + "objectSourceAt", + "performGitObservation", + "pullRequestProvider", + "pushProvider", + "resolveCommit", + "selectManagedRepository", + "selectManagedWorktree", + "useGitAuthentication", +]; + +/** Where each name the amendment publishes has to be reachable from. */ +const AMENDED_ROUTES: readonly { readonly name: string; readonly route: string }[] = [ + { name: "useDenoRepositoryOperations", route: "deno.ts" }, + { name: "DenoRepositoryOperations", route: "deno.ts" }, + { name: "denoPullRequestOperations", route: "deno.ts" }, + { name: "DenoPullRequestOperations", route: "deno.ts" }, + { name: "AdmittedReadExecution", route: "deno.ts" }, + { name: "useDenoGitHubIssues", route: "deno.ts" }, + // The neutral locator grammar has one route, and it is the portable one: the + // provider-neutral record codecs in the REPL command read it, and the Deno + // entrypoint reaches native adapters and Workflow storage. + { name: "admitLocator", route: "mod.ts" }, +]; + +describe("the amended credential boundary", () => { + it("publishes none of the withheld host, session or raw-operation names", function* () { + const violations: string[] = []; + for (const route of ["mod.ts", "api.ts", "deno.ts", "credential-helper.ts"]) { + const names = exportedNames(yield* readTextFile(join(REPOSITORY, PACKAGE, route))); + // Non-vacuity: an unreadable entrypoint would publish "nothing" and make + // every row below innocent. + expect(`${route}: ${names.length > 0}`).toBe(`${route}: true`); + for (const name of WITHHELD) { + if (names.includes(name)) { + violations.push(`${route} publishes ${name}`); + } + } + } + expect(violations).toEqual([]); + }); + + it("publishes each amended member from exactly its own route", function* () { + const published = new Map(); + for (const route of ["mod.ts", "api.ts", "deno.ts", "credential-helper.ts"]) { + published.set(route, exportedNames(yield* readTextFile(join(REPOSITORY, PACKAGE, route)))); + } + const wrong: string[] = []; + for (const { name, route } of AMENDED_ROUTES) { + for (const [where, names] of published) { + const carries = names.includes(name); + if (where === route && !carries) { + wrong.push(`${route} is missing ${name}`); + } + if (where !== route && carries) { + wrong.push(`${where} also publishes ${name}`); + } + } + } + expect(wrong).toEqual([]); + }); + + it("keeps the provider-neutral record codecs out of the Deno graph", function* () { + // These two read the neutral locator grammar and nothing native. Reaching + // it through the Deno entrypoint would pull native adapters and Workflow + // storage into a module whose whole point is that it runs anywhere. + const neutral = [ + "packages/cli/src/repl/repositories/push-records.ts", + "packages/cli/src/repl/repositories/pull-request-records.ts", + ]; + const violations: string[] = []; + for (const consumer of neutral) { + const source = yield* readTextFile(join(REPOSITORY, consumer)); + expect(`${consumer}: ${source.length > 0}`).toBe(`${consumer}: true`); + for (const { specifier } of importedNames(source)) { + if (specifier === "@executablemd/git/deno") { + violations.push(`${consumer} imports from ${specifier}`); + } + } + } + expect(violations).toEqual([]); + }); +}); diff --git a/packages/git/tests/plugin.test.ts b/packages/git/tests/plugin.test.ts index 97b7c247d..1eba55562 100644 --- a/packages/git/tests/plugin.test.ts +++ b/packages/git/tests/plugin.test.ts @@ -218,18 +218,28 @@ function* record( describe("the Git Plugin's command profile", () => { it("declares for the commands that execute or describe a document", function* () { - for (const command of ["run", "plan", "syntax", "repl"]) { + for (const command of ["run", "plan", "syntax"]) { expect(declaresFor({ command, args: [command] })).toBe(true); } }); - it("declares for the REPL, which runs an entry under the ordinary profile", function* () { - // Explicit rather than incidental: the REPL runs a document a person typed, - // so the Git vocabulary is available there exactly as it is to `run`. A - // Plugin that only meant `run` is a different question, and `repl` arriving - // under its own name is what lets one answer it. - expect(declaresFor({ command: "repl", args: ["repl"] })).toBe(true); - expect(declaresFor({ command: "repl", args: ["repl", "xmd://repl/one/repl"] })).toBe(true); + it("declares nothing for the REPL, whose entry history is another package's", function* () { + // The REPL runs a document a person typed, so the Git vocabulary is + // available there exactly as it is to `run` — but not from here. What an + // entry wrote down about the checkout it was standing in is `xmd repl`'s + // own record, and the admission that reads it back has to be inside the + // same contribution as the vocabulary, because core applies an admission + // within each execution's own journal read. So that command assembles this + // Plugin itself and contributes both together, and this predicate declines + // `repl` rather than declaring a vocabulary with no admission beside it. + // + // What the assembled value declares for `repl` is held where the assembly + // is, in `packages/cli/tests/plugin-cli.test.ts` and + // `packages/cli/tests/run-profile.test.ts`. + expect(declaresFor({ command: "repl", args: ["repl"] })).toBe(false); + expect(declaresFor({ command: "repl", args: ["repl", "xmd://repl/one/repl"] })).toBe(false); + // And declining is specific to `repl`: the predicate still answers for a + // command that arrives under its own name. const runOnly = (request: PluginInstallRequest): boolean => request.command === "run"; expect(runOnly({ command: "repl", args: ["repl"] })).toBe(false); expect(runOnly({ command: "run", args: ["run", "doc.md"] })).toBe(true); diff --git a/packages/git/tests/public-entrypoint.test.ts b/packages/git/tests/public-entrypoint.test.ts index 472d6222b..9111d4b4c 100644 --- a/packages/git/tests/public-entrypoint.test.ts +++ b/packages/git/tests/public-entrypoint.test.ts @@ -57,8 +57,13 @@ const ROOT_PUBLISHED: readonly string[] = ["gitPlugin", "gitPluginDeclaresFor"]; * * Listed so that losing one is a failure rather than a silent narrowing: a * host configuring a ceiling needs the recognizer, a document naming a - * provider needs the discriminator, and the REPL host needs the one operation - * an executing entry initializes its recording with. + * provider needs the discriminator, and a host that retains entry work needs + * the three domain handles — native repository work, pull requests, and the + * issue transport default — without being handed a session or a credential. + * + * The REPL entry's own recording is no longer here. What an entry retains is + * the command's record rather than this package's, so the policy that writes it + * lives with the command and asks for these handles instead. */ const PUBLISHED: readonly string[] = [ "GITHUB", @@ -70,7 +75,9 @@ const PUBLISHED: readonly string[] = [ "recognizesGitHubUrl", "useGitHubIssues", "useGitHubPullRequests", - "useReplRepositoryRecording", + "denoPullRequestOperations", + "useDenoGitHubIssues", + "useDenoRepositoryOperations", ]; /** diff --git a/packages/git/tests/repository-replay.test.ts b/packages/git/tests/repository-replay.test.ts index 179817104..159e2ac98 100644 --- a/packages/git/tests/repository-replay.test.ts +++ b/packages/git/tests/repository-replay.test.ts @@ -10,11 +10,13 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, spawn, suspend, until, withResolvers } from "effection"; +import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; -import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { exists, lstat, readTextFile } from "@effectionx/fs"; +import { chmod, mkdtemp, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { exists, lstat, readTextFile, writeTextFile } from "@effectionx/fs"; import { tmpdir } from "node:os"; +import process from "node:process"; import { join } from "node:path"; import { StaleInputError } from "@executablemd/durable-streams"; import { @@ -24,6 +26,10 @@ import { import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { exportTree, importTree } from "../src/deno/composition/materialize.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; +import { writeMetadata } from "../src/deno/run-composition/checkouts.ts"; +import { metadataOf } from "../src/deno/run-composition/checkout-layout.ts"; +import { METADATA_VERSION } from "../src/deno/run-composition/metadata.ts"; +import type { ManagedRepositoryMetadata } from "../src/deno/run-composition/metadata.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; @@ -775,3 +781,374 @@ describe("workflow Repository substituted checkouts", () => { }); }); }); + +/** What a watched `git` child reported about its own lifetime. */ +interface WatchedChildren { + /** Resolves once a watched child has been spawned. */ + readonly spawned: Operation; + /** Handed to the host, which forwards it to the one place a child exists. */ + readonly observe: NonNullable; + observed(): number; + /** Whether every observed child has closed both of its pipes. */ + closed(): boolean; + /** Whether any observed process is still running. */ + alive(): boolean; +} + +/** + * Watch the real children one invocation spawns. + * + * The production host forwards this to the one place a child exists, which is + * the only point at which a case can see the process a command really started. + * The row above this one halts a clone that had not been delegated to yet; what + * these two need is the other case, where the process is running. + */ +function watchChildren(): WatchedChildren { + const pids: number[] = []; + let count = 0; + let closes = 0; + const first = withResolvers(); + return { + spawned: first.operation, + observe(child): void { + count += 1; + if (child.pid !== undefined) { + pids.push(child.pid); + } + child.on("close", () => { + closes += 1; + }); + first.resolve(); + }, + observed: () => count, + closed: () => count > 0 && closes === count, + alive: () => + pids.some((pid) => { + try { + // Signal 0 asks the operating system whether the process is there. + process.kill(pid, 0); + return true; + } catch { + return false; + } + }), + }; +} + +/** A script that reports it ran and then waits, holding its pipes open. */ +function* waitingScript(path: string, marker: string): Operation { + yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); + yield* until(chmod(path, 0o755)); +} + +/** Wait until this path is there, or say what never appeared. */ +function* appears(path: string): Operation { + const deadline = Date.now() + 30_000; + while (!(yield* exists(path))) { + if (Date.now() > deadline) { + throw new Error(`${path} never appeared`); + } + yield* sleep(25); + } +} + +/** The production host, observing one named subcommand's child. */ +function observingHost( + watched: WatchedChildren, + subcommand: string, + settings: readonly string[], + roots: string[], +) { + const inner = denoRepositoryHost(); + return { + *git(invocation: GitInvocation): Operation { + if (invocation.args.includes(subcommand)) { + return yield* inner.git({ + ...invocation, + args: [...settings, ...invocation.args], + observe: watched.observe, + }); + } + return yield* inner.git(invocation); + }, + *useDirectory(): Operation { + const directory = yield* inner.useDirectory(); + roots.push(directory); + return directory; + }, + }; +} + +/** + * What a selector's own child is, and when it is joined. + * + * The halted-clone row above cuts *before* the command is delegated to, so it + * says nothing about a process. These two cut while one is running: the clone + * inside its own transport, and the `worktree add` inside the hook it runs + * after the checkout is already on disk. Neither is observable above the + * invocation — a wrapper around a repository operation sees the call and its + * answer and never the child between them, so residue inspected afterwards + * cannot tell an interrupted command from one that never began. + */ +describe("workflow Repository selector child teardown", () => { + it("joins a running clone's own descendant before the selector finishes", function* () { + const root = yield* useStorageRoot(); + const home = yield* until(mkdtemp(join(tmpdir(), "xmd-clone-child-"))); + yield* ensure(() => until(rm(home, { recursive: true, force: true }))); + const reached = join(home, "transport-ran"); + const transport = join(home, "transport"); + yield* waitingScript(transport, reached); + const watched = watchChildren(); + const roots: string[] = []; + // A locator nothing can answer, reached through a transport that reports it + // was entered and then waits. Git creates the destination only once the + // connection succeeds, so this cut leaves no checkout behind — what it + // discriminates is the teardown. + const locator = "ssh://example.invalid/repo.git"; + + yield* withStorage(root, function* () { + const database = yield* createRun(); + yield* scoped(function* () { + const task = yield* spawn(() => + runDocument(database, ``, { + composition: { + host: observingHost(watched, "clone", ["-c", `core.sshCommand=${transport}`], roots), + }, + }), + ); + yield* watched.spawned; + // Not merely spawned: inside its own transport, with a descendant of + // its own waiting. Halting before that would interrupt a command which + // had done nothing, which is the hole this row closes. + yield* appears(reached); + // The guard against a vacuous row: a clone that had already ended would + // make everything below true without anything being interrupted. + expect(watched.closed()).toBe(false); + // Teardown kills the process group and waits for `close`, so this does + // not return until the child and its descendant are gone. + yield* task.halt(); + }); + + expect(watched.observed()).toBe(1); + expect(watched.closed()).toBe(true); + expect(watched.alive()).toBe(false); + expect(yield* retainedRepositories(database)).toHaveLength(0); + const surviving: string[] = []; + for (const directory of roots) { + if (yield* exists(directory)) { + surviving.push(directory); + } + } + expect(surviving).toEqual([]); + }); + }); + + it("joins a running worktree add's own hook before the selector finishes", function* () { + const root = yield* useStorageRoot(); + const remote = yield* useBareRemote(REMOTE); + const home = yield* until(mkdtemp(join(tmpdir(), "xmd-worktree-child-"))); + yield* ensure(() => until(rm(home, { recursive: true, force: true }))); + // `worktree add` runs `post-checkout` in the checkout it has just made, so + // the marker also says the partial state this row is about is on disk. + const hooks = join(home, "hooks"); + yield* until(mkdir(hooks, { recursive: true })); + const checkedOut = join(home, "post-checkout-ran"); + yield* waitingScript(join(hooks, "post-checkout"), checkedOut); + const watched = watchChildren(); + const roots: string[] = []; + + yield* withStorage(root, function* () { + const database = yield* createRun(); + yield* scoped(function* () { + const task = yield* spawn(() => + runDocument( + database, + [ + ``, + '', + "", + ].join("\n"), + { + composition: { + host: observingHost(watched, "worktree", ["-c", `core.hooksPath=${hooks}`], roots), + }, + }, + ), + ); + yield* watched.spawned; + yield* appears(checkedOut); + expect(watched.closed()).toBe(false); + yield* task.halt(); + }); + + expect(watched.observed()).toBe(1); + expect(watched.closed()).toBe(true); + expect(watched.alive()).toBe(false); + // The repository was selected; the worktree never became a record. + expect(yield* retainedRepositories(database)).toHaveLength(1); + expect(yield* retainedWorktrees(database, "project")).toHaveLength(0); + + // And the checkout the cut left does not block the next attempt. The + // hook is what makes this window reachable: `worktree add` runs + // `post-checkout` after the checkout exists, so the next attempt meets a + // real checkout that no sidecar describes — and still ends with the slot + // described rather than refusing over it. Whether that attempt proves the + // bytes or writes them again is the selector's own business and is not + // asserted here. + yield* runDocument( + database, + [ + ``, + '', + "", + ].join("\n"), + ); + expect(yield* retainedWorktrees(database, "project")).toHaveLength(1); + }); + }); +}); + +/** Let every ready task run, so "reached its wait" is about turns. */ +function* settled(turns = 8): Operation { + for (let turn = 0; turn < turns; turn += 1) { + yield* sleep(0); + } +} + +/** + * A promise a case releases by hand. + * + * What makes the two filesystem cuts below deterministic rather than a race: an + * acquisition is started, the halt is delivered while it is demonstrably still + * pending, and only then is it allowed to deliver. + */ +function gate(): { readonly released: Promise; release(): void } { + let open = (): void => {}; + const released = new Promise((resolve) => { + open = () => resolve(); + }); + return { released, release: () => open() }; +} + +/** The sidecar the metadata cut below writes, which is ordinary record data. */ +const REPOSITORY_SIDECAR: ManagedRepositoryMetadata = Object.freeze({ + kind: "repository" as const, + version: METADATA_VERSION, + name: "project", + locator: "/origin", + locatorFingerprint: "a".repeat(64), + requestedBase: null, + creationCommit: "b".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + commonDirectory: "/origin/.git", +}); + +/** + * What a halt does to an acquisition that has not delivered yet. + * + * Both of these are about the leaves this host owns rather than about any + * entry: a descriptor and a temporary directory are each created by a call that + * can be interrupted between starting and returning, and a teardown that did + * not wait would leak exactly what it could not see. Neither window is + * reachable from above the host — a caller holding a repository operation sees + * the operation and its answer, and the acquisition happens inside. + */ +describe("what the repository host's own acquisitions do when halted", () => { + it("waits for a pending metadata open and closes what it delivers", function* () { + const parent = yield* until(mkdtemp(join(tmpdir(), "xmd-metadata-slot-"))); + yield* ensure(() => until(rm(parent, { recursive: true, force: true }))); + const slot = join(parent, "metadata-slot"); + yield* until(mkdir(slot, { recursive: true })); + + const asked = withResolvers(); + const held = gate(); + let closed = false; + let opened = false; + // The open is started and then held, so the halt below lands in exactly the + // window this ownership discipline exists for: after the acquisition began + // and before its descriptor arrived. + const openExclusive = (path: string): Promise => { + asked.resolve(path); + return held.released + .then(() => open(path, "wx")) + .then((handle) => { + opened = true; + const close = handle.close.bind(handle); + return Object.assign(handle, { + close: (): Promise => { + closed = true; + return close(); + }, + }); + }); + }; + + let returned = false; + yield* scoped(function* (): Operation { + const writing = yield* spawn(() => writeMetadata(slot, REPOSITORY_SIDECAR, openExclusive)); + const temporary = yield* asked.operation; + const halting = yield* spawn(function* (): Operation { + yield* writing.halt(); + returned = true; + }); + yield* settled(); + // Teardown is waiting for the acquisition it armed its release before. + expect(returned).toBe(false); + expect(opened).toBe(false); + + held.release(); + yield* halting; + expect(temporary.startsWith(`${metadataOf(slot)}.`)).toBe(true); + }); + + // It waited for delivery, and closed what was delivered. + expect(returned).toBe(true); + expect(opened).toBe(true); + expect(closed).toBe(true); + // And published nothing: a sidecar is renamed into place only by a write + // that finished. + expect(yield* exists(metadataOf(slot))).toBe(false); + }); + + it("waits for a pending isolation directory and removes what it delivers", function* () { + const made: string[] = []; + const asked = withResolvers(); + const held = gate(); + const create = (prefix: string): Promise => { + asked.resolve(); + return held.released + .then(() => mkdtemp(prefix)) + .then((directory) => { + made.push(directory); + return directory; + }); + }; + const host = denoRepositoryHost({ directories: { create } }); + + let returned = false; + yield* scoped(function* (): Operation { + const taking = yield* spawn(function* (): Operation { + yield* host.useDirectory(); + yield* suspend(); + }); + yield* asked.operation; + const halting = yield* spawn(function* (): Operation { + yield* taking.halt(); + returned = true; + }); + yield* settled(); + // The creation is still pending, and teardown has not finished over it. + expect(returned).toBe(false); + expect(made).toEqual([]); + + held.release(); + yield* halting; + }); + + expect(returned).toBe(true); + // It waited for the directory to arrive, and removed it. + expect(made.length).toBe(1); + expect(yield* exists(made[0] ?? "")).toBe(false); + }); +}); diff --git a/packages/git/tests/run-composition-lazy.test.ts b/packages/git/tests/run-composition-lazy.test.ts index d878e820a..0f391f1ac 100644 --- a/packages/git/tests/run-composition-lazy.test.ts +++ b/packages/git/tests/run-composition-lazy.test.ts @@ -20,7 +20,15 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { all, scoped, spawn } from "effection"; import type { Operation } from "effection"; +import { readTextFile } from "@effectionx/fs"; import { useTempDirectory } from "@executablemd/test-support/temp"; +import { useBareRemote } from "./support/git-remotes.ts"; +import { REMOTE } from "./support/run-composition-tier.ts"; +import { join } from "node:path"; +import { useDenoRepositoryOperations } from "../deno.ts"; +import { repositoryOperationsOver } from "../src/deno/repository-operations.ts"; +import type { DenoRepositoryOperations } from "../deno.ts"; +import type { HelperAssembly } from "../credential-helper.ts"; import { collect, execute, inlineSource } from "@executablemd/core"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useHostFiles } from "@executablemd/runtime"; @@ -156,3 +164,159 @@ function* ambient(): Operation { return false; } } + +/** The twelve credential-free methods this package publishes, and only these. */ +const PUBLISHED_METHODS: readonly string[] = [ + "add", + "ambientCheckout", + "ambientRepository", + "commit", + "commitPresent", + "currentBranch", + "observe", + "pushProvider", + "resolveCommit", + "selectRepository", + "selectWorktree", + "switch", +]; + +/** + * What the published repository handle is, before anybody asks it anything. + * + * The constructor is what a retaining host calls once per execution, so what it + * costs and what it hands back are both part of the contract. Counted at the + * leaves for the cost, read off the value for the shape, and read out of the + * source for the return graph — because a value that happens to carry no + * session today proves nothing about what a later edit could return. + */ +describe("what the published repository handle is, and what constructing it costs", () => { + it("acquires nothing when it is built, over the leaves it would acquire from", function* () { + const seen = counters(); + yield* scoped(function* () { + const directory = yield* useTempDirectory("xmd-handle"); + // The builder the public factory delegates to, over a counting leaf. The + // host is the one this construction would acquire *through*, so a + // constructor that took a directory, ran a command or read an identity + // would show up here. A control that counted some other host could not + // observe this at all. + const operations = yield* repositoryOperationsOver(counting(seen, directory)); + expect(typeof operations.ambientCheckout).toBe("function"); + // Still nothing, with the handle built and in hand. + expect(seen.directories).toBe(0); + expect(seen.commands).toEqual([]); + expect(seen.identities).toBe(0); + }); + // And nothing on the way out either: there was no resource to release. + expect(seen.directories).toBe(0); + expect(seen.commands).toEqual([]); + expect(seen.identities).toBe(0); + }); + + it("acquires its session from that same host, once, when something first asks", function* () { + const seen = counters(); + yield* scoped(function* () { + const directory = yield* useTempDirectory("xmd-handle-lazy"); + const operations = yield* repositoryOperationsOver(counting(seen, directory)); + // Two asks through two different methods. The session behind them is + // acquired from this host, and acquired once — which is also what makes + // the zero above a statement about *when* rather than about whether. + expect(yield* operations.ambientCheckout(directory)).toBe(undefined); + expect(yield* operations.ambientRepository(directory)).toBe(undefined); + expect(seen.directories).toBe(1); + expect(seen.commands.length > 0).toBe(true); + // Every command went to this host: the handle delegates to the leaf it + // was built over and to nothing else. + expect(seen.commands.every((args) => args.length > 0)).toBe(true); + }); + }); + + it("builds that same implementation when a caller states no leaf at all", function* () { + // The public default path and the builder above are one implementation, so + // what the two controls above establish is true of the published factory. + const source = yield* readTextFile( + join(import.meta.dirname ?? "", "../src/deno/repository-operations.ts"), + ); + const published = source.slice( + source.indexOf("export function useDenoRepositoryOperations"), + source.indexOf("export function* repositoryOperationsOver"), + ); + expect(published.length > 0).toBe(true); + // Its whole body is that delegation, over the one shipped host it builds. + expect(published).toContain("return repositoryOperationsOver(denoRepositoryHost("); + // And the handles agree member for member, which a second implementation + // would have to keep in step by hand. + yield* scoped(function* () { + const directory = yield* useTempDirectory("xmd-handle-default"); + const byDefault = yield* useDenoRepositoryOperations(); + const built = yield* repositoryOperationsOver(counting(counters(), directory)); + expect(Object.keys(byDefault).sort()).toEqual(Object.keys(built).sort()); + }); + }); + + it("hands back exactly the twelve credential-free methods, and nothing else", function* () { + const operations: DenoRepositoryOperations = yield* useDenoRepositoryOperations(); + // Every own name, so a member added beside the twelve is a failure here + // rather than a surprise at a caller — and every own member is a function. + // A handle that carried a session, a host or an open attachment would have + // to hand it over as a value, and naming the members and their types + // together is what says there is nowhere here for one to be. That the + // three private surfaces are not published at all is held by + // `public-entrypoint.test.ts`. + const values = Object.entries(operations).map(([name, held]) => `${name}: ${typeof held}`); + expect([...values].sort()).toEqual(PUBLISHED_METHODS.map((name) => `${name}: function`)); + }); + + it("returns a push provider holding exactly observe and perform", function* () { + const remote = yield* useBareRemote(REMOTE); + yield* scoped(function* () { + const operations = yield* useDenoRepositoryOperations(); + const provider = yield* operations.pushProvider( + join(remote.locator, "objects"), + remote.locator, + { + repository: { + name: "project", + locatorFingerprint: "a".repeat(64), + requestedBase: null, + creationCommit: "b".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + }, + remote: "origin", + branch: "review/control", + destinationRef: "refs/heads/review/control", + sourceCommit: "c".repeat(40), + }, + ); + // Two operations and nothing else: the scratch repository and the + // credential session this acquired belong to the attempt that asked, and + // neither is a member a caller could reach. + expect(Object.keys(provider).sort()).toEqual(["observe", "perform"]); + }); + }); + + it("copies the helper it was given, and neither freezes nor mutates it", function* () { + const launcher: Record = { TOKEN: "first" }; + const helper: { -readonly [K in keyof HelperAssembly]: HelperAssembly[K] } = { + runtime: "source", + platform: "unix", + execPath: "/usr/bin/xmd", + launcherEnvironment: launcher, + }; + yield* scoped(function* () { + yield* useDenoRepositoryOperations(helper); + // Changed after the handle was built, which a handle holding the + // caller's own object would be configured by. + launcher["TOKEN"] = "second"; + helper.platform = "windows"; + }); + // The caller's object is left exactly as it was handed over: not frozen, + // and not written through. What a later edit must not do is reach the + // handle, and the handle read its configuration once, at construction. + expect(launcher["TOKEN"]).toBe("second"); + expect(helper.platform).toBe("windows"); + expect(Object.isFrozen(helper)).toBe(false); + expect(Object.isFrozen(launcher)).toBe(false); + }); +}); diff --git a/packages/git/tests/support/issue-providers.ts b/packages/git/tests/support/issue-providers.ts index 333fddf8a..e19465a22 100644 --- a/packages/git/tests/support/issue-providers.ts +++ b/packages/git/tests/support/issue-providers.ts @@ -21,6 +21,10 @@ import type { GitHubAccess, GitHubHttpResponse } from "../../src/deno/compositio import { credentialFor } from "./issue-tracker-server.ts"; import type { CredentialCondition } from "./issue-tracker-server.ts"; import { gitHubSource } from "../../src/deno/composition/github.ts"; +import { issueIdempotencyKey } from "../../src/issue/records.ts"; +import type { IssueEffectIdentity } from "../../src/issue/records.ts"; +import type { IssueOperation } from "../../src/issue/api.ts"; +import { issueOriginMarker } from "../../src/deno/issue/github.ts"; /** What a scenario can watch a provider do. */ export interface ProviderLog { @@ -345,3 +349,37 @@ export function* useKeyRecorder(log: ProviderLog): Operation { }, }); } + +/** + * One fixture transport, as a host supplies one to the issue installer. + * + * Here rather than at the caller: `gitHubSource` is this package's private + * implementation, and a consumer that reached it to build a fixture would make + * the package boundary untrue in a test. A caller states the endpoint and the + * credential condition; what wraps them stays on this side. + */ +export function gitHubFixtureSource( + endpoint: string, + conditions: { + credential: CredentialCondition; + failsTransport?: boolean; + interruptsAfterCreate?: boolean; + }, +): ReturnType { + return gitHubSource(gitHubAccessFor(endpoint, conditions)); +} + +/** + * The mark one request's own identity and target produce. + * + * A caller asserting that the provider carried *its* mark rather than one the + * middleware supplied needs the value, not the two private functions that + * compute it. + */ +export function expectedOriginMarker( + identity: IssueEffectIdentity, + operation: IssueOperation, + target: string, +): string { + return issueOriginMarker(issueIdempotencyKey(identity, operation, target)); +} diff --git a/scripts/bench-repl-file-dir.ts b/scripts/bench-repl-file-dir.ts index ae34d4a12..a7f245fda 100644 --- a/scripts/bench-repl-file-dir.ts +++ b/scripts/bench-repl-file-dir.ts @@ -62,13 +62,14 @@ import { useCompositionComponents } from "../packages/git/src/composition/instal import { useRunComposition } from "../packages/git/src/deno/run-composition/provider.ts"; import type { HelperAssembly } from "../packages/git/src/deno/composition/credential-helper.ts"; import { Execution } from "@executablemd/core/api"; -import { useReplRepositoryRecording } from "../packages/git/src/deno/retained-repository/provider.ts"; -import { admitReplRepositoryHistory } from "../packages/git/src/retained-repository/admission.ts"; -import { denoRepositoryHost } from "../packages/git/src/deno/composition/host.ts"; +import { useReplRepositoryRecording } from "../packages/cli/src/repl/repositories/deno/provider.ts"; +import { admitReplRepositoryHistory } from "../packages/cli/src/repl/repositories/admission.ts"; +import { useDenoRepositoryOperations } from "@executablemd/git/deno"; +import type { DenoRepositoryOperations } from "@executablemd/git/deno"; import { REPL_REPOSITORY_LOCAL, REPL_REPOSITORY_SELECTION, -} from "../packages/git/src/retained-repository/records.ts"; +} from "../packages/cli/src/repl/repositories/records.ts"; /** What one invocation was asked to measure. */ interface Settings { @@ -198,14 +199,24 @@ function countingFiles(counted: Counters): FilesHandler { }; } -function countingHost(counted: Counters) { - const host = denoRepositoryHost(); - return { - git(invocation: Parameters[0]) { - counted.commands += 1; - return host.git(invocation); - }, - useDirectory: () => host.useDirectory(), +/** + * The platform's own repository work, with the one call this benchmark counts. + * + * `ambientCheckout` is what a live file operation asks before it knows whether + * it is inside a checkout, and counting it is the measurement: a replayed + * operation asks nothing. Every other method delegates untouched, and no + * session, attachment or credential crosses this wrapper. + */ +function countingOperations(counted: Counters): () => Operation { + return function* (): Operation { + const operations = yield* useDenoRepositoryOperations(); + return { + ...operations, + *ambientCheckout(from: string) { + counted.commands += 1; + return yield* operations.ambientCheckout(from); + }, + }; }; } @@ -401,7 +412,7 @@ function measure( // the timed region along with the first lazy acquisitions it leads to. yield* Execution.around({ *init(_args, next): Operation { - yield* useReplRepositoryRecording({ host: countingHost(counted) }); + yield* useReplRepositoryRecording({ operations: countingOperations(counted) }); yield* next(); }, }); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 36007b097..4d923248e 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -701,31 +701,31 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ issue: DERIVED_SCOPE, }, { - path: "packages/git/tests/retained-repository-observations-deno.test.ts", + path: "packages/cli/tests/retained-repository-observations-deno.test.ts", reason: - "the subject is the native side of a REPL entry's repository host: cancelling a finished read's append unwinds a Deno-backed Files provider, a held read excludes the same entry's mutation only once that mutation reaches a real `git` child, and another holder's mutation lease is the same kernel-released advisory lock. The record format, its admission, every refusal and the rest of cancellation and persistence run on every runtime in packages/git/tests/retained-repository-observations.test.ts", + "the subject is the native side of a REPL entry's repository host: cancelling a finished read's append unwinds a Deno-backed Files provider, a held read excludes the same entry's mutation only once that mutation reaches a real `git` child, and another holder's mutation lease is the same kernel-released advisory lock. The record format, its admission, every refusal and the rest of cancellation and persistence run on every runtime in packages/cli/tests/retained-repository-observations.test.ts", issue: DERIVED_SCOPE, }, { - path: "packages/git/tests/retained-repository-local.test.ts", + path: "packages/cli/tests/retained-repository-local.test.ts", reason: - "the subject is a REPL entry's native repository host: it discovers a real checkout with a `git` subprocess and records what a real filesystem answered. The record format and the admission are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-records.test.ts", + "the subject is a REPL entry's native repository host: it discovers a real checkout with a `git` subprocess and records what a real filesystem answered. The record format and the admission are provider-neutral and are proven on every runtime by packages/cli/tests/retained-repository-records.test.ts", issue: DERIVED_SCOPE, }, { - path: "packages/git/tests/retained-repository-selection.test.ts", + path: "packages/cli/tests/retained-repository-selection.test.ts", reason: - "the subject is a REPL entry's managed selection: it clones and adds worktrees with real `git` children, holds their slots under a kernel-released exclusive advisory lock taken through the Deno runtime, and interrupts those children to observe their close. The placement record and its admission are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-records.test.ts; that a runtime without the provider refuses a managed selector before it reaches a clone is proven by packages/cli/tests/repl-repositories-pty.test.ts, which runs everywhere", + "the subject is a REPL entry's managed selection: it clones and adds worktrees with real `git` children, holds their slots under a kernel-released exclusive advisory lock taken through the Deno runtime, and interrupts those children to observe their close. The placement record and its admission are provider-neutral and are proven on every runtime by packages/cli/tests/retained-repository-records.test.ts; that a runtime without the provider refuses a managed selector before it reaches a clone is proven by packages/cli/tests/repl-repositories-pty.test.ts, which runs everywhere", issue: DERIVED_SCOPE, }, { - path: "packages/git/tests/retained-repository-git.test.ts", + path: "packages/cli/tests/retained-repository-git.test.ts", reason: - "the subject is a REPL entry's native local Git: it switches branches, stages pathspecs and commits the index with real `git` children in a real checkout, holds that checkout's mutation lease under a kernel-released exclusive advisory lock taken through the Deno runtime, and interrupts a command whose descendant is holding the inherited pipes. The record format and its admission are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-records.test.ts; that a runtime without the provider refuses , and before any native operation is proven by packages/cli/tests/repl-repositories-pty.test.ts, which runs everywhere", + "the subject is a REPL entry's native local Git: it switches branches, stages pathspecs and commits the index with real `git` children in a real checkout, holds that checkout's mutation lease under a kernel-released exclusive advisory lock taken through the Deno runtime, and interrupts a command whose descendant is holding the inherited pipes. The record format and its admission are provider-neutral and are proven on every runtime by packages/cli/tests/retained-repository-records.test.ts; that a runtime without the provider refuses , and before any native operation is proven by packages/cli/tests/repl-repositories-pty.test.ts, which runs everywhere", issue: DERIVED_SCOPE, }, { - path: "packages/git/tests/retained-repository-lifecycle.test.ts", + path: "packages/cli/tests/retained-repository-lifecycle.test.ts", reason: "holds a shared Git directory under a kernel-released exclusive advisory lock taken through the Deno runtime, and spawns a second Deno process to prove the cross-process half; Node and Bun expose no equivalent", issue: DERIVED_SCOPE, @@ -749,15 +749,21 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ issue: DERIVED_SCOPE, }, { - path: "packages/git/tests/retained-repository-push.test.ts", + path: "packages/cli/tests/retained-repository-push.test.ts", reason: - "the subject is a REPL entry's native publication: it clones a managed checkout, reads and pushes to a real bare remote with real `git` children, holds that checkout's managed slot under a kernel-released exclusive advisory lock taken through the Deno runtime, and cuts an entry mid-command. The saved request, its admission and the owner-specified worker are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-push-records.test.ts and packages/git/tests/git-host-owned.test.ts; that a runtime without the provider refuses before any native operation is proven by packages/cli/tests/repl-push-pty.test.ts, which runs everywhere", + "the subject is a REPL entry's native publication: it clones a managed checkout, reads and pushes to a real bare remote with real `git` children, holds that checkout's managed slot under a kernel-released exclusive advisory lock taken through the Deno runtime, and cuts an entry mid-command. The saved request, its admission and the owner-specified worker are provider-neutral and are proven on every runtime by packages/cli/tests/retained-repository-push-records.test.ts and packages/git/tests/git-host-owned.test.ts; that a runtime without the provider refuses before any native operation is proven by packages/cli/tests/repl-push-pty.test.ts, which runs everywhere", issue: DERIVED_SCOPE, }, { - path: "packages/git/tests/retained-repository-history.test.ts", + path: "packages/cli/tests/retained-repository-pull-request.test.ts", reason: - "continues seven histories the merged implementation wrote through the production per-entry capability, so its module graph reaches @executablemd/git/deno — the native repository host, the GitHub issue adapter and Workflow's storage adapter with node:sqlite. The construction those histories are continued through is provider-neutral and is proven on every runtime by packages/git/tests/composition-component-binding.test.ts; the records themselves are proven everywhere by packages/git/tests/retained-repository-push-records.test.ts and packages/git/tests/issue-owned.test.ts", + "the subject is a REPL entry's native pull request: it clones a managed checkout and publishes to a real bare remote with real `git` children, holds that checkout's managed slot under a kernel-released exclusive advisory lock taken through the Deno runtime, and reaches a GitHub model over a loopback listener, so its module graph reaches @executablemd/git/deno and with it Workflow's storage adapter and node:sqlite. The saved request, the mixed Push and pull-request admission and the owner-specified worker are provider-neutral and are proven on every runtime by packages/cli/tests/retained-repository-pull-request-records.test.ts and packages/git/tests/git-host-owned.test.ts; that a runtime without the provider refuses and its evidence reads before any native or remote operation is proven by packages/cli/tests/repl-push-pty.test.ts, which runs everywhere", + issue: "https://github.com/taras/executable.md/issues/967", + }, + { + path: "packages/cli/tests/retained-repository-history.test.ts", + reason: + "continues seven histories the merged implementation wrote through the production per-entry capability, so its module graph reaches @executablemd/git/deno — the native repository host, the GitHub issue adapter and Workflow's storage adapter with node:sqlite. The construction those histories are continued through is provider-neutral and is proven on every runtime by packages/git/tests/composition-component-binding.test.ts; the records themselves are proven everywhere by packages/cli/tests/retained-repository-push-records.test.ts and packages/git/tests/issue-owned.test.ts", issue: "https://github.com/taras/executable.md/issues/181", }, { diff --git a/scripts/tests/repl-push-compiled.test.ts b/scripts/tests/repl-push-compiled.test.ts index 55de2a06d..fa4ea7d20 100644 --- a/scripts/tests/repl-push-compiled.test.ts +++ b/scripts/tests/repl-push-compiled.test.ts @@ -8,8 +8,8 @@ * the publication, and the defect would appear at a person's first attempt to * publish rather than here. * - * It runs the *same* journey as the source row, from one shared module, so the - * two cannot drift into asserting different things. + * It runs the *same* journeys as the source rows, from the same shared modules, + * so the two cannot drift into asserting different things. * * It runs against `dist/xmd`, so `deno task build` has to have happened. A * missing binary is reported as the setup it is rather than as a failure of the @@ -23,6 +23,7 @@ import type { Operation } from "effection"; import { fileURLToPath as fromFileUrl } from "node:url"; import { join } from "node:path"; import { replPushJourney } from "../../packages/cli/tests/support/repl-push-journey.ts"; +import { replPullRequestJourney } from "../../packages/cli/tests/support/repl-pull-request-journey.ts"; const ROOT = fromFileUrl(new URL("../..", import.meta.url)); const BINARY = join(ROOT, "dist", "xmd"); @@ -47,4 +48,9 @@ describe("the compiled binary's REPL publications", () => { yield* requireBinary(); yield* replPushJourney(compiled); }); + + it("B9: carries the pull-request capability, so one is opened and read once", function* (): Operation { + yield* requireBinary(); + yield* replPullRequestJourney(compiled); + }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index bc65bb4ce..1555b63fa 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3064,7 +3064,10 @@ available without the Git Plugin. They are one declaration source, read wherever the bundled Git Plugin's profile is assembled: an ordinary document execution, `xmd syntax`, `xmd plan`'s -validation and generation, and a workflow action that executes a document. +validation and generation, a workflow action that executes a document, and +`xmd repl` — which assembles that same bundled value with its own entry-history +admission rather than installing it directly, and so declares these names +through it. Six of them reach something no local transaction encloses — `Git.Push`, `PullRequest`, its three evidence reads and `Issue` — and each has exactly one @@ -10527,12 +10530,35 @@ configuration or a credential. An interrupted upsert presents the key it used the first time, which is what lets the provider answer with the issue it already accepted rather than filing a second one; a read has no key. -What the entry provider installs nothing of is pull requests, so -`` refuses there exactly as it does under a host that installs no -provider for it, and neither a retained local result nor a retained publication -authorizes one. Constructing it acquires nothing, including no managed root, no -commit identity, no credential and no configuration read, and a passive reading -of a settled history initializes nothing at all. +It installs the pull-request capability beside those, under Deno and the +compiled binary. `` opens or updates one for the branch this entry +published, retaining the request it asked for before anything is contacted and +the Git host's reconciled outcome beside it, so an interrupted upsert opens the +request it already named — the original number, title, verbatim body, draft +state, base and head — rather than resolving today's checkout, and a completed +one answers from its outcome with no provider constructed, no configuration read +and nothing contacted. What admits a fresh one is this entry's own last +acknowledged publication of that branch, from that checkout, at that commit: a +local result, another entry's history, another checkout's publication, a +synthetic handler answer and a failed or pending publication each authorize +nothing, and each refuses before a request, a credential or contact exists. The +upsert takes the selected placement's managed slot and no mutation lease. + +Its three evidence reads retain the provider's snapshot and nothing above it. +Each needs no Repository and no working directory, so an entry with no managed +root reads an allowed URL and still refuses an upsert for want of local +capability. Restoration passes the host's current URL admission and the live +composition around it, and a read authored after a question is a new occurrence +that reaches the service. Each dispatches through both ordinary chains — the +semantic one and the transport one beneath it — ending in a default private to +that one invocation, so a handler that answers without delegating opens no +session and retains nothing, and nested or concurrent reads cannot consume one +another's. + +Node and Bun install none of this, so all six refuse there as they do under any +host with no provider. Constructing the capability acquires nothing, including +no managed root, no commit identity, no credential and no configuration read, +and a passive reading of a settled history initializes nothing at all. The identity an entry's publication and issue work are named by is the entry's own: the execution and the entry's admission-order position, derived by the host @@ -14573,6 +14599,7 @@ acknowledgement. | # | Test | Verify | |---|------|--------| | EPB1 | The public journey | A real-terminal source and compiled `xmd repl` each select a managed Repository and a Worktree, commit inside the captured path through ``, publish the branch to a bare remote, leave at the question, have that branch advanced from a shell, reopen the printed location and reach the question with the remote still holding the commit that was published and no second push, then answer and run the suffix once; a private `git` on `PATH` forwarding unchanged to the real one records that the reopened prefix ran no command at all, the repository's primary branch is untouched throughout, and a new entry's `` advances the destination to what the checkout holds now | +| EPB1a | The public pull-request journey | The same two terminals each publish the branch, open one pull request over it and read its reviews, comments and checks, leave at the question, have the checkout advanced from a shell and the service's feedback changed, reopen the printed location and answer with the pull request's original number and URL and all three arrays restored — no second pull request opened, no collection asked for again and no `git` run at all — and the read authored after the question reaches the service and answers with the feedback that is there now; the service's own request log and the bare remote are what all of that is read from, and the shim maps the journey's logical locator to that remote on every delegation so the document still names the host its adapter is selected by | | EPB2 | Completed publication reaches nothing | A real unfinished prefix left at a question, holding an acknowledged request and outcome, reaches that question again after the managed checkout and the remote are removed, with zero `git` invocations, isolation-directory acquisitions, authentication sessions, object-source builds, provider constructions and live Git-host middleware calls, and appends no second publication; a genuinely new publication at another position takes the live path | | EPB3 | A pending request keeps what it asked for | A request acknowledged before any remote contact is reconciled after HEAD and the branch have moved, publishing the commit it named and not today's; a remote that accepted the branch before the outcome was recorded is adopted with zero second push; a cut taken before the request was acknowledged resolves live inputs instead, so a branch advanced while the entry was away is what the first saved request and publication then name | | EPB4 | The decisions, unchanged | Absent, equal, proven-ancestor and divergent destinations keep the shared engine's decisions; permanent ambiguity and temporary unavailability stay distinct definitive failures that replay as themselves without reaching a provider; a nonzero push earns exactly one further observation and an inconclusive completion is fatal; every assertion reads the remote's own refs and the full retained record | @@ -14580,7 +14607,7 @@ acknowledgement. | EPB6 | Admission | Portable codec rows refuse an unknown version, a hostile or malformed value, an origin outside the closed locator grammar, inputs belonging to another Repository and an object id of the wrong width, and round-trip both object-id formats; the provider holds every record it reads back to the origin the Repository identity in it was fingerprinted from, on the resolved path and the restored one alike, because that digest is the runtime-named half's; history admission refuses a misnamed request, a foreign owner, a saved place no placement described, an orphan outcome, a request answered twice, a mismatched receipt and a refusal word outside the closed set, while accepting a trailing request with no outcome; a live validation refusal rejects a completion before its append | | EPB7 | Acknowledgement, not completion | A refused request append contacts nothing, publishes nothing and fences the work after it; a refused outcome append leaves the remote as it is with no false receipt and no compensating Close, and the next attempt adopts that completion; accepted definitive failures replay provider-free; an unauthored throw is withheld rather than published | | EPB8 | Resources and ownership | A fresh and a pending publication each take the selected placement's managed slot before any native read, and contention appends neither a request nor an outcome while preserving a previously saved pending one; a publication proceeds while another entry holds only the shared Git directory's mutation lease; the checkout's own `pushurl`, hooks and credential helpers reach nothing because the transport runs in a control repository with a read-only alternate; one attempt's scratch transport, object source and credential session survive both phases and are closed and joined before publication, and a cleanup that failed acknowledges neither a success nor a worker-authored refusal; a publication cancelled while its scratch space or its credential session is still pending delivers nothing it then leaves behind, releases what it did receive before the halt settles, publishes no outcome and preserves any acknowledged request, and a cancelled blocked native child closes before the entry settles | -| EPB9 | Assembly and unsupported runtimes | The per-entry factory is inert — no directory, no `git`, no credential and no managed root — and declares exactly this entry's publication and issue components; exactly one core initialization happens per executing entry; a passive reading, a frozen position and a refused location initialize nothing; the file work, selections, local Git and issues of the same entry survive the combined assembly; an entry with no managed root and a runtime that installs no entry capability each report an absent provider for `` before any remote contact or managed root | +| EPB9 | Assembly and unsupported runtimes | The per-entry factory is inert — no directory, no `git`, no credential, no configuration read and no managed root — and declares exactly this entry's six external components: publication, issues, the pull request and its three evidence reads; exactly one core initialization happens per executing entry; a passive reading, a frozen position and a refused location initialize nothing; the file work, selections, local Git and issues of the same entry survive the combined assembly; an entry with no managed root reads an allowed pull-request URL and still reports an absent provider for `` and for an upsert, and a runtime that installs no entry capability reports an absent provider for ``, for `` and for an evidence read before any remote contact, credential read or managed root | --- diff --git a/specs/repl-spec.md b/specs/repl-spec.md index f3208a6a2..84dc732e9 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -1562,6 +1562,53 @@ no hold on the repository itself: it changes no file, index, branch or configuration there. Another entry committing in that repository does not stop it; another entry holding the checkout does, before a request exists. +### The pull request an entry opened, and the feedback it read + +Write the rest of the common path beside that publication: + +```xmd + +Ready for review. + + + +``` + +`` opens a pull request for the branch this entry published, or +brings the one you numbered up to date, and binds its number, URL, state and +head and base commits. It publishes nothing itself: what admits it is this +entry's own last acknowledged `` of that branch, from this checkout, +at this commit. A publication by another entry, by another checkout, or one a +handler answered without reaching the Git host authorizes nothing, and neither +does a failed or still-pending one. A refusal happens before a request is +written down, before a credential is read and before anything is contacted. + +The three reads name a pull request by URL and need no Repository and no working +directory at all. Each binds its own array, and each is its own retained +snapshot: reviews are not comments, and a partial answer binds nothing. + +Leave at a question after all four and come back to the printed location. The +pull request answers with the number and URL it was opened under, and each read +answers with the array it saw — no second pull request is opened, no collection +is asked for again, and no `git` runs. What you get back is what this entry +acknowledged, even if somebody has reviewed it since. + +An entry interrupted between asking for a pull request and hearing back opens +*that* request when you continue it: the title, the verbatim body, the draft +state, the base and the original head commit it named, however far the checkout +has moved. One pull request is opened, not two, and one whose creation the +service accepted but whose result was never written down is adopted rather than +repeated. + +A read written *after* the question is new work, so it reaches the service and +answers with the feedback that is there now. That is the difference between +reopening an entry and writing a fresh read in it. + +Which pull requests you may read is the operator's to state, beside the +credential, rather than the document's. A URL outside what this host authorized +refuses — including on reopening, so permission withdrawn while you were away is +withdrawn for the continuation too. + ### The issue an entry filed, and the one it read An entry files and reads issues wherever you started the REPL, inside a checkout diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index eef788d01..4f392e37a 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -214,10 +214,17 @@ ordinary run and a REPL entry is the policy beneath that operation — which journal it writes to, what it reconciles and what it replays — and nothing a document can see. -`` is not available to a REPL entry and refuses there as it does -under any host that installs no provider for it; neither a retained local result -nor a retained publication authorizes one. `` is available on the same -terms as publication, and lives at a service rather than in a Workspace. +`` and its three evidence reads are available to a REPL entry too, +on publication's own terms: a saved request written down before anything is +contacted, the Git host's own reconciled outcome beside it, and for each read +the provider's snapshot and nothing above it. A retained local result authorizes +none of it — what admits an upsert is this entry's own last acknowledged +publication of that branch, from that checkout, at that commit. An evidence read +needs no Repository and no working directory, and its restoration still passes +the host's current URL admission and the live composition around it, so a target +the operator has since withdrawn refuses rather than binding what was kept. +`` is available on the same terms as publication, and lives at a service +rather than in a Workspace. ## 3. Workflow lifecycle @@ -1889,9 +1896,11 @@ response or the pagination state is retained: what the journal holds is the complete normalized request and the normalized evidence, across the ordinary secret gate. The retained request is the whole normalized question: the operation, the canonical URL, the provider discriminator, the collection, the run and the -expansion. The effect's name covers the question rather than the run — a fork is -a different run reaching the same position with the same question, and a name -carrying the run would make every inherited read a different effect. A retained +expansion. A REPL entry writes its own owner and the site it authenticated in +those same two members, and binds them only inside its private policy. The +effect's name covers the question rather than the run — a fork is a different +run reaching the same position with the same question, and a name carrying the +run would make every inherited read a different effect. A retained read *is* inheritable: it changed nothing at the host, so a fork consumes the record and asks the provider nothing. diff --git a/tsconfig.node.json b/tsconfig.node.json index 44331d6ca..cc31bc08f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -133,6 +133,6 @@ // this proof observes `Deno.openSync` directly and spawns a second // `Deno.Command` to take the same sidecar from another process. The runtime // exclusions keep the same test out of the Node and Bun execution suites. - "packages/git/tests/retained-repository-lifecycle.test.ts" + "packages/cli/tests/retained-repository-lifecycle.test.ts" ] }