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,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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
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