main
rb 355 lines 14.2 KB
Raw
1 require "open3"
2 require "fileutils"
3
4 class GitRepositoryService
5 REPOS_BASE = ENV.fetch("SIGITSI_REPOS_PATH", Rails.root.join("repos").to_s)
6
7 def self.repo_path(username, reponame)
8 File.join(REPOS_BASE, username, "#{reponame}.git")
9 end
10
11 # Git ref/SHA values reach `git` as a single argv entry (Open3.capture3 never
12 # invokes a shell), so they can't carry shell metacharacters. But a value
13 # starting with "-" is still parsed by git itself as an option rather than a
14 # revision (e.g. a branch of "--output=/some/path" could make `git log` write
15 # to an arbitrary file) — real ref/SHA names can never start with "-" (see
16 # `git check-ref-format`), so reject anything that does before it reaches git.
17 def self.safe_rev?(rev)
18 rev.present? && !rev.start_with?("-")
19 end
20 private_class_method :safe_rev?
21
22 def self.create_bare_repo(username, reponame)
23 path = repo_path(username, reponame)
24 FileUtils.mkdir_p(path)
25 _stdout, _stderr, status = Open3.capture3("git", "init", "--bare", path)
26 raise "git init failed" unless status.success?
27 path
28 end
29
30 def self.initialize_with_readme(username, reponame, default_branch: "main", readme: nil)
31 bare_path = repo_path(username, reponame)
32 Dir.mktmpdir do |tmpdir|
33 system("git", "-C", tmpdir, "init", "-b", default_branch, exception: true)
34 system("git", "-C", tmpdir, "config", "user.email", "sigitsi@localhost", exception: true)
35 system("git", "-C", tmpdir, "config", "user.name", "siGit", exception: true)
36 readme_path = File.join(tmpdir, "README.md")
37 File.write(readme_path, readme || "# #{reponame}\n\nWelcome to #{reponame}.\n")
38 system("git", "-C", tmpdir, "add", ".", exception: true)
39 system("git", "-C", tmpdir, "commit", "-m", "Initial commit", exception: true)
40 system("git", "-C", tmpdir, "remote", "add", "origin", bare_path, exception: true)
41 system("git", "-C", tmpdir, "push", "origin", default_branch, exception: true)
42 end
43 end
44
45 # Point HEAD at +branch+ so the landing page and default clone check out the
46 # right branch. Falls back to the first available branch when +branch+ wasn't
47 # among the pushed refs (e.g. an imported repo whose default differs). Returns
48 # the branch HEAD ends up on, or nil if the repo has no branches at all.
49 def self.set_head(path, branch)
50 target = branch if branch.present? && branch_exists?(path, branch)
51 target ||= branches(path).first
52 return nil if target.nil?
53
54 system("git", "--git-dir", path, "symbolic-ref", "HEAD", "refs/heads/#{target}", exception: false)
55 target
56 end
57
58 def self.default_branch(path)
59 out, _err, status = Open3.capture3("git", "--git-dir", path, "symbolic-ref", "--short", "HEAD")
60 return "main" unless status.success?
61 out.strip
62 end
63
64 def self.list_tree(path, branch, tree_path = "")
65 return [] unless safe_rev?(branch)
66 prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"
67 args = [ "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}" ]
68 out, _err, status = Open3.capture3(*args)
69 return [] unless status.success?
70 out.lines.filter_map do |line|
71 # `ls-tree --long` columns: <mode> <type> <sha> <size> <tab> <name>
72 meta, name = line.split("\t", 2)
73 next if name.nil?
74 mode, type, _sha, size = meta.split
75 { mode: mode, type: type, name: name.strip,
76 path: prefix + name.strip,
77 size: (size == "-" ? nil : size.to_i) }
78 end.sort_by { |e| [ e[:type] == "tree" ? 0 : 1, e[:name].downcase ] }
79 end
80
81 # Git LFS / Xet pointer files are tiny text stubs that stand in for large
82 # binary weights stored out-of-band. Recognise them so the UI can show the
83 # real on-disk size and an "LFS" badge instead of the ~130-byte pointer.
84 LFS_POINTER_RE = %r{\Aversion https://(?:git-lfs\.github\.com|hf\.co)/spec/v1}
85 LFS_SIZE_RE = /^size (\d+)$/
86 POINTER_MAX = 1024
87
88 # Root-level file listing for a model repository's "Files and versions" tab,
89 # with LFS pointer sizes resolved. Folders are included but not recursed.
90 def self.model_files(path, branch)
91 list_tree(path, branch).map do |entry|
92 next entry unless entry[:type] == "blob"
93 resolve_lfs(path, branch, entry)
94 end
95 end
96
97 # If +entry+ is a small blob that turns out to be an LFS pointer, replace its
98 # size with the pointer's declared size and flag it. Otherwise return as-is.
99 def self.resolve_lfs(path, branch, entry)
100 return entry if entry[:size].nil? || entry[:size] > POINTER_MAX
101
102 content = file_content(path, branch, entry[:path])
103 return entry unless content&.match?(LFS_POINTER_RE)
104
105 size = content[LFS_SIZE_RE, 1]&.to_i
106 entry.merge(lfs: true, size: size || entry[:size])
107 end
108
109 def self.file_content(path, branch, file_path)
110 return nil unless safe_rev?(branch)
111 out, _err, status = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
112 return nil unless status.success?
113 out
114 end
115
116 # Object id of the blob at <ref>:<file_path>. Stable for identical content, so
117 # it's the natural cache key for rendered/highlighted output. nil if missing.
118 def self.blob_sha(path, branch, file_path)
119 return nil unless safe_rev?(branch)
120 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{branch}:#{file_path}")
121 status.success? ? out.strip.presence : nil
122 end
123
124 # Byte size of the blob without loading it into memory — lets the blob view
125 # decide up front whether a file is too large to highlight or render.
126 def self.blob_size(path, branch, file_path)
127 return nil unless safe_rev?(branch)
128 out, _err, status = Open3.capture3("git", "--git-dir", path, "cat-file", "-s", "#{branch}:#{file_path}")
129 status.success? ? out.strip.to_i : nil
130 end
131
132 # Full commit SHA a ref currently points at. Permalinks pin to this so they
133 # survive the branch moving on.
134 def self.commit_sha(path, ref)
135 return nil unless safe_rev?(ref)
136 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
137 status.success? ? out.strip.presence : nil
138 end
139
140 def self.commits(path, branch, limit: 20, offset: 0)
141 return [] unless safe_rev?(branch)
142 format = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
143 out, _err, status = Open3.capture3(
144 "git", "--git-dir", path, "log",
145 "--format=#{format}", "--date=iso-strict",
146 "--skip=#{offset}", "-n", limit.to_s,
147 branch
148 )
149 return [] unless status.success?
150 out.lines.filter_map do |line|
151 parts = line.chomp.split("\x00")
152 next unless parts.length == 7
153 sha, short_sha, subject, author_name, author_email, authored_date, committer_name = parts
154 {
155 sha: sha,
156 short_sha: short_sha,
157 subject: subject,
158 author_name: author_name,
159 author_email: author_email,
160 authored_date: Time.parse(authored_date),
161 committer_name: committer_name
162 }
163 end
164 end
165
166 def self.commit(path, sha)
167 # %B contains embedded newlines, so we cannot rely on splitting by "\n" to
168 # find the end of the format output. Instead we append a sentinel that git
169 # will never emit naturally and split on that.
170 return nil unless safe_rev?(sha)
171
172 sentinel = "SIGIT-EOH"
173 format = "%H%x00%h%x00%s%x00%B%x00%an%x00%ae%x00%ad%x00%T%x00#{sentinel}"
174 out, _err, status = Open3.capture3(
175 "git", "--git-dir", path, "show",
176 "--no-patch", "--format=#{format}",
177 sha
178 )
179 return nil unless status.success?
180
181 header_raw = out.split(sentinel, 2).first.to_s
182 parts = header_raw.split("\x00", 9) # 8 fields + possible trailing newline
183 return nil unless parts.length >= 8
184
185 diff_out, _e, _s = Open3.capture3("git", "--git-dir", path, "show", "--format=", sha)
186 {
187 sha: parts[0].strip,
188 short_sha: parts[1].strip,
189 subject: parts[2].strip,
190 body: parts[3],
191 author_name: parts[4].strip,
192 author_email: parts[5].strip,
193 authored_date: (Time.parse(parts[6].strip) rescue nil),
194 diff: diff_out
195 }
196 end
197
198 def self.readme_content(path, branch)
199 %w[README.md README.txt README readme.md].each do |name|
200 content = file_content(path, branch, name)
201 return { content: content, filename: name } if content
202 end
203 nil
204 end
205
206 # Search file contents on +branch+ for +query+ using `git grep`. Returns an
207 # array of { path:, line:, snippet: } hashes, one per matching line, capped at
208 # +limit+. The search is fixed-string and case-insensitive; matching binary
209 # files are skipped. Returns [] when nothing matches or the branch is unknown.
210 def self.search_code(path, branch, query, limit: 20)
211 return [] if query.to_s.empty?
212 return [] unless safe_rev?(branch)
213
214 out, _err, status = Open3.capture3(
215 "git", "--git-dir", path, "grep",
216 "-I", # skip binary files
217 "--fixed-strings", # treat query literally, not as a regex
218 "--ignore-case",
219 "--line-number",
220 "--no-color",
221 "-e", query.to_s,
222 branch
223 )
224 # git grep exits 1 with no output when there are no matches — not an error.
225 return [] unless status.success? || status.exitstatus == 1
226
227 out.each_line.first(limit).map do |line|
228 # Format with a tree-ish is "<branch>:<path>:<lineno>:<text>".
229 _ref, file_path, lineno, snippet = line.chomp.split(":", 4)
230 { path: file_path, line: lineno.to_i, snippet: snippet.to_s.strip }
231 end
232 end
233
234 # Unified diff of +head+ against its merge base with +base+ (three-dot
235 # `git diff base...head`, the same range a pull request shows). Returns the
236 # diff String ("" when the refs are identical), or nil when either ref is
237 # unknown, unsafe, or the repository is missing.
238 def self.diff(path, base, head)
239 return nil unless safe_rev?(base) && safe_rev?(head)
240
241 out, _err, status = Open3.capture3("git", "--git-dir", path, "diff", "#{base}...#{head}")
242 return nil unless status.success?
243 out
244 end
245
246 def self.branch_exists?(path, branch)
247 return false unless safe_rev?(branch)
248 _out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch)
249 status.success?
250 end
251
252 def self.branches(path)
253 out, _err, status = Open3.capture3("git", "--git-dir", path, "branch", "--format=%(refname:short)")
254 return [] unless status.success?
255 out.lines.map(&:strip).reject(&:blank?)
256 end
257
258 def self.commit_count(path, branch)
259 return 0 unless safe_rev?(branch)
260 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-list", "--count", branch)
261 return 0 unless status.success?
262 out.strip.to_i
263 end
264
265 # SHA of the tip commit on +branch+, or nil. Cheap single call — used as the
266 # content version when caching derived artifacts (e.g. Open Graph cards) so
267 # they regenerate exactly when the repository content changes.
268 def self.head_sha(path, branch)
269 return nil unless safe_rev?(branch)
270 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "#{branch}^{commit}")
271 status.success? ? out.strip.presence : nil
272 end
273
274 # Flat list of every tracked file path on +branch+. One `ls-tree -r` call;
275 # used for cheap primary-language detection.
276 def self.tree_filenames(path, branch)
277 return [] unless safe_rev?(branch)
278 out, _err, status = Open3.capture3(
279 "git", "--git-dir", path, "ls-tree", "-r", "--name-only", branch
280 )
281 return [] unless status.success?
282 out.lines.map(&:strip).reject(&:blank?)
283 end
284
285 # Commits reachable from +head+ but not from +base+ — the commit list a pull
286 # request shows (same semantics as `git log base..head`).
287 def self.commits_between(path, base, head, limit: 250)
288 return [] unless safe_rev?(base) && safe_rev?(head)
289 format = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
290 out, _err, status = Open3.capture3(
291 "git", "--git-dir", path, "log",
292 "--format=#{format}", "--date=iso-strict",
293 "-n", limit.to_s,
294 "#{base}..#{head}"
295 )
296 return [] unless status.success?
297 out.lines.filter_map do |line|
298 parts = line.chomp.split("\x00")
299 next unless parts.length == 7
300 sha, short_sha, subject, author_name, author_email, authored_date, committer_name = parts
301 {
302 sha: sha,
303 short_sha: short_sha,
304 subject: subject,
305 author_name: author_name,
306 author_email: author_email,
307 authored_date: Time.parse(authored_date),
308 committer_name: committer_name
309 }
310 end
311 end
312
313 # True if +head+ can be merged into +base+ without conflicts. Uses
314 # `merge-tree --write-tree`, which computes the merge without touching any
315 # working tree or index — safe to run directly against a bare repo.
316 def self.mergeable?(path, base, head)
317 return false unless safe_rev?(base) && safe_rev?(head)
318 _out, _err, status = Open3.capture3("git", "--git-dir", path, "merge-tree", "--write-tree", base, head)
319 status.success?
320 end
321
322 # Merges +head+ into +base+ as a new merge commit (always --no-ff), purely
323 # via plumbing so no working tree is needed — the repos behind the app are
324 # bare. Returns the new commit SHA, or nil if either ref is missing, the
325 # merge has conflicts, or +base+ moved underneath us (a concurrent push, so
326 # the compare-and-swap ref update was rejected).
327 def self.merge!(path, base:, head:, message:, author_name:, author_email:)
328 return nil unless safe_rev?(base) && safe_rev?(head)
329
330 base_sha = commit_sha(path, base)
331 head_sha = commit_sha(path, head)
332 return nil unless base_sha && head_sha
333
334 tree_out, _err, tree_status = Open3.capture3(
335 "git", "--git-dir", path, "merge-tree", "--write-tree", base_sha, head_sha
336 )
337 return nil unless tree_status.success?
338 tree_sha = tree_out.lines.first.to_s.strip
339
340 env = {
341 "GIT_AUTHOR_NAME" => author_name, "GIT_AUTHOR_EMAIL" => author_email,
342 "GIT_COMMITTER_NAME" => author_name, "GIT_COMMITTER_EMAIL" => author_email
343 }
344 commit_out, _err, commit_status = Open3.capture3(
345 env, "git", "--git-dir", path, "commit-tree", tree_sha, "-p", base_sha, "-p", head_sha, "-m", message
346 )
347 return nil unless commit_status.success?
348 merge_sha = commit_out.strip
349
350 _out, _err, update_status = Open3.capture3(
351 "git", "--git-dir", path, "update-ref", "refs/heads/#{base}", merge_sha, base_sha
352 )
353 update_status.success? ? merge_sha : nil
354 end
355 end