defmodule Store.Project.Source do @moduledoc """ Abstraction over "where does this project's source come from?" Git projects index the default branch's tree: enumeration comes from `git ls-tree`, content from `git show :`, and the freshness key is the git blob SHA. A user on a feature branch with WIP changes still sees the default-branch snapshot indexed, which is what the rest of the codebase (search, file_notes, reviewer) expects as its ground truth. Non-git projects fall back to the filesystem: enumeration via `find` on `source_root`, content via `File.read`, freshness via sha256 of the file contents. The mode is decided once per project (`mode/1`) and all reads/hashes go through this module so callers never branch on git-ness themselves. """ alias Store.Project @type mode :: :git | :fs @type listing_entry :: %{ rel_path: String.t(), abs_path: String.t(), hash: String.t() | nil } @doc """ Returns `:git` when the project's source_root is a git repo whose default branch can be resolved, and `:fs` otherwise. """ @spec mode(Project.t() | nil) :: mode def mode(nil), do: :fs def mode(%Project{source_root: nil}), do: :fs def mode(%Project{source_root: root}) do case GitCli.default_branch(root) do branch when is_binary(branch) -> :git _ -> :fs end end @doc """ Returns the default branch name when the project is in `:git` mode, nil otherwise. `GitCli.default_branch/1` is NOT cached today - it forks 2-4 git subprocesses per call (`symbolic-ref`, then `rev-parse` probes for main/master). Callers under async_stream should memoize if the overhead matters; `cached_ls_tree/2` in this module uses the returned branch as part of its cache key but does not cache the branch lookup itself. """ @spec default_branch(Project.t()) :: String.t() | nil def default_branch(%Project{source_root: nil}), do: nil def default_branch(%Project{source_root: root}), do: GitCli.default_branch(root) @doc """ Enumerates all source items in the project as `%{rel_path, abs_path, hash}` maps. In git mode the list reflects the default branch's tree (blob SHA as hash); in fs mode it reflects the working tree's filesystem walk (sha256 as hash, computed lazily by callers — the listing returns `hash: nil` for fs mode). Callers are responsible for applying project-level excludes and text/binary filtering; this function only answers "what source items exist?". """ @spec list(Project.t()) :: [listing_entry()] def list(project) do case mode(project) do :git -> list_git(project) :fs -> list_fs(project) end end defp list_git(project) do case cached_ls_tree(project.source_root, default_branch(project)) do {:ok, entries} -> Enum.map(entries, fn {sha, rel_path} -> %{ rel_path: rel_path, abs_path: Path.expand(rel_path, project.source_root), hash: sha } end) {:error, _} -> [] end end # ls-tree is invoked from three hot paths (list, hash, exists?), all of # which run under indexing's async_stream - that's O(N) subprocess forks # on a large project if we don't memoize. Cached for the BEAM's lifetime: # a single fnord invocation is short-lived and the branch tip won't # advance during an index run. Both successes and failures are cached - # a transient git failure shouldn't turn into O(N) retries inside the # worker fan-out. defp cached_ls_tree(root, branch) do key = cache_key(root, branch) case :persistent_term.get(key, :miss) do :miss -> result = GitCli.ls_tree(root, branch) :persistent_term.put(key, result) result cached -> cached end end defp cache_key(root, branch), do: {__MODULE__, :ls_tree, root, branch} defp list_fs(project) do # fs listing intentionally doesn't pre-compute hashes. Working-tree # hashing requires reading every file and only the entries that # pass text/exclude filters will actually need it - we let callers # drive the read via `hash/2`. case project.source_root do nil -> [] root -> args = ["-c", "(find #{root} -type f || true) | sort"] case System.cmd("sh", args, stderr_to_stdout: true) do {output, 0} -> output |> String.split("\n", trim: true) |> Enum.reject(&String.ends_with?(&1, ": Permission denied")) |> Enum.map(fn abs_path -> %{ rel_path: Path.relative_to(abs_path, root), abs_path: Path.absname(abs_path, root), hash: nil } end) _ -> [] end end end @doc """ Returns the content of `rel_path` under the project's active source. Routed to `git show` in git mode, `File.read` in fs mode. """ @spec read(Project.t() | nil, String.t()) :: {:ok, binary} | {:error, term} def read(nil, _rel_path), do: {:error, :no_project} def read(%Project{source_root: nil}, _rel_path), do: {:error, :no_source_root} def read(project, rel_path) do case mode(project) do :git -> GitCli.show_blob(project.source_root, default_branch(project), rel_path) :fs -> File.read(Path.expand(rel_path, project.source_root)) end end @doc """ Returns the freshness hash for `rel_path` under the project's active source. In git mode that's the blob SHA (fetched from ls-tree if available, else computed via `git hash-object`). In fs mode, sha256 of the file's current content. """ @spec hash(Project.t() | nil, String.t() | nil) :: {:ok, String.t()} | {:error, term} def hash(nil, _rel_path), do: {:error, :no_project} def hash(_project, nil), do: {:error, :no_rel_path} def hash(%Project{source_root: nil}, _rel_path), do: {:error, :no_source_root} def hash(project, rel_path) do case mode(project) do :git -> # Cheapest path: ask git for the blob SHA on the default branch. case cached_ls_tree(project.source_root, default_branch(project)) do {:ok, entries} -> case Enum.find(entries, fn {_sha, path} -> path == rel_path end) do {sha, _} -> {:ok, sha} nil -> {:error, :not_in_tree} end {:error, reason} -> {:error, reason} end :fs -> path = Path.expand(rel_path, project.source_root) case File.read(path) do {:ok, content} -> {:ok, sha256(content)} {:error, reason} -> {:error, reason} end end end @doc """ True when `rel_path` still exists under the project's active source. Used by `delete_missing_files` to scope deletion correctly: a file present in the working tree but not on the default branch *is* "missing" for our purposes. """ @spec exists?(Project.t() | nil, String.t() | nil) :: boolean def exists?(nil, _rel_path), do: false def exists?(_project, nil), do: false def exists?(%Project{source_root: nil}, _rel_path), do: false def exists?(project, rel_path) do case mode(project) do :git -> case cached_ls_tree(project.source_root, default_branch(project)) do {:ok, entries} -> Enum.any?(entries, fn {_sha, path} -> path == rel_path end) _ -> false end :fs -> File.exists?(Path.expand(rel_path, project.source_root)) end end defp sha256(content) do :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) end end