bringit 1.0.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,24 @@
1
+ # Bringit::CommitStats counts the additions, deletions, and total changes
2
+ # in a commit.
3
+ module Bringit
4
+ class CommitStats
5
+ attr_reader :id, :additions, :deletions, :total
6
+
7
+ # Instantiate a CommitStats object
8
+ def initialize(commit)
9
+ @id = commit.id
10
+ @additions = 0
11
+ @deletions = 0
12
+ @total = 0
13
+
14
+ diff = commit.diff_from_parent
15
+
16
+ diff.each_patch do |p|
17
+ # TODO: Use the new Rugged convenience methods when they're released
18
+ @additions += p.stat[0]
19
+ @deletions += p.stat[1]
20
+ @total += p.changes
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "committing/merge"
4
+
5
+ module Bringit
6
+ # Methods for committing. Use all these methods only mutexed with the git
7
+ # repository as the key.
8
+ module Committing
9
+ include Bringit::Committing::Merge
10
+
11
+ class Error < StandardError; end
12
+ class InvalidPathError < Error; end
13
+
14
+ # This error is thrown when attempting to commit on a branch whose HEAD has
15
+ # changed.
16
+ class HeadChangedError < Error
17
+ attr_reader :conflicts, :options
18
+ def initialize(message, conflicts, options)
19
+ super(message)
20
+ @conflicts = conflicts
21
+ @options = options
22
+ end
23
+ end
24
+
25
+ # Create a file in repository and return commit sha
26
+ #
27
+ # options should contain the following structure:
28
+ # file: {
29
+ # content: 'Lorem ipsum...',
30
+ # path: 'documents/story.txt'
31
+ # },
32
+ # author: {
33
+ # email: 'user@example.com',
34
+ # name: 'Test User',
35
+ # time: Time.now # optional - default: Time.now
36
+ # },
37
+ # committer: {
38
+ # email: 'user@example.com',
39
+ # name: 'Test User',
40
+ # time: Time.now # optional - default: Time.now
41
+ # },
42
+ # commit: {
43
+ # message: 'Wow such commit',
44
+ # branch: 'master', # optional - default: 'master'
45
+ # update_ref: false # optional - default: true
46
+ # }
47
+ def create_file(options, previous_head_sha = nil)
48
+ commit_multichange(convert_options(options, :create), previous_head_sha)
49
+ end
50
+
51
+ # Change the contents of a file in repository and return commit sha
52
+ #
53
+ # options should contain the following structure:
54
+ # file: {
55
+ # content: 'Lorem ipsum...',
56
+ # path: 'documents/story.txt'
57
+ # },
58
+ # author: {
59
+ # email: 'user@example.com',
60
+ # name: 'Test User',
61
+ # time: Time.now # optional - default: Time.now
62
+ # },
63
+ # committer: {
64
+ # email: 'user@example.com',
65
+ # name: 'Test User',
66
+ # time: Time.now # optional - default: Time.now
67
+ # },
68
+ # commit: {
69
+ # message: 'Wow such commit',
70
+ # branch: 'master', # optional - default: 'master'
71
+ # update_ref: false # optional - default: true
72
+ # }
73
+ def update_file(options, previous_head_sha = nil)
74
+ commit_multichange(convert_options(options, :update), previous_head_sha)
75
+ end
76
+
77
+ # Change contents and path of a file in repository and return commit sha
78
+ #
79
+ # options should contain the following structure:
80
+ # file: {
81
+ # content: 'Lorem ipsum...',
82
+ # path: 'documents/story.txt',
83
+ # previous_path: 'documents/old_story.txt'
84
+ # },
85
+ # author: {
86
+ # email: 'user@example.com',
87
+ # name: 'Test User',
88
+ # time: Time.now # optional - default: Time.now
89
+ # },
90
+ # committer: {
91
+ # email: 'user@example.com',
92
+ # name: 'Test User',
93
+ # time: Time.now # optional - default: Time.now
94
+ # },
95
+ # commit: {
96
+ # message: 'Wow such commit',
97
+ # branch: 'master', # optional - default: 'master'
98
+ # update_ref: false # optional - default: true
99
+ # }
100
+ def rename_and_update_file(options, previous_head_sha = nil)
101
+ commit_multichange(convert_options(options, :rename_and_update), previous_head_sha)
102
+ end
103
+
104
+ # Remove file from repository and return commit sha
105
+ #
106
+ # options should contain the following structure:
107
+ # file: {
108
+ # path: 'documents/story.txt'
109
+ # },
110
+ # author: {
111
+ # email: 'user@example.com',
112
+ # name: 'Test User',
113
+ # time: Time.now # optional - default: Time.now
114
+ # },
115
+ # committer: {
116
+ # email: 'user@example.com',
117
+ # name: 'Test User',
118
+ # time: Time.now # optional - default: Time.now
119
+ # },
120
+ # commit: {
121
+ # message: 'Remove FILENAME',
122
+ # branch: 'master' # optional - default: 'master'
123
+ # }
124
+ def remove_file(options, previous_head_sha = nil)
125
+ commit_multichange(convert_options(options, :remove), previous_head_sha)
126
+ end
127
+
128
+ # Rename file from repository and return commit sha
129
+ # This does not change the file content.
130
+ #
131
+ # options should contain the following structure:
132
+ # file: {
133
+ # previous_path: 'documents/old_story.txt'
134
+ # path: 'documents/story.txt'
135
+ # },
136
+ # author: {
137
+ # email: 'user@example.com',
138
+ # name: 'Test User',
139
+ # time: Time.now # optional - default: Time.now
140
+ # },
141
+ # committer: {
142
+ # email: 'user@example.com',
143
+ # name: 'Test User',
144
+ # time: Time.now # optional - default: Time.now
145
+ # },
146
+ # commit: {
147
+ # message: 'Rename FILENAME',
148
+ # branch: 'master' # optional - default: 'master'
149
+ # }
150
+ #
151
+ def rename_file(options, previous_head_sha = nil)
152
+ commit_multichange(convert_options(options, :rename), previous_head_sha)
153
+ end
154
+
155
+ # Create a new directory with a .gitkeep file. Creates
156
+ # all required nested directories (i.e. mkdir -p behavior)
157
+ #
158
+ # options should contain the following structure:
159
+ # author: {
160
+ # email: 'user@example.com',
161
+ # name: 'Test User',
162
+ # time: Time.now # optional - default: Time.now
163
+ # },
164
+ # committer: {
165
+ # email: 'user@example.com',
166
+ # name: 'Test User',
167
+ # time: Time.now # optional - default: Time.now
168
+ # },
169
+ # commit: {
170
+ # message: 'Wow such commit',
171
+ # branch: 'master', # optional - default: 'master'
172
+ # update_ref: false # optional - default: true
173
+ # }
174
+ def mkdir(path, options, previous_head_sha = nil)
175
+ options[:file] = {path: path}
176
+ commit_multichange(convert_options(options, :mkdir), previous_head_sha)
177
+ end
178
+
179
+ # Apply multiple file changes to the repository
180
+ #
181
+ # options should contain the following structure:
182
+ # files: {
183
+ # [{content: 'Lorem ipsum...',
184
+ # path: 'documents/story.txt',
185
+ # action: :create},
186
+ # {content: 'New Lorem ipsum...',
187
+ # path: 'documents/old_story',
188
+ # action: :update},
189
+ # {content: 'New Lorem ipsum...',
190
+ # previous_path: 'documents/really_old_story.txt',
191
+ # path: 'documents/old_story',
192
+ # action: :rename_and_update},
193
+ # {path: 'documents/obsolet_story.txt',
194
+ # action: :remove},
195
+ # {path: 'documents/old_story',
196
+ # previus_path: 'documents/really_old_story.txt',
197
+ # action: :rename},
198
+ # {path: 'documents/secret',
199
+ # action: :mkdir}
200
+ # ]
201
+ # }
202
+ # },
203
+ # author: {
204
+ # email: 'user@example.com',
205
+ # name: 'Test User',
206
+ # time: Time.now # optional - default: Time.now
207
+ # },
208
+ # committer: {
209
+ # email: 'user@example.com',
210
+ # name: 'Test User',
211
+ # time: Time.now # optional - default: Time.now
212
+ # },
213
+ # commit: {
214
+ # message: 'Wow such commit',
215
+ # branch: 'master', # optional - default: 'master'
216
+ # update_ref: false # optional - default: true
217
+ # }
218
+ def commit_multichange(options, previous_head_sha = nil)
219
+ commit_with(options, previous_head_sha) do |index|
220
+ options[:files].each do |file|
221
+ file_options = {}
222
+ file_options[:file_path] = file[:path] if file[:path]
223
+ file_options[:content] = file[:content] if file[:content]
224
+ file_options[:encoding] = file[:encoding] if file[:encoding]
225
+ case file[:action]
226
+ when :create
227
+ index.create(file_options)
228
+ when :rename
229
+ file_options[:previous_path] = file[:previous_path]
230
+ file_options[:content] ||=
231
+ blob(options[:commit][:branch], file[:previous_path]).data
232
+ index.move(file_options)
233
+ when :update
234
+ index.update(file_options)
235
+ when :rename_and_update
236
+ previous_path = file[:previous_path]
237
+ file_options[:previous_path] = previous_path
238
+ index.move(file_options)
239
+ when :remove
240
+ index.delete(file_options)
241
+ when :mkdir
242
+ index.create_dir(file_options)
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ protected
249
+
250
+
251
+ # Converts the options from a single change commit to a multi change
252
+ # commit.
253
+ def convert_options(options, action)
254
+ converted = options.dup
255
+ converted.delete(:file)
256
+ converted[:files] = [options[:file].merge(action: action)]
257
+ converted
258
+ end
259
+
260
+ def insert_defaults(options)
261
+ options[:author][:time] ||= Time.now
262
+ options[:committer][:time] ||= Time.now
263
+ options[:commit][:branch] ||= 'master'
264
+ options[:commit][:update_ref] = true if options[:commit][:update_ref].nil?
265
+ normalize_ref(options)
266
+ normalize_update_ref(options)
267
+ end
268
+
269
+ def normalize_ref(options)
270
+ return if options[:commit][:branch].start_with?('refs/')
271
+ options[:commit][:branch] = 'refs/heads/' + options[:commit][:branch]
272
+ end
273
+
274
+ def normalize_update_ref(options)
275
+ options[:commit][:update_ref] =
276
+ if options[:commit][:update_ref].nil?
277
+ true
278
+ else
279
+ options[:commit][:update_ref]
280
+ end
281
+ end
282
+
283
+ # This method does the actual committing. Use this mutexed with the git
284
+ # repository as the key.
285
+ # rubocop:disable Metrics/AbcSize
286
+ # rubocop:disable Metrics/CyclomaticComplexity
287
+ # rubocop:disable Metrics/PerceivedComplexity
288
+ # rubocop:disable Metrics/MethodLength
289
+ def commit_with(options, previous_head_sha)
290
+ # rubocop:enable Metrics/AbcSize
291
+ # rubocop:enable Metrics/CyclomaticComplexity
292
+ # rubocop:enable Metrics/PerceivedComplexity
293
+ # rubocop:enable Metrics/MethodLength
294
+ insert_defaults(options)
295
+ action, commit_sha = merge_if_needed(options, previous_head_sha)
296
+ return commit_sha if action == :merge_commit_created
297
+
298
+ index = Bringit::Index.new(bringit)
299
+ parents, last_commit = parents_and_last_commit(options)
300
+ index.read_tree(last_commit.tree) if last_commit
301
+
302
+ yield(index)
303
+ create_commit(index, index.write_tree, options, parents)
304
+ end
305
+
306
+ def parents_and_last_commit(options)
307
+ parents = []
308
+ last_commit = nil
309
+ unless empty?
310
+ rugged_ref = rugged.references[options[:commit][:branch]]
311
+ unless rugged_ref
312
+ raise Bringit::Repository::InvalidRef, 'Invalid branch name'
313
+ end
314
+ last_commit = rugged_ref.target
315
+ parents = [last_commit]
316
+ end
317
+ [parents, last_commit]
318
+ end
319
+
320
+ def create_commit(index, tree, options, parents)
321
+ opts = {}
322
+ opts[:tree] = tree
323
+ opts[:author] = options[:author]
324
+ opts[:committer] = options[:committer]
325
+ opts[:message] = options[:commit][:message]
326
+ opts[:parents] = parents
327
+ if options[:commit][:update_ref]
328
+ opts[:update_ref] = options[:commit][:branch]
329
+ end
330
+
331
+ Rugged::Commit.create(rugged, opts)
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bringit
4
+ module Committing
5
+ # Methods for merging a commit into another if the previous_head_sha is
6
+ # an ancestor of the current HEAD of a branch.
7
+ module Merge
8
+ def merge_if_needed(options, previous_head_sha)
9
+ return [:noop, nil] unless diverged?(options, previous_head_sha)
10
+
11
+ commit_sha = merge(options, previous_head_sha)
12
+ return [:merge_commit_created, commit_sha]
13
+ end
14
+
15
+ def diverged?(options, previous_head_sha)
16
+ !previous_head_sha.nil? &&
17
+ branch_sha(options[:commit][:branch]) != previous_head_sha
18
+ end
19
+
20
+ def merge(options, previous_head_sha)
21
+ user_commit = create_user_commit(options, previous_head_sha)
22
+ base_commit = commit(options[:commit][:branch]).raw_commit
23
+
24
+ index = rugged.merge_commits(base_commit, user_commit)
25
+
26
+ if index.conflicts?
27
+ enriched_conflicts = add_merge_data(options, index)
28
+ raise_head_changed_error(enriched_conflicts, options)
29
+ end
30
+ tree_id = index.write_tree(rugged)
31
+ found_conflicts = conflicts(options, base_commit, user_commit)
32
+ if found_conflicts.any?
33
+ raise_head_changed_error(found_conflicts, options)
34
+ end
35
+ create_merging_commit(base_commit, index, tree_id, options)
36
+ end
37
+
38
+ def add_merge_data(options, index)
39
+ our_label = options[:commit][:branch].sub(%r{\Arefs/heads/}, '')
40
+ index.conflicts.map do |conflict|
41
+ if conflict[:ancestor] && conflict[:ours] && conflict[:theirs]
42
+ conflict[:merge_info] =
43
+ index.merge_file(conflict[:ours][:path],
44
+ ancestor_label: 'parent',
45
+ our_label: our_label,
46
+ their_label: options[:commit][:message])
47
+ else
48
+ conflict[:merge_info] = nil
49
+ end
50
+ conflict
51
+ end
52
+ end
53
+
54
+ def conflicts(options, base_commit, user_commit)
55
+ options[:files].map do |file|
56
+ case file[:action]
57
+ when :update
58
+ conflict_on_update(base_commit, user_commit, file[:path])
59
+ when :rename_and_update
60
+ conflict_on_update(base_commit, user_commit, file[:previous_path])
61
+ end
62
+ end.compact
63
+ end
64
+
65
+ def conflict_on_update(base_commit, user_commit, path)
66
+ base_blob = blob(base_commit.oid, path)
67
+ return nil unless base_blob.nil?
68
+
69
+ ancestor_blob = blob(base_commit.parents.first.oid, path)
70
+ user_blob = blob(user_commit.oid, path)
71
+ result = {merge_info: nil, ours: nil}
72
+ result[:ancestor] = conflict_hash(ancestor_blob, 1) if ancestor_blob
73
+ result[:theirs] = conflict_hash(user_blob, 3) if user_blob
74
+ result
75
+ end
76
+
77
+ def conflict_hash(blob_object, stage)
78
+ {path: blob_object.path,
79
+ oid: blob_object.id,
80
+ dev: 0,
81
+ ino: 0,
82
+ mode: blob_object.mode.to_i(8),
83
+ gid: 0,
84
+ uid: 0,
85
+ file_size: 0,
86
+ valid: false,
87
+ stage: stage,
88
+ ctime: Time.at(0),
89
+ mtime: Time.at(0)}
90
+ end
91
+
92
+ def create_user_commit(options, previous_head_sha)
93
+ with_temp_user_reference(options, previous_head_sha) do |reference|
94
+ new_options = options.dup
95
+ new_options[:commit] = options[:commit].dup
96
+ new_options[:commit][:branch] = reference.name
97
+ new_options[:commit][:update_ref] = false
98
+ commit_sha = commit_multichange(new_options)
99
+ rugged.lookup(commit_sha)
100
+ end
101
+ end
102
+
103
+ def with_temp_user_reference(options, previous_head_sha)
104
+ refname = "#{Time.now.to_f.to_s.tr('.', '')}_#{SecureRandom.hex(20)}"
105
+ full_refname = "refs/merges/user/#{refname}"
106
+ reference = rugged.references.create(full_refname, previous_head_sha)
107
+ yield(reference)
108
+ ensure
109
+ rugged.references.delete(reference)
110
+ end
111
+
112
+ def create_merging_commit(parent_commit, index, tree_id, options)
113
+ parents = [parent_commit.oid]
114
+ create_commit(index, tree_id, options, parents)
115
+ end
116
+
117
+ def raise_head_changed_error(conflicts, options)
118
+ message = <<MESSAGE
119
+ The branch has changed since editing and cannot be merged automatically.
120
+ MESSAGE
121
+ raise HeadChangedError.new(message, conflicts, options)
122
+ end
123
+ end
124
+ end
125
+ end