thuban 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f141979f71fdcf5cb0cc8b6678e084569285d9d3ee9f21bf2e584dbeb98a9139
4
- data.tar.gz: 9fbdf6deec48a31479b5d2b12f564beeaac8cde969d9f50a68f476d89011e59d
3
+ metadata.gz: e52056d963a0a6c8bb0ef6efa4b99a5a3dcae5e77f098dda27fd3b3805a4e224
4
+ data.tar.gz: 8beb8fdd5713ea96004344de2bc6973a09ead85772596de17d76988cd0064589
5
5
  SHA512:
6
- metadata.gz: b98d9e65ebe84062f9960c19557a871a80344bcbdd9c9bbb77ae9ffe8e30579464249be003a6f26831b76d63436fc4641dd741678eeb24f15b2f668706dc8512
7
- data.tar.gz: 4f8eb7a9e0214dcd4e34ce1f08bb8d771c87ad86b428ac5ebe14a26a1bf86502bb6cea8b39d25c14cc475a44e2fdc3d3683d6129e53111599694339fcf03333b
6
+ metadata.gz: 8014b9b58cdc918c361461ef5100adbdd02243a710587857e0bf9fa7d9c4c23eacfdd04c0bbb96fb251172b2d432754fa35ae363ac13e664562efe360c5b60f1
7
+ data.tar.gz: efcf26c58b99c870914943fddec1a1b347eb0b40ab29b1074e65fb4074f64f3aae5d464b15a71f78ac2ed3e0a8eeedde4832c2bc32ae2e208df77a60bd5d7758
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-09-15
4
+
5
+ - Add loose object, tree, and commit writing
6
+ - Add index mutation with v2/v3/v4 encoding and raw optional-extension preservation
7
+ - Add locked loose and packed ref updates with reflogs and optimistic old-OID checks
8
+ - Add index-to-tree commit creation and amend support
9
+ - Add merge-base, reset, and three-way cherry-pick and revert operations
10
+ - Add Git-compatible stash push, list, and three-way pop operations
11
+ - Add delta-free PACK v2 writing with progress and checksum reporting
12
+
3
13
  ## 0.2.0 - 2026-09-14
4
14
 
5
15
  - Add `Thuban::IgnoreMatcher.load` with Git-compatible ignore source precedence
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <h1 align="center">Thuban</h1>
2
2
 
3
3
  <p align="center">
4
- <strong>A pure Ruby reader for local Git repositories</strong>
4
+ <strong>A pure Ruby implementation for local Git repositories</strong>
5
5
  </p>
6
6
 
7
7
  <p align="center">
@@ -22,15 +22,18 @@
22
22
 
23
23
  ---
24
24
 
25
- Thuban is a pure Ruby Git repository reader for objects, packs, index,
26
- status, blame, and guarded checkout. It works directly with local repository
27
- data without invoking the Git executable.
25
+ Thuban is a pure Ruby Git implementation for reading and writing local
26
+ repositories. It works directly with repository data without invoking the Git
27
+ executable.
28
28
 
29
29
  ## Features
30
30
 
31
31
  - Reads loose objects and packfiles, including deltified objects
32
32
  - Resolves refs, branches, commits, trees, and blobs
33
33
  - Reads the index and reports staged, worktree, and untracked changes
34
+ - Writes loose objects, index entries, refs, reflogs, trees, and commits
35
+ - Finds merge bases and performs reset, cherry-pick, revert, and stash operations
36
+ - Writes interoperable delta-free Git packfiles to any writable IO
34
37
  - Tracks line history across commits and renames with blame
35
38
  - Writes files atomically and checks out branches with collision guards
36
39
  - Supports linked worktrees and packed refs
@@ -116,6 +119,82 @@ repo.checkout("feature")
116
119
  file atomically. `Repository#checkout` requires a clean tracked worktree and
117
120
  aborts on untracked or ignored collisions.
118
121
 
