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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +89 -7
- data/lib/thuban/commit_writer.rb +60 -0
- data/lib/thuban/history_writer.rb +317 -0
- data/lib/thuban/index.rb +46 -6
- data/lib/thuban/index_writer.rb +95 -0
- data/lib/thuban/object_writer.rb +139 -0
- data/lib/thuban/pack_writer.rb +76 -0
- data/lib/thuban/ref_lock_error.rb +6 -0
- data/lib/thuban/ref_store.rb +270 -0
- data/lib/thuban/signature.rb +5 -0
- data/lib/thuban/stash.rb +156 -0
- data/lib/thuban/version.rb +1 -1
- data/lib/thuban/worktree_files.rb +1 -7
- data/lib/thuban.rb +9 -0
- data/sig/thuban.rbs +54 -1
- metadata +11 -2
|
@@ -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,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
|