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.
@@ -0,0 +1,5 @@
1
+ require 'vcs_toolkit/objects/object'
2
+ require 'vcs_toolkit/objects/blob'
3
+ require 'vcs_toolkit/objects/tree'
4
+ require 'vcs_toolkit/objects/commit'
5
+ require 'vcs_toolkit/objects/label'
@@ -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