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,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class FieldDiff
|
|
5
|
+
attr_reader :field_name, :status, :a, :b
|
|
6
|
+
|
|
7
|
+
def initialize(field_name:, blob_a:, blob_b:)
|
|
8
|
+
@field_name = field_name.to_sym
|
|
9
|
+
@a = blob_a&.data
|
|
10
|
+
@b = blob_b&.data
|
|
11
|
+
|
|
12
|
+
@status = if blob_a.nil? && blob_b.nil?
|
|
13
|
+
:unchanged
|
|
14
|
+
elsif blob_a.nil?
|
|
15
|
+
:added
|
|
16
|
+
elsif blob_b.nil?
|
|
17
|
+
:removed
|
|
18
|
+
elsif blob_a.sha == blob_b.sha
|
|
19
|
+
:unchanged
|
|
20
|
+
else
|
|
21
|
+
:modified
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def changed?
|
|
26
|
+
status != :unchanged
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def hunks
|
|
30
|
+
@hunks ||= changed? ? ::Diff::LCS.sdiff(lines_a, lines_b) : []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_text
|
|
34
|
+
Renderers::TextRenderer.new(self).render
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_html
|
|
38
|
+
Renderers::HtmlRenderer.new(self).render
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def lines_a
|
|
44
|
+
@lines_a ||= (@a || "").lines(chomp: true)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lines_b
|
|
48
|
+
@lines_b ||= (@b || "").lines(chomp: true)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class FieldSet
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
def initialize(fields = {})
|
|
8
|
+
@fields = fields.transform_keys(&:to_sym)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def [](field_name)
|
|
12
|
+
@fields[field_name.to_sym]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each(&block)
|
|
16
|
+
@fields.each(&block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def keys
|
|
20
|
+
@fields.keys
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def values
|
|
24
|
+
@fields.values
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sha_for(field_name)
|
|
28
|
+
self[field_name]&.sha
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def data_for(field_name)
|
|
32
|
+
self[field_name]&.data
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def shas
|
|
36
|
+
@fields.transform_values(&:sha)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def diff(other)
|
|
40
|
+
keys.each_with_object({}) do |field, result|
|
|
41
|
+
result[field] = sha_for(field) != other.sha_for(field)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def empty?
|
|
46
|
+
@fields.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.empty
|
|
50
|
+
new({})
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class Log
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :commits
|
|
8
|
+
|
|
9
|
+
def initialize(commits)
|
|
10
|
+
@commits = commits
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each(&block)
|
|
14
|
+
@commits.each(&block)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def size
|
|
18
|
+
@commits.size
|
|
19
|
+
end
|
|
20
|
+
alias_method :length, :size
|
|
21
|
+
|
|
22
|
+
def first(n = nil)
|
|
23
|
+
n ? self.class.new(@commits.first(n)) : @commits.first
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def last(n = nil)
|
|
27
|
+
n ? self.class.new(@commits.last(n)) : @commits.last
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def empty?
|
|
31
|
+
@commits.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_text
|
|
35
|
+
@commits.map do |commit|
|
|
36
|
+
prefix = commit.merge? ? "M" : "*"
|
|
37
|
+
"#{prefix} #{commit.sha[0..6]} #{commit.message}"
|
|
38
|
+
end.join("\n")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class Merge
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :into_commit, :from_commit, :ancestor_commit,
|
|
8
|
+
:into, :from
|
|
9
|
+
|
|
10
|
+
def initialize(into_commit:, from_commit:, ancestor_commit:,
|
|
11
|
+
into:, from:, versionable_fields:)
|
|
12
|
+
@into_commit = into_commit
|
|
13
|
+
@from_commit = from_commit
|
|
14
|
+
@ancestor_commit = ancestor_commit
|
|
15
|
+
@into = into.to_s
|
|
16
|
+
@from = from.to_s
|
|
17
|
+
@versionable_fields = versionable_fields
|
|
18
|
+
|
|
19
|
+
@into_fields = into_commit.field_set
|
|
20
|
+
@from_fields = from_commit.field_set
|
|
21
|
+
@ancestor_fields = ancestor_commit&.field_set || FieldSet.empty
|
|
22
|
+
|
|
23
|
+
@fields = build_merge_fields
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def field(name)
|
|
27
|
+
@fields[name.to_sym]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each(&block)
|
|
31
|
+
@fields.each(&block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clean?
|
|
35
|
+
conflicts.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def conflicts
|
|
39
|
+
@fields.values.select(&:conflicted?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def auto_resolved
|
|
43
|
+
@fields.values.select(&:auto_resolved?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unchanged
|
|
47
|
+
@fields.values.select(&:unchanged?)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve(field_name, value)
|
|
51
|
+
f = field(field_name)
|
|
52
|
+
raise Error, "Unknown field: #{field_name}" unless f
|
|
53
|
+
f.resolve(value)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolved?(field_name)
|
|
57
|
+
field(field_name)&.resolved? || false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def all_resolved?
|
|
61
|
+
@fields.values.none?(&:conflicted?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unresolved
|
|
65
|
+
@fields.values.select(&:conflicted?)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def commit!(repository:, author: nil, message: "Merge #{from} into #{into}")
|
|
69
|
+
raise UnresolvedConflictsError, "Unresolved conflicts: #{unresolved.map(&:field_name).join(', ')}" unless all_resolved?
|
|
70
|
+
|
|
71
|
+
fields = @fields.each_with_object({}) do |(name, merge_field), hash|
|
|
72
|
+
hash[name] = merge_field.value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
repository.commit!(
|
|
76
|
+
branch: nil,
|
|
77
|
+
author: author,
|
|
78
|
+
message: message,
|
|
79
|
+
fields: fields,
|
|
80
|
+
parent_shas: [into_commit.sha, from_commit.sha]
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def build_merge_fields
|
|
87
|
+
@versionable_fields.each_with_object({}) do |field, result|
|
|
88
|
+
field = field.to_sym
|
|
89
|
+
|
|
90
|
+
ancestor_sha = @ancestor_fields.sha_for(field)
|
|
91
|
+
into_sha = @into_fields.sha_for(field)
|
|
92
|
+
from_sha = @from_fields.sha_for(field)
|
|
93
|
+
|
|
94
|
+
into_data = @into_fields.data_for(field)
|
|
95
|
+
from_data = @from_fields.data_for(field)
|
|
96
|
+
ancestor_data = @ancestor_fields.data_for(field)
|
|
97
|
+
|
|
98
|
+
into_changed = into_sha != ancestor_sha
|
|
99
|
+
from_changed = from_sha != ancestor_sha
|
|
100
|
+
|
|
101
|
+
status, auto_value = if !into_changed && !from_changed
|
|
102
|
+
[:unchanged, into_data]
|
|
103
|
+
elsif into_changed && !from_changed
|
|
104
|
+
[:auto_resolved, into_data]
|
|
105
|
+
elsif !into_changed && from_changed
|
|
106
|
+
[:auto_resolved, from_data]
|
|
107
|
+
elsif into_sha == from_sha
|
|
108
|
+
[:auto_resolved, into_data]
|
|
109
|
+
else
|
|
110
|
+
[:conflicted, nil]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
result[field] = MergeField.new(
|
|
114
|
+
field_name: field,
|
|
115
|
+
status: status,
|
|
116
|
+
versions: { @into => into_data, @from => from_data },
|
|
117
|
+
ancestor: ancestor_data,
|
|
118
|
+
auto_resolved_value: auto_value
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class MergeField
|
|
5
|
+
attr_reader :field_name, :status, :ancestor, :resolved_value, :versions
|
|
6
|
+
|
|
7
|
+
def initialize(field_name:, status:, versions:, ancestor:, auto_resolved_value: nil)
|
|
8
|
+
@field_name = field_name.to_sym
|
|
9
|
+
@status = status
|
|
10
|
+
@versions = versions
|
|
11
|
+
@ancestor = ancestor
|
|
12
|
+
@auto_resolved_value = auto_resolved_value
|
|
13
|
+
@resolved_value = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def version(branch_name)
|
|
17
|
+
@versions[branch_name.to_s]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def conflicted?
|
|
21
|
+
status == :conflicted && !resolved?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def auto_resolved?
|
|
25
|
+
status == :auto_resolved
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unchanged?
|
|
29
|
+
status == :unchanged
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resolved?
|
|
33
|
+
!@resolved_value.nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve(value)
|
|
37
|
+
@resolved_value = case value
|
|
38
|
+
when :ancestor then ancestor
|
|
39
|
+
when Symbol, String
|
|
40
|
+
name = value.to_s
|
|
41
|
+
if @versions.key?(name)
|
|
42
|
+
@versions[name]
|
|
43
|
+
else
|
|
44
|
+
value.to_s
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
value.to_s
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value
|
|
52
|
+
if resolved?
|
|
53
|
+
resolved_value
|
|
54
|
+
elsif auto_resolved? || unchanged?
|
|
55
|
+
@auto_resolved_value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
module Model
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
after_create :revisable_initial_commit, if: :revisable_has_content?
|
|
9
|
+
after_update :revisable_auto_commit, if: :revisable_fields_changed?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def revisable(*fields, auto_commit: true)
|
|
14
|
+
@revisable_fields = fields.map(&:to_sym)
|
|
15
|
+
@revisable_auto_commit = auto_commit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def revisable_fields
|
|
19
|
+
@revisable_fields || []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def revisable_auto_commit?
|
|
23
|
+
@revisable_auto_commit != false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def repository
|
|
28
|
+
@_revisable_repository ||= Repository.new(
|
|
29
|
+
versionable: self,
|
|
30
|
+
fields: self.class.revisable_fields
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def revisable_has_content?
|
|
37
|
+
self.class.revisable_auto_commit? &&
|
|
38
|
+
self.class.revisable_fields.any? { |f| send(f).present? }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def revisable_fields_changed?
|
|
42
|
+
self.class.revisable_auto_commit? &&
|
|
43
|
+
(saved_changes.keys.map(&:to_sym) & self.class.revisable_fields).any?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def revisable_changed_fields
|
|
47
|
+
self.class.revisable_fields.each_with_object({}) do |field, hash|
|
|
48
|
+
hash[field] = send(field)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def revisable_initial_commit
|
|
53
|
+
repository.commit!(
|
|
54
|
+
message: "Initial version",
|
|
55
|
+
author: CurrentAuthor.get,
|
|
56
|
+
fields: revisable_changed_fields
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def revisable_auto_commit
|
|
61
|
+
changed = saved_changes.keys.map(&:to_sym) & self.class.revisable_fields
|
|
62
|
+
message = "Updated #{changed.map(&:to_s).join(', ')}"
|
|
63
|
+
|
|
64
|
+
repository.commit!(
|
|
65
|
+
message: message,
|
|
66
|
+
author: CurrentAuthor.get,
|
|
67
|
+
fields: revisable_changed_fields
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
class Ref < ActiveRecord::Base
|
|
5
|
+
self.table_name = "revisable_refs"
|
|
6
|
+
|
|
7
|
+
belongs_to :versionable, polymorphic: true
|
|
8
|
+
|
|
9
|
+
belongs_to :commit,
|
|
10
|
+
class_name: "Revisable::Commit",
|
|
11
|
+
foreign_key: :commit_sha,
|
|
12
|
+
primary_key: :sha
|
|
13
|
+
|
|
14
|
+
validates :name, presence: true
|
|
15
|
+
validates :ref_type, presence: true, inclusion: { in: %w[branch tag] }
|
|
16
|
+
validates :name, uniqueness: { scope: [:versionable_type, :versionable_id, :ref_type] }
|
|
17
|
+
|
|
18
|
+
scope :branches, -> { where(ref_type: "branch") }
|
|
19
|
+
scope :tags, -> { where(ref_type: "tag") }
|
|
20
|
+
|
|
21
|
+
def branch?
|
|
22
|
+
ref_type == "branch"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def tag?
|
|
26
|
+
ref_type == "tag"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def advance!(new_sha, expected_sha: nil)
|
|
30
|
+
if expected_sha
|
|
31
|
+
rows = self.class.where(id: id, commit_sha: expected_sha).update_all(commit_sha: new_sha)
|
|
32
|
+
raise StaleRefError, "Ref #{name} was updated by another process" if rows == 0
|
|
33
|
+
self.commit_sha = new_sha
|
|
34
|
+
else
|
|
35
|
+
update!(commit_sha: new_sha)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
module Renderers
|
|
5
|
+
class HtmlRenderer
|
|
6
|
+
attr_reader :field_diff
|
|
7
|
+
|
|
8
|
+
def initialize(field_diff)
|
|
9
|
+
@field_diff = field_diff
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render
|
|
13
|
+
return nil unless field_diff.changed?
|
|
14
|
+
|
|
15
|
+
parts = []
|
|
16
|
+
parts << "<div class=\"revisable-diff\" data-field=\"#{field_diff.field_name}\">"
|
|
17
|
+
|
|
18
|
+
field_diff.hunks.each do |change|
|
|
19
|
+
escaped_old = escape_html(change.old_element.to_s)
|
|
20
|
+
escaped_new = escape_html(change.new_element.to_s)
|
|
21
|
+
|
|
22
|
+
case change.action
|
|
23
|
+
when "="
|
|
24
|
+
parts << "<span class=\"unchanged\">#{escaped_old}</span>"
|
|
25
|
+
when "!"
|
|
26
|
+
parts << "<del>#{escaped_old}</del>"
|
|
27
|
+
parts << "<ins>#{escaped_new}</ins>"
|
|
28
|
+
when "-"
|
|
29
|
+
parts << "<del>#{escaped_old}</del>"
|
|
30
|
+
when "+"
|
|
31
|
+
parts << "<ins>#{escaped_new}</ins>"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
parts << "</div>"
|
|
36
|
+
parts.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def escape_html(str)
|
|
42
|
+
str.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Revisable
|
|
4
|
+
module Renderers
|
|
5
|
+
class TextRenderer
|
|
6
|
+
attr_reader :field_diff
|
|
7
|
+
|
|
8
|
+
def initialize(field_diff)
|
|
9
|
+
@field_diff = field_diff
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render
|
|
13
|
+
return nil unless field_diff.changed?
|
|
14
|
+
|
|
15
|
+
lines = []
|
|
16
|
+
lines << "--- a/#{field_diff.field_name}"
|
|
17
|
+
lines << "+++ b/#{field_diff.field_name}"
|
|
18
|
+
|
|
19
|
+
field_diff.hunks.each do |change|
|
|
20
|
+
case change.action
|
|
21
|
+
when "="
|
|
22
|
+
lines << " #{change.old_element}"
|
|
23
|
+
when "!"
|
|
24
|
+
lines << "-#{change.old_element}"
|
|
25
|
+
lines << "+#{change.new_element}"
|
|
26
|
+
when "-"
|
|
27
|
+
lines << "-#{change.old_element}"
|
|
28
|
+
when "+"
|
|
29
|
+
lines << "+#{change.new_element}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
lines.join("\n")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|