prick 0.2.0 → 0.7.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -5
  3. data/Gemfile +4 -1
  4. data/TODO +10 -0
  5. data/doc/prick.txt +114 -0
  6. data/exe/prick +328 -402
  7. data/lib/ext/fileutils.rb +18 -0
  8. data/lib/ext/forward_method.rb +18 -0
  9. data/lib/ext/shortest_path.rb +44 -0
  10. data/lib/prick.rb +20 -10
  11. data/lib/prick/branch.rb +254 -0
  12. data/lib/prick/builder.rb +164 -0
  13. data/lib/prick/cache.rb +34 -0
  14. data/lib/prick/command.rb +19 -11
  15. data/lib/prick/constants.rb +122 -48
  16. data/lib/prick/database.rb +28 -20
  17. data/lib/prick/diff.rb +125 -0
  18. data/lib/prick/exceptions.rb +15 -3
  19. data/lib/prick/git.rb +77 -30
  20. data/lib/prick/head.rb +183 -0
  21. data/lib/prick/migration.rb +40 -200
  22. data/lib/prick/program.rb +493 -0
  23. data/lib/prick/project.rb +523 -351
  24. data/lib/prick/rdbms.rb +4 -13
  25. data/lib/prick/schema.rb +16 -90
  26. data/lib/prick/share.rb +64 -0
  27. data/lib/prick/state.rb +192 -0
  28. data/lib/prick/version.rb +62 -29
  29. data/libexec/strip-comments +33 -0
  30. data/make_releases +48 -345
  31. data/make_schema +10 -0
  32. data/prick.gemspec +14 -23
  33. data/share/diff/diff.after-tables.sql +4 -0
  34. data/share/diff/diff.before-tables.sql +4 -0
  35. data/share/diff/diff.tables.sql +8 -0
  36. data/share/migration/diff.tables.sql +8 -0
  37. data/share/migration/features.yml +6 -0
  38. data/share/migration/migrate.sql +3 -0
  39. data/share/migration/migrate.yml +8 -0
  40. data/share/migration/tables.sql +3 -0
  41. data/share/schema/build.yml +14 -0
  42. data/share/schema/schema.sql +5 -0
  43. data/share/schema/schema/build.yml +3 -0
  44. data/share/schema/schema/prick/build.yml +14 -0
  45. data/share/schema/schema/prick/data.sql +7 -0
  46. data/share/schema/schema/prick/schema.sql +5 -0
  47. data/share/{schemas/prick/schema.sql → schema/schema/prick/tables.sql} +2 -5
  48. data/{file → share/schema/schema/public/.keep} +0 -0
  49. data/share/schema/schema/public/build.yml +14 -0
  50. data/share/schema/schema/public/schema.sql +3 -0
  51. data/test_assorted +192 -0
  52. data/test_feature +112 -0
  53. data/test_refactor +34 -0
  54. data/test_single_dev +83 -0
  55. metadata +43 -68
  56. data/lib/prick/build.rb +0 -376
  57. data/lib/prick/migra.rb +0 -22
  58. data/share/schemas/prick/data.sql +0 -8
data/lib/prick/project.rb CHANGED
@@ -1,442 +1,614 @@
1
+ require "prick/state.rb"
1
2
 
2
- require "prick/constants.rb"
3
-
4
- require "prick/archive.rb"
5
- require "prick/build.rb"
6
- require "prick/migration.rb"
7
- require "prick/database.rb"
8
- require "prick/schema.rb"
9
- require "prick/git.rb"
10
- require "prick/migra.rb"
11
-
12
- require 'fileutils'
3
+ require "tmpdir"
13
4
 
14
5
  module Prick
15
6
  class Project
16
- # Name of project. Used in database and release names. Defaults to the name
17
- # of the current directory
18
7
  attr_reader :name
8
+ attr_reader :user # Database user
9
+ attr_reader :head # Current branch/tag
10
+ attr_reader :schema
19
11
 
20
- # Name of Postgresql user that own the database(s). Defaults to #name
21
- attr_reader :user
12
+ # Return the versioned database or the project database if `version` is nil
13
+ def database(version = nil)
14
+ version ? Database.new("#{name}-#{version}", user) : @database
15
+ end
22
16
 
