| 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 |