gitem 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +29 -20
- data/exe/gitem +1 -17
- data/lib/gitem/cli.rb +98 -0
- data/lib/gitem/generator.rb +181 -0
- data/lib/gitem/server.rb +25 -0
- data/lib/gitem/version.rb +1 -1
- data/lib/gitem.rb +11 -242
- metadata +14 -24
- /data/lib/gitem/{index.html.erb → index.html} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79d05206bb7ce01fa86ea94f2fc1342912200ce83e7a00650bc1393a427cbe8e
|
|
4
|
+
data.tar.gz: 5a370c3907e190b010fe694302f94004a28e6810ac4d85bd5cf76d6d1bac05c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 425331cceea1d9129d13c37587f3f37fa1dabeed77ed66c86119e9f0de4a7f4f93e1f64a031696d5e8ae2ef0001314bd52bd6b5d9a553bdfaef9bced5dff72c6
|
|
7
|
+
data.tar.gz: ce2f0b7ad472d6d4efa0686507ef7b1414aff3c21224effc4f6d206de3014afe9b380f2d62ec64360a40fc5ba6f09bfc06d6e247ab03d745199e9eb582c303f8
|
data/README.md
CHANGED
|
@@ -1,39 +1,48 @@
|
|
|
1
1
|
# Gitem
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/gitem`. To experiment with that code, run `bin/console` for an interactive prompt.
|
|
3
|
+
A static site generator for git repositories. Browse your git history locally with a GitHub-like interface.
|
|
6
4
|
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Install the gem and add to the application's Gemfile by executing:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
7
|
+
```
|
|
8
|
+
gem install gitem
|
|
15
9
|
```
|
|
16
10
|
|
|
17
|
-
|
|
11
|
+
## Usage
|
|
18
12
|
|
|
19
|
-
```
|
|
20
|
-
|
|
13
|
+
```
|
|
14
|
+
gitem serve # Generate and serve (default)
|
|
15
|
+
gitem serve -p 3000 # Custom port
|
|
16
|
+
gitem serve --open # Open browser
|
|
17
|
+
gitem serve --no-generate # Serve only
|
|
18
|
+
gitem generate # Generate only
|
|
19
|
+
gitem generate -o ./out # Custom output
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
##
|
|
22
|
+
## Requirements
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
- Ruby >= 3.4.0
|
|
25
|
+
- libgit2
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
### macOS
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
```
|
|
30
|
+
brew install libgit2
|
|
31
|
+
```
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
### Ubuntu/Debian
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
apt-get install libgit2-dev cmake
|
|
37
|
+
```
|
|
32
38
|
|
|
33
|
-
##
|
|
39
|
+
## Development
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
```
|
|
42
|
+
bin/setup
|
|
43
|
+
rake spec
|
|
44
|
+
```
|
|
36
45
|
|
|
37
46
|
## License
|
|
38
47
|
|
|
39
|
-
|
|
48
|
+
[MIT](https://opensource.org/licenses/MIT)
|
data/exe/gitem
CHANGED
|
@@ -2,20 +2,4 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "gitem"
|
|
5
|
-
|
|
6
|
-
if ARGV.empty?
|
|
7
|
-
puts "Usage: ruby #{$0} <path-to-git-repo> [output-directory]"
|
|
8
|
-
puts "Default output: <repo>/.git/srv/"
|
|
9
|
-
puts "Example: ruby #{$0} ."
|
|
10
|
-
exit 1
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
repo_path = ARGV[0]
|
|
14
|
-
output_dir = ARGV[1]
|
|
15
|
-
|
|
16
|
-
unless File.exist?(File.join(repo_path, '.git')) || File.exist?(File.join(repo_path, 'HEAD'))
|
|
17
|
-
puts "Error: #{repo_path} is not a valid git repository"
|
|
18
|
-
exit 1
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
Gitem::GitToJson.new(repo_path, output_dir).export!
|
|
5
|
+
Gitem::CLI.new(ARGV).run
|
data/lib/gitem/cli.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitem
|
|
4
|
+
class CLI
|
|
5
|
+
def initialize(argv)
|
|
6
|
+
@argv = argv.dup
|
|
7
|
+
@options = { output: nil, port: 8000, generate: true, open: false }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run
|
|
11
|
+
command = @argv.shift || "serve"
|
|
12
|
+
case command
|
|
13
|
+
when "generate", "g" then run_generate
|
|
14
|
+
when "serve", "s" then run_serve
|
|
15
|
+
when "help", "-h", "--help" then print_help
|
|
16
|
+
when "version", "-v", "--version" then puts "gitem #{VERSION}"
|
|
17
|
+
else
|
|
18
|
+
warn "Unknown command: #{command}"
|
|
19
|
+
print_help
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
rescue Rugged::Error, Rugged::RepositoryError => e
|
|
23
|
+
warn "Git error: #{e.message}"
|
|
24
|
+
exit 1
|
|
25
|
+
rescue Error => e
|
|
26
|
+
warn "Error: #{e.message}"
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run_generate
|
|
33
|
+
parse_generate_options!
|
|
34
|
+
validate_repo!
|
|
35
|
+
Generator.new(@options[:repo_path], @options[:output]).export!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_serve
|
|
39
|
+
parse_serve_options!
|
|
40
|
+
validate_repo!
|
|
41
|
+
generator = Generator.new(@options[:repo_path], @options[:output])
|
|
42
|
+
generator.export! if @options[:generate]
|
|
43
|
+
server = Server.new(generator.output_dir, @options[:port])
|
|
44
|
+
open_browser(server.url) if @options[:open]
|
|
45
|
+
server.start
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_generate_options!
|
|
49
|
+
OptionParser.new do |opts|
|
|
50
|
+
opts.banner = "Usage: gitem generate [REPO_PATH] [options]"
|
|
51
|
+
opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v }
|
|
52
|
+
opts.on("-h", "--help", "Show help") { puts opts; exit }
|
|
53
|
+
end.parse!(@argv)
|
|
54
|
+
@options[:repo_path] = @argv.shift || "."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_serve_options!
|
|
58
|
+
OptionParser.new do |opts|
|
|
59
|
+
opts.banner = "Usage: gitem serve [REPO_PATH] [options]"
|
|
60
|
+
opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v }
|
|
61
|
+
opts.on("-p", "--port PORT", Integer, "Port (default: 8000)") { |v| @options[:port] = v }
|
|
62
|
+
opts.on("--[no-]generate", "Generate before serving") { |v| @options[:generate] = v }
|
|
63
|
+
opts.on("--open", "Open browser") { @options[:open] = true }
|
|
64
|
+
opts.on("-h", "--help", "Show help") { puts opts; exit }
|
|
65
|
+
end.parse!(@argv)
|
|
66
|
+
@options[:repo_path] = @argv.shift || "."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_repo!
|
|
70
|
+
path = @options[:repo_path]
|
|
71
|
+
return if File.exist?(File.join(path, ".git")) || File.exist?(File.join(path, "HEAD"))
|
|
72
|
+
raise Error, "'#{path}' is not a valid git repository"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def open_browser(url)
|
|
76
|
+
cmd = case RbConfig::CONFIG["host_os"]
|
|
77
|
+
when /darwin/i then "open"
|
|
78
|
+
when /linux/i then "xdg-open"
|
|
79
|
+
when /mswin|mingw|cygwin/i then "start"
|
|
80
|
+
end
|
|
81
|
+
system("#{cmd} #{url} > /dev/null 2>&1 &") if cmd
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def print_help
|
|
85
|
+
puts <<~HELP
|
|
86
|
+
Usage: gitem <command> [options]
|
|
87
|
+
|
|
88
|
+
Commands:
|
|
89
|
+
serve, s Generate and serve (default)
|
|
90
|
+
generate, g Generate JSON files only
|
|
91
|
+
help Show help
|
|
92
|
+
version Show version
|
|
93
|
+
|
|
94
|
+
Run 'gitem <command> --help' for options.
|
|
95
|
+
HELP
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitem
|
|
4
|
+
class Generator
|
|
5
|
+
TEMPLATE_PATH = File.expand_path("index.html", __dir__)
|
|
6
|
+
attr_reader :output_dir
|
|
7
|
+
|
|
8
|
+
def initialize(repo_path, output_dir = nil)
|
|
9
|
+
@repo = Rugged::Repository.new(repo_path)
|
|
10
|
+
@output_dir = output_dir || File.join(@repo.path, "srv")
|
|
11
|
+
@processed_trees = Set.new
|
|
12
|
+
@processed_blobs = Set.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def export!
|
|
16
|
+
setup_directories
|
|
17
|
+
export_branches
|
|
18
|
+
export_tags
|
|
19
|
+
export_commits
|
|
20
|
+
export_repo_info
|
|
21
|
+
copy_template
|
|
22
|
+
puts "✓ Generated: #{@output_dir}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def setup_directories
|
|
28
|
+
%w[commits trees blobs refs/heads refs/tags].each do |dir|
|
|
29
|
+
FileUtils.mkdir_p(File.join(@output_dir, dir))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def copy_template
|
|
34
|
+
FileUtils.cp(TEMPLATE_PATH, File.join(@output_dir, "index.html"))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def export_repo_info
|
|
38
|
+
branch = @repo.branches[default_branch_name]
|
|
39
|
+
readme_content, readme_name = extract_readme(branch)
|
|
40
|
+
write_json("repo.json", {
|
|
41
|
+
name: repo_name, default_branch: default_branch_name,
|
|
42
|
+
branches_count: local_branches.size, tags_count: @repo.tags.count,
|
|
43
|
+
readme: readme_content, readme_name: readme_name, generated_at: Time.now.iso8601
|
|
44
|
+
})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def repo_name
|
|
48
|
+
File.basename(@repo.workdir || @repo.path.chomp("/.git/").chomp(".git"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def default_branch_name
|
|
52
|
+
@default_branch_name ||= %w[main master].find { |n| @repo.branches[n] } || local_branches.first&.name || "main"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def local_branches
|
|
56
|
+
@local_branches ||= @repo.branches.select { |b| b.name && !b.name.include?("/") }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_readme(branch)
|
|
60
|
+
return [nil, nil] unless branch&.target
|
|
61
|
+
tree = branch.target.tree
|
|
62
|
+
%w[README.md README.markdown readme.md README.txt README].each do |name|
|
|
63
|
+
entry = tree.each.find { |e| e[:name].casecmp?(name) }
|
|
64
|
+
next unless entry&.dig(:type) == :blob
|
|
65
|
+
blob = @repo.lookup(entry[:oid])
|
|
66
|
+
next if blob.binary?
|
|
67
|
+
return [blob.content.encode("UTF-8", invalid: :replace, undef: :replace), entry[:name]]
|
|
68
|
+
end
|
|
69
|
+
[nil, nil]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def export_branches
|
|
73
|
+
branches = local_branches.filter_map do |branch|
|
|
74
|
+
target = branch.target
|
|
75
|
+
next unless target
|
|
76
|
+
{ name: branch.name, sha: target.oid, is_head: branch.head?,
|
|
77
|
+
committed_at: target.committer[:time].iso8601, author: target.author[:name],
|
|
78
|
+
message: target.message.lines.first&.strip || "" }
|
|
79
|
+
end.sort_by { |b| b[:is_head] ? 0 : 1 }
|
|
80
|
+
write_json("branches.json", branches)
|
|
81
|
+
branches.each { |b| write_json("refs/heads/#{b[:name]}.json", b) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def export_tags
|
|
85
|
+
tags = @repo.tags.filter_map do |tag|
|
|
86
|
+
target = tag.target
|
|
87
|
+
next unless target
|
|
88
|
+
commit = target.is_a?(Rugged::Tag::Annotation) ? target.target : target
|
|
89
|
+
{ name: tag.name, sha: commit.oid, annotated: target.is_a?(Rugged::Tag::Annotation),
|
|
90
|
+
message: target.is_a?(Rugged::Tag::Annotation) ? target.message : nil,
|
|
91
|
+
committed_at: commit.committer[:time].iso8601 }
|
|
92
|
+
end.sort_by { |t| t[:committed_at] }.reverse
|
|
93
|
+
write_json("tags.json", tags)
|
|
94
|
+
tags.each { |t| write_json("refs/tags/#{t[:name]}.json", t) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def export_commits
|
|
98
|
+
commits_list = []
|
|
99
|
+
walker = Rugged::Walker.new(@repo)
|
|
100
|
+
@repo.branches.each { |b| walker.push(b.target.oid) if b.target rescue nil }
|
|
101
|
+
walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_DATE)
|
|
102
|
+
walker.each_with_index do |commit, idx|
|
|
103
|
+
print "\r Processing commits: #{idx + 1}" if ((idx + 1) % 50).zero?
|
|
104
|
+
data = extract_commit(commit)
|
|
105
|
+
commits_list << data.slice(:sha, :short_sha, :message_headline, :author, :committed_at)
|
|
106
|
+
write_json("commits/#{commit.oid}.json", data)
|
|
107
|
+
export_tree(commit.tree, "")
|
|
108
|
+
end
|
|
109
|
+
commits_list.sort_by! { |c| c[:committed_at] }.reverse!
|
|
110
|
+
write_json("commits.json", commits_list)
|
|
111
|
+
puts "\r Processed #{commits_list.size} commits"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_commit(commit)
|
|
115
|
+
stats = commit.parents.empty? ? initial_diff_stats(commit) : parent_diff_stats(commit)
|
|
116
|
+
{ sha: commit.oid, short_sha: commit.oid[0, 7], message: commit.message,
|
|
117
|
+
message_headline: commit.message.lines.first&.strip || "",
|
|
118
|
+
author: { name: commit.author[:name], email: commit.author[:email], date: commit.author[:time].iso8601 },
|
|
119
|
+
committer: { name: commit.committer[:name], email: commit.committer[:email], date: commit.committer[:time].iso8601 },
|
|
120
|
+
committed_at: commit.committer[:time].iso8601,
|
|
121
|
+
parents: commit.parents.map { |p| { sha: p.oid, short_sha: p.oid[0, 7] } },
|
|
122
|
+
tree_sha: commit.tree.oid, stats: stats[:stats], files: stats[:files] }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def initial_diff_stats(commit)
|
|
126
|
+
files = []
|
|
127
|
+
collect_tree_files(commit.tree, "", files)
|
|
128
|
+
{ stats: { additions: files.sum { |f| f[:additions] }, deletions: 0, changed: files.size }, files: files }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def collect_tree_files(tree, path, files)
|
|
132
|
+
tree.each do |entry|
|
|
133
|
+
full_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}"
|
|
134
|
+
if entry[:type] == :blob
|
|
135
|
+
blob = @repo.lookup(entry[:oid])
|
|
136
|
+
files << { path: full_path, additions: blob.binary? ? 0 : blob.content.lines.count, deletions: 0, status: "added" }
|
|
137
|
+
elsif entry[:type] == :tree
|
|
138
|
+
collect_tree_files(@repo.lookup(entry[:oid]), full_path, files)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parent_diff_stats(commit)
|
|
144
|
+
diff = commit.parents.first.diff(commit)
|
|
145
|
+
files, additions, deletions = [], 0, 0
|
|
146
|
+
diff.each_patch do |patch|
|
|
147
|
+
fa, fd = 0, 0
|
|
148
|
+
patch.each_hunk { |h| h.each_line { |l| fa += 1 if l.addition?; fd += 1 if l.deletion? } }
|
|
149
|
+
additions += fa; deletions += fd
|
|
150
|
+
status = { added: "added", deleted: "deleted", renamed: "renamed" }[patch.delta.status] || "modified"
|
|
151
|
+
files << { path: patch.delta.new_file[:path], additions: fa, deletions: fd, status: status }
|
|
152
|
+
end
|
|
153
|
+
{ stats: { additions: additions, deletions: deletions, changed: files.size }, files: files }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def export_tree(tree, path)
|
|
157
|
+
return if @processed_trees.include?(tree.oid)
|
|
158
|
+
@processed_trees.add(tree.oid)
|
|
159
|
+
entries = tree.map do |entry|
|
|
160
|
+
entry_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}"
|
|
161
|
+
export_tree(@repo.lookup(entry[:oid]), entry_path) if entry[:type] == :tree
|
|
162
|
+
export_blob(entry[:oid], entry_path) if entry[:type] == :blob
|
|
163
|
+
{ name: entry[:name], path: entry_path, type: entry[:type].to_s, sha: entry[:oid], mode: entry[:filemode].to_s(8) }
|
|
164
|
+
end
|
|
165
|
+
write_json("trees/#{tree.oid}.json", { sha: tree.oid, path: path, entries: entries })
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def export_blob(oid, path)
|
|
169
|
+
return if @processed_blobs.include?(oid)
|
|
170
|
+
@processed_blobs.add(oid)
|
|
171
|
+
blob = @repo.lookup(oid)
|
|
172
|
+
content = blob.binary? ? nil : blob.content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
173
|
+
content = "#{content[0, 100_000]}\n... [truncated]" if content && content.size > 100_000
|
|
174
|
+
write_json("blobs/#{oid}.json", { sha: oid, path: path, size: blob.size, binary: blob.binary?, content: content, truncated: !blob.binary? && blob.size > 100_000 })
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def write_json(path, data)
|
|
178
|
+
File.write(File.join(@output_dir, path), JSON.generate(data))
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
data/lib/gitem/server.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitem
|
|
4
|
+
class Server
|
|
5
|
+
attr_reader :url
|
|
6
|
+
|
|
7
|
+
def initialize(root, port = 8000)
|
|
8
|
+
@root = root
|
|
9
|
+
@port = port
|
|
10
|
+
@url = "http://localhost:#{port}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def start
|
|
14
|
+
puts "🌐 Server running at #{@url}"
|
|
15
|
+
puts " Press Ctrl+C to stop\n\n"
|
|
16
|
+
server = WEBrick::HTTPServer.new(
|
|
17
|
+
Port: @port, DocumentRoot: @root,
|
|
18
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN), AccessLog: []
|
|
19
|
+
)
|
|
20
|
+
trap("INT") { server.shutdown }
|
|
21
|
+
trap("TERM") { server.shutdown }
|
|
22
|
+
server.start
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/gitem/version.rb
CHANGED
data/lib/gitem.rb
CHANGED
|
@@ -1,250 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "rugged"
|
|
8
|
+
require "set"
|
|
9
|
+
require "time"
|
|
10
|
+
require "webrick"
|
|
7
11
|
|
|
8
12
|
require_relative "gitem/version"
|
|
13
|
+
require_relative "gitem/generator"
|
|
14
|
+
require_relative "gitem/server"
|
|
15
|
+
require_relative "gitem/cli"
|
|
9
16
|
|
|
10
17
|
module Gitem
|
|
11
18
|
class Error < StandardError; end
|
|
12
|
-
|
|
13
|
-
class GitToJson
|
|
14
|
-
def initialize(repo_path, output_dir = nil)
|
|
15
|
-
@repo = Rugged::Repository.new(repo_path)
|
|
16
|
-
@output_dir = output_dir || File.join(@repo.path, 'srv')
|
|
17
|
-
@processed_trees = Set.new
|
|
18
|
-
@processed_blobs = Set.new
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def export!
|
|
22
|
-
setup_directories
|
|
23
|
-
export_branches
|
|
24
|
-
export_tags
|
|
25
|
-
export_commits
|
|
26
|
-
export_repo_info
|
|
27
|
-
puts "\n✓ Export complete! Files written to #{@output_dir}"
|
|
28
|
-
puts " Serve with: cd #{@output_dir} && ruby -run -e httpd . 8000"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def setup_directories
|
|
34
|
-
%w[commits trees blobs refs/heads refs/tags].each do |dir|
|
|
35
|
-
FileUtils.mkdir_p(File.join(@output_dir, dir))
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def export_repo_info
|
|
40
|
-
default_branch = default_branch_name
|
|
41
|
-
branch = @repo.branches[default_branch]
|
|
42
|
-
readme_content = nil
|
|
43
|
-
readme_name = nil
|
|
44
|
-
|
|
45
|
-
if branch&.target
|
|
46
|
-
tree = branch.target.tree
|
|
47
|
-
%w[README.md README.markdown readme.md README.txt README].each do |name|
|
|
48
|
-
entry = tree.each.find { |e| e[:name].downcase == name.downcase }
|
|
49
|
-
if entry && entry[:type] == :blob
|
|
50
|
-
blob = @repo.lookup(entry[:oid])
|
|
51
|
-
readme_content = blob.content.encode('UTF-8', invalid: :replace, undef: :replace) unless blob.binary?
|
|
52
|
-
readme_name = entry[:name]
|
|
53
|
-
break
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
info = {
|
|
59
|
-
name: File.basename(@repo.workdir || @repo.path.chomp('/.git/').chomp('.git')),
|
|
60
|
-
default_branch: default_branch,
|
|
61
|
-
branches_count: @repo.branches.count { |b| b.name && !b.name.include?('/') },
|
|
62
|
-
tags_count: @repo.tags.count,
|
|
63
|
-
readme: readme_content,
|
|
64
|
-
readme_name: readme_name,
|
|
65
|
-
generated_at: Time.now.iso8601
|
|
66
|
-
}
|
|
67
|
-
write_json('repo.json', info)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def default_branch_name
|
|
71
|
-
%w[main master].find { |name| @repo.branches[name] } || @repo.branches.first&.name || 'main'
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def export_branches
|
|
75
|
-
branches = @repo.branches.map do |branch|
|
|
76
|
-
next if branch.name.nil? || branch.name.include?('/')
|
|
77
|
-
target = branch.target rescue nil
|
|
78
|
-
next unless target
|
|
79
|
-
{
|
|
80
|
-
name: branch.name,
|
|
81
|
-
sha: target.oid,
|
|
82
|
-
is_head: branch.head?,
|
|
83
|
-
committed_at: target.committer[:time].iso8601,
|
|
84
|
-
author: target.author[:name],
|
|
85
|
-
message: target.message.lines.first&.strip || ''
|
|
86
|
-
}
|
|
87
|
-
end.compact.sort_by { |b| b[:is_head] ? 0 : 1 }
|
|
88
|
-
|
|
89
|
-
write_json('branches.json', branches)
|
|
90
|
-
branches.each { |b| write_json("refs/heads/#{b[:name]}.json", b) }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def export_tags
|
|
94
|
-
tags = @repo.tags.map do |tag|
|
|
95
|
-
target = tag.target rescue nil
|
|
96
|
-
next unless target
|
|
97
|
-
commit = target.is_a?(Rugged::Tag::Annotation) ? target.target : target
|
|
98
|
-
{
|
|
99
|
-
name: tag.name,
|
|
100
|
-
sha: commit.oid,
|
|
101
|
-
annotated: target.is_a?(Rugged::Tag::Annotation),
|
|
102
|
-
message: target.is_a?(Rugged::Tag::Annotation) ? target.message : nil,
|
|
103
|
-
committed_at: commit.committer[:time].iso8601
|
|
104
|
-
}
|
|
105
|
-
end.compact.sort_by { |t| t[:committed_at] }.reverse
|
|
106
|
-
|
|
107
|
-
write_json('tags.json', tags)
|
|
108
|
-
tags.each { |t| write_json("refs/tags/#{t[:name]}.json", t) }
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def export_commits
|
|
112
|
-
commits_list = []
|
|
113
|
-
walker = Rugged::Walker.new(@repo)
|
|
114
|
-
|
|
115
|
-
@repo.branches.each do |branch|
|
|
116
|
-
next if branch.target.nil?
|
|
117
|
-
walker.push(branch.target.oid) rescue nil
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_DATE)
|
|
121
|
-
|
|
122
|
-
walker.each_with_index do |commit, idx|
|
|
123
|
-
print "\rProcessing commit #{idx + 1}..." if (idx + 1) % 10 == 0
|
|
124
|
-
|
|
125
|
-
commit_data = extract_commit(commit)
|
|
126
|
-
commits_list << commit_data.slice(:sha, :short_sha, :message_headline, :author, :committed_at)
|
|
127
|
-
|
|
128
|
-
write_json("commits/#{commit.oid}.json", commit_data)
|
|
129
|
-
export_tree(commit.tree, '')
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
commits_list.sort_by! { |c| c[:committed_at] }.reverse!
|
|
133
|
-
write_json('commits.json', commits_list)
|
|
134
|
-
puts "\rProcessed #{commits_list.size} commits"
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def extract_commit(commit)
|
|
138
|
-
parents = commit.parents.map { |p| { sha: p.oid, short_sha: p.oid[0..6] } }
|
|
139
|
-
diff_stats = commit.parents.empty? ? initial_diff_stats(commit) : parent_diff_stats(commit)
|
|
140
|
-
|
|
141
|
-
{
|
|
142
|
-
sha: commit.oid,
|
|
143
|
-
short_sha: commit.oid[0..6],
|
|
144
|
-
message: commit.message,
|
|
145
|
-
message_headline: commit.message.lines.first&.strip || '',
|
|
146
|
-
author: { name: commit.author[:name], email: commit.author[:email], date: commit.author[:time].iso8601 },
|
|
147
|
-
committer: { name: commit.committer[:name], email: commit.committer[:email], date: commit.committer[:time].iso8601 },
|
|
148
|
-
committed_at: commit.committer[:time].iso8601,
|
|
149
|
-
parents: parents,
|
|
150
|
-
tree_sha: commit.tree.oid,
|
|
151
|
-
stats: diff_stats[:stats],
|
|
152
|
-
files: diff_stats[:files]
|
|
153
|
-
}
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def initial_diff_stats(commit)
|
|
157
|
-
files = []
|
|
158
|
-
collect_tree_files(commit.tree, '', files)
|
|
159
|
-
{ stats: { additions: files.sum { |f| f[:additions] }, deletions: 0, changed: files.size }, files: files }
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def collect_tree_files(tree, path, files)
|
|
163
|
-
tree.each do |entry|
|
|
164
|
-
full_path = path.empty? ? entry[:name] : "#{path}/#{entry[:name]}"
|
|
165
|
-
if entry[:type] == :blob
|
|
166
|
-
blob = @repo.lookup(entry[:oid])
|
|
167
|
-
lines = blob.binary? ? 0 : blob.content.lines.count
|
|
168
|
-
files << { path: full_path, additions: lines, deletions: 0, status: 'added' }
|
|
169
|
-
elsif entry[:type] == :tree
|
|
170
|
-
collect_tree_files(@repo.lookup(entry[:oid]), full_path, files)
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def parent_diff_stats(commit)
|
|
176
|
-
diff = commit.parents.first.diff(commit)
|
|
177
|
-
files = []
|
|
178
|
-
additions = deletions = 0
|
|
179
|
-
|
|
180
|
-
diff.each_patch do |patch|
|
|
181
|
-
file_adds = file_dels = 0
|
|
182
|
-
patch.each_hunk { |h| h.each_line { |l| l.addition? ? file_adds += 1 : (file_dels += 1 if l.deletion?) } }
|
|
183
|
-
additions += file_adds
|
|
184
|
-
deletions += file_dels
|
|
185
|
-
|
|
186
|
-
status = case patch.delta.status
|
|
187
|
-
when :added then 'added'
|
|
188
|
-
when :deleted then 'deleted'
|
|
189
|
-
when :renamed then 'renamed'
|
|
190
|
-
else 'modified'
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
files << { path: patch.delta.new_file[:path], additions: file_adds, deletions: file_dels, status: status }
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
{ stats: { additions: additions, deletions: deletions, changed: files.size }, files: files }
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def export_tree(tree, path)
|
|
200
|
-
return if @processed_trees.include?(tree.oid)
|
|
201
|
-
@processed_trees.add(tree.oid)
|
|
202
|
-
|
|
203
|
-
entries = tree.map do |entry|
|
|
204
|
-
entry_data = {
|
|
205
|
-
name: entry[:name],
|
|
206
|
-
path: path.empty? ? entry[:name] : "#{path}/#{entry[:name]}",
|
|
207
|
-
type: entry[:type].to_s,
|
|
208
|
-
sha: entry[:oid],
|
|
209
|
-
mode: entry[:filemode].to_s(8)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if entry[:type] == :tree
|
|
213
|
-
export_tree(@repo.lookup(entry[:oid]), entry_data[:path])
|
|
214
|
-
elsif entry[:type] == :blob
|
|
215
|
-
export_blob(entry[:oid], entry_data[:path])
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
entry_data
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
write_json("trees/#{tree.oid}.json", { sha: tree.oid, path: path, entries: entries })
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def export_blob(oid, path)
|
|
225
|
-
return if @processed_blobs.include?(oid)
|
|
226
|
-
@processed_blobs.add(oid)
|
|
227
|
-
|
|
228
|
-
blob = @repo.lookup(oid)
|
|
229
|
-
data = {
|
|
230
|
-
sha: oid,
|
|
231
|
-
path: path,
|
|
232
|
-
size: blob.size,
|
|
233
|
-
binary: blob.binary?,
|
|
234
|
-
content: blob.binary? ? nil : safe_content(blob.content),
|
|
235
|
-
truncated: !blob.binary? && blob.size > 100_000
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
write_json("blobs/#{oid}.json", data)
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def safe_content(content)
|
|
242
|
-
return content[0..100_000] + "\n... [truncated]" if content.size > 100_000
|
|
243
|
-
content.encode('UTF-8', invalid: :replace, undef: :replace, replace: '�')
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def write_json(path, data)
|
|
247
|
-
File.write(File.join(@output_dir, path), JSON.pretty_generate(data))
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
19
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mo khan
|
|
@@ -37,20 +37,6 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '2.0'
|
|
40
|
-
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: open3
|
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - "~>"
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0.1'
|
|
47
|
-
type: :runtime
|
|
48
|
-
prerelease: false
|
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
-
requirements:
|
|
51
|
-
- - "~>"
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0.1'
|
|
54
40
|
- !ruby/object:Gem::Dependency
|
|
55
41
|
name: rugged
|
|
56
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -66,20 +52,20 @@ dependencies:
|
|
|
66
52
|
- !ruby/object:Gem::Version
|
|
67
53
|
version: '1.0'
|
|
68
54
|
- !ruby/object:Gem::Dependency
|
|
69
|
-
name:
|
|
55
|
+
name: webrick
|
|
70
56
|
requirement: !ruby/object:Gem::Requirement
|
|
71
57
|
requirements:
|
|
72
58
|
- - "~>"
|
|
73
59
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: '
|
|
60
|
+
version: '1.8'
|
|
75
61
|
type: :runtime
|
|
76
62
|
prerelease: false
|
|
77
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
64
|
requirements:
|
|
79
65
|
- - "~>"
|
|
80
66
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: '
|
|
82
|
-
description:
|
|
67
|
+
version: '1.8'
|
|
68
|
+
description: Browse your git history locally with a GitHub-like interface.
|
|
83
69
|
email:
|
|
84
70
|
- mo@mokhan.ca
|
|
85
71
|
executables:
|
|
@@ -92,16 +78,20 @@ files:
|
|
|
92
78
|
- Rakefile
|
|
93
79
|
- exe/gitem
|
|
94
80
|
- lib/gitem.rb
|
|
95
|
-
- lib/gitem/
|
|
81
|
+
- lib/gitem/cli.rb
|
|
82
|
+
- lib/gitem/generator.rb
|
|
83
|
+
- lib/gitem/index.html
|
|
84
|
+
- lib/gitem/server.rb
|
|
96
85
|
- lib/gitem/version.rb
|
|
97
86
|
- sig/gitem.rbs
|
|
98
|
-
homepage: https://
|
|
87
|
+
homepage: https://github.com/xlgmokha/gitem
|
|
99
88
|
licenses:
|
|
100
89
|
- MIT
|
|
101
90
|
metadata:
|
|
102
91
|
allowed_push_host: https://rubygems.org
|
|
103
|
-
homepage_uri: https://
|
|
104
|
-
source_code_uri: https://
|
|
92
|
+
homepage_uri: https://github.com/xlgmokha/gitem
|
|
93
|
+
source_code_uri: https://github.com/xlgmokha/gitem
|
|
94
|
+
changelog_uri: https://github.com/xlgmokha/gitem/blob/main/CHANGELOG.md
|
|
105
95
|
rdoc_options: []
|
|
106
96
|
require_paths:
|
|
107
97
|
- lib
|
|
@@ -118,5 +108,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
118
108
|
requirements: []
|
|
119
109
|
rubygems_version: 4.0.1
|
|
120
110
|
specification_version: 4
|
|
121
|
-
summary: A static site
|
|
111
|
+
summary: A static site generator for git repositories.
|
|
122
112
|
test_files: []
|
|
File without changes
|