Skip to content

Exgit.Workspace: writable working tree + VFS backend for agent loops#2

Merged
ivarvong merged 2 commits into
mainfrom
exgit-workspace
May 5, 2026
Merged

Exgit.Workspace: writable working tree + VFS backend for agent loops#2
ivarvong merged 2 commits into
mainfrom
exgit-workspace

Conversation

@ivarvong
Copy link
Copy Markdown
Owner

@ivarvong ivarvong commented May 3, 2026

What this enables

A new top-level capability: a writable, in-memory git working tree that an agent loop can read, write, snapshot, branch, diff, and commit against — with optional :vfs integration so it composes alongside other filesystems (scratch, postgres, S3) under one mount table.

The contract:

  • Every state of the workspace is a real git tree-SHA. Snapshots are 20-byte values you can persist anywhere and restore later; branching is ws_b = ws_a (the struct is a value); commit is O(1) hash-and-store on top of the existing tree.
  • Reads see writes immediately. State threads through every op; cache growth from lazy partial-clone fetches and head_tree advancement from writes are both visible to subsequent calls.
  • Commit is explicit, never implicit. Writes never auto-commit. Workspace.commit/2 materializes a commit; update_ref: advances a real branch atomically.
  • One Exgit.push away from a real remote. Verified end-to-end against a real GitHub repo.
ws = Exgit.Workspace.open(repo, "main")

{:ok, ws} = Exgit.Workspace.write(ws, "lib/foo.ex", new_source)
{:ok, ws} = Exgit.Workspace.rm(ws, "lib/old.ex")

{:ok, content, ws}  = Exgit.Workspace.read(ws, "lib/foo.ex")
{:ok, changes, ws}  = Exgit.Workspace.diff(ws)        # [{:modified, "lib/foo.ex"}, ...]
saved               = Exgit.Workspace.snapshot(ws)    # opaque value, persistable
ws                  = Exgit.Workspace.restore(ws, saved)

{:ok, sha, ws} =
  Exgit.Workspace.commit(ws,
    message: "agent: refactor",
    author: %{name: "agent", email: "agent@example.com"},
    update_ref: "refs/heads/agent-turn-1")

{:ok, _} = Exgit.push(ws.repo, transport, refspecs: ["refs/heads/agent-turn-1"])

Mounted via :vfs:

fs =
  VFS.new()
  |> VFS.mount("/repo", Exgit.Workspace.open(repo, "main"))
  |> VFS.mount("/scratch", VFS.Memory.new())

{:ok, content, fs} = VFS.read_file(fs, "/repo/lib/foo.ex")
{:ok, fs}          = VFS.write_file(fs, "/repo/lib/foo.ex", new_source)

API

Op Shape
open(repo, ref \\ "HEAD") t()
read(ws, path) {:ok, binary, ws} | {:error, _}
write(ws, path, content) {:ok, ws} | {:error, :eisdir | _}
rm(ws, path, opts) {:ok, ws} | {:error, :enoent | :eisdir | _}
ls(ws, path) {:ok, [name], ws} | {:error, _}
stat(ws, path) {:ok, %{type:, mode:, size:}, ws} | {:error, _}
exists?(ws, path) {boolean, ws}
walk(ws) Enumerable.t() (requires :eager repo)
snapshot(ws) :pristine | <<20-byte sha>>
restore(ws, snapshot) t()
diff(ws) {:ok, [{:added | :modified | :deleted, path}], ws}
commit(ws, opts) {:ok, sha, ws} | {:error, :nothing_to_commit | _}
checkout(ws, ref) {:ok, ws} (drops uncommitted writes)
materialize(ws) {:ok, ws} | {:error, _}

VFS integration

Exgit.Workspace ships defimpl VFS.Mountable (file-guarded by Code.ensure_loaded?(VFS.Mountable) so the workspace remains usable without :vfs). Capabilities: [:read, :write, :lazy]. We deliberately do not claim :mkdir — git trees can't represent empty directories, so a faithful mkdir/3 has no honest semantics. write_file implicitly creates parents.

The full vfs VFS.ConformanceCase (34 tests + 3 properties) runs against this backend in test/exgit/workspace_vfs_test.exs. Tagged :vfs so the Elixir 1.17 CI tier (where :vfs requires ~> 1.18) skips it cleanly.

What's not in scope (intentionally)

  • No :mkdir — empty dirs aren't a thing in git; would be a footgun.
  • No auto-commit — the agent decides when to checkpoint.
  • No staging area / index — the workspace IS the working tree; commit is everything-or-nothing.
  • No multi-parent merge — single parent. Future concern.
  • No push/pullExgit.push/3 against the underlying repo handles that.

