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.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.projectile +18 -0
- data/.rubocop.yml +13 -0
- data/.travis.yml +4 -0
- data/.yardocs +15 -0
- data/Gemfile +5 -0
- data/Guardfile +27 -0
- data/LICENSE +201 -0
- data/README.md +94 -0
- data/Rakefile +29 -0
- data/archimate-diff.gemspec +51 -0
- data/exe/archidiff +7 -0
- data/exe/archidiff-summary +7 -0
- data/exe/archimerge +7 -0
- data/exe/fmtxml +7 -0
- data/lib/archimate-diff.rb +33 -0
- data/lib/archimate/diff/archimate_array_reference.rb +113 -0
- data/lib/archimate/diff/archimate_identified_node_reference.rb +41 -0
- data/lib/archimate/diff/archimate_node_attribute_reference.rb +70 -0
- data/lib/archimate/diff/archimate_node_reference.rb +80 -0
- data/lib/archimate/diff/change.rb +49 -0
- data/lib/archimate/diff/cli/conflict_resolver.rb +41 -0
- data/lib/archimate/diff/cli/diff.rb +33 -0
- data/lib/archimate/diff/cli/diff_summary.rb +103 -0
- data/lib/archimate/diff/cli/merge.rb +51 -0
- data/lib/archimate/diff/cli/merger.rb +111 -0
- data/lib/archimate/diff/conflict.rb +31 -0
- data/lib/archimate/diff/conflicts.rb +89 -0
- data/lib/archimate/diff/conflicts/base_conflict.rb +53 -0
- data/lib/archimate/diff/conflicts/deleted_items_child_updated_conflict.rb +30 -0
- data/lib/archimate/diff/conflicts/deleted_items_referenced_conflict.rb +63 -0
- data/lib/archimate/diff/conflicts/path_conflict.rb +51 -0
- data/lib/archimate/diff/delete.rb +41 -0
- data/lib/archimate/diff/difference.rb +113 -0
- data/lib/archimate/diff/insert.rb +43 -0
- data/lib/archimate/diff/merge.rb +70 -0
- data/lib/archimate/diff/move.rb +51 -0
- data/lib/archimate/diff/version.rb +6 -0
- 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
|