diff --git a/README.md b/README.md index e61ac15..82d7f96 100644 --- a/README.md +++ b/README.md @@ -175,26 +175,62 @@ commits are an O(1) hash-and-store. 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, ws} = Exgit.Workspace.move(ws, "lib/old.ex", "lib/legacy.ex") +{:ok, ws} = Exgit.Workspace.rm(ws, "lib/dead.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) + +# Diff: paths only (default) or rich entries with content +{:ok, [{:modified, "lib/foo.ex"}, ...], ws} = Exgit.Workspace.diff(ws) +{:ok, [%{op: :modified, path: "lib/foo.ex", before: <<...>>, after: <<...>>}], ws} + = Exgit.Workspace.diff(ws, content: true) + +# Diff against a saved checkpoint, not just base_ref +checkpoint = Exgit.Workspace.snapshot(ws) +{:ok, ws} = Exgit.Workspace.write(ws, "lib/foo.ex", another_revision) +{:ok, [{:modified, "lib/foo.ex"}], ws} = + Exgit.Workspace.diff(ws, against: checkpoint) + +# Undo edits to a single file (revert to base content) +{:ok, ws} = Exgit.Workspace.revert(ws, "lib/foo.ex") {: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 is an opaque value — persist it and replay later -saved = Exgit.Workspace.snapshot(ws) -ws = Exgit.Workspace.restore(ws, saved) ``` The struct is a plain value, so branching the agent's state for parallel exploration is just `ws_b = ws_a` — both diverge -independently from there. +independently from there. Reconverging is `merge/3`: + +```elixir +ws_a = ws +ws_b = ws + +{:ok, ws_a} = Exgit.Workspace.write(ws_a, "lib/foo.ex", "approach a") +{:ok, ws_b} = Exgit.Workspace.write(ws_b, "lib/bar.ex", "approach b") + +# Path-level three-way merge. Non-overlapping changes apply cleanly. +case Exgit.Workspace.merge(ws_a, ws_b) do + {:ok, ws_merged} -> + Exgit.Workspace.commit(ws_merged, message: "combined", author: ...) + + {:conflict, conflicts, ^ws_a} -> + # Both wrote the same path differently. Re-read both versions + # and write a fresh resolution — OR pass strategy: :ours / :theirs. + handle(conflicts) +end +``` + +For lazy partial-clone repos, walking the working tree needs +`materialize` first; `materialized_walk/1` does both in one call. + +```elixir +{:ok, stream, ws} = Exgit.Workspace.materialized_walk(ws) +stream |> Stream.take(10) |> Enum.to_list() +``` ### Mounting through `:vfs` diff --git a/lib/exgit/fs.ex b/lib/exgit/fs.ex index ca0d2a1..fba3885 100644 --- a/lib/exgit/fs.ex +++ b/lib/exgit/fs.ex @@ -1319,6 +1319,206 @@ defmodule Exgit.FS do end end + @doc """ + Three-way path-level merge of two trees against a common base. + + Returns `{:ok, merged_tree_sha, conflicts, repo}`: + + * Non-conflicting changes from `ours` and `theirs` are both applied + to `base` to produce `merged_tree_sha`. + * `conflicts` is the list of paths where both sides modified the + same file in incompatible ways. + + ## Conflict types + + * `{:both_modified, path}` — both sides changed an existing file's + content (or mode) to different values. + * `{:both_added, path}` — both sides added a path that didn't exist + in `base`, with different blob SHAs. + * `{:modify_delete, path}` — one side modified, the other deleted. + + Mode-only changes, type changes (blob ↔ tree), and submodule changes + are treated as modifications for conflict purposes in v1. + + ## Strategies (`:strategy` opt) + + * `:abort` (default) — if any conflict, return `merged_tree_sha == base` + (no changes applied) along with the conflict list. Callers can + treat this as "abort the merge." + * `:ours` — resolve every conflict by keeping `ours`'s version. + * `:theirs` — resolve every conflict by keeping `theirs`'s version. + + Path-level only — does NOT do text-level conflict resolution. For + agent loops this is the right shape: agents rewrite files, they + don't patch hunks; structured `:both_modified` lets the agent + re-read both versions and write a fresh resolution. + """ + @spec merge_trees(Repository.t(), binary(), binary(), binary(), keyword()) :: + {:ok, binary(), [merge_conflict()], Repository.t()} | {:error, term()} + def merge_trees(%Repository{} = repo, base_sha, ours_sha, theirs_sha, opts \\ []) do + strategy = Keyword.get(opts, :strategy, :abort) + + with {:ok, ours_changes} <- Exgit.Diff.trees(repo, base_sha, ours_sha), + {:ok, theirs_changes} <- Exgit.Diff.trees(repo, base_sha, theirs_sha) do + ours_by_path = Map.new(ours_changes, fn c -> {c.path, c} end) + theirs_by_path = Map.new(theirs_changes, fn c -> {c.path, c} end) + + paths = + ours_by_path + |> Map.keys() + |> Kernel.++(Map.keys(theirs_by_path)) + |> Enum.uniq() + |> Enum.sort() + + {clean_ops, conflicts} = classify_merge_paths(paths, ours_by_path, theirs_by_path) + + case apply_strategy(strategy, clean_ops, conflicts, ours_by_path, theirs_by_path) do + {:apply, ops} -> + case apply_merge_ops(repo, base_sha, ops) do + {:ok, merged, repo} -> {:ok, merged, conflicts, repo} + {:error, _} = err -> err + end + + :abort -> + {:ok, base_sha, conflicts, repo} + end + end + end + + @typedoc """ + A merge conflict reported by `merge_trees/5`. `path` is the file + path within the merged tree. + """ + @type merge_conflict :: + {:both_modified, String.t()} + | {:both_added, String.t()} + | {:modify_delete, String.t()} + + defp classify_merge_paths(paths, ours, theirs) do + Enum.reduce(paths, {[], []}, fn path, {ops, conflicts} -> + o = Map.get(ours, path) + t = Map.get(theirs, path) + + case classify_pair(path, o, t) do + :no_op -> {ops, conflicts} + {:op, op} -> {[op | ops], conflicts} + {:conflict, c} -> {ops, [c | conflicts]} + end + end) + |> then(fn {ops, conflicts} -> {Enum.reverse(ops), Enum.reverse(conflicts)} end) + end + + # Side-only changes (other side untouched): apply. + defp classify_pair(path, %{op: :added, new_mode: m, new_sha: s}, nil), + do: {:op, {:put, path, m, s}} + + defp classify_pair(path, nil, %{op: :added, new_mode: m, new_sha: s}), + do: {:op, {:put, path, m, s}} + + defp classify_pair(path, %{op: :modified, new_mode: m, new_sha: s}, nil), + do: {:op, {:put, path, m, s}} + + defp classify_pair(path, nil, %{op: :modified, new_mode: m, new_sha: s}), + do: {:op, {:put, path, m, s}} + + defp classify_pair(path, %{op: :removed}, nil), + do: {:op, {:rm, path}} + + defp classify_pair(path, nil, %{op: :removed}), + do: {:op, {:rm, path}} + + # Both-side changes. + defp classify_pair( + path, + %{op: o_op, new_sha: o_sha, new_mode: o_mode}, + %{op: t_op, new_sha: t_sha, new_mode: t_mode} + ) + when o_op in [:added, :modified] and t_op in [:added, :modified] do + cond do + # Same content + same mode: both sides agree → apply once. + o_sha == t_sha and o_mode == t_mode -> {:op, {:put, path, o_mode, o_sha}} + o_op == :added and t_op == :added -> {:conflict, {:both_added, path}} + true -> {:conflict, {:both_modified, path}} + end + end + + # Both deleted: not a no-op against base — base still has the entry. + # Apply the rm. + defp classify_pair(path, %{op: :removed}, %{op: :removed}), do: {:op, {:rm, path}} + + defp classify_pair(path, %{op: :removed}, %{op: t_op}) + when t_op in [:added, :modified, :mode_changed, :type_changed, :submodule_change], + do: {:conflict, {:modify_delete, path}} + + defp classify_pair(path, %{op: o_op}, %{op: :removed}) + when o_op in [:added, :modified, :mode_changed, :type_changed, :submodule_change], + do: {:conflict, {:modify_delete, path}} + + # Mode/type/submodule changes lump in with :modified semantics. + defp classify_pair(path, %{new_mode: m, new_sha: s}, nil), + do: {:op, {:put, path, m, s}} + + defp classify_pair(path, nil, %{new_mode: m, new_sha: s}), + do: {:op, {:put, path, m, s}} + + defp classify_pair(path, %{new_sha: o_sha}, %{new_sha: t_sha}) do + if o_sha == t_sha, + do: :no_op, + else: {:conflict, {:both_modified, path}} + end + + defp apply_strategy(:abort, _ops, [_ | _], _, _), do: :abort + + defp apply_strategy(:abort, ops, [], _, _), do: {:apply, ops} + + defp apply_strategy(:ours, ops, conflicts, ours_by_path, _theirs_by_path) do + {:apply, ops ++ Enum.map(conflicts, &resolve_with(&1, ours_by_path))} + end + + defp apply_strategy(:theirs, ops, conflicts, _ours_by_path, theirs_by_path) do + {:apply, ops ++ Enum.map(conflicts, &resolve_with(&1, theirs_by_path))} + end + + defp resolve_with({_kind, path}, by_path) do + case Map.fetch!(by_path, path) do + %{op: :removed} -> {:rm, path} + %{new_mode: m, new_sha: s} -> {:put, path, m, s} + end + end + + defp apply_merge_ops(repo, tree_sha, ops) do + Enum.reduce_while(ops, {:ok, tree_sha, repo}, fn + {:put, path, mode, blob_sha}, {:ok, tree, repo} -> + case write_blob_into_tree_at_path(repo, tree, path, mode, blob_sha) do + {:ok, new_tree, repo} -> {:cont, {:ok, new_tree, repo}} + {:error, _} = err -> {:halt, err} + end + + {:rm, path}, {:ok, tree, repo} -> + case rm_path(repo, tree, path, recursive: true) do + {:ok, new_tree, repo} -> {:cont, {:ok, new_tree, repo}} + # If a remove targets a path that already isn't in the merged + # tree (e.g. base→ours diff said removed, base→theirs diff + # also said removed → we already classified that as :no_op + # so this shouldn't fire, but defensive), it's a no-op. + {:error, :not_found} -> {:cont, {:ok, tree, repo}} + {:error, _} = err -> {:halt, err} + end + end) + end + + # Internal helper used by merge_trees. Inserts an existing blob_sha + # into a tree at a given path with a given mode, without round- + # tripping through `Blob.new` + `ObjectStore.put` (the blob is + # already in the store; we only need to rewrite the parent trees). + # Reuses the same `insert_blob_into_tree` recursion as `write_path/5`. + defp write_blob_into_tree_at_path(repo, tree_sha, path, mode, blob_sha) do + case normalize_path(path) do + [] -> {:error, :cannot_write_root} + segments -> insert_blob_into_tree(repo, tree_sha, segments, mode, blob_sha) + end + end + # ---------------------------------------------------------------------- # Internal: object fetch that threads the repo for Promisor-backed stores # ---------------------------------------------------------------------- diff --git a/lib/exgit/workspace.ex b/lib/exgit/workspace.ex index 74bfb3c..9d409ac 100644 --- a/lib/exgit/workspace.ex +++ b/lib/exgit/workspace.ex @@ -237,6 +237,248 @@ defmodule Exgit.Workspace do %{ws | head_tree: tree} end + # ────────────────────────────────────────────────────────────────── + # Move / revert + # ────────────────────────────────────────────────────────────────── + + @doc """ + Move (rename) `from` to `to`. Preserves the file mode. + + Refuses to move directories in v1 — `from` must be a file. Refuses + to overwrite an existing directory at `to`. + """ + @spec move(t(), String.t(), String.t()) :: {:ok, t()} | {:error, term()} + def move(%__MODULE__{} = ws, from, to) when is_binary(from) and is_binary(to) do + case FS.read_path(ws.repo, effective_ref(ws), from) do + {:ok, {mode, %Blob{data: data}}, repo} -> + ws = %{ws | repo: repo} + + with :ok <- guard_not_directory(ws, to), + {:ok, ws} <- do_write(ws, to, data, mode: mode) do + rm(ws, from) + end + + {:error, :not_a_blob} -> + {:error, :cannot_move_directory} + + {:error, _} = err -> + err + end + end + + @doc """ + Revert edits to `path` — restore it to its content in `base_ref`. + + Three cases: + + * Path exists in base and head differs → write base's content back. + * Path doesn't exist in base, exists in head (agent added it) → rm + from head. + * Path doesn't exist in either → no-op. + + A pristine workspace is a no-op (nothing to revert). + """ + @spec revert(t(), String.t()) :: {:ok, t()} | {:error, term()} + def revert(%__MODULE__{head_tree: nil} = ws, _path), do: {:ok, ws} + + def revert(%__MODULE__{} = ws, path) do + case FS.read_path(ws.repo, ws.base_ref, path) do + {:ok, {mode, %Blob{data: data}}, repo} -> + ws = %{ws | repo: repo} + do_write(ws, path, data, mode: mode) + + {:error, :not_found} -> + case FS.rm_path(ws.repo, effective_ref(ws), path) do + {:ok, new_tree, repo} -> + {:ok, %{ws | repo: repo, head_tree: new_tree}} + + {:error, :not_found} -> + {:ok, ws} + + {:error, _} = err -> + err + end + + {:error, _} = err -> + err + end + end + + defp do_write(%__MODULE__{} = ws, path, content, opts) do + case FS.write_path(ws.repo, effective_ref(ws), path, content, opts) do + {:ok, new_tree, repo} -> {:ok, %{ws | repo: repo, head_tree: new_tree}} + {:error, _} = err -> err + end + end + + # ────────────────────────────────────────────────────────────────── + # Merge + # ────────────────────────────────────────────────────────────────── + + @typedoc """ + A conflict reported by `merge/3`. + """ + @type merge_conflict :: FS.merge_conflict() + + @doc """ + Merge another tree-shaped state into this workspace. + + `source` accepts: + + * `:pristine` — no-op (source has no changes). + * another `%Exgit.Workspace{}` — typical agent-loop pattern. + Objects reachable from the source's working tree are imported + into this workspace'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. + * a 20-byte tree or commit SHA — assumed to be resolvable in + this workspace's repo (no auto-import). + * a ref name like `"refs/heads/feature"` — resolves to its tree + in this workspace's repo. + + The merge is **path-level, three-way**: the merge base is the + workspace's `base_ref`'s tree by default. Override with the + `:base` option (snapshot/ref/sha; must be resolvable in this + workspace's repo). + + Strategies (`:strategy` opt): + + * `:abort` (default) — if any conflict, return + `{:conflict, conflicts, ws}` with the workspace's `head_tree` + unchanged. Repo cache may have grown from diff resolution and + object import; that growth threads back. Agents can re-read + both versions and decide. + * `:ours` / `:theirs` — auto-resolve conflicts with the named + side; return `{:ok, ws}` plus the conflict list reported but + already resolved. + + Non-conflicting changes from `source` apply only when there are + no conflicts at all under `:abort` (atomic). + """ + @spec merge(t(), :pristine | t() | String.t() | binary(), keyword()) :: + {:ok, t()} + | {:conflict, [merge_conflict()], t()} + | {:error, term()} + def merge(ws, source, opts \\ []) + + def merge(%__MODULE__{} = ws, :pristine, _opts), do: {:ok, ws} + + def merge(%__MODULE__{} = target, %__MODULE__{} = source, opts) do + source_tree = source.head_tree || nil + + case source_tree do + nil -> + # source is pristine — its working tree IS its base_ref. + # Resolve in the source's repo, then proceed. + with {:ok, src_tree, _} <- resolve_ref_to_tree(source.repo, source.base_ref), + {:ok, target} <- import_objects_from(target, source, src_tree) do + do_merge(target, src_tree, opts) + end + + tree_sha -> + with {:ok, target} <- import_objects_from(target, source, tree_sha) do + do_merge(target, tree_sha, opts) + end + end + end + + def merge(%__MODULE__{} = ws, source, opts) when is_binary(source) do + case resolve_to_tree(ws.repo, source) do + {:ok, source_tree, repo} -> do_merge(%{ws | repo: repo}, source_tree, opts) + {:error, _} = err -> err + end + end + + defp do_merge(%__MODULE__{} = ws, source_tree, opts) do + strategy = Keyword.get(opts, :strategy, :abort) + + with {:ok, base_tree, repo} <- resolve_merge_base(ws.repo, ws, opts), + ours_tree = ws.head_tree || base_tree, + {:ok, merged, conflicts, repo} <- + FS.merge_trees(repo, base_tree, ours_tree, source_tree, strategy: strategy) do + ws = %{ws | repo: repo} + + cond do + conflicts == [] -> + {:ok, advance_head(ws, merged, base_tree)} + + strategy == :abort -> + {:conflict, conflicts, ws} + + true -> + {:ok, advance_head(ws, merged, base_tree)} + end + end + end + + # Copy every object reachable from `source_tree` from `source.repo` + # into `target.repo`. For Memory stores this is cheap; for Promisor + # stores it walks through the resident cache (any non-resident + # objects must already be fetched on the source side, or the merge + # will fail when fetching them through the target's transport). + defp import_objects_from(target, source, source_tree) do + case copy_object(target.repo, source.repo, source_tree) do + {:ok, repo} -> {:ok, %{target | repo: repo}} + {:error, _} = err -> err + end + end + + defp copy_object(target_repo, source_repo, sha) do + if ObjectStore.has?(target_repo.object_store, sha) do + {:ok, target_repo} + else + case ObjectStore.get(source_repo.object_store, sha) do + {:ok, %Exgit.Object.Tree{entries: entries} = tree} -> + {:ok, _new_sha, store} = ObjectStore.put(target_repo.object_store, tree) + target_repo = %{target_repo | object_store: store} + + # Recurse into entries: blobs and subtrees both need copying. + Enum.reduce_while(entries, {:ok, target_repo}, fn {_mode, _name, entry_sha}, + {:ok, repo} -> + case copy_object(repo, source_repo, entry_sha) do + {:ok, repo} -> {:cont, {:ok, repo}} + {:error, _} = err -> {:halt, err} + end + end) + + {:ok, %Exgit.Object.Blob{} = blob} -> + {:ok, _new_sha, store} = ObjectStore.put(target_repo.object_store, blob) + {:ok, %{target_repo | object_store: store}} + + {:ok, other} -> + # Commits / tags from a working-tree merge shouldn't surface, + # but if they do, copy through. + {:ok, _new_sha, store} = ObjectStore.put(target_repo.object_store, other) + {:ok, %{target_repo | object_store: store}} + + {:error, _} = err -> + err + end + end + end + + defp advance_head(ws, merged_tree, base_tree) do + if merged_tree == base_tree, + do: %{ws | head_tree: nil}, + else: %{ws | head_tree: merged_tree} + end + + defp resolve_merge_base(repo, ws, opts) do + case Keyword.fetch(opts, :base) do + {:ok, :pristine} -> resolve_ref_to_tree(repo, ws.base_ref) + {:ok, b} when is_binary(b) -> resolve_to_tree(repo, b) + :error -> resolve_ref_to_tree(repo, ws.base_ref) + end + end + + defp resolve_to_tree(repo, ref_or_sha) when is_binary(ref_or_sha) do + if byte_size(ref_or_sha) == 20 do + resolve_sha_to_tree(repo, ref_or_sha) + else + resolve_ref_to_tree(repo, ref_or_sha) + end + end + # ────────────────────────────────────────────────────────────────── # Diff # ────────────────────────────────────────────────────────────────── @@ -245,18 +487,101 @@ defmodule Exgit.Workspace do 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. + A pristine workspace returns `{:ok, [], ws}` immediately. For richer + output (content of changed paths) or comparison against a different + target, use `diff/2`. """ @spec diff(t()) :: {:ok, [change()], t()} | {:error, term()} - def diff(%__MODULE__{head_tree: nil} = ws), do: {:ok, [], ws} + def diff(%__MODULE__{} = ws), do: diff(ws, []) + + @doc """ + Like `diff/1`, with options: + + * `:against` — compare against this state instead of `base_ref`. + Accepts `:pristine` (alias for `base_ref`), a 20-byte tree/commit + SHA, or a ref name. Useful for `Workspace.diff(ws, against: + saved_snapshot)` to see what's changed since a checkpoint. + + * `:content` (default `false`) — when `true`, return rich entries + `%{op:, path:, before:, after:}` where `:before` and `:after` + are the blob bytes (`nil` for added/deleted respectively, and + `nil` for non-blob operands like type changes). + """ + @spec diff(t(), keyword()) :: + {:ok, [change()] | [content_change()], t()} | {:error, term()} + def diff(%__MODULE__{head_tree: nil} = ws, opts) do + if Keyword.has_key?(opts, :against), + do: do_diff(ws, opts), + else: {:ok, [], ws} + end + + def diff(%__MODULE__{} = ws, opts), do: do_diff(ws, opts) + + defp do_diff(%__MODULE__{} = ws, opts) do + against = Keyword.get(opts, :against, :base_ref) + content? = Keyword.get(opts, :content, false) + + with {:ok, against_tree, repo} <- resolve_diff_target(ws.repo, against, ws.base_ref), + {:ok, current_tree, repo} <- resolve_current_tree(repo, ws), + {:ok, changes} <- Diff.trees(repo, against_tree, current_tree) do + ws = %{ws | repo: repo} + + if content?, + do: enrich_with_content(changes, ws), + else: {:ok, simplify_changes(changes), ws} + end + end + + defp resolve_diff_target(repo, :base_ref, base_ref), do: resolve_ref_to_tree(repo, base_ref) + defp resolve_diff_target(repo, :pristine, base_ref), do: resolve_ref_to_tree(repo, base_ref) + + defp resolve_diff_target(repo, target, _base_ref) when is_binary(target), + do: resolve_to_tree(repo, target) + + defp resolve_current_tree(repo, %__MODULE__{head_tree: nil, base_ref: ref}), + do: resolve_ref_to_tree(repo, ref) + + defp resolve_current_tree(repo, %__MODULE__{head_tree: tree}), do: {:ok, tree, repo} + + defp enrich_with_content(changes, ws) do + enriched = Enum.map(changes, &enrich_change(&1, ws)) + {:ok, enriched, ws} + end - def diff(%__MODULE__{} = ws) do - with {:ok, base_tree, repo} <- resolve_ref_to_tree(ws.repo, ws.base_ref), - {:ok, changes} <- Diff.trees(repo, base_tree, ws.head_tree) do - {:ok, simplify_changes(changes), %{ws | repo: repo}} + defp enrich_change(%{op: op, path: path} = c, ws) when op in [:added, :removed, :modified] do + {before_sha, after_sha} = + case op do + :added -> {nil, c.new_sha} + :removed -> {c.old_sha, nil} + :modified -> {c.old_sha, c.new_sha} + end + + %{ + op: simplify_op(op), + path: path, + before: fetch_blob_bytes(ws, before_sha), + after: fetch_blob_bytes(ws, after_sha) + } + end + + # Mode/type/submodule changes — content semantics ambiguous. + # Surface as :modified with nil content. + defp enrich_change(%{path: path}, _ws), + do: %{op: :modified, path: path, before: nil, after: nil} + + defp fetch_blob_bytes(_ws, nil), do: nil + + defp fetch_blob_bytes(ws, sha) when is_binary(sha) do + case ObjectStore.get(ws.repo.object_store, sha) do + {:ok, %Blob{data: data}} -> data + # Non-blob (tree/etc) — content not meaningful at file level. + _ -> nil end end + defp simplify_op(:removed), do: :deleted + defp simplify_op(other), do: other + defp simplify_changes(changes) do Enum.map(changes, fn %{op: :added, path: p} -> {:added, p} @@ -268,6 +593,44 @@ defmodule Exgit.Workspace do end) end + @typedoc """ + Rich change entry returned by `diff/2` with `content: true`. + `:before` is `nil` for added paths; `:after` is `nil` for deleted + paths; both are `nil` for non-blob operations. + """ + @type content_change :: %{ + op: :added | :modified | :deleted, + path: String.t(), + before: binary() | nil, + after: binary() | nil + } + + # ────────────────────────────────────────────────────────────────── + # Walk convenience + # ────────────────────────────────────────────────────────────────── + + @doc """ + Materialize the workspace and return a walk stream in one call. + + `Workspace.walk/1` requires the underlying repo to be `:eager` — + on a lazy partial-clone repo it raises. This helper materializes + first (one network round-trip prefetching reachable trees and + blobs) and then returns the walk stream, threading the + materialized workspace back so cache growth is captured. + + {:ok, stream, ws} = Exgit.Workspace.materialized_walk(ws) + stream |> Stream.take(10) |> Enum.to_list() + + Idempotent on already-eager repos. + """ + @spec materialized_walk(t()) :: {:ok, Enumerable.t(), t()} | {:error, term()} + def materialized_walk(%__MODULE__{} = ws) do + case materialize(ws) do + {:ok, ws} -> {:ok, walk(ws), ws} + {:error, _} = err -> err + end + end + # ────────────────────────────────────────────────────────────────── # Commit # ────────────────────────────────────────────────────────────────── diff --git a/test/exgit/fs_test.exs b/test/exgit/fs_test.exs index 6063a4f..6d258da 100644 --- a/test/exgit/fs_test.exs +++ b/test/exgit/fs_test.exs @@ -1035,4 +1035,138 @@ defmodule Exgit.FsTest do assert blob.data == "new\n" end end + + describe "merge_trees/5" do + # Build a base, an "ours" with non-overlapping changes, and a + # "theirs" with non-overlapping changes — should merge cleanly. + setup %{repo: repo, shas: shas} do + {:ok, base_tree, _} = FS.read_path(repo, "HEAD", "README.md") + _ = base_tree + + # ours: modifies src/a.ex + {:ok, ours, repo} = FS.write_path(repo, "HEAD", "src/a.ex", "ours version\n") + + # theirs: modifies src/b.ex + {:ok, theirs, repo} = FS.write_path(repo, "HEAD", "src/b.ex", "theirs version\n") + + {:ok, repo: repo, base: shas.root, ours: ours, theirs: theirs} + end + + test "non-overlapping changes merge cleanly", ctx do + assert {:ok, merged, [], repo} = + FS.merge_trees(ctx.repo, ctx.base, ctx.ours, ctx.theirs) + + # Both sides' changes apply + assert {:ok, {_, a}, _} = FS.read_path(repo, merged, "src/a.ex") + assert a.data == "ours version\n" + assert {:ok, {_, b}, _} = FS.read_path(repo, merged, "src/b.ex") + assert b.data == "theirs version\n" + + # Untouched files preserved + assert {:ok, {_, r}, _} = FS.read_path(repo, merged, "README.md") + assert r.data == "hello\n" + end + + test "both sides modify same path → :both_modified conflict", %{repo: repo, base: base} do + {:ok, ours, repo} = FS.write_path(repo, base, "README.md", "ours\n") + {:ok, theirs, repo} = FS.write_path(repo, base, "README.md", "theirs\n") + + assert {:ok, ^base, [{:both_modified, "README.md"}], _repo} = + FS.merge_trees(repo, base, ours, theirs) + end + + test "both sides add same path with different content → :both_added", %{ + repo: repo, + base: base + } do + {:ok, ours, repo} = FS.write_path(repo, base, "NEW.md", "ours") + {:ok, theirs, repo} = FS.write_path(repo, base, "NEW.md", "theirs") + + assert {:ok, ^base, [{:both_added, "NEW.md"}], _} = + FS.merge_trees(repo, base, ours, theirs) + end + + test "both sides add same path with same content → no conflict", %{repo: repo, base: base} do + {:ok, ours, repo} = FS.write_path(repo, base, "SAME.md", "identical") + {:ok, theirs, repo} = FS.write_path(repo, base, "SAME.md", "identical") + + assert {:ok, merged, [], repo} = FS.merge_trees(repo, base, ours, theirs) + assert {:ok, {_, blob}, _} = FS.read_path(repo, merged, "SAME.md") + assert blob.data == "identical" + end + + test "modify on one side, delete on the other → :modify_delete", %{repo: repo, base: base} do + {:ok, ours, repo} = FS.write_path(repo, base, "README.md", "edited") + {:ok, theirs, repo} = FS.rm_path(repo, base, "README.md") + + assert {:ok, ^base, [{:modify_delete, "README.md"}], _} = + FS.merge_trees(repo, base, ours, theirs) + end + + test "both sides delete same path → no conflict", %{repo: repo, base: base} do + {:ok, ours, repo} = FS.rm_path(repo, base, "README.md") + {:ok, theirs, repo} = FS.rm_path(repo, base, "README.md") + + assert {:ok, merged, [], repo} = FS.merge_trees(repo, base, ours, theirs) + assert {:error, :not_found} = FS.read_path(repo, merged, "README.md") + end + + test ":abort returns base unchanged on conflict, but conflicts listed", %{ + repo: repo, + base: base + } do + {:ok, ours, repo} = FS.write_path(repo, base, "README.md", "ours") + {:ok, theirs, repo} = FS.write_path(repo, base, "README.md", "theirs") + + assert {:ok, ^base, conflicts, _} = + FS.merge_trees(repo, base, ours, theirs, strategy: :abort) + + assert conflicts == [{:both_modified, "README.md"}] + end + + test ":ours strategy resolves conflicts to ours's version", %{repo: repo, base: base} do + {:ok, ours, repo} = FS.write_path(repo, base, "README.md", "ours") + {:ok, theirs, repo} = FS.write_path(repo, base, "README.md", "theirs") + + assert {:ok, merged, [{:both_modified, "README.md"}], repo} = + FS.merge_trees(repo, base, ours, theirs, strategy: :ours) + + assert {:ok, {_, blob}, _} = FS.read_path(repo, merged, "README.md") + assert blob.data == "ours" + end + + test ":theirs strategy resolves conflicts to theirs's version", %{repo: repo, base: base} do + {:ok, ours, repo} = FS.write_path(repo, base, "README.md", "ours") + {:ok, theirs, repo} = FS.write_path(repo, base, "README.md", "theirs") + + assert {:ok, merged, [{:both_modified, "README.md"}], repo} = + FS.merge_trees(repo, base, ours, theirs, strategy: :theirs) + + assert {:ok, {_, blob}, _} = FS.read_path(repo, merged, "README.md") + assert blob.data == "theirs" + end + + test "mixed clean changes and conflicts: :ours resolves overlap, applies non-overlap", %{ + repo: repo, + base: base + } do + {:ok, ours, repo} = FS.write_path(repo, base, "src/a.ex", "ours-a") + {:ok, ours, repo} = FS.write_path(repo, ours, "README.md", "ours-readme") + + {:ok, theirs, repo} = FS.write_path(repo, base, "src/b.ex", "theirs-b") + {:ok, theirs, repo} = FS.write_path(repo, theirs, "README.md", "theirs-readme") + + assert {:ok, merged, [{:both_modified, "README.md"}], repo} = + FS.merge_trees(repo, base, ours, theirs, strategy: :ours) + + assert {:ok, {_, a}, _} = FS.read_path(repo, merged, "src/a.ex") + assert a.data == "ours-a" + + assert {:ok, {_, b}, _} = FS.read_path(repo, merged, "src/b.ex") + assert b.data == "theirs-b" + + assert {:ok, {_, r}, _} = FS.read_path(repo, merged, "README.md") + assert r.data == "ours-readme" + end + end end diff --git a/test/exgit/workspace_test.exs b/test/exgit/workspace_test.exs index 1d9e8ca..64e0ae2 100644 --- a/test/exgit/workspace_test.exs +++ b/test/exgit/workspace_test.exs @@ -286,4 +286,229 @@ defmodule Exgit.WorkspaceTest do assert {:ok, "B", _} = Workspace.read(ws_b, "x.txt") end end + + describe "move/3" do + test "renames a file, preserves content and mode", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.move(ws, "src/a.ex", "src/renamed.ex") + + assert {:error, :not_found} = Workspace.read(ws, "src/a.ex") + assert {:ok, "module A\n", _} = Workspace.read(ws, "src/renamed.ex") + end + + test "moves into a new directory implicitly", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.move(ws, "README.md", "docs/intro.md") + + assert {:ok, "hello\n", _} = Workspace.read(ws, "docs/intro.md") + assert {:error, :not_found} = Workspace.read(ws, "README.md") + end + + test "missing source returns :not_found", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + assert {:error, :not_found} = Workspace.move(ws, "nope", "elsewhere") + end + + test "refuses to move a directory in v1", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + assert {:error, :cannot_move_directory} = Workspace.move(ws, "src", "src2") + end + + test "refuses to overwrite an existing directory", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + assert {:error, :eisdir} = Workspace.move(ws, "README.md", "src") + end + + test "diff after move shows added + deleted", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.move(ws, "src/a.ex", "lib/a.ex") + {:ok, changes, _} = Workspace.diff(ws) + assert Enum.sort(changes) == [{:added, "lib/a.ex"}, {:deleted, "src/a.ex"}] + end + end + + describe "revert/2" do + test "restores a modified file to base content", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.write(ws, "src/a.ex", "broken") + {:ok, ws} = Workspace.revert(ws, "src/a.ex") + assert {:ok, "module A\n", _} = Workspace.read(ws, "src/a.ex") + end + + test "removes a file the agent added", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.write(ws, "added.txt", "agent's contribution") + {:ok, ws} = Workspace.revert(ws, "added.txt") + assert {:error, :not_found} = Workspace.read(ws, "added.txt") + end + + test "no-op on pristine workspace", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + assert {:ok, ws_after} = Workspace.revert(ws, "anything") + assert ws_after.head_tree == nil + end + + test "revert leaves siblings untouched", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.write(ws, "src/a.ex", "broken-a") + {:ok, ws} = Workspace.write(ws, "src/b.ex", "edited-b") + + {:ok, ws} = Workspace.revert(ws, "src/a.ex") + + assert {:ok, "module A\n", _} = Workspace.read(ws, "src/a.ex") + assert {:ok, "edited-b", _} = Workspace.read(ws, "src/b.ex") + end + end + + describe "diff/2 with :content" do + test "returns rich change entries with before/after blob bytes", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.write(ws, "src/a.ex", "rewritten\n") + {:ok, ws} = Workspace.write(ws, "lib/new.ex", "new file\n") + {:ok, ws} = Workspace.rm(ws, "README.md") + + {:ok, changes, _ws} = Workspace.diff(ws, content: true) + changes_by_path = Map.new(changes, fn c -> {c.path, c} end) + + assert %{op: :modified, before: "module A\n", after: "rewritten\n"} = + changes_by_path["src/a.ex"] + + assert %{op: :added, before: nil, after: "new file\n"} = changes_by_path["lib/new.ex"] + + assert %{op: :deleted, before: "hello\n", after: nil} = changes_by_path["README.md"] + end + end + + describe "diff/2 with :against" do + test "compares against a snapshot rather than base_ref", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + + {:ok, ws} = Workspace.write(ws, "step1.txt", "first") + checkpoint = Workspace.snapshot(ws) + + {:ok, ws} = Workspace.write(ws, "step2.txt", "second") + {:ok, ws} = Workspace.rm(ws, "README.md") + + {:ok, changes, _ws} = Workspace.diff(ws, against: checkpoint) + changes = Enum.sort(changes) + + # step1.txt was already in the checkpoint — not in this diff. + assert {:added, "step2.txt"} in changes + assert {:deleted, "README.md"} in changes + refute Enum.any?(changes, fn {_, p} -> p == "step1.txt" end) + end + + test ":pristine is an alias for base_ref", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.write(ws, "x.txt", "x") + + {:ok, default, _} = Workspace.diff(ws) + {:ok, against_pristine, _} = Workspace.diff(ws, against: :pristine) + + assert default == against_pristine + end + end + + describe "merge/3" do + test "non-overlapping changes from a sibling workspace merge cleanly", %{repo: repo} do + ws_base = Workspace.open(repo, "refs/heads/main") + + ws_a = ws_base + ws_b = ws_base + + {:ok, ws_a} = Workspace.write(ws_a, "src/a.ex", "ours-a") + {:ok, ws_b} = Workspace.write(ws_b, "src/b.ex", "theirs-b") + + assert {:ok, ws_merged} = Workspace.merge(ws_a, ws_b) + + assert {:ok, "ours-a", _} = Workspace.read(ws_merged, "src/a.ex") + assert {:ok, "theirs-b", _} = Workspace.read(ws_merged, "src/b.ex") + end + + test "conflicting writes return :conflict with workspace unchanged", %{repo: repo} do + ws_base = Workspace.open(repo, "refs/heads/main") + ws_a = ws_base + ws_b = ws_base + + {:ok, ws_a} = Workspace.write(ws_a, "src/a.ex", "ours") + {:ok, ws_b} = Workspace.write(ws_b, "src/a.ex", "theirs") + + head_before = ws_a.head_tree + + assert {:conflict, [{:both_modified, "src/a.ex"}], ws_after} = + Workspace.merge(ws_a, ws_b) + + # workspace head_tree unchanged + assert ws_after.head_tree == head_before + end + + test ":ours strategy resolves conflicts to our side", %{repo: repo} do + ws_base = Workspace.open(repo, "refs/heads/main") + ws_a = ws_base + ws_b = ws_base + + {:ok, ws_a} = Workspace.write(ws_a, "src/a.ex", "ours-version") + {:ok, ws_b} = Workspace.write(ws_b, "src/a.ex", "theirs-version") + + assert {:ok, ws_merged} = Workspace.merge(ws_a, ws_b, strategy: :ours) + + assert {:ok, "ours-version", _} = Workspace.read(ws_merged, "src/a.ex") + end + + test ":theirs strategy resolves conflicts to their side", %{repo: repo} do + ws_base = Workspace.open(repo, "refs/heads/main") + ws_a = ws_base + ws_b = ws_base + + {:ok, ws_a} = Workspace.write(ws_a, "src/a.ex", "ours-version") + {:ok, ws_b} = Workspace.write(ws_b, "src/a.ex", "theirs-version") + + assert {:ok, ws_merged} = Workspace.merge(ws_a, ws_b, strategy: :theirs) + + assert {:ok, "theirs-version", _} = Workspace.read(ws_merged, "src/a.ex") + end + + test "merging :pristine is a no-op", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.write(ws, "x.txt", "x") + assert {:ok, ws_after} = Workspace.merge(ws, :pristine) + assert ws_after.head_tree == ws.head_tree + end + + test "merge into pristine workspace from a divergent workspace", %{repo: repo} do + ws_base = Workspace.open(repo, "refs/heads/main") + ws_other = ws_base + {:ok, ws_other} = Workspace.write(ws_other, "lib/extra.ex", "extra") + + assert {:ok, ws_merged} = Workspace.merge(ws_base, ws_other) + assert {:ok, "extra", _} = Workspace.read(ws_merged, "lib/extra.ex") + end + + test "merging a same-repo tree-sha works without import", %{repo: repo} do + # When source is a tree-sha already in target's repo, merge by SHA + # is the optimized path (no object copying needed). Re-open from + # the post-write repo so the tree exists in target's store. + ws = Workspace.open(repo, "refs/heads/main") + {:ok, ws} = Workspace.write(ws, "src/c.ex", "merged-c") + tree = Workspace.snapshot(ws) + + ws_fresh = Workspace.open(ws.repo, "refs/heads/main") + assert {:ok, ws_merged} = Workspace.merge(ws_fresh, tree) + assert {:ok, "merged-c", _} = Workspace.read(ws_merged, "src/c.ex") + end + end + + describe "materialized_walk/1" do + test "returns a stream and a materialized workspace", %{repo: repo} do + ws = Workspace.open(repo, "refs/heads/main") + {:ok, stream, ws_eager} = Workspace.materialized_walk(ws) + + paths = stream |> Enum.map(fn {p, _sha} -> p end) |> Enum.sort() + assert "README.md" in paths + assert "src/a.ex" in paths + + # repo flipped to :eager (no-op on already-eager, still :eager) + assert ws_eager.repo.mode == :eager + end + end end