Commit 88617d0

mo khan <mo@mokhan.ca>
2025-12-11 22:07:28
feat: export JSON files from the git repo
1 parent ecc324e
exe/gitem
@@ -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!
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
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
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
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