Packages: show .deb and .rpm icons from their desktop entry

8a09017 · Gabriel Morell · 2026-10-15 03:18

32 files +1291 -40
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages.ex
+30 −1
@@ -270,7 +270,8 @@ defmodule GitGud.Packages do
270 270 A package's icon — `{:ok, content_type, bytes, sha256}` — or `:none`.
271 271
272 272 Generic over formats: any adapter that writes `icon_key` into a
273 − file's metadata at publish time (today, F-Droid's launcher icon) gets
273 + file's metadata at publish time (an APK's launcher icon, a .deb or
274 + .rpm's desktop entry icon) gets
274 275 a picture on the packages pages. The newest file's, which is the one
275 276 an index would show.
276 277 """
@@ -288,6 +289,34 @@ defmodule GitGud.Packages do
288 289 defp icon_content_type("jpg"), do: "image/jpeg"
289 290 defp icon_content_type(_), do: "image/png"
290 291
292 + @doc """
293 + Store a package icon pulled out of an artifact at publish time, and
294 + return the metadata keys that point `icon/1` at it — or `:error`.
295 +
296 + Keyed by content, so the same image published a hundred times is
297 + stored once. `prefix` is only where it lands; F-Droid's icons keep
298 + the prefix they were first stored under.
299 + """
300 + @spec store_icon(binary(), String.t(), String.t()) :: {:ok, map()} | :error
301 + def store_icon(bytes, ext, prefix \\ "package-icons") when is_binary(bytes) do
302 + sha256 = :sha256 |> :crypto.hash(bytes) |> Base.encode16(case: :lower)
303 + key = "#{prefix}/#{sha256}"
304 +
305 + case Storage.put(@artifact_bucket, key, bytes, content_type: icon_content_type(ext)) do
306 + :ok ->
307 + {:ok,
308 + %{
309 + "icon_key" => key,
310 + "icon_sha256" => sha256,
311 + "icon_size" => byte_size(bytes),
312 + "icon_ext" => ext
313 + }}
314 +
315 + {:error, _} ->
316 + :error
317 + end
318 + end
319 +
291 320 @doc "Look up a single file of a package by filename."
292 321 def get_package_file(%Package{id: pid}, filename) when is_binary(filename) do
293 322 from(f in PackageFile,
modified lib/git_gud/packages/apt/native.ex
+13 −0
@@ -90,4 +90,17 @@ defmodule GitGud.Packages.Apt.Native do
90 90 @doc "Decompress a zstd stream."
91 91 @spec zstd_decompress(binary()) :: {:ok, binary()} | {:error, String.t()}
92 92 def zstd_decompress(_data), do: :erlang.nif_error(:nif_not_loaded)
93 +
94 + @doc """
95 + Decompress an xz stream, or fail once the output passes `limit`
96 + bytes — for input whose decompressed size nothing else bounds.
97 + """
98 + @spec xz_decompress_capped(binary(), non_neg_integer()) ::
99 + {:ok, binary()} | {:error, String.t()}
100 + def xz_decompress_capped(_data, _limit), do: :erlang.nif_error(:nif_not_loaded)
101 +
102 + @doc "As `xz_decompress_capped/2`, for zstd."
103 + @spec zstd_decompress_capped(binary(), non_neg_integer()) ::
104 + {:ok, binary()} | {:error, String.t()}
105 + def zstd_decompress_capped(_data, _limit), do: :erlang.nif_error(:nif_not_loaded)
93 106 end
modified lib/git_gud/packages/formats/deb.ex
+8 −6
@@ -70,6 +70,7 @@ defmodule GitGud.Packages.Formats.Deb do
70 70 alias GitGud.Packages
71 71 alias GitGud.Packages.Apt.Native
72 72 alias GitGud.Packages.Formats.Deb.Control
73 + alias GitGud.Packages.Formats.Desktop
73 74 alias GitGud.Packages.Formats.Listing
74 75 alias GitGud.Packages.PackageFile
75 76 alias GitGud.Repositories.Repository
@@ -394,12 +395,13 @@ defmodule GitGud.Packages.Formats.Deb do
394 395 filename: "#{name}_#{version}_#{architecture}.deb",
395 396 body: body,
396 397 content_type: content_type(name),
397 − metadata: %{
398 − "dist" => dist,
399 − "component" => component,
400 − "architecture" => architecture,
401 − "control" => control
402 − },
398 + metadata:
399 + Map.merge(Desktop.metadata("deb", body, name), %{
400 + "dist" => dist,
401 + "component" => component,
402 + "architecture" => architecture,
403 + "control" => control
404 + }),
403 405 version_metadata: %{"control" => control},
404 406 uploader: uploader(ctx)
405 407 }
modified lib/git_gud/packages/formats/deb/control.ex
+31 −3
@@ -8,9 +8,11 @@ defmodule GitGud.Packages.Formats.Deb.Control do
8 8 control.tar.{gz,xz,zst}
9 9 data.tar.{gz,xz,zst}
10 10
11 − Only the control member is read. Inside it is a `control` file
12 − holding one RFC 822-ish stanza: `Package`, `Version`,
13 − `Architecture`, `Depends`, `Description` and friends.
11 + `parse/1` reads only the control member. Inside it is a `control`
12 + file holding one RFC 822-ish stanza: `Package`, `Version`,
13 + `Architecture`, `Depends`, `Description` and friends. `data/1` hands
14 + back the data member, still compressed, for the one thing the index
15 + wants from the payload — the desktop entry's icon.
14 16
15 17 `ar` is parsed here rather than shelled out to, because `ar` is
16 18 sixty bytes of fixed-width ASCII per member and shelling out would
@@ -41,6 +43,32 @@ defmodule GitGud.Packages.Formats.Deb.Control do
41 43
42 44 def parse(_), do: {:error, {400, "not an ar archive — a .deb starts with !<arch>"}}
43 45
46 + @doc """
47 + The `data.tar` member of a `.deb`, as `{:ok, compression, blob}` —
48 + `compression` being `:gzip`, `:xz`, `:zstd` or `:none` — or `:error`.
49 + Left compressed: whether to pay for decompressing it is the caller's
50 + call.
51 + """
52 + @spec data(binary()) :: {:ok, atom(), binary()} | :error
53 + def data(<<@magic, rest::binary>>) do
54 + with {:ok, members} <- ar_members(rest, %{}),
55 + {name, blob} <-
56 + Enum.find(members, fn {name, _} -> String.starts_with?(name, "data.tar") end),
57 + kind when kind != nil <- compression(Path.extname(name)) do
58 + {:ok, kind, blob}
59 + else
60 + _ -> :error
61 + end
62 + end
63 +
64 + def data(_), do: :error
65 +
66 + defp compression(".gz"), do: :gzip
67 + defp compression(".xz"), do: :xz
68 + defp compression(".zst"), do: :zstd
69 + defp compression(".tar"), do: :none
70 + defp compression(_), do: nil
71 +
44 72 # ── ar ──────────────────────────────────────────────────────────────
45 73
46 74 # Each member: a 60-byte header (name[16] mtime[12] uid[6] gid[6]
added lib/git_gud/packages/formats/desktop.ex
+456 −0
@@ -0,0 +1,456 @@
1 +defmodule GitGud.Packages.Formats.Desktop do
2 + @moduledoc """
3 + A Linux package's name and icon, as its desktop entry gives them —
4 + the `.deb`/`.rpm` counterpart of an APK's `android:label` and
5 + launcher icon.
6 +
7 + A package that installs an application ships a desktop entry,
8 + `/usr/share/applications/<id>.desktop`, whose `Name=` is what a
9 + launcher shows and whose `Icon=` names an image the package ships
10 + alongside it:
11 +
12 + [Desktop Entry]
13 + Type=Application
14 + Name=KoCo
15 + Icon=koco
16 + → /usr/share/icons/hicolor/256x256/apps/koco.png
17 +
18 + `Icon=` is either an absolute path or a theme name. A name is looked
19 + up the way the icon theme spec does it, cut down to what the package
20 + itself contains: `<name>.png` under any `share/icons/` theme or
21 + `share/pixmaps/`, the largest that is still a sensible size. Only
22 + PNG: an SVG would need rasterising or serving user-supplied SVG to
23 + browsers, and the mobile client can't draw one either.
24 +
25 + Read once at publish, by the adapters — the icon is stored the way
26 + F-Droid's is, under `icon_key`, so `GitGud.Packages.icon/1` and the
27 + catalog need nothing new. Packages published before that are filled
28 + in by `backfill_icons/1`.
29 + """
30 +
31 + alias GitGud.Packages
32 + alias GitGud.Packages.Apt.Native
33 + alias GitGud.Packages.Formats.Deb.Control
34 + alias GitGud.Packages.Formats.Rpm.Cpio
35 + alias GitGud.Packages.Formats.Rpm.Header
36 + alias GitGud.Packages.Formats.Tar
37 + alias GitGud.Packages.PackageFile
38 +
39 + # Decompressed payloads past this are not looked inside. An icon is
40 + # decoration, and the payload is uploader-controlled: a few kilobytes
41 + # of xz can claim to be gigabytes.
42 + @payload_limit 256 * 1024 * 1024
43 +
44 + # Biggest icon worth keeping. Bigger than this is a splash screen.
45 + @max_icon_bytes 1024 * 1024
46 +
47 + # Preferred ceiling on edge length; anything up to it, the larger the
48 + # better. Past it, the smallest.
49 + @preferred_max 512
50 +
51 + @png <<0x89, "PNG\r\n", 0x1A, "\n">>
52 +
53 + @doc """
54 + Decompress a package payload, giving up past `@payload_limit`.
55 + `kind` is `:gzip`, `:xz`, `:zstd` or `:none`.
56 + """
57 + @spec decompress(atom(), binary()) :: {:ok, binary()} | :error
58 + def decompress(:none, blob), do: {:ok, blob}
59 + def decompress(:gzip, blob), do: gunzip(blob)
60 + def decompress(:xz, blob), do: nif(Native.xz_decompress_capped(blob, @payload_limit))
61 + def decompress(:zstd, blob), do: nif(Native.zstd_decompress_capped(blob, @payload_limit))
62 + def decompress(_kind, _blob), do: :error
63 +
64 + defp nif({:ok, bytes}), do: {:ok, bytes}
65 + defp nif(_), do: :error
66 +
67 + # `:zlib.gunzip/1` has no ceiling; inflating a chunk at a time does.
68 + defp gunzip(blob) do
69 + z = :zlib.open()
70 +
71 + try do
72 + :ok = :zlib.inflateInit(z, 31)
73 + inflate(z, :zlib.safeInflate(z, blob), [], 0)
74 + rescue
75 + _ -> :error
76 + after
77 + :zlib.close(z)
78 + end
79 + end
80 +
81 + defp inflate(z, {status, out}, acc, size) do
82 + size = size + IO.iodata_length(out)
83 +
84 + cond do
85 + size > @payload_limit -> :error
86 + status == :finished -> {:ok, IO.iodata_to_binary([acc, out])}
87 + true -> inflate(z, :zlib.safeInflate(z, []), [acc, out], size)
88 + end
89 + end
90 +
91 + @doc """
92 + What the desktop entry in a `.deb` or `.rpm` says, as metadata to
93 + merge into its package file's: `label`, and the `icon_*` keys with
94 + the icon stored. Empty when the package carries no desktop entry, or
95 + nothing usable — an icon is decoration, and nothing here fails a
96 + publish.
97 +
98 + `package` is the package name, which picks between several entries.
99 + """
100 + @spec metadata(String.t(), binary(), String.t()) :: map()
101 + def metadata(format, body, package) do
102 + case read(format, body, package) do
103 + {:ok, found} -> stored(found)
104 + :none -> %{}
105 + end
106 + end
107 +
108 + defp stored(%{label: label, icon: icon}) do
109 + label = if label, do: %{"label" => label}, else: %{}
110 + Map.merge(label, store(icon))
111 + end
112 +
113 + @doc """
114 + The chosen desktop entry's `Name=` and icon bytes in a `.deb` or
115 + `.rpm`, without storing anything — or `:none`.
116 + """
117 + @spec read(String.t(), binary(), String.t()) ::
118 + {:ok, %{label: String.t() | nil, icon: binary() | nil}} | :none
119 + def read(format, body, package) do
120 + case entries(format, body) do
121 + {:ok, entries} -> find(entries, package)
122 + _ -> :none
123 + end
124 + end
125 +
126 + defp entries("deb", body) do
127 + with {:ok, kind, blob} <- Control.data(body),
128 + {:ok, tar} <- decompress(kind, blob),
129 + do: Tar.entries(tar)
130 + end
131 +
132 + # The header lists every file, so an RPM with no desktop entry — most
133 + # of them — is known not to have one without decompressing anything.
134 + defp entries("rpm", body) do
135 + with {:ok, header} <- Header.parse(body),
136 + true <- Enum.any?(Header.files(header), &String.ends_with?(&1, ".desktop")),
137 + {:ok, kind, blob} <- Header.payload(body, header),
138 + {:ok, cpio} <- decompress(kind, blob),
139 + do: Cpio.entries(cpio)
140 + end
141 +
142 + defp entries(_format, _body), do: :error
143 +
144 + defp store(nil), do: %{}
145 +
146 + defp store(bytes) do
147 + case Packages.store_icon(bytes, "png") do
148 + {:ok, meta} -> meta
149 + # An icon is decoration; a publish doesn't fail over one.
150 + :error -> %{}
151 + end
152 + end
153 +
154 + @doc """
155 + The chosen desktop entry's `Name=` and icon bytes, or `:none`.
156 + """
157 + @spec find(map(), String.t()) :: {:ok, %{label: String.t() | nil, icon: binary() | nil}} | :none
158 + def find(entries, package) do
159 + entries
160 + |> desktop_entries()
161 + |> Enum.sort_by(&rank(&1, package))
162 + |> case do
163 + [] ->
164 + :none
165 +
166 + [first | _] = ranked ->
167 + # The first entry that resolves to an icon, for the icon *and*
168 + # the name — a name from one entry over another's icon would be
169 + # a picture of something else. Failing every icon, the
170 + # best-ranked entry still names the package.
171 + case Enum.find_value(ranked, &with_icon(&1, entries)) do
172 + nil -> {:ok, %{label: first.name, icon: nil}}
173 + found -> {:ok, found}
174 + end
175 + end
176 + end
177 +
178 + defp with_icon(%{icon: nil}, _entries), do: nil
179 +
180 + defp with_icon(entry, entries) do
181 + case icon(entry.icon, entries) do
182 + nil -> nil
183 + bytes -> %{label: entry.name, icon: bytes}
184 + end
185 + end
186 +
187 + # ── backfill ────────────────────────────────────────────────────────
188 +
189 + @formats ~w(deb rpm)
190 +
191 + # Only these come from the re-read.
192 + @backfilled ~w(label icon_key icon_sha256 icon_size icon_ext)
193 +
194 + @doc """
195 + Re-read published `.deb`s and `.rpm`s and record the desktop entry's
196 + name and icon on each, for packages published before the adapters
197 + read them.
198 +
199 + Options:
200 +
201 + * `:all` — every file, not just each package's newest. The newest
202 + is the default because it is the only one the packages pages
203 + read.
204 + * `:dry_run` — report, write nothing.
205 + * `:on_file` — called with a report map per file.
206 +
207 + Returns `{read, written}`. Idempotent: icons are stored by content,
208 + and a package with no desktop entry is read and left alone.
209 + """
210 + @spec backfill_icons(keyword()) :: {non_neg_integer(), non_neg_integer()}
211 + def backfill_icons(opts \\ []) do
212 + all? = Keyword.get(opts, :all, false)
213 + dry? = Keyword.get(opts, :dry_run, false)
214 + on_file = Keyword.get(opts, :on_file, fn _ -> :ok end)
215 +
216 + all?
217 + |> backfill_candidates()
218 + |> Enum.reduce({0, 0}, fn file, {read, written} ->
219 + case backfill_file(file, dry?) do
220 + {:ok, report} ->
221 + on_file.(report)
222 + {read + 1, if(report.written?, do: written + 1, else: written)}
223 +
224 + {:error, report} ->
225 + on_file.(report)
226 + {read + 1, written}
227 + end
228 + end)
229 + end
230 +
231 + defp backfill_candidates(all?) do
232 + import Ecto.Query
233 +
234 + files =
235 + from(f in PackageFile,
236 + join: v in assoc(f, :package_version),
237 + join: p in assoc(v, :package),
238 + where: p.format in @formats,
239 + preload: [package_version: {v, package: p}]
240 + )
241 + |> GitGud.Repo.all()
242 +
243 + if all? do
244 + Enum.sort_by(files, & &1.id)
245 + else
246 + # The same "newest" `Packages.latest_files/1` picks, which is
247 + # what the packages pages read the icon off.
248 + files
249 + |> Enum.group_by(& &1.package_version.package_id)
250 + |> Enum.map(fn {_id, files} ->
251 + Enum.max_by(files, &{pushed_at(&1), &1.package_version.id, &1.id})
252 + end)
253 + |> Enum.sort_by(& &1.id)
254 + end
255 + end
256 +
257 + @doc "One line about a `backfill_icons/1` report, for a terminal."
258 + @spec describe_backfill(map()) :: String.t()
259 + def describe_backfill(%{error: reason, filename: filename}),
260 + do: "#{filename}: could not read — #{inspect(reason)}"
261 +
262 + def describe_backfill(%{filename: filename} = report) do
263 + icon =
264 + case report.icon do
265 + {size, ext} -> "#{size} byte .#{ext} icon"
266 + :none -> "no icon"
267 + end
268 +
269 + name = report.label || "no desktop entry name"
270 + suffix = if report.written?, do: "", else: " — not written"
271 +
272 + "#{filename}: #{name}, #{icon}#{suffix}"
273 + end
274 +
275 + defp pushed_at(%PackageFile{package_version: %{pushed_at: %DateTime{} = at}}),
276 + do: DateTime.to_unix(at)
277 +
278 + defp pushed_at(_file), do: 0
279 +
280 + defp backfill_file(%PackageFile{} = file, dry?) do
281 + package = file.package_version.package
282 + report = %{format: package.format, package: package.name, filename: file.filename}
283 +
284 + case Packages.read_file(file) do
285 + {:ok, body} ->
286 + found =
287 + case read(package.format, body, package.name) do
288 + {:ok, found} -> found
289 + :none -> %{label: nil, icon: nil}
290 + end
291 +
292 + additions = if dry?, do: %{}, else: found |> stored() |> Map.take(@backfilled)
293 +
294 + unless additions == %{} do
295 + file
296 + |> Ecto.Changeset.change(metadata: Map.merge(file.metadata || %{}, additions))
297 + |> GitGud.Repo.update!()
298 + end
299 +
300 + {:ok,
301 + Map.merge(report, %{
302 + label: found.label,
303 + icon: if(found.icon, do: {byte_size(found.icon), "png"}, else: :none),
304 + written?: additions != %{}
305 + })}
306 +
307 + {:error, reason} ->
308 + {:error, Map.put(report, :error, reason)}
309 + end
310 + end
311 +
312 + # ── desktop entries ─────────────────────────────────────────────────
313 +
314 + defp desktop_entries(entries) do
315 + for {path, _} <- entries,
316 + String.match?(path, ~r{/share/applications/[^/]+\.desktop\z}),
317 + bytes = resolve(entries, path),
318 + is_binary(bytes),
319 + entry = parse(bytes),
320 + entry.type in [nil, "Application"] do
321 + Map.put(entry, :id, Path.basename(path, ".desktop"))
322 + end
323 + end
324 +
325 + # The [Desktop Entry] group's unlocalised keys. Other groups
326 + # (`[Desktop Action new-window]`) have keys of the same names that
327 + # mean something else.
328 + @doc false
329 + def parse(text) do
330 + text
331 + |> String.split(["\r\n", "\n"])
332 + |> Enum.reduce({nil, %{}}, fn line, {group, keys} ->
333 + line = String.trim(line)
334 +
335 + cond do
336 + String.starts_with?(line, "[") ->
337 + {String.trim(line, "[") |> String.trim_trailing("]"), keys}
338 +
339 + group == "Desktop Entry" ->
340 + case String.split(line, "=", parts: 2) do
341 + [key, value] -> {group, Map.put_new(keys, String.trim(key), String.trim(value))}
342 + _ -> {group, keys}
343 + end
344 +
345 + true ->
346 + {group, keys}
347 + end
348 + end)
349 + |> elem(1)
350 + |> then(fn keys ->
351 + %{
352 + name: present(keys["Name"]),
353 + icon: present(keys["Icon"]),
354 + type: present(keys["Type"]),
355 + hidden?: keys["NoDisplay"] == "true" or keys["Hidden"] == "true"
356 + }
357 + end)
358 + end
359 +
360 + # Lower sorts first: visible before hidden, named for the package
361 + # before not, then by id for a stable answer.
362 + defp rank(entry, package) do
363 + {if(entry.hidden?, do: 1, else: 0), if(matches?(entry.id, package), do: 0, else: 1), entry.id}
364 + end
365 +
366 + # `koco.desktop` for `koco`, but also `org.neiam.KoCo.desktop` for
367 + # `neiam-koco` — reverse-DNS ids are the norm now, and a package name
368 + # often carries a vendor prefix the id doesn't.
369 + defp matches?(id, package) do
370 + id = String.downcase(id)
371 + last = id |> String.split(".") |> List.last()
372 + package = String.downcase(package)
373 +
374 + id == package or last == package or String.ends_with?(package, "-" <> last)
375 + end
376 +
377 + # ── icons ───────────────────────────────────────────────────────────
378 +
379 + defp icon("/" <> _ = path, entries), do: entries |> resolve(path) |> png()
380 +
381 + defp icon(name, entries) do
382 + file = Path.rootname(name, Path.extname(name)) <> ".png"
383 +
384 + for {path, _} <- entries,
385 + Path.basename(path) == file,
386 + String.match?(path, ~r{/share/(icons|pixmaps)/}),
387 + bytes = png(resolve(entries, path)),
388 + bytes != nil do
389 + {width(bytes), bytes}
390 + end
391 + |> Enum.max_by(&preference/1, fn -> nil end)
392 + |> case do
393 + {_width, bytes} -> bytes
394 + nil -> nil
395 + end
396 + end
397 +
398 + defp preference({width, _}) when width <= @preferred_max, do: {1, width}
399 + defp preference({width, _}), do: {0, -width}
400 +
401 + defp png(<<@png, _::binary>> = bytes) when byte_size(bytes) <= @max_icon_bytes, do: bytes
402 + defp png(_), do: nil
403 +
404 + # IHDR is always first: 8 bytes of signature, 4 of length, 4 of type,
405 + # then the width.
406 + defp width(<<@png, _len::32, "IHDR", width::32, _::binary>>), do: width
407 + defp width(_), do: 0
408 +
409 + # A path's bytes, through up to eight links. Relative symlink targets
410 + # are relative to the link's directory.
411 + defp resolve(entries, path, hops \\ 8)
412 + defp resolve(_entries, _path, 0), do: nil
413 +
414 + defp resolve(entries, path, hops) do
415 + case Map.get(entries, path) do
416 + {:file, bytes} ->
417 + bytes
418 +
419 + {:hardlink, target} ->
420 + resolve(entries, target, hops - 1)
421 +
422 + {:symlink, "/" <> _ = target} ->
423 + resolve(entries, expand(target), hops - 1)
424 +
425 + {:symlink, target} ->
426 + resolve(entries, expand(Path.join(Path.dirname(path), target)), hops - 1)
427 +
428 + nil ->
429 + nil
430 + end
431 + end
432 +
433 + # `..` resolved lexically, which is what a symlink inside a package
434 + # can only mean — there is no filesystem underneath to ask.
435 + defp expand(path) do
436 + path
437 + |> String.split("/", trim: true)
438 + |> Enum.reduce([], fn
439 + ".", acc -> acc
440 + "..", [] -> []
441 + "..", [_ | acc] -> acc
442 + part, acc -> [part | acc]
443 + end)
444 + |> Enum.reverse()
445 + |> then(&("/" <> Enum.join(&1, "/")))
446 + end
447 +
448 + defp present(nil), do: nil
449 +
450 + defp present(text) do
451 + case String.trim(text) do
452 + "" -> nil
453 + text -> text
454 + end
455 + end
456 +end
modified lib/git_gud/packages/formats/fdroid.ex
+7 −23

Click to load diff…

modified lib/git_gud/packages/formats/rpm.ex
+2 −1

Click to load diff…

added lib/git_gud/packages/formats/rpm/cpio.ex
+76 −0

Click to load diff…

modified lib/git_gud/packages/formats/rpm/header.ex
+28 −3

Click to load diff…

added lib/git_gud/packages/formats/tar.ex
+137 −0

Click to load diff…

modified lib/git_gud/release.ex
+37 −0

Click to load diff…

added lib/mix/tasks/gitgud.backfill_desktop_icons.ex
+51 −0

Click to load diff…

modified native/gitgud_apt/src/lib.rs
+71 −0

Click to load diff…

added test/git_gud/packages/desktop_icon_test.exs
+303 −0

Click to load diff…

modified test/support/fixtures/rpm_fixtures.ex
+41 −3

Click to load diff…

Parents: 7784d39