122
+ ### Stage and Commit
123
+
124
+ Write a blob, add it to the index, then create a commit from the index:
125
+
126
+ ```ruby
127
+ path = "README.md"
128
+ oid = repo.write_blob(File.binread(path))
129
+ index = repo.index
130
+ index.stage(path, oid, 0o100644, stat: File.stat(path))
131
+ index.write
132
+
133
+ author = Thuban::Signature.new(
134
+ name: "Example Author",
135
+ email: "author@example.com",
136
+ time: Time.now
137
+ )
138
+ commit = repo.commit!(message: "Update README", author: author)
139
+ ```
140
+
141
+ `Index#write` uses Git's `index.lock`, retains optional extensions as raw bytes,
142
+ and invalidates entry-dependent cache extensions after mutation. `unstage`
143
+ removes the stage-zero entry; stage the corresponding HEAD entry to restore a
144
+ tracked path. `conflicts` exposes stage 1/2/3 entries and `resolve` replaces
145
+ them with a stage-zero entry.
146
+
147
+ Create and update refs with optimistic old-OID checks:
148
+
149
+ ```ruby
150
+ repo.create_branch("topic", repo.head)
151
+ repo.update_ref("refs/heads/topic", commit, old_oid: repo.head, message: "advance")
152
+ repo.reflog("topic") # raw Git reflog records
153
+ ```
154
+
155
+ Ref mutations use `.lock` files and raise `Thuban::RefLockError` when a lock is
156
+ held or the expected old OID no longer matches. Loose updates override packed
157
+ refs, and deletion removes both forms.
158
+
159
+ ### History and Stash
160
+
161
+ Use the same branch names, tags, or full object IDs accepted by the read API:
162
+
163
+ ```ruby
164
+ base = repo.merge_base("main", "topic")
165
+ repo.reset(base, mode: :mixed) # :soft and :hard are also supported
166
+ picked = repo.cherry_pick("topic")
167
+ repo.revert(picked)
168
+
169
+ stash = repo.stash_push(message: "before refactor", include_untracked: true)
170
+ repo.stash_list # newest first, as Commit objects
171
+ repo.stash_pop if stash
172
+ ```
173
+
174
+ Cherry-pick and revert require a clean tracked worktree and accept commits with
175
+ at most one parent. They merge non-overlapping text changes and detect remaining
176
+ three-way conflicts before changing files. Stash uses Git's standard commit and
177
+ reflog layout, retains staged state, and can include untracked files.
178
+ Worktree-changing operations reject submodules and untracked collisions rather
179
+ than silently deleting data.
180
+
181
+ ### Write Packfiles
182
+
183
+ `Pack.write` accepts `[type, data]` object pairs, reports completed objects to an
184
+ optional block, and returns the hexadecimal pack checksum:
185
+
186
+ ```ruby
187
+ objects = object_ids.map { |oid| repo.object(oid) }
188
+ File.open("out.pack", "wb") do |file|
189
+ checksum = Thuban::Pack.write(file, objects) do |current, total|
190
+ warn "#{current}/#{total}"
191
+ end
192
+ end
193
+ ```
194
+
195
+ The emitted PACK v2 stream stores complete compressed objects without delta
196
+ generation. It can be consumed by `git index-pack` and `git verify-pack`.
197
+
119
198
  ### Match Ignored Paths
120
199
 
121
200
  Load Git's global excludes, `.git/info/exclude`, and nested `.gitignore` files.
@@ -135,9 +214,11 @@ continuations, and command-scoped overrides are not evaluated.
135
214
 
136
215
  ## Scope
137
216
 
138
- Thuban does not perform network operations such as fetch or push, rewrite
139
- history, or provide its own diff algorithm. Blame delegates line matching to
140
- Porrima through the injectable `differ:` argument.
217
+ The current write API covers loose objects, the index, refs, reflogs, commits,
218
+ guarded checkout, local history operations, stash, and delta-free pack output.
219
+ Thuban does not yet perform network operations, merges, pack delta generation,
220
+ or streaming pack ingestion. It does not provide its own diff algorithm; blame
221
+ delegates line matching to Porrima through the injectable `differ:` argument.
141
222
 
142
223
  Support is limited to the index and pack formats covered by the test suite.
143
224
  Submodule checkout and optional Git extensions outside that coverage are not
@@ -151,6 +232,7 @@ bundle exec rake
151
232
  ruby tools/check_isolation.rb
152
233
  bundle exec rbs -I sig -r porrima validate
153
234
  gem build --strict thuban.gemspec
