Packages

A model management framework supporting multi-layered abstraction structures, pluggable data layers, and arbitrary command-line transform tools.

Current section

Files

Jump to
paradigm lib graph impl git_repo_graph.ex
Raw

lib/graph/impl/git_repo_graph.ex

defmodule Paradigm.Graph.GitRepoGraph do
@moduledoc """
A graph implementation that adapts git repos to graph nodes.
Uses the GitRepo canonical model to represent repositories, commits, branches, and tags as nodes.
"""
defstruct [:root, :metadata, :current_revision]
def new(opts \\ []) do
root = Keyword.get(opts, :root, "/")
current_revision = Keyword.get(opts, :current_revision, "HEAD")
%__MODULE__{
root: Path.expand(root),
current_revision: current_revision,
metadata: Keyword.take(opts, [:name, :description])
}
end
def new(root_path, opts) when is_binary(root_path) and is_list(opts) do
current_revision = Keyword.get(opts, :current_revision, "HEAD")
%__MODULE__{
root: Path.expand(root_path),
current_revision: current_revision,
metadata: Keyword.take(opts, [:name, :description])
}
end
end
defimpl Paradigm.Graph, for: Paradigm.Graph.GitRepoGraph do
alias Paradigm.Graph.Node
@impl true
def get_name(%{metadata: metadata}) do
Keyword.get(metadata, :name)
end
@impl true
def get_description(%{metadata: metadata}) do
Keyword.get(metadata, :description)
end
@impl true
def get_content_hash(%{root: root_path}) do
case run_git_cmd(["rev-parse", "HEAD"], root_path) do
{output, 0} ->
# Use the current HEAD commit hash as the primary content identifier
head_hash = String.trim(output)
# Also include branch and tag state for a more complete hash
branches_hash = get_all_branches(root_path) |> Enum.sort() |> :erlang.term_to_binary()
tags_hash = get_all_tags(root_path) |> Enum.sort() |> :erlang.term_to_binary()
combined = head_hash <> Base.encode16(:crypto.hash(:sha256, branches_hash <> tags_hash))
Base.encode16(:crypto.hash(:sha256, combined))
|> String.slice(-8..-1)
_ ->
# Fallback for non-git repos or repos without commits
repo_state = %{
branches: get_all_branches(root_path),
tags: get_all_tags(root_path),
path: root_path
}
Base.encode16(:crypto.hash(:sha256, :erlang.term_to_binary(repo_state)))
|> String.slice(-8..-1)
end
end
@impl true
def get_all_nodes(%{root: root_path}) do
case is_git_repo?(root_path) do
true -> collect_all_git_nodes(root_path)
false -> []
end
end
@impl true
def stream_all_nodes(%{root: root_path} = git_graph) do
case is_git_repo?(root_path) do
true ->
Stream.concat([
# Repository node
["repository"]
|> Stream.map(&get_node(git_graph, &1)),
# Stream commits
get_all_commit_hashes(root_path)
|> Stream.map(&("commit:" <> &1))
|> Stream.map(&get_node(git_graph, &1)),
# Stream branches
get_all_branches(root_path)
|> Stream.map(&("branch:" <> &1))
|> Stream.map(&get_node(git_graph, &1)),
# Stream tags
get_all_tags(root_path)
|> Stream.map(&("tag:" <> &1))
|> Stream.map(&get_node(git_graph, &1))
])
|> Stream.reject(&is_nil/1)
false ->
[]
|> Stream.map(& &1)
end
end
@impl true
def get_all_classes(_git_repo_graph) do
["repository", "commit", "branch", "tag"]
end
@impl true
def get_node(%{root: root_path}, node_id) do
cond do
node_id == "repository" ->
%Node{
id: node_id,
class: "repository",
data: build_repository_data(root_path),
owned_by: nil
}
String.starts_with?(node_id, "commit:") ->
commit_hash = String.replace_prefix(node_id, "commit:", "")
case get_commit_info(root_path, commit_hash) do
nil ->
nil
commit_info ->
%Node{
id: node_id,
class: "commit",
data: build_commit_data(root_path, commit_info),
owned_by: "repository"
}
end
String.starts_with?(node_id, "branch:") ->
branch_name = String.replace_prefix(node_id, "branch:", "")
case get_branch_info(root_path, branch_name) do
nil ->
nil
branch_info ->
%Node{
id: node_id,
class: "branch",
data: build_branch_data(branch_info),
owned_by: "repository"
}
end
String.starts_with?(node_id, "tag:") ->
tag_name = String.replace_prefix(node_id, "tag:", "")
case get_tag_info(root_path, tag_name) do
nil ->
nil
tag_info ->
%Node{
id: node_id,
class: "tag",
data: build_tag_data(tag_info),
owned_by: "repository"
}
end
true ->
nil
end
end
@impl true
def get_all_nodes_of_class(git_graph, class_id) when is_binary(class_id) do
get_all_nodes_of_class(git_graph, [class_id])
end
@impl true
def get_all_nodes_of_class(%{root: root_path}, class_ids) when is_list(class_ids) do
all_nodes = collect_all_git_nodes(root_path)
Enum.filter(all_nodes, fn node_id ->
node = get_node(%{root: root_path}, node_id)
node && node.class in class_ids
end)
end
@impl true
def insert_node(%{root: root_path} = git_graph, %Node{
id: node_id,
class: class_id,
data: _node_data
}) do
case class_id do
"repository" ->
# Initialize git repository
run_git_cmd(["init"], root_path)
"commit" ->
{:error, "Cannot insert commits directly"}
"branch" ->
branch_name = String.replace_prefix(node_id, "branch:", "")
run_git_cmd(["checkout", "-b", branch_name], root_path)
"tag" ->
tag_name = String.replace_prefix(node_id, "tag:", "")
run_git_cmd(["tag", tag_name], root_path)
_ ->
{:error, "Unsupported class: #{class_id}"}
end
git_graph
end
@impl true
def insert_nodes(git_graph, nodes) when is_list(nodes) do
Enum.reduce(nodes, git_graph, fn node, acc ->
insert_node(acc, node)
end)
end
@impl true
def follow_reference(%{root: root_path} = git_graph, node_id, reference_property) do
case {node_id, reference_property} do
{"repository", "branches"} ->
get_all_nodes_of_class(git_graph, "branch")
|> Enum.map(&get_node(git_graph, &1))
|> Enum.reject(&is_nil/1)
{"repository", "tags"} ->
get_all_nodes_of_class(git_graph, "tag")
|> Enum.map(&get_node(git_graph, &1))
|> Enum.reject(&is_nil/1)
{"repository", "commits"} ->
get_all_nodes_of_class(git_graph, "commit")
|> Enum.map(&get_node(git_graph, &1))
|> Enum.reject(&is_nil/1)
{branch_id, "head"} ->
if String.starts_with?(branch_id, "branch:") do
branch_name = String.replace_prefix(branch_id, "branch:", "")
case get_branch_head(root_path, branch_name) do
nil -> nil
commit_hash -> get_node(git_graph, "commit:#{commit_hash}")
end
else
nil
end
{tag_id, "commit"} ->
if String.starts_with?(tag_id, "tag:") do
tag_name = String.replace_prefix(tag_id, "tag:", "")
case get_tag_commit(root_path, tag_name) do
nil -> nil
commit_hash -> get_node(git_graph, "commit:#{commit_hash}")
end
else
nil
end
{commit_id, "parents"} ->
if String.starts_with?(commit_id, "commit:") do
commit_hash = String.replace_prefix(commit_id, "commit:", "")
get_commit_parents(root_path, commit_hash)
|> Enum.map(&get_node(git_graph, "commit:#{&1}"))
|> Enum.reject(&is_nil/1)
else
nil
end
_ ->
nil
end
end
# Private helper functions
defp run_git_cmd(args, root_path) do
System.cmd("git", args, cd: root_path, stderr_to_stdout: true)
end
defp is_git_repo?(root_path) do
File.exists?(Path.join(root_path, ".git"))
end
defp collect_all_git_nodes(root_path) do
nodes = ["repository"]
# Add all commits
commit_nodes =
get_all_commit_hashes(root_path)
|> Enum.map(&("commit:" <> &1))
# Add all branches
branch_nodes =
get_all_branches(root_path)
|> Enum.map(&("branch:" <> &1))
# Add all tags
tag_nodes =
get_all_tags(root_path)
|> Enum.map(&("tag:" <> &1))
nodes ++ commit_nodes ++ branch_nodes ++ tag_nodes
end
defp build_repository_data(root_path) do
repo_name = Path.basename(root_path)
%{
"name" => repo_name,
"branches" =>
get_all_branches(root_path) |> Enum.map(&%Node.Ref{id: "branch:#{&1}", composite: true}),
"tags" => get_all_tags(root_path) |> Enum.map(&%Node.Ref{id: "tag:#{&1}", composite: true}),
"commits" =>
get_all_commit_hashes(root_path)
|> Enum.map(&%Node.Ref{id: "commit:#{&1}", composite: true})
}
end
defp build_commit_data(root_path, commit_info) do
filesystem_graph = build_filesystem_graph_for_commit(root_path, commit_info.hash)
parent_refs =
get_commit_parents(root_path, commit_info.hash)
|> Enum.map(&%Node.Ref{id: "commit:#{&1}", composite: false})
%{
"hash" => commit_info.hash,
"message" => commit_info.message,
"author" => commit_info.author,
"date" => commit_info.date,
"parents" => parent_refs,
"filesystem" => filesystem_graph
}
end
defp build_branch_data(branch_info) do
head_ref = %Node.Ref{id: "commit:#{branch_info.head}", composite: false}
%{
"name" => branch_info.name,
"head" => head_ref
}
end
defp build_tag_data(tag_info) do
commit_ref = %Node.Ref{id: "commit:#{tag_info.commit}", composite: false}
%{
"name" => tag_info.name,
"commit" => commit_ref
}
end
defp get_all_commit_hashes(root_path) do
case run_git_cmd(["log", "--all", "--format=%H"], root_path) do
{output, 0} ->
output
|> String.split("\n", trim: true)
_ ->
[]
end
end
defp get_all_branches(root_path) do
case run_git_cmd(["branch", "-a", "--format=%(refname:short)"], root_path) do
{output, 0} ->
output
|> String.split("\n", trim: true)
|> Enum.reject(&String.starts_with?(&1, "origin/"))
_ ->
[]
end
end
defp get_all_tags(root_path) do
case run_git_cmd(["tag", "-l"], root_path) do
{output, 0} ->
output
|> String.split("\n", trim: true)
_ ->
[]
end
end
defp get_commit_info(root_path, commit_hash) do
case run_git_cmd(["show", "--format=%H|%s|%an|%ai", "--no-patch", commit_hash], root_path) do
{output, 0} ->
[hash, message, author, date] = String.split(String.trim(output), "|", parts: 4)
%{hash: hash, message: message, author: author, date: date}
_ ->
nil
end
end
defp get_branch_info(root_path, branch_name) do
case get_branch_head(root_path, branch_name) do
nil -> nil
head_hash -> %{name: branch_name, head: head_hash}
end
end
defp get_tag_info(root_path, tag_name) do
case get_tag_commit(root_path, tag_name) do
nil -> nil
commit_hash -> %{name: tag_name, commit: commit_hash}
end
end
defp get_branch_head(root_path, branch_name) do
case run_git_cmd(["rev-parse", branch_name], root_path) do
{output, 0} -> String.trim(output)
_ -> nil
end
end
defp get_tag_commit(root_path, tag_name) do
case run_git_cmd(["rev-list", "-n", "1", tag_name], root_path) do
{output, 0} -> String.trim(output)
_ -> nil
end
end
defp get_commit_parents(root_path, commit_hash) do
case run_git_cmd(["rev-list", "--parents", "-n", "1", commit_hash], root_path) do
{output, 0} ->
[_commit | parents] = String.split(String.trim(output), " ")
parents
_ ->
[]
end
end
defp build_filesystem_graph_for_commit(root_path, commit_hash) do
# Create a temporary directory to checkout the commit
temp_dir = System.tmp_dir!() |> Path.join("git_checkout_#{:rand.uniform(1_000_000)}")
try do
# Clone and checkout the specific commit in temp directory
case run_git_cmd(["clone", root_path, temp_dir], System.tmp_dir!()) do
{_, 0} ->
case run_git_cmd(["checkout", commit_hash], temp_dir) do
{_, 0} ->
# Create a filesystem graph for this checkout
Paradigm.Graph.FilesystemGraph.new(root: temp_dir)
_ ->
%{}
end
_ ->
%{}
end
after
File.rm_rf(temp_dir)
end
end
end