neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Add per-file commit history
845b545 · Gabriel Morell · 2026-09-09 20:18
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud_web/live/repo_live/blob.ex
+12
−2
@@ -13,7 +13,8 @@ defmodule GitGudWeb.RepoLive.Blob do
| 13 | 13 | Repositories.get_repository_by_path!(owner, name) |
| 14 | 14 | |> GitGud.Repo.preload([:owner, :organization]) |
| 15 | 15 | |
| 16 | − path = path |> List.wrap() |> Enum.join("/") | |
| 16 | + path_segs = List.wrap(path) | |
| 17 | + path = Enum.join(path_segs, "/") | |
| 17 | 18 | |
| 18 | 19 | {:ok, head_sha} = Repositories.resolve(repo, ref) |
| 19 | 20 | {:ok, commit} = Repositories.get_commit(repo, head_sha) |
@@ -36,6 +37,7 @@ defmodule GitGudWeb.RepoLive.Blob do
| 36 | 37 | |> GitGudWeb.RepoLive.Header.assign_chrome(repo) |
| 37 | 38 | |> assign(:ref, ref) |
| 38 | 39 | |> assign(:path, path) |
| 40 | + |> assign(:path_segs, path_segs) | |
| 39 | 41 | |> assign(:blob_name, bname) |
| 40 | 42 | |> assign(:meta, meta) |
| 41 | 43 | |> assign(:body, body) |
@@ -89,7 +91,15 @@ defmodule GitGudWeb.RepoLive.Blob do
| 89 | 91 | |
| 90 | 92 | <div class="flex items-center justify-between text-xs opacity-70"> |
| 91 | 93 | <span>{format_size(@meta.size)} · {if @meta.is_binary, do: "binary", else: "text"}</span> |
| 92 | − <span class="font-mono">{Git.short(@head_sha)}</span> | |
| 94 | + <span class="flex items-center gap-3"> | |
| 95 | + <.link | |
| 96 | + navigate={~p"/r/#{@handle}/#{@repo.name}/commits/#{@ref}/#{@path_segs}"} | |
| 97 | + class="link link-hover" | |
| 98 | + > | |
| 99 | + History | |
| 100 | + </.link> | |
| 101 | + <span class="font-mono">{Git.short(@head_sha)}</span> | |
| 102 | + </span> | |
| 93 | 103 | </div> |
| 94 | 104 | |
| 95 | 105 | <article class="border border-base-300 rounded-md overflow-hidden"> |
modified
lib/git_gud_web/live/repo_live/log.ex
+43
−4
@@ -8,13 +8,29 @@ defmodule GitGudWeb.RepoLive.Log do
| 8 | 8 | @page_size 30 |
| 9 | 9 | |
| 10 | 10 | @impl true |
| 11 | − def mount(%{"owner" => owner, "name" => name, "ref" => ref}, _session, socket) do | |
| 11 | + def mount(%{"owner" => owner, "name" => name, "ref" => ref} = params, _session, socket) do | |
| 12 | 12 | repo = |
| 13 | 13 | Repositories.get_repository_by_path!(owner, name) |
| 14 | 14 | |> GitGud.Repo.preload([:owner, :organization]) |
| 15 | 15 | |
| 16 | + # `*path` scopes the walk to one file or directory (GitHub's | |
| 17 | + # /commits/<ref>/<path>). Absent on the plain branch history route. | |
| 18 | + path_segs = params |> Map.get("path") |> List.wrap() | |
| 19 | + path = Enum.join(path_segs, "/") | |
| 20 | + | |
| 16 | 21 | {:ok, head_sha} = Repositories.resolve(repo, ref) |
| 17 | − {:ok, commits} = Repositories.list_commits(repo, head_sha, limit: @page_size, skip: 0) | |
| 22 | + | |
| 23 | + {:ok, commits} = | |
| 24 | + Repositories.list_commits(repo, head_sha, | |
| 25 | + limit: @page_size, | |
| 26 | + skip: 0, | |
| 27 | + paths: paths_for(path) | |
| 28 | + ) | |
| 29 | + | |
| 30 | + title = | |
| 31 | + if path == "", | |
| 32 | + do: "#{owner}/#{name}: commits", | |
| 33 | + else: "#{owner}/#{name}: history of #{path}" | |
| 18 | 34 | |
| 19 | 35 | {:ok, |
| 20 | 36 | socket |
@@ -22,12 +38,17 @@ defmodule GitGudWeb.RepoLive.Log do
| 22 | 38 | |> assign(:handle, Storage.repo_handle(repo)) |
| 23 | 39 | |> GitGudWeb.RepoLive.Header.assign_chrome(repo) |
| 24 | 40 | |> assign(:ref, ref) |
| 41 | + |> assign(:path, path) | |
| 42 | + |> assign(:path_segs, path_segs) | |
| 25 | 43 | |> assign(:head_sha, head_sha) |
| 26 | 44 | |> assign(:skip, 0) |
| 27 | 45 | |> stream(:commits, Enum.map(commits, &decorate/1)) |
| 28 | − |> assign(:page_title, "#{owner}/#{name}: commits")} | |
| 46 | + |> assign(:page_title, title)} | |
| 29 | 47 | end |
| 30 | 48 | |
| 49 | + defp paths_for(""), do: [] | |
| 50 | + defp paths_for(path), do: [path] | |
| 51 | + | |
| 31 | 52 | defp decorate(commit) do |
| 32 | 53 | Map.merge(commit, %{ |
| 33 | 54 | id: Git.to_hex(commit.sha), |
@@ -44,7 +65,8 @@ defmodule GitGudWeb.RepoLive.Log do
| 44 | 65 | {:ok, commits} = |
| 45 | 66 | Repositories.list_commits(repo, socket.assigns.head_sha, |
| 46 | 67 | limit: @page_size, |
| 47 | − skip: skip | |
| 68 | + skip: skip, | |
| 69 | + paths: paths_for(socket.assigns.path) | |
| 48 | 70 | ) |
| 49 | 71 | |
| 50 | 72 | {:noreply, |
@@ -68,8 +90,25 @@ defmodule GitGudWeb.RepoLive.Log do
| 68 | 90 | /> |
| 69 | 91 | <h1 class="text-xl font-semibold"> |
| 70 | 92 | <span class="font-normal">commits on {@ref}</span> |
| 93 | + <span :if={@path != ""} class="font-mono text-sm opacity-70">/ {@path}</span> | |
| 71 | 94 | </h1> |
| 72 | 95 | |
| 96 | + <p :if={@path != ""} class="text-xs"> | |
| 97 | + <.link | |
| 98 | + navigate={~p"/r/#{@handle}/#{@repo.name}/blob/#{@ref}/#{@path_segs}"} | |
| 99 | + class="link link-hover" | |
| 100 | + > | |
| 101 | + back to file | |
| 102 | + </.link> | |
| 103 | + <span class="opacity-50"> · </span> | |
| 104 | + <.link | |
| 105 | + navigate={~p"/r/#{@handle}/#{@repo.name}/commits/#{@ref}"} | |
| 106 | + class="link link-hover" | |
| 107 | + > | |
| 108 | + full history | |
| 109 | + </.link> | |
| 110 | + </p> | |
| 111 | + | |
| 73 | 112 | <ul id="commits" phx-update="stream" class="divide-y divide-base-300"> |
| 74 | 113 | <li :for={{id, c} <- @streams.commits} id={id} class="py-3"> |
| 75 | 114 | <div class="flex items-baseline justify-between gap-3"> |
modified
lib/git_gud_web/router.ex
+1
−0
@@ -205,6 +205,7 @@ defmodule GitGudWeb.Router do
| 205 | 205 | live "/r/:owner/:name/tree/:ref/*path", RepoLive.Tree, :show |
| 206 | 206 | live "/r/:owner/:name/blob/:ref/*path", RepoLive.Blob, :show |
| 207 | 207 | live "/r/:owner/:name/commits/:ref", RepoLive.Log, :index |
| 208 | + live "/r/:owner/:name/commits/:ref/*path", RepoLive.Log, :index | |
| 208 | 209 | live "/r/:owner/:name/commit/:sha", RepoLive.Commit, :show |
| 209 | 210 | live "/r/:owner/:name/issues", IssueLive.Index, :index |
| 210 | 211 | live "/r/:owner/:name/issues/:number", IssueLive.Show, :show |
added
test/git_gud_web/live/repo_live/log_path_test.exs
+92
−0
@@ -0,0 +1,92 @@
| 1 | +defmodule GitGudWeb.RepoLive.LogPathTest do | |
| 2 | + use GitGudWeb.ConnCase, async: false | |
| 3 | + | |
| 4 | + import Phoenix.LiveViewTest | |
| 5 | + import GitGud.ForgeFixtures | |
| 6 | + | |
| 7 | + alias GitGud.Git | |
| 8 | + alias GitGud.Repositories.Storage | |
| 9 | + | |
| 10 | + setup :register_and_log_in_user | |
| 11 | + | |
| 12 | + # Three commits in the bare repo: one touching only a.txt, one | |
| 13 | + # touching only b.txt, one touching a.txt again. | |
| 14 | + defp seed_history(repo) do | |
| 15 | + path = repo.disk_path | |
| 16 | + | |
| 17 | + {sha, _files} = | |
| 18 | + Enum.reduce([{"a.txt", "1"}, {"b.txt", "1"}, {"a.txt", "2"}], {nil, %{}}, fn | |
| 19 | + {file, body}, {parent, files} -> | |
| 20 | + files = Map.put(files, file, write_blob(path, body)) | |
| 21 | + | |
| 22 | + entries = | |
| 23 | + Enum.map_join(files, "", fn {name, blob} -> "100644 blob #{blob}\t#{name}\n" end) | |
| 24 | + | |
| 25 | + {:ok, tree} = Git.from_hex(mktree(path, entries)) | |
| 26 | + parents = for p <- List.wrap(parent), do: p | |
| 27 | + | |
| 28 | + {:ok, commit} = Git.commit_tree(path, tree, parents, "touch #{file}", []) | |
| 29 | + {commit, files} | |
| 30 | + end) | |
| 31 | + | |
| 32 | + :ok = Git.update_ref(path, "refs/heads/" <> repo.default_branch, sha, nil) | |
| 33 | + end | |
| 34 | + | |
| 35 | + defp write_blob(path, body) do | |
| 36 | + tmp = Path.join(System.tmp_dir!(), "blob-#{System.unique_integer([:positive])}") | |
| 37 | + File.write!(tmp, body) | |
| 38 | + out = git!(path, ["hash-object", "-w", tmp]) | |
| 39 | + File.rm(tmp) | |
| 40 | + out | |
| 41 | + end | |
| 42 | + | |
| 43 | + defp mktree(path, entries) do | |
| 44 | + tmp = Path.join(System.tmp_dir!(), "tree-#{System.unique_integer([:positive])}") | |
| 45 | + File.write!(tmp, entries) | |
| 46 | + | |
| 47 | + out = | |
| 48 | + case System.shell("git -C #{path} mktree < #{tmp}", stderr_to_stdout: true) do | |
| 49 | + {out, 0} -> String.trim(out) | |
| 50 | + {out, code} -> raise "mktree failed (#{code}): #{out}" | |
| 51 | + end | |
| 52 | + | |
| 53 | + File.rm(tmp) | |
| 54 | + out | |
| 55 | + end | |
| 56 | + | |
| 57 | + defp git!(path, args) do | |
| 58 | + case System.cmd("git", ["-C", path | args], stderr_to_stdout: true) do | |
| 59 | + {out, 0} -> String.trim(out) | |
| 60 | + {out, code} -> raise "git #{inspect(args)} failed (#{code}): #{out}" | |
| 61 | + end | |
| 62 | + end | |
| 63 | + | |
| 64 | + setup %{user: user} do | |
| 65 | + {_user, repo} = repository_fixture(%{owner: user, visibility: "public"}) | |
| 66 | + seed_history(repo) | |
| 67 | + repo = GitGud.Repo.preload(repo, [:owner, :organization]) | |
| 68 | + %{repo: repo, base: "/r/#{Storage.repo_handle(repo)}/#{repo.name}"} | |
| 69 | + end | |
| 70 | + | |
| 71 | + test "history route lists only commits touching the file", %{ | |
| 72 | + conn: conn, | |
| 73 | + repo: repo, | |
| 74 | + base: base | |
| 75 | + } do | |
| 76 | + {:ok, _lv, html} = live(conn, "#{base}/commits/#{repo.default_branch}/a.txt") | |
| 77 | + | |
| 78 | + assert html =~ "touch a.txt" | |
| 79 | + refute html =~ "touch b.txt" | |
| 80 | + assert html =~ "back to file" | |
| 81 | + | |
| 82 | + {:ok, _lv, all} = live(conn, "#{base}/commits/#{repo.default_branch}") | |
| 83 | + assert all =~ "touch b.txt" | |
| 84 | + end | |
| 85 | + | |
| 86 | + test "blob view links to the file's history", %{conn: conn, repo: repo, base: base} do | |
| 87 | + {:ok, _lv, html} = live(conn, "#{base}/blob/#{repo.default_branch}/a.txt") | |
| 88 | + | |
| 89 | + assert html =~ "/commits/#{repo.default_branch}/a.txt" | |
| 90 | + assert html =~ "History" | |
| 91 | + end | |
| 92 | +end |
Parents: 9618636