235
+ ruby bench/pack_write.rb --assert
154
236
  ```
155
237
 
156
238
  ## Contributing
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class Repository
5
+ def write_tree_from_index
6
+ current = index
7
+ raise ArgumentError, "cannot write a tree with unresolved conflicts" unless current.conflicts.empty?
8
+
9
+ write_tree_from_entries(current.entries)
10
+ end
11
+
12
+ def commit!(message:, author:, amend: false)
13
+ previous = head
14
+ current = previous && commit(previous)
15
+ raise ArgumentError, "cannot amend an unborn branch" if amend && !current
16
+
17
+ parents = amend ? current.parents : previous ? [previous] : []
18
+ oid = write_commit(tree: write_tree_from_index, parents: parents, author: author, message: message)
19
+ subject = message.lines.first.to_s.strip
20
+ action = if amend
21
+ "commit (amend): #{subject}"
22
+ elsif previous
23
+ "commit: #{subject}"
24
+ else
25
+ "commit (initial): #{subject}"
26
+ end
27
+ update_ref("HEAD", oid, old_oid: previous, message: action)
28
+ oid
29
+ end
30
+
31
+ private
32
+
33
+ def write_tree_from_entries(entries)
34
+ root = {}
35
+ entries.each do |entry|
36
+ node = root
37
+ parts = entry.path.split("/")
38
+ raise ArgumentError, "index path nesting exceeds limit" if parts.length > 256
39
+ parts[0...-1].each do |part|
40
+ raise ArgumentError, "index contains a file/directory collision" if node.key?(part) && !node[part].is_a?(Hash)
41
+ node = node[part] ||= {}
42
+ end
43
+ raise ArgumentError, "index contains duplicate or colliding paths" if node.key?(parts.last)
44
+ node[parts.last] = entry
45
+ end
46
+ write_index_tree(root)
47
+ end
48
+
49
+ def write_index_tree(node)
50
+ entries = node.map do |name, value|
51
+ if value.is_a?(Hash)
52
+ TreeEntry.new(path: name, oid: write_index_tree(value), mode: 0o040000)
53
+ else
54
+ TreeEntry.new(path: name, oid: value.oid, mode: value.mode)
55
+ end
56
+ end
57
+ write_tree(entries)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class Repository
5
+ def merge_base(a, b)
6
+ left = require_commit(a).oid
7
+ right = require_commit(b).oid
8
+ ancestors = {}
9
+ walk_commits(left) { |oid| ancestors[oid] = true }
10
+ common = []
11
+ walk_commits(right) { |oid| common << oid if ancestors[oid] }
12
+ common_set = common.to_h { |oid| [oid, true] }
13
+ inferior = {}
14
+ common.each do |oid|
15
+ require_commit(oid).parents.each { |parent| inferior[parent] = true if common_set[parent] }
16
+ end
17
+ common.find { |oid| !inferior[oid] }
18
+ end
19
+
20
+ def reset(oid, mode: :mixed)
21
+ raise ArgumentError, "invalid reset mode" unless %i[soft mixed hard].include?(mode)
22
+
23
+ target = require_commit(oid)
24
+ target_tree = tree(target.oid)
25
+ previous = head
26
+ RefStore.new(self).update("HEAD", target.oid, old_oid: previous, message: "reset: moving to #{oid}") do
27
+ case mode
28
+ when :mixed then replace_index(target_tree)
29
+ when :hard then replace_repository_state(target_tree, target_tree)
30
+ end
31
+ end
32
+ end
33
+
34
+ def cherry_pick(oid)
35
+ source = require_commit(oid)
36
+ raise ArgumentError, "cannot cherry-pick a merge commit" if source.parents.length > 1
37
+
38
+ 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,
40
+ action: "cherry-pick")
41
+ end
42
+
43
+ def revert(oid)
44
+ source = require_commit(oid)
45
+ raise ArgumentError, "cannot revert a merge commit" if source.parents.length > 1
46
+
47
+ base = source.parents.empty? ? {} : tree(source.parents.first)
48
+ subject = source.message.lines.first.to_s.chomp
49
+ message = "Revert \"#{subject}\"\n\nThis reverts commit #{source.oid}.\n"
50
+ apply_commit_change(source, tree(source.oid), base, author: operation_signature, message: message, action: "revert")
51
+ end
52
+
53
+ private
54
+
55
+ def require_commit(reference)
56
+ commit(reference) || raise(ArgumentError, "unknown commit: #{reference}")
57
+ end
58
+
59
+ def walk_commits(start)
60
+ queue = [start]
61
+ seen = {}
62
+ cursor = 0
63
+ while cursor < queue.length
64
+ oid = queue[cursor]
65
+ cursor += 1
66
+ next if seen[oid]
67
+
68
+ seen[oid] = true
69
+ yield oid
70
+ queue.concat(require_commit(oid).parents)
71
+ end
72
+ end
73
+
74
+ def apply_commit_change(source, base, incoming, author:, message:, action:)
75
+ ensure_clean_tracked_state!
76
+ current_oid = head
77
+ raise ArgumentError, "cannot apply a commit on an unborn branch" unless current_oid
78
+
79
+ current = tree(current_oid)
80
+ result = apply_tree_change(base, incoming, current, action)
81
+ raise ArgumentError, "#{action} is empty" if same_tree?(current, result)
82
+
83
+ tree_oid = write_tree_from_entries(result.values)
84
+ commit_oid = write_commit(tree: tree_oid, parents: [current_oid], author: author,
85
+ committer: operation_signature, message: message)
86
+ RefStore.new(self).update("HEAD", commit_oid, old_oid: current_oid,
87
+ message: "#{action}: #{source.message.lines.first.to_s.strip}") do
88
+ replace_repository_state(result, result)
89
+ end
90
+ end
91
+
92
+ def apply_tree_change(base, incoming, current, action)
93
+ result = current.dup
94
+ conflicts = []
95
+ (base.keys | incoming.keys).each do |path|
96
+ original = base[path]
97
+ ours = current[path]
98
+ theirs = incoming[path]
99
+ if same_tree_entry?(ours, original)
100
+ theirs ? result[path] = theirs : result.delete(path)
101
+ elsif !same_tree_entry?(ours, theirs) && !same_tree_entry?(original, theirs)
102
+ merged = merge_tree_entry(original, ours, theirs)
103
+ merged ? result[path] = merged : conflicts << path
104
+ end
105
+ end
106
+ raise ArgumentError, "#{action} conflicts: #{conflicts.sort.join(', ')}" unless conflicts.empty?
107
+ result
108
+ end
109
+
110
+ def same_tree?(left, right)
111
+ left.keys.sort == right.keys.sort && left.all? { |path, entry| same_tree_entry?(entry, right[path]) }
112
+ end
113
+
114
+ def same_tree_entry?(left, right)
115
+ return left.nil? && right.nil? unless left && right
116
+
117
+ left.oid == right.oid && left.mode == right.mode
118
+ end
119
+
120
+ def merge_tree_entry(base, ours, theirs)
121
+ entries = [base, ours, theirs]
122
+ return unless entries.all? && entries.all? { |entry| [0o100644, 0o100755].include?(entry.mode) }
123
+
124
+ mode = if ours.mode == base.mode then theirs.mode
125
+ elsif theirs.mode == base.mode || ours.mode == theirs.mode then ours.mode end
126
+ return unless mode
127
+
128
+ contents = entries.map do |entry|
129
+ type, content = odb.read(entry.oid)
130
+ raise CorruptObject, "tree entry is not a blob" unless type == "blob"
131
+
132
+ content
133
+ end
134
+ return if contents.any? { |content| content.include?("\0") }
135
+
136
+ merged = Porrima::Merge.three_way(base: contents[0], ours: contents[1], theirs: contents[2])
137
+ return unless merged.clean?
138
+
139
+ TreeEntry.new(path: ours.path, oid: write_blob(merged.sections.join), mode: mode)
140
+ end
141
+
142
+ def ensure_clean_tracked_state!
143
+ raise ArgumentError, "worktree/index must be clean" if status.any? { |entry| entry.index != "?" }
144
+ end
145
+
146
+ def replace_repository_state(worktree_tree, index_tree, remove_paths: [])
147
+ raise ArgumentError, "bare repository has no worktree" unless root
148
+ current_index = index
149
+ current = current_index.entries.to_h { |entry| [entry.path, entry] }
150
+ tracked = head ? tree(head).merge(current) : current
151
+ raise ArgumentError, "submodule updates require separate worktree handling" if
152
+ (tracked.values + worktree_tree.values + index_tree.values).any? { |entry| entry.mode == 0o160000 }
153
+
154
+ removed = (tracked.keys - worktree_tree.keys) | remove_paths
155
+ check_worktree_collisions(worktree_tree, tracked, removed)
156
+ contents = worktree_tree.to_h { |path, entry| [path, odb.read(entry.oid).last] }
157
+ affected = (tracked.keys | worktree_tree.keys | remove_paths)
158
+ backups = affected.to_h { |path| [path, worktree_backup(path)] }
159
+ index_path = File.join(git_dir, "index")
160
+ index_backup = File.file?(index_path) ? [File.binread(index_path), File.stat(index_path).mode & 0o777] : nil
161
+ lock_path = index_path + ".lock"
162
+ begin
163
+ lock = File.open(lock_path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
164
+ rescue Errno::EEXIST
165
+ raise IOError, "Git index is locked: #{lock_path}"
166
+ end
167
+ owns_lock = true
168
+ begin
169
+ write_worktree_tree(worktree_tree, contents, removed)
170
+ extensions = current_index.extensions.reject { |extension| Index::ENTRY_DEPENDENT_EXTENSIONS.include?(extension.byteslice(0, 4)) }
171
+ lock.write(Index.encode(index_entries(index_tree), extensions: extensions, version: current_index.version))
172
+ lock.flush
173
+ lock.fsync
174
+ lock.close
175
+ File.rename(lock_path, index_path)
176
+ owns_lock = false
177
+ rescue StandardError
178
+ restore_worktree(backups, affected)
179
+ raise
180
+ ensure
181
+ lock&.close unless lock&.closed?
182
+ File.unlink(lock_path) if owns_lock && File.exist?(lock_path)
183
+ end
184
+ -> { restore_repository_state(backups, affected, index_path, index_backup) }
185
+ end
186
+
187
+ def write_worktree_tree(target, contents, removed)
188
+ removed.sort_by { |path| -path.count("/") }.each do |path|
189
+ absolute = worktree_path(path)
190
+ File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
191
+ end
192
+ prune_empty_directories(removed)
193
+ target.sort.each do |path, entry|
194
+ absolute = worktree_path(path)
195
+ Dir.rmdir(absolute) if File.directory?(absolute) && !File.symlink?(absolute)
196
+ FileUtils.mkdir_p(File.dirname(absolute))
197
+ File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
198
+ if entry.mode == 0o120000
199
+ File.symlink(contents.fetch(path), absolute)
200
+ else
201
+ atomic_write(absolute, contents.fetch(path), entry.mode & 0o777)
202
+ end
203
+ end
204
+ end
205
+
206
+ def worktree_backup(path)
207
+ absolute = worktree_path(path)
208
+ return [:symlink, File.readlink(absolute).b] if File.symlink?(absolute)
209
+ return [:file, File.binread(absolute), File.stat(absolute).mode & 0o777] if File.file?(absolute)
210
+
211
+ nil
212
+ end
213
+
214
+ def restore_worktree(backups, affected)
215
+ affected.sort_by { |path| -path.count("/") }.each do |path|
216
+ absolute = worktree_path(path)
217
+ File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
218
+ end
219
+ prune_empty_directories(affected)
220
+ backups.each do |path, backup|
221
+ next unless backup
222
+
223
+ absolute = worktree_path(path)
224
+ Dir.rmdir(absolute) if File.directory?(absolute) && !File.symlink?(absolute)
225
+ FileUtils.mkdir_p(File.dirname(absolute))
226
+ if backup[0] == :symlink
227
+ File.symlink(backup[1], absolute)
228
+ else
229
+ atomic_write(absolute, backup[1], backup[2])
230
+ end
231
+ end
232
+ end
233
+
234
+ def restore_repository_state(backups, affected, index_path, index_backup)
235
+ restore_worktree(backups, affected)
236
+ if index_backup
237
+ atomic_write(index_path, *index_backup)
238
+ elsif File.file?(index_path) || File.symlink?(index_path)
239
+ File.unlink(index_path)
240
+ end
241
+ end
242
+
243
+ def check_worktree_collisions(target, current, removed)
244
+ target.each_key do |path|
245
+ absolute = worktree_path(path, replacing: removed)
246
+ unless current.key?(path)
247
+ if File.directory?(absolute) && !File.symlink?(absolute)
248
+ Find.find(absolute) do |entry|
249
+ next if File.directory?(entry) && !File.symlink?(entry)
250
+ relative = entry.delete_prefix(root + File::SEPARATOR)
251
+ raise ArgumentError, "untracked worktree collision: #{path}" unless removed.include?(relative)
252
+ end
253
+ elsif (File.exist?(absolute) || File.symlink?(absolute)) && !removed.include?(path)
254
+ raise ArgumentError, "untracked worktree collision: #{path}"
255
+ end
256
+ end
257
+ parent = File.dirname(absolute)
258
+ until parent == root
259
+ relative = parent.delete_prefix(root + File::SEPARATOR)
260
+ if (File.file?(parent) || File.symlink?(parent)) && !removed.include?(relative)
261
+ raise ArgumentError, "untracked worktree collision: #{relative}"
262
+ end
263
+ parent = File.dirname(parent)
264
+ end
265
+ end
266
+ end
267
+
268
+ def replace_index(target)
269
+ current = index
270
+ backup = File.file?(current.path) ? [File.binread(current.path), File.stat(current.path).mode & 0o777] : nil
271
+ current.entries.replace(index_entries(target))
272
+ current.extensions.reject! { |extension| Index::ENTRY_DEPENDENT_EXTENSIONS.include?(extension.byteslice(0, 4)) }
273
+ current.write
274
+ -> do
275
+ if backup
276
+ atomic_write(current.path, *backup)
277
+ elsif File.file?(current.path) || File.symlink?(current.path)
278
+ File.unlink(current.path)
279
+ end
280
+ end
281
+ end
282
+
283
+ def index_entries(target)
284
+ target.sort.map do |path, entry|
285
+ stat = matching_worktree_stat(path, entry)
286
+ Index::Entry.new(path: path, oid: entry.oid, mode: entry.mode, size: stat&.size.to_i,
287
+ mtime: stat&.mtime.to_i, mtime_nsec: stat&.mtime&.nsec.to_i,
288
+ ctime: stat&.ctime.to_i, ctime_nsec: stat&.ctime&.nsec.to_i,
289
+ dev: stat&.dev.to_i, ino: stat&.ino.to_i, uid: stat&.uid.to_i, gid: stat&.gid.to_i,
290
+ stage: 0, flags: 0, extended_flags: 0)
291
+ end
292
+ end
293
+
294
+ def matching_worktree_stat(path, entry)
295
+ absolute = worktree_path(path)
296
+ stat = File.lstat(absolute)
297
+ mode = stat.symlink? ? 0o120000 : stat.file? ? 0o100000 | ((stat.mode & 0o100).positive? ? 0o755 : 0o644) : 0
298
+ return unless mode == entry.mode
299
+
300
+ content = stat.symlink? ? File.readlink(absolute).b : File.binread(absolute)
301
+ stat if ObjectDatabase.hash("blob", content) == entry.oid
302
+ rescue Errno::ENOENT, Errno::ENOTDIR
303
+ nil
304
+ end
305
+
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
+ def operation_signature
314
+ RefStore.new(self).send(:reflog_signature)
315
+ end
316
+ end
317
+ end
data/lib/thuban/index.rb CHANGED
@@ -7,11 +7,12 @@ module Thuban
7
7
  Entry = Struct.new(:path, :oid, :mode, :size, :mtime, :mtime_nsec, :ctime, :ctime_nsec,
8
8
  :dev, :ino, :uid, :gid, :stage, :flags, :extended_flags, keyword_init: true)
9
9
  include Enumerable
10
- attr_reader :entries, :version, :path
10
+ attr_reader :entries, :extensions, :version, :path
11
11
 
12
12
  def initialize(path)
13
13
  @path = path
14
14
  @entries = []
15
+ @extensions = []
15
16
  @version = 2
16
17
  parse(File.binread(path)) if File.file?(path)
17
18
  end
@@ -19,18 +20,55 @@ module Thuban
19
20
  def each(&block) = entries.each(&block)
20
21
  def [](path) = entries.find { |entry| entry.path == path && entry.stage.zero? }
21
22
 
22
- def self.encode(entries)
23
- bytes = +"DIRC".b << [2, entries.length].pack("N2")
23
+ def self.encode(entries, extensions: [], version: 2)
24
+ raise ArgumentError, "unsupported Git index version #{version}" unless [2, 3, 4].include?(version)
25
+ bytes = +"DIRC".b << [version, entries.length].pack("N2")
26
+ previous = "".b
24
27
  entries.sort_by { |entry| [entry.path.b, entry.stage || 0] }.each do |entry|
25
28
  name = entry.path.b
29
+ raise ArgumentError, "unsafe index path" if name.empty? || name.include?("\0") || name.start_with?("/") || name.split("/").any? { |part| ["", "..", ".git"].include?(part) }
30
+ stage = entry.stage || 0
31
+ raise ArgumentError, "invalid index stage" unless (0..3).cover?(stage)
26
32
  fields = %i[ctime ctime_nsec mtime mtime_nsec dev ino mode uid gid size].map { |field| entry.public_send(field).to_i & 0xffffffff }
27
- record = fields.pack("N10") + [entry.oid].pack("H*") + [[name.bytesize, 0xfff].min | ((entry.stage || 0) << 12)].pack("n") + name + "\0"
28
- record << "\0" * ((8 - record.bytesize % 8) % 8)
33
+ raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(entry.oid.to_s)
34
+ extended = entry.extended_flags.to_i
35
+ has_extended = (entry.flags.to_i & 0x4000).positive? || !extended.zero?
36
+ raise ArgumentError, "extended flags require index version 3 or 4" if has_extended && version == 2
37
+ flags = (entry.flags.to_i & 0x8000) | [name.bytesize, 0xfff].min | (stage << 12)
38
+ flags |= 0x4000 if has_extended
39
+ record = fields.pack("N10") + [entry.oid].pack("H*") + [flags].pack("n")
40
+ record << [extended].pack("n") if has_extended
41
+ if version == 4
42
+ common = 0
43
+ limit = [previous.bytesize, name.bytesize].min
44
+ common += 1 while common < limit && previous.getbyte(common) == name.getbyte(common)
45
+ record << encode_varint(previous.bytesize - common) << name.byteslice(common..) << "\0"
46
+ else
47
+ record << name << "\0"
48
+ record << "\0" * ((8 - record.bytesize % 8) % 8)
49
+ end
29
50
  bytes << record
51
+ previous = name
52
+ end
53
+ extensions.each do |extension|
54
+ raise ArgumentError, "invalid index extension" unless extension.is_a?(String) && extension.bytesize >= 8
55
+ size = extension.byteslice(4, 4).unpack1("N")
56
+ raise ArgumentError, "invalid index extension" unless extension.bytesize == size + 8 && extension.byteslice(0, 1).match?(/[A-Z]/)
57
+ bytes << extension.b
30
58
  end
31
- bytes + Digest::SHA1.digest(bytes)
59
+ bytes << Digest::SHA1.digest(bytes)
32
60
  end
33
61
 
62
+ def self.encode_varint(value)
63
+ bytes = [value & 0x7f]
64
+ while (value >>= 7).positive?
65
+ value -= 1
66
+ bytes << (0x80 | (value & 0x7f))
67
+ end
68
+ bytes.reverse.pack("C*")
69
+ end
70
+ private_class_method :encode_varint
71
+
34
72
  private
35
73
 
36
74
  def parse(bytes)
@@ -79,6 +117,7 @@ module Thuban
79
117
  flags: flags, extended_flags: extended, stage: (flags >> 12) & 3)
80
118
  end
81
119
  while offset < bytes.bytesize - 20
120
+ start = offset
82
121
  raise CorruptObject, "truncated index extension" if offset + 8 > bytes.bytesize - 20
83
122
  signature = bytes[offset, 4]
84
123
  size = bytes[offset + 4, 4].unpack1("N")
@@ -86,6 +125,7 @@ module Thuban
86
125
  raise CorruptObject, "unsupported mandatory index extension #{signature}" if signature[0].match?(/[a-z]/)
87
126
  offset += 8 + size
88
127
  raise CorruptObject, "truncated index extension payload" if offset > bytes.bytesize - 20
128
+ extensions << bytes.byteslice(start, 8 + size).dup.freeze
89
129
  end
90
130
  end
91
131
  end