110.6 KiB · text 5af55d9

Progress

Reverse-chronological record of slices shipped. Append on close.

For what’s not shipped yet, see ROADMAP.md.

Convention. When a session-local task closes, append a one-liner to the current slice’s bullet list. When a slice closes, write the slice header + summary. The fine-grained per-session task graph (the intermediate “I was here when I stopped” state) lives in the Session log at the bottom of this file.


Slice 58 — Org webhooks + CI dashboards

Extends webhooks to org scope and adds two CI “where do my builds stand” views: an org-wide single pane of glass and a per-user feed.

Org-scoped webhooks

  • webhooks is now repo-XOR-org owned. Migration add_org_webhooks_and_run_actor makes repository_id nullable, adds organization_id, and a webhooks_one_owner check constraint (exactly one owner). Webhook schema gains belongs_to :organization.
  • Webhooks.notify/3 fans out to both the repo’s hooks and (when org-owned) the owning org’s hooks via a single dynamic/2 OR-clause. Now takes a full %Repository{} (reads organization_id).
  • workflow_run event is finally emitted — it was a declared known-event with no caller. Workflows.notify_run_webhook/1 fires on terminal run state (maybe_complete_run + cancel/1), best-effort/rescued.
  • Org webhook UI at /orgs/:handle/settings/webhooks (admin-only) with inline recent-deliveries log (Webhooks.recent_deliveries_by_hook/2). Nav links added to org_settings_header + org show page.

CI dashboards

  • Org single pane of glass /orgs/:handle/actions (OrgLive.Actions) — every run across the org’s repos the viewer may see. Visibility respected via Repositories.visible_repo_ids_for_org/2.
  • Per-user view /users/ci (UserLive.Ci) — runs in repos the user owns / is an org member of / has team access to (Repositories.relevant_repo_ids_for_user/1), unioned with runs they triggered. Linked from the user dropdown.
  • Live updates via one instance-wide PubSub topic Workflows.ci_topic() ("ci:runs"); subscribers re-query their visibility-scoped list on {:ci_run_changed, run_id}. Broadcast from update_run_commit_status/1 + the claim→running sites. Shared rendering in GitGudWeb.CiComponents.

Pusher attribution

  • workflow_runs.triggered_by_id threaded end-to-end so the user view can show “runs I triggered”: SSH Ssh.Channel sets GITGUD_PUSHER_ID in the git Port env; smart-HTTP injects it into the CGI env; gitgud-hook forwards x-pusher-id; HookControllerHookReceiver.receive/3CommitBackfillWorkflows.dispatch. Installed per-repo hooks just exec the central script, so only gitgud-hook changed.

Pending

  • Migration not yet applied — dev Postgres was down at write time; run mix ecto.migrate once it’s up.

Slice 57 — libcluster + L1/L2 distributed Nebulex

Lifts the cache from per-node ETS to a two-tier cluster cache so the syntax-highlight and rendered-markdown payloads survive request landings on any pod.

Dependencies + topology

  • Added {:libcluster, "~> 3.4"} to mix.exs. Its Cluster.Strategy.Kubernetes strategy enumerates peer pod IPs through the K8s API.
  • runtime.exs wires the topology only when LIBCLUSTER_KUBERNETES=1 is set, so single-node and dev runs skip clustering entirely. Other env knobs: LIBCLUSTER_NAMESPACE, LIBCLUSTER_SERVICE, LIBCLUSTER_SELECTOR, LIBCLUSTER_BASENAME.
  • GitGud.Application.start/2 spawns Cluster.Supervisor only when a topology exists. No topology → no supervisor, pg group has a single member, replicated writes are local — exactly right on a single node.

Nebulex cache, L1 + L2

  • GitGud.Cache.L1Nebulex.Adapters.Local (ETS). Per-node, sub-microsecond reads.
  • GitGud.Cache.L2Nebulex.Adapters.Replicated. Each clustered node holds a full copy; writes broadcast via pg once libcluster connects the nodes.
  • GitGud.CacheNebulex.Adapters.Multilevel with model: :inclusive and levels: [L1, L2]. Reads cascade L1 → L2 with L2 hits promoted up; writes fan out to both.
  • Multilevel supervises the level caches itself, so the supervision tree only lists GitGud.Cache. Listing L1/L2 directly causes a :already_started.
  • Configuration split into three blocks in config/config.exs: L1 (tighter caps, faster turnover), L2 (looser caps as the “real” working set), and the aggregator’s level wiring.

Manifest (app.yml)

  • ServiceAccount + Role + RoleBinding — read pods/endpoints in the namespace, scoped to the git-gud ServiceAccount.
  • Headless Service git-gud-headless (clusterIP: None, publishNotReadyAddresses: true) exposes epmd:4369 + dist:9100 so libcluster (mode: :ip) can resolve peers and rolled pods can join the cluster before readiness probes pass.
  • New env vars: LIBCLUSTER_*, POD_IP (from status.podIP), RELEASE_NODE=git_gud@$(POD_IP), RELEASE_DISTRIBUTION=name, RELEASE_COOKIE (from elixir-args/erlang_cookie). Pod container exposes 4369 and 9100 ports.
  • app-secrets.example.yml gains an erlang_cookie placeholder with the openssl rand -hex 32 recipe.

Tests

  • The Multilevel adapter starts cleanly under test (no libcluster topology configured → L2 runs single-member), suite is 501 / 501 on the new cache shape.

Slice 56 — gix NIF fill-in: commit / log / tree

Wires the three request-path hot operations through gitoxide. Previously every commit fetch, every directory listing, and every history page forked a git subprocess via the Cli backend. Now those land in-process; the Cli backend stays the floor for the remaining ops via per-call fallback.

What’s wired

  • commit(path, sha)repo.find_object(id).try_into_commit().decode() → atom-keyed map matching the Cli shape exactly: sha, tree_sha, parent_shas: [sha], author_name, author_email, authored_at, committer_name, committer_email, committed_at, message.
  • log(path, start_sha, opts)repo.rev_walk([id]).all() newest-first, honors :limit (default 50) and :skip. Per-commit serialization reuses the same commit_to_map/3 helper as commit/2.
    • When opts[:paths] is set, returns {:error, :unsupported} so the facade falls back to Cli (git log -- <paths>). The single existing call site (CommitBackfill) doesn’t use path filtering.
  • tree(path, sha)find_object → try_into_tree → decode → entries with name, mode (octal string), type (tree/blob/commit), sha (20-byte binary), size (blob size via find_header, nil for non-blobs to match the Cli’s ls-tree --long - cells).

Dates across the boundary

  • gix returns commit times as unix seconds + offset. The NIF emits %Elixir.DateTime{} structs (normalized to UTC; the offset is dropped) built field-by-field via a map_of helper with :__struct__ => Elixir.DateTime, :calendar => Elixir.Calendar.ISO, year/month/day/hour/minute/second from chrono, :microsecond {0, 0}, :utc_offset 0, :std_offset 0, :zone_abbr "UTC", :time_zone "Etc/UTC" — the last two are strings (Ecto’s check_utc_timezone! does a string equality check).

Deferred (still on Cli)

  • diff_stats, merge_base, merge_tree, commit_tree, update_ref — return {:error, :unsupported}, which the facade’s dispatch/2 already handles by re-applying the same args to the Cli backend. gix 0.66’s surface for these is churning across point releases (gix-diff types diverged between our explicit gix-diff dep and gix’s transitive copy; repo.merge_base was renamed/relocated). Cli works correctly today; lift them when the gix API for our pinned version settles.

Backend selection

  • GitGud.Git.pick_backend/0 already preferred Nif when available?/0 is true. With the NIF now exporting the new functions, current_backend/0 reports GitGud.Git.Nif in default config; nothing extra needed.

Tests

  • The full suite is the spec — every existing test exercising the API now runs through the NIF where supported, and through Cli for the deferred ones. The pre-existing JWT-tampering flake aside, the suite is 501 / 501 on clean seeds.

Slice 55 — Org members management

New admin page to add / remove / promote / demote org members. Last-admin guard: the org can never end up without an admin.

Context

  • Organizations.list_members/1 — preloads :user, orders admins first then by handle/email for stable display.
  • Organizations.set_member_role/3 — validates "admin" | "member", returns {:ok, m} (no-op on same-role), {:error, :last_admin} (demoting the only admin), {:error, :not_a_member}. Uses a small internal admin_count/1 to gate.
  • Organizations.remove_member_guarded/2 — wraps remove_member/2 with the same last-admin check, returns :ok | {:error, :last_admin | :not_a_member}.

LiveView

  • OrgLive.Members at /orgs/:handle/settings/members. Admin-only; non-admins push-navigate back to the org page with a flash.
  • Add-member form: type a handle or email; resolves via Accounts.get_user_by_handle/1 or a Repo.get_by(User, email: …) lookup. Adds as member (admins can promote afterwards).
  • Members list: avatar + display name + handle + email, role <select> per row (auto-submits on change), remove button. Inline flashes on guard rejections.
  • Nav link added to the org-show admin nav after “Profile”.

Tests

  • org_members_test.exs — 9 tests: list_members/1 ordering; set_member_role/3 promote/demote, last-admin refusal, no-op same-role, not-a-member; remove_member_guarded/2 member removal, last-admin refusal, removing one admin when another exists, non-member case. 501 tests pass overall.

Slice 54 — Org visibility + public-org anonymous access

Orgs grow the same public | internal | private enum repos already had; the org’s visibility caps what its repos can be; and public orgs no longer require login to view.

Schema

  • Migration 20260513340000organizations.visibility :string NOT NULL DEFAULT 'public', plus a CHECK constraint enforcing the three-value enum at the DB layer.
  • Organization.visibility_rank/1 — public=0, internal=1, private=2. Higher rank = less public.
  • Organization.changeset/2 + visibility_changeset/2 validate inclusion in the enum.

Cascading enforcement

  • Repositories.create_repository_for_org/3 now refuses repos more open than the org with {:error, {:visibility_too_open, "<org_vis>"}}. The check uses Organization.visibility_rank/1 so the rule is a one-line comparison.
  • Repositories.update_repository/2 runs the same check via maybe_check_org_visibility/2 — passing the proposed visibility against the org’s current setting.
  • Organizations.update_organization_visibility/2 is the new path for changing the org’s visibility. When tightening (e.g., public → internal), every repo currently more open than the new floor is bumped to match. Wrapped in a transaction; loosening leaves repos alone.

Anonymous access

  • /orgs/:handle moved out of :authenticated_repo and into the :public_repo live session — same pattern as /r/:owner/:name. The LV does the visibility gate:
    • public orgs: anyone (including anonymous) loads the page.
    • internal orgs: any signed-in user.
    • private orgs: only members (Organizations.member?/2).
    • Mismatches push_navigate to / with a flash.
  • The repo list shown on the org page is filtered by visibility for the viewer — non-members never see private repos even if the org itself is public.
  • Organizations.member?/2 — new helper, parallel to org_admin?/2. Returns false for nil user.

UI

  • OrgLive.Index new-org form adds a Visibility select (3-option dropdown) with a one-line note that “repos can’t be more open than the org.”
  • OrgLive.SettingsProfile gets a “Visibility” section above the existing Public-profile section, with a select + Save button. Wired to update_organization_visibility/2.
  • Description (the existing top-level field that was previously only settable on creation) now editable from settings/profile alongside the rest, via an extended Organization.profile_changeset/2.

Tests

  • organization_visibility_test.exs — 8 tests: rank ordering; create rejects too-public repos for internal + private orgs; cascade bumps over-public repos when tightening; loosening doesn’t touch repos; member?/2 membership check.
  • org_live/show_visibility_test.exs — 4 tests: anonymous can view public org; anonymous bounces on internal; non-member bounces on private; member can view private.
  • 492 tests pass overall.

Slice 53 — Registration modes + invite system

Operators can gate who can sign up. Pattern ported from diogramos, extended from the binary (open / closed) shape into three explicit modes.

Config flag

  • config :git_gud, :registration_mode, :open | :invite_only | :closed (default :invite_only — safer for fresh deploys; the test env overrides to :open so the existing open-registration assertions keep passing).
  • Accounts.registration_mode/0 accepts both atom and string values (so env-var-driven :runtime.exs config works the same) and falls back to :invite_only for unknown values.
  • Accounts.register_user/1 short-circuits with {:error, :registration_closed} when the mode is :closed — defense in depth so a direct API call doesn’t bypass the LV gate.

Schema + context

  • Migration 20260513330000invites(token unique, note, created_by_id, consumed_by_id, expires_at, consumed_at, timestamps). Both FK refs ON DELETE SET NULL.
  • GitGud.Accounts.Invite schema with create_changeset/2 (auto-generates a 24-byte URL-safe token if missing), consume_changeset/2 (idempotent — re-consume is a no-op), and active?/1 (true when not consumed and not past expires_at).
  • Accounts.create_invite/2, list_invites/1, get_active_invite/1, consume_invite/2, revoke_invite/2. Revoke is owner-only — {:error, :forbidden} for anyone else. Revoking stamps consumed_at without a consumed_by_id, distinguishing revoked from used.

