gitgo 0.3.3

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.
Files changed (76) hide show
  1. data/History +44 -0
  2. data/License.txt +22 -0
  3. data/README +45 -0
  4. data/bin/gitgo +4 -0
  5. data/lib/gitgo.rb +1 -0
  6. data/lib/gitgo/app.rb +63 -0
  7. data/lib/gitgo/controller.rb +89 -0
  8. data/lib/gitgo/controllers/code.rb +198 -0
  9. data/lib/gitgo/controllers/issue.rb +76 -0
  10. data/lib/gitgo/controllers/repo.rb +186 -0
  11. data/lib/gitgo/controllers/wiki.rb +19 -0
  12. data/lib/gitgo/document.rb +680 -0
  13. data/lib/gitgo/document/invalid_document_error.rb +34 -0
  14. data/lib/gitgo/documents/comment.rb +20 -0
  15. data/lib/gitgo/documents/issue.rb +56 -0
  16. data/lib/gitgo/git.rb +941 -0
  17. data/lib/gitgo/git/tree.rb +315 -0
  18. data/lib/gitgo/git/utils.rb +59 -0
  19. data/lib/gitgo/helper.rb +3 -0
  20. data/lib/gitgo/helper/doc.rb +28 -0
  21. data/lib/gitgo/helper/form.rb +88 -0
  22. data/lib/gitgo/helper/format.rb +200 -0
  23. data/lib/gitgo/helper/html.rb +19 -0
  24. data/lib/gitgo/helper/utils.rb +85 -0
  25. data/lib/gitgo/index.rb +421 -0
  26. data/lib/gitgo/index/idx_file.rb +119 -0
  27. data/lib/gitgo/index/sha_file.rb +135 -0
  28. data/lib/gitgo/patches/grit.rb +47 -0
  29. data/lib/gitgo/repo.rb +626 -0
  30. data/lib/gitgo/repo/graph.rb +333 -0
  31. data/lib/gitgo/repo/node.rb +122 -0
  32. data/lib/gitgo/rest.rb +87 -0
  33. data/lib/gitgo/server.rb +114 -0
  34. data/lib/gitgo/version.rb +8 -0
  35. data/public/css/gitgo.css +24 -0
  36. data/public/javascript/gitgo.js +148 -0
  37. data/public/javascript/jquery-1.4.2.min.js +154 -0
  38. data/views/app/index.erb +4 -0
  39. data/views/app/timeline.erb +27 -0
  40. data/views/app/welcome.erb +13 -0
  41. data/views/code/_comment.erb +10 -0
  42. data/views/code/_comment_form.erb +14 -0
  43. data/views/code/_comments.erb +5 -0
  44. data/views/code/_commit.erb +25 -0
  45. data/views/code/_grepnav.erb +5 -0
  46. data/views/code/_treenav.erb +3 -0
  47. data/views/code/blob.erb +6 -0
  48. data/views/code/commit_grep.erb +35 -0
  49. data/views/code/commits.erb +11 -0
  50. data/views/code/diff.erb +10 -0
  51. data/views/code/grep.erb +32 -0
  52. data/views/code/index.erb +17 -0
  53. data/views/code/obj/blob.erb +4 -0
  54. data/views/code/obj/commit.erb +25 -0
  55. data/views/code/obj/tag.erb +25 -0
  56. data/views/code/obj/tree.erb +9 -0
  57. data/views/code/tree.erb +9 -0
  58. data/views/error.erb +19 -0
  59. data/views/issue/_issue.erb +15 -0
  60. data/views/issue/_issue_form.erb +39 -0
  61. data/views/issue/edit.erb +11 -0
  62. data/views/issue/index.erb +28 -0
  63. data/views/issue/new.erb +5 -0
  64. data/views/issue/show.erb +27 -0
  65. data/views/layout.erb +34 -0
  66. data/views/not_found.erb +1 -0
  67. data/views/repo/fsck.erb +29 -0
  68. data/views/repo/help.textile +5 -0
  69. data/views/repo/help/faq.textile +19 -0
  70. data/views/repo/help/howto.textile +31 -0
  71. data/views/repo/help/trouble.textile +28 -0
  72. data/views/repo/idx.erb +29 -0
  73. data/views/repo/index.erb +72 -0
  74. data/views/repo/status.erb +16 -0
  75. data/views/wiki/index.erb +3 -0
  76. metadata +253 -0
