mlightner-braid 0.5.1
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/LICENSE +20 -0
- data/README.textile +120 -0
- data/Rakefile +17 -0
- data/bin/braid +249 -0
- data/braid.gemspec +25 -0
- data/lib/braid.rb +29 -0
- data/lib/braid/command.rb +131 -0
- data/lib/braid/commands/add.rb +42 -0
- data/lib/braid/commands/diff.rb +13 -0
- data/lib/braid/commands/list.rb +32 -0
- data/lib/braid/commands/remove.rb +33 -0
- data/lib/braid/commands/setup.rb +34 -0
- data/lib/braid/commands/update.rb +102 -0
- data/lib/braid/config.rb +101 -0
- data/lib/braid/mirror.rb +173 -0
- data/lib/braid/operations.rb +390 -0
- data/test/braid_test.rb +7 -0
- data/test/config_test.rb +62 -0
- data/test/fixtures/shiny/README +3 -0
- data/test/fixtures/skit1.1/layouts/layout.liquid +219 -0
- data/test/fixtures/skit1.2/layouts/layout.liquid +221 -0
- data/test/fixtures/skit1/layouts/layout.liquid +219 -0
- data/test/fixtures/skit1/preview.png +0 -0
- data/test/integration/adding_test.rb +80 -0
- data/test/integration/updating_test.rb +87 -0
- data/test/integration_helper.rb +70 -0
- data/test/mirror_test.rb +110 -0
- data/test/operations_test.rb +66 -0
- data/test/test_helper.rb +15 -0
- metadata +103 -0
data/lib/braid/mirror.rb
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
module Braid
|
2
|
+
class Mirror
|
3
|
+
TYPES = %w(git svn)
|
4
|
+
ATTRIBUTES = %w(url remote type branch squashed revision lock)
|
5
|
+
|
6
|
+
class UnknownType < BraidError
|
7
|
+
def message
|
8
|
+
"unknown type: #{super}"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
class CannotGuessType < BraidError
|
12
|
+
def message
|
13
|
+
"cannot guess type: #{super}"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
class PathRequired < BraidError
|
17
|
+
def message
|
18
|
+
"path is required"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
include Operations::VersionControl
|
23
|
+
|
24
|
+
attr_reader :path, :attributes
|
25
|
+
|
26
|
+
def initialize(path, attributes = {})
|
27
|
+
@path = path.sub(/\/$/, '')
|
28
|
+
@attributes = attributes
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.new_from_options(url, options = {})
|
32
|
+
url = url.sub(/\/$/, '')
|
33
|
+
|
34
|
+
branch = options["branch"] || "master"
|
35
|
+
|
36
|
+
if type = options["type"] || extract_type_from_url(url)
|
37
|
+
raise UnknownType, type unless TYPES.include?(type)
|
38
|
+
else
|
39
|
+
raise CannotGuessType, url
|
40
|
+
end
|
41
|
+
|
42
|
+
unless path = options["path"] || extract_path_from_url(url)
|
43
|
+
raise PathRequired
|
44
|
+
end
|
45
|
+
|
46
|
+
if options["rails_plugin"]
|
47
|
+
path = "vendor/plugins/#{path}"
|
48
|
+
end
|
49
|
+
|
50
|
+
remote = "braid/#{path}".gsub("_", '-') # stupid git svn changes all _ to ., weird
|
51
|
+
squashed = !options["full"]
|
52
|
+
branch = nil if type == "svn"
|
53
|
+
|
54
|
+
attributes = { "url" => url, "remote" => remote, "type" => type, "branch" => branch, "squashed" => squashed }
|
55
|
+
self.new(path, attributes)
|
56
|
+
end
|
57
|
+
|
58
|
+
def ==(comparison)
|
59
|
+
path == comparison.path && attributes == comparison.attributes
|
60
|
+
end
|
61
|
+
|
62
|
+
def type
|
63
|
+
# override Object#type
|
64
|
+
attributes["type"]
|
65
|
+
end
|
66
|
+
|
67
|
+
def locked?
|
68
|
+
!!lock
|
69
|
+
end
|
70
|
+
|
71
|
+
def squashed?
|
72
|
+
!!squashed
|
73
|
+
end
|
74
|
+
|
75
|
+
def merged?(commit)
|
76
|
+
# tip from spearce in #git:
|
77
|
+
# `test z$(git merge-base A B) = z$(git rev-parse --verify A)`
|
78
|
+
commit = git.rev_parse(commit)
|
79
|
+
if squashed?
|
80
|
+
!!base_revision && git.merge_base(commit, base_revision) == commit
|
81
|
+
else
|
82
|
+
git.merge_base(commit, "HEAD") == commit
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def diff
|
87
|
+
remote_hash = git.rev_parse("#{base_revision}:")
|
88
|
+
local_hash = git.tree_hash(path)
|
89
|
+
remote_hash != local_hash ? git.diff_tree(remote_hash, local_hash, path) : ""
|
90
|
+
end
|
91
|
+
|
92
|
+
def fetch
|
93
|
+
unless type == "svn"
|
94
|
+
git_cache.fetch(url) if cached?
|
95
|
+
git.fetch(remote)
|
96
|
+
else
|
97
|
+
git_svn.fetch(remote)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def cached?
|
102
|
+
git.remote_url(remote) == git_cache.path(url)
|
103
|
+
end
|
104
|
+
|
105
|
+
def base_revision
|
106
|
+
if revision
|
107
|
+
unless type == "svn"
|
108
|
+
git.rev_parse(revision)
|
109
|
+
else
|
110
|
+
git_svn.commit_hash(remote, revision)
|
111
|
+
end
|
112
|
+
else
|
113
|
+
inferred_revision
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
def method_missing(name, *args)
|
119
|
+
if ATTRIBUTES.find { |attribute| name.to_s =~ /^(#{attribute})(=)?$/ }
|
120
|
+
unless $2
|
121
|
+
attributes[$1]
|
122
|
+
else
|
123
|
+
attributes[$1] = args[0]
|
124
|
+
end
|
125
|
+
else
|
126
|
+
raise NameError, "unknown attribute `#{name}'"
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def inferred_revision
|
131
|
+
local_commits = git.rev_list("HEAD", "-- #{path}").split("\n")
|
132
|
+
remote_hashes = git.rev_list("--pretty=format:\"%T\"", remote).split("commit ").map do |chunk|
|
133
|
+
chunk.split("\n", 2).map { |value| value.strip }
|
134
|
+
end
|
135
|
+
hash = nil
|
136
|
+
local_commits.each do |local_commit|
|
137
|
+
local_tree = git.tree_hash(path, local_commit)
|
138
|
+
if match = remote_hashes.find { |_, remote_tree| local_tree == remote_tree }
|
139
|
+
hash = match[0]
|
140
|
+
break
|
141
|
+
end
|
142
|
+
end
|
143
|
+
hash
|
144
|
+
end
|
145
|
+
|
146
|
+
def self.extract_type_from_url(url)
|
147
|
+
return nil unless url
|
148
|
+
url.sub!(/\/$/, '')
|
149
|
+
|
150
|
+
# check for git:// and svn:// URLs
|
151
|
+
url_scheme = url.split(":").first
|
152
|
+
return url_scheme if TYPES.include?(url_scheme)
|
153
|
+
|
154
|
+
return "svn" if url[-6..-1] == "/trunk"
|
155
|
+
return "git" if url[-4..-1] == ".git"
|
156
|
+
end
|
157
|
+
|
158
|
+
def self.extract_path_from_url(url)
|
159
|
+
return nil unless url
|
160
|
+
name = File.basename(url)
|
161
|
+
|
162
|
+
if File.extname(name) == ".git"
|
163
|
+
# strip .git
|
164
|
+
name[0..-5]
|
165
|
+
elsif name == "trunk"
|
166
|
+
# use parent
|
167
|
+
File.basename(File.dirname(url))
|
168
|
+
else
|
169
|
+
name
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,390 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
require 'rubygems'
|
3
|
+
require 'open4'
|
4
|
+
require 'tempfile'
|
5
|
+
|
6
|
+
module Braid
|
7
|
+
module Operations
|
8
|
+
class ShellExecutionError < BraidError
|
9
|
+
def initialize(err = nil)
|
10
|
+
@err = err
|
11
|
+
end
|
12
|
+
|
13
|
+
def message
|
14
|
+
@err.to_s.split("\n").first
|
15
|
+
end
|
16
|
+
end
|
17
|
+
class VersionTooLow < BraidError
|
18
|
+
def initialize(command, version, required)
|
19
|
+
@command = command
|
20
|
+
@version = version.to_s.split("\n").first
|
21
|
+
@required = required
|
22
|
+
end
|
23
|
+
|
24
|
+
def message
|
25
|
+
"#{@command} version too low: #{@version}. #{@required} needed."
|
26
|
+
end
|
27
|
+
end
|
28
|
+
class UnknownRevision < BraidError
|
29
|
+
def message
|
30
|
+
"unknown revision: #{super}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
class LocalChangesPresent < BraidError
|
34
|
+
def message
|
35
|
+
"local changes are present"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
class MergeError < BraidError
|
39
|
+
def message
|
40
|
+
"could not merge"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# The command proxy is meant to encapsulate commands such as git, git-svn and svn, that work with subcommands.
|
45
|
+
class Proxy
|
46
|
+
include Singleton
|
47
|
+
|
48
|
+
def self.command; name.split('::').last.downcase; end # hax!
|
49
|
+
|
50
|
+
def version
|
51
|
+
status, out, err = exec!("#{self.class.command} --version")
|
52
|
+
out.sub(/^.* version/, "").strip
|
53
|
+
end
|
54
|
+
|
55
|
+
def require_version(required)
|
56
|
+
required = required.split(".")
|
57
|
+
actual = version.split(".")
|
58
|
+
|
59
|
+
actual.each_with_index do |actual_piece, idx|
|
60
|
+
required_piece = required[idx]
|
61
|
+
|
62
|
+
return true unless required_piece
|
63
|
+
|
64
|
+
case (actual_piece <=> required_piece)
|
65
|
+
when -1
|
66
|
+
return false
|
67
|
+
when 1
|
68
|
+
return true
|
69
|
+
when 0
|
70
|
+
next
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
return actual.length >= required.length
|
75
|
+
end
|
76
|
+
|
77
|
+
def require_version!(required)
|
78
|
+
require_version(required) || raise(VersionTooLow.new(self.class.command, version, required))
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
def command(name)
|
83
|
+
# stub
|
84
|
+
name
|
85
|
+
end
|
86
|
+
|
87
|
+
def invoke(arg, *args)
|
88
|
+
exec!("#{command(arg)} #{args.join(' ')}".strip)[1].strip # return stdout
|
89
|
+
end
|
90
|
+
|
91
|
+
def method_missing(name, *args)
|
92
|
+
invoke(name, *args)
|
93
|
+
end
|
94
|
+
|
95
|
+
def exec(cmd)
|
96
|
+
cmd.strip!
|
97
|
+
|
98
|
+
previous_lang = ENV['LANG']
|
99
|
+
ENV['LANG'] = 'C'
|
100
|
+
|
101
|
+
out, err = nil
|
102
|
+
log(cmd)
|
103
|
+
status = Open4.popen4(cmd) do |pid, stdin, stdout, stderr|
|
104
|
+
out = stdout.read
|
105
|
+
err = stderr.read
|
106
|
+
end.exitstatus
|
107
|
+
[status, out, err]
|
108
|
+
|
109
|
+
ensure
|
110
|
+
ENV['LANG'] = previous_lang
|
111
|
+
end
|
112
|
+
|
113
|
+
def exec!(cmd)
|
114
|
+
status, out, err = exec(cmd)
|
115
|
+
raise ShellExecutionError, err unless status == 0
|
116
|
+
[status, out, err]
|
117
|
+
end
|
118
|
+
|
119
|
+
def sh(cmd, message = nil)
|
120
|
+
message ||= "could not fetch" if cmd =~ /fetch/
|
121
|
+
log(cmd)
|
122
|
+
`#{cmd}`
|
123
|
+
raise ShellExecutionError, message unless $?.exitstatus == 0
|
124
|
+
true
|
125
|
+
end
|
126
|
+
|
127
|
+
def msg(str)
|
128
|
+
puts "Braid: #{str}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def log(cmd)
|
132
|
+
msg "Executing `#{cmd}`" if verbose?
|
133
|
+
end
|
134
|
+
|
135
|
+
def verbose?
|
136
|
+
Braid.verbose
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
class Git < Proxy
|
141
|
+
def commit(message, *args)
|
142
|
+
|
143
|
+
commit_message_file = Tempfile.new("braid_commit", ".")
|
144
|
+
commit_message_file.print("Braid: " + message)
|
145
|
+
commit_message_file.flush
|
146
|
+
status, out, err = exec("git commit -F #{commit_message_file.path} --no-verify #{args.join(' ')}")
|
147
|
+
commit_message_file.unlink
|
148
|
+
|
149
|
+
if status == 0
|
150
|
+
true
|
151
|
+
elsif out.match(/nothing.* to commit/)
|
152
|
+
false
|
153
|
+
else
|
154
|
+
raise ShellExecutionError, err
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def fetch(remote = nil)
|
159
|
+
args = remote && "-n #{remote}"
|
160
|
+
# open4 messes with the pipes of index-pack
|
161
|
+
sh("git fetch #{args} 2>&1 >/dev/null")
|
162
|
+
end
|
163
|
+
|
164
|
+
def checkout(treeish)
|
165
|
+
invoke(:checkout, treeish)
|
166
|
+
true
|
167
|
+
end
|
168
|
+
|
169
|
+
# Returns the base commit or nil.
|
170
|
+
def merge_base(target, source)
|
171
|
+
invoke(:merge_base, target, source)
|
172
|
+
rescue ShellExecutionError
|
173
|
+
nil
|
174
|
+
end
|
175
|
+
|
176
|
+
def rev_parse(opt)
|
177
|
+
invoke(:rev_parse, opt)
|
178
|
+
rescue ShellExecutionError
|
179
|
+
raise UnknownRevision, opt
|
180
|
+
end
|
181
|
+
|
182
|
+
# Implies tracking.
|
183
|
+
def remote_add(remote, path, branch)
|
184
|
+
invoke(:remote, "add", "-t #{branch} -m #{branch}", remote, path)
|
185
|
+
true
|
186
|
+
end
|
187
|
+
|
188
|
+
def remote_rm(remote)
|
189
|
+
invoke(:remote, "rm", remote)
|
190
|
+
true
|
191
|
+
end
|
192
|
+
|
193
|
+
# Checks git and svn remotes.
|
194
|
+
def remote_url(remote)
|
195
|
+
key = "remote.#{remote}.url"
|
196
|
+
begin
|
197
|
+
invoke(:config, key)
|
198
|
+
rescue ShellExecutionError
|
199
|
+
invoke(:config, "svn-#{key}")
|
200
|
+
end
|
201
|
+
rescue ShellExecutionError
|
202
|
+
nil
|
203
|
+
end
|
204
|
+
|
205
|
+
def reset_hard(target)
|
206
|
+
invoke(:reset, "--hard", target)
|
207
|
+
true
|
208
|
+
end
|
209
|
+
|
210
|
+
# Implies no commit.
|
211
|
+
def merge_ours(opt)
|
212
|
+
invoke(:merge, "-s ours --no-commit", opt)
|
213
|
+
true
|
214
|
+
end
|
215
|
+
|
216
|
+
# Implies no commit.
|
217
|
+
def merge_subtree(opt)
|
218
|
+
# TODO which options are needed?
|
219
|
+
invoke(:merge, "-s subtree --no-commit --no-ff", opt)
|
220
|
+
true
|
221
|
+
rescue ShellExecutionError
|
222
|
+
raise MergeError
|
223
|
+
end
|
224
|
+
|
225
|
+
def merge_recursive(base_hash, local_hash, remote_hash)
|
226
|
+
invoke(:merge_recursive, base_hash, "-- #{local_hash} #{remote_hash}")
|
227
|
+
true
|
228
|
+
rescue ShellExecutionError
|
229
|
+
raise MergeError
|
230
|
+
end
|
231
|
+
|
232
|
+
def read_tree_prefix(treeish, prefix)
|
233
|
+
invoke(:read_tree, "--prefix=#{prefix}/ -u", treeish)
|
234
|
+
true
|
235
|
+
end
|
236
|
+
|
237
|
+
def rm_r(path)
|
238
|
+
invoke(:rm, "-r", path)
|
239
|
+
true
|
240
|
+
end
|
241
|
+
|
242
|
+
def tree_hash(path, treeish = "HEAD")
|
243
|
+
out = invoke(:ls_tree, treeish, "-d", path)
|
244
|
+
out.split[2]
|
245
|
+
end
|
246
|
+
|
247
|
+
def diff_tree(src_tree, dst_tree, prefix = nil)
|
248
|
+
cmd = "git diff-tree -p --binary #{src_tree} #{dst_tree}"
|
249
|
+
cmd << " --src-prefix=a/#{prefix}/ --dst-prefix=b/#{prefix}/" if prefix
|
250
|
+
status, out, err = exec!(cmd)
|
251
|
+
out
|
252
|
+
end
|
253
|
+
|
254
|
+
def status_clean?
|
255
|
+
status, out, err = exec("git status")
|
256
|
+
!out.split("\n").grep(/nothing to commit/).empty?
|
257
|
+
end
|
258
|
+
|
259
|
+
def ensure_clean!
|
260
|
+
status_clean? || raise(LocalChangesPresent)
|
261
|
+
end
|
262
|
+
|
263
|
+
def head
|
264
|
+
rev_parse("HEAD")
|
265
|
+
end
|
266
|
+
|
267
|
+
def branch
|
268
|
+
status, out, err = exec!("git branch | grep '*'")
|
269
|
+
out[2..-1]
|
270
|
+
end
|
271
|
+
|
272
|
+
def apply(diff, *args)
|
273
|
+
err = nil
|
274
|
+
status = Open4.popen4("git apply --index --whitespace=nowarn #{args.join(' ')} -") do |pid, stdin, stdout, stderr|
|
275
|
+
stdin.puts(diff)
|
276
|
+
stdin.close
|
277
|
+
|
278
|
+
err = stderr.read
|
279
|
+
end.exitstatus
|
280
|
+
raise ShellExecutionError, err unless status == 0
|
281
|
+
true
|
282
|
+
end
|
283
|
+
|
284
|
+
def clone(*args)
|
285
|
+
# overrides builtin
|
286
|
+
invoke(:clone, *args)
|
287
|
+
end
|
288
|
+
|
289
|
+
private
|
290
|
+
def command(name)
|
291
|
+
"#{self.class.command} #{name.to_s.gsub('_', '-')}"
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
class GitSvn < Proxy
|
296
|
+
def self.command; "git svn"; end
|
297
|
+
|
298
|
+
def commit_hash(remote, revision)
|
299
|
+
out = invoke(:log, "--show-commit --oneline", "-r #{revision}", remote)
|
300
|
+
part = out.to_s.split(" | ")[1]
|
301
|
+
raise UnknownRevision, "r#{revision}" unless part
|
302
|
+
git.rev_parse(part)
|
303
|
+
end
|
304
|
+
|
305
|
+
def fetch(remote)
|
306
|
+
sh("git svn fetch #{remote} 2>&1 >/dev/null")
|
307
|
+
end
|
308
|
+
|
309
|
+
def init(remote, path)
|
310
|
+
invoke(:init, "-R", remote, "--id=#{remote}", path)
|
311
|
+
true
|
312
|
+
end
|
313
|
+
|
314
|
+
private
|
315
|
+
def command(name)
|
316
|
+
"#{self.class.command} #{name}"
|
317
|
+
end
|
318
|
+
|
319
|
+
def git
|
320
|
+
Git.instance
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
class Svn < Proxy
|
325
|
+
def clean_revision(revision)
|
326
|
+
revision.to_i if revision
|
327
|
+
end
|
328
|
+
|
329
|
+
def head_revision(path)
|
330
|
+
# not using svn info because it's retarded and doesn't show the actual last changed rev for the url
|
331
|
+
# git svn has no clue on how to get the actual HEAD revision number on it's own
|
332
|
+
status, out, err = exec!("svn log -q --limit 1 #{path}")
|
333
|
+
out.split(/\n/).find { |x| x.match /^r\d+/ }.split(" | ")[0][1..-1].to_i
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
class GitCache
|
338
|
+
include Singleton
|
339
|
+
|
340
|
+
def fetch(url)
|
341
|
+
dir = path(url)
|
342
|
+
|
343
|
+
# remove local cache if it was created with --no-checkout
|
344
|
+
if File.exists?("#{dir}/.git")
|
345
|
+
FileUtils.rm_r(dir)
|
346
|
+
end
|
347
|
+
|
348
|
+
if File.exists?(dir)
|
349
|
+
Dir.chdir(dir) do
|
350
|
+
git.fetch
|
351
|
+
end
|
352
|
+
else
|
353
|
+
FileUtils.mkdir_p(local_cache_dir)
|
354
|
+
git.clone("--mirror", url, dir)
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
def path(url)
|
359
|
+
File.join(local_cache_dir, url.gsub(/[\/:@]/, "_"))
|
360
|
+
end
|
361
|
+
|
362
|
+
private
|
363
|
+
def local_cache_dir
|
364
|
+
Braid.local_cache_dir
|
365
|
+
end
|
366
|
+
|
367
|
+
def git
|
368
|
+
Git.instance
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
module VersionControl
|
373
|
+
def git
|
374
|
+
Git.instance
|
375
|
+
end
|
376
|
+
|
377
|
+
def git_svn
|
378
|
+
GitSvn.instance
|
379
|
+
end
|
380
|
+
|
381
|
+
def svn
|
382
|
+
Svn.instance
|
383
|
+
end
|
384
|
+
|
385
|
+
def git_cache
|
386
|
+
GitCache.instance
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|