bringit 1.0.0

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