lib/graft/lock.ex

defmodule Graft.Lock do
  @moduledoc """
  Single-writer filesystem guard for a Graft workspace.

  A lock is an O_EXCL file at `<root>/.graft/lock`. Acquisition is
  atomic at the filesystem level — `:file.open(path, [:write, :exclusive])`
  fails with `:eexist` if any other process already holds it.

  ## Scope

  This is intentionally simple:

    * No distributed locking.
    * No retry, wait, or timeout.
    * No stale-lock detection (a crashed process leaves the lock in
      place; future milestones may add liveness checks).

  Use only to prevent two `mix graft.*` mutating commands in the same
  workspace from running concurrently on one machine.
  """

  alias Graft.Error

  @lock_dir ".graft"
  @lock_file "lock"

  @doc "Conventional lock path under `root`."
  @spec lock_path(Path.t()) :: Path.t()
  def lock_path(root), do: Path.join([root, @lock_dir, @lock_file])

  @doc """
  Acquire the workspace lock, run `fun`, and release the lock — even if
  `fun` raises or returns `{:error, _}`. Returns whatever `fun` returns,
  or `{:error, %Graft.Error{kind: :workspace_locked}}` if the lock
  could not be acquired.
  """
  @spec with_lock(Path.t(), (-> any())) :: any()
  def with_lock(root, fun) when is_binary(root) and is_function(fun, 0) do
    path = lock_path(root)

    case acquire(path) do
      :ok ->
        try do
          fun.()
        after
          _ = File.rm(path)
        end

      {:error, %Error{}} = err ->
        err
    end
  end

  @doc false
  def acquire(path) do
    with :ok <- ensure_parent(Path.dirname(path)),
         :ok <- open_exclusive(path) do
      :ok
    end
  end

  defp ensure_parent(dir) do
    case File.mkdir_p(dir) do
      :ok ->
        :ok

      {:error, reason} ->
        {:error,
         Error.new(
           :workspace_locked,
           "Failed to prepare lock directory #{dir}: #{:file.format_error(reason)}",
           %{path: dir, reason: reason, phase: :prepare}
         )}
    end
  end

  defp open_exclusive(path) do
    case :file.open(path, [:write, :exclusive, :raw]) do
      {:ok, fd} ->
        :file.write(
          fd,
          "pid=#{:os.getpid()} acquired_at=#{DateTime.utc_now() |> DateTime.to_iso8601()}\n"
        )

        :file.close(fd)
        :ok

      {:error, :eexist} ->
        {:error,
         Error.new(
           :workspace_locked,
           "Workspace already locked at #{path} — another contrib mutation is in progress",
           %{path: path, phase: :acquire}
         )}

      {:error, reason} ->
        {:error,
         Error.new(
           :workspace_locked,
           "Failed to acquire lock at #{path}: #{:file.format_error(reason)}",
           %{path: path, reason: reason, phase: :acquire}
         )}
    end
  end
end