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.
@@ -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