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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-12
4
+
5
+ - Initial release
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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ Commit = Struct.new(:oid, :tree, :parents, :author, :committer, :message, keyword_init: true)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class CorruptObject < StandardError; end
5
+ 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
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ TreeEntry = Struct.new(:path, :oid, :mode, keyword_init: true)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ VERSION = "0.1.0"
5
+ 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: []