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
webhooksis now repo-XOR-org owned. Migrationadd_org_webhooks_and_run_actormakesrepository_idnullable, addsorganization_id, and awebhooks_one_ownercheck constraint (exactly one owner).Webhookschema gainsbelongs_to :organization.Webhooks.notify/3fans out to both the repo’s hooks and (when org-owned) the owning org’s hooks via a singledynamic/2OR-clause. Now takes a full%Repository{}(readsorganization_id).workflow_runevent is finally emitted — it was a declared known-event with no caller.Workflows.notify_run_webhook/1fires 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 toorg_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 viaRepositories.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 fromupdate_run_commit_status/1+ the claim→running sites. Shared rendering inGitGudWeb.CiComponents.
Pusher attribution
workflow_runs.triggered_by_idthreaded end-to-end so the user view can show “runs I triggered”: SSHSsh.ChannelsetsGITGUD_PUSHER_IDin the git Port env; smart-HTTP injects it into the CGI env;gitgud-hookforwardsx-pusher-id;HookController→HookReceiver.receive/3→CommitBackfill→Workflows.dispatch. Installed per-repo hooks justexecthe central script, so onlygitgud-hookchanged.
Pending
- Migration not yet applied — dev Postgres was down at write time; run
mix ecto.migrateonce 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"}tomix.exs. ItsCluster.Strategy.Kubernetesstrategy enumerates peer pod IPs through the K8s API. runtime.exswires the topology only whenLIBCLUSTER_KUBERNETES=1is set, so single-node and dev runs skip clustering entirely. Other env knobs:LIBCLUSTER_NAMESPACE,LIBCLUSTER_SERVICE,LIBCLUSTER_SELECTOR,LIBCLUSTER_BASENAME.GitGud.Application.start/2spawnsCluster.Supervisoronly when a topology exists. No topology → no supervisor,pggroup has a single member, replicated writes are local — exactly right on a single node.
Nebulex cache, L1 + L2
GitGud.Cache.L1—Nebulex.Adapters.Local(ETS). Per-node, sub-microsecond reads.GitGud.Cache.L2—Nebulex.Adapters.Replicated. Each clustered node holds a full copy; writes broadcast viapgonce libcluster connects the nodes.GitGud.Cache—Nebulex.Adapters.Multilevelwithmodel: :inclusiveandlevels: [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/endpointsin the namespace, scoped to thegit-gudServiceAccount. - Headless Service
git-gud-headless(clusterIP: None,publishNotReadyAddresses: true) exposesepmd:4369+dist:9100so libcluster (mode: :ip) can resolve peers and rolled pods can join the cluster before readiness probes pass. - New env vars:
LIBCLUSTER_*,POD_IP(fromstatus.podIP),RELEASE_NODE=git_gud@$(POD_IP),RELEASE_DISTRIBUTION=name,RELEASE_COOKIE(fromelixir-args/erlang_cookie). Pod container exposes4369and9100ports. app-secrets.example.ymlgains anerlang_cookieplaceholder with theopenssl rand -hex 32recipe.
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 samecommit_to_map/3helper ascommit/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.
- When
tree(path, sha)—find_object → try_into_tree → decode→ entries withname,mode(octal string),type(tree/blob/commit),sha(20-byte binary),size(blob size viafind_header, nil for non-blobs to match the Cli’sls-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 amap_ofhelper with:__struct__ => Elixir.DateTime,:calendar => Elixir.Calendar.ISO, year/month/day/hour/minute/second fromchrono,:microsecond {0, 0},:utc_offset 0,:std_offset 0,:zone_abbr "UTC",:time_zone "Etc/UTC"— the last two are strings (Ecto’scheck_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’sdispatch/2already 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_basewas renamed/relocated). Cli works correctly today; lift them when the gix API for our pinned version settles.
Backend selection
GitGud.Git.pick_backend/0already preferred Nif whenavailable?/0is true. With the NIF now exporting the new functions,current_backend/0reportsGitGud.Git.Nifin 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 internaladmin_count/1to gate.Organizations.remove_member_guarded/2— wrapsremove_member/2with the same last-admin check, returns:ok | {:error, :last_admin | :not_a_member}.
LiveView
OrgLive.Membersat/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/1or aRepo.get_by(User, email: …)lookup. Adds asmember(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/1ordering;set_member_role/3promote/demote, last-admin refusal, no-op same-role, not-a-member;remove_member_guarded/2member 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
20260513340000—organizations.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/2validate inclusion in the enum.
Cascading enforcement
Repositories.create_repository_for_org/3now refuses repos more open than the org with{:error, {:visibility_too_open, "<org_vis>"}}. The check usesOrganization.visibility_rank/1so the rule is a one-line comparison.Repositories.update_repository/2runs the same check viamaybe_check_org_visibility/2— passing the proposed visibility against the org’s current setting.Organizations.update_organization_visibility/2is 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/:handlemoved out of:authenticated_repoand into the:public_repolive 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_navigateto/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 toorg_admin?/2. Returns false forniluser.
UI
OrgLive.Indexnew-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.SettingsProfilegets a “Visibility” section above the existing Public-profile section, with a select + Save button. Wired toupdate_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?/2membership 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:openso the existing open-registration assertions keep passing).Accounts.registration_mode/0accepts both atom and string values (so env-var-driven:runtime.exsconfig works the same) and falls back to:invite_onlyfor unknown values.Accounts.register_user/1short-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
20260513330000—invites(token unique, note, created_by_id, consumed_by_id, expires_at, consumed_at, timestamps). Both FK refsON DELETE SET NULL. GitGud.Accounts.Inviteschema withcreate_changeset/2(auto-generates a 24-byte URL-safe token if missing),consume_changeset/2(idempotent — re-consume is a no-op), andactive?/1(true when not consumed and not pastexpires_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 stampsconsumed_atwithout aconsumed_by_id, distinguishing revoked from used.
Registration LV
- Resolves a three-way
gateassign on mount::closed,:invite_required, or:open. :closedshows a hard “registration is closed” alert and disables the form.:invite_requiredshows the same alert with friendlier copy (“you’ll need an invite link from an existing member”).:openwith a valid?invite=<token>shows a welcome banner and proceeds. Invite is consumed on successful registration.:openwithout an invite is the normal path.savehandlers reject submissions under:closed/:invite_requiredmodes 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/1enforcement of:closed, invite creation + uniqueness,list_invites/1ordering,get_active_invite/1for missing/active/consumed, idempotent consume, owner-only revoke,Invite.active?/1truth 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.SettingsProfileat/orgs/:handle/settings/profile. Admin-only — non-admins (including non-members) hitpush_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), sameplatform | urlsocials textarea parsed into the JSONB shape, same pin-management UI (drag-to-reorder grid + add/remove form). - Pin-management uses the existing
PinReorderhook +PinnedReposGridcomponent 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 withowner_idrecorded as an admin for the audit trail. Visibility ispublic. The seed description is a one-line hint about what the repo is for.- Hooked into
Accounts.register_user/1andOrganizations.create_organization/2as 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 thanRepo.get_by/2with a nil keyword) because Ecto’snilcomparison guardrail otherwise raises in production-mode queries.
Backfill
mix gitgud.profile_repos.backfill— walks every existingusers+organizationsrow and callsensure_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, findsreadme(\.md)?case-insensitively, fetches the blob bytes, and either renders markdown (when.md) or wraps as preformatted text. Returns{:ok, html_string}ornil.- Tolerant of every empty state: missing repo, missing default branch, missing README — all collapse to
niland the profile page just skips the section.
Profile rendering
UserLive.Profile+OrgLive.Showboth load the README on mount and render it on the right column (below Pinned, above Repositories) in aprosearticle 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/1is idempotent, registration doesn’t fail when the repo already exists, org creation auto-creates the org repo,get_profile_readme/2returns 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.exswas updated to account for the now-auto-created profile repos showing up inlist_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 one100644 blob <sha>\tREADME.mdline, run throughsh -cfor stdin redirection. - Commit: reuses the existing
GitGud.Git.commit_tree/5facade (CLI backend) with author/committer = the default"GitGud" <noreply@gitgud.local>. No parents. - Ref:
Git.update_ref/4writesrefs/heads/<default_branch>(typicallymain) 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
20260513320000—pinned_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 NULLand 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 byposition 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 to0..n-1so 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 newpositionfor 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
PinReorderinassets/js/app.js. No third-party dep — uses HTML5dragstart/dragover/drop. The hook attaches itself to the grid container and wires every child withdata-pin-id={id}for drag.- On drop, pushes
"reorder_pins"with%{"order" => [...ids in DOM order]}. The LV handler delegates toProfiles.reorder/2and 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 withreorderable?={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 fromgit_commit_language_statsfor the HEAD of the repo’s default branch).- Rendered on
UserLive.ProfileandOrgLive.Show(read-only, noreorderable?).
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 fromlist_repositories_for/1to this so members can show off org work on their personal profile.pinhandler inUserLive.Settingsvalidated viapinnable_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/sitenot justsite) 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/2now calls that helper after the membership delete. Removed members stop advertising work they no longer have access to. Org-level pins (organization_idset) 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 arounded-full overflow-hidden<span>so any future uploaded image drops in unchanged.- Imported into every LV via
git_gud_web.ex’shtml_helpers/0, so<.avatar />is available globally next to<.icon />.
Theme tracking
- Cell
fills usestyle="fill: var(--color-primary)"(orsecondary,accent,info,success,warning— picked from the seed’s hash byte). Background isbg-base-200on 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/1is 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-characterviewBoxkeeps the cells crisp at every size.
Wiring
- User profile (
UserLive.Profile) and org profile (OrgLive.Show) —size="2xl"next to the title. - Repo header —
size="sm"inline before the{handle}link, so every repo page (after slice 47) carries a tiny identifier of its namespace. - Issue + PR comments —
size="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— addshandle(citext, unique, NOT NULL) tousers, plus the shared profile field block (display_name,bio :text,url,location,company,public_email,timezone,socials :map) to bothusersandorganizations. - Existing rows get a handle backfilled from
lower(split_part(email, '@', 1))with_Nsuffixes on collisions, all in a single SQLUPDATE ... FROM (ranked CTE). So pre-existing users on dev/prod databases acquire URL-safe handles without anyone claiming one.
Accounts
User.email_changeset/3seedshandleautomatically when missing (regex-gated; falls back to nil if the email local-part isn’t URL-safe). Carries aunique_constraint(:handle)so insert collisions surface as a changeset error instead of a Postgres exception.Accounts.register_user/1now retries up to 8 times with_Nsuffix on the handle if a duplicate collides — so signing upalice@a.exampleandalice@b.exampleproducesaliceandalice_1cleanly.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 chaindisplay_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.Profileat/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.Showextended 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 thesocialsJSONB shape (%{"items" => [%{"platform","url","handle"}, …]}). Empty / missing / malformed entries get filtered out silently.
Repo header: clickable namespace
- The
{handle}/{name}title inRepoLive.Header.header/1was a single repo-home link. Split into two — the handle half goes to/u/:handlefor user-owned repos or/orgs/:handlefor org-owned, the name half stays linked to the repo. The/separator stays dim.
Settings form
UserLive.Settingsgains a “Public profile” section above the existing email/password/theme blocks. One simple input per field plus aSocial linkstextarea where each line isplatform | 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/1for aUsernow prefersuser.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,_Nsuffixing for collisions, case-insensitivity,get_user_by_handle/1missing → nil,profile_changesetcast scope + length caps,User.display/1fallback chain, org-side update +Organization.display/1chain. 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 inassets/js/app.jsreadse.detail.textand writes tonavigator.clipboard, falling back to a hidden-textareaexecCommand("copy")when the async clipboard API is unavailable (non-secure context, older browsers). - The dispatch chains a brief
scale-110JS 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/2andssh_clone_url/2helpers that were already inRepoLive.Showpowering 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
20260513290000—git_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. languagesis 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/2walks the cached tree fromgit_trees, recurses into subtrees, and looks up each blob’sdetected_language+sizefromgit_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_backfillon 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_cachequeue, max attempts 3. Unique on(repository_id, sha_hex)across available + scheduled + executing states withperiod: :infinity— duplicate enqueues collapse, the result is the same per immutable commit SHA.- Enqueued from
CommitBackfillfor everyrefs/heads/*update — the new HEAD’s stats are computed in the background as part of the push pipeline. - Also enqueued lazily from
RepoLive.ShowandRepoLive.Languageswhen 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/2calls this and, on:not_computed, enqueues the worker and returnsnilso the bar stays hidden.
UI — the bar
- New
language_bar/1component onRepoLive.Show, rendered between the ref-picker and the file tree. 0.3em tall (≈4.8px at 16px base),rounded-full, segments colored fromGitGud.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’stitle=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: replacesemantics for re-compute, persistence togit_commit_language_stats, and theget_language_stats/2read 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/committersnew 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/languagesand/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— addsmoderated_at,moderated_by_id(FK to users, ON DELETE SET NULL),moderation_note :text,original_body :texttoissues,issue_comments,pull_requests,pr_comments. One(moderated_at)index per table. - Each of the four schemas gains
moderate_changeset/3,restore_changeset/1, andmoderated?/1.moderate_changesetsnapshotsbody→original_body(only if not already snapshotted, so re-moderating doesn’t clobber the original).
Reports module
Reports.moderate_target/3— replaces the olddelete_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 shape —
deletable?: boolean→moderatable?: boolean+moderated?: boolean. Excerpts now preferoriginal_bodyso 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-confirmgates the action. - Already moderated: a “Restore original” button (
phx-click="restore_content").
- Not moderated: an inline form with an optional one-line note input and a “Replace with mod message” submit (
- Both auto-resolve the report differently — replace marks it
actionedwith"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 byIssueLive.ShowandPrLive.Showfor issue/PR bodies and comments. Whenmoderated_atis 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?/2is a pure function (admin ORauthor_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_atdon’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 onIssues.get_issue!/2andPullRequests.get_pull_request!/2. The previous code relied on Postgres’s implementation-defined preload order.
Refuse re-reports on moderated content
Reports.open/4now 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.ShowandPrLive.Showsurface the new error with a flash explaining “This content has already been moderated.”
Tests
- Updated
reports_actions_test.exs: dropped thedelete_targettests, added 12 new ones formoderate_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), andopen/4vs already-moderated (refuses for moderated targets, allows for unmoderatable kinds like actors). - Added
moderation_components_test.exs— 4 tests oncan_view_original?/2covering 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/1API change:expanded_default(integer) replaced withloaded_idx(MapSetof 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 oldexpanded_default/1used, 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 nowdef handle_event("expand_diff_file", params, socket), do: {:noreply, DiffComponents.expand(socket, params)}.- Three call sites updated —
RepoLive.Compare,RepoLive.Commit,PrLive.Show. Each assignsloaded_diff_idxafter computingdiff_filesand delegates the expand event. The per-LVexpanded_default/1helpers 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
defphelpers inAdminFederationLivethat sat betweenhandle_eventclauses, killing a pre-existing “clauses with the same name and arity should be grouped together” warning.mix compile --warnings-as-errorsis now green. - 9 new tests covering
default_loadedtiers,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 returnnil(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 existingFederation.suspend_actor/1so 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 (ifsuspendable_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_targetsuccess /:not_found/ unsupported-type rejection;suspend_actorsuccess / 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_replyhandler that fetches the comment by id from the in-memory thread, runsquote_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 (viaRepositories.Numbering) so a given number is unambiguous.Markdown.to_html(body, repo: repo)— gates expansion on the:repoopt; without it the renderer is unchanged (so README + wiki + blob views, which legitimately contain#42in 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
#42inside\#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_idso the same body in two different repos can’t collide. Repositories.find_by_path/2— non-raising sibling ofget_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
validateevents don’t lose them. Org-inherited labels carry an(org)suffix in the chip. - Query-string autofill —
/issues/new?title=...&body=...&labels=bug,securitypre-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
#42syntax.
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 (
Tailwind500-shade rainbow + slate). Click a swatch →pick_colorevent → 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-sidevalidate_format.
Org-level labels with per-repo opt-out
- Schema:
labels.organization_idadded (nullable, XOR withrepository_idvia CHECK constraint). Newrepository_label_opt_outs(repo_id, label_id)join table. Migration20260513270000. Labels.list_labels/1now 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
inheritedbadge + 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_reviewsgetssource_actor_id+activity_url(matching thepr_commentsshape from slice 22).reviewer_idis now nullable so federated reviews can carry no local user. Migration20260513250000. - Inbound dispatch:
ProcessInboxacceptsLike→approvedreview,Dislike→changes_requestedreview. The activity’sobject(either the local Ticket URL or the remote-mintedactivity_urlwe stored on the federated PR row) maps to the right PR. Idempotent on the activity’sid. find_pr_by_object_urlwidened — now matches against either a local Ticket URL (<repo_actor>/pull_requests/<N>) or a storedpull_requests.activity_url. The same helper is shared betweenCreate(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?/1helper.- Outbound:
PullRequests.add_review/3now detects when the PR hassource_actor_idset and firesFederation.Publishing.publish_pr_review/3. Mapsapproved→ APLike,changes_requested→Dislike,commented→ no AP emit (no standard verb for it). Body carried incontent. ActivityBuilders.pr_review_verdict/4— builds the Like/Dislike activity with the PR’s Ticket URL asobject+ body ascontent.- 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_blockstable —(host, reasons, peers[], peer_count, first_seen_at, last_seen_at, status: pending|approved|dismissed, resolved_by, resolved_at). Unique onhost. SuggestedBlocks.ingest/3— applies a[{host, reason}, ...]list as having come frompeer_host. Per host: create withpeers: [peer]if new, else add peer (deduped) + bump count + merge reasons. Filters out hosts already onfed_instance_blocksso we never propose what’s enforced.SuggestedBlocks.parse_list/1— accepts one host per line, orhost,reasonCSV. Blank lines +#comments ignored.SuggestedBlocks.approve/3— wrapsFederation.block_instance/2+ flips status.dismiss/2flips status without applying.Mix.Tasks.Gitgud.Federation.SuggestBlocks—mix gitgud.federation.suggest_blocks coop.example=https://coop/blocks.csv peer.example=https://peer/blocks.csv [--dry-run]. Supportsfile://URLs for testing. Each pair fetches, parses, and ingests; logs counts.- Admin LiveView tab — new “suggested” tab on
/admin/federationlisting 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 (botheventandtypefield shapes — different zot versions disagree). Bearer-token guarded viaconfig :git_gud, GitGudWeb.PackagesWebhookController, shared_secret: "..."; nil = unauthenticated (dev / loopback-only).- Repo path layout:
<owner>/<repo>/<image...>— the first two segments resolve aGitGud.Repositories.Repository, the rest become the package name. Soorg/site/webandorg/site/dbare 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/4added — wipes a single version, recomputeslatest_versionfrom the next-most-recent push, deletes the package row if that was the last version.RepoLive.Packagesat/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 whoselast_used_at(orarchived_atwhen never read) is older than N days.nildisables.:per_repo_max_mb— per-repo total finalized-bytes cap. After age pruning, anything past the cap is evicted LRU onlast_used_at.nildisables.: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) > cappre-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 shipsmax_age_days: 14, per_repo_max_mb: 5_000, dry_run: false— operator-tunable per deployment. run_now/0wrapsperform/1with 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_entriestable —(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 havearchived_at: niland are invisible to lookups.GitGud.Workflows.ActionsCachecontext —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-cachebucket keyed<repo_id>/<row_id>, served viaStorage.presign_url/4. Works for both Disk + Garage backends without controller changes. ActionsCacheControllerrewired —get_cachedecodeskeys=, looks up via the new context, returns JSON witharchiveLocationon hit.reserve_cacheinserts a row (replacing any prior pending one on the same triple).upload_chunkappends bytes (read-modify-write; cache objects are small enough this is fine).finalizeflipsarchived_at+ records size.- Operator precedence bug found during testing —
||binds looser than|>so acase do ... endchained afterresult_or_fallback || ...silently bypassed the case block when the primary side returned a truthy value. Refactored to an explicitresult = ...; 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 freshWorkflowRuncarrying the sameworkflow_doc/head_sha/head_ref; jobs + steps are re-seeded from the stored doc via the newYaml.normalize_doc/1helper so we don’t need to re-fetch the workflow YAML from disk.Workflows.cancel/1—Repo.update_allflips run → cancelled, queued/running jobs → cancelled withconclusion: "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.statusenum gainscancelledto matchruns+jobs(already had it).Yaml.normalize_doc/1— public-facing version of the existing privatenormalize/1. Letsrerun/1skip 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 onqueued/runningruns, Re-run onsuccess/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
WorkflowRunrow 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
UpdateTaskpolling.
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/1in 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/4now caches the parsedfile_difflist inGitGud.Cachekeyed 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 freshgit diffshell-out + parser pass.RepoLive.Comparewired 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/4backend callback — added toGitGud.Git.Backend,Git.Cli(real impl viagit diff --no-color -U<ctx>),Git.Nif(returns:unsupported→ falls through to Cli). Root commit handled viagit show --format=.GitGud.Git.DiffParser— parses raw unified diff into structuredfile_diffmaps. Tracks per-lineold_line/new_linenumbers, 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 callsGit.diff/4+ parses. Uncached for v1 (diff blobs can be MB-scale; LRU throughGitGud.Cacheis 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 tintedbg-success/10, del rowsbg-error/10, both keyed off daisyUI color tokens so it follows the active theme. Per-line content runs throughGitGud.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.Cachekeyed 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/3return 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 onmfa_enabled?/1(TOTP or WebAuthn), so a WebAuthn-only user no longer slips past the challenge.TwoFactorChallengeLiveView — readshas_totp?+has_webauthn?separately. WebAuthn-only users default to:recoverycode 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_credentialstable —credential_id, COSEpublic_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.Webauthncontext —registration_challenge/1,serialize_registration_options/2,register_credential/3,authentication_challenge/1,serialize_authentication_options/1,verify_assertion/3plus CRUD (list_credentials,delete_credential,rename_credential,count,has_credentials?). Pre-existing credentials populateexcludeCredentialsso the browser doesn’t try to enroll an already-paired key.- Endpoints:
POST /api/webauthn/register/{start,finish}— authed; the start handler stashes theWax.Challengein the sessionPOST /api/webauthn/auth/{start,finish}— anonymous (mid-2FA); reads:two_factor_user_idfrom session, on success writes:webauthn_verified_user_idand 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 viax-csrf-tokenheader. - UI integration:
TwoFactorSetupLiveView gets a “Security keys” section with an Add security key button + a list of registered keys (name, AAGUID, added / last-used timestamps, remove button)TwoFactorChallengeLiveView (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.Webauthnaccepts:rp_id(defaults to the endpoint host) and:origin(defaults toGitGudWeb.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_totpfor RFC 6238 codes,:eqrcodefor the enrollment QR. - Schema:
usersgainstotp_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). Newuser_recovery_codestable — bcrypt-hashed, one row per code,used_atmarks 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 flow —
UserAuth.log_in_user/3detectsTwoFactor.enabled?/1and stashes:two_factor_user_idin 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 runscomplete_two_factor/2, which is the only place wherelog_in_usergets to callcreate_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/0so timing doesn’t leak validity. - Instance enforcement —
config :git_gud, GitGud.Accounts.TwoFactor, required: true, grace_period_days: 14.enforcement_status/1returns:not_required | :enrolled | {:grace, days_left} | :enforced. The:require_authenticatedon_mount hook redirects users in the:enforcedstate 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
uuidfield on theRegisterresponse as a real UUID; we were sending stringifiedrunner.id(“4”, “5”) and the runner failed config save with “invalid UUID length: 1”. Addedrunners.uuidcolumn (Ecto.UUID, backfilled withgen_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
20260513200000addsrunners.repository_id+runners.organization_id(nullable, with a CHECK constraint enforcing at most one set) plusrunner_reg_token_hashcolumns on repositories + organizations. Workflows.Runnersrewritten around scope:mint_registration_token(repo_or_org)stores SHA-256 hash, returns bare token onceconsume_registration_token/1resolves a token to:global/%Repository{}/%Organization{}or:errorregister/2takes a scope, sets the right FKlist_for_scope/1returns repo-eligible runners (repo + parent org + global), org-eligible (org + global), or just globaldelete_runner/1for revocation- Old
valid_registration_token?/1removed (callers useconsume_registration_token/1)
claim_next_job/1widened — joins throughworkflow_runs(andrepositoriesfor 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.registerconsumes the token to determine scope and threads it intoRunners.register/2. Legacy global env token still works.RepoLive.SettingsRunnersat/r/:owner/:name/settings/runners— mint / regenerate the repo token (with copy-pasteforgejo-runner registersnippet), list eligible runners with scope badges, remove repo-scoped runners.OrgLive.SettingsRunnersat/orgs/:handle/settings/runners— same pattern for orgs.SecretsLive.Orgat/orgs/:handle/settings/secrets— mirror ofSecretsLive.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, andlist_for_scope/1aggregation. 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/1andWorkflows.list_artifacts/1context 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 fromcurrent_scope, setsContent-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/1ordering. 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
.proseCSS inassets/css/app.csscovering 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-fallbackstyled too. All built on daisyUI’s--color-base-*variables so it tracks the active page theme. users.editor_themecolumn (migration20260513180000) plusUser.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/1resolves fromcurrent_scope.user.editor_theme, falling back to the app default when unset or invalid.assign_editor_theme/1hook inUserAuth.on_mount/*puts@editor_themeinto every LiveView’s assigns automatically (anon + authed). No per-view boilerplate.GitGud.Markdown.to_html/2+GitGud.SyntaxHighlight.highlight/2both 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_themethrough. - 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 withmax_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:languageor:filename(extension + basename map forDockerfile,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_highlightformatter. Sha256-keyed cache with 500KB input cap.- Duplicated
render_mdhelpers eliminated —WikiLive.Show,PrLive.Show,IssueLive.Show,RepoLive.Show,RepoLive.Bloball delegated toGitGud.Markdown.to_html/1. Each was previously its own copy of the same MDEx call. RepoLive.Blobnow syntax-highlights source files — non-markdown text files render throughSyntaxHighlight.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.LocalforNebulex.Adapters.PartitionedinGitGud.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.Storageimpl reading/writing under a configured:rootdirectory.presign_url/4returns a token-signed URL routed through a newGitGudWeb.StorageController. Token viaPhoenix.Token, embeds bucket+key+method+expiry. Path components sanitized against traversal (..,/,\).GitGudWeb.StorageController—GETandPUTat/_storage/:bucket/*keyverify the token and stream bytes to/from the on-disk path viasend_file/ chunkedread_body. New:rawrouter pipeline so Plug.Parsers doesn’t swallow the upload body.lfs_objectstable (migration20260513170000) —repository_id,oid(64-hex SHA-256),size,storage_bucket,storage_key,uploaded_at. Unique on(repository_id, oid).GitGud.Lfscontext —batch/3for the LFS Batch API (“download” / “upload”),verify/3for the post-upload completion call,get/2lookup. 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_atis only set afterverify.GitSmartHttpplug gets two new dispatch clauses:POST /:owner/:name.git/info/lfs/objects/batchandPOST /: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.- Config —
config :git_gud, GitGud.Storage, impl: GitGud.Storage.Diskis the new default;config :git_gud, GitGud.Storage.Disk, root: "priv/storage". Tests + dev use Disk undertmp/test/storage. Prod can swap toGitGud.Storage.Garagewith 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.Garageand configureex_awsas described in that module’s docstring. - The
/_storageroutes 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 button —
AdminFederationLive.Indexreports tab now has a “Forward to source” action next to Dismiss/Mark actioned. CallsReports.forward_to_source/2; surfaced only whentarget_type ∈ {pull_request, pr_comment, actor}. Backend gates the actual federation eligibility, button is just a heuristic gate. - Remote actor profile pages —
GitGudWeb.ActorLive.Showat/actors/:idrenders 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 anactortarget-type report. Federated-PR show page links the “Offered by” line to/actors/:idso reading users can reach the report path in one click. - “Send PR to remote repo” UI —
PrLive.Newgains 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 callsFederation.Outbound.offer_pull_request/4instead ofcreate_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-letter —
DeliverOutboundnow distinguishes terminal vs in-flight failures: on the final attempt the row is written withstatus: "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
Update—ProcessInbox.dispatch("Update", …)matches a Ticket with merge intent against the source actor’s open federated PRs, callsPullRequests.refresh_federated_head/2to re-fetch the source branch, re-snapshothead_sha+base_sha, re-pinrefs/pull/<N>/head, and clearmergeablefor re-probing. - Inbound
Reject— Reject of a Ticket (by id or as a bare URL) closes the matching federated PR viaPullRequests.set_state/2. - Inbound
Create(Note)—Createwith a NoteinReplyToa local PR’s Ticket URL lands as a federated PR comment withsource_actor_idset andauthor_id: nil. Idempotent via unique partial index onpr_comments.activity_url. Migration20260513160000_add_federated_pr_comment_originaddssource_actor_id,activity_urlcolumns;PrComment.federated_changeset/2+federated?/1helper. - Remote actor refresh worker —
Federation.Workers.RefreshRemoteActorswalks 50 stalest active remote actors per hour (cron@hourly), re-fetching them viaRemoteActors.fetch/1. 404/410 responses mark the actorstatus: "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_commentspreload extended with:source_actorso federated comments render with origin handle +federatedbadge. - Cross-instance report forwarding —
Reports.forward_to_source/2resolves the report’s target to its originating remote actor (PR / pr_comment / actor target types), builds an APFlagactivity authored by the admin’s local actor, and queuesDeliverOutboundto the remote shared / actor inbox. NewActivityBuilders.flag/3. Refuses non-federated targets with{:error, :not_federated}. - Outbound
Offer—GitGud.Federation.Outbound.offer_pull_request/4resolves a target repository’s AP actor URL viaRemoteActors.fetch/1, validates the target is aRepository-typed actor, builds anOffer(Ticket)via the newActivityBuilders.offer_pull_request/3, inserts a local activity row, and queuesDeliverOutbound. 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.exsgrows from 3 to 6 covering Update/Reject/Note; newfederation/outbound_test.exscovers 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_originadds three columns topull_requests:source_actor_id(FK toactors),activity_url(unique partial index for idempotency),source_clone_url. Plus index onsource_actor_id. PullRequestschema gainsbelongs_to :source_actor,field :activity_url,field :source_clone_url, andfederated?/1.PullRequests.create_federated_pull_request/3— opens a PR whose source is a remote AP actor. Fetches the source branch directly from the suppliedsource_clone_urlinto the target repo (refs/pull-tmp/...), verifies the claimed head SHA is reachable, computes the merge base, inserts a PR withsource_repository_id=nil,author_id=nil,source_actor_idset, and pins head intorefs/pull/<N>/head.ProcessInbox.dispatch("Offer", …)— recognizes a ForgeFedTicketwithisMerge: true(orsourceBranch/targetBranchheuristics), resolves the source clone URL fromobject.source.cloneUrithenactor.endpoints.cloneUrithen the actor URL +.gitas a last resort. Idempotent onactivity_url.PrLive.ShowandPrLive.Indexrender a “federated” badge forsource_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: directcreate_federated_pull_requestpath with head pinning, end-to-end throughProcessInboxwith 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 existingActivityBuildershas nopr_offeredbuilder yet. Tracked in ROADMAP under outbound federation.
Slice 20 — Embedded SSH daemon
GitGud.Ssh.ServerGenServer boots Erlang’s:ssh.daemon/2under the app supervisor whenconfig :git_gud, GitGud.Ssh.Server, enabled: true. Default port 2222. Host key auto-generated topriv/ssh/ssh_host_ed25519_keyon first boot viassh-keygen(gitignored).GitGud.Ssh.KeyApi(:ssh_server_key_api): looks up presented public key by SHA-256 fingerprint inuser_ssh_keys, requires the SSH username to equal the user’s email local-part (Gerrit pattern). Fingerprint parity withSshKey.compute_fingerprint/1via:ssh_message.ssh2_pubkey_encode/1with manual SSH wire-format fallback for ed25519 + RSA.GitGud.Ssh.Channel(:ssh_server_channel): parsesgit-{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 asremote:on the client).- Empty-repo page’s
ssh_clone_url/2now uses the configured SSH port + optionalexternal_host. HookReceiver.receive/2self-healsRepository.default_branchfrom 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 underrefs/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 viaupdate_refwith CAS; refuses on divergence withoutforce: true; permission gate (owner or org admin)- Fork sync banner on
RepoLive.Showfor 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 underrefs/compare/<branch>firstRepoLive.Compareat/r/:owner/:name/compare?base=…&head=….headacceptsbranchorowner/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\x01sentinel 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 inassets/css/app.css - Root layout (
layouts/root.html.heex) carries the theme bootstrap script: localStorage-persistent, system fallback respectsprefers-color-scheme,phx:set-themeevent handler updatesdata-themelive - New
Layouts.appshell with a real navbar: brand, Repos / Orgs / Admin links (Admin only whenis_admin: true), theme dropdown, user menu (settings / ssh-keys / log out) or Log in / Register for anonymous wideslot attribute onLayouts.appfor 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. PageHTMLgotfeature/1+quick_link/1function components used by the landingis_admin: truecontrols 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_idmigration;Repository.fork?/1Repositories.fork/4— clones the source bare repo (git clone --bare), installs hooks, links viaparent_repository_id- Permission gates: source must be readable; destination namespace must accept (user-self or org-admin)
Repositories.list_forks/1for 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?/1create_pull_requestaccepts asource_repository_idattr. For cross-repo, fetches the source branch into the target viagit fetch <source_path> <branch>:refs/pull-tmp/<branch>then pins it asrefs/pull/<N>/headon 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’srefs/pull/<N>/headand bumpshead_sha. Hooked intoCommitBackfill- 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 aforkbadge - 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.Secretscontext 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/1for runner-bound code - Master key rotation supported via per-row
key_id+master_keysconfig map - Repo-scoped Secrets/Vars LiveView at
/r/:owner/:name/settings/secrets FetchTaskinjects the resolved{secrets, vars}map into the runner task message- YAML output for
workflow_payloadswapped from the hand-rolled writer to:ymlr GitGud.Workflows.RunnerJwt(HS256, per-task, 24h default TTL) —mint/2+verify/1FetchTaskinjects the JWT intoTask.contextastoken,gitea_runtime_token,ACTIONS_RUNTIME_TOKEN, plus the matching_URLkeys forgejo-runner readsGitGudWeb.Plugs.RunnerJwtAuth— Bearer-token plug that halts 401 on missing/invalid tokenActionsCacheController— v1 cache server (GET always 204 = miss; POST/PATCH persist viaGitGud.Storageto bucketgitgud-cache)ActionsArtifactController— v1 Pipelines artifact API (create, upload, list, download, finalize) backed byGitGud.Storagebucketgitgud-artifacts, with aWorkflowArtifactschema 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
Storageimpl.
Slice 15 — Forgejo runner protobuf
priv/proto/runner/v1/messages.protomirroringforgejo/actions-proto-def/runner/v1mix gitgud.protocregenerateslib/git_gud_web/protos/runner/v1/messages.pb.ex- Controller content-negotiates
application/protobufandapplication/json; runner.v1 endpoints use the generated message structs end-to-end Runner.V1.Taskpopulated withworkflow_payload(gzip’d YAML),context,needs;secrets/varspopulated in slice 16mix gitgud.runner.bootstrapprints the exactforgejo-runner registercommand for the configured instance- 7 runner controller tests + secret roundtrip + JSON roundtrip
- Real
forgejo-runnerbinaries can now register + poll FetchTask against this instance
Slice 14 — gix NIF wiring
:rustlerdep +use RustlerinGitGud.Git.Nif- Implemented in the NIF:
init_bare,list_refs,blob_bytes,blob_meta,resolve - Per-operation fallback:
Git.dispatch/2runs the primary backend, transparently falls back toClion{:error, :unsupported} Git.current_backend/0for observability- Test infrastructure wipes
tmp/test/repos/on suite start (gix is stricter thangitabout 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.Publishingfans 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
attributedToso repo followers receive them - Org actor doc advertises
repositoriescollection;/orgs/:handle/repositoriesreturns 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 inProcessInbox quarantinedstatus onInboxDelivery; admin approve/reject re-runs ProcessInbox- Reports schema + report buttons on issue comments
is_adminflag on users;mix gitgud.promote_admin;:require_adminLV mount hook/admin/federationLiveView (deliveries / quarantine / blocks / allowlist / reports tabs)- Allowlist as the production default;
:openis 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.RemoteActorswith moderation-aware fetch + cache- Raw-body capture plug for signature verification
- Inbox controller verifies signature before processing; rejects with audit row preserved
ProcessInboxOban worker dispatches by activity type (Follow → Accept, Undo, Accept)DeliverOutboundOban 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/1for users/orgs/repos- ActorJSON renderer (AS + ForgeFed + security/v1 contexts)
- AP dispatcher diverts
Accept: application/activity+jsonrequests 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/1resolves namespace from preloaded org/owner- RepoLive.New picks namespace; OrgLive.Show lists org repos
- Pre-receive hook (shell script +
/_hooks/pre-receiveendpoint) 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 vsd:<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-sshwrapper +/_ssh/resolveendpointmix gitgud.ssh.authorized_keysfor regenerationpriv/scripts/gitgud-hook+/_hooks/post-receiveclosing the push → cache backfill loop- 17 smoke tests across Git, Repositories, Issues, Labels, PRs, Wiki
Slice 6 — Object storage + container registry
GitGud.Storagebehaviour +GitGud.Storage.Garageimpl overex_aws_s3- OCI registry token endpoint at
/v2/token, RS256 JWTs scoped to repo + actions packages+package_versionsmetadata mirrorPackages.record_push/4idempotent 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/2fires on push, scans.github/workflows/*and.forgejo/workflows/*claim_next_job/1withFOR UPDATE SKIP LOCKEDPubSubtopic 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/1viagit merge-tree --write-treePullRequests.merge!/3does 3-way merge via plumbing (commit-tree + atomic update-ref CAS)- Reviews + comments tables
- Wiki: sister
.wiki.gitbare repo,Wikis.put_page/4via 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_rendersGitGud.Gitfacade +Backendbehaviour +Cliimplementation (NIF added in slice 14)- Cache-first read API with write-through
Repositories.create_repository/2initializes bare on-disk repo + post-receive hook- Smart HTTP transport via
git http-backendCGI - LiveViews: repo list/show/tree/blob/log/commit
CommitBackfillOban worker triggered by post-receive
Slice 1 — Scaffold + plan
- Phoenix 1.8 +
phx.gen.authbaseline - 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_KEY → Packages.Token and ZOT_WEBHOOK_SECRET → PackagesWebhookController, 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.