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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +89 -7
- data/lib/thuban/commit_writer.rb +60 -0
- data/lib/thuban/history_writer.rb +317 -0
- data/lib/thuban/index.rb +46 -6
- data/lib/thuban/index_writer.rb +95 -0
- data/lib/thuban/object_writer.rb +139 -0
- data/lib/thuban/pack_writer.rb +76 -0
- data/lib/thuban/ref_lock_error.rb +6 -0
- data/lib/thuban/ref_store.rb +270 -0
- data/lib/thuban/signature.rb +5 -0
- data/lib/thuban/stash.rb +156 -0
- data/lib/thuban/version.rb +1 -1
- data/lib/thuban/worktree_files.rb +1 -7
- data/lib/thuban.rb +9 -0
- data/sig/thuban.rbs +54 -1
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e52056d963a0a6c8bb0ef6efa4b99a5a3dcae5e77f098dda27fd3b3805a4e224
|
|
4
|
+
data.tar.gz: 8beb8fdd5713ea96004344de2bc6973a09ead85772596de17d76988cd0064589
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|