git-trac 0.0.20071102
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/README +24 -0
- data/Rakefile +109 -0
- data/bin/git-trac +12 -0
- data/lib/git/trac.rb +15 -0
- data/lib/git/trac/attachment.rb +103 -0
- data/lib/git/trac/repository.rb +229 -0
- data/lib/git/trac/runner.rb +303 -0
- data/lib/git/trac/ticket.rb +183 -0
- data/setup.rb +1585 -0
- data/test/execution_test.rb +40 -0
- data/test/execution_test.rb~ +40 -0
- metadata +64 -0
@@ -0,0 +1,303 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'ostruct'
|
3
|
+
|
4
|
+
module Git
|
5
|
+
module Trac
|
6
|
+
|
7
|
+
def self.run(argv)
|
8
|
+
Runner.new(argv)
|
9
|
+
end
|
10
|
+
|
11
|
+
class Runner
|
12
|
+
|
13
|
+
attr_reader :options
|
14
|
+
|
15
|
+
def initialize(argv)
|
16
|
+
@argv = argv.dup
|
17
|
+
run
|
18
|
+
rescue Git::Trac::Error
|
19
|
+
$stderr.puts "Error: " + $!.message
|
20
|
+
exit 1
|
21
|
+
end
|
22
|
+
|
23
|
+
def abort(message)
|
24
|
+
raise Git::Trac::Error, message
|
25
|
+
end
|
26
|
+
|
27
|
+
def general_usage
|
28
|
+
$stderr.puts <<-EOS
|
29
|
+
Usage: git-trac <command> [options] [arguments]
|
30
|
+
|
31
|
+
Available commands:
|
32
|
+
cleanup Remove old branches for a ticket
|
33
|
+
download Download all patches for a ticket to the cwd
|
34
|
+
fetch Create brances for all patches of a ticket
|
35
|
+
show Show a summary of a ticket
|
36
|
+
upload-patch Upload the current diff against trunk to a ticket
|
37
|
+
EOS
|
38
|
+
exit 1
|
39
|
+
end
|
40
|
+
|
41
|
+
def run
|
42
|
+
|
43
|
+
command = @argv.shift
|
44
|
+
repo = nil
|
45
|
+
if command == "--repository"
|
46
|
+
repo, command = @argv.shift, @argv.shift
|
47
|
+
end
|
48
|
+
|
49
|
+
if command == "help"
|
50
|
+
command = @argv.shift
|
51
|
+
@argv.unshift "--help"
|
52
|
+
end
|
53
|
+
general_usage unless command
|
54
|
+
|
55
|
+
@repository = Git::Trac::Repository.new(repo)
|
56
|
+
unless @repository.url || @argv.first == "--help"
|
57
|
+
abort "no URL. Try `git config trac.url http://trac-url`"
|
58
|
+
end
|
59
|
+
|
60
|
+
klass_name = command.capitalize.gsub(/-(.)/) { $1.upcase }
|
61
|
+
if self.class.const_defined?(klass_name)
|
62
|
+
klass = self.class.const_get(klass_name)
|
63
|
+
if klass < Base
|
64
|
+
return klass.new(@argv, @repository)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
general_usage
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
class Base
|
73
|
+
|
74
|
+
attr_reader :options
|
75
|
+
|
76
|
+
def initialize(argv, repo)
|
77
|
+
@argv, @repository = argv, repo
|
78
|
+
@options = {}
|
79
|
+
@opts = OptionParser.new
|
80
|
+
@opts.banner = "Usage: git-trac #{command} #{banner_arguments}\n#{"\n" if description}#{description}"
|
81
|
+
@opts.separator("")
|
82
|
+
add_options(@opts)
|
83
|
+
begin
|
84
|
+
@opts.parse!(@argv)
|
85
|
+
rescue OptionParser::InvalidOption
|
86
|
+
abort $!.message
|
87
|
+
end
|
88
|
+
run
|
89
|
+
end
|
90
|
+
|
91
|
+
def description
|
92
|
+
end
|
93
|
+
|
94
|
+
def get_ticket_number
|
95
|
+
if @argv.first =~ /^\d+$/
|
96
|
+
Integer(@argv.shift)
|
97
|
+
elsif number = @repository.guess_current_ticket_number
|
98
|
+
number
|
99
|
+
elsif block_given?
|
100
|
+
yield
|
101
|
+
else
|
102
|
+
abort "ticket number required"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def require_ticket_number
|
107
|
+
@opts.separator("Ticket number is required unless it can be derived from the current branch.\n")
|
108
|
+
end
|
109
|
+
|
110
|
+
def abort(message)
|
111
|
+
$stderr.puts "Error: #{message}"
|
112
|
+
# $stderr.puts @opts
|
113
|
+
exit(1)
|
114
|
+
end
|
115
|
+
|
116
|
+
def add_options(opts)
|
117
|
+
end
|
118
|
+
|
119
|
+
def banner_arguments
|
120
|
+
"[options]"
|
121
|
+
end
|
122
|
+
|
123
|
+
def command
|
124
|
+
self.class.name[/[^:]*$/].gsub(/(.)([A-Z])/) { $1+"-"+$2 }.downcase
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
|
129
|
+
class Download < Base
|
130
|
+
|
131
|
+
def description
|
132
|
+
<<-EOF
|
133
|
+
Download all attachments that look like patches to the current working
|
134
|
+
directory.
|
135
|
+
EOF
|
136
|
+
end
|
137
|
+
|
138
|
+
def banner_arguments
|
139
|
+
"[options] [ticket]"
|
140
|
+
end
|
141
|
+
|
142
|
+
def add_options(opts)
|
143
|
+
require_ticket_number
|
144
|
+
opts.separator("Options:")
|
145
|
+
opts.on("--filter PATTERN","only download patches matching PATTERN") do |pattern|
|
146
|
+
options[:filter] = pattern
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def run
|
151
|
+
number = get_ticket_number
|
152
|
+
@repository.ticket(number).attachments.each do |attach|
|
153
|
+
next if options[:filter] && attach.filename !~ /#{options[:filter]}/
|
154
|
+
File.open(attach.filename, "w") do |f|
|
155
|
+
f.puts attach.body
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
end
|
161
|
+
|
162
|
+
class Show < Base
|
163
|
+
|
164
|
+
def banner_arguments
|
165
|
+
"[ticket]"
|
166
|
+
end
|
167
|
+
|
168
|
+
def description
|
169
|
+
<<-EOF
|
170
|
+
Show a crude ticket summary.
|
171
|
+
EOF
|
172
|
+
end
|
173
|
+
|
174
|
+
def add_options(opts)
|
175
|
+
require_ticket_number
|
176
|
+
end
|
177
|
+
|
178
|
+
def run
|
179
|
+
number = get_ticket_number
|
180
|
+
csv = @repository.ticket(number).csv
|
181
|
+
csv.reject {|k,v| k == "description"}.sort.each do |(k,v)|
|
182
|
+
puts "#{k}: #{v}" if v
|
183
|
+
end
|
184
|
+
puts "description:"
|
185
|
+
puts csv["description"]
|
186
|
+
end
|
187
|
+
|
188
|
+
end
|
189
|
+
|
190
|
+
class Fetch < Base
|
191
|
+
|
192
|
+
def banner_arguments
|
193
|
+
"[ticket]"
|
194
|
+
end
|
195
|
+
|
196
|
+
def description
|
197
|
+
<<-EOF
|
198
|
+
Download all branches that look like patches. For each patch, find the
|
199
|
+
revision of trunk that most recently proceeds the the time of upload, apply
|
200
|
+
the patch to it, create a new commit, and add a remote head of the form
|
201
|
+
refs/remotes/trac/ticketnumber/file_name_ext. Patches with the same root
|
202
|
+
name (filename.diff, filename.2.diff, filename.patch, etc.) will committed in
|
203
|
+
parent child relationships based on upload time. This means filename.2.diff
|
204
|
+
might cite filename.diff as a parent. For each root name, a branch is created
|
205
|
+
pointing to the newest patch. Existing branches will not be overridden, but
|
206
|
+
there is an implied `git cleanup <patch>` that runs beforehand which could
|
207
|
+
potentially remove conflicted branches first.
|
208
|
+
EOF
|
209
|
+
end
|
210
|
+
|
211
|
+
def add_options(opts)
|
212
|
+
require_ticket_number
|
213
|
+
end
|
214
|
+
|
215
|
+
def run
|
216
|
+
number = get_ticket_number
|
217
|
+
@repository.ticket(number).fetch do |attachment, branch|
|
218
|
+
if branch
|
219
|
+
puts "#{attachment.filename}: #{branch}"
|
220
|
+
else
|
221
|
+
$stderr.puts "#{attachment.filename} FAILED"
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
end
|
227
|
+
|
228
|
+
class Cleanup < Base
|
229
|
+
|
230
|
+
def banner_arguments
|
231
|
+
"[options] [ticket] [ticket] ..."
|
232
|
+
end
|
233
|
+
|
234
|
+
def description
|
235
|
+
<<-EOF
|
236
|
+
Remove remote heads for a given ticket (e.g., trac/12345/work_patch). Also
|
237
|
+
removes branches that point to one of these heads. Branches that have been
|
238
|
+
committed to will not be removed. The default is to target tickets that have
|
239
|
+
been closed, but you can also specify ticket numbers explicitly or use --all.
|
240
|
+
EOF
|
241
|
+
end
|
242
|
+
|
243
|
+
def add_options(opts)
|
244
|
+
opts.separator("Options:")
|
245
|
+
opts.on("-a","--all", "cleanup all tickets") { options[:all] = true }
|
246
|
+
end
|
247
|
+
|
248
|
+
def run
|
249
|
+
if options[:all]
|
250
|
+
@repository.working_tickets.each do |t|
|
251
|
+
t.cleanup
|
252
|
+
end
|
253
|
+
elsif @argv.any?
|
254
|
+
begin
|
255
|
+
@argv.each do |a|
|
256
|
+
@repository.ticket(Integer(a)).cleanup
|
257
|
+
end
|
258
|
+
rescue TypeError
|
259
|
+
abort "invalid ticket number"
|
260
|
+
end
|
261
|
+
else
|
262
|
+
@repository.working_tickets.each do |t|
|
263
|
+
t.cleanup unless t.open?
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
class UploadPatch < Base
|
270
|
+
|
271
|
+
def banner_arguments
|
272
|
+
"[options] [ticket]"
|
273
|
+
end
|
274
|
+
|
275
|
+
def description
|
276
|
+
<<-EOF
|
277
|
+
Do a `git diff` against trunk (or another branch) and upload the result as an
|
278
|
+
attachment to a ticket. This command is experimental to take care when using
|
279
|
+
it against a production trac server.
|
280
|
+
EOF
|
281
|
+
end
|
282
|
+
|
283
|
+
def add_options(opts)
|
284
|
+
require_ticket_number
|
285
|
+
opts.on("--branch BRANCH", "git diff BRANCH (default trunk)") do |b|
|
286
|
+
options[:branch] = b
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
def run
|
291
|
+
number = get_ticket_number
|
292
|
+
if uri = @repository.ticket(number).upload_patch(options)
|
293
|
+
puts uri
|
294
|
+
else
|
295
|
+
exit 1
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
end
|
301
|
+
|
302
|
+
end
|
303
|
+
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
require 'hpricot'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module Git
|
5
|
+
module Trac
|
6
|
+
|
7
|
+
class Ticket
|
8
|
+
|
9
|
+
attr_reader :repository, :number
|
10
|
+
|
11
|
+
def initialize(repo, number)
|
12
|
+
@repository, @number = repo, Integer(number)
|
13
|
+
end
|
14
|
+
|
15
|
+
def inspect
|
16
|
+
"#<#{self.class.inspect} #{url}>"
|
17
|
+
end
|
18
|
+
|
19
|
+
def url(format = nil)
|
20
|
+
query = "?format=#{format}" if format
|
21
|
+
"#{@repository.url}/ticket/#{@number}#{query}"
|
22
|
+
end
|
23
|
+
|
24
|
+
def attachment_url
|
25
|
+
"#{@repository.url}/attachment/ticket/#{@number}"
|
26
|
+
end
|
27
|
+
|
28
|
+
def csv
|
29
|
+
require 'net/http'
|
30
|
+
require 'uri'
|
31
|
+
body = Net::HTTP.get_response(URI.parse(url(:tab))).body
|
32
|
+
headers, values = body.split(/\r?\n/).map do |line|
|
33
|
+
line.split("\t").map do |column|
|
34
|
+
column.gsub(/\\(.)/) do
|
35
|
+
case $1
|
36
|
+
when "r" then ""
|
37
|
+
when "n" then "\n"
|
38
|
+
when "t" then "\t"
|
39
|
+
else $1
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
return headers.zip(values).inject({}) {|h,(k,v)| h[k] = v; h}
|
45
|
+
end
|
46
|
+
|
47
|
+
attr_reader :number
|
48
|
+
|
49
|
+
def attachments(&block)
|
50
|
+
entries = repository.agent.get(attachment_url).at("dl.attachments")
|
51
|
+
require 'git/trac/attachment'
|
52
|
+
Attachment.from_hpricot(self,entries)
|
53
|
+
end
|
54
|
+
|
55
|
+
def trac_dir
|
56
|
+
"#{repository.git_dir}/refs/remotes/trac/#{number}"
|
57
|
+
end
|
58
|
+
|
59
|
+
class Form
|
60
|
+
def initialize(mech_form)
|
61
|
+
@mech_form = mech_form
|
62
|
+
end
|
63
|
+
|
64
|
+
def submit(*args)
|
65
|
+
@mech_form.submit
|
66
|
+
end
|
67
|
+
|
68
|
+
def method_missing(method,*args,&block)
|
69
|
+
if method.to_s[-1] == "=" && @mech_form.fields.name(method.to_s[0..-2]).any?
|
70
|
+
@mech_form.fields.name(method.to_s[0..-2]).send(:value=,*args,&block)
|
71
|
+
elsif @mech_form.fields.name(method.to_s).any?
|
72
|
+
@mech_form.fields.name(method.to_s).send(:value,*args,&block)
|
73
|
+
else
|
74
|
+
super(method,*args,&block)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def form
|
80
|
+
repository.agent.get(url).forms.last
|
81
|
+
end
|
82
|
+
|
83
|
+
def comment!(body)
|
84
|
+
form = form()
|
85
|
+
form.fields.name("comment").value = body
|
86
|
+
form.submit
|
87
|
+
end
|
88
|
+
|
89
|
+
def upload_attachment(description, filename, body)
|
90
|
+
form = repository.agent.get("#{attachment_url}?action-new").forms.last
|
91
|
+
author = form.fields.name("author")
|
92
|
+
if author.any? && author.value == "anonymous"
|
93
|
+
author.value = repository.options[:user].to_s
|
94
|
+
end
|
95
|
+
form.fields.name("description").value = description.to_s
|
96
|
+
attachment = form.file_uploads.name("attachment").first
|
97
|
+
attachment.file_name = filename
|
98
|
+
attachment.file_data = body
|
99
|
+
form.submit.instance_variable_get(:@uri)
|
100
|
+
end
|
101
|
+
|
102
|
+
def upload_patch(options = {})
|
103
|
+
filename = options[:filename] || "#{File.basename(repository.current_checkout)}.patch"
|
104
|
+
# diff = repository.exec("git-diff","#{options[:branch] || "trunk"}...HEAD")
|
105
|
+
diff = repository.exec("git-diff", options[:branch] || "trunk")
|
106
|
+
return false if diff.empty?
|
107
|
+
# Don't upload the exact same patch that was pulled down
|
108
|
+
return false if repository.generated_commits[repository.rev_parse("HEAD")] == number
|
109
|
+
upload_attachment(options[:description], filename, diff)
|
110
|
+
end
|
111
|
+
|
112
|
+
def open?
|
113
|
+
c = csv
|
114
|
+
%w(new reopened).include?(c["status"])
|
115
|
+
end
|
116
|
+
|
117
|
+
def cleanup
|
118
|
+
revs = []
|
119
|
+
repository.each_ref("refs/remotes/trac/#{number}") do |object, ref|
|
120
|
+
revs << object
|
121
|
+
repository.exec("git-update-ref","-d",ref,object)
|
122
|
+
end
|
123
|
+
unless revs.empty?
|
124
|
+
repository.each_ref("refs/heads") do |object, ref|
|
125
|
+
if revs.include?(object) && repository.current_checkout != File.basename(ref)
|
126
|
+
repository.exec("git-branch","-D",File.basename(ref))
|
127
|
+
end
|
128
|
+
end
|
129
|
+
true
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def fetch(options = {})
|
134
|
+
cleanup
|
135
|
+
seen = {}
|
136
|
+
repository.with_index("tracindex#{$$}") do
|
137
|
+
FileUtils.mkdir_p(trac_dir)
|
138
|
+
attachments.map do |attachment|
|
139
|
+
parent = repository.exec("git-rev-list","--max-count=1","--before=#{attachment.timestamp}",options[:branch] || 'trunk').chomp
|
140
|
+
repository.exec("git-read-tree",parent)
|
141
|
+
unless attachment.apply!
|
142
|
+
yield attachment, false if block_given?
|
143
|
+
next
|
144
|
+
end
|
145
|
+
tree = repository.exec("git-write-tree").chomp
|
146
|
+
ENV["GIT_AUTHOR_NAME"] = ENV["GIT_COMMITTER_NAME"] = attachment.username
|
147
|
+
ENV["GIT_AUTHOR_EMAIL"] = ENV["GIT_COMMITTER_EMAIL"] = attachment.email
|
148
|
+
ENV["GIT_AUTHOR_DATE"] = ENV["GIT_COMMITTER_DATE"] = attachment.timestamp
|
149
|
+
if repeated = seen[attachment.name]
|
150
|
+
if repeated.last == parent
|
151
|
+
parents = " -p #{repeated.first}"
|
152
|
+
else
|
153
|
+
parents = " -p #{repeated.first} -p #{parent}"
|
154
|
+
end
|
155
|
+
else
|
156
|
+
parents = " -p #{parent}"
|
157
|
+
end
|
158
|
+
commit = repository.popen3("git-commit-tree #{tree}#{parents}") do |i,o,e|
|
159
|
+
i.puts attachment.description || attachment.filename
|
160
|
+
i.close
|
161
|
+
o.read.chomp
|
162
|
+
end
|
163
|
+
File.open(attachment.tag_path,"w") do |f|
|
164
|
+
f.puts commit
|
165
|
+
end
|
166
|
+
yield attachment, attachment.tag_name if block_given?
|
167
|
+
seen[attachment.name] = [commit, parent]
|
168
|
+
commit
|
169
|
+
end
|
170
|
+
end
|
171
|
+
seen.each do |k,v|
|
172
|
+
if !File.exists?(path = "#{repository.git_dir}/refs/heads/#{k}")
|
173
|
+
File.open(path, "w") {|f| f.puts v.first}
|
174
|
+
end
|
175
|
+
end
|
176
|
+
ensure
|
177
|
+
%w(GIT_AUTHOR_NAME GIT_COMMITTER_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_EMAIL GIT_AUTHOR_DATE GIT_COMMITTER_DATE).each {|e| ENV[e] = nil}
|
178
|
+
end
|
179
|
+
|
180
|
+
end
|
181
|
+
|
182
|
+
end
|
183
|
+
end
|