An agent-loop working tree on top of a git ref.
A workspace pairs (repository, base_ref, head_tree):
:base_ref— the starting point ("HEAD", a branch, a commit SHA).:head_tree— the current working tree's SHA.nilwhen the workspace is pristine; reads in that state pass through tobase_ref. Set to a 20-byte tree SHA after the first write.
Every state of the workspace is a real git tree object. Snapshots
are 20-byte SHAs you can persist and replay; commits are an O(1)
hash-and-store on top of the head tree; branching the workspace
for parallel exploration is ws_b = ws_a — the struct is a value,
no copy needed.
Lifecycle
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, [{:modified, "lib/foo.ex"}, {:deleted, "lib/old.ex"}], ws}
= Exgit.Workspace.diff(ws)
{:ok, commit_sha, ws} =
Exgit.Workspace.commit(ws,
message: "agent: refactor",
author: %{name: "agent", email: "agent@example.com"},
update_ref: "refs/heads/agent-turn-1")Snapshot / restore
saved = Exgit.Workspace.snapshot(ws) # :pristine | <<20-byte sha>>
ws = Exgit.Workspace.restore(ws, saved)Snapshots are opaque values you can stash anywhere — a database, another conversation, a Linear comment. To replay an agent's run end-to-end, restore from the saved value.
Branching
Pass the same workspace to two parallel computations; each gets its own threaded state.
ws_a = ws
ws_b = ws
{:ok, ws_a} = Exgit.Workspace.write(ws_a, "lib/x.ex", "...")
{:ok, ws_b} = Exgit.Workspace.write(ws_b, "lib/x.ex", "different")ws_a and ws_b now diverge. The underlying object store is shared
(each write puts new blobs/trees) but neither workspace's head_tree
references the other's writes.
VFS integration
When :vfs is loaded, Exgit.Workspace implements VFS.Mountable
and can be mounted into a %VFS{} mount table. See
Exgit.Workspace.VFS.
Summary
Types
Identity for commit/2. Either a pre-formatted git identity string
("Name <email> ts +tz") used verbatim, or a %{name:, email:} map
which is rendered with the current timestamp at UTC.
Opaque snapshot value. Either the sentinel :pristine or a 20-byte
tree SHA.
Functions
Switch the workspace's base_ref. Discards any uncommitted writes
(head_tree is reset to nil).
Materialize the working tree as a commit object.
Compare the workspace's working state against base_ref, returning
a list of {:added | :modified | :deleted, path} entries.
Whether path exists in the current working state.
List names of entries directly under path. Sorted lexicographically.
Convert a lazy partial-clone repo to eager mode by prefetching every
object reachable from the workspace's effective ref. After this,
streaming ops (walk/1, Exgit.FS.grep/4) work without per-blob
network round-trips.
Open a workspace over repo rooted at ref (default "HEAD").
Read the file at path. Returns the blob bytes plus the threaded
workspace.
Replace the workspace's head tree with a previously-captured snapshot.
Remove the entry at path.
Capture the workspace's working state as an opaque snapshot value.
Stat the entry at path. Returns %{type: :blob | :tree | :submodule, mode:, size:}.
Stream every blob path under the workspace's working state. Like
Exgit.FS.walk/2, requires the underlying repo to be :eager —
call materialize/1 first on lazy partial-clone repos.
Write content to path. Creates intermediate directories
implicitly. Refuses to overwrite a directory.
Types
@type change() :: {:added | :modified | :deleted, String.t()}
An entry returned by diff/1. Path is relative to the repo root.
Identity for commit/2. Either a pre-formatted git identity string
("Name <email> ts +tz") used verbatim, or a %{name:, email:} map
which is rendered with the current timestamp at UTC.
@type snapshot() :: :pristine | binary()
Opaque snapshot value. Either the sentinel :pristine or a 20-byte
tree SHA.
@type t() :: %Exgit.Workspace{ base_ref: String.t() | binary(), head_tree: binary() | nil, repo: Exgit.Repository.t() }
Functions
Switch the workspace's base_ref. Discards any uncommitted writes
(head_tree is reset to nil).
Materialize the working tree as a commit object.
Required options:
:message— commit message (a binary, with or without trailing newline).:author—identity/0.
Optional:
:committer— defaults to:author.:update_ref—false(default) leaves refs untouched; the caller takes the returned commit SHA. A binary like"refs/heads/agent-turn-1"writes that ref to point at the new commit and tracks it as the workspace's newbase_ref. The ref may already exist; it is overwritten.
After commit, head_tree is cleared and base_ref advances to
identify where the new commit lives:
update_ref: false→base_refbecomes the commit SHA (binary).update_ref: "refs/heads/foo"→base_refbecomes that string.
Returns {:error, :nothing_to_commit} if the workspace is pristine.
Compare the workspace's working state against base_ref, returning
a list of {:added | :modified | :deleted, path} entries.
A pristine workspace returns {:ok, [], ws} immediately.
Whether path exists in the current working state.
List names of entries directly under path. Sorted lexicographically.
Convert a lazy partial-clone repo to eager mode by prefetching every
object reachable from the workspace's effective ref. After this,
streaming ops (walk/1, Exgit.FS.grep/4) work without per-blob
network round-trips.
No-op for already-eager repos.
@spec open(Exgit.Repository.t(), String.t()) :: t()
Open a workspace over repo rooted at ref (default "HEAD").
The workspace starts pristine — head_tree is nil, reads go
straight to ref.
Read the file at path. Returns the blob bytes plus the threaded
workspace.
Replace the workspace's head tree with a previously-captured snapshot.
The snapshot's referenced objects must already be in the underlying repo's object store — this is the case when the snapshot was produced by a workspace sharing the same store.
Remove the entry at path.
Options:
:recursive— whentrue, removing a directory removes its contents. Defaultfalse; without it, directory removal returns{:error, :eisdir}.
Capture the workspace's working state as an opaque snapshot value.
Returns :pristine for a workspace with no writes, otherwise the
20-byte tree SHA. Persistable, transferable, and replayable via
restore/2.
@spec stat(t(), String.t()) :: {:ok, Exgit.FS.stat(), t()} | {:error, term()}
Stat the entry at path. Returns %{type: :blob | :tree | :submodule, mode:, size:}.
@spec walk(t()) :: Enumerable.t()
Stream every blob path under the workspace's working state. Like
Exgit.FS.walk/2, requires the underlying repo to be :eager —
call materialize/1 first on lazy partial-clone repos.
Write content to path. Creates intermediate directories
implicitly. Refuses to overwrite a directory.