diff --git a/lib/xgit/core/dir_cache.ex b/lib/xgit/core/dir_cache.ex index d0839a3..6a601d9 100644 --- a/lib/xgit/core/dir_cache.ex +++ b/lib/xgit/core/dir_cache.ex @@ -26,6 +26,7 @@ defmodule Xgit.Core.DirCache do import Xgit.Util.ForceCoverage alias Xgit.Core.FilePath + alias Xgit.Core.Tree alias Xgit.Util.Comparison @typedoc ~S""" @@ -414,4 +415,115 @@ defmodule Xgit.Core.DirCache do defp remove_matching_entries([existing_head | existing_tail], sorted_entries_to_remove), do: cover([existing_head | remove_matching_entries(existing_tail, sorted_entries_to_remove)]) + + @typedoc ~S""" + Error reason codes returned by `to_tree_objects/2`. + """ + @type to_tree_objects_reason :: :invalid_dir_cache | :prefix_not_found + + @doc ~S""" + Convert this `DirCache` to one or more `tree` objects. + + ## Parameters + + `prefix`: (`Xgit.Core.FilePath`) if present, return the object ID for the tree + pointed to by `prefix`. All tree objects will be generated, regardless of `prefix`. + + ## Return Value + + `{:ok, objects, prefix_tree}` where `objects` is a list of `Xgit.Core.Object` + structs of type `tree`. All others must be written or must be present in the + object database for the top-level tree to be valid. `prefix_tree` is the + tree for the subtree specified by `prefix` or the top-level tree if no prefix + was specified. + + `{:error, :invalid_dir_cache}` if the `DirCache` is not valid. + + `{:error, :prefix_not_found}` if no tree matching `prefix` exists. + """ + @spec to_tree_objects(dir_cache :: t, prefix :: Xgit.Core.FilePath.t()) :: + {:ok, [Xgit.Core.Object.t()], Xgit.Core.Object.t()} | {:error, to_tree_objects_reason} + def to_tree_objects(dir_cache, prefix \\ []) + + def to_tree_objects(%__MODULE__{entries: entries} = dir_cache, prefix) + when is_list(entries) and is_list(prefix) do + with {:valid?, true} <- {:valid?, valid?(dir_cache)}, + {_entries, tree_for_prefix, _this_tree} <- to_tree_objects_inner(entries, [], %{}, []), + {:prefix, prefix_tree} when prefix_tree != nil <- + {:prefix, Map.get(tree_for_prefix, FilePath.ensure_trailing_separator(prefix))} do + objects = + tree_for_prefix + |> Enum.sort() + |> Enum.map(fn {_prefix, object} -> object end) + + cover {:ok, objects, prefix_tree} + else + {:valid?, _} -> cover {:error, :invalid_dir_cache} + {:prefix, _} -> cover {:error, :prefix_not_found} + end + end + + defp to_tree_objects_inner(entries, prefix, tree_for_prefix, tree_entries_acc) + + defp to_tree_objects_inner([], prefix, tree_for_prefix, tree_entries_acc), + do: make_tree_and_continue([], prefix, tree_for_prefix, tree_entries_acc) + + defp to_tree_objects_inner( + [%__MODULE__.Entry{name: name, object_id: object_id, mode: mode} | tail] = entries, + prefix, + tree_for_prefix, + tree_entries_acc + ) do + if FilePath.starts_with?(name, prefix) do + name_after_prefix = Enum.drop(name, Enum.count(prefix)) + + {next_entries, new_tree_entry, tree_for_prefix} = + if Enum.any?(name_after_prefix, &(&1 == ?/)) do + make_subtree(entries, prefix, tree_for_prefix, tree_entries_acc) + else + cover {tail, %Tree.Entry{name: name_after_prefix, object_id: object_id, mode: mode}, + tree_for_prefix} + end + + to_tree_objects_inner(next_entries, prefix, tree_for_prefix, [ + new_tree_entry | tree_entries_acc + ]) + else + make_tree_and_continue(entries, prefix, tree_for_prefix, tree_entries_acc) + end + end + + defp make_tree_and_continue(entries, prefix, tree_for_prefix, tree_entries_acc) do + tree_object = Tree.to_object(%Tree{entries: Enum.reverse(tree_entries_acc)}) + {entries, Map.put(tree_for_prefix, prefix, tree_object), tree_object} + end + + defp make_subtree( + [%__MODULE__.Entry{name: name} | _tail] = entries, + existing_prefix, + tree_for_prefix, + _tree_entries_acc + ) do + first_segment_after_prefix = + name + |> Enum.drop(Enum.count(existing_prefix)) + |> Enum.drop_while(&(&1 == ?/)) + |> Enum.take_while(&(&1 != ?/)) + + tree_name = + cover '#{FilePath.ensure_trailing_separator(existing_prefix)}#{first_segment_after_prefix}' + + new_prefix = cover '#{tree_name}/' + + {entries, tree_for_prefix, tree_object} = + to_tree_objects_inner(entries, new_prefix, tree_for_prefix, []) + + new_tree_entry = %Tree.Entry{ + name: first_segment_after_prefix, + object_id: tree_object.id, + mode: FileMode.tree() + } + + cover {entries, new_tree_entry, tree_for_prefix} + end end diff --git a/test/xgit/core/dir_cache_test.exs b/test/xgit/core/dir_cache_test.exs index 25a40bd..5896fdb 100644 --- a/test/xgit/core/dir_cache_test.exs +++ b/test/xgit/core/dir_cache_test.exs @@ -3,6 +3,11 @@ defmodule Xgit.Core.DirCacheTest do alias Xgit.Core.DirCache alias Xgit.Core.DirCache.Entry + alias Xgit.GitInitTestCase + alias Xgit.Repository + alias Xgit.Repository.OnDisk + + import FolderDiff describe "empty/0" do assert %DirCache{version: 2, entry_count: 0, entries: []} = empty = DirCache.empty() @@ -338,4 +343,332 @@ defmodule Xgit.Core.DirCacheTest do end end end + + describe "to_tree_objects/2" do + test "happy path: empty dir cache" do + assert_same_output(fn _git_dir -> nil end, DirCache.empty()) + end + + test "happy path: one root-level entry in dir cache" do + assert_same_output( + fn git_dir -> + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "7919e8900c3af541535472aebd56d44222b7b3a3", + "hello.txt" + ], + cd: git_dir + ) + end, + @valid + ) + end + + test "happy path: one blob nested one level" do + assert_same_output( + fn git_dir -> + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "7fa62716fc68733db4c769fe678295cf4cf5b336", + "a/b" + ], + cd: git_dir + ) + end, + %DirCache{ + version: 2, + entry_count: 1, + entries: [ + Map.merge(@valid_entry, %{ + name: 'a/b', + object_id: "7fa62716fc68733db4c769fe678295cf4cf5b336" + }) + ] + } + ) + end + + test "happy path: deeply nested dir cache" do + assert_same_output( + fn git_dir -> + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "7fa62716fc68733db4c769fe678295cf4cf5b336", + "a/a/b" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "0f717230e297de82d0f8d761143dc1e1145c6bd5", + "a/b/c" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "ff287368514462578ba6406d366113953539cbf1", + "a/b/d" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "de588889c4d62aaf3ef3bd90be38fa239be2f5d1", + "a/c/x" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100755", + "7919e8900c3af541535472aebd56d44222b7b3a3", + "other.txt" + ], + cd: git_dir + ) + end, + %DirCache{ + version: 2, + entry_count: 5, + entries: [ + Map.merge(@valid_entry, %{ + name: 'a/a/b', + object_id: "7fa62716fc68733db4c769fe678295cf4cf5b336" + }), + Map.merge(@valid_entry, %{ + name: 'a/b/c', + object_id: "0f717230e297de82d0f8d761143dc1e1145c6bd5" + }), + Map.merge(@valid_entry, %{ + name: 'a/b/d', + object_id: "ff287368514462578ba6406d366113953539cbf1" + }), + Map.merge(@valid_entry, %{ + name: 'a/c/x', + object_id: "de588889c4d62aaf3ef3bd90be38fa239be2f5d1" + }), + Map.merge(@valid_entry, %{name: 'other.txt', mode: 0o100755}) + ] + } + ) + end + + test "honors prefix" do + assert_same_output( + fn git_dir -> + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "7fa62716fc68733db4c769fe678295cf4cf5b336", + "a/a/b" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "0f717230e297de82d0f8d761143dc1e1145c6bd5", + "a/b/c" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "ff287368514462578ba6406d366113953539cbf1", + "a/b/d" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100644", + "de588889c4d62aaf3ef3bd90be38fa239be2f5d1", + "a/c/x" + ], + cd: git_dir + ) + + {_output, 0} = + System.cmd( + "git", + [ + "update-index", + "--add", + "--cacheinfo", + "100755", + "7919e8900c3af541535472aebd56d44222b7b3a3", + "other.txt" + ], + cd: git_dir + ) + end, + %DirCache{ + version: 2, + entry_count: 5, + entries: [ + Map.merge(@valid_entry, %{ + name: 'a/a/b', + object_id: "7fa62716fc68733db4c769fe678295cf4cf5b336" + }), + Map.merge(@valid_entry, %{ + name: 'a/b/c', + object_id: "0f717230e297de82d0f8d761143dc1e1145c6bd5" + }), + Map.merge(@valid_entry, %{ + name: 'a/b/d', + object_id: "ff287368514462578ba6406d366113953539cbf1" + }), + Map.merge(@valid_entry, %{ + name: 'a/c/x', + object_id: "de588889c4d62aaf3ef3bd90be38fa239be2f5d1" + }), + Map.merge(@valid_entry, %{name: 'other.txt', mode: 0o100755}) + ] + }, + 'a/b' + ) + end + + test "prefix doesn't exist" do + dir_cache = %DirCache{ + version: 2, + entry_count: 5, + entries: [ + Map.merge(@valid_entry, %{ + name: 'a/a/b', + object_id: "7fa62716fc68733db4c769fe678295cf4cf5b336" + }), + Map.merge(@valid_entry, %{ + name: 'a/b/c', + object_id: "0f717230e297de82d0f8d761143dc1e1145c6bd5" + }), + Map.merge(@valid_entry, %{ + name: 'a/b/d', + object_id: "ff287368514462578ba6406d366113953539cbf1" + }), + Map.merge(@valid_entry, %{ + name: 'a/c/x', + object_id: "de588889c4d62aaf3ef3bd90be38fa239be2f5d1" + }), + Map.merge(@valid_entry, %{name: 'other.txt', mode: 0o100755}) + ] + } + + assert {:error, :prefix_not_found} = DirCache.to_tree_objects(dir_cache, 'no/such/prefix') + end + + test "error: invalid dir cache" do + assert {:error, :invalid_dir_cache} = + DirCache.to_tree_objects(%DirCache{ + version: 2, + entry_count: 3, + entries: [ + Map.put(@valid_entry, :name, 'abc'), + Map.put(@valid_entry, :name, 'abf'), + Map.put(@valid_entry, :name, 'abe') + ] + }) + end + + defp assert_same_output(git_ref_fn, dir_cache, prefix \\ []) do + {:ok, ref: ref, xgit: xgit} = GitInitTestCase.setup_git_repo() + + git_ref_fn.(ref) + + {output, 0} = + if prefix == [] do + System.cmd("git", ["write-tree", "--missing-ok"], cd: ref) + else + System.cmd("git", ["write-tree", "--missing-ok", "--prefix=#{prefix}"], cd: ref) + end + + content_id = String.trim(output) + + assert {:ok, tree_objects, root_tree_object} = DirCache.to_tree_objects(dir_cache, prefix) + + :ok = OnDisk.create(xgit) + {:ok, repo} = OnDisk.start_link(work_dir: xgit) + + Enum.each(tree_objects, fn tree_object -> + :ok = Repository.put_loose_object(repo, tree_object) + end) + + assert_folders_are_equal( + Path.join([ref, ".git", "objects"]), + Path.join([xgit, ".git", "objects"]) + ) + + assert content_id == root_tree_object.id + end + end end