langstats

a18a296 · gmorell · 2026-05-14 01:46

11 files +803 -0

Files changed

added lib/git_gud/language_colors.ex
+102 −0
@@ -0,0 +1,102 @@
1 +defmodule GitGud.LanguageColors do
2 + @moduledoc """
3 + Small palette mapping detected language names to hex colors for the
4 + repo language-stats bar + detail page.
5 +
6 + Sourced from github/linguist's `colors.yml` for the languages we
7 + actually see come out of Lumis detection. Unknowns fall back to a
8 + deterministic hash-based color so each language is at least
9 + consistently rendered across pages.
10 + """
11 +
12 + # Curated subset — extend as Lumis detection grows. Keys are
13 + # case-sensitive matches against `git_blob_meta.detected_language`.
14 + @palette %{
15 + "Ada" => "#02f88c",
16 + "Assembly" => "#6E4C13",
17 + "Bash" => "#89e051",
18 + "C" => "#555555",
19 + "C#" => "#178600",
20 + "C++" => "#f34b7d",
21 + "CSS" => "#563d7c",
22 + "Clojure" => "#db5855",
23 + "CoffeeScript" => "#244776",
24 + "Crystal" => "#000100",
25 + "Dart" => "#00B4AB",
26 + "Dockerfile" => "#384d54",
27 + "Elixir" => "#6e4a7e",
28 + "Elm" => "#60B5CC",
29 + "Erlang" => "#B83998",
30 + "F#" => "#b845fc",
31 + "Go" => "#00ADD8",
32 + "GraphQL" => "#e10098",
33 + "HCL" => "#844FBA",
34 + "HTML" => "#e34c26",
35 + "Haskell" => "#5e5086",
36 + "Java" => "#b07219",
37 + "JavaScript" => "#f1e05a",
38 + "JSON" => "#292929",
39 + "Julia" => "#a270ba",
40 + "Kotlin" => "#A97BFF",
41 + "LaTeX" => "#3D6117",
42 + "Lua" => "#000080",
43 + "Makefile" => "#427819",
44 + "Markdown" => "#083fa1",
45 + "Nim" => "#ffc200",
46 + "Nix" => "#7e7eff",
47 + "OCaml" => "#3be133",
48 + "PHP" => "#4F5D95",
49 + "Perl" => "#0298c3",
50 + "PowerShell" => "#012456",
51 + "Python" => "#3572A5",
52 + "R" => "#198CE7",
53 + "Raku" => "#0000fb",
54 + "Ruby" => "#701516",
55 + "Rust" => "#dea584",
56 + "SCSS" => "#c6538c",
57 + "SQL" => "#e38c00",
58 + "Scala" => "#c22d40",
59 + "Shell" => "#89e051",
60 + "Solidity" => "#AA6746",
61 + "Svelte" => "#ff3e00",
62 + "Swift" => "#F05138",
63 + "TOML" => "#9c4221",
64 + "TypeScript" => "#3178c6",
65 + "Vim Script" => "#199f4b",
66 + "Vue" => "#41b883",
67 + "WebAssembly" => "#04133b",
68 + "YAML" => "#cb171e",
69 + "Zig" => "#ec915c"
70 + }
71 +
72 + @doc "Look up a language's color, or derive one from its name."
73 + @spec for(String.t() | nil) :: String.t()
74 + def for(nil), do: "#888888"
75 + def for(""), do: "#888888"
76 +
77 + def for(language) when is_binary(language) do
78 + case Map.get(@palette, language) do
79 + nil -> hash_color(language)
80 + hex -> hex
81 + end
82 + end
83 +
84 + @doc false
85 + def palette, do: @palette
86 +
87 + # Deterministic muted color from a phash of the language name. Avoids
88 + # extremes (too-dark / neon) so the bar stays legible.
89 + defp hash_color(s) do
90 + <<r, g, b, _::binary>> = :crypto.hash(:sha256, s)
91 + # Pull each channel toward mid-tones so unknown-language fallbacks
92 + # don't look out of place next to the curated palette.
93 + r2 = mid(r)
94 + g2 = mid(g)
95 + b2 = mid(b)
96 + "#" <> hex(r2) <> hex(g2) <> hex(b2)
97 + end
98 +
99 + defp mid(byte), do: 64 + rem(byte, 144)
100 +
101 + defp hex(v), do: v |> Integer.to_string(16) |> String.pad_leading(2, "0") |> String.downcase()
102 +end
modified lib/git_gud/repositories.ex
+115 −0
@@ -18,6 +18,7 @@ defmodule GitGud.Repositories do
18 18 alias GitGud.Repositories.GitBlobMeta
19 19 alias GitGud.Repositories.GitCommit
20 20 alias GitGud.Repositories.GitCommitDiffstat
21 + alias GitGud.Repositories.GitCommitLanguageStats
21 22 alias GitGud.Repositories.GitRef
22 23 alias GitGud.Repositories.GitTree
23 24 alias GitGud.Repositories.Repository
@@ -788,6 +789,120 @@ defmodule GitGud.Repositories do
788 789 Repo.insert_all(GitCommitDiffstat, [row], on_conflict: :nothing)
789 790 end
790 791
792 + # ── language stats ───────────────────────────────────────────────────
793 +
794 + @doc """
795 + Look up per-language byte/file breakdown for a commit. Cache-only
796 + returns `:not_computed` if the worker hasn't filled in this commit
797 + yet. Callers should enqueue `GitGud.Repositories.Workers.LanguageStats`
798 + when they get `:not_computed` and re-check next render.
799 + """
800 + def get_language_stats(%Repository{id: rid}, sha) when is_binary(sha) do
801 + case Repo.get_by(GitCommitLanguageStats, repository_id: rid, sha: sha) do
802 + nil -> :not_computed
803 + row -> {:ok, decode_language_stats(row)}
804 + end
805 + end
806 +
807 + @doc """
808 + Walk the tree at `sha` and bucket blob bytes by `detected_language`.
809 + Writes the result into `git_commit_language_stats`. Synchronous
810 + called from the Oban worker.
811 + """
812 + def compute_and_store_language_stats(%Repository{id: rid} = repo, sha) when is_binary(sha) do
813 + with {:ok, commit} <- get_commit(repo, sha) do
814 + buckets = walk_language_buckets(repo, commit.tree_sha, %{})
815 +
816 + items =
817 + buckets
818 + |> Enum.map(fn {lang, %{bytes: b, files: f}} ->
819 + %{"language" => lang, "bytes" => b, "files" => f}
820 + end)
821 + |> Enum.sort_by(&(-&1["bytes"]))
822 +
823 + total_bytes = Enum.reduce(items, 0, &(&1["bytes"] + &2))
824 + file_count = Enum.reduce(items, 0, &(&1["files"] + &2))
825 +
826 + Repo.insert_all(
827 + GitCommitLanguageStats,
828 + [
829 + %{
830 + repository_id: rid,
831 + sha: sha,
832 + languages: %{"items" => items},
833 + total_bytes: total_bytes,
834 + file_count: file_count,
835 + computed_at: DateTime.utc_now(:second)
836 + }
837 + ],
838 + on_conflict: {:replace, [:languages, :total_bytes, :file_count, :computed_at]},
839 + conflict_target: [:repository_id, :sha]
840 + )
841 +
842 + {:ok,
843 + %{
844 + languages: items |> Enum.map(&decode_language_item/1),
845 + total_bytes: total_bytes,
846 + file_count: file_count
847 + }}
848 + end
849 + end
850 +
851 + defp walk_language_buckets(repo, tree_sha, acc) do
852 + case get_tree(repo, tree_sha) do
853 + {:ok, entries} ->
854 + Enum.reduce(entries, acc, fn entry, inner ->
855 + case entry.type do
856 + "tree" -> walk_language_buckets(repo, entry.sha, inner)
857 + "blob" -> bucket_blob(repo, entry, inner)
858 + _ -> inner
859 + end
860 + end)
861 +
862 + _ ->
863 + acc
864 + end
865 + end
866 +
867 + defp bucket_blob(repo, %{sha: sha, size: size}, acc) do
868 + case get_blob_meta(repo, sha) do
869 + {:ok, %{detected_language: lang, is_binary: false}}
870 + when is_binary(lang) and lang != "" ->
871 + Map.update(
872 + acc,
873 + lang,
874 + %{bytes: size || 0, files: 1},
875 + fn %{bytes: b, files: f} -> %{bytes: b + (size || 0), files: f + 1} end
876 + )
877 +
878 + _ ->
879 + acc
880 + end
881 + end
882 +
883 + defp decode_language_stats(%GitCommitLanguageStats{} = row) do
884 + items =
885 + case row.languages do
886 + %{"items" => items} when is_list(items) -> items
887 + _ -> []
888 + end
889 +
890 + %{
891 + languages: Enum.map(items, &decode_language_item/1),
892 + total_bytes: row.total_bytes,
893 + file_count: row.file_count,
894 + computed_at: row.computed_at
895 + }
896 + end
897 +
898 + defp decode_language_item(%{} = item) do
899 + %{
900 + language: item["language"],
901 + bytes: item["bytes"] || 0,
902 + files: item["files"] || 0
903 + }
904 + end
905 +
791 906 # ── decoders ─────────────────────────────────────────────────────────
792 907
793 908 defp commit_row_to_map(%GitCommit{} = c) do
added lib/git_gud/repositories/git_commit_language_stats.ex
+20 −0
@@ -0,0 +1,20 @@
1 +defmodule GitGud.Repositories.GitCommitLanguageStats do
2 + use Ecto.Schema
3 +
4 + alias GitGud.Repositories.Repository
5 +
6 + @primary_key false
7 + schema "git_commit_language_stats" do
8 + field :sha, :binary, primary_key: true
9 +
10 + # Wrapped in a map keyed by "items" to dodge ecto's top-level-array
11 + # JSONB quirks. Each item is %{"language", "bytes", "files"}, sorted
12 + # desc by bytes.
13 + field :languages, :map, default: %{"items" => []}
14 + field :total_bytes, :integer, default: 0
15 + field :file_count, :integer, default: 0
16 + field :computed_at, :utc_datetime
17 +
18 + belongs_to :repository, Repository, primary_key: true
19 + end
20 +end
modified lib/git_gud/repositories/workers/commit_backfill.ex
+14 −0
@@ -41,11 +41,25 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
41 41 dispatch_workflows(repo, ref, new_sha)
42 42 publish_push(repo, ref, old_sha, new_sha)
43 43 refresh_cross_repo_prs(repo, ref, new_sha)
44 + enqueue_language_stats(repo, ref, new_sha)
44 45 :ok
45 46 end
46 47 end
47 48 end
48 49
50 + defp enqueue_language_stats(repo, "refs/heads/" <> _branch, new_sha) do
51 + _ =
52 + GitGud.Repositories.Workers.LanguageStats.new_job(%{
53 + repository_id: repo.id,
54 + sha: new_sha
55 + })
56 + |> Oban.insert()
57 +
58 + :ok
59 + end
60 +
61 + defp enqueue_language_stats(_repo, _ref, _sha), do: :ok
62 +
49 63 defp refresh_cross_repo_prs(repo, ref, new_sha) do
50 64 branch =
51 65 case ref do
added lib/git_gud/repositories/workers/language_stats.ex
+46 −0
@@ -0,0 +1,46 @@
1 +defmodule GitGud.Repositories.Workers.LanguageStats do
2 + @moduledoc """
3 + Computes per-language byte/file breakdown for a commit and stores it
4 + in `git_commit_language_stats`.
5 +
6 + Enqueued from:
7 + * `CommitBackfill` — when a new HEAD lands on a branch.
8 + * `RepoLive.Show` — lazily when a viewer hits a commit that's never
9 + been computed (e.g., legacy commits from before this slice).
10 +
11 + Idempotent — `unique` on `(repository_id, sha)` so duplicate enqueues
12 + collapse, and the writer uses `on_conflict: :replace` so a re-run
13 + overwrites stale numbers if the underlying caches changed.
14 + """
15 +
16 + use Oban.Worker,
17 + queue: :git_cache,
18 + max_attempts: 3,
19 + unique: [
20 + fields: [:worker, :args],
21 + keys: [:repository_id, :sha_hex],
22 + period: :infinity,
23 + states: [:available, :scheduled, :executing]
24 + ]
25 +
26 + alias GitGud.Git
27 + alias GitGud.Repositories
28 +
29 + @impl Oban.Worker
30 + def perform(%Oban.Job{args: %{"repository_id" => rid, "sha_hex" => hex}}) do
31 + repo = Repositories.get_repository!(rid)
32 +
33 + with {:ok, sha} <- Git.from_hex(hex),
34 + {:ok, _} <- Repositories.compute_and_store_language_stats(repo, sha) do
35 + :ok
36 + end
37 + end
38 +
39 + @doc "Build a job for `(repo, sha_binary)`."
40 + def new_job(%{repository_id: rid, sha: sha}) when is_binary(sha) do
41 + __MODULE__.new(%{
42 + "repository_id" => rid,
43 + "sha_hex" => Git.to_hex(sha)
44 + })
45 + end
46 +end
added lib/git_gud_web/live/repo_live/languages.ex
+152 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/show.ex
+91 −0

Click to load diff…

modified lib/git_gud_web/router.ex
+1 −0

Click to load diff…

added priv/repo/migrations/20260513290000_create_git_commit_language_stats.exs
+21 −0

Click to load diff…

added test/git_gud/language_colors_test.exs
+25 −0

Click to load diff…

added test/git_gud/repositories_language_stats_test.exs
+216 −0

Click to load diff…

Parents: 71effab