# frozen_string_literal: true module Gitem class Generator TEMPLATE_PATH = File.expand_path("index.html", __dir__) attr_reader :output_dir def initialize(repo_path, output_dir = nil, base_path = nil) @repo = Rugged::Repository.new(repo_path) @output_dir = output_dir || File.join(@repo.path, "srv") @base_path = base_path @processed_trees = Set.new @processed_blobs = Set.new end def export! setup_directories export_branches export_tags export_commits export_repo_info copy_template puts "✓ Generated: #{@output_dir}" end private def setup_directories %w[commits trees blobs refs/heads refs/tags].each do |dir| FileUtils.mkdir_p(File.join(@output_dir, dir)) end end def copy_template FileUtils.cp(TEMPLATE_PATH, File.join(@output_dir, "index.html")) end def export_repo_info branch = @repo.branches[default_branch_name] readme_content, readme_name = extract_readme(branch) info = { name: repo_name, default_branch: default_branch_name, branches_count: local_branches.size, tags_count: @repo.tags.count, readme: readme_content, readme_name: readme_name, generated_at: Time.now.iso8601 } info[:base_path] = @base_path if @base_path write_json("repo.json", info) end def repo_name File.basename(@repo.workdir || @repo.path.chomp("/.git/").chomp(".git")) end def default_branch_name @default_branch_name ||= %w[main master].find { |n| @repo.branches[n] } || local_branches.first&.name || "main" end def local_branches @local_branches ||= @repo.branches.select { |b| b.name && !b.name.include?("/") } end def extract_readme(branch) return [nil, nil] unless branch&.target tree = branch.target.tree %w[README.md README.markdown readme.md README.txt README].each do |name| entry = tree.each.find { |e| e[:name].casecmp?(name) } next unless entry&.dig(:type) == :blob blob = @repo.lookup(entry[:oid]) next if blob.binary? return [blob.content.encode("UTF-8", invalid: :replace, undef: :replace), entry[:name]] end [nil, nil] end def export_branches branches = local_branches.filter_map do |branch| target = branch.target next unless target { name: branch.name, sha: target.oid, is_head: branch.head?, committed_at: target.committer[:time].iso8601, author: target.author[:name], message: target.message.lines.first&.strip || "" } end.sort_by { |b| b[:is_head] ? 0 : 1 } write_json("branches.json", branches) branches.each { |b| write_json("refs/heads/#{b[:name]}.json", b) } end def export_tags tags = @repo.tags.filter_map do |tag| target = tag.target next unless target commit = target.is_a?(Rugged::Tag::Annotation) ? target.target : target { name: tag.name, sha: commit.oid, annotated: target.is_a?(Rugged::Tag::Annotation), message: target.is_a?(Rugged::Tag::Annotation) ? target.message : nil, committed_at: commit.committer[:time].iso8601 } end.sort_by { |t| t[:committed_at] }.reverse write_json("tags.json", tags) tags.each { |t| write_json("refs/tags/#{t[:name]}.json", t) } end def export_commits commits_list = [] walker = Rugged::Walker.new(@repo) @repo.branches.each { |b| walker.push(b.target.oid) if b.target rescue nil } walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_DATE) walker.each_with_index do |commit, idx| print "\r Processing commits: #{idx + 1}" if ((idx + 1) % 50).zero? data = extract_commit(commit) commits_list << data.slice(:sha, :short_sha, :message_headline, :author, :committed_at) write_json("commits/#{commit.oid}.json", data) export_tree(commit.tree, "") end commits_list.sort_by! { |c| c[:committed_at] }.reverse! write_json("commits.json", commits_list) puts "\r Processed #{commits_list.size} commits" end def extract_commit(commit) stats = commit.parents.empty? ? initial_diff_stats(commit) : parent_diff_stats(commit) { sha: commit.oid, short_sha: commit.oid[0, 7], message: commit.message, message_headline: commit.message.lines.first&.strip || "", author: { name: commit.author[:name], email: commit.author[:email], date: commit.author[:time].iso8601 }, committer: { name: commit.committer[:name], email: commit.committer[:email], date: commit.committer[:time].iso8601 }, committed_at: commit.committer[:time].iso8601, parents: commit.parents.map { |p| { sha: p.oid, short_sha: p.oid[0, 7] } }, tree_sha: commit.tree.oid, stats: stats[:stats], files: stats[:files] } end def initial_diff_stats(commit) files = [] collect_tree_files(commit.tree, "", files) { stats: { additions: files.sum { |f| f[:additions] }, deletions: 0, changed: files.size }, files: files } end def collect_tree_files(tree, path, files) tree.each do |entry| full_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}" if entry[:type] == :blob blob = @repo.lookup(entry[:oid]) files << { path: full_path, additions: blob.binary? ? 0 : blob.content.lines.count, deletions: 0, status: "added" } elsif entry[:type] == :tree collect_tree_files(@repo.lookup(entry[:oid]), full_path, files) end end end def parent_diff_stats(commit) diff = commit.parents.first.diff(commit) files, additions, deletions = [], 0, 0 diff.each_patch do |patch| fa, fd = 0, 0 patch.each_hunk { |h| h.each_line { |l| fa += 1 if l.addition?; fd += 1 if l.deletion? } } additions += fa; deletions += fd status = { added: "added", deleted: "deleted", renamed: "renamed" }[patch.delta.status] || "modified" files << { path: patch.delta.new_file[:path], additions: fa, deletions: fd, status: status } end { stats: { additions: additions, deletions: deletions, changed: files.size }, files: files } end def export_tree(tree, path) return if @processed_trees.include?(tree.oid) @processed_trees.add(tree.oid) entries = tree.map do |entry| entry_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}" export_tree(@repo.lookup(entry[:oid]), entry_path) if entry[:type] == :tree export_blob(entry[:oid], entry_path) if entry[:type] == :blob { name: entry[:name], path: entry_path, type: entry[:type].to_s, sha: entry[:oid], mode: entry[:filemode].to_s(8) } end write_json("trees/#{tree.oid}.json", { sha: tree.oid, path: path, entries: entries }) end def export_blob(oid, path) return if @processed_blobs.include?(oid) @processed_blobs.add(oid) blob = @repo.lookup(oid) content = blob.binary? ? nil : blob.content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?") content = "#{content[0, 100_000]}\n... [truncated]" if content && content.size > 100_000 write_json("blobs/#{oid}.json", { sha: oid, path: path, size: blob.size, binary: blob.binary?, content: content, truncated: !blob.binary? && blob.size > 100_000 }) end def write_json(path, data) File.write(File.join(@output_dir, path), JSON.generate(data)) end end end