Exgit.Workspace: writable working tree + VFS backend for agent loops#2
Merged
Conversation
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>
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
:vfsintegration so it composes alongside other filesystems (scratch, postgres, S3) under one mount table.The contract:
ws_b = ws_a(the struct is a value); commit is O(1) hash-and-store on top of the existing tree.head_treeadvancement from writes are both visible to subsequent calls.Workspace.commit/2materializes a commit;update_ref:advances a real branch atomically.Exgit.pushaway from a real remote. Verified end-to-end against a real GitHub repo.Mounted via
:vfs:API
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:eagerrepo)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.Workspaceshipsdefimpl VFS.Mountable(file-guarded byCode.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 faithfulmkdir/3has no honest semantics.write_fileimplicitly creates parents.The full vfs
VFS.ConformanceCase(34 tests + 3 properties) runs against this backend intest/exgit/workspace_vfs_test.exs. Tagged:vfsso the Elixir 1.17 CI tier (where:vfsrequires~> 1.18) skips it cleanly.What's not in scope (intentionally)
:mkdir— empty dirs aren't a thing in git; would be a footgun.Exgit.push/3against the underlying repo handles that.Internals
Exgit.FS.rm_path/4— new public helper, mirrorswrite_path/5's tree-rewrite shape ({:ok, new_tree_sha, repo}). Used by the workspace; useful on its own.diff/1reuses existingExgit.Diff.trees/4.:vfsis added asoptional: true(no env restriction) so downstream consumer builds get correct compile-ordering: when consumers declare:vfsin their own deps, Mix puts it ahead of:exgit, our defimpl compiles in, and protocol consolidation picks it up. Scopingonly: [:dev, :test]would have removed:vfsfrom our:proddep graph and broken that ordering — caught during the e2e smoke.VFS.ConformanceCaselives in vfs'stest/support/; we load it viaCode.require_file/1intest_helper.exsuntil vfs publishes it inlib/.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 athttps://github.com/ivarvong/exgit_smoketest/tree/exgit-workspace-smoke-1777817735805-ca3c5940(commit30839de).Test plan
mix formatcleanmix deps.unlock --check-unusedcleanmix compile --warnings-as-errors(dev + test)MIX_ENV=dev mix credo --strictcleanMIX_ENV=dev mix dialyzerclean (0 warnings)mix test --warnings-as-errors— 698 tests, 0 failuresmix test --warnings-as-errors --include slow --include real_git— 766 tests, 0 failures🤖 Generated with Claude Code