23
- # Schema. Represents the schema definition under the schemas/ directory
24
- attr_reader :schema
25
-
26
- # The current release, prerelease or feature
27
- def release() @builds_by_name[Git.current_branch] end
28
-
29
- # The project database. The project database has the same name as the project
30
- # and doesn't include a version. It does not have to exist
31
- attr_reader :database
32
-
33
- # List of all builds
34
- def builds() @builds_by_name.values end
35
-
36
- # List of releases ordered from oldest to newest. Custom releases are sorted
37
- # after regular releases and alphabetical between themselves
38
- attr_reader :releases
39
-
40
- # List of pre-releases
41
- attr_reader :prereleases
42
-
43
- # Hash from base-release name to feature. Note that this doesn't follow release history
44
- attr_reader :features
45
-
46
- # Ignored and orphaned objects
47
- attr_reader :ignored_feature_nodes
48
- attr_reader :ignored_release_nodes
49
- attr_reader :orphan_feature_nodes
50
- attr_reader :orphan_release_nodes
51
- attr_reader :orphan_git_branches
52
-
53
- # True if we're on a release branch
54
- def release?() release && release.version.pre.nil? && !feature? end
55
-
56
- # True if we're on a pre-release branch
57
- def prerelease?() release && !release.version.pre.nil? && !feature? end
58
-
59
- # True if we're on a feature branch
60
- def feature?() release && !release.version.feature.nil? end
61
-
62
- # Sorted list of databases associated with a release. Also include the
63
- # project database if project. is true. If all: is true, also list
64
- # databases that match the project but is without an associated release
65
- def databases(all: false, project: true)
66
- r = (project ? [database] : [])
67
- if all
68
- r + Rdbms.list_databases(Prick.database_re(name)).map { |name| Database.new(name, user) }
69
- else
70
- r + @releases.map(&:database)
71
- end
17
+ attr_reader :cache
18
+
19
+ def initialize(name, user, head)
20
+ @name = name
21
+ @user = user || ENV['USER']
22
+ @head = head
23
+ @schema = Schema.new
24
+ @database = Database.new(name, user)
25
+ @cache = Cache.new
72
26
  end
73
27
 
74
- # `name` is the project name and `user` is the name of the Postgresql user.
75
- # `name` defaults to the name of the current directory and `user` defaults
76
- # to `name`
77
- def initialize(name = nil, user = nil)
78
- @name = name || File.basename(Dir.getwd)
79
- @name =~ PROJECT_NAME_RE or raise Error, "Illegal project name: #@name"
80
- @user = user || @name
81
- @user =~ USER_NAME_RE or raise Error, "Illegal postgres user name: #@user"
82
- # Rdbms.ensure_state(:user_exist, @user)
83
- Rdbms.create_user(@user) if !Rdbms.exist_user?(@user) # FIXME
84
- @schema = Schema.new(self)
85
- @database = Database.new(@name, @user)
28
+ forward_methods :version, :base_version, :@head
29
+ forward_methods :clean?, :@head
30
+ forward_methods :tag?, :release_tag?, :migration_tag?, :@head
31
+ forward_methods :branch?, :release_branch?, :prerelease_branch?, :feature_branch?, :migration_branch?, :@head
86
32
 
87
- @builds_by_name = {}
88
- @builds_by_version = {}
33
+ def self.exist?(directory)
34
+ File.directory?(directory) && Dir.chdir(directory) { ProjectState.new.exist? }
35
+ end
89
36
 
90
- @releases = []
91
- @prereleases = []
92
- @features = {}
37
+ def self.create(name, user, directory)
38
+ user ||= ENV['USER']
93
39
 
94
- @ignored_feature_nodes = []
95
- @ignored_release_nodes = []
40
+ FileUtils.mkdir(directory) if directory != "."
96
41
 
97
- @orphan_feature_nodes = []
98
- @orphan_release_nodes = [] # Includes prereleases
42
+ Dir.chdir(directory) {
43
+ # Initialize git instance
44
+ Git.init
99
45
 
100
- @orphan_git_branches = []
46
+ # Create prick version file
47
+ PrickVersion.new.write(VERSION)
101
48
 
102
- load_project
49
+ # Create project state file
50
+ ProjectState.new(name: name, user: user).write
51
+
52
+ # Directories
53
+ FileUtils.mkdir_p(DIRS)
54
+ DIRS.each { |dir| FileUtils.touch("#{dir}/.keep") }
55
+
56
+ # Copy default gitignore and schema files
57
+ Share.cp("gitignore", ".gitignore")
58
+ Share.cp("schema/schema", ".")
59
+
60
+ # Add .prick-migration file
61
+ MigrationState.new(version: Version.zero).create
62
+
63
+ # Add everything so far
64
+ Git.add(".")
65
+ Git.commit("Initial import")
66
+
67
+ # Rename branch "master"/"main" to "0.0.0_initial"
68
+ from_branch = Git.branch?("master") ? "master" : "main"
69
+ Git.rename_branch(from_branch, "0.0.0_initial")
70
+
71
+ # Create schema
72
+ schema = Schema.new
73
+ schema.version = Version.zero
74
+ Git.add schema.version_file
75
+
76
+ # Create release
77
+ Git.commit "Created release v0.0.0"
78
+ Git.create_tag(Version.zero)
79
+ Git.checkout_tag(Version.zero)
80
+ }
103
81
  end
104
82
 
