thuban 0.4.1 → 0.5.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 +8 -0
- data/README.md +39 -6
- data/lib/thuban/commit.rb +10 -1
- data/lib/thuban/commit_writer.rb +2 -2
- data/lib/thuban/history_writer.rb +20 -12
- data/lib/thuban/index.rb +6 -1
- data/lib/thuban/index_writer.rb +13 -1
- data/lib/thuban/ref_store.rb +1 -17
- data/lib/thuban/remote/credentials.rb +2 -1
- data/lib/thuban/remote/repository.rb +116 -19
- data/lib/thuban/remote.rb +1 -0
- data/lib/thuban/repository.rb +35 -0
- data/lib/thuban/status.rb +4 -3
- data/lib/thuban/version.rb +1 -1
- data/sig/thuban.rbs +12 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73f95adb20a42969a69908bab030d4939a94a1bd0bc9ddcacbb9b95dff3dd6a9
|
|
4
|
+
data.tar.gz: 8ebf2299e31de698cfae6b7487a7722703f50d4fd5a0820adf6e6c35d1eb2616
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97e8fe6cbbfa497234aafc55a4c49f0d9775721451c2e32a1882d4d15066d079cf1544ef41c48bff61230297ad7191b64da8de5b2a63636a6224516037d9b9da
|
|
7
|
+
data.tar.gz: 889372feb5805cdfb126a1f9668700abda26307b6617be36556e917050c90270aff58e9e193dc3bad832d9be2aa15f41b885466801c3f8f5bed27de38588945c
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.0 - 2026-09-17
|
|
6
|
+
|
|
7
|
+
- Expose parsed commit signatures and separate author and committer identities in `commit!`
|
|
8
|
+
- Add current author and committer identity lookup with `Repository#signature`
|
|
9
|
+
- Add bounded commit history traversal with `Repository#each_commit`
|
|
10
|
+
- Add cancellable high-level fetch and push controls and fast-forward-only pull
|
|
11
|
+
- Honor `core.filemode` when reporting worktree changes
|
|
12
|
+
|
|
5
13
|
## 0.4.1 - 2026-09-17
|
|
6
14
|
|
|
7
15
|
- Load remote transport providers only when remote operations are used
|
data/README.md
CHANGED
|
@@ -48,7 +48,7 @@ repository operations work directly with Git data without invoking the Git execu
|
|
|
48
48
|
Add Thuban to your Gemfile:
|
|
49
49
|
|
|
50
50
|
```ruby
|
|
51
|
-
gem "thuban", "~> 0.
|
|
51
|
+
gem "thuban", "~> 0.5.0"
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Then install it:
|
|
@@ -76,6 +76,9 @@ repo.status.each do |entry|
|
|
|
76
76
|
end
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
Status checks honor the repository's `core.filemode` setting; `repo.filemode?`
|
|
80
|
+
exposes the effective value.
|
|
81
|
+
|
|
79
82
|
## Usage
|
|
80
83
|
|
|
81
84
|
### Read Repository Data
|
|
@@ -84,6 +87,7 @@ end
|
|
|
84
87
|
repo.refs
|
|
85
88
|
repo.branches
|
|
86
89
|
repo.commit
|
|
90
|
+
repo.each_commit(limit: 100)
|
|
87
91
|
repo.tree
|
|
88
92
|
repo.blob("README.md")
|
|
89
93
|
repo.index
|
|
@@ -98,6 +102,10 @@ repo.tree("main")
|
|
|
98
102
|
repo.blob("README.md", reference: "v0.1.0")
|
|
99
103
|
```
|
|
100
104
|
|
|
105
|
+
`each_commit` returns an Enumerator without a block. It visits the selected tip
|
|
106
|
+
and then its parents in deterministic parent order, yields merge ancestors only
|
|
107
|
+
once, and requires a positive `limit` so UI history reads remain bounded.
|
|
108
|
+
|
|
101
109
|
### Compare and Restore Changes
|
|
102
110
|
|
|
103
111
|
Thuban exposes staged and worktree content for explicit comparison with
|
|
@@ -135,14 +143,25 @@ index = repo.index
|
|
|
135
143
|
index.stage(path, oid, 0o100644, stat: File.stat(path))
|
|
136
144
|
index.write
|
|
137
145
|
|
|
138
|
-
author =
|
|
139
|
-
name: "Example Author",
|
|
140
|
-
email: "author@example.com",
|
|
141
|
-
time: Time.now
|
|
142
|
-
)
|
|
146
|
+
author = repo.signature(role: :author)
|
|
143
147
|
commit = repo.commit!(message: "Update README", author: author)
|
|
144
148
|
```
|
|
145
149
|
|
|
150
|
+
`Repository#signature` reads the matching `GIT_AUTHOR_NAME` /
|
|
151
|
+
`GIT_AUTHOR_EMAIL` or `GIT_COMMITTER_NAME` / `GIT_COMMITTER_EMAIL` first,
|
|
152
|
+
then `user.name` / `user.email` from Git configuration. It returns the current
|
|
153
|
+
time and UTC offset and raises `ArgumentError` when either identity value is
|
|
154
|
+
missing or invalid.
|
|
155
|
+
|
|
156
|
+
To amend without changing the original author or author timestamp, read the
|
|
157
|
+
author signature from the commit and provide a separate current committer:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
original = repo.commit
|
|
161
|
+
committer = repo.signature(role: :committer)
|
|
162
|
+
repo.commit!(message: "Update README", author: original.signature(role: :author), committer: committer, amend: true)
|
|
163
|
+
```
|
|
164
|
+
|
|
146
165
|
`Index#write` uses Git's `index.lock`, retains optional extensions as raw bytes,
|
|
147
166
|
and invalidates entry-dependent cache extensions after mutation. `unstage`
|
|
148
167
|
removes the stage-zero entry; stage the corresponding HEAD entry to restore a
|
|
@@ -279,6 +298,11 @@ repo.fetch("origin", depth: 1) { |event| warn "#{event.phase}: #{event.bytes}" }
|
|
|
279
298
|
repo.fetch("origin", filter: "blob:none")
|
|
280
299
|
```
|
|
281
300
|
|
|
301
|
+
High-level fetch, push, and pull accept `credentials:`, `ssh:`, `timeout:`, and
|
|
302
|
+
a zero-argument `cancelled:` callback. Cancellation closes a blocked transport
|
|
303
|
+
and raises `Thuban::Cancelled`. A push may already have been applied remotely
|
|
304
|
+
when cancellation is observed, so callers must refresh remote refs before retrying.
|
|
305
|
+
|
|
282
306
|
`depth:` accepts positive 32-bit integers and `filter:` currently accepts only
|
|
283
307
|
`"blob:none"`. Thuban negotiates only features advertised by the server. Shallow
|
|
284
308
|
boundaries are atomically maintained in Git's `shallow` file. For configured
|
|
@@ -302,6 +326,15 @@ repo.push("origin", refspecs: [
|
|
|
302
326
|
], atomic: true) { |progress| warn "#{progress.phase}: #{progress.current}/#{progress.total}" }
|
|
303
327
|
```
|
|
304
328
|
|
|
329
|
+
Pull updates the current branch, index, and worktree only when the named remote
|
|
330
|
+
branch is a fast-forward. Tracked changes, detached HEADs, bare repositories,
|
|
331
|
+
and untracked collisions are rejected; fetched objects and tracking refs remain
|
|
332
|
+
available when the fast-forward cannot be applied:
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
repo.pull("origin", branch: "main")
|
|
336
|
+
```
|
|
337
|
+
|
|
305
338
|
Remote names, direct paths, `file://` URLs, HTTP(S), and SSH URLs are accepted.
|
|
306
339
|
At the lower level, `Connection#push` accepts `[ref, old_oid, new_oid]` updates;
|
|
307
340
|
the old object ID is an optimistic lease, and `nil` uses the advertised value.
|
data/lib/thuban/commit.rb
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Thuban
|
|
4
|
-
Commit = Struct.new(:oid, :tree, :parents, :author, :committer, :message, keyword_init: true)
|
|
4
|
+
Commit = Struct.new(:oid, :tree, :parents, :author, :committer, :message, keyword_init: true) do
|
|
5
|
+
def signature(role: :author)
|
|
6
|
+
raise ArgumentError, "role must be :author or :committer" unless %i[author committer].include?(role)
|
|
7
|
+
|
|
8
|
+
match = public_send(role).to_s.match(/\A(.+) <([^<>]+)> (-?\d+) ([+-]\d{4})\z/)
|
|
9
|
+
raise CorruptObject, "invalid commit signature" unless match
|
|
10
|
+
|
|
11
|
+
Signature.new(name: match[1], email: match[2], time: Integer(match[3]), offset: match[4])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
5
14
|
end
|
data/lib/thuban/commit_writer.rb
CHANGED
|
@@ -9,13 +9,13 @@ module Thuban
|
|
|
9
9
|
write_tree_from_entries(current.entries)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def commit!(message:, author:, amend: false)
|
|
12
|
+
def commit!(message:, author:, committer: nil, amend: false)
|
|
13
13
|
previous = head
|
|
14
14
|
current = previous && commit(previous)
|
|
15
15
|
raise ArgumentError, "cannot amend an unborn branch" if amend && !current
|
|
16
16
|
|
|
17
17
|
parents = amend ? current.parents : previous ? [previous] : []
|
|
18
|
-
oid = write_commit(tree: write_tree_from_index, parents: parents, author: author, message: message)
|
|
18
|
+
oid = write_commit(tree: write_tree_from_index, parents: parents, author: author, committer: committer, message: message)
|
|
19
19
|
subject = message.lines.first.to_s.strip
|
|
20
20
|
action = if amend
|
|
21
21
|
"commit (amend): #{subject}"
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Thuban
|
|
4
4
|
class Repository
|
|
5
|
+
def each_commit(reference = "HEAD", limit:)
|
|
6
|
+
raise ArgumentError, "limit must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
|
|
7
|
+
return enum_for(__method__, reference, limit: limit) unless block_given?
|
|
8
|
+
|
|
9
|
+
start = commit(reference)
|
|
10
|
+
return unless start
|
|
11
|
+
|
|
12
|
+
walk_commits(start.oid, limit: limit) { |_, revision| yield revision }
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
5
16
|
def merge_base(a, b)
|
|
6
17
|
left = require_commit(a).oid
|
|
7
18
|
right = require_commit(b).oid
|
|
@@ -36,7 +47,7 @@ module Thuban
|
|
|
36
47
|
raise ArgumentError, "cannot cherry-pick a merge commit" if source.parents.length > 1
|
|
37
48
|
|
|
38
49
|
base = source.parents.empty? ? {} : tree(source.parents.first)
|
|
39
|
-
apply_commit_change(source, base, tree(source.oid), author:
|
|
50
|
+
apply_commit_change(source, base, tree(source.oid), author: source.signature(role: :author), message: source.message,
|
|
40
51
|
action: "cherry-pick")
|
|
41
52
|
end
|
|
42
53
|
|
|
@@ -56,7 +67,7 @@ module Thuban
|
|
|
56
67
|
commit(reference) || raise(ArgumentError, "unknown commit: #{reference}")
|
|
57
68
|
end
|
|
58
69
|
|
|
59
|
-
def walk_commits(start)
|
|
70
|
+
def walk_commits(start, limit: nil)
|
|
60
71
|
queue = [start]
|
|
61
72
|
seen = {}
|
|
62
73
|
cursor = 0
|
|
@@ -65,9 +76,11 @@ module Thuban
|
|
|
65
76
|
cursor += 1
|
|
66
77
|
next if seen[oid]
|
|
67
78
|
|
|
79
|
+
revision = require_commit(oid)
|
|
68
80
|
seen[oid] = true
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
queue.concat(revision.parents) unless limit && seen.length >= limit
|
|
82
|
+
yield oid, revision
|
|
83
|
+
break if limit && seen.length >= limit
|
|
71
84
|
end
|
|
72
85
|
end
|
|
73
86
|
|
|
@@ -166,6 +179,8 @@ module Thuban
|
|
|
166
179
|
end
|
|
167
180
|
owns_lock = true
|
|
168
181
|
begin
|
|
182
|
+
raise RefLockError, "Git index changed since it was read" unless current_index.send(:source_unchanged?)
|
|
183
|
+
|
|
169
184
|
write_worktree_tree(worktree_tree, contents, removed)
|
|
170
185
|
extensions = current_index.extensions.reject { |extension| Index::ENTRY_DEPENDENT_EXTENSIONS.include?(extension.byteslice(0, 4)) }
|
|
171
186
|
lock.write(Index.encode(index_entries(index_tree), extensions: extensions, version: current_index.version))
|
|
@@ -303,15 +318,8 @@ module Thuban
|
|
|
303
318
|
nil
|
|
304
319
|
end
|
|
305
320
|
|
|
306
|
-
def signature_from(value)
|
|
307
|
-
match = value.to_s.match(/\A(.+) <([^<>]+)> (-?\d+) ([+-]\d{4})\z/)
|
|
308
|
-
raise CorruptObject, "invalid commit signature" unless match
|
|
309
|
-
|
|
310
|
-
Signature.new(name: match[1], email: match[2], time: Integer(match[3]), offset: match[4])
|
|
311
|
-
end
|
|
312
|
-
|
|
313
321
|
def operation_signature
|
|
314
|
-
|
|
322
|
+
current_signature(:committer, fallback: true)
|
|
315
323
|
end
|
|
316
324
|
end
|
|
317
325
|
end
|
data/lib/thuban/index.rb
CHANGED
|
@@ -14,7 +14,12 @@ module Thuban
|
|
|
14
14
|
@entries = []
|
|
15
15
|
@extensions = []
|
|
16
16
|
@version = 2
|
|
17
|
-
|
|
17
|
+
@source_checksum = nil
|
|
18
|
+
if File.file?(path)
|
|
19
|
+
bytes = File.binread(path)
|
|
20
|
+
@source_checksum = Digest::SHA1.digest(bytes)
|
|
21
|
+
parse(bytes)
|
|
22
|
+
end
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def each(&block) = entries.each(&block)
|
data/lib/thuban/index_writer.rb
CHANGED
|
@@ -10,12 +10,16 @@ module Thuban
|
|
|
10
10
|
FileUtils.mkdir_p(File.dirname(path))
|
|
11
11
|
file = File.open(lock_path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
|
|
12
12
|
owns_lock = true
|
|
13
|
-
|
|
13
|
+
raise RefLockError, "Git index changed since it was read" unless source_unchanged?
|
|
14
|
+
|
|
15
|
+
contents = self.class.encode(entries, extensions: extensions, version: version)
|
|
16
|
+
file.write(contents)
|
|
14
17
|
file.flush
|
|
15
18
|
file.fsync
|
|
16
19
|
file.close
|
|
17
20
|
File.rename(lock_path, path)
|
|
18
21
|
owns_lock = false
|
|
22
|
+
@source_checksum = Digest::SHA1.digest(contents)
|
|
19
23
|
self
|
|
20
24
|
rescue Errno::EEXIST
|
|
21
25
|
raise IOError, "Git index is locked: #{lock_path}"
|
|
@@ -62,6 +66,14 @@ module Thuban
|
|
|
62
66
|
|
|
63
67
|
private
|
|
64
68
|
|
|
69
|
+
def source_unchanged?
|
|
70
|
+
@source_checksum == Digest::SHA1.digest(File.binread(path))
|
|
71
|
+
rescue Errno::ENOENT
|
|
72
|
+
@source_checksum.nil? && !File.symlink?(path)
|
|
73
|
+
rescue SystemCallError
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
65
77
|
def validate_entry(path, oid, mode)
|
|
66
78
|
validate_path(path)
|
|
67
79
|
raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(oid.to_s)
|
data/lib/thuban/ref_store.rb
CHANGED
|
@@ -168,8 +168,7 @@ module Thuban
|
|
|
168
168
|
end
|
|
169
169
|
|
|
170
170
|
def reflog_signature
|
|
171
|
-
|
|
172
|
-
email: ENV["GIT_COMMITTER_EMAIL"] || config_value("email") || "unknown@localhost", time: Time.now)
|
|
171
|
+
repository.send(:current_signature, :committer, fallback: true)
|
|
173
172
|
end
|
|
174
173
|
|
|
175
174
|
def validate_storage_path(path)
|
|
@@ -211,21 +210,6 @@ module Thuban
|
|
|
211
210
|
root
|
|
212
211
|
end
|
|
213
212
|
|
|
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
213
|
def rewrite_packed_refs(file, path, name)
|
|
230
214
|
lines = File.readlines(path, mode: "rb")
|
|
231
215
|
kept = []
|
|
@@ -125,7 +125,8 @@ module Thuban
|
|
|
125
125
|
|
|
126
126
|
def terminate(waiter, grouped)
|
|
127
127
|
unless grouped
|
|
128
|
-
|
|
128
|
+
killed = system("taskkill", "/PID", waiter.pid.to_s, "/T", "/F", out: File::NULL, err: File::NULL)
|
|
129
|
+
Process.kill("KILL", waiter.pid) unless killed
|
|
129
130
|
waiter.join
|
|
130
131
|
return
|
|
131
132
|
end
|
|
@@ -6,9 +6,10 @@ module Thuban
|
|
|
6
6
|
remote_settings.transform_values { |settings| settings[:url] }.compact
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
def fetch(remote = "origin", refspecs: nil, depth: nil, filter: nil)
|
|
9
|
+
def fetch(remote = "origin", refspecs: nil, depth: nil, filter: nil, credentials: nil, ssh: nil, timeout: 30, cancelled: nil)
|
|
10
10
|
depth = Remote::Protocol.validate_depth(depth)
|
|
11
11
|
filter = Remote::Protocol.validate_filter(filter)
|
|
12
|
+
validate_cancelled(cancelled)
|
|
12
13
|
settings = remote_settings[remote]
|
|
13
14
|
direct = direct_remote?(remote)
|
|
14
15
|
url = settings&.fetch(:url, nil) || (remote if direct)
|
|
@@ -21,27 +22,38 @@ module Thuban
|
|
|
21
22
|
specs = ["+refs/heads/*:refs/remotes/#{remote}/*"] if specs.empty?
|
|
22
23
|
end
|
|
23
24
|
specs = normalize_fetch_refspecs(specs)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
updates =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
check_cancellation!(cancelled)
|
|
26
|
+
connection = Remote.open(url, credentials: credentials, ssh: ssh, timeout: timeout)
|
|
27
|
+
advertised, updates, previous, publish = with_transfer(connection, cancelled) do |check_cancelled|
|
|
28
|
+
advertised = connection.refs
|
|
29
|
+
check_cancelled.call
|
|
30
|
+
updates = select_fetch_refs(advertised, specs)
|
|
31
|
+
wants = updates.map { |ref, _| ref.oid }.compact.uniq
|
|
32
|
+
previous = updates.filter_map { |_, destination| [destination, resolve(destination)] if destination }.to_h
|
|
33
|
+
unless wants.empty?
|
|
34
|
+
haves = refs.values.compact.uniq.select { |oid| odb.exist?(oid) }
|
|
35
|
+
connection.fetch(self, wants: wants, haves: haves, depth: depth, filter: filter) do |progress|
|
|
36
|
+
yield progress if block_given?
|
|
37
|
+
check_cancelled.call
|
|
38
|
+
end
|
|
39
|
+
check_cancelled.call
|
|
33
40
|
end
|
|
41
|
+
check_cancelled.call
|
|
42
|
+
[advertised, updates, previous, !wants.empty?]
|
|
43
|
+
end
|
|
44
|
+
check_cancellation!(cancelled)
|
|
45
|
+
if publish
|
|
34
46
|
record_promisor(remote, filter) if filter && settings
|
|
35
47
|
updates.each do |ref, destination|
|
|
36
48
|
update_ref(destination, ref.oid, old_oid: previous.fetch(destination), message: "fetch #{remote}: #{ref.name}") if destination && ref.oid
|
|
37
49
|
end
|
|
38
50
|
end
|
|
39
51
|
advertised
|
|
40
|
-
ensure
|
|
41
|
-
connection&.close
|
|
42
52
|
end
|
|
43
53
|
|
|
44
|
-
def push(remote = "origin", refspecs:, force: false, lease: nil, atomic: false
|
|
54
|
+
def push(remote = "origin", refspecs:, force: false, lease: nil, atomic: false,
|
|
55
|
+
credentials: nil, ssh: nil, timeout: 30, cancelled: nil)
|
|
56
|
+
validate_cancelled(cancelled)
|
|
45
57
|
settings = remote_settings[remote]
|
|
46
58
|
direct = direct_remote?(remote)
|
|
47
59
|
url = settings&.fetch(:pushurl, nil) || settings&.fetch(:url, nil) || (remote if direct)
|
|
@@ -50,16 +62,101 @@ module Thuban
|
|
|
50
62
|
raise ArgumentError, "force must be true or false" unless [true, false].include?(force)
|
|
51
63
|
raise ArgumentError, "atomic must be true or false" unless [true, false].include?(atomic)
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
check_cancellation!(cancelled)
|
|
66
|
+
connection = Remote.open(url, credentials: credentials, ssh: ssh, timeout: timeout)
|
|
67
|
+
with_transfer(connection, cancelled) do |check_cancelled|
|
|
68
|
+
advertised = connection.send(:receive_refs)
|
|
69
|
+
check_cancelled.call
|
|
70
|
+
updates = push_updates(refspecs, force: force, lease: lease, advertised: advertised)
|
|
71
|
+
result = connection.push(self, updates, atomic: atomic) do |progress|
|
|
72
|
+
yield progress if block_given?
|
|
73
|
+
check_cancelled.call
|
|
74
|
+
end
|
|
75
|
+
check_cancelled.call
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def pull(remote = "origin", branch: nil, ff_only: true, credentials: nil, ssh: nil, timeout: 30, cancelled: nil, &progress)
|
|
81
|
+
raise ArgumentError, "only fast-forward pulls are supported" unless ff_only == true
|
|
82
|
+
validate_cancelled(cancelled)
|
|
83
|
+
check_cancellation!(cancelled)
|
|
84
|
+
raise ArgumentError, "bare repository has no worktree" unless root
|
|
85
|
+
local_branch = self.branch
|
|
86
|
+
raise ArgumentError, "cannot pull with a detached HEAD" unless local_branch
|
|
87
|
+
remote_branch = branch || local_branch
|
|
88
|
+
raise ArgumentError, "invalid remote branch" unless valid_refspec_name?("refs/heads/#{remote_branch}")
|
|
89
|
+
ensure_clean_tracked_state!
|
|
90
|
+
current = head
|
|
91
|
+
|
|
92
|
+
settings = remote_settings[remote]
|
|
93
|
+
destination = settings && "refs/remotes/#{remote}/#{remote_branch}"
|
|
94
|
+
spec = "refs/heads/#{remote_branch}:#{destination}"
|
|
95
|
+
spec = "refs/heads/#{remote_branch}" unless destination
|
|
96
|
+
advertised = fetch(remote, refspecs: spec, credentials: credentials, ssh: ssh, timeout: timeout,
|
|
97
|
+
cancelled: cancelled, &progress)
|
|
98
|
+
check_cancellation!(cancelled)
|
|
99
|
+
target = advertised.find { |ref| ref.name == "refs/heads/#{remote_branch}" }&.oid
|
|
100
|
+
raise TransportError, "remote branch not found: #{remote_branch}" unless target
|
|
101
|
+
return target if current == target
|
|
102
|
+
raise TransportError, "pull is not a fast-forward" if current && merge_base(current, target) != current
|
|
103
|
+
|
|
104
|
+
check_cancellation!(cancelled)
|
|
105
|
+
RefStore.new(self).update("HEAD", target, old_oid: current, message: "pull #{remote} #{remote_branch}: fast-forward") do
|
|
106
|
+
raise RefLockError, "HEAD changed during pull" unless self.branch == local_branch
|
|
107
|
+
ensure_clean_tracked_state!
|
|
108
|
+
check_cancellation!(cancelled)
|
|
109
|
+
replace_repository_state(tree(target), tree(target))
|
|
110
|
+
end
|
|
59
111
|
end
|
|
60
112
|
|
|
61
113
|
private
|
|
62
114
|
|
|
115
|
+
def validate_cancelled(cancelled)
|
|
116
|
+
return if cancelled.nil?
|
|
117
|
+
|
|
118
|
+
callable = cancelled.respond_to?(:call) && cancelled.method(:call)
|
|
119
|
+
valid = callable && callable.parameters.none? { |kind, _| %i[req keyreq].include?(kind) }
|
|
120
|
+
raise TypeError, "cancelled must be a zero-argument callable" unless valid
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def check_cancellation!(cancelled)
|
|
124
|
+
raise Cancelled, "transfer cancelled" if cancelled&.call
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def with_transfer(connection, cancelled)
|
|
128
|
+
state = {done: false, cancelled: false}
|
|
129
|
+
lock = Mutex.new
|
|
130
|
+
check = lambda do
|
|
131
|
+
requested = lock.synchronize { state[:cancelled] } || cancelled&.call
|
|
132
|
+
lock.synchronize { state[:cancelled] = true } if requested
|
|
133
|
+
raise Cancelled, "transfer cancelled" if requested
|
|
134
|
+
end
|
|
135
|
+
check.call
|
|
136
|
+
watcher = if cancelled
|
|
137
|
+
Thread.new do
|
|
138
|
+
loop do
|
|
139
|
+
sleep 0.01
|
|
140
|
+
break if lock.synchronize { state[:done] }
|
|
141
|
+
next unless cancelled.call
|
|
142
|
+
|
|
143
|
+
lock.synchronize { state[:cancelled] = true }
|
|
144
|
+
connection.close
|
|
145
|
+
break
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
yield check
|
|
150
|
+
rescue TransportError
|
|
151
|
+
raise Cancelled, "transfer cancelled" if lock&.synchronize { state[:cancelled] }
|
|
152
|
+
|
|
153
|
+
raise
|
|
154
|
+
ensure
|
|
155
|
+
lock&.synchronize { state[:done] = true }
|
|
156
|
+
watcher&.join
|
|
157
|
+
connection&.close
|
|
158
|
+
end
|
|
159
|
+
|
|
63
160
|
def direct_remote?(remote)
|
|
64
161
|
value = remote.to_s
|
|
65
162
|
value.match?(/\A(?:https?|ssh|file):\/\//i) || (!value.include?("://") && value.include?(":")) ||
|
data/lib/thuban/remote.rb
CHANGED
data/lib/thuban/repository.rb
CHANGED
|
@@ -36,6 +36,14 @@ module Thuban
|
|
|
36
36
|
def index = Index.new(File.join(git_dir, "index"))
|
|
37
37
|
def head = resolve("HEAD")
|
|
38
38
|
|
|
39
|
+
def signature(role: :author)
|
|
40
|
+
current_signature(role, fallback: false)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def filemode?
|
|
44
|
+
!%w[false no off 0].include?(git_config_value("core", "filemode").to_s.downcase)
|
|
45
|
+
end
|
|
46
|
+
|
|
39
47
|
def branch
|
|
40
48
|
text = File.read(File.join(git_dir, "HEAD")).strip
|
|
41
49
|
text.start_with?("ref: refs/heads/") ? text.delete_prefix("ref: refs/heads/") : nil
|
|
@@ -253,6 +261,33 @@ module Thuban
|
|
|
253
261
|
|
|
254
262
|
private
|
|
255
263
|
|
|
264
|
+
def current_signature(role, fallback:)
|
|
265
|
+
prefix = case role
|
|
266
|
+
when :author then "GIT_AUTHOR"
|
|
267
|
+
when :committer then "GIT_COMMITTER"
|
|
268
|
+
else raise ArgumentError, "role must be :author or :committer"
|
|
269
|
+
end
|
|
270
|
+
name = ENV["#{prefix}_NAME"] || git_config_value("user", "name")
|
|
271
|
+
email = ENV["#{prefix}_EMAIL"] || git_config_value("user", "email")
|
|
272
|
+
if fallback
|
|
273
|
+
name ||= ENV["USER"] || "unknown"
|
|
274
|
+
email ||= "unknown@localhost"
|
|
275
|
+
end
|
|
276
|
+
missing = {name: name, email: email}.filter_map { |key, value| key if value.nil? || value.empty? }
|
|
277
|
+
raise ArgumentError, "Git #{role} identity is missing #{missing.join(' and ')}" unless missing.empty?
|
|
278
|
+
|
|
279
|
+
now = Time.now
|
|
280
|
+
value = Signature.new(name: name, email: email, time: now, offset: now.utc_offset)
|
|
281
|
+
format_signature(value)
|
|
282
|
+
value
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def git_config_value(section, key)
|
|
286
|
+
files = IgnoreMatcher.send(:git_config_files, root || common_dir)
|
|
287
|
+
files << File.join(common_dir, "config") unless root
|
|
288
|
+
files.uniq.filter_map { |path| IgnoreMatcher.send(:config_value, path, section, key) }.last
|
|
289
|
+
end
|
|
290
|
+
|
|
256
291
|
def prune_empty_directories(paths)
|
|
257
292
|
paths.sort_by { |path| -path.count("/") }.each do |path|
|
|
258
293
|
parent = File.dirname(File.join(root, path))
|
data/lib/thuban/status.rb
CHANGED
|
@@ -14,6 +14,7 @@ module Thuban
|
|
|
14
14
|
repository = @repository
|
|
15
15
|
index = repository.index
|
|
16
16
|
head = repository.tree
|
|
17
|
+
filemode = repository.filemode?
|
|
17
18
|
tracked = index.entries.group_by(&:path)
|
|
18
19
|
result = []
|
|
19
20
|
(head.keys | tracked.keys).sort.each do |path|
|
|
@@ -30,7 +31,7 @@ module Thuban
|
|
|
30
31
|
elsif !staged then "D"
|
|
31
32
|
elsif original.mode != staged.mode || original.oid != staged.oid then "M"
|
|
32
33
|
else " " end
|
|
33
|
-
y = staged ? worktree_status(staged, index.path) : " "
|
|
34
|
+
y = staged ? worktree_status(staged, index.path, filemode) : " "
|
|
34
35
|
result << Entry.new(path: path, index: x, worktree: y) unless x == " " && y == " "
|
|
35
36
|
end
|
|
36
37
|
submodules = index.entries.select { |entry| entry.mode == 0o160000 }.map { |entry| entry.path + "/" }
|
|
@@ -43,7 +44,7 @@ module Thuban
|
|
|
43
44
|
|
|
44
45
|
private
|
|
45
46
|
|
|
46
|
-
def worktree_status(entry, index_path)
|
|
47
|
+
def worktree_status(entry, index_path, filemode)
|
|
47
48
|
absolute = @repository.worktree_path(entry.path)
|
|
48
49
|
stat = File.lstat(absolute)
|
|
49
50
|
if entry.mode == 0o160000
|
|
@@ -56,7 +57,7 @@ module Thuban
|
|
|
56
57
|
end
|
|
57
58
|
mode = stat.symlink? ? 0o120000 : stat.file? ? 0o100000 | ((stat.mode & 0o100).positive? ? 0o755 : 0o644) : 0
|
|
58
59
|
return "T" if (mode & 0o170000) != (entry.mode & 0o170000)
|
|
59
|
-
return "M" if mode != entry.mode
|
|
60
|
+
return "M" if filemode && mode != entry.mode
|
|
60
61
|
return " " if (entry.extended_flags.to_i & 0x4000).positive? # skip-worktree
|
|
61
62
|
index_time = File.mtime(index_path)
|
|
62
63
|
if stat.size == entry.size && stat.mtime.to_i == entry.mtime && stat.mtime.nsec == entry.mtime_nsec &&
|
data/lib/thuban/version.rb
CHANGED
data/sig/thuban.rbs
CHANGED
|
@@ -27,6 +27,9 @@ module Thuban
|
|
|
27
27
|
class TransportError < Error
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
class Cancelled < TransportError
|
|
31
|
+
end
|
|
32
|
+
|
|
30
33
|
class AuthenticationError < TransportError
|
|
31
34
|
end
|
|
32
35
|
|
|
@@ -85,6 +88,7 @@ module Thuban
|
|
|
85
88
|
attr_accessor committer: String?
|
|
86
89
|
attr_accessor message: String
|
|
87
90
|
def self.new: (oid: String, ?tree: String?, parents: Array[String], ?author: String?, ?committer: String?, message: String) -> instance
|
|
91
|
+
def signature: (?role: :author | :committer) -> Signature
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
class TreeEntry < Struct[untyped]
|
|
@@ -126,10 +130,14 @@ module Thuban
|
|
|
126
130
|
def index: () -> Index
|
|
127
131
|
def head: () -> String?
|
|
128
132
|
def branch: () -> String?
|
|
133
|
+
def signature: (?role: :author | :committer) -> Signature
|
|
134
|
+
def filemode?: () -> bool
|
|
129
135
|
def refs: () -> Hash[String, String?]
|
|
130
136
|
def branches: () -> Array[String]
|
|
131
137
|
def resolve: (String name, ?Array[String] seen) -> String?
|
|
132
138
|
def commit: (?String reference) -> Commit?
|
|
139
|
+
def each_commit: (?String reference, limit: Integer) -> Enumerator[Commit, nil]
|
|
140
|
+
| (?String reference, limit: Integer) { (Commit) -> void } -> nil
|
|
133
141
|
def tree: (?String reference) -> Hash[String, TreeEntry]
|
|
134
142
|
def blob: (String path, ?reference: String) -> String?
|
|
135
143
|
def staged_blob: (String path) -> String?
|
|
@@ -149,7 +157,7 @@ module Thuban
|
|
|
149
157
|
def symbolic_ref: (String name, String target) -> String
|
|
150
158
|
def reflog: (String name) -> Array[String]
|
|
151
159
|
def write_tree_from_index: () -> String
|
|
152
|
-
def commit!: (message: String, author: Signature, ?amend: bool) -> String
|
|
160
|
+
def commit!: (message: String, author: Signature, ?committer: Signature?, ?amend: bool) -> String
|
|
153
161
|
def merge_base: (String a, String b) -> String?
|
|
154
162
|
def reset: (String oid, ?mode: :soft | :mixed | :hard) -> String
|
|
155
163
|
def cherry_pick: (String oid) -> String
|
|
@@ -158,8 +166,9 @@ module Thuban
|
|
|
158
166
|
def stash_pop: (?Integer index) -> String
|
|
159
167
|
def stash_list: () -> Array[Commit]
|
|
160
168
|
def remotes: () -> Hash[String, String]
|
|
161
|
-
def fetch: (?String remote, ?refspecs: String | Enumerable[String]?, ?depth: Integer?, ?filter: String?) ?{ (Progress) -> void } -> Array[Ref]
|
|
162
|
-
def push: (?String remote, refspecs: String | Enumerable[String], ?force: bool, ?lease: String | Hash[String, String]?, ?atomic: bool) ?{ (Progress) -> void } -> Array[Ref]
|
|
169
|
+
def fetch: (?String remote, ?refspecs: String | Enumerable[String]?, ?depth: Integer?, ?filter: String?, ?credentials: untyped, ?ssh: Remote::ssh_command?, ?timeout: Numeric, ?cancelled: ^() -> bool?) ?{ (Progress) -> void } -> Array[Ref]
|
|
170
|
+
def push: (?String remote, refspecs: String | Enumerable[String], ?force: bool, ?lease: String | Hash[String, String]?, ?atomic: bool, ?credentials: untyped, ?ssh: Remote::ssh_command?, ?timeout: Numeric, ?cancelled: ^() -> bool?) ?{ (Progress) -> void } -> Array[Ref]
|
|
171
|
+
def pull: (?String remote, ?branch: String?, ?ff_only: bool, ?credentials: untyped, ?ssh: Remote::ssh_command?, ?timeout: Numeric, ?cancelled: ^() -> bool?) ?{ (Progress) -> void } -> String
|
|
163
172
|
end
|
|
164
173
|
|
|
165
174
|
class ObjectDatabase
|