Packages
fnord
0.9.40
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/store/project/commit_index.ex
defmodule Store.Project.CommitIndex do
@moduledoc """
Manages semantic index data for git commits within a project.
Commit index data lives alongside the other project-scoped semantic indexes,
but under its own root so commit entries remain isolated from file and
conversation storage.
"""
alias Store.Project
alias Store.Project.CommitDocument
@type metadata :: %{optional(String.t()) => any}
@type commit_record :: %{
sha: String.t(),
parent_shas: [String.t()],
subject: String.t(),
body: String.t(),
author: String.t(),
committed_at: String.t() | DateTime.t() | non_neg_integer(),
changed_files: [String.t()],
diffstat: String.t() | [map()],
embedding_model: String.t() | nil,
last_indexed_ts: non_neg_integer()
}
@type commit_status :: %{
new: [commit_record()],
stale: [commit_record()],
deleted: [String.t()]
}
@index_dir "commits/index"
@embeddings_filename "embeddings.json"
@metadata_filename "metadata.json"
@spec root(Project.t()) :: String.t()
def root(%Project{store_path: store_path}) do
Path.join(store_path, @index_dir)
end
@spec path_for(Project.t(), String.t()) :: String.t()
def path_for(project, sha) do
project
|> root()
|> Path.join(sha)
end
@doc """
Classifies commit index entries into `new`, `stale`, and `deleted`.
A commit is stale when the stored embedding model, document version, or
canonical commit document hash differs from the current values.
"""
@spec index_status(Project.t()) :: commit_status()
def index_status(%Project{} = project) do
source_commits = list_source_commits(project)
indexed_shas =
project
|> root()
|> Path.join("*/#{@metadata_filename}")
|> Path.wildcard()
|> Enum.map(&Path.dirname/1)
|> Enum.map(&Path.basename/1)
|> MapSet.new()
source_by_sha = Map.new(source_commits, &{&1.sha, &1})
source_shas = MapSet.new(Map.keys(source_by_sha))
new_shas = MapSet.difference(source_shas, indexed_shas)
deleted_shas = MapSet.difference(indexed_shas, source_shas)
stale_commits =
source_commits
|> Enum.filter(fn commit ->
case read_metadata(project, commit.sha) do
{:ok, metadata} -> stale_commit?(commit, metadata)
_ -> false
end
end)
%{
new: Enum.map(new_shas, &Map.fetch!(source_by_sha, &1)),
stale: stale_commits,
deleted: MapSet.to_list(deleted_shas)
}
end
@doc """
Writes embeddings and metadata for a commit.
The embeddings are stored in `embeddings.json` and the metadata in
`metadata.json` under the commit's index directory.
"""
@spec write_embeddings(Project.t(), String.t(), any, metadata()) :: :ok | {:error, term()}
def write_embeddings(%Project{} = project, sha, embeddings, metadata) do
dir = path_for(project, sha)
with :ok <- File.mkdir_p(dir),
:ok <- write_json(Path.join(dir, @embeddings_filename), embeddings),
:ok <- write_json(Path.join(dir, @metadata_filename), metadata) do
:ok
end
end
@doc """
Reads embeddings and metadata for a commit.
"""
@spec read_embeddings(Project.t(), String.t()) ::
{:ok, %{embeddings: any, metadata: metadata()}} | {:error, term()}
def read_embeddings(%Project{} = project, sha) do
dir = path_for(project, sha)
embeddings_path = Path.join(dir, @embeddings_filename)
metadata_path = Path.join(dir, @metadata_filename)
with {:ok, embeddings} <- read_json(embeddings_path),
{:ok, metadata} <- read_json(metadata_path) do
{:ok, %{embeddings: embeddings, metadata: metadata}}
end
end
@doc """
Reads only the metadata for a commit index entry.
"""
@spec read_metadata(Project.t(), String.t()) :: {:ok, metadata()} | {:error, term()}
def read_metadata(%Project{} = project, sha) do
project
|> path_for(sha)
|> Path.join(@metadata_filename)
|> read_json()
end
@doc """
Enumerates all indexed commits, yielding `{sha, embeddings, metadata}`.
"""
@spec all_embeddings(Project.t()) :: Enumerable.t()
def all_embeddings(%Project{} = project) do
project
|> root()
|> Path.join("*/#{@embeddings_filename}")
|> Path.wildcard()
|> Stream.map(fn path ->
sha = path |> Path.dirname() |> Path.basename()
case {read_json(path), read_metadata(project, sha)} do
{{:ok, embeddings}, {:ok, metadata}} -> {sha, embeddings, metadata}
_ -> nil
end
end)
|> Stream.reject(&is_nil/1)
end
@doc """
Deletes the index entry for the given commit SHA.
"""
@spec delete(Project.t(), String.t()) :: :ok
def delete(%Project{} = project, sha) do
project
|> path_for(sha)
|> File.rm_rf!()
:ok
end
@doc """
Builds the canonical commit document and metadata payload used for indexing.
"""
@spec build_metadata(%{
sha: binary(),
parent_shas: [binary()],
subject: binary(),
body: binary(),
author: binary(),
committed_at: binary() | non_neg_integer() | DateTime.t(),
changed_files: [binary()],
diffstat: binary() | [map()]
}) :: %{document: binary(), metadata: metadata()}
def build_metadata(commit) do
{document, doc_hash} = CommitDocument.build(commit)
metadata = %{
"sha" => commit.sha,
"parent_shas" => commit.parent_shas,
"subject" => commit.subject,
"body" => commit.body,
"author" => commit.author,
"committed_at" => commit.committed_at,
"changed_files" => commit.changed_files,
"diffstat" => commit.diffstat,
"embedding_model" => commit.embedding_model,
"index_format_version" => CommitDocument.version(),
"doc_hash" => doc_hash,
"last_indexed_ts" => commit.last_indexed_ts
}
%{document: document, metadata: metadata}
end
defp stale_commit?(commit, metadata) do
%{metadata: current_metadata} = build_metadata(commit)
metadata["embedding_model"] != current_metadata["embedding_model"] or
metadata["index_format_version"] != current_metadata["index_format_version"] or
metadata["doc_hash"] != current_metadata["doc_hash"]
end
@doc """
Returns true when the given commit still needs indexing in this project -
either the index entry is missing, or its metadata indicates a model /
format / doc-hash mismatch with the current commit payload.
Used by foreground indexers to re-check freshness inside a per-commit
lock: if a parallel indexer already wrote the entry, there's no reason
to pay the embed cost a second time.
"""
@spec stale?(Project.t(), commit_record()) :: boolean
def stale?(%Project{} = project, commit) do
case read_metadata(project, commit.sha) do
{:ok, metadata} -> stale_commit?(commit, metadata)
{:error, _} -> true
end
end
@spec list_source_commits(Project.t()) :: [map()]
defp list_source_commits(%Project{} = project) do
# Commit enumeration is anchored to the same ref as file enumeration
# (Store.Project.Source.default_branch/1), so files and commits describe
# the same tree. When default branch resolution fails - detached HEAD,
# no configured remote HEAD - fall back to HEAD so commit indexing still
# makes progress rather than silently returning an empty list.
case git_root(project) do
nil ->
[]
root ->
ref = Store.Project.Source.default_branch(project) || "HEAD"
git_commits(root, ref)
end
end
@spec git_root(Project.t()) :: String.t() | nil
defp git_root(%Project{source_root: nil}), do: nil
defp git_root(%Project{} = project) do
if GitCli.is_git_repo_at?(project.source_root) do
project.source_root
else
nil
end
end
# Commit enumeration goes through the GitCli facade: git subprocess
# mechanics (and their output parsing) live in GitCli.Default, while this
# module owns candidate assembly - model stamping, drop logging, and the
# async fan-out over the SHA list.
@spec git_commits(String.t(), String.t()) :: [map()]
defp git_commits(root, ref) do
shas =
case GitCli.commit_shas(root, ref) do
{:ok, shas} -> shas
_ -> []
end
shas
|> Util.async_stream(fn sha ->
case GitCli.commit_meta(root, sha) do
{:ok, meta} ->
with {:ok, {changed_files, diffstat}} <- GitCli.commit_numstat(root, sha) do
%{
sha: meta.sha,
parent_shas: meta.parent_shas,
author: meta.author,
committed_at: meta.committed_at,
subject: meta.subject,
body: meta.body,
changed_files: changed_files,
diffstat: diffstat,
embedding_model: AI.Embeddings.model_name(),
last_indexed_ts: 0
}
else
{:error, reason} ->
# Changed-file enumeration failed for a commit whose metadata
# parsed. Drop it but leave a trace - the SHA not appearing in
# the index later would otherwise be inscrutable.
UI.warn(
"[commit index] failed to read numstat for #{String.slice(sha, 0, 12)}: " <>
inspect(reason)
)
nil
end
{:error, reason} ->
# Metadata parse failed. The most common cause is a literal
# \x1f byte in a subject or body, which collides with our field
# separator; less common causes include refs we cannot fetch.
# Either way, "commit vanishes from the index with no log line"
# is the wrong failure mode - surface the drop.
UI.warn(
"[commit index] dropping #{String.slice(sha, 0, 12)}: " <>
inspect(reason)
)
nil
end
end)
|> Enum.reduce([], fn
{:ok, nil}, acc -> acc
{:ok, commit}, acc -> [commit | acc]
end)
|> Enum.reverse()
end
@spec write_json(String.t(), any) :: :ok | {:error, term()}
defp write_json(path, data) do
case SafeJson.encode(data) do
{:ok, json} -> File.write(path, json)
error -> error
end
end
@spec read_json(String.t()) :: {:ok, any} | {:error, term()}
defp read_json(path) do
case File.read(path) do
{:ok, contents} -> SafeJson.decode(contents)
error -> error
end
end
end