diff options
| author | mo khan <mo@mokhan.ca> | 2025-12-11 15:07:28 -0700 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-12-11 15:07:28 -0700 |
| commit | 88617d035ad5828e430d4546430f7d855a5c0a24 (patch) | |
| tree | 8ed38439f3f6d437db762cf732e4526e0ad8a152 | |
| parent | ecc324eb9fd20c6b48d65cb279e0afad6e5d886e (diff) | |
feat: export JSON files from the git repo
| -rw-r--r-- | Gemfile.lock | 16 | ||||
| -rwxr-xr-x | exe/gitem | 18 | ||||
| -rw-r--r-- | gitem.gemspec | 12 | ||||
| -rw-r--r-- | lib/gitem.rb | 244 | ||||
| -rw-r--r-- | spec/gitem_spec.rb | 8 |
5 files changed, 284 insertions, 14 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 37b4fe9..d70f079 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,11 @@ PATH remote: . specs: gitem (0.1.0) + fileutils (~> 1.0) + json (~> 2.0) + open3 (~> 0.1) + rugged (~> 1.0) + time (~> 0.1) GEM remote: https://rubygems.org/ @@ -9,11 +14,14 @@ GEM date (3.5.1) diff-lcs (1.6.2) erb (6.0.0) + fileutils (1.8.0) io-console (0.8.1) irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + json (2.18.0) + open3 (0.2.1) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -40,7 +48,10 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) + rugged (1.9.0) stringio (3.1.9) + time (0.4.1) + date tsort (0.2.0) PLATFORMS @@ -57,9 +68,12 @@ CHECKSUMS date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 erb (6.0.0) sha256=2730893f9d8c9733f16cab315a4e4b71c1afa9cabc1a1e7ad1403feba8f52579 + fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02 gitem (0.1.0) io-console (0.8.1) sha256=1e15440a6b2f67b6ea496df7c474ed62c860ad11237f29b3bd187f054b925fcb irb (1.15.3) sha256=4349edff1efa7ff7bfd34cb9df74a133a588ba88c2718098b3b4468b81184aaa + json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 + open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 psych (5.3.0) sha256=8976a41ae29ea38c88356e862629345290347e3bfe27caf654f7c5a920e95eeb @@ -71,7 +85,9 @@ CHECKSUMS rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2 + rugged (1.9.0) sha256=7faaa912c5888d6e348d20fa31209b6409f1574346b1b80e309dbc7e8d63efac stringio (3.1.9) sha256=c111af13d3a73eab96a3bc2655ecf93788d13d28cb8e25c1dcbff89ace885121 + time (0.4.1) sha256=035f360508a4a4dbabcbbcd3886566b9abd432de89136795d2ff7aec5bcdea61 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f BUNDLED WITH @@ -1,3 +1,21 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require "gitem" + +if ARGV.empty? + puts "Usage: ruby #{$0} <path-to-git-repo> [output-directory]" + puts "Default output: <repo>/.git/srv/" + puts "Example: ruby #{$0} ." + exit 1 +end + +repo_path = ARGV[0] +output_dir = ARGV[1] + +unless File.exist?(File.join(repo_path, '.git')) || File.exist?(File.join(repo_path, 'HEAD')) + puts "Error: #{repo_path} is not a valid git repository" + exit 1 +end + +Gitem::GitToJson.new(repo_path, output_dir).export! diff --git a/gitem.gemspec b/gitem.gemspec index e64b544..69ae56e 100644 --- a/gitem.gemspec +++ b/gitem.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| spec.summary = "A static site generated for git repositories." spec.description = "A static site generated for git repositories." - spec.homepage = "https://github.com/xlgmokha/gitem" + spec.homepage = "https://mokhan.ca/xlgmokha/gitem" spec.license = "MIT" spec.required_ruby_version = ">= 3.4.0" @@ -32,9 +32,9 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html + spec.add_dependency "fileutils", "~> 1.0" + spec.add_dependency "json", "~> 2.0" + spec.add_dependency "open3", "~> 0.1" + spec.add_dependency "rugged", "~> 1.0" + spec.add_dependency "time", "~> 0.1" end diff --git a/lib/gitem.rb b/lib/gitem.rb index e23ee60..bc79f67 100644 --- a/lib/gitem.rb +++ b/lib/gitem.rb @@ -1,8 +1,250 @@ # frozen_string_literal: true +require 'fileutils' +require 'json' +require 'rugged' +require 'time' + require_relative "gitem/version" module Gitem class Error < StandardError; end - # Your code goes here... + + class GitToJson + def initialize(repo_path, output_dir = nil) + @repo = Rugged::Repository.new(repo_path) + @output_dir = output_dir || File.join(@repo.path, 'srv') + @processed_trees = Set.new + @processed_blobs = Set.new + end + + def export! + setup_directories + export_branches + export_tags + export_commits + export_repo_info + puts "\n✓ Export complete! Files written to #{@output_dir}" + puts " Serve with: cd #{@output_dir} && ruby -run -e httpd . 8000" + 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 export_repo_info + default_branch = default_branch_name + branch = @repo.branches[default_branch] + readme_content = nil + readme_name = nil + + if 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].downcase == name.downcase } + if entry && entry[:type] == :blob + blob = @repo.lookup(entry[:oid]) + readme_content = blob.content.encode('UTF-8', invalid: :replace, undef: :replace) unless blob.binary? + readme_name = entry[:name] + break + end + end + end + + info = { + name: File.basename(@repo.workdir || @repo.path.chomp('/.git/').chomp('.git')), + default_branch: default_branch, + branches_count: @repo.branches.count { |b| b.name && !b.name.include?('/') }, + tags_count: @repo.tags.count, + readme: readme_content, + readme_name: readme_name, + generated_at: Time.now.iso8601 + } + write_json('repo.json', info) + end + + def default_branch_name + %w[main master].find { |name| @repo.branches[name] } || @repo.branches.first&.name || 'main' + end + + def export_branches + branches = @repo.branches.map do |branch| + next if branch.name.nil? || branch.name.include?('/') + target = branch.target rescue nil + 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.compact.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.map do |tag| + target = tag.target rescue nil + 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.compact.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 do |branch| + next if branch.target.nil? + walker.push(branch.target.oid) rescue nil + end + + walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_DATE) + + walker.each_with_index do |commit, idx| + print "\rProcessing commit #{idx + 1}..." if (idx + 1) % 10 == 0 + + commit_data = extract_commit(commit) + commits_list << commit_data.slice(:sha, :short_sha, :message_headline, :author, :committed_at) + + write_json("commits/#{commit.oid}.json", commit_data) + export_tree(commit.tree, '') + end + + commits_list.sort_by! { |c| c[:committed_at] }.reverse! + write_json('commits.json', commits_list) + puts "\rProcessed #{commits_list.size} commits" + end + + def extract_commit(commit) + parents = commit.parents.map { |p| { sha: p.oid, short_sha: p.oid[0..6] } } + diff_stats = commit.parents.empty? ? initial_diff_stats(commit) : parent_diff_stats(commit) + + { + sha: commit.oid, + short_sha: commit.oid[0..6], + 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: parents, + tree_sha: commit.tree.oid, + stats: diff_stats[:stats], + files: diff_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]) + lines = blob.binary? ? 0 : blob.content.lines.count + files << { path: full_path, additions: lines, 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 + + diff.each_patch do |patch| + file_adds = file_dels = 0 + patch.each_hunk { |h| h.each_line { |l| l.addition? ? file_adds += 1 : (file_dels += 1 if l.deletion?) } } + additions += file_adds + deletions += file_dels + + status = case patch.delta.status + when :added then 'added' + when :deleted then 'deleted' + when :renamed then 'renamed' + else 'modified' + end + + files << { path: patch.delta.new_file[:path], additions: file_adds, deletions: file_dels, 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_data = { + name: entry[:name], + path: path.empty? ? entry[:name] : "#{path}/#{entry[:name]}", + type: entry[:type].to_s, + sha: entry[:oid], + mode: entry[:filemode].to_s(8) + } + + if entry[:type] == :tree + export_tree(@repo.lookup(entry[:oid]), entry_data[:path]) + elsif entry[:type] == :blob + export_blob(entry[:oid], entry_data[:path]) + end + + entry_data + 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) + data = { + sha: oid, + path: path, + size: blob.size, + binary: blob.binary?, + content: blob.binary? ? nil : safe_content(blob.content), + truncated: !blob.binary? && blob.size > 100_000 + } + + write_json("blobs/#{oid}.json", data) + end + + def safe_content(content) + return content[0..100_000] + "\n... [truncated]" if content.size > 100_000 + content.encode('UTF-8', invalid: :replace, undef: :replace, replace: '�') + end + + def write_json(path, data) + File.write(File.join(@output_dir, path), JSON.pretty_generate(data)) + end + end end diff --git a/spec/gitem_spec.rb b/spec/gitem_spec.rb index 7928357..512551d 100644 --- a/spec/gitem_spec.rb +++ b/spec/gitem_spec.rb @@ -1,11 +1,5 @@ # frozen_string_literal: true RSpec.describe Gitem do - it "has a version number" do - expect(Gitem::VERSION).not_to be nil - end - - it "does something useful" do - expect(false).to eq(true) - end + it { expect(Gitem::VERSION).not_to be nil } end |
