neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Last-touched column on listings, and finish the gix NIF
cafccc2 · Gabriel Morell · 2026-09-12 04:53
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/application.ex
+3
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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