diff options
| author | mo khan <mo@mokhan.ca> | 2025-12-11 16:27:45 -0700 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-12-11 16:27:45 -0700 |
| commit | de14376f33de34e27176a7492050ac1f99867648 (patch) | |
| tree | da980a7de3342deb859d17875fc90282e19d8ec7 /lib | |
| parent | ff11ed9e4078a18de5e0012a2b32c3f1440a84ba (diff) | |
feat: add subcommand to generate and run serverv0.2.0
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/gitem.rb | 253 | ||||
| -rw-r--r-- | lib/gitem/cli.rb | 98 | ||||
| -rw-r--r-- | lib/gitem/generator.rb | 181 | ||||
| -rw-r--r-- | lib/gitem/index.html (renamed from lib/gitem/index.html.erb) | 0 | ||||
| -rw-r--r-- | lib/gitem/server.rb | 25 | ||||
| -rw-r--r-- | lib/gitem/version.rb | 2 |
6 files changed, 316 insertions, 243 deletions
diff --git a/lib/gitem.rb b/lib/gitem.rb index bc79f67..9fd6249 100644 --- a/lib/gitem.rb +++ b/lib/gitem.rb @@ -1,250 +1,19 @@ # frozen_string_literal: true -require 'fileutils' -require 'json' -require 'rugged' -require 'time' +require "fileutils" +require "json" +require "optparse" +require "rbconfig" +require "rugged" +require "set" +require "time" +require "webrick" require_relative "gitem/version" +require_relative "gitem/generator" +require_relative "gitem/server" +require_relative "gitem/cli" module Gitem class Error < StandardError; end - - 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/lib/gitem/cli.rb b/lib/gitem/cli.rb new file mode 100644 index 0000000..a522163 --- /dev/null +++ b/lib/gitem/cli.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Gitem + class CLI + def initialize(argv) + @argv = argv.dup + @options = { output: nil, port: 8000, generate: true, open: false } + end + + def run + command = @argv.shift || "serve" + case command + when "generate", "g" then run_generate + when "serve", "s" then run_serve + when "help", "-h", "--help" then print_help + when "version", "-v", "--version" then puts "gitem #{VERSION}" + else + warn "Unknown command: #{command}" + print_help + exit 1 + end + rescue Rugged::Error, Rugged::RepositoryError => e + warn "Git error: #{e.message}" + exit 1 + rescue Error => e + warn "Error: #{e.message}" + exit 1 + end + + private + + def run_generate + parse_generate_options! + validate_repo! + Generator.new(@options[:repo_path], @options[:output]).export! + end + + def run_serve + parse_serve_options! + validate_repo! + generator = Generator.new(@options[:repo_path], @options[:output]) + generator.export! if @options[:generate] + server = Server.new(generator.output_dir, @options[:port]) + open_browser(server.url) if @options[:open] + server.start + end + + def parse_generate_options! + OptionParser.new do |opts| + opts.banner = "Usage: gitem generate [REPO_PATH] [options]" + opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v } + opts.on("-h", "--help", "Show help") { puts opts; exit } + end.parse!(@argv) + @options[:repo_path] = @argv.shift || "." + end + + def parse_serve_options! + OptionParser.new do |opts| + opts.banner = "Usage: gitem serve [REPO_PATH] [options]" + opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v } + opts.on("-p", "--port PORT", Integer, "Port (default: 8000)") { |v| @options[:port] = v } + opts.on("--[no-]generate", "Generate before serving") { |v| @options[:generate] = v } + opts.on("--open", "Open browser") { @options[:open] = true } + opts.on("-h", "--help", "Show help") { puts opts; exit } + end.parse!(@argv) + @options[:repo_path] = @argv.shift || "." + end + + def validate_repo! + path = @options[:repo_path] + return if File.exist?(File.join(path, ".git")) || File.exist?(File.join(path, "HEAD")) + raise Error, "'#{path}' is not a valid git repository" + end + + def open_browser(url) + cmd = case RbConfig::CONFIG["host_os"] + when /darwin/i then "open" + when /linux/i then "xdg-open" + when /mswin|mingw|cygwin/i then "start" + end + system("#{cmd} #{url} > /dev/null 2>&1 &") if cmd + end + + def print_help + puts <<~HELP + Usage: gitem <command> [options] + + Commands: + serve, s Generate and serve (default) + generate, g Generate JSON files only + help Show help + version Show version + + Run 'gitem <command> --help' for options. + HELP + end + end +end diff --git a/lib/gitem/generator.rb b/lib/gitem/generator.rb new file mode 100644 index 0000000..e8f483a --- /dev/null +++ b/lib/gitem/generator.rb @@ -0,0 +1,181 @@ +# 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) + @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 + 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) + write_json("repo.json", { + 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 + }) + 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 diff --git a/lib/gitem/index.html.erb b/lib/gitem/index.html index 3f885a6..3f885a6 100644 --- a/lib/gitem/index.html.erb +++ b/lib/gitem/index.html diff --git a/lib/gitem/server.rb b/lib/gitem/server.rb new file mode 100644 index 0000000..c73ff56 --- /dev/null +++ b/lib/gitem/server.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitem + class Server + attr_reader :url + + def initialize(root, port = 8000) + @root = root + @port = port + @url = "http://localhost:#{port}" + end + + def start + puts "š Server running at #{@url}" + puts " Press Ctrl+C to stop\n\n" + server = WEBrick::HTTPServer.new( + Port: @port, DocumentRoot: @root, + Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN), AccessLog: [] + ) + trap("INT") { server.shutdown } + trap("TERM") { server.shutdown } + server.start + end + end +end diff --git a/lib/gitem/version.rb b/lib/gitem/version.rb index 25f4495..a56774b 100644 --- a/lib/gitem/version.rb +++ b/lib/gitem/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Gitem - VERSION = "0.1.0" + VERSION = "0.2.0" end |
