revisable 0.1.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.
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Revisable
4
+ class Repository
5
+ attr_reader :versionable, :versionable_fields
6
+
7
+ def initialize(versionable:, fields:)
8
+ @versionable = versionable
9
+ @versionable_fields = fields.map(&:to_sym)
10
+ end
11
+
12
+ # --- Commits ---
13
+
14
+ def commit!(branch: "main", author: nil, message:, fields:, parent_shas: nil)
15
+ timestamp = Time.current
16
+
17
+ # Resolve parent(s)
18
+ if parent_shas.nil?
19
+ ref = find_ref(branch, type: "branch") if branch
20
+ parent_commit = ref&.commit
21
+ parent_shas = parent_commit ? [parent_commit.sha] : []
22
+ end
23
+
24
+ # Build full field set: start from parent, overlay changes
25
+ parent_field_set = parent_shas.any? ? Commit.find_by!(sha: parent_shas.first).field_set : FieldSet.empty
26
+ blob_shas = {}
27
+
28
+ versionable_fields.each do |field|
29
+ if fields.key?(field)
30
+ blob = Blob.store(fields[field])
31
+ blob_shas[field] = blob.sha
32
+ elsif parent_field_set[field]
33
+ blob_shas[field] = parent_field_set.sha_for(field)
34
+ else
35
+ blob = Blob.store("")
36
+ blob_shas[field] = blob.sha
37
+ end
38
+ end
39
+
40
+ # Compute commit SHA
41
+ sha = Commit.build_sha(
42
+ parent_shas: parent_shas,
43
+ field_blobs: blob_shas,
44
+ message: message,
45
+ timestamp: timestamp
46
+ )
47
+
48
+ # Create commit
49
+ commit = Commit.create!(
50
+ sha: sha,
51
+ versionable: versionable,
52
+ author: author,
53
+ message: message,
54
+ committed_at: timestamp
55
+ )
56
+
57
+ # Link parents
58
+ parent_shas.each_with_index do |parent_sha, i|
59
+ CommitParent.create!(commit_sha: sha, parent_sha: parent_sha, position: i)
60
+ end
61
+
62
+ # Create commit fields
63
+ blob_shas.each do |field, blob_sha|
64
+ CommitField.create!(commit_sha: sha, field_name: field.to_s, blob_sha: blob_sha)
65
+ end
66
+
67
+ # Advance branch ref
68
+ if branch
69
+ ref = find_ref(branch, type: "branch")
70
+ if ref
71
+ ref.advance!(sha)
72
+ else
73
+ Ref.create!(
74
+ versionable: versionable,
75
+ name: branch,
76
+ ref_type: "branch",
77
+ commit_sha: sha
78
+ )
79
+ end
80
+ end
81
+
82
+ commit
83
+ end
84
+
85
+ # --- Branches ---
86
+
87
+ def branch!(name, from: "main")
88
+ source_ref = find_ref!(from)
89
+ ref = Ref.create!(
90
+ versionable: versionable,
91
+ name: name,
92
+ ref_type: "branch",
93
+ commit_sha: source_ref.commit_sha
94
+ )
95
+ Branch.new(ref)
96
+ end
97
+
98
+ def branches
99
+ refs_scope.branches.map { |ref| Branch.new(ref) }
100
+ end
101
+
102
+ # --- Tags ---
103
+
104
+ def tag!(name, ref: "main", message: nil)
105
+ source = find_ref!(ref)
106
+ ref = Ref.create!(
107
+ versionable: versionable,
108
+ name: name,
109
+ ref_type: "tag",
110
+ commit_sha: source.commit_sha,
111
+ message: message
112
+ )
113
+ Tag.new(ref)
114
+ end
115
+
116
+ def tags
117
+ refs_scope.tags.map { |ref| Tag.new(ref) }
118
+ end
119
+
120
+ # --- Reading ---
121
+
122
+ def at(ref_or_sha)
123
+ commit = resolve_commit(ref_or_sha)
124
+ Snapshot.new(commit: commit, fields: versionable_fields)
125
+ end
126
+
127
+ def log(ref = "main", limit: nil)
128
+ commit = resolve_commit(ref)
129
+ commits = walk_history(commit, limit: limit)
130
+ Log.new(commits)
131
+ end
132
+
133
+ # --- Diffing ---
134
+
135
+ def diff(ref_a, ref_b)
136
+ commit_a = resolve_commit(ref_a)
137
+ commit_b = resolve_commit(ref_b)
138
+
139
+ Diff.new(
140
+ commit_a: commit_a,
141
+ commit_b: commit_b,
142
+ versionable_fields: versionable_fields
143
+ )
144
+ end
145
+
146
+ # --- Merging ---
147
+
148
+ def merge(from_ref, into: "main")
149
+ into_commit = resolve_commit(into)
150
+ from_commit = resolve_commit(from_ref)
151
+ ancestor_commit = find_common_ancestor(into_commit, from_commit)
152
+
153
+ Merge.new(
154
+ into_commit: into_commit,
155
+ from_commit: from_commit,
156
+ ancestor_commit: ancestor_commit,
157
+ into: into,
158
+ from: from_ref,
159
+ versionable_fields: versionable_fields
160
+ )
161
+ end
162
+
163
+ # --- Publishing ---
164
+
165
+ def publish!(ref = nil)
166
+ ref ||= refs_scope.tags.order(created_at: :desc).first&.name
167
+ raise RefNotFoundError, "No tags found" unless ref
168
+
169
+ snapshot = at(ref)
170
+ attrs = snapshot.to_h
171
+ versionable.update!(attrs)
172
+ end
173
+
174
+ private
175
+
176
+ def refs_scope
177
+ Ref.where(versionable_type: versionable.class.name, versionable_id: versionable.id)
178
+ end
179
+
180
+ def commits_scope
181
+ Commit.where(versionable_type: versionable.class.name, versionable_id: versionable.id)
182
+ end
183
+
184
+ def find_ref(name, type: nil)
185
+ scope = refs_scope.where(name: name)
186
+ scope = scope.where(ref_type: type) if type
187
+ scope.first
188
+ end
189
+
190
+ def find_ref!(name, type: nil)
191
+ find_ref(name, type: type) || raise(RefNotFoundError, "Ref '#{name}' not found")
192
+ end
193
+
194
+ def resolve_commit(ref_or_sha)
195
+ # Try as ref first (branch or tag)
196
+ ref = find_ref(ref_or_sha)
197
+ return ref.commit if ref
198
+
199
+ # Try as SHA
200
+ commit = commits_scope.find_by(sha: ref_or_sha)
201
+ return commit if commit
202
+
203
+ # Try as SHA prefix
204
+ commit = commits_scope.where("sha LIKE ?", "#{ref_or_sha}%").first
205
+ return commit if commit
206
+
207
+ raise RefNotFoundError, "Could not resolve '#{ref_or_sha}'"
208
+ end
209
+
210
+ def walk_history(commit, limit: nil)
211
+ result = []
212
+ queue = [commit]
213
+ visited = Set.new
214
+
215
+ while queue.any? && (limit.nil? || result.size < limit)
216
+ current = queue.shift
217
+ next if visited.include?(current.sha)
218
+ visited.add(current.sha)
219
+
220
+ result << current
221
+
222
+ current.parents.order(committed_at: :desc).each do |parent|
223
+ queue << parent unless visited.include?(parent.sha)
224
+ end
225
+ end
226
+
227
+ result
228
+ end
229
+
230
+ def find_common_ancestor(commit_a, commit_b)
231
+ ancestors_a = collect_ancestors(commit_a)
232
+ ancestors_b = collect_ancestors(commit_b)
233
+ common = ancestors_a & ancestors_b
234
+ return nil if common.empty?
235
+
236
+ # Return the most recent common ancestor
237
+ commits_scope.where(sha: common.to_a).order(committed_at: :desc).first
238
+ end
239
+
240
+ def collect_ancestors(commit)
241
+ ancestors = Set.new
242
+ queue = [commit]
243
+
244
+ while queue.any?
245
+ current = queue.shift
246
+ next if ancestors.include?(current.sha)
247
+ ancestors.add(current.sha)
248
+ current.parents.each { |p| queue << p }
249
+ end
250
+
251
+ ancestors
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Revisable
4
+ class Snapshot
5
+ attr_reader :commit, :fields
6
+
7
+ def initialize(commit:, fields:)
8
+ @commit = commit
9
+ @fields = fields
10
+ @field_set = commit.field_set
11
+ end
12
+
13
+ def [](field_name)
14
+ @field_set.data_for(field_name)
15
+ end
16
+
17
+ def to_h
18
+ fields.each_with_object({}) do |field, hash|
19
+ hash[field.to_sym] = self[field]
20
+ end
21
+ end
22
+
23
+ def respond_to_missing?(method, include_private = false)
24
+ fields.map(&:to_sym).include?(method.to_sym) || super
25
+ end
26
+
27
+ def method_missing(method, *args)
28
+ if fields.map(&:to_sym).include?(method.to_sym)
29
+ self[method]
30
+ else
31
+ super
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Revisable
4
+ class Tag
5
+ attr_reader :ref
6
+
7
+ def initialize(ref)
8
+ @ref = ref
9
+ end
10
+
11
+ def name
12
+ ref.name
13
+ end
14
+
15
+ def message
16
+ ref.message
17
+ end
18
+
19
+ def commit
20
+ ref.commit
21
+ end
22
+
23
+ def commit_sha
24
+ ref.commit_sha
25
+ end
26
+
27
+ def snapshot(fields:)
28
+ Snapshot.new(commit: commit, fields: fields)
29
+ end
30
+
31
+ def to_s
32
+ name
33
+ end
34
+
35
+ def ==(other)
36
+ other.is_a?(Tag) && name == other.name && commit_sha == other.commit_sha
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Revisable
4
+ VERSION = "0.1.0"
5
+ end
data/lib/revisable.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support"
5
+ require "diff/lcs"
6
+
7
+ require_relative "revisable/version"
8
+
9
+ # Core (no AR dependency in these objects)
10
+ require_relative "revisable/field_set"
11
+ require_relative "revisable/snapshot"
12
+ require_relative "revisable/renderers/text_renderer"
13
+ require_relative "revisable/renderers/html_renderer"
14
+ require_relative "revisable/field_diff"
15
+ require_relative "revisable/diff"
16
+ require_relative "revisable/log"
17
+ require_relative "revisable/merge_field"
18
+ require_relative "revisable/merge"
19
+ require_relative "revisable/current_author"
20
+
21
+ # ActiveRecord models
22
+ require_relative "revisable/blob"
23
+ require_relative "revisable/commit"
24
+ require_relative "revisable/commit_field"
25
+ require_relative "revisable/ref"
26
+ require_relative "revisable/branch"
27
+ require_relative "revisable/tag"
28
+
29
+ # ActiveRecord integration
30
+ require_relative "revisable/repository"
31
+ require_relative "revisable/model"
32
+ require_relative "revisable/active_record/controller_helpers"
33
+
34
+ module Revisable
35
+ class Error < StandardError; end
36
+ class ConflictError < Error; end
37
+ class RefNotFoundError < Error; end
38
+ class UnresolvedConflictsError < Error; end
39
+ class StaleRefError < Error; end
40
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: revisable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brad Gessler
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-03-22 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: diff-lcs
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.5'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.5'
54
+ description: Content-addressed versioning with branches, merges, diffs, and tags for
55
+ ActiveRecord models. Like git, but in your database.
56
+ email:
57
+ - bradgessler@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE.txt
63
+ - README.md
64
+ - lib/generators/revisable/install_generator.rb
65
+ - lib/generators/revisable/templates/create_revisable_tables.rb.erb
66
+ - lib/revisable.rb
67
+ - lib/revisable/active_record/controller_helpers.rb
68
+ - lib/revisable/blob.rb
69
+ - lib/revisable/branch.rb
70
+ - lib/revisable/commit.rb
71
+ - lib/revisable/commit_field.rb
72
+ - lib/revisable/current_author.rb
73
+ - lib/revisable/diff.rb
74
+ - lib/revisable/field_diff.rb
75
+ - lib/revisable/field_set.rb
76
+ - lib/revisable/log.rb
77
+ - lib/revisable/merge.rb
78
+ - lib/revisable/merge_field.rb
79
+ - lib/revisable/model.rb
80
+ - lib/revisable/ref.rb
81
+ - lib/revisable/renderers/html_renderer.rb
82
+ - lib/revisable/renderers/text_renderer.rb
83
+ - lib/revisable/repository.rb
84
+ - lib/revisable/snapshot.rb
85
+ - lib/revisable/tag.rb
86
+ - lib/revisable/version.rb
87
+ homepage: https://github.com/rubymonolith/revisable
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ homepage_uri: https://github.com/rubymonolith/revisable
92
+ source_code_uri: https://github.com/rubymonolith/revisable
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.2.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.6.2
108
+ specification_version: 4
109
+ summary: Git-like versioning for ActiveRecord text content
110
+ test_files: []