@@ -0,0 +1,76 @@
1
+ require 'gitgo/controller'
2
+ require 'gitgo/documents/issue'
3
+
4
+ module Gitgo
5
+ module Controllers
6
+ class Issue < Controller
7
+ include Rest
8
+
9
+ set :views, File.expand_path("views/issue", ROOT)
10
+
11
+ get('/issue') { index }
12
+ get('/issue/new') { preview }
13
+ get('/issue/:sha') {|sha| show(sha) }
14
+ get('/issue/:sha/edit') {|sha| edit(sha) }
15
+
16
+ post('/issue') { create }
17
+ post('/issue/:sha') {|sha|
18
+ _method = request[:_method]
19
+ case _method
20
+ when /\Aupdate\z/i then update(sha)
21
+ when /\Adelete\z/i then destroy(sha)
22
+ else create(sha)
23
+ end
24
+ }
25
+
26
+ put('/issue/:sha') {|sha| update(sha) }
27
+ delete('/issue/:sha') {|sha| destroy(sha) }
28
+
29
+ #
30
+ # actions
31
+ #
32
+
33
+ Issue = Documents::Issue
34
+
35
+ def index
36
+ all = request['all']
37
+ any = request['any']
38
+
39
+ if tags = request['tags']
40
+ tags = [tags] unless tags.kind_of?(Array)
41
+ ((all ||= {})['tags'] ||= []).concat(tags)
42
+ end
43
+
44
+ issues = Issue.find(all, any)
45
+
46
+ # sort results
47
+ sort = request['sort'] || 'date'
48
+ reverse = request['reverse'] == 'true'
49
+
50
+ issues.sort! {|a, b| a[sort] <=> b[sort] }
51
+ issues.reverse! if reverse
52
+
53
+ erb :index, :locals => {
54
+ :docs => issues,
55
+ :any => any || {},
56
+ :all => all || {},
57
+ :sort => sort,
58
+ :reverse => reverse,
59
+ :active_sha => session_head
60
+ }
61
+ end
62
+
63
+ def tags
64
+ repo.index.values('tags')
65
+ end
66
+
67
+ def model
68
+ Issue
69
+ end
70
+
71
+ def attrs
72
+ request['doc'] || {'tags' => ['open'], 'at' => session_head}
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,186 @@
1
+ require 'gitgo/controller'
2
+
3
+ module Gitgo
4
+ module Controllers
5
+ class Repo < Controller
6
+ set :views, File.expand_path('views/repo', ROOT)
7
+
8
+ get('/repo') { index }
9
+ get('/repo/index') { show_idx }
10
+ get('/repo/index/:key') {|key| show_idx(key) }
11
+ get('/repo/index/:k/:v') {|key, value| show_idx(key, value) }
12
+ get('/repo/status') { repo_status }
13
+ get('/repo/fsck') { fsck }
14
+ get('/repo/*') {|path| template(path) }
15
+
16
+ post('/repo/track') { track }
17
+ post('/repo/commit') { commit }
18
+ post('/repo/update') { update }
19
+ post('/repo/reindex') { reset }
20
+ post('/repo/reset') { reset }
21
+ post('/repo/prune') { prune }
22
+ post('/repo/gc') { gc }
23
+ post('/repo/setup') { setup }
24
+
25
+ def git
26
+ @git ||= repo.git
27
+ end
28
+
29
+ def grit
30
+ @grit ||= git.grit
31
+ end
32
+
33
+ #
34
+ # actions
35
+ #
36
+
37
+ def index
38
+ erb :index, :locals => {
39
+ :path => repo.path,
40
+ :branch => repo.branch,
41
+ :commit => repo.head.nil? ? nil : grit.commit(repo.head),
42
+ :upstream_branch => repo.upstream_branch,
43
+ :refs => repo.refs,
44
+ :active_sha => session_head,
45
+ :active_commit => session_head ? grit.commit(session_head) : nil,
46
+ }
47
+ end
48
+
49
+ def template(path)
50
+ begin
51
+ textile path.to_sym
52
+ rescue(Errno::ENOENT)
53
+ $!.message.include?(path) ? not_found : raise
54
+ end
55
+ end
56
+
57
+ def show_idx(key=nil, value=nil)
58
+ index = repo.index
59
+
60
+ erb :idx, :locals => {
61
+ :current_key => key,
62
+ :index_keys => index.keys.sort,
63
+ :current_value => value,
64
+ :index_values => key ? index.values(key).sort : [],
65
+ :shas => key && value ? index[key][value].collect {|idx| index.list[idx] } : index.list
66
+ }
67
+ end
68
+
69
+ # (note status is taken as a method by Sinatra)
70
+ def repo_status
71
+ erb :status, :locals => {
72
+ :branch => git.branch,
73
+ :status => git.status(true)
74
+ }
75
+ end
76
+
77
+ def fsck
78
+ erb :fsck, :locals => {
79
+ :branch => git.branch,
80
+ :head => session_head,
81
+ :issues => git.fsck.split("\n"),
82
+ :stats => git.stats
83
+ }
84
+ end
85
+
86
+ def commit
87
+ repo.commit request['message']
88
+ redirect url('/repo/status')
89
+ end
90
+
91
+ def update
92
+ unless repo.status.empty?
93
+ raise 'local changes; cannot update'
94
+ end
95
+
96
+ upstream_branch = request['upstream_branch'] || git.upstream_branch
97
+ unless upstream_branch.nil? || upstream_branch.empty?
98
+
99
+ # Note that push and pull cannot be cleanly supported as separate
100
+ # updates because pull can easily fail without a preceding pull. Since
101
+ # there is no good way to detect that failure, see issue 7f7e85, the
102
+ # next best option is to ensure a pull if doing a push.
103
+ git.pull(upstream_branch)
104
+ Document.update_index
105
+
106
+ if request['sync'] == 'true'
107
+ git.push(upstream_branch)
108
+ end
109
+ end
110
+
111
+ redirect url('/repo')
112
+ end
113
+
114
+ def track
115
+ tracking_branch = request['tracking_branch']
116
+ git.track(tracking_branch.empty? ? nil : tracking_branch)
117
+
118
+ redirect url('/repo')
119
+ end
120
+
121
+ def reset
122
+ repo.index.clear
123
+
124
+ if full = request['full']
125
+ git.reset(full == 'true')
126
+ end
127
+
128
+ Document.update_index
129
+ redirect env['HTTP_REFERER'] || url('/repo')
130
+ end
131
+
132
+ def prune
133
+ git.prune
134
+ redirect url('/repo/fsck')
135
+ end
136
+
137
+ def gc
138
+ git.gc
139
+ redirect url('/repo/fsck')
140
+ end
141
+
142
+ def setup
143
+ gitgo = request['gitgo'] || {}
144
+ if branch = gitgo['branch']
145
+ repo.checkout(branch)
146
+ end
147
+
148
+ if upstream_branch = request['upstream_branch']
149
+ repo.setup(upstream_branch)
150
+ end
151
+
152
+ session = request['session'] || {}
153
+ if head = session['head']
154
+ self.session_head = head.strip.empty? ? nil : head
155
+ end
156
+
157
+ redirect request['redirect'] || env['HTTP_REFERER'] || url('/repo')
158
+ end
159
+
160
+ # Renders template as erb, then formats using RedCloth.
161
+ def textile(template, options={}, locals={})
162
+ require_warn('RedCloth') unless defined?(::RedCloth)
163
+
164
+ # extract generic options
165
+ layout = options.delete(:layout)
166
+ layout = :layout if layout.nil? || layout == true
167
+ views = options.delete(:views) || self.class.views || "./views"
168
+ locals = options.delete(:locals) || locals || {}
169
+
170
+ # render template
171
+ data, options[:filename], options[:line] = lookup_template(:textile, template, views)
172
+ output = format.textile render_erb(template, data, options, locals)
173
+
174
+ # render layout
175
+ if layout
176
+ data, options[:filename], options[:line] = lookup_layout(:erb, layout, views)
177
+ if data
178
+ output = render_erb(layout, data, options, locals) { output }
179
+ end
180
+ end
181
+
182
+ output
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,19 @@
1
+ require 'gitgo/controller'
2
+
3
+ module Gitgo
4
+ module Controllers
5
+ class Wiki < Controller
6
+ set :views, File.expand_path("views/wiki", ROOT)
7
+
8
+ get("/wiki") { index }
9
+
10
+ #
11
+ # actions
12
+ #
13
+
14
+ def index
15
+ erb :index
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,680 @@
1
+ require 'gitgo/repo'
2
+ require 'gitgo/document/invalid_document_error'
3
+
4
+ module Gitgo
5
+
6
+ # Document represents the data model of Gitgo, and provides high(er)-level
7
+ # access to documents stored in a Repo. Content and data consistency
8
+ # constraints are enforced on Document and not on Repo. As such, Document
9
+ # should be the only way casual users enter data into a Repo.
10
+ #
11
+ # == Usage
12
+ #
13
+ # For the most part Document behaves like a standard ORM model. The primary
14
+ # gotcha revolves around setting documents into the git repository and
15
+ # exists to prevent the creation of unnecessary git objects.
16
+ #
17
+ # Unlike you would expect, two method calls are required to store a
18
+ # document:
19
+ #
20
+ # a = Document.new(:content => 'a')
21
+ # a.save
22
+ # a.create
23
+ #
24
+ # The save method sets the document data into the git repo as a blob and
25
+ # records the blob sha as a unique identifier for the document. The create
26
+ # method is what indicates the document is the head of a new document graph.
27
+ # Simply calling save is not enough (indeed the result of save is a hanging
28
+ # blob that can be gc'd by git).
29
+ #
30
+ # The link and update methods are used instead of create to associate new
31
+ # documents into an existing graph. For example:
32
+ #
33
+ # b = Document.new(:content => 'b')
34
+ # b.save
35
+ # a.link(b)
36
+ #
37
+ # Calling create prevents a document from being linked into another graph
38
+ # and vice-versa; the intent is that a given document only belongs to one
39
+ # document graph. This constraint is only enforced at the Document level
40
+ # and represents the main reason why using repo directy is a no-no.
41
+ #
42
+ # Additionally, as in the command-line git workflow, newly added documents
43
+ # are not actually committed to a repo until commit is called.
44
+ class Document
45
+ class << self
46
+
47
+ # Returns a hash registry mapping a type string to a Document class.
48
+ # Document itself is registered as the nil type. Types also includes
49
+ # reverse mappings for a Document class to it's type string.
50
+ attr_reader :types
51
+
52
+ # A hash of (key, validator) pairs mapping attribute keys to a
53
+ # validation method. Not all attributes will have a validator whereas
54
+ # some attributes share the same validation method.
55
+ attr_reader :validators
56
+
57
+ def inherited(base) # :nodoc:
58
+ base.instance_variable_set(:@validators, validators.dup)
59
+ base.instance_variable_set(:@types, types)
60
+ base.register_as base.to_s.split('::').last.downcase
61
+ end
62
+
63
+ # Returns the Repo currently in scope (see Repo.current)
64
+ def repo
65
+ Repo.current
66
+ end
67
+
68
+ # Returns the type string for self.
69
+ def type
70
+ types[self]
71
+ end
72
+
73
+ # Creates a new document with the attributes and saves. Saved documents
74
+ # are not automatically associated with a document graph and must be
75
+ # associated with one via create/update/link to be permanently stored in
76
+ # the repo.
77
+ def save(attrs={})
78
+ doc = new(attrs, repo)
79
+ doc.save
80
+ doc.reindex
81
+ doc
82
+ end
83
+
84
+ # Creates a new document with the attrs. The document is saved,
85
+ # created, and indexed before being returned.
86
+ def create(attrs={})
87
+ update_index
88
+ doc = save(attrs)
89
+ doc.create
90
+ doc
91
+ end
92
+
93
+ # Reads the specified document and casts it into an instance as per
94
+ # cast. Returns nil if the document doesn't exist.
95
+ #
96
+ # == Usage Note
97
+ #
98
+ # Read will re-read the document directly from the git repository every
99
+ # time it is called. For better performance, use the AGET method which
100
+ # performs the same read but uses the Repo cache if possible.
101
+ def read(sha)
102
+ sha = repo.resolve(sha)
103
+ attrs = repo.read(sha)
104
+
105
+ attrs ? cast(attrs, sha) : nil
106
+ end
107
+
108
+ # Reads the specified document from the repo cache and casts it into an
109
+ # instance as per cast. Returns nil if the document doesn't exist.
110
+ def [](sha)
111
+ sha = repo.resolve(sha)
112
+ attrs = repo[sha]
113
+
114
+ attrs ? cast(attrs, sha) : nil
115
+ end
116
+
117
+ # Casts the attributes hash into a document instance. The document
118
+ # class is determined by resolving the 'type' attribute against the
119
+ # types registry.
120
+ def cast(attrs, sha)
121
+ type = attrs['type']
122
+ klass = types[type] or raise "unknown type: #{type}"
123
+ klass.new(attrs, repo, sha)
124
+ end
125
+
126
+ # Updates and indexes the old document with new attributes. The new
127
+ # attributes are merged with the current doc attributes. Returns the
128
+ # new document.
129
+ #
130
+ # The new document can be used to update other documents, if necessary,
131
+ # as when resolving forks in an update graph:
132
+ #
133
+ # a = Document.create(:content => 'a')
134
+ # b = Document.update(a, :content => 'b')
135
+ # c = Document.update(a, :content => 'c')
136
+ #
137
+ # d = Document.update(b, :content => 'd')
138
+ # c.update(d)
139
+ #
140
+ # a.reset
141
+ # a.node.versions.uniq # => [d.sha]
142
+ #
143
+ def update(old_doc, attrs={})
144
+ update_index
145
+
146
+ unless old_doc.kind_of?(Document)
147
+ old_doc = Document[old_doc]
148
+ end
149
+
150
+ new_doc = old_doc.merge(attrs)
151
+ new_doc.save
152
+ new_doc.reindex
153
+
154
+ old_doc.update(new_doc)
155
+ new_doc
156
+ end
157
+
158
+ # Finds all documents matching the any and all criteria. The any and
159
+ # all inputs are hashes of index values used to filter all possible
160
+ # documents. They consist of (key, value) or (key, [values]) pairs. At
161
+ # least one of pair must match in the any case. All pairs must match in
162
+ # the all case. Specify nil for either array to prevent filtering using
163
+ # that criteria.
164
+ #
165
+ # See basis for more detail regarding the scope of 'all documents' that
166
+ # can be found via find.
167
+ def find(all={}, any=nil)
168
+ update_index
169
+
170
+ repo.index.select(
171
+ :basis => basis,
172
+ :all => all,
173
+ :any => any,
174
+ :shas => true
175
+ ).collect! {|sha| self[sha] }
176
+ end
177
+
178
+ def delete(sha)
179
+ doc = self[sha]
180
+ doc.delete
181
+ doc
182
+ end
183
+
184
+ # Performs a partial update of the document index. All documents added
185
+ # between the index-head and the repo-head are updated using this
186
+ # method.
187
+ #
188
+ # Specify reindex to clobber and completely rebuild the index.
189
+ def update_index(reindex=false)
190
+ index = repo.index
191
+ index.clear if reindex
192
+ git_head, index_head = repo.git.head, index.head
193
+
194
+ # if the index is up-to-date save the work of doing diff
195
+ if git_head.nil? || git_head == index_head
196
+ return []
197
+ end
198
+
199
+ shas = repo.diff(index_head, git_head)
200
+ shas.each do |source|
201
+ doc = self[source]
202
+ doc.reindex
203
+
204
+ repo.each_assoc(source) do |target, type|
205
+ index.assoc(source, target, type)
206
+ end
207
+ end
208
+
209
+ index.write(git_head)
210
+ shas
211
+ end
212
+
213
+ protected
214
+
215
+ # Returns the basis for finds, ie the set of documents that get filtered
216
+ # by the find method.
217
+ #
218
+ # If type is specified for self, then only documents of type will be
219
+ # available (ie Issue.find will only find documents of type 'issue').
220
+ # Document itself will filter all documents with an email; which should
221
+ # typically represent all possible documents.
222
+ def basis
223
+ type ? repo.index['type'][type] : repo.index.all('email')
224
+ end
225
+
226
+ # Registers self as the specified type. The previous registered type is
227
+ # overridden.
228
+ def register_as(type)
229
+ types.delete_if {|key, value| key == self || value == self }
230
+ types[type] = self
231
+ types[self] = type
232
+ end
233
+
234
+ # Turns on attribute definition for the duration of the block. If
235
+ # attribute definition is on, then the standard attribute declarations
236
+ # (attr_reader, attr_writer, attr_accessor) will create accessors for
237
+ # the attrs hash rather than instance variables.
238
+ #
239
+ # Moreover, blocks given to attr_writer/attr_accessor will be used to
240
+ # define a validator for the accessor.
241
+ def define_attributes(&block)
242
+ begin
243
+ @define_attributes = true
244
+ instance_eval(&block)
245
+ ensure
246
+ @define_attributes = false
247
+ end
248
+ end
249
+
250
+ def attr_reader(*keys) # :nodoc:
251
+ return super unless @define_attributes
252
+ keys.each do |key|
253
+ key = key.to_s
254
+ define_method(key) { attrs[key] }
255
+ end
256
+ end
257
+
258
+ def attr_writer(*keys, &block) # :nodoc:
259
+ return super unless @define_attributes
260
+ keys.each do |key|
261
+ key = key.to_s
262
+ define_method("#{key}=") {|value| attrs[key] = value }
263
+ validate(key, &block) if block_given?
264
+ end
265
+ end
266
+
267
+ def attr_accessor(*keys, &block) # :nodoc:
268
+ return super unless @define_attributes
269
+ attr_reader(*keys)
270
+ attr_writer(*keys, &block)
271
+ end
272
+
273
+ # Registers the validator method to validate the specified attribute. If
274
+ # a block is given, it will be used to define the validator as a
275
+ # protected instance method (otherwise you need to define the validator
276
+ # method manually).
277
+ def validate(key, validator="validate_#{key}", &block)
278
+ validators[key.to_s] = validator.to_sym
279
+
280
+ if block_given?
281
+ define_method(validator, &block)
282
+ protected validator
283
+ end
284
+ end
285
+ end
286
+
287
+ AUTHOR = /\A.*?<.*?>\z/
288
+ DATE = /\A\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d-\d\d:\d\d\z/
289
+ SHA = Git::SHA
290
+
291
+ @define_attributes = false
292
+ @validators = {}
293
+ @types = {}
294
+ register_as(nil)
295
+
296
+ # The repo this document belongs to.
297
+ attr_reader :repo
298
+
299
+ # A hash of the document attributes, corresponding to what is stored in
300
+ # the repo.
301
+ attr_reader :attrs
302
+
303
+ # The document sha, unset until the document is saved.
304
+ attr_reader :sha
305
+
306
+ validate(:author) {|author| validate_format(author, AUTHOR) }
307
+ validate(:date) {|date| validate_format(date, DATE) }
308
+
309
+ define_attributes do
310
+ attr_accessor(:at) {|at| validate_format_or_nil(at, SHA) }
311
+ attr_writer(:tags) {|tags| validate_array_or_nil(tags) }
312
+ attr_accessor(:type)
313
+ end
314
+
315
+ def initialize(attrs={}, repo=nil, sha=nil)
316
+ @repo = repo || Repo.current
317
+ @attrs = attrs
318
+ reset(sha)
319
+ end
320
+
321
+ # Returns the repo index.
322
+ def index
323
+ repo.index
324
+ end
325
+
326
+ def idx
327
+ sha ? index.idx(sha) : nil
328
+ end
329
+
330
+ def graph_head_idx
331
+ index.graph_head_idx(idx)
332
+ end
333
+
334
+ def graph_head?
335
+ graph_head_idx == idx
336
+ end
337
+
338
+ def graph_head
339
+ idx = graph_head_idx
340
+ idx ? index.list[idx] : nil
341
+ end
342
+
343
+ def graph
344
+ @graph ||= repo.graph(graph_head)
345
+ end
346
+
347
+ def node
348
+ graph[sha]
349
+ end
350
+
351
+ # Gets the specified attribute.
352
+ def [](key)
353
+ attrs[key]
354
+ end
355
+
356
+ # Sets the specified attribute.
357
+ def []=(key, value)
358
+ attrs[key] = value
359
+ end
360
+
361
+ def author=(author)
362
+ if author.kind_of?(Grit::Actor)
363
+ email = author.email
364
+ author = blank?(email) ? author.name : "#{author.name} <#{email}>".lstrip
365
+ end
366
+ self['author'] = author
367
+ end
368
+
369
+ def author(cast=true)
370
+ author = attrs['author']
371
+ if cast && author.kind_of?(String)
372
+ author = Grit::Actor.from_string(author)
373
+ end
374
+ author
375
+ end
376
+
377
+ def date=(date)
378
+ if date.respond_to?(:iso8601)
379
+ date = date.iso8601
380
+ end
381
+ self['date'] = date
382
+ end
383
+
384
+ def date(cast=true)
385
+ date = attrs['date']
386
+ if cast && date.kind_of?(String)
387
+ date = Time.parse(date)
388
+ end
389
+ date
390
+ end
391
+
392
+ def active?(commit=nil)
393
+ return true if at.nil? || commit.nil?
394
+ repo.rev_list(commit).include?(at)
395
+ end
396
+
397
+ def tags
398
+ self['tags'] ||= []
399
+ end
400
+
401
+ def summary
402
+ sha
403
+ end
404
+
405
+ def merge(attrs)
406
+ dup.merge!(attrs)
407
+ end
408
+
409
+ def merge!(attrs)
410
+ self.attrs.merge!(attrs)
411
+ self
412
+ end
413
+
414
+ def normalize
415
+ dup.normalize!
416
+ end
417
+
418
+ def normalize!
419
+ self.author ||= repo.git.author
420
+ self.date ||= Time.now
421
+
422
+ if at = attrs['at']
423
+ attrs['at'] = repo.resolve(at)
424
+ end
425
+
426
+ if tags = attrs['tags']
427
+ tags = arrayify(tags)
428
+ tags.delete_if {|tag| tag.to_s.empty? }
429
+ attrs['tags'] = tags
430
+ end
431
+
432
+ unless type = attrs['type']
433
+ default_type = self.class.type
434
+ attrs['type'] = default_type if default_type
435
+ end
436
+
437
+ self
438
+ end
439
+
440
+ def errors
441
+ errors = {}
442
+ self.class.validators.each_pair do |key, validator|
443
+ begin
444
+ send(validator, attrs[key])
445
+ rescue
446
+ errors[key] = $!
447
+ end
448
+ end
449
+ errors
450
+ end
451
+
452
+ def validate(normalize=true)
453
+ normalize! if normalize
454
+
455
+ errors = self.errors
456
+ unless errors.empty?
457
+ raise InvalidDocumentError.new(self, errors)
458
+ end
459
+ self
460
+ end
461
+
462
+ def indexes
463
+ indexes = []
464
+ each_index {|key, value| indexes << [key, value] }
465
+ indexes
466
+ end
467
+
468
+ def each_index
469
+ if author = attrs['author']
470
+ email = Grit::Actor.from_string(author).email
471
+ yield('email', blank?(email) ? 'unknown' : email)
472
+ end
473
+
474
+ if date = attrs['date']
475
+ # reformats iso8601 as YYYYMMDD
476
+ yield('date', "#{date[0,4]}#{date[5,2]}#{date[8,2]}")
477
+ end
478
+
479
+ if at = attrs['at']
480
+ yield('at', at)
481
+ end
482
+
483
+ if tags = attrs['tags']
484
+ tags.each do |tag|
485
+ yield('tags', tag)
486
+ end
487
+ end
488
+
489
+ if type = attrs['type']
490
+ yield('type', type)
491
+ end
492
+
493
+ self
494
+ end
495
+
496
+ # Validates and saves attrs into the repo, then resets self with the
497
+ # resulting sha. Returns self.
498
+ def save
499
+ validate
500
+ reset repo.save(attrs)
501
+ end
502
+
503
+ # Returns true if sha is set.
504
+ def saved?
505
+ sha.nil? ? false : true
506
+ end
507
+
508
+ # Stores self as a new graph head. Returns self.
509
+ def create
510
+ unless saved?
511
+ raise "cannot create unless saved"
512
+ end
513
+
514
+ index.create(sha)
515
+ repo.create(sha)
516
+ self
517
+ end
518
+
519
+ # Updates self with the new document. Returns self.
520
+ def update(new_doc)
521
+ unless saved?
522
+ raise "cannot update unless saved"
523
+ end
524
+
525
+ unless new_doc.saved?
526
+ raise "cannot update with an unsaved document: #{new_doc.inspect}"
527
+ end
528
+
529
+ new_sha = new_doc.sha
530
+ if repo.assoc_type(sha, new_sha) == :link
531
+ raise "cannot update with a child of self: #{sha} -> #{new_sha}"
532
+ end
533
+
534
+ index.update(sha, new_sha)
535
+ repo.update(sha, new_sha)
536
+
537
+ new_doc.reset
538
+ reset
539
+ end
540
+
541
+ def update_to(*old_docs)
542
+ originals = old_docs.collect {|old_doc| old_doc.node.original }.uniq
543
+
544
+ unless originals.length == 1
545
+ old_docs.collect! {|old_doc| old_doc.sha }
546
+ raise "cannot update unrelated documents: #{old_docs.inspect}"
547
+ end
548
+
549
+ old_docs.each do |old_doc|
550
+ old_doc.update(self)
551
+ end
552
+ self
553
+ end
554
+
555
+ # Links the child document to self. Returns self.
556
+ def link(child)
557
+ unless saved?
558
+ raise "cannot link unless saved"
559
+ end
560
+
561
+ unless child.saved?
562
+ raise "cannot link to an unsaved document: #{child.inspect}"
563
+ end
564
+
565
+ child_sha = child.sha
566
+ if repo.assoc_type(sha, child_sha) == :update
567
+ raise "cannot link to an update of self: #{sha} -> #{child_sha}"
568
+ end
569
+
570
+ index.link(sha, child_sha)
571
+ repo.link(sha, child_sha)
572
+
573
+ child.reset
574
+ reset
575
+ end
576
+
577
+ def link_to(*parents)
578
+ graph_heads = parents.collect {|parent| parent.graph_head }.uniq
579
+
580
+ unless graph_heads.length == 1
581
+ parents.collect! {|parent| parent.sha }
582
+ raise "cannot link to unrelated documents: #{parents.inspect}"
583
+ end
584
+
585
+ parents.each do |parent|
586
+ parent.link(self)
587
+ end
588
+ self
589
+ end
590
+
591
+ # Deletes self. Delete raises an error if unsaved. Returns self.
592
+ def delete
593
+ unless saved?
594
+ raise "cannot delete unless saved"
595
+ end
596
+
597
+ index.delete(sha)
598
+ repo.delete(sha)
599
+ self
600
+ end
601
+
602
+ def reindex
603
+ raise "cannot reindex unless saved" unless saved?
604
+
605
+ idx = self.idx
606
+ each_index do |key, value|
607
+ index[key][value] << idx
608
+ end
609
+
610
+ self
611
+ end
612
+
613
+ def reset(new_sha=sha)
614
+ @sha = new_sha
615
+ @graph = nil
616
+ self
617
+ end
618
+
619
+ def commit!
620
+ repo.commit!
621
+ self
622
+ end
623
+
624
+ def initialize_copy(orig)
625
+ super
626
+ reset(nil)
627
+ @attrs = orig.attrs.dup
628
+ end
629
+
630
+ def inspect
631
+ "#<#{self.class}:#{object_id} sha=#{sha.inspect}>"
632
+ end
633
+
634
+ # This is a thin equality -- use with caution.
635
+ def ==(another)
636
+ saved? && another.kind_of?(Document) ? sha == another.sha : super
637
+ end
638
+
639
+ protected
640
+
641
+ def arrayify(obj)
642
+ case obj
643
+ when Array then obj
644
+ when nil then []
645
+ when String then obj.strip.empty? ? [] : [obj]
646
+ else [obj]
647
+ end
648
+ end
649
+
650
+ def blank?(obj)
651
+ obj.nil? || obj.to_s.strip.empty?
652
+ end
653
+
654
+ def validate_not_blank(str)
655
+ if blank?(str)
656
+ raise 'nothing specified'
657
+ end
658
+ end
659
+
660
+ def validate_format(value, format)
661
+ if value.nil?
662
+ raise 'missing'
663
+ end
664
+
665
+ unless value =~ format
666
+ raise 'misformatted'
667
+ end
668
+ end
669
+
670
+ def validate_format_or_nil(value, format)
671
+ value.nil? || validate_format(value, format)
672
+ end
673
+
674
+ def validate_array_or_nil(value)
675
+ unless value.nil? || value.kind_of?(Array)
676
+ raise 'not an array'
677
+ end
678
+ end
679
+ end
680
+ end