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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73f95adb20a42969a69908bab030d4939a94a1bd0bc9ddcacbb9b95dff3dd6a9
4
- data.tar.gz: 8ebf2299e31de698cfae6b7487a7722703f50d4fd5a0820adf6e6c35d1eb2616
3
+ metadata.gz: a397fe928169662180d71c9c3c9df55eabd167eca7b300d01aaf4db57a5b5d16
4
+ data.tar.gz: 6cc6e1ed699ada02e46bf3623ca03dd73012ef9163b7b7a270889cbd1f817dcd
5
5
  SHA512:
6
- metadata.gz: 97e8fe6cbbfa497234aafc55a4c49f0d9775721451c2e32a1882d4d15066d079cf1544ef41c48bff61230297ad7191b64da8de5b2a63636a6224516037d9b9da
7
- data.tar.gz: 889372feb5805cdfb126a1f9668700abda26307b6617be36556e917050c90270aff58e9e193dc3bad832d9be2aa15f41b885466801c3f8f5bed27de38588945c
6
+ metadata.gz: 006ee4c48ee212ce1e70df30691e743bc0e1f798789d53a1afdc64ad643eba50a85a32f2e0c36e2fb043a3b7680225ee9b140153ef7374e636d1b96b81c1756a
7
+ data.tar.gz: 216cf9908627ff5655820f6d85b9499921aa89424bc5461df35c8d100392d9206c613f1b179850ce5a887d69839d32ce2fc7a481e7324ed5498737eb5323c31e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.0 - 2026-09-17
4
+
5
+ - Add transactional repository-level merge conflict resolution with stale-state checks and Git-compatible type status.
4
6
 
5
7
  ## 0.5.0 - 2026-09-17
6
8
 
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.5.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 = index
162
- current = current_index.entries.to_h { |entry| [entry.path, entry] }
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
- raise ArgumentError, "submodule updates require separate worktree handling" if
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
- write_worktree_tree(worktree_tree, contents, removed)
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
- lock.write(Index.encode(index_entries(index_tree), extensions: extensions, version: current_index.version))
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]
@@ -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) : " "
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Thuban
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
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.5.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