Exgit.Workspace (exgit v0.1.0)

Copy Markdown View Source

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. nil when the workspace is pristine; reads in that state pass through to base_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

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.

Opaque snapshot value. Either the sentinel :pristine or a 20-byte tree SHA.

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.

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

change()

@type change() :: {:added | :modified | :deleted, String.t()}

An entry returned by diff/1. Path is relative to the repo root.

identity()

@type identity() :: String.t() | %{name: String.t(), email: String.t()}

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.

snapshot()

@type snapshot() :: :pristine | binary()

Opaque snapshot value. Either the sentinel :pristine or a 20-byte tree SHA.

t()

@type t() :: %Exgit.Workspace{
  base_ref: String.t() | binary(),
  head_tree: binary() | nil,
  repo: Exgit.Repository.t()
}

Functions

checkout(ws, ref)

@spec checkout(t(), String.t()) :: {:ok, t()}

Switch the workspace's base_ref. Discards any uncommitted writes (head_tree is reset to nil).

commit(ws, opts)

@spec commit(
  t(),
  keyword()
) :: {:ok, binary(), t()} | {:error, term()}

Materialize the working tree as a commit object.

Required options:

  • :message — commit message (a binary, with or without trailing newline).
  • :authoridentity/0.

Optional:

  • :committer — defaults to :author.
  • :update_reffalse (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 new base_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: falsebase_ref becomes the commit SHA (binary).
  • update_ref: "refs/heads/foo"base_ref becomes that string.

Returns {:error, :nothing_to_commit} if the workspace is pristine.

diff(ws)

@spec diff(t()) :: {:ok, [change()], t()} | {:error, term()}

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.

exists?(ws, path)

@spec exists?(t(), String.t()) :: {boolean(), t()}

Whether path exists in the current working state.

ls(ws, path)

@spec ls(t(), String.t()) :: {:ok, [String.t()], t()} | {:error, term()}

List names of entries directly under path. Sorted lexicographically.

materialize(ws)

@spec materialize(t()) :: {:ok, t()} | {:error, term()}

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.

open(repo, ref \\ "HEAD")

@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(ws, path)

@spec read(t(), String.t()) :: {:ok, binary(), t()} | {:error, term()}

Read the file at path. Returns the blob bytes plus the threaded workspace.

restore(ws, tree)

@spec restore(t(), snapshot()) :: t()

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.

rm(ws, path, opts \\ [])

@spec rm(t(), String.t(), keyword()) :: {:ok, t()} | {:error, term()}

Remove the entry at path.

Options:

  • :recursive — when true, removing a directory removes its contents. Default false; without it, directory removal returns {:error, :eisdir}.

snapshot(workspace)

@spec snapshot(t()) :: snapshot()

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.

stat(ws, path)

@spec stat(t(), String.t()) :: {:ok, Exgit.FS.stat(), t()} | {:error, term()}

Stat the entry at path. Returns %{type: :blob | :tree | :submodule, mode:, size:}.

walk(ws)

@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(ws, path, content)

@spec write(t(), String.t(), binary()) :: {:ok, t()} | {:error, term()}

Write content to path. Creates intermediate directories implicitly. Refuses to overwrite a directory.