langstats
a18a296 · gmorell · 2026-05-14 01:46
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