Add tabs controller and user profile tabs, update UI and dev panel
- Add Stimulus tabs controller and user profile tabbed layout - Prefix usernames with @ in links for consistency - Update dev panel button style and position - Move dev panel into footer for better layout - Add .local-repos to .gitignore for local bare repos - Update Gemfile: pin tailwindcss-rails and smbcloud-auth versions - Configure letter_opener for mailer in development - Remove unused @:username routes - Add dev:seed_repo rake task for local repo seeding - Minor UI tweaks (home page, sidebar, etc.)
Seto Elkahfi committed
Apr 12, 2026 at 12:59 UTC
3adde13aa6a42636b9ff22dfb750d8822b2fd809
20 files changed
+751
-76
.agents/AGENTS.md
new file mode 100644
index 0000000..e69de29
.agents/skills/design-system/SKILL.md
new file mode 100644
index 0000000..e69de29
.agents/skills/local-environment/SKILL.md
new file mode 100644
index 0000000..e69de29
.gitignore
+4
index a7fd80d..0fdde5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,7 @@
# Ignore node_modules.
/node_modules/
+
+# Ignore local git repos for development environment
+.local-repos/*
+!.local-repos/.keep
.local-repos/.keep
new file mode 100644
index 0000000..e69de29
Gemfile
+10
-2
index 8f86961..66e30f7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -15,12 +15,12 @@ gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails]
-gem "tailwindcss-rails"
+gem "tailwindcss-rails", "~> 3.3.1"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout
-gem "smbcloud-auth", path: "../smbcloud-cli/sdk/gems/auth"
+gem "smbcloud-auth", "~> 0.3.34"
# Markdown rendering for README files
gem "redcarpet", "~> 3.6"
@@ -51,10 +51,18 @@ gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"
+group :development do
+ # Open sent emails in the browser instead of sending them
+ gem "letter_opener"
+end
+
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
+ # Load .env file into ENV on boot
+ gem "dotenv-rails"
+
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
Gemfile.lock
+21
-9
index f49f9ce..d909c54 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,10 +1,3 @@
-PATH
- remote: ../smbcloud-cli/sdk/gems/auth
- specs:
- smbcloud-auth (0.3.33)
- json
- rb_sys (~> 0.9.91)
-
GEM
remote: https://rubygems.org/
specs:
@@ -82,6 +75,8 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt_pbkdf (1.1.2)
@@ -95,6 +90,8 @@ GEM
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
+ childprocess (5.1.0)
+ logger (~> 1.5)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
@@ -103,6 +100,9 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.2.0)
+ dotenv-rails (3.2.0)
+ dotenv (= 3.2.0)
+ railties (>= 6.1)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.2)
@@ -146,6 +146,12 @@ GEM
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
language_server-protocol (3.17.0.5)
+ launchy (3.1.1)
+ addressable (~> 2.8)
+ childprocess (~> 5.0)
+ logger (~> 1.6)
+ letter_opener (1.10.0)
+ launchy (>= 2.2, < 4)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.1)
@@ -199,6 +205,7 @@ GEM
psych (5.3.1)
date
stringio
+ public_suffix (7.0.5)
puma (8.0.0)
nio4r (~> 2.0)
raabro (1.4.0)
@@ -289,6 +296,9 @@ GEM
ffi (~> 1.12)
logger
securerandom (0.4.1)
+ smbcloud-auth (0.3.35)
+ json
+ rb_sys (~> 0.9.91)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
@@ -351,10 +361,12 @@ DEPENDENCIES
brakeman
bundler-audit
debug
+ dotenv-rails
image_processing (~> 1.2)
importmap-rails
jbuilder
kamal
+ letter_opener
pg (~> 1.1)
propshaft
puma (>= 5.0)
@@ -362,12 +374,12 @@ DEPENDENCIES
redcarpet (~> 3.6)
rouge (~> 4.5)
rubocop-rails-omakase
- smbcloud-auth!
+ smbcloud-auth (~> 0.3.34)
solid_cable
solid_cache
solid_queue
stimulus-rails
- tailwindcss-rails
+ tailwindcss-rails (~> 3.3.1)
thruster
turbo-rails
tzinfo-data
app/javascript/controllers/tabs_controller.js
+37
new file mode 100644
index 0000000..62cbb61
--- /dev/null
+++ b/app/javascript/controllers/tabs_controller.js
@@ -0,0 +1,37 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["tab", "panel"]
+ static values = { active: String }
+
+ connect() {
+ const initial = this.activeValue || this.tabTargets[0]?.dataset.tabsKey
+ if (initial) this.#activate(initial, false)
+ }
+
+ switch(event) {
+ event.preventDefault()
+ const key = event.currentTarget.dataset.tabsKey
+ if (key) this.#activate(key, true)
+ }
+
+ #activate(key, push) {
+ this.activeValue = key
+
+ this.tabTargets.forEach(tab => {
+ const active = tab.dataset.tabsKey === key
+ tab.classList.toggle("active", active)
+ })
+
+ this.panelTargets.forEach(panel => {
+ const active = panel.dataset.tabsKey === key
+ panel.classList.toggle("hidden", !active)
+ })
+
+ if (push && history.pushState) {
+ const url = new URL(window.location)
+ url.searchParams.set("tab", key)
+ history.replaceState({}, "", url)
+ }
+ }
+}
app/views/blobs/show.html.erb
+1
-1
index 07946e3..da0a3d1 100644
--- a/app/views/blobs/show.html.erb
+++ b/app/views/blobs/show.html.erb
@@ -3,7 +3,7 @@
<div class="border-b border-surface-600 bg-surface-800">
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
<div class="flex items-center gap-2 text-sm mb-4">
- <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
<span class="text-gray-500">/</span>
<%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
</div>
app/views/commits/index.html.erb
+1
-1
index ba18a7f..19e3e60 100644
--- a/app/views/commits/index.html.erb
+++ b/app/views/commits/index.html.erb
@@ -3,7 +3,7 @@
<div class="border-b border-surface-600 bg-surface-800">
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
<div class="flex items-center gap-2 text-sm mb-4">
- <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
<span class="text-gray-500">/</span>
<%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
</div>
app/views/commits/show.html.erb
+1
-1
index 49e7ce3..a3a30dc 100644
--- a/app/views/commits/show.html.erb
+++ b/app/views/commits/show.html.erb
@@ -3,7 +3,7 @@
<div class="border-b border-surface-600 bg-surface-800">
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-4">
<div class="flex items-center gap-2 text-sm mb-3">
- <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
<span class="text-gray-500">/</span>
<%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
</div>
app/views/layouts/application.html.erb
+1
-3
index b76324e..aa5f474 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -30,12 +30,10 @@
<%= yield %>
</main>
- <%= render "shared/dev_panel" if Rails.env.development? %>
-
<footer class="border-t border-surface-600 mt-16">
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex items-center justify-between text-xs text-gray-500">
<span>siGit</span>
- <span>Made with <a href="https://rubyonrails.org" class="hover:text-gray-300">Rails 8</a></span>
+ <%= render "shared/dev_panel" if Rails.env.development? %>
</div>
</footer>
</body>
app/views/pages/home.html.erb
-1
index f244352..7924bd5 100644
--- a/app/views/pages/home.html.erb
+++ b/app/views/pages/home.html.erb
@@ -5,7 +5,6 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28">
<div class="max-w-3xl">
<div class="mb-8 inline-flex items-center gap-3 rounded-full border border-surface-500 bg-surface-700 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.18em] text-gray-400">
- <img src="/icon.png" alt="siGit" class="h-5 w-auto shrink-0">
<span>Quiet Git hosting</span>
</div>
app/views/repositories/show.html.erb
+1
-1
index b6d906f..18fd2b5 100644
--- a/app/views/repositories/show.html.erb
+++ b/app/views/repositories/show.html.erb
@@ -7,7 +7,7 @@
<svg class="w-4 h-4 text-gray-500" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
</svg>
- <%= link_to @owner.username, user_profile_path(@owner.username),
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
class: "text-brand-500 hover:underline font-medium" %>
<span class="text-gray-500">/</span>
<%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/repositories/tree.html.erb
+1
-1
index 8be3d47..b34aa63 100644
--- a/app/views/repositories/tree.html.erb
+++ b/app/views/repositories/tree.html.erb
@@ -4,7 +4,7 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
<div class="flex items-center gap-2 text-sm mb-3">
- <%= link_to @owner.username, user_profile_path(@owner.username),
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
class: "text-brand-500 hover:underline font-medium" %>
<span class="text-gray-500">/</span>
<%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/shared/_dev_panel.html.erb
+9
-8
index e501939..dabfe5b 100644
--- a/app/views/shared/_dev_panel.html.erb
+++ b/app/views/shared/_dev_panel.html.erb
@@ -1,15 +1,16 @@
-<div data-controller="dev-panel"
- class="fixed bottom-4 right-4 z-[9999] flex flex-col items-end gap-2">
+<div data-controller="dev-panel" class="relative flex items-center">
- <%# The dropdown — Stimulus renders content into this target %>
- <div data-dev-panel-target="panel" class="hidden"></div>
+ <%# The dropdown — opens above the footer, aligned to the right %>
+ <div data-dev-panel-target="panel"
+ class="hidden absolute bottom-full right-0 mb-2 z-[9999]"></div>
- <%# Toggle button — terminal icon %>
+ <%# Toggle button — bug icon, amber for visibility %>
<button data-action="click->dev-panel#toggle"
aria-label="Toggle dev panel"
- class="flex h-8 w-8 items-center justify-center rounded-full border border-surface-500 bg-surface-700 text-gray-400 shadow-lg transition-colors hover:border-brand-500 hover:text-brand-500">
- <svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25V2.75A1.75 1.75 0 0 0 14.25 1ZM2.5 4.75a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H3.25a.75.75 0 0 1-.75-.75Zm1.28 2.97a.75.75 0 1 0-1.06 1.06l1.72 1.72-1.72 1.72a.75.75 0 1 0 1.06 1.06l2.25-2.25a.75.75 0 0 0 0-1.06L3.78 7.72ZM7.25 10a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5Z" clip-rule="evenodd"/>
+ class="flex h-6 w-6 items-center justify-center rounded border border-amber-500/40 bg-surface-700 text-amber-400 transition-colors hover:border-amber-400 hover:text-amber-300">
+ <svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
+ <path d="M4.085.82a.75.75 0 0 1 1.06.03l.525.55a5.496 5.496 0 0 1 4.66 0l.525-.55a.75.75 0 1 1 1.09 1.03l-.429.45c.41.28.775.614 1.083.993l.612-.42a.75.75 0 1 1 .836 1.243l-.88.605a5.43 5.43 0 0 1 .285 1.249H14.5a.75.75 0 0 1 0 1.5h-.985a5.495 5.495 0 0 1-.25 1.19l.833.555a.75.75 0 1 1-.832 1.248l-.99-.658A5.5 5.5 0 0 1 8 11.5a5.5 5.5 0 0 1-4.276-2.045l-.989.658a.75.75 0 0 1-.832-1.248l.833-.556a5.495 5.495 0 0 1-.25-1.189H1.5a.75.75 0 0 1 0-1.5h.993a5.43 5.43 0 0 1 .285-1.25l-.88-.604A.75.75 0 1 1 2.734 2.51l.612.42A5.453 5.453 0 0 1 4.43 1.938L4 1.49a.75.75 0 0 1 .085-.67ZM8 4a4 4 0 1 0 0 8A4 4 0 0 0 8 4ZM6.5 7.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm4.5 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
</svg>
</button>
+
</div>
app/views/users/show.html.erb
+180
-37
index de5063b..c0069e7 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -1,16 +1,21 @@
<% content_for :title, @profile_user.display_name_or_username %>
-<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10">
+<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10"
+ data-controller="tabs"
+ data-tabs-active-value="<%= params[:tab] || 'feed' %>">
+
<div class="grid grid-cols-1 lg:grid-cols-4 gap-10">
+ <%# ── Sidebar ── %>
<aside class="lg:col-span-1">
<img src="<%= @profile_user.avatar_url_or_default %>"
alt="<%= @profile_user.display_name_or_username %>"
class="w-full max-w-[200px] rounded-full border border-surface-600 mb-4">
<h1 class="text-xl font-semibold text-gray-100"><%= @profile_user.display_name_or_username %></h1>
- <p class="text-gray-400 text-sm"><%= @profile_user.username %></p>
+ <p class="text-gray-400 text-sm mb-4"><%= @profile_user.username %></p>
+
<% if @profile_user.email.present? %>
- <div class="flex items-center gap-2 mt-3 text-sm text-gray-400">
+ <div class="flex items-center gap-2 text-sm text-gray-400">
<svg class="w-4 h-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.251c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V5.809L8.38 9.397a.75.75 0 0 1-.76 0L1.5 5.809v6.442Zm13-8.181v-.32a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25v.32L8 7.88Z"/>
</svg>
@@ -19,48 +24,186 @@
<% end %>
</aside>
+ <%# ── Main ── %>
<main class="lg:col-span-3">
- <div class="flex items-center justify-between mb-4">
- <h2 class="text-base font-semibold text-gray-100">
+
+ <%# Tab navigation %>
+ <div class="flex items-center border-b border-surface-600 mb-6 gap-1">
+ <button data-tabs-target="tab"
+ data-tabs-key="feed"
+ data-action="click->tabs#switch"
+ class="tab-item active">
+ <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3 6.25a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 3 6.25ZM3.75 9a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5h-5Z"/>
+ </svg>
+ Feed
+ </button>
+ <button data-tabs-target="tab"
+ data-tabs-key="repositories"
+ data-action="click->tabs#switch"
+ class="tab-item">
+ <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
+ </svg>
Repositories
- <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
- </h2>
- <% if signed_in? && current_user == @profile_user %>
- <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
- <% end %>
+ <span class="badge-gray ml-1.5 align-middle"><%= @repositories.count %></span>
+ </button>
</div>
- <% if @repositories.any? %>
- <div class="space-y-3">
- <% @repositories.each do |repo| %>
- <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
- <div class="flex items-start justify-between gap-4">
- <div class="min-w-0 flex-1">
- <div class="flex items-center gap-2 mb-1">
- <%= link_to repo.name,
- repository_path(@profile_user.username, repo.name),
- class: "font-semibold text-brand-500 hover:underline" %>
- <% if repo.is_private %>
- <span class="badge-gray">Private</span>
+ <%# ── Feed panel ── %>
+ <div data-tabs-target="panel" data-tabs-key="feed">
+ <ol class="relative border-l border-surface-600 ml-3 space-y-0">
+
+ <%# Item: push commits %>
+ <li class="mb-8 ml-6">
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
+ <svg class="w-3.5 h-3.5 text-brand-500" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M1.643 3.143 .427 1.927A.25.25 0 0 0 0 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 0 0 .177-.427L2.715 4.215a6.5 6.5 0 1 1-1.18 4.458.75.75 0 1 0-1.493.154 8.001 8.001 0 1 0 1.6-5.684ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z"/>
+ </svg>
+ </span>
+ <div class="card px-4 py-3">
+ <p class="text-xs text-gray-500 mb-2">2 hours ago</p>
+ <p class="text-sm mb-3">
+ <span class="text-gray-400">Pushed </span>
+ <span class="font-semibold text-gray-200">3 commits</span>
+ <span class="text-gray-400"> to </span>
+ <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
+ <span class="text-gray-400"> on </span>
+ <code class="bg-surface-600 px-1.5 py-0.5 rounded text-xs text-gray-200">main</code>
+ </p>
+ <ul class="space-y-1.5 border-t border-surface-600 pt-3">
+ <li class="flex items-start gap-2 text-xs text-gray-400">
+ <%= link_to "a3f9d12", repository_commit_path(@profile_user.username, "sigit-si", "a3f9d12"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
+ <span>Fix avatar fallback when gravatar URL is empty</span>
+ </li>
+ <li class="flex items-start gap-2 text-xs text-gray-400">
+ <%= link_to "b1e2c34", repository_commit_path(@profile_user.username, "sigit-si", "b1e2c34"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
+ <span>Add dev panel to footer, remove Rails 8 credit</span>
+ </li>
+ <li class="flex items-start gap-2 text-xs text-gray-400">
+ <%= link_to "8d04f71", repository_commit_path(@profile_user.username, "sigit-si", "8d04f71"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
+ <span>Scope private repos to authenticated owner only</span>
+ </li>
+ </ul>
+ </div>
+ </li>
+
+ <%# Item: opened issue %>
+ <li class="mb-8 ml-6">
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
+ <svg class="w-3.5 h-3.5 text-emerald-400" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/>
+ </svg>
+ </span>
+ <div class="card px-4 py-3">
+ <p class="text-xs text-gray-500 mb-2">yesterday</p>
+ <p class="text-sm">
+ <span class="text-gray-400">Opened issue </span>
+ <span class="text-brand-500 font-medium">#14 Add SSH key management</span>
+ <span class="text-gray-400"> in </span>
+ <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
+ </p>
+ </div>
+ </li>
+
+ <%# Item: merged PR %>
+ <li class="mb-8 ml-6">
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
+ <svg class="w-3.5 h-3.5 text-purple-400" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"/>
+ </svg>
+ </span>
+ <div class="card px-4 py-3">
+ <p class="text-xs text-gray-500 mb-2">3 days ago</p>
+ <p class="text-sm">
+ <span class="text-gray-400">Merged pull request </span>
+ <span class="text-brand-500 font-medium">#11 Improve repository listing performance</span>
+ <span class="text-gray-400"> in </span>
+ <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
+ </p>
+ </div>
+ </li>
+
+ <%# Item: created repo %>
+ <li class="mb-8 ml-6">
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
+ <svg class="w-3.5 h-3.5 text-gray-400" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
+ </svg>
+ </span>
+ <div class="card px-4 py-3">
+ <p class="text-xs text-gray-500 mb-2">1 week ago</p>
+ <p class="text-sm">
+ <span class="text-gray-400">Created repository </span>
+ <%= link_to "@#{@profile_user.username}/nord", repository_path(@profile_user.username, "nord"), class: "text-brand-500 hover:underline font-medium" %>
+ </p>
+ </div>
+ </li>
+
+ <%# Item: starred repo %>
+ <li class="ml-6">
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
+ <svg class="w-3.5 h-3.5 text-amber-400" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>
+ </svg>
+ </span>
+ <div class="card px-4 py-3">
+ <p class="text-xs text-gray-500 mb-2">2 weeks ago</p>
+ <p class="text-sm">
+ <span class="text-gray-400">Starred </span>
+ <%= link_to "bartowski/Qwen2.5-3B-Instruct-GGUF", "https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF", class: "text-brand-500 hover:underline font-medium", target: "_blank", rel: "noopener noreferrer" %>
+ </p>
+ </div>
+ </li>
+
+ </ol>
+ </div>
+
+ <%# ── Repositories panel ── %>
+ <div data-tabs-target="panel" data-tabs-key="repositories" class="hidden">
+ <div class="flex items-center justify-between mb-4">
+ <h2 class="text-base font-semibold text-gray-100">
+ Repositories
+ <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
+ </h2>
+ <% if signed_in? && current_user == @profile_user %>
+ <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
+ <% end %>
+ </div>
+
+ <% if @repositories.any? %>
+ <div class="space-y-3">
+ <% @repositories.each do |repo| %>
+ <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
+ <div class="flex items-start justify-between gap-4">
+ <div class="min-w-0 flex-1">
+ <div class="flex items-center gap-2 mb-1">
+ <%= link_to repo.name,
+ repository_path(@profile_user.username, repo.name),
+ class: "font-semibold text-brand-500 hover:underline" %>
+ <% if repo.is_private %>
+ <span class="badge-gray">Private</span>
+ <% end %>
+ </div>
+ <% if repo.description.present? %>
+ <p class="text-sm text-gray-400"><%= repo.description %></p>
<% end %>
</div>
- <% if repo.description.present? %>
- <p class="text-sm text-gray-400"><%= repo.description %></p>
- <% end %>
- </div>
- <div class="text-xs text-gray-500 shrink-0">
- <%= time_ago_in_words(repo.updated_at) %> ago
+ <div class="text-xs text-gray-500 shrink-0">
+ <%= time_ago_in_words(repo.updated_at) %> ago
+ </div>
</div>
</div>
- </div>
- <% end %>
- </div>
- <% else %>
- <div class="card px-6 py-12 text-center">
- <p class="text-sm text-gray-500">No public repositories.</p>
- </div>
- <% end %>
- </main>
+ <% end %>
+ </div>
+ <% else %>
+ <div class="card px-6 py-12 text-center">
+ <p class="text-sm text-gray-500">No public repositories.</p>
+ </div>
+ <% end %>
+ </div>
+ </main>
</div>
</div>
config/environments/development.rb
+2
index 75243c3..ee1310c 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -33,6 +33,8 @@ Rails.application.configure do
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
+ config.action_mailer.delivery_method = :letter_opener
+ config.action_mailer.perform_deliveries = true
# Make template changes take effect immediately.
config.action_mailer.perform_caching = false
config/routes.rb
-11
index 36dfbc5..d0d43ed 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -27,21 +27,10 @@ Rails.application.routes.draw do
patch "/settings", to: "users#update_settings"
# User profile (must come before repository routes)
- get "/@:username", to: "users#show", as: :at_user_profile,
- constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }
get "/:username", to: "users#show", as: :user_profile,
constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }
# Repository routes (in order of specificity)
- get "/@:username/:repository/commits/:branch", to: "commits#index", as: :at_repository_commits,
- constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ }
- get "/@:username/:repository/commit/:sha", to: "commits#show", as: :at_repository_commit
- get "/@:username/:repository/blob/:branch/*path", to: "blobs#show", as: :at_repository_blob
- get "/@:username/:repository/raw/:branch/*path", to: "blobs#raw", as: :at_repository_blob_raw
- get "/@:username/:repository/tree/:branch/*path", to: "repositories#tree", as: :at_repository_tree
- get "/@:username/:repository/tree/:branch", to: "repositories#tree", as: :at_repository_branch
- get "/@:username/:repository", to: "repositories#show", as: :at_repository
-
get "/:username/:repository/commits/:branch", to: "commits#index", as: :repository_commits,
constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ }
get "/:username/:repository/commit/:sha", to: "commits#show", as: :repository_commit
lib/tasks/dev.rake
+482
new file mode 100644
index 0000000..e891cf8
--- /dev/null
+++ b/lib/tasks/dev.rake
@@ -0,0 +1,482 @@
+# lib/tasks/dev.rake
+# Usage:
+# bin/rails dev:seed_repo
+# SIGIT_USER=alice REPO=hello-world bin/rails dev:seed_repo
+
+namespace :dev do
+ desc "Create an example bare git repo on disk and its matching DB record"
+ task seed_repo: :environment do
+ require "fileutils"
+ require "tmpdir"
+
+ # ── Config ────────────────────────────────────────────────────────────────
+ username = ENV.fetch("SIGIT_USER", "sigit")
+ repo_name = ENV.fetch("REPO", "sigit-si")
+ branch = "main"
+ description = "The siGit native app — quiet Git hosting built with Rails 8"
+
+ # ── Find user ─────────────────────────────────────────────────────────────
+ user = User.find_by(username: username)
+ abort " [dev:seed_repo] No user '#{username}' found. Sign in once first." unless user
+
+ # ── Skip if already exists ────────────────────────────────────────────────
+ if user.repositories.exists?(name: repo_name)
+ puts " [dev:seed_repo] '#{username}/#{repo_name}' already exists — skipping."
+ next
+ end
+
+ local_repos = Rails.root.join(".local-repos").to_s
+ bare_path = File.join(local_repos, username, "#{repo_name}.git")
+ puts " [dev:seed_repo] Creating bare repo at #{bare_path} …"
+
+ # ── 1. Create bare repo ───────────────────────────────────────────────────
+ FileUtils.mkdir_p(bare_path)
+ system("git", "init", "--bare", "--initial-branch=#{branch}", bare_path, exception: true)
+
+ # ── 2. Build history in a temp working tree ───────────────────────────────
+ Dir.mktmpdir("sigitsi-seed-") do |tmp|
+ git = ->(*args) { system("git", "-C", tmp, *args, exception: true) }
+ write = ->(rel, content) {
+ abs = File.join(tmp, rel)
+ FileUtils.mkdir_p(File.dirname(abs))
+ File.write(abs, content)
+ }
+
+ git.("init", "-b", branch)
+ git.("config", "user.email", "sigit@sigit.si")
+ git.("config", "user.name", "Sigit")
+ git.("config", "commit.gpgsign", "false")
+ git.("config", "tag.gpgsign", "false")
+ git.("remote", "add", "origin", bare_path)
+
+ # ── Commit 1: scaffold ──────────────────────────────────────────────────
+ write.("README.md", <<~'MD')
+ # siGit
+
+ Quiet, self-hosted Git hosting built with **Ruby on Rails 8**.
+
+ ## Features
+
+ - Push/pull over SSH using your own keys
+ - Browse code, commits, and diffs in the browser
+ - Per-repository privacy (public / private)
+ - Dark-mode UI powered by Tailwind CSS
+ - Stimulus + Turbo for a snappy, no-SPA feel
+
+ ## Quick start
+
+ ```bash
+ git clone git@sigitsi.com:sigit/sigit-si
+ cd sigit-si
+ bin/setup
+ bin/rails server
+ ```
+
+ Open [http://localhost:3000](http://localhost:3000).
+
+ ## Stack
+
+ | Layer | Choice |
+ |------------|---------------------------------|
+ | Backend | Ruby 3.4 · Rails 8 |
+ | Database | PostgreSQL |
+ | Frontend | Tailwind CSS · Stimulus · Turbo |
+ | Git store | Bare repos via shell / Open3 |
+ | Auth | smbCloud OAuth |
+
+ ## Environment variables
+
+ | Variable | Default | Description |
+ |-----------------------|------------------|--------------------------------------|
+ | `SIGITSI_REPOS_PATH` | `./repos` | Directory that holds bare git repos |
+ | `SIGITSI_DATABASE_URL`| — | Postgres connection string |
+ | `SMBCLOUD_CLIENT_ID` | — | OAuth client ID |
+ | `SMBCLOUD_CLIENT_SECRET` | — | OAuth client secret |
+
+ ## License
+
+ MIT
+ MD
+
+ write.(".gitignore", <<~'IGNORE')
+ /.bundle/
+ /.byebug_history
+ /db/*.sqlite3
+ /log/*.log
+ /public/assets
+ /storage/
+ /tmp/
+ /vendor/bundle
+ .env
+ .env.local
+ repos/
+ .local-repos/
+ node_modules/
+ .DS_Store
+ IGNORE
+
+ write.("Gemfile", <<~'RUBY')
+ source "https://rubygems.org"
+ ruby "~> 3.4"
+
+ gem "rails", "~> 8.0"
+ gem "pg", "~> 1.5"
+ gem "puma", ">= 5.0"
+ gem "importmap-rails"
+ gem "turbo-rails"
+ gem "stimulus-rails"
+ gem "tailwindcss-rails"
+ gem "redcarpet"
+ gem "rouge"
+ gem "solid_cache"
+ gem "solid_queue"
+
+ group :development, :test do
+ gem "debug", platforms: %i[mri mswin]
+ gem "brakeman"
+ end
+
+ group :development do
+ gem "web-console"
+ end
+ RUBY
+
+ git.("add", ".")
+ git.("commit", "--date=2025-01-01T09:00:00",
+ "-m", "Initial commit: scaffold Rails 8 app")
+
+ # ── Commit 2: models ────────────────────────────────────────────────────
+ write.("app/models/application_record.rb", <<~'RUBY')
+ class ApplicationRecord < ActiveRecord::Base
+ primary_abstract_class
+ end
+ RUBY
+
+ write.("app/models/user.rb", <<~'RUBY')
+ class User < ApplicationRecord
+ has_many :repositories, dependent: :destroy
+ has_many :ssh_keys, dependent: :destroy
+
+ validates :username, presence: true,
+ uniqueness: { case_sensitive: false },
+ format: { with: /\\A[a-z0-9_\\-]+\\z/,
+ message: "lowercase letters, numbers, hyphens, underscores only" }
+ validates :email, presence: true, uniqueness: true
+
+ def display_name_or_username
+ display_name.presence || username
+ end
+
+ def avatar_url_or_default
+ return avatar_url if avatar_url.present?
+ digest = Digest::MD5.hexdigest(email.to_s.downcase.strip)
+ "https://www.gravatar.com/avatar/#{digest}?d=identicon&s=200"
+ end
+ end
+ RUBY
+
+ write.("app/models/repository.rb", <<~'RUBY')
+ class Repository < ApplicationRecord
+ belongs_to :user
+
+ validates :name, presence: true,
+ format: {
+ with: /\\A(?!\\.)[a-zA-Z0-9_.\\-]+(?<!\\.)\\'\\z/,
+ message: "letters, numbers, hyphens, underscores, dots only"
+ },
+ uniqueness: { scope: :user_id, case_sensitive: false }
+ validates :default_branch, presence: true
+
+ def full_name = "#{user.username}/#{name}"
+ def initialized? = disk_path.present? &&
+ Dir.exist?(disk_path) &&
+ File.exist?(File.join(disk_path, "HEAD"))
+ end
+ RUBY
+
+ write.("app/models/ssh_key.rb", <<~'RUBY')
+ class SshKey < ApplicationRecord
+ belongs_to :user
+
+ validates :title, presence: true
+ validates :key_text, presence: true
+ validates :fingerprint, presence: true, uniqueness: true
+
+ before_validation :compute_fingerprint
+
+ private
+
+ def compute_fingerprint
+ return if key_text.blank?
+ self.fingerprint = `echo #{Shellwords.escape(key_text.strip)} | ssh-keygen -lf - 2>/dev/null`
+ .split.second
+ rescue StandardError
+ nil
+ end
+ end
+ RUBY
+
+ git.("add", ".")
+ git.("commit", "--date=2025-01-08T11:30:00",
+ "-m", "feat(models): User, Repository, SshKey with validations")
+
+ # ── Commit 3: git service ───────────────────────────────────────────────
+ write.("app/services/git_repository_service.rb", <<~'RUBY')
+ require "open3"
+ require "fileutils"
+
+ class GitRepositoryService
+ REPOS_BASE = ENV.fetch("SIGITSI_REPOS_PATH", Rails.root.join(".local-repos").to_s)
+
+ def self.repo_path(username, reponame)
+ File.join(REPOS_BASE, username, "#{reponame}.git")
+ end
+
+ def self.create_bare_repo(username, reponame)
+ path = repo_path(username, reponame)
+ FileUtils.mkdir_p(path)
+ _, _, status = Open3.capture3("git", "init", "--bare", path)
+ raise "git init failed" unless status.success?
+ path
+ end
+
+ def self.list_tree(path, branch, tree_path = "")
+ prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"
+ out, _, status = Open3.capture3(
+ "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}"
+ )
+ return [] unless status.success?
+ out.lines.filter_map do |line|
+ parts = line.split
+ next unless parts.length >= 5
+ _mode, type, _sha, _size, *name_parts = parts
+ name = name_parts.join(" ").strip
+ { type: type, name: name, path: prefix + name }
+ end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }
+ end
+
+ def self.commits(path, branch, limit: 20, offset: 0)
+ fmt = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
+ out, _, status = Open3.capture3(
+ "git", "--git-dir", path, "log",
+ "--format=#{fmt}", "--date=iso-strict",
+ "--skip=#{offset}", "-n", limit.to_s, branch
+ )
+ return [] unless status.success?
+ out.lines.filter_map do |line|
+ parts = line.chomp.split("\\x00")
+ next unless parts.length == 7
+ { sha: parts[0], short_sha: parts[1], subject: parts[2],
+ author_name: parts[3], author_email: parts[4],
+ authored_date: Time.parse(parts[5]), committer_name: parts[6] }
+ end
+ end
+
+ def self.commit_count(path, branch)
+ out, _, s = Open3.capture3("git", "--git-dir", path, "rev-list", "--count", branch)
+ s.success? ? out.strip.to_i : 0
+ end
+
+ def self.branch_exists?(path, branch)
+ _, _, s = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch)
+ s.success?
+ end
+
+ def self.file_content(path, branch, file_path)
+ out, _, s = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
+ s.success? ? out : nil
+ end
+
+ def self.readme_content(path, branch)
+ %w[README.md README.txt README readme.md].each do |name|
+ content = file_content(path, branch, name)
+ return { content: content, filename: name } if content
+ end
+ nil
+ end
+ end
+ RUBY
+
+ git.("add", ".")
+ git.("commit", "--date=2025-01-12T14:15:00",
+ "-m", "feat(services): GitRepositoryService — bare-repo ops via Open3")
+
+ # ── Commit 4: routes + controllers ─────────────────────────────────────
+ write.("config/routes.rb", <<~'RUBY')
+ Rails.application.routes.draw do
+ root "pages#home"
+
+ get "signin", to: "sessions#new", as: :signin
+ post "signin", to: "sessions#create"
+ delete "signout", to: "sessions#destroy", as: :signout
+
+ get "settings", to: "users#settings", as: :settings
+ patch "settings", to: "users#update_settings"
+
+ get "@:username", to: "users#show", as: :user_profile
+
+ scope "@:username" do
+ resources :repositories, path: "", param: :repository, only: %i[new create show] do
+ member do
+ get "tree/*path", to: "repositories#tree", as: :tree
+ get "blob/*path", to: "blobs#show", as: :blob
+ get "raw/*path", to: "blobs#raw", as: :blob_raw
+ get "commits/:branch", to: "commits#index", as: :commits
+ get "commit/:sha", to: "commits#show", as: :commit
+ end
+ end
+ end
+ end
+ RUBY
+
+ write.("app/controllers/application_controller.rb", <<~'RUBY')
+ class ApplicationController < ActionController::Base
+ helper_method :current_user, :signed_in?
+
+ private
+
+ def current_user
+ @current_user ||= User.find_by(id: session[:user_id])
+ end
+
+ def signed_in?
+ current_user.present?
+ end
+
+ def require_sign_in!
+ redirect_to signin_path, alert: "Please sign in." unless signed_in?
+ end
+ end
+ RUBY
+
+ write.("app/controllers/repositories_controller.rb", <<~'RUBY')
+ class RepositoriesController < ApplicationController
+ before_action :require_sign_in!, only: %i[new create]
+ before_action :load_owner
+ before_action :load_repository, except: %i[new create]
+ before_action :ensure_can_read!, except: %i[new create]
+
+ def new = @repository = Repository.new
+
+ def create
+ @repository = current_user.repositories.new(repository_params)
+ @repository.disk_path = GitRepositoryService.repo_path(
+ current_user.username, @repository.name
+ )
+ if @repository.save
+ GitRepositoryService.create_bare_repo(current_user.username, @repository.name)
+ redirect_to repository_path(current_user.username, @repository.name),
+ notice: "Repository created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def show
+ @branch = params[:branch] || @repository.default_branch
+ return (@empty = true) unless @repository.initialized? &&
+ GitRepositoryService.branch_exists?(@repository.disk_path, @branch)
+
+ @tree = GitRepositoryService.list_tree(@repository.disk_path, @branch)
+ @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch)
+ @recent_commits = GitRepositoryService.commits(@repository.disk_path, @branch, limit: 3)
+ readme = GitRepositoryService.readme_content(@repository.disk_path, @branch)
+ @readme_html = render_markdown(readme[:content]) if readme
+ @readme_filename = readme[:filename] if readme
+ end
+
+ private
+
+ def load_owner = @owner = User.find_by!(username: params[:username])
+ def load_repository = @repository = @owner.repositories.find_by!(name: params[:repository])
+ def ensure_can_read!
+ return unless @repository.is_private
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false unless signed_in? && current_user == @owner
+ end
+ def repository_params = params.require(:repository).permit(:name, :description, :default_branch, :is_private)
+ end
+ RUBY
+
+ git.("add", ".")
+ git.("commit", "--date=2025-01-18T10:00:00",
+ "-m", "feat(routes): scope @:username routes, wire controllers")
+
+ # ── Commit 5: Tailwind theme + stylesheet ───────────────────────────────
+ write.("config/tailwind.config.js", <<~'JS')
+ const defaultTheme = require("tailwindcss/defaultTheme")
+
+ module.exports = {
+ content: [
+ "./app/views/**/*.html.erb",
+ "./app/helpers/**/*.rb",
+ "./app/javascript/**/*.js",
+ ],
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ["Inter", ...defaultTheme.fontFamily.sans],
+ mono: ["JetBrains Mono", ...defaultTheme.fontFamily.mono],
+ },
+ colors: {
+ brand: { 400: "#f97316", 500: "#ea580c" },
+ surface: { 600: "#2d3139", 700: "#22262d", 800: "#1a1d21", 900: "#13151a" },
+ },
+ },
+ },
+ plugins: [],
+ }
+ JS
+
+ write.("app/assets/stylesheets/application.tailwind.css", <<~'CSS')
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
+
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+
+ @layer base {
+ body { @apply bg-surface-900 text-gray-300 text-sm leading-relaxed; }
+ a { @apply text-brand-500; }
+ }
+
+ @layer components {
+ .btn-primary { @apply inline-flex items-center gap-2 px-4 py-2 bg-brand-500 text-white text-sm font-medium rounded hover:bg-brand-400 transition-colors; }
+ .btn-secondary { @apply inline-flex items-center gap-2 px-4 py-2 bg-surface-700 text-gray-300 text-sm font-medium rounded border border-surface-500 hover:bg-surface-600 transition-colors; }
+ .card { @apply bg-surface-700 border border-surface-500 rounded-sm; }
+ .badge-gray { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-600 text-gray-300; }
+ .tab-item { @apply px-4 py-2.5 text-sm font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition-all; }
+ .tab-item.active { @apply text-gray-100 border-brand-500; }
+ }
+ CSS
+
+ git.("add", ".")
+ git.("commit", "--date=2025-01-22T16:45:00",
+ "-m", "feat(ui): Tailwind theme — brand orange, surface grays, component layer")
+
+ # ── Commit 6: fix avatar fallback ───────────────────────────────────────
+ write.("app/models/user.rb", File.read(File.join(tmp, "app/models/user.rb"))
+ .sub("?d=identicon", "?d=identicon&size=200"))
+
+ git.("add", ".")
+ git.("commit", "--date=2025-01-25T09:10:00",
+ "-m", "fix(user): correct Gravatar size param (size= not s=)")
+
+ # ── Push all commits to bare repo ───────────────────────────────────────
+ git.("push", "origin", branch)
+ puts " [dev:seed_repo] Pushed #{`git -C #{tmp} rev-list --count #{branch}`.strip} commits to bare repo."
+ end
+
+ # ── 3. Create the DB record ───────────────────────────────────────────────
+ repo = user.repositories.create!(
+ name: repo_name,
+ description: description,
+ disk_path: bare_path,
+ default_branch: branch,
+ is_private: false
+ )
+
+ puts " [dev:seed_repo] Done! Repository record id=#{repo.id}"
+ puts " [dev:seed_repo] Browse at http://localhost:3000/@#{username}/#{repo_name}"
+ end
+end