Skip to content

✨ Stage, switch and commit in REPL entries - #927

Merged
taras merged 3 commits into
mainfrom
agent/repl-local-git-pr3
Oct 16, 2026
Merged

taras merged 3 commits into
mainfrom
agent/repl-local-git-pr3

Conversation

@taras

@taras taras commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Why

REPL entries already retain file operations and managed Repository/Worktree selections across leaving and returning. They still refuse <Git.Add>, <Git.Switch> and <Git.Commit>, preventing an entry from staging and committing its work before pausing for a decision.

This adds the three local Git operations to Deno source and compiled REPL entries. It is delivery 3 of #181, following #902 and #925.

What changes

Before: staging, switching and committing require an ordinary run or a workflow.

After: an entry performs them in its ambient checkout or a managed Repository/Worktree and retains each result. For example, inside a checkout with an initial commit:

<File path="note.md">first</File>
<Git.Commit message="Record experiment" as="commit">
  <Git.Add paths="note.md" />
</Git.Commit>
<Elicit schema={{ type: "string", enum: ["continue"] }} as="answer">Recorded commit: {commit}</Elicit>
<File path="after.md">continued</File>

Leave at the question, change the file, index and HEAD from a shell, then reopen the printed location. The question still shows the saved commit ID. No completed Git operation runs again—not even write-tree or git var—and the external changes survive. Answering runs the new suffix; a new entry uses today's checkout.

Live operations reuse ordinary semantics: Add stages explicit pathspecs, Switch follows the existing branch rules, and Commit records the current index under the invoking user's author and committer identity. The saved commit ID describes the earlier result and makes no assertion about current HEAD.

How it works

Git component -> entry handler -> acknowledged result
                            or -> authenticated checkout -> ordinary performer
                                                        -> validate -> append

Each handler snapshots its received request and matches the owning coroutine's journal position before resolving paths or acquiring resources. Live work authenticates the selection through the entry's private registry, takes the managed-slot hold before the shared Git-directory mutation hold, and reuses the existing performer. Commit identity is acquired lazily once per entry.

The local codec retains fixed operation results and closed refusal classifications. The live payload is parsed and checked against its request and registered placement before publication. Ordinary Git middleware keeps its existing answer, transform, refuse and delegate behavior.

Review guide

Start with “Staging, switching and committing in an entry” in specs/repl-spec.md, then review:

  1. packages/git/src/deno/retained-repository/git.ts: request snapshots, replay bypass, ownership and validation before publication.
  2. packages/git/src/retained-repository/git-records.ts and admission.ts: request/result matching and refusal parsing.
  3. packages/git/src/deno/retained-repository/provider.ts: private checkout registration and lazy identity.
  4. The native Git regressions and shared source/compiled REPL journey.

What must stay true

  • Completed replay performs no Git calls, path resolution, identity reads or lock acquisition. Changed-checkout and removed-worktree regressions verify this.
  • An acknowledged result is readable and matches its request. Switch regressions cover a changing caller-owned branch value and an exact empty supplied base; validation runs before append.
  • Commit children finish as separate journal operations before Commit begins. Nested staging and coroutine-concurrency tests verify the ordering.
  • Contention and persistence failures stop later work without recording a successful operation. Ownership and controlled append/cancellation tests cover both boundaries.

How to verify it

Focused evidence on 3341c669:

Coverage Passing result
Local Git, codec, placement/Files lifetime and journal composition 10 suites / 124 steps
Entry assembly, history, segments and generated permissions 32 suites / 138 steps
Ordinary performers, identity, message semantics and isolated children 39 suites / 168 steps
Workflow contracts and module/test inventories 28 suites / 153 steps
Public source journey, fresh build, compiled journey 5 steps; build succeeded; 5 steps
Portable codec and unsupported-runtime behavior Node: 51 passed; Bun: 51 passed

The public journey logs every Git invocation through a forwarding shim, so replay cannot silently perform observational commands. A commit interrupted after native completion but before acknowledgement is retried live and reports ordinary empty-index, proving that no historical commit is adopted or predicted.

Scope

Includes local Add/Switch/Commit retention, runtime integration, record admission, regression coverage and matching architecture/specification updates.

Preserves ordinary-run behavior, Workflow transactions, component forms, the shared authored/generated Files route and existing external-operation protocols. Generated Git remains excluded. Node and Bun retain ordinary File/Dir behavior and refuse these Git operations before native work. Push and pull-request/evidence delivery remain separate; Issues is an independent track.

New abstractions

  • ReplGitEntry supplies the handlers with their entry-owned session, identity acquisition, holds, serialization and private registrations.

  • The local Git codec handles ordinary host checkout paths while preserving the existing Workspace-relative Workflow parsers.

  • ReplGitCheckout pairs ordinary checkout routing data with the placement needed to reacquire ownership.

  • Each new abstraction has concrete consumers or a clear ownership purpose.

  • No speculative functionality is included.

Risks and limitations

Native work and journal acknowledgement are separate. An interrupted or failed append can leave Git changes without a saved result; continuation then runs the operation live. No rollback or exactly-once guarantee is added. Native Add can partially stage matching paths before refusing an ignored path, and its retained refusal makes no rollback claim.

The checkout holds coordinate participating XMD entries/processes; external editors and Git commands can still change the checkout. No dependency, lockfile, release, build, runtime-runner or CI-job changes are included.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Mechanical module-inventory and runtime-exclusion updates are identified.
  • The description matches the final diff and focused test results.

