main
rake 515 lines 20.8 KB
Raw
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")
15 branch = "main"
16 description = "siGit — a simple ACP-compatible coding agent that uses local LLM by default"
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 # ── Resolve disk_path ─────────────────────────────────────────────────────
23 # Development: point straight at the project's own .git directory so every
24 # real commit SHA is browsable without a separate push step.
25 # Production: SIGITSI_REPOS_PATH must be set and the bare repo must already
26 # exist (clone or push it there first).
27 if Rails.env.production?
28 repos_base = ENV["SIGITSI_REPOS_PATH"].presence
29 abort <<~MSG unless repos_base
30 [dev:seed_repo] SIGITSI_REPOS_PATH is not set.
31 Set it to the directory that holds bare repos, e.g.:
32 SIGITSI_REPOS_PATH=/home/git/repos
33 Then create the bare repo before running this task:
34 git clone --bare <source> #{repos_base}/#{username}/#{repo_name}.git
35 MSG
36
37 git_path = File.join(repos_base, username, "#{repo_name}.git")
38 abort <<~MSG unless Dir.exist?(git_path)
39 [dev:seed_repo] Bare repo not found at #{git_path}.
40 Create it first:
41 git clone --bare <source> #{git_path}
42 Then re-run this task.
43 MSG
44 else
45 # Development shortcut — uses the working tree's .git directly.
46 git_path = Rails.root.join(".git").to_s
47 end
48
49 repo = user.repositories.find_or_initialize_by(name: repo_name)
50 if repo.persisted? && repo.disk_path == git_path
51 puts " [dev:seed_repo] '#{username}/#{repo_name}' already up to date — skipping."
52 next
53 end
54
55 repo.assign_attributes(
56 description: description,
57 disk_path: git_path,
58 default_branch: branch,
59 is_private: false
60 )
61 repo.save!
62 puts " [dev:seed_repo] #{repo.previously_new_record? ? "Created" : "Updated"} '#{username}/#{repo_name}' → #{git_path}"
63 next
64
65 # ── 1. Create bare repo ───────────────────────────────────────────────────
66 FileUtils.mkdir_p(bare_path)
67 system("git", "init", "--bare", "--initial-branch=#{branch}", bare_path, exception: true)
68
69 # ── 2. Build history in a temp working tree ───────────────────────────────
70 Dir.mktmpdir("sigitsi-seed-") do |tmp|
71 git = ->(*args) { system("git", "-C", tmp, *args, exception: true) }
72 write = ->(rel, content) {
73 abs = File.join(tmp, rel)
74 FileUtils.mkdir_p(File.dirname(abs))
75 File.write(abs, content)
76 }
77
78 git.("init", "-b", branch)
79 git.("config", "user.email", "sigit@sigit.si")
80 git.("config", "user.name", "Sigit")
81 git.("config", "commit.gpgsign", "false")
82 git.("config", "tag.gpgsign", "false")
83 git.("remote", "add", "origin", bare_path)
84
85 # ── Commit 1: scaffold ──────────────────────────────────────────────────
86 write.("README.md", <<~'MD')
87 # siGit
88
89 Quiet, self-hosted Git hosting built with **Ruby on Rails 8**.
90
91 ## Features
92
93 - Push/pull over SSH using your own keys
94 - Browse code, commits, and diffs in the browser
95 - Per-repository privacy (public / private)
96 - Dark-mode UI powered by Tailwind CSS
97 - Stimulus + Turbo for a snappy, no-SPA feel
98
99 ## Quick start
100
101 ```bash
102 git clone git@sigitsi.com:sigit/sigit-si
103 cd sigit-si
104 bin/setup
105 bin/rails server
106 ```
107
108 Open [http://localhost:3000](http://localhost:3000).
109
110 ## Stack
111
112 | Layer | Choice |
113 |------------|---------------------------------|
114 | Backend | Ruby 3.4 · Rails 8 |
115 | Database | PostgreSQL |
116 | Frontend | Tailwind CSS · Stimulus · Turbo |
117 | Git store | Bare repos via shell / Open3 |
118 | Auth | smbCloud OAuth |
119
120 ## Environment variables
121
122 | Variable | Default | Description |
123 |-----------------------|------------------|--------------------------------------|
124 | `SIGITSI_REPOS_PATH` | `./repos` | Directory that holds bare git repos |
125 | `SIGITSI_DATABASE_URL`| — | Postgres connection string |
126 | `SMBCLOUD_CLIENT_ID` | — | OAuth client ID |
127 | `SMBCLOUD_CLIENT_SECRET` | — | OAuth client secret |
128
129 ## License
130
131 MIT
132 MD
133
134 write.(".gitignore", <<~'IGNORE')
135 /.bundle/
136 /.byebug_history
137 /db/*.sqlite3
138 /log/*.log
139 /public/assets
140 /storage/
141 /tmp/
142 /vendor/bundle
143 .env
144 .env.local
145 repos/
146 .local-repos/
147 node_modules/
148 .DS_Store
149 IGNORE
150
151 write.("Gemfile", <<~'RUBY')
152 source "https://rubygems.org"
153 ruby "~> 3.4"
154
155 gem "rails", "~> 8.0"
156 gem "pg", "~> 1.5"
157 gem "puma", ">= 5.0"
158 gem "importmap-rails"
159 gem "turbo-rails"
160 gem "stimulus-rails"
161 gem "tailwindcss-rails"
162 gem "redcarpet"
163 gem "rouge"
164 gem "solid_cache"
165 gem "solid_queue"
166
167 group :development, :test do
168 gem "debug", platforms: %i[mri mswin]
169 gem "brakeman"
170 end
171
172 group :development do
173 gem "web-console"
174 end
175 RUBY
176
177 git.("add", ".")
178 git.("commit", "--date=2025-01-01T09:00:00",
179 "-m", "Initial commit: scaffold Rails 8 app")
180
181 # ── Commit 2: models ────────────────────────────────────────────────────
182 write.("app/models/application_record.rb", <<~'RUBY')
183 class ApplicationRecord < ActiveRecord::Base
184 primary_abstract_class
185 end
186 RUBY
187
188 write.("app/models/user.rb", <<~'RUBY')
189 class User < ApplicationRecord
190 has_many :repositories, dependent: :destroy
191 has_many :ssh_keys, dependent: :destroy
192
193 validates :username, presence: true,
194 uniqueness: { case_sensitive: false },
195 format: { with: /\\A[a-z0-9_\\-]+\\z/,
196 message: "lowercase letters, numbers, hyphens, underscores only" }
197 validates :email, presence: true, uniqueness: true
198
199 def display_name_or_username
200 display_name.presence || username
201 end
202
203 def avatar_url_or_default
204 return avatar_url if avatar_url.present?
205 digest = Digest::MD5.hexdigest(email.to_s.downcase.strip)
206 "https://www.gravatar.com/avatar/#{digest}?d=identicon&s=200"
207 end
208 end
209 RUBY
210
211 write.("app/models/repository.rb", <<~'RUBY')
212 class Repository < ApplicationRecord
213 belongs_to :user
214
215 validates :name, presence: true,
216 format: {
217 with: /\\A(?!\\.)[a-zA-Z0-9_.\\-]+(?<!\\.)\\'\\z/,
218 message: "letters, numbers, hyphens, underscores, dots only"
219 },
220 uniqueness: { scope: :user_id, case_sensitive: false }
221 validates :default_branch, presence: true
222
223 def full_name = "#{user.username}/#{name}"
224 def initialized? = disk_path.present? &&
225 Dir.exist?(disk_path) &&
226 File.exist?(File.join(disk_path, "HEAD"))
227 end
228 RUBY
229
230 write.("app/models/ssh_key.rb", <<~'RUBY')
231 class SshKey < ApplicationRecord
232 belongs_to :user
233
234 validates :title, presence: true
235 validates :key_text, presence: true
236 validates :fingerprint, presence: true, uniqueness: true
237
238 before_validation :compute_fingerprint
239
240 private
241
242 def compute_fingerprint
243 return if key_text.blank?
244 self.fingerprint = `echo #{Shellwords.escape(key_text.strip)} | ssh-keygen -lf - 2>/dev/null`
245 .split.second
246 rescue StandardError
247 nil
248 end
249 end
250 RUBY
251
252 git.("add", ".")
253 git.("commit", "--date=2025-01-08T11:30:00",
254 "-m", "feat(models): User, Repository, SshKey with validations")
255
256 # ── Commit 3: git service ───────────────────────────────────────────────
257 write.("app/services/git_repository_service.rb", <<~'RUBY')
258 require "open3"
259 require "fileutils"
260
261 class GitRepositoryService
262 REPOS_BASE = ENV.fetch("SIGITSI_REPOS_PATH", Rails.root.join(".local-repos").to_s)
263
264 def self.repo_path(username, reponame)
265 File.join(REPOS_BASE, username, "#{reponame}.git")
266 end
267
268 def self.create_bare_repo(username, reponame)
269 path = repo_path(username, reponame)
270 FileUtils.mkdir_p(path)
271 _, _, status = Open3.capture3("git", "init", "--bare", path)
272 raise "git init failed" unless status.success?
273 path
274 end
275
276 def self.list_tree(path, branch, tree_path = "")
277 prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"
278 out, _, status = Open3.capture3(
279 "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}"
280 )
281 return [] unless status.success?
282 out.lines.filter_map do |line|
283 parts = line.split
284 next unless parts.length >= 5
285 _mode, type, _sha, _size, *name_parts = parts
286 name = name_parts.join(" ").strip
287 { type: type, name: name, path: prefix + name }
288 end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }
289 end
290
291 def self.commits(path, branch, limit: 20, offset: 0)
292 fmt = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
293 out, _, status = Open3.capture3(
294 "git", "--git-dir", path, "log",
295 "--format=#{fmt}", "--date=iso-strict",
296 "--skip=#{offset}", "-n", limit.to_s, branch
297 )
298 return [] unless status.success?
299 out.lines.filter_map do |line|
300 parts = line.chomp.split("\\x00")
301 next unless parts.length == 7
302 { sha: parts[0], short_sha: parts[1], subject: parts[2],
303 author_name: parts[3], author_email: parts[4],
304 authored_date: Time.parse(parts[5]), committer_name: parts[6] }
305 end
306 end
307
308 def self.commit_count(path, branch)
309 out, _, s = Open3.capture3("git", "--git-dir", path, "rev-list", "--count", branch)
310 s.success? ? out.strip.to_i : 0
311 end
312
313 def self.branch_exists?(path, branch)
314 _, _, s = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch)
315 s.success?
316 end
317
318 def self.file_content(path, branch, file_path)
319 out, _, s = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
320 s.success? ? out : nil
321 end
322
323 def self.readme_content(path, branch)
324 %w[README.md README.txt README readme.md].each do |name|
325 content = file_content(path, branch, name)
326 return { content: content, filename: name } if content
327 end
328 nil
329 end
330 end
331 RUBY
332
333 git.("add", ".")
334 git.("commit", "--date=2025-01-12T14:15:00",
335 "-m", "feat(services): GitRepositoryService — bare-repo ops via Open3")
336
337 # ── Commit 4: routes + controllers ─────────────────────────────────────
338 write.("config/routes.rb", <<~'RUBY')
339 Rails.application.routes.draw do
340 root "pages#home"
341
342 get "signin", to: "sessions#new", as: :signin
343 post "signin", to: "sessions#create"
344 delete "signout", to: "sessions#destroy", as: :signout
345
346 get "settings", to: "users#settings", as: :settings
347 patch "settings", to: "users#update_settings"
348
349 get "@:username", to: "users#show", as: :user_profile
350
351 scope "@:username" do
352 resources :repositories, path: "", param: :repository, only: %i[new create show] do
353 member do
354 get "tree/*path", to: "repositories#tree", as: :tree
355 get "blob/*path", to: "blobs#show", as: :blob
356 get "raw/*path", to: "blobs#raw", as: :blob_raw
357 get "commits/:branch", to: "commits#index", as: :commits
358 get "commit/:sha", to: "commits#show", as: :commit
359 end
360 end
361 end
362 end
363 RUBY
364
365 write.("app/controllers/application_controller.rb", <<~'RUBY')
366 class ApplicationController < ActionController::Base
367 helper_method :current_user, :signed_in?
368
369 private
370
371 def current_user
372 @current_user ||= User.find_by(id: session[:user_id])
373 end
374
375 def signed_in?
376 current_user.present?
377 end
378
379 def require_sign_in!
380 redirect_to signin_path, alert: "Please sign in." unless signed_in?
381 end
382 end
383 RUBY
384
385 write.("app/controllers/repositories_controller.rb", <<~'RUBY')
386 class RepositoriesController < ApplicationController
387 before_action :require_sign_in!, only: %i[new create]
388 before_action :load_owner
389 before_action :load_repository, except: %i[new create]
390 before_action :ensure_can_read!, except: %i[new create]
391
392 def new = @repository = Repository.new
393
394 def create
395 @repository = current_user.repositories.new(repository_params)
396 @repository.disk_path = GitRepositoryService.repo_path(
397 current_user.username, @repository.name
398 )
399 if @repository.save
400 GitRepositoryService.create_bare_repo(current_user.username, @repository.name)
401 redirect_to repository_path(current_user.username, @repository.name),
402 notice: "Repository created."
403 else
404 render :new, status: :unprocessable_entity
405 end
406 end
407
408 def show
409 @branch = params[:branch] || @repository.default_branch
410 return (@empty = true) unless @repository.initialized? &&
411 GitRepositoryService.branch_exists?(@repository.disk_path, @branch)
412
413 @tree = GitRepositoryService.list_tree(@repository.disk_path, @branch)
414 @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch)
415 @recent_commits = GitRepositoryService.commits(@repository.disk_path, @branch, limit: 3)
416 readme = GitRepositoryService.readme_content(@repository.disk_path, @branch)
417 @readme_html = render_markdown(readme[:content]) if readme
418 @readme_filename = readme[:filename] if readme
419 end
420
421 private
422
423 def load_owner = @owner = User.find_by!(username: params[:username])
424 def load_repository = @repository = @owner.repositories.find_by!(name: params[:repository])
425 def ensure_can_read!
426 return unless @repository.is_private
427 render file: Rails.public_path.join("404.html"), status: :not_found, layout: false unless signed_in? && current_user == @owner
428 end
429 def repository_params = params.require(:repository).permit(:name, :description, :default_branch, :is_private)
430 end
431 RUBY
432
433 git.("add", ".")
434 git.("commit", "--date=2025-01-18T10:00:00",
435 "-m", "feat(routes): scope @:username routes, wire controllers")
436
437 # ── Commit 5: Tailwind theme + stylesheet ───────────────────────────────
438 write.("config/tailwind.config.js", <<~'JS')
439 const defaultTheme = require("tailwindcss/defaultTheme")
440
441 module.exports = {
442 content: [
443 "./app/views/**/*.html.erb",
444 "./app/helpers/**/*.rb",
445 "./app/javascript/**/*.js",
446 ],
447 theme: {
448 extend: {
449 fontFamily: {
450 sans: ["Inter", ...defaultTheme.fontFamily.sans],
451 mono: ["JetBrains Mono", ...defaultTheme.fontFamily.mono],
452 },
453 colors: {
454 brand: { 400: "#f97316", 500: "#ea580c" },
455 surface: { 600: "#2d3139", 700: "#22262d", 800: "#1a1d21", 900: "#13151a" },
456 },
457 },
458 },
459 plugins: [],
460 }
461 JS
462
463 write.("app/assets/stylesheets/application.tailwind.css", <<~'CSS')
464 @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
465 @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
466
467 @tailwind base;
468 @tailwind components;
469 @tailwind utilities;
470
471 @layer base {
472 body { @apply bg-surface-900 text-gray-300 text-sm leading-relaxed; }
473 a { @apply text-brand-500; }
474 }
475
476 @layer components {
477 .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; }
478 .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; }
479 .card { @apply bg-surface-700 border border-surface-500 rounded-sm; }
480 .badge-gray { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-600 text-gray-300; }
481 .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; }
482 .tab-item.active { @apply text-gray-100 border-brand-500; }
483 }
484 CSS
485
486 git.("add", ".")
487 git.("commit", "--date=2025-01-22T16:45:00",
488 "-m", "feat(ui): Tailwind theme — brand orange, surface grays, component layer")
489
490 # ── Commit 6: fix avatar fallback ───────────────────────────────────────
491 write.("app/models/user.rb", File.read(File.join(tmp, "app/models/user.rb"))
492 .sub("?d=identicon", "?d=identicon&size=200"))
493
494 git.("add", ".")
495 git.("commit", "--date=2025-01-25T09:10:00",
496 "-m", "fix(user): correct Gravatar size param (size= not s=)")
497
498 # ── Push all commits to bare repo ───────────────────────────────────────
499 git.("push", "origin", branch)
500 puts " [dev:seed_repo] Pushed #{`git -C #{tmp} rev-list --count #{branch}`.strip} commits to bare repo."
501 end
502
503 # ── 3. Create the DB record ───────────────────────────────────────────────
504 repo = user.repositories.create!(
505 name: repo_name,
506 description: description,
507 disk_path: bare_path,
508 default_branch: branch,
509 is_private: false
510 )
511
512 puts " [dev:seed_repo] Done! Repository record id=#{repo.id}"
513 puts " [dev:seed_repo] Browse at http://localhost:3000/@#{username}/#{repo_name}"
514 end
515 end