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
.agents/skills/design-system/SKILL.md
.agents/skills/local-environment/SKILL.md
.gitignore
+4
@@ -36,3 +36,7 @@
36
37
# Ignore node_modules.
38
/node_modules/
39
+
40
+# Ignore local git repos for development environment
41
+.local-repos/*
42
+!.local-repos/.keep
.local-repos/.keep
Gemfile
+10
-2
@@ -15,12 +15,12 @@ gem "turbo-rails"
15
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
16
gem "stimulus-rails"
17
# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails]
18
-gem "tailwindcss-rails"
18
+gem "tailwindcss-rails", "~> 3.3.1"
19
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
20
gem "jbuilder"
21
22
# smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout
23
-gem "smbcloud-auth", path: "../smbcloud-cli/sdk/gems/auth"
23
+gem "smbcloud-auth", "~> 0.3.34"
24
25
# Markdown rendering for README files
26
gem "redcarpet", "~> 3.6"
@@ -51,10 +51,18 @@ gem "thruster", require: false
51
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
52
gem "image_processing", "~> 1.2"
53
54
+group :development do
55
+ # Open sent emails in the browser instead of sending them
56
+ gem "letter_opener"
57
+end
58
+
59
group :development, :test do
60
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
61
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
62
63
+ # Load .env file into ENV on boot
64
+ gem "dotenv-rails"
65
+
66
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
67
gem "bundler-audit", require: false
68
Gemfile.lock
+21
-9
@@ -1,10 +1,3 @@
1
-PATH
2
- remote: ../smbcloud-cli/sdk/gems/auth
3
- specs:
4
- smbcloud-auth (0.3.33)
5
- json
6
- rb_sys (~> 0.9.91)
7
-
1
GEM
2
remote: https://rubygems.org/
3
specs:
@@ -82,6 +75,8 @@ GEM
75
securerandom (>= 0.3)
76
tzinfo (~> 2.0, >= 2.0.5)
77
uri (>= 0.13.1)
78
+ addressable (2.9.0)
79
+ public_suffix (>= 2.0.2, < 8.0)
80
ast (2.4.3)
81
base64 (0.3.0)
82
bcrypt_pbkdf (1.1.2)
@@ -95,6 +90,8 @@ GEM
90
bundler-audit (0.9.3)
91
bundler (>= 1.2.0)
92
thor (~> 1.0)
93
+ childprocess (5.1.0)
94
+ logger (~> 1.5)
95
concurrent-ruby (1.3.6)
96
connection_pool (3.0.2)
97
crass (1.0.6)
@@ -103,6 +100,9 @@ GEM
100
irb (~> 1.10)
101
reline (>= 0.3.8)
102
dotenv (3.2.0)
103
+ dotenv-rails (3.2.0)
104
+ dotenv (= 3.2.0)
105
+ railties (>= 6.1)
106
drb (2.2.3)
107
ed25519 (1.4.0)
108
erb (6.0.2)
@@ -146,6 +146,12 @@ GEM
146
thor (~> 1.3)
147
zeitwerk (>= 2.6.18, < 3.0)
148
language_server-protocol (3.17.0.5)
149
+ launchy (3.1.1)
150
+ addressable (~> 2.8)
151
+ childprocess (~> 5.0)
152
+ logger (~> 1.6)
153
+ letter_opener (1.10.0)
154
+ launchy (>= 2.2, < 4)
155
lint_roller (1.1.0)
156
logger (1.7.0)
157
loofah (2.25.1)
@@ -199,6 +205,7 @@ GEM
205
psych (5.3.1)
206
date
207
stringio
208
+ public_suffix (7.0.5)
209
puma (8.0.0)
210
nio4r (~> 2.0)
211
raabro (1.4.0)
@@ -289,6 +296,9 @@ GEM
296
ffi (~> 1.12)
297
logger
298
securerandom (0.4.1)
299
+ smbcloud-auth (0.3.35)
300
+ json
301
+ rb_sys (~> 0.9.91)
302
solid_cable (3.0.12)
303
actioncable (>= 7.2)
304
activejob (>= 7.2)
@@ -351,10 +361,12 @@ DEPENDENCIES
361
brakeman
362
bundler-audit
363
debug
364
+ dotenv-rails
365
image_processing (~> 1.2)
366
importmap-rails
367
jbuilder
368
kamal
369
+ letter_opener
370
pg (~> 1.1)
371
propshaft
372
puma (>= 5.0)
@@ -362,12 +374,12 @@ DEPENDENCIES
374
redcarpet (~> 3.6)
375
rouge (~> 4.5)
376
rubocop-rails-omakase
365
- smbcloud-auth!
377
+ smbcloud-auth (~> 0.3.34)
378
solid_cable
379
solid_cache
380
solid_queue
381
stimulus-rails
370
- tailwindcss-rails
382
+ tailwindcss-rails (~> 3.3.1)
383
thruster
384
turbo-rails
385
tzinfo-data
app/javascript/controllers/tabs_controller.js
new
+37
@@ -0,0 +1,37 @@
1
+import { Controller } from "@hotwired/stimulus"
2
+
3
+export default class extends Controller {
4
+ static targets = ["tab", "panel"]
5
+ static values = { active: String }
6
+
7
+ connect() {
8
+ const initial = this.activeValue || this.tabTargets[0]?.dataset.tabsKey
9
+ if (initial) this.#activate(initial, false)
10
+ }
11
+
12
+ switch(event) {
13
+ event.preventDefault()
14
+ const key = event.currentTarget.dataset.tabsKey
15
+ if (key) this.#activate(key, true)
16
+ }
17
+
18
+ #activate(key, push) {
19
+ this.activeValue = key
20
+
21
+ this.tabTargets.forEach(tab => {
22
+ const active = tab.dataset.tabsKey === key
23
+ tab.classList.toggle("active", active)
24
+ })
25
+
26
+ this.panelTargets.forEach(panel => {
27
+ const active = panel.dataset.tabsKey === key
28
+ panel.classList.toggle("hidden", !active)
29
+ })
30
+
31
+ if (push && history.pushState) {
32
+ const url = new URL(window.location)
33
+ url.searchParams.set("tab", key)
34
+ history.replaceState({}, "", url)
35
+ }
36
+ }
37
+}
app/views/blobs/show.html.erb
+1
-1
@@ -3,7 +3,7 @@
3
<div class="border-b border-surface-600 bg-surface-800">
4
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
5
<div class="flex items-center gap-2 text-sm mb-4">
6
- <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
6
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
7
<span class="text-gray-500">/</span>
8
<%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
9
</div>
app/views/commits/index.html.erb
+1
-1
@@ -3,7 +3,7 @@
3
<div class="border-b border-surface-600 bg-surface-800">
4
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
5
<div class="flex items-center gap-2 text-sm mb-4">
6
- <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
6
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
7
<span class="text-gray-500">/</span>
8
<%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
9
</div>
app/views/commits/show.html.erb
+1
-1
@@ -3,7 +3,7 @@
3
<div class="border-b border-surface-600 bg-surface-800">
4
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-4">
5
<div class="flex items-center gap-2 text-sm mb-3">
6
- <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
6
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
7
<span class="text-gray-500">/</span>
8
<%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
9
</div>
app/views/layouts/application.html.erb
+1
-3
@@ -30,12 +30,10 @@
30
<%= yield %>
31
</main>
32
33
- <%= render "shared/dev_panel" if Rails.env.development? %>
34
-
33
<footer class="border-t border-surface-600 mt-16">
34
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex items-center justify-between text-xs text-gray-500">
35
<span>siGit</span>
38
- <span>Made with <a href="https://rubyonrails.org" class="hover:text-gray-300">Rails 8</a></span>
36
+ <%= render "shared/dev_panel" if Rails.env.development? %>
37
</div>
38
</footer>
39
</body>
app/views/pages/home.html.erb
-1
@@ -5,7 +5,6 @@
5
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28">
6
<div class="max-w-3xl">
7
<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">
8
- <img src="/icon.png" alt="siGit" class="h-5 w-auto shrink-0">
8
<span>Quiet Git hosting</span>
9
</div>
10
app/views/repositories/show.html.erb
+1
-1
@@ -7,7 +7,7 @@
7
<svg class="w-4 h-4 text-gray-500" viewBox="0 0 16 16" fill="currentColor">
8
<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"/>
9
</svg>
10
- <%= link_to @owner.username, user_profile_path(@owner.username),
10
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
11
class: "text-brand-500 hover:underline font-medium" %>
12
<span class="text-gray-500">/</span>
13
<%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/repositories/tree.html.erb
+1
-1
@@ -4,7 +4,7 @@
4
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
5
6
<div class="flex items-center gap-2 text-sm mb-3">
7
- <%= link_to @owner.username, user_profile_path(@owner.username),
7
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
8
class: "text-brand-500 hover:underline font-medium" %>
9
<span class="text-gray-500">/</span>
10
<%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/shared/_dev_panel.html.erb
+9
-8
@@ -1,15 +1,16 @@
1
-<div data-controller="dev-panel"
2
- class="fixed bottom-4 right-4 z-[9999] flex flex-col items-end gap-2">
1
+<div data-controller="dev-panel" class="relative flex items-center">
2
4
- <%# The dropdown — Stimulus renders content into this target %>
5
- <div data-dev-panel-target="panel" class="hidden"></div>
3
+ <%# The dropdown — opens above the footer, aligned to the right %>
4
+ <div data-dev-panel-target="panel"
5
+ class="hidden absolute bottom-full right-0 mb-2 z-[9999]"></div>
6
7
- <%# Toggle button — terminal icon %>
7
+ <%# Toggle button — bug icon, amber for visibility %>
8
<button data-action="click->dev-panel#toggle"
9
aria-label="Toggle dev panel"
10
- 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">
11
- <svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
12
- <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"/>
10
+ 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">
11
+ <svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
12
+ <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"/>
13
</svg>
14
</button>
15
+
16
</div>
app/views/users/show.html.erb
+180
-37
@@ -1,16 +1,21 @@
1
<% content_for :title, @profile_user.display_name_or_username %>
2
3
-<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10">
3
+<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10"
4
+ data-controller="tabs"
5
+ data-tabs-active-value="<%= params[:tab] || 'feed' %>">
6
+
7
<div class="grid grid-cols-1 lg:grid-cols-4 gap-10">
8
9
+ <%# ── Sidebar ── %>
10
<aside class="lg:col-span-1">
11
<img src="<%= @profile_user.avatar_url_or_default %>"
12
alt="<%= @profile_user.display_name_or_username %>"
13
class="w-full max-w-[200px] rounded-full border border-surface-600 mb-4">
14
<h1 class="text-xl font-semibold text-gray-100"><%= @profile_user.display_name_or_username %></h1>
11
- <p class="text-gray-400 text-sm"><%= @profile_user.username %></p>
15
+ <p class="text-gray-400 text-sm mb-4"><%= @profile_user.username %></p>
16
+
17
<% if @profile_user.email.present? %>
13
- <div class="flex items-center gap-2 mt-3 text-sm text-gray-400">
18
+ <div class="flex items-center gap-2 text-sm text-gray-400">
19
<svg class="w-4 h-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
20
<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"/>
21
</svg>
@@ -19,48 +24,186 @@
24
<% end %>
25
</aside>
26
27
+ <%# ── Main ── %>
28
<main class="lg:col-span-3">
23
- <div class="flex items-center justify-between mb-4">
24
- <h2 class="text-base font-semibold text-gray-100">
29
+
30
+ <%# Tab navigation %>
31
+ <div class="flex items-center border-b border-surface-600 mb-6 gap-1">
32
+ <button data-tabs-target="tab"
33
+ data-tabs-key="feed"
34
+ data-action="click->tabs#switch"
35
+ class="tab-item active">
36
+ <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor">
37
+ <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"/>
38
+ </svg>
39
+ Feed
40
+ </button>
41
+ <button data-tabs-target="tab"
42
+ data-tabs-key="repositories"
43
+ data-action="click->tabs#switch"
44
+ class="tab-item">
45
+ <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor">
46
+ <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"/>
47
+ </svg>
48
Repositories
26
- <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
27
- </h2>
28
- <% if signed_in? && current_user == @profile_user %>
29
- <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
30
- <% end %>
49
+ <span class="badge-gray ml-1.5 align-middle"><%= @repositories.count %></span>
50
+ </button>
51
</div>
52
33
- <% if @repositories.any? %>
34
- <div class="space-y-3">
35
- <% @repositories.each do |repo| %>
36
- <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
37
- <div class="flex items-start justify-between gap-4">
38
- <div class="min-w-0 flex-1">
39
- <div class="flex items-center gap-2 mb-1">
40
- <%= link_to repo.name,
41
- repository_path(@profile_user.username, repo.name),
42
- class: "font-semibold text-brand-500 hover:underline" %>
43
- <% if repo.is_private %>
44
- <span class="badge-gray">Private</span>
53
+ <%# ── Feed panel ── %>
54
+ <div data-tabs-target="panel" data-tabs-key="feed">
55
+ <ol class="relative border-l border-surface-600 ml-3 space-y-0">
56
+
57
+ <%# Item: push commits %>
58
+ <li class="mb-8 ml-6">
59
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
60
+ <svg class="w-3.5 h-3.5 text-brand-500" viewBox="0 0 16 16" fill="currentColor">
61
+ <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"/>
62
+ </svg>
63
+ </span>
64
+ <div class="card px-4 py-3">
65
+ <p class="text-xs text-gray-500 mb-2">2 hours ago</p>
66
+ <p class="text-sm mb-3">
67
+ <span class="text-gray-400">Pushed </span>
68
+ <span class="font-semibold text-gray-200">3 commits</span>
69
+ <span class="text-gray-400"> to </span>
70
+ <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
71
+ <span class="text-gray-400"> on </span>
72
+ <code class="bg-surface-600 px-1.5 py-0.5 rounded text-xs text-gray-200">main</code>
73
+ </p>
74
+ <ul class="space-y-1.5 border-t border-surface-600 pt-3">
75
+ <li class="flex items-start gap-2 text-xs text-gray-400">
76
+ <%= link_to "a3f9d12", repository_commit_path(@profile_user.username, "sigit-si", "a3f9d12"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
77
+ <span>Fix avatar fallback when gravatar URL is empty</span>
78
+ </li>
79
+ <li class="flex items-start gap-2 text-xs text-gray-400">
80
+ <%= link_to "b1e2c34", repository_commit_path(@profile_user.username, "sigit-si", "b1e2c34"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
81
+ <span>Add dev panel to footer, remove Rails 8 credit</span>
82
+ </li>
83
+ <li class="flex items-start gap-2 text-xs text-gray-400">
84
+ <%= link_to "8d04f71", repository_commit_path(@profile_user.username, "sigit-si", "8d04f71"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
85
+ <span>Scope private repos to authenticated owner only</span>
86
+ </li>
87
+ </ul>
88
+ </div>
89
+ </li>
90
+
91
+ <%# Item: opened issue %>
92
+ <li class="mb-8 ml-6">
93
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
94
+ <svg class="w-3.5 h-3.5 text-emerald-400" viewBox="0 0 16 16" fill="currentColor">
95
+ <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
96
+ <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"/>
97
+ </svg>
98
+ </span>
99
+ <div class="card px-4 py-3">
100
+ <p class="text-xs text-gray-500 mb-2">yesterday</p>
101
+ <p class="text-sm">
102
+ <span class="text-gray-400">Opened issue </span>
103
+ <span class="text-brand-500 font-medium">#14 Add SSH key management</span>
104
+ <span class="text-gray-400"> in </span>
105
+ <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
106
+ </p>
107
+ </div>
108
+ </li>
109
+
110
+ <%# Item: merged PR %>
111
+ <li class="mb-8 ml-6">
112
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
113
+ <svg class="w-3.5 h-3.5 text-purple-400" viewBox="0 0 16 16" fill="currentColor">
114
+ <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"/>
115
+ </svg>
116
+ </span>
117
+ <div class="card px-4 py-3">
118
+ <p class="text-xs text-gray-500 mb-2">3 days ago</p>
119
+ <p class="text-sm">
120
+ <span class="text-gray-400">Merged pull request </span>
121
+ <span class="text-brand-500 font-medium">#11 Improve repository listing performance</span>
122
+ <span class="text-gray-400"> in </span>
123
+ <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
124
+ </p>
125
+ </div>
126
+ </li>
127
+
128
+ <%# Item: created repo %>
129
+ <li class="mb-8 ml-6">
130
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
131
+ <svg class="w-3.5 h-3.5 text-gray-400" viewBox="0 0 16 16" fill="currentColor">
132
+ <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"/>
133
+ </svg>
134
+ </span>
135
+ <div class="card px-4 py-3">
136
+ <p class="text-xs text-gray-500 mb-2">1 week ago</p>
137
+ <p class="text-sm">
138
+ <span class="text-gray-400">Created repository </span>
139
+ <%= link_to "@#{@profile_user.username}/nord", repository_path(@profile_user.username, "nord"), class: "text-brand-500 hover:underline font-medium" %>
140
+ </p>
141
+ </div>
142
+ </li>
143
+
144
+ <%# Item: starred repo %>
145
+ <li class="ml-6">
146
+ <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
147
+ <svg class="w-3.5 h-3.5 text-amber-400" viewBox="0 0 16 16" fill="currentColor">
148
+ <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"/>
149
+ </svg>
150
+ </span>
151
+ <div class="card px-4 py-3">
152
+ <p class="text-xs text-gray-500 mb-2">2 weeks ago</p>
153
+ <p class="text-sm">
154
+ <span class="text-gray-400">Starred </span>
155
+ <%= 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" %>
156
+ </p>
157
+ </div>
158
+ </li>
159
+
160
+ </ol>
161
+ </div>
162
+
163
+ <%# ── Repositories panel ── %>
164
+ <div data-tabs-target="panel" data-tabs-key="repositories" class="hidden">
165
+ <div class="flex items-center justify-between mb-4">
166
+ <h2 class="text-base font-semibold text-gray-100">
167
+ Repositories
168
+ <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
169
+ </h2>
170
+ <% if signed_in? && current_user == @profile_user %>
171
+ <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
172
+ <% end %>
173
+ </div>
174
+
175
+ <% if @repositories.any? %>
176
+ <div class="space-y-3">
177
+ <% @repositories.each do |repo| %>
178
+ <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
179
+ <div class="flex items-start justify-between gap-4">
180
+ <div class="min-w-0 flex-1">
181
+ <div class="flex items-center gap-2 mb-1">
182
+ <%= link_to repo.name,
183
+ repository_path(@profile_user.username, repo.name),
184
+ class: "font-semibold text-brand-500 hover:underline" %>
185
+ <% if repo.is_private %>
186
+ <span class="badge-gray">Private</span>
187
+ <% end %>
188
+ </div>
189
+ <% if repo.description.present? %>
190
+ <p class="text-sm text-gray-400"><%= repo.description %></p>
191
<% end %>
192
</div>
47
- <% if repo.description.present? %>
48
- <p class="text-sm text-gray-400"><%= repo.description %></p>
49
- <% end %>
50
- </div>
51
- <div class="text-xs text-gray-500 shrink-0">
52
- <%= time_ago_in_words(repo.updated_at) %> ago
193
+ <div class="text-xs text-gray-500 shrink-0">
194
+ <%= time_ago_in_words(repo.updated_at) %> ago
195
+ </div>
196
</div>
197
</div>
55
- </div>
56
- <% end %>
57
- </div>
58
- <% else %>
59
- <div class="card px-6 py-12 text-center">
60
- <p class="text-sm text-gray-500">No public repositories.</p>
61
- </div>
62
- <% end %>
63
- </main>
198
+ <% end %>
199
+ </div>
200
+ <% else %>
201
+ <div class="card px-6 py-12 text-center">
202
+ <p class="text-sm text-gray-500">No public repositories.</p>
203
+ </div>
204
+ <% end %>
205
+ </div>
206
207
+ </main>
208
</div>
209
</div>
config/environments/development.rb
+2
@@ -33,6 +33,8 @@ Rails.application.configure do
33
34
# Don't care if the mailer can't send.
35
config.action_mailer.raise_delivery_errors = false
36
+ config.action_mailer.delivery_method = :letter_opener
37
+ config.action_mailer.perform_deliveries = true
38
39
# Make template changes take effect immediately.
40
config.action_mailer.perform_caching = false
config/routes.rb
-11
@@ -27,21 +27,10 @@ Rails.application.routes.draw do
27
patch "/settings", to: "users#update_settings"
28
29
# User profile (must come before repository routes)
30
- get "/@:username", to: "users#show", as: :at_user_profile,
31
- constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }
30
get "/:username", to: "users#show", as: :user_profile,
31
constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }
32
33
# Repository routes (in order of specificity)
36
- get "/@:username/:repository/commits/:branch", to: "commits#index", as: :at_repository_commits,
37
- constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ }
38
- get "/@:username/:repository/commit/:sha", to: "commits#show", as: :at_repository_commit
39
- get "/@:username/:repository/blob/:branch/*path", to: "blobs#show", as: :at_repository_blob
40
- get "/@:username/:repository/raw/:branch/*path", to: "blobs#raw", as: :at_repository_blob_raw
41
- get "/@:username/:repository/tree/:branch/*path", to: "repositories#tree", as: :at_repository_tree
42
- get "/@:username/:repository/tree/:branch", to: "repositories#tree", as: :at_repository_branch
43
- get "/@:username/:repository", to: "repositories#show", as: :at_repository
44
-
34
get "/:username/:repository/commits/:branch", to: "commits#index", as: :repository_commits,
35
constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ }
36
get "/:username/:repository/commit/:sha", to: "commits#show", as: :repository_commit
lib/tasks/dev.rake
new
+482
@@ -0,0 +1,482 @@
1
+# lib/tasks/dev.rake
2
+# Usage:
3
+# bin/rails dev:seed_repo
4
+# SIGIT_USER=alice REPO=hello-world bin/rails dev:seed_repo
5
+
6
+namespace :dev do
7
+ desc "Create an example bare git repo on disk and its matching DB record"
8
+ task seed_repo: :environment do
9
+ require "fileutils"
10
+ require "tmpdir"
11
+
12
+ # ── Config ────────────────────────────────────────────────────────────────
13
+ username = ENV.fetch("SIGIT_USER", "sigit")
14
+ repo_name = ENV.fetch("REPO", "sigit-si")
15
+ branch = "main"
16
+ description = "The siGit native app — quiet Git hosting built with Rails 8"
17
+
18
+ # ── Find user ─────────────────────────────────────────────────────────────
19
+ user = User.find_by(username: username)
20
+ abort " [dev:seed_repo] No user '#{username}' found. Sign in once first." unless user
21
+
22
+ # ── Skip if already exists ────────────────────────────────────────────────
23
+ if user.repositories.exists?(name: repo_name)
24
+ puts " [dev:seed_repo] '#{username}/#{repo_name}' already exists — skipping."
25
+ next
26
+ end
27
+
28
+ local_repos = Rails.root.join(".local-repos").to_s
29
+ bare_path = File.join(local_repos, username, "#{repo_name}.git")
30
+ puts " [dev:seed_repo] Creating bare repo at #{bare_path} …"
31
+
32
+ # ── 1. Create bare repo ───────────────────────────────────────────────────
33
+ FileUtils.mkdir_p(bare_path)
34
+ system("git", "init", "--bare", "--initial-branch=#{branch}", bare_path, exception: true)
35
+
36
+ # ── 2. Build history in a temp working tree ───────────────────────────────
37
+ Dir.mktmpdir("sigitsi-seed-") do |tmp|
38
+ git = ->(*args) { system("git", "-C", tmp, *args, exception: true) }
39
+ write = ->(rel, content) {
40
+ abs = File.join(tmp, rel)
41
+ FileUtils.mkdir_p(File.dirname(abs))
42
+ File.write(abs, content)
43
+ }
44
+
45
+ git.("init", "-b", branch)
46
+ git.("config", "user.email", "sigit@sigit.si")
47
+ git.("config", "user.name", "Sigit")
48
+ git.("config", "commit.gpgsign", "false")
49
+ git.("config", "tag.gpgsign", "false")
50
+ git.("remote", "add", "origin", bare_path)
51
+
52
+ # ── Commit 1: scaffold ──────────────────────────────────────────────────
53
+ write.("README.md", <<~'MD')
54
+ # siGit
55
+
56
+ Quiet, self-hosted Git hosting built with **Ruby on Rails 8**.
57
+
58
+ ## Features
59
+
60
+ - Push/pull over SSH using your own keys
61
+ - Browse code, commits, and diffs in the browser
62
+ - Per-repository privacy (public / private)
63
+ - Dark-mode UI powered by Tailwind CSS
64
+ - Stimulus + Turbo for a snappy, no-SPA feel
65
+
66
+ ## Quick start
67
+
68
+ ```bash
69
+ git clone git@sigitsi.com:sigit/sigit-si
70
+ cd sigit-si
71
+ bin/setup
72
+ bin/rails server
73
+ ```
74
+
75
+ Open [http://localhost:3000](http://localhost:3000).
76
+
77
+ ## Stack
78
+
79
+ | Layer | Choice |
80
+ |------------|---------------------------------|
81
+ | Backend | Ruby 3.4 · Rails 8 |
82
+ | Database | PostgreSQL |
83
+ | Frontend | Tailwind CSS · Stimulus · Turbo |
84
+ | Git store | Bare repos via shell / Open3 |
85
+ | Auth | smbCloud OAuth |
86
+
87
+ ## Environment variables
88
+
89
+ | Variable | Default | Description |
90
+ |-----------------------|------------------|--------------------------------------|
91
+ | `SIGITSI_REPOS_PATH` | `./repos` | Directory that holds bare git repos |
92
+ | `SIGITSI_DATABASE_URL`| — | Postgres connection string |
93
+ | `SMBCLOUD_CLIENT_ID` | — | OAuth client ID |
94
+ | `SMBCLOUD_CLIENT_SECRET` | — | OAuth client secret |
95
+
96
+ ## License
97
+
98
+ MIT
99
+ MD
100
+
101
+ write.(".gitignore", <<~'IGNORE')
102
+ /.bundle/
103
+ /.byebug_history
104
+ /db/*.sqlite3
105
+ /log/*.log
106
+ /public/assets
107
+ /storage/
108
+ /tmp/
109
+ /vendor/bundle
110
+ .env
111
+ .env.local
112
+ repos/
113
+ .local-repos/
114
+ node_modules/
115
+ .DS_Store
116
+ IGNORE
117
+
118
+ write.("Gemfile", <<~'RUBY')
119
+ source "https://rubygems.org"
120
+ ruby "~> 3.4"
121
+
122
+ gem "rails", "~> 8.0"
123
+ gem "pg", "~> 1.5"
124
+ gem "puma", ">= 5.0"
125
+ gem "importmap-rails"
126
+ gem "turbo-rails"
127
+ gem "stimulus-rails"
128
+ gem "tailwindcss-rails"
129
+ gem "redcarpet"
130
+ gem "rouge"
131
+ gem "solid_cache"
132
+ gem "solid_queue"
133
+
134
+ group :development, :test do
135
+ gem "debug", platforms: %i[mri mswin]
136
+ gem "brakeman"
137
+ end
138
+
139
+ group :development do
140
+ gem "web-console"
141
+ end
142
+ RUBY
143
+
144
+ git.("add", ".")
145
+ git.("commit", "--date=2025-01-01T09:00:00",
146
+ "-m", "Initial commit: scaffold Rails 8 app")
147
+
148
+ # ── Commit 2: models ────────────────────────────────────────────────────
149
+ write.("app/models/application_record.rb", <<~'RUBY')
150
+ class ApplicationRecord < ActiveRecord::Base
151
+ primary_abstract_class
152
+ end
153
+ RUBY
154
+
155
+ write.("app/models/user.rb", <<~'RUBY')
156
+ class User < ApplicationRecord
157
+ has_many :repositories, dependent: :destroy
158
+ has_many :ssh_keys, dependent: :destroy
159
+
160
+ validates :username, presence: true,
161
+ uniqueness: { case_sensitive: false },
162
+ format: { with: /\\A[a-z0-9_\\-]+\\z/,
163
+ message: "lowercase letters, numbers, hyphens, underscores only" }
164
+ validates :email, presence: true, uniqueness: true
165
+
166
+ def display_name_or_username
167
+ display_name.presence || username
168
+ end
169
+
170
+ def avatar_url_or_default
171
+ return avatar_url if avatar_url.present?
172
+ digest = Digest::MD5.hexdigest(email.to_s.downcase.strip)
173
+ "https://www.gravatar.com/avatar/#{digest}?d=identicon&s=200"
174
+ end
175
+ end
176
+ RUBY
177
+
178
+ write.("app/models/repository.rb", <<~'RUBY')
179
+ class Repository < ApplicationRecord
180
+ belongs_to :user
181
+
182
+ validates :name, presence: true,
183
+ format: {
184
+ with: /\\A(?!\\.)[a-zA-Z0-9_.\\-]+(?<!\\.)\\'\\z/,
185
+ message: "letters, numbers, hyphens, underscores, dots only"
186
+ },
187
+ uniqueness: { scope: :user_id, case_sensitive: false }
188
+ validates :default_branch, presence: true
189
+
190
+ def full_name = "#{user.username}/#{name}"
191
+ def initialized? = disk_path.present? &&
192
+ Dir.exist?(disk_path) &&
193
+ File.exist?(File.join(disk_path, "HEAD"))
194
+ end
195
+ RUBY
196
+
197
+ write.("app/models/ssh_key.rb", <<~'RUBY')
198
+ class SshKey < ApplicationRecord
199
+ belongs_to :user
200
+
201
+ validates :title, presence: true
202
+ validates :key_text, presence: true
203
+ validates :fingerprint, presence: true, uniqueness: true
204
+
205
+ before_validation :compute_fingerprint
206
+
207
+ private
208
+
209
+ def compute_fingerprint
210
+ return if key_text.blank?
211
+ self.fingerprint = `echo #{Shellwords.escape(key_text.strip)} | ssh-keygen -lf - 2>/dev/null`
212
+ .split.second
213
+ rescue StandardError
214
+ nil
215
+ end
216
+ end
217
+ RUBY
218
+
219
+ git.("add", ".")
220
+ git.("commit", "--date=2025-01-08T11:30:00",
221
+ "-m", "feat(models): User, Repository, SshKey with validations")
222
+
223
+ # ── Commit 3: git service ───────────────────────────────────────────────
224
+ write.("app/services/git_repository_service.rb", <<~'RUBY')
225
+ require "open3"
226
+ require "fileutils"
227
+
228
+ class GitRepositoryService
229
+ REPOS_BASE = ENV.fetch("SIGITSI_REPOS_PATH", Rails.root.join(".local-repos").to_s)
230
+
231
+ def self.repo_path(username, reponame)
232
+ File.join(REPOS_BASE, username, "#{reponame}.git")
233
+ end
234
+
235
+ def self.create_bare_repo(username, reponame)
236
+ path = repo_path(username, reponame)
237
+ FileUtils.mkdir_p(path)
238
+ _, _, status = Open3.capture3("git", "init", "--bare", path)
239
+ raise "git init failed" unless status.success?
240
+ path
241
+ end
242
+
243
+ def self.list_tree(path, branch, tree_path = "")
244
+ prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"
245
+ out, _, status = Open3.capture3(
246
+ "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}"
247
+ )
248
+ return [] unless status.success?
249
+ out.lines.filter_map do |line|
250
+ parts = line.split
251
+ next unless parts.length >= 5
252
+ _mode, type, _sha, _size, *name_parts = parts
253
+ name = name_parts.join(" ").strip
254
+ { type: type, name: name, path: prefix + name }
255
+ end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }
256
+ end
257
+
258
+ def self.commits(path, branch, limit: 20, offset: 0)
259
+ fmt = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
260
+ out, _, status = Open3.capture3(
261
+ "git", "--git-dir", path, "log",
262
+ "--format=#{fmt}", "--date=iso-strict",
263
+ "--skip=#{offset}", "-n", limit.to_s, branch
264
+ )
265
+ return [] unless status.success?
266
+ out.lines.filter_map do |line|
267
+ parts = line.chomp.split("\\x00")
268
+ next unless parts.length == 7
269
+ { sha: parts[0], short_sha: parts[1], subject: parts[2],
270
+ author_name: parts[3], author_email: parts[4],
271
+ authored_date: Time.parse(parts[5]), committer_name: parts[6] }
272
+ end
273
+ end
274
+
275
+ def self.commit_count(path, branch)
276
+ out, _, s = Open3.capture3("git", "--git-dir", path, "rev-list", "--count", branch)
277
+ s.success? ? out.strip.to_i : 0
278
+ end
279
+
280
+ def self.branch_exists?(path, branch)
281
+ _, _, s = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch)
282
+ s.success?
283
+ end
284
+
285
+ def self.file_content(path, branch, file_path)
286
+ out, _, s = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
287
+ s.success? ? out : nil
288
+ end
289
+
290
+ def self.readme_content(path, branch)
291
+ %w[README.md README.txt README readme.md].each do |name|
292
+ content = file_content(path, branch, name)
293
+ return { content: content, filename: name } if content
294
+ end
295
+ nil
296
+ end
297
+ end
298
+ RUBY
299
+
300
+ git.("add", ".")
301
+ git.("commit", "--date=2025-01-12T14:15:00",
302
+ "-m", "feat(services): GitRepositoryService — bare-repo ops via Open3")
303
+
304
+ # ── Commit 4: routes + controllers ─────────────────────────────────────
305
+ write.("config/routes.rb", <<~'RUBY')
306
+ Rails.application.routes.draw do
307
+ root "pages#home"
308
+
309
+ get "signin", to: "sessions#new", as: :signin
310
+ post "signin", to: "sessions#create"
311
+ delete "signout", to: "sessions#destroy", as: :signout
312
+
313
+ get "settings", to: "users#settings", as: :settings
314
+ patch "settings", to: "users#update_settings"
315
+
316
+ get "@:username", to: "users#show", as: :user_profile
317
+
318
+ scope "@:username" do
319
+ resources :repositories, path: "", param: :repository, only: %i[new create show] do
320
+ member do
321
+ get "tree/*path", to: "repositories#tree", as: :tree
322
+ get "blob/*path", to: "blobs#show", as: :blob
323
+ get "raw/*path", to: "blobs#raw", as: :blob_raw
324
+ get "commits/:branch", to: "commits#index", as: :commits
325
+ get "commit/:sha", to: "commits#show", as: :commit
326
+ end
327
+ end
328
+ end
329
+ end
330
+ RUBY
331
+
332
+ write.("app/controllers/application_controller.rb", <<~'RUBY')
333
+ class ApplicationController < ActionController::Base
334
+ helper_method :current_user, :signed_in?
335
+
336
+ private
337
+
338
+ def current_user
339
+ @current_user ||= User.find_by(id: session[:user_id])
340
+ end
341
+
342
+ def signed_in?
343
+ current_user.present?
344
+ end
345
+
346
+ def require_sign_in!
347
+ redirect_to signin_path, alert: "Please sign in." unless signed_in?
348
+ end
349
+ end
350
+ RUBY
351
+
352
+ write.("app/controllers/repositories_controller.rb", <<~'RUBY')
353
+ class RepositoriesController < ApplicationController
354
+ before_action :require_sign_in!, only: %i[new create]
355
+ before_action :load_owner
356
+ before_action :load_repository, except: %i[new create]
357
+ before_action :ensure_can_read!, except: %i[new create]
358
+
359
+ def new = @repository = Repository.new
360
+
361
+ def create
362
+ @repository = current_user.repositories.new(repository_params)
363
+ @repository.disk_path = GitRepositoryService.repo_path(
364
+ current_user.username, @repository.name
365
+ )
366
+ if @repository.save
367
+ GitRepositoryService.create_bare_repo(current_user.username, @repository.name)
368
+ redirect_to repository_path(current_user.username, @repository.name),
369
+ notice: "Repository created."
370
+ else
371
+ render :new, status: :unprocessable_entity
372
+ end
373
+ end
374
+
375
+ def show
376
+ @branch = params[:branch] || @repository.default_branch
377
+ return (@empty = true) unless @repository.initialized? &&
378
+ GitRepositoryService.branch_exists?(@repository.disk_path, @branch)
379
+
380
+ @tree = GitRepositoryService.list_tree(@repository.disk_path, @branch)
381
+ @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch)
382
+ @recent_commits = GitRepositoryService.commits(@repository.disk_path, @branch, limit: 3)
383
+ readme = GitRepositoryService.readme_content(@repository.disk_path, @branch)
384
+ @readme_html = render_markdown(readme[:content]) if readme
385
+ @readme_filename = readme[:filename] if readme
386
+ end
387
+
388
+ private
389
+
390
+ def load_owner = @owner = User.find_by!(username: params[:username])
391
+ def load_repository = @repository = @owner.repositories.find_by!(name: params[:repository])
392
+ def ensure_can_read!
393
+ return unless @repository.is_private
394
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false unless signed_in? && current_user == @owner
395
+ end
396
+ def repository_params = params.require(:repository).permit(:name, :description, :default_branch, :is_private)
397
+ end
398
+ RUBY
399
+
400
+ git.("add", ".")
401
+ git.("commit", "--date=2025-01-18T10:00:00",
402
+ "-m", "feat(routes): scope @:username routes, wire controllers")
403
+
404
+ # ── Commit 5: Tailwind theme + stylesheet ───────────────────────────────
405
+ write.("config/tailwind.config.js", <<~'JS')
406
+ const defaultTheme = require("tailwindcss/defaultTheme")
407
+
408
+ module.exports = {
409
+ content: [
410
+ "./app/views/**/*.html.erb",
411
+ "./app/helpers/**/*.rb",
412
+ "./app/javascript/**/*.js",
413
+ ],
414
+ theme: {
415
+ extend: {
416
+ fontFamily: {
417
+ sans: ["Inter", ...defaultTheme.fontFamily.sans],
418
+ mono: ["JetBrains Mono", ...defaultTheme.fontFamily.mono],
419
+ },
420
+ colors: {
421
+ brand: { 400: "#f97316", 500: "#ea580c" },
422
+ surface: { 600: "#2d3139", 700: "#22262d", 800: "#1a1d21", 900: "#13151a" },
423
+ },
424
+ },
425
+ },
426
+ plugins: [],
427
+ }
428
+ JS
429
+
430
+ write.("app/assets/stylesheets/application.tailwind.css", <<~'CSS')
431
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
432
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
433
+
434
+ @tailwind base;
435
+ @tailwind components;
436
+ @tailwind utilities;
437
+
438
+ @layer base {
439
+ body { @apply bg-surface-900 text-gray-300 text-sm leading-relaxed; }
440
+ a { @apply text-brand-500; }
441
+ }
442
+
443
+ @layer components {
444
+ .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; }
445
+ .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; }
446
+ .card { @apply bg-surface-700 border border-surface-500 rounded-sm; }
447
+ .badge-gray { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-600 text-gray-300; }
448
+ .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; }
449
+ .tab-item.active { @apply text-gray-100 border-brand-500; }
450
+ }
451
+ CSS
452
+
453
+ git.("add", ".")
454
+ git.("commit", "--date=2025-01-22T16:45:00",
455
+ "-m", "feat(ui): Tailwind theme — brand orange, surface grays, component layer")
456
+
457
+ # ── Commit 6: fix avatar fallback ───────────────────────────────────────
458
+ write.("app/models/user.rb", File.read(File.join(tmp, "app/models/user.rb"))
459
+ .sub("?d=identicon", "?d=identicon&size=200"))
460
+
461
+ git.("add", ".")
462
+ git.("commit", "--date=2025-01-25T09:10:00",
463
+ "-m", "fix(user): correct Gravatar size param (size= not s=)")
464
+
465
+ # ── Push all commits to bare repo ───────────────────────────────────────
466
+ git.("push", "origin", branch)
467
+ puts " [dev:seed_repo] Pushed #{`git -C #{tmp} rev-list --count #{branch}`.strip} commits to bare repo."
468
+ end
469
+
470
+ # ── 3. Create the DB record ───────────────────────────────────────────────
471
+ repo = user.repositories.create!(
472
+ name: repo_name,
473
+ description: description,
474
+ disk_path: bare_path,
475
+ default_branch: branch,
476
+ is_private: false
477
+ )
478
+
479
+ puts " [dev:seed_repo] Done! Repository record id=#{repo.id}"
480
+ puts " [dev:seed_repo] Browse at http://localhost:3000/@#{username}/#{repo_name}"
481
+ end
482
+end