Registration LV

  • Resolves a three-way gate assign on mount: :closed, :invite_required, or :open.
  • :closed shows a hard “registration is closed” alert and disables the form.
  • :invite_required shows the same alert with friendlier copy (“you’ll need an invite link from an existing member”).
  • :open with a valid ?invite=<token> shows a welcome banner and proceeds. Invite is consumed on successful registration.
  • :open without an invite is the normal path.
  • save handlers reject submissions under :closed / :invite_required modes as a belt-and-braces guard.

Invites LV

  • /users/settings/invites — per-user invite management. Stream of the user’s invites, “New invite” button creates one, copy-friendly URL rendered inline (~p"/users/register?invite=#{token}"), revoke button while active.
  • Mode-aware banner: open mode notes invites aren’t required; closed mode warns that even invite links won’t help right now.
  • Link entry added to UserLive.Settings (“Manage invites”) alongside the other account-management entries.

Tests

  • invites_test.exs — 12 tests: registration mode resolution (default, atom, string, garbage), register_user/1 enforcement of :closed, invite creation + uniqueness, list_invites/1 ordering, get_active_invite/1 for missing/active/consumed, idempotent consume, owner-only revoke, Invite.active?/1 truth table. 480 tests pass overall.

Slice 52 — Org profile editor

Symmetry with the user-side settings: orgs need their own editor for the slice-48 profile fields + slice-50 pinned repos. Schema was already there since slice 48; this slice ships the UI.

LiveView

  • OrgLive.SettingsProfile at /orgs/:handle/settings/profile. Admin-only — non-admins (including non-members) hit push_navigate(/orgs/:handle) with a flash.
  • Mirrors the structure of UserLive.Settings’ profile section: same fields (display_name, bio, url, location, company, public_email, timezone), same platform | url socials textarea parsed into the JSONB shape, same pin-management UI (drag-to-reorder grid + add/remove form).
  • Pin-management uses the existing PinReorder hook + PinnedReposGrid component from slice 50; no new JS.
  • The “Pin” picker for orgs lists repos owned by the org (via Repositories.list_repositories_for/1).

Nav

  • The org-show page’s admin nav (next to Secrets / Runners / Labels) grows a new “Profile” link as its first entry.

Tests

  • org_live/settings_profile_test.exs — 3 tests: non-admin is bounced (:live_redirect), admin sees the editor, admin can update profile fields including the socials block. 468 tests pass overall.

Slice 51 — Auto-created same-name profile README

Fourth and final slice of the profile arc. A user alice gets an alice/alice repo auto-provisioned on signup; an org acme gets acme/acme. The repo’s README.md (on the default branch) renders inside the profile page. Same pattern GitHub uses, no special-case storage — it’s a regular repo, so all the usual editing, branching, federation, etc. just work.

Provisioning

  • Repositories.ensure_profile_repo/1 — idempotent. For users, the repo is <handle>/<handle> owned by the user with no organization; for orgs, <org-handle>/<org-handle> is owned by the org with owner_id recorded as an admin for the audit trail. Visibility is public. The seed description is a one-line hint about what the repo is for.
  • Hooked into Accounts.register_user/1 and Organizations.create_organization/2 as best-effort. If the disk-side git init fails (stale tmp/, permission glitch, etc.), the error is logged and the surrounding action proceeds — registration shouldn’t fail because the profile-repo wasn’t initable.
  • The check for existing repos uses from … where: owner_id == ^uid and is_nil(organization_id) and name == ^h (rather than Repo.get_by/2 with a nil keyword) because Ecto’s nil comparison guardrail otherwise raises in production-mode queries.

Backfill

  • mix gitgud.profile_repos.backfill — walks every existing users + organizations row and calls ensure_profile_repo/1. Prints a created/skipped/failed summary at the end. Idempotent; safe to re-run.

README rendering

  • Repositories.get_profile_readme/2 — locates the <handle>/<handle> repo, resolves its default branch, walks the tree, finds readme(\.md)? case-insensitively, fetches the blob bytes, and either renders markdown (when .md) or wraps as preformatted text. Returns {:ok, html_string} or nil.
  • Tolerant of every empty state: missing repo, missing default branch, missing README — all collapse to nil and the profile page just skips the section.

Profile rendering

  • UserLive.Profile + OrgLive.Show both load the README on mount and render it on the right column (below Pinned, above Repositories) in a prose article box. An “Edit in <handle>/<handle> →” deep-link shows for the self / admins.

Tests

  • profile_repo_test.exs — 7 tests: registration auto-creates the repo with the right owner/visibility/name, ensure_profile_repo/1 is idempotent, registration doesn’t fail when the repo already exists, org creation auto-creates the org repo, get_profile_readme/2 returns nil when there’s no profile repo, registered users get the seeded README, registered orgs get the seeded README.
  • One existing test in profiles_test.exs was updated to account for the now-auto-created profile repos showing up in list_pinnable_for_user/1’s output (asserts via subset / refute-membership rather than equality).
  • 465 tests pass overall.

Follow-up — seeded default README

The auto-created repo is no longer empty. After the bare repo is initialized, seed_profile_readme/2 stages a one-file initial commit:

  • Write blob: content goes through a tmp file + git -C <path> hash-object -w <tmp> (cleaner than wrestling Port-based stdin half-close from Erlang).
  • Make tree: git -C <path> mktree < <tmp> with one 100644 blob <sha>\tREADME.md line, run through sh -c for stdin redirection.
  • Commit: reuses the existing GitGud.Git.commit_tree/5 facade (CLI backend) with author/committer = the default "GitGud" <noreply@gitgud.local>. No parents.
  • Ref: Git.update_ref/4 writes refs/heads/<default_branch> (typically main) at the new commit.

The content differs slightly between users ("Hi, I'm @alice 👋…") and orgs ("# acme — This is the Org README…") so the placeholder reads sensibly out of the box. Best-effort: any plumbing failure is logged and the empty-repo state is preserved — registration still succeeds. Backfill works the same way for existing rows.

Slice 50 — Pinned repos with drag-to-reorder

Third of the profile arc. Up to 7 pinned repos per user or org, manageable from settings and rendered as a card grid on the profile.

Schema

  • Migration 20260513320000pinned_repositories(id, owner_id, organization_id, repository_id, position, timestamps). CHECK constraint enforces XOR — exactly one of owner/organization must be set.
  • Partial unique indexes: (owner_id, repository_id) WHERE owner_id IS NOT NULL and the symmetric one for orgs, so the same repo can be pinned by both its user owner and an org but not twice on the same scope.
  • Partial composite indexes on (owner_id, position) and (organization_id, position) for the ordered-list query.

Context — GitGud.Profiles

  • list_pins/1 — repository preloaded with :owner, :organization, ordered by position asc. Polymorphic on %User{} / %Organization{}.
  • pin/2 — appends at the end (position = current_count). Returns {:error, :max_reached} when the limit (max_pins/0 = 7) is hit. Duplicate pins surface as a unique-constraint changeset error from the partial index.
  • unpin/2 — deletes the pin AND compacts remaining positions back to 0..n-1 so reorder math stays clean.
  • reorder/2 — accepts a list of pin IDs (or strings — the JS hook sends strings). Validates each id belongs to the owner, dedupes, then writes the new position for each. Pins not in the list keep their relative order at the end, so partial reorders are safe. Wrapped in a transaction.

Drag-to-reorder hook

  • PinReorder in assets/js/app.js. No third-party dep — uses HTML5 dragstart / dragover / drop. The hook attaches itself to the grid container and wires every child with data-pin-id={id} for drag.
  • On drop, pushes "reorder_pins" with %{"order" => [...ids in DOM order]}. The LV handler delegates to Profiles.reorder/2 and re-renders.
  • Picks “drop before” vs “drop after” based on the cursor’s x position relative to the target card’s midpoint — feels right for a horizontal row, still works fine when cards wrap to a new row.

Settings UI (user)

  • New “Pinned repositories” section at the top of UserLive.Settings. Renders the grid with reorderable?={true}, plus an explicit unpin button per row and a pick-a-repo select-and-Pin form below.
  • Add form is hidden when the cap is reached. Select shows only repos the user owns that aren’t already pinned.

Profile rendering

  • GitGudWeb.PinnedReposGrid.grid/1 — shared component. CSS grid: 1 col mobile, 2 sm, 3 lg. Each card: handle/name link, description (line-clamped), top language indicator (color swatch + name pulled from git_commit_language_stats for the HEAD of the repo’s default branch).
  • Rendered on UserLive.Profile and OrgLive.Show (read-only, no reorderable?).

Tests

  • profiles_test.exs — 8 tests: append-at-end positions, cap at 7, duplicate-pin changeset error, unpin compacts, reorder applies + drops unknown ids, partial reorders preserve untouched relative order, string ids parse cleanly (mimics the JS payload shape), per-user scoping. 455 tests pass overall.

Follow-up — org-member pinning + removal cleanup

  • Repositories.list_pinnable_for_user/1 — returns own repos + repos in any org the user is a member of, deduped. Settings UI swaps from list_repositories_for/1 to this so members can show off org work on their personal profile.
  • pin handler in UserLive.Settings validated via pinnable_by_user?/2 — own-repo + nil-org branch, OR org-repo with membership check. Mismatched picks (someone else’s private repo, etc.) get a flash and no DB write.
  • Pinnable <option> labels include the namespace (acme/site not just site) so users disambiguate their pinned ord vs personal repos in the picker.
  • Profiles.unpin_org_repos/2 — drops every pin a user has on repos belonging to a given org, then compacts positions.
  • Organizations.remove_member/2 now calls that helper after the membership delete. Removed members stop advertising work they no longer have access to. Org-level pins (organization_id set) are unaffected — they belong to the org, not the removed user.
  • 3 new tests covering the cleanup (target user’s pins go, other members’ pins stay, owner’s pins stay) and the expanded pinnable list. 458 tests pass overall.

Slice 49 — Theme-aware identicon avatars

Second of the profile arc. No image storage, no upload pipeline yet — every user, org, and namespace gets a deterministic identicon-style avatar whose colors come from the active daisyUI theme.

Component

  • GitGudWeb.AvatarComponent.avatar/1 — inline SVG, 5×5 grid, GitHub-identicon-style left/center cells mirrored on the right. Wrapped in a rounded-full overflow-hidden <span> so any future uploaded image drops in unchanged.
  • Imported into every LV via git_gud_web.ex’s html_helpers/0, so <.avatar /> is available globally next to <.icon />.

Theme tracking

  • Cell fills use style="fill: var(--color-primary)" (or secondary, accent, info, success, warning — picked from the seed’s hash byte). Background is bg-base-200 on the wrapper.
  • Switching daisyUI themes recolors every avatar live with zero re-render — the SVG references CSS variables, not baked-in hex.

Deterministic generation

  • SHA-256 of the seed name → 32 bytes. First byte mod palette length picks the foreground slot. Next 15 bits drive the cells: 5 rows × 3 left/center columns, each bit toggles its cell on/off. Left half + center is rendered; right half mirrors.
  • render_spec/1 is pure — {fg_color, [{x,y}…]} — so it’s trivially testable.

Sizes

  • Tailwind size classes — xs (size-4), sm (size-6), md (size-8), lg (size-12), xl (size-16), 2xl (size-24). Two-character viewBox keeps the cells crisp at every size.

Wiring

  • User profile (UserLive.Profile) and org profile (OrgLive.Show) — size="2xl" next to the title.
  • Repo headersize="sm" inline before the {handle} link, so every repo page (after slice 47) carries a tiny identifier of its namespace.
  • Issue + PR commentssize="xs" before the author name.

Tests

  • avatar_component_test.exs — 9 tests: determinism per seed, divergence across seeds, palette membership, symmetric mirroring around x=2, in-grid coords, render emits SVG + rounded wrapper, fills always reference CSS vars (no hard-coded hex), size mapping, render stability. 447 tests pass overall.

Slice 48 — User + org profile pages

First of four slices building out user/org profiles. This one ships the schema, the public profile page, the namespace-half link in the repo header, and the settings form.

Schema

  • Migration 20260513310000 — adds handle (citext, unique, NOT NULL) to users, plus the shared profile field block (display_name, bio :text, url, location, company, public_email, timezone, socials :map) to both users and organizations.
  • Existing rows get a handle backfilled from lower(split_part(email, '@', 1)) with _N suffixes on collisions, all in a single SQL UPDATE ... FROM (ranked CTE). So pre-existing users on dev/prod databases acquire URL-safe handles without anyone claiming one.

