Commit 88617d0
Changed files (5)
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