Current section

Files

Jump to
xgit lib xgit repository working_tree.ex
Raw

lib/xgit/repository/working_tree.ex

defmodule Xgit.Repository.WorkingTree do
@moduledoc ~S"""
A working tree is an on-disk manifestation of a commit or pending commit in
a git repository.
An `Xgit.Repository` may have a default working tree associated with it or
it may not. (Such a repository is often referred to as a "bare" repository.)
More than one working tree may be associated with a repository, though this
is not (currently) well-tested in Xgit.
A working tree is itself strictly tied to a file system, but it need not be
tied to an on-disk repository instance.
_IMPORTANT NOTE:_ This is intended as a reference implementation largely
for testing purposes and may not necessarily handle all of the edge cases that
the traditional `git` command-line interface will handle.
"""
use GenServer
import Xgit.Util.ForceCoverage
alias Xgit.Core.DirCache
alias Xgit.Core.DirCache.Entry, as: DirCacheEntry
alias Xgit.Core.FilePath
alias Xgit.Core.Object
alias Xgit.Core.ObjectId
alias Xgit.Core.Tree
alias Xgit.Repository
alias Xgit.Repository.WorkingTree.ParseIndexFile
alias Xgit.Repository.WorkingTree.WriteIndexFile
alias Xgit.Util.TrailingHashDevice
require Logger
@typedoc ~S"""
The process ID for a `WorkingTree` process.
"""
@type t :: pid
@doc """
Starts a `WorkingTree` process linked to the current process.
## Parameters
`repository` is the associated `Xgit.Repository` process.
`work_dir` is the root path for the working tree.
`options` are passed to `GenServer.start_link/3`.
## Return Value
See `GenServer.start_link/3`.
If the process is unable to create the working directory root, the response
will be `{:error, {:mkdir, :eexist}}` (or perhaps a different posix error code).
"""
@spec start_link(repository :: Repository.t(), work_dir :: Path.t(), GenServer.options()) ::
GenServer.on_start()
def start_link(repository, work_dir, options \\ [])
when is_pid(repository) and is_binary(work_dir) and is_list(options) do
if Repository.valid?(repository) do
GenServer.start_link(__MODULE__, {repository, work_dir}, options)
else
cover {:error, :invalid_repository}
end
end
@impl true
def init({repository, work_dir}) do
case File.mkdir_p(work_dir) do
:ok ->
index_path = Path.join([work_dir, ".git", "index"])
Process.monitor(repository)
# Read index file here or maybe in a :continue handler?
cover {:ok, %{repository: repository, work_dir: work_dir, index_path: index_path}}
{:error, reason} ->
cover {:stop, {:mkdir, reason}}
end
end
@doc ~S"""
Returns `true` if the argument is a PID representing a valid `WorkingTree` process.
"""
@spec valid?(working_tree :: term) :: boolean
def valid?(working_tree) when is_pid(working_tree) do
Process.alive?(working_tree) &&
GenServer.call(working_tree, :valid_working_tree?) == :valid_working_tree
end
def valid?(_), do: cover(false)
@doc ~S"""
Returns a current snapshot of the working tree state.
## Return Value
`{:ok, dir_cache}` if an index file exists and could be parsed as a dir cache file.
`{:ok, dir_cache}` if no index file exists. (`dir_cache` will have zero entries.)
`{:error, reason}` if the file exists but could not be parsed.
See `Xgit.Repository.WorkingTree.ParseIndexFile.from_iodevice/1` for possible
reason codes.
## TO DO
Find index file in appropriate location (i.e. as potentially modified
by `.git/config` file). [Issue #86](https://github.com/elixir-git/xgit/issues/86)
Cache state of index file so we don't have to parse it for every
call. [Issue #87](https://github.com/elixir-git/xgit/issues/87)
Consider scalability of passing a potentially large `Xgit.Core.DirCache` structure
across process boundaries. [Issue #88](https://github.com/elixir-git/xgit/issues/88)
"""
@spec dir_cache(working_tree :: t) ::
{:ok, DirCache.t()} | {:error, reason :: ParseIndexFile.from_iodevice_reason()}
def dir_cache(working_tree) when is_pid(working_tree),
do: GenServer.call(working_tree, :dir_cache)
defp handle_dir_cache(%{index_path: index_path} = state) do
case parse_index_file_if_exists(index_path) do
{:ok, dir_cache} -> {:reply, {:ok, dir_cache}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@typedoc ~S"""
Error code reasons returned by `reset_dir_cache/1`.
"""
@type reset_dir_cache_reason :: WriteIndexFile.to_iodevice_reason()
@doc ~S"""
Reset the dir cache to empty and rewrite the index file accordingly.
## Return Values
`:ok` if successful.
`{:error, reason}` if unable. The relevant reason codes may come from:
* `Xgit.Repository.WorkingTree.WriteIndexFile.to_iodevice/2`.
"""
@spec reset_dir_cache(working_tree :: t) ::
:ok | {:error, reset_dir_cache_reason}
def reset_dir_cache(working_tree) when is_pid(working_tree),
do: GenServer.call(working_tree, :reset_dir_cache)
defp handle_reset_dir_cache(%{index_path: index_path} = state) do
case write_index_file(DirCache.empty(), index_path) do
:ok -> cover {:reply, :ok, state}
{:error, reason} -> cover {:reply, {:error, reason}, state}
end
end
@typedoc ~S"""
Error code reasons returned by `update_dir_cache/3`.
"""
@type update_dir_cache_reason ::
DirCache.add_entries_reason()
| DirCache.remove_entries_reason()
| ParseIndexFile.from_iodevice_reason()
| WriteIndexFile.to_iodevice_reason()
@doc ~S"""
Apply updates to the dir cache and rewrite the index tree accordingly.
## Parameters
`add`: a list of `Xgit.Core.DirCache.Entry` structs to add to the dir cache.
In the event of collisions with existing entries, the existing entries will
be replaced with the corresponding new entries.
`remove`: a list of `{path, stage}` tuples to remove from the dir cache.
`stage` must be `0..3` to remove a specific stage entry or `:all` to match
any entry for the `path`.
## Return Values
`{:ok, dir_cache}` where `dir_cache` is the original `dir_cache` with the new
entries added (and properly sorted) and targeted entries removed.
`{:error, reason}` if unable. The relevant reason codes may come from:
* `Xgit.Core.DirCache.add_entries/2`
* `Xgit.Core.DirCache.remove_entries/2`
* `Xgit.Repository.WorkingTree.ParseIndexFile.from_iodevice/1`
* `Xgit.Repository.WorkingTree.WriteIndexFile.to_iodevice/2`.
## TO DO
Find index file in appropriate location (i.e. as potentially modified
by `.git/config` file). [Issue #86](https://github.com/elixir-git/xgit/issues/86)
Cache state of index file so we don't have to parse it for every
call. [Issue #87](https://github.com/elixir-git/xgit/issues/87)
"""
@spec update_dir_cache(
working_tree :: t,
add :: [DirCacheEntry.t()],
remove :: [{path :: FilePath.t(), stage :: DirCacheEntry.stage_match()}]
) ::
{:ok, DirCache.t()} | {:error, update_dir_cache_reason}
def update_dir_cache(working_tree, add, remove)
when is_pid(working_tree) and is_list(add) and is_list(remove),
do: GenServer.call(working_tree, {:update_dir_cache, add, remove})
defp handle_update_dir_cache(add, remove, %{index_path: index_path} = state) do
with {:ok, dir_cache} <- parse_index_file_if_exists(index_path),
{:ok, dir_cache} <- DirCache.add_entries(dir_cache, add),
{:ok, dir_cache} <- DirCache.remove_entries(dir_cache, remove),
:ok <- write_index_file(dir_cache, index_path) do
{:reply, :ok, state}
else
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@typedoc ~S"""
Reason codes that can be returned by `read_tree/3`.
"""
@type read_tree_reason ::
:objects_missing
| Tree.from_object_reason()
| Repository.get_object_reason()
| WriteIndexFile.to_iodevice_reason()
@doc ~S"""
Read a `tree` object and any trees it may refer to and populate the dir cache accordingly.
Does not update files in the working tree itself.
Analogous to [`git read-tree`](https://git-scm.com/docs/git-read-tree).
## Parameters
`object_id` is the object ID of the root working tree.
## Options
`:missing_ok?`: `true` to ignore any objects that are referenced by the tree
structures that are not present in the object database. Normally this would be an error.
## Return Value
`:ok` if successful.
`{:error, :objects_missing}` if any of the objects referenced by the index
are not present in the object store. (Exception: If `missing_ok?` is `true`,
then this condition will be ignored.)
Reason codes may also come from the following functions:
* `Xgit.Core.Tree.from_object/1`
* `Xgit.Repository.get_object/2`
* `Xgit.Repository.WorkingTree.WriteIndexFile.to_iodevice/2`
## TO DO
Implement `--prefix` option. https://github.com/elixir-git/xgit/issues/175
"""
@spec read_tree(working_tree :: t, object_id :: ObjectId.t(), missing_ok?: boolean) ::
:ok | {:error, reason :: read_tree_reason}
def read_tree(working_tree, object_id, opts \\ [])
when is_pid(working_tree) and is_binary(object_id) and is_list(opts) do
missing_ok? = validate_read_tree_options(opts)
GenServer.call(working_tree, {:read_tree, object_id, missing_ok?})
end
defp validate_read_tree_options(opts) do
missing_ok? = Keyword.get(opts, :missing_ok?, false)
unless is_boolean(missing_ok?) do
raise ArgumentError,
"Xgit.Repository.WorkingTree.read_tree/3: missing_ok? #{inspect(missing_ok?)} is invalid"
end
missing_ok?
end
defp handle_read_tree(
object_id,
missing_ok?,
%{repository: repository, index_path: index_path} = state
) do
with {:ok, %DirCache{entries: entries} = dir_cache} <-
tree_to_dir_cache(repository, object_id),
{:has_all_objects?, true} <-
{:has_all_objects?, has_all_objects?(repository, entries, missing_ok?)},
:ok <- write_index_file(dir_cache, index_path) do
cover {:reply, :ok, state}
else
{:error, reason} -> cover {:reply, {:error, reason}, state}
{:has_all_objects?, false} -> cover {:reply, {:error, :objects_missing}, state}
end
end
defp tree_to_dir_cache(repository, object_id) do
case tree_to_dir_cache_entries(repository, object_id, '', []) do
{:ok, reversed_entries} ->
{:ok,
%DirCache{
version: 2,
entry_count: Enum.count(reversed_entries),
entries: Enum.reverse(reversed_entries)
}}
{:error, reason} ->
{:error, reason}
end
end
defp tree_to_dir_cache_entries(repository, object_id, prefix, acc) do
with {:ok, object} <- Repository.get_object(repository, object_id),
{:ok, %Tree{entries: tree_entries} = _tree} <- Tree.from_object(object) do
tree_entries_to_dir_cache_entries(repository, tree_entries, prefix, acc)
# TO DO: A malformed tree could cause an infinite loop here.
# https://github.com/elixir-git/xgit/issues/178
else
{:error, reason} -> {:error, reason}
end
end
defp tree_entries_to_dir_cache_entries(repository, tree_entries, prefix, acc)
defp tree_entries_to_dir_cache_entries(_repository, [], _prefix, acc), do: {:ok, acc}
defp tree_entries_to_dir_cache_entries(
repository,
[%{mode: 0o040000, object_id: object_id, name: name} = _tree_entry | tail],
prefix,
acc
) do
case tree_to_dir_cache_entries(repository, object_id, append_to_prefix(prefix, name), acc) do
{:ok, acc} ->
tree_entries_to_dir_cache_entries(repository, tail, prefix, acc)
{:error, reason} ->
{:error, reason}
end
end
defp tree_entries_to_dir_cache_entries(
repository,
[%{mode: mode, object_id: object_id, name: name} = _tree_entry | tail],
prefix,
acc
) do
dir_cache_entry = %DirCacheEntry{
name: append_to_prefix(prefix, name),
stage: 0,
object_id: object_id,
mode: mode,
size: 0,
ctime: 0,
mtime: 0
}
tree_entries_to_dir_cache_entries(repository, tail, prefix, [dir_cache_entry | acc])
end
defp append_to_prefix('', name), do: name
defp append_to_prefix(prefix, name), do: '#{prefix}/#{name}'
@typedoc ~S"""
Reason codes that can be returned by `write_tree/2`.
"""
@type write_tree_reason ::
:incomplete_merge
| :objects_missing
| :prefix_not_found
| DirCache.to_tree_objects_reason()
| ParseIndexFile.from_iodevice_reason()
| Repository.put_loose_object_reason()
@doc ~S"""
Translates the current dir cache, as reflected in its index file, to one or more
tree objects.
The working tree must be in a fully-merged state.
## Options
`:missing_ok?`: `true` to ignore any objects that are referenced by the index
file that are not present in the object database. Normally this would be an error.
`:prefix`: (`Xgit.Core.FilePath`) if present, returns the `object_id` for the tree at
the given subdirectory. If not present, writes a tree corresponding to the root.
(The entire tree is written in either case.)
## Return Value
`{:ok, object_id}` with the object ID for the tree that was generated. (If the exact tree
specified by the index already existed, it will return that existing tree's ID.)
`{:error, :incomplete_merge}` if any entry in the index file is not fully merged.
`{:error, :objects_missing}` if any of the objects referenced by the index
are not present in the object store. (Exception: If `missing_ok?` is `true`,
then this condition will be ignored.)
`{:error, :prefix_not_found}` if `prefix` was specified, but that prefix is not referenced
in the index file.
Reason codes may also come from the following functions:
* `Xgit.Core.DirCache.to_tree_objects/2`
* `Xgit.Repository.put_loose_object/2`
* `Xgit.Repository.WorkingTree.ParseIndexFile.from_iodevice/1`
"""
@spec write_tree(working_tree :: t, missing_ok?: boolean, prefix: FilePath.t()) ::
{:ok, object_id :: ObjectId.t()} | {:error, reason :: write_tree_reason}
def write_tree(working_tree, opts \\ []) when is_pid(working_tree) do
{missing_ok?, prefix} = validate_write_tree_options(opts)
GenServer.call(working_tree, {:write_tree, missing_ok?, prefix})
end
defp validate_write_tree_options(opts) do
missing_ok? = Keyword.get(opts, :missing_ok?, false)
unless is_boolean(missing_ok?) do
raise ArgumentError,
"Xgit.Repository.WorkingTree.write_tree/2: missing_ok? #{inspect(missing_ok?)} is invalid"
end
prefix = Keyword.get(opts, :prefix, [])
unless prefix == [] or FilePath.valid?(prefix) do
raise ArgumentError,
"Xgit.Repository.WorkingTree.write_tree/2: prefix #{inspect(prefix)} is invalid (should be a charlist, not a String)"
end
{missing_ok?, prefix}
end
defp handle_write_tree(
missing_ok?,
prefix,
%{repository: repository, index_path: index_path} = state
) do
with {:ok, %DirCache{entries: entries} = dir_cache} <- parse_index_file_if_exists(index_path),
{:merged?, true} <- {:merged?, DirCache.fully_merged?(dir_cache)},
{:has_all_objects?, true} <-
{:has_all_objects?, has_all_objects?(repository, entries, missing_ok?)},
{:ok, objects, %Object{id: object_id}} <- DirCache.to_tree_objects(dir_cache, prefix),
:ok <- write_all_objects(repository, objects) do
cover {:reply, {:ok, object_id}, state}
else
{:error, reason} -> cover {:reply, {:error, reason}, state}
{:merged?, false} -> cover {:reply, {:error, :incomplete_merge}, state}
{:has_all_objects?, false} -> cover {:reply, {:error, :objects_missing}, state}
end
end
defp has_all_objects?(repository, entries, missing_ok?)
defp has_all_objects?(_repository, _entries, true), do: cover(true)
defp has_all_objects?(repository, entries, false) do
entries
|> Enum.chunk_every(100)
|> Enum.all?(fn entries_chunk ->
Repository.has_all_object_ids?(
repository,
Enum.map(entries_chunk, fn %{object_id: id} -> id end)
)
end)
end
defp write_all_objects(repository, objects)
defp write_all_objects(_repository, []), do: cover(:ok)
defp write_all_objects(repository, [object | tail]) do
case Repository.put_loose_object(repository, object) do
:ok -> write_all_objects(repository, tail)
{:error, :object_exists} -> write_all_objects(repository, tail)
{:error, reason} -> cover {:error, reason}
end
end
defp parse_index_file_if_exists(index_path) do
with true <- File.exists?(index_path),
{:ok, iodevice} when is_pid(iodevice) <- TrailingHashDevice.open_file(index_path) do
res = ParseIndexFile.from_iodevice(iodevice)
:ok = File.close(iodevice)
res
else
false -> cover {:ok, DirCache.empty()}
{:error, reason} -> cover {:error, reason}
end
end
defp write_index_file(dir_cache, index_path) do
with {:ok, iodevice}
when is_pid(iodevice) <- TrailingHashDevice.open_file_for_write(index_path),
:ok <- WriteIndexFile.to_iodevice(dir_cache, iodevice),
:ok <- File.close(iodevice) do
:ok
else
{:error, reason} -> {:error, reason}
end
end
@impl true
def handle_call(:valid_working_tree?, _from, state), do: {:reply, :valid_working_tree, state}
def handle_call(:dir_cache, _from, state), do: handle_dir_cache(state)
def handle_call(:reset_dir_cache, _from, state), do: handle_reset_dir_cache(state)
def handle_call({:update_dir_cache, add, remove}, _from, state),
do: handle_update_dir_cache(add, remove, state)
def handle_call({:read_tree, object_id, missing_ok?}, _from, state),
do: handle_read_tree(object_id, missing_ok?, state)
def handle_call({:write_tree, missing_ok?, prefix}, _from, state),
do: handle_write_tree(missing_ok?, prefix, state)
def handle_call(message, _from, state) do
Logger.warn("WorkingTree received unrecognized call #{inspect(message)}")
{:reply, {:error, :unknown_message}, state}
end
@impl true
def handle_info({:DOWN, _ref, :process, _object, reason}, state), do: {:stop, reason, state}
end