Accounts

  • User.email_changeset/3 seeds handle automatically when missing (regex-gated; falls back to nil if the email local-part isn’t URL-safe). Carries a unique_constraint(:handle) so insert collisions surface as a changeset error instead of a Postgres exception.
  • Accounts.register_user/1 now retries up to 8 times with _N suffix on the handle if a duplicate collides — so signing up alice@a.example and alice@b.example produces alice and alice_1 cleanly.
  • User.profile_changeset/2 — separate cast that only touches the profile field set, with length caps on every string field.
  • Accounts.update_user_profile/2 + get_user_by_handle/1 — domain-level helpers.
  • User.display/1 — common fallback chain display_name → handle → email-local → "(unknown)".

Organizations

  • Organization.profile_changeset/2 + Organizations.update_organization_profile/2 + get_organization_by_handle/1 (non-bang) + Organization.display/1 — exact same shape as the user side.

Profile page

  • New UserLive.Profile at /u/:handle. Picked /u/ to avoid colliding with /users/log-in, /users/register, /users/settings/* — Phoenix would otherwise route those into the profile LV.
  • Renders header (display name + @handle), profile detail block, repository list (filtered by visibility for non-self viewers), and an “Edit profile” button for the self.
  • OrgLive.Show extended to render the same profile detail block under its existing header.

Shared component

  • GitGudWeb.ProfileComponents.details/1 — single component used by both profile pages. Takes a User or Organization struct (both carry the same fields) and renders bio + company + location + url + public_email + timezone + socials in a uniform <dl> block with heroicon row gutters.
  • social_items/1 — tolerant parser of the socials JSONB shape (%{"items" => [%{"platform","url","handle"}, …]}). Empty / missing / malformed entries get filtered out silently.

Repo header: clickable namespace

  • The {handle}/{name} title in RepoLive.Header.header/1 was a single repo-home link. Split into two — the handle half goes to /u/:handle for user-owned repos or /orgs/:handle for org-owned, the name half stays linked to the repo. The / separator stays dim.

Settings form

  • UserLive.Settings gains a “Public profile” section above the existing email/password/theme blocks. One simple input per field plus a Social links textarea where each line is platform | url — parsed into the JSONB shape on submit. Crude but functional editor for the v1; a proper repeating-row editor is a slice-49 follow-up if needed.

Storage handle resolution

  • Storage.owner_handle/1 for a User now prefers user.handle (when present) and falls back to email-local — so existing tests that build users without explicitly setting handle still work.

Tests

  • profile_fields_test.exs — 9 tests: handle seeding from email, _N suffixing for collisions, case-insensitivity, get_user_by_handle/1 missing → nil, profile_changeset cast scope + length caps, User.display/1 fallback chain, org-side update + Organization.display/1 chain. 438 tests pass overall.

Slice 46 — Clone-URL copy widget

Tiny widget on the repo show page that lets a viewer grab the SSH or HTTPS clone URL without leaving the page. Inline by the ref-picker.

Layout (joined button group)

[ ▶ | url-text | HTTP | SSH | 📋 ]
▲ only at 2 ▲ select only at ≥1
chevron rotates per state

Three cycle states driven by cycle_clone_widget:

State Visible
0 chevron + HTTP + SSH (compact)
1 + copy button
2 + inline URL text + copy button

The chevron’s rotate-{0,90,180} Tailwind class flips per state so the user has a visual cue of where they are. HTTP/SSH toggle the active transport — whichever is highlighted is what the copy button (and inline URL) reflects. The existing set_transport event handles the toggle.

Clipboard copy

  • The copy button fires Phoenix.LiveView.JS.dispatch("git_gud:copy", detail: %{text: url}). A small listener in assets/js/app.js reads e.detail.text and writes to navigator.clipboard, falling back to a hidden-textarea execCommand("copy") when the async clipboard API is unavailable (non-secure context, older browsers).
  • The dispatch chains a brief scale-110 JS transition for tactile feedback.
  • No server round-trip for the copy itself — pure client-side. The LV stays responsible only for cycling state + toggling transport.

URL builders

  • Reused the existing http_clone_url/2 and ssh_clone_url/2 helpers that were already in RepoLive.Show powering the empty-repo onboarding panel. Same string output, so SSH-key prompts and the empty-state instructions stay consistent with what the widget shows.

429 tests pass (no new tests — pure UI; existing show-page tests still cover render shape).

Slice 45 — Language stats bar + detail page

Adds a tiny (0.3em) GitHub-style language strip to the repo show page and a /stats/languages detail view behind it. Stats are computed from existing PG caches (no extra git walk on the request path) and recomputed lazily via Oban.

Storage

  • Migration 20260513290000git_commit_language_stats(repository_id, sha, languages jsonb, total_bytes, file_count, computed_at). Pkey (repository_id, sha); commit SHAs are immutable so this never goes stale.
  • languages is a {"items" => [%{"language", "bytes", "files"}, …]} map, sorted desc by bytes when written.

Compute path (no extra git CLI work)

  • Repositories.compute_and_store_language_stats/2 walks the cached tree from git_trees, recurses into subtrees, and looks up each blob’s detected_language + size from git_blob_meta. Binary blobs and unrecognized-language blobs are dropped. Bytes are summed per language; file counts kept alongside.
  • Both caches are populated naturally by commit_backfill on push (trees) and by the blob-view path (blob meta). So by the time a viewer opens the repo page after a push, the inputs are typically warm.
  • Write uses on_conflict: {:replace, [...]} on (repository_id, sha) so a re-run cleanly overwrites stale numbers if the underlying detection improves.

Worker

  • GitGud.Repositories.Workers.LanguageStats — Oban worker, :git_cache queue, max attempts 3. Unique on (repository_id, sha_hex) across available + scheduled + executing states with period: :infinity — duplicate enqueues collapse, the result is the same per immutable commit SHA.
  • Enqueued from CommitBackfill for every refs/heads/* update — the new HEAD’s stats are computed in the background as part of the push pipeline.
  • Also enqueued lazily from RepoLive.Show and RepoLive.Languages when a viewer hits a commit that pre-dates this slice (or whose computation got dropped). Idempotent dedupe handles the race.

Read path

  • Repositories.get_language_stats/2 — cache-only read. Returns {:ok, stats} or :not_computed. Never falls through to walking git on the request path; the bar simply doesn’t render until the worker fills it in.
  • RepoLive.Show.load_language_stats/2 calls this and, on :not_computed, enqueues the worker and returns nil so the bar stays hidden.

UI — the bar

  • New language_bar/1 component on RepoLive.Show, rendered between the ref-picker and the file tree. 0.3em tall (≈4.8px at 16px base), rounded-full, segments colored from GitGud.LanguageColors.
  • Caps at top 6 languages plus an “Other” segment so very polyglot repos still render a usable bar. Each segment carries a title="Language · %" tooltip; the link’s title= is a one-line summary of the top languages.
  • Whole bar is a <.link navigate=...> to the detail page.

UI — /r/:owner/:name/stats/languages

  • New LiveView RepoLive.Languages. Three states:
    • :empty — branch resolves to nothing (empty repo or unknown ref).
    • :pending — stats not computed yet; renders a “background job running, refresh shortly” notice. Enqueues the worker just like the show page.
    • :ready — renders a full-width 1em bar above a table: color swatch · language · percent · bytes · files. Totals row at the bottom; “Computed at” timestamp underneath.
  • Route nested under /stats/... so future stats pages (contributors, churn) can live alongside without renaming.

Language palette

  • GitGud.LanguageColors — curated map of ~55 GitHub-linguist hex colors for the languages Lumis detects. Unknown-language fallback hashes the name (SHA-256, mid-clamped channels) into a deterministic muted color so each unknown is at least consistent across pages. Nil/empty falls back to #888888.

Tests

  • repositories_language_stats_test.exs — 6 tests synthesizing PG cache rows directly (no git CLI). Covers bucketing by language sorted desc, recursion into subtrees, on_conflict: replace semantics for re-compute, persistence to git_commit_language_stats, and the get_language_stats/2 read path including :not_computed.
  • language_colors_test.exs — 3 tests: known palette, hash-fallback determinism + hex shape, nil/empty fallback.
  • 419 tests pass overall.

Detection fix + per-committer stats

Two follow-ups in the same session:

Fix — language detection by filename. The initial compute keyed off git_blob_meta.detected_language, but Git.blob_meta/2 doesn’t actually populate that field — every blob came back nil, so the bar showed 0 B / 0 files. New GitGud.Languages.detect/1 (extension + well-known basename map: Dockerfile, Makefile, mix.exs, etc.) is now called directly during the tree walk, using the filename already present on git_trees.entries. No per-blob lookup needed. Migration 20260513300000 truncates git_commit_language_stats so the next page load triggers a clean recompute. 4 new tests on Languages.detect/1.

Per-committer stats. New Repositories.committer_stats/1 runs a single GROUP BY over git_commits LEFT JOIN git_commit_diffstats, returning one row per author_email with commit count, summed insertions/deletions, and first/last commit dates. No table, no worker — computed at request time since the underlying caches are already populated on push by CommitBackfill. Authors are coalesced by email verbatim (.mailmap support deferred).

  • /r/:owner/:name/stats/committers new LiveView. Table: avatar swatch (deterministic hash of email) + initial · name+email · commits · +/− · first · last. Empty state when no commits are cached yet.
  • Shared RepoLive.StatsTabs.tabs/1 — sibling tabs on both /stats/languages and /stats/committers. Both pages now share a “Stats” header with a per-page active tab, modeled on GitHub Insights.
  • 6 new tests on committer_stats/1 (group-by author + sort, sum via diffstats join, commits without diffstats counted as zero churn, first/last span all authored_at, per-repo scoping, empty repo → []).
  • 429 tests pass overall.

Slice 44 — Replace-with-mod-message moderation (+ undo)

Replaces the slice-42 hard-delete with a soft, reversible flow. Reported content gets its body swapped out for a moderator note; the original is preserved and revealable to the people who need to see it.

Schema

  • Migration 20260513280000 — adds moderated_at, moderated_by_id (FK to users, ON DELETE SET NULL), moderation_note :text, original_body :text to issues, issue_comments, pull_requests, pr_comments. One (moderated_at) index per table.
  • Each of the four schemas gains moderate_changeset/3, restore_changeset/1, and moderated?/1. moderate_changeset snapshots bodyoriginal_body (only if not already snapshotted, so re-moderating doesn’t clobber the original).

Reports module

  • Reports.moderate_target/3 — replaces the old delete_target/1. Takes the report, the moderating user, and an optional note. Empty or whitespace-only note falls back to "Removed by moderator". Notes are trimmed and clipped at 500 chars. Returns {:ok, target} or {:error, :not_found | :unmoderatable_target_type}.
  • Reports.restore_target/1 — reverses a previous moderation. Returns {:error, :not_moderated} if the target isn’t currently moderated, so admins don’t accidentally clear a non-existent original.
  • Preview shapedeletable?: booleanmoderatable?: boolean + moderated?: boolean. Excerpts now prefer original_body so admins reviewing the queue see what was actually reported, not the placeholder.

Admin UI

  • Reports queue: the old single “Delete content” button is replaced with one of two states based on preview.moderated?:
    • Not moderated: an inline form with an optional one-line note input and a “Replace with mod message” submit (phx-submit="moderate_content"). data-confirm gates the action.
    • Already moderated: a “Restore original” button (phx-click="restore_content").
  • Both auto-resolve the report differently — replace marks it actioned with "content replaced with mod message"; restore leaves the report state alone (admin decides separately whether to dismiss).

Issue / PR rendering

  • New GitGudWeb.ModerationComponents.moderated_body/1 — single shared component used by IssueLive.Show and PrLive.Show for issue/PR bodies and comments. When moderated_at is set, renders a warning banner (hero-shield-exclamation + the mod note) instead of the body.
  • Show-original toggle — admins and the original author get a <details> with the original body rendered as Markdown. Everyone else sees only the banner. No LiveView state needed; the native <details> element handles the toggle.
  • can_view_original?/2 is a pure function (admin OR author_id == viewer.id) so it’s trivially unit-testable.

Post order preserved

  • Moderated content keeps its slot in the thread because the row’s id and inserted_at don’t change — only the body is replaced.
  • Made that guarantee explicit by adding order_by: [asc: inserted_at, asc: id] to the comment + reviews preloads on Issues.get_issue!/2 and PullRequests.get_pull_request!/2. The previous code relied on Postgres’s implementation-defined preload order.

Refuse re-reports on moderated content

  • Reports.open/4 now checks if the target is already moderated and returns {:error, :already_moderated} if so. No need to re-report something an admin has already replaced.
  • The flag (report) button is hidden in the UI on already-moderated issues, PRs, and comments — admins still see “Restore original” in the queue, but viewers can’t pile new reports onto something that’s already been actioned.
  • LV report handlers in IssueLive.Show and PrLive.Show surface the new error with a flash explaining “This content has already been moderated.”

Tests

  • Updated reports_actions_test.exs: dropped the delete_target tests, added 12 new ones for moderate_target (basic flow, default note, whitespace-only note, 500-char clip, missing row, unmoderatable type, re-moderate-doesn’t-clobber), restore_target (round-trip, not-moderated error, missing row), and open/4 vs already-moderated (refuses for moderated targets, allows for unmoderatable kinds like actors).
  • Added moderation_components_test.exs — 4 tests on can_view_original?/2 covering admin, author, neither, and anonymous viewer.
  • 410 tests pass overall.

Slice 43 — Lazy per-file diff expansion

Closes the perf item from slice 33: the initial render of a many-file diff no longer puts every hunk in the DOM.

  • DiffComponents.diff/1 API change: expanded_default (integer) replaced with loaded_idx (MapSet of file indices whose hunks should be rendered). Files not in the set render only the header + a “Click to load diff…” placeholder. The <details> element stays — but for unloaded files it’s closed and the inner <table> of hunks isn’t in the DOM at all.
  • DiffComponents.default_loaded/1 — derives the initial set from file count: ≤10 → preload all, ≤50 → first 5, otherwise first 2. Same tier the old expanded_default/1 used, lifted into the component so the three callers don’t each carry their own copy.
  • DiffComponents.expand/2 — one-line socket update: MapSet.put(loaded_diff_idx, idx). Each LV’s handler is now def handle_event("expand_diff_file", params, socket), do: {:noreply, DiffComponents.expand(socket, params)}.
  • Three call sites updatedRepoLive.Compare, RepoLive.Commit, PrLive.Show. Each assigns loaded_diff_idx after computing diff_files and delegates the expand event. The per-LV expanded_default/1 helpers are gone.
  • Click semantics: phx-click="expand_diff_file" rides on the <summary> element. Native <details> open/close still works on subsequent clicks; the LV event is idempotent (MapSet.put on an already-present key) so re-clicking a loaded file is a no-op.
  • Cleanup: moved two defp helpers in AdminFederationLive that sat between handle_event clauses, killing a pre-existing “clauses with the same name and arity should be grouped together” warning. mix compile --warnings-as-errors is now green.
  • 9 new tests covering default_loaded tiers, expand (success + garbage-idx no-op), and component render (loaded vs unloaded placeholder, default fallback, empty-list). 397 tests pass overall.

Slice 42 — Actionable reports queue

Closes the moderation loop: admins can now see what was reported and act on it without leaving the page.

  • Reports.preview/1 — resolves a {target_type, target_id} to a uniform map: kind, title, excerpt (first ~240 chars of body), url (deep-link into the issue / PR / actor page), author (display name), deletable?, suspendable_actor_id. Supported targets: issue, issue_comment, pull_request, pr_comment, actor, inbox_delivery. Missing rows return nil (the queue tolerates orphan reports rather than crashing on a stale FK).
  • Reports.delete_target/1 — soft-deletes reported issue / pr / comment content (sets body to a tombstone marker, preserves the row so threads don’t reflow). Rejects unsupported target types so admins can’t accidentally delete an actor profile via the wrong button.
  • Reports.suspend_actor/1 — wraps the existing Federation.suspend_actor/1 so a single click from the report queue both stops inbound activity and records the action on the report.
  • Admin federation tab — reports: each pending report now renders with an inline preview card (kind badge + title + excerpt + deep-link), followed by action buttons: Delete content (if deletable?), Suspend actor (if suspendable_actor_id), Forward to source (federated reports), Mark actioned, Dismiss. Reports whose target has already been deleted upstream show a “target gone” placeholder so they can still be dismissed cleanly.
  • 8 new tests: preview shape for issue / actor / missing; delete_target success / :not_found / unsupported-type rejection; suspend_actor success / not_found. 388 tests pass overall.

Slice 41 — Quote-reply on comments

Tiny UX slice — every comment grows a “Quote” button next to the existing “Report” flag. Click it and the comment’s body lands in your reply textarea as a Markdown blockquote, prefixed with > @author wrote:.

  • Markdown.quote_block/2 — prefixes every line of the original body with > , attributing it to @author. Handles already-quoted text by adding another > level (so nested quotes work).
  • Issue + PR LiveViews gained a quote_reply handler that fetches the comment by id from the in-memory thread, runs quote_block, and appends to the in-progress reply textarea separated by a blank line.
  • The textarea is bound to socket.assigns.comment_body (was a plain uncontrolled <textarea> before) so the LiveView can mutate it. phx-change="update_comment_body" keeps the assign in sync with user typing.

Slice 40 — Cross-references in markdown + new-issue flow

Cross-references

#42 and owner/repo#42 in markdown now auto-resolve to issue or PR links wherever a repo context is present.

  • Issues.lookup_referent/2 — number → {:issue, struct} | {:pull_request, struct} | nil. PR and issue numbering share a sequence per repo (via Repositories.Numbering) so a given number is unambiguous.
  • Markdown.to_html(body, repo: repo) — gates expansion on the :repo opt; without it the renderer is unchanged (so README + wiki + blob views, which legitimately contain #42 in unrelated contexts, stay literal).
  • Pre-pass walks the body alternating code / non-code regions — single backticks, triple backticks, and nested fences all preserve their contents as-is so #42 inside \#42`` or a fenced block stays literal.
  • Regex: (?<![\w/])(?:owner/repo)?#(\d+)(?![\w]). The anchors keep us from matching inside identifiers, URLs (/foo#bar), or hashes (a1b2c#).
  • Cache key extended to include repo_id so the same body in two different repos can’t collide.
  • Repositories.find_by_path/2 — non-raising sibling of get_repository_by_path!/2, used by the cross-repo branch.
  • Issue + PR show LiveViews wired through with :repo. Wiki / README / blob explicitly NOT wired — code in source files often has # for legitimate reasons unrelated to issue refs.
  • 7 new tests covering current-repo issue resolution, PR resolution, unresolved-passthrough, code-span isolation, fenced-block isolation, cross-repo expansion, no-repo passthrough.

New issue flow

  • Write / Preview tabs — click “Preview” to see the markdown render (with cross-refs applied) before submitting.
  • Label picker — multi-select chips for repo + inherited org labels minus opt-outs (from slice 39). Selected ids round-trip via hidden inputs so validate events don’t lose them. Org-inherited labels carry an (org) suffix in the chip.
  • Query-string autofill/issues/new?title=...&body=...&labels=bug,security pre-seeds the form. Lets external buttons / docs deep-link with structured content (e.g. a “report this comment” button could pre-fill the body).
  • Markdown helper hint below the textarea explains the #42 syntax.

374 tests pass overall.

Slice 39 — Labels: color picker + org-level + per-repo opt-out

Color picker

Replaces the bare hex-text field on the label form with a real picker:

  • Native <input type="color"> (browser-provided palette / eyedropper) bound to the same form field as the existing hex input — both stay in sync.
  • 16-swatch preset palette (Tailwind 500-shade rainbow + slate). Click a swatch → pick_color event → form re-renders with that color.
  • Live preview badge next to the color inputs shows the label-in-progress styled with the current color (and the typed name once the user starts entering it).
  • Hex input keeps the ^#[0-9a-fA-F]{6}$ pattern attr + the server-side validate_format.

Org-level labels with per-repo opt-out

  • Schema: labels.organization_id added (nullable, XOR with repository_id via CHECK constraint). New repository_label_opt_outs(repo_id, label_id) join table. Migration 20260513270000.
  • Labels.list_labels/1 now merges repo-own + parent-org labels minus opt-outs, surfacing each with an :inherited? boolean so the UI can distinguish.
  • Labels.create_organization_label/2, opt_out/2, opt_in/2, opted_out?/2 — the new org-scope CRUD + opt-toggle.
  • Repo labels page: inherited rows render with an inherited badge + a Hide button (opts out) instead of the trash icon. Local labels keep their delete button. Trying to delete an inherited label is rejected server-side as belt-and-braces.
  • Org labels page at /orgs/:handle/settings/labels — admin-only, same color picker + palette UI, no opt-out controls (those live on the repo side). Wired into the org settings nav alongside Secrets / Runners.
  • Opt-out semantics: hides the label from the pickable set only. Labels already attached to issues/PRs on that repo stay attached and continue rendering — moderation decisions on past content shouldn’t depend on an org-level hide.
  • 6 new tests covering inheritance flag, opt-out scoping, opt-in re-enabling, non-org label opt-out rejection, org-scope list isolation. 367 tests pass overall.

Slice 38 — Federated PR reviews + suggested-blocks queue

Two federation completion items shipped together.

Federated PR reviews

  • Schema: pr_reviews gets source_actor_id + activity_url (matching the pr_comments shape from slice 22). reviewer_id is now nullable so federated reviews can carry no local user. Migration 20260513250000.
  • Inbound dispatch: ProcessInbox accepts Likeapproved review, Dislikechanges_requested review. The activity’s object (either the local Ticket URL or the remote-minted activity_url we stored on the federated PR row) maps to the right PR. Idempotent on the activity’s id.
  • find_pr_by_object_url widened — now matches against either a local Ticket URL (<repo_actor>/pull_requests/<N>) or a stored pull_requests.activity_url. The same helper is shared between Create(Note) (comments) and the new review dispatch.
  • PullRequests.add_federated_review/3 — federated insert path with unique-partial-index idempotency. PrReview.federated_changeset/2 + federated?/1 helper.
  • Outbound: PullRequests.add_review/3 now detects when the PR has source_actor_id set and fires Federation.Publishing.publish_pr_review/3. Maps approved → AP Like, changes_requestedDislike, commented → no AP emit (no standard verb for it). Body carried in content.
  • ActivityBuilders.pr_review_verdict/4 — builds the Like/Dislike activity with the PR’s Ticket URL as object + body as content.
  • 3 new tests: Like → approved, Dislike → changes_requested, re-delivery idempotent.

Suggested-blocks queue (mix gitgud.federation.suggest_blocks)

The safe alternative to defederation cascades — peer block lists become a signal, not enforcement.

  • Schema: suggested_instance_blocks table — (host, reasons, peers[], peer_count, first_seen_at, last_seen_at, status: pending|approved|dismissed, resolved_by, resolved_at). Unique on host.
  • SuggestedBlocks.ingest/3 — applies a [{host, reason}, ...] list as having come from peer_host. Per host: create with peers: [peer] if new, else add peer (deduped) + bump count + merge reasons. Filters out hosts already on fed_instance_blocks so we never propose what’s enforced.
  • SuggestedBlocks.parse_list/1 — accepts one host per line, or host,reason CSV. Blank lines + # comments ignored.
  • SuggestedBlocks.approve/3 — wraps Federation.block_instance/2 + flips status. dismiss/2 flips status without applying.
  • Mix.Tasks.Gitgud.Federation.SuggestBlocksmix gitgud.federation.suggest_blocks coop.example=https://coop/blocks.csv peer.example=https://peer/blocks.csv [--dry-run]. Supports file:// URLs for testing. Each pair fetches, parses, and ingests; logs counts.
  • Admin LiveView tab — new “suggested” tab on /admin/federation listing pending rows (sorted by peer_count desc, then last_seen_at), per-row Apply / Dismiss. Tab badge shows the pending count.
  • 8 new tests covering parser, ingest semantics (peer aggregation, already-blocked filtering, dry-run), approve/dismiss flow.

361 tests pass overall.

Slice 37 — zot → Packages mirror

Closes the OCI registry loop. zot is the authoritative blob store; the forge keeps a Postgres mirror so UIs can list packages without scraping /v2/_catalog on every request.

  • POST /_webhooks/zot — accepts zot’s push/delete notification format (both event and type field shapes — different zot versions disagree). Bearer-token guarded via config :git_gud, GitGudWeb.PackagesWebhookController, shared_secret: "..."; nil = unauthenticated (dev / loopback-only).
  • Repo path layout: <owner>/<repo>/<image...> — the first two segments resolve a GitGud.Repositories.Repository, the rest become the package name. So org/site/web and org/site/db are distinct packages on the same repo. Falls back to using the repo name as the package name when the path has just two segments.
  • Packages.delete_version/4 added — wipes a single version, recomputes latest_version from the next-most-recent push, deletes the package row if that was the last version.
  • RepoLive.Packages at /r/:owner/:name/packages — lists all packages for a repo with a click-to-expand version history (lazy-loaded into socket state). Renders short digest, size, push time.
  • Repo header link — “Packages” appears between Compare and Actions when the repo has at least one package mirrored; hidden otherwise so empty repos don’t carry noise.
  • 6 new webhook tests covering PUSH (create + re-upsert), nested-path image names, DELETE (with latest_version recompute), unknown repos returning 422, shared-secret bearer enforcement. 350 tests pass overall.

To wire zot:

# zot.json — `extensions.search.notifications`:
{
"url": "http://forge.internal/_webhooks/zot",
"headers": [{"name": "Authorization", "value": "Bearer <same as shared_secret>"}],
"events": ["push:*", "delete:*"]
}

Then in config/runtime.exs:

config :git_gud, GitGudWeb.PackagesWebhookController,
shared_secret: System.fetch_env!("ZOT_WEBHOOK_SECRET")

Slice 36 — Cache eviction worker

Closes the operational loop on actions/cache so disk usage doesn’t grow unboundedly.

  • GitGud.Workflows.Workers.CacheEviction — Oban cron worker, queue :default, runs @hourly. Two independently-configurable knobs:
    • :max_age_days — drops entries whose last_used_at (or archived_at when never read) is older than N days. nil disables.
    • :per_repo_max_mb — per-repo total finalized-bytes cap. After age pruning, anything past the cap is evicted LRU on last_used_at. nil disables.
    • :dry_run — computes what would be evicted without touching DB or storage. Useful when piloting knob values.
  • Order matters: storage object deletes happen before the row delete. An interruption mid-prune leaves stranded blobs (cheap, eventually overwritten by re-uploads) rather than DB rows pointing at missing objects (which would fail lookups).
  • Per-repo size pruning uses a SQL GROUP BY ... HAVING SUM(size) > cap pre-filter so we only walk entries for repos actually over the cap, then walks newest→oldest in memory to pick the eviction set.
  • Config knobs in config/config.exs. Default ships max_age_days: 14, per_repo_max_mb: 5_000, dry_run: false — operator-tunable per deployment.
  • run_now/0 wraps perform/1 with an empty job arg so the worker is callable from iex / mix tasks / tests without going through Oban.
  • 5 new tests covering age pruning, per-repo size LRU, dry_run no-op, both-nil no-op, and storage-then-row delete order. 344 tests pass overall.

Slice 35 — actions/cache hit matching

The v1 cache server used to return 204 (miss) for every read — uploads landed in object storage but lookups never found them, so cross-job caching did nothing. Now the protocol works end to end.

  • cache_entries table(repository_id, key, version, ref, storage_key, size, archived_at, last_used_at). Unique on (repo, key, version, ref) so the same triple re-uploads as a single row; pending uploads have archived_at: nil and are invisible to lookups.
  • GitGud.Workflows.ActionsCache context — lookup/5, reserve/4, append/2, finalize/2, download_url/1. Lookup implements the GitHub spec exactly:
    • For each candidate ref (current ref → repo default), try primary key as exact match
    • Then for each fallback key, do a prefix match (LIKE prefix%, newest first)
    • Skip pending rows
  • Storage — bytes live in the gitgud-cache bucket keyed <repo_id>/<row_id>, served via Storage.presign_url/4. Works for both Disk + Garage backends without controller changes.
  • ActionsCacheController rewired — get_cache decodes keys=, looks up via the new context, returns JSON with archiveLocation on hit. reserve_cache inserts a row (replacing any prior pending one on the same triple). upload_chunk appends bytes (read-modify-write; cache objects are small enough this is fine). finalize flips archived_at + records size.
  • Operator precedence bug found during testing — || binds looser than |> so a case do ... end chained after result_or_fallback || ... silently bypassed the case block when the primary side returned a truthy value. Refactored to an explicit result = ...; case result do ... to make precedence boring.
  • 8 new lookup-precedence tests (exact ref-priority, default-ref fallback, fallback prefix, version-mismatch miss, pending invisible, empty keys, reserve upsert, append+finalize roundtrip). 339 tests pass overall.

Follow-up: eviction worker. Right now we never delete cache entries — last_used_at is updated on every lookup, but nothing scans it. A simple Oban-cron job that drops entries older than N days (or that exceed a configurable per-repo cap) would close the operational loop. Not blocking — disk fills up slowly with cache misses, and operators can run Repo.delete_all if it gets out of hand.

Slice 34 — Re-run / cancel workflow runs

CI control surface closing the loop after slice 27’s run UI.

  • Workflows.rerun/1 — creates a fresh WorkflowRun carrying the same workflow_doc / head_sha / head_ref; jobs + steps are re-seeded from the stored doc via the new Yaml.normalize_doc/1 helper so we don’t need to re-fetch the workflow YAML from disk.
  • Workflows.cancel/1Repo.update_all flips run → cancelled, queued/running jobs → cancelled with conclusion: "cancelled" + completed_at, pending/running steps the same way. Broadcasts a status update so any open WorkflowLive.Show resubscribes. No-op when the run is already terminal. Runners mid-step on a cancelled job see the status flip on next heartbeat and self-terminate (we don’t kill containers out-of-band — that’s the runner’s concern).
  • workflow_steps.status enum gains cancelled to match runs + jobs (already had it).
  • Yaml.normalize_doc/1 — public-facing version of the existing private normalize/1. Lets rerun/1 skip a YAML round-trip on the already-parsed doc we persisted.
  • WorkflowLive.Show — gains a header action row visible only to repo admins (or org admins on org-owned repos). Cancel button shows on queued/running runs, Re-run on success/failure/cancelled. Re-run navigates to the new run’s page on success.
  • 3 new tests covering rerun (new number + same doc + queued state), cancel (cascading status), no-op on terminal runs. 331 tests pass overall.

Notes:

  • Re-run creates a fresh WorkflowRun row rather than resetting the old one — keeps the audit trail. Runs index will show both.
  • Cancelling doesn’t refund cache hits, secrets reads, or anything material — purely a state flip. Good enough for now; if we ever want hard cancellation that signals running runners directly, the runner protocol has a “task cancellation” message we’d need to emit on UpdateTask polling.

Slice 33 — Diff cap removal + caching + compare view

Polish pass over the diff renderer.

  • Hard 50-file cap removed from PR show + commit show. Every diff renders inline regardless of size.
  • expanded_default/1 in each view picks how many files to expand by default based on diff size — small diffs (≤10 files) expand everything; medium (11–50) expand the first 5; huge (>50) expand 2. The rest stay as collapsed <details> headers so initial page load isn’t dominated by hunk markup, but the user can click into any of them without a roundtrip.
  • Repositories.get_diff/4 now caches the parsed file_diff list in GitGud.Cache keyed by (base_sha, head_sha, context). SHAs are immutable so entries never go stale; 24h TTL just bounds memory pressure. Re-loading a PR or commit page is now an ETS hit, not a fresh git diff shell-out + parser pass.
  • RepoLive.Compare wired with the same <.diff> component. After the commits list (when there’s anything ahead), the same Lumis-highlighted hunks render with the same expansion behavior as PR show.
  • 328 tests still pass; no new tests since the change is just plumbing (parser + renderer are already covered from slice 32).

Follow-up worth doing: lazy per-file expansion via LiveView events. Right now even with the cache, a 500-file diff sends a lot of HTML to the browser at mount. Switching to “render file headers eagerly, fetch hunks on <details> open” would let the page-load stay flat regardless of diff size. Not blocking for current PR sizes, but worth a slice when someone lands a 1000-file refactor.

Slice 32 — Line-level diff renderer

PR show used to print “+3 / -1 in foo.rb” stats with no actual hunks. Now renders unified diffs inline with Lumis-highlighted lines.

  • Git.diff/4 backend callback — added to GitGud.Git.Backend, Git.Cli (real impl via git diff --no-color -U<ctx>), Git.Nif (returns :unsupported → falls through to Cli). Root commit handled via git show --format=.
  • GitGud.Git.DiffParser — parses raw unified diff into structured file_diff maps. Tracks per-line old_line/new_line numbers, status (:added | :deleted | :modified | :renamed | :binary), insertions/deletions counts. Handles /dev/null (add/delete), Binary files differ, \ No newline at end of file, multi-file diffs.
  • Repositories.get_diff/4 — wrapper that calls Git.diff/4 + parses. Uncached for v1 (diff blobs can be MB-scale; LRU through GitGud.Cache is a follow-up).
  • GitGudWeb.DiffComponents — function component renderer. Each file is a collapsible <details> (first 5 expanded by default); inside, each hunk is a <table> with old-line / new-line / content columns. Add rows tinted bg-success/10, del rows bg-error/10, both keyed off daisyUI color tokens so it follows the active theme. Per-line content runs through GitGud.SyntaxHighlight.highlight/2 (Lumis), keyed by file path, with the wrapping <pre><code> stripped so the inline highlight drops cleanly into the table cell.
  • PrLive.Show — gains a “Files changed” section after the stats. Caps inline render at 50 files; bigger PRs show a “diff too large” hint with a pointer to per-commit views.
  • 8 new parser tests covering modify / line numbers / add / delete / binary / no-newline marker / multi-file / empty input. 328 tests pass overall.

Follow-ups:

  • Compare view doesn’t render the diff yet (still summary-only) — straightforward to wire, same component
  • Cache the parsed diff in GitGud.Cache keyed by (base_sha, head_sha)
  • Per-line syntax highlighting loses multi-line context (a string opened on one line stops at the newline) — acceptable for diff rendering, but a full-file pass with line-anchored token slicing would be sharper

Slice 31 — Unified MFA: recovery codes for any factor

Cleanup pass over the two-factor surface so recovery codes are tied to “MFA” generally, not specifically to TOTP. With WebAuthn-only users now possible (slice 30 only minted codes on TOTP enroll), they would have had no recovery path on key loss.

  • TwoFactor.ensure_recovery_codes/1 — mints codes only if the user has none unused. Called from both TOTP enable + first WebAuthn enrollment so codes are issued exactly once across MFA setup methods, no matter which factor the user adds first.
  • Webauthn.register_credential/3 return type widened to {:ok, cred, recovery_codes | nil}. Controller forwards the codes to the JS client; the JS hook renders them inline (via a [data-recovery-codes-target] sink in the page) with an “I’ve saved these” button to dismiss + reload. Existing TOTP flow unchanged — recovery codes still appear on the enrollment page after a successful first code.
  • TwoFactor.disable/1 — keeps recovery codes if the user has any WebAuthn credentials remaining (they’re still useful as a fallback for the other factor). Only wipes them when 2FA is fully off.
  • UserAuth.log_in_user/3 — gates on mfa_enabled?/1 (TOTP or WebAuthn), so a WebAuthn-only user no longer slips past the challenge.
  • TwoFactorChallenge LiveView — reads has_totp? + has_webauthn? separately. WebAuthn-only users default to :recovery code entry (since there’s no TOTP to fall back to) and the “Back to authenticator code” link is hidden. Security-key button stays visible for both states.
  • 3 new tests; 320 pass overall.

Slice 30 — WebAuthn / security keys

Second-factor option alongside TOTP. Hardware keys (YubiKey, Titan), platform authenticators (Touch ID / Windows Hello), and browser passkeys all work — anything implementing FIDO2.

  • Dep: :wax_ for attestation + assertion verification (handles CBOR + COSE + multiple attestation formats + clock-skew bounds).
  • Schema: new user_webauthn_credentials table — credential_id, COSE public_key, sign_count (clone-detection counter), aaguid (authenticator model), name, last_used_at. Unique on (user_id, credential_id) so re-registering the same physical key replaces rather than duplicates.
  • GitGud.Accounts.Webauthn context — registration_challenge/1, serialize_registration_options/2, register_credential/3, authentication_challenge/1, serialize_authentication_options/1, verify_assertion/3 plus CRUD (list_credentials, delete_credential, rename_credential, count, has_credentials?). Pre-existing credentials populate excludeCredentials so the browser doesn’t try to enroll an already-paired key.
  • Endpoints:
    • POST /api/webauthn/register/{start,finish} — authed; the start handler stashes the Wax.Challenge in the session
    • POST /api/webauthn/auth/{start,finish} — anonymous (mid-2FA); reads :two_factor_user_id from session, on success writes :webauthn_verified_user_id and returns the JSON redirect target
  • JS hooks (assets/js/webauthn.js) — two LiveView hooks (WebauthnRegister, WebauthnAuthenticate) handle base64url ↔ Uint8Array conversion + navigator.credentials.create / .get. CSRF token forwarded via x-csrf-token header.
  • UI integration:
    • TwoFactorSetup LiveView gets a “Security keys” section with an Add security key button + a list of registered keys (name, AAGUID, added / last-used timestamps, remove button)
    • TwoFactorChallenge LiveView (login second factor) shows a Use security key button when the user has any registered — visible alongside the TOTP / recovery code paths
  • 7 new tests cover challenge generation, serialization shape, and CRUD. The cryptographic round-trip is covered upstream in Wax’s own test suite + manual browser testing. 317 tests pass overall.

Notes:

  • config :git_gud, GitGud.Accounts.Webauthn accepts :rp_id (defaults to the endpoint host) and :origin (defaults to GitGudWeb.Endpoint.url()). For a multi-origin deployment (e.g. dev + prod under different hostnames), set these explicitly.
  • Recovery flow when the user loses every security key + their TOTP secret: recovery codes from slice 29 still work.

Slice 29 — TOTP + recovery codes + instance enforcement

  • Deps: :nimble_totp for RFC 6238 codes, :eqrcode for the enrollment QR.
  • Schema: users gains totp_key_id, totp_ciphertext, totp_iv, totp_tag, totp_enabled_at. Secrets encrypted via the existing AES-256-GCM helpers (same primitive as CI secrets, same key rotation story). New user_recovery_codes table — bcrypt-hashed, one row per code, used_at marks consumption.
  • GitGud.Accounts.TwoFactor — context covering enroll / verify / disable / recovery codes / instance enforcement. Three user states: disabled / pending / enabled. A half-enrolled secret never gates login.
  • Login flowUserAuth.log_in_user/3 detects TwoFactor.enabled?/1 and stashes :two_factor_user_id in session, then redirects to /users/log-in/two-factor. The challenge LiveView accepts a 6-digit TOTP or a recovery code (the latter consumed single-use). On success, a small controller endpoint clears the stash and runs complete_two_factor/2, which is the only place where log_in_user gets to call create_or_extend_session.
  • Enrollment LiveView at /users/settings/two-factor — three views in one module: disabled → pending (QR + manual key + confirmation field) → enabled (recovery codes shown once + regenerate / disable controls). Pending secrets live in socket state only; they touch the DB only after a valid first code.
  • Recovery codes — 10 codes per user, lowercase base32, hashed with bcrypt (each row its own salt). Verification scans the user’s unused codes; on miss runs Bcrypt.no_user_verify/0 so timing doesn’t leak validity.
  • Instance enforcementconfig :git_gud, GitGud.Accounts.TwoFactor, required: true, grace_period_days: 14. enforcement_status/1 returns :not_required | :enrolled | {:grace, days_left} | :enforced. The :require_authenticated on_mount hook redirects users in the :enforced state to the enrollment LiveView (excluding the enrollment LiveView itself to avoid a loop). Default ships off so existing deployments aren’t surprised.
  • Settings page gets a Two-factor section with a status-aware enable/manage button.
  • Runner UUID fix — Forgejo runner v12+ validates the uuid field on the Register response as a real UUID; we were sending stringified runner.id (“4”, “5”) and the runner failed config save with “invalid UUID length: 1”. Added runners.uuid column (Ecto.UUID, backfilled with gen_random_uuid()), generated on registration, returned in the proto.
  • 13 new TwoFactor tests; 310 tests pass overall.

Slice 28 — Per-scope runners + org secrets UI

  • Runner scope — migration 20260513200000 adds runners.repository_id + runners.organization_id (nullable, with a CHECK constraint enforcing at most one set) plus runner_reg_token_hash columns on repositories + organizations.
  • Workflows.Runners rewritten around scope:
    • mint_registration_token(repo_or_org) stores SHA-256 hash, returns bare token once
    • consume_registration_token/1 resolves a token to :global / %Repository{} / %Organization{} or :error
    • register/2 takes a scope, sets the right FK
    • list_for_scope/1 returns repo-eligible runners (repo + parent org + global), org-eligible (org + global), or just global
    • delete_runner/1 for revocation
    • Old valid_registration_token?/1 removed (callers use consume_registration_token/1)
  • claim_next_job/1 widened — joins through workflow_runs (and repositories for org scope) so a repo runner only sees its repo’s jobs, an org runner sees jobs from any of its repos, and a global runner sees everything. Label match unchanged.
  • RunnerController.register consumes the token to determine scope and threads it into Runners.register/2. Legacy global env token still works.
  • RepoLive.SettingsRunners at /r/:owner/:name/settings/runners — mint / regenerate the repo token (with copy-paste forgejo-runner register snippet), list eligible runners with scope badges, remove repo-scoped runners.
  • OrgLive.SettingsRunners at /orgs/:handle/settings/runners — same pattern for orgs.
  • SecretsLive.Org at /orgs/:handle/settings/secrets — mirror of SecretsLive.Repo. The org secrets/vars backend was already shipped slice 16; this was the missing UI.
  • Settings nav — repo settings page gets a sub-nav linking to Secrets / Runners / Branches / Webhooks / Deploy keys. Org show page gets an admin-only Secrets + Runners nav.
  • 6 new tests covering token mint/consume/regen, scope-aware register/2, and list_for_scope/1 aggregation. 297 tests pass overall.

Slice 27 — Expand repo CI UI

Fills out the workflow LiveViews so the existing CI backend is actually visible.

  • Workflows.latest_run/1 and Workflows.list_artifacts/1 context helpers — the controller had inline queries, but the UI needed straightforward functions.
  • Repo show header gets an Actions link with a status badge showing the most recent run’s state (queued / running / success / failure). Currently sits next to Compare and is visible to anyone with read access.
  • Workflow index lists each run with start time + duration (“started 2026-05-13 11:00 · in 1m 42s”) alongside trigger/ref/sha.
  • Workflow show:
    • Run-level started / finished / duration under the badge
    • Per-job duration in the job header
    • Per-step duration in the toggle row
    • New Artifacts panel below jobs: name, size, content-type, upload time + per-artifact Download button
  • GitGudWeb.WorkflowArtifactController — browser-side download path at /r/:owner/:name/actions/:number/artifacts/:id. Gates on repo read access from current_scope, sets Content-Disposition: attachment, sanitizes filenames. The runner-side endpoint (/api/actions/_apis/...) keeps its JWT gate intact.
  • 3 new tests covering latest_run/1 + list_artifacts/1 ordering. 291 tests pass overall.

Still left from the CI roadmap (separate slices when wanted):

  • Re-run / cancel actions
  • Cache-hit path on the v1 Actions cache server
  • Org-scoped secrets/vars LiveView (the repo equivalent shipped slice 16)

Slice 26 — Markdown styling + per-user editor theme

Followup to slice 25 — the cache + highlighter were rendering correctly but two visible gaps remained: no typography styles for the prose selector (Tailwind v4 has no built-in typography), and no way for a user to pick a syntax theme.

  • Hand-rolled .prose CSS in assets/css/app.css covering headings (h1/h2 get separator rules like GitHub), paragraphs, lists with disc/decimal markers, GFM task lists (with the checkbox prefix), blockquotes, GitHub-style alerts (> [!NOTE] etc), inline + fenced code, tables, images, hr, heading anchors. Plain .markdown-fallback / .lumis-fallback styled too. All built on daisyUI’s --color-base-* variables so it tracks the active page theme.
  • users.editor_theme column (migration 20260513180000) plus User.editor_theme_changeset/2.
  • GitGud.Themes.Editor — curated Lumis subset surfaced in UI: OneDark, Dracula, Solarized Dark/Light, Monokai, GitHub Dark/Light, Gruvbox Dark/Light, Nord, Tokyo Night, Catppuccin Mocha/Latte, Rose Pine. for_scope/1 resolves from current_scope.user.editor_theme, falling back to the app default when unset or invalid.
  • assign_editor_theme/1 hook in UserAuth.on_mount/* puts @editor_theme into every LiveView’s assigns automatically (anon + authed). No per-view boilerplate.
  • GitGud.Markdown.to_html/2 + GitGud.SyntaxHighlight.highlight/2 both accept :theme; cache keys include it so changing themes doesn’t poison prior entries.
  • All call sites updated — issue / PR / wiki / repo show / blob views now pass @editor_theme through.
  • User settings page — new “Editor theme” form between email and password with a dropdown of the curated set. Empty selection means “use app default.”
  • 4 new tests for GitGud.Themes.Editor. 288 tests pass overall.

Slice 25 — Nebulex cache + syntax highlighting + markdown overhaul

  • GitGud.Cache — Nebulex local cache (ETS, partitioned) started under the app supervisor. Configured with max_size: 50_000, allocated_memory: 200MB, gc_interval: 12h. Adapter swap (replicated / partitioned) is a config change, not a code change.
  • GitGud.SyntaxHighlight — Rust-backed highlighter via Lumis, the tree-sitter highlighter pulled in transitively through MDEx. (User asked for Syntect; Lumis is functionally equivalent and already in the dep graph — same Rust-NIF latency profile.) Language resolved from explicit :language or :filename (extension + basename map for Dockerfile, Makefile, etc.). Results cached by sha256(content) + language + theme; never go stale because the key includes the bytes. 250KB cap with HTML-escaped fallback above that.
  • GitGud.Markdown — single entry point for all markdown rendering in the codebase. Backed by MDEx with the full GFM feature set: tables, strikethrough, task lists, autolink, footnotes, GitHub-style alerts (> [!NOTE]), heading anchors (h- prefix so deep links work), smart punctuation, relaxed task-list matching. Fenced code blocks get Lumis-powered syntax highlighting via MDEx’s :syntax_highlight formatter. Sha256-keyed cache with 500KB input cap.
  • Duplicated render_md helpers eliminatedWikiLive.Show, PrLive.Show, IssueLive.Show, RepoLive.Show, RepoLive.Blob all delegated to GitGud.Markdown.to_html/1. Each was previously its own copy of the same MDEx call.
  • RepoLive.Blob now syntax-highlights source files — non-markdown text files render through SyntaxHighlight.highlight/2, picking up the filename-based language hint. Markdown files keep the markdown render path. Binary / oversize fallbacks unchanged.
  • 12 new tests (8 markdown + 4 highlight) covering GFM features, cache hits, oversize fallback, language resolution. 284 tests pass overall.

Notes for ops:

  • Cache is per-node ETS; restart drops it. Acceptable since regen is cheap and miss path returns the right answer.
  • To partition across a cluster, swap Nebulex.Adapters.Local for Nebulex.Adapters.Partitioned in GitGud.Cache. No call-site changes required.

Slice 24 — Disk storage backend + LFS

Pluggable object storage with a Disk impl for dev / single-node prod / NFS-shared multi-node, plus the Git LFS Batch API on top.

  • GitGud.Storage.Disk@behaviour GitGud.Storage impl reading/writing under a configured :root directory. presign_url/4 returns a token-signed URL routed through a new GitGudWeb.StorageController. Token via Phoenix.Token, embeds bucket+key+method+expiry. Path components sanitized against traversal (.., /, \).
  • GitGudWeb.StorageControllerGET and PUT at /_storage/:bucket/*key verify the token and stream bytes to/from the on-disk path via send_file / chunked read_body. New :raw router pipeline so Plug.Parsers doesn’t swallow the upload body.
  • lfs_objects table (migration 20260513170000) — repository_id, oid (64-hex SHA-256), size, storage_bucket, storage_key, uploaded_at. Unique on (repository_id, oid).
  • GitGud.Lfs contextbatch/3 for the LFS Batch API (“download” / “upload”), verify/3 for the post-upload completion call, get/2 lookup. Storage key layout <repo_id>/<oid[0..1]>/<oid[2..]> to keep fan-out reasonable on filesystem backends. Placeholder rows are created at batch time so concurrent uploads of the same oid don’t race; uploaded_at is only set after verify.
  • GitSmartHttp plug gets two new dispatch clauses: POST /:owner/:name.git/info/lfs/objects/batch and POST /:owner/:name.git/info/lfs/verify. JSON body parsed inline (Plug.Parsers is bypassed at this point in the pipeline). Auth reuses the existing basic-auth helpers — download maps to read, upload maps to write.
  • Configconfig :git_gud, GitGud.Storage, impl: GitGud.Storage.Disk is the new default; config :git_gud, GitGud.Storage.Disk, root: "priv/storage". Tests + dev use Disk under tmp/test/storage. Prod can swap to GitGud.Storage.Garage with one config line.
  • Tests — 11 unit tests (Storage.Disk roundtrip + token verification + LFS context lifecycle) and 3 HTTP-level tests against the LFS endpoints. 272 tests pass overall.

Notes for ops:

  • Multi-node Disk requires every node to mount the same shared filesystem at the configured :root. NFS or any POSIX-shared FS works; no node-local writes.
  • For S3-flavored deployments, set impl: GitGud.Storage.Garage and configure ex_aws as described in that module’s docstring.
  • The /_storage routes only matter for the Disk backend; with Garage the presigned URLs go directly to S3.

Slice 23 — Federation follow-up UI

The three small UI gaps left by slice 22.

  • Admin reports queue — Forward to source buttonAdminFederationLive.Index reports tab now has a “Forward to source” action next to Dismiss/Mark actioned. Calls Reports.forward_to_source/2; surfaced only when target_type ∈ {pull_request, pr_comment, actor}. Backend gates the actual federation eligibility, button is just a heuristic gate.
  • Remote actor profile pagesGitGudWeb.ActorLive.Show at /actors/:id renders a remote (or local) actor’s cached profile: name, handle@host, summary, last_fetched_at, status, canonical URL link. Includes a “Report this actor” button that opens an actor target-type report. Federated-PR show page links the “Offered by” line to /actors/:id so reading users can reach the report path in one click.
  • “Send PR to remote repo” UIPrLive.New gains a toggle for “Send to a remote repository (ForgeFed Offer)”. When on, the target-branch input becomes a free-text field (we don’t know the remote’s branches), an AP URL field appears, and Submit calls Federation.Outbound.offer_pull_request/4 instead of create_pull_request/3. No local PR row is created — the user is told “Offer queued for delivery.”
  • 258 tests still pass; UI-only wiring with no new tests.

ROADMAP federation section now empty except for the bigger discretionary items (federated reviews shape, mix gitgud.federation.suggest_blocks, RFC 9421, reputation aging).

Slice 22 — Federation completion

Closes the remaining federation gaps. Covers the federated-PR lifecycle (Update/Reject + comments), outbound delivery hardening, remote-actor freshness, and abuse-report plumbing.

  • Outbound dead-letterDeliverOutbound now distinguishes terminal vs in-flight failures: on the final attempt the row is written with status: "dead_letter" (new status enum value) and the job is :discarded cleanly instead of bouncing as a retryable error. Status enum: pending|success|failure|dropped|dead_letter.
  • Inbound UpdateProcessInbox.dispatch("Update", …) matches a Ticket with merge intent against the source actor’s open federated PRs, calls PullRequests.refresh_federated_head/2 to re-fetch the source branch, re-snapshot head_sha + base_sha, re-pin refs/pull/<N>/head, and clear mergeable for re-probing.
  • Inbound Reject — Reject of a Ticket (by id or as a bare URL) closes the matching federated PR via PullRequests.set_state/2.
  • Inbound Create(Note)Create with a Note inReplyTo a local PR’s Ticket URL lands as a federated PR comment with source_actor_id set and author_id: nil. Idempotent via unique partial index on pr_comments.activity_url. Migration 20260513160000_add_federated_pr_comment_origin adds source_actor_id, activity_url columns; PrComment.federated_changeset/2 + federated?/1 helper.
  • Remote actor refresh workerFederation.Workers.RefreshRemoteActors walks 50 stalest active remote actors per hour (cron @hourly), re-fetching them via RemoteActors.fetch/1. 404/410 responses mark the actor status: "suspended" so we stop wasting refresh cycles on tombstones.
  • PR + actor reporting (UI wiring) — PR show page now has a flag button on the header and on each comment. Handler mirrors the issue path: GitGud.Reports.open(user, {type, id}, reason). pr_comments preload extended with :source_actor so federated comments render with origin handle + federated badge.
  • Cross-instance report forwardingReports.forward_to_source/2 resolves the report’s target to its originating remote actor (PR / pr_comment / actor target types), builds an AP Flag activity authored by the admin’s local actor, and queues DeliverOutbound to the remote shared / actor inbox. New ActivityBuilders.flag/3. Refuses non-federated targets with {:error, :not_federated}.
  • Outbound OfferGitGud.Federation.Outbound.offer_pull_request/4 resolves a target repository’s AP actor URL via RemoteActors.fetch/1, validates the target is a Repository-typed actor, builds an Offer(Ticket) via the new ActivityBuilders.offer_pull_request/3, inserts a local activity row, and queues DeliverOutbound. No local PR is created — the canonical state lives on the target instance. Operator-callable today; UI integration for “send to remote repo” is a follow-up.
  • 7 new tests (pull_requests_federated_test.exs grows from 3 to 6 covering Update/Reject/Note; new federation/outbound_test.exs covers offer + Flag forwarding + non-federated refusal). 258 tests pass overall.

Deliberately out of scope for the slice:

  • Admin LiveView for the reports queue (with dismiss/action/forward buttons) — backend exists, UI is a separate small task
  • Remote actor profile pages with their own “Report this actor” button — needs the actor browser first
  • RFC 9421 signature support — current draft-cavage-12 still works against the federation universe

Slice 21 — Federated PRs (inbound Offer)

  • Migration 20260513150000_add_federated_pr_origin adds three columns to pull_requests: source_actor_id (FK to actors), activity_url (unique partial index for idempotency), source_clone_url. Plus index on source_actor_id.
  • PullRequest schema gains belongs_to :source_actor, field :activity_url, field :source_clone_url, and federated?/1.
  • PullRequests.create_federated_pull_request/3 — opens a PR whose source is a remote AP actor. Fetches the source branch directly from the supplied source_clone_url into the target repo (refs/pull-tmp/...), verifies the claimed head SHA is reachable, computes the merge base, inserts a PR with source_repository_id=nil, author_id=nil, source_actor_id set, and pins head into refs/pull/<N>/head.
  • ProcessInbox.dispatch("Offer", …) — recognizes a ForgeFed Ticket with isMerge: true (or sourceBranch/targetBranch heuristics), resolves the source clone URL from object.source.cloneUri then actor.endpoints.cloneUri then the actor URL + .git as a last resort. Idempotent on activity_url.
  • PrLive.Show and PrLive.Index render a “federated” badge for source_actor_id-bearing PRs; the show page also names the offering actor and the source clone URL.
  • 3 new tests in test/git_gud/pull_requests_federated_test.exs: direct create_federated_pull_request path with head pinning, end-to-end through ProcessInbox with re-delivery idempotency, and non-merge Ticket rejection. 251 tests pass overall.
  • Outbound Offer (local user opens a PR against a remote repo) is not in this slice — that needs a target-resolution flow (webfinger or AP id) and the existing ActivityBuilders has no pr_offered builder yet. Tracked in ROADMAP under outbound federation.

Slice 20 — Embedded SSH daemon

  • GitGud.Ssh.Server GenServer boots Erlang’s :ssh.daemon/2 under the app supervisor when config :git_gud, GitGud.Ssh.Server, enabled: true. Default port 2222. Host key auto-generated to priv/ssh/ssh_host_ed25519_key on first boot via ssh-keygen (gitignored).
  • GitGud.Ssh.KeyApi (:ssh_server_key_api): looks up presented public key by SHA-256 fingerprint in user_ssh_keys, requires the SSH username to equal the user’s email local-part (Gerrit pattern). Fingerprint parity with SshKey.compute_fingerprint/1 via :ssh_message.ssh2_pubkey_encode/1 with manual SSH wire-format fallback for ed25519 + RSA.
  • GitGud.Ssh.Channel (:ssh_server_channel): parses git-{upload,receive}-pack '<owner>/<repo>.git', authorizes against repo visibility / ownership / org admin, spawns git via Port using the subcommand form (git upload-pack <path>, not the dashed binary — wrong invocation reported “is not a git command”). Stderr stays on BEAM’s stderr, not merged into stdout, so the pkt-line protocol stream isn’t corrupted. Deny messages go via SSH extended-data type 1 (renders as remote: on the client).
  • Empty-repo page’s ssh_clone_url/2 now uses the configured SSH port + optional external_host.
  • HookReceiver.receive/2 self-heals Repository.default_branch from disk HEAD when the configured branch doesn’t resolve — closes the “first push to master while default_branch=main shows empty repo” gap.
  • 4 fingerprint-parity tests in test/git_gud/ssh/key_api_test.exs. 248 tests pass.
  • Deploy keys over the embedded daemon: not in v1 (still work via the external SSH wrapper). Documented in module doc.

Slice 19 — Fork sync, compare view, empty-repo onboarding

  • Repositories.fork_status/2 — fetches upstream into the fork under refs/upstream/<branch> and returns {behind, ahead, fast_forward?, parent_sha, fork_sha}
  • Repositories.sync_fork/4 — fast-forwards the fork’s branch to upstream HEAD via update_ref with CAS; refuses on divergence without force: true; permission gate (owner or org admin)
  • Fork sync banner on RepoLive.Show for fork branches showing behind/ahead counts + a Sync button when it’s a clean fast-forward
  • Repositories.compare/5 — base/head, same-repo or cross-repo, returns {ahead, behind, commits, merge_base_sha, base_sha, head_sha, diff_stat}. Cross-repo path fetches the head’s branch into the base under refs/compare/<branch> first
  • RepoLive.Compare at /r/:owner/:name/compare?base=…&head=…. head accepts branch or owner/name:branch. Renders the comparison + “Create pull request” link pre-filled
  • Repo header gets a new “Compare” link alongside Issues / Labels / Forks
  • Bug fix in GitGud.Git.Cli.parse_commit_record/1 — multi-commit logs were dropping all but the first commit because the \x01 sentinel only appeared at byte 0 of the first record; subsequent records start with the newline git inserts between them. Now strips leading whitespace before matching.
  • Empty-repo onboarding rewritten with HTTP/SSH transport toggle (default SSH), three tabs (brand-new local / push existing / clone), per-default-branch templating, warning when SSH selected but no keys configured. URL helper split into http_clone_url + ssh_clone_url.
  • 7 new fork tests + 2 compare tests; 244 tests pass overall

Slice 18 — Layout + nav + themes + landing

  • Ported diogramos’s theme system: GitGud.Themes (light/dark + 6 neiam-co themes + blueprint) backing 9 daisyUI theme plugin entries in assets/css/app.css
  • Root layout (layouts/root.html.heex) carries the theme bootstrap script: localStorage-persistent, system fallback respects prefers-color-scheme, phx:set-theme event handler updates data-theme live
  • New Layouts.app shell with a real navbar: brand, Repos / Orgs / Admin links (Admin only when is_admin: true), theme dropdown, user menu (settings / ssh-keys / log out) or Log in / Register for anonymous
  • wide slot attribute on Layouts.app for future full-bleed pages
  • Landing page (PageController :home) replaced with a forge intro: hero copy, 6 feature cards, quick links into the things you can actually click. Anonymous gets the Register CTA; authed gets New Repository.
  • PageHTML got feature/1 + quick_link/1 function components used by the landing
  • is_admin: true controls visibility of the Admin nav entry + the federation-admin quick link
  • 1 page test updated, 1 added; 237 tests pass; precommit green

Slice 17 — Forks + cross-repo PRs

  • repositories.parent_repository_id migration; Repository.fork?/1
  • Repositories.fork/4 — clones the source bare repo (git clone --bare), installs hooks, links via parent_repository_id
  • Permission gates: source must be readable; destination namespace must accept (user-self or org-admin)
  • Repositories.list_forks/1 for direct children
  • Fork LiveView at /r/:owner/:name/fork (destination + name picker); Forks list at /r/:owner/:name/forks; Fork button on RepoLive.Show
  • pull_requests.source_repository_id (nullable; nil = same-repo); PullRequest.cross_repo?/1
  • create_pull_request accepts a source_repository_id attr. For cross-repo, fetches the source branch into the target via git fetch <source_path> <branch>:refs/pull-tmp/<branch> then pins it as refs/pull/<N>/head on the target
  • Mergeability + merge use the target repo’s view, which now sees the source commit thanks to the pinned ref
  • PullRequests.refresh_cross_repo_heads/3 — when a fork pushes, re-fetches every open cross-repo PR’s branch into the upstream’s refs/pull/<N>/head and bumps head_sha. Hooked into CommitBackfill
  • PrLive.New: source-repo picker (current + parent + forks the actor can read); branches reload when source changes
  • PrLive.Show: cross-repo PRs render as <src_handle>/<src_repo>:<branch> → <target_branch> with a fork badge
  • 7 fork + cross-repo PR tests covering: fork ACLs, list_forks scope, end-to-end open-and-merge from a fork, head refresh on fork push

Slice 16 — Runner secrets, YAML, JWT, artifacts

  • GitGud.Secrets context with AES-256-GCM encrypted-at-rest values
  • Schemas: repository_secrets, organization_secrets, repository_variables, organization_variables
  • Resolution: org < repo (repo wins); plaintext exposed only via Secrets.resolve_for/1 for runner-bound code
  • Master key rotation supported via per-row key_id + master_keys config map
  • Repo-scoped Secrets/Vars LiveView at /r/:owner/:name/settings/secrets
  • FetchTask injects the resolved {secrets, vars} map into the runner task message
  • YAML output for workflow_payload swapped from the hand-rolled writer to :ymlr
  • GitGud.Workflows.RunnerJwt (HS256, per-task, 24h default TTL) — mint/2 + verify/1
  • FetchTask injects the JWT into Task.context as token, gitea_runtime_token, ACTIONS_RUNTIME_TOKEN, plus the matching _URL keys forgejo-runner reads
  • GitGudWeb.Plugs.RunnerJwtAuth — Bearer-token plug that halts 401 on missing/invalid token
  • ActionsCacheController — v1 cache server (GET always 204 = miss; POST/PATCH persist via GitGud.Storage to bucket gitgud-cache)
  • ActionsArtifactController — v1 Pipelines artifact API (create, upload, list, download, finalize) backed by GitGud.Storage bucket gitgud-artifacts, with a WorkflowArtifact schema row per artifact
  • 16 new tests (secrets, runner JWT, artifact upload/download round-trip, cache auth + miss)

Carveouts (in ROADMAP):

  • Cache hit matching — currently always returns miss. Needs (key, version) lookup keyed on persisted entries.
  • Org-scoped secrets/vars LiveView (repo-scoped shipped here).
  • Real Garage in tests — actions endpoint tests swap in an in-memory Storage impl.

Slice 15 — Forgejo runner protobuf

  • priv/proto/runner/v1/messages.proto mirroring forgejo/actions-proto-def/runner/v1
  • mix gitgud.protoc regenerates lib/git_gud_web/protos/runner/v1/messages.pb.ex
  • Controller content-negotiates application/protobuf and application/json; runner.v1 endpoints use the generated message structs end-to-end
  • Runner.V1.Task populated with workflow_payload (gzip’d YAML), context, needs; secrets/vars populated in slice 16
  • mix gitgud.runner.bootstrap prints the exact forgejo-runner register command for the configured instance
  • 7 runner controller tests + secret roundtrip + JSON roundtrip
  • Real forgejo-runner binaries can now register + poll FetchTask against this instance

Slice 14 — gix NIF wiring

  • :rustler dep + use Rustler in GitGud.Git.Nif
  • Implemented in the NIF: init_bare, list_refs, blob_bytes, blob_meta, resolve
  • Per-operation fallback: Git.dispatch/2 runs the primary backend, transparently falls back to Cli on {:error, :unsupported}
  • Git.current_backend/0 for observability
  • Test infrastructure wipes tmp/test/repos/ on suite start (gix is stricter than git about non-empty init targets)

Slice 13 — Federation outbound + org events

  • GitGud.Federation.ActivityBuilders (Push, Create-Ticket, create_pull_request, create_comment, pr_merged, ticket_closed, create_repository, org_add_member)
  • GitGud.Federation.Publishing fans out to unique follower inboxes (collapsing sharedInbox), persisting each Activity row
  • Wired into CommitBackfill (push), Issues (create/comment), PullRequests (create/comment/merge), Organizations.add_member, Repositories.create_repository_for_org
  • ForgeFed convention: repo-scoped activities sent as the repo actor with human in attributedTo so repo followers receive them
  • Org actor doc advertises repositories collection; /orgs/:handle/repositories returns it

Slice 12 — Federation moderation tail

  • Inbox rate limiter (ETS, fixed-minute window, per source host, allowlist multiplier)
  • Per-repo interaction_policy (public | followers | approved) enforced in ProcessInbox
  • quarantined status on InboxDelivery; admin approve/reject re-runs ProcessInbox
  • Reports schema + report buttons on issue comments
  • is_admin flag on users; mix gitgud.promote_admin; :require_admin LV mount hook
  • /admin/federation LiveView (deliveries / quarantine / blocks / allowlist / reports tabs)
  • Allowlist as the production default; :open is opt-in
  • Configurable retention worker (inbox / outbound / remote activities)

Slice 11 — Federation: signatures + inbox processing

  • GitGud.Federation.HttpSignatures (draft-cavage-12 RSA-SHA256, Digest, clock-skew)
  • GitGud.Federation.RemoteActors with moderation-aware fetch + cache
  • Raw-body capture plug for signature verification
  • Inbox controller verifies signature before processing; rejects with audit row preserved
  • ProcessInbox Oban worker dispatches by activity type (Follow → Accept, Undo, Accept)
  • DeliverOutbound Oban worker signs + POSTs to remote inboxes
  • All inbound activity 202s regardless of outcome (moderation isn’t leaked to the network)

Slice 10 — Federation foundation

  • Actors table (local + remote partition, RSA keypairs, CHECK constraint)
  • Activities, follows, inbound deliveries, outbound deliveries, instance blocks, actor blocks, allowlist
  • Federation.ensure_local_actor/1 for users/orgs/repos
  • ActorJSON renderer (AS + ForgeFed + security/v1 contexts)
  • AP dispatcher diverts Accept: application/activity+json requests on entity paths to the AP controller
  • WebFinger, NodeInfo, empty inbox/outbox/followers/following collections
  • 17 federation tests

Slice 9 — Org-owned repos + pre-receive enforcement

  • Polymorphic repo namespace (user or org) with partial unique indexes
  • Storage.repo_handle/1 resolves namespace from preloaded org/owner
  • RepoLive.New picks namespace; OrgLive.Show lists org repos
  • Pre-receive hook (shell script + /_hooks/pre-receive endpoint) enforces branch protection rules before forwarding to git-http-backend
  • 5 branch protection tests

Slice 8 — Webhooks + deploy keys + branch protection + per-step logs + orgs/teams

  • HMAC-SHA256-signed webhooks, Oban delivery worker, GitHub-shaped payloads
  • Deploy keys (repo-scoped, read-only by default)
  • SSH wrapper distinguishes u:<id> user keys vs d:<id> deploy keys
  • Branch protection rules (glob pattern, approval count, force-push/delete gates), enforced on PR merge
  • Per-step log surfacing in WorkflowLive (collapsible cards)
  • Organizations + teams + per-repo team permissions (read/triage/write/maintain/admin)
  • 10 new tests across these surfaces

Slice 7 — SSH + hook bridge + smoke tests

  • SSH keys schema + management LiveView
  • priv/scripts/gitgud-ssh wrapper + /_ssh/resolve endpoint
  • mix gitgud.ssh.authorized_keys for regeneration
  • priv/scripts/gitgud-hook + /_hooks/post-receive closing the push → cache backfill loop
  • 17 smoke tests across Git, Repositories, Issues, Labels, PRs, Wiki

Slice 6 — Object storage + container registry

  • GitGud.Storage behaviour + GitGud.Storage.Garage impl over ex_aws_s3
  • OCI registry token endpoint at /v2/token, RS256 JWTs scoped to repo + actions
  • packages + package_versions metadata mirror
  • Packages.record_push/4 idempotent on (package, version)

Slice 5 — CI domain + Forgejo runner endpoints (JSON-only)

  • runners, workflow_runs, workflow_jobs, workflow_steps, workflow_step_logs, workflow_artifacts
  • GH Actions YAML parser (on:, jobs.<id>.{name, runs-on, needs, steps}, branch/tag globs)
  • Workflows.dispatch/2 fires on push, scans .github/workflows/* and .forgejo/workflows/*
  • claim_next_job/1 with FOR UPDATE SKIP LOCKED
  • PubSub topic per run; LiveView log streaming
  • JSON-only Twirp endpoints for runner.v1 (protobuf came in slice 15)

Slice 4 — Pull requests + Wiki

  • PR schema with FTS, source/target refs, head/base SHA snapshots, mergeability cache
  • PullRequests.check_mergeability/1 via git merge-tree --write-tree
  • PullRequests.merge!/3 does 3-way merge via plumbing (commit-tree + atomic update-ref CAS)
  • Reviews + comments tables
  • Wiki: sister .wiki.git bare repo, Wikis.put_page/4 via scratch-index plumbing, edit-via-commit
  • LiveViews for both surfaces

Slice 3 — Issues + labels

  • Labels (polymorphic via labelings(label_id, target_type, target_id))
  • Per-repo numbering shared across issues + PRs (Numbering.next_for/1)
  • Issues + comments, FTS via generated tsvector
  • LiveViews for issue list/show/new + label management

Slice 2 — Repository + git data cache

  • repositories, git_refs, git_commits, git_trees, git_blob_meta, git_commit_diffstats, git_renders
  • GitGud.Git facade + Backend behaviour + Cli implementation (NIF added in slice 14)
  • Cache-first read API with write-through
  • Repositories.create_repository/2 initializes bare on-disk repo + post-receive hook
  • Smart HTTP transport via git http-backend CGI
  • LiveViews: repo list/show/tree/blob/log/commit
  • CommitBackfill Oban worker triggered by post-receive

Slice 1 — Scaffold + plan

  • Phoenix 1.8 + phx.gen.auth baseline
  • Architectural decisions locked: Forgejo runner protocol, gix Rustler NIF, Garage S3, Postgres caching of git content
  • AGENTS.md inherited from phx.new (Phoenix conventions, untouched)

Session log

Per-session fine-grained task graph. Captures the state at session boundaries so a future session can pick up mid-slice without re-reading all of PROGRESS.md.

Session 2026-05-13 (continued)

Closed slices 14, 15, 16, 17 in this session. Final task graph:

# Status Task
52–54 done gix NIF wiring + per-op fallback + tests (slice 14)
55–59 done Forgejo runner protobuf (.proto, codegen, controller, payload, bootstrap mix task) (slice 15)
60 done Secrets + variables (repo + org scope, encrypted) (slice 16)
61 done Swap in ymlr for YAML output (slice 16)
62 done Runner ephemeral JWT + Task injection (slice 16)
63 done Artifact + cache HTTP endpoints (Garage-backed) (slice 16)
64 done PROGRESS.md + ROADMAP.md initial drafts
65–70 done Forks + cross-repo PRs (slice 17)
71–74 done Layout + nav + themes + landing (slice 18)
75–77, 79 done Fork sync, compare, empty-repo onboarding (slice 19)
80–83 done Embedded SSH daemon + self-healing default_branch (slice 20)
78 done Federated PRs — inbound Offer (slice 21)
84 done Federation completion: outbound dead-letter, Update/Reject/Note, refresh worker, PR reporting, forward Flag, outbound Offer (slice 22)
85 done Federation follow-up UI: admin Forward button, actor profile page, Send-PR-to-remote toggle (slice 23)
86 done Disk storage backend + LFS Batch API (slice 24)
87 done Nebulex cache + Lumis syntax highlight + Markdown overhaul (slice 25)
88 done Markdown styling + per-user editor theme (slice 26)
89 done Repo CI UI: artifacts panel, timings, latest-run badge (slice 27)
90 done Per-scope runners + org secrets UI (slice 28)
91 done TOTP + recovery codes + instance enforcement + runner UUID fix (slice 29)
92 done WebAuthn / security keys (slice 30)
93 done Unified MFA: recovery codes for any factor (slice 31)
94 done Line-level diff renderer (slice 32)
95 done Diff cap removal + caching + compare view (slice 33)
96 done Re-run / cancel workflow runs (slice 34)
97 done actions/cache hit matching (slice 35)
98 done Cache eviction worker (slice 36)
99 done zot → Packages mirror (slice 37)
100 done Federated PR reviews + suggested-blocks queue (slice 38)
101 done Labels: color picker + org-level + opt-out (slice 39)
102 done Cross-references + new-issue flow (slice 40)
103 done Quote-reply on comments (slice 41)
104 done Actionable reports queue: preview + delete/suspend (slice 42)
105 done Refresh README + landing page for slices 32–42
106 done Lazy per-file diff expansion (slice 43)
107 done Replace-with-mod-message moderation (slice 44)
108 done Language stats bar + detail page (slice 45)
109 done Language detection fix (filename-based) + per-committer stats (slice 45 follow-up)
110 done Clone-URL copy widget on repo show (slice 46)
111 done Shared header across repo pages (slice 47)
112 done User + org profile pages (slice 48 — first of profile arc)
113 done Theme-aware identicon avatars (slice 49)
114 done Pinned repos with drag-to-reorder (slice 50)
115 done Org-member pinning + removal cleanup (slice 50 follow-up)
116 done Auto-created same-name profile README (slice 51 — profile arc complete)
117 done Org profile editor (slice 52)
118 done Registration modes + invite system (slice 53)
119 done Org visibility + public-org anonymous access + description in settings (slice 54)
120 done Org members management (slice 55)
121 done gix NIF: commit + log + tree (slice 56)
122 done libcluster + L1/L2 distributed Nebulex (slice 57)
123 done Runner-register UI: generate-config + Quadlet + Kubernetes tabs (slice 58)
124 done Canonical .forgejo/workflows/build.yml (ports .gitlab-ci.yml to Actions + buildx)
125 done zot deployment manifest zot.yml (PVC-backed, ggr.neiam.co, fsGroup init)
126 done mix gitgud.registry.keygen task + Packages.Token / ZOT_WEBHOOK_SECRET runtime env wiring

End-of-session: 501 tests pass on the NIF backend with the new Multilevel cache. Next pickups: in-browser PR conflict resolution, or filling in the remaining gix NIF ops (diff_stats / merge_base / merge_tree / commit_tree / update_ref) once the gix API shakes out. Embedded SSH daemon verified end-to-end against git push --set-upstream origin master from a real client (after fixing two bugs: spawning git with the dashed subcommand name failed; :stderr_to_stdout on the Port corrupted the pkt-line stream).

End-of-session (registry full-cycle slice): runner-register UI now matches Forgejo’s binary + docker install docs (generate-config step, --config flag on daemon) and gained Podman Quadlet + Kubernetes deployment styles. Ported the legacy .gitlab-ci.yml to a canonical Actions workflow at .forgejo/workflows/build.yml (buildx + metadata-action + registry build cache). Wrote zot.yml in the same heavily-commented Traefik/cert-manager style as app.yml, backed by a 100Gi zot-data PVC on openebs-zfspv (S3/Garage driver kept available in a comment). Added mix gitgud.registry.keygen — emits PKCS#1 private + SubjectPublicKeyInfo public PEMs (verified against openssl rsa -pubout) plus kubectl one-liners for splitting them across the elixir-args and zot Secrets. runtime.exs now reads GITGUD_REGISTRY_PRIVATE_KEYPackages.Token and ZOT_WEBHOOK_SECRETPackagesWebhookController, and app.yml exposes both env vars (both optional: true so pods survive before/after key provisioning). End-to-end push → workflow → runner → registry path is now wired; what remains is operator-side: keygen, kubectl apply -f zot.yml, DNS for ggr.neiam.co, and providing the new secrets to elixir-args.