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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fd4f8874fc442816b49cd9d29963c6c318a733771f50466350cb84040a0242d
4
- data.tar.gz: 67a2e2496b0f3775f9619c181771ce8ae92a6e57063b5cd0dd383f4e7571e439
3
+ metadata.gz: 73f95adb20a42969a69908bab030d4939a94a1bd0bc9ddcacbb9b95dff3dd6a9
4
+ data.tar.gz: 8ebf2299e31de698cfae6b7487a7722703f50d4fd5a0820adf6e6c35d1eb2616
5
5
  SHA512:
6
- metadata.gz: 7ae73ad1b5ff6a95e5c221b3382316bc3860d25aff5e5272170468c4f65288fbd0055bff324485bca4c7c3fd896843815d540cb6536a3d50aa9ef29b0e87841f
7
- data.tar.gz: aa7b91df7056ee82de5bec2d27eff57ca38bf51bd393d3505034ebe2639d9a8aee76d3047b80d4aef5a3caa1c8432e7a79cfb4da98a62ecf695cfd020c7ded28
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.3.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 = Thuban::Signature.new(
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
@@ -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: signature_from(source.author), message: source.message,
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
- yield oid
70
- queue.concat(require_commit(oid).parents)
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
- RefStore.new(self).send(:reflog_signature)
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
- parse(File.binread(path)) if File.file?(path)
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)
@@ -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
- file.write(self.class.encode(entries, extensions: extensions, version: version))
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)
@@ -168,8 +168,7 @@ module Thuban
168
168
  end
169
169
 
170
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)
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
- Process.kill("KILL", waiter.pid)
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
- 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?
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
- 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
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
@@ -3,6 +3,7 @@
3
3
  module Thuban
4
4
  class Error < StandardError; end
5
5
  class TransportError < Error; end
6
+ class Cancelled < TransportError; end
6
7
  class AuthenticationError < TransportError; end
7
8
 
8
9
  Ref = Struct.new(:name, :oid, :symref_target, :peeled, keyword_init: true)
@@ -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 &&
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Thuban
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: thuban
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada