vcs_toolkit 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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