From 8ba01e58f1c6bc0a5de7c9472fdd549ef1394ded Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 16 Oct 2026 15:22:29 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Stage,=20switch=20and=20commit?= =?UTF-8?q?=20inside=20a=20REPL=20entry=20(#181,=203/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REPL entry now performs ``, `` and `` in the checkout it is working in — the ambient one it was started in, or a managed Repository or Worktree it selected — and retains what each produced. Leave the entry at a question, change the branch, the index and HEAD from a shell, reopen the printed location, and the question still shows the object id the commit bound: no `git` runs at all, not even a `write-tree` or a `git var`, and the external work stands untouched. Answering performs the work after the question for the first time. A new entry stages and commits what the checkout holds now. The performers are the ordinary ones an `xmd run` uses, so the authored semantics are unchanged: exactly the pathspecs a document wrote, the ordinary branch rules, and the current index under the invoking person's own author and committer. What is added is memory, with the honesty that costs. A saved commit id is history rather than a prediction and nothing re-resolves it; an operation that physically finished and was never acknowledged runs again and may instead refuse `empty-index`; a native Add that refused an ignored path keeps what it already staged and claims no rollback. There is no transaction, no commit adoption and no exactly-once local effect. A new provider-neutral codec owns the closed version-1 `repl_repository_git` record: the authenticated Repository identity, the selection's checkout and common directory, the working directory as received, and the operation's own arguments — with a commit's message described by a digest and a byte length rather than copied a second time. It parses absolute host checkout paths and reuses the shared state parser and result types without widening the Workspace-relative Workflow parsers. A new Deno module owns the three recorded handlers, private routing over the entry's own registrations, the two holds in slot-then-common order, one lazy commit identity per entry, refusal classification that keeps the route that reported it, and the native-completion-versus-acknowledgement boundary. --- architecture.md | 130 +- packages/cli/src/deno-repositories.ts | 18 +- .../cli/tests/repl-repositories-pty.test.ts | 15 + .../cli/tests/support/repl-files-journey.ts | 320 +++- packages/git/src/composition/git-records.ts | 17 +- .../git/src/deno/retained-repository/git.ts | 633 ++++++++ .../git/src/deno/retained-repository/local.ts | 8 +- .../src/deno/retained-repository/provider.ts | 123 +- .../git/src/retained-repository/admission.ts | 45 + .../src/retained-repository/git-records.ts | 741 +++++++++ packages/git/tests/module-partition.test.ts | 2 + .../git/tests/retained-repository-git.test.ts | 1385 +++++++++++++++++ .../retained-repository-lifecycle.test.ts | 223 ++- .../tests/retained-repository-records.test.ts | 461 ++++++ scripts/runtime-test-exclusions.ts | 6 + .../tests/repl-repositories-compiled.test.ts | 11 + specs/executable-mdx-spec.md | 65 +- specs/repl-spec.md | 85 +- specs/workflow-workspace-spec.md | 64 +- 19 files changed, 4260 insertions(+), 92 deletions(-) create mode 100644 packages/git/src/deno/retained-repository/git.ts create mode 100644 packages/git/src/retained-repository/git-records.ts create mode 100644 packages/git/tests/retained-repository-git.test.ts diff --git a/architecture.md b/architecture.md index a9aeebe1b..85e1d57bf 100644 --- a/architecture.md +++ b/architecture.md @@ -6457,13 +6457,16 @@ entry that read a file, wrote three and stopped at a question is reopened at the printed location, those four answer from what they produced, and the write after the question happens for the first time. -Four operations are retained and no others: a `` text read, a `` -write, a ``, and the directory a `` ensures, each in a -checkout this entry is working in — the ambient one it was started in, or a +Four file operations are retained and no others: a `` text read, a +`` write, a ``, and the directory a `` ensures, each in +a checkout this entry is working in — the ambient one it was started in, or a managed one it selected. A read is retained because a document binds its text and a continuation has to offer the same binding; a glob, a path check and a temporary directory bind nothing a continuation must answer again, and are left -alone. A call outside every such checkout is ordinary filesystem behavior, +alone. Three local Git operations are retained beside them — ``, +`` and `` — and those counts are separate: the file +rules below are about the four, and the Git rules further down are about the +three. A call outside every such checkout is ordinary filesystem behavior, recorded by nothing — a record would place the call in a checkout it is not in — so it runs again during a partial continuation. A managed placement is looked for first and the most specific one wins; the ambient route answers everything no @@ -6605,11 +6608,116 @@ deletion or repair. An acknowledged placement never enters that path again. A failed append fences later work with the first persistence failure, with no success result and no compensating Close. -There is no local Git operation here: ``, ``, -``, `` and `` stay declared where they -already are and refuse through their own unchanged missing-provider defaults. -Nothing inventories the managed root, snapshots a checkout's contents or -restores one. +#### The three local Git operations, and what a saved object id means + +``, `` and `` are retained on the same terms as +the file work above, and they call the *ordinary* performers: Add stages exactly +the pathspecs a document wrote, Switch follows the ordinary branch rules — named +branches only, a creation base consulted only when a branch has to be created, a +refusal for a branch another checkout holds and for changes it would overwrite — +and Commit records the current index and nothing else. What is added is memory. A +completed operation answers with its saved result and runs no `git` at all, not +even an apparently observational one: a replayed Add does not write a tree and a +replayed Commit does not read an identity. + +A saved commit object id is history, not a prediction. It names the commit that +was made then and says nothing about where HEAD is now, and nothing re-resolves +it, recomputes an object or injects a historical clock. A live Commit captures +the clock after it has decided the index is nonempty, commits exactly that index +and reads the object back; what is retained is that actual id and that actual +second. An entry whose Commit physically finished and whose result was never +acknowledged leaves no completion, so a continuation runs it live and may get +today's answer instead — including the ordinary empty-index refusal, because the +earlier attempt already consumed the index. There is no rollback, commit +adoption, repair or exactly-once guarantee for these operations. + +A result is a fixed-size description rather than a snapshot: the checkout it ran +in, the branch, commit, HEAD tree and index tree on both sides, and what the +operation itself produced — Add's ordered pathspecs, Switch's requested and +resolved branch and base, Commit's message digest and byte length, parent, tree, +commit and second. Nothing holds a file list, a checkout's contents, Git's own +output or a credential. The raw commit message is not retained a second time: +the journal already holds the authored source, and the record carries a digest +and a length. + +The handlers are ordinary Git dispatch installed at the implementation slot, +exactly where the ordinary provider installs its own, so middleware above may +answer without reaching them, refuse, or transform and delegate — and when it +delegates, the request that arrives is the request recorded. A call answered +wholly by a replacement acquires none of this retention. There is one durable +operation per call and no outer envelope: a `` written inside a +`` is an earlier journal operation of its own, so the staging child +finishes and is recorded before the commit that encloses it starts. + +Routing is private and happens only for live work. After a completed result has +been excluded, the received working directory is canonicalized and the ordinary +selector chooses the longest covering checkout among this entry's own +registrations, restricted by the whole authenticated Repository identity — which +is what lets a subdirectory, a linked worktree and a captured path entered +through `` each select the checkout their own paths belong to without +reaching another same-origin Repository. A missing context, a foreign or edited +selection, and a directory inside none of those checkouts are fatal admission, +before any performer runs and before any result is acknowledged. A saved +result's checkout is held to those same registrations with parsed facts alone; a +result path never authorizes a native call, and a genuinely new call chooses its +checkout independently. + +Who a commit is by is read from the host once per entry, lazily, and only by a +genuinely live Commit. Add, Switch, installation, a passive reading and a +completed Commit's replay ask nothing. A host that cannot say is remembered as +not saying for the rest of the entry and becomes the existing actionable +Commit-only refusal; a new entry asks again. Those four identity strings are +the one thing borrowed from the invoking environment — hooks, signing, +filesystem monitors and repository-supplied helper programs stay disabled by +fixed command-line configuration, and `HOME` stays disposable. The identity is +neither a request field nor a replay coordinate: who is at the keyboard is not +part of which operation this is. + +Ownership is the two holds already described, in one order. A live Add, Switch +or Commit takes the selected placement's managed slot and then the shared Git +directory's mutation lease, and keeps both for the rest of the entry; an ambient +target has no slot and takes only the lease. A no-op Add, a same-branch Switch +and a Commit that will refuse take them too, because whether native work will +change state is not something to predict. Both are the entry's existing +single-flight acquisitions, so an operation inside a checkout File work already +owns takes nothing new, and a completed replay takes neither. Contention stops +the entry with the existing fatal diagnostic and is never written down. Beside +them, one turn per shared Git directory serializes this entry's own native Git +attempts with each other; it is not a transaction over concurrent File work, +Worktree selection or middleware, and the entry-wide locks are about other +entries and other processes rather than about a person's editor. + +A refusal the document asked for is retained as a refusal, under a vocabulary +that keeps the route that reported it: native Git's own word, this provider's +closed composition word, or an absent ordinary identity. Fixing the checkout +after a refusal was acknowledged does not turn that historical answer into +success; a new invocation can succeed. Native Add is deliberately not +all-or-none — a command naming an ignored path stages what it already matched +and then refuses — and nothing here rolls that back, so the refusal says what +`git` reported and never claims the staging was discarded. Lock contention, +admission and infrastructure conditions are not refusals: an unrecognized +performer or cleanup failure activates the first-failure fence and propagates, +publishing nothing, and a printing region around the component does not weaken +that. + +The performer runs in a joined boundary before its outcome exists, so every +per-call native child has finished and closed before anything is published — +and a cancellation while a child is running joins that child's close before the +entry settles. Publication is once, and the ordinary refusal is raised after it, +so a live refusal and a replayed one are the same error. Cancellation after +native completion but before the acknowledged append leaves the checkout changed +and no completion; cancellation after it returns the saved answer next time. +Neither cut restores checkout state. A failed append stays the engine's +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. + +Publication is not here: `` and `` stay declared where +they already are and refuse through their own unchanged missing-provider +defaults, and a local result confers no publication evidence. `` is the +one external effect an entry does retain, and it is the next section's subject +because it belongs to no checkout. Nothing inventories the managed root, +snapshots a checkout's contents or restores one. ### What one entry retains about the issue it filed @@ -6667,7 +6775,6 @@ An entry's history is held to its own owner before any of it is replayed: a retained Issue record that parses, is named by the request it carries and carries a valid normalized result for its own operation is readable, and one written under another owner is refused rather than borrowed. - ### What the screen teaches, and what leaving it costs Presentation guidance, visual emphasis and the contextual status a person reads @@ -7042,9 +7149,10 @@ Status is measured against main. | `GitQuery.resolve()` | verifies and resolves one Git revision expression contextually [Interface and evidence](specs/workflow-spec.md#7-the-git-capability--moved). | built on main | | workflow run storage | creates or compatibly finds one run by public run ID, retains its identity, state, document executions and filtered journal, and validates immutable Workspace roots through one provider-owned connection entry [Invariant](#workflow-run-storage); [Interface and evidence](specs/workflow-spec.md#9-retained-run-storage). | built on the #365 stack; the CLI lifecycle reaches it on the #366 stack | | caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in [Invariant](#one-database-at-a-time-and-a-transaction-a-caller-can-hold). | built on main | -| REPL entry retained file work | one executing REPL entry's own recording, established by core initialization in the scope the invocation owns and ending with it: it retains the `` reads, `` writes, `` deletions and `` ensures that land inside a checkout the entry is working in — the ambient one it was started in, or a managed one it selected — authored and admitted-generated alike, because both cross the one document filesystem. A completed operation answers from its saved result without live path canonicalization, ambient discovery, a lease, a permission lookup or a filesystem call — so only *new* work needs the environment, a continuation still binds the text its read answered with while the file says something else, and a new entry reading that path sees what is there now. A record's name is the digest of the record version, the operation kind and the complete received request, so two equal requests in one entry are one name at two ordered positions and the durable engine's positional matching tells them apart; equal work in two entries is the same name, and each entry executing against its own stream segment is what keeps those apart. Recording is ordinary ancestor-max `Files` middleware, so a provider beneath that answers directly or transforms and delegates is kept as provider behavior while the received request stays the operation's identity. A shared Git directory admits one live *mutating* entry at a time, held lazily for as long as the execution lasts and refused rather than queued; no read acquires it, so a new read proceeds while another entry holds it. There is no local Git operation and nothing external here; work outside every checkout the entry is working in stays ordinary and unrecorded, and a passive reading initializes nothing [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | +| REPL entry retained file work | one executing REPL entry's own recording, established by core initialization in the scope the invocation owns and ending with it: it retains the `` reads, `` writes, `` deletions and `` ensures that land inside a checkout the entry is working in — the ambient one it was started in, or a managed one it selected — authored and admitted-generated alike, because both cross the one document filesystem. A completed operation answers from its saved result without live path canonicalization, ambient discovery, a lease, a permission lookup or a filesystem call — so only *new* work needs the environment, a continuation still binds the text its read answered with while the file says something else, and a new entry reading that path sees what is there now. A record's name is the digest of the record version, the operation kind and the complete received request, so two equal requests in one entry are one name at two ordered positions and the durable engine's positional matching tells them apart; equal work in two entries is the same name, and each entry executing against its own stream segment is what keeps those apart. Recording is ordinary ancestor-max `Files` middleware, so a provider beneath that answers directly or transforms and delegates is kept as provider behavior while the received request stays the operation's identity. A shared Git directory admits one live *mutating* entry at a time, held lazily for as long as the execution lasts and refused rather than queued; no read acquires it, so a new read proceeds while another entry holds it. The local Git and issue work an entry retains have rows of their own beside this one; work outside every checkout the entry is working in stays ordinary and unrecorded, and a passive reading initializes nothing [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | | REPL entry retained selection | one executing REPL entry's own `` and `` selections, recorded beside its file work as small routing records: the canonical managed root, the checkout root, the common Git directory, the credential-free repository identity, the display name and the owner checkout, with a root-level ``'s ambient owner recorded as its own answer. The selector is the ordinary managed one, with its own creation, compatible-reuse, conflict and interrupted-adoption policy; what this adds is that a completed selection answers from its record, calling no selector and reaching no clone, `worktree add`, sidecar, credential or lock — so a continuation works in a checkout somebody has since changed or removed, while only new work meets today's filesystem. Parsed facts are held to the call they stand at with pure slot arithmetic against the root the record itself carries, registered in entry-private state and minted as a fresh selection, so a copied or edited Repository value can misname a target and never reach one. One entry at a time *owns* a managed slot, held until teardown and reacquired lazily by the first new operation inside a restored placement; that is a different hold from the shared Git directory's mutation lease, which only a new `` selector and a new mutation take. A selection is acknowledged before its path is bound, and native work that was never acknowledged runs live again under the ordinary partial-creation policy [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | | REPL entry retained issue work | one executing REPL entry's own `` work, retained under the ordinary `issue_effect` records beside its file work and needing no checkout at all: no repository is discovered for it, no managed root is made and no hold is taken. A completed read answers with the snapshot it saw and a completed upsert with the URL it was filed under, neither resolving a provider, reading configuration nor opening a credential. What names it is the entry's own owner — derived by the session from the execution and the entry's admission-order position — with the identity canonical execution minted where it selected this implementation for this invocation; the `runId` and `expansionId` spellings are a compatibility encoding of those two, and a REPL entry installs no synthetic WorkflowRun. Neither member is reachable from a prop, a tracker, a Context, a middleware request or a record field a document can write, and the entry's `` is an ordinary shadowable registration a repository component still outranks. One durable boundary encloses the operation and the live attempt inside it composes `IssueOperations` over a transport-facing default of the entry's own, bound in the attempt's own scope under the attempt's own idempotency key and gone before the envelope publishes; canonical form dispatch selects which of the two effects an invocation asks for. An upsert the service accepted and this end never acknowledged retains nothing and presents the same key next time, which is what the provider adopts under; a read has no key and may be made again. The entry's retained issue records are held to its own owner before any of them are replayed [Reference](#what-one-entry-retains-about-the-issue-it-filed). | built on the #181 stack | +| REPL entry retained local Git | one executing REPL entry's own ``, `` and ``, recorded beside its file work and its selections. The performers are the ordinary ones — exactly the pathspecs a document wrote, the ordinary branch rules, the current index and nothing else — installed as ordinary Git dispatch at the implementation slot, so middleware above may answer, refuse, or transform and delegate, and a call answered wholly by a replacement acquires no retention. One durable operation per call and no outer envelope, so a staging child inside a Commit is an earlier journal operation of its own. A completed operation answers from its saved result and runs no `git` at all, writing no tree and reading no identity; a saved commit object id names the commit that was made then and is never re-resolved against today's HEAD. A result is fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products — never a file list, a checkout snapshot or Git output, and a commit message appears as a digest and a byte length rather than a second copy of the text. Routing is private and live-only: the received working directory is canonicalized and the ordinary selector chooses the longest covering checkout among this entry's registrations under the whole authenticated Repository identity, and a saved result's checkout is held to those registrations with parsed facts alone. The invoking person's author and committer are read once per entry, lazily, and only by a genuinely live Commit; a host that cannot say becomes the existing Commit-only refusal. A live operation takes the selected placement's managed slot and then the shared Git directory's mutation lease, keeps both until teardown and takes neither on replay, with this entry's own native attempts serialized per shared Git directory. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add says what `git` reported and claims no rollback; contention, admission and infrastructure failures are fatal and unrecorded. Native completion and acknowledgement are separate: an unacknowledged Commit runs live again and may instead refuse empty-index, and a failed append fences later work with no success binding and no compensating Close. Nothing external is here, and a local result confers no publication evidence [Reference](#the-three-local-git-operations-and-what-a-saved-object-id-means). | built on the #181 stack | | live durable-operation coordinator | explicitly coordinates structured live execution with existing Yield publication while leaving replay and callback effects unchanged [Invariant](#live-effect-coordination). | built on the #365 stack | | Workspace coordination API | fails closed by default; replaceable context routes only a one-use provider selection, while the selected provider directly invokes an execution-owned credentialed capability for execution, publication and failure activation [Invariant](#live-effect-coordination). | built on the #365 stack; the Deno provider installs an adapter-private atomic handler | | shared Git-host reconciliation | Reconciles Git push and pull-request upsert through one request-only boundary whose private terminal alone settles the retained outcome. [Invariant](#external-effects-and-git-host-mutations); [Invariant](#issue-effects). | Built on #297; Push (#370) and PullRequest (#295) consume it. Issue (#296) has its own middleware boundary. | diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index ec3ac3f8e..95d2c6ea0 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -158,16 +158,18 @@ function snapshot(helper: HelperAssembly): HelperAssembly { } /** - * What a REPL entry's execution initializes: recording and selection, and - * nothing else. + * What this registration gives a REPL entry's execution: recording, selection + * and local Git. * * Registered by the selected REPL host rather than by the bundled Plugin, - * because an entry is not an ordinary run: it retains the file work it does and - * the managed checkouts it selects to do that work in, and installs no Git host, - * publication or external worker. Each executing entry initializes its own, in - * the scope below its Agent attachment, so a passive view — a settled history, - * a frozen position — initializes nothing, creates no managed root and takes no - * lease. + * because an entry is not an ordinary run: it retains the file work it does, the + * managed checkouts it selects to do that work in, and the staging, branch + * switches and commits it performs in them — and it installs no publication and + * no external worker. The issue capability an entry runs with is selected + * separately, above, because it belongs to no checkout. Each executing entry initializes its own, in the scope + * below its Agent attachment, so a passive view — a settled history, a frozen + * position — initializes nothing, creates no managed root, reads no commit + * identity and takes no lease. * * The root and the helper are copied here, at registration, rather than held as * the caller's object. What an entry initializes with has to be what this host diff --git a/packages/cli/tests/repl-repositories-pty.test.ts b/packages/cli/tests/repl-repositories-pty.test.ts index 4a923504b..2cf17910d 100644 --- a/packages/cli/tests/repl-repositories-pty.test.ts +++ b/packages/cli/tests/repl-repositories-pty.test.ts @@ -26,8 +26,10 @@ import { cliRuntime, cliShellCommand } from "@executablemd/test-support/launch"; import type { Operation } from "effection"; import { replFilesJourney, + replGitJourney, replOrdinaryFileWork, replRepositoryJourney, + replUnsupportedGit, replUnsupportedSelection, } from "./support/repl-files-journey.ts"; @@ -59,9 +61,22 @@ describe("a REPL entry's files, across leaving and coming back", () => { }, ); + it( + JOURNEY + ? "E1: a committed object id answers again while the checkout has moved on" + : "E8: this runtime refuses Add, Switch and Commit before a native operation", + function* (): Operation { + yield* JOURNEY ? replGitJourney(sourceCommand, "ambient") : replUnsupportedGit(sourceCommand); + }, + ); + if (JOURNEY) { it("E1: a managed Worktree's captured path routes a Dir, and answers again", function* (): Operation { yield* replRepositoryJourney(sourceCommand, "worktree"); }); + + it("E1: a selected Worktree's Add and Commit answer again on reopening", function* (): Operation { + yield* replGitJourney(sourceCommand, "worktree"); + }); } }); diff --git a/packages/cli/tests/support/repl-files-journey.ts b/packages/cli/tests/support/repl-files-journey.ts index 414c9f0f5..679cc9987 100644 --- a/packages/cli/tests/support/repl-files-journey.ts +++ b/packages/cli/tests/support/repl-files-journey.ts @@ -23,7 +23,7 @@ import { useTempDirectory } from "@executablemd/test-support/temp"; import { type Operation, scoped, sleep, until } from "effection"; import { exists, readTextFile, writeTextFile } from "@effectionx/fs"; import { execFile } from "node:child_process"; -import { mkdir, readdir } from "node:fs/promises"; +import { chmod, mkdir, readdir } from "node:fs/promises"; import { join } from "node:path"; import process from "node:process"; import { allocate, showing } from "./pty.ts"; @@ -599,3 +599,321 @@ export function replOrdinaryFileWork(repl: ReplCommand): Operation { expect(yield* ended(pty)).toBe(0); }); } + +/** + * A `git` on `PATH` that logs every invocation and forwards to the real one. + * + * The local Git journey's one new question is whether a reopened prefix runs + * `git` at all — including the apparently observational commands, `write-tree` + * and `git var` among them. Counting them from outside the process is the only + * way to answer it for a *packaged* binary, which has no seam a suite can + * substitute. + * + * It forwards unchanged to the real Git it resolved, so what runs is the real + * command with the real arguments. 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. + */ +function* gitShim(home: string): Operation<{ readonly prefix: string; readonly log: string }> { + const real = yield* until( + new Promise((resolve, reject) => { + execFile("sh", ["-c", "command -v git"], (error, stdout) => + error === null ? resolve(stdout.trim()) : reject(error), + ); + }), + ); + if (real === "") { + throw new Error("this host has no git on PATH, so there is nothing to forward to"); + } + const directory = join(home, "git-shim"); + yield* until(mkdir(directory, { recursive: true })); + const log = join(home, "git-commands.log"); + const shim = join(directory, "git"); + yield* writeTextFile( + shim, + `#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(log)}\nexec ${shellQuote(real)} "$@"\n`, + ); + yield* until(chmod(shim, 0o755)); + yield* writeTextFile(log, ""); + return { prefix: `PATH=${shellQuote(directory)}:$PATH`, log }; +} + +/** Every `git` the shim has seen since it was last forgotten. */ +function* logged(log: string): Operation { + const held = (yield* exists(log)) ? yield* readTextFile(log) : ""; + return held.split("\n").filter((line) => line !== ""); +} + +/** One seeded checkout with a commit in it, so a Commit has a parent. */ +function* seedCommitted(home: string): Operation { + const checkout = join(home, "checkout"); + yield* until(mkdir(checkout, { recursive: true })); + yield* git(home, ["init", "-b", "main", checkout]); + yield* writeTextFile(join(checkout, "seed.md"), "seed\n"); + yield* git(checkout, ["add", "seed.md"], home); + yield* git(checkout, ["commit", "-m", "seed"], home); + return checkout; +} + +/** What one `git` command printed, for a fixture that reads the checkout. */ +function gitSaid(cwd: string, args: readonly string[], home: string): Operation { + return until( + new Promise((resolve, reject) => { + execFile( + "git", + [...args], + { + cwd, + env: { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_AUTHOR_NAME: "Journey", + GIT_AUTHOR_EMAIL: "journey@example.test", + GIT_COMMITTER_NAME: "Journey", + GIT_COMMITTER_EMAIL: "journey@example.test", + }, + }, + (error, stdout) => (error === null ? resolve(stdout.trim()) : reject(error)), + ); + }), + ); +} + +/** Wait until the checkout's HEAD names something other than this commit. */ +function* moved(checkout: string, home: string, from: string): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const head = yield* gitSaid(checkout, ["rev-parse", "HEAD"], home); + if (head !== from) { + return head; + } + if (Date.now() > deadline) { + throw new Error(`${checkout} never moved off ${from}`); + } + yield* sleep(200); + } +} + +/** Where the local Git journey's entry works: the ambient checkout, or a worktree. */ +export type GitJourney = "ambient" | "worktree"; + +const GIT_QUESTION = + 'Recorded commit: {commit}. Continue this experiment?'; + +/** The entry this journey types, as the specification shows it. */ +function gitEntry(through: GitJourney, origin: string): string { + const body = [ + 'first', + '', + '', + "", + GIT_QUESTION, + 'continued', + ]; + if (through === "ambient") { + return body.join("\n"); + } + // The self-closing spelling binds the checkout path, and a `` sibling + // renders its descendants at exactly that path — while the enclosing + // `` stays the owner every Git element below belongs to. + return [ + ``, + '', + "", + ...body, + "", + "", + ].join("\n"); +} + +/** A new entry that stages and commits whatever the checkout holds now. */ +function gitLiveCommit(through: GitJourney, origin: string): string { + const body = [ + '', + '', + "again is {again}", + ]; + if (through === "ambient") { + return body.join("\n"); + } + return [ + ``, + '', + "", + ...body, + "", + "", + ].join("\n"); +} + +/** + * The whole journey again, through the three local Git operations. + * + * The one addition this delivery is about: the entry stages and commits, and + * the object id it bound is what the question still shows after the checkout + * has moved on. A reopened prefix runs no `git` at all, which the shim on + * `PATH` is what proves — and a *new* entry commits today's staged work, which + * is what says this is retention rather than a cache. + */ +export function replGitJourney(repl: ReplCommand, through: GitJourney): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-git-pty-"); + const shim = yield* gitShim(home); + const ambient = yield* seedCommitted(home); + const origin = through === "ambient" ? "" : yield* seedOrigin(home); + // The ambient journey is typed from inside the checkout; the managed one + // from a directory inside no checkout, so what it works in is the placement + // it selected. + const away = join(home, "away"); + yield* until(mkdir(away, { recursive: true })); + const from = through === "ambient" ? ambient : away; + const open = (args: readonly string[]): string => + `cd ${shellQuote(from)} && ${shim.prefix} ${repl(args)}`; + + const first = yield* allocate(open(["repl", "--deny-all"]), home); + yield* showing(first, "[exit]", REACH_MS); + yield* first.type(gitEntry(through, origin)); + yield* sleep(500); + yield* first.type("\r"); + + const working = through === "ambient" ? ambient : yield* managedCheckout(home, "worktrees"); + + // The prefix ran: the file is there, the commit was made, and the write + // after the question is not. + yield* holding(join(working, "note.md"), "first"); + yield* showing(first, "waiting for an answer", ACT_MS); + expect(yield* exists(join(working, "after.md"))).toBe(false); + const recorded = yield* gitSaid(working, ["rev-parse", "HEAD"], home); + expect(recorded).toMatch(/^[0-9a-f]{40}$/); + // The commit holds exactly what the staging child staged. + expect(yield* gitSaid(working, ["show", "HEAD:note.md"], home)).toBe("first"); + // And the document's own Git really ran, which is what makes the empty log + // below say something. + expect((yield* logged(shim.log)).length > 0).toBe(true); + + yield* reach(first, "[exit]"); + yield* first.type("\r"); + expect(yield* ended(first)).toBe(0); + const location = printedLocation((yield* first.shows()).join("\n")); + + // Changed from outside: the file, the index and HEAD all move on, which is + // exactly what this execution model allows. + yield* writeTextFile(join(working, "note.md"), "second"); + yield* gitSaid(working, ["add", "note.md"], home); + yield* writeTextFile(join(working, "staged.md"), "staged\n"); + yield* gitSaid(working, ["add", "staged.md"], home); + yield* gitSaid(working, ["commit", "-m", "theirs"], home); + const theirs = yield* gitSaid(working, ["rev-parse", "HEAD"], home); + expect(theirs).not.toBe(recorded); + // And something is left staged and uncommitted, so "the index did not + // move" below is a fact about work a replayed Commit would have consumed. + yield* writeTextFile(join(working, "today.md"), "today"); + yield* gitSaid(working, ["add", "today.md"], home); + const theirIndex = yield* gitSaid(working, ["write-tree"], home); + expect(theirIndex).not.toBe(yield* gitSaid(working, ["rev-parse", "HEAD^{tree}"], home)); + + // Forgotten here, so what the log holds afterwards is what *reopening* ran. + yield* writeTextFile(shim.log, ""); + + 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); + + // No completed operation ran again, and no `git` ran at all — not a + // `write-tree`, not a `git var`, not a `rev-parse`. + expect(yield* logged(shim.log)).toEqual([]); + expect(yield* readTextFile(join(working, "note.md"))).toBe("second"); + expect(yield* gitSaid(working, ["rev-parse", "HEAD"], home)).toBe(theirs); + expect(yield* gitSaid(working, ["write-tree"], home)).toBe(theirIndex); + expect(yield* exists(join(working, "after.md"))).toBe(false); + + yield* reach(second, "[answer]"); + yield* second.type("\r"); + // The saved object id, which describes the earlier commit rather than + // today's HEAD. + yield* showing(second, `Recorded commit: ${recorded}`, ACT_MS); + yield* click(second, "( ) continue"); + yield* showing(second, "[ok] entry-1", ACT_MS); + + // The suffix write executed, for the first time, on the answer. + yield* holding(join(working, "after.md"), "continued"); + + yield* reach(second, "[exit]"); + yield* second.type("\r"); + expect(yield* ended(second)).toBe(0); + + // A separate new entry: its Add and its Commit are new work, so they stage + // and commit what the checkout holds now. + const third = yield* allocate(open(["repl", "--deny-all", location]), home); + yield* showing(third, "[exit]", REACH_MS); + yield* third.type(gitLiveCommit(through, origin)); + yield* sleep(500); + yield* third.type("\r"); + const today = yield* moved(working, home, theirs); + yield* showing(third, `again is ${today}`, ACT_MS); + // Today's staged work, not the commit the earlier entry retained. + expect(today).not.toBe(recorded); + expect(yield* gitSaid(working, ["show", "HEAD:today.md"], home)).toBe("today"); + expect(yield* gitSaid(working, ["show", "HEAD:note.md"], home)).toBe("second"); + + yield* reach(third, "[exit]"); + yield* third.type("\r"); + expect(yield* ended(third)).toBe(0); + }); +} + +/** + * What the three local Git operations do on a runtime with no native setup. + * + * Node and Bun start a REPL and do ordinary file and directory work; neither + * installs a Git provider, because the Deno adapter's module graph reaches + * `node:sqlite` and Bun has not got one. So ``, `` and + * `` there say no provider is installed rather than reaching a + * native operation — and say it before anything is staged, switched or + * committed. + */ +export function replUnsupportedGit(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-git-pty-"); + const shim = yield* gitShim(home); + const checkout = yield* seedCommitted(home); + const head = yield* gitSaid(checkout, ["rev-parse", "HEAD"], home); + const index = yield* gitSaid(checkout, ["write-tree"], home); + yield* writeTextFile(shim.log, ""); + + const pty = yield* allocate( + `cd ${shellQuote(checkout)} && ${shim.prefix} ${repl(["repl", "--deny-all"])}`, + home, + ); + yield* showing(pty, "[exit]", REACH_MS); + // All three in one printing region, because an absent provider is an + // ordinary refusal a region recovers: each of them reports, and the + // ordinary file work after them still runs. + yield* pty.type( + [ + "", + '', + '', + '', + "", + 'continued', + ].join("\n"), + ); + yield* sleep(500); + yield* pty.type("\r"); + + // Refused before a repository is in scope, which is before Git exists in + // the story at all. Either way nothing native was reached. + yield* showing(pty, "composition provider is installed", ACT_MS); + yield* holding(join(checkout, "after.md"), "continued"); + expect(yield* logged(shim.log)).toEqual([]); + expect(yield* gitSaid(checkout, ["rev-parse", "HEAD"], home)).toBe(head); + expect(yield* gitSaid(checkout, ["write-tree"], home)).toBe(index); + expect(yield* exists(join(home, ".xmd", "repositories"))).toBe(false); + + yield* reach(pty, "[exit]"); + yield* pty.type("\r"); + expect(yield* ended(pty)).toBe(0); + }); +} diff --git a/packages/git/src/composition/git-records.ts b/packages/git/src/composition/git-records.ts index 7adb4b2e2..2105be423 100644 --- a/packages/git/src/composition/git-records.ts +++ b/packages/git/src/composition/git-records.ts @@ -104,7 +104,7 @@ const SWITCH_MEMBERS = [ * object id, and treating one as an id is how a retained result that says * nothing passes for one that says something. */ -function objectId(value: unknown, format: GitObjectFormat): string | undefined { +export function parseGitObjectId(value: unknown, format: GitObjectFormat): string | undefined { const candidate = text(value); const width = format === "sha1" ? 40 : 64; return candidate !== undefined && new RegExp(`^[0-9a-f]{${width}}$`).test(candidate) @@ -135,9 +135,9 @@ export function parseGitCheckoutState( return undefined; } const branch = text(record.branch); - const commit = objectId(record.commit, format); - const headTree = objectId(record.headTree, format); - const indexTree = objectId(record.indexTree, format); + const commit = parseGitObjectId(record.commit, format); + const headTree = parseGitObjectId(record.headTree, format); + const indexTree = parseGitObjectId(record.indexTree, format); if ( branch === undefined || commit === undefined || @@ -221,7 +221,8 @@ export function parseGitSwitchResult( const requestedBranch = text(record.requestedBranch); const resolvedBranch = text(record.resolvedBranch); const requestedBase = optionalText(record.requestedBase); - const resolvedBase = record.resolvedBase === null ? null : objectId(record.resolvedBase, format); + const resolvedBase = + record.resolvedBase === null ? null : parseGitObjectId(record.resolvedBase, format); const before = parseGitCheckoutState(record.before, format); const after = parseGitCheckoutState(record.after, format); if ( @@ -556,9 +557,9 @@ export function parseGitCommitResult( const messageSource = parseGitCommitMessageSource(record.messageSource); const messageDigest = digest(record.messageDigest); const messageLength = count(record.messageLength); - const parent = objectId(record.parent, format); - const tree = objectId(record.tree, format); - const commit = objectId(record.commit, format); + const parent = parseGitObjectId(record.parent, format); + const tree = parseGitObjectId(record.tree, format); + const commit = parseGitObjectId(record.commit, format); const committedAt = count(record.committedAt); const before = parseGitCheckoutState(record.before, format); const after = parseGitCheckoutState(record.after, format); diff --git a/packages/git/src/deno/retained-repository/git.ts b/packages/git/src/deno/retained-repository/git.ts new file mode 100644 index 000000000..b4fc7aa18 --- /dev/null +++ b/packages/git/src/deno/retained-repository/git.ts @@ -0,0 +1,633 @@ +/** + * One REPL entry's local Add, Switch and Commit: performed once, answered from + * what performing them produced. + * + * These are the ordinary performers an `xmd run` uses, wrapped in this entry's + * durable boundary. `` stages exactly the pathspecs a document wrote, + * `` follows the ordinary branch rules, and `` records + * the current index under the invoking person's own author and committer. What + * is added is memory: a completed operation answers with its saved result and + * runs no `git`, however the checkout has changed since. + * + * ## A saved commit id is history, not a prediction + * + * The honesty the REPL trades for not snapshotting anybody's repository is + * explicit here. A completed Commit's object id names the commit that was made + * then; it says nothing about where HEAD is now, and nothing re-resolves it. + * Work that physically finished and whose result was never acknowledged leaves + * no completion, so a continuation runs it again and may get another answer — + * including the ordinary empty-index refusal, because the earlier attempt + * already consumed the index. Nothing rolls back, adopts, repairs or predicts. + * + * ## Why this reads the cursor before it resolves anything + * + * Routing a call means canonicalizing its working directory and choosing among + * this entry's registered checkouts, and acquiring ownership means opening lock + * descriptors. None of that is anything a saved result is about: a continuation + * whose managed worktree has since been removed answers from what it already + * recorded rather than failing at a question nobody asked. So the retained + * cursor is read first, without consuming anything, and only a call with + * nothing saved resolves, routes, acquires and performs. + * + * The read is a hint and never an authority. `createDurableOperation` remains + * the only judge of position, type and name, so the recording callback asks for + * the same routing and the same holds again — idempotently — around work that + * really does run live. + * + * ## Two holds and one turn + * + * A live operation takes the selected placement's managed slot and then the + * mutation lease on the shared Git directory, in that order, and keeps both for + * the rest of the entry. Both are the entry's existing single-flight + * acquisitions, so an operation inside a checkout File work already owns takes + * nothing new. A no-op Add, a same-branch Switch and a Commit that will refuse + * take them too: whether native work will change state is not something to + * predict. + * + * Beside them, one turn per shared Git directory serializes this entry's own + * native Git attempts with each other. It is not a transaction over anything + * else: concurrent File work, Worktree selection and middleware keep their + * existing scheduling, and the entry-wide locks are about other entries and + * other processes rather than about a person's editor. + */ + +import { Err, Ok, scoped, until } from "effection"; +import type { Operation, Result } from "effection"; +import { realpath } from "node:fs/promises"; +import { createDurableOperation, StaleInputError } from "@executablemd/durable-streams"; +import type { + ActivateDurabilityFailure, + Json, + LiveDurableOperationCoordinator, + Result as ProtocolResult, +} from "@executablemd/durable-streams"; +import type { Around } from "@effectionx/context-api"; +import type { + GitAddInvocation, + 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"; +import { + parseReplGitRecord, + replGitFingerprint, + replGitRecordJson, + replGitRecordName, + replGitRefusal, + ReplGitRefusal, + REPL_REPOSITORY_GIT, +} from "../../retained-repository/git-records.ts"; +import type { + ReplGitAddRequest, + ReplGitCommitRequest, + ReplGitOperation, + ReplGitPlace, + ReplGitRefusalData, + 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"; +import { holdManagedPlacement } from "./leases.ts"; +import { retainedRecordAhead } from "./local.ts"; +import { placementSubject } from "./selections.ts"; +import type { ReplPlacementRegistration } from "./selections.ts"; + +/** + * A saved local Git result standing where this operation belongs that it cannot + * read. + * + * Fatal: a value that does not describe this operation is not an outcome of it, + * so it does not belong in the result channel. A refusal there is the + * operation's own sentence, which `` may print and carry on past. + */ +export class ReplGitRecordError extends StaleInputError { + override name = "ReplGitRecordError"; +} + +/** + * One checkout this entry may run native Git in. + * + * The ordinary routing fact the shared selector reads, beside the placement + * registration whose managed slot and recorded root a live operation + * reacquires. An ambient owner is registered too and carries no slot: it is the + * checkout the person was already standing in. + */ +export interface ReplGitCheckout { + readonly checkout: RegisteredCheckout; + readonly registration: ReplPlacementRegistration; +} + +/** 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; + /** + * Who an ordinary commit in this entry is by, or `undefined` when this host + * cannot say. + * + * Acquired at most once per entry and reached only by a genuinely live + * Commit. Add, Switch, installation, passive history and a completed Commit's + * replay ask it nothing. + */ + identity(): Operation; + /** Hold a managed slot for the entry, single-flight per root and slot. */ + holdSlot(root: string, kind: SlotKind, path: string, subject: string): Operation; + /** Hold the shared Git directory this operation is about to mutate in. */ + holdCommon(commonDirectory: string): Operation; + /** Run live native Git for one shared Git directory in order within this entry. */ + serialize(key: string, work: () => Operation): Operation; + /** This entry's private map from the selections it minted to what it holds. */ + readonly selections: SelectionRegistry; + /** + * The checkouts this entry has registered, as they stand now. + * + * Not an operation: membership is rebuilt by selections that already + * happened, so reading it acquires nothing. + */ + checkouts(): readonly ReplGitCheckout[]; +} + +/** + * The recorded Add, Switch and Commit this entry installs. + * + * Installed at the implementation slot, exactly where the ordinary provider + * installs its own, so this is ordinary Git dispatch rather than a protected + * channel: middleware above may answer without reaching here, refuse, or + * transform and delegate — and when it delegates, the request that arrives is + * the request recorded. A call answered wholly by a replacement never reaches + * this provider and acquires none of its retention. + * + * Push is absent. Publication has its own protocol and its own evidence, and a + * local result authorizes none of it, so `` reaches the ordinary + * missing-provider refusal it reaches today. + */ +export function replGitMiddleware(entry: ReplGitEntry): Partial> { + return { + switch: ([invocation]: [GitSwitchInvocation]): Operation => + recordedSwitch(entry, invocation), + add: ([invocation]: [GitAddInvocation]): Operation => + recordedAdd(entry, invocation), + commit: ([invocation]: [GitCommitInvocation]): Operation => + recordedCommit(entry, invocation), + }; +} + +function* recordedAdd(entry: ReplGitEntry, invocation: GitAddInvocation): Operation { + // Admitted where the request enters, exactly as the ordinary provider admits + // it, and copied: the Api is public, and the array a caller still holds is + // not what this operation is named by. + const paths = admitPathspecs(invocation.paths); + const request: ReplGitAddRequest = Object.freeze({ + operation: "add" as const, + ...place(entry, ADD, invocation), + paths, + }); + const value = yield* recorded(entry, ADD, request, (checkout) => liveAdd(checkout, paths)); + if (!("paths" in value)) { + throw mismatch(ADD); + } + return value; +} + +function* recordedSwitch( + entry: ReplGitEntry, + invocation: GitSwitchInvocation, +): Operation { + const base = invocation.base; + const request: ReplGitSwitchRequest = Object.freeze({ + operation: "switch" as const, + ...place(entry, SWITCH, invocation), + branch: invocation.branch, + base: base ?? null, + }); + const value = yield* recorded(entry, SWITCH, request, (checkout) => + liveSwitch(checkout, invocation.branch, base), + ); + if (!("requestedBranch" in value)) { + throw mismatch(SWITCH); + } + return value; +} + +function* recordedCommit( + entry: ReplGitEntry, + invocation: GitCommitInvocation, +): Operation { + const message = admitCommitMessage(invocation.message); + const messageSource = admitMessageSource(invocation.messageSource); + const evidence = gitCommitMessageEvidence(message); + const request: ReplGitCommitRequest = Object.freeze({ + operation: "commit" as const, + ...place(entry, COMMIT, invocation), + messageSource, + messageDigest: evidence.digest, + messageLength: evidence.length, + }); + const value = yield* recorded(entry, COMMIT, request, function* (checkout) { + // 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()); + }); + if (!("commit" in value)) { + throw mismatch(COMMIT); + } + return value; +} + +/** + * Where this invocation runs, as this entry's own registry describes it. + * + * The received selection is authenticated before anything else and its *parsed* + * facts are what the request carries. The fresh opaque identifier the provider + * minted for this entry never appears: it names nothing once the entry ends, so + * a durable name built from one would differ on every reopen of the same + * question. + */ +function place( + entry: ReplGitEntry, + element: string, + invocation: { readonly repository: RepositorySelection; readonly workingDirectory: string }, +): ReplGitPlace { + const placement = entry.selections.authenticate( + invocation.repository, + () => + new GitOperationAdmissionError( + element, + "the Repository in scope is not one this entry selected, so it names no checkout", + ), + ).placement; + return { + repository: placement.identity, + selectionCheckout: placement.checkoutRoot, + commonDirectory: placement.commonDirectory, + // Exactly as received. Resolving it is live routing, which a completed + // operation does not do, so the name cannot depend on it. + workingDirectory: invocation.workingDirectory, + }; +} + +/** + * Perform this operation, or answer with what performing it produced before. + * + * The result is the operation's own — the same value a live call produces and a + * replay restores — so a caller cannot tell from it which of the two happened, + * which is the point. An ordinary refusal is raised from the saved + * classification on both paths, by this one route, so a live refusal and a + * replayed one are the same error for the same element. + */ +function* recorded( + entry: ReplGitEntry, + element: string, + request: ReplGitRequest, + perform: (checkout: GitCheckout) => Operation, +): Operation { + const fingerprint = yield* replGitFingerprint(request); + const name = replGitRecordName({ operation: request.operation, fingerprint }); + + // Resolved at most once for this call, because the live path routes before it + // takes the turn and the recording callback asks again inside it. + let routed: ReplGitCheckout | undefined; + function* routing(): Operation { + if (routed === undefined) { + routed = yield* route(entry, element, request); + } + return routed; + } + + function* once(): Operation { + return yield createDurableOperation( + { type: REPL_REPOSITORY_GIT, name }, + function* () { + 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 replGitRecordJson({ fingerprint, request, outcome }); + }, + { coordinator: gitCoordinator }, + ); + } + + // Journal data on a replay, so it arrives as the unknown it is and is parsed + // below rather than trusted as this code's own. + const stored: unknown = (yield* retainedRecordAhead(REPL_REPOSITORY_GIT, name)) + ? yield* once() + : yield* live(entry, routing, once); + + const parsed = parseReplGitRecord(stored); + if (parsed === undefined) { + throw new ReplGitRecordError( + `the saved result for ${element} is not one this host can read, so what it says about work ` + + "already done is unknown.", + ); + } + if (parsed.operation !== request.operation || parsed.fingerprint !== fingerprint) { + throw mismatch(element); + } + if (!parsed.outcome.ok) { + throw failure(element, refusalOf(parsed.outcome.error)); + } + // The checkout a saved result names has to be one this entry registered under + // the repository it authenticated. Parsed facts only: a result path is never + // used to authorize a native call, and a new call chooses its own checkout. + authorized(entry, element, request, parsed.outcome.value); + return parsed.outcome.value; +} + +/** + * Route, take the turn, take ownership, and perform. + * + * Acquisition happens outside the recorded operation, which is what keeps a + * slot another entry holds a fatal diagnostic the person can act on rather than + * a durable outcome — and keeps temporary contention out of the journal + * entirely. + */ +function* live( + entry: ReplGitEntry, + routing: () => Operation, + once: () => Operation, +): Operation { + const target = yield* routing(); + return yield* entry.serialize( + `git ${target.registration.placement.commonDirectory}`, + function* () { + yield* own(entry, target); + return yield* once(); + }, + ); +} + +/** + * Which registered checkout this repository and working directory select. + * + * The ordinary selector over this entry's own registrations, restricted by the + * whole authenticated Repository identity and resolving the received directory + * first — a checkout root is the path Git resolved, and a working directory + * reached through a link is the same place under another name. 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: ReplGitEntry, + element: string, + request: ReplGitRequest, +): Operation { + const registered = entry.checkouts(); + const selected = selectCheckout( + registered.map((held) => held.checkout), + request.repository, + yield* canonicalPath(request.workingDirectory), + element, + ); + const found = registered.find((held) => held.checkout.root === selected.root); + if (found === undefined) { + throw new GitOperationAdmissionError( + element, + "the checkout it selected is not one this entry holds a placement for", + ); + } + return found; +} + +/** + * Take ownership of the checkout this operation is about to change. + * + * The managed slot first and the shared Git directory second, because the slot + * says which entry owns a managed checkout and the lease says which entry is + * changing a repository — and a restored placement took neither when it + * replayed. An ambient target has no slot: it is the checkout the person was + * already in. + */ +function* own(entry: ReplGitEntry, target: ReplGitCheckout): Operation { + const registration = target.registration; + const slot = registration.slot; + // The root this placement recorded, not the one configured now: a restored + // placement's slot belongs to the root it was selected under. + const root = registration.placement.managedRoot; + if (slot !== undefined && root !== undefined) { + yield* holdManagedPlacement(() => + entry.holdSlot(root, slot.kind, slot.path, placementSubject(registration.placement)), + ); + } + yield* entry.holdCommon(registration.placement.commonDirectory); +} + +/** + * Run the live performer, or classify its refusal as one this record may carry. + * + * Joined, so every per-call native child has finished and closed before the + * outcome exists — the entry's own Git session, its isolation directory, the + * identity acquisition and both holds stay outside, where the entry owns them. + * + * A classified refusal is an answer the document asked for and is written down. + * Anything else is infrastructure: it publishes no completion, so a continuation + * reaches the ordinary live path rather than replaying a verdict this host has + * no word for. + */ +function* attempted( + operation: ReplGitOperation, + perform: () => Operation, +): Operation> { + try { + return Ok(yield* scoped(perform)); + } catch (error) { + const refusal = classified(operation, error); + if (refusal === undefined) { + throw error; + } + return Err(new ReplGitRefusal(refusal)); + } +} + +/** + * The closed classification this failure refuses under, or `undefined`. + * + * Three routes, and each keeps its own. A native `GitRefusal` is what `git` + * itself reported; a `CompositionRefusal` carries a word from the Git + * vocabulary this provider owns; and an absent ordinary identity is the host + * saying it cannot name a committer. Keeping them apart is what stops a native + * Add's ignored-path refusal from being rebuilt with the Workflow formatter's + * sentence, which says the staging was discarded — nothing here discards + * anything. + * + * Lock contention, admission and infrastructure failures are none of these. + * Each is decided before an operation exists or describes a condition this host + * has no word for, and writing one down would make it the permanent answer + * every later continuation replays. + */ +function classified(operation: ReplGitOperation, error: unknown): ReplGitRefusalData | undefined { + if (error instanceof GitRefusal) { + return replGitRefusal(operation, "native-git", error.reason); + } + if (error instanceof UnresolvedGitIdentityError) { + return replGitRefusal(operation, "ordinary-identity", error.reason); + } + if (!(error instanceof CompositionRefusal)) { + return undefined; + } + const word = refusalReason(error, "git"); + return word === undefined ? undefined : replGitRefusal(operation, "composition-git", word); +} + +/** + * The failure a refused operation reaches the document as. + * + * Built here on a live refusal and on a replayed one alike, so the two cannot + * diverge: what the record carries is a vocabulary and a word, and this is the + * one place either of them becomes an error a component shows. + */ +function failure(element: string, data: ReplGitRefusalData): Error { + if (data.vocabulary === "native-git") { + return new GitRefusal(data.reason); + } + if (data.vocabulary === "ordinary-identity") { + return new UnresolvedGitIdentityError(); + } + const refusal = restoredGitRefusal(element, data.reason); + if (refusal === undefined) { + return new ReplGitRecordError( + `the saved refusal for ${element} names a condition this build has no word for, so what it ` + + "refused is unknown.", + ); + } + return refusal; +} + +/** + * The refusal data a parsed failure carries. + * + * The codec's failure side is `ReplGitRefusal` by construction, so anything + * else here is this host's own invariant rather than something a record did. + */ +function refusalOf(error: Error): ReplGitRefusalData { + if (!(error instanceof ReplGitRefusal)) { + throw new ReplGitRecordError( + "a saved local Git refusal carries no closed classification, so what it refused is unknown.", + ); + } + return error.refusal; +} + +/** + * That the checkout a saved result names is one this entry registered. + * + * Pure arithmetic over parsed facts. The identity has to be the one this call + * authenticated, and the path and the worktree name have to be a placement this + * entry holds. What it deliberately does not require is that the result name the + * selection's own checkout: a lexical `` can own several worktrees, + * and a `` into one of them selects that one. + * + * Effective-cwd selection is checked on the live path, where a directory is + * resolved. A replay does not resolve an old alias again to re-prove routing + * that already happened. + */ +function authorized( + entry: ReplGitEntry, + element: string, + request: ReplGitRequest, + value: ReplGitValue, +): void { + const identity = value.checkout; + for (const candidate of entry.checkouts()) { + if ( + candidate.checkout.root === identity.checkoutPath && + candidate.checkout.repositoryName === identity.repositoryName && + candidate.checkout.worktreeName === identity.worktreeName && + sameRepositoryIdentity(candidate.checkout.identity, request.repository) + ) { + return; + } + } + throw new ReplGitRecordError( + `the saved result for ${element} names a checkout this entry holds no placement for, so this ` + + "history does not say where the operation ran.", + ); +} + +function mismatch(element: string): ReplGitRecordError { + return new ReplGitRecordError( + `the saved result standing where ${element} belongs describes another operation, so this ` + + "history does not say what happened here.", + ); +} + +/** The canonical directory this path resolves to, or the path as written. */ +function* canonicalPath(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return path; + } +} + +/** + * The live boundary one recorded local Git operation publishes through. + * + * The same contract this feature's file work and its selections publish under. + * 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: a + * performer that broke its contract may or may not have moved a branch, so + * recording a completion for it would let a continuation skip work that never + * happened. + * + * A throw therefore activates the durability fence and is re-raised with its + * identity intact, publishing nothing — and a continuation takes the ordinary + * live path, where today's checkout decides what happens next. A valid outcome, + * success or classified refusal, is published once and returned only after the + * append is acknowledged. A failing append is the engine's own persistence fence + * and is not caught here. + */ +const gitCoordinator: 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/local.ts b/packages/git/src/deno/retained-repository/local.ts index baa3181d5..46c6881ca 100644 --- a/packages/git/src/deno/retained-repository/local.ts +++ b/packages/git/src/deno/retained-repository/local.ts @@ -1,10 +1,10 @@ /** * One local file operation, performed once and answered from its result. * - * This is the whole of the REPL's local durability: an ordinary durable - * operation whose name is this entry's own, whose value is what the ordinary - * Files call produced, and whose replay is the durable engine's own positional - * matching. A completed operation answers with its saved result and touches no + * The file half of the REPL's local durability, and the substrate the local Git + * half beside it reuses: an ordinary durable operation whose name is this + * entry's own, whose value is what the ordinary Files call produced, and whose + * replay is the durable engine's own positional matching. A completed operation answers with its saved result and touches no * file, however the file has changed since. The first operation with no saved * result runs live, against whatever the environment actually holds. * diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/git/src/deno/retained-repository/provider.ts index 7744bba59..5470440f9 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/git/src/deno/retained-repository/provider.ts @@ -17,24 +17,28 @@ * * ## What it is not * - * This records file and directory work and the managed checkouts a document - * selects to do it in: authored and generated writes, authored deletions, the - * directory an authored `` ensures, and the `` and - * `` selections that say where. There is no local Git operation — - * no Add, Switch, Commit or Push — and nothing external. The components for - * those stay declared where they already are and refuse through their own - * unchanged missing-provider defaults, and the selection handlers installed - * here are not the ordinary all-feature provider, which would enable them. + * This records file and directory work, the managed checkouts a document + * selects to do it in, and the three local Git operations: authored and + * generated writes, authored deletions, the directory an authored `` + * ensures, the `` and `` selections that say where, and + * ``, `` and `` in the checkout those select. + * There is nothing external — no ``, no pull request, no Issue. Those + * components stay declared where they already are and refuse through their own + * unchanged missing-provider defaults, and what is installed here is not the + * ordinary all-feature provider, which would enable them. * * ## Installing it reaches nothing * - * No directory, no lease, no `git`, no filesystem read, and no managed root. - * 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. Being outside a checkout is remembered as "there is - * none" rather than refused, so an entry writing files beside the document runs - * exactly as it would anywhere else. + * No directory, no lease, no `git`, no filesystem read, no commit identity and + * no managed root. 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 + * who it is. Being outside a checkout is remembered as "there is none" rather + * than refused, so an entry writing files beside the document runs exactly as it + * would anywhere else. */ import { until, useScope } from "effection"; @@ -51,17 +55,23 @@ 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 { holdManagedPlacement, useCommonDirectoryLease } from "./leases.ts"; import type { ReplAmbientCheckout } from "../../retained-repository/records.ts"; import { currentCoroutine, selectedCheckout } from "./local.ts"; import { replFileMiddleware } from "./files.ts"; import type { ReplLocalRecorder, ReplManagedPlacement } from "./files.ts"; +import { replGitMiddleware } from "./git.ts"; +import type { ReplGitCheckout, ReplGitEntry } from "./git.ts"; import { placementSubject, selectReplAmbientOwner, @@ -93,6 +103,16 @@ export interface ReplRecordingOptions { 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. + * + * Absent uses native Git with the caller's own environment and the directory + * the entry started in, which is the whole question. A suite substitutes it to + * say what this host knows — including that it knows nothing — and to observe + * that a completed Commit asks nothing at all. Nothing a document, a prop or a + * Context value holds can select or replace it. + */ + readonly identity?: IdentityReader; } /** @@ -159,6 +179,13 @@ export function* useReplRepositoryRecording(options: ReplRecordingOptions = {}): // leaves PR 1's ambient routing the only route there. const placements: ReplManagedPlacement[] = []; + // The checkouts native Git may run in, which is a wider set than the one + // above: the ambient owner a root `` recorded is a checkout this + // entry can commit in, and it is deliberately not a managed placement for + // file coverage — registering it there would shadow the saved ambient record a + // coroutine consumes at its own cursor. + const gitCheckouts: ReplGitCheckout[] = []; + const recorder: ReplLocalRecorder = { *selection(): Operation { const coroutine = yield* currentCoroutine(); @@ -275,6 +302,10 @@ export function* useReplRepositoryRecording(options: ReplRecordingOptions = {}): serialize, selections, remember(registration: ReplPlacementRegistration): void { + // Every successfully parsed placement, the ambient owner included: a + // Repository this entry authenticated is one its Git operations may run + // in, whether or not a managed root placed it. + rememberGitCheckout(registration); const slot = registration.slot; // The root this placement recorded, not the one configured now: a // restored placement's slot belongs to the root it was selected under, @@ -305,9 +336,60 @@ export function* useReplRepositoryRecording(options: ReplRecordingOptions = {}): }, }; - // Repository selection, and nothing else this package can do. Installing the - // ordinary all-feature provider here would enable local Git, publication and - // external workers, none of which this delivery retains. + /** + * Remember where native Git may run, replacing an earlier registration of + * the same checkout. + * + * Routing data only, derived from the placement rather than read out of the + * journal: the repository's own name, the Worktree's display name for a + * linked checkout and `null` otherwise. No origin, because a local result + * confers no publication evidence and this provider publishes nothing. + * + * Repeated registration replaces equivalent routing data and never suppresses + * a durable call: which operations happened is the journal's answer, and this + * is only where they are allowed to happen. + */ + function rememberGitCheckout(registration: ReplPlacementRegistration): void { + const placement = registration.placement; + const checkout: RegisteredCheckout = Object.freeze({ + root: placement.checkoutRoot, + identity: placement.identity, + repositoryName: placement.identity.name, + worktreeName: placement.kind === "worktree" ? placement.displayName : null, + origin: undefined, + }); + const held: ReplGitCheckout = Object.freeze({ checkout, registration }); + const existing = gitCheckouts.findIndex((entry_) => entry_.checkout.root === checkout.root); + if (existing < 0) { + gitCheckouts.push(held); + return; + } + gitCheckouts[existing] = held; + } + + // From the trusted host: the four strings a commit records about who made it, + // read from the directory this entry started in. A host that cannot say is + // remembered as not saying for the rest of the entry, and only `` + // refuses — so the read happens when a commit needs it, not before, and a + // completed Commit's replay never reaches it. + const useIdentity = lazily(entry, function* (): Operation { + return yield* captureCommitIdentity(options.identity ?? denoIdentityReader(directory)); + }); + + const gitEntry: ReplGitEntry = { + git: useGit, + identity: useIdentity, + holdSlot, + holdCommon: (commonDirectory: string) => holdCommon(commonDirectory), + serialize, + selections, + checkouts: () => gitCheckouts, + }; + + // Repository selection and the three local operations, and nothing else this + // package can do. Installing the ordinary all-feature provider here would + // enable publication and the external workers, neither of which this delivery + // retains. yield* Repository.around( { select: ([request]: [RepositoryRequest]): Operation => @@ -323,4 +405,9 @@ export function* useReplRepositoryRecording(options: ReplRecordingOptions = {}): }, { at: "min" }, ); + + // The implementation slot, where the ordinary provider installs its own, so + // middleware a document or a host puts above this composes exactly as it does + // in an ordinary run. + yield* Git.around(replGitMiddleware(gitEntry), { at: "min" }); } diff --git a/packages/git/src/retained-repository/admission.ts b/packages/git/src/retained-repository/admission.ts index e6488a723..c95e27bd0 100644 --- a/packages/git/src/retained-repository/admission.ts +++ b/packages/git/src/retained-repository/admission.ts @@ -45,12 +45,19 @@ import { replPlacementRecordName, REPL_REPOSITORY_PLACEMENT, } from "./placement-records.ts"; +import { + parseReplGitRecord, + replGitFingerprint, + replGitRecordName, + REPL_REPOSITORY_GIT, +} from "./git-records.ts"; /** The durable types this reader owns, and the only ones it reads. */ const OWNED: ReadonlySet = new Set([ REPL_REPOSITORY_LOCAL, REPL_REPOSITORY_SELECTION, REPL_REPOSITORY_PLACEMENT, + REPL_REPOSITORY_GIT, ]); /** Why a history cannot be continued. */ @@ -97,6 +104,14 @@ export function* admitReplRepositoryHistory(retained: readonly DurableEvent[]): continue; } if (event.result.status !== "ok") { + // A local Git operation's own refusals live inside its successful + // payload, so a failed protocol envelope under that type is not a record + // this host ever wrote — and what it says about work already done is + // unknown. The three older types keep their existing policy, where a + // non-ok envelope is the engine's own failure rather than theirs. + if (type === REPL_REPOSITORY_GIT) { + throw malformed(REPL_REPOSITORY_GIT); + } continue; } const payload = event.result.value; @@ -116,10 +131,40 @@ export function* admitReplRepositoryHistory(retained: readonly DurableEvent[]): yield* admitPlacement(payload, event.description.name); continue; } + if (type === REPL_REPOSITORY_GIT) { + yield* admitGit(payload, event.description.name); + continue; + } yield* admitLocal(payload, event.description.name); } } +/** + * Hold one recorded local Git operation to the request it carries. + * + * The same two questions the other records answer, over a record whose success + * side is an operation result: a saved result that does not describe the request + * beside it — another checkout's repository, another branch, another message, a + * transition the operation does not make — does not parse, so it is refused here + * rather than binding an object id on replay. + * + * It runs no `git` and opens nothing. Whether the branch a record names is still + * there is not this reader's question and is not a question a continuation asks. + */ +function* admitGit(payload: unknown, name: string): Operation { + const record = parseReplGitRecord(payload); + if (record === undefined) { + throw malformed(REPL_REPOSITORY_GIT); + } + const fingerprint = yield* replGitFingerprint(record.request); + if (record.fingerprint !== fingerprint) { + throw changed(REPL_REPOSITORY_GIT); + } + if (name !== replGitRecordName({ operation: record.operation, fingerprint })) { + throw misnamed(REPL_REPOSITORY_GIT); + } +} + function* admitLocal(payload: unknown, name: string): Operation { const record = parseReplLocalRecord(payload); if (record === undefined) { diff --git a/packages/git/src/retained-repository/git-records.ts b/packages/git/src/retained-repository/git-records.ts new file mode 100644 index 000000000..17bc425fc --- /dev/null +++ b/packages/git/src/retained-repository/git-records.ts @@ -0,0 +1,741 @@ +/** + * What one REPL entry's local Add, Switch and Commit look like on the wire. + * + * An entry that stages a path, switches a branch or commits the index records + * what the operation produced. Reopen the entry and the saved result answers + * again: the same object id, the same before and after readings, and no `git`. + * That is what lets an entry interrupted at a question be continued rather than + * repeated — and it is why a saved commit id describes the commit that was made + * then, not wherever HEAD is now. + * + * Three operations are recorded and nothing else. Push, pull requests and + * Issues have their own protocols; a Glob, a path check and a `` + * selection are recorded elsewhere or nowhere. + * + * ## Identity is the request, and position is the journal's + * + * A record's name is the digest of the record version, the operation and the + * whole normalized request: the repository identity, the checkout the + * authenticated selection named, its common Git directory, the working + * directory as received, and what the operation itself asks for. Two equal + * requests produce one name, and that is allowed — telling two of them apart is + * the durable engine's own positional matching, and nothing here searches + * history by name. + * + * The working directory is named as it arrived. Resolving it through the + * filesystem is a fact about the host at that moment, and the question the + * document asked is not. + * + * Neither the commit message nor the commit identity is a request field. The + * message is described by a digest and a byte length, because a journal already + * holds the authored source and a second copy of the text would publish it + * again. The author and committer are live inputs to a commit rather than + * coordinates a replay matches on: who is at the keyboard is not part of which + * operation this is. + * + * ## What a result may carry + * + * The ordinary result the performer returned, with its absolute host checkout + * identity and its fixed scalar fields — the branch, commit, HEAD tree and index + * tree on both sides, plus what the operation itself produced. These are small + * operation results: no file list, no checkout snapshot, no Git output, no + * credential and no handle. + * + * A refusal carries a vocabulary and a word from a closed set, so a continuation + * raises the same ordinary failure the live call raised. Nothing a host printed + * reaches a record: an unrecognized condition is infrastructure, and publishing + * it as something the document asked for is exactly what must not happen. + */ + +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 { + gitAddResultJson, + gitCommitResultJson, + gitSwitchResultJson, + parseGitCheckoutState, + parseGitCommitMessageSource, + parseGitObjectId, + type GitAddResult, + type GitCheckoutIdentity, + type GitCommitMessageSource, + type GitCommitResult, + type GitSwitchResult, +} from "../composition/git-records.ts"; +import { + parseRepositoryIdentity, + repositoryIdentityJson, + type RepositoryIdentity, +} from "../composition/selection.ts"; +import { digest, RECORD_PREFIX, REPL_REPOSITORY_VERSION } from "./records.ts"; + +/** The durable type one completed local Git operation is journaled under. */ +export const REPL_REPOSITORY_GIT = "repl_repository_git"; + +/** Which local Git operation a record describes. */ +export type ReplGitOperation = "add" | "switch" | "commit"; + +const GIT_OPERATIONS: readonly ReplGitOperation[] = ["add", "switch", "commit"]; + +/** Where every local Git operation runs, as the entry authenticated it. */ +export interface ReplGitPlace { + /** 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 one `` asks for: the pathspecs, as the document wrote them. */ +export interface ReplGitAddRequest extends ReplGitPlace { + readonly operation: "add"; + /** Nonempty, preserving order, duplicates and every spelling. */ + readonly paths: readonly string[]; +} + +/** What one `` asks for. */ +export interface ReplGitSwitchRequest extends ReplGitPlace { + readonly operation: "switch"; + readonly branch: string; + /** The base exactly as it was supplied, or `null` when none was. */ + readonly base: string | null; +} + +/** + * What one `` asks for, message included by description. + * + * The digest and the byte length are the admitted canonical message's, which is + * what Git receives and what the result's own evidence describes. The text + * itself stays in the live callback's closure. + */ +export interface ReplGitCommitRequest extends ReplGitPlace { + readonly operation: "commit"; + readonly messageSource: GitCommitMessageSource; + /** Lowercase SHA-256 of the canonical message bytes. */ + readonly messageDigest: string; + /** The canonical message's length in UTF-8 bytes. */ + readonly messageLength: number; +} + +export type ReplGitRequest = ReplGitAddRequest | ReplGitSwitchRequest | ReplGitCommitRequest; + +/** What one completed local Git operation produced. */ +export type ReplGitValue = GitAddResult | GitSwitchResult | GitCommitResult; + +/** + * Which route refused a local Git operation. + * + * Three, because the three conditions arrive by three routes and each rebuilds + * its own failure. A native `GitRefusal` says what `git` itself reported; a + * composition refusal is the provider's own closed classification; and an + * absent ordinary identity is the host saying it cannot name a committer. + * + * Keeping them apart is what stops a native Add's ignored-path refusal from + * being rebuilt with the Workflow formatter's sentence, which says the staging + * was discarded. Nothing rolls back here, so nothing may say that. + */ +export type ReplGitVocabulary = "native-git" | "composition-git" | "ordinary-identity"; + +/** A word one local Git operation may be refused under. */ +export type ReplGitRefusalReason = + | "invalid-branch" + | "unresolved-base" + | "branch-checked-out-elsewhere" + | "overwrites-local-changes" + | "unmatched-pathspec" + | "ignored-pathspec" + | "outside-checkout-pathspec" + | "invalid-pathspec-magic" + | "empty-index" + | "unresolved-identity"; + +/** The closed classification one refused local Git operation carries. */ +export interface ReplGitRefusalData { + readonly vocabulary: ReplGitVocabulary; + readonly reason: ReplGitRefusalReason; +} + +/** + * Which operation each word belongs to, and which route reports it. + * + * One table rather than three, because a refusal is exactly one condition: the + * operation it can happen to and the route that reports it are both functions + * of the word. A record pairing a word with another operation or another + * vocabulary describes a refusal this host does not produce, so it is not one it + * may read back as an answer. + */ +const REFUSALS: ReadonlyMap< + ReplGitRefusalReason, + { readonly operation: ReplGitOperation; readonly vocabulary: ReplGitVocabulary } +> = new Map([ + ["invalid-branch", { operation: "switch", vocabulary: "composition-git" }], + ["unresolved-base", { operation: "switch", vocabulary: "composition-git" }], + ["branch-checked-out-elsewhere", { operation: "switch", vocabulary: "native-git" }], + ["overwrites-local-changes", { operation: "switch", vocabulary: "native-git" }], + ["unmatched-pathspec", { operation: "add", vocabulary: "native-git" }], + ["ignored-pathspec", { operation: "add", vocabulary: "native-git" }], + ["outside-checkout-pathspec", { operation: "add", vocabulary: "native-git" }], + ["invalid-pathspec-magic", { operation: "add", vocabulary: "native-git" }], + ["empty-index", { operation: "commit", vocabulary: "composition-git" }], + ["unresolved-identity", { operation: "commit", vocabulary: "ordinary-identity" }], +] as const); + +/** + * A refused local Git operation, as the record carries it. + * + * The failure side of the internal `Result`, and never what a document sees: the + * adapter rebuilds the ordinary error its own vocabulary names, by the same + * route on a live refusal and on a replayed one, so the two cannot diverge. + */ +export class ReplGitRefusal extends Error { + override name = "ReplGitRefusal"; + + readonly refusal: ReplGitRefusalData; + + constructor(refusal: ReplGitRefusalData) { + super(`${refusal.vocabulary} refused: ${refusal.reason}`); + this.refusal = refusal; + } +} + +/** One completed local Git operation, as a continuation reads it back. */ +export interface ReplGitRecord { + readonly version: typeof REPL_REPOSITORY_VERSION; + readonly operation: ReplGitOperation; + /** The digest of the version, the operation and the normalized request. */ + readonly fingerprint: string; + readonly request: ReplGitRequest; + /** The result it produced, or the refusal it answered with. */ + readonly outcome: Result; +} + +/** The stable name one local Git operation's record is journaled under. */ +export function replGitRecordName(record: { + readonly operation: ReplGitOperation; + readonly fingerprint: string; +}): string { + return canonicalJson([ + RECORD_PREFIX, + "git", + REPL_REPOSITORY_VERSION, + record.operation, + record.fingerprint, + ]); +} + +/** The digest of everything one local Git operation asks for. */ +export function replGitFingerprint(request: ReplGitRequest): Operation { + return digest({ + version: REPL_REPOSITORY_VERSION, + operation: request.operation, + request: requestJson(request), + }); +} + +/** The payload one completed local Git operation is journaled with. */ +export function replGitRecordJson(input: { + readonly fingerprint: string; + readonly request: ReplGitRequest; + readonly outcome: Result; +}): Json { + return { + version: REPL_REPOSITORY_VERSION, + operation: input.request.operation, + fingerprint: input.fingerprint, + request: requestJson(input.request), + outcome: outcomeJson(input.request.operation, input.outcome), + }; +} + +/** + * The local Git operation this saved value describes, or `undefined`. + * + * Total, and exact about every member. A payload carrying anything this version + * did not write describes something other than an operation this host + * performed, and answering a document from it would bind an object id nobody + * produced. + */ +export function parseReplGitRecord(value: unknown): ReplGitRecord | undefined { + const read = members(value, ["version", "operation", "fingerprint", "request", "outcome"]); + if (read === undefined || read["version"] !== REPL_REPOSITORY_VERSION) { + return undefined; + } + const operation = parseReplGitOperation(read["operation"]); + const fingerprint = text(read["fingerprint"]); + if (operation === undefined || fingerprint === undefined) { + return undefined; + } + const request = parseRequest(read["request"], operation); + if (request === undefined) { + return undefined; + } + const outcome = parseOutcome(read["outcome"], request); + if (outcome === undefined) { + return undefined; + } + return Object.freeze({ + version: REPL_REPOSITORY_VERSION, + operation, + fingerprint, + request, + outcome, + }); +} + +/** The operation this value names, or `undefined`. */ +export function parseReplGitOperation(value: unknown): ReplGitOperation | undefined { + return GIT_OPERATIONS.find((operation) => operation === value); +} + +/** + * The refusal data this error carries, or `undefined` when it carries none. + * + * What decides whether a failed operation may be written down at all: a + * classified refusal is an answer the document asked for, and anything else is a + * condition this host has no word for. + */ +export function replGitRefusalOf(error: unknown): ReplGitRefusalData | undefined { + return error instanceof ReplGitRefusal ? error.refusal : undefined; +} + +/** + * The refusal this word and vocabulary name for this operation, or `undefined`. + * + * The one place a word becomes a classification, so a live refusal and a + * replayed one are classified by the same table. A word belonging to another + * operation, or reported through a route that never reports it, is not a refusal + * this format carries. + */ +export function replGitRefusal( + operation: ReplGitOperation, + vocabulary: string, + reason: string, +): ReplGitRefusalData | undefined { + for (const [word, source] of REFUSALS) { + if (word !== reason || source.operation !== operation || source.vocabulary !== vocabulary) { + continue; + } + return Object.freeze({ vocabulary: source.vocabulary, reason: word }); + } + return undefined; +} + +function requestJson(request: ReplGitRequest): Json { + const place = { + repository: repositoryIdentityJson(request.repository), + selectionCheckout: request.selectionCheckout, + commonDirectory: request.commonDirectory, + workingDirectory: request.workingDirectory, + }; + if (request.operation === "add") { + return { ...place, paths: [...request.paths] }; + } + if (request.operation === "switch") { + return { ...place, branch: request.branch, base: request.base }; + } + return { + ...place, + messageSource: request.messageSource, + messageDigest: request.messageDigest, + messageLength: request.messageLength, + }; +} + +function outcomeJson(operation: ReplGitOperation, outcome: Result): Json { + if (!outcome.ok) { + const data = refusalData(outcome.error); + return { ok: false, error: { vocabulary: data.vocabulary, reason: data.reason } }; + } + return { ok: true, value: valueJson(operation, outcome.value) }; +} + +/** + * One result as the shared writer for its own operation writes it. + * + * Dispatched on the operation the request names rather than on which members + * the value happens to carry: what this record describes is decided by the + * request, and a value that is not that operation's result is caught by the + * writer refusing to read it. + */ +function valueJson(operation: ReplGitOperation, value: ReplGitValue): Json { + if (operation === "add" && "paths" in value) { + return gitAddResultJson(value); + } + if (operation === "switch" && "requestedBranch" in value) { + return gitSwitchResultJson(value); + } + if (operation === "commit" && "commit" in value) { + return gitCommitResultJson(value); + } + throw new TypeError(`a retained ${operation} carries no ${operation} result`); +} + +function refusalData(error: Error): ReplGitRefusalData { + const data = replGitRefusalOf(error); + if (data === undefined) { + throw new TypeError("a retained local Git refusal carries no closed classification"); + } + return data; +} + +const PLACE_MEMBERS = [ + "repository", + "selectionCheckout", + "commonDirectory", + "workingDirectory", +] as const; + +function parseRequest(value: unknown, operation: ReplGitOperation): ReplGitRequest | undefined { + const read = members(value, [...PLACE_MEMBERS, ...OWN_MEMBERS[operation]]); + 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; + } + const place = { repository, selectionCheckout, commonDirectory, workingDirectory }; + if (operation === "add") { + const paths = pathspecs(read["paths"]); + return paths === undefined + ? undefined + : Object.freeze({ operation: "add" as const, ...place, paths }); + } + if (operation === "switch") { + const branch = text(read["branch"]); + const base = optionalText(read["base"]); + return branch === undefined || base === undefined + ? undefined + : Object.freeze({ operation: "switch" as const, ...place, branch, base }); + } + const messageSource = parseGitCommitMessageSource(read["messageSource"]); + const messageDigest = sha256(read["messageDigest"]); + const messageLength = count(read["messageLength"]); + if (messageSource === undefined || messageDigest === undefined || messageLength === undefined) { + return undefined; + } + return Object.freeze({ + operation: "commit" as const, + ...place, + messageSource, + messageDigest, + messageLength, + }); +} + +const OWN_MEMBERS: Readonly> = { + add: ["paths"], + switch: ["branch", "base"], + commit: ["messageSource", "messageDigest", "messageLength"], +}; + +function parseOutcome(value: unknown, request: ReplGitRequest): Result | undefined { + const refused = members(value, ["ok", "error"]); + const succeeded = refused === undefined ? members(value, ["ok", "value"]) : undefined; + if (refused !== undefined) { + if (refused["ok"] !== false) { + return undefined; + } + const data = parseRefusal(refused["error"], request.operation); + return data === undefined ? undefined : Err(new ReplGitRefusal(data)); + } + if (succeeded === undefined || succeeded["ok"] !== true) { + return undefined; + } + const result = parseReplGitResult(succeeded["value"], request); + return result === undefined ? undefined : Ok(result); +} + +function parseRefusal(value: unknown, operation: ReplGitOperation): ReplGitRefusalData | undefined { + const read = members(value, ["vocabulary", "reason"]); + if (read === undefined) { + return undefined; + } + const vocabulary = read["vocabulary"]; + const reason = read["reason"]; + if (typeof vocabulary !== "string" || typeof reason !== "string") { + return undefined; + } + return replGitRefusal(operation, vocabulary, reason); +} + +/** + * The result this saved value describes for this request, or `undefined`. + * + * The same arithmetic the Workflow parsers apply, over an absolute host + * checkout path instead of a Workspace-relative one. Where a checkout path is + * placed is this host's own question and is held to the entry's private + * registrations by the caller; what is decided here is that the result + * describes *this* request and describes a transition the operation makes. + */ +export function parseReplGitResult( + value: unknown, + request: ReplGitRequest, +): ReplGitValue | undefined { + if (request.operation === "add") { + return addResult(value, request); + } + if (request.operation === "switch") { + return switchResult(value, request); + } + return commitResult(value, request); +} + +function addResult(value: unknown, request: ReplGitAddRequest): GitAddResult | undefined { + const record = members(value, ["checkout", "paths", "before", "after"]); + if (record === undefined) { + return undefined; + } + const format = request.repository.objectFormat; + const checkout = hostCheckoutIdentity(record["checkout"], request); + const paths = pathspecs(record["paths"]); + const before = parseGitCheckoutState(record["before"], format); + const after = parseGitCheckoutState(record["after"], format); + if ( + checkout === undefined || + paths === undefined || + before === undefined || + after === undefined || + !samePaths(paths, request.paths) + ) { + return undefined; + } + // Staging moves the index and nothing else, so the branch, the commit and the + // HEAD tree are where the command found them. The index tree may differ or + // not: staging what is already staged changes nothing, and a result saying so + // is describing what happened. + if ( + before.branch !== after.branch || + before.commit !== after.commit || + before.headTree !== after.headTree + ) { + return undefined; + } + return Object.freeze({ checkout, paths, before, after }); +} + +function switchResult(value: unknown, request: ReplGitSwitchRequest): GitSwitchResult | undefined { + const record = members(value, [ + "checkout", + "requestedBranch", + "resolvedBranch", + "requestedBase", + "resolvedBase", + "before", + "after", + ]); + if (record === undefined) { + return undefined; + } + const format = request.repository.objectFormat; + const checkout = hostCheckoutIdentity(record["checkout"], request); + const requestedBranch = text(record["requestedBranch"]); + const resolvedBranch = text(record["resolvedBranch"]); + const requestedBase = optionalText(record["requestedBase"]); + const resolvedBase = + record["resolvedBase"] === null ? null : parseGitObjectId(record["resolvedBase"], format); + const before = parseGitCheckoutState(record["before"], format); + const after = parseGitCheckoutState(record["after"], format); + if ( + checkout === undefined || + requestedBranch === undefined || + resolvedBranch === undefined || + requestedBase === undefined || + resolvedBase === undefined || + before === undefined || + after === undefined + ) { + return undefined; + } + if ( + requestedBranch !== request.branch || + requestedBase !== request.base || + resolvedBranch !== request.branch || + after.branch !== resolvedBranch + ) { + return undefined; + } + const created = resolvedBase !== null; + if (created && (after.commit !== resolvedBase || before.branch === after.branch)) { + return undefined; + } + // Switching to the branch a checkout is already on moves nothing: no reset, no + // reference change, and nothing staged or unstaged. + if ( + !created && + before.branch === after.branch && + (before.commit !== after.commit || + before.headTree !== after.headTree || + before.indexTree !== after.indexTree) + ) { + return undefined; + } + return Object.freeze({ + checkout, + requestedBranch, + resolvedBranch, + requestedBase, + resolvedBase, + before, + after, + }); +} + +function commitResult(value: unknown, request: ReplGitCommitRequest): GitCommitResult | undefined { + const record = members(value, [ + "checkout", + "messageSource", + "messageDigest", + "messageLength", + "parent", + "tree", + "commit", + "committedAt", + "before", + "after", + ]); + if (record === undefined) { + return undefined; + } + const format = request.repository.objectFormat; + const checkout = hostCheckoutIdentity(record["checkout"], request); + const messageSource = parseGitCommitMessageSource(record["messageSource"]); + const messageDigest = sha256(record["messageDigest"]); + const messageLength = count(record["messageLength"]); + const parent = parseGitObjectId(record["parent"], format); + const tree = parseGitObjectId(record["tree"], format); + const commit = parseGitObjectId(record["commit"], format); + const committedAt = count(record["committedAt"]); + const before = parseGitCheckoutState(record["before"], format); + const after = parseGitCheckoutState(record["after"], format); + if ( + checkout === undefined || + messageSource === undefined || + messageDigest === undefined || + messageLength === undefined || + parent === undefined || + tree === undefined || + commit === undefined || + committedAt === undefined || + before === undefined || + after === undefined + ) { + return undefined; + } + if ( + messageSource !== request.messageSource || + messageDigest !== request.messageDigest || + messageLength !== request.messageLength + ) { + return undefined; + } + // A commit of the index has exactly one parent and that parent is where the + // checkout was; its tree is the tree the index described; and the checkout + // ends on that commit with HEAD and index both equal to it. Committing an + // index that already matched HEAD is the condition this operation refuses + // rather than one it records. + if ( + parent !== before.commit || + tree !== before.indexTree || + commit !== after.commit || + after.headTree !== tree || + after.indexTree !== tree || + before.branch !== after.branch || + parent === commit || + before.indexTree === before.headTree + ) { + return undefined; + } + return Object.freeze({ + checkout, + messageSource, + messageDigest, + messageLength, + parent, + tree, + commit, + committedAt, + before, + after, + }); +} + +/** + * The checkout identity a native result carries, or `undefined`. + * + * The checkout path is an absolute host path, which is what the ordinary + * performers answer with and what this feature's placement records already + * carry. It is read as the string it is: whether it names a checkout this entry + * registered is decided by the caller against its own private registrations, + * never by consulting a filesystem. + * + * The repository name is held to the authenticated identity's, because every + * checkout of one repository carries that repository's name and a result naming + * another repository is not this request's. + */ +function hostCheckoutIdentity( + value: unknown, + request: ReplGitRequest, +): GitCheckoutIdentity | undefined { + const record = members(value, ["repositoryName", "worktreeName", "checkoutPath"]); + if (record === undefined) { + return undefined; + } + const repositoryName = text(record["repositoryName"]); + const worktreeName = optionalText(record["worktreeName"]); + const checkoutPath = text(record["checkoutPath"]); + if ( + repositoryName === undefined || + worktreeName === undefined || + checkoutPath === undefined || + repositoryName !== request.repository.name + ) { + return undefined; + } + return Object.freeze({ repositoryName, worktreeName, checkoutPath }); +} + +/** The pathspec array this value is, once every entry is one. */ +function pathspecs(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + const entries: string[] = []; + for (const entry of value) { + const pathspec = text(entry); + if (pathspec === undefined) { + return undefined; + } + entries.push(pathspec); + } + return Object.freeze(entries); +} + +function samePaths(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((entry, index) => entry === right[index]); +} + +/** A SHA-256 digest, which is one width and one case wherever one is written. */ +function sha256(value: unknown): string | undefined { + const candidate = text(value); + return candidate !== undefined && /^[0-9a-f]{64}$/.test(candidate) ? candidate : undefined; +} + +/** A count of something, which is a whole number and never a negative one. */ +function count(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; +} diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 762290021..299ae1fd2 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -93,6 +93,7 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/issue/tracker.ts", "src/plugin.ts", "src/retained-repository/admission.ts", + "src/retained-repository/git-records.ts", "src/retained-repository/placement-records.ts", "src/retained-repository/records.ts", ]; @@ -161,6 +162,7 @@ const DENO_ADAPTER: readonly string[] = [ "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", diff --git a/packages/git/tests/retained-repository-git.test.ts b/packages/git/tests/retained-repository-git.test.ts new file mode 100644 index 000000000..5ec4fc431 --- /dev/null +++ b/packages/git/tests/retained-repository-git.test.ts @@ -0,0 +1,1385 @@ +/** + * What one REPL entry's local Add, Switch and Commit retain, replay and refuse + * to guess. + * + * Everything here runs a document through canonical execution with the + * production entry host installed, so what is proven is the whole path: the + * authored component asks the composition Api, the ordinary performer runs real + * `git` against a real checkout, and the result is recorded once. A fixture that + * asserted on the record without the performer would prove the bookkeeping and + * nothing about the answer. + * + * The contract these rows hold the host to is the REPL's execution model. + * Replay restores recorded results; it does not restore the checkout or require + * it to match. So the rows that matter most are the ones where the two + * disagree: a branch, an index and a HEAD moved behind a completed commit, a + * selected worktree deleted outright, and a commit that physically happened and + * was never acknowledged. + * + * ## How "nothing was resolved" is proven + * + * Counting `git` is not enough: routing a live call canonicalizes its working + * directory and chooses among this entry's registered checkouts, and neither + * touches `git`. So the directory an entry is opened in is a symbolic link, and + * a continuation runs with that link pointing somewhere else entirely. Live + * routing would resolve it, find it inside none of the checkouts this entry + * registered, and fail admission. A continuation that answers normally has + * therefore resolved nothing and selected nothing. + * + * Ownership is proven the same way round. A continuation runs while this suite + * holds the checkout's own mutation lease, which the operating system would + * refuse a second descriptor on. One that answers has taken no lock. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation } from "effection"; +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 { 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 } 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 { 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/placement.ts"; + +interface Fixture { + /** The scenario's own directory: checkouts, managed root and HOME. */ + readonly root: string; + /** An ordinary checkout with one commit, which entries are opened in. */ + readonly checkout: string; + /** A second ordinary checkout, so "another repository" is a real other one. */ + readonly elsewhere: string; + /** The repository a `` row clones from. */ + readonly origin: string; + /** Where this scenario's managed checkouts live. */ + readonly managed: string; + /** + * A symbolic link an entry is opened through. + * + * Pointed at {@link Fixture.checkout} to begin with, and re-pointed by the + * rows that need "nothing resolved this directory" to be a positive fact. + */ + readonly link: string; +} + +/** One seeded checkout with a commit in it, plus this scenario's own root. */ +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* 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); + made.push(directory); + } + const link = join(root, "entered"); + yield* until(symlink(made[0] ?? "", link)); + yield* provide({ + root, + checkout: made[0] ?? "", + elsewhere: made[1] ?? "", + origin: made[2] ?? "", + managed: join(root, "repositories"), + link, + }); + }); +} + +/** Point the link an entry is opened through at another directory. */ +function* repoint(fixture: Fixture, target: string): Operation { + yield* until(unlink(fixture.link)); + yield* until(symlink(target, fixture.link)); +} + +/** 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); + } +} + +/** 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. */ +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 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: [] }; +} + +/** The Git subcommands one entry issued, in order. */ +function subcommands(counted: Counters): string[] { + return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +} + +/** The host filesystem, with the document's own writes and reads counted. */ +function countingFiles(counted: Counters): FilesHandler { + const host = hostFilesHandler(); + return { + checkFilePath: (input) => host.checkFilePath(input), + *readTextFile(input: FilePathInput) { + counted.reads.push(input.path); + return yield* host.readTextFile(input); + }, + globFiles: (input) => host.globFiles(input), + temporaryDirectory: () => host.temporaryDirectory(), + *writeTextFile(input: FileWriteInput) { + counted.writes.push(input.path); + return yield* host.writeTextFile(input); + }, + deleteFile: (input) => host.deleteFile(input), + ensureDirectory: (input) => host.ensureDirectory(input), + }; +} + +/** What a row may do to one `git` invocation before the real one runs. */ +type GitBarrier = (invocation: GitInvocation) => Operation; + +/** + * The production host, counted, and interruptible at a named subcommand. + * + * Every leaf 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. + */ +function countingHost( + counted: Counters, + barrier?: GitBarrier, + intercept?: (invocation: GitInvocation) => Partial | undefined, +): RepositoryHost { + const host = denoRepositoryHost(); + return { + *git(invocation: GitInvocation): Operation { + counted.commands.push([...invocation.args]); + if (barrier !== undefined) { + yield* barrier(invocation); + } + 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 identity reader these rows substitute. + * + * Counted, so "a completed Commit reads no identity" and "a live one reads it + * once" are both positive facts, and answerable with nothing so the + * missing-identity refusal is reachable without touching anybody's real Git + * configuration. + */ +function identityReader( + counted: Counters, + known: Record | undefined, +): (variable: string) => Operation { + // deno-lint-ignore require-yield + return function* (variable: string): Operation { + counted.identity.push(variable); + return known?.[variable]; + }; +} + +/** The author and committer this suite's host states by default. */ +const STATED_IDENTITY: Record = { + GIT_AUTHOR_IDENT: "Entry Author 1700000000 +0000", + 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[]; + /** What ended the entry, when something did. */ + 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; + /** 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; + /** 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. */ + readonly identity?: Record | undefined; +} + +/** 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; + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return working; + }, + }, + { at: "min" }, + ); + const files = countingFiles(counted); + 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.between !== undefined) { + yield* options.between(); + } + const managed = "root" in options ? options.root : fixture.managed; + // Registered the way the REPL host registers it: core initialization is + // what establishes the recording and the handlers, inside the scope the + // invocation owns. + yield* Execution.around({ + *init(_args, next): Operation { + yield* useReplRepositoryRecording({ + host: countingHost(counted, options.barrier, options.intercept), + identity: identityReader( + counted, + "identity" in options ? options.identity : STATED_IDENTITY, + ), + ...(managed === undefined ? {} : { root: managed }), + }); + yield* next(); + }, + }); + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled({ path, stream, includes: [fixture.root] }, [ + // What the Git Plugin contributes for `repl`, attached directly: + // this suite assembles no command Plugins. + { admissions: [(retained) => admitReplRepositoryHistory(retained)] }, + ]), + ), + ); + } 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 the suffix. + * What these rows are about is the other case — somebody answered nothing, the + * entry is still standing there, and the work after the question has never run. + */ +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(); + // Nobody answers, so this is where the entry is when it is left. + return yield* suspend(); + }, + }); + }, + }), + ); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the entry never reached its question"); + })(), + ]); + const prefix = [...(yield* stream.readAll())]; + yield* task.halt(); + return prefix; + }); +} + +/** An elicitation that answers at once, for a continuation past a question. */ +function answered(count: { asked: number } = { asked: 0 }): Operation { + return Elicitation.around({ + // deno-lint-ignore require-yield + *elicit(): Operation { + count.asked += 1; + return {}; + }, + }); +} + +/** Every payload of one record type, in journal order. */ +function recordsOf(events: readonly DurableEvent[], type: string): readonly Json[] { + const held: Json[] = []; + for (const event of events) { + if (event.type === "yield" && event.description.type === type && event.result.status === "ok") { + held.push(event.result.value ?? null); + } + } + return held; +} + +/** What each local Git record says about the operation it describes. */ +function gitRecords(events: readonly DurableEvent[]): ReplGitRecord[] { + return recordsOf(events, REPL_REPOSITORY_GIT).map((payload) => { + const parsed = parseReplGitRecord(payload); + if (parsed === undefined) { + throw new Error("a recorded local Git operation is not readable"); + } + return parsed; + }); +} + +/** The names, in order, this journal recorded local Git operations under. */ +function gitNames(events: readonly DurableEvent[]): readonly string[] { + return events + .filter((event) => event.type === "yield" && event.description.type === REPL_REPOSITORY_GIT) + .map((event) => (event.type === "yield" ? event.description.name : "")); +} + +/** The object id a recorded commit names, for the rows about a saved binding. */ +function committed(record: ReplGitRecord | undefined): string | undefined { + if (record?.outcome.ok !== true) { + return undefined; + } + const value = record.outcome.value; + return "commit" in value ? value.commit : undefined; +} + +/** The branch a recorded switch ended on. */ +function switched(record: ReplGitRecord | undefined): string | undefined { + if (record?.outcome.ok !== true) { + return undefined; + } + const value = record.outcome.value; + return "resolvedBranch" in value ? value.resolvedBranch : undefined; +} + +/** What one checkout holds right now, read with a session 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(), + }; +} + +/** 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(); +} + +/** The canonical common Git directory the repository owning this checkout has. */ +function commonOf(fixture: Fixture, checkout: string): Operation { + return fixtureGit(fixture, ["rev-parse", "--path-format=absolute", "--git-common-dir"], checkout); +} + +/** The question an entry is left at, never answered. */ +const QUESTION = 'Continue?'; + +describe("one entry's local Add, Switch and Commit", () => { + it("E2: replays acknowledged Switch, Add and Commit with no Git work", function* () { + const fixture = yield* useFixture(); + const live = counters(); + const source = [ + '', + 'first', + '', + ' ', + "", + "Recorded commit: {commit}", + QUESTION, + 'continued', + ].join("\n"); + + const prefix = yield* left(fixture, source, live); + + // Three recorded Git operations, in the order the document wrote them, with + // the staging child before the Commit that encloses it. + const recorded = gitRecords(prefix); + expect(recorded.map((record) => record.operation)).toEqual(["switch", "add", "commit"]); + expect(switched(recorded[0])).toBe("experiment"); + const commit = committed(recorded[2]); + expect(commit).toMatch(/^[0-9a-f]{40}$/); + // The commit really happened in the checkout, and it is what the document + // 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"); + + // The checkout and the record now disagree, which the REPL's execution + // model allows: branch, index and HEAD all move behind the completed work. + yield* fixtureGit(fixture, ["switch", "-c", "elsewhere"], fixture.checkout); + yield* writeTextFile(join(fixture.checkout, "note.md"), "changed by somebody else"); + yield* fixtureGit(fixture, ["add", "note.md"], fixture.checkout); + yield* fixtureGit(fixture, ["commit", "-m", "theirs"], fixture.checkout); + const theirs = yield* observed(fixture, fixture.checkout); + expect(theirs.head).not.toBe(commit); + + // And the directory the entry was opened through now points at another + // repository entirely, so live routing would fail admission rather than + // answer. A continuation that answers has resolved nothing. + yield* repoint(fixture, fixture.elsewhere); + + const replayed = counters(); + const asked = { asked: 0 }; + const again = yield* scoped(function* () { + // Held by this suite for the whole continuation. A continuation taking + // the mutation lease would open a second descriptor on the same sidecar + // and be refused by the operating system. + yield* useCommonDirectoryLease(yield* until(realpath(join(fixture.checkout, ".git")))); + return yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { + between: () => answered(asked), + }); + }); + + expect(again.refusal).toBe(undefined); + // 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 + // local-Git record for the prefix. + expect(replayed.commands).toEqual([]); + expect(replayed.directories).toBe(0); + expect(replayed.identity).toEqual([]); + expect(gitRecords(again.events).length).toBe(recorded.length); + // The suffix after the answered question ran exactly once, and the external + // work survived. + expect(asked.asked).toBe(1); + expect(replayed.writes).toEqual(["after.md"]); + const after = yield* observed(fixture, fixture.checkout); + expect(after.branch).toBe("elsewhere"); + expect(after.head).toBe(theirs.head); + expect(yield* readTextFile(join(fixture.checkout, "note.md"))).toBe("changed by somebody else"); + }); + + it("E2: answers a saved managed prefix with the selected worktree removed", function* () { + const fixture = yield* useFixture(); + const live = counters(); + const source = [ + ``, + '', + "", + 'first', + '', + ' ', + "", + "Recorded commit: {commit}", + QUESTION, + "", + "", + ].join("\n"); + + const prefix = yield* left(fixture, source, live); + const recorded = gitRecords(prefix); + expect(recorded.map((record) => record.operation)).toEqual(["add", "commit"]); + const commit = committed(recorded[1]); + expect(commit).toMatch(/^[0-9a-f]{40}$/); + + // The commit ran in the selected worktree rather than in the Repository's + // own checkout, which is what the `` around it selected. + const where = recorded[1]?.outcome.ok === true ? recorded[1].outcome.value.checkout : undefined; + expect(where?.worktreeName).toBe("experiment"); + expect(where?.checkoutPath).toBe( + `${worktreeSlot(fixture.managed, yield* commonOf(fixture, where?.checkoutPath ?? ""), "experiment")}/checkout`, + ); + + // The selected worktree is removed outright — the state a selector would + // refuse and a repair would recreate. + yield* rm(where?.checkoutPath ?? "", { recursive: true, force: true }); + + const replayed = counters(); + const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { + between: () => answered(), + }); + + 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([]); + expect(replayed.identity).toEqual([]); + expect(yield* exists(where?.checkoutPath ?? "")).toBe(false); + }); + + it("E3: ignores an unusable creation base for a branch that exists", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + yield* fixtureGit(fixture, ["branch", "ready"], fixture.checkout); + const before = yield* observed(fixture, fixture.checkout); + + const performed = yield* run( + fixture, + '', + new InMemoryStream(), + counted, + ); + + expect(performed.refusal).toBe(undefined); + const record = gitRecords(performed.events)[0]; + expect(switched(record)).toBe("ready"); + // A base is consulted only when a branch has to be created, so the + // unusable one is irrelevant rather than a refusal — and nothing moved. + expect( + record?.outcome.ok === true && "resolvedBase" in record.outcome.value + ? record.outcome.value.resolvedBase + : "unset", + ).toBe(null); + const after = yield* observed(fixture, fixture.checkout); + expect(after.branch).toBe("ready"); + expect(after.head).toBe(before.head); + }); + + it("E3: starts a missing branch at live HEAD when no base is supplied", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const before = yield* observed(fixture, fixture.checkout); + + const performed = yield* run( + fixture, + '', + new InMemoryStream(), + counted, + ); + + expect(performed.refusal).toBe(undefined); + const record = gitRecords(performed.events)[0]; + expect(switched(record)).toBe("fresh"); + expect( + record?.outcome.ok === true && "resolvedBase" in record.outcome.value + ? record.outcome.value.resolvedBase + : undefined, + ).toBe(before.head); + expect((yield* observed(fixture, fixture.checkout)).head).toBe(before.head); + }); + + it("E3: retains a conflicting Switch as a refusal and replays it after repair", function* () { + const fixture = yield* useFixture(); + const live = counters(); + // A branch that holds different content for a file the working tree has + // changed, so switching to it would overwrite work. + yield* fixtureGit(fixture, ["switch", "-c", "other"], fixture.checkout); + yield* writeTextFile(join(fixture.checkout, "seed.md"), "other\n"); + yield* fixtureGit(fixture, ["commit", "-am", "other"], fixture.checkout); + yield* fixtureGit(fixture, ["switch", "main"], fixture.checkout); + yield* writeTextFile(join(fixture.checkout, "seed.md"), "mine, uncommitted\n"); + + const source = [ + "", + '', + "", + QUESTION, + ].join("\n"); + const prefix = yield* left(fixture, source, live); + + const recorded = gitRecords(prefix); + expect(recorded.length).toBe(1); + expect(recorded[0]?.outcome.ok).toBe(false); + // The native refusal keeps its own words. The Workflow formatter's sentence + // for an overwrite claims nothing was discarded to make room, and the + // native one says what `git` reported. + expect(prefix.length > 0).toBe(true); + expect((yield* observed(fixture, fixture.checkout)).branch).toBe("main"); + + // The checkout is repaired: the local change is gone, so a new attempt + // would succeed. + yield* fixtureGit(fixture, ["checkout", "--", "seed.md"], fixture.checkout); + + const replayed = counters(); + const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { + between: () => answered(), + }); + + // 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((yield* observed(fixture, fixture.checkout)).branch).toBe("main"); + + // A new invocation, in a new entry, does succeed. + const fresh = counters(); + const later = yield* run(fixture, '', new InMemoryStream(), fresh); + expect(later.refusal).toBe(undefined); + expect((yield* observed(fixture, fixture.checkout)).branch).toBe("other"); + }); + + it("E3: commits the current index under the host identity and binds its id", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + // Staged by hand, outside the document, and one unstaged file beside it. + yield* writeTextFile(join(fixture.checkout, "staged.md"), "staged\n"); + yield* writeTextFile(join(fixture.checkout, "unstaged.md"), "unstaged\n"); + yield* fixtureGit(fixture, ["add", "staged.md"], fixture.checkout); + + const performed = yield* run( + fixture, + ['', "Commit: {commit}"].join("\n"), + new InMemoryStream(), + counted, + ); + + expect(performed.refusal).toBe(undefined); + const record = gitRecords(performed.events)[0]; + const commit = committed(record); + expect(commit).toMatch(/^[0-9a-f]{40}$/); + expect(performed.output).toContain(`Commit: ${commit}`); + // Exactly the index: nothing was staged for it, so the unstaged file is + // still unstaged and is not in the commit. + expect(yield* fixtureGit(fixture, ["rev-parse", "HEAD"], fixture.checkout)).toBe(commit); + const listed = yield* fixtureGit( + fixture, + ["show", "--name-only", "--format=", "HEAD"], + fixture.checkout, + ); + expect(listed.split("\n").filter((line) => line !== "")).toEqual(["staged.md"]); + // The invoking person's own author and committer, read once. + expect(counted.identity).toEqual(["GIT_AUTHOR_IDENT", "GIT_COMMITTER_IDENT"]); + expect( + yield* fixtureGit(fixture, ["log", "-1", "--format=%an <%ae>|%cn <%ce>"], fixture.checkout), + ).toBe("Entry Author |Entry Committer "); + // And the clock is the operation's own rather than anything the identity + // reported: `git var` named 1700000000, which this commit is not stamped at. + const when = Number( + yield* fixtureGit(fixture, ["log", "-1", "--format=%at"], fixture.checkout), + ); + expect(when > 1700000000).toBe(true); + expect( + record?.outcome.ok === true && "committedAt" in record.outcome.value + ? record.outcome.value.committedAt + : undefined, + ).toBe(when); + }); + + it("E3: refuses Commit alone when the host states no identity, reading once", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + yield* writeTextFile(join(fixture.checkout, "staged.md"), "staged\n"); + yield* fixtureGit(fixture, ["add", "staged.md"], fixture.checkout); + const before = yield* observed(fixture, fixture.checkout); + + const performed = yield* run( + fixture, + [ + '', + "", + '', + "", + '', + 'after', + ].join("\n"), + new InMemoryStream(), + counted, + { identity: undefined }, + ); + + // Printed and carried on past: Switch before it and Add after it both ran, + // and the file beside them was written. + expect(performed.refusal).toBe(undefined); + expect(performed.output).toContain("cannot record who this commit is by"); + const records = gitRecords(performed.events); + expect(records.map((record) => record.operation)).toEqual(["switch", "commit", "add"]); + expect(records[1]?.outcome.ok).toBe(false); + expect(records[0]?.outcome.ok).toBe(true); + expect(records[2]?.outcome.ok).toBe(true); + // Nothing was committed, and the read happened once for the one operation + // that needed it. + expect((yield* observed(fixture, fixture.checkout)).head).toBe(before.head); + expect(counted.identity).toEqual(["GIT_AUTHOR_IDENT", "GIT_COMMITTER_IDENT"]); + + // And the refusal replays as the same refusal, with no identity read at all. + const replayed = counters(); + const again = yield* run( + fixture, + [ + '', + "", + '', + "", + '', + 'after', + ].join("\n"), + new InMemoryStream(performed.events.filter((event) => event.type !== "close")), + replayed, + { identity: undefined }, + ); + expect(again.output).toContain("cannot record who this commit is by"); + expect(replayed.identity).toEqual([]); + expect(replayed.commands).toEqual([]); + }); + + it("E3: keeps a native ignored-path Add refusal native, with no rollback claim", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + yield* writeTextFile(join(fixture.checkout, ".gitignore"), "ignored.md\n"); + yield* writeTextFile(join(fixture.checkout, "ignored.md"), "ignored\n"); + yield* writeTextFile(join(fixture.checkout, "ordinary.md"), "ordinary\n"); + + const performed = yield* run( + fixture, + [ + "", + '', + "", + 'after', + ].join("\n"), + new InMemoryStream(), + counted, + ); + + expect(performed.refusal).toBe(undefined); + // `git` itself refused, in its own fixed words. The Workflow sentence for + // this condition claims the staging was discarded with the rest of the + // attempt, and nothing here discards anything. + expect(performed.output).toContain("git refused: ignored-pathspec"); + expect(performed.output).not.toContain("was discarded with the rest of the attempt"); + const record = gitRecords(performed.events)[0]; + expect(record?.outcome.ok).toBe(false); + // Native Add is not all-or-none: the ordinary path it matched before it + // refused is staged, and the refusal rolls nothing back. + const staged = yield* fixtureGit( + fixture, + ["diff", "--cached", "--name-only"], + fixture.checkout, + ); + expect(staged.split("\n").filter((line) => line !== "")).toEqual(["ordinary.md"]); + + // The refusal replays as the same one, with the ignored path still ignored + // and nothing run to find out. + const replayed = counters(); + const again = yield* run( + fixture, + [ + "", + '', + "", + 'after', + ].join("\n"), + new InMemoryStream(performed.events.filter((event) => event.type !== "close")), + replayed, + ); + expect(again.output).toContain("git refused: ignored-pathspec"); + expect(replayed.commands).toEqual([]); + }); + + it("E4: records two equal Adds at two positions and consumes two results", function* () { + const fixture = yield* useFixture(); + const live = counters(); + yield* writeTextFile(join(fixture.checkout, "note.md"), "first\n"); + const source = ['', '', QUESTION].join( + "\n", + ); + + const prefix = yield* left(fixture, source, live); + const recorded = gitRecords(prefix); + expect(recorded.map((record) => record.operation)).toEqual(["add", "add"]); + // One name at two ordered positions. Two equal requests are two records, + // and nothing deduplicates them. + 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); + + 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(gitRecords(again.events).length).toBe(2); + }); + + it("E4: serializes two same-repository callers through one acquisition", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + yield* writeTextFile(join(fixture.checkout, "one.md"), "one\n"); + yield* writeTextFile(join(fixture.checkout, "two.md"), "two\n"); + + // Both branches reach their Add; the turn is what keeps the two native + // commands from overlapping. + let inside = 0; + let overlapped = 0; + const barrier: GitBarrier = function* (invocation): Operation { + if (!invocation.args.includes("add")) { + return; + } + inside += 1; + overlapped = Math.max(overlapped, inside); + yield* settled(); + inside -= 1; + }; + + const performed = yield* run( + fixture, + [ + "", + "", + '', + "", + "", + '', + "", + "", + ].join("\n"), + new InMemoryStream(), + counted, + { barrier }, + ); + + expect(performed.refusal).toBe(undefined); + // Two distinct results, and never two native commands at once. + const records = gitRecords(performed.events); + 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"], + fixture.checkout, + ); + expect( + staged + .split("\n") + .filter((line) => line !== "") + .sort(), + ).toEqual(["one.md", "two.md"]); + }); + + it("E4: keeps a nested Add before the Commit that encloses it", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + + const performed = yield* run( + fixture, + [ + 'first', + '', + ' ', + "", + ].join("\n"), + new InMemoryStream(), + counted, + ); + + expect(performed.refusal).toBe(undefined); + const records = gitRecords(performed.events); + // The staging child finished before the Commit started, and each is its own + // journal operation: the Commit's envelope does not enclose its children. + expect(records.map((record) => record.operation)).toEqual(["add", "commit"]); + const staged = records[0]; + const commit = records[1]; + const index = + staged?.outcome.ok === true && "paths" in staged.outcome.value + ? staged.outcome.value.after.indexTree + : undefined; + // The commit wrote exactly the tree the child had just staged. + expect( + commit?.outcome.ok === true && "tree" in commit.outcome.value + ? commit.outcome.value.tree + : undefined, + ).toBe(index); + }); + + it("E4: resumes two spawned branches' Git in reverse order, sharing nothing", function* () { + const fixture = yield* useFixture(); + const live = counters(); + // Two branches, two different managed repositories, two questions. Each + // branch stages at its own position in its own coroutine. + const branch = (name: string) => + [ + ``, + `${name}`, + ``, + `Question for ${name}`, + ``, + "", + ].join("\n"); + const source = ["", branch("alpha"), branch("beta"), ""].join("\n"); + + const prefix = yield* scoped(function* () { + const stream = new InMemoryStream(); + const waiting = withResolvers(); + let asked = 0; + const task = yield* spawn(() => + run(fixture, source, stream, live, { + *between(): Operation { + yield* Elicitation.around({ + *elicit(): Operation { + asked += 1; + if (asked === 2) { + waiting.resolve(); + } + return yield* suspend(); + }, + }); + }, + }), + ); + yield* race([ + waiting.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("both branches never reached their questions"); + })(), + ]); + const held = [...(yield* stream.readAll())]; + yield* task.halt(); + return held; + }); + + // Two staged operations, on two different coroutines, and neither branch's + // record is in the other's. + const staged = prefix.filter( + (event) => event.type === "yield" && event.description.type === REPL_REPOSITORY_GIT, + ); + expect(staged.length).toBe(2); + expect( + new Set(staged.map((event) => (event.type === "yield" ? event.coroutineId : ""))).size, + ).toBe(2); + + // Continued with the answers arriving in the other order: `alpha` reached + // its question first and is answered last here. + const replayed = counters(); + const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { + *between(): Operation { + yield* Elicitation.around({ + *elicit([request]: [ElicitationRequest]): Operation { + yield* sleep(request.message.includes("alpha") ? 30 : 0); + return {}; + }, + }); + }, + }); + + expect(again.refusal).toBe(undefined); + // 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); + }); + + it("E4: keeps equal sources in two journals independent", function* () { + const fixture = yield* useFixture(); + yield* writeTextFile(join(fixture.checkout, "note.md"), "note\n"); + const source = ''; + + const first = counters(); + const one = yield* run(fixture, source, new InMemoryStream(), first); + const second = counters(); + const two = yield* run(fixture, source, new InMemoryStream(), second); + + expect(one.refusal).toBe(undefined); + expect(two.refusal).toBe(undefined); + // The same source twice is the same name, and that is allowed: each entry + // 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"); + }); + + it("E6: leaves no completion when a finished Commit's append is cancelled", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const events: DurableEvent[] = []; + const reached = withResolvers(); + const held = withResolvers(); + yield* writeTextFile(join(fixture.checkout, "staged.md"), "staged\n"); + yield* fixtureGit(fixture, ["add", "staged.md"], fixture.checkout); + + // A barrier between the native work and its acknowledgement: the commit has + // happened, and the append has not. + const barrier: DurableStream = { + *readAll(): Operation { + return [...events]; + }, + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === REPL_REPOSITORY_GIT) { + reached.resolve(); + yield* held.operation; + } + events.push(event); + }, + }; + + // One document, run twice, so the continuation is a resumption rather than + // a divergence. The printing region is where the second run's ordinary + // refusal is shown. + const source = [ + "", + '', + "Commit: {commit}", + "", + 'sentinel', + ].join("\n"); + + yield* scoped(function* () { + const task = yield* spawn(() => run(fixture, source, barrier, counted)); + yield* reached.operation; + yield* task.halt(); + }); + + // 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(recordsOf(events, REPL_REPOSITORY_GIT)).toEqual([]); + expect(yield* exists(join(fixture.checkout, "sentinel.md"))).toBe(false); + + // So a continuation takes the live path — and gets today's answer, which is + // the ordinary empty-index refusal, because the earlier attempt already + // consumed the index. Nothing adopts or predicts the commit that was made. + const again = counters(); + const continued = yield* run(fixture, source, new InMemoryStream([...events]), again); + expect(continued.refusal).toBe(undefined); + expect(continued.output).toContain("nothing is staged in the checkout it ran in"); + // Neither adopted nor predicted: the id of the commit that was really made + // appears nowhere, and no success binding was offered for it. + expect(continued.output).not.toContain(after.head); + const records = gitRecords(continued.events); + expect(records.length).toBe(1); + expect(records[0]?.outcome.ok).toBe(false); + expect(yield* readTextFile(join(fixture.checkout, "sentinel.md"))).toBe("sentinel"); + }); + + it("E6: replays an acknowledged Commit that was then cancelled", function* () { + const fixture = yield* useFixture(); + const live = counters(); + yield* writeTextFile(join(fixture.checkout, "staged.md"), "staged\n"); + yield* fixtureGit(fixture, ["add", "staged.md"], fixture.checkout); + const source = [ + '', + "Commit: {commit}", + QUESTION, + ].join("\n"); + + const prefix = yield* left(fixture, source, live); + const commit = committed(gitRecords(prefix)[0]); + expect(commit).toMatch(/^[0-9a-f]{40}$/); + + const replayed = counters(); + const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { + between: () => answered(), + }); + expect(again.output).toContain(`Commit: ${commit}`); + expect(replayed.commands).toEqual([]); + expect(replayed.identity).toEqual([]); + }); + + it("E6: keeps a failed result append fatal, with no binding and nothing after", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const events: DurableEvent[] = []; + yield* writeTextFile(join(fixture.checkout, "staged.md"), "staged\n"); + yield* fixtureGit(fixture, ["add", "staged.md"], fixture.checkout); + + const rejecting: DurableStream = { + *readAll(): Operation { + return [...events]; + }, + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === REPL_REPOSITORY_GIT) { + throw new Error("the journal refused this event"); + } + events.push(event); + }, + }; + + const performed = yield* run( + fixture, + [ + "", + '', + "Commit: {commit}", + "", + 'sentinel', + ].join("\n"), + rejecting, + counted, + ); + + // 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"); + 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* () { + 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; + + yield* scoped(function* () { + const task = yield* spawn(() => + run(fixture, '', new InMemoryStream(), 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 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); + }); + + it("E6: keeps an unclassified performer failure fatal and unrecorded", function* () { + const fixture = yield* useFixture(); + 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"] } + : undefined; + + const performed = yield* run( + fixture, + [ + "", + '', + "", + 'sentinel', + ].join("\n"), + new InMemoryStream(), + counted, + { intercept }, + ); + + expect(recordsOf(performed.events, REPL_REPOSITORY_GIT)).toEqual([]); + expect(yield* exists(join(fixture.checkout, "sentinel.md"))).toBe(false); + const reported = performed.refusal ?? performed.output; + expect(reported).not.toContain("sentinel"); + }); + + it("refuses a Git element in an entry that configures no managed root", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + + const performed = yield* run( + fixture, + [ + "", + '', + "", + 'after', + ].join("\n"), + new InMemoryStream(), + counted, + { root: undefined }, + ); + + // An entry with no managed root installs no selector and no Git handler, so + // the element reaches its own unchanged missing-provider refusal — before + // any Git operation exists, and with nothing written down for it. The file + // 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(recordsOf(performed.events, REPL_REPOSITORY_GIT)).toEqual([]); + expect(counted.writes).toEqual(["after.md"]); + }); +}); diff --git a/packages/git/tests/retained-repository-lifecycle.test.ts b/packages/git/tests/retained-repository-lifecycle.test.ts index ef8b77ca2..7c7521a44 100644 --- a/packages/git/tests/retained-repository-lifecycle.test.ts +++ b/packages/git/tests/retained-repository-lifecycle.test.ts @@ -43,6 +43,7 @@ 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"; @@ -52,6 +53,7 @@ import { } from "../src/deno/retained-repository/leases.ts"; import { commonDirectoryLock, + lockOf, repositorySlot, worktreeSlot, } from "../src/deno/run-composition/placement.ts"; @@ -59,6 +61,7 @@ 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"; /** Let every ready task run, so "reached its wait" is about turns. */ function* settled(turns = 8): Operation { @@ -110,10 +113,38 @@ interface Counters { reads: string[]; writes: string[]; deletes: string[]; + /** Every `git` invocation this entry ran, as its argument list. */ + commands: string[][]; } function counters(): Counters { - return { reads: [], writes: [], deletes: [] }; + return { reads: [], writes: [], deletes: [], commands: [] }; +} + +/** + * The production host, with every `git` it runs counted. + * + * 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) }), + }; +} + +/** The Git subcommands one entry issued, in order. */ +function subcommands(counted: Counters): string[] { + return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); } function countingFiles(counted: Counters): FilesHandler { @@ -208,7 +239,10 @@ function entry( yield* useCompositionComponents(); yield* Execution.around({ *init(_args, next): Operation { - yield* useReplRepositoryRecording(root === undefined ? {} : { root }); + yield* useReplRepositoryRecording({ + host: countingHost(counted), + ...(root === undefined ? {} : { root }), + }); yield* next(); }, }); @@ -249,6 +283,12 @@ function placementRecords(events: readonly DurableEvent[]): number { ).length; } +function gitRecords(events: readonly DurableEvent[]): number { + return events.filter( + (event) => event.type === "yield" && event.description.type === REPL_REPOSITORY_GIT, + ).length; +} + function localRecords(events: readonly DurableEvent[]): number { return events.filter( (event) => event.type === "yield" && event.description.type === REPL_REPOSITORY_LOCAL, @@ -972,6 +1012,185 @@ describe("an entry's hold on the checkout it is working in", () => { expect(observed.events()).toEqual(["open", "lock-threw", "close"]); }); }); + + it("E5: refuses new Git in a restored placement whose slot is held, until teardown", function* () { + const fixture = yield* useFixture(); + const managed = join(fixture.root, "repositories"); + const origin = yield* useOrigin(fixture.root, "slot-origin"); + // A managed Repository selected, left at a question, and reopened with a + // Git operation as the first genuinely new work inside it. + // A lexical `` expands its content with the checkout as the + // contextual working directory, so the file and the staging below are + // inside the managed checkout rather than beside the document. + const source = [ + ``, + 'staged', + QUESTION, + '', + "", + ].join("\n"); + + const first = counters(); + const prefix = yield* leftEntry(fixture.checkout, source, first, managed); + expect(placementRecords(prefix)).toBe(1); + expect(gitRecords(prefix)).toBe(0); + + yield* scoped(function* (): Operation { + // Another live entry holding the same managed slot. + const holding = counters(); + const acquired = withResolvers(); + const release = withResolvers(); + yield* spawn(function* (): Operation { + yield* entry( + fixture.elsewhere, + [ + ``, + QUESTION, + ].join("\n"), + new InMemoryStream(), + holding, + function* (): Operation { + acquired.resolve(); + yield* release.operation; + }, + managed, + ); + }); + yield* acquired.operation; + + const blocked = counters(); + const refused = yield* entry( + fixture.checkout, + source, + new InMemoryStream([...prefix]), + blocked, + answeringOnce(), + managed, + ); + // 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(gitRecords(refused.events)).toBe(0); + + release.resolve(); + }); + + // The holder is gone, so the same first-new-Git operation runs — the + // positive control for the refusal above. + const after = counters(); + const later = yield* entry( + fixture.checkout, + source, + new InMemoryStream([...prefix]), + after, + answeringOnce(), + managed, + ); + expect(later.refusal).toBe(undefined); + expect(subcommands(after)).toContain("add"); + expect(gitRecords(later.events)).toBe(1); + }); + + it("E5: holds the slot before the shared Git directory, and replay holds neither", function* () { + const fixture = yield* useFixture(); + const managed = join(fixture.root, "repositories"); + const origin = yield* useOrigin(fixture.root, "shared-git"); + const commonDirectory = yield* until(realpath(join(origin, ".git"))); + const commonLock = commonDirectoryLock(commonDirectory); + + // One linked placement of the repository the entry was opened in, with a + // Git operation acknowledged inside it and a second one still to run. + const source = [ + '', + 'probe', + '', + QUESTION, + 'later', + '', + "", + ].join("\n"); + + const first = counters(); + const prefix = yield* leftEntry(origin, source, first, managed); + expect(gitRecords(prefix)).toBe(1); + expect(subcommands(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"), + ); + + yield* scoped(function* (): Operation { + // A different linked checkout of the same repository, held by another + // entry — which means the shared Git directory's mutation lease too. + const holding = counters(); + const acquired = withResolvers(); + const release = withResolvers(); + yield* spawn(function* (): Operation { + yield* entry( + origin, + [ + '', + 'held', + QUESTION, + "", + ].join("\n"), + new InMemoryStream(), + holding, + function* (): Operation { + acquired.resolve(); + yield* release.operation; + }, + managed, + ); + }); + yield* acquired.operation; + + const observed = yield* useObservedDescriptors(); + const blocked = counters(); + const refused = yield* entry( + origin, + source, + new InMemoryStream([...prefix]), + blocked, + answeringOnce(), + managed, + ); + + // The completed Git operation replayed with no native work and no hold on + // the shared Git directory at all; the new one 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(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); + + release.resolve(); + }); + + // After the holder's joined release the same new work succeeds. + const after = counters(); + const later = yield* entry( + origin, + source, + new InMemoryStream([...prefix]), + after, + answeringOnce(), + managed, + ); + expect(later.refusal).toBe(undefined); + expect(subcommands(after)).toContain("add"); + expect(gitRecords(later.events)).toBe(gitRecords(prefix) + 1); + }); }); /** diff --git a/packages/git/tests/retained-repository-records.test.ts b/packages/git/tests/retained-repository-records.test.ts index 61f3a8258..1fc5ec9f3 100644 --- a/packages/git/tests/retained-repository-records.test.ts +++ b/packages/git/tests/retained-repository-records.test.ts @@ -53,6 +53,20 @@ import type { ReplPlacementRequest, } from "../src/retained-repository/placement-records.ts"; import type { RepositoryIdentity } from "../src/composition/selection.ts"; +import { + parseReplGitRecord, + replGitFingerprint, + replGitRecordJson, + replGitRecordName, + ReplGitRefusal, + replGitRefusalOf, + REPL_REPOSITORY_GIT, +} from "../src/retained-repository/git-records.ts"; +import type { ReplGitRequest, ReplGitValue } from "../src/retained-repository/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. +import type { GitAddResult, GitCommitResult, GitSwitchResult } from "@executablemd/git"; const WRITE: ReplLocalRequest = { cwd: "/work", path: "note.md", content: "first" }; @@ -663,3 +677,450 @@ function saved(event: DurableEvent): Json { } return event.result.value ?? null; } + +const GIT_PLACE = { + repository: IDENTITY, + selectionCheckout: "/work/checkout", + commonDirectory: "/work/checkout/.git", + workingDirectory: "/work/checkout", +} as const; + +/** + * The checkout a native result names: an absolute host path. + * + * Which is the whole difference from the Workflow parsers beside these. A + * Workspace-relative path is what a workflow run retains, and an ordinary + * checkout is a directory on somebody's own filesystem. + */ +const GIT_CHECKOUT = Object.freeze({ + repositoryName: "project", + worktreeName: null, + checkoutPath: "/work/checkout", +}); + +const AT_REST = Object.freeze({ + branch: "main", + commit: "c".repeat(40), + headTree: "d".repeat(40), + indexTree: "d".repeat(40), +}); + +const ADD_REQUEST: ReplGitRequest = Object.freeze({ + operation: "add", + ...GIT_PLACE, + paths: Object.freeze(["note.md", "note.md"]), +}); + +const ADD_RESULT: GitAddResult = Object.freeze({ + checkout: GIT_CHECKOUT, + paths: Object.freeze(["note.md", "note.md"]), + before: AT_REST, + // Staging moves the index and nothing else. + after: Object.freeze({ ...AT_REST, indexTree: "e".repeat(40) }), +}); + +const SWITCH_REQUEST: ReplGitRequest = Object.freeze({ + operation: "switch", + ...GIT_PLACE, + branch: "experiment", + base: null, +}); + +const SWITCH_RESULT: GitSwitchResult = Object.freeze({ + checkout: GIT_CHECKOUT, + requestedBranch: "experiment", + resolvedBranch: "experiment", + requestedBase: null, + // Created, so it ends at the commit it was created from and on another branch. + resolvedBase: "c".repeat(40), + before: AT_REST, + after: Object.freeze({ ...AT_REST, branch: "experiment" }), +}); + +const STAGED = Object.freeze({ ...AT_REST, indexTree: "e".repeat(40) }); + +const COMMIT_REQUEST: ReplGitRequest = Object.freeze({ + operation: "commit", + ...GIT_PLACE, + messageSource: "prop", + messageDigest: "f".repeat(64), + messageLength: 18, +}); + +const COMMIT_RESULT: GitCommitResult = Object.freeze({ + checkout: GIT_CHECKOUT, + messageSource: "prop", + messageDigest: "f".repeat(64), + messageLength: 18, + parent: "c".repeat(40), + tree: "e".repeat(40), + commit: "1".repeat(40), + committedAt: 1_700_000_000, + before: STAGED, + after: Object.freeze({ + branch: "main", + commit: "1".repeat(40), + headTree: "e".repeat(40), + indexTree: "e".repeat(40), + }), +}); + +/** The payload one local Git record is journaled with. */ +function gitPayload( + request: ReplGitRequest, + outcome: Result, +): Operation<{ payload: Json; fingerprint: string }> { + return (function* () { + const fingerprint = yield* replGitFingerprint(request); + return { payload: replGitRecordJson({ fingerprint, request, outcome }), fingerprint }; + })(); +} + +/** One journaled local Git operation, named the way the host names it. */ +function* gitEvent( + request: ReplGitRequest, + outcome: Result, + name?: string, +): Operation { + const { payload, fingerprint } = yield* gitPayload(request, outcome); + return { + type: "yield", + coroutineId: "root", + description: { + type: REPL_REPOSITORY_GIT, + name: name ?? replGitRecordName({ operation: request.operation, fingerprint }), + }, + result: { status: "ok", value: payload }, + }; +} + +/** The saved value one record's outcome carries, for editing it member by member. */ +function savedValue(payload: Json): Record { + const outcome = Object(Object(payload)["outcome"]); + return { ...Object(outcome["value"]) }; +} + +/** That payload again, with this value in place of the one it carried. */ +function withValue(payload: Json, value: unknown): unknown { + return { ...Object(payload), outcome: { ok: true, value } }; +} + +describe("what one entry's local Git records say", () => { + it("names an operation by its complete request", function* () { + const add = yield* replGitFingerprint(ADD_REQUEST); + const elsewhere = yield* replGitFingerprint({ + ...ADD_REQUEST, + workingDirectory: "/work/checkout/inside", + }); + const other = yield* replGitFingerprint({ ...ADD_REQUEST, paths: ["other.md"] }); + const ordered = yield* replGitFingerprint({ ...ADD_REQUEST, paths: ["note.md"] }); + const switched = yield* replGitFingerprint(SWITCH_REQUEST); + const committed = yield* replGitFingerprint(COMMIT_REQUEST); + + // The directory, the pathspecs, their repetitions and the operation are all + // identity. + expect(new Set([add, elsewhere, other, ordered, switched, committed]).size).toBe(6); + // And the same request twice is the same name, which is what makes two + // equal Adds one name at two ordered positions. + expect(yield* replGitFingerprint(ADD_REQUEST)).toBe(add); + // The name carries the operation beside the digest, so two operations can + // never share one. + expect(replGitRecordName({ operation: "add", fingerprint: add })).not.toBe( + replGitRecordName({ operation: "switch", fingerprint: add }), + ); + }); + + it("reads back each operation's own result, and refuses another's", function* () { + for (const [request, result] of [ + [ADD_REQUEST, ADD_RESULT], + [SWITCH_REQUEST, SWITCH_RESULT], + [COMMIT_REQUEST, COMMIT_RESULT], + ] as const) { + const { payload } = yield* gitPayload(request, Ok(result)); + const record = parseReplGitRecord(payload); + expect(record?.operation).toBe(request.operation); + expect(record?.outcome.ok === true ? record.outcome.value : undefined).toEqual(result); + } + + // A result of another operation standing where this one belongs is not an + // outcome of it, whatever else it is. + const { payload } = yield* gitPayload(ADD_REQUEST, Ok(ADD_RESULT)); + expect(parseReplGitRecord(withValue(payload, { ...SWITCH_RESULT }))).toBe(undefined); + }); + + it("refuses a result carrying a wrong, extra or missing member", function* () { + const { payload } = yield* gitPayload(ADD_REQUEST, Ok(ADD_RESULT)); + // The positive control: the record this host wrote parses. + expect(parseReplGitRecord(payload)).not.toBe(undefined); + + const value = savedValue(payload); + expect(parseReplGitRecord(withValue(payload, { ...value, extra: 1 }))).toBe(undefined); + const { paths: _dropped, ...missing } = value; + expect(parseReplGitRecord(withValue(payload, missing))).toBe(undefined); + expect(parseReplGitRecord(withValue(payload, { ...value, paths: "note.md" }))).toBe(undefined); + expect(parseReplGitRecord(withValue(payload, { ...value, paths: [] }))).toBe(undefined); + // A host checkout path is read as the string it is; nothing else is one. + expect( + parseReplGitRecord( + withValue(payload, { ...value, checkout: { ...GIT_CHECKOUT, checkoutPath: 42 } }), + ), + ).toBe(undefined); + // And the repository a result names has to be the one the request + // authenticated. + expect( + parseReplGitRecord( + withValue(payload, { + ...value, + checkout: { ...GIT_CHECKOUT, repositoryName: "another" }, + }), + ), + ).toBe(undefined); + // A whole record missing one of its five wire members is not one either. + const { outcome: _outcome, ...envelope } = Object(payload); + expect(parseReplGitRecord(envelope)).toBe(undefined); + expect(parseReplGitRecord({ ...Object(payload), surprise: true })).toBe(undefined); + }); + + it("refuses an object id of the wrong width for the request's format", function* () { + const { payload } = yield* gitPayload(COMMIT_REQUEST, Ok(COMMIT_RESULT)); + const value = savedValue(payload); + // A sha1 repository names its objects in forty hex characters. A sixty-four + // character id is a real digest and still not one of this repository's. + expect(parseReplGitRecord(withValue(payload, { ...value, commit: "1".repeat(64) }))).toBe( + undefined, + ); + expect(parseReplGitRecord(withValue(payload, { ...value, commit: "1".repeat(39) }))).toBe( + undefined, + ); + expect(parseReplGitRecord(withValue(payload, { ...value, commit: "Z".repeat(40) }))).toBe( + undefined, + ); + // A time and a byte length are whole and never negative. + expect(parseReplGitRecord(withValue(payload, { ...value, committedAt: -1 }))).toBe(undefined); + expect(parseReplGitRecord(withValue(payload, { ...value, messageLength: 1.5 }))).toBe( + undefined, + ); + }); + + it("refuses a transition the operation does not make", function* () { + const add = yield* gitPayload(ADD_REQUEST, Ok(ADD_RESULT)); + const moved = savedValue(add.payload); + // Staging moves the index. A result whose commit or HEAD tree moved + // across it describes something `git add` does not do. + expect( + parseReplGitRecord( + withValue(add.payload, { + ...moved, + after: { ...ADD_RESULT.after, commit: "9".repeat(40) }, + }), + ), + ).toBe(undefined); + + const commit = yield* gitPayload(COMMIT_REQUEST, Ok(COMMIT_RESULT)); + const graph = savedValue(commit.payload); + // A commit continues the commit the checkout was on, and holds the tree the + // index described. + expect( + parseReplGitRecord(withValue(commit.payload, { ...graph, parent: "9".repeat(40) })), + ).toBe(undefined); + expect(parseReplGitRecord(withValue(commit.payload, { ...graph, tree: "9".repeat(40) }))).toBe( + undefined, + ); + // Committing an index that already matched HEAD is the condition this + // operation refuses rather than one it records. + expect( + parseReplGitRecord( + withValue(commit.payload, { + ...graph, + before: { ...STAGED, indexTree: STAGED.headTree }, + }), + ), + ).toBe(undefined); + + const switched = yield* gitPayload(SWITCH_REQUEST, Ok(SWITCH_RESULT)); + const branch = savedValue(switched.payload); + // A created branch ends at the commit it was created from and cannot be the + // branch the checkout was already on. + expect( + parseReplGitRecord( + withValue(switched.payload, { + ...branch, + after: { ...SWITCH_RESULT.after, commit: "9".repeat(40) }, + }), + ), + ).toBe(undefined); + // And switching to the branch a checkout is already on moves nothing. + expect( + parseReplGitRecord( + withValue(switched.payload, { + ...branch, + resolvedBase: null, + after: { ...AT_REST, indexTree: "9".repeat(40) }, + }), + ), + ).toBe(undefined); + }); + + it("refuses a result whose arguments are not the ones the request asked for", function* () { + const add = yield* gitPayload(ADD_REQUEST, Ok(ADD_RESULT)); + // Order and repetitions are part of what was asked for, because each of + // them changes what Git stages. + expect( + parseReplGitRecord( + withValue(add.payload, { ...savedValue(add.payload), paths: ["note.md"] }), + ), + ).toBe(undefined); + + const switched = yield* gitPayload(SWITCH_REQUEST, Ok(SWITCH_RESULT)); + const branch = savedValue(switched.payload); + expect( + parseReplGitRecord( + withValue(switched.payload, { + ...branch, + requestedBranch: "other", + resolvedBranch: "other", + after: { ...SWITCH_RESULT.after, branch: "other" }, + }), + ), + ).toBe(undefined); + expect( + parseReplGitRecord(withValue(switched.payload, { ...branch, requestedBase: "main" })), + ).toBe(undefined); + + const commit = yield* gitPayload(COMMIT_REQUEST, Ok(COMMIT_RESULT)); + const evidence = savedValue(commit.payload); + expect( + parseReplGitRecord(withValue(commit.payload, { ...evidence, messageDigest: "a".repeat(64) })), + ).toBe(undefined); + expect( + parseReplGitRecord(withValue(commit.payload, { ...evidence, messageSource: "children" })), + ).toBe(undefined); + expect(parseReplGitRecord(withValue(commit.payload, { ...evidence, messageLength: 19 }))).toBe( + undefined, + ); + }); + + it("reads back a refusal, and refuses a word outside the closed set", function* () { + const { payload } = yield* gitPayload( + ADD_REQUEST, + Err(new ReplGitRefusal({ vocabulary: "native-git", reason: "ignored-pathspec" })), + ); + const record = parseReplGitRecord(payload); + expect(record?.outcome.ok).toBe(false); + const carried = + record?.outcome.ok === false ? replGitRefusalOf(record.outcome.error) : undefined; + // The route stays the route it was. An ignored path native Git reported is + // not the Workflow formatter's condition, which claims the staging was + // discarded. + expect(carried).toEqual({ vocabulary: "native-git", reason: "ignored-pathspec" }); + + const refused = { ...Object(payload) }; + for (const error of [ + { vocabulary: "native-git", reason: "empty-index" }, + { vocabulary: "composition-git", reason: "ignored-pathspec" }, + { vocabulary: "somewhere-else", reason: "ignored-pathspec" }, + { vocabulary: "native-git", reason: "something new" }, + { vocabulary: "native-git" }, + { vocabulary: "native-git", reason: "ignored-pathspec", sentence: "git said so" }, + ]) { + expect(parseReplGitRecord({ ...refused, outcome: { ok: false, error } })).toBe(undefined); + } + // A settlement that states neither side, and one that states the wrong + // one, are both records this host did not write. + expect(parseReplGitRecord({ ...refused, outcome: { ok: true, error: {} } })).toBe(undefined); + expect( + parseReplGitRecord({ + ...refused, + outcome: { ok: false, error: { vocabulary: "native-git", reason: "ignored-pathspec" } }, + }), + ).not.toBe(undefined); + + // Each route's own word, for the two the other vocabularies own. + const empty = yield* gitPayload( + COMMIT_REQUEST, + Err(new ReplGitRefusal({ vocabulary: "composition-git", reason: "empty-index" })), + ); + expect(parseReplGitRecord(empty.payload)?.outcome.ok).toBe(false); + const unresolved = yield* gitPayload( + COMMIT_REQUEST, + Err(new ReplGitRefusal({ vocabulary: "ordinary-identity", reason: "unresolved-identity" })), + ); + expect(parseReplGitRecord(unresolved.payload)?.outcome.ok).toBe(false); + }); +}); + +describe("which local Git histories one entry will continue", () => { + it("admits its own records, including repeated equal names", function* () { + const first = yield* gitEvent(ADD_REQUEST, Ok(ADD_RESULT)); + const second = yield* gitEvent(ADD_REQUEST, Ok(ADD_RESULT)); + const refused = yield* gitEvent( + SWITCH_REQUEST, + Err(new ReplGitRefusal({ vocabulary: "native-git", reason: "overwrites-local-changes" })), + ); + expect(yield* refusalOf([first, second, refused])).toBe(undefined); + }); + + it("refuses a local Git record it cannot read", function* () { + const event = yield* gitEvent(ADD_REQUEST, Ok(ADD_RESULT)); + const broken: DurableEvent = { + ...event, + result: { status: "ok", value: { version: REPL_REPOSITORY_VERSION, operation: "add" } }, + }; + expect((yield* refusalOf([broken]))?.reason).toBe("malformed-record"); + }); + + it("refuses a local Git record stating a version this host does not implement", function* () { + const event = yield* gitEvent(ADD_REQUEST, Ok(ADD_RESULT)); + const stated: DurableEvent = { + ...event, + result: { status: "ok", value: { ...payloadOf(event), version: 2 } }, + }; + expect((yield* refusalOf([stated]))?.reason).toBe("unknown-version"); + }); + + it("refuses a local Git record whose request no longer names it", function* () { + const event = yield* gitEvent(ADD_REQUEST, Ok(ADD_RESULT)); + const changed: DurableEvent = { + ...event, + result: { + status: "ok", + value: { + ...payloadOf(event), + request: { + repository: { ...IDENTITY }, + selectionCheckout: "/work/checkout", + commonDirectory: "/work/checkout/.git", + workingDirectory: "/somewhere/else", + paths: ["note.md", "note.md"], + }, + }, + }, + }; + expect((yield* refusalOf([changed]))?.reason).toBe("request-fingerprint"); + + const misnamed = yield* gitEvent(ADD_REQUEST, Ok(ADD_RESULT), "another name"); + expect((yield* refusalOf([misnamed]))?.reason).toBe("request-fingerprint"); + }); + + it("refuses a failed protocol envelope for a local Git record", function* () { + const event = yield* gitEvent(ADD_REQUEST, Ok(ADD_RESULT)); + const failed: DurableEvent = { + ...event, + result: { status: "err", error: { name: "Error", message: "something went wrong" } }, + }; + // This operation's own refusals live inside its successful payload, so a + // failed envelope under this type is not a record this host ever wrote. + expect((yield* refusalOf([failed]))?.reason).toBe("malformed-record"); + }); + + it("admits a history holding only the older record types", function* () { + const file = yield* localEvent("file-write", WRITE); + const placement = yield* placementEvent(); + expect(yield* refusalOf([file, placement])).toBe(undefined); + // Together with a local Git record, which is what an entry that did both + // leaves behind. + expect( + yield* refusalOf([file, placement, yield* gitEvent(COMMIT_REQUEST, Ok(COMMIT_RESULT))]), + ).toBe(undefined); + }); +}); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index c6e9a9c05..17faf9d9f 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -694,6 +694,12 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ "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", issue: DERIVED_SCOPE, }, + { + path: "packages/git/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", + issue: DERIVED_SCOPE, + }, { path: "packages/git/tests/retained-repository-lifecycle.test.ts", reason: diff --git a/scripts/tests/repl-repositories-compiled.test.ts b/scripts/tests/repl-repositories-compiled.test.ts index 582d16c78..e34e8f01a 100644 --- a/scripts/tests/repl-repositories-compiled.test.ts +++ b/scripts/tests/repl-repositories-compiled.test.ts @@ -24,6 +24,7 @@ import { fileURLToPath as fromFileUrl } from "node:url"; import { join } from "node:path"; import { replFilesJourney, + replGitJourney, replRepositoryJourney, } from "../../packages/cli/tests/support/repl-files-journey.ts"; @@ -60,4 +61,14 @@ describe("the compiled binary's REPL entry files", () => { yield* requireBinary(); yield* replRepositoryJourney(compiled, "worktree"); }); + + it("E1: carries the local Git handlers, so a committed id answers again", function* (): Operation { + yield* requireBinary(); + yield* replGitJourney(compiled, "ambient"); + }); + + it("E1: carries them inside a selected Worktree too", function* (): Operation { + yield* requireBinary(); + yield* replGitJourney(compiled, "worktree"); + }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 3795f6bbe..e426d5611 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10086,8 +10086,9 @@ bytes. The contracts below describe the two providers a document can reach. A document execution reaches repository operations only through the provider the command installed inside the execution scope, before the root document is -imported. There are three, and they differ in lifetime and permission rather -than in what an author writes. +imported. There are three — the ordinary one, the retained one and the entry +one — and they differ in lifetime and permission rather than in what an author +writes. The **ordinary provider** is what the Deno source entrypoint and the compiled binary install for `xmd run`. Constructing it acquires nothing at all: no @@ -10137,19 +10138,37 @@ creation, reuse, conflict and interrupted-adoption policy, and it records what the selector answered: a completed selection is answered from its record without calling a selector, cloning, adding a worktree, reading a sidecar, opening a credential or taking a lock, and only genuinely new work meets the current -filesystem. It installs one external capability beside that: the issue -adapter, so `` files and reads in an entry and its answers are retained -on the same terms — a completed read answers with the snapshot it saw and a -completed upsert with the URL it was filed under, neither of them reaching a -tracker, a 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 still installs nothing of is local Git, publication and the -external worker — so ``, ``, ``, `` -and `` refuse there exactly as they do under a host that installs -no provider for them. Constructing it acquires nothing, including no managed root -and no configuration read, and a passive reading of a settled history -initializes nothing at all. +filesystem. + +It answers three local Git operations on the same terms — ``, +`` and `` — by calling the *ordinary* performers and +recording what they produced. A completed one is answered from its record with +no `git` invocation of any kind, and the object id a completed `` +bound names the commit that was made then rather than the checkout's current +HEAD. A live `` reads the invoking user's own author and committer +from the trusted host once per entry, lazily, exactly as the ordinary provider +does, and a host that can name no identity refuses that one component. Routing +is private: the received working directory is canonicalized and the entry's own +registered checkouts are selected among under the whole authenticated Repository +identity, so a copied or edited selection can misname a target and never reach +one. A live operation takes the selected placement's managed slot and then the +hold on the repository it changes, keeps both until the entry is torn down, and +takes neither to answer a completed one. + +It installs one external capability beside those: the issue adapter, so +`` files and reads in an entry and its answers are retained on the same +terms — a completed read answers with the snapshot it saw and a completed +upsert with the URL it was filed under, neither of them reaching a tracker, a +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 publication and the external +worker, so `` and `` refuse there exactly as they do +under a host that installs no provider for them, and a retained local result +authorizes neither. Constructing it acquires nothing, including no managed +root, no commit identity and no configuration read, and a passive reading of a +settled history initializes nothing at all. The identity an entry's issue work is named by is the entry's own: the execution and the entry's admission-order position, derived by the host that opened the @@ -14117,9 +14136,11 @@ user's own `~/.xmd/repositories`. ### Tier ERC — Repository composition inside a REPL entry (§5.3, §8.1) -Bounded to what one executing entry retains about the checkouts it selected. -Everything an ordinary run proves about selection itself stays in Tier ORC: the -rows below are about the record and the holds around it. +Bounded to what one executing entry retains about the checkouts it selected and +the local Git it performed in them. Everything an ordinary run proves about +selection and about Add, Switch and Commit themselves stays in Tier ORC: the +rows below are about the record, the holds around it and the window between +native completion and acknowledgement. | # | Test | Verify | |---|------|--------| @@ -14130,7 +14151,13 @@ rows below are about the record and the holds around it. | ERC5 | Interruption | A clone and a `worktree add` each interrupted while their child is genuinely running join that child and its descendant before the entry ends, leaving no placement, no body sentinel and no orphan sidecar; what a `worktree add` did write is accounted for by the ordinary interrupted-creation policy rather than recreated or deleted; a metadata open and an isolation-directory creation each cut while still pending are joined before teardown finishes, and what they deliver is closed and removed | | ERC6 | Acknowledgement, not completion | A native placement completed before its append is cancelled leaves no record and the continuation runs live; one acknowledged before cancellation bypasses the selector; a rejected placement append preserves the first persistence failure with no body after it | | ERC7 | Admission and ceiling | Malformed, unknown-version, misnamed and request-mismatched placement records refuse before native work; an admitted generated `` inside a managed body is retained on the same terms as an authored one while a generated ``, `` or `` still refuses preflight | -| ERC8 | Unsupported runtimes | A REPL on a runtime that installs no entry provider performs ordinary file and directory work and reports an absent Repository provider before any clone, creating no managed root | +| ERC8 | Unsupported runtimes | A REPL on a runtime that installs no entry provider performs ordinary file and directory work and reports an absent Repository provider before any clone, creating no managed root; ``, `` and `` there report an absent Git provider before any native operation, leaving the checkout's HEAD and index exactly as they were | +| ERC9 | The public local-Git journey | A real-terminal source and compiled `xmd repl` each stage and commit in an ambient checkout and in a selected Worktree reached through a captured path and ``, leave at the question, have the file, the index and HEAD moved from a shell, reopen the printed location and show the object id the commit bound while the external work stands untouched, 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, `write-tree` and `git var` included, and a new entry's Add and Commit commit today's staged work | +| ERC10 | Completed local Git reaches nothing | An unfinished prefix holding acknowledged Switch, Add and Commit results replays, after the branch, index and HEAD are changed from outside and with the selected managed worktree removed, with zero `git` invocations, working-directory resolutions, identity reads, isolation-directory acquisitions and lock opens, returning the same saved results and object id and recreating nothing | +| ERC11 | Ordinary local semantics | A live Switch to an existing branch ignores an unusable creation base and a missing branch with no base starts at live HEAD; one conflicting-local-change Switch is retained as an ordinary refusal that replays with zero native work after the checkout is repaired, while a new invocation succeeds; a live Commit stages no implicit path, commits the current index under the host's own author and committer with the operation's own clock, and binds the returned object id; a native ignored-path Add refusal keeps native Git's words, leaves what was already matched staged and claims no rollback; a missing identity refuses Commit alone and is read lazily once | +| ERC12 | One result boundary | Two equal Adds at two positions produce and consume two results; a nested Add is an earlier journal operation than the Commit enclosing it, and the commit holds exactly the tree that child staged; two same-repository callers share one ownership acquisition and serialize their native attempts with distinct results; two spawned branches in two repositories resume in reverse order sharing no cursor or result; equal entry sources in two journals stay independent | +| ERC13 | Local-Git acknowledgement | A live Commit gated before its append and then cancelled leaves no completion and no later sentinel, and the continuation runs live and reports ordinary `empty-index` rather than adopting the object id that was really written; one acknowledged and then cancelled replays with no native work; a rejected result append preserves the first persistence failure with no success binding, no later work and no compensating Close; an interrupted Add whose descendant holds the inherited pipes is joined before the entry settles; an unclassified performer failure is fatal and unrecorded | +| ERC14 | Local-Git admission | Portable success and refusal codec rows refuse a wrong, extra or missing member, an unknown version, a mismatched fingerprint or name, an object id of the wrong width, a transition the operation does not make, mismatched operation arguments and a refusal word outside the closed set, and refuse a failed protocol envelope for this type; admission accepts repeated equal names and histories holding only the older record types | --- diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 85a2a891c..315b969d8 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -886,9 +886,73 @@ shares, and a new write, deletion or directory takes it too. Neither hold is taken to answer a completed selection or a completed file operation, and a read takes only the ownership of the checkout it is reading in. -Local Git and publication remain unavailable here; the exclusions below say -which components those are. Issues are not among them — the section below says -what an entry may file and read. +Publication remains unavailable here; the exclusions below say which components +those are. The two sections after this one say what else an entry retains: the +Git work it did in these checkouts, and the issues it filed and read, which need +no checkout at all. + +### Staging, switching and committing in an entry + +Inside a Git checkout with a commit in it, type this: + +```xmd +first + + + +Recorded commit: {commit} +continued +``` + +The staging child finishes before the commit starts. The commit writes the +current index — nothing is staged for it and nothing is amended — and `commit` +binds its full object id. Leave at the question, change the file, stage +something else and make a commit of your own from a shell, then come back to the +printed location. The question still shows the object id the entry recorded. +Nothing ran again: not the write, not the staging, not the commit, and not a +single `git` command. Your file, your index and your HEAD are exactly as you left +them. Answering performs the write after the question for the first time. + +That saved id describes the earlier commit. It is not today's HEAD, and nothing +re-resolves it. A *new* entry that stages and commits commits what the checkout +holds now. + +The same body works inside a lexical `` and inside a selected +``, including a self-closing Worktree's captured path entered through +`` while the enclosing Repository stays the owner. A new `` +uses the ordinary rules: named branches only; an existing branch ignores a +creation-only base; a missing branch starts at the base you supplied or at live +HEAD; Git refuses a branch another checkout of the same repository holds, and +refuses changes it would overwrite. A completed Switch restores its result +without switching anything back. + +A refusal you asked for is retained as a refusal. Repair the checkout after it +was acknowledged and that historical answer stays a refusal — a *new* invocation +can succeed. `` is not all-or-none: a pathspec naming an ignored path +stages what Git already matched and then refuses, and nothing here undoes that. +`` refuses an index that already matches HEAD rather than making an +empty commit, and refuses when this host cannot say who the commit would be by — +the message names the two commands that fix it, and no other identity is +substituted for yours. That refusal reaches Commit alone. + +Who the commit is by is read from your own Git once per entry, and only when a +commit really runs. An entry that commits nothing, and a reopened entry whose +commit is already recorded, ask nothing about who you are. Hooks, signing, +filesystem monitors and repository credential helpers stay disabled. + +Native completion and acknowledgement are different moments, and the gap is +real. Interrupt an entry after the commit object exists and before its result is +written down, and the journal holds no completion: the commit is in your +repository, and a continuation runs that `` live. It may then refuse +`empty-index`, because the earlier attempt already consumed the index. Nothing +adopts the commit that was made, predicts an id or rolls anything back. + +The first new Git operation inside a restored placement takes that placement's +ownership of the managed checkout and then the hold on the repository it is +changing, in that order, and keeps both until the entry is torn down. A +completed Git operation takes neither. A no-op Add, a same-branch Switch and a +Commit that will refuse take them too: whether `git` will change anything is not +predicted. ### The issue an entry filed, and the one it read @@ -1033,12 +1097,15 @@ schedules no timer. do not open the same location twice for writing. The hold on a checkout is a different thing: it says one entry at a time may mutate one repository, and it says nothing about who may write this execution's history. -- Local Git and publication are not part of this. ``, ``, - ``, `` and `` refuse in a REPL entry - exactly as they do under any host that installs no provider for them. What an - entry retains is `` and `` selection, the file and - directory work inside those checkouts and the one it was started in, and the - issues it filed and read; nothing is staged, committed, pushed or published. +- Publication is not part of this. `` and `` refuse in a + REPL entry exactly as they do under any host that installs no provider for + them, and a retained local result authorizes neither. What an entry retains is + `` and `` selection, the file and directory work inside + those checkouts and the one it was started in, ``, `` and + `` in them, and the issues it filed and read; nothing is pushed or + published. A runtime that installs no repository provider — Node and Bun — + refuses the Git components before any native operation, while ordinary + `` and `` work keeps working there. - `` is an ordinary shadowable default here, as it is under every other host. A repository component of that name keeps its ordinary precedence over it, and what such a component does not get is the identity an entry's own diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 66558a29c..d82afbc29 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -150,13 +150,39 @@ now holds, so a continuation works after the checkout has been changed or removed, while new work meets the current filesystem. Nothing is inventoried, snapshotted or restored, and no managed checkout is deleted. +``, `` and `` are available to a REPL entry and +are retained on the same terms. The performers are the ordinary ones §7 +describes — exactly the authored pathspecs, the ordinary branch rules, the +current index and nothing else — against the caller's own checkout rather than +an exported materialization, and the entry adds a record of what each produced. +A completed one is answered from that record with no `git` invocation at all, and +the object id a completed `` bound describes the commit that was made +then rather than the checkout's current HEAD; nothing re-resolves it. A live +`` reads the invoking person's own author and committer from the +trusted host, once per entry and only when a commit really runs, captures the +clock after deciding the index is nonempty, and commits exactly that index. + +The Workflow-specific guarantees in §§7.1–7.3 stay Workflow's. There is no +transaction here, so an Add that native Git refused partway leaves what it had +already matched staged, and the refusal says what `git` reported rather than +claiming the staging was discarded. There is no exactly-once local effect +either: native completion and acknowledgement are separate moments, so an +interrupted entry whose commit object exists and whose result was never recorded +runs that `` live again and may report `empty-index` instead. +Nothing adopts, predicts, repairs or rolls back a local operation. + So the three environments retain mutation differently, and none of the other two -changes. `xmd run` journals no filesystem mutation at all. A workflow run's -mutations are Workspace effects inside a transaction, ordered by the frontier -§9 and §10 describe, and the REPL has none of that: no Workspace, no -transaction, no frontier and no logical filesystem. The local Git operations, -`` and `` are not available to a REPL entry and refuse there -as they do under any host that installs no provider for them. +changes. `xmd run` journals no filesystem mutation at all and replays nothing. A +workflow run's mutations are Workspace effects inside a transaction, ordered by +the frontier §9 and §10 describe, and the REPL has none of that: no Workspace, +no transaction, no frontier and no logical filesystem — what it has is a +historical record of each completed operation, which is neither of the other +two. `` and `` are not available to a REPL entry and +refuse there as they do under any host that installs no provider for them, and a +retained local result authorizes neither. `` is available, and what it +retains is `specs/repl-spec.md`'s subject rather than this document's: it lives +at a service rather than in a Workspace, so none of the transaction, frontier or +materialization rules here reach it. ## 3. Workflow lifecycle @@ -957,9 +983,9 @@ A REPL entry selects through that same ordinary selector, and records the answer rather than the checkout (§2.3). The hold lasts for the entry rather than for one document execution, so a later `` inside the same entry is still protected, and a completed selection takes no hold at all — the first genuinely -new file operation inside a restored placement reacquires it. None of the -durability this section states applies there either: there is still no -Workspace, no transaction and no fork. +new file operation inside a restored placement reacquires it, and so does the +first genuinely new Git operation. None of the durability this section states +applies there either: there is still no Workspace, no transaction and no fork. What both share is the composition value a component observes: a Repository selection carries a provider-minted identifier, a display name, the @@ -1181,9 +1207,9 @@ contextual way `` writes one. ## 7. Git operations -The four Git components are likewise one language with two providers. Which -checkout one acts on is decided the same way in both: by the Repository in -scope and by the contextual working directory, neither of which admits +The four Git components are likewise one language behind several providers. +Which checkout one acts on is decided the same way everywhere: by the Repository +in scope and by the contextual working directory, neither of which admits anything. Under a workflow run each is a durable Workspace effect, or — for Push — a @@ -1199,6 +1225,19 @@ rather than substituting one. Push keeps the observe/adopt/fast-forward/refuse rules and, instead of a reconciliation record, leaves private evidence in the provider instance that verified it. +A REPL entry performs Add, Switch and Commit through those same ordinary +performers and keeps a historical record of what each produced (§2.3). The +record is neither of the other two things: it is not a transaction, and it is +not the absence of replay. A completed operation is answered from it with no +`git` invocation at all, and the object id a completed Commit bound names the +commit that was made then rather than the checkout's current HEAD. A live Commit +there reads the invoking person's own identity on the ordinary run's terms, +captures the clock after deciding the index is nonempty, and commits exactly +that index. The rollback and identity clauses of §§7.1–7.3 remain Workflow's, +and a REPL entry promises neither: an Add native Git refused partway leaves what +it had already staged, and an operation that physically finished without being +acknowledged may run again and answer differently. + Git operations require a contextual Repository or Worktree checkout. They use the transactional Workspace Git implementation, not an implicit host command. @@ -3578,6 +3617,7 @@ fetch operation requires its own language and durability contract. | the same twelve declarations under every runtime | built by #643: one shadowable array declared by the bundled Git Plugin and consumed wherever its profile is assembled — `xmd syntax`, `xmd plan`, an ordinary document execution and a workflow action that executes a document. `` left that array when canonical core took the name, and is present under every runtime without it | | ``, `` and the ambient Repository under an ordinary run | built by #643, Deno and compiled only: managed checkouts under `~/.xmd/repositories` with version 1 sidecars and execution-owned non-blocking locks, and the checkout the command was run in as the default Repository. Node and Bun install no operational provider | | `` and `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the ordinary managed selector above, with what it answered retained as a small routing record so a completed selection restores without a selector, a clone, a `worktree add`, a sidecar read, a credential or a lock — and only new work meets the current filesystem. One entry at a time owns a managed slot, held until the entry is torn down and reacquired lazily by the first new operation inside a restored placement; that hold is distinct from the shared Git directory's mutation lease. Nothing is inventoried, snapshotted, restored or deleted, and Node and Bun install no operational provider | +| ``, `` and `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the ordinary performers above against the checkout the entry is working in, with what each produced retained as fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products. A completed operation is answered from that record with no `git` invocation of any kind, and a completed Commit's object id names the commit that was made then rather than today's HEAD. A live Commit reads the invoking person's own author and committer once per entry and only when a commit runs, captures the clock after deciding the index is nonempty, and commits exactly that index. A live operation takes the selected placement's slot and then the shared Git directory's mutation lease and keeps both until teardown; a completed one takes neither. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add keeps what it staged and claims no rollback; native completion and acknowledgement are separate, so an unacknowledged operation may run again and answer differently. No transaction, no rollback, no exactly-once local effect, and no publication evidence | | local Git operations and `Git.Push` evidence under an ordinary run | built by #643, Deno and compiled only: the same authored transitions with no transaction and no replay, commits recorded under the invoking user's own captured Git identity with an actionable refusal when the host can name none, and a private per-execution Push evidence entry that authorizes `` and crosses no run | | `` and pull-request reads under an ordinary run | built by #643, Deno and compiled only: the same transports and ceilings with no durable envelope, keyed by this execution's own invocation identity | | transactional Git components (`Git.Switch`, `Git.Add`, `Git.Commit`) | built by #294, Deno provider only | From c8b566427ec1f49493c2309317b6ba4a4c3e2aad Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 16 Oct 2026 15:53:37 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Snapshot=20a=20Switch's=20br?= =?UTF-8?q?anch,=20and=20read=20a=20result=20back=20before=20acknowledging?= =?UTF-8?q?=20it=20(#181,=203/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reproduced defects let a REPL entry append local Git history the same build cannot read. `` captured the received branch into its request and then read the invocation again when it called the performer. The invocation is the caller's own object and the operation suspends several times in between — hashing the request, routing, taking the turn and taking two holds — so ordinary middleware that transforms and delegates could leave the record naming `ready` while the checkout moved to `changed`, with that mismatched result already acknowledged. The branch is now read once, before the first suspension, and that one value is what both the request and the performer use. The local codec refused an empty supplied base. `` is the absence of a base to the component, so no document reaches it, but the public Api can be called with one and the ordinary performer ignores a base for a branch that already exists — so the switch succeeded, was acknowledged, and then could be read back by nothing, which also refused the whole history at admission. The codec now carries a Switch base and requestedBase as null or any string, the empty one included, keeping the exact bytes and the exact request/result equality. The branch stays a non-empty name, a non-string base is still refused, the commit a created branch started at is still an object id, and the shared Workflow parsers are untouched. Both were acknowledged before anything checked them, so the live callback now reads its own payload back — against the request and this entry's registered placements — before the coordinator publishes it. An unreadable or mismatched result takes the existing fatal path with nothing appended, by the same route the replay side already used. Regressions: a deterministic changing-branch request through ordinary Git middleware, a direct empty base on an existing branch replaying from a real unfinished prefix, and portable codec coverage for the empty base with wrong-type and missing-member controls. The E5 common-directory row no longer writes a file between the question and its first new Git call, so what acquires the hold there is Git itself. --- architecture.md | 12 +- .../git/src/deno/retained-repository/git.ts | 54 ++++++-- .../src/retained-repository/git-records.ts | 32 ++++- .../git/tests/retained-repository-git.test.ts | 124 ++++++++++++++++++ .../retained-repository-lifecycle.test.ts | 14 +- .../tests/retained-repository-records.test.ts | 56 ++++++++ specs/repl-spec.md | 5 + 7 files changed, 278 insertions(+), 19 deletions(-) diff --git a/architecture.md b/architecture.md index 85e1d57bf..3b8189854 100644 --- a/architecture.md +++ b/architecture.md @@ -6703,8 +6703,16 @@ that. The performer runs in a joined boundary before its outcome exists, so every per-call native child has finished and closed before anything is published — and a cancellation while a child is running joins that child's close before the -entry settles. Publication is once, and the ordinary refusal is raised after it, -so a live refusal and a replayed one are the same error. Cancellation after +entry settles. What is about to be published is read back first, against the +request and this entry's own registrations: a payload this build cannot parse, +one describing another request, and one naming a checkout no placement covers +each take the fatal path *before* the append rather than becoming history +nothing can read. The received invocation is likewise read once, before the +first suspension, because it is the caller's own object and this operation +suspends several times before Git runs — a value read again afterwards could +leave the record naming one branch and the checkout standing on another. +Publication is once, and the ordinary refusal is raised after it, so a live +refusal and a replayed one are the same error. Cancellation after native completion but before the acknowledged append leaves the checkout changed and no completion; cancellation after it returns the saved answer next time. Neither cut restores checkout state. A failed append stays the engine's diff --git a/packages/git/src/deno/retained-repository/git.ts b/packages/git/src/deno/retained-repository/git.ts index b4fc7aa18..f22a8ee47 100644 --- a/packages/git/src/deno/retained-repository/git.ts +++ b/packages/git/src/deno/retained-repository/git.ts @@ -95,6 +95,7 @@ import { import type { ReplGitAddRequest, ReplGitCommitRequest, + ReplGitRecord, ReplGitOperation, ReplGitPlace, ReplGitRefusalData, @@ -225,15 +226,21 @@ function* recordedSwitch( entry: ReplGitEntry, invocation: GitSwitchInvocation, ): Operation { + // Read once, here, and used by both the request and the performer. The + // invocation is the caller's own object and this operation suspends several + // times before Git runs — hashing the request, routing, taking the turn and + // taking two holds — so reading it again later would let the record name one + // branch while the checkout moved to another. + const branch = invocation.branch; const base = invocation.base; const request: ReplGitSwitchRequest = Object.freeze({ operation: "switch" as const, ...place(entry, SWITCH, invocation), - branch: invocation.branch, + branch, base: base ?? null, }); const value = yield* recorded(entry, SWITCH, request, (checkout) => - liveSwitch(checkout, invocation.branch, base), + liveSwitch(checkout, branch, base), ); if (!("requestedBranch" in value)) { throw mismatch(SWITCH); @@ -338,7 +345,14 @@ function* recorded( liveCheckout(yield* entry.git(), target.checkout, request.workingDirectory), ); }); - return replGitRecordJson({ fingerprint, request, outcome }); + const payload = replGitRecordJson({ fingerprint, request, outcome }); + // Read back before the coordinator publishes it. A payload this build + // cannot parse, or one describing another request or a checkout this + // entry holds no placement for, is not an outcome to acknowledge: it + // takes the fatal path here, so nothing is appended and a continuation + // meets today's checkout instead of a record it could never read. + readback(entry, element, request, fingerprint, payload); + return payload; }, { coordinator: gitCoordinator }, ); @@ -350,6 +364,30 @@ function* recorded( ? yield* once() : yield* live(entry, routing, once); + const parsed = readback(entry, element, request, fingerprint, stored); + if (!parsed.outcome.ok) { + throw failure(element, refusalOf(parsed.outcome.error)); + } + return parsed.outcome.value; +} + +/** + * The record this value is for this operation, or the fatal failure it is not. + * + * The same three questions on both paths, which is the point: a live outcome is + * held to them before it is published, and a restored one before it is + * returned. The payload parses, it describes this request, and the checkout a + * success names is one this entry registered under the repository it + * authenticated — with parsed facts alone, so a result path never authorizes a + * native call and a new call chooses its checkout independently. + */ +function readback( + entry: ReplGitEntry, + element: string, + request: ReplGitRequest, + fingerprint: string, + stored: unknown, +): ReplGitRecord { const parsed = parseReplGitRecord(stored); if (parsed === undefined) { throw new ReplGitRecordError( @@ -360,14 +398,10 @@ function* recorded( if (parsed.operation !== request.operation || parsed.fingerprint !== fingerprint) { throw mismatch(element); } - if (!parsed.outcome.ok) { - throw failure(element, refusalOf(parsed.outcome.error)); + if (parsed.outcome.ok) { + authorized(entry, element, request, parsed.outcome.value); } - // The checkout a saved result names has to be one this entry registered under - // the repository it authenticated. Parsed facts only: a result path is never - // used to authorize a native call, and a new call chooses its own checkout. - authorized(entry, element, request, parsed.outcome.value); - return parsed.outcome.value; + return parsed; } /** diff --git a/packages/git/src/retained-repository/git-records.ts b/packages/git/src/retained-repository/git-records.ts index 17bc425fc..2790d39ae 100644 --- a/packages/git/src/retained-repository/git-records.ts +++ b/packages/git/src/retained-repository/git-records.ts @@ -103,7 +103,16 @@ export interface ReplGitAddRequest extends ReplGitPlace { export interface ReplGitSwitchRequest extends ReplGitPlace { readonly operation: "switch"; readonly branch: string; - /** The base exactly as it was supplied, or `null` when none was. */ + /** + * The base exactly as it was supplied, or `null` when none was. + * + * Any string, the empty one included. `` is the absence + * of a base as far as the component is concerned, so a document cannot write + * one — but the public Api can be called with it, the ordinary performer + * consults a base only when a branch has to be created, and an operation this + * host acknowledged has to be one it can read back. Refusing the empty string + * here would leave a successful switch journaled and unreadable. + */ readonly base: string | null; } @@ -417,7 +426,7 @@ function parseRequest(value: unknown, operation: ReplGitOperation): ReplGitReque } if (operation === "switch") { const branch = text(read["branch"]); - const base = optionalText(read["base"]); + const base = suppliedBase(read["base"]); return branch === undefined || base === undefined ? undefined : Object.freeze({ operation: "switch" as const, ...place, branch, base }); @@ -545,7 +554,7 @@ function switchResult(value: unknown, request: ReplGitSwitchRequest): GitSwitchR const checkout = hostCheckoutIdentity(record["checkout"], request); const requestedBranch = text(record["requestedBranch"]); const resolvedBranch = text(record["resolvedBranch"]); - const requestedBase = optionalText(record["requestedBase"]); + const requestedBase = suppliedBase(record["requestedBase"]); const resolvedBase = record["resolvedBase"] === null ? null : parseGitObjectId(record["resolvedBase"], format); const before = parseGitCheckoutState(record["before"], format); @@ -729,6 +738,23 @@ function samePaths(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((entry, index) => entry === right[index]); } +/** + * The base a Switch was given: `null`, or any string it was given. + * + * Wider than the identity members beside it, and deliberately so. A base is a + * value the caller supplied rather than a fact this host derived, the ordinary + * performer reads it only when it has to create a branch, and the record has to + * carry back exactly what came in. Anything that is neither a string nor `null` + * — a number, an object, an absent member — is a record this version did not + * write and is refused as one. + */ +function suppliedBase(value: unknown): string | null | undefined { + if (value === null) { + return null; + } + return typeof value === "string" ? value : undefined; +} + /** A SHA-256 digest, which is one width and one case wherever one is written. */ function sha256(value: unknown): string | undefined { const candidate = text(value); diff --git a/packages/git/tests/retained-repository-git.test.ts b/packages/git/tests/retained-repository-git.test.ts index 5ec4fc431..0c57358be 100644 --- a/packages/git/tests/retained-repository-git.test.ts +++ b/packages/git/tests/retained-repository-git.test.ts @@ -58,6 +58,11 @@ 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 { Git } from "@executablemd/git/api"; +// From the root rather than from the source module: this result type is what the +// 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"; @@ -550,6 +555,16 @@ function commonOf(fixture: Fixture, checkout: string): Operation { return fixtureGit(fixture, ["rev-parse", "--path-format=absolute", "--git-common-dir"], checkout); } +/** What admitting this history answered: nothing, or the refusal it raised. */ +function* admitted(events: readonly DurableEvent[]): Operation { + try { + yield* admitReplRepositoryHistory(events); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + /** The question an entry is left at, never answered. */ const QUESTION = 'Continue?'; @@ -885,6 +900,115 @@ describe("one entry's local Add, Switch and Commit", () => { expect(replayed.commands).toEqual([]); }); + it("E3: switches to the branch it recorded when the caller's value changes", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + // Two real branches, so whichever one is reached is a branch that exists + // and the difference is about which was asked for rather than about a + // refusal. + yield* fixtureGit(fixture, ["branch", "ready"], fixture.checkout); + yield* fixtureGit(fixture, ["branch", "changed"], fixture.checkout); + + // Ordinary middleware that transforms and delegates, which is the supported + // route. What it delegates is a request whose branch answers differently + // the second time it is read — a caller-owned value this operation suspends + // several times across. + let reads = 0; + const changing = function* (): Operation { + yield* Git.around({ + *switch([invocation], next): Operation { + return yield* next({ + ...invocation, + get branch(): string { + reads += 1; + return reads === 1 ? "ready" : "changed"; + }, + }); + }, + }); + }; + + const source = ['', QUESTION].join("\n"); + const prefix = yield* left(fixture, source, counted, { between: changing }); + + // Read once, so the record and the checkout cannot disagree. + expect(reads).toBe(1); + const record = gitRecords(prefix)[0]; + expect(record?.request.operation === "switch" ? record.request.branch : undefined).toBe( + "ready", + ); + expect(switched(record)).toBe("ready"); + expect((yield* observed(fixture, fixture.checkout)).branch).toBe("ready"); + + // And the acknowledged result is one this host can read, so the entry + // continues from it with no native work. + const replayed = counters(); + let later = 0; + const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { + *between(): Operation { + yield* Git.around({ + *switch([invocation], next): Operation { + later += 1; + return yield* next(invocation); + }, + }); + yield* answered(); + }, + }); + expect(again.refusal).toBe(undefined); + expect(replayed.commands).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); + }); + + it("E3: retains an empty supplied base exactly, and reads it back", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + yield* fixtureGit(fixture, ["branch", "ready"], fixture.checkout); + const before = yield* observed(fixture, fixture.checkout); + + // `` is the absence of a base to the component, so this + // arrives through the public Api — where the ordinary performer ignores it + // for a branch that exists, exactly as it ignores any base there. + const supplied = function* (): Operation { + yield* Git.around({ + *switch([invocation], next): Operation { + return yield* next({ ...invocation, base: "" }); + }, + }); + }; + + const source = ['', QUESTION].join("\n"); + const prefix = yield* left(fixture, source, counted, { between: supplied }); + + // The ordinary success, retained with the bytes it was given rather than + // with an absence invented for it. + const record = gitRecords(prefix)[0]; + expect(record?.outcome.ok).toBe(true); + expect(record?.request.operation === "switch" ? record.request.base : undefined).toBe(""); + expect( + record?.outcome.ok === true && "requestedBase" in record.outcome.value + ? record.outcome.value.requestedBase + : undefined, + ).toBe(""); + expect(switched(record)).toBe("ready"); + expect((yield* observed(fixture, fixture.checkout)).head).toBe(before.head); + // Readable, and admissible as this entry's own history. + expect(yield* admitted(prefix)).toBe(undefined); + + // So a real unfinished prefix continues from it, with no native work. + const replayed = counters(); + const again = yield* run(fixture, source, new InMemoryStream([...prefix]), replayed, { + *between(): Operation { + yield* supplied(); + yield* answered(); + }, + }); + expect(again.refusal).toBe(undefined); + expect(replayed.commands).toEqual([]); + }); + it("E3: keeps a native ignored-path Add refusal native, with no rollback claim", function* () { const fixture = yield* useFixture(); const counted = counters(); diff --git a/packages/git/tests/retained-repository-lifecycle.test.ts b/packages/git/tests/retained-repository-lifecycle.test.ts index 7c7521a44..f9124a372 100644 --- a/packages/git/tests/retained-repository-lifecycle.test.ts +++ b/packages/git/tests/retained-repository-lifecycle.test.ts @@ -1101,13 +1101,17 @@ describe("an entry's hold on the checkout it is working in", () => { // One linked placement of the repository the entry was opened in, with a // Git operation acknowledged inside it and a second one still to run. + // + // Nothing comes between the question and that second Add. A new `` + // there would take both holds first, and the Add's reacquisition of a hold + // somebody else already took for it says nothing about what Git itself + // acquires — so Git is the first new operation on this continuation. const source = [ '', 'probe', '', QUESTION, - 'later', - '', + '', "", ].join("\n"); @@ -1163,10 +1167,12 @@ describe("an entry's hold on the checkout it is working in", () => { ); // The completed Git operation replayed with no native work and no hold on - // the shared Git directory at all; the new one refused under the holder's - // mutation lease, before it ran and before it appended anything. + // the shared Git directory at all; the new one — the first new operation + // 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(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 diff --git a/packages/git/tests/retained-repository-records.test.ts b/packages/git/tests/retained-repository-records.test.ts index 1fc5ec9f3..9f2691301 100644 --- a/packages/git/tests/retained-repository-records.test.ts +++ b/packages/git/tests/retained-repository-records.test.ts @@ -1000,6 +1000,62 @@ describe("what one entry's local Git records say", () => { ); }); + it("carries any supplied Switch base exactly, the empty string included", function* () { + const supplied: ReplGitRequest = Object.freeze({ ...SWITCH_REQUEST, base: "" }); + const result: GitSwitchResult = Object.freeze({ ...SWITCH_RESULT, requestedBase: "" }); + const { payload } = yield* gitPayload(supplied, Ok(result)); + + // Round trips as the bytes it was given. A base is a value the caller + // supplied rather than a fact this host derived, the ordinary performer + // consults one only to create a branch, and an acknowledged success has to + // be readable — so the empty string is carried rather than refused. + const record = parseReplGitRecord(payload); + expect(record?.request.operation === "switch" ? record.request.base : undefined).toBe(""); + expect( + record?.outcome.ok === true && "requestedBase" in record.outcome.value + ? record.outcome.value.requestedBase + : undefined, + ).toBe(""); + // And it names a different operation from the same request with no base, + // so the two cannot be confused for one another. + expect(yield* replGitFingerprint(supplied)).not.toBe(yield* replGitFingerprint(SWITCH_REQUEST)); + + // Request and result still have to agree about it. + const value = savedValue(payload); + expect(parseReplGitRecord(withValue(payload, { ...value, requestedBase: null }))).toBe( + undefined, + ); + + // Everything that is neither a string nor null is still a record this + // version did not write — on both sides. + for (const wrong of [42, {}, [], true]) { + expect( + parseReplGitRecord({ + ...Object(payload), + request: { ...Object(Object(payload)["request"]), base: wrong }, + }), + ).toBe(undefined); + expect(parseReplGitRecord(withValue(payload, { ...value, requestedBase: wrong }))).toBe( + undefined, + ); + } + // An absent member is absent, not an absent base. + const { base: _dropped, ...missing } = Object(Object(payload)["request"]); + expect(parseReplGitRecord({ ...Object(payload), request: missing })).toBe(undefined); + + // The branch stays a non-empty name: it is what the operation is of. + expect( + parseReplGitRecord({ + ...Object(payload), + request: { ...Object(Object(payload)["request"]), branch: "" }, + }), + ).toBe(undefined); + + // And the commit a created branch started at is still an object id, which + // the empty string is not. + expect(parseReplGitRecord(withValue(payload, { ...value, resolvedBase: "" }))).toBe(undefined); + }); + it("reads back a refusal, and refuses a word outside the closed set", function* () { const { payload } = yield* gitPayload( ADD_REQUEST, diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 315b969d8..0db36b9a4 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -940,6 +940,11 @@ commit really runs. An entry that commits nothing, and a reopened entry whose commit is already recorded, ask nothing about who you are. Hooks, signing, filesystem monitors and repository credential helpers stay disabled. +What an entry writes down, it can read back. A result this build cannot parse, +or one that does not describe the operation it stands at, ends the entry before +anything is recorded rather than becoming a saved answer no continuation can +use. + Native completion and acknowledgement are different moments, and the gap is real. Interrupt an entry after the commit object exists and before its result is written down, and the journal holds no completion: the commit is in your From 0ac64e8da81b612d538654cc99b8ad141bec2636 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 16 Oct 2026 16:39:40 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9A=A8=20Give=20the=20Git=20journey's?= =?UTF-8?q?=20home=20an=20identity,=20and=20drop=20two=20banned=20words=20?= =?UTF-8?q?(#181,=203/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI failures, two causes, neither in the delivered behavior. `` borrows the invoking person's Git identity, and the public journey starts the REPL in an isolated `HOME` of its own. On this Mac `git var` auto- detects one from the operating-system user, so the row passed locally; a runner whose hostname has no domain can auto-detect nothing, `git var` fails, and the component reported its ordinary missing-identity refusal. The product was right and the fixture was wrong: that home now carries a `user.name` and `user.email` the way a person's would, and the commit is held to them — which also makes the row prove the real reader end to end rather than whatever the host happened to guess. It fixed both `test-deno (7/15)`, where the source journey runs, and `smoke`, where the compiled one does. `lint` failed on two uses of "claims" in `architecture.md`, which `scripts/lint-architecture.js` forbids in prose. Both now say what they mean. --- architecture.md | 4 +-- .../cli/tests/support/repl-files-journey.ts | 33 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/architecture.md b/architecture.md index 3b8189854..2ef50f0ba 100644 --- a/architecture.md +++ b/architecture.md @@ -6694,7 +6694,7 @@ after a refusal was acknowledged does not turn that historical answer into success; a new invocation can succeed. Native Add is deliberately not all-or-none — a command naming an ignored path stages what it already matched and then refuses — and nothing here rolls that back, so the refusal says what -`git` reported and never claims the staging was discarded. Lock contention, +`git` reported and never says the staging was discarded. Lock contention, admission and infrastructure conditions are not refusals: an unrecognized performer or cleanup failure activates the first-failure fence and propagates, publishing nothing, and a printing region around the component does not weaken @@ -7160,7 +7160,7 @@ Status is measured against main. | REPL entry retained file work | one executing REPL entry's own recording, established by core initialization in the scope the invocation owns and ending with it: it retains the `` reads, `` writes, `` deletions and `` ensures that land inside a checkout the entry is working in — the ambient one it was started in, or a managed one it selected — authored and admitted-generated alike, because both cross the one document filesystem. A completed operation answers from its saved result without live path canonicalization, ambient discovery, a lease, a permission lookup or a filesystem call — so only *new* work needs the environment, a continuation still binds the text its read answered with while the file says something else, and a new entry reading that path sees what is there now. A record's name is the digest of the record version, the operation kind and the complete received request, so two equal requests in one entry are one name at two ordered positions and the durable engine's positional matching tells them apart; equal work in two entries is the same name, and each entry executing against its own stream segment is what keeps those apart. Recording is ordinary ancestor-max `Files` middleware, so a provider beneath that answers directly or transforms and delegates is kept as provider behavior while the received request stays the operation's identity. A shared Git directory admits one live *mutating* entry at a time, held lazily for as long as the execution lasts and refused rather than queued; no read acquires it, so a new read proceeds while another entry holds it. The local Git and issue work an entry retains have rows of their own beside this one; work outside every checkout the entry is working in stays ordinary and unrecorded, and a passive reading initializes nothing [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | | REPL entry retained selection | one executing REPL entry's own `` and `` selections, recorded beside its file work as small routing records: the canonical managed root, the checkout root, the common Git directory, the credential-free repository identity, the display name and the owner checkout, with a root-level ``'s ambient owner recorded as its own answer. The selector is the ordinary managed one, with its own creation, compatible-reuse, conflict and interrupted-adoption policy; what this adds is that a completed selection answers from its record, calling no selector and reaching no clone, `worktree add`, sidecar, credential or lock — so a continuation works in a checkout somebody has since changed or removed, while only new work meets today's filesystem. Parsed facts are held to the call they stand at with pure slot arithmetic against the root the record itself carries, registered in entry-private state and minted as a fresh selection, so a copied or edited Repository value can misname a target and never reach one. One entry at a time *owns* a managed slot, held until teardown and reacquired lazily by the first new operation inside a restored placement; that is a different hold from the shared Git directory's mutation lease, which only a new `` selector and a new mutation take. A selection is acknowledged before its path is bound, and native work that was never acknowledged runs live again under the ordinary partial-creation policy [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | | REPL entry retained issue work | one executing REPL entry's own `` work, retained under the ordinary `issue_effect` records beside its file work and needing no checkout at all: no repository is discovered for it, no managed root is made and no hold is taken. A completed read answers with the snapshot it saw and a completed upsert with the URL it was filed under, neither resolving a provider, reading configuration nor opening a credential. What names it is the entry's own owner — derived by the session from the execution and the entry's admission-order position — with the identity canonical execution minted where it selected this implementation for this invocation; the `runId` and `expansionId` spellings are a compatibility encoding of those two, and a REPL entry installs no synthetic WorkflowRun. Neither member is reachable from a prop, a tracker, a Context, a middleware request or a record field a document can write, and the entry's `` is an ordinary shadowable registration a repository component still outranks. One durable boundary encloses the operation and the live attempt inside it composes `IssueOperations` over a transport-facing default of the entry's own, bound in the attempt's own scope under the attempt's own idempotency key and gone before the envelope publishes; canonical form dispatch selects which of the two effects an invocation asks for. An upsert the service accepted and this end never acknowledged retains nothing and presents the same key next time, which is what the provider adopts under; a read has no key and may be made again. The entry's retained issue records are held to its own owner before any of them are replayed [Reference](#what-one-entry-retains-about-the-issue-it-filed). | built on the #181 stack | -| REPL entry retained local Git | one executing REPL entry's own ``, `` and ``, recorded beside its file work and its selections. The performers are the ordinary ones — exactly the pathspecs a document wrote, the ordinary branch rules, the current index and nothing else — installed as ordinary Git dispatch at the implementation slot, so middleware above may answer, refuse, or transform and delegate, and a call answered wholly by a replacement acquires no retention. One durable operation per call and no outer envelope, so a staging child inside a Commit is an earlier journal operation of its own. A completed operation answers from its saved result and runs no `git` at all, writing no tree and reading no identity; a saved commit object id names the commit that was made then and is never re-resolved against today's HEAD. A result is fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products — never a file list, a checkout snapshot or Git output, and a commit message appears as a digest and a byte length rather than a second copy of the text. Routing is private and live-only: the received working directory is canonicalized and the ordinary selector chooses the longest covering checkout among this entry's registrations under the whole authenticated Repository identity, and a saved result's checkout is held to those registrations with parsed facts alone. The invoking person's author and committer are read once per entry, lazily, and only by a genuinely live Commit; a host that cannot say becomes the existing Commit-only refusal. A live operation takes the selected placement's managed slot and then the shared Git directory's mutation lease, keeps both until teardown and takes neither on replay, with this entry's own native attempts serialized per shared Git directory. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add says what `git` reported and claims no rollback; contention, admission and infrastructure failures are fatal and unrecorded. Native completion and acknowledgement are separate: an unacknowledged Commit runs live again and may instead refuse empty-index, and a failed append fences later work with no success binding and no compensating Close. Nothing external is here, and a local result confers no publication evidence [Reference](#the-three-local-git-operations-and-what-a-saved-object-id-means). | built on the #181 stack | +| REPL entry retained local Git | one executing REPL entry's own ``, `` and ``, recorded beside its file work and its selections. The performers are the ordinary ones — exactly the pathspecs a document wrote, the ordinary branch rules, the current index and nothing else — installed as ordinary Git dispatch at the implementation slot, so middleware above may answer, refuse, or transform and delegate, and a call answered wholly by a replacement acquires no retention. One durable operation per call and no outer envelope, so a staging child inside a Commit is an earlier journal operation of its own. A completed operation answers from its saved result and runs no `git` at all, writing no tree and reading no identity; a saved commit object id names the commit that was made then and is never re-resolved against today's HEAD. A result is fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products — never a file list, a checkout snapshot or Git output, and a commit message appears as a digest and a byte length rather than a second copy of the text. Routing is private and live-only: the received working directory is canonicalized and the ordinary selector chooses the longest covering checkout among this entry's registrations under the whole authenticated Repository identity, and a saved result's checkout is held to those registrations with parsed facts alone. The invoking person's author and committer are read once per entry, lazily, and only by a genuinely live Commit; a host that cannot say becomes the existing Commit-only refusal. A live operation takes the selected placement's managed slot and then the shared Git directory's mutation lease, keeps both until teardown and takes neither on replay, with this entry's own native attempts serialized per shared Git directory. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add says what `git` reported and promises no rollback; contention, admission and infrastructure failures are fatal and unrecorded. Native completion and acknowledgement are separate: an unacknowledged Commit runs live again and may instead refuse empty-index, and a failed append fences later work with no success binding and no compensating Close. Nothing external is here, and a local result confers no publication evidence [Reference](#the-three-local-git-operations-and-what-a-saved-object-id-means). | built on the #181 stack | | live durable-operation coordinator | explicitly coordinates structured live execution with existing Yield publication while leaving replay and callback effects unchanged [Invariant](#live-effect-coordination). | built on the #365 stack | | Workspace coordination API | fails closed by default; replaceable context routes only a one-use provider selection, while the selected provider directly invokes an execution-owned credentialed capability for execution, publication and failure activation [Invariant](#live-effect-coordination). | built on the #365 stack; the Deno provider installs an adapter-private atomic handler | | shared Git-host reconciliation | Reconciles Git push and pull-request upsert through one request-only boundary whose private terminal alone settles the retained outcome. [Invariant](#external-effects-and-git-host-mutations); [Invariant](#issue-effects). | Built on #297; Push (#370) and PullRequest (#295) consume it. Issue (#296) has its own middleware boundary. | diff --git a/packages/cli/tests/support/repl-files-journey.ts b/packages/cli/tests/support/repl-files-journey.ts index 679cc9987..9196d1a15 100644 --- a/packages/cli/tests/support/repl-files-journey.ts +++ b/packages/cli/tests/support/repl-files-journey.ts @@ -644,6 +644,30 @@ function* logged(log: string): Operation { return held.split("\n").filter((line) => line !== ""); } +/** + * The author and committer this journey's entry commits under. + * + * `` borrows the *invoking person's* identity, read with `git var` + * from the environment the command was started in — and this journey starts it + * in an isolated `HOME` of its own. So that home gets a configuration, exactly + * as a person's would have one, and the commit is then held to it below. + * + * Without it the identity is whatever the host happens to auto-detect from its + * operating-system user, which a continuous-integration runner commonly cannot + * do at all: `git var` fails, `` reports its ordinary + * missing-identity refusal, and the row would be failing for a reason that is + * about the runner rather than about this feature. + */ +const JOURNEY_IDENTITY = { name: "Journey Author", email: "journey-author@example.test" }; + +/** Give this journey's isolated home a Git identity to commit under. */ +function* seedIdentity(home: string): Operation { + yield* writeTextFile( + join(home, ".gitconfig"), + `[user]\n\tname = ${JOURNEY_IDENTITY.name}\n\temail = ${JOURNEY_IDENTITY.email}\n`, + ); +} + /** One seeded checkout with a commit in it, so a Commit has a parent. */ function* seedCommitted(home: string): Operation { const checkout = join(home, "checkout"); @@ -760,6 +784,7 @@ export function replGitJourney(repl: ReplCommand, through: GitJourney): Operatio return scoped(function* (): Operation { const home = yield* useTempDirectory("xmd-repl-git-pty-"); const shim = yield* gitShim(home); + yield* seedIdentity(home); const ambient = yield* seedCommitted(home); const origin = through === "ambient" ? "" : yield* seedOrigin(home); // The ambient journey is typed from inside the checkout; the managed one @@ -786,8 +811,14 @@ export function replGitJourney(repl: ReplCommand, through: GitJourney): Operatio expect(yield* exists(join(working, "after.md"))).toBe(false); const recorded = yield* gitSaid(working, ["rev-parse", "HEAD"], home); expect(recorded).toMatch(/^[0-9a-f]{40}$/); - // The commit holds exactly what the staging child staged. + // The commit holds exactly what the staging child staged, and records the + // identity this home configured — the invoking person's own, read by the + // host rather than substituted. expect(yield* gitSaid(working, ["show", "HEAD:note.md"], home)).toBe("first"); + expect(yield* gitSaid(working, ["log", "-1", "--format=%an <%ae>|%cn <%ce>"], home)).toBe( + `${JOURNEY_IDENTITY.name} <${JOURNEY_IDENTITY.email}>|` + + `${JOURNEY_IDENTITY.name} <${JOURNEY_IDENTITY.email}>`, + ); // And the document's own Git really ran, which is what makes the empty log // below say something. expect((yield* logged(shim.log)).length > 0).toBe(true);