thuban 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +67 -0
- data/lib/thuban/blame.rb +48 -0
- data/lib/thuban/commit.rb +5 -0
- data/lib/thuban/corrupt_object.rb +5 -0
- data/lib/thuban/ignore_matcher.rb +106 -0
- data/lib/thuban/index.rb +92 -0
- data/lib/thuban/object_database.rb +43 -0
- data/lib/thuban/pack.rb +184 -0
- data/lib/thuban/repository.rb +306 -0
- data/lib/thuban/status.rb +72 -0
- data/lib/thuban/tree_entry.rb +5 -0
- data/lib/thuban/version.rb +5 -0
- data/lib/thuban/worktree_files.rb +55 -0
- data/lib/thuban.rb +18 -0
- data/sig/thuban.rbs +128 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 41d8c298a842a358db7f9ec204f0b25eb9a11c6b1baa1ac7206f230d148c9874
|
|
4
|
+
data.tar.gz: 3ca82e618bf82cc6f40851ac4bd230179a242dc1ba96059379e42e3c4826e6ef
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 121c47d02bb849760a8e7ca06c8a272dc29bcdc7165401c43064146950207f763dd141118dcfdeb279baa1cc80e02d79a9086077c788ff7a2be9704aa451affe
|
|
7
|
+
data.tar.gz: e1bc999f1a766a6ddc7602c6aae005ea5fc5a90530c2646820b9b9b66f3dad0440dc6fe0fd117b31a6e0410b5fe9126d1a96cb287b7e0524ffb6b21154b61059
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Thuban
|
|
2
|
+
|
|
3
|
+
Thuban is a pure Ruby reader for local Git repositories. It reads SHA-1
|
|
4
|
+
objects, packfiles, refs, commits, trees, blobs, indexes, status, and blame,
|
|
5
|
+
and supports a guarded checkout.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "thuban", "~> 0.1.0"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "thuban"
|
|
17
|
+
|
|
18
|
+
repo = Thuban::Repository.new(Dir.pwd)
|
|
19
|
+
repo.head
|
|
20
|
+
repo.branch
|
|
21
|
+
repo.blob("README.md")
|
|
22
|
+
repo.staged_blob("README.md")
|
|
23
|
+
repo.worktree_content("README.md")
|
|
24
|
+
repo.status
|
|
25
|
+
repo.blame("README.md")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Text comparison stays explicit:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
diff = Porrima.diff(
|
|
32
|
+
repo.staged_blob("README.md").to_s,
|
|
33
|
+
repo.worktree_content("README.md").to_s
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
hunk = diff.hunks.first
|
|
37
|
+
repo.write("README.md", Porrima.revert(repo.worktree_content("README.md"), hunk)) if hunk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`Repository#write` rejects symlinks, preserves the file mode, and replaces the
|
|
41
|
+
file atomically. `Repository#checkout` requires a clean tracked worktree and
|
|
42
|
+
aborts on untracked collisions.
|
|
43
|
+
|
|
44
|
+
## Scope
|
|
45
|
+
|
|
46
|
+
Thuban does not implement a diff algorithm; blame delegates line matching to
|
|
47
|
+
Porrima through the injectable `differ:` argument. It does not perform network
|
|
48
|
+
operations such as fetch or push. Writes are limited to atomic worktree
|
|
49
|
+
replacement and checkout; it does not rewrite history.
|
|
50
|
+
|
|
51
|
+
Thuban targets SHA-1 repositories. It supports the index and pack formats
|
|
52
|
+
covered by its test suite, not every optional Git extension. Submodule checkout
|
|
53
|
+
is deliberately excluded.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
bundle install
|
|
59
|
+
bundle exec rake
|
|
60
|
+
ruby tools/check_isolation.rb
|
|
61
|
+
bundle exec rbs -I sig -r porrima validate
|
|
62
|
+
gem build --strict thuban.gemspec
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
Thuban is available under the MIT License.
|
data/lib/thuban/blame.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
class Blame
|
|
5
|
+
Line = Struct.new(:line, :text, :commit, :author, :original_line, :path, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def initialize(repository)
|
|
8
|
+
@repository = repository
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(path, reference: "HEAD", differ: Porrima)
|
|
12
|
+
revision = @repository.commit(reference)
|
|
13
|
+
return [] unless revision
|
|
14
|
+
current = @repository.blob(path, reference: revision.oid)
|
|
15
|
+
return [] unless current
|
|
16
|
+
result = Array.new(current.lines.length)
|
|
17
|
+
pending = [[revision, path, current, result.each_index.to_h { |index| [index, index] }]]
|
|
18
|
+
until pending.empty?
|
|
19
|
+
commit, current_path, contents, unresolved = pending.pop
|
|
20
|
+
commit.parents.each do |parent_oid|
|
|
21
|
+
break if unresolved.empty?
|
|
22
|
+
parent = @repository.commit(parent_oid)
|
|
23
|
+
parent_tree = @repository.tree(parent_oid)
|
|
24
|
+
parent_path = current_path
|
|
25
|
+
unless parent_tree.key?(current_path)
|
|
26
|
+
current_oid = @repository.tree(commit.oid)[current_path]&.oid
|
|
27
|
+
parent_path = parent_tree.values.find { |entry| entry.oid == current_oid }&.path
|
|
28
|
+
end
|
|
29
|
+
next unless parent_path
|
|
30
|
+
previous = @repository.blob(parent_path, reference: parent_oid)
|
|
31
|
+
next unless previous
|
|
32
|
+
inherited = {}
|
|
33
|
+
differ.edits(previous, contents).each do |edit|
|
|
34
|
+
next unless edit.kind == :equal && unresolved.key?(edit.new_line - 1)
|
|
35
|
+
inherited[edit.old_line - 1] = unresolved.delete(edit.new_line - 1)
|
|
36
|
+
end
|
|
37
|
+
pending << [parent, parent_path, previous, inherited] unless inherited.empty?
|
|
38
|
+
end
|
|
39
|
+
lines = contents.lines
|
|
40
|
+
unresolved.each do |original, target|
|
|
41
|
+
result[target] = Line.new(line: target + 1, text: lines[original].force_encoding(Encoding::UTF_8),
|
|
42
|
+
commit: commit.oid, author: commit.author, original_line: original + 1, path: current_path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
class IgnoreMatcher
|
|
5
|
+
Rule = Struct.new(:base, :expression, :negated, :directory, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def initialize(rules = [])
|
|
8
|
+
@rules = rules.freeze
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def add(source, base: "")
|
|
12
|
+
rules = source.lines.filter_map do |line|
|
|
13
|
+
line = line.delete_suffix("\n").delete_suffix("\r")
|
|
14
|
+
line = line.sub(/(?<!\\)(?:\\\\)*\K +\z/, "")
|
|
15
|
+
next if line.empty? || line.start_with?("#")
|
|
16
|
+
|
|
17
|
+
negated = line.start_with?("!")
|
|
18
|
+
line = line[1..] if negated
|
|
19
|
+
directory = line.end_with?("/")
|
|
20
|
+
line = line.delete_suffix("/") if directory
|
|
21
|
+
anchored = line.start_with?("/") || line.include?("/")
|
|
22
|
+
line = line.delete_prefix("/")
|
|
23
|
+
next if line.empty?
|
|
24
|
+
|
|
25
|
+
expression = Regexp.new((anchored ? "\\A" : "(?:\\A|/)") + glob(line) + "\\z")
|
|
26
|
+
Rule.new(base: base.delete_suffix("/"), expression: expression, negated: negated, directory: directory)
|
|
27
|
+
rescue RegexpError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
self.class.new(@rules + rules)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ignored?(path, directory: false)
|
|
34
|
+
ignored = false
|
|
35
|
+
@rules.each do |rule|
|
|
36
|
+
next if rule.directory && !directory
|
|
37
|
+
next unless rule.base.empty? || path.start_with?(rule.base + "/")
|
|
38
|
+
|
|
39
|
+
local = rule.base.empty? ? path : path[(rule.base.length + 1)..]
|
|
40
|
+
ignored = !rule.negated if rule.expression.match?(local)
|
|
41
|
+
end
|
|
42
|
+
ignored
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def glob(pattern)
|
|
48
|
+
result = +""
|
|
49
|
+
index = 0
|
|
50
|
+
while index < pattern.length
|
|
51
|
+
char = pattern[index]
|
|
52
|
+
case char
|
|
53
|
+
when "\\"
|
|
54
|
+
index += 1
|
|
55
|
+
result << Regexp.escape(pattern[index] || "\\")
|
|
56
|
+
when "*"
|
|
57
|
+
finish = index
|
|
58
|
+
finish += 1 while pattern[finish + 1] == "*"
|
|
59
|
+
if finish > index && (index.zero? || pattern[index - 1] == "/") && (finish == pattern.length - 1 || pattern[finish + 1] == "/")
|
|
60
|
+
if pattern[finish + 1] == "/"
|
|
61
|
+
result << "(?:[^/]+/)*"
|
|
62
|
+
finish += 1
|
|
63
|
+
else
|
|
64
|
+
result << ".*"
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
result << "[^/]*"
|
|
68
|
+
end
|
|
69
|
+
index = finish
|
|
70
|
+
when "?"
|
|
71
|
+
result << "[^/]"
|
|
72
|
+
when "["
|
|
73
|
+
cursor = index + 1
|
|
74
|
+
cursor += 1 if ["!", "^"].include?(pattern[cursor])
|
|
75
|
+
cursor += 1 if pattern[cursor] == "]"
|
|
76
|
+
finish = nil
|
|
77
|
+
while cursor < pattern.length
|
|
78
|
+
if pattern[cursor, 2] == "[:" && (ending = pattern.index(":]", cursor + 2))
|
|
79
|
+
cursor = ending + 2
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
if pattern[cursor] == "]"
|
|
83
|
+
finish = cursor
|
|
84
|
+
break
|
|
85
|
+
end
|
|
86
|
+
cursor += pattern[cursor] == "\\" ? 2 : 1
|
|
87
|
+
end
|
|
88
|
+
if finish
|
|
89
|
+
content = pattern[(index + 1)...finish].sub(/\A!/, "^")
|
|
90
|
+
content = content.gsub(/(?<!\\)(.)-(.)/) { Regexp.last_match(1).ord > Regexp.last_match(2).ord ? Regexp.last_match(1) : Regexp.last_match(0) }
|
|
91
|
+
result << "(?!/)[#{content}]"
|
|
92
|
+
index = finish
|
|
93
|
+
else
|
|
94
|
+
result << "\\["
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
result << Regexp.escape(char)
|
|
98
|
+
end
|
|
99
|
+
index += 1
|
|
100
|
+
end
|
|
101
|
+
result
|
|
102
|
+
rescue RegexpError
|
|
103
|
+
Regexp.escape(pattern)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/thuban/index.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
|
|
5
|
+
module Thuban
|
|
6
|
+
class Index
|
|
7
|
+
Entry = Struct.new(:path, :oid, :mode, :size, :mtime, :mtime_nsec, :ctime, :ctime_nsec,
|
|
8
|
+
:dev, :ino, :uid, :gid, :stage, :flags, :extended_flags, keyword_init: true)
|
|
9
|
+
include Enumerable
|
|
10
|
+
attr_reader :entries, :version, :path
|
|
11
|
+
|
|
12
|
+
def initialize(path)
|
|
13
|
+
@path = path
|
|
14
|
+
@entries = []
|
|
15
|
+
@version = 2
|
|
16
|
+
parse(File.binread(path)) if File.file?(path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def each(&block) = entries.each(&block)
|
|
20
|
+
def [](path) = entries.find { |entry| entry.path == path && entry.stage.zero? }
|
|
21
|
+
|
|
22
|
+
def self.encode(entries)
|
|
23
|
+
bytes = +"DIRC".b << [2, entries.length].pack("N2")
|
|
24
|
+
entries.sort_by { |entry| [entry.path.b, entry.stage || 0] }.each do |entry|
|
|
25
|
+
name = entry.path.b
|
|
26
|
+
fields = %i[ctime ctime_nsec mtime mtime_nsec dev ino mode uid gid size].map { |field| entry.public_send(field).to_i & 0xffffffff }
|
|
27
|
+
record = fields.pack("N10") + [entry.oid].pack("H*") + [[name.bytesize, 0xfff].min | ((entry.stage || 0) << 12)].pack("n") + name + "\0"
|
|
28
|
+
record << "\0" * ((8 - record.bytesize % 8) % 8)
|
|
29
|
+
bytes << record
|
|
30
|
+
end
|
|
31
|
+
bytes + Digest::SHA1.digest(bytes)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse(bytes)
|
|
37
|
+
raise CorruptObject, "invalid Git index" unless bytes.bytesize >= 32 && bytes.start_with?("DIRC")
|
|
38
|
+
raise CorruptObject, "Git index checksum mismatch" unless Digest::SHA1.digest(bytes[0...-20]) == bytes[-20, 20]
|
|
39
|
+
@version, count = bytes[4, 8].unpack("N2")
|
|
40
|
+
raise CorruptObject, "unsupported Git index version #{version}" unless [2, 3, 4].include?(version)
|
|
41
|
+
offset = 12
|
|
42
|
+
previous = "".b
|
|
43
|
+
count.times do
|
|
44
|
+
start = offset
|
|
45
|
+
raise CorruptObject, "truncated Git index entry" if offset + 62 > bytes.bytesize - 20
|
|
46
|
+
fields = bytes[offset, 40].unpack("N10")
|
|
47
|
+
oid = bytes[offset + 40, 20].unpack1("H*")
|
|
48
|
+
flags = bytes[offset + 60, 2].unpack1("n")
|
|
49
|
+
offset += 62
|
|
50
|
+
extended = 0
|
|
51
|
+
if (flags & 0x4000).positive?
|
|
52
|
+
raise CorruptObject, "invalid index extended flags" if version == 2 || offset + 2 > bytes.bytesize - 20
|
|
53
|
+
extended = bytes[offset, 2].unpack1("n")
|
|
54
|
+
offset += 2
|
|
55
|
+
end
|
|
56
|
+
if version == 4
|
|
57
|
+
byte = bytes.getbyte(offset)
|
|
58
|
+
raise CorruptObject, "truncated index path prefix" unless byte
|
|
59
|
+
offset += 1
|
|
60
|
+
strip = byte & 0x7f
|
|
61
|
+
while (byte & 0x80).positive?
|
|
62
|
+
byte = bytes.getbyte(offset)
|
|
63
|
+
raise CorruptObject, "invalid index path prefix" unless byte && strip <= previous.bytesize
|
|
64
|
+
offset += 1
|
|
65
|
+
strip = ((strip + 1) << 7) | (byte & 0x7f)
|
|
66
|
+
end
|
|
67
|
+
raise CorruptObject, "index path prefix outside previous path" if strip > previous.bytesize
|
|
68
|
+
end
|
|
69
|
+
ending = bytes.index("\0", offset)
|
|
70
|
+
raise CorruptObject, "unterminated index path" unless ending && ending < bytes.bytesize - 20
|
|
71
|
+
name = bytes[offset...ending]
|
|
72
|
+
name = previous.byteslice(0, previous.bytesize - strip) + name if version == 4
|
|
73
|
+
raise CorruptObject, "unsafe index path" if name.empty? || name.start_with?("/") || name.split("/").any? { |part| part == ".." || part == ".git" }
|
|
74
|
+
previous = name
|
|
75
|
+
offset = ending + 1
|
|
76
|
+
offset += (8 - (offset - start) % 8) % 8 unless version == 4
|
|
77
|
+
values = %i[ctime ctime_nsec mtime mtime_nsec dev ino mode uid gid size].zip(fields).to_h
|
|
78
|
+
entries << Entry.new(**values, path: name.force_encoding(Encoding::UTF_8), oid: oid,
|
|
79
|
+
flags: flags, extended_flags: extended, stage: (flags >> 12) & 3)
|
|
80
|
+
end
|
|
81
|
+
while offset < bytes.bytesize - 20
|
|
82
|
+
raise CorruptObject, "truncated index extension" if offset + 8 > bytes.bytesize - 20
|
|
83
|
+
signature = bytes[offset, 4]
|
|
84
|
+
size = bytes[offset + 4, 4].unpack1("N")
|
|
85
|
+
# Lowercase extensions change index interpretation (e.g. split index).
|
|
86
|
+
raise CorruptObject, "unsupported mandatory index extension #{signature}" if signature[0].match?(/[a-z]/)
|
|
87
|
+
offset += 8 + size
|
|
88
|
+
raise CorruptObject, "truncated index extension payload" if offset > bytes.bytesize - 20
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pack"
|
|
4
|
+
|
|
5
|
+
module Thuban
|
|
6
|
+
class ObjectDatabase
|
|
7
|
+
attr_reader :directory
|
|
8
|
+
|
|
9
|
+
def initialize(directory)
|
|
10
|
+
@directory = File.expand_path(directory)
|
|
11
|
+
@packs = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.hash(type, data) = Digest::SHA1.hexdigest("#{type} #{data.bytesize}\0".b + data.b)
|
|
15
|
+
|
|
16
|
+
def read(oid, seen = [])
|
|
17
|
+
raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(oid.to_s)
|
|
18
|
+
raise CorruptObject, "cyclic object reference" if seen.include?(oid) || seen.length > 128
|
|
19
|
+
loose = File.join(directory, oid[0, 2], oid[2..])
|
|
20
|
+
object = if File.file?(loose)
|
|
21
|
+
inflated = Zlib::Inflate.inflate(File.binread(loose))
|
|
22
|
+
header, data = inflated.split("\0", 2)
|
|
23
|
+
type, size = header.split(" ", 2)
|
|
24
|
+
raise CorruptObject, "invalid loose object header" unless %w[commit tree blob tag].include?(type) && size&.match?(/\A\d+\z/) && data && data.bytesize == size.to_i
|
|
25
|
+
[type, data]
|
|
26
|
+
else
|
|
27
|
+
pack = packs.find { |entry| entry.include?(oid) }
|
|
28
|
+
unless pack
|
|
29
|
+
@packs = nil # New packs may appear during background GC.
|
|
30
|
+
pack = packs.find { |entry| entry.include?(oid) }
|
|
31
|
+
end
|
|
32
|
+
raise KeyError, "Git object not found: #{oid}" unless pack
|
|
33
|
+
pack.read(oid) { |base| read(base, seen + [oid]) }
|
|
34
|
+
end
|
|
35
|
+
raise CorruptObject, "object SHA-1 mismatch: #{oid}" unless self.class.hash(*object) == oid
|
|
36
|
+
object
|
|
37
|
+
rescue Zlib::Error => error
|
|
38
|
+
raise CorruptObject, error.message
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def packs = @packs ||= Dir[File.join(directory, "pack", "*.idx")].sort.map { |path| Pack.new(path) }
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/thuban/pack.rb
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "digest/sha1"
|
|
5
|
+
require_relative "corrupt_object"
|
|
6
|
+
|
|
7
|
+
module Thuban
|
|
8
|
+
class Pack
|
|
9
|
+
TYPES = {1 => "commit", 2 => "tree", 3 => "blob", 4 => "tag"}.freeze
|
|
10
|
+
MAX_OBJECT_SIZE = 512 * 1024 * 1024
|
|
11
|
+
attr_reader :path, :offsets
|
|
12
|
+
|
|
13
|
+
def initialize(index_path)
|
|
14
|
+
@path = index_path.sub(/\.idx\z/, ".pack")
|
|
15
|
+
@offsets = read_index(File.binread(index_path))
|
|
16
|
+
File.open(path, "rb") do |file|
|
|
17
|
+
header = file.read(12)
|
|
18
|
+
raise CorruptObject, "invalid pack header" unless header&.bytesize == 12 && header[0, 4] == "PACK" && [2, 3].include?(header[4, 4].unpack1("N"))
|
|
19
|
+
raise CorruptObject, "pack/index object count mismatch" unless header[8, 4].unpack1("N") == offsets.length
|
|
20
|
+
file.seek(-20, IO::SEEK_END)
|
|
21
|
+
raise CorruptObject, "pack/index checksum mismatch" unless file.read(20) == @pack_checksum
|
|
22
|
+
end
|
|
23
|
+
@cache = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def include?(oid) = offsets.key?(oid)
|
|
27
|
+
|
|
28
|
+
def read(oid, &resolve)
|
|
29
|
+
offset = offsets[oid]
|
|
30
|
+
raise KeyError, "object not in pack: #{oid}" unless offset
|
|
31
|
+
read_at(offset, [], &resolve)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.apply_delta(base, delta)
|
|
35
|
+
cursor = 0
|
|
36
|
+
read_size = lambda do
|
|
37
|
+
size = shift = 0
|
|
38
|
+
loop do
|
|
39
|
+
byte = delta.getbyte(cursor)
|
|
40
|
+
raise CorruptObject, "truncated delta header" unless byte && shift <= 63
|
|
41
|
+
cursor += 1
|
|
42
|
+
size |= (byte & 0x7f) << shift
|
|
43
|
+
break if (byte & 0x80).zero?
|
|
44
|
+
shift += 7
|
|
45
|
+
end
|
|
46
|
+
size
|
|
47
|
+
end
|
|
48
|
+
source_size = read_size.call
|
|
49
|
+
target_size = read_size.call
|
|
50
|
+
raise CorruptObject, "delta base size mismatch" unless base.bytesize == source_size
|
|
51
|
+
raise CorruptObject, "delta too large" if target_size > MAX_OBJECT_SIZE
|
|
52
|
+
output = +"".b
|
|
53
|
+
while cursor < delta.bytesize
|
|
54
|
+
opcode = delta.getbyte(cursor)
|
|
55
|
+
cursor += 1
|
|
56
|
+
if (opcode & 0x80).positive?
|
|
57
|
+
offset = length = 0
|
|
58
|
+
7.times do |bit|
|
|
59
|
+
next if (opcode & (1 << bit)).zero?
|
|
60
|
+
byte = delta.getbyte(cursor)
|
|
61
|
+
raise CorruptObject, "truncated delta copy" unless byte
|
|
62
|
+
cursor += 1
|
|
63
|
+
bit < 4 ? offset |= byte << (bit * 8) : length |= byte << ((bit - 4) * 8)
|
|
64
|
+
end
|
|
65
|
+
length = 0x10000 if length.zero?
|
|
66
|
+
raise CorruptObject, "delta copy outside base" if offset + length > base.bytesize
|
|
67
|
+
output << base.byteslice(offset, length)
|
|
68
|
+
elsif opcode.positive?
|
|
69
|
+
raise CorruptObject, "truncated delta insert" if cursor + opcode > delta.bytesize
|
|
70
|
+
output << delta.byteslice(cursor, opcode)
|
|
71
|
+
cursor += opcode
|
|
72
|
+
else
|
|
73
|
+
raise CorruptObject, "invalid delta opcode"
|
|
74
|
+
end
|
|
75
|
+
raise CorruptObject, "delta exceeds target size" if output.bytesize > target_size
|
|
76
|
+
end
|
|
77
|
+
raise CorruptObject, "delta target size mismatch" unless output.bytesize == target_size
|
|
78
|
+
output
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def read_index(bytes)
|
|
84
|
+
raise CorruptObject, "truncated pack index" if bytes.bytesize < 1064
|
|
85
|
+
raise CorruptObject, "pack index checksum mismatch" unless Digest::SHA1.digest(bytes[0...-20]) == bytes[-20, 20]
|
|
86
|
+
@pack_checksum = bytes[-40, 20]
|
|
87
|
+
version = bytes.start_with?("\xfftOc".b) ? bytes[4, 4].unpack1("N") : 1
|
|
88
|
+
raise CorruptObject, "unsupported pack index version #{version}" unless [1, 2].include?(version)
|
|
89
|
+
start = version == 1 ? 0 : 8
|
|
90
|
+
fanout = bytes[start, 1024].unpack("N*")
|
|
91
|
+
raise CorruptObject, "invalid pack index fanout" unless fanout.each_cons(2).all? { |a, b| a <= b }
|
|
92
|
+
count = fanout.last
|
|
93
|
+
cursor = start + 1024
|
|
94
|
+
minimum = cursor + count * (version == 1 ? 24 : 28) + 40
|
|
95
|
+
raise CorruptObject, "truncated pack index entries" if minimum > bytes.bytesize
|
|
96
|
+
if version == 1
|
|
97
|
+
return count.times.to_h do |index|
|
|
98
|
+
position = cursor + index * 24
|
|
99
|
+
[bytes[position + 4, 20].unpack1("H*"), bytes[position, 4].unpack1("N")]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
names = cursor
|
|
103
|
+
positions = cursor + count * 24
|
|
104
|
+
large_positions = positions + count * 4
|
|
105
|
+
count.times.to_h do |index|
|
|
106
|
+
offset = bytes[positions + index * 4, 4].unpack1("N")
|
|
107
|
+
if offset >= 0x80000000
|
|
108
|
+
location = large_positions + (offset & 0x7fffffff) * 8
|
|
109
|
+
raise CorruptObject, "truncated 64-bit pack offset" if location + 8 > bytes.bytesize - 40
|
|
110
|
+
offset = bytes[location, 8].unpack1("Q>")
|
|
111
|
+
end
|
|
112
|
+
[bytes[names + index * 20, 20].unpack1("H*"), offset]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def read_at(offset, stack, &resolve)
|
|
117
|
+
return @cache[offset] if @cache.key?(offset)
|
|
118
|
+
raise CorruptObject, "cyclic or excessive pack delta chain" if stack.include?(offset) || stack.length > 128
|
|
119
|
+
stack = stack + [offset]
|
|
120
|
+
type = data = base_offset = base_oid = nil
|
|
121
|
+
File.open(path, "rb") do |file|
|
|
122
|
+
raise CorruptObject, "object offset outside pack" unless offset >= 12 && offset < file.size - 20
|
|
123
|
+
file.seek(offset)
|
|
124
|
+
byte = file.getbyte
|
|
125
|
+
type = (byte >> 4) & 7
|
|
126
|
+
size = byte & 15
|
|
127
|
+
shift = 4
|
|
128
|
+
while (byte & 0x80).positive?
|
|
129
|
+
byte = file.getbyte
|
|
130
|
+
raise CorruptObject, "truncated pack object size" unless byte && shift <= 63
|
|
131
|
+
size |= (byte & 0x7f) << shift
|
|
132
|
+
shift += 7
|
|
133
|
+
end
|
|
134
|
+
raise CorruptObject, "pack object too large" if size > MAX_OBJECT_SIZE
|
|
135
|
+
if type == 6
|
|
136
|
+
byte = file.getbyte
|
|
137
|
+
raise CorruptObject, "truncated delta offset" unless byte
|
|
138
|
+
distance = byte & 0x7f
|
|
139
|
+
count = 0
|
|
140
|
+
while (byte & 0x80).positive?
|
|
141
|
+
byte = file.getbyte
|
|
142
|
+
count += 1
|
|
143
|
+
raise CorruptObject, "invalid delta offset" unless byte && count <= 9
|
|
144
|
+
distance = ((distance + 1) << 7) | (byte & 0x7f)
|
|
145
|
+
end
|
|
146
|
+
base_offset = offset - distance
|
|
147
|
+
raise CorruptObject, "invalid delta base offset" unless base_offset >= 12 && base_offset < offset
|
|
148
|
+
elsif type == 7
|
|
149
|
+
raw = file.read(20)
|
|
150
|
+
raise CorruptObject, "truncated delta reference" unless raw&.bytesize == 20
|
|
151
|
+
base_oid = raw.unpack1("H*")
|
|
152
|
+
elsif !TYPES.key?(type)
|
|
153
|
+
raise CorruptObject, "invalid packed object type #{type}"
|
|
154
|
+
end
|
|
155
|
+
inflater = Zlib::Inflate.new
|
|
156
|
+
begin
|
|
157
|
+
data = +"".b
|
|
158
|
+
until inflater.finished?
|
|
159
|
+
chunk = file.read(16_384)
|
|
160
|
+
raise CorruptObject, "truncated compressed object" unless chunk
|
|
161
|
+
inflater.inflate(chunk) do |part|
|
|
162
|
+
data << part
|
|
163
|
+
raise CorruptObject, "packed object exceeds declared size" if data.bytesize > size
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
rescue Zlib::Error => error
|
|
167
|
+
raise CorruptObject, error.message
|
|
168
|
+
ensure
|
|
169
|
+
inflater.close
|
|
170
|
+
end
|
|
171
|
+
raise CorruptObject, "packed object size mismatch" unless data.bytesize == size
|
|
172
|
+
end
|
|
173
|
+
object = if base_offset || base_oid
|
|
174
|
+
base_type, base = base_offset ? read_at(base_offset, stack, &resolve) : resolve.call(base_oid)
|
|
175
|
+
[base_type, self.class.apply_delta(base, data)]
|
|
176
|
+
else
|
|
177
|
+
[TYPES.fetch(type), data]
|
|
178
|
+
end
|
|
179
|
+
# ponytail: bound object cache by count; byte budgeting if large blobs dominate.
|
|
180
|
+
@cache.shift if @cache.length >= 128
|
|
181
|
+
@cache[offset] = object.map(&:freeze).freeze
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
class Repository
|
|
5
|
+
attr_reader :root, :git_dir, :common_dir, :odb
|
|
6
|
+
|
|
7
|
+
def initialize(path)
|
|
8
|
+
directory = File.expand_path(path)
|
|
9
|
+
directory = File.dirname(directory) unless File.directory?(directory)
|
|
10
|
+
loop do
|
|
11
|
+
location = File.join(directory, ".git")
|
|
12
|
+
if File.directory?(location)
|
|
13
|
+
@root, @git_dir = directory, location
|
|
14
|
+
break
|
|
15
|
+
elsif File.file?(location)
|
|
16
|
+
value = File.read(location).strip
|
|
17
|
+
raise ArgumentError, "invalid gitdir file" unless value.start_with?("gitdir: ")
|
|
18
|
+
@root, @git_dir = directory, File.expand_path(value.delete_prefix("gitdir: "), directory)
|
|
19
|
+
break
|
|
20
|
+
elsif File.file?(File.join(directory, "HEAD")) && File.directory?(File.join(directory, "objects"))
|
|
21
|
+
@root, @git_dir = nil, directory
|
|
22
|
+
break
|
|
23
|
+
end
|
|
24
|
+
parent = File.dirname(directory)
|
|
25
|
+
raise ArgumentError, "not a Git repository: #{path}" if parent == directory
|
|
26
|
+
directory = parent
|
|
27
|
+
end
|
|
28
|
+
common = File.join(git_dir, "commondir")
|
|
29
|
+
@common_dir = File.file?(common) ? File.expand_path(File.read(common).strip, git_dir) : git_dir
|
|
30
|
+
config = File.join(common_dir, "config")
|
|
31
|
+
raise ArgumentError, "SHA-256 repositories are not supported" if File.file?(config) && File.read(config).match?(/objectformat\s*=\s*sha256/i)
|
|
32
|
+
@odb = ObjectDatabase.new(File.join(common_dir, "objects"))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def object(oid) = odb.read(resolve(oid) || oid)
|
|
36
|
+
def index = Index.new(File.join(git_dir, "index"))
|
|
37
|
+
def head = resolve("HEAD")
|
|
38
|
+
|
|
39
|
+
def branch
|
|
40
|
+
text = File.read(File.join(git_dir, "HEAD")).strip
|
|
41
|
+
text.start_with?("ref: refs/heads/") ? text.delete_prefix("ref: refs/heads/") : nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refs
|
|
45
|
+
result = {}
|
|
46
|
+
packed = File.join(common_dir, "packed-refs")
|
|
47
|
+
if File.file?(packed)
|
|
48
|
+
File.foreach(packed) do |line|
|
|
49
|
+
next if line.start_with?("#", "^")
|
|
50
|
+
oid, name = line.strip.split(" ", 2)
|
|
51
|
+
result[name] = oid if name && /\A[0-9a-f]{40}\z/.match?(oid)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
Dir[File.join(common_dir, "refs", "**", "*")].sort.each do |path|
|
|
55
|
+
next unless File.file?(path)
|
|
56
|
+
name = path.delete_prefix(common_dir + "/")
|
|
57
|
+
result[name] = resolve(name)
|
|
58
|
+
end
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def branches = refs.keys.grep(%r{\Arefs/heads/}).map { |ref| ref.delete_prefix("refs/heads/") }.sort
|
|
63
|
+
|
|
64
|
+
def resolve(name, seen = [])
|
|
65
|
+
return name if /\A[0-9a-f]{40}\z/.match?(name.to_s)
|
|
66
|
+
raise ArgumentError, "invalid Git reference" unless name.is_a?(String) && !name.empty? && !name.start_with?("/") && !name.split("/").any? { |piece| piece == ".." || piece.empty? } && !name.match?(/[\x00-\x20\\]/)
|
|
67
|
+
raise CorruptObject, "cyclic symbolic reference" if seen.include?(name) || seen.length > 32
|
|
68
|
+
candidates = name == "HEAD" || name.start_with?("refs/") ? [name] : ["refs/heads/#{name}", "refs/tags/#{name}", "refs/remotes/#{name}"]
|
|
69
|
+
candidates.each do |candidate|
|
|
70
|
+
directory = candidate == "HEAD" ? git_dir : common_dir
|
|
71
|
+
path = File.join(directory, candidate)
|
|
72
|
+
if File.file?(path)
|
|
73
|
+
value = File.read(path).strip
|
|
74
|
+
return resolve(value.delete_prefix("ref: "), seen + [name]) if value.start_with?("ref: ")
|
|
75
|
+
raise CorruptObject, "invalid reference #{candidate}" unless value.match?(/\A[0-9a-f]{40}\z/)
|
|
76
|
+
return value
|
|
77
|
+
end
|
|
78
|
+
packed = File.join(common_dir, "packed-refs")
|
|
79
|
+
next unless File.file?(packed)
|
|
80
|
+
File.foreach(packed) do |line|
|
|
81
|
+
oid, reference = line.strip.split(" ", 2)
|
|
82
|
+
return oid if reference == candidate && oid.match?(/\A[0-9a-f]{40}\z/)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def commit(reference = "HEAD")
|
|
89
|
+
oid = resolve(reference)
|
|
90
|
+
return nil unless oid
|
|
91
|
+
seen = []
|
|
92
|
+
loop do
|
|
93
|
+
type, data = odb.read(oid)
|
|
94
|
+
if type == "tag"
|
|
95
|
+
raise CorruptObject, "cyclic annotated tag" if seen.include?(oid)
|
|
96
|
+
seen << oid
|
|
97
|
+
oid = data.lines.find { |line| line.start_with?("object ") }&.split&.last
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
raise ArgumentError, "reference is not a commit" unless type == "commit"
|
|
101
|
+
headers, message = data.split("\n\n", 2)
|
|
102
|
+
values = headers.lines.reject { |line| line.start_with?(" ") }.map { |line| line.chomp.split(" ", 2) }
|
|
103
|
+
return Commit.new(oid: oid, tree: values.assoc("tree")&.last,
|
|
104
|
+
parents: values.select { |key, _| key == "parent" }.map(&:last),
|
|
105
|
+
author: values.assoc("author")&.last, committer: values.assoc("committer")&.last,
|
|
106
|
+
message: message.to_s.force_encoding(Encoding::UTF_8))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def tree(reference = "HEAD")
|
|
111
|
+
revision = commit(reference)
|
|
112
|
+
revision ? read_tree(revision.tree) : {}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def blob(path, reference: "HEAD")
|
|
116
|
+
entry = tree(reference)[path]
|
|
117
|
+
entry && entry.mode != 0o160000 ? odb.read(entry.oid).last : nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def staged_blob(path)
|
|
121
|
+
entry = index[path]
|
|
122
|
+
entry ? odb.read(entry.oid).last : nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def status = Status.new(self).call
|
|
126
|
+
def blame(path, reference: "HEAD", differ: Porrima) = Blame.new(self).call(path, reference: reference, differ: differ)
|
|
127
|
+
|
|
128
|
+
def worktree_content(path)
|
|
129
|
+
absolute = worktree_path(path)
|
|
130
|
+
return File.readlink(absolute).b if File.symlink?(absolute)
|
|
131
|
+
File.file?(absolute) ? File.binread(absolute) : nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def write(path, content)
|
|
135
|
+
absolute = worktree_path(path)
|
|
136
|
+
raise ArgumentError, "cannot write through a symlink" if File.symlink?(absolute)
|
|
137
|
+
atomic_write(absolute, content, File.stat(absolute).mode & 0o777)
|
|
138
|
+
content
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def worktree_path(path, replacing: [])
|
|
142
|
+
raise ArgumentError, "bare repository has no worktree" unless root
|
|
143
|
+
raise ArgumentError, "unsafe worktree path" if path.empty? || path.start_with?("/") || path.split("/").any? { |part| ["..", ".git", ""].include?(part) }
|
|
144
|
+
absolute = File.expand_path(path, root)
|
|
145
|
+
raise ArgumentError, "path outside worktree" unless absolute.start_with?(root + "/")
|
|
146
|
+
parent = File.dirname(absolute)
|
|
147
|
+
while parent != root
|
|
148
|
+
raise ArgumentError, "worktree parent is a symlink" if File.symlink?(parent) && !replacing.include?(parent.delete_prefix(root + "/"))
|
|
149
|
+
parent = File.dirname(parent)
|
|
150
|
+
end
|
|
151
|
+
absolute
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Checkout is deliberately limited to a clean index and tracked worktree.
|
|
155
|
+
# Untracked files are retained and any collision aborts before the first write.
|
|
156
|
+
def checkout(name)
|
|
157
|
+
raise ArgumentError, "unknown branch: #{name}" unless branches.include?(name)
|
|
158
|
+
raise ArgumentError, "worktree/index must be clean" if status.any? { |entry| entry.index != "?" }
|
|
159
|
+
target = tree("refs/heads/#{name}")
|
|
160
|
+
current = index.entries.to_h { |entry| [entry.path, entry] }
|
|
161
|
+
raise ArgumentError, "submodule checkout requires separate worktree handling" if (target.values + current.values).any? { |entry| entry.mode == 0o160000 }
|
|
162
|
+
removed = current.keys - target.keys
|
|
163
|
+
target.each_key do |path|
|
|
164
|
+
absolute = worktree_path(path, replacing: removed)
|
|
165
|
+
unless current.key?(path)
|
|
166
|
+
if File.directory?(absolute) && !File.symlink?(absolute)
|
|
167
|
+
Find.find(absolute) do |entry|
|
|
168
|
+
next if File.directory?(entry) && !File.symlink?(entry)
|
|
169
|
+
raise ArgumentError, "untracked checkout collision: #{path}" unless removed.include?(entry.delete_prefix(root + "/"))
|
|
170
|
+
end
|
|
171
|
+
elsif File.exist?(absolute) || File.symlink?(absolute)
|
|
172
|
+
raise ArgumentError, "untracked checkout collision: #{path}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
parent = File.dirname(absolute)
|
|
176
|
+
until parent == root
|
|
177
|
+
if (File.file?(parent) || File.symlink?(parent)) && !removed.include?(parent.delete_prefix(root + "/"))
|
|
178
|
+
raise ArgumentError, "untracked checkout collision: #{parent}"
|
|
179
|
+
end
|
|
180
|
+
parent = File.dirname(parent)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
contents = target.to_h { |path, entry| [path, odb.read(entry.oid).last] }
|
|
184
|
+
backups = current.to_h { |path, entry| [path, [worktree_content(path), entry.mode]] }
|
|
185
|
+
backups.each do |path, (data, _)|
|
|
186
|
+
raise ArgumentError, "worktree changed before checkout: #{path}" unless data && ObjectDatabase.hash("blob", data) == current[path].oid
|
|
187
|
+
end
|
|
188
|
+
index_path = File.join(git_dir, "index")
|
|
189
|
+
head_path = File.join(git_dir, "HEAD")
|
|
190
|
+
old_index = File.binread(index_path) if File.exist?(index_path)
|
|
191
|
+
old_head = File.binread(head_path)
|
|
192
|
+
locks = []
|
|
193
|
+
changed = false
|
|
194
|
+
begin
|
|
195
|
+
[index_path, head_path].each { |path| locks << File.open(path + ".lock", File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644) }
|
|
196
|
+
changed = true
|
|
197
|
+
removed.sort_by { |path| -path.count("/") }.each { |path| File.unlink(worktree_path(path)) }
|
|
198
|
+
prune_empty_directories(removed)
|
|
199
|
+
target.each do |path, entry|
|
|
200
|
+
absolute = worktree_path(path)
|
|
201
|
+
Dir.rmdir(absolute) if File.directory?(absolute) && !File.symlink?(absolute)
|
|
202
|
+
FileUtils.mkdir_p(File.dirname(absolute))
|
|
203
|
+
File.unlink(absolute) if File.symlink?(absolute)
|
|
204
|
+
if entry.mode == 0o120000
|
|
205
|
+
File.unlink(absolute) if File.exist?(absolute)
|
|
206
|
+
File.symlink(contents[path], absolute)
|
|
207
|
+
else
|
|
208
|
+
atomic_write(absolute, contents[path], entry.mode & 0o777)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
entries = target.map do |path, entry|
|
|
212
|
+
stat = File.lstat(worktree_path(path))
|
|
213
|
+
Index::Entry.new(path: path, oid: entry.oid, mode: entry.mode, size: stat.size,
|
|
214
|
+
mtime: stat.mtime.to_i, mtime_nsec: stat.mtime.nsec, ctime: stat.ctime.to_i, ctime_nsec: stat.ctime.nsec,
|
|
215
|
+
dev: stat.dev, ino: stat.ino, uid: stat.uid, gid: stat.gid, stage: 0)
|
|
216
|
+
end
|
|
217
|
+
locks[0].write(Index.encode(entries))
|
|
218
|
+
locks[1].write("ref: refs/heads/#{name}\n")
|
|
219
|
+
locks.each { |file| file.flush; file.fsync; file.close }
|
|
220
|
+
File.rename(index_path + ".lock", index_path)
|
|
221
|
+
File.rename(head_path + ".lock", head_path)
|
|
222
|
+
rescue StandardError
|
|
223
|
+
if changed
|
|
224
|
+
(target.keys - current.keys).each do |path|
|
|
225
|
+
absolute = worktree_path(path)
|
|
226
|
+
File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
|
|
227
|
+
end
|
|
228
|
+
prune_empty_directories(target.keys - current.keys)
|
|
229
|
+
backups.each do |path, (data, mode)|
|
|
230
|
+
absolute = worktree_path(path)
|
|
231
|
+
Dir.rmdir(absolute) if File.directory?(absolute) && !File.symlink?(absolute)
|
|
232
|
+
FileUtils.mkdir_p(File.dirname(absolute))
|
|
233
|
+
File.unlink(absolute) if File.symlink?(absolute)
|
|
234
|
+
if mode == 0o120000
|
|
235
|
+
File.unlink(absolute) if File.exist?(absolute)
|
|
236
|
+
File.symlink(data, absolute)
|
|
237
|
+
else
|
|
238
|
+
atomic_write(absolute, data, mode & 0o777)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
old_index ? atomic_write(index_path, old_index, 0o644) : File.unlink(index_path) if old_index || File.exist?(index_path)
|
|
242
|
+
atomic_write(head_path, old_head, 0o644)
|
|
243
|
+
end
|
|
244
|
+
raise
|
|
245
|
+
ensure
|
|
246
|
+
locks.each do |file|
|
|
247
|
+
file.close unless file.closed?
|
|
248
|
+
File.unlink(file.path) if File.exist?(file.path)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
name
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
def prune_empty_directories(paths)
|
|
257
|
+
paths.sort_by { |path| -path.count("/") }.each do |path|
|
|
258
|
+
parent = File.dirname(File.join(root, path))
|
|
259
|
+
until parent == root
|
|
260
|
+
begin
|
|
261
|
+
Dir.rmdir(parent)
|
|
262
|
+
rescue Errno::ENOTEMPTY, Errno::EEXIST, Errno::ENOENT, Errno::ENOTDIR
|
|
263
|
+
break
|
|
264
|
+
end
|
|
265
|
+
parent = File.dirname(parent)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def atomic_write(path, data, mode)
|
|
271
|
+
Tempfile.create([".thuban-", ".tmp"], File.dirname(path)) do |file|
|
|
272
|
+
file.binmode
|
|
273
|
+
file.chmod(mode)
|
|
274
|
+
file.write(data)
|
|
275
|
+
file.flush
|
|
276
|
+
file.fsync
|
|
277
|
+
file.close
|
|
278
|
+
File.rename(file.path, path)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def read_tree(oid, prefix = "", stack = [])
|
|
283
|
+
raise CorruptObject, "tree nesting exceeds limit" if stack.length > 256 || stack.include?(oid)
|
|
284
|
+
type, data = odb.read(oid)
|
|
285
|
+
raise CorruptObject, "expected tree object" unless type == "tree"
|
|
286
|
+
result = {}
|
|
287
|
+
offset = 0
|
|
288
|
+
while offset < data.bytesize
|
|
289
|
+
ending = data.index("\0", offset)
|
|
290
|
+
raise CorruptObject, "truncated tree entry" unless ending && ending + 21 <= data.bytesize
|
|
291
|
+
mode, name = data[offset...ending].split(" ", 2)
|
|
292
|
+
raise CorruptObject, "unsafe tree entry" unless mode&.match?(/\A[0-7]+\z/) && name && !["", ".", "..", ".git"].include?(name) && !name.include?("/")
|
|
293
|
+
path = (prefix + name).force_encoding(Encoding::UTF_8)
|
|
294
|
+
object = data[ending + 1, 20].unpack1("H*")
|
|
295
|
+
mode = mode.to_i(8)
|
|
296
|
+
if mode == 0o40000
|
|
297
|
+
result.merge!(read_tree(object, path + "/", stack + [oid]))
|
|
298
|
+
else
|
|
299
|
+
result[path] = TreeEntry.new(path: path, oid: object, mode: mode)
|
|
300
|
+
end
|
|
301
|
+
offset = ending + 21
|
|
302
|
+
end
|
|
303
|
+
result
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
class Status
|
|
5
|
+
Entry = Struct.new(:path, :index, :worktree, keyword_init: true) do
|
|
6
|
+
def code = index + worktree
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(repository)
|
|
10
|
+
@repository = repository
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
repository = @repository
|
|
15
|
+
index = repository.index
|
|
16
|
+
head = repository.tree
|
|
17
|
+
tracked = index.entries.group_by(&:path)
|
|
18
|
+
result = []
|
|
19
|
+
(head.keys | tracked.keys).sort.each do |path|
|
|
20
|
+
entries = tracked[path]
|
|
21
|
+
if entries&.any? { |entry| entry.stage != 0 }
|
|
22
|
+
stages = entries.map(&:stage)
|
|
23
|
+
code = {[1] => "DD", [2] => "AU", [3] => "UA", [1, 2] => "UD", [1, 3] => "DU", [2, 3] => "AA", [1, 2, 3] => "UU"}.fetch(stages.sort, "UU")
|
|
24
|
+
result << Entry.new(path: path, index: code[0], worktree: code[1])
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
staged = entries&.first
|
|
28
|
+
original = head[path]
|
|
29
|
+
x = if !original then "A"
|
|
30
|
+
elsif !staged then "D"
|
|
31
|
+
elsif original.mode != staged.mode || original.oid != staged.oid then "M"
|
|
32
|
+
else " " end
|
|
33
|
+
y = staged ? worktree_status(staged, index.path) : " "
|
|
34
|
+
result << Entry.new(path: path, index: x, worktree: y) unless x == " " && y == " "
|
|
35
|
+
end
|
|
36
|
+
submodules = index.entries.select { |entry| entry.mode == 0o160000 }.map { |entry| entry.path + "/" }
|
|
37
|
+
WorktreeFiles.new(repository.root, repository.common_dir).each do |path|
|
|
38
|
+
next if submodules.any? { |prefix| path.start_with?(prefix) }
|
|
39
|
+
result << Entry.new(path: path, index: "?", worktree: "?") unless tracked.key?(path)
|
|
40
|
+
end
|
|
41
|
+
result.sort_by(&:path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def worktree_status(entry, index_path)
|
|
47
|
+
absolute = @repository.worktree_path(entry.path)
|
|
48
|
+
stat = File.lstat(absolute)
|
|
49
|
+
if entry.mode == 0o160000
|
|
50
|
+
return "T" unless stat.directory?
|
|
51
|
+
begin
|
|
52
|
+
return Repository.new(absolute).head == entry.oid ? " " : "M"
|
|
53
|
+
rescue ArgumentError
|
|
54
|
+
return "M"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
mode = stat.symlink? ? 0o120000 : stat.file? ? 0o100000 | ((stat.mode & 0o100).positive? ? 0o755 : 0o644) : 0
|
|
58
|
+
return "T" if (mode & 0o170000) != (entry.mode & 0o170000)
|
|
59
|
+
return "M" if mode != entry.mode
|
|
60
|
+
return " " if (entry.extended_flags.to_i & 0x4000).positive? # skip-worktree
|
|
61
|
+
index_time = File.mtime(index_path)
|
|
62
|
+
if stat.size == entry.size && stat.mtime.to_i == entry.mtime && stat.mtime.nsec == entry.mtime_nsec &&
|
|
63
|
+
stat.ctime.to_i == entry.ctime && stat.ctime.nsec == entry.ctime_nsec && stat.mtime.to_i < index_time.to_i
|
|
64
|
+
return " "
|
|
65
|
+
end
|
|
66
|
+
content = stat.symlink? ? File.readlink(absolute).b : File.binread(absolute)
|
|
67
|
+
ObjectDatabase.hash("blob", content) == entry.oid ? " " : "M"
|
|
68
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
69
|
+
(entry.extended_flags.to_i & 0x4000).positive? ? " " : "D"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
class WorktreeFiles
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
def initialize(root, common_dir)
|
|
8
|
+
@root = File.realpath(root)
|
|
9
|
+
@common_dir = common_dir
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def each(&block)
|
|
13
|
+
return enum_for(__method__) unless block
|
|
14
|
+
|
|
15
|
+
rules = IgnoreMatcher.new
|
|
16
|
+
exclude = File.join(@common_dir, "info", "exclude")
|
|
17
|
+
rules = rules.add(File.read(exclude), base: "") if File.file?(exclude)
|
|
18
|
+
walk("", rules, {}, &block)
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def walk(directory, rules, visited, &block)
|
|
25
|
+
stat = File.stat(path(directory))
|
|
26
|
+
return if visited[[stat.dev, stat.ino]]
|
|
27
|
+
|
|
28
|
+
visited[[stat.dev, stat.ino]] = true
|
|
29
|
+
%w[.gitignore .ignore].each do |name|
|
|
30
|
+
source = path(directory.empty? ? name : File.join(directory, name))
|
|
31
|
+
rules = rules.add(File.read(source, encoding: "UTF-8"), base: directory) if File.file?(source)
|
|
32
|
+
end
|
|
33
|
+
Dir.children(path(directory)).sort.each do |name|
|
|
34
|
+
next if name == ".git"
|
|
35
|
+
|
|
36
|
+
relative = directory.empty? ? name : File.join(directory, name)
|
|
37
|
+
absolute = path(relative)
|
|
38
|
+
entry = File.lstat(absolute)
|
|
39
|
+
if entry.symlink?
|
|
40
|
+
yield relative unless rules.ignored?(relative)
|
|
41
|
+
elsif !rules.ignored?(relative, directory: entry.directory?)
|
|
42
|
+
if entry.directory?
|
|
43
|
+
walk(relative, rules, visited, &block)
|
|
44
|
+
elsif entry.file?
|
|
45
|
+
yield relative
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def path(relative) = File.expand_path(relative, @root)
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/thuban.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "thuban/version"
|
|
4
|
+
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "find"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "porrima"
|
|
9
|
+
|
|
10
|
+
require_relative "thuban/ignore_matcher"
|
|
11
|
+
require_relative "thuban/worktree_files"
|
|
12
|
+
require_relative "thuban/object_database"
|
|
13
|
+
require_relative "thuban/index"
|
|
14
|
+
require_relative "thuban/status"
|
|
15
|
+
require_relative "thuban/blame"
|
|
16
|
+
require_relative "thuban/commit"
|
|
17
|
+
require_relative "thuban/tree_entry"
|
|
18
|
+
require_relative "thuban/repository"
|
data/sig/thuban.rbs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module Thuban
|
|
2
|
+
VERSION: String
|
|
3
|
+
type object = [String, String]
|
|
4
|
+
|
|
5
|
+
interface _Differ
|
|
6
|
+
def edits: (Porrima::lines, Porrima::lines) -> Array[Porrima::Edit]
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class CorruptObject < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Commit < Struct[untyped]
|
|
13
|
+
attr_accessor oid: String
|
|
14
|
+
attr_accessor tree: String?
|
|
15
|
+
attr_accessor parents: Array[String]
|
|
16
|
+
attr_accessor author: String?
|
|
17
|
+
attr_accessor committer: String?
|
|
18
|
+
attr_accessor message: String
|
|
19
|
+
def self.new: (oid: String, ?tree: String?, parents: Array[String], ?author: String?, ?committer: String?, message: String) -> instance
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class TreeEntry < Struct[untyped]
|
|
23
|
+
attr_accessor path: String
|
|
24
|
+
attr_accessor oid: String
|
|
25
|
+
attr_accessor mode: Integer
|
|
26
|
+
def self.new: (path: String, oid: String, mode: Integer) -> instance
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Repository
|
|
30
|
+
attr_reader root: String?
|
|
31
|
+
attr_reader git_dir: String
|
|
32
|
+
attr_reader common_dir: String
|
|
33
|
+
attr_reader odb: ObjectDatabase
|
|
34
|
+
def initialize: (String path) -> void
|
|
35
|
+
def object: (String oid) -> object
|
|
36
|
+
def index: () -> Index
|
|
37
|
+
def head: () -> String?
|
|
38
|
+
def branch: () -> String?
|
|
39
|
+
def refs: () -> Hash[String, String?]
|
|
40
|
+
def branches: () -> Array[String]
|
|
41
|
+
def resolve: (String name, ?Array[String] seen) -> String?
|
|
42
|
+
def commit: (?String reference) -> Commit?
|
|
43
|
+
def tree: (?String reference) -> Hash[String, TreeEntry]
|
|
44
|
+
def blob: (String path, ?reference: String) -> String?
|
|
45
|
+
def staged_blob: (String path) -> String?
|
|
46
|
+
def worktree_content: (String path) -> String?
|
|
47
|
+
def worktree_path: (String path, ?replacing: Array[String]) -> String
|
|
48
|
+
def write: (String path, String content) -> String
|
|
49
|
+
def status: () -> Array[Status::Entry]
|
|
50
|
+
def blame: (String path, ?reference: String, ?differ: _Differ) -> Array[Blame::Line]
|
|
51
|
+
def checkout: (String name) -> String
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class ObjectDatabase
|
|
55
|
+
attr_reader directory: String
|
|
56
|
+
def initialize: (String directory) -> void
|
|
57
|
+
def self.hash: (String type, String data) -> String
|
|
58
|
+
def read: (String oid, ?Array[String] seen) -> object
|
|
59
|
+
def packs: () -> Array[Pack]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class Pack
|
|
63
|
+
TYPES: Hash[Integer, String]
|
|
64
|
+
MAX_OBJECT_SIZE: Integer
|
|
65
|
+
attr_reader path: String
|
|
66
|
+
attr_reader offsets: Hash[String, Integer]
|
|
67
|
+
def initialize: (String index_path) -> void
|
|
68
|
+
def include?: (String oid) -> bool
|
|
69
|
+
def read: (String oid) ?{ (String) -> object } -> object
|
|
70
|
+
def self.apply_delta: (String base, String delta) -> String
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Index
|
|
74
|
+
include Enumerable[Entry]
|
|
75
|
+
class Entry < Struct[untyped]
|
|
76
|
+
attr_accessor path: String
|
|
77
|
+
attr_accessor oid: String
|
|
78
|
+
attr_accessor mode: Integer
|
|
79
|
+
attr_accessor size: Integer
|
|
80
|
+
attr_accessor mtime: Integer
|
|
81
|
+
attr_accessor mtime_nsec: Integer
|
|
82
|
+
attr_accessor ctime: Integer
|
|
83
|
+
attr_accessor ctime_nsec: Integer
|
|
84
|
+
attr_accessor dev: Integer
|
|
85
|
+
attr_accessor ino: Integer
|
|
86
|
+
attr_accessor uid: Integer
|
|
87
|
+
attr_accessor gid: Integer
|
|
88
|
+
attr_accessor stage: Integer
|
|
89
|
+
attr_accessor flags: Integer?
|
|
90
|
+
attr_accessor extended_flags: Integer?
|
|
91
|
+
def self.new: (path: String, oid: String, mode: Integer, size: Integer, mtime: Integer, mtime_nsec: Integer, ctime: Integer, ctime_nsec: Integer, dev: Integer, ino: Integer, uid: Integer, gid: Integer, stage: Integer, ?flags: Integer?, ?extended_flags: Integer?) -> instance
|
|
92
|
+
end
|
|
93
|
+
attr_reader entries: Array[Entry]
|
|
94
|
+
attr_reader version: Integer
|
|
95
|
+
attr_reader path: String
|
|
96
|
+
def initialize: (String path) -> void
|
|
97
|
+
def each: () -> Enumerator[Entry, Array[Entry]]
|
|
98
|
+
| () { (Entry) -> void } -> Array[Entry]
|
|
99
|
+
def []: (String path) -> Entry?
|
|
100
|
+
def self.encode: (Array[Entry] entries) -> String
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class Status
|
|
104
|
+
class Entry < Struct[untyped]
|
|
105
|
+
attr_accessor path: String
|
|
106
|
+
attr_accessor index: String
|
|
107
|
+
attr_accessor worktree: String
|
|
108
|
+
def self.new: (path: String, index: String, worktree: String) -> instance
|
|
109
|
+
def code: () -> String
|
|
110
|
+
end
|
|
111
|
+
def initialize: (Repository repository) -> void
|
|
112
|
+
def call: () -> Array[Entry]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class Blame
|
|
116
|
+
class Line < Struct[untyped]
|
|
117
|
+
attr_accessor line: Integer
|
|
118
|
+
attr_accessor text: String
|
|
119
|
+
attr_accessor commit: String
|
|
120
|
+
attr_accessor author: String?
|
|
121
|
+
attr_accessor original_line: Integer
|
|
122
|
+
attr_accessor path: String
|
|
123
|
+
def self.new: (line: Integer, text: String, commit: String, author: String?, original_line: Integer, path: String) -> instance
|
|
124
|
+
end
|
|
125
|
+
def initialize: (Repository repository) -> void
|
|
126
|
+
def call: (String path, ?reference: String, ?differ: _Differ) -> Array[Line]
|
|
127
|
+
end
|
|
128
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: thuban
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: porrima
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
email:
|
|
27
|
+
- t.yudai92@gmail.com
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- CHANGELOG.md
|
|
33
|
+
- LICENSE.txt
|
|
34
|
+
- README.md
|
|
35
|
+
- lib/thuban.rb
|
|
36
|
+
- lib/thuban/blame.rb
|
|
37
|
+
- lib/thuban/commit.rb
|
|
38
|
+
- lib/thuban/corrupt_object.rb
|
|
39
|
+
- lib/thuban/ignore_matcher.rb
|
|
40
|
+
- lib/thuban/index.rb
|
|
41
|
+
- lib/thuban/object_database.rb
|
|
42
|
+
- lib/thuban/pack.rb
|
|
43
|
+
- lib/thuban/repository.rb
|
|
44
|
+
- lib/thuban/status.rb
|
|
45
|
+
- lib/thuban/tree_entry.rb
|
|
46
|
+
- lib/thuban/version.rb
|
|
47
|
+
- lib/thuban/worktree_files.rb
|
|
48
|
+
- sig/thuban.rbs
|
|
49
|
+
homepage: https://github.com/noxdea/thuban
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
source_code_uri: https://github.com/noxdea/thuban
|
|
54
|
+
changelog_uri: https://github.com/noxdea/thuban/blob/main/CHANGELOG.md
|
|
55
|
+
allowed_push_host: https://rubygems.org
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.1'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 4.0.19
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: A pure Ruby Git repository reader
|
|
74
|
+
test_files: []
|