105
- def [](name_or_version)
106
- name = version = name_or_version
107
- case name_or_version
108
- when String; @builds_by_name[name]
109
- when Version; @builds_by_version[version]
110
- when NilClass; nil
83
+ # Initialize from disk
84
+ #
85
+ # TODO Handle migrations
86
+ def self.load
87
+ if Git.detached?
88
+ name = "v#{Git.current_tag}"
111
89
  else
112
- raise Internal, "Expected String or Version index, got #{name_or_version.class}"
90
+ name = Git.current_branch
91
+ end
92
+ begin
93
+ branch = Head.load(name)
94
+ rescue Version::FormatError
95
+ raise Fail, "Illegal branch name: #{name}"
113
96
  end
97
+ state = ProjectState.new.read
98
+ Project.new(state.name, state.user, branch)
114
99
  end
115
100
 
116
- def []=(name_or_version, branch)
117
- case name_or_version
118
- when String;
119
- name = name_or_version
120
- version = Version.new(name)
121
- when Version
122
- version = name_or_version
123
- name = version.to_s
101
+ def build(database = self.database, version: nil)
102
+ database.clean
103
+ if version
104
+ FileUtils.mkdir_p(TMP_DIR)
105
+ Dir.mktmpdir("clone-", TMP_DIR) { |dir|
106
+ Command.command "git clone . #{dir}"
107
+ Dir.chdir(dir) {
108
+ Git.checkout_tag(version)
109
+ project = Project.load
110
+ project.head.build(database)
111
+ }
112
+ }
124
113
  else
125
- raise Internal, "Expected String or Version index, got #{name_or_version.class}"
114
+ head.build(database)
126
115
  end
127
- @builds_by_name[name] = @builds_by_version[version] = branch
116
+ self
128
117
  end
129
118
 
130
- def dirty?() !Git.clean? end
131
-
132
- def build?(name_or_version)
133
- name = version = name_or_version
134
- case name_or_version
135
- when String; @builds_by_name.key?(name)
136
- when Version; @builds_by_version.key?(version)
137
- when NilClass; nil
119
+ def load(database = self.database, version: nil, file: nil)
120
+ version.nil? ^ file.nil? or raise Internal, "Need exactly one of :file and :version to be defined"
121
+ database.clean
122
+ if version
123
+ cache.exist?(version) or raise Internal, "Can't find cache file for database #{database}"
124
+ cache.load(database, version)
138
125
  else
139
- raise Internal, "Expected String or Version index, got #{name_or_version.class}"
126
+ database.load(file)
140
127
  end
128
+ self
141
129
  end
142
130
 
143
- # Initialize an on-disk prick instance
144
- def self.initialize_directory(directory)
145
- FileUtils.mkdir_p(directory)
146
- Dir.chdir(directory) {
147
- DIRS.each { |dir|
148
- !File.exist?(dir) or raise Fail, "Already initialized: Directory '#{dir}' exists"
149
- }
150
-
151
- FileUtils.mkdir_p(DIRS)
152
- DIRS.each { |dir| FileUtils.touch("#{dir}/.keep") }
153
- }
131
+ def save(database = nil, file: nil)
132
+ !database.nil? || file or raise Internal, "Need a database when saving to file"
133
+ database ||= self.database
134
+ if file
135
+ database.save(file)
136
+ else
137
+ cache.save(database)
138
+ end
139
+ self
154
140
  end
155
141
 
156
- # Create an initial release 0.0.0. ::initialize_project expects to be executed
157
- # in the project directory
158
- def self.initialize_project(name = File.basename(Dir.getwd), user = name || ENV['USER'])
159
- FileUtils.cp("#{SHARE_PATH}/gitignore", ".gitignore")
160
- FileUtils.cp("#{SHARE_PATH}/schemas/prick/schema.sql", "schemas/prick")
161
- FileUtils.cp("#{SHARE_PATH}/schemas/prick/data.sql", "schemas/prick")
162
- Git.init
163
- Git.add(".")
164
- Git.commit("Initial import")
165
- project = Project.new(name, user)
166
- release = Release.new(project, nil, Version.new("0.0.0"))
167
- release.create
142
+ # Create a schema diff between two database versions. to_version defaults to the
143
+ # current version. Returns the Diff object
144
+ def diff(from_version, to_version)
145
+ begin
146
+ from_db = Database.new("#{name}-base", user)
147
+ to_db = Database.new("#{name}-next", user)
148
+ from_version ||= version
149
+ build(from_db, version: from_version)
150
+ build(to_db, version: to_version)
151
+ Diff.new(from_db, to_db)
152
+ ensure
153
+ from_db&.drop
154
+ to_db&.drop
155
+ end
156
+ end
168
157
 