@taras taras changed the title ✨ Resume REPL entries from the Git work they already did ✨ Stage, switch and commit in REPL entries Oct 16, 2026
taras added 2 commits October 16, 2026 16:16
A REPL entry now performs `<Git.Add>`, `<Git.Switch>` and `<Git.Commit>` 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.
…ging it (#181, 3/6)

Two reproduced defects let a REPL entry append local Git history the same build
cannot read.

`<Git.Switch>` 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. `<Git.Switch 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.
@taras
taras force-pushed the agent/repl-local-git-pr3 branch from 3341c66 to c8b5664 Compare October 16, 2026 20:26

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 9 redundant comments. Inline suggestions to remove them below.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// The commit holds exactly what the staging child staged.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Forgotten here, so what the log holds afterwards is what *reopening* ran.

yield* reach(second, "[answer]");
yield* second.type("\r");
// The saved object id, which describes the earlier commit rather than
// today's HEAD.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// today's HEAD.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// placement's slot belongs to the root it was selected under.

// 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 `<Git.Commit>`
// refuses — so the read happens when a commit needs it, not before, and a
// completed Commit's replay never reaches it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// completed Commit's replay never reaches it.


// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// in an ordinary run.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// is describing what happened.

return undefined;
}
// Switching to the branch a checkout is already on moves nothing: no reset, no
// reference change, and nothing staged or unstaged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// reference change, and nothing staged or unstaged.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// rather than one it records.

@taras

taras commented Oct 16, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main at 4702890c, which now carries delivery 6 (#926). New head c8b566427ec1f49493c2309317b6ba4a4c3e2aad; both commits re-signed. The previously reviewed 3341c669 is preserved unamended.

The earlier exact-SHA PASS was anchored to 3341c669 and does not carry over. Delivery 6 touched seven of the same files; four conflicted and their resolutions are content no review has seen:

  • packages/git/tests/module-partition.test.ts — both declared a new Deno adapter module in one list; both kept.
  • architecture.md — both replaced the same "there is no local Git operation here" paragraph. Dropped it, kept the local-Git subsection inside the checkout section and delivery 6's issue section after it, and kept both construct-inventory rows.
  • specs/repl-spec.md — same shape. The Git section became a ### sibling of the selection section rather than a ## that would have broken out of "What is retained, and what is not"; checkout work first, issues after, both exclusion bullets kept.
  • specs/executable-mdx-spec.md — both extended §8.1's entry-provider paragraph; kept delivery 6's issue capability beside the three local Git operations and stated the one remaining absence once.

Three sentences the combination untrued were corrected: the architecture file-work row no longer claims "no local Git operation and nothing external", the REPL registration's doc no longer says "nothing else" (the issue capability is selected separately, and that route was audited as untouched), and §2.3 of the workflow spec no longer lists <Issue> among what an entry cannot reach.

Re-ran the whole frozen battery on the rebased tree — F1 10/124, F2 32/139, F3 39/168, F4 28/153, F5 5 steps → rebuild → 5 steps, F6 Node 51/51 and Bun 51/51 — plus the three preserved review probes, 2 suites / 3 steps. Nothing failed at any point. Staying a draft pending a fresh verdict on this head.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

PR #927: ✨ Stage, switch and commit in REPL entries

19 files, +4550 / -92

Scope

🔴 PR has 4642 lines changed. Split into focused PRs.

🟡 4642 lines changed. PRs under 400 receive more thorough review.

🟡 Changes span 8 directories.

Structural

✅ No structural bloat detected.

Slop

  • packages/cli/tests/support/repl-files-journey.ts:792 — // it selected.
  • packages/cli/tests/support/repl-files-journey.ts:823 — // below say something.
  • packages/cli/tests/support/repl-files-journey.ts:847 — // Forgotten here, so what the log holds afterwards is what *reopening* ran.
  • packages/cli/tests/support/repl-files-journey.ts:855 — // write-tree, not a git var, not a rev-parse.
  • packages/cli/tests/support/repl-files-journey.ts:886 — // Today's staged work, not the commit the earlier entry retained.
  • packages/git/src/deno/retained-repository/git.ts:268 — // Commit never reaches this callback at all.
  • packages/git/src/retained-repository/admission.ts:111 — // non-ok envelope is the engine's own failure rather than theirs.
  • packages/git/src/retained-repository/git-records.ts:529 — // is describing what happened.
  • packages/git/src/retained-repository/git-records.ts:586 — // reference change, and nothing staged or unstaged.
  • packages/git/src/retained-repository/git-records.ts:659 — // rather than one it records.

Static Analysis

Oxlint: 4 diagnostics across 3 files (3 rules)
Density: 0.001 violations/added-line

no-shadow (2): packages/git/src/deno/retained-repository/provider.ts
no-array-sort (1): packages/cli/tests/support/repl-files-journey.ts
unbound-method (1): packages/cli/src/deno-repositories.ts

Correctness

No extraneous code patterns detected.

@taras
taras marked this pull request as ready for review October 16, 2026 20:32
…181, 3/6)

Three CI failures, two causes, neither in the delivered behavior.

`<Git.Commit>` 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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 10 redundant comments. Inline suggestions to remove them below.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// it selected.

`${JOURNEY_IDENTITY.name} <${JOURNEY_IDENTITY.email}>`,
);
// And the document's own Git really ran, which is what makes the empty log
// below say something.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// below say something.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Forgotten here, so what the log holds afterwards is what *reopening* ran.

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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// `write-tree`, not a `git var`, not a `rev-parse`.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Today's staged work, not the commit the earlier entry retained.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Commit never reaches this callback at all.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// non-ok envelope is the engine's own failure rather than theirs.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// is describing what happened.

return undefined;
}
// Switching to the branch a checkout is already on moves nothing: no reset, no
// reference change, and nothing staged or unstaged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// reference change, and nothing staged or unstaged.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// rather than one it records.

@taras
taras merged commit fd8ae52 into main Oct 16, 2026
84 of 86 checks passed
@taras
taras deleted the agent/repl-local-git-pr3 branch October 16, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant