summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2025-12-11 16:27:45 -0700
committermo khan <mo@mokhan.ca>2025-12-11 16:27:45 -0700
commitde14376f33de34e27176a7492050ac1f99867648 (patch)
treeda980a7de3342deb859d17875fc90282e19d8ec7 /lib
parentff11ed9e4078a18de5e0012a2b32c3f1440a84ba (diff)
feat: add subcommand to generate and run serverv0.2.0
Diffstat (limited to 'lib')
-rw-r--r--lib/gitem.rb253
-rw-r--r--lib/gitem/cli.rb98
-rw-r--r--lib/gitem/generator.rb181
-rw-r--r--lib/gitem/index.html (renamed from lib/gitem/index.html.erb)0
-rw-r--r--lib/gitem/server.rb25
-rw-r--r--lib/gitem/version.rb2
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