169
- Git.delete_branch("master")
158
+ def prepare_release(fork = nil)
159
+ check_clean(:release_tag)
160
+ @head = ReleaseBranch.new(fork, version).create
161
+ submit "Prepared new release based on v#{version}", true
162
+ self
170
163
  end
171
164
 
172
- # Returns the full path to the project directory (TODO: Test)
173
- def path() File.expand_path(".") end
165
+ def prepare_diff(from_version = version)
166
+ begin
167
+ from_name = "#{name}-base"
168
+ from_db = Database.new(from_name, user)
169
+ build(from_db, version: from_version)
170
+
171
+ to_name = "#{name}-next"
172
+ to_db = Database.new(to_name, user)
173
+ build(to_db)
174
174
 
175
- ### COMMON METHODS
175
+ if prerelease_branch?
176
+ head.migrate_features(from_db)
177
+ end
176
178
 
177
- def checkout(name)
178
- check_clean
179
- Git.checkout_branch(name)
179
+ diff = Diff.new(from_db, to_db)
180
+ for path, lines, tables in [
181
+ [BEFORE_TABLES_DIFF_PATH, diff.before_table_changes, false],
182
+ [TABLES_DIFF_PATH, diff.table_changes, true],
183
+ [AFTER_TABLES_DIFF_PATH, diff.after_table_changes, false]]
184
+ if lines.empty?
185
+ if File.exist?(path)
186
+ if tables
187
+ Share.cp(File.join("diff", File.basename(path)), path)
188
+ Git.add(path)
189
+ else
190
+ Git.rm(path)
191
+ end
192
+ end
193
+ else
194
+ Share.cp(File.join("diff", File.basename(path)), path)
195
+ File.open(path, "a") { |f| f.puts lines }
196
+ Git.add(path) if !tables
197
+ end
198
+ end
199
+ self
200
+ ensure
201
+ from_db&.drop
202
+ to_db&.drop
203
+ end
180
204
  end
181
205
 
182
- # Migrate the database from its current version to the current release on disk
183
- def migrate
184
- check_clean
185
- database.exist? or raise "Project database not found"
186
- history = release.history.keys.reverse.drop(1)
187
- history.select! { |release| release.version >= database.version }
188
- history.each { |release| release.migration.migrate(database.name) }
189
- database.version = release.version
206
+ def create_release(new_version)
207
+ check_clean(:release_branch)
208
+ head = ReleaseTag.new(new_version, version)
209
+ check_migration(head.migration)
210
+ (@head = head).create
211
+ self
190
212
  end
191
213
 
192
- ### DEVELOPER-LEVEL METHODS
214
+ def generate_schema
215
+ build = SchemaBuilder.new(database, SCHEMA_DIR).build(execute: false)
216
+ puts build.lines
217
+ end
193
218
 
194
- # Create and switch to feature branch
195
- def feature(name)
196
- check_clean
197
- release? or raise Error, "Need to be on a release branch to create a feature"
198
- feature = Feature.new(self, release, name).ensure(:active)
199
- Git.commit "Created feature #{name}"
219
+ def generate_migration
220
+ build = MigrationBuilder.new(database, MIGRATION_DIR).build(execute: false)
221
+ puts build.lines
200
222
  end
201
223
 
202
- def rebase(version)
203
- check_clean
204
- feature? or raise Error, "Need to be on a feature branch to rebase"
205
- release.rebase(version)
206
- Git.commit "Rebased to #{version}"
224
+ private
225
+ def check(kind) self.send(:"#{kind}?") end
226
+ def check_clean(kind = nil)
227
+ clean? or raise Internal, "Dirty repository"
228
+ kind.nil? || check(kind) or raise Internal, "Not on a #{kind} tag/branh"
207
229
  end
208
230
 
209
- ### RELEASE MANAGER LEVEL METHODS
231
+ def check_branch() branch? or raise Internal, "Not on a branch" end
232
+ def check_tag() tag? or raise Internal, "Not on a tag" end
210
233
 
211
- # Create the first prerelease
212
- #
213
- # TODO Make it just copy files and commit them. Supports a single-user
214
- # workflow. Requires a :prepared state
215
- def prepare_release
216
- check_clean
217
- release? or raise Error, "Need to be on a release branch to prepare a new release"
218
- release.prepare
219
- end
220
-
221
- def prepare_migration
222
- check_clean
223
- prerelease? || feature? or raise Error, "Need to be on a prerelease or feature branch to include features"
224
-
225
- base_database = release.base_release.database
226
- build(base_release.version) if !base_database.loaded?
227
- build # if !database.loaded?
228
-
229
- diff_sql = release.migration.diff_sql
230
- Migra.migrate(base_database.name, database.name, diff_sql)
231
- end
232
-
233
- def create_release(version = nil)
234
- check_clean
235
- if release?
236
- !version.nil? or raise Error, "Need version argument when on a release branch"
237
- new_release = Release.new(self, release, version)
238
- elsif prerelease?
239
- version.nil? or raise Error, "Can't use version argument when on a pre-release branch"
240
- new_release = release.target_release
234
+ # FIXME: Use Cache::file
235
+ def cache_file(version) File.join(CACHE_DIR, "#{database(version)}.sql.gz") end
236
+
237
+ def submit(msg, commit = true)
238
+ Git.commit msg if commit
239
+ @message = msg
240
+ end
241
+
242
+ def clean(database) # FIXME: Use Database#clean
243
+ if database.exist?
244
+ database.recreate if database.loaded?
241
245
  else
