drupid 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.
data/bin/drupid ADDED
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- coding: utf-8 -*-
3
+
4
+ # Copyright (c) 2012 Lifepillar
5
+ #
6
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ # of this software and associated documentation files (the "Software"), to deal
8
+ # in the Software without restriction, including without limitation the rights
9
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the Software is
11
+ # furnished to do so, subject to the following conditions:
12
+ #
13
+ # The above copyright notice and this permission notice shall be included in all
14
+ # copies or substantial portions of the Software.
15
+ #
16
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ # SOFTWARE.
23
+
24
+ require 'drupid'
25
+
26
+ module Drupid
27
+
28
+ class Fools
29
+ include Drupid::Utils
30
+
31
+ def initialize
32
+ @options = { # Defaults
33
+ :command => :sync,
34
+ :directory => nil,
35
+ :dry => false,
36
+ :edit => nil,
37
+ :force => false,
38
+ :nocore => false,
39
+ :nodeps => false,
40
+ :nolibs => false,
41
+ :out => nil,
42
+ :site => nil
43
+ }
44
+ @updater = nil
45
+ end
46
+
47
+ def preflight_dependencies
48
+ odie 'Drupid requires Ruby 1.8.7 or later' if RUBY_VERSION < '1.8.7'
49
+ ['diff','curl','git','drush','mktemp','patch','rsync'].each do |cmd|
50
+ if `which #{cmd} 2>/dev/null`.chomp.empty?
51
+ odie "Drupid requires '#{cmd}', but '#{cmd}' was not found in your PATH."
52
+ end
53
+ end
54
+ end
55
+
56
+ def parse_options
57
+ require 'optparse'
58
+
59
+ begin
60
+ OptionParser.new do |o|
61
+ o.banner = "Drupid synchronizes a Drush makefile" +
62
+ " with a Drupal platform, and more!\n" +
63
+ "Usage:\n" +
64
+ "drupid -s <MAKEFILE> -p <DIRECTORY> [-cdDflnSv]\n" +
65
+ "drupid --clear-cache [-nv]\n" +
66
+ "drupid --edit [<URL>] [-v] [-o <FILE>]\n" +
67
+ "drupid --graph -p <DIRECTORY> [-Sv]\n" +
68
+ "drupid --help\n" +
69
+ "drupid --version"
70
+ o.on('-s', '--spec MAKEFILE', 'Path to a drush .make file.') do |p|
71
+ begin
72
+ @options[:makefile] = Pathname.new(p).realpath
73
+ rescue
74
+ odie "#{p} does not exist."
75
+ end
76
+ end
77
+ o.on('-c', '--no-core', 'Do not synchronize Drupal core.') { @options[:nocore] = true }
78
+ o.on('-C', '--clear-cache', 'Clear Drupid\'s cache and exit.') { @options[:command] = :clear }
79
+ o.on('-d', '--no-deps', 'Do not follow dependencies...',
80
+ '...and miss one of the coolest features of Drupid :)') { @options[:nodeps] = true }
81
+ o.on('-D', '--debug', 'Enable debugging.') { $DEBUG = true; $VERBOSE = true }
82
+ o.on('-e', '--edit [URL]', 'Create patches interactively.',
83
+ 'With no URL, edit the current directory.') { |u| @options[:command] = :edit; @options[:edit] = u }
84
+ o.on('-f', '--force', 'Force completion, even if there are errors.') { |b| @options[:force] = b }
85
+ o.on('-g', '--graph', 'Generate a dependency graph and exit.') { @options[:command] = :graph }
86
+ o.on('-h', '--help', 'Print help and exit.') {
87
+ puts o
88
+ puts "\nGleefully brought to you by Lifepillar! http://lifepillar.com"
89
+ exit 0
90
+ }
91
+ o.on('-l', '--no-libs', 'Do not synchronize libraries.') { @options[:nolibs] = true }
92
+ o.on('-n', '--dry', 'Dry run.') { |b| @options[:dry] = b }
93
+ o.on('-o', '--out FILE', 'Name of the output patch.') { |o| @options[:out] = o }
94
+ o.on('-p', '--path DIRECTORY', 'Path to a Drupal platform.') do |p|
95
+ @options[:directory] = Pathname.new(p).expand_path
96
+ @options[:directory].mkpath
97
+ end
98
+ o.on('-S', '--site NAME', 'Process the given site.',
99
+ '(For multi-site platforms.)') { |s| @options[:site] = s }
100
+ o.on('-v', '--verbose', 'Be verbose.') { $VERBOSE = true }
101
+ o.on('-V', '--version', 'Print version and exit.') { puts DRUPID_USER_AGENT; exit 0 }
102
+ o.parse!
103
+ end
104
+ rescue OptionParser::InvalidOption, OptionParser::AmbiguousOption => ex
105
+ odie "#{ex}\nTry 'drupid --help' to see the available options."
106
+ rescue OptionParser::MissingArgument, OptionParser::NeedlessArgument => ex
107
+ odie "#{ex}\nTry 'drupid --help' for the correct syntax."
108
+ end
109
+ case @options[:command]
110
+ when :graph
111
+ odie "Please specify the path to a Drupal platform." unless @options[:directory]
112
+ when :clear
113
+ when :edit
114
+ when :sync
115
+ odie "Please specify a makefile." unless @options[:makefile]
116
+ odie "Please specify a destination." unless @options[:directory]
117
+ else
118
+ odie "Unknown command: #{@options[:command]}"
119
+ end
120
+ end
121
+
122
+ def sync!
123
+ blah "Caching files in #{Drupid.cache_path}"
124
+ begin
125
+ mf = Drupid::Makefile.new(@options[:makefile])
126
+ rescue ParseMakefileError => ex
127
+ odie "Could not parse makefile: #{ex}"
128
+ end
129
+ begin
130
+ pl = Drupid::Platform.new(@options[:directory])
131
+ pl.contrib_path = pl.sites_dir + @options[:site] if @options[:site]
132
+ rescue => ex
133
+ odie "Could not analyze platform: #{ex}"
134
+ end
135
+ @updater = Drupid::Updater.new(mf, pl, @options[:site])
136
+ blah 'Syncing (this may take a while)...'
137
+ ohai 'Preflighting changes...'
138
+ @updater.sync(
139
+ :nocore => @options[:nocore],
140
+ :nofollow => @options[:nodeps],
141
+ :nolibs => @options[:nolibs]
142
+ )
143
+
144
+ # Check outcome and apply changes
145
+ failed = @updater.log.errors?
146
+ if failed
147
+ puts
148
+ ohai "The following errors should be fixed for a successful update:"
149
+ @updater.log.errors.each { |e| ofail e }
150
+ end
151
+
152
+ if @updater.pending_actions?
153
+ if (failed and (not @options[:force])) or @options[:dry]
154
+ ohai 'No changes applied.'
155
+ else
156
+ ohai 'Applying changes' + (failed ? ' despite errors...' : '...')
157
+ @updater.apply_changes(:force => @options[:force])
158
+ ohai 'Success!'
159
+ end
160
+ else
161
+ ohai "The platform is in sync with the makefile." unless failed
162
+ end
163
+ # Write .lock makefile
164
+ if !(@options[:no_lockfile] or @options[:dry]) and
165
+ (!(failed) or @options[:force]) and
166
+ '.lock' != @updater.makefile.path.extname
167
+ @updater.makefile.save(@updater.makefile.path.sub_ext('.make.lock'))
168
+ end
169
+ end
170
+
171
+ def clear_cache
172
+ FileUtils.rmtree Drupid.cache_path.to_s, :noop => @options[:dry], :verbose => $VERBOSE
173
+ ohai "Cache cleared."
174
+ end
175
+
176
+ def graph
177
+ platform = Drupid::Platform.new(@options[:directory])
178
+ platform.contrib_path = platform.sites_dir + @options[:site] if @options[:site]
179
+ outfile = platform.dependency_graph
180
+ ohai "#{outfile} created in the current directory."
181
+ end
182
+
183
+ def patch_interactive
184
+ patch = ''
185
+ if @options[:edit]
186
+ begin
187
+ tmp = Pathname.new `mktemp -d /tmp/temp_item-XXXXXX`.strip
188
+ dl = Drupid.makeDownloader @options[:edit], tmp, File.basename(@options[:edit])
189
+ ohai "Fetching #{@options[:edit]}"
190
+ dl.fetch
191
+ dl.stage
192
+ wd = dl.staged_path
193
+ rescue => ex
194
+ odie "Error retrieving #{@options[:edit]}: #{ex}"
195
+ end
196
+ else
197
+ wd = Pathname.pwd
198
+ end
199
+ git_repo = (wd + '.git').exist?
200
+ Dir.chdir wd.to_s do
201
+ if git_repo
202
+ blah "This directory appears to be a git repo"
203
+ unless git('status', '-s').empty?
204
+ odie "This git repo is not in a clean state"
205
+ end
206
+ else
207
+ begin
208
+ blah "Initializing temporary git repo inside #{wd}"
209
+ git 'init'
210
+ git 'add', '-A'
211
+ git 'commit', '-m', 'Temporary commit'
212
+ rescue
213
+ odie "Unable to create temporary git repo."
214
+ end
215
+ end
216
+ begin
217
+ ohai 'Make any changes you wish, then exit from the shell.'
218
+ interactive_shell
219
+ git 'add', '-A'
220
+ patch = git 'diff', '--binary', 'HEAD'
221
+ ensure
222
+ git 'reset', '--hard'
223
+ FileUtils.rmtree '.git' unless git_repo
224
+ end
225
+ end
226
+ # Show/write patch
227
+ if patch.empty?
228
+ ohai "No changes made"
229
+ else
230
+ if @options[:out]
231
+ writeFile @options[:out], patch
232
+ ohai "Patch written to #{@options[:out]}"
233
+ else
234
+ ohai 'May I interest you in a patch?'
235
+ puts patch
236
+ end
237
+ end
238
+ end
239
+
240
+ def go!
241
+ preflight_dependencies
242
+ parse_options
243
+ ohai "Dry run" if @options[:dry]
244
+ case @options[:command]
245
+ when :clear then clear_cache
246
+ when :graph then graph
247
+ when :edit then patch_interactive
248
+ else sync!
249
+ end
250
+ end
251
+
252
+ def self.rush_in!
253
+ begin
254
+ drupid = Fools.new
255
+ drupid.go!
256
+ rescue Interrupt
257
+ puts
258
+ drupid.ohai "Drupid interrupted"
259
+ rescue => ex
260
+ puts
261
+ drupid.debug 'Backtrace:', ex.backtrace.join("\n")
262
+ drupid.odie "Unexpected exception raised:\n#{ex}"
263
+ end
264
+ end
265
+
266
+ end # Fools
267
+
268
+ Fools.rush_in!
269
+
270
+ end # Drupid
@@ -0,0 +1,236 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ # Copyright (c) 2012 Lifepillar
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ module Drupid
24
+
25
+ if RUBY_PLATFORM =~ /darwin/
26
+ @@cache_path = Pathname.new(ENV['HOME']) + 'Library/Caches/Drupid'
27
+ else
28
+ @@cache_path = Pathname.new(ENV['HOME']) + '.drupid_cache'
29
+ end
30
+
31
+ def Drupid.cache_path
32
+ @@cache_path
33
+ end
34
+
35
+ def Drupid.cache_path=(new_path)
36
+ raise "Invalid cache path" unless new_path.to_s =~ /cache/i
37
+ @@cache_path = Pathname.new(new_path).realpath # must exist
38
+ end
39
+
40
+ class Component
41
+ include Drupid::Utils
42
+
43
+ attr :name
44
+ attr_accessor :download_url
45
+ attr_accessor :download_type
46
+ attr_accessor :download_specs
47
+ attr_accessor :overwrite
48
+ attr_accessor :local_path
49
+ attr :ignore_paths
50
+
51
+ def initialize name
52
+ @name = name
53
+ @download_url = nil
54
+ @download_type = nil
55
+ @download_specs = Hash.new
56
+ @overwrite = false
57
+ @subdir = nil
58
+ @directory_name = nil
59
+ @local_path = nil
60
+ @ignore_paths = Array.new
61
+ @patches = Array.new
62
+ end
63
+
64
+ # Performs a deep copy of this object.
65
+ def clone
66
+ Marshal.load(Marshal.dump(self))
67
+ end
68
+
69
+ def extended_name
70
+ @name
71
+ end
72
+
73
+ # A synonym for #extended_name.
74
+ def to_s
75
+ extended_name
76
+ end
77
+
78
+ # Returns a path to a subdirectory where this component
79
+ # should be installed. The path is meant to be relative
80
+ # to the 'default' installation path (e.g., 'sites/all/modules' for modules,
81
+ # 'sites/all/themes' for themes, 'profiles' for profiles, etc...).
82
+ # For example, if a module 'foobar' must be installed under 'sites/all/modules'
83
+ # and this property is set, say, to 'contrib', then the module will be installed
84
+ # at 'sites/all/modules/contrib/foobar'.
85
+ def subdir
86
+ (@subdir) ? Pathname.new(@subdir) : Pathname.new('.')
87
+ end
88
+
89
+ # Sets the path to a subdirectory where this component should be installed,
90
+ # relative to the default installation path.
91
+ def subdir=(d)
92
+ @subdir = d
93
+ end
94
+
95
+ # Returns the directory name for this component.
96
+ def directory_name
97
+ return @directory_name.to_s if @directory_name
98
+ return local_path.basename.to_s if exist?
99
+ return name
100
+ end
101
+
102
+ # Sets the directory name for this component.
103
+ def directory_name=(d)
104
+ @directory_name = d
105
+ end
106
+
107
+ # Returns true if this project is associated to a local copy on disk;
108
+ # returns false otherwise.
109
+ def exist?
110
+ @local_path and @local_path.exist?
111
+ end
112
+
113
+ def add_download_spec(spec, ref)
114
+ @download_specs.merge!({spec => ref})
115
+ end
116
+
117
+ # Downloads to local cache.
118
+ def fetch
119
+ if cached_location.exist?
120
+ @local_path = cached_location
121
+ debug "#{extended_name} is cached"
122
+ else
123
+ raise "No download URL specified for #{extended_name}" unless download_url
124
+ blah "Fetching #{extended_name}"
125
+ downloader = Drupid.makeDownloader download_url.to_s, cached_location.dirname.to_s, cached_location.basename.to_s, download_specs
126
+ downloader.fetch
127
+ downloader.stage
128
+ @local_path = downloader.staged_path
129
+ end
130
+ end
131
+
132
+ # Applies the patches associated to this component.
133
+ # Raises an exception if a patch cannot be applied.
134
+ def patch
135
+ fetch unless exist?
136
+ return unless has_patches?
137
+ patched_location.rmtree if patched_location.exist? # Ensure no previous patched copy exists
138
+ @local_path.ditto patched_location
139
+ @local_path = patched_location
140
+ # Download patches
141
+ patched_location.dirname.cd do
142
+ each_patch do |p|
143
+ p.fetch
144
+ end
145
+ end
146
+ # Apply patches
147
+ patched_location.cd do
148
+ each_patch do |p|
149
+ p.apply
150
+ end
151
+ end
152
+ end
153
+
154
+ # Removes all the patches from this component.
155
+ def clear_patches
156
+ @patches.clear
157
+ end
158
+
159
+ # Returns true if this component has been patched;
160
+ # returns false otherwise.
161
+ def patched?
162
+ @local_path == patched_location
163
+ end
164
+
165
+ # Iterates over each patch associated to this component,
166
+ # yielding a Drupid::Patch object.
167
+ def each_patch
168
+ @patches.each do |p|
169
+ yield p
170
+ end
171
+ end
172
+
173
+ # Returns true if patches are associated to this component,
174
+ # returns false otherwise.
175
+ def has_patches?
176
+ !@patches.empty?
177
+ end
178
+
179
+ def add_patch(url, descr, md5 = nil)
180
+ @patches << Patch.new(url, descr, md5)
181
+ end
182
+
183
+ # Returns the first patch with the given description, or
184
+ # nil if no such patch exists.
185
+ def get_patch descr
186
+ @patches.each do |p|
187
+ return p if descr == p.descr
188
+ end
189
+ end
190
+
191
+ # Full path to the location where a cached copy of this component is located.
192
+ def cached_location
193
+ dlt = (download_type) ? download_type : 'default'
194
+ Drupid.cache_path + self.class.to_s.split(/::/).last + extended_name + dlt + name
195
+ end
196
+
197
+ # Full path to the directory where a patched copy of this component is located.
198
+ def patched_location
199
+ cached_location.dirname + '__patches' + name
200
+ end
201
+
202
+ # Ignores the given path relative to this component's path.
203
+ # This is useful, for example, when an external library is installed
204
+ # inside a module's folder (rather than in the libraries folder).
205
+ def ignore_path(relative_path)
206
+ @ignore_paths << Pathname.new(relative_path)
207
+ end
208
+
209
+ # Performs a file-by-file comparison of this component with another.
210
+ # Returns a list of files that are different between the two copies.
211
+ # If the directories of the two projects look the same, returns an empty array.
212
+ # Local copies must exist for both projects, otherwise this method raises an error.
213
+ #
214
+ # If one of the projects has a makefile, the content of the following directories
215
+ # is ignored: libraries, modules, themes.
216
+ # Version control directories (.git) are always ignored.
217
+ def file_level_compare_with tgt, additional_rsync_args = []
218
+ raise "#{extended_name} does not exist at #{local_path}" unless exist?
219
+ raise "#{tgt.extended_name} does not exist at #{tgt.local_path}" unless tgt.exist?
220
+ args = Array.new
221
+ default_exclusions = [
222
+ '.DS_Store',
223
+ '.git/',
224
+ '.bzr/',
225
+ '.hg/',
226
+ '.svn/'
227
+ ]
228
+ default_exclusions.each { |e| args << "--exclude=#{e}" }
229
+ ignore_paths.each { |p| args << "--exclude=#{p}" }
230
+ tgt.ignore_paths.each { |p| args << "--exclude=#{p}" }
231
+ args += additional_rsync_args
232
+ compare_paths local_path, tgt.local_path, args
233
+ end
234
+
235
+ end # Component
236
+ end # Drupid