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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4a689bd4a025ca4964e233c27e82610dac766a357638cd238881a8aa3df70ab9
|
|
4
|
+
data.tar.gz: e699b99e853085603d3bf24a3afb2c70fa86202adc91b5ec5457aecd28f95ebe
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b99e4b430666ee6f134c2faea2954ea3470572a82142dcc51c438abf19215d8883a1eb6391948deeaa559df747d37bcadd5020fa05679ac3f46a70f98aa42305
|
|
7
|
+
data.tar.gz: 7acc9f61e70b786d074626b1911dd4925cd603f3217ae5a16d6881a057d1b01cb3e066bf9bc98c893e9193de2140331b0f520ee26c6cf6e545d44f1697eadfc6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brad Gessler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# Revisable
|
|
2
|
+
|
|
3
|
+
Git-like versioning for ActiveRecord text content. Branches, merges, diffs, tags, and publishing — in your database.
|
|
4
|
+
|
|
5
|
+
## Why not PaperTrail / Audited / Logidze?
|
|
6
|
+
|
|
7
|
+
Every existing Rails versioning gem gives you a **linear audit log**. That's fine for tracking "who changed what when," but it falls apart when you need real content workflows:
|
|
8
|
+
|
|
9
|
+
| | PaperTrail | Audited | Logidze | **Revisable** |
|
|
10
|
+
|---|---|---|---|---|
|
|
11
|
+
| Branching | No | No | No | **Yes** |
|
|
12
|
+
| Merging | No | No | No | **Yes** |
|
|
13
|
+
| Diffing | Adjacent only | No | Between versions | **Any two refs** |
|
|
14
|
+
| Tags / named versions | No | No | No | **Yes** |
|
|
15
|
+
| Content deduplication | No | No | No | **SHA-based** |
|
|
16
|
+
| Publish workflow | No | No | No | **Tag → publish** |
|
|
17
|
+
| Storage model | Full snapshots | After-state | Incremental JSON | **Content-addressed blobs** |
|
|
18
|
+
|
|
19
|
+
PaperTrail and friends answer "what happened?" Revisable answers "what's the state of this content, on this branch, and how does it differ from that branch?"
|
|
20
|
+
|
|
21
|
+
### When to use Revisable
|
|
22
|
+
|
|
23
|
+
You're building a CMS, docs site, legal document system, or any app where **content goes through drafts, review, and publishing**. Multiple people might edit the same article. You want to see diffs, branch off drafts, and tag releases.
|
|
24
|
+
|
|
25
|
+
### When to use PaperTrail
|
|
26
|
+
|
|
27
|
+
You want an audit log for compliance. You need to know that `user_42` changed `price` from `19.99` to `24.99` at 3:47pm. Linear history is fine. You don't need branches or diffs.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Add to your Gemfile:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem "revisable"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Generate the migration:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
rails generate revisable:install
|
|
41
|
+
rails db:migrate
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class Post < ApplicationRecord
|
|
48
|
+
include Revisable::Model
|
|
49
|
+
revisable :title, :body
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That's it. Any text/string columns you pass to `revisable` get git-like version control.
|
|
54
|
+
|
|
55
|
+
## ActiveRecord Integration
|
|
56
|
+
|
|
57
|
+
### Auto-commit on save
|
|
58
|
+
|
|
59
|
+
Revisable hooks into `after_create` and `after_update`. When you save a model with revisable fields, a commit is created automatically:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
post = Post.create!(title: "First Draft", body: "Hello world")
|
|
63
|
+
# => auto-creates initial commit on "main"
|
|
64
|
+
|
|
65
|
+
post.update!(title: "Better Title")
|
|
66
|
+
# => auto-creates commit "Updated title" on "main"
|
|
67
|
+
|
|
68
|
+
post.repository.log("main").map(&:message)
|
|
69
|
+
# => ["Updated title", "Initial version"]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To disable auto-commit (for models where you want manual control):
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class LegalDocument < ApplicationRecord
|
|
76
|
+
include Revisable::Model
|
|
77
|
+
revisable :content, :summary, auto_commit: false
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Author tracking
|
|
82
|
+
|
|
83
|
+
Set the current author in your controller so auto-commits know who made the change:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class ApplicationController < ActionController::Base
|
|
87
|
+
include Revisable::ActiveRecord::ControllerHelpers
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def revisable_author
|
|
92
|
+
current_user
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Now every save within a request automatically records the author:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
post.update!(title: "New Title")
|
|
101
|
+
post.repository.log("main").first.author # => #<User id: 1, name: "Alice">
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
You can also set the author explicitly for background jobs or console work:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
Revisable::CurrentAuthor.with(admin_user) do
|
|
108
|
+
post.update!(body: "Bulk-edited content")
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Usage
|
|
113
|
+
|
|
114
|
+
### Manual commits
|
|
115
|
+
|
|
116
|
+
For more control, use the repository directly. Pass only the fields that changed — unchanged fields carry forward automatically.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
repo = post.repository
|
|
120
|
+
|
|
121
|
+
repo.commit!(
|
|
122
|
+
message: "First draft",
|
|
123
|
+
author: current_user,
|
|
124
|
+
fields: { title: "How to Deploy Rails", body: "# Step 1\n..." }
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Later, update just the body
|
|
128
|
+
repo.commit!(
|
|
129
|
+
message: "Added step 2",
|
|
130
|
+
author: current_user,
|
|
131
|
+
fields: { body: "# Step 1\n...\n# Step 2\n..." }
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Content is stored as SHA-256 addressed blobs. If you revert a title to a previous value, no new storage is used — it points to the existing blob.
|
|
136
|
+
|
|
137
|
+
### Reading
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# Read current state of any branch or tag
|
|
141
|
+
snapshot = repo.at("main")
|
|
142
|
+
snapshot.title # => "How to Deploy Rails"
|
|
143
|
+
snapshot.body # => "# Step 1\n..."
|
|
144
|
+
snapshot.to_h # => { title: "...", body: "..." }
|
|
145
|
+
|
|
146
|
+
# Read by tag or SHA
|
|
147
|
+
repo.at("v2").title
|
|
148
|
+
repo.at("abc123").title
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Branching
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
repo.branch!("amy-draft", from: "main")
|
|
155
|
+
|
|
156
|
+
repo.commit!(
|
|
157
|
+
branch: "amy-draft",
|
|
158
|
+
message: "Rewrote intro",
|
|
159
|
+
author: amy,
|
|
160
|
+
fields: { body: "# Better intro\n..." }
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# main is untouched
|
|
164
|
+
repo.at("main").body # => original
|
|
165
|
+
repo.at("amy-draft").body # => rewritten
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Diffing
|
|
169
|
+
|
|
170
|
+
Compare any two refs, commits, or tags. Diffs are field-level — blob SHAs are compared first, so unchanged fields skip text diffing entirely.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
diff = repo.diff("main", "amy-draft")
|
|
174
|
+
|
|
175
|
+
diff.changed? # => true
|
|
176
|
+
diff.changed_fields # => [:body]
|
|
177
|
+
|
|
178
|
+
diff.field(:title).status # => :unchanged
|
|
179
|
+
diff.field(:body).status # => :modified
|
|
180
|
+
|
|
181
|
+
# Unified text diff (like `git diff`)
|
|
182
|
+
puts diff.field(:body).to_text
|
|
183
|
+
# --- a/body
|
|
184
|
+
# +++ b/body
|
|
185
|
+
# -# Step 1
|
|
186
|
+
# +# Better intro
|
|
187
|
+
|
|
188
|
+
# HTML diff (for web UIs)
|
|
189
|
+
diff.field(:body).to_html
|
|
190
|
+
# <div class="revisable-diff" data-field="body">
|
|
191
|
+
# <del># Step 1</del>
|
|
192
|
+
# <ins># Better intro</ins>
|
|
193
|
+
# </div>
|
|
194
|
+
|
|
195
|
+
# Combined diff across all fields
|
|
196
|
+
diff.to_text
|
|
197
|
+
diff.to_html
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Merging
|
|
201
|
+
|
|
202
|
+
Three-way merge using the common ancestor. Fields that only changed on one side auto-resolve. Fields changed on both sides are conflicts.
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
merge = repo.merge("amy-draft", into: "main")
|
|
206
|
+
|
|
207
|
+
merge.clean? # => false
|
|
208
|
+
merge.conflicts # => [:body]
|
|
209
|
+
merge.auto_resolved # => [:title]
|
|
210
|
+
|
|
211
|
+
# Inspect a conflict — each field is a MergeField object
|
|
212
|
+
field = merge.field(:body)
|
|
213
|
+
field.status # => :conflicted
|
|
214
|
+
field.ours # => main's version
|
|
215
|
+
field.theirs # => amy-draft's version
|
|
216
|
+
field.ancestor # => common ancestor
|
|
217
|
+
|
|
218
|
+
# Resolve it (on the field directly, or via the merge)
|
|
219
|
+
field.resolve("hand-written resolution")
|
|
220
|
+
field.resolve(:ours) # pick main's version
|
|
221
|
+
field.resolve(:theirs) # pick amy's version
|
|
222
|
+
|
|
223
|
+
# Or resolve all conflicts at once
|
|
224
|
+
merge.each do |name, f|
|
|
225
|
+
f.resolve(:theirs) if f.conflicted?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Commit the merge (creates a two-parent commit)
|
|
229
|
+
merge.commit!(repository: repo, author: current_user, message: "Merged Amy's draft")
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Tags and publishing
|
|
233
|
+
|
|
234
|
+
Tags are immutable pointers to a commit. Publishing materializes a tag's content into the model's actual columns — so your views and APIs read plain ActiveRecord attributes with zero versioning overhead.
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
# Tag the current state of main
|
|
238
|
+
repo.tag!("v1", ref: "main", message: "Launch version")
|
|
239
|
+
|
|
240
|
+
# More editing happens on main...
|
|
241
|
+
repo.commit!(message: "Post-launch tweaks", fields: { body: "..." })
|
|
242
|
+
|
|
243
|
+
# Readers still see v1 until you publish
|
|
244
|
+
repo.publish!("v1")
|
|
245
|
+
|
|
246
|
+
# post.title and post.body now reflect v1
|
|
247
|
+
# Your views just do: @post.title — no versioning API needed
|
|
248
|
+
|
|
249
|
+
# Publish latest tag
|
|
250
|
+
repo.publish!
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Log
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
log = repo.log("main") # => Log (Enumerable)
|
|
257
|
+
log = repo.log("main", limit: 10) # => last 10
|
|
258
|
+
|
|
259
|
+
# Log is enumerable
|
|
260
|
+
log.map(&:message) # => ["Added step 2", "First draft"]
|
|
261
|
+
log.first # => most recent Commit
|
|
262
|
+
log.to_text # => "* a1b2c3 Added step 2\n* f4e5d6 First draft"
|
|
263
|
+
|
|
264
|
+
# Commit objects
|
|
265
|
+
commit = log.first
|
|
266
|
+
commit.sha # => "a1b2c3..."
|
|
267
|
+
commit.message # => "Added step 2"
|
|
268
|
+
commit.author # => #<User id: 1, name: "Alice">
|
|
269
|
+
commit.committed_at # => 2026-03-21 14:30:00 UTC
|
|
270
|
+
commit.parents # => [#<Commit ...>]
|
|
271
|
+
commit.merge? # => false
|
|
272
|
+
commit.root? # => false
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## How it works
|
|
276
|
+
|
|
277
|
+
Revisable uses the same conceptual model as git, minus the parts you don't need in a database:
|
|
278
|
+
|
|
279
|
+
| Git concept | Revisable equivalent | Notes |
|
|
280
|
+
|---|---|---|
|
|
281
|
+
| Blob | `revisable_blobs` | SHA-256 content-addressed. Identical content = one row. |
|
|
282
|
+
| Tree | — | Skipped. Fields are flat, not nested directories. |
|
|
283
|
+
| Commit | `revisable_commits` | Polymorphic author, parent links, scoped to a record. |
|
|
284
|
+
| Tree entries | `revisable_commit_fields` | Each commit stores a blob SHA for every revisable field. |
|
|
285
|
+
| Ref | `revisable_refs` | Branches and tags. Compare-and-swap updates for concurrency. |
|
|
286
|
+
| Packfile | — | Not needed. Postgres handles storage. |
|
|
287
|
+
| Index / staging | — | Not needed. Every `commit!` is direct. |
|
|
288
|
+
|
|
289
|
+
Each commit stores **full state** (a blob SHA for every field), not deltas. This means you can read any commit without walking history. Unchanged fields reuse the same blob SHA, so storage cost is minimal.
|
|
290
|
+
|
|
291
|
+
## Schema
|
|
292
|
+
|
|
293
|
+
Revisable creates 5 tables. Run `rails generate revisable:install` to get the migration:
|
|
294
|
+
|
|
295
|
+
- **`revisable_blobs`** — content-addressed text storage
|
|
296
|
+
- **`revisable_commits`** — commit metadata, polymorphic to any model
|
|
297
|
+
- **`revisable_commit_parents`** — parent links (supports merge commits with 2+ parents)
|
|
298
|
+
- **`revisable_commit_fields`** — maps each commit × field to a blob
|
|
299
|
+
- **`revisable_refs`** — branches and tags per record
|
|
300
|
+
|
|
301
|
+
All tables are polymorphic — one set of tables serves every model that uses `revisable`.
|
|
302
|
+
|
|
303
|
+
## Concurrency
|
|
304
|
+
|
|
305
|
+
Branch refs use compare-and-swap updates. If two writers commit to the same branch simultaneously, the second one gets a `Revisable::StaleRefError` and can retry. At CMS-level write volumes this is rarely hit, but it's there.
|
|
306
|
+
|
|
307
|
+
## License
|
|
308
|
+
|
|
309
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Revisable
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include ActiveRecord::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def create_migration_file
|
|
13
|
+
migration_template "create_revisable_tables.rb.erb", "db/migrate/create_revisable_tables.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
class CreateRevisableTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :revisable_blobs, id: :bigint do |t|
|
|
4
|
+
t.string :sha, null: false, index: { unique: true }
|
|
5
|
+
t.text :data, null: false
|
|
6
|
+
t.integer :size, null: false
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
create_table :revisable_commits, id: :bigint do |t|
|
|
11
|
+
t.string :sha, null: false, index: { unique: true }
|
|
12
|
+
t.string :versionable_type, null: false
|
|
13
|
+
t.bigint :versionable_id, null: false
|
|
14
|
+
t.string :author_type
|
|
15
|
+
t.bigint :author_id
|
|
16
|
+
t.string :message, null: false
|
|
17
|
+
t.datetime :committed_at, null: false
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
add_index :revisable_commits, [:versionable_type, :versionable_id]
|
|
22
|
+
add_index :revisable_commits, [:author_type, :author_id]
|
|
23
|
+
|
|
24
|
+
create_table :revisable_commit_parents, id: :bigint do |t|
|
|
25
|
+
t.string :commit_sha, null: false
|
|
26
|
+
t.string :parent_sha, null: false
|
|
27
|
+
t.integer :position, null: false, default: 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
add_index :revisable_commit_parents, :commit_sha
|
|
31
|
+
add_index :revisable_commit_parents, :parent_sha
|
|
32
|
+
|
|
33
|
+
create_table :revisable_commit_fields, id: :bigint do |t|
|
|
34
|
+
t.string :commit_sha, null: false
|
|
35
|
+
t.string :field_name, null: false
|
|
36
|
+
t.string :blob_sha, null: false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
add_index :revisable_commit_fields, :commit_sha
|
|
40
|
+
add_index :revisable_commit_fields, :blob_sha
|
|
41
|
+
add_index :revisable_commit_fields, [:commit_sha, :field_name], unique: true
|
|
42
|
+
|
|
43
|
+
create_table :revisable_refs, id: :bigint do |t|
|
|
44
|
+
t.string :versionable_type, null: false
|
|
45
|
+
t.bigint :versionable_id, null: false
|
|
46
|
+
t.string :name, null: false
|
|
47
|
+
t.string :ref_type, null: false
|
|
48
|
+
t.string :commit_sha, null: false
|
|
49
|
+
t.string :message
|
|
50
|
+
t.timestamps
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
add_index :revisable_refs, [:versionable_type, :versionable_id, :ref_type, :name],
|
|
54
|
+
unique: true, name: "index_revisable_refs_on_versionable_and_type_and_name"
|
|
55
|
+
add_index :revisable_refs, :commit_sha
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module ControllerHelpers
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
around_action :revisable_set_author, if: :revisable_author
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def revisable_set_author(&block)
|
|
15
|
+
Revisable::CurrentAuthor.with(revisable_author, &block)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Override in your controller to provide the current user
|
|
19
|
+
# Example:
|
|
20
|
+
# def revisable_author
|
|
21
|
+
# current_user
|
|
22
|
+
# end
|
|
23
|
+
def revisable_author
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
|
|
5
|
+
module Revisable
|
|
6
|
+
class Blob < ActiveRecord::Base
|
|
7
|
+
self.table_name = "revisable_blobs"
|
|
8
|
+
|
|
9
|
+
validates :sha, presence: true, uniqueness: true
|
|
10
|
+
validates :data, presence: true
|
|
11
|
+
validates :size, presence: true
|
|
12
|
+
|
|
13
|
+
def self.store(content)
|
|
14
|
+
content = content.to_s
|
|
15
|
+
sha = compute_sha(content)
|
|
16
|
+
|
|
17
|
+
find_or_create_by!(sha: sha) do |blob|
|
|
18
|
+
blob.data = content
|
|
19
|
+
blob.size = content.bytesize
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.compute_sha(content)
|
|
24
|
+
Digest::SHA256.hexdigest(content.to_s)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class Branch
|
|
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 commit
|
|
16
|
+
ref.commit
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def commit_sha
|
|
20
|
+
ref.commit_sha
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tip(fields:)
|
|
24
|
+
Snapshot.new(commit: commit, fields: fields)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
name
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.is_a?(Branch) && name == other.name && commit_sha == other.commit_sha
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
|
|
5
|
+
module Revisable
|
|
6
|
+
class Commit < ActiveRecord::Base
|
|
7
|
+
self.table_name = "revisable_commits"
|
|
8
|
+
|
|
9
|
+
belongs_to :versionable, polymorphic: true
|
|
10
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
11
|
+
|
|
12
|
+
has_many :commit_fields,
|
|
13
|
+
class_name: "Revisable::CommitField",
|
|
14
|
+
foreign_key: :commit_sha,
|
|
15
|
+
primary_key: :sha,
|
|
16
|
+
dependent: :destroy
|
|
17
|
+
|
|
18
|
+
has_many :parent_links,
|
|
19
|
+
class_name: "Revisable::CommitParent",
|
|
20
|
+
foreign_key: :commit_sha,
|
|
21
|
+
primary_key: :sha,
|
|
22
|
+
dependent: :destroy
|
|
23
|
+
|
|
24
|
+
validates :sha, presence: true, uniqueness: true
|
|
25
|
+
validates :message, presence: true
|
|
26
|
+
|
|
27
|
+
def parents
|
|
28
|
+
Commit.where(sha: parent_links.order(:position).pluck(:parent_sha))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parent_shas
|
|
32
|
+
parent_links.order(:position).pluck(:parent_sha)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def root?
|
|
36
|
+
parent_links.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def merge?
|
|
40
|
+
parent_links.count > 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def snapshot(fields:)
|
|
44
|
+
Snapshot.new(commit: self, fields: fields)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def field_set
|
|
48
|
+
@field_set ||= begin
|
|
49
|
+
blobs = commit_fields.includes(:blob).each_with_object({}) do |cf, hash|
|
|
50
|
+
hash[cf.field_name.to_sym] = cf.blob
|
|
51
|
+
end
|
|
52
|
+
FieldSet.new(blobs)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.build_sha(parent_shas:, field_blobs:, message:, timestamp:)
|
|
57
|
+
parts = [
|
|
58
|
+
"parents:#{parent_shas.sort.join(',')}",
|
|
59
|
+
"fields:#{field_blobs.sort.map { |k, v| "#{k}:#{v}" }.join(',')}",
|
|
60
|
+
"message:#{message}",
|
|
61
|
+
"timestamp:#{timestamp.iso8601}"
|
|
62
|
+
]
|
|
63
|
+
Digest::SHA256.hexdigest(parts.join("\n"))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class CommitParent < ActiveRecord::Base
|
|
68
|
+
self.table_name = "revisable_commit_parents"
|
|
69
|
+
|
|
70
|
+
belongs_to :commit,
|
|
71
|
+
class_name: "Revisable::Commit",
|
|
72
|
+
foreign_key: :commit_sha,
|
|
73
|
+
primary_key: :sha
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class CommitField < ActiveRecord::Base
|
|
5
|
+
self.table_name = "revisable_commit_fields"
|
|
6
|
+
|
|
7
|
+
belongs_to :commit,
|
|
8
|
+
class_name: "Revisable::Commit",
|
|
9
|
+
foreign_key: :commit_sha,
|
|
10
|
+
primary_key: :sha
|
|
11
|
+
|
|
12
|
+
belongs_to :blob,
|
|
13
|
+
class_name: "Revisable::Blob",
|
|
14
|
+
foreign_key: :blob_sha,
|
|
15
|
+
primary_key: :sha
|
|
16
|
+
|
|
17
|
+
validates :field_name, presence: true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
module CurrentAuthor
|
|
5
|
+
def self.set(author)
|
|
6
|
+
Thread.current[:revisable_current_author] = author
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.get
|
|
10
|
+
Thread.current[:revisable_current_author]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.clear
|
|
14
|
+
Thread.current[:revisable_current_author] = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.with(author)
|
|
18
|
+
previous = get
|
|
19
|
+
set(author)
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
set(previous)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class Diff
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :commit_a, :commit_b
|
|
8
|
+
|
|
9
|
+
def initialize(commit_a:, commit_b:, versionable_fields:)
|
|
10
|
+
@commit_a = commit_a
|
|
11
|
+
@commit_b = commit_b
|
|
12
|
+
@fields_a = commit_a&.field_set || FieldSet.empty
|
|
13
|
+
@fields_b = commit_b&.field_set || FieldSet.empty
|
|
14
|
+
@versionable_fields = versionable_fields
|
|
15
|
+
|
|
16
|
+
@fields = build_field_diffs
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def field(name)
|
|
20
|
+
@fields[name.to_sym]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def each(&block)
|
|
24
|
+
@fields.each(&block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def changed?
|
|
28
|
+
@fields.values.any?(&:changed?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def changed_fields
|
|
32
|
+
@fields.values.select(&:changed?)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_text
|
|
36
|
+
@fields.values
|
|
37
|
+
.select(&:changed?)
|
|
38
|
+
.map(&:to_text)
|
|
39
|
+
.join("\n\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_html
|
|
43
|
+
@fields.values
|
|
44
|
+
.select(&:changed?)
|
|
45
|
+
.map(&:to_html)
|
|
46
|
+
.join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_field_diffs
|
|
52
|
+
@versionable_fields.each_with_object({}) do |field, hash|
|
|
53
|
+
field = field.to_sym
|
|
54
|
+
hash[field] = FieldDiff.new(
|
|
55
|
+
field_name: field,
|
|
56
|
+
blob_a: @fields_a[field],
|
|
57
|
+
blob_b: @fields_b[field]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|