242
- raise Error, "Need to be on a release or pre-prelease branch to create a new release"
246
+ database.create
243
247
  end
244
- new_release.create
245
- new_release
246
248
  end
247
249
 
248
- def create_prerelease(target_version)
249
- check_clean
250
- release? or raise Error, "Need to be on a release branch to create a pre-release"
251
- prerelease = Prerelease.new(self, release, target_version.increment(:pre, 0), target_version)
252
- prerelease.create
250
+ def check_migration(migration)
251
+ begin
252
+ from_version = migration.base_version
253
+ from_db = Database.new("#{name}-base", user)
254
+ to_db = Database.new("#{name}-next", user)
255
+ build(from_db, version: from_version)
256
+ build(to_db)
257
+ migration.migrate(from_db)
258
+ Diff.new(from_db, to_db).same? or raise Error, "Schema/migration mismatch"
259
+ ensure
260
+ from_db&.drop
261
+ to_db&.drop
262
+ end
253
263
  end
264
+ end
265
+ end
254
266
 
255
- def increment_prerelease
256
- check_clean
257
- prerelease? or raise Error, "Need to be on a pre-release branch to make an incremental pre-release"
258
- prerelease = Prerelease.new(self, release.base_release, release.version.increment(:pre))
259
- prerelease.create
260
- end
267
+ __END__
261
268
 
262
- def create_feature(name)
263
- check_clean
264
- release? or raise Error, "Need to be on a release branch to create a feature"
265
- feature = Feature.new(self, release, name)
266
- feature.create
267
- end
269
+ module Prick
270
+ class Project
271
+ # Name of project. Persisted in the project state file
272
+ attr_reader :name
268
273
 
269
- # Note: Does not commit
270
- def include_feature(feature_version)
271
- check_clean
272
- prerelease? || feature? or raise Error, "Need to be on a prerelease or feature branch to include features"
273
- Git.branch?(feature_version) or raise Error, "Can't find branch #{feature_version}"
274
- release.include_feature(self[feature_version])
275
- end
274
+ # Name of Postgresql user that owns the databases. Defaults to #name.
275
+ # Persisted in the project state file. TODO: Make writable
276
+ attr_reader :user
277
+
278
+ # Current branch
279
+ attr :branch
276
280
 
277
- def commit_feature
278
- msg = File.readlines(".git/MERGE_MSG").first.chomp
279
- Git.commit msg
280
- # Command.command "git commit -F .git/MERGE_MSG"
281
+ # Version of the current branch. If is defined as #branch.version
282
+ def version() branch.version end
283
+
284
+ # Project (default) database
285
+ def database(version = nil)
286
+ version ? Database.new("#{name}-#{version.truncate(:pre)}", user) : @database
281
287
  end
282
288
 
283
- def create_migration
289
+ # Last commit message. TODO: Move to git.rb
290
+ attr_reader :message
291
+
292
+ # True if we're on a tag
293
+ def tag?() Git.detached? end
294
+ def self.tag?() Git.detached? end
295
+
296
+ # Classifiers
297
+ forward_methods :release?, :prerelease?, :feature?, :migration?, :@branch
298
+
299
+ def initialize(name, user, branch)
300
+ @name = name
301
+ @user = user
302
+ @branch = branch
303
+ @database = Database.new(name, user)
284
304
  end
285
305
 
286
- # Build the current database from the content of schema
287
- def build(version = nil)
288
- if version.nil?
289
- database.recreate
290
- schema.build
306
+ def self.load
307
+ name = Git.current_branch || Git.current_tag
308
+ if name =~ MIGRATION_RE
309
+ branch = MigrationRelease.load(name)
291
310
  else
292
- release = self[version] or raise Error, "Can't find release #{version}"
293
- release.database.recreate
294
- FileUtils.mkdir_p(CACHE_DIR)
295
- if !File.directory?(File.join(CLONE_DIR, ".git"))
296
- FileUtils.rm_rf(CLONE_DIR)
297
- Command.command "git clone . #{CLONE_DIR}"
311
+ begin
312
+ version = Version.new(name)
313
+ rescue Version::FormatError
314
+ raise Fail, "Illegal branch name: #{name}"
315
+ end
316
+ if version.release?
317
+ branch = Release.load(name)
318
+ elsif version.pre?
319
+ branch = PreRelease.load(name, version)
320
+ elsif version.feature?
321
+ branch = Feature.load(name)
322
+ else
323
+ raise Oops
298
324
  end
