From 42ae860404de8bec00474295fb63413e69bdc321 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 16:49:44 -0400 Subject: [PATCH] fix: FS.write_path starts from the empty tree over an unborn ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace.commit/2 already treats an unborn ref as parentless, but Workspace.write/3 failed {:error, :not_found} on the same base — so a workspace over a freshly initialized or cloned-empty repository could never stage its first file. Unborn detection is deliberately narrow: a ref NAME absent from the ref store. A raw SHA that fails to resolve stays an error, since in a lazy clone that is a fetch miss and an empty base there would silently drop every existing tree entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FpxHS5iGm9ktgpnL9QZu9x --- lib/exgit/fs.ex | 36 ++++++++++++++++++++++++++++++-- test/exgit/workspace_test.exs | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/exgit/fs.ex b/lib/exgit/fs.ex index 293eaab..b67501e 100644 --- a/lib/exgit/fs.ex +++ b/lib/exgit/fs.ex @@ -1244,6 +1244,13 @@ defmodule Exgit.FS do @doc """ Write `content` to `path`. Returns `{:ok, new_tree_sha, repo}`. + + An unborn base — a ref *name* with no entry in the ref store, as on a + freshly initialized or cloned-empty repository — starts from the empty + tree, mirroring how `Exgit.Workspace.commit/2` treats an unborn ref as + parentless. A raw SHA that fails to resolve stays `{:error, :not_found}`: + in a lazy clone that is a missing object, and starting from empty there + would silently drop every existing entry from the produced tree. """ @spec write_path(Repository.t(), ref(), path(), binary(), keyword()) :: {:ok, binary(), Repository.t()} | {:error, term()} @@ -1254,16 +1261,41 @@ defmodule Exgit.FS do if segments == [] do {:error, :cannot_write_root} else - with {:ok, tree_sha, repo} <- resolve_tree(repo, reference) do + with {:ok, base, repo} <- resolve_base_tree_for_write(repo, reference) do blob = Blob.new(content) {:ok, blob_sha, store} = ObjectStore.put(repo.object_store, blob) repo = %{repo | object_store: store} - insert_blob_into_tree(repo, tree_sha, segments, mode, blob_sha) + case base do + {:tree, tree_sha} -> insert_blob_into_tree(repo, tree_sha, segments, mode, blob_sha) + :unborn -> insert_blob_into_empty(repo, segments, mode, blob_sha) + end end end end + defp resolve_base_tree_for_write(repo, reference) do + case resolve_tree(repo, reference) do + {:ok, tree_sha, repo} -> + {:ok, {:tree, tree_sha}, repo} + + {:error, :not_found} = err -> + if unborn_ref?(repo, reference), do: {:ok, :unborn, repo}, else: err + + {:error, _} = err -> + err + end + end + + # Unborn means the reference is a ref name (per the same heuristic + # resolve_tree/2 uses to tell names from raw SHAs) that the ref store + # has no entry for. A ref that resolves to a SHA whose object is + # missing is NOT unborn — that is a lazy-clone fetch miss. + defp unborn_ref?(repo, reference) do + ref_name? = byte_size(reference) != 20 or printable_ascii_ref?(reference) + ref_name? and match?({:error, :not_found}, RefStore.resolve(repo.ref_store, reference)) + end + defp insert_blob_into_tree(repo, tree_sha, [name], mode, blob_sha) do with {:ok, %Tree{entries: entries}, repo} <- fetch_object(repo, tree_sha) do new_entries = diff --git a/test/exgit/workspace_test.exs b/test/exgit/workspace_test.exs index 1d9e8ca..b0709dd 100644 --- a/test/exgit/workspace_test.exs +++ b/test/exgit/workspace_test.exs @@ -287,3 +287,42 @@ defmodule Exgit.WorkspaceTest do end end end + +defmodule Exgit.WorkspaceUnbornRefTest do + use ExUnit.Case, async: true + + alias Exgit.Workspace + + describe "workspace over an unborn branch" do + test "write starts from the empty tree; commit and read work" do + {:ok, repo} = Exgit.init() + ws = Workspace.open(repo, "refs/heads/main") + + assert {:ok, ws} = Workspace.write(ws, "/hello.txt", "hi\n") + + assert {:ok, _sha, ws} = + Workspace.commit(ws, + message: "first", + author: %{name: "t", email: "t@t"}, + update_ref: "refs/heads/main" + ) + + assert {:ok, "hi\n", _ws} = Workspace.read(ws, "/hello.txt") + end + + test "nested paths build intermediate trees from nothing" do + {:ok, repo} = Exgit.init() + ws = Workspace.open(repo, "refs/heads/main") + + assert {:ok, ws} = Workspace.write(ws, "/a/b/c.txt", "deep\n") + assert {:ok, "deep\n", _ws} = Workspace.read(ws, "/a/b/c.txt") + end + + test "a missing raw SHA base is still an error, not an empty tree" do + {:ok, repo} = Exgit.init() + ws = Workspace.open(repo, <<0::160>>) + + assert {:error, :not_found} = Workspace.write(ws, "/hello.txt", "hi\n") + end + end +end