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.
- data/.yardopts +1 -0
- data/Changelog.md +13 -0
- data/LICENSE +25 -0
- data/README.md +153 -0
- data/Rakefile +45 -0
- data/bin/silo +9 -0
- data/gemspec.yml +29 -0
- data/lib/core_ext/pathname.rb +20 -0
- data/lib/silo.rb +20 -0
- data/lib/silo/cli.rb +167 -0
- data/lib/silo/errors.rb +58 -0
- data/lib/silo/remote/base.rb +33 -0
- data/lib/silo/remote/git.rb +50 -0
- data/lib/silo/repository.rb +366 -0
- data/lib/silo/version.rb +11 -0
- data/test/data/file1 +0 -0
- data/test/data/file2 +0 -0
- data/test/data/subdir1/file1 +0 -0
- data/test/data/subdir2/file2 +0 -0
- data/test/helper.rb +13 -0
- data/test/test_repository.rb +204 -0
- metadata +167 -0
data/lib/silo/errors.rb
ADDED
@@ -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
|