Add per-file commit history

845b545 · Gabriel Morell · 2026-09-09 20:18

4 files +148 -6
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