archimate-diff 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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/.projectile +18 -0
  4. data/.rubocop.yml +13 -0
  5. data/.travis.yml +4 -0
  6. data/.yardocs +15 -0
  7. data/Gemfile +5 -0
  8. data/Guardfile +27 -0
  9. data/LICENSE +201 -0
  10. data/README.md +94 -0
  11. data/Rakefile +29 -0
  12. data/archimate-diff.gemspec +51 -0
  13. data/exe/archidiff +7 -0
  14. data/exe/archidiff-summary +7 -0
  15. data/exe/archimerge +7 -0
  16. data/exe/fmtxml +7 -0
  17. data/lib/archimate-diff.rb +33 -0
  18. data/lib/archimate/diff/archimate_array_reference.rb +113 -0
  19. data/lib/archimate/diff/archimate_identified_node_reference.rb +41 -0
  20. data/lib/archimate/diff/archimate_node_attribute_reference.rb +70 -0
  21. data/lib/archimate/diff/archimate_node_reference.rb +80 -0
  22. data/lib/archimate/diff/change.rb +49 -0
  23. data/lib/archimate/diff/cli/conflict_resolver.rb +41 -0
  24. data/lib/archimate/diff/cli/diff.rb +33 -0
  25. data/lib/archimate/diff/cli/diff_summary.rb +103 -0
  26. data/lib/archimate/diff/cli/merge.rb +51 -0
  27. data/lib/archimate/diff/cli/merger.rb +111 -0
  28. data/lib/archimate/diff/conflict.rb +31 -0
  29. data/lib/archimate/diff/conflicts.rb +89 -0
  30. data/lib/archimate/diff/conflicts/base_conflict.rb +53 -0
  31. data/lib/archimate/diff/conflicts/deleted_items_child_updated_conflict.rb +30 -0
  32. data/lib/archimate/diff/conflicts/deleted_items_referenced_conflict.rb +63 -0
  33. data/lib/archimate/diff/conflicts/path_conflict.rb +51 -0
  34. data/lib/archimate/diff/delete.rb +41 -0
  35. data/lib/archimate/diff/difference.rb +113 -0
  36. data/lib/archimate/diff/insert.rb +43 -0
  37. data/lib/archimate/diff/merge.rb +70 -0
  38. data/lib/archimate/diff/move.rb +51 -0
  39. data/lib/archimate/diff/version.rb +6 -0
  40. metadata +453 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "highline"
