| 1 | class Repository < ApplicationRecord |
| 2 | KINDS = %w[code model].freeze |
| 3 | |
| 4 | belongs_to :user |
| 5 | has_many :stars, dependent: :destroy |
| 6 | has_many :stargazers, through: :stars, source: :user |
| 7 | has_many :pull_requests, dependent: :destroy |
| 8 | has_many :issues, dependent: :destroy |
| 9 | has_many :repository_imports, dependent: :nullify |
| 10 | |
| 11 | # Upstream credential for a private mirror, encrypted at rest and never |
| 12 | # returned to the client. Nil for public upstreams and non-mirror repos. |
| 13 | encrypts :upstream_token |
| 14 | |
| 15 | scope :mirrors, -> { where(mirror: true) } |
| 16 | |
| 17 | scope :models, -> { where(kind: "model") } |
| 18 | scope :code, -> { where(kind: "code") } |
| 19 | scope :visible_to, ->(user) { |
| 20 | user ? where("is_private = ? OR user_id = ?", false, user.id) : where(is_private: false) |
| 21 | } |
| 22 | |
| 23 | validates :name, presence: true, |
| 24 | format: { |
| 25 | with: /\A(?!\.)[a-zA-Z0-9_.\-]+(?<!\.)(?<!\.\.)\z/, |
| 26 | message: "may only contain letters, numbers, hyphens, underscores, and dots; must not start or end with a dot; must not contain consecutive dots" |
| 27 | }, |
| 28 | uniqueness: { scope: :user_id, case_sensitive: false } |
| 29 | validates :default_branch, presence: true |
| 30 | validates :kind, inclusion: { in: KINDS } |
| 31 | |
| 32 | validate :no_consecutive_dots_in_name |
| 33 | validate :no_leading_hyphen_in_name |
| 34 | |
| 35 | def to_param |
| 36 | name |
| 37 | end |
| 38 | |
| 39 | def full_name |
| 40 | "#{user.username}/#{name}" |
| 41 | end |
| 42 | |
| 43 | # Absolute web URL for this repository, used when building links for API/MCP |
| 44 | # clients that have no request context of their own. |
| 45 | def self.site_url |
| 46 | ENV.fetch("SIGITSI_URL", "https://sigit.si") |
| 47 | end |
| 48 | |
| 49 | def html_url |
| 50 | "#{self.class.site_url}/#{full_name}" |
| 51 | end |
| 52 | |
| 53 | # True if +user+ may push to / open changes against this repo. siGit repos |
| 54 | # have a single owner and no collaborators yet, so write == ownership — but a |
| 55 | # mirror is read-only for everyone (including the owner) until it's detached, |
| 56 | # so its upstream stays the single source of truth. |
| 57 | def writable_by?(user) |
| 58 | return false if mirror? |
| 59 | user.present? && user_id == user.id |
| 60 | end |
| 61 | |
| 62 | # ── Mirror mode ───────────────────────────────────────────────────────── |
| 63 | |
| 64 | def mirror? |
| 65 | mirror |
| 66 | end |
| 67 | |
| 68 | # Flip a mirror into a normal writable repo and stop syncing. Idempotent: a |
| 69 | # non-mirror repo is left untouched. The upstream URL is kept for reference, |
| 70 | # but the credential is dropped since we no longer fetch with it. |
| 71 | def detach_mirror! |
| 72 | return false unless mirror? |
| 73 | |
| 74 | update!( |
| 75 | mirror: false, |
| 76 | upstream_token: nil, |
| 77 | mirror_status: nil, |
| 78 | mirror_error: nil |
| 79 | ) |
| 80 | true |
| 81 | end |
| 82 | |
| 83 | # A short human status for the mirror badge/health line. |
| 84 | def mirror_state_label |
| 85 | return nil unless mirror? |
| 86 | |
| 87 | case mirror_status |
| 88 | when "syncing" then "Syncing…" |
| 89 | when "failed" then "Sync failed" |
| 90 | else |
| 91 | mirror_synced_at ? "Synced #{mirror_synced_at.strftime('%b %-d, %H:%M UTC')}" : "Awaiting first sync" |
| 92 | end |
| 93 | end |
| 94 | |
| 95 | # Read a file's contents at +ref+ (branch, tag, or SHA). Returns the content |
| 96 | # String, or nil when the path doesn't exist at that ref. This is the git read |
| 97 | # layer the MCP `get_file_contents` tool sits on. |
| 98 | def blob_at(ref, path) |
| 99 | GitRepositoryService.file_content(disk_path, ref, path) |
| 100 | end |
| 101 | |
| 102 | # Search file contents at +ref+ (default branch when omitted). Returns an array |
| 103 | # of { path:, line:, snippet: } hashes, capped at +limit+. |
| 104 | def search_code(query, limit: 20, ref: nil) |
| 105 | GitRepositoryService.search_code(disk_path, ref || default_branch, query, limit: limit) |
| 106 | end |
| 107 | |
| 108 | # Unified diff of +head+ against its merge base with +base+ (what a pull |
| 109 | # request shows). Returns the diff String ("" when the refs are identical), |
| 110 | # or nil when either ref is unknown — e.g. the head branch of a merged pull |
| 111 | # request has been deleted. |
| 112 | def diff_between(base, head) |
| 113 | GitRepositoryService.diff(disk_path, base, head) |
| 114 | end |
| 115 | |
| 116 | # Next number in the shared issue / pull request number space. Issues and |
| 117 | # pull requests draw from one per-repository sequence (as on GitHub), so a |
| 118 | # number resolves to exactly one record. Call inside a `with_lock` block |
| 119 | # when allocating. |
| 120 | def next_issue_number |
| 121 | [ pull_requests.maximum(:number) || 0, issues.maximum(:number) || 0 ].max + 1 |
| 122 | end |
| 123 | |
| 124 | def git_path |
| 125 | disk_path |
| 126 | end |
| 127 | |
| 128 | def open_pull_requests_count |
| 129 | pull_requests.open.count |
| 130 | end |
| 131 | |
| 132 | def initialized? |
| 133 | return false if git_path.blank? |
| 134 | Dir.exist?(git_path) && File.exist?(File.join(git_path, "HEAD")) |
| 135 | end |
| 136 | |
| 137 | def model? |
| 138 | kind == "model" |
| 139 | end |
| 140 | |
| 141 | def code? |
| 142 | kind != "model" |
| 143 | end |
| 144 | |
| 145 | def starred_by?(user) |
| 146 | return false unless user |
| 147 | stars.exists?(user_id: user.id) |
| 148 | end |
| 149 | |
| 150 | # ── SEO / social-card metadata ────────────────────────────────────────── |
| 151 | # |
| 152 | # Common source-file extensions → { name, color }. Colors mirror GitHub |
| 153 | # Linguist so the language dot reads as expected. Intentionally small: the |
| 154 | # goal is a good guess for the share card, not exhaustive coverage. |
| 155 | LANGUAGE_BY_EXT = { |
| 156 | "rb" => [ "Ruby", "#701516" ], "py" => [ "Python", "#3572A5" ], |
| 157 | "js" => [ "JavaScript", "#f1e05a" ], "mjs" => [ "JavaScript", "#f1e05a" ], |
| 158 | "ts" => [ "TypeScript", "#3178c6" ], "tsx" => [ "TypeScript", "#3178c6" ], |
| 159 | "jsx" => [ "JavaScript", "#f1e05a" ], "go" => [ "Go", "#00ADD8" ], |
| 160 | "rs" => [ "Rust", "#dea584" ], "java" => [ "Java", "#b07219" ], |
| 161 | "kt" => [ "Kotlin", "#A97BFF" ], "swift" => [ "Swift", "#F05138" ], |
| 162 | "c" => [ "C", "#555555" ], "h" => [ "C", "#555555" ], |
| 163 | "cpp" => [ "C++", "#f34b7d" ], "cc" => [ "C++", "#f34b7d" ], "hpp" => [ "C++", "#f34b7d" ], |
| 164 | "cs" => [ "C#", "#178600" ], "php" => [ "PHP", "#4F5D95" ], |
| 165 | "rb.erb" => [ "Ruby", "#701516" ], "erb" => [ "HTML", "#e34c26" ], |
| 166 | "html" => [ "HTML", "#e34c26" ], "css" => [ "CSS", "#563d7c" ], |
| 167 | "scss" => [ "SCSS", "#c6538c" ], "vue" => [ "Vue", "#41b883" ], |
| 168 | "ex" => [ "Elixir", "#6e4a7e" ], "exs" => [ "Elixir", "#6e4a7e" ], |
| 169 | "scala" => [ "Scala", "#c22d40" ], "sh" => [ "Shell", "#89e051" ], |
| 170 | "lua" => [ "Lua", "#000080" ], "dart" => [ "Dart", "#00B4AB" ], |
| 171 | "r" => [ "R", "#198CE7" ], "jl" => [ "Julia", "#a270ba" ], |
| 172 | "ipynb" => [ "Jupyter Notebook", "#DA5B0B" ], "sql" => [ "SQL", "#e38c00" ], |
| 173 | "md" => [ "Markdown", "#083fa1" ] |
| 174 | }.freeze |
| 175 | |
| 176 | WEIGHT_EXTS = %w[safetensors gguf bin onnx pt pth h5 ckpt].freeze |
| 177 | |
| 178 | # A short, human description for titles/social cards, with sane fallbacks so |
| 179 | # tags are never empty even when the repo has no description. |
| 180 | def seo_description |
| 181 | return description.strip if description.present? |
| 182 | |
| 183 | if model? |
| 184 | "#{name} — an open-weights model by @#{user.username}, with a full model " \ |
| 185 | "card and LFS-backed weights on siGit." |
| 186 | else |
| 187 | "#{name} — a Git repository by @#{user.username} on siGit, Git hosting " \ |
| 188 | "built for AI workflows." |
| 189 | end |
| 190 | end |
| 191 | |
| 192 | # Best-guess primary language as [name, color], or nil. For model repos that |
| 193 | # carry weight files we surface the weight format instead of a code language. |
| 194 | # Memoized; walks the tree once on first call. |
| 195 | def primary_language |
| 196 | return @primary_language if defined?(@primary_language) |
| 197 | @primary_language = detect_primary_language |
| 198 | end |
| 199 | |
| 200 | # Opaque content version for the repo's social card — changes whenever the |
| 201 | # tip commit, name, description, or star count changes, so cached cards stay |
| 202 | # correct without manual busting. |
| 203 | def og_version |
| 204 | head = initialized? ? GitRepositoryService.head_sha(disk_path, default_branch) : nil |
| 205 | Digest::SHA256.hexdigest( |
| 206 | [ id, name, description, stars_count, head, updated_at.to_i ].join("|") |
| 207 | )[0, 16] |
| 208 | end |
| 209 | |
| 210 | # Parses the Hugging Face-style YAML front-matter from the README (the "model |
| 211 | # card") on the default branch. Memoized per instance; returns a ModelCard |
| 212 | # even when there is no front-matter so callers can use it unconditionally. |
| 213 | def model_card(branch: nil) |
| 214 | @model_card ||= begin |
| 215 | branch ||= default_branch |
| 216 | readme = (GitRepositoryService.readme_content(disk_path, branch) if initialized?) |
| 217 | ModelCard.parse(readme&.dig(:content)) |
| 218 | end |
| 219 | end |
| 220 | |
| 221 | private |
| 222 | |
| 223 | def detect_primary_language |
| 224 | return nil unless initialized? |
| 225 | files = GitRepositoryService.tree_filenames(disk_path, default_branch) |
| 226 | return nil if files.empty? |
| 227 | |
| 228 | counts = Hash.new(0) |
| 229 | files.each do |f| |
| 230 | ext = File.extname(f).delete_prefix(".").downcase |
| 231 | next if ext.empty? |
| 232 | if WEIGHT_EXTS.include?(ext) |
| 233 | counts[[ "#{ext.upcase} weights", "#92D3A2" ]] += 1 |
| 234 | elsif (lang = LANGUAGE_BY_EXT[ext]) |
| 235 | counts[lang] += 1 |
| 236 | end |
| 237 | end |
| 238 | counts.max_by { |_lang, n| n }&.first |
| 239 | end |
| 240 | |
| 241 | def no_consecutive_dots_in_name |
| 242 | return if name.blank? |
| 243 | errors.add(:name, "must not contain consecutive dots") if name.include?("..") |
| 244 | end |
| 245 | |
| 246 | def no_leading_hyphen_in_name |
| 247 | return if name.blank? |
| 248 | errors.add(:name, "must not start with a hyphen") if name.start_with?("-") |
| 249 | end |
| 250 | end |