vcs_toolkit 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 +20 -0
- data/README.md +8 -0
- data/lib/vcs_toolkit.rb +16 -0
- data/lib/vcs_toolkit/conflict.rb +55 -0
- data/lib/vcs_toolkit/diff.rb +61 -0
- data/lib/vcs_toolkit/exceptions.rb +10 -0
- data/lib/vcs_toolkit/file_store.rb +139 -0
- data/lib/vcs_toolkit/merge.rb +96 -0
- data/lib/vcs_toolkit/object_store.rb +51 -0
- data/lib/vcs_toolkit/objects.rb +5 -0
- data/lib/vcs_toolkit/objects/blob.rb +37 -0
- data/lib/vcs_toolkit/objects/commit.rb +76 -0
- data/lib/vcs_toolkit/objects/label.rb +29 -0
- data/lib/vcs_toolkit/objects/object.rb +47 -0
- data/lib/vcs_toolkit/objects/tree.rb +81 -0
- data/lib/vcs_toolkit/repository.rb +302 -0
- data/lib/vcs_toolkit/serializable.rb +23 -0
- data/lib/vcs_toolkit/utils/hashable_object.rb +45 -0
- data/lib/vcs_toolkit/utils/memory_file_store.rb +101 -0
- data/lib/vcs_toolkit/utils/status.rb +60 -0
- data/lib/vcs_toolkit/utils/sync.rb +73 -0
- data/lib/vcs_toolkit/version.rb +3 -0
- metadata +124 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'digest/sha1'
|
2
|
+
|
3
|
+
require 'vcs_toolkit/exceptions'
|
4
|
+
require 'vcs_toolkit/objects/object'
|
5
|
+
|
6
|
+
module VCSToolkit
|
7
|
+
module Objects
|
8
|
+
|
9
|
+
##
|
10
|
+
# A blob is a nameless object that contains a snapshot
|
11
|
+
# of a file's data. The file name is stored
|
12
|
+
# with the reference to this object (in a Tree object).
|
13
|
+
#
|
14
|
+
# The id of the blob is by default its content's hash.
|
15
|
+
#
|
16
|
+
# The content is not serialized by default (in Blob.to_hash) because
|
17
|
+
# one might decide that content should be
|
18
|
+
# handled differently (or in different format).
|
19
|
+
#
|
20
|
+
class Blob < Object
|
21
|
+
|
22
|
+
attr_reader :content
|
23
|
+
hash_on :content
|
24
|
+
serialize_on :id, :object_type
|
25
|
+
|
26
|
+
def initialize(content:, id: nil, **context)
|
27
|
+
@content = content
|
28
|
+
|
29
|
+
super id: id,
|
30
|
+
object_type: :blob,
|
31
|
+
**context
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module VCSToolkit
|
2
|
+
module Objects
|
3
|
+
|
4
|
+
class Commit < Object
|
5
|
+
|
6
|
+
attr_reader :message, :tree, :parents, :author, :date
|
7
|
+
hash_on :message, :tree, :parents, :author, :date
|
8
|
+
serialize_on :id, :object_type, :message, :tree, :parents, :author, :date
|
9
|
+
|
10
|
+
def initialize(message:, tree:, parents:, author:, date:, id: nil, **context)
|
11
|
+
@message = message
|
12
|
+
@tree = tree
|
13
|
+
@parents = parents
|
14
|
+
@author = author
|
15
|
+
@date = date
|
16
|
+
|
17
|
+
super id: id,
|
18
|
+
object_type: :commit,
|
19
|
+
**context
|
20
|
+
end
|
21
|
+
|
22
|
+
##
|
23
|
+
# Enumerates all commits in the current commit's history.
|
24
|
+
# If a block is given each commit is yielded to it.
|
25
|
+
#
|
26
|
+
def history(object_store)
|
27
|
+
history_diff(object_store) do |commit|
|
28
|
+
yield commit if block_given?
|
29
|
+
|
30
|
+
false
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
##
|
35
|
+
# Enumerates commits in the current commit's history.
|
36
|
+
#
|
37
|
+
# Each commit is yielded and if the block result is a trueish
|
38
|
+
# value the commit's parents are not enumerated.
|
39
|
+
#
|
40
|
+
def history_diff(object_store)
|
41
|
+
commits = {id => self}
|
42
|
+
commit_queue = [self]
|
43
|
+
|
44
|
+
until commit_queue.empty?
|
45
|
+
commit = commit_queue.shift
|
46
|
+
|
47
|
+
if yield commit
|
48
|
+
commits.delete commit.id
|
49
|
+
next
|
50
|
+
end
|
51
|
+
|
52
|
+
commit.parents.each do |parent_id|
|
53
|
+
unless commits.key? parent_id
|
54
|
+
parent = object_store.fetch parent_id
|
55
|
+
|
56
|
+
commits[parent_id] = parent
|
57
|
+
commit_queue << parent
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
commits.values
|
63
|
+
end
|
64
|
+
|
65
|
+
def common_ancestor(other_commit, object_store)
|
66
|
+
my_ancestors = history(object_store).to_set
|
67
|
+
|
68
|
+
other_commit.enum_for(:history, object_store).find do |ancestor|
|
69
|
+
my_ancestors.include? ancestor
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module VCSToolkit
|
2
|
+
module Objects
|
3
|
+
|
4
|
+
class Label < Object
|
5
|
+
attr_accessor :reference_id
|
6
|
+
serialize_on :id, :object_type, :reference_id
|
7
|
+
|
8
|
+
def initialize(id:, reference_id:, **context)
|
9
|
+
@reference_id = reference_id
|
10
|
+
|
11
|
+
super id: id,
|
12
|
+
object_type: :label,
|
13
|
+
named: true,
|
14
|
+
**context
|
15
|
+
end
|
16
|
+
|
17
|
+
def ==(other)
|
18
|
+
id == other.id and reference_id == other.reference_id
|
19
|
+
end
|
20
|
+
|
21
|
+
alias_method :eql?, :==
|
22
|
+
|
23
|
+
def hash
|
24
|
+
[id, reference_id].hash
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'vcs_toolkit/serializable'
|
2
|
+
require 'vcs_toolkit/utils/hashable_object'
|
3
|
+
|
4
|
+
module VCSToolkit
|
5
|
+
module Objects
|
6
|
+
|
7
|
+
class Object
|
8
|
+
extend Serializable
|
9
|
+
include Utils::HashableObject
|
10
|
+
|
11
|
+
attr_reader :id, :object_type
|
12
|
+
serialize_on :id, :object_type
|
13
|
+
|
14
|
+
def initialize(id: nil,
|
15
|
+
object_type: :object,
|
16
|
+
named: false,
|
17
|
+
verify_object_id: true,
|
18
|
+
**context)
|
19
|
+
@object_type = object_type.to_sym
|
20
|
+
@named = named
|
21
|
+
|
22
|
+
if id
|
23
|
+
@id = id
|
24
|
+
raise InvalidObjectError, 'Invalid id' if verify_object_id and not named? and not id_valid?
|
25
|
+
else
|
26
|
+
raise InvalidObjectError, 'Named objects should always specify an id' if named?
|
27
|
+
@id = generate_id
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def named?
|
32
|
+
@named
|
33
|
+
end
|
34
|
+
|
35
|
+
def ==(other)
|
36
|
+
id == other.id
|
37
|
+
end
|
38
|
+
|
39
|
+
alias_method :eql?, :==
|
40
|
+
|
41
|
+
def hash
|
42
|
+
id.hash
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'digest/sha1'
|
2
|
+
|
3
|
+
require 'vcs_toolkit/exceptions'
|
4
|
+
require 'vcs_toolkit/objects/object'
|
5
|
+
|
6
|
+
module VCSToolkit
|
7
|
+
module Objects
|
8
|
+
|
9
|
+
class Tree < Object
|
10
|
+
|
11
|
+
attr_reader :files, :trees
|
12
|
+
serialize_on :id, :object_type, :files, :trees
|
13
|
+
|
14
|
+
def initialize(files:, trees:, id: nil, **context)
|
15
|
+
@files = files
|
16
|
+
@trees = trees
|
17
|
+
|
18
|
+
super id: id,
|
19
|
+
object_type: :tree,
|
20
|
+
**context
|
21
|
+
end
|
22
|
+
|
23
|
+
##
|
24
|
+
# Iterates over all [file, blob_id] pairs recursively
|
25
|
+
# (including files in child trees).
|
26
|
+
#
|
27
|
+
def all_files(object_store, ignore: [])
|
28
|
+
enum_for :yield_all_files, object_store, ignore: ignore
|
29
|
+
end
|
30
|
+
|
31
|
+
##
|
32
|
+
# Finds the object id of a blob or tree by its relative path
|
33
|
+
# to the current tree.
|
34
|
+
#
|
35
|
+
def find(object_store, path)
|
36
|
+
if [nil, '', '/', '.'].include? path
|
37
|
+
id
|
38
|
+
elsif files.key? path
|
39
|
+
files[path]
|
40
|
+
else
|
41
|
+
dir_name, sub_path = path.split('/', 2)
|
42
|
+
|
43
|
+
return nil unless trees.key? dir_name
|
44
|
+
|
45
|
+
subtree = object_store.fetch trees[dir_name]
|
46
|
+
subtree.find(object_store, sub_path)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def yield_all_files(object_store, ignore: [], &block)
|
53
|
+
files.reject { |path| ignored? path, ignore }.each &block
|
54
|
+
|
55
|
+
trees.each do |dir_name, tree_id|
|
56
|
+
tree = object_store.fetch tree_id
|
57
|
+
|
58
|
+
tree.all_files(object_store).each do |file, blob_id|
|
59
|
+
file_path = File.join(dir_name, file)
|
60
|
+
|
61
|
+
yield file_path, blob_id unless ignored?(file_path, ignore) or ignored?(file.split('/').last, ignore)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def ignored?(path, ignores)
|
67
|
+
ignores.any? do |ignore|
|
68
|
+
if ignore.is_a? Regexp
|
69
|
+
ignore =~ path
|
70
|
+
else
|
71
|
+
ignore == path
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def hash_objects
|
77
|
+
[@files.sort, @trees.sort]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,302 @@
|
|
1
|
+
module VCSToolkit
|
2
|
+
|
3
|
+
class Repository
|
4
|
+
attr_reader :object_store, :staging_area,
|
5
|
+
:commit_class, :tree_class, :blob_class, :label_class
|
6
|
+
|
7
|
+
attr_accessor :head, :branch_head
|
8
|
+
|
9
|
+
def initialize(object_store, staging_area, head: nil,
|
10
|
+
commit_class: Objects::Commit,
|
11
|
+
tree_class: Objects::Tree,
|
12
|
+
blob_class: Objects::Blob,
|
13
|
+
label_class: Objects::Label)
|
14
|
+
@object_store = object_store
|
15
|
+
@staging_area = staging_area
|
16
|
+
|
17
|
+
@commit_class = commit_class
|
18
|
+
@tree_class = tree_class
|
19
|
+
@blob_class = blob_class
|
20
|
+
@label_class = label_class
|
21
|
+
|
22
|
+
self.head = head if head
|
23
|
+
end
|
24
|
+
|
25
|
+
def head=(label_or_id)
|
26
|
+
case label_or_id
|
27
|
+
when Objects::Label
|
28
|
+
@head = label_or_id.id
|
29
|
+
when String
|
30
|
+
@head = label_or_id
|
31
|
+
when nil
|
32
|
+
# Ignore. There is no current branch
|
33
|
+
else
|
34
|
+
raise UnknownLabelError
|
35
|
+
end
|
36
|
+
|
37
|
+
@branch_head = get_object(@head).reference_id
|
38
|
+
set_label :head, @head
|
39
|
+
end
|
40
|
+
|
41
|
+
def branch_head=(commit_or_id)
|
42
|
+
case commit_or_id
|
43
|
+
when Objects::Commit
|
44
|
+
@branch_head = commit_or_id.id
|
45
|
+
when String
|
46
|
+
@branch_head = commit_or_id
|
47
|
+
when nil
|
48
|
+
# Ignore. The current branch has no commits
|
49
|
+
else
|
50
|
+
raise UnknownLabelError
|
51
|
+
end
|
52
|
+
|
53
|
+
set_label head, @branch_head if head
|
54
|
+
end
|
55
|
+
|
56
|
+
def commit(message, author, date, ignores: [], parents: nil, **context)
|
57
|
+
tree = create_tree ignores: ignores, **context
|
58
|
+
|
59
|
+
parents = branch_head.nil? ? [] : [branch_head] if parents.nil?
|
60
|
+
|
61
|
+
commit = commit_class.new message: message,
|
62
|
+
tree: tree.id,
|
63
|
+
parents: parents,
|
64
|
+
author: author,
|
65
|
+
date: date,
|
66
|
+
**context
|
67
|
+
|
68
|
+
object_store.store commit.id, commit
|
69
|
+
self.branch_head = commit
|
70
|
+
|
71
|
+
commit
|
72
|
+
end
|
73
|
+
|
74
|
+
##
|
75
|
+
# Return the object with this object_id or nil if it doesn't exist.
|
76
|
+
#
|
77
|
+
def get_object(object_id)
|
78
|
+
object_store.fetch object_id if object_store.key? object_id
|
79
|
+
end
|
80
|
+
|
81
|
+
alias_method :[], :get_object
|
82
|
+
|
83
|
+
##
|
84
|
+
# Return new, changed and deleted files
|
85
|
+
# compared to a specific commit and the staging area.
|
86
|
+
#
|
87
|
+
# The return value is a hash with :created, :changed and :deleted keys.
|
88
|
+
#
|
89
|
+
def status(commit, ignore: [])
|
90
|
+
tree = get_object(commit.tree) unless commit.nil?
|
91
|
+
|
92
|
+
Utils::Status.compare_tree_and_store tree,
|
93
|
+
staging_area,
|
94
|
+
object_store,
|
95
|
+
ignore: ignore
|
96
|
+
end
|
97
|
+
|
98
|
+
##
|
99
|
+
# Return new, changed and deleted files
|
100
|
+
# by comparing two commits.
|
101
|
+
#
|
102
|
+
# The return value is a hash with :created, :changed and :deleted keys.
|
103
|
+
#
|
104
|
+
def commit_status(base_commit, new_commit, ignore: [])
|
105
|
+
base_tree = get_object(base_commit.tree) unless base_commit.nil?
|
106
|
+
new_tree = get_object(new_commit.tree) unless new_commit.nil?
|
107
|
+
|
108
|
+
Utils::Status.compare_trees base_tree,
|
109
|
+
new_tree,
|
110
|
+
object_store,
|
111
|
+
ignore: ignore
|
112
|
+
end
|
113
|
+
|
114
|
+
##
|
115
|
+
# Enumerate all commits beginning with branch_head and ending
|
116
|
+
# with the commits that have empty `parents` list.
|
117
|
+
#
|
118
|
+
# They aren't strictly ordered by date, but in a BFS visit order.
|
119
|
+
#
|
120
|
+
def history
|
121
|
+
return [] if branch_head.nil?
|
122
|
+
|
123
|
+
get_object(branch_head).history(object_store)
|
124
|
+
end
|
125
|
+
|
126
|
+
##
|
127
|
+
# Merge two commits and save the changes to the staging area.
|
128
|
+
#
|
129
|
+
def merge(commit_one, commit_two)
|
130
|
+
common_ancestor = commit_one.common_ancestor(commit_two, object_store)
|
131
|
+
commit_one_files = Hash[get_object(commit_one.tree).all_files(object_store).to_a]
|
132
|
+
commit_two_files = Hash[get_object(commit_two.tree).all_files(object_store).to_a]
|
133
|
+
|
134
|
+
if common_ancestor.nil?
|
135
|
+
ancestor_files = {}
|
136
|
+
else
|
137
|
+
ancestor_files = Hash[get_object(common_ancestor.tree).all_files(object_store).to_a]
|
138
|
+
end
|
139
|
+
|
140
|
+
all_files = commit_one_files.keys | commit_two_files.keys | ancestor_files.keys
|
141
|
+
|
142
|
+
merged = []
|
143
|
+
conflicted = []
|
144
|
+
|
145
|
+
all_files.each do |file|
|
146
|
+
ancestor = ancestor_files.key?(file) ? get_object(ancestor_files[file]).content.lines : []
|
147
|
+
file_one = commit_one_files.key?(file) ? get_object(commit_one_files[file]).content.lines : []
|
148
|
+
file_two = commit_two_files.key?(file) ? get_object(commit_two_files[file]).content.lines : []
|
149
|
+
|
150
|
+
diff = VCSToolkit::Merge.three_way ancestor, file_one, file_two
|
151
|
+
|
152
|
+
if diff.has_conflicts?
|
153
|
+
conflicted << file
|
154
|
+
elsif diff.has_changes?
|
155
|
+
merged << file
|
156
|
+
end
|
157
|
+
|
158
|
+
content = diff.new_content("<<<<< #{commit_one.id}\n", ">>>>> #{commit_two.id}\n", "=====\n")
|
159
|
+
|
160
|
+
if content.empty?
|
161
|
+
staging_area.delete_file file if staging_area.file? file
|
162
|
+
else
|
163
|
+
staging_area.store file, content.join('')
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
{merged: merged, conflicted: conflicted}
|
168
|
+
end
|
169
|
+
|
170
|
+
##
|
171
|
+
# Return a list of changes between a file in the staging area
|
172
|
+
# and a specific commit.
|
173
|
+
#
|
174
|
+
# This method is just a tiny wrapper around VCSToolkit::Diff.from_sequences
|
175
|
+
# which loads the two files and splits them by lines beforehand.
|
176
|
+
# It also ensures that both files have \n at the end (otherwise the last
|
177
|
+
# two lines of the diff may be merged).
|
178
|
+
#
|
179
|
+
def file_difference(file_path, commit)
|
180
|
+
if staging_area.file? file_path
|
181
|
+
file_lines = staging_area.fetch(file_path).lines
|
182
|
+
file_lines.last << "\n" unless file_lines.last.nil? or file_lines.last.end_with? "\n"
|
183
|
+
else
|
184
|
+
file_lines = []
|
185
|
+
end
|
186
|
+
|
187
|
+
tree = get_object commit.tree
|
188
|
+
|
189
|
+
blob_name_and_id = tree.all_files(object_store).find { |file, _| file_path == file }
|
190
|
+
|
191
|
+
if blob_name_and_id.nil?
|
192
|
+
blob_lines = []
|
193
|
+
else
|
194
|
+
blob = get_object blob_name_and_id.last
|
195
|
+
blob_lines = blob.content.lines
|
196
|
+
blob_lines.last << "\n" unless blob_lines.last.nil? or blob_lines.last.end_with? "\n"
|
197
|
+
end
|
198
|
+
|
199
|
+
Diff.from_sequences blob_lines, file_lines
|
200
|
+
end
|
201
|
+
|
202
|
+
def restore(path='', commit)
|
203
|
+
tree = get_object commit.tree
|
204
|
+
object_id = tree.find(object_store, path)
|
205
|
+
|
206
|
+
raise KeyError, 'File does not exist in the specified commit' if object_id.nil?
|
207
|
+
|
208
|
+
blob_or_tree = get_object object_id
|
209
|
+
|
210
|
+
case blob_or_tree.object_type
|
211
|
+
when :blob
|
212
|
+
restore_file path, blob_or_tree
|
213
|
+
when :tree
|
214
|
+
restore_directory path, blob_or_tree
|
215
|
+
else
|
216
|
+
raise 'Unknown object type returned by Tree#find'
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
##
|
221
|
+
# Create a label (named object) pointing to `reference_id`
|
222
|
+
#
|
223
|
+
# If the label already exists it is overriden.
|
224
|
+
#
|
225
|
+
def set_label(name, reference_id)
|
226
|
+
label = label_class.new id: name, reference_id: reference_id
|
227
|
+
|
228
|
+
object_store.store name, label
|
229
|
+
end
|
230
|
+
|
231
|
+
private
|
232
|
+
|
233
|
+
def restore_directory(path, tree)
|
234
|
+
tree.all_files(object_store).each do |file, blob_id|
|
235
|
+
restore_file File.join(path, file), get_object(blob_id)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
def restore_file(path, blob)
|
240
|
+
staging_area.store path, blob.content
|
241
|
+
end
|
242
|
+
|
243
|
+
protected
|
244
|
+
|
245
|
+
def create_tree(path='', ignore: [/^\./], **context)
|
246
|
+
files = staging_area.files(path, ignore: ignore).each_with_object({}) do |file_name, files|
|
247
|
+
file_path = concat_path path, file_name
|
248
|
+
|
249
|
+
next if ignored? file_path, ignore
|
250
|
+
|
251
|
+
files[file_name] = blob_class.new content: staging_area.fetch(file_path), **context
|
252
|
+
end
|
253
|
+
|
254
|
+
trees = staging_area.directories(path, ignore: ignore).each_with_object({}) do |dir_name, trees|
|
255
|
+
dir_path = concat_path path, dir_name
|
256
|
+
|
257
|
+
next if ignored? dir_path, ignore
|
258
|
+
|
259
|
+
trees[dir_name] = create_tree dir_path, **context
|
260
|
+
end
|
261
|
+
|
262
|
+
files.each do |name, file|
|
263
|
+
object_store.store file.id, file unless object_store.key? file.id
|
264
|
+
|
265
|
+
files[name] = file.id
|
266
|
+
end
|
267
|
+
trees.each do |name, tree|
|
268
|
+
trees[name] = tree.id
|
269
|
+
end
|
270
|
+
|
271
|
+
tree = tree_class.new files: files,
|
272
|
+
trees: trees,
|
273
|
+
**context
|
274
|
+
|
275
|
+
object_store.store tree.id, tree unless object_store.key? tree.id
|
276
|
+
|
277
|
+
tree
|
278
|
+
end
|
279
|
+
|
280
|
+
private
|
281
|
+
|
282
|
+
def ignored?(path, ignores)
|
283
|
+
ignores.any? do |ignore|
|
284
|
+
if ignore.is_a? Regexp
|
285
|
+
ignore =~ path
|
286
|
+
else
|
287
|
+
ignore == path
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
def concat_path(directory, file)
|
293
|
+
return file if directory.empty?
|
294
|
+
|
295
|
+
file = file.sub(/^\/+/, '')
|
296
|
+
directory = directory.sub(/\/+$/, '')
|
297
|
+
|
298
|
+
"#{directory}/#{file}"
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
end
|