thuban 0.2.0 → 0.3.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.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class Index
5
+ WRITABLE_MODES = [0o100644, 0o100755, 0o120000, 0o160000].freeze
6
+ ENTRY_DEPENDENT_EXTENSIONS = %w[TREE EOIE IEOT].freeze
7
+
8
+ def write
9
+ lock_path = path + ".lock"
10
+ FileUtils.mkdir_p(File.dirname(path))
11
+ file = File.open(lock_path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
12
+ owns_lock = true
13
+ file.write(self.class.encode(entries, extensions: extensions, version: version))
14
+ file.flush
15
+ file.fsync
16
+ file.close
17
+ File.rename(lock_path, path)
18
+ owns_lock = false
19
+ self
20
+ rescue Errno::EEXIST
21
+ raise IOError, "Git index is locked: #{lock_path}"
22
+ ensure
23
+ file&.close unless file&.closed?
24
+ File.unlink(lock_path) if owns_lock && File.exist?(lock_path)
25
+ end
26
+
27
+ def stage(path, oid, mode, stat: nil)
28
+ validate_entry(path, oid, mode)
29
+ entry = Entry.new(path: path, oid: oid, mode: mode, stage: 0, flags: 0, extended_flags: 0,
30
+ **stat_fields(stat))
31
+ entries.reject! { |current| current.path == path }
32
+ entries << entry
33
+ entries.sort_by! { |current| [current.path.b, current.stage] }
34
+ invalidate_entry_extensions
35
+ entry
36
+ end
37
+
38
+ def unstage(path)
39
+ validate_path(path)
40
+ removed = entries.select { |entry| entry.path == path && entry.stage.zero? }
41
+ entries.reject! { |entry| entry.path == path && entry.stage.zero? }
42
+ invalidate_entry_extensions unless removed.empty?
43
+ removed
44
+ end
45
+
46
+ def remove(path)
47
+ validate_path(path)
48
+ removed = entries.select { |entry| entry.path == path }
49
+ entries.reject! { |entry| entry.path == path }
50
+ invalidate_entry_extensions unless removed.empty?
51
+ removed
52
+ end
53
+
54
+ def conflicts = entries.select { |entry| !entry.stage.zero? }
55
+
56
+ def resolve(path, oid, mode)
57
+ validate_path(path)
58
+ raise ArgumentError, "path has no index conflict: #{path}" unless entries.any? { |entry| entry.path == path && !entry.stage.zero? }
59
+
60
+ stage(path, oid, mode)
61
+ end
62
+
63
+ private
64
+
65
+ def validate_entry(path, oid, mode)
66
+ validate_path(path)
67
+ raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(oid.to_s)
68
+ raise ArgumentError, "invalid index mode" unless WRITABLE_MODES.include?(mode)
69
+ end
70
+
71
+ def validate_path(path)
72
+ safe = path.is_a?(String) && !path.empty? && !path.include?("\0") && !path.include?("\\") && !path.start_with?("/") &&
73
+ !path.split("/").any? { |part| ["", ".", ".."].include?(part) || part.casecmp?(".git") }
74
+ raise ArgumentError, "unsafe index path" unless safe
75
+ end
76
+
77
+ def stat_fields(stat)
78
+ return {size: 0, mtime: 0, mtime_nsec: 0, ctime: 0, ctime_nsec: 0, dev: 0, ino: 0, uid: 0, gid: 0} unless stat
79
+
80
+ mtime = stat_value(stat, :mtime)
81
+ ctime = stat_value(stat, :ctime)
82
+ {size: stat_value(stat, :size).to_i, mtime: mtime.to_i, mtime_nsec: mtime.respond_to?(:nsec) ? mtime.nsec : 0,
83
+ ctime: ctime.to_i, ctime_nsec: ctime.respond_to?(:nsec) ? ctime.nsec : 0, dev: stat_value(stat, :dev).to_i,
84
+ ino: stat_value(stat, :ino).to_i, uid: stat_value(stat, :uid).to_i, gid: stat_value(stat, :gid).to_i}
85
+ end
86
+
87
+ def stat_value(stat, name)
88
+ stat.respond_to?(name) ? stat.public_send(name) : stat.fetch(name, 0)
89
+ end
90
+
91
+ def invalidate_entry_extensions
92
+ extensions.reject! { |extension| ENTRY_DEPENDENT_EXTENSIONS.include?(extension.byteslice(0, 4)) }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class ObjectDatabase
5
+ TYPES = %w[blob tree commit tag].freeze
6
+
7
+ def exist?(oid)
8
+ validate_oid(oid)
9
+ loose = File.join(directory, oid[0, 2], oid[2..])
10
+ raise ArgumentError, "unsafe loose object path" if File.symlink?(loose)
11
+ if File.file?(loose)
12
+ validate_object_directory(File.dirname(loose))
13
+ return true
14
+ end
15
+ return true if packs.any? { |pack| pack.include?(oid) }
16
+
17
+ @packs = nil
18
+ packs.any? { |pack| pack.include?(oid) }
19
+ end
20
+
21
+ def write(type, data) = write_loose(type, data)
22
+
23
+ def write_loose(type, data)
24
+ raise ArgumentError, "invalid Git object type" unless TYPES.include?(type)
25
+ raise TypeError, "object data must be a String" unless data.is_a?(String)
26
+
27
+ raw = "#{type} #{data.bytesize}\0".b + data.b
28
+ oid = Digest::SHA1.hexdigest(raw)
29
+ return oid if exist?(oid)
30
+
31
+ target = File.join(directory, oid[0, 2], oid[2..])
32
+ object_directory = File.dirname(target)
33
+ raise ArgumentError, "unsafe loose object directory" if File.symlink?(object_directory)
34
+ FileUtils.mkdir_p(object_directory)
35
+ validate_object_directory(object_directory)
36
+ Tempfile.create([".thuban-object-", ".tmp"], File.dirname(target)) do |file|
37
+ file.binmode
38
+ file.chmod(0o444)
39
+ file.write(Zlib::Deflate.deflate(raw))
40
+ file.flush
41
+ file.fsync
42
+ file.close
43
+ File.rename(file.path, target) unless File.exist?(target)
44
+ end
45
+ oid
46
+ end
47
+
48
+ private
49
+
50
+ def validate_oid(oid)
51
+ raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(oid.to_s)
52
+ end
53
+
54
+ def validate_object_directory(path)
55
+ root = File.realpath(directory)
56
+ raise ArgumentError, "unsafe loose object directory" if File.symlink?(path) || !File.realpath(path).start_with?(root + File::SEPARATOR)
57
+ end
58
+ end
59
+
60
+ class Repository
61
+ TREE_MODES = [0o040000, 0o100644, 0o100755, 0o120000, 0o160000].freeze
62
+
63
+ def write_blob(content) = odb.write("blob", content)
64
+
65
+ def write_tree(entries)
66
+ seen = {}
67
+ records = entries.map do |entry|
68
+ name = entry.path
69
+ raise ArgumentError, "unsafe tree entry" unless name.is_a?(String) && !name.empty? && !name.include?("/") && !name.include?("\0") && ![".", ".."].include?(name) && !name.casecmp?(".git")
70
+ raise ArgumentError, "duplicate tree entry: #{name}" if seen[name.b]
71
+ raise ArgumentError, "invalid tree mode" unless TREE_MODES.include?(entry.mode)
72
+ raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(entry.oid.to_s)
73
+
74
+ unless entry.mode == 0o160000
75
+ expected = entry.mode == 0o040000 ? "tree" : "blob"
76
+ type, = odb.read(entry.oid)
77
+ raise ArgumentError, "tree entry mode does not match object" unless type == expected
78
+ end
79
+
80
+ seen[name.b] = true
81
+ [name.b + (entry.mode == 0o040000 ? "/" : ""), "#{entry.mode.to_s(8)} #{name}\0".b + [entry.oid].pack("H*")]
82
+ end
83
+ odb.write("tree", records.sort_by(&:first).map(&:last).join)
84
+ end
85
+
86
+ def write_commit(tree:, parents: [], author:, committer: nil, message:)
87
+ validate_object_type(tree, "tree")
88
+ parents.each { |oid| validate_object_type(oid, "commit") }
89
+ raise ArgumentError, "duplicate commit parent" unless parents.uniq.length == parents.length
90
+ raise TypeError, "message must be a String" unless message.is_a?(String)
91
+ raise ArgumentError, "commit message contains NUL" if message.include?("\0")
92
+
93
+ author_line = format_signature(author)
94
+ committer_line = committer ? format_signature(committer) : author_line
95
+ body = +"tree #{tree}\n"
96
+ parents.each { |oid| body << "parent #{oid}\n" }
97
+ body << "author #{author_line}\ncommitter #{committer_line}\n\n#{message}"
98
+ body << "\n" unless body.end_with?("\n")
99
+ odb.write("commit", body)
100
+ end
101
+
102
+ private
103
+
104
+ def validate_object_type(oid, expected)
105
+ raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(oid.to_s)
106
+ type, = odb.read(oid)
107
+ raise ArgumentError, "expected #{expected} object" unless type == expected
108
+ end
109
+
110
+ def format_signature(signature)
111
+ raise TypeError, "expected Thuban::Signature" unless signature.is_a?(Signature)
112
+ raise TypeError, "signature name and email must be Strings" unless signature.name.is_a?(String) && signature.email.is_a?(String)
113
+ name, email = signature.name, signature.email
114
+ raise ArgumentError, "invalid signature name" if name.empty? || name.match?(/[\x00-\x1f\x7f<>]/)
115
+ raise ArgumentError, "invalid signature email" if email.empty? || email.match?(/[\x00-\x1f\x7f<>]/)
116
+
117
+ time = signature.time || Time.now
118
+ timestamp = begin
119
+ time.respond_to?(:to_time) ? time.to_time.to_i : Integer(time)
120
+ rescue ArgumentError, TypeError
121
+ raise ArgumentError, "invalid signature time"
122
+ end
123
+ offset = signature.offset
124
+ offset = time.utc_offset if offset.nil? && time.respond_to?(:utc_offset)
125
+ offset ||= 0
126
+ zone = if offset.is_a?(Integer)
127
+ raise ArgumentError, "invalid signature offset" if offset.abs > 86_340
128
+ sign = offset.negative? ? "-" : "+"
129
+ minutes = offset.abs / 60
130
+ format("%s%02d%02d", sign, minutes / 60, minutes % 60)
131
+ else
132
+ offset.to_s
133
+ end
134
+ raise ArgumentError, "invalid signature offset" unless /\A[+-](?:[01]\d|2[0-3])[0-5]\d\z/.match?(zone)
135
+
136
+ "#{name} <#{email}> #{timestamp} #{zone}"
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class Pack
5
+ TYPE_CODES = TYPES.invert.freeze
6
+
7
+ def self.write(io, objects)
8
+ raise TypeError, "pack output must respond to write" unless io.respond_to?(:write)
9
+
10
+ raise TypeError, "objects must be enumerable" unless objects.respond_to?(:each)
11
+
12
+ entries = objects.to_a
13
+ raise ArgumentError, "too many pack objects" if entries.length > 0xffffffff
14
+ entries.each do |entry|
15
+ valid = entry.is_a?(Array) && entry.length == 2 && TYPE_CODES.key?(entry[0]) && entry[1].is_a?(String)
16
+ raise ArgumentError, "objects must be [type, data] pairs" unless valid
17
+ raise ArgumentError, "pack object too large" if entry[1].bytesize > MAX_OBJECT_SIZE
18
+ end
19
+
20
+ digest = Digest::SHA1.new
21
+ sink = lambda do |bytes|
22
+ write_all(io, bytes)
23
+ digest.update(bytes)
24
+ end
25
+ sink.call("PACK".b + [2, entries.length].pack("N2"))
26
+ entries.each_with_index do |(type, data), index|
27
+ sink.call(encode_object_header(TYPE_CODES.fetch(type), data.bytesize))
28
+ deflater = Zlib::Deflate.new
29
+ begin
30
+ offset = 0
31
+ while offset < data.bytesize
32
+ chunk = data.byteslice(offset, 65_536)
33
+ compressed = deflater.deflate(chunk)
34
+ sink.call(compressed) unless compressed.empty?
35
+ offset += chunk.bytesize
36
+ end
37
+ compressed = deflater.finish
38
+ sink.call(compressed) unless compressed.empty?
39
+ ensure
40
+ deflater.close
41
+ end
42
+ yield index + 1, entries.length if block_given?
43
+ end
44
+ checksum = digest.digest
45
+ write_all(io, checksum)
46
+ checksum.unpack1("H*")
47
+ end
48
+
49
+ def self.encode_object_header(type, size)
50
+ first = (type << 4) | (size & 0x0f)
51
+ size >>= 4
52
+ bytes = []
53
+ while size.positive?
54
+ bytes << (first | 0x80)
55
+ first = size & 0x7f
56
+ size >>= 7
57
+ end
58
+ bytes << first
59
+ bytes.pack("C*")
60
+ end
61
+ private_class_method :encode_object_header
62
+
63
+ def self.write_all(io, bytes)
64
+ offset = 0
65
+ while offset < bytes.bytesize
66
+ remaining = bytes.bytesize - offset
67
+ written = io.write(bytes.byteslice(offset, remaining))
68
+ valid = written.is_a?(Integer) && written.positive? && written <= remaining
69
+ raise IOError, "pack output stopped accepting bytes" unless valid
70
+
71
+ offset += written
72
+ end
73
+ end
74
+ private_class_method :write_all
75
+ end
76
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class RefLockError < StandardError
5
+ end
6
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class RefStore
5
+ ZERO_OID = "0" * 40
6
+
7
+ def initialize(repository)
8
+ @repository = repository
9
+ end
10
+
11
+ def update(name, new_oid, old_oid: :any, message: nil)
12
+ validate_ref(name)
13
+ validate_oid(new_oid)
14
+ raise ArgumentError, "Git object not found: #{new_oid}" unless repository.odb.exist?(new_oid)
15
+
16
+ target = dereference(name)
17
+ identity = repository.send(:format_signature, reflog_signature)
18
+ action = message.to_s.gsub(/[\x00-\x1f]/, " ")
19
+ [target, name].uniq.each { |reference| prepare_storage_path(log_path(reference)) }
20
+ paths = [ref_path(target)]
21
+ paths << ref_path(name) if target != name
22
+ with_locks(paths) do |locks|
23
+ raise RefLockError, "symbolic reference changed: #{name}" unless dereference(name) == target
24
+ current = repository.resolve(target)
25
+ verify_old_oid(name, current, old_oid)
26
+ rollback = yield current if block_given?
27
+ logs = [target, name].uniq.map { |reference| log_path(reference) }
28
+ log_sizes = logs.to_h { |path| [path, File.file?(path) ? File.size(path) : nil] }
29
+ begin
30
+ file = locks.fetch(ref_path(target))
31
+ file.write("#{new_oid}\n")
32
+ sync(file)
33
+ append_reflog(target, current, new_oid, identity, action)
34
+ append_reflog(name, current, new_oid, identity, action) if target != name
35
+ File.rename(file.path, ref_path(target))
36
+ locks.delete(ref_path(target))
37
+ rescue StandardError
38
+ restore_reflogs(log_sizes)
39
+ rollback.call if rollback.respond_to?(:call)
40
+ raise
41
+ end
42
+ end
43
+ new_oid
44
+ end
45
+
46
+ def delete(name, old_oid: :any)
47
+ validate_ref(name, head: false)
48
+ paths = [ref_path(name)]
49
+ packed_path = File.join(repository.common_dir, "packed-refs")
50
+ paths << packed_path if File.file?(packed_path)
51
+ prepare_storage_path(log_path(name))
52
+ deleted = nil
53
+ with_locks(paths) do |locks|
54
+ current = repository.resolve(name)
55
+ verify_old_oid(name, current, old_oid)
56
+ raise RefLockError, "reference does not exist: #{name}" unless current
57
+ deleted = current
58
+ locks.delete(packed_path) if locks[packed_path] && rewrite_packed_refs(locks[packed_path], packed_path, name)
59
+ File.unlink(ref_path(name)) if File.file?(ref_path(name)) || File.symlink?(ref_path(name))
60
+ File.unlink(log_path(name)) if File.file?(log_path(name))
61
+ end
62
+ deleted
63
+ end
64
+
65
+ def symbolic(name, target)
66
+ validate_ref(name)
67
+ validate_ref(target, head: false)
68
+ path = ref_path(name)
69
+ with_locks([path]) do |locks|
70
+ file = locks.fetch(path)
71
+ file.write("ref: #{target}\n")
72
+ sync(file)
73
+ File.rename(file.path, path)
74
+ locks.delete(path)
75
+ end
76
+ target
77
+ end
78
+
79
+ def reflog(name)
80
+ name = "refs/heads/#{name}" unless name == "HEAD" || name.start_with?("refs/")
81
+ validate_ref(name)
82
+ path = log_path(name)
83
+ return [] unless File.file?(path)
84
+ validate_storage_path(path)
85
+ File.readlines(path, chomp: true, encoding: Encoding::BINARY)
86
+ end
87
+
88
+ private
89
+
90
+ attr_reader :repository
91
+
92
+ def validate_ref(name, head: true)
93
+ return if head && name == "HEAD"
94
+ invalid = !name.is_a?(String) || !name.start_with?("refs/") || name.end_with?("/", ".") ||
95
+ name.include?("..") || name.include?("@{") || name.match?(/[\x00-\x20\x7f~^:?*\[\\]/) ||
96
+ name.split("/").any? { |part| part.empty? || part.start_with?(".") || part.downcase.end_with?(".lock") }
97
+ raise ArgumentError, "invalid Git reference" if invalid
98
+ end
99
+
100
+ def validate_oid(oid)
101
+ raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(oid.to_s)
102
+ end
103
+
104
+ def dereference(name)
105
+ seen = []
106
+ loop do
107
+ raise CorruptObject, "cyclic symbolic reference" if seen.include?(name) || seen.length > 32
108
+ seen << name
109
+ path = ref_path(name)
110
+ raise ArgumentError, "unsafe Git metadata path" if File.symlink?(path)
111
+ return name unless File.file?(path)
112
+ value = File.read(path).strip
113
+ return name unless value.start_with?("ref: ")
114
+ name = value.delete_prefix("ref: ")
115
+ validate_ref(name, head: false)
116
+ end
117
+ end
118
+
119
+ def ref_path(name) = File.join(name == "HEAD" ? repository.git_dir : repository.common_dir, name)
120
+ def log_path(name) = File.join(name == "HEAD" ? repository.git_dir : repository.common_dir, "logs", name)
121
+
122
+ def with_locks(paths)
123
+ locks = {}
124
+ paths.uniq.sort.each do |path|
125
+ prepare_storage_path(path)
126
+ file = File.open(path + ".lock", File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
127
+ locks[path] = file
128
+ end
129
+ yield locks
130
+ rescue Errno::EEXIST => error
131
+ raise RefLockError, "reference is locked: #{error.message}"
132
+ ensure
133
+ locks&.each_value do |file|
134
+ file.close unless file.closed?
135
+ File.unlink(file.path) if File.exist?(file.path)
136
+ end
137
+ end
138
+
139
+ def verify_old_oid(name, current, expected)
140
+ return if expected == :any || current == expected
141
+ raise RefLockError, "reference changed: #{name}"
142
+ end
143
+
144
+ def sync(file)
145
+ file.flush
146
+ file.fsync
147
+ file.close
148
+ end
149
+
150
+ def append_reflog(name, old_oid, new_oid, identity, action)
151
+ path = log_path(name)
152
+ prepare_storage_path(path)
153
+ File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::BINARY, 0o644) do |file|
154
+ file.write("#{old_oid || ZERO_OID} #{new_oid} #{identity}\t#{action}\n")
155
+ file.flush
156
+ file.fsync
157
+ end
158
+ end
159
+
160
+ def restore_reflogs(sizes)
161
+ sizes.each do |path, size|
162
+ if size
163
+ File.truncate(path, size)
164
+ elsif File.file?(path)
165
+ File.unlink(path)
166
+ end
167
+ end
168
+ end
169
+
170
+ def reflog_signature
171
+ Signature.new(name: ENV["GIT_COMMITTER_NAME"] || config_value("name") || ENV["USER"] || "unknown",
172
+ email: ENV["GIT_COMMITTER_EMAIL"] || config_value("email") || "unknown@localhost", time: Time.now)
173
+ end
174
+
175
+ def validate_storage_path(path)
176
+ root = storage_root(path)
177
+ relative = path.delete_prefix(root).delete_prefix(File::SEPARATOR)
178
+ cursor = root
179
+ relative.split(File::SEPARATOR).each do |part|
180
+ cursor = File.join(cursor, part)
181
+ raise ArgumentError, "unsafe Git metadata path" if File.symlink?(cursor)
182
+ end
183
+ parent = File.realpath(File.dirname(path))
184
+ real_root = File.realpath(root)
185
+ return if parent == real_root || parent.start_with?(real_root + File::SEPARATOR)
186
+
187
+ raise ArgumentError, "unsafe Git metadata path"
188
+ end
189
+
190
+ def prepare_storage_path(path)
191
+ root = storage_root(path)
192
+ relative = File.dirname(path).delete_prefix(root).delete_prefix(File::SEPARATOR)
193
+ cursor = root
194
+ relative.split(File::SEPARATOR).each do |part|
195
+ cursor = File.join(cursor, part)
196
+ raise ArgumentError, "unsafe Git metadata path" if File.symlink?(cursor)
197
+ begin
198
+ Dir.mkdir(cursor)
199
+ rescue Errno::EEXIST
200
+ raise ArgumentError, "unsafe Git metadata path" unless File.directory?(cursor) && !File.symlink?(cursor)
201
+ end
202
+ end
203
+ validate_storage_path(path)
204
+ end
205
+
206
+ def storage_root(path)
207
+ root = [repository.git_dir, repository.common_dir].uniq.sort_by { |candidate| -candidate.length }
208
+ .find { |candidate| path == candidate || path.start_with?(candidate + File::SEPARATOR) }
209
+ raise ArgumentError, "unsafe Git metadata path" unless root
210
+
211
+ root
212
+ end
213
+
214
+ def config_value(key)
215
+ section = nil
216
+ File.foreach(File.join(repository.common_dir, "config"), encoding: "UTF-8") do |line|
217
+ stripped = line.strip
218
+ if (section_match = stripped.match(/\A\[\s*([^\s\]"]+)/))
219
+ section = section_match[1].downcase
220
+ elsif section == "user" && (value_match = stripped.match(/\A#{key}\s*=\s*(.*)\z/i))
221
+ return value_match[1].strip.delete_prefix("\"").delete_suffix("\"")
222
+ end
223
+ end
224
+ nil
225
+ rescue Errno::ENOENT, Errno::EACCES
226
+ nil
227
+ end
228
+
229
+ def rewrite_packed_refs(file, path, name)
230
+ lines = File.readlines(path, mode: "rb")
231
+ kept = []
232
+ removing = false
233
+ lines.each do |line|
234
+ if line.start_with?("^")
235
+ kept << line unless removing
236
+ removing = false
237
+ else
238
+ removing = line.split(" ", 2)[1]&.strip == name
239
+ kept << line unless removing
240
+ end
241
+ end
242
+ return unless kept.length != lines.length
243
+
244
+ file.write(kept.join)
245
+ sync(file)
246
+ File.rename(file.path, path)
247
+ end
248
+ end
249
+
250
+ class Repository
251
+ def update_ref(name, new_oid, old_oid: :any, message: nil) = RefStore.new(self).update(name, new_oid, old_oid: old_oid, message: message)
252
+ def delete_ref(name, old_oid: :any) = RefStore.new(self).delete(name, old_oid: old_oid)
253
+
254
+ def create_branch(name, oid)
255
+ raise ArgumentError, "invalid branch name" if name.to_s.start_with?("-")
256
+ validate_object_type(oid, "commit")
257
+ update_ref("refs/heads/#{name}", oid, old_oid: nil, message: "branch: Created")
258
+ name
259
+ end
260
+
261
+ def delete_branch(name)
262
+ raise ArgumentError, "cannot delete the current branch" if branch == name
263
+ delete_ref("refs/heads/#{name}")
264
+ name
265
+ end
266
+
267
+ def symbolic_ref(name, target) = RefStore.new(self).symbolic(name, target)
268
+ def reflog(name) = RefStore.new(self).reflog(name)
269
+ end
270
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ Signature = Struct.new(:name, :email, :time, :offset, keyword_init: true)
5
+ end