thuban 0.3.0 → 0.4.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 +12 -0
- data/README.md +150 -7
- data/lib/thuban/pack_reader.rb +216 -0
- data/lib/thuban/remote/credentials.rb +209 -0
- data/lib/thuban/remote/fetch.rb +287 -0
- data/lib/thuban/remote/http.rb +233 -0
- data/lib/thuban/remote/local.rb +55 -0
- data/lib/thuban/remote/protocol.rb +91 -0
- data/lib/thuban/remote/push.rb +250 -0
- data/lib/thuban/remote/repository.rb +223 -0
- data/lib/thuban/remote/ssh.rb +248 -0
- data/lib/thuban/remote.rb +27 -0
- data/lib/thuban/version.rb +1 -1
- data/lib/thuban.rb +10 -0
- data/sig/thuban.rbs +65 -0
- metadata +14 -4
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Thuban
|
|
6
|
+
module Remote
|
|
7
|
+
class Connection
|
|
8
|
+
MAX_PUSH_UPDATES = 1024
|
|
9
|
+
MAX_PUSH_RESPONSE_SIZE = 8 * 1024 * 1024
|
|
10
|
+
ZERO_OID = "0" * 40
|
|
11
|
+
|
|
12
|
+
def push(repository, updates, atomic: false)
|
|
13
|
+
ensure_open
|
|
14
|
+
raise TypeError, "expected Thuban::Repository" unless repository.is_a?(Repository)
|
|
15
|
+
raise ArgumentError, "atomic must be true or false" unless [true, false].include?(atomic)
|
|
16
|
+
|
|
17
|
+
updates = normalize_push_updates(repository, updates)
|
|
18
|
+
advertised = receive_refs
|
|
19
|
+
updates.each do |update|
|
|
20
|
+
current = advertised.find { |ref| ref.name == update[:ref] }&.oid || ZERO_OID
|
|
21
|
+
expected = update[:old] || current
|
|
22
|
+
raise TransportError, "remote reference changed: #{update[:ref]}" unless current == expected
|
|
23
|
+
|
|
24
|
+
update[:old] = expected
|
|
25
|
+
end
|
|
26
|
+
capabilities = push_capabilities(atomic, updates)
|
|
27
|
+
objects = push_objects(repository, updates.map { |update| update[:new] }, advertised)
|
|
28
|
+
output = StringIO.new(Protocol.commands(updates, capabilities))
|
|
29
|
+
output.seek(0, IO::SEEK_END)
|
|
30
|
+
Pack.write(output, objects) do |current, total|
|
|
31
|
+
ensure_open
|
|
32
|
+
yield Progress.new(phase: :pack, current: current, total: total, bytes: output.pos) if block_given?
|
|
33
|
+
end if updates.any? { |update| update[:new] != ZERO_OID }
|
|
34
|
+
|
|
35
|
+
response = request(:post, "/git-receive-pack", body: output.string,
|
|
36
|
+
headers: {"Content-Type" => "application/x-git-receive-pack-request",
|
|
37
|
+
"Accept" => "application/x-git-receive-pack-result"},
|
|
38
|
+
content_type: "application/x-git-receive-pack-result", limit: MAX_PUSH_RESPONSE_SIZE)
|
|
39
|
+
parse_push_response(response, updates, capabilities) do |progress|
|
|
40
|
+
yield progress if block_given?
|
|
41
|
+
end
|
|
42
|
+
updates.map { |update| Ref.new(name: update[:ref], oid: update[:new] == ZERO_OID ? nil : update[:new]) }
|
|
43
|
+
ensure
|
|
44
|
+
@receive_refs = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def receive_refs
|
|
50
|
+
return @receive_refs if @receive_refs
|
|
51
|
+
|
|
52
|
+
fetch_capabilities = @capabilities
|
|
53
|
+
begin
|
|
54
|
+
body = request(:get, "/info/refs", query: "service=git-receive-pack",
|
|
55
|
+
headers: {"Accept" => "application/x-git-receive-pack-advertisement"},
|
|
56
|
+
content_type: "application/x-git-receive-pack-advertisement", limit: MAX_ADVERTISEMENT_SIZE)
|
|
57
|
+
reader = Protocol::Reader.new(StringIO.new(body), max_bytes: MAX_ADVERTISEMENT_SIZE)
|
|
58
|
+
first = reader.read
|
|
59
|
+
if first == "# service=git-receive-pack\n"
|
|
60
|
+
raise TransportError, "invalid receive-pack advertisement" unless reader.read == Protocol::FLUSH
|
|
61
|
+
first = reader.read
|
|
62
|
+
end
|
|
63
|
+
refs = read_v0_refs(reader, first)
|
|
64
|
+
@receive_capabilities = @capabilities
|
|
65
|
+
@receive_refs = refs
|
|
66
|
+
ensure
|
|
67
|
+
@capabilities = fetch_capabilities
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_push_updates(repository, updates)
|
|
72
|
+
raise TypeError, "updates must be enumerable" unless updates.respond_to?(:each)
|
|
73
|
+
|
|
74
|
+
result = []
|
|
75
|
+
updates.each do |entry|
|
|
76
|
+
raise ArgumentError, "updates must be [ref, old_oid, new_oid]" unless entry.is_a?(Array) && entry.length == 3
|
|
77
|
+
raise ArgumentError, "too many push updates" if result.length >= MAX_PUSH_UPDATES
|
|
78
|
+
|
|
79
|
+
ref, old_oid, new_oid = entry
|
|
80
|
+
ref = Protocol.validate_ref(ref)
|
|
81
|
+
raise TransportError, "HEAD cannot be updated directly" if ref == "HEAD"
|
|
82
|
+
old_oid = Protocol.validate_oid(old_oid) unless old_oid.nil?
|
|
83
|
+
new_oid = Protocol.validate_oid(new_oid)
|
|
84
|
+
raise ArgumentError, "object to push was not found: #{new_oid}" if new_oid != ZERO_OID && !repository.odb.exist?(new_oid)
|
|
85
|
+
raise ArgumentError, "duplicate push destination: #{ref}" if result.any? { |update| update[:ref] == ref }
|
|
86
|
+
|
|
87
|
+
result << {ref: ref, old: old_oid, new: new_oid}
|
|
88
|
+
end
|
|
89
|
+
raise ArgumentError, "at least one push update is required" if result.empty?
|
|
90
|
+
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def push_capabilities(atomic, updates)
|
|
95
|
+
advertised = @receive_capabilities
|
|
96
|
+
status = %w[report-status-v2 report-status].find { |capability| advertised.include?(capability) }
|
|
97
|
+
raise TransportError, "server does not report push status" unless status
|
|
98
|
+
raise TransportError, "server does not support reference deletion" if updates.any? { |update| update[:new] == ZERO_OID } && !advertised.include?("delete-refs")
|
|
99
|
+
raise TransportError, "server does not support atomic push" if atomic && !advertised.include?("atomic")
|
|
100
|
+
|
|
101
|
+
[status, ("side-band-64k" if advertised.include?("side-band-64k")), ("atomic" if atomic)].compact
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def push_objects(repository, new_oids, advertised)
|
|
105
|
+
excluded = advertised.flat_map { |ref| [ref.oid, ref.peeled] }.compact.to_h { |oid| [oid, true] }
|
|
106
|
+
seen = {}
|
|
107
|
+
objects = []
|
|
108
|
+
pending = new_oids.reject { |oid| oid == ZERO_OID }
|
|
109
|
+
until pending.empty?
|
|
110
|
+
ensure_open
|
|
111
|
+
oid = pending.pop
|
|
112
|
+
next if seen[oid] || excluded[oid]
|
|
113
|
+
|
|
114
|
+
seen[oid] = true
|
|
115
|
+
type, data = repository.odb.read(oid)
|
|
116
|
+
objects << [type, data]
|
|
117
|
+
pending.concat(referenced_oids(type, data))
|
|
118
|
+
end
|
|
119
|
+
objects
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def referenced_oids(type, data)
|
|
123
|
+
case type
|
|
124
|
+
when "commit"
|
|
125
|
+
data.split("\n\n", 2).first.lines.filter_map do |line|
|
|
126
|
+
oid = line[/\A(?:tree|parent) ([0-9a-f]{40})\n?\z/, 1]
|
|
127
|
+
oid && Protocol.validate_oid(oid)
|
|
128
|
+
end
|
|
129
|
+
when "tag"
|
|
130
|
+
oid = data[/\Aobject ([0-9a-f]{40})\n/, 1]
|
|
131
|
+
oid ? [Protocol.validate_oid(oid)] : []
|
|
132
|
+
when "tree"
|
|
133
|
+
tree_oids(data)
|
|
134
|
+
else
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def tree_oids(data)
|
|
140
|
+
result = []
|
|
141
|
+
offset = 0
|
|
142
|
+
while offset < data.bytesize
|
|
143
|
+
ending = data.index("\0", offset)
|
|
144
|
+
raise CorruptObject, "invalid tree object" unless ending && ending + 21 <= data.bytesize
|
|
145
|
+
|
|
146
|
+
result << data.byteslice(ending + 1, 20).unpack1("H*")
|
|
147
|
+
offset = ending + 21
|
|
148
|
+
end
|
|
149
|
+
result
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse_push_response(body, updates, capabilities)
|
|
153
|
+
packets = read_push_packets(StringIO.new(body), capabilities.include?("side-band-64k")) do |text|
|
|
154
|
+
yield Progress.new(phase: :remote, current: nil, total: nil, bytes: text.bytesize)
|
|
155
|
+
end
|
|
156
|
+
unpack = packets.shift
|
|
157
|
+
raise TransportError, "push response omitted unpack status" unless unpack&.start_with?("unpack ")
|
|
158
|
+
raise TransportError, "remote unpack failed: #{safe_message(unpack.delete_prefix("unpack "))}" unless unpack == "unpack ok\n"
|
|
159
|
+
|
|
160
|
+
statuses = {}
|
|
161
|
+
report_v2 = capabilities.include?("report-status-v2")
|
|
162
|
+
current_ref = nil
|
|
163
|
+
options = {}
|
|
164
|
+
packets.each do |packet|
|
|
165
|
+
state, ref, reason = packet.chomp.split(" ", 3)
|
|
166
|
+
if state == "option"
|
|
167
|
+
raise TransportError, "invalid push option" unless report_v2 && current_ref && ref && !options[ref]
|
|
168
|
+
|
|
169
|
+
validate_push_option(ref, reason)
|
|
170
|
+
options[ref] = true
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
valid = state == "ok" ? ref && reason.nil? : state == "ng" && ref && reason && !reason.empty?
|
|
174
|
+
raise TransportError, "invalid push status" unless valid
|
|
175
|
+
|
|
176
|
+
ref = Protocol.validate_ref(ref)
|
|
177
|
+
raise TransportError, "duplicate push status" if statuses.key?(ref)
|
|
178
|
+
|
|
179
|
+
statuses[ref] = [state, reason]
|
|
180
|
+
current_ref = state == "ok" ? ref : nil
|
|
181
|
+
options = {}
|
|
182
|
+
end
|
|
183
|
+
expected_refs = updates.map { |update| update[:ref] }
|
|
184
|
+
raise TransportError, "push status included an unknown reference" unless (statuses.keys - expected_refs).empty?
|
|
185
|
+
|
|
186
|
+
updates.each_with_index do |update, index|
|
|
187
|
+
state, reason = statuses.fetch(update[:ref]) { raise TransportError, "push status missing for #{update[:ref]}" }
|
|
188
|
+
raise TransportError, "push rejected for #{update[:ref]}: #{safe_message(reason.to_s)}" unless state == "ok"
|
|
189
|
+
yield Progress.new(phase: :push, current: index + 1, total: updates.length, bytes: nil)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_push_option(name, value)
|
|
194
|
+
case name
|
|
195
|
+
when "refname" then Protocol.validate_ref(value)
|
|
196
|
+
when "old-oid", "new-oid" then Protocol.validate_oid(value)
|
|
197
|
+
when "forced-update" then raise TransportError, "invalid push option" unless value.nil?
|
|
198
|
+
else raise TransportError, "invalid push option"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def read_push_packets(input, sideband)
|
|
203
|
+
reader = Protocol::Reader.new(input, max_bytes: MAX_PUSH_RESPONSE_SIZE)
|
|
204
|
+
status = +"".b
|
|
205
|
+
result = []
|
|
206
|
+
loop do
|
|
207
|
+
packet = reader.read
|
|
208
|
+
raise TransportError, "truncated push response" if packet.nil?
|
|
209
|
+
break if packet == Protocol::FLUSH
|
|
210
|
+
raise TransportError, "invalid push response" unless packet.is_a?(String)
|
|
211
|
+
|
|
212
|
+
unless sideband
|
|
213
|
+
result << packet
|
|
214
|
+
next
|
|
215
|
+
end
|
|
216
|
+
band = packet.getbyte(0)
|
|
217
|
+
data = packet.byteslice(1..).to_s
|
|
218
|
+
case band
|
|
219
|
+
when 1 then status << data
|
|
220
|
+
when 2 then yield safe_message(data)
|
|
221
|
+
when 3 then raise TransportError, "remote error: #{safe_message(data)}"
|
|
222
|
+
else raise TransportError, "invalid sideband channel"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
return result unless sideband
|
|
226
|
+
|
|
227
|
+
nested = Protocol::Reader.new(StringIO.new(status), max_bytes: MAX_PUSH_RESPONSE_SIZE)
|
|
228
|
+
loop do
|
|
229
|
+
packet = nested.read
|
|
230
|
+
raise TransportError, "truncated push status" if packet.nil?
|
|
231
|
+
break if packet == Protocol::FLUSH
|
|
232
|
+
raise TransportError, "invalid push status" unless packet.is_a?(String)
|
|
233
|
+
result << packet
|
|
234
|
+
end
|
|
235
|
+
raise TransportError, "invalid push status" if nested.read
|
|
236
|
+
|
|
237
|
+
result
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
module Protocol
|
|
242
|
+
def self.commands(updates, capabilities)
|
|
243
|
+
updates.each_with_index.map do |update, index|
|
|
244
|
+
suffix = index.zero? && !capabilities.empty? ? "\0#{capabilities.join(" ")}" : ""
|
|
245
|
+
packet("#{update.fetch(:old)} #{update.fetch(:new)} #{update.fetch(:ref)}#{suffix}\n")
|
|
246
|
+
end.join + flush
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
class Repository
|
|
5
|
+
def remotes
|
|
6
|
+
remote_settings.transform_values { |settings| settings[:url] }.compact
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def fetch(remote = "origin", refspecs: nil, depth: nil, filter: nil)
|
|
10
|
+
depth = Remote::Protocol.validate_depth(depth)
|
|
11
|
+
filter = Remote::Protocol.validate_filter(filter)
|
|
12
|
+
settings = remote_settings[remote]
|
|
13
|
+
direct = direct_remote?(remote)
|
|
14
|
+
url = settings&.fetch(:url, nil) || (remote if direct)
|
|
15
|
+
raise ArgumentError, "unknown remote: #{remote}" unless url
|
|
16
|
+
url = normalize_remote_url(url)
|
|
17
|
+
|
|
18
|
+
specs = refspecs
|
|
19
|
+
if specs.nil? && settings
|
|
20
|
+
specs = settings[:fetch]
|
|
21
|
+
specs = ["+refs/heads/*:refs/remotes/#{remote}/*"] if specs.empty?
|
|
22
|
+
end
|
|
23
|
+
specs = normalize_fetch_refspecs(specs)
|
|
24
|
+
connection = Remote.open(url)
|
|
25
|
+
advertised = connection.refs
|
|
26
|
+
updates = select_fetch_refs(advertised, specs)
|
|
27
|
+
wants = updates.map { |ref, _| ref.oid }.compact.uniq
|
|
28
|
+
previous = updates.filter_map { |_, destination| [destination, resolve(destination)] if destination }.to_h
|
|
29
|
+
unless wants.empty?
|
|
30
|
+
haves = refs.values.compact.uniq.select { |oid| odb.exist?(oid) }
|
|
31
|
+
connection.fetch(self, wants: wants, haves: haves, depth: depth, filter: filter) do |progress|
|
|
32
|
+
yield progress if block_given?
|
|
33
|
+
end
|
|
34
|
+
record_promisor(remote, filter) if filter && settings
|
|
35
|
+
updates.each do |ref, destination|
|
|
36
|
+
update_ref(destination, ref.oid, old_oid: previous.fetch(destination), message: "fetch #{remote}: #{ref.name}") if destination && ref.oid
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
advertised
|
|
40
|
+
ensure
|
|
41
|
+
connection&.close
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def push(remote = "origin", refspecs:, force: false, lease: nil, atomic: false)
|
|
45
|
+
settings = remote_settings[remote]
|
|
46
|
+
direct = direct_remote?(remote)
|
|
47
|
+
url = settings&.fetch(:pushurl, nil) || settings&.fetch(:url, nil) || (remote if direct)
|
|
48
|
+
raise ArgumentError, "unknown remote: #{remote}" unless url
|
|
49
|
+
url = normalize_remote_url(url)
|
|
50
|
+
raise ArgumentError, "force must be true or false" unless [true, false].include?(force)
|
|
51
|
+
raise ArgumentError, "atomic must be true or false" unless [true, false].include?(atomic)
|
|
52
|
+
|
|
53
|
+
connection = Remote.open(url)
|
|
54
|
+
advertised = connection.send(:receive_refs)
|
|
55
|
+
updates = push_updates(refspecs, force: force, lease: lease, advertised: advertised)
|
|
56
|
+
connection.push(self, updates, atomic: atomic) { |progress| yield progress if block_given? }
|
|
57
|
+
ensure
|
|
58
|
+
connection&.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def direct_remote?(remote)
|
|
64
|
+
value = remote.to_s
|
|
65
|
+
value.match?(/\A(?:https?|ssh|file):\/\//i) || (!value.include?("://") && value.include?(":")) ||
|
|
66
|
+
value.start_with?("/", "./", "../", "~") || File.exist?(File.expand_path(value, root || common_dir))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalize_remote_url(url)
|
|
70
|
+
local = !url.include?("://") && (url.start_with?("/", "./", "../", "~") ||
|
|
71
|
+
!url.include?(":") || url.match?(/\A[A-Za-z]:[\\\/]/))
|
|
72
|
+
local ? File.expand_path(url, root || common_dir) : url
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remote_settings
|
|
76
|
+
result = {}
|
|
77
|
+
current = nil
|
|
78
|
+
File.foreach(File.join(common_dir, "config"), encoding: "UTF-8") do |line|
|
|
79
|
+
stripped = line.strip
|
|
80
|
+
if (match = stripped.match(/\A\[remote\s+"([^"\r\n]+)"\]\z/i))
|
|
81
|
+
current = match[1]
|
|
82
|
+
result[current] ||= {fetch: [], push: []}
|
|
83
|
+
elsif stripped.start_with?("[")
|
|
84
|
+
current = nil
|
|
85
|
+
elsif current && (match = stripped.match(/\A(url|pushurl|fetch|push)\s*=\s*(.*)\z/i))
|
|
86
|
+
key = match[1].downcase.to_sym
|
|
87
|
+
value = IgnoreMatcher.send(:parse_config_value, match[2])
|
|
88
|
+
%i[fetch push].include?(key) ? result[current][key] << value : result[current][key] = value
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
result
|
|
92
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
93
|
+
{}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record_promisor(remote, filter)
|
|
97
|
+
path = File.join(common_dir, "config")
|
|
98
|
+
raise ArgumentError, "unsafe Git config" if File.symlink?(path) || File.symlink?(path + ".lock")
|
|
99
|
+
lock = File.open(path + ".lock", File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
|
|
100
|
+
lines = File.readlines(path, mode: "rb")
|
|
101
|
+
start = lines.index { |line| line.strip.match?(/\A\[remote\s+"#{Regexp.escape(remote)}"\]\z/i) }
|
|
102
|
+
raise RefLockError, "remote configuration changed during fetch" unless start
|
|
103
|
+
|
|
104
|
+
finish = ((start + 1)...lines.length).find { |index| lines[index].lstrip.start_with?("[") } || lines.length
|
|
105
|
+
body = lines[(start + 1)...finish].reject { |line| line.strip.match?(/\A(?:promisor|partialclonefilter)\s*=/i) }
|
|
106
|
+
body[-1] = body[-1] + "\n" if body.any? && !body[-1].end_with?("\n")
|
|
107
|
+
body << "\tpromisor = true\n" << "\tpartialclonefilter = #{filter}\n"
|
|
108
|
+
lock.chmod(File.stat(path).mode & 0o777)
|
|
109
|
+
lock.write((lines[0..start] + body + lines[finish..].to_a).join)
|
|
110
|
+
lock.flush
|
|
111
|
+
lock.fsync
|
|
112
|
+
lock.close
|
|
113
|
+
File.rename(lock.path, path)
|
|
114
|
+
rescue Errno::EEXIST
|
|
115
|
+
raise RefLockError, "Git config is locked"
|
|
116
|
+
ensure
|
|
117
|
+
lock&.close unless lock&.closed?
|
|
118
|
+
File.unlink(lock.path) if lock && File.exist?(lock.path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_fetch_refspecs(refspecs)
|
|
122
|
+
return if refspecs.nil?
|
|
123
|
+
|
|
124
|
+
refspecs = [refspecs] if refspecs.is_a?(String)
|
|
125
|
+
raise TypeError, "refspecs must be enumerable" unless refspecs.respond_to?(:each)
|
|
126
|
+
|
|
127
|
+
refspecs.map do |source|
|
|
128
|
+
raise ArgumentError, "invalid fetch refspec" unless source.is_a?(String)
|
|
129
|
+
|
|
130
|
+
source = source.delete_prefix("+")
|
|
131
|
+
from, to = source.split(":", 2)
|
|
132
|
+
valid = valid_refspec_name?(from) && (!to || valid_refspec_name?(to)) && from.count("*") <= 1 && to.to_s.count("*") <= 1 &&
|
|
133
|
+
from.include?("*") == to.to_s.include?("*")
|
|
134
|
+
raise ArgumentError, "invalid fetch refspec" unless valid
|
|
135
|
+
|
|
136
|
+
[/\A#{Regexp.escape(from).sub("\\*", "(.*)")}\z/, to]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def valid_refspec_name?(name)
|
|
141
|
+
name.is_a?(String) && name.start_with?("refs/") && !name.end_with?("/", ".") &&
|
|
142
|
+
!name.include?("..") && !name.include?("@{") && !name.match?(/[\x00-\x20\x7f~^:?\[\\]/) &&
|
|
143
|
+
name.split("/").none? { |part| part.empty? || part.start_with?(".") || part.downcase.end_with?(".lock") }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def select_fetch_refs(advertised, refspecs)
|
|
147
|
+
return advertised.map { |ref| [ref, nil] } if refspecs.nil?
|
|
148
|
+
|
|
149
|
+
refspecs.flat_map do |pattern, destination|
|
|
150
|
+
advertised.filter_map do |ref|
|
|
151
|
+
match = pattern.match(ref.name)
|
|
152
|
+
[ref, destination&.sub("*", match[1].to_s)] if match
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def push_updates(refspecs, force:, lease:, advertised:)
|
|
158
|
+
specs = refspecs.is_a?(String) ? [refspecs] : refspecs
|
|
159
|
+
raise TypeError, "refspecs must be enumerable" unless specs.respond_to?(:each)
|
|
160
|
+
|
|
161
|
+
entries = specs.flat_map { |spec| expand_push_refspec(spec, force: force) }
|
|
162
|
+
raise ArgumentError, "at least one push refspec is required" if entries.empty?
|
|
163
|
+
expected = push_leases(lease, entries)
|
|
164
|
+
entries.map do |entry|
|
|
165
|
+
current = advertised.find { |ref| ref.name == entry[:destination] }&.oid || Remote::Connection::ZERO_OID
|
|
166
|
+
old_oid = expected.fetch(entry[:destination], current)
|
|
167
|
+
raise TransportError, "remote reference changed: #{entry[:destination]}" unless old_oid == current
|
|
168
|
+
unless entry[:forced] || expected.key?(entry[:destination]) || entry[:new_oid] == Remote::Connection::ZERO_OID
|
|
169
|
+
if current != Remote::Connection::ZERO_OID && current != entry[:new_oid] && (!entry[:destination].start_with?("refs/heads/") ||
|
|
170
|
+
!odb.exist?(current) || merge_base(current, entry[:new_oid]) != current)
|
|
171
|
+
raise TransportError, "non-fast-forward push requires force or lease: #{entry[:destination]}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
[entry[:destination], old_oid, entry[:new_oid]]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def expand_push_refspec(spec, force:)
|
|
179
|
+
raise ArgumentError, "invalid push refspec" unless spec.is_a?(String)
|
|
180
|
+
|
|
181
|
+
forced = force || spec.start_with?("+")
|
|
182
|
+
source, destination = spec.delete_prefix("+").split(":", 2)
|
|
183
|
+
valid = destination && valid_refspec_name?(destination) && source.count("*") <= 1 && destination.count("*") <= 1 &&
|
|
184
|
+
source.include?("*") == destination.include?("*")
|
|
185
|
+
raise ArgumentError, "invalid push refspec" unless valid
|
|
186
|
+
|
|
187
|
+
if source.empty?
|
|
188
|
+
raise ArgumentError, "wildcard deletion is not supported" if destination.include?("*")
|
|
189
|
+
return [{destination: destination, new_oid: Remote::Connection::ZERO_OID, forced: forced}]
|
|
190
|
+
end
|
|
191
|
+
if source.include?("*")
|
|
192
|
+
raise ArgumentError, "invalid push refspec" unless valid_refspec_name?(source)
|
|
193
|
+
pattern = /\A#{Regexp.escape(source).sub("\\*", "(.*)")}\z/
|
|
194
|
+
return refs.filter_map do |name, oid|
|
|
195
|
+
match = pattern.match(name)
|
|
196
|
+
{destination: destination.sub("*", match[1]), new_oid: oid, forced: forced} if match && oid
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
new_oid = resolve(source)
|
|
201
|
+
raise ArgumentError, "unknown push source: #{source}" unless new_oid && odb.exist?(new_oid)
|
|
202
|
+
|
|
203
|
+
[{destination: destination, new_oid: new_oid, forced: forced}]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def push_leases(lease, entries)
|
|
207
|
+
return {} if lease.nil?
|
|
208
|
+
if lease.is_a?(String)
|
|
209
|
+
raise ArgumentError, "a scalar lease requires one refspec" unless entries.length == 1
|
|
210
|
+
return {entries.first[:destination] => Remote::Protocol.validate_oid(lease)}
|
|
211
|
+
end
|
|
212
|
+
raise TypeError, "lease must be an object ID or a ref-to-object-ID Hash" unless lease.is_a?(Hash)
|
|
213
|
+
|
|
214
|
+
lease.to_h do |name, oid|
|
|
215
|
+
raise ArgumentError, "lease contains an invalid reference" unless valid_refspec_name?(name)
|
|
216
|
+
[name, Remote::Protocol.validate_oid(oid)]
|
|
217
|
+
end.tap do |leases|
|
|
218
|
+
missing = entries.map { |entry| entry[:destination] } - leases.keys
|
|
219
|
+
raise ArgumentError, "lease is missing a push destination" unless missing.empty?
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|