299
- Dir.chdir(CLONE_DIR) {
300
- Git.checkout_tag(version.to_s)
301
- project = Project.new(name, user)
302
- release.database.recreate
303
- project.schema.build(release.database)
304
- }
305
- release.cache
306
325
  end
326
+ state = ProjectState.new.read
327
+ Project.new(state.name, state.user, branch)
307
328
  end
308
329
 
309
- # Load file into current database
310
- def use_database(file = nil)
311
- check_clean
312
- File.file?(file) or raise Error, "Can't find #{file}"
313
- database.recreate
314
- database.load(file)
330
+ def self.initialized?(directory)
331
+ File.directory?(directory) && Dir.chdir(directory) { ProjectState.new.exist? }
315
332
  end
316
333
 
317
- # Load file into the given database version. If version is nil the current
318
- # database is used. If file is nil, the database associated with the given
319
- # version is used. Not both version and file can be nil
320
- def load_database(version, file = nil)
321
- check_clean
322
- !version.nil? || !file.nil? or raise Fail, "Not both version and file can be nil"
323
- if version
324
- release = self[version]
325
- file ||= release.archive.path
326
- File.file?(file) or raise Error, "Can't find #{file}"
327
- release.database.recreate
328
- release.database.load(file)
329
- else
330
- database.recreate
331
- database.load(file)
332
- end
334
+ def self.initialize_directory(name, user, directory)
335
+ FileUtils.mkdir_p(directory)
336
+ Dir.chdir(directory) {
337
+ # Initialize git instance
338
+ Git.init
339
+
340
+ # Create prick version file
341
+ PrickVersion.new.write(VERSION)
342
+
343
+ # Create project state file
344
+ ProjectState.new(name: name, user: user).write
345
+
346
+ # Directories
347
+ DIRS.each { |dir|
348
+ !File.exist?(dir) or raise Fail, "Already initialized: Directory '#{dir}' exists"
349
+ }
350
+ FileUtils.mkdir_p(DIRS)
351
+ DIRS.each { |dir| FileUtils.touch("#{dir}/.keep") }
352
+
353
+ # Copy default gitignore and schema files
354
+ Share.cp("gitignore", ".gitignore")
355
+ Share.cp("schemas", ".")
356
+
357
+ # Add everything so far
358
+ Git.add(".")
359
+ Git.commit("Initial import")
360
+
361
+ # Create initial release
362
+ release = Release.new(Version.zero, nil)
363
+ release.create
364
+
365
+ schema = Schema.new(SCHEMAS_DIR)
366
+ schema.version = release.version
367
+ Git.add schema.version_file
368
+
369
+ Git.commit "Release 0.0.0"
370
+ release.tag!
371
+
372
+ # Kill master branch
373
+ Git.delete_branch("master")
374
+ }
333
375
  end
334
376
 
335
- # def reload_release(version, *file)
336
- # lookup(version).reload(*file)
337
- # end
338
- #
339
- # def unload(version = nil)
340
- # if version
341
- # lookup(version).database.drop
342
- # else
343
- # databases.each(&:drop)
344
- # end
345
- # end
346
- #
347
- # Remove temporary files and databases
348
- def cleanup(version = nil, project: false)
377
+ def build(database: self.database, version: nil)
378
+ clean(database)
349
379
  if version
350
- release = self[version]
351
- release.archive.delete
352
- release.database.drop
380
+ FileUtils.mkdir_p(TMP_DIR)
381
+ Dir.mktmpdir("clone-", TMP_DIR) { |dir|
382
+ Command.command "git clone . #{dir}"
383
+ Dir.chdir(dir) {
384
+ Git.checkout_tag(version)
385
+ project = Project.load
386
+ project.branch.build(database)
387
+ }
388
+ }
353
389
  else
354
- FileUtils::rm_rf CLONE_DIR
355
- FileUtils::rm_f Dir.glob(File.join(CACHE_DIR, Prick.dump_glob(name)))
356
- databases(all: true, project: project).each(&:drop)
390
+ branch.build(database)
357
391
  end
392
+ self
358
393
  end
359
394
 
360
- private
361
- def check_clean()
362
- !dirty? or raise "Repository is dirty. Commit your changes and try again"
395
+ def load(file, database: self.database)
396
+ clean(database)
397
+ database.load(file)
398
+ self
363
399
  end
364
400
 
365
- # For debug
366
- def dump_deps(deps)
367
- deps.each { |k,v|
368
- puts " #{k} -> #{v || 'nil'}"
369
- }
401
+ def save(file, database: self.database)
402
+ database.save(file)
403
+ self
370
404
  end
371
405
 
