thuban 0.5.0 → 0.6.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 +3 -1
- data/README.md +19 -1
- data/lib/thuban/conflict_resolution.rb +174 -0
- data/lib/thuban/history_writer.rb +48 -16
- data/lib/thuban/index.rb +2 -0
- data/lib/thuban/ref_store.rb +12 -0
- data/lib/thuban/status.rb +1 -0
- data/lib/thuban/version.rb +1 -1
- data/lib/thuban.rb +1 -0
- data/sig/thuban.rbs +20 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a397fe928169662180d71c9c3c9df55eabd167eca7b300d01aaf4db57a5b5d16
|
|
4
|
+
data.tar.gz: 6cc6e1ed699ada02e46bf3623ca03dd73012ef9163b7b7a270889cbd1f817dcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 006ee4c48ee212ce1e70df30691e743bc0e1f798789d53a1afdc64ad643eba50a85a32f2e0c36e2fb043a3b7680225ee9b140153ef7374e636d1b96b81c1756a
|
|
7
|
+
data.tar.gz: 216cf9908627ff5655820f6d85b9499921aa89424bc5461df35c8d100392d9206c613f1b179850ce5a887d69839d32ce2fc7a481e7324ed5498737eb5323c31e
|
data/CHANGELOG.md
CHANGED
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.6.0"
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Then install it:
|
|
@@ -168,6 +168,24 @@ removes the stage-zero entry; stage the corresponding HEAD entry to restore a
|
|
|
168
168
|
tracked path. `conflicts` exposes stage 1/2/3 entries and `resolve` replaces
|
|
169
169
|
them with a stage-zero entry.
|
|
170
170
|
|
|
171
|
+
For a transactional index and worktree update, capture a repository conflict
|
|
172
|
+
and resolve that exact snapshot:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
conflict = repo.conflicts.first
|
|
176
|
+
repo.resolve_conflict(conflict, choice: :ours) # :theirs or :both
|
|
177
|
+
repo.resolve_conflict(conflict, choice: :manual, content: edited, mode: 0o100644)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`:ours` and `:theirs` also resolve a deleted side. `:both` performs a text
|
|
181
|
+
three-way merge and places ours before theirs in remaining regions. Binary and
|
|
182
|
+
symlink conflicts can be selected unchanged or replaced with `:manual`; they
|
|
183
|
+
cannot use `:both`. HEAD and the index are locked, and the captured worktree is
|
|
184
|
+
revalidated at each write boundary. A change observed there raises
|
|
185
|
+
`RefLockError` before the index or worktree is changed; uncoordinated writes
|
|
186
|
+
racing the final atomic replacement require external coordination. Other
|
|
187
|
+
unresolved paths remain in the index.
|
|
188
|
+
|
|
171
189
|
Create and update refs with optimistic old-OID checks:
|
|
172
190
|
|
|
173
191
|
```ruby
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
ConflictWorktree = Struct.new(:kind, :content, :mode, keyword_init: true)
|
|
5
|
+
Conflict = Struct.new(:path, :base, :ours, :theirs, :head, :worktree, :index_checksum, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
class Repository
|
|
8
|
+
CONFLICT_CHOICES = %i[ours theirs both manual].freeze
|
|
9
|
+
CONFLICT_FILE_MODES = [0o100644, 0o100755, 0o120000].freeze
|
|
10
|
+
|
|
11
|
+
def conflicts
|
|
12
|
+
current_index = index
|
|
13
|
+
checksum = current_index.send(:source_checksum)
|
|
14
|
+
current_head = head
|
|
15
|
+
current_index.conflicts.group_by(&:path).sort.map do |path, entries|
|
|
16
|
+
current_index.send(:validate_path, path)
|
|
17
|
+
all_entries = current_index.entries.select { |entry| entry.path == path }
|
|
18
|
+
stages = all_entries.to_h { |entry| [entry.stage, entry] }
|
|
19
|
+
raise CorruptObject, "invalid conflict entries for #{path}" unless
|
|
20
|
+
stages.length == all_entries.length && !stages.key?(0) && stages.keys.all? { |stage| (1..3).cover?(stage) }
|
|
21
|
+
|
|
22
|
+
Conflict.new(path: path.dup.freeze, base: conflict_entry(stages[1]), ours: conflict_entry(stages[2]),
|
|
23
|
+
theirs: conflict_entry(stages[3]), head: current_head&.dup&.freeze,
|
|
24
|
+
worktree: conflict_worktree(path), index_checksum: checksum.dup.freeze).freeze
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_conflict(conflict, choice:, content: nil, mode: nil)
|
|
29
|
+
raise ArgumentError, "expected a Thuban::Conflict" unless conflict.is_a?(Conflict)
|
|
30
|
+
raise ArgumentError, "invalid conflict choice" unless CONFLICT_CHOICES.include?(choice)
|
|
31
|
+
raise ArgumentError, "unsafe conflict worktree type" unless %i[missing file symlink].include?(conflict.worktree.kind)
|
|
32
|
+
|
|
33
|
+
current_index = index
|
|
34
|
+
current_index.send(:validate_path, conflict.path)
|
|
35
|
+
entries = current_index.entries.dup
|
|
36
|
+
worktree = {}
|
|
37
|
+
removed = []
|
|
38
|
+
validate = ->(locked_index, backups) { validate_conflict_snapshot!(conflict, locked_index, backups.fetch(conflict.path)) }
|
|
39
|
+
|
|
40
|
+
RefStore.new(self).send(:with_lock, "HEAD", old_oid: conflict.head) do
|
|
41
|
+
replace_repository_state(worktree, entries, remove_paths: removed, affected_paths: [conflict.path],
|
|
42
|
+
current_index: current_index, validate: validate) do
|
|
43
|
+
selected = conflict_resolution(conflict, choice, content, mode)
|
|
44
|
+
entries.reject! { |entry| entry.path == conflict.path }
|
|
45
|
+
if selected
|
|
46
|
+
entry, data = selected
|
|
47
|
+
entries << entry
|
|
48
|
+
worktree[conflict.path] = TreeEntry.new(path: conflict.path, oid: entry.oid, mode: entry.mode)
|
|
49
|
+
type, stored = odb.read(entry.oid)
|
|
50
|
+
raise CorruptObject, "conflict entry is not a blob" unless type == "blob" && stored == data
|
|
51
|
+
else
|
|
52
|
+
removed << conflict.path
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
conflict.path
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def conflict_entry(entry)
|
|
62
|
+
return unless entry
|
|
63
|
+
|
|
64
|
+
copy = entry.dup
|
|
65
|
+
copy.path = copy.path.dup.freeze
|
|
66
|
+
copy.oid = copy.oid.dup.freeze
|
|
67
|
+
copy.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def conflict_worktree(path)
|
|
71
|
+
absolute = worktree_path(path)
|
|
72
|
+
stat = File.lstat(absolute)
|
|
73
|
+
if stat.symlink?
|
|
74
|
+
ConflictWorktree.new(kind: :symlink, content: File.readlink(absolute).b.freeze, mode: 0o120000).freeze
|
|
75
|
+
elsif stat.file?
|
|
76
|
+
mode = 0o100000 | ((stat.mode & 0o100).positive? ? 0o755 : 0o644)
|
|
77
|
+
ConflictWorktree.new(kind: :file, content: File.binread(absolute).freeze, mode: mode).freeze
|
|
78
|
+
elsif stat.directory?
|
|
79
|
+
ConflictWorktree.new(kind: :directory, content: nil, mode: 0o040000).freeze
|
|
80
|
+
else
|
|
81
|
+
ConflictWorktree.new(kind: :other, content: nil, mode: stat.mode & 0o170000).freeze
|
|
82
|
+
end
|
|
83
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
84
|
+
ConflictWorktree.new(kind: :missing, content: nil, mode: nil).freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def conflict_resolution(conflict, choice, content, mode)
|
|
88
|
+
case choice
|
|
89
|
+
when :ours, :theirs
|
|
90
|
+
raise ArgumentError, "content and mode are only accepted for both or manual resolution" if content || mode
|
|
91
|
+
entry = conflict.public_send(choice)
|
|
92
|
+
entry && conflict_selected_entry(conflict.path, entry, read_conflict_blob(entry))
|
|
93
|
+
when :both
|
|
94
|
+
raise ArgumentError, "content is only accepted for manual resolution" if content
|
|
95
|
+
conflict_both_resolution(conflict, mode)
|
|
96
|
+
when :manual
|
|
97
|
+
raise ArgumentError, "manual resolution requires String content" unless content.is_a?(String)
|
|
98
|
+
selected_mode = conflict_resolution_mode(conflict, mode)
|
|
99
|
+
conflict_selected_entry(conflict.path, nil, content.b, mode: selected_mode)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def conflict_both_resolution(conflict, mode)
|
|
104
|
+
raise ArgumentError, "both resolution requires ours and theirs" unless conflict.ours && conflict.theirs
|
|
105
|
+
sides = [conflict.base, conflict.ours, conflict.theirs].compact
|
|
106
|
+
raise ArgumentError, "both resolution only supports regular files" unless
|
|
107
|
+
sides.all? { |entry| [0o100644, 0o100755].include?(entry.mode) }
|
|
108
|
+
|
|
109
|
+
base = conflict.base ? read_conflict_blob(conflict.base) : "".b
|
|
110
|
+
ours = read_conflict_blob(conflict.ours)
|
|
111
|
+
theirs = read_conflict_blob(conflict.theirs)
|
|
112
|
+
raise ArgumentError, "both resolution does not support binary files" if [base, ours, theirs].any? { |value| value.include?("\0") }
|
|
113
|
+
|
|
114
|
+
result = Porrima::Merge.three_way(base: base, ours: ours, theirs: theirs)
|
|
115
|
+
data = Porrima::Merge.to_resolved_text(Porrima::Merge.resolve_all(result, :ours_then_theirs))
|
|
116
|
+
conflict_selected_entry(conflict.path, nil, data, mode: conflict_resolution_mode(conflict, mode, regular: true))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def conflict_resolution_mode(conflict, mode, regular: false)
|
|
120
|
+
allowed = regular ? CONFLICT_FILE_MODES.first(2) : CONFLICT_FILE_MODES
|
|
121
|
+
if mode
|
|
122
|
+
raise ArgumentError, "invalid conflict resolution mode" unless allowed.include?(mode)
|
|
123
|
+
return mode
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
modes = [conflict.ours, conflict.theirs].compact.map(&:mode).uniq
|
|
127
|
+
raise ArgumentError, "conflict resolution mode is ambiguous" unless modes.length == 1 && allowed.include?(modes.first)
|
|
128
|
+
|
|
129
|
+
modes.first
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def conflict_selected_entry(path, source, content, mode: source&.mode)
|
|
133
|
+
raise ArgumentError, "submodule conflicts require separate worktree handling" unless CONFLICT_FILE_MODES.include?(mode)
|
|
134
|
+
|
|
135
|
+
oid = source&.oid || write_blob(content)
|
|
136
|
+
[Index::Entry.new(path: path, oid: oid, mode: mode, size: 0, mtime: 0, mtime_nsec: 0,
|
|
137
|
+
ctime: 0, ctime_nsec: 0, dev: 0, ino: 0, uid: 0, gid: 0, stage: 0, flags: 0, extended_flags: 0), content]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_conflict_blob(entry)
|
|
141
|
+
raise ArgumentError, "submodule conflicts require separate worktree handling" if entry.mode == 0o160000
|
|
142
|
+
type, content = odb.read(entry.oid)
|
|
143
|
+
raise CorruptObject, "conflict entry is not a blob" unless type == "blob"
|
|
144
|
+
|
|
145
|
+
content
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_conflict_snapshot!(conflict, current_index, worktree_backup)
|
|
149
|
+
expected = [conflict.base, conflict.ours, conflict.theirs].compact.map do |entry|
|
|
150
|
+
[entry.path, entry.stage, entry.mode, entry.oid]
|
|
151
|
+
end.sort_by { |entry| entry[1] }
|
|
152
|
+
actual = current_index.entries.select { |entry| entry.path == conflict.path }.map do |entry|
|
|
153
|
+
[entry.path, entry.stage, entry.mode, entry.oid]
|
|
154
|
+
end.sort_by { |entry| entry[1] }
|
|
155
|
+
stale = conflict.head != head || conflict.index_checksum != current_index.send(:source_checksum) ||
|
|
156
|
+
expected != actual || actual.empty? || actual.any? { |entry| entry[1].zero? } ||
|
|
157
|
+
conflict.worktree != conflict_worktree_backup(worktree_backup) ||
|
|
158
|
+
conflict.worktree != conflict_worktree(conflict.path)
|
|
159
|
+
raise RefLockError, "Git conflict changed since it was read" if stale
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def conflict_worktree_backup(backup)
|
|
163
|
+
case backup&.first
|
|
164
|
+
when :symlink
|
|
165
|
+
ConflictWorktree.new(kind: :symlink, content: backup[1].freeze, mode: 0o120000).freeze
|
|
166
|
+
when :file
|
|
167
|
+
mode = 0o100000 | ((backup[2] & 0o100).positive? ? 0o755 : 0o644)
|
|
168
|
+
ConflictWorktree.new(kind: :file, content: backup[1].freeze, mode: mode).freeze
|
|
169
|
+
else
|
|
170
|
+
ConflictWorktree.new(kind: :missing, content: nil, mode: nil).freeze
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -156,21 +156,14 @@ module Thuban
|
|
|
156
156
|
raise ArgumentError, "worktree/index must be clean" if status.any? { |entry| entry.index != "?" }
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
-
def replace_repository_state(worktree_tree, index_tree, remove_paths: []
|
|
159
|
+
def replace_repository_state(worktree_tree, index_tree, remove_paths: [], affected_paths: nil, current_index: nil,
|
|
160
|
+
validate: nil)
|
|
160
161
|
raise ArgumentError, "bare repository has no worktree" unless root
|
|
161
|
-
current_index
|
|
162
|
-
current = current_index.entries.
|
|
162
|
+
current_index ||= index
|
|
163
|
+
current = current_index.entries.group_by(&:path).transform_values { |entries| entries.find { |entry| entry.stage.zero? } || entries.first }
|
|
163
164
|
tracked = head ? tree(head).merge(current) : current
|
|
164
|
-
|
|
165
|
-
(tracked.values + worktree_tree.values + index_tree.values).any? { |entry| entry.mode == 0o160000 }
|
|
166
|
-
|
|
167
|
-
removed = (tracked.keys - worktree_tree.keys) | remove_paths
|
|
168
|
-
check_worktree_collisions(worktree_tree, tracked, removed)
|
|
169
|
-
contents = worktree_tree.to_h { |path, entry| [path, odb.read(entry.oid).last] }
|
|
170
|
-
affected = (tracked.keys | worktree_tree.keys | remove_paths)
|
|
171
|
-
backups = affected.to_h { |path| [path, worktree_backup(path)] }
|
|
165
|
+
affected = affected_paths || (tracked.keys | worktree_tree.keys | remove_paths)
|
|
172
166
|
index_path = File.join(git_dir, "index")
|
|
173
|
-
index_backup = File.file?(index_path) ? [File.binread(index_path), File.stat(index_path).mode & 0o777] : nil
|
|
174
167
|
lock_path = index_path + ".lock"
|
|
175
168
|
begin
|
|
176
169
|
lock = File.open(lock_path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
|
|
@@ -178,19 +171,38 @@ module Thuban
|
|
|
178
171
|
raise IOError, "Git index is locked: #{lock_path}"
|
|
179
172
|
end
|
|
180
173
|
owns_lock = true
|
|
174
|
+
changed = false
|
|
181
175
|
begin
|
|
182
176
|
raise RefLockError, "Git index changed since it was read" unless current_index.send(:source_unchanged?)
|
|
183
|
-
|
|
184
|
-
|
|
177
|
+
backups = affected.to_h { |path| [path, worktree_backup(path)] }
|
|
178
|
+
index_backup = File.file?(index_path) ? [File.binread(index_path), File.stat(index_path).mode & 0o777] : nil
|
|
179
|
+
validate&.call(current_index, backups)
|
|
180
|
+
yield current_index if block_given?
|
|
181
|
+
|
|
182
|
+
raise ArgumentError, "repository state update escaped its affected paths" unless
|
|
183
|
+
(worktree_tree.keys | remove_paths).all? { |path| affected.include?(path) }
|
|
184
|
+
candidates = current_index.entries + worktree_tree.values + index_target_entries(index_tree)
|
|
185
|
+
candidates.select! { |entry| affected.include?(entry.path) } if affected_paths
|
|
186
|
+
raise ArgumentError, "submodule updates require separate worktree handling" if candidates.any? { |entry| entry.mode == 0o160000 }
|
|
187
|
+
|
|
188
|
+
removed = affected_paths ? ((affected - worktree_tree.keys) | remove_paths) : ((tracked.keys - worktree_tree.keys) | remove_paths)
|
|
189
|
+
check_worktree_collisions(worktree_tree, tracked, removed)
|
|
190
|
+
contents = worktree_tree.to_h { |path, entry| [path, odb.read(entry.oid).last] }
|
|
191
|
+
validate&.call(current_index, backups)
|
|
192
|
+
write_worktree_tree(worktree_tree, contents, removed) do
|
|
193
|
+
validate&.call(current_index, backups)
|
|
194
|
+
changed = true
|
|
195
|
+
end
|
|
185
196
|
extensions = current_index.extensions.reject { |extension| Index::ENTRY_DEPENDENT_EXTENSIONS.include?(extension.byteslice(0, 4)) }
|
|
186
|
-
|
|
197
|
+
entries = index_tree.is_a?(Array) ? refresh_index_entries(index_tree, worktree_tree, affected) : index_entries(index_tree)
|
|
198
|
+
lock.write(Index.encode(entries, extensions: extensions, version: current_index.version))
|
|
187
199
|
lock.flush
|
|
188
200
|
lock.fsync
|
|
189
201
|
lock.close
|
|
190
202
|
File.rename(lock_path, index_path)
|
|
191
203
|
owns_lock = false
|
|
192
204
|
rescue StandardError
|
|
193
|
-
restore_worktree(backups, affected)
|
|
205
|
+
restore_worktree(backups, affected) if changed
|
|
194
206
|
raise
|
|
195
207
|
ensure
|
|
196
208
|
lock&.close unless lock&.closed?
|
|
@@ -199,13 +211,33 @@ module Thuban
|
|
|
199
211
|
-> { restore_repository_state(backups, affected, index_path, index_backup) }
|
|
200
212
|
end
|
|
201
213
|
|
|
214
|
+
def index_target_entries(target)
|
|
215
|
+
target.is_a?(Array) ? target : target.values
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def refresh_index_entries(entries, worktree_tree, affected)
|
|
219
|
+
entries.map do |entry|
|
|
220
|
+
target = worktree_tree[entry.path]
|
|
221
|
+
next entry unless entry.stage.zero? && target && affected.include?(entry.path)
|
|
222
|
+
|
|
223
|
+
stat = matching_worktree_stat(entry.path, target)
|
|
224
|
+
Index::Entry.new(path: entry.path, oid: entry.oid, mode: entry.mode, size: stat&.size.to_i,
|
|
225
|
+
mtime: stat&.mtime.to_i, mtime_nsec: stat&.mtime&.nsec.to_i,
|
|
226
|
+
ctime: stat&.ctime.to_i, ctime_nsec: stat&.ctime&.nsec.to_i,
|
|
227
|
+
dev: stat&.dev.to_i, ino: stat&.ino.to_i, uid: stat&.uid.to_i, gid: stat&.gid.to_i,
|
|
228
|
+
stage: 0, flags: 0, extended_flags: 0)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
202
232
|
def write_worktree_tree(target, contents, removed)
|
|
203
233
|
removed.sort_by { |path| -path.count("/") }.each do |path|
|
|
234
|
+
yield if block_given?
|
|
204
235
|
absolute = worktree_path(path)
|
|
205
236
|
File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
|
|
206
237
|
end
|
|
207
238
|
prune_empty_directories(removed)
|
|
208
239
|
target.sort.each do |path, entry|
|
|
240
|
+
yield if block_given?
|
|
209
241
|
absolute = worktree_path(path)
|
|
210
242
|
Dir.rmdir(absolute) if File.directory?(absolute) && !File.symlink?(absolute)
|
|
211
243
|
FileUtils.mkdir_p(File.dirname(absolute))
|
data/lib/thuban/index.rb
CHANGED
|
@@ -76,6 +76,8 @@ module Thuban
|
|
|
76
76
|
|
|
77
77
|
private
|
|
78
78
|
|
|
79
|
+
def source_checksum = @source_checksum
|
|
80
|
+
|
|
79
81
|
def parse(bytes)
|
|
80
82
|
raise CorruptObject, "invalid Git index" unless bytes.bytesize >= 32 && bytes.start_with?("DIRC")
|
|
81
83
|
raise CorruptObject, "Git index checksum mismatch" unless Digest::SHA1.digest(bytes[0...-20]) == bytes[-20, 20]
|
data/lib/thuban/ref_store.rb
CHANGED
|
@@ -89,6 +89,18 @@ module Thuban
|
|
|
89
89
|
|
|
90
90
|
attr_reader :repository
|
|
91
91
|
|
|
92
|
+
def with_lock(name, old_oid: :any)
|
|
93
|
+
validate_ref(name)
|
|
94
|
+
target = dereference(name)
|
|
95
|
+
paths = [ref_path(target)]
|
|
96
|
+
paths << ref_path(name) if target != name
|
|
97
|
+
with_locks(paths) do
|
|
98
|
+
raise RefLockError, "symbolic reference changed: #{name}" unless dereference(name) == target
|
|
99
|
+
verify_old_oid(name, repository.resolve(target), old_oid)
|
|
100
|
+
yield
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
92
104
|
def validate_ref(name, head: true)
|
|
93
105
|
return if head && name == "HEAD"
|
|
94
106
|
invalid = !name.is_a?(String) || !name.start_with?("refs/") || name.end_with?("/", ".") ||
|
data/lib/thuban/status.rb
CHANGED
|
@@ -29,6 +29,7 @@ module Thuban
|
|
|
29
29
|
original = head[path]
|
|
30
30
|
x = if !original then "A"
|
|
31
31
|
elsif !staged then "D"
|
|
32
|
+
elsif (original.mode & 0o170000) != (staged.mode & 0o170000) then "T"
|
|
32
33
|
elsif original.mode != staged.mode || original.oid != staged.oid then "M"
|
|
33
34
|
else " " end
|
|
34
35
|
y = staged ? worktree_status(staged, index.path, filemode) : " "
|
data/lib/thuban/version.rb
CHANGED
data/lib/thuban.rb
CHANGED
|
@@ -23,6 +23,7 @@ require_relative "thuban/ref_lock_error"
|
|
|
23
23
|
require_relative "thuban/ref_store"
|
|
24
24
|
require_relative "thuban/commit_writer"
|
|
25
25
|
require_relative "thuban/history_writer"
|
|
26
|
+
require_relative "thuban/conflict_resolution"
|
|
26
27
|
require_relative "thuban/stash"
|
|
27
28
|
require_relative "thuban/pack_writer"
|
|
28
29
|
require_relative "thuban/pack_reader"
|
data/sig/thuban.rbs
CHANGED
|
@@ -98,6 +98,24 @@ module Thuban
|
|
|
98
98
|
def self.new: (path: String, oid: String, mode: Integer) -> instance
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
class ConflictWorktree < Struct[untyped]
|
|
102
|
+
attr_accessor kind: :missing | :file | :symlink | :directory | :other
|
|
103
|
+
attr_accessor content: String?
|
|
104
|
+
attr_accessor mode: Integer?
|
|
105
|
+
def self.new: (kind: (:missing | :file | :symlink | :directory | :other), content: String?, mode: Integer?) -> instance
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class Conflict < Struct[untyped]
|
|
109
|
+
attr_accessor path: String
|
|
110
|
+
attr_accessor base: Index::Entry?
|
|
111
|
+
attr_accessor ours: Index::Entry?
|
|
112
|
+
attr_accessor theirs: Index::Entry?
|
|
113
|
+
attr_accessor head: String?
|
|
114
|
+
attr_accessor worktree: ConflictWorktree
|
|
115
|
+
attr_accessor index_checksum: String?
|
|
116
|
+
def self.new: (path: String, base: Index::Entry?, ours: Index::Entry?, theirs: Index::Entry?, head: String?, worktree: ConflictWorktree, index_checksum: String?) -> instance
|
|
117
|
+
end
|
|
118
|
+
|
|
101
119
|
class Signature < Struct[untyped]
|
|
102
120
|
attr_accessor name: String
|
|
103
121
|
attr_accessor email: String
|
|
@@ -162,6 +180,8 @@ module Thuban
|
|
|
162
180
|
def reset: (String oid, ?mode: :soft | :mixed | :hard) -> String
|
|
163
181
|
def cherry_pick: (String oid) -> String
|
|
164
182
|
def revert: (String oid) -> String
|
|
183
|
+
def conflicts: () -> Array[Conflict]
|
|
184
|
+
def resolve_conflict: (Conflict conflict, choice: (:ours | :theirs | :both | :manual), ?content: String?, ?mode: Integer?) -> String
|
|
165
185
|
def stash_push: (?message: String?, ?include_untracked: bool) -> String?
|
|
166
186
|
def stash_pop: (?Integer index) -> String
|
|
167
187
|
def stash_list: () -> Array[Commit]
|
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
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -36,6 +36,7 @@ files:
|
|
|
36
36
|
- lib/thuban/blame.rb
|
|
37
37
|
- lib/thuban/commit.rb
|
|
38
38
|
- lib/thuban/commit_writer.rb
|
|
39
|
+
- lib/thuban/conflict_resolution.rb
|
|
39
40
|
- lib/thuban/corrupt_object.rb
|
|
40
41
|
- lib/thuban/history_writer.rb
|
|
41
42
|
- lib/thuban/ignore_matcher.rb
|