Skip to content

Workspace Tier 1: merge, move, revert, content diffs#3

Merged
ivarvong merged 1 commit into
exgit-workspacefrom
workspace-tier-1
May 5, 2026
Merged

Workspace Tier 1: merge, move, revert, content diffs#3
ivarvong merged 1 commit into
exgit-workspacefrom
workspace-tier-1

Conversation

@ivarvong
Copy link
Copy Markdown
Owner

@ivarvong ivarvong commented May 3, 2026

What this enables

The agent ops Tier 1 from the capability map — the things an SWE agent is genuinely worse without. Built on top of Exgit.Workspace from #2.

Capability Method What it lets the agent do
Three-way merge Workspace.merge(target, source, opts) Reconverge two divergent workspaces; pull upstream into a working tree; sub-agent → parent rollup
Atomic rename Workspace.move(ws, from, to) Rename without losing mode and without two intermediate trees in head_tree history
Selective revert Workspace.revert(ws, path) Drop edits to one path while keeping others
Content diffs Workspace.diff(ws, content: true) See what changed (before/after blob bytes), not just which paths
Diff against snapshot Workspace.diff(ws, against: snapshot) Compare to a checkpoint, not just base_ref
Materialize-then-walk Workspace.materialized_walk(ws) Walk a lazy repo's working tree in one call, with state threaded back

Merge: agent-shaped, path-level three-way

Real git merge does text-level merge with conflict markers in files. For agents that's the wrong shape — agents rewrite files, they don't patch hunks; markers in a file just become a parse-and-rewrite step. The agent-shaped version is path-level with structured conflicts:

  • Non-overlapping changes always merge cleanly.
  • Conflicts come back as [{:both_modified | :both_added | :modify_delete, path}] — the agent re-reads both versions and writes a fresh resolution.
  • :strategy: :ours | :theirs for auto-resolution when conflict semantics are clear-cut.
ws_a = ws  # forked
ws_b = ws

{:ok, ws_a} = Workspace.write(ws_a, "lib/foo.ex", "approach a")
{:ok, ws_b} = Workspace.write(ws_b, "lib/bar.ex", "approach b")

case Workspace.merge(ws_a, ws_b) do
  {:ok, ws_merged}                -> commit_or_continue(ws_merged)
  {:conflict, conflicts, ^ws_a}   -> agent_resolves(conflicts)
end

Cross-store object import. Each %Workspace{} carries an independent object_store. When two forks each write, ws_a's objects aren't in ws_b's store and vice versa. Naïve merge-by-snapshot fails with :not_found. So merge/3 accepts a workspace and copies objects reachable from the source's working tree into the target's repo before merging — agent-loop forks reconverge cleanly without manual plumbing.

The signature still accepts a tree-sha or ref string for the case where the source is known to be in the target's repo (e.g. merging from a local branch). Two clauses, one mental model.

What's in the FS layer vs the workspace layer

Exgit.FS.merge_trees/5 is the pure tree-merge primitive — (repo, base, ours, theirs, opts) → {:ok, merged_tree, conflicts, repo}. No workspace state. Used by Workspace.merge but composable on its own.

Workspace.merge is the workspace-aware wrapper: handles object import for cross-fork merges, resolves the merge base from base_ref, threads head_tree.

Test coverage

  • 10 new merge_trees/5 tests in fs_test.exs: clean merges, every conflict variant, both-sides-add-same-content (no conflict), both-sides-delete-same-path (no conflict), strategy: :abort/:ours/:theirs, mixed clean+conflict scenarios.
  • 21 new workspace tests in workspace_test.exs: move/revert/merge/diff variants, materialized_walk, plus a regression test for the cross-store import path.

Full suite: 729 tests, 0 failures. Extended tier (--include slow --include real_git): 797 tests, 0 failures.

Test plan

  • mix format --check-formatted
  • mix compile --warnings-as-errors
  • MIX_ENV=dev mix credo --strict
  • MIX_ENV=dev mix dialyzer — 0 warnings
  • mix test --warnings-as-errors
  • mix test --warnings-as-errors --include slow --include real_git

Out of scope (deferred to Tier 2)

  • Workspace.update/2 (pull-and-merge upstream)
  • Workspace.cherry_pick/2
  • Workspace.apply_patch/2 (unified-diff patch application)
  • Branch/ref convenience ops (list/create/delete branches via workspace)

These are the next chunk; Tier 1 was scoped to "things needed for the basic edit-and-coordinate loop."

Branch base

Targets exgit-workspace (PR #2) since it builds directly on the workspace primitives. Once #2 merges to main, GitHub auto-rebases this onto main.

🤖 Generated with Claude Code

…ized_walk

Tier 1 from the SWE-agent capability map: the ops an agent is
genuinely worse without. Building on Exgit.Workspace.

Adds:

  * `Exgit.FS.merge_trees/5` — pure path-level three-way tree merge
    primitive, returning {:ok, merged_tree, conflicts, repo}.
    Strategies: :abort (default) | :ours | :theirs. Conflict types:
    :both_modified, :both_added, :modify_delete. Used by Workspace.merge
    but composable on its own for callers building higher-level merge
    machinery.

  * `Workspace.merge/3` — agent-shaped path-level merge. Accepts
    another %Workspace{} (typical), a tree/commit SHA, a ref name, or
    :pristine. For workspace sources, objects reachable from the
    source's working tree are imported into the target's repo before
    merging — so two workspaces forked from a common ancestor can
    re-converge even though each has accumulated its own object-store
    state. Returns {:ok, ws} | {:conflict, conflicts, ws} | {:error, _}.

  * `Workspace.move/3` — rename a file, preserving mode. Atomic from
    the caller's view (one return, one observable head_tree advance).
    Refuses to move directories in v1; refuses to overwrite an
    existing dir.

  * `Workspace.revert/2` — undo edits to a single path. Restores from
    base_ref content if it exists there; rms from head_tree if the
    agent had added the path. No-op on pristine.

  * `Workspace.diff/2` (replaces diff/1, which delegates) — opts:
    - `:against` — compare against a snapshot/ref/sha rather than
      base_ref. Critical for "what's changed since my checkpoint."
    - `:content` — return rich entries with before/after blob bytes
      instead of just paths.

  * `Workspace.materialized_walk/1` — convenience: materialize a lazy
    repo and return the walk stream in one call, threading the
    materialized workspace back so cache growth is captured.

Tests: 10 new merge_trees tests, 21 new workspace tests (move/revert/
diff opts/merge/materialized_walk). Full suite 729 tests / 0 failures;
extended tier 797 tests / 0 failures. Format, credo --strict, dialyzer
all green.

Builds on PR #2 (Exgit.Workspace base). Once #2 merges, this rebases
to main cleanly.

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