372
- def load_project
373
- # Maps from version to base release version
374
- releases = { Version.zero => nil }
375
- prereleases = {}
376
- features = {}
377
-
378
- # Collect branches from git
379
- Git.list_branches.each { |branch|
380
- if Version.version?(branch)
381
- version = Version.new(branch)
382
- if version.feature?
383
- features[version] = version.truncate(:feature)
384
- elsif version.pre?
385
- prereleases[version] = nil
386
- elsif version.release?
387
- releases[version] = nil
388
- else
389
- raise Internal, "Unexpected state for #{version}"
390
- end
406
+ def cache_file(version) File.join(CACHE_DIR, "#{database(version)}.sql.gz") end
407
+
408
+ def make(database, subject)
409
+ Schema.built? or raise Error, "Schema is not built into project database"
410
+ Schema.make(database, subject)
411
+ end
412
+
413
+ def backup(path = nil)
414
+ save path || File.join(SPOOL_DIR, "#{name}-#{Time.now.utc.strftime("%Y%m%d-%H%M%S")}.sql.gz")
415
+ end
416
+
417
+ def restore(path = nil)
418
+ load path || Dir.glob(File.join(SPOOL_DIR, "#{name}-*.sql.gz")).sort.last
419
+ end
420
+
421
+ def prepare_release(commit: true)
422
+ release = ReleaseMigration.new(nil, branch.version).create
423
+ submit "Prepared new release based on #{version}", commit
424
+ release
425
+ end
426
+
427
+ def prepare_schema(name, commit: true)
428
+ path = File.join(SCHEMAS_DIR, name)
429
+ FileUtils.mkdir_p(path)
430
+ Git.add Share.cp("schema/*", path, clobber: false, templates: { 'SCHEMA' => name })
431
+ File.open(branch.schema.yml_file, "a") { |f| f.write("- #{name}\n") }
432
+ Git.add(branch.schema.yml_file)
433
+ submit "Added schema #{name}", commit
434
+ end
435
+
436
+ def prepare_diff(from_version)
437
+ to_database = nil
438
+ begin
439
+ from_name = "#{name}-base"
440
+ from_database = Database.new(from_name, user)
441
+ build(database: from_database, version: from_version)
442
+
443
+ to_name = "#{name}-next"
444
+ to_database = Database.new(to_name, user)
445
+ build(database: to_database)
446
+
447
+ # Helpful double-check
448
+ Diff.same?(to_database.name, database.name) or
449
+ raise Error, "Schema and project database are not synchronized"
450
+
451
+ if release?
452
+ dir = branch.migration.migration_dir
391
453
  else
392
- @orphan_git_branches << branch
454
+ dir = branch.migration.features_dir
393
455
  end
394
- }
395
456
 
