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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +309 -0
- data/lib/generators/revisable/install_generator.rb +16 -0
- data/lib/generators/revisable/templates/create_revisable_tables.rb.erb +57 -0
- data/lib/revisable/active_record/controller_helpers.rb +28 -0
- data/lib/revisable/blob.rb +27 -0
- data/lib/revisable/branch.rb +35 -0
- data/lib/revisable/commit.rb +75 -0
- data/lib/revisable/commit_field.rb +19 -0
- data/lib/revisable/current_author.rb +25 -0
- data/lib/revisable/diff.rb +62 -0
- data/lib/revisable/field_diff.rb +51 -0
- data/lib/revisable/field_set.rb +53 -0
- data/lib/revisable/log.rb +41 -0
- data/lib/revisable/merge.rb +123 -0
- data/lib/revisable/merge_field.rb +59 -0
- data/lib/revisable/model.rb +71 -0
- data/lib/revisable/ref.rb +39 -0
- data/lib/revisable/renderers/html_renderer.rb +46 -0
- data/lib/revisable/renderers/text_renderer.rb +37 -0
- data/lib/revisable/repository.rb +254 -0
- data/lib/revisable/snapshot.rb +35 -0
- data/lib/revisable/tag.rb +39 -0
- data/lib/revisable/version.rb +5 -0
- data/lib/revisable.rb +40 -0
- metadata +110 -0
|
@@ -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
|
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: []
|