4
+
5
+ module Archimate
6
+ module Diff
7
+ module Cli
8
+ class ConflictResolver
9
+ def initialize
10
+ @config = Config.instance
11
+ # TODO: pull the stdin/stdout from the app config
12
+ @hl = HighLine.new(STDIN, STDOUT)
13
+ end
14
+
15
+ # TODO: this implementation has much to be written
16
+ def resolve(conflict)
17
+ return [] unless @config.interactive
18
+ base_local_diffs = conflict.base_local_diffs
19
+ base_remote_diffs = conflict.base_remote_diffs
20
+ choice = @hl.choose do |menu|
21
+ menu.prompt = conflict
22
+ menu.choice(:local, text: base_local_diffs.map(&:to_s).join("\n\t\t"))
23
+ menu.choice(:remote, text: base_remote_diffs.map(&:to_s).join("\n\t\t"))
24
+ # menu.choice(:neither, help: "Don't choose either set of diffs")
25
+ # menu.choice(:edit, help: "Edit the diffs (coming soon)")
26
+ # menu.choice(:quit, help: "I'm in over my head. Just stop!")
27
+ menu.select_by = :index_or_name
28
+ end
29
+ case choice
30
+ when :local
31
+ base_local_diffs
32
+ when :remote
33
+ base_remote_diffs
34
+ else
35
+ error "Unexpected choice #{choice.inspect}."
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ module Cli
5
+ class Diff
6
+ attr_reader :local, :remote
7
+
8
+ def self.diff(local_file, remote_file)
9
+ local = Archimate.read(local_file)
10
+ remote = Archimate.read(remote_file)
11
+
12
+ my_diff = Diff.new(local, remote)
13
+ my_diff.diff
14
+ end
15
+
16
+ def initialize(local, remote)
17
+ @local = local
18
+ @remote = remote
19
+ end
20
+
21
+ def diff
22
+ diffs = Archimate.diff(local, remote)
23
+
24
+ diffs.each { |d| puts d }
25
+
26
+ puts "\n\n#{diffs.size} Differences"
27
+
28
+ diffs
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+ require 'forwardable'
3
+
4
+ module Archimate
5
+ module Diff
6
+ module Cli
7
+ class DiffSummary
8
+ using DataModel::DiffableArray
9
+ using DataModel::DiffablePrimitive
10
+
11
+ DIFF_KINDS = %w(Delete Change Insert).freeze
12
+
13
+ attr_reader :local, :remote
14
+
15
+ def self.diff(local_file, remote_file, options = { verbose: true })
16
+ logger.info "Reading #{local_file}"
17
+ local = Archimate.read(local_file)
18
+ logger.info "Reading #{remote_file}"
19
+ remote = Archimate.read(remote_file)
20
+
21
+ my_diff = DiffSummary.new(local, remote)
22
+ my_diff.diff
23
+ end
24
+
25
+ def initialize(local, remote)
26
+ @local = local
27
+ @remote = remote
28
+ @summary = Hash.new { |hash, key| hash[key] = Hash.new { |k_hash, k_key| k_hash[k_key] = 0 } }
29
+ end
30
+
31
+ def diff
32
+ logger.info "Calculating differences"
33
+ diffs = Archimate.diff(local, remote)
34
+
35
+ puts Color.color("Summary of differences", :headline)
36
+ puts "\n"
37
+
38
+ summary_element_diffs = diffs.group_by { |diff| diff.summary_element.class.to_s.split("::").last }
39
+ summarize_elements summary_element_diffs["Element"]
40
+ summarize "Organization", summary_element_diffs["Organization"]
41
+ summarize "Relationship", summary_element_diffs["Relationship"]
42
+ summarize_diagrams summary_element_diffs["Diagram"]
43
+
44
+ puts "Total Diffs: #{diffs.size}"
45
+ end
46
+
47
+ def summarize(title, diffs)
48
+ return if diffs.nil? || diffs.empty?
49
+ by_kind = diffs_by_kind(diffs)
50
+
51
+ puts color(title)
52
+ DIFF_KINDS.each do |kind|
53
+ puts format(" #{color(kind)}: #{by_kind[kind]&.size}") if by_kind.key?(kind)
54
+ end
55
+ end
56
+
57
+ def diffs_by_kind(diffs)
58
+ diffs
59
+ .group_by(&:summary_element)
60
+ .each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(summary_element, element_diffs), a|
61
+ top_level_diff = element_diffs.find { |diff| summary_element == diff.target.value }
62
+ if top_level_diff
63
+ a[top_level_diff.kind] << summary_element
64
+ else
65
+ a["Change"] << summary_element
66
+ end
67
+ end
68
+ end
69
+
70
+ def summarize_elements(diffs)
71
+ return if diffs.nil? || diffs.empty?
72
+ puts Color.color("Elements", :headline)
73
+ by_layer = diffs.group_by { |diff| diff.summary_element.layer }
74
+ summarize "Business", by_layer["Business"]
75
+ summarize "Application", by_layer["Application"]
76
+ summarize "Technology", by_layer["Technology"]
77
+ summarize "Motivation", by_layer["Motivation"]
78
+ summarize "Implementation and Migration", by_layer["Implementation and Migration"]
79
+ summarize "Connectors", by_layer["Connectors"]
80
+ end
81
+
82
+ def summarize_diagrams(diffs)
83
+ return if diffs.nil? || diffs.empty?
84
+ puts Color.color("Diagrams", :headline)
85
+
86
+ by_kind = diffs_by_kind(diffs)
87
+ %w(Delete Change Insert).each do |kind|
88
+ next unless by_kind.key?(kind)
89
+ diagram_names = by_kind[kind].uniq.map(&:name)
90
+ puts " #{color(kind)}"
91
+ # TODO: make this magic number an option
92
+ diagram_names[0..14].each { |diagram_name| puts " #{diagram_name}" }
93
+ puts " ... and #{diagram_names.size - 15} more" if diagram_names.size > 15
94
+ end
95
+ end
96
+
97
+ def color(kind)
98
+ Color.color(kind, kind)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parallel"
4
+
5
+ module Archimate
6
+ module Diff
7
+ module Cli
8
+ class Merge
9
+ include Logging
10
+
11
+ attr_reader :base, :local, :remote, :merged_file
12
+
13
+ def self.merge(base_file, remote_file, local_file, merged_file)
14
+ Logging.debug { "Reading base file: #{base_file}, local file: #{local_file}, remote file: #{remote_file}" }
15
+ base, local, remote = Parallel.map([base_file, local_file, remote_file], in_processes: 3) do |file|
16
+ Archimate.read(file)
17
+ end
18
+ Logging.debug { "Merged file is #{merged_file}" }
19
+
20
+ Merge.new(base, local, remote, merged_file).run_merge
21
+ end
22
+
23
+ def initialize(base, local, remote, merged_file)
24
+ @base = base
25
+ @local = local
26
+ @remote = remote
27
+ @merged_file = merged_file
28
+ @merge = Archimate::Diff::Merge.new
29
+ end
30
+
31
+ def run_merge
32
+ debug { "Starting merging" }
33
+ merged, conflicts = @merge.three_way(base, local, remote)
34
+ # TODO: there should be no conflicts here
35
+ debug do
36
+ <<~MSG
37
+ Done merging
38
+ #{conflicts}
39
+ MSG
40
+ end
41
+
42
+ File.open(merged_file, "w") do |file|
43
+ # TODO: this should be controlled by the options and the defaulted to the read format
44
+ debug { "Serializing" }
45
+ Archimate::FileFormats::ArchiFileWriter.write(merged, file)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archimate
4
+ module Diff
5
+ module Cli
6
+ # Merger is a class that is a decorator on Archimate::DataModel::Model
7
+ # to provide the capability to merge another model into itself
8
+ #
9
+ # TODO: provide for a conflict resolver instance
10
+ # TODO: provide an option to determine if potential matches are merged
11
+ # or if the conflict resolver should be asked.
12
+ class Merger # < SimpleDelegator
13
+ # def initialize(primary_model, conflict_resolver)
14
+ # super(primary_model)
15
+ # @resolver = conflict_resolver
16
+ # end
17
+
18
+ # What merge does:
19
+ # For all entities: (other than Model...):
20
+ # - PropertyDefinition
21
+ # - View
22
+ # - Viewpoint
23
+ # Entity:
24
+ # look for a matching entity: with result
25
+ # 1. Found a matching entity: goto entity merge
26
+ # 2. Found no matching entity, but id conflicts: gen new id, goto add entity
27
+ # 3. Found no matching entity: goto add entity
28
+ # entity merge:
29
+ # 1. merge (with func from deduper)
30
+ # add entity:
31
+ # 1. add entity to model
32
+ # add remapping entry to map from entities other model id to id in this model
33
+ # Relationship:
34
+ # def merge(other_model)
35
+ # other_model.entities.each do |entity|
36
+ # # TODO: matching entity should use the same criteria that DuplicateEntities uses.
37
+ # my_entity = find_matching_entity(entity)
38
+ # if my_entity
39
+ # end
40
+ # end
41
+
42
+ # TODO: handle inner text of elements
43
+ # TODO: handle merging by element type
44
+
45
+ def hash_to_attr(h)
46
+ h.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
47
+ end
48
+
49
+ def e_to_s(e)
50
+ "#{e.name} #{hash_to_attr(e.attributes)}"
51
+ end
52
+
53
+ # Merge node1, node2
54
+ # For node
55
+ # For each child
56
+ # If has a matching child
57
+ def merge(doc1, doc2)
58
+ doc2.children.each do |e|
59
+ next if e.name == "text" && e.text.strip.empty?
60
+ # p = e.path
61
+ # if p =~ /\[\d+\]$/
62
+ # p = p.gsub(/\[\d+\]$/, "[@name=\"#{e.attr("name")}\"]")
63
+ # end
64
+ # puts "Looking for #{p}"``
65
+ # matches = doc1.xpath(p)
66
+ css = ">#{e.name}"
67
+ # puts css
68
+ css += "[name=\"#{e.attr('name')}\"]" if e.attributes.include?("name")
69
+ css += "[xsi|type=\"#{e.attr('xsi:type')}\"]" if e.attributes.include?("xsi:type")
70
+ matches = doc1.css(css)
71
+ if !matches.empty? # We have a potential match
72
+ # puts "Match?"
73
+ # puts " Doc2: #{e_to_s(e)}"
74
+ # matches.each do |e1|
75
+ # puts " Doc1: #{e_to_s(e1)}"
76
+ # end
77
+ merge(matches[0], e) unless matches.size > 1
78
+ else # No match insert the node into the tree TODO: handle id conflicts
79
+ doc1.add_child(e)
80
+ end
81
+ end
82
+ doc1
83
+ end
84
+
85
+ def id_hash_for(doc)
86
+ doc.css("[id]").each_with_object({}) do |obj, memo|
87
+ memo[obj["id"]] = obj
88
+ memo
89
+ end
90
+ end
91
+
92
+ def conflicting_ids(doc1, doc2)
93
+ doc_id_hash1 = id_hash_for(doc1)
94
+ doc_id_hash2 = id_hash_for(doc2)
95
+ cids = Set.new(doc_id_hash1.keys) & doc_id_hash2.keys
96
+ # puts "ID Conflicts:"
97
+ # puts cids.to_a.join(",")
98
+ cids
99
+ end
100
+
101
+ def merge_files(file1, file2)
102
+ doc1 = Nokogiri::XML(File.open(file1))
103
+ doc2 = Nokogiri::XML(File.open(file2))
104
+
105
+ # cids = conflicting_ids(doc1, doc2)
106
+ merge(doc1.root, doc2.root).document
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ module Archimate
3
+ module Diff
4
+ class Conflict
5
+ attr_reader :base_local_diffs
6
+ attr_reader :base_remote_diffs
7
+ attr_reader :reason
8
+ attr_reader :diffs
9
+
10
+ def initialize(base_local_diffs, base_remote_diffs, reason)
11
+ @base_local_diffs = Array(base_local_diffs)
12
+ @base_remote_diffs = Array(base_remote_diffs)
13
+ @diffs = @base_local_diffs + @base_remote_diffs
14
+ @reason = reason
15
+ end
16
+
17
+ def to_s
18
+ "#{Color.color('CONFLICT:', [:black, :on_red])} #{reason}\n" \
19
+ "\tBase->Local Diff(s):\n\t\t#{base_local_diffs.map(&:to_s).join("\n\t\t")}" \
20
+ "\n\tBase->Remote Diffs(s):\n\t\t#{base_remote_diffs.map(&:to_s).join("\n\t\t")}"
21
+ end
22
+
23
+ def ==(other)
24
+ other.is_a?(self.class) &&
25
+ base_local_diffs == other.base_local_diffs &&
26
+ base_remote_diffs == other.base_remote_diffs &&
27
+ reason == other.reason
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'parallel'
5
+ require 'archimate/diff/conflicts/base_conflict'
6
+ require 'archimate/diff/conflicts/deleted_items_child_updated_conflict'
7
+ require 'archimate/diff/conflicts/deleted_items_referenced_conflict'
8
+ require 'archimate/diff/conflicts/path_conflict'
9
+
10
+ module Archimate
11
+ module Diff
12
+ class Conflicts
13
+ extend Forwardable
14
+
15
+ attr_reader :base_local_diffs
16
+ attr_reader :base_remote_diffs
17
+
18
+ def_delegator :@conflicts, :empty?
19
+ def_delegator :@conflicts, :size
20
+ def_delegator :@conflicts, :first
21
+ def_delegator :@conflicts, :map
22
+ def_delegator :@conflicts, :each
23
+
24
+ include Archimate::Logging
25
+
26
+ def initialize(base_local_diffs, base_remote_diffs)
27
+ @base_local_diffs = base_local_diffs
28
+ @base_remote_diffs = base_remote_diffs
29
+ @conflict_finders = [PathConflict, DeletedItemsChildUpdatedConflict, DeletedItemsReferencedConflict]
30
+ @conflicts = nil
31
+ @conflicting_diffs = nil
32
+ @unconflicted_diffs = nil
33
+ # TODO: consider making this an argument
34
+ @conflict_resolver = Cli::ConflictResolver.new
35
+ end
36
+
37
+ # TODO: refactor this method elsewhere
38
+ # resolve iterates through the set of conflicting diffs asking the user
39
+ # (if running interactively) and return the set of diffs that can be applied.
40
+ #
41
+ # To keep diffs reasonably human readable in logs, the local diffs should
42
+ # be applied first followed by the remote diffs.
43
+ def resolve
44
+ debug do
45
+ <<~MSG
46
+ Filtering out #{conflicts.size} conflicts from #{base_local_diffs.size + base_remote_diffs.size} diffs
47
+ Remaining diffs #{unconflicted_diffs.size}
48
+ MSG
49
+ end
50
+
51
+ conflicts.each_with_object(unconflicted_diffs) do |conflict, diffs|
52
+ # TODO: this will result in diffs being out of order from their
53
+ # original order. diffs should be flagged as conflicted and
54
+ # this method should instead remove the conflicted flag.
55
+ diffs.concat(@conflict_resolver.resolve(conflict))
56
+ # TODO: if the conflict is resolved, it should be removed from the
57
+ # @conflicts array.
58
+ end
59
+ end
60
+
61
+ def conflicts
62
+ @conflicts ||= find_conflicts
63
+ end
64
+
65
+ def conflicting_diffs
66
+ @conflicting_diffs ||= conflicts.map(&:diffs).flatten
67
+ end
68
+
69
+ def unconflicted_diffs
70
+ @unconflicted_diffs ||=
71
+ (base_local_diffs + base_remote_diffs) - conflicting_diffs
72
+ end
73
+
74
+ def to_s
75
+ "Conflicts:\n\n#{conflicts.map(&:to_s).join("\n\n")}\n"
76
+ end
77
+
78
+ private
79
+
80
+ def find_conflicts
81
+ # TODO: Running this in parallel breaks currently.
82
+ # @conflicts = Parallel.map(@conflict_finders, in_processes: 3) { |cf_class|
83
+ @conflicts = @conflict_finders.map { |cf_class|
84
+ cf_class.new(base_local_diffs, base_remote_diffs).conflicts
85
+ }.flatten
86
+ end
87
+ end
88
+ end
89
+ end