396
- # Find base release for present releases and prereleases
397
- Dir.each_child(RELEASE_DIR) { |node|
398
- next if node.start_with?(".")
399
- if Version.version?(node)
400
- version = Version.new(node)
401
- if releases.key?(version)
402
- base_release = Build.deref_node_file(File.join(RELEASE_DIR, node))
403
- releases[version] = base_release
404
- elsif prereleases.key?(version)
405
- base_release = Build.deref_node_file(File.join(RELEASE_DIR, node))
406
- prereleases[version] = base_release
407
- else
408
- @orphan_release_nodes << node
409
- end
410
- else !prerelease_deps.key?(node)
411
- @ignored_release_nodes << node
457
+ # dir = branch.migration.features_dir || branch.migration.migration_dir
458
+ to_file = File.join(dir, DIFF_FILE)
459
+
460
+ if prerelease?
461
+ branch.migrate_features(from_database)
412
462
  end
463
+
464
+ Diff.new(from_name, to_name).write(to_file)
465
+ ensure
466
+ from_database&.drop
467
+ to_database&.drop
468
+ end
469
+ end
470
+
471
+ def prepare_migration(from, commit: true)
472
+ to = branch.version
473
+ migration_release = MigrationRelease.new(to, from)
474
+ migration_release.create
475
+ submit "Prepared migration from #{from} to #{to}", commit
476
+ migration_release
477
+ end
478
+
479
+ def create_release(version, commit: true)
480
+ check_migration(branch.version) or raise Error, "Schema/migration mismatch"
481
+ release = Release.new(version, branch.version)
482
+ release.create
483
+ submit "Created release #{version}", commit
484
+ release.tag!
485
+ build
486
+ release
487
+ end
488
+
489
+ def cancel_release(version, commit: true)
490
+ Git.cancel_tag(version)
491
+ end
492
+
493
+ def create_release_from_prerelease(commit: true)
494
+ check_migration(branch.base_version) or raise Error, "Schema/migration mismatch"
495
+ release = Release.new(branch.version.truncate(:pre), branch.base_version)
496
+ release.create
497
+ submit "Released #{release.version}", commit
498
+ release.tag!
499
+ build
500
+ release
501
+ end
502
+
503
+ def create_prerelease(version, commit: true)
504
+ prerelease = PreRelease.new(version.increment(:pre), branch.version)
505
+ prerelease.create
506
+ submit "Created pre-release #{prerelease.version}", commit
507
+ build
508
+ prerelease
509
+ end
510
+
511
+ def increment_prerelease(commit: true)
512
+ prerelease = branch.increment
513
+ prerelease.create
514
+ submit "Created pre-release #{prerelease.version}", commit
515
+ build
516
+ prerelease
517
+ end
518
+
519
+ # # Turned out to be a bad idea
520
+ # def retarget_migration(version, commit: true)
521
+ # branch.retarget(version)
522
+ # commit "Retargeted to #{version}" if commit
523
+ # branch
524
+ # end
525
+
526
+ def create_feature(name, commit: true)
527
+ feature = Feature.new(name, version)
528
+ feature.create
529
+ submit "Created feature #{name}", commit
530
+ end
531
+
532
+ def include_feature(feature_version, commit: false)
533
+ Git.merge_branch(feature_version, exclude_files: [branch.schema.version_file], fail: true)
534
+ branch.include(feature_version)
535
+ end
536
+
537
+ # Checks that the migration of the current branch takes the database from
538
+ # the base version to the content of the schema
539
+ def check_migration(from_version)
540
+ begin
541
+ from_db = Database.new("#{name}-base", user)
542
+ to_db = Database.new("#{name}-next", user)
543
+ build(database: from_db, version: from_version)
544
+ build(database: to_db)
545
+ migration = # FIXME: This may be a repeated pattern
546
+ if migration?
547
+ branch.migration
548
+ elsif prerelease?
549
+ branch.migration
550
+ else
551
+ Migration.new(nil, branch.directory)
552
+ end
553
+ migration.migrate(from_db)
554
+ Diff.new(from_db, to_db).same?
555
+ ensure
556
+ from_db&.drop
557
+ to_db&.drop
558
+ end
559
+ end
560
+
561
+ # TODO: Make production-only and add a to-argument
562
+ def upgrade
563
+ branches = list_upgrades(database.version, branch.version)
564
+ FileUtils.mkdir_p(TMP_DIR)
565
+ Dir.mktmpdir("clone-", TMP_DIR) { |dir|
566
+ Command.command "git clone . #{dir}"
567
+ Dir.chdir(dir) {
568
+ branches.each { |branch|
569
+ branch.checkout_release
570
+ project = Project.load
571
+ project.branch.migrate(project.database)
572
+ }
573
+ }
413
574
  }
575
+ end
414
576
 
415
- releases.sort_by { |k,v| k }.each { |release, base_release|
416
- @releases << Release.new(self, self[base_release], release)
577
+ def list_releases(all: false)
578
+ Git.list_tags(include_cancelled: all).grep(RELEASE_RE) { |tag|
579
+ version = $2
580
+ dir = File.join(RELEASES_DIR, version)
581
+ migration_state = MigrationState.new(dir).read(tag: tag)
582
+ Release.new(migration_state.version, migration_state.base_version)
417
583
  }
418
- @releases.sort!
584
+ end
419
585
 
420
- prereleases.each { |prerelease, base_release|
421
- if base_release
422
- @prereleases << Prerelease.new(self, self[base_release], prerelease)
423
- else
424
- @ignored_release_nodes << prerelease
425
- end
586
+ # TODO
587
+ # list_migrations(all: false)
588
+ def list_migrations
589
+ Git.list_branches.grep(MIGRATION_RE) { |branch|
590
+ from_version = Version.new($1)
591
+ to_version = Version.new($4)
592
+ MigrationRelease.new(to_version, from_version)
426
593
  }
427
- @prereleases.sort!
594
+ end
428
595
 
429
- if !Git.detached?
430
- features.each { |feature, base_release|
431
- # if File.exist?(base_release_file)
432
- # recursive include of dependent releases
596
+ def list_upgrades(from, to)
597
+ edges = (list_releases + list_migrations).map { |branch| [branch.base_version, branch.version, branch] }
598
+ (Algorithm.shortest_path(edges, from, to) || []).map(&:last)
599
+ end
433
600
 
434
- if base_release
435
- (@features[base_release] ||= []) << Feature.new(self, self[base_release], feature.feature)
436
- else
437
- @ignored_release_nodes << release
438
- end
439
- }
601
+ private
602
+ def submit(msg, commit = true)
603
+ Git.commit msg if commit
604
+ @message = msg
605
+ end
606
+
607
+ def clean(database)
608
+ if database.exist?
609
+ database.recreate if database.loaded?
610
+ else
611
+ database.create
440
612
  end
441
613
  end
442
614
  end