From 687827b9e81f01234c293a87447e243ae0c95d03 Mon Sep 17 00:00:00 2001 From: Eric Scouten Date: Tue, 23 Jul 2019 23:26:05 -0700 Subject: [PATCH] Implement Xgit.Repository.put_loose_object/2. (#47) * Define new function in Xgit.Repository: put_loose_object/2. Implement for Xgit.Repository.OnDisk. Basic case now matches command-line git behaviour; needs more edge-case and error-case testing. * Finish up edge-case handling. --- lib/xgit/repository.ex | 43 +++++++++- lib/xgit/repository/on_disk.ex | 4 + .../repository/on_disk/put_loose_object.ex | 66 ++++++++++++++ .../on_disk/put_loose_object_test.exs | 85 +++++++++++++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 lib/xgit/repository/on_disk/put_loose_object.ex create mode 100644 test/xgit/repository/on_disk/put_loose_object_test.exs diff --git a/lib/xgit/repository.ex b/lib/xgit/repository.ex index b0d2236..564f913 100644 --- a/lib/xgit/repository.ex +++ b/lib/xgit/repository.ex @@ -26,9 +26,11 @@ defmodule Xgit.Repository do information stored in a typical `.git` directory in a local repository. You will be building an alternative to that storage mechanism. """ - use GenServer + alias Xgit.Core.Object + alias Xgit.Util.GenServerUtils + require Logger @typedoc ~S""" @@ -78,9 +80,46 @@ defmodule Xgit.Repository do def valid?(_), do: false + @doc ~S""" + Writes a loose object to the repository. + + ## Return Value + + `:ok` if written successfully. + + `{:error, "reason"}` if unable to write the object. + """ + @spec put_loose_object(repository :: t, object :: Object.t()) :: + :ok | {:error, reason :: String.t()} + def put_loose_object(repository, %Object{} = object) when is_pid(repository), + do: GenServer.call(repository, {:put_loose_object, object}) + + @doc ~S""" + Writes a loose object to the repository. + + Called when `put_loose_object/2` is called. + + ## Return Value + + Should return `:ok` if written successfully. + + Should return `{:error, "reason"}` if unable to write the object. + """ + @callback handle_put_loose_object(state :: any, object :: Object.t()) :: + {:ok, state :: any} | {:error, reason :: String.t(), state :: any} + @impl true def handle_call(:valid_repository?, _from, state), do: {:reply, :valid_repository, state} + def handle_call({:put_loose_object, %Object{} = object}, _from, {mod, mod_state}) do + GenServerUtils.delegate_call_to( + mod, + :handle_put_loose_object, + [mod_state, object], + mod_state + ) + end + def handle_call(message, _from, state) do Logger.warn("Repository received unrecognized call #{inspect(message)}") {:reply, {:error, :unknown_message}, state} @@ -92,7 +131,7 @@ defmodule Xgit.Repository do alias Xgit.Repository - # @behaviour Repository (not yet, but it will be) + @behaviour Repository end end end diff --git a/lib/xgit/repository/on_disk.ex b/lib/xgit/repository/on_disk.ex index 51600c0..bcbe4fd 100644 --- a/lib/xgit/repository/on_disk.ex +++ b/lib/xgit/repository/on_disk.ex @@ -70,4 +70,8 @@ defmodule Xgit.Repository.OnDisk do """ @spec create(work_dir :: String.t()) :: :ok | {:error, reason :: String.t()} defdelegate create(work_dir), to: Xgit.Repository.OnDisk.Create + + @impl true + defdelegate handle_put_loose_object(state, object), + to: Xgit.Repository.OnDisk.PutLooseObject end diff --git a/lib/xgit/repository/on_disk/put_loose_object.ex b/lib/xgit/repository/on_disk/put_loose_object.ex new file mode 100644 index 0000000..fd07526 --- /dev/null +++ b/lib/xgit/repository/on_disk/put_loose_object.ex @@ -0,0 +1,66 @@ +defmodule Xgit.Repository.OnDisk.PutLooseObject do + @moduledoc false + # Implements Xgit.Repository.OnDisk.handle_put_loose_object/2. + + alias Xgit.Core.ContentSource + alias Xgit.Core.Object + + @spec handle_put_loose_object(state :: any, object :: Object.t()) :: + {:ok, state :: any} | {:error, reason :: String.t(), state :: any} + def handle_put_loose_object(%{git_dir: git_dir} = state, %Object{id: id} = object) do + object_dir = Path.join([git_dir, "objects", String.slice(id, 0, 2)]) + path = Path.join(object_dir, String.slice(id, 2, 38)) + + with {:mkdir, :ok} <- + {:mkdir, File.mkdir_p(object_dir)}, + {:file, {:ok, :ok}} <- + {:file, + File.open(path, [:write, :binary, :exclusive], fn file_pid -> + deflate_and_write(file_pid, object) + end)} do + {:ok, state} + else + {:mkdir, _} -> + {:error, :cant_create_dir, state} + + {:file, {:error, :eexist}} -> + {:error, :object_exists, state} + end + end + + defp deflate_and_write(file, %Object{type: type, size: size, content: content}) do + z = :zlib.open() + :ok = :zlib.deflateInit(z, 1) + + deflate_and_write_bytes(file, z, '#{type} #{size}') + deflate_and_write_bytes(file, z, [0]) + + if is_list(content) do + deflate_and_write_bytes(file, z, content, :finish) + else + deflate_content(file, z, content) + deflate_and_write_bytes(file, z, [], :finish) + end + + :zlib.deflateEnd(z) + end + + defp deflate_content(file, z, content) do + content + |> ContentSource.stream() + |> Stream.each(fn chunk -> + deflate_and_write_bytes(file, z, [chunk]) + end) + |> Stream.run() + end + + defp deflate_and_write_bytes(file, z, bytes, flush \\ :none) do + compressed = + z + |> :zlib.deflate(bytes, flush) + |> Enum.join() + |> :binary.bin_to_list() + + IO.write(file, compressed) + end +end diff --git a/test/xgit/repository/on_disk/put_loose_object_test.exs b/test/xgit/repository/on_disk/put_loose_object_test.exs new file mode 100644 index 0000000..eb56a8b --- /dev/null +++ b/test/xgit/repository/on_disk/put_loose_object_test.exs @@ -0,0 +1,85 @@ +defmodule Xgit.Repository.OnDisk.PutLooseObjectTest do + use Xgit.GitInitTestCase, async: true + + alias Xgit.Core.ContentSource + alias Xgit.Core.FileContentSource + alias Xgit.Core.Object + alias Xgit.Repository + alias Xgit.Repository.OnDisk + + import FolderDiff + + describe "put_loose_object/2" do + @test_content 'test content\n' + @test_content_id "d670460b4b4aece5915caf5c68d12f560a9fe3e4" + + test "happy path matches command-line git (small file)", %{ref: ref, xgit: xgit} do + Temp.track!() + path = Temp.path!() + File.write!(path, "test content\n") + + {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) + assert String.trim(output) == @test_content_id + + assert :ok = OnDisk.create(xgit) + assert {:ok, repo} = OnDisk.start_link(work_dir: xgit) + + object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} + assert :ok = Repository.put_loose_object(repo, object) + + assert_folders_are_equal(ref, xgit) + end + + test "happy path matches command-line git (large file)", %{ref: ref, xgit: xgit} do + Temp.track!() + path = Temp.path!() + + content = + 1..1000 + |> Enum.map(fn _ -> "foobar" end) + |> Enum.join() + + File.write!(path, content) + + {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) + content_id = String.trim(output) + + assert :ok = OnDisk.create(xgit) + assert {:ok, repo} = OnDisk.start_link(work_dir: xgit) + + fcs = FileContentSource.new(path) + object = %Object{type: :blob, content: fcs, size: ContentSource.length(fcs), id: content_id} + assert :ok = Repository.put_loose_object(repo, object) + + assert_folders_are_equal(ref, xgit) + end + + test "error: can't create objects dir", %{xgit: xgit} do + assert :ok = OnDisk.create(xgit) + assert {:ok, repo} = OnDisk.start_link(work_dir: xgit) + + objects_dir = Path.join([xgit, ".git", "objects", String.slice(@test_content_id, 0, 2)]) + File.mkdir_p!(Path.join([xgit, ".git", "objects"])) + File.write!(objects_dir, "sand in the gears") + + object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} + assert {:error, :cant_create_dir} = Repository.put_loose_object(repo, object) + end + + test "error: object exists already", %{xgit: xgit} do + assert :ok = OnDisk.create(xgit) + assert {:ok, repo} = OnDisk.start_link(work_dir: xgit) + + objects_dir = Path.join([xgit, ".git", "objects", String.slice(@test_content_id, 0, 2)]) + File.mkdir_p!(objects_dir) + + File.write!( + Path.join(objects_dir, String.slice(@test_content_id, 2, 38)), + "sand in the gears" + ) + + object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} + assert {:error, :object_exists} = Repository.put_loose_object(repo, object) + end + end +end