Internals

  • Exgit.FS.rm_path/4 — new public helper, mirrors write_path/5's tree-rewrite shape ({:ok, new_tree_sha, repo}). Used by the workspace; useful on its own.
  • Workspace diff/1 reuses existing Exgit.Diff.trees/4.
  • :vfs is added as optional: true (no env restriction) so downstream consumer builds get correct compile-ordering: when consumers declare :vfs in their own deps, Mix puts it ahead of :exgit, our defimpl compiles in, and protocol consolidation picks it up. Scoping only: [:dev, :test] would have removed :vfs from our :prod dep graph and broken that ordering — caught during the e2e smoke.
  • VFS.ConformanceCase lives in vfs's test/support/; we load it via Code.require_file/1 in test_helper.exs until vfs publishes it in lib/.

Verified end-to-end

Authenticated lazy clone → workspace writes → diff → commit (with update_ref:) → Exgit.push → re-clone → byte-for-byte read of the pushed file matches the locally-committed sha. Branch left at https://github.com/ivarvong/exgit_smoketest/tree/exgit-workspace-smoke-1777817735805-ca3c5940 (commit 30839de).

Test plan

  • mix format clean
  • mix deps.unlock --check-unused clean
  • mix compile --warnings-as-errors (dev + test)
  • MIX_ENV=dev mix credo --strict clean
  • MIX_ENV=dev mix dialyzer clean (0 warnings)
  • mix test --warnings-as-errors — 698 tests, 0 failures
  • mix test --warnings-as-errors --include slow --include real_git — 766 tests, 0 failures
  • e2e against real GitHub repo with PAT auth + push verified

🤖 Generated with Claude Code

ivarvong and others added 2 commits May 3, 2026 10:17
Adds `Exgit.Workspace`, an agent-loop working tree on top of any git ref.
Every state is a real git tree-SHA: snapshots are 20-byte values you can
persist and replay; commits are O(1) hash-and-store; branching the
workspace for parallel exploration is `ws_b = ws_a` (the struct is a
value). Reads pass through to base_ref until a write happens; writes
update the head tree-SHA.

Capability surface:

  * read/write/rm/ls/stat/walk against the working state
  * snapshot/1 + restore/2 for cheap save-and-restore
  * diff/1 against base_ref → [{:added | :modified | :deleted, path}]
  * commit/2 with optional update_ref to push the working tree onto a
    real ref atomically
  * checkout/2 to switch base_ref (drops uncommitted writes)
  * materialize/1 for lazy partial-clone repos

Ships an `Exgit.Workspace.VFS` defimpl (capabilities `[:read, :write,
:lazy]`, conditional on `Code.ensure_loaded?(VFS.Mountable)`) so a
workspace mounts into a `:vfs` mount table alongside other backends
(in-memory scratch, postgres, S3) under one tree. State threads through
every protocol op — cache growth and head_tree advancement both
visible to subsequent calls.

Internals:

  * Adds `Exgit.FS.rm_path/4` (mirrors `write_path/5`) — needed for
    rm-through-workspace and pretty handy on its own.
  * Reuses existing `Exgit.Diff.trees/4` for the diff op.
  * `:vfs` is `optional: true` (no env restriction) so downstream
    consumer builds get correct compile-ordering: declaring `:vfs`
    in their deps puts it ahead of exgit, our defimpl compiles in,
    and protocol consolidation picks it up.

Verified against `https://github.com/ivarvong/exgit_smoketest`:
authenticated lazy clone → workspace.write → workspace.diff →
workspace.commit (with update_ref) → Exgit.push → re-clone → byte-for-
byte read of the pushed file. Branch left for inspection at
`exgit-workspace-smoke-1777817735805-ca3c5940`.

Tests: 25 workspace API tests, 7 rm_path/4 tests, 34 VFS conformance
tests + 3 properties (run via VFS.ConformanceCase). Full CI gates
(format, compile --warnings-as-errors, credo --strict, dialyzer, test,
test --include slow --include real_git) all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The multi-line :vfs dep tuple short-form fits on one line under 1.19's
formatter; CI on 1.19 caught this. Local 1.19.5 likely passed because
of a subtle pre-1.19.x formatter difference. Single line now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ivarvong ivarvong merged commit 2c6a9cd into main May 5, 2026
2 checks passed
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