Last-touched column on listings, and finish the gix NIF

cafccc2 · Gabriel Morell · 2026-09-12 04:53

44 files +1630 -775
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/application.ex
+3 −0
@@ -33,6 +33,9 @@ defmodule GitGud.Application do
33 33 GitGud.Cache,
34 34 {Oban, Application.fetch_env!(:git_gud, Oban)},
35 35 GitGud.Federation.RateLimiter,
36 + # Ad-hoc background work kicked off by a request or a live
37 + # view — see GitGudWeb.RepoLive.EntryTimes.
38 + {Task.Supervisor, name: GitGud.TaskSupervisor},
36 39 GitGud.Ssh.Server,
37 40 GitGudWeb.Endpoint
38 41 ]
modified lib/git_gud/git.ex
+21 −0
@@ -46,6 +46,10 @@ defmodule GitGud.Git do
46 46 size: integer(),
47 47 is_binary: boolean()
48 48 }
49 + @type touch :: %{
50 + sha: sha(),
51 + committed_at: DateTime.t()
52 + }
49 53 @type diff_stat :: %{
50 54 files_changed: integer(),
51 55 insertions: integer(),
@@ -76,6 +80,23 @@ defmodule GitGud.Git do
76 80 @spec root_commits(repo_path()) :: {:ok, [sha()]} | {:error, term()}
77 81 def root_commits(path), do: dispatch(:root_commits, [path])
78 82
83 + @doc """
84 + For each of `names` — the children of `dir` in the tree at
85 + `start_sha` — the newest commit reachable from `start_sha` that
86 + touched it. One history walk covers the whole directory, so this is
87 + the shape to reach for when rendering a listing rather than calling
88 + `log/3` once per path.
89 +
90 + Names that nothing in the walk touched are simply absent from the
91 + result.
92 + """
93 + @spec last_touched(repo_path(), sha(), String.t(), [String.t()]) ::
94 + {:ok, %{optional(String.t()) => touch()}} | {:error, term()}
95 + def last_touched(_path, _start_sha, _dir, []), do: {:ok, %{}}
96 +
97 + def last_touched(path, start_sha, dir, names),
98 + do: dispatch(:last_touched, [path, start_sha, dir, names])
99 +
79 100 @doc "Load a single commit by SHA."
80 101 @spec commit(repo_path(), sha()) :: {:ok, commit()} | {:error, term()}
81 102 def commit(path, sha), do: dispatch(:commit, [path, sha])
modified lib/git_gud/git/backend.ex
+2 −0
@@ -13,6 +13,8 @@ defmodule GitGud.Git.Backend do
13 13 @callback log(Git.repo_path(), Git.sha(), keyword()) ::
14 14 {:ok, [Git.commit()]} | {:error, term()}
15 15 @callback commit(Git.repo_path(), Git.sha()) :: {:ok, Git.commit()} | {:error, term()}
16 + @callback last_touched(Git.repo_path(), Git.sha(), String.t(), [String.t()]) ::
17 + {:ok, %{optional(String.t()) => Git.touch()}} | {:error, term()}
16 18 @callback root_commits(Git.repo_path()) :: {:ok, [Git.sha()]} | {:error, term()}
17 19 @callback tree(Git.repo_path(), Git.sha()) :: {:ok, [Git.tree_entry()]} | {:error, term()}
18 20 @callback blob_bytes(Git.repo_path(), Git.sha()) :: {:ok, binary()} | {:error, term()}
modified lib/git_gud/git/cli.ex
+77 −0
@@ -75,6 +75,10 @@ defmodule GitGud.Git.Cli do
75 75 defp ref_type("refs/tags/" <> _, _), do: "tag"
76 76 defp ref_type(_, _), do: "branch"
77 77
78 + # A listing entry untouched in this many commits renders blank rather
79 + # than costing an unbounded walk. Mirrors the NIF's cap.
80 + @last_touched_max_commits 25_000
81 +
78 82 @impl true
79 83 def log(path, start_sha, opts) do
80 84 limit = Keyword.get(opts, :limit, 50)
@@ -98,6 +102,79 @@ defmodule GitGud.Git.Cli do
98 102 end
99 103 end
100 104
105 + # `git log --name-only` over the directory, folded down to the first
106 + # commit that names each entry. One invocation for the whole listing —
107 + # the per-path alternative is one `git log -1` per row.
108 + @impl true
109 + def last_touched(_path, _start_sha, _dir, []), do: {:ok, %{}}
110 +
111 + def last_touched(path, start_sha, dir, names) do
112 + wanted = MapSet.new(names)
113 + pathspec = if dir == "", do: ".", else: dir
114 +
115 + args = [
116 + "log",
117 + "--name-only",
118 + "--pretty=format:%x01%H%x00%cI",
119 + "-n",
120 + to_string(@last_touched_max_commits),
121 + Git.to_hex(start_sha),
122 + "--",
123 + pathspec
124 + ]
125 +
126 + case git(path, args) do
127 + {:ok, out} -> {:ok, fold_name_only(out, dir, wanted)}
128 + err -> err
129 + end
130 + end
131 +
132 + defp fold_name_only(out, dir, wanted) do
133 + out
134 + |> String.split(<<1>>, trim: true)
135 + |> Enum.reduce(%{}, fn block, acc ->
136 + case String.split(block, "\n") do
137 + [header | files] ->
138 + case String.split(header, <<0>>, parts: 2) do
139 + [sha_hex, iso] ->
140 + touch = %{sha: hex_to_sha_or_nil(sha_hex), committed_at: parse_dt(iso)}
141 +
142 + files
143 + |> Enum.flat_map(&entry_name(&1, dir))
144 + |> Enum.filter(&MapSet.member?(wanted, &1))
145 + |> Enum.reduce(acc, fn name, acc -> Map.put_new(acc, name, touch) end)
146 +
147 + _ ->
148 + acc
149 + end
150 +
151 + _ ->
152 + acc
153 + end
154 + end)
155 + end
156 +
157 + # `--name-only` reports full paths from the repo root; the listing
158 + # cares about which of *its* children the change fell under.
159 + defp entry_name(line, dir) do
160 + line = String.trim(line)
161 + prefix = if dir == "", do: "", else: dir <> "/"
162 +
163 + case line do
164 + "" -> []
165 + _ -> if String.starts_with?(line, prefix), do: first_segment(line, prefix), else: []
166 + end
167 + end
168 +
169 + defp first_segment(line, prefix) do
170 + case line
171 + |> binary_part(byte_size(prefix), byte_size(line) - byte_size(prefix))
172 + |> String.split("/", parts: 2) do
173 + [name | _] when name != "" -> [name]
174 + _ -> []
175 + end
176 + end
177 +
101 178 @impl true
102 179 def root_commits(path) do
103 180 case git(path, ["rev-list", "--max-parents=0", "--all"]) do
modified lib/git_gud/git/nif.ex
+35 −1
@@ -37,6 +37,9 @@ defmodule GitGud.Git.Nif do
37 37 @impl true
38 38 def commit(_path, _sha), do: nif_error()
39 39
40 + @impl true
41 + def last_touched(_path, _start_sha, _dir, _names), do: nif_error()
42 +
40 43 @impl true
41 44 def root_commits(_path), do: {:error, :unsupported}
42 45
@@ -64,8 +67,39 @@ defmodule GitGud.Git.Nif do
64 67 @impl true
65 68 def merge_tree(_path, _base, _a, _b), do: nif_error()
66 69
70 + @doc """
71 + `%DateTime{}` is an Elixir struct the NIF has no business decoding, so
72 + the author identity is flattened here and the native function takes
73 + the three plain values.
74 + """
67 75 @impl true
68 − def commit_tree(_path, _tree_sha, _parents, _message, _opts), do: nif_error()
76 + def commit_tree(path, tree_sha, parents, message, opts) do
77 + {name, email, dt} = Keyword.get(opts, :author) || default_identity()
78 +
79 + nif_commit_tree(
80 + path,
81 + tree_sha,
82 + parents,
83 + message,
84 + name,
85 + email,
86 + DateTime.to_unix(dt || DateTime.utc_now(:second))
87 + )
88 + end
89 +
90 + @doc false
91 + def nif_commit_tree(
92 + _path,
93 + _tree_sha,
94 + _parents,
95 + _message,
96 + _author_name,
97 + _author_email,
98 + _seconds
99 + ),
100 + do: nif_error()
101 +
102 + defp default_identity, do: {"GitGud", "noreply@gitgud.local", DateTime.utc_now(:second)}
69 103
70 104 @impl true
71 105 def update_ref(_path, _ref, _new_sha, _expected_old), do: nif_error()
modified lib/git_gud/repositories.ex
+7 −0

Click to load diff…

added lib/git_gud_web/live/repo_live/entry_times.ex
+86 −0

Click to load diff…

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

Click to load diff…

modified lib/git_gud_web/live/repo_live/tree.ex
+10 −1

Click to load diff…

modified native/gitgud_gix/Cargo.lock
+507 −708

Click to load diff…

modified native/gitgud_gix/Cargo.toml
+3 −2

Click to load diff…

modified native/gitgud_gix/README.md
+18 −12

Click to load diff…

modified native/gitgud_gix/src/lib.rs
+519 −45

Click to load diff…

modified native/gitgud_gix/target/.rustc_info.json
+1 −1

Click to load diff…

modified native/gitgud_gix/target/release/.fingerprint/gitgud_gix-2627c70af7106c92/lib-gitgud_gix
+1 −1

Click to load diff…

modified native/gitgud_gix/target/release/.fingerprint/gitgud_gix-2627c70af7106c92/lib-gitgud_gix.json
+1 −1

Click to load diff…

modified native/gitgud_gix/target/release/deps/gitgud_gix.d
+2 −2

Click to load diff…

modified ?
+0 −0

Click to load diff…

modified native/gitgud_gix/target/release/libgitgud_gix.d
+1 −1

Click to load diff…

modified ?
+0 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

modified test/git_gud/git_nif_test.exs
+231 −0

Click to load diff…

added test/git_gud_web/live/repo_live/entry_times_test.exs
+95 −0

Click to load diff…

Parents: 3fb3f48