silo 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,58 @@
1
+ # This code is free software; you can redistribute it and/or modify it under
2
+ # the terms of the new BSD License.
3
+ #
4
+ # Copyright (c) 2010, Sebastian Staudt
5
+
6
+ module Silo
7
+
8
+ # Raised when trying to initialize a Silo repository in a path where another
9
+ # Silo repository already exists.
10
+ #
11
+ # @author Sebastian Staudt
12
+ # @since 0.1.0
13
+ class AlreadyPreparedError < StandardError
14
+ end
15
+
16
+ # Raised when trying to restore files from a repository that do not exist.
17
+ #
18
+ # @author Sebastian Staudt
19
+ # @see Repository#restore
20
+ # @since 0.1.0
21
+ class FileNotFoundError < StandardError
22
+
23
+ # Creates an instance of FileNotFoundError for the given file path
24
+ #
25
+ # @param [String] path The path of the file that does not exist in the
26
+ # repository
27
+ def initialize(path)
28
+ super "File not found: '#{path}'"
29
+ end
30
+
31
+ end
32
+
33
+ # Raised when trying to initializa a Silo repository in a path where another
34
+ # Git repository exists, that contains non-Silo data.
35
+ #
36
+ # @author Sebastian Staudt
37
+ # @since 0.1.0
38
+ class InvalidRepositoryError < StandardError
39
+ end
40
+
41
+ # Raised when trying to access a remote by name that has no been defined for
42
+ # the repository
43
+ #
44
+ # @author Sebastian Staudt
45
+ # @see Remote
46
+ # @since 0.1.0
47
+ class UndefinedRemoteError < StandardError
48
+
49
+ # Creates an instance of UndefinedRemoteError for the given name
50
+ #
51
+ # @param [String] name The name of the undefined remote
52
+ def initialize(name)
53
+ super "No remote with name '#{name}' defined."
54
+ end
55
+
56
+ end
57
+
58
+ end
@@ -0,0 +1,33 @@
1
+ # This code is free software; you can redistribute it and/or modify it under
2
+ # the terms of the new BSD License.
3
+ #
4
+ # Copyright (c) 2011, Sebastian Staudt
5
+
6
+ module Silo
7
+
8
+ module Remote
9
+
10
+ # This class represents a standard Git remote attached to the Git
11
+ # repository backing the Silo repository
12
+ class Base
13
+
14
+ # @return [String] The name of this remote
15
+ attr_reader :name
16
+
17
+ # @return [String] The URL of this remote
18
+ attr_reader :url
19
+
20
+ # Creates a new remote with the specified name
21
+ #
22
+ # @param [Repository] repo The Silo repository this remote belongs to
23
+ # @param [String] name The name of the remote
24
+ def initialize(repo, name)
25
+ @name = name
26
+ @repo = repo
27
+ end
28
+
29
+ end
30
+
31
+ end
32
+
33
+ end
@@ -0,0 +1,50 @@
1
+ # This code is free software; you can redistribute it and/or modify it under
2
+ # the terms of the new BSD License.
3
+ #
4
+ # Copyright (c) 2010-2011, Sebastian Staudt
5
+
6
+ require 'silo/remote/base'
7
+
8
+ module Silo
9
+
10
+ module Remote
11
+
12
+ # This class represents a standard Git remote attached to the Git
13
+ # repository backing the Silo repository
14
+ #
15
+ # @see Repository
16
+ class Git < Base
17
+
18
+ # Creates a new Git remote
19
+ #
20
+ # @param [Repository] repo The Silo repository this remote belongs to
21
+ # @param [String] name The name of the remote
22
+ # @param [String] url The URL of the remote Git repository. This may use
23
+ # any protocol supported by Git (+git:+, +file:+, +http(s):+)
24
+ def initialize(repo, name, url)
25
+ super repo, name
26
+
27
+ @url = url
28
+ end
29
+
30
+ # Adds this remote as a mirror to the backing Git repository
31
+ def add
32
+ @repo.git.git.remote({}, 'add', '--mirror', @name, @url)
33
+ end
34
+
35
+ # Pushes the current history of the repository to the remote repository
36
+ # using `git push`
37
+ def push
38
+ @repo.git.git.push({}, @name)
39
+ end
40
+
41
+ # Removes this remote from the backing Git repository
42
+ def remove
43
+ @repo.git.git.remote({}, 'rm', @name)
44
+ end
45
+
46
+ end
47
+
48
+ end
49
+
50
+ end
@@ -0,0 +1,366 @@
1
+ # This code is free software; you can redistribute it and/or modify it under
2
+ # the terms of the new BSD License.
3
+ #
4
+ # Copyright (c) 2010-2011, Sebastian Staudt
5
+
6
+ require 'tmpdir'
7
+
8
+ require 'rubygems'
9
+ require 'grit'
10
+
11
+ module Silo
12
+
13
+ # Represents a Silo repository
14
+ #
15
+ # This provides the core features of Silo to initialize a repository and work
16
+ # with it.
17
+ #
18
+ # @author Sebastian Staudt
19
+ # @since 0.1.0
20
+ class Repository
21
+
22
+ # @return [Grit::Repo] The Grit object to access the Git repository
23
+ attr_reader :git
24
+
25
+ # @return [String] The file system path of the repository
26
+ attr_reader :path
27
+
28
+ # @return [Hash<Remote::Base>] The remote repositories configured for this
29
+ # repository
30
+ attr_reader :remotes
31
+
32
+ # Creates a new repository instance on the given path
33
+ #
34
+ # @param [Hash] options A hash of options
35
+ # @option options [Boolean] :create (true) Creates the backing Git
36
+ # repository if it does not already exist
37
+ # @option options [Boolean] :prepare (true) Prepares the backing Git
38
+ # repository for use with Silo if not already done
39
+ #
40
+ # @raise [Grit::InvalidGitRepositoryError] if the path exists, but is not a
41
+ # valid Git repository
42
+ # @raise [Grit::NoSuchPathError] if the path does not exist and option
43
+ # :create is +false+
44
+ # @raise [InvalidRepositoryError] if the path contains another Git
45
+ # repository that does not contain data managed by Silo.
46
+ def initialize(path, options = {})
47
+ options = {
48
+ :create => true,
49
+ :prepare => true
50
+ }.merge options
51
+
52
+ @path = File.expand_path path
53
+
54
+ if File.exist?(@path) && Dir.new(@path).count > 2
55
+ unless File.exist?(File.join(@path, 'HEAD')) &&
56
+ File.stat(File.join(@path, 'objects')).directory? &&
57
+ File.stat(File.join(@path, 'refs')).directory?
58
+ raise Grit::InvalidGitRepositoryError.new(@path)
59
+ end
60
+ @git = Grit::Repo.new(@path, { :is_bare => true })
61
+ else
62
+ if options[:create]
63
+ @git = Grit::Repo.init_bare(@path, {}, { :is_bare => true })
64
+ else
65
+ raise Grit::NoSuchPathError.new(@path)
66
+ end
67
+ end
68
+
69
+ if !prepared? && @git.commit_count > 0
70
+ raise InvalidRepositoryError.new(@path)
71
+ end
72
+
73
+ @remotes = {}
74
+ @remotes.merge! git_remotes
75
+
76
+ prepare if options[:prepare] && !prepared?
77
+ end
78
+
79
+ # Stores a file or full directory structure into the repository inside an
80
+ # optional prefix path
81
+ #
82
+ # This adds one commit to the history of the repository including the file
83
+ # or directory structure. If the file or directory already existed inside
84
+ # the prefix, Git will only save the changes.
85
+ #
86
+ # @param [String] path The path of the file or directory to store into the
87
+ # repository
88
+ # @param [String] prefix An optional prefix where the file is stored inside
89
+ # the repository
90
+ def add(path, prefix = nil)
91
+ path = File.expand_path path
92
+ prefix ||= '/'
93
+ in_work_tree File.dirname(path) do
94
+ index = @git.index
95
+ index.read_tree 'HEAD'
96
+ add = lambda do |f, p|
97
+ file = File.basename f
98
+ pre = (p == '/') ? file : File.join(p, file)
99
+ dir = File.stat(f).directory?
100
+ if dir
101
+ Dir.entries(f)[2..-1].each do |child|
102
+ add.call File.join(f, child), pre
103
+ end
104
+ else
105
+ index.add pre, IO.read(f)
106
+ end
107
+ dir
108
+ end
109
+ dir = add.call path, prefix
110
+ type = dir ? 'directory' : 'file'
111
+ commit_msg = "Added #{type} #{path} into '#{prefix}'"
112
+ index.commit commit_msg, @git.head.commit.sha
113
+ end
114
+ end
115
+
116
+ # Adds a new remote to this Repository
117
+ #
118
+ # @param [String] name The name of the remote to add
119
+ # @param [String] url The URL of the remote repository
120
+ # @see Remote
121
+ def add_remote(name, url)
122
+ @remotes[name] = Remote::Git.new(self, name, url)
123
+ @remotes[name].add
124
+ end
125
+
126
+ # Gets a list of files and directories in the specified path inside the
127
+ # repository
128
+ #
129
+ # @param [String] path The path to search for inside the repository
130
+ # @return [Array<String>] All files and directories found in the specidied
131
+ # path
132
+ def contents(path = nil)
133
+ contents = []
134
+
135
+ object = find_object(path || '/')
136
+ contents << path unless path.nil? || object.nil?
137
+ if object.is_a? Grit::Tree
138
+ (object.blobs + object.trees).each do |obj|
139
+ contents += contents(path.nil? ? obj.basename : File.join(path, obj.basename))
140
+ end
141
+ end
142
+
143
+ contents
144
+ end
145
+
146
+ # Push the current state of the repository to each attached remote
147
+ # repository
148
+ #
149
+ # @see Remote::Git#push
150
+ def distribute
151
+ @remotes.each_value { |remote| remote.push }
152
+ end
153
+
154
+ # Run a block of code with +$GIT_WORK_TREE+ set to a specified path
155
+ #
156
+ # This executes a block of code while the environment variable
157
+ # +$GIT_WORK_TREE+ is set to a specified path or alternatively the path of
158
+ # a temporary directory.
159
+ #
160
+ # @param [String, :tmp] path A path or +:tmp+ which will create a temporary
161
+ # directory that will be removed afterwards
162
+ # @yield [path] The code inside this block will be executed with
163
+ # +$GIT_WORK_TREE+ set
164
+ # @yieldparam [String] path The absolute path used for +$GIT_WORK_TREE+
165
+ def in_work_tree(path = '.')
166
+ tmp_dir = path == :tmp
167
+ path = tmp_dir ? Dir.mktmpdir : File.expand_path(path)
168
+ old_work_tree = ENV['GIT_WORK_TREE']
169
+ ENV['GIT_WORK_TREE'] = path
170
+ Dir.chdir(path) { yield path }
171
+ ENV['GIT_WORK_TREE'] = old_work_tree
172
+ FileUtils.rm_rf path, :secure => true if tmp_dir
173
+ end
174
+
175
+ # Get information about a file or directory in the repository
176
+ #
177
+ # @param [String] path The path of the file or directory to get information
178
+ # about
179
+ # @return [Hash<Symbol, Object>] Information about the requested file or
180
+ # directory.
181
+ def info(path)
182
+ info = {}
183
+ object = object! path
184
+
185
+ info[:history] = history path
186
+ info[:mode] = object.mode
187
+ info[:name] = object.name
188
+ info[:path] = path
189
+ info[:sha] = object.id
190
+
191
+ info[:created] = info[:history].last.committed_date
192
+ info[:modified] = info[:history].first.committed_date
193
+
194
+ if object.is_a? Grit::Blob
195
+ info[:mime] = object.mime_type
196
+ info[:size] = object.size
197
+ info[:type] = :blob
198
+ else
199
+ info[:path] += '/'
200
+ info[:type] = :tree
201
+ end
202
+
203
+ info
204
+ end
205
+
206
+ # Loads remotes from the backing Git repository's configuration
207
+ #
208
+ # @see Remote::Git
209
+ def git_remotes
210
+ remotes = {}
211
+ @git.git.remote.split.each do |remote|
212
+ url = @git.git.config({}, '--get', "remote.#{remote}.url").strip
213
+ remotes[remote] = Remote::Git.new(self, remote, url)
214
+ end
215
+ remotes
216
+ end
217
+
218
+ # Prepares the Git repository backing this Silo repository for use with
219
+ # Silo
220
+ #
221
+ # @raise [AlreadyPreparedError] if the repository has been already prepared
222
+ def prepare
223
+ raise AlreadyPreparedError.new(@path) if prepared?
224
+ in_work_tree :tmp do
225
+ FileUtils.touch '.silo'
226
+ @git.add '.silo'
227
+ @git.commit_index 'Enabled Silo for this repository'
228
+ end
229
+ end
230
+
231
+ # Generate a history of Git commits for either the complete repository or
232
+ # a specified file or directory
233
+ #
234
+ # @param [String] path The path of the file or directory to generate the
235
+ # history for. If +nil+, the history of the entire repository will
236
+ # be returned.
237
+ # @return [Array<Grit::Commit>] The commit history for the repository or
238
+ # given path
239
+ def history(path = nil)
240
+ params = ['--format=raw']
241
+ params += ['--', path] unless path.nil?
242
+ output = @git.git.log({}, *params)
243
+ Grit::Commit.list_from_string @git, output
244
+ end
245
+
246
+ # Returns the object (tree or blob) at the given path inside the repository
247
+ #
248
+ # @param [String] path The path of the object in the repository
249
+ # @raise [FileNotFoundError] if no object with the given path exists
250
+ # @return [Grit::Blob, Grit::Tree] The object at the given path
251
+ def find_object(path = '/')
252
+ (path == '/') ? @git.tree : @git.tree/path
253
+ end
254
+
255
+ # Returns the object (tree or blob) at the given path inside the repository
256
+ # or fail if it does not exist
257
+ #
258
+ # @param (see #find_object)
259
+ # @raise [FileNotFoundError] if no object with the given path exists
260
+ # @return (see #find_object)
261
+ def object!(path)
262
+ object = find_object path
263
+ raise FileNotFoundError.new(path) if object.nil?
264
+ object
265
+ end
266
+
267
+ # Return whether the Git repository backing this Silo repository has
268
+ # already been prepared for use with Silo
269
+ #
270
+ # @return The preparation status of the backing Git repository
271
+ def prepared?
272
+ !(@git.tree/'.silo').nil?
273
+ end
274
+
275
+ # Purges a single file or the complete structure of a directory with the
276
+ # given path from the repository
277
+ #
278
+ # *WARNING*: This will cause a complete rewrite of the repository history
279
+ # and therefore deletes the data completely.
280
+ #
281
+ # @param [String] path The path of the file or directory to purge from the
282
+ # repository
283
+ # @param [Boolean] prune Remove empty commits in the Git history
284
+ def purge(path, prune = true)
285
+ object = object! path
286
+ if object.is_a? Grit::Tree
287
+ (object.blobs + object.trees).each do |blob|
288
+ purge File.join(path, blob.basename), prune
289
+ end
290
+ else
291
+ params = ['-f', '--index-filter',
292
+ "git rm --cached --ignore-unmatch #{path}"]
293
+ params << '--prune-empty' if prune
294
+ params << 'HEAD'
295
+ @git.git.filter_branch({}, *params)
296
+ end
297
+ end
298
+
299
+ # Removes a single file or the complete structure of a directory with the
300
+ # given path from the HEAD revision of the repository
301
+ #
302
+ # *NOTE*: The data won't be lost as it will be preserved in the history of
303
+ # the Git repository.
304
+ #
305
+ # @param [String] path The path of the file or directory to remove from the
306
+ # repository
307
+ def remove(path)
308
+ index = @git.index
309
+ index.read_tree 'HEAD'
310
+ remove = lambda do |f|
311
+ dir = File.stat(f).directory?
312
+ if dir
313
+ Dir.entries(f)[2..-1].each do |child|
314
+ remove.call File.join(f, child)
315
+ end
316
+ else
317
+ index.delete f
318
+ end
319
+ dir
320
+ end
321
+ dir = remove.call path
322
+ type = dir ? 'directory' : 'file'
323
+ commit_msg = "Removed #{type} #{path}"
324
+ index.commit commit_msg, @git.head.commit.sha
325
+ end
326
+ alias_method :rm, :remove
327
+
328
+ # Removes the remote with the given name from this repository
329
+ #
330
+ # @param [String] name The name of the remote to remove
331
+ # @see Remote
332
+ def remove_remote(name)
333
+ remote = @remotes[name]
334
+ raise UndefinedRemoteError.new(name) if remote.nil?
335
+ remote.remove
336
+ @remotes[name] = nil
337
+ end
338
+
339
+ # Restores a single file or the complete structure of a directory with the
340
+ # given path from the repository
341
+ #
342
+ # @param [String] path The path of the file or directory to restore from
343
+ # the repository
344
+ # @param [String] prefix An optional prefix where the file is restored
345
+ def restore(path, prefix = '.')
346
+ object = object! path
347
+ prefix = File.expand_path prefix
348
+ FileUtils.mkdir_p prefix unless File.exists? prefix
349
+
350
+ file_path = File.join prefix, File.basename(path)
351
+
352
+ if object.is_a? Grit::Tree
353
+ FileUtils.mkdir file_path unless File.directory? file_path
354
+ (object.blobs + object.trees).each do |obj|
355
+ restore File.join(path, obj.basename), file_path
356
+ end
357
+ else
358
+ file = File.new file_path, 'w'
359
+ file.write object.data
360
+ file.close
361
+ end
362
+ end
363
+
364
+ end
365
+
366
+ end