summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorgregor herrmann <gregoa@debian.org>2020-02-23 11:56:51 +0100
committergregor herrmann <gregoa@debian.org>2020-02-23 11:56:51 +0100
commit2d95027a3c426e133c6612ae1016d408483ec7ef (patch)
tree86da540d08eadb81effe53c7900723e45a39ad12
Import sqitch_1.0.0000.orig.tar.gz
[dgit import orig sqitch_1.0.0000.orig.tar.gz]
-rw-r--r--Build.PL154
-rw-r--r--Changes1979
-rw-r--r--LICENSE32
-rw-r--r--LICENSE.md21
-rw-r--r--MANIFEST281
-rw-r--r--META.json305
-rw-r--r--META.yml151
-rw-r--r--README13
-rw-r--r--README.md160
-rwxr-xr-xbin/sqitch15
-rw-r--r--dist/cpanfile164
-rw-r--r--dist/sqitch.spec546
-rw-r--r--etc/templates/deploy/exasol.tmpl11
-rw-r--r--etc/templates/deploy/firebird.tmpl11
-rw-r--r--etc/templates/deploy/mysql.tmpl13
-rw-r--r--etc/templates/deploy/oracle.tmpl9
-rw-r--r--etc/templates/deploy/pg.tmpl13
-rw-r--r--etc/templates/deploy/snowflake.tmpl11
-rw-r--r--etc/templates/deploy/sqlite.tmpl13
-rw-r--r--etc/templates/deploy/vertica.tmpl9
-rw-r--r--etc/templates/revert/exasol.tmpl5
-rw-r--r--etc/templates/revert/firebird.tmpl5
-rw-r--r--etc/templates/revert/mysql.tmpl7
-rw-r--r--etc/templates/revert/oracle.tmpl3
-rw-r--r--etc/templates/revert/pg.tmpl7
-rw-r--r--etc/templates/revert/snowflake.tmpl5
-rw-r--r--etc/templates/revert/sqlite.tmpl7
-rw-r--r--etc/templates/revert/vertica.tmpl3
-rw-r--r--etc/templates/verify/exasol.tmpl5
-rw-r--r--etc/templates/verify/firebird.tmpl5
-rw-r--r--etc/templates/verify/mysql.tmpl7
-rw-r--r--etc/templates/verify/oracle.tmpl3
-rw-r--r--etc/templates/verify/pg.tmpl7
-rw-r--r--etc/templates/verify/snowflake.tmpl5
-rw-r--r--etc/templates/verify/sqlite.tmpl7
-rw-r--r--etc/templates/verify/vertica.tmpl3
-rw-r--r--etc/tools/upgrade-registry-to-mysql-5.5.0.sql43
-rw-r--r--etc/tools/upgrade-registry-to-mysql-5.6.4.sql15
-rw-r--r--inc/Menlo/Sqitch.pm171
-rw-r--r--inc/Module/Build/Sqitch.pm324
-rw-r--r--lib/App/Sqitch.pm927
-rw-r--r--lib/App/Sqitch/Command.pm774
-rw-r--r--lib/App/Sqitch/Command/add.pm565
-rw-r--r--lib/App/Sqitch/Command/bundle.pm398
-rw-r--r--lib/App/Sqitch/Command/checkout.pm211
-rw-r--r--lib/App/Sqitch/Command/config.pm666
-rw-r--r--lib/App/Sqitch/Command/deploy.pm230
-rw-r--r--lib/App/Sqitch/Command/engine.pm457
-rw-r--r--lib/App/Sqitch/Command/help.pm142
-rw-r--r--lib/App/Sqitch/Command/init.pm292
-rw-r--r--lib/App/Sqitch/Command/log.pm373
-rw-r--r--lib/App/Sqitch/Command/plan.pm354
-rw-r--r--lib/App/Sqitch/Command/rebase.pm183
-rw-r--r--lib/App/Sqitch/Command/revert.pm234
-rw-r--r--lib/App/Sqitch/Command/rework.pm333
-rw-r--r--lib/App/Sqitch/Command/show.pm203
-rw-r--r--lib/App/Sqitch/Command/status.pm432
-rw-r--r--lib/App/Sqitch/Command/tag.pm206
-rw-r--r--lib/App/Sqitch/Command/target.pm337
-rw-r--r--lib/App/Sqitch/Command/upgrade.pm148
-rw-r--r--lib/App/Sqitch/Command/verify.pm205
-rw-r--r--lib/App/Sqitch/Config.pm231
-rw-r--r--lib/App/Sqitch/DateTime.pm214
-rw-r--r--lib/App/Sqitch/Engine.pm2463
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql21
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql3
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql45
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql49
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql20
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql2
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql44
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql31
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql30
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql7
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql22
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql3
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql62
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql27
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql15
-rw-r--r--lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql3
-rw-r--r--lib/App/Sqitch/Engine/exasol.pm587
-rw-r--r--lib/App/Sqitch/Engine/exasol.sql142
-rw-r--r--lib/App/Sqitch/Engine/firebird.pm998
-rw-r--r--lib/App/Sqitch/Engine/firebird.sql166
-rw-r--r--lib/App/Sqitch/Engine/mysql.pm551
-rw-r--r--lib/App/Sqitch/Engine/mysql.sql192
-rw-r--r--lib/App/Sqitch/Engine/oracle.pm832
-rw-r--r--lib/App/Sqitch/Engine/oracle.sql142
-rw-r--r--lib/App/Sqitch/Engine/pg.pm505
-rw-r--r--lib/App/Sqitch/Engine/pg.sql145
-rw-r--r--lib/App/Sqitch/Engine/snowflake.pm724
-rw-r--r--lib/App/Sqitch/Engine/snowflake.sql142
-rw-r--r--lib/App/Sqitch/Engine/sqlite.pm305
-rw-r--r--lib/App/Sqitch/Engine/sqlite.sql80
-rw-r--r--lib/App/Sqitch/Engine/vertica.pm585
-rw-r--r--lib/App/Sqitch/Engine/vertica.sql85
-rw-r--r--lib/App/Sqitch/ItemFormatter.pm607
-rw-r--r--lib/App/Sqitch/Plan.pm1620
-rw-r--r--lib/App/Sqitch/Plan/Blank.pm62
-rw-r--r--lib/App/Sqitch/Plan/Change.pm670
-rw-r--r--lib/App/Sqitch/Plan/ChangeList.pm433
-rw-r--r--lib/App/Sqitch/Plan/Depend.pm389
-rw-r--r--lib/App/Sqitch/Plan/Line.pm370
-rw-r--r--lib/App/Sqitch/Plan/LineList.pm133
-rw-r--r--lib/App/Sqitch/Plan/Pragma.pm125
-rw-r--r--lib/App/Sqitch/Plan/Tag.pm181
-rw-r--r--lib/App/Sqitch/Role/ConnectingCommand.pm143
-rw-r--r--lib/App/Sqitch/Role/ContextCommand.pm145
-rw-r--r--lib/App/Sqitch/Role/DBIEngine.pm1132
-rw-r--r--lib/App/Sqitch/Role/RevertDeployCommand.pm272
-rw-r--r--lib/App/Sqitch/Role/TargetConfigCommand.pm549
-rw-r--r--lib/App/Sqitch/Target.pm865
-rw-r--r--lib/App/Sqitch/Types.pm191
-rw-r--r--lib/App/Sqitch/X.pm199
-rw-r--r--lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mobin0 -> 32584 bytes
-rw-r--r--lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mobin0 -> 13122 bytes
-rw-r--r--lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mobin0 -> 30312 bytes
-rw-r--r--lib/sqitch-add-usage.pod25
-rw-r--r--lib/sqitch-add.pod435
-rw-r--r--lib/sqitch-authentication.pod391
-rw-r--r--lib/sqitch-bundle-usage.pod14
-rw-r--r--lib/sqitch-bundle.pod133
-rw-r--r--lib/sqitch-checkout-usage.pod26
-rw-r--r--lib/sqitch-checkout.pod278
-rw-r--r--lib/sqitch-config-usage.pod37
-rw-r--r--lib/sqitch-config.pod645
-rw-r--r--lib/sqitch-configuration.pod1041
-rw-r--r--lib/sqitch-deploy-usage.pod24
-rw-r--r--lib/sqitch-deploy.pod223
-rw-r--r--lib/sqitch-engine-usage.pod25
-rw-r--r--lib/sqitch-engine.pod267
-rw-r--r--lib/sqitch-environment.pod343
-rw-r--r--lib/sqitch-help-usage.pod12
-rw-r--r--lib/sqitch-help.pod35
-rw-r--r--lib/sqitch-init-usage.pod21
-rw-r--r--lib/sqitch-init.pod256
-rw-r--r--lib/sqitch-log-usage.pod40
-rw-r--r--lib/sqitch-log.pod502
-rw-r--r--lib/sqitch-passwords.pod13
-rw-r--r--lib/sqitch-plan-usage.pod32
-rw-r--r--lib/sqitch-plan.pod397
-rw-r--r--lib/sqitch-rebase-usage.pod28
-rw-r--r--lib/sqitch-rebase.pod305
-rw-r--r--lib/sqitch-revert-usage.pod22
-rw-r--r--lib/sqitch-revert.pod208
-rw-r--r--lib/sqitch-rework-usage.pod19
-rw-r--r--lib/sqitch-rework.pod184
-rw-r--r--lib/sqitch-show-usage.pod15
-rw-r--r--lib/sqitch-show.pod96
-rw-r--r--lib/sqitch-status-usage.pod22
-rw-r--r--lib/sqitch-status.pod189
-rw-r--r--lib/sqitch-tag-usage.pod15
-rw-r--r--lib/sqitch-tag.pod152
-rw-r--r--lib/sqitch-target-usage.pod25
-rw-r--r--lib/sqitch-target.pod260
-rw-r--r--lib/sqitch-upgrade-usage.pod17
-rw-r--r--lib/sqitch-upgrade.pod98
-rw-r--r--lib/sqitch-verify-usage.pod21
-rw-r--r--lib/sqitch-verify.pod215
-rw-r--r--lib/sqitch.pod490
-rw-r--r--lib/sqitchchanges.pod197
-rw-r--r--lib/sqitchcommands.pod44
-rw-r--r--lib/sqitchguides.pod28
-rw-r--r--lib/sqitchtutorial-exasol.pod1407
-rw-r--r--lib/sqitchtutorial-firebird.pod1264
-rw-r--r--lib/sqitchtutorial-mysql.pod1724
-rw-r--r--lib/sqitchtutorial-oracle.pod1870
-rw-r--r--lib/sqitchtutorial-snowflake.pod1419
-rw-r--r--lib/sqitchtutorial-sqlite.pod1240
-rw-r--r--lib/sqitchtutorial-vertica.pod1390
-rw-r--r--lib/sqitchtutorial.pod1686
-rw-r--r--lib/sqitchusage.pod25
-rw-r--r--t/add.t1010
-rw-r--r--t/add_change.conf10
-rw-r--r--t/base.t675
-rw-r--r--t/blank.t135
-rw-r--r--t/bundle.t565
-rw-r--r--t/change.t438
-rw-r--r--t/changelist.t366
-rw-r--r--t/checkout.t660
-rw-r--r--t/command.t725
-rw-r--r--t/config.t1122
-rw-r--r--t/configuration.t90
-rw-r--r--t/conn_cmd_role.t112
-rw-r--r--t/core.conf2
-rw-r--r--t/core_target.conf2
-rw-r--r--t/cx_cmd_role.t109
-rw-r--r--t/datetime.t95
-rw-r--r--t/depend.t224
-rw-r--r--t/deploy.t327
-rw-r--r--t/die.pl5
-rw-r--r--t/echo.pl3
-rw-r--r--t/editor.conf3
-rw-r--r--t/engine.conf20
-rw-r--r--t/engine.t3089
-rw-r--r--t/engine/deploy/func/add_user.sql13
-rw-r--r--t/engine/deploy/users.sql6
-rw-r--r--t/engine/deploy/widgets.sql7
-rw-r--r--t/engine/revert/func/add_user.sql7
-rw-r--r--t/engine/revert/users.sql2
-rw-r--r--t/engine/revert/widgets.sql2
-rw-r--r--t/engine/reworked/deploy/users@alpha.sql6
-rw-r--r--t/engine/reworked/revert/users@alpha.sql2
-rw-r--r--t/engine/sqitch.plan7
-rw-r--r--t/engine_cmd.t631
-rw-r--r--t/exasol.t381
-rw-r--r--t/firebird.t380
-rw-r--r--t/help.t93
-rw-r--r--t/init.t641
-rw-r--r--t/item_formatter.t287
-rw-r--r--t/lib/App/Sqitch/Command/bad.pm3
-rw-r--r--t/lib/App/Sqitch/Command/good.pm20
-rw-r--r--t/lib/App/Sqitch/Engine/bad.pm3
-rw-r--r--t/lib/App/Sqitch/Engine/good.pm18
-rw-r--r--t/lib/DBIEngineTest.pm1807
-rw-r--r--t/lib/LC.pm17
-rw-r--r--t/lib/MockOutput.pm74
-rw-r--r--t/lib/TestConfig.pm148
-rw-r--r--t/lib/upgradable_registries/exasol.sql139
-rw-r--r--t/lib/upgradable_registries/firebird.sql327
-rw-r--r--t/lib/upgradable_registries/mysql.sql189
-rw-r--r--t/lib/upgradable_registries/oracle.sql136
-rw-r--r--t/lib/upgradable_registries/pg.sql140
-rw-r--r--t/lib/upgradable_registries/snowflake.sql139
-rw-r--r--t/lib/upgradable_registries/sqlite.sql75
-rw-r--r--t/lib/upgradable_registries/vertica.sql84
-rw-r--r--t/linelist.t81
-rw-r--r--t/local.conf15
-rw-r--r--t/log.t754
-rw-r--r--t/mooseless.t31
-rw-r--r--t/multiplan.conf13
-rw-r--r--t/mysql.t521
-rw-r--r--t/odbc/odbcinst.ini11
-rw-r--r--t/odbc/vertica.ini4
-rw-r--r--t/options.t210
-rw-r--r--t/oracle.t571
-rw-r--r--t/pg.t322
-rw-r--r--t/plan.t2034
-rw-r--r--t/plan_cmd.t675
-rw-r--r--t/plans/bad-change.plan8
-rw-r--r--t/plans/changes-only.plan8
-rw-r--r--t/plans/dependencies.plan12
-rw-r--r--t/plans/deploy-and-revert.plan11
-rw-r--r--t/plans/dos.plan8
-rw-r--r--t/plans/dupe-change-diff-tag.plan10
-rw-r--r--t/plans/dupe-change.plan10
-rw-r--r--t/plans/dupe-tag.plan14
-rw-r--r--t/plans/multi.plan13
-rw-r--r--t/plans/pragmas.plan9
-rw-r--r--t/plans/project_deps.plan12
-rw-r--r--t/plans/reserved-tag.plan10
-rw-r--r--t/plans/widgets.plan8
-rw-r--r--t/pragma.t63
-rw-r--r--t/read.pl3
-rw-r--r--t/rebase.t674
-rw-r--r--t/revert.t347
-rw-r--r--t/rework.conf2
-rw-r--r--t/rework.t978
-rw-r--r--t/show.t198
-rw-r--r--t/snowflake.t562
-rwxr-xr-xt/sqitch16
-rw-r--r--t/sqitch.conf24
-rw-r--r--t/sql/deploy/roles.sql1
-rw-r--r--t/sql/deploy/users.sql2
-rw-r--r--t/sql/deploy/widgets.sql2
-rw-r--r--t/sql/sqitch.plan8
-rw-r--r--t/sql/verify/users.sql1
-rw-r--r--t/sqlite.t384
-rw-r--r--t/status.t616
-rw-r--r--t/tag.t167
-rw-r--r--t/tag_cmd.t370
-rw-r--r--t/target.conf13
-rw-r--r--t/target.t669
-rw-r--r--t/target_cmd.t766
-rw-r--r--t/templates.conf9
-rw-r--r--t/upgrade.t118
-rw-r--r--t/user.conf24
-rw-r--r--t/verify.t298
-rw-r--r--t/vertica.t320
-rw-r--r--t/x.t78
280 files changed, 77326 insertions, 0 deletions
diff --git a/Build.PL b/Build.PL
new file mode 100644
index 00000000..96628bb4
--- /dev/null
+++ b/Build.PL
@@ -0,0 +1,154 @@
+
+# This file was automatically generated by Dist::Zilla::Plugin::ModuleBuild v6.012.
+use strict;
+use warnings;
+
+use Module::Build 0.35;
+use lib qw{inc}; use Module::Build::Sqitch;
+
+my %module_build_args = (
+ "build_requires" => {
+ "Module::Build" => "0.35"
+ },
+ "configure_requires" => {
+ "Module::Build" => "0.35"
+ },
+ "dist_abstract" => "Sensible database change management",
+ "dist_author" => [
+ "\"iovation Inc.\""
+ ],
+ "dist_name" => "App-Sqitch",
+ "dist_version" => "v1.0.0",
+ "license" => "mit",
+ "module_name" => "App::Sqitch",
+ "recommends" => {
+ "Class::XSAccessor" => "1.18",
+ "Pod::Simple" => "1.41",
+ "Template" => 0,
+ "Type::Tiny::XS" => "0.010"
+ },
+ "recursive_test_files" => 1,
+ "requires" => {
+ "Clone" => 0,
+ "Config::GitLike" => "1.15",
+ "DBI" => 0,
+ "DateTime" => "1.04",
+ "DateTime::TimeZone" => 0,
+ "Devel::StackTrace" => "1.30",
+ "Digest::SHA" => 0,
+ "Encode" => 0,
+ "Encode::Locale" => 0,
+ "File::Basename" => 0,
+ "File::Copy" => 0,
+ "File::Path" => 0,
+ "File::Temp" => 0,
+ "Getopt::Long" => 0,
+ "Hash::Merge" => 0,
+ "IO::Handle" => 0,
+ "IO::Pager" => "0.34",
+ "IPC::Run3" => 0,
+ "IPC::System::Simple" => "1.17",
+ "List::MoreUtils" => 0,
+ "List::Util" => 0,
+ "Locale::Messages" => 0,
+ "Locale::TextDomain" => "1.20",
+ "Moo" => "1.002000",
+ "Moo::Role" => 0,
+ "POSIX" => 0,
+ "Path::Class" => "0.33",
+ "PerlIO::utf8_strict" => 0,
+ "Pod::Escapes" => "1.04",
+ "Pod::Find" => 0,
+ "Pod::Usage" => 0,
+ "Scalar::Util" => 0,
+ "StackTrace::Auto" => 0,
+ "String::Formatter" => 0,
+ "String::ShellQuote" => 0,
+ "Sub::Exporter" => 0,
+ "Sub::Exporter::Util" => 0,
+ "Sys::Hostname" => 0,
+ "Template::Tiny" => "0.11",
+ "Term::ANSIColor" => "2.02",
+ "Throwable" => "0.200009",
+ "Time::HiRes" => 0,
+ "Time::Local" => 0,
+ "Try::Tiny" => 0,
+ "Type::Library" => "0.040",
+ "Type::Utils" => 0,
+ "Types::Standard" => 0,
+ "URI" => 0,
+ "URI::QueryParam" => 0,
+ "URI::db" => "0.19",
+ "User::pwent" => 0,
+ "constant" => 0,
+ "if" => 0,
+ "namespace::autoclean" => "0.16",
+ "overload" => 0,
+ "parent" => 0,
+ "perl" => "5.010",
+ "strict" => 0,
+ "utf8" => 0,
+ "warnings" => 0
+ },
+ "script_files" => [
+ "bin/sqitch"
+ ],
+ "test_requires" => {
+ "Capture::Tiny" => "0.12",
+ "Carp" => 0,
+ "File::Find" => 0,
+ "File::Spec" => 0,
+ "File::Spec::Functions" => 0,
+ "FindBin" => 0,
+ "IO::Pager" => "0.34",
+ "Module::Runtime" => 0,
+ "Path::Class" => "0.33",
+ "Test::Deep" => 0,
+ "Test::Dir" => 0,
+ "Test::Exception" => 0,
+ "Test::File" => 0,
+ "Test::File::Contents" => "0.20",
+ "Test::MockModule" => "0.17",
+ "Test::More" => "0.94",
+ "Test::NoWarnings" => "0.083",
+ "Test::Warn" => 0,
+ "base" => 0,
+ "lib" => 0
+ }
+);
+
+
+my %fallback_build_requires = (
+ "Capture::Tiny" => "0.12",
+ "Carp" => 0,
+ "File::Find" => 0,
+ "File::Spec" => 0,
+ "File::Spec::Functions" => 0,
+ "FindBin" => 0,
+ "IO::Pager" => "0.34",
+ "Module::Build" => "0.35",
+ "Module::Runtime" => 0,
+ "Path::Class" => "0.33",
+ "Test::Deep" => 0,
+ "Test::Dir" => 0,
+ "Test::Exception" => 0,
+ "Test::File" => 0,
+ "Test::File::Contents" => "0.20",
+ "Test::MockModule" => "0.17",
+ "Test::More" => "0.94",
+ "Test::NoWarnings" => "0.083",
+ "Test::Warn" => 0,
+ "base" => 0,
+ "lib" => 0
+);
+
+
+unless ( eval { Module::Build->VERSION(0.4004) } ) {
+ delete $module_build_args{test_requires};
+ $module_build_args{build_requires} = \%fallback_build_requires;
+}
+
+my $build = Module::Build::Sqitch->new(%module_build_args);
+
+
+$build->create_build_script;
diff --git a/Changes b/Changes
new file mode 100644
index 00000000..28661173
--- /dev/null
+++ b/Changes
@@ -0,0 +1,1979 @@
+Revision history for Perl extension App::Sqitch
+
+1.0.0 2019-06-04T12:56:22Z
+ - Fixed test failure due to a hard-coded system error that may be
+ localized on non-en-US hosts. Thanks to Slaven Rezić for the catch
+ (#427).
+ - Now require Test::MockModule 0.17 to silence a warning during testing.
+ Thanks to Slaven Rezić for the suggestion.
+ - Fixed an error when Sqitch is run with no arguments. Thanks to Henrik
+ Tudborg for the report (#428).
+ - Fixed missing dependency on IO::Pager in the distribution metadata.
+ - Removed use of File::HomeDir, thanks to a PR from Karen Etheridge
+ (#433).
+ - Updated the tagline from "Sane database change management" to "Sensible
+ database change management" out of sensitivity to those subject to
+ mental illness (#435).
+ - Removed double-quoting of SQLite commands on Windows, inadvertently
+ added by the workaround for Windows quoting in v0.9999.
+ - Fixed a Snowflake issue where Sqitch failed to recognize the proper
+ error code for a missing table and therefore an uninitialized registry.
+ Thanks to @lerouxt and @kulmam92 for the report and fix (#439).
+ - Added check for project initialization when no engine config can be
+ found. When run from a directory with no configuration, Sqitch now
+ reports that the project is not initialized instead of complaining
+ about a lack of engine config (#437).
+ - Documented Snowflake key pair authentication in
+ `sqitch-authentication`, as well as `$SNOWSQL_PRIVATE_KEY_PASSPHRASE`
+ in `sqitch-environment`. Thanks to Casey Largent for figuring it out
+ (#441).
+ - Added the German localization. Thanks to Thomas Iguchi for the pull
+ request (#451).
+ - Renamed the French localization from "fr" to "fr_FR", so that systems
+ will actually find it.
+ - Added the `ask_yes_no()` method as a replacement for `ask_y_n()`, which
+ is now deprecated. The new method expects localized responses from the
+ user when translations are provided. Defaults to the English "yes" and
+ "no" when no translation is available. Suggested by German translator
+ Thomas Iguchi (#449).
+ - Fixed a bug where only project without a URI was allowed in the
+ registry. Thanks to Conding-Brunna for the report (#450).
+ - Clarified the role of project URIs for uniqueness: They don't allow
+ multiple projects with the same name, but do prevent the deployment of
+ a project with the same name but different URI.
+ - Fixed an issue where target variables could not be found when a target
+ name was not lowercase. Thanks to @maximejanssens for the report
+ (#454).
+ - Now require Config::GitLike 1.15 or higher.
+ - Fixed the indentation of variables emitted by the `show` actions of the
+ `target` and `engine` commands, fixing a "Negative repeat count does
+ nothing" warning in the process. Thanks to @maximejanssens for the
+ report (#454).
+ - Fixed a Snowflake test failure when the current system username has a
+ space or other character requiring URI escaping. Thanks to Ralph
+ Andrade for the report (#463).
+ - Fixed an issue where a wayward newline in some versions of SQLite
+ prevented Sqitch from parsing the version. Thanks to Kivanc Yazan
+ for the report (#465) and the fix (#465)!
+ - Fixed an error when Sqitch was run on a system without a valid
+ username, such as some Docker environments. Thanks to Ferdinand Salis
+ for the report (#459)!
+ - When Sqitch finds the registry does not exist on PostgreSQL, it now
+ sends a warning to the PostgreSQL log reporting that it will initialize
+ the database. This is to reduce confusion for folks watching the
+ PostgreSQL error log while Sqitch runs (#314).
+
+0.9999 2019-02-01T15:29:40Z
+ [Bug Fixes]
+ - Fixed a test failure with the MySQL max limit value, mostly exhibited
+ on BSD platforms.
+ - Removed fallback in the PostgreSQL engine on the `$PGUSER` and
+ `$PGPASSWORD` environnement variables, as well as the system username,
+ since libpq does all that automatically, and collects data from other
+ sources that we did not (e.g., the password and connection service
+ files). Thanks to Tom Bloor for the report (issue #410).
+ - Changed dependency validation to prevent an error when a change required
+ from a different project has been reworked. Previously, when requiring a
+ change such as `foo:greeble`, Sqitch would raise an error if
+ `foo:greeble` was reworked, suggesting that the dependency be
+ tag-qualified to eliminate ambiguity. Now reworked dependencies may be
+ required without tag-qualification, though tag-qualification should still
+ be specified if functionality as of a particular tag is required.
+ - Added a workaround for the shell quoting issue on Windows. Applies to
+ IPC::System::Simple 1.29 and lower. See
+ [pjf/ipc-system-simple#29](https://github.com/pjf/ipc-system-simple/pull/29)
+ for details (#413).
+ - Fixed an issue with the MariaDB client where a deploy, revert, or
+ verify failure was not properly propagated to Sqitch. Sqitch now passes
+ `--abort-source-on-error` to the Maria `mysql` client to ensure that
+ SQL errors cause the client to abort with an error so that Sqitch can
+ properly handle it. Thanks to @mvgrimes for the original report and,
+ years later, the fix (#209).
+ - Fixed an issue with command argument parsing so that it truly never
+ returns a target without an engine specified, as documented.
+ - Removed documentation for methods that don't exist.
+ - Fixed test failures due to a change in Encode v2.99 that's stricter
+ about `undef` arguments that should be defined.
+
+ [Improvements]
+ - The Snowflake engine now consults the `connections.warehousename`,
+ `connections.dbname`, and `connections.rolename` variables in the
+ SnowSQL configuration file (`~/.snowsql/config`) before falling back on
+ the hard-coded warehouse name "sqitch" and using the system username as
+ the database name and no default for the role.
+ - Switched to using a constant internally to optimize windows-specific
+ code paths at compile time.
+ - When `deploy` detects undeployed dependencies, it now eliminates
+ duplicates before listing them in the error message.
+ - Now requiring IO::Pager v0.34 or later for its more consistent
+ interface.
+ - Added notes about creating databases to the tutorials. Thanks to Dave
+ Rolsky for the prompt (#315).
+ - Added a status message to tell the user when the registry is being
+ updated, rather than just show each individual update. Thanks to Ben
+ Hutton for the suggestion (#276).
+ - Added support for a `$SQITCH_TARGET` environment variable, which takes
+ precedence over all other target specifications except for command-line
+ options and arguments. Thanks to @mvgrimes for the suggestion (#203).
+ - Fixed target/engine/change argument parsing so it won't automatically
+ fail when `core.engine` isn't set unless no targets are found. This
+ lets engines be determined strictly from command-line arguments --
+ derived from targets, or just listed on their own -- whether or not
+ `core.engine` is set. This change eliminates the need for the
+ `no_default` parameter to the `parse_args()` method of App::Sqitch
+ Command. It also greatly reduces the need for the core `--engine`
+ option, which was previously required to work around this issue (see
+ below for its removal).
+ - Refactored config handling in tests to use a custom subclass of
+ App::Sqitch::Config instead of various mocks, temporary files, and the
+ like.
+ - Added advice to use the PL/pgSQL `ASSERT()` function for verify scripts
+ to the Postgres tutorial. Thanks to Sergii Tkachenko for the PR (#425).
+
+ [Target Variables]
+ - The `verify` command now reads `deploy.variables`, and individual
+ `verify.variables override `deploy.variables`, on the assumption that
+ the verify variables in general ought to be the same as the deploy
+ variables. This makes `verify` variable configuration consistent with
+ `revert` variable configuration.
+ - Variables set via the `--set-deploy` option on the `rebase` and
+ `checkout` commands no longer apply to both reverts and deploys, but
+ only deploys. Use the `--set` option to apply a variable to both
+ reverts and deploys.
+ - Added support for core, engine, and target variable configuration. The
+ simplest way to use them is via the `--set` option on the `init`,
+ `engine`, and `target` commands. These commands allow the configuration
+ of database client variables for specific engines and targets, as well
+ as defaults that apply to all change execution commands (`deploy`,
+ `revert`, `verify`, `rebase`, and `checkout`). The commands merge the
+ variables from each level in this priority order:
+ * `--set-deploy` and `--set-revert` options on `rebase` and `checkout`
+ * `--set` option
+ * `target.$target.variables`
+ * `engine.$engine.variables`
+ * `deploy.variables`, `revert.variables`, and `verify.variables`
+ * `core.variables`
+ See `sqitch-configuration` for general documentation of of the
+ hierarchy for merging variables and the documentation for each command
+ for specifics.
+
+ [Options Unification]
+ - Added the `--chdir`/`--cd`/`-C` option to specify a directory to change
+ to before executing any Sqitch commands. Thanks to Thomas Sibley for
+ the suggestion (#411).
+ - Added the `--no-pager` option to disable the pager (#414).
+ - Changed command-line parsing to allow core and command options to
+ appear anywhere on the line. Previously, core options had to come
+ before the command name, and command options after. No more. The caveat
+ is that command options that take arguments should either appear after
+ the command or use the `--opt=val` syntax instead of `--opt val`, so
+ that Sqitch doesn't think `val` is the command. Even in that case, it
+ will search the rest of the arguments to find a valid command.
+ However, to minimize this challenge, the documentation now suggests
+ and demonstrates putting all options after the command, like so:
+ `sqitch [command] [options]`.
+ - Simplified and clarified the distinction between core and command
+ options by removing all options from the core except those that affect
+ output and runtime context. The core options are:
+ * -C --chdir --cd <dir> Change to directory before performing any actions
+ * --etc-path Print the path to the etc directory and exit
+ * --no-pager Do not pipe output into a pager
+ * --quiet Quiet mode with non-error output suppressed
+ * -V --verbose Increment verbosity
+ * --version Print the version number and exit
+ * --help Show a list of commands and exit
+ * --man Print the introductory documentation and exit
+ - Relatedly, single-letter core options will now always be uppercase,
+ while single-letter command options will be lowercase. As such, `-V`
+ has been added as an alias for `--version`, although `-v` remains for
+ now, undocumented. It may be removed in the future should a compelling
+ use for `-v` in a command be discovered.
+ - All other options have been moved to the commands they affect. Their
+ use should remain mostly unchanged now that command options are parsed
+ from anywhere on the command-line, although we recommend that all
+ options come after commands. The options were moved as follows:
+ * `--registry`, `--client`, `--db-name`, `--db-user`, `--db-host`, and
+ `--db-port` (and their aliases) have been moved to the `checkout`,
+ `deploy`, `log`, `rebase`, `revert`, `status`, `upgrade`, and
+ `verify` commands.
+ * `--plan-file` and `--top-dir` (deprecated; see below) have been moved
+ to the `add`, `bundle`, `checkout`, `deploy`, `rebase`, `revert`,
+ `rework`, `show`, `status`, `tag`, and `verify` commands. They were
+ already supported by the `init`, `engine`, and `target` commands
+ (where `--top-dir` is not deprecated).
+ - Because some command options conflicted with core options, a few
+ options have been removed altogether, including:
+ * The `--verbose` option on the `--engine` and `--target` commands has
+ been removed, but no visible change should be apparent, since those
+ commands now read the core `--verbose` option.
+ * The undocumented `--dir` alias for `--top-dir` has been removed, as
+ it conflicted with the option of the same name but different meaning
+ in the `init`, `engine`, and `target` commands.
+ * The `-d` alias for `--set-deploy` in the `rebase` and `checkout`
+ commands has been changed to `-e` so as not to conflict with the `-d`
+ alias for `--db-name`.
+ * Added tests for all commands to ensure none of their options conflict
+ with core options. Will help prevent conflicts in the future.
+
+ [Deprecations & Removals]
+ - Deprecated the `--top-dir` option in favor of `--chdir` with a warning
+ except when used for configuration in the `init`, `engine`, and
+ `target` commands.
+ - Removed the core `--deploy-dir`, `--revert-dir`, and `--verify-dir`
+ options, which have been deprecated and triggering warnings since
+ v0.9993 (August 2015). The `--dir` option to the `init`, `engine`, and
+ `target` commands remains the favored interface for specifying script
+ directories.
+ - Removed the deprecated core `--engine` option. The `init` command still
+ supports it, while other commands are able to parse the engine name as
+ an argument --- e.g., `sqitch deploy mysql` --- or implicitly as part
+ of a target, as in `sqitch revert db:pg:tryme`. When Sqitch is unable
+ to determine the engine for a command, the error message no longer
+ mentions `--engine` and instead suggests specifying the engine via the
+ target. This option never triggered an error, but demonstration of its
+ use has been limited to `init` examples.
+ - Removed support for reading the `core.$engine` configuration, which has
+ been deprecated with warnings in favor of `engine.$engine` since 0.997
+ (November 2014). The `sqitch engine update-config` action remains
+ available to update old configurations, but may be removed in the
+ future.
+ - Removed the `--deploy`, `--revert`, and `--verify` options on the `add`
+ command, as well as their `--no-*` variants. They have been deprecated
+ with warnings in favor of the `--with` and `--without` options since
+ v0.990 (January 2014).
+ - Removed the `--deploy-template`, `--revert-template`, and
+ `--verify-template` options to the `add` command. They have been
+ deprecated with warnings in favor of the `--use` option since v0.990
+ (January 2014).
+ - Removed the `add.deploy_template`, `add.revert_template`, and
+ `add.verify_template` configuration settings. They have been deprecated
+ with warnings in favor of the `add.templates` configuration section
+ since v0.990 (January 2014).
+ - Removed the `@FIRST` and `@LAST` symbolic tags, which have been
+ deprecated with warnings in favor of `@ROOT` and `@HEAD`, respectively,
+ since 0.997 (November 2014).
+ - Removed the command-specific options with the string "target" in them,
+ such as `--to-target`, `--upto-target`, which have been deprecated with
+ warnings in in favor of options containing the string "change", such as
+ `--to-change` and `--upto-change`, since v0.997 (November 2014).
+ - Remove the `engine` and `target` command `set-*` actions and their
+ corresponding methods, which have been deprecated in favor of the
+ `alter` action since v0.9993 (August 2015).
+ - Removed the automatic updating of change and tag IDs in the Postgres
+ engine. This functionality was added in v0.940 (December 2012), when
+ Postgres was the only engine, and the SHA-1 hash for change and tag IDs
+ was changed. There were very few deployments at the time, and all
+ should long since have been updated.
+
+ [API Changes]
+ - Added the URI-overriding parameters `user`, `host`, `port`, and
+ `dbname` to App::Sqitch::Target so that command options can be used to
+ easily set them.
+ - Added support for passing attribute parameters to the `all_targets`
+ group constructor on App::Sqitch::Target, so that command-line options
+ can be used to assign attributes to all targets read from the
+ configuration.
+ - Aded the `target_params` method to App::Sqitch::Command and updated all
+ commands to use it when constructing targets. This allows commands to
+ define options for Target parameters, as required for moving options to
+ commands as described above.
+ - Added the `class_for` method to App::Sqitch::Command so that the new
+ options parser described above can load a command class without
+ instantiating an instance. Useful for searching command-line arguments
+ for a command name.
+ - Added the `create` constructor to App::Sqitch::Command to let Sqitch
+ instantiate an instance of a command once it finds one via `class_for`.
+ Previously, Sqitch used the `load` method, which handled the
+ functionality of both `class_for` and `create`. That method still
+ exists but is used only in tests.
+ - Added the ConnectingCommand role to define database connection options
+ for the commands that need them.
+ - Added the ContextCommand role to define command options for the
+ location of the plan file and top directory. This is also where use of
+ the deprecated form of `--top-dir` triggers a warning.
+ - Removed the `verbosity` attribute from App::Sqitch::Command::engine and
+ App::Sqitch::Command::target, since the `--verbose` option is no longer
+ needed. These commands now rely on the core `--verbose` option.
+ - Removed the copying of core options from the target class and
+ TargetConfigCommand role, since the attributes fetched from there are
+ no longer core options, but provided as attribute parameters to the
+ constructors by commands.
+ - Removed documentation for the optional `config` parameter to the
+ `all_targets` constructor of App::Sqitch::Target, since it was never
+ used by Sqitch. It always fetched the config from the required `sqitch`
+ parameter. Support for the `config` parameter has not been removed,
+ since third-parties might use it.
+ - Removed the `set_*` methods in the `engine` and `target` commands,
+ which have been deprecated in favor of the new `alter` method since
+ v0.9993 (August 2015).
+ - Removed the `old_id` and `old_info` methods from Change and Tag, which
+ date from v0.940 (December 2012), and were provided only to allow
+ existing Postgres databases to be updated from the old to new ID
+ format, now removed. There should be no other use case for these
+ methods.
+
+0.9998 2018-10-03T20:53:58Z
+ - Fixed an issue where Sqitch would sometimes truncate the registry
+ version number fetched from MySQL, most likely because the Perl runtime
+ was using 32-bit integers. Fixed by casting the version to CHAR in the
+ query, before Perl ever see it. Thanks to Allen Godfrey David for the
+ report.
+ - Added the Snowflake engine.
+ - Now require URI::db v0.19 for Snowflake URI support.
+ - The Vertica and Exasol engines now require DBD::ODBC 1.59, which fixes
+ a Unicode issue. Thanks to Martin J. Evans for the quick fix
+ (perl5-dbi/DBD-ODBC#8)!
+ - Added the `bundle` command to `./Build`. This command installs only the
+ runtime dependencies into the `--install_base` directory. This should
+ simplify building distribution packages, binary installs, Docker images,
+ and the like.
+ - Added the `--with` option to `./Build`, to require that Sqitch be build
+ with the specified engine. Pass once for each engine. See the README
+ for the list of supported engines.
+ - Added a check for Hash::Merge 0.298 during installation, since that
+ release has a fatal bug that breaks Sqitch. If it's installed, the
+ installer will issue a warning and added v0.299 to its list of
+ dependencies. Thanks to Slaven Rezić for the suggestion (#377).
+ - Fixed the PostgreSQL engine so it properly checks the `psql` client
+ version to determine whether or not the `:registry` variable is
+ supported. Previously it relied on the server version, which would fail
+ if the server version was greater than 8.4 but the `psql` client was
+ not. Thanks to multiple folks reporting issues with registry names and
+ search paths (#314).
+ - The plan parser will now complain if a change specifies a duplicate
+ dependency. This should be less confusing than a database unique
+ violation. Thanks to Eric Bréchemier for the suggestion (#344).
+ - Moved the project to its own GitHub organization,
+ [Sqitchers](https://github.com/sqitchers).
+ - Fixed likely cause of Oracle buffer allocation bug when selecting
+ timestamp strings. Thanks to @johannwilfling for the bug report and to
+ @nmaqsudov for the analysis and solution (#316).
+ - Changed the way the conninfo string is passed to `psql` to eliminate
+ argument ordering problems on Windows. Thanks to @highlowhighlow for
+ the report (#384).
+ - Added `$SQITCH_USERNAME` environment variable to complement
+ `$SQITCH_PASSWORD`. It can be used to override the username set in
+ for a target.
+ - Added the `$SQITCH_FULLNAME` and `$SQITCH_EMAIL` environment
+ variables, which take precedence over the values of the `user.name` and
+ `user.email` config variables.
+ - Added the `$SQITCH_ORIG_SYSUSER`, `$SQITCH_ORIG_FULLNAME` and
+ `$SQITCH_ORIG_EMAIL` environment variables. For those situations when
+ Sqitch attempts to read OS data for user information, These new
+ environment variables override these system-derived values. The
+ intention is to allow an originating host to set these values on
+ another host where Sqitch will actually execute.
+ - Fixed an error triggered by whitespace trailing an engine name in the
+ configuration. Thanks to Jeremy Simkins for the report (#400).
+ - Refactored the engine-specific username and password attributes to
+ support a consistent search for values. Sqitch searches first for one
+ of its own environment variables (`$SQITCH_USERNAME` and
+ `$SQITCH_PASSSWORD`), then the target URI, and finally any engine-
+ specific values, which might include additional environment variables,
+ configuration files, the system user, or none at all.
+ - Database engines that implicitly relied on username and/or password
+ environment variables or on the system username now explicitly rely on
+ them. These include the Firebird, MySQL, Postgres, and Vertical
+ engines. This change should exhibit no change in the behavior of these
+ engines.
+ - Added support for the `$MYSQL_HOST` and `$MYSQL_TCP_PORT` environment
+ variables to the MySQL engine.
+ - Documented all supported engine-specific environment variables in the
+ sqitch-environment guide.
+ - Renamed the sqitch-passwords guide to sqitch-authentication and added a
+ section on username specification.
+ - Updated all URLs to use the https scheme. Only exceptions are tt2.org,
+ which doesn't support TLS, and conferences.embarcadero.com, which
+ appears to be down.
+
+0.9997 2018-03-15T21:13:52Z
+ - Fixed the Firebird engine to properly detect multiple instances of a
+ change specified to `revert` and `verify`, matching the behavior of
+ displaying tag-qualified alternates added to the other engines in
+ v0.9996.
+ - Fixed test failure on Windows.
+ - Updated the MySQL and PostgreSQL tests to use process-specific database
+ names, to try to avoid conflicts when tests are being run by multiple
+ processes on the same box, as happens with CPAN smoke testing boxes.
+ - Fixed an issue where Sqitch would sometimes truncate the registry
+ version number fetched from Postgres, most likely because the Perl
+ runtime was using 32-bit integers. Fixed by casting the version to text
+ in the query, before Perl ever see it. Thanks to Malte Legenhausen for
+ the report (#343).
+ - The MySQL engine will now read the username from MySQL configuration
+ files. Thanks to Eliot Alter for the bug report (#353).
+ - Added Italian translation, with thanks to Luca Ferrari and @BeaData!
+ - Improved multi-value config examples in the `sqitch-config`
+ documentation to be a bit less confusing. Thanks to Emil for reporting
+ where he got confused!
+ - Added the Exasol engine. Thanks to Johan Wärlander for the PR (#362)!
+ - Fixed an issue where URI::db needed to be explicitly loaded. Thanks to
+ Hugh Esco for the report (#370)!
+ - Changed the exit value for `rebase` and `revert` from 1 to 0 when there
+ is no work to do. This is to match the expectation of non-zero exit
+ statuses only when a command is unsuccessful, as well as the behavior
+ of `deploy` as of v0.995. Nothing to do is considered successful.
+ Thanks to Paul Williams for the PR (#374)!
+ - Update `psql` options to use a conninfo string to honor connection
+ parameter key words for PostgreSQL targets. It can now take advantage
+ of the connection service file using `db:pg:///?service=$PGSERVICE` as
+ well as other connection parameters. Thanks to Paul Williams for the PR
+ (#375)!
+
+0.9996 2017-07-17T18:33:12Z
+ - Fixed an error where Oracle sometimes truncated timestamp formats so
+ that date parsing failed. Thanks to Johann Wilfling for the report and
+ @nmaqsudov for the solution (#316).
+ - Added pager configuration, prioritizing the new `core.pager`
+ configuration variable over the `$PAGER` environment variable. The new
+ `$SQITCH_PAGER` environment variable trumps all. Thanks to Yati Sagade
+ for the pull request (#329).
+ - Documented the `core.editor` configuration variable.
+ - Updated PostgreSQL registry detection to avoid errors when not running
+ Sqitch as a superuser and the registry schema already exists. Done by
+ looking for the `changes` table in the `pg_tables` view instead of
+ looking for the registry schema in the `pg_namespace` catalog table,
+ and by using `CREATE SCHEMA IF NOT EXISTS` on PostgreSQL 9.3 and
+ higher. Thanks to @djk447 for the pull request (#307).
+ - Updated PostgreSQL registry detection to avoid errors when the `psql`
+ client is newer than the server version. Sqitch now fetches the version
+ from the server instead of parsing it from the client.
+ - Removed `no Moo::sification`, to allow modules to be used by Moose
+ applications. Replaced with tests to make sure Sqitch itself never uses
+ Moose. Thanks to @perigrin for the PR (#332).
+ - Specifying a change before a target name on the command-line no longer
+ ignores the target (#281).
+ - The `--db-*` options are now more consistently applied to a target,
+ including when the target is specified as a URI (#293).
+ - `HEAD` and `ROOT` are now properly recognized as aliases for `@HEAD`
+ and `@ROOT`, when querying the database. This was supposedly done in
+ v0.991, but due to a bug, it wasn't really. Sorry about that.
+ - The `revert` and `verify` commands will now fail if a change is
+ specified and matches multiple changes. This happens when referencing a
+ reworked change only by its name. In this case, Sqitch will emit an
+ error listing properly tag-qualified changes to use. Suggested by Jay
+ Hannah (#312).
+ - Sqitch no longer returns an error when a target name is passed to a
+ command and the default target's plan file does not exist (#324).
+ - Added missing options to the `rework` usage statement. Thanks to Jay
+ Hannah for the PR (#342).
+ - Passing an engine name or plan file as the `<database>` parameter to
+ the `log`, `status`, and `upgrade` commands now works correctly,
+ matching what the documentation has said for some time (#324).
+ - Added the `--target` option to the `plan` and `show` commands.
+ - Added the `<database>` parameter to the `plan` command.
+ - Sqitch now loads targets from all config files, not just the local
+ file, when trying to determine if a `<database>` parameter is a plan
+ file name.
+ - Improved the error message when a change is found more than once in a
+ plan, typically a reworked changed referenced only by name. The error
+ will no longer be "Key at multiple indexes", but "Change is ambiguous.
+ Please specify a tag-qualified change:", followed by a list of
+ tag-qualified variants of the change.
+ - Fixed a bug where the verify command would return a database error when
+ it finds no registry. Now it reports that the registry wasn't found in
+ the database.
+
+0.9995 2016-07-27T09:23:55Z
+ - Taught the `add` command not to ignore the `--change` option.
+ - The `add` command now emits a usage statement when no change name is
+ passed to it.
+ - The `add` command now helpfully suggests using the --change option when
+ attempting to add a change with the same name as a target. Thanks to
+ Ivan Nunes for the report!
+ - The `tag` command now helpfully suggests using the --tag option when
+ attempting to add a tag with the same name as a target.
+ - Added `--global` as an alias for `--user` to the `config` command. This
+ alias benefits the muscle memory of Git users.
+ - Added a note for Git users to the `sqitch-revert` documentation, to
+ head off potential confusion with `git revert`. Thanks to Eric
+ Bréchemier for the "time travel" analogy and wording.
+ - Fixed an "uninitialized value" error when creating a registry database
+ on Windows. Thanks to Steven C. Buttgereit for the report (Issue #289).
+ - Fixed editor selection to prioritize the `core.editor` configuration
+ variable over the `$EDITOR` environment variable. The `$SQITCH_EDITOR`
+ environment variable still trumps all. Thanks to Jim Nasby for the pull
+ request (#296).
+ - Added detection of the `$VISUAL` environment variable to Editor
+ selection, prioritized after the `core.editor` configuration variable
+ and before the `$EDITOR` environment variable. Thanks to Jim Nasby for
+ the pull request (#296).
+ - Updated the DateTime code to set the locale via `set_locale()` instead
+ of `set()`, as the latter may actually change the local time
+ unintentionally, and has been deprecated since DateTime v1.04. Thanks
+ to Dave Rolsky for the pull request (#304).
+
+0.9994 2016-01-08T19:46:43Z
+ - Reduced minimum required MySQL engine from 5.1.0 to 5.0.0. Thanks to
+ @dgc-wh for testing it (Issue #251).
+ - Fixed floating-point rounding issue with SQLite registry versions on
+ Perls with 16-byte doubles. Thanks to H. Merijn Brand for the report
+ and testing.
+ - Fixed an error when adding an engine with the `engine` command. Thanks
+ to Victor Mours for the report and fix!
+ - Updated the Oracle engine to support Oracle Wallet connection strings,
+ where no username or host is in the connection URI. Thanks to Timothy
+ Procter for the patch!
+ - Improved the installer's selection of the prefix in which to install
+ `etc` files to better match the `--installdirs` option, which defaults
+ to the "site" directories. Thanks to @carragom for the pull request
+ (#265).
+ - Added missing dash to `-engine` in sample calls to `sqitch init` in the
+ tutorials. Thanks to Andrew Dunstan for the spot (Issue #268).
+ - Fixed broken Vertica documentation links.
+ - Attempting to revert a database with no associated registry no longer
+ reports the registry as version 0, but correctly reports that no
+ registry can be found. Thanks to Arnaldo Piccinelli for the spot (Issue
+ #271).
+ - Fixed the search for change IDs in engines to match the search for
+ changes. Specifically, change ID search now properly handles the
+ offset characters `~` and `^`. This bug mainly affected the `verify`
+ command, but it's good to address the inconsistency, done mainly by
+ adding the `find_change_id` and `change_id_offset_from_id` methods to
+ complement the `find_change` and `change_offset_from_id` methods.
+ Thanks to Andrew Dunstan for the spot (Issue #272).
+ - Fixed the `flips` table example in the MySQL tutorial. It was
+ inappropriately copied from the PostgreSQL tutorial at some point.
+ Thanks to Jeff Carpenter for the spot (Issue #254)!
+
+0.9993 2015-08-17T17:55:26Z
+ [Bug Fixes]
+ - Eliminated test failures due to warnings from DateTime::Locale when
+ `LC_TIME` is set to C.UTF-8. Thanks to Shantanu Bhadoria for the report
+ and Dave Rolsky for the workaround.
+ - Fixed an error checking the registry version when the local uses a
+ comma for decimal values. Thanks to Steffen Müller for the report
+ (Issue #234).
+ - Worked around an error setting the MySQL storage engine using versions
+ of DBI prior to 1.631. Thanks to melon-babak for the report!
+ - Fixed an error from the Oracle engine when deploying more than 1000
+ changes. Thanks to Timothy Procter and Minh Hoang for the report and
+ testing the fix.
+ - Fixed a bunch of typos in error messages, comments, and documentation.
+ Thanks to Dmitriy for the pull request!
+ - Fixed test failures due to new warnings from File::Path on Perl
+ 5.23.1.
+ - On Firebird, Looking up a change and tag in the database (via the
+ `--onto` option to `rebase` or the `--to` option to `revert`, among
+ others) would sometimes return the incorrect change if the change has
+ been reworked two or more times. Was fixed for the other engines in
+ v0.9991.
+ - Fixed the `--all` option used to apply a command to all known targets
+ so that it loads only targets specified by the local configuration.
+ Otherwise, user and system configuration can get in the way when they
+ specify engines and targets not used by the current project.
+ [Improvements]
+ - Added support for the `--set` option when deploying to MySQL. Thanks to
+ Chris Bandy for figuring out how to do it!
+ - Added support for a "reworked directory". By default, reworked change
+ scripts live in the deploy, revert, and verify directories along with
+ all the other change scripts. But if that starts to get too messy, or
+ you simply don't want to see them, add a `reworked_dir` setting to the
+ core, engine, or target config and reworked scripts will be stored
+ there, instead. Also supported are `reworked_deploy_dir`,
+ `reworked_revert_dir`, and `reworked_verify_dir`.
+ - Added the `--dir` option to the `init`, `engine`, and `target`
+ commands.
+ - Copied the core configuration options (`--engine`, `--target`,
+ `--plan-file`, `--registry`, etc.) to the `init`, `engine`, and
+ `target` commands. This means that they can be specified after the
+ command, which is a bit more natural. It also means that the
+ `--registry` and `--client` options of the `target` are no longer
+ deprecated.
+ - The `init` command no longer writes out commented values for the
+ `deploy_dir`, `revert_dir`, or `verify_dir` settings. I think these
+ settings are not commonly used, and it would start to get crowded if we
+ also added their "reworked" variants, which will be used still less.
+ - Added the `alter` action to the `engine` and `target` commands to set
+ engine and target properties.
+ - Added support for setting reworked directories to the `engine` and
+ `target` commands.
+ - Reformatted the output of the `engine` and `target` command `show`
+ actions to include reworked directories, and to bit a bit less flat.
+ - Attempting to add or alter an engine with a target URI that connects to
+ a different engine now triggers an error. For example, you can't set
+ the target for engine `pg` to `db:sqlite:`.
+ - The `add` and `alter` actions of the `engine` and `target` commands
+ now create script directories if they don't already exist.
+ - The `add` action of the `engine` and `target` commands now creates a
+ plan file if one does not exist in the specified location for the
+ engine or target.
+ - Added the `deploy_dir`, `revert_dir`, and `verify_dir` methods to
+ App::Sqitch::Plan::Change. Each points to the proper directory for the
+ target depending on whether or not the change has been reworked.
+ - In the MySQL engine, the following URI query params will be converted
+ to options passed to the command-line client, if they're present:
+ * mysql_compression=1 => --compress
+ * mysql_ssl=1 => --ssl
+ * mysql_connect_timeout => --connect_timeout
+ * mysql_init_command => --init-command
+ * mysql_socket => --socket
+ * mysql_ssl_client_key => --ssl-key
+ * mysql_ssl_client_cert => --ssl-cert
+ * mysql_ssl_ca_file => --ssl-ca
+ * mysql_ssl_ca_path => --ssl-capath
+ * mysql_ssl_cipher => --ssl-cipher
+ [Documentation]
+ - Added the "Overworked" section to sqitch-configuration guide with an
+ example of how to move reworked change scripts into a `reworked_dir`.
+ [Deprecations]
+ - Deprecated the `set-*` actions in the `engine` and `target` commands in
+ favor of the new `alter` action.
+ - The core `--deployed-dir`, `--revert-dir`, and `--verify-dir` options
+ are deprecated in favor of the `--dir` option on the `init`, `engine`,
+ and `target` command.
+
+0.9992 2015-05-20T23:51:41Z
+ - On PostgreSQL, Sqitch now sets the `client_encoding` parameter to
+ `UTF8` for its own connection to the database. This ensures that data
+ sent to and from the database should always be properly encoded and
+ decoded. Users should still set the proper encodings for change scripts
+ as appropriate.
+ - Fixed test failures due to path differences on Windows.
+ - DateTime::TimeZone is now explicitly required in an attempt to head off
+ "Cannot determine local time zone" errors.
+ - Corrected some typos and thinkos in `sqitchtutorial-oracle`, thanks to
+ George Hartzell.
+ - Improved the script to upgrade an Oracle registry to v1.0 to support
+ versions prior to Oracle 12, thanks to Timothy Procter.
+ - Added missing closing parenthesis to the "Nothing to deploy" message.
+ Thanks to George Hartzell for the pull request (Issue #226).
+ - Replaced the unique constraint on the `script_hash` column in the
+ `changes` registry table with a unique constraint on `project` and
+ `script_hash`. This is to allow a deploy script to be used in more than
+ one project in a single database. This change increments the registry
+ version to v1.1. Thanks to Timothy Procter for the report.
+ - Updated the registry check constraints to have consistent names on the
+ engines that support them. This will make it easier to modify the
+ constraints in the future.
+ - Fixed precision issues with the registry version on MySQL and Firebird.
+ - Added comment to sqitch-passwords guide that MySQL::Config is required
+ to read passwords from the MySQL configuration files. Thanks to
+ Sterling Hanenkamp for the patch!
+
+0.9991 2015-04-03T23:14:39Z
+ [Improvements]
+ - Reduced minimum required MySQL engine from 5.6.4 to 5.1.0. Versions
+ prior to 5.6.4 lose the following features:
+ * Versions earlier than 5.6.4 is fractional second precision on
+ registry `DATETIME` columns. Since the ordering of those timestamps
+ is so important to the functioning of Sqitch, it will sleep in 100 ms
+ increments between logging changes to the registry until the time has
+ ticked over to the next second. Naturally, reverts and deploys will
+ be a little slower on versions of MySQL before 5.6.4, but accurate.
+ * Versions earlier than 5.5.0 lose the `checkit()` functions, which
+ would otherwise be used to emulate CHECK constraints in the registry,
+ as well as in user-created verify scripts, as recommended in the
+ MySQL tutorial, `sqitchtutorial-mysql`.
+ - Added a script to update the `DATETIME` columns in a MySQL Sqitch
+ registry that was upgraded to MySQL 5.6.4 or higher. It will be
+ installed as `tools/upgrade-registry-to-mysql-5.6.4.sql` in the
+ directory returned by `sqitch --etc`.
+ - Added a script to add the `checkit()` function and registry triggers to
+ emulate CHECK constraints to a MySQL Sqitch registry that was upgraded
+ to MySQL 5.5.0 or higher. It will be installed as
+ `tools/upgrade-registry-to-mysql-5.5.0.sql` in the directory returned
+ by `sqitch --etc`.
+ - The `init` command now throws an error when the plan file already
+ exists and is invalid or defined for a different project. Thanks to
+ Gabriel Potkány for the suggestion (Issue #214).
+ - All commands that take target arguments can now specify them as engine
+ names or plan file paths as well as target names and URIs.
+ - Added the `--all` option and the `$command.all` configuration variable
+ to the `add`, `rework`, `tag`, and `bundle` commands. This option tells
+ the commands to do their thing for all plans known from the
+ configuration, not just the default plan.
+ - Pass engine, target, or plan file names to the `add`, `rework`, `tag`,
+ and `bundle` commands` commands to specify specify one or more targets,
+ engines, and plans to act on.
+ - Added the `--change` option to the `add`, `rework`, and `tag` commands
+ to distinguish the change to be added, reworked, or tagged from
+ plan-specifying arguments, if necessary.
+ - Added the `--tag` option to the `tag` command to distinguish the tag to
+ be added from plan-specifying arguments, if necessary.
+ - Changed the short variant of the `--conflicts` option to the `add` and
+ `rework` commands from `-c` to `-x`. The `-c` option is now used as the
+ short variant for `--change` (and `--conflicts` has almost certainly
+ never been used, anyway).
+ - Added the `engine` and `project` variables to the execution of script
+ templates by the `add` command. The default templates now use it to
+ make their first lines one of:
+ * -- Deploy [% project %]:[% change %] to [% engine]
+ * -- Revert [% project %]:[% change %] from [% engine]
+ * -- Verify [% project %]:[% change %] on [% engine]
+ [Bug Fixes]
+ - DateTime::TimeZone::Local::Win32 is now required on Windows.
+ - The MySQL engine no longer passes `--skip-pager` on Windows, since
+ it is not supported there. Thanks to Gabriel Potkány for the report
+ (Issue #213).
+ - Fixed "no such table: changes" error when upgrading the SQLite
+ registry.
+ - Fixed upgrade failure on PostgreSQL 8.4. Thanks to Phillip Smith for
+ the report!
+ - Fixed an error when the `status` command `show_changes` and `show_tags`
+ configuration variables were set. Thanks to Adrian Klaver for the
+ report (Issue #219).
+ - Fixed `log` and `plan` usage statements to properly spell `--abbrev`.
+ Thanks to Adrian Klaver for the report (Issue #220).
+ - Fixed the formatting of change notes so that a space precedes the `#`
+ character whether the note was added by the `--note` option or via an
+ editor.
+ - Fixed a bug when parsing plan files with DOS/Windows line endings.
+ Thanks to Timothy Procter for the report (Issue #212).
+ - Looking up a change and tag in the database (via the `--onto` option to
+ `rebase` or the `--to` option to `revert`, among others) would
+ sometimes return the incorrect change if the change has been reworked
+ two or more times. Thanks to BryLo for the report!
+ [Documentation]
+ - Updated docs to be consistent in referring to the location of the system
+ configuration and template location as `$(prefix)/etc/sqitch`. Also
+ added notes pointing to the `--etc-dir` to find out exactly what that
+ resolves to. Suggested by Joseph Anthony Pasquale Holsten (Issue #167).
+ [Deprecations]
+ - Reverted deprecation of the database connection options. Target URIs
+ are still generally preferred, but sometimes you want to use a target
+ but just change the user name or database name. Retaining the options
+ is the easiest way to do this. Plus, a fair number of people have
+ scripts that use these options, and it seems petty to break them. Sorry
+ for the double-take here! The list of un-deprecated options is:
+ * `--db-client`
+ * `--db-host`
+ * `--db-port`
+ * `--db-username`
+ * `--db-password`
+ * `--db-name`
+
+0.999 2015-02-12T19:43:45Z
+ - Improved MySQL missing table error detection by relying on error codes
+ instead of matching a (possibly localized) error string.
+ - Made the registry upgrade more transparent when deploying. Sqitch is
+ now is a little more vigilant in checking for things being out-of-date
+ and updating them.
+ - Fixed an issue where the `status` command would return an error when
+ run against a an older version of the registry.
+ - Fixed a Postgres test failure when DBD::Pg is installed but psql is not
+ in the path.
+ - Now require Config::GitLike 1.15 to build on Windows in order to avoid
+ test failures when Cwd::abs_path dies on non-existent paths.
+ - Clarified the behavior of each `deploy` reversion mode with regard to
+ deploy script vs. verify script failures, and with the expectation that
+ deploy scripts are atomic.
+ - Target passwords can now be set via a single environment variable,
+ `$SQITCH_PASSWORD`. Its value will override URI-specified password.
+ - Added the sqitch-passwords and sqitch-environment guides.
+
+0.998 2015-01-15T22:17:44Z
+ - Fixed a bug in `sqitch engine update-config` where it would add data to
+ config files that did not previously have them, or report that data was
+ present in nonexistent config files.
+ - Added the `releases` table to the databases. This table will keep track
+ of releases of the Sqitch registry schema.
+ - The Oracle `registry` variable is now always `DEFINE`d when Oracle
+ scripts run.
+ - Added the `upgrade` command, which upgrades the schema for the Sqitch
+ registry for a target database.
+ - Added the `script_hash` column to the `changes` registry table. This
+ column contains a SHA-1 hash of the deploy script for the change at the
+ time it was deployed. For existing registries, the upgrade script sets
+ its value to be the same as the change ID. This value is update the
+ next time a project is deployed to the database.
+ - The error message when `deploy` cannot find the currently-deployed
+ change ID in the plan now includes more contextual information,
+ including the change name, associated tags, and the plan file name.
+ Suggested by Curtis Poe (Issue #205).
+ - Comments on Firebird registry objects are now created with the
+ `COMMENT` command, rather than INSERTs into catalog tables.
+ - Added support for "merge" events, though none are logged, yet.
+
+0.997 2014-11-04T22:52:23Z
+ [New Features]
+ - Added support for new target properties. In addition to the existing
+ `uri`, `client`, and `registry` properties, targets may also configure
+ these properties via the new `--set` option to and `set-*` actions on
+ the `target` command:
+ * `top_dir`
+ * `plan_file`
+ * `extension`
+ * `deploy_dir`
+ * `revert_dir`
+ * `verify_dir`
+ - Added support for new engine configuration variables. In addition to
+ the existing `target`, `client`, and `registry` variables, engine
+ configuration may also include these variables:
+ * `top_dir`
+ * `plan_file`
+ * `extension`
+ * `deploy_dir`
+ * `revert_dir`
+ * `verify_dir`
+ - Rationalized the hierarchical configuration of deployment targets. The
+ properties of any given target will now be determined by examining
+ values in the following order:
+ * Command-line options
+ * Target configuration
+ * Engine configuration
+ * Core configuration
+ * Reasonable engine-specific defaults
+ - Added the `engine` command to simplify engine configuration. This
+ complements the newly-improved `target` command. Run `sqitch engine
+ update-config` to update deprecated engine configurations and start
+ using it.
+ - Added the sqitch-configuration guide to provide an overview of core,
+ engine, and target configuration. Includes some use-case examples and
+ best suggested practices.
+ [Improvements]
+ - Simplified the output of `sqitch help`, and added the more important
+ options to it.
+ - Added the `--guide` option to `sqitch help` to list Sqitch guides.
+ - Renamed the `--db-client` option to `--client`. `--db-client` still
+ works, but is deprecated.
+ - Added the `--registry` core option for parity with `--client`,
+ `--top-dir`, `--plan-file`, and the rest of the hierarchical
+ configuration properties.
+ - Updated the `init` documentation to better cover all the options
+ processed.
+ - Incremented the version plan file format version to v1.0.0. No changes;
+ it has been stable for at least a year, so it's time.
+ [Bug Fixes]
+ - At runtime, the Vertica engine now properly requires DBD::ODBC
+ instead of DBD::Pg.
+ - The Vertica engine now supports Vertica 6, as documented.
+ - Fixed a warning from Type::Utils, thanks to a report from Géraud
+ CONTINSOUZAS.
+ - The `status` command once again notices if the specified database is
+ uninitialized and says as much, rather than dying with an SQL error.
+ - The `--etc-path` option works again.
+ [Deprecations]
+ - Deprecated `core.$engine` configuration in favor of `engine.$engine`. A
+ warning will be emitted if Sqitch sees the former. Run `sqitch engine
+ update-config` to update your configurations. Existing `core.$engine`
+ configurations will be left in place for compatibility with older
+ versions of Sqitch, but the `sqitch engine` command will not modify
+ them, so they can get out-of-sync. Run `sqitch config --remove-section
+ core.$engine` to remove them.
+ - Formally deprecated the database connection options in favor of target
+ URIs. If any of these options is used, a warning will be issued. They
+ will be dropped in v1.0:
+ * `--db-host`
+ * `--db-port`
+ * `--db-username`
+ * `--db-password`
+ * `--db-name`
+ - Formally deprecated the database connection configuration variables in
+ favor of target URIs. If any of these variables is used, a warning will
+ be issued. Run `sqitch engine update-config` to update your
+ configurations. Existing `core.$engine` configurations will be left in
+ place for compatibility with older versions of Sqitch, but the `sqitch
+ engine` command will not modify them, so they can get out-of-sync. Run
+ `sqitch config --remove-section core.$engine` to remove them. Sqitch
+ will cease to support them in v1.0:
+ * `core.$engine.host`
+ * `core.$engine.port`
+ * `core.$engine.username`
+ * `core.$engine.password`
+ * `core.$engine.db_name`
+ - Deprecated the `--registry` and `--client` options of the `target`
+ command. All target properties should now be set via the new `--set`
+ option, such as `--set registry=reg`.
+ - Formally deprecated the following options of the `add` command. They
+ have been replaced with the `--with`, `--without`, and `--use` options
+ since v0.991. Their use will emit a warning, and they will be removed
+ in v1.0:
+ * `--deploy-template`
+ * `--revert-template`
+ * `--verify-template`
+ * `--deploy`
+ * `--no-deploy`
+ * `--revert`
+ * `--no-revert`
+ * `--verify`
+ * `--no-verify`
+ - Dropped support for the long-deprecated (and likely never used outside
+ ancient tests long deleted) engine configuration variables
+ `core.sqlite.sqitch_db` and `core.pg.sqitch_schema`. Both have been
+ replaced with `engine.$engine.registry`, which applies to all engines.
+ - Formally deprecated the `@FIRST` and `@LAST` symbolic tags. Their use
+ will trigger a warning to use `@ROOT` and `@HEAD`, instead. They will
+ be removed in v1.0.
+ [Internals]
+ - Moved target and engine configuration from App::Sqitch and
+ App::Sqitch::Engine to a new class, App::Sqitch::Target. This class is
+ solely responsible for finding the appropriate values for attributes on
+ every run. The target knows what plan and engine to use, based on those
+ properties. App::Sqitch is now responsible solely for encapsulating
+ command-line options, configuration, and utilities. Classes are now
+ responsible for instantiating both an App::Sqitch and
+ App::Sqitch::Target options as appropriate.
+ - Updated all classes to create both Sqitch and Target objects as
+ appropriate. This change touched almost every class.
+ - Replaced attributes in App::Sqitch that were previously set from
+ command-line options or configuration with a single attribute,
+ `options`, which is a hash only of the command-line options. Classes
+ are now responsible for finding the proper values in config or options.
+ Mostly this requirement is encapsulated by the new App::Sqitch::Target
+ class.
+ - Updated the command classes to use either a "default target" derived
+ from command-line options, engine configuration, and core
+ configuration, or a target looked up by name in the configuration
+ maintained by the `target` command.
+
+0.996 2014-09-05T21:11:00Z
+ - Fixed one more test failure due to the introduction of "Negative repeat
+ count does nothing" warning in Perl 5.21.1.
+ - Fixed "Redundant argument in printf" warning on Perl 5.21.2.
+ - Switched from Digest::SHA1, which is deprecated, to Digest::SHA for
+ generating SHA-1 IDs.
+ - Switched from Mouse and Moose to Moo. Sqitch no longer depends on any
+ modules that use Moose, either. This results in an approximately 40%
+ startup time speedup.
+ - Loading of App::Sqitch::DateTime is now deferred until it's needed.
+ This is because DateTime is rather expensive to load. Since a number of
+ commands don't need it, it seems silly to load it in those cases.
+ - Now recommend Type::Tiny::XS and Class::XSAccessor for improved
+ performance.
+ - The `check` command now properly fails on a plan parse error, instead
+ of blindly continuing on.
+ - Fixed a failing test on PostgreSQL due to localization issues. Thanks
+ to Sven Schoberf for the report (Issue #171).
+ - Added the `revert.prompt_accept`, `rebase.prompt_accept`, and
+ `checkout.prompt_accept` boolean configuration variables. Set these
+ variables to false to change the default answer to the revert prompt to
+ "No". When rebasing or checking out, if the variables specific to those
+ commands are not set, Sqitch will fall back on the value of
+ `revert.prompt_accept`. Suggested by Graeme Lawton (Issue #164).
+ - The MySQL engine now sets the `$MYSQL_PWD` environment variable if a
+ password is provided in a target. This should simplify authentication
+ when running MySQL change scripts through the `mysql` client client
+ (Issue #150).
+ - The MySQL engine now reads `client` and `mysql` groups in the MySQL
+ configuration files for a password when connecting to the registry
+ database, and when the target URI includes no password. The MySQL
+ client already read those files, of course, but now the internal
+ database connection does as well (Issue #150).
+ - The Firebird engine now sets the `$ISC_PASSWORD` environment variable
+ if a password is provided in a target. This should simplify
+ authentication when running Firebird change scripts through the `isql`
+ client client. Patch from Ștefan Suciu.
+ - No longer passing URI query params as DBI params, because they are
+ already included in the DSN provided by URI::db.
+ - Added the Vertica engine.
+
+0.995 2014-07-13T22:24:53Z
+ - Fixed test failures due to the introduction of "Negative repeat count
+ does nothing" warning in Perl 5.21.1.
+ - Fixed more test failures when DBD::Firebird is installed but Firebird
+ isql cannot be found.
+ - Fixed registry file naming issues on Win32 for the SQLite engine, and
+ as well as the tests that failed because of it.
+ - Worked around Config::GitLike bug on Windows in the target test.
+ - Changed the exit value for an attempt to deploy to an up-to-date
+ database from 1 to 0. In other words, it no longer looks like an error
+ (Issue #147).
+
+0.994 2014-06-20T02:58:10Z
+ - Fixed installation failure due to missing IO::File module on Windows.
+ - Fixed file test failure for the Oracle engine on Windows.
+ - Fixed bug where namespace-autoclean: 0.16 caused errors such as
+ "Invalid object instance: 'yellow'".
+ - Fixed Oracle SQL*Plus capture test failure on Windows.
+
+0.993 2014-06-04T20:14:34Z
+ - Fixed engine loading to prefer the engine implied by the target URI
+ over the `core.engine` configuration variable. This means that you no
+ longer have to pass `--engine` when using commands that accept a target
+ option or argument, such as `deploy`.
+ - Fixed test failure when DBD::Firebird is installed but Firebird isql
+ cannot be found.
+ - Fixed issue where the revert command fails to execute the proper revert
+ script. This can occur when a change has been reworked in the plan, but
+ the reworked version of the change has not been deployed to the
+ database. Thanks to Timothy Procter for the report (Issue #166).
+ - Fixed issue with aggregating text values with `COLLECT()` on Oracle.
+ Thanks to Timothy Procter for the digging and invocation of an Oracle
+ support request (Issue #91).
+ - Fixed issue where SQL*Plus could not run rework scripts because of the
+ `@` in the file name. It now uses a symlink (or copied file on Windows)
+ to circumvent the problem. Thanks to Timothy Procter for the report
+ (Issue #165).
+ - Fix issue where, on first deploy, the MySQL engine would fail to notice
+ that the server was not the right version of MySQL. Thanks to Luke
+ Young for the report (Issue #158).
+ - Made the `checkit()` MySQL function DETERMINISTIC, to improve
+ compatibility with MariaDB. Thanks to Jesse Luehrs for the report
+ (Issue #158).
+ - Fixed deployment to PostgreSQL 8.4 so that it no longer chokes on the
+ `:tableopts`. Thanks to Justin Hawkins for the report!
+
+0.992 2014-03-05T00:34:49Z
+ - Fixed target test failures on Windows.
+ - Added support for Postgres-XC to the PostgreSQL engine. Sqitch registry
+ tables are distributed by replication to all data nodes.
+ - Added support to MariaDB 5.3 and higher to the MySQL engine, thanks to
+ Ed Silva.
+
+0.991 2014-01-16T23:24:33Z
+ - Greatly simplified determining the Firebird ISQL client. It no longer
+ tries so hard to find a full path, but does search through the path list
+ for a likely candidate between fbsql, isql-fb, and isql (or equivalents
+ ending in .exe on Windows).
+ - Removed a bunch of inappropriately pasted stuff from the Firebird
+ tutorial, and updated it a bit.
+ - `HEAD` and `ROOT` are now recognized as aliases for `@HEAD` and
+ `@ROOT`, when querying the database, too. That means that `revert --to
+ HEAD` now works the same as `revert --to @HEAD`, as had been expected
+ in v0.990.
+ - Eliminated "use of uninitialized value" warnings when database
+ connections fail.
+ - Reduced the minimum required DBD::Firebird to v1.11.
+ - Fixed the `--verbose` option to the `target` command.
+ - Eliminated more user-configuration issues in tests, thanks to
+ chromatic.
+ - Fixed test failures when the `$PGPASSWORD` environment variable is set,
+ thanks to Ioan Rogers's test smoker.
+
+0.990 2014-01-04T01:14:24Z
+ [New Features]
+ - Added new command and feature: `target`. Use it to manage multiple
+ database targets, each with an associated URI and, optionally, a
+ registry name and command-line client. Inspired by Git remotes.
+ - Added Firebird engine. Three cheers to Ștefan Suciu for this
+ contribution!
+ - Added support for the generation of arbitrary scripts from templates to
+ the `add` command. Just add template files to subdirectories of the
+ `templates` directory, and scripts will be created in a directory of
+ the same name based on those templates.
+ - Added `--open-editor` option (and aliases) to the `add` and `rework`
+ commands. This option will open the newly-added change scripts in the
+ preferred editor. Thanks to Thomas Sibley for the patch!
+
+ [Improvements]
+ - Improved database driver loading to ensure the proper version of the
+ driver is required.
+ - Non-fatal but possibly unexpected messages -- which correspond to exit
+ value 1 -- now send their messages to STDOUT instead of STDERR, and
+ respect the `--quiet` option. Thanks to @giorgio-v for the report!
+ - Added or replaced the `--target` option to commands that connect to a
+ database to specify the name of target managed by the new `target`
+ command or a database URI.
+ - `HEAD` and `ROOT` are now recognized as aliases for `@HEAD` and
+ `@ROOT`, respectively, since they are disallowed as change names,
+ anyway, and folks often use them out of habit from Git.
+
+ [Internals]
+ - Replaced the engine-specific connection attributes with three
+ attributes use by every engine:
+ * `target`: The name of a target managed by the new `target` command.
+ Defaults to a value stored for the `core.$engine.target`
+ configuration variable. If that variable does not exist, the target
+ falls back on the stringification of `uri`.
+ * `uri`: a database URI with the format `db:{engine}:{dbname}` or
+ `db:{engine}://{user}:{password}@{host}:{port}/{dbname}`. If its
+ value is not passed to the constructor, a `uri` value is looked up
+ for the associated `target`. If `target` is not passed or configured,
+ or if it has no URI associated with it, the `config.$engine.uri`
+ configuration variable is used. If that value does not exist, the URI
+ defaults to `db:$engine:`. In any of these cases, if any of the
+ `--db-*` options are passed, they will be merged into the URI.
+ * `registry`: the name to use for the Sqitch registry schema or
+ database, where Sqitch's own data will be stored, as appropriate to
+ each engine. If its value is not passed to the constructor, a
+ `registry` value is looked up for the associated `target`. If
+ `target` is not passed or configured, or if it has no registry
+ associated with it, the `config.$engine.registry` configuration
+ variable is used. If no value is found there, it defaults to an
+ engine-specific value, usually "sqitch".
+
+ [Bug Fixes]
+ - Fixed a bug when installing under local::lib. Thanks to Thomas Sibley
+ for the pull request!
+ - Eliminated "Wide character in print" warnings when piping the `log`
+ command.
+ - Documented that reworked changes do not have their verify tests run by
+ the `verify` command. They do run when using the `--verify` deploy
+ option.
+ - Removed the documentation for the `add.with_deploy`, `add.with_revert`,
+ and `add.with_verify` configuration variables, which were never
+ implemented.
+
+ [Deprecations]
+ - Deprecated engine-specific connection attributes and configuration
+ variables. See the "Internals" section for their replacements. The
+ deprecated options are:
+ * `core.$engine.username`
+ * `core.$engine.password`
+ * `core.$engine.db_name`
+ * `core.$engine.host`
+ * `core.$engine.port`
+ * `core.$engine.sqitch_schema`
+ * `core.$engine.sqitch_db`
+ - Deprecated all command-specific options with the string "target" in
+ them, such as `--to-target`, `--upto-target`, etc. They have been
+ replaced with options containing the string "change", instead, such as
+ `--to-change` and `--upto-change`. Few people used these options,
+ preferring their shorter aliases (`--to`, `--upto`, etc.).
+ - Deprecated the `--deploy-template`, `--revert-template`, and
+ `--verify-template` options to the `add` command. They are replaced
+ with a single option, `--use` which takes a key/value pair for the
+ script name and template path. This makes it useful for arbitrary
+ script generation, as well.
+ - Deprecated the `--deploy`, `--revert`, and `--verify` options to the
+ `add` command, as well as their `--no-*` variants. They are replaced
+ with two new options, `--with` and `--without`, to which a script name
+ is passed. These are useful for arbitrary script generation, as well.
+ - Deprecated the `add.deploy_template`, `add.revert_template`, and
+ `add.verify_template` configuration settings. They have been replaced
+ with a section, `add.templates`, which is more general, and supports
+ arbitrary script generation, as well.
+
+ [Incompatibilities]
+ - Removed the undocumented `--test` option to the `add` command.
+ - Changed the meaning of `--target` from specifying a change to
+ specifying a deployment target. Use the new `--change` option to
+ specify a change.
+
+0.983 2013-11-21T21:50:12Z
+ - Fixed "Use of uninitialized value" in the MySQL engine. Thanks to
+ Jean-Michel REY for the report.
+ - All tests now protect against failures due to the presence of the
+ `$SQITCH_CONFIG` environment variable (issue #114).
+ - The installer now respects the `distdir` option to `Build.PL` when
+ searching for existing templates. Important for packaging.
+ - Fixed the error "Table 'sqitch.changes' doesn't exist" when deploying
+ to a MySQL database that exists but has not been initialized. Thanks to
+ Jean-Michel REY for the report!
+ - Refactored the handling of the C<--log-only> option so it sets an
+ engine attribute, rather than passing the flag to a whole stack of
+ method calls.
+ - Fixed "Argument "en_us" isn't numeric" error on Windows.
+ - Now using `LC_ALL` instead of `LC_MESSAGES` when setting the locale, as
+ the latter is not present on Windows.
+ - The sqitch-pg RPM now requires DBD::Pg 2.0.0 or higher.
+ - Improved handling of invalid command names so that the error message is
+ less ambiguous when triggered by a Perl parse error.
+ - Added `-m` as an alias for `--note`, for you Git folks out there.
+ - Added exception handling to the Postgres and Oracle engines to avoid
+ unexpected errors when deploying to a database that has not been
+ deployed to before.
+ - Updated detection of an uninitialized database to double-check with the
+ engine that it really thinks it's uninitialized, not just that the
+ "changes" table is missing. This should catch the case where the
+ database has its own "changes" table unrelated to Sqitch.
+
+0.982 2013-09-11T18:26:07Z
+ - Errors thrown by Template toolkit are no longer silently ignored.
+ - Variables passed to change templates are now cloned before the
+ execution of each template. This prevents one template from deleting
+ variable values another template might also need.
+ - Fixed "The getpwnam function is unimplemented" errors on Win32.
+ - No longer runs revert scripts when deploying with `--log-only` and a
+ verify script fails, as that could lead to data loss (yikes!). Thanks
+ to BryLo for the report (issue #112).
+
+0.981 2013-09-06T00:22:26Z
+ - Now use Encode::Locale to try to decode the user's full name from the
+ system encoding when fetched from the system on all OSes. Note that
+ this is not necessary if the `user.name` config is explicitly set, as
+ recommended. Issue #107.
+ - Removed the special-case handling of the user's full name fetched from
+ the system on OS X.
+ - Added call to `sleep` to test in an attempt to fix SQLite failures.
+ - The SQLite engine now requires that the SQLite client be 3.3.9 or
+ later, for support of the `-bail` option.
+ - Bug fix: The MySQL engine now properly uses the host, port, and
+ password options when connecting to the database. Thanks to vreb87 for
+ the report!
+
+0.980 2013-08-28T21:40:00Z
+ - Changed the default SQLite Sqitch database name from
+ `$dbname-sqitch.$suffix` to `sqitch.$suffix`. The `$suffix` still
+ comes from the destination database name. This breaks compatibility
+ with previous releases. If you need the old name, set it with
+ `sqitch config core.sqlite.sqitch_db $dbname`.
+ - Fixed encoding of the user's full name when fetched from the system on
+ OS X. Thanks to Tomohiro Hosaka for the pull request!
+ - Fixed test failures when DBD::SQLite is installed but compiled with
+ SQLite 3.7.10 or lower.
+ - Fixed a bug where declaring a dependency on a reworked change would
+ incorrectly result in the error "Key "foo" matches multiple changes".
+ Thanks to BryLo for the report (issue #103).
+ - Modified tests to allow them to run in parallel without stomping on
+ each other.
+ - Bundling of options, such as `-vvv`, now works properly (issue #108).
+ - Added alias `--get-regexp` for `--get-regex` to the `config` command.
+ This brings it in line with the documentation for the `config` command
+ (Issue #110).
+ - Fixed all of the `config` command actions that contain a dash so that
+ they actually work. Thanks to Ștefan Suciu for the report (issue #110).
+ - All leading and trailing white space is now trimmed from plan notes,
+ rather than just vertical white space. Thanks to Ronan Dunklau for the
+ report (issue #106).
+ - The `status` command now notices if the specified database is
+ uninitialized and says as much, rather than dying with an SQL error
+ (issue #109).
+ - When reading the user's username from the system Sqitch now uses
+ Encode::Locale to try to decode the value from the system encoding.
+ Issue #107.
+ - Compatibility change: Changed the location and name of script template
+ files. Previously they were called `deploy.tmpl`, `revert.tmpl`, and
+ `verify.tmpl`, and they lived in the `templates` subdirectory of the
+ system-wide and user-specific configuration directories. They now live
+ in subdirectories of the `templates` directory named for each action
+ (deploy, revert, and verify), and with file names matching engine names
+ (`pg.tmpl`, `sqlite.tmpl`, `oracle.tmpl`, and `mysql.tmpl`). The
+ installer will move old files from the system-wide config directory
+ (`sqitch --etc-path`) to their new homes, named `pg.tmpl` and
+ `sqlite.tmpl`. It assumes no customizations exist for Oracle. If that's
+ not true in your case, simply copy the `pg.tmpl` files to
+ `oracle.tmpl`.
+ - Added the `--template-name` option to the `add` command. By default, it
+ looks for templates named for the current engine. The option allows for
+ the user of task-specific templates. For example, if you create
+ templates named `createtable.tmpl` in the `deploy`, `revert`, and
+ `verify` subdirectories of `~/.sqitch/templates`, You can specify
+ `--template-name createtable` to use those templates when adding a
+ change.
+ - Added the `--exists` option to the `show` command.
+ - Fixed the `--set` option to the `add` command so that duplicate keys
+ have their values passed to the template as an array, as documented.
+ - If Template::Toolkit is installed, the `add` command will use it for
+ processing templates instead of Template::Tiny. This makes it easy to
+ upgrade the templating environment just by installing a module.
+
+0.973 2013-07-03T13:47:22Z
+ - Now Require DBD::SQLite compiled with SQLite 3.7.11 or higher. It
+ always has, but now it throws a meaningful exception if an older
+ version is compiled into DBD::SQLite. Thanks to Damon Buckwalter for
+ the report.
+ - When a deploy fails because of missing dependencies, the list of
+ missing dependencies no longer contains duplicates. Thanks to Damon
+ Buckwalter for the report.
+
+0.972 2013-05-31T23:26:52Z
+ - Fixed test failures on Windows.
+ - Fixed locale configuration on Windows so that `sqitch` will actually
+ run, rather than exiting with an error about `LC_MESSAGES` not being
+ set.
+ - Fixed a test hang on Windows when DBD::Oracle is installed but the
+ Oracle libraries (`OCI.dll`) are not or cannot be found. This was
+ triggering a UI dialog that did not dismiss itself. Using Win32::API
+ to work around this issue. Thanks to Jan Dubois for the fix.
+
+0.971 2013-05-18T21:08:51Z
+ - Removed most uses of the smartmatch operator, since as of Perl 5.17.11
+ it is marked as experimental, and silenced the warning where it is
+ still used.
+ - Added 0.1s sleep between logging changes back-to-back in the engine
+ tests, mostly to try to get SQLite to generate different timestamps.
+ Pretty sure the recent test failures have been due to the passage of
+ less than a millisecond between the two inserts.
+ - Added the `shell` and `quote_shell` methods to Sqitch.pm for shelling
+ out a command.
+ - Sqitch now shells out to an editor when opening a file for the user to
+ edit. For example, if the `$EDITOR` environment variable is set to
+ `"emacs -nw"`, it will now work. Thanks to Florian Ragwitz for the
+ report (issue #80).
+ - Removed the pod-checking tests from the distribution.
+
+0.970 2013-05-09T00:21:06Z
+ - Fixed the default ordering of changes displayed by the `plan` command.
+ They are now ascending by default.
+ - Switched to PerlIO::utf8_strict for fast character encoding and
+ decoding.
+ - The help emitted when an unknown option is passed to `sqitch` now
+ consists of a usage statement and brief table of options, rather than
+ the entire man page.
+ - Added the project name in a header to the output of the `plan` command.
+ - Added the Oracle engine.
+ - Added `sqitchtutorial-oracle.pod`, a Oracle-specific variant of
+ `sqitchtutorial.pod`.
+ - Added missing version declaration to the App::Sqitch::Plan::* modules.
+ - Devel::StackTrace 1.30 is now properly required (it was previously
+ recommended).
+ - The `--show-tags` and `--show-changes` options to the `status` command
+ now show the changes when the project plan cannot be found (issue #90).
+
+0.965 2013-04-23T16:25:59Z
+ - Fixed failing test due to line-ending character variations on Windows.
+ Many thanks to Jan Dubois for the testing help.
+ - Replaced all uses of `$/` in output to `"\n"`. Thanks to Jan Dubois for
+ pointing out the incorrect use of `$/`.
+ - Fixed build error that prevented installation on Perl 5.10 when the
+ parent module was not installed.
+
+0.964 2013-04-15T18:47:30Z
+ - Fixed test failures on Perl versions lower than 5.14 when DBD::SQLite
+ or DBD::Pg is not installed.
+ - Removed DBD::SQLite from the list of build dependencies.
+ - Fixed test failures due to encoded (wide-character) warnings on
+ triggered on systems with non-english locales. Thanks to Alexandr
+ Ciornii for the smoke testing that revealed this issue.
+ - Removed overriding of Throwable's `previous_exception` in
+ App::Sqitch::X on Throwable 0.200007 and higher, where it is no longer
+ needed.
+ - Changed test comparing file contents that fails on Windows to do a
+ looser comparison and hopefully fix the test failure.
+
+0.963 2013-04-12T19:11:29Z
+ - Fixed a test failure when Git is in the execution path and the test is
+ not run from a Git checkout.
+ - Added `plan` to `sqitchchanges`, the contents of which are shown when
+ Sqitch is run with no command.
+ - Removed the unique constraint on tag names in the database, as it
+ prevented two projects from having the same tag name. Replaced it with
+ a unique constraint on the project and tag names. Folks with production
+ PostgreSQL installs should run these queries:
+ ALTER TABLE sqitch.tags DROP CONSTRAINT tags_tag_key, ADD UNIQUE(project, tag);
+ COMMENT ON COLUMN sqitch.tags.tag IS 'Project-unique tag name.';
+ - Fixed failing tests when DBD::SQLite is not installed.
+ - Removed dependency on Git::Wrapper. The `checkout` command does things
+ very simply, and we already have tools for running command-line
+ applications. So we just take advantage of that. The code is no more
+ complicated than it was before.
+ - Added the `core.vcs.client` configuration setting. Defaults to `git`
+ (or `git.exe` on Windows).
+
+0.962 2013-04-10T17:10:05Z
+ - Fixed failing test on Perl 5.12 and lower.
+ - Fixed the French translation by re-encoding it in UTF-8 (Ronan
+ Dunklau).
+ - Fixed the loading of the editor with placeholder text to properly
+ encode that text as UTF-8 (Ronan Dunklau).
+
+0.961 2013-04-09T19:21:15Z
+ - Fixed error when running on PostgreSQL 9.0.
+ - Added support for PostgreSQL 8.4.
+ - Fixed the SQLite tests to skip the live tests when `sqlite3` cannot be
+ found.
+ - Fixed the Postgres tests to skip the live tests if `psql` cannot be
+ found or cannot connect to the database.
+ - Fixed the `checkout` test to skip tests that depend on Git and Git is
+ not found in the path.
+ - Fixed test failures on Windows (hopefully).
+ - Made the order of commented configuration variables in the project
+ configuration file deterministic. It will now always be the same order
+ as specified by the engine class. This fixes test failures on Perl
+ 5.17.
+ - Fixed encoding issue that caused test failures on Perl 5.17.
+ - Requiring Devel::StackTrace 1.30, as earlier versions can
+ intermittently suppress errors.
+ - Added hack to `App::Sqitch::X::hurl()` to work around a bug in
+ Throwable that prevents `previous_exception` from being set half the
+ time on v5.17.
+
+0.960 2013-04-05T23:04:35Z
+ - Removed `-CAS` from the shebang line on Perl 5.10.0. This is to
+ eliminate `Too late for "-CAS" option` errors. This means that UTF-8
+ semantics will be suboptimal on Perl 5.10.0. Consider upgrading to 5.12
+ or higher.
+ - Added the `checkout` command. Pass it the name of a VCS branch, and it
+ will compare the plans between that branch and the current branch,
+ revert to the last common change, check out the branch, and then
+ redeploy. This makes it easy to switch between working branches that
+ have different sets of commits. Git-only for now. Idea and code by
+ Ronan Dunklau.
+ - The `rebase` command no longer fails if the database is already
+ reverted, but just makes a note of it and goes on to the deploy.
+ - Added the `plan` command. It's like `log`, but shows a list of changes
+ in the plan, rather than events recorded in the database.
+ - Added `search_changes()` to Plan. Used by the `plan` command.
+ - Added the `--oneline` option to the `log` command.
+ - Allow tagging of an arbitrary change, not just the last change in the
+ plan, by passing a change specification (name, ID, or tag) as the
+ second argument to the `tag` command.
+ - Updated error messages to note that blank characters are not allowed in
+ project, change, or tag names.
+ - Factored most of the engine-specific code into
+ App::Sqitch::Role::DBIEngine. Future DBI-based engines should be able
+ to use this role to handle most of the work.
+ - Factored the live engine tests int `t/lib/DBIEngineTest`. Future
+ DBI-based engines can use this module to do all or most of the live
+ testing.
+ - Added the SQLite engine. The Sqitch metadata is stored in a separate
+ file from a database, by default in the same directory as the database
+ file.
+ - Added `sqitchtutorial-sqlite.pod`, a SQLite-specific variant of
+ `sqitchtutorial.pod`.
+
+0.953 2013-02-21T23:37:57Z
+ - Fixed test failure in `t/engine.t` triggered by a clock tick.
+ - Changed the verify template to end with `ROLLBACK` rather than
+ `COMMIT`. This it to encourage folks to make no lasting changes in
+ verify tests.
+ - Fixed exception triggered on an attempt to revert or rebase `--to` a
+ change that does not exist in the database.
+ - Added recommendation for Pod::Simple to the build process.
+ - Added the `--etcdir` build option to specify the directory in which
+ configuration and template files should be installed. Defaults to the
+ `etc/sqitch` subdirectory of the `--prefix`, `--install_base`, or
+ Perl's prefix.
+ - Added the `--installed_etcdir` build option. This is used to set
+ the location of the system etc directory. Defaults to the value of
+ `--etcdir`.
+ - When building with `--prefix` or `--install_base`, and without
+ `--etcdir`, the configuration files and tmeplates are now installed
+ into `etc/sqitch` in that directory, rather than just `etc`. This is to
+ enable packaging systems to move the directory to the proper location.
+
+0.952 2013-01-12T00:02:54Z
+ - Switched from Moose to Mouse whever possible. Speeds load and runtime
+ 20-30%. Thanks to Michael Schwern for the pull request!
+
+0.951 2013-01-08T00:21:58Z
+ - Fixed double "@" displayed for tags in the output of `revert`.
+ - Fixed reversion of reworked changes to run the original revert script,
+ rather than the reworked script.
+ - Added `is_reworked` accessor to App::Sqitch::Plan::Change.
+ - Changed the behavior determining the file name to use for reworked
+ change scripts. It now looks for a deploy script using the name of any
+ tag between the reworked instances of a change and selects the first
+ one it finds that exists. This will allow Sqitch to find the proper
+ script name even if new tags have been added to the plan (issue #70).
+
+0.950 2013-01-03T23:09:42Z
+ - Fixed the "Name" header in `sqitch-rebase` so that it will actually
+ show up on the CPAN search sites.
+ - Fixed test failure triggered by the passage of time in `t/engine.t`.
+ - At the start of a `deploy`, if the most recently deployed change has
+ any unlogged tags (that is, tags added since the last `deploy`), they
+ will be logged before the `deploy` continues (issue #60).
+ - Added the `--no-log` option to `deploy`, `revert`, and `rebase`. This
+ causes the changes to be logged as deployed without actually running
+ the deploy scripts. Useful for an existing database that is being
+ converted to Sqitch, and you need to log changes as deployed because
+ they have been deployed by other means in the past.
+ - Now check that dependencies are required for all changes to be deployed
+ or reverted before deploying or reverting anything, rather than
+ checking dependencies for each change just before deploying or reverting
+ it. This allows a or revert deploy to fail sooner, with no database
+ changes, when dependencies are not met.
+ - The `deploy` command now checks that no changes its about to deploy are
+ already deployed.
+ - Added `--mode` to the `rebase` command.
+ - Added the `--verify` option to `deploy` and `rebase`. Specify this
+ option to run the verify script, if it exists, for each change after it
+ is deployed. If the verify script dies, the deploy will be considered a
+ failure and the requisite reversion (as specified for `--mode`) will
+ begin.
+ - Added the `verify` command, which verifies that a database is valid
+ relative to the plan and each deployed change's verification scripts.
+ - Changed the format of the list of changes output by `deploy` and
+ `revert` so that each now gets "ok" or "not ok" printed on success or
+ failure.
+ - Added short aliases for commonly-used core options:
+ * -f for --plan-file
+ * -v for --verbose
+ * -h for --db-host
+ * -p for --db-port
+
+0.940 2012-12-04T05:49:45Z
+ - Fixed tests that failed due to I18N issues, with thanks to Arnaud
+ (Arhuman) ASSAD!
+ - Localized messages are now properly encoded in UTF-8. Thanks to Ronan
+ Dunklau for the report (issue #46) and to Guido Flohr for details on
+ how to address the issue.
+ - The variables defined for the `add`, `deploy`, and `revert` commands
+ now have the case of there names preserved if Config::GitLike 1.10 or
+ later is installed. Thanks to Ronan Dunklau for the report (issue #48)
+ and to Alex Vandiver for the case-preserving update to Config::GitLike.
+ - Attempting to run `sqitch` with no command now outputs the list of
+ supported commands (`sqitchcommands`), rather than the list of core
+ options. Thanks to BryLo for the suggestion.
+ - Changed the plan parser so that it no longer changes the order of
+ changes based on the dependency graph. Unfortunately, this meant that
+ the order could change from one run to another, especially if new
+ changes were added since the last deploy. The planner now throws an
+ exception if the order in the plan is wrong, and suggests that the user
+ move changes in the plan file to get it to work properly.
+ - Fixed bug where the `core.plan_file` configuration variable was
+ ignored.
+ - Improved error handling when deploying and reverting a change. If the
+ change successfully deployed but the logging of the deployment to the
+ database failed, there was just a rollback message. Sqitch will now
+ emit the underlying error *and* run the revert script for the
+ just-deployed change.
+ - Modified the text hashed for change and tag IDs. Both now include the
+ note, if present, the ID of the preceding change, and the list of
+ dependencies. The result is that, when a change is modified or moved in
+ the plan, it gets a new ID ID. The upshot is that things *must* be in
+ order for a deploy to succeed. Existing deployments will automatically
+ have their IDs updated by the `deploy` command.
+ - Changed the `revert` command so that it *only* fetches information about
+ changes to be reverted from the database, rather than the plan.
+ - Deprecated the `@LAST` and `@FIRST` symbolic tags. With `revert` now
+ fetching change information from the database, there is no longer a
+ need to specify that changes be found in the database. It's possible
+ some other way to search database changes will be added in the future,
+ but if so, it will be less limiting than `@LAST` and `@FIRST`, because
+ it will likely allow searches by literal tags.
+ - Added the `rebase` command. This command combines a `revert` and a
+ `deploy` into a single command, which should allow for more natural
+ deployment testing during development. `sqitch rebase @HEAD^` should
+ become a common command for database developers.
+ - Duplicate values passed via `--requires` and `--conflicts` in the `add`
+ and `rework` actions are now ignored.
+ - The `add` command now throws an exception if `--template-directory` is
+ passed or specified in the configuration file, and the specified
+ directory does not exist or is not a directory. Thanks to Ronan Dunklau
+ for the report! (Issue #52).
+ - The `revert` command now prompts for confirmation before reverting
+ anything. The prompt can be skipped via the `-y` option or setting the
+ `revert.no_prompt` configuration variable. Works for rebase, too, which
+ reads `rebase.no_prompt` before `revert.no_prompt`.' (Issue #49.)
+ - Added the `show` command, which show information about changes or tags,
+ or the contents of change script files. (Issue #57.)
+ - Renamed the `test` scripts and planned command to `verify`.
+
+0.938 2012-10-12T19:16:57Z
+ - Added a primary key to the PostgreSQL `events` table, which should make
+ it easier to support replication.
+
+0.937 2012-10-09T21:54:36Z
+ - Fixed the `--to` option to `deploy` and `revert`, which was ignored
+ starting in v0.936.
+
+0.936 2012-10-09T19:11:5Z2
+ - Added `--set` option to the `deploy` and `revert` commands. Useful for
+ setting database client variables for use in scripts. Used by the
+ PostgreSQL engine.
+ - Merged the contents of `dist/sqitch-pg.spec` into a subpackage in
+ `sqitch.spec`. This allows both RPMs are created from a single build
+ process. Simplifies things quite a bit and improves the flexibility for
+ adding other engines in the future.
+ - Reduced required Perl version from 5.10.1 to 5.10.0.
+ - Fixed inconsistent handling of command options with dashes where some
+ were ignored.
+ - The bundle command now properly copies scripts for changes with slashes
+ in their names -- that is, where the scripts are in subdirectories.
+
+0.935 2012-10-02T19:21:05Z
+ - Updated `dist/sqitch-pg.spec` to require `postgresql` rather than
+ "postgresql91". The version doesn't matter so much.
+ - All known Windows issues and failures fixed, with many thanks to Randy
+ Stauner for repeatedly running tests and supplying patches:
+ - Fixed "'2' is not recognized as an internal or external command,
+ operable program or batch file" error on Windows.
+ - Fixed multiple errors detecting Windows. The OS name is "MSWin32",
+ not "Win32". The test failure thus addressed was the setting of the
+ DateTime locale.
+ - Fixed failing tests that were incorrectly comparing subprocess errors
+ messages on Windows
+ - Fixed bug in `bundle` where a file would be re-copied even if the
+ source and destination had the same timestamps, as they seem to do
+ during tests on Windows. Patch from Randy Stauner.
+ - Fixed failing test that failed to include `.exe` in a file name on
+ Windows. Patch from Randy Stauner.
+ - Added French translation, with thanks to Arnaud (Arhuman) ASSAD!
+
+0.934 2012-09-28T16:43:43Z
+ - Fixed typo in error handling that prevented an I/O error message from
+ being properly emitted.
+
+0.933 2012-09-27T18:04:53Z
+ - The `init` command no longer fails if `--top-dir` does not exist. It
+ creates it.
+ - Yet another attempt to fix "List form of pipe open not implemented" bug
+ on Windows.
+
+0.932 2012-09-26T21:32:48Z
+ - One more attempt to fix "List form of pipe open not implemented" bug on
+ Windows.
+
+0.931 2012-09-25T19:09:14Z
+ - Now properly require Text::LocaleDomain 1.20.
+ - Stubbed out French and German localization files. Translators wanted!
+ - Added LocaleTextDomain dzil support (no impact on distribution).
+ - Fix "List form of pipe open not implemented" bug on Windows by using
+ Win32::ShellQuote to quote commands.
+
+0.93 2012-08-31T22:29:41Z
+ - Added forward and reverse change references. Append ^ to a change
+ reference to mean the change before, or ~ to mean the change following.
+ Use ~~ and ^^ to select two changes forward and back, and ~n and ^n,
+ where n is an integer, to select that number of changes forward or
+ back. Idea stolen from Git, though the meanings of the characters are
+ different.
+ - Added the @FIRST and @LAST symbolic references to refer to the first
+ and last changes deployed to the database, respectively. These vary
+ from the existing @ROOT and @HEAD symbolic references, which refer to
+ the first and last changes listed in the plan.
+ - Updated the tutorial to use the new symbolic references and ^ and ~
+ qualifiers where appropriate.
+ - The messages output by the `deploy` and `revert` commands now show the
+ resolved name of the `--to` target, rather than the value passed to
+ `--to`. This is most useful when using a symbolic reference, so you
+ can see what you're actually deploying or reverting to.
+
+0.922 2012-08-30T17:41:59Z
+ - Loosened constraint to disallow only `/[~^/=%]/` before digits at the
+ end of name. This allows, for example, a tag to be named "v1.2-1".
+ - Added the `bundle` command to the documentation displayed by `sqitch
+ help`.
+ - Updated the mention of the `bundle` command in the main `sqitch`
+ documentation.
+
+0.921 2012-08-30T00:09:56Z
+ - Made Win32::Locale required only on Windows.
+ - Fixed some module minimum version requirements so that dependencies
+ will be properly listed in `Build.PL`.
+
+0.92 2012-08-28T23:14:37Z
+ - Added the `bundle` command.
+ - Attempts to deploy a project with a different name or URI than
+ previously registered now throws an exception.
+ - Added UNIQUE constraint to `projects.uri` in the PostgreSQL Sqitch
+ schema.
+ - Added ON UPDATE actions to foreign key constraints in the PostgreSQL
+ Sqitch schema.
+
+0.913 2012-08-28T17:31:29Z
+ - Fixed oversight in test that still relied on `$ENV{USER}` instead of
+ `Sqitch->sysuser`,
+
+0.912 2012-08-27T21:23:19Z
+ - Fall back on `Sqitch->sysuser` when looking for the PostgreSQL user,
+ rather than just `$ENV{USER}`. The method does a lot more work to find
+ the system user name. This will hopefully also fix test failures on
+ systems where `$ENV{USER}` is not set.
+ - Use Win32::Locale to set the locale on DateTime objects on Windows.
+
+0.911 2012-08-23T19:19:17Z
+ - Fixed more platform-specific test failures in `t/base.t`.
+ - Increased liklihood of finding a user's full name on Windows. Thanks to
+ H. Merijn Brand for testing.
+
+0.91 2012-08-23T00:37:36Z
+ - Moved `requires` and `conflicts` array columns from the `changes` table
+ to an new table, `dependencies`, where there is just one per row.
+ - Requirements are now checked before reverting a change. If the change
+ is depended on by other changes, it will not be reverted (Issue #36).
+ - Fixed bug where the `status` command would show changes and/or tags
+ from other projects when `--show-tags` or `--show-changes` were used.
+ - Fixed test failures on Windows.
+ - Added more ways to look up the current username to minimize the chances
+ that none is found.
+ - Added Windows-specific way of finding the current user's full name,
+ since the existing approach died on Windows.
+ - Windows-specific modules are no longer required, but are recommended on
+ Windows. They will be listed by `./Build` and added to the "recommends"
+ section of the the generated `MYMETA.*` files on Windows.
+ - Fixed a bug where dependencies on other projects would be rejected
+ in calls to `add` and `rework`.
+
+0.902 2012-08-20T21:14:08Z
+ - Fixed another occasional test failure due to a clock tick in `t/pg.t.`
+ - Fixed test failures in `t/status.t` on systems without DBD::Pg.
+
+0.901 2012-08-20T19:31:03Z
+ - Fix test failure in `t/status.t` caused by failing to ignore a
+ pre-existing `~/.sqitch/sqitch.conf` configuration file.
+ - Eliminated "Use of uninitialized value in length" warnings.
+
+0.90 2012-08-18T00:05:41Z
+ - Added `dist/sqitch.spec`. This file was created to generate an RPM for
+ CentOS 6.1.
+ - Added `dist/sqitch-pg.spec` to use for creating RPMs for Sqitch with
+ PostgreSQL support.
+ - Fixed an occasional test failure due to a clock tick in `t/pg.t.`
+ - Switched to Dist::Zilla for creating the distribution. For end-users,
+ this just means that `Build.PL` is now a generated file.
+ - Required module versions are now declared in code. This is so that they
+ are enforced at runtime, and also so that they will be picked up by
+ Dist::Zilla for inclusion in the generated `Build.PL` and `META` files.x
+ - Added support for declaring dependencies (required and conflicting
+ changes) from other Sqitch projects. This allows one project to depend
+ on changes from another. The syntax is `--requires $projname:$change`.
+ This use of the colon required a few changes to the Plan syntax:
+ + Pragmas may now appear only in the first "header" section of the
+ plan, separated from the changes in the "body" of the plan by a blank
+ line.
+ + Required dependencies no longer begin with ":". Conflicts still must
+ begin with "!".
+ + Object names may no longer contain ":", as it is used for project
+ specification.
+ + Project-qualified dependencies are supported by the project name
+ appearing before the change name, separated by a colon.
+ - Added App::Sqitch::Plan::Depend, an object to parse, represent, and
+ serialize dependencies.
+ - The plan parser does not validate changes required from other projects,
+ as it has no access to the plans from those projects.
+ - The engine interface validates cross-project dependencies before
+ deploying changes.
+ - Project data is not included in the Sqitch metadata tables in the
+ database. There is a table for all known projects, as well as foreign
+ key references in the `changes`, `tags`, and `events` tables.
+ - Project information is now displayed in the output of `sqitch status`
+ and `sqitch log` (in some formats).
+ - Added `--project` option to `sqitch status` to identify the project for
+ which to display the status. Defaults to the current project, if there
+ is one, or to the project in the database, if there is only one
+ registered project.
+ - Added `--project` option to `sqitch log` to allow searching for events
+ from projects matching a regular expression.
+ - Now require Config::GitLike 1.09 for its improved character encoding
+ support.
+ - Dependencies can now be declared as SHA1 hash IDs, including for IDs
+ from other projects.
+ - Fixed change and tag name validation to count "_" as a non-punctuation
+ character, and therefore able to be used at the beginning or end of
+ names.
+ - Replaced the `appuser` change in `sqitchtutorial` with `appschema`.
+ This simplifies things, since users are global objects in PostgreSQL,
+ while schemas are not. As a result, a bunch of irrelevant code was
+ removed from the tutorial.
+
+0.82 2012-08-03T21:25:27Z
+ - Now require Moose 2.0300, since MooseX::Role::Parameterized, which
+ requires Role::HasMessage, requires it, anyway,
+ - Fixed test failure in `t/pg.t` when running on Test::More 0.94.
+ - Require POSIX in `t/datetime.t` to fix test failure with CentOS 6
+ Perl. Not sure why it did not fail anywhere else, but it's harmless
+ enough to make sure it's loaded early.
+
+0.81 2012-08-03T11:34:46Z
+ - Removed wayward `/l` from a regular expression, which breaks Perls
+ earlier than 5.14, and is not needed anyway.
+ - Fixed error in `log` that caused invalid output on Perls earlier than
+ 5.14. Seems that `return` is required for `when` statements meant to
+ return a value, and postfix `when` is not supported in Perl 5.10.
+
+0.80 2012-08-01T21:54:00Z
+ - Added the `log` command to `sqitchcommands.pod`, which is shown as the
+ output of `sqitch help`.
+ - Added `user.name` and `user.email` configuration variables.
+ - Now using `user.name` and `user.email`, rather than the system or
+ database user name, to log the user committing changes to a database.
+ - Database-specific options are now prefixed with `--db-`.
+ - Added "raw" format to App::Sqitch::DateTime. It is ISO-8601 format in
+ UTC.
+ - Modified the "raw" log format to use the raw DateTime format.
+ - Added timestamp and planner info to the plan. This is additional
+ metadata included in every change and tag: The planner's name and email
+ address and the current timestamp. This makes it easier to audit who
+ added changes to a plan and when.
+ - Added the `--note` option to the `add`, `rework`, and `tag` commands.
+ - For consistency throughout, renamed all attributes and options from
+ "message" and "comment" to "note", which is shorter and better reflects
+ their purpose.
+ - The planner's name and email address, as well as the plan time and
+ note, are now stored in the database whenever changes or tags are
+ committed and logged.
+ - Renamed various database columns to be more consistent, with the terms
+ "commit", "plan", and "note".
+ - Added `requires` and `conflicts` columns to the events table, so that
+ they can become available to the `log` command.
+ - Various `log` format changes:
+ * Renamed %n (newline) to %v (vertical space)
+ * Renamed %c to %n (change name)
+ * Replaced %a (committer name) with %c (committer info). It takes an
+ optional argument:
+ + "name" or "n" for committer name
+ + "email" or "e" for committer email
+ + "d" or "date" for commit date
+ + "d:$format" or "date:$format" for formatted commit date
+ * Added %p (planner info). It takes an optional argument just like
+ "%c" does:
+ + "name" or "n" for planner name
+ + "email" or "e" for planner email
+ + "d" or "date" for plan date
+ + "d:$format" or "date:$format" for formatted plan date
+ * Added special argument to "%C", `:event", which returns a color based
+ on the value of the event type:
+ + Green for "deploy"
+ + Blue for "revert"
+ + Red for "fail"
+ * Added "%r" and "%R" for lists of required changes.
+ * Added "%x" and "%X" for lists of conflicting changes.
+ * Added "%a" to display an unlocalized attribute name and value.
+ * Added "planner", "committer", "planned", and "email" arguments to %_.
+ * Documented that the dates can take CLDR or strftime formats, too.
+ * Added the %s, %b, and %B format for "subject", "body", and raw body
+ akin to Git. The values are taken from the note value, if available.
+ * Added committer email addresses to default formats.
+ * Added plan data to default formats.
+ * Added note data to default formats.
+ * Added lists of required and conflicting changes to the "raw" and
+ "full" formats.
+ * Switched to event-driven colors for event types and change IDs in
+ default formats.
+ * Added color to the event type and change ID output in the "raw"
+ format.
+ - Added detailed descriptions of the default formats to `sqitch-log.pod`.
+ - Updated the Change object to encode and decode vertical whitespace in a
+ note, so that all data remains on a single line for each object in the
+ plan file.
+ - Now require a note when adding, reworking, or tagging a change. If
+ `--note` is not specified, an editor will be launched and the user
+ prompted to write a note. This is similar to how `git commit` behaves,
+ and encourages documentation of changes.
+ - Added required "project" and optional "uri" pragmas to the plan.
+ - Added `--project` and `--uri` attributes to the `init` command.
+ - Removed the `core.uri` configuration variable and corresponding core
+ `--uri` option (since it has been replaced with the `init` command's
+ `--uri` option.
+ - Command-line arguments are now all assumed to be UTF-8, and are parsed
+ as such.
+ - Added workaround to force the configuration file to be written and read
+ as UTF-8. Requires an unreleased version of Config::GitLike to actually
+ work properly.
+ - Text passed to a pager (as when running `sqitch log`) is now encoded in
+ UTF-8.
+ - Fixed `--quiet` option so that it properly trumps `--verbose`.
+
+0.71 2012-07-12T15:30:27Z
+ - Updated the example `sqitch log` output in `sqitchtutorial`.
+ - Changed the terms "actor", "agent" to "committer" throughout the API
+ and output.
+ - Renamed the `events` table columns from `logged_at` and `logged_by` to
+ `committed_at` and `committed_by`.
+
+0.70 2012-07-12T13:24:13Z
+ - Changed the `current_changes()` and `current_tags()` Engine methods so
+ that they return iterator code references instead of lists.
+ - Added the `search_events()` Engine method, to search the event log.
+ - Added the `pager` attribute and `page()` methods to App::Sqitch.
+ - Added support for `strftime:` and `cldr:` options to the `status`
+ command's `--date-format` option.
+ - Added the `log` command.
+ - Added the `strftime:$string` and `cldr:$string` options to
+ `--date-format` in the `status` and `log` commands.
+
+0.60 2012-07-07T11:12:26Z
+ - Removed some discussion of VCS integration, since it is not yet
+ implemented, and it may be a while before it is.
+ - Added `sqitchcommands`, documentation of the most common Sqitch
+ commands, and fixed `--help` to show it.
+ - Fixed `--man` to show the sqitch command documentation.
+ - Fixed error handling for unknown commands, so that it displays a
+ message saying the command is unknown, rather than a stack trace.
+ - Adding a change after a tag now also inserts a blank line into the plan
+ between the tag and the new change, for nicer plan file formatting.
+ - Added the `status` command.
+ - Added App::Sqitch::DateTime, a DateTime subclass with named formats.
+
+0.51 2012-07-04T18:34:07Z
+ - Added Role::HasMessage to the list or requirements in `Build.PL`. Was
+ an oversight that it was omitted in v0.50.
+ - Removed the `--dry-run` option. It was completely ignored. Maybe it
+ will return someday.
+ - Removed `fail()`, `bail()`, `unfound()`, and `help()`. It's better for
+ commands not to exit, so have them throw exceptions in the appropriate
+ places, instead.
+ - Replaced all uses of Carp and non-exception handling uses of `die` with
+ our own localized exceptions.
+ - Localized all output and exception messages.
+
+0.50 2012-07-03T19:55:20Z
+ - Require a plan file.
+ - Renamed "steps" to "changes".
+ - New plan file spec.
+ + Tags are just labels on a particular change, no longer a list of
+ changes.
+ + Dependencies now specified in the plan file, not in the deploy
+ script.
+ + Changes can be specified as deploys or reverts, though reverts
+ are not currently supported.
+ + Changes can be specified with an optional leading `+` for deploy or
+ `-` for revert, which will eventually be important for conflict
+ management.
+ + Dependencies can be specified as other change names, tags, or a
+ change as of a tag (e.g., `foo@beta`).
+ + Pragmas can be specified with a leading `%`. Only `%syntax-version`
+ is currently recognized; all others are ignored.
+ - Renamed the `add-step` command to just `add`.
+ - Added the `tag` command.
+ - Added the `revert` command.
+ - Added the `rework` command.
+ - Added exception objects and started using them.
+ - Added localization support and started using it.
+ - Added IDs to changes and tags. These are SHA1s generated from the return
+ value of the new `info` method, which describes the change or tag.
+ - Updated the PostgreSQL engine to comply with the new Engine API.
+ - Updated the PostgreSQL engine to use IDs for tracking changes and tags.
+ - Eliminated the term "node" from the plan implementation and docs.
+ - Updated the engine base class for the new plan API, and to just deploy
+ changes one-at-a-time.
+ - Added many new ways to look for changes in the plan, including:
+ + `change_name`
+ + `@tag_name`
+ + `change_name@tag_name`
+ + `change_id`
+ + `tag_id`
+ - The plan file can now be written out with nearly all white space and
+ comments preserved.
+ - Changed the `add` command to write out the plan file after a new change
+ is added.
+ - Change names can now be duplicated, as long as a tag name appears
+ between them.
+ - Renamed `target` to destination in Engine.
+ - Started referring to the change to deploy or revert to in docs as the
+ "target".
+ - PostgreSQL errors will now be thrown as Sqitch exceptions, for proper
+ handling during command execution.
+ - Added required `core.uri` configuration setting. Used to keep change
+ IDs unique across projects.
+ - Added `--mode` option to `deploy`, to trigger reverts on failure to
+ either:
+ + Not at all: keep the latest successful change.
+ + To the last deployed tag
+ + To the point at which the current deploy started
+ - Added the implicit tags `@ROOT` and `@HEAD` for looking up changes in
+ the plan.
+ - Renamed `sql_dir` to `top_dir` and made it default to the current
+ directory.
+ - Changed the location of the plan file to the top directory. This will
+ make it easier to have plans and scripts for multiple database
+ platforms in a single project.
+ - Fixed a bug in the build process so that template files will be
+ properly written to the `etc` directory.
+ - Rewrote `sqitchtutorial` to reflect the new realities.
+ - Updated `sqitch` documentation, and moved the plan file information to
+ App::Sqitch::Plan.
+
+0.31 2012-05-21T22:29:42Z
+ - Fixed some typos and failing tests.
+
+0.30 2012-05-18T15:43:12Z
+ - The `init` command now properly writes out the `[core]` section header
+ when there are only commented core settings.
+ - The `--requires` and `--conflicts` options to `add` now work
+ properly.
+ - Fixed anticipated Win32 test failures in `t/init.t`.'
+ - Fixed the `--plan-file`, `--top-dir`, and other directory options so
+ that they no longer throw errors, but actually work.
+ - Implemented the plan parser. It's designed to later be subclassed to
+ support VCS integration. Includes dependency parsing and sorting.
+ - Switched to IPC::System::Simple instead for system/capture code.
+ - Implemented Engine interface for deploying and reverting tags.
+ - Implemented PostgreSQL engine. It uses a lock to ensure that only one
+ deployment can run at any time.
+ - Added the `deploy` command. it is now possible to deploy to a
+ PostgreSQL database.
+
+0.20 2012-05-01T02:48:47Z
+ - Added `--local` option to `sqitch config`.
+ - Renamed `project_file()` to `--local_file()` in App::Sqitch::Config.
+ - `sqitch init` now writes core and engine config settings with default
+ values to the configuration file. This makes it easier for folks to get
+ started editing it.
+ - Implemented `add` command. Includes support for system-wide or
+ use-specific templates using Template::Tiny.
+ - Added `etc` directory with default templates. This is installed into
+ `$Config{prefix}/etc/skitch`, unless built with `--prefix` or
+ `--install_base`, in which case it will simply be installed into `etc`
+ in that directory.
+ - Added `--etc-path`, so that one can know where the system-wide
+ configuration and templates are to be found.
+
+0.11 2012-04-27T06:44:54Z
+ - Implemented `init` command.
+ - Started sketching out the engine interface, with preliminary PostgreSQL
+ and SQLite implementations.
+ - Require Perl v5.10.1 (did before, but in the wrong place, so it was
+ ignored).
+ - Fixed test failures on different verions of Moose.
+ - Fixed test failure on Perl 5.12.
+
+0.10 2012-04-25T20:46:59Z
+ - Initial unstable release.
+ - Implemented `help` command.
+ - Implemented `config` command, very similar to `git-config`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..bac1a2e0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,32 @@
+This software is Copyright (c) 2019 by "iovation Inc.".
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+The MIT License
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the Software
+without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to
+whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall
+be included in all copies or substantial portions of the
+Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT
+WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR
+PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 00000000..537a967c
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2012-2018 iovation, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MANIFEST b/MANIFEST
new file mode 100644
index 00000000..a17e99e0
--- /dev/null
+++ b/MANIFEST
@@ -0,0 +1,281 @@
+# This file was automatically generated by Dist::Zilla::Plugin::Manifest v6.012.
+Build.PL
+Changes
+LICENSE
+LICENSE.md
+MANIFEST
+META.json
+META.yml
+README
+README.md
+bin/sqitch
+dist/cpanfile
+dist/sqitch.spec
+etc/templates/deploy/exasol.tmpl
+etc/templates/deploy/firebird.tmpl
+etc/templates/deploy/mysql.tmpl
+etc/templates/deploy/oracle.tmpl
+etc/templates/deploy/pg.tmpl
+etc/templates/deploy/snowflake.tmpl
+etc/templates/deploy/sqlite.tmpl
+etc/templates/deploy/vertica.tmpl
+etc/templates/revert/exasol.tmpl
+etc/templates/revert/firebird.tmpl
+etc/templates/revert/mysql.tmpl
+etc/templates/revert/oracle.tmpl
+etc/templates/revert/pg.tmpl
+etc/templates/revert/snowflake.tmpl
+etc/templates/revert/sqlite.tmpl
+etc/templates/revert/vertica.tmpl
+etc/templates/verify/exasol.tmpl
+etc/templates/verify/firebird.tmpl
+etc/templates/verify/mysql.tmpl
+etc/templates/verify/oracle.tmpl
+etc/templates/verify/pg.tmpl
+etc/templates/verify/snowflake.tmpl
+etc/templates/verify/sqlite.tmpl
+etc/templates/verify/vertica.tmpl
+etc/tools/upgrade-registry-to-mysql-5.5.0.sql
+etc/tools/upgrade-registry-to-mysql-5.6.4.sql
+inc/Menlo/Sqitch.pm
+inc/Module/Build/Sqitch.pm
+lib/App/Sqitch.pm
+lib/App/Sqitch/Command.pm
+lib/App/Sqitch/Command/add.pm
+lib/App/Sqitch/Command/bundle.pm
+lib/App/Sqitch/Command/checkout.pm
+lib/App/Sqitch/Command/config.pm
+lib/App/Sqitch/Command/deploy.pm
+lib/App/Sqitch/Command/engine.pm
+lib/App/Sqitch/Command/help.pm
+lib/App/Sqitch/Command/init.pm
+lib/App/Sqitch/Command/log.pm
+lib/App/Sqitch/Command/plan.pm
+lib/App/Sqitch/Command/rebase.pm
+lib/App/Sqitch/Command/revert.pm
+lib/App/Sqitch/Command/rework.pm
+lib/App/Sqitch/Command/show.pm
+lib/App/Sqitch/Command/status.pm
+lib/App/Sqitch/Command/tag.pm
+lib/App/Sqitch/Command/target.pm
+lib/App/Sqitch/Command/upgrade.pm
+lib/App/Sqitch/Command/verify.pm
+lib/App/Sqitch/Config.pm
+lib/App/Sqitch/DateTime.pm
+lib/App/Sqitch/Engine.pm
+lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql
+lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql
+lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql
+lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql
+lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql
+lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql
+lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql
+lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql
+lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql
+lib/App/Sqitch/Engine/exasol.pm
+lib/App/Sqitch/Engine/exasol.sql
+lib/App/Sqitch/Engine/firebird.pm
+lib/App/Sqitch/Engine/firebird.sql
+lib/App/Sqitch/Engine/mysql.pm
+lib/App/Sqitch/Engine/mysql.sql
+lib/App/Sqitch/Engine/oracle.pm
+lib/App/Sqitch/Engine/oracle.sql
+lib/App/Sqitch/Engine/pg.pm
+lib/App/Sqitch/Engine/pg.sql
+lib/App/Sqitch/Engine/snowflake.pm
+lib/App/Sqitch/Engine/snowflake.sql
+lib/App/Sqitch/Engine/sqlite.pm
+lib/App/Sqitch/Engine/sqlite.sql
+lib/App/Sqitch/Engine/vertica.pm
+lib/App/Sqitch/Engine/vertica.sql
+lib/App/Sqitch/ItemFormatter.pm
+lib/App/Sqitch/Plan.pm
+lib/App/Sqitch/Plan/Blank.pm
+lib/App/Sqitch/Plan/Change.pm
+lib/App/Sqitch/Plan/ChangeList.pm
+lib/App/Sqitch/Plan/Depend.pm
+lib/App/Sqitch/Plan/Line.pm
+lib/App/Sqitch/Plan/LineList.pm
+lib/App/Sqitch/Plan/Pragma.pm
+lib/App/Sqitch/Plan/Tag.pm
+lib/App/Sqitch/Role/ConnectingCommand.pm
+lib/App/Sqitch/Role/ContextCommand.pm
+lib/App/Sqitch/Role/DBIEngine.pm
+lib/App/Sqitch/Role/RevertDeployCommand.pm
+lib/App/Sqitch/Role/TargetConfigCommand.pm
+lib/App/Sqitch/Target.pm
+lib/App/Sqitch/Types.pm
+lib/App/Sqitch/X.pm
+lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo
+lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo
+lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo
+lib/sqitch-add-usage.pod
+lib/sqitch-add.pod
+lib/sqitch-authentication.pod
+lib/sqitch-bundle-usage.pod
+lib/sqitch-bundle.pod
+lib/sqitch-checkout-usage.pod
+lib/sqitch-checkout.pod
+lib/sqitch-config-usage.pod
+lib/sqitch-config.pod
+lib/sqitch-configuration.pod
+lib/sqitch-deploy-usage.pod
+lib/sqitch-deploy.pod
+lib/sqitch-engine-usage.pod
+lib/sqitch-engine.pod
+lib/sqitch-environment.pod
+lib/sqitch-help-usage.pod
+lib/sqitch-help.pod
+lib/sqitch-init-usage.pod
+lib/sqitch-init.pod
+lib/sqitch-log-usage.pod
+lib/sqitch-log.pod
+lib/sqitch-passwords.pod
+lib/sqitch-plan-usage.pod
+lib/sqitch-plan.pod
+lib/sqitch-rebase-usage.pod
+lib/sqitch-rebase.pod
+lib/sqitch-revert-usage.pod
+lib/sqitch-revert.pod
+lib/sqitch-rework-usage.pod
+lib/sqitch-rework.pod
+lib/sqitch-show-usage.pod
+lib/sqitch-show.pod
+lib/sqitch-status-usage.pod
+lib/sqitch-status.pod
+lib/sqitch-tag-usage.pod
+lib/sqitch-tag.pod
+lib/sqitch-target-usage.pod
+lib/sqitch-target.pod
+lib/sqitch-upgrade-usage.pod
+lib/sqitch-upgrade.pod
+lib/sqitch-verify-usage.pod
+lib/sqitch-verify.pod
+lib/sqitch.pod
+lib/sqitchchanges.pod
+lib/sqitchcommands.pod
+lib/sqitchguides.pod
+lib/sqitchtutorial-exasol.pod
+lib/sqitchtutorial-firebird.pod
+lib/sqitchtutorial-mysql.pod
+lib/sqitchtutorial-oracle.pod
+lib/sqitchtutorial-snowflake.pod
+lib/sqitchtutorial-sqlite.pod
+lib/sqitchtutorial-vertica.pod
+lib/sqitchtutorial.pod
+lib/sqitchusage.pod
+t/add.t
+t/add_change.conf
+t/base.t
+t/blank.t
+t/bundle.t
+t/change.t
+t/changelist.t
+t/checkout.t
+t/command.t
+t/config.t
+t/configuration.t
+t/conn_cmd_role.t
+t/core.conf
+t/core_target.conf
+t/cx_cmd_role.t
+t/datetime.t
+t/depend.t
+t/deploy.t
+t/die.pl
+t/echo.pl
+t/editor.conf
+t/engine.conf
+t/engine.t
+t/engine/deploy/func/add_user.sql
+t/engine/deploy/users.sql
+t/engine/deploy/widgets.sql
+t/engine/revert/func/add_user.sql
+t/engine/revert/users.sql
+t/engine/revert/widgets.sql
+t/engine/reworked/deploy/users@alpha.sql
+t/engine/reworked/revert/users@alpha.sql
+t/engine/sqitch.plan
+t/engine_cmd.t
+t/exasol.t
+t/firebird.t
+t/help.t
+t/init.t
+t/item_formatter.t
+t/lib/App/Sqitch/Command/bad.pm
+t/lib/App/Sqitch/Command/good.pm
+t/lib/App/Sqitch/Engine/bad.pm
+t/lib/App/Sqitch/Engine/good.pm
+t/lib/DBIEngineTest.pm
+t/lib/LC.pm
+t/lib/MockOutput.pm
+t/lib/TestConfig.pm
+t/lib/upgradable_registries/exasol.sql
+t/lib/upgradable_registries/firebird.sql
+t/lib/upgradable_registries/mysql.sql
+t/lib/upgradable_registries/oracle.sql
+t/lib/upgradable_registries/pg.sql
+t/lib/upgradable_registries/snowflake.sql
+t/lib/upgradable_registries/sqlite.sql
+t/lib/upgradable_registries/vertica.sql
+t/linelist.t
+t/local.conf
+t/log.t
+t/mooseless.t
+t/multiplan.conf
+t/mysql.t
+t/odbc/odbcinst.ini
+t/odbc/vertica.ini
+t/options.t
+t/oracle.t
+t/pg.t
+t/plan.t
+t/plan_cmd.t
+t/plans/bad-change.plan
+t/plans/changes-only.plan
+t/plans/dependencies.plan
+t/plans/deploy-and-revert.plan
+t/plans/dos.plan
+t/plans/dupe-change-diff-tag.plan
+t/plans/dupe-change.plan
+t/plans/dupe-tag.plan
+t/plans/multi.plan
+t/plans/pragmas.plan
+t/plans/project_deps.plan
+t/plans/reserved-tag.plan
+t/plans/widgets.plan
+t/pragma.t
+t/read.pl
+t/rebase.t
+t/revert.t
+t/rework.conf
+t/rework.t
+t/show.t
+t/snowflake.t
+t/sqitch
+t/sqitch.conf
+t/sql/deploy/roles.sql
+t/sql/deploy/users.sql
+t/sql/deploy/widgets.sql
+t/sql/sqitch.plan
+t/sql/verify/users.sql
+t/sqlite.t
+t/status.t
+t/tag.t
+t/tag_cmd.t
+t/target.conf
+t/target.t
+t/target_cmd.t
+t/templates.conf
+t/upgrade.t
+t/user.conf
+t/verify.t
+t/vertica.t
+t/x.t
diff --git a/META.json b/META.json
new file mode 100644
index 00000000..72d974f6
--- /dev/null
+++ b/META.json
@@ -0,0 +1,305 @@
+{
+ "abstract" : "Sensible database change management",
+ "author" : [
+ "\"iovation Inc.\""
+ ],
+ "dynamic_config" : 0,
+ "generated_by" : "Dist::Zilla version 6.012, CPAN::Meta::Converter version 2.150010",
+ "license" : [
+ "mit"
+ ],
+ "meta-spec" : {
+ "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec",
+ "version" : 2
+ },
+ "name" : "App-Sqitch",
+ "no_index" : {
+ "directory" : [
+ "priv"
+ ]
+ },
+ "optional_features" : {
+ "exasol" : {
+ "description" : "Support for managing Exasol databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::ODBC" : "1.59"
+ }
+ }
+ }
+ },
+ "firebird" : {
+ "description" : "Support for managing Firebird databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::Firebird" : "1.11",
+ "Time::HiRes" : "0",
+ "Time::Local" : "0"
+ }
+ }
+ }
+ },
+ "mysql" : {
+ "description" : "Support for managing MySQL databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::mysql" : "4.018",
+ "MySQL::Config" : "0"
+ }
+ }
+ }
+ },
+ "odbc" : {
+ "description" : "Include the ODBC driver.",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::ODBC" : "1.59"
+ }
+ }
+ }
+ },
+ "oracle" : {
+ "description" : "Support for managing Oracle databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::Oracle" : "1.23"
+ }
+ }
+ }
+ },
+ "postgres" : {
+ "description" : "Support for managing PostgreSQL databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::Pg" : "2.0"
+ }
+ }
+ }
+ },
+ "snowflake" : {
+ "description" : "Support for managing Snowflake databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::ODBC" : "1.59"
+ }
+ }
+ }
+ },
+ "sqlite" : {
+ "description" : "Support for managing SQLite databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::SQLite" : "1.37"
+ }
+ }
+ }
+ },
+ "vertica" : {
+ "description" : "Support for managing Vertica databases",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "DBD::ODBC" : "1.59"
+ }
+ }
+ }
+ }
+ },
+ "prereqs" : {
+ "build" : {
+ "recommends" : {
+ "Menlo::CLI::Compat" : "0"
+ },
+ "requires" : {
+ "Module::Build" : "0.35"
+ }
+ },
+ "configure" : {
+ "requires" : {
+ "Module::Build" : "0.35"
+ }
+ },
+ "develop" : {
+ "recommends" : {
+ "DBD::Firebird" : "1.11",
+ "DBD::ODBC" : "1.59",
+ "DBD::Pg" : "2.0",
+ "DBD::SQLite" : "1.37",
+ "DBD::mysql" : "4.018",
+ "Dist::Zilla" : "5",
+ "Dist::Zilla::Plugin::AutoPrereqs" : "0",
+ "Dist::Zilla::Plugin::CPANFile" : "0",
+ "Dist::Zilla::Plugin::ConfirmRelease" : "0",
+ "Dist::Zilla::Plugin::ExecDir" : "0",
+ "Dist::Zilla::Plugin::GatherDir" : "0",
+ "Dist::Zilla::Plugin::License" : "0",
+ "Dist::Zilla::Plugin::LocaleTextDomain" : "0",
+ "Dist::Zilla::Plugin::Manifest" : "0",
+ "Dist::Zilla::Plugin::ManifestSkip" : "0",
+ "Dist::Zilla::Plugin::MetaJSON" : "0",
+ "Dist::Zilla::Plugin::MetaNoIndex" : "0",
+ "Dist::Zilla::Plugin::MetaResources" : "0",
+ "Dist::Zilla::Plugin::MetaYAML" : "0",
+ "Dist::Zilla::Plugin::ModuleBuild" : "0",
+ "Dist::Zilla::Plugin::OptionalFeature" : "0",
+ "Dist::Zilla::Plugin::OurPkgVersion" : "0",
+ "Dist::Zilla::Plugin::Prereqs" : "0",
+ "Dist::Zilla::Plugin::Prereqs::AuthorDeps" : "0",
+ "Dist::Zilla::Plugin::PruneCruft" : "0",
+ "Dist::Zilla::Plugin::Readme" : "0",
+ "Dist::Zilla::Plugin::RunExtraTests" : "0",
+ "Dist::Zilla::Plugin::ShareDir" : "0",
+ "Dist::Zilla::Plugin::TestRelease" : "0",
+ "Dist::Zilla::Plugin::UploadToCPAN" : "0",
+ "MySQL::Config" : "0",
+ "Software::License::MIT" : "0",
+ "Test::Pod" : "1.41",
+ "Test::Pod::Coverage" : "1.08",
+ "Test::Spelling" : "0",
+ "Time::HiRes" : "0",
+ "Time::Local" : "0"
+ },
+ "requires" : {
+ "DBD::Firebird" : "1.11",
+ "DBD::ODBC" : "1.59",
+ "DBD::Oracle" : "1.23",
+ "DBD::Pg" : "2.0",
+ "DBD::SQLite" : "1.37",
+ "DBD::mysql" : "4.018",
+ "MySQL::Config" : "0",
+ "Time::HiRes" : "0",
+ "Time::Local" : "0"
+ },
+ "suggests" : {
+ "DBD::Oracle" : "1.23"
+ }
+ },
+ "runtime" : {
+ "recommends" : {
+ "Class::XSAccessor" : "1.18",
+ "Pod::Simple" : "1.41",
+ "Template" : "0",
+ "Type::Tiny::XS" : "0.010"
+ },
+ "requires" : {
+ "Clone" : "0",
+ "Config::GitLike" : "1.15",
+ "DBI" : "0",
+ "DateTime" : "1.04",
+ "DateTime::TimeZone" : "0",
+ "Devel::StackTrace" : "1.30",
+ "Digest::SHA" : "0",
+ "Encode" : "0",
+ "Encode::Locale" : "0",
+ "File::Basename" : "0",
+ "File::Copy" : "0",
+ "File::Path" : "0",
+ "File::Temp" : "0",
+ "Getopt::Long" : "0",
+ "Hash::Merge" : "0",
+ "IO::Handle" : "0",
+ "IO::Pager" : "0.34",
+ "IPC::Run3" : "0",
+ "IPC::System::Simple" : "1.17",
+ "List::MoreUtils" : "0",
+ "List::Util" : "0",
+ "Locale::Messages" : "0",
+ "Locale::TextDomain" : "1.20",
+ "Moo" : "1.002000",
+ "Moo::Role" : "0",
+ "POSIX" : "0",
+ "Path::Class" : "0.33",
+ "PerlIO::utf8_strict" : "0",
+ "Pod::Escapes" : "1.04",
+ "Pod::Find" : "0",
+ "Pod::Usage" : "0",
+ "Scalar::Util" : "0",
+ "StackTrace::Auto" : "0",
+ "String::Formatter" : "0",
+ "String::ShellQuote" : "0",
+ "Sub::Exporter" : "0",
+ "Sub::Exporter::Util" : "0",
+ "Sys::Hostname" : "0",
+ "Template::Tiny" : "0.11",
+ "Term::ANSIColor" : "2.02",
+ "Throwable" : "0.200009",
+ "Time::HiRes" : "0",
+ "Time::Local" : "0",
+ "Try::Tiny" : "0",
+ "Type::Library" : "0.040",
+ "Type::Utils" : "0",
+ "Types::Standard" : "0",
+ "URI" : "0",
+ "URI::QueryParam" : "0",
+ "URI::db" : "0.19",
+ "User::pwent" : "0",
+ "constant" : "0",
+ "if" : "0",
+ "namespace::autoclean" : "0.16",
+ "overload" : "0",
+ "parent" : "0",
+ "perl" : "5.010",
+ "strict" : "0",
+ "utf8" : "0",
+ "warnings" : "0"
+ },
+ "suggests" : {
+ "DBD::Firebird" : "1.11",
+ "DBD::ODBC" : "1.59",
+ "DBD::Oracle" : "1.23",
+ "DBD::Pg" : "2.0",
+ "DBD::SQLite" : "1.37",
+ "DBD::mysql" : "4.018",
+ "MySQL::Config" : "0",
+ "Time::HiRes" : "0",
+ "Time::Local" : "0"
+ }
+ },
+ "test" : {
+ "requires" : {
+ "Capture::Tiny" : "0.12",
+ "Carp" : "0",
+ "File::Find" : "0",
+ "File::Spec" : "0",
+ "File::Spec::Functions" : "0",
+ "FindBin" : "0",
+ "IO::Pager" : "0.34",
+ "Module::Runtime" : "0",
+ "Path::Class" : "0.33",
+ "Test::Deep" : "0",
+ "Test::Dir" : "0",
+ "Test::Exception" : "0",
+ "Test::File" : "0",
+ "Test::File::Contents" : "0.20",
+ "Test::MockModule" : "0.17",
+ "Test::More" : "0.94",
+ "Test::NoWarnings" : "0.083",
+ "Test::Warn" : "0",
+ "base" : "0",
+ "lib" : "0"
+ }
+ }
+ },
+ "release_status" : "stable",
+ "resources" : {
+ "bugtracker" : {
+ "web" : "https://github.com/sqitchers/sqitch/issues/"
+ },
+ "homepage" : "https://sqitch.org/",
+ "repository" : {
+ "url" : "https://github.com/sqitchers/sqitch/"
+ }
+ },
+ "version" : "v1.0.0",
+ "x_generated_by_perl" : "v5.30.0",
+ "x_serialization_backend" : "Cpanel::JSON::XS version 4.11"
+}
+
diff --git a/META.yml b/META.yml
new file mode 100644
index 00000000..fd856473
--- /dev/null
+++ b/META.yml
@@ -0,0 +1,151 @@
+---
+abstract: 'Sensible database change management'
+author:
+ - '"iovation Inc."'
+build_requires:
+ Capture::Tiny: '0.12'
+ Carp: '0'
+ File::Find: '0'
+ File::Spec: '0'
+ File::Spec::Functions: '0'
+ FindBin: '0'
+ IO::Pager: '0.34'
+ Module::Build: '0.35'
+ Module::Runtime: '0'
+ Path::Class: '0.33'
+ Test::Deep: '0'
+ Test::Dir: '0'
+ Test::Exception: '0'
+ Test::File: '0'
+ Test::File::Contents: '0.20'
+ Test::MockModule: '0.17'
+ Test::More: '0.94'
+ Test::NoWarnings: '0.083'
+ Test::Warn: '0'
+ base: '0'
+ lib: '0'
+configure_requires:
+ Module::Build: '0.35'
+dynamic_config: 0
+generated_by: 'Dist::Zilla version 6.012, CPAN::Meta::Converter version 2.150010'
+license: mit
+meta-spec:
+ url: http://module-build.sourceforge.net/META-spec-v1.4.html
+ version: '1.4'
+name: App-Sqitch
+no_index:
+ directory:
+ - priv
+optional_features:
+ exasol:
+ description: 'Support for managing Exasol databases'
+ requires:
+ DBD::ODBC: '1.59'
+ firebird:
+ description: 'Support for managing Firebird databases'
+ requires:
+ DBD::Firebird: '1.11'
+ Time::HiRes: '0'
+ Time::Local: '0'
+ mysql:
+ description: 'Support for managing MySQL databases'
+ requires:
+ DBD::mysql: '4.018'
+ MySQL::Config: '0'
+ odbc:
+ description: 'Include the ODBC driver.'
+ requires:
+ DBD::ODBC: '1.59'
+ oracle:
+ description: 'Support for managing Oracle databases'
+ requires:
+ DBD::Oracle: '1.23'
+ postgres:
+ description: 'Support for managing PostgreSQL databases'
+ requires:
+ DBD::Pg: '2.0'
+ snowflake:
+ description: 'Support for managing Snowflake databases'
+ requires:
+ DBD::ODBC: '1.59'
+ sqlite:
+ description: 'Support for managing SQLite databases'
+ requires:
+ DBD::SQLite: '1.37'
+ vertica:
+ description: 'Support for managing Vertica databases'
+ requires:
+ DBD::ODBC: '1.59'
+recommends:
+ Class::XSAccessor: '1.18'
+ Pod::Simple: '1.41'
+ Template: '0'
+ Type::Tiny::XS: '0.010'
+requires:
+ Clone: '0'
+ Config::GitLike: '1.15'
+ DBI: '0'
+ DateTime: '1.04'
+ DateTime::TimeZone: '0'
+ Devel::StackTrace: '1.30'
+ Digest::SHA: '0'
+ Encode: '0'
+ Encode::Locale: '0'
+ File::Basename: '0'
+ File::Copy: '0'
+ File::Path: '0'
+ File::Temp: '0'
+ Getopt::Long: '0'
+ Hash::Merge: '0'
+ IO::Handle: '0'
+ IO::Pager: '0.34'
+ IPC::Run3: '0'
+ IPC::System::Simple: '1.17'
+ List::MoreUtils: '0'
+ List::Util: '0'
+ Locale::Messages: '0'
+ Locale::TextDomain: '1.20'
+ Moo: '1.002000'
+ Moo::Role: '0'
+ POSIX: '0'
+ Path::Class: '0.33'
+ PerlIO::utf8_strict: '0'
+ Pod::Escapes: '1.04'
+ Pod::Find: '0'
+ Pod::Usage: '0'
+ Scalar::Util: '0'
+ StackTrace::Auto: '0'
+ String::Formatter: '0'
+ String::ShellQuote: '0'
+ Sub::Exporter: '0'
+ Sub::Exporter::Util: '0'
+ Sys::Hostname: '0'
+ Template::Tiny: '0.11'
+ Term::ANSIColor: '2.02'
+ Throwable: '0.200009'
+ Time::HiRes: '0'
+ Time::Local: '0'
+ Try::Tiny: '0'
+ Type::Library: '0.040'
+ Type::Utils: '0'
+ Types::Standard: '0'
+ URI: '0'
+ URI::QueryParam: '0'
+ URI::db: '0.19'
+ User::pwent: '0'
+ constant: '0'
+ if: '0'
+ namespace::autoclean: '0.16'
+ overload: '0'
+ parent: '0'
+ perl: '5.010'
+ strict: '0'
+ utf8: '0'
+ warnings: '0'
+resources:
+ bugtracker: https://github.com/sqitchers/sqitch/issues/
+ homepage: https://sqitch.org/
+ repository: https://github.com/sqitchers/sqitch/
+version: v1.0.0
+x_generated_by_perl: v5.30.0
+x_serialization_backend: 'YAML::Tiny version 1.73'
diff --git a/README b/README
new file mode 100644
index 00000000..286c9317
--- /dev/null
+++ b/README
@@ -0,0 +1,13 @@
+This archive contains the distribution App-Sqitch,
+version v1.0.0:
+
+ Sensible database change management
+
+This software is Copyright (c) 2019 by "iovation Inc.".
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+
+This README file was generated by Dist::Zilla::Plugin::Readme v6.012.
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..b528d1ab
--- /dev/null
+++ b/README.md
@@ -0,0 +1,160 @@
+App/Sqitch version 0.9999
+=========================
+
+[![CPAN version](https://badge.fury.io/pl/App-Sqitch.svg)](https://badge.fury.io/pl/App-Sqitch)
+[![Build Status](https://travis-ci.com/sqitchers/sqitch.svg)](https://travis-ci.com/sqitchers/sqitch)
+[![Coverage Status](https://coveralls.io/repos/sqitchers/sqitch/badge.svg)](https://coveralls.io/r/sqitchers/sqitch)
+
+[Sqitch](https://sqitch.org/) is a database change management application. It
+currently supports PostgreSQL 8.4+, SQLite 3.7.11+, MySQL 5.0+, Oracle 10g+,
+Firebird 2.0+, Vertica 6.0+, Exasol 6.0+ and Snowflake.
+
+What makes it different from your typical
+[migration](https://guides.rubyonrails.org/migrations.html) approaches? A few
+things:
+
+* No opinions
+
+ Sqitch is not tied to any framework, ORM, or platform. Rather, it is a
+ standalone change management system with no opinions about your database
+ engine, application framework, or development environment.
+
+* Native scripting
+
+ Changes are implemented as scripts native to your selected database
+ engine. Writing a [PostgreSQL](https://postgresql.org/) application? Write
+ SQL scripts for
+ [`psql`](https://www.postgresql.org/docs/current/static/app-psql.html).
+ Writing an [Oracle](https://www.oracle.com/database/)-backed app?
+ Write SQL scripts for [SQL\*Plus](https://www.orafaq.com/wiki/SQL*Plus).
+
+* Dependency resolution
+
+ Database changes may declare dependencies on other changes -- even on
+ changes from other Sqitch projects. This ensures proper order of
+ execution, even when you've committed changes to your VCS out-of-order.
+
+* Deployment integrity
+
+ Sqitch manages changes and dependencies via a plan file, and employs a
+ [Merkle tree](https://en.wikipedia.org/wiki/Merkle_tree "Wikipedia: “Merkle tree”")
+ pattern similar to
+ [Git](https://stackoverflow.com/a/18589734/ "Stack Overflow: “What is the mathematical structure that represents a Git repo”")
+ and [Blockchain](https://medium.com/byzantine-studio/blockchain-fundamentals-what-is-a-merkle-tree-d44c529391d7 "Medium: “Blockchain Fundamentals #1: What is a Merkle Tree?”")
+ to ensure deployment integrity.
+ As such, there is no need to number your changes, although you can if you
+ want. Sqitch doesn't much care how you name your changes.
+
+* Iterative Development
+
+ Up until you [tag](https://sqitch.org/docs/manual/sqitch-tag/) and
+ [release](https://sqitch.org/docs/manual/sqitch-tag/) your project, you
+ can modify your change deployment scripts as often as you like. They're
+ not locked in just because they've been committed to your VCS. This allows
+ you to take an iterative approach to developing your database schema. Or,
+ better, you can do test-driven database development.
+
+Want to learn more? The best place to start is in the tutorials:
+
+* [Introduction to Sqitch on PostgreSQL](lib/sqitchtutorial.pod)
+* [Introduction to Sqitch on SQLite](lib/sqitchtutorial-sqlite.pod)
+* [Introduction to Sqitch on Oracle](lib/sqitchtutorial-oracle.pod)
+* [Introduction to Sqitch on MySQL](lib/sqitchtutorial-mysql.pod)
+* [Introduction to Sqitch on Firebird](lib/sqitchtutorial-firebird.pod)
+* [Introduction to Sqitch on Vertica](lib/sqitchtutorial-vertica.pod)
+* [Introduction to Sqitch on Exasol](lib/sqitchtutorial-exasol.pod)
+* [Introduction to Sqitch on Snowflake](lib/sqitchtutorial-snowflake.pod)
+
+There have also been a number of presentations on Sqitch:
+
+* [PDX.pm Presentation](https://speakerdeck.com/theory/sane-database-change-management-with-sqitch):
+ Slides from "Sane Database Management with Sqitch", presented to the
+ Portland Perl Mongers in January, 2013.
+
+* [PDXPUG Presentation](https://vimeo.com/50104469): Movie of "Sane Database
+ Management with Sqitch", presented to the Portland PostgreSQL Users Group in
+ September, 2012.
+
+* [Agile Database Development](https://speakerdeck.com/theory/agile-database-development-2ed):
+ Slides from a three-hour tutorial session on using [Git](https://git-scm.org),
+ test-driven development with [pgTAP](https://pgtap.org), and change
+ management with Sqitch, updated in January, 2014.
+
+Installation
+------------
+
+To install Sqitch from a distribution download, type the following:
+
+ perl Build.PL
+ ./Build installdeps
+ ./Build
+ ./Build test
+ ./Build install
+
+To install Sqitch and all of its dependencies into a single directory named
+`sqitch_bundle`, install the Menlo CPAN client and build the bundle:
+
+ cpanm Menlo::CLI::Compat
+ ./Build bundle --install_base sqitch_bundle
+
+After which, Sqitch can be run from `./sqitch_bundle/bin/sqitch`. By default,
+no modules that are included in the core Perl distrituion are included. To
+require that dual-life modules also be bundled, pass `--dual_life 1`:
+
+ ./Build bundle --install_base sqitch_bundle --dual_life 1
+
+To include support for a feature in the bundle, pass the `--with` option
+naming the feature:
+
+ ./Build bundle --install_base sqitch_bundle --with postgres --with sqlite
+
+The feature names generally correspond to the supported engines. The currently
+supported features are:
+
+* `--with postgres`: Support for managing PostgreSQL databases
+* `--with sqlite`: Support for managing SQLite databases
+* `--with mysql`: Support for managing MySQL databases
+* `--with firebird`: Support for managing Firebird databases
+* `--with oracle`: Support for managing Oracle databases
+* `--with vertica`: Support for managing Vertica databases
+* `--with exasol`: Support for managing Exasol databases
+* `--with snowflake`: Support for managing Snowflake databases
+* `--with odbc`: Include the ODBC driver
+
+To build from a Git clone, first install
+[Dist::Zilla](https://metacpan.org/module/Dist::Zilla), then use it to install
+Sqitch and all dependencies:
+
+ cpanm Dist::Zilla
+ dzil authordeps --missing | cpanm
+ dzil listdeps --missing | cpanm
+ dzil install
+
+To run Sqitch directly from the Git clone, execute `t/sqitch`.
+
+To install Sqitch on a specific platform, including Debian- and RedHat-derived
+Linux distributions and Windows, see the
+[Installation documentation](https://sqitch.org/#installation).
+
+Licence
+-------
+
+Copyright © 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/bin/sqitch b/bin/sqitch
new file mode 100755
index 00000000..f24e726f
--- /dev/null
+++ b/bin/sqitch
@@ -0,0 +1,15 @@
+#!perl -w -CAS
+
+our $VERSION = 'v1.0.0'; # VERSION
+use POSIX qw(setlocale);
+BEGIN {
+ if ($^O eq 'MSWin32') {
+ require Win32::Locale;
+ setlocale POSIX::LC_ALL, Win32::Locale::get_locale();
+ } else {
+ setlocale POSIX::LC_ALL, '';
+ }
+}
+use App::Sqitch;
+
+exit App::Sqitch->go;
diff --git a/dist/cpanfile b/dist/cpanfile
new file mode 100644
index 00000000..f107b799
--- /dev/null
+++ b/dist/cpanfile
@@ -0,0 +1,164 @@
+requires "Clone" => "0";
+requires "Config::GitLike" => "1.15";
+requires "DBI" => "0";
+requires "DateTime" => "1.04";
+requires "DateTime::TimeZone" => "0";
+requires "Devel::StackTrace" => "1.30";
+requires "Digest::SHA" => "0";
+requires "Encode" => "0";
+requires "Encode::Locale" => "0";
+requires "File::Basename" => "0";
+requires "File::Copy" => "0";
+requires "File::Path" => "0";
+requires "File::Temp" => "0";
+requires "Getopt::Long" => "0";
+requires "Hash::Merge" => "0";
+requires "IO::Handle" => "0";
+requires "IO::Pager" => "0.34";
+requires "IPC::Run3" => "0";
+requires "IPC::System::Simple" => "1.17";
+requires "List::MoreUtils" => "0";
+requires "List::Util" => "0";
+requires "Locale::Messages" => "0";
+requires "Locale::TextDomain" => "1.20";
+requires "Moo" => "1.002000";
+requires "Moo::Role" => "0";
+requires "POSIX" => "0";
+requires "Path::Class" => "0.33";
+requires "PerlIO::utf8_strict" => "0";
+requires "Pod::Escapes" => "1.04";
+requires "Pod::Find" => "0";
+requires "Pod::Usage" => "0";
+requires "Scalar::Util" => "0";
+requires "StackTrace::Auto" => "0";
+requires "String::Formatter" => "0";
+requires "String::ShellQuote" => "0";
+requires "Sub::Exporter" => "0";
+requires "Sub::Exporter::Util" => "0";
+requires "Sys::Hostname" => "0";
+requires "Template::Tiny" => "0.11";
+requires "Term::ANSIColor" => "2.02";
+requires "Throwable" => "0.200009";
+requires "Time::HiRes" => "0";
+requires "Time::Local" => "0";
+requires "Try::Tiny" => "0";
+requires "Type::Library" => "0.040";
+requires "Type::Utils" => "0";
+requires "Types::Standard" => "0";
+requires "URI" => "0";
+requires "URI::QueryParam" => "0";
+requires "URI::db" => "0.19";
+requires "User::pwent" => "0";
+requires "constant" => "0";
+requires "if" => "0";
+requires "namespace::autoclean" => "0.16";
+requires "overload" => "0";
+requires "parent" => "0";
+requires "perl" => "5.010";
+requires "strict" => "0";
+requires "utf8" => "0";
+requires "warnings" => "0";
+recommends "Class::XSAccessor" => "1.18";
+recommends "Pod::Simple" => "1.41";
+recommends "Template" => "0";
+recommends "Type::Tiny::XS" => "0.010";
+suggests "DBD::Firebird" => "1.11";
+suggests "DBD::ODBC" => "1.59";
+suggests "DBD::Oracle" => "1.23";
+suggests "DBD::Pg" => "2.0";
+suggests "DBD::SQLite" => "1.37";
+suggests "DBD::mysql" => "4.018";
+suggests "MySQL::Config" => "0";
+suggests "Time::HiRes" => "0";
+suggests "Time::Local" => "0";
+
+on 'build' => sub {
+ requires "Module::Build" => "0.35";
+};
+
+on 'build' => sub {
+ recommends "Menlo::CLI::Compat" => "0";
+};
+
+on 'test' => sub {
+ requires "Capture::Tiny" => "0.12";
+ requires "Carp" => "0";
+ requires "File::Find" => "0";
+ requires "File::Spec" => "0";
+ requires "File::Spec::Functions" => "0";
+ requires "FindBin" => "0";
+ requires "IO::Pager" => "0.34";
+ requires "Module::Runtime" => "0";
+ requires "Path::Class" => "0.33";
+ requires "Test::Deep" => "0";
+ requires "Test::Dir" => "0";
+ requires "Test::Exception" => "0";
+ requires "Test::File" => "0";
+ requires "Test::File::Contents" => "0.20";
+ requires "Test::MockModule" => "0.17";
+ requires "Test::More" => "0.94";
+ requires "Test::NoWarnings" => "0.083";
+ requires "Test::Warn" => "0";
+ requires "base" => "0";
+ requires "lib" => "0";
+};
+
+on 'configure' => sub {
+ requires "Module::Build" => "0.35";
+};
+
+on 'develop' => sub {
+ requires "DBD::Firebird" => "1.11";
+ requires "DBD::ODBC" => "1.59";
+ requires "DBD::Oracle" => "1.23";
+ requires "DBD::Pg" => "2.0";
+ requires "DBD::SQLite" => "1.37";
+ requires "DBD::mysql" => "4.018";
+ requires "MySQL::Config" => "0";
+ requires "Time::HiRes" => "0";
+ requires "Time::Local" => "0";
+};
+
+on 'develop' => sub {
+ recommends "DBD::Firebird" => "1.11";
+ recommends "DBD::ODBC" => "1.59";
+ recommends "DBD::Pg" => "2.0";
+ recommends "DBD::SQLite" => "1.37";
+ recommends "DBD::mysql" => "4.018";
+ recommends "Dist::Zilla" => "5";
+ recommends "Dist::Zilla::Plugin::AutoPrereqs" => "0";
+ recommends "Dist::Zilla::Plugin::CPANFile" => "0";
+ recommends "Dist::Zilla::Plugin::ConfirmRelease" => "0";
+ recommends "Dist::Zilla::Plugin::ExecDir" => "0";
+ recommends "Dist::Zilla::Plugin::GatherDir" => "0";
+ recommends "Dist::Zilla::Plugin::License" => "0";
+ recommends "Dist::Zilla::Plugin::LocaleTextDomain" => "0";
+ recommends "Dist::Zilla::Plugin::Manifest" => "0";
+ recommends "Dist::Zilla::Plugin::ManifestSkip" => "0";
+ recommends "Dist::Zilla::Plugin::MetaJSON" => "0";
+ recommends "Dist::Zilla::Plugin::MetaNoIndex" => "0";
+ recommends "Dist::Zilla::Plugin::MetaResources" => "0";
+ recommends "Dist::Zilla::Plugin::MetaYAML" => "0";
+ recommends "Dist::Zilla::Plugin::ModuleBuild" => "0";
+ recommends "Dist::Zilla::Plugin::OptionalFeature" => "0";
+ recommends "Dist::Zilla::Plugin::OurPkgVersion" => "0";
+ recommends "Dist::Zilla::Plugin::Prereqs" => "0";
+ recommends "Dist::Zilla::Plugin::Prereqs::AuthorDeps" => "0";
+ recommends "Dist::Zilla::Plugin::PruneCruft" => "0";
+ recommends "Dist::Zilla::Plugin::Readme" => "0";
+ recommends "Dist::Zilla::Plugin::RunExtraTests" => "0";
+ recommends "Dist::Zilla::Plugin::ShareDir" => "0";
+ recommends "Dist::Zilla::Plugin::TestRelease" => "0";
+ recommends "Dist::Zilla::Plugin::UploadToCPAN" => "0";
+ recommends "MySQL::Config" => "0";
+ recommends "Software::License::MIT" => "0";
+ recommends "Test::Pod" => "1.41";
+ recommends "Test::Pod::Coverage" => "1.08";
+ recommends "Test::Spelling" => "0";
+ recommends "Time::HiRes" => "0";
+ recommends "Time::Local" => "0";
+};
+
+on 'develop' => sub {
+ suggests "DBD::Oracle" => "1.23";
+};
diff --git a/dist/sqitch.spec b/dist/sqitch.spec
new file mode 100644
index 00000000..8cfa9e68
--- /dev/null
+++ b/dist/sqitch.spec
@@ -0,0 +1,546 @@
+Name: sqitch
+Version: 1.0.0
+Release: 1%{?dist}
+Summary: Sensible database change management
+License: MIT
+Group: Development/Libraries
+URL: https://sqitch.org/
+Source0: https://www.cpan.org/modules/by-module/App/App-Sqitch-%{version}.tar.gz
+BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
+BuildArch: noarch
+BuildRequires: perl >= 1:v5.10.0
+BuildRequires: perl(Capture::Tiny) >= 0.12
+BuildRequires: perl(Carp)
+BuildRequires: perl(Class::XSAccessor) >= 1.18
+BuildRequires: perl(Clone)
+BuildRequires: perl(Config)
+BuildRequires: perl(Config::GitLike) >= 1.15
+BuildRequires: perl(constant)
+BuildRequires: perl(DateTime) >= 1.04
+BuildRequires: perl(DateTime::TimeZone)
+BuildRequires: perl(DBI)
+BuildRequires: perl(Devel::StackTrace) >= 1.30
+BuildRequires: perl(Digest::SHA)
+BuildRequires: perl(Encode)
+BuildRequires: perl(Encode::Locale)
+BuildRequires: perl(File::Basename)
+BuildRequires: perl(File::Copy)
+BuildRequires: perl(File::Find)
+BuildRequires: perl(File::Path)
+BuildRequires: perl(File::Spec)
+BuildRequires: perl(File::Temp)
+BuildRequires: perl(Getopt::Long)
+BuildRequires: perl(Hash::Merge)
+BuildRequires: perl(IO::Pager) >= 0.34
+BuildRequires: perl(IPC::Run3)
+BuildRequires: perl(IPC::System::Simple) >= 1.17
+BuildRequires: perl(List::Util)
+BuildRequires: perl(List::MoreUtils)
+BuildRequires: perl(Locale::Messages)
+BuildRequires: perl(Locale::TextDomain) >= 1.20
+BuildRequires: perl(Module::Build) >= 0.35
+BuildRequires: perl(Module::Runtime)
+BuildRequires: perl(Moo) >= 1.002000
+BuildRequires: perl(Moo::Role)
+BuildRequires: perl(namespace::autoclean) >= 0.16
+BuildRequires: perl(parent)
+BuildRequires: perl(overload)
+BuildRequires: perl(Path::Class) >= 0.33
+BuildRequires: perl(PerlIO::utf8_strict)
+BuildRequires: perl(Pod::Escapes)
+BuildRequires: perl(Pod::Find)
+BuildRequires: perl(Pod::Usage)
+BuildRequires: perl(POSIX)
+BuildRequires: perl(Scalar::Util)
+BuildRequires: perl(StackTrace::Auto)
+BuildRequires: perl(strict)
+BuildRequires: perl(String::Formatter)
+BuildRequires: perl(String::ShellQuote)
+BuildRequires: perl(Sub::Exporter)
+BuildRequires: perl(Sub::Exporter::Util)
+BuildRequires: perl(Sys::Hostname)
+BuildRequires: perl(Template::Tiny) >= 0.11
+BuildRequires: perl(Term::ANSIColor) >= 2.02
+BuildRequires: perl(Test::Deep)
+BuildRequires: perl(Test::Dir)
+BuildRequires: perl(Test::Exception)
+BuildRequires: perl(Test::File)
+BuildRequires: perl(Test::File::Contents) >= 0.20
+BuildRequires: perl(Test::MockModule) >= 0.17
+BuildRequires: perl(Test::More) >= 0.94
+BuildRequires: perl(Test::NoWarnings) >= 0.083
+BuildRequires: perl(Test::Warn)
+BuildRequires: perl(Throwable) >= 0.200009
+BuildRequires: perl(Time::HiRes)
+BuildRequires: perl(Try::Tiny)
+BuildRequires: perl(Type::Library) >= 0.040
+BuildRequires: perl(Type::Tiny::XS) >= 0.010
+BuildRequires: perl(Type::Utils)
+BuildRequires: perl(Types::Standard)
+BuildRequires: perl(URI)
+BuildRequires: perl(URI::db) >= 0.19
+BuildRequires: perl(User::pwent)
+BuildRequires: perl(utf8)
+BuildRequires: perl(warnings)
+Requires: perl(Class::XSAccessor) >= 1.18
+Requires: perl(Clone)
+Requires: perl(Config)
+Requires: perl(Config::GitLike) >= 1.15
+Requires: perl(constant)
+Requires: perl(DateTime) >= 1.04
+Requires: perl(DateTime::TimeZone)
+Requires: perl(Devel::StackTrace) >= 1.30
+Requires: perl(Digest::SHA)
+Requires: perl(Encode)
+Requires: perl(Encode::Locale)
+Requires: perl(File::Basename)
+Requires: perl(File::Copy)
+Requires: perl(File::Path)
+Requires: perl(File::Temp)
+Requires: perl(Getopt::Long)
+Requires: perl(Hash::Merge)
+Requires: perl(IO::Pager) >= 0.34
+Requires: perl(IPC::Run3)
+Requires: perl(IPC::System::Simple) >= 1.17
+Requires: perl(List::Util)
+Requires: perl(List::MoreUtils)
+Requires: perl(Locale::Messages)
+Requires: perl(Locale::TextDomain) >= 1.20
+Requires: perl(Moo) => 1.002000
+Requires: perl(Moo::Role)
+Requires: perl(namespace::autoclean) >= 0.16
+Requires: perl(parent)
+Requires: perl(overload)
+Requires: perl(Path::Class)
+Requires: perl(PerlIO::utf8_strict)
+Requires: perl(Pod::Escapes)
+Requires: perl(Pod::Find)
+Requires: perl(Pod::Usage)
+Requires: perl(POSIX)
+Requires: perl(Scalar::Util)
+Requires: perl(StackTrace::Auto)
+Requires: perl(strict)
+Requires: perl(String::Formatter)
+Requires: perl(String::ShellQuote)
+Requires: perl(Sub::Exporter)
+Requires: perl(Sub::Exporter::Util)
+Requires: perl(Sys::Hostname)
+Requires: perl(Template::Tiny) >= 0.11
+Requires: perl(Term::ANSIColor) >= 2.02
+Requires: perl(Throwable) >= 0.200009
+Requires: perl(Try::Tiny)
+Requires: perl(Type::Library) >= 0.040
+Requires: perl(Type::Tiny::XS) >= 0.010
+Requires: perl(Type::Utils)
+Requires: perl(Types::Standard)
+Requires: perl(URI)
+Requires: perl(URI::db) >= 0.19
+Requires: perl(User::pwent)
+Requires: perl(utf8)
+Requires: perl(warnings)
+Requires: perl(:MODULE_COMPAT_%(eval "`%{__perl} -V:version`"; echo $version))
+Provides: sqitch
+
+%define etcdir %(%{__perl} -MConfig -E 'say "$Config{prefix}/etc"')
+
+%description
+This application, `sqitch`, provides a simple yet robust interface for
+database change management. The philosophy and functionality is inspired by
+Git.
+
+%prep
+%setup -q -n App-Sqitch-%{version}
+
+%build
+%{__perl} Build.PL installdirs=vendor destdir=$RPM_BUILD_ROOT
+./Build
+
+%install
+rm -rf $RPM_BUILD_ROOT
+
+./Build install
+find $RPM_BUILD_ROOT -depth -type d -exec rmdir {} 2>/dev/null \;
+
+# Grab and tweak the .packlist file.
+find $RPM_BUILD_ROOT -type f -name .packlist -exec mv {} . \;
+perl -i -pe 's/[.]([13](?:pm)?)$/.$1*/g' .packlist
+perl -i -pe "s{^\Q$RPM_BUILD_ROOT}{}g" .packlist
+
+%{_fixperms} $RPM_BUILD_ROOT/*
+
+%check
+./Build test
+
+%clean
+rm -rf $RPM_BUILD_ROOT
+
+%files -f .packlist
+%defattr(-,root,root,-)
+%doc Changes META.json README.md
+%config %{etcdir}/*
+
+%package pg
+Summary: Sensible database change management for PostgreSQL
+Group: Development/Libraries
+Requires: sqitch >= %{version}
+Requires: postgresql >= 8.4.0
+Requires: perl(DBI)
+Requires: perl(DBD::Pg) >= 2.0.0
+Provides: sqitch-pg
+
+%description pg
+Sqitch provides a simple yet robust interface for database change
+management. The philosophy and functionality is inspired by Git. This
+package bundles the Sqitch PostgreSQL support.
+
+%files pg
+# No additional files required.
+
+%package sqlite
+Summary: Sensible database change management for SQLite
+Group: Development/Libraries
+Requires: sqitch >= %{version}
+Requires: sqlite
+Requires: perl(DBI)
+Requires: perl(DBD::SQLite) >= 1.37
+Provides: sqitch-sqlite
+
+%description sqlite
+Sqitch provides a simple yet robust interface for database change
+management. The philosophy and functionality is inspired by Git. This
+package bundles the Sqitch SQLite support.
+
+%files sqlite
+# No additional files required.
+
+%package oracle
+Summary: Sensible database change management for Oracle
+Group: Development/Libraries
+Requires: sqitch >= %{version}
+Requires: oracle-instantclient11.2-sqlplus
+Requires: perl(DBI)
+Requires: perl(DBD::Oracle) >= 1.23
+Provides: sqitch-oracle
+
+%description oracle
+Sqitch provides a simple yet robust interface for database change
+management. The philosophy and functionality is inspired by Git. This
+package bundles the Sqitch Oracle support.
+
+%files oracle
+# No additional files required.
+
+%package mysql
+Summary: Sensible database change management for MySQL
+Group: Development/Libraries
+Requires: sqitch >= %{version}
+Requires: mysql >= 5.0.0
+Requires: perl(DBI)
+Requires: perl(DBD::mysql) >= 4.018
+Requires: perl(MySQL::Config)
+Provides: sqitch-mysql
+
+%description mysql
+Sqitch provides a simple yet robust interface for database change
+management. The philosophy and functionality is inspired by Git. This
+package bundles the Sqitch MySQL support.
+
+%files mysql
+# No additional files required.
+
+%package firebird
+Summary: Sensible database change management for Firebird
+Group: Development/Libraries
+Requires: sqitch >= %{version}
+Requires: firebird >= 2.5.0
+Requires: perl(DBI)
+Requires: perl(DBD::Firebird) >= 1.11
+Requires: perl(Time::HiRes)
+Requires: perl(Time::Local)
+BuildRequires: firebird >= 2.5.0
+Provides: sqitch-firebird
+
+%description firebird
+Sqitch provides a simple yet robust interface for database change
+management. The philosophy and functionality is inspired by Git. This
+package bundles the Sqitch Firebird support.
+
+%files firebird
+# No additional files required.
+
+%package vertica
+Summary: Sensible database change management for Vertica
+Group: Development/Libraries
+Requires: sqitch >= %{version}
+Requires: libverticaodbc.so
+Requires: /opt/vertica/bin/vsql
+Requires: perl(DBI)
+Requires: perl(DBD::ODBC) >= 1.59
+Provides: sqitch-vertica
+
+%description vertica
+Sqitch provides a simple yet robust interface for database change management.
+The philosophy and functionality is inspired by Git. This package bundles the
+Sqitch Vertica support.
+
+%files vertica
+# No additional files required.
+
+%package snowflake
+Summary: Sensible database change management for Snowflake
+Group: Development/Libraries
+Requires: sqitch >= %{version}
+Requires: snowflake-odbc
+Requires: perl(DBI)
+Requires: perl(DBD::ODBC) >= 1.59
+Provides: sqitch-snowflake
+
+%description snowflake
+Sqitch provides a simple yet robust interface for database change management.
+The philosophy and functionality is inspired by Git. This package bundles the
+Sqitch Snowflake support. It requires that the SnowSQL client and ODBC driver
+also be installed.
+
+%files snowflake
+# No additional files required.
+
+%changelog
+* Tue Jun 4 2019 David E. Wheeler <david.wheeler@iovation.com> 1.0.0-1
+- Upgrade to v1.0.0.
+- Config::GitLike now requires v1.15.
+- Test::MockModule now requires v0.17.
+- Removed File::HomeDir.
+- Changed "sane" to "sensible" in the summary.
+
+* Fri Feb 1 2019 David E. Wheeler <david.wheeler@iovation.com> 0.9999-1
+- Upgrade to v0.9999.
+- Added requirement for IO::Pager 0.34 or higher.
+- Added Test::Warn build requirement.
+- Removed cross-project dependency patch, since it's part of v0.99999.
+
+* Wed Oct 3 2018 David E. Wheeler <david.wheeler@iovation.com> 0.9998-1
+- Upgrade to v0.9998.
+- Added sqitch-snowflake package.
+- Added Locale::Messages requirement.
+- URI::db now requires v0.19.
+- DBD::ODBC now requires v1.59.
+- Files for installation are now read from the .packlist generated by the Perl
+ installer.
+
+* Thu Mar 15 2018 David E. Wheeler <david.wheeler@iovation.com> 0.9997-1
+- Upgrade to v0.9997.
+
+* Wed Jul 19 2017 David E. Wheeler <david.wheeler@iovation.com> 0.9996-2
+- Require File::Find and Module::Runtime at build time.
+- Remove Moo::sification.
+
+* Mon Jul 17 2017 David E. Wheeler <david.wheeler@iovation.com> 0.9996-1
+- Upgrade to v0.9996.
+
+* Wed Jul 27 2016 David E. Wheeler <david.wheeler@iovation.com> 0.9995-1
+- Require DateTime v1.04.
+- Upgrade to v0.9995.
+
+* Thu Feb 11 2016 David E. Wheeler <david.wheeler@iovation.com> 0.9994-2
+- Add perl(Pod::Escapes) to work around missing dependencies in Pod::Simple.
+ https://github.com/perl-pod/pod-simple/issues/84.
+
+* Fri Jan 8 2016 David E. Wheeler <david.wheeler@iovation.com> 0.9994-1
+- Reduced required MySQL version to 5.0.
+- Upgrade to v0.9994.
+
+* Mon Aug 17 2015 David E. Wheeler <david.wheeler@iovation.com> 0.9993-1
+- Upgrade to v0.9993.
+
+* Wed May 20 2015 David E. Wheeler <david.wheeler@iovation.com> 0.9992-1
+- Upgrade to v0.9992.
+- Add perl(DateTime::TimeZone).
+- Add Provides.
+- Replace requirement for firebird-classic with firebird.
+- Replace requirement for vertica-client with /opt/vertica/bin/vsql and
+ libverticaodbc.so.
+
+* Tue Mar 3 2015 David E. Wheeler <david.wheeler@iovation.com> 0.9991-1
+- Upgrade to v0.9991.
+- Reduced required MySQL version to 5.1.
+
+* Thu Feb 12 2015 David E. Wheeler <david.wheeler@iovation.com> 0.999-1
+- Upgrade to v0.999.
+
+* Thu Jan 15 2015 David E. Wheeler <david.wheeler@iovation.com> 0.998-1
+- Upgrade to v0.998.
+- Require Path::Class v0.33 when building.
+
+* Tue Nov 4 2014 David E. Wheeler <david.wheeler@iovation.com> 0.997-1
+- Upgrade to v0.997.
+
+* Fri Sep 5 2014 David E. Wheeler <david.wheeler@iovation.com> 0.996-1
+- Upgrade to v0.996.
+- Remove Moose and Mouse dependencies.
+- Add Moo dependencies.
+- Add Type::Library and related module dependencies.
+- Switch from Digest::SHA1 to Digest::SHA.
+- Require the Moo-backed version of Config::GitLike.
+- Remove Role module dependencies.
+- Require URI::db v0.15.
+- Add sqitch-vertica.
+
+* Sun Jul 13 2014 David E. Wheeler <david.wheeler@iovation.com> 0.995-1
+- Upgrade to v0.995.
+
+* Thu Jun 19 2014 David E. Wheeler <david.wheeler@iovation.com> 0.994-1
+- Upgrade to v0.994.
+
+* Wed Jun 4 2014 David E. Wheeler <david.wheeler@iovation.com> 0.993-1
+- Upgrade to v0.993.
+
+* Tue Mar 4 2014 David E. Wheeler <david.wheeler@iovation.com> 0.992-1
+- Upgrade to v0.992.
+
+* Thu Jan 16 2014 David E. Wheeler <david.wheeler@iovation.com> 0.991-1
+- Upgrade to v0.991.
+- Remove File::Which from sqitch-firebird.
+
+* Fri Jan 3 2014 David E. Wheeler <david.wheeler@iovation.com> 0.990-1
+- Upgrade to v0.990.
+- Add sqitch-firebird.
+- Add target command and arguments.
+- Add support for arbitrary change script templating.
+- Add --open-editor option.
+
+* Thu Nov 21 2013 David E. Wheeler <david.wheeler@iovation.com> 0.983-1
+- Upgrade to v0.983.
+- Require DBD::Pg 2.0.0 or higher.
+
+* Wed Sep 18 2013 David E. Wheeler <david.wheeler@iovation.com> 0.982-2
+- No longer include template files ending in .default in the RPM.
+- All files in the etc dir now treated as configuration files.
+- The etc and inc files are no longer treated as documentation.
+
+* Wed Sep 11 2013 David E. Wheeler <david.wheeler@iovation.com> 0.982-1
+- Upgrade to v0.982.
+- Require Clone.
+
+* Thu Sep 5 2013 David E. Wheeler <david.wheeler@iovation.com> 0.981-1
+- Upgrade to v0.981.
+
+* Wed Aug 28 2013 David E. Wheeler <david.wheeler@iovation.com> 0.980-1
+- Upgrade to v0.980.
+- Require Encode::Locale.
+- Require DBD::SQLite 1.37.
+- Require PostgreSQL 8.4.0.
+- Remove FindBin requirement.
+- Add sqitch-mysql.
+
+* Wed Jul 3 2013 David E. Wheeler <david.wheeler@iovation.com> 0.973-1
+- Upgrade to v0.973.
+
+* Fri May 31 2013 David E. Wheeler <david.wheeler@iovation.com> 0.972-1
+- Upgrade to v0.972.
+
+* Sat May 18 2013 David E. Wheeler <david.wheeler@iovation.com> 0.971-1
+- Upgrade to v0.971.
+
+* Wed May 8 2013 David E. Wheeler <david.wheeler@iovation.com> 0.970-1
+- Upgrade to v0.970.
+- Add sqitch-oracle.
+
+* Tue Apr 23 2013 David E. Wheeler <david.wheeler@iovation.com> 0.965-1
+- Upgrade to v0.965.
+
+* Mon Apr 15 2013 David E. Wheeler <david.wheeler@iovation.com> 0.964-1
+- Upgrade to v0.964.
+
+* Fri Apr 12 2013 David E. Wheeler <david.wheeler@iovation.com> 0.963-1
+- Upgrade to v0.963.
+- Add missing dependency on Devel::StackTrace 1.30.
+- Remove dependency on Git::Wrapper.
+
+* Wed Apr 10 2013 David E. Wheeler <david.wheeler@iovation.com> 0.962-1
+- Upgrade to v0.962.
+
+* Tue Apr 9 2013 David E. Wheeler <david.wheeler@iovation.com> 0.961-1
+- Upgrade to v0.961.
+
+* Mon Apr 8 2013 David E. Wheeler <david.wheeler@iovation.com> 0.960-2
+- Add missing dependency on Git::Wrapper.
+
+* Fri Apr 5 2013 David E. Wheeler <david.wheeler@iovation.com> 0.960-1
+- Upgrade to v0.960.
+- Add sqitch-sqlite.
+
+* Thu Feb 21 2013 David E. Wheeler <david.wheeler@iovation.com> 0.953-1
+- Upgrade to v0.953.
+
+* Fri Jan 11 2013 David E. Wheeler <david.wheeler@iovation.com> 0.952-1
+- Upgrade to v0.952.
+
+* Mon Jan 7 2013 David E. Wheeler <david.wheeler@iovation.com> 0.951-1
+- Upgrade to v0.951.
+
+* Thu Jan 3 2013 David E. Wheeler <david.wheeler@iovation.com> 0.950-1
+- Upgrade to v0.950.
+
+* Mon Dec 3 2012 David E. Wheeler <david.wheeler@iovation.com> 0.940-1
+- Upgrade to v0.940.
+
+* Fri Oct 12 2012 David E. Wheeler <david.wheeler@iovation.com> 0.938-1
+- Upgrade to v0.938.
+
+* Tue Oct 9 2012 David E. Wheeler <david.wheeler@iovation.com> 0.937-1
+- Upgrade to v0.937.
+
+* Tue Oct 9 2012 David E. Wheeler <david.wheeler@iovation.com> 0.936-1
+- Upgrade to v0.936.
+
+* Tue Oct 2 2012 David E. Wheeler <david.wheeler@iovation.com> 0.935-1
+- Upgrade to v0.935.
+
+* Fri Sep 28 2012 David E. Wheeler <david.wheeler@iovation.com> 0.934-1
+- Upgrade to v0.934.
+
+* Thu Sep 27 2012 David E. Wheeler <david.wheeler@iovation.com> 0.933-1
+- Upgrade to v0.933.
+
+* Wed Sep 26 2012 David E. Wheeler <david.wheeler@iovation.com> 0.932-1
+- Upgrade to v0.932.
+
+* Tue Sep 25 2012 David E. Wheeler <david.wheeler@iovation.com> 0.931-1
+- Upgrade to v0.931.
+
+* Fri Aug 31 2012 David E. Wheeler <david.wheeler@iovation.com> 0.930-1
+- Upgrade to v0.93.
+
+* Thu Aug 30 2012 David E. Wheeler <david.wheeler@iovation.com> 0.922-1
+- Upgrade to v0.922.
+
+* Wed Aug 29 2012 David E. Wheeler <david.wheeler@iovation.com> 0.921-1
+- Upgrade to v0.921.
+
+* Tue Aug 28 2012 David E. Wheeler <david.wheeler@iovation.com> 0.920-1
+- Upgrade to v0.92.
+
+* Tue Aug 28 2012 David E. Wheeler <david.wheeler@iovation.com> 0.913-1
+- Upgrade to v0.913.
+
+* Mon Aug 27 2012 David E. Wheeler <david.wheeler@iovation.com> 0.912-1
+- Upgrade to v0.912.
+
+* Thu Aug 23 2012 David E. Wheeler <david.wheeler@iovation.com> 0.911-1
+- Upgrade to v0.911.
+
+* Wed Aug 22 2012 David E. Wheeler <david.wheeler@iovation.com> 0.91-1
+- Upgrade to v0.91.
+
+* Mon Aug 20 2012 David E. Wheeler <david.wheeler@iovation.com> 0.902-1
+- Upgrade to v0.902.
+
+* Mon Aug 20 2012 David E. Wheeler <david.wheeler@iovation.com> 0.901-1
+- Upgrade to v0.901.
+
+* Mon Aug 13 2012 David E. Wheeler <david.wheeler@iovation.com> 0.82-2
+- Require Config::GitLike 1.09, which offers better encoding support an other
+ bug fixes.
+
+* Fri Aug 03 2012 David E. Wheeler <david.wheeler@iovation.com> 0.82-2
+- Specfile autogenerated by cpanspec 1.78.
diff --git a/etc/templates/deploy/exasol.tmpl b/etc/templates/deploy/exasol.tmpl
new file mode 100644
index 00000000..369d2236
--- /dev/null
+++ b/etc/templates/deploy/exasol.tmpl
@@ -0,0 +1,11 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/deploy/firebird.tmpl b/etc/templates/deploy/firebird.tmpl
new file mode 100644
index 00000000..369d2236
--- /dev/null
+++ b/etc/templates/deploy/firebird.tmpl
@@ -0,0 +1,11 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/deploy/mysql.tmpl b/etc/templates/deploy/mysql.tmpl
new file mode 100644
index 00000000..ef39d2bb
--- /dev/null
+++ b/etc/templates/deploy/mysql.tmpl
@@ -0,0 +1,13 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/deploy/oracle.tmpl b/etc/templates/deploy/oracle.tmpl
new file mode 100644
index 00000000..72adefc8
--- /dev/null
+++ b/etc/templates/deploy/oracle.tmpl
@@ -0,0 +1,9 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+-- XXX Add DDLs here.
diff --git a/etc/templates/deploy/pg.tmpl b/etc/templates/deploy/pg.tmpl
new file mode 100644
index 00000000..ef39d2bb
--- /dev/null
+++ b/etc/templates/deploy/pg.tmpl
@@ -0,0 +1,13 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/deploy/snowflake.tmpl b/etc/templates/deploy/snowflake.tmpl
new file mode 100644
index 00000000..ebb50541
--- /dev/null
+++ b/etc/templates/deploy/snowflake.tmpl
@@ -0,0 +1,11 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+USE WAREHOUSE &warehouse;
+
+-- XXX Add DDLs here.
diff --git a/etc/templates/deploy/sqlite.tmpl b/etc/templates/deploy/sqlite.tmpl
new file mode 100644
index 00000000..ef39d2bb
--- /dev/null
+++ b/etc/templates/deploy/sqlite.tmpl
@@ -0,0 +1,13 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/deploy/vertica.tmpl b/etc/templates/deploy/vertica.tmpl
new file mode 100644
index 00000000..72adefc8
--- /dev/null
+++ b/etc/templates/deploy/vertica.tmpl
@@ -0,0 +1,9 @@
+-- Deploy [% project %]:[% change %] to [% engine %]
+[% FOREACH item IN requires -%]
+-- requires: [% item %]
+[% END -%]
+[% FOREACH item IN conflicts -%]
+-- conflicts: [% item %]
+[% END -%]
+
+-- XXX Add DDLs here.
diff --git a/etc/templates/revert/exasol.tmpl b/etc/templates/revert/exasol.tmpl
new file mode 100644
index 00000000..c2d48048
--- /dev/null
+++ b/etc/templates/revert/exasol.tmpl
@@ -0,0 +1,5 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/revert/firebird.tmpl b/etc/templates/revert/firebird.tmpl
new file mode 100644
index 00000000..c2d48048
--- /dev/null
+++ b/etc/templates/revert/firebird.tmpl
@@ -0,0 +1,5 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/revert/mysql.tmpl b/etc/templates/revert/mysql.tmpl
new file mode 100644
index 00000000..c17751d8
--- /dev/null
+++ b/etc/templates/revert/mysql.tmpl
@@ -0,0 +1,7 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/revert/oracle.tmpl b/etc/templates/revert/oracle.tmpl
new file mode 100644
index 00000000..90ccdeb9
--- /dev/null
+++ b/etc/templates/revert/oracle.tmpl
@@ -0,0 +1,3 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+-- XXX Add DDLs here.
diff --git a/etc/templates/revert/pg.tmpl b/etc/templates/revert/pg.tmpl
new file mode 100644
index 00000000..c17751d8
--- /dev/null
+++ b/etc/templates/revert/pg.tmpl
@@ -0,0 +1,7 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/revert/snowflake.tmpl b/etc/templates/revert/snowflake.tmpl
new file mode 100644
index 00000000..77034b3b
--- /dev/null
+++ b/etc/templates/revert/snowflake.tmpl
@@ -0,0 +1,5 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+USE WAREHOUSE &warehouse;
+
+-- XXX Add DDLs here.
diff --git a/etc/templates/revert/sqlite.tmpl b/etc/templates/revert/sqlite.tmpl
new file mode 100644
index 00000000..c17751d8
--- /dev/null
+++ b/etc/templates/revert/sqlite.tmpl
@@ -0,0 +1,7 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
diff --git a/etc/templates/revert/vertica.tmpl b/etc/templates/revert/vertica.tmpl
new file mode 100644
index 00000000..90ccdeb9
--- /dev/null
+++ b/etc/templates/revert/vertica.tmpl
@@ -0,0 +1,3 @@
+-- Revert [% project %]:[% change %] from [% engine %]
+
+-- XXX Add DDLs here.
diff --git a/etc/templates/verify/exasol.tmpl b/etc/templates/verify/exasol.tmpl
new file mode 100644
index 00000000..7dc84652
--- /dev/null
+++ b/etc/templates/verify/exasol.tmpl
@@ -0,0 +1,5 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+-- XXX Add verifications here.
+
+ROLLBACK;
diff --git a/etc/templates/verify/firebird.tmpl b/etc/templates/verify/firebird.tmpl
new file mode 100644
index 00000000..7dc84652
--- /dev/null
+++ b/etc/templates/verify/firebird.tmpl
@@ -0,0 +1,5 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+-- XXX Add verifications here.
+
+ROLLBACK;
diff --git a/etc/templates/verify/mysql.tmpl b/etc/templates/verify/mysql.tmpl
new file mode 100644
index 00000000..0d002b25
--- /dev/null
+++ b/etc/templates/verify/mysql.tmpl
@@ -0,0 +1,7 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+BEGIN;
+
+-- XXX Add verifications here.
+
+ROLLBACK;
diff --git a/etc/templates/verify/oracle.tmpl b/etc/templates/verify/oracle.tmpl
new file mode 100644
index 00000000..ee2496fb
--- /dev/null
+++ b/etc/templates/verify/oracle.tmpl
@@ -0,0 +1,3 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+-- XXX Add verifications here.
diff --git a/etc/templates/verify/pg.tmpl b/etc/templates/verify/pg.tmpl
new file mode 100644
index 00000000..0d002b25
--- /dev/null
+++ b/etc/templates/verify/pg.tmpl
@@ -0,0 +1,7 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+BEGIN;
+
+-- XXX Add verifications here.
+
+ROLLBACK;
diff --git a/etc/templates/verify/snowflake.tmpl b/etc/templates/verify/snowflake.tmpl
new file mode 100644
index 00000000..066d6248
--- /dev/null
+++ b/etc/templates/verify/snowflake.tmpl
@@ -0,0 +1,5 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+USE WAREHOUSE &warehouse;
+
+-- XXX Add verifications here.
diff --git a/etc/templates/verify/sqlite.tmpl b/etc/templates/verify/sqlite.tmpl
new file mode 100644
index 00000000..0d002b25
--- /dev/null
+++ b/etc/templates/verify/sqlite.tmpl
@@ -0,0 +1,7 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+BEGIN;
+
+-- XXX Add verifications here.
+
+ROLLBACK;
diff --git a/etc/templates/verify/vertica.tmpl b/etc/templates/verify/vertica.tmpl
new file mode 100644
index 00000000..ee2496fb
--- /dev/null
+++ b/etc/templates/verify/vertica.tmpl
@@ -0,0 +1,3 @@
+-- Verify [% project %]:[% change %] on [% engine %]
+
+-- XXX Add verifications here.
diff --git a/etc/tools/upgrade-registry-to-mysql-5.5.0.sql b/etc/tools/upgrade-registry-to-mysql-5.5.0.sql
new file mode 100644
index 00000000..ce65d09f
--- /dev/null
+++ b/etc/tools/upgrade-registry-to-mysql-5.5.0.sql
@@ -0,0 +1,43 @@
+-- This script upgrades the Sqitch registry for MySQL 5.5.0 and higher. It
+-- creates the checkit() function and sets up triggers in the registry to use
+-- it to emulate CHECK constraints. It will then also be available for use in
+-- verify scripts, as described in sqitchtutorial-mysql. If you have an
+-- existing Sqitch registry that was upgraded from an earlier version of MySQL
+-- to 5.5.0 or highher, you'll need to run this script to update it, like so:
+
+-- mysql -u root sqitch --execute "source `sqitch --etc`/tools/upgrade-registry-to-mysql-5.5.0.sql'
+
+DELIMITER |
+
+CREATE FUNCTION checkit(doit INTEGER, message VARCHAR(256)) RETURNS INTEGER DETERMINISTIC
+BEGIN
+ IF doit IS NULL OR doit = 0 THEN
+ SIGNAL SQLSTATE 'ERR0R' SET MESSAGE_TEXT = message;
+ END IF;
+ RETURN doit;
+END;
+|
+
+CREATE TRIGGER ck_insert_dependency BEFORE INSERT ON dependencies
+FOR EACH ROW BEGIN
+ -- DO does not work. https://bugs.mysql.com/bug.php?id=69647
+ SET @dummy := checkit(
+ (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL)
+ OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL),
+ 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set'
+ );
+END;
+|
+
+CREATE TRIGGER ck_update_dependency BEFORE UPDATE ON dependencies
+FOR EACH ROW BEGIN
+ -- DO does not work. https://bugs.mysql.com/bug.php?id=69647
+ SET @dummy := checkit(
+ (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL)
+ OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL),
+ 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set'
+ );
+END;
+|
+
+DELIMITER ;
diff --git a/etc/tools/upgrade-registry-to-mysql-5.6.4.sql b/etc/tools/upgrade-registry-to-mysql-5.6.4.sql
new file mode 100644
index 00000000..ecc68fea
--- /dev/null
+++ b/etc/tools/upgrade-registry-to-mysql-5.6.4.sql
@@ -0,0 +1,15 @@
+-- This script upgrades the Sqitch registry for MySQL 5.6.4 and higher. Sqitch
+-- expects datetime columns to have a precision on 5.6.4 and higher. If you have
+-- an existing Sqitch registry that was upgraded from an earlier version of MySQL
+-- to 5.6.4 or highher, you'll need to run this script to update it, like so:
+
+-- mysql -u root sqitch --execute "source `sqitch --etc`/tools/upgrade-registry-to-mysql-5.6.4.sql'
+
+ALTER TABLE releases CHANGE installed_at installed_at DATETIME(6) NOT NULL;
+ALTER TABLE projects CHANGE created_at created_at DATETIME(6) NOT NULL;
+ALTER TABLE changes CHANGE committed_at committed_at DATETIME(6) NOT NULL,
+ CHANGE planned_at planned_at DATETIME(6) NOT NULL;
+ALTER TABLE tags CHANGE committed_at committed_at DATETIME(6) NOT NULL,
+ CHANGE planned_at planned_at DATETIME(6) NOT NULL;
+ALTER TABLE events CHANGE committed_at committed_at DATETIME(6) NOT NULL,
+ CHANGE planned_at planned_at DATETIME(6) NOT NULL;
diff --git a/inc/Menlo/Sqitch.pm b/inc/Menlo/Sqitch.pm
new file mode 100644
index 00000000..4fc98f25
--- /dev/null
+++ b/inc/Menlo/Sqitch.pm
@@ -0,0 +1,171 @@
+package Menlo::Sqitch;
+
+use strict;
+use warnings;
+use base 'Menlo::CLI::Compat';
+
+sub new {
+ shift->SUPER::new(
+ @_,
+ _remove => [],
+ _bld_deps => { map { chomp; $_ => 1 } <DATA> },
+ );
+}
+
+sub find_prereqs {
+ my ($self, $dist) = @_;
+ # Menlo defaults to config, test, runtime. We just want to bundle runtime.
+ $dist->{want_phases} = ['runtime'];
+ return $self->SUPER::find_prereqs($dist);
+}
+
+sub configure {
+ my $self = shift;
+ my $cmd = $_[0];
+ return $self->SUPER::configure(@_) if ref $cmd ne 'ARRAY';
+ # Always use vendor install dirs. Hack for
+ # https://github.com/miyagawa/cpanminus/issues/581.
+ if ($cmd->[1] eq 'Makefile.PL') {
+ push @{ $cmd } => 'INSTALLDIRS=vendor';
+ } elsif ($cmd->[1] eq 'Build.PL') {
+ push @{ $cmd } => '--installdirs', 'vendor';
+ }
+ return $self->SUPER::configure(@_);
+}
+
+sub save_meta {
+ my $self = shift;
+ my ($module, $dist) = @_;
+ # Record if we've installed a build-only dependency.
+ my $dname = $dist->{meta}{name};
+ push @{ $self->{_remove} } => $module if $self->{_bld_deps}{$dname};
+ $self->SUPER::save_meta(@_);
+}
+
+sub remove_build_dependencies {
+ # Uninstall modules for distributions not actually needed to run Sqitch.
+ my $self = shift;
+ local $self->{force} = 1;
+ my @fail;
+ for my $mod (reverse @{ $self->{_remove} }) {
+ $self->uninstall_module($mod) or push @fail, $mod;
+ }
+ return !@fail;
+}
+
+1;
+
+# List of distirbutions that might be installed but are not actually needed to
+# run Sqitch. Used to track unneeded installs so they can be removed by
+# remove_build_dependencies().
+#
+# Data pasted from the report of build-only dependencies by
+# dev/dependency_report.
+__DATA__
+AppConfig
+Archive-Tar
+CGI
+CPAN
+CPAN-Common-Index
+CPAN-DistnameInfo
+CPAN-Meta
+CPAN-Meta-Check
+CPAN-Meta-Requirements
+CPAN-Meta-YAML
+Capture-Tiny
+Class-Tiny
+Compress-Raw-Bzip2
+Compress-Raw-Zlib
+Config-AutoConf
+Devel-CheckLib
+Devel-PPPort
+Devel-Symdump
+Digest
+Digest-MD5
+Dist-CheckConflicts
+Dumpvalue
+Expect
+ExtUtils-CBuilder
+ExtUtils-Config
+ExtUtils-Constant
+ExtUtils-Helpers
+ExtUtils-Install
+ExtUtils-InstallPaths
+ExtUtils-MakeMaker
+ExtUtils-MakeMaker-CPANfile
+ExtUtils-ParseXS
+File-Fetch
+File-Find-Rule
+File-Find-Rule-Perl
+File-Listing
+File-ShareDir-Install
+File-Slurp-Tiny
+File-pushd
+HTML-Parser
+HTML-Tagset
+HTTP-CookieJar
+HTTP-Cookies
+HTTP-Daemon
+HTTP-Date
+HTTP-Message
+HTTP-Negotiate
+HTTP-Tiny
+HTTP-Tinyish
+IO-CaptureOutput
+IO-Compress
+IO-HTML
+IO-Socket-IP
+IO-Socket-SSL
+IO-Tty
+IO-Zlib
+IPC-Cmd
+IPC-Run
+JSON-PP
+LWP-MediaTypes
+Locale-Maketext-Simple
+Menlo
+Menlo-Legacy
+Mock-Config
+Module-Build
+Module-Build-Tiny
+Module-CPANfile
+Module-CoreList
+Module-Load
+Module-Load-Conditional
+Module-Metadata
+Module-Signature
+Mozilla-CA
+Mozilla-PublicSuffix
+Net-HTTP
+Net-Ping
+Net-SSLeay
+Number-Compare
+Params-Check
+Parse-PMFile
+Perl-Tidy
+Pod-Coverage
+Readonly
+Safe
+Search-Dict
+Sub-Uplevel
+TermReadKey
+Test
+Test-Exception
+Test-Fatal
+Test-Harness
+Test-LeakTrace
+Test-Pod
+Test-Pod-Coverage
+Test-Simple
+Test-Version
+Text-Glob
+Text-ParseWords
+Tie-File
+Tie-Handle-Offset
+TimeDate
+WWW-RobotRules
+Win32-ShellQuote
+YAML
+inc-latest
+libwww-perl
+local-lib
diff --git a/inc/Module/Build/Sqitch.pm b/inc/Module/Build/Sqitch.pm
new file mode 100644
index 00000000..bfe73636
--- /dev/null
+++ b/inc/Module/Build/Sqitch.pm
@@ -0,0 +1,324 @@
+package Module::Build::Sqitch;
+
+use strict;
+use warnings;
+use Module::Build 0.35;
+use base 'Module::Build';
+use IO::File ();
+use File::Spec ();
+use Config ();
+use File::Path ();
+use File::Copy ();
+
+__PACKAGE__->add_property($_) for qw(etcdir installed_etcdir);
+
+# List one more more engines to include in a bundle install.
+# --with postgres --with msyql
+__PACKAGE__->add_property(with => []);
+
+# Set dual_life to true to force dual-life modules such as Pod::Simple to be
+# incliuded in the bundle directory.
+# --dual_life 1
+__PACKAGE__->add_property(dual_life => 0);
+
+sub new {
+ my ( $class, %p ) = @_;
+ if ($^O eq 'MSWin32') {
+ my $recs = $p{recommends} ||= {};
+ $recs->{$_} = 0 for qw(
+ Win32
+ Win32::Console::ANSI
+ Win32API::Net
+ );
+ $p{requires}{'Win32::Locale'} = 0;
+ $p{requires}{'Win32::ShellQuote'} = 0;
+ $p{requires}{'DateTime::TimeZone::Local::Win32'} = 0;
+ }
+ if (eval { require Hash::Merge; 1 } && $Hash::Merge::VERSION eq '0.298') {
+ warn join "\n", (
+ '**************************************************************',
+ '* You have Hash::Merge $Hash::Merge::VERSION, which is broken.',
+ "**************************************************************\n",
+ );
+ $p{requires}{'Hash::Merge'} = '0.299';
+ }
+ my $self = $class->SUPER::new(%p);
+ $self->add_build_element('etc');
+ $self->add_build_element('mo');
+ $self->add_build_element('sql');
+ return $self;
+}
+
+sub _getetc {
+ my $self = shift;
+ my $prefix;
+
+ if ($self->installdirs eq 'site') {
+ $prefix = $Config::Config{siteprefix} // $Config::Config{prefix};
+ } elsif ($self->installdirs eq 'vendor') {
+ $prefix = $Config::Config{vendorprefix} // $Config::Config{siteprefix} // $Config::Config{prefix};
+ } else {
+ $prefix = $Config::Config{prefix};
+ }
+
+ # Prefer the user-specified directory.
+ if (my $etc = $self->etcdir) {
+ return $etc;
+ }
+
+ # Use a directory under the install base (or prefix).
+ my @subdirs = qw(etc sqitch);
+ if ( my $dir = $self->install_base || $self->prefix ) {
+ return File::Spec->catdir( $dir, @subdirs );
+ }
+
+ # Go under Perl's prefix.
+ return File::Spec->catdir( $prefix, @subdirs );
+}
+
+sub ACTION_move_old_templates {
+ my $self = shift;
+ $self->depends_on('build');
+
+ # First, rename existing etc dir templates; They were moved in v0.980.
+ my $notify = 0;
+ my $tmpl_dir = File::Spec->catdir(
+ ( $self->destdir ? $self->destdir : ()),
+ $self->_getetc,
+ 'templates'
+ );
+ if (-e $tmpl_dir && -d _) {
+ # Scan for old templates, but only if we can read the directory.
+ if (opendir my $dh, $tmpl_dir) {
+ while (my $bn = readdir $dh) {
+ next unless $bn =~ /^(deploy|verify|revert)[.]tmpl([.]default)?$/;
+ my ($action, $default) = ($1, $2);
+ my $file = File::Spec->catfile($tmpl_dir, $bn);
+ if ($default) {
+ $self->log_verbose("Unlinking $file\n");
+ # Just unlink default files.
+ unlink $file;
+ next;
+ }
+ # Move action templates to $action/pg.tmpl and $action/sqlite.tmpl.
+ my $action_dir = File::Spec->catdir($tmpl_dir, $action);
+ File::Path::mkpath($action_dir) or die;
+ for my $engine (qw(pg sqlite)) {
+ my $dest = File::Spec->catdir($action_dir, "$engine.tmpl");
+ $self->log_info("Copying old $bn to $dest\n");
+ File::Copy::copy($file, $dest)
+ or die "Cannot copy('$file', '$dest'): $!\n";
+ }
+
+ $self->log_verbose("Unlinking $file\n");
+ unlink $file;
+ $notify = 1;
+ }
+ }
+ }
+
+ # If we moved any files, nofify the user that custom templates will need
+ # to be updated, too.
+ if ($notify) {
+ $self->log_warn(q{
+ #################################################################
+ # WARNING #
+ # #
+ # As of v0.980, the location of script templates has changed. #
+ # The system-wide templates have been moved to their new #
+ # locations as described above. However, user-specific #
+ # templates have not been moved. #
+ # #
+ # Please inform all users that any custom Sqitch templates in #
+ # their ~/.sqitch/templates directories must be moved into #
+ # subdirectories using the appropriate engine name (pg, sqlite, #
+ # or oracle) as follows: #
+ # #
+ # deploy.tmpl -> deploy/$engine.tmpl #
+ # revert.tmpl -> revert/$engine.tmpl #
+ # verify.tmpl -> verify/$engine.tmpl #
+ # #
+ #################################################################
+ } . "\n");
+ }
+}
+
+sub ACTION_install {
+ my ($self, @params) = @_;
+ $self->depends_on('move_old_templates');
+ $self->SUPER::ACTION_install(@_);
+}
+
+sub process_etc_files {
+ my $self = shift;
+ my $etc = $self->_getetc;
+ $self->install_path( etc => $etc );
+
+ if (my $ddir = $self->destdir) {
+ # Need to search the final destination directory.
+ $etc = File::Spec->catdir($ddir, $etc);
+ }
+
+ for my $file ( @{ $self->rscan_dir( 'etc', sub { -f && !/\.\#/ } ) } ) {
+ $file = $self->localize_file_path($file);
+
+ # Remove leading `etc/` to get path relative to $etc.
+ my ($vol, $dirs, $fn) = File::Spec->splitpath($file);
+ my (undef, @segs) = File::Spec->splitdir($dirs);
+ my $rel = File::Spec->catpath($vol, File::Spec->catdir(@segs), $fn);
+
+ my $dest = $file;
+
+ # Append .default if file already exists at its ultimate destination
+ # or if it exists with an old name (to be moved by move_old_templates).
+ if ( -e File::Spec->catfile($etc, $rel) || (
+ $segs[0] eq 'templates'
+ && $fn =~ /^(?:pg|sqlite)[.]tmpl$/
+ && -e File::Spec->catfile($etc, 'templates', "$segs[1].tmpl")
+ ) ) {
+ $dest .= '.default';
+ }
+
+ $self->copy_if_modified(
+ from => $file,
+ to => File::Spec->catfile( $self->blib, $dest )
+ );
+ }
+}
+
+sub process_pm_files {
+ my $self = shift;
+ my $ret = $self->SUPER::process_pm_files(@_);
+ my $pm = File::Spec->catfile(qw(blib lib App Sqitch Config.pm));
+ my $etc = $self->installed_etcdir || $self->_getetc;
+
+ $self->do_system(
+ $self->perl, '-i.bak', '-pe',
+ qq{s{my \\\$SYSTEM_DIR = undef}{my \\\$SYSTEM_DIR = q{\Q$etc\E}}},
+ $pm,
+ );
+ unlink "$pm.bak";
+
+ return $ret;
+}
+
+sub fix_shebang_line {
+ my $self = shift;
+ # Noting to do after 5.10.0.
+ return $self->SUPER::fix_shebang_line(@_) if $] > 5.010000;
+
+ # Remove -C from the shebang line.
+ for my $file (@_) {
+ my $FIXIN = IO::File->new($file) or die "Can't process '$file': $!";
+ local $/ = "\n";
+ chomp(my $line = <$FIXIN>);
+ next unless $line =~ s/^\s*\#!\s*//; # Not a shebang file.
+
+ my ($cmd, $arg) = (split(' ', $line, 2), '');
+ next unless $cmd =~ /perl/i && $arg =~ s/ -C\w+//;
+
+ # We removed -C; write the file out.
+ my $FIXOUT = IO::File->new(">$file.new")
+ or die "Can't create new $file: $!\n";
+ local $\;
+ undef $/; # Was localized above
+ print $FIXOUT "#!$cmd $arg", <$FIXIN>;
+ close $FIXIN;
+ close $FIXOUT;
+
+ rename($file, "$file.bak")
+ or die "Can't rename $file to $file.bak: $!";
+
+ rename("$file.new", $file)
+ or die "Can't rename $file.new to $file: $!";
+
+ $self->delete_filetree("$file.bak")
+ or $self->log_warn("Couldn't clean up $file.bak, leaving it there");
+ }
+
+ # Back at it now.
+ return $self->SUPER::fix_shebang_line(@_);
+}
+
+sub ACTION_bundle {
+ my ($self, @params) = @_;
+ my $base = $self->install_base or die "No --install_base specified\n";
+
+ # XXX Consider replacing with a Carton or Carmel-based solution?
+ SHHH: {
+ local $SIG{__WARN__} = sub {}; # Menlo has noisy warnings.
+ local $ENV{PERL_CPANM_OPT}; # Override cpanm options.
+ require Menlo::Sqitch;
+ my $feat = $self->with || [];
+ $feat = [$feat] unless ref $feat;
+ my $app = Menlo::Sqitch->new(
+ quiet => $self->quiet,
+ verbose => $self->verbose,
+ notest => 1,
+ self_contained => 1,
+ skip_installed => 0,
+ install_types => [qw(requires recommends)],
+ local_lib => File::Spec->rel2abs($base),
+ pod2man => undef,
+ features => { map { $_ => 1 } @{ $feat } },
+ );
+
+ if ($self->dual_life) {
+ # Force Install dual-life modules.
+ $app->{argv} = [qw(
+ File::Temp Scalar::Util Pod::Usage Digest::SHA Pod::Escapes
+ Pod::Find Getopt::Long Time::HiRes File::Path List::Util
+ Encode Pod::Simple Time::Local parent IO::File if
+ Term::ANSIColor
+ )];
+ die "Error installing modules: $@\n" if $app->run;
+ }
+
+ # Install required modules, but not Sqitch itself.
+ $app->{argv} = ['.'];
+ $app->{installdeps} = 1;
+ die "Error installing modules: $@\n" if $app->run;
+
+ # Remove unneeded build-time dependencies.
+ die "Error removing build modules: $@\n"
+ unless $app->remove_build_dependencies;
+ }
+
+ # Install Sqitch. Required to intall man pages.
+ $self->depends_on('install');
+
+ # Delete unneeded files.
+ $self->delete_filetree(File::Spec->catdir($base, qw(lib perl5 Test)));
+ $self->delete_filetree(File::Spec->catdir($base, qw(bin)));
+ for my $file (@{ $self->rscan_dir($base, qr/[.](?:meta|packlist)$/) }) {
+ $self->delete_filetree($file);
+ }
+
+ # Install sqitch script using FindBin.
+ $self->_copy_findbin_script;
+
+ # Delete empty directories.
+ File::Find::finddepth(sub{rmdir},$base);
+}
+
+sub _copy_findbin_script {
+ my $self = shift;
+ # XXX Switch to lib/perl5.
+ my $bin = $self->install_destination('script');
+ my $script = File::Spec->catfile(qw(bin sqitch));
+ my $dest = File::Spec->catfile($bin, 'sqitch');
+ my $result = $self->copy_if_modified($script, $bin, 'flatten') or return;
+ $self->fix_shebang_line($result) unless $self->is_vmsish;
+ $self->_set_findbin($result);
+ $self->make_executable($result);
+}
+
+sub _set_findbin {
+ my ($self, $file) = @_;
+ local $^I = '';
+ local @ARGV = ($file);
+ while (<>) {
+ s{^BEGIN}{use FindBin;\nuse lib "\$FindBin::RealBin/../lib/perl5";\nBEGIN};
+ print;
+ }
+}
diff --git a/lib/App/Sqitch.pm b/lib/App/Sqitch.pm
new file mode 100644
index 00000000..139d7837
--- /dev/null
+++ b/lib/App/Sqitch.pm
@@ -0,0 +1,927 @@
+package App::Sqitch;
+
+# ABSTRACT: Sensible database change management
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Getopt::Long;
+use Hash::Merge qw(merge);
+use Path::Class;
+use Config;
+use Locale::TextDomain 1.20 qw(App-Sqitch);
+use Locale::Messages qw(bind_textdomain_filter);
+use App::Sqitch::X qw(hurl);
+use Moo 1.002000;
+use Type::Utils qw(where declare);
+use App::Sqitch::Types qw(Str UserName UserEmail Maybe Config HashRef);
+use Encode ();
+use Try::Tiny;
+use List::Util qw(first);
+use IPC::System::Simple 1.17 qw(runx capturex $EXITVAL);
+use namespace::autoclean 0.16;
+use constant ISWIN => $^O eq 'MSWin32';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+BEGIN {
+ # Force Locale::TextDomain to encode in UTF-8 and to decode all messages.
+ $ENV{OUTPUT_CHARSET} = 'UTF-8';
+ bind_textdomain_filter 'App-Sqitch' => \&Encode::decode_utf8, Encode::FB_DEFAULT;
+}
+
+# Okay to load Sqitch classes now that types are created.
+use App::Sqitch::Config;
+use App::Sqitch::Command;
+use App::Sqitch::Plan;
+
+has options => (
+ is => 'ro',
+ isa => HashRef,
+ default => sub { {} },
+);
+
+has verbosity => (
+ is => 'ro',
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->options->{verbosity} // $self->config->get( key => 'core.verbosity' ) // 1;
+ }
+);
+
+has sysuser => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ default => sub {
+ $ENV{ SQITCH_ORIG_SYSUSER } || do {
+ # Adapted from User.pm.
+ require Encode::Locale;
+ return Encode::decode( locale => getlogin )
+ || Encode::decode( locale => scalar getpwuid( $< ) )
+ || $ENV{ LOGNAME }
+ || $ENV{ USER }
+ || $ENV{ USERNAME }
+ || try {
+ require Win32;
+ Encode::decode( locale => Win32::LoginName() )
+ };
+ };
+ },
+);
+
+has user_name => (
+ is => 'ro',
+ lazy => 1,
+ isa => UserName,
+ default => sub {
+ my $self = shift;
+ $ENV{ SQITCH_FULLNAME }
+ || $self->config->get( key => 'user.name' )
+ || $ENV{ SQITCH_ORIG_FULLNAME }
+ || do {
+ my $sysname = $self->sysuser || hurl user => __(
+ 'Cannot find your name; run sqitch config --user user.name "YOUR NAME"'
+ );
+ if (ISWIN) {
+ try { require Win32API::Net } || return $sysname;
+ Win32API::Net::UserGetInfo( "", $sysname, 10, my $info = {} );
+ return $sysname unless $info->{fullName};
+ require Encode::Locale;
+ return Encode::decode( locale => $info->{fullName} );
+ }
+ require User::pwent;
+ my $name = User::pwent::getpwnam($sysname) || return $sysname;
+ require Encode::Locale;
+ return Encode::decode( locale => ($name->gecos)[0] );
+ };
+ }
+);
+
+has user_email => (
+ is => 'ro',
+ lazy => 1,
+ isa => UserEmail,
+ default => sub {
+ my $self = shift;
+ $ENV{ SQITCH_EMAIL }
+ || $self->config->get( key => 'user.email' )
+ || $ENV{ SQITCH_ORIG_EMAIL }
+ || do {
+ my $sysname = $self->sysuser || hurl user => __(
+ 'Cannot infer your email address; run sqitch config --user user.email you@host.com'
+ );
+ require Sys::Hostname;
+ "$sysname@" . Sys::Hostname::hostname();
+ };
+ }
+);
+
+has config => (
+ is => 'ro',
+ isa => Config,
+ lazy => 1,
+ default => sub {
+ App::Sqitch::Config->new;
+ }
+);
+
+has editor => (
+ is => 'ro',
+ lazy => 1,
+ default => sub {
+ return
+ $ENV{SQITCH_EDITOR}
+ || shift->config->get( key => 'core.editor' )
+ || $ENV{VISUAL}
+ || $ENV{EDITOR}
+ || ( ISWIN ? 'notepad.exe' : 'vi' );
+ }
+);
+
+has pager_program => (
+ is => "ro",
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ return
+ $ENV{SQITCH_PAGER}
+ || $self->config->get(key => "core.pager")
+ || $ENV{PAGER};
+ },
+);
+
+has pager => (
+ is => 'ro',
+ lazy => 1,
+ isa => declare('Pager', where {
+ eval { $_->isa('IO::Pager') || $_->isa('IO::Handle') }
+ }),
+ default => sub {
+ # Dupe and configure STDOUT.
+ require IO::Handle;
+ my $fh = IO::Handle->new_from_fd(*STDOUT, 'w');
+ binmode $fh, ':utf8_strict';
+
+ # Just return if no pager is wanted or there is no TTY.
+ return $fh if shift->options->{no_pager} || !(-t *STDOUT);
+
+ # Load IO::Pager and tie the handle to it.
+ eval "use IO::Pager 0.34"; die $@ if $@;
+ return IO::Pager->new($fh, ':utf8_strict');
+ },
+);
+
+sub go {
+ my $class = shift;
+ my @args = @ARGV;
+
+ # 1. Parse core options.
+ my $opts = $class->_parse_core_opts(\@args);
+
+ # 2. Load config.
+ my $config = App::Sqitch::Config->new;
+
+ # 3. Instantiate Sqitch.
+ my $sqitch = $class->new({ options => $opts, config => $config });
+
+ # 4. Find the command.
+ my $cmd = $class->_find_cmd(\@args);
+
+ # 5. Instantiate the command object.
+ my $command = $cmd->create({
+ sqitch => $sqitch,
+ config => $config,
+ args => \@args,
+ });
+
+ # IO::Pager respects the PAGER environment variable.
+ local $ENV{PAGER} = $sqitch->pager_program;
+
+ # 6. Execute command.
+ return try {
+ $command->execute( @args ) ? 0 : 2;
+ } catch {
+ # Just bail for unknown exceptions.
+ $sqitch->vent($_) && return 2 unless eval { $_->isa('App::Sqitch::X') };
+
+ # It's one of ours.
+ if ($_->exitval == 1) {
+ # Non-fatal exception; just send the message to info.
+ $sqitch->info($_->message);
+ } else {
+ # Fatal exception; vent.
+ $sqitch->vent($_->message);
+
+ # Emit the stack trace. DEV errors should be vented; otherwise trace.
+ my $meth = $_->ident eq 'DEV' ? 'vent' : 'trace';
+ $sqitch->$meth($_->stack_trace->as_string);
+ }
+
+ # Bail.
+ return $_->exitval;
+ };
+}
+
+sub _core_opts {
+ return qw(
+ chdir|cd|C=s
+ etc-path
+ no-pager
+ quiet
+ verbose|V|v+
+ help
+ man
+ version
+ );
+}
+
+sub _parse_core_opts {
+ my ( $self, $args ) = @_;
+ my %opts;
+ Getopt::Long::Configure(qw(bundling pass_through));
+ Getopt::Long::GetOptionsFromArray(
+ $args,
+ map {
+ ( my $k = $_ ) =~ s/[|=+:!].*//;
+ $k =~ s/-/_/g;
+ $_ => \$opts{$k};
+ } $self->_core_opts
+ ) or $self->_pod2usage('sqitchusage', '-verbose' => 99 );
+
+ # Handle documentation requests.
+ if ($opts{help} || $opts{man}) {
+ $self->_pod2usage(
+ $opts{help} ? 'sqitchcommands' : 'sqitch',
+ '-exitval' => 0,
+ '-verbose' => 2,
+ );
+ }
+
+ # Handle version request.
+ if ( delete $opts{version} ) {
+ require File::Basename;
+ my $fn = File::Basename::basename($0);
+ print $fn, ' (', __PACKAGE__, ') ', __PACKAGE__->VERSION, "\n";
+ exit;
+ }
+
+ # Handle --etc-path.
+ if ( $opts{etc_path} ) {
+ say App::Sqitch::Config->class->system_dir;
+ exit;
+ }
+
+ # Handle --chdir
+ if ( my $dir = delete $opts{chdir} ) {
+ chdir $dir or hurl fs => __x(
+ 'Cannot change to directory {directory}: {error}',
+ directory => $dir,
+ error => $!,
+ );
+ }
+
+ # Normalize the options (remove undefs) and return.
+ $opts{verbosity} = delete $opts{verbose};
+ $opts{verbosity} = 0 if delete $opts{quiet};
+ delete $opts{$_} for grep { !defined $opts{$_} } keys %opts;
+ return \%opts;
+}
+
+sub _find_cmd {
+ my ( $class, $args ) = @_;
+ my (@tried, $prev);
+ for (my $i = 0; $i <= $#$args; $i++) {
+ my $arg = $args->[$i] or next;
+ if ($arg =~ /^-/) {
+ last if $arg eq '--';
+ # Skip the next argument if this looks like a pre-0.9999 option.
+ # There shouldn't be many since we now recommend putting options
+ # after the command. XXX Remove at some future date.
+ $i++ if $arg =~ /^(?:-[duhp])|(?:--(?:db-\w+|client|engine|extension|plan-file|registry|top-dir))$/;
+ next;
+ }
+ push @tried => $arg;
+ my $cmd = try { App::Sqitch::Command->class_for($class, $arg) } or next;
+ splice @{ $args }, $i, 1;
+ return $cmd;
+ }
+
+ # No valid command found. Report those we tried.
+ $class->vent(__x(
+ '"{command}" is not a valid command',
+ command => $_,
+ )) for @tried;
+ $class->_pod2usage('sqitchcommands');
+}
+
+sub _pod2usage {
+ my ( $self, $doc ) = ( shift, shift );
+ require App::Sqitch::Command::help;
+ # Help does not need the Sqitch command; since it's required, fake it.
+ my $help = App::Sqitch::Command::help->new( sqitch => bless {}, $self );
+ $help->find_and_show( $doc || 'sqitch', '-exitval' => 2, @_ );
+}
+
+sub run {
+ my $self = shift;
+ local $SIG{__DIE__} = sub {
+ ( my $msg = shift ) =~ s/\s+at\s+.+/\n/ms;
+ die $msg;
+ };
+ if (ISWIN && IPC::System::Simple->VERSION <= 1.25) {
+ runx ( shift, $self->quote_shell(@_) );
+ return $self;
+ }
+ runx @_;
+ return $self;
+}
+
+sub shell {
+ my ($self, $cmd) = @_;
+ local $SIG{__DIE__} = sub {
+ ( my $msg = shift ) =~ s/\s+at\s+.+/\n/ms;
+ die $msg;
+ };
+ IPC::System::Simple::run $cmd;
+ return $self;
+}
+
+sub quote_shell {
+ my $self = shift;
+ if (ISWIN) {
+ require Win32::ShellQuote;
+ return Win32::ShellQuote::quote_native(@_);
+ }
+ require String::ShellQuote;
+ return String::ShellQuote::shell_quote(@_);
+}
+
+sub capture {
+ my $self = shift;
+ local $SIG{__DIE__} = sub {
+ ( my $msg = shift ) =~ s/\s+at\s+.+/\n/ms;
+ die $msg;
+ };
+ return capturex ( shift, $self->quote_shell(@_) )
+ if ISWIN && IPC::System::Simple->VERSION <= 1.25;
+ capturex @_;
+}
+
+sub _is_interactive {
+ return -t STDIN && (-t STDOUT || !(-f STDOUT || -c STDOUT)) ; # Pipe?
+}
+
+sub _is_unattended {
+ my $self = shift;
+ return !$self->_is_interactive && eof STDIN;
+}
+
+sub _readline {
+ my $self = shift;
+ return undef if $self->_is_unattended;
+ my $answer = <STDIN>;
+ chomp $answer if defined $answer;
+ return $answer;
+}
+
+sub prompt {
+ my $self = shift;
+ my $msg = shift or hurl 'prompt() called without a prompt message';
+
+ # use a list to distinguish a default of undef() from no default
+ my @def;
+ @def = (shift) if @_;
+ # use dispdef for output
+ my @dispdef = scalar(@def)
+ ? ('[', (defined($def[0]) ? $def[0] : ''), '] ')
+ : ('', '');
+
+ # Don't use emit because it adds a newline.
+ local $|=1;
+ print $msg, ' ', @dispdef;
+
+ if ($self->_is_unattended) {
+ hurl io => __(
+ 'Sqitch seems to be unattended and there is no default value for this question'
+ ) unless @def;
+ print "$dispdef[1]\n";
+ }
+
+ my $ans = $self->_readline;
+
+ if ( !defined $ans or !length $ans ) {
+ # Ctrl-D or user hit return;
+ $ans = @def ? $def[0] : '';
+ }
+
+ return $ans;
+}
+
+sub ask_yes_no {
+ my ($self, @msg) = (shift, shift);
+ hurl 'ask_yes_no() called without a prompt message' unless $msg[0];
+
+ my $y = __p 'Confirm prompt answer yes', 'Yes';
+ my $n = __p 'Confirm prompt answer no', 'No';
+ push @msg => $_[0] ? $y : $n if @_;
+
+ my $answer;
+ my $i = 3;
+ while ($i--) {
+ $answer = $self->prompt(@msg);
+ return 1 if $y =~ /^\Q$answer/i;
+ return 0 if $n =~ /^\Q$answer/i;
+ $self->emit(__ 'Please answer "y" or "n".');
+ }
+
+ hurl io => __ 'No valid answer after 3 attempts; aborting';
+}
+
+sub ask_y_n {
+ my $self = shift;
+ $self->warn('The ask_y_n() method has been deprecated. Use ask_yes_no() instead.');
+ return $self->ask_yes_no(@_) unless @_ > 1;
+
+ my ($msg, $def) = @_;
+ hurl 'Invalid default value: ask_y_n() default must be "y" or "n"'
+ if $def && $def !~ /^[yn]/i;
+ return $self->ask_yes_no($msg, $def =~ /^y/i ? 1 : 0);
+}
+
+sub spool {
+ my ($self, $fh) = (shift, shift);
+ local $SIG{__WARN__} = sub { }; # Silence warning.
+ my $pipe;
+ if (ISWIN) {
+ no warnings;
+ open $pipe, '|' . $self->quote_shell(@_) or hurl io => __x(
+ 'Cannot exec {command}: {error}',
+ command => $_[0],
+ error => $!,
+ );
+ } else {
+ no warnings;
+ open $pipe, '|-', @_ or hurl io => __x(
+ 'Cannot exec {command}: {error}',
+ command => $_[0],
+ error => $!,
+ );
+ }
+
+ local $SIG{PIPE} = sub { die 'spooler pipe broke' };
+ if (ref $fh eq 'ARRAY') {
+ for my $h (@{ $fh }) {
+ print $pipe $_ while <$h>;
+ }
+ } else {
+ print $pipe $_ while <$fh>;
+ }
+
+ close $pipe or hurl io => $! ? __x(
+ 'Error closing pipe to {command}: {error}',
+ command => $_[0],
+ error => $!,
+ ) : __x(
+ '{command} unexpectedly returned exit value {exitval}',
+ command => $_[0],
+ exitval => ($? >> 8),
+ );
+ return $self;
+}
+
+sub probe {
+ my ($ret) = shift->capture(@_);
+ chomp $ret if $ret;
+ return $ret;
+}
+
+sub _bn {
+ require File::Basename;
+ File::Basename::basename($0);
+}
+
+sub _prepend {
+ my $prefix = shift;
+ my $msg = join '', map { $_ // '' } @_;
+ $msg =~ s/^/$prefix /gms;
+ return $msg;
+}
+
+sub page {
+ my $pager = shift->pager;
+ return $pager->say(@_);
+}
+
+sub page_literal {
+ my $pager = shift->pager;
+ return $pager->print(@_);
+}
+
+sub trace {
+ my $self = shift;
+ $self->emit( _prepend 'trace:', @_ ) if $self->verbosity > 2;
+}
+
+sub trace_literal {
+ my $self = shift;
+ $self->emit_literal( _prepend 'trace:', @_ ) if $self->verbosity > 2;
+}
+
+sub debug {
+ my $self = shift;
+ $self->emit( _prepend 'debug:', @_ ) if $self->verbosity > 1;
+}
+
+sub debug_literal {
+ my $self = shift;
+ $self->emit_literal( _prepend 'debug:', @_ ) if $self->verbosity > 1;
+}
+
+sub info {
+ my $self = shift;
+ $self->emit(@_) if $self->verbosity;
+}
+
+sub info_literal {
+ my $self = shift;
+ $self->emit_literal(@_) if $self->verbosity;
+}
+
+sub comment {
+ my $self = shift;
+ $self->emit( _prepend '#', @_ );
+}
+
+sub comment_literal {
+ my $self = shift;
+ $self->emit_literal( _prepend '#', @_ );
+}
+
+sub emit {
+ shift;
+ local $|=1;
+ say @_;
+}
+
+sub emit_literal {
+ shift;
+ local $|=1;
+ print @_;
+}
+
+sub vent {
+ shift;
+ my $fh = select;
+ select STDERR;
+ local $|=1;
+ say STDERR @_;
+ select $fh;
+}
+
+sub vent_literal {
+ shift;
+ my $fh = select;
+ select STDERR;
+ local $|=1;
+ print STDERR @_;
+ select $fh;
+}
+
+sub warn {
+ my $self = shift;
+ $self->vent(_prepend 'warning:', @_);
+}
+
+sub warn_literal {
+ my $self = shift;
+ $self->vent_literal(_prepend 'warning:', @_);
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch - Sensible database change management
+
+=head1 Synopsis
+
+ use App::Sqitch;
+ exit App::Sqitch->go;
+
+=head1 Description
+
+This module provides the implementation for L<sqitch>. You probably want to
+read L<its documentation|sqitch>, or L<the tutorial|sqitchtutorial>. Unless
+you want to hack on Sqitch itself, or provide support for a new engine or
+L<command|Sqitch::App::Command>. In which case, you will find this API
+documentation useful.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<go>
+
+ App::Sqitch->go;
+
+Called from C<sqitch>, this class method parses command-line options and
+arguments in C<@ARGV>, parses the configuration file, constructs an
+App::Sqitch object, constructs a command object, and runs it.
+
+=head2 Constructor
+
+=head3 C<new>
+
+ my $sqitch = App::Sqitch->new(\%params);
+
+Constructs and returns a new Sqitch object. The supported parameters include:
+
+=over
+
+=item C<options>
+
+=item C<user_name>
+
+=item C<user_email>
+
+=item C<editor>
+
+=item C<verbosity>
+
+=back
+
+=head2 Accessors
+
+=head3 C<user_name>
+
+=head3 C<user_email>
+
+=head3 C<editor>
+
+=head3 C<options>
+
+ my $options = $sqitch->options;
+
+Returns a hashref of the core command-line options.
+
+=head3 C<config>
+
+ my $config = $sqitch->config;
+
+Returns the full configuration, combined from the project, user, and system
+configuration files.
+
+=head3 C<verbosity>
+
+=head2 Instance Methods
+
+=head3 C<run>
+
+ $sqitch->run('echo', '-n', 'hello');
+
+Runs a system command and waits for it to finish. Throws an exception on
+error. Does not use the shell, so arguments must be passed as a list. Use
+C<shell> to run a command and its arguments as a single string.
+
+=over
+
+=item C<target>
+
+The name of the target, as passed.
+
+=item C<uri>
+
+A L<database URI|URI::db> object, to be used to connect to the target
+database.
+
+
+=item C<registry>
+
+The name of the Sqitch registry in the target database.
+
+=back
+
+If the C<$target> argument looks like a database URI, it will simply returned
+in the hash reference. If the C<$target> argument corresponds to a target
+configuration key, the target configuration will be returned, with the C<uri>
+value a upgraded to a L<URI> object. Otherwise returns C<undef>.
+
+=head3 C<shell>
+
+ $sqitch->shell('echo -n hello');
+
+Shells out a system command and waits for it to finish. Throws an exception on
+error. Always uses the shell, so a single string must be passed encapsulating
+the entire command and its arguments. Use C<quote_shell> to assemble strings
+into a single shell command. Use C<run> to execute a list without a shell.
+
+=head3 C<quote_shell>
+
+ my $cmd = $sqitch->quote_shell('echo', '-n', 'hello');
+
+Assemble a list into a single string quoted for execution by C<shell>. Useful
+for combining a specified command, such as C<editor()>, which might include
+the options in the string, for example:
+
+ $sqitch->shell( $sqitch->editor, $sqitch->quote_shell($file) );
+
+=head3 C<capture>
+
+ my @files = $sqitch->capture(qw(ls -lah));
+
+Runs a system command and captures its output to C<STDOUT>. Returns the output
+lines in list context and the concatenation of the lines in scalar context.
+Throws an exception on error.
+
+=head3 C<probe>
+
+ my $git_version = $sqitch->capture(qw(git --version));
+
+Like C<capture>, but returns just the C<chomp>ed first line of output.
+
+=head3 C<spool>
+
+ $sqitch->spool($sql_file_handle, 'sqlite3', 'my.db');
+ $sqitch->spool(\@file_handles, 'sqlite3', 'my.db');
+
+Like run, but spools the contents of one or ore file handle to the standard
+input the system command. Returns true on success and throws an exception on
+failure.
+
+=head3 C<trace>
+
+=head3 C<trace_literal>
+
+ $sqitch->trace_literal('About to fuzzle the wuzzle.');
+ $sqitch->trace('Done.');
+
+Send trace information to C<STDOUT> if the verbosity level is 3 or higher.
+Trace messages will have C<trace: > prefixed to every line. If it's lower than
+3, nothing will be output. C<trace> appends a newline to the end of the
+message while C<trace_literal> does not.
+
+=head3 C<debug>
+
+=head3 C<debug_literal>
+
+ $sqitch->debug('Found snuggle in the crib.');
+ $sqitch->debug_literal('ITYM "snuggie".');
+
+Send debug information to C<STDOUT> if the verbosity level is 2 or higher.
+Debug messages will have C<debug: > prefixed to every line. If it's lower than
+2, nothing will be output. C<debug> appends a newline to the end of the
+message while C<debug_literal> does not.
+
+=head3 C<info>
+
+=head3 C<info_literal>
+
+ $sqitch->info('Nothing to deploy (up-to-date)');
+ $sqitch->info_literal('Going to frobble the shiznet.');
+
+Send informational message to C<STDOUT> if the verbosity level is 1 or higher,
+which, by default, it is. Should be used for normal messages the user would
+normally want to see. If verbosity is lower than 1, nothing will be output.
+C<info> appends a newline to the end of the message while C<info_literal> does
+not.
+
+=head3 C<comment>
+
+=head3 C<comment_literal>
+
+ $sqitch->comment('On database flipr_test');
+ $sqitch->comment_literal('Uh-oh...');
+
+Send comments to C<STDOUT> if the verbosity level is 1 or higher, which, by
+default, it is. Comments have C<# > prefixed to every line. If verbosity is
+lower than 1, nothing will be output. C<comment> appends a newline to the end
+of the message while C<comment_literal> does not.
+
+=head3 C<emit>
+
+=head3 C<emit_literal>
+
+ $sqitch->emit('core.editor=emacs');
+ $sqitch->emit_literal('Getting ready...');
+
+Send a message to C<STDOUT>, without regard to the verbosity. Should be used
+only if the user explicitly asks for output, such as for C<sqitch config --get
+core.editor>. C<emit> appends a newline to the end of the message while
+C<emit_literal> does not.
+
+=head3 C<vent>
+
+=head3 C<vent_literal>
+
+ $sqitch->vent('That was a misage.');
+ $sqitch->vent_literal('This is going to be bad...');
+
+Send a message to C<STDERR>, without regard to the verbosity. Should be used
+only for error messages to be printed before exiting with an error, such as
+when reverting failed changes. C<vent> appends a newline to the end of the
+message while C<vent_literal> does not.
+
+=head3 C<page>
+
+=head3 C<page_literal>
+
+ $sqitch->page('Search results:');
+ $sqitch->page("Here we go\n");
+
+Like C<emit()>, but sends the output to a pager handle rather than C<STDOUT>.
+Unless there is no TTY (such as when output is being piped elsewhere), in
+which case it I<is> sent to C<STDOUT>. C<page> appends a newline to the end of
+the message while C<page_literal> does not. Meant to be used to send a lot of
+data to the user at once, such as when display the results of searching the
+event log:
+
+ $iter = $engine->search_events;
+ while ( my $change = $iter->() ) {
+ $sqitch->page(join ' - ', @{ $change }{ qw(change_id event change) });
+ }
+
+=head3 C<warn>
+
+=head3 C<warn_literal>
+
+ $sqitch->warn('Could not find nerble; using nobble instead.');
+ $sqitch->warn_literal("Cannot read file: $!\n");
+
+Send a warning messages to C<STDERR>. Warnings will have C<warning: > prefixed
+to every line. Use if something unexpected happened but you can recover from
+it. C<warn> appends a newline to the end of the message while C<warn_literal>
+does not.
+
+=head3 C<prompt>
+
+ my $ans = $sqitch->('Why would you want to do this?', 'because');
+
+Prompts the user for input and returns that input. Pass in an optional default
+value for the user to accept or to be used if Sqitch is running unattended. An
+exception will be thrown if there is no prompt message or if Sqitch is
+unattended and there is no default value.
+
+=head3 C<ask_yes_no>
+
+ if ( $sqitch->ask_yes_no('Are you sure?', 1) ) { # do it! }
+
+Prompts the user with a "yes" or "no" question. Returns true if the user
+replies in the affirmative and false if the reply is in the negative. If the
+optional second argument is passed and true, the answer will default to the
+affirmative. If the second argument is passed but false, the answer will
+default to the negative. When a translation library is in use, the affirmative
+and negative replies from the user should be localized variants of "yes" and
+"no", and will be matched as such. If no translation library is in use, the
+answers will default to the English "yes" and "no".
+
+If the user inputs an invalid value three times, an exception will be thrown.
+An exception will also be thrown if there is no message. As with C<prompt()>,
+an exception will be thrown if Sqitch is running unattended and there is no
+default.
+
+=head3 C<ask_y_n>
+
+This method has been deprecated in favor of C<ask_yes_no()> and will be
+removed in a future version of Sqitch.
+
+
+=head2 Constants
+
+=head3 C<ISWIN>
+
+ my $app = 'sqitch' . ( ISWIN ? '.bat' : '' );
+
+True when Sqitch is running on Windows, and false when it's not.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command.pm b/lib/App/Sqitch/Command.pm
new file mode 100644
index 00000000..a80fc553
--- /dev/null
+++ b/lib/App/Sqitch/Command.pm
@@ -0,0 +1,774 @@
+package App::Sqitch::Command;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Try::Tiny;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Hash::Merge 'merge';
+use Moo;
+use App::Sqitch::Types qw(Sqitch Target);
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+use constant ENGINES => qw(
+ pg
+ sqlite
+ mysql
+ oracle
+ firebird
+ vertica
+ exasol
+ snowflake
+);
+
+has sqitch => (
+ is => 'ro',
+ isa => Sqitch,
+ required => 1,
+ handles => [qw(
+ run
+ shell
+ quote_shell
+ capture
+ probe
+ verbosity
+ trace
+ trace_literal
+ debug
+ debug_literal
+ info
+ info_literal
+ comment
+ comment_literal
+ emit
+ emit_literal
+ vent
+ vent_literal
+ warn
+ warn_literal
+ page
+ page_literal
+ prompt
+ ask_y_n
+ )],
+);
+
+has default_target => (
+ is => 'ro',
+ isa => Target,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my @params = $self->target_params;
+ unless (
+ $sqitch->config->get(key => 'core.engine')
+ || $sqitch->config->get(key => 'core.target')
+ ) {
+ # No specified engine, so specify an engineless URI.
+ require URI::db;
+ unshift @params, uri => URI::db->new('db:');
+ }
+ require App::Sqitch::Target;
+ return App::Sqitch::Target->new(@params);
+ },
+);
+
+sub command {
+ my $class = ref $_[0] || shift;
+ return '' if $class eq __PACKAGE__;
+ my $pkg = quotemeta __PACKAGE__;
+ $class =~ s/^$pkg\:://;
+ $class =~ s/_/-/g;
+ return $class;
+}
+
+sub class_for {
+ my ( $class, $sqitch, $cmd ) = @_;
+
+ $cmd =~ s/-/_/g;
+
+ # Load the command class.
+ my $pkg = __PACKAGE__ . "::$cmd";
+ eval "require $pkg; 1" or do {
+ # Emit the original error for debugging.
+ $sqitch->debug($@);
+ return undef;
+ };
+ return $pkg;
+}
+
+sub load {
+ my ( $class, $p ) = @_;
+ # We should have a command.
+ my $cmd = delete $p->{command} or $class->usage;
+ my $pkg = $class->class_for($p->{sqitch}, $cmd) or hurl {
+ ident => 'command',
+ exitval => 1,
+ message => __x(
+ '"{command}" is not a valid command',
+ command => $cmd,
+ ),
+ };
+ $pkg->create($p);
+}
+
+sub create {
+ my ( $class, $p ) = @_;
+
+ # Merge the command-line options and configuration parameters
+ my $params = $class->configure(
+ $p->{config},
+ $class->_parse_opts( $p->{args} )
+ );
+
+ # Instantiate and return the command.
+ $params->{sqitch} = $p->{sqitch};
+ return $class->new($params);
+}
+
+sub configure {
+ my ( $class, $config, $options ) = @_;
+
+ return Hash::Merge->new->merge(
+ $options,
+ $config->get_section( section => $class->command ),
+ );
+}
+
+sub options {
+ return;
+}
+
+sub _parse_opts {
+ my ( $class, $args ) = @_;
+ return {} unless $args && @{$args};
+
+ my %opts;
+ Getopt::Long::Configure(qw(bundling no_pass_through));
+ Getopt::Long::GetOptionsFromArray( $args, \%opts, $class->options )
+ or $class->usage;
+
+ # Convert dashes to underscores.
+ for my $k (keys %opts) {
+ next unless ( my $nk = $k ) =~ s/-/_/g;
+ $opts{$nk} = delete $opts{$k};
+ }
+
+ return \%opts;
+}
+
+sub _bn {
+ require File::Basename;
+ File::Basename::basename($0);
+}
+
+sub _pod2usage {
+ my ( $self, %params ) = @_;
+ my $command = $self->command;
+ require Pod::Find;
+ require Pod::Usage;
+ my $bn = _bn;
+ my $find_pod = sub {
+ Pod::Find::pod_where({ '-inc' => 1, '-script' => 1 }, shift );
+ };
+ $params{'-input'} ||= $find_pod->("$bn-$command")
+ || $find_pod->("sqitch-$command")
+ || $find_pod->($bn)
+ || $find_pod->('sqitch')
+ || $find_pod->(ref $self || $self)
+ || $find_pod->(__PACKAGE__);
+ Pod::Usage::pod2usage(
+ '-verbose' => 99,
+ '-sections' => '(?i:(Usage|Synopsis|Options))',
+ '-exitval' => 2,
+ %params,
+ );
+}
+
+sub execute {
+ my $self = shift;
+ hurl(
+ 'The execute() method must be called from a subclass of '
+ . __PACKAGE__
+ ) if ref $self eq __PACKAGE__;
+
+ hurl 'The execute() method has not been overridden in ' . ref $self;
+}
+
+sub usage {
+ my $self = shift;
+ require Pod::Find;
+ my $upod = _bn . '-' . $self->command . '-usage';
+ $self->_pod2usage(
+ '-input' => Pod::Find::pod_where( { '-inc' => 1 }, $upod ) || undef,
+ '-message' => join '', @_
+ );
+}
+
+sub target_params {
+ return (sqitch => shift->sqitch);
+}
+
+sub parse_args {
+ my ($self, %p) = @_;
+ my $config = $self->sqitch->config;
+ my @params = $self->target_params;
+
+ # Load the specified or default target.
+ require App::Sqitch::Target;
+ my $deftarget_err;
+ my $target = try {
+ App::Sqitch::Target->new( @params, name => $p{target} )
+ } catch {
+ # Die if a target was specified; otherwise keep the error for later.
+ die $_ if $p{target};
+ $deftarget_err = $_;
+ undef;
+ };
+
+ # Set up the default results.
+ my (%seen, %target_for);
+ my %rec = map { $_ => [] } qw(targets unknown);
+ $rec{changes} = [] unless $p{no_changes};
+ if ($p{target}) {
+ push @{ $rec{targets} } => $target;
+ $seen{$target->name}++;
+ }
+
+ # Iterate over the argsx to look for changes, engines, plans, or targets.
+ my %engines = map { $_ => 1 } ENGINES;
+ for my $arg (@{ $p{args} }) {
+ if ( !$p{no_changes} && $target && -e $target->plan_file && $target->plan->contains($arg) ) {
+ # A change.
+ push @{ $rec{changes} } => $arg;
+ } elsif ($config->get( key => "target.$arg.uri") || URI->new($arg)->isa('URI::db')) {
+ # A target. Instantiate and keep for subsequente change searches.
+ $target = App::Sqitch::Target->new( @params, name => $arg );
+ push @{ $rec{targets} } => $target unless $seen{$target->name}++;
+ } elsif ($engines{$arg}) {
+ # An engine. Add its target.
+ my $name = $config->get(key => "engine.$arg.target") || "db:$arg:";
+ $target = App::Sqitch::Target->new( @params, name => $name );
+ push @{ $rec{targets} } => $target unless $seen{$target->name}++;
+ } elsif (-e $arg) {
+ # Maybe it's a plan file?
+ %target_for = map {
+ $_->plan_file => $_
+ } reverse App::Sqitch::Target->all_targets(@params) unless %target_for;
+ if ($target_for{$arg}) {
+ # It *is* a plan file.
+ $target = $target_for{$arg};
+ push @{ $rec{targets} } => $target unless $seen{$target->name}++;
+ } else {
+ # Nah, who knows.
+ push @{ $rec{unknown} } => $arg;
+ }
+ } else {
+ # Who knows?
+ push @{ $rec{unknown} } => $arg;
+ }
+ }
+
+ # Replace missing names with unknown values.
+ my @names = map { $_ || shift @{ $rec{unknown} } } @{ $p{names} || [] };
+
+ # Die on unknowns.
+ if (my @unknown = @{ $rec{unknown} } ) {
+ hurl $self->command => __nx(
+ 'Unknown argument "{arg}"',
+ 'Unknown arguments: {arg}',
+ scalar @unknown,
+ arg => join ', ', @unknown
+ );
+ }
+
+ # Figure out what targets to access. Use default unless --all.
+ my @targets = @{ $rec{targets} };
+ if ($p{all}) {
+ # Got --all.
+ hurl $self->command => __(
+ 'Cannot specify both --all and engine, target, or plan arugments'
+ ) if @targets;
+ @targets = App::Sqitch::Target->all_targets(@params );
+ } elsif (!@targets) {
+ # Use all if tag.all is set, otherwise just the default.
+ my $key = $self->command . '.all';
+ @targets = $self->sqitch->config->get(key => $key, as => 'bool')
+ ? App::Sqitch::Target->all_targets(@params )
+ : do {
+ # Fall back on the default unless it's invalid.
+ die $deftarget_err if $deftarget_err;
+ ($target)
+ }
+ }
+
+ return (@names, \@targets, $rec{changes});
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command - Sqitch Command support
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command->load( deploy => \%params );
+ $cmd->run;
+
+=head1 Description
+
+App::Sqitch::Command is the base class for all Sqitch commands.
+
+=head1 Interface
+
+=head2 Constants
+
+=head3 C<ENGINES>
+
+Returns the list of supported engines, currently:
+
+=over
+
+=item * C<firebird>
+
+=item * C<mysql>
+
+=item * C<oracle>
+
+=item * C<pg>
+
+=item * C<sqlite>
+
+=item * C<vertica>
+
+=item * C<exasol>
+
+=item * C<snowflake>
+
+=back
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @spec = App::Sqitch::Command->options;
+
+Returns a list of L<Getopt::Long> options specifications. When C<load> loads
+the class, any options passed to the command will be parsed using these
+values. The keys in the resulting hash will be the first part of each option,
+with dashes converted to underscores. This hash will be passed to C<configure>
+along with a L<App::Sqitch::Config> object for munging into parameters to be
+passed to the constructor.
+
+Here's an example excerpted from the C<config> command:
+
+ sub options {
+ return qw(
+ get
+ unset
+ list
+ global
+ system
+ config-file=s
+ );
+ }
+
+This will result in hash keys with the same names as each option except for
+C<config-file=s>, which will be named C<config_file>.
+
+=head3 C<configure>
+
+ my $params = App::Sqitch::Command->configure($config, $options);
+
+Takes two arguments, an L<App::Sqitch::Config> object and the hash of
+command-line options as specified by C<options>. The returned hash should be
+the result of munging these two objects into a hash reference of parameters to
+be passed to the command subclass constructor.
+
+By default, this method converts dashes to underscores in command-line options
+keys, and then merges the configuration values with the options, with the
+command-line options taking priority. You may wish to override this method to
+do something different.
+
+=head3 C<class_for>
+
+ my $subclass = App::Sqitch::Command->subclass_for($sqitch, $cmd_name);
+
+This method attempts to load the subclass of App::Sqitch::Commmand that
+corresponds to the command name. Returns C<undef> and sends errors to the
+C<debug> method of the <$sqitch> object if no such subclass can
+be loaded.
+
+=head2 Constructors
+
+=head3 C<load>
+
+ my $cmd = App::Sqitch::Command->load( \%params );
+
+A factory method for instantiating Sqitch commands. It loads the subclass for
+the specified command and calls C<create> to instantiate and return an
+instance of the subclass. Sends error messages to the C<debug> method of the
+C<sqitch> parameter and throws an exception if the subclass does not exist or
+cannot be loaded. Supported parameters are:
+
+=over
+
+=item C<sqitch>
+
+The App::Sqitch object driving the whole thing.
+
+=item C<config>
+
+An L<App::Sqitch::Config> representing the current application configuration
+state.
+
+=item C<command>
+
+The name of the command to be executed.
+
+=item C<args>
+
+An array reference of command-line arguments passed to the command.
+
+=back
+
+=head3 C<create>
+
+ my $pkg = App::Sqitch::Command->class_for( $sqitch, $cmd_name )
+ or die "No such command $cmd_name";
+ my $cmd = $pkg->create({
+ sqitch => $sqitch,
+ config => $config,
+ args => \@ARGV,
+ });
+
+Creates and returns a new object for a subclass of App::Sqitch::Command. It
+parses options from the C<args> parameter, calls C<configure> to merge
+configuration with the options, and finally calls C<new> with the resulting
+hash. Supported parameters are the same as for C<load> except for the
+C<command> parameter, which will be ignored.
+
+=head3 C<new>
+
+ my $cmd = App::Sqitch::Command->new(%params);
+
+Instantiates and returns a App::Sqitch::Command object. This method is not
+designed to be overridden by subclasses; they should implement
+L<C<BUILDARGS>|Moo::Manual::Construction/BUILDARGS> or
+L<C<BUILD>|Moo::Manual::Construction/BUILD>, instead.
+
+=head2 Accessors
+
+=head3 C<sqitch>
+
+ my $sqitch = $cmd->sqitch;
+
+Returns the L<App::Sqitch> object that instantiated the command. Commands may
+access its properties in order to manage global state.
+
+=head2 Overridable Instance Methods
+
+These methods should be overridden by all subclasses.
+
+=head3 C<execute>
+
+ $cmd->execute;
+
+Executes the command. This is the method that does the work of the command.
+Must be overridden in all subclasses. Dies if the method is not overridden for
+the object on which it is called, or if it is called against a base
+App::Sqitch::Command object.
+
+=head3 C<command>
+
+ my $command = $cmd->command;
+
+The name of the command. Defaults to the last part of the package name, so as
+a rule you should not need to override it, since it is that string that Sqitch
+uses to find the command class.
+
+=head2 Utility Instance Methods
+
+These methods are mainly provided as utilities for the command subclasses to
+use.
+
+=head3 C<default_target>
+
+ my $target = $cmd->default_target;
+
+This method returns the default target. It should only be used by commands
+that don't use a C<parse_args()> to find and load a target.
+
+This method should always return a target option, never C<undef>. If the
+C<core.engine> configuration option has been set, then the target will support
+that engine. In the latter case, if C<engine.$engine.target> is set, that
+value will be used. Otherwise, the returned target will have a URI of C<db:>
+and no associated engine; the C<engine> method will throw an exception. This
+behavior should be fine for commands that don't need to load the engine.
+
+=head3 C<parse_args>
+
+ my ($name1, $name2, $targets, $changes) = $cmd->parse_args(
+ names => \@names,
+ target => $target_name,
+ args => \@args
+ );
+
+Examines each argument to determine whether it's a known change spec or
+identifies a target or engine. Unrecognized arguments will replace false
+values in the C<names> array reference. Any remaining unknown arguments will
+trigger an error.
+
+Returns a list consisting all the desired names, followed by an array
+reference of target objects and an array reference of change specs.
+
+This method is useful for commands that take a number of arguments where the
+order may be mixed.
+
+The supported parameters are:
+
+=over
+
+=item C<args>
+
+An array reference of the command arguments.
+
+=item C<target>
+
+The name of a target, if any. Useful for commands that offer their own
+C<--target> option. This target will be the default target, and the first
+returned in the targets array.
+
+=item C<names>
+
+An array reference of names. If any is false, its place will be taken by an
+otherwise unrecognized argument. The number of values in this array reference
+determines the number of values returned as names in the return values. Such
+values may still be false or undefined; it's up to the caller to decide what
+to do about that.
+
+=item C<all>
+
+In the event that no targets are recognized (or changes that implicitly
+recognize the default target), if this parameter is true, then all known
+targets from the configuration will be returned.
+
+=item C<no_changes>
+
+If true, the parser will not check to see if any argument corresponds to a
+change. The last value returned will be C<undef> instead of the usual array
+reference. Any argument that might have been recognized as a change will
+instead be included in either the C<targets> array -- if it's recognized as a
+target -- or used to set names to return. Any remaining are considered
+unknown arguments and will result in an exception.
+
+=back
+
+If a target parameter is passed, it will always be instantiated and returned
+as the first item in the "target" array, and arguments recognized as changes
+in the plan associated with that target will be returned as changes.
+
+If no target is passed or appears in the arguments, a default target will be
+instantiated based on the command-line options and configuration. Unlike the
+target returned by C<default_target>, this target B<must> have an associated
+engine specified by the configuration. This is on the assumption that it will
+be used by commands that require an engine to do their work. Of course, any
+changes must be recognized from the plan associated with this target.
+
+Changes are only recognized if they're found in the plan of the target that
+precedes them. If no target precedes them, the target specified by the
+C<target> parameter or the default target will be searched. Such changes can
+be specified in any way documented in L<sqitchchanges>.
+
+Targets may be recognized by any one of these types of arguments:
+
+=over
+
+=item * Target Name
+
+=item * Database URI
+
+=item * Engine Name
+
+=item * Plan File
+
+=back
+
+In the case of plan files, C<parse_args()> will return the first target it
+finds for that plan file, even if multiple targets use the same plan file. The
+order of precedence for this determination is the default project target,
+followed by named targets, then engine targets.
+
+=head3 C<target_params>
+
+ my $target = App::Sqitch::Target->new( $cmd->target_params );
+
+Returns a list of parameters suitable for passing to the C<new> or
+C<all_targets> constructors of App::Sqitch::Target.
+
+=head3 C<run>
+
+ $cmd->run('echo hello');
+
+Runs a system command and waits for it to finish. Throws an exception on
+error.
+
+=head3 C<capture>
+
+ my @files = $cmd->capture(qw(ls -lah));
+
+Runs a system command and captures its output to C<STDOUT>. Returns the output
+lines in list context and the concatenation of the lines in scalar context.
+Throws an exception on error.
+
+=head3 C<probe>
+
+ my $git_version = $cmd->capture(qw(git --version));
+
+Like C<capture>, but returns just the C<chomp>ed first line of output.
+
+=head3 C<verbosity>
+
+ my $verbosity = $cmd->verbosity;
+
+Returns the verbosity level.
+
+=head3 C<trace>
+
+Send trace information to C<STDOUT> if the verbosity level is 3 or higher.
+Trace messages will have C<trace: > prefixed to every line. If it's lower than
+3, nothing will be output.
+
+=head3 C<debug>
+
+ $cmd->debug('Found snuggle in the crib.');
+
+Send debug information to C<STDOUT> if the verbosity level is 2 or higher.
+Debug messages will have C<debug: > prefixed to every line. If it's lower than
+2, nothing will be output.
+
+=head3 C<info>
+
+ $cmd->info('Nothing to deploy (up-to-date)');
+
+Send informational message to C<STDOUT> if the verbosity level is 1 or higher,
+which, by default, it is. Should be used for normal messages the user would
+normally want to see. If verbosity is lower than 1, nothing will be output.
+
+=head3 C<comment>
+
+ $cmd->comment('On database flipr_test');
+
+Send comments to C<STDOUT> if the verbosity level is 1 or higher, which, by
+default, it is. Comments have C<# > prefixed to every line. If verbosity is
+lower than 1, nothing will be output.
+
+=head3 C<emit>
+
+ $cmd->emit('core.editor=emacs');
+
+Send a message to C<STDOUT>, without regard to the verbosity. Should be used
+only if the user explicitly asks for output, such as for
+C<sqitch config --get core.editor>.
+
+=head3 C<vent>
+
+ $cmd->vent('That was a misage.');
+
+Send a message to C<STDERR>, without regard to the verbosity. Should be used
+only for error messages to be printed before exiting with an error, such as
+when reverting failed changes.
+
+=head3 C<page>
+
+ $sqitch->page('Search results:');
+
+Like C<emit()>, but sends the output to a pager handle rather than C<STDOUT>.
+Unless there is no TTY (such as when output is being piped elsewhere), in
+which case it I<is> sent to C<STDOUT>. Meant to be used to send a lot of data
+to the user at once, such as when display the results of searching the event
+log:
+
+ $iter = $engine->search_events;
+ while ( my $change = $iter->() ) {
+ $cmd->page(join ' - ', @{ $change }{ qw(change_id event change) });
+ }
+
+=head3 C<warn>
+
+ $cmd->warn('Could not find nerble; using nobble instead.');
+
+Send a warning messages to C<STDERR>. Warnings will have C<warning: > prefixed
+to every line. Use if something unexpected happened but you can recover from
+it.
+
+=head3 C<usage>
+
+ $cmd->usage('Missing "value" argument');
+
+Sends the specified message to C<STDERR>, followed by the usage sections of
+the command's documentation. Those sections may be named "Name", "Synopsis",
+or "Options". Any or all of these will be shown. The doc used to display them
+will be the first found of:
+
+=over
+
+=item C<sqitch-$command-usage>
+
+=item C<sqitch-$command>
+
+=item C<sqitch>
+
+=item C<App::Sqitch::Command::$command>
+
+=item C<App::Sqitch::Command>
+
+=back
+
+For an ideal usage messages, C<sqitch-$command-usage.pod> should be created by
+all command subclasses.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/add.pm b/lib/App/Sqitch/Command/add.pm
new file mode 100644
index 00000000..633aa660
--- /dev/null
+++ b/lib/App/Sqitch/Command/add.pm
@@ -0,0 +1,565 @@
+package App::Sqitch::Command::add;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Moo;
+use App::Sqitch::Types qw(Str Int ArrayRef HashRef Dir Bool Maybe);
+use Path::Class;
+use Try::Tiny;
+use File::Path qw(make_path);
+use Clone qw(clone);
+use List::Util qw(first);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has change_name => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has requires => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ default => sub { [] },
+);
+
+has conflicts => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ default => sub { [] },
+);
+
+has all => (
+ is => 'ro',
+ isa => Bool,
+ default => 0
+);
+
+has note => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ default => sub { [] },
+);
+
+has variables => (
+ is => 'ro',
+ isa => HashRef,
+ lazy => 1,
+ default => sub {
+ shift->sqitch->config->get_section( section => 'add.variables' );
+ },
+);
+
+has template_directory => (
+ is => 'ro',
+ isa => Maybe[Dir],
+);
+
+has template_name => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has with_scripts => (
+ is => 'ro',
+ isa => HashRef,
+ default => sub { {} },
+);
+
+has templates => (
+ is => 'ro',
+ isa => HashRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->_config_templates($self->sqitch->config);
+ },
+);
+
+has open_editor => (
+ is => 'ro',
+ isa => Bool,
+ lazy => 1,
+ default => sub {
+ shift->sqitch->config->get(
+ key => 'add.open_editor',
+ as => 'bool',
+ ) // 0;
+ },
+);
+
+sub _check_script($) {
+ my $file = file shift;
+
+ hurl add => __x(
+ 'Template {template} does not exist',
+ template => $file,
+ ) unless -e $file;
+
+ hurl add => __x(
+ 'Template {template} is not a file',
+ template => $file,
+ ) unless -f $file;
+
+ return $file;
+}
+
+sub _config_templates {
+ my ($self, $config) = @_;
+ my $tmpl = $config->get_section( section => 'add.templates' );
+ $_ = _check_script $_ for values %{ $tmpl };
+ return $tmpl;
+}
+
+sub all_templates {
+ my ($self, $name) = @_;
+ my $config = $self->sqitch->config;
+ my $tmpl = $self->templates;
+
+ # Read all the template directories.
+ for my $dir (
+ $self->template_directory,
+ $config->user_dir->subdir('templates'),
+ $config->system_dir->subdir('templates'),
+ ) {
+ next unless $dir && -d $dir;
+ for my $subdir($dir->children) {
+ next unless $subdir->is_dir;
+ next if $tmpl->{my $script = $subdir->basename};
+ my $file = $subdir->file("$name.tmpl");
+ $tmpl->{$script} = $file if -f $file
+ }
+ }
+
+ # Make sure we have core templates.
+ my $with = $self->with_scripts;
+ for my $script (qw(deploy revert verify)) {
+ hurl add => __x(
+ 'Cannot find {script} template',
+ script => $script,
+ ) if !$tmpl->{$script} && ($with->{$script} || !exists $with->{$script});
+ }
+
+ return $tmpl;
+}
+
+sub options {
+ return qw(
+ change-name|change|c=s
+ requires|r=s@
+ conflicts|x=s@
+ note|n|m=s@
+ all|a!
+ template-name|template|t=s
+ template-directory=s
+ with=s@
+ without=s@
+ use=s%
+ open-editor|edit|e!
+ );
+}
+
+# Override to convert multiple vars to an array.
+sub _parse_opts {
+ my ( $class, $args ) = @_;
+
+ my (%opts, %vars);
+ Getopt::Long::Configure(qw(bundling no_pass_through));
+ Getopt::Long::GetOptionsFromArray(
+ $args, \%opts,
+ $class->options,
+ 'set|s=s%' => sub {
+ my ($opt, $key, $val) = @_;
+ if (exists $vars{$key}) {
+ $vars{$key} = [$vars{$key}] unless ref $vars{$key};
+ push @{ $vars{$key} } => $val;
+ } else {
+ $vars{$key} = $val;
+ }
+ }
+ ) or $class->usage;
+ $opts{set} = \%vars if %vars;
+
+ # Convert dashes to underscores.
+ for my $k (keys %opts) {
+ next unless ( my $nk = $k ) =~ s/-/_/g;
+ $opts{$nk} = delete $opts{$k};
+ }
+
+ # Merge with and without.
+ $opts{with_scripts} = {
+ ( map { $_ => 1 } qw(deploy revert verify) ),
+ ( map { $_ => 1 } @{ delete $opts{with} || [] } ),
+ ( map { $_ => 0 } @{ delete $opts{without} || [] } ),
+ };
+ return \%opts;
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ my %params = (
+ requires => $opt->{requires} || [],
+ conflicts => $opt->{conflicts} || [],
+ note => $opt->{note} || [],
+ );
+
+ for my $key (qw(with_scripts change_name)) {
+ $params{$key} = $opt->{$key} if $opt->{$key};
+ }
+
+ if (
+ my $dir = $opt->{template_directory}
+ || $config->get( key => 'add.template_directory' )
+ ) {
+ $dir = $params{template_directory} = dir $dir;
+ hurl add => __x(
+ 'Directory "{dir}" does not exist',
+ dir => $dir,
+ ) unless -e $dir;
+
+ hurl add => __x(
+ '"{dir}" is not a directory',
+ dir => $dir,
+ ) unless -d $dir;
+ }
+
+ if (
+ my $name = $opt->{template_name}
+ || $config->get( key => 'add.template_name' )
+ ) {
+ $params{template_name} = $name;
+ }
+
+ # Merge variables.
+ if ( my $vars = $opt->{set} ) {
+ $params{variables} = {
+ %{ $config->get_section( section => 'add.variables' ) },
+ %{ $vars },
+ };
+ }
+
+ # Merge template info.
+ my $tmpl = $class->_config_templates($config);
+ if ( my $use = delete $opt->{use} ) {
+ while (my ($k, $v) = each %{ $use }) {
+ $tmpl->{$k} = _check_script $v;
+ }
+ }
+ $params{templates} = $tmpl if %{ $tmpl };
+
+ # Copy other options.
+ for my $key (qw(all open_editor)) {
+ $params{$key} = $opt->{$key} if exists $opt->{$key};
+ }
+
+ return \%params;
+}
+
+sub execute {
+ my $self = shift;
+ $self->usage unless @_ || $self->change_name;
+
+ my ($name, $targets) = $self->parse_args(
+ names => [$self->change_name],
+ all => $self->all,
+ args => \@_,
+ no_changes => 1,
+ );
+
+ # Check for missing name.
+ unless (defined $name) {
+ if (my $target = first { my $n = $_->name; first { $_ eq $n } @_ } @{ $targets }) {
+ # Name conflicts with a target.
+ hurl add => __x(
+ 'Name "{name}" identifies a target; use "--change {name}" to use it for the change name',
+ name => $target->name,
+ );
+ }
+ $self->usage;
+ }
+
+ my $note = join "\n\n", => @{ $self->note };
+ my ($first_change, %added, @files, %seen);
+
+ for my $target (@{ $targets }) {
+ my $plan = $target->plan;
+ my $with = $self->with_scripts;
+ my $tmpl = $self->all_templates($self->template_name || $target->engine_key);
+ my $file = $plan->file;
+ my $spec = $added{$file} ||= { scripts => [], seen => {} };
+ my $change = $spec->{change};
+ if ($change) {
+ # Need a dupe for *this* target so script names are right.
+ $change = ref($change)->new(
+ plan => $plan,
+ name => $change->name,
+ );
+ } else {
+ $change = $spec->{change} = $plan->add(
+ name => $name,
+ requires => $self->requires,
+ conflicts => $self->conflicts,
+ note => $note,
+ );
+ $first_change ||= $change;
+ }
+
+ # Suss out the files we'll need to write.
+ push @{ $spec->{scripts} } => map {
+ push @files => $_->[1] unless $seen{$_->[1]}++;
+ [ $_->[1], $tmpl->{ $_->[0] }, $target->engine_key, $plan->project ];
+ } grep {
+ !$spec->{seen}{ $_->[1] }++;
+ } map {
+ [$_ => $change->script_file($_)];
+ } grep {
+ !exists $with->{$_} || $with->{$_}
+ } sort keys %{ $tmpl };
+ }
+
+ # Make sure we have a note.
+ $note = $first_change->request_note(
+ for => __ 'add',
+ scripts => \@files,
+ );
+
+ # Time to write everything out.
+ for my $target (@{ $targets }) {
+ my $plan = $target->plan;
+ my $file = $plan->file;
+ my $spec = delete $added{$file} or next;
+
+ # Write out the scripts.
+ $self->_add($name, @{ $_ }) for @{ $spec->{scripts} };
+
+ # We good. Set the note on all changes and write out the plan files.
+ my $change = $spec->{change};
+ $change->note($note);
+ $plan->write_to( $plan->file );
+ $self->info(__x(
+ 'Added "{change}" to {file}',
+ change => $spec->{change}->format_op_name_dependencies,
+ file => $plan->file,
+ ));
+ }
+
+ # Let 'em at it.
+ if ($self->open_editor) {
+ my $sqitch = $self->sqitch;
+ $sqitch->shell( $sqitch->editor . ' ' . $sqitch->quote_shell(@files) );
+ }
+
+ return $self;
+}
+
+sub _add {
+ my ( $self, $name, $file, $tmpl, $engine, $project ) = @_;
+ if (-e $file) {
+ $self->info(__x(
+ 'Skipped {file}: already exists',
+ file => $file,
+ ));
+ return $self;
+ }
+
+ # Create the directory for the file, if it does not exist.
+ make_path $file->dir->stringify, { error => \my $err };
+ if ( my $diag = shift @{ $err } ) {
+ my ( $path, $msg ) = %{ $diag };
+ hurl add => __x(
+ 'Error creating {path}: {error}',
+ path => $path,
+ error => $msg,
+ ) if $path;
+ hurl add => $msg;
+ }
+
+ my $vars = clone {
+ %{ $self->variables },
+ change => $name,
+ engine => $engine,
+ project => $project,
+ requires => $self->requires,
+ conflicts => $self->conflicts,
+ };
+
+ my $fh = $file->open('>:utf8_strict') or hurl add => __x(
+ 'Cannot open {file}: {error}',
+ file => $file,
+ error => $!
+ );
+
+ if (eval 'use Template; 1') {
+ my $tt = Template->new;
+ $tt->process( $self->_slurp($tmpl), $vars, $fh ) or hurl add => __x(
+ 'Error executing {template}: {error}',
+ template => $tmpl,
+ error => $tt->error,
+ );
+ } else {
+ eval 'use Template::Tiny 0.11; 1' or die $@;
+ my $output = '';
+ Template::Tiny->new->process( $self->_slurp($tmpl), $vars, \$output );
+ print $fh $output;
+ }
+
+ close $fh or hurl add => __x(
+ 'Error closing {file}: {error}',
+ file => $file,
+ error => $!
+ );
+ $self->info(__x 'Created {file}', file => $file);
+}
+
+sub _slurp {
+ my ( $self, $tmpl ) = @_;
+ open my $fh, "<:utf8_strict", $tmpl or hurl add => __x(
+ 'Cannot open {file}: {error}',
+ file => $tmpl,
+ error => $!
+ );
+ local $/;
+ return \<$fh>;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::add - Add a new change to Sqitch plans
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::add->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+Adds a new deployment change. This will result in the creation of a scripts in
+the deploy, revert, and verify directories. The scripts are based on
+L<Template::Tiny> templates in F<~/.sqitch/templates/> or
+C<$(prefix)/etc/sqitch/templates> (call C<sqitch --etc-path> to find out
+where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.conf>).
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::add->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<add> command.
+
+=head3 C<configure>
+
+ my $params = App::Sqitch::Command::add->configure(
+ $config,
+ $options,
+ );
+
+Processes the configuration and command options and returns a hash suitable
+for the constructor.
+
+=head2 Attributes
+
+=head3 C<change_name>
+
+The name of the change to be added.
+
+=head3 C<note>
+
+Text of the change note.
+
+=head3 C<requires>
+
+List of required changes.
+
+=head3 C<conflicts>
+
+List of conflicting changes.
+
+=head3 C<all>
+
+Boolean indicating whether or not to run the command against all plans in the
+project.
+
+=head3 C<template_name>
+
+The name of the templates to use when generating scripts. Defaults to the
+engine for which the scripts are being generated.
+
+=head3 C<template_directory>
+
+Directory in which to find the change script templates.
+
+=head3 C<with_scripts>
+
+Hash reference indicating which scripts to create.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $add->execute($command);
+
+Executes the C<add> command.
+
+=head3 C<all_templates>
+
+Returns a hash reference of script names mapped to template files for all
+scripts that should be generated for the new change.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-add>
+
+Documentation for the C<add> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/bundle.pm b/lib/App/Sqitch/Command/bundle.pm
new file mode 100644
index 00000000..ce68292f
--- /dev/null
+++ b/lib/App/Sqitch/Command/bundle.pm
@@ -0,0 +1,398 @@
+package App::Sqitch::Command::bundle;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use App::Sqitch::Types qw(Str Dir Maybe Bool);
+use File::Path qw(make_path);
+use Path::Class;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use File::Copy ();
+use List::Util qw(first);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has from => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has to => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has dest_dir => (
+ is => 'ro',
+ isa => Dir,
+ lazy => 1,
+ default => sub { dir 'bundle' },
+);
+
+has all => (
+ is => 'ro',
+ isa => Bool,
+ default => 0
+);
+
+sub dest_top_dir {
+ my $self = shift;
+ dir $self->dest_dir, shift->top_dir->relative;
+}
+
+sub dest_dirs_for {
+ my ($self, $target) = @_;
+ my $dest = $self->dest_dir;
+ return {
+ deploy => dir($dest, $target->deploy_dir->relative),
+ revert => dir($dest, $target->revert_dir->relative),
+ verify => dir($dest, $target->verify_dir->relative),
+ reworked_deploy => dir($dest, $target->reworked_deploy_dir->relative),
+ reworked_revert => dir($dest, $target->reworked_revert_dir->relative),
+ reworked_verify => dir($dest, $target->reworked_verify_dir->relative),
+ };
+}
+
+sub options {
+ return qw(
+ dest-dir|dir=s
+ all|a!
+ from=s
+ to=s
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ my %params;
+
+ if (my $dir = $opt->{dest_dir} || $config->get(key => 'bundle.dest_dir') ) {
+ $params{dest_dir} = dir $dir;
+ }
+
+ # Make sure we get the --all, --from and --to options passed through.
+ for my $key (qw(all from to)) {
+ $params{$key} = $opt->{$key} if exists $opt->{$key};
+ }
+
+ return \%params;
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets, $changes) = $self->parse_args(
+ all => $self->all,
+ args => \@_,
+ );
+
+ # Warn if --to or --from is specified for more thane one target.
+ if ( @{ $targets } > 1 && ($self->from || $self->to) ) {
+ $self->sqitch->warn(__(
+ "Use of --to or --from to bundle multiple targets is not recommended.\nPass them as arguments after each target argument, instead."
+ ));
+ }
+
+ # Die if --to or --from and changes are specified.
+ if ( @{ $changes } && ($self->from || $self->to) ) {
+ hurl bundle => __(
+ 'Cannot specify both --from or --to and change arguments'
+ );
+ }
+
+ # Time to get started!
+ $self->info(__x 'Bundling into {dir}', dir => $self->dest_dir );
+ $self->bundle_config;
+
+ if (my @fromto = grep { $_ } $self->from, $self->to) {
+ # One set of from/to options for all targets.
+ for my $target (@{ $targets }) {
+ $self->bundle_plan($target, @fromto);
+ $self->bundle_scripts($target, @fromto);
+ }
+ } else {
+ # Separate from/to options for all targets.
+ for my $target (@{ $ targets }) {
+ my @fromto = splice @{ $changes }, 0, 2;
+ $self->bundle_plan($target, @fromto);
+ $self->bundle_scripts($target, @fromto);
+ }
+ }
+
+ return $self;
+}
+
+sub _mkpath {
+ my ( $self, $dir ) = @_;
+ $self->debug( ' ', __x 'Created {file}', file => $dir )
+ if make_path $dir, { error => \my $err };
+
+ my $diag = shift @{ $err } or return $self;
+
+ my ( $path, $msg ) = %{ $diag };
+ hurl bundle => __x(
+ 'Error creating {path}: {error}',
+ path => $path,
+ error => $msg,
+ ) if $path;
+ hurl bundle => $msg;
+}
+
+sub _copy_if_modified {
+ my ( $self, $src, $dst ) = @_;
+
+ hurl bundle => __x(
+ 'Cannot copy {file}: does not exist',
+ file => $src,
+ ) unless -e $src;
+
+ if (-e $dst) {
+ # Skip the file if it is up-to-date.
+ return $self if -M $dst <= -M $src;
+ } else {
+ # Create the directory.
+ $self->_mkpath( $dst->dir );
+ }
+
+ $self->debug(' ', __x(
+ "Copying {source} -> {dest}",
+ source => $src,
+ dest => $dst
+ ));
+
+ # Stringify to work around bug in File::Copy warning on 5.10.0.
+ File::Copy::copy "$src", "$dst" or hurl bundle => __x(
+ 'Cannot copy "{source}" to "{dest}": {error}',
+ source => $src,
+ dest => $dst,
+ error => $!,
+ );
+ return $self;
+}
+
+sub bundle_config {
+ my $self = shift;
+ $self->info(__ 'Writing config');
+ my $file = $self->sqitch->config->local_file;
+ $self->_copy_if_modified( $file, $self->dest_dir->file( $file->basename ) );
+}
+
+sub bundle_plan {
+ my ($self, $target, $from, $to) = @_;
+
+ my $dir = $self->dest_top_dir($target);
+
+ if (!defined $from && !defined $to) {
+ $self->info(__ 'Writing plan');
+ my $file = $target->plan_file;
+ return $self->_copy_if_modified(
+ $file,
+ $dir->file( $file->basename ),
+ );
+ }
+
+ $self->info(__x(
+ 'Writing plan from {from} to {to}',
+ from => $from // '@ROOT',
+ to => $to // '@HEAD',
+ ));
+
+ $self->_mkpath( $dir );
+ $target->plan->write_to(
+ $dir->file( $target->plan_file->basename ),
+ $from,
+ $to,
+ );
+}
+
+sub bundle_scripts {
+ my ($self, $target, $from, $to) = @_;
+ my $plan = $target->plan;
+
+ my $from_index = $plan->index_of(
+ $from // '@ROOT'
+ ) // hurl bundle => __x(
+ 'Cannot find change {change}',
+ change => $from,
+ );
+
+ my $to_index = $plan->index_of(
+ $to // '@HEAD'
+ ) // hurl bundle => __x(
+ 'Cannot find change {change}',
+ change => $to,
+ );
+
+ $self->info(__ 'Writing scripts');
+ $plan->position( $from_index );
+ my $dir_for = $self->dest_dirs_for($target);
+
+ while ( $plan->position <= $to_index ) {
+ my $change = $plan->current // last;
+ $self->info(' + ', $change->format_name_with_tags);
+ my $prefix = $change->is_reworked ? 'reworked_' : '';
+ my @path = $change->path_segments;
+ if (-e ( my $file = $change->deploy_file )) {
+ $self->_copy_if_modified(
+ $file,
+ $dir_for->{"${prefix}deploy"}->file(@path)
+ );
+ }
+ if (-e ( my $file = $change->revert_file )) {
+ $self->_copy_if_modified(
+ $file,
+ $dir_for->{"${prefix}revert"}->file(@path)
+ );
+ }
+ if (-e ( my $file = $change->verify_file )) {
+ $self->_copy_if_modified(
+ $file,
+ $dir_for->{"${prefix}verify"}->file(@path)
+ );
+ }
+ $plan->next;
+ }
+
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::bundle - Bundle Sqitch changes for distribution
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::bundle->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+Bundles a Sqitch project for distribution. Done by creating a new directory
+and copying the configuration file, plan file, and change files into it.
+
+=head1 Interface
+
+=head2 Attributes
+
+=head3 C<from>
+
+Change from which to build the bundled plan.
+
+=head3 C<to>
+
+Change up to which to build the bundled plan.
+
+=head3 C<all>
+
+Boolean indicating whether or not to run the command against all plans in the
+project.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $bundle->execute($command);
+
+Executes the C<bundle> command.
+
+=head3 C<bundle_config>
+
+ $bundle->bundle_config;
+
+Copies the configuration file to the bundle directory.
+
+=head3 C<bundle_plan>
+
+ $bundle->bundle_plan($target);
+
+Copies the plan file for the specified target to the bundle directory.
+
+=head3 C<bundle_scripts>
+
+ $bundle->bundle_scripts($target);
+
+Copies the deploy, revert, and verify scripts for each step in the plan for
+the specified target to the bundle directory. Files in the script directories
+that do not correspond to changes in the plan will not be copied.
+
+=head3 C<dest_top_dir>
+
+ my $top_dir = $bundle->top_dir($target);
+
+Returns the destination top directory for the specified target.
+
+=head3 C<dest_dirs_for>
+
+ my $dirs = $bundle->dest__dirs_for($target);
+
+Returns a hash of change script destination directories for the specified
+target. The keys are the types of scripts, and include:
+
+=over
+
+=item C<deploy>
+
+=item C<revert>
+
+=item C<verfiy>
+
+=item C<reworked_deploy>
+
+=item C<reworked_revert>
+
+=item C<reworked_verfiy>
+
+=back
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-bundle>
+
+Documentation for the C<bundle> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/checkout.pm b/lib/App/Sqitch/Command/checkout.pm
new file mode 100644
index 00000000..4381def6
--- /dev/null
+++ b/lib/App/Sqitch/Command/checkout.pm
@@ -0,0 +1,211 @@
+package App::Sqitch::Command::checkout;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use App::Sqitch::Types qw(Str);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use App::Sqitch::Plan;
+use Path::Class qw(dir);
+use Try::Tiny;
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::RevertDeployCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has client => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $sqitch = shift->sqitch;
+ return $sqitch->config->get( key => 'core.vcs.client' )
+ || 'git' . ( App::Sqitch::ISWIN ? '.exe' : '' );
+ },
+);
+
+sub configure { {} }
+
+sub execute {
+ my $self = shift;
+ my ($branch, $targets) = $self->parse_args(
+ target => $self->target,
+ names => [undef],
+ args => \@_,
+ no_changes => 1,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $target->name,
+ )) if @{ $targets };
+
+ # Now get to work.
+ my $sqitch = $self->sqitch;
+ my $git = $self->client;
+ my $engine = $target->engine;
+ $engine->with_verify( $self->verify );
+ $engine->no_prompt( $self->no_prompt );
+ $engine->prompt_accept( $self->prompt_accept );
+ $engine->log_only( $self->log_only );
+
+ # What branch are we on?
+ my $current_branch = $sqitch->probe($git, qw(rev-parse --abbrev-ref HEAD));
+ hurl {
+ ident => 'checkout',
+ message => __x('Already on branch {branch}', branch => $branch),
+ exitval => 1,
+ } if $current_branch eq $branch;
+
+ # Instantitate a plan without calling $target->plan.
+ my $from_plan = App::Sqitch::Plan->new(
+ sqitch => $sqitch,
+ target => $target,
+ );
+
+ # Load the branch plan from Git, assuming the same path.
+ my $to_plan = App::Sqitch::Plan->new(
+ sqitch => $sqitch,
+ target => $target,
+ )->parse(
+ # XXX Handle missing file/no contents.
+ scalar $sqitch->capture( $git, 'show', "$branch:" . $target->plan_file)
+ );
+
+ # Find the last change the plans have in common.
+ my $last_common_change;
+ for my $change ($to_plan->changes){
+ last unless $from_plan->get( $change->id );
+ $last_common_change = $change;
+ }
+
+ hurl checkout => __x(
+ 'Branch {branch} has no changes in common with current branch {current}',
+ branch => $branch,
+ current => $current_branch,
+ ) unless $last_common_change;
+
+ $sqitch->info(__x(
+ 'Last change before the branches diverged: {last_change}',
+ last_change => $last_common_change->format_name_with_tags,
+ ));
+
+ # Revert to the last common change.
+ $engine->set_variables( $self->_collect_revert_vars($target) );
+ $engine->plan( $from_plan );
+ try {
+ $engine->revert( $last_common_change->id );
+ } catch {
+ # Rethrow unknown errors or errors with exitval > 1.
+ die $_ if ! eval { $_->isa('App::Sqitch::X') }
+ || $_->exitval > 1
+ || $_->ident eq 'revert:confirm';
+ # Emite notice of non-fatal errors (e.g., nothing to revert).
+ $self->info($_->message)
+ };
+
+
+ # Check out the new branch.
+ $sqitch->run($git, 'checkout', $branch);
+
+ # Deploy!
+ $engine->set_variables( $self->_collect_deploy_vars($target) );
+ $engine->plan( $to_plan );
+ $engine->deploy( undef, $self->mode);
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::checkout - Revert, change checkout a VCS branch, and redeploy
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::checkout->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<checkout> command, you probably want to
+be reading C<sqitch-checkout>. But if you really want to know how the
+C<checkout> command works, read on.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::checkout->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<checkout> command.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $checkout->execute;
+
+Executes the checkout command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-checkout>
+
+Documentation for the C<checkout> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Authors
+
+=over
+
+=item * Ronan Dunklau <ronan@dunklau.fr>
+
+=item * David E. Wheeler <david@justatheory.com>
+
+=back
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Copyright (c) 2012-2013 Ronan Dunklau
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/config.pm b/lib/App/Sqitch/Command/config.pm
new file mode 100644
index 00000000..82fd7f8c
--- /dev/null
+++ b/lib/App/Sqitch/Command/config.pm
@@ -0,0 +1,666 @@
+package App::Sqitch::Command::config;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Path::Class ();
+use Try::Tiny;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(first);
+use Moo;
+use App::Sqitch::Types qw(Str Dir Maybe);
+use Type::Utils qw(enum);
+use namespace::autoclean;
+extends 'App::Sqitch::Command';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has file => (
+ is => 'ro',
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $meth = ( $self->context || 'local' ) . '_file';
+ return $self->sqitch->config->$meth;
+ }
+);
+
+has action => (
+ is => 'ro',
+ isa => enum([qw(
+ get
+ get_all
+ get_regex
+ set
+ unset
+ list
+ edit
+ add
+ replace_all
+ unset_all
+ rename_section
+ remove_section
+ )]),
+);
+
+has context => (
+ is => 'ro',
+ isa => Maybe[enum([qw(
+ local
+ user
+ system
+ )])],
+);
+
+has type => ( is => 'ro', isa => enum( [qw(int num bool bool-or-int)] ) );
+
+sub options {
+ return qw(
+ file|config-file|f=s
+ local
+ user|global
+ system
+
+ int
+ bool
+ bool-or-int
+ num
+
+ get
+ get-all
+ get-regex|get-regexp
+ add
+ replace-all
+ unset
+ unset-all
+ rename-section
+ remove-section
+ list|l
+ edit|e
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ # Make sure we are accessing only one file.
+ my @file = grep { $opt->{$_} } qw(local user system file);
+ $class->usage('Only one config file at a time.') if @file > 1;
+
+ # Make sure we have only one type.
+ my @type = grep { $opt->{$_} } qw(bool int num bool_or_int);
+ $class->usage('Only one type at a time.') if @type > 1;
+
+ # Make sure we are performing only one action.
+ my @action = grep { $opt->{$_} } qw(
+ get
+ get_all
+ get_regex
+ unset
+ list
+ edit
+ add
+ replace_all
+ unset_all
+ rename_section
+ remove_section
+ );
+ $class->usage('Only one action at a time.') if @action > 1;
+
+ # Get the action and context.
+ my $context = first { $opt->{$_} } qw(local user system);
+
+ # Make it so.
+ return {
+ ( $action[0] ? ( action => $action[0] ) : () ),
+ ( $type[0] ? ( type => $type[0] ) : () ),
+ ( $context ? ( context => $context ) : () ),
+ ( $opt->{file} ? ( file => $opt->{file} ) : () ),
+ };
+}
+
+sub execute {
+ my $self = shift;
+ my $action = $self->action || ( @_ > 1 ? 'set' : 'get' );
+ $action =~ s/-/_/g;
+ my $meth = $self->can($action) or hurl config => __x(
+ 'Unknown config action: {action}',
+ action => $action,
+ );
+ return $self->$meth(@_);
+}
+
+sub get {
+ my ( $self, $key, $rx ) = @_;
+ $self->usage('Wrong number of arguments.') if !defined $key || $key eq '';
+
+ my $val = try {
+ $self->sqitch->config->get(
+ key => $key,
+ filter => $rx,
+ as => $self->type,
+ human => 1,
+ );
+ }
+ catch {
+ hurl config => __x(
+ 'More then one value for the key "{key}"',
+ key => $key,
+ ) if /^\QMultiple values/i;
+ hurl config => $_;
+ };
+
+ hurl {
+ ident => 'config',
+ message => '',
+ exitval => 1,
+ } unless defined $val;
+ $self->emit($val);
+ return $self;
+}
+
+sub get_all {
+ my ( $self, $key, $rx ) = @_;
+ $self->usage('Wrong number of arguments.') if !defined $key || $key eq '';
+
+ my @vals = try {
+ $self->sqitch->config->get_all(
+ key => $key,
+ filter => $rx,
+ as => $self->type,
+ human => 1,
+ );
+ }
+ catch {
+ hurl config => $_;
+ };
+ hurl {
+ ident => 'config',
+ message => '',
+ exitval => 1,
+ } unless @vals;
+ $self->emit( join "\n", @vals );
+ return $self;
+}
+
+sub get_regex {
+ my ( $self, $key, $rx ) = @_;
+ $self->usage('Wrong number of arguments.') if !defined $key || $key eq '';
+
+ my $config = $self->sqitch->config;
+ my %vals = try {
+ $config->get_regexp(
+ key => $key,
+ filter => $rx,
+ as => $self->type,
+ human => 1,
+ );
+ }
+ catch {
+ hurl config => $_;
+ };
+ hurl {
+ ident => 'config',
+ message => '',
+ exitval => 1,
+ } unless %vals;
+ my @out;
+ for my $key ( sort keys %vals ) {
+ if ( defined $vals{$key} ) {
+ if ( $config->is_multiple($key) ) {
+ push @out => "$key=[" . join( ', ', @{ $vals{$key} } ) . ']';
+ }
+ else {
+ push @out => "$key=$vals{$key}";
+ }
+ }
+ else {
+ push @out => $key;
+ }
+ }
+ $self->emit( join "\n" => @out );
+
+ return $self;
+}
+
+sub set {
+ my ( $self, $key, $value, $rx ) = @_;
+ $self->_set( $key, $value, $rx, multiple => 0 );
+}
+
+sub add {
+ my ( $self, $key, $value ) = @_;
+ $self->_set( $key, $value, undef, multiple => 1 );
+}
+
+sub replace_all {
+ my ( $self, $key, $value, $rx ) = @_;
+ $self->_set( $key, $value, $rx, multiple => 1, replace_all => 1 );
+}
+
+sub _set {
+ my ( $self, $key, $value, $rx, @p ) = @_;
+ $self->usage('Wrong number of arguments.')
+ if !defined $key || $key eq '' || !defined $value;
+
+ $self->_touch_dir;
+ try {
+ $self->sqitch->config->set(
+ key => $key,
+ value => $value,
+ filename => $self->file,
+ filter => $rx,
+ as => $self->type,
+ @p,
+ );
+ }
+ catch {
+ hurl config => __(
+ 'Cannot overwrite multiple values with a single value'
+ ) if /^Multiple occurrences/i;
+ hurl config => $_;
+ };
+ return $self;
+}
+
+sub _file_config {
+ my $file = shift->file;
+ return unless -e $file;
+ my $config = App::Sqitch::Config->new;
+ $config->load_file($file);
+ return $config;
+}
+
+sub unset {
+ my ( $self, $key, $rx ) = @_;
+ $self->usage('Wrong number of arguments.') if !defined $key || $key eq '';
+ $self->_touch_dir;
+
+ try {
+ $self->sqitch->config->set(
+ key => $key,
+ filename => $self->file,
+ filter => $rx,
+ multiple => 0,
+ );
+ }
+ catch {
+ hurl config => __(
+ 'Cannot unset key with multiple values'
+ ) if /^Multiple occurrences/i;
+ hurl config => $_;
+ };
+ return $self;
+}
+
+sub unset_all {
+ my ( $self, $key, $rx ) = @_;
+ $self->usage('Wrong number of arguments.') if !defined $key || $key eq '';
+
+ $self->_touch_dir;
+ $self->sqitch->config->set(
+ key => $key,
+ filename => $self->file,
+ filter => $rx,
+ multiple => 1,
+ );
+ return $self;
+}
+
+sub list {
+ my $self = shift;
+ my $config = $self->context
+ ? $self->_file_config
+ : $self->sqitch->config;
+ $self->emit( scalar $config->dump ) if $config;
+ return $self;
+}
+
+sub edit {
+ my $self = shift;
+
+ # Let the editor deal with locking.
+ $self->shell(
+ $self->sqitch->editor . ' ' . $self->quote_shell( $self->file )
+ );
+}
+
+sub rename_section {
+ my ( $self, $old_name, $new_name ) = @_;
+ $self->usage('Wrong number of arguments.')
+ unless defined $old_name && $old_name ne ''
+ && defined $new_name && $new_name ne '';
+
+ try {
+ $self->sqitch->config->rename_section(
+ from => $old_name,
+ to => $new_name,
+ filename => $self->file
+ );
+ }
+ catch {
+ hurl config => __ 'No such section!' if /\Qno such section/i;
+ hurl config => $_;
+ };
+ return $self;
+}
+
+sub remove_section {
+ my ( $self, $section ) = @_;
+ $self->usage('Wrong number of arguments.')
+ unless defined $section && $section ne '';
+ try {
+ $self->sqitch->config->remove_section(
+ section => $section,
+ filename => $self->file
+ );
+ }
+ catch {
+ hurl config => __ 'No such section!' if /\Qno such section/i;
+ hurl config => $_;
+ };
+ return $self;
+}
+
+sub _touch_dir {
+ my $self = shift;
+ unless ( -e $self->file ) {
+ require File::Basename;
+ my $dir = File::Basename::dirname( $self->file );
+ unless ( -e $dir && -d _ ) {
+ require File::Path;
+ File::Path::make_path($dir);
+ }
+ }
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::config - Get and set local, user, or system Sqitch options
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::config->new(\%params);
+ $cmd->execute;
+
+=head1 Description
+
+You can query/set/replace/unset Sqitch options with this command. The name is
+actually the section and the key separated by a dot, and the value will be
+escaped.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::config->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<config> command.
+
+=head3 C<configure>
+
+ my $params = App::Sqitch::Command::config->configure(
+ $config,
+ $options,
+ );
+
+Processes the configuration and command options and returns a hash suitable
+for the constructor. Exits with an error on option specification errors.
+
+=head2 Constructor
+
+=head3 C<new>
+
+ my $config = App::Sqitch::Command::config->new($params);
+
+Creates and returns a new C<config> command object. The supported parameters
+include:
+
+=over
+
+=item C<sqitch>
+
+The core L<Sqitch|App::Sqitch> object.
+
+=item C<file>
+
+Configuration file to read from and write to.
+
+=item C<action>
+
+The action to be executed. May be one of:
+
+=over
+
+=item * C<get>
+
+=item * C<get-all>
+
+=item * C<get-regexp>
+
+=item * C<set>
+
+=item * C<add>
+
+=item * C<replace-all>
+
+=item * C<unset>
+
+=item * C<unset-all>
+
+=item * C<list>
+
+=item * C<edit>
+
+=item * C<rename-section>
+
+=item * C<remove-section>
+
+=back
+
+If not specified, the action taken by C<execute()> will depend on the number
+of arguments passed to it. If only one, the action will be C<get>. If two or
+more, the action will be C<set>.
+
+=item C<context>
+
+The configuration file context. Must be one of:
+
+=over
+
+=item * C<local>
+
+=item * C<user>
+
+=item * C<system>
+
+=back
+
+=item C<type>
+
+The type to cast a value to be set to or fetched as. May be one of:
+
+=over
+
+=item * C<bool>
+
+=item * C<int>
+
+=item * C<num>
+
+=item * C<bool-or-int>
+
+=back
+
+If not specified or C<undef>, no casting will be performed.
+
+=back
+
+=head2 Instance Methods
+
+These methods are mainly provided as utilities for the command subclasses to
+use.
+
+=head3 C<execute>
+
+ $config->execute($property, $value);
+
+Executes the config command. Pass the name of the property and the value to
+be assigned to it, if applicable.
+
+=head3 C<get>
+
+ $config->get($key);
+ $config->get($key, $regex);
+
+Emits the value for the specified key. The optional second argument is a
+regular expression that the value to be returned must match. Exits with an
+error if the is more than one value for the specified key, or if the key does
+not exist.
+
+=head3 C<get_all>
+
+ $config->get_all($key);
+ $config->get_all($key, $regex);
+
+Like C<get()>, but emits all of the values for the given key, rather then
+exiting with an error when there is more than one value.
+
+=head3 C<get_regex>
+
+ $config->get_regex($key);
+ $config->get_regex($key, $regex);
+
+Like C<get_all()>, but the first parameter is a regular expression that will
+be matched against all keys.
+
+=head3 C<set>
+
+ $config->set($key, $value);
+ $config->set($key, $value, $regex);
+
+Sets the value for a key. Exits with an error if the key already exists and
+has multiple values.
+
+=head3 C<add>
+
+ $config->add($key, $value);
+
+Adds a value for a key. If the key already exists, the value will be added as
+an additional value.
+
+=head3 C<replace_all>
+
+ $config->replace_all($key, $value);
+ $config->replace_all($key, $value, $regex);
+
+Replace all matching values.
+
+=head3 C<unset>
+
+ $config->unset($key);
+ $config->unset($key, $regex);
+
+Unsets a key. If the optional second argument is passed, the key will be unset
+only if the value matches the regular expression. If the key has multiple
+values, C<unset()> will exit with an error.
+
+=head3 C<unset_all>
+
+ $config->unset_all($key);
+ $config->unset_all($key, $regex);
+
+Like C<unset()>, but will not exit with an error if the key has multiple
+values.
+
+=head3 C<rename_section>
+
+ $config->rename_section($old_name, $new_name);
+
+Renames a section. Exits with an error if the section does not exist or if
+either name is not a valid section name.
+
+=head3 C<remove_section>
+
+ $config->remove_section($section);
+
+Removes a section. Exits with an error if the section does not exist.
+
+=head3 C<list>
+
+ $config->list;
+
+Lists all of the values in the configuration. If the context is C<local>,
+C<user>, or C<system>, only the settings set for that context will be emitted.
+Otherwise, all settings will be listed.
+
+=head3 C<edit>
+
+ $config->edit;
+
+Opens the context-specific configuration file in a text editor for direct
+editing. If no context is specified, the local config file will be opened. The
+editor is determined by L<Sqitch/editor>.
+
+=head2 Instance Accessors
+
+=head3 C<file>
+
+ my $file_name = $config->file;
+
+Returns the path to the configuration file to be acted upon. If the context is
+C<system>, then the value returned is C<$($etc_prefix)/sqitch.conf>. If the
+context is C<user>, then the value returned is C<~/.sqitch/sqitch.conf>.
+Otherwise, the default is F<./sqitch.conf>.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-config>
+
+Help for the C<config> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
+
diff --git a/lib/App/Sqitch/Command/deploy.pm b/lib/App/Sqitch/Command/deploy.pm
new file mode 100644
index 00000000..adb9f4b7
--- /dev/null
+++ b/lib/App/Sqitch/Command/deploy.pm
@@ -0,0 +1,230 @@
+package App::Sqitch::Command::deploy;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use App::Sqitch::Types qw(URI Str Bool HashRef);
+use Locale::TextDomain qw(App-Sqitch);
+use Type::Utils qw(enum);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(first);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+with 'App::Sqitch::Role::ConnectingCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+has to_change => (
+ is => 'ro',
+ isa => Str,
+);
+
+has mode => (
+ is => 'ro',
+ isa => enum([qw(
+ change
+ tag
+ all
+ )]),
+ default => 'all',
+);
+
+has log_only => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has verify => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has variables => (
+ is => 'ro',
+ isa => HashRef,
+ lazy => 1,
+ default => sub { {} },
+);
+
+sub options {
+ return qw(
+ target|t=s
+ to-change|to|change=s
+ mode=s
+ set|s=s%
+ log-only
+ verify!
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ my %params = (
+ mode => $opt->{mode} || $config->get( key => 'deploy.mode' ) || 'all',
+ verify => $opt->{verify} // $config->get( key => 'deploy.verify', as => 'boolean' ) // 0,
+ log_only => $opt->{log_only} || 0,
+ );
+ $params{to_change} = $opt->{to_change} if exists $opt->{to_change};
+ $params{target} = $opt->{target} if exists $opt->{target};
+
+ if ( my $vars = $opt->{set} ) {
+ $params{variables} = $vars;
+ }
+
+ return \%params;
+}
+
+sub _collect_vars {
+ my ($self, $target) = @_;
+ my $cfg = $self->sqitch->config;
+ return (
+ %{ $cfg->get_section(section => 'core.variables') },
+ %{ $cfg->get_section(section => 'deploy.variables') },
+ %{ $target->variables }, # includes engine
+ %{ $self->variables }, # --set
+ );
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets, $changes) = $self->parse_args(
+ target => $self->target,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $target->name,
+ )) if @{ $targets };
+
+ # Warn on too many changes.
+ my $change = $self->to_change // shift @{ $changes };
+ $self->warn(__x(
+ 'Too many changes specified; deploying to "{change}"',
+ change => $change,
+ )) if @{ $changes };
+
+ # Now get to work.
+ my $engine = $target->engine;
+ $engine->with_verify( $self->verify );
+ $engine->log_only( $self->log_only );
+ $engine->set_variables( $self->_collect_vars($target) );
+ $engine->deploy( $change, $self->mode );
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::deploy - Deploy Sqitch changes to a database
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::deploy->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<deploy> command, you probably want to be
+reading C<sqitch-deploy>. But if you really want to know how the C<deploy> command
+works, read on.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::deploy->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<deploy> command.
+
+=head2 Attributes
+
+=head3 C<log_only>
+
+Boolean indicating whether to log the deploy without running the scripts.
+
+=head3 C<mode>
+
+Deploy mode, one of "change", "tag", or "all".
+
+=head3 C<target>
+
+The deployment target URI.
+
+=head3 C<to_change>
+
+Change up to which to deploy.
+
+=head3 C<verify>
+
+Boolean indicating whether or not to run verify scripts after each change.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $deploy->execute;
+
+Executes the deploy command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-deploy>
+
+Documentation for the C<deploy> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/engine.pm b/lib/App/Sqitch/Command/engine.pm
new file mode 100644
index 00000000..7ae72e1c
--- /dev/null
+++ b/lib/App/Sqitch/Command/engine.pm
@@ -0,0 +1,457 @@
+package App::Sqitch::Command::engine;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use Types::Standard qw(Str Int HashRef);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Try::Tiny;
+use URI::db;
+use Path::Class qw(file dir);
+use List::Util qw(max first);
+use namespace::autoclean;
+use constant extra_target_keys => qw(target);
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::TargetConfigCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub _chk_engine($) {
+ my $engine = shift;
+ hurl engine => __x(
+ 'Unknown engine "{engine}"', engine => $engine
+ ) unless first { $engine eq $_ } App::Sqitch::Command::ENGINES;
+}
+
+sub configure {
+ # No config; engine config is actually engines.
+ return {};
+}
+
+sub execute {
+ my ( $self, $action ) = (shift, shift);
+ $action ||= 'list';
+ $action =~ s/-/_/g;
+ my $meth = $self->can($action) or $self->usage(__x(
+ 'Unknown action "{action}"',
+ action => $action,
+ ));
+
+ return $self->$meth(@_);
+}
+
+sub list {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $rx = join '|' => App::Sqitch::Command::ENGINES;
+ my %engines = $sqitch->config->get_regexp(key => qr/^engine[.](?:$rx)[.]target$/);
+
+ # Make it verbose if --verbose was passed at all.
+ my $format = $sqitch->options->{verbosity} ? "%1\$s\t%2\$s" : '%1$s';
+ for my $key (sort keys %engines) {
+ my ($engine) = $key =~ /engine[.]([^.]+)/;
+ $sqitch->emit(sprintf $format, $engine, $engines{$key})
+ }
+
+ return $self;
+}
+
+sub _target {
+ my ($self, $engine, $name) = @_;
+ my $target = $self->properties->{target} || $name || return;
+
+ if ($target =~ /:/) {
+ # It's URI. Return it if it uses the proper engine.
+ my $uri = URI::db->new($target, 'db:');
+ hurl engine => __x(
+ 'Cannot assign URI using engine "{new}" to engine "{old}"',
+ new => $uri->canonical_engine,
+ old => $engine,
+ ) if $uri->canonical_engine ne $engine;
+ return $uri->as_string;
+ }
+
+ # Otherwise, it needs to be a known target from the config.
+ return $target if $self->sqitch->config->get(key => "target.$target.uri");
+ hurl engine => __x(
+ 'Unknown target "{target}"',
+ target => $target
+ );
+}
+
+sub add {
+ my ($self, $engine, $target) = @_;
+ $self->usage unless $engine;
+ _chk_engine $engine;
+
+ my $key = "engine.$engine";
+ my $config = $self->sqitch->config;
+
+ hurl engine => __x(
+ 'Engine "{engine}" already exists',
+ engine => $engine
+ ) if $config->get( key => "$key.target");
+
+ # Set up the target and other config variables.
+ my $vars = $self->config_params($key);
+ unshift @{ $vars } => {
+ key => "$key.target",
+ value => $self->_target($engine, $target) || "db:$engine:",
+ };
+
+ # Make it so.
+ $config->group_set( $config->local_file, $vars );
+ $target = $self->config_target(
+ name => $target,
+ engine => $engine,
+ );
+ $self->write_plan(target => $target);
+ $self->make_directories_for($target);
+}
+
+sub alter {
+ my ($self, $engine) = @_;
+ $self->usage unless $engine;
+ _chk_engine $engine;
+
+ my $key = "engine.$engine";
+ my $config = $self->sqitch->config;
+ my $props = $self->properties;
+
+ hurl engine => __x(
+ 'Missing Engine "{engine}"; use "{command}" to add it',
+ engine => $engine,
+ command => "add $engine " . ($props->{target} || "db:$engine:"),
+ ) unless $config->get( key => "engine.$engine.target");
+
+ if (my $targ = $props->{target}) {
+ $props->{target} = $self->_target($engine, $targ) or hurl engine => __(
+ 'Cannot unset an engine target'
+ );
+ }
+
+ # Make it so.
+ $config->group_set( $config->local_file, $self->config_params($key) );
+ $self->make_directories_for( $self->config_target( engine => $engine) );
+}
+
+sub rm { shift->remove(@_) }
+sub remove {
+ my ($self, $engine) = @_;
+ $self->usage unless $engine;
+
+ my $config = $self->sqitch->config;
+ try {
+ $config->rename_section(
+ from => "engine.$engine",
+ filename => $config->local_file,
+ );
+ } catch {
+ die $_ unless /No such section/;
+ hurl engine => __x(
+ 'Unknown engine "{engine}"',
+ engine => $engine,
+ );
+ };
+ try {
+ $config->rename_section(
+ from => "engine.$engine.variables",
+ filename => $config->local_file,
+ );
+ } catch {
+ die $_ unless /No such section/;
+ };
+ return $self;
+}
+
+sub show {
+ my ($self, @names) = @_;
+ return $self->list unless @names;
+ my $sqitch = $self->sqitch;
+ my $config = $sqitch->config;
+
+ # Set up labels.
+ my %label_for = (
+ target => __ 'Target',
+ registry => __ 'Registry',
+ client => __ 'Client',
+ top_dir => __ 'Top Directory',
+ plan_file => __ 'Plan File',
+ extension => __ 'Extension',
+ revert => ' ' . __ 'Revert',
+ deploy => ' ' . __ 'Deploy',
+ verify => ' ' . __ 'Verify',
+ reworked => ' ' . __ 'Reworked',
+ );
+
+ my $len = max map { length } values %label_for;
+ $_ .= ': ' . ' ' x ($len - length $_) for values %label_for;
+
+ # Header labels.
+ $label_for{script_dirs} = __('Script Directories') . ':';
+ $label_for{reworked_dirs} = __('Reworked Script Directories') . ':';
+ $label_for{variables} = __('Variables') . ':';
+ $label_for{no_variables} = __('No Variables');
+
+ require App::Sqitch::Target;
+ for my $engine (@names) {
+ my $target = App::Sqitch::Target->new(
+ $self->target_params,
+ name => $config->get(key => "engine.$engine.target") || "db:$engine",
+ );
+
+ $self->emit("* $engine");
+ $self->emit(' ', $label_for{target}, $target->target);
+ $self->emit(' ', $label_for{registry}, $target->registry);
+ $self->emit(' ', $label_for{client}, $target->client);
+ $self->emit(' ', $label_for{top_dir}, $target->top_dir);
+ $self->emit(' ', $label_for{plan_file}, $target->plan_file);
+ $self->emit(' ', $label_for{extension}, $target->extension);
+ $self->emit(' ', $label_for{script_dirs});
+ $self->emit(' ', $label_for{deploy}, $target->deploy_dir);
+ $self->emit(' ', $label_for{revert}, $target->revert_dir);
+ $self->emit(' ', $label_for{verify}, $target->verify_dir);
+ $self->emit(' ', $label_for{reworked_dirs});
+ $self->emit(' ', $label_for{reworked}, $target->reworked_dir);
+ $self->emit(' ', $label_for{deploy}, $target->reworked_deploy_dir);
+ $self->emit(' ', $label_for{revert}, $target->reworked_revert_dir);
+ $self->emit(' ', $label_for{verify}, $target->reworked_verify_dir);
+ my $vars = $target->variables;
+ if (%{ $vars }) {
+ my $len = max map { length } keys %{ $vars };
+ $self->emit(' ', $label_for{variables});
+ $self->emit(" $_: " . (' ' x ($len - length $_)) . $vars->{$_})
+ for sort { lc $a cmp lc $b } keys %{ $vars };
+ } else {
+ $self->emit(' ', $label_for{no_variables});
+ }
+ }
+
+ return $self;
+}
+
+# DEPRECATTION: Added in v0.997 (Oct 2014). As of v0.9999, Sqitch no longer
+# notices and warns about core.$engine; most folks should long since have
+# updated their configurations. Keeping this method for now, since it might
+# still be useful and doesn't add much overhead in general except for the
+# compilation of the engine command. But consider removing in the future.
+sub update_config {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $config = $sqitch->config;
+
+ my $local_file = $config->local_file;
+ for my $file (
+ $local_file,
+ $config->user_file,
+ $config->system_file,
+ ) {
+ $sqitch->emit(__x( 'Loading {file}', file => $file ));
+ # Hide all other files. Just want to deal with the one.
+ local $ENV{SQITCH_CONFIG} = '/dev/null/not.conf';
+ local $ENV{SQITCH_USER_CONFIG} = '/dev/null/not.user';
+ local $ENV{SQITCH_SYSTEM_CONFIG} = '/dev/null/not.sys';
+ my $c = App::Sqitch::Config->new;
+ $c->load_file($file);
+ my %engines;
+ for my $ekey (App::Sqitch::Command::ENGINES) {
+ my $sect = $c->get_section( section => "core.$ekey");
+ if (%{ $sect }) {
+ if (%{ $c->get_section( section => "engine.$ekey") }) {
+ $sqitch->warn(' - ' . __x(
+ "Deprecated {section} found in {file}; to remove it, run\n {sqitch} config --file {file} --remove-section {section}",
+ section => "core.$ekey",
+ file => $file,
+ sqitch => $0,
+ ));
+ next;
+ }
+ # Migrate this one.
+ $engines{$ekey} = $sect;
+ }
+ }
+ unless (%engines) {
+ $sqitch->emit(__ ' - No engines to update');
+ next;
+ }
+
+ # Make sure we can write to the file.
+ unless (-w $file) {
+ $sqitch->warn(' - ' . __x(
+ 'Cannot update {file}. Please make it writable',
+ file => $file,
+ ));
+ next;
+ }
+
+ # Move all of the engines.
+ for my $ekey (sort keys %engines) {
+ my $old = $engines{$ekey};
+
+ my @new;
+ if ( my $target = delete $old->{target} ) {
+ # Good, there is already a specific target.
+ push @new => {
+ key => "engine.$ekey.target",
+ value => $target,
+ };
+ # Kill off deprecated variables.
+ delete $old->{$_} for qw(host port username password db_name);
+ } elsif ( $file eq $local_file ) {
+ # Start with a default and migrate deprecated configs.
+ my $uri = URI::db->new("db:$ekey:");
+ for my $spec (
+ [host => 'host'],
+ [port => 'port'],
+ [username => 'user'],
+ [password => 'password'],
+ [db_name => 'dbname'],
+ ) {
+ my ($key, $meth) = @{ $spec };
+ my $val = delete $old->{$key} or next;
+ $uri->$meth($val);
+ }
+ push @new => {
+ key => "engine.$ekey.target",
+ value => $uri->as_string,
+ };
+ } else {
+ # Just kill off any of the deprecated variables.
+ delete $old->{$_} for qw(host port username password db_name);
+ }
+
+ # Copy over the remaining variabls.
+ push @new => map {{
+ key => "engine.$ekey.$_",
+ value => $old->{$_},
+ }} keys %{ $old };
+
+ # Create the new variables and delete the old section.
+ $config->group_set( $file, \@new );
+ # $c->rename_section(
+ # from => "core.$ekey",
+ # filename => $file,
+ # );
+
+ $sqitch->emit(' - ' . __x(
+ "Migrated {old} to {new}; To remove {old}, run\n {sqitch} config --file {file} --remove-section {old}",
+ old => "core.$ekey",
+ new => "engine.$ekey",
+ sqitch => $0,
+ file => $file,
+ ));
+ }
+ }
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::engine - Add, modify, or list Sqitch database engines
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::engine->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+Manages Sqitch database engines, which are stored in the local configuration file.
+
+=head1 Interface
+
+=head3 Class Methods
+
+=head3 C<extra_target_keys>
+
+Returns a list of additional option keys to be specified via options.
+
+=head2 Instance Methods
+
+=head2 Attributes
+
+=head3 C<properties>
+
+Hash of property values to set.
+
+=head3 C<execute>
+
+ $engine->execute($command);
+
+Executes the C<engine> command.
+
+=head3 C<add>
+
+Implements the C<add> action.
+
+=head3 C<alter>
+
+Implements the C<alter> action.
+
+=head3 C<list>
+
+Implements the C<list> action.
+
+=head3 C<remove>
+
+=head3 C<rm>
+
+Implements the C<remove> action.
+
+=head3 C<show>
+
+Implements the C<show> action.
+
+=head3 C<update_config>
+
+Implements the C<update_config> action.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-engine>
+
+Documentation for the C<engine> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/help.pm b/lib/App/Sqitch/Command/help.pm
new file mode 100644
index 00000000..1b4a7a62
--- /dev/null
+++ b/lib/App/Sqitch/Command/help.pm
@@ -0,0 +1,142 @@
+package App::Sqitch::Command::help;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Types::Standard qw(Bool);
+use Pod::Find;
+use Moo;
+extends 'App::Sqitch::Command';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has guide => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+sub options {
+ # XXX Add --all at some point, to output a list of all possible commands.
+ return qw(
+ guide|g
+ );
+}
+
+sub execute {
+ my ( $self, $command ) = @_;
+ $self->find_and_show('sqitch' . (
+ $command ? ($command =~ /^changes|tutorial/ ? '' : '-') . $command
+ : $self->guide ? 'guides'
+ : 'commands'
+ ));
+}
+
+sub find_and_show {
+ my ( $self, $look_for ) = (shift, shift);
+
+ my $pod = Pod::Find::pod_where({
+ '-inc' => 1,
+ '-script' => 1
+ }, $look_for ) or hurl {
+ ident => 'help',
+ message => __x('No manual entry for {command}', command => $look_for),
+ exitval => 1,
+ };
+
+ $self->_pod2usage(
+ '-input' => $pod,
+ '-verbose' => 2,
+ '-exitval' => 0,
+ @_,
+ );
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::help - Display help information about Sqitch
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::help->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<help> command, you probably want to be
+reading C<sqitch-help>. But if you really want to know how the C<help> command
+works, read on.
+
+=head1 Interface
+
+=head2 Attributes
+
+=head3 C<guide>
+
+Boolean indicating whether to list the guides.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $help->execute($command);
+
+Executes the help command. If a command is passed, the help for that command will
+be shown. If it cannot be found, Sqitch will throw an error and exit. If no
+command is specified, the L<Sqitch core documentation|sqitch> will be shown.
+
+=head3 C<find_and_show>
+
+ $help->find_and_show($file, %options);
+
+Does the work of finding the pod file C<$file> and passing it on to
+L<Pod::Usage>, along with any additional options for Pod::Usage's constructor.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-help>
+
+Documentation for the C<help> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/init.pm b/lib/App/Sqitch/Command/init.pm
new file mode 100644
index 00000000..815882e6
--- /dev/null
+++ b/lib/App/Sqitch/Command/init.pm
@@ -0,0 +1,292 @@
+package App::Sqitch::Command::init;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use App::Sqitch::Types qw(URI Maybe);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use List::MoreUtils qw(natatime);
+use Path::Class;
+use App::Sqitch::Plan;
+use namespace::autoclean;
+use constant extra_target_keys => qw(engine target);
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::TargetConfigCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub execute {
+ my ( $self, $project ) = @_;
+ $self->_validate_project($project);
+ $self->write_config;
+ my $target = $self->config_target;
+ $self->write_plan(
+ project => $project,
+ uri => $self->uri,
+ target => $target,
+ );
+ $self->make_directories_for($target);
+ return $self;
+}
+
+has uri => (
+ is => 'ro',
+ isa => Maybe[URI],
+);
+
+sub options {
+ return qw(uri=s);
+}
+
+sub _validate_project {
+ my ( $self, $project ) = @_;
+ $self->usage unless $project;
+ my $name_re = 'App::Sqitch::Plan'->name_regex;
+ hurl init => __x(
+ qq{invalid project name "{project}": project names must not }
+ . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in '
+ . 'punctuation or digits following punctuation',
+ project => $project
+ ) unless $project =~ /\A$name_re\z/;
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ if ( my $uri = $opt->{uri} ) {
+ require URI;
+ $opt->{uri} = 'URI'->new($uri);
+ }
+
+ return $opt;
+}
+
+sub write_config {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $config = $sqitch->config;
+ my $file = $config->local_file;
+ if ( -f $file ) {
+
+ # Do nothing? Update config?
+ return $self;
+ }
+
+ my ( @vars, @comments );
+
+ # Get the props, and make sure the target can find the engine.
+ my $props = $self->properties;
+ my $target = $self->config_target;
+
+ # Write the engine from --engine or core.engine.
+ my $ekey = $props->{engine} || $target->engine_key;
+ if ($ekey) {
+ push @vars => {
+ key => "core.engine",
+ value => $ekey,
+ };
+ }
+ else {
+ push @comments => "\tengine = ";
+ }
+
+ # Add core properties.
+ for my $name (qw(
+ plan_file
+ top_dir
+ )) {
+ # Set properties passed on the command-line.
+ if ( my $val = $props->{$name} ) {
+ push @vars => {
+ key => "core.$name",
+ value => $val,
+ };
+ }
+ else {
+ my $val //= $target->$name // '';
+ push @comments => "\t$name = $val";
+ }
+ }
+
+ # Add script options passed to the init command. No comments if not set.
+ for my $attr (qw(
+ extension
+ deploy_dir
+ revert_dir
+ verify_dir
+ reworked_dir
+ reworked_deploy_dir
+ reworked_revert_dir
+ reworked_verify_dir
+ )) {
+ push @vars => { key => "core.$attr", value => $props->{$attr} }
+ if defined $props->{$attr};
+ }
+
+ # Add variables.
+ if (my $vars = $props->{variables}) {
+ push @vars => map {{
+ key => "core.variables.$_",
+ value => $vars->{$_},
+ }} keys %{ $vars };
+ }
+
+ # Emit them.
+ if (@vars) {
+ $config->group_set( $file => \@vars );
+ }
+ else {
+ unshift @comments => '[core]';
+ }
+
+ # Emit the comments.
+ $config->add_comment(
+ filename => $file,
+ indented => 1,
+ comment => join "\n" => @comments,
+ ) if @comments;
+
+ if ($ekey) {
+ # Write out the engine.$engine section.
+ my $config_key = "engine.$ekey";
+ @comments = @vars = ();
+
+ for my $key (qw(target registry client)) {
+ # Was it passed as an option?
+ if ( my $val = $props->{$key} ) {
+ push @vars => {
+ key => "$config_key.$key",
+ value => $val,
+ };
+ # We're good on this one.
+ next;
+ }
+
+ # No value, but add it as a comment, possibly with a default.
+ my $def = $target->$key
+ // $config->get( key => "$config_key.$key" )
+ // '';
+ push @comments => "\t$key = $def";
+ }
+
+ if (@vars) {
+ # Emit them.
+ $config->group_set( $file => \@vars ) if @vars;
+ }
+ else {
+ # Still want the section, emit it as a comment.
+ unshift @comments => qq{[engine "$ekey"]};
+ }
+
+ # Emit the comments.
+ $config->add_comment(
+ filename => $file,
+ indented => 1,
+ comment => join "\n" => @comments,
+ ) if @comments;
+ }
+
+ # Is there are target?
+ if (my $target_name = $props->{target}) {
+ # If it's a named target, add it to the configuration.
+ $config->set(
+ filename => $file,
+ key => "target.$target_name.uri",
+ value => $target->uri,
+ ) if $target_name !~ /:/
+ }
+
+ $self->info( __x 'Created {file}', file => $file );
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::init - Initialize a Sqitch project
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::init->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+This command creates the files and directories for a new Sqitch project -
+basically a F<sqitch.conf> file and directories for deploy and revert
+scripts.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::init->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<config> command.
+
+=head3 C<extra_target_keys>
+
+Returns a list of additional option keys to be specified via options.
+
+=head2 Attributes
+
+=head3 C<uri>
+
+URI for the project.
+
+=head3 C<properties>
+
+Hash of property values to set.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $init->execute($project);
+
+Executes the C<init> command.
+
+=head3 C<write_config>
+
+ $init->write_config;
+
+Writes out the configuration file. Called by C<execute()>.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
+
diff --git a/lib/App/Sqitch/Command/log.pm b/lib/App/Sqitch/Command/log.pm
new file mode 100644
index 00000000..add1dffc
--- /dev/null
+++ b/lib/App/Sqitch/Command/log.pm
@@ -0,0 +1,373 @@
+package App::Sqitch::Command::log;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Moo;
+use Types::Standard qw(Str Int ArrayRef Bool);
+use Type::Utils qw(class_type);
+use App::Sqitch::ItemFormatter;
+use namespace::autoclean;
+use Try::Tiny;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ConnectingCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+my %FORMATS;
+$FORMATS{raw} = <<EOF;
+%{:event}C%e %H%{reset}C%T
+name %n
+project %o
+%{requires}a%{conflicts}aplanner %{name}p <%{email}p>
+planned %{date:raw}p
+committer %{name}c <%{email}c>
+committed %{date:raw}c
+
+%{ }B
+EOF
+
+$FORMATS{full} = <<EOF;
+%{:event}C%L %h%{reset}C%T
+%{name}_ %n
+%{project}_ %o
+%R%X%{planner}_ %p
+%{planned}_ %{date}p
+%{committer}_ %c
+%{committed}_ %{date}c
+
+%{ }B
+EOF
+
+$FORMATS{long} = <<EOF;
+%{:event}C%L %h%{reset}C%T
+%{name}_ %n
+%{project}_ %o
+%{planner}_ %p
+%{committer}_ %c
+
+%{ }B
+EOF
+
+$FORMATS{medium} = <<EOF;
+%{:event}C%L %h%{reset}C
+%{name}_ %n
+%{committer}_ %c
+%{date}_ %{date}c
+
+%{ }B
+EOF
+
+$FORMATS{short} = <<EOF;
+%{:event}C%L %h%{reset}C
+%{name}_ %n
+%{committer}_ %c
+
+%{ }s
+EOF
+
+$FORMATS{oneline} = '%{:event}C%h %l%{reset}C %o:%n %s';
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+has event => (
+ is => 'ro',
+ isa => ArrayRef,
+);
+
+has change_pattern => (
+ is => 'ro',
+ isa => Str,
+);
+
+has project_pattern => (
+ is => 'ro',
+ isa => Str,
+);
+
+has committer_pattern => (
+ is => 'ro',
+ isa => Str,
+);
+
+has max_count => (
+ is => 'ro',
+ isa => Int,
+);
+
+has skip => (
+ is => 'ro',
+ isa => Int,
+);
+
+has reverse => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has headers => (
+ is => 'ro',
+ isa => Bool,
+ default => 1,
+);
+
+has format => (
+ is => 'ro',
+ isa => Str,
+ default => $FORMATS{medium},
+);
+
+has formatter => (
+ is => 'ro',
+ isa => class_type('App::Sqitch::ItemFormatter'),
+ lazy => 1,
+ default => sub { App::Sqitch::ItemFormatter->new },
+);
+
+sub options {
+ return qw(
+ event=s@
+ target|t=s
+ change-pattern|change=s
+ project-pattern|project=s
+ committer-pattern|committer=s
+ format|f=s
+ date-format|date=s
+ max-count|n=i
+ skip=i
+ reverse!
+ color=s
+ no-color
+ abbrev=i
+ oneline
+ headers!
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ # Set base values if --oneline.
+ if ($opt->{oneline}) {
+ $opt->{format} ||= 'oneline';
+ $opt->{abbrev} //= 6;
+ }
+
+ # Determine and validate the date format.
+ my $date_format = delete $opt->{date_format} || $config->get(
+ key => 'log.date_format'
+ );
+ if ($date_format) {
+ require App::Sqitch::DateTime;
+ App::Sqitch::DateTime->validate_as_string_format($date_format);
+ } else {
+ $date_format = 'iso';
+ }
+
+ # Make sure the log format is valid.
+ if (my $format = $opt->{format}
+ || $config->get(key => 'log.format')
+ ) {
+ if ($format =~ s/^format://) {
+ $opt->{format} = $format;
+ } else {
+ $opt->{format} = $FORMATS{$format} or hurl log => __x(
+ 'Unknown log format "{format}"',
+ format => $format
+ );
+ }
+ }
+
+ # Determine how to handle ANSI colors.
+ my $color = delete $opt->{no_color} ? 'never'
+ : delete $opt->{color} || $config->get(key => 'log.color');
+
+ $opt->{formatter} = App::Sqitch::ItemFormatter->new(
+ ( $date_format ? ( date_format => $date_format ) : () ),
+ ( $color ? ( color => $color ) : () ),
+ ( $opt->{abbrev} ? ( abbrev => delete $opt->{abbrev} ) : () ),
+ );
+
+ return $class->SUPER::configure( $config, $opt );
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets) = $self->parse_args(
+ target => $self->target,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $target->name,
+ )) if @{ $targets };
+ my $engine = $target->engine;
+
+ # Exit with status 1 on uninitialized database, probably not expected.
+ hurl {
+ ident => 'log',
+ exitval => 1,
+ message => __x(
+ 'Database {db} has not been initialized for Sqitch',
+ db => $engine->registry_destination,
+ ),
+ } unless $engine->initialized;
+
+ # Exit with status 1 on no events, probably not expected.
+ my $iter = $engine->search_events(limit => 1);
+ hurl {
+ ident => 'log',
+ exitval => 1,
+ message => __x(
+ 'No events logged for {db}',
+ db => $engine->destination,
+ ),
+ } unless $iter->();
+
+ # Search the event log.
+ $iter = $engine->search_events(
+ event => $self->event,
+ change => $self->change_pattern,
+ project => $self->project_pattern,
+ committer => $self->committer_pattern,
+ limit => $self->max_count,
+ offset => $self->skip,
+ direction => $self->reverse ? 'ASC' : 'DESC',
+ );
+
+ # Send the results.
+ my $formatter = $self->formatter;
+ my $format = $self->format;
+ $self->page( __x 'On database {db}', db => $engine->destination )
+ if $self->headers;
+ while ( my $change = $iter->() ) {
+ $self->page( $formatter->format( $format, $change ) );
+ }
+
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::log - Show a database event log
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::log->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<log> command, you probably want to be
+reading C<sqitch-log>. But if you really want to know how the C<log> command
+works, read on.
+
+=head1 Interface
+
+=head2 Attributes
+
+=head3 C<change_pattern>
+
+Regular expression to match against change names.
+
+=head3 C<committer_pattern>
+
+Regular expression to match against committer names.
+
+=head3 C<project_pattern>
+
+Regular expression to match against project names.
+
+=head3 C<event>
+
+Event type buy which to filter entries to display.
+
+=head3 C<format>
+
+Display format template.
+
+=head3 C<max_count>
+
+Maximum number of entries to display.
+
+=head3 C<reverse>
+
+Reverse the usual order of the display of entries.
+
+=head3 C<headers>
+
+Output headers. Defaults to true.
+
+=head3 C<skip>
+
+Number of entries to skip before displaying entries.
+
+=head3 C<target>
+
+The database target from which to read the log.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $log->execute;
+
+Executes the log command. The current log for the target database will be
+searched and the resulting change history displayed.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-log>
+
+Documentation for the C<log> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/plan.pm b/lib/App/Sqitch/Command/plan.pm
new file mode 100644
index 00000000..e31a1738
--- /dev/null
+++ b/lib/App/Sqitch/Command/plan.pm
@@ -0,0 +1,354 @@
+package App::Sqitch::Command::plan;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Moo;
+use Types::Standard qw(Str Int ArrayRef Bool);
+use Type::Utils qw(class_type);
+use App::Sqitch::ItemFormatter;
+use namespace::autoclean;
+use Try::Tiny;
+extends 'App::Sqitch::Command';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+my %FORMATS;
+$FORMATS{raw} = <<EOF;
+%{:event}C%e %H%{reset}C%T
+name %n
+project %o
+%{requires}a%{conflicts}aplanner %{name}p <%{email}p>
+planned %{date:raw}p
+
+%{ }B
+EOF
+
+$FORMATS{full} = <<EOF;
+%{:event}C%L %h%{reset}C%T
+%{name}_ %n
+%{project}_ %o
+%R%X%{planner}_ %p
+%{planned}_ %{date}p
+
+%{ }B
+EOF
+
+$FORMATS{long} = <<EOF;
+%{:event}C%L %h%{reset}C%T
+%{name}_ %n
+%{project}_ %o
+%{planner}_ %p
+
+%{ }B
+EOF
+
+$FORMATS{medium} = <<EOF;
+%{:event}C%L %h%{reset}C
+%{name}_ %n
+%{planner}_ %p
+%{date}_ %{date}p
+
+%{ }B
+EOF
+
+$FORMATS{short} = <<EOF;
+%{:event}C%L %h%{reset}C
+%{name}_ %n
+%{planner}_ %p
+
+%{ }s
+EOF
+
+$FORMATS{oneline} = '%{:event}C%h %l%{reset}C %n%{cyan}C%t%{reset}C';
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+has event => (
+ is => 'ro',
+ isa => Str,
+);
+
+has change_pattern => (
+ is => 'ro',
+ isa => Str,
+);
+
+has planner_pattern => (
+ is => 'ro',
+ isa => Str,
+);
+
+has max_count => (
+ is => 'ro',
+ isa => Int,
+);
+
+has skip => (
+ is => 'ro',
+ isa => Int,
+);
+
+has reverse => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has headers => (
+ is => 'ro',
+ isa => Bool,
+ default => 1,
+);
+
+has format => (
+ is => 'ro',
+ isa => Str,
+ default => $FORMATS{medium},
+);
+
+has formatter => (
+ is => 'ro',
+ isa => class_type('App::Sqitch::ItemFormatter'),
+ lazy => 1,
+ default => sub { App::Sqitch::ItemFormatter->new },
+);
+
+sub options {
+ return qw(
+ event=s
+ target|t=s
+ change-pattern|change=s
+ planner-pattern|planner=s
+ format|f=s
+ date-format|date=s
+ max-count|n=i
+ skip=i
+ reverse!
+ color=s
+ no-color
+ abbrev=i
+ oneline
+ headers!
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ # Set base values if --oneline.
+ if ($opt->{oneline}) {
+ $opt->{format} ||= 'oneline';
+ $opt->{abbrev} //= 6;
+ }
+
+ # Determine and validate the date format.
+ my $date_format = delete $opt->{date_format} || $config->get(
+ key => 'plan.date_format'
+ );
+ if ($date_format) {
+ require App::Sqitch::DateTime;
+ App::Sqitch::DateTime->validate_as_string_format($date_format);
+ } else {
+ $date_format = 'iso';
+ }
+
+ # Make sure the plan format is valid.
+ if (my $format = $opt->{format}
+ || $config->get(key => 'plan.format')
+ ) {
+ if ($format =~ s/^format://) {
+ $opt->{format} = $format;
+ } else {
+ $opt->{format} = $FORMATS{$format} or hurl plan => __x(
+ 'Unknown plan format "{format}"',
+ format => $format
+ );
+ }
+ }
+
+ # Determine how to handle ANSI colors.
+ my $color = delete $opt->{no_color} ? 'never'
+ : delete $opt->{color} || $config->get(key => 'plan.color');
+
+ $opt->{formatter} = App::Sqitch::ItemFormatter->new(
+ ( $date_format ? ( date_format => $date_format ) : () ),
+ ( $color ? ( color => $color ) : () ),
+ ( $opt->{abbrev} ? ( abbrev => delete $opt->{abbrev} ) : () ),
+ );
+
+ return $class->SUPER::configure( $config, $opt );
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets) = $self->parse_args(
+ target => $self->target,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; using {target}',
+ target => $target->name,
+ )) if @{ $targets };
+ my $plan = $target->plan;
+
+ # Exit with status 1 on no changes, probably not expected.
+ hurl {
+ ident => 'plan',
+ exitval => 1,
+ message => __x(
+ 'No changes in {file}',
+ file => $plan->file,
+ ),
+ } unless $plan->count;
+
+ # Search the changes.
+ my $iter = $plan->search_changes(
+ operation => $self->event,
+ name => $self->change_pattern,
+ planner => $self->planner_pattern,
+ limit => $self->max_count,
+ offset => $self->skip,
+ direction => $self->reverse ? 'DESC' : 'ASC',
+ );
+
+ # Send the results.
+ my $formatter = $self->formatter;
+ my $format = $self->format;
+ if ($self->headers) {
+ $self->page( '# ', __x 'Project: {project}', project => $plan->project );
+ $self->page( '# ', __x 'File: {file}', file => $plan->file );
+ $self->page('');
+ }
+ while ( my $change = $iter->() ) {
+ $self->page( $formatter->format( $format, {
+ event => $change->is_deploy ? 'deploy' : 'revert',
+ project => $change->project,
+ change_id => $change->id,
+ change => $change->name,
+ note => $change->note,
+ tags => [ map { $_->format_name } $change->tags ],
+ requires => [ map { $_->as_string } $change->requires ],
+ conflicts => [ map { $_->as_string } $change->conflicts ],
+ planned_at => $change->timestamp,
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ } ) );
+ }
+
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::plan - List the changes in the plan
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::plan->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<plan> command, you probably want to be
+reading C<sqitch-plan>. But if you really want to know how the C<plan> command
+works, read on.
+
+=head1 Interface
+
+=head2 Attributes
+
+=head3 C<change_pattern>
+
+Regular expression to match against change names.
+
+=head3 C<planner_pattern>
+
+Regular expression to match against planner names.
+
+=head3 C<event>
+
+Event type buy which to filter entries to display.
+
+=head3 C<format>
+
+Display format template.
+
+=head3 C<max_count>
+
+Maximum number of entries to display.
+
+=head3 C<reverse>
+
+Reverse the usual order of the display of entries.
+
+=head3 C<headers>
+
+Output headers. Defaults to true.
+
+=head3 C<skip>
+
+Number of entries to skip before displaying entries.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $plan->execute;
+
+Executes the plan command. The plan will be searched and the results output.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-plan>
+
+Documentation for the C<plan> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/rebase.pm b/lib/App/Sqitch/Command/rebase.pm
new file mode 100644
index 00000000..995a8097
--- /dev/null
+++ b/lib/App/Sqitch/Command/rebase.pm
@@ -0,0 +1,183 @@
+package App::Sqitch::Command::rebase;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use Types::Standard qw(Str);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(first);
+use Try::Tiny;
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::RevertDeployCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has onto_change => (
+ is => 'ro',
+ isa => Str,
+);
+
+has upto_change => (
+ is => 'ro',
+ isa => Str,
+);
+
+sub options {
+ return qw(
+ onto-change|onto=s
+ upto-change|upto=s
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+ return { map { $_ => $opt->{$_} } grep { exists $opt->{$_} } qw(
+ onto_change
+ upto_change
+ ) };
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets, $changes) = $self->parse_args(
+ target => $self->target,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $target->name,
+ )) if @{ $targets };
+
+ # Warn on too many changes.
+ my $onto = $self->onto_change // shift @{ $changes };
+ my $upto = $self->upto_change // shift @{ $changes };
+ $self->warn(__x(
+ 'Too many changes specified; rebasing onto "{onto}" up to "{upto}"',
+ onto => $onto,
+ upto => $upto,
+ )) if @{ $changes };
+
+
+ # Now get to work.
+ my $engine = $target->engine;
+ $engine->with_verify( $self->verify );
+ $engine->no_prompt( $self->no_prompt );
+ $engine->prompt_accept( $self->prompt_accept );
+ $engine->log_only( $self->log_only );
+
+ # Revert.
+ $engine->set_variables( $self->_collect_revert_vars($target) );
+ try {
+ $engine->revert( $onto );
+ } catch {
+ # Rethrow unknown errors or errors with exitval > 1.
+ die $_ if ! eval { $_->isa('App::Sqitch::X') }
+ || $_->exitval > 1
+ || $_->ident eq 'revert:confirm';
+ # Emit notice of non-fatal errors (e.g., nothing to revert).
+ $self->info($_->message)
+ };
+
+ # Deploy.
+ $engine->set_variables( $self->_collect_deploy_vars($target) );
+ $engine->deploy( $upto, $self->mode );
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::rebase - Revert and redeploy Sqitch changes
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::rebase->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<rebase> command, you probably want to be
+reading C<sqitch-rebase>. But if you really want to know how the C<rebase> command
+works, read on.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::rebase->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<rebase> command.
+
+=head2 Attributes
+
+=head3 C<onto_change>
+
+Change onto which to rebase the target.
+
+=head3 C<upto_change>
+
+Change up to which to rebase the target.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $rebase->execute;
+
+Executes the rebase command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-rebase>
+
+Documentation for the C<rebase> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/revert.pm b/lib/App/Sqitch/Command/revert.pm
new file mode 100644
index 00000000..b731c159
--- /dev/null
+++ b/lib/App/Sqitch/Command/revert.pm
@@ -0,0 +1,234 @@
+package App::Sqitch::Command::revert;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use Types::Standard qw(Str Bool HashRef);
+use List::Util qw(first);
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+with 'App::Sqitch::Role::ConnectingCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+has to_change => (
+ is => 'ro',
+ isa => Str,
+);
+
+has no_prompt => (
+ is => 'ro',
+ isa => Bool
+);
+
+has prompt_accept => (
+ is => 'ro',
+ isa => Bool
+);
+
+has log_only => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has variables => (
+ is => 'ro',
+ isa => HashRef,
+ lazy => 1,
+ default => sub { {} },
+);
+
+sub options {
+ return qw(
+ target|t=s
+ to-change|to|change=s
+ set|s=s%
+ log-only
+ y
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ my %params = map { $_ => $opt->{$_} } grep { exists $opt->{$_} } qw(
+ to_change
+ log_only
+ target
+ );
+
+ if ( my $vars = $opt->{set} ) {
+ $params{variables} = $vars
+ }
+
+ $params{no_prompt} = delete $opt->{y} // $config->get(
+ key => 'revert.no_prompt',
+ as => 'bool',
+ ) // 0;
+
+ $params{prompt_accept} = $config->get(
+ key => 'revert.prompt_accept',
+ as => 'bool',
+ ) // 1;
+
+ return \%params;
+}
+
+sub _collect_vars {
+ my ($self, $target) = @_;
+ my $cfg = $self->sqitch->config;
+ return (
+ %{ $cfg->get_section(section => 'core.variables') },
+ %{ $cfg->get_section(section => 'deploy.variables') },
+ %{ $cfg->get_section(section => 'revert.variables') },
+ %{ $target->variables }, # includes engine
+ %{ $self->variables }, # --set
+ );
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets, $changes) = $self->parse_args(
+ target => $self->target,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $target->name,
+ )) if @{ $targets };
+
+ # Warn on too many changes.
+ my $change = $self->to_change // shift @{ $changes };
+ $self->warn(__x(
+ 'Too many changes specified; reverting to "{change}"',
+ change => $change,
+ )) if @{ $changes };
+
+ # Now get to work.
+ my $engine = $target->engine;
+ $engine->no_prompt( $self->no_prompt );
+ $engine->prompt_accept( $self->prompt_accept );
+ $engine->log_only( $self->log_only );
+ $engine->set_variables( $self->_collect_vars($target) );
+ $engine->revert( $change );
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::revert - Revert Sqitch changes from a database
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::revert->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<revert> command, you probably want to be
+reading C<sqitch-revert>. But if you really want to know how the C<revert> command
+works, read on.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::revert->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<revert> command.
+
+=head2 Attributes
+
+=head3 C<log_only>
+
+Boolean indicating whether to log the deploy without running the scripts.
+
+=head3 C<no_prompt>
+
+Boolean indicating whether or not to prompt the user to really go through with
+the revert.
+
+=head3 C<prompt_accept>
+
+Boolean value to indicate whether or not the default value for the prompt,
+should the user hit C<return>, is to accept the prompt or deny it.
+
+=head3 C<target>
+
+The deployment target URI.
+
+=head3 C<to_change>
+
+Change to revert to.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $revert->execute;
+
+Executes the revert command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-revert>
+
+Documentation for the C<revert> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/rework.pm b/lib/App/Sqitch/Command/rework.pm
new file mode 100644
index 00000000..7b885689
--- /dev/null
+++ b/lib/App/Sqitch/Command/rework.pm
@@ -0,0 +1,333 @@
+package App::Sqitch::Command::rework;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use File::Copy;
+use Moo;
+use App::Sqitch::Types qw(Str ArrayRef Bool Maybe);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has change_name => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has requires => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ default => sub { [] },
+);
+
+has conflicts => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ default => sub { [] },
+);
+
+has all => (
+ is => 'ro',
+ isa => Bool,
+ default => 0
+);
+
+has note => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ default => sub { [] },
+);
+
+has open_editor => (
+ is => 'ro',
+ isa => Bool,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ return $self->sqitch->config->get(
+ key => 'rework.open_editor',
+ as => 'bool',
+ ) // $self->sqitch->config->get(
+ key => 'add.open_editor',
+ as => 'bool',
+ ) // 0;
+ },
+);
+
+sub options {
+ return qw(
+ change-name|change|c=s
+ requires|r=s@
+ conflicts|x=s@
+ all|a!
+ note|n|m=s@
+ open-editor|edit|e!
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+ # Just keep the options.
+ return $opt;
+}
+
+sub execute {
+ my $self = shift;
+ my ($name, $targets, $changes) = $self->parse_args(
+ names => [$self->change_name],
+ all => $self->all,
+ args => \@_,
+ no_changes => 1,
+ );
+
+ # Check if the name is identified as a change.
+ $name ||= shift @{ $changes } || $self->usage;
+
+ my $note = join "\n\n", => @{ $self->note };
+ my ($first_change, %reworked, @files, %seen);
+
+ for my $target (@{ $targets }) {
+ my $plan = $target->plan;
+ my $file = $plan->file;
+ my $spec = $reworked{$file} ||= { scripts => [] };
+ my ($prev, $reworked);
+ if ($prev = $spec->{prev}) {
+ # Need a dupe for *this* target so script names are right.
+ $reworked = ref($prev)->new(
+ plan => $plan,
+ name => $name,
+ );
+
+ # Copy the rework tags to the previous instance in this plan.
+ my $new_prev = $spec->{prev} = $plan->get(
+ $name . [$plan->last_tagged_change->tags]->[-1]->format_name
+ );
+ $new_prev->add_rework_tags($prev->rework_tags);
+ $prev = $new_prev;
+
+ } else {
+ # Rework it.
+ $reworked = $spec->{change} = $plan->rework(
+ name => $name,
+ requires => $self->requires,
+ conflicts => $self->conflicts,
+ note => $note,
+ );
+ $first_change ||= $reworked;
+
+ # Get the latest instance of the change.
+ $prev = $spec->{prev} = $plan->get(
+ $name . [$plan->last_tagged_change->tags]->[-1]->format_name
+ );
+ }
+
+ # Record the files to be copied to the previous change name.
+ push @{ $spec->{scripts} } => map {
+ push @files => $_->[0] if -e $_->[0];
+ $_;
+ } grep {
+ !$seen{ $_->[0] }++;
+ } (
+ [ $reworked->deploy_file, $prev->deploy_file ],
+ [ $reworked->revert_file, $prev->revert_file ],
+ [ $reworked->verify_file, $prev->verify_file ],
+ );
+
+ # Replace the revert file with the previous deploy file.
+ push @{ $spec->{scripts} } => [
+ $reworked->deploy_file,
+ $reworked->revert_file,
+ $prev->revert_file,
+ ] unless $seen{$prev->revert_file}++;
+ }
+
+ # Make sure we have a note.
+ $note = $first_change->request_note(
+ for => __ 'rework',
+ scripts => \@files,
+ );
+
+ # Time to write everything out.
+ for my $target (@{ $targets }) {
+ my $plan = $target->plan;
+ my $file = $plan->file;
+ my $spec = delete $reworked{$file} or next;
+
+ # Copy the files for this spec.
+ $self->_copy(@{ $_ }) for @{ $spec->{scripts } };
+
+ # We good, write the plan file back out.
+ $plan->write_to( $plan->file );
+
+ # Let the user know.
+ $self->info(__x(
+ 'Added "{change}" to {file}.',
+ change => $spec->{change}->format_op_name_dependencies,
+ file => $plan->file,
+ ));
+ }
+
+ # Now tell them what to do.
+ $self->info(__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ scalar @files,
+ ));
+ $self->info(" * $_") for @files;
+
+ # Let 'em at it.
+ if ($self->open_editor) {
+ my $sqitch = $self->sqitch;
+ $sqitch->shell( $sqitch->editor . ' ' . $sqitch->quote_shell(@files) );
+ }
+
+ return $self;
+}
+
+sub _copy {
+ my ( $self, $src, $dest, $orig ) = @_;
+ $orig ||= $src;
+ if (!-e $orig) {
+ $self->debug(__x(
+ 'Skipped {dest}: {src} does not exist',
+ dest => $dest,
+ src => $orig,
+ ));
+ return;
+ }
+
+ # Stringify to work around bug in File::Copy warning on 5.10.0.
+ File::Copy::syscopy "$src", "$dest" or hurl rework => __x(
+ 'Cannot copy {src} to {dest}: {error}',
+ src => $src,
+ dest => $dest,
+ error => $!,
+ );
+
+ $self->debug(__x(
+ 'Copied {src} to {dest}',
+ dest => $dest,
+ src => $src,
+ ));
+ return $orig;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::rework - Rework a Sqitch change
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::rework->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+Reworks a change. This will result in the copying of the existing deploy,
+revert, and verify scripts for the change to preserve the earlier instances of
+the change.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::rework->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<rework> command.
+
+=head3 C<configure>
+
+ my $params = App::Sqitch::Command::rework->configure(
+ $config,
+ $options,
+ );
+
+Processes the configuration and command options and returns a hash suitable
+for the constructor.
+
+=head2 Attributes
+
+=head3 C<change_name>
+
+The name of the change to be reworked.
+
+=head3 C<note>
+
+Text of the change note.
+
+=head3 C<requires>
+
+List of required changes.
+
+=head3 C<conflicts>
+
+List of conflicting changes.
+
+=head3 C<all>
+
+Boolean indicating whether or not to run the command against all plans in the
+project.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $rework->execute($command);
+
+Executes the C<rework> command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-rework>
+
+Documentation for the C<rework> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/show.pm b/lib/App/Sqitch/Command/show.pm
new file mode 100644
index 00000000..2fdcdc2b
--- /dev/null
+++ b/lib/App/Sqitch/Command/show.pm
@@ -0,0 +1,203 @@
+package App::Sqitch::Command::show;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(first);
+use Moo;
+use App::Sqitch::Types qw(Bool Str);
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+has exists_only => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+sub options {
+ return qw(
+ target|t=s
+ exists|e!
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+ $opt->{exists_only} = delete $opt->{exists}
+ if exists $opt->{exists};
+ return $class->SUPER::configure( $config, $opt );
+}
+
+sub execute {
+ my ( $self, $type, $key ) = @_;
+ $self->usage unless $type && $key;
+
+ my $target = $self->target ? App::Sqitch::Target->new(
+ $self->target_params,
+ name => $self->target,
+ ) : $self->default_target;
+ my $plan = $target->plan;
+
+ # Handle tags first.
+ if ( $type eq 'tag' ) {
+ my $is_id = $key =~ /^[0-9a-f]{40}/;
+ my $change = $plan->get(
+ $is_id ? $key : ($key =~ /^@/ ? '' : '@') . $key
+ );
+
+ my $tag = $change ? do {
+ if ($is_id) {
+ # It's a tag ID.
+ first { $_->id eq $key } $change->tags;
+ } else {
+ # Tag name.
+ (my $name = $key) =~ s/^[@]//;
+ first { $_->name eq $name } $change->tags;
+ }
+ } : undef;
+ unless ($tag) {
+ return if $self->exists_only;
+ hurl show => __x( 'Unknown tag "{tag}"', tag => $key );
+ }
+ $self->emit( $tag->info ) unless $self->exists_only;
+ return $self;
+ }
+
+ # Make sure we recognize the type.
+ hurl show => __x(
+ 'Unknown object type "{type}',
+ type => $type,
+ ) unless first { $type eq $_ } qw(change deploy revert verify);
+
+ # Make sure we have a change object.
+ my $change = $plan->get($key) or do {
+ return if $self->exists_only;
+ hurl show => __x(
+ 'Unknown change "{change}"',
+ change => $key
+ );
+ };
+
+ if ($type eq 'change') {
+ # Just show its info.
+ $self->emit( $change->info ) unless $self->exists_only;
+ return $self;
+ }
+
+ my $meth = $change->can("$type\_file");
+ my $path = $change->$meth;
+ unless (-e $path) {
+ return if $self->exists_only;
+ hurl show => __x('File "{path}" does not exist', path => $path);
+ }
+ hurl show => __x('"{path}" is not a file', path => $path)
+ if $path->is_dir;
+
+ return $self if $self->exists_only;
+
+ # Assume nothing about the encoding.
+ binmode STDOUT, ':raw';
+ $self->emit( $path->slurp(iomode => '<:raw') );
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::show - Show Sqitch changes to a database
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::show->new(%params);
+ $cmd->execute($type, $name);
+
+=head1 Description
+
+Shows the content of a Sqitch object.
+
+If you want to know how to use the C<show> command, you probably want to be
+reading C<sqitch-show>. But if you really want to know how the C<show> command
+works, read on.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::show->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<show> command.
+
+=head2 Attributes
+
+=head3 C<exists_only>
+
+Boolean indicating whether or not to suppress output and instead exit with
+zero status if object exists and is a valid object.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $show->execute;
+
+Executes the show command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-show>
+
+Documentation for the C<show> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/status.pm b/lib/App/Sqitch/Command/status.pm
new file mode 100644
index 00000000..51a9071e
--- /dev/null
+++ b/lib/App/Sqitch/Command/status.pm
@@ -0,0 +1,432 @@
+package App::Sqitch::Command::status;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Moo;
+use App::Sqitch::Types qw(Str Bool Target);
+use List::Util qw(max);
+use Try::Tiny;
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+with 'App::Sqitch::Role::ConnectingCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has target_name => (
+ is => 'ro',
+ isa => Str,
+);
+
+has target => (
+ is => 'rw',
+ isa => Target,
+ handles => [qw(engine plan plan_file)],
+);
+
+has show_changes => (
+ is => 'ro',
+ isa => Bool,
+ lazy => 1,
+ default => sub {
+ shift->sqitch->config->get(
+ key => "status.show_changes",
+ as => 'bool',
+ ) // 0;
+ }
+);
+
+has show_tags => (
+ is => 'ro',
+ isa => Bool,
+ lazy => 1,
+ default => sub {
+ shift->sqitch->config->get(
+ key => "status.show_tags",
+ as => 'bool',
+ ) // 0;
+ }
+);
+
+has date_format => (
+ is => 'ro',
+ lazy => 1,
+ isa => Str,
+ default => sub {
+ shift->sqitch->config->get( key => 'status.date_format' ) || 'iso'
+ }
+);
+
+has project => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ try { $self->plan->project } catch {
+ # Just die on parse and I/O errors.
+ die $_ if try { $_->ident eq 'parse' || $_->ident eq 'io' };
+
+ # Try to extract a project name from the registry.
+ my $engine = $self->engine;
+ hurl status => __ 'Database not initialized for Sqitch'
+ unless $engine->initialized;
+ my @projs = $engine->registered_projects
+ or hurl status => __ 'No projects registered';
+ hurl status => __x(
+ 'Use --project to select which project to query: {projects}',
+ projects => join __ ', ', @projs,
+ ) if @projs > 1;
+ return $projs[0];
+ };
+ },
+);
+
+sub options {
+ return qw(
+ project=s
+ target|t=s
+ show-tags
+ show-changes
+ date-format|date=s
+ );
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets) = $self->parse_args(
+ target => $self->target_name,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $target->name,
+ )) if @{ $targets };
+
+ # Good to go.
+ $self->target($target);
+ my $engine = $target->engine;
+
+ # Where are we?
+ $self->comment( __x 'On database {db}', db => $engine->destination );
+
+ # Exit with status 1 on no state, probably not expected.
+ my $state = try {
+ $engine->current_state( $self->project )
+ } catch {
+ # Just die on parse and I/O errors.
+ die $_ if try { $_->ident eq 'parse' || $_->ident eq 'io' };
+
+ # Hrm. Maybe not initialized?
+ die $_ if $engine->initialized;
+ hurl status => __x(
+ 'Database {db} has not been initialized for Sqitch',
+ db => $engine->registry_destination
+ );
+ };
+
+ hurl {
+ ident => 'status',
+ message => __ 'No changes deployed',
+ exitval => 1,
+ } unless $state;
+
+ # Emit the state basics.
+ $self->emit_state($state);
+
+ # Emit changes and tags, if required.
+ $self->emit_changes;
+ $self->emit_tags;
+
+ my $plan_proj = try { $target->plan->project };
+ if (defined $plan_proj && $self->project eq $plan_proj ) {
+ $self->emit_status($state);
+ } else {
+ # If we have no access to the project plan, we can't emit the status.
+ $self->comment('');
+ $self->emit(__x(
+ 'Status unknown. Use --plan-file to assess "{project}" status',
+ project => $self->project,
+ ));
+ }
+
+ return $self;
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ # Make sure the date format is valid.
+ if (my $format = $opt->{date_format}
+ || $config->get(key => 'status.date_format')
+ ) {
+ require App::Sqitch::DateTime;
+ App::Sqitch::DateTime->validate_as_string_format($format);
+ }
+
+ # Set boolean options from config.
+ for my $key (qw(show_changes show_tags)) {
+ next if exists $opt->{$key};
+ my $val = $config->get(key => "status.$key", as => 'bool') // next;
+ $opt->{$key} = $val;
+ }
+
+ my $ret = $class->SUPER::configure( $config, $opt );
+ $ret->{target_name} = delete $ret->{target} if exists $ret->{target};
+ return $ret;
+}
+
+sub emit_state {
+ my ( $self, $state ) = @_;
+ $self->comment(__x(
+ 'Project: {project}',
+ project => $state->{project},
+ ));
+ $self->comment(__x(
+ 'Change: {change_id}',
+ change_id => $state->{change_id},
+ ));
+ $self->comment(__x(
+ 'Name: {change}',
+ change => $state->{change},
+ ));
+ if (my @tags = @{ $state->{tags}} ) {
+ $self->comment(__nx(
+ 'Tag: {tags}',
+ 'Tags: {tags}',
+ @tags,
+ tags => join(__ ', ', @tags),
+ ));
+ }
+
+ $self->comment(__x(
+ 'Deployed: {date}',
+ date => $state->{committed_at}->as_string(
+ format => $self->date_format
+ ),
+ ));
+ $self->comment(__x(
+ 'By: {name} <{email}>',
+ name => $state->{committer_name},
+ email=> $state->{committer_email},
+ ));
+ return $self;
+}
+
+sub _all {
+ my $iter = shift;
+ my @res;
+ while (my $row = $iter->()) {
+ push @res => $row;
+ }
+ return \@res;
+}
+
+sub emit_changes {
+ my $self = shift;
+ return $self unless $self->show_changes;
+
+ # Emit the header.
+ my $changes = _all $self->engine->current_changes( $self->project );
+ $self->comment('');
+ $self->comment(__n 'Change:', 'Changes:', @{ $changes });
+
+ # Find the longest change name.
+ my $len = max map { length $_->{change} } @{ $changes };
+ my $format = $self->date_format;
+
+ # Emit each change.
+ $self->comment(sprintf(
+ ' %s%s - %s - %s <%s>',
+ $_->{change},
+ ((' ') x ($len - length $_->{change})) || '',
+ $_->{committed_at}->as_string( format => $format ),
+ $_->{committer_name},
+ $_->{committer_email},
+ )) for @{ $changes };
+
+ return $self;
+}
+
+sub emit_tags {
+ my $self = shift;
+ return $self unless $self->show_tags;
+
+ # Emit the header.
+ my $tags = _all $self->engine->current_tags( $self->project );
+ $self->comment('');
+
+ # If no tags, say so and return.
+ unless (@{ $tags }) {
+ $self->comment(__ 'Tags: None.');
+ return $self;
+ }
+
+ $self->comment(__n 'Tag:', 'Tags:', @{ $tags });
+
+ # Find the longest tag name.
+ my $len = max map { length $_->{tag} } @{ $tags };
+ my $format = $self->date_format;
+
+ # Emit each tag.
+ $self->comment(sprintf(
+ ' %s%s - %s - %s <%s>',
+ $_->{tag},
+ ((' ') x ($len - length $_->{tag})) || '',
+ $_->{committed_at}->as_string( format => $format ),
+ $_->{committer_name},
+ $_->{committer_email},
+ )) for @{ $tags };
+
+ return $self;
+}
+
+sub emit_status {
+ my ( $self, $state ) = @_;
+ my $plan = $self->plan;
+ $self->comment('');
+
+ my $idx = $plan->index_of( $state->{change_id} ) // do {
+ $self->vent(__x(
+ 'Cannot find this change in {file}',
+ file => $self->plan_file
+ ));
+ hurl status => __ 'Make sure you are connected to the proper '
+ . 'database for this project.';
+ };
+
+ # Say something about our current state.
+ if ( $idx == $plan->count - 1 ) {
+ $self->emit( __ 'Nothing to deploy (up-to-date)' );
+ } else {
+ $self->emit(__n(
+ 'Undeployed change:',
+ 'Undeployed changes:',
+ $plan->count - ( $idx + 1 )
+ ));
+ $plan->position($idx);
+ while ( my $change = $plan->next ) {
+ $self->emit( ' * ', $change->format_name_with_tags );
+ }
+ }
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::status - Display status information about Sqitch
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::status->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<status> command, you probably want to be
+reading C<sqitch-status>. But if you really want to know how the C<status> command
+works, read on.
+
+=head1 Interface
+
+=head2 Attributes
+
+=head3 C<target_name>
+
+The name or URI of the database target as specified by the C<--target> option.
+
+=head3 C<target>
+
+An L<App::Sqitch::Target> object from which to read the status. Must be
+instantiated by C<execute()>.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $status->execute;
+
+Executes the status command. The current state of the target database will be
+compared to the plan in order to show where things stand.
+
+=head3 C<emit_changes>
+
+ $status->emit_changes;
+
+Emits a list of deployed changes if C<show_changes> is true.
+
+=head3 C<emit_tags>
+
+ $status->emit_tags;
+
+Emits a list of deployed tags if C<show_tags> is true.
+
+=head3 C<emit_state>
+
+ $status->emit_state($state);
+
+Emits the current state of the target database. Pass in a state hash as
+returned by L<App::Sqitch::Engine> C<current_state()>.
+
+=head3 C<emit_status>
+
+ $status->emit_state($state);
+
+Emits information about the current status of the target database compared to
+the plan. Pass in a state hash as returned by L<App::Sqitch::Engine>
+C<current_state()>. Throws an exception if the current state's change cannot
+be found in the plan.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-status>
+
+Documentation for the C<status> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/tag.pm b/lib/App/Sqitch/Command/tag.pm
new file mode 100644
index 00000000..37193e93
--- /dev/null
+++ b/lib/App/Sqitch/Command/tag.pm
@@ -0,0 +1,206 @@
+package App::Sqitch::Command::tag;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use App::Sqitch::X qw(hurl);
+use Types::Standard qw(Str ArrayRef Maybe Bool);
+use Locale::TextDomain qw(App-Sqitch);
+use List::Util qw(first);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has tag_name => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has change_name => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has all => (
+ is => 'ro',
+ isa => Bool,
+ default => 0
+);
+
+has note => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ default => sub { [] },
+);
+
+sub options {
+ return qw(
+ tag-name|tag|t=s
+ change-name|change|c=s
+ all|a!
+ note|n|m=s@
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+ # Just keep options.
+ return $opt;
+}
+
+sub execute {
+ my $self = shift;
+ my ($name, $change, $targets) = $self->parse_args(
+ names => [$self->tag_name, $self->change_name],
+ all => $self->all,
+ args => \@_,
+ no_changes => 1,
+ );
+
+ if (defined $name) {
+ my $note = join "\n\n" => @{ $self->note };
+ my (%seen, @plans, @tags);
+ for my $target (@{ $targets }) {
+ next if $seen{$target->plan_file}++;
+ my $plan = $target->plan;
+ push @tags => $plan->tag(
+ name => $name,
+ change => $change,
+ note => $note,
+ );
+ push @plans => $plan;
+ }
+
+ # Make sure we have a note.
+ $note = $tags[0]->request_note(for => __ 'tag');
+
+ # We good, write the plan files back out.
+ for my $plan (@plans) {
+ my $tag = shift @tags;
+ $tag->note($note);
+ $plan->write_to( $plan->file );
+ $self->info(__x(
+ 'Tagged "{change}" with {tag} in {file}',
+ change => $tag->change->format_name,
+ tag => $tag->format_name,
+ file => $plan->file,
+ ));
+ }
+ } else {
+ # Check for missing name.
+ if (@_) {
+ if (my $target = first { my $n = $_->name; first { $_ eq $n } @_ } @{ $targets }) {
+ # Name conflicts with a target.
+ hurl tag => __x(
+ 'Name "{name}" identifies a target; use "--tag {name}" to use it for the tag name',
+ name => $target->name,
+ );
+ }
+ }
+
+ # Show unique tags.
+ my %seen;
+ for my $target (@{ $targets }) {
+ my $plan = $target->plan;
+ for my $tag ($plan->tags) {
+ my $name = $tag->format_name;
+ $self->info($name) unless $seen{$name}++;
+ }
+ }
+ }
+
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::tag - Add or list tags in Sqitch plans
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::tag->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+Tags a Sqitch change. The tag will be added to the last change in the plan.
+
+=head1 Interface
+
+=head2 Attributes
+
+=head3 C<tag_name>
+
+The name of the tag to add.
+
+=head3 C<change_name>
+
+The name of the change to tag.
+
+=head3 C<all>
+
+Boolean indicating whether or not to run the command against all plans in the
+project.
+
+=head3 C<note>
+
+Text of the tag note.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $tag->execute($command);
+
+Executes the C<tag> command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-tag>
+
+Documentation for the C<tag> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/target.pm b/lib/App/Sqitch/Command/target.pm
new file mode 100644
index 00000000..35c6f6da
--- /dev/null
+++ b/lib/App/Sqitch/Command/target.pm
@@ -0,0 +1,337 @@
+package App::Sqitch::Command::target;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use Types::Standard qw(Str Int HashRef);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use URI::db;
+use Try::Tiny;
+use Path::Class qw(file dir);
+use List::Util qw(max);
+use namespace::autoclean;
+use constant extra_target_keys => qw(uri);
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::TargetConfigCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub configure {
+ # No config; target config is actually targets.
+ return {};
+}
+
+sub execute {
+ my ( $self, $action ) = (shift, shift);
+ $action ||= 'list';
+ $action =~ s/-/_/g;
+ my $meth = $self->can($action) or $self->usage(__x(
+ 'Unknown action "{action}"',
+ action => $action,
+ ));
+ return $self->$meth(@_);
+}
+
+sub list {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my %targets = $sqitch->config->get_regexp(key => qr/^target[.][^.]+[.]uri$/);
+
+ # Make it verbose if --verbose was passed at all.
+ my $format = $sqitch->options->{verbosity} ? "%1\$s\t%2\$s" : '%1$s';
+ for my $key (sort keys %targets) {
+ my ($target) = $key =~ /target[.]([^.]+)/;
+ $sqitch->emit(sprintf $format, $target, $targets{$key});
+ }
+
+ return $self;
+}
+
+sub add {
+ my ($self, $name, $uri) = @_;
+ $self->usage unless $name && $uri;
+
+ my $key = "target.$name";
+ my $config = $self->sqitch->config;
+
+ hurl target => __x(
+ 'Target "{target}" already exists',
+ target => $name
+ ) if $config->get( key => "$key.uri");
+
+ # Put together the URI and other config variables.
+ my $vars = $self->config_params($key);
+ unshift @{ $vars } => {
+ key => "$key.uri",
+ value => URI::db->new($uri, 'db:')->as_string,
+ };
+
+ # Make it so.
+ $config->group_set( $config->local_file, $vars );
+ my $target = $self->config_target(name => $name);
+ $self->write_plan(target => $target);
+ $self->make_directories_for( $target );
+ return $self;
+}
+
+sub alter {
+ my ($self, $target) = @_;
+ $self->usage unless $target;
+
+ my $key = "target.$target";
+ my $config = $self->sqitch->config;
+ my $props = $self->properties;
+
+ hurl target => __x(
+ 'Missing Target "{target}"; use "{command}" to add it',
+ target => $target,
+ command => "add $target " . ($props->{uri} || '$uri'),
+ ) unless $config->get( key => "target.$target.uri");
+
+ # Make it so.
+ $config->group_set( $config->local_file, $self->config_params($key) );
+ $self->make_directories_for( $self->config_target(name => $target) );
+}
+
+sub rm { shift->remove(@_) }
+sub remove {
+ my ($self, $name) = @_;
+ $self->usage unless $name;
+ if ( my @deps = $self->_dependencies($name) ) {
+ hurl target => __x(
+ q{Cannot rename target "{target}" because it's referenced by: {engines}},
+ target => $name,
+ engines => join ', ', @deps
+ );
+ }
+ $self->_rename($name);
+}
+
+sub rename {
+ my ($self, $old, $new) = @_;
+ $self->usage unless $old && $new;
+ if ( my @deps = $self->_dependencies($old) ) {
+ hurl target => __x(
+ q{Cannot rename target "{target}" because it's referenced by: {engines}},
+ target => $old,
+ engines => join ', ', @deps
+ );
+ }
+ $self->_rename($old, $new);
+}
+
+sub _dependencies {
+ my ($self, $name) = @_;
+ my %depends = $self->sqitch->config->get_regexp(
+ key => qr/^(?:core|engine[.][^.]+)[.]target$/
+ );
+ return grep { $depends{$_} eq $name } sort keys %depends;
+}
+
+sub _rename {
+ my ($self, $old, $new) = @_;
+ my $config = $self->sqitch->config;
+
+ try {
+ $config->rename_section(
+ from => "target.$old",
+ ($new ? (to => "target.$new") : ()),
+ filename => $config->local_file,
+ );
+ } catch {
+ die $_ unless /No such section/;
+ hurl target => __x(
+ 'Unknown target "{target}"',
+ target => $old,
+ );
+ };
+ try {
+ $config->rename_section(
+ from => "target.$old.variables",
+ ($new ? (to => "target.$new.variables") : ()),
+ filename => $config->local_file,
+ );
+ } catch {
+ die $_ unless /No such section/;
+ };
+ return $self;
+}
+
+sub show {
+ my ($self, @names) = @_;
+ return $self->list unless @names;
+ my $sqitch = $self->sqitch;
+ my $config = $sqitch->config;
+
+ my %label_for = (
+ uri => __('URI'),
+ registry => __('Registry'),
+ client => __('Client'),
+ top_dir => __('Top Directory'),
+ plan_file => __('Plan File'),
+ extension => __('Extension'),
+ revert => ' ' . __ 'Revert',
+ deploy => ' ' . __ 'Deploy',
+ verify => ' ' . __ 'Verify',
+ reworked => ' ' . __ 'Reworked',
+ );
+
+ my $len = max map { length } values %label_for;
+ $_ .= ': ' . ' ' x ($len - length $_) for values %label_for;
+
+ # Header labels.
+ $label_for{script_dirs} = __('Script Directories') . ':';
+ $label_for{reworked_dirs} = __('Reworked Script Directories') . ':';
+ $label_for{variables} = __('Variables') . ':';
+ $label_for{no_variables} = __('No Variables');
+
+ require App::Sqitch::Target;
+ for my $name (@names) {
+ my $target = App::Sqitch::Target->new(
+ $self->target_params,
+ name => $name,
+ );
+ $self->emit("* $name");
+ $self->emit(' ', $label_for{uri}, $target->uri->as_string);
+ $self->emit(' ', $label_for{registry}, $target->registry);
+ $self->emit(' ', $label_for{client}, $target->client);
+ $self->emit(' ', $label_for{top_dir}, $target->top_dir);
+ $self->emit(' ', $label_for{plan_file}, $target->plan_file);
+ $self->emit(' ', $label_for{extension}, $target->extension);
+ $self->emit(' ', $label_for{script_dirs});
+ $self->emit(' ', $label_for{deploy}, $target->deploy_dir);
+ $self->emit(' ', $label_for{revert}, $target->revert_dir);
+ $self->emit(' ', $label_for{verify}, $target->verify_dir);
+ $self->emit(' ', $label_for{reworked_dirs});
+ $self->emit(' ', $label_for{reworked}, $target->reworked_dir);
+ $self->emit(' ', $label_for{deploy}, $target->reworked_deploy_dir);
+ $self->emit(' ', $label_for{revert}, $target->reworked_revert_dir);
+ $self->emit(' ', $label_for{verify}, $target->reworked_verify_dir);
+ my $vars = $target->variables;
+ if (%{ $vars }) {
+ my $len = max map { length } keys %{ $vars };
+ $self->emit(' ', $label_for{variables});
+ $self->emit(" $_: " . (' ' x ($len - length $_)) . $vars->{$_})
+ for sort { lc $a cmp lc $b } keys %{ $vars };
+ } else {
+ $self->emit(' ', $label_for{no_variables});
+ }
+ }
+
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::target - Add, modify, or list Sqitch target databases
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::target->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+Manages Sqitch targets, which are stored in the local configuration file.
+
+=head1 Interface
+
+=head3 Class Methods
+
+=head3 C<extra_target_keys>
+
+Returns a list of additional option keys to be specified via options.
+
+=head2 Instance Methods
+
+=head2 Attributes
+
+=head3 C<properties>
+
+Hash of property values to set.
+
+=head3 C<verbose>
+
+Verbosity.
+
+=head3 C<execute>
+
+ $target->execute($command);
+
+Executes the C<target> command.
+
+=head3 C<add>
+
+Implements the C<add> action.
+
+=head3 C<alter>
+
+Implements the C<alter> action.
+
+=head3 C<list>
+
+Implements the C<list> action.
+
+=head3 C<remove>
+
+=head3 C<rm>
+
+Implements the C<remove> action.
+
+=head3 C<rename>
+
+Implements the C<rename> action.
+
+=head3 C<show>
+
+Implements the C<show> action.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-target>
+
+Documentation for the C<target> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/upgrade.pm b/lib/App/Sqitch/Command/upgrade.pm
new file mode 100644
index 00000000..55393187
--- /dev/null
+++ b/lib/App/Sqitch/Command/upgrade.pm
@@ -0,0 +1,148 @@
+package App::Sqitch::Command::upgrade;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use App::Sqitch::Types qw(URI Maybe Str Bool HashRef);
+use Locale::TextDomain qw(App-Sqitch);
+use Type::Utils qw(enum);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(first);
+use namespace::autoclean;
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ConnectingCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+sub options {
+ return qw(
+ target|t=s
+ );
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets) = $self->parse_args(
+ target => $self->target,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; using {target}',
+ target => $target->name,
+ )) if @{ $targets };
+
+ my $engine = $target->engine;
+
+ if ($engine->needs_upgrade) {
+ $self->info(__x(
+ 'Upgrading registry {registry} to version {version}',
+ registry => $engine->registry_destination,
+ version => $engine->registry_release,
+ ));
+ $engine->upgrade_registry;
+ } else {
+ $self->info(__x(
+ 'Registry {registry} is up-to-date at version {version}',
+ registry => $engine->registry_destination,
+ version => $engine->registry_release,
+ ));
+ }
+
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::upgrade - Upgrade the Sqitch registry
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::upgrade->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<upgrade> command, you probably want to be
+reading C<sqitch-upgrade>. But if you really want to know how the C<upgrade>
+command works, read on.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::upgrade->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<upgrade> command.
+
+=head2 Attributes
+
+=head3 C<target>
+
+The upgrade target.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $upgrade->execute;
+
+Executes the upgrade command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-upgrade>
+
+Documentation for the C<upgrade> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Command/verify.pm b/lib/App/Sqitch/Command/verify.pm
new file mode 100644
index 00000000..497ae807
--- /dev/null
+++ b/lib/App/Sqitch/Command/verify.pm
@@ -0,0 +1,205 @@
+package App::Sqitch::Command::verify;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo;
+use Types::Standard qw(Str HashRef);
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use List::Util qw(first);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Command';
+with 'App::Sqitch::Role::ContextCommand';
+with 'App::Sqitch::Role::ConnectingCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+has from_change => (
+ is => 'ro',
+ isa => Str,
+);
+
+has to_change => (
+ is => 'ro',
+ isa => Str,
+);
+
+has variables => (
+ is => 'ro',
+ isa => HashRef,
+ lazy => 1,
+ default => sub { {} },
+);
+
+sub options {
+ return qw(
+ target|t=s
+ from-change|from=s
+ to-change|to=s
+ set|s=s%
+ );
+}
+
+sub configure {
+ my ( $class, $config, $opt ) = @_;
+
+ my %params = map {
+ $_ => $opt->{$_}
+ } grep {
+ exists $opt->{$_}
+ } qw(target from_change to_change);
+
+ if ( my $vars = $opt->{set} ) {
+ $params{variables} = $vars;
+ }
+
+ return \%params;
+}
+
+sub _collect_vars {
+ my ($self, $target) = @_;
+ my $cfg = $self->sqitch->config;
+ return (
+ %{ $cfg->get_section(section => 'core.variables') },
+ %{ $cfg->get_section(section => 'deploy.variables') },
+ %{ $cfg->get_section(section => 'verify.variables') },
+ %{ $target->variables }, # includes engine
+ %{ $self->variables }, # --set
+ );
+}
+
+sub execute {
+ my $self = shift;
+ my ($targets, $changes) = $self->parse_args(
+ target => $self->target,
+ args => \@_,
+ );
+
+ # Warn on multiple targets.
+ my $target = shift @{ $targets };
+ $self->warn(__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $target->name,
+ )) if @{ $targets };
+
+ # Warn on too many changes.
+ my $from = $self->from_change // shift @{ $changes };
+ my $to = $self->to_change // shift @{ $changes };
+ $self->warn(__x(
+ 'Too many changes specified; verifying from "{from}" to "{to}"',
+ from => $from,
+ to => $to,
+ )) if @{ $changes };
+
+ # Now get to work.
+ my $engine = $target->engine;
+ $engine->set_variables( $self->_collect_vars($target) );
+ $engine->verify($from, $to);
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::verify - Verify deployed Sqitch changes
+
+=head1 Synopsis
+
+ my $cmd = App::Sqitch::Command::verify->new(%params);
+ $cmd->execute;
+
+=head1 Description
+
+If you want to know how to use the C<verify> command, you probably want to be
+reading C<sqitch-verify>. But if you really want to know how the C<verify> command
+works, read on.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::verify->options;
+
+Returns a list of L<Getopt::Long> option specifications for the command-line
+options for the C<verify> command.
+
+=head2 Attributes
+
+=head3 C<onto_change>
+
+Change onto which to rebase the target.
+
+=head3 C<target>
+
+The verify target database URI.
+
+=head3 C<from_change>
+
+Change from which to verify changes.
+
+=head3 C<to_change>
+
+Change up to which to verify changes.
+
+=head2 Instance Methods
+
+=head3 C<execute>
+
+ $verify->execute;
+
+Executes the verify command.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-verify>
+
+Documentation for the C<verify> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Config.pm b/lib/App/Sqitch/Config.pm
new file mode 100644
index 00000000..cd79ab17
--- /dev/null
+++ b/lib/App/Sqitch/Config.pm
@@ -0,0 +1,231 @@
+package App::Sqitch::Config;
+
+use 5.010;
+use Moo;
+use strict;
+use warnings;
+use Path::Class;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Config::GitLike 1.15;
+use utf8;
+
+extends 'Config::GitLike';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has '+confname' => ( default => 'sqitch.conf' );
+has '+encoding' => ( default => 'UTF-8' );
+
+# Set by ./Build; see Module::Build::Sqitch for details.
+my $SYSTEM_DIR = undef;
+
+sub user_dir {
+ my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0];
+ hurl config => __("Could not determine home directory") if not $hd;
+ return dir $hd, '.sqitch';
+}
+
+sub system_dir {
+ dir $SYSTEM_DIR || do {
+ require Config;
+ $Config::Config{prefix}, 'etc', 'sqitch';
+ };
+}
+
+sub system_file {
+ my $self = shift;
+ return file $ENV{SQITCH_SYSTEM_CONFIG}
+ || $self->system_dir->file( $self->confname );
+}
+
+sub global_file { shift->system_file }
+
+sub user_file {
+ my $self = shift;
+ return file $ENV{SQITCH_USER_CONFIG}
+ || $self->user_dir->file( $self->confname );
+}
+
+sub local_file {
+ return file $ENV{SQITCH_CONFIG} if $ENV{SQITCH_CONFIG};
+ return file shift->confname;
+}
+
+sub dir_file { shift->local_file }
+
+# Section keys always have the top section lowercase, and subsections are
+# left as-is.
+sub _skey($) {
+ my $key = shift // return '';
+ my ($sec, $sub, $name) = Config::GitLike::_split_key($key);
+ return lc $key unless $sec;
+ return lc($sec) . '.' . join '.', grep { defined } $sub, $name;
+}
+
+sub get_section {
+ my ( $self, %p ) = @_;
+ $self->load unless $self->is_loaded;
+ my $section = _skey $p{section};
+ my $data = $self->data;
+ return {
+ map {
+ ( split /[.]/ => $self->initial_key("$section.$_") )[-1],
+ $data->{"$section.$_"}
+ }
+ grep { s{^\Q$section.\E([^.]+)$}{$1} } keys %{$data}
+ };
+}
+
+sub initial_key {
+ my $key = shift->original_key(shift);
+ return ref $key ? $key->[0] : $key;
+}
+
+sub initialized {
+ my $self = shift;
+ $self->load unless $self->is_loaded;
+ return $self->{_initialized};
+}
+
+sub load_dirs {
+ my $self = shift;
+ local $self->{__loading_dirs} = 1;
+ $self->SUPER::load_dirs(@_);
+}
+
+sub load_file {
+ my $self = shift;
+ $self->{_initialized} ||= $self->{__loading_dirs};
+ $self->SUPER::load_file(@_);
+}
+
+1;
+
+=head1 Name
+
+App::Sqitch::Config - Sqitch configuration management
+
+=head1 Synopsis
+
+ my $config = App::Sqitch::Config->new;
+ say scalar $config->dump;
+
+=head1 Description
+
+This class provides the interface to Sqitch configuration. It inherits from
+L<Config::GitLike>, and therefore provides the complete interface of that
+module.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<confname>
+
+Returns the configuration file base name, which is F<sqitch.conf>.
+
+=head3 C<system_dir>
+
+Returns the path to the system configuration directory, which is
+F<$(prefix)/etc/sqitch/templates>. Call C<sqitch --etc-path> to find out
+where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.plan>).
+
+=head3 C<user_dir>
+
+Returns the path to the user configuration directory, which is F<~/.sqitch/>.
+
+=head3 C<system_file>
+
+Returns the path to the system configuration file. The value returned will be
+the contents of the C<$SQITCH_SYSTEM_CONFIG> environment variable, if it's
+defined, or else F<$(prefix)/etc/sqitch/templates>. Call C<sqitch --etc-path>
+to find out where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.plan>).
+
+=head3 C<global_file>
+
+An alias for C<system_file()> for use by the parent class.
+
+=head3 C<user_file>
+
+Returns the path to the user configuration file. The value returned will be
+the contents of the C<$SQITCH_USER_CONFIG> environment variable, if it's
+defined, or else C<~/.sqitch/sqitch.conf>.
+
+=head3 C<local_file>
+
+Returns the path to the local configuration file, which is just
+F<./sqitch.conf>, unless C<$SQITCH_CONFIG> is set, in which case its value
+will be returned.
+
+=head3 C<dir_file>
+
+An alias for C<local_file()> for use by the parent class.
+
+=head3 C<initialized>
+
+ say 'Project not initialized' unless $config->initialized;
+
+Returns true if the project configuration file was found, and false if it was
+not. Useful for detecting when a command has been run from a directory with no
+Sqitch configuration.
+
+=head3 C<get_section>
+
+ my $core = $config->get_section(section => 'core');
+ my $pg = $config->get_section(section => 'engine.pg');
+
+Returns a hash reference containing only the keys within the specified
+section or subsection.
+
+=head3 C<add_comment>
+
+Adds a comment to the configuration file.
+
+=head3 C<initial_key>
+
+ my $key = $config->initial_key($data_key);
+
+Given the lowercase key from the loaded data, this method returns it in its
+original case. This is like C<original_key>, only in the case where there are
+multiple keys (for multivalue keys), only the first key is returned.
+
+=head1 See Also
+
+=over
+
+=item * L<Config::GitLike>
+
+=item * L<App::Sqitch::Command::config>
+
+=item * L<sqitch-config>
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/DateTime.pm b/lib/App/Sqitch/DateTime.pm
new file mode 100644
index 00000000..f599de0a
--- /dev/null
+++ b/lib/App/Sqitch/DateTime.pm
@@ -0,0 +1,214 @@
+package App::Sqitch::DateTime;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use parent 'DateTime';
+use DateTime 1.04;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(first);
+use constant ISWIN => $^O eq 'MSWin32';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub as_string_formats {
+ return qw(
+ raw
+ iso
+ iso8601
+ rfc
+ rfc2822
+ full
+ long
+ medium
+ short
+ );
+}
+
+sub validate_as_string_format {
+ my ( $self, $format ) = @_;
+ hurl datetime => __x(
+ 'Unknown date format "{format}"',
+ format => $format
+ ) unless (first { $format eq $_ } $self->as_string_formats)
+ || $format =~ /^(?:cldr|strftime):/;
+ return $self;
+}
+
+sub as_string {
+ my ( $self, %opts ) = @_;
+ my $format = $opts{format} || 'raw';
+ my $dt = $self->clone;
+
+ if ($format eq 'raw') {
+ $dt->set_time_zone('UTC');
+ return $dt->iso8601 . 'Z';
+ }
+
+ $dt->set_time_zone('local');
+
+ if ( first { $format eq $_ } qw(iso iso8601) ) {
+ return join ' ', $dt->ymd('-'), $dt->hms(':'), $dt->strftime('%z');
+ } elsif ( first { $format eq $_ } qw(rfc rfc2822) ) {
+ $dt->set_locale('en_US');
+ ( my $rv = $dt->strftime('%a, %d %b %Y %H:%M:%S %z') ) =~
+ s/\+0000$/-0000/;
+ return $rv;
+ } else {
+ if (ISWIN) {
+ require Win32::Locale;
+ $dt->set_locale( Win32::Locale::get_locale() );
+ } else {
+ require POSIX;
+ $dt->set_locale( POSIX::setlocale( POSIX::LC_TIME() ) );
+ }
+ return $dt->format_cldr($format) if $format =~ s/^cldr://;
+ return $dt->strftime($format) if $format =~ s/^strftime://;
+ my $meth = $dt->locale->can("datetime_format_$format") or hurl(
+ datetime => __x(
+ 'Unknown date format "{format}"',
+ format => $format
+ )
+ );
+ return $dt->format_cldr( $dt->locale->$meth );
+ }
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::DateTime - Sqitch DateTime object
+
+=head1 Synopsis
+
+ my $dt = App::Sqitch::DateTime->new(%params);
+ say $dt->as_string( format => 'iso' );
+
+=head1 Description
+
+This subclass of L<DateTime> provides additional interfaces to support named
+formats. These can be used for L<status|sqitch-status> or L<log|sqitch-log>
+C<--date-format> options. App::Sqitch::DateTime provides a list of supported
+formats, validates that a format string, and uses the formats to convert
+itself into the appropriate string.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<as_string_formats>
+
+ my @formats = App::Sqitch::DateTime->as_string_formats;
+
+Returns a list of formats supported by the C<format> parameter to
+C<as_string>. The list currently includes:
+
+=over
+
+=item C<iso>
+
+=item C<iso8601>
+
+ISO-8601 format.
+
+=item C<rfc>
+
+=item C<rfc2822>
+
+RFC-2822 format.
+
+=item C<full>
+
+=item C<long>
+
+=item C<medium>
+
+=item C<short>
+
+Localized format of the specified length.
+
+=item C<raw>
+
+Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone.
+
+=item C<strftime:$string>
+
+Show timestamps using an arbitrary C<strftime> pattern. See
+L<DateTime/strftime Paterns> for comprehensive documentation of supported
+patterns.
+
+=item C<cldr:$string>
+
+Show timestamps using an arbitrary C<cldr> pattern. See L<DateTime/CLDR
+Paterns> for comprehensive documentation of supported patterns.
+
+=back
+
+=head3 C<validate_as_string_format>
+
+ App::Sqitch::DateTime->validate_as_string_format($format);
+
+Validates that a format is supported by C<as_string>. Throws an exception if
+it's not, and returns if it is.
+
+=head2 Instance Methods
+
+=head3 C<as_string>
+
+ $dt->as_string;
+ $dt->as_string( format => $format );
+
+Returns a string representation using the provided format. The format must be
+one of those listed by C<as_string_formats> or an exception will be thrown. If
+no format is passed, the string will be formatted with the C<raw> format.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-status>
+
+Documentation for the C<status> command to the Sqitch command-line client.
+
+=item L<sqitch-log>
+
+Documentation for the C<log> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine.pm b/lib/App/Sqitch/Engine.pm
new file mode 100644
index 00000000..2c1ce685
--- /dev/null
+++ b/lib/App/Sqitch/Engine.pm
@@ -0,0 +1,2463 @@
+package App::Sqitch::Engine;
+
+use 5.010;
+use Moo;
+use strict;
+use utf8;
+use Try::Tiny;
+use Locale::TextDomain qw(App-Sqitch);
+use Path::Class qw(file);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(first max);
+use URI::db 0.19;
+use App::Sqitch::Types qw(Str Int Sqitch Plan Bool HashRef URI Maybe Target);
+use namespace::autoclean;
+use constant registry_release => '1.1';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has sqitch => (
+ is => 'ro',
+ isa => Sqitch,
+ required => 1,
+ weak_ref => 1,
+);
+
+has target => (
+ is => 'ro',
+ isa => Target,
+ required => 1,
+ weak_ref => 1,
+ handles => {
+ uri => 'uri',
+ client => 'client',
+ registry => 'registry',
+ destination => 'name',
+ }
+);
+
+has username => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->target->username || $self->_def_user
+ },
+);
+
+has password => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->target->password || $self->_def_pass
+ },
+);
+
+sub _def_user { }
+sub _def_pass { }
+
+sub registry_destination { shift->destination }
+
+has start_at => (
+ is => 'rw',
+ isa => Str
+);
+
+has no_prompt => (
+ is => 'rw',
+ isa => Bool,
+ default => 0,
+);
+
+has prompt_accept => (
+ is => 'rw',
+ isa => Bool,
+ default => 1,
+);
+
+has log_only => (
+ is => 'rw',
+ isa => Bool,
+ default => 0,
+);
+
+has with_verify => (
+ is => 'rw',
+ isa => Bool,
+ default => 0,
+);
+
+has max_name_length => (
+ is => 'rw',
+ isa => Int,
+ default => 0,
+ lazy => 1,
+ default => sub {
+ my $plan = shift->plan;
+ max map {
+ length $_->format_name_with_tags
+ } $plan->changes;
+ },
+);
+
+has plan => (
+ is => 'rw',
+ isa => Plan,
+ lazy => 1,
+ default => sub { shift->target->plan }
+);
+
+has _variables => (
+ is => 'rw',
+ isa => HashRef[Str],
+ default => sub { {} },
+);
+
+sub variables { %{ shift->_variables } }
+sub set_variables { shift->_variables({ @_ }) }
+sub clear_variables { %{ shift->_variables } = () }
+
+sub default_registry { 'sqitch' }
+
+sub load {
+ my ( $class, $p ) = @_;
+
+ # We should have an engine param.
+ my $target = $p->{target} or hurl 'Missing "target" parameter to load()';
+
+ # Load the engine class.
+ my $ekey = $target->engine_key or hurl engine => __(
+ 'No engine specified; specify via target or core.engine'
+ );
+
+ my $pkg = __PACKAGE__ . '::' . $target->engine_key;
+ eval "require $pkg" or hurl "Unable to load $pkg";
+ return $pkg->new( $p );
+}
+
+sub driver { shift->key }
+
+sub key {
+ my $class = ref $_[0] || shift;
+ hurl engine => __ 'No engine specified; specify via target or core.engine'
+ if $class eq __PACKAGE__;
+ my $pkg = quotemeta __PACKAGE__;
+ $class =~ s/^$pkg\:://;
+ return $class;
+}
+
+sub name { shift->key }
+
+sub config_vars {
+ return (
+ target => 'any',
+ registry => 'any',
+ client => 'any'
+ );
+}
+
+sub use_driver {
+ my $self = shift;
+ my $driver = $self->driver;
+ eval "use $driver";
+ hurl $self->key => __x(
+ '{driver} required to manage {engine}',
+ driver => $driver,
+ engine => $self->name,
+ ) if $@;
+ return $self;
+}
+
+sub deploy {
+ my ( $self, $to, $mode ) = @_;
+ my $sqitch = $self->sqitch;
+ my $plan = $self->_sync_plan;
+ my $to_index = $plan->count - 1;
+
+ hurl plan => __ 'Nothing to deploy (empty plan)' if $to_index < 0;
+
+ if (defined $to) {
+ $to_index = $plan->index_of($to) // hurl plan => __x(
+ 'Unknown change: "{change}"',
+ change => $to,
+ );
+
+ # Just return if there is nothing to do.
+ if ($to_index == $plan->position) {
+ $sqitch->info(__x(
+ 'Nothing to deploy (already at "{change}")',
+ change => $to
+ ));
+ return $self;
+ }
+ }
+
+ if ($plan->position == $to_index) {
+ # We are up-to-date.
+ $sqitch->info( __ 'Nothing to deploy (up-to-date)' );
+ return $self;
+
+ } elsif ($plan->position == -1) {
+ # Initialize or upgrade the database, if necessary.
+ if ($self->initialized) {
+ $self->upgrade_registry;
+ } else {
+ $sqitch->info(__x(
+ 'Adding registry tables to {destination}',
+ destination => $self->registry_destination,
+ ));
+ $self->initialize;
+ }
+ $self->register_project;
+
+ } else {
+ # Make sure that $to_index is greater than the current point.
+ hurl deploy => __ 'Cannot deploy to an earlier change; use "revert" instead'
+ if $to_index < $plan->position;
+ # Upgrade database if it needs it.
+ $self->upgrade_registry;
+ }
+
+ $sqitch->info(
+ defined $to ? __x(
+ 'Deploying changes through {change} to {destination}',
+ change => $plan->change_at($to_index)->format_name_with_tags,
+ destination => $self->destination,
+ ) : __x(
+ 'Deploying changes to {destination}',
+ destination => $self->destination,
+ )
+ );
+
+ # Check that all dependencies will be satisfied.
+ $self->check_deploy_dependencies($plan, $to_index);
+
+ # Do it!
+ $mode ||= 'all';
+ my $meth = $mode eq 'change' ? '_deploy_by_change'
+ : $mode eq 'tag' ? '_deploy_by_tag'
+ : $mode eq 'all' ? '_deploy_all'
+ : hurl deploy => __x 'Unknown deployment mode: "{mode}"', mode => $mode;
+ ;
+
+ $self->max_name_length(
+ max map {
+ length $_->format_name_with_tags
+ } ($plan->changes)[$plan->position + 1..$to_index]
+ );
+
+ $self->$meth( $plan, $to_index );
+}
+
+sub revert {
+ my ( $self, $to ) = @_;
+ $self->_check_registry;
+ my $sqitch = $self->sqitch;
+ my $plan = $self->plan;
+
+ my @changes;
+
+ if (defined $to) {
+ my ($change) = $self->_load_changes(
+ $self->change_for_key($to)
+ ) or do {
+ # Not deployed. Is it in the plan?
+ if ( $plan->find($to) ) {
+ # Known but not deployed.
+ hurl revert => __x(
+ 'Change not deployed: "{change}"',
+ change => $to
+ );
+ }
+ # Never heard of it.
+ hurl revert => __x(
+ 'Unknown change: "{change}"',
+ change => $to,
+ );
+ };
+
+ @changes = $self->deployed_changes_since(
+ $self->_load_changes($change)
+ ) or do {
+ $sqitch->info(__x(
+ 'No changes deployed since: "{change}"',
+ change => $to,
+ ));
+ return $self;
+ };
+
+ if ($self->no_prompt) {
+ $sqitch->info(__x(
+ 'Reverting changes to {change} from {destination}',
+ change => $change->format_name_with_tags,
+ destination => $self->destination,
+ ));
+ } else {
+ hurl {
+ ident => 'revert:confirm',
+ message => __ 'Nothing reverted',
+ exitval => 1,
+ } unless $sqitch->ask_yes_no(__x(
+ 'Revert changes to {change} from {destination}?',
+ change => $change->format_name_with_tags,
+ destination => $self->destination,
+ ), $self->prompt_accept );
+ }
+
+ } else {
+ @changes = $self->deployed_changes or do {
+ $sqitch->info(__ 'Nothing to revert (nothing deployed)');
+ return $self;
+ };
+
+ if ($self->no_prompt) {
+ $sqitch->info(__x(
+ 'Reverting all changes from {destination}',
+ destination => $self->destination,
+ ));
+ } else {
+ hurl {
+ ident => 'revert',
+ message => __ 'Nothing reverted',
+ exitval => 1,
+ } unless $sqitch->ask_yes_no(__x(
+ 'Revert all changes from {destination}?',
+ destination => $self->destination,
+ ), $self->prompt_accept );
+ }
+ }
+
+ # Make change objects and check that all dependencies will be satisfied.
+ @changes = reverse $self->_load_changes( @changes );
+ $self->check_revert_dependencies(@changes);
+
+ # Do we want to support modes, where failures would re-deploy to previous
+ # tag or all the way back to the starting point? This would be very much
+ # like deploy() mode. I'm thinking not, as a failure on a revert is not
+ # something you generally want to recover from by deploying back to where
+ # you started. But maybe I'm wrong?
+ $self->max_name_length(
+ max map { length $_->format_name_with_tags } @changes
+ );
+ $self->revert_change($_) for @changes;
+
+ return $self;
+}
+
+sub verify {
+ my ( $self, $from, $to ) = @_;
+ $self->_check_registry;
+ my $sqitch = $self->sqitch;
+ my $plan = $self->plan;
+ my @changes = $self->_load_changes( $self->deployed_changes );
+
+ $sqitch->info(__x(
+ 'Verifying {destination}',
+ destination => $self->destination,
+ ));
+
+ if (!@changes) {
+ my $msg = $plan->count
+ ? __ 'No changes deployed'
+ : __ 'Nothing to verify (no planned or deployed changes)';
+ $sqitch->info($msg);
+ return $self;
+ }
+
+ if ($plan->count == 0) {
+ # Oy, there are deployed changes, but not planned!
+ hurl verify => __ 'There are deployed changes, but none planned!';
+ }
+
+ # Figure out where to start and end relative to the plan.
+ my $from_idx = defined $from
+ ? $self->_trim_to('verify', $from, \@changes)
+ : 0;
+
+ my $to_idx = defined $to ? $self->_trim_to('verify', $to, \@changes, 1) : do {
+ if (my $id = $self->latest_change_id) {
+ $plan->index_of( $id );
+ }
+ } // $plan->count - 1;
+
+ # Run the verify tests.
+ if ( my $count = $self->_verify_changes($from_idx, $to_idx, !$to, @changes) ) {
+ # Emit a quick report.
+ # XXX Consider coloring red.
+ my $num_changes = 1 + $to_idx - $from_idx;
+ $num_changes = @changes if @changes > $num_changes;
+ my $msg = __ 'Verify Summary Report';
+ $sqitch->emit("\n", $msg);
+ $sqitch->emit('-' x length $msg);
+ $sqitch->emit(__x 'Changes: {number}', number => $num_changes );
+ $sqitch->emit(__x 'Errors: {number}', number => $count );
+ hurl verify => __ 'Verify failed';
+ }
+
+ # Success!
+ # XXX Consider coloring green.
+ $sqitch->emit(__ 'Verify successful');
+
+ return $self;
+}
+
+sub _trim_to {
+ my ( $self, $ident, $key, $changes, $pop ) = @_;
+ my $sqitch = $self->sqitch;
+ my $plan = $self->plan;
+
+ # Find the to change in the database.
+ my $to_id = $self->change_id_for_key( $key ) || hurl $ident => (
+ $plan->contains( $key ) ? __x(
+ 'Change "{change}" has not been deployed',
+ change => $key,
+ ) : __x(
+ 'Cannot find "{change}" in the database or the plan',
+ change => $key,
+ )
+ );
+
+ # Find the change in the plan.
+ my $to_idx = $plan->index_of( $to_id ) // hurl $ident => __x(
+ 'Change "{change}" is deployed, but not planned',
+ change => $key,
+ );
+
+ # Pop or shift changes till we find the change we want.
+ if ($pop) {
+ pop @{ $changes } while $changes->[-1]->id ne $to_id;
+ } else {
+ shift @{ $changes } while $changes->[0]->id ne $to_id;
+ }
+
+ # We good.
+ return $to_idx;
+}
+
+sub _verify_changes {
+ my $self = shift;
+ my $from_idx = shift;
+ my $to_idx = shift;
+ my $pending = shift;
+ my $sqitch = $self->sqitch;
+ my $plan = $self->plan;
+ my $errcount = 0;
+ my $i = -1;
+ my @seen;
+
+ my $max_name_len = max map {
+ length $_->format_name_with_tags
+ } @_, map { $plan->change_at($_) } $from_idx..$to_idx;
+
+ for my $change (@_) {
+ $i++;
+ my $errs = 0;
+ my $reworked = 0;
+ my $name = $change->format_name_with_tags;
+ $sqitch->emit_literal(
+ " * $name ..",
+ '.' x ($max_name_len - length $name), ' '
+ );
+
+ my $plan_index = $plan->index_of( $change->id );
+ if (defined $plan_index) {
+ push @seen => $plan_index;
+ if ( $plan_index != ($from_idx + $i) ) {
+ $sqitch->comment(__ 'Out of order');
+ $errs++;
+ }
+ # Is it reworked?
+ $reworked = $plan->change_at($plan_index)->is_reworked;
+ } else {
+ $sqitch->comment(__ 'Not present in the plan');
+ $errs++;
+ }
+
+ # Run the verify script.
+ try { $self->verify_change( $change ) } catch {
+ $sqitch->comment(eval { $_->message } // $_);
+ $errs++;
+ } unless $reworked;
+
+ # Emit pass/fail and add to the total error count.
+ $sqitch->emit( $errs ? __ 'not ok' : __ 'ok' );
+ $errcount += $errs;
+ }
+
+ # List any undeployed changes.
+ for my $idx ($from_idx..$to_idx) {
+ next if defined first { $_ == $idx } @seen;
+ my $change = $plan->change_at( $idx );
+ my $name = $change->format_name_with_tags;
+ $sqitch->emit_literal(
+ " * $name ..",
+ '.' x ($max_name_len - length $name), ' ',
+ __ 'not ok', ' '
+ );
+ $sqitch->comment(__ 'Not deployed');
+ $errcount++;
+ }
+
+ # List any pending changes.
+ if ($pending && $to_idx < ($plan->count - 1)) {
+ if (my @pending = map {
+ $plan->change_at($_)
+ } ($to_idx + 1)..($plan->count - 1) ) {
+ $sqitch->emit(__n(
+ 'Undeployed change:',
+ 'Undeployed changes:',
+ @pending,
+ ));
+
+ $sqitch->emit( ' * ', $_->format_name_with_tags ) for @pending;
+ }
+ }
+
+ return $errcount;
+}
+
+sub verify_change {
+ my ( $self, $change ) = @_;
+ my $file = $change->verify_file;
+ if (-e $file) {
+ return try { $self->run_verify($file) }
+ catch {
+ hurl {
+ ident => 'verify',
+ previous_exception => $_,
+ message => __x(
+ 'Verify script "{script}" failed.',
+ script => $file,
+ ),
+ };
+ };
+ }
+
+ # The file does not exist. Complain, but don't die.
+ $self->sqitch->vent(__x(
+ 'Verify script {file} does not exist',
+ file => $file,
+ ));
+
+ return $self;
+}
+
+sub run_deploy { shift->run_file(@_) }
+sub run_revert { shift->run_file(@_) }
+sub run_verify { shift->run_file(@_) }
+sub run_upgrade { shift->run_file(@_) }
+
+sub check_deploy_dependencies {
+ my ( $self, $plan, $to_index ) = @_;
+ my $from_index = $plan->position + 1;
+ $to_index //= $plan->count - 1;
+ my @changes = map { $plan->change_at($_) } $from_index..$to_index;
+ my (%seen, @conflicts, @required);
+
+ for my $change (@changes) {
+ # Check for conflicts.
+ push @conflicts => grep {
+ $seen{ $_->id // '' } || $self->change_id_for_depend($_)
+ } $change->conflicts;
+
+ # Check for prerequisites.
+ push @required => grep { !$_->resolved_id(do {
+ if ( my $req = $seen{ $_->id // '' } ) {
+ $req->id;
+ } else {
+ $self->change_id_for_depend($_);
+ }
+ }) } $change->requires;
+ $seen{ $change->id } = $change;
+ }
+
+ if (@conflicts or @required) {
+ require List::MoreUtils;
+ my $listof = sub { List::MoreUtils::uniq(map { $_->as_string } @_) };
+ # Dependencies not satisfied. Put together the error messages.
+ my @msg;
+ push @msg, __nx(
+ 'Conflicts with previously deployed change: {changes}',
+ 'Conflicts with previously deployed changes: {changes}',
+ scalar @conflicts,
+ changes => join ' ', @conflicts,
+ ) if @conflicts = $listof->(@conflicts);
+
+ push @msg, __nx(
+ 'Missing required change: {changes}',
+ 'Missing required changes: {changes}',
+ scalar @required,
+ changes => join ' ', @required,
+ ) if @required = $listof->(@required);
+
+ hurl deploy => join "\n" => @msg;
+ }
+
+ # Make sure nothing isn't already deployed.
+ if ( my @ids = $self->are_deployed_changes(@changes) ) {
+ hurl deploy => __nx(
+ 'Change "{changes}" has already been deployed',
+ 'Changes have already been deployed: {changes}',
+ scalar @ids,
+ changes => join ' ', map { $seen{$_} } @ids
+ );
+ }
+
+ return $self;
+}
+
+sub check_revert_dependencies {
+ my $self = shift;
+ my $proj = $self->plan->project;
+ my (%seen, @msg);
+
+ for my $change (@_) {
+ $seen{ $change->id } = 1;
+ my @requiring = grep {
+ !$seen{ $_->{change_id} }
+ } $self->changes_requiring_change($change) or next;
+
+ # XXX Include change_id in the output?
+ push @msg => __nx(
+ 'Change "{change}" required by currently deployed change: {changes}',
+ 'Change "{change}" required by currently deployed changes: {changes}',
+ scalar @requiring,
+ change => $change->format_name_with_tags,
+ changes => join ' ', map {
+ ($_->{project} eq $proj ? '' : "$_->{project}:" )
+ . $_->{change}
+ . ($_->{asof_tag} // '')
+ } @requiring
+ );
+ }
+
+ hurl revert => join "\n", @msg if @msg;
+
+ # XXX Should we make sure that they are all deployed before trying to
+ # revert them?
+
+ return $self;
+}
+
+sub change_id_for_depend {
+ my ( $self, $dep ) = @_;
+ hurl engine => __x(
+ 'Invalid dependency: {dependency}',
+ dependency => $dep->as_string,
+ ) unless defined $dep->id
+ || defined $dep->change
+ || defined $dep->tag;
+
+ # Return the first one.
+ return $self->change_id_for(
+ change_id => $dep->id,
+ change => $dep->change,
+ tag => $dep->tag,
+ project => $dep->project,
+ first => 1,
+ );
+}
+
+sub _params_for_key {
+ my ( $self, $key ) = @_;
+ my $offset = App::Sqitch::Plan::ChangeList::_offset $key;
+ my ( $cname, $tag ) = split /@/ => $key, 2;
+
+ my @off = ( offset => $offset );
+ return ( @off, change => $cname, tag => $tag ) if $tag;
+ return ( @off, change_id => $cname ) if $cname =~ /^[0-9a-f]{40}$/;
+ return ( @off, tag => $cname ) if $cname eq 'HEAD' || $cname eq 'ROOT';
+ return ( @off, change => $cname );
+}
+
+sub change_id_for_key {
+ my $self = shift;
+ return $self->find_change_id( $self->_params_for_key(shift) );
+}
+
+sub find_change_id {
+ my ( $self, %p ) = @_;
+
+ # Find the change ID or return undef.
+ my $change_id = $self->change_id_for(
+ change_id => $p{change_id},
+ change => $p{change},
+ tag => $p{tag},
+ project => $p{project} || $self->plan->project,
+ ) // return;
+
+ # Return relative to the offset.
+ return $self->change_id_offset_from_id($change_id, $p{offset});
+}
+
+sub change_for_key {
+ my $self = shift;
+ return $self->find_change( $self->_params_for_key(shift) );
+}
+
+sub find_change {
+ my ( $self, %p ) = @_;
+
+ # Find the change ID or return undef.
+ my $change_id = $self->change_id_for(
+ change_id => $p{change_id},
+ change => $p{change},
+ tag => $p{tag},
+ project => $p{project} || $self->plan->project,
+ ) // return;
+
+ # Return relative to the offset.
+ return $self->change_offset_from_id($change_id, $p{offset});
+}
+
+sub _load_changes {
+ my $self = shift;
+ my $plan = $self->plan;
+ my (@changes, %seen);
+ my %rework_tags_for;
+ for my $params (@_) {
+ next unless $params;
+ my $tags = $params->{tags} || [];
+ my $c = App::Sqitch::Plan::Change->new(%{ $params }, plan => $plan );
+
+ # Add tags.
+ $c->add_tag(
+ App::Sqitch::Plan::Tag->new(name => $_, plan => $plan, change => $c )
+ ) for map { s/^@//; $_ } @{ $tags };
+
+ if ( defined ( my $prev_idx = $seen{ $params->{name} } ) ) {
+ # It's reworked; grab all subsequent tags up to but not including
+ # the reworking change to the reworked change.
+ my $ctags = $rework_tags_for{ $prev_idx } ||= [];
+ my $i;
+ for my $x ($prev_idx..$#changes) {
+ my $rtags = $ctags->[$i++] ||= [];
+ my %s = map { $_->name => 1 } @{ $rtags };
+ push @{ $rtags } => grep { !$s{$_->name} } $changes[$x]->tags;
+ }
+ }
+
+ if ( defined ( my $reworked_idx = eval {
+ $plan->first_index_of( @{ $params }{qw(name id)} )
+ } ) ) {
+ # The plan has it reworked later; grab all tags from this change
+ # up to but not including the reworked change.
+ my $ctags = $rework_tags_for{ $#changes + 1 } ||= [];
+ my $idx = $plan->index_of($params->{id});
+ my $i;
+ for my $x ($idx..$reworked_idx - 1) {
+ my $c = $plan->change_at($x);
+ my $rtags = $ctags->[$i++] ||= [];
+ push @{ $rtags } => $plan->change_at($x)->tags;
+ }
+ }
+
+ push @changes => $c;
+ $seen{ $params->{name} } = $#changes;
+ }
+
+ # Associate all rework tags in reverse order. Tags fetched from the plan
+ # have priority over tags fetched from the database.
+ while (my ($idx, $tags) = each %rework_tags_for) {
+ my %seen;
+ $changes[$idx]->add_rework_tags(
+ grep { !$seen{$_->name}++ }
+ map { @{ $_ } } reverse @{ $tags }
+ );
+ }
+
+ return @changes;
+}
+
+sub _handle_lookup_index {
+ my ( $self, $change, $ids ) = @_;
+
+ # Return if 0 or 1 ID.
+ return $ids->[0] if @{ $ids } <= 1;
+
+ # Too many found! Let the user know.
+ my $sqitch = $self->sqitch;
+ $sqitch->vent(__x(
+ 'Change "{change}" is ambiguous. Please specify a tag-qualified change:',
+ change => $change,
+ ));
+
+ # Lookup, emit reverse-chron list of tag-qualified changes, and die.
+ my $plan = $self->plan;
+ for my $id ( reverse @{ $ids } ) {
+ # Look in the plan, first.
+ if ( my $change = $plan->find($id) ) {
+ $self->sqitch->vent( ' * ', $change->format_tag_qualified_name )
+ } else {
+ # Look it up in the database.
+ $self->sqitch->vent( ' * ', $self->name_for_change_id($id) // '' )
+ }
+ }
+ hurl engine => __ 'Change Lookup Failed';
+}
+
+sub _deploy_by_change {
+ my ( $self, $plan, $to_index ) = @_;
+
+ # Just deploy each change. If any fails, we just stop.
+ while ($plan->position < $to_index) {
+ $self->deploy_change($plan->next);
+ }
+
+ return $self;
+}
+
+sub _rollback {
+ my ($self, $tagged) = (shift, shift);
+ my $sqitch = $self->sqitch;
+
+ if (my @run = reverse @_) {
+ $tagged = $tagged ? $tagged->format_name_with_tags : $self->start_at;
+ $sqitch->vent(
+ $tagged ? __x('Reverting to {change}', change => $tagged)
+ : __ 'Reverting all changes'
+ );
+
+ try {
+ $self->revert_change($_) for @run;
+ } catch {
+ # Sucks when this happens.
+ $sqitch->vent(eval { $_->message } // $_);
+ $sqitch->vent(__ 'The schema will need to be manually repaired');
+ };
+ }
+
+ hurl deploy => __ 'Deploy failed';
+}
+
+sub _deploy_by_tag {
+ my ( $self, $plan, $to_index ) = @_;
+
+ my ($last_tagged, @run);
+ try {
+ while ($plan->position < $to_index) {
+ my $change = $plan->next;
+ $self->deploy_change($change);
+ push @run => $change;
+ if ($change->tags) {
+ @run = ();
+ $last_tagged = $change;
+ }
+ }
+ } catch {
+ if (my $ident = eval { $_->ident }) {
+ $self->sqitch->vent($_->message) unless $ident eq 'private'
+ } else {
+ $self->sqitch->vent($_);
+ }
+ $self->_rollback($last_tagged, @run);
+ };
+
+ return $self;
+}
+
+sub _deploy_all {
+ my ( $self, $plan, $to_index ) = @_;
+
+ my @run;
+ try {
+ while ($plan->position < $to_index) {
+ my $change = $plan->next;
+ $self->deploy_change($change);
+ push @run => $change;
+ }
+ } catch {
+ if (my $ident = eval { $_->ident }) {
+ $self->sqitch->vent($_->message) unless $ident eq 'private'
+ } else {
+ $self->sqitch->vent($_);
+ }
+ $self->_rollback(undef, @run);
+ };
+
+ return $self;
+}
+
+sub _sync_plan {
+ my $self = shift;
+ my $plan = $self->plan;
+
+ if (my $state = $self->current_state) {
+ my $idx = $plan->index_of($state->{change_id}) // hurl plan => __x(
+ 'Cannot find change {id} ({change}) in {file}',
+ id => $state->{change_id},
+ change => join(' ', $state->{change}, @{ $state->{tags} || [] }),
+ file => $plan->file,
+ );
+
+ # Upgrade the registry if there is no script_hash column.
+ unless ( exists $state->{script_hash} ) {
+ $self->upgrade_registry;
+ $state->{script_hash} = $state->{change_id};
+ }
+
+ # Update the script hashes if they're the same as the change ID.
+ # DEPRECATTION: Added in v0.998 (Jan 2015, c86cba61c); consider removing
+ # in the future when all databases are likely to be updated already.
+ $self->_update_script_hashes if $state->{script_hash}
+ && $state->{script_hash} eq $state->{change_id};
+
+ $plan->position($idx);
+ my $change = $plan->change_at($idx);
+ if (my @tags = $change->tags) {
+ $self->log_new_tags($change);
+ $self->start_at( $change->format_name . $tags[-1]->format_name );
+ } else {
+ $self->start_at( $change->format_name );
+ }
+
+ } else {
+ $plan->reset;
+ }
+ return $plan;
+}
+
+sub is_deployed {
+ my ($self, $thing) = @_;
+ return $thing->isa('App::Sqitch::Plan::Tag')
+ ? $self->is_deployed_tag($thing)
+ : $self->is_deployed_change($thing);
+}
+
+sub deploy_change {
+ my ( $self, $change ) = @_;
+ my $sqitch = $self->sqitch;
+ my $name = $change->format_name_with_tags;
+ $sqitch->info_literal(
+ " + $name ..",
+ '.' x ($self->max_name_length - length $name), ' '
+ );
+ $self->begin_work($change);
+
+ return try {
+ $self->run_deploy($change->deploy_file) unless $self->log_only;
+ try {
+ $self->verify_change( $change ) if $self->with_verify;
+ $self->log_deploy_change($change);
+ $sqitch->info(__ 'ok');
+ } catch {
+ # Oy, logging or verify failed. Rollback.
+ $sqitch->vent(eval { $_->message } // $_);
+ $self->rollback_work($change);
+
+ # Begin work and run the revert.
+ try {
+ # Don't bother displaying the reverting change name.
+ # $self->sqitch->info(' - ', $change->format_name_with_tags);
+ $self->begin_work($change);
+ $self->run_revert($change->revert_file) unless $self->log_only;
+ } catch {
+ # Oy, the revert failed. Just emit the error.
+ $sqitch->vent(eval { $_->message } // $_);
+ };
+ hurl private => __ 'Deploy failed';
+ };
+ } finally {
+ $self->finish_work($change);
+ } catch {
+ $self->log_fail_change($change);
+ $sqitch->info(__ 'not ok');
+ die $_;
+ };
+}
+
+sub revert_change {
+ my ( $self, $change ) = @_;
+ my $sqitch = $self->sqitch;
+ my $name = $change->format_name_with_tags;
+ $sqitch->info_literal(
+ " - $name ..",
+ '.' x ($self->max_name_length - length $name), ' '
+ );
+
+ $self->begin_work($change);
+
+ try {
+ $self->run_revert($change->revert_file) unless $self->log_only;
+ try {
+ $self->log_revert_change($change);
+ $sqitch->info(__ 'ok');
+ } catch {
+ # Oy, our logging died. Rollback and revert this change.
+ $self->sqitch->vent(eval { $_->message } // $_);
+ $self->rollback_work($change);
+ hurl revert => 'Revert failed';
+ };
+ } finally {
+ $self->finish_work($change);
+ } catch {
+ $sqitch->info(__ 'not ok');
+ die $_;
+ };
+}
+
+sub begin_work { shift }
+sub finish_work { shift }
+sub rollback_work { shift }
+
+sub earliest_change {
+ my $self = shift;
+ my $change_id = $self->earliest_change_id(@_) // return undef;
+ return $self->plan->get( $change_id );
+}
+
+sub latest_change {
+ my $self = shift;
+ my $change_id = $self->latest_change_id(@_) // return undef;
+ return $self->plan->get( $change_id );
+}
+
+sub needs_upgrade {
+ my $self = shift;
+ $self->registry_version != $self->registry_release;
+}
+
+sub _check_registry {
+ my $self = shift;
+ my $newver = $self->registry_release;
+ my $oldver = $self->registry_version;
+ return $self if $newver == $oldver;
+
+ hurl engine => __x(
+ 'No registry found in {destination}. Have you ever deployed?',
+ destination => $self->registry_destination,
+ ) if $oldver == 0 && !$self->initialized;
+
+ hurl engine => __x(
+ 'Registry version is {old} but {new} is the latest known. Please upgrade Sqitch',
+ old => $oldver,
+ new => $newver,
+ ) if $newver < $oldver;
+
+ hurl engine => __x(
+ 'Registry is at version {old} but latest is {new}. Please run the "upgrade" command',
+ old => $oldver,
+ new => $newver,
+ ) if $newver > $oldver;
+}
+
+sub upgrade_registry {
+ my $self = shift;
+ return $self unless $self->needs_upgrade;
+
+ my $sqitch = $self->sqitch;
+ my $newver = $self->registry_release;
+ my $oldver = $self->registry_version;
+
+ hurl __x(
+ 'Registry version is {old} but {new} is the latest known. Please upgrade Sqitch.',
+ old => $oldver,
+ new => $newver,
+ ) if $newver < $oldver;
+
+ my $key = $self->key;
+ my $dir = file(__FILE__)->dir->subdir(qw(Engine Upgrade));
+
+ my @scripts = sort { $a->[0] <=> $b->[0] } grep { $_->[0] > $oldver } map {
+ $_->basename =~ /\A\Q$key\E-(\d(?:[.]\d*)?)/;
+ [ $1 || 0, $_ ];
+ } $dir->children;
+
+ # Make sure we're upgrading to where we want to be.
+ hurl engine => __x(
+ 'Cannot upgrade to {version}: Cannot find upgrade script "{file}"',
+ version => $newver,
+ file => $dir->file("$key-$newver.*"),
+ ) unless @scripts && $scripts[-1]->[0] == $newver;
+
+ # Run the upgrades.
+ $sqitch->info(__x(
+ 'Upgrading the Sqitch registry from {old} to {new}',
+ old => $oldver,
+ new => $newver,
+ ));
+ for my $script (@scripts) {
+ my ($version, $file) = @{ $script };
+ $sqitch->info(' * ' . __x(
+ 'From {old} to {new}',
+ old => $oldver,
+ new => $version,
+ ));
+ $self->run_upgrade($file);
+ $self->_register_release($version);
+ $oldver = $version;
+ }
+
+ return $self;
+}
+
+sub initialized {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented initialized()";
+}
+
+sub initialize {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented initialize()";
+}
+
+sub register_project {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented register_project()";
+}
+
+sub run_file {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented run_file()";
+}
+
+sub run_handle {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented run_handle()";
+}
+
+sub log_deploy_change {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented log_deploy_change()";
+}
+
+sub log_fail_change {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented log_fail_change()";
+}
+
+sub log_revert_change {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented log_revert_change()";
+}
+
+sub log_new_tags {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented log_new_tags()";
+}
+
+sub is_deployed_tag {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented is_deployed_tag()";
+}
+
+sub is_deployed_change {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented is_deployed_change()";
+}
+
+sub are_deployed_changes {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented are_deployed_changes()";
+}
+
+sub change_id_for {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented change_id_for()";
+}
+
+sub earliest_change_id {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented earliest_change_id()";
+}
+
+sub latest_change_id {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented latest_change_id()";
+}
+
+sub deployed_changes {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented deployed_changes()";
+}
+
+sub deployed_changes_since {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented deployed_changes_since()";
+}
+
+sub load_change {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented load_change()";
+}
+
+sub changes_requiring_change {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented changes_requiring_change()";
+}
+
+sub name_for_change_id {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented name_for_change_id()";
+}
+
+sub change_offset_from_id {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented change_offset_from_id()";
+}
+
+sub change_id_offset_from_id {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented change_id_offset_from_id()";
+}
+
+sub registered_projects {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented registered_projects()";
+}
+
+sub current_state {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented current_state()";
+}
+
+sub current_changes {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented current_changes()";
+}
+
+sub current_tags {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented current_tags()";
+}
+
+sub search_events {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented search_events()";
+}
+
+sub registry_version {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented registry_version()";
+}
+
+sub _update_script_hashes {
+ my $class = ref $_[0] || $_[0];
+ hurl "$class has not implemented _update_script_hashes()";
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine - Sqitch Deployment Engine
+
+=head1 Synopsis
+
+ my $engine = App::Sqitch::Engine->new( sqitch => $sqitch );
+
+=head1 Description
+
+App::Sqitch::Engine provides the base class for all Sqitch storage engines.
+Most likely this will not be of much interest to you unless you are hacking on
+the engine code.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<key>
+
+ my $name = App::Sqitch::Engine->key;
+
+The key name of the engine. Should be the last part of the package name.
+
+=head3 C<name>
+
+ my $name = App::Sqitch::Engine->name;
+
+The name of the engine. Returns the same value as C<key> by default, but
+should probably be overridden to return a display name for the engine.
+
+=head3 C<default_registry>
+
+ my $reg = App::Sqitch::Engine->default_registry;
+
+Returns the name of the default registry for the engine. Most engines just
+inherit the default value, C<sqitch>, but some must do more munging, such as
+specifying a file name, to determine the default registry name.
+
+=head3 C<default_client>
+
+ my $cli = App::Sqitch::Engine->default_client;
+
+Returns the name of the default client for the engine. Must be implemented by
+each engine.
+
+=head3 C<driver>
+
+ my $driver = App::Sqitch::Engine->driver;
+
+The name and version of the database driver to use with the engine, returned
+as a string suitable for passing to C<use>. Used internally by C<use_driver()>
+to C<use> the driver and, if it dies, to display an appropriate error message.
+Must be overridden by subclasses.
+
+=head3 C<use_driver>
+
+ App::Sqitch::Engine->use_driver;
+
+Uses the driver and version returned by C<driver>. Returns an error on failure
+and returns true on success.
+
+=head3 C<config_vars>
+
+ my %vars = App::Sqitch::Engine->config_vars;
+
+Returns a hash of names and types to use for configuration variables for the
+engine. These can be set under the C<engine.$engine_name> section in any
+configuration file.
+
+The keys in the returned hash are the names of the variables. The values are
+the data types. Valid data types include:
+
+=over
+
+=item C<any>
+
+=item C<int>
+
+=item C<num>
+
+=item C<bool>
+
+=item C<bool-or-int>
+
+=back
+
+Values ending in C<+> (a plus sign) may be specified multiple times. Example:
+
+ (
+ client => 'any',
+ host => 'any',
+ port => 'int',
+ set => 'any+',
+ )
+
+In this example, the C<port> variable will be stored and retrieved as an
+integer. The C<set> variable may be of any type and may be included multiple
+times. All the other variables may be of any type.
+
+By default, App::Sqitch::Engine returns:
+
+ (
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+ )
+
+Subclasses for supported engines will return more.
+
+=head3 C<registry_release>
+
+Returns the version of the registry understood by this release of Sqitch. The
+C<needs_upgrade()> method compares this value to that returned by
+C<registry_version()> to determine whether the target's registry needs
+upgrading.
+
+=head2 Constructors
+
+=head3 C<load>
+
+ my $cmd = App::Sqitch::Engine->load(%params);
+
+A factory method for instantiating Sqitch engines. It loads the subclass for
+the specified engine and calls C<new>, passing the Sqitch object. Supported
+parameters are:
+
+=over
+
+=item C<sqitch>
+
+The App::Sqitch object driving the whole thing.
+
+=back
+
+=head3 C<new>
+
+ my $engine = App::Sqitch::Engine->new(%params);
+
+Instantiates and returns a App::Sqitch::Engine object.
+
+=head2 Instance Accessors
+
+=head3 C<sqitch>
+
+The current Sqitch object.
+
+=head3 C<target>
+
+An L<App::Sqitch::Target> object identifying the database target, usually
+derived from the name of target specified on the command-line, or the default.
+
+=head3 C<uri>
+
+A L<URI::db> object representing the target database. Defaults to a URI
+constructed from the L<App::Sqitch> C<db_*> attributes.
+
+=head3 C<destination>
+
+A string identifying the target database. Usually the same as the C<target>,
+unless it's a URI with the password included, in which case it returns the
+value of C<uri> with the password removed.
+
+=head3 C<registry>
+
+The name of the registry schema or database.
+
+=head3 C<start_at>
+
+The point in the plan from which to start deploying changes.
+
+=head3 C<no_prompt>
+
+Boolean indicating whether or not to prompt for reverts. False by default.
+
+=head3 C<log_only>
+
+Boolean indicating whether or not to log changes I<without running deploy or
+revert scripts>. This is useful for an existing database schema that needs to
+be converted to Sqitch. False by default.
+
+=head3 C<with_verify>
+
+Boolean indicating whether or not to run the verification script after each
+deploy script. False by default.
+
+=head3 C<variables>
+
+A hash of engine client variables to be set. May be set and retrieved as a
+list.
+
+=head2 Instance Methods
+
+=head3 C<username>
+
+ my $username = $engine->username;
+
+The username to use to connect to the database, for engines that require
+authentication. The username is looked up in the following places, returning
+the first to have a value:
+
+=over
+
+=item 1.
+
+The C<$SQITCH_USERNAME> environment variable.
+
+=item 2.
+
+The username from the target URI.
+
+=item 3.
+
+An engine-specific default password, which may be derived from an environment
+variable, engine configuration file, the system user, or none at all.
+
+=back
+
+See L<sqitch-authentication> for details and best practices for Sqitch engine
+authentication.
+
+=head3 C<password>
+
+ my $password = $engine->password;
+
+The password to use to connect to the database, for engines that require
+authentication. The password is looked up in the following places, returning
+the first to have a value:
+
+=over
+
+=item 1.
+
+The C<$SQITCH_PASSWORD> environment variable.
+
+=item 2.
+
+The password from the target URI.
+
+=item 3.
+
+An engine-specific default password, which may be derived from an environment
+variable, engine configuration file, or none at all.
+
+=back
+
+See L<sqitch-authentication> for details and best practices for Sqitch engine
+authentication.
+
+=head3 C<registry_destination>
+
+ my $registry_destination = $engine->registry_destination;
+
+Returns the name of the registry database. In other words, the database in
+which Sqitch's own data is stored. It will usually be the same as C<target()>,
+but some engines, such as L<SQLite|App::Sqitch::Engine::sqlite>, may use a
+separate database. Used internally to name the target when the registration
+tables are created.
+
+=head3 C<variables>
+
+=head3 C<set_variables>
+
+=head3 C<clear_variables>
+
+ my %vars = $engine->variables;
+ $engine->set_variables(foo => 'bar', baz => 'hi there');
+ $engine->clear_variables;
+
+Get, set, and clear engine variables. Variables are defined as key/value pairs
+to be passed to the engine client in calls to C<deploy> and C<revert>, if the
+client supports variables. For example, the
+L<PostgreSQL|App::Sqitch::Engine::pg> and
+L<Vertica|App::Sqitch::Engine::vertica> engines pass all the variables to
+their C<psql> and C<vsql> clients via the C<--set> option, while the
+L<MySQL engine|App::Sqitch::Engine::mysql> engine sets them via the C<SET>
+command and the L<Oracle engine|App::Sqitch::Engine::oracle> engine sets them
+via the SQL*Plus C<DEFINE> command.
+
+
+=head3 C<deploy>
+
+ $engine->deploy($to_change);
+ $engine->deploy($to_change, $mode);
+ $engine->deploy($to_change, $mode);
+
+Deploys changes to the target database, starting with the current deployment
+state, and continuing to C<$to_change>. C<$to_change> must be a valid change
+specification as passable to the C<index_of()> method of L<App::Sqitch::Plan>.
+If C<$to_change> is not specified, all changes will be applied.
+
+The second argument specifies the reversion mode in the case of deployment
+failure. The allowed values are:
+
+=over
+
+=item C<all>
+
+In the event of failure, revert all deployed changes, back to the point at
+which deployment started. This is the default.
+
+=item C<tag>
+
+In the event of failure, revert all deployed changes to the last
+successfully-applied tag. If no tags were applied during this deployment, all
+changes will be reverted to the pint at which deployment began.
+
+=item C<change>
+
+In the event of failure, no changes will be reverted. This is on the
+assumption that a change failure is total, and the change may be applied again.
+
+=back
+
+Note that, in the event of failure, if a reversion fails, the target database
+B<may be left in a corrupted state>. Write your revert scripts carefully!
+
+=head3 C<revert>
+
+ $engine->revert;
+ $engine->revert($tag);
+ $engine->revert($tag);
+
+Reverts the L<App::Sqitch::Plan::Tag> from the database, including all of its
+associated changes.
+
+=head3 C<verify>
+
+ $engine->verify;
+ $engine->verify( $from );
+ $engine->verify( $from, $to );
+ $engine->verify( undef, $to );
+
+Verifies the database against the plan. Pass in change identifiers, as
+described in L<sqitchchanges>, to limit the changes to verify. For each
+change, information will be emitted if:
+
+=over
+
+=item *
+
+It does not appear in the plan.
+
+=item *
+
+It has not been deployed to the database.
+
+=item *
+
+It has been deployed out-of-order relative to the plan.
+
+=item *
+
+Its verify script fails.
+
+=back
+
+Changes without verify scripts will emit a warning, but not constitute a
+failure. If there are any failures, an exception will be thrown once all
+verifications have completed.
+
+=head3 C<check_deploy_dependencies>
+
+ $engine->check_deploy_dependencies;
+ $engine->check_deploy_dependencies($to_index);
+
+Validates that all dependencies will be met for all changes to be deployed,
+starting with the currently-deployed change up to the specified index, or to
+the last change in the plan if no index is passed. If any of the changes to be
+deployed would conflict with previously-deployed changes or are missing any
+required changes, an exception will be thrown. Used internally by C<deploy()>
+to ensure that dependencies will be satisfied before deploying any changes.
+
+=head3 C<check_revert_dependencies>
+
+ $engine->check_revert_dependencies(@changes);
+
+Validates that the list of changes to be reverted, which should be passed in
+the order in which they will be reverted, are not depended upon by other
+changes. If any are depended upon by other changes, an exception will be
+thrown listing the changes that cannot be reverted and what changes depend on
+them. Used internally by C<revert()> to ensure no dependencies will be
+violated before revering any changes.
+
+=head3 C<deploy_change>
+
+ $engine->deploy_change($change);
+ $engine->deploy_change($change);
+
+Used internally by C<deploy()> to deploy an individual change.
+
+=head3 C<revert_change>
+
+ $engine->revert_change($change);
+ $engine->revert_change($change);
+
+Used internally by C<revert()> (and, by C<deploy()> when a deploy fails) to
+revert an individual change.
+
+=head3 C<verify_change>
+
+ $engine->verify_change($change);
+
+Used internally by C<deploy_change()> to verify a just-deployed change if
+C<with_verify> is true.
+
+=head3 C<is_deployed>
+
+ say "Tag deployed" if $engine->is_deployed($tag);
+ say "Change deployed" if $engine->is_deployed($change);
+
+Convenience method that dispatches to C<is_deployed_tag()> or
+C<is_deployed_change()> as appropriate to its argument.
+
+=head3 C<earliest_change>
+
+ my $change = $engine->earliest_change;
+ my $change = $engine->earliest_change($offset);
+
+Returns the L<App::Sqitch::Plan::Change> object representing the earliest
+applied change. With the optional C<$offset> argument, the returned change
+will be the offset number of changes following the earliest change.
+
+
+=head3 C<latest_change>
+
+ my $change = $engine->latest_change;
+ my $change = $engine->latest_change($offset);
+
+Returns the L<App::Sqitch::Plan::Change> object representing the latest
+applied change. With the optional C<$offset> argument, the returned change
+will be the offset number of changes before the latest change.
+
+=head3 C<change_for_key>
+
+ my $change = if $engine->change_for_key($key);
+
+Searches the deployed changes for a change corresponding to the specified key,
+which should be in a format as described in L<sqitchchanges>. Throws an
+exception if the key matches more than one changes. Returns C<undef> if it
+matches no changes.
+
+=head3 C<change_id_for_key>
+
+ my $change_id = if $engine->change_id_for_key($key);
+
+Searches the deployed changes for a change corresponding to the specified key,
+which should be in a format as described in L<sqitchchanges>, and returns the
+change's ID. Throws an exception if the key matches more than one change.
+Returns C<undef> if it matches no changes.
+
+=head3 C<change_for_key>
+
+ my $change = if $engine->change_for_key($key);
+
+Searches the list of deployed changes for a change corresponding to the
+specified key, which should be in a format as described in L<sqitchchanges>.
+Throws an exception if the key matches multiple changes.
+
+=head3 C<change_id_for_depend>
+
+ say 'Dependency satisfied' if $engine->change_id_for_depend($depend);
+
+Returns the change ID for a L<dependency|App::Sqitch::Plan::Depend>, if the
+dependency resolves to a change currently deployed to the database. Returns
+C<undef> if the dependency resolves to no currently-deployed change.
+
+=head3 C<find_change>
+
+ my $change = $engine->find_change(%params);
+
+Finds and returns a deployed change, or C<undef> if the change has not been
+deployed. The supported parameters are:
+
+=over
+
+=item C<change_id>
+
+The change ID.
+
+=item C<change>
+
+A change name.
+
+=item C<tag>
+
+A tag name.
+
+=item C<project>
+
+A project name. Defaults to the current project.
+
+=item C<offset>
+
+The number of changes offset from the change found by the other parameters
+should actually be returned. May be positive or negative.
+
+=back
+
+The order of precedence for the search is:
+
+=over
+
+=item 1.
+
+Search by change ID, if passed.
+
+=item 2.
+
+Search by change name as of tag, if both are passed.
+
+=item 3.
+
+Search by change name or tag.
+
+=back
+
+The offset, if passed, will be applied relative to whatever change is found by
+the above algorithm.
+
+=head3 C<find_change_id>
+
+ my $change_id = $engine->find_change_id(%params);
+
+Like C<find_change()>, taking the same parameters, but returning an ID instead
+of a change.
+
+=head3 C<run_deploy>
+
+ $engine->run_deploy($deploy_file);
+
+Runs a deploy script. The implementation is just an alias for C<run_file()>;
+subclasses may override as appropriate.
+
+=head3 C<run_revert>
+
+ $engine->run_revert($revert_file);
+
+Runs a revert script. The implementation is just an alias for C<run_file()>;
+subclasses may override as appropriate.
+
+=head3 C<run_verify>
+
+ $engine->run_verify($verify_file);
+
+Runs a verify script. The implementation is just an alias for C<run_file()>;
+subclasses may override as appropriate.
+
+=head3 C<run_upgrade>
+
+ $engine->run_upgrade($upgrade_file);
+
+Runs an upgrade script. The implementation is just an alias for C<run_file()>;
+subclasses may override as appropriate.
+
+=head3 C<needs_upgrade>
+
+ if ($engine->needs_upgrade) {
+ $engine->upgrade_registry;
+ }
+
+Determines if the target's registry needs upgrading and returns true if it
+does.
+
+=head3 C<upgrade_registry>
+
+ $engine->upgrade_registry;
+
+Upgrades the target's registry, if it needs upgrading. Used by the
+L<C<upgrade>|App::Sqitch::Command::upgrade> command.
+
+=head2 Abstract Instance Methods
+
+These methods must be overridden in subclasses.
+
+=head3 C<begin_work>
+
+ $engine->begin_work($change);
+
+This method is called just before a change is deployed or reverted. It should
+create a lock to prevent any other processes from making changes to the
+database, to be freed in C<finish_work> or C<rollback_work>.
+
+=head3 C<finish_work>
+
+ $engine->finish_work($change);
+
+This method is called after a change has been deployed or reverted. It should
+unlock the lock created by C<begin_work>.
+
+=head3 C<rollback_work>
+
+ $engine->rollback_work($change);
+
+This method is called after a change has been deployed or reverted and the
+logging of that change has failed. It should rollback changes started by
+C<begin_work>.
+
+=head3 C<initialized>
+
+ $engine->initialize unless $engine->initialized;
+
+Returns true if the database has been initialized for Sqitch, and false if it
+has not.
+
+=head3 C<initialize>
+
+ $engine->initialize;
+
+Initializes the target database for Sqitch by installing the Sqitch registry
+schema and/or tables. Should be overridden by subclasses. This implementation
+throws an exception
+
+=head3 C<register_project>
+
+ $engine->register_project;
+
+Registers the current project plan in the registry database. The
+implementation should insert the project name and URI if they have not already
+been inserted. If a project already exists with the same name but different
+URI, or a different name and the same URI, an exception should be thrown.
+
+=head3 C<is_deployed_tag>
+
+ say 'Tag deployed' if $engine->is_deployed_tag($tag);
+
+Should return true if the L<tag|App::Sqitch::Plan::Tag> has been applied to
+the database, and false if it has not.
+
+=head3 C<is_deployed_change>
+
+ say 'Change deployed' if $engine->is_deployed_change($change);
+
+Should return true if the L<change|App::Sqitch::Plan::Change> has been
+deployed to the database, and false if it has not.
+
+=head3 C<are_deployed_changes>
+
+ say "Change $_ is deployed" for $engine->are_deployed_change(@changes);
+
+Should return the IDs of any of the changes passed in that are currently
+deployed. Used by C<deploy> to ensure that no changes already deployed are
+re-deployed.
+
+=head3 C<change_id_for>
+
+ say $engine->change_id_for(
+ change => $change_name,
+ tag => $tag_name,
+ project => $project,
+ );
+
+Searches the database for the change with the specified name, tag, project,
+or ID. Returns C<undef> if it matches no changes. If it matches more than one
+change, it returns the earliest deployed change if the C<first> parameter is
+passed; otherwise it throws an exception The parameters are as follows:
+
+=over
+
+=item C<change>
+
+The name of a change. Required unless C<tag> or C<change_id> is passed.
+
+=item C<change_id>
+
+The ID of a change. Required unless C<tag> or C<change> is passed. Useful
+to determine whether an ID in a plan has been deployed to the database.
+
+=item C<tag>
+
+The name of a tag. Required unless C<change> is passed.
+
+=item C<project>
+
+The name of the project to search. Defaults to the current project.
+
+=item C<first>
+
+Return the earliest deployed change ID if the search matches more than one
+change. If false or not passed and more than one change is found, an
+exception will be thrown.
+
+=back
+
+If both C<change> and C<tag> are passed, C<find_change_id> will search for the
+last instance of the named change deployed I<before> the tag.
+
+=head3 C<changes_requiring_change>
+
+ my @requiring = $engine->changes_requiring_change($change);
+
+Returns a list of hash references representing currently deployed changes that
+require the passed change. When this method returns one or more hash
+references, the change should not be reverted. Each hash reference should
+contain the following keys:
+
+=over
+
+=item C<change_id>
+
+The requiring change ID.
+
+=item C<change>
+
+The requiring change name.
+
+=item C<project>
+
+The project the requiring change is from.
+
+=item C<asof_tag>
+
+Name of the first tag to be applied after the requiring change was deployed,
+if any.
+
+=back
+
+=head3 C<log_deploy_change>
+
+ $engine->log_deploy_change($change);
+
+Should write the records to the registry necessary to indicate that the change
+has been deployed.
+
+=head3 C<log_fail_change>
+
+ $engine->log_fail_change($change);
+
+Should write to the database event history a record reflecting that deployment
+of the change failed.
+
+=head3 C<log_revert_change>
+
+ $engine->log_revert_change($change);
+
+Should write to and/or remove from the registry the records necessary to
+indicate that the change has been reverted.
+
+=head3 C<log_new_tags>
+
+ $engine->log_new_tags($change);
+
+Given a change, if it has any tags that are not currently logged in the
+database, they should be logged. This is assuming, of course, that the change
+itself has previously been logged.
+
+=head3 C<earliest_change_id>
+
+ my $change_id = $engine->earliest_change_id($offset);
+
+Returns the ID of the earliest applied change from the current project. With
+the optional C<$offset> argument, the ID of the change the offset number of
+changes following the earliest change will be returned.
+
+=head3 C<latest_change_id>
+
+ my $change_id = $engine->latest_change_id;
+ my $change_id = $engine->latest_change_id($offset);
+
+Returns the ID of the latest applied change from the current project.
+With the optional C<$offset> argument, the ID of the change the offset
+number of changes before the latest change will be returned.
+
+=head3 C<deployed_changes>
+
+ my @change_hashes = $engine->deployed_changes;
+
+Returns a list of hash references, each representing a change from the current
+project in the order in which they were deployed. The keys in each hash
+reference must be:
+
+=over
+
+=item C<id>
+
+The change ID.
+
+=item C<name>
+
+The change name.
+
+=item C<project>
+
+The name of the project with which the change is associated.
+
+=item C<note>
+
+The note attached to the change.
+
+=item C<planner_name>
+
+The name of the user who planned the change.
+
+=item C<planner_email>
+
+The email address of the user who planned the change.
+
+=item C<timestamp>
+
+An L<App::Sqitch::DateTime> object representing the time the change was planned.
+
+=item C<tags>
+
+An array reference of the tag names associated with the change.
+
+=back
+
+=head3 C<deployed_changes_since>
+
+ my @change_hashes = $engine->deployed_changes_since($change);
+
+Returns a list of hash references, each representing a change from the current
+project deployed after the specified change. The keys in the hash references
+should be the same as for those returned by C<deployed_changes()>.
+
+=head3 C<name_for_change_id>
+
+ my $change_name = $engine->name_for_change_id($change_id);
+
+Returns the tag-qualified name of the change identified by the ID. If a tag
+was applied to a change after that change, the name will be returned with the
+tag qualification, e.g., C<app_user@beta>. Otherwise, it will include the
+symbolic tag C<@HEAD>. e.g., C<widgets@HEAD>. This value should be suitable
+for uniquely identifying the change, and passing to the C<get> or C<index_of>
+methods of L<App::Sqitch::Plan>.
+
+=head3 C<registered_projects>
+
+ my @projects = $engine->registered_projects;
+
+Returns a list of the names of Sqitch projects registered in the database.
+
+=head3 C<current_state>
+
+ my $state = $engine->current_state;
+ my $state = $engine->current_state($project);
+
+Returns a hash reference representing the current project deployment state of
+the database, or C<undef> if the database has no changes deployed. If a
+project name is passed, the state will be returned for that project. Otherwise,
+the state will be returned for the local project.
+
+The hash contains information about the last successfully deployed change, as
+well as any associated tags. The keys to the hash should include:
+
+=over
+
+=item C<project>
+
+The name of the project for which the state is reported.
+
+=item C<change_id>
+
+The current change ID.
+
+=item C<script_hash>
+
+The deploy script SHA-1 hash.
+
+=item C<change>
+
+The current change name.
+
+=item C<note>
+
+A brief description of the change.
+
+=item C<tags>
+
+An array reference of the names of associated tags.
+
+=item C<committed_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+change was deployed.
+
+=item C<committer_name>
+
+Name of the user who deployed the change.
+
+=item C<committer_email>
+
+Email address of the user who deployed the change.
+
+=item C<planned_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+change was added to the plan.
+
+=item C<planner_name>
+
+Name of the user who added the change to the plan.
+
+=item C<planner_email>
+
+Email address of the user who added the change to the plan.
+
+=back
+
+=head3 C<current_changes>
+
+ my $iter = $engine->current_changes;
+ my $iter = $engine->current_changes($project);
+ while (my $change = $iter->()) {
+ say '* ', $change->{change};
+ }
+
+Returns a code reference that iterates over a list of the currently deployed
+changes in reverse chronological order. If a project name is not passed, the
+current project will be assumed. Each change is represented by a hash
+reference containing the following keys:
+
+=over
+
+=item C<change_id>
+
+The current change ID.
+
+=item C<script_hash>
+
+The deploy script SHA-1 hash.
+
+=item C<change>
+
+The current change name.
+
+=item C<committed_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+change was deployed.
+
+=item C<committer_name>
+
+Name of the user who deployed the change.
+
+=item C<committer_email>
+
+Email address of the user who deployed the change.
+
+=item C<planned_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+change was added to the plan.
+
+=item C<planner_name>
+
+Name of the user who added the change to the plan.
+
+=item C<planner_email>
+
+Email address of the user who added the change to the plan.
+
+=back
+
+=head3 C<current_tags>
+
+ my $iter = $engine->current_tags;
+ my $iter = $engine->current_tags($project);
+ while (my $tag = $iter->()) {
+ say '* ', $tag->{tag};
+ }
+
+Returns a code reference that iterates over a list of the currently deployed
+tags in reverse chronological order. If a project name is not passed, the
+current project will be assumed. Each tag is represented by a hash reference
+containing the following keys:
+
+=over
+
+=item C<tag_id>
+
+The tag ID.
+
+=item C<tag>
+
+The name of the tag.
+
+=item C<committed_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+tag was applied.
+
+=item C<committer_name>
+
+Name of the user who applied the tag.
+
+=item C<committer_email>
+
+Email address of the user who applied the tag.
+
+=item C<planned_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+tag was added to the plan.
+
+=item C<planner_name>
+
+Name of the user who added the tag to the plan.
+
+=item C<planner_email>
+
+Email address of the user who added the tag to the plan.
+
+=back
+
+=head3 C<search_events>
+
+ my $iter = $engine->search_events( %params );
+ while (my $change = $iter->()) {
+ say '* $change->{event}ed $change->{change}";
+ }
+
+Searches the deployment event log and returns an iterator code reference with
+the results. If no parameters are provided, a list of all events will be
+returned from the iterator reverse chronological order. The supported parameters
+are:
+
+=over
+
+=item C<event>
+
+An array of the type of event to search for. Allowed values are "deploy",
+"revert", and "fail".
+
+=item C<project>
+
+Limit the events to those with project names matching the specified regular
+expression.
+
+=item C<change>
+
+Limit the events to those with changes matching the specified regular
+expression.
+
+=item C<committer>
+
+Limit the events to those logged for the actions of the committers with names
+matching the specified regular expression.
+
+=item C<planner>
+
+Limit the events to those with changes who's planner's name matches the
+specified regular expression.
+
+=item C<limit>
+
+Limit the number of events to the specified number.
+
+=item C<offset>
+
+Skip the specified number of events.
+
+=item C<direction>
+
+Return the results in the specified order, which must be a value matching
+C</^(:?a|de)sc/i> for "ascending" or "descending".
+
+=back
+
+Each event is represented by a hash reference containing the following keys:
+
+=over
+
+=item C<event>
+
+The type of event, which is one of:
+
+=over
+
+=item C<deploy>
+
+=item C<revert>
+
+=item C<fail>
+
+=back
+
+=item C<project>
+
+The name of the project with which the change is associated.
+
+=item C<change_id>
+
+The change ID.
+
+=item C<change>
+
+The name of the change.
+
+=item C<note>
+
+A brief description of the change.
+
+=item C<tags>
+
+An array reference of the names of associated tags.
+
+=item C<requires>
+
+An array reference of the names of any changes required by the change.
+
+=item C<conflicts>
+
+An array reference of the names of any changes that conflict with the change.
+
+=item C<committed_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+event was logged.
+
+=item C<committer_name>
+
+Name of the user who deployed the change.
+
+=item C<committer_email>
+
+Email address of the user who deployed the change.
+
+=item C<planned_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+change was added to the plan.
+
+=item C<planner_name>
+
+Name of the user who added the change to the plan.
+
+=item C<planner_email>
+
+Email address of the user who added the change to the plan.
+
+=back
+
+=head3 C<run_file>
+
+ $engine->run_file($file);
+
+Should execute the commands in the specified file. This will generally be an
+SQL file to run through the engine's native client.
+
+=head3 C<run_handle>
+
+ $engine->run_handle($file_handle);
+
+Should execute the commands in the specified file handle. The file handle's
+contents should be piped to the engine's native client.
+
+=head3 C<load_change>
+
+ my $change = $engine->load_change($change_id);
+
+Given a deployed change ID, loads an returns a hash reference representing the
+change in the database. The keys should be the same as those in the hash
+references returned by C<deployed_changes()>. Returns C<undef> if the change
+has not been deployed.
+
+=head3 C<change_offset_from_id>
+
+ my $change = $engine->change_offset_from_id( $change_id, $offset );
+
+Given a change ID and an offset, returns a hash reference of the data for a
+deployed change (with the same keys as defined for C<deployed_changes()>) in
+the current project that was deployed C<$offset> steps before the change
+identified by C<$change_id>. If C<$offset> is C<0> or C<undef>, the change
+represented by C<$change_id> should be returned (just like C<load_change()>).
+Otherwise, the change returned should be C<$offset> steps from that change ID,
+where C<$offset> may be positive (later step) or negative (earlier step).
+Returns C<undef> if the change was not found or if the offset is more than the
+number of changes before or after the change, as appropriate.
+
+=head3 C<change_id_offset_from_id>
+
+ my $id = $engine->change_id_offset_from_id( $change_id, $offset );
+
+Like C<change_offset_from_id()> but returns the change ID rather than the
+change object.
+
+=head3 C<registry_version>
+
+Should return the current version of the target's registry.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql
new file mode 100644
index 00000000..f03a0f0f
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql
@@ -0,0 +1,21 @@
+CREATE TABLE &registry..releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ installer_name VARCHAR2(512) NOT NULL,
+ installer_email VARCHAR2(512) NOT NULL
+);
+
+COMMENT ON TABLE &registry..releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry..releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry..releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry..releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry..releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+-- Add the script_hash column to the changes table. Copy change_id for now.
+ALTER TABLE &registry..changes ADD script_hash CHAR(40) NULL;
+UPDATE &registry..changes SET script_hash = change_id;
+COMMENT ON COLUMN &registry..changes.script_hash IS 'Deploy script SHA-1 hash.';
+
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.0.';
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql
new file mode 100644
index 00000000..5a0bba0a
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql
@@ -0,0 +1,3 @@
+-- Nothing to do here.. Exasol doesn't support UNIQUE constraints, except in
+-- the form of PRIMARY KEY.
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.1.';
diff --git a/lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql
new file mode 100644
index 00000000..d666c416
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql
@@ -0,0 +1,45 @@
+SET AUTOddl OFF;
+
+CREATE TABLE releases (
+ version FLOAT NOT NULL PRIMARY KEY,
+ installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ installer_name VARCHAR(255) NOT NULL,
+ installer_email VARCHAR(255) NOT NULL
+);
+
+COMMENT ON TABLE releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+-- Add the script_hash column to the changes table.
+ALTER TABLE changes ADD script_hash VARCHAR(40) UNIQUE;
+COMMIT;
+UPDATE changes SET script_hash = change_id;
+COMMENT ON COLUMN changes.script_hash IS 'Deploy script SHA-1 hash.';
+
+-- Allow "merge" events.
+SET TERM ^;
+EXECUTE BLOCK AS
+ DECLARE trig VARCHAR(64);
+BEGIN
+ SELECT TRIM(cc.rdb$constraint_name)
+ FROM rdb$relation_constraints rc
+ JOIN rdb$check_constraints cc ON rc.rdb$constraint_name = cc.rdb$constraint_name
+ JOIN rdb$triggers trg ON cc.rdb$trigger_name = trg.rdb$trigger_name
+ WHERE rc.rdb$relation_name = 'EVENTS'
+ AND rc.rdb$constraint_type = 'CHECK'
+ AND trg.rdb$trigger_type = 1
+ INTO trig;
+ EXECUTE STATEMENT 'ALTER TABLE EVENTS DROP CONSTRAINT ' || trig;
+END^
+
+SET TERM ;^
+COMMIT;
+
+ALTER TABLE events ADD CONSTRAINT check_event_type CHECK (
+ event IN ('deploy', 'revert', 'fail', 'merge')
+);
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql
new file mode 100644
index 00000000..246a03f8
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql
@@ -0,0 +1,49 @@
+SET AUTOddl OFF;
+
+SET TERM ^;
+EXECUTE BLOCK AS
+ DECLARE uniq VARCHAR(64);
+BEGIN
+ SELECT TRIM(rdb$constraint_name)
+ FROM rdb$relation_constraints
+ WHERE rdb$relation_name = 'CHANGES'
+ AND rdb$constraint_type = 'UNIQUE'
+ INTO uniq;
+ EXECUTE STATEMENT 'ALTER TABLE CHANGES DROP CONSTRAINT ' || uniq;
+END^
+
+EXECUTE BLOCK AS
+ DECLARE trig VARCHAR(64);
+BEGIN
+ SELECT TRIM(cc.rdb$constraint_name)
+ FROM rdb$relation_constraints rc
+ JOIN rdb$check_constraints cc ON rc.rdb$constraint_name = cc.rdb$constraint_name
+ JOIN rdb$triggers trg ON cc.rdb$trigger_name = trg.rdb$trigger_name
+ WHERE rc.rdb$relation_name = 'DEPENDENCIES'
+ AND rc.rdb$constraint_type = 'CHECK'
+ AND trg.rdb$trigger_type = 1
+ INTO trig;
+ EXECUTE STATEMENT 'ALTER TABLE DEPENDENCIES DROP CONSTRAINT ' || trig;
+END^
+
+SET TERM ;^
+COMMIT;
+
+-- Drop check_event_type; we give it a new name below.
+ALTER TABLE events DROP CONSTRAINT check_event_type;
+COMMIT;
+
+-- Create the new unique constraint.
+ALTER TABLE changes ADD UNIQUE (project, script_hash);
+
+-- Give the check constraints name consistent with other engines.
+ALTER TABLE dependencies ADD CONSTRAINT dependencies_check CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+);
+
+ALTER TABLE events ADD CONSTRAINT events_event_check CHECK (
+ event IN ('deploy', 'revert', 'fail', 'merge')
+);
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql
new file mode 100644
index 00000000..ac88c549
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql
@@ -0,0 +1,20 @@
+CREATE TABLE releases (
+ version FLOAT PRIMARY KEY
+ COMMENT 'Version of the Sqitch registry.',
+ installed_at TIMESTAMP NOT NULL
+ COMMENT 'Date the registry release was installed.',
+ installer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who installed the registry release.',
+ installer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who installed the registry release.'
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Sqitch registry releases.'
+;
+
+-- Add the script_hash column to the changes table. Copy change_id for now.
+ALTER TABLE changes ADD COLUMN script_hash VARCHAR(40) NULL UNIQUE AFTER change_id;
+UPDATE changes SET script_hash = change_id;
+
+-- Allow "merge" events.
+ALTER TABLE events CHANGE event event ENUM ('deploy', 'fail', 'merge', 'revert') NOT NULL;
diff --git a/lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql
new file mode 100644
index 00000000..6b3db35f
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql
@@ -0,0 +1,2 @@
+DROP INDEX script_hash ON changes;
+ALTER TABLE changes ADD UNIQUE(project, script_hash);
diff --git a/lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql
new file mode 100644
index 00000000..d019d904
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql
@@ -0,0 +1,44 @@
+CREATE TABLE &registry..releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ installer_name VARCHAR2(512 CHAR) NOT NULL,
+ installer_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry..releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry..releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry..releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry..releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+-- Add the script_hash column to the changes table. Copy change_id for now.
+ALTER TABLE &registry..changes ADD script_hash CHAR(40) NULL UNIQUE;
+UPDATE &registry..changes SET script_hash = change_id;
+COMMENT ON COLUMN &registry..changes.script_hash IS 'Deploy script SHA-1 hash.';
+
+DECLARE
+ CURSOR c_event_constraints IS
+ SELECT constraint_name
+ FROM user_cons_columns
+ WHERE table_name = 'EVENTS' AND column_name = 'EVENT';
+ rec_consname c_event_constraints%ROWTYPE;
+BEGIN
+ OPEN c_event_constraints;
+ LOOP
+ FETCH c_event_constraints INTO rec_consname;
+ IF c_event_constraints%NOTFOUND THEN EXIT; END IF;
+
+ -- Drop the constraint.
+ EXECUTE IMMEDIATE 'ALTER TABLE &registry..events DROP CONSTRAINT '
+ || rec_consname.constraint_name;
+ END LOOP;
+ CLOSE c_event_constraints;
+
+ -- Use EXECUTE IMMEDIATE because ALTER isn't allowed in PL/SQL.
+ EXECUTE IMMEDIATE 'ALTER TABLE &registry..events MODIFY event NOT NULL';
+END;
+/
+
+ALTER TABLE &registry..events ADD CONSTRAINT check_event_type CHECK (
+ event IN ('deploy', 'revert', 'fail', 'merge')
+);
diff --git a/lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql
new file mode 100644
index 00000000..70d84f0b
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql
@@ -0,0 +1,31 @@
+COLUMN cname for a30 new_value check_name;
+
+SELECT u.constraint_name AS cname
+ FROM user_constraints u
+ JOIN user_cons_columns c ON u.constraint_name = c.constraint_name
+ WHERE u.table_name = 'CHANGES'
+ AND u.constraint_type = 'U'
+ AND c.column_name = 'SCRIPT_HASH';
+
+ALTER TABLE &registry..changes DROP CONSTRAINT &check_name;
+ALTER TABLE &registry..changes ADD CONSTRAINT &check_name UNIQUE (project, script_hash);
+
+-- Rename the changes check constraint.
+ALTER TABLE &registry..events RENAME CONSTRAINT check_event_type TO events_event_check;
+
+-- Rename the dependencies check constraint.
+SELECT constraint_name AS cname
+ FROM user_cons_columns
+ WHERE table_name = 'DEPENDENCIES'
+ AND column_name = 'DEPENDENCY_ID'
+ AND constraint_name IN (
+ SELECT constraint_name
+ FROM user_cons_columns
+ WHERE table_name = 'DEPENDENCIES'
+ AND column_name = 'TYPE'
+ );
+
+ALTER TABLE &registry..dependencies
+ RENAME CONSTRAINT &check_name TO dependencies_check;
+
+
diff --git a/lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql
new file mode 100644
index 00000000..fda82216
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql
@@ -0,0 +1,30 @@
+BEGIN;
+
+SET client_min_messages = warning;
+
+CREATE TABLE :"registry".releases (
+ version REAL PRIMARY KEY,
+ installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN :"registry".releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN :"registry".releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN :"registry".releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN :"registry".releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+-- Add the script_hash column to the changes table. Copy change_id for now.
+ALTER TABLE :"registry".changes ADD COLUMN script_hash TEXT NULL UNIQUE;
+UPDATE :"registry".changes SET script_hash = change_id;
+COMMENT ON COLUMN :"registry".changes.script_hash IS 'Deploy script SHA-1 hash.';
+
+-- Allow "merge" events.
+ALTER TABLE :"registry".events DROP CONSTRAINT events_event_check;
+ALTER TABLE :"registry".events ADD CONSTRAINT events_event_check
+ CHECK (event IN ('deploy', 'revert', 'fail', 'merge'));
+
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.';
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql
new file mode 100644
index 00000000..45422c08
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql
@@ -0,0 +1,7 @@
+BEGIN;
+
+SET client_min_messages = warning;
+ALTER TABLE :"registry".changes DROP CONSTRAINT changes_script_hash_key;
+ALTER TABLE :"registry".changes ADD UNIQUE (project, script_hash);
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.';
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql
new file mode 100644
index 00000000..24d1338e
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql
@@ -0,0 +1,22 @@
+CREATE TABLE &registry.releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp,
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE &registry.releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry.releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry.releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry.releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry.releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+-- Add the script_hash column to the changes table. Copy change_id for now.
+ALTER TABLE &registry.changes ADD script_hash TEXT NULL;
+ALTER WAREHOUSE &warehouse RESUME IF SUSPENDED;
+USE WAREHOUSE &warehouse;
+UPDATE &registry.changes SET script_hash = change_id;
+ALTER TABLE &registry.changes ADD UNIQUE(script_hash);
+COMMENT ON COLUMN &registry.changes.script_hash IS 'Deploy script SHA-1 hash.';
+
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.0.';
diff --git a/lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql
new file mode 100644
index 00000000..445b23f6
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql
@@ -0,0 +1,3 @@
+ALTER TABLE &registry.changes DROP UNIQUE(script_hash);
+ALTER TABLE &registry.changes ADD UNIQUE(project, script_hash);
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.0.';
diff --git a/lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql
new file mode 100644
index 00000000..291a676c
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql
@@ -0,0 +1,62 @@
+BEGIN;
+
+CREATE TABLE releases (
+ version FLOAT PRIMARY KEY,
+ installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+-- Create a new changes table with script_hash.
+CREATE TABLE new_changes (
+ change_id TEXT PRIMARY KEY,
+ script_hash TEXT NULL UNIQUE,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL
+);
+
+-- Copy all the data to the new table and move it into place.
+INSERT INTO new_changes
+SELECT change_id, change_id, change, project, note,
+ committed_at, committer_name, committer_email,
+ planned_at, planner_name, planner_email
+ FROM changes;
+PRAGMA foreign_keys = OFF;
+DROP TABLE changes;
+ALTER TABLE new_changes RENAME TO changes;
+PRAGMA foreign_keys = ON;
+
+-- Create a new events table with support for "merge" events.
+CREATE TABLE new_events (
+ event TEXT NOT NULL CHECK (event IN ('deploy', 'revert', 'fail', 'merge')),
+ change_id TEXT NOT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ requires TEXT NOT NULL DEFAULT '',
+ conflicts TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+INSERT INTO new_events
+SELECT * FROM events;
+PRAGMA foreign_keys = OFF;
+DROP TABLE events;
+ALTER TABLE new_events RENAME TO events;
+PRAGMA foreign_keys = ON;
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql
new file mode 100644
index 00000000..8ff0beda
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql
@@ -0,0 +1,27 @@
+BEGIN;
+
+-- Create a new changes table with updated unique constraint.
+CREATE TABLE new_changes (
+ change_id TEXT PRIMARY KEY,
+ script_hash TEXT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, script_hash)
+);
+
+-- Copy all the data to the new table and move it into place.
+INSERT INTO new_changes
+SELECT * FROM changes;
+PRAGMA foreign_keys = OFF;
+DROP TABLE changes;
+ALTER TABLE new_changes RENAME TO changes;
+PRAGMA foreign_keys = ON;
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql
new file mode 100644
index 00000000..e636bc20
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql
@@ -0,0 +1,15 @@
+CREATE TABLE :"registry".releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ installer_name VARCHAR(1024) NOT NULL,
+ installer_email VARCHAR(1024) NOT NULL
+);
+
+COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.';
+
+-- Add the script_hash column to the changes table. Copy change_id for now.
+ALTER TABLE :"registry".changes ADD COLUMN script_hash CHAR(40);
+UPDATE :"registry".changes SET script_hash = change_id;
+ALTER TABLE :"registry".changes ADD UNIQUE(script_hash);
+
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.';
diff --git a/lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql
new file mode 100644
index 00000000..4f05271d
--- /dev/null
+++ b/lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql
@@ -0,0 +1,3 @@
+ALTER TABLE :"registry".changes DROP CONSTRAINT c_unique;
+ALTER TABLE :"registry".changes ADD UNIQUE(project, script_hash);
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.';
diff --git a/lib/App/Sqitch/Engine/exasol.pm b/lib/App/Sqitch/Engine/exasol.pm
new file mode 100644
index 00000000..99518a17
--- /dev/null
+++ b/lib/App/Sqitch/Engine/exasol.pm
@@ -0,0 +1,587 @@
+package App::Sqitch::Engine::exasol;
+
+use 5.010;
+use Moo;
+use utf8;
+use Path::Class;
+use DBI;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Types qw(DBH Dir ArrayRef);
+use App::Sqitch::Plan::Change;
+use List::Util qw(first);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub _dt($) {
+ require App::Sqitch::DateTime;
+ return App::Sqitch::DateTime->new(split /:/ => shift);
+}
+
+sub key { 'exasol' }
+sub name { 'Exasol' }
+sub driver { 'DBD::ODBC 1.59' }
+sub default_client { 'exaplus' }
+
+BEGIN {
+ # Disable SQLPATH so that we don't read scripts from unexpected places.
+ $ENV{SQLPATH} = '';
+}
+
+sub destination {
+ my $self = shift;
+
+ # Just use the target name if it doesn't look like a URI or if the URI
+ # includes the database name.
+ return $self->target->name if $self->target->name !~ /:/
+ || $self->target->uri->dbname;
+
+ # Use the URI sans password.
+ my $uri = $self->target->uri->clone;
+ $uri->password(undef) if $uri->password;
+ return $uri->as_string;
+}
+
+# No username or password defaults.
+sub _def_user { }
+sub _def_pass { }
+
+has _exaplus => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+ my @ret = ( $self->client );
+
+ for my $spec (
+ [ u => $self->username ],
+ [ p => $self->password ],
+ [ c => $uri->host && $uri->_port ? $uri->host . ':' . $uri->_port : undef ],
+ [ profile => $uri->host ? undef : $uri->dbname ]
+ ) {
+ push @ret, "-$spec->[0]" => $spec->[1] if $spec->[1];
+ }
+
+ push @ret => (
+ '-q', # Quiet mode
+ '-L', # Don't prompt if login fails, just exit
+ '-pipe', # Enable piping of scripts to 'exaplus'
+ '-x', # Stop in case of errors
+ '-autoCompletion' => 'OFF',
+ '-encoding' => 'UTF8',
+ '-autocommit' => 'OFF',
+ );
+ return \@ret;
+ },
+);
+
+sub exaplus { @{ shift->_exaplus } }
+
+has tmpdir => (
+ is => 'ro',
+ isa => Dir,
+ lazy => 1,
+ default => sub {
+ require File::Temp;
+ dir File::Temp::tempdir( CLEANUP => 1 );
+ },
+);
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->use_driver;
+
+ my $uri = $self->uri;
+ DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ odbc_utf8_on => 1,
+ FetchHashKeyName => 'NAME_lc',
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ Callbacks => {
+ connected => sub {
+ my $dbh = shift;
+ $dbh->do("ALTER SESSION SET $_='YYYY-MM-DD HH24:MI:SS'") for qw(
+ nls_date_format
+ nls_timestamp_format
+ );
+ $dbh->do("ALTER SESSION SET TIME_ZONE='UTC'");
+ if (my $schema = $self->registry) {
+ try {
+ $dbh->do("OPEN SCHEMA $schema");
+ # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html
+ $dbh->set_err(undef, undef) if $dbh->err;
+ };
+ }
+ return;
+ },
+ },
+ });
+ }
+);
+
+# Need to wait until dbh is defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+# Timestamp formats
+
+sub _char2ts {
+ my $dt = $_[1];
+ $dt->set_time_zone('UTC');
+ $dt->ymd('-') . ' ' . $dt->hms(':');
+}
+
+sub _ts2char_format {
+ return qq{'year:' || CAST(EXTRACT(YEAR FROM %s) AS SMALLINT)
+ || ':month:' || CAST(EXTRACT(MONTH FROM %1\$s) AS SMALLINT)
+ || ':day:' || CAST(EXTRACT(DAY FROM %1\$s) AS SMALLINT)
+ || ':hour:' || CAST(EXTRACT(HOUR FROM %1\$s) AS SMALLINT)
+ || ':minute:' || CAST(EXTRACT(MINUTE FROM %1\$s) AS SMALLINT)
+ || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM %1\$s) AS NUMERIC(9,4)))
+ || ':time_zone:UTC'};
+}
+
+sub _ts_default { 'current_timestamp' }
+
+sub _listagg_format {
+ return q{GROUP_CONCAT(%s SEPARATOR ' ')};
+}
+
+sub _regex_op { 'REGEXP_LIKE' }
+
+# LIMIT in Exasol doesn't behave properly with values > 18446744073709551611
+sub _limit_default { '18446744073709551611' }
+
+sub _simple_from { ' FROM dual' }
+
+sub _multi_values {
+ my ($self, $count, $expr) = @_;
+ return join "\nUNION ALL ", ("SELECT $expr FROM dual") x $count;
+}
+
+sub initialized {
+ my $self = shift;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT EXISTS(
+ SELECT TRUE FROM exa_all_tables
+ WHERE table_schema = ? AND table_name = ?
+ )
+ }, undef, uc $self->registry, 'CHANGES')->[0];
+}
+
+# LIMIT / OFFSET in Exasol doesn't seem to play nice in the original query with
+# JOIN and GROUP BY; wrap it in a subquery instead..
+sub change_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the object if there is no offset.
+ return $self->load_change($change_id) unless $offset;
+
+ # Are we offset forwards or backwards?
+ my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+
+ my $change = $self->dbh->selectrow_hashref(qq{
+ SELECT id, name, project, note, "timestamp", planner_name, planner_email,
+ tags
+ FROM (
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ $tagcol AS tags, c.committed_at
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ AND c.committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
+ c.planner_name, c.planner_email, c.committed_at
+ ) changes
+ ORDER BY changes.committed_at $dir
+ LIMIT 1 $offset_expr
+ }, undef, $self->plan->project, $change_id) || return undef;
+ $change->{timestamp} = _dt $change->{timestamp};
+ unless (ref $change->{tags}) {
+ $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : [];
+ }
+ return $change;
+}
+
+sub changes_requiring_change {
+ my ( $self, $change ) = @_;
+ # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
+ # NOTE: Query from DBIEngine doesn't work in Exasol:
+ # Error: [00444] more than one column in select list of correlated subselect
+ # The CTE-based query below seems to be fine, however.
+ return @{ $self->dbh->selectall_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT c.change_id, c.project, c.change, t.tag AS asof_tag
+ FROM dependencies d
+ JOIN changes c ON c.change_id = d.change_id
+ LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
+ WHERE d.dependency_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, { Slice => {} }, $change->id) };
+}
+
+sub name_for_change_id {
+ my ( $self, $change_id ) = @_;
+ # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
+ # NOTE: Query from DBIEngine doesn't work in Exasol:
+ # Error: [0A000] Feature not supported: non-equality correlations in correlated subselect
+ # The CTE-based query below seems to be fine, however.
+ return $self->dbh->selectcol_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT change || COALESCE(t.tag, '@HEAD')
+ FROM changes c
+ LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
+ WHERE change_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, undef, $change_id)->[0];
+}
+
+sub _cid {
+ my ( $self, $ord, $offset, $project ) = @_;
+
+ my $offset_expr = $offset ? " OFFSET $offset" : '';
+ return try {
+ $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ ORDER BY committed_at $ord
+ LIMIT 1$offset_expr
+ }, undef, $project || $self->plan->project)->[0];
+ } catch {
+ return if $self->_no_table_error && !$self->initialized;
+ die $_;
+ };
+}
+
+sub is_deployed_tag {
+ my ( $self, $tag ) = @_;
+ return $self->dbh->selectcol_arrayref(
+ 'SELECT 1 FROM tags WHERE tag_id = ?',
+ undef, $tag->id
+ )->[0];
+}
+
+sub are_deployed_changes {
+ my $self = shift;
+ my @qs;
+ my $i = @_;
+ while ($i > 250) {
+ push @qs => 'change_id IN (' . join(', ' => ('?') x 250) . ')';
+ $i -= 250;
+ }
+ push @qs => 'change_id IN (' . join(', ' => ('?') x @_) . ')';
+ my $expr = join ' OR ', @qs;
+ @{ $self->dbh->selectcol_arrayref(
+ "SELECT change_id FROM changes WHERE $expr",
+ undef,
+ map { $_->id } @_,
+ ) };
+}
+
+sub _registry_variable {
+ my $self = shift;
+ my $schema = $self->registry;
+ return "DEFINE registry=$schema;";
+}
+
+sub initialize {
+ my $self = shift;
+ my $schema = $self->registry;
+ hurl engine => __ 'Sqitch already initialized' if $self->initialized;
+
+ # Load up our database.
+ (my $file = file(__FILE__)->dir->file('exasol.sql')) =~ s/"/""/g;
+ $self->_run_with_verbosity($file);
+ $self->dbh->do("OPEN SCHEMA $schema") if $schema;
+ $self->_register_release;
+}
+
+sub _limit_offset {
+ # LIMIT/OFFSET don't support parameters, alas. So just put them in the query.
+ my ($self, $lim, $off) = @_;
+ # OFFSET cannot be used without LIMIT, sadly.
+ return ['LIMIT ' . ($lim || $self->_limit_default), "OFFSET $off"], [] if $off;
+ return ["LIMIT $lim"], [] if $lim;
+ return [], [];
+}
+
+sub _regex_expr {
+ my ( $self, $col, $regex ) = @_;
+ $regex = '.*' . $regex if $regex !~ m{^\^};
+ $regex .= '.*' if $regex !~ m{\$$};
+ my $op = $self->_regex_op;
+ return "$col $op ?", $regex;
+}
+
+# Override to lock the changes table. This ensures that only one instance of
+# Sqitch runs at one time.
+sub begin_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+
+ # Start transaction and lock changes to allow only one change at a time.
+ # https://www.exasol.com/portal/pages/viewpage.action?pageId=22518143
+ $dbh->begin_work;
+ $dbh->do('DELETE FROM changes WHERE FALSE');
+ return $self;
+}
+
+# Release lock by comitting or rolling back.
+sub finish_work {
+ my $self = shift;
+ $self->dbh->commit;
+ return $self;
+}
+
+sub rollback_work {
+ my $self = shift;
+ $self->dbh->rollback;
+ return $self;
+}
+
+sub _file_for_script {
+ my ($self, $file) = @_;
+
+ # Just use the file if no special character.
+ if ($file !~ /[@?%\$]/) {
+ $file =~ s/"/""/g;
+ return $file;
+ }
+
+ # Alias or copy the file to a temporary directory that's removed on exit.
+ (my $alias = $file->basename) =~ s/[@?%\$]/_/g;
+ $alias = $self->tmpdir->file($alias);
+
+ # Remove existing file.
+ if (-e $alias) {
+ $alias->remove or hurl exasol => __x(
+ 'Cannot remove {file}: {error}',
+ file => $alias,
+ error => $!
+ );
+ }
+
+ if (App::Sqitch::ISWIN) {
+ # Copy it.
+ $file->copy_to($alias) or hurl exasol => __x(
+ 'Cannot copy {file} to {alias}: {error}',
+ file => $file,
+ alias => $alias,
+ error => $!
+ );
+ } else {
+ # Symlink it.
+ $alias->remove;
+ symlink $file->absolute, $alias or hurl exasol => __x(
+ 'Cannot symlink {file} to {alias}: {error}',
+ file => $file,
+ alias => $alias,
+ error => $!
+ );
+ }
+
+ # Return the alias.
+ $alias =~ s/"/""/g;
+ return $alias;
+}
+
+sub run_file {
+ my $self = shift;
+ my $file = $self->_file_for_script(shift);
+ $self->_capture(qq{\@"$file"});
+}
+
+sub _run_with_verbosity {
+ my $self = shift;
+ my $file = $self->_file_for_script(shift);
+ # Suppress STDOUT unless we want extra verbosity.
+ #my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
+ my $meth = '_capture';
+ $self->$meth(qq{\@"$file"});
+}
+
+sub run_upgrade { shift->_run_with_verbosity(@_) }
+sub run_verify { shift->_run_with_verbosity(@_) }
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ my $conn = $self->_script;
+ open my $tfh, '<:utf8_strict', \$conn;
+ $self->sqitch->spool( [$tfh, $fh], $self->exaplus );
+}
+
+# Exasol treats empty string as NULL; adjust accordingly..
+
+sub _log_tags_param {
+ my $res = join ' ' => map { $_->format_name } $_[1]->tags;
+ return $res || ' ';
+}
+
+sub _log_requires_param {
+ my $res = join ',' => map { $_->as_string } $_[1]->requires;
+ return $res || ' ';
+}
+
+sub _log_conflicts_param {
+ my $res = join ',' => map { $_->as_string } $_[1]->conflicts;
+ return $res || ' ';
+}
+
+sub _no_table_error {
+ return $DBI::errstr && $DBI::errstr =~ /object \w+ not found/m;
+}
+
+sub _no_column_error {
+ return $DBI::errstr && $DBI::errstr =~ /object \w+ not found/m;
+}
+
+sub _script {
+ my $self = shift;
+ my $uri = $self->uri;
+ my %vars = $self->variables;
+
+ return join "\n" => (
+ 'SET FEEDBACK OFF;',
+ 'SET HEADING OFF;',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT 4;',
+ (map {; (my $v = $vars{$_}) =~ s/'/''/g; qq{DEFINE $_='$v';} } sort keys %vars),
+ $self->_registry_variable,
+ # Just 'map { s/;?$/;/r } ...' doesn't work in earlier Perl versions;
+ # see: https://www.perlmonks.org/index.pl?node_id=1048579
+ map { (my $foo=$_) =~ s/;?$/;/; $foo } @_
+ );
+}
+
+sub _run {
+ my $self = shift;
+ my $script = $self->_script(@_);
+ open my $fh, '<:utf8_strict', \$script;
+ return $self->sqitch->spool( $fh, $self->exaplus );
+}
+
+sub _capture {
+ my $self = shift;
+ my $conn = $self->_script(@_);
+ my @out;
+ my @errout;
+
+ $self->sqitch->debug('CMD: ' . join(' ', $self->exaplus));
+ $self->sqitch->debug("SQL:\n---\n", $conn, "\n---");
+
+ require IPC::Run3;
+ IPC::Run3::run3(
+ [$self->exaplus], \$conn, \@out, \@out,
+ { return_if_system_error => 1 },
+ );
+
+ # EXAplus doesn't always seem to give a useful exit value; we need to match
+ # on output as well..
+ if (my $err = $? || grep { /^Error:/m } @out) {
+ # Ugh, send everything to STDERR.
+ $self->sqitch->vent(@out);
+ hurl io => __x(
+ '{command} unexpectedly failed; exit value = {exitval}',
+ command => $self->client,
+ exitval => ($err >> 8),
+ );
+ }
+ return wantarray ? @out : \@out;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine::exasol - Sqitch Exasol Engine
+
+=head1 Synopsis
+
+ my $exasol = App::Sqitch::Engine->load( engine => 'exasol' );
+
+=head1 Description
+
+App::Sqitch::Engine::exasol provides the Exasol storage engine for Sqitch. It
+is tested with Exasol 6.0 and higher.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<initialized>
+
+ $exasol->initialize unless $exasol->initialized;
+
+Returns true if the database has been initialized for Sqitch, and false if it
+has not.
+
+=head3 C<initialize>
+
+ $exasol->initialize;
+
+Initializes a database for Sqitch by installing the Sqitch registry schema.
+
+=head3 C<exaplus>
+
+Returns a list containing the C<exaplus> client and options to be passed to it.
+Used internally when executing scripts.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/exasol.sql b/lib/App/Sqitch/Engine/exasol.sql
new file mode 100644
index 00000000..3f16005e
--- /dev/null
+++ b/lib/App/Sqitch/Engine/exasol.sql
@@ -0,0 +1,142 @@
+CREATE SCHEMA IF NOT EXISTS &registry;
+
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.1.';
+
+CREATE TABLE &registry..releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ installer_name VARCHAR2(512 CHAR) NOT NULL,
+ installer_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry..releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry..releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry..releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry..releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE &registry..projects (
+ project VARCHAR2(512 CHAR) PRIMARY KEY,
+ uri VARCHAR2(512 CHAR) NULL, -- UNIQUE should also be used here, but not supported in EXASOL
+ created_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ creator_name VARCHAR2(512 CHAR) NOT NULL,
+ creator_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN &registry..projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN &registry..projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN &registry..projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN &registry..projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN &registry..projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE &registry..changes (
+ change_id CHAR(40) PRIMARY KEY,
+ script_hash CHAR(40) NULL,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+ -- UNIQUE(project, script_hash)
+);
+
+COMMENT ON TABLE &registry..changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN &registry..changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN &registry..changes.script_hash IS 'Deploy script SHA-1 hash.';
+COMMENT ON COLUMN &registry..changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN &registry..changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..changes.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN &registry..changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN &registry..changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE &registry..tags (
+ tag_id CHAR(40) PRIMARY KEY,
+ tag VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+ -- UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE &registry..tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN &registry..tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN &registry..tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN &registry..tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN &registry..tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN &registry..tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN &registry..tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN &registry..tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN &registry..tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN &registry..tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE &registry..dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id), -- ON DELETE CASCADE,
+ type VARCHAR2(8) NOT NULL,
+ dependency VARCHAR2(1024 CHAR) NOT NULL,
+ dependency_id CHAR(40) NULL REFERENCES &registry..changes(change_id),
+ -- CONSTRAINT dependencies_check CHECK (
+ -- (type = 'require' AND dependency_id IS NOT NULL)
+ -- OR (type = 'conflict' AND dependency_id IS NULL)
+ -- ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE &registry..dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN &registry..dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN &registry..dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN &registry..dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN &registry..dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TABLE &registry..events (
+ event VARCHAR2(6) NOT NULL,
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ requires VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL,
+ conflicts VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL,
+ tags VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+-- CREATE INDEX &registry..events_pkey ON &registry..events(change_id, committed_at);
+
+COMMENT ON TABLE &registry..events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN &registry..events.event IS 'Type of event.';
+COMMENT ON COLUMN &registry..events.change_id IS 'Change ID.';
+COMMENT ON COLUMN &registry..events.change IS 'Change name.';
+COMMENT ON COLUMN &registry..events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..events.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..events.requires IS 'List of the names of required changes.';
+COMMENT ON COLUMN &registry..events.conflicts IS 'List of the names of conflicting changes.';
+COMMENT ON COLUMN &registry..events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN &registry..events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN &registry..events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN &registry..events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..events.planner_email IS 'Email address of the user who plan planned the change.';
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/firebird.pm b/lib/App/Sqitch/Engine/firebird.pm
new file mode 100644
index 00000000..79afabdb
--- /dev/null
+++ b/lib/App/Sqitch/Engine/firebird.pm
@@ -0,0 +1,998 @@
+package App::Sqitch::Engine::firebird;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Plan::Change;
+use Path::Class;
+use File::Basename;
+use Time::Local;
+use Time::HiRes qw(sleep);
+use Moo;
+use App::Sqitch::Types qw(DBH URIDB ArrayRef Maybe Int);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has registry_uri => (
+ is => 'ro',
+ isa => URIDB,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri->clone;
+ my $reg = $self->registry;
+
+ if ( file($reg)->is_absolute ) {
+ # Just use an absolute path.
+ $uri->dbname($reg);
+ } elsif (my @segs = $uri->path_segments) {
+ # Use the same name, but replace $name.$ext with $reg.$ext.
+ my $reg = $self->registry;
+ if ($reg =~ /[.]/) {
+ $segs[-1] =~ s/^[^.]+(?:[.].+)?$/$reg/;
+ } else {
+ $segs[-1] =~ s{^[^.]+([.].+)?$}{$reg . ($1 // '')}e;
+ }
+ $uri->path_segments(@segs);
+ } else {
+ # No known path, so no name.
+ $uri->dbname(undef);
+ }
+
+ return $uri;
+ },
+);
+
+sub registry_destination {
+ my $uri = shift->registry_uri;
+ if ($uri->password) {
+ $uri = $uri->clone;
+ $uri->password(undef);
+ }
+ return $uri->as_string;
+}
+
+sub _def_user { $ENV{ISC_USER} }
+sub _def_pass { $ENV{ISC_PASSWORD} }
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->registry_uri;
+ $self->use_driver;
+
+ my $dsn = $uri->dbi_dsn . ';ib_dialect=3;ib_charset=UTF8';
+ return DBI->connect($dsn, scalar $self->username, scalar $self->password, {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ ib_enable_utf8 => 1,
+ FetchHashKeyName => 'NAME_lc',
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ });
+ }
+);
+
+# Need to wait until dbh is defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+has _isql => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+ my @ret = ( $self->client );
+ for my $spec (
+ [ user => $self->username ],
+ [ password => $self->password ],
+ ) {
+ push @ret, "-$spec->[0]" => $spec->[1] if $spec->[1];
+ }
+
+ push @ret => (
+ '-quiet',
+ '-bail',
+ '-sqldialect' => '3',
+ '-pagelength' => '16384',
+ '-charset' => 'UTF8',
+ $self->connection_string($uri),
+ );
+
+ return \@ret;
+ },
+);
+
+sub isql { @{ shift->_isql } }
+
+has tz_offset => (
+ is => 'ro',
+ isa => Maybe[Int],
+ lazy => 1,
+ default => sub {
+ # From: https://stackoverflow.com/questions/2143528/whats-the-best-way-to-get-the-utc-offset-in-perl
+ my @t = localtime(time);
+ my $gmt_offset_in_seconds = timegm(@t) - timelocal(@t);
+ my $offset = -($gmt_offset_in_seconds / 3600);
+ return $offset;
+ },
+);
+
+sub key { 'firebird' }
+sub name { 'Firebird' }
+sub driver { 'DBD::Firebird 1.11' }
+
+sub _char2ts {
+ my $dt = $_[1];
+ $dt->set_time_zone('UTC');
+ return join ' ', $dt->ymd('-'), $dt->hms(':');
+}
+
+sub _ts2char_format {
+ return qq{'year:' || CAST(EXTRACT(YEAR FROM %s) AS SMALLINT)
+ || ':month:' || CAST(EXTRACT(MONTH FROM %1\$s) AS SMALLINT)
+ || ':day:' || CAST(EXTRACT(DAY FROM %1\$s) AS SMALLINT)
+ || ':hour:' || CAST(EXTRACT(HOUR FROM %1\$s) AS SMALLINT)
+ || ':minute:' || CAST(EXTRACT(MINUTE FROM %1\$s) AS SMALLINT)
+ || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM %1\$s) AS NUMERIC(9,4)))
+ || ':time_zone:UTC'};
+}
+
+sub _ts_default {
+ my $offset = shift->tz_offset;
+ sleep 0.01; # give Firebird a little time to tick microseconds.
+ return qq(DATEADD($offset HOUR TO CURRENT_TIMESTAMP(3)));
+}
+
+sub _version_query {
+ # Turns out, if you cast to varchar, the trailing 0s get removed. So value
+ # 1.1, represented as 1.10000002384186, returns as preferred value 1.1.
+ 'SELECT CAST(ROUND(MAX(version), 1) AS VARCHAR(24)) AS v FROM releases',
+}
+
+sub is_deployed_change {
+ my ( $self, $change ) = @_;
+ return $self->dbh->selectcol_arrayref(
+ 'SELECT 1 FROM changes WHERE change_id = ?',
+ undef, $change->id
+ )->[0];
+}
+
+sub is_deployed_tag {
+ my ( $self, $tag ) = @_;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT 1
+ FROM tags
+ WHERE tag_id = ?
+ }, undef, $tag->id)->[0];
+}
+
+sub initialized {
+ my $self = shift;
+
+ # Try to connect.
+ my $err = 0;
+ my $dbh = try { $self->dbh } catch { $err = $DBI::err; $self->sqitch->debug($_); };
+ return 0 if $err;
+
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT COUNT(RDB\$RELATION_NAME)
+ FROM RDB\$RELATIONS
+ WHERE RDB\$SYSTEM_FLAG=0
+ AND RDB\$VIEW_BLR IS NULL
+ AND RDB\$RELATION_NAME = ?
+ }, undef, 'CHANGES')->[0];
+}
+
+sub initialize {
+ my $self = shift;
+ my $uri = $self->registry_uri;
+ hurl engine => __x(
+ 'Sqitch database {database} already initialized',
+ database => $uri->dbname,
+ ) if $self->initialized;
+
+ my $sqitch_db = $self->connection_string($uri);
+
+ # Create the registry database if it does not exist.
+ $self->use_driver;
+ try {
+ DBD::Firebird->create_database({
+ db_path => $sqitch_db,
+ user => scalar $self->username,
+ password => scalar $self->password,
+ character_set => 'UTF8',
+ page_size => 16384,
+ });
+ }
+ catch {
+ hurl firebird => __x(
+ 'Cannot create database {database}: {error}',
+ database => $sqitch_db,
+ error => $_,
+ );
+ };
+
+ # Load up our database. The database must exist!
+ $self->run_upgrade( file(__FILE__)->dir->file('firebird.sql') );
+ $self->_register_release;
+}
+
+sub connection_string {
+ my ($self, $uri) = @_;
+ my $file = $uri->dbname or hurl firebird => __x(
+ 'Database name missing in URI {uri}',
+ uri => $uri,
+ );
+ my $host = $uri->host or return $file;
+ my $port = $uri->_port or return "$host:$file";
+ return "$host/$port:$file";
+}
+
+# Override to lock the Sqitch tables. This ensures that only one instance of
+# Sqitch runs at one time.
+sub begin_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+
+ # Start transaction and lock all tables to disallow concurrent changes.
+ # This should be equivalent to 'LOCK TABLE changes' ???
+ # http://conferences.embarcadero.com/article/32280#TableReservation
+ $dbh->func(
+ -lock_resolution => 'no_wait',
+ -reserving => {
+ changes => {
+ lock => 'read',
+ access => 'protected',
+ },
+ },
+ 'ib_set_tx_param'
+ );
+ $dbh->begin_work;
+ return $self;
+}
+
+# Override to unlock the tables, otherwise future transactions on this
+# connection can fail.
+sub finish_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+ $dbh->commit;
+ $dbh->func( 'ib_set_tx_param' ); # reset parameters
+ return $self;
+}
+
+sub _dt($) {
+ require App::Sqitch::DateTime;
+ return App::Sqitch::DateTime->new(split /:/ => shift);
+}
+
+sub _no_table_error {
+ return $DBI::errstr && $DBI::errstr =~ /^-Table unknown|No such file or directory/m;
+}
+
+sub _no_column_error {
+ return $DBI::errstr && $DBI::errstr =~ /^-Column unknown|/m;
+}
+
+sub _regex_op { 'SIMILAR TO' } # NOT good match for
+ # REGEXP :(
+
+sub _limit_default { '18446744073709551615' }
+
+sub _listagg_format {
+ return q{LIST(ALL %s, ' ')}; # Firebird v2.1.4 minimum
+}
+
+sub _run {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->run( $self->isql, @_ );
+ local $ENV{ISC_PASSWORD} = $pass;
+ return $sqitch->run( $self->isql, @_ );
+}
+
+sub _capture {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->capture( $self->isql, @_ );
+ local $ENV{ISC_PASSWORD} = $pass;
+ return $sqitch->capture( $self->isql, @_ );
+}
+
+sub _spool {
+ my $self = shift;
+ my $fh = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->spool( $fh, $self->isql, @_ );
+ local $ENV{ISC_PASSWORD} = $pass;
+ return $sqitch->spool( $fh, $self->isql, @_ );
+}
+
+sub run_file {
+ my ($self, $file) = @_;
+ $self->_run( '-input' => $file );
+}
+
+sub run_verify {
+ my ($self, $file) = @_;
+ # Suppress STDOUT unless we want extra verbosity.
+ my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
+ $self->$meth( '-input' => $file );
+}
+
+sub run_upgrade {
+ my ($self, $file) = @_;
+ my $uri = $self->registry_uri;
+ my @cmd = $self->isql;
+ $cmd[-1] = $self->connection_string($uri);
+ my $sqitch = $self->sqitch;
+ $sqitch->run( @cmd, '-input' => $sqitch->quote_shell($file) );
+}
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ $self->_spool($fh);
+}
+
+sub _cid {
+ my ( $self, $ord, $offset, $project ) = @_;
+
+ my $offexpr = $offset ? " SKIP $offset" : '';
+ return try {
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT FIRST 1$offexpr change_id
+ FROM changes
+ WHERE project = ?
+ ORDER BY committed_at $ord;
+ }, undef, $project || $self->plan->project)->[0];
+ } catch {
+ # Firebird generic error code -902, one possible message:
+ # -I/O error during "open" operation for file...
+ # -Error while trying to open file
+ # -No such file or directory
+ # print "===DBI ERROR: $DBI::err\n";
+ return if $DBI::err == -902; # can't connect to database
+ die $_;
+ };
+}
+
+sub current_state {
+ my ( $self, $project ) = @_;
+ my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+ my $state = try {
+ $self->dbh->selectrow_hashref(qq{
+ SELECT FIRST 1 c.change_id
+ , c.script_hash
+ , c.change
+ , c.project
+ , c.note
+ , c.committer_name
+ , c.committer_email
+ , $cdtcol AS committed_at
+ , c.planner_name
+ , c.planner_email
+ , $pdtcol AS planned_at
+ , $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ GROUP BY c.change_id
+ , c.script_hash
+ , c.change
+ , c.project
+ , c.note
+ , c.committer_name
+ , c.committer_email
+ , c.committed_at
+ , c.planner_name
+ , c.planner_email
+ , c.planned_at
+ ORDER BY c.committed_at DESC
+ }, undef, $project // $self->plan->project );
+ } catch {
+ return if $self->_no_table_error && !$self->initialized;
+ die $_;
+ } or return undef;
+
+ unless (ref $state->{tags}) {
+ $state->{tags} = $state->{tags} ? [ split / / => $state->{tags} ] : [];
+ }
+ $state->{committed_at} = _dt $state->{committed_at};
+ $state->{planned_at} = _dt $state->{planned_at};
+ return $state;
+}
+
+sub search_events {
+ my ( $self, %p ) = @_;
+
+ # Determine order direction.
+ my $dir = 'DESC';
+ if (my $d = delete $p{direction}) {
+ $dir = $d =~ /^ASC/i ? 'ASC'
+ : $d =~ /^DESC/i ? 'DESC'
+ : hurl 'Search direction must be either "ASC" or "DESC"';
+ }
+
+ # Limit with regular expressions?
+ my (@wheres, @params);
+ my $op = $self->_regex_op;
+ for my $spec (
+ [ committer => 'e.committer_name' ],
+ [ planner => 'e.planner_name' ],
+ [ change => 'e.change' ],
+ [ project => 'e.project' ],
+ ) {
+ my $regex = delete $p{ $spec->[0] } // next;
+ # Trying to adapt REGEXP for SIMILAR TO from Firebird 2.5 :)
+ # Yes, I know is ugly...
+ # There is no support for ^ and $ as in normal REGEXP.
+ #
+ # From the docs:
+ # Description: SIMILAR TO matches a string against an SQL
+ # regular expression pattern. UNLIKE in some other languages,
+ # the pattern MUST MATCH THE ENTIRE STRING in order to succeed
+ # – matching a substring is not enough. If any operand is
+ # NULL, the result is NULL. Otherwise, the result is TRUE or
+ # FALSE.
+ #
+ # Maybe use the CONTAINING operator instead?
+ # print "===REGEX: $regex\n";
+ if ( $regex =~ m{^\^} and $regex =~ m{\$$} ) {
+ $regex =~ s{\^}{};
+ $regex =~ s{\$}{};
+ $regex = "%$regex%";
+ }
+ else {
+ if ( $regex !~ m{^\^} and $regex !~ m{\$$} ) {
+ $regex = "%$regex%";
+ }
+ }
+ if ( $regex =~ m{\$$} ) {
+ $regex =~ s{\$}{};
+ $regex = "%$regex";
+ }
+ if ( $regex =~ m{^\^} ) {
+ $regex =~ s{\^}{};
+ $regex = "$regex%";
+ }
+ # print "== SIMILAR TO: $regex\n";
+ push @wheres => "$spec->[1] $op ?";
+ push @params => "$regex";
+ }
+
+ # Match events?
+ if (my $e = delete $p{event} ) {
+ my ($in, @vals) = $self->_in_expr( $e );
+ push @wheres => "e.event $in";
+ push @params => @vals;
+ }
+
+ # Assemble the where clause.
+ my $where = @wheres
+ ? "\n WHERE " . join( "\n ", @wheres )
+ : '';
+
+ # Handle remaining parameters.
+ my $limits = '';
+ if (exists $p{limit} || exists $p{offset}) {
+ my $lim = delete $p{limit};
+ if ($lim) {
+ $limits = " FIRST ? ";
+ push @params => $lim;
+ }
+ if (my $off = delete $p{offset}) {
+ $limits .= " SKIP ? ";
+ push @params => $off;
+ }
+ }
+
+ hurl 'Invalid parameters passed to search_events(): '
+ . join ', ', sort keys %p if %p;
+
+ $self->dbh->{ib_softcommit} = 1;
+
+ # Prepare, execute, and return.
+ my $cdtcol = sprintf $self->_ts2char_format, 'e.committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'e.planned_at';
+ my $sth = $self->dbh->prepare(qq{
+ SELECT $limits e.event
+ , e.project
+ , e.change_id
+ , e.change
+ , e.note
+ , e.requires
+ , e.conflicts
+ , e.tags
+ , e.committer_name
+ , e.committer_email
+ , $cdtcol AS committed_at
+ , e.planner_name
+ , e.planner_email
+ , $pdtcol AS planned_at
+ FROM events e$where
+ ORDER BY e.committed_at $dir
+ });
+ $sth->execute(@params);
+ return sub {
+ my $row = $sth->fetchrow_hashref or return;
+ $row->{committed_at} = _dt $row->{committed_at};
+ $row->{planned_at} = _dt $row->{planned_at};
+ return $row;
+ };
+}
+
+sub changes_requiring_change {
+ my ( $self, $change ) = @_;
+ return @{ $self->dbh->selectall_arrayref(q{
+ SELECT c.change_id, c.project, c.change, (
+ SELECT FIRST 1 tag
+ FROM changes c2
+ JOIN tags ON c2.change_id = tags.change_id
+ WHERE c2.project = c.project
+ AND c2.committed_at >= c.committed_at
+ ORDER BY c2.committed_at
+ ) AS asof_tag
+ FROM dependencies d
+ JOIN changes c ON c.change_id = d.change_id
+ WHERE d.dependency_id = ?
+ }, { Slice => {} }, $change->id) };
+}
+
+sub name_for_change_id {
+ my ( $self, $change_id ) = @_;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT c.change || COALESCE((
+ SELECT FIRST 1 tag
+ FROM changes c2
+ JOIN tags ON c2.change_id = tags.change_id
+ WHERE c2.committed_at >= c.committed_at
+ AND c2.project = c.project
+ ), '@HEAD')
+ FROM changes c
+ WHERE change_id = ?
+ }, undef, $change_id)->[0];
+}
+
+sub _offset_op {
+ my ( $self, $offset ) = @_;
+ my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' );
+ return $dir, $op, 'SKIP ' . (abs($offset) - 1);
+}
+
+sub change_id_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the ID if there is no offset.
+ return $change_id unless $offset;
+
+ my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT FIRST 1 $offset_expr change_id AS "id"
+ FROM changes
+ WHERE project = ?
+ AND committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ ORDER BY committed_at $dir
+ }, undef, $self->plan->project, $change_id )->[0];
+}
+
+sub change_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the object if there is no offset.
+ return $self->load_change($change_id) unless $offset;
+
+ # Are we offset forwards or backwards?
+ my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+
+ my $change = $self->dbh->selectrow_hashref(qq{
+ SELECT FIRST 1 $offset_expr
+ c.change_id AS "id", c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ AND c.committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
+ c.planner_name, c.planner_email, c.committed_at
+ ORDER BY c.committed_at $dir
+ }, undef, $self->plan->project, $change_id ) || return undef;
+ $change->{timestamp} = _dt $change->{timestamp};
+ unless ( ref $change->{tags} ) {
+ $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : [];
+ }
+ return $change;
+}
+
+sub _cid_head {
+ my ($self, $project, $change) = @_;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT FIRST 1 change_id
+ FROM changes
+ WHERE project = ?
+ AND changes.change = ?
+ ORDER BY committed_at DESC
+ }, undef, $project, $change)->[0];
+}
+
+sub change_id_for {
+ my ( $self, %p) = @_;
+ my $dbh = $self->dbh;
+
+ if ( my $cid = $p{change_id} ) {
+ # Find by ID.
+ return $dbh->selectcol_arrayref(q{
+ SELECT change_id
+ FROM changes
+ WHERE change_id = ?
+ }, undef, $cid)->[0];
+ }
+
+ my $project = $p{project} || $self->plan->project;
+ if ( my $change = $p{change} ) {
+ if ( my $tag = $p{tag} ) {
+ # There is nothing before the first tag.
+ return undef if $tag eq 'ROOT';
+
+ # Find closest to the end for @HEAD.
+ return $self->_cid_head($project, $change) if $tag eq 'HEAD';
+
+ # Find by change name and following tag.
+ return $dbh->selectcol_arrayref(q{
+ SELECT FIRST 1 changes.change_id
+ FROM changes
+ JOIN tags
+ ON changes.committed_at <= tags.committed_at
+ AND changes.project = tags.project
+ WHERE changes.project = ?
+ AND changes.change = ?
+ AND tags.tag = ?
+ ORDER BY changes.committed_at DESC
+ }, undef, $project, $change, '@' . $tag)->[0];
+ }
+
+ # Find earliest by change name.
+ my $ids = $dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ AND changes.change = ?
+ ORDER BY changes.committed_at ASC
+ }, undef, $project, $change);
+
+ # Return the ID.
+ return $ids->[0] if $p{first};
+ return $self->_handle_lookup_index($change, $ids);
+ }
+
+ if ( my $tag = $p{tag} ) {
+ # Just return the latest for @HEAD.
+ return $self->_cid('DESC', 0, $project) if $tag eq 'HEAD';
+
+ # Just return the earliest for @ROOT.
+ return $self->_cid('ASC', 0, $project) if $tag eq 'ROOT';
+
+ # Find by tag name.
+ return $dbh->selectcol_arrayref(q{
+ SELECT change_id
+ FROM tags
+ WHERE project = ?
+ AND tag = ?
+ }, undef, $project, '@' . $tag)->[0];
+ }
+
+ # We got nothin.
+ return undef;
+}
+
+sub log_new_tags {
+ my ( $self, $change ) = @_;
+ my @tags = $change->tags or return $self;
+ my $sqitch = $self->sqitch;
+
+ my ($id, $name, $proj, $user, $email) = (
+ $change->id,
+ $change->format_name,
+ $change->project,
+ $sqitch->user_name,
+ $sqitch->user_email
+ );
+
+ my $ts = $self->_ts_default;
+ my $sf = $self->_simple_from;
+
+ my $sql = q{
+ INSERT INTO tags (
+ tag_id
+ , tag
+ , project
+ , change_id
+ , note
+ , committer_name
+ , committer_email
+ , planned_at
+ , planner_name
+ , planner_email
+ , committed_at
+ )
+ SELECT i.* FROM (
+ } . join(
+ "\n UNION ALL ",
+ ("SELECT CAST(? AS CHAR(40)) AS tid
+ , CAST(? AS VARCHAR(250)) AS tname
+ , CAST(? AS VARCHAR(255)) AS proj
+ , CAST(? AS CHAR(40)) AS cid
+ , CAST(? AS VARCHAR(4000)) AS note
+ , CAST(? AS VARCHAR(512)) AS cuser
+ , CAST(? AS VARCHAR(512)) AS cemail
+ , CAST(? AS TIMESTAMP) AS tts
+ , CAST(? AS VARCHAR(512)) AS puser
+ , CAST(? AS VARCHAR(512)) AS pemail
+ , CAST($ts$sf AS TIMESTAMP) AS cts"
+ ) x @tags ) . q{
+ FROM RDB$DATABASE ) i
+ LEFT JOIN tags ON i.tid = tags.tag_id
+ WHERE tags.tag_id IS NULL
+ };
+ my @params = map { (
+ $_->id,
+ $_->format_name,
+ $proj,
+ $id,
+ $_->note,
+ $user,
+ $email,
+ $self->_char2ts( $_->timestamp ),
+ $_->planner_name,
+ $_->planner_email,
+ ) } @tags;
+ $self->dbh->do($sql, undef, @params );
+ return $self;
+}
+
+sub log_deploy_change {
+ my ($self, $change) = @_;
+ my $dbh = $self->dbh;
+ my $sqitch = $self->sqitch;
+
+ my ($id, $name, $proj, $user, $email) = (
+ $change->id,
+ $change->format_name,
+ $change->project,
+ $sqitch->user_name,
+ $sqitch->user_email
+ );
+
+ my $ts = $self->_ts_default;
+ my $cols = join "\n , ", $self->_quote_idents(qw(
+ change_id
+ script_hash
+ change
+ project
+ note
+ committer_name
+ committer_email
+ planned_at
+ planner_name
+ planner_email
+ committed_at
+ ));
+ $dbh->do(qq{
+ INSERT INTO changes (
+ $cols
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts)
+ }, undef,
+ $id,
+ $change->script_hash,
+ $name,
+ $proj,
+ $change->note,
+ $user,
+ $email,
+ $self->_char2ts( $change->timestamp ),
+ $change->planner_name,
+ $change->planner_email,
+ );
+
+ if ( my @deps = $change->dependencies ) {
+ foreach my $dep (@deps) {
+ my $sql = q{
+ INSERT INTO dependencies (
+ change_id
+ , type
+ , dependency
+ , dependency_id
+ ) VALUES ( ?, ?, ?, ? ) };
+ $dbh->do( $sql, undef,
+ ( $id, $dep->type, $dep->as_string, $dep->resolved_id ) );
+ }
+ }
+
+ if ( my @tags = $change->tags ) {
+ foreach my $tag (@tags) {
+ my $sql = qq{
+ INSERT INTO tags (
+ tag_id
+ , tag
+ , project
+ , change_id
+ , note
+ , committer_name
+ , committer_email
+ , planned_at
+ , planner_name
+ , planner_email
+ , committed_at
+ ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts) };
+ $dbh->do(
+ $sql, undef,
+ ( $tag->id, $tag->format_name,
+ $proj, $id,
+ $tag->note, $user,
+ $email, $self->_char2ts( $tag->timestamp ),
+ $tag->planner_name, $tag->planner_email,
+ )
+ );
+ }
+ }
+
+ return $self->_log_event( deploy => $change );
+}
+
+sub default_client {
+ my $self = shift;
+ my $ext = App::Sqitch::ISWIN || $^O eq 'cygwin' ? '.exe' : '';
+
+ # Create a script to run.
+ require File::Temp;
+ my $fh = File::Temp->new( CLEANUP => 1 );
+ my @opts = (qw(-z -q -i), $fh->filename);
+ $fh->print("quit;\n");
+ $fh->close;
+
+ # Suppress STDERR, including in subprocess.
+ open my $olderr, '>&', \*STDERR or hurl firebird => __x(
+ 'Cannot dup STDERR: {error}', $!
+ );
+ close STDERR;
+ open STDERR, '>', \my $stderr or hurl firebird => __x(
+ 'Cannot reirect STDERR: {error}', $!
+ );
+
+ # Try to find a client in the path.
+ for my $try ( map { $_ . $ext } qw(fbsql isql-fb isql) ) {
+ my $loops = 0;
+ for my $dir (File::Spec->path) {
+ my $path = file $dir, $try;
+ $path = Win32::GetShortPathName($path) if App::Sqitch::ISWIN;
+ if (-f $path && -x $path) {
+ if (try { App::Sqitch->probe($path, @opts) =~ /Firebird/ } ) {
+ # Restore STDERR and return.
+ open STDERR, '>&', $olderr or hurl firebird => __x(
+ 'Cannot dup STDERR: {error}', $!
+ );
+ return $loops ? $path->stringify : $try;
+ }
+ $loops++;
+ }
+ }
+ }
+
+ # Restore STDERR and die.
+ open STDERR, '>&', $olderr or hurl firebird => __x(
+ 'Cannot dup STDERR: {error}', $!
+ );
+ hurl firebird => __(
+ 'Unable to locate Firebird ISQL; set "engine.firebird.client" via sqitch config'
+ );
+}
+
+sub _update_script_hashes {
+ my $self = shift;
+ my $plan = $self->plan;
+ my $proj = $plan->project;
+ my $dbh = $self->dbh;
+
+ $self->begin_work;
+ # Firebird refuses to update via a prepared statement, so use do(). :-(
+ $dbh->do(
+ 'UPDATE changes SET script_hash = ? WHERE change_id = ?',
+ undef, $_->script_hash, $_->id
+ ) for $plan->changes;
+ $dbh->do(q{
+ UPDATE changes SET script_hash = NULL
+ WHERE project = ? AND script_hash = change_id
+ }, undef, $proj);
+
+ $self->finish_work;
+ return $self;
+}
+
+1;
+
+__END__
+
+=encoding utf8
+
+=head1 Name
+
+App::Sqitch::Engine::firebird - Sqitch Firebird Engine
+
+=head1 Synopsis
+
+ my $firebird = App::Sqitch::Engine->load( engine => 'firebird' );
+
+=head1 Description
+
+App::Sqitch::Engine::firebird provides the Firebird storage engine for Sqitch.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<connection_string>
+
+Constructs a connection string from a database URI for passing to C<isql>.
+
+=head3 C<isql>
+
+Returns a list containing the C<isql> client and options to be passed to it.
+Used internally when executing scripts.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+Ștefan Suciu <stefan@s2i2.ro>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Copyright (c) 2013 Ștefan Suciu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/firebird.sql b/lib/App/Sqitch/Engine/firebird.sql
new file mode 100644
index 00000000..5757c172
--- /dev/null
+++ b/lib/App/Sqitch/Engine/firebird.sql
@@ -0,0 +1,166 @@
+/*
+ * Sqitch database deployment metadata v1.1.;
+ */
+
+/*
+ * Required PAGE SIZE = 16384 to avoid error: "key size exceeds
+ * implementation restriction for index..."
+ */
+
+-- Table: releases
+
+CREATE TABLE releases (
+ version FLOAT NOT NULL PRIMARY KEY,
+ installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ installer_name VARCHAR(255) NOT NULL,
+ installer_email VARCHAR(255) NOT NULL
+);
+
+COMMENT ON TABLE releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+-- Table: projects
+
+CREATE TABLE projects (
+ project VARCHAR(255) NOT NULL PRIMARY KEY,
+ uri VARCHAR(255) UNIQUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ creator_name VARCHAR(255) NOT NULL,
+ creator_email VARCHAR(255) NOT NULL
+);
+
+COMMENT ON TABLE projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN projects.creator_email IS 'Email address of the user who added the project.';
+
+-- Table: changes
+
+CREATE TABLE changes (
+ change_id VARCHAR(40) NOT NULL PRIMARY KEY,
+ script_hash VARCHAR(40),
+ change VARCHAR(255) NOT NULL,
+ project VARCHAR(255) NOT NULL REFERENCES projects(project)
+ ON UPDATE CASCADE,
+ note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ committer_name VARCHAR(255) NOT NULL,
+ committer_email VARCHAR(255) NOT NULL,
+ planned_at TIMESTAMP NOT NULL,
+ planner_name VARCHAR(255) NOT NULL,
+ planner_email VARCHAR(255) NOT NULL,
+ UNIQUE(project, script_hash)
+);
+
+COMMENT ON TABLE changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN changes.script_hash IS 'Deploy script SHA-1 hash.';
+COMMENT ON COLUMN changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN changes.note IS 'Description of the change.';
+COMMENT ON COLUMN changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN changes.planner_email IS 'Email address of the user who planned the change.';
+
+-- Table: tags
+
+CREATE TABLE tags (
+ tag_id CHAR(40) NOT NULL PRIMARY KEY,
+ tag VARCHAR(250) NOT NULL,
+ project VARCHAR(255) NOT NULL REFERENCES projects(project)
+ ON UPDATE CASCADE,
+ change_id CHAR(40) NOT NULL REFERENCES changes(change_id)
+ ON UPDATE CASCADE,
+ note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ committer_name VARCHAR(512) NOT NULL,
+ committer_email VARCHAR(512) NOT NULL,
+ planned_at TIMESTAMP NOT NULL,
+ planner_name VARCHAR(512) NOT NULL,
+ planner_email VARCHAR(512) NOT NULL,
+ UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN tags.planner_email IS 'Email address of the user who planned the tag.';
+
+-- Table: dependencies
+
+CREATE TABLE dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES changes(change_id)
+ ON UPDATE CASCADE ON DELETE CASCADE,
+ type VARCHAR(8) NOT NULL,
+ dependency VARCHAR(512) NOT NULL,
+ dependency_id CHAR(40) REFERENCES changes(change_id)
+ ON UPDATE CASCADE CONSTRAINT dependencies_check CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+-- Table: events
+
+CREATE TABLE events (
+ event VARCHAR(6) NOT NULL
+ CONSTRAINT events_event_check CHECK (
+ event IN ('deploy', 'revert', 'fail', 'merge')
+ ),
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR(512) NOT NULL,
+ project VARCHAR(255) NOT NULL REFERENCES projects(project)
+ ON UPDATE CASCADE,
+ note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ requires BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ conflicts BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ tags BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ committer_name VARCHAR(512) NOT NULL,
+ committer_email VARCHAR(512) NOT NULL,
+ planned_at TIMESTAMP NOT NULL,
+ planner_name VARCHAR(512) NOT NULL,
+ planner_email VARCHAR(512) NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+COMMENT ON TABLE events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN events.event IS 'Type of event.';
+COMMENT ON COLUMN events.change_id IS 'Change ID.';
+COMMENT ON COLUMN events.change IS 'Change name.';
+COMMENT ON COLUMN events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN events.note IS 'Description of the change.';
+COMMENT ON COLUMN events.requires IS 'Array of the names of required changes.';
+COMMENT ON COLUMN events.conflicts IS 'Array of the names of conflicting changes.';
+COMMENT ON COLUMN events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN events.planner_email IS 'Email address of the user who plan planned the change.';
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/mysql.pm b/lib/App/Sqitch/Engine/mysql.pm
new file mode 100644
index 00000000..3070936d
--- /dev/null
+++ b/lib/App/Sqitch/Engine/mysql.pm
@@ -0,0 +1,551 @@
+package App::Sqitch::Engine::mysql;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Plan::Change;
+use Path::Class;
+use Moo;
+use App::Sqitch::Types qw(DBH URIDB ArrayRef Bool Str HashRef);
+use namespace::autoclean;
+use List::MoreUtils qw(firstidx);
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has uri => (
+ is => 'ro',
+ isa => URIDB,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->SUPER::uri;
+ $uri->host($ENV{MYSQL_HOST}) if !$uri->host && $ENV{MYSQL_HOST};
+ $uri->port($ENV{MYSQL_TCP_PORT}) if !$uri->_port && $ENV{MYSQL_TCP_PORT};
+ return $uri;
+ },
+);
+
+has registry_uri => (
+ is => 'ro',
+ isa => URIDB,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri->clone;
+ $uri->dbname($self->registry);
+ return $uri;
+ },
+);
+
+sub registry_destination {
+ my $uri = shift->registry_uri;
+ if ($uri->password) {
+ $uri = $uri->clone;
+ $uri->password(undef);
+ }
+ return $uri->as_string;
+}
+
+has _mycnf => (
+ is => 'rw',
+ isa => HashRef,
+ default => sub {
+ eval 'require MySQL::Config; 1' or return {};
+ return scalar MySQL::Config::parse_defaults('my', [qw(client mysql)]);
+ },
+);
+
+sub _def_user { $_[0]->_mycnf->{user} || $_[0]->sqitch->sysuser }
+sub _def_pass { $ENV{MYSQL_PWD} || shift->_mycnf->{password} }
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->use_driver;
+ my $uri = $self->registry_uri;
+ my $dbh = DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ mysql_enable_utf8 => 1,
+ mysql_auto_reconnect => 0,
+ mysql_use_result => 0, # Prevent "Commands out of sync" error.
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ Callbacks => {
+ connected => sub {
+ my $dbh = shift;
+ $dbh->do("SET SESSION $_") for (
+ q{character_set_client = 'utf8'},
+ q{character_set_server = 'utf8'},
+ ($dbh->{mysql_serverversion} || 0 < 50500 ? () : (q{default_storage_engine = 'InnoDB'})),
+ q{time_zone = '+00:00'},
+ q{group_concat_max_len = 32768},
+ q{sql_mode = '} . join(',', qw(
+ ansi
+ strict_trans_tables
+ no_auto_value_on_zero
+ no_zero_date
+ no_zero_in_date
+ only_full_group_by
+ error_for_division_by_zero
+ )) . q{'},
+ );
+ if (!$dbh->{mysql_serverversion} && DBI->VERSION < 1.631) {
+ # Prior to 1.631, callbacks were inner handles and
+ # mysql_* aren't set yet. So set InnoDB in a try block.
+ try {
+ $dbh->do(q{SET SESSION default_storage_engine = 'InnoDB'});
+ };
+ # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html
+ $dbh->set_err(undef, undef) if $dbh->err;
+ }
+ return;
+ },
+ },
+ });
+
+ # Make sure we support this version.
+ my ($dbms, $vnum, $vstr) = $dbh->{mysql_serverinfo} =~ /mariadb/i
+ ? ('MariaDB', 50300, '5.3')
+ : ('MySQL', 50000, '5.0.0');
+ hurl mysql => __x(
+ 'Sqitch requires {rdbms} {want_version} or higher; this is {have_version}',
+ rdbms => $dbms,
+ want_version => $vstr,
+ have_version => $dbh->selectcol_arrayref('SELECT version()')->[0],
+ ) unless $dbh->{mysql_serverversion} >= $vnum;
+
+ return $dbh;
+ }
+);
+
+has _ts_default => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ return 'utc_timestamp(6)' if shift->_fractional_seconds;
+ return 'utc_timestamp';
+ },
+);
+
+# Need to wait until dbh and _ts_default are defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+has _mysql => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+
+ $self->sqitch->warn(__x
+ 'Database name missing in URI "{uri}"',
+ uri => $uri
+ ) unless $uri->dbname;
+
+ my @ret = ( $self->client );
+ for my $spec (
+ [ user => $self->username ],
+ [ database => $uri->dbname ],
+ [ host => $uri->host ],
+ [ port => $uri->_port ],
+ ) {
+ push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1];
+ }
+
+ # Special-case --password, which requires = before the value. O_o
+ if (my $pw = $self->password) {
+ push @ret, "--password=$pw";
+ }
+
+ # Options to keep things quiet.
+ push @ret => (
+ (App::Sqitch::ISWIN ? () : '--skip-pager' ),
+ '--silent',
+ '--skip-column-names',
+ '--skip-line-numbers',
+ );
+
+ # Get Maria to abort properly on error.
+ my $vinfo = try { $self->sqitch->probe($self->client, '--version') } || '';
+ if ($vinfo =~ /mariadb/i) {
+ my ($version) = $vinfo =~ /Ver\s(\S+)/;
+ my ($maj, undef, $pat) = split /[.]/ => $version;
+ push @ret => '--abort-source-on-error'
+ if $maj > 5 || ($maj == 5 && $pat >= 66);
+ }
+
+ # Add relevant query args.
+ if (my @p = $uri->query_params) {
+ my %option_for = (
+ mysql_compression => sub { $_[0] ? '--compress' : () },
+ mysql_ssl => sub { $_[0] ? '--ssl' : () },
+ mysql_connect_timeout => sub { '--connect_timeout', $_[0] },
+ mysql_init_command => sub { '--init-command', $_[0] },
+ mysql_socket => sub { '--socket', $_[0] },
+ mysql_ssl_client_key => sub { '--ssl-key', $_[0] },
+ mysql_ssl_client_cert => sub { '--ssl-cert', $_[0] },
+ mysql_ssl_ca_file => sub { '--ssl-ca', $_[0] },
+ mysql_ssl_ca_path => sub { '--ssl-capath', $_[0] },
+ mysql_ssl_cipher => sub { '--ssl-cipher', $_[0] },
+ );
+ while (@p) {
+ my ($k, $v) = (shift @p, shift @p);
+ my $code = $option_for{$k} or next;
+ push @ret => $code->($v);
+ }
+ }
+
+ return \@ret;
+ },
+);
+
+has _fractional_seconds => (
+ is => 'ro',
+ isa => Bool,
+ lazy => 1,
+ default => sub {
+ my $dbh = shift->dbh;
+ return $dbh->{mysql_serverinfo} !~ /mariadb/i
+ && $dbh->{mysql_serverversion} >= 50604;
+ },
+);
+
+sub mysql { @{ shift->_mysql } }
+
+sub key { 'mysql' }
+sub name { 'MySQL' }
+sub driver { 'DBD::mysql 4.018' }
+sub default_client { 'mysql' }
+
+sub _char2ts {
+ $_[1]->set_time_zone('UTC')->iso8601;
+}
+
+sub _ts2char_format {
+ return q{date_format(%s, 'year:%%Y:month:%%m:day:%%d:hour:%%H:minute:%%i:second:%%S:time_zone:UTC')};
+}
+
+sub _quote_idents {
+ shift;
+ map { $_ eq 'change' ? '"change"' : $_ } @_;
+}
+
+sub _version_query { 'SELECT CAST(ROUND(MAX(version), 1) AS CHAR) FROM releases' }
+
+sub initialized {
+ my $self = shift;
+
+ # Try to connect.
+ my $dbh = try { $self->dbh } catch {
+ # MySQL error code 1049 (ER_BAD_DB_ERROR): Unknown database '%-.192s'
+ return if $DBI::err && $DBI::err == 1049;
+ die $_;
+ } or return 0;
+
+ return $dbh->selectcol_arrayref(q{
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = ?
+ AND table_name = ?
+ }, undef, $self->registry, 'changes')->[0];
+}
+
+sub initialize {
+ my $self = shift;
+ hurl engine => __x(
+ 'Sqitch database {database} already initialized',
+ database => $self->registry,
+ ) if $self->initialized;
+
+ # Create the Sqitch database if it does not exist.
+ (my $db = $self->registry) =~ s/"/""/g;
+ $self->_run(
+ '--execute' => sprintf(
+ 'SET sql_mode = ansi; CREATE DATABASE IF NOT EXISTS "%s"',
+ $self->registry
+ ),
+ );
+
+ # Deploy the registry to the Sqitch database.
+ $self->run_upgrade( file(__FILE__)->dir->file('mysql.sql') );
+ $self->_register_release;
+}
+
+# Override to lock the Sqitch tables. This ensures that only one instance of
+# Sqitch runs at one time.
+sub begin_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+
+ # Start transaction and lock all tables to disallow concurrent changes.
+ $dbh->do('LOCK TABLES ' . join ', ', map {
+ "$_ WRITE"
+ } qw(releases changes dependencies events projects tags));
+ $dbh->begin_work;
+ return $self;
+}
+
+# Override to unlock the tables, otherwise future transactions on this
+# connection can fail.
+sub finish_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+ $dbh->commit;
+ $dbh->do('UNLOCK TABLES');
+ return $self;
+}
+
+sub _no_table_error {
+ return $DBI::state && (
+ $DBI::state eq '42S02' # ER_BAD_TABLE_ERROR
+ ||
+ ($DBI::state eq '42000' && $DBI::err == '1049') # ER_BAD_DB_ERROR
+ )
+}
+
+sub _no_column_error {
+ return $DBI::state && $DBI::state eq '42S22' && $DBI::err == '1054'; # ER_BAD_FIELD_ERROR
+}
+
+sub _regex_op { 'REGEXP' }
+
+sub _limit_default { '18446744073709551615' }
+
+sub _listagg_format {
+ return q{GROUP_CONCAT(%s SEPARATOR ' ')};
+}
+
+sub _prepare_to_log {
+ my ($self, $table, $change) = @_;
+ return $self if $self->_fractional_seconds;
+
+ # No sub-second precision, so delay logging a change until a second has passed.
+ my $dbh = $self->dbh;
+ my $sth = $dbh->prepare(qq{
+ SELECT UNIX_TIMESTAMP(committed_at) >= UNIX_TIMESTAMP()
+ FROM $table
+ WHERE project = ?
+ ORDER BY committed_at DESC
+ LIMIT 1
+ });
+ while ($dbh->selectcol_arrayref($sth, undef, $change->project)->[0]) {
+ # Sleep for 100 ms.
+ require Time::HiRes;
+ Time::HiRes::sleep(0.1);
+ }
+
+ return $self;
+}
+
+sub _set_vars {
+ my %vars = shift->variables or return;
+ return 'SET ' . join(', ', map {
+ (my $k = $_) =~ s/"/""/g;
+ (my $v = $vars{$_}) =~ s/'/''/g;
+ qq{\@"$k" = '$v'};
+ } sort keys %vars) . ";\n";
+}
+
+sub _source {
+ my ($self, $file) = @_;
+ my $set = $self->_set_vars || '';
+ return ('--execute' => "${set}source $file");
+}
+
+sub _run {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->run( $self->mysql, @_ );
+ local $ENV{MYSQL_PWD} = $pass;
+ return $sqitch->run( $self->mysql, @_ );
+}
+
+sub _capture {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->capture( $self->mysql, @_ );
+ local $ENV{MYSQL_PWD} = $pass;
+ return $sqitch->capture( $self->mysql, @_ );
+}
+
+sub _spool {
+ my $self = shift;
+ my @fh = (shift);
+ my $sqitch = $self->sqitch;
+ if (my $set = $self->_set_vars) {
+ open my $sfh, '<:utf8_strict', \$set;
+ unshift @fh, $sfh;
+ }
+ my $pass = $self->password or return $sqitch->spool( \@fh, $self->mysql, @_ );
+ local $ENV{MYSQL_PWD} = $pass;
+ return $sqitch->spool( \@fh, $self->mysql, @_ );
+}
+
+sub run_file {
+ my $self = shift;
+ $self->_run( $self->_source(@_) );
+}
+
+sub run_verify {
+ my $self = shift;
+ # Suppress STDOUT unless we want extra verbosity.
+ my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
+ $self->$meth( $self->_source(@_) );
+}
+
+sub run_upgrade {
+ my ($self, $file) = @_;
+ my $dbh = $self->dbh;
+ my @cmd = $self->mysql;
+ $cmd[1 + firstidx { $_ eq '--database' } @cmd ] = $self->registry;
+ return $self->sqitch->run( @cmd, $self->_source($file) )
+ if $self->_fractional_seconds;
+
+ # Need to strip out datetime precision.
+ (my $sql = scalar $file->slurp) =~ s{DATETIME\(\d+\)}{DATETIME}g;
+
+ # Strip out 5.5 stuff on earlier versions.
+ $sql =~ s/-- ## BEGIN 5[.]5.+?-- ## END 5[.]5//ms if $dbh->{mysql_serverversion} < 50500;
+
+ # Write out a temp file and execute it.
+ require File::Temp;
+ my $fh = File::Temp->new;
+ print $fh $sql;
+ close $fh;
+ $self->sqitch->run( @cmd, $self->_source($fh) );
+}
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ $self->_spool($fh);
+}
+
+sub _cid {
+ my ( $self, $ord, $offset, $project ) = @_;
+
+ my $offexpr = $offset ? " OFFSET $offset" : '';
+ return try {
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ ORDER BY committed_at $ord
+ LIMIT 1$offexpr
+ }, undef, $project || $self->plan->project)->[0];
+ } catch {
+ # MySQL error code 1049 (ER_BAD_DB_ERROR): Unknown database '%-.192s'
+ # MySQL error code 1146 (ER_NO_SUCH_TABLE): Table '%s.%s' doesn't exist
+ return if $DBI::err && ($DBI::err == 1049 || $DBI::err == 1146);
+ die $_;
+ };
+}
+
+1;
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine::mysql - Sqitch MySQL Engine
+
+=head1 Synopsis
+
+ my $mysql = App::Sqitch::Engine->load( engine => 'mysql' );
+
+=head1 Description
+
+App::Sqitch::Engine::mysql provides the MySQL storage engine for Sqitch. It
+supports MySQL 5.1.0 and higher (best on 5.6.4 and higher), as well as MariaDB
+5.3.0 and higher.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<mysql>
+
+Returns a list containing the C<mysql> client and options to be passed to it.
+Used internally when executing scripts. Query parameters in the URI that map
+to C<mysql> client options will be passed to the client, as follows:
+
+=over
+
+=item * C<mysql_compression=1>: C<--compress>
+
+=item * C<mysql_ssl=1>: C<--ssl>
+
+=item * C<mysql_connect_timeout>: C<--connect_timeout>
+
+=item * C<mysql_init_command>: C<--init-command>
+
+=item * C<mysql_socket>: C<--socket>
+
+=item * C<mysql_ssl_client_key>: C<--ssl-key>
+
+=item * C<mysql_ssl_client_cert>: C<--ssl-cert>
+
+=item * C<mysql_ssl_ca_file>: C<--ssl-ca>
+
+=item * C<mysql_ssl_ca_path>: C<--ssl-capath>
+
+=item * C<mysql_ssl_cipher>: C<--ssl-cipher>
+
+=back
+
+=head3 C<username>
+
+=head3 C<password>
+
+Overrides the methods provided by the target so that, if the target has
+no username or password, Sqitch looks them up in the
+L<F</etc/my.cnf> and F<~/.my.cnf> files|https://dev.mysql.com/doc/refman/5.7/en/password-security-user.html>.
+These files must limit access only to the current user (C<0600>). Sqitch will
+look for a username and password under the C<[client]> and C<[mysql]>
+sections, in that order.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/mysql.sql b/lib/App/Sqitch/Engine/mysql.sql
new file mode 100644
index 00000000..8c9caaaf
--- /dev/null
+++ b/lib/App/Sqitch/Engine/mysql.sql
@@ -0,0 +1,192 @@
+BEGIN;
+
+SET SESSION sql_mode = ansi;
+
+CREATE TABLE releases (
+ version FLOAT(4, 1) PRIMARY KEY
+ COMMENT 'Version of the Sqitch registry.',
+ installed_at DATETIME(6) NOT NULL
+ COMMENT 'Date the registry release was installed.',
+ installer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who installed the registry release.',
+ installer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who installed the registry release.'
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Sqitch registry releases.'
+;
+
+CREATE TABLE projects (
+ project VARCHAR(255) PRIMARY KEY
+ COMMENT 'Unique Name of a project.',
+ uri VARCHAR(255) NULL UNIQUE
+ COMMENT 'Optional project URI',
+ created_at DATETIME(6) NOT NULL
+ COMMENT 'Date the project was added to the database.',
+ creator_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who added the project.',
+ creator_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who added the project.'
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Sqitch projects deployed to this database.'
+;
+
+CREATE TABLE changes (
+ change_id VARCHAR(40) PRIMARY KEY
+ COMMENT 'Change primary key.',
+ script_hash VARCHAR(40) NULL
+ COMMENT 'Deploy script SHA-1 hash.',
+ "change" VARCHAR(255) NOT NULL
+ COMMENT 'Name of a deployed change.',
+ project VARCHAR(255) NOT NULL
+ COMMENT 'Name of the Sqitch project to which the change belongs.'
+ REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL
+ COMMENT 'Description of the change.',
+ committed_at DATETIME(6) NOT NULL
+ COMMENT 'Date the change was deployed.',
+ committer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who deployed the change.',
+ committer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who deployed the change.',
+ planned_at DATETIME(6) NOT NULL
+ COMMENT 'Date the change was added to the plan.',
+ planner_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who planed the change.',
+ planner_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who planned the change.',
+ UNIQUE(project, script_hash)
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Tracks the changes currently deployed to the database.'
+;
+
+CREATE TABLE tags (
+ tag_id VARCHAR(40) PRIMARY KEY
+ COMMENT 'Tag primary key.',
+ tag VARCHAR(255) NOT NULL
+ COMMENT 'Project-unique tag name.',
+ project VARCHAR(255) NOT NULL
+ COMMENT 'Name of the Sqitch project to which the tag belongs.'
+ REFERENCES projects(project) ON UPDATE CASCADE,
+ change_id VARCHAR(40) NOT NULL
+ COMMENT 'ID of last change deployed before the tag was applied.'
+ REFERENCES changes(change_id) ON UPDATE CASCADE,
+ note VARCHAR(255) NOT NULL
+ COMMENT 'Description of the tag.',
+ committed_at DATETIME(6) NOT NULL
+ COMMENT 'Date the tag was applied to the database.',
+ committer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who applied the tag.',
+ committer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who applied the tag.',
+ planned_at DATETIME(6) NOT NULL
+ COMMENT 'Date the tag was added to the plan.',
+ planner_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who planed the tag.',
+ planner_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who planned the tag.',
+ UNIQUE(project, tag)
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Tracks the tags currently applied to the database.'
+;
+
+CREATE TABLE dependencies (
+ change_id VARCHAR(40) NOT NULL
+ COMMENT 'ID of the depending change.'
+ REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type VARCHAR(8) NOT NULL
+ COMMENT 'Type of dependency.',
+ dependency VARCHAR(255) NOT NULL
+ COMMENT 'Dependency name.',
+ dependency_id VARCHAR(40) NULL
+ COMMENT 'Change ID the dependency resolves to.'
+ REFERENCES changes(change_id) ON UPDATE CASCADE,
+ PRIMARY KEY (change_id, dependency)
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Tracks the currently satisfied dependencies.'
+;
+
+CREATE TABLE events (
+ event ENUM ('deploy', 'fail', 'merge', 'revert') NOT NULL
+ COMMENT 'Type of event.',
+ change_id VARCHAR(40) NOT NULL
+ COMMENT 'Change ID.',
+ "change" VARCHAR(255) NOT NULL
+ COMMENT 'Change name.',
+ project VARCHAR(255) NOT NULL
+ COMMENT 'Name of the Sqitch project to which the change belongs.'
+ REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL
+ COMMENT 'Description of the change.',
+ requires TEXT NOT NULL
+ COMMENT 'List of the names of required changes.',
+ conflicts TEXT NOT NULL
+ COMMENT 'List of the names of conflicting changes.',
+ tags TEXT NOT NULL
+ COMMENT 'List of tags associated with the change.',
+ committed_at DATETIME(6) NOT NULL
+ COMMENT 'Date the event was committed.',
+ committer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who committed the event.',
+ committer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who committed the event.',
+ planned_at DATETIME(6) NOT NULL
+ COMMENT 'Date the event was added to the plan.',
+ planner_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who planed the change.',
+ planner_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who plan planned the change.',
+ PRIMARY KEY (change_id, committed_at)
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Contains full history of all deployment events.'
+;
+
+-- ## BEGIN 5.5
+-- MySQL does not support checks, so we kind of create our own. The checkit()
+-- function works sort of like a CHECK: if the first argument is 0 or NULL, it
+-- throws the second argument as an exception. Conveniently, verify scripts
+-- can also use it to ensure an error is thrown when a change cannot be
+-- verified. Requires MySQL 5.5.0.
+
+DELIMITER |
+
+CREATE FUNCTION checkit(doit INTEGER, message VARCHAR(256)) RETURNS INTEGER DETERMINISTIC
+BEGIN
+ IF doit IS NULL OR doit = 0 THEN
+ SIGNAL SQLSTATE 'ERR0R' SET MESSAGE_TEXT = message;
+ END IF;
+ RETURN doit;
+END;
+|
+
+CREATE TRIGGER ck_insert_dependency BEFORE INSERT ON dependencies
+FOR EACH ROW BEGIN
+ -- DO does not work. https://bugs.mysql.com/bug.php?id=69647
+ SET @dummy := checkit(
+ (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL)
+ OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL),
+ 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set'
+ );
+END;
+|
+
+CREATE TRIGGER ck_update_dependency BEFORE UPDATE ON dependencies
+FOR EACH ROW BEGIN
+ -- DO does not work. https://bugs.mysql.com/bug.php?id=69647
+ SET @dummy := checkit(
+ (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL)
+ OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL),
+ 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set'
+ );
+END;
+|
+
+DELIMITER ;
+-- ## END 5.5
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/oracle.pm b/lib/App/Sqitch/Engine/oracle.pm
new file mode 100644
index 00000000..49fd446f
--- /dev/null
+++ b/lib/App/Sqitch/Engine/oracle.pm
@@ -0,0 +1,832 @@
+package App::Sqitch::Engine::oracle;
+
+use 5.010;
+use Moo;
+use utf8;
+use Path::Class;
+use DBI;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Plan::Change;
+use List::Util qw(first);
+use App::Sqitch::Types qw(DBH Dir ArrayRef);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+BEGIN {
+ # We tell the Oracle connector which encoding to use. The last part of the
+ # environment variable NLS_LANG is relevant concerning data encoding.
+ $ENV{NLS_LANG} = 'AMERICAN_AMERICA.AL32UTF8';
+
+ # Disable SQLPATH so that no start scripts run.
+ $ENV{SQLPATH} = '';
+}
+
+sub destination {
+ my $self = shift;
+
+ # Just use the target name if it doesn't look like a URI or if the URI
+ # includes the database name.
+ return $self->target->name if $self->target->name !~ /:/
+ || $self->target->uri->dbname;
+
+ # Use the URI sans password, and with the database name added.
+ my $uri = $self->target->uri->clone;
+ $uri->password(undef) if $uri->password;
+ $uri->dbname(
+ $ENV{TWO_TASK}
+ || ( App::Sqitch::ISWIN ? $ENV{LOCAL} : undef )
+ || $ENV{ORACLE_SID}
+ || $self->username
+ );
+ return $uri->as_string;
+}
+
+# No username or password defaults.
+sub _def_user { }
+sub _def_pass { }
+
+has _sqlplus => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ [ $self->client, qw(-S -L /nolog) ];
+ },
+);
+
+sub sqlplus { @{ shift->_sqlplus } }
+
+has tmpdir => (
+ is => 'ro',
+ isa => Dir,
+ lazy => 1,
+ default => sub {
+ require File::Temp;
+ dir File::Temp::tempdir( CLEANUP => 1 );
+ },
+);
+
+sub key { 'oracle' }
+sub name { 'Oracle' }
+sub driver { 'DBD::Oracle 1.23' }
+sub default_registry { '' }
+
+sub default_client {
+ file( ($ENV{ORACLE_HOME} || ()), 'sqlplus' )->stringify
+}
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->use_driver;
+
+ my $uri = $self->uri;
+ DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ FetchHashKeyName => 'NAME_lc',
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ Callbacks => {
+ connected => sub {
+ my $dbh = shift;
+ $dbh->do("ALTER SESSION SET $_='YYYY-MM-DD HH24:MI:SS TZR'") for qw(
+ nls_date_format
+ nls_timestamp_format
+ nls_timestamp_tz_format
+ );
+ if (my $schema = $self->registry) {
+ try {
+ $dbh->do("ALTER SESSION SET CURRENT_SCHEMA = $schema");
+ # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html
+ $dbh->set_err(undef, undef) if $dbh->err;
+ };
+ }
+ return;
+ },
+ },
+ });
+ }
+);
+
+# Need to wait until dbh is defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+sub _log_tags_param {
+ [ map { $_->format_name } $_[1]->tags ];
+}
+
+sub _log_requires_param {
+ [ map { $_->as_string } $_[1]->requires ];
+}
+
+sub _log_conflicts_param {
+ [ map { $_->as_string } $_[1]->conflicts ];
+}
+
+sub _ts2char_format {
+ # q{CAST(to_char(%1$s AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD') AS VARCHAR2(100 byte)) || CAST(to_char(%1$s AT TIME ZONE 'UTC', ':"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"') AS VARCHAR2(168 byte))}
+ # Good grief, Oracle, WTF? https://github.com/sqitchers/sqitch/issues/316
+ join ' || ', (
+ q{to_char(%1$s AT TIME ZONE 'UTC', '"year":YYYY')},
+ q{to_char(%1$s AT TIME ZONE 'UTC', ':"month":MM')},
+ q{to_char(%1$s AT TIME ZONE 'UTC', ':"day":DD')},
+ q{to_char(%1$s AT TIME ZONE 'UTC', ':"hour":HH24')},
+ q{to_char(%1$s AT TIME ZONE 'UTC', ':"minute":MI')},
+ q{to_char(%1$s AT TIME ZONE 'UTC', ':"second":SS')},
+ q{':time_zone:UTC'},
+ );
+}
+sub _ts_default { 'current_timestamp' }
+
+sub _can_limit { 0 }
+
+sub _char2ts {
+ my $dt = $_[1];
+ join ' ', $dt->ymd('-'), $dt->hms(':'), $dt->time_zone->name;
+}
+
+sub _listagg_format {
+ # https://stackoverflow.com/q/16313631/79202
+ return q{CAST(COLLECT(CAST(%s AS VARCHAR2(512))) AS sqitch_array)};
+}
+
+sub _regex_op { 'REGEXP_LIKE(%s, ?)' }
+
+sub _simple_from { ' FROM dual' }
+
+sub _multi_values {
+ my ($self, $count, $expr) = @_;
+ return join "\nUNION ALL ", ("SELECT $expr FROM dual") x $count;
+}
+
+sub _dt($) {
+ require App::Sqitch::DateTime;
+ return App::Sqitch::DateTime->new(split /:/ => shift);
+}
+
+sub _cid {
+ my ( $self, $ord, $offset, $project ) = @_;
+
+ return try {
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id FROM (
+ SELECT change_id, rownum as rnum FROM (
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ ORDER BY committed_at $ord
+ )
+ ) WHERE rnum = ?
+ }, undef, $project || $self->plan->project, ($offset // 0) + 1)->[0];
+ } catch {
+ return if $self->_no_table_error;
+ die $_;
+ };
+}
+
+sub _cid_head {
+ my ($self, $project, $change) = @_;
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id FROM (
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ AND change = ?
+ ORDER BY committed_at DESC
+ ) WHERE rownum = 1
+ }, undef, $project, $change)->[0];
+}
+
+sub _select_state {
+ my ( $self, $project, $with_hash ) = @_;
+ my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+ my $hshcol = $with_hash ? "c.script_hash\n , " : '';
+ my $dbh = $self->dbh;
+ return $dbh->selectrow_hashref(qq{
+ SELECT * FROM (
+ SELECT c.change_id
+ , ${hshcol}c.change
+ , c.project
+ , c.note
+ , c.committer_name
+ , c.committer_email
+ , $cdtcol AS committed_at
+ , c.planner_name
+ , c.planner_email
+ , $pdtcol AS planned_at
+ , $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ GROUP BY c.change_id
+ , ${hshcol}c.change
+ , c.project
+ , c.note
+ , c.committer_name
+ , c.committer_email
+ , c.committed_at
+ , c.planner_name
+ , c.planner_email
+ , c.planned_at
+ ORDER BY c.committed_at DESC
+ ) WHERE rownum = 1
+ }, undef, $project // $self->plan->project);
+}
+
+sub is_deployed_change {
+ my ( $self, $change ) = @_;
+ $self->dbh->selectcol_arrayref(
+ 'SELECT 1 FROM changes WHERE change_id = ?',
+ undef, $change->id
+ )->[0];
+}
+
+sub initialized {
+ my $self = shift;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT 1
+ FROM all_tables
+ WHERE owner = UPPER(?)
+ AND table_name = 'CHANGES'
+ }, undef, $self->registry || $self->username)->[0];
+}
+
+sub _log_event {
+ my ( $self, $event, $change, $tags, $requires, $conflicts) = @_;
+ my $dbh = $self->dbh;
+ my $sqitch = $self->sqitch;
+
+ $tags ||= $self->_log_tags_param($change);
+ $requires ||= $self->_log_requires_param($change);
+ $conflicts ||= $self->_log_conflicts_param($change);
+
+ # Use the sqitch_array() constructor to insert arrays of values.
+ my $tag_ph = 'sqitch_array('. join(', ', ('?') x @{ $tags }) . ')';
+ my $req_ph = 'sqitch_array('. join(', ', ('?') x @{ $requires }) . ')';
+ my $con_ph = 'sqitch_array('. join(', ', ('?') x @{ $conflicts }) . ')';
+ my $ts = $self->_ts_default;
+
+ $dbh->do(qq{
+ INSERT INTO events (
+ event
+ , change_id
+ , change
+ , project
+ , note
+ , tags
+ , requires
+ , conflicts
+ , committer_name
+ , committer_email
+ , planned_at
+ , planner_name
+ , planner_email
+ , committed_at
+ )
+ VALUES (?, ?, ?, ?, ?, $tag_ph, $req_ph, $con_ph, ?, ?, ?, ?, ?, $ts)
+ }, undef,
+ $event,
+ $change->id,
+ $change->name,
+ $change->project,
+ $change->note,
+ @{ $tags },
+ @{ $requires },
+ @{ $conflicts },
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $self->_char2ts( $change->timestamp ),
+ $change->planner_name,
+ $change->planner_email,
+ );
+
+ return $self;
+}
+
+sub changes_requiring_change {
+ my ( $self, $change ) = @_;
+ # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
+ return @{ $self->dbh->selectall_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT c.change_id, c.project, c.change, t.tag AS asof_tag
+ FROM dependencies d
+ JOIN changes c ON c.change_id = d.change_id
+ LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
+ WHERE d.dependency_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, { Slice => {} }, $change->id) };
+}
+
+sub name_for_change_id {
+ my ( $self, $change_id ) = @_;
+ # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
+ return $self->dbh->selectcol_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT change || COALESCE(t.tag, '@HEAD')
+ FROM changes c
+ LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
+ WHERE change_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, undef, $change_id)->[0];
+}
+
+sub change_id_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the ID if there is no offset.
+ return $change_id unless $offset;
+
+ # Are we offset forwards or backwards?
+ my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' );
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT id FROM (
+ SELECT id, rownum AS rnum FROM (
+ SELECT change_id AS id
+ FROM changes
+ WHERE project = ?
+ AND committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ ORDER BY committed_at $dir
+ )
+ ) WHERE rnum = ?
+ }, undef, $self->plan->project, $change_id, abs $offset)->[0];
+}
+
+sub change_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the object if there is no offset.
+ return $self->load_change($change_id) unless $offset;
+
+ # Are we offset forwards or backwards?
+ my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' );
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+
+ my $change = $self->dbh->selectrow_hashref(qq{
+ SELECT id, name, project, note, timestamp, planner_name, planner_email, tags
+ FROM (
+ SELECT id, name, project, note, timestamp, planner_name, planner_email, tags, rownum AS rnum
+ FROM (
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS timestamp, c.planner_name, c.planner_email,
+ $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ AND c.committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
+ c.planner_name, c.planner_email, c.committed_at
+ ORDER BY c.committed_at $dir
+ )
+ ) WHERE rnum = ?
+ }, undef, $self->plan->project, $change_id, abs $offset) || return undef;
+ $change->{timestamp} = _dt $change->{timestamp};
+ return $change;
+}
+
+sub is_deployed_tag {
+ my ( $self, $tag ) = @_;
+ return $self->dbh->selectcol_arrayref(
+ 'SELECT 1 FROM tags WHERE tag_id = ?',
+ undef, $tag->id
+ )->[0];
+}
+
+sub are_deployed_changes {
+ my $self = shift;
+ my @qs;
+ my $i = @_;
+ while ($i > 250) {
+ push @qs => 'change_id IN (' . join(', ' => ('?') x 250) . ')';
+ $i -= 250;
+ }
+ push @qs => 'change_id IN (' . join(', ' => ('?') x @_) . ')';
+ my $expr = join ' OR ', @qs;
+ @{ $self->dbh->selectcol_arrayref(
+ "SELECT change_id FROM changes WHERE $expr",
+ undef,
+ map { $_->id } @_,
+ ) };
+}
+
+sub _registry_variable {
+ my $self = shift;
+ my $schema = $self->registry;
+ return $schema ? ("DEFINE registry=$schema") : (
+ # Select the current schema into &registry.
+ # https://www.orafaq.com/node/515
+ 'COLUMN sname for a30 new_value registry',
+ q{SELECT SYS_CONTEXT('USERENV', 'SESSION_SCHEMA') AS sname FROM DUAL;},
+ );
+}
+
+sub initialize {
+ my $self = shift;
+ my $schema = $self->registry;
+ hurl engine => __ 'Sqitch already initialized' if $self->initialized;
+
+ # Load up our database.
+ (my $file = file(__FILE__)->dir->file('oracle.sql')) =~ s/"/""/g;
+ $self->_run_with_verbosity($file);
+ $self->dbh->do("ALTER SESSION SET CURRENT_SCHEMA = $schema") if $schema;
+ $self->_register_release;
+}
+
+# Override for special handling of regular the expression operator and
+# LIMIT/OFFSET.
+sub search_events {
+ my ( $self, %p ) = @_;
+
+ # Determine order direction.
+ my $dir = 'DESC';
+ if (my $d = delete $p{direction}) {
+ $dir = $d =~ /^ASC/i ? 'ASC'
+ : $d =~ /^DESC/i ? 'DESC'
+ : hurl 'Search direction must be either "ASC" or "DESC"';
+ }
+
+ # Limit with regular expressions?
+ my (@wheres, @params);
+ for my $spec (
+ [ committer => 'committer_name' ],
+ [ planner => 'planner_name' ],
+ [ change => 'change' ],
+ [ project => 'project' ],
+ ) {
+ my $regex = delete $p{ $spec->[0] } // next;
+ push @wheres => "REGEXP_LIKE($spec->[1], ?)";
+ push @params => $regex;
+ }
+
+ # Match events?
+ if (my $e = delete $p{event} ) {
+ my ($in, @vals) = $self->_in_expr( $e );
+ push @wheres => "event $in";
+ push @params => @vals;
+ }
+
+ # Assemble the where clause.
+ my $where = @wheres
+ ? "\n WHERE " . join( "\n ", @wheres )
+ : '';
+
+ # Handle remaining parameters.
+ my ($lim, $off) = (delete $p{limit}, delete $p{offset});
+
+ hurl 'Invalid parameters passed to search_events(): '
+ . join ', ', sort keys %p if %p;
+
+ # Prepare, execute, and return.
+ my $cdtcol = sprintf $self->_ts2char_format, 'committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'planned_at';
+ my $sql = qq{
+ SELECT event
+ , project
+ , change_id
+ , change
+ , note
+ , requires
+ , conflicts
+ , tags
+ , committer_name
+ , committer_email
+ , $cdtcol AS committed_at
+ , planner_name
+ , planner_email
+ , $pdtcol AS planned_at
+ FROM events$where
+ ORDER BY events.committed_at $dir
+ };
+
+ if ($lim || $off) {
+ my @limits;
+ if ($lim) {
+ $off //= 0;
+ push @params => $lim + $off;
+ push @limits => 'rnum <= ?';
+ }
+ if ($off) {
+ push @params => $off;
+ push @limits => 'rnum > ?';
+ }
+
+ $sql = "SELECT * FROM ( SELECT ROWNUM AS rnum, i.* FROM ($sql) i ) WHERE "
+ . join ' AND ', @limits;
+ }
+
+ my $sth = $self->dbh->prepare($sql);
+ $sth->execute(@params);
+ return sub {
+ my $row = $sth->fetchrow_hashref or return;
+ delete $row->{rnum};
+ $row->{committed_at} = _dt $row->{committed_at};
+ $row->{planned_at} = _dt $row->{planned_at};
+ return $row;
+ };
+}
+
+# Override to lock the changes table. This ensures that only one instance of
+# Sqitch runs at one time.
+sub begin_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+
+ # Start transaction and lock changes to allow only one change at a time.
+ $dbh->begin_work;
+ $dbh->do('LOCK TABLE changes IN EXCLUSIVE MODE');
+ return $self;
+}
+
+sub _file_for_script {
+ my ($self, $file) = @_;
+
+ # Just use the file if no special character.
+ if ($file !~ /[@?%\$]/) {
+ $file =~ s/"/""/g;
+ return $file;
+ }
+
+ # Alias or copy the file to a temporary directory that's removed on exit.
+ (my $alias = $file->basename) =~ s/[@?%\$]/_/g;
+ $alias = $self->tmpdir->file($alias);
+
+ # Remove existing file.
+ if (-e $alias) {
+ $alias->remove or hurl oracle => __x(
+ 'Cannot remove {file}: {error}',
+ file => $alias,
+ error => $!
+ );
+ }
+
+ if (App::Sqitch::ISWIN) {
+ # Copy it.
+ $file->copy_to($alias) or hurl oracle => __x(
+ 'Cannot copy {file} to {alias}: {error}',
+ file => $file,
+ alias => $alias,
+ error => $!
+ );
+ } else {
+ # Symlink it.
+ $alias->remove;
+ symlink $file->absolute, $alias or hurl oracle => __x(
+ 'Cannot symlink {file} to {alias}: {error}',
+ file => $file,
+ alias => $alias,
+ error => $!
+ );
+ }
+
+ # Return the alias.
+ $alias =~ s/"/""/g;
+ return $alias;
+}
+
+sub run_file {
+ my $self = shift;
+ my $file = $self->_file_for_script(shift);
+ $self->_run(qq{\@"$file"});
+}
+
+sub _run_with_verbosity {
+ my $self = shift;
+ my $file = $self->_file_for_script(shift);
+ # Suppress STDOUT unless we want extra verbosity.
+ my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
+ $self->$meth(qq{\@"$file"});
+}
+
+sub run_upgrade { shift->_run_with_verbosity(@_) }
+sub run_verify { shift->_run_with_verbosity(@_) }
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ my $conn = $self->_script;
+ open my $tfh, '<:utf8_strict', \$conn;
+ $self->sqitch->spool( [$tfh, $fh], $self->sqlplus );
+}
+
+# Override to take advantage of the RETURNING expression, and to save tags as
+# an array rather than a space-delimited string.
+sub log_revert_change {
+ my ($self, $change) = @_;
+ my $dbh = $self->dbh;
+ my $cid = $change->id;
+
+ # Delete tags.
+ my $sth = $dbh->prepare(
+ 'DELETE FROM tags WHERE change_id = ? RETURNING tag INTO ?',
+ );
+ $sth->bind_param(1, $cid);
+ $sth->bind_param_inout_array(2, my $del_tags = [], 0, {
+ ora_type => DBD::Oracle::ORA_VARCHAR2()
+ });
+ $sth->execute;
+
+ # Retrieve dependencies.
+ my $depcol = sprintf $self->_listagg_format, 'dependency';
+ my ($req, $conf) = $dbh->selectrow_array(qq{
+ SELECT (
+ SELECT $depcol
+ FROM dependencies
+ WHERE change_id = ?
+ AND type = 'require'
+ ),
+ (
+ SELECT $depcol
+ FROM dependencies
+ WHERE change_id = ?
+ AND type = 'conflict'
+ ) FROM dual
+ }, undef, $cid, $cid);
+
+ # Delete the change record.
+ $dbh->do(
+ 'DELETE FROM changes where change_id = ?',
+ undef, $change->id,
+ );
+
+ # Log it.
+ return $self->_log_event( revert => $change, $del_tags, $req, $conf );
+}
+
+sub _no_table_error {
+ return $DBI::err && $DBI::err == 942; # ORA-00942: table or view does not exist
+}
+
+sub _no_column_error {
+ return $DBI::err && $DBI::err == 904; # ORA-00904: invalid identifier
+}
+
+sub _script {
+ my $self = shift;
+ my $uri = $self->uri;
+ my $conn = '';
+ my ($user, $pass, $host, $port) = (
+ $self->username, $self->password, $uri->host, $uri->_port
+ );
+ if ($user || $pass || $host || $port) {
+ $conn = $user // '';
+ if ($pass) {
+ $pass =~ s/"/""/g;
+ $conn .= qq{/"$pass"};
+ }
+ if (my $db = $uri->dbname) {
+ $conn .= '@';
+ $db =~ s/"/""/g;
+ if ($host || $port) {
+ $conn .= '//' . ($host || '');
+ if ($port) {
+ $conn .= ":$port";
+ }
+ $conn .= qq{/"$db"};
+ } else {
+ $conn .= qq{"$db"};
+ }
+ }
+ } else {
+ # OS authentication or Oracle wallet (no username or password).
+ if (my $db = $uri->dbname) {
+ $db =~ s/"/""/g;
+ $conn = qq{/@"$db"};
+ }
+ }
+ my %vars = $self->variables;
+
+ return join "\n" => (
+ 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT SQL.SQLCODE;',
+ (map {; (my $v = $vars{$_}) =~ s/"/""/g; qq{DEFINE $_="$v"} } sort keys %vars),
+ "connect $conn",
+ $self->_registry_variable,
+ @_
+ );
+}
+
+sub _run {
+ my $self = shift;
+ my $script = $self->_script(@_);
+ open my $fh, '<:utf8_strict', \$script;
+ return $self->sqitch->spool( $fh, $self->sqlplus );
+}
+
+sub _capture {
+ my $self = shift;
+ my $conn = $self->_script(@_);
+ my @out;
+
+ require IPC::Run3;
+ IPC::Run3::run3(
+ [$self->sqlplus], \$conn, \@out, \@out,
+ { return_if_system_error => 1 },
+ );
+ if (my $err = $?) {
+ # Ugh, send everything to STDERR.
+ $self->sqitch->vent(@out);
+ hurl io => __x(
+ '{command} unexpectedly returned exit value {exitval}',
+ command => $self->client,
+ exitval => ($err >> 8),
+ );
+ }
+
+ return wantarray ? @out : \@out;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine::oracle - Sqitch Oracle Engine
+
+=head1 Synopsis
+
+ my $oracle = App::Sqitch::Engine->load( engine => 'oracle' );
+
+=head1 Description
+
+App::Sqitch::Engine::oracle provides the Oracle storage engine for Sqitch. It
+supports Oracle 10g and higher.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<initialized>
+
+ $oracle->initialize unless $oracle->initialized;
+
+Returns true if the database has been initialized for Sqitch, and false if it
+has not.
+
+=head3 C<initialize>
+
+ $oracle->initialize;
+
+Initializes a database for Sqitch by installing the Sqitch registry schema.
+
+=head3 C<sqlplus>
+
+Returns a list containing the C<sqlplus> client and options to be passed to it.
+Used internally when executing scripts.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/oracle.sql b/lib/App/Sqitch/Engine/oracle.sql
new file mode 100644
index 00000000..2c3d2cf6
--- /dev/null
+++ b/lib/App/Sqitch/Engine/oracle.sql
@@ -0,0 +1,142 @@
+CREATE TABLE &registry..releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ installer_name VARCHAR2(512 CHAR) NOT NULL,
+ installer_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry..releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry..releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry..releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry..releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE &registry..projects (
+ project VARCHAR2(512 CHAR) PRIMARY KEY,
+ uri VARCHAR2(512 CHAR) NULL UNIQUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ creator_name VARCHAR2(512 CHAR) NOT NULL,
+ creator_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN &registry..projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN &registry..projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN &registry..projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN &registry..projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN &registry..projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE &registry..changes (
+ change_id CHAR(40) PRIMARY KEY,
+ script_hash CHAR(40) NULL,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL,
+ UNIQUE(project, script_hash)
+);
+
+COMMENT ON TABLE &registry..changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN &registry..changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN &registry..changes.script_hash IS 'Deploy script SHA-1 hash.';
+COMMENT ON COLUMN &registry..changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN &registry..changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..changes.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN &registry..changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN &registry..changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE &registry..tags (
+ tag_id CHAR(40) PRIMARY KEY,
+ tag VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL,
+ UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE &registry..tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN &registry..tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN &registry..tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN &registry..tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN &registry..tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN &registry..tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN &registry..tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN &registry..tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN &registry..tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN &registry..tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE &registry..dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id) ON DELETE CASCADE,
+ type VARCHAR2(8) NOT NULL,
+ dependency VARCHAR2(1024 CHAR) NOT NULL,
+ dependency_id CHAR(40) NULL REFERENCES &registry..changes(change_id),
+ CONSTRAINT dependencies_check CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE &registry..dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN &registry..dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN &registry..dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN &registry..dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN &registry..dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TYPE &registry..sqitch_array AS varray(1024) OF VARCHAR2(512);
+/
+
+CREATE TABLE &registry..events (
+ event VARCHAR2(6) NOT NULL
+ CONSTRAINT events_event_check CHECK (
+ event IN ('deploy', 'revert', 'fail', 'merge')
+ ),
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ requires &registry..SQITCH_ARRAY DEFAULT &registry..SQITCH_ARRAY() NOT NULL,
+ conflicts &registry..SQITCH_ARRAY DEFAULT &registry..SQITCH_ARRAY() NOT NULL,
+ tags &registry..SQITCH_ARRAY DEFAULT &registry..SQITCH_ARRAY() NOT NULL,
+ committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+CREATE UNIQUE INDEX &registry..events_pkey ON &registry..events(change_id, committed_at);
+
+COMMENT ON TABLE &registry..events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN &registry..events.event IS 'Type of event.';
+COMMENT ON COLUMN &registry..events.change_id IS 'Change ID.';
+COMMENT ON COLUMN &registry..events.change IS 'Change name.';
+COMMENT ON COLUMN &registry..events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..events.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..events.requires IS 'Array of the names of required changes.';
+COMMENT ON COLUMN &registry..events.conflicts IS 'Array of the names of conflicting changes.';
+COMMENT ON COLUMN &registry..events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN &registry..events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN &registry..events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN &registry..events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..events.planner_email IS 'Email address of the user who plan planned the change.';
diff --git a/lib/App/Sqitch/Engine/pg.pm b/lib/App/Sqitch/Engine/pg.pm
new file mode 100644
index 00000000..3753df0e
--- /dev/null
+++ b/lib/App/Sqitch/Engine/pg.pm
@@ -0,0 +1,505 @@
+package App::Sqitch::Engine::pg;
+
+use 5.010;
+use Moo;
+use utf8;
+use Path::Class;
+use DBI;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Plan::Change;
+use List::Util qw(first);
+use App::Sqitch::Types qw(DBH ArrayRef);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub destination {
+ my $self = shift;
+
+ # Just use the target name if it doesn't look like a URI or if the URI
+ # includes the database name.
+ return $self->target->name if $self->target->name !~ /:/
+ || $self->target->uri->dbname;
+
+ # Use the URI sans password, and with the database name added.
+ my $uri = $self->target->uri->clone;
+ $uri->password(undef) if $uri->password;
+ $uri->dbname(
+ $ENV{PGDATABASE}
+ || $self->username
+ || $ENV{PGUSER}
+ || $self->sqitch->sysuser
+ );
+ return $uri->as_string;
+}
+
+# DBD::pg and psql use fallbacks consistently, thanks to libpq. These include
+# environment variables, system info (username), the password file, and the
+# connection service file. Best for us not to second-guess these values,
+# though we admittedly try when setting the database name in the destination
+# URI for unnamed targets a few lines up from here.
+sub _def_user { }
+sub _def_pass { }
+
+has _psql => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+ my @ret = ( $self->client );
+
+ my %query_params = $uri->query_params;
+ my @conninfo;
+ for my $spec (
+ [ user => $self->username ],
+ [ dbname => $uri->dbname ],
+ [ host => $uri->host ],
+ [ port => $uri->_port ],
+ map { [ $_ => $query_params{$_} ] }
+ sort keys %query_params,
+ ) {
+ next unless defined $spec->[1] && length $spec->[1];
+ if ($spec->[1] =~ /[ "'\\]/) {
+ $spec->[1] =~ s/([ "'\\])/\\$1/g;
+ }
+ push @conninfo, "$spec->[0]=$spec->[1]";
+ }
+
+ push @ret => '--dbname', join ' ', @conninfo if @conninfo;
+
+ if (my %vars = $self->variables) {
+ push @ret => map {; '--set', "$_=$vars{$_}" } sort keys %vars;
+ }
+
+ push @ret => $self->_client_opts;
+ return \@ret;
+ },
+);
+
+sub _client_opts {
+ my $self = shift;
+ return (
+ '--quiet',
+ '--no-psqlrc',
+ '--no-align',
+ '--tuples-only',
+ '--set' => 'ON_ERROR_STOP=1',
+ '--set' => 'registry=' . $self->registry,
+ );
+}
+
+sub psql { @{ shift->_psql } }
+
+sub key { 'pg' }
+sub name { 'PostgreSQL' }
+sub driver { 'DBD::Pg 2.0' }
+sub default_client { 'psql' }
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->use_driver;
+
+ my $uri = $self->uri;
+ local $ENV{PGCLIENTENCODING} = 'UTF8';
+ DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ pg_enable_utf8 => 1,
+ pg_server_prepare => 1,
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ Callbacks => {
+ connected => sub {
+ my $dbh = shift;
+ $dbh->do('SET client_min_messages = WARNING');
+ try {
+ $dbh->do(
+ 'SET search_path = ?',
+ undef, $self->registry
+ );
+ # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html
+ $dbh->set_err(undef, undef) if $dbh->err;
+ };
+ return;
+ },
+ },
+ });
+ }
+);
+
+# Need to wait until dbh is defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+sub _log_tags_param {
+ [ map { $_->format_name } $_[1]->tags ];
+}
+
+sub _log_requires_param {
+ [ map { $_->as_string } $_[1]->requires ];
+}
+
+sub _log_conflicts_param {
+ [ map { $_->as_string } $_[1]->conflicts ];
+}
+
+sub _ts2char_format {
+ q{to_char(%s AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')};
+}
+
+sub _ts_default { 'clock_timestamp()' }
+
+sub _char2ts { $_[1]->as_string(format => 'iso') }
+
+sub _listagg_format {
+ q{ARRAY(SELECT * FROM UNNEST( array_agg(%s) ) a WHERE a IS NOT NULL)}
+}
+
+sub _regex_op { '~' }
+
+sub _version_query { 'SELECT MAX(version)::TEXT FROM releases' }
+
+sub initialized {
+ my $self = shift;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT EXISTS(
+ SELECT TRUE FROM pg_catalog.pg_tables
+ WHERE schemaname = ? AND tablename = ?
+ )
+ }, undef, $self->registry, 'changes')->[0];
+}
+
+sub initialize {
+ my $self = shift;
+ hurl engine => __x(
+ 'Sqitch schema "{schema}" already exists',
+ schema => $self->registry
+ ) if $self->initialized;
+ $self->_run_registry_file( file(__FILE__)->dir->file('pg.sql') );
+ $self->_register_release;
+}
+
+sub _psql_major_version {
+ my $self = shift;
+ my $psql_version = $self->sqitch->probe($self->client, '--version');
+ my @parts = split /\s+/, $psql_version;
+ my ($maj) = $parts[-1] =~ /^(\d+)/;
+ return $maj || 0;
+}
+
+sub _run_registry_file {
+ my ($self, $file) = @_;
+ my $schema = $self->registry;
+
+ # Fetch the client version. 8.4 == 80400
+ my $version = $self->_probe('-c', 'SHOW server_version_num');
+ my $psql_maj = $self->_psql_major_version;
+
+ # Is this XC?
+ my $opts = $self->_probe('-c', q{
+ SELECT count(*)
+ FROM pg_catalog.pg_proc p
+ JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid
+ WHERE nspname = 'pg_catalog'
+ AND proname = 'pgxc_version';
+ }) ? ' DISTRIBUTE BY REPLICATION' : '';
+
+ if ($version < 90300 || $psql_maj < 9) {
+ # Need to transform the SQL and write it to a temp file.
+ my $sql = scalar $file->slurp;
+
+ # No CREATE SCHEMA IF NOT EXISTS syntax prior to 9.3.
+ $sql =~ s/SCHEMA IF NOT EXISTS/SCHEMA/ if $version < 90300;
+ if ($psql_maj < 9) {
+ # Also no :"registry" variable syntax prior to psql 9.0.s
+ ($schema) = $self->dbh->selectrow_array(
+ 'SELECT quote_ident(?)', undef, $schema
+ );
+ $sql =~ s{:"registry"}{$schema}g;
+ }
+ require File::Temp;
+ my $fh = File::Temp->new;
+ print $fh $sql;
+ close $fh;
+ $self->_run(
+ '--file' => $fh->filename,
+ '--set' => "tableopts=$opts",
+ );
+ } else {
+ # We can take advantage of the :"registry" variable syntax.
+ $self->_run(
+ '--file' => $file,
+ '--set' => "registry=$schema",
+ '--set' => "tableopts=$opts",
+ );
+ }
+
+ $self->dbh->do('SET search_path = ?', undef, $schema);
+}
+
+# Override to lock the changes table. This ensures that only one instance of
+# Sqitch runs at one time.
+sub begin_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+
+ # Start transaction and lock changes to allow only one change at a time.
+ $dbh->begin_work;
+ $dbh->do('LOCK TABLE changes IN EXCLUSIVE MODE');
+ return $self;
+}
+
+sub run_file {
+ my ($self, $file) = @_;
+ $self->_run('--file' => $file);
+}
+
+sub run_verify {
+ my $self = shift;
+ # Suppress STDOUT unless we want extra verbosity.
+ my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
+ return $self->$meth('--file' => @_);
+}
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ $self->_spool($fh);
+}
+
+sub run_upgrade {
+ shift->_run_registry_file(@_);
+}
+
+# Override to avoid cast errors, and to use VALUES instead of a UNION query.
+sub log_new_tags {
+ my ( $self, $change ) = @_;
+ my @tags = $change->tags or return $self;
+ my $sqitch = $self->sqitch;
+
+ my ($id, $name, $proj, $user, $email) = (
+ $change->id,
+ $change->format_name,
+ $change->project,
+ $sqitch->user_name,
+ $sqitch->user_email
+ );
+
+ $self->dbh->do(
+ q{
+ INSERT INTO tags (
+ tag_id
+ , tag
+ , project
+ , change_id
+ , note
+ , committer_name
+ , committer_email
+ , planned_at
+ , planner_name
+ , planner_email
+ )
+ SELECT tid, tg, proj, chid, n, name, email, at, pname, pemail FROM ( VALUES
+ } . join( ",\n ", ( q{(?, ?, ?, ?, ?, ?, ?, ?::timestamptz, ?, ?)} ) x @tags )
+ . q{
+ ) i(tid, tg, proj, chid, n, name, email, at, pname, pemail)
+ LEFT JOIN tags ON i.tid = tags.tag_id
+ WHERE tags.tag_id IS NULL
+ },
+ undef,
+ map { (
+ $_->id,
+ $_->format_name,
+ $proj,
+ $id,
+ $_->note,
+ $user,
+ $email,
+ $_->timestamp->as_string(format => 'iso'),
+ $_->planner_name,
+ $_->planner_email,
+ ) } @tags
+ );
+
+ return $self;
+}
+
+# Override to take advantage of the RETURNING expression, and to save tags as
+# an array rather than a space-delimited string.
+sub log_revert_change {
+ my ($self, $change) = @_;
+ my $dbh = $self->dbh;
+
+ # Delete tags.
+ my $del_tags = $dbh->selectcol_arrayref(
+ 'DELETE FROM tags WHERE change_id = ? RETURNING tag',
+ undef, $change->id
+ ) || [];
+
+ # Retrieve dependencies.
+ my ($req, $conf) = $dbh->selectrow_array(q{
+ SELECT ARRAY(
+ SELECT dependency
+ FROM dependencies
+ WHERE change_id = $1
+ AND type = 'require'
+ ), ARRAY(
+ SELECT dependency
+ FROM dependencies
+ WHERE change_id = $1
+ AND type = 'conflict'
+ )
+ }, undef, $change->id);
+
+ # Delete the change record.
+ $dbh->do(
+ 'DELETE FROM changes where change_id = ?',
+ undef, $change->id,
+ );
+
+ # Log it.
+ return $self->_log_event( revert => $change, $del_tags, $req, $conf );
+}
+
+sub _dt($) {
+ require App::Sqitch::DateTime;
+ return App::Sqitch::DateTime->new(split /:/ => shift);
+}
+
+sub _no_table_error {
+ return 0 unless $DBI::state && $DBI::state eq '42P01'; # undefined_table
+ my $dbh = shift->dbh;
+ my @msg = map { $dbh->quote($_) } (
+ __ 'Sqitch registry not initialized',
+ __ 'Because the "changes" table does not exist, Sqitch will now initialize the database to create its registry tables.',
+ );
+ $dbh->do(sprintf q{DO $$
+ BEGIN
+ SET LOCAL client_min_messages = 'ERROR';
+ RAISE WARNING USING ERRCODE = 'undefined_table', MESSAGE = %s, DETAIL = %s;
+ END;
+ $$}, @msg) if $dbh->{pg_server_version} >= 90000;
+ return 1;
+}
+
+sub _no_column_error {
+ return $DBI::state && $DBI::state eq '42703'; # undefined_column
+}
+
+sub _in_expr {
+ my ($self, $vals) = @_;
+ return '= ANY(?)', $vals;
+}
+
+sub _run {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->run( $self->psql, @_ );
+ local $ENV{PGPASSWORD} = $pass;
+ return $sqitch->run( $self->psql, @_ );
+}
+
+sub _capture {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->capture( $self->psql, @_ );
+ local $ENV{PGPASSWORD} = $pass;
+ return $sqitch->capture( $self->psql, @_ );
+}
+
+sub _probe {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->probe( $self->psql, @_ );
+ local $ENV{PGPASSWORD} = $pass;
+ return $sqitch->probe( $self->psql, @_ );
+}
+
+sub _spool {
+ my $self = shift;
+ my $fh = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->spool( $fh, $self->psql, @_ );
+ local $ENV{PGPASSWORD} = $pass;
+ return $sqitch->spool( $fh, $self->psql, @_ );
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine::pg - Sqitch PostgreSQL Engine
+
+=head1 Synopsis
+
+ my $pg = App::Sqitch::Engine->load( engine => 'pg' );
+
+=head1 Description
+
+App::Sqitch::Engine::pg provides the PostgreSQL storage engine for Sqitch. It
+supports PostgreSQL 8.4.0 and higher as well as Postgres-XC 1.2 and higher.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<initialized>
+
+ $pg->initialize unless $pg->initialized;
+
+Returns true if the database has been initialized for Sqitch, and false if it
+has not.
+
+=head3 C<initialize>
+
+ $pg->initialize;
+
+Initializes a database for Sqitch by installing the Sqitch registry schema.
+
+=head3 C<psql>
+
+Returns a list containing the C<psql> client and options to be passed to it.
+Used internally when executing scripts.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/pg.sql b/lib/App/Sqitch/Engine/pg.sql
new file mode 100644
index 00000000..87a0375a
--- /dev/null
+++ b/lib/App/Sqitch/Engine/pg.sql
@@ -0,0 +1,145 @@
+BEGIN;
+
+SET client_min_messages = warning;
+CREATE SCHEMA IF NOT EXISTS :"registry";
+
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.';
+
+CREATE TABLE :"registry".releases (
+ version REAL PRIMARY KEY,
+ installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN :"registry".releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN :"registry".releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN :"registry".releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN :"registry".releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE :"registry".projects (
+ project TEXT PRIMARY KEY,
+ uri TEXT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ creator_name TEXT NOT NULL,
+ creator_email TEXT NOT NULL
+):tableopts;
+
+COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN :"registry".projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN :"registry".projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN :"registry".projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN :"registry".projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN :"registry".projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE :"registry".changes (
+ change_id TEXT PRIMARY KEY,
+ script_hash TEXT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, script_hash)
+):tableopts;
+
+COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN :"registry".changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN :"registry".changes.script_hash IS 'Deploy script SHA-1 hash.';
+COMMENT ON COLUMN :"registry".changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN :"registry".changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN :"registry".changes.note IS 'Description of the change.';
+COMMENT ON COLUMN :"registry".changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN :"registry".changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN :"registry".changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN :"registry".changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN :"registry".changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN :"registry".changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE :"registry".tags (
+ tag_id TEXT PRIMARY KEY,
+ tag TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE,
+ change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, tag)
+):tableopts;
+
+COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN :"registry".tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN :"registry".tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN :"registry".tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN :"registry".tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN :"registry".tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN :"registry".tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN :"registry".tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN :"registry".tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN :"registry".tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN :"registry".tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN :"registry".tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE :"registry".dependencies (
+ change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ dependency TEXT NOT NULL,
+ dependency_id TEXT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE CONSTRAINT dependencies_check CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+):tableopts;
+
+COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN :"registry".dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN :"registry".dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN :"registry".dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN :"registry".dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TABLE :"registry".events (
+ event TEXT NOT NULL CONSTRAINT events_event_check CHECK (
+ event IN ('deploy', 'revert', 'fail', 'merge')
+ ),
+ change_id TEXT NOT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ requires TEXT[] NOT NULL DEFAULT '{}',
+ conflicts TEXT[] NOT NULL DEFAULT '{}',
+ tags TEXT[] NOT NULL DEFAULT '{}',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+):tableopts;
+
+COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN :"registry".events.event IS 'Type of event.';
+COMMENT ON COLUMN :"registry".events.change_id IS 'Change ID.';
+COMMENT ON COLUMN :"registry".events.change IS 'Change name.';
+COMMENT ON COLUMN :"registry".events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN :"registry".events.note IS 'Description of the change.';
+COMMENT ON COLUMN :"registry".events.requires IS 'Array of the names of required changes.';
+COMMENT ON COLUMN :"registry".events.conflicts IS 'Array of the names of conflicting changes.';
+COMMENT ON COLUMN :"registry".events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN :"registry".events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN :"registry".events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN :"registry".events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN :"registry".events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN :"registry".events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN :"registry".events.planner_email IS 'Email address of the user who plan planned the change.';
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/snowflake.pm b/lib/App/Sqitch/Engine/snowflake.pm
new file mode 100644
index 00000000..758423ea
--- /dev/null
+++ b/lib/App/Sqitch/Engine/snowflake.pm
@@ -0,0 +1,724 @@
+package App::Sqitch::Engine::snowflake;
+
+use 5.010;
+use Moo;
+use utf8;
+use Path::Class;
+use DBI;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Types qw(DBH ArrayRef HashRef URIDB Str);
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub key { 'snowflake' }
+sub name { 'Snowflake' }
+sub driver { 'DBD::ODBC 1.59' }
+sub default_client { 'snowsql' }
+
+sub destination {
+ my $self = shift;
+ # Just use the target name if it doesn't look like a URI.
+ return $self->target->name if $self->target->name !~ /:/;
+
+ # Use the URI sans password.
+ my $uri = $self->target->uri->clone;
+ $uri->password(undef) if $uri->password;
+ return $uri->as_string;
+}
+
+has _snowsql => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+ my @ret = ( $self->client );
+ for my $spec (
+ [ accountname => $self->account ],
+ [ username => $self->username ],
+ [ dbname => $uri->dbname ],
+ [ rolename => $self->role ],
+ ) {
+ push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1];
+ }
+
+ if (my %vars = $self->variables) {
+ push @ret => map {; '--variable', "$_=$vars{$_}" } sort keys %vars;
+ }
+
+ push @ret => $self->_client_opts;
+ return \@ret;
+ },
+);
+
+sub snowsql { @{ shift->_snowsql } }
+
+has _snowcfg => (
+ is => 'rw',
+ isa => HashRef,
+ lazy => 1,
+ default => sub {
+ my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0];
+ return {} if not $hd;
+ my $fn = dir $hd, '.snowsql', 'config';
+ return {} unless -e $fn;
+ my $data = App::Sqitch::Config->new->load_file($fn);
+ my $cfg = {};
+ for my $k (keys %{ $data }) {
+ # We only want the default connections config. No named config.
+ # (For now, anyway; maybe use database as config name laster?)
+ next unless $k =~ /\Aconnections[.]([^.]+)\z/;
+ my $key = $1;
+ my $val = $data->{$k};
+ # Apparently snowsql config supports single quotes, while
+ # Config::GitLike does not.
+ # https://support.snowflake.net/s/case/5000Z000010xUYJQA2
+ # https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file
+ if ($val =~ s/\A'//) {
+ $val = $data->{$k} unless $val =~ s/'\z//;
+ }
+ $cfg->{$key} = $val;
+ }
+ return $cfg;
+ },
+);
+
+has uri => (
+ is => 'ro',
+ isa => URIDB,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->SUPER::uri;
+
+ # Set defaults in the URI.
+ $uri->host($self->_host($uri));
+ $uri->port($ENV{SNOWSQL_PORT}) if !$uri->_port && $ENV{SNOWSQL_PORT};
+ $uri->dbname(
+ $ENV{SNOWSQL_DATABASE}
+ || $self->_snowcfg->{dbname}
+ || $self->username
+ ) if !$uri->dbname;
+ return $uri;
+ },
+);
+
+sub _def_user {
+ $ENV{SNOWSQL_USER} || $_[0]->_snowcfg->{username} || $_[0]->sqitch->sysuser
+}
+
+sub _def_pass { $ENV{SNOWSQL_PWD} || shift->_snowcfg->{password} }
+sub _def_acct {
+ return $ENV{SNOWSQL_ACCOUNT} || shift->_snowcfg->{accountname}
+ || hurl engine => __('Cannot determine Snowflake account name');
+}
+
+has account => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ if (my $host = $self->uri->host) {
+ # <account_name>.<region_id>.snowflakecomputing.com
+ $host =~ s/[.].+//;
+ return $host;
+ }
+ return $self->_def_acct;
+ },
+);
+
+sub _host {
+ my ($self, $uri) = @_;
+ if (my $host = $uri->host) {
+ # Allow host to just be account name or account + region.
+ return $host if $host =~ /\.snowflakecomputing\.com$/;
+ return $host . ".snowflakecomputing.com";
+ }
+ return $ENV{SNOWSQL_HOST} if $ENV{SNOWSQL_HOST};
+ return join '.', (
+ $self->_def_acct,
+ (grep { $_ } $ENV{SNOWSQL_REGION} || $self->_snowcfg->{region} || ()),
+ 'snowflakecomputing.com',
+ );
+}
+
+has warehouse => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+ require URI::QueryParam;
+ $uri->query_param('warehouse')
+ || $ENV{SNOWSQL_WAREHOUSE}
+ || $self->_snowcfg->{warehousename}
+ || 'sqitch';
+ },
+);
+
+has role => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+ require URI::QueryParam;
+ $uri->query_param('role')
+ || $ENV{SNOWSQL_ROLE}
+ || $self->_snowcfg->{rolename}
+ || '';
+ },
+);
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->use_driver;
+ my $uri = $self->uri;
+ my $wh = $self->warehouse;
+ my $role = $self->role;
+ DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ odbc_utf8_on => 1,
+ FetchHashKeyName => 'NAME_lc',
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ Callbacks => {
+ connected => sub {
+ my $dbh = shift;
+ try {
+ $dbh->do($_) for (
+ ($role ? ("USE ROLE $role") : ()),
+ "ALTER WAREHOUSE $wh RESUME IF SUSPENDED",
+ "USE WAREHOUSE $wh",
+ 'USE SCHEMA ' . $self->registry,
+ 'ALTER SESSION SET TIMESTAMP_TYPE_MAPPING=TIMESTAMP_LTZ',
+ "ALTER SESSION SET TIMESTAMP_OUTPUT_FORMAT='YYYY-MM-DD HH24:MI:SS'",
+ "ALTER SESSION SET TIMEZONE='UTC'",
+ );
+ $dbh->set_err(undef, undef) if $dbh->err;
+ };
+ return;
+ },
+ disconnect => sub {
+ shift->do("ALTER WAREHOUSE $wh SUSPEND");
+ return;
+ },
+ },
+ });
+ }
+);
+
+# Need to wait until dbh is defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+sub _client_opts {
+ return (
+ '--noup',
+ '--option' => 'auto_completion=false',
+ '--option' => 'echo=false',
+ '--option' => 'execution_only=false',
+ '--option' => 'friendly=false',
+ '--option' => 'header=false',
+ '--option' => 'exit_on_error=true',
+ '--option' => 'stop_on_error=true',
+ '--option' => 'output_format=csv',
+ '--option' => 'paging=false',
+ '--option' => 'timing=false',
+ # results=false suppresses errors! Bug report:
+ # https://support.snowflake.net/s/case/5000Z000010wm6BQAQ/
+ '--option' => 'results=true',
+ '--option' => 'wrap=false',
+ '--option' => 'rowset_size=1000',
+ '--option' => 'syntax_style=default',
+ '--option' => 'variable_substitution=true',
+ '--variable' => 'registry=' . $_[0]->registry,
+ '--variable' => 'warehouse=' . $_[0]->warehouse,
+ );
+}
+
+sub _quiet_opts {
+ return (
+ '--option' => 'quiet=true',
+ );
+}
+
+sub _verbose_opts {
+ return (
+ '--option' => 'quiet=false',
+ );
+}
+
+# Not using arrays, but delimited strings that are the default in
+# App::Sqitch::Role::DBIEngine, because:
+# * There is currently no literal syntax for arrays
+# https://support.snowflake.net/s/case/5000Z000010wXBRQA2/
+# * Scalar variables like the array constructor can't be used in WHERE clauses
+# https://support.snowflake.net/s/case/5000Z000010wX7yQAE/
+sub _listagg_format {
+ return q{listagg(%s, ' ')};
+}
+
+sub _ts_default { 'current_timestamp' }
+
+sub initialized {
+ my $self = shift;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT true
+ FROM information_schema.tables
+ WHERE TABLE_CATALOG = current_database()
+ AND TABLE_SCHEMA = UPPER(?)
+ AND TABLE_NAME = UPPER(?)
+ }, undef, $self->registry, 'changes')->[0];
+}
+
+sub initialize {
+ my $self = shift;
+ my $schema = $self->registry;
+ hurl engine => __x(
+ 'Sqitch schema "{schema}" already exists',
+ schema => $schema
+ ) if $self->initialized;
+
+ $self->run_file( file(__FILE__)->dir->file('snowflake.sql') );
+ $self->dbh->do("USE SCHEMA $schema");
+ $self->_register_release;
+}
+
+sub _no_table_error {
+ return $DBI::state && $DBI::state eq '42S02'; # ERRCODE_UNDEFINED_TABLE
+}
+
+sub _no_column_error {
+ return $DBI::state && $DBI::state eq '42703'; # ERRCODE_UNDEFINED_COLUMN
+}
+
+sub _ts2char_format {
+ # The colon has to be inside the quotation marks, because otherwise it
+ # generates wayward single quotation marks. Bug report:
+ # https://support.snowflake.net/s/case/5000Z000010wTkKQAU/
+ qq{to_varchar(CONVERT_TIMEZONE('UTC', %s), '"year:"YYYY":month:"MM":day:"DD":hour:"HH24":minute:"MI":second:"SS":time_zone:UTC"')};
+}
+
+sub _char2ts { $_[1]->as_string(format => 'iso') }
+
+sub _dt($) {
+ require App::Sqitch::DateTime;
+ return App::Sqitch::DateTime->new(split /:/ => shift);
+}
+
+sub _regex_op { 'REGEXP' } # XXX But not used; see regex_expr() below.
+
+sub _simple_from { ' FROM dual' }
+
+sub _cid {
+ my ( $self, $ord, $offset, $project ) = @_;
+
+ my $offset_expr = $offset ? " OFFSET $offset" : '';
+ return try {
+ $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ ORDER BY committed_at $ord
+ LIMIT 1$offset_expr
+ }, undef, $project || $self->plan->project)->[0];
+ } catch {
+ return if $self->_no_table_error && !$self->initialized;
+ die $_;
+ };
+}
+
+sub changes_requiring_change {
+ my ( $self, $change ) = @_;
+ # NOTE: Query from DBIEngine doesn't work in Snowflake:
+ # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601)
+ # Looks like it doesn't yet support correlated subqueries.
+ # https://docs.snowflake.net/manuals/sql-reference/operators-subquery.html
+ # The CTE-based query borrowed from Exasol seems to be fine, however.
+ return @{ $self->dbh->selectall_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT c.change_id, c.project, c.change, t.tag AS asof_tag
+ FROM dependencies d
+ JOIN changes c ON c.change_id = d.change_id
+ LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
+ WHERE d.dependency_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, { Slice => {} }, $change->id) };
+}
+
+sub name_for_change_id {
+ my ( $self, $change_id ) = @_;
+ # NOTE: Query from DBIEngine doesn't work in Snowflake:
+ # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601)
+ # Looks like it doesn't yet support correlated subqueries.
+ # https://docs.snowflake.net/manuals/sql-reference/operators-subquery.html
+ # The CTE-based query borrowed from Exasol seems to be fine, however.
+ return $self->dbh->selectcol_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT change || COALESCE(t.tag, '@HEAD')
+ FROM changes c
+ LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
+ WHERE change_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, undef, $change_id)->[0];
+}
+
+# https://support.snowflake.net/s/question/0D50Z00008BENO5SAP
+sub _limit_default { '4611686018427387903' }
+
+sub _limit_offset {
+ # LIMIT/OFFSET don't support parameters, alas. So just put them in the query.
+ my ($self, $lim, $off) = @_;
+ # OFFSET cannot be used without LIMIT, sadly.
+ # https://support.snowflake.net/s/case/5000Z000010wfnWQAQ
+ return ['LIMIT ' . ($lim || $self->_limit_default), "OFFSET $off"], [] if $off;
+ return ["LIMIT $lim"], [] if $lim;
+ return [], [];
+}
+
+sub _regex_expr {
+ my ( $self, $col, $regex ) = @_;
+ # Snowflake regular expressions are implicitly anchored to match the
+ # entire string. To work around this, issue, we use regexp_substr(), which
+ # is not so anchored, and just check to see that if it returns a string.
+ # https://support.snowflake.net/s/case/5000Z000010wbUSQAY
+ # https://support.snowflake.net/s/question/0D50Z00008C90beSAB/
+ return "regexp_substr($col, ?) IS NOT NULL", $regex;
+}
+
+sub run_file {
+ my ($self, $file) = @_;
+ $self->_run(_quiet_opts, '--filename' => $file);
+}
+
+sub run_verify {
+ my ($self, $file) = @_;
+ # Suppress STDOUT unless we want extra verbosity.
+ return $self->run_file($file) unless $self->sqitch->verbosity > 1;
+ $self->_run(_verbose_opts, '--filename' => $file);
+}
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ $self->_spool($fh);
+}
+
+sub _run {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or
+ # Use capture and emit instead of _run to avoid a wayward newline in
+ # the output.
+ return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) );
+ # Does not override connection config, alas.
+ local $ENV{SNOWSQL_PWD} = $pass;
+ return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) );
+}
+
+sub _capture {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or
+ return $sqitch->capture( $self->snowsql, _verbose_opts, @_ );
+ local $ENV{SNOWSQL_PWD} = $pass;
+ return $sqitch->capture( $self->snowsql, _verbose_opts, @_ );
+}
+
+sub _probe {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or
+ return $sqitch->probe( $self->snowsql, _verbose_opts, @_ );
+ local $ENV{SNOWSQL_PWD} = $pass;
+ return $sqitch->probe( $self->snowsql, _verbose_opts, @_ );
+}
+
+sub _spool {
+ my $self = shift;
+ my $fh = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or
+ return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ );
+ local $ENV{SNOWSQL_PWD} = $pass;
+ return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ );
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine::snowflake - Sqitch Snowflake Engine
+
+=head1 Synopsis
+
+ my $snowflake = App::Sqitch::Engine->load( engine => 'snowflake' );
+
+=head1 Description
+
+App::Sqitch::Engine::snowflake provides the Snowflake storage engine for Sqitch.
+
+=head1 Interface
+
+=head2 Attributes
+
+=head3 C<uri>
+
+Returns the Snowflake database URI name. It starts with the URI for the target
+and builds out missing parts. Sqitch looks for the host name in this order:
+
+=over
+
+=item 1
+
+In the host name of the target URI. If that host name does not end in
+C<snowflakecomputing.com>, Sqitch appends it. This lets Snowflake URLs just
+reference the Snowflake account name or the account name and region in URLs.
+
+=item 2
+
+In the C<$SNOWSQL_HOST> environment variable.
+
+=item 3
+
+By concatenating the account name and region, if available, from the
+C<$SNOWSQL_ACCOUNT> environment variable or C<connections.accountname> setting
+in the
+L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>,
+the C<$SNOWSQL_REGION> or C<connections.region> setting in the
+L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>,
+and C<snowflakecomputing.com>.
+
+=back
+
+The port defaults to 443, but uses to the C<$SNOWSQL_PORT> environment
+variable if it's set. The database name is determined by the following methods:
+
+=over
+
+=item 1.
+
+The path par t of the database URI.
+
+=item 2.
+
+The C<$SNOWSQL_DATABASE> environment variable.
+
+=item 3.
+
+In the C<connections.dbname> setting in the
+L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>.
+
+=item 4.
+
+If sqitch finds no value in the above places, it falls back on the system
+username.
+
+=back
+
+Other attributes of the URI are set from the C<account>, C<username> and
+C<password> attributes documented below.
+
+=head3 C<account>
+
+Returns the Snowflake account name, or an exception if none can be determined.
+Sqitch looks for the account code in this order:
+
+=over
+
+=item 1
+
+In the host name of the target URI.
+
+=item 2
+
+In the C<$SNOWSQL_ACCOUNT> environment variable.
+
+=item 3
+
+In the C<connections.accountname> setting in the
+L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>.
+
+=back
+
+=head3 username
+
+Returns the snowflake user name. Sqitch looks for the user name in this order:
+
+=over
+
+=item 1
+
+In the C<$SQITCH_USERNAME> environment variable.
+
+=item 2
+
+In the target URI.
+
+=item 3
+
+In the C<$SNOWSQL_USER> environment variable.
+
+=item 4
+
+In the C<connections.username> variable from the
+L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>.
+
+=item 5
+
+The system username.
+
+=back
+
+=head3 password
+
+Returns the snowflake password. Sqitch looks for the password in this order:
+
+=over
+
+=item 1
+
+In the C<$SQITCH_PASSWORD> environment variable.
+
+=item 2
+
+In the target URI.
+
+=item 3
+
+In the C<$SNOWSQL_PWD> environment variable.
+
+=item 4
+
+In the C<connections.password> variable from the
+L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>.
+
+=back
+
+=head3 C<warehouse>
+
+Returns the warehouse to use for all connections. This value will be available
+to all Snowflake change scripts as the C<&warehouse> variable. Sqitch looks
+for the warehouse in this order:
+
+=over
+
+=item 1
+
+In the C<warehouse> query parameter of the target URI
+
+=item 2
+
+In the C<$SNOWSQL_WAREHOUSE> environment variable.
+
+=item 3
+
+In the C<connections.warehousename> variable from the
+L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>.
+
+=item 4
+
+If none of the above are found, it falls back on the hard-coded value
+"sqitch".
+
+=back
+
+=head3 C<role>
+
+Returns the role to use for all connections. Sqitch looks for the role in this
+order:
+
+=over
+
+=item 1
+
+In the C<role> query parameter of the target URI
+
+=item 2
+
+In the C<$SNOWSQL_ROLE> environment variable.
+
+=item 3
+
+In the C<connections.rolename> variable from the
+L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>.
+
+=item 4
+
+If none of the above are found, no role will be set.
+
+=back
+
+=head2 Instance Methods
+
+=head3 C<initialized>
+
+ $snowflake->initialize unless $snowflake->initialized;
+
+Returns true if the database has been initialized for Sqitch, and false if it
+has not.
+
+=head3 C<initialize>
+
+ $snowflake->initialize;
+
+Initializes a database for Sqitch by installing the Sqitch registry schema.
+
+=head3 C<snowsql>
+
+Returns a list containing the C<snowsql> client and options to be passed to
+it. Used internally when executing scripts.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/snowflake.sql b/lib/App/Sqitch/Engine/snowflake.sql
new file mode 100644
index 00000000..93b244e2
--- /dev/null
+++ b/lib/App/Sqitch/Engine/snowflake.sql
@@ -0,0 +1,142 @@
+CREATE SCHEMA IF NOT EXISTS &registry;
+
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.1.';
+
+CREATE TABLE &registry.releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp,
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE &registry.releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry.releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry.releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry.releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry.releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE &registry.projects (
+ project TEXT PRIMARY KEY,
+ uri TEXT NULL UNIQUE,
+ created_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp,
+ creator_name TEXT NOT NULL,
+ creator_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE &registry.projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN &registry.projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN &registry.projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN &registry.projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN &registry.projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN &registry.projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE &registry.changes (
+ change_id TEXT PRIMARY KEY,
+ script_hash TEXT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES &registry.projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMP_TZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, script_hash)
+);
+
+COMMENT ON TABLE &registry.changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN &registry.changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN &registry.changes.script_hash IS 'Deploy script SHA-1 hash.';
+COMMENT ON COLUMN &registry.changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN &registry.changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry.changes.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry.changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN &registry.changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN &registry.changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN &registry.changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN &registry.changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry.changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE &registry.tags (
+ tag_id TEXT PRIMARY KEY,
+ tag TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES &registry.projects(project) ON UPDATE CASCADE,
+ change_id TEXT NOT NULL REFERENCES &registry.changes(change_id) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE &registry.tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN &registry.tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN &registry.tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN &registry.tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN &registry.tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN &registry.tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN &registry.tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN &registry.tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN &registry.tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN &registry.tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN &registry.tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN &registry.tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE &registry.dependencies (
+ change_id TEXT NOT NULL REFERENCES &registry.changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ dependency TEXT NOT NULL,
+ dependency_id TEXT NULL REFERENCES &registry.changes(change_id) ON UPDATE CASCADE,
+ -- CONSTRAINT dependencies_check CHECK (
+ -- (type = 'require' AND dependency_id IS NOT NULL)
+ -- OR (type = 'conflict' AND dependency_id IS NULL)
+ -- ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE &registry.dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN &registry.dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN &registry.dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN &registry.dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN &registry.dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TABLE &registry.events (
+ event TEXT NOT NULL,
+ -- CONSTRAINT events_event_check CHECK (
+ -- event IN ('deploy', 'revert', 'fail', 'merge')
+ -- ),
+ change_id TEXT NOT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES &registry.projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ requires TEXT NOT NULL DEFAULT '',
+ conflicts TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+COMMENT ON TABLE &registry.events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN &registry.events.event IS 'Type of event.';
+COMMENT ON COLUMN &registry.events.change_id IS 'Change ID.';
+COMMENT ON COLUMN &registry.events.change IS 'Change name.';
+COMMENT ON COLUMN &registry.events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry.events.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry.events.requires IS 'Array of the names of required changes.';
+COMMENT ON COLUMN &registry.events.conflicts IS 'Array of the names of conflicting changes.';
+COMMENT ON COLUMN &registry.events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN &registry.events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN &registry.events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN &registry.events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN &registry.events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN &registry.events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry.events.planner_email IS 'Email address of the user who plan planned the change.';
diff --git a/lib/App/Sqitch/Engine/sqlite.pm b/lib/App/Sqitch/Engine/sqlite.pm
new file mode 100644
index 00000000..dc86276d
--- /dev/null
+++ b/lib/App/Sqitch/Engine/sqlite.pm
@@ -0,0 +1,305 @@
+package App::Sqitch::Engine::sqlite;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Plan::Change;
+use Path::Class;
+use Moo;
+use App::Sqitch::Types qw(URIDB DBH ArrayRef);
+use namespace::autoclean;
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has registry_uri => (
+ is => 'ro',
+ isa => URIDB,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri->clone;
+ my $reg = $self->registry;
+
+ if ( file($reg)->is_absolute ) {
+ # Just use an absolute path.
+ $uri->dbname($reg);
+ } elsif (my @segs = $uri->path_segments) {
+ # Use the same name, but replace $name.$ext with $reg.$ext.
+ my $bn = file( $segs[-1] )->basename;
+ if ($reg =~ /[.]/ || $bn !~ /[.]/) {
+ $segs[-1] =~ s/\Q$bn\E$/$reg/;
+ } else {
+ my ($b, $e) = split /[.]/, $bn, 2;
+ $segs[-1] =~ s/\Q$b\E[.]$e$/$reg.$e/;
+ }
+ $uri->path_segments(@segs);
+ } else {
+ # No known path, so no name.
+ $uri->dbname(undef);
+ }
+
+ return $uri;
+ },
+);
+
+sub registry_destination {
+ my $uri = shift->registry_uri;
+ if ($uri->password) {
+ $uri = $uri->clone;
+ $uri->password(undef);
+ }
+ return $uri->as_string;
+}
+
+sub key { 'sqlite' }
+sub name { 'SQLite' }
+sub driver { 'DBD::SQLite 1.37' }
+sub default_client { 'sqlite3' }
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->use_driver;
+
+ my $uri = $self->registry_uri;
+ my $dbh = DBI->connect($uri->dbi_dsn, '', '', {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ sqlite_unicode => 1,
+ sqlite_use_immediate_transaction => 1,
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ Callbacks => {
+ connected => sub {
+ my $dbh = shift;
+ $dbh->do('PRAGMA foreign_keys = ON');
+ return;
+ },
+ },
+ });
+
+ # Make sure we support this version.
+ my @v = split /[.]/ => $dbh->{sqlite_version};
+ hurl sqlite => __x(
+ 'Sqitch requires SQLite 3.7.11 or later; DBD::SQLite was built with {version}',
+ version => $dbh->{sqlite_version}
+ ) unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 7 || ($v[1] == 7 && $v[2] >= 11)));
+
+ return $dbh;
+ }
+);
+
+# Need to wait until dbh is defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+has _sqlite3 => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+
+ # Make sure we can use this version of SQLite.
+ my @v = split /[.]/ => (
+ split / / => scalar $self->sqitch->capture( $self->client, '-version' )
+ )[0];
+ hurl sqlite => __x(
+ 'Sqitch requires SQLite 3.3.9 or later; {client} is {version}',
+ client => $self->client,
+ version => join( '.', @v)
+ ) unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 3 || ($v[1] == 3 && $v[2] >= 9)));
+
+ my $dbname = $self->uri->dbname or hurl sqlite => __x(
+ 'Database name missing in URI {uri}',
+ uri => $self->uri,
+ );
+
+ return [
+ $self->client,
+ '-noheader',
+ '-bail',
+ '-batch',
+ '-csv', # or -column or -line?
+ $dbname,
+ ];
+ },
+);
+
+sub sqlite3 { @{ shift->_sqlite3 } }
+
+sub _version_query { 'SELECT CAST(ROUND(MAX(version), 1) AS TEXT) FROM releases' }
+
+sub initialized {
+ my $self = shift;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT EXISTS(
+ SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?
+ )
+ }, undef, 'changes')->[0];
+}
+
+sub initialize {
+ my $self = shift;
+ hurl engine => __x(
+ 'Sqitch database {database} already initialized',
+ database => $self->registry_uri->dbname,
+ ) if $self->initialized;
+
+ # Load up our database.
+ my @cmd = $self->sqlite3;
+ $cmd[-1] = $self->registry_uri->dbname;
+ my $file = file(__FILE__)->dir->file('sqlite.sql');
+ $self->sqitch->run( @cmd, $self->_read($file) );
+ $self->_register_release;
+}
+
+sub _no_table_error {
+ return $DBI::errstr && $DBI::errstr =~ /^\Qno such table:/;
+}
+
+sub _no_column_error {
+ return try { $_->message =~ /^\Qno such column:/ };
+}
+
+sub _regex_op { 'REGEXP' }
+
+sub _limit_default { -1 }
+
+sub _ts_default {
+ q{strftime('%Y-%m-%d %H:%M:%f')};
+}
+
+sub _ts2char_format {
+ return q{strftime('year:%%Y:month:%%m:day:%%d:hour:%%H:minute:%%M:second:%%S:time_zone:UTC', %s)};
+}
+
+sub _listagg_format {
+ return q{group_concat(%s, ' ')};
+}
+
+sub _char2ts {
+ my $dt = $_[1];
+ $dt->set_time_zone('UTC');
+ return join ' ', $dt->ymd('-'), $dt->hms(':');
+}
+
+sub _run {
+ my $self = shift;
+ return $self->sqitch->run( $self->sqlite3, @_ );
+}
+
+sub _capture {
+ my $self = shift;
+ return $self->sqitch->capture( $self->sqlite3, @_ );
+}
+
+sub _spool {
+ my $self = shift;
+ my $fh = shift;
+ return $self->sqitch->spool( $fh, $self->sqlite3, @_ );
+}
+
+sub run_file {
+ my ($self, $file) = @_;
+ $self->_run( $self->_read($file) );
+}
+
+sub run_verify {
+ my ($self, $file) = @_;
+ # Suppress STDOUT unless we want extra verbosity.
+ my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
+ $self->$meth( $self->_read($file) );
+}
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ $self->_spool($fh);
+}
+
+sub run_upgrade {
+ my ($self, $file) = @_;
+ my @cmd = $self->sqlite3;
+ $cmd[-1] = $self->registry_uri->dbname;
+ return $self->sqitch->run( @cmd, $self->_read($file) );
+}
+
+sub _read {
+ my $self = shift;
+ return '.read ' . $self->dbh->quote(shift);
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine::sqlite - Sqitch SQLite Engine
+
+=head1 Synopsis
+
+ my $sqlite = App::Sqitch::Engine->load( engine => 'sqlite' );
+
+=head1 Description
+
+App::Sqitch::Engine::sqlite provides the SQLite storage engine for Sqitch.
+
+=head1 Interface
+
+=head2 Accessors
+
+=head3 C<client>
+
+Returns the path to the SQLite client. If C<--client> was passed to C<sqitch>,
+that's what will be returned. Otherwise, it uses the C<engine.sqlite.client>
+configuration value, or else defaults to C<sqlite3> (or C<sqlite3.exe> on
+Windows), which should work if it's in your path.
+
+=head2 Instance Methods
+
+=head3 C<sqlite3>
+
+Returns a list containing the C<sqlite3> client and options to be passed to it.
+Used internally when executing scripts.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/sqlite.sql b/lib/App/Sqitch/Engine/sqlite.sql
new file mode 100644
index 00000000..fe35605e
--- /dev/null
+++ b/lib/App/Sqitch/Engine/sqlite.sql
@@ -0,0 +1,80 @@
+BEGIN;
+
+CREATE TABLE releases (
+ version FLOAT PRIMARY KEY,
+ installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+CREATE TABLE projects (
+ project TEXT PRIMARY KEY,
+ uri TEXT NULL UNIQUE,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ creator_name TEXT NOT NULL,
+ creator_email TEXT NOT NULL
+);
+
+CREATE TABLE changes (
+ change_id TEXT PRIMARY KEY,
+ script_hash TEXT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, script_hash)
+);
+
+CREATE TABLE tags (
+ tag_id TEXT PRIMARY KEY,
+ tag TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, tag)
+);
+
+CREATE TABLE dependencies (
+ change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ dependency TEXT NOT NULL,
+ dependency_id TEXT NULL REFERENCES changes(change_id) ON UPDATE CASCADE
+ CONSTRAINT dependencies_check CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+CREATE TABLE events (
+ event TEXT NOT NULL CONSTRAINT events_event_check CHECK (
+ event IN ('deploy', 'revert', 'fail', 'merge')
+ ),
+ change_id TEXT NOT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ requires TEXT NOT NULL DEFAULT '',
+ conflicts TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+COMMIT;
diff --git a/lib/App/Sqitch/Engine/vertica.pm b/lib/App/Sqitch/Engine/vertica.pm
new file mode 100644
index 00000000..bb42e29c
--- /dev/null
+++ b/lib/App/Sqitch/Engine/vertica.pm
@@ -0,0 +1,585 @@
+package App::Sqitch::Engine::vertica;
+
+use 5.010;
+use Moo;
+use utf8;
+use Path::Class;
+use DBI;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::Types qw(DBH ArrayRef);
+
+extends 'App::Sqitch::Engine';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub key { 'vertica' }
+sub name { 'Vertica' }
+sub driver { 'DBD::ODBC 1.59' }
+sub default_client { 'vsql' }
+
+sub destination {
+ my $self = shift;
+
+ # Just use the target name if it doesn't look like a URI or if the URI
+ # includes the database name.
+ return $self->target->name if $self->target->name !~ /:/
+ || $self->target->uri->dbname;
+
+ # Use the URI sans password, and with the database name added.
+ my $uri = $self->target->uri->clone;
+ $uri->password(undef) if $uri->password;
+ $uri->dbname( $ENV{VSQL_DATABASE} || $self->username );
+ return $uri->as_string;
+}
+
+
+sub _def_user { $ENV{VSQL_USER} || shift->sqitch->sysuser }
+sub _def_pass { $ENV{VSQL_PASSWORD} }
+
+has _vsql => (
+ is => 'ro',
+ isa => ArrayRef,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $uri = $self->uri;
+ my @ret = ( $self->client );
+ for my $spec (
+ [ username => $self->username ],
+ [ dbname => $uri->dbname ],
+ [ host => $uri->host ],
+ [ port => $uri->_port ],
+ ) {
+ push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1];
+ }
+
+ if (my %vars = $self->variables) {
+ push @ret => map {; '--set', "$_=$vars{$_}" } sort keys %vars;
+ }
+
+ push @ret => $self->_client_opts;
+ return \@ret;
+ },
+);
+
+sub vsql { @{ shift->_vsql } }
+
+has dbh => (
+ is => 'rw',
+ isa => DBH,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->use_driver;
+
+ # Set defaults in the URI.
+ my $target = $self->target;
+ my $uri = $self->uri;
+ # https://my.vertica.com/docs/5.1.6/HTML/index.htm#2736.htm
+ $uri->dbname($ENV{VSQL_DATABASE}) if !$uri->dbname && $ENV{VSQL_DATABASE};
+ $uri->host($ENV{VSQL_HOST}) if !$uri->host && $ENV{VSQL_HOST};
+ $uri->port($ENV{VSQL_PORT}) if !$uri->_port && $ENV{VSQL_PORT};
+
+ DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
+ PrintError => 0,
+ RaiseError => 0,
+ AutoCommit => 1,
+ odbc_utf8_on => 1,
+ HandleError => sub {
+ my ($err, $dbh) = @_;
+ $@ = $err;
+ @_ = ($dbh->state || 'DEV' => $dbh->errstr);
+ goto &hurl;
+ },
+ Callbacks => {
+ connected => sub {
+ my $dbh = shift;
+ try {
+ $dbh->do(
+ 'SET search_path = ' . $dbh->quote($self->registry)
+ );
+ # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html
+ $dbh->set_err(undef, undef) if $dbh->err;
+ };
+ return;
+ },
+ },
+ });
+ }
+);
+
+sub _listagg_format { undef } # Vertica has none!
+
+# Need to wait until dbh is defined.
+with 'App::Sqitch::Role::DBIEngine';
+
+sub _client_opts {
+ return (
+ '--quiet',
+ '--no-vsqlrc',
+ '--no-align',
+ '--tuples-only',
+ '--set' => 'ON_ERROR_STOP=1',
+ '--set' => 'registry=' . shift->registry,
+ );
+}
+
+sub initialized {
+ my $self = shift;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT EXISTS(
+ SELECT TRUE FROM v_catalog.schemata WHERE schema_name = ?
+ )
+ }, undef, $self->registry)->[0];
+}
+
+sub initialize {
+ my $self = shift;
+ my $schema = $self->registry;
+ hurl engine => __x(
+ 'Sqitch schema "{schema}" already exists',
+ schema => $schema
+ ) if $self->initialized;
+
+ $self->_run_registry_file( file(__FILE__)->dir->file('vertica.sql') );
+ $self->dbh->do('SET search_path = ' . $self->dbh->quote($schema));
+ $self->_register_release;
+}
+
+sub run_upgrade {
+ shift->_run_registry_file(@_);
+}
+
+sub _run_registry_file {
+ my ($self, $file) = @_;
+
+ # Check the database version.
+ my $vline = $self->dbh->selectcol_arrayref('SELECT version()')->[0];
+ my ($maj) = $vline =~ /\bv?(\d+)/;
+
+ # Need to write a temp file; no :"registry" variable syntax.
+ my ($schema) = $self->dbh->selectrow_array(
+ 'SELECT quote_ident(?)', undef, $self->registry
+ );
+ (my $sql = scalar $file->slurp) =~ s{:"registry"}{$schema}g;
+
+ # No LONG VARCHAR before Vertica 7.
+ $sql =~ s/LONG //g if $maj < 7;
+
+ # Write out the temporary file.
+ require File::Temp;
+ my $fh = File::Temp->new;
+ print $fh $sql;
+ close $fh;
+
+ # Now we can execute the file.
+ $self->_run_with_verbosity( $fh->filename );
+}
+
+sub _no_table_error {
+ return $DBI::state && $DBI::state eq '42V01'; # ERRCODE_UNDEFINED_TABLE
+}
+
+sub _no_column_error {
+ return $DBI::state && $DBI::state eq '42703'; # ERRCODE_UNDEFINED_COLUMN
+}
+
+sub _dt($) {
+ require App::Sqitch::DateTime;
+ return App::Sqitch::DateTime->new(split /:/ => shift);
+}
+
+sub _multi_values {
+ my ($self, $count, $expr) = @_;
+ return join "\nUNION ALL ", ("SELECT $expr") x $count;
+}
+
+sub _dependency_placeholders {
+ return 'CAST(? AS CHAR(40)), CAST(? AS VARCHAR), CAST(? AS VARCHAR), CAST(? AS CHAR(40))';
+}
+
+sub _tag_placeholders {
+ my $self = shift;
+ return join(', ',
+ 'CAST(? AS CHAR(40))',
+ 'CAST(? AS VARCHAR)',
+ 'CAST(? AS VARCHAR)',
+ 'CAST(? AS CHAR(40))',
+ 'CAST(? AS VARCHAR)',
+ 'CAST(? AS VARCHAR)',
+ 'CAST(? AS VARCHAR)',
+ 'CAST(? AS TIMESTAMPTZ)',
+ 'CAST(? AS VARCHAR)',
+ 'CAST(? AS VARCHAR)',
+ $self->_ts_default,
+ );
+}
+
+sub _tag_subselect_columns {
+ my $self = shift;
+ return join(', ',
+ 'CAST(? AS CHAR(40)) AS tid',
+ 'CAST(? AS VARCHAR) AS tname',
+ 'CAST(? AS VARCHAR) AS proj',
+ 'CAST(? AS CHAR(40)) AS cid',
+ 'CAST(? AS VARCHAR) AS note',
+ 'CAST(? AS VARCHAR) AS cuser',
+ 'CAST(? AS VARCHAR) AS cemail',
+ 'CAST(? AS TIMESTAMPTZ) AS tts',
+ 'CAST(? AS VARCHAR) AS puser',
+ 'CAST(? AS VARCHAR) AS pemail',
+ $self->_ts_default,
+ );
+}
+
+sub _select_state {
+ my ( $self, $project, $with_hash ) = @_;
+ my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $hshcol = $with_hash ? "c.script_hash\n , " : '';
+ return $self->dbh->selectrow_hashref(qq{
+ SELECT c.change_id
+ , ${hshcol}c.change
+ , c.project
+ , c.note
+ , c.committer_name
+ , c.committer_email
+ , $cdtcol AS committed_at
+ , c.planner_name
+ , c.planner_email
+ , $pdtcol AS planned_at
+ FROM changes c
+ WHERE c.project = ?
+ ORDER BY c.committed_at DESC
+ LIMIT 1
+ }, undef, $project // $self->plan->project );
+}
+
+sub current_state {
+ my ( $self, $project ) = @_;
+ my $dbh = $self->dbh;
+ my $state = try {
+ $self->_select_state($project, 1)
+ } catch {
+ return if $self->_no_table_error && !$self->initialized;
+ return $self->_select_state($project, 0) if $self->_no_column_error;
+ die $_;
+ } or return undef;
+
+ $state->{tags} = $dbh->selectcol_arrayref(
+ 'SELECT tag FROM tags WHERE change_id = ? ORDER BY committed_at',
+ undef, $state->{change_id}
+ );
+ $state->{committed_at} = _dt $state->{committed_at};
+ $state->{planned_at} = _dt $state->{planned_at};
+ return $state;
+}
+
+sub _deployed_changes {
+ my ($self, $sql, @params) = @_;
+ my $sth = $self->dbh->prepare($sql);
+ $sth->execute(@params);
+
+ my ($last_id, @changes) = ('');
+ while (my $res = $sth->fetchrow_hashref) {
+ if ($res->{id} eq $last_id) {
+ push @{ $changes[-1]->{tags} } => $res->{tag};
+ } else {
+ $last_id = $res->{id};
+ $res->{tags} = [ delete $res->{tag} || () ];
+ $res->{timestamp} = _dt $res->{timestamp};
+ push @changes => $res;
+ }
+ }
+ return @changes;
+}
+
+sub deployed_changes {
+ my $self = shift;
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ return $self->_deployed_changes(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ t.tag AS tag
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ ORDER BY c.committed_at ASC
+ }, $self->plan->project);
+}
+
+sub deployed_changes_since {
+ my ( $self, $change ) = @_;
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ $self->_deployed_changes(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ t.tag AS tag
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ AND c.committed_at > (SELECT committed_at FROM changes WHERE change_id = ?)
+ ORDER BY c.committed_at ASC
+ }, $self->plan->project, $change->id);
+}
+
+sub load_change {
+ my ( $self, $change_id ) = @_;
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my @res = $self->_deployed_changes(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ t.tag AS tag
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.change_id = ?
+ }, $change_id);
+ return $res[0];
+}
+
+sub _offset_op {
+ my ( $self, $offset ) = @_;
+ my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' );
+ return $dir, $op, 'OFFSET ' . (abs($offset) - 1);
+}
+
+sub change_id_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the ID if there is no offset.
+ return $change_id unless $offset;
+
+ # Are we offset forwards or backwards?
+ my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ AND committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ ORDER BY committed_at $dir
+ LIMIT 1 $offset_expr
+ }, undef, $self->plan->project, $change_id)->[0];
+}
+
+sub change_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the object if there is no offset.
+ return $self->load_change($change_id) unless $offset;
+
+ # Are we offset forwards or backwards?
+ my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+
+ my @res = $self->_deployed_changes(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ t.tag AS tag
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ AND c.committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ ORDER BY c.committed_at $dir
+ $offset_expr
+ }, $self->plan->project, $change_id);
+ return $res[0];
+}
+
+sub _ts2char_format {
+ q{to_char(%s AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')};
+}
+
+sub _ts_default { 'clock_timestamp()' }
+
+sub _char2ts { $_[1]->as_string(format => 'iso') }
+
+sub _regex_op { '~' }
+
+# Override to lock the changes table. This ensures that only one instance of
+# Sqitch runs at one time.
+sub begin_work {
+ my $self = shift;
+ my $dbh = $self->dbh;
+
+ # Start transaction and lock changes to allow only one change at a time.
+ $dbh->begin_work;
+ $dbh->do('LOCK TABLE changes IN EXCLUSIVE MODE');
+ return $self;
+}
+
+sub run_file {
+ my ($self, $file) = @_;
+ $self->_run('--file' => $file);
+}
+
+sub run_verify { shift->_run_with_verbosity(@_) }
+
+sub _run_with_verbosity {
+ my $self = shift;
+ my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
+ return $self->$meth('--file' => @_);
+}
+
+sub run_handle {
+ my ($self, $fh) = @_;
+ $self->_spool($fh);
+}
+
+sub _cid {
+ my ( $self, $ord, $offset, $project ) = @_;
+
+ my $offexpr = $offset ? " OFFSET $offset" : '';
+ return try {
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ ORDER BY committed_at $ord
+ LIMIT 1$offexpr
+ }, undef, $project || $self->plan->project)->[0];
+ } catch {
+ return if $self->_no_table_error && !$self->initialized;
+ die $_;
+ };
+}
+
+sub changes_requiring_change {
+ my ( $self, $change ) = @_;
+ # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
+ return @{ $self->dbh->selectall_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT c.change_id, c.project, c.change, t.tag AS asof_tag
+ FROM dependencies d
+ JOIN changes c ON c.change_id = d.change_id
+ LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
+ WHERE d.dependency_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, { Slice => {} }, $change->id) };
+}
+
+sub name_for_change_id {
+ my ( $self, $change_id ) = @_;
+ # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
+ return $self->dbh->selectcol_arrayref(q{
+ WITH tag AS (
+ SELECT tag, committed_at, project,
+ ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
+ FROM tags
+ )
+ SELECT change || COALESCE(t.tag, '@HEAD')
+ FROM changes c
+ LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
+ WHERE change_id = ?
+ AND (t.rnk IS NULL OR t.rnk = 1)
+ }, undef, $change_id)->[0];
+}
+
+sub _run {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->run( $self->vsql, @_ );
+ local $ENV{VSQL_PASSWORD} = $pass;
+ return $sqitch->run( $self->vsql, @_ );
+}
+
+sub _capture {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->capture( $self->vsql, @_ );
+ local $ENV{VSQL_PASSWORD} = $pass;
+ return $sqitch->capture( $self->vsql, @_ );
+}
+
+sub _probe {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->probe( $self->vsql, @_ );
+ local $ENV{VSQL_PASSWORD} = $pass;
+ return $sqitch->probe( $self->vsql, @_ );
+}
+
+sub _spool {
+ my $self = shift;
+ my $fh = shift;
+ my $sqitch = $self->sqitch;
+ my $pass = $self->password or return $sqitch->spool( $fh, $self->vsql, @_ );
+ local $ENV{VSQL_PASSWORD} = $pass;
+ return $sqitch->spool( $fh, $self->vsql, @_ );
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Engine::vertica - Sqitch Vertica Engine
+
+=head1 Synopsis
+
+ my $vertica = App::Sqitch::Engine->load( engine => 'vertica' );
+
+=head1 Description
+
+App::Sqitch::Engine::vertica provides the Vertica storage engine for Sqitch.
+It supports Vertica 6.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<initialized>
+
+ $vertica->initialize unless $vertica->initialized;
+
+Returns true if the database has been initialized for Sqitch, and false if it
+has not.
+
+=head3 C<initialize>
+
+ $vertica->initialize;
+
+Initializes a database for Sqitch by installing the Sqitch registry schema.
+
+=head3 C<vsql>
+
+Returns a list containing the C<vsql> client and options to be passed to it.
+Used internally when executing scripts.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Engine/vertica.sql b/lib/App/Sqitch/Engine/vertica.sql
new file mode 100644
index 00000000..db5f111d
--- /dev/null
+++ b/lib/App/Sqitch/Engine/vertica.sql
@@ -0,0 +1,85 @@
+CREATE SCHEMA :"registry";
+
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.';
+
+CREATE TABLE :"registry".releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ installer_name VARCHAR(1024) NOT NULL,
+ installer_email VARCHAR(1024) NOT NULL
+);
+
+COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.';
+
+CREATE TABLE :"registry".projects (
+ project VARCHAR(1024) PRIMARY KEY ENCODING AUTO,
+ uri VARCHAR(1024) NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ creator_name VARCHAR(1024) NOT NULL,
+ creator_email VARCHAR(1024) NOT NULL
+);
+
+COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.';
+
+CREATE TABLE :"registry".changes (
+ change_id CHAR(40) PRIMARY KEY ENCODING AUTO,
+ script_hash CHAR(40) NULL UNIQUE,
+ change VARCHAR(1024) NOT NULL,
+ project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project),
+ note VARCHAR(65000) NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name VARCHAR(1024) NOT NULL,
+ committer_email VARCHAR(1024) NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name VARCHAR(1024) NOT NULL,
+ planner_email VARCHAR(1024) NOT NULL
+);
+
+COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.';
+
+CREATE TABLE :"registry".tags (
+ tag_id CHAR(40) PRIMARY KEY ENCODING AUTO,
+ tag VARCHAR(1024) NOT NULL,
+ project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project),
+ change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id),
+ note VARCHAR(65000) NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name VARCHAR(1024) NOT NULL,
+ committer_email VARCHAR(1024) NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name VARCHAR(1024) NOT NULL,
+ planner_email VARCHAR(1024) NOT NULL,
+ UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.';
+
+CREATE TABLE :"registry".dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id),
+ type VARCHAR(8) NOT NULL ENCODING AUTO,
+ dependency VARCHAR(2048) NOT NULL,
+ dependency_id CHAR(40) NULL REFERENCES :"registry".changes(change_id),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.';
+
+CREATE TABLE :"registry".events (
+ event VARCHAR(6) NOT NULL ENCODING AUTO,
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR(1024) NOT NULL,
+ project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project),
+ note VARCHAR(65000) NOT NULL DEFAULT '',
+ requires LONG VARCHAR NOT NULL DEFAULT '{}',
+ conflicts LONG VARCHAR NOT NULL DEFAULT '{}',
+ tags LONG VARCHAR NOT NULL DEFAULT '{}',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name VARCHAR(1024) NOT NULL,
+ committer_email VARCHAR(1024) NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name VARCHAR(1024) NOT NULL,
+ planner_email VARCHAR(1024) NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.';
diff --git a/lib/App/Sqitch/ItemFormatter.pm b/lib/App/Sqitch/ItemFormatter.pm
new file mode 100644
index 00000000..f96c21ba
--- /dev/null
+++ b/lib/App/Sqitch/ItemFormatter.pm
@@ -0,0 +1,607 @@
+package App::Sqitch::ItemFormatter;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use List::Util qw(max);
+use Moo;
+use Types::Standard qw(Str Int);
+use Type::Utils qw(enum class_type);
+use String::Formatter;
+use namespace::autoclean;
+use Try::Tiny;
+use Term::ANSIColor 2.02 qw(colorvalid);
+my $encolor = \&Term::ANSIColor::color;
+
+use constant CAN_OUTPUT_COLOR => $^O eq 'MSWin32'
+ ? try { require Win32::Console::ANSI }
+ : -t *STDOUT;
+
+BEGIN {
+ $ENV{ANSI_COLORS_DISABLED} = 1 unless CAN_OUTPUT_COLOR;
+}
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has abbrev => (
+ is => 'ro',
+ isa => Int,
+ default => 0,
+);
+
+has date_format => (
+ is => 'ro',
+ isa => Str,
+ default => 'iso',
+);
+
+has color => (
+ is => 'ro',
+ isa => enum([ qw(always never auto) ]),
+ default => 'auto',
+);
+
+has formatter => (
+ is => 'ro',
+ lazy => 1,
+ isa => class_type('String::Formatter'),
+ default => sub {
+ my $self = shift;
+ no if $] >= 5.017011, warnings => 'experimental::smartmatch';
+ String::Formatter->new({
+ input_processor => 'require_single_input',
+ string_replacer => 'method_replace',
+ codes => {
+ e => sub { $_[0]->{event} },
+ L => sub {
+ given ($_[0]->{event}) {
+ when ('deploy') { return __ 'Deploy' }
+ when ('revert') { return __ 'Revert' }
+ when ('fail') { return __ 'Fail' }
+ }
+ },
+ l => sub {
+ given ($_[0]->{event}) {
+ when ('deploy') { return __ 'deploy' }
+ when ('revert') { return __ 'revert' }
+ when ('fail') { return __ 'fail' }
+ }
+ },
+ _ => sub {
+ given ($_[1]) {
+ when ('event') { return __ 'Event: ' }
+ when ('change') { return __ 'Change: ' }
+ when ('committer') { return __ 'Committer:' }
+ when ('planner') { return __ 'Planner: ' }
+ when ('by') { return __ 'By: ' }
+ when ('date') { return __ 'Date: ' }
+ when ('committed') { return __ 'Committed:' }
+ when ('planned') { return __ 'Planned: ' }
+ when ('name') { return __ 'Name: ' }
+ when ('project') { return __ 'Project: ' }
+ when ('email') { return __ 'Email: ' }
+ when ('requires') { return __ 'Requires: ' }
+ when ('conflicts') { return __ 'Conflicts:' }
+ when (undef) {
+ hurl format => __ 'No label passed to the _ format';
+ }
+ default {
+ hurl format => __x(
+ 'Unknown label "{label}" passed to the _ format',
+ label => $_[1],
+ );
+ }
+ };
+ },
+ H => sub { $_[0]->{change_id} },
+ h => sub {
+ if (my $abb = $_[1] || $self->abbrev) {
+ return substr $_[0]->{change_id}, 0, $abb;
+ }
+ return $_[0]->{change_id};
+ },
+ n => sub { $_[0]->{change} },
+ o => sub { $_[0]->{project} },
+
+ c => sub {
+ return "$_[0]->{committer_name} <$_[0]->{committer_email}>"
+ unless defined $_[1];
+ return $_[0]->{committer_name} if $_[1] ~~ [qw(n name)];
+ return $_[0]->{committer_email} if $_[1] ~~ [qw(e email)];
+ return $_[0]->{committed_at}->as_string(
+ format => $_[1] || $self->date_format
+ ) if $_[1] =~ s/^d(?:ate)?(?::|$)//;
+ },
+
+ p => sub {
+ return "$_[0]->{planner_name} <$_[0]->{planner_email}>"
+ unless defined $_[1];
+ return $_[0]->{planner_name} if $_[1] ~~ [qw(n name)];
+ return $_[0]->{planner_email} if $_[1] ~~ [qw(e email)];
+ return $_[0]->{planned_at}->as_string(
+ format => $_[1] || $self->date_format
+ ) if $_[1] =~ s/^d(?:ate)?(?::|$)//;
+ },
+
+ t => sub {
+ @{ $_[0]->{tags} }
+ ? ' ' . join $_[1] || ', ' => @{ $_[0]->{tags} }
+ : '';
+ },
+ T => sub {
+ @{ $_[0]->{tags} }
+ ? ' (' . join($_[1] || ', ' => @{ $_[0]->{tags} }) . ')'
+ : '';
+ },
+ v => sub { "\n" },
+ C => sub {
+ if (($_[1] // '') eq ':event') {
+ # Select a color based on some attribute.
+ return $encolor->(
+ $_[0]->{event} eq 'deploy' ? 'green'
+ : $_[0]->{event} eq 'revert' ? 'blue'
+ : 'red'
+ );
+ }
+ hurl format => __x(
+ '{color} is not a valid ANSI color', color => $_[1]
+ ) unless $_[1] && colorvalid( $_[1] );
+ $encolor->( $_[1] );
+ },
+ s => sub {
+ ( my $s = $_[0]->{note} ) =~ s/\v.*//ms;
+ return ($_[1] // '') . $s;
+ },
+ b => sub {
+ return '' unless $_[0]->{note} =~ /\v/;
+ ( my $b = $_[0]->{note} ) =~ s/^.+\v+//;
+ $b =~ s/^/$_[1]/gms if defined $_[1] && length $b;
+ return $b;
+ },
+ B => sub {
+ return $_[0]->{note} unless defined $_[1];
+ ( my $note = $_[0]->{note} ) =~ s/^/$_[1]/gms;
+ return $note;
+ },
+ r => sub {
+ @{ $_[0]->{requires} }
+ ? ' ' . join $_[1] || ', ' => @{ $_[0]->{requires} }
+ : '';
+ },
+ R => sub {
+ return '' unless @{ $_[0]->{requires} };
+ return __ ('Requires: ') . ' ' . join(
+ $_[1] || ', ' => @{ $_[0]->{requires} }
+ ) . "\n";
+ },
+ x => sub {
+ @{ $_[0]->{conflicts} }
+ ? ' ' . join $_[1] || ', ' => @{ $_[0]->{conflicts} }
+ : '';
+ },
+ X => sub {
+ return '' unless @{ $_[0]->{conflicts} };
+ return __('Conflicts:') . ' ' . join(
+ $_[1] || ', ' => @{ $_[0]->{conflicts} }
+ ) . "\n";
+ },
+ a => sub {
+ hurl format => __x(
+ '{attr} is not a valid change attribute', attr => $_[1]
+ ) unless $_[1] && exists $_[0]->{ $_[1] };
+ my $val = $_[0]->{ $_[1] } // return '';
+
+ if (ref $val eq 'ARRAY') {
+ return '' unless @{ $val };
+ $val = join ', ' => @{ $val };
+ } elsif (eval { $val->isa('App::Sqitch::DateTime') }) {
+ $val = $val->as_string( format => 'raw' );
+ }
+
+ my $sp = ' ' x max(9 - length $_[1], 0);
+ return "$_[1]$sp $val\n";
+ }
+ },
+ });
+ }
+);
+
+sub format {
+ my $self = shift;
+ local $SIG{__DIE__} = sub {
+ die @_ if $_[0] !~ /^Unknown conversion in stringf: (\S+)/;
+ hurl format => __x 'Unknown format code "{code}"', code => $1;
+ };
+
+ # Older versions of TERM::ANSIColor check for definedness.
+ local $ENV{ANSI_COLORS_DISABLED} = $self->color eq 'always' ? undef
+ : $self->color eq 'never' ? 1
+ : $ENV{ANSI_COLORS_DISABLED};
+
+ return $self->formatter->format(@_);
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::ItemFormatter - Format events and changes for command output
+
+=head1 Synopsis
+
+ my $formatter = App::Sqitch::ItemFormatter->new(%params);
+ say $formatter->format($format, $item);
+
+=head1 Description
+
+This class is used by commands to format items for output. For example,
+L<C<log>|sqitch-log> uses it to format the events it finds. It uses
+L<String::Formatter> to do the actual formatting, but configures it for all
+the various times of things typically displayed, such as change names, IDs,
+event types, etc. This keeps things relatively simple, as all one needs to
+pass to C<format()> is a format and then a hash reference of values to be used
+in the format.
+
+=head1 Interface
+
+=head2 Constructor
+
+=head3 C<new>
+
+ my $formatter = App::Sqitch::ItemFormatter->new(%params);
+
+Constructs and returns a formatter object. The supported parameters are:
+
+=over
+
+=item C<abbrev>
+
+Instead of showing the full 40-byte hexadecimal change ID, format as a partial
+prefix the specified number of characters long.
+
+=item C<date_format>
+
+Format to use for timestamps. Defaults to C<iso>. Allowed values:
+
+=over
+
+=item C<iso>
+
+=item C<iso8601>
+
+ISO-8601 format.
+
+=item C<rfc>
+
+=item C<rfc2822>
+
+RFC-2822 format.
+
+=item C<full>
+
+=item C<long>
+
+=item C<medium>
+
+=item C<short>
+
+A format length to pass to the system locale's C<LC_TIME> category.
+
+=item C<raw>
+
+Raw format, which is strict ISO-8601 in the UTC time zone.
+
+=item C<strftime:$string>
+
+An arbitrary C<strftime> pattern. See L<DateTime/strftime Paterns> for
+comprehensive documentation of supported patterns.
+
+=item C<cldr:$pattern>
+
+An arbitrary C<cldr> pattern. See L<DateTime/CLDR Paterns> for comprehensive
+documentation of supported patterns.
+
+=back
+
+=item C<color>
+
+Controls the use of ANSI color formatting. The value may be one of:
+
+=over
+
+=item C<auto> (the default)
+
+=item C<always>
+
+=item C<never>
+
+=back
+
+=item C<formatter>
+
+A String::Formatter object. You probably don't want to pass one of these, as
+the default one understands all the values to that Sqitch is likely to want to
+format.
+
+=back
+
+=head2 Instance Methods
+
+=head3 C<format>
+
+ $formatter->format( $format, $item );
+
+Formats an item as a string and returns it. The item will be formatted using
+the first argument. See L</Formats> for the gory details.
+
+The second argument is a hash reference defining the item to be formatted.
+These are simple key/value pairs, generally identifying attribute names and
+values. The supported keys are:
+
+=over
+
+=item C<event>
+
+The type of event, which is one of:
+
+=over
+
+=item C<deploy>
+
+=item C<revert>
+
+=item C<fail>
+
+=back
+
+=item C<project>
+
+The name of the project with which the change is associated.
+
+=item C<change_id>
+
+The change ID.
+
+=item C<change>
+
+The name of the change.
+
+=item C<note>
+
+A brief description of the change.
+
+=item C<tags>
+
+An array reference of the names of associated tags.
+
+=item C<requires>
+
+An array reference of the names of any changes required by the change.
+
+=item C<conflicts>
+
+An array reference of the names of any changes that conflict with the change.
+
+=item C<committed_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+event was logged.
+
+=item C<committer_name>
+
+Name of the user who deployed the change.
+
+=item C<committer_email>
+
+Email address of the user who deployed the change.
+
+=item C<planned_at>
+
+An L<App::Sqitch::DateTime> object representing the date and time at which the
+change was added to the plan.
+
+=item C<planner_name>
+
+Name of the user who added the change to the plan.
+
+=item C<planner_email>
+
+Email address of the user who added the change to the plan.
+
+=back
+
+=head1 Formats
+
+The format argument to C<format()> specifies the item information to be
+included in the resulting string. It works a little bit like C<printf> format
+and a little like Git log format. For example, this format:
+
+ format:The committer of %h was %{name}c%vThe title was >>%s<<%v
+
+Would show something like this:
+
+ The committer of f26a3s was Tom Lane
+ The title was >>We really need to get this right.<<
+
+The placeholders are:
+
+=over
+
+=item * C<%H>: Event change ID
+
+=item * C<%h>: Event change ID (respects C<--abbrev>)
+
+=item * C<%n>: Event change name
+
+=item * C<%o>: Event change project name
+
+=item * C<%($len)h>: abbreviated change of length C<$len>
+
+=item * C<%e>: Event type (deploy, revert, fail)
+
+=item * C<%l>: Localized lowercase event type label
+
+=item * C<%L>: Localized title case event type label
+
+=item * C<%c>: Event committer name and email address
+
+=item * C<%{name}c>: Event committer name
+
+=item * C<%{email}c>: Event committer email address
+
+=item * C<%{date}c>: commit date (respects C<--date-format>)
+
+=item * C<%{date:rfc}c>: commit date, RFC2822 format
+
+=item * C<%{date:iso}c>: commit date, ISO-8601 format
+
+=item * C<%{date:full}c>: commit date, full format
+
+=item * C<%{date:long}c>: commit date, long format
+
+=item * C<%{date:medium}c>: commit date, medium format
+
+=item * C<%{date:short}c>: commit date, short format
+
+=item * C<%{date:cldr:$pattern}c>: commit date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns>
+
+=item * C<%{date:strftime:$pattern}c>: commit date, formatted with custom L<strftime pattern|DateTime/strftime Patterns>
+
+=item * C<%c>: Change planner name and email address
+
+=item * C<%{name}p>: Change planner name
+
+=item * C<%{email}p>: Change planner email address
+
+=item * C<%{date}p>: plan date (respects C<--date-format>)
+
+=item * C<%{date:rfc}p>: plan date, RFC2822 format
+
+=item * C<%{date:iso}p>: plan date, ISO-8601 format
+
+=item * C<%{date:full}p>: plan date, full format
+
+=item * C<%{date:long}p>: plan date, long format
+
+=item * C<%{date:medium}p>: plan date, medium format
+
+=item * C<%{date:short}p>: plan date, short format
+
+=item * C<%{date:cldr:$pattern}p>: plan date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns>
+
+=item * C<%{date:strftime:$pattern}p>: plan date, formatted with custom L<strftime pattern|DateTime/strftime Patterns>
+
+=item * C<%t>: Comma-delimited list of tags
+
+=item * C<%{$sep}t>: list of tags delimited by C<$sep>
+
+=item * C<%T>: Parenthesized list of comma-delimited tags
+
+=item * C<%{$sep}T>: Parenthesized list of tags delimited by C<$sep>
+
+=item * C<%s>: Subject (a.k.a. title line)
+
+=item * C<%r>: Comma-delimited list of required changes
+
+=item * C<%{$sep}r>: list of required changes delimited by C<$sep>
+
+=item * C<%R>: Localized label and list of comma-delimited required changes
+
+=item * C<%{$sep}R>: Localized label and list of required changes delimited by C<$sep>
+
+=item * C<%x>: Comma-delimited list of conflicting changes
+
+=item * C<%{$sep}x>: list of conflicting changes delimited by C<$sep>
+
+=item * C<%X>: Localized label and list of comma-delimited conflicting changes
+
+=item * C<%{$sep}X>: Localized label and list of conflicting changes delimited by C<$sep>
+
+=item * C<%b>: Body
+
+=item * C<%B>: Raw body (unwrapped subject and body)
+
+=item * C<%{$prefix}>B: Raw body with C<$prefix> prefixed to every line
+
+=item * C<%{event}_> Localized label for "event"
+
+=item * C<%{change}_> Localized label for "change"
+
+=item * C<%{committer}_> Localized label for "committer"
+
+=item * C<%{planner}_> Localized label for "planner"
+
+=item * C<%{by}_> Localized label for "by"
+
+=item * C<%{date}_> Localized label for "date"
+
+=item * C<%{committed}_> Localized label for "committed"
+
+=item * C<%{planned}_> Localized label for "planned"
+
+=item * C<%{name}_> Localized label for "name"
+
+=item * C<%{project}_> Localized label for "project"
+
+=item * C<%{email}_> Localized label for "email"
+
+=item * C<%{requires}_> Localized label for "requires"
+
+=item * C<%{conflicts}_> Localized label for "conflicts"
+
+=item * C<%v> vertical space (newline)
+
+=item * C<%{$color}C>: An ANSI color: black, red, green, yellow, reset, etc.
+
+=item * C<%{:event}C>: An ANSI color based on event type (green deploy, blue revert, red fail)
+
+=item * C<%{$attribute}a>: The raw attribute name and value, if it exists and has a value
+
+=back
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-log>
+
+Documentation for the C<log> command to the Sqitch command-line client.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan.pm b/lib/App/Sqitch/Plan.pm
new file mode 100644
index 00000000..3106f547
--- /dev/null
+++ b/lib/App/Sqitch/Plan.pm
@@ -0,0 +1,1620 @@
+package App::Sqitch::Plan;
+
+use 5.010;
+use utf8;
+use App::Sqitch::Plan::Tag;
+use App::Sqitch::Plan::Change;
+use App::Sqitch::Plan::Blank;
+use App::Sqitch::Plan::Pragma;
+use App::Sqitch::Plan::Depend;
+use Path::Class;
+use App::Sqitch::Plan::ChangeList;
+use App::Sqitch::Plan::LineList;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use List::MoreUtils qw(uniq any);
+use namespace::autoclean;
+use Moo;
+use App::Sqitch::Types qw(Str Int HashRef ChangeList LineList Maybe Sqitch URI File Target);
+use constant SYNTAX_VERSION => '1.0.0';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+# Like [:punct:], but excluding _. Copied from perlrecharclass.
+my $punct = q{-!"#$%&'()*+,./:;<=>?@[\\]^`{|}~};
+my $name_re = qr{
+ (?![$punct]) # first character isn't punctuation
+ (?: # start non-capturing group, repeated once or more ...
+ (?! # negative look ahead for...
+ [~/=%^] # symbolic reference punctuation
+ [[:digit:]]+ # digits
+ (?:$|[[:blank:]]) # eol or blank
+ ) # ...
+ [^[:blank:]:@#] # match a valid character
+ )+ # ... end non-capturing group
+ (?<![$punct])\b # last character isn't punctuation
+}x;
+
+my %reserved = map { $_ => undef } qw(ROOT HEAD);
+
+sub name_regex { $name_re }
+
+has sqitch => (
+ is => 'ro',
+ isa => Sqitch,
+ required => 1,
+ weak_ref => 1,
+);
+
+has target => (
+ is => 'ro',
+ isa => Target,
+ required => 1,
+ weak_ref => 1,
+);
+
+has file => (
+ is => 'ro',
+ isa => File,
+ lazy => 1,
+ default => sub {
+ shift->target->plan_file
+ },
+);
+
+has _plan => (
+ is => 'rw',
+ isa => HashRef,
+ builder => 'load',
+ init_arg => 'plan',
+ lazy => 1,
+ required => 1,
+);
+
+has _changes => (
+ is => 'ro',
+ isa => ChangeList,
+ lazy => 1,
+ default => sub {
+ App::Sqitch::Plan::ChangeList->new(@{ shift->_plan->{changes} }),
+ },
+);
+
+has _lines => (
+ is => 'ro',
+ isa => LineList,
+ lazy => 1,
+ default => sub {
+ App::Sqitch::Plan::LineList->new(@{ shift->_plan->{lines} }),
+ },
+);
+
+has position => (
+ is => 'rw',
+ isa => Int,
+ default => -1,
+);
+
+has project => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ shift->_plan->{pragmas}{project};
+ }
+);
+
+has uri => (
+ is => 'ro',
+ isa => Maybe[URI],
+ lazy => 1,
+ default => sub {
+ my $uri = shift->_plan->{pragmas}{uri} || return;
+ require URI;
+ 'URI'->new($uri);
+ }
+);
+
+sub parse {
+ my ( $self, $data ) = @_;
+ open my $fh, '<:utf8_strict', \$data;
+ $self->_plan( $self->load($fh) );
+ return $self;
+}
+
+sub load {
+ my $self = shift;
+ my $file = $self->file;
+ my $fh = shift || do {
+ hurl plan => __x('Plan file {file} does not exist', file => $file)
+ unless -e $file;
+ hurl plan => __x('Plan file {file} is not a regular file', file => $file)
+ unless -f $file;
+ $file->open('<:utf8_strict') or hurl io => __x(
+ 'Cannot open {file}: {error}',
+ file => $file,
+ error => $!
+ );
+ };
+
+ return $self->_parse($file, $fh);
+}
+
+sub _parse {
+ my ( $self, $file, $fh ) = @_;
+
+ my @lines; # List of lines.
+ my @changes; # List of changes.
+ my @curr_changes; # List of changes since last tag.
+ my %line_no_for; # Maps tags and changes to line numbers.
+ my %change_named; # Maps change names to change objects.
+ my %tag_changes; # Maps changes in current tag section to line numbers.
+ my %pragmas; # Maps pragma names to values.
+ my $seen_version; # Have we seen a version pragma?
+ my $prev_tag; # Last seen tag.
+ my $prev_change; # Last seen change.
+
+ # Regex to match timestamps.
+ my $ts_re = qr/
+ (?<yr>[[:digit:]]{4}) # year
+ - # dash
+ (?<mo>[[:digit:]]{2}) # month
+ - # dash
+ (?<dy>[[:digit:]]{2}) # day
+ T # T
+ (?<hr>[[:digit:]]{2}) # hour
+ : # colon
+ (?<mi>[[:digit:]]{2}) # minute
+ : # colon
+ (?<sc>[[:digit:]]{2}) # second
+ Z # Zulu time
+ /x;
+
+ my $planner_re = qr/
+ (?<planner_name>[^<]+) # name
+ [[:blank:]]+ # blanks
+ <(?<planner_email>[^>]+)> # email
+ /x;
+
+ # Use for raising syntax error exceptions.
+ my $raise_syntax_error = sub {
+ hurl parse => __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => $fh->input_line_number,
+ error => shift
+ );
+ };
+
+ # First, find pragmas.
+ HEADER: while ( my $line = $fh->getline ) {
+ $line =~ s/\r?\n\z//;
+
+ # Grab blank lines first.
+ if ($line =~ /\A(?<lspace>[[:blank:]]*)(?:#[[:blank:]]*(?<note>.+)|$)/) {
+ my $line = App::Sqitch::Plan::Blank->new( plan => $self, %+ );
+ push @lines => $line;
+ last HEADER if @lines && !$line->note;
+ next HEADER;
+ }
+
+ # Grab inline note.
+ $line =~ s/(?<rspace>[[:blank:]]*)(?:[#][[:blank:]]*(?<note>.*))?$//;
+ my %params = %+;
+
+ $raise_syntax_error->(
+ __ 'Invalid pragma; a blank line must come between pragmas and changes'
+ ) unless $line =~ /
+ \A # Beginning of line
+ (?<lspace>[[:blank:]]*)? # Optional leading space
+ [%] # Required %
+ (?<hspace>[[:blank:]]*)? # Optional space
+ (?<name> # followed by name consisting of...
+ [^$punct] # not punct
+ (?: # followed by...
+ [^[:blank:]=]*? # any number non-blank, non-=
+ [^$punct[:blank:]] # one not blank or punct
+ )? # ... optionally
+ ) # ... required
+ (?: # followed by value consisting of...
+ (?<lopspace>[[:blank:]]*) # Optional blanks
+ (?<operator>=) # Required =
+ (?<ropspace>[[:blank:]]*) # Optional blanks
+ (?<value>.+) # String value
+ )? # ... optionally
+ \z # end of line
+ /x;
+
+ # XXX Die if the pragma is a dupe?
+
+ if ($+{name} eq 'syntax-version') {
+ # Set explicit version in case we write it out later. In future
+ # releases, may change parsers depending on the version.
+ $pragmas{syntax_version} = $params{value} = SYNTAX_VERSION;
+ } elsif ($+{name} eq 'project') {
+ my $proj = $+{value};
+ $raise_syntax_error->(__x(
+ qq{invalid project name "{project}": project names must not }
+ . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in '
+ . 'punctuation or digits following punctuation',
+ project => $proj,
+ )) unless $proj =~ /\A$name_re\z/;
+ $pragmas{project} = $proj;
+ } else {
+ $pragmas{ $+{name} } = $+{value} // 1;
+ }
+
+ push @lines => App::Sqitch::Plan::Pragma->new(
+ plan => $self,
+ %+,
+ %params
+ );
+ next HEADER;
+ }
+
+ # We should have a version pragma.
+ unless ( $pragmas{syntax_version} ) {
+ unshift @lines => $self->_version_line;
+ $pragmas{syntax_version} = SYNTAX_VERSION;
+ }
+
+ # Should have valid project pragma.
+ hurl parse => __x(
+ 'Missing %project pragma in {file}',
+ file => $file,
+ ) unless $pragmas{project};
+
+ LINE: while ( my $line = $fh->getline ) {
+ $line =~ s/\r?\n\z//;
+
+ # Grab blank lines first.
+ if ($line =~ /\A(?<lspace>[[:blank:]]*)(?:#[[:blank:]]*(?<note>.+)|$)/) {
+ my $line = App::Sqitch::Plan::Blank->new( plan => $self, %+ );
+ push @lines => $line;
+ next LINE;
+ }
+
+ # Grab inline note.
+ $line =~ s/(?<rspace>[[:blank:]]*)(?:[#][[:blank:]]*(?<note>.*))?$//;
+ my %params = %+;
+
+ # Is it a tag or a change?
+ my $type = $line =~ /^[[:blank:]]*[@]/ ? 'tag' : 'change';
+ $line =~ /
+ ^ # Beginning of line
+ (?<lspace>[[:blank:]]*)? # Optional leading space
+
+ (?: # followed by...
+ [@] # @ for tag
+ | # ...or...
+ (?<lopspace>[[:blank:]]*) # Optional blanks
+ (?<operator>[+-]) # Required + or -
+ (?<ropspace>[[:blank:]]*) # Optional blanks
+ )? # ... optionally
+
+ (?<name>$name_re) # followed by name
+ (?<pspace>[[:blank:]]+)? # blanks
+
+ (?: # followed by...
+ [[](?<dependencies>[^]]+)[]] # dependencies
+ [[:blank:]]* # blanks
+ )? # ... optionally
+
+ (?: # followed by...
+ $ts_re # timestamp
+ [[:blank:]]* # blanks
+ )? # ... optionally
+
+ (?: # followed by
+ $planner_re # planner
+ )? # ... optionally
+ $ # end of line
+ /x;
+
+ %params = ( %params, %+ );
+
+ # Raise errors for missing data.
+ $raise_syntax_error->(__(
+ qq{Invalid name; names must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ )) if !$params{name}
+ || (!$params{yr} && $line =~ $ts_re);
+
+ $raise_syntax_error->(__ 'Missing timestamp and planner name and email')
+ unless $params{yr} || $params{planner_name};
+ $raise_syntax_error->(__ 'Missing timestamp') unless $params{yr};
+
+ $raise_syntax_error->(__ 'Missing planner name and email')
+ unless $params{planner_name};
+
+ # It must not be a reserved name.
+ $raise_syntax_error->(__x(
+ '"{name}" is a reserved name',
+ name => ($type eq 'tag' ? '@' : '') . $params{name},
+ )) if exists $reserved{ $params{name} };
+
+ # It must not look like a SHA1 hash.
+ $raise_syntax_error->(__x(
+ '"{name}" is invalid because it could be confused with a SHA1 ID',
+ name => $params{name},
+ )) if $params{name} =~ /^[0-9a-f]{40}/;
+
+ # Assemble the timestamp.
+ require App::Sqitch::DateTime;
+ $params{timestamp} = App::Sqitch::DateTime->new(
+ year => delete $params{yr},
+ month => delete $params{mo},
+ day => delete $params{dy},
+ hour => delete $params{hr},
+ minute => delete $params{mi},
+ second => delete $params{sc},
+ time_zone => 'UTC',
+ );
+
+ if ($type eq 'tag') {
+ # Fail if no changes.
+ unless ($prev_change) {
+ $raise_syntax_error->(__x(
+ 'Tag "{tag}" declared without a preceding change',
+ tag => $params{name},
+ ));
+ }
+
+ # Fail on duplicate tag.
+ my $key = '@' . $params{name};
+ if ( my $at = $line_no_for{$key} ) {
+ $raise_syntax_error->(__x(
+ 'Tag "{tag}" duplicates earlier declaration on line {line}',
+ tag => $params{name},
+ line => $at,
+ ));
+ }
+
+ # Fail on dependencies.
+ $raise_syntax_error->(__x(
+ __ 'Tags may not specify dependencies'
+ )) if $params{dependencies};
+
+ if (@curr_changes) {
+ # Sort all changes up to this tag by their dependencies.
+ push @changes => $self->check_changes(
+ $pragmas{project},
+ \%line_no_for,
+ @curr_changes,
+ );
+ @curr_changes = ();
+ }
+
+ # Create the tag and associate it with the previous change.
+ $prev_tag = App::Sqitch::Plan::Tag->new(
+ plan => $self,
+ change => $prev_change,
+ %params,
+ );
+
+ # Keep track of everything and clean up.
+ $prev_change->add_tag($prev_tag);
+ push @lines => $prev_tag;
+ %line_no_for = (%line_no_for, %tag_changes, $key => $fh->input_line_number);
+ %tag_changes = ();
+ } else {
+ # Fail on duplicate change since last tag.
+ if ( my $at = $tag_changes{ $params{name} } ) {
+ $raise_syntax_error->(__x(
+ 'Change "{change}" duplicates earlier declaration on line {line}',
+ change => $params{name},
+ line => $at,
+ ));
+ }
+
+ # Got dependencies?
+ if (my $deps = $params{dependencies}) {
+ my (@req, @con, %seen_dep);
+ for my $depstring (split /[[:blank:]]+/, $deps) {
+ my $dep_params = App::Sqitch::Plan::Depend->parse(
+ $depstring,
+ ) or $raise_syntax_error->(__x(
+ '"{dep}" is not a valid dependency specification',
+ dep => $depstring,
+ ));
+ my $dep = App::Sqitch::Plan::Depend->new(
+ plan => $self,
+ %{ $dep_params },
+ );
+ # Prevent dupes.
+ $raise_syntax_error->(
+ __x( 'Duplicate dependency "{dep}"', dep => $depstring ),
+ ) if $seen_dep{$depstring}++;
+ if ($dep->conflicts) {
+ push @con => $dep;
+ } else {
+ push @req => $dep;
+ }
+ }
+ $params{requires} = \@req;
+ $params{conflicts} = \@con;
+ }
+
+ $tag_changes{ $params{name} } = $fh->input_line_number;
+ push @curr_changes => $prev_change = App::Sqitch::Plan::Change->new(
+ plan => $self,
+ ( $prev_tag ? ( since_tag => $prev_tag ) : () ),
+ ( $prev_change ? ( parent => $prev_change ) : () ),
+ %params,
+ );
+ push @lines => $prev_change;
+
+ if (my $duped = $change_named{ $params{name} }) {
+ # Get rework tags by change in reverse order to reworked change.
+ my @rework_tags;
+ for (my $i = $#changes; $changes[$i] ne $duped; $i--) {
+ push @rework_tags => $changes[$i]->tags;
+ }
+ # Add list of rework tags to the reworked change.
+ $duped->add_rework_tags(@rework_tags, $duped->tags);
+ }
+ $change_named{ $params{name} } = $prev_change;
+ }
+ }
+
+ # Sort and store any remaining changes.
+ push @changes => $self->check_changes(
+ $pragmas{project},
+ \%line_no_for,
+ @curr_changes,
+ ) if @curr_changes;
+
+ return {
+ changes => \@changes,
+ lines => \@lines,
+ pragmas => \%pragmas,
+ };
+}
+
+sub _version_line {
+ App::Sqitch::Plan::Pragma->new(
+ plan => shift,
+ name => 'syntax-version',
+ operator => '=',
+ value => SYNTAX_VERSION,
+ );
+}
+
+sub check_changes {
+ my ( $self, $proj ) = ( shift, shift );
+ my $seen = ref $_[0] eq 'HASH' ? shift : {};
+
+ my %position;
+ my @invalid;
+
+ my $i = 0;
+ for my $change (@_) {
+ my @bad;
+
+ # XXX Ignoring conflicts for now.
+ for my $dep ( $change->requires ) {
+ # Ignore dependencies on other projects.
+ if ($dep->got_project) {
+ # Skip if parsed project name different from current project.
+ next if $dep->project ne $proj;
+ } else {
+ # Skip if an ID was passed, is it could be internal or external.
+ next if $dep->got_id;
+ }
+ my $key = $dep->key_name;
+
+ # Skip it if it's a change from an earlier tag.
+ if ($key =~ /.@/) {
+ # Need to look it up before the tag.
+ my ( $change, $tag ) = split /@/ => $key, 2;
+ if ( my $tag_at = $seen->{"\@$tag"} ) {
+ if ( my $change_at = $seen->{$change}) {
+ next if $change_at < $tag_at;
+ }
+ }
+ } else {
+ # Skip it if we've already seen it in the plan.
+ next if exists $seen->{$key} || $position{$key};
+ }
+
+ # Hrm, unknown dependency.
+ push @bad, $key;
+ }
+ $position{$change->name} = ++$i;
+ push @invalid, [ $change->name => \@bad ] if @bad;
+ }
+
+
+ # Nothing bad, then go!
+ return @_ unless @invalid;
+
+ # Build up all of the error messages.
+ my @errors;
+ for my $bad (@invalid) {
+ my $change = $bad->[0];
+ my $max_delta = 0;
+ for my $dep (@{ $bad->[1] }) {
+ if ($change eq $dep) {
+ push @errors => __x(
+ 'Change "{change}" cannot require itself',
+ change => $change,
+ );
+ } elsif (my $pos = $position{ $dep }) {
+ my $delta = $pos - $position{$change};
+ $max_delta = $delta if $delta > $max_delta;
+ push @errors => __xn(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ $delta,
+ change => $change,
+ required => $dep,
+ num => $delta,
+ );
+ } else {
+ push @errors => __x(
+ 'Unknown change "{required}" required by change "{change}"',
+ required => $dep,
+ change => $change,
+ );
+ }
+ }
+ if ($max_delta) {
+ # Suggest that the change be moved.
+ # XXX Potentially offer to move it and rewrite the plan.
+ $errors[-1] .= "\n " . __xn(
+ 'HINT: move "{change}" down {num} line in {plan}',
+ 'HINT: move "{change}" down {num} lines in {plan}',
+ $max_delta,
+ change => $change,
+ num => $max_delta,
+ plan => $self->file,
+ );
+ }
+ }
+
+ # Throw the exception with all of the errors.
+ hurl parse => join(
+ "\n ",
+ __n(
+ 'Dependency error detected:',
+ 'Dependency errors detected:',
+ @errors
+ ),
+ @errors,
+ );
+}
+
+sub open_script {
+ my ( $self, $file ) = @_;
+ # return has higher precedence than or, so use ||.
+ return $file->open('<:utf8_strict') || hurl io => __x(
+ 'Cannot open {file}: {error}',
+ file => $file,
+ error => $!,
+ );
+}
+
+sub syntax_version { shift->_plan->{pragmas}{syntax_version} };
+sub lines { shift->_lines->items }
+sub changes { shift->_changes->changes }
+sub tags { shift->_changes->tags }
+sub count { shift->_changes->count }
+sub index_of { shift->_changes->index_of(shift) }
+sub get { shift->_changes->get(shift) }
+sub contains { shift->_changes->contains( shift ) }
+sub find { shift->_changes->find(shift) }
+sub first_index_of { shift->_changes->first_index_of(@_) }
+sub change_at { shift->_changes->change_at(shift) }
+sub last_tagged_change { shift->_changes->last_tagged_change }
+
+sub search_changes {
+ my ( $self, %p ) = @_;
+
+ my $reverse = 0;
+ if (my $d = delete $p{direction}) {
+ $reverse = $d =~ /^ASC/i ? 0
+ : $d =~ /^DESC/i ? 1
+ : hurl 'Search direction must be either "ASC" or "DESC"';
+ }
+
+ # Limit with regular expressions?
+ my @filters;
+ if (my $regex = delete $p{planner}) {
+ $regex = qr/$regex/;
+ push @filters => sub { $_[0]->planner_name =~ $regex };
+ }
+ if (my $regex = delete $p{name}) {
+ $regex = qr/$regex/;
+ push @filters => sub { $_[0]->name =~ $regex };
+ }
+
+ # Match events?
+ if (my $op = lc(delete $p{operation} || '') ) {
+ push @filters => $op eq 'deploy' ? sub { $_[0]->is_deploy }
+ : $op eq 'revert' ? sub { $_[0]->is_revert }
+ : hurl qq{Unknown change operation "$op"};
+ }
+
+ my $changes = $self->_changes;
+ my $offset = delete $p{offset} || 0;
+ my $limit = delete $p{limit} || 0;
+
+ hurl 'Invalid parameters passed to search_changes(): '
+ . join ', ', sort keys %p if %p;
+
+ # If no filters, we want to return everything.
+ push @filters => sub { 1 } unless @filters;
+
+ if ($reverse) {
+ # Go backwards.
+ my $index = $changes->count - ($offset + 1);
+ my $end_at = $limit - 1;
+ return sub {
+ while ($index > $end_at) {
+ my $change = $changes->change_at($index--) or return;
+ return $change if any { $_->($change) } @filters;
+ }
+ return;
+ };
+ }
+
+ my $index = $offset - 1;
+ my $end_at = $limit ? $index + $limit : $changes->count - 1;
+ return sub {
+ while ($index < $end_at) {
+ my $change = $changes->change_at(++$index) or return;
+ return $change if any { $_->($change) } @filters;
+ }
+ return;
+ };
+}
+
+sub seek {
+ my ( $self, $key ) = @_;
+ my $index = $self->index_of($key);
+ hurl plan => __x(
+ 'Cannot find change "{change}" in plan',
+ change => $key,
+ ) unless defined $index;
+ $self->position($index);
+ return $self;
+}
+
+sub reset {
+ my $self = shift;
+ $self->position(-1);
+ return $self;
+}
+
+sub next {
+ my $self = shift;
+ if ( my $next = $self->peek ) {
+ $self->position( $self->position + 1 );
+ return $next;
+ }
+ $self->position( $self->position + 1 ) if defined $self->current;
+ return undef;
+}
+
+sub current {
+ my $self = shift;
+ my $pos = $self->position;
+ return if $pos < 0;
+ $self->_changes->change_at( $pos );
+}
+
+sub peek {
+ my $self = shift;
+ $self->_changes->change_at( $self->position + 1 );
+}
+
+sub last {
+ shift->_changes->change_at( -1 );
+}
+
+sub do {
+ my ( $self, $code ) = @_;
+ while ( local $_ = $self->next ) {
+ return unless $code->($_);
+ }
+}
+
+sub tag {
+ my ( $self, %p ) = @_;
+ ( my $name = $p{name} ) =~ s/^@//;
+ $self->_is_valid(tag => $name);
+
+ my $changes = $self->_changes;
+ my $key = "\@$name";
+
+ hurl plan => __x(
+ 'Tag "{tag}" already exists',
+ tag => $key
+ ) if defined $changes->index_of($key);
+
+ my $change;
+ if (my $spec = $p{change}) {
+ $change = $changes->get($spec) or hurl plan => __x(
+ 'Unknown change: "{change}"',
+ change => $spec,
+ );
+ } else {
+ $change = $changes->last_change or hurl plan => __x(
+ 'Cannot apply tag "{tag}" to a plan with no changes',
+ tag => $key
+ );
+ }
+
+ my $tag = App::Sqitch::Plan::Tag->new(
+ %p,
+ plan => $self,
+ name => $name,
+ change => $change,
+ );
+
+ $change->add_tag($tag);
+ $changes->index_tag( $changes->index_of( $change->id ), $tag );
+
+ # Add tag to line list, after the change and any preceding tags.
+ my $lines = $self->_lines;
+ $lines->insert_at( $tag, $lines->index_of($change) + $change->tags );
+ return $tag;
+}
+
+sub _parse_deps {
+ my ( $self, $p ) = @_;
+ # Dependencies must be parsed into objects.
+ $p->{requires} = [ map {
+ my $p = App::Sqitch::Plan::Depend->parse($_) // hurl plan => __x(
+ '"{dep}" is not a valid dependency specification',
+ dep => $_,
+ );
+ App::Sqitch::Plan::Depend->new(
+ %{ $p },
+ plan => $self,
+ conflicts => 0,
+ );
+ } uniq @{ $p->{requires} } ] if $p->{requires};
+
+ $p->{conflicts} = [ map {
+ my $p = App::Sqitch::Plan::Depend->parse("!$_") // hurl plan => __x(
+ '"{dep}" is not a valid dependency specification',
+ dep => $_,
+ );
+ App::Sqitch::Plan::Depend->new(
+ %{ $p },
+ plan => $self,
+ conflicts => 1,
+ );
+ } uniq @{ $p->{conflicts} } ] if $p->{conflicts};
+}
+
+sub add {
+ my ( $self, %p ) = @_;
+ $self->_is_valid(change => $p{name});
+ my $changes = $self->_changes;
+
+ if ( defined( my $idx = $changes->index_of( $p{name} . '@HEAD' ) ) ) {
+ my $tag_idx = $changes->index_of_last_tagged;
+ hurl plan => __x(
+ qq{Change "{change}" already exists in plan {file}.\n}
+ . 'Use "sqitch rework" to copy and rework it',
+ change => $p{name},
+ file => $self->file,
+ );
+ }
+
+ $self->_parse_deps(\%p);
+ my $change = App::Sqitch::Plan::Change->new( %p, plan => $self );
+
+ # Make sure dependencies are valid.
+ $self->_check_dependencies( $change, 'add' );
+
+ # We good. Append a blank line if the previous change has a tag.
+ if ( $changes->count ) {
+ my $prev = $changes->change_at( $changes->count - 1 );
+ if ( $prev->tags ) {
+ $self->_lines->append(
+ App::Sqitch::Plan::Blank->new( plan => $self )
+ );
+ }
+ }
+
+ # Append the change and return.
+ $changes->append( $change );
+ $self->_lines->append( $change );
+ return $change;
+}
+
+sub rework {
+ my ( $self, %p ) = @_;
+ my $changes = $self->_changes;
+ my $idx = $changes->index_of( $p{name} . '@HEAD') // hurl plan => __x(
+ qq{Change "{change}" does not exist in {file}.\n}
+ . 'Use "sqitch add {change}" to add it to the plan',
+ change => $p{name},
+ file => $self->file,
+ );
+
+ my $tag_idx = $changes->index_of_last_tagged;
+ hurl plan => __x(
+ qq{Cannot rework "{change}" without an intervening tag.\n}
+ . 'Use "sqitch tag" to create a tag and try again',
+ change => $p{name},
+ ) if !defined $tag_idx || $tag_idx < $idx;
+
+ $self->_parse_deps(\%p);
+
+ my ($tag) = $changes->change_at($tag_idx)->tags;
+ unshift @{ $p{requires} ||= [] } => App::Sqitch::Plan::Depend->new(
+ plan => $self,
+ change => $p{name},
+ tag => $tag->name,
+ );
+
+ my $orig = $changes->change_at($idx);
+ my $new = App::Sqitch::Plan::Change->new( %p, plan => $self );
+
+ # Make sure dependencies are valid.
+ $self->_check_dependencies( $new, 'rework' );
+
+ # We good.
+ $orig->add_rework_tags($tag);
+ $changes->append( $new );
+ $self->_lines->append( $new );
+ return $new;
+}
+
+sub _check_dependencies {
+ my ( $self, $change, $action ) = @_;
+ my $changes = $self->_changes;
+ my $project = $self->project;
+ for my $req ( $change->requires ) {
+ next if $req->project ne $project;
+ $req = $req->key_name;
+ next if defined $changes->index_of($req =~ /@/ ? $req : $req . '@HEAD');
+ my $name = $change->name;
+ if ($action eq 'add') {
+ hurl plan => __x(
+ 'Cannot add change "{change}": requires unknown change "{req}"',
+ change => $name,
+ req => $req,
+ );
+ } else {
+ hurl plan => __x(
+ 'Cannot rework change "{change}": requires unknown change "{req}"',
+ change => $name,
+ req => $req,
+ );
+ }
+ }
+ return $self;
+}
+
+sub _is_valid {
+ my ( $self, $type, $name ) = @_;
+ hurl plan => __x(
+ '"{name}" is a reserved name',
+ name => $name
+ ) if exists $reserved{$name};
+ hurl plan => __x(
+ '"{name}" is invalid because it could be confused with a SHA1 ID',
+ name => $name,
+ ) if $name =~ /^[0-9a-f]{40}/;
+
+ unless ($name =~ /\A$name_re\z/) {
+ if ($type eq 'change') {
+ hurl plan => __x(
+ qq{"{name}" is invalid: changes must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ name => $name,
+ );
+ } else {
+ hurl plan => __x(
+ qq{"{name}" is invalid: tags must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ name => $name,
+ );
+ }
+ }
+}
+
+sub write_to {
+ my ( $self, $file, $from, $to ) = @_;
+
+ my @lines = $self->lines;
+
+ if (defined $from || defined $to) {
+ my $lines = $self->_lines;
+
+ # Where are the pragmas?
+ my $head_ends_at = do {
+ my $i = 0;
+ while ( my $line = $lines[$i] ) {
+ last if $line->isa('App::Sqitch::Plan::Blank')
+ && !length $line->note;
+ ++$i;
+ }
+ $i;
+ };
+
+ # Where do we start with the changes?
+ my $from_idx = defined $from ? do {
+ my $change = $self->find($from // '@ROOT') // hurl plan => __x(
+ 'Cannot find change {change}',
+ change => $from,
+ );
+ $lines->index_of($change);
+ } : $head_ends_at + 1;
+
+ # Where do we end up?
+ my $to_idx = defined $to ? do {
+ my $change = $self->find( $to // '@HEAD' ) // hurl plan => __x(
+ 'Cannot find change {change}',
+ change => $to,
+ );
+
+ # Include any subsequent tags.
+ if (my @tags = $change->tags) {
+ $change = $tags[-1];
+ }
+ $lines->index_of($change);
+ } : $#lines;
+
+ # Collect the lines to write.
+ @lines = (
+ @lines[ 0 .. $head_ends_at ],
+ @lines[ $from_idx .. $to_idx ],
+ );
+ }
+
+ my $fh = $file->open('>:utf8_strict') or hurl io => __x(
+ 'Cannot open {file}: {error}',
+ file => $file,
+ error => $!
+ );
+ $fh->say($_->as_string) for @lines;
+ $fh->close or hurl io => __x(
+ '"Error closing {file}: {error}',
+ file => $file,
+ error => $!,
+ );
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan - Sqitch Deployment Plan
+
+=head1 Synopsis
+
+ my $plan = App::Sqitch::Plan->new( sqitch => $sqitch );
+ while (my $change = $plan->next) {
+ say "Deploy ", $change->format_name;
+ }
+
+=head1 Description
+
+App::Sqitch::Plan provides the interface for a Sqitch plan. It parses a plan
+file and provides an iteration interface for working with the plan.
+
+=head1 Interface
+
+=head2 Constants
+
+=head3 C<SYNTAX_VERSION>
+
+Returns the current version of the Sqitch plan syntax. Used for the
+C<%sytax-version> pragma.
+
+=head2 Class Methods
+
+=head3 C<name_regex>
+
+ die "$this has no name" unless $this =~ App::Sqitch::Plan->name_regex;
+
+Returns a regular expression that matches names. Note that it is not anchored,
+so if you need to make sure that a string is a valid name and nothing else,
+you will need to anchor it yourself, like so:
+
+ my $name_re = App::Sqitch::Plan->name_regex;
+ die "$this is not a valid name" if $this !~ /\A$name_re\z/;
+
+=head2 Constructors
+
+=head3 C<new>
+
+ my $plan = App::Sqitch::Plan->new( sqitch => $sqitch );
+
+Instantiates and returns a App::Sqitch::Plan object. Takes a single parameter:
+an L<App::Sqitch> object.
+
+=head2 Accessors
+
+=head3 C<sqitch>
+
+ my $sqitch = $plan->sqitch;
+
+Returns the L<App::Sqitch> object that instantiated the plan.
+
+=head3 C<target>
+
+ my $target = $plan->target
+
+Returns the L<App::Sqitch::Target> passed to the constructor.
+
+=head3 C<file>
+
+ my $file = $plan->file;
+
+The file name from which to read the plan.
+
+=head3 C<position>
+
+Returns the current position of the iterator. This is an integer that's used
+as an index into plan. If C<next()> has not been called, or if C<reset()> has
+been called, the value will be -1, meaning it is outside of the plan. When
+C<next> returns C<undef>, the value will be the last index in the plan plus 1.
+
+=head3 C<project>
+
+ my $project = $plan->project;
+
+Returns the name of the project as set via the C<%project> pragma in the plan
+file.
+
+=head3 C<uri>
+
+ my $uri = $plan->uri;
+
+Returns the URI for the project as set via the C<%uri> pragma, which is
+optional. If it is not present, C<undef> will be returned.
+
+=head3 C<syntax_version>
+
+ my $syntax_version = $plan->syntax_version;
+
+Returns the plan syntax version, which is always the latest version.
+
+=head2 Instance Methods
+
+=head3 C<index_of>
+
+ my $index = $plan->index_of('6c2f28d125aff1deea615f8de774599acf39a7a1');
+ my $foo_index = $plan->index_of('@foo');
+ my $bar_index = $plan->index_of('bar');
+ my $bar1_index = $plan->index_of('bar@alpha')
+ my $bar2_index = $plan->index_of('bar@HEAD');
+
+Returns the index of the specified change. Returns C<undef> if no such change
+exists. The argument may be any one of:
+
+=over
+
+=item * An ID
+
+ my $index = $plan->index_of('6c2f28d125aff1deea615f8de774599acf39a7a1');
+
+This is the SHA1 hash of a change or tag. Currently, the full 40-character hexed
+hash string must be specified.
+
+=item * A change name
+
+ my $index = $plan->index_of('users_table');
+
+The name of a change. Will throw an exception if the named change appears more
+than once in the list.
+
+=item * A tag name
+
+ my $index = $plan->index_of('@beta1');
+
+The name of a tag, including the leading C<@>.
+
+=item * A tag-qualified change name
+
+ my $index = $plan->index_of('users_table@beta1');
+
+The named change as it was last seen in the list before the specified tag.
+
+=back
+
+=head3 C<contains>
+
+ say 'Yes!' if $plan->contains('6c2f28d125aff1deea615f8de774599acf39a7a1');
+
+Like C<index_of()>, but never throws an exception, and returns true if the
+plan contains the specified change, and false if it does not.
+
+=head3 C<get>
+
+ my $change = $plan->get('6c2f28d125aff1deea615f8de774599acf39a7a1');
+ my $foo = $plan->get('@foo');
+ my $bar = $plan->get('bar');
+ my $bar1 = $plan->get('bar@alpha')
+ my $bar2 = $plan->get('bar@HEAD');
+
+Returns the change corresponding to the specified ID or name. The argument may
+be in any of the formats described for C<index_of()>.
+
+=head3 C<find>
+
+ my $change = $plan->find('6c2f28d125aff1deea615f8de774599acf39a7a1');
+ my $foo = $plan->find('@foo');
+ my $bar = $plan->find('bar');
+ my $bar1 = $plan->find('bar@alpha')
+ my $bar2 = $plan->find('bar@HEAD');
+
+Finds the change corresponding to the specified ID or name. The argument may be
+in any of the formats described for C<index_of()>. Unlike C<get()>, C<find()>
+will not throw an error if more than one change exists with the specified name,
+but will return the first instance.
+
+=head3 C<first_index_of>
+
+ my $index = $plan->first_index_of($change_name);
+ my $index = $plan->first_index_of($change_name, $change_or_tag_name);
+
+Returns the index of the first instance of the named change in the plan. If a
+second argument is passed, the index of the first instance of the change
+I<after> the index of the second argument will be returned. This is useful
+for getting the index of a change as it was deployed after a particular tag, for
+example, to get the first index of the F<foo> change since the C<@beta> tag, do
+this:
+
+ my $index = $plan->first_index_of('foo', '@beta');
+
+You can also specify the first instance of a change after another change,
+including such a change at the point of a tag:
+
+ my $index = $plan->first_index_of('foo', 'users_table@beta1');
+
+The second argument must unambiguously refer to a single change in the plan. As
+such, it should usually be a tag name or tag-qualified change name. Returns
+C<undef> if the change does not appear in the plan, or if it does not appear
+after the specified second argument change name.
+
+=head3 C<last_tagged_change>
+
+ my $change = $plan->last_tagged_change;
+
+Returns the last tagged change object. Returns C<undef> if no changes have
+been tagged.
+
+=head3 C<change_at>
+
+ my $change = $plan->change_at($index);
+
+Returns the change at the specified index.
+
+=head3 C<seek>
+
+ $plan->seek('@foo');
+ $plan->seek('bar');
+
+Move the plan position to the specified change. Dies if the change cannot be found
+in the plan.
+
+=head3 C<reset>
+
+ $plan->reset;
+
+Resets iteration. Same as C<< $plan->position(-1) >>, but better.
+
+=head3 C<next>
+
+ while (my $change = $plan->next) {
+ say "Deploy ", $change->format_name;
+ }
+
+Returns the next L<change|App::Sqitch::Plan::Change> in the plan. Returns C<undef>
+if there are no more changes.
+
+=head3 C<last>
+
+ my $change = $plan->last;
+
+Returns the last change in the plan. Does not change the current position.
+
+=head3 C<current>
+
+ my $change = $plan->current;
+
+Returns the same change as was last returned by C<next()>. Returns C<undef> if
+C<next()> has not been called or if the plan has been reset.
+
+=head3 C<peek>
+
+ my $change = $plan->peek;
+
+Returns the next change in the plan without incrementing the iterator. Returns
+C<undef> if there are no more changes beyond the current change.
+
+=head3 C<changes>
+
+ my @changes = $plan->changes;
+
+Returns all of the changes in the plan. This constitutes the entire plan.
+
+=head3 C<tags>
+
+ my @tags = $plan->tags;
+
+Returns all of the tags in the plan.
+
+=head3 C<count>
+
+ my $count = $plan->count;
+
+Returns the number of changes in the plan.
+
+=head3 C<lines>
+
+ my @lines = $plan->lines;
+
+Returns all of the lines in the plan. This includes all the
+L<changes|App::Sqitch::Plan::Change>, L<tags|App::Sqitch::Plan::Tag>,
+L<pragmas|App::Sqitch::Plan::Pragma>, and L<blank
+lines|App::Sqitch::Plan::Blank>.
+
+=head3 C<do>
+
+ $plan->do(sub { say $_[0]->name; return $_[0]; });
+ $plan->do(sub { say $_->name; return $_; });
+
+Pass a code reference to this method to execute it for each change in the plan.
+Each change will be stored in C<$_> before executing the code reference, and
+will also be passed as the sole argument. If C<next()> has been called prior
+to the call to C<do()>, then only the remaining changes in the iterator will
+passed to the code reference. Iteration terminates when the code reference
+returns false, so be sure to have it return a true value if you want it to
+iterate over every change.
+
+=head3 C<search_changes>
+
+ my $iter = $engine->search_changes( %params );
+ while (my $change = $iter->()) {
+ say '* $change->{event}ed $change->{change}";
+ }
+
+Searches the changes in the plan returns an iterator code reference with the
+results. If no parameters are provided, a list of all changes will be returned
+from the iterator in plan order. The supported parameters are:
+
+=over
+
+=item C<event>
+
+An array of the type of event to search for. Allowed values are "deploy" and
+ "revert".
+
+=item C<name>
+
+Limit the results to changes with names matching the specified regular
+expression.
+
+=item C<planner>
+
+Limit the changes to those added by planners matching the specified regular
+expression.
+
+=item C<limit>
+
+Limit the number of changes to the specified number.
+
+=item C<offset>
+
+Skip the specified number of events.
+
+=item C<direction>
+
+Return the results in the specified order, which must be a value matching
+C</^(:?a|de)sc/i> for "ascending" or "descending".
+
+=back
+
+=head3 C<write_to>
+
+ $plan->write_to($file);
+ $plan->write_to($file, $from, $to);
+
+Write the plan to the named file, including notes and white space from the
+original plan file. If C<from> and/or C<$to> are provided, the plan will be
+written only with the pragmas headers and the lines between those specified
+changes.
+
+=head3 C<open_script>
+
+ my $file_handle = $plan->open_script( $change->deploy_file );
+
+Opens the script file passed to it and returns a file handle for reading. The
+script file must be encoded in UTF-8.
+
+=head3 C<load>
+
+ my $plan_data = $plan->load;
+
+Loads the plan data. Called internally, not meant to be called directly, as it
+parses the plan file and deploy scripts every time it's called. If you want
+the all of the changes, call C<changes()> instead. And if you want to load an
+alternate plan, use C<parse()>.
+
+=head3 C<parse>
+
+ $plan->parse($plan_data);
+
+Load an alternate plan by passing the complete text of the plan. The text
+should be UTF-8 encoded. Useful for loading a plan from a different VCS
+branch, for example.
+
+=head3 C<check_changes>
+
+ @changes = $plan->check_changes( $project, @changes );
+ @changes = $plan->check_changes( $project, { '@foo' => 1 }, @changes );
+
+Checks a list of changes to validate their dependencies and returns them. If
+the second argument is a hash reference, its keys should be previously-seen
+change and tag names that can be assumed to be satisfied requirements for the
+succeeding changes.
+
+=head3 C<tag>
+
+ $plan->tag( name => 'whee' );
+
+Tags a change in the plan. Exits with a fatal error if the tag already exists
+in the plan or if a change cannot be found to tag. The supported parameters
+are:
+
+=over
+
+=item C<name>
+
+The tag name to use. Required.
+
+=item C<change>
+
+The change to be tagged, specified as a supported change specification as
+described in L<sqitchchanges>. Defaults to the last change in the plan.
+
+=item C<note>
+
+A brief note about the tag.
+
+=item C<planner_name>
+
+The name of the user adding the tag to the plan. Defaults to the value of the
+C<user.name> configuration variable.
+
+=item C<planner_email>
+
+The email address of the user adding the tag to the plan. Defaults to the
+value of the C<user.email> configuration variable.
+
+=back
+
+=head3 C<add>
+
+ $plan->add( name => 'whatevs' );
+ $plan->add(
+ name => 'widgets',
+ requires => [qw(foo bar)],
+ conflicts => [qw(dr_evil)],
+ );
+
+Adds a change to the plan. The supported parameters are the same as those
+passed to the L<App::Sqitch::Plan::Change> constructor. Exits with a fatal
+error if the change already exists, or if the any of the dependencies are
+unknown.
+
+=head3 C<rework>
+
+ $plan->rework( 'whatevs' );
+ $plan->rework( 'widgets', [qw(foo bar)], [qw(dr_evil)] );
+
+Reworks an existing change. Said change must already exist in the plan and be
+tagged or have a tag following it or an exception will be thrown. The previous
+occurrence of the change will have the suffix of the most recent tag added to
+it, and a new tag instance will be added to the list.
+
+=head1 Plan File
+
+A plan file describes the deployment changes to be run against a database, and
+is typically maintained using the L<C<add>|sqitch-add> and
+L<C<rework>|sqitch-rework> commands. Its contents must be plain text encoded
+as UTF-8. Each line of a plan file may be one of four things:
+
+=over
+
+=item *
+
+A blank line. May include any amount of white space, which will be ignored.
+
+=item * A Pragma
+
+Begins with a C<%>, followed by a pragma name, optionally followed by C<=> and
+a value. Currently, the only pragma recognized by Sqitch is C<syntax-version>.
+
+=item * A change.
+
+A named change change as defined in L<sqitchchanges>. A change may then also
+contain a space-delimited list of dependencies, which are the names of other
+changes or tags prefixed with a colon (C<:>) for required changes or with an
+exclamation point (C<!>) for conflicting changes.
+
+Changes with a leading C<-> are slated to be reverted, while changes with no
+character or a leading C<+> are to be deployed.
+
+=item * A tag.
+
+A named deployment tag, generally corresponding to a release name. Begins with
+a C<@>, followed by one or more non-blanks characters, excluding "@", ":",
+"#", and blanks. The first and last characters must not be punctuation
+characters.
+
+=item * A note.
+
+Begins with a C<#> and goes to the end of the line. Preceding white space is
+ignored. May appear on a line after a pragma, change, or tag.
+
+=back
+
+Here's an example of a plan file with a single deploy change and tag:
+
+ %syntax-version=1.0.0
+ +users_table
+ @alpha
+
+There may, of course, be any number of tags and changes. Here's an expansion:
+
+ %syntax-version=1.0.0
+ +users_table
+ +insert_user
+ +update_user
+ +delete_user
+ @root
+ @alpha
+
+Here we have four changes -- "users_table", "insert_user", "update_user", and
+"delete_user" -- followed by two tags: "@root" and "@alpha".
+
+Most plans will have many changes and tags. Here's a longer example with three
+tagged deployment points, as well as a change that is deployed and later
+reverted:
+
+ %syntax-version=1.0.0
+ +users_table
+ +insert_user
+ +update_user
+ +delete_user
+ +dr_evil
+ @root
+ @alpha
+
+ +widgets_table
+ +list_widgets
+ @beta
+
+ -dr_evil
+ +ftw
+ @gamma
+
+Using this plan, to deploy to the "beta" tag, all of the changes up to the
+"@root" and "@alpha" tags must be deployed, as must changes listed before the
+"@beta" tag. To then deploy to the "@gamma" tag, the "dr_evil" change must be
+reverted and the "ftw" change must be deployed. If you then choose to revert
+to "@alpha", then the "ftw" change will be reverted, the "dr_evil" change
+re-deployed, and the "@gamma" tag removed; then "list_widgets" must be
+reverted and the associated "@beta" tag removed, then the "widgets_table"
+change must be reverted.
+
+Changes can only be repeated if one or more tags intervene. This allows Sqitch
+to distinguish between them. An example:
+
+ %syntax-version=1.0.0
+ +users_table
+ @alpha
+
+ +add_widget
+ +widgets_table
+ @beta
+
+ +add_user
+ @gamma
+
+ +widgets_created_at
+ @delta
+
+ +add_widget
+
+Note that the "add_widget" change is repeated after the "@beta" tag, and at
+the end. Sqitch will notice the repetition when it parses this file, and allow
+it, because at least one tag "@beta" appears between the instances of
+"add_widget". When deploying, Sqitch will fetch the instance of the deploy
+script as of the "@delta" tag and apply it as the first change, and then, when
+it gets to the last change, retrieve the current instance of the deploy
+script. How does it find such files? The first instances files will either be
+named F<add_widget@delta.sql> or (soon) findable in the VCS history as of a
+VCS "delta" tag.
+
+=head2 Grammar
+
+Here is the EBNF Grammar for the plan file:
+
+ plan-file = { <pragma> | <change-line> | <tag-line> | <note-line> | <blank-line> }* ;
+
+ blank-line = [ <blanks> ] <eol>;
+ note-line = <note> ;
+ change-line = <name> [ "[" { <requires> | <conflicts> } "]" ] ( <eol> | <note> ) ;
+ tag-line = <tag> ( <eol> | <note> ) ;
+ pragma = "%" [ <blanks> ] <name> [ <blanks> ] = [ <blanks> ] <value> ( <eol> | <note> ) ;
+
+ tag = "@" <name> ;
+ requires = <name> ;
+ conflicts = "!" <name> ;
+ name = <non-punct> [ [ ? non-blank and not "@", ":", or "#" characters ? ] <non-punct> ] ;
+ non-punct = ? non-punctuation, non-blank character ? ;
+ value = ? non-EOL or "#" characters ?
+
+ note = [ <blanks> ] "#" [ <string> ] <EOL> ;
+ eol = [ <blanks> ] <EOL> ;
+
+ blanks = ? blank characters ? ;
+ string = ? non-EOL characters ? ;
+
+And written as regular expressions:
+
+ my $eol = qr/[[:blank:]]*$/
+ my $note = qr/(?:[[:blank:]]+)?[#].+$/;
+ my $punct = q{-!"#$%&'()*+,./:;<=>?@[\\]^`{|}~};
+ my $name = qr/[^$punct[:blank:]](?:(?:[^[:space:]:#@]+)?[^$punct[:blank:]])?/;
+ my $tag = qr/[@]$name/;
+ my $requires = qr/$name/;
+ my conflicts = qr/[!]$name/;
+ my $tag_line = qr/^$tag(?:$note|$eol)/;
+ my $change_line = qr/^$name(?:[[](?:$requires|$conflicts)+[]])?(?:$note|$eol)/;
+ my $note_line = qr/^$note/;
+ my $pragma = qr/^][[:blank:]]*[%][[:blank:]]*$name[[:blank:]]*=[[:blank:]].+?(?:$note|$eol)$/;
+ my $blank_line = qr/^$eol/;
+ my $plan = qr/(?:$pragma|$change_line|$tag_line|$note_line|$blank_line)+/ms;
+
+=head1 See Also
+
+=over
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/Blank.pm b/lib/App/Sqitch/Plan/Blank.pm
new file mode 100644
index 00000000..ec44682c
--- /dev/null
+++ b/lib/App/Sqitch/Plan/Blank.pm
@@ -0,0 +1,62 @@
+package App::Sqitch::Plan::Blank;
+
+use 5.010;
+use utf8;
+use namespace::autoclean;
+use Moo;
+extends 'App::Sqitch::Plan::Line';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has '+name' => ( default => '', required => 0 );
+
+sub format_name { '' }
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::Blank - Sqitch deployment plan blank line
+
+=head1 Synopsis
+
+ my $plan = App::Sqitch::Plan->new( sqitch => $sqitch );
+ for my $line ($plan->lines) {
+ say $line->as_string;
+ }
+
+=head1 Description
+
+An App::Sqitch::Plan::Blank represents a blank line or comment-only line in
+the plan file. See L<App::Sqitch::Plan::Line> for its interface. The only
+difference is that the C<name> is always an empty string.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/Change.pm b/lib/App/Sqitch/Plan/Change.pm
new file mode 100644
index 00000000..2d08c724
--- /dev/null
+++ b/lib/App/Sqitch/Plan/Change.pm
@@ -0,0 +1,670 @@
+package App::Sqitch::Plan::Change;
+
+use 5.010;
+use utf8;
+use namespace::autoclean;
+use Encode;
+use Moo;
+use App::Sqitch::Types qw(Str Bool Maybe Change Tag Depend UserEmail DateTime ArrayRef);
+use App::Sqitch::Plan::Depend;
+use Locale::TextDomain qw(App-Sqitch);
+extends 'App::Sqitch::Plan::Line';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has _requires => (
+ is => 'ro',
+ isa => ArrayRef[Depend],
+ init_arg => 'requires',
+ default => sub { [] },
+);
+
+sub requires { @{ shift->_requires } }
+
+has _conflicts => (
+ is => 'ro',
+ isa => ArrayRef[Depend],
+ init_arg => 'conflicts',
+ default => sub { [] },
+);
+
+sub conflicts { @{ shift->_conflicts } }
+
+has pspace => (
+ is => 'ro',
+ isa => Str,
+ default => ' ',
+);
+
+has since_tag => (
+ is => 'ro',
+ isa => Tag,
+);
+
+has parent => (
+ is => 'ro',
+ isa => Change,
+);
+
+has _rework_tags => (
+ is => 'ro',
+ isa => ArrayRef[Tag],
+ init_arg => 'rework_tags',
+ lazy => 1,
+ default => sub { [] },
+);
+
+sub rework_tags { @{ shift->_rework_tags } }
+sub add_rework_tags { push @{ shift->_rework_tags } => @_ }
+sub clear_rework_tags { @{ shift->_rework_tags } = () }
+sub is_reworked { @{ shift->_rework_tags } > 0 }
+
+after add_rework_tags => sub {
+ my $self = shift;
+ # Need to reset the file name if a new value is passed.
+ $self->_clear_path_segments(undef);
+};
+
+has _tags => (
+ is => 'ro',
+ isa => ArrayRef[Tag],
+ lazy => 1,
+ default => sub { [] },
+);
+
+sub tags { @{ shift->_tags } }
+sub add_tag { push @{ shift->_tags } => @_ }
+
+has _path_segments => (
+ is => 'ro',
+ isa => ArrayRef[Str],
+ lazy => 1,
+ clearer => 1, # Creates _clear_path_segments().
+ default => sub {
+ my $self = shift;
+ my @path = split m{/} => $self->name;
+ my $ext = '.' . $self->target->extension;
+ if (my @rework_tags = $self->rework_tags) {
+ # Determine suffix based on the first one found in the deploy dir.
+ my $dir = $self->deploy_dir;
+ my $bn = pop @path;
+ my $first;
+ for my $tag (@rework_tags) {
+ my $fn = join '', $bn, $tag->format_name, $ext;
+ $first //= $fn;
+ if ( -e $dir->file(@path, $fn) ) {
+ push @path => $fn;
+ $first = undef;
+ last;
+ }
+ }
+ push @path => $first if defined $first;
+ } else {
+ $path[-1] .= $ext;
+ }
+ return \@path;
+ },
+);
+
+sub path_segments { @{ shift->_path_segments } }
+
+has info => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $reqs = join "\n + ", map { $_->as_string } $self->requires;
+ my $confs = join "\n - ", map { $_->as_string } $self->conflicts;
+ return join "\n", (
+ 'project ' . $self->project,
+ ( $self->uri ? ( 'uri ' . $self->uri->canonical ) : () ),
+ 'change ' . $self->format_name,
+ ( $self->parent ? ( 'parent ' . $self->parent->id ) : () ),
+ 'planner ' . $self->format_planner,
+ 'date ' . $self->timestamp->as_string,
+ ( $reqs ? "requires\n + $reqs" : ()),
+ ( $confs ? "conflicts\n - $confs" : ()),
+ ( $self->note ? ('', $self->note) : ()),
+ );
+ }
+);
+
+has id => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $content = encode_utf8 shift->info;
+ require Digest::SHA;
+ return Digest::SHA->new(1)->add(
+ 'change ' . length($content) . "\0" . $content
+ )->hexdigest;
+ }
+);
+
+has timestamp => (
+ is => 'ro',
+ isa => DateTime,
+ default => sub { require App::Sqitch::DateTime && App::Sqitch::DateTime->now },
+);
+
+has planner_name => (
+ is => 'ro',
+ isa => Str,
+ default => sub { shift->sqitch->user_name },
+);
+
+has planner_email => (
+ is => 'ro',
+ isa => UserEmail,
+ default => sub { shift->sqitch->user_email },
+);
+
+has script_hash => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ builder => '_deploy_hash'
+);
+
+sub dependencies {
+ my $self = shift;
+ return $self->requires, $self->conflicts;
+}
+
+sub deploy_dir {
+ my $self = shift;
+ my $target = $self->target;
+ return $self->is_reworked
+ ? $target->reworked_deploy_dir
+ : $target->deploy_dir;
+}
+
+sub deploy_file {
+ my $self = shift;
+ $self->deploy_dir->file( $self->path_segments );
+}
+
+sub _deploy_hash {
+ my $path = shift->deploy_file;
+ return undef unless -f $path;
+ require Digest::SHA;
+ my $sha = Digest::SHA->new(1);
+ $sha->add( $path->slurp(iomode => '<:raw') );
+ return $sha->hexdigest;
+}
+
+sub revert_dir {
+ my $self = shift;
+ my $target = $self->target;
+ return $self->is_reworked
+ ? $target->reworked_revert_dir
+ : $target->revert_dir;
+}
+
+sub revert_file {
+ my $self = shift;
+ $self->revert_dir->file( $self->path_segments );
+}
+
+sub verify_dir {
+ my $self = shift;
+ my $target = $self->target;
+ return $self->is_reworked
+ ? $target->reworked_verify_dir
+ : $target->verify_dir;
+}
+
+sub verify_file {
+ my $self = shift;
+ $self->verify_dir->file( $self->path_segments );
+}
+
+sub script_file {
+ my ($self, $name) = @_;
+ if ( my $meth = $self->can("$name\_file") ) {
+ return $self->$meth;
+ }
+ return $self->target->top_dir->subdir($name)->cleanup->file(
+ $self->path_segments
+ );
+}
+
+sub is_revert {
+ shift->operator eq '-';
+}
+
+sub is_deploy {
+ shift->operator ne '-';
+}
+
+sub action {
+ shift->is_deploy ? 'deploy' : 'revert';
+}
+
+sub format_name_with_tags {
+ my $self = shift;
+ return join ' ', $self->format_name, map { $_->format_name } $self->tags;
+}
+
+sub format_tag_qualified_name {
+ my $self = shift;
+ my ($tag) = $self->tags;
+ unless ($tag) {
+ ($tag) = $self->rework_tags or return $self->format_name . '@HEAD';
+ }
+ return join '', $self->format_name, $tag->format_name;
+}
+
+sub format_dependencies {
+ my $self = shift;
+ my $deps = join(
+ ' ',
+ map { $_->as_plan_string } $self->requires, $self->conflicts
+ ) or return '';
+ return "[$deps]";
+}
+
+sub format_name_with_dependencies {
+ my $self = shift;
+ my $dep = $self->format_dependencies or return $self->format_name;
+ return $self->format_name . $self->pspace . $dep;
+}
+
+sub format_op_name_dependencies {
+ my $self = shift;
+ return $self->format_operator . $self->format_name_with_dependencies;
+}
+
+sub format_planner {
+ my $self = shift;
+ return join ' ', $self->planner_name, '<' . $self->planner_email . '>';
+}
+
+sub deploy_handle {
+ my $self = shift;
+ $self->plan->open_script($self->deploy_file);
+}
+
+sub revert_handle {
+ my $self = shift;
+ $self->plan->open_script($self->revert_file);
+}
+
+sub verify_handle {
+ my $self = shift;
+ $self->plan->open_script($self->verify_file);
+}
+
+sub format_content {
+ my $self = shift;
+ return $self->SUPER::format_content . $self->pspace . join (
+ ' ',
+ ($self->format_dependencies || ()),
+ $self->timestamp->as_string,
+ $self->format_planner
+ );
+}
+
+sub requires_changes {
+ my $self = shift;
+ my $plan = $self->plan;
+ return map { $plan->find( $_->key_name ) } $self->requires;
+}
+
+sub conflicts_changes {
+ my $self = shift;
+ my $plan = $self->plan;
+ return map { $plan->find( $_->key_name ) } $self->conflicts;
+}
+
+sub note_prompt {
+ my ( $self, %p ) = @_;
+
+ return join(
+ '',
+ __x(
+ "Please enter a note for your change. Lines starting with '#' will\n" .
+ "be ignored, and an empty message aborts the {command}.",
+ command => $p{for},
+ ),
+ "\n",
+ __x('Change to {command}:', command => $p{for}),
+ "\n\n",
+ ' ', $self->format_op_name_dependencies,
+ join "\n ", '', @{ $p{scripts} },
+ "\n",
+ );
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::Change - Sqitch deployment plan tag
+
+=head1 Synopsis
+
+ my $plan = App::Sqitch::Plan->new( sqitch => $sqitch );
+ for my $line ($plan->lines) {
+ say $line->as_string;
+ }
+
+=head1 Description
+
+A App::Sqitch::Plan::Change represents a change as parsed from a plan file. In
+addition to the interface inherited from L<App::Sqitch::Plan::Line>, it offers
+interfaces for parsing dependencies from the deploy script, as well as for
+opening the deploy, revert, and verify scripts.
+
+=head1 Interface
+
+See L<App::Sqitch::Plan::Line> for the basics.
+
+=head2 Accessors
+
+=head3 C<since_tag>
+
+An L<App::Sqitch::Plan::Tag> object representing the last tag to appear in the
+plan B<before> the change. May be C<undef>.
+
+=head3 C<pspace>
+
+Blank space separating the change name from the dependencies, timestamp, and
+planner in the file.
+
+=head3 C<is_reworked>
+
+Boolean indicting whether or not the change has been reworked.
+
+=head3 C<info>
+
+Information about the change, returned as a string. Includes the change ID,
+the name and email address of the user who added the change to the plan, and
+the timestamp for when the change was added to the plan.
+
+=head3 C<id>
+
+A SHA1 hash of the data returned by C<info()>, which can be used as a
+globally-unique identifier for the change.
+
+=head3 C<timestamp>
+
+Returns the an L<App::Sqitch::DateTime> object representing the time at which
+the change was added to the plan.
+
+=head3 C<planner_name>
+
+Returns the name of the user who added the change to the plan.
+
+=head3 C<planner_email>
+
+Returns the email address of the user who added the change to the plan.
+
+=head3 C<parent>
+
+Parent change object.
+
+=head3 C<tags>
+
+A list of tag objects associated with the change.
+
+=head2 Instance Methods
+
+=head3 C<path_segments>
+
+ my @segments = $change->path_segments;
+
+Returns the path segment for the change. For example, if the change is named
+"foo", C<('foo.sql')> is returned. If the change is named "functions/bar>
+C<('functions', 'bar.sql')> is returned. Internally, this data is used to
+create the deploy, revert, and verify file names.
+
+=head3 C<deploy_dir>
+
+ my $file = $change->deploy_dir;
+
+Returns the path to the deploy directory for the change.
+
+=head3 C<deploy_file>
+
+ my $file = $change->deploy_file;
+
+Returns the path to the deploy script file for the change.
+
+=head3 C<revert_dir>
+
+ my $file = $change->revert_dir;
+
+Returns the path to the revert directory for the change.
+
+=head3 C<revert_file>
+
+ my $file = $change->revert_file;
+
+Returns the path to the revert script file for the change.
+
+=head3 C<verify_dir>
+
+ my $file = $change->verify_dir;
+
+Returns the path to the verify directory for the change.
+
+=head3 C<verify_file>
+
+ my $file = $change->verify_file;
+
+Returns the path to the verify script file for the change.
+
+=head3 C<script_file>
+
+ my $file = $sqitch->script_file($script_name);
+
+Returns the path to a script, for the change.
+
+=head3 C<script_hash>
+
+ my $hash = $change->script_hash;
+
+Returns the hex digest of the SHA-1 hash for the deploy script.
+
+=head3 C<rework_tags>
+
+ my @tags = $change->rework_tags;
+
+Returns a list of tags that occur between a change and its next reworking.
+Returns an empty list if the change is not reworked.
+
+=head3 C<add_tag>
+
+ $change->add_tag($tag);
+
+Adds a tag object to the change.
+
+=head3 C<add_rework_tags>
+
+ $change->add_rework_tags(@tags);
+
+Adds tags to the list of rework tags.
+
+=head3 C<clear_rework_tags>
+
+ $change->clear_rework_tags(@tags);
+
+Clears the list of rework tags.
+
+=head3 C<requires>
+
+ my @requires = $change->requires;
+
+Returns a list of L<App::Sqitch::Plan::Depend> objects representing changes
+required by this change.
+
+=head3 C<requires_changes>
+
+ my @requires_changes = $change->requires_changes;
+
+Returns a list of the C<App::Sqitch::Plan::Change> objects representing
+changes required by this change.
+
+=head3 C<conflicts>
+
+ my @conflicts = $change->conflicts;
+
+Returns a list of L<App::Sqitch::Plan::Depend> objects representing changes
+with which this change conflicts.
+
+=head3 C<conflicts_changes>
+
+ my @conflicts_changes = $change->conflicts_changes;
+
+Returns a list of the C<App::Sqitch::Plan::Change> objects representing
+changes with which this change conflicts.
+
+=head3 C<dependencies>
+
+ my @dependencies = $change->dependencies;
+
+Returns a list of L<App::Sqitch::Plan::Depend> objects representing all
+dependencies, required and conflicting.
+
+=head3 C<is_deploy>
+
+Returns true if the change is intended to be deployed, and false if it should be
+reverted.
+
+=head3 C<is_revert>
+
+Returns true if the change is intended to be reverted, and false if it should be
+deployed.
+
+=head3 C<action>
+
+Returns "deploy" if the change should be deployed, or "revert" if it should be
+reverted.
+
+=head3 C<format_tag_qualified_name>
+
+ my $tag_qualified_name = $change->format_tag_qualified_name;
+
+Returns a string with the change name followed by the next tag in the plan.
+Useful for displaying unambiguous change specifications for reworked changes.
+If there is no tag appearing in the file after the change, the C<@HEAD> will
+be used.
+
+=head3 C<format_name_with_tags>
+
+ my $name_with_tags = $change->format_name_with_tags;
+
+Returns a string formatted with the change name followed by the list of tags, if
+any, associated with the change. Used to display a change as it is deployed.
+
+=head3 C<format_dependencies>
+
+ my $dependencies = $change->format_dependencies;
+
+Returns a string containing a bracketed list of dependencies. If there are no
+dependencies, an empty string will be returned.
+
+=head3 C<format_name_with_dependencies>
+
+ my $name_with_dependencies = $change->format_name_with_dependencies;
+
+Returns a string formatted with the change name followed by a bracketed list
+of dependencies, if any, associated with the change. Used to display a change
+when added to a plan.
+
+=head3 C<format_op_name_dependencies>
+
+ my $op_name_dependencies = $change->format_op_name_dependencies;
+
+Like C<format_name_with_dependencies>, but includes the operator, if present.
+
+=head3 C<format_planner>
+
+ my $planner = $change->format_planner;
+
+Returns a string formatted with the name and email address of the user who
+added the change to the plan.
+
+=head3 C<deploy_handle>
+
+ my $fh = $change->deploy_handle;
+
+Returns an L<IO::File> file handle, opened for reading, for the deploy script
+for the change.
+
+=head3 C<revert_handle>
+
+ my $fh = $change->revert_handle;
+
+Returns an L<IO::File> file handle, opened for reading, for the revert script
+for the change.
+
+=head3 C<verify_handle>
+
+ my $fh = $change->verify_handle;
+
+Returns an L<IO::File> file handle, opened for reading, for the verify script
+for the change.
+
+=head3 C<note_prompt>
+
+ my $prompt = $change->note_prompt(
+ for => 'rework',
+ scripts => [$change->deploy_file, $change->revert_file],
+ );
+
+Overrides the implementation from C<App::Sqitch::Plan::Line> to add the
+C<files> parameter. This is a list of the files to be created for the command.
+These will usually be the deploy, revert, and verify files, but the caller
+might not be creating all of them, so it needs to pass the list.
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Plan>
+
+Class representing a plan.
+
+=item L<App::Sqitch::Plan::Line>
+
+Base class from which App::Sqitch::Plan::Change inherits.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/ChangeList.pm b/lib/App/Sqitch/Plan/ChangeList.pm
new file mode 100644
index 00000000..a1b19aaf
--- /dev/null
+++ b/lib/App/Sqitch/Plan/ChangeList.pm
@@ -0,0 +1,433 @@
+package App::Sqitch::Plan::ChangeList;
+
+use 5.010;
+use utf8;
+use strict;
+use List::Util;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub new {
+ my $class = shift;
+ my $self = bless {
+ list => [],
+ lookup => {},
+ last_tagged_at => undef,
+ } => $class;
+ $self->append(@_) if @_;
+ return $self;
+}
+
+sub count { scalar @{ shift->{list} } }
+sub changes { @{ shift->{list} } }
+sub tags { map { $_->tags } @{ shift->{list} } }
+sub items { @{ shift->{list} } }
+sub change_at { shift->{list}[shift] }
+sub last_change { return shift->{list}[ -1 ] }
+
+# Like [:punct:], but excluding _. Copied from perlrecharclass.
+my $punct = q{-!"#$%&'()*+,./:;<=>?@[\\]^`{|}~};
+
+sub _offset($) {
+ # Look for symbolic references.
+ if ( $_[0] =~ s{(?<![$punct])([~^])(?:(\1)|(\d+))?\z}{} ) {
+ my $offset = $3 // ($2 ? 2 : 1);
+ $offset *= -1 if $1 eq '^';
+ return $offset;
+ } else {
+ return 0;
+ }
+}
+
+sub index_of {
+ my ( $self, $key ) = @_;
+
+ # Look for non-deployed symbolic references.
+ if ( my $offset = _offset $key ) {
+ my $idx = $self->_index_of( $key ) // return undef;
+ $idx += $offset;
+ return $idx < 0 ? undef : $idx > $#{ $self->{list} } ? undef : $idx;
+ } else {
+ return $self->_index_of( $key );
+ }
+}
+
+sub _index_of {
+ my ( $self, $key ) = @_;
+
+ my ( $change, $tag ) = split /@/ => $key, 2;
+
+ if ($change eq '') {
+ # Just want the change with the associated tag.
+ my $idx = $self->{lookup}{'@' . $tag} or return undef;
+ return $idx->[0];
+ }
+
+ my $idx = $self->{lookup}{$change} or return undef;
+ if (defined $tag) {
+ # Wanted for a change as of a specific tag.
+ my $tag_idx = $self->{lookup}{'@' . $tag} or hurl plan => __x(
+ 'Unknown tag "{tag}"',
+ tag => '@' . $tag,
+ );
+ $tag_idx = $tag_idx->[0];
+ for my $i (reverse @{ $idx }) {
+ return $i if $i <= $tag_idx;
+ }
+ return undef;
+ };
+
+ # Just want index for a change name. Return if we have 0 or 1.
+ return $idx->[0] if @{ $idx } < 2;
+
+
+ # Too many changes found. Give the user some options and die.
+ App::Sqitch->vent(__x(
+ 'Change "{change}" is ambiguous. Please specify a tag-qualified change:',
+ change => $key,
+ ));
+
+ my $list = $self->{list};
+ App::Sqitch->vent( ' * ', $list->[$_]->format_tag_qualified_name )
+ for reverse @{ $idx };
+
+ hurl plan => __ 'Change lookup failed';
+}
+
+sub first_index_of {
+ my ( $self, $key, $since ) = @_;
+
+ # Look for non-deployed symbolic references.
+ if ( my $offset = _offset $key ) {
+ my $idx = $self->_first_index_of( $key, $since ) // return undef;
+ $idx += $offset;
+ return $idx < 0 ? undef : $idx > $#{ $self->{list} } ? undef : $idx;
+ } else {
+ return $self->_first_index_of( $key, $since );
+ }
+}
+
+sub _first_index_of {
+ my ( $self, $change, $since ) = @_;
+
+ # Just return the first index if no tag.
+ my $idx = $self->{lookup}{$change} or return undef;
+ return $idx->[0] unless defined $since;
+
+ # Find the tag index.
+ my $since_index = $self->index_of($since) // hurl plan => __x(
+ 'Unknown change: "{change}"',
+ change => $since,
+ );
+
+ # Return the first change after the tag.
+ return List::Util::first { $_ > $since_index } @{ $idx };
+}
+
+sub index_of_last_tagged {
+ shift->{last_tagged_at};
+}
+
+sub last_tagged_change {
+ my $self = shift;
+ return defined $self->{last_tagged_at}
+ ? $self->{list}[ $self->{last_tagged_at} ]
+ : undef;
+}
+
+sub get {
+ my $self = shift;
+ my $idx = $self->index_of(@_) // return undef;
+ return $self->{list}[ $idx ];
+}
+
+sub contains {
+ my ( $self, $name ) = @_;
+ return defined ( $name =~ /@/
+ ? $self->index_of($name)
+ : $self->first_index_of($name)
+ );
+}
+
+sub find {
+ my ( $self, $name ) = @_;
+ my $idx = $name =~ /@/
+ ? $self->index_of($name)
+ : $self->first_index_of($name);
+ return defined $idx ? $self->change_at($idx) : undef;
+}
+
+sub append {
+ my $self = shift;
+ my $list = $self->{list};
+ my $lookup = $self->{lookup};
+
+ for my $change (@_) {
+ push @{ $list } => $change;
+ push @{ $lookup->{ $change->format_name } } => $#$list;
+ $lookup->{ $change->id } = my $pos = [$#$list];
+
+ # Index on the tags, too.
+ for my $tag ($change->tags) {
+ $lookup->{ $tag->format_name } = $pos;
+ $lookup->{ $tag->id } = $pos;
+ $self->{last_tagged_at} = $#$list;
+ }
+ }
+
+ $lookup->{'HEAD'} = $lookup->{'@HEAD'} = [$#$list];
+ $lookup->{'ROOT'} = $lookup->{'@ROOT'} = [0];
+
+ return $self;
+}
+
+sub index_tag {
+ my ( $self, $index, $tag ) = @_;
+ my $list = $self->{list};
+ my $lookup = $self->{lookup};
+ my $pos = [$index];
+ $lookup->{ $tag->id } = $pos;
+ $lookup->{ $tag->format_name } = $pos;
+ $self->{last_tagged_at} = $index if $index == $#{ $self->{list} };
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::ChangeList - Sqitch deployment plan change list
+
+=head1 Synopsis
+
+ my $list = App::Sqitch::Plan::ChangeList->new(
+ $add_roles,
+ $add_users,
+ $insert_user,
+ $insert_user2,
+ );
+
+ my @changes = $list->changes;
+ my $add_users = $list->change_at(1);
+ my $add_users = $list->get('add_users');
+
+ my $insert_user1 = $list->get('insert_user@alpha');
+ my $insert_user2 = $list->get('insert_user');
+
+=head1 Description
+
+This module is used internally by L<App::Sqitch::Plan> to manage plan changes.
+It's modeled on L<Array::AsHash> and L<Hash::MultiValue>, but makes allowances
+for finding changes relative to tags.
+
+=head1 Interface
+
+=head2 Constructors
+
+=head3 C<new>
+
+ my $plan = App::Sqitch::Plan::ChangeList->new( @changes );
+
+Instantiates and returns a App::Sqitch::Plan::ChangeList object with the list of
+changes. Each change should be a L<App::Sqitch::Plan::Change> object. Order will be
+preserved but the location of each change will be indexed by its name and ID, as
+well as the names and IDs of any associated tags.
+
+=head2 Instance Methods
+
+=head3 C<count>
+
+ my $count = $changelist->count;
+
+Returns the number of changes in the list.
+
+=head3 C<changes>
+
+ my @changes = $changelist->changes;
+
+Returns all of the changes in the list.
+
+=head3 C<tags>
+
+ my @tags = $changelist->tags;
+
+Returns all of the tags associated with changes in the list.
+
+=head3 C<items>
+
+ my @changes = $changelist->items;
+
+An alias for C<changes>.
+
+=head3 C<change_at>
+
+ my $change = $change_list->change_at(10);
+
+Returns the change at the specified index.
+
+=head3 C<index_of>
+
+ my $index = $changelist->index_of($change_id);
+ my $index = $changelist->index_of($change_name);
+
+Returns the index of the change with the specified ID or name. The value passed
+may be one of these forms:
+
+=over
+
+=item * An ID
+
+ my $index = $changelist->index_of('6c2f28d125aff1deea615f8de774599acf39a7a1');
+
+This is the SHA1 ID of a change or tag. Currently, the full 40-character hexed
+hash string must be specified.
+
+=item * A change name
+
+ my $index = $changelist->index_of('users_table');
+
+The name of a change. Will throw an exception if the more than one change in the
+list goes by that name.
+
+=item * A tag name
+
+ my $index = $changelist->index_of('@beta1');
+
+The name of a tag, including the leading C<@>.
+
+=item * A tag-qualified change name
+
+ my $index = $changelist->index_of('users_table@beta1');
+
+The named change as it was last seen in the list before the specified tag.
+
+=back
+
+=head3 C<first_index_of>
+
+ my $index = $changelist->first_index_of($change_name);
+ my $index = $changelist->first_index_of($change_name, $name);
+
+Returns the index of the first instance of the named change in the list. If a
+second argument is passed, the index of the first instance of the change
+I<after> the index of the second argument will be returned. This is useful
+for getting the index of a change as it was deployed after a particular tag, for
+example:
+
+ my $index = $changelist->first_index_of('foo', '@beta');
+ my $index = $changelist->first_index_of('foo', 'users_table@beta1');
+
+The second argument must unambiguously refer to a single change in the list. As
+such, it should usually be a tag name or tag-qualified change name. Returns
+C<undef> if the change does not appear in the list, or if it does not appear
+after the specified second argument change name.
+
+=head3 C<last_change>
+
+ my $change = $changelist->last_change;
+
+Returns the last change to be appear in the list. Returns C<undef> if the list
+contains no changes.
+
+=head3 C<last_tagged_change>
+
+ my $change = $changelist->last_tagged_change;
+
+Returns the last tagged change in the list. Returns C<undef> if the list
+contains no tagged changes.
+
+=head3 C<index_of_last_tagged>
+
+ my $index = $changelist->index_of_last_tagged;
+
+Returns the index of the last tagged change in the list. Returns C<undef> if the
+list contains no tags.
+
+=head3 C<get>
+
+ my $change = $changelist->get($id);
+ my $change = $changelist->get($change_name);
+ my $change = $changelist->get($tag_name);
+
+Returns the change for the specified ID or name. The name may be specified as
+described for C<index_of()>. An exception will be thrown if more than one change
+goes by a specified name. As such, it is best to specify it as unambiguously
+as possible: as a tag name, a tag-qualified change name, or an ID.
+
+=head3 C<contains>
+
+ say 'Yes!' if $plan->contains('6c2f28d125aff1deea615f8de774599acf39a7a1');
+
+Like C<index_of()>, but never throws an exception, and returns true if the
+plan contains the specified change, and false if it does not.
+
+=head3 C<find>
+
+ my $change = $changelist->find($id);
+ my $change = $changelist->find($change_name);
+ my $change = $changelist->find($tag_name);
+ my $change = $changelist->find("$change_name\@$tag_name");
+
+Tries to find and return a change based on the argument. If no tag is specified,
+finds and returns the first instance of the named change. Otherwise, it returns
+the change as of the specified tag. Unlike C<get()>, it will not throw an error
+if more than one change exists with the specified name, but will return the
+first instance.
+
+=head3 C<append>
+
+ $changelist->append(@changes);
+
+Append one or more changes to the list. Does not check for duplicates, so
+use with care.
+
+=head3 C<index_tag>
+
+ $changelist->index_tag($index, $tag);
+
+Index the tag at the specified index. That is, the tag is assumed to be
+associated with the change at the specified index, and so the internal look up
+table is updated so that the change at that index can be found via the tag's
+name and ID.
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Plan>
+
+The Sqitch plan.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/Depend.pm b/lib/App/Sqitch/Plan/Depend.pm
new file mode 100644
index 00000000..cbb792b2
--- /dev/null
+++ b/lib/App/Sqitch/Plan/Depend.pm
@@ -0,0 +1,389 @@
+package App::Sqitch::Plan::Depend;
+
+use 5.010;
+use utf8;
+use Moo;
+use App::Sqitch::Types qw(Str Bool Maybe Plan);
+use App::Sqitch::Plan;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use namespace::autoclean;
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has conflicts => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has got_id => (
+ is => 'ro',
+ isa => Bool,
+ required => 1
+);
+
+has got_project => (
+ is => 'ro',
+ isa => Bool,
+ required => 1
+);
+
+has project => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $plan = $self->plan;
+
+ # Local project is the default unless an ID was passed.
+ return $plan->project unless $self->got_id;
+
+ # Local project is default if passed ID is in plan.
+ return $plan->project if $plan->find( $self->id );
+
+ # Otherwise, the project is unknown (and external).
+ return undef;
+ }
+);
+
+has change => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has tag => (
+ is => 'ro',
+ isa => Maybe[Str],
+);
+
+has plan => (
+ is => 'ro',
+ isa => Plan,
+ weak_ref => 1,
+ required => 1,
+);
+
+has id => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $plan = $self->plan;
+ my $proj = $self->project // return undef;
+ return undef if $proj ne $plan->project;
+ my $change = $plan->find( $self->key_name ) // hurl plan => __x(
+ 'Unable to find change "{change}" in plan {file}',
+ change => $self->key_name,
+ file => $plan->file,
+ );
+ return $change->id;
+ }
+);
+
+has resolved_id => (
+ is => 'rw',
+ isa => Maybe[Str],
+);
+
+has is_external => (
+ is => 'ro',
+ isa => Bool,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+
+ # If no project, then it must be external.
+ my $proj = $self->project // return 1;
+
+ # Just compare to the local project.
+ return $proj eq $self->plan->project ? 0 : 1;
+ },
+);
+
+sub type { shift->conflicts ? 'conflict' : 'require' }
+sub required { shift->conflicts ? 0 : 1 }
+sub is_internal { shift->is_external ? 0 : 1 }
+
+sub BUILDARGS {
+ my $class = shift;
+ my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ };
+ hurl 'Depend object must have either "change", "tag", or "id" defined'
+ unless length($p->{change} // '') || length($p->{tag} // '') || $p->{id};
+
+ hurl 'Depend object cannot contain both an ID and a tag or change'
+ if $p->{id} && (length($p->{change} // '') || length($p->{tag} // ''));
+
+ $p->{got_id} = defined $p->{id} ? 1 : 0;
+ $p->{got_project} = defined $p->{project} ? 1 : 0;
+
+ return $p;
+}
+
+sub parse {
+ my ( $class, $string ) = @_;
+ my $name_re = Plan->class->name_regex;
+ return undef if $string !~ /
+ \A # Beginning of string
+ (?<conflicts>!?) # Optional negation
+ (?:(?<project>$name_re)[:])? # Optional project + :
+ (?: # Followed by...
+ (?<id>[0-9a-f]{40}) # SHA1 hash
+ | # - OR -
+ (?<change>$name_re) # Change name
+ (?:[@](?<tag>$name_re))? # Optional tag
+ | # - OR -
+ (?:[@](?<tag>$name_re))? # Tag
+ ) # ... required
+ \z # End of string
+ /x;
+
+ return { %+, conflicts => $+{conflicts} ? 1 : 0 };
+}
+
+sub key_name {
+ my $self = shift;
+ my @parts;
+
+ if (defined (my $change = $self->change)) {
+ push @parts => $change;
+ }
+
+ if (defined (my $tag = $self->tag)) {
+ push @parts => '@' . $tag;
+ }
+
+ if ( !@parts && defined ( my $id = $self->id ) ) {
+ push @parts, $id;
+ }
+
+ return join '' => @parts;
+}
+
+sub as_string {
+ my $self = shift;
+ my $proj = $self->project // return $self->key_name;
+ return $self->key_name if $proj eq $self->plan->project;
+ return "$proj:" . $self->key_name;
+}
+
+sub as_plan_string {
+ my $self = shift;
+ return ($self->conflicts ? '!' : '') . $self->as_string;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::Depend - Sqitch dependency specification
+
+=head1 Synopsis
+
+ my $depend = App::Sqitch::Plan::Depend->new(
+ plan => $plan,
+ App::Sqitch::Plan::Depend->parse('!proj:change@tag')
+ );
+
+=head1 Description
+
+An App::Sqitch::Plan::Line represents a single dependency from the dependency
+list for a planned change. Is is constructed by L<App::Sqitch::Plan> and
+included in L<App::Sqitch::Plan::Change> objects C<conflicts> and C<requires>
+attributes.
+
+=head1 Interface
+
+=head2 Constructors
+
+=head3 C<new>
+
+ my $depend = App::Sqitch::Plan::Depend->new(%params);
+
+Instantiates and returns a App::Sqitch::Plan::Line object. Parameters:
+
+=over
+
+=item C<plan>
+
+The plan with which the dependency is associated. Required.
+
+=item C<project>
+
+Name of the project. Required.
+
+=item C<conflicts>
+
+Boolean to indicate whether the dependency is a conflicting dependency.
+
+=item C<change>
+
+The name of the change.
+
+=item C<tag>
+
+The name of the tag claimed as the dependency.
+
+=item C<id>
+
+The ID of a change. Mutually exclusive with C<change> and C<tag>.
+
+=back
+
+=head3 C<parse>
+
+ my %params = App::Sqitch::Plan::Depend->parse($string);
+
+Parses a dependency specification as extracted from a plan and returns a hash
+reference of parameters suitable for passing to C<new()>. Returns C<undef> if
+the string is not a properly-formatted dependency.
+
+=head2 Accessors
+
+=head3 C<plan>
+
+ my $plan = $depend->plan;
+
+Returns the L<App::Sqitch::Plan> object with which the dependency
+specification is associated.
+
+=head3 C<conflicts>
+
+ say $depend->as_string, ' conflicts' if $depend->conflicts;
+
+Returns true if the dependency is a conflicting dependency, and false if it
+is not (in which case it is a required dependency).
+
+=head3 C<required>
+
+ say $depend->as_string, ' required' if $depend->required;
+
+Returns true if the dependency is a required, and false if it is not (in which
+case it is a conflicting dependency).
+
+=head3 C<type>
+
+ say $depend->type;
+
+Returns a string indicating the type of dependency, either "require" or
+"conflict".
+
+=head3 C<project>
+
+ my $proj = $depend->project;
+
+Returns the name of the project with which the dependency is associated.
+
+=head3 C<got_project>
+
+Returns true if the C<project> parameter was passed to the constructor with a
+defined value, and false if it was not passed to the constructor.
+
+=head3 C<change>
+
+ my $change = $depend->change;
+
+Returns the name of the change, if any. If C<undef> is returned, the dependency
+is a tag-only dependency.
+
+=head3 C<tag>
+
+ my $tag = $depend->tag;
+
+Returns the name of the tag, if any. If C<undef> is returned, the dependency
+is a change-only dependency.
+
+=head3 C<id>
+
+Returns the ID of the change if the dependency was specified as an ID, or if
+the dependency is a local dependency.
+
+=head3 C<got_id>
+
+Returns true if the C<id> parameter was passed to the constructor with a
+defined value, and false if it was not passed to the constructor.
+
+=head3 C<resolved_id>
+
+Change ID used by the engine when deploying a change. That is, if the
+dependency is in the database, it will be assigned this ID from the database.
+If it is not in the database, C<resolved_id> will be undef.
+
+=head3 C<is_external>
+
+Returns true if the dependency references a change external to the current
+project, and false if it is part of the current project.
+
+=head3 C<is_internal>
+
+The opposite of C<is_external()>: returns true if the dependency is in the
+internal (current) project, and false if not.
+
+=head2 Instance Methods
+
+=head3 C<key_name>
+
+Returns the key name of the dependency, with the change name and/or tag,
+properly formatted for passing to the C<find()> method of
+L<App::Sqitch::Plan>. If the dependency was specified as an ID, rather than a
+change or tag, then the ID will be returned.
+
+=head3 C<as_string>
+
+Returns the project-qualified key name. That is, if there is a project name,
+it returns a string with the project name, a colon, and the key name. If there
+is no project name, the key name is returned.
+
+=head3 C<as_plan_string>
+
+ my $string = $depend->as_string;
+
+Returns the full stringification of the dependency, suitable for output to a
+plan file. That is, the same as C<as_string> unless C<conflicts> returns true,
+in which case it is prepended with "!".
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Plan>
+
+Class representing a plan.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/Line.pm b/lib/App/Sqitch/Plan/Line.pm
new file mode 100644
index 00000000..2392f06b
--- /dev/null
+++ b/lib/App/Sqitch/Plan/Line.pm
@@ -0,0 +1,370 @@
+package App::Sqitch::Plan::Line;
+
+use 5.010;
+use utf8;
+use namespace::autoclean;
+use Moo;
+use App::Sqitch::Types qw(Str Plan);
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has name => (
+ is => 'ro',
+ isa => Str,
+ required => 1,
+);
+
+has operator => (
+ is => 'ro',
+ isa => Str,
+ default => '',
+);
+
+has lspace => (
+ is => 'ro',
+ isa => Str,
+ default => '',
+);
+
+has rspace => (
+ is => 'rwp',
+ isa => Str,
+ default => '',
+);
+
+has lopspace => (
+ is => 'ro',
+ isa => Str,
+ default => '',
+);
+
+has ropspace => (
+ is => 'ro',
+ isa => Str,
+ default => '',
+);
+
+has note => (
+ is => 'rw',
+ isa => Str,
+ default => '',
+);
+
+after note => sub {
+ my $self = shift;
+ $self->_set_rspace(' ') if $_[0] && !$self->rspace;
+};
+
+has plan => (
+ is => 'ro',
+ isa => Plan,
+ weak_ref => 1,
+ required => 1,
+ handles => [qw(sqitch project uri target)],
+);
+
+my %escape = (
+ "\n" => '\\n',
+ "\r" => '\\r',
+ '\\' => '\\\\',
+);
+
+my %unescape = reverse %escape;
+
+sub BUILDARGS {
+ my $class = shift;
+ my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ };
+ if (my $note = $p->{note}) {
+ # Trim and then encode newlines.
+ $note =~ s/\A\s+//;
+ $note =~ s/\s+\z//;
+ $note =~ s/(\\[\\nr])/$unescape{$1}/g;
+ $p->{note} = $note;
+ $p->{rspace} //= ' ' if $note && $p->{name};
+ }
+ return $p;
+}
+
+sub request_note {
+ my ( $self, %p ) = @_;
+ my $note = $self->note // '';
+ return $note if $note =~ /\S/;
+
+ # Edit in a file.
+ require File::Temp;
+ my $tmp = File::Temp->new;
+ binmode $tmp, ':utf8_strict';
+ ( my $prompt = $self->note_prompt(%p) ) =~ s/^/# /gms;
+ $tmp->print( "\n", $prompt, "\n" );
+ $tmp->close;
+
+ my $sqitch = $self->sqitch;
+ $sqitch->shell( $sqitch->editor . ' ' . $sqitch->quote_shell($tmp) );
+
+ open my $fh, '<:utf8_strict', $tmp or hurl add => __x(
+ 'Cannot open {file}: {error}',
+ file => $tmp,
+ error => $!
+ );
+
+ $note = join '', grep { $_ !~ /^\s*#/ } <$fh>;
+ hurl {
+ ident => 'plan',
+ message => __ 'Aborting due to empty note',
+ exitval => 1,
+ } unless $note =~ /\S/;
+
+ # Trim the note.
+ $note =~ s/\A\v+//;
+ $note =~ s/\v+\z//;
+
+ # Set the note.
+ $self->note($note);
+ return $note;
+}
+
+sub note_prompt {
+ my ( $self, %p ) = @_;
+ __x(
+ "Write a {command} note.\nLines starting with '#' will be ignored.",
+ command => $p{for}
+ );
+}
+
+sub format_name {
+ shift->name;
+}
+
+sub format_operator {
+ my $self = shift;
+ join '', $self->lopspace, $self->operator, $self->ropspace;
+}
+
+sub format_content {
+ my $self = shift;
+ join '', $self->format_operator, $self->format_name;
+}
+
+sub format_note {
+ my $note = shift->note;
+ return '' unless length $note;
+ $note =~ s/([\r\n\\])/$escape{$1}/g;
+ return "# $note";
+}
+
+sub as_string {
+ my $self = shift;
+ return $self->lspace
+ . $self->format_content
+ . $self->rspace
+ . $self->format_note;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::Line - Sqitch deployment plan line
+
+=head1 Synopsis
+
+ my $plan = App::Sqitch::Plan->new( sqitch => $sqitch );
+ for my $line ($plan->lines) {
+ say $line->as_string;
+ }
+
+=head1 Description
+
+An App::Sqitch::Plan::Line represents a single line from a Sqitch plan file.
+Each object managed by an L<App::Sqitch::Plan> object is derived from this
+class. This is actually an abstract base class. See
+L<App::Sqitch::Plan::Change>, L<App::Sqitch::Plan::Tag>, and
+L<App::Sqitch::Plan::Blank> for concrete subclasses.
+
+=head1 Interface
+
+=head2 Constructors
+
+=head3 C<new>
+
+ my $plan = App::Sqitch::Plan::Line->new(%params);
+
+Instantiates and returns a App::Sqitch::Plan::Line object. Parameters:
+
+=over
+
+=item C<plan>
+
+The L<App::Sqitch::Plan> object with which the line is associated.
+
+=item C<name>
+
+The name of the line. Should be empty for blank lines. Tags names should
+not include the leading C<@>.
+
+=item C<lspace>
+
+The white space from the beginning of the line, if any.
+
+=item C<lopspace>
+
+The white space to the left of the operator, if any.
+
+=item C<operator>
+
+An operator, if any.
+
+=item C<ropspace>
+
+The white space to the right of the operator, if any.
+
+=item C<rspace>
+
+The white space after the name until the end of the line or the start of a
+note.
+
+=item C<note>
+
+A note. Does not include the leading C<#>, but does include any white space
+immediate after the C<#> when the plan file is parsed.
+
+=back
+
+=head2 Accessors
+
+=head3 C<plan>
+
+ my $plan = $line->plan;
+
+Returns the plan object with which the line object is associated.
+
+=head3 C<name>
+
+ my $name = $line->name;
+
+Returns the name of the line. Returns an empty string if there is no name.
+
+=head3 C<lspace>
+
+ my $lspace = $line->lspace.
+
+Returns the white space from the beginning of the line, if any.
+
+=head3 C<rspace>
+
+ my $rspace = $line->rspace.
+
+Returns the white space after the name until the end of the line or the start
+of a note.
+
+=head3 C<note>
+
+ my $note = $line->note.
+
+Returns the note. Does not include the leading C<#>, but does include any
+white space immediate after the C<#> when the plan file is parsed. Returns the
+empty string if there is no note.
+
+=head2 Instance Methods
+
+=head3 C<format_name>
+
+ my $formatted_name = $line->format_name;
+
+Returns the name of the line properly formatted for output. For
+L<tags|App::Sqitch::Plan::Tag>, it's the name with a leading C<@>. For all
+other lines, it is simply the name.
+
+=head3 C<format_operator>
+
+ my $formatted_operator = $line->format_operator;
+
+Returns the formatted representation of the operator. This is just the
+operator an its associated white space. If neither the operator nor its white
+space exists, an empty string is returned. Used internally by C<as_string()>.
+
+=head3 C<format_content>
+
+ my $formatted_content $line->format_content;
+
+Formats and returns the main content of the line. This consists of an operator
+and its associated white space, if any, followed by the formatted name.
+
+=head3 C<format_note>
+
+ my $note = $line->format_note;
+
+Returns the note formatted for output. That is, with a leading C<#> and
+newlines encoded.
+
+=head3 C<as_string>
+
+ my $string = $line->as_string;
+
+Returns the full stringification of the line, suitable for output to a plan
+file.
+
+=head3 C<request_note>
+
+ my $note = $line->request_note( for => 'add' );
+
+Request the note from the user. Pass in the name of the command for which the
+note is requested via the C<for> parameter. If there is a note, it is simply
+returned. Otherwise, an editor will be launched and the user asked to write
+one. Once the editor exits, the note will be retrieved from the file, saved,
+and returned. If no note was written, an exception will be thrown with an
+C<exitval> of 1.
+
+=head3 C<note_prompt>
+
+ my $prompt = $line->note_prompt( for => 'tag' );
+
+Returns a localized string for use in the temporary file created by
+C<request_note()>. Pass in the name of the command for which to prompt via the
+C<for> parameter.
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Plan>
+
+Class representing a plan.
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/LineList.pm b/lib/App/Sqitch/Plan/LineList.pm
new file mode 100644
index 00000000..c15ea735
--- /dev/null
+++ b/lib/App/Sqitch/Plan/LineList.pm
@@ -0,0 +1,133 @@
+package App::Sqitch::Plan::LineList;
+
+use 5.010;
+use strict;
+use utf8;
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub new {
+ my $class = shift;
+ my (@list, %index);
+ for my $line (@_) {
+ push @list => $line;
+ $index{ $line } = $#list;
+ }
+
+ return bless {
+ list => \@list,
+ lookup => \%index,
+ }
+}
+
+sub count { scalar @{ shift->{list} } }
+sub items { @{ shift->{list} } }
+sub item_at { shift->{list}->[shift] }
+sub index_of { shift->{lookup}{+shift} }
+
+sub append {
+ my ( $self, $line ) = @_;
+ my $list = $self->{list};
+ push @{ $list } => $line;
+ $self->{lookup}{$line} = $#$list;
+}
+
+sub insert_at {
+ my ( $self, $line, $idx ) = @_;
+
+ # Add the line to the list.
+ my $list = $self->{list};
+ splice @{ $list }, $idx, 0, $line;
+
+ # Reindex.
+ my $index = $self->{lookup};
+ $index->{ $list->[$_] } = $_ for $idx..$#$list;
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::LineList - Sqitch deployment plan line list
+
+=head1 Synopsis
+
+ my $list = App::Sqitch::Plan::LineList->new(@lines);
+
+ my @lines = $list->items;
+ my $index = $list->index_of($line);
+
+ $lines->append($another_line);
+
+=head1 Description
+
+This module is used internally by L<App::Sqitch::Plan> to manage plan file
+lines. It's modeled on L<Array::AsHash>, but is much simpler and hews closer
+to the API of L<App::Sqitch::Plan::ChangeList>.
+
+=head1 Interface
+
+=head2 Constructors
+
+=head3 C<new>
+
+ my $plan = App::Sqitch::Plan::LineList->new(map { $_->name => @_ } @changes );
+
+Instantiates and returns a App::Sqitch::Plan::LineList object. The parameters
+should be a key/value list of lines. Keys may be duplicated, as long as a tag
+appears between each instance of a key.
+
+=head2 Instance Methods
+
+=head3 C<count>
+
+=head3 C<items>
+
+=head3 C<item_at>
+
+=head3 C<index_of>
+
+=head3 C<append>
+
+=head3 C<insert_at>
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Plan>
+
+The Sqitch plan.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/Pragma.pm b/lib/App/Sqitch/Plan/Pragma.pm
new file mode 100644
index 00000000..8b55c8eb
--- /dev/null
+++ b/lib/App/Sqitch/Plan/Pragma.pm
@@ -0,0 +1,125 @@
+package App::Sqitch::Plan::Pragma;
+
+use 5.010;
+use utf8;
+use namespace::autoclean;
+use Moo;
+use App::Sqitch::Types qw(Str);
+extends 'App::Sqitch::Plan::Line';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has value => (
+ is => 'ro',
+ isa => Str,
+);
+
+has hspace => (
+ is => 'ro',
+ isa => Str,
+ default => '',
+);
+
+sub BUILDARGS {
+ my $class = shift;
+ my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ };
+ $p->{value} =~ s/\s+$// if $p->{value};
+ $p->{op} //= '';
+ return $p;
+}
+
+sub format_name {
+ my $self = shift;
+ return '%' . $self->hspace . $self->name;
+}
+
+sub format_value {
+ shift->value // '';
+}
+
+sub format_content {
+ my $self = shift;
+ join '', $self->format_name, $self->format_operator, $self->format_value;
+}
+
+sub as_string {
+ my $self = shift;
+ return $self->lspace
+ . $self->format_content
+ . $self->rspace
+ . $self->format_note;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::Pragma.pm - Sqitch deployment plan blank line
+
+=head1 Synopsis
+
+ my $plan = App::Sqitch::Plan->new( sqitch => $sqitch );
+ for my $line ($plan->lines) {
+ say $line->as_string;
+ }
+
+=head1 Description
+
+An App::Sqitch::Plan::Pragma represents a plan file pragma. See
+L<App::Sqitch::Plan::Line> for its interface.
+
+=head1 Interface
+
+In addition to the interface inherited from L<App::Sqitch::Plan::Line>,
+App::Sqitch::Plan::Line::Pragma adds a few methods of its own.
+
+=head2 Accessors
+
+=head3 C<value>
+
+The value of the pragma.
+
+=head3 C<op>
+
+The operator, including surrounding white space.
+
+=head3 C<hspace>
+
+The horizontal space between the pragma and its value.
+
+=head2 Instance Methods
+
+=head3 C<format_value>
+
+Formats the value for output. If there is no value, an empty string is
+returned. Otherwise the value is returned as-is.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Plan/Tag.pm b/lib/App/Sqitch/Plan/Tag.pm
new file mode 100644
index 00000000..2dcfe7fe
--- /dev/null
+++ b/lib/App/Sqitch/Plan/Tag.pm
@@ -0,0 +1,181 @@
+package App::Sqitch::Plan::Tag;
+
+use 5.010;
+use utf8;
+use namespace::autoclean;
+use Moo;
+use App::Sqitch::Types qw(Str Change UserEmail DateTime);
+use Encode;
+
+extends 'App::Sqitch::Plan::Line';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+sub format_name {
+ '@' . shift->name;
+}
+
+has info => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $plan = $self->plan;
+
+ return join "\n", (
+ 'project ' . $self->project,
+ ( $self->uri ? ( 'uri ' . $self->uri->canonical ) : () ),
+ 'tag ' . $self->format_name,
+ 'change ' . $self->change->id,
+ 'planner ' . $self->format_planner,
+ 'date ' . $self->timestamp->as_string,
+ ( $self->note ? ('', $self->note) : ()),
+ );
+ }
+);
+
+has id => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $content = encode_utf8 shift->info;
+ require Digest::SHA;
+ return Digest::SHA->new(1)->add(
+ 'tag ' . length($content) . "\0" . $content
+ )->hexdigest;
+ }
+);
+
+has change => (
+ is => 'ro',
+ isa => Change,
+ weak_ref => 1,
+ required => 1,
+);
+
+has timestamp => (
+ is => 'ro',
+ isa => DateTime,
+ default => sub { require App::Sqitch::DateTime && App::Sqitch::DateTime->now },
+);
+
+has planner_name => (
+ is => 'ro',
+ isa => Str,
+ default => sub { shift->sqitch->user_name },
+);
+
+has planner_email => (
+ is => 'ro',
+ isa => UserEmail,
+ default => sub { shift->sqitch->user_email },
+);
+
+sub format_planner {
+ my $self = shift;
+ return join ' ', $self->planner_name, '<' . $self->planner_email . '>';
+}
+
+sub format_content {
+ my $self = shift;
+ return join ' ',
+ $self->SUPER::format_content,
+ $self->timestamp->as_string,
+ $self->format_planner;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Plan::Tag - Sqitch deployment plan tag
+
+=head1 Synopsis
+
+ my $plan = App::Sqitch::Plan->new( sqitch => $sqitch );
+ for my $line ($plan->lines) {
+ say $line->as_string;
+ }
+
+=head1 Description
+
+A App::Sqitch::Plan::Tag represents a tag as parsed from a plan file. In
+addition to the interface inherited from L<App::Sqitch::Plan::Line>, it offers
+interfaces fetching and formatting timestamp and planner information.
+
+=head1 Interface
+
+See L<App::Sqitch::Plan::Line> for the basics.
+
+=head2 Accessors
+
+=head3 C<change>
+
+Returns the L<App::Sqitch::Plan::Change> object with which the tag is
+associated.
+
+=head3 C<timestamp>
+
+Returns the an L<App::Sqitch::DateTime> object representing the time at which
+the tag was added to the plan.
+
+=head3 C<planner_name>
+
+Returns the name of the user who added the tag to the plan.
+
+=head3 C<planner_email>
+
+Returns the email address of the user who added the tag to the plan.
+
+=head3 C<info>
+
+Information about the tag, returned as a string. Includes the tag ID, the ID
+of the associated change, the name and email address of the user who added the
+tag to the plan, and the timestamp for when the tag was added to the plan.
+
+=head3 C<id>
+
+A SHA1 hash of the data returned by C<info()>, which can be used as a
+globally-unique identifier for the tag.
+
+=head2 Instance Methods
+
+=head3 C<format_planner>
+
+ my $planner = $tag->format_planner;
+
+Returns a string formatted with the name and email address of the user who
+added the tag to the plan.
+
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Role/ConnectingCommand.pm b/lib/App/Sqitch/Role/ConnectingCommand.pm
new file mode 100644
index 00000000..479f0be6
--- /dev/null
+++ b/lib/App/Sqitch/Role/ConnectingCommand.pm
@@ -0,0 +1,143 @@
+package App::Sqitch::Role::ConnectingCommand;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo::Role;
+use App::Sqitch::Types qw(ArrayRef);
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+requires 'options';
+requires 'configure';
+requires 'target_params';
+
+has _params => (
+ is => 'ro',
+ isa => ArrayRef,
+ default => sub { [] },
+);
+
+around options => sub {
+ my $orig = shift;
+ return $orig->(@_), qw(
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+ );
+};
+
+around configure => sub {
+ my ( $orig, $class, $config, $opt ) = @_;
+
+ # Grab the options we're responsible for.
+ my @params = (
+ (exists $opt->{db_user} ? ('user', => delete $opt->{db_user}) : ()),
+ (exists $opt->{db_host} ? ('host', => delete $opt->{db_host}) : ()),
+ (exists $opt->{db_port} ? ('port', => delete $opt->{db_port}) : ()),
+ (exists $opt->{db_name} ? ('dbname' => delete $opt->{db_name}) : ()),
+ (exists $opt->{registry} ? ('registry' => delete $opt->{registry}) : ()),
+ (exists $opt->{client} ? ('client' => delete $opt->{client}) : ()),
+ );
+
+ # Let the command take care of its options.
+ my $params = $class->$orig($config, $opt);
+
+ # Hang on to the target parameters.
+ $params->{_params} = \@params;
+ return $params;
+};
+
+around target_params => sub {
+ my ($orig, $self) = (shift, shift);
+ return $self->$orig(@_), @{ $self->_params };
+};
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Role::ConnectingCommand - A command that connects to a target
+
+=head1 Synopsis
+
+ package App::Sqitch::Command::deploy;
+ extends 'App::Sqitch::Command';
+ with 'App::Sqitch::Role::ConnectingCommand';
+
+=head1 Description
+
+This role encapsulates the options and target parameters required by commands
+that connect to a database target.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::deploy->options;
+
+Adds database connection options.
+
+=head3 C<configure>
+
+Configures the options used for target parameters.
+
+=head2 Instance Methods
+
+=head3 C<target_params>
+
+Returns a list of parameters to be passed to App::Sqitch::Target's C<new>
+and C<all_targets> methods.
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Command::deploy>
+
+The C<deploy> command deploys changes to a database.
+
+=item L<App::Sqitch::Command::revert>
+
+The C<revert> command reverts changes from a database.
+
+=item L<App::Sqitch::Command::log>
+
+The C<log> command shows the event log for a database.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Role/ContextCommand.pm b/lib/App/Sqitch/Role/ContextCommand.pm
new file mode 100644
index 00000000..de9263f4
--- /dev/null
+++ b/lib/App/Sqitch/Role/ContextCommand.pm
@@ -0,0 +1,145 @@
+package App::Sqitch::Role::ContextCommand;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo::Role;
+use Path::Class;
+use App::Sqitch::Types qw(ArrayRef);
+use Locale::TextDomain qw(App-Sqitch); # XXX Until deprecation removed below.
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+requires 'options';
+requires 'configure';
+requires 'target_params';
+requires 'command'; # XXX Until deprecation removed below.
+
+has _cx => (
+ is => 'ro',
+ isa => ArrayRef,
+ default => sub { [] },
+);
+
+around options => sub {
+ my $orig = shift;
+ return $orig->(@_), qw(
+ plan-file|f=s
+ top-dir=s
+ );
+};
+
+around configure => sub {
+ my ( $orig, $class, $config, $opt ) = @_;
+
+ # DEPRECATTION: --top-dir deprecated in v0.9999. Remove at some point.
+ App::Sqitch->warn(__x(
+ " Option --top-dir is deprecated for {command} and other non-configuration commands.\n Use --chdir instead.",
+ command => $class->command,
+ )) if $opt->{top_dir};
+
+ # Grab the target params.
+ my @cx = (
+ do { my $f = delete $opt->{top_dir}; $f ? ( top_dir => dir($f)) : () },
+ do { my $f = delete $opt->{plan_file}; $f ? ( plan_file => file($f)) : () },
+ );
+
+ # Let the command take care of its options.
+ my $params = $class->$orig($config, $opt);
+
+ # Hang on to the target parameters.
+ $params->{_cx} = \@cx;
+ return $params;
+};
+
+around target_params => sub {
+ my ($orig, $self) = (shift, shift);
+ return $self->$orig(@_), @{ $self->_cx };
+};
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Role::ContextCommand - A command that needs to know where things are
+
+=head1 Synopsis
+
+ package App::Sqitch::Command::add;
+ extends 'App::Sqitch::Command';
+ with 'App::Sqitch::Role::ContextCommand';
+
+=head1 Description
+
+This role encapsulates the options and target parameters required by commands
+that need to know where to find project files.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::add->options;
+
+Adds contextual options C<--plan-file> and C<--top-dir>.
+
+=head3 C<configure>
+
+Configures the options used for target parameters.
+
+=head2 Instance Methods
+
+=head3 C<target_params>
+
+Returns a list of parameters to be passed to App::Sqitch::Target's C<new>
+and C<all_targets> methods.
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Command::add>
+
+The C<add> command adds changes to the the plan and change scripts to the project.
+
+=item L<App::Sqitch::Command::deploy>
+
+The C<deploy> command deploys changes to a database.
+
+=item L<App::Sqitch::Command::bundle>
+
+The C<bundle> command bundles Sqitch changes for distribution.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Role/DBIEngine.pm b/lib/App/Sqitch/Role/DBIEngine.pm
new file mode 100644
index 00000000..4b51062c
--- /dev/null
+++ b/lib/App/Sqitch/Role/DBIEngine.pm
@@ -0,0 +1,1132 @@
+package App::Sqitch::Role::DBIEngine;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use DBI;
+use Moo::Role;
+use Try::Tiny;
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use namespace::autoclean;
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+requires 'dbh';
+requires 'sqitch';
+requires 'plan';
+requires '_regex_op';
+requires '_ts2char_format';
+requires '_char2ts';
+requires '_listagg_format';
+requires '_no_table_error';
+requires '_handle_lookup_index';
+
+sub _dt($) {
+ require App::Sqitch::DateTime;
+ return App::Sqitch::DateTime->new(split /:/ => shift);
+}
+
+sub _log_tags_param {
+ join ' ' => map { $_->format_name } $_[1]->tags;
+}
+
+sub _log_requires_param {
+ join ',' => map { $_->as_string } $_[1]->requires;
+}
+
+sub _log_conflicts_param {
+ join ',' => map { $_->as_string } $_[1]->conflicts;
+}
+
+sub _ts_default { 'DEFAULT' }
+
+sub _can_limit { 1 }
+sub _limit_default { undef }
+
+sub _simple_from { '' }
+
+sub _quote_idents { shift; @_ }
+
+sub _in_expr {
+ my ($self, $vals) = @_;
+ my $in = sprintf 'IN (%s)', join ', ', ('?') x @{ $vals };
+ return $in, @{ $vals };
+}
+
+sub _register_release {
+ my $self = shift;
+ my $version = shift || $self->registry_release;
+ my $sqitch = $self->sqitch;
+ my $ts = $self->_ts_default;
+
+ $self->begin_work;
+ $self->dbh->do(qq{
+ INSERT INTO releases (version, installed_at, installer_name, installer_email)
+ VALUES (?, $ts, ?, ?)
+ }, undef, $version, $sqitch->user_name, $sqitch->user_email);
+ $self->finish_work;
+ return $self;
+}
+
+sub _version_query { 'SELECT MAX(version) FROM releases' }
+
+sub registry_version {
+ my $self = shift;
+ try {
+ $self->dbh->selectcol_arrayref($self->_version_query)->[0];
+ } catch {
+ return 0 if $self->_no_table_error;
+ die $_;
+ };
+}
+
+sub _cid {
+ my ( $self, $ord, $offset, $project ) = @_;
+ return try {
+ $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ ORDER BY committed_at $ord
+ LIMIT 1
+ OFFSET COALESCE(?, 0)
+ }, undef, $project || $self->plan->project, $offset)->[0];
+ } catch {
+ return if $self->_no_table_error && !$self->initialized;
+ die $_;
+ };
+}
+
+sub earliest_change_id {
+ shift->_cid('ASC', @_);
+}
+
+sub latest_change_id {
+ shift->_cid('DESC', @_);
+}
+
+sub _select_state {
+ my ( $self, $project, $with_hash ) = @_;
+ my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+ my $hshcol = $with_hash ? "c.script_hash\n , " : '';
+ my $dbh = $self->dbh;
+ $dbh->selectrow_hashref(qq{
+ SELECT c.change_id
+ , ${hshcol}c.change
+ , c.project
+ , c.note
+ , c.committer_name
+ , c.committer_email
+ , $cdtcol AS committed_at
+ , c.planner_name
+ , c.planner_email
+ , $pdtcol AS planned_at
+ , $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ GROUP BY c.change_id
+ , ${hshcol}c.change
+ , c.project
+ , c.note
+ , c.committer_name
+ , c.committer_email
+ , c.committed_at
+ , c.planner_name
+ , c.planner_email
+ , c.planned_at
+ ORDER BY c.committed_at DESC
+ LIMIT 1
+ }, undef, $project // $self->plan->project );
+}
+
+sub current_state {
+ my ( $self, $project ) = @_;
+ my $state = try {
+ $self->_select_state($project, 1)
+ } catch {
+ return if $self->_no_table_error && !$self->initialized;
+ return $self->_select_state($project, 0) if $self->_no_column_error;
+ die $_;
+ } or return undef;
+
+ unless (ref $state->{tags}) {
+ $state->{tags} = $state->{tags} ? [ split / / => $state->{tags} ] : [];
+ }
+ $state->{committed_at} = _dt $state->{committed_at};
+ $state->{planned_at} = _dt $state->{planned_at};
+ return $state;
+}
+
+sub current_changes {
+ my ( $self, $project ) = @_;
+ my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $sth = $self->dbh->prepare(qq{
+ SELECT c.change_id
+ , c.script_hash
+ , c.change
+ , c.committer_name
+ , c.committer_email
+ , $cdtcol AS committed_at
+ , c.planner_name
+ , c.planner_email
+ , $pdtcol AS planned_at
+ FROM changes c
+ WHERE project = ?
+ ORDER BY c.committed_at DESC
+ });
+ $sth->execute($project // $self->plan->project);
+ return sub {
+ my $row = $sth->fetchrow_hashref or return;
+ $row->{committed_at} = _dt $row->{committed_at};
+ $row->{planned_at} = _dt $row->{planned_at};
+ return $row;
+ };
+}
+
+sub current_tags {
+ my ( $self, $project ) = @_;
+ my $cdtcol = sprintf $self->_ts2char_format, 'committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'planned_at';
+ my $sth = $self->dbh->prepare(qq{
+ SELECT tag_id
+ , tag
+ , committer_name
+ , committer_email
+ , $cdtcol AS committed_at
+ , planner_name
+ , planner_email
+ , $pdtcol AS planned_at
+ FROM tags
+ WHERE project = ?
+ ORDER BY tags.committed_at DESC
+ });
+ $sth->execute($project // $self->plan->project);
+ return sub {
+ my $row = $sth->fetchrow_hashref or return;
+ $row->{committed_at} = _dt $row->{committed_at};
+ $row->{planned_at} = _dt $row->{planned_at};
+ return $row;
+ };
+}
+
+sub search_events {
+ my ( $self, %p ) = @_;
+
+ # Determine order direction.
+ my $dir = 'DESC';
+ if (my $d = delete $p{direction}) {
+ $dir = $d =~ /^ASC/i ? 'ASC'
+ : $d =~ /^DESC/i ? 'DESC'
+ : hurl 'Search direction must be either "ASC" or "DESC"';
+ }
+
+ # Limit with regular expressions?
+ my (@wheres, @params);
+ for my $spec (
+ [ committer => 'e.committer_name' ],
+ [ planner => 'e.planner_name' ],
+ [ change => 'e.change' ],
+ [ project => 'e.project' ],
+ ) {
+ my $regex = delete $p{ $spec->[0] } // next;
+ my ($op, $expr) = $self->_regex_expr($spec->[1], $regex);
+ push @wheres => $op;
+ push @params => $expr;
+ }
+
+ # Match events?
+ if (my $e = delete $p{event} ) {
+ my ($in, @vals) = $self->_in_expr( $e );
+ push @wheres => "e.event $in";
+ push @params => @vals;
+ }
+
+ # Assemble the where clause.
+ my $where = @wheres
+ ? "\n WHERE " . join( "\n ", @wheres )
+ : '';
+
+ # Handle remaining parameters.
+ my $limits = '';
+ if (exists $p{limit} || exists $p{offset}) {
+ my ($exprs, $values) = $self->_limit_offset(delete $p{limit}, delete $p{offset});
+ if (@{ $exprs}) {
+ $limits = join "\n ", '', @{ $exprs };
+ push @params => @{ $values || [] };
+ }
+ }
+
+ hurl 'Invalid parameters passed to search_events(): '
+ . join ', ', sort keys %p if %p;
+
+ # Prepare, execute, and return.
+ my $cdtcol = sprintf $self->_ts2char_format, 'e.committed_at';
+ my $pdtcol = sprintf $self->_ts2char_format, 'e.planned_at';
+ my $sth = $self->dbh->prepare(qq{
+ SELECT e.event
+ , e.project
+ , e.change_id
+ , e.change
+ , e.note
+ , e.requires
+ , e.conflicts
+ , e.tags
+ , e.committer_name
+ , e.committer_email
+ , $cdtcol AS committed_at
+ , e.planner_name
+ , e.planner_email
+ , $pdtcol AS planned_at
+ FROM events e$where
+ ORDER BY e.committed_at $dir$limits
+ });
+
+ $sth->execute(@params);
+ return sub {
+ my $row = $sth->fetchrow_hashref or return;
+ $row->{committed_at} = _dt $row->{committed_at};
+ $row->{planned_at} = _dt $row->{planned_at};
+ return $row;
+ };
+}
+
+sub _regex_expr {
+ my ( $self, $col, $regex ) = @_;
+ my $op = $self->_regex_op;
+ return "$col $op ?", $regex;
+}
+
+sub _limit_offset {
+ my ($self, $lim, $off) = @_;
+ my (@limits, @params);
+
+ if ($lim) {
+ push @limits => 'LIMIT ?';
+ push @params => $lim;
+ }
+ if ($off) {
+ if (!$lim && ($lim = $self->_limit_default)) {
+ # Some drivers require LIMIT when OFFSET is set.
+ push @limits => 'LIMIT ?';
+ push @params => $lim;
+ }
+ push @limits => 'OFFSET ?';
+ push @params => $off;
+ }
+ return \@limits, \@params;
+}
+
+sub registered_projects {
+ return @{ shift->dbh->selectcol_arrayref(
+ 'SELECT project FROM projects ORDER BY project'
+ ) };
+}
+
+sub register_project {
+ my $self = shift;
+ my $sqitch = $self->sqitch;
+ my $dbh = $self->dbh;
+ my $plan = $self->plan;
+ my $proj = $plan->project;
+ my $uri = $plan->uri;
+
+ my $res = $dbh->selectcol_arrayref(
+ 'SELECT uri FROM projects WHERE project = ?',
+ undef, $proj
+ );
+
+ if (@{ $res }) {
+ # A project with that name is already registered. Compare URIs.
+ my $reg_uri = $res->[0];
+ if ( defined $uri && !defined $reg_uri ) {
+ hurl engine => __x(
+ 'Cannot register "{project}" with URI {uri}: already exists with NULL URI',
+ project => $proj,
+ uri => $uri
+ );
+ } elsif ( !defined $uri && defined $reg_uri ) {
+ hurl engine => __x(
+ 'Cannot register "{project}" without URI: already exists with URI {uri}',
+ project => $proj,
+ uri => $reg_uri
+ );
+ } elsif ( defined $uri && defined $reg_uri ) {
+ hurl engine => __x(
+ 'Cannot register "{project}" with URI {uri}: already exists with URI {reg_uri}',
+ project => $proj,
+ uri => $uri,
+ reg_uri => $reg_uri,
+ ) if $uri ne $reg_uri;
+ } else {
+ # Both are undef, so cool.
+ }
+ } else {
+ # No project with that name exists. Check to see if the URI does.
+ if (defined $uri) {
+ # Does the URI already exist?
+ my $res = $dbh->selectcol_arrayref(
+ 'SELECT project FROM projects WHERE uri = ?',
+ undef, $uri
+ );
+
+ hurl engine => __x(
+ 'Cannot register "{project}" with URI {uri}: project "{reg_proj}" already using that URI',
+ project => $proj,
+ uri => $uri,
+ reg_proj => $res->[0],
+ ) if @{ $res };
+ }
+
+ # Insert the project.
+ my $ts = $self->_ts_default;
+ $dbh->do(qq{
+ INSERT INTO projects (project, uri, creator_name, creator_email, created_at)
+ VALUES (?, ?, ?, ?, $ts)
+ }, undef, $proj, $uri, $sqitch->user_name, $sqitch->user_email);
+ }
+
+ return $self;
+}
+
+sub is_deployed_change {
+ my ( $self, $change ) = @_;
+ $self->dbh->selectcol_arrayref(q{
+ SELECT EXISTS(
+ SELECT 1
+ FROM changes
+ WHERE change_id = ?
+ )
+ }, undef, $change->id)->[0];
+}
+
+sub are_deployed_changes {
+ my $self = shift;
+ my $qs = join ', ' => ('?') x @_;
+ @{ $self->dbh->selectcol_arrayref(
+ "SELECT change_id FROM changes WHERE change_id IN ($qs)",
+ undef,
+ map { $_->id } @_,
+ ) };
+}
+
+sub is_deployed_tag {
+ my ( $self, $tag ) = @_;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT EXISTS(
+ SELECT 1
+ FROM tags
+ WHERE tag_id = ?
+ );
+ }, undef, $tag->id)->[0];
+}
+
+sub _multi_values {
+ my ($self, $count, $expr) = @_;
+ return 'VALUES ' . join(', ', ("($expr)") x $count)
+}
+
+sub _dependency_placeholders {
+ return '?, ?, ?, ?';
+}
+
+sub _tag_placeholders {
+ my $self = shift;
+ return '?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ' . $self->_ts_default;
+}
+
+sub _tag_subselect_columns {
+ my $self = shift;
+ return join(', ',
+ '? AS tid',
+ '? AS tname',
+ '? AS proj',
+ '? AS cid',
+ '? AS note',
+ '? AS cuser',
+ '? AS cemail',
+ '? AS tts',
+ '? AS puser',
+ '? AS pemail',
+ $self->_ts_default,
+ );
+}
+
+sub _prepare_to_log { $_[0] }
+
+sub log_deploy_change {
+ my ($self, $change) = @_;
+ my $dbh = $self->dbh;
+ my $sqitch = $self->sqitch;
+
+ my ($id, $name, $proj, $user, $email) = (
+ $change->id,
+ $change->format_name,
+ $change->project,
+ $sqitch->user_name,
+ $sqitch->user_email
+ );
+
+ my $ts = $self->_ts_default;
+ my $cols = join "\n , ", $self->_quote_idents(qw(
+ change_id
+ script_hash
+ change
+ project
+ note
+ committer_name
+ committer_email
+ planned_at
+ planner_name
+ planner_email
+ committed_at
+ ));
+
+ $self->_prepare_to_log(changes => $change);
+ $dbh->do(qq{
+ INSERT INTO changes (
+ $cols
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts)
+ }, undef,
+ $id,
+ $change->script_hash,
+ $name,
+ $proj,
+ $change->note,
+ $user,
+ $email,
+ $self->_char2ts( $change->timestamp ),
+ $change->planner_name,
+ $change->planner_email,
+ );
+
+ if ( my @deps = $change->dependencies ) {
+ $dbh->do(q{
+ INSERT INTO dependencies(
+ change_id
+ , type
+ , dependency
+ , dependency_id
+ ) } . $self->_multi_values(scalar @deps, $self->_dependency_placeholders),
+ undef,
+ map { (
+ $id,
+ $_->type,
+ $_->as_string,
+ $_->resolved_id,
+ ) } @deps
+ );
+ }
+
+ if ( my @tags = $change->tags ) {
+ $dbh->do(q{
+ INSERT INTO tags (
+ tag_id
+ , tag
+ , project
+ , change_id
+ , note
+ , committer_name
+ , committer_email
+ , planned_at
+ , planner_name
+ , planner_email
+ , committed_at
+ ) } . $self->_multi_values(scalar @tags, $self->_tag_placeholders),
+ undef,
+ map { (
+ $_->id,
+ $_->format_name,
+ $proj,
+ $id,
+ $_->note,
+ $user,
+ $email,
+ $self->_char2ts( $_->timestamp ),
+ $_->planner_name,
+ $_->planner_email,
+ ) } @tags
+ );
+ }
+
+ return $self->_log_event( deploy => $change );
+}
+
+sub log_fail_change {
+ shift->_log_event( fail => shift );
+}
+
+sub _log_event {
+ my ( $self, $event, $change, $tags, $requires, $conflicts) = @_;
+ my $dbh = $self->dbh;
+ my $sqitch = $self->sqitch;
+
+ my $ts = $self->_ts_default;
+ my $cols = join "\n , ", $self->_quote_idents(qw(
+ event
+ change_id
+ change
+ project
+ note
+ tags
+ requires
+ conflicts
+ committer_name
+ committer_email
+ planned_at
+ planner_name
+ planner_email
+ committed_at
+ ));
+
+ $self->_prepare_to_log(events => $change);
+ $dbh->do(qq{
+ INSERT INTO events (
+ $cols
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts)
+ }, undef,
+ $event,
+ $change->id,
+ $change->name,
+ $change->project,
+ $change->note,
+ $tags || $self->_log_tags_param($change),
+ $requires || $self->_log_requires_param($change),
+ $conflicts || $self->_log_conflicts_param($change),
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $self->_char2ts( $change->timestamp ),
+ $change->planner_name,
+ $change->planner_email,
+ );
+
+ return $self;
+}
+
+sub changes_requiring_change {
+ my ( $self, $change ) = @_;
+ return @{ $self->dbh->selectall_arrayref(q{
+ SELECT c.change_id, c.project, c.change, (
+ SELECT tag
+ FROM changes c2
+ JOIN tags ON c2.change_id = tags.change_id
+ WHERE c2.project = c.project
+ AND c2.committed_at >= c.committed_at
+ ORDER BY c2.committed_at
+ LIMIT 1
+ ) AS asof_tag
+ FROM dependencies d
+ JOIN changes c ON c.change_id = d.change_id
+ WHERE d.dependency_id = ?
+ }, { Slice => {} }, $change->id) };
+}
+
+sub name_for_change_id {
+ my ( $self, $change_id ) = @_;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT c.change || COALESCE((
+ SELECT tag
+ FROM changes c2
+ JOIN tags ON c2.change_id = tags.change_id
+ WHERE c2.committed_at >= c.committed_at
+ AND c2.project = c.project
+ LIMIT 1
+ ), '@HEAD')
+ FROM changes c
+ WHERE change_id = ?
+ }, undef, $change_id)->[0];
+}
+
+sub log_new_tags {
+ my ( $self, $change ) = @_;
+ my @tags = $change->tags or return $self;
+ my $sqitch = $self->sqitch;
+
+ my ($id, $name, $proj, $user, $email) = (
+ $change->id,
+ $change->format_name,
+ $change->project,
+ $sqitch->user_name,
+ $sqitch->user_email
+ );
+
+ my $subselect = 'SELECT ' . $self->_tag_subselect_columns . $self->_simple_from;
+ $self->dbh->do(
+ q{
+ INSERT INTO tags (
+ tag_id
+ , tag
+ , project
+ , change_id
+ , note
+ , committer_name
+ , committer_email
+ , planned_at
+ , planner_name
+ , planner_email
+ , committed_at
+ )
+ SELECT i.* FROM (
+ } . join(
+ "\n UNION ALL ",
+ ($subselect) x @tags
+ ) . q{
+ ) i
+ LEFT JOIN tags ON i.tid = tags.tag_id
+ WHERE tags.tag_id IS NULL
+ },
+ undef,
+ map { (
+ $_->id,
+ $_->format_name,
+ $proj,
+ $id,
+ $_->note,
+ $user,
+ $email,
+ $self->_char2ts( $_->timestamp ),
+ $_->planner_name,
+ $_->planner_email,
+ ) } @tags
+ );
+
+ return $self;
+}
+
+sub log_revert_change {
+ my ($self, $change) = @_;
+ my $dbh = $self->dbh;
+ my $cid = $change->id;
+
+ # Retrieve and delete tags.
+ my $del_tags = join ',' => @{ $dbh->selectcol_arrayref(
+ 'SELECT tag FROM tags WHERE change_id = ?',
+ undef, $cid
+ ) || [] };
+
+ $dbh->do(
+ 'DELETE FROM tags WHERE change_id = ?',
+ undef, $cid
+ );
+
+ # Retrieve dependencies and delete.
+ my $sth = $dbh->prepare(q{
+ SELECT dependency
+ FROM dependencies
+ WHERE change_id = ?
+ AND type = ?
+ });
+ my $req = join ',' => @{ $dbh->selectcol_arrayref(
+ $sth, undef, $cid, 'require'
+ ) };
+
+ my $conf = join ',' => @{ $dbh->selectcol_arrayref(
+ $sth, undef, $cid, 'conflict'
+ ) };
+
+ $dbh->do('DELETE FROM dependencies WHERE change_id = ?', undef, $cid);
+
+ # Delete the change record.
+ $dbh->do(
+ 'DELETE FROM changes where change_id = ?',
+ undef, $cid,
+ );
+
+ # Log it.
+ return $self->_log_event( revert => $change, $del_tags, $req, $conf );
+}
+
+sub deployed_changes {
+ my $self = shift;
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+ return map {
+ $_->{timestamp} = _dt $_->{timestamp};
+ unless (ref $_->{tags}) {
+ $_->{tags} = $_->{tags} ? [ split / / => $_->{tags} ] : [];
+ }
+ $_;
+ } @{ $self->dbh->selectall_arrayref(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
+ c.planner_name, c.planner_email, c.committed_at
+ ORDER BY c.committed_at ASC
+ }, { Slice => {} }, $self->plan->project) };
+}
+
+sub deployed_changes_since {
+ my ( $self, $change ) = @_;
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+ return map {
+ $_->{timestamp} = _dt $_->{timestamp};
+ unless (ref $_->{tags}) {
+ $_->{tags} = $_->{tags} ? [ split / / => $_->{tags} ] : [];
+ }
+ $_;
+ } @{ $self->dbh->selectall_arrayref(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ AND c.committed_at > (SELECT committed_at FROM changes WHERE change_id = ?)
+ GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
+ c.planner_name, c.planner_email, c.committed_at
+ ORDER BY c.committed_at ASC
+ }, { Slice => {} }, $self->plan->project, $change->id) };
+}
+
+sub load_change {
+ my ( $self, $change_id ) = @_;
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+ my $change = $self->dbh->selectrow_hashref(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.change_id = ?
+ GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
+ c.planner_name, c.planner_email
+ }, undef, $change_id) || return undef;
+ $change->{timestamp} = _dt $change->{timestamp};
+ unless (ref $change->{tags}) {
+ $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : [];
+ }
+ return $change;
+}
+
+sub _offset_op {
+ my ( $self, $offset ) = @_;
+ my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' );
+ return $dir, $op, 'OFFSET ' . (abs($offset) - 1);
+}
+
+sub change_id_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the ID if there is no offset.
+ return $change_id unless $offset;
+
+ my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
+ return $self->dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ AND committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ ORDER BY committed_at $dir
+ LIMIT 1 $offset_expr
+ }, undef, $self->plan->project, $change_id)->[0];
+}
+
+sub change_offset_from_id {
+ my ( $self, $change_id, $offset ) = @_;
+
+ # Just return the object if there is no offset.
+ return $self->load_change($change_id) unless $offset;
+
+ # Are we offset forwards or backwards?
+ my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
+ my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
+ my $tagcol = sprintf $self->_listagg_format, 't.tag';
+
+ my $change = $self->dbh->selectrow_hashref(qq{
+ SELECT c.change_id AS id, c.change AS name, c.project, c.note,
+ $tscol AS "timestamp", c.planner_name, c.planner_email,
+ $tagcol AS tags
+ FROM changes c
+ LEFT JOIN tags t ON c.change_id = t.change_id
+ WHERE c.project = ?
+ AND c.committed_at $op (
+ SELECT committed_at FROM changes WHERE change_id = ?
+ )
+ GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
+ c.planner_name, c.planner_email, c.committed_at
+ ORDER BY c.committed_at $dir
+ LIMIT 1 $offset_expr
+ }, undef, $self->plan->project, $change_id) || return undef;
+ $change->{timestamp} = _dt $change->{timestamp};
+ unless (ref $change->{tags}) {
+ $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : [];
+ }
+ return $change;
+}
+
+sub _cid_head {
+ my ($self, $project, $change) = @_;
+ return $self->dbh->selectcol_arrayref(q{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ AND changes.change = ?
+ ORDER BY committed_at DESC
+ LIMIT 1
+ }, undef, $project, $change)->[0];
+}
+
+sub change_id_for {
+ my ( $self, %p) = @_;
+ my $dbh = $self->dbh;
+
+ if ( my $cid = $p{change_id} ) {
+ # Find by ID.
+ return $dbh->selectcol_arrayref(q{
+ SELECT change_id
+ FROM changes
+ WHERE change_id = ?
+ }, undef, $cid)->[0];
+ }
+
+ my $project = $p{project} || $self->plan->project;
+ if ( my $change = $p{change} ) {
+ if ( my $tag = $p{tag} ) {
+ # There is nothing before the first tag.
+ return undef if $tag eq 'ROOT';
+
+ # Find closest to the end for @HEAD.
+ return $self->_cid_head($project, $change) if $tag eq 'HEAD';
+
+ # Find by change name and following tag.
+ my $limit = $self->_can_limit ? "\n LIMIT 1" : '';
+ return $dbh->selectcol_arrayref(qq{
+ SELECT changes.change_id
+ FROM changes
+ JOIN tags
+ ON changes.committed_at <= tags.committed_at
+ AND changes.project = tags.project
+ WHERE changes.project = ?
+ AND changes.change = ?
+ AND tags.tag = ?
+ ORDER BY changes.committed_at DESC$limit
+ }, undef, $project, $change, '@' . $tag)->[0];
+ }
+
+ # Find earliest by change name.
+ my $ids = $dbh->selectcol_arrayref(qq{
+ SELECT change_id
+ FROM changes
+ WHERE project = ?
+ AND changes.change = ?
+ ORDER BY changes.committed_at ASC
+ }, undef, $project, $change);
+
+ # Return the ID.
+ return $ids->[0] if $p{first};
+ return $self->_handle_lookup_index($change, $ids);
+ }
+
+ if ( my $tag = $p{tag} ) {
+ # Just return the latest for @HEAD.
+ return $self->_cid('DESC', 0, $project) if $tag eq 'HEAD';
+
+ # Just return the earliest for @ROOT.
+ return $self->_cid('ASC', 0, $project) if $tag eq 'ROOT';
+
+ # Find by tag name.
+ return $dbh->selectcol_arrayref(q{
+ SELECT change_id
+ FROM tags
+ WHERE project = ?
+ AND tag = ?
+ }, undef, $project, '@' . $tag)->[0];
+ }
+
+ # We got nothin.
+ return undef;
+}
+
+sub _update_script_hashes {
+ my $self = shift;
+ my $plan = $self->plan;
+ my $proj = $plan->project;
+ my $dbh = $self->dbh;
+ my $sth = $dbh->prepare(
+ 'UPDATE changes SET script_hash = ? WHERE change_id = ? AND script_hash = ?'
+ );
+
+ $self->begin_work;
+ $sth->execute($_->script_hash, $_->id, $_->id) for $plan->changes;
+ $dbh->do(q{
+ UPDATE changes SET script_hash = NULL
+ WHERE project = ? AND script_hash = change_id
+ }, undef, $proj);
+
+ $self->finish_work;
+ return $self;
+}
+
+
+sub begin_work {
+ my $self = shift;
+ # Note: Engines should acquire locks to prevent concurrent Sqitch activity.
+ $self->dbh->begin_work;
+ return $self;
+}
+
+sub finish_work {
+ my $self = shift;
+ $self->dbh->commit;
+ return $self;
+}
+
+sub rollback_work {
+ my $self = shift;
+ $self->dbh->rollback;
+ return $self;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Command::checkout - An engine based on the DBI
+
+=head1 Synopsis
+
+ package App::Sqitch::Engine::sqlite;
+ extends 'App::Sqitch::Engine';
+ with 'App::Sqitch::Role::DBIEngine';
+
+=head1 Description
+
+This role encapsulates the common attributes and methods required by
+DBI-powered engines.
+
+=head1 Interface
+
+=head2 Instance Methods
+
+=head3 C<earliest_change_id>
+
+=head3 C<latest_change_id>
+
+=head3 C<current_state>
+
+=head3 C<current_changes>
+
+=head3 C<current_tags>
+
+=head3 C<search_events>
+
+=head3 C<registered_projects>
+
+=head3 C<register_project>
+
+=head3 C<is_deployed_change>
+
+=head3 C<are_deployed_changes>
+
+=head3 C<log_deploy_change>
+
+=head3 C<log_fail_change>
+
+=head3 C<changes_requiring_change>
+
+=head3 C<name_for_change_id>
+
+=head3 C<log_new_tags>
+
+=head3 C<log_revert_change>
+
+=head3 C<begin_work>
+
+=head3 C<finish_work>
+
+=head3 C<rollback_work>
+
+=head3 C<is_deployed_tag>
+
+=head3 C<deployed_changes>
+
+=head3 C<deployed_changes_since>
+
+=head3 C<load_change>
+
+=head3 C<change_offset_from_id>
+
+=head3 C<change_id_offset_from_id>
+
+=head3 C<change_id_for>
+
+=head3 C<registry_version>
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Engine::pg>
+
+The PostgreSQL engine.
+
+=item L<App::Sqitch::Engine::sqlite>
+
+The SQLite engine.
+
+=item L<App::Sqitch::Engine::oracle>
+
+The Oracle engine.
+
+=item L<App::Sqitch::Engine::mysql>
+
+The MySQL engine.
+
+=item L<App::Sqitch::Engine::vertica>
+
+The Vertica engine.
+
+=item L<App::Sqitch::Engine::exasol>
+
+The Exasol engine.
+
+=item L<App::Sqitch::Engine::snowflake>
+
+The Snowflake engine.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Role/RevertDeployCommand.pm b/lib/App/Sqitch/Role/RevertDeployCommand.pm
new file mode 100644
index 00000000..172c77a9
--- /dev/null
+++ b/lib/App/Sqitch/Role/RevertDeployCommand.pm
@@ -0,0 +1,272 @@
+package App::Sqitch::Role::RevertDeployCommand;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo::Role;
+use App::Sqitch::Types qw(Str Bool HashRef);
+use Type::Utils qw(enum);
+use namespace::autoclean;
+
+requires 'sqitch';
+requires 'command';
+requires 'options';
+requires 'configure';
+
+with 'App::Sqitch::Role::ContextCommand';
+with 'App::Sqitch::Role::ConnectingCommand';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has target => (
+ is => 'ro',
+ isa => Str,
+);
+
+has verify => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has log_only => (
+ is => 'ro',
+ isa => Bool,
+ default => 0,
+);
+
+has no_prompt => (
+ is => 'ro',
+ isa => Bool
+);
+
+has prompt_accept => (
+ is => 'ro',
+ isa => Bool
+);
+
+has mode => (
+ is => 'ro',
+ isa => enum([qw(
+ change
+ tag
+ all
+ )]),
+ default => 'all',
+);
+
+has deploy_variables => (
+ is => 'ro',
+ isa => HashRef,
+ lazy => 1,
+ default => sub { {} },
+);
+
+has revert_variables => (
+ is => 'ro',
+ isa => HashRef,
+ lazy => 1,
+ default => sub { {} },
+);
+
+sub _collect_deploy_vars {
+ my ($self, $target) = @_;
+ my $cfg = $self->sqitch->config;
+ return (
+ %{ $cfg->get_section(section => 'core.variables') },
+ %{ $cfg->get_section(section => 'deploy.variables') },
+ %{ $target->variables }, # includes engine
+ %{ $self->deploy_variables }, # --set, --set-deploy
+ );
+}
+
+sub _collect_revert_vars {
+ my ($self, $target) = @_;
+ my $cfg = $self->sqitch->config;
+ return (
+ %{ $cfg->get_section(section => 'core.variables') },
+ %{ $cfg->get_section(section => 'deploy.variables') },
+ %{ $cfg->get_section(section => 'revert.variables') },
+ %{ $target->variables }, # includes engine
+ %{ $self->revert_variables }, # --set, --set-revert
+ );
+}
+
+around options => sub {
+ my ($orig, $class) = @_;
+ return ($class->$orig), qw(
+ target|t=s
+ mode=s
+ verify!
+ set|s=s%
+ set-deploy|e=s%
+ set-revert|r=s%
+ log-only
+ y
+ );
+};
+
+around configure => sub {
+ my ( $orig, $class, $config, $opt ) = @_;
+ my $cmd = $class->command;
+
+ my $params = $class->$orig($config, $opt);
+ $params->{log_only} = $opt->{log_only} if $opt->{log_only};
+ $params->{target} = $opt->{target} if $opt->{target};
+
+ # Verify?
+ $params->{verify} = $opt->{verify}
+ // $config->get( key => "$cmd.verify", as => 'boolean' )
+ // $config->get( key => 'deploy.verify', as => 'boolean' )
+ // 0;
+ $params->{mode} = $opt->{mode}
+ || $config->get( key => "$cmd.mode" )
+ || $config->get( key => 'deploy.mode' )
+ || 'all';
+
+ if ( my $vars = $opt->{set} ) {
+ # --set used for both revert and deploy.
+ $params->{revert_variables} = $params->{deploy_variables} = $vars;
+ }
+
+ if ( my $vars = $opt->{set_deploy} ) {
+ # --set-deploy used only for deploy.
+ $params->{deploy_variables} = {
+ %{ $params->{deploy_variables} || {} },
+ %{ $vars },
+ };
+ }
+
+ if ( my $vars = $opt->{set_revert} ) {
+ # --set-revert used only for revert.
+ $params->{revert_variables} = {
+ %{ $params->{revert_variables} || {} },
+ %{ $vars },
+ };
+ }
+
+ $params->{no_prompt} = delete $opt->{y} // $config->get(
+ key => "$cmd.no_prompt",
+ as => 'bool',
+ ) // $config->get(
+ key => 'revert.no_prompt',
+ as => 'bool',
+ ) // 0;
+
+ $params->{prompt_accept} = $config->get(
+ key => "$cmd.prompt_accept",
+ as => 'bool',
+ ) // $config->get(
+ key => 'revert.prompt_accept',
+ as => 'bool',
+ ) // 1;
+
+ return $params;
+};
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Role::RevertDeployCommand - A command that reverts and deploys
+
+=head1 Synopsis
+
+ package App::Sqitch::Command::rebase;
+ extends 'App::Sqitch::Command';
+ with 'App::Sqitch::Role::RevertDeployCommand';
+
+=head1 Description
+
+This role encapsulates the common attributes and methods required by commands
+that both revert and deploy.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::checkout->options;
+
+Adds options common to the commands that revert and deploy.
+
+=head3 C<configure>
+
+Configures the options common to commands that revert and deploy.
+
+=head2 Attributes
+
+=head3 C<log_only>
+
+Boolean indicating whether to log the deploy without running the scripts.
+
+=head3 C<no_prompt>
+
+Boolean indicating whether or not to prompt the user to really go through with
+the revert.
+
+=head3 C<prompt_accept>
+
+Boolean value to indicate whether or not the default value for the prompt,
+should the user hit C<return>, is to accept the prompt or deny it.
+
+=head3 C<target>
+
+The deployment target URI.
+
+=head3 C<verify>
+
+Boolean indicating whether or not to run verify scripts after deploying
+changes.
+
+=head3 C<mode>
+
+Deploy mode, one of "change", "tag", or "all".
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Command::rebase>
+
+The C<rebase> command reverts and deploys changes.
+
+=item L<App::Sqitch::Command::checkout>
+
+The C<checkout> command takes a VCS commit name, determines the last change in
+common with the current commit, reverts to that change, then checks out the
+named commit and re-deploys.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Role/TargetConfigCommand.pm b/lib/App/Sqitch/Role/TargetConfigCommand.pm
new file mode 100644
index 00000000..0e4db743
--- /dev/null
+++ b/lib/App/Sqitch/Role/TargetConfigCommand.pm
@@ -0,0 +1,549 @@
+package App::Sqitch::Role::TargetConfigCommand;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Moo::Role;
+use App::Sqitch::Types qw(HashRef);
+use App::Sqitch::X qw(hurl);
+use Path::Class;
+use Try::Tiny;
+use URI::db;
+use Locale::TextDomain qw(App-Sqitch);
+use List::Util qw(first);
+use File::Path qw(make_path);
+use namespace::autoclean;
+use constant extra_target_keys => ();
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+requires 'command';
+requires 'options';
+requires 'configure';
+requires 'sqitch';
+requires 'extra_target_keys';
+requires 'default_target';
+
+has properties => (
+ is => 'ro',
+ isa => HashRef,
+ default => sub { {} },
+);
+
+around options => sub {
+ my ($orig, $class) = @_;
+ return ($class->$orig), (map { "$_=s" } $class->extra_target_keys), qw(
+ plan-file|f=s
+ registry=s
+ client=s
+ extension=s
+ top-dir=s
+ dir|d=s%
+ set|s=s%
+ );
+};
+
+around configure => sub {
+ my ( $orig, $class, $config, $opt ) = @_;
+
+ # Grab the options we're responsible for.
+ my $props = {};
+ for my $key (
+ $class->extra_target_keys,
+ qw(plan_file registry client extension top_dir dir)
+ ) {
+ $props->{$key} = delete $opt->{$key} if exists $opt->{$key};
+ }
+
+ # Let the command take care of its options.
+ my $params = $class->$orig($config, $opt);
+
+ # Convert file option to Class::Path::File object.
+ if ( my $file = $props->{plan_file} ) {
+ $props->{plan_file} = file($file)->cleanup;
+ }
+
+ # Convert directory option to Class::Path::Dir object.
+ if ( my $file = $props->{top_dir} ) {
+ $props->{top_dir} = dir($file)->cleanup;
+ }
+
+ # Convert URI.
+ if ( my $uri = $props->{uri} ) {
+ require URI;
+ $props->{uri} = URI->new($uri);
+ }
+
+ # Convert directory properties to Class::Path::Dir objects.
+ if (my $dirs = delete $props->{dir}) {
+ my %ok_keys = map {; $_ => undef } (
+ qw(reworked),
+ map { ($_, "reworked_$_") } qw(deploy revert verify)
+ );
+
+ my @unknown;
+ for my $key (keys %{ $dirs }) {
+ unless (exists $ok_keys{$key}) {
+ push @unknown => $key;
+ next;
+ }
+ $props->{"$key\_dir"} = dir(delete $dirs->{$key})->cleanup
+ }
+
+ if (@unknown) {
+ hurl $class->command => __nx(
+ 'Unknown directory name: {dirs}',
+ 'Unknown directory names: {dirs}',
+ @unknown,
+ dirs => join(__ ', ', sort @unknown),
+ );
+ }
+ }
+
+ # Copy variables.
+ if ( my $vars = $opt->{set} ) {
+ $props->{variables} = $vars;
+ }
+
+ # All done.
+ $params->{properties} = $props;
+ return $params;
+};
+
+sub BUILD {
+ my $self = shift;
+ my $props = $self->properties;
+
+ if (my $engine = $props->{engine}) {
+ # Validate engine.
+ hurl $self->command => __x(
+ 'Unknown engine "{engine}"', engine => $engine
+ ) unless first { $engine eq $_ } App::Sqitch::Command::ENGINES;
+ }
+
+ if (my $uri = $props->{uri}) {
+ # Validate URI.
+ hurl $self->command => __x(
+ 'URI "{uri}" is not a database URI',
+ uri => $uri,
+ ) unless eval { $uri->isa('URI::db') };
+
+ my $engine = $uri->canonical_engine or hurl $self->command => __x(
+ 'No database engine in URI "{uri}"',
+ uri => $uri,
+ );
+ hurl $self->command => __x(
+ 'Unknown engine "{engine}" in URI "{uri}"',
+ engine => $engine,
+ uri => $uri,
+ ) unless first { $engine eq $_ } App::Sqitch::Command::ENGINES;
+
+ }
+}
+
+sub config_target {
+ my ($self, %p) = @_;
+ my $sqitch = $self->sqitch;
+ my $props = $self->properties;
+ my @params = (sqitch => $sqitch);
+
+ if (my $name = $p{name} || $props->{target}) {
+ push @params => (name => $name);
+ if (my $uri = $p{uri}) {
+ push @params => (uri => $uri);
+ } else {
+ my $config = $sqitch->config;
+ if ($name !~ /:/ && !$config->get(key => "target.$name.uri")) {
+ # No URI. Give it one.
+ my $engine = $p{engine} || $props->{engine}
+ || $config->get(key => 'core.engine');
+ push @params => (uri => URI::db->new("db:$engine:"));
+ }
+ }
+ } elsif (my $engine = $p{engine} || $props->{engine}) {
+ my $config = $sqitch->config;
+ push @params => (
+ name => $config->get(key => "engine.$engine.target")
+ || $config->get(key => 'core.target')
+ || "db:$engine:"
+ );
+ } else {
+ # Get the name and URI from the default target.
+ my $default = $self->default_target;
+ push @params => (
+ name => $default->name,
+ uri => $default->uri,
+ );
+ }
+
+ # Return the target with all relevant attributes overridden.
+ require App::Sqitch::Target;
+ return App::Sqitch::Target->new(
+ @params,
+ map { $_ => $props->{$_} } grep { $props->{$_} } qw(
+ top_dir
+ plan_file
+ registry
+ client
+ deploy_dir
+ revert_dir
+ verify_dir
+ reworked_dir
+ reworked_deploy_dir
+ reworked_revert_dir
+ reworked_verify_dir
+ extension
+ )
+ );
+}
+
+sub directories_for {
+ my $self = shift;
+ my $props = $self->properties;
+ my (@dirs, %seen);
+
+ for my $target (@_) {
+ # Script directories.
+ if (my $top_dir = $props->{top_dir}) {
+ push @dirs => grep { !$seen{$_}++ } map {
+ $props->{"$_\_$_"} || $top_dir->subdir($_);
+ } qw(deploy revert verify);
+ } else {
+ push @dirs => grep { !$seen{$_}++ } map {
+ my $name = "$_\_dir";
+ $props->{$name} || $target->$name;
+ } qw(deploy revert verify);
+ }
+
+ # Reworked script directories.
+ if (my $reworked_dir = $props->{reworked_dir} || $props->{top_dir}) {
+ push @dirs => grep { !$seen{$_}++ } map {
+ $props->{"reworked_$_\_dir"} || $reworked_dir->subdir($_);
+ } qw(deploy revert verify);
+ } else {
+ push @dirs => grep { !$seen{$_}++ } map {
+ my $name = "reworked_$_\_dir";
+ $props->{$name} || $target->$name;
+ } qw(deploy revert verify);
+ }
+ }
+
+ return @dirs;
+}
+
+sub make_directories_for {
+ my $self = shift;
+ $self->mkdirs( $self->directories_for(@_) );
+}
+
+sub mkdirs {
+ my $self = shift;
+
+ for my $dir (@_) {
+ next if -d $dir;
+ my $sep = dir('')->stringify; # OS-specific directory separator.
+ $self->info(__x(
+ 'Created {file}',
+ file => "$dir$sep"
+ )) if make_path $dir, { error => \my $err };
+ if ( my $diag = shift @{ $err } ) {
+ my ( $path, $msg ) = %{ $diag };
+ hurl $self->command => __x(
+ 'Error creating {path}: {error}',
+ path => $path,
+ error => $msg,
+ ) if $path;
+ hurl $self->command => $msg;
+ }
+ }
+
+ return $self;
+}
+
+sub write_plan {
+ my ( $self, %p ) = @_;
+ my $project = $p{project};
+ my $uri = $p{uri};
+ my $target = $p{target} || $self->config_target;
+ my $file = $target->plan_file;
+
+ unless ($project && $uri) {
+ # Find a plan to copy the project name and URI from.
+ my $conf_plan = $target->plan;
+ my $def_plan = $self->default_target->plan;
+ if (try { $def_plan->project }) {
+ $project ||= $def_plan->project;
+ $uri ||= $def_plan->uri;
+ } elsif (try { $conf_plan->project }) {
+ $project ||= $conf_plan->project;
+ $uri ||= $conf_plan->uri;
+ } else {
+ hurl $self->command => __x(
+ 'Cannot write a plan file without a project name'
+ ) unless $project;
+ }
+ }
+
+ if (-e $file) {
+ hurl init => __x(
+ 'Cannot initialize because {file} already exists and is not a file',
+ file => $file,
+ ) unless -f $file;
+
+ # Try to load the plan file.
+ my $plan = App::Sqitch::Plan->new(
+ sqitch => $self->sqitch,
+ file => $file,
+ target => $target,
+ );
+ my $file_proj = try { $plan->project } or hurl init => __x(
+ 'Cannot initialize because {file} already exists and is not a valid plan file',
+ file => $file,
+ );
+
+ # Bail if this plan file looks like it's for a different project.
+ hurl init => __x(
+ 'Cannot initialize because project "{project}" already initialized in {file}',
+ project => $plan->project,
+ file => $file,
+ ) if $plan->project ne $project;
+ return $self;
+ }
+
+ $self->mkdirs( $file->dir ) unless -d $file->dir;
+
+ my $fh = $file->open('>:utf8_strict') or hurl init => __x(
+ 'Cannot open {file}: {error}',
+ file => $file,
+ error => $!,
+ );
+ require App::Sqitch::Plan;
+ $fh->print(
+ '%syntax-version=', App::Sqitch::Plan::SYNTAX_VERSION(), "\n",
+ '%project=', "$project\n",
+ ( $uri ? ('%uri=', $uri->canonical, "\n") : () ), "\n",
+ );
+ $fh->close or hurl add => __x(
+ 'Error closing {file}: {error}',
+ file => $file,
+ error => $!
+ );
+
+ $self->sqitch->info( __x 'Created {file}', file => $file );
+ return $self;
+}
+
+sub config_params {
+ my ($self, $key) = @_;
+ my @vars;
+ while (my ($prop, $val) = each %{ $self->properties } ) {
+ if (ref $val eq 'HASH') {
+ push @vars => map {{
+ key => "$key.$prop.$_",
+ value => $val->{$_},
+ }} keys %{ $val };
+ } else {
+ push @vars => {
+ key => "$key.$prop",
+ value => $val,
+ };
+ }
+ }
+ return \@vars;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Role::TargetConfigCommand - A command that handles target-related configuration
+
+=head1 Synopsis
+
+ package App::Sqitch::Command::init;
+ extends 'App::Sqitch::Command';
+ with 'App::Sqitch::Role::TargetConfigCommand';
+
+=head1 Description
+
+This role encapsulates the common attributes and methods required by commands
+that deal with change script configuration, including script directories and
+extensions.
+
+=head1 Interface
+
+=head2 Class Methods
+
+=head3 C<options>
+
+ my @opts = App::Sqitch::Command::checkout->options;
+
+Adds options common to the commands that manage script configuration.
+
+=head3 C<configure>
+
+Configures the options common to commands manage script configuration.
+
+=head2 Attributes
+
+=head3 C<properties>
+
+A hash reference of target configurations. The keys may be as follows:
+
+=over
+
+=item C<deploy>
+
+=item C<revert>
+
+=item C<verify>
+
+=item C<reworked>
+
+=item C<reworked_deploy>
+
+=item C<reworked_revert>
+
+=item C<reworked_verify>
+
+=item C<extension>
+
+=back
+
+=head2 Instance Methods
+
+=head3 C<config_target>
+
+ my $target = $cmd->config_target;
+ my $target = $cmd->config_target(%params);
+
+Constructs a target based on the contents of C<properties>. The supported
+parameters are:
+
+=over
+
+=item C<name>
+
+A target name.
+
+=item C<uri>
+
+A target URI.
+
+=item C<engine>
+
+An engine name.
+
+=back
+
+The passed target and engine names take highest precedence, falling back on
+the properties and the C<default_target>. All other properties are applied to
+the target before returning it.
+
+=head3 C<write_plan>
+
+ $cmd->write_plan(%params);
+
+Writes out the plan file. Supported parameters are:
+
+=over
+
+=item C<target>
+
+The target for which the plan will be written. Defaults to the target returned
+by C<config_target()>.
+
+=item C<project>
+
+The project name. If not passed, the project name will be read from the
+default target's plan, if it exists. Otherwise an error will be thrown.
+
+=item C<uri>
+
+The project URI. Optional. If not passed, the URI will be read from the
+default target's plan, if it exists. Optional.
+
+=back
+
+=head3 C<directories_for>
+
+ my @dirs = $cmd->directories_for(@targets);
+
+Returns a set of script directories for a list of targets. Options passed to
+the command are preferred. Paths are pulled from the command only when they
+have not been passed as options.
+
+=head3 C<make_directories_for>
+
+ $cmd->directories_for(@targets);
+
+Creates script directories for one or more targets. Options passed to the
+command are preferred. Paths are pulled from the command only when they have
+not been passed as options.
+
+=head3 C<mkdirs>
+
+ $cmd->directories_for(@dirs);
+
+Creates the list of directories on the file system. Directories that already
+exist are skipped. Messages are sent to C<info()> for each directory, and an
+error is thrown on the first to fail.
+
+=head3 C<config_params>
+
+ my @params = $cmd->config_params($key);
+
+Returns a list of parameters to pass to the L<App::Sqitch::Config> C<set>
+method, built up from the C<properties>.
+
+=head1 See Also
+
+=over
+
+=item L<App::Sqitch::Command::init>
+
+The C<init> command initializes a Sqitch project, setting up the change script
+configuration and directories.
+
+=item L<App::Sqitch::Command::engine>
+
+The C<engine> command manages engine configuration, including engine-specific
+change script configuration.
+
+=item L<App::Sqitch::Command::target>
+
+The C<engine> command manages target configuration, including target-specific
+change script configuration.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Target.pm b/lib/App/Sqitch/Target.pm
new file mode 100644
index 00000000..b24556ae
--- /dev/null
+++ b/lib/App/Sqitch/Target.pm
@@ -0,0 +1,865 @@
+package App::Sqitch::Target;
+
+use 5.010;
+use Moo;
+use strict;
+use warnings;
+use App::Sqitch::Types qw(Maybe URIDB Str Dir Engine Sqitch File Plan HashRef);
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use Path::Class qw(dir file);
+use URI::db;
+use namespace::autoclean;
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has name => (
+ is => 'ro',
+ isa => Str,
+ required => 1,
+);
+sub target { shift->name }
+
+has uri => (
+ is => 'ro',
+ isa => URIDB,
+ required => 1,
+ handles => {
+ engine_key => 'canonical_engine',
+ dsn => 'dbi_dsn',
+ },
+);
+
+has username => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $ENV{SQITCH_USERNAME} || $self->uri->user
+ },
+);
+
+has password => (
+ is => 'ro',
+ isa => Maybe[Str],
+ lazy => 1,
+ default => sub {
+ $ENV{SQITCH_PASSWORD} || shift->uri->password
+ },
+);
+
+has sqitch => (
+ is => 'ro',
+ isa => Sqitch,
+ required => 1,
+ handles => {
+ _config => 'config',
+ _options => 'options',
+ },
+);
+
+has engine => (
+ is => 'ro',
+ isa => Engine,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ require App::Sqitch::Engine;
+ App::Sqitch::Engine->load({
+ sqitch => $self->sqitch,
+ target => $self,
+ });
+ },
+);
+
+sub _fetch {
+ my ($self, $key) = @_;
+ my $config = $self->_config;
+ return $config->get( key => "target." . $self->name . ".$key" )
+ || do {
+ my $ekey = $self->engine_key;
+ $ekey ? $config->get( key => "engine.$ekey.$key") : ();
+ } || $config->get( key => "core.$key");
+}
+
+has variables => (
+ is => 'rw',
+ isa => HashRef[Str],
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $config = $self->sqitch->config;
+ return {
+ map { %{ $config->get_section( section => "$_.variables" ) || {} } } (
+ 'engine.' . $self->engine_key,
+ 'target.' . $self->name,
+ )
+ };
+ },
+);
+
+has registry => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->_fetch('registry') || $self->engine->default_registry;
+ },
+);
+
+has client => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ $self->_fetch('client') || do {
+ my $client = $self->engine->default_client;
+ return $client unless App::Sqitch::ISWIN;
+ return $client if $client =~ /[.](?:exe|bat)$/;
+ return $client . '.exe';
+ };
+ },
+);
+
+has plan_file => (
+ is => 'ro',
+ isa => File,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ if ( my $f = $self->_fetch('plan_file') ) {
+ return file $f;
+ }
+ return $self->top_dir->file('sqitch.plan')->cleanup;
+ },
+);
+
+has plan => (
+ is => 'ro',
+ isa => Plan,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ App::Sqitch::Plan->new(
+ sqitch => $self->sqitch,
+ target => $self,
+ );
+ },
+);
+
+has top_dir => (
+ is => 'ro',
+ isa => Dir,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ dir $self->_fetch('top_dir') || ();
+ },
+);
+
+has reworked_dir => (
+ is => 'ro',
+ isa => Dir,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ if ( my $dir = $self->_fetch('reworked_dir') ) {
+ return dir $dir;
+ }
+ $self->top_dir;
+ },
+);
+
+for my $script (qw(deploy revert verify)) {
+ has "$script\_dir" => (
+ is => 'ro',
+ isa => Dir,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ if ( my $dir = $self->_fetch("$script\_dir") ) {
+ return dir $dir;
+ }
+ $self->top_dir->subdir($script)->cleanup;
+ },
+ );
+ has "reworked_$script\_dir" => (
+ is => 'ro',
+ isa => Dir,
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ if ( my $dir = $self->_fetch("reworked_$script\_dir") ) {
+ return dir $dir;
+ }
+ $self->reworked_dir->subdir($script)->cleanup;
+ },
+ );
+}
+
+has extension => (
+ is => 'ro',
+ isa => Str,
+ lazy => 1,
+ default => sub {
+ shift->_fetch('extension') || 'sql';
+ },
+);
+
+sub BUILDARGS {
+ my $class = shift;
+ my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ };
+
+ # Fetch params. URI can come from passed name.
+ my $sqitch = $p->{sqitch} or return $p;
+ my $name = $p->{name} || $ENV{SQITCH_TARGET} || '';
+ my $uri = $p->{uri};
+
+ # If we have a URI up-front, it's all good.
+ if ($uri) {
+ unless ($name) {
+ # Set the URI as the name, sans password.
+ if ($uri->password) {
+ $uri = $uri->clone;
+ $uri->password(undef);
+ }
+ $p->{name} = $uri->as_string;
+ }
+ return $p;
+ }
+
+ my $ekey;
+ my $config = $sqitch->config;
+
+ # If no name, try to find one.
+ if (!$name) {
+ # There are a couple of places to look for a name.
+ NAME: {
+ # Look for core target.
+ if ( $uri = $config->get( key => 'core.target' ) ) {
+ # We got core.target.
+ $p->{name} = $name = $uri;
+ last NAME;
+ }
+
+ # No core target, look for an engine key.
+ $ekey = $config->get( key => 'core.engine' ) or do {
+ hurl target => __(
+ 'No engine specified; specify via target or core.engine'
+ ) if $config->initialized;
+ hurl target => __(
+ 'No project configuration found. Run the "init" command to initialize a project'
+ );
+ };
+ $ekey =~ s/\s+$//;
+
+ # Find the name in the engine config, or fall back on a simple URI.
+ $uri = $config->get( key => "engine.$ekey.target" ) || "db:$ekey:";
+ $p->{name} = $name = $uri;
+ }
+ }
+
+ # Now we should have a name. What is it?
+ if ($name =~ /:/) {
+ # The name is a URI.
+ $uri = $name;
+ $name = $p->{name} = undef;
+ } else {
+ $p->{name} = $name;
+ # Well then, there had better be a config with a URI.
+ $uri = $config->get( key => "target.$name.uri" ) or do {
+ # Die on no section or no URI.
+ hurl target => __x(
+ 'Cannot find target "{target}"',
+ target => $name
+ ) unless %{ $config->get_section(
+ section => "target.$name"
+ ) };
+ hurl target => __x(
+ 'No URI associated with target "{target}"',
+ target => $name,
+ );
+ };
+ }
+
+ # Instantiate the URI.
+ $uri = $p->{uri} = URI::db->new( $uri );
+ $ekey ||= $uri->canonical_engine or hurl target => __x(
+ 'No engine specified by URI {uri}; URI must start with "db:$engine:"',
+ uri => $uri->as_string,
+ );
+
+ # Override with optional parameters.
+ for my $attr (qw(user host port dbname)) {
+ $uri->$attr(delete $p->{$attr}) if exists $p->{$attr};
+ }
+
+ unless ($name) {
+ # Set the name.
+ if ($uri->password) {
+ # Remove the password from the name.
+ my $tmp = $uri->clone;
+ $tmp->password(undef);
+ $p->{name} = $tmp->as_string;
+ } else {
+ $p->{name} = $uri->as_string;
+ }
+ }
+
+ return $p;
+}
+
+sub all_targets {
+ my ($class, %p) = @_;
+ my $sqitch = $p{sqitch} or hurl 'Missing required argument: sqitch';
+ my $config = delete $p{config} || $sqitch->config;
+ my (@targets, %seen);
+ my %dump = $config->dump;
+
+ # First, load the default target.
+ my $core = $dump{'core.target'} || do {
+ if ( my $engine = $dump{'core.engine'} ) {
+ $engine =~ s/\s+$//;
+ $dump{"engine.$engine.target"} || "db:$engine:";
+ }
+ };
+ push @targets => $seen{$core} = $class->new(%p, name => $core)
+ if $core;
+
+ # Next, load named targets.
+ for my $key (keys %dump) {
+ next if $key !~ /^target[.]([^.]+)[.]uri$/;
+ push @targets => $seen{$1} = $class->new(%p, name => $1)
+ unless $seen{$1};
+ }
+
+ # Now, load the engine targets.
+ while ( my ($key, $val) = each %dump ) {
+ next if $key !~ /^engine[.]([^.]+)[.]target$/;
+ push @targets => $seen{$val} = $class->new(%p, name => $val)
+ unless $seen{$val};
+ $seen{$1} = $seen{$val};
+ }
+
+ # Finally, load any engines for which no target name was specified.
+ while ( my ($key, $val) = each %dump ) {
+ my ($engine) = $key =~ /^engine[.]([^.]+)/ or next;
+ $engine =~ s/\s+$//;
+ next if $seen{$engine}++;
+ my $uri = URI->new("db:$engine:");
+ push @targets => $seen{$uri} = $class->new(%p, uri => $uri)
+ unless $seen{$uri};
+ }
+
+ # Return all the targets.
+ return @targets;
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::Target - Sqitch deployment target
+
+=head1 Synopsis
+
+ my $plan = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ name => 'development',
+ );
+ $target->engine->deploy;
+
+=head1 Description
+
+App::Sqitch::Target provides collects, in one place, the
+L<engine|App::Sqitch::Engine>, L<plan|App::Sqitch::Engine>, and file locations
+required to carry out Sqitch commands. All commands should instantiate a
+target to work with the plan or database.
+
+=head1 Interface
+
+=head2 Constructors
+
+=head3 C<new>
+
+ my $target = App::Sqitch::Target->new( sqitch => $sqitch );
+
+Instantiates and returns an App::Sqitch::Target object. The most important
+parameters are C<sqitch>, C<name>, and C<uri>. The constructor tries really
+hard to figure out the proper name and URI during construction. If the C<uri>
+parameter is passed, this is straight-forward: if no C<name> is passed,
+C<name> will be set to the stringified format of the URI (minus the password,
+if present).
+
+Otherwise, when no URI is passed, the name and URI are determined by taking
+the following steps:
+
+=over
+
+=item *
+
+If there is no name, get the engine key from or the C<core.engine>
++configuration option. If no key can be determined, an exception will be
+thrown.
+
+=item *
+
+Use the key to look up the target name in the C<engine.$engine.target>
+configuration option. If none is found, use C<db:$key:>.
+
+=item *
+
+If the name contains a colon (C<:>), assume it is also the value for the URI.
+
+=item *
+
+Otherwise, it should be the name of a configured target, so look for a URI in
+the C<target.$name.uri> configuration option.
+
+=back
+
+As a general rule, then, pass either a target name or URI string in the
+C<name> parameter, and Sqitch will do its best to find all the relevant target
+information. And if there is no name or URI, it will try to construct a
+reasonable default from the command-line options or engine configuration.
+
+All Target attributes may be passed as parameters to C<new()>. In addition,
+C<new()> accepts a few non-attribute parameters that may be used to override
+parts of the connection URI. They are:
+
+=over
+
+=item * C<user>
+
+=item * C<host>
+
+=item * C<port>
+
+=item * C<dbname>
+
+=back
+
+For example, if the the named target had its URI configured as
+C<db:pg://fred@example.com/work>, The C<uri> would be set as such by:
+
+ my $target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'work');
+ say $target->uri;
+
+However, passing the URI parameters like this:
+
+ my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ name => 'work',
+ user => 'bill',
+ port => 1212,
+ );
+ say $target->uri;
+
+Sets the URI to C<db:pg://bill@example.com:1212/work>.
+
+=head3 C<all_targets>
+
+Returns a list of all the targets defined by the local Sqitch configuration
+file. Done by examining the configuration object to find all defined targets
+and engines, as well as the default "core" target. Duplicates are removed and
+the list returned. This method takes the same parameters as C<new>; only
+C<sqitch> is required. All other parameters will be set on all of the returned
+targets.
+
+=head2 Accessors
+
+=head3 C<sqitch>
+
+ my $sqitch = $target->sqitch;
+
+Returns the L<App::Sqitch> object that instantiated the target.
+
+=head3 C<name>
+
+=head3 C<target>
+
+ my $name = $target->name;
+ $name = $target->target;
+
+The name of the target. If there was no name specified, the URI will be used
+(minus the password, if there is one).
+
+=head3 C<uri>
+
+ my $uri = $target->uri;
+
+The L<URI::db> object encapsulating the database connection information.
+
+=head3 C<username>
+
+ my $username = $target->username;
+
+Returns the target username, if any. The username is looked up from the URI.
+
+=head3 C<password>
+
+ my $password = $target->password;
+
+Returns the target password, if any. The password is looked up from the URI
+or the C<$SQITCH_PASSWORD> environment variable.
+
+=head3 C<engine>
+
+ my $engine = $target->engine;
+
+A L<App::Sqitch::Engine> object to use for database interactions with the
+target.
+
+=head3 C<registry>
+
+ my $registry = $target->registry;
+
+The name of the registry used by the database. The value comes from one of
+these options, searched in this order:
+
+=over
+
+=item * C<--registry>
+
+=item * C<target.$name.registry>
+
+=item * C<engine.$engine.registry>
+
+=item * C<core.registry>
+
+=item * Engine-specific default
+
+=back
+
+=head3 C<client>
+
+ my $client = $target->client;
+
+Path to the engine command-line client. The value comes from one of these
+options, searched in this order:
+
+=over
+
+=item * C<--client>
+
+=item * C<target.$name.client>
+
+=item * C<engine.$engine.client>
+
+=item * C<core.client>
+
+=item * Engine-and-OS-specific default
+
+=back
+
+=head3 C<top_dir>
+
+ my $top_dir = $target->top_dir;
+
+The path to the top directory of the project. This directory generally
+contains the plan file and subdirectories for deploy, revert, and verify
+scripts. The value comes from one of these options, searched in this order:
+
+=over
+
+=item * C<--top-dir>
+
+=item * C<target.$name.top_dir>
+
+=item * C<engine.$engine.top_dir>
+
+=item * C<core.top_dir>
+
+=item * F<.>
+
+=back
+
+=head3 C<plan_file>
+
+ my $plan_file = $target->plan_file;
+
+The path to the plan file. The value comes from one of these options, searched
+in this order:
+
+=over
+
+=item * C<--plan-file>
+
+=item * C<target.$name.plan_file>
+
+=item * C<engine.$engine.plan_file>
+
+=item * C<core.plan_file>
+
+=item * F<C<$top_dir>/sqitch.plan>
+
+=back
+
+=head3 C<deploy_dir>
+
+ my $deploy_dir = $target->deploy_dir;
+
+The path to the deploy directory of the project. This directory contains all
+of the deploy scripts referenced by changes in the C<plan_file>. The value
+comes from one of these options, searched in this order:
+
+=over
+
+=item * C<--dir deploy_dir=$deploy_dir>
+
+=item * C<target.$name.deploy_dir>
+
+=item * C<engine.$engine.deploy_dir>
+
+=item * C<core.deploy_dir>
+
+=item * F<C<$top_dir/deploy>>
+
+=back
+
+=head3 C<revert_dir>
+
+ my $revert_dir = $target->revert_dir;
+
+The path to the revert directory of the project. This directory contains all
+of the revert scripts referenced by changes the C<plan_file>. The value comes
+from one of these options, searched in this order:
+
+=over
+
+=item * C<--dir revert_dir=$revert_dir>
+
+=item * C<target.$name.revert_dir>
+
+=item * C<engine.$engine.revert_dir>
+
+=item * C<core.revert_dir>
+
+=item * F<C<$top_dir/revert>>
+
+=back
+
+=head3 C<verify_dir>
+
+ my $verify_dir = $target->verify_dir;
+
+The path to the verify directory of the project. This directory contains all
+of the verify scripts referenced by changes in the C<plan_file>. The value
+comes from one of these options, searched in this order:
+
+=over
+
+=item * C<--dir verify_dir=$verify_dir>
+
+=item * C<target.$name.verify_dir>
+
+=item * C<engine.$engine.verify_dir>
+
+=item * C<core.verify_dir>
+
+=item * F<C<$top_dir/verify>>
+
+=back
+
+=head3 C<reworked_dir>
+
+ my $reworked_dir = $target->reworked_dir;
+
+The path to the reworked directory of the project. This directory contains
+subdirectories for reworked deploy, revert, and verify scripts. The value
+comes from one of these options, searched in this order:
+
+=over
+
+=item * C<--dir reworked_dir=$reworked_dir>
+
+=item * C<target.$name.reworked_dir>
+
+=item * C<engine.$engine.reworked_dir>
+
+=item * C<core.reworked_dir>
+
+=item * C<$top_dir>
+
+=back
+
+=head3 C<reworked_deploy_dir>
+
+ my $reworked_deploy_dir = $target->reworked_deploy_dir;
+
+The path to the reworked deploy directory of the project. This directory
+contains all of the reworked deploy scripts referenced by changes in the
+C<plan_file>. The value comes from one of these options, searched in this
+order:
+
+=over
+
+=item * C<--dir reworked_deploy_dir=$reworked_deploy_dir>
+
+=item * C<target.$name.reworked_deploy_dir>
+
+=item * C<engine.$engine.reworked_deploy_dir>
+
+=item * C<core.reworked_deploy_dir>
+
+=item * F<C<$reworked_dir/reworked_deploy>>
+
+=back
+
+=head3 C<reworked_revert_dir>
+
+ my $reworked_revert_dir = $target->reworked_revert_dir;
+
+The path to the reworked revert directory of the project. This directory
+contains all of the reworked revert scripts referenced by changes the
+C<plan_file>. The value comes from one of these options, searched in this
+order:
+
+=over
+
+=item * C<--dir reworked_revert_dir=$reworked_revert_dir>
+
+=item * C<target.$name.reworked_revert_dir>
+
+=item * C<engine.$engine.reworked_revert_dir>
+
+=item * C<core.reworked_revert_dir>
+
+=item * F<C<$reworked_dir/reworked_revert>>
+
+=back
+
+=head3 C<reworked_verify_dir>
+
+ my $reworked_verify_dir = $target->reworked_verify_dir;
+
+The path to the reworked verify directory of the project. This directory
+contains all of the reworked verify scripts referenced by changes in the
+C<plan_file>. The value comes from one of these options, searched in this
+order:
+
+=over
+
+=item * C<--dir reworked_verify_dir=$reworked_verify_dir>
+
+=item * C<target.$name.reworked_verify_dir>
+
+=item * C<engine.$engine.reworked_verify_dir>
+
+=item * C<core.reworked_verify_dir>
+
+=item * F<C<$reworked_dir/reworked_verify>>
+
+=back
+
+=head3 C<extension>
+
+ my $extension = $target->extension;
+
+The file name extension to append to change names to create script file names.
+The value comes from one of these options, searched in this order:
+
+=over
+
+=item * C<--extension>
+
+=item * C<target.$name.extension>
+
+=item * C<engine.$engine.extension>
+
+=item * C<core.extension>
+
+=item * C<"sql">
+
+=back
+
+=head3 C<variables>
+
+ my $variables = $target->variables;
+
+The database variables to use in change scripts. The value are merged from
+these options, in this order:
+
+=over
+
+=item * C<target.$name.variables>
+
+=item * C<engine.$engine.variables>
+
+=back
+
+The C<core.variables> configuration is not read, because command-specific
+configurations, such as C<deploy.variables> and C<revert.variables> take
+priority. The command themselves therefore pass them to the engine in the
+proper priority order.
+
+=head3 C<engine_key>
+
+ my $key = $target->engine_key;
+
+The key defining which engine to use. This value defines the class loaded by
+C<engine>. Convenience method for C<< $target->uri->canonical_engine >>.
+
+=head3 C<dsn>
+
+ my $dsn = $target->dsn;
+
+The DSN to use when connecting to the target via the DBI. Convenience method
+for C<< $target->uri->dbi_dsn >>.
+
+=head3 C<username>
+
+ my $username = $target->username;
+
+The username to use when connecting to the target via the DBI. Convenience
+method for C<< $target->uri->user >>.
+
+=head3 C<password>
+
+ my $password = $target->password;
+
+The password to use when connecting to the target via the DBI. Convenience
+method for C<< $target->uri->password >>.
+
+=head1 See Also
+
+=over
+
+=item L<sqitch>
+
+The Sqitch command-line client.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/Types.pm b/lib/App/Sqitch/Types.pm
new file mode 100644
index 00000000..fe49ee33
--- /dev/null
+++ b/lib/App/Sqitch/Types.pm
@@ -0,0 +1,191 @@
+package App::Sqitch::Types;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Type::Library 0.040 -base, -declare => qw(
+ Sqitch
+ Engine
+ Target
+ UserName
+ UserEmail
+ Plan
+ Change
+ ChangeList
+ LineList
+ Tag
+ Depend
+ DateTime
+ URI
+ URIDB
+ File
+ Dir
+ Config
+ DBH
+);
+use Type::Utils -all;
+use Types::Standard -types;
+use Locale::TextDomain 1.20 qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use App::Sqitch::Config;
+use Scalar::Util qw(blessed);
+use List::Util qw(first);
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+# Inherit standard types.
+BEGIN { extends 'Types::Standard' };
+
+class_type Sqitch, { class => 'App::Sqitch' };
+class_type Engine, { class => 'App::Sqitch::Engine' };
+class_type Target, { class => 'App::Sqitch::Target' };
+class_type Plan, { class => 'App::Sqitch::Plan' };
+class_type Change, { class => 'App::Sqitch::Plan::Change' };
+class_type ChangeList, { class => 'App::Sqitch::Plan::ChangeList' };
+class_type LineList, { class => 'App::Sqitch::Plan::LineList' };
+class_type Tag, { class => 'App::Sqitch::Plan::Tag' };
+class_type Depend, { class => 'App::Sqitch::Plan::Depend' };
+class_type DateTime, { class => 'App::Sqitch::DateTime' };
+class_type URIDB, { class => 'URI::db' };
+class_type Config { class => 'App::Sqitch::Config' };
+class_type File { class => 'Path::Class::File' };
+class_type Dir { class => 'Path::Class::Dir' };
+class_type DBH { class => 'DBI::db' };
+
+subtype UserName, as Str, where {
+ hurl user => __ 'User name may not contain "<" or start with "["'
+ if /^[[]/ || /</;
+ 1;
+};
+
+subtype UserEmail, as Str, where {
+ hurl user => __ 'User email may not contain ">"' if />/;
+ 1;
+};
+
+# URI can be URI or URI::Nested.
+declare name => URI, constraint => sub {
+ my $o = $_;
+ return blessed $o && first { $o->isa($_)} qw(URI URI::Nested URI::WithBase)
+};
+
+1;
+__END__
+
+=head1 Name
+
+App::Sqitch::Types - Definition of attribute data types
+
+=head1 Synopsis
+
+ use App::Sqitch::Types qw(Bool);
+
+=head1 Description
+
+This module defines data types use in Sqitch object attributes. Supported types
+are:
+
+=over
+
+=item C<Sqitch>
+
+An L<App::Sqitch> object.
+
+=item C<Engine>
+
+An L<App::Sqitch::Engine> object.
+
+=item C<Target>
+
+An L<App::Sqitch::Target> object.
+
+=item C<UserName>
+
+A Sqitch user name.
+
+=item C<UserEmail>
+
+A Sqitch user email address.
+
+=item C<Plan>
+
+A L<Sqitch::App::Plan> object.
+
+=item C<Change>
+
+A L<Sqitch::App::Plan::Change> object.
+
+=item C<ChangeList>
+
+A L<Sqitch::App::Plan::ChangeList> object.
+
+=item C<LineList>
+
+A L<Sqitch::App::Plan::LineList> object.
+
+=item C<Tag>
+
+A L<Sqitch::App::Plan::Tag> object.
+
+=item C<Depend>
+
+A L<Sqitch::App::Plan::Depend> object.
+
+=item C<DateTime>
+
+A L<Sqitch::App::DateTime> object.
+
+=item C<URI>
+
+A L<URI> object.
+
+=item C<URIDB>
+
+A L<URI::db> object.
+
+=item C<File>
+
+A C<Class::Path::File> object.
+
+=item C<Dir>
+
+A C<Class::Path::Dir> object.
+
+=item C<Config>
+
+A L<Sqitch::App::Config> object.
+
+=item C<DBH>
+
+A L<DBI> database handle.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/App/Sqitch/X.pm b/lib/App/Sqitch/X.pm
new file mode 100644
index 00000000..6a5596d7
--- /dev/null
+++ b/lib/App/Sqitch/X.pm
@@ -0,0 +1,199 @@
+package App::Sqitch::X;
+
+use 5.010;
+use utf8;
+use Moo;
+use Types::Standard qw(Str Int);
+use Sub::Exporter::Util ();
+use Throwable 0.200009;
+use Sub::Exporter -setup => [qw(hurl)];
+use overload '""' => 'as_string';
+
+our $VERSION = 'v1.0.0'; # VERSION
+
+has message => (
+ is => 'ro',
+ isa => Str,
+ required => 1,
+);
+
+has exitval => (
+ is => 'ro',
+ isa => Int,
+ default => 2,
+);
+
+
+with qw(
+ Throwable
+ StackTrace::Auto
+);
+
+has ident => (
+ is => 'ro',
+ isa => Str,
+ default => 'DEV'
+);
+
+has '+previous_exception' => (init_arg => 'previous_exception')
+ if Throwable->VERSION < 0.200007;
+
+sub hurl {
+ @_ = (
+ __PACKAGE__,
+ # Always pass $@, as Throwable is unreliable about getting it thanks
+ # to hash randomization. Its workaround in v0.200006:
+ # https://github.com/rjbs/throwable/commit/596dfbafed970a30324dc21539d4edf2cbda767a
+ previous_exception => $@,
+ ref $_[0] ? %{ $_[0] }
+ : @_ == 1 ? (message => $_[0])
+ : (ident => $_[0], message => $_[1])
+ );
+ goto __PACKAGE__->can('throw');
+}
+
+sub as_string {
+ my $self = shift;
+ join "\n", grep { defined } (
+ $self->message,
+ $self->previous_exception,
+ $self->stack_trace
+ );
+}
+
+1;
+
+__END__
+
+=head1 Name
+
+App::Sqitch::X - Sqitch Exception class
+
+=head1 Synopsis
+
+ use Locale::TextDomain;
+ use App::Sqitch::X qw(hurl);
+ open my $fh, '>', 'foo.txt' or hurl {
+ ident => 'io',
+ message => __x 'Cannot open {file}: {err}", file => 'foo.txt', err => $!,
+ };
+
+Developer:
+
+ hurl 'Odd number of arguments passed to burf()' if @_ % 2;
+
+=head1 Description
+
+This module provides implements Sqitch exceptions. Exceptions may be thrown by
+any part of the code, and, as long as a command is running, they will be
+handled, showing the error message to the user.
+
+=head1 Interface
+
+=head2 Function
+
+=head3 C<hurl>
+
+Throws an exception. Pass the parameters as a hash reference, like so:
+
+ use App::Sqitch::X qw(hurl);
+ open my $fh, '>', 'foo.txt' or hurl {
+ ident => 'io',
+ message => __x 'Cannot open {file}: {err}", file => 'foo.txt', err => $!,
+ };
+
+More simply, if all you need to pass are the C<ident> and C<message>
+parameters, you can pass them as the only arguments to C<hurl()>:
+
+ open my $fh, '>', 'foo.txt'
+ or hurl io => __x 'Cannot open {file}: {err}", file => 'foo.txt', err => $!
+
+For errors that should only happen during development (e.g., an invalid
+parameter passed by some other library that should know better), you can omit
+the C<ident>:
+
+ hurl 'Odd number of arguments passed to burf()' if @_ % 2;
+
+In this case, the C<ident> will be C<DEV>, which you should not otherwise use.
+Sqitch will emit a more detailed error message, including a stack trace, when
+it sees C<DEV> exceptions.
+
+The supported parameters are:
+
+=over
+
+=item C<ident>
+
+A non-localized string identifying the type of exception.
+
+=item C<message>
+
+The exception message. Use L<Locale::TextDomain> to craft localized messages.
+
+=item C<exitval>
+
+Suggested exit value to use. Defaults to 2. This will be used if Sqitch
+handles an exception while a command is running.
+
+=back
+
+=head2 Methods
+
+=head3 C<as_string>
+
+ my $errstr = $x->as_string;
+
+Returns the stringified representation of the exception. This value is also
+used for string overloading of the exception object, which means it is the
+output shown for uncaught exceptions. Its contents are the concatenation of
+the exception message, the previous exception (if any), and the stack trace.
+
+=head1 Handling Exceptions
+
+use L<Try::Tiny> to do exception handling, like so:
+
+ use Try::Tiny;
+ try {
+ # ...
+ } catch {
+ die $_ unless eval { $_->isa('App::Sqitch::X') };
+ $sqitch->vent($x_->message);
+ if ($_->ident eq 'DEV') {
+ $sqitch->vent($_->stack_trace->as_string);
+ } else {
+ $sqitch->debug($_->stack_trace->as_string);
+ }
+ exit $_->exitval;
+ };
+
+Use the C<ident> attribute to determine what category of exception it is, and
+take changes as appropriate.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
+
diff --git a/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo
new file mode 100644
index 00000000..6c977d94
--- /dev/null
+++ b/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo
Binary files differ
diff --git a/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo
new file mode 100644
index 00000000..c0eea0a1
--- /dev/null
+++ b/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo
Binary files differ
diff --git a/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo
new file mode 100644
index 00000000..c449b8ac
--- /dev/null
+++ b/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo
Binary files differ
diff --git a/lib/sqitch-add-usage.pod b/lib/sqitch-add-usage.pod
new file mode 100644
index 00000000..905102d6
--- /dev/null
+++ b/lib/sqitch-add-usage.pod
@@ -0,0 +1,25 @@
+=head1 Name
+
+sqitch-add-usage - Sqitch add usage statement
+
+=head1 Usage
+
+ sqitch add [options] [template options] <change> [<target>]
+
+=head1 Options
+
+ -c --change name of the change to add
+ -r --requires require change
+ -x --conflicts declare conflicting change
+ -a --all add change to all project plans
+ -s --set set a template variable
+ -n --note a note describing the change
+
+ -t --template name of the templates to use
+ --template-directory path to directory containing templates
+ --use [template=path] path to named template
+
+ --with [script] create named script
+ --without [script] do not create named script
+ -e --edit, --open-editor open change scripts in an editor
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-add.pod b/lib/sqitch-add.pod
new file mode 100644
index 00000000..7a8923d4
--- /dev/null
+++ b/lib/sqitch-add.pod
@@ -0,0 +1,435 @@
+=head1 Name
+
+sqitch-add - Add a database change to plans
+
+=head1 Synopsis
+
+ sqitch add widgets [options
+ sqitch add blankets --all
+ sqitch add --change sprockets pg sql
+ sqitch add slinkies --require sprockets --set schema=industry
+
+=head1 Description
+
+This command adds a database change to one or more plans. This will result in
+the creation of script files in the deploy, revert, and verify directories,
+and possibly others. The content of these files is determined by the
+evaluation of templates. By default, system templates in
+F<$(prefix)/etc/sqitch/templates> are used. These can be overridden by a
+single user by creating templates in F<~/.sqitch/templates/> See L</Templates>
+for details.
+
+The paths and extensions of the generated scripts depend on the configuration
+of Sqitch targets, engines, and the core. See L<sqitch-configuration> for
+details.
+
+Note that the name of the new change must adhere to the rules as defined in
+L<sqitchchanges>.
+
+By default, the C<add> command will add the change to the default plan and the
+scripts to any top directories for that plan, as defined by the core
+configuration and command-line options. This works well for projects in which
+there is a single plan with separate top directories for each engine, for
+example. Pass the C<--all> option to have it iterate over all known plans and
+top directories (as specified for engines and targets) and add the change to
+them all.
+
+To specify which plans and top directories to which the change and its scripts
+will be added, pass the target, engine, or plan file names as arguments. Use
+C<--change> to disambiguate the tag and change names from the other parameters
+if necessary (or preferable). See L</Examples> for examples.
+
+=head1 Options
+
+=over
+
+=item C<-c>
+
+=item C<--change>
+
+=item C<--change-name>
+
+The name of the change to add. The name can be specified with or without this
+option, but the option can be useful for disambiguating the change name from
+other arguments.
+
+=item C<-r>
+
+=item C<--requires>
+
+Name of a change that is required by the new change. May be specified multiple
+times. See L<sqitchchanges> for the various ways in which changes can be
+specified.
+
+=item C<-x>
+
+=item C<--conflicts>
+
+Name of a change that conflicts with the new change. May be specified multiple
+times. See L<sqitchchanges> for the various ways in which changes can be
+specified.
+
+=item C<-a>
+
+=item C<--all>
+
+Add the change to all plans in the project. Cannot be mixed with target,
+engine, or plan file name arguments; doing so will result in an error. Useful
+for multi-plan projects in which changes should be kept in sync. Overrides the
+value of the C<add.all> configuration; use C<--no-all> to override a true
+C<add.all> configuration.
+
+=item C<-n>
+
+=item C<--note>
+
+A brief note describing the purpose of the change. The note will be attached
+to the change as a comment. Multiple invocations will be concatenated together
+as separate paragraphs.
+
+For you Git folks out there, C<-m> also works.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use in the templates. The format must be
+C<name=value>, e.g., C<--set comment='This one is for you, babe.'>.
+
+=item C<--template-directory>
+
+Location to look for the templates. If none is specified, C<add> will
+first look in F<~/.sqitch/templates/> for each template, and fall back on
+F<$(prefix)/etc/sqitch/templates>.
+
+=item C<-t>
+
+=item C<--template>
+
+=item C<--template-name>
+
+Name of the templates to use for the scripts. When Sqitch searches the
+template directory for templates, it uses this name to find them in subdirectories
+named for the various types of scripts, including:
+
+=over
+
+=item C<deploy/$name.tmpl>
+
+=item C<revert/$name.tmpl>
+
+=item C<verify/$name.tmpl>
+
+=back
+
+Any templates found with the same name in additional subdirectories will also
+be evaluated.
+
+This option allows one to define templates for specific tasks, such as
+creating a table, and then use them for changes that perform those tasks.
+Defaults to the name of the database engine (C<pg>, C<sqlite>, C<mysql>,
+C<oracle>, C<firebird>, C<vertica>, C<exasol>, or C<snowflake>).
+
+=item C<--use script=template>
+
+Specify the path to a template for a specific type of script. Defaults to the
+individual templates and using C<--template-name>, found in
+C<--template-directory> and the configuration template directories.
+
+=item C<--with>
+
+=item C<--without>
+
+Specify a type of template to generate or not generate.
+
+=item C<-e>
+
+=item C<--edit>
+
+=item C<--open-editor>
+
+Open the generated change scripts in an editor.
+
+=item C<--no-edit>
+
+=item C<--no-open-editor>
+
+Do not open the change scripts in an editor. Useful when L<C<add.open_editor>>
+is true.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Examples
+
+Add a change to a project and be prompted for a note.
+
+ sqitch add widgets
+
+Add a change and specify the note.
+
+ sqitch add sprockets --note 'Adds the sprockets table.'
+
+Add a change that requires the C<users> change from earlier in the plan.
+
+ sqitch add contacts --requires users -n 'Adds the contacts table'
+
+Add a change that requires multiple changes, including the change named
+C<extract> from a completely different Sqitch project named C<utilities>:
+
+ sqitch add coffee -r users -r utilities:extract -n 'Mmmmm...coffee!'
+
+Add a change that uses the C<createtable> templates to generate the scripts,
+as well as variables to be used in that template (See
+L<https://justatheory.com/2013/09/sqitch-templating/> for a custom template
+tutorial):
+
+ sqitch add corp_widgets --template createtable \
+ -s schema=corp -s table=widgets \
+ -s column=id -s type=SERIAL \
+ -s column=name -s type=TEXT \
+ -s column=quantity -s type=INTEGER \
+ -n 'Add corp.widgets table.'
+
+Add a change only to the plan used by the C<vertica> engine in a project:
+
+ sqitch add --change logs vertica -n 'Adds the logs table to Vertica.'
+
+Add a change to just two plans in a project, and generate the scripts only for
+those plans:
+
+ sqitch add -a coolfunctions sqlite.plan pg.plan -n 'Adds functions.'
+
+=head1 Templates
+
+Sqitch contains a very simple set of templates for generating the deploy,
+revert, and verify scripts, and you can create more of your own. By default,
+Sqitch uses system-wide templates installed in
+F<$(prefix)/etc/sqitch/templates>; call C<sqitch --etc-path> to find out
+where, exactly (e.g., C<$(sqitch --etc-path)/templates>). Individual templates
+may be overridden on a user basis by copying templates to
+F<~/.sqitch/templates> and making modifications. They may also be overridden
+by using the C<--template-directory> or C<--template-name> options, as well as
+the template-specific options.
+
+=head2 Directory Layout
+
+Sqitch looks for templates in the following directories, and in this order:
+
+=over
+
+=item * C<--template-directory> or C<add.template_directory>
+
+=item * F<~/.sqitch/templates/>
+
+=item * F<$(prefix)/etc/sqitch/templates>
+
+=back
+
+Each should consist of subdirectories named for the types of scripts to be
+generated. These should include F<deploy>, F<revert>, and F<verify>, but you
+can create any number of other directories to create additional scripts that
+will end up in a directory of the same name.
+
+Each directory should include one or more files ending in F<.tmpl>. The
+main part of the file name can be anything, but by default Sqitch will
+look for a file named for the database engine. Use the C<--template> option
+to have Sqitch use a different file.
+
+For example, say you have this directory structure:
+
+ templates/deploy/pg.tmpl
+ templates/deploy/create_table.tmpl
+ templates/revert/pg.tmpl
+ templates/revert/create_table.tmpl
+ templates/test/pg.tmpl
+ templates/verify/pg.tmpl
+ templates/verify/create_table.tmpl
+
+Assuming that you're using the PostgreSQL engine, the code for which is C<pg>,
+when you add a new change like so:
+
+ sqitch add schema -n 'Creates schema'
+
+Sqitch will use the C<pg.tmpl> files to create the following files in the
+top directory configured for the project (See L<sqitch-configuration> for
+details).
+
+ deploy/schema.sql
+ revert/schema.sql
+ test/schema.sql
+ verify/schema.sql
+
+If you want to use the C<create_table> templates, instead, use the
+C<--template> option, like so:
+
+ sqitch add user_table --template create_table -n 'Create user table'
+
+Sqitch will use the C<create_table.tmpl> files to create the following files
+in the top directory configured for the project (See L<sqitch-configuration>
+for details).
+
+ deploy/user_table.sql
+ revert/user_table.sql
+ verify/user_table.sql
+
+Note that the C<test> file was not created, because no
+F<test/create_table.tmpl> template file exists.
+
+=head2 Syntax
+
+The syntax of Sqitch templates is the very simple language provided by
+L<Template::Tiny>, which is limited to:
+
+=over
+
+=item C<[% %]>
+
+This is the directive syntax. By default, the return value of the expression
+is output:
+
+ -- Deploy [% project %]:[% change %] to [% engine %]
+
+You can add C<-> to the immediate start or end of a directive tag to control
+the whitespace chomping options:
+
+ [% IF foo -%] # remove trailing newline
+ We have foo!
+ [%- END %] # remove leading newline
+
+=item C<[% IF %]>
+
+=item C<[% IF %] / [% ELSE %]>
+
+=item C<[% UNLESS %]>
+
+Conditional blocks:
+
+ [% IF transactions %]
+ BEGIN;
+ [% ELSE %]
+ -- No transaction, beware!
+ [% END %]
+
+=item C<[% FOREACH item IN list %]>
+
+Loop over a list of values:
+
+ [% FOREACH item IN requires -%]
+ -- requires: [% item %]
+ [% END -%]
+
+=back
+
+If this is not sufficient for your needs, simply install L<Template::Toolkit>
+and all templates will be processed by its more comprehensive features. See
+the L<complete Template Toolkit documentation|http://tt2.org/docs/manual/> for
+details, especially the L<syntax docs|http://tt2.org/docs/manual/Syntax.html>
+
+=head2 Variables
+
+Sqitch defines five variables for all templates. Any number of additional
+variables can be added via the C<--set> option, like so:
+
+ sqitch add --set transactions=1 --set schema=foo
+
+Any number of variables may be specified in this manner. You may then use
+those variables in custom templates. Variables that appear multiple times will
+be passed to the templates as lists of values for which you will likely want
+to use C<[% FOREACH %]>. If the templates do not reference your variables,
+they will be ignored. Variables may also be specified in a C<add.variables>
+L<config|sqitch-config> section (see L</Configuration Variables>). Variables
+specified via C<--set> will override configuration variables.
+
+The five core variables are:
+
+=over
+
+=item C<change>
+
+The name of the change being added.
+
+=item C<engine>
+
+The name of the engine for which the change was added. One of C<pg>,
+C<sqlite>, C<mysql>, C<oracle>, C<firebird>, C<vertica> C<exasol>, or
+C<snowflake>.
+
+=item C<project>
+
+The name of the Sqitch project to which the change was added. The project name
+is set in the plan by the L<C<init> command>|sqitch-init>.
+
+=item C<requires>
+
+A list of required changes as passed via one or more instances of the
+C<--requires> option.
+
+=item C<conflicts>
+
+A list of conflicting changes as passed via one or more instances of the
+C<--conflicts> option.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<add.all>
+
+Add the change to all the plans in the project. Useful for multi-plan projects
+in which changes should be kept in sync. May be overridden by C<--all>,
+C<--no-all>, or target, engine, and plan file name arguments.
+
+=item C<add.template_directory>
+
+Directory in which to find the templates. Any templates found in this
+directory take precedence over user- or system-specific templates, and may in
+turn be overridden by the C<--use> option.
+
+=item C<add.template_name>
+
+Name used for template files. Should not include the F<.tmpl> suffix.
+Overrides the default, which is the name of the database engine, and may in
+turn be overridden by the C<--template> option.
+
+=item C<[add.templates]>
+
+Location of templates of different types. Core templates include:
+
+=over
+
+=item C<add.templates.deploy>
+
+=item C<add.templates.revert>
+
+=item C<add.templates.verify>
+
+=back
+
+But a custom template type can have its location specified here, as well,
+such as C<add.template.unit_test>. May be overridden by C<--use>.
+
+=item C<[add.variables]>
+
+A section defining template variables. Useful if you've customized templates
+with your own variables and want project-, user-, or system-specific defaults
+for them.
+
+=item C<add.open_editor>
+
+Boolean indicating if the add command should spawn an editor after generating
+change scripts. When true, equivalent to passing C<--edit>. Defaults off.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-authentication.pod b/lib/sqitch-authentication.pod
new file mode 100644
index 00000000..ac5aaabd
--- /dev/null
+++ b/lib/sqitch-authentication.pod
@@ -0,0 +1,391 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitch-authentication - Guide to using database authentication credentials with Sqitch
+
+=head1 Description
+
+For database engines that require authentication, Sqitch supports a number
+of credential-specification options, and searches for them in a specific
+sequence. These searches are performed in two parts: a search for a username
+and a search for a password.
+
+=head1 Usernames
+
+Sqitch searches for usernames sequentially, using the first value it finds.
+Any of these approaches may be used to specify a username, in this order:
+
+=over
+
+=item 1. In the C<$SQITCH_USERNAME> environment variable
+
+=item 2. Via the C<--db-username> option
+
+=item 3. In the deploy target URI; this is the preferred option
+
+=item 4. In an engine-specific environment variable or configuration
+
+=back
+
+Naturally, this last option varies by database engine. The details are as
+follows:
+
+=over
+
+=item PostgreSQL
+
+The Postgres engine uses the C<PGUSER> environment variable, if set.
+Otherwise, it uses the system username.
+
+
+=item MySQL
+
+For MySQL, if the L<MySQL::Config> module is installed, usernames and
+passwords can be specified in the
+L<F</etc/my.cnf> and F<~/.my.cnf> files|https://dev.mysql.com/doc/refman/5.7/en/password-security-user.html>.
+These files must limit access only to the current user (C<0600>). Sqitch will
+look for a username and password under the C<[client]> and C<[mysql]>
+sections, in that order.
+
+=item Oracle
+
+Oracle provides no default to search for a username.
+
+=item Vertica
+
+The Vertica engine uses the C<VSQL_USER> environment variable, if set.
+Otherwise, it uses the system username.
+
+=item Firebird
+
+The Firebird engine uses the C<ISC_USER> environment variable, if set.
+
+=item Exasol
+
+Exasol provides no default to search for a username.
+
+=item Snowflake
+
+
+The Snowflake engine uses the C<SNOWSQL_USER> environment variable, if set.
+Next, it looks in the
+L<F<~/.snowsql/config> file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#snowsql-config-file>
+and use the default C<connections.username> value. Otherwise, it uses the
+system username.
+
+=back
+
+=head1 Passwords
+
+You may have noticed that Sqitch has no C<--password> option. This is
+intentional. It's generally not a great idea to specify a password on the
+command-line: from there, it gets logged to your command history and is easy
+to extract by anyone with access to your system. So you might wonder how to
+specify passwords so that Sqitch an successfully deploy to databases that
+require passwords. There are four approaches, in order from most- to
+least-recommended:
+
+=over
+
+=item 1. Avoid using a password at all
+
+=item 2. Use a database engine-specific password file
+
+=item 3. Use the C<$SQITCH_PASSWORD> environment variable
+
+=item 4. Include the password in the deploy target URI
+
+=back
+
+Each is covered in detail in the sections below.
+
+=head2 Don't use Passwords
+
+Of course, the best way to protect your passwords is not to use them at all.
+If your database engine is able to do passwordless authentication, it's worth
+taking the time to make it work, especially on your production database
+systems. Some examples:
+
+=over
+
+=item PostgreSQL
+
+PostgreSQL supports a number of
+L<authentication methods|https://www.postgresql.org/docs/current/static/auth-methods.html>,
+including the passwordless L<SSL certificate|https://www.postgresql.org/docs/current/static/auth-methods.html#AUTH-CERT>, L<GSSAPI|https://www.postgresql.org/docs/current/static/auth-methods.html#GSSAPI-AUTH>, and, for local connections,
+L<peer authentication|https://www.postgresql.org/docs/current/static/auth-methods.html#AUTH-PEER>.
+
+=item MySQL
+
+MySQL supports a number of
+L<authentication methods|https://dev.mysql.com/doc/internals/en/authentication-method.html>,
+plus L<SSL authentication|https://dev.mysql.com/doc/internals/en/ssl.html>.
+
+=item Oracle
+
+Oracle supports a number of
+L<authentication methods|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#BABCGGEB>,
+including
+L<SSL authentication|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#i1009722>,
+L<third-party authentication|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#i1009853>,
+and, for local connections,
+L<OS authentication|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#i1007520>.
+
+=item Vertica
+
+Vertica supports a number of
+L<authentication methods|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/SupportedClientAuthenticationMethods.htm>
+including the passwordless L<TLS authentication|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/ConfiguringTLSAuthentication.htm>,
+L<GSS authentication|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/Kerberos/ImplementingKerberosAuthentication.htm>,
+and, for local connections,
+L<ident authentication|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/ConfiguringIdentAuthentication.htm>.
+
+=item Firebird
+
+Firebird supports passwordless authentication only via
+L<trusted authentication|https://www.firebirdsql.org/manual/qsg2-config.html>
+for local connections.
+
+=item Exasol
+
+Exasol doesn't seem to support password-less authentication at this time; for
+other options, see the L<documentation|https://www.exasol.com/portal/display/DOC/Database+User+Manual>.
+
+=item Snowflake
+
+Snowflake does not support password-less authentication, but does support
+key-pair authentication. Follow
+L<the instructions|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#using-key-pair-authentication>
+to create a key pair, then set the following variables in the F<~/.snowsql/config>
+file:
+
+ authenticator = SNOWFLAKE_JWT
+ private_key_path = "path/to/privatekey.p8"
+
+To connect, set the C<$SNOWSQL_PRIVATE_KEY_PASSPHRASE> environment variable to
+the passphrase for the private key, and add these parameters to the query part
+of your connection URI:
+
+=over
+
+=item * C<authenticator=SNOWFLAKE_JWT>
+
+=item * C<uid=$username>
+
+=item * C<priv_key_file=path/to/privatekey.p8>
+
+=item * C<priv_key_file_pwd=$private_key_password>
+
+=back
+
+For example:
+
+ db:snowflake://movera@example.snowflakecomputing.com/flipr?Driver=Snowflake;warehouse=sqitch;authenticator=SNOWFLAKE_JWT;uid=movera;priv_key_file=path/to/privatekey.p8;priv_key_file_pwd=s0up3rs3cre7
+
+=back
+
+=head2 Use a Password File
+
+If you must use password authentication with your database server, you may be
+able to use a protected password file. This is file with access limited only
+to the current user that the server client library can read in. As such, the
+format is specified by the database vendor, and not all database servers offer
+the feature. Here's how the database engines supported by Sqitch shake out:
+
+=over
+
+=item PostgreSQL
+
+PostgreSQL will use a
+L<F<.pgpass> file|https://www.postgresql.org/docs/current/static/libpq-pgpass.html> in the
+user's home directory to or referenced by the C<$PGPASSFILE> environment
+variable. This file must limit access only to the current user (C<0600>) and
+contains lines specify authentication rules as follows:
+
+ hostname:port:database:username:password
+
+=item MySQL
+
+For MySQL, if the L<MySQL::Config> module is installed, usernames and
+passwords can be specified in the
+L<F</etc/my.cnf> and F<~/.my.cnf> files|https://dev.mysql.com/doc/refman/5.7/en/password-security-user.html>.
+These files must limit access only to the current user (C<0600>). Sqitch will
+look for a username and password under the C<[client]> and C<[mysql]>
+sections, in that order.
+
+=item Oracle
+
+Oracle supports
+L<password file|https://docs.oracle.com/cd/B28359_01/server.111/b28310/dba007.htm#ADMIN10241>
+created with the C<ORAPWD> utility to authenticate C<SYSDBA> and C<SYSOPER>
+users, but B<Sqitch is unable to take advantage of this functionality.> Neither can
+one L<embed a username and password|https://stackoverflow.com/q/7183513/79202>
+into a
+L<F<tnsnames.ora>|https://docs.oracle.com/cd/B28359_01/network.111/b28317/tnsnames.htm#NETRF007>
+file.
+
+=item Vertica
+
+Vertica does not currently support a password file.
+
+=item Firebird
+
+Firebird does not currently support a password file.
+
+=item Exasol
+
+Exasol allows configuring connection profiles for the 'exaplus' client:
+
+ > exaplus -u sys -p exasol -c localhost:8563 -wp flipr_test
+ EXAplus 6.0.4 (c) EXASOL AG
+
+ Profile flipr_test is saved.
+ > exaplus -profile flipr_test -q -sql "select current_timestamp;"
+
+ CURRENT_TIMESTAMP
+ --------------------------
+ 2017-11-02 13:35:48.360000
+
+These profiles are stored in F<~/.exasol/profiles.xml>, readable only to the user
+by default. See the L<documentation|https://www.exasol.com/portal/display/DOC/Database+User+Manual>
+for more information on connection profiles, specifically the EXAplus section in
+the chapter on "Clients and interfaces".
+
+For ODBC connections from Sqitch, we can use connection settings in F<~/.odbc.ini>:
+
+ [flipr_test]
+ DRIVER = Exasol
+ EXAHOST = localhost:8563
+ EXAUID = sys
+ EXAPWD = exasol
+
+When combining the above, Sqitch doesn't need to know any credentials; they are
+stored somewhat safely in F<~/.exasol/profiles.xml> and F<~/.odbc.ini>:
+
+ > sqitch status db:exasol:flipr_test
+ # On database db:exasol:flipr_test
+ # Project: flipr
+ # ...
+ #
+ Nothing to deploy (up-to-date)
+ > sqitch rebase --onto '@HEAD^' -y db:exasol:flipr_test
+ Reverting changes to hashtags @v1.0.0-dev2 from db:exasol:flipr_test
+ - userflips .. ok
+ Deploying changes to db:exasol:flipr_test
+ + userflips .. ok
+
+=item Snowflake
+
+For Snowflake, Sqitch will read the
+L<F<~/.snowsql/config> file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#snowsql-config-file>
+and use the default connections settings; named connections are not supported.
+An example:
+
+ [connections]
+ accountname = myaccount
+ region = us-east-1
+ warehousename = compute
+ username = frank
+ password = fistula postmark bag
+ rolename = ACCOUNTADMIN
+ dbname = reporting
+
+The variables that Sqitch currently reads are:
+
+=over
+
+=item C<connections.accountname>
+
+=item C<connections.username>
+
+=item C<connections.password>
+
+=item C<connections.rolename>
+
+=item C<connections.region>
+
+=item C<connections.warehousename>
+
+=item C<connections.dbname>
+
+=back
+
+=back
+
+=head2 Use C<$SQITCH_PASSWORD>
+
+The C<$SQITCH_PASSWORD> environment variable can be used to specify the
+password for any supported database engine. However use of this environment
+variable is not recommended for security reasons, as some operating systems
+allow non-root users to see process environment variables via C<ps>.
+
+The behavior of C<$SQITCH_PASSWORD> is consistent across all supported
+engines, as is the complementary C<$SQITCH_USERNAME> environment variable.
+Some database engines support their own password environment variables, which
+you may wish to use instead. However, their behaviors may not be consistent:
+
+=over
+
+=item PostgreSQL
+
+C<$PGPASSWORD>
+
+=item MySQL
+
+C<$MYSQL_PWD>
+
+=item Vertica
+
+C<$VSQL_PASSWORD>
+
+=item Firebird
+
+C<$ISC_PASSWORD>
+
+=item Snowflake
+
+C<$SNOWSQL_PWD>
+
+=back
+
+=head2 Use Target URIs
+
+Passwords may also be specified in L<target URIs|sqitch-target/Description>.
+This is not generally recommended, since such URIs are either specified via
+the command-line (and therefore visible in C<ps> and your shell history) or
+stored in the L<configuration|sqitch-configuration>, the project instance of
+which is generally pushed to your source code repository. But it's provided
+here as an absolute last resort (and because web URLs support it, though it's
+heavily frowned upon there, too).
+
+Such URIs can either be specified on the command-line:
+
+ sqitch deploy db:pg://fred:s3cr3t@db.example.com/widgets
+
+Or stored as named targets in the project configuration file:
+
+ sqitch target add wigets db:pg://fred:s3cr3t@db.example.com/widgets
+
+After which the target is available by its name:
+
+ sqitch deploy widgets
+
+See L<sqitch-targets> and C<sqitch-configuration> for details on target
+configuration.
+
+=head1 See Also
+
+=over
+
+=item * L<sqitch-environment>
+
+=item * L<sqitch-configuration>
+
+=item * L<sqitch-target>
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-bundle-usage.pod b/lib/sqitch-bundle-usage.pod
new file mode 100644
index 00000000..7219b5f3
--- /dev/null
+++ b/lib/sqitch-bundle-usage.pod
@@ -0,0 +1,14 @@
+=head1 Name
+
+sqitch-bundle-usage - Sqitch bundle usage statement
+
+=head1 Usage
+
+ sqitch bundle [options] [<engine>] [<plan>] [<target>]
+
+=head1 Options
+
+ --dest-dir --dir <dir> destination directory to which to copy files
+ --from change from which to start bundling
+ --to change to which to end bundling
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-bundle.pod b/lib/sqitch-bundle.pod
new file mode 100644
index 00000000..633e7128
--- /dev/null
+++ b/lib/sqitch-bundle.pod
@@ -0,0 +1,133 @@
+=head1 Name
+
+sqitch-bundle - Bundle a Sqitch project for distribution
+
+=head1 Synopsis
+
+ sqitch bundle [options
+ sqitch bundle --dest-dir widgets-1.0.0
+ sqitch bundle --all
+ sqitch bundle pg mysql
+
+=head1 Description
+
+This command bundles up a sqitch project for distribution. At its simplest, it
+copies the project configuration file, plan files, and all of the change
+scripts to a directory. This directory can then be packaged up for
+distribution (as a tarball, RPM, etc.).
+
+By default, the C<bundle> command will bundle the plan and scripts for the
+default plan and top directory, as defined by the core configuration and
+command-line options. Pass the C<--all> option to have it iterate over all
+known plans and top directories (as specified for engines and targets) and
+bundle them all. This works well for creating a a single bundle with all
+plans and scripts.
+
+To specify which plans an top directories to bundle, pass the target, engine,
+or plan file names as arguments. See L</Examples> for examples.
+
+=over
+
+=item * Engine names
+
+=item * Target names
+
+=item * Plan file names
+
+=back
+
+The bundle command also allows you to limit bundled changes to a subset of
+those in a plan. When bundling a single plan, use the C<--from> and/or C<--to>
+options to do the limiting. When using multiple plans, specify the changes
+after each target argument. In either case, the changes can be specified in
+any way documented on L<sqitchchanges>. See L</Examples> for examples.
+
+=head1 Options
+
+=over
+
+=item C<--dest-dir>
+
+=item C<--dir>
+
+The name of the directory in which to bundle the project. The configuration
+file will be created in this directory, and all top, deploy, revert, and
+verify directories will be created relative to it. Defaults to F<bundle>.
+
+=item C<--from>
+
+The change from which to start bundling. If you need to bundle up only a subset
+of a plan, specify a change (using a supported L<change specification|sqitchchanges>
+from which to start the bundling via this option. This option is probably only
+useful when bundling a single plan.
+
+=item C<--to>
+
+The change to which to end bundling. If you need to bundle up only a subset
+of a plan, specify a change (using a supported L<change specification|sqitchchanges>
+that should be the last change to be included in the bundle. This option is
+probably only useful when bundling a single plan.
+
+=item C<-a>
+
+=item C<--all>
+
+Bundle all the project plans and their associated scripts. Cannot be mixed
+with target, engine, or plan file name arguments; doing so will result in an
+error. Useful for multi-plan projects that should have all the plans bundled
+together. Overrides the value of the C<bundle.all> configuration; use
+C<--no-all> to override a true C<bundle.all> configuration.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<bundle.dest_dir>
+
+The name of the directory in which to bundle the project.
+
+=back
+
+=head1 Examples
+
+Bundle a Sqitch project with the default plan and scripts into F<bundle>:
+
+ sqitch bundle
+
+Bundle a Sqitch project with all plans and scripts into F<bundle>:
+
+ sqitch bundle --all
+
+Bundle a Sqitch project into F<BUILDROOT/MyProj>:
+
+ sqitch bundle --dest-dir BUILDROOT/MyProj
+
+Bundle a project including changes C<adduser> through C<@v1.0>:
+
+ sqitch bundle --from adduser --to @v1.0
+
+Bundle a the C<pg> engine plans with changes C<adduser> through C<@v1.0>, and
+the C<sqlite> engine with changes from the start of the plan up to C<widgets>:
+
+ sqitch bundle pg adduser @v1.0 sqlite @ROOT wigets
+
+Bundle just the files necessary to execute the plan for the C<pg> engine:
+
+ sqitch bundle pg
+
+Bundle the files necessary for two plan files:
+
+ sqitch bundle sqlite/sqitch.plan mysql/sqitch.plan
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-checkout-usage.pod b/lib/sqitch-checkout-usage.pod
new file mode 100644
index 00000000..4d766733
--- /dev/null
+++ b/lib/sqitch-checkout-usage.pod
@@ -0,0 +1,26 @@
+=head1 Name
+
+sqitch-checkout-usage - Sqitch checkout usage statement
+
+=head1 Usage
+
+ sqitch checkout [options] [<database>] <branch>
+
+=head1 Options
+
+ -t --target <target> database to which to connect
+ --mode <mode> deploy failure reversion mode (all, tag, or change)
+ --verify run verify scripts after deploying each change
+ --no-verify do not run verify scripts
+ -s --set <key=value> set a database client variable
+ -r --set-revert <key=value> set a database client revert variable
+ -e --set-deploy <key=value> set a database client deploy variable
+ --log-only log changes without running them
+ -y disable the prompt before reverting
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-checkout.pod b/lib/sqitch-checkout.pod
new file mode 100644
index 00000000..9039b311
--- /dev/null
+++ b/lib/sqitch-checkout.pod
@@ -0,0 +1,278 @@
+=head1 Name
+
+sqitch-checkout - Revert, checkout another VCS branch, and re-deploy changes
+
+=head1 Synopsis
+
+ sqitch checkout [options] [<database>] <branch>
+
+=head1 Description
+
+Checkout another branch in your project's VCS (such as
+L<git|https://git-scm.org/>), while performing the necessary database changes
+to update your database for the new branch.
+
+More specifically, the C<checkout> command compares the plan in the current
+branch to that in the branch to check out, identifies the last common changes
+between them, reverts to that change as if L<C<sqitch revert>|sqitch-revert>
+was called (unless there have been no changes deployed since that change),
+checks out the new branch, and then deploys all changes as if
+L<C<sqitch deploy>|sqitch-deploy> had been called.
+
+If the VCS is already on the specified branch, nothing will be done.
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--mode>
+
+Specify the reversion mode to use in case of failure. Possible values are:
+
+=over
+
+=item C<all>
+
+In the event of failure, revert all deployed changes, back to the point at
+which deployment started. This is the default.
+
+=item C<tag>
+
+In the event of failure, revert all deployed changes to the last
+successfully-applied tag. If no tags were applied during this deployment, all
+changes will be reverted to the point at which deployment began.
+
+=item C<change>
+
+In the event of failure, no changes will be reverted. This is on the
+assumption that a change is atomic, and thus may may be deployed again.
+
+=back
+
+=item C<--verify>
+
+Verify each change by running its verify script, if there is one. If a verify
+test fails, the deploy will be considered to have failed and the appropriate
+reversion will be carried out, depending on the value of C<--mode>.
+
+=item C<--no-verify>
+
+Don't verify each change. This is the default.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values loaded from
+L</configuration Variables>.
+
+=item C<-e>
+
+=item C<--set-deploy>
+
+Set a variable name and value for use by the database engine client when
+deploying, if it supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values
+loaded from L</configuration Variables>.
+
+=item C<-r>
+
+=item C<--set-revert>
+
+Sets a variable name to be used by the database engine client during when
+reverting, if it supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values
+loaded from L</configuration Variables>.
+
+=item C<--log-only>
+
+Log the changes as if they were deployed, but without actually running the
+deploy scripts. Useful for an existing database that is being converted to
+Sqitch, and you need to log changes as deployed because they have been
+deployed by other means in the past.
+
+=item C<-y>
+
+Disable the prompt that normally asks whether or not to execute the revert.
+
+=item C<--registry>
+
+ sqitch checkout --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch checkout --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch checkout --db-name widgets
+ sqitch checkout -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch checkout --db-username root
+ sqitch checkout --db-user postgres
+ sqitch checkout -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch checkout --db-host db.example.com
+ sqitch checkout -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch checkout --db-port 7654
+ sqitch checkout -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch checkout --plan-file my.plan
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<[deploy.variables]>
+
+=item C<[revert.variables]>
+
+A section defining database client variables. These variables are useful if
+your database engine supports variables in scripts, such as PostgreSQL's
+L<C<psql>
+variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>,
+Vertica's L<C<vsql>
+variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>,
+MySQL's L<user
+variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>,
+SQL*Plus's L<C<DEFINE>
+variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>,
+and Snowflake's L<SnowSQL
+variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>.
+
+May be overridden by C<--set>, C<--set-deploy>, C<--set-revert>, or target and
+engine configuration. Variables are merged in the following priority order:
+
+=over
+
+=item C<--set-revert>
+
+Used only while reverting changes.
+
+=item C<--set-deploy>
+
+Used only while deploying changes.
+
+=item C<--set>
+
+Used while reverting and deploying changes.
+
+=item C<target.$target.variables>
+
+Used while reverting and deploying changes.
+
+=item C<engine.$engine.variables>
+
+Used while reverting and deploying changes.
+
+=item C<revert.variables>
+
+Used only while reverting changes.
+
+=item C<deploy.variables>
+
+Used while reverting and deploying changes.
+
+=item C<core.variables>
+
+Used while reverting and deploying changes.
+
+=back
+
+=item C<checkout.verify>
+
+=item C<deploy.verify>
+
+Boolean indicating whether or not to verify each change after deploying it.
+
+=item C<checkout.mode>
+
+=item C<deploy.mode>
+
+Deploy mode. The supported values are the same as for the C<--mode> option.
+
+=item C<[checkout.no_prompt]>
+
+=item C<[revert.no_prompt]>
+
+A boolean value indicating whether or not to disable the prompt before
+executing the revert. The C<checkout.no_prompt> variable takes precedence over
+C<revert.no_prompt>, and both may of course be overridden by C<-y>.
+
+=item C<[checkout.prompt_accept]>
+
+=item C<[revert.prompt_accept]>
+
+A boolean value indicating whether default reply to the prompt before
+executing the revert should be "yes" or "no". The C<checkout.prompt_accept>
+variable takes precedence over C<revert.prompt_accept>, and both default to
+true, meaning to accept the revert.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-config-usage.pod b/lib/sqitch-config-usage.pod
new file mode 100644
index 00000000..077ab54d
--- /dev/null
+++ b/lib/sqitch-config-usage.pod
@@ -0,0 +1,37 @@
+=head1 Name
+
+sqitch-config-usage - Sqitch config usage statement
+
+=head1 Usage
+
+ sqitch [options] config [config options]
+
+=head1 Options
+
+Config file location
+
+ --local use local config file
+ --user use user config file
+ --system use system config file
+ -f, --file <file> use given config file
+
+Action
+
+ --get get value: name [value-regex]
+ --get-all get all values: key [value-regex]
+ --get-regexp get values for regexp: name-regex [value-regex]
+ --replace-all replace all matching variables: name value [value_regex]
+ --add adds a new variable: name value
+ --unset removes a variable: name [value-regex]
+ --unset-all removes all matches: name [value-regex]
+ --rename-section rename section: old-name new-name
+ --remove-section remove a section: name
+ -l, --list list all
+ -e, --edit opens an editor
+
+Type
+
+ --bool value is "true" or "false"
+ --int value is decimal number
+ --num value is decimal number
+ --bool-or-int value is --bool or --int
diff --git a/lib/sqitch-config.pod b/lib/sqitch-config.pod
new file mode 100644
index 00000000..f15ae95e
--- /dev/null
+++ b/lib/sqitch-config.pod
@@ -0,0 +1,645 @@
+=head1 Name
+
+sqitch-config - Get and set local, user, or system Sqitch options
+
+=head1 Synopsis
+
+ sqitch config [<file-option>] [type] name [value [value_regex]]
+ sqitch config [<file-option>] [type] --add name value
+ sqitch config [<file-option>] [type] --replace-all name value [value_regex]
+ sqitch config [<file-option>] [type] --get name [value_regex]
+ sqitch config [<file-option>] [type] --get-all name [value_regex]
+ sqitch config [<file-option>] [type] --get-regexp name_regex [value_regex]
+ sqitch config [<file-option>] --unset name [value_regex]
+ sqitch config [<file-option>] --unset-all name [value_regex]
+ sqitch config [<file-option>] --rename-section old_name new_name
+ sqitch config [<file-option>] --remove-section name
+ sqitch config [<file-option>] -l | --list
+ sqitch config [<file-option>] -e | --edit
+
+=head1 Description
+
+You can query/set/replace/unset Sqitch options with this command. The name is
+actually the section and the key separated by a dot, and the value will be
+escaped.
+
+Multiple lines can be added to an option by using the C<--add> option. If you
+want to update or unset an option which can occur on multiple lines, a Perl
+regular expression C<value_regex> needs to be given. Only the existing values
+that match the regex will be updated or unset. If you want to handle lines
+that do not match the regex, just prepend a single C<!> (exclamation point) in
+front (see L<Examples>).
+
+The C<type> specifier can be C<--int>, C<--num>, or C<--bool>, to ensure that
+the variable(s) are of the given type and convert the value to the canonical
+form (simple integer for C<--int>, decimal number for C<--num>, a "true" or
+"false" string for C<--bool>) If no type specifier is passed, no checks or
+transformations are performed on the value.
+
+The C<file-option> can be one of C<--local>, C<--user>, C<--system>, or
+C<--file>, which specify where the values will be read from or written to. The
+default is to assume the local config file in the current project directory,
+for editing, and the all files merged for fetching (see L</Files>).
+
+=begin comment
+
+XXX Need to implmenent these.
+
+This command will fail (with exit code ret) if:
+
+=over
+
+=item 1.
+
+The config file is invalid (ret=3)
+
+=item 2.
+
+Cannot write to the config file (ret=4)
+
+=item 3.
+
+No section or name was provided (ret=2)
+
+=item 4.
+
+The section or key is invalid (ret=1)
+
+=item 5.
+
+You try to unset an option which does not exist (ret=5)
+
+=item 6.
+
+You try to unset/set an option for which multiple lines match (ret=5)
+
+=item 7.
+
+You try to use an invalid regexp (ret=6)
+
+=item 8.
+
+You use C<--user> option without C<$HOME> being properly set (ret=128)
+
+=back
+
+=end comment
+
+On success, the command returns the exit code 0.
+
+=head1 Options
+
+=over
+
+=item C<--replace-all>
+
+The default behavior is to replace at most one line. This replaces all lines
+matching the key (and optionally the C<value_regex>).
+
+=item C<--add>
+
+Adds a new line to the option without altering any existing values. This is
+the same as providing C<^$> as the value_regex in C<--replace-all>.
+
+=item C<--get>
+
+Get the value for a given key (optionally filtered by a regex matching the
+value). Returns error code 1 if the key was not found and error code 2 if
+multiple values were found.
+
+=item C<--get-all>
+
+Like C<--get>, but does not fail if the number of values for the key is not
+exactly one.
+
+=item C<--get-regexp>
+
+Like C<--get-all>, but interprets the name as a regular expression and writes
+out the key names and value.
+
+=item C<--local>
+
+For writing options: write to the local F<./sqitch.conf> file. This is
+the default if no file option is specified.
+
+For reading options: read only from the local F<./sqitch.conf> file rather
+than from all available files.
+
+See also L</Files>.
+
+=item C<--user>
+
+For writing options: write to the user F<~/.sqitch/sqitch.conf> file rather
+than the repository F<./sqitch.conf>.
+
+For reading options: read only from the user F<~/.sqitch/sqitch.conf> file
+rather than from all available files.
+
+See also L</Files>.
+
+=item C<--global>
+
+An alias for C<--user> for the benefit of the muscle memory of Git users.
+
+=item C<--system>
+
+For writing options: write to system-wide F<$(prefix)/etc/sqitch/sqitch.conf>
+file rather than the repository F<./sqitch.conf>.
+
+For reading options: read only from system-wide
+F<$(prefix)/etc/sqitch/sqitch.conf> file rather than from all available files.
+
+Call C<sqitch --etc-path> to find out exactly where the system configuration
+file lives (e.g., C<$(sqitch --etc-path)/sqitch.conf>).
+
+See also L</Files>.
+
+=item C<-f config-file, --file config-file>
+
+Use the given config file instead of the one specified by C<$SQITCH_CONFIG>.
+
+=item C<--remove-section>
+
+Remove the given section from the configuration file.
+
+=item C<--rename-section>
+
+Rename the given section to a new name.
+
+=item C<--unset>
+
+Remove the line matching the key from config file.
+
+=item C<--unset-all>
+
+Remove all lines matching the key from config file.
+
+=item C<-l, --list>
+
+List all variables set in config file.
+
+=item C<--bool>
+
+C<sqitch config> will ensure that the output is "true" or "false".
+
+=item C<--int>
+
+C<sqitch config> will ensure that the output is a simple integer.
+
+=item C<--num>
+
+C<sqitch config> will ensure that the output is a simple decimal number.
+
+=item C<--bool-or-int>
+
+C<sqitch config> will ensure that the output matches the format of either
+C<--bool> or C<--int>, as described above.
+
+=item C<-e, --edit>
+
+Opens an editor to modify the specified config file; either C<--local>,
+C<--user>, C<--system>, or C<--file>. If none of those options is specified,
+the local file will be opened.
+
+=back
+
+=head1 Files
+
+If not set explicitly with C<--file>, there are three files in which
+C<sqitch config> will search for configuration options:
+
+=over
+
+=item F<./sqitch.conf>
+
+Local, project-specific configuration file.
+
+=item F<~/.sqitch/sqitch.conf>
+
+User-specific configuration file.
+
+=item F<$(prefix)/etc/sqitch/sqitch.conf>
+
+System-wide configuration file.
+
+=back
+
+=head1 Environment
+
+=over
+
+=item C<SQITCH_CONFIG>
+
+Take the local configuration from the given file instead of F<./sqitch.conf>.
+
+=item C<SQITCH_USER_CONFIG>
+
+Take the user configuration from the given file instead of
+F<~/.sqitch/sqitch.conf>.
+
+=item C<SQITCH_SYSTEM_CONFIG>
+
+Take the system configuration from the given file instead of
+F<$($etc_prefix)/sqitch.conf>.
+
+=back
+
+=head1 Examples
+
+Given a F<./sqitch.conf> like this:
+
+ #
+ # This is the config file, and
+ # a '#' or ';' character indicates
+ # a comment
+ #
+
+ ; core variables
+ [core]
+ ; Use PostgreSQL
+ engine = pg
+
+ ; Bundle command settings.
+ [bundle]
+ from = gamma
+ tags_only = false
+ dest_dir = _build/sql
+
+ ; Fuzzle command settings
+ [core "fuzzle"]
+ clack = foo
+ clack = bar
+ clack = barzlewidth
+
+You can set the C<tags_only> setting to true with
+
+ % sqitch config bundle.tags_only true
+
+The hypothetical C<clack> key in the C<core.fuzzle> section might need to set
+C<foo> to "hi" instead of "foo". You can make the replacement by passing an
+additional argument to match the old value, which will be evaluated as a
+regular expression. Here's one way to make that change:
+
+ % sqitch config core.fuzzle.clack hi '^foo$'
+
+To delete the entry for C<bundle.from>, do
+
+ % sqitch config --unset bundle.from
+
+If you want to delete an entry for a multivalue setting (like
+C<core.fuzzle.clack>), provide a regex matching the value of exactly one line.
+This example deletes the "bar" value:
+
+ % sqitch config --unset core.fuzzle.clack '^bar$'
+
+To query the value for a given key, do:
+
+ % sqitch config --get core.engine
+
+Or:
+
+ % sqitch config core.engine
+
+Or, to query a multivalue setting for only those values that match C</ba/>:
+
+ % sqitch config --get core.fuzzle.clack ba
+
+If you want to know all the values for a multivalue setting, do:
+
+ % sqitch config --get-all core.fuzzle.clack
+
+If you like to live dangerously, you can replace all C<core.fuzzle.clack> with a
+new one with
+
+ % sqitch config --replace-all core.fuzzle.clack funk
+
+However, if you only want to replace lines that don't match C<bar>, prepend
+the matching regular expression with an exclamation point (C<!>), like so:
+
+ % sqitch config --replace-all core.fuzzle.clack yow '!bar'
+
+To match only values with an exclamation mark, you have to escape it:
+
+ % sqitch config section.key '[!]'
+
+To add a new setting without altering any of the existing ones, use:
+
+ % sqitch config --add core.fuzzle.set widget=fred
+
+=head1 Configuration File
+
+The sqitch configuration file contains a number of variables that affect the
+sqitch command's behavior. The F<./sqitch.conf> file local to each project is
+used to store the configuration for that project, and
+F<$HOME/.sqitch/sqitch.conf> is used to store a per-user configuration as
+fallback values for the F<./sqitch.conf> file. The file
+F<$($etc_prefix)/sqitch.conf> can be used to store a system-wide default
+configuration.
+
+The variables are divided into sections, wherein the fully qualified variable
+name of the variable itself is the last dot-separated segment and the section
+name is everything before the last dot. The variable names are
+case-insensitive, allow only alphanumeric characters and -, and must start
+with an alphabetic character. Some variables may appear multiple times.
+
+=head2 Syntax
+
+The syntax is fairly flexible and permissive; white space is mostly ignored.
+The C<#> and C<;> characters begin comments to the end of line, blank lines
+are ignored.
+
+The file consists of sections and variables. A section begins with the name of
+the section in square brackets and continues until the next section begins.
+Section names are not case sensitive. Only alphanumeric characters, C<-> and
+C<.> are allowed in section names. Each variable must belong to some section,
+which means that there must be a section header before the first setting of a
+variable.
+
+Sections can be further divided into subsections. To begin a subsection put
+its name in double quotes, separated by space from the section name, in the
+section header, like in the example below:
+
+ [section "subsection"]
+
+Subsection names are case sensitive and can contain any characters except
+newline (double quote and backslash have to be escaped as C<\"> and C<\\>,
+respectively). Section headers cannot span multiple lines. Variables may
+belong directly to a section or to a given subsection. You can have
+C<[section]> if you have C<[section "subsection"]>, but you don't need to.
+
+All the other lines (and the remainder of the line after the section header)
+are recognized as setting variables, in the form C<name = value>. If there is
+no equal sign on the line, the entire line is taken as name and the variable
+is recognized as boolean C<true>. The variable names are case-insensitive,
+allow only alphanumeric characters and C<->, and must start with an alphabetic
+character. There can be more than one value for a given variable; we say then
+that the variable is multivalued.
+
+Leading and trailing whitespace in a variable value is discarded. Internal
+whitespace within a variable value is retained verbatim.
+
+The values following the equals sign in variable assignments are either
+strings, integers, numbers, or booleans. Boolean values may be given as
+yes/no, 1/0, true/false or on/off. Case is not significant in boolean values,
+when converting value to the canonical form using the C<--bool> type
+specifier; C<sqitch config> will ensure that the output is "true" or "false".
+
+String values may be entirely or partially enclosed in double quotes. You need
+to enclose variable values in double quotes if you want to preserve leading or
+trailing whitespace, or if the variable value contains comment characters
+(i.e. it contains C<#> or C<;>). Double quote and backslash characters in
+variable values must be escaped: use C<\"> for C<"> and C<\\> for C<\>.
+
+The following escape sequences (beside C<\"> and C<\\>) are recognized: C<\n>
+for newline character (NL), C<\t> for horizontal tabulation (HT, TAB) and
+C<\b> for backspace (BS). No other character escape sequence or octal
+character sequence is valid.
+
+Variable values ending in a C<\> are continued on the next line in the
+customary UNIX fashion.
+
+Some variables may require a special value format.
+
+=head2 Example
+
+ # Core variables
+ [core]
+ engine = pg
+ top_dir = migrations
+ extension = ddl
+
+ [engine "pg"]
+ registry = widgetopolis
+
+ [revert]
+ to = gamma
+
+ [bundle]
+ from = gamma
+ tags_only = yes
+ dest_dir = _build/sql
+
+=head2 Variables
+
+Note that this list is not comprehensive and not necessarily complete. For
+command-specific variables, you will find a more detailed description in the
+appropriate manual page.
+
+=over
+
+=item C<core.plan_file>
+
+The plan file to use. Defaults to F<$top_dir/sqitch.plan>.
+
+=item C<core.engine>
+
+The database engine to use. Supported engines include:
+
+=over
+
+=item * C<pg> - L<PostgreSQL|https://postgresql.org/> and L<Postgres-XC|https://sourceforge.net/>
+
+=item * C<sqlite> - L<SQLite|https://sqlite.org/>
+
+=item * C<oracle> - L<Oracle|https://www.oracle.com/us/products/database/>
+
+=item * C<mysql> - L<MySQL|https://dev.mysql.com/> and L<MariaDB|https://mariadb.com/>
+
+=item * C<firebird> - L<Firebird|https://www.firebirdsql.org/>
+
+=item * C<vertica> - L<Vertica|https://my.vertica.com/>
+
+=item * C<exasol> - L<Exasol|https://www.exasol.com/>
+
+=item * C<snowflake> - L<Snowflake|https://www.snowflake.net/>
+
+=back
+
+=item C<core.top_dir>
+
+Path to directory containing deploy, revert, and verify SQL scripts. It
+should contain subdirectories named C<deploy>, C<revert>, and (optionally)
+C<verify>. These may be overridden by C<deploy_dir>, C<revert_dir>, and
+C<verify_dir>. Defaults to C<.>.
+
+=item C<core.deploy_dir>
+
+Path to a directory containing SQL deployment scripts. Overrides the value
+implied by C<core.top_dir>.
+
+=item C<core.revert_dir>
+
+Path to a directory containing SQL reversion scripts. Overrides the value
+implied by C<core.top_dir>.
+
+=item C<core.verify_dir>
+
+Path to a directory containing SQL verify scripts. Overrides the value implied
+by C<core.top_dir>.
+
+=item C<core.extension>
+
+The file name extension on deploy, revert, and verify SQL scripts. Defaults to
+C<sql>.
+
+=item C<core.verbosity>
+
+An integer determining how verbose Sqitch should be. Defaults to 1. Set to 0
+to silence status messages and to 2 or three to increase verbosity. Error
+message output will not be affected by this property.
+
+=item C<core.pager>
+
+The command to use as a pager program. This overrides the C<PAGER>
+environment variable on UNIX like systems. Both can be overridden by setting
+the C<$SQITCH_PAGER> environment variable. If none of these variables are
+set, Sqitch makes a best-effort search among the commonly installed pager
+programs like C<less> and C<more>.
+
+=item C<core.editor>
+
+The command to use as a editor program. This overrides the C<EDITOR>
+environment variable on UNIX like systems. Both can be overridden by setting
+the C<$SQITCH_EDITOR> environment variable. If none of these variables are
+set, Sqitch defaults to C<notepad.exe> on Windows and C<vi> elsewhere.
+
+=back
+
+=head3 C<user>
+
+Configuration properties that identify the user.
+
+=over
+
+=item C<user.name>
+
+Your full name, to be recorded in changes and tags added to the plan,
+and to commits to the database.
+
+=item C<user.email>
+
+Your email address, to be recorded in changes and tags added to the plan, and
+to commits to the database.
+
+=back
+
+=head3 C<engine.$engine>
+
+Each supported engine offers a set of configuration variables, falling under
+the key C<engine.$engine> where C<$engine> may be any value accepted for
+C<core.engine>.
+
+=over
+
+=item C<engine.$engine.target>
+
+A database target, either the name of target managed by the
+L<C<target>|sqitch-target> command, or a database connection URI. If it's a
+target name, then the associated C<uri>, C<registry>, and C<client> values
+will override any values specified for the values below. Targets are the
+preferred way to configure engines on a per-database basis, and the one
+specified here should be considered the default.
+
+=item C<engine.$engine.uri>
+
+A database connection URI.
+
+=item C<engine.$engine.registry>
+
+The name of the Sqitch registry schema or database. Sqitch will store its own
+data in this schema.
+
+=item C<engine.$engine.client>
+
+Path to the engine command-line client. Defaults to the first instance found
+in the path.
+
+=back
+
+Notes on engine-specific configuration:
+
+=over
+
+=item C<engine.pg.registry>
+
+For the PostgreSQL engine, the C<registry> value identifies the schema for
+Sqitch to use for its own data. No other data should be stored there. Defaults
+to C<sqitch>.
+
+=item C<engine.sqlite.registry>
+
+For the SQLite engine, if the C<registry> value looks like an absolute path,
+then it will be the database file. Otherwise, it will be in the same directory
+as the database specified by the C<uri>. Defaults to C<sqitch>.
+
+=item C<engine.mysql.registry>
+
+For the MySQL engine, the C<registry> value identifies the database for Sqitch
+to use for its own data. If you need to manage multiple databases on a single
+server, and don't want them all to share the same registry, change this
+property to a value specific for your database. Defaults to C<sqitch>.
+
+=item C<engine.oracle.registry>
+
+For Oracle, C<registry> value identifies the schema for Sqitch to use for its
+own data. No other data should be stored there. Uses the current schema by
+default (usually the same name as the connection user).
+
+=item C<engine.firebird.registry>
+
+For the Firebird engine, if the C<registry> value looks like an absolute path,
+then it will be the database file. Otherwise, it will be in the same directory
+as the database specified by the C<uri>. Defaults to C<sqitch.$extension>,
+where C<$extension> is the same as that in the C<uri>, if any.
+
+=item C<engine.vertica.registry>
+
+For the Vertica engine, the C<registry> value identifies the schema for Sqitch
+to use for its own data. No other data should be stored there. Defaults to
+C<sqitch>.
+
+=item C<engine.exasol.registry>
+
+For the Exasol engine, the C<registry> value identifies the schema for Sqitch
+to use for its own data. No other data should be stored there. Defaults to
+C<sqitch>.
+
+=item C<engine.snowflake.registry>
+
+For the Snowflake engine, the C<registry> value identifies the schema for
+Sqitch to use for its own data. No other data should be stored there. Defaults
+to C<sqitch>.
+
+=back
+
+=head3 C<core.vcs>
+
+Configuration properties for the version control system. Currently, only Git
+is supported.
+
+=over
+
+=item C<core.vcs.client>
+
+Path to the C<VCS> command-line client. Defaults to the first instance of
+F<git> found in the path.
+
+=back
+
+=head3 C<user>
+
+=over
+
+=item C<user.email>
+
+Your email address to be recorded in any newly planned changes.
+
+=item C<user.name>
+
+Your full name to be recorded in any newly planned changes.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-configuration.pod b/lib/sqitch-configuration.pod
new file mode 100644
index 00000000..10675cff
--- /dev/null
+++ b/lib/sqitch-configuration.pod
@@ -0,0 +1,1041 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitch-configuration - Hierarchical engine and target configuration
+
+=head1 Description
+
+The specification of database targets is core to Sqitch database change
+management. A target consists of a
+L<database connection URI|https://github.com/libwww-perl/uri-db>, a plan file,
+change script directories, a registry schema or database name, and the path to
+a database engine command-line client. Sqitch determines the values for these
+attributes via a hierarchical evaluation of the runtime configuration,
+examining and selecting from these values:
+
+=over
+
+=item 1.
+
+Command-line options
+
+=item 2.
+
+Target-specific configuration
+
+=item 3.
+
+Engine-specific configuration
+
+=item 4.
+
+Core configuration
+
+=item 5.
+
+A reasonable default
+
+=back
+
+This document explains how this evaluation works, and how to use the
+L<C<init>|sqitch-init>, L<C<config>|sqitch-config>,
+L<C<engine>|sqitch-engine>, and L<C<target>|sqitch-target> commands to
+configure these values for various deployment scenarios.
+
+=head1 Project Initialization
+
+Typically, the first thing you do with Sqitch is use the
+L<C<init>|sqitch-init> command to start a new project. Now, the most important
+thing Sqitch needs to know is what database engine you'll be managing, so it's
+best to use C<--engine> to configure the engine right up front to start off on
+the right foot. Here, we start a project called "widgets" to manage PostgreSQL
+databases:
+
+ > sqitch init widgets --engine pg
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+This creates a very simple configuration file with most of the settings
+commented out, like so:
+
+ > cat sqitch.conf
+ [core]
+ engine = pg
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "pg"]
+ # target = db:pg:
+ # registry = sqitch
+ # client = psql
+
+The C<[core]> section contains default configurations, the most important of
+which is the default engine, C<pg>. Of course, it's the I<only> engine this
+project supports, and the values of the other configuration variables are
+reasonable for a single-engine project. If your Sqitch project never needs to
+manage more than one database engine, this might be all you need: the current
+directory is the top directory of the project, and it's here you'll find the
+plan file as well as the deploy, revert, and verify script directories. Once
+you start using the L<C<add>|sqitch-add> command to add changes, and the
+L<C<deploy>|sqitch-deploy> command to deploy changes to a database, these
+variables will be used extensively.
+
+The C<[engine "pg"]> section houses the variables specific to the engine. The
+C<target> defines the default L<database URI|https://github.com/libwww-perl/uri-db>
+for connecting to a PostgreSQL database. As you can see there isn't much here,
+but if you were to distribute this project, it's likely that your users would
+specify a target URI when deploying to their own databases. The C<registry>
+determines where Sqitch will store its own metadata when managing a database;
+generally the default, "sqitch", is fine.
+
+More interesting, perhaps, is the C<client> setting, which defaults to the
+appropriate engine-specific client name appropriate for your OS. In this
+example, sqitch will assume it can find F<psql> in your path.
+
+=head1 Global Configuration
+
+But sometimes that's not the case. Let's say that the C<psql> client on your
+system is not in the path, but instead in F</usr/local/pgsql/bin/psql>. You
+could set its location right here in the project configuration file, but that
+won't do if you end up distributing the project to other users who might have
+their client somewhere else. For that use case, the default path-specific
+value is probably best.
+
+A better idea is to tell Sqitch where to find F<psql> for I<all> of your
+projects. Use the L<C<config>|sqitch-config> command's C<--user> option to set
+that configuration for yourself:
+
+ > sqitch config --user engine.pg.client /usr/local/pgsql/bin/psql
+
+This won't change the project configuration file at all, but add the value to
+F<~/.sqitch/sqitch.conf>, which is your personal cross-project Sqitch
+configuration. In other words, it sets the PostgreSQL client for all Sqitch
+projects you manage on this host. In fact, it can be a good idea to configure
+clients not in the path first thing whenever you start working on a new host:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+ > sqitch config --user engine.pg.client /usr/local/pgsql/bin/psql
+ > sqitch config --user engine.mysql.client /usr/local/mysql/bin/mysql
+ > sqitch config --user engine.sqlite.client /sbin/sqlite3
+
+If you'd like to make the configuration global to all accounts on your host,
+use the C<--system> option, instead:
+
+ > sudo sqitch config --system engine.pg.client /usr/local/pgsql/bin/psql
+ > sudo sqitch config --system engine.mysql.client /usr/local/mysql/bin/mysql
+ > sudo sqitch config --system engine.sqlite.client /sbin/sqlite3
+
+That will put the values into the global Sqitch configuration file, which is
+in C<`sqitch --etc-path`/sqitch.conf>.
+
+=head1 Engine Configuration
+
+So you've got the widgets project well developed, and now you've been asked to
+port it to SQLite. Fundamentally, that means porting all of your deploy,
+revert, and verify scripts. The simplest way to organize files for this
+configuration is with top-level directories for each engine. First, let's move
+the existing PostgreSQL stuff to a subdirectory.
+
+ > mkdir pg
+ > mv deploy revert verify sqitch.plan pg
+ > ls pg
+ deploy/ revert/ sqitch.plan verify/
+
+Now we need to tell Sqitch where things are. To create an engine-specific
+configuration, use the L<C<engine>|sqitch-engine> command's C<add> action:
+
+ sqitch engine add pg --top-dir pg
+
+The C<add> action adds the C<pg> engine to the configuration, setting the top
+directory to our newly-created C<pg> directory. The configuration looks like
+this (with comments removed for clarity):
+
+ [core]
+ engine = pg
+ [engine "pg"]
+ target = db:pg:
+ top_dir = pg
+
+Curious about all the other settings for the engine? Let C<sqitch engine show>
+show you:
+
+ > sqitch engine show pg
+ * pg
+ Target: db:pg:
+ Registry: sqitch
+ Client: psql
+ Top Directory: pg
+ Plan File: pg/sqitch.plan
+ Extension: sql
+ Script Directories:
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ Reworked Script Directories:
+ Reworked: pg
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ No Variables
+
+The C<show> action nicely presents the result of the fully-evaluated
+configuration, even though only the top directory and client have been set.
+Nice, right?
+
+Now, to add the SQLite support. There are two basic ways to go about it. We'll
+start with the more obvious one.
+
+=head2 Separate Plans
+
+The first approach is to create an entirely independent SQLite project with
+its own plan and scripts. This is I<almost> like starting from scratch: just
+create a new directory and add the Sqitch engine using it for its top
+directory: add initialize it as a new Sqitch project:
+
+ > sqitch engine add sqlite --top-dir sqlite
+ Created sqlite/
+ Created sqlite/sqitch.plan
+ Created sqlite/deploy/
+ Created sqlite/revert/
+ Created sqlite/verify/
+
+Note the creation of a new F<sqlite/sqitch.conf> file. It will have copied the
+project name and URI from the existing plan file. The SQLite configuration is
+now added to the configuration file:
+
+ > sqitch engine show sqlite
+ * sqlite
+ Target: db:sqlite:
+ Registry: sqitch
+ Client: sqlite3
+ Top Directory: sqlite
+ Plan File: sqlite/sqitch.plan
+ Extension: sql
+ Script Directories:
+ Deploy: sqlite/deploy
+ Revert: sqlite/revert
+ Verify: sqlite/verify
+ Reworked Script Directories:
+ Reworked: sqlite
+ Deploy: sqlite/deploy
+ Revert: sqlite/revert
+ Verify: sqlite/verify
+ No Variables
+
+Good, everything's in the right place. Start adding changes to the SQLite plan
+by passing the engine name to the C<add> command:
+
+ > sqitch add users sqlite -m 'Creates users table.'
+ Created sqlite/deploy/users.sql
+ Created sqlite/revert/users.sql
+ Created sqlite/verify/users.sql
+ Added "users" to sqlite/sqitch.plan
+
+Pass C<pg> when adding PostgreSQL changes, or omit it, in which case Sqitch
+will fall back on the default engine, defined by the C<core.engine> variable
+set when we created the PostgreSQL project. Want to add a change with the same
+name to both engines? Simply pass them both, or use the C<--all> option:
+
+ > sqitch add users --all -m 'Creates users table.'
+ Created pg/deploy/users.sql
+ Created pg/revert/users.sql
+ Created pg/test/users.sql
+ Created pg/verify/users.sql
+ Added "users" to pg/sqitch.plan
+ Created sqlite/deploy/users.sql
+ Created sqlite/revert/users.sql
+ Created sqlite/test/users.sql
+ Created sqlite/verify/users.sql
+ Added "users" to sqlite/sqitch.plan
+
+=head2 Shared Plan
+
+The other approach is to have both the PostgreSQL and the SQLite projects
+share the same plan. In that case, we should move the plan file out of the
+PostgreSQL directory:
+
+ > mv pg/sqitch.plan .
+ > sqitch engine alter pg --plan-file sqitch.plan
+ > sqitch engine show pg
+ * pg
+ Target: db:pg:
+ Registry: sqitch
+ Client: psql
+ Top Directory: pg
+ Plan File: sqitch.plan
+ Extension: sql
+ Script Directories:
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ Reworked Script Directories:
+ Reworked: pg
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ No Variables
+
+Good, it's now using F<./sqitch.plan>. Now let's start the SQLite project.
+Since we're going to use the same plan, we'll need to port all the scripts
+from PostgreSQL. Let's just copy them, and then configure the SQLite engine to
+use the shared plan file:
+
+ > cp -rf pg sqlite
+ > sqitch engine add sqlite --plan-file sqitch.plan --top-dir sqlite
+ > sqitch engine show sqlite
+ * sqlite
+ Target: db:sqlite:
+ Registry: sqitch
+ Client: sqlite3
+ Top Directory: sqlite
+ Plan File: sqitch.plan
+ Extension: sql
+ Script Directories:
+ Deploy: sqlite/deploy
+ Revert: sqlite/revert
+ Verify: sqlite/verify
+ Reworked Script Directories:
+ Reworked: sqlite
+ Deploy: sqlite/deploy
+ Revert: sqlite/revert
+ Verify: sqlite/verify
+ No Variables
+
+Looks good! Now port all the scripts in the F<sqlite> directory from
+PostgreSQL to SQLite and you're ready to go.
+
+Later, when you want to add a new change to both projects, just pass the
+C<--all> option to the C<add> command:
+
+ > sqitch add users --all -n 'Creates users table.'
+ Created pg/deploy/users.sql
+ Created pg/revert/users.sql
+ Created pg/verify/users.sql
+ Created sqlite/deploy/users.sql
+ Created sqlite/revert/users.sql
+ Created sqlite/verify/users.sql
+ Added "users" to sqitch.plan
+
+This option also works for the C<tag>, C<rework>, and C<bundle> commands. If
+you know you always want to act on all plans, set the C<all> configuration
+variable for each command:
+
+ sqitch config --bool add.all 1
+ sqitch config --bool tag.all 1
+ sqitch config --bool rework.all 1
+ sqitch config --bool bundle.all 1
+
+=head2 Database Interactions
+
+With either of these two approaches, you can manage database interactions by
+passing an engine name or a L<database URI|https://github.com/libwww-perl/uri-db>
+to the database commands. For example, to deploy to a PostgreSQL database to
+the default PostgreSQL database:
+
+ sqitch deploy pg
+
+You usually won't want to use the default database in production, though.
+Here's how to deploy to a PostgreSQL database named "widgets" on host
+C<db.example.com>:
+
+ sqitch deploy db:pg://db.example.com/widgets
+
+Sqitch is smart enough to pick out the proper engine from the URI. If you pass
+a C<db:pg:> URI, rest assured that Sqitch won't try to deploy the SQLite
+changes. Use a C<db:sqlite:> URI to interact with an SQLite database:
+
+ sqitch log db:sqlite:/var/db/widgets.db
+
+The commands that take engine and target URI arguments include:
+
+=over
+
+=item * L<C<status>|sqitch-status>
+
+=item * L<C<log>|sqitch-log>
+
+=item * L<C<deploy>|sqitch-deploy>
+
+=item * L<C<revert>|sqitch-revert>
+
+=item * L<C<rebase>|sqitch-rebase>
+
+=item * L<C<checkout>|sqitch-checkout>
+
+=item * L<C<verify>|sqitch-verify>
+
+=item * L<C<verify>|sqitch-upgrade>
+
+=back
+
+=head1 Target Configuration
+
+Great, now we can easily manage changes for multiple database engines. But
+what about multiple databases for the same engine? For example, you might want
+to deploy your database to two hosts in a primary/standby configuration. To
+make things as simple as possible for your IT organization, set up named
+targets for those servers:
+
+ > sqitch target add prod-primary db:pg://sqitch@db1.example.com/widgets
+ > sqitch target add prod-standby db:pg://sqitch@db2.example.com/widgets
+
+Targets inherit configuration from engines, based on the engine specified in
+the URI. Thus the configuration all comes together:
+
+ > sqitch target show prod-primary prod-standby
+ * prod-primary
+ URI: db:pg://sqitch@db1.example.com/widgets
+ Registry: sqitch
+ Client: psql
+ Top Directory: pg
+ Plan File: sqitch.plan
+ Extension: sql
+ Script Directories:
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ Reworked Script Directories:
+ Reworked: pg
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ No Variables
+ * prod-standby
+ URI: db:pg://sqitch@db2.example.com/widgets
+ Registry: sqitch
+ Client: psql
+ Top Directory: pg
+ Plan File: sqitch.plan
+ Extension: sql
+ Script Directories:
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ Reworked Script Directories:
+ Reworked: pg
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ No Variables
+
+Note the use of the shared plan and the F<pg> directory for scripts. We can
+add a target for our SQLite database, too. Maybe it's used for development?
+
+ > sqitch target add dev-sqlite db:sqlite:/var/db/widgets_dev.db
+ > sqitch target show dev-sqlite
+ * dev-sqlite
+ URI: db:sqlite:/var/db/widgets_dev.db
+ Registry: sqitch
+ Client: sqlite3
+ Top Directory: sqlite
+ Plan File: sqitch.plan
+ Extension: sql
+ Script Directories:
+ Deploy: sqlite/deploy
+ Revert: sqlite/revert
+ Verify: sqlite/verify
+ Reworked Script Directories:
+ Reworked: sqlite
+ Deploy: sqlite/deploy
+ Revert: sqlite/revert
+ Verify: sqlite/verify
+ No Variables
+
+Now deploying any of these databases is as simple as specifying the target
+name when executing the L<C<deploy>|sqitch-deploy> command (assuming the
+C<sqitch> user is configured to authenticate to PostgreSQL without prompting
+for a password):
+
+ > sqitch deploy prod-primary
+ > sqitch deploy prod-standby
+
+Want them all? Just query the targets and pass each in turn:
+
+ for target in `sqitch target | grep prod-`; do
+ sqitch deploy $target
+ done
+
+The commands that accept a target name are identical to those that take
+an engine name or target URI, as described in L</Database Interactions>.
+
+=head3 Different Target, Different Plan
+
+What about a project that manages different -- but related -- schemas on the
+same engine? For example, say you have two plans for PostgreSQL, one for a
+canonical data store, and one for a read-only copy that will have a subset of
+data replicated to it. Maybe your billing database just needs an up-to-date
+copy of the C<customers> and C<users> tables.
+
+Targets can help us here, too. Just create the new plan file. It might use
+some of the same change scripts as the canonical plan, or its own scripts, or
+some of each. Just be sure all of its scripts are in the same top directory.
+Then add targets for the specific servers and plans:
+
+ > sqitch target add prod-primary db:pg://db1.example.com/widgets
+ > sqitch target add prod-billing db:pg://cpa.example.com/billing --plan-file target.plan
+ > sqitch target show prod-billing
+ * prod-billing
+ URI: db:pg://cpa.example.com/billing
+ Registry: sqitch
+ Client: psql
+ Top Directory: pg
+ Plan File: target.plan
+ Extension: sql
+ Script Directories:
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ Reworked Script Directories:
+ Reworked: pg
+ Deploy: pg/deploy
+ Revert: pg/revert
+ Verify: pg/verify
+ No Variables
+
+Now, any management of the C<prod-billing> target will use the F<target.plan>
+plan file. Want to add changes to that plan? specify the plan file. Here's
+an example that re-uses the existing change scripts:
+
+ > sqitch add users target.plan -n 'Creates users table.'
+ Skipped pg/deploy/users.sql: already exists
+ Skipped pg/revert/users.sql: already exists
+ Skipped pg/test/users.sql: already exists
+ Skipped pg/verify/users.sql: already exists
+ Added "users" to target.plan
+
+=head1 Overworked
+
+Say you've been working on your project for some time, and now you have a slew
+of changes you've L<reworked|sqitch-rework>. (You really only do that with
+procedures and views, right? Because it's silly to use for C<ALTER>
+statements; just add new changes in those cases.) As a result, your deploy,
+revert, and verify directories are full of files representing older versions
+of the changes, all containing the C<@> symbol, and they're starting to get in
+the way (in general you'll never modify them). Here's an example adapted from
+a real project:
+
+ > find pg -name '*@*'
+ pg/deploy/extensions@v2.9.0.sql
+ pg/deploy/jobs/func_enabler@v2.6.1.sql
+ pg/deploy/stem/func_check_all_widgets@v2.11.0.sql
+ pg/deploy/stem/func_check_all_widgets@v2.12.2.sql
+ pg/deploy/stem/func_check_all_widgets@v2.12.3.sql
+ pg/deploy/crank/func_update_jobs@v2.12.0.sql
+ pg/deploy/crank/func_update_jobs@v2.8.0.sql
+ pg/deploy/utility/func_get_sleepercell@v2.9.0.sql
+ pg/deploy/utility/func_update_connection@v2.10.0.sql
+ pg/deploy/utility/func_update_connection@v2.10.1.sql
+ pg/deploy/utility/func_update_connection@v2.11.0.sql
+ pg/revert/extensions@v2.9.0.sql
+ pg/revert/jobs/func_enabler@v2.6.1.sql
+ pg/revert/stem/func_check_all_widgets@v2.11.0.sql
+ pg/revert/stem/func_check_all_widgets@v2.12.2.sql
+ pg/revert/stem/func_check_all_widgets@v2.12.3.sql
+ pg/revert/crank/func_update_jobs@v2.12.0.sql
+ pg/revert/crank/func_update_jobs@v2.8.0.sql
+ pg/revert/utility/func_get_sleepercell@v2.9.0.sql
+ pg/revert/utility/func_update_connection@v2.10.0.sql
+ pg/revert/utility/func_update_connection@v2.10.1.sql
+ pg/revert/utility/func_update_connection@v2.11.0.sql
+ pg/verify/extensions@v2.9.0.sql
+ pg/verify/jobs/func_enabler@v2.6.1.sql
+ pg/verify/stem/func_check_all_widgets@v2.11.0.sql
+ pg/verify/stem/func_check_all_widgets@v2.12.2.sql
+ pg/verify/stem/func_check_all_widgets@v2.12.3.sql
+ pg/verify/crank/func_update_jobs@v2.12.0.sql
+ pg/verify/crank/func_update_jobs@v2.8.0.sql
+ pg/verify/utility/func_get_sleepercell@v2.9.0.sql
+ pg/verify/utility/func_update_connection@v2.10.0.sql
+ pg/verify/utility/func_update_connection@v2.10.1.sql
+ pg/verify/utility/func_update_connection@v2.11.0.sql
+
+Ugh. Wouldn't it be nice to move them out of the way? Of course it would! So
+let's do that. We want all of the PostgreSQL engine's reworked scripts all to
+go into to a new directory named "reworked", so tell Sqitch where to find
+them:
+
+ > sqitch engine alter pg --dir reworked=pg/reworked
+ Created pg/reworked/deploy/
+ Created pg/reworked/revert/
+ Created pg/reworked/verify/
+
+Great, it created the new directories. Note that if you wanted the directories
+to have different names or locations, you can use the C<reworked_deploy>,
+C<reworked_revert>, and C<reworked_verify> options.
+
+Now all we have to do is move the files:
+
+ cd pg
+ for file in `find . -name '*@*'`
+ do
+ mkdir -p reworked/`dirname $file`
+ mv $file reworked/`dirname $file`
+ done
+ cd ..
+
+Now all the reworked deploy files are in F<pg/reworked/deploy>, the reworked
+revert files are in F<pg/reworked/revert>, and the reworked verify files are
+in F<pg/reworked/verify>. And you're good to go! From here on in Sqitch always
+knows to find the reworked scripts when doing a L<deploy|sqitch-deploy>,
+L<revert|sqitch-revert>, or L<bundle|sqitch-bundle>. And meanwhile, they're
+tucked out of the way, less likely to break your brain or your IDE.
+
+=head1 Other Options
+
+You can see by the output of the L<C<init>|sqitch-init>,
+L<C<engine>|sqitch-engine>, and L<C<target>|sqitch-target> commands that there
+are quite a few other properties that can be set on a per-engine or per-target
+database. To determine the value of each, Sqitch looks at a combination of
+command-line options and configuration variables. Here's a complete list,
+including specification of their values and how to set them.
+
+=over
+
+=item C<target>
+
+The target database. May be a L<database URI|https://github.com/libwww-perl/uri-db> or
+a named target managed by the L<C<target>|sqitch-target> commands. On each run,
+its value will be determined by examining each of the following in turn:
+
+=over
+
+=item Command target argument or option
+
+ sqitch deploy $target
+ sqitch revert --target $target
+
+=item C<$SQITCH_TARGET> environment variable
+
+ env SQITCH_TARGET=$target sqitch deploy
+ env SQITCH_TARGET=$target sqitch revert
+
+=item C<engine.$engine.target>
+
+ sqitch init $project --engine $engine --target $target
+ sqitch engine add $engine --target $target
+ sqitch engine alter $engine --target target
+
+=item C<core.target>
+
+ sqitch config core.target $target
+
+=back
+
+=item C<uri>
+
+The L<database URI|https://github.com/libwww-perl/uri-db> to which to connect. May
+only be specified as a target argument or via a named target:
+
+=over
+
+=item Command target argument or option
+
+ sqitch deploy $uri
+ sqitch revert --target $uri
+
+=item C<$SQITCH_TARGET> environment variable
+
+ env SQITCH_TARGET=$uri sqitch deploy
+ env SQITCH_TARGET=$uri sqitch revert
+
+=item C<target.$target.uri>
+
+ sqitch init $project --engine $engine --target $uri
+ sqitch target add $target --uri $uri
+ sqitch target alter $target --uri $uri
+
+=back
+
+=item C<client>
+
+The path to the engine client. The default is engine- and OS-specific, which
+will generally work for clients in the path. If you need a custom client, you
+can specify it via the following:
+
+=over
+
+=item C<--client>
+
+ sqitch deploy --client $client
+
+=item C<target.$target.client>
+
+ sqitch target add $target --client $client
+ sqitch target alter $target --client $client
+ sqitch config --user target.$target.client $client
+
+=item C<engine.$engine.client>
+
+ sqitch init $project --engine $engine --client client
+ sqitch engine add $engine --client $client
+ sqitch engine alter $engine --client $client
+ sqitch config --user engine.$engine.client $client
+
+=item C<core.client>
+
+ sqitch config core.client $client
+ sqitch config --user core.client $client
+
+=back
+
+=item C<registry>
+
+The name of the Sqitch registry schema or database. The default is C<sqitch>,
+which should work for most uses. If you need a custom registry, specify it via
+the following:
+
+=over
+
+=item C<--registry>
+
+ sqitch deploy --registry $registry
+
+=item C<target.$target.registry>
+
+ sqitch target add $target --registry $registry
+ sqitch target alter $target --registry $registry
+
+=item C<engine.$engine.registry>
+
+ sqitch init $project --engine $engine --registry $registry
+ sqitch engine add $engine --registry $registry
+ sqitch engine alter $engine --registry $registry
+
+=item C<core.registry>
+
+ sqitch config core.registry $registry
+
+=back
+
+=item C<top_dir>
+
+The directory in which project files an subdirectories can be found, including
+the plan file and script directories. The default is the current directory. If
+you need a custom directory, specify it via the following:
+
+=over
+
+=item C<target.$target.top_dir>
+
+ sqitch target add $target --top-dir $top_dir
+ sqitch target alter $target --top-dir $top_dir
+
+=item C<engine.$engine.top_dir>
+
+ sqitch engine add $engine --top-dir $top_dir
+ sqitch engine alter $engine --top-dir $top_dir
+
+=item C<core.top_dir>
+
+ sqitch init $project --top-dir $top_dir
+ sqitch config core.top_dir $top_dir
+
+=back
+
+=item C<plan_file>
+
+The project deployment plan file, which defaults to F<C<$top_dir/sqitch.plan>>.
+If you need a different file, specify it via the following:
+
+=over
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch $command --plan-file $plan_file
+
+=item C<target.$target.plan_file>
+
+ sqitch target add $target --plan-file $plan_file
+ sqitch target alter $target --plan-file $plan_file
+
+=item C<engine.$engine.plan_file>
+
+ sqitch engine add $engine --plan-file $plan_file
+ sqitch engine alter $engine --plan-file $plan_file
+
+=item C<core.plan_file>
+
+ sqitch init $project --plan-file $plan_file
+ sqitch config core.plan_file $plan_file
+
+=back
+
+=item C<extension>
+
+The file name extension to append to change names for change script file
+names. Defaults to C<sql>. If you need a custom extension, specify it via the
+following:
+
+=over
+
+=item C<target.$target.extension>
+
+ sqitch target add $target --extension $extension
+ sqitch target alter $target --extension $extension
+
+=item C<engine.$engine.extension>
+
+ sqitch engine add $engine --extension $extension
+ sqitch engine alter $engine --extension $extension
+
+=item C<core.extension>
+
+ sqitch init $project --extension $extension
+ sqitch config core.extension $extension
+
+=back
+
+=item C<variables>
+
+Database client variables. Useful if your database engine
+supports variables in scripts, such as PostgreSQL's
+L<C<psql> variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>,
+Vertica's
+L<C<vsql> variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>
+MySQL's
+L<user variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>,
+SQL*Plus's
+L<C<DEFINE> variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>,
+and Snowflake's
+L<SnowSQL variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>.
+To set variables, specify them via the following:
+
+=over
+
+=item Command variable option
+
+ sqitch deploy --set $key=$val -s $key2=$val2
+ sqitch revert --set $key=$val -s $key2=$val2
+ sqitch verify --set $key=$val -s $key2=$val2
+ sqitch rework --set $key=$val -s $key2=$val2
+ sqitch rework --set-deploy $key=$val --set-revert $key=$val
+ sqitch checkout --set $key=$val -s $key2=$val2
+ sqitch checkout --set-deploy $key=$val --set-revert $key=$val
+
+=item C<target.$target.variables>
+
+ sqitch target add $target --set $key=$val -s $key2=$val2
+ sqitch target alter $target --set $key=$val -s $key2=$val2
+
+=item C<engine.$engine.variables>
+
+ sqitch engine add $engine --set $key=$val -s $key2=$val2
+ sqitch engine alter $engine --set $key=$val -s $key2=$val2
+
+=item C<$command.variables>
+
+ sqitch config deploy.variables.$key $val
+ sqitch config revert.variables.$key $val
+ sqitch config verify.variables.$key $val
+
+=item C<core.variables>
+
+ sqitch init $project --set $key=$val -s $key2=$val2
+ sqitch config core.variables.$key $val
+ sqitch config core.variables.$key2 $val2
+
+=back
+
+=item C<deploy_dir>
+
+The directory in which project deploy scripts can be found. Defaults to
+F<C<$top_dir/deploy>>. If you need a different directory, specify it via the
+following:
+
+=over
+
+=item C<target.$target.deploy_dir>
+
+ sqitch target add $target --dir deploy=$deploy_dir
+ sqitch target alter $target --dir deploy=$deploy_dir
+
+=item C<engine.$engine.deploy_dir>
+
+ sqitch engine add $engine --dir deploy=$deploy_dir
+ sqitch engine alter --dir deploy=$deploy_dir
+
+=item C<core.deploy_dir>
+
+ sqitch init $project --dir deploy=$deploy_dir
+ sqitch config core.deploy_dir $deploy_dir
+
+=back
+
+=item C<revert_dir>
+
+=item F<C<$top_dir/deploy>>
+
+
+The directory in which project revert scripts can be found. Defaults to
+F<C<$top_dir/revert>>. If you need a different directory, specify it via the
+following:
+
+=over
+
+=item C<target.$target.revert_dir>
+
+ sqitch target add $target --dir revert=$revert_dir
+ sqitch target alter $target --dir revert=$revert_dir
+
+=item C<engine.$engine.revert_dir>
+
+ sqitch engine add $engine --dir revert=$revert_dir
+ sqitch engine alter --dir revert=$revert_dir
+
+=item C<core.revert_dir>
+
+ sqitch init $project --dir revert=$revert_dir
+ sqitch config core.revert_dir $revert_dir
+
+=back
+
+=item C<verify_dir>
+
+The directory in which project verify scripts can be found. Defaults to
+F<C<$top_dir/verify>>. If you need a different directory, specify it via the
+following:
+
+=over
+
+=item C<target.$target.verify_dir>
+
+ sqitch target add $target --dir verify=$verify_dir
+ sqitch target alter $target --dir verify=$verify_dir
+
+=item C<engine.$engine.verify_dir>
+
+ sqitch engine add $engine --dir verify=$verify_dir
+ sqitch engine alter $engine --dir verify=$verify_dir
+
+=item C<core.verify_dir>
+
+ sqitch init $project --dir verify=$verify_dir
+ sqitch config core.verify_dir $verify_dir
+
+=back
+
+=item C<reworked_dir>
+
+The directory in which subdirectories for reworked scripts can be found.
+Defaults to F<C<$top_dir>>. If you need a different directory, specify it via
+the following:
+
+=over
+
+=item C<target.$target.reworked_dir>
+
+ sqitch target add $target --dir reworked=$reworked_dir
+ sqitch target alter $target --dir reworked=$reworked_dir
+
+=item C<engine.$engine.reworked_dir>
+
+ sqitch engine add $engine --dir reworked=$reworked_dir
+ sqitch engine alter $engine --dir reworked=$reworked_dir
+
+=item C<core.reworked_dir>
+
+ sqitch init $project --dir reworked=$reworked_dir
+ sqitch config core.reworked_dir $reworked_dir
+
+=back
+
+=item C<reworked_deploy_dir>
+
+The directory in which project deploy scripts can be found. Defaults to
+F<C<reworked_dir/deploy>>. If you need a different directory, specify it via the
+following:
+
+=over
+
+=item C<target.$target.reworked_deploy_dir>
+
+ sqitch target add $target --dir deploy=$reworked_deploy_dir
+ sqitch target alter $target --dir deploy=$reworked_deploy_dir
+
+=item C<engine.$engine.reworked_deploy_dir>
+
+ sqitch engine add $engine --dir deploy=$reworked_deploy_dir
+ sqitch engine alter --dir deploy=$reworked_deploy_dir
+
+=item C<core.reworked_deploy_dir>
+
+ sqitch init $project --dir deploy=$reworked_deploy_dir
+ sqitch config core.reworked_deploy_dir $reworked_deploy_dir
+
+=back
+
+=item C<reworked_revert_dir>
+
+The directory in which project revert scripts can be found. Defaults to
+F<C<reworked_dir/revert>>. If you need a different directory, specify it via the
+following:
+
+=over
+
+=item C<target.$target.reworked_revert_dir>
+
+ sqitch target add $target --dir revert=$reworked_revert_dir
+ sqitch target alter $target --dir revert=$reworked_revert_dir
+
+=item C<engine.$engine.reworked_revert_dir>
+
+ sqitch engine add $engine --dir revert=$reworked_revert_dir
+ sqitch engine alter --dir revert=$reworked_revert_dir
+
+=item C<core.reworked_revert_dir>
+
+ sqitch init $project --dir revert=$reworked_revert_dir
+ sqitch config core.reworked_revert_dir $reworked_revert_dir
+
+=back
+
+=item C<reworked_verify_dir>
+
+The directory in which project verify scripts can be found. Defaults to
+F<C<reworked_dir/verify>>. If you need a different directory, specify it via the
+following:
+
+=over
+
+=item C<target.$target.reworked_verify_dir>
+
+ sqitch target add $target --dir verify=$reworked_verify_dir
+ sqitch target alter $target --dir verify=$reworked_verify_dir
+
+=item C<engine.$engine.reworked_verify_dir>
+
+ sqitch engine add $engine --dir verify=$reworked_verify_dir
+ sqitch engine alter $engine --dir verify=$reworked_verify_dir
+
+=item C<core.reworked_verify_dir>
+
+ sqitch init $project --dir verify=$reworked_verify_dir
+ sqitch config core.reworked_verify_dir $reworked_verify_dir
+
+=back
+
+=back
+
+=head1 See Also
+
+=over
+
+=item * L<sqitch-init>
+
+=item * L<sqitch-target>
+
+=item * L<sqitch-engine>
+
+=item * L<sqitch-config>
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-deploy-usage.pod b/lib/sqitch-deploy-usage.pod
new file mode 100644
index 00000000..50c11a38
--- /dev/null
+++ b/lib/sqitch-deploy-usage.pod
@@ -0,0 +1,24 @@
+=head1 Name
+
+sqitch-deploy-usage - Sqitch deploy usage statement
+
+=head1 Usage
+
+ sqitch deploy [options] [<database>]
+
+=head1 Options
+
+ -t --target <target> database to which to connect
+ --to-change <change> deploy to change
+ --mode <mode> failure reversion mode
+ -s --set <key=value> set a database client variable
+ --verify run verify scripts after each change
+ --no-verify do not run verify scripts
+ --log-only log changes without running them
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-deploy.pod b/lib/sqitch-deploy.pod
new file mode 100644
index 00000000..2051eb43
--- /dev/null
+++ b/lib/sqitch-deploy.pod
@@ -0,0 +1,223 @@
+=head1 Name
+
+sqitch-deploy - Deploy changes to a database
+
+=head1 Synopsis
+
+ sqitch deploy [options] [<database>]
+ sqitch deploy [options] [<database>] <change>
+ sqitch deploy [options] [<database>] --to-change <change>
+
+=head1 Description
+
+Deploy changes to the database. Changes will begin from the current deployment
+state. They will run to the latest change, unless a change is specified,
+either via C<--to> or with no option flag, in which case changes will be
+deployed up-to and including that change.
+
+If the database it up-to-date or already deployed to the specified change, no
+changes will be made. If the change appears earlier in the plan than the
+currently-deployed state, an error will be returned, along with a suggestion
+to instead use L<sqitch-revert>.
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--to-change>
+
+=item C<--change>
+
+=item C<--to>
+
+Specify the deployment change. Defaults to the last point in the plan. See
+L<sqitchchanges> for the various ways in which changes can be specified.
+
+=item C<--mode>
+
+Specify the reversion mode to use in case of deploy or verify failure.
+Possible values are:
+
+=over
+
+=item C<all>
+
+In the event of failure, revert all deployed changes, back to the point at
+which deployment started. This is the default.
+
+=item C<tag>
+
+In the event of failure, revert all deployed changes to the last
+successfully-applied tag. If no tags were applied during this deployment, all
+changes will be reverted to the point at which deployment began.
+
+=item C<change>
+
+In the event of deploy failure, no changes will be reverted; for a verify
+failure, only the failed change will be reverted.
+
+=back
+
+Note that Sqitch assumes that each change is atomic. Therefore, when a deploy
+script fails, it assumes there is nothing to revert. If a verify script fails,
+it will of course run the revert script for the failed change.
+
+=item C<--verify>
+
+Verify each change by running its verify script, if there is one. If a verify
+test fails, the deploy will be considered to have failed and the appropriate
+reversion will be carried out, depending on the value of C<--mode>.
+
+=item C<--no-verify>
+
+Don't verify each change. This is the default.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values loaded from
+L</configuration Variables>.
+
+=item C<--log-only>
+
+Log the changes as if they were deployed, but without actually running the
+deploy scripts. Useful for an existing database that is being converted to
+Sqitch, and you need to log changes as deployed because they have been
+deployed by other means in the past.
+
+=item C<--registry>
+
+ sqitch deploy --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch deploy --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch deploy --db-name widgets
+ sqitch deploy -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch deploy --db-username root
+ sqitch deploy --db-user postgres
+ sqitch deploy -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch deploy --db-host db.example.com
+ sqitch deploy -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch deploy --db-port 7654
+ sqitch deploy -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch deploy --plan-file my.plan
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<deploy.mode>
+
+Deploy mode. The supported values are the same as for the C<--mode> option.
+
+=item C<deploy.verify>
+
+Boolean indicating whether or not to verify each change.
+
+=item C<[deploy.variables]>
+
+A section defining database client variables. Useful if your database engine
+supports variables in scripts, such as PostgreSQL's
+L<C<psql> variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>,
+Vertica's
+L<C<vsql> variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>
+MySQL's
+L<user variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>,
+SQL*Plus's
+L<C<DEFINE> variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>,
+and Snowflake's
+L<SnowSQL variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>.
+
+May be overridden by C<--set> or target and engine configuration. Variables
+are merged in the following priority order:
+
+=over
+
+=item C<--set>
+
+=item C<target.$target.variables>
+
+=item C<engine.$engine.variables>
+
+=item C<deploy.variables>
+
+=item C<core.variables>
+
+=back
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-engine-usage.pod b/lib/sqitch-engine-usage.pod
new file mode 100644
index 00000000..78b7af11
--- /dev/null
+++ b/lib/sqitch-engine-usage.pod
@@ -0,0 +1,25 @@
+=head1 Name
+
+sqitch-engine-usage - Sqitch engine usage statement
+
+=head1 Usage
+
+ sqitch engine
+ sqitch engine [-v | --verbose]
+ sqitch engine add <name> [engine-options]
+ sqitch engine alter <name> [engine-options]
+ sqitch engine remove <name>
+ sqitch engine show <name>
+ sqitch engine update-config
+
+=head1 Options
+
+ -v, --verbose be verbose; must be placed before an action
+ --target <target> database target
+ --registry <registry> registry schema or database
+ --client <path> path to engine command-line client
+ -f --plan-file <file> path to deployment plan file
+ --top-dir <dir> path to directory with plan and scripts
+ --extension <ext> change script file name extension
+ --dir <name>=<path> path to named directory
+ -s --set <key=value> set a database client variable
diff --git a/lib/sqitch-engine.pod b/lib/sqitch-engine.pod
new file mode 100644
index 00000000..acda9e14
--- /dev/null
+++ b/lib/sqitch-engine.pod
@@ -0,0 +1,267 @@
+=head1 Name
+
+sqitch-engine - Manage database engine configuration
+
+=head1 Synopsis
+
+ sqitch engine
+ sqitch engine [-v | --verbose]
+ sqitch engine add <name> [engine-options]
+ sqitch engine alter <name> [engine-options]
+ sqitch engine remove <name>
+ sqitch engine show <name>
+ sqitch engine update-config
+
+=head1 Description
+
+Manage the database engines you deploy to. The list of supported engines
+includes:
+
+=over
+
+=item * C<firebird>
+
+=item * C<mysql>
+
+=item * C<oracle>
+
+=item * C<pg>
+
+=item * C<sqlite>
+
+=item * C<vertica>
+
+=item * C<exasol>
+
+=item * C<snowflake>
+
+=back
+
+Each engine may have a number of properties:
+
+=over
+
+=item C<target>
+
+The name or URI of the database target. Note that if the value is a URI, the
+engine in the URI must match the engine being added or altered. The default is
+C<db:$engine>. See L<sqitch-target> for details on target configuration.
+
+=item C<registry>
+
+The name of the registry schema or database. The default is C<sqitch>.
+
+=item C<client>
+
+The command-line client to use. If not specified, each engine looks in the OS
+Path for an appropriate client.
+
+=item C<top_dir>
+
+The path to the top directory for the engine. This directory generally
+contains the plan file and subdirectories for deploy, revert, and verify
+scripts, as well as reworked instances of those scripts. The default is F<.>,
+the current directory.
+
+=item C<plan_file>
+
+The plan file to use for this engine. The default is C<$top_dir/sqitch.plan>.
+
+=item C<deploy_dir>
+
+The path to the deploy directory for the engine. This directory contains all
+of the deploy scripts referenced by changes in the C<plan_file>. The default
+is C<$top_dir/deploy>.
+
+=item C<revert_dir>
+
+The path to the revert directory for the engine. This directory contains all
+of the revert scripts referenced by changes in the C<plan_file>. The default
+is C<$top_dir/revert>.
+
+=item C<verify_dir>
+
+The path to the verify directory for the engine. This directory contains all
+of the verify scripts referenced by changes in the C<plan_file>. The default
+is C<$top_dir/verify>.
+
+=item C<reworked_dir>
+
+The path to the reworked directory for the engine. This directory contains all
+subdirectories for all reworked scripts referenced by changes in the
+C<plan_file>. The default is C<$top_dir>.
+
+=item C<reworked_deploy_dir>
+
+The path to the reworked deploy directory for the engine. This directory
+contains all of the reworked deploy scripts referenced by changes in the
+C<plan_file>. The default is C<$reworked_dir/deploy>.
+
+=item C<reworked_revert_dir>
+
+The path to the reworked revert directory for the engine. This directory
+contains all of the reworked revert scripts referenced by changes in the
+C<plan_file>. The default is C<$reworked_dir/revert>.
+
+=item C<reworked_verify_dir>
+
+The path to the reworked verify directory for the engine. This directory
+contains all of the reworked verify scripts referenced by changes in the
+C<plan_file>. The default is C<$reworked_dir/verify>.
+
+=item C<extension>
+
+The file name extension to append to change names to create script file names.
+The default is C<sql>.
+
+=back
+
+Each of these overrides the corresponding core configuration -- for example,
+the C<core.target>, C<core.plan_file>, C<core.registry>, and C<core.client>
+L<config|sqitch-config> options.
+
+=head1 Options
+
+=over
+
+=item List Option
+
+=over
+
+=item C<-v>
+
+=item C<--verbose>
+
+ sqitch engine --verbose
+
+Be more verbose when listing engines.
+
+=back
+
+=item Add and Alter Options
+
+=over
+
+=item C<--top-dir>
+
+ sqitch engine add pg --top-dir sql
+
+Specifies the top directory to use for the engine. Typically contains the
+deployment plan file and the change script directories.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch engine add pg --plan-file my.plan
+
+Specifies the path to the deployment plan file. Defaults to
+C<$top_dir/sqitch.plan>.
+
+=item C<--extension>
+
+ sqitch engine add pg --extension ddl
+
+Specifies the file name extension to use for change script file names.
+Defaults to C<sql>.
+
+=item C<--dir>
+
+ sqitch engine add pg --dir deploy=dep --dir revert=rev --dir verify=tst
+
+Sets the path to a script directory. May be specified multiple times.
+Supported keys are:
+
+=over
+
+=item * C<deploy>
+
+=item * C<revert>
+
+=item * C<verify>
+
+=item * C<reworked>
+
+=item * C<reworked_deploy>
+
+=item * C<reworked_revert>
+
+=item * C<reworked_verify>
+
+=back
+
+=item C<--target>
+
+ sqitch engine add pg --target db:pg:widgets
+
+Specifies the name or L<URI|https://github.com/libwww-perl/uri-db/> of the target
+database for the engine.
+
+=item C<--registry>
+
+ sqitch engine add pg --registry meta
+
+Specifies the name of the database object where Sqitch's state and history
+data is stored. Typically a schema name (as in PostgreSQL and Oracle) or a
+database name (as in SQLite and MySQL). Defaults to C<sqitch>.
+
+=item C<--client>
+
+ sqitch engine add pg --client /usr/local/pgsql/bin/psql
+
+Specifies the path to the command-line client for the engine. Defaults to a
+client in the current path named appropriately for the engine.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>.
+
+=back
+
+=back
+
+=head1 Actions
+
+With no arguments, shows a list of existing engines. Several actions are
+available to perform operations on the engines.
+
+=head2 C<add>
+
+Add an engine named C<< <name> >> for the database at C<< <uri> >>. The
+C<--set> option specifies engine-specific properties. A new plan file and
+new script script directories will be created if they don't already exist.
+
+=head2 C<alter>
+
+Alter an engine named C<< <name> >>. The C<--set> option specifies
+engine-specific properties to set. New script script directories will be
+created if they don't already exist.
+
+=head2 C<remove>, C<rm>
+
+Remove the engine named C<< <name> >> from the configuration. The plan file
+and script directories will not be affected.
+
+=head2 C<show>
+
+Gives some information about the engine C<< <name> >>, including the
+associated properties. Specify multiple engine names to see information for
+each.
+
+=head2 C<update-config>
+
+Update the configuration from a configuration file that predates the addition
+of the C<engine> command to Sqitch.
+
+=head1 Configuration Variables
+
+The engines are stored in the configuration file, but the command itself
+currently relies on no configuration variables.
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-environment.pod b/lib/sqitch-environment.pod
new file mode 100644
index 00000000..58c14e83
--- /dev/null
+++ b/lib/sqitch-environment.pod
@@ -0,0 +1,343 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitch-environment - Environment variables recognized by Sqitch
+
+=head1 Description
+
+Sqitch supports a number of environment variables that affect its
+functionality. This document lists them all, along with brief descriptions of
+their purposes and pointers to relevant documentation.
+
+=head2 Sqitch Environment
+
+=over
+
+=item C<SQITCH_CONFIG>
+
+Path to the project configuration file. Overrides the default, which is
+F<./sqitch.conf>. See L<sqitch-config> for details.
+
+=item C<SQITCH_USER_CONFIG>
+
+Path to the user's configuration file. Overrides the default, which is
+F<./.sqitch/sqitch.conf>. See L<sqitch-config> for details.
+
+=item C<SQITCH_SYSTEM_CONFIG>
+
+Path to the system's configuration file. Overrides the default, which is a
+file named C<sqitch.conf> in the directory identified by C<sqitch --etc>. See
+L<sqitch-config> for details.
+
+=item C<SQITCH_TARGET>
+
+The name or URI of the database target to connect to. Overrides values stored
+in the configuration, but not command-line options or arguments.
+
+=item C<SQITCH_USERNAME>
+
+Username to use when connecting to a database, for those database engines that
+support authentication. Overrides values stored in a target URI or the
+configuration. See L<sqitch-authentication> for details.
+
+=item C<SQITCH_PASSWORD>
+
+Password to use when connecting to a database, for those database engines that
+support authentication. Overrides values stored in a target URI or the
+configuration. See L<sqitch-authentication> for details.
+
+=item C<SQITCH_FULLNAME>
+
+Full name of the current user. Used to identify the user adding a change to a
+plan file or deploying a change. Supersedes the <user.name> L<sqitch-config>
+variable.
+
+=item C<SQITCH_EMAIL>
+
+Email address of the current user. Used to identify the user adding a change to
+a plan file or deploying a change. Supersedes the C<user.email> L<sqitch-config>
+variable.
+
+=item C<SQITCH_ORIG_SYSUSER>
+
+Username from the original system. Intended for use by scripts that run Sqitch
+from another host, where the originating host username should be passed to the
+execution host, such as
+L<this Docker script|https://github.com/sqitchers/docker-sqitch/blob/master/docker-sqitch.sh>.
+
+=item C<SQITCH_ORIG_FULLNAME>
+
+Full name of the original system user. Intended for use by scripts that run
+Sqitch from another host, where the originating host user's identity should be
+passed to the execution host, such as
+L<this Docker script|https://github.com/sqitchers/docker-sqitch/blob/master/docker-sqitch.sh>.
+This value will be used only when neither the C<$SQITCH_FULLNAME> nor the
+C<user.name> L<sqitch-config> variable is set.
+
+=item C<SQITCH_ORIG_EMAIL>
+
+Email address of the original user. Intended for use by scripts that run
+Sqitch on a separate host, where the originating host user's identity should
+be passed to the execution host, such as
+L<this Docker script|https://github.com/sqitchers/docker-sqitch/blob/master/docker-sqitch.sh>.
+This value will be used only when neither the C<$SQITCH_EMAIL> nor the
+C<user.email> L<sqitch-config> variable is set.
+
+=item C<SQITCH_EDITOR>
+
+The editor that Sqitch will launch when the user needs to edit some text (a
+change note, for example). If unset, the C<core.editor> configuration variable
+will be used. If it's not set, C<$VISUAL> or C<$EDITOR> will be consulted (in
+that order). Finally, if none of these are set, Sqitch will invoke
+C<notepad.exe> on Windows and C<vi> elsewhere.
+
+=item C<SQITCH_PAGER>
+
+The pager program that Sqitch will use when a command (like C<sqitch log>)
+produces multi-page output. If unset, the C<core.pager> configuration
+variable will be used. If this is also not set, the C<PAGER> environment
+variable will be used. Finally, if none of these are set, Sqitch will attempt
+to find and use one of the commonly used pager programs like C<less> and
+C<more>.
+
+=back
+
+=head2 Engine Environments
+
+In addition to Sqitch's environment variables, some of the database engines
+support environment variables of their own. These are not comprehensive for
+all variables supported by a database engine, but document those supported by
+Sqitch's implementation for each engine.
+
+=head3 PostgreSQL
+
+All the usual
+L<PostgreSQL environment variables|https://www.postgresql.org/docs/current/static/libpq-envars.html>
+should be implicitly used. However, the following variables are explicitly
+recognized by Sqitch:
+
+=over
+
+=item C<PGUSER>
+
+The username to use to connect to the server. Superseded by
+C<$SQITCH_USERNAME> and the target URI username.
+
+=item C<PGPASSWORD>
+
+The password to use to connect to the server. Superseded by
+C<$SQITCH_PASSWORD> and the target URI password.
+
+=item C<PGHOST>
+
+The PostgreSQL server host to connect to. Superseded by the target URI host
+name.
+
+=item C<PGPORT>
+
+The PostgreSQL server port to connect to. Superseded by the target URI port.
+
+=item C<PGDATABASE>
+
+The name of the database to connect to. Superseded by the target URI database
+name.
+
+=back
+
+=head3 SQLite
+
+SQLite provides no environment variable support.
+
+=head3 MySQL
+
+Sqitch recognizes and takes advantage of the following
+L<MySQL environment variables|https://dev.mysql.com/doc/refman/5.7/en/environment-variables.html>:
+
+=over
+
+=item C<MYSQL_PWD>
+
+The password to use to connect to the server. Superseded by
+C<$SQITCH_PASSWORD> and the target URI password.
+
+=item C<MYSQL_HOST>
+
+The MySQL server host to connect to. Superseded by the target URI host
+name.
+
+=item C<MYSQL_TCP_PORT>
+
+The MySQL server port to connect to. Superseded by the target URI port.
+
+=back
+
+=head3 Oracle
+
+Sqitch's Oracle engine supports a few environment variables:
+
+=over
+
+=item C<ORACLE_HOME>
+
+Required to point to the Oracle home directory, and contain both the SQL*Plus
+client and the shared libraries with which the Perl Oracle driver was
+compiled.
+
+=item C<TNS_ADMIN>
+
+The directory in which the Oracle networking interface will find its configuration
+files, notably F<tnsnames.ora>. Defaults to C<$ORACLE HOME/network/admin> if not
+set.
+
+=item C<TWO_TASK>
+
+The name of the Oracle database to connect to. Superseded by the target URI.
+
+=item C<LOCAL>
+
+The name of the Oracle database to connect to. Windows only. Superseded by the
+target URI.
+
+=item C<ORACLE_SID>
+
+The System Identifier (SID) representing the Oracle database to connect to.
+Superseded by the target URI, C<TWO_TASK> and C<LOCAL> on Windows.
+
+=back
+
+In addition, the Oracle engine in Sqitch explicitly overrides the C<NLS_LANG>
+and C<SQLPATH> environment variables. The former is set to
+C<AMERICAN_AMERICA.AL32UTF8> to ensure that all database connections use the
+UTF-8 encoding. The latter is set to an empty string, to prevent SQL*Plus
+executing SQL scripts unexpectedly.
+
+=head3 Firebird
+
+The Sqitch Firebird engine supports the following environment variables:
+
+=over
+
+=item C<ISC_USER>
+
+The username to use to connect to Firebird. Superseded by
+C<$SQITCH_USERNAME> and the target URI username.
+
+=item C<ISC_PASSWORD>
+
+The password to use to connect to Firebird. Superseded by C<$SQITCH_PASSWORD>
+and the target URI password.
+
+=back
+
+=head3 Vertica
+
+Sqitch provides explicit support for the following
+L<Vertica environment variables|https://www.vertica.com/docs/8.1.x/HTML/index.htm#Authoring/ConnectingToVertica/vsql/vsqlEnvironmentVariables.htm>:
+
+=over
+
+=item C<VSQL_USER>
+
+The username to use to connect to the server. Superseded by
+C<$SQITCH_USERNAME> and the target URI username.
+
+=item C<VSQL_PASSWORD>
+
+The password to use to connect to the server. Superseded by
+C<$SQITCH_PASSWORD> and the target URI password.
+
+=item C<VSQL_HOST>
+
+The PostgreSQL server host to connect to. Superseded by the target URI host
+name.
+
+=item C<VSQL_PORT>
+
+The PostgreSQL server port to connect to. Superseded by the target URI port.
+
+=item C<VSQL_DATABASE>
+
+The name of the database to connect to. Superseded by the target URI database
+name.
+
+=back
+
+=head3 Exasol
+
+The Sqitch Exasol engine supports no special environment variables. It does,
+however, override THE C<SQLPATH> environment variable, to prevent EXAplus
+executing SQL scripts unexpectedly.
+
+=head3 Snowflake
+
+Sqitch provides explicit support for the following
+L<Snowflake environment variables|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#connection-syntax>:
+
+=over
+
+=item C<SNOWSQL_ACCOUNT>
+
+The name assigned to the snowflake account. Superseded by the target URI host
+name.
+
+=item C<SNOWSQL_USER>
+
+The username to use to connect to the server. Superseded by
+C<$SQITCH_USERNAME> and the target URI username.
+
+=item C<SNOWSQL_PWD>
+
+The password to use to connect to the server. Superseded by
+C<$SQITCH_PASSWORD> and the target URI password.
+
+=item C<SNOWSQL_PRIVATE_KEY_PASSPHRASE>
+
+The passphrase for the private key file when using key pair authentication.
+See L<sqitch-authentication> for details.
+
+=item C<SNOWSQL_ROLE>
+
+The role to use when connecting to the server. Superseded by the target URI
+database C<role> query parameter.
+
+=item C<SNOWSQL_HOST>
+
+The PostgreSQL server host to connect to. Superseded by the target URI host
+name.
+
+=item C<SNOWSQL_PORT>
+
+The PostgreSQL server port to connect to. Superseded by the target URI port.
+
+=item C<SNOWSQL_DATABASE>
+
+The name of the database to connect to. Superseded by the target URI database
+name.
+
+=item C<SNOWSQL_REGION>
+
+The snowflake region. Superseded by the target URI host name.
+
+=item C<SNOWSQL_WAREHOUSE>
+
+The warehouse to use. Superseded by the target URI database C<warehouse> query
+parameter.
+
+=back
+
+=head1 See Also
+
+=over
+
+=item * L<sqitch-configuration>
+
+=item * L<sqitch-config>
+
+=item * L<sqitch-authentication>
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-help-usage.pod b/lib/sqitch-help-usage.pod
new file mode 100644
index 00000000..05f9b3df
--- /dev/null
+++ b/lib/sqitch-help-usage.pod
@@ -0,0 +1,12 @@
+=head1 Name
+
+sqitch-help-usage - Sqitch help usage statement
+
+=head1 Usage
+
+ sqitch help [--guide] [<command>] [<guide>]
+
+=head1 Options
+
+ -g --guide Print list of guides
+
diff --git a/lib/sqitch-help.pod b/lib/sqitch-help.pod
new file mode 100644
index 00000000..20556399
--- /dev/null
+++ b/lib/sqitch-help.pod
@@ -0,0 +1,35 @@
+=head1 Name
+
+sqitch-help - Display help for Sqitch and Sqitch commands
+
+=head1 Synopsis
+
+ sqitch help [COMMAND]
+ sqitch help --guide
+
+=head1 Description
+
+With no options and no C<COMMAND> given, the synopsis of the C<sqitch> options
+and a list of the most commonly used Sqitch commands will be displayed.
+
+If the option C<--guide> or C<-g> is given, then a list of Sqitch guides will
+be displayed
+
+If a Sqitch command is named, the documentation for that command will be
+displayed.
+
+=head1 Options
+
+=over
+
+=item C<-g>
+
+=item C<--guide>
+
+Prints a list of available Sqitch guides.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-init-usage.pod b/lib/sqitch-init-usage.pod
new file mode 100644
index 00000000..3d39fa4e
--- /dev/null
+++ b/lib/sqitch-init-usage.pod
@@ -0,0 +1,21 @@
+=head1 Name
+
+sqitch-init-usage - Sqitch init usage statement
+
+=head1 Usage
+
+ sqitch init <project>
+ sqitch init <project> --uri <uri>
+
+=head1 Options
+
+ --uri <uri> associate a URI with the project plan
+ --engine <engine> database engine
+ --top-dir <dir> path to directory with plan and scripts
+ -f --plan-file <file> path to deployment plan file
+ --target <target> database target
+ --registry <registry> registry schema or database
+ --client <path> path to engine command-line client
+ --extension <ext> change script file name extension
+ --dir <name>=<path> path to named directory
+ -s --set <key=value> set a database client variable
diff --git a/lib/sqitch-init.pod b/lib/sqitch-init.pod
new file mode 100644
index 00000000..3863b488
--- /dev/null
+++ b/lib/sqitch-init.pod
@@ -0,0 +1,256 @@
+=head1 Name
+
+sqitch-init - Create a new Sqitch project
+
+=head1 Synopsis
+
+ sqitch init <project>
+ sqitch init <project> --uri <uri>
+
+=head1 Description
+
+This command creates an new Sqitch project -- basically a F<sqitch.conf> file,
+a F<sqitch.plan> file, and F<deploy>, F<revert>, and F<verify> subdirectories.
+
+Running sqitch init in an existing project is safe. It will not overwrite
+things that are already there.
+
+=head1 Options
+
+=over
+
+=item C<--uri>
+
+ sqitch init widgets --uri https://github.com/me/wigets
+
+Optional URI to associate with the project. If present, the URI will be
+written to the project plan and used for added uniqueness in hashed object
+IDs, as well as to prevent the deployment of another project with the same
+name but different URI.
+
+=item C<--engine>
+
+ sqitch init widgets --engine pg
+
+Specifies the default database engine to use in the project. Supported engines
+include:
+
+=over
+
+=item * C<pg> - L<PostgreSQL|https://postgresql.org/> and L<Postgres-XC|https://sourceforge.net/>
+
+=item * C<sqlite> - L<SQLite|https://sqlite.org/>
+
+=item * C<oracle> - L<Oracle|https://www.oracle.com/us/products/database/>
+
+=item * C<mysql> - L<MySQL|https://dev.mysql.com/> and L<MariaDB|https://mariadb.com/>
+
+=item * C<firebird> - L<Firebird|https://www.firebirdsql.org/>
+
+=item * C<vertica> - L<Vertica|https://my.vertica.com/>
+
+=item * C<exasol> - L<Exasol|https://www.exasol.com/>
+
+=item * C<snowflake> - L<Snowflake|https://www.snowflake.net/>
+
+=back
+
+=item C<--top-dir>
+
+ sqitch init widgets --top-dir sql
+
+Specifies the top directory to use for the project. Typically contains the
+deployment plan file and the change script directories.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch init widgets --plan-file my.plan
+
+Specifies the path to the deployment plan file. Defaults to
+C<$top_dir/sqitch.plan>.
+
+=item C<--extension>
+
+ sqitch init widgets --extension ddl
+
+Specifies the file name extension to use for change script file names.
+Defaults to C<sql>.
+
+=item C<--dir>
+
+ sqitch init widgets --dir deploy=dep --dir revert=rev --dir verify=tst
+
+Sets the path to a script directory. May be specified multiple times.
+Supported keys are:
+
+=over
+
+=item * C<deploy>
+
+=item * C<revert>
+
+=item * C<verify>
+
+=item * C<reworked>
+
+=item * C<reworked_deploy>
+
+=item * C<reworked_revert>
+
+=item * C<reworked_verify>
+
+=back
+
+=item C<--target>
+
+ sqitch init widgets --target db:pg:widgets
+
+Specifies the name or L<URI|https://github.com/libwww-perl/uri-db/> of the default
+target database. If specified as a name, the default URI for the target will
+be C<db:$engine:>.
+
+=item C<--registry>
+
+ sqitch init widgets --registry meta
+
+Specifies the name of the database object where Sqitch's state and history
+data is stored. Typically a schema name (as in PostgreSQL and Oracle) or a
+database name (as in SQLite and MySQL). Defaults to C<sqitch>.
+
+=item C<--client>
+
+ sqitch init widgets --client /usr/local/pgsql/bin/psql
+
+Specifies the path to the command-line client for the database engine.
+Defaults to a client in the current path named appropriately for the specified
+engine.
+
+=back
+
+=head1 Configuration
+
+The most important thing C<sqitch init> does is create the project plan file,
+F<sqitch.conf>. The options determine what gets written to the file:
+
+=over
+
+=item C<--engine>
+
+Sets the C<core.engine> configuration variable.
+
+=item C<--top-dir>
+
+Sets the C<core.top_dir> configuration variable.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+Sets the C<core.plan_file> configuration variable.
+
+=item C<--extension>
+
+Sets the C<core.extension> configuration variable.
+
+=item C<--dir>
+
+Sets the following configuration variables:
+
+=over
+
+=item * C<deploy> sets C<core.deploy_dir>
+
+=item * C<revert> sets C<core.revert_dir>
+
+=item * C<verify> sets C<core.verify_dir>
+
+=item * C<reworked> sets C<core.reworked_dir>
+
+=item * C<reworked_deplpoy> sets C<core.reworked_deploy_dir>
+
+=item * C<reworked_deplpoy> sets C<core.reworked_revert_dir>
+
+=item * C<reworked_deplpoy> sets C<core.reworked_verify_dir>
+
+=back
+
+=item C<--target>
+
+Sets the C<engine.$engine.target> configuration variable if C<--engine> is
+also passed and, if it's a target name, C<target.$target.uri>
+
+=item C<--registry>
+
+Sets the C<engine.$engine.registry> configuration variable if C<--engine> is also
+passed.
+
+=item C<--client>
+
+Sets the C<engine.$engine.client> configuration variable if C<--engine> is
+also passed.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Variables are set in C<core.variables>.
+
+=back
+
+As a general rule, you likely won't need any of these options except for
+C<--engine>, since many commands need to know what engine to use, and
+specifying it on the command-line forever after would be annoying.
+
+These variables will only be written if their corresponding options are
+specified. Otherwise, core options get written as comments with user or system
+configuration settings, or, failing any values from those locations, from
+their default values. If no defaults are specified, they will still be
+written, commented out, with a bar C<=> and no value. This allows one to know
+what sorts of things are available to edit.
+
+=head1 Examples
+
+Start a new Sqitch project named "quack" using the SQLite engine, setting the
+top directory for the project to F<sqlite>:
+
+ sqitch init --engine sqlite --top-dir sqlite quack
+
+Start a new Sqitch project named "bey" using the PostgreSQL engine, setting
+the top directory to F<postgres>, script extension to C<ddl>, reworked
+directory to C<reworked> and a version-specific client:
+
+ sqitch init --engine pg \
+ --top-dir postgres \
+ --client /opt/pgsql-9.1/bin/psql \
+ --extension ddl --dir reworked=reworked \
+ bey
+
+=head1 See Also
+
+=over
+
+=item L<sqitch-configuration>
+
+Describes how Sqitch hierarchical engine and target configuration works.
+
+=item L<sqitch-engine>
+
+Command to manage database engine configuration.
+
+=item L<sqitch-target>
+
+Command to manage target database configuration.
+
+=item L<sqitch-config>
+
+Command to manage all Sqitch configuration.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-log-usage.pod b/lib/sqitch-log-usage.pod
new file mode 100644
index 00000000..e3651890
--- /dev/null
+++ b/lib/sqitch-log-usage.pod
@@ -0,0 +1,40 @@
+=head1 Name
+
+sqitch-log-usage - Sqitch log usage statement
+
+=head1 Usage
+
+ sqitch log [options] [<database>]
+
+=head1 Options
+
+Search options:
+
+ -t --target <target> database to which to connect
+ --event <type> type of event
+ --change-pattern --change <name match regex against change names
+ --committer-pattern --committer <name> match regex against committer names
+ -n --max-count <count> show only specified number of events
+ --skip <number> skip the specified number of events
+ --reverse show events in reverse order
+ --no-reverse don't show events in reverse order
+
+Formatting:
+
+ -f --format <format> show events in the specified format
+ --date-format --date <format> show dates in the specified format
+ --color use ANSI colors
+ --no-color never use ANSI colors
+ --abbrev abbreviate change IDs
+ --oneline shorthand for --format oneline --abbrev 6
+ --headers show headers for the target
+ --no-headers don't show target headers
+
+Connection:
+
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
diff --git a/lib/sqitch-log.pod b/lib/sqitch-log.pod
new file mode 100644
index 00000000..b04fe382
--- /dev/null
+++ b/lib/sqitch-log.pod
@@ -0,0 +1,502 @@
+=head1 Name
+
+sqitch-log - Show Sqitch change deployment logs
+
+=head1 Synopsis
+
+ sqitch log [options] [<database>]
+
+=head1 Description
+
+Sqitch keeps a record of the deployment, failed deployment, or reversion of
+all changes in a target database. Even after a change has been reverted, a log
+of its earlier deployment is retained. The C<log> command is your key to
+accessing it. You can simply list all the events, search for events matching
+regular expressions, and limit the results.
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--event>
+
+Filter by event type. May be specified more than once. Allowed values are:
+
+=over
+
+=item * C<deploy>
+
+=item * C<revert>
+
+=item * C<fail>
+
+=back
+
+=item C<--change-pattern>
+
+=item C<--change>
+
+A regular expression to match against change names.
+
+=item C<--project-pattern>
+
+=item C<--project>
+
+A regular expression to match against project names.
+
+=item C<--committer-pattern>
+
+=item C<--committer>
+
+A regular expression to match against committer names.
+
+=item C<--format>
+
+=item C<-f>
+
+The format to use. May be one of:
+
+=over
+
+=item C<full>
+
+=item C<long>
+
+=item C<medium>
+
+=item C<short>
+
+=item C<oneline>
+
+=item C<raw>
+
+=item C<< format:<string> >>
+
+=back
+
+See L</Formats> for details on each format. Defaults to C<medium>.
+
+=item C<--date-format>
+
+=item C<--date>
+
+Format to use for timestamps. Defaults to C<iso>. Allowed values:
+
+=over
+
+=item C<iso>
+
+=item C<iso8601>
+
+Shows timestamps in ISO-8601 format.
+
+=item C<rfc>
+
+=item C<rfc2822>
+
+Show timestamps in RFC-2822 format.
+
+=item C<full>
+
+=item C<long>
+
+=item C<medium>
+
+=item C<short>
+
+Show timestamps in the specified format length, using the system locale's
+C<LC_TIME> category.
+
+=item C<raw>
+
+Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone.
+
+=item C<strftime:$string>
+
+Show timestamps using an arbitrary C<strftime> pattern. See
+L<DateTime/strftime Paterns> for comprehensive documentation of supported
+patterns.
+
+=item C<cldr:$pattern>
+
+Show timestamps using an arbitrary C<cldr> pattern. See
+L<DateTime/CLDR Paterns> for comprehensive documentation of supported
+patterns.
+
+=back
+
+=item C<--max-count>
+
+=item C<-n>
+
+Limit the number of events to output.
+
+=item C<--skip>
+
+Skip the specified number events before starting to show the event output.
+
+=item C<--reverse>
+
+Output the events in reverse order.
+
+=item C<--no-reverse>
+
+Do not output the events in reverse order.
+
+=item C<--headers>
+
+Output headers describing target. Enabled by default.
+
+=item C<--no-headers>
+
+Do not output headers describing target.
+
+=item C<--color>
+
+Show colored output. The value may be one of:
+
+=over
+
+=item C<auto> (the default)
+
+=item C<always>
+
+=item C<never>
+
+=back
+
+=item C<--no-color>
+
+Turn off colored output. It is the same as C<--color never>.
+
+=item C<--abbrev>
+
+Instead of showing the full 40-byte hexadecimal change ID, show only a partial
+prefix the specified number of characters long.
+
+=item C<--oneline>
+
+Shorthand for C<--format oneline --abbrev 6>.
+
+=item C<--registry>
+
+ sqitch log --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch log --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch log --db-name widgets
+ sqitch log -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch log --db-username root
+ sqitch log --db-user postgres
+ sqitch log -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch log --db-host db.example.com
+ sqitch log -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch log --db-port 7654
+ sqitch log -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<log.format>
+
+Output format to use. Supports the same values as C<--format>.
+
+=item C<log.date_format>
+
+Format to use for timestamps. Supports the same values as the C<--date-format>
+option.
+
+=item C<log.color>
+
+Output colors. Supports the same values as the C<--color> option.
+
+=back
+
+=head1 Formats
+
+There are several built-in formats, and you can emit data in a custom format
+C<< format:<string> >> format. Here are the details of the built-in formats:
+
+=over
+
+=item C<oneline>
+
+ <change id> <event type> <project name>:<change name> <title line>
+
+This is designed to be as compact as possible.
+
+=item C<short>
+
+ <event type> <change id>
+ Name: <change name>
+ Committer: <committer>
+
+ <title line>
+
+=item C<medium>
+
+ <event type> <change id>
+ Name: <change name>
+ Committer: <committer>
+ Date: <commit date>
+
+ <full change note>
+
+=item C<long>
+
+ <event type> <change id> <tags>
+ Name: <change name>
+ Project: <change name>
+ Planner: <planner>
+ Committer: <committer>
+
+ <full change note>
+
+=item C<full>
+
+ <event type> <change id> <tags>
+ Event: <event type>
+ Name: <change name>
+ Project: <change name>
+ Requires: <required changes>
+ Conflicts: <conflicting changes>
+ Planner: <planner>
+ Planned: <plan date>
+ Committer: <committer>
+ Committed: <commit date>
+
+ <full change note>
+
+=item C<raw>
+
+ <event type> <change id> <tags>
+ name <change name>
+ project <project name>
+ requires <required changes>
+ conflicts <conflicting changes>
+ planner <planner>
+ planned <raw plan date>
+ committer <committer>
+ committed <raw commit date>
+
+ <full change note>
+
+Suitable for parsing: the change ID is displayed in full, without regard to
+the value of C<--abbrev>; dates are formatted raw (strict ISO-8601 format in
+the UTC time zone); and all labels are lowercased and unlocalized.
+
+=item C<< format:<string> >>
+
+The C<< format:<string> >> format allows you to specify which information you
+want to show. It works a little bit like C<printf> format and a little like
+Git log format. For example, this format:
+
+ format:The committer of %h was %{name}c%vThe title was >>%s<<%v
+
+Would show something like this:
+
+ The committer of f26a3s was Tom Lane
+ The title was >>We really need to get this right.<<
+
+The placeholders are:
+
+=over
+
+=item * C<%H>: Event change ID
+
+=item * C<%h>: Event change ID (respects C<--abbrev>)
+
+=item * C<%n>: Event change name
+
+=item * C<%o>: Event change project name
+
+=item * C<%($len)h>: abbreviated change of length C<$len>
+
+=item * C<%e>: Event type (deploy, revert, fail)
+
+=item * C<%l>: Localized lowercase event type label
+
+=item * C<%L>: Localized title case event type label
+
+=item * C<%c>: Event committer name and email address
+
+=item * C<%{name}c>: Event committer name
+
+=item * C<%{email}c>: Event committer email address
+
+=item * C<%{date}c>: commit date (respects C<--date-format>)
+
+=item * C<%{date:rfc}c>: commit date, RFC2822 format
+
+=item * C<%{date:iso}c>: commit date, ISO-8601 format
+
+=item * C<%{date:full}c>: commit date, full format
+
+=item * C<%{date:long}c>: commit date, long format
+
+=item * C<%{date:medium}c>: commit date, medium format
+
+=item * C<%{date:short}c>: commit date, short format
+
+=item * C<%{date:cldr:$pattern}c>: commit date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns>
+
+=item * C<%{date:strftime:$pattern}c>: commit date, formatted with custom L<strftime pattern|DateTime/strftime Patterns>
+
+=item * C<%c>: Change planner name and email address
+
+=item * C<%{name}p>: Change planner name
+
+=item * C<%{email}p>: Change planner email address
+
+=item * C<%{date}p>: plan date (respects C<--date-format>)
+
+=item * C<%{date:rfc}p>: plan date, RFC2822 format
+
+=item * C<%{date:iso}p>: plan date, ISO-8601 format
+
+=item * C<%{date:full}p>: plan date, full format
+
+=item * C<%{date:long}p>: plan date, long format
+
+=item * C<%{date:medium}p>: plan date, medium format
+
+=item * C<%{date:short}p>: plan date, short format
+
+=item * C<%{date:cldr:$pattern}p>: plan date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns>
+
+=item * C<%{date:strftime:$pattern}p>: plan date, formatted with custom L<strftime pattern|DateTime/strftime Patterns>
+
+=item * C<%t>: Comma-delimited list of tags
+
+=item * C<%{$sep}t>: list of tags delimited by C<$sep>
+
+=item * C<%T>: Parenthesized list of comma-delimited tags
+
+=item * C<%{$sep}T>: Parenthesized list of tags delimited by C<$sep>
+
+=item * C<%s>: Subject (a.k.a. title line)
+
+=item * C<%r>: Comma-delimited list of required changes
+
+=item * C<%{$sep}r>: list of required changes delimited by C<$sep>
+
+=item * C<%R>: Localized label and list of comma-delimited required changes
+
+=item * C<%{$sep}R>: Localized label and list of required changes delimited by C<$sep>
+
+=item * C<%x>: Comma-delimited list of conflicting changes
+
+=item * C<%{$sep}x>: list of conflicting changes delimited by C<$sep>
+
+=item * C<%X>: Localized label and list of comma-delimited conflicting changes
+
+=item * C<%{$sep}X>: Localized label and list of conflicting changes delimited by C<$sep>
+
+=item * C<%b>: Body
+
+=item * C<%B>: Raw body (unwrapped subject and body)
+
+=item * C<%{$prefix}>B: Raw body with C<$prefix> prefixed to every line
+
+=item * C<%{event}_> Localized label for "event"
+
+=item * C<%{change}_> Localized label for "change"
+
+=item * C<%{committer}_> Localized label for "committer"
+
+=item * C<%{planner}_> Localized label for "planner"
+
+=item * C<%{by}_> Localized label for "by"
+
+=item * C<%{date}_> Localized label for "date"
+
+=item * C<%{committed}_> Localized label for "committed"
+
+=item * C<%{planned}_> Localized label for "planned"
+
+=item * C<%{name}_> Localized label for "name"
+
+=item * C<%{project}_> Localized label for "project"
+
+=item * C<%{email}_> Localized label for "email"
+
+=item * C<%{requires}_> Localized label for "requires"
+
+=item * C<%{conflicts}_> Localized label for "conflicts"
+
+=item * C<%v> vertical space (newline)
+
+=item * C<%{$color}C>: An ANSI color: black, red, green, yellow, reset, etc.
+
+=item * C<%{:event}C>: An ANSI color based on event type (green deploy, blue revert, red fail)
+
+=item * C<%{$attribute}a>: The raw attribute name and value, if it exists and has a value
+
+=back
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-passwords.pod b/lib/sqitch-passwords.pod
new file mode 100644
index 00000000..0673fd47
--- /dev/null
+++ b/lib/sqitch-passwords.pod
@@ -0,0 +1,13 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitch-passwords - Guide to using database passwords with Sqitch
+
+=head1 Description
+
+This guide has been moved to L<sqitch-authentication>.
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-plan-usage.pod b/lib/sqitch-plan-usage.pod
new file mode 100644
index 00000000..c567c190
--- /dev/null
+++ b/lib/sqitch-plan-usage.pod
@@ -0,0 +1,32 @@
+=head1 Name
+
+sqitch-plan-usage - Sqitch plan usage statement
+
+=head1 Usage
+
+ sqitch plan [options] [<database>]
+
+=head1 Options
+
+Search options:
+
+ -t --target <target> database target specifying the plan
+ --event type of event
+ --change-pattern --change match regex against change names
+ --planner-pattern --planner match regex against committer names
+ -n --max-count show only specified number of events
+ --skip skip the specified number of events
+ --reverse show events in reverse order
+ --no-reverse don't show events in reverse order
+
+Formatting
+
+ -f --format show events in the specified format
+ --date-format --date show dates in the specified format
+ --color use ANSI colors
+ --no-color never use ANSI colors
+ --abbrev abbreviate change IDs
+ --oneline shorthand for --format oneline --abbrev 6
+ --headers show headers for plan file and project
+ --no-headers don't show plan headers
+
diff --git a/lib/sqitch-plan.pod b/lib/sqitch-plan.pod
new file mode 100644
index 00000000..542c42b1
--- /dev/null
+++ b/lib/sqitch-plan.pod
@@ -0,0 +1,397 @@
+=head1 Name
+
+sqitch-plan - Show planned database changes
+
+=head1 Synopsis
+
+ sqitch plan [options] [<database>]
+
+=head1 Description
+
+The C<plan> command displays information about planned changes for a database
+target. By default, it will show all the changes for the plan, but you can
+also search for changes matching regular expressions, and limit the results.
+Of course you could just C<cat> your plan file, but this is more fun.
+
+The C<< <database> >> parameter specifies the database for which the plan
+should be read, and may also be specified as the C<--target> option. It can be
+target name, a URI, an engine name, or plan file path.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database, the plan for which should be read. This option should be
+the name of a target in the configuration.
+
+=item C<--event>
+
+Filter by event type. May be specified more than once. Allowed values are:
+
+=over
+
+=item * C<deploy>
+
+=item * C<revert>
+
+=back
+
+=item C<--change-pattern>
+
+=item C<--change>
+
+A regular expression to match against change names.
+
+=item C<--planner-pattern>
+
+=item C<--planner>
+
+A regular expression to match against planner names.
+
+=item C<--format>
+
+=item C<-f>
+
+The format to use. May be one of:
+
+=over
+
+=item C<full>
+
+=item C<long>
+
+=item C<medium>
+
+=item C<short>
+
+=item C<oneline>
+
+=item C<raw>
+
+=item C<< format:<string> >>
+
+=back
+
+See L</Formats> for details on each format. Defaults to C<medium>.
+
+=item C<--date-format>
+
+=item C<--date>
+
+Format to use for timestamps. Defaults to C<iso>. Allowed values:
+
+=over
+
+=item C<iso>
+
+=item C<iso8601>
+
+Shows timestamps in ISO-8601 format.
+
+=item C<rfc>
+
+=item C<rfc2822>
+
+Show timestamps in RFC-2822 format.
+
+=item C<full>
+
+=item C<long>
+
+=item C<medium>
+
+=item C<short>
+
+Show timestamps in the specified format length, using the system locale's
+C<LC_TIME> category.
+
+=item C<raw>
+
+Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone.
+
+=item C<strftime:$string>
+
+Show timestamps using an arbitrary C<strftime> pattern. See
+L<DateTime/strftime Paterns> for comprehensive documentation of supported
+patterns.
+
+=item C<cldr:$pattern>
+
+Show timestamps using an arbitrary C<cldr> pattern. See
+L<DateTime/CLDR Paterns> for comprehensive documentation of supported
+patterns.
+
+=back
+
+=item C<--max-count>
+
+=item C<-n>
+
+Limit the number of changes to output.
+
+=item C<--skip>
+
+Skip the specified number changes before starting to show the output.
+
+=item C<--reverse>
+
+Output the changes in reverse order.
+
+=item C<--no-reverse>
+
+Do not output the changes in reverse order.
+
+=item C<--headers>
+
+Output headers describing the project and plan file. Enabled by default.
+
+=item C<--no-headers>
+
+Do not output headers describing the project and plan file.
+
+=item C<--color>
+
+Show colored output. The value may be one of:
+
+=over
+
+=item C<auto> (the default)
+
+=item C<always>
+
+=item C<never>
+
+=back
+
+=item C<--no-color>
+
+Turn off colored output. It is the same as C<--color never>.
+
+=item C<--abbrev>
+
+Instead of showing the full 40-byte hexadecimal change ID, show only a partial
+prefix the specified number of characters long.
+
+=item C<--oneline>
+
+Shorthand for C<--format oneline --abbrev 6>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<plan.format>
+
+Output format to use. Supports the same values as C<--format>.
+
+=item C<plan.date_format>
+
+Format to use for timestamps. Supports the same values as the C<--date-format>
+option.
+
+=item C<plan.color>
+
+Output colors. Supports the same values as the C<--color> option.
+
+=back
+
+=head1 Formats
+
+There are several built-in formats, and you can emit data in a custom format
+C<< format:<string> >> format. Here are the details of the built-in formats:
+
+=over
+
+=item C<oneline>
+
+ <change id> <event type> <change name> <title line> <tags>
+
+This is designed to be as compact as possible.
+
+=item C<short>
+
+ <event type> <change id>
+ Name: <change name>
+ Planner: <planner>
+
+ <title line>
+
+=item C<medium>
+
+ <event type> <change id>
+ Name: <change name>
+ Planner: <planner>
+ Date: <commit date>
+
+ <full change note>
+
+=item C<long>
+
+ <event type> <change id> <tags>
+ Name: <change name>
+ Project: <change name>
+ Planner: <planner>
+
+ <full change note>
+
+=item C<full>
+
+ <event type> <change id> <tags>
+ Event: <event type>
+ Name: <change name>
+ Project: <change name>
+ Requires: <required changes>
+ Conflicts: <conflicting changes>
+ Planner: <planner>
+ Planned: <plan date>
+
+ <full change note>
+
+=item C<raw>
+
+ <event type> <change id> <tags>
+ name <change name>
+ project <project name>
+ requires <required changes>
+ conflicts <conflicting changes>
+ planner <planner>
+ planned <raw plan date>
+
+ <full change note>
+
+Suitable for parsing: the change ID is displayed in full, without regard to
+the value of C<--abbrev>; dates are formatted raw (strict ISO-8601 format in
+the UTC time zone); and all labels are lowercased and unlocalized.
+
+=item C<< format:<string> >>
+
+The C<< format:<string> >> format allows you to specify which information you
+want to show. It works a little bit like C<printf> format and a little like
+Git plan format. For example, this format:
+
+ format:The planner of %h was %{name}p%vThe title was >>%s<<%v
+
+Would show something like this:
+
+ The planner of f26a3s was Tom Lane
+ The title was >>We really need to get this right.<<
+
+The placeholders are:
+
+=over
+
+=item * C<%H>: Event change ID
+
+=item * C<%h>: Event change ID (respects C<--abbrev>)
+
+=item * C<%n>: Event change name
+
+=item * C<%o>: Event change project name
+
+=item * C<%($len)h>: abbreviated change of length C<$len>
+
+=item * C<%e>: Event type (deploy, revert, fail)
+
+=item * C<%l>: Localized lowercase event type label
+
+=item * C<%L>: Localized title case event type label
+
+=item * C<%c>: Change planner name and email address
+
+=item * C<%{name}p>: Change planner name
+
+=item * C<%{email}p>: Change planner email address
+
+=item * C<%{date}p>: plan date (respects C<--date-format>)
+
+=item * C<%{date:rfc}p>: plan date, RFC2822 format
+
+=item * C<%{date:iso}p>: plan date, ISO-8601 format
+
+=item * C<%{date:full}p>: plan date, full format
+
+=item * C<%{date:long}p>: plan date, long format
+
+=item * C<%{date:medium}p>: plan date, medium format
+
+=item * C<%{date:short}p>: plan date, short format
+
+=item * C<%{date:cldr:$pattern}p>: plan date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns>
+
+=item * C<%{date:strftime:$pattern}p>: plan date, formatted with custom L<strftime pattern|DateTime/strftime Patterns>
+
+=item * C<%t>: Comma-delimited list of tags
+
+=item * C<%{$sep}t>: list of tags delimited by C<$sep>
+
+=item * C<%T>: Parenthesized list of comma-delimited tags
+
+=item * C<%{$sep}T>: Parenthesized list of tags delimited by C<$sep>
+
+=item * C<%s>: Subject (a.k.a. title line)
+
+=item * C<%r>: Comma-delimited list of required changes
+
+=item * C<%{$sep}r>: list of required changes delimited by C<$sep>
+
+=item * C<%R>: Localized label and list of comma-delimited required changes
+
+=item * C<%{$sep}R>: Localized label and list of required changes delimited by C<$sep>
+
+=item * C<%x>: Comma-delimited list of conflicting changes
+
+=item * C<%{$sep}x>: list of conflicting changes delimited by C<$sep>
+
+=item * C<%X>: Localized label and list of comma-delimited conflicting changes
+
+=item * C<%{$sep}X>: Localized label and list of conflicting changes delimited by C<$sep>
+
+=item * C<%b>: Body
+
+=item * C<%B>: Raw body (unwrapped subject and body)
+
+=item * C<%{$prefix}>B: Raw body with C<$prefix> prefixed to every line
+
+=item * C<%{event}_> Localized label for "event"
+
+=item * C<%{change}_> Localized label for "change"
+
+=item * C<%{planner}_> Localized label for "planner"
+
+=item * C<%{by}_> Localized label for "by"
+
+=item * C<%{date}_> Localized label for "date"
+
+=item * C<%{planned}_> Localized label for "planned"
+
+=item * C<%{name}_> Localized label for "name"
+
+=item * C<%{project}_> Localized label for "project"
+
+=item * C<%{email}_> Localized label for "email"
+
+=item * C<%{requires}_> Localized label for "requires"
+
+=item * C<%{conflicts}_> Localized label for "conflicts"
+
+=item * C<%v> vertical space (newline)
+
+=item * C<%{$color}C>: An ANSI color: black, red, green, yellow, reset, etc.
+
+=item * C<%{:event}C>: An ANSI color based on event type (green deploy, blue revert, red fail)
+
+=item * C<%{$attribute}a>: The raw attribute name and value, if it exists and has a value
+
+=back
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-rebase-usage.pod b/lib/sqitch-rebase-usage.pod
new file mode 100644
index 00000000..9ef9aee8
--- /dev/null
+++ b/lib/sqitch-rebase-usage.pod
@@ -0,0 +1,28 @@
+=head1 Name
+
+sqitch-rebase-usage - Sqitch rebase usage statement
+
+=head1 Usage
+
+ sqitch rebase [options] [revert-change options] [deploy-change options]] [<database>]
+
+=head1 Options
+
+ -t --target <target> database to which to connect
+ --onto --onto-change <change> revert to change
+ --upto --upto-change <change> deploy to change
+ --mode <mode> deploy reversion mode (all, tag, change)
+ --verify run verify scripts after each change
+ --no-verify do not run verify scripts
+ -s --set <key=value> set a database client variable
+ -r --set-revert <key=value> set a database client revert variable
+ -e --set-deploy <key=value> set a database client deploy variable
+ --log-only log changes without running them
+ -y disable the prompt before reverting
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-rebase.pod b/lib/sqitch-rebase.pod
new file mode 100644
index 00000000..2493b9ab
--- /dev/null
+++ b/lib/sqitch-rebase.pod
@@ -0,0 +1,305 @@
+=head1 Name
+
+sqitch-rebase - Revert and redeploy database changes
+
+=head1 Synopsis
+
+ sqitch rebase [options] [<database>]
+ sqitch rebase [options] [<database>] --onto-change <change>
+ sqitch rebase [options] [<database>] --onto-change <change> --upto-change <change>
+ sqitch rebase [options] [<database>] <change>
+ sqitch rebase [options] [<database>] <change> --upto-change <change>
+ sqitch rebase [options] [<database>] <change> <change>
+
+=head1 Description
+
+Revert and redeploy changes to the database. It's effectively a shortcut for
+running L<C<sqitch revert>|sqitch-revert> and L<C<sqitch deploy>|sqitch-deploy>
+in succession.
+
+More specifically, starting from the current deployment state, changes will be
+reverted in reverse the order of application. All changes will be reverted
+unless a change is specified, either via C<--onto> or with no option flag, in
+which case changes will be reverted back to that change. If nothing needs to
+be reverted, a message will be emitted explaining why and nothing will be
+reverted.
+
+Once the revert finishes, changes will be deployed starting from the deployed
+state through the rest of the deployment plan. They will run to the latest
+change in the plan, unless a change is specified, either via C<--upto> or with
+no option flag, in which case changes will be deployed up-to and including
+that change.
+
+If the database has not been deployed to, or its state already matches the
+specified change, no reverts will be run. And if, at that point, the database
+is up-to-date, no deploys will be run.
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--onto-change>
+
+=item C<--onto>
+
+Specify the reversion change. Defaults to reverting all changes. See
+L<sqitchchanges> for the various ways in which changes can be specified.
+
+=item C<--upto-change>
+
+=item C<--upto>
+
+Specify the deployment change. Defaults to the last point in the plan. See
+L<sqitchchanges> for the various ways in which changes can be specified.
+
+=item C<--mode>
+
+Specify the reversion mode to use in case of deploy failure. Possible values
+are:
+
+=over
+
+=item C<all>
+
+In the event of failure, revert all deployed changes, back to
+C<--onto-change>. This is the default.
+
+=item C<tag>
+
+In the event of failure, revert all deployed changes to the last
+successfully-applied tag. If no tags were applied, all changes will be
+reverted to C<--onto-change>.
+
+=item C<change>
+
+In the event of failure, no changes will be reverted. This is on the
+assumption that a change is atomic, and thus may may be deployed again.
+
+=back
+
+=item C<--verify>
+
+Verify each change by running its verify script, if there is one, immediate
+after deploying it. If a verify test fails, the deploy will be considered to
+have failed and the appropriate reversion will be carried out, depending on
+the value of C<--mode>.
+
+=item C<--no-verify>
+
+Don't verify each change. This is the default.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values loaded from
+L</configuration Variables>.
+
+=item C<-e>
+
+=item C<--set-deploy>
+
+Set a variable name and value for use by the database engine client when
+deploying, if it supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values
+loaded from L</configuration Variables>.
+
+=item C<-r>
+
+=item C<--set-revert>
+
+Sets a variable name to be used by the database engine client during when
+reverting, if it supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values
+loaded from L</configuration Variables>.
+
+=item C<--log-only>
+
+Log the changes as if they were deployed and reverted, but without actually
+running the deploy and revert scripts.
+
+=item C<-y>
+
+Disable the prompt that normally asks whether or not to execute the revert.
+
+=item C<--registry>
+
+ sqitch rebase --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch rebase --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch rebase --db-name widgets
+ sqitch rebase -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch rebase --db-username root
+ sqitch rebase --db-user postgres
+ sqitch rebase -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch rebase --db-host db.example.com
+ sqitch rebase -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch rebase --db-port 7654
+ sqitch rebase -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch rebase --plan-file my.plan
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<[deploy.variables]>
+
+=item C<[revert.variables]>
+
+A section defining database client variables. These variables are useful if
+your database engine supports variables in scripts, such as PostgreSQL's
+L<C<psql>
+variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>,
+Vertica's L<C<vsql>
+variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>,
+MySQL's L<user
+variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>,
+SQL*Plus's L<C<DEFINE>
+variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>,
+and Snowflake's L<SnowSQL
+variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>.
+
+May be overridden by C<--set>, C<--set-deploy>, C<--set-revert>, or target and
+engine configuration. Variables are merged in the following priority order:
+
+=over
+
+=item C<--set-revert>
+
+Used only while reverting changes.
+
+=item C<--set-deploy>
+
+Used only while deploying changes.
+
+=item C<--set>
+
+Used while reverting and deploying changes.
+
+=item C<target.$target.variables>
+
+Used while reverting and deploying changes.
+
+=item C<engine.$engine.variables>
+
+Used while reverting and deploying changes.
+
+=item C<revert.variables>
+
+Used only while reverting changes.
+
+=item C<deploy.variables>
+
+Used while reverting and deploying changes.
+
+=item C<core.variables>
+
+Used while reverting and deploying changes.
+
+=back
+
+=item C<rebase.verify>
+
+=item C<deploy.verify>
+
+Boolean indicating whether or not to verify each change after deploying it.
+
+=item C<rebase.mode>
+
+=item C<deploy.mode>
+
+Deploy mode. The supported values are the same as for the C<--mode> option.
+
+=item C<[rebase.no_prompt]>
+
+=item C<[revert.no_prompt]>
+
+A boolean value indicating whether or not to disable the prompt before
+executing the revert. The C<rebase.no_prompt> variable takes precedence over
+C<revert.no_prompt>, and both may of course be overridden by C<-y>.
+
+=item C<[rebase.prompt_accept]>
+
+=item C<[revert.prompt_accept]>
+
+A boolean value indicating whether default reply to the prompt before
+executing the revert should be "yes" or "no". The C<rebase.prompt_accept>
+variable takes precedence over C<revert.prompt_accept>, and both default to
+true, meaning to accept the revert by default.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-revert-usage.pod b/lib/sqitch-revert-usage.pod
new file mode 100644
index 00000000..f37a0430
--- /dev/null
+++ b/lib/sqitch-revert-usage.pod
@@ -0,0 +1,22 @@
+=head1 Name
+
+sqitch-revert-usage - Sqitch revert usage statement
+
+=head1 Usage
+
+ sqitch revert [options] [<database>]
+
+=head1 Options
+
+ -t --target <target> database to which to connect
+ --to-change <change> revert to change
+ -s --set <key=value> set a database client variable
+ --log-only log changes without running them
+ -y disable the prompt before reverting
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-revert.pod b/lib/sqitch-revert.pod
new file mode 100644
index 00000000..fa69b697
--- /dev/null
+++ b/lib/sqitch-revert.pod
@@ -0,0 +1,208 @@
+=head1 Name
+
+sqitch-revert - Revert changes to a database
+
+=head1 Synopsis
+
+ sqitch revert [options] [<database>]
+ sqitch revert [options] [<database>] <change>
+ sqitch revert [options] [<database>] --to-change <change>
+
+=head1 Description
+
+Revert changes to the database. Starting from the current deployment state,
+changes will be reverted in reverse the order of application. All changes will
+be reverted unless a change is specified, either via C<--to> or with no option
+flag, in which case changes will be reverted back to that change.
+
+If the database has not been deployed to, or its state already matches the
+specified change, no changes will be made. If the change appears later in the
+plan than the currently-deployed state, an error will be returned, along with
+a suggestion to instead use L<sqitch-deploy>.
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+=head3 Attention Git Users
+
+If you're a git user thinking this is like C<git revert>, it's not.
+C<sqitch revert> is more like time travel. It takes your database back to the
+state it had just after applying the target change. It feels like magic, but
+it's actually all the time you spent writing revert scripts that finally pays
+off. Starting from the last change currently deployed, C<sqitch revert> runs
+each revert script in turn until the target change is reached and becomes the
+last change deployed.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--to-change>
+
+=item C<--change>
+
+=item C<--to>
+
+Specify the reversion change. Defaults to reverting all changes. See
+L<sqitchchanges> for the various ways in which changes can be specified.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values loaded from
+L</configuration Variables>.
+
+=item C<--log-only>
+
+Log the changes as if they were reverted, but without actually running the
+revert scripts.
+
+=item C<-y>
+
+Disable the prompt that normally asks whether or not to execute the revert.
+
+=item C<--registry>
+
+ sqitch revert --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch revert --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch revert --db-name widgets
+ sqitch revert -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch revert --db-username root
+ sqitch revert --db-user postgres
+ sqitch revert -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch revert --db-host db.example.com
+ sqitch revert -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch revert --db-port 7654
+ sqitch revert -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch revert --plan-file my.plan
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<[deploy.variables]>
+
+=item C<[revert.variables]>
+
+A section defining database client variables. The C<deploy.variables>
+configuration is read from the C<deploy> command configuration, on the
+assumption that the values will generally be the same on revert. If they're
+not, use C<revert.variables> to override C<deploy.variables>.
+
+These variables are useful if your database engine supports variables in
+scripts, such as PostgreSQL's
+L<C<psql> variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>,
+Vertica's
+L<C<vsql> variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>,
+MySQL's
+L<user variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>,
+SQL*Plus's
+L<C<DEFINE> variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>,
+and Snowflake's
+L<SnowSQL variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>.
+
+May be overridden by C<--set> or target and engine configuration. Variables
+are merged in the following priority order:
+
+=over
+
+=item C<--set>
+
+=item C<target.$target.variables>
+
+=item C<engine.$engine.variables>
+
+=item C<revert.variables>
+
+=item C<deploy.variables>
+
+=item C<core.variables>
+
+=back
+
+=item C<[revert.no_prompt]>
+
+A boolean value indicating whether or not to disable the prompt before
+executing the revert. May be overridden by C<-y>.
+
+=item C<[revert.prompt_accept]>
+
+A boolean value indicating whether default reply to the prompt before
+executing the revert should be "yes" or "no". Defaults to true, meaning to
+accept the revert.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-rework-usage.pod b/lib/sqitch-rework-usage.pod
new file mode 100644
index 00000000..7001b255
--- /dev/null
+++ b/lib/sqitch-rework-usage.pod
@@ -0,0 +1,19 @@
+=head1 Name
+
+sqitch-rework-usage - Sqitch rework usage statement
+
+=head1 Usage
+
+ sqitch rework [options] changename
+
+=head1 Options
+
+ -c --change --change-name <name> name of the change to rework
+ -r --requires <change> require change
+ -x --conflicts <change> declare conflicting change
+ -n --note <note> a note describing the change
+ -a --all rework the change in all plans in the project
+ -e --edit --open-editor open change scripts in an editor
+ --no-edit --no-open-editor do not open the change scripts in an editor
+ -f --plan-file <file> path to a deployment plan file
+
diff --git a/lib/sqitch-rework.pod b/lib/sqitch-rework.pod
new file mode 100644
index 00000000..b07bef11
--- /dev/null
+++ b/lib/sqitch-rework.pod
@@ -0,0 +1,184 @@
+=head1 Name
+
+sqitch-rework - Rework a database change
+
+=head1 Synopsis
+
+ sqitch rework [options] [<dependency-options>] name
+
+=head1 Description
+
+This command allows for the reworking of an existing database change. It is
+best used only under the following circumstances:
+
+=over
+
+=item *
+
+There are production deployments, so that you cannot revert to before the
+change, modify it, and then re-deploy. Just reverting, modifying, and
+re-deploying is the thing to do while developing the database, but once it
+has been released and deployed to production, you must not change previous
+change scripts.
+
+=item *
+
+The modifications will be L<idempotent|https://en.wikipedia.org/wiki/Idempotence>.
+In other words, either the earlier instance of the change or the new, reworked
+instance can be run any number of times, and the outcome of each will be the same.
+They must not break each other in case one needs to deploy and revert changes.
+
+=item *
+
+A tag must have been applied to the plan since the previous instance of the
+change. This is required so that Sqitch can disambiguate the two instances of
+the change. It's a good idea to always tag a release anyway. If you haven't,
+see L<sqitch-tag>.
+
+=back
+
+If all of these hold, then feel free to rework an existing change.
+
+In effect, reworking a change is similar to L<adding one|sqitch-add>. However,
+rather than writing new files for the change, the C<rework> command copies the
+files for the existing change. The new files are named with the tag that comes
+between the changes, and serves as the file for the original change. This
+leaves you free to edit the existing files.
+
+By default, the C<rework> command will rework the change in the default plan
+and the scripts to any top directories for that plan, as defined by the core
+configuration and command-line options. This works well for projects in which
+there is a single plan with separate top directories for each engine, for
+example. Pass the C<--all> option to have it iterate over all known plans and
+top directories (as specified for engines and targets) and rework the change
+to them all. Of course, the a change by that name must exist in all the plans
+of the reworking will fail.
+
+To specify which plans to in which to rework the change, pass the target,
+engine, or plan file names to tag as arguments. Use C<--change> to
+disambiguate the and change name from the other parameters if necessary (or
+preferable). See L</Examples> for examples.
+
+=head1 Options
+
+=over
+
+=item C<-c>
+
+=item C<--change>
+
+=item C<--change-name>
+
+The name of the change to rework. The name can be specified with or without
+this option, but the option can be useful for disambiguating the change name
+from other arguments.
+
+=item C<-r>
+
+=item C<--requires>
+
+Name of a change that is required by the new change. May be specified multiple
+times. See L<sqitchchanges> for the various ways in which changes can be
+specified.
+
+=item C<-x>
+
+=item C<--conflicts>
+
+Name of a change that conflicts with the new change. May be specified multiple
+times. See L<sqitchchanges> for the various ways in which changes can be
+specified.
+
+=item C<-a>
+
+=item C<--all>
+
+Rework the change in all plans in the project. Cannot be mixed with target,
+engine, or plan file name arguments; doing so will result in an error. Useful
+for multi-plan projects in which changes should be kept in sync. Overrides the
+value of the C<add.all> configuration; use C<--no-all> to override a true
+C<add.all> configuration.
+
+=item C<-n>
+
+=item C<--note>
+
+A brief note describing the purpose of the reworking. The note will be
+attached to the change as a comment. Multiple invocations will be concatenated
+together as separate paragraphs.
+
+For you Git folks out there, C<-m> also works.
+
+=item C<-e>
+
+=item C<--edit>
+
+=item C<--open-editor>
+
+Open the generated change scripts in an editor.
+
+=item C<--no-edit>
+
+=item C<--no-open-editor>
+
+Do not open the change scripts in an editor. Useful when L<C<rework.open_editor>>
+is true.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Examples
+
+Rework a change in a project and be prompted for a note.
+
+ sqitch rework widgets
+
+Rework a change and specify the note.
+
+ sqitch rework sprockets --note 'Reworks the sprockets view.'
+
+Rework a change that requires the C<users> change from earlier in the plan.
+
+ sqitch rework contacts --requires users -n 'Reworks the contacts view.'
+
+Rework a change that requires multiple changes, including the change named
+C<extract> from a completely different Sqitch project named C<utilities>:
+
+ sqitch rework coffee -r users -r utilities:extract -n 'Mmmmm...coffee!'
+
+Rework a change only to the plan used by the C<vertica> engine in a project:
+
+ sqitch rework --change logs vertica -n 'Reworks the logs view in Vertica.'
+
+Rework a change in two plans in a project, and generate the scripts only for
+those plans:
+
+ sqitch rework -a coolfunctions sqlite.plan pg.plan -n 'Reworks functions.'
+
+=head1 Configuration Variables
+
+=over
+
+=item C<rework.all>
+
+Rework the change to all the plans in the project. Useful for multi-plan projects
+in which changes should be kept in sync. May be overridden by C<--all>,
+C<--no-all>, or target, engine, and plan file name arguments.
+
+=item C<rework.open_editor>
+
+Boolean indicating if the rework command should spawn an editor after
+generating change scripts. When true, equivalent to passing C<--edit>.
+Defaults off.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-show-usage.pod b/lib/sqitch-show-usage.pod
new file mode 100644
index 00000000..a4ce057a
--- /dev/null
+++ b/lib/sqitch-show-usage.pod
@@ -0,0 +1,15 @@
+=head1 Name
+
+sqitch-show-usage - Sqitch show usage statement
+
+=head1 Usage
+
+ sqitch show [options] <type> <object>
+
+C<< <type> >> can be one of: change, tag, deploy, revert, verify
+
+=head1 Options
+
+ -t --target <target> database target specifying the plan
+ -e --exists suppress output, exit 0 when object exists
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-show.pod b/lib/sqitch-show.pod
new file mode 100644
index 00000000..804e9015
--- /dev/null
+++ b/lib/sqitch-show.pod
@@ -0,0 +1,96 @@
+=head1 Name
+
+sqitch-show - Show object information or script contents
+
+=head1 Synopsis
+
+ sqitch show [options] <type> <object>
+
+=head1 Description
+
+Shows information about Sqitch objects. The first argument must be the type of
+object to show, and the second must be a key identifier for the object in the
+plan. The second argument must be a a change name or tag as specified in
+L<sqitchchanges>. The supported types include:
+
+=over
+
+=item C<change>
+
+A change object. Outputs the text used to generate the change SHA1 ID.
+
+=item C<tag>
+
+A tag. Outputs the text used to generate the tag SHA1 ID.
+
+=item C<deploy>
+
+A change deploy script.
+
+=item C<revert>
+
+A change revert script.
+
+=item C<verify>
+
+A change verify script.
+
+=back
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database, the plan for which should be read before deciding what
+object to show. This option should be the name of a target in the
+configuration.
+
+=item C<-e>
+
+=item C<--exists>
+
+Suppress all output; instead exit with zero status if C<< <object> >> exists
+and is a valid object.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head2 Examples
+
+=over
+
+=item * Show information about a specific change:
+
+ sqitch show change add_users_table
+
+=item * Show information about a change by ID:
+
+ sqitch show change be7cd00571d7151eacb0691e825dfc8980cc14ff
+
+=item * Show the most recent change info:
+
+ sqitch show change @HEAD
+
+=item * Show information about a tag:
+
+ sqitch show tag @beta1
+
+=item * Show the contents of a deploy file:
+
+ sqitch show deploy add_users_table@HEAD
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-status-usage.pod b/lib/sqitch-status-usage.pod
new file mode 100644
index 00000000..90d7ffb3
--- /dev/null
+++ b/lib/sqitch-status-usage.pod
@@ -0,0 +1,22 @@
+=head1 Name
+
+sqitch-status-usage - Sqitch status usage statement
+
+=head1 Usage
+
+ sqitch status [options] [<database>]
+
+=head1 Options
+
+ -t --target <target> database to which to connect
+ --project <project> project for which to retrieve the status
+ --show-changes include a list of deployed changes
+ --show-tags include a list of applied tags
+ --date-format <format> display dates using the specified format
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-status.pod b/lib/sqitch-status.pod
new file mode 100644
index 00000000..14783c27
--- /dev/null
+++ b/lib/sqitch-status.pod
@@ -0,0 +1,189 @@
+=head1 Name
+
+sqitch-status - Show the current deployment status of a database
+
+=head1 Synopsis
+
+ sqitch status [options] [<database>]
+
+=head1 Description
+
+Displays information about the current deployment status of a database. The
+most recently deployed change information is displayed, as well as any related
+tags. If there are undeployed changes in the plan, they will be listed.
+Otherwise, a message will indicate that the database is up-to-date.
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--project>
+
+Project for which to retrieve the status. Defaults to the status of the
+current project, if a plan can be found.
+
+=item C<--show-changes>
+
+Also display a list of deployed changes.
+
+=item C<--show-tags>
+
+Also display a list of applied tags.
+
+=item C<--date-format>
+
+=item C<--date>
+
+Format to use for timestamps. Defaults to C<iso>. Allowed values:
+
+=over
+
+=item C<iso>
+
+=item C<iso8601>
+
+Shows timestamps in ISO-8601 format.
+
+=item C<rfc>
+
+=item C<rfc2822>
+
+Show timestamps in RFC-2822 format.
+
+=item C<full>
+
+=item C<long>
+
+=item C<medium>
+
+=item C<short>
+
+Show timestamps in the specified format length, using the system locale's
+C<LC_TIME> category.
+
+=item C<raw>
+
+Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone.
+
+=item C<strftime:$string>
+
+Show timestamps using an arbitrary C<strftime> pattern. See
+L<DateTime/strftime Paterns> for comprehensive documentation of supported
+patterns.
+
+=item C<cldr:$string>
+
+Show timestamps using an arbitrary C<cldr> pattern. See L<DateTime/CLDR
+Paterns> for comprehensive documentation of supported patterns.
+
+=back
+
+=item C<--registry>
+
+ sqitch status --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch status --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch status --db-name widgets
+ sqitch status -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch status --db-username root
+ sqitch status --db-user postgres
+ sqitch status -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch status --db-host db.example.com
+ sqitch status -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch status --db-port 7654
+ sqitch status -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch status --plan-file my.plan
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<status.show_changes>
+
+Boolean value indicates whether or not to display changes in the output.
+Defaults to false.
+
+=item C<status.show_tags>
+
+Boolean value indicates whether or not to display tags in the output. Defaults
+to false.
+
+=item C<status.date_format>
+
+Format to use for timestamps. Supports the same values as the C<--date-format>
+option.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-tag-usage.pod b/lib/sqitch-tag-usage.pod
new file mode 100644
index 00000000..ecea9a2d
--- /dev/null
+++ b/lib/sqitch-tag-usage.pod
@@ -0,0 +1,15 @@
+=head1 Name
+
+sqitch-tag-usage - Sqitch tag usage statement
+
+=head1 Usage
+
+ sqitch tag [options] <tagname> [<change>] [<target>]
+
+=head1 Options
+
+ -t --tag <tag> name of the tag to add
+ -c --change <change> name of the change to tag
+ -a --all tag all project plans
+ -n --note <note> a note describing the tag
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-tag.pod b/lib/sqitch-tag.pod
new file mode 100644
index 00000000..07a06310
--- /dev/null
+++ b/lib/sqitch-tag.pod
@@ -0,0 +1,152 @@
+=head1 Name
+
+sqitch-tag - Create or list tag objects
+
+=head1 Synopsis
+
+ sqitch tag [options]
+ sqitch tag <name>
+ sqitch tag <name> change --note <note>
+ sqitch tag --tag <name> --change <change>
+ sqitch tag <name> --all
+
+=head1 Description
+
+Tags a change or outputs a list of existing tags in one or more project plans.
+Tagging is useful for preparing for a release. Tags are also required in order
+to rework a change.
+
+To specify a change, use a change specification as documented in
+L<sqitchchanges>. If called with a tag name but no change, the most recent
+change in each plan will be tagged. If called with no name specified, a list
+of the current tags will be output.
+
+Note that the name of the new tag must adhere to the rules as defined in
+L<sqitchchanges>.
+
+By default, the C<tag> command will add a new tag to the project's default
+plan, as defined by the core configuration and command-line options. Pass the
+C<--all> option to have it iterate over all known targets and list tags or add
+a tag to all the plans. This works well to keep tags in sync in all plan
+files.
+
+To specify which plans to tag, pass the target, engine, or plan file names to
+tag as arguments. Use C<--tag> and C<--change> to disambiguate the tag and
+change names from the other parameters if necessary (or preferable). See
+L</Examples> for examples.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--tag>
+
+=item C<--tag-name>
+
+The name of the tag to add. The name can be specified with or without this
+option, but the option can be useful for disambiguating the tag name from
+other arguments.
+
+=item C<-c>
+
+=item C<--change>
+
+=item C<--change-name>
+
+The name of the change to tag. The name can be specified with or without this
+option, but the option can be useful for disambiguating the change name from
+other arguments.
+
+=item C<-a>
+
+=item C<--all>
+
+List the tags or add the new tag to all the plans in a project. Cannot be
+mixed with target, engine, or plan file name arguments; doing so will result
+in an error. Useful for multi-plan projects in which tags should be kept in
+sync. Overrides the value of the C<tag.all> configuration; use C<--no-all> to
+override a true C<tag.all> configuration.
+
+=item C<-n>
+
+=item C<--note>
+
+A brief note describing the tag. The note will be attached to the tag as a
+comment. Multiple invocations will be concatenated together as separate
+paragraphs.
+
+For you Git folks out there, C<-m> also works.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<tag.all>
+
+List the tags or add the new tag to all the plans in a project. Useful for
+multi-plan projects in which tags should be kept in sync. May be overridden by
+C<--all>, C<--no-all>, or target, engine, and plan file name arguments.
+
+=back
+
+=head1 Examples
+
+Get a list of tags in the default project plan:
+
+ sqitch tag
+
+Get a list of all tags in the project:
+
+ sqitch tag --all
+
+Get a list of the tags in the plan used by the C<pg> engine:
+
+ sqitch tag pg
+
+Get a list of the tags in two specific plans:
+
+ sqitch tag sqlite.plan pg.plan
+
+Tag the latest change in the default project plan and be prompted for a note.
+
+ sqitch tag alpha1
+
+Tag the latest change in all project plans and be prompted for a note.
+
+ sqitch tag alpha1 --all
+
+Tag the latest change in the default project plan and and specify the note.
+
+ sqitch tag alpha2 -n 'Tag @alpha2.'
+
+Tag change C<users> in the default plan:
+
+ sqitch tag --tag alpha3 --change users
+
+Tag the latest change change in the project plan used by the C<vertica>
+engine:
+
+ sqitch tag --tag beta1 vertica -n 'Tag the Vertica with @beta1.'
+
+Tag the latest change in two plans in a project:
+
+ sqitch tag -t v1.0.1 sqlite.plan pg.plan -n 'Tag @v1.0.1.'
+
+=head1 Configuration Variables
+
+None currently.
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-target-usage.pod b/lib/sqitch-target-usage.pod
new file mode 100644
index 00000000..eab6c85e
--- /dev/null
+++ b/lib/sqitch-target-usage.pod
@@ -0,0 +1,25 @@
+=head1 Name
+
+sqitch-target-usage - Sqitch target usage statement
+
+=head1 Usage
+
+ sqitch target
+ sqitch target [-v | --verbose]
+ sqitch target add <name> <uri> [target-options]
+ sqitch target alter <name> [target-options]
+ sqitch target remove <name>
+ sqitch target rename <old> <new>
+ sqitch target show <name>
+
+=head1 Options
+
+ -v, --verbose be verbose; must be placed before an action
+ --uri <uri> database URI
+ --registry <registry> registry schema or database
+ --client <path> path to engine command-line client
+ -f --plan-file <file> path to deployment plan file
+ --top-dir <dir> path to directory with plan and scripts
+ --extension <ext> change script file name extension
+ --dir <name>=<path> path to named directory
+ -s --set <key=value> set a database client variable
diff --git a/lib/sqitch-target.pod b/lib/sqitch-target.pod
new file mode 100644
index 00000000..bfab8e54
--- /dev/null
+++ b/lib/sqitch-target.pod
@@ -0,0 +1,260 @@
+=head1 Name
+
+sqitch-target - Manage target database configuration
+
+=head1 Synopsis
+
+ sqitch target
+ sqitch target [-v | --verbose]
+ sqitch target add <name> <uri> [-s <property>=<value> ...]
+ sqitch target alter <name> [-s <property>=<value> ...]
+ sqitch target remove <name>
+ sqitch target rename <old> <new>
+ sqitch target show <name> [...]
+
+=head1 Description
+
+Manage the set of databases ("targets") you deploy to. Each target may have a
+number of properties:
+
+=over
+
+=item C<uri>
+
+The L<database connection URI|URI::db> for the target. Required. Its format is:
+
+ db:engine:[dbname]
+ db:engine:[//[user[:password]@][host][:port]/][dbname][?params][#fragment]
+
+Some examples:
+
+=over
+
+=item C<db:sqlite:widgets.db>
+
+=item C<db:pg://dba@example.net/blanket>
+
+=item C<db:mysql://db.example.com/>
+
+=item C<db:firebird://localhost//tmp/test.fdb>
+
+=back
+
+See the L<DB URI Draft|https://github.com/libwww-perl/uri-db> for details.
+
+=item C<registry>
+
+The name of the registry schema or database. The default is C<sqitch>.
+
+=item C<client>
+
+The command-line client to use. If not specified, each engine looks in the OS
+Path for an appropriate client.
+
+=item C<top_dir>
+
+The path to the top directory for the target. This directory generally
+contains the plan file and subdirectories for deploy, revert, and verify
+scripts, as well as reworked instances of those scripts. The default is F<.>,
+the current directory.
+
+=item C<plan_file>
+
+The plan file to use for this target. The default is C<$top_dir/sqitch.plan>.
+
+=item C<deploy_dir>
+
+The path to the deploy directory for the target. This directory contains all
+of the deploy scripts referenced by changes in the C<plan_file>. The default
+is C<$top_dir/deploy>.
+
+=item C<revert_dir>
+
+The path to the revert directory for the target. This directory contains all
+of the revert scripts referenced by changes in the C<plan_file>. The default
+is C<$top_dir/revert>.
+
+=item C<verify_dir>
+
+The path to the verify directory for the target. This directory contains all
+of the verify scripts referenced by changes in the C<plan_file>. The default
+is C<$top_dir/verify>.
+
+=item C<reworked_dir>
+
+The path to the reworked directory for the target. This directory contains all
+subdirectories for all reworked scripts referenced by changes in the
+C<plan_file>. The default is C<$top_dir>.
+
+=item C<reworked_deploy_dir>
+
+The path to the reworked deploy directory for the target. This directory
+contains all of the reworked deploy scripts referenced by changes in the
+C<plan_file>. The default is C<$reworked_dir/deploy>.
+
+=item C<reworked_revert_dir>
+
+The path to the reworked revert directory for the target. This directory
+contains all of the reworked revert scripts referenced by changes in the
+C<plan_file>. The default is C<$reworked_dir/revert>.
+
+=item C<reworked_verify_dir>
+
+The path to the reworked verify directory for the target. This directory
+contains all of the reworked verify scripts referenced by changes in the
+C<plan_file>. The default is C<$reworked_dir/verify>.
+
+=item C<extension>
+
+The file name extension to append to change names to create script file names.
+The default is C<sql>.
+
+=back
+
+Each of these overrides the corresponding engine-specific configuration
+managed by L<engine|sqitch-engine>.
+
+=head1 Options
+
+=over
+
+=item List Option
+
+=over
+
+=item C<-v>
+
+=item C<--verbose>
+
+ sqitch engine --verbose
+
+Be more verbose when listing engines.
+
+=back
+
+=item Add and Alter Options
+
+=over
+
+=item C<--uri>
+
+ sqitch target add devwidgets --uri db:pg:widgets
+
+Specifies the L<URI|https://github.com/libwww-perl/uri-db/> of the target database.
+
+=item C<--top-dir>
+
+ sqitch target add devwidgets --top-dir sql
+
+Specifies the top directory to use for the target. Typically contains the
+deployment plan file and the change script directories.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch target add devwidgets --plan-file my.plan
+
+Specifies the path to the deployment plan file. Defaults to
+C<$top_dir/sqitch.plan>.
+
+=item C<--extension>
+
+ sqitch target add devwidgets --extension ddl
+
+Specifies the file name extension to use for change script file names.
+Defaults to C<sql>.
+
+=item C<--dir>
+
+ sqitch target add devwidgets --dir deploy=dep --dir revert=rev --dir verify=tst
+
+Sets the path to a script directory. May be specified multiple times.
+Supported keys are:
+
+=over
+
+=item * C<deploy>
+
+=item * C<revert>
+
+=item * C<verify>
+
+=item * C<reworked>
+
+=item * C<reworked_deploy>
+
+=item * C<reworked_revert>
+
+=item * C<reworked_verify>
+
+=back
+
+=item C<--registry>
+
+ sqitch target add devwidgets --registry meta
+
+Specifies the name of the database object where Sqitch's state and history
+data is stored. Typically a schema name (as in PostgreSQL and Oracle) or a
+database name (as in SQLite and MySQL). Defaults to C<sqitch>.
+
+=item C<--client>
+
+ sqitch target add devwidgets --client /usr/local/pgsql/bin/psql
+
+Specifies the path to the command-line client for the target. Defaults to a
+client in the current path named appropriately for the engine specified by the
+URI.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>.
+
+=back
+
+=back
+
+=head1 Actions
+
+With no arguments, shows a list of existing targets. Several actions are
+available to perform operations on the targets.
+
+=head2 C<add>
+
+Add a target named C<< <name> >> for the database at C<< <uri> >>. The
+C<--set> option specifies target-specific properties. A new plan file and new
+script script directories will be created if they don't already exist.
+
+=head2 C<alter>
+
+Alter target named C<< <name> >>. The C<--set> option specifies
+engine-specific properties to set. New script script directories will be
+created if they don't already exist.
+
+=head2 C<remove>, C<rm>
+
+Remove the target named C<< <name> >>. The plan file and script directories
+will not be affected.
+
+=head2 C<rename>
+
+Rename the target named C<< <old> >> to C<< <new> >>.
+
+=head2 C<show>
+
+Gives some information about the target C<< <name> >>, including the
+associated properties. Specify multiple target names to see information for
+each.
+
+=head1 Configuration Variables
+
+The targets are stored in the configuration file, but the command itself
+currently relies on no configuration variables.
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-upgrade-usage.pod b/lib/sqitch-upgrade-usage.pod
new file mode 100644
index 00000000..edf45a38
--- /dev/null
+++ b/lib/sqitch-upgrade-usage.pod
@@ -0,0 +1,17 @@
+=head1 Name
+
+sqitch-upgrade-usage - Sqitch upgrade usage statement
+
+=head1 Usage
+
+ sqitch upgrade [options] [<database>]
+
+=head1 Options
+
+ -t --target <target> database to which to connect
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
diff --git a/lib/sqitch-upgrade.pod b/lib/sqitch-upgrade.pod
new file mode 100644
index 00000000..5a63a5b7
--- /dev/null
+++ b/lib/sqitch-upgrade.pod
@@ -0,0 +1,98 @@
+=head1 Name
+
+sqitch-upgrade - Upgrade the registry to the current version
+
+=head1 Synopsis
+
+ sqitch upgrade [options] [<database>]
+
+=head1 Description
+
+Upgrades the Sqitch registry for a database. That is, it makes sure that the
+schema the Sqitch uses for its registry is up-to-date. This will occasionally
+be necessary when new features are added to Sqitch that require registry
+schema changes.
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--registry>
+
+ sqitch upgrade --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch upgrade --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch upgrade --db-name widgets
+ sqitch upgrade -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch upgrade --db-username root
+ sqitch upgrade --db-user postgres
+ sqitch upgrade -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch upgrade --db-host db.example.com
+ sqitch upgrade -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch upgrade --db-port 7654
+ sqitch upgrade -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch-verify-usage.pod b/lib/sqitch-verify-usage.pod
new file mode 100644
index 00000000..5d84e2bc
--- /dev/null
+++ b/lib/sqitch-verify-usage.pod
@@ -0,0 +1,21 @@
+=head1 Name
+
+sqitch-verify-usage - Sqitch verify usage statement
+
+=head1 Usage
+
+ sqitch verify [options] [<database>]
+
+=head1 Options
+
+ -t --target <target> database to which to connect
+ --from-change <change> verify from change
+ --to-change <change> verify to change
+ -s --set <key=value> set a database client variable
+ --registry <registry> registry schema or database
+ --db-client <path> path to the engine command-line client
+ -d --db-name <name> database name
+ -u --db-user <user> database user name
+ -h --db-host <host> database server host name
+ -p --db-port <port> database server port number
+ -f --plan-file <file> path to a deployment plan file
diff --git a/lib/sqitch-verify.pod b/lib/sqitch-verify.pod
new file mode 100644
index 00000000..a6ad0307
--- /dev/null
+++ b/lib/sqitch-verify.pod
@@ -0,0 +1,215 @@
+=head1 Name
+
+sqitch-verify - Verify deployed database changes
+
+=head1 Synopsis
+
+ sqitch verify [options] [<database>]
+ sqitch verify [options] --from-change <change>
+ sqitch verify [options] --to-change <change>
+ sqitch verify [options] --from-change <change> --to-change <change>
+
+=head1 Description
+
+Verify that a database is valid relative to the plan and the verification
+scripts for each deployed change.
+
+More specifically, C<verify> iterates over all deployed and planned changes
+(or the subset identified by C<--from-change> and/or C<--to-change>) and
+checks that each:
+
+=over
+
+=item *
+
+Is deployed.
+
+=item *
+
+Is present in the plan.
+
+=item *
+
+Was deployed in the proper order.
+
+=item *
+
+Passes its verify test, if one exists and the change has not been reworked.
+
+=back
+
+The C<< <database> >> parameter specifies the database to which to connect,
+and may also be specified as the C<--target> option. It can be target name,
+a URI, an engine name, or plan file path.
+
+Verify tests are scripts that may be associated with each change. If a change
+has no verify script, a warning is emitted, but it is not considered a
+failure. If a change has been reworked, only the most recent reworking will
+have its verify script executed.
+
+Verify scripts should make no assumptions about the contents of the database,
+as unit tests might. Rather, their job is to ensure that the state of a
+database is correct after a deploy script has completed. Verify scripts are
+run through the database engine command-line client, just like deploy and
+revert scripts. They should cause the client to exit with a non-zero exit code
+if they fail.
+
+=head1 Options
+
+=over
+
+=item C<-t>
+
+=item C<--target>
+
+The target database to which to connect. This option can be either a URI or
+the name of a target in the configuration.
+
+=item C<--from-change>
+
+=item C<--from>
+
+Specify the change with which to start the verification. Defaults to the
+earliest deployed change. See L<sqitchchanges> for the various ways in which
+changes can be specified.
+
+=item C<--to-change>
+
+=item C<--to>
+
+Specify the change with which to complete the verification. Defaults to the
+last deployed change. See L<sqitchchanges> for the various ways in which
+changes can be specified.
+
+=item C<-s>
+
+=item C<--set>
+
+Set a variable name and value for use by the database engine client, if it
+supports variables. The format must be C<name=value>, e.g.,
+C<--set defuser='Homer Simpson'>. Overrides any values loaded from
+L</configuration Variables>.
+
+=item C<--registry>
+
+ sqitch verify --registry registry
+
+The name of the Sqitch registry schema or database in which sqitch stores its
+own data.
+
+=item C<--db-client>
+
+=item C<--client>
+
+ sqitch verify --client /usr/local/pgsql/bin/psql
+
+Path to the command-line client for the database engine. Defaults to a client
+in the current path named appropriately for the database engine.
+
+=item C<-d>
+
+=item C<--db-name>
+
+ sqitch verify --db-name widgets
+ sqitch verify -d bricolage
+
+Name of the database. In general, L<targets|sqitch-target> and URIs are
+preferred, but this option can be used to override the database name in a
+target.
+
+=item C<-u>
+
+=item C<--db-user>
+
+=item C<--db-username>
+
+ sqitch verify --db-username root
+ sqitch verify --db-user postgres
+ sqitch verify -u Mom
+
+User name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the user name in a target.
+
+=item C<-h>
+
+=item C<--db-host>
+
+ sqitch verify --db-host db.example.com
+ sqitch verify -h appdb.example.net
+
+Host name to use when connecting to the database. Does not apply to all
+engines. In general, L<targets|sqitch-target> and URIs are preferred, but this
+option can be used to override the host name in a target.
+
+=item C<-p>
+
+=item C<--db-port>
+
+ sqitch verify --db-port 7654
+ sqitch verify -p 5431
+
+Port number to connect to. Does not apply to all engines. In general,
+L<targets|sqitch-target> and URIs are preferred, but this option can be used
+to override the port in a target.
+
+=item C<--plan-file>
+
+=item C<-f>
+
+ sqitch verify --plan-file my.plan
+
+Path to the deployment plan file. Overrides target, engine, and core
+configuration values. Defaults to F<$top_dir/sqitch.plan>.
+
+=back
+
+=head1 Configuration Variables
+
+=over
+
+=item C<[deploy.variables]>
+
+=item C<[verify.variables]>
+
+A section defining database client variables. The C<deploy.variables>
+configuration is read from the C<deploy> command configuration, on the
+assumption that the values will generally be the same on verify. If they're
+not, use C<verify.variables> to override C<deploy.variables>.
+
+These variables are useful if your database engine supports variables in
+scripts, such as PostgreSQL's
+L<C<psql> variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>,
+Vertica's
+L<C<vsql> variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>,
+MySQL's
+L<user variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>,
+SQL*Plus's
+L<C<DEFINE> variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>,
+and Snowflake's
+L<SnowSQL variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>.
+
+May be overridden by C<--set> or target and engine configuration. Variables
+are merged in the following priority order:
+
+=over
+
+=item C<--set>
+
+=item C<target.$target.variables>
+
+=item C<engine.$engine.variables>
+
+=item C<verify.variables>
+
+=item C<deploy.variables>
+
+=item C<core.variables>
+
+=back
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitch.pod b/lib/sqitch.pod
new file mode 100644
index 00000000..5e9d07e4
--- /dev/null
+++ b/lib/sqitch.pod
@@ -0,0 +1,490 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitch - Sensible database change management
+
+=head1 Synopsis
+
+ sqitch <command> [options] [command-options] [args]
+
+=head1 Description
+
+Sqitch is a database change management application. What makes it different
+from your typical L<migration|Module::Build::DB>-L<style|DBIx::Migration>
+approaches? A few things:
+
+=over
+
+=item No opinions
+
+Sqitch is not tied to any framework, ORM, or platform. Rather, it is a
+standalone change management system with no opinions about your database
+engine, application framework, or development environment.
+
+=item Native scripting
+
+Changes are implemented as scripts native to your selected database engine.
+Writing a L<PostgreSQL|https://postgresql.org/> application? Write SQL scripts
+for L<C<psql>|https://www.postgresql.org/docs/current/static/app-psql.html>.
+Writing an L<Oracle|https://www.oracle.com/us/products/database/>-backed app?
+Write SQL scripts for L<SQL*Plus|https://www.orafaq.com/wiki/SQL*Plus>.
+
+=begin comment
+
+=item VCS integration
+
+Sqitch likes to use your VCS history to determine in what order to execute
+changes. No need to keep track of execution order; your VCS already tracks
+information sufficient for Sqitch to figure it out for you.
+
+=end comment
+
+=item Dependency resolution
+
+Database changes may declare dependencies on other changes -- even on changes
+from other Sqitch projects. This ensures proper order of execution, even when
+you've committed changes to your VCS out-of-order.
+
+=item Deployment integrity
+
+Sqitch manages changes and dependencies via a plan file, and employs a
+L<Merkle tree|https://en.wikipedia.org/wiki/Merkle_tree> pattern similar to
+L<Git|https://stackoverflow.com/a/18589734/> and
+L<Blockchain|https://medium.com/byzantine-studio/blockchain-fundamentals-what-is-a-merkle-tree-d44c529391d7>
+to ensure deployment integrity. As such, there is no need to number your
+changes, although you can if you want. Sqitch doesn't much care how you name
+your changes.
+
+
+=item Iterative Development
+
+Up until you L<tag|sqitch-tag> and L<release|sqitch-bundle> your project, you
+can modify your change deployment scripts as often as you like. They're not
+locked in just because they've been committed to your VCS. This allows you to
+take an iterative approach to developing your database schema. Or, better, you
+can do test-driven database development.
+
+=begin comment
+
+=item Bundling
+
+Rely on your VCS history for deployment but have Sqitch bundle up changes for
+distribution. Sqitch can read your VCS history and write out a plan file along
+with the appropriate deployment and reversion scripts. Once the bundle is
+installed on a new system, Sqitch can use the plan file to deploy or the
+changes in the proper order.
+
+=item Reduced Duplication
+
+If you're using a VCS to track your changes, you don't have to duplicate
+entire change scripts for simple changes. As long as the changes are
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence>, you can change
+your code directly, and Sqitch will know it needs to be updated.
+
+=end comment
+
+=back
+
+Ready to get started? Here's where:
+
+=over
+
+=item Sqitch Tutorials
+
+Detailed tutorials demonstrating the creation, development, and maintenance
+of a database with Sqitch.
+
+=over
+
+=item * L<PostgreSQL Tutorial|sqitchtutorial>
+
+=item * L<SQLite Tutorial|sqitchtutorial-sqlite>
+
+=item * L<MySQL Tutorial|sqitchtutorial-mysql>
+
+=item * L<Oracle Tutorial|sqitchtutorial-oracle>
+
+=item * L<Firebird Tutorial|sqitchtutorial-firebird>
+
+=item * L<Vertica Tutorial|sqitchtutorial-vertica>
+
+=item * L<Exasol Tutorial|sqitchtutorial-exasol>
+
+=item * L<Snowflake Tutorial|sqitchtutorial-snowflake>
+
+=back
+
+=item L<PDX.pm Presentation|https://speakerdeck.com/theory/sane-database-change-management-with-sqitch>
+
+Slides from "Sane Database Management with Sqitch", presented to the Portland
+Perl Mongers in January, 2013.
+
+=item L<PDXPUG Presentation|https://vimeo.com/50104469>
+
+Movie of "Sane Database Management with Sqitch", presented to the Portland
+PostgreSQL Users Group in September, 2012.
+
+=item L<Agile Database Development|https://speakerdeck.com/theory/agile-database-development-2ed>
+
+Three-hour tutorial session on using L<Git|https://git-scm.org/>, test-driven
+development with L<pgTAP|https://pgtap.org>, and change management with Sqitch.
+
+=back
+
+=begin comment
+
+Eventually move to L<sqitchtutorial> or L<sqitchintro> or some such.
+
+=end comment
+
+=head2 Terminology
+
+=over
+
+=item C<change>
+
+A named unit of change. A change name must be used in the file names of its
+deploy and a revert scripts. It may also be used in a verify script file
+name.
+
+=item C<tag>
+
+A known deployment state, pointing to a single change, typically corresponding
+to a release. Think of it is a version number or VCS revision. A given point
+in the plan may have any number of tags.
+
+=item C<state>
+
+The current state of the database. This is represented by the most
+recently-deployed change. If the state of the database is the same as the most
+recent change, then it is considered "up-to-date".
+
+=item C<plan>
+
+A list of one or more changes and their dependencies that define the order of
+deployment execution. The plan is stored in a "plan file," usually named
+F<sqitch.plan>. Sqitch reads the plan file to determine what changes to
+execute to change the database from one state to another.
+
+=item C<target>
+
+A named database to which to deploy changes. Always has an associated
+connection URI, and may also have an associated command-line client and
+registry name.
+
+=item C<registry>
+
+The name of the database object where Sqitch's state and history data is
+stored. Typically a schema name (as in PostgreSQL and Oracle) or a database
+name (as in SQLite and MySQL).
+
+=item C<add>
+
+The act of adding a change to the plan. Sqitch will generate scripts for the
+change, which you then may modify with the necessary code (typically DDLs) to
+actually deploy, revert, and verify the change.
+
+=item C<deploy>
+
+The act of deploying changes to a database. Sqitch reads the plan, checks the
+current state of the database, and applies all the changes necessary to either
+bring the database up-to-date or to a requested state (a change name or tag).
+
+=item C<revert>
+
+The act of reverting database changes to reach an earlier deployment state.
+Sqitch reads the list of deployed changes from the database and reverts
+them in the reverse of the order in which they were applied. All changes
+may be reverted, or changes may be reverted to a requested state (a change
+name or tag).
+
+=item C<committer>
+
+User who commits or reverts changes to a database.
+
+=item C<planner>
+
+User who adds a change to the plan.
+
+=back
+
+=head1 Options
+
+ -C --chdir --cd DIR Change to directory before performing any actions.
+ --etc-path Print path to etc directory and exit.
+ --no-pager Do not pipe output into a pager.
+ --quiet Quiet mode with non-error output suppressed.
+ -V --verbose Increment verbosity.
+ --version Print version number and exit.
+ --help Show a list of commands and exit.
+ --man Print introductory documentation and exit.
+
+=head1 Options Details
+
+=over
+
+=item C<--chdir>
+
+=item C<--cd>
+
+=item C<-C>
+
+ sqitch --chdir dbproject
+ sqitch --cd /usr/local/somedb
+ sqitch -C dbcheckout
+
+Change to the specified directory before performing any actions. Effectively the
+same as:
+
+ (cd somedir && sqitch ...)
+
+But a bit friendlier when managing multiple projects.
+
+=item C<--etc-path>
+
+ sqitch --etc-path
+
+Print out the path to the Sqitch F<etc> directory and exit. This is the
+directory where the system-wide configuration file lives, as well as change
+script templates.
+
+=item C<--no-pager>
+
+ sqitch --no-pager
+
+Do not pipe Sqitch output into a pager. Currently limited to the C<log> and
+C<plan> commands.
+
+=item C<--quiet>
+
+ sqitch --quiet
+
+Suppress normal output messages. Error messages will still be emitted to
+C<STDERR>. Overrides any value specified by C<--verbose>.
+
+=item C<-V>
+
+=item C<--verbose>
+
+ sqitch --verbose
+ sqitch -VVV
+
+Pass multiple times to specify a value between 0 and 3 to determine how
+verbose Sqitch should be. Unless C<--quiet> is specified, the default is 1,
+meaning that Sqitch will output basic status messages as it does its thing.
+Values of 2 and 3 each cause greater verbosity. Ignored if C<--quiet> is
+specified.
+
+=item C<--help>
+
+ sqitch --help
+
+Outputs a brief description all known Sqitch commands and exits.
+
+=item C<--man>
+
+ sqitch --man
+
+Outputs this documentation and exits.
+
+=item C<--version>
+
+ sqitch --version
+
+Outputs the program name and version and exits.
+
+=back
+
+=head1 Sqitch Commands
+
+=over
+
+=item L<C<init>|sqitch-init>
+
+Create the plan file and directories for deploy, revert, and verify scripts if
+they do not already exist. This command is useful for starting a new Sqitch
+project.
+
+=item L<C<status>|sqitch-status>
+
+Output information about the current deployment state of a database, including
+the name of the last deployed change, as well as any tags applied to it. If
+any changes in the plan have not been deployed, they will be listed
+separately.
+
+=item L<C<log>|sqitch-log>
+
+Search and Output the complete change history of a database. Provides
+information about when changes were deployed, reverted, or failed, as well as
+who planned and committed the changes, and when.
+
+=item L<C<add>|sqitch-add>
+
+Add a new change.
+
+=item L<C<tag>|sqitch-tag>
+
+List tags or tag the latest change.
+
+=item L<C<rework>|sqitch-rework>
+
+Rework an existing change.
+
+=item L<C<target>|sqitch-target>
+
+Manage target databases.
+
+=item L<C<deploy>|sqitch-deploy>
+
+Deploy changes to a database
+
+=item L<C<revert>|sqitch-revert>
+
+Revert changes from a database.
+
+=item L<C<verify>|sqitch-verify>
+
+Verify changes deployed to a database.
+
+=item L<C<config>|sqitch-config>
+
+Get and set project, user, or system Sqitch options.
+
+=item L<C<bundle>|sqitch-bundle>
+
+Bundle a Sqitch project for distribution. This command copies the Sqitch
+configuration, plan, and deploy, revert, and verify scripts to a directory, so
+that it can be packaged up for distribution, such as in an RPM or tarball.
+
+=item L<C<help>|sqitch-help>
+
+Show help for a specific command or, if no command is specified, show the same
+documentation as C<--help>.
+
+=back
+
+=head1 Configuration
+
+Sqitch configuration can be set up on a project, user, or system-wide basis.
+The format of the configuration file, named F<sqitch.conf>, is the same as for
+L<git>.
+
+Here's an example of a configuration file that might be useful checked into a
+VCS for a project that deploys to PostgreSQL and stores its deployment scripts
+with the extension F<ddl> under the C<migrations> directory. It also wants
+bundle to be created in the F<_build/sql> directory, and to deploy starting
+with the "gamma" tag:
+
+ [core]
+ engine = pg
+ top_dir = migrations
+ extension = ddl
+
+ [engine "pg"]
+ target = widgetopolis
+
+ [revert]
+ to = gamma
+
+ [bundle]
+ from = gamma
+ tags_only = yes
+ dest_dir = _build/sql
+
+ [target "widgetopolis"]
+ uri = db:pg:widgetopolis
+
+And here's an example of useful configuration in F<~/.sqitch/sqitch.conf>, to
+point to system-specific engine information:
+
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+ [engine "pg"]
+ client = /usr/local/pgsql/bin/psql
+
+ [engine "mysql"]
+ client = /usr/local/mysql/bin/mysql
+
+ [engine "oracle"]
+ client = /usr/local/instantclient_11_2/sqlplus
+
+ [engine "sqlite"]
+ client = /usr/local/bin/sqlite3
+
+Various commands read from the configuration file and adjust their operation
+accordingly. See L<sqitch-config> for a list.
+
+=head1 See Also
+
+The original design for Sqitch was sketched out in a number of blog posts:
+
+=over
+
+=item *
+
+L<Simple SQL Change Management|https://justatheory.com/computers/databases/simple-sql-change-management.html>
+
+=item *
+
+L<VCS-Enabled SQL Change Management|https://justatheory.com/computers/databases/vcs-sql-change-management.html>
+
+=item *
+
+L<SQL Change Management Sans Duplication|https://justatheory.com/computers/databases/sql-change-management-sans-redundancy.html>
+
+=back
+
+Other tools that do database change management include:
+
+=over
+
+=item L<Rails migrations|https://guides.rubyonrails.org/migrations.html>
+
+Numbered migrations for L<Ruby on Rails|https://rubyonrails.org/>.
+
+=item L<Module::Build::DB>
+
+Numbered changes in pure SQL, integrated with Perl's L<Module::Build> build
+system. Does not support reversion.
+
+=item L<DBIx::Migration>
+
+Numbered migrations in pure SQL.
+
+=item L<Versioning|https://www.depesz.com/2010/08/22/versioning/>
+
+PostgreSQL-specific dependency-tracking solution by
+L<depesz|https://www.depesz.com/>.
+
+=back
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchchanges.pod b/lib/sqitchchanges.pod
new file mode 100644
index 00000000..55021ddd
--- /dev/null
+++ b/lib/sqitchchanges.pod
@@ -0,0 +1,197 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchchanges - Specifying changes for Sqitch
+
+=head1 Description
+
+Many Sqitch commands take change parameters as arguments. Depending on the
+command, they denote a specific change or, for commands which walk change
+history or the change plan (such as L<C<sqitch log>|sqitch-log>), all changes
+which can be reached from that change. Most commands search the plan for the
+relevant change, though some, such as L<C<sqitch revert>|sqitch-revert> and
+L<C<sqitch log>|sqitch-log>, search the database for the change.
+
+=head2 Change Names
+
+A change name, such as that passed to L<C<sqitch add>|sqitch-add> and written
+to the plan file has a few limitations on the characters it may contain. The
+same limitations apply to tag names. The rules are:
+
+=over
+
+=item *
+
+Must be at least one character.
+
+=item *
+
+Must contain no blank characters.
+
+=item *
+
+The first character may not be punctuation.
+
+=item *
+
+Last letter may not be punctuation.
+
+=item *
+
+Must not end in "~", "^", "/", "=", or "%" followed by digits.
+
+=item *
+
+All other characters may be any UTF-8 character other than ":", "@", and "#".
+
+=back
+
+Note that "_" (underscore) is never considered punctuation. Some examples of
+valid names:
+
+=over
+
+=item C<foo>
+
+=item C<12>
+
+=item C<t>
+
+=item C<6>
+
+=item C<阱阪阬>
+
+=item C<阱阪阬92>
+
+=item C<foo/bar>
+
+=item C<beta1>
+
+=item C<foo_>
+
+=item C<_foo>
+
+=item C<v1.0-1b>
+
+=item C<v1.2-1>
+
+=item C<v1.2+1>
+
+=item C<v1.2_1>
+
+=back
+
+Some examples of invalid names:
+
+=over
+
+=item C<^foo>
+
+=item C<foo^>
+
+=item C<foo^6>
+
+=item C<foo^666>
+
+=item C<%hi>
+
+=item C<hi!>
+
+=item C<foo@bar>
+
+=item C<foo:bar>
+
+=item C<+foo>
+
+=item C<-foo>
+
+=item C<@foo>
+
+=back
+
+=head1 Specifying Changes
+
+A change parameter names a change object. It uses what is called an extended
+SHA1 syntax. Here are various ways to spell change names:
+
+=over
+
+=item C<< <change_name> >>, e.g., C<users_table>
+
+The name of a change itself, as it was added to the plan via
+L<C<sqitch add>|sqitch-add>.
+
+=item C<< @<tag_name> >>, e.g., C<@rc1>
+
+The change as of the named tag. Tags can be added to the plan via
+L<C<sqitch tag>|sqitch-tag>.
+
+=item C<< <change_name>@<tag_name> >>, e.g., C<users_table@beta1>
+
+The named change as of a tag, also known as a tag-qualified change name. For
+change iteration commands (such as L<C<sqitch log>|sqitch-log>), this means
+the instance of a change with that name before the specified tag. For
+dependency parameters (such as in L<C<sqitch add>|sqitch-add>), this means any
+instance of a change just before that tag, or at any time after the tag.
+
+=item C<< <sha1> >>, e.g., C<40763784148fa190d75bad036730ef44d1c2eac6>
+
+The change full SHA1 ID (40-byte hexadecimal string). In some cases, such as
+L<C<sqitch add>|sqitch-add>, the ID may refer to a change in another Sqitch
+project.
+
+=item C<< <project>:<change_name> >>, e.g., C<mybase:users_table>
+
+The name of a change in a specific project. Non-SHA1 change parameters without
+a project prefix are assumed to belong to the current project. Most useful for
+declaring a dependency on a change from another project in
+L<C<sqitch add>|sqitch-add>.
+
+=item C<< <project>:@<tag_name> >>, e.g., C<mybase:@rc1>
+
+The name of a tag in an the named project.
+
+=item C<< <project>:<change_name>@<tag_name> >>, e.g., C<project:users_table@beta1>
+
+A tag-qualified named change in the named project.
+
+=item C<< <project>:<sha1> >>, e.g., C<mybase:40763784148fa190d75bad036730ef44d1c2eac6>
+
+The full SHA1 ID from another project. Probably redundant, since the SHA1 I
+should itself be sufficient. But useful for declaring dependencies in the
+current project so that L<C<sqitch add>|sqitch-add> or
+L<C<sqitch rework>|sqitch-rework> will validate that the specified change is in
+the current project.
+
+=item C<@HEAD>
+
+=item C<HEAD>
+
+Special symbolic name for the last change in the plan.
+
+=item C<@ROOT>
+
+=item C<ROOT>
+
+Special symbolic name for the first change in the plan.
+
+=item C<< <change>^ >>, e.g., C<@HEAD^^>, C<@HEAD^3>, C<@beta^2>
+
+A suffix C<^> to a symbolic or actual name means the change I<prior> to that
+change. Two C<^>s indicate the second prior change. Additional prior changes
+can be specified as C<< ^<n> >>, where C<< <n> >> represents the number of
+changes to go back.
+
+=item C<< <change>~ >>, e.g., C<@ROOT~>, C<@ROOT~~>, C<@bar~4>
+
+A suffix C<~> to a symbolic or actual name means the change I<after> that
+change. Two C<~>s indicate the second following change. Additional following
+changes can be specified as C<< ~<n> >>, where C<< <n> >> represents the
+number of changes to go forward.
+
+=back
+
+=head1 Sqitch
+
+Part of the L<sqitch> suite.
diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod
new file mode 100644
index 00000000..9fc1bb48
--- /dev/null
+++ b/lib/sqitchcommands.pod
@@ -0,0 +1,44 @@
+=begin private
+
+Keep private so it's not displayed, but will still be indexed by the CPAN
+toolchain.
+
+=head1 Name
+
+sqitchcommands - List of common sqitch commands
+
+=end private
+
+=head1 Usage
+
+ sqitch [--etc-path | --help | --man | --version]
+ sqitch <command> [--chdir <path>] [--no-pager] [--quiet] [--verbose]
+ [<command-options>] [<args>]
+
+=head1 Common Commands
+
+The most commonly used sqitch commands are:
+
+ add Add a new change to the plan
+ bundle Bundle a Sqitch project for distribution
+ checkout Revert, checkout another VCS branch, and re-deploy changes
+ config Get and set local, user, or system options
+ deploy Deploy changes to a database
+ engine Manage database engine configuration
+ help Display help information about Sqitch commands
+ init Initialize a project
+ log Show change logs for a database
+ plan Show the contents of a plan
+ rebase Revert and redeploy database changes
+ revert Revert changes from a database
+ rework Duplicate a change in the plan and revise its scripts
+ show Show information about changes and tags, or change script contents
+ status Show the current deployment status of a database
+ tag Add or list tags in the plan
+ target Manage target database configuration
+ upgrade Upgrade the registry to the current version
+ verify Verify changes to a database
+
+See C<< sqitch help <command> >> or C<< sqitch help <concept> >> to read about
+a specific command or concept. See C<< sqitch help --guide >> for a list of
+conceptual guides.
diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod
new file mode 100644
index 00000000..8b195388
--- /dev/null
+++ b/lib/sqitchguides.pod
@@ -0,0 +1,28 @@
+=begin private
+
+Keep private so it's not displayed, but will still be indexed by the CPAN
+toolchain.
+
+=head1 Name
+
+sqitchguides - List of common Sqitch guides
+
+=end private
+
+The common Sqitch guides are:
+
+ changes Specifying changes for Sqitch
+ configuration Hierarchical engine and target configuration
+ environment Environment variables
+ authentication Specifying target authentication credentials
+ tutorial PostgreSQL Tutorial
+ tutorial-firebird Firebird Tutorial
+ tutorial-mysql MySQL Tutorial
+ tutorial-oracle Oracle Tutorial
+ tutorial-sqlite SQLite Tutorial
+ tutorial-vertica Vertica Tutorial
+ tutorial-exasol Exasol Tutorial
+ tutorial-snowflake Snowflake Tutorial
+
+See C<< sqitch help <command> >> or C<< sqitch help <concept> >> to read about
+a specific command or concept.
diff --git a/lib/sqitchtutorial-exasol.pod b/lib/sqitchtutorial-exasol.pod
new file mode 100644
index 00000000..e6672fe7
--- /dev/null
+++ b/lib/sqitchtutorial-exasol.pod
@@ -0,0 +1,1407 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial-exasol - A tutorial introduction to Sqitch change management on Exasol
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled Exasol project, use a
+VCS for deployment planning, and work with other developers to make sure
+changes remain in sync and in the proper order.
+
+We'll start by creating a new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<Exasol|https://www.exasol.com/> as the storage engine, but for
+the most part you can substitute other VCSes and database engines in the
+examples as appropriate.
+
+If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>.
+
+If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>.
+
+If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>.
+
+If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>.
+
+If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>.
+
+If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>.
+
+If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>.
+
+=head2 Connection Configuration
+
+Sqitch requires ODBC to connect to the Exasol database. As such, you'll need
+to make sure that the Exasol ODBC driver is properly configured. At its
+simplest, on Unix-like systems, name the driver "Exasol" by adding this entry
+to C<odbcinst.ini> (usually found in C</etc>, C</usr/etc>, or
+C</usr/local/etc>):
+
+ [Exasol]
+ Description = ODBC for Exasol
+ Driver = /opt/EXASOL_ODBC-6.0.4/lib/linux/x86_64/libexaodbc-uo2214lv2.so
+
+Note that you'll need to adjust the path depending on the version of the ODBC
+driver, and where you installed it.
+
+You might also consider naming your database connection by putting an entry in
+C<~/.odbc.ini>, like so (assuming that Exasol is running on your local host):
+
+ [flipr_test]
+ Driver = Exasol
+ EXAHOST = 127.0.0.1:8563
+ EXAUID = sys
+ EXAPWD = exasol
+
+Putting user and password information here is optional, but probably safer than
+other available options as long as the file is protected (mode 0600) so that
+only you can read it.
+
+Normally, of course, you'd have a separate user per project (and a separate
+ODBC connection defined); the above example is taken from the simplified
+tutorial setup we're using.
+
+See the
+L<Exasol downloads|https://www.exasol.com/portal/display/DOWNLOAD/6.0> for
+ODBC drivers, documentation, etc.
+
+=head2 Database Setup
+
+While installing and configuring an Exasol instance is beyond the scope of this
+tutorial, if you just want to follow along, you can start a Docker instance:
+
+ > docker run --detach --privileged --stop-timeout 120 -p 127.0.0.1:8563:8888 exasol/docker-db:latest
+
+This will make Exasol available at 127.0.0.1 (localhost) on port 8563, as if
+you were running it directly on your machine. You may have to wait for a minute
+until the database finished initializing; you can try connecting with `exaplus`
+to check status.
+
+See the corresponding L<GitHub repository|https://github.com/EXASOL/docker-db>
+for more information about how to run Exasol using Docker, for example if you
+want your data to persist beyond the lifetime of the container, etc.
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -am 'Initialize project, add README.'
+
+If you're a Git user and want to follow along the history, the repository
+used in these examples is
+L<on GitHub|https://github.com/sqitchers/sqitch-exasol-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ > sqitch init flipr --uri https://github.com/sqitchers/sqitch-exasol-intro/ --engine exasol
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = exasol
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "exasol"]
+ # target = db:exasol:
+ # registry = sqitch
+ # client = exaplus
+
+Good, it picked up on the fact that we're creating changes for the Exasol
+engine, thanks to the C<--engine exasol> option, and saved it to the
+file. Furthermore, it wrote a commented-out C<[engine "exasol"]> section with
+all the available Exasol engine-specific settings commented out and ready to
+be edited as appropriate.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings. Since Exasol's C<exaplus> client is not in the path on my system,
+let's go ahead an tell it where to find the client on our computer:
+
+ > sqitch config --user engine.exasol.client /opt/EXAplus/exaplus
+
+And let's also tell it who we are, since this data will be used in all
+of our projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [engine "exasol"]
+ client = /opt/EXAplus/exaplus
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch should be able to find C<exaplus> for any project, and
+that it will always properly identify us when planning and committing changes.
+
+Back to the repository. Have a look at the plan file, F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-exasol-intro/
+
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -am 'Initialize Sqitch configuration.'
+ [master a42564d] Initialize Sqitch configuration.
+ 2 files changed, 16 insertions(+), 0 deletions(-)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+=head1 Our First Change
+
+First, our project will need a schema. This creates a nice namespace for all
+of the objects that will be part of the flipr app. Run this command:
+
+ > sqitch add appschema -n 'Add schema for all flipr objects.'
+ Created deploy/appschema.sql
+ Created revert/appschema.sql
+ Created verify/appschema.sql
+ Added "appschema" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the schema. So we add
+this to F<deploy/appschema.sql>:
+
+ CREATE SCHEMA flipr;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we add this to F<revert/appschema.sql>:
+
+ DROP SCHEMA flipr;
+
+Now we can try deploying this change. We tell Sqitch where to send the change
+via a L<database URI|https://github.com/libwww-perl/uri-db/>, assuming the default
+C<sys> user and an ODBC driver named C<Exasol> (see
+L</Connection Configuration> for details):
+
+ > sqitch deploy 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ Adding registry tables to db:exasol://sys:@localhost:8563/?Driver=Exasol
+ Deploying changes to db:exasol://sys:@localhost:8563/?Driver=Exasol
+ + appschema .. ok
+
+First Sqitch created registry tables used to track database changes. The
+structure and name of the registry varies between databases (Exasol uses a
+schema to namespace its registry, while SQLite and MySQL use separate
+databases). Next, Sqitch deploys changes. We only have one so far; the C<+>
+reinforces the idea that the change is being C<added> to the database.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the schema:
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "select schema_name from exa_schemas;"
+
+ SCHEMA_NAME
+ --------------------------------------------------------------------------------------------------------------------------------
+ SQITCH
+ FLIPR
+
+=head2 Trust, But Verify
+
+But that's too much work. Do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did was it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. In Exasol, the simplest way to do so for schema is probably to
+simply create an object in the schema. Put this SQL into
+F<verify/appschema.sql>:
+
+ CREATE TABLE flipr.verify__ (id int);
+ DROP TABLE flipr.verify__;
+
+In truth, you can use I<any> query that generates an SQL error if the schema
+doesn't exist. Another handy way to do that is to divide by zero if an object
+doesn't exist. For example, to throw an error when the C<flipr> schema does
+not exist, you could do something like this:
+
+ SELECT 1/COUNT(*) FROM exa_schemas WHERE schema_name = 'FLIPR';
+
+Either way, run the C<verify> script with the L<C<verify>|sqitch-verify>
+command:
+
+ > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol
+ * appschema .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the schema doesn't exist, temporarily change the schema name in the script to
+something that doesn't exist, something like:
+
+ CREATE TABLE nonesuch.verify__ (id int);
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol
+ * appschema .. Error: [42000] schema NONESUCH not found [line 1, column 40] (Session: 1582884049218108749)
+
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+It's even nice enough to tell us what the problem is. Or, for the
+divide-by-zero example, change the schema name:
+
+ SELECT 1/COUNT(*) FROM exa_schemas WHERE schema_name = 'nonesuch';
+
+Then the verify will look something like:
+
+ > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol
+ * appschema .. Error: [22012] data exception - division by zero (Session: 1582884446489810101)
+
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+Less useful error output, but enough to alert us that something has gone
+wrong.
+
+Don't forget to change the schema name back before continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the registry
+tables from the database:
+
+ > sqitch status 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ # On database db:exasol://sys:@localhost:8563/?Driver=Exasol
+ # Project: flipr
+ # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ # Name: appschema
+ # Deployed: 2014-09-04 15:26:28 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ Revert all changes from db:exasol://sys:@localhost:8563/?Driver=Exasol? [Yes]
+ - appschema .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the schema should be gone:
+
+> exaplus -q -u sys -p exasol -c localhost:8563 -sql "select schema_name from exa_schemas;"
+
+ SCHEMA_NAME
+ --------------------------------------------------------------------------------------------------------------------------------
+ SQITCH
+
+And the status message should reflect as much:
+
+ > sqitch status 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ # On database db:exasol://sys:@localhost:8563/?Driver=Exasol
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ On database db:exasol://sys:@localhost:8563/?Driver=Exasol
+ Revert f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2014-09-04 16:33:02 -0700
+
+ Add schema for all flipr objects.
+
+ Deploy f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2014-09-04 15:26:28 -0700
+
+ Add schema for all flipr objects.
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Now let's commit it.
+
+ > git add .
+ > git commit -m 'Add flipr schema.'
+ [master 9bee4bd] Add flipr schema.
+ 5 files changed, 197 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/appschema.sql
+ create mode 100644 revert/appschema.sql
+ create mode 100644 sqitch.sql
+ create mode 100644 verify/appschema.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy --verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ Deploying changes to db:exasol://sys:@localhost:8563/?Driver=Exasol
+ + appschema .. ok
+
+And now the schema should be back:
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "select schema_name from exa_schemas;"
+
+ SCHEMA_NAME
+ --------------------------------------------------------------------------------------------------------------------------------
+ SQITCH
+ FLIPR
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+ # On database db:exasol://sys:@localhost:8563/?Driver=Exasol
+ # Project: flipr
+ # Change: fef4c2911ae68aee8f6ea164293a32923dc13b67
+ # Name: appschema
+ # Deployed: 2014-09-04 16:37:38 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type
+C<db:exasol://sys:exasol@localhost:8563/?Driver=Exasol>, aren't
+you? This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells
+Sqitch how to connect to the deployment target, but we don't have to keep
+using the URI. We can name the target:
+
+ > sqitch target add flipr_test 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol'
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also tell
+Sqitch to deploy to the C<flipr_test> target by default:
+
+ > sqitch engine add exasol flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ # Name: appschema
+ # Deployed: 2014-09-04 16:37:38 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and and make some more changes!
+
+ > git commit -am 'Set default deployment target and always verify.'
+ [master 469779a] Set default deployment target and always verify.
+ 1 files changed, 8 insertions(+), 0 deletions(-)
+
+=head1 Deploy with Dependency
+
+Let's add another change, this time to create a table. Our app will need
+users, of course, so we'll create a table for them. First, add the new change:
+
+ > sqitch add users --requires appschema -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users [appschema]" to sqitch.plan
+
+Note that we're requiring the C<appschema> change as a dependency of the new
+C<users> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<users> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/users.sql> should look like
+this:
+
+ -- Deploy flipr:users to exasol
+ -- requires: appschema
+
+ CREATE TABLE flipr.users (
+ nickname VARCHAR(64) PRIMARY KEY,
+ password VARCHAR(256) NOT NULL,
+ fullname VARCHAR(256) NOT NULL,
+ twitter VARCHAR(256) NOT NULL,
+ ts TIMESTAMP WITH LOCAL TIME ZONE DEFAULT NOW() NOT NULL
+ );
+
+ COMMIT;
+
+A few things to notice here. On the second line, the dependence on the
+C<appschema> change has been listed. This doesn't do anything, but the default
+C<deploy> Exasol template lists it here for your reference while editing
+the file. Useful, right?
+
+The table itself will be created in the C<flipr> schema. This is why we need
+to require the C<appschema> change.
+
+Now for the verify script. The simplest way to check that the table was
+created and has the expected columns without touching the data? Just select
+from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>:
+
+ SELECT nickname, password, fullname, twitter, ts
+ FROM flipr.users
+ WHERE FALSE;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/users.sql>:
+
+ DROP TABLE flipr.users;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.users;"
+
+ COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY
+ -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ----------------
+ NICKNAME VARCHAR(64) UTF8 FALSE FALSE
+ PASSWORD VARCHAR(256) UTF8 FALSE FALSE
+ FULLNAME VARCHAR(256) UTF8 FALSE FALSE
+ TWITTER VARCHAR(256) UTF8 FALSE FALSE
+ TS TIMESTAMP WITH LOCAL TIME ZONE FALSE FALSE
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 794a6c78816543909d592e2e9f5c0fade5b47406
+ # Name: users
+ # Deployed: 2017-11-02 11:02:40 +0100
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to appschema from flipr_test
+ - users .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<appschema>, the penultimate change.
+The other potentially useful symbolic tag is C<@ROOT>, which refers to the
+first change deployed to the database (or in the plan, depending on the
+command).
+
+Back to the database. The C<users> table should be gone but the C<flipr> schema
+should still be around:
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.users;"
+ Error: [42000] table or view FLIPR.USERS not found [line 1, column 10] (Session: 1582958508294847446)
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ # Name: appschema
+ # Deployed: 2014-09-04 16:37:38 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * users
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ Undeployed change:
+ * users
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "users" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add users table.'
+ [master c7c24c5] Add users table.
+ 4 files changed, 18 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d647ac8c130a7e0b12c9049789e46afb4a4f6e53
+ # Name: users
+ # Deployed: 2014-09-04 17:42:53 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Excellent. Let's do some more!
+
+=head1 Add Two at Once
+
+Let's add a couple more changes. Our app will need to store status messages
+from users. Let's call them -- and the table to store them -- "flips". And
+we'll also need a view that lists user names with their flips. Let's add
+changes for them both:
+
+ > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [appschema users]" to sqitch.plan
+
+ > sqitch add userflips -r appschema -r users -r flips \
+ -n 'Creates the userflips view.'
+ Created deploy/userflips.sql
+ Created revert/userflips.sql
+ Created verify/userflips.sql
+ Added "userflips [appschema users flips]" to sqitch.plan
+
+Now might be a good time to have a look at the deployment plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-exasol-intro/
+
+ appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+
+Each change appears on a single line with the name of the change, a bracketed
+list of dependencies, a timestamp, the name and email address of the user who
+planned the change, and a note.
+
+Let's write the code for the new changes. Here's what F<deploy/flips.sql>
+should look like:
+
+ -- Deploy flipr:flips to exasol
+ -- requires: appschema
+ -- requires: users
+
+ CREATE TABLE flipr.flips (
+ id INTEGER IDENTITY PRIMARY KEY,
+ nickname VARCHAR(64) NOT NULL REFERENCES flipr.users(nickname),
+ body VARCHAR(180) DEFAULT '' NOT NULL,
+ ts TIMESTAMP WITH LOCAL TIME ZONE DEFAULT NOW() NOT NULL
+ );
+
+ COMMIT;
+
+Here's what F<verify/flips.sql> might look like:
+
+ -- Verify flipr:flips on exasol
+
+ SELECT id, nickname, body, ts
+ FROM flipr.flips
+ WHERE FALSE;
+
+ ROLLBACK;
+
+And F<revert/flips.sql> should look something like this:
+
+ -- Revert flipr:flips from exasol
+
+ DROP TABLE flipr.flips;
+
+ COMMIT;
+
+Now for C<userflips>; F<deploy/userflips.sql> might look like this:
+
+ -- Deploy flipr:userflips to exasol
+ -- requires: appschema
+ -- requires: users
+ -- requires: flips
+
+ CREATE OR REPLACE VIEW flipr.userflips AS
+ SELECT f.id, u.nickname, u.fullname, f.body, f.ts
+ FROM flipr.users u
+ JOIN flipr.flips f ON u.nickname = f.nickname;
+
+ COMMIT;
+
+Use a C<SELECT> statement in F<verify/userflips.sql> again:
+
+ -- Verify flipr:userflips on exasol
+
+ SELECT id, nickname, fullname, body, ts
+ FROM flipr.userflips
+ WHERE FALSE;
+
+ ROLLBACK;
+
+And of course, its C<revert> script, F<revert/userflips.sql>, should look
+something like:
+
+ -- Revert flipr:userflips from exasol
+
+ DROP VIEW flipr.userflips;
+
+ COMMIT;
+
+Try em out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips ...... ok
+ + userflips .. ok
+
+Do we have the new table and view? Of course we do, they were verified. Still,
+have a look:
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "select table_name from exa_all_tables where table_schema = 'FLIPR';"
+
+ TABLE_NAME
+ --------------------------------------------------------------------------------------------------------------------------------
+ USERS
+ FLIPS
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;"
+
+ COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY
+ -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ----------------
+ ID DECIMAL(18,0)
+ NICKNAME VARCHAR(64) UTF8
+ FULLNAME VARCHAR(256) UTF8
+ BODY VARCHAR(180) UTF8
+ TS TIMESTAMP WITH LOCAL TIME ZONE
+
+And what's the status?
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d1f998618fb863d93049a724fd0d2b49a29add86
+ # Name: userflips
+ # Deployed: 2014-09-04 17:51:21 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Looks good. Let's make sure revert works:
+
+ > sqitch revert -y --to @HEAD^^
+ Reverting changes to users from flipr_test
+ - userflips .. ok
+ - flips ...... ok
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.flips;"
+ Error: [42000] table or view FLIPR.FLIPS not found [line 1, column 10] (Session: 1582955242708359302)
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;"
+ Error: [42000] table or view FLIPR.USERFLIPS not found [line 1, column 10] (Session: 1582955248116468907)
+
+Note the use of C<@HEAD^^> to specify that the revert be to two changes prior
+the last deployed change. Looks good. Let's do the commit and re-deploy dance:
+
+ > git add .
+ > git commit -m 'Add flips table and userflips view.'
+ [master c40f23f] Add flips table and userflips view.
+ 7 files changed, 41 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 deploy/userflips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 revert/userflips.sql
+ create mode 100644 verify/flips.sql
+ create mode 100644 verify/userflips.sql
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips ...... ok
+ + userflips .. ok
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d1f998618fb863d93049a724fd0d2b49a29add86
+ # Name: userflips
+ # Deployed: 2014-09-04 17:59:34 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ * flips ...... ok
+ * userflips .. ok
+ Verify successful
+
+Great, we're fully up-to-date!
+
+=head1 Ship It!
+
+Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want
+to have it go out with deployments tied to the release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "userflips" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master b07ce3d] Tag the database with v1.0.0-dev1.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+We can try deploying to make sure the tag gets picked up like so:
+
+ > sqitch deploy
+ Nothing to deploy (up-to-date)
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d1f998618fb863d93049a724fd0d2b49a29add86
+ # Name: userflips
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2014-09-04 17:59:34 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the new "Tag" line in the output of C<sqitch status>: no new changes
+needed to be deployed, but Sqitch did deploy the tag on the C<userflips>
+change. Now let's bundle everything up for release:
+
+ > sqitch bundle
+ Bundling into bundle
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. Let's try
+deploying it to another database. First, start up a new Docker instance on a
+different port:
+
+ > docker run --detach --privileged --stop-timeout 120 -p 127.0.0.1:9999:8888 exasol/docker-db:latest
+
+Now you should be able to deploy to the new database:
+
+ > cd bundle
+ > sqitch deploy 'db:exasol://sys:exasol@localhost:9999/?Driver=Exasol'
+ Adding registry tables to db:exasol://sys:@localhost:9999/?Driver=Exasol
+ Deploying changes to db:exasol://sys:@localhost:9999/?Driver=Exasol
+ + appschema ............... ok
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+
+Notice how the tag on C<userflips> now appears in the deploy output. Nice, eh?
+Now, package it up and ship it!
+
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Making a Hash of Things
+
+Now that we've got the basics of the app done, let's add a feature. Gotta
+track the hashtags associated with flips, right? Let's add a table for them.
+But since other folks are working on other tasks in the repository, we'll work
+on a branch, so we can all stay out of each other's way. So let's branch:
+
+ > git checkout -b hashtags
+ Switched to a new branch 'hashtags'
+
+Now we can add a new change to create a table for hashtags.
+
+ > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.'
+ Created deploy/hashtags.sql
+ Created revert/hashtags.sql
+ Created verify/hashtags.sql
+ Added "hashtags [appschema flips]" to sqitch.plan
+
+You know the drill by now. Add this to F<deploy/hashtags.sql>
+
+ CREATE TABLE flipr.hashtags (
+ flip_id INTEGER NOT NULL REFERENCES flipr.flips(id),
+ hashtag VARCHAR(128) NOT NULL,
+ PRIMARY KEY (flip_id, hashtag)
+ );
+
+Again, select from the table in F<verify/hashtags.sql>:
+
+ SELECT flip_id, hashtag FROM flipr.hashtags WHERE FALSE;
+
+And drop it in F<revert/hashtags.sql>
+
+ DROP TABLE flipr.hashtags;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: fda6daef73e0ac12252bf6af5f259ccb207d4197
+ # Name: hashtags
+ # Deployed: 2014-09-05 10:46:20 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2014-09-05 09:09:38 -0700 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Make sure we can
+revert, too:
+
+ > sqitch rebase -y --onto @HEAD^
+ Reverting changes to userflips @v1.0.0-dev1 from flipr_test
+ - hashtags .. ok
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Great! Now make it so:
+
+ > git add .
+ > git commit -m 'Add hashtags table.'
+ [hashtags d893e9c] Add hashtags table.
+ 4 files changed, 18 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating b07ce3d..05d3e5d
+ Fast-forward
+ deploy/lists.sql | 10 ++++++++++
+ revert/lists.sql | 3 +++
+ sqitch.plan | 2 ++
+ verify/lists.sql | 5 +++++
+ 4 files changed, 20 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff hashtags
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<hashtags> branch added changes to the plan. Let's
+try a different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<hashtags> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at 05d3e5d Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout hashtags
+ Switched to branch 'hashtags'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ <stdin>:16: new blank line at EOF.
+ +
+ warning: 1 line adds whitespace errors.
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Failed to merge in the changes.
+ Patch failed at 0001 Add hashtags table.
+
+ When you have resolved this problem run "git rebase --continue".
+ If you would prefer to skip this patch, instead run "git rebase --skip".
+ To restore the original branch and stop rebasing run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to
+L<its docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+ HEAD is now at d893e9c Add hashtags table.
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ <stdin>:16: new blank line at EOF.
+ +
+ warning: 1 line adds whitespace errors.
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-exasol-intro/
+
+ appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "hashtags" branch. Test it to make sure it works
+as expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - hashtags ................ ok
+ - userflips @v1.0.0-dev1 .. ok
+ - flips ................... ok
+ - users ................... ok
+ - appschema ............... ok
+ Deploying changes to flipr_test
+ + appschema ............... ok
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+ + lists ................... ok
+ + hashtags ................ ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [hashtags 2f065a3] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff hashtags -m "Merge branch 'hashtags'"
+ Merge made by recursive.
+ .gitattributes | 1 +
+ deploy/hashtags.sql | 10 ++++++++++
+ revert/hashtags.sql | 3 +++
+ sqitch.plan | 1 +
+ verify/hashtags.sql | 3 +++
+ 5 files changed, 18 insertions(+), 0 deletions(-)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-exasol-intro/
+
+ appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "hashtags" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 8a6a73b] Tag the database with v1.0.0-dev2.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+ + lists
+ + hashtags @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Well, some folks have been testing the C<1.0.0-dev2> release and have demanded
+that Twitter user links be added to Flipr pages. Why anyone would want to
+include social network links in an anti-social networking app is beyond us
+programmers, but we're just the plumbers, right? Gotta go with what Product
+demands. The upshot is that we need to update the C<userflips> view, which is
+used for the feature in question, to include the Twitter user names.
+
+Normally, modifying views in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>.
+
+=item 2.
+
+Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the
+C<twitter> column to the view.
+
+=item 3.
+
+Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>.
+
+=item 5.
+
+Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>.
+
+=item 6.
+
+Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter>
+column.
+
+=item 7.
+
+Test the changes to make sure you can deploy and revert the
+C<userflips_twitter> change.
+
+=back
+
+But you can have Sqitch do most of the work for you. The only requirement is
+that a tag appear between the two instances of a change we want to modify. In
+general, you're going to make a change like this after a release, which you've
+tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>:
+
+ > sqitch rework userflips -n 'Adds userflips.twitter.'
+ Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/userflips.sql
+ * revert/userflips.sql
+ * verify/userflips.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<userflips> change, which we can see via C<git status>:
+
+ > git status
+ # On branch master
+ # Changed but not updated:
+ # (use "git add <file>..." to update what will be committed)
+ # (use "git checkout -- <file>..." to discard changes in working directory)
+ #
+ # modified: revert/userflips.sql
+ # modified: sqitch.plan
+ #
+ # Untracked files:
+ # (use "git add <file>..." to include in what will be committed)
+ #
+ # deploy/userflips@v1.0.0-dev2.sql
+ # revert/userflips@v1.0.0-dev2.sql
+ # verify/userflips@v1.0.0-dev2.sql
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "untracked files" part of the output is the first thing to notice. They're
+all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the C<userflips>
+change as it was implemented as of the C<@v1.0.0-dev2> tag." These are copies
+of the original scripts, and thereafter Sqitch will find them when it needs to
+run scripts for the first instance of the C<userflips> change. As such, it's
+important not to change them again. But hey, if you're reworking the change,
+you shouldn't need to.
+
+The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch
+replaced it with the original deploy script. As of now,
+F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on
+the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before. Of course, the original deploy script may not be
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. If it's not, you will likely need to modify it so that it
+properly restores things to how they were after the original deploy script was
+deployed. Or, more simply, it should revert changes back to how they were
+as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>.
+
+Fortunately, our view deploy scripts are already idempotent, thanks to the
+use of the C<OR REPLACE> expression. No matter how many times a deployment
+script is run, the end result will be the same instance of the view, with
+no duplicates or errors.
+
+As a result, there is no need to explicitly add changes. So go ahead. Modify
+the script to add the C<twitter> column to the view. Make this change to
+F<deploy/userflips.sql>:
+
+ @@ -4,6 +4,6 @@
+ -- requires: flips
+
+ CREATE OR REPLACE VIEW flipr.userflips AS
+ -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp
+ FROM flipr.users u
+ JOIN flipr.flips f ON u.nickname = f.nickname;
+
+Next, modify F<verify/userflips.sql> to check for the C<twitter> column.
+Here's the diff:
+
+ @@ -1,6 +1,6 @@
+ -- Verify flipr:userflips on exasol
+
+ -SELECT id, nickname, fullname, body, timestamp
+ +SELECT id, nickname, fullname, twitter, body, timestamp
+ FROM flipr.userflips
+ WHERE FALSE;
+
+Now try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + userflips .. ok
+
+So, are the changes deployed?
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;"
+
+ COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY
+ -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ----------------
+ ID DECIMAL(18,0)
+ NICKNAME VARCHAR(64) UTF8
+ FULLNAME VARCHAR(256) UTF8
+ TWITTER VARCHAR(256) UTF8
+ BODY VARCHAR(180) UTF8
+ TS TIMESTAMP WITH LOCAL TIME ZONE
+
+Awesome, the view now includes the C<twitter> column. But can we revert?
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to hashtags @v1.0.0-dev2 from flipr_test
+ - userflips .. ok
+
+Did that work, is the C<twitter> column gone?
+
+ > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;"
+
+ COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY
+ -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ----------------
+ ID DECIMAL(18,0)
+ NICKNAME VARCHAR(64) UTF8
+ FULLNAME VARCHAR(256) UTF8
+ BODY VARCHAR(180) UTF8
+ TS TIMESTAMP WITH LOCAL TIME ZONE
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+Excellent. Let's go ahead and commit these changes:
+
+ > git add .
+ > git commit -m 'Add the twitter column to the userflips view.'
+ [master 95d6dd0] Add the twitter column to the userflips view.
+ 7 files changed, 30 insertions(+), 4 deletions(-)
+ create mode 100644 deploy/userflips@v1.0.0-dev2.sql
+ create mode 100644 revert/userflips@v1.0.0-dev2.sql
+ create mode 100644 verify/userflips@v1.0.0-dev2.sql
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchtutorial-firebird.pod b/lib/sqitchtutorial-firebird.pod
new file mode 100644
index 00000000..706bae26
--- /dev/null
+++ b/lib/sqitchtutorial-firebird.pod
@@ -0,0 +1,1264 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial-firebird - A tutorial introduction to Sqitch change management on Firebird
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled Firebird project, use a
+VCS for deployment planning, and work with other developers to make sure
+changes remain in sync and in the proper order.
+
+We'll start by creating new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<Firebird|https://www.firebirdsql.org/> as the storage engine.
+
+If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>.
+
+If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>.
+
+If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>.
+
+If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>.
+
+If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>.
+
+If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>.
+
+If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>.
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -am 'Initialize project, add README.'
+ [master (root-commit) 761ffcd] Initialize project, add README.
+ 1 files changed, 39 insertions(+), 0 deletions(-)
+ create mode 100644 README.md
+
+If you're a Git user and want to follow along the history, the
+repository used in these examples is
+L<on GitHub|https://github.com/sqitchers/sqitch-firebird-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ > sqitch init flipr --uri https://github.com/sqitchers/sqitch-firebird-intro/ --engine firebird
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = firebird
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "firebird"]
+ # target = db:firebird:
+ # registry = sqitch
+ # client = isql-fb
+
+Good, it picked up on the fact that we're creating changes for the Firebird
+engine, thanks to the C<--engine firebird> option, and saved it to the
+file. Furthermore, it wrote a commented-out C<[engine "firebird"]> section
+with all the available Firebird engine-specific settings commented out and
+ready to be edited as appropriate.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings.
+
+The current implementation of the engine will try to find Firebird's
+L<C<firebird> client|https://www.firebirdsql.org/manual/isql-commands.html>
+(implemented for GNU/Linux and Microsoft Windows). This might fail,
+so we go ahead an tell it where to find the client on our computer,
+for example on GNU/Linux with the standard location of the Firebird
+installation the command is:
+
+ > sqitch config --user engine.firebird.client /opt/firebird/bin/isql
+
+Note: On some GNU/Linux distributions the firebird client is renamed
+to C<isql-fb>, for example in Debian and Fedora, or C<fbsql> in
+Gentoo.
+
+And let's also tell it who we are, since this data will be used in all
+of our projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see something like
+this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [engine "firebird"]
+ client = /opt/local/bin/isql
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch should be able to find C<isql> for any project, and
+that it will always properly identify us when planning and committing changes.
+
+Back to the repository. Have a look at the plan file, F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-firebird-intro/
+
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -am 'Initialize Sqitch configuration.'
+ [master 2177ce4] Initialize Sqitch configuration.
+ 2 files changed, 19 insertions(+), 0 deletions(-)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+Let's create our flipr test database using C<isql>:
+
+ > sudo -u firebird mkdir /tmp/flipr_test
+ > echo "CREATE DATABASE 'localhost:/tmp/flipr_test/flipr.fdb'; exit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+
+=head1 Our First Change
+
+Let's create a table. Our app will need users, of course, so we'll create a
+table for them. Run this command:
+
+ > sqitch add users -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the table. By default,
+the F<deploy/users.sql> file looks like this:
+
+ -- Deploy flipr:users to firebird
+
+ -- XXX Add DDLs here.
+
+ COMMIT;
+
+What we want to do is to replace the C<XXX> comment with the C<CREATE TABLE>
+statement, like so:
+
+ -- Deploy flipr:users to firebird
+
+ CREATE TABLE users (
+ nickname VARCHAR(50) PRIMARY KEY,
+ password VARCHAR(512) NOT NULL,
+ fullname VARCHAR(512) NOT NULL,
+ twitter VARCHAR(512) NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
+ );
+
+ COMMIT;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we edit this to F<revert/users.sql> to look like this:
+
+ -- Revert flipr:users from firebird
+
+ DROP TABLE users;
+
+ COMMIT;
+
+Now we can try deploying this change. We tell Sqitch where to send the change
+via a L<database URI|https://github.com/libwww-perl/uri-db/>. Here we've
+specified a database file, F</tmp/flipr_test/flipr.fdb>:
+
+ > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ Adding registry tables to db:firebird://sysdba:@localhost//tmp/flipr_test/sqitch.fdb
+ Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ + users .. ok
+
+First Sqitch created the registry database and tables used to track database
+changes. The registry is separate from the database to which the C<users>
+change was deployed; by default, its name is C<sqitch.$suffix>, where
+C<$suffix> is the same as the suffix on the target database, if any. It lives
+in the same directory as the target database, which means that one registry
+database is used for all the databases with the same suffix in a single
+directory. In this case, we should end up with two databases:
+
+=over
+
+=item * F</tmp/flipr_test/sqitch.fdb>
+
+The Sqitch registry database.
+
+=item * F</tmp/flipr_test/flipr.fdb>
+
+The database Sqitch manages.
+
+=back
+
+Next, Sqitch deploys changes to the target database. We only have one change
+so far; the C<+> reinforces the idea that the change is being I<added> to the
+database.
+
+If you'd like it to have a different name for the registry database, use
+C<sqitch engine add firebird $name> to configure it (or via the
+L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be
+useful if you don't want to use the same registry database to manage multiple
+databases, or if you do, but they live in different directories.
+
+Next, Sqitch deploys changes to the target database, which we specified on the
+command-line. We only have one so far; the C<+> reinforces the idea that the
+change is being I<added> to the database.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the C<users> table:
+
+ > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ USERS
+
+=head2 Trust, But Verify
+
+But that's too much work. do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did was it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. The easiest way to do that with a table is to simply C<SELECT>
+from it. Put this query into F<verify/users.sql>:
+
+ SELECT nickname, password, fullname, twitter
+ FROM users
+ WHERE 1=2;
+
+Now you can run the C<verify> script with the L<C<verify>|sqitch-verify>
+command:
+
+ > sqitch verify db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ Verifying db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ * users .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the table doesn't exist, temporarily change the table name in the script to
+something that doesn't exist, something like:
+
+ SELECT nickname, password, fullname, twitter, created_at
+ FROM users_nonesuch
+ WHERE 1=2;
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ Verifying db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ * users .. Statement failed, SQLSTATE = 42S02
+ Dynamic SQL Error
+ -SQL error code = -204
+ -Table unknown
+ -USERS_NONESUCH
+ -At line 3, column 2
+ At line 3 in file verify/users.sql
+ # Verify script "verify/users.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+Firebird is kind enough to tell us what the problem is. Don't forget to change
+the table name back before continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the tables
+from the registry database:
+
+ > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ # On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ # Project: flipr
+ # Change: 2cde9cc8c19161e9837de57741502243b2ad380e
+ # Name: users
+ # Deployed: 2014-01-05 14:05:22 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ Revert all changes from db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb? [Yes]
+ - users .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the C<users> table should be gone:
+
+ > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ There are no tables in this database
+
+And the status message should reflect as much:
+
+ > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ # On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ Verifying db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ Revert 2cde9cc8c19161e9837de57741502243b2ad380e
+ Name: users
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2014-01-05 14:06:59 -0800
+
+ Creates table to track our users.
+
+ Deploy 2cde9cc8c19161e9837de57741502243b2ad380e
+ Name: users
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2014-01-05 14:05:22 -0800
+
+ Creates table to track our users.
+
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Now let's commit it.
+
+ > git add .
+ > git commit -m 'Add users table.'
+ [master ec72105] Add users table.
+ 4 files changed, 24 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb --verify
+ Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ + users .. ok
+
+And now the C<users> table should be back:
+
+ > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ USERS
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+ # On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb
+ # Project: flipr
+ # Change: 2cde9cc8c19161e9837de57741502243b2ad380e
+ # Name: users
+ # Deployed: 2014-01-05 14:19:32 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type
+C<db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb>, aren't
+you? This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells
+Sqitch how to connect to the deployment target, but we don't have to keep
+using the URI. We can name the target:
+
+ > sqitch target add flipr_test db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also tell
+Sqitch to deploy to the C<flipr_test> target by default:
+
+ > sqitch engine add firebird target flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 2cde9cc8c19161e9837de57741502243b2ad380e
+ # Name: users
+ # Deployed: 2014-01-05 14:19:32 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and and make some more changes!
+
+ > git commit -am 'Set default target and always verify.'
+ [master cfc9fea] Set default target and always verify.
+ 1 files changed, 8 insertions(+), 0 deletions(-)
+
+=head1 Deploy with Dependency
+
+Let's add another change. Our app will need to store status messages from
+users. Let's call them -- and the table to store them -- "flips". First, add
+the new change:
+
+ > sqitch add flips --requires users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [users]" to sqitch.plan
+
+Note that we're requiring the C<users> change as a dependency of the new
+C<flips> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<flips> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/flips.sql> should look like
+this:
+
+ -- Deploy flipr:flips to firebird
+ -- requires: users
+
+ CREATE TABLE flips (
+ id INTEGER NOT NULL PRIMARY KEY,
+ nickname VARCHAR(50) NOT NULL REFERENCES users(nickname),
+ body VARCHAR(512) DEFAULT '' NOT NULL CHECK ( char_length(body) <= 180 ),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
+ );
+
+ COMMMIT;
+
+A couple things to notice here. On the second line, the dependence on the
+C<users> change has been listed. This doesn't do anything, but the default
+C<deploy> template lists it here for your reference while editing the file.
+Useful, right?
+
+The C<users.nickname> column references the C<users> table. This is why we
+need to require the C<users> change.
+
+Now for the verify script. Again, all we need to do is C<SELECT> from the
+table. I recommend selecting each column by name, too, to be sure that no
+column is missing. Here's the F<verify/flips.sql>:
+
+ -- Verify flipr:flips on firebird
+
+ SELECT id, nickname, body, created_at
+ FROM flips
+ WHERE 1=2;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/flips.sql>:
+
+ -- Revert flipr:flips from firebird
+
+ DROP TABLE flips;
+
+ COMMIT;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ FLIPS USERS
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test.db
+ * users .. ok
+ * flips .. ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: dfe72351c686bd36017a2b586042b5336301e809
+ # Name: flips
+ # Deployed: 2014-01-05 14:22:33 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to users from flipr_test
+ - flips .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<users>, the penultimate change. The
+other potentially useful symbolic tag is C<@ROOT>, which refers to the first
+change deployed to the database (or in the plan, depending on the command).
+
+Back to the database. The C<flips> table should be gone but the
+C<users> table should still be around:
+
+ > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ USERS
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 2cde9cc8c19161e9837de57741502243b2ad380e
+ # Name: users
+ # Deployed: 2014-01-05 14:19:32 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * flips
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * users .. ok
+ Undeployed change:
+ * flips
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "flips" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add flips table.'
+ [master 09c636c] Add flips table.
+ 4 files changed, 24 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 verify/flips.sql
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: dfe72351c686bd36017a2b586042b5336301e809
+ # Name: flips
+ # Deployed: 2014-01-05 14:24:06 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 View to a Thrill
+
+One more thing to add before we are ready to ship a first beta release. Let's
+create a view that lists user names with their flips.
+
+ > sqitch add userflips --requires users --requires flips \
+ -n 'Creates the userflips view.'
+ Created deploy/userflips.sql
+ Created revert/userflips.sql
+ Created verify/userflips.sql
+ Added "userflips [users flips]" to sqitch.plan
+
+Now add this SQL to F<deploy/userflips.sql>:
+
+ CREATE OR ALTER VIEW userflips AS
+ SELECT f.id, u.nickname, u.fullname, f.body, f.created_at
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname;
+
+Add this SQL to F<verify/userflips.sql>
+
+ SELECT id, nickname, fullname, body, created_at
+ FROM userflips
+ WHERE 1=2;
+
+And add the C<DROP VIEW> statement to F<revert/userflips.sql>:
+
+ DROP VIEW userflips;
+
+Now Try it out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + userflips .. ok
+ > sqitch revert -y
+ Reverting all changes from flipr_test
+ - userflips .. ok
+ - flips ...... ok
+ - users ...... ok
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users ...... ok
+ + flips ...... ok
+ + userflips .. ok
+
+Looks good! Commit it.
+
+ > git add .
+ > git commit -m 'Add the userflips view.'
+ [master 28ffa63] Add the userflips view.
+ 4 files changed, 23 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/userflips.sql
+ create mode 100644 revert/userflips.sql
+ create mode 100644 verify/userflips.sql
+
+=head1 Ship It!
+
+Now we're ready for the first development release of our app. Let's call it
+C<1.0.0-dev1> Since we want to have it go out with deployments tied to the
+release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "userflips" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master 696a891] Tag the database with v1.0.0-dev1.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+We can try deploying to make sure the tag gets picked up like so:
+
+ > sudo -u firebird mkdir /tmp/flipr_dev
+ > echo "CREATE DATABASE 'localhost:/tmp/flipr_dev/flipr.fdb'; exit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_dev/flipr.fdb
+ Adding registry tables to db:firebird://sysdba:@localhost//tmp/flipr_dev/sqitch.fdb
+ Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_dev/flipr.fdb
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+
+Great, both changes were deployed and C<userflips> was tagged with
+C<@v1.0.0-dev1>. Let's have a look at the status:
+
+ > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_dev/flipr.fdb
+ # On database db:firebird://sysdba:@localhost//tmp/flipr_dev/flipr.fdb
+ # Project: flipr
+ # Change: 785a0d14a5e26b2ae24882a137db45d34f71b5ff
+ # Name: userflips
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2014-01-05 14:43:28 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the listing of the tag as part of the status message. Now let's bundle
+everything up for release:
+
+ > sqitch bundle
+ Bundling into bundle
+ Writing config
+ Writing plan
+ Writing scripts
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. Let's try
+deploying it:
+
+ > cd bundle
+ > sudo -u firebird mkdir /tmp/flipr_prod
+ > echo "CREATE DATABASE 'localhost:/tmp/flipr_prod/flipr.fdb'; exit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_prod/flipr.fdb
+ Adding registry tables to db:firebird://sysdba:@localhost//tmp/flipr_prod/sqitch.fdb
+ Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_prod/flipr.fdb
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+
+Looks much the same as before, eh? Package it up and ship it!
+
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Making a Hash of Things
+
+Now that we've got the basics of the app done, let's add a feature. Gotta
+track the hashtags associated with flips, right? Let's add a table for them.
+But since other folks are working on other tasks in the repository, we'll work
+on a branch, so we can all stay out of each other's way. So let's branch:
+
+ > git checkout -b hashtags
+ Switched to a new branch 'hashtags'
+
+Now we can add a new change to create a table for hashtags.
+
+ > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.'
+ Created deploy/hashtags.sql
+ Created revert/hashtags.sql
+ Created verify/hashtags.sql
+ Added "hashtags [flips]" to sqitch.plan
+
+You know the drill by now. Add this to F<deploy/hashtags.sql>
+
+ CREATE TABLE hashtags (
+ flip_id INTEGER NOT NULL REFERENCES flips(id),
+ hashtag VARCHAR(512) NOT NULL CHECK(char_length(hashtag) > 0),
+ PRIMARY KEY (flip_id, hashtag)
+ );
+
+Again, select from the table in F<verify/hashtags.sql>:
+
+ SELECT flip_id, hashtag FROM hashtags WHERE 1=2;
+
+And drop it in F<revert/hashtags.sql>
+
+ DROP TABLE hashtags;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: 9474af3b057294633ccf81b9e8d7771a9588ac67
+ # Name: hashtags
+ # Deployed: 2014-01-05 14:55:56 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2014-01-05 14:49:56 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Now make it so:
+
+ > rm -rf flipr-v1.0.0-dev1*
+ > git add .
+ > git commit -am 'Add hashtags table.'
+ [hashtags 9c40bf5] Add hashtags table.
+ 4 files changed, 22 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating 696a891..9af80a1
+ Fast-forward
+ deploy/lists.sql | 11 +++++++++++
+ revert/lists.sql | 5 +++++
+ sqitch.plan | 2 ++
+ verify/lists.sql | 7 +++++++
+ 4 files changed, 25 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff hashtags
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<hashtags> branch added changes to the plan. Let's
+try a different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<hashtags> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at d5e7e86 Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout hashtags
+ Switched to branch 'hashtags'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Failed to merge in the changes.
+ Patch failed at 0001 Add hashtags table.
+ The copy of the patch that failed is found in:
+ .git/rebase-apply/patch
+
+ When you have resolved this problem, run "git rebase --continue".
+ If you prefer to skip this patch, run "git rebase --skip" instead.
+ To check out the original branch and stop rebasing, run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to L<its
+docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-firebird-intro/
+
+ users 2014-01-05T22:01:30Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [users] 2014-01-05T22:21:24Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [users flips] 2014-01-05T22:40:29Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2014-01-05T22:42:36Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [flips] 2014-01-05T22:44:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [flips] 2014-01-05T22:54:27Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "hashtags" branch. Test it to make sure it works
+as expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - hashtags ................ ok
+ - userflips @v1.0.0-dev1 .. ok
+ - flips ................... ok
+ - users ................... ok
+ Deploying changes to flipr_test
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+ + lists ................... ok
+ + hashtags ................ ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [hashtags 52ed9a2] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff hashtags -m "Merge branch 'hashtags'"
+ Merge made by recursive.
+ .gitattributes | 1 +
+ deploy/hashtags.sql | 10 ++++++++++
+ revert/hashtags.sql | 5 +++++
+ sqitch.plan | 1 +
+ verify/hashtags.sql | 5 +++++
+ 5 files changed, 22 insertions(+), 0 deletions(-)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-firebird-intro/
+
+ users 2014-01-05T22:01:30Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [users] 2014-01-05T22:21:24Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [users flips] 2014-01-05T22:40:29Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2014-01-05T22:42:36Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [flips] 2014-01-05T22:44:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [flips] 2014-01-05T22:54:27Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "hashtags" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 7d07ee3] Tag the database with v1.0.0-dev2.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+ + lists
+ + hashtags @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Well, some folks have been testing the C<1.0.0-dev2> release and have demanded
+that Twitter user links be added to Flipr pages. Why anyone would want to
+include social network links in an anti-social networking app is beyond us
+programmers, but we're just the plumbers, right? Gotta go with what Marketing
+demands. The upshot is that we need to update the C<userflips> view, which is
+used for the feature in question, to include the Twitter user names.
+
+Normally, modifying views in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>.
+
+=item 2.
+
+Edit F<deploy/userflips_twitter.sql> to re-create the view with the
+new C<twitter> column added to the view.
+
+=item 3.
+
+Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>.
+
+=item 5.
+
+Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>.
+
+=item 6.
+
+Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter>
+column.
+
+=item 7.
+
+Test the changes to make sure you can deploy and revert the
+C<userflips_twitter> change.
+
+=back
+
+But you can have Sqitch do most of the work for you. The only requirement is
+that a tag appear between the two instances of a change we want to modify. In
+general, you're going to make a change like this after a release, which you've
+tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>:
+
+ > sqitch rework userflips -n 'Adds userflips.twitter.'
+ Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/userflips.sql
+ * revert/userflips.sql
+ * verify/userflips.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<userflips> change, which we can see via C<git status>:
+
+ > git status
+ # On branch master
+ # Your branch is ahead of 'origin/master' by 4 commits.
+ # (use "git push" to publish your local commits)
+ #
+ # Changes not staged for commit:
+ # (use "git add <file>..." to update what will be committed)
+ # (use "git checkout -- <file>..." to discard changes in working directory)
+ #
+ # modified: revert/userflips.sql
+ # modified: sqitch.plan
+ #
+ # Untracked files:
+ # (use "git add <file>..." to include in what will be committed)
+ #
+ # deploy/userflips@v1.0.0-dev2.sql
+ # revert/userflips@v1.0.0-dev2.sql
+ # verify/userflips@v1.0.0-dev2.sql
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "untracked files" part of the output is the first thing to notice. They
+are all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the
+C<userflips> change as it was implemented as of the C<@v1.0.0-dev2> tag."
+These are copies of the original scripts, and thereafter Sqitch will find them
+when it needs to run scripts for the first instance of the C<userflips>
+change. As such, it's important not to change them again. But hey, if you're
+reworking the change, you shouldn't need to.
+
+The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch
+replaced it with the original deploy script. As of now,
+F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on
+the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before.
+
+Fortunately, our view deploy scripts are already almost
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. If it's not, you will likely need to modify it so that it
+properly restores things to how they were after the original deploy script was
+deployed. Or, more simply, it should revert changes back to how they were
+as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>.
+
+Fortunately, our view deploy scripts are already idempotent, thanks to the
+use of the C<OR ALTER> expression. No matter how many times a deployment
+script is run, the end result will be the same instance of the view, with
+no duplicates or errors.
+
+As a result, there is no need to explicitly add changes. So go ahead. Modify
+F<deploy/userflips.sql> to add the C<twitter> column.
+
+ @@ -3,7 +3,7 @@
+ -- requires: flips
+
+ CREATE OR ALTER VIEW userflips AS
+ -SELECT f.id, u.nickname, u.fullname, f.body, f.created_at
+ +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.created_at
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname;
+
+
+Next, modify F<verify/userflips.sql> to check for the C<twitter> column.
+Here's the diff:
+
+ @@ -1,6 +1,6 @@
+ -- Verify flipr:userflips on firebird
+
+ -SELECT id, nickname, fullname, body, created_at
+ +SELECT id, nickname, twitter, fullname, body, created_at
+ FROM userflips
+ WHERE 1=2;
+
+
+Now try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + userflips .. ok
+
+So, are the changes deployed?
+
+ > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW VIEW userflips; quit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ ID INTEGER Not Null
+ NICKNAME VARCHAR(50) Not Null
+ FULLNAME VARCHAR(512) Not Null
+ TWITTER VARCHAR(512) Not Null
+ BODY VARCHAR(512) Not Null
+ CREATED_AT TIMESTAMP Not Null
+ View Source:
+ ==== ======
+
+ SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.created_at
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname
+
+Awesome, the view now includes the C<twitter> column. But can we revert?
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to hashtags @v1.0.0-dev2 from flipr_test
+ - userflips .. ok
+
+Did that work, is the C<twitter> column gone?
+
+ > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW VIEW userflips; quit;" \
+ | isql-fb -q -u SYSDBA -p masterkey
+ ID INTEGER Not Null
+ NICKNAME VARCHAR(50) Not Null
+ FULLNAME VARCHAR(512) Not Null
+ BODY VARCHAR(512) Not Null
+ CREATED_AT TIMESTAMP Not Null
+ View Source:
+ ==== ======
+
+ SELECT f.id, u.nickname, u.fullname, f.body, f.created_at
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+Excellent. Let's go ahead and commit these changes:
+
+ > rm -rf flipr-1.0.0-dev2
+ > git add .
+ > git commit -m 'Add the twitter column to the userflips view.'
+ [master f530359] Add the twitter column to the userflips view.
+ 7 files changed, 32 insertions(+), 4 deletions(-)
+ create mode 100644 deploy/userflips@v1.0.0-dev2.sql
+ create mode 100644 revert/userflips@v1.0.0-dev2.sql
+ create mode 100644 verify/userflips@v1.0.0-dev2.sql
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Authors
+
+=over
+
+=item * Ștefan Suciu <stefbv70@gmail.com>
+
+=item * David E. Wheeler <david@justatheory.com>
+
+=back
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchtutorial-mysql.pod b/lib/sqitchtutorial-mysql.pod
new file mode 100644
index 00000000..8959d0fe
--- /dev/null
+++ b/lib/sqitchtutorial-mysql.pod
@@ -0,0 +1,1724 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial-mysql - A tutorial introduction to Sqitch change management on MySQL
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled MySQL project, use a VCS
+for deployment planning, and work with other developers to make sure changes
+remain in sync and in the proper order.
+
+We'll start by creating new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<MySQL|https://dev.mysql.com/> as the storage engine.
+
+If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>.
+
+If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>.
+
+If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>.
+
+If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>.
+
+If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>.
+
+If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>.
+
+If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>.
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -am 'Initialize project, add README.'
+ [master (root-commit) fdf2a40] Initialize project, add README.
+ 1 file changed, 38 insertions(+)
+ create mode 100644 README.md
+
+If you're a Git user and want to follow along the history, the repository used
+in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-mysql-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ > sqitch init flipr --uri https://github.com/sqitchers/sqitch-mysql-intro/ --engine mysql
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = mysql
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "mysql"]
+ # target = db:mysql:
+ # registry = sqitch
+ # client = mysql
+
+Good, it picked up on the fact that we're creating changes for the MySQL
+engine, thanks to the C<--engine mysql> option, and saved it to the file.
+Furthermore, it wrote a commented-out C<[engine "mysql"]> section with all the
+available MySQL engine-specific settings commented out and ready to be edited
+as appropriate.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings. Since MySQL's
+L<C<mysql> client|https://dev.mysql.com/doc/refman/5.6/en/mysql.html> is not
+in the path on my system, let's go ahead an tell it where to find the client
+on our computer:
+
+ > sqitch config --user engine.mysql.client /usr/local/mysql/bin/mysql
+
+And let's also tell it who we are, since this data will be used in all
+of our projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [engine "mysql"]
+ client = /usr/local/mysql/bin/mysql
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch should be able to find C<mysql> for any project, and
+that it will always properly identify us when planning and committing changes.
+
+Back to the repository. Have a look at the plan file, F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-mysql-intro/
+
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -am 'Initialize Sqitch configuration.'
+ [master 79fe2cc] Initialize Sqitch configuration.
+ 2 files changed, 19 insertions(+)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+=head1 Our First Change
+
+First, our app will need a database user, so let's create one. Run this
+command:
+
+ > sqitch add appuser -n 'Creates a an application user.'
+ Created deploy/appuser.sql
+ Created revert/appuser.sql
+ Created verify/appuser.sql
+ Added "appuser" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the table. By default,
+the F<deploy/appuser.sql> file looks like this:
+
+ -- Deploy flipr:appuser to mysql
+
+ BEGIN;
+
+ -- XXX Add DDLs here.
+
+ COMMIT;
+
+What we want to do is to replace the C<XXX> comment with the C<CREATE USER>
+statement, like so:
+
+ -- Deploy flipr:users to mysql
+
+ BEGIN;
+
+ CREATE USER flipr;
+
+ COMMIT;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we edit this to F<revert/appuser.sql> to look like this:
+
+ -- Revert flipr:users from mysql
+
+ BEGIN;
+
+ DROP USER flipr;
+
+ COMMIT;
+
+Now we can try deploying this change. First, we need to create a database
+to deploy to:
+
+ > mysql -u root --execute 'CREATE DATABASE flipr_test'
+
+Now we tell Sqitch where to send the change via a
+L<database URI|https://github.com/libwww-perl/uri-db/>:
+
+ > sqitch deploy db:mysql://root@/flipr_test
+ Deploying changes to db:mysql://root@/flipr_test
+ + appuser .. ok
+
+First Sqitch created the registry database and tables used to track database
+changes. The registry database is separate from the database to which the
+C<appuser> change was deployed; by default, its name is C<sqitch>, and will be
+used to manage I<all> projects on a single MySQL server. Ideally, only Sqitch
+data will be stored in this database, so it probably makes the most sense to
+create a superuser named C<sqitch> or something similar and use it to deploy
+changes.
+
+If you'd like it to use a different database as the registry database, use
+C<sqitch engine add mysql $name> to configure it (or via the
+L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be
+useful if you don't want to use the same registry database to manage multiple
+databases on the same server.
+
+Next, Sqitch deploys changes to the target database, which we specified on the
+command-line. We only have one change so far; the C<+> reinforces the idea
+that the change is being I<added> to the database.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the user:
+
+ > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';"
+ +-------+
+ | User |
+ +-------+
+ | flipr |
+ +-------+
+
+=head2 Trust, But Verify
+
+But that's too much work. do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did was it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. The simplest way to see if a user exists is to check the
+C<mysql.user> table. However, throwing an error in the event that the user
+does not exist is tricky in MySQL. To simplify things, on MySQL 5.5.0 and
+higher, Sqitch provides a custom function you can use in your tests,
+C<checkit()>. It works kind of like a C<CHECK> constraint in other databases:
+pass an expression as the first argument, and an error message as the second.
+If the expression evaluates to false, an exception will be thrown with the
+error message.
+
+Give it a try. Put this query into F<verify/appuser.sql>:
+
+ SELECT sqitch.checkit(COUNT(*), 'User "flipr" does not exist')
+ FROM mysql.user WHERE user = 'flipr';
+
+This will work well as long as we know that the registry database is named
+C<sqitch>. If you've set C<engine.mysql.registry> to a different value, you
+will need to make sure you specify the correct database name in the script.
+
+Now you can run the C<verify> script with the L<C<verify>|sqitch-verify>
+command:
+
+ > sqitch verify db:mysql://root@/flipr_test
+ Verifying flipr_test
+ * appuser .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the table doesn't exist, temporarily change the user name in the script to
+something that doesn't exist, something like:
+
+ SELECT sqitch.checkit(COUNT(*), 'User "flipr" does not exist')
+ FROM mysql.user WHERE user = 'nonesuch';
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify db:mysql://root@/flipr_test
+ Verifying db:mysql://root@/flipr_test
+ * appuser .. ERROR 1644 (ERR0R) at line 5 in file: 'verify/appuser.sql': User "flipr" does not exist
+ # Verify script "verify/appuser.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+The C<checkit()> function is kind enough to use the error message to tell us
+what the problem is. Don't forget to change the table name back before
+continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the tables
+from the registry database:
+
+ > sqitch status db:mysql://root@/flipr_test
+ # On database db:mysql://root@/flipr_test
+ # Project: flipr
+ # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2
+ # Name: appuser
+ # Deployed: 2013-12-31 13:13:17 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert db:mysql://root@/flipr_test
+ Revert all changes from db:mysql://root@/flipr_test? [Yes]
+ - appuser .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the schema should be gone:
+
+ > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';"
+
+And the status message should reflect as much:
+
+ > sqitch status db:mysql://root@/flipr_test
+ # On database db:mysql://root@/flipr_test
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify db:mysql://root@/flipr_test
+ Verifying db:mysql://root@/flipr_test
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log db:mysql://root@/flipr_test
+ On database db:mysql://root@/flipr_test
+ Revert f56dd1a1ab029f398cec2cebb2ecc527fa0332c2
+ Name: appuser
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-31 13:26:39 -0800
+
+ Creates a an application user.
+
+ Deploy f56dd1a1ab029f398cec2cebb2ecc527fa0332c2
+ Name: appuser
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-31 13:13:17 -0800
+
+ Creates a an application user.
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Now let's commit it.
+
+ > git add .
+ > git commit -m 'Add the "flipr" user.'
+ [master c63acb9] Add the "flipr" user.
+ 4 files changed, 23 insertions(+)
+ create mode 100644 deploy/appuser.sql
+ create mode 100644 revert/appuser.sql
+ create mode 100644 verify/appuser.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy --verify db:mysql://root@/flipr_test
+ Deploying changes to db:mysql://root@/flipr_test
+ + appuser .. ok
+
+And now the C<flipr> user should be back:
+
+ > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';"
+ +-------+
+ | user |
+ +-------+
+ | flipr |
+ +-------+
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status db:mysql://root@/flipr_test
+ # On database db:mysql://root@/flipr_test
+ # Project: flipr
+ # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2
+ # Name: appuser
+ # Deployed: 2013-12-31 13:28:23 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type
+C<db:mysql://root@/flipr_test>, aren't you? This
+L<database connection URI|https://github.com/libwww-perl/uri-db/> tells Sqitch how
+to connect to the deployment target, but we don't have to keep using the URI.
+We can name the target:
+
+ > sqitch target add flipr_test db:mysql://root@/flipr_test
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also tell
+Sqitch to deploy to the C<flipr_test> target by default:
+
+ > sqitch engine add mysql flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2
+ # Name: appuser
+ # Deployed: 2013-12-31 13:28:23 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and and make some more changes!
+
+ > git commit -am 'Set default target and always verify.'
+ [master c793050] Set default target and always verify.
+ 1 file changed, 8 insertions(+)
+
+=head1 Deploy with Dependency
+
+Let's add another change, this time to create a table. Our app will need
+users, of course, so we'll create a table for them. First, add the new change:
+
+ > sqitch add users --requires appuser -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users [appuser]" to sqitch.plan
+
+Note that we're requiring the C<appuser> change as a dependency of the new
+C<users> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<users> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/users.sql> should look like
+this:
+
+ -- Deploy flipr:users to mysql
+ -- requires: appuser
+
+ BEGIN;
+
+ CREATE TABLE users (
+ nickname VARCHAR(512) PRIMARY KEY,
+ password VARCHAR(512) NOT NULL,
+ timestamp DATETIME(6) NOT NULL
+ );
+
+ GRANT SELECT ON TABLE users TO flipr;
+
+ COMMIT;
+
+A few things to notice here. On the second line, the dependence on the
+C<appuser> change has been listed. This doesn't do anything, but the default
+MySQL C<deploy> template lists it here for your reference while editing the
+file. Useful, right?
+
+The C<flipr> user has been granted C<SELECT> access to the table. The app
+needs to read the data, right? This is why we need to require the C<appuser>
+change.
+
+Now for the verify script. The simplest way to check that the table was
+created and has the expected columns without touching the data? Just select
+from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>:
+
+ SELECT nickname, password, timestamp
+ FROM users
+ WHERE 0;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/users.sql>:
+
+ DROP TABLE users;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > mysql -u root -D flipr_test --execute 'SHOW TABLES'
+ +----------------------+
+ | Tables_in_flipr_test |
+ +----------------------+
+ | users |
+ +----------------------+
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appuser .. ok
+ * users .... ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 2bd1190fdb324c2609f0c7f0cef73d8cb434ba0e
+ # Name: users
+ # Deployed: 2013-12-31 13:34:25 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to appuser from flipr_test
+ - users .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<appuser>, the penultimate change. The
+other potentially useful symbolic tag is C<@ROOT>, which refers to the first
+change deployed to the database (or in the plan, depending on the command).
+
+Back to the database. The C<users> table should be gone but the C<flipr> user
+should still be around:
+
+ > mysql -u root -D flipr_test --execute 'SHOW TABLES'
+ > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';"
+ +-------+
+ | User |
+ +-------+
+ | flipr |
+ +-------+
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2
+ # Name: appuser
+ # Deployed: 2013-12-31 13:28:23 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * users
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appuser .. ok
+ Undeployed change:
+ * users
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "users" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add users table.'
+ [master 7c99fb0] Add users table.
+ 4 files changed, 31 insertions(+)
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 2bd1190fdb324c2609f0c7f0cef73d8cb434ba0e
+ # Name: users
+ # Deployed: 2013-12-31 13:37:02 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Excellent. Let's do some more!
+
+=head1 Add Two at Once
+
+Let's add a couple more changes to add functions for managing users.
+
+ > sqitch add insert_user --requires users --requires appuser \
+ -n 'Creates a function to insert a user.'
+ Created deploy/insert_user.sql
+ Created revert/insert_user.sql
+ Created verify/insert_user.sql
+ Added "insert_user [users appuser]" to sqitch.plan
+
+ > sqitch add change_pass --requires users --requires appuser \
+ -n 'Creates a function to change a user password.'
+ Created deploy/change_pass.sql
+ Created revert/change_pass.sql
+ Created verify/change_pass.sql
+ Added "change_pass [users appuser]" to sqitch.plan
+
+Now might be a good time to have a look at the deployment plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-mysql-intro/
+
+ appuser 2013-12-31T21:04:04Z Marge N. O’Vera <marge@example.com> # Creates a an application user.
+ users [appuser] 2013-12-31T21:32:48Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appuser] 2013-12-31T21:37:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appuser] 2013-12-31T21:37:36Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+
+Each change appears on a single line with the name of the change, a bracketed
+list of dependencies, a timestamp, the name and email address of the user who
+planned the change, and a note.
+
+Let's write the code for the new changes. Here's what
+F<deploy/insert_user.sql> should look like:
+
+ -- Deploy flipr:insert_user to mysql
+ -- requires: users
+ -- requires: appuser
+
+ BEGIN;
+
+ DELIMITER //
+
+ CREATE PROCEDURE insert_user(
+ nickname VARCHAR(512),
+ password VARCHAR(512)
+ ) SQL SECURITY DEFINER
+ BEGIN
+ INSERT INTO users (nickname, password, timestamp)
+ VALUES (nickname, md5(password), UTC_TIMESTAMP(6));
+ END
+ //
+
+ DELIMITER ;
+
+ GRANT EXECUTE ON PROCEDURE insert_user to flipr;
+
+ COMMIT;
+
+Here's what F<verify/insert_user.sql> might look like, using the Sqitch
+C<checkit()> function again:
+
+ -- Verify flipr:insert_user on mysql
+
+ BEGIN;
+
+ SELECT sqitch.checkit(COUNT(*), 'Procedure "insert_user" does not exist')
+ FROM mysql.proc
+ WHERE db = database()
+ AND specific_name = 'insert_user';
+
+ ROLLBACK;
+
+We simply take advantage of the fact that the new procedure should be listed
+in the C<mysql.proc> table and throw an exception if it does not exist.
+
+And F<revert/insert_user.sql> should look something like this:
+
+ -- Revert flipr:insert_user from mysql
+ BEGIN;
+ DROP PROCEDURE insert_user;
+ COMMIT;
+
+Now for C<change_pass>; F<deploy/change_pass.sql> might look like this:
+
+ -- Deploy flipr:change_pass to mysql
+ -- requires: users
+ -- requires: appuser
+
+ BEGIN;
+
+ DELIMITER //
+
+ CREATE FUNCTION change_pass(
+ nickname VARCHAR(512),
+ oldpass VARCHAR(512),
+ newpass VARCHAR(512)
+ ) RETURNS INTEGER SQL SECURITY DEFINER
+ BEGIN
+ UPDATE users
+ SET password = md5(newpass)
+ WHERE nickname = nickname
+ AND password = md5(oldpass);
+ RETURN ROW_COUNT();
+ END;
+ //
+
+ DELIMITER ;
+
+ GRANT EXECUTE ON FUNCTION change_pass to flipr;
+
+ COMMIT;
+
+Use C<checkit()> in F<verify/change_pass.sql> again:
+
+ BEGIN;
+ SELECT sqitch.checkit(COUNT(*), 'Procedure "change_pass" does not exist')
+ FROM mysql.proc
+ WHERE db = database()
+ AND specific_name = 'change_pass';
+ COMMIT;
+
+And of course, its C<revert> script, F<revert/change_pass.sql>, should look
+something like:
+
+ -- Revert flipr:change_pass from mysql
+ BEGIN;
+ DROP FUNCTION change_pass;
+ COMMIT;
+
+Try em out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+Do we have the functions? Of course we do, they were verified. Still, have a
+look:
+
+ > mysql -u root --execute "SELECT name FROM mysql.proc WHERE db = 'flipr_test'"
+ +-------------+
+ | name |
+ +-------------+
+ | change_pass |
+ | insert_user |
+ +-------------+
+
+And what's the status?
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: b0a598b91ce97cf1b95ded97a6452bf03231a2cd
+ # Name: change_pass
+ # Deployed: 2013-12-31 13:39:49 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Looks good. Let's make sure revert works:
+
+ > sqitch revert -y --to @HEAD^^
+ Reverting changes to users from flipr_test
+ - change_pass .. ok
+ - insert_user .. ok
+ > mysql -u root --execute "SELECT name FROM mysql.proc WHERE db = 'flipr_test'"
+
+Note the use of C<@HEAD^^> to specify that the revert be to two changes prior
+the last deployed change. Looks good. Let's do the commit and re-deploy dance:
+
+ > git add .
+ > git commit -m 'Add `insert_user()` and `change_pass()`.'
+ [master 0f95e13] Add `insert_user()` and `change_pass()`.
+ 7 files changed, 86 insertions(+)
+ create mode 100644 deploy/change_pass.sql
+ create mode 100644 deploy/insert_user.sql
+ create mode 100644 revert/change_pass.sql
+ create mode 100644 revert/insert_user.sql
+ create mode 100644 verify/change_pass.sql
+ create mode 100644 verify/insert_user.sql
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: b0a598b91ce97cf1b95ded97a6452bf03231a2cd
+ # Name: change_pass
+ # Deployed: 2013-12-31 13:40:40 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+ > sqitch verify
+ Verifying flipr_test
+ * appuser ...... ok
+ * users ........ ok
+ * insert_user .. ok
+ * change_pass .. ok
+ Verify successful
+
+Great, we're fully up-to-date!
+
+=head1 Ship It!
+
+Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want
+to have it go out with deployments tied to the release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "change_pass" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master 0595297] Tag the database with v1.0.0-dev1.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+Now let's bundle everything up for release:
+
+ > sqitch bundle
+ Bundling into bundle/
+ Writing config
+ Writing plan
+ Writing scripts
+ + appuser
+ + users
+ + insert_user
+ + change_pass @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. We ought
+to try deploying it, but first we'll need to revert our existing databases, as
+a single Sqitch project cannot be deployed to two databases on the same server
+unless it uses a different registry database and the C<checkit()> function is
+not used in verify scripts. We have used C<checkit()> quite a bit, so we need
+to keep the Sqitch database name just where it is. Fortunately, it's easy to
+build the database again, so let's just revert it.
+
+ > sqitch revert -y
+ Reverting all changes from flipr_test
+ - change_pass .. ok
+ - insert_user .. ok
+ - users ........ ok
+ - appuser ...... ok
+
+Now we can try deploying the bundle:
+
+ > cd bundle
+ > mysql -u root --execute 'CREATE DATABASE flipr_dev'
+ > sqitch deploy db:mysql://root@/flipr_dev
+ Deploying changes to db:mysql://root@/flipr_dev
+ + appuser ................... ok
+ + users ..................... ok
+ + insert_user ............... ok
+ + change_pass @v1.0.0-dev1 .. ok
+
+Great, all four changes were deployed and C<change_pass> was tagged with
+C<@v1.0.0-dev1>. Let's have a look at the status:
+
+ > sqitch status db:mysql://root@/flipr_dev
+ # On database db:mysql://root@/flipr_dev
+ # Project: flipr
+ # Change: b0a598b91ce97cf1b95ded97a6452bf03231a2cd
+ # Name: change_pass
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2013-12-31 13:44:04 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Looks good, eh? Go ahead and revert it:
+
+ > sqitch revert -y db:mysql://root@/flipr_dev
+ Reverting all changes from db:mysql://root@/flipr_dev
+ - change_pass @v1.0.0-dev1 .. ok
+ - insert_user ............... ok
+ - users ..................... ok
+ - appuser ................... ok
+
+Now package it up and ship it!
+
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Flip Out
+
+Now that we've got the basics of user management done, let's get to work on
+the core of our product, the "flip." Since other folks are working on other
+tasks in the repository, we'll work on a branch, so we can all stay out of
+each other's way. So let's branch:
+
+ > git checkout -b flips
+ Switched to a new branch 'flips'
+
+Now we can add a new change to create a table for our flips.
+
+ > sqitch add flips -r appuser -r users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [appuser users]" to sqitch.plan
+
+You know the drill by now. Edit F<deploy/flips.sql>:
+
+ -- Deploy flipr:flips to mysql
+ -- requires: appuser
+ -- requires: users
+
+ BEGIN;
+
+ CREATE TABLE flips (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ nickname VARCHAR(512) NOT NULL REFERENCES users(nickname),
+ body VARCHAR(180) NOT NULL,
+ timestamp DATETIME(6) NOT NULL
+ );
+
+ GRANT SELECT ON TABLE flips TO flipr;
+
+ COMMIT;
+
+Edit F<verify/flips.sql>:
+
+ -- Verify flipr:flips on mysql
+
+ BEGIN;
+
+ SELECT id
+ , nickname
+ , body
+ , timestamp
+ FROM flipr.flips
+ WHERE 0;
+
+ ROLLBACK;
+
+And edit F<revert/flips.sql>:
+
+ -- Revert flipr:flips from mysql
+
+ BEGIN;
+
+ DROP TABLE flips;
+
+ COMMIT;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + appuser ................... ok
+ + users ..................... ok
+ + insert_user ............... ok
+ + change_pass @v1.0.0-dev1 .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: b3ccd37da58ac232c23edfa0adaf2d6f483842fd
+ # Name: flips
+ # Deployed: 2013-12-31 13:55:04 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2013-12-31 13:55:04 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Now make it so:
+
+ > git add .
+ > git commit -am 'Add flips table.'
+ [flips ce1b53d] Add flips table.
+ 4 files changed, 37 insertions(+)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 verify/flips.sql
+
+=head1 Wash, Rinse, Repeat
+
+Now comes the time to add functions to manage flips. I'm sure you have things
+nailed down now. Go ahead and add C<insert_flip> and C<delete_flip> changes
+and commit them. The C<insert_flip> deploy script might look something like:
+
+ -- Deploy flipr:insert_flip to mysql
+ -- requires: flips
+ -- requires: appuser
+
+ BEGIN;
+
+ DELIMITER //
+
+ CREATE FUNCTION insert_flip(
+ nickname VARCHAR(512),
+ body VARCHAR(180)
+ ) RETURNS BIGINT SQL SECURITY DEFINER
+ BEGIN
+ INSERT INTO flips (nickname, body)
+ VALUES (nickname, body);
+ RETURN LAST_INSERT_ID();
+ END;
+ //
+
+ DELIMITER ;
+
+ GRANT EXECUTE ON FUNCTION insert_flip to flipr;
+
+ COMMIT;
+
+And the C<delete_flip> deploy script might look something like:
+
+ -- Deploy flipr:delete_flip to mysql
+ -- requires: flips
+ -- requires: appuser
+
+ BEGIN;
+
+ DELIMITER //
+
+ CREATE FUNCTION delete_flip(
+ flip_id BIGINT
+ ) RETURNS INTEGER SQL SECURITY DEFINER
+ BEGIN
+ DELETE FROM flips WHERE id = flip_id;
+ RETURN ROW_COUNT();
+ END;
+ //
+
+ DELIMITER ;
+
+ GRANT EXECUTE ON FUNCTION delete_flip to flipr;
+
+ COMMIT;
+
+The C<verify> scripts are:
+
+ -- Verify flipr:insert_flip on mysql
+
+ BEGIN;
+
+ SELECT sqitch.checkit(COUNT(*), 'Function "insert_flip" does not exist')
+ FROM mysql.proc
+ WHERE db = database()
+ AND specific_name = 'insert_flip';
+
+ ROLLBACK;
+
+And:
+
+ -- Verify flipr:delete_flip on mysql
+
+ BEGIN;
+
+ SELECT sqitch.checkit(COUNT(*), 'Function "delete_flip" does not exist')
+ FROM mysql.proc
+ WHERE db = database()
+ AND specific_name = 'delete_flip';
+
+ ROLLBACK;
+
+The C<revert> scripts are:
+
+ -- Revert flipr:insert_flip from mysql
+
+ BEGIN;
+
+ DROP FUNCTION insert_flip;
+
+ COMMIT;
+
+And:
+
+ -- Revert flipr:delete_flip from mysql
+
+ BEGIN;
+
+ DROP FUNCTION delete_flip;
+
+ COMMIT;
+
+Check the L<example git repository|https://github.com/sqitchers/sqitch-intro> for
+the complete details. Test L<C<deploy>|sqitch-deploy> and
+L<C<revert>|sqitch-revert>, then commit it to the repository. The status
+should end up looking something like this:
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: 7bf30e6b7b0a4e61f30dd4148f5b837bdddae086
+ # Name: delete_flip
+ # Deployed: 2013-12-31 13:58:54 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2013-12-31 13:55:04 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating 0595297..5a58089
+ Fast-forward
+ deploy/delete_list.sql | 22 ++++++++++++++++++++++
+ deploy/insert_list.sql | 25 +++++++++++++++++++++++++
+ deploy/lists.sql | 17 +++++++++++++++++
+ revert/delete_list.sql | 7 +++++++
+ revert/insert_list.sql | 7 +++++++
+ revert/lists.sql | 7 +++++++
+ sqitch.plan | 4 ++++
+ verify/delete_list.sql | 10 ++++++++++
+ verify/insert_list.sql | 10 ++++++++++
+ verify/lists.sql | 8 ++++++++
+ 10 files changed, 117 insertions(+)
+ create mode 100644 deploy/delete_list.sql
+ create mode 100644 deploy/insert_list.sql
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/delete_list.sql
+ create mode 100644 revert/insert_list.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/delete_list.sql
+ create mode 100644 verify/insert_list.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff flips
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<flips> branch added changes to the plan. Let's try a
+different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<flips> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at 5a58089 Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout flips
+ Switched to branch 'flips'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add flips table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Failed to merge in the changes.
+ Patch failed at 0001 Add flips table.
+ The copy of the patch that failed is found in:
+ .git/rebase-apply/patch
+
+ When you have resolved this problem, run "git rebase --continue".
+ If you prefer to skip this patch, run "git rebase --skip" instead.
+ To check out the original branch and stop rebasing, run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to L<its
+docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add flips table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ Applying: Add functions to insert and delete flips.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-mysql-intro/
+
+ appuser 2013-12-31T21:04:04Z Marge N. O’Vera <marge@example.com> # Creates a an application user.
+ users [appuser] 2013-12-31T21:32:48Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appuser] 2013-12-31T21:37:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appuser] 2013-12-31T21:37:36Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+ @v1.0.0-dev1 2013-12-31T21:41:08Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appuser users] 2013-12-31T21:46:22Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ insert_list [lists appuser] 2013-12-31T21:48:14Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ delete_list [lists appuser] 2013-12-31T21:49:41Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ flips [appuser users] 2013-12-31T21:53:03Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ insert_flip [flips appuser] 2013-12-31T21:56:12Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip.
+ delete_flip [flips appuser] 2013-12-31T21:56:22Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "flips" branch. Test it to make sure it works as
+expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - delete_flip ............... ok
+ - insert_flip ............... ok
+ - flips ..................... ok
+ - change_pass @v1.0.0-dev1 .. ok
+ - insert_user ............... ok
+ - users ..................... ok
+ - appuser ................... ok
+ Deploying changes to flipr_test
+ + appuser ................... ok
+ + users ..................... ok
+ + insert_user ............... ok
+ + change_pass @v1.0.0-dev1 .. ok
+ + lists ..................... ok
+ + insert_list ............... ok
+ + delete_list ............... ok
+ + flips ..................... ok
+ + insert_flip ............... ok
+ + delete_flip ............... ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [flips d813f7c] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 file changed, 1 insertion(+)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff flips -m "Merge branch 'flips'"
+ Merge made by the 'recursive' strategy.
+ .gitattributes | 1 +
+ deploy/delete_flip.sql | 22 ++++++++++++++++++++++
+ deploy/flips.sql | 16 ++++++++++++++++
+ deploy/insert_flip.sql | 24 ++++++++++++++++++++++++
+ revert/delete_flip.sql | 7 +++++++
+ revert/flips.sql | 7 +++++++
+ revert/insert_flip.sql | 7 +++++++
+ sqitch.plan | 3 +++
+ verify/delete_flip.sql | 10 ++++++++++
+ verify/flips.sql | 12 ++++++++++++
+ verify/insert_flip.sql | 10 ++++++++++
+ 11 files changed, 119 insertions(+)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/delete_flip.sql
+ create mode 100644 deploy/flips.sql
+ create mode 100644 deploy/insert_flip.sql
+ create mode 100644 revert/delete_flip.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 revert/insert_flip.sql
+ create mode 100644 verify/delete_flip.sql
+ create mode 100644 verify/flips.sql
+ create mode 100644 verify/insert_flip.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-mysql-intro/
+
+ appuser 2013-12-31T21:04:04Z Marge N. O’Vera <marge@example.com> # Creates a an application user.
+ users [appuser] 2013-12-31T21:32:48Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appuser] 2013-12-31T21:37:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appuser] 2013-12-31T21:37:36Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+ @v1.0.0-dev1 2013-12-31T21:41:08Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appuser users] 2013-12-31T21:46:22Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ insert_list [lists appuser] 2013-12-31T21:48:14Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ delete_list [lists appuser] 2013-12-31T21:49:41Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ flips [appuser users] 2013-12-31T21:53:03Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ insert_flip [flips appuser] 2013-12-31T21:56:12Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip.
+ delete_flip [flips appuser] 2013-12-31T21:56:22Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "flips" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "delete_flip" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 76d6e15] Tag the database with v1.0.0-dev2.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + appuser
+ + users
+ + insert_user
+ + change_pass @v1.0.0-dev1
+ + lists
+ + insert_list
+ + delete_list
+ + flips
+ + insert_flip
+ + delete_flip @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Uh-oh, someone just noticed that MD5 hashing is not particularly secure. Why?
+Have a look at this:
+
+ > mysql -u root -D flipr_test --execute "
+ CALL insert_user('foo', 'secr3t');
+ CALL insert_user('bar', 'secr3t');
+ SELECT * FROM users;
+ "
+ +----------+----------------------------------+----------------------------+
+ | nickname | password | timestamp |
+ +----------+----------------------------------+----------------------------+
+ | bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:06:28.359118 |
+ | foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:06:28.358789 |
+ +----------+----------------------------------+----------------------------+
+
+If user "foo" ever got access to the database, she could quickly discover that
+user "bar" has the same password and thus be able to exploit the account. Not
+a great idea. So we need to modify the C<insert_user()> and C<change_pass()>
+functions to fix that. How?
+
+We can use MySQL's
+L<C<ENCRYPT()>|https://dev.mysql.com/doc/refman/5.5/en/encryption-functions.html#function_encrypt>
+function to encrypt passwords with a salt, so that they're all unique. But how
+to deploy the changes to C<insert_user()> and C<change_pass()>?
+
+Normally, modifying functions in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/insert_user.sql> to F<deploy/insert_user_encrypt.sql>.
+
+=item 2.
+
+Edit F<deploy/insert_user_encrypt.sql> to switch from C<MD5()> to C<ENCRYPT()>.
+
+=item 3.
+
+Copy F<deploy/insert_user.sql> to F<revert/insert_user_encrypt.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Copy F<verify/insert_user.sql> to F<verify/insert_user_encrypt.sql>.
+
+=item 5.
+
+Edit F<verify/insert_user_encrypt.sql> to test that the function now properly
+uses C<ENCRYPT()>.
+
+=item 6.
+
+Test the changes to make sure you can deploy and revert the
+C<insert_user_encrypt> change.
+
+=item 7.
+
+Now do the same for the C<change_pass> scripts.
+
+=back
+
+But you can have Sqitch do it for you. The only requirement is that a tag
+appear between the two instances of a change we want to modify. In general,
+you're going to make a change like this after a release, which you've tagged
+anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>:
+
+ > sqitch rework insert_user -n 'Change insert_user to use encyrpt().'
+ Added "insert_user [insert_user@v1.0.0-dev2]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/insert_user.sql
+ * revert/insert_user.sql
+ * verify/insert_user.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<insert_user> change, which we can see via C<git status>:
+
+ > git status
+ # On branch master
+ # Your branch is ahead of 'origin/master' by 5 commits.
+ # (use "git push" to publish your local commits)
+ #
+ # Changes not staged for commit:
+ # (use "git add <file>..." to update what will be committed)
+ # (use "git checkout -- <file>..." to discard changes in working directory)
+ #
+ # modified: revert/insert_user.sql
+ # modified: sqitch.plan
+ #
+ # Untracked files:
+ # (use "git add <file>..." to include in what will be committed)
+ #
+ # deploy/insert_user@v1.0.0-dev2.sql
+ # revert/insert_user@v1.0.0-dev2.sql
+ # verify/insert_user@v1.0.0-dev2.sql
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "untracked files" part of the output is the first thing to notice. They
+are all named C<insert_user@v1.0.0-dev2.sql>. What that means is: "the
+C<insert_user> change as it was implemented as of the C<@v1.0.0-dev2> tag."
+These are copies of the original scripts, and thereafter Sqitch will find them
+when it needs to run scripts for the first instance of the C<insert_user>
+change. As such, it's important not to change them again. But hey, if you're
+reworking the change, you shouldn't need to.
+
+The other thing to notice is that F<revert/insert_user.sql> has changed.
+Sqitch replaced it with the original deploy script. As of now,
+F<deploy/insert_user.sql> and F<revert/insert_user.sql> are identical. This is
+on the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before. Of course, the original deploy script may not be
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. If it's not, you will likely need to modify it so that it
+properly restores things to how they were after the original deploy script was
+deployed. Or, more simply, it should revert changes back to how they were
+as-of the deployment of F<deploy/insert_user@v1.0.0-dev2.sql>.
+
+Had MySQL supported an C<OR REPLACE> expression on C<CREATE FUNCTION> and we
+had used it, our function deploy scripts would already idempotent. No matter
+how many times they were run, the end results would be the same instance of
+the function, with no duplicates or errors.
+
+Alas, such is not the case for MySQL, so we will have to modify the scripts to
+drop the function before re-creating it. So let's do it. We'll modify the
+scripts drop and re-create the functions with to use C<ENCRYPT()>. Make this
+change to F<deploy/insert_user.sql>:
+
+ @@ -6,13 +6,14 @@ BEGIN;
+
+ DELIMITER //
+
+ +DROP PROCEDURE insert_user;
+ CREATE PROCEDURE insert_user(
+ nickname VARCHAR(512),
+ password VARCHAR(512)
+ ) SQL SECURITY DEFINER
+ BEGIN
+ INSERT INTO users (nickname, password, timestamp)
+ - VALUES (nickname, md5(password), UTC_TIMESTAMP(6));
+ + VALUES (nickname, ENCRYPT(md5(password), md5(FLOOR(RAND() * 0xFFFFFFFF))), UTC_TIMESTAMP(6));
+ END
+ //
+
+We just need to add the C<DROP> statement to the revert script,
+F<revert/insert_user.sql>:
+
+ @@ -6,6 +6,7 @@ BEGIN;
+
+ DELIMITER //
+
+ +DROP PROCEDURE insert_user;
+ CREATE PROCEDURE insert_user(
+ nickname VARCHAR(512),
+ password VARCHAR(512)
+
+Go ahead and rework the C<change_pass> change, too:
+
+ > sqitch rework change_pass -n 'Change change_pass to use encyrpt().'
+ Added "change_pass [change_pass@v1.0.0-dev2]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/change_pass.sql
+ * revert/change_pass.sql
+ * verify/change_pass.sql
+
+And make this change to F<deploy/change_pass.sql>:
+
+ @@ -6,6 +6,7 @@ BEGIN;
+
+ DELIMITER //
+
+ +DROP FUNCTION change_pass;
+ CREATE FUNCTION change_pass(
+ nickname VARCHAR(512),
+ oldpass VARCHAR(512),
+ @@ -13,9 +14,9 @@ CREATE FUNCTION change_pass(
+ ) RETURNS INTEGER SQL SECURITY DEFINER
+ BEGIN
+ UPDATE users
+ - SET password = md5(newpass)
+ + SET password = ENCRYPT(md5(newpass), md5(FLOOR(RAND() * 0xFFFFFFFF)))
+ WHERE nickname = nickname
+ - AND password = md5(oldpass);
+ + AND password = ENCRYPT(md5(oldpass), password);
+ RETURN ROW_COUNT();
+ END;
+ //
+
+And add the C<DROP FUNCTION> statement to its revert script, too:
+
+ @@ -6,6 +6,7 @@ BEGIN;
+
+ DELIMITER //
+
+ +DROP FUNCTION change_pass;
+ CREATE FUNCTION change_pass(
+ nickname VARCHAR(512),
+ oldpass VARCHAR(512),
+
+And now we're ready to try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+So, are the changes deployed?
+
+ > mysql -u root -D flipr_test --execute "
+ DELETE FROM users;
+ CALL insert_user('foo', 'secr3t');
+ CALL insert_user('bar', 'secr3t');
+ SELECT * FROM users;
+ "
+ +----------+---------------+----------------------------+
+ | nickname | password | timestamp |
+ +----------+---------------+----------------------------+
+ | bar | 0aasvM1.AzY0Y | 2013-12-31 22:14:45.554942 |
+ | foo | 80v1DpnRrqbwo | 2013-12-31 22:14:45.554457 |
+ +----------+---------------+----------------------------+
+
+Awesome, the stored passwords are different now. But can we revert, even
+though we haven't written any reversion scripts?
+
+ > sqitch revert --to @HEAD^^ -y
+ Reverting changes to delete_flip @v1.0.0-dev2 from flipr_test
+ - change_pass .. ok
+ - insert_user .. ok
+
+Did that work, are the C<MD5()> passwords back?
+
+ > mysql -u root -D flipr_test --execute "
+ DELETE FROM users;
+ CALL insert_user('foo', 'secr3t');
+ CALL insert_user('bar', 'secr3t');
+ SELECT * FROM users;
+ "
+ +----------+----------------------------------+----------------------------+
+ | nickname | password | timestamp |
+ +----------+----------------------------------+----------------------------+
+ | bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:15:29.843140 |
+ | foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:15:29.842700 |
+ +----------+----------------------------------+----------------------------+
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+But what about the verify script? How can we verify that the functions have
+been modified to use C<ENCRYPT()>? I think the simplest thing to do is to
+examine the body of the function as returned by
+L<C<INFORMATION_SCHEMA.ROUTINES>|https://dev.mysql.com/doc/refman/5.6/en/routines-table.html>
+So the C<insert_user> verify script looks like this:
+
+ -- Verify flipr:insert_user on mysql
+
+ BEGIN;
+
+ SELECT sqitch.checkit(COUNT(*), 'Procedure "insert_user" does not exist or is not up-to-date')
+ FROM mysql.proc
+ WHERE db = database()
+ AND specific_name = 'insert_user'
+ AND body_utf8 LIKE '%ENCRYPT(md5(password), md5(FLOOR(RAND() * 0xFFFFFFFF))%';
+
+ ROLLBACK;
+
+And the C<change_pass> verify script looks like this:
+
+ -- Verify flipr:change_pass on mysql
+
+ BEGIN;
+
+ SELECT sqitch.checkit(COUNT(*), 'Procedure "change_pass" does not exist or is not up-to-date')
+ FROM mysql.proc
+ WHERE db = database()
+ AND specific_name = 'change_pass'
+ AND body_utf8 LIKE '%ENCRYPT(md5(oldpass), password)%';
+
+ ROLLBACK;
+
+Make sure these pass by re-deploying:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+Excellent. Let's go ahead and commit these changes:
+
+ > git add .
+ > git commit -m 'Use encrypt() to encrypt passwords.'
+ [master abcce73] Use encrypt() to encrypt passwords.
+ 13 files changed, 137 insertions(+), 9 deletions(-)
+ create mode 100644 deploy/change_pass@v1.0.0-dev2.sql
+ create mode 100644 deploy/insert_user@v1.0.0-dev2.sql
+ create mode 100644 revert/change_pass@v1.0.0-dev2.sql
+ create mode 100644 revert/insert_user@v1.0.0-dev2.sql
+ create mode 100644 verify/change_pass@v1.0.0-dev2.sql
+ create mode 100644 verify/insert_user@v1.0.0-dev2.sql
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 6f2e1cd4b1c031a66930811328cfcdb0389d8320
+ # Name: change_pass
+ # Deployed: 2013-12-31 14:16:45 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchtutorial-oracle.pod b/lib/sqitchtutorial-oracle.pod
new file mode 100644
index 00000000..98d990b6
--- /dev/null
+++ b/lib/sqitchtutorial-oracle.pod
@@ -0,0 +1,1870 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial-oracle - A tutorial introduction to Sqitch change management on Oracle
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled Oracle project, use a
+VCS for deployment planning, and work with other developers to make sure
+changes remain in sync and in the proper order.
+
+We'll start by creating new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<Oracle|https://www.oracle.com/us/products/database/> as the
+storage engine. Note that you will need to set
+L<C<$ORACLE_HOME>|https://www.orafaq.com/wiki/ORACLE_HOME> so that all the
+database connections will work.
+
+If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>.
+
+If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>.
+
+If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>.
+
+If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>.
+
+If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>.
+
+If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>.
+
+If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>.
+
+=head2 VM Configuration
+
+Some instructions for setting up a VM for following along in this tutorial.
+
+=over
+
+=item *
+
+See F<t/oracle.t> for instructions on downloading, installing, and configuring
+the Oracle developer days VM.
+
+=item *
+
+Connect as the DBA via SQL*Plus:
+
+ sqlplus sys/oracle@localhost/ORCL as sysdba
+
+=item *
+
+Give user C<scott> the access it needs:
+
+ ALTER USER scott IDENTIFIED BY tiger;
+ GRANT ALL PRIVILEGES TO scott;
+
+=item *
+
+Add this entry to F<tnsnames.ora>:
+
+ FLIPR_TEST =
+ (DESCRIPTION =
+ (ADDRESS = (PROTOCOL = TCP)(HOST = localhost)(PORT = 1521))
+ (CONNECT_DATA =
+ (SERVER = DEDICATED)
+ (SERVICE_NAME = orcl)
+ )
+ )
+
+=back
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -am 'Initialize project, add README.'
+ [master (root-commit) 1bd134b] Initialize project, add README.
+ 1 file changed, 38 insertions(+)
+ create mode 100644 README.md
+
+If you're a Git user and want to follow along the history, the repository used
+in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-oracle-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, so let's specify one when we initialize Sqitch:
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = oracle
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "oracle"]
+ # target = db:oracle:
+ # registry =
+ # client = sqlplus
+
+Good, it picked up on the fact that we're creating changes for the Oracle
+engine, thanks to the C<--engine oracle> option, and saved it to the file.
+Furthermore, it wrote a commented-out C<[engine "oracle"]> section with all
+the available Oracle engine-specific settings commented out and ready to be
+edited as appropriate. This includes the path to
+L<SQL*Plus|https://www.orafaq.com/wiki/SQL*Plus> in my C<$ORACLE_HOME>.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings. Let's tell it who we are, since this data will be used in all of our
+projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch will always properly identify us when planning and
+committing changes. Back to the repository. Have a look at the plan file,
+F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-oracle-intro/
+
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -am 'Initialize Sqitch configuration.'
+ [master bd82f41] Initialize Sqitch configuration.
+ 2 files changed, 19 insertions(+)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+=head1 Our First Change
+
+First, our project will need an Oracle user and accompanying schema. This
+creates a nice namespace for all of the objects that will be part of the flipr
+app. Run this command:
+
+ > sqitch add appschema -n 'App user and schema for all flipr objects.'
+ Created deploy/appschema.sql
+ Created revert/appschema.sql
+ Created verify/appschema.sql
+ Added "appschema" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the user. So we add
+this to F<deploy/appschema.sql>:
+
+ CREATE USER flipr IDENTIFIED BY whatever;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we add this to F<revert/appschema.sql>:
+
+ DROP USER flipr;
+
+Now we can try deploying this change. Before going any further, you might
+need to
+L<create the database|https://docs.oracle.com/cd/B28359_01/server.111/b28310/create001.htm#ADMIN11068>
+and configure the SID. Assuming you have an Oracle SID named C<flipr_test> set
+up in your C<F<TNSNAMES.ORA>|https://www.orafaq.com/wiki/Tnsnames.ora> file,
+tell Sqitch where to send the change via a
+L<database URI|https://github.com/libwww-perl/uri-db/>:
+
+
+ > sqitch deploy db:oracle://scott:tiger@/flipr_test
+ Adding registry tables to db:oracle://scott:@/flipr_test
+ Deploying changes to db:oracle://scott:@/flipr_test
+ + appschema .. ok
+
+First Sqitch created the registry tables used to track database changes. The
+structure and name of the registry varies between databases, but in Oracle
+they are simply stored in the current schema -- that is, the schema with the
+same name as the user you've connected as. In this example, that schema is
+C<scott>. Ideally, only Sqitch data will be stored in this schema, so it
+probably makes the most sense to create a superuser named C<sqitch> or
+something similar and use it to deploy changes.
+
+If you'd like it to use a different database as the registry database, use
+C<sqitch engine add oracle $name> to configure it (or via the
+L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be
+useful if you don't want to use the same registry database to manage multiple
+databases on the same server.
+
+Next, Sqitch deploys changes to the target database, which we specified on the
+command-line. We only have one change so far; the C<+> reinforces the idea
+that the change is being I<added> to the database.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the schema:
+
+ > echo "SELECT username FROM all_users WHERE username = 'FLIPR';" \
+ | sqlplus -S scott/tiger@flipr_test
+ USERNAME
+ ------------------------------
+ FLIPR
+
+=head2 Trust, But Verify
+
+But that's too much work. Do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did was it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. In Oracle, the simplest way to do so for schema is probably to
+simply create an object in the schema. Put this SQL into
+F<verify/appschema.sql>:
+
+ CREATE TABLE flipr.verify__ (id int);
+ DROP TABLE flipr.verify__;
+
+In truth, you can use I<any> query that generates an SQL error if the schema
+doesn't exist. This works because Sqitch configures SQL*Plus so that SQL
+errors cause it to exit with the error code (more on that below). Another
+handy way to do that is to divide by zero if an object doesn't exist. For
+example, to throw an error when the C<flipr> schema does not exist, you could
+do something like this:
+
+ SELECT 1/COUNT(*) FROM sys.all_users WHERE username = 'FLIPR';
+
+Either way, run the C<verify> script with the L<C<verify>|sqitch-verify>
+command:
+
+ > sqitch verify db:oracle://scott:tiger@/flipr_test
+ Verifying db:oracle://scott:@/flipr_test
+ * appschema .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the schema doesn't exist, temporarily change the schema name in the script to
+something that doesn't exist, something like:
+
+ CREATE TABLE nonesuch.verify__ (id int);
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify db:oracle://scott:tiger@/flipr_test
+ Verifying db:oracle://scott:@/flipr_test
+ * appschema .. CREATE TABLE nonesuch.verify__ (id int)
+ *
+ ERROR at line 1:
+ ORA-01918: user 'NONESUCH' does not exist
+
+
+
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+It's even nice enough to tell us what the problem is. Or, for the
+divide-by-zero example, change the schema name:
+
+ SELECT 1/COUNT(*) FROM sys.all_users WHERE username = 'NONESUCH';
+
+Then the verify will look something like:
+
+ > sqitch verify db:oracle://scott:tiger@/flipr_test
+ Verifying db:oracle://scott:@/flipr_test
+ * appschema .. SELECT 1/COUNT(*) FROM sys.all_users WHERE username = 'NONESUCH'
+ *
+ ERROR at line 1:
+ ORA-01476: divisor is equal to zero
+
+
+
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+Less useful error output, but enough to alert us that something has gone
+wrong.
+
+Don't forget to change the schema name back before continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the registry
+tables from the database:
+
+ > sqitch status db:oracle://scott:tiger@/flipr_test
+ # On database db:oracle://scott:@/flipr_test
+ # Project: flipr
+ # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd
+ # Name: appschema
+ # Deployed: 2013-12-31 15:25:23 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert db:oracle://scott:tiger@/flipr_test
+ Revert all changes from db:oracle://scott:@/flipr_test? [Yes]
+ - appschema .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the schema should be gone:
+
+ > echo "SELECT username FROM all_users WHERE username = 'FLIPR';" \
+ | sqlplus -S scott/tiger@flipr_test
+ no rows selected
+
+And the status message should reflect as much:
+
+ > sqitch status db:oracle://scott:tiger@/flipr_test
+ # On database db:oracle://scott:@/flipr_test
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify db:oracle://scott:tiger@/flipr_test
+ Verifying db:oracle://scott:@/flipr_test
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log db:oracle://scott:tiger@/flipr_test
+ On database db:oracle://scott:@/flipr_test
+ Revert c59e700589fc03568e8f35f592c0d9b7c638cbdd
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-31 16:19:38 -0800
+
+ App user and schema for all flipr objects.
+
+ Deploy c59e700589fc03568e8f35f592c0d9b7c638cbdd
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-31 15:25:23 -0800
+
+ App user and schema for all flipr objects.
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Now let's commit it.
+
+ > git add .
+ > git commit -m 'Add flipr schema.'
+ [master e0e0b11] Add flipr schema.
+ 4 files changed, 11 insertions(+)
+ create mode 100644 deploy/appschema.sql
+ create mode 100644 revert/appschema.sql
+ create mode 100644 verify/appschema.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy --verify db:oracle://scott:tiger@/flipr_test
+ Deploying changes to db:oracle://scott:@/flipr_test
+ + appschema .. ok
+
+And now the schema should be back:
+
+ > echo "SELECT username FROM all_users WHERE username = 'FLIPR';" \
+ | sqlplus -S scott/tiger@flipr_test
+ USERNAME
+ ------------------------------
+ FLIPR
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status db:oracle://scott:tiger@/flipr_test
+ # On database db:oracle://scott:@/flipr_test
+ # Project: flipr
+ # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd
+ # Name: appschema
+ # Deployed: 2013-12-31 16:22:01 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type
+C<db:oracle://scott:tiger@/flipr_test>, aren't you? This
+L<database connection URI|https://github.com/libwww-perl/uri-db/> tells Sqitch how
+to connect to the deployment target, but we don't have to keep using the URI.
+We can name the target:
+
+ > sqitch target add flipr_test db:oracle://scott:tiger@/flipr_test
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also tell
+Sqitch to deploy to the C<flipr_test> target by default:
+
+ > sqitch engine add oracle flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd
+ # Name: appschema
+ # Deployed: 2013-12-31 16:22:01 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and make some more changes!
+
+ > git commit -am 'Set default target and always verify.'
+ [master c4a308a] Set default target and always verify.
+ 1 file changed, 8 insertions(+)
+
+=head1 Deploy with Dependency
+
+Let's add another change, this time to create a table. Our app will need
+users, of course, so we'll create a table for them. First, add the new change:
+
+ > sqitch add users --requires appschema -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users [appschema]" to sqitch.plan
+
+Note that we're requiring the C<appschema> change as a dependency of the new
+C<users> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<users> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/users.sql> should look like
+this:
+
+ -- Deploy flipr:users to oracle
+ -- requires: appschema
+
+ CREATE TABLE flipr.users (
+ nickname VARCHAR2(512 CHAR) PRIMARY KEY,
+ password VARCHAR2(512 CHAR) NOT NULL,
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
+ );
+
+A few things to notice here. On the second line, the dependence on the
+C<appschema> change has been listed in a comment. This doesn't do anything,
+but the default Oracle C<deploy> template lists it here for your reference
+while editing the file. Useful, right?
+
+The table itself will been created in the C<flipr> schema. This is why we need
+to require the C<appschema> change.
+
+Notice that we've done nothing about error handling. Sqitch needs SQL*Plus
+to return failure when a script experiences an error, so one might expect that
+each script would need to start with lines like these:
+
+ WHENEVER OSERROR EXIT 9
+ WHENEVER SQLERROR EXIT SQL.SQLCODE
+
+However, Sqitch always sets these error handling parameters before it executes
+your scripts, so you don't have to.
+
+Now for the verify script. The simplest way to check that the table was
+created and has the expected columns without touching the data? Just select
+from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>:
+
+ SELECT nickname, password, timestamp
+ FROM flipr.users
+ WHERE 0 = 1;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/users.sql>:
+
+ DROP TABLE flipr.users;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > echo "DESCRIBE flipr.users;" | sqlplus -S scott/tiger@flipr_test
+
+ Name Null? Type
+ ----------------------------------------- -------- ----------------------------
+ NICKNAME NOT NULL VARCHAR2(512 CHAR)
+ PASSWORD NOT NULL VARCHAR2(512 CHAR)
+ TIMESTAMP NOT NULL TIMESTAMP(6) WITH TIME ZONE
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 6840dc13beb0cd716b8bd3979b03a259c1e94405
+ # Name: users
+ # Deployed: 2013-12-31 16:32:31 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to appschema from flipr_test
+ - users .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<appschema>, the penultimate change.
+The other potentially useful symbolic tag is C<@ROOT>, which refers to the
+first change deployed to the database (or in the plan, depending on the
+command).
+
+Back to the database. The C<users> table should be gone but the C<flipr> schema
+should still be around:
+
+ > echo "DESCRIBE flipr.users;" | sqlplus -S scott/tiger@flipr_test
+
+ ERROR:
+ ORA-04043: object flipr.users does not exist
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd
+ # Name: appschema
+ # Deployed: 2013-12-31 16:22:01 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * users
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ Undeployed change:
+ * users
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "users" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add users table.'
+ [master 2506312] Add users table.
+ 4 files changed, 17 insertions(+)
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 6840dc13beb0cd716b8bd3979b03a259c1e94405
+ # Name: users
+ # Deployed: 2013-12-31 16:34:28 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Excellent. Let's do some more!
+
+=head1 Add Two at Once
+
+Let's add a couple more changes to add functions for managing users.
+
+ > sqitch add insert_user --requires users --requires appschema \
+ -n 'Creates a function to insert a user.'
+ Created deploy/insert_user.sql
+ Created revert/insert_user.sql
+ Created verify/insert_user.sql
+ Added "insert_user [users appschema]" to sqitch.plan
+
+ > sqitch add change_pass --requires users --requires appschema \
+ -n 'Creates a function to change a user password.'
+ Created deploy/change_pass.sql
+ Created revert/change_pass.sql
+ Created verify/change_pass.sql
+ Added "change_pass [users appschema]" to sqitch.plan
+
+Now might be a good time to have a look at the deployment plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-oracle-intro/
+
+ appschema 2013-12-31T22:34:42Z Marge N. O’Vera <marge@example.com> # App user and schema for all flipr objects.
+ users [appschema] 2014-01-01T00:31:20Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appschema] 2014-01-01T00:35:21Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appschema] 2014-01-01T00:35:28Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+
+Each change appears on a single line with the name of the change, a bracketed
+list of dependencies, a timestamp, the name and email address of the user who
+planned the change, and a note.
+
+Let's write the code for the new changes. Here's what
+F<deploy/insert_user.sql> should look like:
+
+ -- Deploy flipr:insert_user to oracle
+ -- requires: users
+ -- requires: appschema
+
+ CREATE OR REPLACE PROCEDURE flipr.insert_user(
+ nickname VARCHAR2,
+ password VARCHAR2
+ ) AS
+ BEGIN
+ INSERT INTO flipr.users VALUES(
+ nickname,
+ LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW(
+ sys.dbms_obfuscation_toolkit.md5(input_string => password)
+ ) ) ),
+ DEFAULT
+ );
+ END;
+ /
+
+ SHOW ERRORS;
+
+ -- Drop and die on error.
+ DECLARE
+ l_err_count INTEGER;
+ BEGIN
+ SELECT COUNT(*)
+ INTO l_err_count
+ FROM all_errors
+ WHERE owner = 'FLIPR'
+ AND name = 'INSERT_USER';
+
+ IF l_err_count > 0 THEN
+ EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.insert_user';
+ raise_application_error(-20001, 'Errors in FLIPR.INSERT_USER');
+ END IF;
+ END;
+ /
+
+The C<DECLARE> PL/SQL block is to catch compilation warnings, which are not
+normally fatal. It's admittedly
+L<a bit convoluted|https://stackoverflow.com/a/16429231/79202>, but ensures that
+errors propagate and a broken function get dropped.
+
+Here's what F<verify/insert_user.sql> might look like:
+
+ -- Verify flipr:insert_user on oracle
+ DESCRIBE flipr.insert_user;
+
+We simply take advantage of the fact that C<DESCRIBE> throws an exception if
+the specified function does not exist.
+
+And F<revert/insert_user.sql> should look something like this:
+
+ -- Revert flipr:insert_user from oracle
+ DROP PROCEDURE flipr.insert_user;
+
+Now for C<change_pass>; F<deploy/change_pass.sql> might look like this:
+
+ -- Deploy flipr:change_pass to oracle
+ -- requires: users
+ -- requires: appschema
+
+ CREATE OR REPLACE PROCEDURE flipr.change_pass(
+ nick VARCHAR2,
+ oldpass VARCHAR2,
+ newpass VARCHAR2
+ ) IS
+ flipr_auth_failed EXCEPTION;
+ BEGIN
+ UPDATE flipr.users
+ SET password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW(
+ sys.dbms_obfuscation_toolkit.md5(input_string => newpass)
+ ) ) )
+ WHERE nickname = nick
+ AND password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW(
+ sys.dbms_obfuscation_toolkit.md5(input_string => oldpass)
+ ) ) );
+ IF SQL%ROWCOUNT = 0 THEN RAISE flipr_auth_failed; END IF;
+ END;
+ /
+
+ SHOW ERRORS;
+
+ -- Drop and die on error.
+ DECLARE
+ l_err_count INTEGER;
+ BEGIN
+ SELECT COUNT(*)
+ INTO l_err_count
+ FROM all_errors
+ WHERE owner = 'FLIPR'
+ AND name = 'CHANGE_PASS';
+
+ IF l_err_count > 0 THEN
+ EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.CHANGE_PASS';
+ raise_application_error(-20001, 'Errors in FLIPR.CHANGE_PASS');
+ END IF;
+ END;
+ /
+
+We again need the C<DECLARE> PL/SQL block to detect compilation warnings and
+make the script die. Use C<DESCRIBE> in F<verify/change_pass.sql> again:
+
+ -- Verify flipr:change_pass on oracle
+ DESCRIBE flipr.change_pass;
+
+And of course, its C<revert> script, F<revert/change_pass.sql>, should look
+something like:
+
+ -- Revert flipr:change_pass from oracle
+ DROP PROCEDURE flipr.change_pass;
+
+Try em out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. No errors.
+ ok
+ + change_pass .. No errors.
+ ok
+
+Looks good. The "No errors" notices come from the C<SHOW ERRORS> SQL*Plus
+command. It's not very useful here, but very useful if there are compilation
+errors. If it bothers you, you can drop the C<SHOW ERRORS> line and select the
+error for display in the C<DECLARE> block, instead.
+
+Now, do we have the functions? Of course we do, they were verified. Still,
+have a look:
+
+ > echo "DESCRIBE flipr.insert_user;\nDESCRIBE flipr.change_pass;" \
+ | sqlplus -S scott/tiger@flipr_test
+
+ PROCEDURE flipr.insert_user
+ Argument Name Type In/Out Default?
+ ------------------------------ ----------------------- ------ --------
+ NICKNAME VARCHAR2 IN
+ PASSWORD VARCHAR2 IN
+
+ PROCEDURE flipr.change_pass
+ Argument Name Type In/Out Default?
+ ------------------------------ ----------------------- ------ --------
+ NICK VARCHAR2 IN
+ OLDPASS VARCHAR2 IN
+ NEWPASS VARCHAR2 IN
+
+And what's the status?
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: e1c9df6a95da835769eb560790588c16174f78df
+ # Name: change_pass
+ # Deployed: 2013-12-31 16:37:22 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Looks good. Let's make sure revert works:
+
+ > sqitch revert -y --to @HEAD^^
+ Reverting changes to users from flipr_test
+ - change_pass .. ok
+ - insert_user .. ok
+ > echo "DESCRIBE flipr.insert_user;\nDESCRIBE flipr.change_pass;" \
+ | sqlplus -S dwheeler/dwheeler@flipr_test
+ ERROR:
+ ORA-04043: object flipr.insert_user does not exist
+
+ ERROR:
+ ORA-04043: object flipr.change_pass does not exist
+
+Note the use of C<@HEAD^^> to specify that the revert be to two changes prior
+the last deployed change. Looks good. Let's do the commit and re-deploy dance:
+
+ > git add .
+ > git commit -m 'Add `insert_user()` and `change_pass()`.'
+ [master 6b6797e] Add `insert_user()` and `change_pass()`.
+ 7 files changed, 92 insertions(+)
+ create mode 100644 deploy/change_pass.sql
+ create mode 100644 deploy/insert_user.sql
+ create mode 100644 revert/change_pass.sql
+ create mode 100644 revert/insert_user.sql
+ create mode 100644 verify/change_pass.sql
+ create mode 100644 verify/insert_user.sql
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. No errors.
+ ok
+ + change_pass .. No errors.
+ ok
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: e1c9df6a95da835769eb560790588c16174f78df
+ # Name: change_pass
+ # Deployed: 2013-12-31 16:38:46 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .... ok
+ * users ........ ok
+ * insert_user .. ok
+ * change_pass .. ok
+ Verify successful
+
+Great, we're fully up-to-date!
+
+=head1 Ship It!
+
+Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want
+to have it go out with deployments tied to the release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "change_pass" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master eae5f71] Tag the database with v1.0.0-dev1.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+We can try deploying to make sure the tag gets picked up by deploying to a new
+database, like so (assuming you have an Oracle SID named C<flipr_dev> that
+points to a different database):
+
+ > sqitch deploy db:oracle://scott:tiger@/flipr_dev
+ Adding registry tables to db:oracle://scott:@/flipr_dev
+ Deploying changes to db:oracle://scott:@/flipr_dev
+ + appschema ................. ok
+ + users ..................... ok
+ + insert_user ............... No errors.
+ ok
+ + change_pass @v1.0.0-dev1 .. No errors.
+ ok
+
+Great, all four changes were deployed and C<change_pass> was tagged with
+C<@v1.0.0-dev1>. Let's have a look at the status:
+
+ > sqitch status db:oracle://scott:tiger@/flipr_dev
+ # On database db:oracle://scott:tiger@/flipr_dev
+ # Project: flipr
+ # Change: e1c9df6a95da835769eb560790588c16174f78df
+ # Name: change_pass
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2013-12-31 16:40:02 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the listing of the tag as part of the status message. Now let's bundle
+everything up for release:
+
+ > sqitch bundle
+ Bundling into bundle/
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + insert_user
+ + change_pass @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. Let's try
+deploying it to yet another database (again, assuming you have a SID named
+C<flipr_prod>:
+
+ > cd bundle
+ > sqitch deploy db:oracle://scott:tiger@/flipr_prod
+ Adding registry tables to db:oracle://scott:@/flipr_prod
+ Deploying changes to flipr_prod
+ + appschema ................. ok
+ + users ..................... ok
+ + insert_user ............... ok
+ + change_pass @v1.0.0-dev1 .. ok
+
+Looks much the same as before, eh? Package it up and ship it!
+
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Flip Out
+
+Now that we've got the basics of user management done, let's get to work on
+the core of our product, the "flip." Since other folks are working on other
+tasks in the repository, we'll work on a branch, so we can all stay out of
+each other's way. So let's branch:
+
+ > git checkout -b flips
+ Switched to a new branch 'flips'
+
+Now we can add a new change to create a table for our flips.
+
+ > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [appschema users]" to sqitch.plan
+
+You know the drill by now. Edit F<deploy/flips.sql>:
+
+ -- Deploy flipr:flips to oracle
+ -- requires: appschema
+ -- requires: users
+
+ CREATE TABLE flipr.flips (
+ id INTEGER PRIMARY KEY,
+ nickname VARCHAR2(512 CHAR) NOT NULL REFERENCES flipr.users(nickname),
+ body VARCHAR2(180 CHAR) NOT NULL,
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
+ );
+
+ CREATE SEQUENCE flipr.flip_id_seq START WITH 1 INCREMENT BY 1 NOCACHE;
+
+ CREATE OR REPLACE TRIGGER flipr.flip_pk BEFORE INSERT ON flipr.flips
+ FOR EACH ROW WHEN (NEW.id IS NULL)
+ DECLARE
+ v_id flipr.flips.id%TYPE;
+ BEGIN
+ SELECT flipr.flip_id_seq.nextval INTO v_id FROM DUAL;
+ :new.id := v_id;
+ END;
+ /
+
+Edit F<verify/flips.sql>:
+
+ -- Verify flipr:flips on oracle
+ DESCRIBE flipr.flips;
+
+And edit F<revert/flips.sql>:
+
+ -- Revert flipr:flips from oracle
+ DROP TRIGGER flipr.flip_pk;
+ DROP SEQUENCE flipr.flip_id_seq;
+ DROP TABLE flipr.flips;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: 8e1573bb5ce5dfc239d5370c33d6e10820234aad
+ # Name: flips
+ # Deployed: 2013-12-31 16:51:54 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2013-12-31 16:44:00 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Now make it so:
+
+ > git add .
+ > git commit -am 'Add flips table.'
+ [flips bbea131] Add flips table.
+ 4 files changed, 32 insertions(+)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 verify/flips.sql
+
+=head1 Wash, Rinse, Repeat
+
+Now comes the time to add functions to manage flips. I'm sure you have things
+nailed down now. Go ahead and add C<insert_flip> and C<delete_flip> changes
+and commit them. The C<insert_flip> deploy script might look something like:
+
+ -- Deploy flipr:insert_flip to oracle
+ -- requires: flips
+ -- requires: appschema
+
+ CREATE OR REPLACE PROCEDURE flipr.insert_flip(
+ nickname VARCHAR2,
+ body VARCHAR2
+ ) AS
+ BEGIN
+ INSERT INTO flipr.flips (nickname, body)
+ VALUES (nickname, body);
+ END;
+ /
+
+ SHOW ERRORS;
+
+ -- Drop and die on error.
+ DECLARE
+ l_err_count INTEGER;
+ BEGIN
+ SELECT COUNT(*)
+ INTO l_err_count
+ FROM all_errors
+ WHERE owner = 'FLIPR'
+ AND name = 'INSERT_FLIP';
+
+ IF l_err_count > 0 THEN
+ EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.insert_flip';
+ raise_application_error(-20001, 'Errors in FLIPR.INSERT_FLIP');
+ END IF;
+ END;
+ /
+
+And the C<delete_flip> deploy script might look something like:
+
+ -- Deploy flipr:delete_flip to oracle
+ -- requires: flips
+ -- requires: appschema
+
+ CREATE OR REPLACE PROCEDURE flipr.delete_flip(
+ flip_id INTEGER
+ ) IS
+ flipr_flip_delete_failed EXCEPTION;
+ BEGIN
+ DELETE FROM flipr.flips WHERE id = flip_id;
+ IF SQL%ROWCOUNT = 0 THEN RAISE flipr_flip_delete_failed; END IF;
+ END;
+ /
+
+ SHOW ERRORS;
+
+ -- Drop and die on error.
+ DECLARE
+ l_err_count INTEGER;
+ BEGIN
+ SELECT COUNT(*)
+ INTO l_err_count
+ FROM all_errors
+ WHERE owner = 'FLIPR'
+ AND name = 'DELETE_FLIP';
+
+ IF l_err_count > 0 THEN
+ EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.delete_flip';
+ raise_application_error(-20001, 'Errors in FLIPR.DELETE_FLIP');
+ END IF;
+ END;
+ /
+
+The C<verify> scripts are:
+
+ -- Verify flipr:insert_flip on oracle
+ DESCRIBE flipr.insert_flip;
+
+And:
+
+ -- Verify flipr:delete_flip on oracle
+ DESCRIBE flipr.delete_flip;
+
+The C<revert> scripts are:
+
+ -- Revert flipr:insert_flip from oracle
+ DROP PROCEDURE flipr.insert_flip;
+
+And:
+
+ -- Revert flipr:delete_flip from oracle
+ DROP PROCEDURE flipr.delete_flip;
+
+Check the L<example git repository|https://github.com/sqitchers/sqitch-oracle-intro> for
+the complete details. Test L<C<deploy>|sqitch-deploy> and
+L<C<revert>|sqitch-revert>, then commit it to the repository. The status
+should end up looking something like this:
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: a47be5a474eaad1a28546666eadeb0eba3ac12dc
+ # Name: delete_flip
+ # Deployed: 2013-12-31 16:54:31 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2013-12-31 16:44:00 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating eae5f71..a16f97c
+ Fast-forward
+ deploy/delete_list.sql | 35 +++++++++++++++++++++++++++++++++++
+ deploy/insert_list.sql | 33 +++++++++++++++++++++++++++++++++
+ deploy/lists.sql | 10 ++++++++++
+ revert/delete_list.sql | 3 +++
+ revert/insert_list.sql | 3 +++
+ revert/lists.sql | 3 +++
+ sqitch.plan | 4 ++++
+ verify/delete_list.sql | 3 +++
+ verify/insert_list.sql | 3 +++
+ verify/lists.sql | 5 +++++
+ 10 files changed, 102 insertions(+)
+ create mode 100644 deploy/delete_list.sql
+ create mode 100644 deploy/insert_list.sql
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/delete_list.sql
+ create mode 100644 revert/insert_list.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/delete_list.sql
+ create mode 100644 verify/insert_list.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff flips
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<flips> branch added changes to the plan. Let's try a
+different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<flips> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at a16f97c Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout flips
+ Switched to branch 'flips'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add flips table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Failed to merge in the changes.
+ Patch failed at 0001 Add flips table.
+ The copy of the patch that failed is found in:
+ .git/rebase-apply/patch
+
+ When you have resolved this problem, run "git rebase --continue".
+ If you prefer to skip this patch, run "git rebase --skip" instead.
+ To check out the original branch and stop rebasing, run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to L<its
+docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add flips table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ Applying: Add functions to insert and delete flips.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-oracle-intro/
+
+ appschema 2013-12-31T22:34:42Z Marge N. O’Vera <marge@example.com> # App user and schema for all flipr objects.
+ users [appschema] 2014-01-01T00:31:20Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appschema] 2014-01-01T00:35:21Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appschema] 2014-01-01T00:35:28Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+ @v1.0.0-dev1 2014-01-01T00:39:35Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2014-01-01T00:43:46Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ insert_list [lists appschema] 2014-01-01T00:45:24Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ delete_list [lists appschema] 2014-01-01T00:45:43Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list.
+ flips [appschema users] 2014-01-01T00:51:15Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ insert_flip [flips appschema] 2014-01-01T00:53:00Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip.
+ delete_flip [flips appschema] 2014-01-01T00:53:16Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "flips" branch. Test it to make sure it works as
+expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - delete_flip ............... ok
+ - insert_flip ............... ok
+ - flips ..................... ok
+ - change_pass @v1.0.0-dev1 .. ok
+ - insert_user ............... ok
+ - users ..................... ok
+ - appschema ................. ok
+ Deploying changes to flipr_test
+ + appschema ................. ok
+ + users ..................... ok
+ + insert_user ............... No errors.
+ ok
+ + change_pass @v1.0.0-dev1 .. No errors.
+ ok
+ + lists ..................... ok
+ + insert_list ............... No errors.
+ ok
+ + delete_list ............... No errors.
+ ok
+ + flips ..................... ok
+ + insert_flip ............... No errors.
+ ok
+ + delete_flip ............... No errors.
+ ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [flips 383691f] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 file changed, 1 insertion(+)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff flips -m "Merge branch 'flips'"
+ Merge made by the 'recursive' strategy.
+ .gitattributes | 1 +
+ deploy/delete_flip.sql | 32 ++++++++++++++++++++++++++++++++
+ deploy/flips.sql | 22 ++++++++++++++++++++++
+ deploy/insert_flip.sql | 32 ++++++++++++++++++++++++++++++++
+ revert/delete_flip.sql | 3 +++
+ revert/flips.sql | 5 +++++
+ revert/insert_flip.sql | 3 +++
+ sqitch.plan | 3 +++
+ verify/delete_flip.sql | 3 +++
+ verify/flips.sql | 3 +++
+ verify/insert_flip.sql | 3 +++
+ 11 files changed, 110 insertions(+)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/delete_flip.sql
+ create mode 100644 deploy/flips.sql
+ create mode 100644 deploy/insert_flip.sql
+ create mode 100644 revert/delete_flip.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 revert/insert_flip.sql
+ create mode 100644 verify/delete_flip.sql
+ create mode 100644 verify/flips.sql
+ create mode 100644 verify/insert_flip.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-oracle-intro/
+
+ appschema 2013-12-31T22:34:42Z Marge N. O’Vera <marge@example.com> # App user and schema for all flipr objects.
+ users [appschema] 2014-01-01T00:31:20Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appschema] 2014-01-01T00:35:21Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appschema] 2014-01-01T00:35:28Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+ @v1.0.0-dev1 2014-01-01T00:39:35Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2014-01-01T00:43:46Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ insert_list [lists appschema] 2014-01-01T00:45:24Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ delete_list [lists appschema] 2014-01-01T00:45:43Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list.
+ flips [appschema users] 2014-01-01T00:51:15Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ insert_flip [flips appschema] 2014-01-01T00:53:00Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip.
+ delete_flip [flips appschema] 2014-01-01T00:53:16Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "flips" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "delete_flip" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 5427456] Tag the database with v1.0.0-dev2.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + insert_user
+ + change_pass @v1.0.0-dev1
+ + lists
+ + insert_list
+ + delete_list
+ + flips
+ + insert_flip
+ + delete_flip @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Uh-oh, someone just noticed that MD5 hashing is not particularly secure. Why?
+Have a look at this:
+
+=begin comment
+
+If you get this error:
+
+ ORA-01950: no privileges on tablespace 'USERS'
+
+Then connect as sysdba and grant unlimited quota to flipr:
+
+ ALTER USER flipr QUOTA UNLIMITED ON USERS;
+
+=end comment
+
+ > echo "
+ DELETE FROM flipr.users;
+ EXECUTE flipr.insert_user('foo', 's3cr3t');
+ EXECUTE flipr.insert_user('bar', 's3cr3t');
+ SELECT nickname, password FROM flipr.users;
+ " | sqlplus -S scott/tiger@flipr_test
+
+ PL/SQL procedure successfully completed.
+
+
+ PL/SQL procedure successfully completed.
+
+
+ NICKNAME
+ --------------------------------------------------------------------------------
+ PASSWORD
+ --------------------------------------------------------------------------------
+ foo
+ a4d80eac9ab26a4a2da04125bc2c096a
+
+ bar
+ a4d80eac9ab26a4a2da04125bc2c096a
+
+If user "foo" ever got access to the database, she could quickly discover that
+user "bar" has the same password and thus be able to exploit the account. Not
+a great idea. So we need to modify the C<insert_user()> and C<change_pass()>
+functions to fix that. How?
+
+We'll create a function that encrypts passwords using a
+L<cryptographic salt|https://en.wikipedia.org/wiki/Salt_(cryptography)>. This
+will allow the password hashes to be stored with random hashing. So we'll need
+to add the function. The deploy script should be:
+
+ -- Deploy flipr:crypt to oracle
+ -- requires: appschema
+
+ CREATE OR REPLACE FUNCTION flipr.crypt(
+ password VARCHAR2,
+ salt VARCHAR2
+ ) RETURN VARCHAR2 IS
+ salted CHAR(10) := SUBSTR(salt, 0, 10);
+ BEGIN
+ RETURN salted || LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW(
+ sys.dbms_obfuscation_toolkit.md5(input_string => password || salted)
+ ) ) );
+ END;
+ /
+
+ SHOW ERRORS;
+
+ -- Drop and die on error.
+ DECLARE
+ l_err_count INTEGER;
+ BEGIN
+ SELECT COUNT(*)
+ INTO l_err_count
+ FROM all_errors
+ WHERE owner = 'FLIPR'
+ AND name = 'CRYPT';
+
+ IF l_err_count > 0 THEN
+ EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.crypt';
+ raise_application_error(-20001, 'Errors in FLIPR.CRYPT');
+ END IF;
+ END;
+ /
+
+And the revert script should be:
+
+ -- Revert flipr:crypt. from oracle
+ DROP FUNCTION flipr.crypt;
+
+And, as usual, the verify script should just use C<DESCRIBE>:
+
+ -- Verify flipr:crypt on oracle
+ DESCRIBE flipr.crypt;
+
+With that change in place and committed, we're ready to make use of the
+improved encryption. But how to deploy the changes to C<insert_user()> and
+C<change_pass()>?
+
+Normally, modifying functions in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/insert_user.sql> to F<deploy/insert_user_crypt.sql>.
+
+=item 2.
+
+Edit F<deploy/insert_user_crypt.sql> to switch from
+C<sys.dbms_obfuscation_toolkit.md5()> to C<flipr.crypt()> and to add a
+dependency on the C<crypt> change.
+
+=item 3.
+
+Copy F<deploy/insert_user.sql> to F<revert/insert_user_crypt.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Copy F<verify/insert_user.sql> to F<verify/insert_user_crypt.sql>.
+
+=item 5.
+
+Edit F<verify/insert_user_crypt.sql> to test that the function now properly
+uses C<flipr.crypt()>.
+
+=item 6.
+
+Test the changes to make sure you can deploy and revert the
+C<insert_user_crypt> change.
+
+=item 7.
+
+Now do the same for the C<change_pass> scripts.
+
+=back
+
+But you can have Sqitch do it for you. The only requirement is that a tag
+appear between the two instances of a change we want to modify. In general,
+you're going to make a change like this after a release, which you've tagged
+anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>, including support for the C<--requires> option:
+
+ > sqitch rework insert_user --requires crypt -n 'Change insert_user to use crypt.'
+ Added "insert_user [insert_user@v1.0.0-dev2 crypt]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/insert_user.sql
+ * revert/insert_user.sql
+ * verify/insert_user.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<insert_user> change, which we can see via C<git status>:
+
+ > git status
+ # On branch master
+ # Your branch is ahead of 'origin/master' by 2 commits.
+ # (use "git push" to publish your local commits)
+ #
+ # Changes not staged for commit:
+ # (use "git add <file>..." to update what will be committed)
+ # (use "git checkout -- <file>..." to discard changes in working directory)
+ #
+ # modified: revert/insert_user.sql
+ # modified: sqitch.plan
+ #
+ # Untracked files:
+ # (use "git add <file>..." to include in what will be committed)
+ #
+ # deploy/insert_user@v1.0.0-dev2.sql
+ # revert/insert_user@v1.0.0-dev2.sql
+ # verify/insert_user@v1.0.0-dev2.sql
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "untracked files" part of the output is the first thing to notice. They
+are all named C<insert_user@v1.0.0-dev2.sql>. What that means is: "the
+C<insert_user> change as it was implemented as of the C<@v1.0.0-dev2> tag."
+These are copies of the original scripts, and thereafter Sqitch will find them
+when it needs to run scripts for the first instance of the C<insert_user>
+change. As such, it's important not to change them again. But hey, if you're
+reworking the change, you shouldn't need to.
+
+The other thing to notice is that F<revert/insert_user.sql> has changed.
+Sqitch replaced it with the original deploy script. As of now,
+F<deploy/insert_user.sql> and F<revert/insert_user.sql> are identical. This is
+on the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before. Of course, the original deploy script may not be
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. If it's not, you will likely need to modify it so that it
+properly restores things to how they were after the original deploy script was
+deployed. Or, more simply, it should revert changes back to how they were
+as-of the deployment of F<deploy/insert_user@v1.0.0-dev2.sql>.
+
+Fortunately, our function deploy scripts are already idempotent, thanks to the
+use of the C<OR REPLACE> expression. No matter how many times a deployment
+script is run, the end result will be the same instance of the function, with
+no duplicates or errors.
+
+As a result, there is no need to explicitly add changes. So go ahead. Modify the
+script to switch to C<crypt()>. Make this change to
+F<deploy/insert_user.sql>:
+
+ @@ -1,6 +1,7 @@
+ -- Deploy flipr:insert_user to oracle
+ -- requires: users
+ -- requires: appschema
+ +-- requires: crypt
+
+ CREATE OR REPLACE PROCEDURE flipr.insert_user(
+ nickname VARCHAR2,
+ @@ -9,9 +10,7 @@ CREATE OR REPLACE PROCEDURE flipr.insert_user(
+ BEGIN
+ INSERT INTO flipr.users VALUES(
+ nickname,
+ - LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW(
+ - sys.dbms_obfuscation_toolkit.md5(input_string => password)
+ - ) ) ),
+ + flipr.crypt(password, DBMS_RANDOM.STRING('p', 10)),
+ DEFAULT
+ );
+ END;
+
+Go ahead and rework the C<change_pass> change, too:
+
+ > sqitch rework change_pass --requires crypt -n 'Change change_pass to use crypt.'
+ Added "change_pass [change_pass@v1.0.0-dev2 crypt]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/change_pass.sql
+ * revert/change_pass.sql
+ * verify/change_pass.sql
+
+And make this change to F<deploy/change_pass.sql>:
+
+ @@ -1,6 +1,7 @@
+ -- Deploy flipr:change_pass to oracle
+ -- requires: users
+ -- requires: appschema
+ +-- requires: crypt
+
+ CREATE OR REPLACE PROCEDURE flipr.change_pass(
+ nick VARCHAR2,
+ @@ -10,13 +11,9 @@ CREATE OR REPLACE PROCEDURE flipr.change_pass(
+ flipr_auth_failed EXCEPTION;
+ BEGIN
+ UPDATE flipr.users
+ - SET password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW(
+ - sys.dbms_obfuscation_toolkit.md5(input_string => newpass)
+ - ) ) )
+ + SET password = flipr.crypt(newpass, DBMS_RANDOM.STRING('p', 10))
+ WHERE nickname = nick
+ - AND password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW(
+ - sys.dbms_obfuscation_toolkit.md5(input_string => oldpass)
+ - ) ) );
+ + AND password = flipr.crypt(oldpass, password);
+ IF SQL%ROWCOUNT = 0 THEN RAISE flipr_auth_failed; END IF;
+ END;
+ /
+
+And then try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. No errors.
+ ok
+ + change_pass .. No errors.
+ ok
+
+So, are the changes deployed?
+
+ > echo "
+ DELETE FROM flipr.users;
+ EXECUTE flipr.insert_user('foo', 's3cr3t');
+ EXECUTE flipr.insert_user('bar', 's3cr3t');
+ SELECT nickname, password FROM flipr.users;
+ " | sqlplus -S scott/tiger@flipr_test
+
+ PL/SQL procedure successfully completed.
+
+
+ PL/SQL procedure successfully completed.
+
+
+ NICKNAME
+ --------------------------------------------------------------------------------
+ PASSWORD
+ --------------------------------------------------------------------------------
+ foo
+ cP?.eR!V[pf3d91ce9b7dcfe9260c6f4bb94ed0b22
+
+ bar
+ Z+l"_W_JiSefb62b789c0ff114cddcccc69c422e78
+
+Awesome, the stored passwords are different now. But can we revert, even
+though we haven't written any reversion scripts?
+
+ > sqitch revert --to @HEAD^^ -y
+ Reverting changes to crypt from flipr_test
+ - change_pass .. No errors.
+ ok
+ - insert_user .. No errors.
+ ok
+
+Did that work, are the MD5 passwords back?
+
+ > echo "
+ DELETE FROM flipr.users;
+ EXECUTE flipr.insert_user('foo', 's3cr3t');
+ EXECUTE flipr.insert_user('bar', 's3cr3t');
+ SELECT nickname, password FROM flipr.users;
+ " | sqlplus -S scott/tiger@flipr_test
+
+ PL/SQL procedure successfully completed.
+
+
+ PL/SQL procedure successfully completed.
+
+
+ NICKNAME
+ --------------------------------------------------------------------------------
+ PASSWORD
+ --------------------------------------------------------------------------------
+ foo
+ a4d80eac9ab26a4a2da04125bc2c096a
+
+ bar
+ a4d80eac9ab26a4a2da04125bc2c096a
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+But what about the verify script? How can we verify that the functions have
+been modified to use C<crypt()>? I think the simplest thing to do is to
+examine the body of the function by querying the
+L<C<all_source>|https://docs.oracle.com/cd/B19306_01/server.102/b14237/statviews_2063.htm>
+view. So the C<insert_user> verify script looks like this:
+
+ -- Verify flipr:insert_user on oracle
+
+ DESCRIBE flipr.insert_user;
+
+ SELECT 1/COUNT(*)
+ FROM all_source
+ WHERE type = 'PROCEDURE'
+ AND name = 'INSERT_USER'
+ AND text LIKE '%flipr.crypt(password, DBMS_RANDOM.STRING(''p'', 10))%';
+
+And the C<change_pass> verify script looks like this:
+
+ -- Verify flipr:change_pass on oracle
+
+ DESCRIBE flipr.change_pass;
+
+ SELECT 1/COUNT(*)
+ FROM all_source
+ WHERE type = 'PROCEDURE'
+ AND name = 'CHANGE_PASS'
+ AND text LIKE '%password = flipr.crypt(newpass, DBMS_RANDOM.STRING(''p'', 10))%';
+
+Make sure these pass by re-deploying:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. No errors.
+ ok
+ + change_pass .. No errors.
+ ok
+
+Excellent. Let's go ahead and commit these changes:
+
+ > git add .
+ > git commit -m 'Use crypt to encrypt passwords.'
+ [master be46175] Use crypt to encrypt passwords.
+ 13 files changed, 181 insertions(+), 15 deletions(-)
+ create mode 100644 deploy/change_pass@v1.0.0-dev2.sql
+ create mode 100644 deploy/insert_user@v1.0.0-dev2.sql
+ rewrite revert/change_pass.sql (98%)
+ rename revert/{change_pass.sql => change_pass@v1.0.0-dev2.sql} (100%)
+ rewrite revert/insert_user.sql (98%)
+ rename revert/{insert_user.sql => insert_user@v1.0.0-dev2.sql} (100%)
+ create mode 100644 verify/change_pass@v1.0.0-dev2.sql
+ create mode 100644 verify/insert_user@v1.0.0-dev2.sql
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 8367dc3bff7a563ec27f145421a1ffdf724cb6de
+ # Name: change_pass
+ # Deployed: 2013-12-31 17:18:28 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchtutorial-snowflake.pod b/lib/sqitchtutorial-snowflake.pod
new file mode 100644
index 00000000..7117c531
--- /dev/null
+++ b/lib/sqitchtutorial-snowflake.pod
@@ -0,0 +1,1419 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial-snowflake - A tutorial introduction to Sqitch change management on Snowflake
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled Snowflake project, use a
+VCS for deployment planning, and work with other developers to make sure
+changes remain in sync and in the proper order.
+
+We'll start by creating a new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<Snowflake|https://www.snowflake.net/> as the storage engine, but
+for the most part you can substitute other VCSes and database engines in the
+examples as appropriate.
+
+If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>.
+
+If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>.
+
+If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>.
+
+If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>.
+
+If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>.
+
+If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>.
+
+If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>.
+
+=head2 Connection Configuration
+
+Sqitch requires ODBC to connect to the Snowflake database. As such, you'll
+need to make sure that the
+L<Snowflake ODBC driver|https://docs.snowflake.net/manuals/user-guide/odbc.html>
+is installed and properly configured. At its simplest, on Unix-like systems,
+name the driver "Snowflake" by adding this entry to C<odbcinst.ini> (usually
+found in C</etc>, C</usr/etc>, or C</usr/local/etc>):
+
+ [Snowflake]
+ Description = ODBC for Snowflake
+ Driver = /usr/lib64/snowflake/odbc/lib/libSnowflake.so
+
+Note that you'll need to adjust the path depending on the version of the ODBC
+driver, and where you installed it.
+
+See the L<Snowflake ODBC documentation|https://docs.snowflake.net/manuals/user-guide/odbc.html>
+for details on downloading, installing, and configuring ODBC for your
+platform.
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -am 'Initialize project, add README.'
+
+If you're a Git user and want to follow along the history, the repository
+used in these examples is
+L<on GitHub|https://github.com/sqitchers/sqitch-snowflake-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ > sqitch init flipr --uri https://github.com/sqitchers/sqitch-snowflake-intro/ --engine snowflake
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = snowflake
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "snowflake"]
+ # target = db:snowflake:
+ # registry = sqitch
+ # client = snowsql
+
+Good, it picked up on the fact that we're creating changes for the Snowflake
+engine, thanks to the C<--engine snowflake> option, and saved it to the
+file. Furthermore, it wrote a commented-out C<[engine "snowflake"]> section with
+all the available Snowflake engine-specific settings commented out and ready to
+be edited as appropriate.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings. Since Snowflake's C<snowsql> client is not in the path on my system,
+let's go ahead an tell it where to find the client on our computer:
+
+ > sqitch config --user engine.snowflake.client /Applications/SnowSQL.app/Contents/MacOS/snowsql
+
+And let's also tell it who we are, since this data will be used in all
+of our projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [engine "snowflake"]
+ client = /Applications/SnowSQL.app/Contents/MacOS/snowsql
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch should be able to find C<snowsql> for any project, and
+that it will always properly identify us when planning and committing changes.
+
+Back to the repository. Have a look at the plan file, F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-snowflake-intro/
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -am 'Initialize Sqitch configuration.'
+ [master b731cc3] Initialize Sqitch configuration.
+ 2 files changed, 15 insertions(+)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+=head1 Our First Change
+
+First, our project will need a schema. This creates a nice namespace for all
+of the objects that will be part of the flipr app. Run this command:
+
+ > sqitch add appschema -n 'Add schema for all flipr objects.'
+ Created deploy/appschema.sql
+ Created revert/appschema.sql
+ Created verify/appschema.sql
+ Added "appschema" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the schema. So we add
+this to F<deploy/appschema.sql>:
+
+ CREATE SCHEMA flipr;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we add this to F<revert/appschema.sql>:
+
+ DROP SCHEMA flipr;
+
+Now we can try deploying this change. We tell Sqitch where to send the change
+via a L<database URI|https://github.com/libwww-perl/uri-db/>. Let's say we're
+using the account name C<example>, username C<movera>, database C<flipr>, and
+warehouse C<sqitch>, and an ODBC driver named C<Snowflake> (see
+L</Connection Configuration> for details). The URI would be structured like
+this:
+
+ db:snowflake://movera@example/flipr?Driver=Snowflake;warehouse=sqitch
+
+Note that Sqitch requires a C<warehouse> parameter in order to record its work
+in the registry. The default warehouse is named C<sqitch>, so you can omit it
+from the URI if that's the warehouse you want Sqitch to use (we'll omit it for
+the remainder of this tutorial). Otherwise, specify it in the URI. Snowflake
+also requires a password, which could also be included in the URI, but it's
+best to put it in the C<connections> section of the
+L<F<.snowsql/config> file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>.
+See L<sqitch-authentication> for details.
+
+We just tell Sqitch to use that URI to deploy the change:
+
+ > sqitch deploy 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ Adding registry tables to db:snowflake://movera@example/flipr?Driver=Snowflake
+ Deploying changes to db:snowflake://movera@example/flipr?Driver=Snowflake
+ + appschema .. ok
+
+First Sqitch created registry tables used to track database changes. The
+structure and name of the registry varies between databases (Snowflake uses a
+schema to namespace its registry, while SQLite and MySQL use separate
+databases). Next, Sqitch deploys changes. We only have one so far; the C<+>
+reinforces the idea that the change is being C<added> to the database.
+
+Note that this process can take quite a bit of time. Sqitch connects to the
+database via ODBC and retains the connection throughout, but the creation of
+the registry and all change scripts run through individual runs of C<snowsql>.
+These connections can be quite slow. So if Sqitch seems hung, just wait; it's
+most likely waiting on Snowflake.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the schema:
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE SCHEMAS LIKE 'flipr'"
+ +-------------------------------+-------+------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |-------------------------------+-------+------+---------------+-------------|
+ | 2018-07-27 14:47:22.614 +0000 | FLIPR | NULL | DWHEELER | NULL |
+ +-------------------------------+-------+------+---------------+-------------+
+ 1 Row(s) produced. Time Elapsed: 0.283s
+
+=head2 Trust, But Verify
+
+But that's too much work. Do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did was it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. In Snowflake, the simplest way to do so for schema is probably to
+simply create an object in the schema. Put this SQL into
+F<verify/appschema.sql>:
+
+ CREATE TEMPORARY TABLE flipr.verify__ (id INT);
+
+In truth, you can use I<any> query that generates an SQL error if the schema
+doesn't exist. Another handy way to do that is to divide by zero if an object
+doesn't exist. For example, to throw an error when the C<flipr> schema does
+not exist, you could do something like this:
+
+ USE WAREHOUSE &warehouse;
+ SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'FLIPR';
+
+Note the C<USE WAREHOUSE> statement which is provided in the default Snowflake
+change script templates. For scripts that execute queries requiring compute
+resources (typically DML and C<SELECT> statements), we'll need to use a
+L<virtual warehouse|https://docs.snowflake.net/manuals/user-guide/warehouses.html>.
+This statement lets the script use the warehouse that Sqitch itself uses for
+its registry, which should be a reasonable default, since Sqitch is
+already using this warehouse. You can always change it to a different
+warehouse if need be. If not, Sqitch always sets this variable (as well as
+C<&registry> containing the name of the Sqitch registry schema) for all
+deploy, revert, and verify script executions.
+
+Now run the C<verify> script with the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ Verifying db:snowflake://movera@example/flipr?Driver=Snowflake
+ * appschema .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the schema doesn't exist, temporarily change the schema name in the script to
+something that doesn't exist, something like:
+
+ CREATE TEMPORARY TABLE nonesuch.verify__ (id INT);
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ Verifying db:snowflake://movera@example/flipr?Driver=Snowflake
+ * appschema ..
+ 002003 (02000): SQL compilation error:
+ Schema 'FLIPR.NONESUCH' does not exist.
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+It's even nice enough to tell us what the problem is. Or, for the
+divide-by-zero example, change the schema name:
+
+ USE WAREHOUSE &warehouse;
+ SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'NONESUCH';
+
+Then the verify will look something like:
+
+ > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ Verifying db:snowflake://movera@example/flipr?Driver=Snowflake
+ * appschema ..
+ 100051 (22012): Division by zero
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+Less useful error output, but enough to alert us that something has gone
+wrong.
+
+Don't forget to change the schema name back before continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the registry
+tables from the database:
+
+ > sqitch status 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ # On database db:snowflake://movera@example/flipr?Driver=Snowflake
+ # Project: flipr
+ # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a
+ # Name: appschema
+ # Deployed: 2018-07-27 10:47:23 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ Revert all changes from db:snowflake://movera@example/flipr?Driver=Snowflake? [Yes]
+ - appschema .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the schema should be gone:
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE SCHEMAS LIKE 'flipr'"
+ +------------+------+------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |------------+------+------+---------------+-------------|
+ +------------+------+------+---------------+-------------+
+ 0 Row(s) produced. Time Elapsed: 0.204s
+
+And the status message should reflect as much:
+
+ > sqitch status 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ # On database db:snowflake://movera@example/flipr?Driver=Snowflake
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ Verifying db:snowflake://movera@example/flipr?Driver=Snowflake
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ On database db:snowflake://movera@example/flipr?Driver=Snowflake
+ Revert 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2018-07-27 10:48:48 -0400
+
+ Add schema for all flipr objects.
+
+ Deploy 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2018-07-27 10:47:24 -0400
+
+ Add schema for all flipr objects.
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Now let's commit it.
+
+ > git add .
+ > git commit -m 'Add flipr schema.'
+ [master 7fd5ace] Add flipr schema.
+ 4 files changed, 10 insertions(+)
+ create mode 100644 deploy/appschema.sql
+ create mode 100644 revert/appschema.sql
+ create mode 100644 verify/appschema.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy --verify 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ Deploying changes to db:snowflake://movera@example/flipr?Driver=Snowflake
+ + appschema .. ok
+
+And now the schema should be back:
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE SCHEMAS LIKE 'flipr'"
+ +-------------------------------+-------+------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |-------------------------------+-------+------+---------------+-------------|
+ | 2018-07-27 14:52:50.116 +0000 | FLIPR | NULL | DWHEELER | NULL |
+ +-------------------------------+-------+------+---------------+-------------+
+ 1 Row(s) produced. Time Elapsed: 0.283s
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+ # On database db:snowflake://movera@example/flipr?Driver=Snowflake
+ # Project: flipr
+ # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a
+ # Name: appschema
+ # Deployed: 2018-07-27 10:52:54 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type
+C<db:snowflake://movera@example/flipr?Driver=Snowflake>, aren't you?
+This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells
+Sqitch how to connect to the deployment target, but we don't have to keep
+using the URI. We can name the target:
+
+ > sqitch target add flipr_test 'db:snowflake://movera@example/flipr?Driver=Snowflake'
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also tell
+Sqitch to deploy to the C<flipr_test> target by default:
+
+ > sqitch engine add snowflake flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a
+ # Name: appschema
+ # Deployed: 2018-07-27 10:52:54 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and and make some more changes!
+
+ > git commit -am 'Set default deployment target and always verify.'
+ [master 3834a8d] Set default deployment target and always verify.
+ 1 files changed, 8 insertions(+), 0 deletions(-)
+
+=head1 Deploy with Dependency
+
+Let's add another change, this time to create a table. Our app will need
+users, of course, so we'll create a table for them. First, add the new change:
+
+ > sqitch add users --requires appschema -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users [appschema]" to sqitch.plan
+
+Note that we're requiring the C<appschema> change as a dependency of the new
+C<users> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<users> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/users.sql> should look like
+this:
+
+ -- Deploy flipr:users to snowflake
+ -- requires: appschema
+
+ USE WAREHOUSE &warehouse;
+ CREATE TABLE flipr.users (
+ nickname TEXT PRIMARY KEY,
+ password TEXT NOT NULL,
+ fullname TEXT NOT NULL,
+ twitter TEXT NOT NULL,
+ timestamp TIMESTAMP_TZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+A few things to notice here. On the second line, the dependence on the
+C<appschema> change has been listed. This doesn't do anything, but the default
+C<deploy> Snowflake template lists it here for your reference while editing
+the file. Useful, right?
+
+The table itself will be created in the C<flipr> schema. This is why we need
+to require the C<appschema> change.
+
+On the fourth line, the C<USE WAREHOUSE> statement was inserted by the default
+Snowflake template. We don't actually need it to create a table, but there's
+no harm in leaving it here.
+
+Now for the verify script. The simplest way to check that the table was
+created and has the expected columns without touching the data? Just select
+from the table with a false C<WHERE> clause. Here the C<USE WAREHOUSE>
+statement is required so that the C<SELECT> statement can actually execute.
+Probably easiest just to leave the default, which uses the warehouse that
+Sqitch uses to maintain its registry. Edit F<verify/users.sql> to look like
+this:
+
+ USE WAREHOUSE &warehouse;
+ SELECT nickname, password, fullname, twitter, timestamp
+ FROM flipr.users
+ WHERE FALSE;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/users.sql>:
+
+ DROP TABLE flipr.users;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE TABLES LIKE 'users' IN flipr"
+ +-------------------------------+-------+-------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |-------------------------------+-------+-------+---------------+-------------|
+ | 2018-07-27 15:13:21.767 +0000 | USERS | TABLE | DWHEELER | FLIPR |
+ +-------------------------------+-------+-------+---------------+-------------+
+ 1 Row(s) produced. Time Elapsed: 0.318s
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d251b2c9b4bc46a4b4db6b7a8a637951484e6f6b
+ # Name: users
+ # Deployed: 2018-07-27 11:09:12 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to appschema from flipr_test
+ - users .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<appschema>, the penultimate change.
+The other potentially useful symbolic tag is C<@ROOT>, which refers to the
+first change deployed to the database (or in the plan, depending on the
+command).
+
+Back to the database. The C<users> table should be gone but the C<flipr> schema
+should still be around:
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE TABLES LIKE 'users' IN flipr"
+ +------------+------+------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |------------+------+------+---------------+-------------|
+ +------------+------+------+---------------+-------------+
+ 0 Row(s) produced. Time Elapsed: 0.367s
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ # On database flipr_test
+ # Project: flipr
+ # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a
+ # Name: appschema
+ # Deployed: 2018-07-27 10:52:54 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * users
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ Undeployed change:
+ * users
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "users" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add users table.'
+ [master 8c16c09] Add users table.
+ 4 files changed, 22 insertions(+)
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # Project: flipr
+ # Change: d251b2c9b4bc46a4b4db6b7a8a637951484e6f6b
+ # Name: users
+ # Deployed: 2018-07-27 11:19:30 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Excellent. Let's do some more!
+
+=head1 Add Two at Once
+
+Let's add a couple more changes. Our app will need to store status messages
+from users. Let's call them -- and the table to store them -- "flips". And
+we'll also need a view that lists user names with their flips. Let's add
+changes for them both:
+
+ > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [appschema users]" to sqitch.plan
+
+ > sqitch add userflips -r appschema -r users -r flips \
+ -n 'Creates the userflips view.'
+ Created deploy/userflips.sql
+ Created revert/userflips.sql
+ Created verify/userflips.sql
+ Added "userflips [appschema users flips]" to sqitch.plan
+
+Now might be a good time to have a look at the deployment plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-snowflake-intro/
+
+ appschema 2018-07-27T14:27:24Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2018-07-27T15:03:56Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2018-07-27T15:23:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2018-07-27T15:23:50Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+
+Each change appears on a single line with the name of the change, a bracketed
+list of dependencies, a timestamp, the name and email address of the user who
+planned the change, and a note.
+
+Let's write the code for the new changes. Here's what F<deploy/flips.sql>
+should look like:
+
+ -- Deploy flipr:flips to snowflake
+ -- requires: appschema
+ -- requires: users
+
+ USE WAREHOUSE &warehouse;
+ CREATE TABLE flipr.flips (
+ id INTEGER PRIMARY KEY,
+ nickname TEXT NOT NULL REFERENCES flipr.users(nickname),
+ body VARCHAR(180) NOT NULL DEFAULT '',
+ timestamp TIMESTAMP_TZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+Here's what F<verify/flips.sql> might look like:
+
+ -- Verify flipr:flips on snowflake
+
+ USE WAREHOUSE &warehouse;
+ SELECT id, nickname, body, timestamp
+ FROM flipr.flips
+ WHERE FALSE;
+
+And F<revert/flips.sql> should look something like this:
+
+ -- Revert flipr:flips from snowflake
+
+ USE WAREHOUSE &warehouse;
+ DROP TABLE flipr.flips;
+
+Now for C<userflips>; F<deploy/userflips.sql> might look like this:
+
+ -- Deploy flipr:userflips to snowflake
+ -- requires: appschema
+ -- requires: users
+ -- requires: flips
+
+ USE WAREHOUSE &warehouse;
+ CREATE OR REPLACE VIEW flipr.userflips AS
+ SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ FROM flipr.users u
+ JOIN flipr.flips f ON u.nickname = f.nickname;
+
+Use a C<SELECT> statement in F<verify/userflips.sql> again:
+
+ -- Verify flipr:userflips on snowflake
+
+ USE WAREHOUSE &warehouse;
+ SELECT id, nickname, fullname, body, timestamp
+ FROM flipr.userflips
+ WHERE FALSE;
+
+And of course, its C<revert> script, F<revert/userflips.sql>, should look
+something like:
+
+ -- Revert flipr:userflips from snowflake
+
+ USE WAREHOUSE &warehouse;
+ DROP VIEW flipr.userflips;
+
+Try em out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips ...... ok
+ + userflips .. ok
+
+Do we have the new table and view? Of course we do, they were verified. Still,
+have a look:
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE TABLES LIKE 'flips' IN flipr"
+ +-------------------------------+-------+-------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |-------------------------------+-------+-------+---------------+-------------|
+ | 2018-07-27 15:31:07.137 +0000 | FLIPS | TABLE | DWHEELER | FLIPR |
+ +-------------------------------+-------+-------+---------------+-------------+
+ 1 Row(s) produced. Time Elapsed: 0.225s
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE VIEWS LIKE 'userflips' IN flipr"
+ +-------------------------------+-----------+------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |-------------------------------+-----------+------+---------------+-------------|
+ | 2018-07-27 15:29:25.733 +0000 | USERFLIPS | VIEW | DWHEELER | FLIPR |
+ +-------------------------------+-----------+------+---------------+-------------+
+ 1 Row(s) produced. Time Elapsed: 0.299s
+
+And what's the status?
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 73cd50c99de2a8b3eab206c73514afbeb952023c
+ # Name: userflips
+ # Deployed: 2018-07-27 11:31:24 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Looks good. Let's make sure revert works:
+
+ > sqitch revert -y --to @HEAD^^
+ Reverting changes to users from flipr_test
+ - userflips .. ok
+ - flips ...... ok
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW TERSE TABLES LIKE 'flips' IN flipr"
+ +------------+------+------+---------------+-------------+
+ | created_on | name | kind | database_name | schema_name |
+ |------------+------+------+---------------+-------------|
+ +------------+------+------+---------------+-------------+
+ 0 Row(s) produced. Time Elapsed: 0.306s
+
+Note the use of C<@HEAD^^> to specify that the revert be to two changes prior
+the last deployed change. Looks good. Let's do the commit and re-deploy dance:
+
+ > git add .
+ > git commit -m 'Add flips table and userflips view.'
+ [master b36f48b] Add flips table and userflips view.
+ 7 files changed, 43 insertions(+)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 deploy/userflips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 revert/userflips.sql
+ create mode 100644 verify/flips.sql
+ create mode 100644 verify/userflips.sql
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips ...... ok
+ + userflips .. ok
+
+ > sqitch status
+ # Project: flipr
+ # Change: 73cd50c99de2a8b3eab206c73514afbeb952023c
+ # Name: userflips
+ # Deployed: 2018-07-27 11:38:02 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ * flips ...... ok
+ * userflips .. ok
+ Verify successful
+
+Great, we're fully up-to-date!
+
+=head1 Ship It!
+
+Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want
+to have it go out with deployments tied to the release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "userflips" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master 84ed9db] Tag the database with v1.0.0-dev1.
+ 1 files changed, 1 insertion(+)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+We can try deploying to make sure the tag gets picked up like so:
+
+ > sqitch deploy
+ Nothing to deploy (up-to-date)
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 73cd50c99de2a8b3eab206c73514afbeb952023c
+ # Name: userflips
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2018-07-27 11:38:02 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the new "Tag" line in the output of C<sqitch status>: no new changes
+needed to be deployed, but Sqitch did deploy the tag on the C<userflips>
+change. Now let's bundle everything up for release:
+
+ > sqitch bundle
+ Bundling into bundle
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. Let's try
+deploying it to another database, C<flipr_prod>:
+
+ > cd bundle
+ > sqitch deploy 'db:snowflake://movera@example/flipr_prod?Driver=Snowflake'
+ Adding registry tables to db:snowflake://movera@example/flipr_prod?Driver=Snowflake'
+ Deploying changes to db:snowflake://movera@example/flipr_prod?Driver=Snowflake'
+ + appschema ............... ok
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+
+Notice how the tag on C<userflips> now appears in the deploy output. Nice, eh?
+Now, package it up and ship it!
+
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Making a Hash of Things
+
+Now that we've got the basics of the app done, let's add a feature. Gotta
+track the hashtags associated with flips, right? Let's add a table for them.
+But since other folks are working on other tasks in the repository, we'll work
+on a branch, so we can all stay out of each other's way. So let's branch:
+
+ > git checkout -b hashtags
+ Switched to a new branch 'hashtags'
+
+Now we can add a new change to create a table for hashtags.
+
+ > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.'
+ Created deploy/hashtags.sql
+ Created revert/hashtags.sql
+ Created verify/hashtags.sql
+ Added "hashtags [appschema flips]" to sqitch.plan
+
+You know the drill by now. Add this to F<deploy/hashtags.sql>
+
+ CREATE TABLE flipr.hashtags (
+ flip_id INTEGER NOT NULL REFERENCES flipr.flips(id),
+ hashtag VARCHAR(128) NOT NULL,
+ PRIMARY KEY (flip_id, hashtag)
+ );
+
+Again, select from the table in F<verify/hashtags.sql>:
+
+ SELECT flip_id, hashtag FROM flipr.hashtags WHERE FALSE;
+
+And drop it in F<revert/hashtags.sql>
+
+ DROP TABLE flipr.hashtags;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: d750cbeec487841c45715115a31297739fbb4046
+ # Name: hashtags
+ # Deployed: 2018-07-27 11:53:02 -0400
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2018-07-27 11:41:13 -0400 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Make sure we can
+revert, too:
+
+ > sqitch revert -y --onto @HEAD^
+ Reverting changes to userflips @v1.0.0-dev1 from flipr_test
+ - hashtags .. ok
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Great! Now make it so:
+
+ > git add .
+ > git commit -m 'Add hashtags table.'
+ [hashtags 06a0bf4] Add hashtags table.
+ 4 files changed, 19 insertions(+)
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating 84ed9db..31d026c
+ Fast-forward
+ deploy/lists.sql | 11 +++++++++++
+ revert/lists.sql | 4 ++++
+ sqitch.plan | 2 ++
+ verify/lists.sql | 6 ++++++
+ 4 files changed, 23 insertions(+)
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff hashtags
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<hashtags> branch added changes to the plan. Let's
+try a different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<hashtags> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at 31d026c Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout hashtags
+ Switched to branch 'hashtags'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ error: Failed to merge in the changes.
+ Patch failed at 0001 Add hashtags table.
+ Use 'git am --show-current-patch' to see the failed patch
+
+ Resolve all conflicts manually, mark them as resolved with
+ "git add/rm <conflicted_files>", then run "git rebase --continue".
+ You can instead skip this commit: run "git rebase --skip".
+ To abort and get back to the state before "git rebase", run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to
+L<its docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+ HEAD is now at 06a0bf4 Add hashtags table.
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-snowflake-intro/
+
+ appschema 2018-07-27T14:27:24Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2018-07-27T15:03:56Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2018-07-27T15:23:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2018-07-27T15:23:50Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2018-07-27T15:40:25Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema flips] 2018-07-27T16:00:00Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [flips] 2018-07-27T15:51:16Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "hashtags" branch. Test it to make sure it works
+as expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - hashtags ................ ok
+ - userflips @v1.0.0-dev1 .. ok
+ - flips ................... ok
+ - users ................... ok
+ - appschema ............... ok
+ Deploying changes to flipr_test
+ + appschema ............... ok
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+ + lists ................... ok
+ + hashtags ................ ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [hashtags 86596a9] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff hashtags -m "Merge branch 'hashtags'"
+ Merge made by the 'recursive' strategy.
+ .gitattributes | 1 +
+ deploy/hashtags.sql | 9 ++++++++++
+ revert/hashtags.sql | 4 ++++
+ sqitch.plan | 1 +
+ verify/hashtags.sql | 4 ++++
+ 5 files changed, 19 insertions(+)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-snowflake-intro/
+
+ appschema 2018-07-27T14:27:24Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2018-07-27T15:03:56Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2018-07-27T15:23:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2018-07-27T15:23:50Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2018-07-27T15:40:25Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema flips] 2018-07-27T16:00:00Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [flips] 2018-07-27T15:51:16Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "hashtags" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 1c67e0d] Tag the database with v1.0.0-dev2.
+ 1 files changed, 1 insertion(+)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+ + lists
+ + hashtags @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Well, some folks have been testing the C<1.0.0-dev2> release and have demanded
+that Twitter user links be added to Flipr pages. Why anyone would want to
+include social network links in an anti-social networking app is beyond us
+programmers, but we're just the plumbers, right? Gotta go with what Product
+demands. The upshot is that we need to update the C<userflips> view, which is
+used for the feature in question, to include the Twitter user names.
+
+Normally, modifying views in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>.
+
+=item 2.
+
+Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the
+C<twitter> column to the view.
+
+=item 3.
+
+Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>.
+
+=item 5.
+
+Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>.
+
+=item 6.
+
+Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter>
+column.
+
+=item 7.
+
+Test the changes to make sure you can deploy and revert the
+C<userflips_twitter> change.
+
+=back
+
+But you can have Sqitch do most of the work for you. The only requirement is
+that a tag appear between the two instances of a change we want to modify. In
+general, you're going to make a change like this after a release, which you've
+tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>:
+
+ > sqitch rework userflips -n 'Adds userflips.twitter.'
+ Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/userflips.sql
+ * revert/userflips.sql
+ * verify/userflips.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<userflips> change, which we can see via C<git status>:
+
+ > git status
+ On branch master
+ Your branch is up to date with 'origin/master'.
+
+ Changes not staged for commit:
+ (use "git add <file>..." to update what will be committed)
+ (use "git checkout -- <file>..." to discard changes in working directory)
+
+ modified: revert/userflips.sql
+ modified: sqitch.plan
+
+ Untracked files:
+ (use "git add <file>..." to include in what will be committed)
+
+ deploy/userflips@v1.0.0-dev2.sql
+ revert/userflips@v1.0.0-dev2.sql
+ verify/userflips@v1.0.0-dev2.sql
+
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "Untracked files" part of the output is the first thing to notice. They're
+all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the C<userflips>
+change as it was implemented as of the C<@v1.0.0-dev2> tag." These are copies
+of the original scripts, and thereafter Sqitch will find them when it needs to
+run scripts for the first instance of the C<userflips> change. As such, it's
+important not to change them again. But hey, if you're reworking the change,
+you shouldn't need to.
+
+The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch
+replaced it with the original deploy script. As of now,
+F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on
+the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before. Of course, the original deploy script may not be
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. If it's not, you will likely need to modify it so that it
+properly restores things to how they were after the original deploy script was
+deployed. Or, more simply, it should revert changes back to how they were
+as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>.
+
+Fortunately, our view deploy scripts are already idempotent, thanks to the
+use of the C<OR REPLACE> expression. No matter how many times a deployment
+script is run, the end result will be the same instance of the view, with
+no duplicates or errors.
+
+As a result, there is no need to explicitly add changes. So go ahead. Modify
+the script to add the C<twitter> column to the view. Make this change to
+F<deploy/userflips.sql>:
+
+ @@ -5,6 +5,6 @@
+
+ USE WAREHOUSE &warehouse;
+ CREATE OR REPLACE VIEW flipr.userflips AS
+ -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ +SELECT SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp
+ FROM flipr.users u
+ JOIN flipr.flips f ON u.nickname = f.nickname;
+
+Next, modify F<verify/userflips.sql> to check for the C<twitter> column.
+Here's the diff:
+
+ @@ -1,6 +1,6 @@
+ -- Verify flipr:userflips on snowflake
+
+ -SELECT id, nickname, fullname, body, timestamp
+ +SELECT id, nickname, fullname, twitter, body, timestamp
+ FROM flipr.userflips
+ WHERE FALSE;
+
+Now try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + userflips .. ok
+
+So, are the changes deployed?
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW VIEWS LIKE 'userflips' IN flipr"
+ +-------------------------------+-----------+----------+---------------+-------------+--------+---------+---------------------------------------------------------------------+-----------+
+ | created_on | name | reserved | database_name | schema_name | owner | comment | text | is_secure |
+ |-------------------------------+-----------+----------+---------------+-------------+--------+---------+---------------------------------------------------------------------+-----------|
+ | 2018-07-27 18:19:29.818 +0000 | USERFLIPS | | DWHEELER | FLIPR | SQITCH | | CREATE OR REPLACE VIEW flipr.userflips AS | false |
+ | | | | | | | | SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp | |
+ | | | | | | | | FROM flipr.users u | |
+ | | | | | | | | JOIN flipr.flips f ON u.nickname = f.nickname; | |
+ +-------------------------------+-----------+----------+---------------+-------------+--------+---------+---------------------------------------------------------------------+-----------+
+ 1 Row(s) produced. Time Elapsed: 0.413s
+
+Awesome, the view now includes the C<twitter> column. But can we revert?
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to hashtags @v1.0.0-dev2 from flipr_test
+ - userflips .. ok
+
+Did that work, is the C<twitter> column gone?
+
+ > snowsql --accountname example --username movera --dbname flipr -o friendly=false \
+ --query "SHOW VIEWS LIKE 'userflips' IN flipr"
+ +-------------------------------+-----------+----------+---------------+-------------+--------+---------+----------------------------------------------------------+-----------+
+ | created_on | name | reserved | database_name | schema_name | owner | comment | text | is_secure |
+ |-------------------------------+-----------+----------+---------------+-------------+--------+---------+----------------------------------------------------------+-----------|
+ | 2018-07-27 18:50:52.064 +0000 | USERFLIPS | | DWHEELER | FLIPR | SQITCH | | CREATE OR REPLACE VIEW flipr.userflips AS | false |
+ | | | | | | | | SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp | |
+ | | | | | | | | FROM flipr.users u | |
+ | | | | | | | | JOIN flipr.flips f ON u.nickname = f.nickname; | |
+ +-------------------------------+-----------+----------+---------------+-------------+--------+---------+----------------------------------------------------------+-----------+
+ 1 Row(s) produced. Time Elapsed: 0.362s
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+Excellent. Let's go ahead and commit these changes:
+
+ > git add .
+ > git commit -m 'Add the twitter column to the userflips view.'
+ [master c004445] Add the twitter column to the userflips view.
+ 7 files changed, 31 insertions(+), 4 deletions(-)
+ create mode 100644 deploy/userflips@v1.0.0-dev2.sql
+ create mode 100644 revert/userflips@v1.0.0-dev2.sql
+ create mode 100644 verify/userflips@v1.0.0-dev2.sql
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchtutorial-sqlite.pod b/lib/sqitchtutorial-sqlite.pod
new file mode 100644
index 00000000..30fb1c13
--- /dev/null
+++ b/lib/sqitchtutorial-sqlite.pod
@@ -0,0 +1,1240 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial-sqlite - A tutorial introduction to Sqitch change management on SQLite
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled SQLite project, use a
+VCS for deployment planning, and work with other developers to make sure
+changes remain in sync and in the proper order.
+
+We'll start by creating new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<SQLite|https://www.sqlite.org/> as the storage engine.
+
+If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>.
+
+If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>.
+
+If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>.
+
+If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>.
+
+If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>.
+
+If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>.
+
+If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>.
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -m 'Initialize project, add README.'
+ [master (root-commit) 253542e] Initialize project, add README.
+ 1 file changed, 37 insertions(+)
+ create mode 100644 README.md
+
+If you're a Git user and want to follow along the history, the repository used
+in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-sqlite-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ > sqitch init flipr --uri https://github.com/sqitchers/sqitch-sqlite-intro/ --engine sqlite
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = sqlite
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "sqlite"]
+ # target = db:sqlite:
+ # registry = sqitch
+ # client = sqlite3
+
+Good, it picked up on the fact that we're creating changes for the SQLite
+engine, thanks to the C<--engine sqlite> option, and saved it to the file.
+Furthermore, it wrote a commented-out C<[engine "sqlite"]> section with all
+the available SQLite engine-specific settings commented out and ready to be
+edited as appropriate.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings. Since SQLite's C<sqlite3> client is not in the path on my system,
+let's go ahead an tell it where to find the client on our computer.
+
+ > sqitch config --user engine.sqlite.client /opt/local/bin/sqlite3
+
+And let's also tell it who we are, since this data will be used in all
+of our projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [engine "sqlite"]
+ client = /opt/local/bin/sqlite3
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch should be able to find C<sqlite3> for any project, and
+that it will always properly identify us when planning and committing changes.
+
+Back to the repository. Have a look at the plan file, F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-sqlite-intro/
+
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -m 'Initialize Sqitch configuration.'
+ [master 91e2f0d] Initialize Sqitch configuration.
+ 2 files changed, 19 insertions(+)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+=head1 Our First Change
+
+Let's create a table. Our app will need users, of course, so we'll create a
+table for them. Run this command:
+
+ > sqitch add users -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the table. By default,
+the F<deploy/users.sql> file looks like this:
+
+ -- Deploy flipr:users to sqlite
+
+ BEGIN;
+
+ -- XXX Add DDLs here.
+
+ COMMIT;
+
+What we want to do is to replace the C<XXX> comment with the C<CREATE TABLE>
+statement, like so:
+
+ -- Deploy flipr:users to sqlite
+
+ BEGIN;
+
+ CREATE TABLE users (
+ nickname TEXT PRIMARY KEY,
+ password TEXT NOT NULL,
+ fullname TEXT NOT NULL,
+ twitter TEXT NOT NULL,
+ timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ COMMIT;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we edit this to F<revert/users.sql> to look like this:
+
+ -- Revert flipr:users from sqlite
+
+ BEGIN;
+
+ DROP TABLE users;
+
+ COMMIT;
+
+Now we can try deploying this change. We tell Sqitch where to send the change
+via a L<database URI|https://github.com/libwww-perl/uri-db/>. Here we've
+specified a database file, F<flipr_test.db>:
+
+ > sqitch deploy db:sqlite:flipr_test.db
+ Adding registry tables to db:sqlite:sqitch.db
+ Deploying changes to db:sqlite:flipr_test.db
+ + users .. ok
+
+First Sqitch created the registry database and tables used to track database
+changes. The registry is separate from the database to which the C<users>
+change was deployed; by default, its name is C<sqitch.$suffix>, where
+C<$suffix> is the same as the suffix on the target database, if any. It lives
+in the same directory as the target database. This will be useful if you use
+the SQLite L<C<ATTACHDATABASE>|https://www.sqlite.org/lang_attach.html>
+command to manage multiple database files in a single project. In that case,
+you will want to use the same file for all the databases. Keep them all in the
+same directory with the same suffix and you get just that with the default
+sqitch database. In this case, we should end up with two databases:
+
+=over
+
+=item * F<sqitch.db>
+
+The Sqitch registry database.
+
+=item * F<flipr_test.b>
+
+The database Sqitch manages.
+
+=back
+
+If you'd like it to have a different name for the registry database, use
+C<sqitch engine add sqlite $name> to configure it (or via the
+L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be
+useful if you don't want to use the same registry database to manage multiple
+databases, or if you do, but they live in different directories.
+
+Next, Sqitch deploys changes to the target database, which we specified on the
+command-line. We only have one so far; the C<+> reinforces the idea that the
+change is being I<added> to the database.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the schema:
+
+ > sqlite3 flipr_test.db '.tables'
+ users
+
+=head2 Trust, But Verify
+
+But that's too much work. do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did was it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. The easiest way to do that with a table is to simply C<SELECT>
+from it. Put this query into F<verify/users.sql>:
+
+ SELECT nickname, password, fullname, twitter
+ FROM users
+ WHERE 0;
+
+Now you can run the C<verify> script with the L<C<verify>|sqitch-verify>
+command:
+
+ > sqitch verify db:sqlite:flipr_test.db
+ Verifying db:sqlite:flipr_test.db
+ * users .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the table doesn't exist, temporarily change the table name in the script to
+something that doesn't exist, something like:
+
+ SELECT nickname, password, timestamp
+ FROM users_nonesuch
+ WHERE 0;
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify db:sqlite:flipr_test.db
+ Verifying db:sqlite:flipr_test.db
+ * users .. Error: near line 5: no such table: users_nonesuch
+ # Verify script "verify/users.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+SQLite is kind enough to tell us what the problem is. Don't forget to change
+the table name back before continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the tables
+from the registry database:
+
+ > sqitch status db:sqlite:flipr_test.db
+ # On database db:sqlite:flipr_test.db
+ # Project: flipr
+ # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676
+ # Name: users
+ # Deployed: 2013-12-31 10:26:59 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert db:sqlite:flipr_test.db
+ Revert all changes from db:sqlite:flipr_test.db? [Yes]
+ - users .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the schema should be gone:
+
+ > sqlite3 flipr_test.db '.tables'
+
+And the status message should reflect as much:
+
+ > sqitch status db:sqlite:flipr_test.db
+ # On database db:sqlite:flipr_test.db
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify db:sqlite:flipr_test.db
+ Verifying db:sqlite:flipr_test.db
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log db:sqlite:flipr_test.db
+ On database db:sqlite:flipr_test.db
+ Revert f30fe47f5f99501fb8d481e910d9112c5ac0a676
+ Name: users
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-31 10:53:25 -0800
+
+ Creates table to track our users.
+
+ Deploy f30fe47f5f99501fb8d481e910d9112c5ac0a676
+ Name: users
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-31 10:26:59 -0800
+
+ Creates table to track our users.
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Let's tell Git to ignore F<*.db> files and then commit it.
+
+ > echo '*.db' > .gitignore
+ > git add .
+ > git commit -m 'Add users table.'
+ [master 6725454] Add users table.
+ 5 files changed, 31 insertions(+)
+ create mode 100644 .gitignore
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy db:sqlite:flipr_test.db --verify
+ Deploying changes to db:sqlite:flipr_test.db
+ + users .. ok
+
+And now the C<users> table should be back:
+
+ > sqlite3 flipr_test.db '.tables'
+ users
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status db:sqlite:flipr_test.db
+ # On database db:sqlite:flipr_test.db
+ # Project: flipr
+ # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676
+ # Name: users
+ # Deployed: 2013-12-31 10:57:55 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type
+C<db:sqlite:flipr_test.db>, aren't you? This
+L<database connection URI|https://github.com/libwww-perl/uri-db/> tells Sqitch how
+to connect to the deployment target, but we don't have to keep using the URI.
+We can name the target:
+
+ > sqitch target add flipr_test db:sqlite:flipr_test.db
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also tell
+Sqitch to deploy to the C<flipr_test> target by default:
+
+ > sqitch engine add sqlite flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676
+ # Name: users
+ # Deployed: 2013-12-31 10:57:55 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and and make some more changes!
+
+ > git commit -am 'Set default target and always verify.'
+ [master 5fb57ec] Set default target and always verify.
+ 1 file changed, 8 insertions(+)
+
+=head1 Deploy with Dependency
+
+Let's add another change. Our app will need to store status messages from
+users. Let's call them -- and the table to store them -- "flips". First, add
+the new change:
+
+ > sqitch add flips --requires users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [users]" to sqitch.plan
+
+Note that we're requiring the C<users> change as a dependency of the new
+C<flips> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<flips> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/flips.sql> should look like
+this:
+
+ -- Deploy flipr:flips to sqlite
+ -- requires: users
+
+ BEGIN;
+
+ CREATE TABLE flips (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ nickname TEXT NOT NULL REFERENCES users(nickname),
+ body TEXT NOT NULL DEFAULT '' CHECK ( length(body) <= 180 ),
+ timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ COMMIT;
+
+A couple things to notice here. On the second line, the dependence on the
+C<users> change has been listed. This doesn't do anything, but the default
+C<deploy> template lists it here for your reference while editing the file.
+Useful, right?
+
+The C<users.nickname> column references the C<users> table. This is why we
+need to require the C<users> change.
+
+Now for the verify script. Again, all we need to do is C<SELECT> from the
+table. I recommend selecting each column by name, too, to be sure that no
+column is missing. Here's the F<verify/flips.sql>:
+
+ -- Verify flipr:flips on sqlite
+
+ BEGIN;
+
+ SELECT id, nickname, body, timestamp
+ FROM flips
+ WHERE 0;
+
+ ROLLBACK;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/flips.sql>:
+
+ -- Revert flipr:flips from sqlite
+
+ BEGIN;
+
+ DROP TABLE flips;
+
+ COMMIT;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > sqlite3 flipr_test.db '.tables'
+ flips users
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * users .. ok
+ * flips .. ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 32ee57069c0d7fec52b6f86f453dc0c16bc1090a
+ # Name: flips
+ # Deployed: 2013-12-31 11:02:51 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to users from flipr_test
+ - flips .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<users>, the penultimate change. The
+other potentially useful symbolic tag is C<@ROOT>, which refers to the first
+change deployed to the database (or in the plan, depending on the command).
+
+Back to the database. The C<flips> table should be gone but the C<users> table
+should still be around:
+
+ > sqlite3 flipr_test.db '.tables'
+ users
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676
+ # Name: users
+ # Deployed: 2013-12-31 10:57:55 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * flips
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * users .. ok
+ Undeployed change:
+ * flips
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "flips" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add flips table.'
+ [master 21cba95] Add flips table.
+ 4 files changed, 30 insertions(+)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 verify/flips.sql
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 32ee57069c0d7fec52b6f86f453dc0c16bc1090a
+ # Name: flips
+ # Deployed: 2013-12-31 11:05:44 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 View to a Thrill
+
+One more thing to add before we are ready to ship a first beta release. Let's
+create a view that lists user names with their flips.
+
+ > sqitch add userflips --requires users --requires flips \
+ -n 'Creates the userflips view.'
+ Created deploy/userflips.sql
+ Created revert/userflips.sql
+ Created verify/userflips.sql
+ Added "userflips [users flips]" to sqitch.plan
+
+Now add this SQL to F<deploy/userflips.sql>:
+
+ CREATE VIEW userflips AS
+ SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname;
+
+Add this SQL to F<verify/userflips.sql>
+
+ SELECT id, nickname, fullname, body, timestamp
+ FROM userflips
+ WHERE 0;
+
+And add the C<DROP VIEW> statement to F<revert/userflips.sql>:
+
+ DROP VIEW userflips;
+
+Now Try it out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + userflips .. ok
+ > sqitch revert -y
+ Reverting all changes from flipr_test
+ - userflips .. ok
+ - flips ...... ok
+ - users ...... ok
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users ...... ok
+ + flips ...... ok
+ + userflips .. ok
+
+Looks good! Commit it.
+
+ > git add .
+ > git commit -m 'Add the userflips view.'
+ [master c74bfb4] Add the userflips view.
+ 4 files changed, 29 insertions(+)
+ create mode 100644 deploy/userflips.sql
+ create mode 100644 revert/userflips.sql
+ create mode 100644 verify/userflips.sql
+
+=head1 Ship It!
+
+Now we're ready for the first development release of our app. Let's call it
+C<1.0.0-dev1> Since we want to have it go out with deployments tied to the
+release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "userflips" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master 7a479fd] Tag the database with v1.0.0-dev1.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+We can try deploying to make sure the tag gets picked up like so:
+
+ > mkdir dev
+ > sqitch deploy db:sqlite:dev/flipr.db
+ Adding registry tables to db:sqlite:dev/sqitch.db
+ Deploying changes to db:sqlite:dev/flipr.db
+ + users ................... ok
+ + flips ................... ok
+
+Great, both changes were deployed and C<userflips> was tagged with
+C<@v1.0.0-dev1>. Let's have a look at the status:
+
+ > sqitch status db:sqlite:dev/flipr_dev.db
+ # On database db:sqlite:dev/flipr_dev.db
+ # Project: flipr
+ # Change: 60ee3aba0445bf3287f9dc1dd97b1877523fa139
+ # Name: userflips
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2013-12-31 11:19:15 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the listing of the tag as part of the status message. Now let's bundle
+everything up for release:
+
+ > rm -rf dev
+ > sqitch bundle
+ Bundling into bundle
+ Writing config
+ Writing plan
+ Writing scripts
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. Let's try
+deploying it:
+
+ > cd bundle
+ > sqitch deploy db:sqlite:flipr_prod.db
+ Adding registry tables to db:sqlite:sqitch.db
+ Deploying changes to db:sqlite:flipr_prod.db
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+
+Looks much the same as before, eh? Package it up and ship it!
+
+ > rm *.db
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Making a Hash of Things
+
+Now that we've got the basics of the app done, let's add a feature. Gotta
+track the hashtags associated with flips, right? Let's add a table for them.
+But since other folks are working on other tasks in the repository, we'll work
+on a branch, so we can all stay out of each other's way. So let's branch:
+
+ > git checkout -b hashtags
+ Switched to a new branch 'hashtags'
+
+Now we can add a new change to create a table for hashtags.
+
+ > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.'
+ Created deploy/hashtags.sql
+ Created revert/hashtags.sql
+ Created verify/hashtags.sql
+ Added "hashtags [flips]" to sqitch.plan
+
+You know the drill by now. Add this to F<deploy/hashtags.sql>
+
+ CREATE TABLE hashtags (
+ flip_id INTEGER NOT NULL REFERENCES flips(id),
+ hashtag TEXT NOT NULL CHECK ( length(hashtag) > 0 ),
+ PRIMARY KEY (flip_id, hashtag)
+ );
+
+Again, select from the table in F<verify/hashtags.sql>:
+
+ SELECT flip_id, hashtag FROM hashtags WHERE 0;
+
+And drop it in F<revert/hashtags.sql>
+
+ DROP TABLE hashtags;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: 1352464e8b5f3d5eeac76a1986379f07de43bffd
+ # Name: hashtags
+ # Deployed: 2013-12-31 11:30:53 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2013-12-31 11:13:49 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Make sure we can
+revert, too:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to userflips @v1.0.0-dev1 from flipr_test
+ - hashtags .. ok
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Great! Now make it so:
+
+ > git add .
+ > git commit -m 'Add hashtags table.'
+ [hashtags 94f02b8] Add hashtags table.
+ 4 files changed, 28 insertions(+)
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating 7a479fd..47a4107
+ Fast-forward
+ deploy/lists.sql | 13 +++++++++++++
+ revert/lists.sql | 7 +++++++
+ sqitch.plan | 2 ++
+ verify/lists.sql | 9 +++++++++
+ 4 files changed, 31 insertions(+)
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff hashtags
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<hashtags> branch added changes to the plan. Let's
+try a different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<hashtags> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at 47a4107 Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout hashtags
+ Switched to branch 'hashtags'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Failed to merge in the changes.
+ Patch failed at 0001 Add hashtags table.
+ The copy of the patch that failed is found in:
+ .git/rebase-apply/patch
+
+ When you have resolved this problem, run "git rebase --continue".
+ If you prefer to skip this patch, run "git rebase --skip" instead.
+ To check out the original branch and stop rebasing, run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to L<its
+docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-sqlite-intro/
+
+ users 2013-12-31T18:06:04Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [users] 2013-12-31T19:01:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [users flips] 2013-12-31T19:11:11Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2013-12-31T19:13:02Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [users] 2013-12-31T19:28:05Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [flips] 2013-12-31T19:30:13Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "hashtags" branch. Test it to make sure it works
+as expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - hashtags ................ ok
+ - userflips @v1.0.0-dev1 .. ok
+ - flips ................... ok
+ - users ................... ok
+ Deploying changes to flipr_test
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+ + lists ................... ok
+ + hashtags ................ ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [hashtags 4f93ac4] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 file changed, 1 insertion(+)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff hashtags -m "Merge branch 'hashtags'"
+ Merge made by the 'recursive' strategy.
+ .gitattributes | 1 +
+ deploy/hashtags.sql | 12 ++++++++++++
+ revert/hashtags.sql | 7 +++++++
+ sqitch.plan | 1 +
+ verify/hashtags.sql | 7 +++++++
+ 5 files changed, 28 insertions(+)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-sqlite-intro/
+
+ users 2013-12-31T18:06:04Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [users] 2013-12-31T19:01:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [users flips] 2013-12-31T19:11:11Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2013-12-31T19:13:02Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [users] 2013-12-31T19:28:05Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [flips] 2013-12-31T19:30:13Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "hashtags" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 7abfd9b] Tag the database with v1.0.0-dev2.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+ + lists
+ + hashtags @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Well, some folks have been testing the C<1.0.0-dev2> release and have demanded
+that Twitter user links be added to Flipr pages. Why anyone would want to
+include social network links in an anti-social networking app is beyond us
+programmers, but we're just the plumbers, right? Gotta go with what Product
+demands. The upshot is that we need to update the C<userflips> view, which is
+used for the feature in question, to include the Twitter user names.
+
+Normally, modifying views in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>.
+
+=item 2.
+
+Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the
+C<twitter> column to the view.
+
+=item 3.
+
+Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>.
+
+=item 5.
+
+Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>.
+
+=item 6.
+
+Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter>
+column.
+
+=item 7.
+
+Test the changes to make sure you can deploy and revert the
+C<userflips_twitter> change.
+
+=back
+
+But you can have Sqitch do most of the work for you. The only requirement is
+that a tag appear between the two instances of a change we want to modify. In
+general, you're going to make a change like this after a release, which you've
+tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>:
+
+ > sqitch rework userflips -n 'Adds userflips.twitter.'
+ Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/userflips.sql
+ * revert/userflips.sql
+ * verify/userflips.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<userflips> change, which we can see via C<git status>:
+
+ > git status
+ # On branch master
+ # Your branch is ahead of 'origin/master' by 4 commits.
+ # (use "git push" to publish your local commits)
+ #
+ # Changes not staged for commit:
+ # (use "git add <file>..." to update what will be committed)
+ # (use "git checkout -- <file>..." to discard changes in working directory)
+ #
+ # modified: revert/userflips.sql
+ # modified: sqitch.plan
+ #
+ # Untracked files:
+ # (use "git add <file>..." to include in what will be committed)
+ #
+ # deploy/userflips@v1.0.0-dev2.sql
+ # revert/userflips@v1.0.0-dev2.sql
+ # verify/userflips@v1.0.0-dev2.sql
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "untracked files" part of the output is the first thing to notice. They
+are all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the
+C<userflips> change as it was implemented as of the C<@v1.0.0-dev2> tag."
+These are copies of the original scripts, and thereafter Sqitch will find them
+when it needs to run scripts for the first instance of the C<userflips>
+change. As such, it's important not to change them again. But hey, if you're
+reworking the change, you shouldn't need to.
+
+The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch
+replaced it with the original deploy script. As of now,
+F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on
+the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before. Of course, the original deploy script won't be
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. It could be if SQLite supported C<CREATE OR REPLACE VIEW>, but
+since it doesn't, we will have to edit the script to drop the view before
+creating it. Or, more simply, it needs to be updated to revert changes back to
+how they were as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>.
+
+Modify F<deploy/userflips.sql> to add the C<twitter> column; in fact, let's
+also add a C<DROP VIEW IF EXISTS> statement, in case we need to rework this
+change again in the future:
+
+ @@ -4,8 +4,9 @@
+
+ BEGIN;
+
+ +DROP VIEW IF EXISTS userflips;
+ CREATE VIEW userflips AS
+ -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname;
+
+
+Next, modify F<verify/userflips.sql> to check for the C<twitter> column.
+Here's the diff:
+
+ @@ -2,7 +2,7 @@
+
+ BEGIN;
+
+ -SELECT id, nickname, fullname, body, timestamp
+ +SELECT id, nickname, fullname, twitter, body, timestamp
+ FROM userflips
+ WHERE 0;
+
+And finally, modify F<revert/userflips@v1.0.0-dev2.sql> to drop the view
+before creating it:
+
+ @@ -4,6 +4,7 @@
+
+ BEGIN;
+
+ +DROP VIEW IF EXISTS userflips;
+ CREATE VIEW userflips AS
+ SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ FROM users u
+
+Note that if we had included that statement when we originally created the
+C<userflips> change, we wouldn't have to change this file at all.
+
+Now try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + userflips .. ok
+
+So, are the changes deployed?
+
+ > sqlite3 flipr_test.db '.schema userflips'
+ CREATE VIEW userflips AS
+ SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname;
+
+Awesome, the view now includes the C<twitter> column. But can we revert?
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to hashtags @v1.0.0-dev2 from flipr_test
+ - userflips .. ok
+
+Did that work, is the C<twitter> column gone?
+
+ > sqlite3 flipr_test.db '.schema userflips'
+ CREATE VIEW userflips AS
+ SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ FROM users u
+ JOIN flips f ON u.nickname = f.nickname;
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+Excellent. Let's go ahead and commit these changes:
+
+ > git add .
+ > git commit -m 'Add the twitter column to the userflips view.'
+ [master 3eb96d9] Add the twitter column to the userflips view.
+ 7 files changed, 40 insertions(+), 4 deletions(-)
+ create mode 100644 deploy/userflips@v1.0.0-dev2.sql
+ create mode 100644 revert/userflips@v1.0.0-dev2.sql
+ create mode 100644 verify/userflips@v1.0.0-dev2.sql
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchtutorial-vertica.pod b/lib/sqitchtutorial-vertica.pod
new file mode 100644
index 00000000..ef6ebdec
--- /dev/null
+++ b/lib/sqitchtutorial-vertica.pod
@@ -0,0 +1,1390 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial-vertica - A tutorial introduction to Sqitch change management on Vertica
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled Vertica project, use a
+VCS for deployment planning, and work with other developers to make sure
+changes remain in sync and in the proper order.
+
+We'll start by creating a new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<Vertica|https://my.vertica.com/> as the storage engine, but for
+the most part you can substitute other VCSes and database engines in the
+examples as appropriate.
+
+If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>.
+
+If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>.
+
+If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>.
+
+If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>.
+
+If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>.
+
+If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>.
+
+If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>.
+
+=head2 Connection Configuration
+
+Sqitch requires ODBC to connect to the Vertica database. As such, you'll need
+to make sure that the Vertica ODBC driver is properly configured. At its
+simplest, on Unix-like systems, name the driver "Vertica" by adding this entry
+to C<odbcinst.ini> (usually found in C</etc>, C</usr/etc>, or
+C</usr/local/etc>):
+
+ [Vertica]
+ Description = ODBC for Vertica
+ Driver = /opt/vertica/lib64/libverticaodbc.so
+
+And also creating a C<vertica.ini> file in the same directory that contains:
+
+ [Driver]
+ DriverManagerEncoding=UTF-16
+ ODBCInstLib=/usr/lib64/libodbcinst.so
+ ErrorMessagesPath=/opt/vertica/lib64
+
+You might also consider naming your database connection by putting an entry in
+C<odbc.ini> (same directory), like so (assuming that Vertica is running on
+your local host):
+
+ [dbadmin]
+ Description = Vertica dbadmin connection
+ Driver = Vertica
+ Database = dbadmin
+ Servername = localhost
+ UserName = dbadmin
+ Password = password
+ Port = 5433
+ Locale = en_US
+
+See the
+L<Vertica ODBC Documentation|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDataSourceNameDSN.htm>
+for details. Specific links:
+
+=over
+
+=item * L<Unix ODBC Configuration|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDSNForLinuxSolarisAIXAndHP-UX.htm>
+
+=item * L<Additional Linux ODBC Configuration (C<vertica.ini>)|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/ClientODBC/AdditionalODBCDriverConfigurationSettings.htm>
+
+=item * L<Windows ODBC Configuration|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDSNForWindowsClients.htm>
+
+=item * L<Mac OS X ODBC Configuration|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDSNForMacintoshOSXClients.htm>
+
+=back
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -am 'Initialize project, add README.'
+
+If you're a Git user and want to follow along the history, the repository
+used in these examples is
+L<on GitHub|https://github.com/sqitchers/sqitch-vertica-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ > sqitch init flipr --uri https://github.com/sqitchers/sqitch-vertica-intro/ --engine vertica
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = vertica
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "vertica"]
+ # target = db:vertica:
+ # registry = sqitch
+ # client = vsql
+
+Good, it picked up on the fact that we're creating changes for the Vertica
+engine, thanks to the C<--engine vertica> option, and saved it to the
+file. Furthermore, it wrote a commented-out C<[engine "vertica"]> section with
+all the available Vertica engine-specific settings commented out and ready to
+be edited as appropriate.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings. Since Vertica's C<vsql> client is not in the path on my system,
+let's go ahead an tell it where to find the client on our computer:
+
+ > sqitch config --user engine.vertica.client /opt/vertica/bin/vsql
+
+And let's also tell it who we are, since this data will be used in all
+of our projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [engine "vertica"]
+ client = /opt/vertica/bin/vsql
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch should be able to find C<vsql> for any project, and
+that it will always properly identify us when planning and committing changes.
+
+Back to the repository. Have a look at the plan file, F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-vertica-intro/
+
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -am 'Initialize Sqitch configuration.'
+ [master a42564d] Initialize Sqitch configuration.
+ 2 files changed, 16 insertions(+), 0 deletions(-)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+=head1 Our First Change
+
+First, our project will need a schema. This creates a nice namespace for all
+of the objects that will be part of the flipr app. Run this command:
+
+ > sqitch add appschema -n 'Add schema for all flipr objects.'
+ Created deploy/appschema.sql
+ Created revert/appschema.sql
+ Created verify/appschema.sql
+ Added "appschema" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the schema. So we add
+this to F<deploy/appschema.sql>:
+
+ CREATE SCHEMA flipr;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we add this to F<revert/appschema.sql>:
+
+ DROP SCHEMA flipr;
+
+Now we can try deploying this change. We tell Sqitch where to send the change
+via a L<database URI|https://github.com/libwww-perl/uri-db/>, assuming the default
+C<dbadmin> database and user and an ODBC driver named C<Vertica> (see
+L</Connection Configuration> for details). If you want to first
+L<create a database|https://www.vertica.com/docs/8.1.x/HTML/index.htm#Authoring/InstallationGuide/AfterYouInstall/CreatingADatabase.htm>,
+simply use its name in place of C<dbadmin>:
+
+ > sqitch deploy 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ Adding registry tables to db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ Deploying changes to db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ + appschema .. ok
+
+First Sqitch created registry tables used to track database changes. The
+structure and name of the registry varies between databases (Vertica uses a
+schema to namespace its registry, while SQLite and MySQL use separate
+databases). Next, Sqitch deploys changes. We only have one so far; the C<+>
+reinforces the idea that the change is being C<added> to the database.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the schema:
+
+ > vsql -U dbadmin -c '\dn flipr'
+ List of schemas
+ Name | Owner | Comment
+ -------+---------+---------
+ flipr | dbadmin |
+
+=head2 Trust, But Verify
+
+But that's too much work. Do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did was it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. In Vertica, the simplest way to do so for schema is probably to
+simply create an object in the schema. Put this SQL into
+F<verify/appschema.sql>:
+
+ CREATE TABLE flipr.verify__ (id int);
+ DROP TABLE flipr.verify__;
+
+In truth, you can use I<any> query that generates an SQL error if the schema
+doesn't exist. Another handy way to do that is to divide by zero if an object
+doesn't exist. For example, to throw an error when the C<flipr> schema does
+not exist, you could do something like this:
+
+ SELECT 1/COUNT(*) FROM v_catalog.schemata WHERE schema_name = 'flipr';
+
+Either way, run the C<verify> script with the L<C<verify>|sqitch-verify>
+command:
+
+ > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ * appschema .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the schema doesn't exist, temporarily change the schema name in the script to
+something that doesn't exist, something like:
+
+ CREATE TABLE nonesuch.verify__ (id int);
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ * appschema .. vsql:verify/appschema.sql:5: ROLLBACK 4650: Schema "nonesuch" does not exist
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+It's even nice enough to tell us what the problem is. Or, for the
+divide-by-zero example, change the schema name:
+
+ SELECT 1/COUNT(*) FROM v_catalog.schemata WHERE schema_name = 'nonesuch';
+
+Then the verify will look something like:
+
+ > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ * appschema .. vsql:verify/appschema.sql:5: ERROR 2005: division by zero
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+Less useful error output, but enough to alert us that something has gone
+wrong.
+
+Don't forget to change the schema name back before continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the registry
+tables from the database:
+
+ > sqitch status 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ # On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ # Project: flipr
+ # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ # Name: appschema
+ # Deployed: 2014-09-04 15:26:28 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ Revert all changes from db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica? [Yes]
+ - appschema .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the schema should be gone:
+
+ > vsql -U dbadmin -c '\dn flipr'
+ List of schemas
+ Name | Owner | Comment
+ ------+-------+---------
+ (0 rows)
+
+And the status message should reflect as much:
+
+ > sqitch status 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ # On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ Revert f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2014-09-04 16:33:02 -0700
+
+ Add schema for all flipr objects.
+
+ Deploy f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2014-09-04 15:26:28 -0700
+
+ Add schema for all flipr objects.
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Now let's commit it.
+
+ > git add .
+ > git commit -m 'Add flipr schema.'
+ [master 9bee4bd] Add flipr schema.
+ 5 files changed, 197 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/appschema.sql
+ create mode 100644 revert/appschema.sql
+ create mode 100644 sqitch.sql
+ create mode 100644 verify/appschema.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy --verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ Deploying changes to db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ + appschema .. ok
+
+And now the schema should be back:
+
+ > vsql -U dbadmin -c '\dn flipr'
+ List of schemas
+ Name | Owner | Comment
+ -------+---------+---------
+ flipr | dbadmin |
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+ # On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica
+ # Project: flipr
+ # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ # Name: appschema
+ # Deployed: 2014-09-04 16:37:38 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type
+C<'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'>, aren't
+you? This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells
+Sqitch how to connect to the deployment target, but we don't have to keep
+using the URI. We can name the target:
+
+ > sqitch target add flipr_test 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also tell
+Sqitch to deploy to the C<flipr_test> target by default:
+
+ > sqitch engine add vertica flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ # Name: appschema
+ # Deployed: 2014-09-04 16:37:38 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and and make some more changes!
+
+ > git commit -am 'Set default deployment target and always verify.'
+ [master 469779a] Set default deployment target and always verify.
+ 1 files changed, 8 insertions(+), 0 deletions(-)
+
+=head1 Deploy with Dependency
+
+Let's add another change, this time to create a table. Our app will need
+users, of course, so we'll create a table for them. First, add the new change:
+
+ > sqitch add users --requires appschema -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users [appschema]" to sqitch.plan
+
+Note that we're requiring the C<appschema> change as a dependency of the new
+C<users> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<users> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/users.sql> should look like
+this:
+
+ -- Deploy flipr:users to vertica
+ -- requires: appschema
+
+ CREATE TABLE flipr.users (
+ nickname VARCHAR PRIMARY KEY,
+ password VARCHAR NOT NULL,
+ fullname VARCHAR(256) NOT NULL,
+ twitter VARCHAR NOT NULL,
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+
+A few things to notice here. On the second line, the dependence on the
+C<appschema> change has been listed. This doesn't do anything, but the default
+C<deploy> Vertica template lists it here for your reference while editing
+the file. Useful, right?
+
+The table itself will be created in the C<flipr> schema. This is why we need
+to require the C<appschema> change.
+
+Now for the verify script. The simplest way to check that the table was
+created and has the expected columns without touching the data? Just select
+from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>:
+
+ SELECT nickname, password, fullname, twitter, timestamp
+ FROM flipr.users
+ WHERE FALSE;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/users.sql>:
+
+ DROP TABLE flipr.users;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > vsql -U dbadmin -c '\d flipr.users'
+ List of Fields by Tables
+ Schema | Table | Column | Type | Size | Default | Not Null | Primary Key | Foreign Key
+ --------+-------+-------------+-------------+------+---------+----------+-------------+-------------
+ flipr | users | nickname | varchar(80) | 80 | | t | t |
+ flipr | users | password | varchar(80) | 80 | | t | f |
+ flipr | users | "timestamp" | timestamptz | 8 | now() | t | f |
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d647ac8c130a7e0b12c9049789e46afb4a4f6e53
+ # Name: users
+ # Deployed: 2014-09-04 16:42:45 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to appschema from flipr_test
+ - users .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<appschema>, the penultimate change.
+The other potentially useful symbolic tag is C<@ROOT>, which refers to the
+first change deployed to the database (or in the plan, depending on the
+command).
+
+Back to the database. The C<users> table should be gone but the C<flipr> schema
+should still be around:
+
+ > vsql -U dbadmin -c '\d flipr.users'
+ Did not find any relation.
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764
+ # Name: appschema
+ # Deployed: 2014-09-04 16:37:38 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * users
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ Undeployed change:
+ * users
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "users" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add users table.'
+ [master c7c24c5] Add users table.
+ 4 files changed, 18 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+ Deploying changes to flipr_test
+ + users .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d647ac8c130a7e0b12c9049789e46afb4a4f6e53
+ # Name: users
+ # Deployed: 2014-09-04 17:42:53 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Excellent. Let's do some more!
+
+=head1 Add Two at Once
+
+Let's add a couple more changes. Our app will need to store status messages
+from users. Let's call them -- and the table to store them -- "flips". And
+we'll also need a view that lists user names with their flips. Let's add
+changes for them both:
+
+ > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [appschema users]" to sqitch.plan
+
+ > sqitch add userflips -r appschema -r users -r flips \
+ -n 'Creates the userflips view.'
+ Created deploy/userflips.sql
+ Created revert/userflips.sql
+ Created verify/userflips.sql
+ Added "userflips [appschema users flips]" to sqitch.plan
+
+Now might be a good time to have a look at the deployment plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-vertica-intro/
+
+ appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+
+Each change appears on a single line with the name of the change, a bracketed
+list of dependencies, a timestamp, the name and email address of the user who
+planned the change, and a note.
+
+Let's write the code for the new changes. Here's what F<deploy/flips.sql>
+should look like:
+
+ -- Deploy flipr:flips to vertica
+ -- requires: appschema
+ -- requires: users
+
+ CREATE TABLE flipr.flips (
+ id AUTO_INCREMENT PRIMARY KEY ,
+ nickname VARCHAR NOT NULL REFERENCES flipr.users(nickname),
+ body VARCHAR(180) NOT NULL DEFAULT '',
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
+ );
+
+Here's what F<verify/flips.sql> might look like:
+
+ -- Verify flipr:flips on vertica
+ SELECT id, nickname, body, timestamp
+ FROM flipr.flips
+ WHERE FALSE;
+
+We simply take advantage of the fact that C<has_function_privilege()> throws
+an exception if the specified function does not exist.
+
+And F<revert/flips.sql> should look something like this:
+
+ -- Revert flipr:flips from vertica
+ DROP TABLE flipr.flips;
+
+Now for C<userflips>; F<deploy/userflips.sql> might look like this:
+
+ -- Deploy flipr:userflips to vertica
+ -- requires: appschema
+ -- requires: users
+ -- requires: flips
+
+ CREATE OR REPLACE VIEW flipr.userflips AS
+ SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ FROM flipr.users u
+ JOIN flipr.flips f ON u.nickname = f.nickname;
+
+Use a C<SELECT> statement in F<verify/userflips.sql> again:
+
+ -- Verify flipr:userflips on vertica
+ SELECT id, nickname, fullname, body, timestamp
+ FROM flipr.userflips
+ WHERE FALSE;
+
+And of course, its C<revert> script, F<revert/userflips.sql>, should look
+something like:
+
+ -- Revert flipr:userflips from vertica
+ DROP VIEW flipr.userflips;
+
+Try em out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips ...... ok
+ + userflips .. ok
+
+Do we have the new table and view? Of course we do, they were verified. Still,
+have a look:
+
+ > vsql -U dbadmin -c '\dt flipr.flips'
+ List of tables
+ Schema | Name | Kind | Owner | Comment
+ --------+-------+-------+---------+---------
+ flipr | flips | table | dbadmin |
+
+ > vsql -U dbadmin -c '\dv flipr.userflips'
+ List of View Fields
+ Schema | View | Column | Type | Size
+ --------+-----------+-------------+--------------+------
+ flipr | userflips | id | int | 8
+ flipr | userflips | nickname | varchar(80) | 80
+ flipr | userflips | fullname | varchar(256) | 256
+ flipr | userflips | body | varchar(180) | 180
+ flipr | userflips | "timestamp" | timestamptz | 8
+
+And what's the status?
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d1f998618fb863d93049a724fd0d2b49a29add86
+ # Name: userflips
+ # Deployed: 2014-09-04 17:51:21 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Looks good. Let's make sure revert works:
+
+ > sqitch revert -y --to @HEAD^
+ Reverting changes to users from flipr_test
+ - userflips .. ok
+ - flips ...... ok
+ > vsql -U dbadmin -c '\d flipr.flips'
+ Did not find any relation.
+ > vsql -U dbadmin -c '\dv flipr.userflips'
+ No matching relations found.
+
+Note the use of C<@HEAD^^> to specify that the revert be to two changes prior
+the last deployed change. Looks good. Let's do the commit and re-deploy dance:
+
+ > git add .
+ > git commit -m 'Add flips table and userflips view.'
+ [master c40f23f] Add flips table and userflips view.
+ 7 files changed, 41 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 deploy/userflips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 revert/userflips.sql
+ create mode 100644 verify/flips.sql
+ create mode 100644 verify/userflips.sql
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips ...... ok
+ + userflips .. ok
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d1f998618fb863d93049a724fd0d2b49a29add86
+ # Name: userflips
+ # Deployed: 2014-09-04 17:59:34 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ * flips ...... ok
+ * userflips .. ok
+ Verify successful
+
+Great, we're fully up-to-date!
+
+=head1 Ship It!
+
+Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want
+to have it go out with deployments tied to the release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "userflips" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master b07ce3d] Tag the database with v1.0.0-dev1.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+We can try deploying to make sure the tag gets picked up like so:
+
+ > sqitch deploy
+ Nothing to deploy (up-to-date)
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d1f998618fb863d93049a724fd0d2b49a29add86
+ # Name: userflips
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2014-09-04 17:59:34 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the new "Tag" line in the output of C<sqitch status>: no new changes
+needed to be deployed, but Sqitch did deploy the tag on the C<userflips>
+change. Now let's bundle everything up for release:
+
+ > sqitch bundle
+ Bundling into bundle
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. Let's try
+deploying it to another database:
+
+ > cd bundle
+ > sqitch deploy db:vertica://dbadmin:password@db.example.com:5433/flipr?Driver=Vertica
+ Adding registry tables to db:vertica://dbadmin:@db.example.com:5433/flipr?Driver=Vertica
+ Deploying changes to db:vertica://dbadmin:@db.example.com:5433/flipr?Driver=Vertica
+ + appschema ............... ok
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+
+Notice how the tag on C<userflips> now appears in the deploy output. Nice, eh?
+Now, package it up and ship it!
+
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Making a Hash of Things
+
+Now that we've got the basics of the app done, let's add a feature. Gotta
+track the hashtags associated with flips, right? Let's add a table for them.
+But since other folks are working on other tasks in the repository, we'll work
+on a branch, so we can all stay out of each other's way. So let's branch:
+
+=head1 Making a Hash of Things
+
+Now that we've got the basics of the app done, let's add a feature. Gotta
+track the hashtags associated with flips, right? Let's add a table for them.
+But since other folks are working on other tasks in the repository, we'll work
+on a branch, so we can all stay out of each other's way. So let's branch:
+
+ > git checkout -b hashtags
+ Switched to a new branch 'hashtags'
+
+Now we can add a new change to create a table for hashtags.
+
+ > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.'
+ Created deploy/hashtags.sql
+ Created revert/hashtags.sql
+ Created verify/hashtags.sql
+ Added "hashtags [appschema flips]" to sqitch.plan
+
+You know the drill by now. Add this to F<deploy/hashtags.sql>
+
+ CREATE TABLE flipr.hashtags (
+ flip_id BIGINT NOT NULL REFERENCES flipr.Flips(id),
+ hashtag VARCHAR(128) NOT NULL,
+ PRIMARY KEY (flip_id, hashtag)
+ );
+
+Again, select from the table in F<verify/hashtags.sql>:
+
+ SELECT flip_id, hashtag FROM flipr.hashtags WHERE FALSE;
+
+And drop it in F<revert/hashtags.sql>
+
+ DROP TABLE flipr.hashtags;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: fda6daef73e0ac12252bf6af5f259ccb207d4197
+ # Name: hashtags
+ # Deployed: 2014-09-05 10:46:20 -0700
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2014-09-05 09:09:38 -0700 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Make sure we can
+revert, too:
+
+ > sqitch rebase -y --onto @HEAD^
+ Reverting changes to userflips @v1.0.0-dev1 from flipr_test
+ - hashtags .. ok
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + hashtags .. ok
+
+Great! Now make it so:
+
+ > git add .
+ > git commit -m 'Add hashtags table.'
+ [hashtags d893e9c] Add hashtags table.
+ 4 files changed, 18 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating b07ce3d..05d3e5d
+ Fast-forward
+ deploy/lists.sql | 10 ++++++++++
+ revert/lists.sql | 3 +++
+ sqitch.plan | 2 ++
+ verify/lists.sql | 5 +++++
+ 4 files changed, 20 insertions(+), 0 deletions(-)
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff hashtags
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<hashtags> branch added changes to the plan. Let's
+try a different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<hashtags> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at 05d3e5d Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout hashtags
+ Switched to branch 'hashtags'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ <stdin>:16: new blank line at EOF.
+ +
+ warning: 1 line adds whitespace errors.
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Failed to merge in the changes.
+ Patch failed at 0001 Add hashtags table.
+
+ When you have resolved this problem run "git rebase --continue".
+ If you would prefer to skip this patch, instead run "git rebase --skip".
+ To restore the original branch and stop rebasing run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to
+L<its docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+ HEAD is now at d893e9c Add hashtags table.
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add hashtags table.
+ Using index info to reconstruct a base tree...
+ <stdin>:16: new blank line at EOF.
+ +
+ warning: 1 line adds whitespace errors.
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-vertica-intro/
+
+ appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "hashtags" branch. Test it to make sure it works
+as expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - hashtags ................ ok
+ - userflips @v1.0.0-dev1 .. ok
+ - flips ................... ok
+ - users ................... ok
+ - appschema ............... ok
+ Deploying changes to flipr_test
+ + appschema ............... ok
+ + users ................... ok
+ + flips ................... ok
+ + userflips @v1.0.0-dev1 .. ok
+ + lists ................... ok
+ + hashtags ................ ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [hashtags 2f065a3] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff hashtags -m "Merge branch 'hashtags'"
+ Merge made by recursive.
+ .gitattributes | 1 +
+ deploy/hashtags.sql | 10 ++++++++++
+ revert/hashtags.sql | 3 +++
+ sqitch.plan | 1 +
+ verify/hashtags.sql | 3 +++
+ 5 files changed, 18 insertions(+), 0 deletions(-)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/hashtags.sql
+ create mode 100644 revert/hashtags.sql
+ create mode 100644 verify/hashtags.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-vertica-intro/
+
+ appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view.
+ @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "hashtags" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 8a6a73b] Tag the database with v1.0.0-dev2.
+ 1 files changed, 1 insertions(+), 0 deletions(-)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + flips
+ + userflips @v1.0.0-dev1
+ + lists
+ + hashtags @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Well, some folks have been testing the C<1.0.0-dev2> release and have demanded
+that Twitter user links be added to Flipr pages. Why anyone would want to
+include social network links in an anti-social networking app is beyond us
+programmers, but we're just the plumbers, right? Gotta go with what Product
+demands. The upshot is that we need to update the C<userflips> view, which is
+used for the feature in question, to include the Twitter user names.
+
+Normally, modifying views in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>.
+
+=item 2.
+
+Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the
+C<twitter> column to the view.
+
+=item 3.
+
+Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>.
+
+=item 5.
+
+Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>.
+
+=item 6.
+
+Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter>
+column.
+
+=item 7.
+
+Test the changes to make sure you can deploy and revert the
+C<userflips_twitter> change.
+
+=back
+
+But you can have Sqitch do most of the work for you. The only requirement is
+that a tag appear between the two instances of a change we want to modify. In
+general, you're going to make a change like this after a release, which you've
+tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>:
+
+ > sqitch rework userflips -n 'Adds userflips.twitter.'
+ Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/userflips.sql
+ * revert/userflips.sql
+ * verify/userflips.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<userflips> change, which we can see via C<git status>:
+
+ > git status
+ # On branch master
+ # Changed but not updated:
+ # (use "git add <file>..." to update what will be committed)
+ # (use "git checkout -- <file>..." to discard changes in working directory)
+ #
+ # modified: revert/userflips.sql
+ # modified: sqitch.plan
+ #
+ # Untracked files:
+ # (use "git add <file>..." to include in what will be committed)
+ #
+ # deploy/userflips@v1.0.0-dev2.sql
+ # revert/userflips@v1.0.0-dev2.sql
+ # verify/userflips@v1.0.0-dev2.sql
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "untracked files" part of the output is the first thing to notice. They're
+all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the C<userflips>
+change as it was implemented as of the C<@v1.0.0-dev2> tag." These are copies
+of the original scripts, and thereafter Sqitch will find them when it needs to
+run scripts for the first instance of the C<userflips> change. As such, it's
+important not to change them again. But hey, if you're reworking the change,
+you shouldn't need to.
+
+The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch
+replaced it with the original deploy script. As of now,
+F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on
+the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before. Of course, the original deploy script may not be
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. If it's not, you will likely need to modify it so that it
+properly restores things to how they were after the original deploy script was
+deployed. Or, more simply, it should revert changes back to how they were
+as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>.
+
+Fortunately, our function deploy scripts are already idempotent, thanks to the
+use of the C<OR REPLACE> expression. No matter how many times a deployment
+script is run, the end result will be the same instance of the function, with
+no duplicates or errors.
+
+As a result, there is no need to explicitly add changes. So go ahead. Modify
+the script to add the C<twitter> column to the view. Make this change to
+F<deploy/userflips.sql>:
+
+ @@ -4,8 +4,9 @@
+
+ BEGIN;
+
+ @@ -4,6 +4,6 @@
+ -- requires: flips
+
+ CREATE OR REPLACE VIEW flipr.userflips AS
+ -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp
+ +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp
+ FROM flipr.users u
+ JOIN flipr.flips f ON u.nickname = f.nickname;
+
+Next, modify F<verify/userflips.sql> to check for the C<twitter> column.
+Here's the diff:
+
+ @@ -1,6 +1,6 @@
+ -- Verify flipr:userflips on vertica
+
+ -SELECT id, nickname, fullname, body, timestamp
+ +SELECT id, nickname, fullname, twitter, body, timestamp
+ FROM flipr.userflips
+ WHERE FALSE;
+
+Now try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + userflips .. ok
+
+So, are the changes deployed?
+
+ > vsql -U dbadmin -c '\dv flipr.userflips'
+ List of View Fields
+ Schema | View | Column | Type | Size
+ --------+-----------+-------------+--------------+------
+ flipr | userflips | id | int | 8
+ flipr | userflips | nickname | varchar(80) | 80
+ flipr | userflips | fullname | varchar(256) | 256
+ flipr | userflips | twitter | varchar(80) | 80
+ flipr | userflips | body | varchar(180) | 180
+ flipr | userflips | "timestamp" | timestamptz | 8
+
+Awesome, the view now includes the C<twitter> column. But can we revert?
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to hashtags @v1.0.0-dev2 from flipr_test
+ - userflips .. ok
+
+Did that work, is the C<twitter> column gone?
+
+ > vsql -U dbadmin -c '\dv flipr.userflips'
+ List of View Fields
+ Schema | View | Column | Type | Size
+ --------+-----------+-------------+--------------+------
+ flipr | userflips | id | int | 8
+ flipr | userflips | nickname | varchar(80) | 80
+ flipr | userflips | fullname | varchar(256) | 256
+ flipr | userflips | twitter | varchar(80) | 80
+ flipr | userflips | body | varchar(180) | 180
+ flipr | userflips | "timestamp" | timestamptz | 8
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+Excellent. Let's go ahead and commit these changes:
+
+ > git add .
+ > git commit -m 'Add the twitter column to the userflips view.'
+ [master 95d6dd0] Add the twitter column to the userflips view.
+ 7 files changed, 30 insertions(+), 4 deletions(-)
+ create mode 100644 deploy/userflips@v1.0.0-dev2.sql
+ create mode 100644 revert/userflips@v1.0.0-dev2.sql
+ create mode 100644 verify/userflips@v1.0.0-dev2.sql
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchtutorial.pod b/lib/sqitchtutorial.pod
new file mode 100644
index 00000000..0eee1f19
--- /dev/null
+++ b/lib/sqitchtutorial.pod
@@ -0,0 +1,1686 @@
+=encoding UTF-8
+
+=head1 Name
+
+sqitchtutorial - A tutorial introduction to Sqitch change management on PostgreSQL
+
+=head1 Synopsis
+
+ sqitch *
+
+=head1 Description
+
+This tutorial explains how to create a sqitch-enabled PostgreSQL project, use
+a VCS for deployment planning, and work with other developers to make sure
+changes remain in sync and in the proper order.
+
+We'll start by creating a new project from scratch, a fictional antisocial
+networking site called Flipr. All examples use L<Git|https://git-scm.com/> as
+the VCS and L<PostgreSQL|https://www.postgresql.org/> as the storage engine,
+but for the most part you can substitute other VCSes and database engines in
+the examples as appropriate.
+
+If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>.
+
+If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>.
+
+If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>.
+
+If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>.
+
+If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>.
+
+If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>.
+
+If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>.
+
+=head1 Starting a New Project
+
+Usually the first thing to do when starting a new project is to create a
+source code repository. So let's do that with Git:
+
+ > mkdir flipr
+ > cd flipr
+ > git init .
+ Initialized empty Git repository in /flipr/.git/
+ > touch README.md
+ > git add .
+ > git commit -am 'Initialize project, add README.'
+
+If you're a Git user and want to follow along the history, the repository
+used in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-intro>.
+
+Now that we have a repository, let's get started with Sqitch. Every Sqitch
+project must have a name associated with it, and, optionally, a unique URI. We
+recommend including the URI, as it increases the uniqueness of object
+identifiers internally, and will prevent the deployment of a different project
+with the same name. So let's specify one when we initialize Sqitch:
+
+ > sqitch init flipr --uri https://github.com/sqitchers/sqitch-intro/ --engine pg
+ Created sqitch.conf
+ Created sqitch.plan
+ Created deploy/
+ Created revert/
+ Created verify/
+
+Let's have a look at F<sqitch.conf>:
+
+ > cat sqitch.conf
+ [core]
+ engine = pg
+ # plan_file = sqitch.plan
+ # top_dir = .
+ # [engine "pg"]
+ # target = db:pg:
+ # registry = sqitch
+ # client = psql
+
+Good, it picked up on the fact that we're creating changes for the PostgreSQL
+engine, thanks to the C<--engine pg> option, and saved it to the file.
+Furthermore, it wrote a commented-out C<[engine "pg"]> section with all the
+available PostgreSQL engine-specific settings commented out and ready to be
+edited as appropriate.
+
+By default, Sqitch will read F<sqitch.conf> in the current directory for
+settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific
+settings. Since PostgreSQL's C<psql> client is not in the path on my system,
+let's go ahead and tell it where to find the client on our computer:
+
+ > sqitch config --user engine.pg.client /opt/local/pgsql/bin/psql
+
+And let's also tell it who we are, since this data will be used in all
+of our projects:
+
+ > sqitch config --user user.name 'Marge N. O’Vera'
+ > sqitch config --user user.email 'marge@example.com'
+
+Have a look at F<~/.sqitch/sqitch.conf> and you'll see this:
+
+ > cat ~/.sqitch/sqitch.conf
+ [engine "pg"]
+ client = /opt/local/pgsql/bin/psql
+ [user]
+ name = Marge N. O’Vera
+ email = marge@example.com
+
+Which means that Sqitch should be able to find C<psql> for any project, and
+that it will always properly identify us when planning and committing changes.
+
+Back to the repository. Have a look at the plan file, F<sqitch.plan>:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-intro/
+
+
+Note that it has picked up on the name and URI of the app we're building.
+Sqitch uses this data to manage cross-project dependencies. The
+C<%syntax-version> pragma is always set by Sqitch, so that it always knows how
+to parse the plan, even if the format changes in the future.
+
+Let's commit these changes and start creating the database changes.
+
+ > git add .
+ > git commit -am 'Initialize Sqitch configuration.'
+ [master 85e8d7c] Initialize Sqitch configuration.
+ 2 files changed, 19 insertions(+)
+ create mode 100644 sqitch.conf
+ create mode 100644 sqitch.plan
+
+=head1 Our First Change
+
+First, our project will need a schema. This creates a nice namespace for all
+of the objects that will be part of the flipr app. Run this command:
+
+ > sqitch add appschema -n 'Add schema for all flipr objects.'
+ Created deploy/appschema.sql
+ Created revert/appschema.sql
+ Created verify/appschema.sql
+ Added "appschema" to sqitch.plan
+
+The L<C<add>|sqitch-add> command adds a database change to the plan and writes
+deploy, revert, and verify scripts that represent the change. Now we edit
+these files. The C<deploy> script's job is to create the schema. So we add
+this to F<deploy/appschema.sql>:
+
+ CREATE SCHEMA flipr;
+
+The C<revert> script's job is to precisely revert the change to the deploy
+script, so we add this to F<revert/appschema.sql>:
+
+ DROP SCHEMA flipr;
+
+Now we can try deploying this change. First, we need to create a database
+to deploy to:
+
+ > createdb flipr_test
+
+Now we tell Sqitch where to send the change via a
+L<database URI|https://github.com/libwww-perl/uri-db/>:
+
+ > sqitch deploy db:pg:flipr_test
+ Adding registry tables to db:pg:flipr_test
+ Deploying to db:pg:flipr_test
+ + appschema .. ok
+
+First Sqitch created registry tables used to track database changes. The
+structure and name of the registry varies between databases (PostgreSQL uses a
+schema to namespace its registry, while SQLite and MySQL use separate
+databases). Next, Sqitch deploys changes. We only have one so far; the C<+>
+reinforces the idea that the change is being C<added> to the database.
+
+With this change deployed, if you connect to the database, you'll be able to
+see the schema:
+
+ > psql -d flipr_test -c '\dn flipr'
+ List of schemas
+ Name | Owner
+ -------+-------
+ flipr | marge
+
+=head2 Trust, But Verify
+
+But that's too much work. Do you really want to do something like that after
+every deploy?
+
+Here's where the C<verify> script comes in. Its job is to test that the deploy
+did what it was supposed to. It should do so without regard to any data that
+might be in the database, and should throw an error if the deploy was not
+successful. In PostgreSQL, the simplest way to do so for non-queryable objects
+such as schemas is to take advantage the
+L<access privilege inquiry functions|https://www.postgresql.org/docs/current/static/functions-info.html#FUNCTIONS-INFO-ACCESS-TABLE>.
+These functions conveniently throw exceptions if the object being inquired
+does not exist. For our new schema, C<has_schema_privilege()> will do very
+nicely. Put this query into F<verify/appschema.sql>:
+
+ SELECT pg_catalog.has_schema_privilege('flipr', 'usage');
+
+B<Important!> This query isn't verifying that the user has C<usage> privilege
+on schema C<flipr>. The verification will pass even if the current user
+has no usage rights.
+
+B<Important!> Both C<SELECT false;> and C<SELECT true;> queries will successfully
+pass C<verify> step. Only queries that raise an exception will fail.
+
+Such functionality may not be available to other databases, but you can use
+I<any> query that will throw an exception if the schema doesn't exist. One
+handy way to do that is to divide by zero if an object doesn't exist. So for
+other databases, assuming division by zero is fatal, you could do something
+like this:
+
+ SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'flipr';
+
+In Postgres 9.5+ you can use C<PL/pgSQL> anonymous functions with
+C<ASSERT> / C<RAISE> statements.
+
+ DO $$
+ BEGIN
+ ASSERT (SELECT has_schema_privilege('flipr', 'usage'));
+ END $$;
+
+You can use variables to perform more complex checks:
+
+ DO $$
+ DECLARE
+ result varchar;
+ BEGIN
+ result := (SELECT name FROM flipr.pipelines WHERE id = 1);
+ ASSERT result = 'Example';
+ END $$;
+
+This example ensures the record with C<id=1> in C<pipelines> table
+has C<name> field equals C<'Example'>.
+
+Either way, run the C<verify> script with the L<C<verify>|sqitch-verify>
+command:
+
+ > sqitch verify db:pg:flipr_test
+ Verifying db:pg:flipr_test
+ * appschema .. ok
+ Verify successful
+
+Looks good! If you want to make sure that the verify script correctly dies if
+the schema doesn't exist, temporarily change the schema name in the script to
+something that doesn't exist, something like:
+
+ SELECT pg_catalog.has_schema_privilege('nonesuch', 'usage');
+
+Then L<C<verify>|sqitch-verify> again:
+
+ > sqitch verify db:pg:flipr_test
+ Verifying db:pg:flipr_test
+ * appschema .. psql:verify/appschema.sql:5: ERROR: schema "nonesuch" does not exist
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+It's even nice enough to tell us what the problem is. Or, for the
+divide-by-zero example, change the schema name:
+
+ SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'nonesuch';
+
+Then the verify will look something like:
+
+ > sqitch verify db:pg:flipr_test
+ Verifying db:pg:flipr_test
+ * appschema .. psql:verify/appschema.sql:5: ERROR: division by zero
+ # Verify script "verify/appschema.sql" failed.
+ not ok
+
+ Verify Summary Report
+ ---------------------
+ Changes: 1
+ Errors: 1
+ Verify failed
+
+Less useful error output, but enough to alert us that something has gone
+wrong.
+
+Don't forget to change the schema name back before continuing!
+
+=head2 Status, Revert, Log, Repeat
+
+For purely informational purposes, we can always see how a deployment was
+recorded via the L<C<status>|sqitch-status> command, which reads the registry
+tables from the database:
+
+ > sqitch status db:pg:flipr_test
+ # On database db:pg:flipr_test
+ # Project: flipr
+ # Change: c7981df861183412b01be706889e508a63d445ca
+ # Name: appschema
+ # Deployed: 2013-12-30 15:27:15 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Let's make sure that we can revert the change:
+
+ > sqitch revert db:pg:flipr_test
+ Revert all changes from db:pg:flipr_test? [Yes]
+ - appschema .. ok
+
+The L<C<revert>|sqitch-revert> command first prompts to make sure that we
+really do want to revert. This is to prevent unnecessary accidents. You can
+pass the C<-y> option to disable the prompt. Also, notice the C<-> before the
+change name in the output, which reinforces that the change is being
+I<removed> from the database. And now the schema should be gone:
+
+ > psql -d flipr_test -c '\dn flipr'
+ List of schemas
+ Name | Owner
+ ------+-------
+
+And the status message should reflect as much:
+
+ > sqitch status db:pg:flipr_test
+ # On database db:pg:flipr_test
+ No changes deployed
+
+Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command
+has nothing to verify:
+
+ > sqitch verify db:pg:flipr_test
+ Verifying db:pg:flipr_test
+ No changes deployed
+
+However, we still have a record that the change happened, visible via the
+L<C<log>|sqitch-log> command:
+
+ > sqitch log db:pg:flipr_test
+ On database db:pg:flipr_test
+ Revert c7981df861183412b01be706889e508a63d445ca
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-30 15:38:17 -0800
+
+ Add schema for all flipr objects.
+
+ Deploy c7981df861183412b01be706889e508a63d445ca
+ Name: appschema
+ Committer: Marge N. O’Vera <marge@example.com>
+ Date: 2013-12-30 15:27:15 -0800
+
+ Add schema for all flipr objects.
+
+Note that the actions we took are shown in reverse chronological order, with
+the revert first and then the deploy.
+
+Cool. Now let's commit it.
+
+ > git add .
+ > git commit -m 'Add flipr schema.'
+ [master d812132] Add flipr schema.
+ 4 files changed, 22 insertions(+)
+ create mode 100644 deploy/appschema.sql
+ create mode 100644 revert/appschema.sql
+ create mode 100644 verify/appschema.sql
+
+And then deploy again. This time, let's use the C<--verify> option, so that
+the C<verify> script is applied when the change is deployed:
+
+ > sqitch deploy --verify db:pg:flipr_test
+ Deploying changes to db:pg:flipr_test
+ + appschema .. ok
+
+And now the schema should be back:
+
+ > psql -d flipr_test -c '\dn flipr'
+ List of schemas
+ Name | Owner
+ -------+-------
+ flipr | marge
+
+When we look at the status, the deployment will be there:
+
+ > sqitch status db:pg:flipr_test
+ # On database db:pg:flipr_test
+ # Project: flipr
+ # Change: c7981df861183412b01be706889e508a63d445ca
+ # Name: appschema
+ # Deployed: 2013-12-30 15:40:53 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 On Target
+
+I'm getting a little tired of always having to type C<db:pg:flipr_test>,
+aren't you? This L<database connection URI|https://github.com/libwww-perl/uri-db/>
+tells Sqitch how to connect to the deployment target, but we don't have
+to keep using the URI. We can name the target:
+
+ > sqitch target add flipr_test db:pg:flipr_test
+
+The L<C<target>|sqitch-target> command, inspired by
+L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one
+or more named deployment targets. We've just added a target named
+C<flipr_test>, which means we can use the string C<flipr_test> for the target,
+rather than the URI. But since we're doing so much testing, we can also use
+the L<C<engine>|sqitch-engine> command to tell Sqitch to deploy to the
+C<flipr_test> target by default:
+
+ > sqitch engine add pg flipr_test
+
+Now we can omit the target argument altogether, unless we need to deploy to
+another database. Which we will, eventually, but at least our examples will be
+simpler from here on in, e.g.:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: c7981df861183412b01be706889e508a63d445ca
+ # Name: appschema
+ # Deployed: 2013-12-30 15:40:53 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Yay, that allows things to be a little more concise. Let's also make sure that
+changes are verified after deploying them:
+
+ > sqitch config --bool deploy.verify true
+ > sqitch config --bool rebase.verify true
+
+We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime,
+let's commit the new configuration and and make some more changes!
+
+ > git commit -am 'Set default deployment target and always verify.'
+ [master a6267d3] Set default deployment target and always verify.
+ 1 file changed, 8 insertions(+)
+
+=head1 Deploy with Dependency
+
+Let's add another change, this time to create a table. Our app will need
+users, of course, so we'll create a table for them. First, add the new change:
+
+ > sqitch add users --requires appschema -n 'Creates table to track our users.'
+ Created deploy/users.sql
+ Created revert/users.sql
+ Created verify/users.sql
+ Added "users [appschema]" to sqitch.plan
+
+Note that we're requiring the C<appschema> change as a dependency of the new
+C<users> change. Although that change has already been added to the plan and
+therefore should always be applied before the C<users> change, it's a good
+idea to be explicit about dependencies.
+
+Now edit the scripts. When you're done, F<deploy/users.sql> should look like
+this:
+
+ -- Deploy flipr:users to pg
+ -- requires: appschema
+
+ BEGIN;
+
+ SET client_min_messages = 'warning';
+
+ CREATE TABLE flipr.users (
+ nickname TEXT PRIMARY KEY,
+ password TEXT NOT NULL,
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+
+ COMMIT;
+
+A few things to notice here. On the second line, the dependence on the
+C<appschema> change has been listed. This doesn't do anything, but the default
+C<deploy> PostgreSQL template lists it here for your reference while editing
+the file. Useful, right?
+
+Notice that all of the SQL code is wrapped in a transaction. This is handy for
+PostgreSQL deployments, because PostgreSQL DDLs are transactional. The upshot
+is that if any part of this deploy script fails, the whole change fails. Such
+may work less-well for database engines that don't support transactional DDLs.
+
+The table itself will be created in the C<flipr> schema. This is why we need
+to require the C<appschema> change.
+
+Now for the verify script. The simplest way to check that the table was
+created and has the expected columns without touching the data? Just select
+from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>:
+
+ SELECT nickname, password, timestamp
+ FROM flipr.users
+ WHERE FALSE;
+
+Now for the revert script: all we have to do is drop the table. Add this to
+F<revert/users.sql>:
+
+ DROP TABLE flipr.users;
+
+Couldn't be much simpler, right? Let's deploy this bad boy:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+We know, since verification is enabled, that the table must have been created.
+But for the purposes of visibility, let's have a quick look:
+
+ > psql -d flipr_test -c '\d flipr.users'
+ Table "flipr.users"
+ Column | Type | Modifiers
+ -----------+--------------------------+------------------------
+ nickname | text | not null
+ password | text | not null
+ timestamp | timestamp with time zone | not null default now()
+ Indexes:
+ "users_pkey" PRIMARY KEY, btree (nickname)
+
+We can also verify all currently deployed changes with the
+L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ * users ...... ok
+ Verify successful
+
+Now have a look at the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 77398e1dbc5fbce58b05eb67d201f15774718727
+ # Name: users
+ # Deployed: 2013-12-30 15:51:09 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Success! Let's make sure we can revert the change, as well:
+
+ > sqitch revert --to @HEAD^ -y
+ Reverting changes to appschema from flipr_test
+ - users .. ok
+
+Note that we've used the C<--to> option to specify the change to revert to.
+And what do we revert to? The symbolic tag C<@HEAD>, when passed to
+L<C<revert>|sqitch-revert>, always refers to the last change deployed to the
+database. (For other commands, it refers to the last change in the plan.)
+Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the
+last deployed change. So we revert to C<appschema>, the penultimate change.
+The other potentially useful symbolic tag is C<@ROOT>, which refers to the
+first change deployed to the database (or in the plan, depending on the
+command).
+
+Back to the database. The C<users> table should be gone but the C<flipr> schema
+should still be around:
+
+ > psql -d flipr_test -c '\d flipr.users'
+ Did not find any relation named "flipr.users".
+
+The L<C<status>|sqitch-status> command politely informs us that we have
+undeployed changes:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: c7981df861183412b01be706889e508a63d445ca
+ # Name: appschema
+ # Deployed: 2013-12-30 15:40:53 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Undeployed change:
+ * users
+
+As does the L<C<verify>|sqitch-verify> command:
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .. ok
+ Undeployed change:
+ * users
+ Verify successful
+
+Note that the verify is successful, because all currently-deployed changes are
+verified. The list of undeployed changes (just "users" here) reminds us about
+the current state.
+
+Okay, let's commit and deploy again:
+
+ > git add .
+ > git commit -am 'Add users table.'
+ [master d58ea2f] Add users table.
+ 4 files changed, 31 insertions(+)
+ create mode 100644 deploy/users.sql
+ create mode 100644 revert/users.sql
+ create mode 100644 verify/users.sql
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + users .. ok
+
+Looks good. Check the status:
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 77398e1dbc5fbce58b05eb67d201f15774718727
+ # Name: users
+ # Deployed: 2013-12-30 15:57:14 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Excellent. Let's do some more!
+
+=head1 Add Two at Once
+
+Let's add a couple more changes to add functions for managing users.
+
+ > sqitch add insert_user --requires users --requires appschema \
+ -n 'Creates a function to insert a user.'
+ Created deploy/insert_user.sql
+ Created revert/insert_user.sql
+ Created verify/insert_user.sql
+ Added "insert_user [users appschema]" to sqitch.plan
+
+ > sqitch add change_pass --requires users --requires appschema \
+ -n 'Creates a function to change a user password.'
+ Created deploy/change_pass.sql
+ Created revert/change_pass.sql
+ Created verify/change_pass.sql
+ Added "change_pass [users appschema]" to sqitch.plan
+
+Now might be a good time to have a look at the deployment plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-intro/
+
+ appschema 2013-12-30T23:19:45Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2013-12-30T23:49:00Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appschema] 2013-12-30T23:57:36Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appschema] 2013-12-30T23:57:45Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+
+Each change appears on a single line with the name of the change, a bracketed
+list of dependencies, a timestamp, the name and email address of the user who
+planned the change, and a note.
+
+Let's write the code for the new changes. Here's what
+F<deploy/insert_user.sql> should look like:
+
+ -- Deploy flipr:insert_user to pg
+ -- requires: users
+ -- requires: appschema
+
+ BEGIN;
+
+ CREATE OR REPLACE FUNCTION flipr.insert_user(
+ nickname TEXT,
+ password TEXT
+ ) RETURNS VOID LANGUAGE SQL SECURITY DEFINER AS $$
+ INSERT INTO flipr.users VALUES($1, md5($2));
+ $$;
+
+ COMMIT;
+
+Here's what F<verify/insert_user.sql> might look like:
+
+ BEGIN;
+ SELECT has_function_privilege('flipr.insert_user(text, text)', 'execute');
+ ROLLBACK;
+
+We simply take advantage of the fact that C<has_function_privilege()> throws
+an exception if the specified function does not exist.
+
+And F<revert/insert_user.sql> should look something like this:
+
+ -- Revert flipr:insert_user from pg
+ BEGIN;
+ DROP FUNCTION flipr.insert_user(TEXT, TEXT);
+ COMMIT;
+
+Now for C<change_pass>; F<deploy/change_pass.sql> might look like this:
+
+ -- Deploy flipr:change_pass to pg
+ -- requires: users
+ -- requires: appschema
+
+ BEGIN;
+
+ CREATE OR REPLACE FUNCTION flipr.change_pass(
+ nick TEXT,
+ oldpass TEXT,
+ newpass TEXT
+ ) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$
+ BEGIN
+ UPDATE flipr.users
+ SET password = md5($3)
+ WHERE nickname = $1
+ AND password = md5($2);
+ RETURN FOUND;
+ END;
+ $$;
+
+ COMMIT;
+
+Use C<has_function_privilege()> in F<verify/change_pass.sql> again:
+
+ BEGIN;
+ SELECT has_function_privilege('flipr.change_pass(text, text, text)', 'execute');
+ ROLLBACK;
+
+And of course, its C<revert> script, F<revert/change_pass.sql>, should look
+something like:
+
+ -- Revert flipr:change_pass from pg
+ BEGIN;
+ DROP FUNCTION flipr.change_pass(TEXT, TEXT, TEXT);
+ COMMIT;
+
+Try em out!
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+Do we have the functions? Of course we do, they were verified. Still, have a
+look:
+
+ > psql -d flipr_test -c '\df flipr.*'
+ List of functions
+ Schema | Name | Result data type | Argument data types | Type
+ --------+-------------+------------------+---------------------------------------+--------
+ flipr | change_pass | boolean | nick text, oldpass text, newpass text | normal
+ flipr | insert_user | void | nickname text, password text | normal
+
+And what's the status?
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 01a4f6964b89284525cb5877d222df8be70d1647
+ # Name: change_pass
+ # Deployed: 2013-12-30 15:59:44 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Looks good. Let's make sure revert works:
+
+ > sqitch revert -y --to @HEAD^^
+ Reverting changes to users from flipr_test
+ - change_pass .. ok
+ - insert_user .. ok
+ > psql -d flipr_test -c '\df flipr.*'
+ List of functions
+ Schema | Name | Result data type | Argument data types | Type
+ --------+------+------------------+---------------------+------
+
+Note the use of C<@HEAD^^> to specify that the revert be to two changes prior
+the last deployed change. Looks good. Let's do the commit and re-deploy dance:
+
+ > git add .
+ > git commit -m 'Add `insert_user()` and `change_pass()`.'
+ [master c9b4d68] Add `insert_user()` and `change_pass()`.
+ 7 files changed, 65 insertions(+)
+ create mode 100644 deploy/change_pass.sql
+ create mode 100644 deploy/insert_user.sql
+ create mode 100644 revert/change_pass.sql
+ create mode 100644 revert/insert_user.sql
+ create mode 100644 verify/change_pass.sql
+ create mode 100644 verify/insert_user.sql
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: 01a4f6964b89284525cb5877d222df8be70d1647
+ # Name: change_pass
+ # Deployed: 2013-12-30 16:00:50 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+ > sqitch verify
+ Verifying flipr_test
+ * appschema .... ok
+ * users ........ ok
+ * insert_user .. ok
+ * change_pass .. ok
+ Verify successful
+
+Great, we're fully up-to-date!
+
+=head1 Ship It!
+
+Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want
+to have it go out with deployments tied to the release, let's tag it:
+
+ > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.'
+ Tagged "change_pass" with @v1.0.0-dev1
+ > git commit -am 'Tag the database with v1.0.0-dev1.'
+ [master 0acef3e] Tag the database with v1.0.0-dev1.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1'
+
+We can try deploying to make sure the tag gets picked up like so:
+
+ > createdb flipr_dev
+ > sqitch deploy db:pg:flipr_dev
+ Adding registry tables to db:pg:flipr_dev
+ Deploying changes to db:pg:flipr_dev
+ + appschema ................. ok
+ + users ..................... ok
+ + insert_user ............... ok
+ + change_pass @v1.0.0-dev1 .. ok
+
+Great, all four changes were deployed and C<change_pass> was tagged with
+C<@v1.0.0-dev1>. Let's have a look at the status:
+
+ > sqitch status db:pg:flipr_dev
+ # On database db:pg:flipr_dev
+ # Project: flipr
+ # Change: 01a4f6964b89284525cb5877d222df8be70d1647
+ # Name: change_pass
+ # Tag: @v1.0.0-dev1
+ # Deployed: 2013-12-30 16:02:19 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+
+Note the listing of the tag as part of the status message. Now let's bundle
+everything up for release:
+
+ > sqitch bundle
+ Bundling into bundle/
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + insert_user
+ + change_pass @v1.0.0-dev1
+
+Now we can package the F<bundle> directory and distribute it. When it gets
+installed somewhere, users can use Sqitch to deploy to the database. Let's try
+deploying it:
+
+ > cd bundle
+ > createdb flipr_prod
+ > sqitch deploy db:pg:flipr_prod
+ Adding registry tables to db:pg:flipr_prod
+ Deploying changes to db:pg:flipr_prod
+ + appschema ................. ok
+ + users ..................... ok
+ + insert_user ............... ok
+ + change_pass @v1.0.0-dev1 .. ok
+
+Looks much the same as before, eh? Package it up and ship it!
+
+ > cd ..
+ > mv bundle flipr-v1.0.0-dev1
+ > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1
+
+=head1 Flip Out
+
+Now that we've got the basics of user management done, let's get to work on
+the core of our product, the "flip." Since other folks are working on other
+tasks in the repository, we'll work on a branch, so we can all stay out of
+each other's way. So let's branch:
+
+ > git checkout -b flips
+ Switched to a new branch 'flips'
+
+Now we can add a new change to create a table for our flips.
+
+ > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.'
+ Created deploy/flips.sql
+ Created revert/flips.sql
+ Created verify/flips.sql
+ Added "flips [appschema users]" to sqitch.plan
+
+You know the drill by now. Edit F<deploy/flips.sql>:
+
+ -- Deploy flipr:flips to pg
+ -- requires: appschema
+ -- requires: users
+
+ BEGIN;
+
+ SET client_min_messages = 'warning';
+
+ CREATE TABLE flipr.flips (
+ id BIGSERIAL PRIMARY KEY,
+ nickname TEXT NOT NULL REFERENCES flipr.users(nickname),
+ body TEXT NOT NULL DEFAULT '' CHECK ( length(body) <= 180 ),
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
+ );
+
+ COMMIT;
+
+Edit F<verify/flips.sql>:
+
+ -- Verify flipr:flips on pg
+
+ BEGIN;
+
+ SELECT id
+ , nickname
+ , body
+ , timestamp
+ FROM flipr.flips
+ WHERE FALSE;
+
+ ROLLBACK;
+
+And edit F<revert/flips.sql>:
+
+ -- Revert flipr:flips from pg
+
+ BEGIN;
+
+ DROP TABLE flipr.flips;
+
+ COMMIT;
+
+And give it a whirl:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + flips .. ok
+
+Look good?
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: 4d164ef5986450f00a565735518b1d126f8ee69d
+ # Name: flips
+ # Deployed: 2013-12-30 16:34:38 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2013-12-30 16:34:38 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Note the use of C<--show-tags> to show all the deployed tags. Now make it so:
+
+ > git add .
+ [flips e8f4655] Add flips table.
+ > git commit -am 'Add flips table.'
+ 4 files changed, 37 insertions(+)
+ create mode 100644 deploy/flips.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 verify/flips.sql
+
+=head1 Wash, Rinse, Repeat
+
+Now comes the time to add functions to manage flips. I'm sure you have things
+nailed down now. Go ahead and add C<insert_flip> and C<delete_flip> changes
+and commit them. The C<insert_flip> deploy script might look something like:
+
+ -- Deploy flipr:insert_flip to pg
+ -- requires: flips
+ -- requires: appschema
+ -- requires: users
+
+ BEGIN;
+
+ CREATE OR REPLACE FUNCTION flipr.insert_flip(
+ nickname TEXT,
+ body TEXT
+ ) RETURNS BIGINT LANGUAGE sql SECURITY DEFINER AS $$
+ INSERT INTO flipr.flips (nickname, body)
+ VALUES ($1, $2)
+ RETURNING id;
+ $$;
+
+ COMMIT;
+
+And the C<delete_flip> deploy script might look something like:
+
+ -- Deploy flipr:delete_flip to pg
+ -- requires: flips
+ -- requires: appschema
+ -- requires: users
+
+ BEGIN;
+
+ CREATE OR REPLACE FUNCTION flipr.delete_flip(
+ flip_id BIGINT
+ ) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$
+ BEGIN
+ DELETE FROM flipr.flips WHERE id = flip_id;
+ RETURN FOUND;
+ END;
+ $$;
+
+ COMMIT;
+
+The C<verify> scripts are:
+
+ -- Verify flipr:insert_flip on pg
+
+ BEGIN;
+
+ SELECT has_function_privilege('flipr.insert_flip(text, text)', 'execute');
+
+ ROLLBACK;
+
+And:
+
+ -- Verify flipr:delete_flip on pg
+
+ BEGIN;
+
+ SELECT has_function_privilege('flipr.delete_flip(bigint)', 'execute');
+
+ ROLLBACK;
+
+The C<revert> scripts are:
+
+ -- Revert flipr:insert_flip from pg
+
+ BEGIN;
+
+ DROP FUNCTION flipr.insert_flip(TEXT, TEXT);
+
+ COMMIT;
+
+And:
+
+ -- Revert flipr:delete_flip from pg
+
+ BEGIN;
+
+ DROP FUNCTION flipr.delete_flip(BIGINT);
+
+ COMMIT;
+
+Check the L<example git repository|https://github.com/sqitchers/sqitch-intro> for
+the complete details. Test L<C<deploy>|sqitch-deploy> and
+L<C<revert>|sqitch-revert>, then commit it to the repository. The status
+should end up looking something like this:
+
+ > sqitch status --show-tags
+ # On database flipr_test
+ # Project: flipr
+ # Change: 9a645034b35fa46df37a3725c480982628cc64ec
+ # Name: delete_flip
+ # Deployed: 2013-12-30 16:37:51 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ # Tag:
+ # @v1.0.0-dev1 - 2013-12-30 16:34:38 -0800 - Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+Good, we've finished this feature. Time to merge back into C<master>.
+
+=head2 Emergency
+
+Let's do it:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git pull
+ Updating 0acef3e..d4cbd7d
+ Fast-forward
+ deploy/delete_list.sql | 20 ++++++++++++++++++++
+ deploy/insert_list.sql | 17 +++++++++++++++++
+ deploy/lists.sql | 16 ++++++++++++++++
+ revert/delete_list.sql | 7 +++++++
+ revert/insert_list.sql | 7 +++++++
+ revert/lists.sql | 7 +++++++
+ sqitch.plan | 4 ++++
+ verify/delete_list.sql | 7 +++++++
+ verify/insert_list.sql | 7 +++++++
+ verify/lists.sql | 9 +++++++++
+ 10 files changed, 101 insertions(+)
+ create mode 100644 deploy/delete_list.sql
+ create mode 100644 deploy/insert_list.sql
+ create mode 100644 deploy/lists.sql
+ create mode 100644 revert/delete_list.sql
+ create mode 100644 revert/insert_list.sql
+ create mode 100644 revert/lists.sql
+ create mode 100644 verify/delete_list.sql
+ create mode 100644 verify/insert_list.sql
+ create mode 100644 verify/lists.sql
+
+Hrm, that's interesting. Looks like someone made some changes to C<master>.
+They added list support. Well, let's see what happens when we merge our
+changes.
+
+ > git merge --no-ff flips
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Automatic merge failed; fix conflicts and then commit the result.
+
+Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged
+C<lists> branch and our C<flips> branch added changes to the plan. Let's try a
+different approach.
+
+The truth is, we got lazy. Those changes when we pulled master from the origin
+should have raised a red flag. It's considered a bad practice not to look at
+what's changed in C<master> before merging in a branch. What one I<should> do
+is either:
+
+=over
+
+=item *
+
+Rebase the F<flips> branch from master before merging. This "rewinds" the
+branch changes, pulls from C<master>, and then replays the changes back on top
+of the pulled changes.
+
+=item *
+
+Create a patch and apply I<that> to master. This is the sort of thing you
+might have to do if you're sending changes to another user, especially if the
+VCS is not Git.
+
+=back
+
+So let's restore things to how they were at master:
+
+ > git reset --hard HEAD
+ HEAD is now at ff60b9b Merge branch 'lists'
+
+That throws out our botched merge. Now let's go back to our branch and rebase
+it on C<master>:
+
+ > git checkout flips
+ Switched to branch 'flips'
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add flips table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ CONFLICT (content): Merge conflict in sqitch.plan
+ Failed to merge in the changes.
+ Patch failed at 0001 Add flips table.
+ The copy of the patch that failed is found in:
+ .git/rebase-apply/patch
+
+ When you have resolved this problem, run "git rebase --continue".
+ If you prefer to skip this patch, run "git rebase --skip" instead.
+ To check out the original branch and stop rebasing, run "git rebase --abort".
+
+Oy, that's kind of a pain. It seems like no matter what we do, we'll need to
+resolve conflicts in that file. Except in Git. Fortunately for us, we can tell
+Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever
+append lines to the file, we can have it use the "union" merge driver, which,
+according to L<its
+docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>:
+
+=over
+
+Run 3-way file level merge for text files, but take lines from both versions,
+instead of leaving conflict markers. This tends to leave the added lines in
+the resulting file in random order and the user should verify the result. Do
+not use this if you do not understand the implications.
+
+=back
+
+This has the effect of appending lines from all the merging files, which is
+exactly what we need. So let's give it a try. First, back out the botched
+rebase:
+
+ > git rebase --abort
+
+Now add the union merge driver to F<.gitattributes> for F<sqitch.plan>
+and rebase again:
+
+ > echo sqitch.plan merge=union > .gitattributes
+ > git rebase master
+ First, rewinding head to replay your work on top of it...
+ Applying: Add flips table.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+ Applying: Add functions to insert and delete flips.
+ Using index info to reconstruct a base tree...
+ M sqitch.plan
+ Falling back to patching base and 3-way merge...
+ Auto-merging sqitch.plan
+
+Ah, that looks a bit better. Let's have a look at the plan:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-intro/
+
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-intro/
+
+ appschema 2013-12-30T23:19:45Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2013-12-30T23:49:00Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appschema] 2013-12-30T23:57:36Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appschema] 2013-12-30T23:57:45Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+ @v1.0.0-dev1 2013-12-31T00:01:22Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2013-12-31T00:39:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ insert_list [lists appschema users] 2013-12-31T00:41:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ delete_list [lists appschema users] 2013-12-31T00:41:37Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list.
+ flips [appschema users] 2013-12-31T00:32:39Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ insert_flip [flips appschema users] 2013-12-31T00:35:59Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip.
+ delete_flip [flips appschema users] 2013-12-31T00:36:34Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip.
+
+Note that it has appended the changes from the merged "lists" branch, and then
+merged the changes from our "flips" branch. Test it to make sure it works as
+expected:
+
+ > sqitch rebase -y
+ Reverting all changes from flipr_test
+ - delete_flip ............... ok
+ - insert_flip ............... ok
+ - flips ..................... ok
+ - change_pass @v1.0.0-dev1 .. ok
+ - insert_user ............... ok
+ - users ..................... ok
+ - appschema ................. ok
+ Deploying changes to flipr_test
+ + appschema ................. ok
+ + users ..................... ok
+ + insert_user ............... ok
+ + change_pass @v1.0.0-dev1 .. ok
+ + lists ..................... ok
+ + insert_list ............... ok
+ + delete_list ............... ok
+ + flips ..................... ok
+ + insert_flip ............... ok
+ + delete_flip ............... ok
+
+Note the use of L<C<rebase>|sqitch-rebase>, which combines a
+L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single
+command. Handy, right? It correctly reverted our changes, and then deployed
+them all again in the proper order. So let's commit F<.gitattributes>; seems
+worthwhile to keep that change:
+
+ > git add .
+ > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.'
+ [flips f5ad242] Add `.gitattributes` with union merge for `sqitch.plan`.
+ 1 file changed, 1 insertion(+)
+ create mode 100644 .gitattributes
+
+=head2 Merges Mastered
+
+And now, finally, we can merge into C<master>:
+
+ > git checkout master
+ Switched to branch 'master'
+ > git merge --no-ff flips
+ Merge made by the 'recursive' strategy.
+ .gitattributes | 1 +
+ deploy/delete_flip.sql | 17 +++++++++++++++++
+ deploy/flips.sql | 16 ++++++++++++++++
+ deploy/insert_flip.sql | 17 +++++++++++++++++
+ revert/delete_flip.sql | 7 +++++++
+ revert/flips.sql | 7 +++++++
+ revert/insert_flip.sql | 7 +++++++
+ sqitch.plan | 3 +++
+ verify/delete_flip.sql | 7 +++++++
+ verify/flips.sql | 12 ++++++++++++
+ verify/insert_flip.sql | 7 +++++++
+ 11 files changed, 101 insertions(+)
+ create mode 100644 .gitattributes
+ create mode 100644 deploy/delete_flip.sql
+ create mode 100644 deploy/flips.sql
+ create mode 100644 deploy/insert_flip.sql
+ create mode 100644 revert/delete_flip.sql
+ create mode 100644 revert/flips.sql
+ create mode 100644 revert/insert_flip.sql
+ create mode 100644 verify/delete_flip.sql
+ create mode 100644 verify/flips.sql
+ create mode 100644 verify/insert_flip.sql
+
+And double-check our work:
+
+ > cat sqitch.plan
+ %syntax-version=1.0.0
+ %project=flipr
+ %uri=https://github.com/sqitchers/sqitch-intro/
+
+ appschema 2013-12-30T23:19:45Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects.
+ users [appschema] 2013-12-30T23:49:00Z Marge N. O’Vera <marge@example.com> # Creates table to track our users.
+ insert_user [users appschema] 2013-12-30T23:57:36Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user.
+ change_pass [users appschema] 2013-12-30T23:57:45Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password.
+ @v1.0.0-dev1 2013-12-31T00:01:22Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1.
+
+ lists [appschema users] 2013-12-31T00:39:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists.
+ insert_list [lists appschema users] 2013-12-31T00:41:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list.
+ delete_list [lists appschema users] 2013-12-31T00:41:37Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list.
+ flips [appschema users] 2013-12-31T00:32:39Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips.
+ insert_flip [flips appschema users] 2013-12-31T00:35:59Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip.
+ delete_flip [flips appschema users] 2013-12-31T00:36:34Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip.
+
+Much much better, a nice clean master now. And because it is now identical to
+the "flips" branch, we can just carry on. Go ahead and tag it, bundle, and
+release:
+
+ > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.'
+ Tagged "delete_flip" with @v1.0.0-dev2
+ > git commit -am 'Tag the database with v1.0.0-dev2.'
+ [master 230603b] Tag the database with v1.0.0-dev2.
+ 1 file changed, 1 insertion(+)
+ > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2'
+ > sqitch bundle --dest-dir flipr-1.0.0-dev2
+ Bundling into flipr-1.0.0-dev2
+ Writing config
+ Writing plan
+ Writing scripts
+ + appschema
+ + users
+ + insert_user
+ + change_pass @v1.0.0-dev1
+ + lists
+ + insert_list
+ + delete_list
+ + flips
+ + insert_flip
+ + delete_flip @v1.0.0-dev2
+
+Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way
+to create the top-level directory name so we don't have to rename it from
+F<bundle>.
+
+=head1 In Place Changes
+
+Uh-oh, someone just noticed that MD5 hashing is not particularly secure. Why?
+Have a look at this:
+
+ > psql -d flipr_test -c "
+ SELECT flipr.insert_user('foo', 'secr3t'), flipr.insert_user('bar', 'secr3t');
+ SELECT * FROM flipr.users;
+ "
+ nickname | password | timestamp
+ ----------+----------------------------------+-------------------------------
+ foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 00:56:20.240481+00
+ bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 00:56:20.240481+00
+
+If user "foo" ever got access to the database, she could quickly discover that
+user "bar" has the same password and thus be able to exploit the account. Not
+a great idea. So we need to modify the C<insert_user()> and C<change_pass()>
+functions to fix that. How?
+
+We'll use
+L<C<pgcrypto>|https://www.postgresql.org/docs/current/static/pgcrypto.html>'s
+C<crypt()> function to encrypt passwords with a salt, so that they're all
+unique. We just add a change to add C<pgcrypto> to the database, and then we
+can use it. The deploy script should be:
+
+ CREATE EXTENSION pgcrypto;
+
+And the revert script should be:
+
+ DROP EXTENSION pgcrypto;
+
+=over
+
+If you're on PostgreSQL 9.0 or lower, you won't be able to deploy C<pgcrypto>
+with a Sqitch change, alas. You'll have to install it manually, like so:
+
+ psql -d flipr_test -f /path/to/pgsql/share/contrib/pgcrypto.sql
+
+Don't forget to do this with your staging and production databases, too. Or
+consider upgrading to PostgreSQL 9.1 or higher; the SQL-level extension
+support is amazingly useful.
+
+=back
+
+We're going to use the C<crypt()> and C<gen_salt()> functions, so in the
+C<verify> script, let's make sure that the extension exists I<and> that both
+those functions exist:
+
+ SELECT 1/count(*) FROM pg_extension WHERE extname = 'pgcrypto';
+ SELECT has_function_privilege('crypt(text, text)', 'execute');
+ SELECT has_function_privilege('gen_salt(text)', 'execute');
+
+Now we can use C<pgcrypto>. But how to deploy the changes to C<insert_user()>
+and C<change_pass()>?
+
+Normally, modifying functions in database changes is a
+L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make
+changes like these:
+
+=over
+
+=item 1.
+
+Copy F<deploy/insert_user.sql> to F<deploy/insert_user_crypt.sql>.
+
+=item 2.
+
+Edit F<deploy/insert_user_crypt.sql> to switch from C<MD5()> to C<crypt()>
+and to add a dependency on the C<pgcrypto> change.
+
+=item 3.
+
+Copy F<deploy/insert_user.sql> to F<revert/insert_user_crypt.sql>.
+Yes, copy the original change script to the new revert change.
+
+=item 4.
+
+Copy F<verify/insert_user.sql> to F<verify/insert_user_crypt.sql>.
+
+=item 5.
+
+Edit F<verify/insert_user_crypt.sql> to test that the function now properly
+uses C<crypt()>.
+
+=item 6.
+
+Test the changes to make sure you can deploy and revert the
+C<insert_user_crypt> change.
+
+=item 7.
+
+Now do the same for the C<change_pass> scripts.
+
+=back
+
+But you can have Sqitch do it for you. The only requirement is that a tag
+appear between the two instances of a change we want to modify. In general,
+you're going to make a change like this after a release, which you've tagged
+anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous
+section. With that, we can let Sqitch do most of the hard work for us, thanks
+to the L<C<rework>|sqitch-rework> command, which is similar to
+L<C<add>|sqitch-add>, including support for the C<--requires> option:
+
+ > sqitch rework insert_user --requires pgcrypto -n 'Change insert_user to use pgcrypto.'
+ Added "insert_user [insert_user@v1.0.0-dev2 pgcrypto]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/insert_user.sql
+ * revert/insert_user.sql
+ * verify/insert_user.sql
+
+Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in
+point of fact, it has copied the files to stand in for the previous instance
+of the C<insert_user> change, which we can see via C<git status>:
+
+ > git status
+ # On branch master
+ # Changes not staged for commit:
+ # (use "git add <file>..." to update what will be committed)
+ # (use "git checkout -- <file>..." to discard changes in working directory)
+ #
+ # modified: revert/insert_user.sql
+ # modified: sqitch.plan
+ #
+ # Untracked files:
+ # (use "git add <file>..." to include in what will be committed)
+ #
+ # deploy/insert_user@v1.0.0-dev2.sql
+ # revert/insert_user@v1.0.0-dev2.sql
+ # verify/insert_user@v1.0.0-dev2.sql
+ no changes added to commit (use "git add" and/or "git commit -a")
+
+The "untracked files" part of the output is the first thing to notice. They
+are all named C<insert_user@v1.0.0-dev2.sql>. What that means is: "the
+C<insert_user> change as it was implemented as of the C<@v1.0.0-dev2> tag."
+These are copies of the original scripts, and thereafter Sqitch will find them
+when it needs to run scripts for the first instance of the C<insert_user>
+change. As such, it's important not to change them again. But hey, if you're
+reworking the change, you shouldn't need to.
+
+The other thing to notice is that F<revert/insert_user.sql> has changed.
+Sqitch replaced it with the original deploy script. As of now,
+F<deploy/insert_user.sql> and F<revert/insert_user.sql> are identical. This is
+on the assumption that the deploy script will be changed (we're reworking it,
+remember?), and that the revert script should actually change things back to
+how they were before. Of course, the original deploy script may not be
+L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be
+applied multiple times without changing the result beyond the initial
+application. If it's not, you will likely need to modify it so that it
+properly restores things to how they were after the original deploy script was
+deployed. Or, more simply, it should revert changes back to how they were
+as-of the deployment of F<deploy/insert_user@v1.0.0-dev2.sql>.
+
+Fortunately, our function deploy scripts are already idempotent, thanks to the
+use of the C<OR REPLACE> expression. No matter how many times a deployment
+script is run, the end result will be the same instance of the function, with
+no duplicates or errors.
+
+As a result, there is no need to explicitly add changes. So go ahead. Modify the
+script to switch to C<crypt()>. Make this change to
+F<deploy/insert_user.sql>:
+
+ @@ -1,6 +1,7 @@
+ -- Deploy flipr:insert_user to pg
+ -- requires: users
+ -- requires: appschema
+ +-- requires: pgcrypto
+
+ BEGIN;
+
+ @@ -8,7 +9,7 @@ CREATE OR REPLACE FUNCTION flipr.insert_user(
+ nickname TEXT,
+ password TEXT
+ ) RETURNS VOID LANGUAGE SQL SECURITY DEFINER AS $$
+ - INSERT INTO flipr.users VALUES($1, md5($2));
+ + INSERT INTO flipr.users values($1, crypt($2, gen_salt('md5')));
+ $$;
+
+ COMMIT;
+
+Go ahead and rework the C<change_pass> change, too:
+
+ > sqitch rework change_pass --requires pgcrypto -n 'Change change_pass to use pgcrypto.'
+ Added "change_pass [change_pass@v1.0.0-dev2 pgcrypto]" to sqitch.plan.
+ Modify these files as appropriate:
+ * deploy/change_pass.sql
+ * revert/change_pass.sql
+ * verify/change_pass.sql
+
+And make this change to F<deploy/change_pass.sql>:
+
+ @@ -1,6 +1,7 @@
+ -- Deploy flipr:change_pass to pg
+ -- requires: users
+ -- requires: appschema
+ +-- requires: pgcrypto
+
+ BEGIN;
+
+ @@ -11,9 +12,9 @@ CREATE OR REPLACE FUNCTION flipr.change_pass(
+ ) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$
+ BEGIN
+ UPDATE flipr.users
+ - SET password = md5($3)
+ + SET password = crypt($3, gen_salt('md5'))
+ WHERE nickname = $1
+ - AND password = md5($2);
+ + AND password = crypt($2, password);
+ RETURN FOUND;
+ END;
+ $$;
+
+And then try a deployment:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+So, are the changes deployed?
+
+ > psql -d flipr_test -c "
+ DELETE FROM flipr.users;
+ SELECT flipr.insert_user('foo', 'secr3t'), flipr.insert_user('bar', 'secr3t');
+ SELECT * FROM flipr.users;
+ "
+ nickname | password | timestamp
+ ----------+------------------------------------+-------------------------------
+ foo | $1$pRNfJjI9$CdcEXJ9xCoJPD.R5Z/7.R1 | 2013-12-31 01:03:15.398572+00
+ bar | $1$Nf1LcU.p$B9sKzdu8vMgu5oxbimo5P1 | 2013-12-31 01:03:15.398572+00
+
+Awesome, the stored passwords are different now. But can we revert, even
+though we haven't written any reversion scripts?
+
+ > sqitch revert --to @HEAD^^ -y
+ Reverting changes to pgcrypto from flipr_test
+ - change_pass .. ok
+ - insert_user .. ok
+
+Did that work, are the C<MD5()> passwords back?
+
+ > psql -d flipr_test -c "
+ DELETE FROM flipr.users;
+ SELECT flipr.insert_user('foo', 'secr3t'), flipr.insert_user('bar', 'secr3t');
+ SELECT * FROM flipr.users;
+ "
+ nickname | password | timestamp
+ ----------+----------------------------------+-------------------------------
+ foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 01:03:57.263583+00
+ bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 01:03:57.263583+00
+
+Yes, it works! Sqitch properly finds the original instances of these changes
+in the new script files that include tags.
+
+But what about the verify script? How can we verify that the functions have
+been modified to use C<crypt()>? I think the simplest thing to do is to
+examine the body of the function, using
+L<C<pg_get_functiondef()>|https://www.postgresql.org/docs/9.2/static/functions-info.html#FUNCTIONS-INFO-CATALOG-TABLE>. So the C<insert_user> verify script looks like this:
+
+ -- Verify flipr:insert_user on pg
+
+ BEGIN;
+
+ SELECT has_function_privilege('flipr.insert_user(text, text)', 'execute');
+
+ SELECT 1/COUNT(*)
+ FROM pg_catalog.pg_proc
+ WHERE proname = 'insert_user'
+ AND pg_get_functiondef(oid) LIKE $$%crypt($2, gen_salt('md5'))%$$;
+
+ ROLLBACK;
+
+And the C<change_pass> verify script looks like this:
+
+ -- Verify flipr:change_pass on pg
+
+ BEGIN;
+
+ SELECT has_function_privilege('flipr.change_pass(text, text, text)', 'execute');
+
+ SELECT 1/COUNT(*)
+ FROM pg_catalog.pg_proc
+ WHERE proname = 'change_pass'
+ AND pg_get_functiondef(oid) LIKE $$%crypt($3, gen_salt('md5'))%$$;
+
+ ROLLBACK;
+
+Make sure these pass by re-deploying:
+
+ > sqitch deploy
+ Deploying changes to flipr_test
+ + insert_user .. ok
+ + change_pass .. ok
+
+Excellent. Let's go ahead and commit these changes:
+
+ > git add .
+ > git commit -m 'Use pgcrypto to encrypt passwords.'
+ [master 4257ae6] Use pgcrypto to encrypt passwords.
+ 13 files changed, 107 insertions(+), 9 deletions(-)
+ create mode 100644 deploy/change_pass@v1.0.0-dev2.sql
+ create mode 100644 deploy/insert_user@v1.0.0-dev2.sql
+ create mode 100644 revert/change_pass@v1.0.0-dev2.sql
+ create mode 100644 revert/insert_user@v1.0.0-dev2.sql
+ create mode 100644 verify/change_pass@v1.0.0-dev2.sql
+ create mode 100644 verify/insert_user@v1.0.0-dev2.sql
+
+ > sqitch status
+ # On database flipr_test
+ # Project: flipr
+ # Change: d3ffa30b72abaf9619ae1f0e726026667612f2b1
+ # Name: change_pass
+ # Deployed: 2013-12-30 17:05:08 -0800
+ # By: Marge N. O’Vera <marge@example.com>
+ #
+ Nothing to deploy (up-to-date)
+
+=head1 More to Come
+
+Sqitch is a work in progress. Better integration with version control systems
+is planned to make managing idempotent reworkings even easier. Stay tuned.
+
+=head1 Author
+
+David E. Wheeler <david@justatheory.com>
+
+=head1 License
+
+Copyright (c) 2012-2018 iovation Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+=cut
diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod
new file mode 100644
index 00000000..4955f113
--- /dev/null
+++ b/lib/sqitchusage.pod
@@ -0,0 +1,25 @@
+=begin private
+
+Keep private so it's not displayed, but will still be indexed by the CPAN
+toolchain.
+
+=head1 Name
+
+sqitchusage - Sqitch usage statement
+
+=end private
+
+=head1 Usage
+
+ sqitch <command> [options] [command-options] [args]
+
+=head1 Options
+
+ -C --chdir --cd <dir> Change to directory before performing any actions
+ --etc-path Print the path to the etc directory and exit
+ --no-pager Do not pipe output into a pager
+ --quiet Quiet mode with non-error output suppressed
+ -V --verbose Increment verbosity
+ --version Print the version number and exit
+ --help Show a list of commands and exit
+ --man Print the introductory documentation and exit
diff --git a/t/add.t b/t/add.t
new file mode 100644
index 00000000..858458b4
--- /dev/null
+++ b/t/add.t
@@ -0,0 +1,1010 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 236;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use App::Sqitch::Target;
+use Locale::TextDomain qw(App-Sqitch);
+use Path::Class;
+use Test::Exception;
+use Test::Warn;
+use Test::Dir;
+use File::Temp 'tempdir';
+use Test::File qw(file_not_exists_ok file_exists_ok);
+use Test::File::Contents 0.05;
+use File::Path qw(make_path remove_tree);
+use Test::NoWarnings 0.083;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::add';
+
+my $config = TestConfig->new(
+ 'core.engine' => 'pg',
+ 'core.top_dir' => dir('test-add')->stringify,
+);
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+
+isa_ok my $add = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'add',
+ config => $config,
+ args => [],
+}), $CLASS, 'add command';
+my $target = $add->default_target;
+
+sub dep($$) {
+ my $dep = App::Sqitch::Plan::Depend->new(
+ %{ App::Sqitch::Plan::Depend->parse( $_[1] ) },
+ plan => $add->default_target->plan,
+ conflicts => $_[0],
+ );
+ $dep->project;
+ return $dep;
+}
+
+can_ok $CLASS, qw(
+ options
+ requires
+ conflicts
+ variables
+ template_name
+ template_directory
+ with_scripts
+ templates
+ open_editor
+ configure
+ execute
+ _config_templates
+ all_templates
+ _slurp
+ _add
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::ContextCommand"),
+ "$CLASS does ContextCommand";
+
+is_deeply [$CLASS->options], [qw(
+ change-name|change|c=s
+ requires|r=s@
+ conflicts|x=s@
+ note|n|m=s@
+ all|a!
+ template-name|template|t=s
+ template-directory=s
+ with=s@
+ without=s@
+ use=s%
+ open-editor|edit|e!
+ plan-file|f=s
+ top-dir=s
+)], 'Options should be set up';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+sub contents_of ($) {
+ my $file = shift;
+ open my $fh, "<:utf8_strict", $file or die "cannot open $file: $!";
+ local $/;
+ return <$fh>;
+}
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}, $sqitch), {
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+}, 'Should have default configuration with no config or opts';
+
+is_deeply $CLASS->configure($config, {
+ requires => [qw(foo bar)],
+ conflicts => ['baz'],
+ note => [qw(hellow there)],
+}), {
+ requires => [qw(foo bar)],
+ conflicts => ['baz'],
+ note => [qw(hellow there)],
+ _cx => [],
+}, 'Should have get requires and conflicts options';
+
+is_deeply $CLASS->configure($config, { template_directory => 't' }), {
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+ template_directory => dir('t'),
+}, 'Should set up template directory option';
+
+is_deeply $CLASS->configure($config, { change_name => 'blog' }), {
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+ change_name => 'blog',
+}, 'Should set up change name option';
+
+throws_ok {
+ $CLASS->configure($config, { template_directory => '__nonexistent__' });
+} 'App::Sqitch::X', 'Should die if --template-directory does not exist';
+is $@->ident, 'add', 'Missing directory ident should be "add"';
+is $@->message, __x(
+ 'Directory "{dir}" does not exist',
+ dir => '__nonexistent__',
+), 'Missing directory error message should be correct';
+
+throws_ok {
+ $CLASS->configure($config, { template_directory => 'README.md' });
+} 'App::Sqitch::X', 'Should die if --template-directory does is not a dir';
+is $@->ident, 'add', 'In alid directory ident should be "add"';
+is $@->message, __x(
+ '"{dir}" is not a directory',
+ dir => 'README.md',
+), 'Invalid directory error message should be correct';
+
+is_deeply $CLASS->configure($config, { template_name => 'foo' }), {
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+ template_name => 'foo',
+}, 'Should set up template name option';
+
+is_deeply $CLASS->configure($config, {
+ all => 1,
+ with_scripts => { deploy => 1, revert => 1, verify => 0 },
+ use => {
+ deploy => 'etc/templates/deploy/pg.tmpl',
+ revert => 'etc/templates/revert/pg.tmpl',
+ verify => 'etc/templates/verify/pg.tmpl',
+ whatev => 'etc/templates/verify/pg.tmpl',
+ },
+}), {
+ all => 1,
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+ with_scripts => { deploy => 1, revert => 1, verify => 0 },
+ templates => {
+ deploy => file('etc/templates/deploy/pg.tmpl'),
+ revert => file('etc/templates/revert/pg.tmpl'),
+ verify => file('etc/templates/verify/pg.tmpl'),
+ whatev => file('etc/templates/verify/pg.tmpl'),
+ }
+}, 'Should have get template options';
+
+# Test variable configuration.
+CONFIG: {
+ my $config = TestConfig->from(
+ local => File::Spec->catfile(qw(t add_change.conf))
+ );
+ my $dir = dir 't';
+ is_deeply $CLASS->configure($config, {}), {
+ template_directory => $dir,
+ template_name => 'hi',
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+ }, 'Variables should by default not be loaded from config';
+
+ is_deeply $CLASS->configure($config, {set => { yo => 'dawg' }}), {
+ template_directory => $dir,
+ template_name => 'hi',
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+ variables => {
+ foo => 'bar',
+ baz => [qw(hi there you)],
+ yo => 'dawg',
+ },
+ }, '--set should be merged with config variables';
+
+ is_deeply $CLASS->configure($config, {set => { foo => 'ick' }}), {
+ template_directory => $dir,
+ template_name => 'hi',
+ requires => [],
+ conflicts => [],
+ note => [],
+ _cx => [],
+ variables => {
+ foo => 'ick',
+ baz => [qw(hi there you)],
+ },
+ }, '--set should be override config variables';
+}
+
+##############################################################################
+# Test attributes.
+is_deeply $add->requires, [], 'Requires should be an arrayref';
+is_deeply $add->conflicts, [], 'Conflicts should be an arrayref';
+is_deeply $add->note, [], 'Notes should be an arrayref';
+is_deeply $add->variables, {}, 'Varibles should be a hashref';
+is $add->template_directory, undef, 'Default dir should be undef';
+is $add->template_name, undef, 'Default temlate_name should be undef';
+is_deeply $add->with_scripts, { map { $_ => 1} qw(deploy revert verify) },
+ 'Default with_scripts should be all true';
+is_deeply $add->templates, {}, 'Default templates should be empty';
+
+##############################################################################
+# Test _check_script.
+isa_ok my $check = $CLASS->can('_check_script'), 'CODE', '_check_script';
+my $tmpl = 'etc/templates/verify/pg.tmpl';
+is $check->($tmpl), file($tmpl), '_check_script should be okay with script';
+
+throws_ok { $check->('nonexistent') } 'App::Sqitch::X',
+ '_check_script should die on nonexistent file';
+is $@->ident, 'add', 'Nonexistent file ident should be "add"';
+is $@->message, __x(
+ 'Template {template} does not exist',
+ template => 'nonexistent',
+), 'Nonexistent file error message should be correct';
+
+throws_ok { $check->('lib') } 'App::Sqitch::X',
+ '_check_script should die on directory';
+is $@->ident, 'add', 'Directory error ident should be "add"';
+is $@->message, __x(
+ 'Template {template} is not a file',
+ template => 'lib',
+), 'Directory error message should be correct';
+
+##############################################################################
+# Test _config_templates.
+READCONFIG: {
+ my $config = TestConfig->from(
+ local => file('t/templates.conf')->stringify
+ );
+ $config->update('core.top_dir' => dir('test-add')->stringify);
+ ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load another sqitch sqitch object';
+ ok $add = $CLASS->new(sqitch => $sqitch),
+ 'Create add with template config';
+ is_deeply $add->_config_templates($config), {
+ deploy => file('etc/templates/deploy/pg.tmpl'),
+ revert => file('etc/templates/revert/pg.tmpl'),
+ test => file('etc/templates/verify/pg.tmpl'),
+ verify => file('etc/templates/verify/pg.tmpl'),
+ }, 'Should load the config templates';
+}
+
+##############################################################################
+# Test all_templates().
+my $tmpldir = dir 'etc/templates';
+my $sysdir = dir 'nonexistent';
+my $usrdir = dir 'nonexistent';
+my $mock = TestConfig->mock(
+ system_dir => sub { $sysdir },
+ user_dir => sub { $usrdir },
+);
+
+# First, specify template directory.
+ok $add = $CLASS->new(sqitch => $sqitch, template_directory => $tmpldir),
+ 'Add object with template directory';
+is $add->template_name, undef, 'Template name should be undef';
+my $tname = $add->template_name || $target->engine_key;
+is_deeply $add->all_templates($tname), {
+ deploy => file('etc/templates/deploy/pg.tmpl'),
+ revert => file('etc/templates/revert/pg.tmpl'),
+ verify => file('etc/templates/verify/pg.tmpl'),
+}, 'Should find all templates in directory';
+
+# Now let it find the templates in the user dir.
+$usrdir = dir 'etc';
+ok $add = $CLASS->new(sqitch => $sqitch, template_name => 'sqlite'),
+ 'Add object with template name';
+is_deeply $add->all_templates($add->template_name), {
+ deploy => file('etc/templates/deploy/sqlite.tmpl'),
+ revert => file('etc/templates/revert/sqlite.tmpl'),
+ verify => file('etc/templates/verify/sqlite.tmpl'),
+}, 'Should find all templates in user directory';
+
+# And then the system dir.
+($usrdir, $sysdir) = ($sysdir, $usrdir);
+ok $add = $CLASS->new(sqitch => $sqitch, template_name => 'mysql'),
+ 'Add object with another template name';
+is_deeply $add->all_templates($add->template_name), {
+ deploy => file('etc/templates/deploy/mysql.tmpl'),
+ revert => file('etc/templates/revert/mysql.tmpl'),
+ verify => file('etc/templates/verify/mysql.tmpl'),
+}, 'Should find all templates in systsem directory';
+
+# Now make sure it combines directories.
+my $tmp_dir = dir tempdir CLEANUP => 1;
+for my $script (qw(deploy whatev)) {
+ my $subdir = $tmp_dir->subdir($script);
+ $subdir->mkpath;
+ $subdir->file('pg.tmpl')->touch;
+}
+
+ok $add = $CLASS->new(sqitch => $sqitch, template_directory => $tmp_dir),
+ 'Add object with temporary template directory';
+is_deeply $add->all_templates($tname), {
+ deploy => $tmp_dir->file('deploy/pg.tmpl'),
+ whatev => $tmp_dir->file('whatev/pg.tmpl'),
+ revert => file('etc/templates/revert/pg.tmpl'),
+ verify => file('etc/templates/verify/pg.tmpl'),
+}, 'Template dir files should override others';
+
+# Add in configured files.
+ok $add = $CLASS->new(
+ sqitch => $sqitch,
+ template_directory => $tmp_dir,
+ templates => {
+ foo => file('foo'),
+ verify => file('verify'),
+ deploy => file('deploy'),
+ },
+), 'Add object with configured templates';
+
+is_deeply $add->all_templates($tname), {
+ deploy => file('deploy'),
+ verify => file('verify'),
+ foo => file('foo'),
+ whatev => $tmp_dir->file('whatev/pg.tmpl'),
+ revert => file('etc/templates/revert/pg.tmpl'),
+}, 'Template dir files should override others';
+
+# Should die when missing files.
+$sysdir = $usrdir;
+for my $script (qw(deploy revert verify)) {
+ ok $add = $CLASS->new(
+ sqitch => $sqitch,
+ with_scripts => { deploy => 0, revert => 0, verify => 0, $script => 1 },
+ ), "Add object requiring $script template";
+
+ throws_ok { $add->all_templates($tname) } 'App::Sqitch::X',
+ "Should get error for missing $script template";
+ is $@->ident, 'add', qq{Missing $script template ident should be "add"};
+ is $@->message, __x(
+ 'Cannot find {script} template',
+ script => $script,
+ ), "Missing $script template message should be correct";
+}
+
+##############################################################################
+# Test _slurp().
+$tmpl = file(qw(etc templates deploy pg.tmpl));
+is $ { $add->_slurp($tmpl)}, contents_of $tmpl,
+ '_slurp() should load a reference to file contents';
+
+##############################################################################
+# Test _add().
+my $test_add = sub {
+ my $engine = shift;
+ make_path 'test-add';
+ my $fn = $target->plan_file;
+ open my $fh, '>', $fn or die "Cannot open $fn: $!";
+ say $fh "%project=add\n\n";
+ close $fh or die "Error closing $fn: $!";
+ END { remove_tree 'test-add' };
+ my $out = file 'test-add', 'sqitch_change_test.sql';
+ file_not_exists_ok $out;
+ ok my $add = $CLASS->new(
+ sqitch => $sqitch,
+ template_directory => $tmpldir,
+ ), 'Create add command';
+ ok $add->_add('sqitch_change_test', $out, $tmpl, 'sqlite', 'add'),
+ 'Write out a script';
+ file_exists_ok $out;
+ file_contents_is $out, <<EOF, 'The template should have been evaluated';
+-- Deploy add:sqitch_change_test to sqlite
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
+EOF
+ is_deeply +MockOutput->get_info, [[__x 'Created {file}', file => $out ]],
+ 'Info should show $out created';
+ unlink $out;
+
+ # Try with requires and conflicts.
+ ok $add = $CLASS->new(
+ sqitch => $sqitch,
+ requires => [qw(foo bar)],
+ conflicts => ['baz'],
+ template_directory => $tmpldir,
+ ), 'Create add cmd with requires and conflicts';
+
+ $out = file 'test-add', 'another_change_test.sql';
+ ok $add->_add('another_change_test', $out, $tmpl, 'sqlite', 'add'),
+ 'Write out a script with requires and conflicts';
+ is_deeply +MockOutput->get_info, [[__x 'Created {file}', file => $out ]],
+ 'Info should show $out created';
+ file_contents_is $out, <<EOF, 'The template should have been evaluated with requires and conflicts';
+-- Deploy add:another_change_test to sqlite
+-- requires: foo
+-- requires: bar
+-- conflicts: baz
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
+EOF
+ unlink $out;
+};
+
+# First, test with Template::Tiny.
+unshift @INC => sub {
+ my ($self, $file) = @_;
+ return if $file ne 'Template.pm';
+ my $i = 0;
+ return sub {
+ $_ = 'die "NO ONE HERE";';
+ return $i = !$i;
+ }, 1;
+};
+
+$test_add->('Template::Tiny');
+
+# Test _add() with Template.
+shift @INC;
+delete $INC{'Template.pm'};
+SKIP: {
+ skip 'Template Toolkit not installed', 14 unless eval 'use Template; 1';
+ $test_add->('Template Toolkit');
+
+ # Template Toolkit should throw an error on template syntax errors.
+ ok my $add = $CLASS->new(sqitch => $sqitch, template_directory => $tmpldir),
+ 'Create add command';
+ my $mock_add = Test::MockModule->new($CLASS);
+ $mock_add->mock(_slurp => sub { \'[% IF foo %]' });
+ my $out = file 'test-add', 'sqitch_change_test.sql';
+
+ throws_ok { $add->_add('sqitch_change_test', $out, $tmpl) }
+ 'App::Sqitch::X', 'Should get an exception on TT syntax error';
+ is $@->ident, 'add', 'TT exception ident should be "add"';
+ is $@->message, __x(
+ 'Error executing {template}: {error}',
+ template => $tmpl,
+ error => 'file error - parse error - input text line 1: unexpected end of input',
+ ), 'TT exception message should include the original error message';
+}
+
+##############################################################################
+# Test execute.
+ok $add = $CLASS->new(
+ sqitch => $sqitch,
+ template_directory => $tmpldir,
+), 'Create another add with template_directory';
+
+# Override request_note().
+my $change_mocker = Test::MockModule->new('App::Sqitch::Plan::Change');
+my %request_params;
+$change_mocker->mock(request_note => sub {
+ my $self = shift;
+ %request_params = @_;
+ return $self->note;
+});
+
+# Set up a function to force the reload of the plan.
+my $reload = sub {
+ my $plan = shift;
+ $plan->_plan( $plan->load);
+ delete $plan->{$_} for qw(_changes _lines project uri);
+};
+
+my $deploy_file = file qw(test-add deploy widgets_table.sql);
+my $revert_file = file qw(test-add revert widgets_table.sql);
+my $verify_file = file qw(test-add verify widgets_table.sql);
+
+my $plan = $add->default_target->plan;
+is $plan->get('widgets_table'), undef, 'Should not have "widgets_table" in plan';
+dir_not_exists_ok +File::Spec->catdir('test-add', $_) for qw(deploy revert verify);
+ok $add->execute('widgets_table'), 'Add change "widgets_table"';
+
+# Reload the plan.
+$reload->($plan);
+
+# Make sure the change was written to the plan file.
+isa_ok my $change = $plan->get('widgets_table'), 'App::Sqitch::Plan::Change',
+ 'Added change';
+is $change->name, 'widgets_table', 'Change name should be set';
+is_deeply [$change->requires], [], 'It should have no requires';
+is_deeply [$change->conflicts], [], 'It should have no conflicts';
+is_deeply \%request_params, {
+ for => __ 'add',
+ scripts => [$change->deploy_file, $change->revert_file, $change->verify_file],
+}, 'It should have prompted for a note';
+
+file_exists_ok $_ for ($deploy_file, $revert_file, $verify_file);
+file_contents_like $deploy_file, qr/^-- Deploy add:widgets_table/,
+ 'Deploy script should look right';
+file_contents_like $revert_file, qr/^-- Revert add:widgets_table/,
+ 'Revert script should look right';
+file_contents_like $verify_file, qr/^-- Verify add:widgets_table/,
+ 'Verify script should look right';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $deploy_file],
+ [__x 'Created {file}', file => $revert_file],
+ [__x 'Created {file}', file => $verify_file],
+ [__x 'Added "{change}" to {file}',
+ change => 'widgets_table',
+ file => $target->plan_file,
+ ],
+], 'Info should have reported file creation';
+
+# Make sure conflicts are avoided and conflicts and requires are respected.
+ok $add = $CLASS->new(
+ change_name => 'foo_table',
+ sqitch => $sqitch,
+ requires => ['widgets_table'],
+ conflicts => [qw(dr_evil joker)],
+ note => [qw(hello there)],
+ with_scripts => { verify => 0 },
+ template_directory => $tmpldir,
+), 'Create another add with template_directory and no verify script';
+
+$deploy_file = file qw(test-add deploy foo_table.sql);
+$revert_file = file qw(test-add revert foo_table.sql);
+$verify_file = file qw(test-add ferify foo_table.sql);
+$deploy_file->touch;
+
+file_exists_ok $deploy_file;
+file_not_exists_ok $_ for ($revert_file, $verify_file);
+is $plan->get('foo_table'), undef, 'Should not have "foo_table" in plan';
+ok $add->execute, 'Add change "foo_table"';
+file_exists_ok $_ for ($deploy_file, $revert_file);
+file_not_exists_ok $verify_file;
+$plan = $add->default_target->plan;
+isa_ok $change = $plan->get('foo_table'), 'App::Sqitch::Plan::Change',
+ '"foo_table" change';
+is_deeply \%request_params, {
+ for => __ 'add',
+ scripts => [$change->deploy_file, $change->revert_file],
+}, 'It should have prompted for a note';
+
+is $change->name, 'foo_table', 'Change name should be set to "foo_table"';
+is_deeply [$change->requires], [dep 0, 'widgets_table'], 'It should have requires';
+is_deeply [$change->conflicts], [map { dep 1, $_ } qw(dr_evil joker)], 'It should have conflicts';
+is $change->note, "hello\n\nthere", 'It should have a comment';
+
+is_deeply +MockOutput->get_info, [
+ [__x 'Skipped {file}: already exists', file => $deploy_file],
+ [__x 'Created {file}', file => $revert_file],
+ [__x 'Added "{change}" to {file}',
+ change => 'foo_table [widgets_table !dr_evil !joker]',
+ file => $target->plan_file,
+ ],
+], 'Info should report skipping file and include dependencies';
+
+# Make sure we die on an unknown argument.
+throws_ok { $add->execute(qw(foo bar)) } 'App::Sqitch::X',
+ 'Should get an error on unkonwn argument';
+is $@->ident, 'add', 'Unkown argument error ident should be "add"';
+is $@->message, __x(
+ 'Unknown arguments: {arg}',
+ arg => 'foo, bar',
+), 'Unknown argument error message should be correct';
+
+# Make sure we die if the passed name conflicts with a target.
+TARGET: {
+ my $mock_add = Test::MockModule->new($CLASS);
+ $mock_add->mock(parse_args => sub {
+ return undef, [$target];
+ });
+ $mock_add->mock(name => 'blog');
+ my $mock_target = Test::MockModule->new('App::Sqitch::Target');
+ $mock_target->mock(name => 'blog');
+
+ throws_ok { $add->execute('blog') } 'App::Sqitch::X',
+ 'Should get an error for conflict with target name';
+ is $@->ident, 'add', 'Conflicting target error ident should be "add"';
+ is $@->message, __x(
+ 'Name "{name}" identifies a target; use "--change {name}" to use it for the change name',
+ name => 'blog',
+ ), 'Conflicting target error message should be correct';
+}
+
+# Make sure we get a usage message when no name specified.
+USAGE: {
+ my @args;
+ my $mock_add = Test::MockModule->new($CLASS);
+ $mock_add->mock(usage => sub { @args = @_; die 'USAGE' });
+ my $add = $CLASS->new(sqitch => $sqitch);
+ throws_ok { $add->execute } qr/USAGE/,
+ 'No name arg or option should yield usage';
+ is_deeply \@args, [$add], 'No args should be passed to usage';
+
+ # Should be true when no engine is specified, either.
+ $add = $CLASS->new(sqitch => App::Sqitch->new(config => TestConfig->new));
+ throws_ok { $add->execute } qr/USAGE/,
+ 'No name arg or option should yield usage';
+ is_deeply \@args, [$add], 'No args should be passed to usage';
+}
+
+# Make sure --open-editor works
+MOCKSHELL: {
+ my $sqitch_mocker = Test::MockModule->new('App::Sqitch');
+ my $shell_cmd;
+ $sqitch_mocker->mock(shell => sub { $shell_cmd = $_[1] });
+ $sqitch_mocker->mock(quote_shell => sub { shift; join ' ' => @_ });
+
+ ok $add = $CLASS->new(
+ sqitch => $sqitch,
+ template_directory => $tmpldir,
+ note => ['Testing --open-editor'],
+ open_editor => 1,
+ ), 'Create another add with open_editor';
+
+ my $deploy_file = file qw(test-add deploy open_editor.sql);
+ my $revert_file = file qw(test-add revert open_editor.sql);
+ my $verify_file = file qw(test-add verify open_editor.sql);
+
+ my $plan = $add->default_target->plan;
+ is $plan->get('open_editor'), undef, 'Should not have "open_editor" in plan';
+ ok $add->execute('open_editor'), 'Add change "open_editor"';
+
+ # Instantiate fresh target and plan to force the file to be re-read.
+ $target = App::Sqitch::Target->new(sqitch => $sqitch);
+ $plan = App::Sqitch::Plan->new( sqitch => $sqitch, target => $target );
+
+ isa_ok my $change = $plan->get('open_editor'), 'App::Sqitch::Plan::Change',
+ 'Added change';
+ is $change->name, 'open_editor', 'Change name should be set';
+ is $shell_cmd, join(' ', $sqitch->editor, $deploy_file, $revert_file, $verify_file),
+ 'It should have prompted to edit sql files';
+
+ file_exists_ok $_ for ($deploy_file, $revert_file, $verify_file);
+ file_contents_like +File::Spec->catfile(qw(test-add deploy open_editor.sql)),
+ qr/^-- Deploy add:open_editor/, 'Deploy script should look right';
+ file_contents_like +File::Spec->catfile(qw(test-add revert open_editor.sql)),
+ qr/^-- Revert add:open_editor/, 'Revert script should look right';
+ file_contents_like +File::Spec->catfile(qw(test-add verify open_editor.sql)),
+ qr/^-- Verify add:open_editor/, 'Verify script should look right';
+ is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $deploy_file],
+ [__x 'Created {file}', file => $revert_file],
+ [__x 'Created {file}', file => $verify_file],
+ [__x 'Added "{change}" to {file}',
+ change => 'open_editor',
+ file => $target->plan_file,
+ ],
+ ], 'Info should have reported file creation';
+};
+
+# Make sure an additional script and an exclusion work properly.
+EXTRAS: {
+ ok my $add = $CLASS->new(
+ sqitch => $sqitch,
+ template_directory => $tmpldir,
+ with_scripts => { verify => 0 },
+ templates => { whatev => file(qw(etc templates verify mysql.tmpl)) },
+ note => ['Testing custom scripts'],
+ ), 'Create another add with custom script and no verify';
+
+ my $deploy_file = file qw(test-add deploy custom_script.sql);
+ my $revert_file = file qw(test-add revert custom_script.sql);
+ my $verify_file = file qw(test-add verify custom_script.sql);
+ my $whatev_file = file qw(test-add whatev custom_script.sql);
+
+ ok $add->execute('custom_script'), 'Add change "custom_script"';
+ my $plan = $add->default_target->plan;
+ isa_ok my $change = $plan->get('custom_script'), 'App::Sqitch::Plan::Change',
+ 'Added change';
+ is $change->name, 'custom_script', 'Change name should be set';
+ is_deeply [$change->requires], [], 'It should have no requires';
+ is_deeply [$change->conflicts], [], 'It should have no conflicts';
+ is_deeply \%request_params, {
+ for => __ 'add',
+ scripts => [ map { $change->script_file($_) } qw(deploy revert whatev)]
+ }, 'It should have prompted for a note';
+
+ file_exists_ok $_ for ($deploy_file, $revert_file, $whatev_file);
+ file_not_exists_ok $verify_file;
+ file_contents_like $deploy_file, qr/^-- Deploy add:custom_script/,
+ 'Deploy script should look right';
+ file_contents_like $revert_file, qr/^-- Revert add:custom_script/,
+ 'Revert script should look right';
+ file_contents_like $whatev_file, qr/^-- Verify add:custom_script/,
+ 'Whatev script should look right';
+ file_contents_unlike $whatev_file, qr/^BEGIN/,
+ 'Whatev script should be based on the MySQL verify script';
+ is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $deploy_file],
+ [__x 'Created {file}', file => $revert_file],
+ [__x 'Created {file}', file => $whatev_file],
+ [__x 'Added "{change}" to {file}',
+ change => 'custom_script',
+ file => $target->plan_file,
+ ],
+ ], 'Info should have reported file creation';
+
+ # Relod the plan file to make sure change is written to it.
+ $reload->($plan);
+ isa_ok $change = $plan->get('custom_script'), 'App::Sqitch::Plan::Change',
+ 'Added change in reloaded plan';
+}
+
+# Make sure a configuration with multiple plans works.
+MULTIPLAN: {
+ make_path 'test-multiadd';
+ END { remove_tree 'test-multiadd' };
+ chdir 'test-multiadd';
+ my $config = TestConfig->new(
+ 'core.engine' => 'pg',
+ 'engine.pg.top_dir' => 'pg',
+ 'engine.sqlite.top_dir' => 'sqlite',
+ 'engine.mysql.top_dir' => 'mysql',
+ );
+
+ # Create plan files and determine the scripts that to be created.
+ my @scripts = map {
+ my $dir = dir $_;
+ $dir->mkpath;
+ $dir->file('sqitch.plan')->spew("%project=add\n\n");
+ map { $dir->file($_, 'widgets.sql') } qw(deploy revert verify);
+ } qw(pg sqlite mysql);
+
+ # Load up the configuration for this project.
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $add = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple plans'],
+ all => 1,
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create another add with custom multiplan config';
+
+ my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is @targets, 3, 'Should have three targets';
+
+ # Make sure the target list matches our script list order (by engine).
+ # pg always comes first, as primary engine, but the other two are random.
+ push @targets, splice @targets, 1, 1 if $targets[1]->engine_key ne 'sqlite';
+
+ # Let's do this thing!
+ ok $add->execute('widgets'), 'Add change "widgets" to all plans';
+ ok $_->plan->get('widgets'), 'Should have "widgets" in ' . $_->engine_key . ' plan'
+ for @targets;
+ file_exists_ok $_ for @scripts;
+
+ # Make sure we see the proper output.
+ my $info = MockOutput->get_info;
+ my $ekey = $targets[1]->engine_key;
+ if ($info->[4][0] !~ /$ekey/) {
+ # Got the targets in a different order. So reorder results to match.
+ push @{ $info } => splice @{ $info }, 4, 4;
+ }
+ is_deeply $info, [
+ (map { [__x 'Created {file}', file => $_] } @scripts[0..2]),
+ [
+ __x 'Added "{change}" to {file}',
+ change => 'widgets',
+ file => $targets[0]->plan_file,
+ ],
+ (map { [__x 'Created {file}', file => $_] } @scripts[3..5]),
+ [
+ __x 'Added "{change}" to {file}',
+ change => 'widgets',
+ file => $targets[1]->plan_file,
+ ],
+ (map { [__x 'Created {file}', file => $_] } @scripts[6..8]),
+ [
+ __x 'Added "{change}" to {file}',
+ change => 'widgets',
+ file => $targets[2]->plan_file,
+ ],
+ ], 'Info should have reported all script creations and plan updates';
+
+ # Make sure we get an error using --all and a target arg.
+ throws_ok { $add->execute('foo', 'pg' ) } 'App::Sqitch::X',
+ 'Should get an error for --all and a target arg';
+ is $@->ident, 'add', 'Mixed arguments error ident should be "add"';
+ is $@->message, __(
+ 'Cannot specify both --all and engine, target, or plan arugments'
+ ), 'Mixed arguments error message should be correct';
+
+ # Now try adding a change to just one engine. Remove --all
+ ok $add = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple plans'],
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create yet another add with custom multiplan config';
+
+ ok $add->execute('choc', 'sqlite'), 'Add change "choc" to the sqlite plan';
+ my %targets = map { $_->engine_key => $_ }
+ App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is keys %targets, 3, 'Should still have three targets';
+ ok !$targets{pg}->plan->get('choc'), 'Should not have "choc" in the pg plan';
+ ok !$targets{mysql}->plan->get('choc'), 'Should not have "choc" in the mysql plan';
+ ok $targets{sqlite}->plan->get('choc'), 'Should have "choc" in the sqlite plan';
+
+ @scripts = map {
+ my $dir = dir $_;
+ $dir->mkpath;
+ map { $dir->file($_, 'choc.sql') } qw(deploy revert verify);
+ } qw(sqlite pg mysql);
+ file_exists_ok $_ for @scripts[0..2];
+ file_not_exists_ok $_ for @scripts[3..8];
+ is_deeply +MockOutput->get_info, [
+ (map { [__x 'Created {file}', file => $_] } @scripts[0..2]),
+ [
+ __x 'Added "{change}" to {file}',
+ change => 'choc',
+ file => $targets{sqlite}->plan_file,
+ ],
+ ], 'Info should have reported sqlite choc script creations and plan updates';
+
+ chdir File::Spec->updir;
+}
+
+# Make sure we update only one plan but write out multiple target files.
+MULTITARGET: {
+ remove_tree 'test-multiadd';
+ make_path 'test-multiadd';
+ chdir 'test-multiadd';
+ my $config = TestConfig->new(
+ 'core.engine' => 'pg',
+ 'core.plan_file' => 'sqitch.plan',
+ 'engine.pg.top_dir' => 'pg',
+ 'engine.sqlite.top_dir' => 'sqlite',
+ 'add.all' => 1,
+ );
+ file('sqitch.plan')->spew("%project=add\n\n");
+
+ # Create list of scripts to be created.
+ my @scripts = map {
+ my $dir = dir $_;
+ $dir->mkpath;
+ map { $dir->file($_, 'widgets.sql') } qw(deploy revert verify);
+ } qw(pg sqlite);
+
+ # Load up the configuration for this project.
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $add = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple targets'],
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create another add with single plan, multi-target config';
+
+ my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is @targets, 2, 'Should have two targets';
+ is $targets[0]->plan_file, $targets[1]->plan_file,
+ 'Targets should use the same plan file';
+
+ # Let's do this thing!
+ ok $add->execute('widgets'), 'Add change "widgets" to all plans';
+ ok $targets[0]->plan->get('widgets'), 'Should have "widgets" in the plan';
+ file_exists_ok $_ for @scripts;
+
+ is_deeply \%request_params, {
+ for => __ 'add',
+ scripts => \@scripts,
+ }, 'Should have the proper files listed in the note promt';
+
+ is_deeply +MockOutput->get_info, [
+ (map { [__x 'Created {file}', file => $_] } @scripts),
+ [
+ __x 'Added "{change}" to {file}',
+ change => 'widgets',
+ file => $targets[0]->plan_file,
+ ],
+ ], 'Info should have reported all script creations and one plan update';
+ chdir File::Spec->updir;
+}
+
+# Make sure we're okay with multiple plans sharing the same top dir.
+ONETOP: {
+ remove_tree 'test-multiadd';
+ make_path 'test-multiadd';
+ chdir 'test-multiadd';
+ my $config = TestConfig->new(
+ 'core.engine' => 'pg',
+ 'engine.pg.plan_file' => 'pg.plan',
+ 'engine.sqlite.plan_file' => 'sqlite.plan',
+ );
+ file("$_.plan")->spew("%project=add\n\n") for qw(pg sqlite);
+
+ # Create list of scripts to be created.
+ my @scripts = map { file $_, 'widgets.sql' } qw(deploy revert verify);
+
+ # Load up the configuration for this project.
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $add = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing two targets, one top_dir'],
+ all => 1,
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create another add with two targets, one top dir';
+
+ my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is @targets, 2, 'Should have two targets';
+ is $targets[0]->plan_file, file('pg.plan'),
+ 'First target plan should be in pg.plan';
+ is $targets[1]->plan_file, file('sqlite.plan'),
+ 'Second target plan should be in sqlite.plan';
+
+ # Let's do this thing!
+ ok $add->execute('widgets'), 'Add change "widgets" to all plans';
+ ok $_->plan->get('widgets'), 'Should have "widgets" in ' . $_->engine_key . ' plan'
+ for @targets;
+ file_exists_ok $_ for @scripts;
+
+ is_deeply \%request_params, {
+ for => __ 'add',
+ scripts => \@scripts,
+ }, 'Should have the proper files listed in the note promt';
+
+ is_deeply my $info = MockOutput->get_info, [
+ (map { [__x 'Created {file}', file => $_] } @scripts),
+ [
+ __x 'Added "{change}" to {file}',
+ change => 'widgets',
+ file => $targets[0]->plan_file,
+ ],
+ (map { [__x 'Skipped {file}: already exists', file => $_] } @scripts),
+ [
+ __x 'Added "{change}" to {file}',
+ change => 'widgets',
+ file => $targets[1]->plan_file,
+ ],
+ ], 'Info should have script creations and skips';
+
+ chdir File::Spec->updir;
+}
+
+##############################################################################
+# Test options parsing.
+can_ok $CLASS, 'options', '_parse_opts';
+ok $add = $CLASS->new({ sqitch => $sqitch }), "Create a $CLASS object again";
+is_deeply $add->_parse_opts([]),
+ { with_scripts => { map { $_ => 1} qw(deploy revert verify) } },
+ 'Base _parse_opts should return the script config';
+
+is_deeply $add->_parse_opts([1]), {
+ with_scripts => { deploy => 1, verify => 1, revert => 1 },
+}, '_parse_opts() hould use options spec';
+my $args = [qw(
+ --note foo
+ --template bar
+ whatever
+)];
+is_deeply $add->_parse_opts($args), {
+ note => ['foo'],
+ template_name => 'bar',
+ with_scripts => { deploy => 1, verify => 1, revert => 1 },
+}, '_parse_opts() should parse options spec';
+is_deeply $args, ['whatever'], 'Args array should be cleared of options';
+
+# Make sure --set works.
+push @{ $args }, '--set' => 'schema=foo', '--set' => 'table=bar';
+is_deeply $add->_parse_opts($args), {
+ set => { schema => 'foo', table => 'bar' },
+ with_scripts => { deploy => 1, verify => 1, revert => 1 },
+}, '_parse_opts() should parse --set options';
+is_deeply $args, ['whatever'], 'Args array should be cleared of options';
+
+# make sure --set works with repeating keys.
+push @{ $args }, '--set' => 'column=id', '--set' => 'column=name';
+is_deeply $add->_parse_opts($args), {
+ set => { column => [qw(id name)] },
+ with_scripts => { deploy => 1, verify => 1, revert => 1 },
+}, '_parse_opts() should parse --set options with repeting key';
+is_deeply $args, ['whatever'], 'Args array should be cleared of options';
+
+# Make sure --with and --use work.
+push @{ $args }, qw(--with deploy --without verify --use),
+ "foo=$tmpl";
+is_deeply $add->_parse_opts($args), {
+ with_scripts => { deploy => 1, verify => 0, revert => 1 },
+ use => { foo => $tmpl }
+}, '_parse_opts() should parse --with, --without, and --user';
+is_deeply $args, ['whatever'], 'Args array should be cleared of options';
diff --git a/t/add_change.conf b/t/add_change.conf
new file mode 100644
index 00000000..782ee9ae
--- /dev/null
+++ b/t/add_change.conf
@@ -0,0 +1,10 @@
+[add]
+template_directory = t
+template_name = hi
+all = true
+[add "variables"]
+foo = bar
+baz = hi
+baz = there
+baz = you
+
diff --git a/t/base.t b/t/base.t
new file mode 100644
index 00000000..506864e7
--- /dev/null
+++ b/t/base.t
@@ -0,0 +1,675 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use Test::More tests => 189;
+#use Test::More 'no_plan';
+use Test::MockModule 0.17;
+use Path::Class;
+use Test::Exception;
+use Test::NoWarnings;
+use Capture::Tiny 0.12 qw(:all);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X 'hurl';
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+BEGIN {
+ $CLASS = 'App::Sqitch';
+ use_ok $CLASS or die;
+}
+
+can_ok $CLASS, qw(
+ go
+ new
+ options
+ user_name
+ user_email
+ verbosity
+ prompt
+ ask_yes_no
+ ask_y_n
+);
+
+##############################################################################
+# Overrides.
+my $config = TestConfig->new;
+$config->data({'core.verbosity' => 2});
+isa_ok my $sqitch = $CLASS->new({ config => $config, options => {} }),
+ $CLASS, 'A configured object';
+
+is $sqitch->verbosity, 2, 'Configured verbosity should override default';
+
+isa_ok $sqitch = $CLASS->new({ config => $config, options => {verbosity => 3} }),
+ $CLASS, 'A configured object';
+
+is $sqitch->verbosity, 3, 'Verbosity option should override configuration';
+
+##############################################################################
+# Defaults.
+$config->replace;
+isa_ok $sqitch = $CLASS->new(config => $config), $CLASS, 'A new object';
+
+is $sqitch->verbosity, 1, 'Default verbosity should be 1';
+ok $sqitch->sysuser, 'Should have default sysuser from system';
+ok $sqitch->user_name, 'Default user_name should be set from system';
+is $sqitch->user_email, do {
+ require Sys::Hostname;
+ $sqitch->sysuser . '@' . Sys::Hostname::hostname();
+}, 'Default user_email should be set from system';
+
+##############################################################################
+# User environment variables.
+ENV: {
+ # Try originating host variables.
+ local $ENV{SQITCH_ORIG_SYSUSER} = "__kamala__";
+ local $ENV{SQITCH_ORIG_FULLNAME} = 'Kamala Harris';
+ local $ENV{SQITCH_ORIG_EMAIL} = 'kamala@whitehouse.gov';
+ isa_ok $sqitch = $CLASS->new(config => $config), $CLASS, 'Another new object';
+ is $sqitch->sysuser, $ENV{SQITCH_ORIG_SYSUSER},
+ "SQITCH_ORIG_SYSUER should override system username";
+ is $sqitch->user_name, $ENV{SQITCH_ORIG_FULLNAME},
+ "SQITCH_ORIG_FULLNAME should override system user full name";
+ is $sqitch->user_email, $ENV{SQITCH_ORIG_EMAIL},
+ "SQITCH_ORIG_EMAIL should override system-derived email";
+
+ # Local variables take precedence over originating host variables.
+ local $ENV{SQITCH_FULLNAME} = 'Barack Obama';
+ local $ENV{SQITCH_EMAIL} = 'barack@whitehouse.gov';
+ isa_ok $sqitch = $CLASS->new, $CLASS, 'Another new object';
+ is $sqitch->user_name, $ENV{SQITCH_FULLNAME},
+ "SQITCH_FULLNAME should override originating host user full name";
+ is $sqitch->user_email, $ENV{SQITCH_EMAIL},
+ "SQITCH_EMAIL should override originating host email";
+}
+
+##############################################################################
+# Test go().
+GO: {
+ local $ENV{SQITCH_ORIG_SYSUSER} = "__barack__";
+ local $ENV{SQITCH_ORIG_FULLNAME} = 'Barack Obama';
+ local $ENV{SQITCH_ORIG_EMAIL} = 'barack@whitehouse.gov';
+
+ my $mock = Test::MockModule->new('App::Sqitch::Command::help');
+ my ($cmd, @params);
+ my $ret = 1;
+ $mock->mock(execute => sub { ($cmd, @params) = @_; $ret });
+ chdir 't';
+
+ my $config = TestConfig->from(
+ local => 'sqitch.conf',
+ user => 'user.conf',
+ );
+
+ my $mocker = Test::MockModule->new('App::Sqitch::Config');
+ $mocker->mock(new => $config);
+
+ local @ARGV = qw(help config);
+ is +App::Sqitch->go, 0, 'Should get 0 from go()';
+
+ isa_ok $cmd, 'App::Sqitch::Command::help', 'Command';
+ is_deeply \@params, ['config'], 'Extra args should be passed to execute';
+
+ isa_ok my $sqitch = $cmd->sqitch, 'App::Sqitch';
+ ok $config = $sqitch->config, 'Get the Sqitch config';
+ is $config->get(key => 'engine.pg.client'), '/usr/local/pgsql/bin/psql',
+ 'Should have local config overriding user';
+ is $config->get(key => 'engine.pg.registry'), 'meta',
+ 'Should fall back on user config';
+ is $sqitch->user_name, 'Michael Stonebraker',
+ 'Should have read user name from configuration';
+ is $sqitch->user_email, 'michael@example.com',
+ 'Should have read user email from configuration';
+ is_deeply $sqitch->options, { }, 'Should have no options';
+
+ # Make sure USER_NAME and USER_EMAIL take precedence over configuration.
+ local $ENV{SQITCH_FULLNAME} = 'Michelle Obama';
+ local $ENV{SQITCH_EMAIL} = 'michelle@whitehouse.gov';
+ is +App::Sqitch->go, 0, 'Should get 0 from go() again';
+ isa_ok $sqitch = $cmd->sqitch, 'App::Sqitch';
+ is $sqitch->user_name, 'Michelle Obama',
+ 'Should have read user name from environment';
+ is $sqitch->user_email, 'michelle@whitehouse.gov',
+ 'Should have read user email from environment';
+
+ # Now make it die.
+ sub puke { App::Sqitch::X->new(@_) } # Ensures we have trace frames.
+ my $ex = puke(ident => 'ohai', message => 'OMGWTF!');
+ $mock->mock(execute => sub { die $ex });
+ my $sqitch_mock = Test::MockModule->new($CLASS);
+ my @vented;
+ $sqitch_mock->mock(vent => sub { push @vented => $_[1]; });
+ my $traced;
+ $sqitch_mock->mock(trace => sub { $traced = $_[1]; });
+ is $sqitch->go, 2, 'Go should return 2 on Sqitch exception';
+ is_deeply \@vented, ['OMGWTF!'], 'The error should have been vented';
+ is $traced, $ex->stack_trace->as_string,
+ 'The stack trace should have been sent to trace';
+
+ # Make it die with a developer exception.
+ @vented = ();
+ $traced = undef;
+ $ex = puke( message => 'OUCH!', exitval => 4 );
+ is $sqitch->go, 4, 'Go should return exitval on another exception';
+ is_deeply \@vented, ['OUCH!', $ex->stack_trace->as_string],
+ 'Both the message and the trace should have been vented';
+ is $traced, undef, 'Nothing should have been traced';
+
+ # Make it die without an exception object.
+ $ex = 'LOLZ';
+ @vented = ();
+ is $sqitch->go, 2, 'Go should return 2 on a third Sqitch exception';
+ is @vented, 1, 'Should have one thing vented';
+ like $vented[0], qr/^LOLZ\b/, 'And it should include our message';
+}
+
+##############################################################################
+# Test the editor.
+EDITOR: {
+ local $ENV{SQITCH_EDITOR};
+ local $ENV{VISUAL};
+
+ local $ENV{EDITOR} = 'edd';
+ my $sqitch = App::Sqitch->new(config => $config);
+ is $sqitch->editor, 'edd', 'editor should use $EDITOR';
+
+ local $ENV{VISUAL} = 'gvim';
+ $sqitch = App::Sqitch->new(config => $config);
+ is $sqitch->editor, 'gvim', 'editor should prefer $VISUAL over $EDITOR';
+
+ my $config = TestConfig->from(local => 'editor.conf');
+ $sqitch = App::Sqitch->new(config => $config);
+ is $sqitch->editor, 'config_specified_editor', 'editor should prefer core.editor over $VISUAL';
+
+ local $ENV{SQITCH_EDITOR} = 'vimz';
+ $sqitch = App::Sqitch->new(config => $config);
+ is $sqitch->editor, 'vimz', 'editor should prefer $SQITCH_EDITOR over $VISUAL';
+
+ $sqitch = App::Sqitch->new({editor => 'emacz' });
+ is $sqitch->editor, 'emacz', 'editor should use use parameter regardless of environment';
+
+ delete $ENV{SQITCH_EDITOR};
+ delete $ENV{VISUAL};
+ delete $ENV{EDITOR};
+ $config->replace;
+ $sqitch = App::Sqitch->new(config => $config);
+ if (App::Sqitch::ISWIN) {
+ is $sqitch->editor, 'notepad.exe', 'editor fall back on notepad on Windows';
+ } else {
+ is $sqitch->editor, 'vi', 'editor fall back on vi when not Windows';
+ }
+}
+
+##############################################################################
+# Test the pager program config. We want to pick up from one of the following
+# places, earlier in the list more preferred.
+# - SQITCH_PAGER environment variable.
+# - core.pager configuration prop.
+# - PAGER environment variable.
+#
+PAGER_PROGRAM: {
+ # Ignore warnings while loading IO::Pager.
+ { local $SIG{__WARN__} = sub {}; require IO::Pager }
+
+ # Mock the IO::Pager constructor.
+ my $mock_pager = Test::MockModule->new('IO::Pager');
+ $mock_pager->mock(new => sub { return bless => {} => 'IO::Pager' });
+
+ # No pager if no TTY.
+ my $pager_class = -t *STDOUT ? 'IO::Pager' : 'IO::Handle';
+ {
+ local $ENV{SQITCH_PAGER};
+ local $ENV{PAGER} = "morez";
+ my $sqitch = App::Sqitch->new(config => $config);
+ is $sqitch->pager_program, "morez",
+ "pager program should be picked up from PAGER when SQITCH_PAGER and core.pager are not set";
+ isa_ok $sqitch->pager, $pager_class, 'morez pager';
+ }
+
+ {
+ local $ENV{SQITCH_PAGER} = "less -myway";
+ local $ENV{PAGER} = "morezz";
+
+ my $sqitch = App::Sqitch->new;
+ is $sqitch->pager_program, "less -myway", "SQITCH_PAGER should take precedence over PAGER";
+ isa_ok $sqitch->pager, $pager_class, 'less -myway';
+ }
+
+ {
+ local $ENV{SQITCH_PAGER};
+ local $ENV{PAGER} = "morezz";
+
+ my $config = TestConfig->from(local => 'sqitch.conf');
+ my $sqitch = App::Sqitch->new(config => $config);
+ is $sqitch->pager_program, "less -r",
+ "`core.pager' setting should take precedence over PAGER when SQITCH_PAGER is not set.";
+ isa_ok $sqitch->pager, $pager_class, 'morezz pager';
+ }
+
+ {
+ local $ENV{SQITCH_PAGER} = "less -rules";
+ local $ENV{PAGER} = "more -dontcare";
+
+ # Should always get IO::Handle with --no-pager.
+ my $config = TestConfig->from(local => 'sqitch.conf');
+ my $sqitch = App::Sqitch->new(config => $config, options => {no_pager => 1});
+ is $sqitch->pager_program, "less -rules",
+ "SQITCH_PAGER should take precedence over both PAGER and the `core.pager' setting.";
+ isa_ok $sqitch->pager, 'IO::Handle', 'less -rules';
+ }
+}
+
+##############################################################################
+# Test message levels. Start with trace.
+$sqitch = $CLASS->new(verbosity => 3);
+is capture_stdout { $sqitch->trace('This ', "that\n", 'and the other') },
+ "trace: This that\ntrace: and the other\n",
+ 'trace should work';
+$sqitch = $CLASS->new(verbosity => 2);
+is capture_stdout { $sqitch->trace('This ', "that\n", 'and the other') },
+ '', 'Should get no trace output for verbosity 2';
+
+# Trace literal
+$sqitch = $CLASS->new(verbosity => 3);
+is capture_stdout { $sqitch->trace_literal('This ', "that\n", 'and the other') },
+ "trace: This that\ntrace: and the other",
+ 'trace_literal should work';
+$sqitch = $CLASS->new(verbosity => 2);
+is capture_stdout { $sqitch->trace_literal('This ', "that\n", 'and the other') },
+ '', 'Should get no trace_literal output for verbosity 2';
+
+# Debug.
+$sqitch = $CLASS->new(verbosity => 2);
+is capture_stdout { $sqitch->debug('This ', "that\n", 'and the other') },
+ "debug: This that\ndebug: and the other\n",
+ 'debug should work';
+$sqitch = $CLASS->new(verbosity => 1);
+is capture_stdout { $sqitch->debug('This ', "that\n", 'and the other') },
+ '', 'Should get no debug output for verbosity 1';
+
+# Debug literal.
+$sqitch = $CLASS->new(verbosity => 2);
+is capture_stdout { $sqitch->debug_literal('This ', "that\n", 'and the other') },
+ "debug: This that\ndebug: and the other",
+ 'debug_literal should work';
+$sqitch = $CLASS->new(verbosity => 1);
+is capture_stdout { $sqitch->debug_literal('This ', "that\n", 'and the other') },
+ '', 'Should get no debug_literal output for verbosity 1';
+
+# Info.
+$sqitch = $CLASS->new(verbosity => 1);
+is capture_stdout { $sqitch->info('This ', "that\n", 'and the other') },
+ "This that\nand the other\n",
+ 'info should work';
+$sqitch = $CLASS->new(verbosity => 0);
+is capture_stdout { $sqitch->info('This ', "that\n", 'and the other') },
+ '', 'Should get no info output for verbosity 0';
+
+# Info literal.
+$sqitch = $CLASS->new(verbosity => 1);
+is capture_stdout { $sqitch->info_literal('This ', "that\n", 'and the other') },
+ "This that\nand the other",
+ 'info_literal should work';
+$sqitch = $CLASS->new(verbosity => 0);
+is capture_stdout { $sqitch->info_literal('This ', "that\n", 'and the other') },
+ '', 'Should get no info_literal output for verbosity 0';
+
+# Comment.
+$sqitch = $CLASS->new(verbosity => 1);
+is capture_stdout { $sqitch->comment('This ', "that\n", 'and the other') },
+ "# This that\n# and the other\n",
+ 'comment should work';
+$sqitch = $CLASS->new(verbosity => 0);
+is capture_stdout { $sqitch->comment('This ', "that\n", 'and the other') },
+ "# This that\n# and the other\n",
+ 'comment should work with verbosity 0';
+
+# Comment literal.
+$sqitch = $CLASS->new(verbosity => 1);
+is capture_stdout { $sqitch->comment_literal('This ', "that\n", 'and the other') },
+ "# This that\n# and the other",
+ 'comment_literal should work';
+$sqitch = $CLASS->new(verbosity => 0);
+is capture_stdout { $sqitch->comment_literal('This ', "that\n", 'and the other') },
+ "# This that\n# and the other",
+ 'comment_literal should work with verbosity 0';
+
+# Emit.
+is capture_stdout { $sqitch->emit('This ', "that\n", 'and the other') },
+ "This that\nand the other\n",
+ 'emit should work';
+$sqitch = $CLASS->new(verbosity => 0);
+is capture_stdout { $sqitch->emit('This ', "that\n", 'and the other') },
+ "This that\nand the other\n",
+ 'emit should work even with verbosity 0';
+
+# Emit literal.
+is capture_stdout { $sqitch->emit_literal('This ', "that\n", 'and the other') },
+ "This that\nand the other",
+ 'emit_literal should work';
+$sqitch = $CLASS->new(verbosity => 0);
+is capture_stdout { $sqitch->emit_literal('This ', "that\n", 'and the other') },
+ "This that\nand the other",
+ 'emit_literal should work even with verbosity 0';
+
+# Warn.
+is capture_stderr { $sqitch->warn('This ', "that\n", 'and the other') },
+ "warning: This that\nwarning: and the other\n",
+ 'warn should work';
+
+# Warn_Literal.
+is capture_stderr { $sqitch->warn_literal('This ', "that\n", 'and the other') },
+ "warning: This that\nwarning: and the other",
+ 'warn_literal should work';
+
+# Vent.
+is capture_stderr { $sqitch->vent('This ', "that\n", 'and the other') },
+ "This that\nand the other\n",
+ 'vent should work';
+
+# Vent literal.
+is capture_stderr { $sqitch->vent_literal('This ', "that\n", 'and the other') },
+ "This that\nand the other",
+ 'vent_literal should work';
+
+##############################################################################
+# Test run().
+can_ok $CLASS, 'run';
+my ($stdout, $stderr) = capture {
+ ok $sqitch->run(
+ $^X, 'echo.pl', qw(hi there)
+ ), 'Should get success back from run echo';
+};
+
+is $stdout, "hi there\n", 'The echo script should have run';
+is $stderr, '', 'Nothing should have gone to STDERR';
+
+($stdout, $stderr) = capture {
+ throws_ok {
+ $sqitch->run( $^X, 'die.pl', qw(hi there))
+ } qr/unexpectedly returned/, 'run die should, well, die';
+};
+
+is $stdout, "hi there\n", 'The die script should have its STDOUT ummolested';
+like $stderr, qr/OMGWTF/, 'The die script should have its STDERR unmolested';
+
+##############################################################################
+# Test shell().
+can_ok $CLASS, 'shell';
+my $pl = $sqitch->quote_shell($^X);
+($stdout, $stderr) = capture {
+ ok $sqitch->shell(
+ "$pl echo.pl hi there"
+ ), 'Should get success back from shell echo';
+};
+
+is $stdout, "hi there\n", 'The echo script should have shell';
+is $stderr, '', 'Nothing should have gone to STDERR';
+
+($stdout, $stderr) = capture {
+ throws_ok {
+ $sqitch->shell( "$pl die.pl hi there" )
+ } qr/unexpectedly returned/, 'shell die should, well, die';
+};
+
+is $stdout, "hi there\n", 'The die script should have its STDOUT ummolested';
+like $stderr, qr/OMGWTF/, 'The die script should have its STDERR unmolested';
+
+##############################################################################
+# Test quote_shell().
+my $quoter = do {
+ if (App::Sqitch::ISWIN) {
+ require Win32::ShellQuote;
+ \&Win32::ShellQuote::quote_native;
+ } else {
+ require String::ShellQuote;
+ \&String::ShellQuote::shell_quote;
+ }
+};
+
+is $sqitch->quote_shell(qw(foo bar baz), 'hi there'),
+ $quoter->(qw(foo bar baz), 'hi there'), 'quote_shell should work';
+
+##############################################################################
+# Test capture().
+can_ok $CLASS, 'capture';
+is $sqitch->capture($^X, 'echo.pl', qw(hi there)),
+ "hi there\n", 'The echo script output should have been returned';
+like capture_stderr {
+ throws_ok { $sqitch->capture($^X, 'die.pl', qw(hi there)) }
+ qr/unexpectedly returned/,
+ 'Should get an error if the command errors out';
+}, qr/OMGWTF/m, 'The die script STDERR should have passed through';
+
+##############################################################################
+# Test probe().
+can_ok $CLASS, 'probe';
+is $sqitch->probe($^X, 'echo.pl', qw(hi there), "\nyo"),
+ "hi there ", 'Should have just chomped first line of output';
+
+##############################################################################
+# Test spool().
+can_ok $CLASS, 'spool';
+my $data = "hi\nthere\n";
+open my $fh, '<', \$data;
+is capture_stdout {
+ ok $sqitch->spool($fh, $^X, 'read.pl'), 'Spool to read.pl';
+}, $data, 'Data should have been sent to STDOUT by read.pl';
+seek $fh, 0, 0;
+open my $fh2, '<', \$CLASS;
+is capture_stdout {
+ ok $sqitch->spool([$fh, $fh2], $^X, 'read.pl'), 'Spool to read.pl';
+}, $data . $CLASS, 'All data should have been sent to STDOUT by read.pl';
+
+like capture_stderr {
+ local $ENV{LANGUAGE} = 'en';
+ throws_ok { $sqitch->spool($fh, $^X, 'die.pl') }
+ 'App::Sqitch::X', 'Should get error when die.pl dies';
+ is $@->ident, 'io', 'Error ident should be "io"';
+ like $@->message,
+ qr/\Q$^X\E unexpectedly returned exit value |\QError closing pipe to/,
+ 'The error message should be one of the I/O messages';
+}, qr/OMGWTF/, 'The die script STDERR should have passed through';
+
+throws_ok {
+ local $ENV{LANGUAGE} = 'en';
+ $sqitch->spool($fh, '--nosuchscript.ply--')
+} 'App::Sqitch::X', 'Should get an error for a bad command';
+is $@->ident, 'io', 'Error ident should be "io"';
+like $@->message,
+ qr/\QCannot exec --nosuchscript.ply--:\E|\QError closing pipe to --nosuchscript.ply--:/,
+ 'Error message should be about inability to exec';
+
+##############################################################################
+# Test prompt().
+throws_ok { $sqitch->prompt } 'App::Sqitch::X',
+ 'Should get error for no prompt message';
+is $@->ident, 'DEV', 'No prompt ident should be "DEV"';
+is $@->message, 'prompt() called without a prompt message',
+ 'No prompt error message should be correct';
+
+my $sqitch_mock = Test::MockModule->new($CLASS);
+my $input = 'hey';
+$sqitch_mock->mock(_readline => sub { $input });
+my $unattended = 0;
+$sqitch_mock->mock(_is_unattended => sub { $unattended });
+
+is capture_stdout {
+ is $sqitch->prompt('hi'), 'hey', 'Prompt should return input';
+}, 'hi ', 'Prompt should prompt';
+
+$input = 'how';
+is capture_stdout {
+ is $sqitch->prompt('hi', 'blah'), 'how',
+ 'Prompt with default should return input';
+}, 'hi [blah] ', 'Prompt should prompt with default';
+$input = 'hi';
+is capture_stdout {
+ is $sqitch->prompt('hi', undef), 'hi',
+ 'Prompt with undef default should return input';
+}, 'hi [] ', 'Prompt should prompt with bracket for undef default';
+
+$input = undef;
+is capture_stdout {
+ is $sqitch->prompt('hi', 'yo'), 'yo',
+ 'Prompt should return default for undef input';
+}, 'hi [yo] ', 'Prompt should show default when undef input';
+
+$input = '';
+is capture_stdout {
+ is $sqitch->prompt('hi', 'yo'), 'yo',
+ 'Prompt should return input for empty input';
+}, 'hi [yo] ', 'Prompt should show default when empty input';
+
+$unattended = 1;
+throws_ok {
+ is capture_stdout { $sqitch->prompt('yo') }, "yo \n",
+ 'Unattended message should be emitted';
+} 'App::Sqitch::X', 'Should get error when uattended and no default';
+is $@->ident, 'io', 'Unattended error ident should be "io"';
+is $@->message, __(
+ 'Sqitch seems to be unattended and there is no default value for this question'
+), 'Unattended error message should be correct';
+
+is capture_stdout {
+ is $sqitch->prompt('hi', 'yo'), 'yo', 'Prompt should return input';
+}, "hi [yo] yo\n", 'Prompt should show default as selected when unattended';
+
+##############################################################################
+# Test ask_yes_no().
+throws_ok { $sqitch->ask_yes_no } 'App::Sqitch::X',
+ 'Should get error for no ask_yes_no message';
+is $@->ident, 'DEV', 'No ask_yes_no ident should be "DEV"';
+is $@->message, 'ask_yes_no() called without a prompt message',
+ 'No ask_yes_no error message should be correct';
+
+my $yes = __ 'Yes';
+my $no = __ 'No';
+
+# Test affermation.
+for my $variant ($yes, lc $yes, uc $yes, lc substr($yes, 0, 1), substr($yes, 0, 2)) {
+ $input = $variant;
+ $unattended = 0;
+ is capture_stdout {
+ ok $sqitch->ask_yes_no('hi'),
+ qq{ask_yes_no() should return true for "$variant" input};
+ }, 'hi ', qq{ask_yes_no() should prompt for "$variant"};
+}
+
+# Test negation.
+for my $variant ($no, lc $no, uc $no, lc substr($no, 0, 1), substr($no, 0, 2)) {
+ $input = $variant;
+ $unattended = 0;
+ is capture_stdout {
+ ok !$sqitch->ask_yes_no('hi'),
+ qq{ask_yes_no() should return false for "$variant" input};
+ }, 'hi ', qq{ask_yes_no() should prompt for "$variant"};
+}
+
+# Test defaults.
+$input = '';
+is capture_stdout {
+ ok $sqitch->ask_yes_no('whu?', 1),
+ 'ask_yes_no() should return true for true default'
+}, "whu? [$yes] ", 'ask_yes_no() should prompt and show default "Yes"';
+is capture_stdout {
+ ok !$sqitch->ask_yes_no('whu?', 0),
+ 'ask_yes_no() should return false for false default'
+}, "whu? [$no] ", 'ask_yes_no() should prompt and show default "No"';
+
+my $please = __ 'Please answer "y" or "n".';
+$input = 'ha!';
+throws_ok {
+ is capture_stdout { $sqitch->ask_yes_no('hi') },
+ "hi \n$please\nhi \n$please\nhi \n",
+ 'Should get prompts for repeated bad answers';
+} 'App::Sqitch::X', 'Should get error for bad answers';
+is $@->ident, 'io', 'Bad answers ident should be "IO"';
+is $@->message, __ 'No valid answer after 3 attempts; aborting',
+ 'Bad answers message should be correct';
+
+##############################################################################
+# Test ask_y_n().
+my $warning;
+$sqitch_mock->mock(warn => sub { shift; $warning = "@_" });
+throws_ok { $sqitch->ask_y_n } 'App::Sqitch::X',
+ 'Should get error for no ask_y_n message';
+is $@->ident, 'DEV', 'No ask_y_n ident should be "DEV"';
+is $@->message, 'ask_yes_no() called without a prompt message',
+ 'No ask_y_n error message should be correct';
+is $warning, 'The ask_y_n() method has been deprecated. Use ask_yes_no() instead.',
+ 'Should get a deprecation warning from ask_y_n';
+
+throws_ok { $sqitch->ask_y_n('hi', 'b') } 'App::Sqitch::X',
+ 'Should get error for invalid ask_y_n default';
+is $@->ident, 'DEV', 'Invalid ask_y_n default ident should be "DEV"';
+is $@->message, 'Invalid default value: ask_y_n() default must be "y" or "n"',
+ 'Invalid ask_y_n default error message should be correct';
+
+$input = lc substr $yes, 0, 1;
+$unattended = 0;
+is capture_stdout {
+ ok $sqitch->ask_y_n('hi'),
+ qq{ask_y_n should return true for "$input" input}
+}, 'hi ', 'ask_y_n() should prompt';
+
+$input = lc substr $no, 0, 1;
+is capture_stdout {
+ ok !$sqitch->ask_y_n('howdy'),
+ qq{ask_y_n should return false for "$input" input}
+}, 'howdy ', 'ask_y_n() should prompt for no';
+
+$input = uc substr $no, 0, 1;
+is capture_stdout {
+ ok !$sqitch->ask_y_n('howdy'),
+ qq{ask_y_n should return false for "$input" input}
+}, 'howdy ', 'ask_y_n() should prompt for no';
+
+$input = uc substr $yes, 0, 2;
+is capture_stdout {
+ ok $sqitch->ask_y_n('howdy'),
+ qq{ask_y_n should return true for "$input" input}
+}, 'howdy ', 'ask_y_n() should prompt for yes';
+
+$input = '';
+is capture_stdout {
+ ok $sqitch->ask_y_n('whu?', 'y'),
+ qq{ask_y_n should return true default "$yes"}
+}, "whu? [$yes] ", 'ask_y_n() should prompt and show default "Yes"';
+
+is capture_stdout {
+ ok !$sqitch->ask_y_n('whu?', 'n'),
+ qq{ask_y_n should return false default "$no"};
+}, "whu? [$no] ", 'ask_y_n() should prompt and show default "No"';
+
+$input = 'ha!';
+throws_ok {
+ is capture_stdout { $sqitch->ask_y_n('hi') },
+ "hi \n$please\nhi \n$please\nhi \n",
+ 'Should get prompts for repeated bad answers';
+} 'App::Sqitch::X', 'Should get error for bad answers';
+is $@->ident, 'io', 'Bad answers ident should be "IO"';
+is $@->message, __ 'No valid answer after 3 attempts; aborting',
+ 'Bad answers message should be correct';
+
+##############################################################################
+# Test _readline.
+$sqitch_mock->unmock('_readline');
+$input = 'hep';
+open my $stdin, '<', \$input;
+*STDIN = $stdin;
+is $sqitch->_readline, $input, '_readline should work';
+
+$unattended = 1;
+is $sqitch->_readline, undef, '_readline should return undef when unattended';
+$sqitch_mock->unmock_all;
+
+##############################################################################
+# Make sure Test::LocaleDomain gives us decoded strings.
+for my $lang (qw(en fr)) {
+ local $ENV{LANGUAGE} = $lang;
+ my $text = __x 'On database {db}', db => 'foo';
+ ok utf8::valid($text), 'Localied string should be valid UTF-8';
+ ok utf8::is_utf8($text), 'Localied string should be decoded';
+}
diff --git a/t/blank.t b/t/blank.t
new file mode 100644
index 00000000..665c480a
--- /dev/null
+++ b/t/blank.t
@@ -0,0 +1,135 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 34;
+#use Test::More 'no_plan';
+use Locale::TextDomain qw(App-Sqitch);
+use Test::NoWarnings;
+use Test::Exception;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use Test::MockModule;
+use Test::File;
+use Test::File::Contents 0.20;
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Plan::Blank';
+ require_ok $CLASS or die;
+}
+
+can_ok $CLASS, qw(
+ name
+ lspace
+ rspace
+ note
+ plan
+ request_note
+ note_prompt
+);
+
+my $config = TestConfig->new('core.engine' => 'sqlite');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target);
+isa_ok my $blank = $CLASS->new(
+ name => 'foo',
+ plan => $plan,
+), $CLASS;
+isa_ok $blank, 'App::Sqitch::Plan::Line';
+
+is $blank->format_name, '', 'Name should format as ""';
+is $blank->as_string, '', 'should stringify to ""';
+
+ok $blank = $CLASS->new(
+ name => 'howdy',
+ plan => $plan,
+ lspace => ' ',
+ rspace => "\t",
+ note => 'blah blah blah',
+), 'Create tag with more stuff';
+
+is $blank->as_string, " \t# blah blah blah",
+ 'It should stringify correctly';
+
+ok $blank = $CLASS->new(plan => $plan, note => "foo\nbar\nbaz\\\n"),
+ 'Create a blank with newlines and backslashes in the note';
+is $blank->note, "foo\nbar\nbaz\\",
+ 'The newlines and backslashe should not be escaped';
+
+is $blank->format_note, '# foo\\nbar\\nbaz\\\\',
+ 'The newlines and backslahs should be escaped by format_note';
+
+ok $blank = $CLASS->new(plan => $plan, note => "foo\\nbar\\nbaz\\\\\\n"),
+ 'Create a blank with escapes';
+is $blank->note, "foo\nbar\nbaz\\\n", 'Note shoud be unescaped';
+
+for my $spec (
+ ["\n\n\nfoo" => 'foo', 'Leading newlines' ],
+ ["\r\r\rfoo" => 'foo', 'Leading line feeds' ],
+ ["foo\n\n\n" => 'foo', 'Trailing newlines' ],
+ ["foo\r\r\r" => 'foo', 'trailing line feeds' ],
+ ["\r\n\r\n\r\nfoo\n\nbar\r" => "foo\n\nbar", 'Leading and trailing vertical space' ],
+ ["\n\n\n foo \n" => 'foo', 'Leading and trailing newlines and spaces' ],
+) {
+ is $CLASS->new(
+ plan => $plan,
+ note => $spec->[0]
+ )->note, $spec->[1], "Should trim $spec->[2] from note";
+}
+
+##############################################################################
+# Test note requirement.
+is $blank->note_prompt(for => 'add'), __x(
+ "Write a {command} note.\nLines starting with '#' will be ignored.",
+ command => 'add'
+), 'Should have localized not prompt';
+
+my $sqitch_mocker = Test::MockModule->new('App::Sqitch');
+my $note = '';
+my $for = 'add';
+$sqitch_mocker->mock(shell => sub {
+ my ( $self, $cmd ) = @_;
+ my $editor = $sqitch->editor;
+ ok $cmd =~ s/^\Q$editor\E //, 'Shell command should start with editor';
+ my $fn = $cmd;
+ file_exists_ok $fn, 'Temp file should exist';
+
+ ( my $prompt = $CLASS->note_prompt(for => $for) ) =~ s/^/# /gms;
+ file_contents_eq $fn, "\n$prompt\n", 'Temp file contents should include prompt',
+ { encoding => ':raw:utf8_strict' };
+
+ if ($note) {
+ open my $fh, '>:utf8_strict', $fn or die "Cannot open $fn: $!";
+ print $fh $note, $prompt, "\n";
+ close $fh or die "Error closing $fn: $!";
+ }
+});
+
+# Do no actual shell quoting.
+$sqitch_mocker->mock(quote_shell => sub { shift; join ' ' => @_ });
+
+throws_ok { $CLASS->new(plan => $plan )->request_note(for => $for) }
+ 'App::Sqitch::X',
+ 'Should get exception for no note text';
+is $@->ident, 'plan', 'No note error ident should be "plan"';
+is $@->message, __ 'Aborting due to empty note',
+ 'No note error message should be correct';
+is $@->exitval, 1, 'Exit val should be 1';
+
+# Now write a note.
+$for = 'rework';
+$note = "This is my awesome note.\n";
+$blank = $CLASS->new(plan => $plan );
+is $blank->request_note(for => $for), 'This is my awesome note.', 'Request note';
+$note = '';
+is $blank->note, 'This is my awesome note.', 'Should have the edited note';
+is $blank->request_note(for => $for), 'This is my awesome note.',
+ 'The request should not prompt again';
diff --git a/t/bundle.t b/t/bundle.t
new file mode 100644
index 00000000..783712e6
--- /dev/null
+++ b/t/bundle.t
@@ -0,0 +1,565 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 306;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Path::Class;
+use Test::Exception;
+use Test::Dir;
+use Test::Warn;
+use Test::File qw(file_exists_ok file_not_exists_ok);
+use Test::File::Contents;
+use Locale::TextDomain qw(App-Sqitch);
+use File::Path qw(make_path remove_tree);
+use Test::NoWarnings;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::bundle';
+
+ok my $sqitch = App::Sqitch->new, 'Load a sqitch object';
+my $config = $sqitch->config;
+isa_ok my $bundle = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'bundle',
+ config => $config,
+}), $CLASS, 'bundle command';
+
+can_ok $CLASS, qw(
+ configure
+ execute
+ from
+ to
+ dest_dir
+ dest_top_dir
+ dest_dirs_for
+ bundle_config
+ bundle_plan
+ bundle_scripts
+ _mkpath
+ _copy_if_modified
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::ContextCommand"),
+ "$CLASS does ContextCommand";
+
+is_deeply [$CLASS->options], [qw(
+ dest-dir|dir=s
+ all|a!
+ from=s
+ to=s
+ plan-file|f=s
+ top-dir=s
+)], 'Should have dest_dir option';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+is $bundle->dest_dir, dir('bundle'),
+ 'Default dest_dir should be bundle/';
+
+is $bundle->dest_top_dir($bundle->default_target), dir('bundle'),
+ 'Should have dest top dir';
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {_cx => []},
+ 'Default config should be empty';
+is_deeply $CLASS->configure($config, {dest_dir => 'whu'}), {
+ dest_dir => dir('whu'),
+ _cx => [],
+}, '--dest_dir should be converted to a path object by configure()';
+
+is_deeply $CLASS->configure($config, {from => 'HERE', to => 'THERE'}), {
+ from => 'HERE',
+ to => 'THERE',
+ _cx => [],
+}, '--from and --to should be passed through configure';
+
+chdir 't';
+$config= TestConfig->from(local => 'sqitch.conf');
+$config->update('core.top_dir' => dir('sql')->stringify);
+END { remove_tree 'bundle' if -d 'bundle' }
+ok $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch object with top_dir';
+$config = $sqitch->config;
+my $dir = dir qw(_build sql);
+is_deeply $CLASS->configure($config, {}), {
+ dest_dir => $dir,
+ _cx => [],
+}, 'bundle.dest_dir config should be converted to a path object by configure()';
+
+##############################################################################
+# Load a real project.
+isa_ok $bundle = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'bundle',
+ config => $config,
+}), $CLASS, 'another bundle command';
+
+is $bundle->dest_dir, $dir, qq{dest_dir should be "$dir"};
+is $bundle->dest_top_dir($bundle->default_target), dir(qw(_build sql sql)),
+ 'Dest top dir should be _build/sql/sql/';
+my $target = $bundle->default_target;
+my $dir_for = $bundle->dest_dirs_for($target);
+for my $sub (qw(deploy revert verify)) {
+ is $dir_for->{$sub}, $dir->subdir('sql', $sub),
+ "Dest $sub dir should be _build/sql/sql/$sub";
+}
+
+# Try engine project.
+$config->update(
+ 'core.top_dir' => dir('engine')->stringify,
+ 'core.reworked_dir' => dir(qw(engine reworked))->stringify,
+);
+ok $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch object with engine top_dir';
+isa_ok $bundle = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'bundle',
+ config => $config,
+}), $CLASS, 'engine bundle command';
+$target = $bundle->default_target;
+
+is $bundle->dest_dir, $dir, qq{dest_dir should again be "$dir"};
+$dir_for = $bundle->dest_dirs_for($target);
+for my $sub (qw(deploy revert verify)) {
+ is $dir_for->{$sub}, $dir->subdir('engine', $sub),
+ "Dest $sub dir should be _build/sql/engine/$sub";
+}
+
+##############################################################################
+# Test _mkpath.
+my $path = dir 'delete.me';
+dir_not_exists_ok $path, "Path $path should not exist";
+END { remove_tree $path->stringify if -e $path }
+ok $bundle->_mkpath($path), "Create $path";
+dir_exists_ok $path, "Path $path should now exist";
+is_deeply +MockOutput->get_debug, [[' ', __x 'Created {file}', file => $path]],
+ 'The mkdir info should have been output';
+
+# Create it again.
+ok $bundle->_mkpath($path), "Create $path again";
+dir_exists_ok $path, "Path $path should still exist";
+is_deeply +MockOutput->get_debug, [], 'Nothing should have been emitted';
+
+# Handle errors.
+FSERR: {
+ # Make mkpath to insert an error.
+ my $mock = Test::MockModule->new('File::Path');
+ $mock->mock( mkpath => sub {
+ my ($file, $p) = @_;
+ ${ $p->{error} } = [{ $file => 'Permission denied yo'}];
+ return;
+ });
+
+ throws_ok { $bundle->_mkpath('foo') } 'App::Sqitch::X',
+ 'Should fail on permission issue';
+ is $@->ident, 'bundle', 'Permission error should have ident "bundle"';
+ is $@->message, __x(
+ 'Error creating {path}: {error}',
+ path => 'foo',
+ error => 'Permission denied yo',
+ ), 'The permission error should be formatted properly';
+}
+
+##############################################################################
+# Test _copy().
+my $file = file qw(sql deploy roles.sql);
+my $dest = file $path, qw(deploy roles.sql);
+file_not_exists_ok $dest, "File $dest should not exist";
+ok $bundle->_copy_if_modified($file, $dest), "Copy $file to $dest";
+file_exists_ok $dest, "File $dest should now exist";
+file_contents_identical $dest, $file;
+is_deeply +MockOutput->get_debug, [
+ [' ', __x 'Created {file}', file => $dest->dir],
+ [' ', __x(
+ "Copying {source} -> {dest}",
+ source => $file,
+ dest => $dest
+ )],
+], 'The mkdir and copy info should have been output';
+
+# Copy it again.
+ok $bundle->_copy_if_modified($file, $dest), "Copy $file to $dest again";
+file_exists_ok $dest, "File $dest should still exist";
+file_contents_identical $dest, $file;
+my $out = MockOutput->get_debug;
+is_deeply $out, [], 'Should have no debugging output' or diag explain $out;
+
+# Make it old and copy it again.
+utime 0, $file->stat->mtime - 1, $dest;
+ok $bundle->_copy_if_modified($file, $dest), "Copy $file to old $dest";
+file_exists_ok $dest, "File $dest should still be there";
+file_contents_identical $dest, $file;
+is_deeply +MockOutput->get_debug, [[' ', __x(
+ "Copying {source} -> {dest}",
+ source => $file,
+ dest => $dest
+)]], 'Only copy message should again have been emitted';
+
+# Copy a different file.
+my $file2 = file qw(sql deploy users.sql);
+$dest->remove;
+ok $bundle->_copy_if_modified($file2, $dest), "Copy $file2 to $dest";
+file_exists_ok $dest, "File $dest should now exist";
+file_contents_identical $dest, $file2;
+is_deeply +MockOutput->get_debug, [[' ', __x(
+ "Copying {source} -> {dest}",
+ source => $file2,
+ dest => $dest
+)]], 'Again only Copy message should have been emitted';
+
+# Try to copy a nonexistent file.
+my $nonfile = file 'nonexistent.txt';
+throws_ok { $bundle->_copy_if_modified($nonfile, $dest) } 'App::Sqitch::X',
+ 'Should get exception when source file does not exist';
+is $@->ident, 'bundle', 'Nonexistent file error ident should be "bundle"';
+is $@->message, __x(
+ 'Cannot copy {file}: does not exist',
+ file => $nonfile,
+), 'Nonexistent file error message should be correct';
+
+COPYDIE: {
+ # Make copy die.
+ $dest->remove;
+ my $mocker = Test::MockModule->new('File::Copy');
+ $mocker->mock(copy => sub { return 0 });
+ throws_ok { $bundle->_copy_if_modified($file, $dest) } 'App::Sqitch::X',
+ 'Should get exception when copy returns false';
+ is $@->ident, 'bundle', 'Copy fail ident should be "bundle"';
+ is $@->message, __x(
+ 'Cannot copy "{source}" to "{dest}": {error}',
+ source => $file,
+ dest => $dest,
+ error => $!,
+ ), 'Copy fail error message should be correct';
+}
+
+##############################################################################
+# Test bundle_config().
+END {
+ my $to_remove = $dir->parent->stringify;
+ remove_tree $to_remove if -e $to_remove;
+}
+$dest = file $dir, qw(sqitch.conf);
+file_not_exists_ok $dest;
+ok $bundle->bundle_config, 'Bundle the config file';
+file_exists_ok $dest;
+file_contents_identical $dest, file('sqitch.conf');
+is_deeply +MockOutput->get_info, [[__ 'Writing config']],
+ 'Should have config notice';
+
+##############################################################################
+# Test bundle_plan().
+$dest = file $bundle->dest_top_dir($bundle->default_target), qw(sqitch.plan);
+file_not_exists_ok $dest;
+ok $bundle->bundle_plan($bundle->default_target),
+ 'Bundle the default target plan file';
+file_exists_ok $dest;
+file_contents_identical $dest, file(qw(engine sqitch.plan));
+is_deeply +MockOutput->get_info, [[__ 'Writing plan']],
+ 'Should have plan notice';
+
+# Make sure that --from works.
+isa_ok $bundle = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'bundle',
+ config => $config,
+ args => ['--from', 'widgets'],
+}), $CLASS, '--from bundle command';
+is $bundle->from, 'widgets', 'From should be "widgets"';
+ok $bundle->bundle_plan($bundle->default_target, 'widgets'),
+ 'Bundle the default target plan file with from arg';
+my $plan = $bundle->default_target->plan;
+is_deeply +MockOutput->get_info, [[__x(
+ 'Writing plan from {from} to {to}',
+ from => 'widgets',
+ to => '@HEAD',
+)]], 'Statement of the bits written should have been emitted';
+file_contents_is $dest,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=engine' . "\n"
+ . "\n"
+ . $plan->find('widgets')->as_string . "\n"
+ . $plan->find('func/add_user')->as_string . "\n"
+ . $plan->find('users@HEAD')->as_string . "\n",
+ 'Plan should contain only changes from "widgets" on';
+
+# Make sure that --to works.
+isa_ok $bundle = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'bundle',
+ config => $config,
+ args => ['--to', 'users'],
+}), $CLASS, '--to bundle command';
+is $bundle->to, 'users', 'To should be "users"';
+ok $bundle->bundle_plan($bundle->default_target, undef, 'users'),
+ 'Bundle the default target plan file with to arg';
+is_deeply +MockOutput->get_info, [[__x(
+ 'Writing plan from {from} to {to}',
+ from => '@ROOT',
+ to => 'users',
+)]], 'Statement of the bits written should have been emitted';
+file_contents_is $dest,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=engine' . "\n"
+ . "\n"
+ . $plan->find('users')->as_string . "\n"
+ . join( "\n", map { $_->as_string } $plan->find('users')->tags ) . "\n",
+ 'Plan should have written only "users" and its tags';
+
+##############################################################################
+# Test bundle_scripts().
+my @scripts = (
+ $dir_for->{reworked_deploy}->file('users@alpha.sql'),
+ $dir_for->{reworked_revert}->file('users@alpha.sql'),
+ $dir_for->{deploy}->file('widgets.sql'),
+ $dir_for->{revert}->file('widgets.sql'),
+ $dir_for->{deploy}->file(qw(func add_user.sql)),
+ $dir_for->{revert}->file(qw(func add_user.sql)),
+ $dir_for->{deploy}->file('users.sql'),
+ $dir_for->{revert}->file('users.sql'),
+);
+file_not_exists_ok $_ for @scripts;
+$config->update( 'core.extension' => 'sql');
+isa_ok $bundle = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'bundle',
+ config => $config,
+}), $CLASS, 'another bundle command';
+ok $bundle->bundle_scripts($bundle->default_target),
+ 'Bundle default target scripts';
+file_exists_ok $_ for @scripts;
+is_deeply +MockOutput->get_info, [
+ [__ 'Writing scripts'],
+ [' + ', 'users @alpha'],
+ [' + ', 'widgets'],
+ [' + ', 'func/add_user'],
+ [' + ', 'users'],
+], 'Should have change notices';
+
+# Make sure that --from works.
+remove_tree $dir->parent->stringify;
+isa_ok $bundle = App::Sqitch::Command::bundle->new(
+ sqitch => $sqitch,
+ dest_dir => $bundle->dest_dir,
+ from => 'widgets',
+), $CLASS, 'bundle from "widgets"';
+ok $bundle->bundle_scripts($bundle->default_target, 'widgets'), 'Bundle scripts';
+file_not_exists_ok $_ for @scripts[0,1];
+file_exists_ok $_ for @scripts[2,3];
+is_deeply +MockOutput->get_info, [
+ [__ 'Writing scripts'],
+ [' + ', 'widgets'],
+ [' + ', 'func/add_user'],
+ [' + ', 'users'],
+], 'Should have changes only from "widets" onward in notices';
+
+# Make sure that --to works.
+remove_tree $dir->parent->stringify;
+isa_ok $bundle = App::Sqitch::Command::bundle->new(
+ sqitch => $sqitch,
+ dest_dir => $bundle->dest_dir,
+ to => 'users@alpha',
+), $CLASS, 'bundle to "users"';
+ok $bundle->bundle_scripts($bundle->default_target, undef, 'users@alpha'), 'Bundle scripts';
+file_exists_ok $_ for @scripts[0,1];
+file_not_exists_ok $_ for @scripts[2,3];
+is_deeply +MockOutput->get_info, [
+ [__ 'Writing scripts'],
+ [' + ', 'users @alpha'],
+], 'Should have only "users" in change notices';
+
+# Should throw exceptions on unknonw changes.
+for my $key (qw(from to)) {
+ my $bundle = $CLASS->new( sqitch => $sqitch, $key => 'nonexistent' );
+ throws_ok {
+ $bundle->bundle_scripts($bundle->default_target, 'nonexistent')
+ } 'App::Sqitch::X', "Should die on nonexistent $key change";
+ is $@->ident, 'bundle', qq{Nonexistent $key change ident should be "bundle"};
+ is $@->message, __x(
+ 'Cannot find change {change}',
+ change => 'nonexistent',
+ ), "Nonexistent $key message change should be correct";
+}
+
+##############################################################################
+# Test execute().
+MockOutput->get_debug;
+remove_tree $dir->parent->stringify;
+@scripts = (
+ file($dir, 'sqitch.conf'),
+ file($bundle->dest_top_dir($bundle->default_target), 'sqitch.plan'),
+ @scripts,
+);
+file_not_exists_ok $_ for @scripts;
+isa_ok $bundle = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'bundle',
+ config => $config,
+}), $CLASS, 'another bundle command';
+ok $bundle->execute, 'Execute!';
+file_exists_ok $_ for @scripts;
+is_deeply +MockOutput->get_info, [
+ [__x 'Bundling into {dir}', dir => $bundle->dest_dir ],
+ [__ 'Writing config'],
+ [__ 'Writing plan'],
+ [__ 'Writing scripts'],
+ [' + ', 'users @alpha'],
+ [' + ', 'widgets'],
+ [' + ', 'func/add_user'],
+ [' + ', 'users'],
+], 'Should have all notices';
+
+# Try a configuration with multiple plans.
+my $multidir = $dir->parent;
+END { remove_tree $multidir->stringify }
+remove_tree $multidir->stringify;
+my @sql = (
+ $multidir->file(qw(sql sqitch.plan)),
+ $multidir->file(qw(sql deploy roles.sql)),
+ $multidir->file(qw(sql deploy users.sql)),
+ $multidir->file(qw(sql verify users.sql)),
+ $multidir->file(qw(sql deploy widgets.sql)),
+);
+my @engine = (
+ $multidir->file(qw(engine sqitch.plan)),
+ $multidir->file(qw(engine reworked deploy users@alpha.sql)),
+ $multidir->file(qw(engine reworked revert users@alpha.sql)),
+ $multidir->file(qw(engine deploy widgets.sql)),
+ $multidir->file(qw(engine revert widgets.sql)),
+ $multidir->file(qw(engine deploy func add_user.sql)),
+ $multidir->file(qw(engine revert func add_user.sql)),
+ $multidir->file(qw(engine deploy users.sql)),
+ $multidir->file(qw(engine revert users.sql)),
+);
+my $conf_file = $multidir->file('multiplan.conf'),;
+file_not_exists_ok $_ for ($conf_file, @sql, @engine);
+
+$config = TestConfig->from(local => 'multiplan.conf');
+$sqitch = App::Sqitch->new(config => $config);
+isa_ok $bundle = $CLASS->new(
+ sqitch => $sqitch,
+ config => $config,
+ all => 1,
+ dest_dir => dir '_build',
+), $CLASS, 'all multiplan bundle command';
+ok $bundle->execute, 'Execute multi-target bundle!';
+file_exists_ok $_ for ($conf_file, @sql, @engine);
+
+# Make sure we get an error with both --all and a specified target.
+throws_ok { $bundle->execute('pg' ) } 'App::Sqitch::X',
+ 'Should get an error for --all and a target arg';
+is $@->ident, 'bundle', 'Mixed arguments error ident should be "bundle"';
+is $@->message, __(
+ 'Cannot specify both --all and engine, target, or plan arugments'
+), 'Mixed arguments error message should be correct';
+
+# Try without --all.
+isa_ok $bundle = $CLASS->new(
+ sqitch => $sqitch,
+ config => $sqitch->config,
+ dest_dir => dir '_build',
+), $CLASS, 'multiplan bundle command';
+remove_tree $multidir->stringify;
+ok $bundle->execute, qq{Execute with no arg};
+file_exists_ok $_ for ($conf_file, @engine);
+file_not_exists_ok $_ for @sql;
+
+# Make sure it works with bundle.all set, as well.
+$config->update('bundle.all' => 1);
+remove_tree $multidir->stringify;
+ok $bundle->execute, qq{Execute with bundle.all config};
+file_exists_ok $_ for ($conf_file, @engine, @sql);
+
+# Try limiting it in various ways.
+for my $spec (
+ [
+ target => 'pg',
+ { include => \@engine, exclude => \@sql },
+ ],
+ [
+ 'plan file' => file(qw(engine sqitch.plan))->stringify,
+ { include => \@engine, exclude => \@sql },
+ ],
+ [
+ target => 'mysql',
+ { include => \@sql, exclude => \@engine },
+ ],
+ [
+ 'plan file' => file(qw(sql sqitch.plan))->stringify,
+ { include => \@sql, exclude => \@engine },
+ ],
+) {
+ my ($type, $arg, $files) = @{ $spec };
+ remove_tree $multidir->stringify;
+ ok $bundle->execute($arg), qq{Execute with $type arg "$arg"};
+ file_exists_ok $_ for ($conf_file, @{ $files->{include} });
+ file_not_exists_ok $_ for @{ $files->{exclude} };
+}
+
+# Make sure we handle --to and --from.
+isa_ok $bundle = $CLASS->new(
+ sqitch => $sqitch,
+ config => $sqitch->config,
+ from => 'widgets',
+ to => 'widgets',
+ dest_dir => dir '_build',
+), $CLASS, 'to/from bundle command';
+remove_tree $multidir->stringify;
+ok $bundle->execute('pg'), 'Execute to/from bundle!';
+file_exists_ok $_ for ($conf_file, @engine[0,3,4]);
+file_not_exists_ok $_ for (@engine[1,2,5..$#engine]);
+file_contents_is $engine[0],
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=engine' . "\n"
+ . "\n"
+ . $plan->find('widgets')->as_string . "\n",
+ 'Plan should have written only "widgets"';
+
+# Make sure we handle to and from args.
+isa_ok $bundle = $CLASS->new(
+ sqitch => $sqitch,
+ config => $sqitch->config,
+ dest_dir => dir '_build',
+), $CLASS, 'another bundle command';
+remove_tree $multidir->stringify;
+ok $bundle->execute(qw(pg widgets @HEAD)), 'Execute bundle with to/from args!';
+file_exists_ok $_ for ($conf_file, @engine[0,3..$#engine]);
+file_not_exists_ok $_ for (@engine[1,2]);
+file_contents_is $engine[0],
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=engine' . "\n"
+ . "\n"
+ . $plan->find('widgets')->as_string . "\n"
+ . $plan->find('func/add_user')->as_string . "\n"
+ . $plan->find('users@HEAD')->as_string . "\n",
+ 'Plan should have written "widgets" and "func/add_user"';
+
+# Should die on unknown argument.
+throws_ok { $bundle->execute('nonesuch') } 'App::Sqitch::X',
+ 'Should get an exception for unknown argument';
+is $@->ident, 'bundle', 'Unknown argument error ident shoud be "bundle"';
+is $@->message, __x(
+ 'Unknown argument "{arg}"',
+ arg => 'nonesuch',
+), 'Unknown argument error message should be correct';
+
+# Should handle multiple arguments, too.
+throws_ok { $bundle->execute(qw(ba da dum)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown arguments';
+is $@->ident, 'bundle', 'Unknown arguments error ident shoud be "bundle"';
+is $@->message, __x(
+ 'Unknown arguments: {arg}',
+ arg => join ', ', qw(ba da dum)
+), 'Unknown arguments error message should be correct';
diff --git a/t/change.t b/t/change.t
new file mode 100644
index 00000000..ff3f33b8
--- /dev/null
+++ b/t/change.t
@@ -0,0 +1,438 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 92;
+#use Test::More 'no_plan';
+use Test::NoWarnings;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use App::Sqitch::Plan::Tag;
+use Encode qw(encode_utf8);
+use Locale::TextDomain qw(App-Sqitch);
+use Test::Exception;
+use Path::Class;
+use File::Path qw(make_path remove_tree);
+use Digest::SHA;
+use Test::MockModule;
+use URI;
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Plan::Change';
+ require_ok $CLASS or die;
+}
+
+can_ok $CLASS, qw(
+ name
+ info
+ id
+ lspace
+ rspace
+ note
+ parent
+ since_tag
+ rework_tags
+ add_rework_tags
+ is_reworked
+ tags
+ add_tag
+ plan
+ deploy_dir
+ deploy_file
+ script_hash
+ revert_dir
+ revert_file
+ revert_dir
+ verify_file
+ requires
+ conflicts
+ timestamp
+ planner_name
+ planner_email
+ format_name
+ format_dependencies
+ format_name_with_tags
+ format_tag_qualified_name
+ format_name_with_dependencies
+ format_op_name_dependencies
+ format_planner
+ note_prompt
+);
+
+my $sqitch = App::Sqitch->new(
+ config => TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir('test-change')->stringify,
+ ),
+);
+my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ reworked_dir => dir('test-change/reworked'),
+);
+my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target);
+make_path 'test-change';
+END { remove_tree 'test-change' };
+my $fn = $target->plan_file;
+open my $fh, '>', $fn or die "Cannot open $fn: $!";
+say $fh "%project=change\n\n";
+close $fh or die "Error closing $fn: $!";
+
+isa_ok my $change = $CLASS->new(
+ name => 'foo',
+ plan => $plan,
+), $CLASS;
+
+isa_ok $change, 'App::Sqitch::Plan::Line';
+ok $change->is_deploy, 'It should be a deploy change';
+ok !$change->is_revert, 'It should not be a revert change';
+is $change->action, 'deploy', 'And it should say so';
+isa_ok $change->timestamp, 'App::Sqitch::DateTime', 'Timestamp';
+
+my $tag = App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ name => 'alpha',
+ change => $change,
+);
+
+is_deeply [ $change->path_segments ], ['foo.sql'],
+ 'path_segments should have the file name';
+is $change->deploy_dir, $target->deploy_dir,
+ 'The deploy dir should be correct';
+is $change->deploy_file, $target->deploy_dir->file('foo.sql'),
+ 'The deploy file should be correct';
+is $change->revert_dir, $target->revert_dir,
+ 'The revert dir should be correct';
+is $change->revert_file, $target->revert_dir->file('foo.sql'),
+ 'The revert file should be correct';
+is $change->verify_dir, $target->verify_dir,
+ 'The verify dir should be correct';
+is $change->verify_file, $target->verify_dir->file('foo.sql'),
+ 'The verify file should be correct';
+ok !$change->is_reworked, 'The change should not be reworked';
+is_deeply [ $change->path_segments ], ['foo.sql'],
+ 'path_segments should not include suffix';
+
+# Test script_hash.
+is $change->script_hash, undef,
+ 'Nonexistent deploy script hash should be undef';
+make_path $change->deploy_dir->stringify;
+$change->deploy_file->spew(iomode => '>:raw', encode_utf8 "Foo\nBar\nBøz\n亜唖娃阿" );
+$change = $CLASS->new( name => 'foo', plan => $plan );
+is $change->script_hash, 'd48866b846300912570f643c99b2ceec4ba29f5c',
+ 'Deploy script hash should be correct';
+is $change->format_tag_qualified_name, 'foo@HEAD',
+ 'Tag-qualified name should be tagged with @HEAD';
+
+# Identify it as reworked.
+ok $change->add_rework_tags($tag), 'Add a rework tag';
+is_deeply [$change->rework_tags], [$tag], 'Reworked tag should be stored';
+ok $change->is_reworked, 'The change should be reworked';
+$change->deploy_dir->mkpath;
+$change->deploy_dir->file('foo@alpha.sql')->touch;
+is_deeply [ $change->path_segments ], ['foo@alpha.sql'],
+ 'path_segments should now include suffix';
+
+# Make sure all rework tags are searched.
+$change->clear_rework_tags;
+ok !$change->is_reworked, 'The change should not be reworked';
+
+my $tag2 = App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ name => 'beta',
+ change => $change,
+);
+ok $change->add_rework_tags($tag2, $tag), 'Add two rework tags';
+ok $change->is_reworked, 'The change should again be reworked';
+is_deeply [ $change->path_segments ], ['foo@alpha.sql'],
+ 'path_segments should now include the correct suffixc';
+
+is $change->format_name, 'foo', 'Name should format as "foo"';
+is $change->format_name_with_tags, 'foo',
+ 'Name should format with tags as "foo"';
+is $change->format_tag_qualified_name, 'foo@beta',
+ 'Tag-qualified Name should format as "foo@beta"';
+is $change->format_dependencies, '', 'Dependencies should format as ""';
+is $change->format_name_with_dependencies, 'foo',
+ 'Name should format with dependencies as "foo"';
+is $change->format_op_name_dependencies, 'foo',
+ 'Name should format op without dependencies as "foo"';
+is $change->format_content, 'foo ' . $change->timestamp->as_string
+ . ' ' . $change->format_planner,
+ 'Change content should format correctly without dependencies';
+
+is $change->planner_name, $sqitch->user_name,
+ 'Planner name shoudld default to user name';
+is $change->planner_email, $sqitch->user_email,
+ 'Planner email shoudld default to user email';
+is $change->format_planner, join(
+ ' ',
+ $sqitch->user_name,
+ '<' . $sqitch->user_email . '>'
+), 'Planner name and email should format properly';
+
+my $ts = $change->timestamp->as_string;
+is $change->as_string, "foo $ts " . $change->format_planner,
+ 'should stringify to "foo" + planner';
+is $change->since_tag, undef, 'Since tag should be undef';
+is $change->parent, undef, 'Parent should be undef';
+
+is $change->info, join("\n",
+ 'project change',
+ 'change foo',
+ 'planner ' . $change->format_planner,
+ 'date ' . $change->timestamp->as_string,
+), 'Change info should be correct';
+is $change->id, do {
+ my $content = encode_utf8 $change->info;
+ Digest::SHA->new(1)->add(
+ 'change ' . length($content) . "\0" . $content
+ )->hexdigest;
+},'Change ID should be correct';
+
+my $date = App::Sqitch::DateTime->new(
+ year => 2012,
+ month => 7,
+ day => 16,
+ hour => 17,
+ minute => 25,
+ second => 7,
+ time_zone => 'UTC',
+);
+
+sub dep($) {
+ App::Sqitch::Plan::Depend->new(
+ %{ App::Sqitch::Plan::Depend->parse(shift) },
+ plan => $target->plan,
+ project => 'change',
+ )
+}
+
+ok my $change2 = $CLASS->new(
+ name => 'yo/howdy',
+ plan => $plan,
+ since_tag => $tag,
+ parent => $change,
+ lspace => ' ',
+ operator => '-',
+ ropspace => ' ',
+ rspace => "\t",
+ suffix => '@beta',
+ note => 'blah blah blah ',
+ pspace => ' ',
+ requires => [map { dep $_ } qw(foo bar @baz)],
+ conflicts => [dep '!dr_evil'],
+ timestamp => $date,
+ planner_name => 'Barack Obama',
+ planner_email => 'potus@whitehouse.gov',
+), 'Create change with more stuff';
+
+my $ts2 = '2012-07-16T17:25:07Z';
+is $change2->as_string, " - yo/howdy [foo bar \@baz !dr_evil] "
+ . "$ts2 Barack Obama <potus\@whitehouse.gov>\t# blah blah blah",
+ 'It should stringify correctly';
+my $mock_plan = Test::MockModule->new(ref $plan);
+$mock_plan->mock(index_of => 0);
+my $uri = URI->new('https://github.com/sqitchers/sqitch/');
+$mock_plan->mock( uri => $uri );
+
+ok !$change2->is_deploy, 'It should not be a deploy change';
+ok $change2->is_revert, 'It should be a revert change';
+is $change2->action, 'revert', 'It should say so';
+is $change2->since_tag, $tag, 'It should have a since tag';
+is $change2->parent, $change, 'It should have a parent';
+
+is $change2->info, join("\n",
+ 'project change',
+ 'uri https://github.com/sqitchers/sqitch/',
+ 'change yo/howdy',
+ 'parent ' . $change->id,
+ 'planner Barack Obama <potus@whitehouse.gov>',
+ 'date 2012-07-16T17:25:07Z',
+ 'requires',
+ ' + foo',
+ ' + bar',
+ ' + @baz',
+ 'conflicts',
+ ' - dr_evil',
+ '', 'blah blah blah'
+), 'Info should include parent and dependencies';
+
+# Check tags.
+is_deeply [$change2->tags], [], 'Should have no tags';
+ok $change2->add_tag($tag), 'Add a tag';
+is_deeply [$change2->tags], [$tag], 'Should have the tag';
+is $change2->format_name_with_tags, 'yo/howdy @alpha',
+ 'Should format name with tags';
+is $change2->format_tag_qualified_name, 'yo/howdy@alpha',
+ 'Should format tag-qualiified name';
+
+# Add another tag.
+ok $change2->add_tag($tag2), 'Add another tag';
+is_deeply [$change2->tags], [$tag, $tag2], 'Should have both tags';
+is $change2->format_name_with_tags, 'yo/howdy @alpha @beta',
+ 'Should format name with both tags';
+is $change2->format_tag_qualified_name, 'yo/howdy@alpha',
+ 'Should format tag-qualified name with first tag';
+
+is $change2->format_planner, 'Barack Obama <potus@whitehouse.gov>',
+ 'Planner name and email should format properly';
+is $change2->format_dependencies, '[foo bar @baz !dr_evil]',
+ 'Dependencies should format as "[foo bar @baz !dr_evil]"';
+is $change2->format_name_with_dependencies, 'yo/howdy [foo bar @baz !dr_evil]',
+ 'Name should format with dependencies as "yo/howdy [foo bar @baz !dr_evil]"';
+is $change2->format_op_name_dependencies, '- yo/howdy [foo bar @baz !dr_evil]',
+ 'Name should format op with dependencies as "yo/howdy [foo bar @baz !dr_evil]"';
+is $change2->format_content, '- yo/howdy [foo bar @baz !dr_evil] '
+ . $change2->timestamp->as_string . ' ' . $change2->format_planner,
+ 'Change content should format correctly with dependencies';
+
+# Check file names.
+my @fn = ('yo', 'howdy@beta.sql');
+$change2->add_rework_tags($tag2);
+is_deeply [ $change2->path_segments ], \@fn,
+ 'path_segments should include directories';
+is $change2->deploy_dir, $target->reworked_deploy_dir,
+ 'Deploy dir should be in rworked dir';
+is $change2->deploy_file, $target->reworked_deploy_dir->file(@fn),
+ 'Deploy file should be in rworked dir and include suffix';
+is $change2->revert_dir, $target->reworked_revert_dir,
+ 'Revert dir should be in rworked dir';
+is $change2->revert_file, $target->reworked_revert_dir->file(@fn),
+ 'Revert file should be in rworked dir and include suffix';
+is $change2->verify_dir, $target->reworked_verify_dir,
+ 'Verify dir should be in rworked dir';
+is $change2->verify_file, $target->reworked_verify_dir->file(@fn),
+ 'Verify file should be in rworked dir and include suffix';
+
+##############################################################################
+# Test open_script.
+make_path dir(qw(test-change deploy))->stringify;
+file(qw(test-change deploy baz.sql))->touch;
+my $change2_file = file qw(test-change deploy bar.sql);
+$fh = $change2_file->open('>:utf8_strict') or die "Cannot open $change2_file: $!\n";
+$fh->say('-- This is a comment');
+$fh->say('# And so is this');
+$fh->say('; and this, w€€!');
+$fh->say('/* blah blah blah */');
+$fh->close;
+
+ok $change2 = $CLASS->new( name => 'baz', plan => $plan ),
+ 'Create change "baz"';
+
+ok $change2 = $CLASS->new( name => 'bar', plan => $plan ),
+ 'Create change "bar"';
+
+##############################################################################
+# Test file handles.
+ok $fh = $change2->deploy_handle, 'Get deploy handle';
+is $fh->getline, "-- This is a comment\n", 'It should be the deploy file';
+
+make_path dir(qw(test-change revert))->stringify;
+$fh = $change2->revert_file->open('>')
+ or die "Cannot open " . $change2->revert_file . ": $!\n";
+$fh->say('-- revert it, baby');
+$fh->close;
+ok $fh = $change2->revert_handle, 'Get revert handle';
+is $fh->getline, "-- revert it, baby\n", 'It should be the revert file';
+
+make_path dir(qw(test-change verify))->stringify;
+$fh = $change2->verify_file->open('>')
+ or die "Cannot open " . $change2->verify_file . ": $!\n";
+$fh->say('-- verify it, baby');
+$fh->close;
+ok $fh = $change2->verify_handle, 'Get verify handle';
+is $fh->getline, "-- verify it, baby\n", 'It should be the verify file';
+
+##############################################################################
+# Test the requires/conflicts params.
+my $file = file qw(t plans multi.plan);
+my $sqitch2 = App::Sqitch->new(
+ config => TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir('test-change')->stringify,
+ 'core.plan_file' => $file->stringify,
+ ),
+);
+my $target2 = App::Sqitch::Target->new(sqitch => $sqitch2);
+my $plan2 = $target2->plan;
+ok $change2 = $CLASS->new(
+ name => 'whatever',
+ plan => $plan2,
+ requires => [dep 'hey', dep 'you'],
+ conflicts => [dep '!hey-there'],
+), 'Create a change with explicit requires and conflicts';
+is_deeply [$change2->requires], [dep 'hey', dep 'you'], 'requires should be set';
+is_deeply [$change2->conflicts], [dep '!hey-there'], 'conflicts should be set';
+is_deeply [$change2->dependencies], [dep 'hey', dep 'you', dep '!hey-there'],
+ 'Dependencies should include requires and conflicts';
+is_deeply [$change2->requires_changes], [$plan2->get('hey'), $plan2->get('you')],
+ 'Should find changes for requires';
+is_deeply [$change2->conflicts_changes], [$plan2->get('hey-there')],
+ 'Should find changes for conflicts';
+
+##############################################################################
+# Test ID for a change with a UTF-8 name.
+ok $change2 = $CLASS->new(
+ name => '阱阪阬',
+ plan => $plan2,
+), 'Create change with UTF-8 name';
+
+is $change2->info, join("\n",
+ 'project ' . 'multi',
+ 'uri ' . $uri->canonical,
+ 'change ' . '阱阪阬',
+ 'planner ' . $change2->format_planner,
+ 'date ' . $change2->timestamp->as_string,
+), 'The name should be decoded text in info';
+
+is $change2->id, do {
+ my $content = Encode::encode_utf8 $change2->info;
+ Digest::SHA->new(1)->add(
+ 'change ' . length($content) . "\0" . $content
+ )->hexdigest;
+},'Change ID should be hashed from encoded UTF-8';
+
+##############################################################################
+# Test note_prompt().
+is $change->note_prompt(
+ for => 'add',
+ scripts => [$change->deploy_file, $change->revert_file, $change->verify_file],
+), exp_prompt(
+ for => 'add',
+ scripts => [$change->deploy_file, $change->revert_file, $change->verify_file],
+ name => $change->format_op_name_dependencies,
+), 'note_prompt() should work';
+
+is $change2->note_prompt(
+ for => 'add',
+ scripts => [$change2->deploy_file, $change2->revert_file, $change2->verify_file],
+), exp_prompt(
+ for => 'add',
+ scripts => [$change2->deploy_file, $change2->revert_file, $change2->verify_file],
+ name => $change2->format_op_name_dependencies,
+), 'note_prompt() should work';
+
+sub exp_prompt {
+ my %p = @_;
+ join(
+ '',
+ __x(
+ "Please enter a note for your change. Lines starting with '#' will\n" .
+ "be ignored, and an empty message aborts the {command}.",
+ command => $p{for},
+ ),
+ "\n",
+ __x('Change to {command}:', command => $p{for}),
+ "\n\n",
+ ' ', $p{name},
+ join "\n ", '', @{ $p{scripts} },
+ "\n",
+ );
+}
diff --git a/t/changelist.t b/t/changelist.t
new file mode 100644
index 00000000..b35510b3
--- /dev/null
+++ b/t/changelist.t
@@ -0,0 +1,366 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 248;
+#use Test::More 'no_plan';
+use Test::NoWarnings;
+use Test::Exception;
+use Path::Class;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::MockModule;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+BEGIN { require_ok 'App::Sqitch::Plan::ChangeList' or die }
+
+my $sqitch = App::Sqitch->new(config => TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+));
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target);
+
+my $foo = App::Sqitch::Plan::Change->new(plan => $plan, name => 'foo');
+my $bar = App::Sqitch::Plan::Change->new(plan => $plan, name => 'bar', parent => $foo);
+my $baz = App::Sqitch::Plan::Change->new(plan => $plan, name => 'baz', parent => $bar);
+my $yo1 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo', parent => $baz);
+my $yo2 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo', parent => $yo1, planner_name => 'Phil' );
+
+my $alpha = App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ change => $yo1,
+ name => 'alpha',
+);
+$yo1->add_tag($alpha);
+my $changes = App::Sqitch::Plan::ChangeList->new(
+ $foo,
+ $bar,
+ $yo1,
+ $baz,
+ $yo2,
+);
+
+my ($earliest_id, $latest_id);
+my $engine_mocker = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+my $offset = 0;
+
+$engine_mocker->mock(earliest_change_id => sub {
+ $offset = $_[1];
+ $changes->change_at( $changes->index_of($earliest_id) + $offset )->id;
+});
+
+$engine_mocker->mock(latest_change_id => sub {
+ $offset = $_[1];
+ $changes->change_at( $changes->index_of($latest_id) - $offset )->id;
+});
+
+is $changes->count, 5, 'Count should be six';
+is_deeply [$changes->changes], [$foo, $bar, $yo1, $baz, $yo2],
+ 'Changes should be in order';
+is_deeply [$changes->items], [$changes->changes],
+ 'Items should be the same as changes';
+is_deeply [$changes->tags], [$alpha], 'Tags should return the one tag';
+is $changes->change_at(0), $foo, 'Should have foo at 0';
+is $changes->change_at(1), $bar, 'Should have bar at 1';
+is $changes->change_at(2), $yo1, 'Should have yo1 at 2';
+is $changes->change_at(3), $baz, 'Should have baz at 4';
+is $changes->change_at(4), $yo2, 'Should have yo2 at 5';
+
+is $changes->index_of('non'), undef, 'Should not find "non"';
+is $changes->index_of('@non'), undef, 'Should not find "@non"';
+is $changes->index_of('foo'), 0, 'Should find foo at 0';
+is $changes->index_of($foo->id), 0, 'Should find foo by ID at 0';
+is $changes->index_of('bar'), 1, 'Should find bar at 1';
+is $changes->index_of('bar^'), 0, 'Should find bar^ at 0';
+is $changes->index_of('bar~'), 2, 'Should find bar~ at 2';
+is $changes->index_of('bar~~'), 3, 'Should find bar~~ at 3';
+is $changes->index_of('bar~~~'), undef, 'Should not find bar~~~';
+is $changes->index_of('bar~2'), 3, 'Should find bar~2 at 3';
+is $changes->index_of('bar~3'), 4, 'Should find bar~3 at 4';
+is $changes->index_of($bar->id), 1, 'Should find bar by ID at 1';
+is $changes->index_of('@alpha'), 2, 'Should find @alpha at 2';
+is $changes->index_of('@alpha^'), 1, 'Should find @alpha^ at 1';
+is $changes->index_of('@alpha^^'), 0, 'Should find @alpha^^ at 1';
+is $changes->index_of('@alpha^^^'), undef, 'Should not find @alpha^^^';
+is $changes->index_of($alpha->id), 2, 'Should find @alpha by ID at 2';
+is $changes->index_of('baz'), 3, 'Should find baz at 3';
+is $changes->index_of($baz->id), 3, 'Should find baz by ID at 3';
+is $changes->index_of('baz^^^'), undef, 'Should not find baz^^^';
+is $changes->index_of('baz^3'), 0, 'Should not find baz^3 at 0';
+is $changes->index_of('baz^4'), undef, 'Should not find baz^4';
+is $changes->index_of($baz->id . '^'), 2, 'Should find baz by ID^ at 2';
+
+throws_ok { $changes->index_of('yo') } 'App::Sqitch::X',
+ 'Should get multiple indexes error looking for index of "yo"';
+is $@->ident, 'plan', 'Multiple indexes error ident should be "plan"';
+is $@->message, __ 'Change lookup failed',
+ 'Multiple indexes message should be correct';
+is_deeply +MockOutput->get_vent, [
+ [__x(
+ 'Change "{change}" is ambiguous. Please specify a tag-qualified change:',
+ change => 'yo',
+ )],
+ [ ' * ', 'yo@HEAD' ],
+ [ ' * ', 'yo@alpha' ],
+], 'Should have output listing tag-qualified changes';
+
+throws_ok { $changes->index_of('yo@howdy') } 'App::Sqitch::X',
+ 'Should unknown tag error for invalid tag';
+is $@->ident, 'plan', 'Unknown tag error ident should be "plan"';
+is $@->message, __x(
+ 'Unknown tag "{tag}"',
+ tag => '@howdy',
+), 'Unknown taf message should be correct';
+
+is $changes->index_of('yo@alpha'), 2, 'Should get 2 for yo@alpha';
+is $changes->index_of('yo@alpha^'), 1, 'Should get 1 for yo@alpha^';
+is $changes->index_of('yo@HEAD'), 4, 'Should get 4 for yo@HEAD';
+is $changes->index_of('yo@HEAD^'), 3, 'Should get 3 for yo@HEAD^';
+is $changes->index_of('yo@HEAD~'), undef, 'Should get undef for yo@HEAD~';
+is $changes->index_of('yo@HEAD~~'), undef, 'Should get undef for yo@HEAD~~';
+is $changes->index_of('foo@alpha'), 0, 'Should get 0 for foo@alpha';
+is $changes->index_of('foo@HEAD'), 0, 'Should get 0 for foo@HEAD';
+is $changes->index_of('foo@ROOT'), 0, 'Should get 0 for foo@ROOT';
+is $changes->index_of('baz@alpha'), undef, 'Should get undef for baz@alpha';
+is $changes->index_of('baz@HEAD'), 3, 'Should get 3 for baz@HEAD';
+is $changes->index_of('@HEAD'), 4, 'Should get 4 for @HEAD';
+is $changes->index_of('@ROOT'), 0, 'Should get 0 for @ROOT';
+is $changes->index_of('@HEAD^'), 3, 'Should get 3 for @HEAD^';
+is $changes->index_of('@HEAD~'), undef, 'Should get undef for @HEAD~';
+is $changes->index_of('@ROOT~'), 1, 'Should get 1 for @ROOT~';
+is $changes->index_of('@ROOT^'), undef, 'Should get undef for @ROOT^';
+is $changes->index_of('HEAD'), 4, 'Should get 4 for HEAD';
+is $changes->index_of('ROOT'), 0, 'Should get 0 for ROOT';
+is $changes->index_of('HEAD^'), 3, 'Should get 3 for HEAD^';
+is $changes->index_of('HEAD~'), undef, 'Should get undef for HEAD~';
+is $changes->index_of('ROOT~'), 1, 'Should get 1 for ROOT~';
+is $changes->index_of('ROOT^'), undef, 'Should get undef for ROOT^';
+
+is $changes->get('foo'), $foo, 'Should get foo for "foo"';
+is $changes->get('foo~'), $bar, 'Should get bar for "foo~"';
+is $changes->get($foo->id), $foo, 'Should get foo by ID';
+is $changes->get('bar'), $bar, 'Should get bar for "bar"';
+is $changes->get('bar^'), $foo, 'Should get foo for "bar^"';
+is $changes->get('bar~'), $yo1, 'Should get yo1 for "bar~"';
+is $changes->get('bar~~'), $baz, 'Should get baz for "bar~~"';
+is $changes->get('bar~3'), $yo2, 'Should get yo2 for "bar~3"';
+is $changes->get($bar->id), $bar, 'Should get bar by ID';
+is $changes->get($alpha->id), $yo1, 'Should get "yo" by the @alpha tag ID';
+is $changes->get('baz'), $baz, 'Should get baz for "baz"';
+is $changes->get($baz->id), $baz, 'Should get baz by ID';
+is $changes->get('@HEAD^'), $baz, 'Should get baz for "@HEAD^"';
+is $changes->get('@HEAD^^'), $yo1, 'Should get yo1 for "@HEAD^^"';
+is $changes->get('@HEAD^3'), $bar, 'Should get bar for "@HEAD^3"';
+is $changes->get('@ROOT'), $foo, 'Should get foo for "@ROOT"';
+is $changes->get('HEAD^'), $baz, 'Should get baz for "HEAD^"';
+is $changes->get('HEAD^^'), $yo1, 'Should get yo1 for "HEAD^^"';
+is $changes->get('HEAD^3'), $bar, 'Should get bar for "HEAD^3"';
+is $changes->get('ROOT'), $foo, 'Should get foo for "ROOT"';
+
+is $changes->get('yo@alpha'), $yo1, 'Should get yo1 for yo@alpha';
+is $changes->get('yo@HEAD'), $yo2, 'Should get yo2 for yo@HEAD';
+is $changes->get('foo@alpha'), $foo, 'Should get foo for foo@alpha';
+is $changes->get('foo@HEAD'), $foo, 'Should get foo for foo@HEAD';
+is $changes->get('baz@alpha'), undef, 'Should get undef for baz@alpha';
+is $changes->get('baz@HEAD'), $baz, 'Should get baz for baz@HEAD';
+is $changes->get('yo@HEAD'), $yo2, 'Should get yo2 for "yo@HEAD"';
+is $changes->get('foo@ROOT'), $foo, 'Should get foo for "foo@ROOT"';
+
+is $changes->find('yo'), $yo1, 'Should find yo1 with "yo"';
+is $changes->find('yo@alpha'), $yo1, 'Should find yo1 with "yo@alpha"';
+is $changes->find('yo@HEAD'), $yo2, 'Should find yo2 with yo@HEAD';
+is $changes->find('foo'), $foo, 'Should find foo for "foo"';
+is $changes->find('foo@alpha'), $foo, 'Should find foo for "foo@alpha"';
+is $changes->find('foo@HEAD'), $foo, 'Should find foo for "foo@HEAD"';
+is $changes->find('yo^'), $bar, 'Should find bar with "yo^"';
+is $changes->find('yo^^'), $foo, 'Should find foo with "yo^^"';
+is $changes->find('yo^2'), $foo, 'Should find foo with "yo^2"';
+is $changes->find('yo~'), $baz, 'Should find baz with "yo~"';
+is $changes->find('yo~~'), $yo2, 'Should find yo2 with "yo~~"';
+is $changes->find('yo~2'), $yo2, 'Should find yo2 with "yo~2"';
+is $changes->find('yo@alpha^'), $bar, 'Should find bar with "yo@alpha^"';
+is $changes->find('yo@alpha~'), $baz, 'Should find baz with "yo@alpha^"';
+is $changes->find('yo@HEAD^'), $baz, 'Should find baz with yo@HEAD^';
+is $changes->find('@HEAD^'), $baz, 'Should find baz with @HEAD^';
+is $changes->find('@ROOT~'), $bar, 'Should find bar with @ROOT~^';
+is $changes->find('HEAD^'), $baz, 'Should find baz with HEAD^';
+is $changes->find('ROOT~'), $bar, 'Should find bar with ROOT~^';
+
+ok $changes->contains('yo'), 'Should contain yo1 with "yo"';
+ok $changes->contains('yo@alpha'), 'Should contain yo1 with "yo@alpha"';
+ok $changes->contains('yo@HEAD'), 'Should contain yo2 with yo@HEAD';
+ok $changes->contains('foo'), 'Should contain foo for "foo"';
+ok $changes->contains('foo@alpha'), 'Should contain foo for "foo@alpha"';
+ok $changes->contains('foo@HEAD'), 'Should contain foo for "foo@HEAD"';
+ok $changes->contains('yo^'), 'Should contain bar with "yo^"';
+ok $changes->contains('yo^^'), 'Should contain foo with "yo^^"';
+ok $changes->contains('yo^2'), 'Should contain foo with "yo^2"';
+ok $changes->contains('yo~'), 'Should contain baz with "yo~"';
+ok $changes->contains('yo~~'), 'Should contain yo2 with "yo~~"';
+ok $changes->contains('yo~2'), 'Should contain yo2 with "yo~2"';
+ok $changes->contains('yo@alpha^'), 'Should contain bar with "yo@alpha^"';
+ok $changes->contains('yo@alpha~'), 'Should contain baz with "yo@alpha^"';
+ok $changes->contains('yo@HEAD^'), 'Should contain baz with yo@HEAD^';
+ok $changes->contains('@HEAD^'), 'Should contain baz with @HEAD^';
+ok $changes->contains('@ROOT~'), 'Should contain bar with @ROOT~^';
+ok $changes->contains('HEAD^'), 'Should contain baz with HEAD^';
+ok $changes->contains('ROOT~'), 'Should contain bar with ROOT~^';
+
+throws_ok { $changes->get('yo') } 'App::Sqitch::X',
+ 'Should get multiple indexes error looking for index of "yo"';
+is $@->ident, 'plan', 'Multiple indexes error ident should be "plan"';
+is $@->message, __ 'Change lookup failed',
+ 'Multiple indexes message should be correct';
+is_deeply +MockOutput->get_vent, [
+ [__x(
+ 'Change "{change}" is ambiguous. Please specify a tag-qualified change:',
+ change => 'yo',
+ )],
+ [ ' * ', 'yo@HEAD' ],
+ [ ' * ', 'yo@alpha' ],
+], 'Should have output listing tag-qualified changes';
+
+throws_ok { $changes->get('yo@howdy') } 'App::Sqitch::X',
+ 'Should unknown tag error for invalid tag';
+is $@->ident, 'plan', 'Unknown tag error ident should be "plan"';
+is $@->message, __x(
+ 'Unknown tag "{tag}"',
+ tag => '@howdy',
+), 'Unknown taf message should be correct';
+
+my $hi = App::Sqitch::Plan::Change->new(plan => $plan, name => 'hi');
+ok $changes->append($hi), 'Push hi';
+is $changes->count, 6, 'Count should now be six';
+is_deeply [$changes->changes], [$foo, $bar, $yo1, $baz, $yo2, $hi],
+ 'Changes should be in order with $hi at the end';
+is $changes->index_of('hi'), 5, 'Should find "hi" at index 5';
+is $changes->index_of($hi->id), 5, 'Should find "hi" by ID at index 5';
+is $changes->index_of('@ROOT'), 0, 'Index of @ROOT should still be 0';
+is $changes->index_of('@HEAD'), 5, 'Index of @HEAD should now be 5';
+is $changes->index_of('ROOT'), 0, 'Index of ROOT should still be 0';
+is $changes->index_of('HEAD'), 5, 'Index of HEAD should now be 5';
+
+# Now try first_index_of().
+is $changes->first_index_of('non'), undef, 'First index of "non" should be undef';
+is $changes->first_index_of('foo'), 0, 'First index of "foo" should be 0';
+is $changes->first_index_of('foo~'), 1, 'First index of "foo~" should be 1';
+is $changes->first_index_of('foo~~'), 2, 'First index of "foo~~" should be 2';
+is $changes->first_index_of('foo~3'), 3, 'First index of "foo~3" should be 3';
+is $changes->first_index_of('foo~~~'), undef, 'Should not find first index of "foo~~~"';
+is $changes->first_index_of('foo', '@ROOT'), undef, 'First index of "foo" since @ROOT should be undef';
+is $changes->first_index_of('bar'), 1, 'First index of "bar" should be 1';
+is $changes->first_index_of('yo'), 2, 'First index of "yo" should be 2';
+is $changes->first_index_of('yo', '@ROOT'), 2, 'First index of "yo" since @ROOT should be 2';
+is $changes->first_index_of('baz'), 3, 'First index of "baz" should be 3';
+is $changes->first_index_of('baz^'), 2, 'First index of "baz^" should be 2';
+is $changes->first_index_of('baz^^'), 1, 'First index of "baz^^" should be 1';
+is $changes->first_index_of('baz^3'), 0, 'First index of "baz^3" should be 0';
+is $changes->first_index_of('baz^^^'), undef, 'Should not find first index of "baz^^^"';
+is $changes->first_index_of('yo', '@alpha'), 4,
+ 'First index of "yo" since "@alpha" should be 4';
+is $changes->first_index_of('yo', 'baz'), 4,
+ 'First index of "yo" since "baz" should be 4';
+is $changes->first_index_of('yo^', 'baz'), 3,
+ 'First index of "yo^" since "baz" should be 4';
+is $changes->first_index_of('yo~', 'baz'), 5,
+ 'First index of "yo~" since "baz" should be 5';
+throws_ok { $changes->first_index_of('baz', 'nonexistent') } 'App::Sqitch::X',
+ 'Should get an exception for an unknown change passed to first_index_of()';
+is $@->ident, 'plan', 'Unknown change error ident should be "plan"';
+is $@->message, __x(
+ 'Unknown change: "{change}"',
+ change => 'nonexistent',
+), 'Unknown change message should be correct';
+
+# Try appending a couple more changes.
+my $so = App::Sqitch::Plan::Change->new(plan => $plan, name => 'so');
+my $fu = App::Sqitch::Plan::Change->new(plan => $plan, name => 'fu');
+ok $changes->append($so, $fu), 'Push so and fu';
+is $changes->count, 8, 'Count should now be eight';
+is $changes->index_of('@ROOT'), 0, 'Index of @ROOT should remain 0';
+is $changes->index_of('@HEAD'), 7, 'Index of @HEAD should now be 7';
+is $changes->index_of('ROOT'), 0, 'Index of ROOT should remain 0';
+is $changes->index_of('HEAD'), 7, 'Index of HEAD should now be 7';
+is_deeply [$changes->changes], [$foo, $bar, $yo1, $baz, $yo2, $hi, $so, $fu],
+ 'Changes should be in order with $so and $fu at the end';
+
+# Try indexing a tag.
+my $beta = App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ change => $yo2,
+ name => 'beta',
+);
+$yo2->add_tag($beta);
+ok $changes->index_tag(4, $beta), 'Index beta';
+is $changes->index_of('@beta'), 4, 'Should find @beta at index 4';
+is $changes->get('@beta'), $yo2, 'Should find yo2 via @beta';
+is $changes->get($beta->id), $yo2, 'Should find yo2 via @beta ID';
+is_deeply [$changes->tags], [$alpha, $beta], 'Tags should return both tags';
+
+##############################################################################
+# Test last_tagged(), last_change(), index_of_last_tagged().
+is $changes->index_of_last_tagged, 2, 'Should get 2 for last tagged index';
+is $changes->last_tagged_change, $yo1, 'Should find "yo" as last tagged';
+is $changes->count, 8, 'Should get 8 for count';
+is $changes->last_change, $fu, 'Should find fu as last change';
+
+for my $changes (
+ [0, $yo1],
+ [1, $foo, $yo1],
+ [3, $foo, $bar, $baz, $yo1],
+ [4, $foo, $bar, $baz, $hi, $yo1],
+) {
+ my $index = shift @{ $changes };
+ my $n = App::Sqitch::Plan::ChangeList->new(@{ $changes });
+ is $n->index_of_last_tagged, $index, "Should find last tagged index at $index";
+ is $n->last_tagged_change, $changes->[$index], "Should find last tagged at $index";
+ is $n->count, ($index + 1), "Should get count " . ($index + 1);
+ is $n->last_change, $changes->[$index], "Should find last change at $index";
+}
+
+for my $changes (
+ [],
+ [$foo, $baz],
+ [$foo, $bar, $baz, $hi],
+) {
+ my $n = App::Sqitch::Plan::ChangeList->new(@{ $changes });
+ is $n->index_of_last_tagged, undef,
+ 'Should not find tag index in ' . scalar @{$changes} . ' changes';
+ is $n->last_tagged_change, undef,
+ 'Should not find tag in ' . scalar @{$changes} . ' changes';
+ if (!@{ $changes }) {
+ is $n->last_change, undef, "Should find no change in empty plan";
+ }
+}
+
+# Try an empty change list.
+isa_ok $changes = App::Sqitch::Plan::ChangeList->new,
+ 'App::Sqitch::Plan::ChangeList';
+for my $ref (qw(
+ foo
+ bar
+ HEAD
+ @HEAD
+ ROOT
+ @ROOT
+ alpha
+ @alpha
+)) {
+ is $changes->index_of($ref), undef,
+ qq{Should not find index of "$ref" in empty list};
+ is $changes->first_index_of($ref), undef,
+ qq{Should not find first index of "$ref" in empty list};
+ is $changes->get($ref), undef,
+ qq{Should get undef for "$ref" in empty list};
+ ok !$changes->contains($ref),
+ qq{Should not contain "$ref" in empty list};
+ is $changes->find($ref), undef,
+ qq{Should find undef for "$ref" in empty list};
+}
diff --git a/t/checkout.t b/t/checkout.t
new file mode 100644
index 00000000..9c81b4a1
--- /dev/null
+++ b/t/checkout.t
@@ -0,0 +1,660 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use utf8;
+use Path::Class qw(dir file);
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use Test::MockModule;
+use Test::Exception;
+use Test::Warn;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::checkout';
+require_ok $CLASS or die;
+
+isa_ok $CLASS, 'App::Sqitch::Command';
+can_ok $CLASS, qw(
+ target
+ options
+ configure
+ log_only
+ execute
+ deploy_variables
+ revert_variables
+ _collect_deploy_vars
+ _collect_revert_vars
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_"
+ for qw(RevertDeployCommand ConnectingCommand ContextCommand);
+
+is_deeply [$CLASS->options], [qw(
+ plan-file|f=s
+ top-dir=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+ target|t=s
+ mode=s
+ verify!
+ set|s=s%
+ set-deploy|e=s%
+ set-revert|r=s%
+ log-only
+ y
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+ok my $sqitch = App::Sqitch->new(
+ config => TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ ),
+), 'Load a sqitch object';
+
+my $config = $sqitch->config;
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ mode => 'all',
+ _params => [],
+ _cx => [],
+}, 'Check default configuration';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+}), {
+ verify => 0,
+ no_prompt => 0,
+ prompt_accept => 1,
+ mode => 'all',
+ deploy_variables => { foo => 'bar' },
+ revert_variables => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}, 'Should have set option';
+
+is_deeply $CLASS->configure($config, {
+ y => 1,
+ set_deploy => { foo => 'bar' },
+ log_only => 1,
+ verify => 1,
+ mode => 'tag',
+}), {
+ mode => 'tag',
+ no_prompt => 1,
+ prompt_accept => 1,
+ deploy_variables => { foo => 'bar' },
+ verify => 1,
+ log_only => 1,
+ _params => [],
+ _cx => [],
+}, 'Should have mode, deploy_variables, verify, no_prompt, and log_only';
+
+is_deeply $CLASS->configure($config, {
+ y => 0,
+ set_revert => { foo => 'bar' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ revert_variables => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}, 'Should have set_revert option and no_prompt false';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+ set_deploy => { foo => 'dep', hi => 'you' },
+ set_revert => { foo => 'rev', hi => 'me' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'dep', hi => 'you' },
+ revert_variables => { foo => 'rev', hi => 'me' },
+ _params => [],
+ _cx => [],
+}, 'set_deploy and set_revert should overrid set';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+ set_deploy => { hi => 'you' },
+ set_revert => { hi => 'me' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'bar', hi => 'you' },
+ revert_variables => { foo => 'bar', hi => 'me' },
+ _params => [],
+ _cx => [],
+}, 'set_deploy and set_revert should merge with set';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+ set_deploy => { hi => 'you' },
+ set_revert => { my => 'yo' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'bar', hi => 'you' },
+ revert_variables => { foo => 'bar', my => 'yo' },
+ _params => [],
+ _cx => [],
+}, 'set_revert should merge with set_deploy';
+
+CONFIG: {
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ mode => 'all',
+ _params => [],
+ _cx => [],
+ }, 'Should have deploy configuration';
+
+ # Try setting variables.
+ is_deeply $CLASS->configure($config, {
+ set => { foo => 'yo', yo => 'stellar' },
+ }), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'yo', yo => 'stellar' },
+ revert_variables => { foo => 'yo', yo => 'stellar' },
+ _params => [],
+ _cx => [],
+ }, 'Should have merged variables';
+
+ # Make sure we can override mode, prompting, and verify.
+ $config->replace(
+ 'core.engine' => 'sqlite',
+ 'revert.no_prompt' => 1,
+ 'revert.prompt_accept' => 0,
+ 'deploy.verify' => 1,
+ 'deploy.mode' => 'tag',
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 1,
+ prompt_accept => 0,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt and prompt_accept from revert config';
+
+ # Checkout option takes precendence
+ $config->update(
+ 'checkout.no_prompt' => 0,
+ 'checkout.prompt_accept' => 1,
+ 'checkout.verify' => 0,
+ 'checkout.mode' => 'change',
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ mode => 'change',
+ _params => [],
+ _cx => [],
+ }, 'Should have false log_only, verify, true prompt_accept from checkout config';
+
+ $config->update(
+ 'checkout.no_prompt' => 1,
+ map { $_ => undef } qw(
+ revert.no_prompt
+ revert.prompt_accept
+ checkout.verify
+ checkout.mode
+ )
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 1,
+ prompt_accept => 1,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have log_only, prompt_accept true from checkout and verify from deploy';
+
+ # But option should override.
+ is_deeply $CLASS->configure($config, {y => 0, verify => 0, mode => 'all'}), {
+ no_prompt => 0,
+ verify => 0,
+ mode => 'all',
+ prompt_accept => 1,
+ _params => [],
+ _cx => [],
+ }, 'Should have log_only false and mode all again';
+
+ $config->update(
+ 'checkout.no_prompt' => 0,
+ 'checkout.prompt_accept' => 1,
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have log_only false for false config';
+
+ is_deeply $CLASS->configure($config, {y => 1}), {
+ no_prompt => 1,
+ prompt_accept => 1,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt true with -y';
+}
+
+##############################################################################
+# Test _collect_deploy_vars and _collect_revert_vars.
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+);
+my $checkout = $CLASS->new( sqitch => $sqitch);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {},
+ 'Should collect no variables for deploy';
+is_deeply { $checkout->_collect_revert_vars($target) }, {},
+ 'Should collect no variables for revert';
+
+# Add core variables.
+$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'SELECT',
+}, 'Should collect core deploy vars for deploy';
+is_deeply { $checkout->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'SELECT',
+}, 'Should collect core revert vars for revert';
+
+# Add deploy variables.
+$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Should override core vars with deploy vars for deploy';
+
+is_deeply { $checkout->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Should override core vars with deploy vars for revert';
+
+# Add revert variables.
+$config->update('revert.variables' => { dance => 'disco', lunch => 'pizza' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Deploy vars should be unaffected by revert vars';
+is_deeply { $checkout->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'pizza',
+}, 'Should override deploy vars with revert vars for revert';
+
+# Add engine variables.
+$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey', priv => 'UP' });
+my $uri = URI::db->new('db:pg:');
+$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'whiskey',
+}, 'Should override deploy vars with engine vars for deploy';
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'whiskey',
+}, 'Should override checkout vars with engine vars for revert';
+
+# Add target variables.
+$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'winning',
+}, 'Should override engine vars with deploy vars for deploy';
+is_deeply { $checkout->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'winning',
+}, 'Should override engine vars with target vars for revert';
+
+# Add --set variables.
+my %opts = (
+ set => { status => 'tired', herb => 'oregano' },
+);
+$checkout = $CLASS->new(
+ sqitch => $sqitch,
+ %{ $CLASS->configure($config, { %opts }) },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should override target vars with --set vars for deploy';
+is_deeply { $checkout->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should override target vars with --set variables for revert';
+
+# Add --set-deploy-vars
+$opts{set_deploy} = { herb => 'basil', color => 'black' };
+$checkout = $CLASS->new(
+ sqitch => $sqitch,
+ %{ $CLASS->configure($config, { %opts }) },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'basil',
+ color => 'black',
+}, 'Should override --set vars with --set-deploy variables for deploy';
+is_deeply { $checkout->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should not override --set vars with --set-deploy variables for revert';
+
+# Add --set-revert-vars
+$opts{set_revert} = { herb => 'garlic', color => 'red' };
+$checkout = $CLASS->new(
+ sqitch => $sqitch,
+ %{ $CLASS->configure($config, { %opts }) },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $checkout->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'basil',
+ color => 'black',
+}, 'Should not override --set vars with --set-revert variables for deploy';
+is_deeply { $checkout->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'garlic',
+ color => 'red',
+}, 'Should override --set vars with --set-revert variables for revert';
+
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+);
+
+##############################################################################
+# Test execute().
+my $mock_sqitch = Test::MockModule->new(ref $sqitch);
+my (@probe_args, $probed, $orig_method);
+$mock_sqitch->mock(probe => sub { shift; @probe_args = @_; $probed });
+my $mock_cmd = Test::MockModule->new($CLASS);
+$mock_cmd->mock(parse_args => sub {
+ my @ret = shift->$orig_method(@_);
+ $target = $ret[1][0];
+ @ret;
+});
+$orig_method = $mock_cmd->original('parse_args');
+
+my @run_args;
+$mock_sqitch->mock(run => sub { shift; @run_args = @_ });
+
+# Try rebasing to the current branch.
+isa_ok $checkout = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'checkout',
+ config => $config,
+}), $CLASS, 'checkout command';
+my $client = $checkout->client;
+
+$probed = 'fixdupes';
+throws_ok { $checkout->execute($probed) } 'App::Sqitch::X',
+ 'Should get an error current branch';
+is $@->ident, 'checkout', 'Current branch error ident should be "checkout"';
+is $@->message, __x('Already on branch {branch}', branch => $probed),
+ 'Should get proper error for current branch error';
+is_deeply \@probe_args, [$client, qw(rev-parse --abbrev-ref HEAD)],
+ 'The proper args should have been passed to rev-parse';
+@probe_args = ();
+
+# Try a plan with nothing in common with the current branch's plan.
+my (@capture_args, $captured);
+$mock_sqitch->mock(capture => sub { shift; @capture_args = @_; $captured });
+$captured = q{%project=sql
+
+foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+};
+
+throws_ok { $checkout->execute('master') } 'App::Sqitch::X',
+ 'Should get an error for plans without a common change';
+is $@->ident, 'checkout',
+ 'The no common change error ident should be "checkout"';
+is $@->message, __x(
+ 'Branch {branch} has no changes in common with current branch {current}',
+ branch => 'master',
+ current => $probed,
+), 'The no common change error message should be correct';
+
+# Mock the engine interface.
+my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+my (@dep_args, @dep_changes);
+$mock_engine->mock(deploy => sub {
+ @dep_changes = map { $_->name } shift->plan->changes;
+ @dep_args = @_;
+});
+
+my (@rev_args, @rev_changes);
+$mock_engine->mock(revert => sub {
+ @rev_changes = map { $_->name } shift->plan->changes;
+ @rev_args = @_;
+ });
+my @vars;
+$mock_engine->mock(set_variables => sub { shift; push @vars => [@_] });
+
+# Load up the plan file without decoding and change the plan.
+$captured = file(qw(t sql sqitch.plan))->slurp;
+{
+ no utf8;
+ $captured =~ s/widgets/thingíes/;
+}
+
+# Checkout with options.
+isa_ok $checkout = $CLASS->new(
+ log_only => 1,
+ verify => 1,
+ sqitch => $sqitch,
+ mode => 'tag',
+ deploy_variables => { foo => 'bar', one => 1 },
+ revert_variables => { hey => 'there' },
+), $CLASS, 'Object with to and variables';
+
+ok $checkout->execute('master'), 'Checkout master';
+is_deeply \@probe_args, [$client, qw(rev-parse --abbrev-ref HEAD)],
+ 'The proper args should again have been passed to rev-parse';
+is_deeply \@capture_args, [$client, 'show', 'master:' . $checkout->default_target->plan_file ],
+
+ 'Should have requested the plan file contents as of master';
+is_deeply \@run_args, [$client, qw(checkout master)], 'Should have checked out other branch';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+is_deeply +MockOutput->get_info, [[__x(
+ 'Last change before the branches diverged: {last_change}',
+ last_change => 'users @alpha',
+)]], 'Should have emitted info identifying the last common change';
+
+# Did it revert?
+is_deeply \@rev_args, [$checkout->default_target->plan->get('users')->id],
+ '"users" ID and 1 should be passed to the engine revert';
+is_deeply \@rev_changes, [qw(roles users widgets)],
+ 'Should have had the current changes for revision';
+
+# Did it deploy?
+is_deeply \@dep_args, [undef, 'tag'],
+ 'undef, "tag", and 1 should be passed to the engine deploy';
+is_deeply \@dep_changes, [qw(roles users thingíes)],
+ 'Should have had the other branch changes (decoded) for deploy';
+
+ok $target->engine->with_verify, 'Engine should verify';
+ok $target->engine->log_only, 'The engine should be set to log_only';
+is @vars, 2, 'Variables should have been passed to the engine twice';
+is_deeply { @{ $vars[0] } }, { hey => 'there' },
+ 'The revert vars should have been passed first';
+is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 },
+ 'The deploy vars should have been next';
+
+# Try passing a target.
+@vars = ();
+ok $checkout->execute('master', 'db:sqlite:foo'), 'Checkout master with target';
+is $target->name, 'db:sqlite:foo', 'Target should be passed to engine';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# If nothing is deployed, or we are already at the revert target, the revert
+# should be skipped.
+isa_ok $checkout = $CLASS->new(
+ target => 'db:sqlite:hello',
+ log_only => 0,
+ verify => 0,
+ sqitch => $sqitch,
+ mode => 'tag',
+ deploy_variables => { foo => 'bar', one => 1 },
+ revert_variables => { hey => 'there' },
+), $CLASS, 'Object with to and variables';
+
+$mock_engine->mock(revert => sub { hurl { ident => 'revert', message => 'foo', exitval => 1 } });
+@dep_args = @rev_args = @vars = ();
+ok $checkout->execute('master'), 'Checkout master again';
+is $target->name, 'db:sqlite:hello', 'Target should be passed to engine';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Did it deploy?
+ok !$target->engine->log_only, 'The engine should not be set to log_only';
+ok !$target->engine->with_verify, 'The engine should not be set with_verfy';
+is_deeply \@dep_args, [undef, 'tag'],
+ 'undef, "tag", and 1 should be passed to the engine deploy again';
+is_deeply \@dep_changes, [qw(roles users thingíes)],
+ 'Should have had the other branch changes (decoded) for deploy again';
+is @vars, 2, 'Variables should again have been passed to the engine twice';
+is_deeply { @{ $vars[0] } }, { hey => 'there' },
+ 'The revert vars should again have been passed first';
+is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 },
+ 'The deploy vars should again have been next';
+
+# Should get a warning for two targets.
+ok $checkout->execute('master', 'db:sqlite:'), 'Checkout master again with target';
+is $target->name, 'db:sqlite:hello', 'Target should be passed to engine';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; connecting to {target}',
+ target => 'db:sqlite:hello',
+)]], 'Should have warning about two targets';
+
+# Make sure we get an exception for unknown args.
+throws_ok { $checkout->execute(qw(master greg)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown arg';
+is $@->ident, 'checkout', 'Unknow arg ident should be "checkout"';
+is $@->message, __x(
+ 'Unknown argument "{arg}"',
+ arg => 'greg',
+), 'Should get an exeption for two unknown arg';
+
+throws_ok { $checkout->execute(qw(master greg widgets)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown args';
+is $@->ident, 'checkout', 'Unknow args ident should be "checkout"';
+is $@->message, __x(
+ 'Unknown arguments: {arg}',
+ arg => 'greg, widgets',
+), 'Should get an exeption for two unknown args';
+
+# Should die for fatal, unknown, or confirmation errors.
+for my $spec (
+ [ confirm => App::Sqitch::X->new(ident => 'revert:confirm', message => 'foo', exitval => 1) ],
+ [ fatal => App::Sqitch::X->new(ident => 'revert', message => 'foo', exitval => 2) ],
+ [ unknown => bless { } => __PACKAGE__ ],
+) {
+ $mock_engine->mock(revert => sub { die $spec->[1] });
+ throws_ok { $checkout->execute('master') } ref $spec->[1],
+ "Should rethrow $spec->[0] exception";
+}
+
+done_testing;
diff --git a/t/command.t b/t/command.t
new file mode 100644
index 00000000..1b2c85ba
--- /dev/null
+++ b/t/command.t
@@ -0,0 +1,725 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 182;
+#use Test::More 'no_plan';
+use Test::NoWarnings;
+use List::Util qw(first);
+use lib 't/lib';
+use TestConfig;
+
+my $catch_exit;
+BEGIN {
+ $catch_exit = 0;
+ # Stub out exit.
+ *CORE::GLOBAL::exit = sub {
+ die 'EXITED: ' . (@_ ? shift : 0) if $catch_exit;
+ CORE::exit(@_);
+ };
+}
+
+use App::Sqitch;
+use App::Sqitch::Target;
+use Test::Exception;
+use Test::NoWarnings;
+use Test::MockModule;
+use Locale::TextDomain qw(App-Sqitch);
+use Capture::Tiny 0.12 ':all';
+use Path::Class;
+use lib 't/lib';
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Command';
+ use_ok $CLASS or die;
+}
+
+can_ok $CLASS, qw(
+ load
+ class_for
+ create
+ new
+ options
+ configure
+ command
+ prompt
+ ask_y_n
+ parse_args
+ target_params
+ default_target
+);
+
+COMMAND: {
+ # Stub out a couple of commands.
+ package App::Sqitch::Command::whu;
+ use Moo;
+ extends 'App::Sqitch::Command';
+ has foo => (is => 'ro');
+ has feathers => (is => 'ro');
+ $INC{'App/Sqitch/Command/whu.pm'} = __FILE__;
+
+ sub options {
+ return qw(
+ foo
+ hi-there|h
+ icky-foo!
+ feathers=s
+ );
+ }
+
+ package App::Sqitch::Command::wah_hoo;
+ use Moo;
+ extends 'App::Sqitch::Command';
+ $INC{'App/Sqitch/Command/wah_hoo.pm'} = __FILE__;
+}
+
+my $config = TestConfig->new;
+ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object';
+
+##############################################################################
+# Test new().
+throws_ok { $CLASS->new }
+ qr/\QMissing required arguments: sqitch/,
+ 'Should get an exception for missing sqitch param';
+my $array = [];
+throws_ok { $CLASS->new({ sqitch => $array }) }
+ qr/\QReference [] did not pass type constraint "Sqitch"/,
+ 'Should get an exception for array sqitch param';
+throws_ok { $CLASS->new({ sqitch => 'foo' }) }
+ qr/\QValue "foo" did not pass type constraint "Sqitch"/,
+ 'Should get an exception for string sqitch param';
+
+isa_ok $CLASS->new({sqitch => $sqitch}), $CLASS;
+
+##############################################################################
+# Test configure.
+my $subclass = 'App::Sqitch::Command::whu';
+is_deeply $subclass->configure($config, {}), {},
+ 'Should get empty hash for no config or options';
+$config->update('whu.foo' => 'hi');
+is_deeply $subclass->configure($config, {}), {foo => 'hi'},
+ 'Should get config with no options';
+is_deeply $subclass->configure($config, {foo => 'yo'}), {foo => 'yo'},
+ 'Options should override config';
+is_deeply $subclass->configure($config, {'foo_bar' => 'yo'}),
+ {foo => 'hi', foo_bar => 'yo'},
+ 'Options keys should have dashes changed to underscores';
+
+##############################################################################
+# Test class_for().
+is $CLASS->class_for($sqitch, 'whu'), 'App::Sqitch::Command::whu',
+ 'Should find class for "whu"';
+is $CLASS->class_for($sqitch, 'wah-hoo'), 'App::Sqitch::Command::wah_hoo',
+ 'Should find class for "wah-hoo"';
+is $CLASS->class_for($sqitch, 'help'), 'App::Sqitch::Command::help',
+ 'Should find class for "help"';
+
+# Make sure it logs debugging for unkonwn classes.
+DEBUG: {
+ my $smock = Test::MockModule->new('App::Sqitch');
+ my $debug;
+ $smock->mock(debug => sub { $debug = $_[1] });
+ is $CLASS->class_for($sqitch, '_nonesuch'), undef,
+ 'Should find no class for "_nonesush"';
+ like $debug, qr{^Can't locate App/Sqitch/Command/_nonesuch\.pm in \@INC},
+ 'Should have sent error to debug';
+}
+
+##############################################################################
+# Test load().
+ok $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object';
+ok my $cmd = $CLASS->load({
+ command => 'whu',
+ sqitch => $sqitch,
+ config => $config,
+ args => []
+}), 'Load a "whu" command';
+isa_ok $cmd, 'App::Sqitch::Command::whu';
+is $cmd->sqitch, $sqitch, 'The sqitch attribute should be set';
+is $cmd->command, 'whu', 'The command method should return "whu"';
+
+$config->update('whu.foo' => 'hi');
+ok $cmd = $CLASS->load({
+ command => 'whu',
+ sqitch => $sqitch,
+ config => $config,
+ args => []
+}), 'Load a "whu" command with "foo" config';
+is $cmd->foo, 'hi', 'The "foo" attribute should be set';
+
+# Test handling of nonexistent commands.
+throws_ok { $CLASS->load({ command => 'nonexistent', sqitch => $sqitch }) }
+ 'App::Sqitch::X', 'Should exit';
+is $@->ident, 'command', 'Nonexistent command error ident should be "config"';
+is $@->message, __x(
+ '"{command}" is not a valid command',
+ command => 'nonexistent',
+), 'Should get proper mesage for nonexistent command';
+is $@->exitval, 1, 'Nonexistent command should yield exitval of 1';
+
+# Test command that evals to a syntax error.
+throws_ok {
+ local $SIG{__WARN__} = sub { } if $] < 5.11; # Warns on 5.10.
+ $CLASS->load({ command => 'foo.bar', sqitch => $sqitch })
+} 'App::Sqitch::X', 'Should die on bad command';
+is $@->ident, 'command', 'Bad command error ident should be "config"';
+is $@->message, __x(
+ '"{command}" is not a valid command',
+ command => 'foo.bar',
+), 'Should get proper mesage for bad command';
+is $@->exitval, 1, 'Bad command should yield exitval of 1';
+
+NOCOMMAND: {
+ # Test handling of no command.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $CLASS->load({ command => '', sqitch => $sqitch }) }
+ qr/USAGE/, 'No command should yield usage';
+ is_deeply \@args, [$CLASS], 'No args should be passed to usage';
+}
+
+# Test handling a bad command implementation.
+throws_ok { $CLASS->load({ command => 'bad', sqitch => $sqitch }) }
+ 'App::Sqitch::X', 'Should die on broken command module';
+is $@->ident, 'command', 'Broken command error ident should be "config"';
+is $@->message, __x(
+ '"{command}" is not a valid command',
+ command => 'bad',
+), 'Should get proper mesage for broken command';
+is $@->exitval, 1, 'Broken command should yield exitval of 1';
+
+# Test options processing.
+$config->update('whu.feathers' => 'yes');
+ok $cmd = $CLASS->load({
+ command => 'whu',
+ sqitch => $sqitch,
+ config => $config,
+ args => ['--feathers' => 'no']
+}), 'Load a "whu" command with "--feathers" option';
+is $cmd->feathers, 'no', 'The "feathers" attribute should be set';
+
+# Test command with a dash in its name.
+ok $cmd = $CLASS->load({
+ command => 'wah-hoo',
+ sqitch => $sqitch,
+ config => $config,
+}), 'Load a "wah-hoo" command';
+isa_ok $cmd, "$CLASS\::wah_hoo", 'It';
+is $cmd->command, 'wah-hoo', 'command() should return hyphenated name';
+
+##############################################################################
+# Test create().
+my $pkg = $CLASS . '::whu';
+$config->replace;
+ok $cmd = $pkg->create({
+ sqitch => $sqitch,
+ config => $config,
+ args => []
+}), 'Create a "whu" command';
+isa_ok $cmd, 'App::Sqitch::Command::whu';
+is $cmd->sqitch, $sqitch, 'The sqitch attribute should be set';
+is $cmd->command, 'whu', 'The command method should return "whu"';
+
+# Test config merging.
+$config->update('whu.foo' => 'hi');
+ok $cmd = $pkg->create({
+ sqitch => $sqitch,
+ config => $config,
+ args => []
+}), 'Create a "whu" command with "foo" config';
+is $cmd->foo, 'hi', 'The "foo" attribute should be set';
+
+# Test options processing.
+$config->update('whu.feathers' => 'yes');
+ok $cmd = $pkg->create({
+ sqitch => $sqitch,
+ config => $config,
+ args => ['--feathers' => 'no']
+}), 'Create a "whu" command with "--feathers" option';
+is $cmd->feathers, 'no', 'The "feathers" attribute should be set';
+
+##############################################################################
+# Test default_target.
+ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object";
+isa_ok my $target = $cmd->default_target, 'App::Sqitch::Target',
+ 'default target';
+is $target->name, 'db:', 'Default target name should be "db:"';
+is $target->uri, URI->new('db:'), 'Default target URI should be "db:"';
+
+# Track what gets passed to Config->get().
+my (@get_keys, $orig_get);
+my $cmock = TestConfig->mock(get => sub {
+ my ($self, %p) = @_;
+ push @get_keys => $p{key};
+ $orig_get->($self, %p);
+});
+$orig_get = $cmock->original('get');
+
+# Make sure the core.engine config option gets used.
+$config->update('core.engine' => 'sqlite');
+ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object";
+isa_ok $target = $cmd->default_target, 'App::Sqitch::Target',
+ 'default target';
+is $target->name, 'db:sqlite:', 'Default target name should be "db:sqlite:"';
+is $target->uri, URI->new('db:sqlite:'), 'Default target URI should be "db:sqlite:"';
+is_deeply \@get_keys,
+ [qw(core.engine core.target core.engine engine.sqlite.target)],
+ 'Should have fetched config stuff';
+
+# We should get stuff from the engine section of the config.
+$config->update(
+ 'core.engine' => 'pg',
+ 'engine.pg.target' => 'db:pg:foo',
+);
+@get_keys = ();
+ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object";
+isa_ok $target = $cmd->default_target, 'App::Sqitch::Target',
+ 'default target';
+is $target->name, 'db:pg:foo', 'Default target name should be "db:pg:foo"';
+is $target->uri, URI->new('db:pg:foo'), 'Default target URI should be "db:pg:foo"';
+is_deeply \@get_keys,
+ [qw(core.engine core.target core.engine engine.pg.target)],
+ 'Should have fetched config stuff again';
+
+# Cleanup.
+$cmock->unmock('get');
+
+##############################################################################
+# Test command and execute.
+can_ok $CLASS, 'execute';
+ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object";
+is $CLASS->command, '', 'Base class command should be ""';
+is $cmd->command, '', 'Base object command should be ""';
+throws_ok { $cmd->execute } 'App::Sqitch::X',
+ 'Should get an error calling execute on command base class';
+is $@->ident, 'DEV', 'Execute exception ident should be "DEV"';
+is $@->message, "The execute() method must be called from a subclass of $CLASS",
+ 'The execute() error message should be correct';
+
+ok $cmd = App::Sqitch::Command::whu->new({sqitch => $sqitch}),
+ 'Create a subclass command object';
+is $cmd->command, 'whu', 'Subclass oject command should be "whu"';
+is +App::Sqitch::Command::whu->command, 'whu', 'Subclass class command should be "whu"';
+throws_ok { $cmd->execute } 'App::Sqitch::X',
+ 'Should get an error for un-overridden execute() method';
+is $@->ident, 'DEV', 'Un-overidden execute() exception ident should be "DEV"';
+is $@->message, "The execute() method has not been overridden in $CLASS\::whu",
+ 'The unoverridden execute() error message should be correct';
+
+##############################################################################
+# Test options parsing.
+can_ok $CLASS, 'options', '_parse_opts';
+ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object again";
+is_deeply $cmd->_parse_opts, {}, 'Base _parse_opts should return an empty hash';
+
+ok $cmd = App::Sqitch::Command::whu->new({sqitch => $sqitch}),
+ 'Create a subclass command object again';
+is_deeply $cmd->_parse_opts, {}, 'Subclass should return an empty hash for no args';
+
+is_deeply $cmd->_parse_opts([1]), {}, 'Subclass should use options spec';
+my $args = [qw(
+ --foo
+ --h
+ --no-icky-foo
+ --feathers down
+ whatever
+)];
+is_deeply $cmd->_parse_opts($args), {
+ 'foo' => 1,
+ 'hi_there' => 1,
+ 'icky_foo' => 0,
+ 'feathers' => 'down',
+}, 'Subclass should parse options spec';
+is_deeply $args, ['whatever'], 'Args array should be cleared of options';
+
+PARSEOPTSERR: {
+ # Make sure that invalid options trigger an error.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; });
+ my @warn; local $SIG{__WARN__} = sub { @warn = @_ };
+ $cmd->_parse_opts(['--dont-do-this']);
+ is_deeply \@warn, ["Unknown option: dont-do-this\n"],
+ 'Should get warning for unknown option';
+ is_deeply \@args, [$cmd], 'Should call _pod2usage on options parse failure';
+
+ # Try it with a command with no options.
+ @args = @warn = ();
+ isa_ok $cmd = App::Sqitch::Command->load({
+ command => 'good',
+ sqitch => $sqitch,
+ config => $config,
+ }), 'App::Sqitch::Command::good', 'Good command object';
+ $cmd->_parse_opts(['--dont-do-this']);
+ is_deeply \@warn, ["Unknown option: dont-do-this\n"],
+ 'Should get warning for unknown option when there are no options';
+ is_deeply \@args, [$cmd], 'Should call _pod2usage on no options parse failure';
+}
+
+##############################################################################
+# Test target_params.
+is_deeply [$cmd->target_params], [sqitch => $sqitch],
+ 'Should get sqitch param from target_params';
+
+##############################################################################
+# Test argument parsing.
+ARGS: {
+ my $config = TestConfig->from(local => file qw(t local.conf) );
+ $config->update(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t plans multi.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify
+ );
+ ok $sqitch = App::Sqitch->new(config => $config),
+ 'Load Sqitch with config and plan';
+
+ ok my $cmd = $CLASS->load({
+ sqitch => $sqitch,
+ config => $config,
+ command => 'whu',
+ }), 'Load cmd with config and plan';
+ my $parsem = sub {
+ my @ret = $cmd->parse_args(@_);
+ # Targets are always second to last.
+ $ret[-2] = [ map { $_->name } @{ $ret[-2] } ];
+ return \@ret;
+ };
+
+ my $msg = sub {
+ __nx(
+ 'Unknown argument "{arg}"',
+ 'Unknown arguments: {arg}',
+ scalar @_,
+ arg => join ', ', @_
+ )
+ };
+
+ is_deeply $parsem->(), [['devdb'], []],
+ 'Parsing no args should return default target';
+ throws_ok { $parsem->( args => ['foo'] ) } 'App::Sqitch::X',
+ 'Single unknown arg raise an error';
+ is $@->ident, 'whu', 'Unknown error ident should be "whu"';
+ is $@->message, $msg->('foo'), 'Unknown error message should be correct';
+ is_deeply $parsem->( args => ['hey'] ), [['devdb'], ['hey']],
+ 'Single change should be recognized as change';
+ is_deeply $parsem->( args => ['devdb'] ), [['devdb'], []],
+ 'Single target should be recognized as target';
+ is_deeply $parsem->(args => ['db:pg:']), [['db:pg:'], []],
+ 'URI target should be recognized as target, too';
+ is_deeply $parsem->(args => ['devdb', 'hey']), [['devdb'], ['hey']],
+ 'Target and change should be recognized';
+ is_deeply $parsem->(args => ['hey', 'devdb']), [['devdb'], ['hey']],
+ 'Change and target should be recognized';
+ is_deeply $parsem->(args => ['mydb', 'users']), [['mydb'], ['users']],
+ 'Alternate Target and change should be recognized';
+ is_deeply $parsem->(args => ['hey', 'mydb']), [['mydb'], ['hey']],
+ 'Change and alternate target should be recognized';
+ is_deeply $parsem->(args => ['hey', 'devdb', 'foo'], names => [undef]),
+ ['foo', ['devdb'], ['hey']],
+ 'Change, target, and unknown name should be recognized';
+ is_deeply $parsem->(args => ['hey', 'devdb', 'foo', 'hey-there'], names => [0]),
+ ['foo', ['devdb'], ['hey', 'hey-there']],
+ 'Multiple changes, target, and unknown name should be recognized';
+ is_deeply $parsem->(args => ['yuck', 'hey', 'devdb', 'foo'], names => [0, 0]),
+ ['yuck', 'foo', ['devdb'], ['hey']],
+ 'Multiple names should be recognized';
+ throws_ok {
+ $parsem->(args => ['yuck', 'hey', 'devdb'], names => ['hi']);
+ } 'App::Sqitch::X', 'Should get an error with name and unknown';
+ is $@->ident, 'whu', 'Unknown error ident should be "whu"';
+ is $@->message, $msg->('yuck'), 'Unknown error message should be correct';
+ throws_ok {
+ $parsem->(args => ['yuck', 'hey', 'devdb', 'foo'], names => ['hi']);
+ } 'App::Sqitch::X', 'Should get an error with name and two unknowns';
+ is $@->ident, 'whu', 'Two unknowns error ident should be "whu"';
+ is $@->message, $msg->('yuck', 'foo'),
+ 'Two unknowns error message should be correct';
+
+ # Make sure changes are found in previously-passed target.
+ $config->update('core.top_dir' => dir(qw(t sql))->stringify);
+ ok $sqitch = App::Sqitch->new(config => $config),
+ 'Load Sqitch with config';
+ ok $cmd = $CLASS->load({
+ sqitch => $sqitch,
+ command => 'whu',
+ config => $config,
+ }), 'Load cmd with config';
+ is_deeply $parsem->(args => ['mydb', 'add_user']),
+ [['mydb'], ['add_user']],
+ 'Change following target should be recognized from target plan';
+
+ # Now pass a target.
+ is_deeply $parsem->(target => 'devdb'), [['devdb'], []],
+ 'Passed target should always be returned';
+ is_deeply $parsem->(target => 'devdb', args => ['mydb']),
+ [['devdb', 'mydb'], []],
+ 'Passed and specified targets should always be returned';
+ throws_ok {
+ $parsem->(target => 'devdb', args => ['users'])
+ } 'App::Sqitch::X', 'Change unknown to passed target should error';
+ is $@->ident, 'whu', 'Change unknown error ident should be "whu"';
+ is $@->message, $msg->('users'),
+ 'Change unknown error message should be correct';
+
+ $config->update('core.plan_file' => undef);
+ is_deeply $parsem->(args => ['sqlite', 'widgets', '@beta']),
+ [['devdb'], ['widgets', '@beta']],
+ 'Should get known changes from default target (t/sql/sqitch.plan)';
+ throws_ok {
+ $parsem->(args => ['sqlite', 'widgets', 'mydb', 'foo', '@beta']);
+ } 'App::Sqitch::X', 'Change seen after target should error if not in that target';
+ is $@->ident, 'whu', 'Change after target error ident should be "whu"';
+ is $@->message, $msg->('foo', '@beta'),
+ 'Change after target error message should be correct';
+
+ # Make sure a plan file name is recognized as pointing to a target.
+ is_deeply $parsem->(args => [file(qw(t plans dependencies.plan))->stringify]),
+ [['mydb'], []], 'Should resolve plan file to a target';
+
+ # Should work for default plan file, too.
+ is_deeply $parsem->(args => [file(qw(t sql sqitch.plan))->stringify]),
+ [['devdb'], []], 'SHould resolve default plan file to target';
+
+ # Should also recognize an engine argument.
+ is_deeply $parsem->(args => ['pg']), [['mydb'], []],
+ 'Should resolve engine "pg" file to its target';
+
+ is_deeply $parsem->(args => ['sqlite']), [['devdb'], []],
+ 'Should resolve engine "sqlite" file to its target';
+
+ # Try a bad target.
+ throws_ok {
+ $parsem->(args => [target => 'db:']);
+ } 'App::Sqitch::X', 'Bad target should trigger error';
+ is $@->ident, 'target', 'Bad target error ident should be "target"';
+ is $@->message, __x(
+ 'No engine specified by URI {uri}; URI must start with "db:$engine:"',
+ uri => 'db:',
+ ), 'Should have bad target error message';
+
+ # Make sure we don't get an error when the default target has no plan file.
+ NOPLAN: {
+ my $mock_target = Test::MockModule->new('App::Sqitch::Target');
+ $mock_target->mock(plan_file => file 'no-such-file.txt');
+ is_deeply $parsem->( args => ['devdb'] ), [['devdb'], []],
+ 'Should recognize target when default target has no plan file';
+ }
+
+ # Make sure we get an error when no engine is specified.
+ NOENGINE: {
+ my $config = TestConfig->new(
+ 'core.plan_file' => file(qw(t plans multi.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ );
+ ok $sqitch = App::Sqitch->new(config => $config),
+ 'Load Sqitch without engine';
+
+ ok $cmd = $CLASS->load({
+ sqitch => $sqitch,
+ config => $config,
+ command => 'whu',
+ }), 'Load cmd without engine';
+ throws_ok { $parsem->() } 'App::Sqitch::X',
+ 'Should have error for no engine or target';
+ is $@->ident, 'target', 'Should have target ident';
+ is $@->message, __(
+ 'No project configuration found. Run the "init" command to initialize a project',
+ ), 'Should have message about no config';
+
+ # But it should be okay if we pass an engine or valid target.
+ is_deeply $parsem->(args => ['pg']),
+ [['db:pg:'], []],
+ 'Engine arg should override core target error';
+ is_deeply $parsem->(args => ['db:sqlite:foo']),
+ [['db:sqlite:foo'], []],
+ 'Target arg should override core target error';
+ }
+}
+
+##############################################################################
+# Test _pod2usage().
+POD2USAGE: {
+ my $mock = Test::MockModule->new('Pod::Usage');
+ my %args;
+ $mock->mock(pod2usage => sub { %args = @_} );
+ $cmd = $CLASS->new({ sqitch => $sqitch });
+ ok $cmd->_pod2usage, 'Call _pod2usage on base object';
+ is_deeply \%args, {
+ '-verbose' => 99,
+ '-sections' => '(?i:(Usage|Synopsis|Options))',
+ '-exitval' => 2,
+ '-input' => Pod::Find::pod_where({'-inc' => 1}, 'sqitch'),
+ }, 'Default params should be passed to Pod::Usage';
+
+ $cmd = App::Sqitch::Command::whu->new({ sqitch => $sqitch });
+ ok $cmd->_pod2usage, 'Call _pod2usage on "whu" command object';
+ is_deeply \%args, {
+ '-verbose' => 99,
+ '-sections' => '(?i:(Usage|Synopsis|Options))',
+ '-exitval' => 2,
+ '-input' => Pod::Find::pod_where({'-inc' => 1}, 'sqitch'),
+ }, 'Default params should be passed to Pod::Usage';
+
+ isa_ok $cmd = App::Sqitch::Command->load({
+ command => 'config',
+ sqitch => $sqitch,
+ config => $config,
+ }), 'App::Sqitch::Command::config', 'Config command object';
+ ok $cmd->_pod2usage, 'Call _pod2usage on "config" command object';
+ is_deeply \%args, {
+ '-verbose' => 99,
+ '-sections' => '(?i:(Usage|Synopsis|Options))',
+ '-exitval' => 2,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch-config'),
+ }, 'Should find sqitch-config docs to pass to Pod::Usage';
+
+ isa_ok $cmd = App::Sqitch::Command->load({
+ command => 'good',
+ sqitch => $sqitch,
+ config => $config,
+ }), 'App::Sqitch::Command::good', 'Good command object';
+ ok $cmd->_pod2usage, 'Call _pod2usage on "good" command object';
+ is_deeply \%args, {
+ '-verbose' => 99,
+ '-sections' => '(?i:(Usage|Synopsis|Options))',
+ '-exitval' => 2,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch'),
+ }, 'Should find App::Sqitch::Command::good docs to pass to Pod::Usage';
+
+ # Test usage(), too.
+ can_ok $cmd, 'usage';
+ $cmd->usage('Hello ', 'gorgeous');
+ is_deeply \%args, {
+ '-verbose' => 99,
+ '-sections' => '(?i:(Usage|Synopsis|Options))',
+ '-exitval' => 2,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch'),
+ '-message' => 'Hello gorgeous',
+ }, 'Should find App::Sqitch::Command::good docs to pass to Pod::Usage';
+}
+
+##############################################################################
+# Test verbosity.
+can_ok $CLASS, 'verbosity';
+is $cmd->verbosity, $sqitch->verbosity, 'Verbosity should be from sqitch';
+$sqitch->{verbosity} = 3;
+is $cmd->verbosity, $sqitch->verbosity, 'Verbosity should change with sqitch';
+
+##############################################################################
+# Test message levels. Start with trace.
+$sqitch->{verbosity} = 3;
+is capture_stdout { $cmd->trace('This ', "that\n", 'and the other') },
+ "trace: This that\ntrace: and the other\n",
+ 'trace should work';
+$sqitch->{verbosity} = 2;
+is capture_stdout { $cmd->trace('This ', "that\n", 'and the other') },
+ '', 'Should get no trace output for verbosity 2';
+
+# Trace literal.
+$sqitch->{verbosity} = 3;
+is capture_stdout { $cmd->trace_literal('This ', "that\n", 'and the other') },
+ "trace: This that\ntrace: and the other",
+ 'trace_literal should work';
+$sqitch->{verbosity} = 2;
+is capture_stdout { $cmd->trace_literal('This ', "that\n", 'and the other') },
+ '', 'Should get no trace_literal output for verbosity 2';
+
+# Debug.
+$sqitch->{verbosity} = 2;
+is capture_stdout { $cmd->debug('This ', "that\n", 'and the other') },
+ "debug: This that\ndebug: and the other\n",
+ 'debug should work';
+$sqitch->{verbosity} = 1;
+is capture_stdout { $cmd->debug('This ', "that\n", 'and the other') },
+ '', 'Should get no debug output for verbosity 1';
+
+# Debug literal.
+$sqitch->{verbosity} = 2;
+is capture_stdout { $cmd->debug_literal('This ', "that\n", 'and the other') },
+ "debug: This that\ndebug: and the other",
+ 'debug_literal should work';
+$sqitch->{verbosity} = 1;
+is capture_stdout { $cmd->debug_literal('This ', "that\n", 'and the other') },
+ '', 'Should get no debug_literal output for verbosity 1';
+
+# Info.
+$sqitch->{verbosity} = 1;
+is capture_stdout { $cmd->info('This ', "that\n", 'and the other') },
+ "This that\nand the other\n",
+ 'info should work';
+$sqitch->{verbosity} = 0;
+is capture_stdout { $cmd->info('This ', "that\n", 'and the other') },
+ '', 'Should get no info output for verbosity 0';
+
+# Info literal.
+$sqitch->{verbosity} = 1;
+is capture_stdout { $cmd->info_literal('This ', "that\n", 'and the other') },
+ "This that\nand the other",
+ 'info_literal should work';
+$sqitch->{verbosity} = 0;
+is capture_stdout { $cmd->info_literal('This ', "that\n", 'and the other') },
+ '', 'Should get no info_literal output for verbosity 0';
+
+# Comment.
+$sqitch->{verbosity} = 1;
+is capture_stdout { $cmd->comment('This ', "that\n", 'and the other') },
+ "# This that\n# and the other\n",
+ 'comment should work';
+$sqitch->{verbosity} = 0;
+is capture_stdout { $sqitch->comment('This ', "that\n", 'and the other') },
+ "# This that\n# and the other\n",
+ 'comment should work with verbosity 0';
+
+# Comment literal.
+$sqitch->{verbosity} = 1;
+is capture_stdout { $cmd->comment_literal('This ', "that\n", 'and the other') },
+ "# This that\n# and the other",
+ 'comment_literal should work';
+$sqitch->{verbosity} = 0;
+is capture_stdout { $sqitch->comment_literal('This ', "that\n", 'and the other') },
+ "# This that\n# and the other",
+ 'comment_literal should work with verbosity 0';
+
+# Emit.
+is capture_stdout { $cmd->emit('This ', "that\n", 'and the other') },
+ "This that\nand the other\n",
+ 'emit should work';
+$sqitch->{verbosity} = 0;
+is capture_stdout { $cmd->emit('This ', "that\n", 'and the other') },
+ "This that\nand the other\n",
+ 'emit should work even with verbosity 0';
+
+# Emit literal.
+is capture_stdout { $cmd->emit_literal('This ', "that\n", 'and the other') },
+ "This that\nand the other",
+ 'emit_literal should work';
+$sqitch->{verbosity} = 0;
+is capture_stdout { $cmd->emit_literal('This ', "that\n", 'and the other') },
+ "This that\nand the other",
+ 'emit_literal should work even with verbosity 0';
+
+# Warn.
+is capture_stderr { $cmd->warn('This ', "that\n", 'and the other') },
+ "warning: This that\nwarning: and the other\n",
+ 'warn should work';
+
+# Warn literal.
+is capture_stderr { $cmd->warn_literal('This ', "that\n", 'and the other') },
+ "warning: This that\nwarning: and the other",
+ 'warn_literal should work';
+
+# Usage.
+$catch_exit = 1;
+like capture_stderr {
+ throws_ok { $cmd->usage('Invalid whozit') } qr/EXITED: 2/
+}, qr/Invalid whozit/, 'usage should work';
+
+like capture_stderr {
+ throws_ok { $cmd->usage('Invalid whozit') } qr/EXITED: 2/
+}, qr/\Qsqitch <command> [options] [command-options] [args]/,
+ 'usage should prefer sqitch-$command-usage';
diff --git a/t/config.t b/t/config.t
new file mode 100644
index 00000000..8c45166e
--- /dev/null
+++ b/t/config.t
@@ -0,0 +1,1122 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use Test::More tests => 346;
+#use Test::More 'no_plan';
+use File::Spec;
+use Test::MockModule;
+use Test::Exception;
+use Test::NoWarnings;
+use Test::Warn;
+use Path::Class;
+use File::Path qw(remove_tree);
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+BEGIN {
+ $CLASS = 'App::Sqitch::Command::config';
+ use_ok $CLASS or die;
+}
+
+my $config = TestConfig->new;
+ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object';
+isa_ok my $cmd = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'config',
+ config => $config,
+}), 'App::Sqitch::Command::config', 'Config command';
+
+isa_ok $cmd, 'App::Sqitch::Command', 'Config command';
+can_ok $cmd, qw(file action context get get_all get_regex set add unset unset_all list edit);
+
+is_deeply [$cmd->options], [qw(
+ file|config-file|f=s
+ local
+ user|global
+ system
+ int
+ bool
+ bool-or-int
+ num
+ get
+ get-all
+ get-regex|get-regexp
+ add
+ replace-all
+ unset
+ unset-all
+ rename-section
+ remove-section
+ list|l
+ edit|e
+)], 'Options should be configured';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+##############################################################################
+# Test configure errors.
+my $mock = Test::MockModule->new('App::Sqitch::Command::config');
+my @usage;
+$mock->mock(usage => sub { shift; @usage = @_; die 'USAGE' });
+
+# Test for multiple config file specifications.
+throws_ok { $CLASS->configure( $sqitch->config, {
+ user => 1,
+ system => 1,
+}) } qr/USAGE/, 'Construct with user and system';
+is_deeply \@usage, ['Only one config file at a time.'],
+ 'Should get error for multiple config files';
+
+throws_ok { $CLASS->configure( $sqitch->config, {
+ user => 1,
+ local => 1,
+}) } qr/USAGE/, 'Construct with user and local';
+is_deeply \@usage, ['Only one config file at a time.'],
+ 'Should get error for multiple config files';
+
+throws_ok { $CLASS->configure( $sqitch->config, {
+ file => 't/sqitch.ini',
+ system => 1,
+})} qr/USAGE/, 'Construct with file and system';
+is_deeply \@usage, ['Only one config file at a time.'],
+ 'Should get another error for multiple config files';
+
+throws_ok { $CLASS->configure( $sqitch->config, {
+ file => 't/sqitch.ini',
+ user => 1,
+})} qr/USAGE/, 'Construct with file and user';
+is_deeply \@usage, ['Only one config file at a time.'],
+ 'Should get a third error for multiple config files';
+
+throws_ok { $CLASS->configure( $sqitch->config, {
+ file => 't/sqitch.ini',
+ user => 1,
+ system => 1,
+})} qr/USAGE/, 'Construct with file, system, and user';
+is_deeply \@usage, ['Only one config file at a time.'],
+ 'Should get one last error for multiple config files';
+
+# Test for multiple type specifications.
+throws_ok { $CLASS->configure( $sqitch->config, {
+ bool => 1,
+ num => 1,
+}) } qr/USAGE/, 'Construct with bool and num';
+is_deeply \@usage, ['Only one type at a time.'],
+ 'Should get error for multiple types';
+
+throws_ok { $CLASS->configure( $sqitch->config, {
+ sqitch => $sqitch,
+ int => 1,
+ num => 1,
+})} qr/USAGE/, 'Construct with int and num';
+is_deeply \@usage, ['Only one type at a time.'],
+ 'Should get another error for multiple types';
+
+throws_ok { $CLASS->configure( $sqitch->config, {
+ int => 1,
+ bool => 1,
+})} qr/USAGE/, 'Construct with int and bool';
+is_deeply \@usage, ['Only one type at a time.'],
+ 'Should get a third error for multiple types';
+
+throws_ok { $CLASS->configure( $sqitch->config, {
+ int => 1,
+ bool => 1,
+ num => 1,
+})} qr/USAGE/, 'Construct with int, num, and bool';
+is_deeply \@usage, ['Only one type at a time.'],
+ 'Should get one last error for multiple types';
+
+# Test for multiple action specifications.
+for my $spec (
+ [qw(get unset)],
+ [qw(get unset edit)],
+ [qw(get unset edit list)],
+ [qw(unset edit)],
+ [qw(unset edit list)],
+ [qw(edit list)],
+ [qw(edit add list)],
+ [qw(edit add list get_all)],
+ [qw(edit add list get_regex)],
+ [qw(edit add list unset_all)],
+ [qw(edit add list get_all unset_all)],
+ [qw(edit list remove_section)],
+ [qw(edit list remove_section rename_section)],
+) {
+ throws_ok { $CLASS->configure( $sqitch->config, {
+ map { $_ => 1 } @{ $spec }
+ })} qr/USAGE/, 'Construct with ' . join ' & ' => @{ $spec };
+ is_deeply \@usage, ['Only one action at a time.'],
+ 'Should get error for multiple actions';
+}
+
+##############################################################################
+# Test context.
+is $cmd->file, $sqitch->config->dir_file,
+ 'Default context should be local context';
+is $cmd->action, undef, 'Default action should be undef';
+is $cmd->context, undef, 'Default context should be undef';
+
+# Test local file name.
+is_deeply $CLASS->configure( $sqitch->config, {
+ local => 1,
+}), {
+ context => 'local',
+}, 'Local context should be local';
+
+# Test user file name.
+is_deeply $CLASS->configure( $sqitch->config, {
+ user => 1,
+}), {
+ context => 'user',
+}, 'User context should be user';
+
+# Test system file name.
+is_deeply $CLASS->configure( $sqitch->config, {
+ system => 1,
+}), {
+ context => 'system',
+}, 'System context should be system';
+
+##############################################################################
+# Test execute().
+my @fail;
+$mock->mock(fail => sub { shift; @fail = @_; die "FAIL @_" });
+my @set;
+$mock->mock(set => sub { shift; @set = @_; return 1 });
+my @get;
+$mock->mock(get => sub { shift; @get = @_; return 1 });
+my @get_all;
+$mock->mock(get_all => sub { shift; @get_all = @_; return 1 });
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'system',
+}), 'Create config set command';
+
+ok $cmd->execute(qw(foo bar)), 'Execute the set command';
+is_deeply \@set, [qw(foo bar)], 'The set method should have been called';
+ok $cmd->execute(qw(foo)), 'Execute the get command';
+is_deeply \@get, [qw(foo)], 'The get method should have been called';
+
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_all',
+}), 'Create config get_all command';
+$cmd->execute('boy.howdy');
+is_deeply \@get_all, ['boy.howdy'],
+ 'An action with a dash should have triggered a method with an underscore';
+$mock->unmock(qw(set get get_all));
+
+##############################################################################
+# Test get().
+chdir 't';
+$config = TestConfig->from(local => 'sqitch.conf', user => 'user.conf');
+$sqitch = App::Sqitch->new(config => $config);
+my @emit;
+$mock->mock(emit => sub { shift; push @emit => [@_] });
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get',
+}), 'Create config get command';
+
+ok $cmd->execute('core.engine'), 'Get core.engine';
+is_deeply \@emit, [['pg']], 'Should have emitted the merged core.engine';
+@emit = ();
+
+ok $cmd->execute('engine.pg.registry'), 'Get engine.pg.registry';
+is_deeply \@emit, [['meta']], 'Should have emitted the merged engine.pg.registry';
+@emit = ();
+
+ok $cmd->execute('engine.pg.client'), 'Get engine.pg.client';
+is_deeply \@emit, [['/usr/local/pgsql/bin/psql']],
+ 'Should have emitted the merged engine.pg.client';
+@emit = ();
+
+# Make sure the key is required.
+throws_ok { $cmd->get } qr/USAGE/, 'Should get usage for missing get key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing get key should trigger a usage message';
+throws_ok { $cmd->get('') } qr/USAGE/, 'Should get usage for invalid get key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the invalid get key should trigger a usage message';
+
+# Make sure int data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get',
+ type => 'int',
+}), 'Create config get int command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as int';
+is_deeply \@emit, [[2]],
+ 'Should have emitted the revert count';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as int';
+is_deeply \@emit, [[1]],
+ 'Should have emitted the revert revision as an int';
+@emit = ();
+
+throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X',
+ 'Get bundle.tags_only as an int should fail';
+is $@->ident, 'config', 'Int cast exception ident should be "config"';
+
+# Make sure num data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get',
+ type => 'num',
+}), 'Create config get num command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as num';
+is_deeply \@emit, [[2]],
+ 'Should have emitted the revert count';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as num';
+is_deeply \@emit, [[1.1]],
+ 'Should have emitted the revert revision as an num';
+@emit = ();
+
+throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X',
+ 'Get bundle.tags_only as an num should fail';
+is $@->ident, 'config', 'Num cast exception ident should be "config"';
+
+# Make sure bool data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get',
+ type => 'bool',
+}), 'Create config get bool command';
+
+throws_ok { $cmd->execute('revert.count') } 'App::Sqitch::X',
+ 'Should get failure for invalid bool int';
+is $@->ident, 'config', 'Bool int cast exception ident should be "config"';
+throws_ok { $cmd->execute('revert.revision') } 'App::Sqitch::X',
+ 'Should get failure for invalid bool num';
+is $@->ident, 'config', 'Bool num cast exception ident should be "config"';
+
+ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool';
+is_deeply \@emit, [['true']],
+ 'Should have emitted bundle.tags_only as a bool';
+@emit = ();
+
+# Make sure bool-or-int data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get',
+ type => 'bool-or-int',
+}), 'Create config get bool-or-int command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as bool-or-int';
+is_deeply \@emit, [[2]],
+ 'Should have emitted the revert count as an int';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as bool-or-int';
+is_deeply \@emit, [[1]],
+ 'Should have emitted the revert revision as an int';
+@emit = ();
+
+ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool-or-int';
+is_deeply \@emit, [['true']],
+ 'Should have emitted bundle.tags_only as a bool';
+@emit = ();
+
+chdir File::Spec->updir;
+
+CONTEXT: {
+ my $config = TestConfig->from(system => file qw(t sqitch.conf));
+ $sqitch = App::Sqitch->new(config => $config);
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'system',
+ action => 'get',
+ }), 'Create system config get command';
+ ok $cmd->execute('core.engine'), 'Get system core.engine';
+ is_deeply \@emit, [['pg']], 'Should have emitted the system core.engine';
+ @emit = ();
+
+ ok $cmd->execute('engine.pg.client'), 'Get system engine.pg.client';
+ is_deeply \@emit, [['/usr/local/pgsql/bin/psql']],
+ 'Should have emitted the system engine.pg.client';
+ @emit = @fail = ();
+
+ throws_ok { $cmd->execute('engine.pg.host') } 'App::Sqitch::X',
+ 'Attempt to get engine.pg.host should fail';
+ is $@->ident, 'config', 'Error ident should be "config"';
+ is $@->message, '', 'Error Message should be empty';
+ is $@->exitval, 1, 'Error exitval should be 1';
+ is_deeply \@emit, [], 'Nothing should have been emitted';
+
+ $config = TestConfig->from(
+ system => file(qw(t sqitch.conf)),
+ user => file(qw(t user.conf)),
+ );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'user',
+ action => 'get',
+ }), 'Create user config get command';
+ @emit = ();
+
+ ok $cmd->execute('engine.pg.registry'), 'Get user engine.pg.registry';
+ is_deeply \@emit, [['meta']], 'Should have emitted the user engine.pg.registry';
+ @emit = ();
+
+ ok $cmd->execute('engine.pg.client'), 'Get user engine.pg.client';
+ is_deeply \@emit, [['/opt/local/pgsql/bin/psql']],
+ 'Should have emitted the user engine.pg.client';
+ @emit = ();
+
+ $config = TestConfig->from(
+ system => file(qw(t sqitch.conf)),
+ user => file(qw(t user.conf)),
+ local => file(qw(t local.conf)),
+ );
+ $sqitch->config->load;
+ $sqitch = App::Sqitch->new(config => $config);
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'local',
+ action => 'get',
+ }), 'Create local config get command';
+ @emit = ();
+
+ ok $cmd->execute('engine.pg.target'), 'Get local engine.pg.target';
+ is_deeply \@emit, [['mydb']], 'Should have emitted the local engine.pg.target';
+ @emit = ();
+
+ ok $cmd->execute('core.engine'), 'Get local core.engine';
+ is_deeply \@emit, [['pg']], 'Should have emitted the local core.engine';
+ @emit = ();
+}
+
+CONTEXT: {
+ # What happens when there is no config file?
+ my $config = TestConfig->new;
+ $sqitch = App::Sqitch->new(config => $config);
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'system',
+ action => 'get',
+ }), 'Create another system config get command';
+ ok !-f $cmd->file, 'There should be no system config file';
+ throws_ok { $cmd->execute('core.engine') } 'App::Sqitch::X',
+ 'Should fail when no system config file';
+ is $@->ident, 'config', 'Error ident should be "config"';
+ is $@->message, '', 'Error Message should be empty';
+ is $@->exitval, 1, 'Error exitval should be 1';
+
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'user',
+ action => 'get',
+ }), 'Create another user config get command';
+ ok !-f $cmd->file, 'There should be no user config file';
+ throws_ok { $cmd->execute('core.engine') } 'App::Sqitch::X',
+ 'Should fail when no user config file';
+ is $@->ident, 'config', 'Error ident should be "config"';
+ is $@->message, '', 'Error Message should be empty';
+ is $@->exitval, 1, 'Error exitval should be 1';
+
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'local',
+ action => 'get',
+ }), 'Create another local config get command';
+ ok !-f $cmd->file, 'There should be no local config file';
+ throws_ok { $cmd->execute('core.engine') } 'App::Sqitch::X',
+ 'Should fail when no local config file';
+ is $@->ident, 'config', 'Error ident should be "config"';
+ is $@->message, '', 'Error Message should be empty';
+ is $@->exitval, 1, 'Error exitval should be 1';
+}
+
+##############################################################################
+# Test list().
+$config = TestConfig->from(
+ system => file(qw(t sqitch.conf)),
+ user => file(qw(t user.conf)),
+ local => file(qw(t local.conf)),
+);
+$sqitch = App::Sqitch->new(config => $config);
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'list',
+}), 'Create config list command';
+ok $cmd->execute, 'Execute the list action';
+is_deeply \@emit, [[
+ 'bundle.dest_dir=_build/sql
+bundle.from=gamma
+bundle.tags_only=true
+core.engine=pg
+core.extension=ddl
+core.pager=less -r
+core.top_dir=migrations
+core.uri=https://github.com/sqitchers/sqitch/
+engine.firebird.client=/opt/firebird/bin/isql
+engine.firebird.registry=meta
+engine.mysql.client=/opt/local/mysql/bin/mysql
+engine.mysql.registry=meta
+engine.mysql.variables.prefix=foo_
+engine.pg.client=/opt/local/pgsql/bin/psql
+engine.pg.registry=meta
+engine.pg.target=mydb
+engine.sqlite.client=/opt/local/bin/sqlite3
+engine.sqlite.registry=meta
+engine.sqlite.target=devdb
+foo.BAR.baz=hello
+guess.Yes.No.calico=false
+guess.Yes.No.red=true
+revert.count=2
+revert.revision=1.1
+revert.to=gamma
+target.devdb.uri=db:sqlite:
+target.mydb.plan_file=t/plans/dependencies.plan
+target.mydb.uri=db:pg:mydb
+user.email=michael@example.com
+user.name=Michael Stonebraker
+'
+]], 'Should have emitted the merged config';
+@emit = ();
+
+CONTEXT: {
+ $config = TestConfig->from(system => file qw(t sqitch.conf) );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'system',
+ action => 'list',
+ }), 'Create system config list command';
+ ok $cmd->execute, 'List the system config';
+ is_deeply \@emit, [[
+ 'bundle.dest_dir=_build/sql
+bundle.from=gamma
+bundle.tags_only=true
+core.engine=pg
+core.extension=ddl
+core.pager=less -r
+core.top_dir=migrations
+core.uri=https://github.com/sqitchers/sqitch/
+engine.pg.client=/usr/local/pgsql/bin/psql
+foo.BAR.baz=hello
+guess.Yes.No.calico=false
+guess.Yes.No.red=true
+revert.count=2
+revert.revision=1.1
+revert.to=gamma
+'
+ ]], 'Should have emitted the system config list';
+ @emit = ();
+
+ $config = TestConfig->from(
+ system => file(qw(t sqitch.conf)),
+ user => file(qw(t user.conf)),
+ );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'user',
+ action => 'list',
+ }), 'Create user config list command';
+ ok $cmd->execute, 'List the user config';
+ is_deeply \@emit, [[
+ 'engine.firebird.client=/opt/firebird/bin/isql
+engine.firebird.registry=meta
+engine.mysql.client=/opt/local/mysql/bin/mysql
+engine.mysql.registry=meta
+engine.mysql.variables.prefix=foo_
+engine.pg.client=/opt/local/pgsql/bin/psql
+engine.pg.registry=meta
+engine.pg.target=db:pg://postgres@localhost/thingies
+engine.sqlite.client=/opt/local/bin/sqlite3
+engine.sqlite.registry=meta
+engine.sqlite.target=db:sqlite:my.db
+user.email=michael@example.com
+user.name=Michael Stonebraker
+'
+ ]], 'Should only have emitted the user config list';
+ @emit = ();
+
+ $config = TestConfig->from(
+ system => file(qw(t sqitch.conf)),
+ user => file(qw(t user.conf)),
+ local => file(qw(t local.conf)),
+ );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'local',
+ action => 'list',
+ }), 'Create local config list command';
+ ok $cmd->execute, 'List the local config';
+ is_deeply \@emit, [[
+ 'core.engine=pg
+engine.pg.target=mydb
+engine.sqlite.target=devdb
+target.devdb.uri=db:sqlite:
+target.mydb.plan_file=t/plans/dependencies.plan
+target.mydb.uri=db:pg:mydb
+'
+ ]], 'Should only have emitted the local config list';
+ @emit = ();
+}
+
+# What happens when there is no config file?
+$config = TestConfig->from;
+$sqitch = App::Sqitch->new(config => $config);
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'system',
+ action => 'list',
+}), 'Create system config list command with no file';
+ok $cmd->execute, 'List the system config';
+is_deeply \@emit, [], 'Nothing should have been emitted';
+
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ context => 'user',
+ action => 'list',
+}), 'Create user config list command with no file';
+ok $cmd->execute, 'List the user config';
+is_deeply \@emit, [], 'Nothing should have been emitted';
+
+##############################################################################
+# Test set().
+my $file = 'testconfig.conf';
+$mock->mock(file => $file);
+END { unlink $file }
+
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+}), 'Create system config set command';
+ok $cmd->execute('core.foo' => 'bar'), 'Write core.foo';
+is_deeply $config->data_from($cmd->file), {'core.foo' => 'bar' },
+ 'The property should have been written';
+
+# Write another property.
+ok $cmd->execute('core.engine' => 'funky'), 'Write core.engine';
+is_deeply $config->data_from($cmd->file), {'core.foo' => 'bar', 'core.engine' => 'funky' },
+ 'Both settings should be saved';
+
+# Write a sub-propery.
+ok $cmd->execute('engine.pg.user' => 'theory'), 'Write engine.pg.user';
+is_deeply $config->data_from($cmd->file), {
+ 'core.foo' => 'bar',
+ 'core.engine' => 'funky',
+ 'engine.pg.user' => 'theory',
+}, 'Both sections should be saved';
+
+# Make sure the key is required.
+throws_ok { $cmd->set } qr/USAGE/, 'Should set usage for missing set key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing set key should trigger a usage message';
+throws_ok { $cmd->set('') } qr/USAGE/, 'Should set usage for invalid set key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the invalid set key should trigger a usage message';
+
+# Make sure the value is required.
+throws_ok { $cmd->set('foo.bar') } qr/USAGE/, 'Should set usage for missing set value';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing set value should trigger a usage message';
+
+##############################################################################
+# Test add().
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'add',
+}), 'Create system config add command';
+ok $cmd->execute('core.foo' => 'baz'), 'Add to core.foo';
+is_deeply $config->data_from($cmd->file), {
+ 'core.foo' => ['bar', 'baz'],
+ 'core.engine' => 'funky',
+ 'engine.pg.user' => 'theory',
+}, 'The value should have been added to the property';
+
+# Make sure the key is required.
+throws_ok { $cmd->add } qr/USAGE/, 'Should add usage for missing add key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing add key should trigger a usage message';
+throws_ok { $cmd->add('') } qr/USAGE/, 'Should add usage for invalid add key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the invalid add key should trigger a usage message';
+
+# Make sure the value is required.
+throws_ok { $cmd->add('foo.bar') } qr/USAGE/, 'Should add usage for missing add value';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing add value should trigger a usage message';
+
+##############################################################################
+# Test get with regex.
+$config = TestConfig->from(user => $file);
+$sqitch = App::Sqitch->new(config => $config);
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get',
+}), 'Create system config add command';
+ok $cmd->execute('core.engine', 'funk'), 'Get core.engine with regex';
+is_deeply \@emit, [['funky']], 'Should have emitted value';
+@emit = ();
+
+ok $cmd->execute('core.foo', 'z$'), 'Get core.foo with regex';
+is_deeply \@emit, [['baz']], 'Should have emitted value';
+@emit = ();
+
+throws_ok { $cmd->execute('core.foo', 'x$') } 'App::Sqitch::X',
+ 'Attempt to get core.foo with non-matching regex should fail';
+is $@->ident, 'config', 'Error ident should be "config"';
+is $@->message, '', 'Error Message should be empty';
+is $@->exitval, 1, 'Error exitval should be 1';
+is_deeply \@emit, [], 'Nothing should have been emitted';
+
+##############################################################################
+# Test get_all().
+@emit = ();
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_all',
+}), 'Create system config get_all command';
+ok $cmd->execute('core.engine'), 'Call get_all on core.engine';
+is_deeply \@emit, [['funky']], 'The engine should have been emitted';
+@emit = ();
+
+ok $cmd->execute('core.engine', 'funk'), 'Get all core.engine with regex';
+is_deeply \@emit, [['funky']], 'Should have emitted value';
+@emit = ();
+
+ok $cmd->execute('core.foo'), 'Call get_all on core.foo';
+is_deeply \@emit, [["bar\nbaz"]], 'Both foos should have been emitted';
+@emit = ();
+
+ok $cmd->execute('core.foo', '^ba'), 'Call get_all on core.foo with regex';
+is_deeply \@emit, [["bar\nbaz"]], 'Both foos should have been emitted';
+@emit = ();
+
+ok $cmd->execute('core.foo', 'z$'), 'Call get_all on core.foo with limiting regex';
+is_deeply \@emit, [["baz"]], 'Only the one foo should have been emitted';
+@emit = ();
+
+throws_ok { $cmd->execute('core.foo', 'x$') } 'App::Sqitch::X',
+ 'Attempt to get_all core.foo with non-matching regex should fail';
+is $@->ident, 'config', 'Error ident should be "config"';
+is $@->message, '', 'Error Message should be empty';
+is $@->exitval, 1, 'Error exitval should be 1';
+is_deeply \@emit, [], 'Nothing should have been emitted';
+
+# Make sure the key is required.
+throws_ok { $cmd->get_all } qr/USAGE/, 'Should get_all usage for missing get_all key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing get_all key should trigger a usage message';
+throws_ok { $cmd->get_all('') } qr/USAGE/, 'Should get_all usage for invalid get_all key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the invalid get_all key should trigger a usage message';
+
+# Make sure int data type works.
+$config = TestConfig->from(local => file qw(t sqitch.conf));
+$sqitch = App::Sqitch->new(config => $config);
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_all',
+ type => 'int',
+}), 'Create config get_all int command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as int';
+is_deeply \@emit, [[2]],
+ 'Should have emitted the revert count';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as int';
+is_deeply \@emit, [[1]],
+ 'Should have emitted the revert revision as an int';
+@emit = ();
+
+throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X',
+ 'Get bundle.tags_only as an int should fail';
+is $@->ident, 'config', 'Int cast exception ident should be "config"';
+
+# Make sure num data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_all',
+ type => 'num',
+}), 'Create config get_all num command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as num';
+is_deeply \@emit, [[2]],
+ 'Should have emitted the revert count';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as num';
+is_deeply \@emit, [[1.1]],
+ 'Should have emitted the revert revision as an num';
+@emit = ();
+
+throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X',
+ 'Get bundle.tags_only as an num should fail';
+is $@->ident, 'config', 'Num cast exception ident should be "config"';
+
+# Make sure bool data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_all',
+ type => 'bool',
+}), 'Create config get_all bool command';
+
+throws_ok { $cmd->execute('revert.count') } 'App::Sqitch::X',
+ 'Should get failure for invalid bool int';
+is $@->ident, 'config', 'Bool int cast exception ident should be "config"';
+throws_ok { $cmd->execute('revert.revision') } 'App::Sqitch::X',
+ 'Should get failure for invalid bool num';
+is $@->ident, 'config', 'Num int cast exception ident should be "config"';
+
+ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool';
+is_deeply \@emit, [['true']], 'Should have emitted bundle.tags_only as a bool';
+@emit = ();
+
+# Make sure bool-or-int data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_all',
+ type => 'bool-or-int',
+}), 'Create config get_all bool-or-int command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as bool-or-int';
+is_deeply \@emit, [[2]],
+ 'Should have emitted the revert count as an int';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as bool-or-int';
+is_deeply \@emit, [[1]],
+ 'Should have emitted the revert revision as an int';
+@emit = ();
+
+ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool-or-int';
+is_deeply \@emit, [['true']], 'Should have emitted bundle.tags_only as a bool';
+@emit = ();
+
+##############################################################################
+# Test get_regex().
+$config = TestConfig->from(local => $file, user => file qw(t sqitch.conf));
+$sqitch = App::Sqitch->new(config => $config);
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_regex',
+}), 'Create system config get_regex command';
+ok $cmd->execute('core\\..+'), 'Call get_regex on core\\..+';
+is_deeply \@emit, [[q{core.engine=funky
+core.extension=ddl
+core.foo=[bar, baz]
+core.pager=less -r
+core.top_dir=migrations
+core.uri=https://github.com/sqitchers/sqitch/}
+]], 'Should match all core options';
+@emit = ();
+
+ok $cmd->execute('engine\\.pg\\..+'), 'Call get_regex on engine\\.pg\\..+';
+is_deeply \@emit, [[q{engine.pg.client=/usr/local/pgsql/bin/psql
+engine.pg.user=theory}
+]], 'Should match all engine.pg options';
+@emit = ();
+
+ok $cmd->execute('engine\\.pg\\..+', 'theory$'),
+ 'Call get_regex on engine\\.pg\\..+ and value regex';
+is_deeply \@emit, [[q{engine.pg.user=theory}
+]], 'Should match all engine.pg options that match';
+@emit = ();
+
+throws_ok { $cmd->execute('engine\\.pg\\..+', 'x$') } 'App::Sqitch::X',
+ 'Attempt to get_regex core.foo with non-matching regex should fail';
+is $@->ident, 'config', 'Error ident should be "config"';
+is $@->message, '', 'Error Message should be empty';
+is $@->exitval, 1, 'Error exitval should be 1';
+is_deeply \@emit, [], 'Nothing should have been emitted';
+
+# Make sure the key is required.
+throws_ok { $cmd->get_regex } qr/USAGE/, 'Should get_regex usage for missing get_regex key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing get_regex key should trigger a usage message';
+throws_ok { $cmd->get_regex('') } qr/USAGE/, 'Should get_regex usage for invalid get_regex key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the invalid get_regex key should trigger a usage message';
+
+# Make sure int data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_regex',
+ type => 'int',
+}), 'Create config get_regex int command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as int';
+is_deeply \@emit, [['revert.count=2']],
+ 'Should have emitted the revert count';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as int';
+is_deeply \@emit, [['revert.revision=1']],
+ 'Should have emitted the revert revision as an int';
+@emit = ();
+
+throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X',
+ 'Get bundle.tags_only as an int should fail';
+is $@->ident, 'config', 'Int cast exception ident should be "config"';
+
+# Make sure num data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_regex',
+ type => 'num',
+}), 'Create config get_regexp num command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as num';
+is_deeply \@emit, [['revert.count=2']],
+ 'Should have emitted the revert count';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as num';
+is_deeply \@emit, [['revert.revision=1.1']],
+ 'Should have emitted the revert revision as an num';
+@emit = ();
+
+throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X',
+ 'Get bundle.tags_only as an num should fail';
+is $@->ident, 'config', 'Num cast exception ident should be "config"';
+
+# Make sure bool data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_regex',
+ type => 'bool',
+}), 'Create config get_regex bool command';
+
+throws_ok { $cmd->execute('revert.count') } 'App::Sqitch::X',
+ 'Should get failure for invalid bool int';
+is $@->ident, 'config', 'Bool int cast exception ident should be "config"';
+throws_ok { $cmd->execute('revert.revision') } 'App::Sqitch::X',
+ 'Should get failure for invalid bool num';
+is $@->ident, 'config', 'Num int cast exception ident should be "config"';
+
+ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool';
+is_deeply \@emit, [['bundle.tags_only=true']],
+ 'Should have emitted bundle.tags_only as a bool';
+@emit = ();
+
+# Make sure int data type works.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'get_regex',
+ type => 'bool-or-int',
+}), 'Create config get_regex bool-or-int command';
+
+ok $cmd->execute('revert.count'), 'Get revert.count as bool-or-int';
+is_deeply \@emit, [['revert.count=2']],
+ 'Should have emitted the revert count as an int';
+@emit = ();
+
+ok $cmd->execute('revert.revision'), 'Get revert.revision as bool-or-int';
+is_deeply \@emit, [['revert.revision=1']],
+ 'Should have emitted the revert revision as an int';
+@emit = ();
+
+ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool-or-int';
+is_deeply \@emit, [['bundle.tags_only=true']],
+ 'Should have emitted bundle.tags_only as a bool';
+@emit = ();
+
+##############################################################################
+# Test unset().
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'unset',
+}), 'Create system config unset command';
+
+ok $cmd->execute('engine.pg.user'), 'Unset engine.pg.user';
+is_deeply $config->data_from($cmd->file), {
+ 'core.foo' => ['bar', 'baz'],
+ 'core.engine' => 'funky',
+}, 'engine.pg.user should be gone';
+ok $cmd->execute('core.engine'), 'Unset core.engine';
+is_deeply $config->data_from($cmd->file), {
+ 'core.foo' => ['bar', 'baz'],
+}, 'core.engine should have been removed';
+
+throws_ok { $cmd->execute('core.foo') } 'App::Sqitch::X',
+ 'Should get failure trying to delete multivalue key';
+is $@->ident, 'config', 'Multiple value exception ident should be "config"';
+is $@->message, __ 'Cannot unset key with multiple values',
+ 'And it should have the proper error message';
+
+ok $cmd->execute('core.foo', 'z$'), 'Unset core.foo with a regex';
+is_deeply $config->data_from($cmd->file), {
+ 'core.foo' => 'bar',
+}, 'The core.foo "baz" value should have been removed';
+
+# Make sure the key is required.
+throws_ok { $cmd->unset } qr/USAGE/, 'Should unset usage for missing unset key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing unset key should trigger a usage message';
+throws_ok { $cmd->unset('') } qr/USAGE/, 'Should unset usage for invalid unset key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the invalid unset key should trigger a usage message';
+
+##############################################################################
+# Test unset_all().
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'unset_all',
+}), 'Create system config unset_all command';
+
+$cmd->add('core.foo', 'baz');
+ok $cmd->execute('core.foo'), 'unset_all core.foo';
+is_deeply $config->data_from($cmd->file), {}, 'core.foo should have been removed';
+
+# Test handling of multiple value.
+$cmd->add('core.foo', 'bar');
+$cmd->add('core.foo', 'baz');
+$cmd->add('core.foo', 'yo');
+
+ok $cmd->execute('core.foo', '^ba'), 'unset_all core.foo with regex';
+is_deeply $config->data_from($cmd->file), {
+ 'core.foo' => 'yo',
+}, 'core.foo should have one value left';
+
+# Make sure the key is required.
+throws_ok { $cmd->unset_all } qr/USAGE/, 'Should unset_all usage for missing unset_all key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the missing unset_all key should trigger a usage message';
+throws_ok { $cmd->unset_all('') } qr/USAGE/, 'Should unset_all usage for invalid unset_all key';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'And the invalid unset_all key should trigger a usage message';
+
+##############################################################################
+# Test replace_all.
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'replace_all',
+}), 'Create system config replace_all command';
+
+$cmd->add('core.bar', 'bar');
+$cmd->add('core.bar', 'baz');
+$cmd->add('core.bar', 'yo');
+
+ok $cmd->execute('core.bar', 'hi'), 'Replace all core.bar';
+is_deeply $config->data_from($cmd->file), {
+ 'core.bar' => 'hi',
+ 'core.foo' => 'yo',
+}, 'core.bar should have all its values with one value';
+
+$cmd->add('core.foo', 'bar');
+$cmd->add('core.foo', 'baz');
+ok $cmd->execute('core.foo', 'ba', '^ba'), 'Replace all core.bar matching /^ba/';
+
+is_deeply $config->data_from($cmd->file), {
+ 'core.bar' => 'hi',
+ 'core.foo' => ['yo', 'ba'],
+}, 'core.foo should have had the matching values replaced';
+
+# Clean up.
+$cmd->unset_all('core.bar');
+$cmd->unset('core.foo', 'ba');
+
+##############################################################################
+# Test rename_section().
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'rename_section',
+}), 'Create system config rename_section command';
+ok $cmd->execute('core', 'funk'), 'Rename "core" to "funk"';
+is_deeply $config->data_from($cmd->file), {
+ 'funk.foo' => 'yo',
+}, 'core.foo should have become funk.foo';
+
+throws_ok { $cmd->execute('foo') } qr/USAGE/, 'Should fail with no new name';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'Message should be in the usage call';
+
+throws_ok { $cmd->execute('', 'bar') } qr/USAGE/, 'Should fail with bad old name';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'Message should be in the usage call';
+
+throws_ok { $cmd->execute('baz', '') } qr/USAGE/, 'Should fail with bad new name';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'Message should be in the usage call';
+
+throws_ok { $cmd->execute('foo', 'bar') } 'App::Sqitch::X',
+ 'Should fail with invalid section';
+is $@->ident, 'config', 'Invalid section exception ident should be "config"';
+is $@->message, __ 'No such section!',
+ 'Invalid section exception message should be set';
+
+##############################################################################
+# Test remove_section().
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'remove_section',
+}), 'Create system config remove_section command';
+ok $cmd->execute('funk'), 'Remove "func" section';
+is_deeply $config->data_from($cmd->file), {},
+ 'The "funk" section should be gone';
+
+throws_ok { $cmd->execute() } qr/USAGE/, 'Should fail with no name';
+is_deeply \@usage, ['Wrong number of arguments.'],
+ 'Message should be in the usage call';
+
+throws_ok { $cmd->execute('bar') } 'App::Sqitch::X',
+ 'Should fail with invalid name';
+is $@->ident, 'config', 'Invalid key name exception ident should be "config"';
+is $@->message, __ 'No such section!', 'And the invalid key message should be set';
+
+##############################################################################
+# Test errors with multiple values.
+
+throws_ok { $cmd->get('core.foo', '.') } 'App::Sqitch::X',
+ 'Should fail fetching multi-value key';
+is $@->ident, 'config', 'Multi-value key exception ident should be "config"';
+is $@->message, __x(
+ 'More then one value for the key "{key}"',
+ key => 'core.foo',
+), 'The multiple value error should be thrown';
+
+$cmd->add('core.foo', 'hi');
+$cmd->add('core.foo', 'bye');
+throws_ok { $cmd->set('core.foo', 'hi') } 'App::Sqitch::X',
+ 'Should fail setting multi-value key';
+is $@->ident, 'config', 'Mult-valkue key exception ident should be "config"';
+is $@->message, __('Cannot overwrite multiple values with a single value'),
+ 'The multi-value key error should be thrown';
+
+##############################################################################
+# Test edit().
+my $shell;
+my $ret = 1;
+$mock->mock(shell => sub { $shell = $_[1]; return $ret });
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ action => 'edit',
+}), 'Create system config edit command';
+ok $cmd->execute, 'Execute the edit comand';
+is $shell, $sqitch->editor . ' ' . $sqitch->quote_shell($cmd->file),
+ 'The editor should have been run';
+
+##############################################################################
+# Make sure we can write to a file in a directory.
+my $path = file qw(t config.tmp test.conf);
+$mock->mock(file => $path);
+END { remove_tree +File::Spec->catdir(qw(t config.tmp)) }
+ok $sqitch = App::Sqitch->new, 'Load a new sqitch object';
+ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+}), 'Create system config set command with subdirectory config file path';
+ok $cmd->execute('my.foo', 'hi'), 'Set "my.foo" in subdirectory config file';
+is_deeply $config->data_from($cmd->file), {'my.foo' => 'hi' },
+ 'The file should have been written';
diff --git a/t/configuration.t b/t/configuration.t
new file mode 100644
index 00000000..9dca488d
--- /dev/null
+++ b/t/configuration.t
@@ -0,0 +1,90 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use Test::More tests => 22;
+#use Test::More 'no_plan';
+use File::Spec;
+use Test::Exception;
+use Test::NoWarnings;
+
+my $CLASS;
+BEGIN {
+ $CLASS = 'App::Sqitch::Config';
+ use_ok $CLASS or die;
+}
+
+# protect against user's environment variables
+delete @ENV{qw( SQITCH_CONFIG SQITCH_USER_CONFIG SQITCH_SYSTEM_CONFIG )};
+
+isa_ok my $config = $CLASS->new, $CLASS, 'New config object';
+is $config->confname, 'sqitch.conf', 'confname should be "sqitch.conf"';
+ok !$config->initialized, 'Should not be initialized';
+
+my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0];
+
+SKIP: {
+ skip 'System dir can be modified at build time', 1
+ if $INC{'App/Sqitch/Config.pm'} =~ /\bblib\b/;
+ is $config->system_dir, File::Spec->catfile(
+ $Config::Config{prefix}, 'etc', 'sqitch'
+ ), 'Default system directory should be correct';
+}
+
+is $config->user_dir, File::Spec->catfile(
+ $hd, '.sqitch'
+), 'Default user directory should be correct';
+
+is $config->global_file, File::Spec->catfile(
+ $config->system_dir, 'sqitch.conf'
+), 'Default global file name should be correct';
+
+my $file = File::Spec->catfile(qw(FOO BAR));
+$ENV{SQITCH_SYSTEM_CONFIG} = $file;
+is $config->global_file, $file,
+ 'Should preferably get SQITCH_SYSTEM_CONFIG file from global_file';
+is $config->system_file, $config->global_file, 'system_file should alias global_file';
+
+is $config->user_file, File::Spec->catfile(
+ $hd, '.sqitch', 'sqitch.conf'
+), 'Default user file name should be correct';
+
+$ENV{SQITCH_USER_CONFIG} = $file,
+is $config->user_file, $file,
+ 'Should preferably get SQITCH_USER_CONFIG file from user_file';
+
+is $config->local_file, 'sqitch.conf',
+ 'Local file should be correct';
+is $config->dir_file, $config->local_file, 'dir_file should alias local_file';
+
+SQITCH_CONFIG: {
+ local $ENV{SQITCH_CONFIG} = 'sqitch.ini';
+ is $config->local_file, 'sqitch.ini', 'local_file should prefer $SQITCH_CONFIG';
+ is $config->dir_file, 'sqitch.ini', 'And so should dir_file';
+}
+
+chdir 't';
+isa_ok $config = $CLASS->new, $CLASS, 'Another config object';
+ok $config->initialized, 'Should be initialized';
+is_deeply $config->get_section(section => 'core'), {
+ engine => "pg",
+ extension => "ddl",
+ top_dir => "migrations",
+ uri => 'https://github.com/sqitchers/sqitch/',
+ pager => "less -r",
+}, 'get_section("core") should work';
+
+is_deeply $config->get_section(section => 'engine.pg'), {
+ client => "/usr/local/pgsql/bin/psql",
+}, 'get_section("engine.pg") should work';
+
+# Make sure it works with irregular casing.
+is_deeply $config->get_section(section => 'foo.BAR'), {
+ baz => 'hello'
+}, 'get_section() whould work with capitalized subsection';
+
+# Should work with multiple subsections and case-preserved keys.
+is_deeply $config->get_section(section => 'guess.Yes.No'), {
+ red => 'true',
+ Calico => 'false',
+}, 'get_section() whould work with mixed case subsections';
diff --git a/t/conn_cmd_role.t b/t/conn_cmd_role.t
new file mode 100644
index 00000000..9924c901
--- /dev/null
+++ b/t/conn_cmd_role.t
@@ -0,0 +1,112 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More;
+use App::Sqitch;
+use lib 't/lib';
+use TestConfig;
+
+my $ROLE;
+
+BEGIN {
+ $ROLE = 'App::Sqitch::Role::ConnectingCommand';
+ use_ok $ROLE or die;
+}
+
+COMMAND: {
+ # Stub out a command.
+ package App::Sqitch::Command::click;
+ use Moo;
+ extends 'App::Sqitch::Command';
+ with $ROLE;
+ $INC{'App/Sqitch/Command/click.pm'} = __FILE__;
+
+ sub options {
+ return qw(
+ foo
+ quack|k=s
+ );
+ }
+}
+
+my $CLASS = 'App::Sqitch::Command::click';
+can_ok $CLASS, 'does';
+ok $CLASS->does($ROLE), "$CLASS does $ROLE";
+
+is_deeply [$CLASS->options], [qw(
+ foo
+ quack|k=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+)], 'Options should include connection options';
+
+##############################################################################
+# Test configure.
+my $opts = {};
+my $config = TestConfig->new;
+my @params;
+is_deeply $CLASS->configure($config, $opts), { _params => \@params },
+ 'Should get no params for no options';
+
+$opts->{db_name} = 'disco';
+push @params => dbname => 'disco';
+is_deeply $CLASS->configure($config, $opts), { _params => \@params },
+ 'Should get dbname for --db-name';
+
+$opts = {
+ db_user => '',
+ db_host => undef,
+ db_port => 0,
+ db_name => '',
+};
+@params = (
+ user => '',
+ host => undef,
+ port => 0,
+ dbname => '',
+);
+
+is_deeply $CLASS->configure($config, $opts), { _params => \@params },
+ 'Should collect existing but false params';
+
+$opts = {
+ db_user => 'theory',
+ db_host => 'justatheory.com',
+ db_port => 9876,
+ db_name => 'funk',
+ registry => 'crickets',
+ client => '/bin/true',
+ quack => 'woof',
+};
+@params = (
+ user => 'theory',
+ host => 'justatheory.com',
+ port => 9876,
+ dbname => 'funk',
+ registry => 'crickets',
+ client => '/bin/true',
+);
+is_deeply $CLASS->configure($config, $opts),
+ { _params => \@params, quack => 'woof' },
+ 'Should collect params';
+
+##############################################################################
+# Test target_params.
+my $sqitch = App::Sqitch->new(config => $config);
+isa_ok my $cmd = $CLASS->new(
+ sqitch => $sqitch,
+ quack => 'beep',
+ _params => \@params,
+), $CLASS;
+
+is_deeply [$cmd->target_params], [sqitch => $sqitch, @params],
+ 'Should get connection params from target_params';
+
+done_testing;
diff --git a/t/core.conf b/t/core.conf
new file mode 100644
index 00000000..822241da
--- /dev/null
+++ b/t/core.conf
@@ -0,0 +1,2 @@
+[core]
+ engine = pg
diff --git a/t/core_target.conf b/t/core_target.conf
new file mode 100644
index 00000000..1ece3a7a
--- /dev/null
+++ b/t/core_target.conf
@@ -0,0 +1,2 @@
+[core]
+ target = db:pg:whatever
diff --git a/t/cx_cmd_role.t b/t/cx_cmd_role.t
new file mode 100644
index 00000000..a5cfeb96
--- /dev/null
+++ b/t/cx_cmd_role.t
@@ -0,0 +1,109 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More;
+use Path::Class;
+use App::Sqitch;
+use lib 't/lib';
+use TestConfig;
+use Test::MockModule;
+use Locale::TextDomain qw(App-Sqitch); # XXX Until deprecation removed below.
+
+my $ROLE;
+
+BEGIN {
+ $ROLE = 'App::Sqitch::Role::ContextCommand';
+ use_ok $ROLE or die;
+}
+
+COMMAND: {
+ # Stub out a command.
+ package App::Sqitch::Command::click;
+ use Moo;
+ extends 'App::Sqitch::Command';
+ with $ROLE;
+ $INC{'App/Sqitch/Command/click.pm'} = __FILE__;
+
+ sub options {
+ return qw(
+ foo
+ quack|k=s
+ );
+ }
+}
+
+my $CLASS = 'App::Sqitch::Command::click';
+can_ok $CLASS, 'does';
+ok $CLASS->does($ROLE), "$CLASS does $ROLE";
+
+is_deeply [$CLASS->options], [qw(
+ foo
+ quack|k=s
+ plan-file|f=s
+ top-dir=s
+)], 'Options should include context options';
+
+
+# Silence warnings.
+my $mock = Test::MockModule->new('App::Sqitch');
+my $warning;
+$mock->mock(warn => sub { $warning = $_[1] });
+
+##############################################################################
+# Test configure.
+my $opts = {};
+my $config = TestConfig->new;
+is_deeply $CLASS->configure($config, $opts), { _cx => [] },
+ 'Should get no params for no options';
+
+$opts = {
+ top_dir => '',
+ plan_file => '0',
+};
+is_deeply $CLASS->configure($config, $opts), { _cx => [] },
+ 'Should get no params for empty options';
+is $warning, undef, 'Should have no warning';
+
+$opts = { top_dir => 't' };
+my @params = ( top_dir => dir 't');
+is_deeply $CLASS->configure($config, $opts), { _cx => \@params },
+ 'Should get top_dir';
+is $warning, __x(
+ " Option --top-dir is deprecated for {command} and other non-configuration commands.\n Use --chdir instead.",
+ command => $CLASS->command,
+), 'Should have --top-dir deprecation warning';
+$warning = undef;
+
+$opts = {
+ top_dir => 'lib',
+ plan_file => 'README.md',
+ quack => 'woof',
+};
+@params = (
+ top_dir => dir('lib'),
+ plan_file => file('README.md'),
+);
+is_deeply $CLASS->configure($config, $opts),
+ { _cx => \@params, quack => 'woof' },
+ 'Should collect params';
+is $warning, __x(
+ " Option --top-dir is deprecated for {command} and other non-configuration commands.\n Use --chdir instead.",
+ command => $CLASS->command,
+), 'Should have --top-dir deprecation warning again';
+
+##############################################################################
+# Test target_params.
+my $sqitch = App::Sqitch->new(config => $config);
+isa_ok my $cmd = $CLASS->new(
+ sqitch => $sqitch,
+ quack => 'beep',
+ _cx => \@params,
+), $CLASS;
+
+is_deeply [$cmd->target_params], [sqitch => $sqitch, @params],
+ 'Should get context params from target_params';
+
+done_testing;
diff --git a/t/datetime.t b/t/datetime.t
new file mode 100644
index 00000000..50bea602
--- /dev/null
+++ b/t/datetime.t
@@ -0,0 +1,95 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 33;
+#use Test::More 'no_plan';
+use Locale::TextDomain qw(App-Sqitch);
+use Test::NoWarnings;
+use Test::Exception;
+use Encode;
+use lib 't/lib';
+use LC;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::DateTime';
+require_ok $CLASS;
+
+ok my $dt = $CLASS->now, 'Construct a datetime object';
+is_deeply [$dt->as_string_formats], [qw(
+ raw
+ iso
+ iso8601
+ rfc
+ rfc2822
+ full
+ long
+ medium
+ short
+)], 'as_string_formats should be correct';
+
+my $rfc = do {
+ my $clone = $dt->clone;
+ $clone->set_time_zone('local');
+ $clone->set_locale('en_US');
+ ( my $rv = $clone->strftime('%a, %d %b %Y %H:%M:%S %z') ) =~ s/\+0000$/-0000/;
+ $rv;
+};
+
+my $iso = do {
+ my $clone = $dt->clone;
+ $clone->set_time_zone('local');
+ join ' ', $clone->ymd('-'), $clone->hms(':'), $clone->strftime('%z')
+};
+
+my $ldt = do {
+ my $clone = $dt->clone;
+ $clone->set_time_zone('local');
+ $clone->set_locale($LC::TIME);
+ $clone;
+};
+
+my $raw = do {
+ my $clone = $dt->clone;
+ $clone->set_time_zone('UTC');
+ $clone->iso8601 . 'Z';
+};
+
+for my $spec (
+ [ full => $ldt->format_cldr( $ldt->locale->datetime_format_full )],
+ [ long => $ldt->format_cldr( $ldt->locale->datetime_format_long )],
+ [ medium => $ldt->format_cldr( $ldt->locale->datetime_format_medium )],
+ [ short => $ldt->format_cldr( $ldt->locale->datetime_format_short )],
+ [ raw => $raw ],
+ [ '' => $raw ],
+ [ iso => $iso ],
+ [ iso8601 => $iso ],
+ [ rfc => $rfc ],
+ [ rfc2822 => $rfc ],
+ [ q{cldr:HH'h' mm'm'} => $ldt->format_cldr( q{HH'h' mm'm'} ) ],
+ [ 'strftime:%a at %H:%M:%S' => $ldt->strftime('%a at %H:%M:%S') ],
+) {
+ my $clone = $dt->clone;
+ $clone->set_time_zone('UTC');
+ is $dt->as_string( format => $spec->[0] ), $spec->[1],
+ sprintf 'Date format "%s" should yield "%s"', $spec->[0], encode_utf8 $spec->[1];
+ ok $dt->validate_as_string_format($spec->[0]),
+ qq{Format "$spec->[0]" should be valid} if $spec->[0];
+}
+
+throws_ok { $dt->validate_as_string_format('nonesuch') } 'App::Sqitch::X',
+ 'Should get error for invalid date format';
+is $@->ident, 'datetime', 'Invalid date format error ident should be "datetime"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'nonesuch',
+), 'Invalid date format error message should be correct';
+
+throws_ok { $dt->as_string( format => 'nonesuch' ) } 'App::Sqitch::X',
+ 'Should get error for invalid as_string format param';
+is $@->ident, 'datetime', 'Invalid date format error ident should be "datetime"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'nonesuch',
+), 'Invalid date format error message should be correct';
diff --git a/t/depend.t b/t/depend.t
new file mode 100644
index 00000000..b3395f4b
--- /dev/null
+++ b/t/depend.t
@@ -0,0 +1,224 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 326;
+#use Test::More 'no_plan';
+use Test::Exception;
+#use Test::NoWarnings;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use Path::Class;
+use Locale::TextDomain qw(App-Sqitch);
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Plan::Depend';
+ require_ok $CLASS or die;
+}
+
+ok my $sqitch = App::Sqitch->new(
+ config => TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ ),
+), 'Load a sqitch sqitch object';
+my $target = App::Sqitch::Target->new( sqitch => $sqitch );
+my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, project => 'depend', target => $target);
+
+can_ok $CLASS, qw(
+ conflicts
+ project
+ change
+ tag
+ id
+ resolved_id
+ key_name
+ as_string
+ as_plan_string
+);
+
+my $id = '9ed961ad7902a67fe0804c8e49e8993719fd5065';
+for my $spec(
+ [ 'foo' => change => 'foo' ],
+ [ 'bar' => change => 'bar' ],
+ [ '@bar' => tag => 'bar' ],
+ [ '!foo' => change => 'foo', conflicts => 1 ],
+ [ '!@bar' => tag => 'bar', conflicts => 1 ],
+ [ 'foo@bar' => change => 'foo', tag => 'bar' ],
+ [ '!foo@bar' => change => 'foo', tag => 'bar', conflicts => 1 ],
+ [ 'proj:foo' => change => 'foo', project => 'proj' ],
+ [ '!proj:foo' => change => 'foo', project => 'proj', conflicts => 1 ],
+ [ 'proj:@foo' => tag => 'foo', project => 'proj' ],
+ [ '!proj:@foo' => tag => 'foo', project => 'proj', conflicts => 1 ],
+ [ 'proj:foo@bar' => change => 'foo', tag => 'bar', project => 'proj' ],
+ [
+ '!proj:foo@bar',
+ change => 'foo',
+ tag => 'bar',
+ project => 'proj',
+ conflicts => 1
+ ],
+ [ $id => id => $id ],
+ [ "!$id" => id => $id, conflicts => 1 ],
+ [ "foo:$id" => id => $id, project => 'foo' ],
+ [ "!foo:$id" => id => $id, project => 'foo', conflicts => 1 ],
+ [ "$id\@what" => change => $id, tag => 'what' ],
+ [ "!$id\@what" => change => $id, tag => 'what', conflicts => 1 ],
+ [ "foo:$id\@what" => change => $id, tag => 'what', project => 'foo' ],
+) {
+ my $exp = shift @{$spec};
+ ok my $depend = $CLASS->new(
+ plan => $plan,
+ @{$spec},
+ ), qq{Construct "$exp"};
+ ( my $str = $exp ) =~ s/^!//;
+ ( my $key = $str ) =~ s/^[^:]+://;
+ my $proj = $1;
+ is $depend->as_string, $str, qq{Constructed should stringify as "$str"};
+ is $depend->key_name, $key, qq{Constructed should have key name "$key"};
+ is $depend->as_plan_string, $exp, qq{Constructed should plan stringify as "$exp"};
+ ok $depend = $CLASS->new(
+ plan => $plan,
+ %{ $CLASS->parse($exp) },
+ ), qq{Parse "$exp"};
+ is $depend->as_plan_string, $exp, qq{Parsed should plan stringify as "$exp"};
+
+ if ($exp =~ /^!/) {
+ # Conflicting.
+ ok $depend->conflicts, qq{"$exp" should be conflicting};
+ ok !$depend->required, qq{"$exp" should not be required};
+ is $depend->type, 'conflict', qq{"$exp" type should be "conflict"};
+ } else {
+ # Required.
+ ok $depend->required, qq{"$exp" should be required};
+ ok !$depend->conflicts, qq{"$exp" should not be conflicting};
+ is $depend->type, 'require', qq{"$exp" type should be "require"};
+ }
+
+ if ($str =~ /^([^:]+):/) {
+ # Project specified in spec.
+ my $prj = $1;
+ ok $depend->got_project, qq{Should have got project from "$exp"};
+ is $depend->project, $prj, qq{Should have project "$prj" for "$exp"};
+ if ($prj eq $plan->project) {
+ ok !$depend->is_external, qq{"$exp" should not be external};
+ ok $depend->is_internal, qq{"$exp" should be internal};
+ } else {
+ ok $depend->is_external, qq{"$exp" should be external};
+ ok !$depend->is_internal, qq{"$exp" should not be internal};
+ }
+ } else {
+ ok !$depend->got_project, qq{Should not have got project from "$exp"};
+ if ($depend->change || $depend->tag) {
+ # No ID, default to current project.
+ my $prj = $plan->project;
+ is $depend->project, $prj, qq{Should have project "$prj" for "$exp"};
+ ok !$depend->is_external, qq{"$exp" should not be external};
+ ok $depend->is_internal, qq{"$exp" should be internal};
+ } else {
+ # ID specified, but no project, and ID not in plan, so unknown project.
+ is $depend->project, undef, qq{Should have undef project for "$exp"};
+ ok $depend->is_external, qq{"$exp" should be external};
+ ok !$depend->is_internal, qq{"$exp" should not be internal};
+ }
+ }
+
+ if ($exp =~ /\Q$id\E(?![@])/) {
+ ok $depend->got_id, qq{Should have got ID from "$exp"};
+ } else {
+ ok !$depend->got_id, qq{Should not have got ID from "$exp"};
+ }
+}
+
+for my $bad ( 'foo bar', 'foo+@bar', 'foo:+bar', 'foo@bar+', 'proj:foo@bar+', )
+{
+ is $CLASS->parse($bad), undef, qq{Should fail to parse "$bad"};
+}
+
+throws_ok { $CLASS->new( plan => $plan ) } 'App::Sqitch::X',
+ 'Should get exception for no change or tag';
+is $@->ident, 'DEV', 'No change or tag error ident should be "DEV"';
+is $@->message,
+ 'Depend object must have either "change", "tag", or "id" defined',
+ 'No change or tag error message should be correct';
+
+for my $params (
+ { change => 'foo' },
+ { tag => 'bar' },
+ { change => 'foo', tag => 'bar' },
+) {
+ my $keys = join ' and ' => keys %{ $params };
+ throws_ok { $CLASS->new( plan => $plan, id => $id, %{ $params} ) }
+ 'App::Sqitch::X', "Should get an error for ID + $keys";
+ is $@->ident, 'DEV', qq{ID + $keys error ident ident should be "DEV"};
+ is $@->message,
+ 'Depend object cannot contain both an ID and a tag or change',
+ qq{ID + $keys error message should be correct};
+}
+
+##############################################################################
+# Test ID.
+ok my $depend = $CLASS->new(
+ plan => $plan,
+ %{ $CLASS->parse('roles') },
+), 'Create "roles" dependency';
+is $depend->id, $plan->find('roles')->id,
+ 'Should find the "roles" ID in the plan';
+ok !$depend->is_external, 'The "roles" change should not be external';
+ok $depend->is_internal, 'The "roles" change should be internal';
+
+ok $depend = $CLASS->new(
+ plan => $plan,
+ %{ $CLASS->parse('elsewhere:roles') },
+), 'Create "elsewhere:roles" dependency';
+is $depend->id, undef, 'The "elsewhere:roles" id should be undef';
+ok $depend->is_external, 'The "elsewhere:roles" change should be external';
+ok !$depend->is_internal, 'The "elsewhere:roles" change should not be internal';
+
+ok $depend = $CLASS->new(
+ plan => $plan,
+ id => $id,
+), 'Create depend using external ID';
+is $depend->id, $id, 'The external ID should be set';
+ok $depend->is_external, 'The external ID should register as external';
+ok !$depend->is_internal, 'The external ID should not register as internal';
+
+$id = $plan->find('roles')->id;
+ok $depend = $CLASS->new(
+ plan => $plan,
+ id => $id,
+), 'Create depend using "roles" ID';
+is $depend->id, $id, 'The "roles" ID should be set';
+ok !$depend->is_external, 'The "roles" ID should not register as external';
+ok $depend->is_internal, 'The "roles" ID should register as internal';
+
+ok $depend = $CLASS->new(
+ plan => $plan,
+ project => $plan->project,
+ %{ $CLASS->parse('nonexistent') },
+), 'Create "nonexistent" dependency';
+throws_ok { $depend->id } 'App::Sqitch::X',
+ 'Should get error for nonexistent change';
+is $@->ident, 'plan', 'Nonexistent change error ident should be "plan"';
+is $@->message, __x(
+ 'Unable to find change "{change}" in plan {file}',
+ change => 'nonexistent',
+ file => $target->plan_file,
+), 'Nonexistent change error message should be correct';
+
+##############################################################################
+# Test resolved_id.
+ok $depend = $CLASS->new( plan => $plan, tag => 'foo' ),
+ 'Create depend without ID';
+is $depend->resolved_id, undef, 'Resolved ID should be undef';
+ok $depend->resolved_id($id), 'Set resolved ID';
+is $depend->resolved_id, $id, 'Resolved ID should be set';
+ok !$depend->resolved_id(undef), 'Unset resolved ID';
+is $depend->resolved_id, undef, 'Resolved ID should be undef again';
diff --git a/t/deploy.t b/t/deploy.t
new file mode 100644
index 00000000..b3b87223
--- /dev/null
+++ b/t/deploy.t
@@ -0,0 +1,327 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Path::Class qw(dir file);
+use Test::MockModule;
+use Test::Exception;
+use Test::Warn;
+use Locale::TextDomain qw(App-Sqitch);
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::deploy';
+require_ok $CLASS or die;
+
+isa_ok $CLASS, 'App::Sqitch::Command';
+can_ok $CLASS, qw(
+ target
+ options
+ configure
+ new
+ to_change
+ mode
+ log_only
+ execute
+ variables
+ does
+ _collect_vars
+);
+
+ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_"
+ for qw(ContextCommand ConnectingCommand);
+
+is_deeply [$CLASS->options], [qw(
+ target|t=s
+ to-change|to|change=s
+ mode=s
+ set|s=s%
+ log-only
+ verify!
+ plan-file|f=s
+ top-dir=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+);
+my $sqitch = App::Sqitch->new(config => $config);
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {
+ mode => 'all',
+ verify => 0,
+ log_only => 0,
+ _params => [],
+ _cx => [],
+}, 'Should have default configuration with no config or opts';
+
+is_deeply $CLASS->configure($config, {
+ mode => 'tag',
+ verify => 1,
+ log_only => 1,
+ set => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}), {
+ mode => 'tag',
+ verify => 1,
+ log_only => 1,
+ variables => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}, 'Should have mode, verify, set, and log-only options';
+
+CONFIG: {
+ my $config = TestConfig->new(
+ 'deploy.mode' => 'change',
+ 'deploy.verify' => 1,
+ 'deploy.variables' => { foo => 'bar', hi => 21 },
+ );
+
+ is_deeply $CLASS->configure($config, {}), {
+ mode => 'change',
+ verify => 1,
+ log_only => 0,
+ _params => [],
+ _cx => [],
+ }, 'Should have mode and verify configuration';
+}
+
+##############################################################################
+# Test construction.
+isa_ok my $deploy = $CLASS->new(
+ sqitch => $sqitch,
+ target => 'foo',
+), $CLASS, 'new deploy with target';
+is $deploy->target, 'foo', 'Should have target "foo"';
+
+isa_ok $deploy = $CLASS->new(sqitch => $sqitch), $CLASS;
+is $deploy->target, undef, 'Should have undef default target';
+is $deploy->to_change, undef, 'to_change should be undef';
+is $deploy->mode, 'all', 'mode should be "all"';
+
+##############################################################################
+# Test _collect_vars.
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $deploy->_collect_vars($target) }, {}, 'Should collect no variables';
+
+# Add core variables.
+$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $deploy->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'SELECT',
+}, 'Should collect core vars';
+
+# Add deploy variables.
+$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $deploy->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Should override core vars with deploy vars';
+
+# Add engine variables.
+$config->update('engine.pg.variables' => { dance => 'disco', lunch => 'pizza' });
+my $uri = URI::db->new('db:pg:');
+$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri);
+is_deeply { $deploy->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'pizza',
+}, 'Should override deploy vars with engine vars';
+
+# Add target variables.
+$config->update('target.foo.variables' => { lunch => 'burrito', drink => 'whiskey' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $deploy->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'whiskey',
+}, 'Should override engine vars with target vars';
+
+# Add --set variables.
+$deploy = $CLASS->new(
+ sqitch => $sqitch,
+ variables => { drink => 'scotch', status => 'winning' },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $deploy->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'winning',
+}, 'Should override target vars with --set variables';
+
+##############################################################################
+# Test execution.
+# Mock parse_args() so that we can grab the target it returns.
+my $mock_cmd = Test::MockModule->new($CLASS);
+my $parser;
+$mock_cmd->mock(parse_args => sub {
+ my @ret = $parser->(@_);
+ $target = $ret[0][0];
+ return @ret;
+});
+$parser = $mock_cmd->original('parse_args');
+
+# Mock the engine interface.
+my $mock_engine = Test::MockModule->new('App::Sqitch::Engine');
+my @args;
+$mock_engine->mock(deploy => sub { shift; @args = @_ });
+my @vars;
+$mock_engine->mock(set_variables => sub { shift; @vars = @_ });
+
+ok $deploy->execute('@alpha'), 'Execute to "@alpha"';
+is_deeply \@args, ['@alpha', 'all'],
+ '"@alpha" "all", and 0 should be passed to the engine';
+ok $target, 'Should have a target';
+ok !$target->engine->log_only, 'The engine should not be set log_only';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+@args = ();
+ok $deploy->execute, 'Execute';
+is_deeply \@args, [undef, 'all'],
+ 'undef and "all" should be passed to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Try passing the change.
+ok $deploy->execute('widgets'), 'Execute with change';
+is_deeply \@args, ['widgets', 'all'],
+ '"widgets" and "all" should be passed to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Try passing the target.
+ok $deploy->execute('db:pg:foo'), 'Execute with target';
+is_deeply \@args, [undef, 'all'],
+ 'undef and "all" should be passed to the engine';
+is $target->name, 'db:pg:foo', 'The target should be as specified';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Pass both!
+ok $deploy->execute('db:pg:blah', 'widgets'), 'Execute with change and target';
+is_deeply \@args, ['widgets', 'all'],
+ '"widgets" and "all" should be passed to the engine';
+is $target->name, 'db:pg:blah', 'The target should be as specified';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Reverse them!
+ok $deploy->execute('db:pg:blah', 'widgets'), 'Execute with target and change';
+is_deeply \@args, ['widgets', 'all'],
+ '"widgets" and "all" should be passed to the engine';
+is $target->name, 'db:pg:blah', 'The target should be as specified';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Now pass a bunch of options.
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+);
+isa_ok $deploy = $CLASS->new(
+ sqitch => $sqitch,
+ to_change => 'foo',
+ target => 'db:pg:hi',
+ mode => 'tag',
+ log_only => 1,
+ verify => 1,
+ variables => { foo => 'bar', one => 1 },
+), $CLASS, 'Object with to, mode, log_only, and variables';
+
+@args = ();
+ok $deploy->execute, 'Execute again';
+ok $target->engine->with_verify, 'Engine should verify';
+ok $target->engine->log_only, 'The engine should be set log_only';
+is_deeply \@args, ['foo', 'tag'],
+ '"foo", "tag", and 1 should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is $target->name, 'db:pg:hi', 'The target name should be from the target option';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Try passing the change.
+ok $deploy->execute('widgets'), 'Execute with change';
+ok $target->engine->with_verify, 'Engine should verify';
+ok $target->engine->log_only, 'The engine should be set log_only';
+is_deeply \@args, ['foo', 'tag'],
+ '"foo", "tag", and 1 should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many changes specified; deploying to "{change}"',
+ change => 'foo',
+)]], 'Should have too many changes warning';
+
+# Pass the target.
+ok $deploy->execute('db:pg:bye'), 'Execute with target again';
+ok $target->engine->with_verify, 'Engine should verify';
+ok $target->engine->log_only, 'The engine should be set log_only';
+is_deeply \@args, ['foo', 'tag'],
+ '"foo", "tag", and 1 should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is $target->name, 'db:pg:hi', 'The target should be from the target option';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; connecting to {target}',
+ target => 'db:pg:hi',
+)]], 'Should have warning about too many targets';
+
+# Make sure the mode enum works.
+for my $mode (qw(all tag change)) {
+ ok $CLASS->new( sqitch => $sqitch, mode => $mode ),
+ qq{"$mode" should be a valid mode};
+}
+
+for my $bad (qw(foo bad gar)) {
+ throws_ok {
+ $CLASS->new( sqitch => $sqitch, mode => $bad )
+ } qr/\QValue "$bad" did not pass type constraint "Enum[all,change,tag]/,
+ qq{"$bad" should not be a valid mode};
+}
+
+# Make sure we get an exception for unknown args.
+throws_ok { $deploy->execute(qw(greg)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown arg';
+is $@->ident, 'deploy', 'Unknow arg ident should be "deploy"';
+is $@->message, __x(
+ 'Unknown argument "{arg}"',
+ arg => 'greg',
+), 'Should get an exeption for two unknown arg';
+
+throws_ok { $deploy->execute(qw(greg jon)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown args';
+is $@->ident, 'deploy', 'Unknow args ident should be "deploy"';
+is $@->message, __x(
+ 'Unknown arguments: {arg}',
+ arg => 'greg, jon',
+), 'Should get an exeption for two unknown args';
+
+done_testing;
diff --git a/t/die.pl b/t/die.pl
new file mode 100644
index 00000000..23d5a941
--- /dev/null
+++ b/t/die.pl
@@ -0,0 +1,5 @@
+use v5.10;
+
+say "@ARGV" if @ARGV;
+die 'OMGWTF';
+
diff --git a/t/echo.pl b/t/echo.pl
new file mode 100644
index 00000000..3e8a290f
--- /dev/null
+++ b/t/echo.pl
@@ -0,0 +1,3 @@
+use 5.010;
+
+say "@ARGV";
diff --git a/t/editor.conf b/t/editor.conf
new file mode 100644
index 00000000..240061a3
--- /dev/null
+++ b/t/editor.conf
@@ -0,0 +1,3 @@
+[core]
+ engine = pg
+ editor = config_specified_editor
diff --git a/t/engine.conf b/t/engine.conf
new file mode 100644
index 00000000..ebf650e1
--- /dev/null
+++ b/t/engine.conf
@@ -0,0 +1,20 @@
+[core]
+ engine = pg
+
+[engine "mysql"]
+ target = db:mysql://root@/foo
+ client = /usr/sbin/mysql
+
+[engine "pg"]
+ target = db:pg:try
+ registry = meta
+ client = /usr/sbin/psql
+
+[engine "sqlite"]
+ target = widgets
+ client = /usr/sbin/sqlite3
+
+[target "widgets"]
+ uri = db:sqlite:widgets.db
+ plan_file = foo.plan
+
diff --git a/t/engine.t b/t/engine.t
new file mode 100644
index 00000000..3285ae11
--- /dev/null
+++ b/t/engine.t
@@ -0,0 +1,3089 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 633;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use App::Sqitch::Plan;
+use App::Sqitch::Target;
+use Path::Class;
+use Test::Exception;
+use Test::NoWarnings;
+use Test::MockModule;
+use Locale::TextDomain qw(App-Sqitch);
+use App::Sqitch::X qw(hurl);
+use App::Sqitch::DateTime;
+use List::Util qw(max);
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine';
+ use_ok $CLASS or die;
+ delete $ENV{PGDATABASE};
+ delete $ENV{PGUSER};
+ delete $ENV{USER};
+}
+
+can_ok $CLASS, qw(load new name no_prompt run_deploy run_revert run_verify uri);
+
+my ($is_deployed_tag, $is_deployed_change) = (0, 0);
+my @deployed_changes;
+my @deployed_change_ids;
+my @resolved;
+my @requiring;
+my @load_changes;
+my $offset_change;
+my $die = '';
+my $record_work = 1;
+my ( $earliest_change_id, $latest_change_id, $initialized );
+my $registry_version = $CLASS->registry_release;
+my $script_hash;
+ENGINE: {
+ # Stub out an engine.
+ package App::Sqitch::Engine::whu;
+ use Moo;
+ use App::Sqitch::X qw(hurl);
+ extends 'App::Sqitch::Engine';
+ $INC{'App/Sqitch/Engine/whu.pm'} = __FILE__;
+
+ my @SEEN;
+ for my $meth (qw(
+ run_file
+ log_deploy_change
+ log_revert_change
+ log_fail_change
+ )) {
+ no strict 'refs';
+ *$meth = sub {
+ hurl 'AAAH!' if $die eq $meth;
+ push @SEEN => [ $meth => $_[1] ];
+ };
+ }
+ sub is_deployed_tag { push @SEEN => [ is_deployed_tag => $_[1] ]; $is_deployed_tag }
+ sub is_deployed_change { push @SEEN => [ is_deployed_change => $_[1] ]; $is_deployed_change }
+ sub are_deployed_changes { shift; push @SEEN => [ are_deployed_changes => [@_] ]; @deployed_change_ids }
+ sub change_id_for { shift; push @SEEN => [ change_id_for => {@_} ]; shift @resolved }
+ sub change_offset_from_id { shift; push @SEEN => [ change_offset_from_id => [@_] ]; $offset_change }
+ sub change_id_offset_from_id { shift; push @SEEN => [ change_id_offset_from_id => [@_] ]; $_[0] }
+ sub changes_requiring_change { push @SEEN => [ changes_requiring_change => $_[1] ]; @{ shift @requiring } }
+ sub earliest_change_id { push @SEEN => [ earliest_change_id => $_[1] ]; $earliest_change_id }
+ sub latest_change_id { push @SEEN => [ latest_change_id => $_[1] ]; $latest_change_id }
+ sub current_state { push @SEEN => [ current_state => $_[1] ]; $latest_change_id ? { change => 'what', change_id => $latest_change_id, script_hash => $script_hash } : undef }
+ sub initialized { push @SEEN => 'initialized'; $initialized }
+ sub initialize { push @SEEN => 'initialize' }
+ sub register_project { push @SEEN => 'register_project' }
+ sub deployed_changes { push @SEEN => [ deployed_changes => $_[1] ]; @deployed_changes }
+ sub load_change { push @SEEN => [ load_change => $_[1] ]; @load_changes }
+ sub deployed_changes_since { push @SEEN => [ deployed_changes_since => $_[1] ]; @deployed_changes }
+ sub mock_check_deploy { shift; push @SEEN => [ check_deploy_dependencies => [@_] ] }
+ sub mock_check_revert { shift; push @SEEN => [ check_revert_dependencies => [@_] ] }
+ sub begin_work { push @SEEN => ['begin_work'] if $record_work }
+ sub finish_work { push @SEEN => ['finish_work'] if $record_work }
+ sub log_new_tags { push @SEEN => [ log_new_tags => $_[1] ]; $_[0] }
+ sub _update_script_hashes { push @SEEN => ['_update_script_hashes']; $_[0] }
+
+ sub seen { [@SEEN] }
+ after seen => sub { @SEEN = () };
+
+ sub name_for_change_id { return 'bugaboo' }
+ sub registry_version { $registry_version }
+}
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ 'core.plan_file' => file(qw(t plans multi.plan))->stringify,
+);
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+
+my $mock_engine = Test::MockModule->new($CLASS);
+
+##############################################################################
+# Test new().
+my $target = App::Sqitch::Target->new( sqitch => $sqitch );
+throws_ok { $CLASS->new( sqitch => $sqitch ) }
+ qr/\QMissing required arguments: target/,
+ 'Should get an exception for missing sqitch param';
+throws_ok { $CLASS->new( target => $target ) }
+ qr/\QMissing required arguments: sqitch/,
+ 'Should get an exception for missing sqitch param';
+my $array = [];
+throws_ok { $CLASS->new({ sqitch => $array, target => $target }) }
+ qr/\QReference [] did not pass type constraint "Sqitch"/,
+ 'Should get an exception for array sqitch param';
+throws_ok { $CLASS->new({ sqitch => $sqitch, target => $array }) }
+ qr/\QReference [] did not pass type constraint "Target"/,
+ 'Should get an exception for array target param';
+throws_ok { $CLASS->new({ sqitch => 'foo', target => $target }) }
+ qr/\QValue "foo" did not pass type constraint "Sqitch"/,
+ 'Should get an exception for string sqitch param';
+throws_ok { $CLASS->new({ sqitch => $sqitch, target => 'foo' }) }
+ qr/\QValue "foo" did not pass type constraint "Target"/,
+ 'Should get an exception for string target param';
+
+isa_ok $CLASS->new({sqitch => $sqitch, target => $target}), $CLASS, 'Engine';
+
+##############################################################################
+# Test load().
+$config->update('core.engine' => 'whu');
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok my $engine = $CLASS->load({
+ sqitch => $sqitch,
+ target => $target,
+}), 'Load an engine';
+isa_ok $engine, 'App::Sqitch::Engine::whu';
+is $engine->sqitch, $sqitch, 'The sqitch attribute should be set';
+
+# Test handling of an invalid engine.
+my $unknown_target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:nonexistent:')
+);
+throws_ok { $CLASS->load({ sqitch => $sqitch, target => $unknown_target }) }
+ 'App::Sqitch::X', 'Should die on unknown target';
+is $@->message, 'Unable to load App::Sqitch::Engine::nonexistent',
+ 'Should get load error message';
+like $@->previous_exception, qr/\QCan't locate/,
+ 'Should have relevant previoius exception';
+
+NOENGINE: {
+ # Test handling of no target.
+ throws_ok { $CLASS->load({ sqitch => $sqitch }) } 'App::Sqitch::X',
+ 'No target should die';
+ is $@->message, 'Missing "target" parameter to load()',
+ 'It should be the expected message';
+}
+
+# Test handling a bad engine implementation.
+use lib 't/lib';
+my $bad_target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:bad:')
+);
+throws_ok { $CLASS->load({ sqitch => $sqitch, target => $bad_target }) }
+ 'App::Sqitch::X', 'Should die on bad engine module';
+is $@->message, 'Unable to load App::Sqitch::Engine::bad',
+ 'Should get another load error message';
+like $@->previous_exception, qr/^LOL BADZ/,
+ 'Should have relevant previoius exception from the bad module';
+
+
+##############################################################################
+# Test name.
+can_ok $CLASS, 'name';
+ok $engine = $CLASS->new({ sqitch => $sqitch, target => $target }),
+ "Create a $CLASS object";
+throws_ok { $engine->name } 'App::Sqitch::X',
+ 'Should get error from base engine name';
+is $@->ident, 'engine', 'Name error ident should be "engine"';
+is $@->message, __('No engine specified; specify via target or core.engine'),
+ 'Name error message should be correct';
+
+ok $engine = App::Sqitch::Engine::whu->new({sqitch => $sqitch, target => $target}),
+ 'Create a subclass name object';
+is $engine->name, 'whu', 'Subclass oject name should be "whu"';
+is +App::Sqitch::Engine::whu->name, 'whu', 'Subclass class name should be "whu"';
+
+##############################################################################
+# Test config_vars.
+can_ok $CLASS, 'config_vars';
+is_deeply [App::Sqitch::Engine->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'Should have database and client in engine base class';
+
+##############################################################################
+# Test variables.
+can_ok $CLASS, qw(variables set_variables clear_variables);
+is_deeply [$engine->variables], [], 'Should have no variables';
+ok $engine->set_variables(foo => 'bar'), 'Add a variable';
+is_deeply [$engine->variables], [foo => 'bar'], 'Should have the variable';
+ok $engine->set_variables(foo => 'baz', whu => 'hi', yo => 'stellar'),
+ 'Set more variables';
+is_deeply {$engine->variables}, {foo => 'baz', whu => 'hi', yo => 'stellar'},
+ 'Should have all of the variables';
+$engine->clear_variables;
+is_deeply [$engine->variables], [], 'Should again have no variables';
+
+##############################################################################
+# Test target.
+ok $engine = $CLASS->load({
+ sqitch => $sqitch,
+ target => $target,
+}), 'Load engine';
+is $engine->target, $target, 'Target should be as passed';
+
+# Make sure password is removed from the target.
+ok $engine = $CLASS->load({
+ sqitch => $sqitch,
+ target => $target,
+ uri => URI->new('db:whu://foo:bar@localhost/blah'),
+}), 'Load engine with URI with password';
+isa_ok $engine->target, 'App::Sqitch::Target', 'target attribute';
+
+##############################################################################
+# Test destination.
+ok $engine = $CLASS->load({
+ sqitch => $sqitch,
+ target => $target,
+}), 'Load engine';
+is $engine->destination, 'db:whu:', 'Destination should be URI string';
+is $engine->registry_destination, $engine->destination,
+ 'Rgistry destination should be the same as destination';
+
+# Make sure password is removed from the destination.
+my $long_target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI->new('db:whu://foo:bar@localhost/blah'),
+);
+ok $engine = $CLASS->load({
+ sqitch => $sqitch,
+ target => $long_target,
+}), 'Load engine with URI with password';
+like $engine->destination, qr{^db:whu://foo:?\@localhost/blah$},
+ 'Destination should not include password';
+is $engine->registry_destination, $engine->destination,
+ 'Registry destination should again be the same as destination';
+
+##############################################################################
+# Test _check_registry.
+can_ok $engine, '_check_registry';
+ok $engine->_check_registry, 'Registry should be fine at current version';
+
+# Make the registry non-existent.
+$registry_version = 0;
+$initialized = 0;
+throws_ok { $engine->_check_registry } 'App::Sqitch::X',
+ 'Should get error for non-existent registry';
+is $@->ident, 'engine', 'Non-existent registry error ident should be "engine"';
+is $@->message, __x(
+ 'No registry found in {destination}. Have you ever deployed?',
+ destination => $engine->registry_destination,
+), 'Non-existent registry error message should be correct';
+$engine->seen;
+
+# Make sure it's checked on revert and verify.
+for my $meth (qw(revert verify)) {
+ throws_ok { $engine->$meth } 'App::Sqitch::X', "Should get error from $meth";
+ is $@->ident, 'engine', qq{$meth registry error ident should be "engine"};
+ is $@->message, __x(
+ 'No registry found in {destination}. Have you ever deployed?',
+ destination => $engine->registry_destination,
+ ), "$meth registry error message should be correct";
+ $engine->seen;
+}
+
+# Make the registry out-of-date.
+$registry_version = 0.1;
+throws_ok { $engine->_check_registry } 'App::Sqitch::X',
+ 'Should get error for out-of-date registry';
+is $@->ident, 'engine', 'Out-of-date registry error ident should be "engine"';
+is $@->message, __x(
+ 'Registry is at version {old} but latest is {new}. Please run the "upgrade" command',
+ old => 0.1,
+ new => $engine->registry_release,
+), 'Out-of-date registry error message should be correct';
+
+# Send the registry to the future.
+$registry_version = 999.99;
+throws_ok { $engine->_check_registry } 'App::Sqitch::X',
+ 'Should get error for future registry';
+is $@->ident, 'engine', 'Future registry error ident should be "engine"';
+is $@->message, __x(
+ 'Registry version is {old} but {new} is the latest known. Please upgrade Sqitch',
+ old => 999.99,
+ new => $engine->registry_release,
+), 'Future registry error message should be correct';
+
+
+# Restore the registry version.
+$registry_version = $CLASS->registry_release;
+
+##############################################################################
+# Test abstract methods.
+ok $engine = $CLASS->new({
+ sqitch => $sqitch,
+ target => $target,
+}), "Create a $CLASS object again";
+for my $abs (qw(
+ initialized
+ initialize
+ register_project
+ run_file
+ run_handle
+ log_deploy_change
+ log_fail_change
+ log_revert_change
+ log_new_tags
+ is_deployed_tag
+ is_deployed_change
+ are_deployed_changes
+ change_id_for
+ changes_requiring_change
+ earliest_change_id
+ latest_change_id
+ deployed_changes
+ deployed_changes_since
+ load_change
+ name_for_change_id
+ current_state
+ current_changes
+ current_tags
+ search_events
+ registered_projects
+ change_offset_from_id
+ change_id_offset_from_id
+)) {
+ throws_ok { $engine->$abs } qr/\Q$CLASS has not implemented $abs()/,
+ "Should get an unimplemented exception from $abs()"
+}
+
+##############################################################################
+# Test _load_changes().
+can_ok $engine, '_load_changes';
+my $now = App::Sqitch::DateTime->now;
+my $plan = $target->plan;
+
+# Mock App::Sqitch::DateTime so that dbchange tags all have the same
+# timestamps.
+my $mock_dt = Test::MockModule->new('App::Sqitch::DateTime');
+$mock_dt->mock(now => $now);
+
+
+for my $spec (
+ [ 'no change' => [] ],
+ [ 'undef' => [undef] ],
+ ['no tags' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ },
+ ]],
+ ['multiple hashes with no tags' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ },
+ {
+ id => 'ae5b4397f78dfc6072ccf6d505b17f9624d0e3b0',
+ name => 'booyah',
+ project => 'engine',
+ note => 'Whatever',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ },
+ ]],
+ ['tags' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(foo bar)],
+ },
+ ]],
+ ['tags with leading @' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(@foo @bar)],
+ },
+ ]],
+ ['multiple hashes with tags' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(foo bar)],
+ },
+ {
+ id => 'ae5b4397f78dfc6072ccf6d505b17f9624d0e3b0',
+ name => 'booyah',
+ project => 'engine',
+ note => 'Whatever',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(@foo @bar)],
+ },
+ ]],
+ ['reworked change' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(foo bar)],
+ },
+ {
+ id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ rtags => [qw(howdy)],
+ },
+ ]],
+ ['reworked change & multiple tags' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(foo bar)],
+ },
+ {
+ id => 'ae5b4397f78dfc6072ccf6d505b17f9624d0e3b0',
+ name => 'booyah',
+ project => 'engine',
+ note => 'Whatever',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(@settle)],
+ },
+ {
+ id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ rtags => [qw(booyah howdy)],
+ },
+ ]],
+ ['doubly reworked change' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ tags => [qw(foo bar)],
+ },
+ {
+ id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ rtags => [qw(howdy)],
+ tags => [qw(why)],
+ },
+ {
+ id => 'f38ceb6efcf2a813104b7bb08cc90667033ddf6b',
+ name => 'howdy',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ rtags => [qw(howdy)],
+ },
+ ]],
+) {
+ my ($desc, $args) = @{ $spec };
+ my %seen;
+ is_deeply [ $engine->_load_changes(@{ $args }) ], [ map {
+ my $tags = $_->{tags} || [];
+ my $rtags = $_->{rtags};
+ my $c = App::Sqitch::Plan::Change->new(%{ $_ }, plan => $plan );
+ $c->add_tag(App::Sqitch::Plan::Tag->new(
+ name => $_,
+ plan => $plan,
+ change => $c,
+ timestamp => $now,
+ )) for map { s/^@//; $_ } @{ $tags };
+ if (my $dupe = $seen{ $_->{name} }) {
+ $dupe->add_rework_tags( map { $seen{$_}->tags } @{ $rtags });
+ }
+ $seen{ $_->{name} } = $c;
+ $c;
+ } grep { $_ } @{ $args }], "Should load changes with $desc";
+}
+
+# Rework a change in the plan.
+my $you = $plan->get('you');
+my $this_rocks = $plan->get('this/rocks');
+my $hey_there = $plan->get('hey-there');
+ok my $rev_change = $plan->rework( name => 'you' ), 'Rework change "you"';
+ok $plan->tag( name => '@beta1' ), 'Tag @beta1';
+
+# Load changes
+for my $spec (
+ [ 'Unplanned change' => [
+ {
+ id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d',
+ name => 'you',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ },
+ {
+ id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163',
+ name => 'this/rocks',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ },
+ ]],
+ [ 'reworked change without reworked version deployed' => [
+ {
+ id => $you->id,
+ name => $you->name,
+ project => $you->project,
+ note => $you->note,
+ planner_name => $you->planner_name,
+ planner_email => $you->planner_email,
+ timestamp => $you->timestamp,
+ ptags => [ $hey_there->tags, $you->tags ],
+ },
+ {
+ id => $this_rocks->id,
+ name => 'this/rocks',
+ project => 'engine',
+ note => 'For realz',
+ planner_name => 'Barack Obama',
+ planner_email => 'bo@whitehouse.gov',
+ timestamp => $now,
+ },
+ ]],
+ [ 'reworked change with reworked version deployed' => [
+ {
+ id => $you->id,
+ name => $you->name,
+ project => $you->project,
+ note => $you->note,
+ planner_name => $you->planner_name,
+ planner_email => $you->planner_email,
+ timestamp => $you->timestamp,
+ tags => [qw(@foo @bar)],
+ ptags => [ $hey_there->tags, $you->tags ],
+ },
+ {
+ id => $rev_change->id,
+ name => $rev_change->name,
+ project => 'engine',
+ note => $rev_change->note,
+ planner_name => $rev_change->planner_name,
+ planner_email => $rev_change->planner_email,
+ timestamp => $rev_change->timestamp,
+ },
+ ]],
+) {
+ my ($desc, $args) = @{ $spec };
+ my %seen;
+ is_deeply [ $engine->_load_changes(@{ $args }) ], [ map {
+ my $tags = $_->{tags} || [];
+ my $rtags = $_->{rtags};
+ my $ptags = $_->{ptags};
+ my $c = App::Sqitch::Plan::Change->new(%{ $_ }, plan => $plan );
+ $c->add_tag(App::Sqitch::Plan::Tag->new(
+ name => $_,
+ plan => $plan,
+ change => $c,
+ timestamp => $now,
+ )) for map { s/^@//; $_ } @{ $tags };
+ my %seen_tags;
+ if (@{ $ptags || [] }) {
+ $c->add_rework_tags( @{ $ptags });
+ }
+ if (my $dupe = $seen{ $_->{name} }) {
+ $dupe->add_rework_tags( map { $seen{$_}->tags } @{ $rtags });
+ }
+ $seen{ $_->{name} } = $c;
+ $c;
+ } grep { $_ } @{ $args }], "Should load changes with $desc";
+}
+
+##############################################################################
+# Test deploy_change and revert_change.
+ok $engine = App::Sqitch::Engine::whu->new( sqitch => $sqitch, target => $target ),
+ 'Create a subclass name object again';
+can_ok $engine, 'deploy_change', 'revert_change';
+
+my $change = App::Sqitch::Plan::Change->new( name => 'users', plan => $target->plan );
+$engine->max_name_length(length $change->format_name_with_tags);
+
+ok $engine->deploy_change($change), 'Deploy a change';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [run_file => $change->deploy_file ],
+ [log_deploy_change => $change ],
+ ['finish_work'],
+], 'deploy_change should have called the proper methods';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + users ..', '' , ' '
+]], 'Output should reflect the deployment';
+is_deeply +MockOutput->get_info, [[__ 'ok' ]],
+ 'Output should reflect success';
+
+# Have it log only.
+$engine->log_only(1);
+ok $engine->deploy_change($change), 'Only log a change';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [log_deploy_change => $change ],
+ ['finish_work'],
+], 'log-only deploy_change should not have called run_file';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + users ..', '' , ' '
+]], 'Output should reflect the logging';
+is_deeply +MockOutput->get_info, [[__ 'ok' ]],
+ 'Output should reflect deploy success';
+
+# Have it verify.
+ok $engine->with_verify(1), 'Enable verification';
+$engine->log_only(0);
+ok $engine->deploy_change($change), 'Deploy a change to be verified';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [run_file => $change->deploy_file ],
+ [run_file => $change->verify_file ],
+ [log_deploy_change => $change ],
+ ['finish_work'],
+], 'deploy_change with verification should run the verify file';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + users ..', '' , ' '
+]], 'Output should reflect the logging';
+is_deeply +MockOutput->get_info, [[__ 'ok' ]],
+ 'Output should reflect deploy success';
+
+# Have it verify *and* log-only.
+ok $engine->log_only(1), 'Enable log_only';
+ok $engine->deploy_change($change), 'Verify and log a change';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [run_file => $change->verify_file ],
+ [log_deploy_change => $change ],
+ ['finish_work'],
+], 'deploy_change with verification and log-only should not run deploy';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + users ..', '' , ' '
+]], 'Output should reflect the logging';
+is_deeply +MockOutput->get_info, [[__ 'ok' ]],
+ 'Output should reflect deploy success';
+
+# Make it fail.
+$die = 'run_file';
+$engine->log_only(0);
+throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X',
+ 'Deploy change with error';
+is $@->message, 'AAAH!', 'Error should be from run_file';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [log_fail_change => $change ],
+ ['finish_work'],
+], 'Should have logged change failure';
+$die = '';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + users ..', '' , ' '
+]], 'Output should reflect the deployment, even with failure';
+is_deeply +MockOutput->get_info, [[__ 'not ok' ]],
+ 'Output should reflect deploy failure';
+
+# Make the verify fail.
+$mock_engine->mock( verify_change => sub { hurl 'WTF!' });
+throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X',
+ 'Deploy change with failed verification';
+is $@->message, __ 'Deploy failed', 'Error should be from deploy_change';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [run_file => $change->deploy_file ],
+ ['begin_work'],
+ [run_file => $change->revert_file ],
+ [log_fail_change => $change ],
+ ['finish_work'],
+], 'Should have logged verify failure';
+$die = '';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + users ..', '' , ' '
+]], 'Output should reflect the deployment, even with verify failure';
+is_deeply +MockOutput->get_info, [[__ 'not ok' ]],
+ 'Output should reflect deploy failure';
+is_deeply +MockOutput->get_vent, [['WTF!']],
+ 'Verify error should have been vented';
+
+# Make the verify fail with log only.
+ok $engine->log_only(1), 'Enable log_only';
+throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X',
+ 'Deploy change with log-only and failed verification';
+is $@->message, __ 'Deploy failed', 'Error should be from deploy_change';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ ['begin_work'],
+ [log_fail_change => $change ],
+ ['finish_work'],
+], 'Should have logged verify failure but not reverted';
+$die = '';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + users ..', '' , ' '
+]], 'Output should reflect the deployment, even with verify failure';
+is_deeply +MockOutput->get_info, [[__ 'not ok' ]],
+ 'Output should reflect deploy failure';
+is_deeply +MockOutput->get_vent, [['WTF!']],
+ 'Verify error should have been vented';
+
+# Try a change with no verify file.
+$engine->log_only(0);
+$mock_engine->unmock( 'verify_change' );
+$change = App::Sqitch::Plan::Change->new( name => 'foo', plan => $target->plan );
+ok $engine->deploy_change($change), 'Deploy a change with no verify script';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [run_file => $change->deploy_file ],
+ [log_deploy_change => $change ],
+ ['finish_work'],
+], 'deploy_change with no verify file should not run it';
+is_deeply +MockOutput->get_info_literal, [[
+ ' + foo ..', '..' , ' '
+]], 'Output should reflect the logging';
+is_deeply +MockOutput->get_info, [[__ 'ok' ]],
+ 'Output should reflect deploy success';
+is_deeply +MockOutput->get_vent, [
+ [__x 'Verify script {file} does not exist', file => $change->verify_file],
+], 'A warning about no verify file should have been emitted';
+
+# Alright, disable verify now.
+$engine->with_verify(0);
+
+ok $engine->revert_change($change), 'Revert a change';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [run_file => $change->revert_file ],
+ [log_revert_change => $change ],
+ ['finish_work'],
+], 'revert_change should have called the proper methods';
+is_deeply +MockOutput->get_info_literal, [[
+ ' - foo ..', '..', ' '
+]], 'Output should reflect reversion';
+is_deeply +MockOutput->get_info, [[__ 'ok']],
+ 'Output should acknowldge revert success';
+
+# Revert with log-only.
+ok $engine->log_only(1), 'Enable log_only';
+ok $engine->revert_change($change), 'Revert a change with log-only';
+is_deeply $engine->seen, [
+ ['begin_work'],
+ [log_revert_change => $change ],
+ ['finish_work'],
+], 'Log-only revert_change should not have run the change script';
+is_deeply +MockOutput->get_info_literal, [[
+ ' - foo ..', '..', ' '
+]], 'Output should reflect logged reversion';
+is_deeply +MockOutput->get_info, [[__ 'ok']],
+ 'Output should acknowldge revert success';
+$record_work = 0;
+
+##############################################################################
+# Test earliest_change() and latest_change().
+chdir 't';
+my $plan_file = file qw(sql sqitch.plan);
+my $sqitch_old = $sqitch; # Hang on to this because $change does not retain it.
+$config->update(
+ 'core.top_dir' => 'sql',
+ 'core.plan_file' => $plan_file->stringify,
+);
+$sqitch = App::Sqitch->new(config => $config);
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+$change = App::Sqitch::Plan::Change->new( name => 'foo', plan => $target->plan );
+ok $engine = App::Sqitch::Engine::whu->new( sqitch => $sqitch, target => $target ),
+ 'Engine with sqitch with plan file';
+$plan = $target->plan;
+my @changes = $plan->changes;
+
+$latest_change_id = $changes[0]->id;
+is $engine->latest_change, $changes[0], 'Should get proper change from latest_change()';
+is_deeply $engine->seen, [[ latest_change_id => undef ]],
+ 'Latest change ID should have been called with no arg';
+$latest_change_id = $changes[2]->id;
+is $engine->latest_change(2), $changes[2],
+ 'Should again get proper change from latest_change()';
+is_deeply $engine->seen, [[ latest_change_id => 2 ]],
+ 'Latest change ID should have been called with offset arg';
+$latest_change_id = undef;
+
+$earliest_change_id = $changes[0]->id;
+is $engine->earliest_change, $changes[0], 'Should get proper change from earliest_change()';
+is_deeply $engine->seen, [[ earliest_change_id => undef ]],
+ 'Earliest change ID should have been called with no arg';
+$earliest_change_id = $changes[2]->id;
+is $engine->earliest_change(4), $changes[2],
+ 'Should again get proper change from earliest_change()';
+is_deeply $engine->seen, [[ earliest_change_id => 4 ]],
+ 'Earliest change ID should have been called with offset arg';
+$earliest_change_id = undef;
+
+##############################################################################
+# Test _sync_plan()
+can_ok $CLASS, '_sync_plan';
+$engine->seen;
+
+is $plan->position, -1, 'Plan should start at position -1';
+is $engine->start_at, undef, 'start_at should be undef';
+
+ok $engine->_sync_plan, 'Sync the plan';
+is $plan->position, -1, 'Plan should still be at position -1';
+is $engine->start_at, undef, 'start_at should still be undef';
+$plan->position(4);
+is_deeply $engine->seen, [['current_state', undef]],
+ 'Should not have updated IDs or hashes';
+
+ok $engine->_sync_plan, 'Sync the plan again';
+is $plan->position, -1, 'Plan should again be at position -1';
+is $engine->start_at, undef, 'start_at should again be undef';
+is_deeply $engine->seen, [['current_state', undef]],
+ 'Still should not have updated IDs or hashes';
+
+# Have latest_item return a tag.
+$latest_change_id = $changes[2]->id;
+ok $engine->_sync_plan, 'Sync the plan to a tag';
+is $plan->position, 2, 'Plan should now be at position 2';
+is $engine->start_at, 'widgets@beta', 'start_at should now be widgets@beta';
+is_deeply $engine->seen, [
+ ['current_state', undef],
+ ['log_new_tags' => $plan->change_at(2)],
+], 'Should have updated IDs';
+
+# Have current_state return a script hash.
+$script_hash = '550aeeab2ae39cba45840888b12a70820a2d6f83';
+ok $engine->_sync_plan, 'Sync the plan with a random script hash';
+is $plan->position, 2, 'Plan should now be at position 1';
+is $engine->start_at, 'widgets@beta', 'start_at should now be widgets@beta';
+is_deeply $engine->seen, [
+ ['current_state', undef],
+ ['log_new_tags' => $plan->change_at(2)],
+], 'Should have updated IDs but not hashes';
+
+# Have current_state return the last deployed ID as script_hash.
+$script_hash = $latest_change_id;
+ok $engine->_sync_plan, 'Sync the plan with a random script hash';
+is $plan->position, 2, 'Plan should now be at position 1';
+is $engine->start_at, 'widgets@beta', 'start_at should now be widgets@beta';
+is_deeply $engine->seen, [
+ ['current_state', undef],
+ ['_update_script_hashes'],
+ ['log_new_tags' => $plan->change_at(2)],
+], 'Should have updated IDs and hashes';
+
+# Return no change ID, now.
+$script_hash = $latest_change_id = $changes[1]->id;
+ok $engine->_sync_plan, 'Sync the plan';
+is $plan->position, 1, 'Plan should be at position 1';
+is $engine->start_at, 'users@alpha', 'start_at should be users@alpha';
+is_deeply $engine->seen, [
+ ['current_state', undef],
+ ['_update_script_hashes'],
+ ['log_new_tags' => $plan->change_at(1)],
+], 'Should have updated hashes but not IDs';
+
+##############################################################################
+# Test deploy.
+can_ok $CLASS, 'deploy';
+$script_hash = undef;
+$latest_change_id = undef;
+$plan->reset;
+$engine->seen;
+@changes = $plan->changes;
+
+# Mock the deploy methods to log which were called.
+my $deploy_meth;
+for my $meth (qw(_deploy_all _deploy_by_tag _deploy_by_change)) {
+ my $orig = $CLASS->can($meth);
+ $mock_engine->mock($meth => sub {
+ $deploy_meth = $meth;
+ $orig->(@_);
+ });
+}
+
+# Mock dependency checking to add its call to the seen stuff.
+$mock_engine->mock( check_deploy_dependencies => sub {
+ shift->mock_check_deploy(@_);
+});
+$mock_engine->mock( check_revert_dependencies => sub {
+ shift->mock_check_revert(@_);
+});
+
+ok $engine->deploy('@alpha'), 'Deploy to @alpha';
+is $plan->position, 1, 'Plan should be at position 1';
+is_deeply $engine->seen, [
+ [current_state => undef],
+ 'initialized',
+ 'initialize',
+ 'register_project',
+ [check_deploy_dependencies => [$plan, 1]],
+ [run_file => $changes[0]->deploy_file],
+ [log_deploy_change => $changes[0]],
+ [run_file => $changes[1]->deploy_file],
+ [log_deploy_change => $changes[1]],
+], 'Should have deployed through @alpha';
+
+is $deploy_meth, '_deploy_all', 'Should have called _deploy_all()';
+is_deeply +MockOutput->get_info, [
+ [__x 'Adding registry tables to {destination}',
+ destination => $engine->registry_destination,
+ ],
+ [__x 'Deploying changes through {change} to {destination}',
+ destination => $engine->destination,
+ change => $plan->get('@alpha')->format_name_with_tags,
+ ],
+ [__ 'ok'],
+ [__ 'ok'],
+], 'Should have seen the output of the deploy to @alpha';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '.......', ' '],
+ [' + users @alpha ..', '', ' '],
+], 'Both change names should be output';
+
+# Try with log-only in all modes.
+for my $mode (qw(change tag all)) {
+ ok $engine->log_only(1), 'Enable log_only';
+ ok $engine->deploy('@alpha', $mode, 1), 'Log-only deploy in $mode mode to @alpha';
+ is $plan->position, 1, 'Plan should be at position 1';
+ is_deeply $engine->seen, [
+ [current_state => undef],
+ 'initialized',
+ 'initialize',
+ 'register_project',
+ [check_deploy_dependencies => [$plan, 1]],
+ [log_deploy_change => $changes[0]],
+ [log_deploy_change => $changes[1]],
+ ], 'Should have deployed through @alpha without running files';
+
+ my $meth = $mode eq 'all' ? 'all' : ('by_' . $mode);
+ is $deploy_meth, "_deploy_$meth", "Should have called _deploy_$meth()";
+ is_deeply +MockOutput->get_info, [
+ [
+ __x 'Adding registry tables to {destination}',
+ destination => $engine->registry_destination,
+ ],
+ [
+ __x 'Deploying changes through {change} to {destination}',
+ destination => $engine->destination,
+ change => $plan->get('@alpha')->format_name_with_tags,
+ ],
+ [__ 'ok'],
+ [__ 'ok'],
+ ], 'Should have seen the output of the deploy to @alpha';
+ is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '.......', ' '],
+ [' + users @alpha ..', '', ' '],
+ ], 'Both change names should be output';
+}
+
+# Try with no need to initialize.
+$initialized = 1;
+$plan->reset;
+$engine->log_only(0);
+ok $engine->deploy('@alpha', 'tag'), 'Deploy to @alpha with tag mode';
+is $plan->position, 1, 'Plan should again be at position 1';
+is_deeply $engine->seen, [
+ [current_state => undef],
+ 'initialized',
+ 'register_project',
+ [check_deploy_dependencies => [$plan, 1]],
+ [run_file => $changes[0]->deploy_file],
+ [log_deploy_change => $changes[0]],
+ [run_file => $changes[1]->deploy_file],
+ [log_deploy_change => $changes[1]],
+], 'Should have deployed through @alpha without initialization';
+
+is $deploy_meth, '_deploy_by_tag', 'Should have called _deploy_by_tag()';
+is_deeply +MockOutput->get_info, [
+ [__x 'Deploying changes through {change} to {destination}',
+ destination => $engine->registry_destination,
+ change => $plan->get('@alpha')->format_name_with_tags,
+ ],
+ [__ 'ok'],
+ [__ 'ok'],
+], 'Should have seen the output of the deploy to @alpha';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '.......', ' '],
+ [' + users @alpha ..', '', ' '],
+], 'Both change names should be output';
+
+# Try a bogus change.
+throws_ok { $engine->deploy('nonexistent') } 'App::Sqitch::X',
+ 'Should get an error for an unknown change';
+is $@->message, __x(
+ 'Unknown change: "{change}"',
+ change => 'nonexistent',
+), 'The exception should report the unknown change';
+is_deeply $engine->seen, [
+ [current_state => undef],
+], 'Only latest_item() should have been called';
+
+# Start with @alpha.
+$latest_change_id = ($changes[1]->tags)[0]->id;
+ok $engine->deploy('@alpha'), 'Deploy to alpha thrice';
+is_deeply $engine->seen, [
+ [current_state => undef],
+ ['log_new_tags' => $changes[1]],
+], 'Only latest_item() should have been called';
+is_deeply +MockOutput->get_info, [
+ [__x 'Nothing to deploy (already at "{change}")', change => '@alpha'],
+], 'Should notify user that already at @alpha';
+
+# Start with widgets.
+$latest_change_id = $changes[2]->id;
+throws_ok { $engine->deploy('@alpha') } 'App::Sqitch::X',
+ 'Should fail changeing older change';
+is $@->ident, 'deploy', 'Should be a "deploy" error';
+is $@->message, __ 'Cannot deploy to an earlier change; use "revert" instead',
+ 'It should suggest using "revert"';
+is_deeply $engine->seen, [
+ [current_state => undef],
+ ['log_new_tags' => $changes[2]],
+], 'Should have called latest_item() and latest_tag()';
+
+# Make sure we can deploy everything by change.
+$latest_change_id = undef;
+$plan->reset;
+$plan->add( name => 'lolz', note => 'ha ha' );
+@changes = $plan->changes;
+ok $engine->deploy(undef, 'change'), 'Deploy everything by change';
+is $plan->position, 3, 'Plan should be at position 3';
+is_deeply $engine->seen, [
+ [current_state => undef],
+ 'initialized',
+ 'register_project',
+ [check_deploy_dependencies => [$plan, 3]],
+ [run_file => $changes[0]->deploy_file],
+ [log_deploy_change => $changes[0]],
+ [run_file => $changes[1]->deploy_file],
+ [log_deploy_change => $changes[1]],
+ [run_file => $changes[2]->deploy_file],
+ [log_deploy_change => $changes[2]],
+ [run_file => $changes[3]->deploy_file],
+ [log_deploy_change => $changes[3]],
+], 'Should have deployed everything';
+
+is $deploy_meth, '_deploy_by_change', 'Should have called _deploy_by_change()';
+is_deeply +MockOutput->get_info, [
+ [__x 'Deploying changes to {destination}', destination => $engine->destination ],
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+], 'Should have emitted deploy announcement and successes';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+ [' + widgets @beta ..', '', ' '],
+ [' + lolz ..', '.........', ' '],
+], 'Should have seen the output of the deploy to the end';
+
+# If we deploy again, it should be up-to-date.
+$latest_change_id = $changes[-1]->id;
+ok $engine->deploy, 'Should return success for deploy to up-to-date DB';
+is_deeply +MockOutput->get_info, [
+ [__ 'Nothing to deploy (up-to-date)' ],
+], 'Should have emitted deploy announcement and successes';
+is_deeply $engine->seen, [
+ [current_state => undef],
+], 'It should have just fetched the latest change ID';
+
+$latest_change_id = undef;
+
+# Try invalid mode.
+throws_ok { $engine->deploy(undef, 'evil_mode') } 'App::Sqitch::X',
+ 'Should fail on invalid mode';
+is $@->ident, 'deploy', 'Should be a "deploy" error';
+is $@->message, __x('Unknown deployment mode: "{mode}"', mode => 'evil_mode'),
+ 'And the message should reflect the unknown mode';
+is_deeply $engine->seen, [
+ [current_state => undef],
+ 'initialized',
+ 'register_project',
+ [check_deploy_dependencies => [$plan, 3]],
+], 'It should have check for initialization';
+is_deeply +MockOutput->get_info, [
+ [__x 'Deploying changes to {destination}', destination => $engine->destination ],
+], 'Should have announced destination';
+
+# Try a plan with no changes.
+NOSTEPS: {
+ my $plan_file = file qw(empty.plan);
+ my $fh = $plan_file->open('>') or die "Cannot open $plan_file: $!";
+ say $fh '%project=empty';
+ $fh->close or die "Error closing $plan_file: $!";
+ END { $plan_file->remove }
+ $config->update('core.plan_file' => $plan_file->stringify);
+ my $sqitch = App::Sqitch->new(config => $config);
+ my $target = App::Sqitch::Target->new(sqitch => $sqitch );
+ ok my $engine = App::Sqitch::Engine::whu->new(
+ sqitch => $sqitch,
+ target => $target,
+ ), 'Engine with sqitch with no file';
+ $engine->max_name_length(10);
+ throws_ok { $engine->deploy } 'App::Sqitch::X', 'Should die with no changes';
+ is $@->message, __"Nothing to deploy (empty plan)",
+ 'Should have the localized message';
+ is_deeply $engine->seen, [
+ [current_state => undef],
+ ], 'It should have checked for the latest item';
+}
+
+##############################################################################
+# Test _deploy_by_change()
+$engine = App::Sqitch::Engine::whu->new(sqitch => $sqitch, target => $target);
+$plan->reset;
+$mock_engine->unmock('_deploy_by_change');
+$engine->max_name_length(
+ max map {
+ length $_->format_name_with_tags
+ } $plan->changes
+);
+ok $engine->_deploy_by_change($plan, 1), 'Deploy changewise to index 1';
+is_deeply $engine->seen, [
+ [run_file => $changes[0]->deploy_file],
+ [log_deploy_change => $changes[0]],
+ [run_file => $changes[1]->deploy_file],
+ [log_deploy_change => $changes[1]],
+], 'Should changewise deploy to index 2';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+], 'Should have seen output of each change';
+is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']],
+ 'Output should reflect deploy successes';
+
+ok $engine->_deploy_by_change($plan, 3), 'Deploy changewise to index 2';
+is_deeply $engine->seen, [
+ [run_file => $changes[2]->deploy_file],
+ [log_deploy_change => $changes[2]],
+ [run_file => $changes[3]->deploy_file],
+ [log_deploy_change => $changes[3]],
+], 'Should changewise deploy to from index 2 to index 3';
+is_deeply +MockOutput->get_info_literal, [
+ [' + widgets @beta ..', '', ' '],
+ [' + lolz ..', '.........', ' '],
+], 'Should have seen output of changes 2-3';
+is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']],
+ 'Output should reflect deploy successes';
+
+# Make it die.
+$plan->reset;
+$die = 'run_file';
+throws_ok { $engine->_deploy_by_change($plan, 2) } 'App::Sqitch::X',
+ 'Die in _deploy_by_change';
+is $@->message, 'AAAH!', 'It should have died in run_file';
+is_deeply $engine->seen, [
+ [log_fail_change => $changes[0] ],
+], 'It should have logged the failure';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+], 'Should have seen output for first change';
+is_deeply +MockOutput->get_info, [[__ 'not ok']],
+ 'Output should reflect deploy failure';
+$die = '';
+
+##############################################################################
+# Test _deploy_by_tag().
+$plan->reset;
+$mock_engine->unmock('_deploy_by_tag');
+ok $engine->_deploy_by_tag($plan, 1), 'Deploy tagwise to index 1';
+
+is_deeply $engine->seen, [
+ [run_file => $changes[0]->deploy_file],
+ [log_deploy_change => $changes[0]],
+ [run_file => $changes[1]->deploy_file],
+ [log_deploy_change => $changes[1]],
+], 'Should tagwise deploy to index 1';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+], 'Should have seen output of each change';
+is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']],
+ 'Output should reflect deploy successes';
+
+ok $engine->_deploy_by_tag($plan, 3), 'Deploy tagwise to index 3';
+is_deeply $engine->seen, [
+ [run_file => $changes[2]->deploy_file],
+ [log_deploy_change => $changes[2]],
+ [run_file => $changes[3]->deploy_file],
+ [log_deploy_change => $changes[3]],
+], 'Should tagwise deploy from index 2 to index 3';
+is_deeply +MockOutput->get_info_literal, [
+ [' + widgets @beta ..', '', ' '],
+ [' + lolz ..', '.........', ' '],
+], 'Should have seen output of changes 3-3';
+is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']],
+ 'Output should reflect deploy successes';
+
+# Add another couple of changes.
+$plan->add(name => 'tacos' );
+$plan->add(name => 'curry' );
+@changes = $plan->changes;
+
+# Make it die.
+$plan->position(1);
+my $mock_whu = Test::MockModule->new('App::Sqitch::Engine::whu');
+$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[-1] });
+throws_ok { $engine->_deploy_by_tag($plan, $#changes) } 'App::Sqitch::X',
+ 'Die in log_deploy_change';
+is $@->message, __('Deploy failed'), 'Should get final deploy failure message';
+is_deeply $engine->seen, [
+ [run_file => $changes[2]->deploy_file],
+ [run_file => $changes[3]->deploy_file],
+ [run_file => $changes[4]->deploy_file],
+ [run_file => $changes[5]->deploy_file],
+ [run_file => $changes[5]->revert_file],
+ [log_fail_change => $changes[5] ],
+ [run_file => $changes[4]->revert_file],
+ [log_revert_change => $changes[4]],
+ [run_file => $changes[3]->revert_file],
+ [log_revert_change => $changes[3]],
+], 'It should have reverted back to the last deployed tag';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' + widgets @beta ..', '', ' '],
+ [' + lolz ..', '.........', ' '],
+ [' + tacos ..', '........', ' '],
+ [' + curry ..', '........', ' '],
+ [' - tacos ..', '........', ' '],
+ [' - lolz ..', '.........', ' '],
+], 'Should have seen deploy and revert messages (excluding curry revert)';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failure';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__x 'Reverting to {change}', change => 'widgets @beta']
+], 'The original error should have been vented';
+$mock_whu->unmock('log_deploy_change');
+
+# Make it die with log-only.
+$plan->position(1);
+ok $engine->log_only(1), 'Enable log_only';
+$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[-1] });
+throws_ok { $engine->_deploy_by_tag($plan, $#changes, 1) } 'App::Sqitch::X',
+ 'Die in log_deploy_change log-only';
+is $@->message, __('Deploy failed'), 'Should get final deploy failure message';
+is_deeply $engine->seen, [
+ [log_fail_change => $changes[5] ],
+ [log_revert_change => $changes[4]],
+ [log_revert_change => $changes[3]],
+], 'It should have run no deploy or revert scripts';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' + widgets @beta ..', '', ' '],
+ [' + lolz ..', '.........', ' '],
+ [' + tacos ..', '........', ' '],
+ [' + curry ..', '........', ' '],
+ [' - tacos ..', '........', ' '],
+ [' - lolz ..', '.........', ' '],
+], 'Should have seen deploy and revert messages (excluding curry revert)';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failure';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__x 'Reverting to {change}', change => 'widgets @beta']
+], 'The original error should have been vented';
+$mock_whu->unmock('log_deploy_change');
+
+# Now have it fail back to the beginning.
+$plan->reset;
+$engine->log_only(0);
+$mock_whu->mock(run_file => sub { die 'ROFL' if $_[1]->basename eq 'users.sql' });
+throws_ok { $engine->_deploy_by_tag($plan, $plan->count -1 ) } 'App::Sqitch::X',
+ 'Die in _deploy_by_tag again';
+is $@->message, __('Deploy failed'), 'Should again get final deploy failure message';
+is_deeply $engine->seen, [
+ [log_deploy_change => $changes[0]],
+ [log_fail_change => $changes[1]],
+ [log_revert_change => $changes[0]],
+], 'Should have logged back to the beginning';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'Should have seen deploy and revert messages';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failure';
+my $vented = MockOutput->get_vent;
+is @{ $vented }, 2, 'Should have one vented message';
+my $errmsg = shift @{ $vented->[0] };
+like $errmsg, qr/^ROFL\b/, 'And it should be the underlying error';
+is_deeply $vented, [
+ [],
+ [__ 'Reverting all changes'],
+], 'And it should had notified that all changes were reverted';
+
+# Add a change and deploy to that, to make sure it rolls back any changes since
+# last tag.
+$plan->add(name => 'dr_evil' );
+@changes = $plan->changes;
+$plan->reset;
+$mock_whu->mock(run_file => sub { hurl 'ROFL' if $_[1]->basename eq 'dr_evil.sql' });
+throws_ok { $engine->_deploy_by_tag($plan, $plan->count -1 ) } 'App::Sqitch::X',
+ 'Die in _deploy_by_tag yet again';
+is $@->message, __('Deploy failed'), 'Should die "Deploy failed" again';
+is_deeply $engine->seen, [
+ [log_deploy_change => $changes[0]],
+ [log_deploy_change => $changes[1]],
+ [log_deploy_change => $changes[2]],
+ [log_deploy_change => $changes[3]],
+ [log_deploy_change => $changes[4]],
+ [log_deploy_change => $changes[5]],
+ [log_fail_change => $changes[6]],
+ [log_revert_change => $changes[5] ],
+ [log_revert_change => $changes[4] ],
+ [log_revert_change => $changes[3] ],
+], 'Should have reverted back to last tag';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+ [' + widgets @beta ..', '', ' '],
+ [' + lolz ..', '.........', ' '],
+ [' + tacos ..', '........', ' '],
+ [' + curry ..', '........', ' '],
+ [' + dr_evil ..', '......', ' '],
+ [' - curry ..', '........', ' '],
+ [' - tacos ..', '........', ' '],
+ [' - lolz ..', '.........', ' '],
+], 'Should have user change reversion messages';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failure';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__x 'Reverting to {change}', change => 'widgets @beta']
+], 'Should see underlying error and reversion message';
+
+# Make it choke on change reversion.
+$mock_whu->unmock_all;
+$die = '';
+$plan->reset;
+$mock_whu->mock(run_file => sub {
+ hurl 'ROFL' if $_[1] eq $changes[1]->deploy_file;
+ hurl 'BARF' if $_[1] eq $changes[0]->revert_file;
+});
+$mock_whu->mock(start_at => 'whatever');
+throws_ok { $engine->_deploy_by_tag($plan, $plan->count -1 ) } 'App::Sqitch::X',
+ 'Die in _deploy_by_tag again';
+is $@->message, __('Deploy failed'), 'Should once again get final deploy failure message';
+is_deeply $engine->seen, [
+ [log_deploy_change => $changes[0] ],
+ [log_fail_change => $changes[1] ],
+], 'Should have tried to revert one change';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'Should have seen revert message';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'not ok' ],
+], 'Output should reflect deploy successes and failure';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__x 'Reverting to {change}', change => 'whatever'],
+ ['BARF'],
+ [__ 'The schema will need to be manually repaired']
+], 'Should get reversion failure message';
+$mock_whu->unmock_all;
+
+##############################################################################
+# Test _deploy_all().
+$plan->reset;
+$mock_engine->unmock('_deploy_all');
+ok $engine->_deploy_all($plan, 1), 'Deploy all to index 1';
+
+is_deeply $engine->seen, [
+ [run_file => $changes[0]->deploy_file],
+ [log_deploy_change => $changes[0]],
+ [run_file => $changes[1]->deploy_file],
+ [log_deploy_change => $changes[1]],
+], 'Should tagwise deploy to index 1';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+], 'Should have seen output of each change';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes';
+
+ok $engine->_deploy_all($plan, 2), 'Deploy tagwise to index 2';
+is_deeply $engine->seen, [
+ [run_file => $changes[2]->deploy_file],
+ [log_deploy_change => $changes[2]],
+], 'Should tagwise deploy to from index 1 to index 2';
+is_deeply +MockOutput->get_info_literal, [
+ [' + widgets @beta ..', '', ' '],
+], 'Should have seen output of changes 3-4';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+], 'Output should reflect deploy successe';
+
+# Make it die.
+$plan->reset;
+$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[2] });
+throws_ok { $engine->_deploy_all($plan, 3) } 'App::Sqitch::X',
+ 'Die in _deploy_all';
+is $@->message, __('Deploy failed'), 'Should get final deploy failure message';
+$mock_whu->unmock('log_deploy_change');
+is_deeply $engine->seen, [
+ [run_file => $changes[0]->deploy_file],
+ [run_file => $changes[1]->deploy_file],
+ [run_file => $changes[2]->deploy_file],
+ [run_file => $changes[2]->revert_file],
+ [log_fail_change => $changes[2]],
+ [run_file => $changes[1]->revert_file],
+ [log_revert_change => $changes[1]],
+ [run_file => $changes[0]->revert_file],
+ [log_revert_change => $changes[0]],
+], 'It should have logged up to the failure';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+ [' + widgets @beta ..', '', ' '],
+ [' - users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'Should have seen deploy and revert messages excluding revert for failed logging';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failures';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__ 'Reverting all changes'],
+], 'The original error should have been vented';
+$die = '';
+
+# Make it die with log-only.
+$plan->reset;
+ok $engine->log_only(1), 'Enable log_only';
+$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[2] });
+throws_ok { $engine->_deploy_all($plan, 3, 1) } 'App::Sqitch::X',
+ 'Die in log-only _deploy_all';
+is $@->message, __('Deploy failed'), 'Should get final deploy failure message';
+$mock_whu->unmock('log_deploy_change');
+is_deeply $engine->seen, [
+ [log_fail_change => $changes[2]],
+ [log_revert_change => $changes[1]],
+ [log_revert_change => $changes[0]],
+], 'It should have run no deploys or reverts';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+ [' + widgets @beta ..', '', ' '],
+ [' - users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'Should have seen deploy and revert messages excluding revert for failed logging';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failures';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__ 'Reverting all changes'],
+], 'The original error should have been vented';
+$die = '';
+
+# Now have it fail on a later change, should still go all the way back.
+$plan->reset;
+$engine->log_only(0);
+$mock_whu->mock(run_file => sub { hurl 'ROFL' if $_[1]->basename eq 'widgets.sql' });
+throws_ok { $engine->_deploy_all($plan, $plan->count -1 ) } 'App::Sqitch::X',
+ 'Die in _deploy_all again';
+is $@->message, __('Deploy failed'), 'Should again get final deploy failure message';
+is_deeply $engine->seen, [
+ [log_deploy_change => $changes[0]],
+ [log_deploy_change => $changes[1]],
+ [log_fail_change => $changes[2]],
+ [log_revert_change => $changes[1]],
+ [log_revert_change => $changes[0]],
+], 'Should have reveted all changes and tags';
+is_deeply +MockOutput->get_info_literal, [
+ [' + roles ..', '........', ' '],
+ [' + users @alpha ..', '.', ' '],
+ [' + widgets @beta ..', '', ' '],
+ [' - users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'Should see all changes revert';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failures';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__ 'Reverting all changes'],
+], 'Should notifiy user of error and rollback';
+
+# Die when starting from a later point.
+$plan->position(2);
+$engine->start_at('@alpha');
+$mock_whu->mock(run_file => sub { hurl 'ROFL' if $_[1]->basename eq 'dr_evil.sql' });
+throws_ok { $engine->_deploy_all($plan, $plan->count -1 ) } 'App::Sqitch::X',
+ 'Die in _deploy_all on the last change';
+is $@->message, __('Deploy failed'), 'Should once again get final deploy failure message';
+is_deeply $engine->seen, [
+ [log_deploy_change => $changes[3]],
+ [log_deploy_change => $changes[4]],
+ [log_deploy_change => $changes[5]],
+ [log_fail_change => $changes[6]],
+ [log_revert_change => $changes[5]],
+ [log_revert_change => $changes[4]],
+ [log_revert_change => $changes[3]],
+], 'Should have deployed to dr_evil and revered down to @alpha';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' + lolz ..', '.........', ' '],
+ [' + tacos ..', '........', ' '],
+ [' + curry ..', '........', ' '],
+ [' + dr_evil ..', '......', ' '],
+ [' - curry ..', '........', ' '],
+ [' - tacos ..', '........', ' '],
+ [' - lolz ..', '.........', ' '],
+], 'Should see changes revert back to @alpha';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'not ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+ [__ 'ok' ],
+], 'Output should reflect deploy successes and failures';
+is_deeply +MockOutput->get_vent, [
+ ['ROFL'],
+ [__x 'Reverting to {change}', change => '@alpha'],
+], 'Should notifiy user of error and rollback to @alpha';
+$mock_whu->unmock_all;
+
+##############################################################################
+# Test is_deployed().
+my $tag = App::Sqitch::Plan::Tag->new(
+ name => 'foo',
+ change => $change,
+ plan => $target->plan,
+);
+$is_deployed_tag = $is_deployed_change = 1;
+ok $engine->is_deployed($tag), 'Test is_deployed(tag)';
+is_deeply $engine->seen, [
+ [is_deployed_tag => $tag],
+], 'It should have called is_deployed_tag()';
+
+ok $engine->is_deployed($change), 'Test is_deployed(change)';
+is_deeply $engine->seen, [
+ [is_deployed_change => $change],
+], 'It should have called is_deployed_change()';
+
+##############################################################################
+# Test deploy_change.
+can_ok $engine, 'deploy_change';
+ok $engine->deploy_change($change), 'Deploy a change';
+is_deeply $engine->seen, [
+ [run_file => $change->deploy_file],
+ [log_deploy_change => $change],
+], 'It should have been deployed';
+is_deeply +MockOutput->get_info_literal, [
+ [' + foo ..', '..........', ' ']
+], 'Should have shown change name';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok' ],
+], 'Output should reflect deploy success';
+
+my $make_deps = sub {
+ my $conflicts = shift;
+ return map {
+ my $dep = App::Sqitch::Plan::Depend->new(
+ change => $_,
+ plan => $plan,
+ project => $plan->project,
+ conflicts => $conflicts,
+ );
+ $dep;
+ } @_;
+};
+
+DEPLOYDIE: {
+ my $mock_depend = Test::MockModule->new('App::Sqitch::Plan::Depend');
+ $mock_depend->mock(id => sub { undef });
+
+ # Now make it die on the actual deploy.
+ $die = 'log_deploy_change';
+ my @requires = $make_deps->( 0, qw(foo bar) );
+ my @conflicts = $make_deps->( 1, qw(dr_evil) );
+ my $change = App::Sqitch::Plan::Change->new(
+ name => 'foo',
+ plan => $target->plan,
+ requires => \@requires,
+ conflicts => \@conflicts,
+ );
+ throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X',
+ 'Shuld die on deploy failure';
+ is $@->message, __ 'Deploy failed', 'Should be told the deploy failed';
+ is_deeply $engine->seen, [
+ [run_file => $change->deploy_file],
+ [run_file => $change->revert_file],
+ [log_fail_change => $change],
+ ], 'It should failed to have been deployed';
+ is_deeply +MockOutput->get_vent, [
+ ['AAAH!'],
+ ], 'Should have vented the original error';
+ is_deeply +MockOutput->get_info_literal, [
+ [' + foo ..', '..........', ' '],
+ ], 'Should have shown change name';
+ is_deeply +MockOutput->get_info, [
+ [__ 'not ok' ],
+ ], 'Output should reflect deploy failure';
+ $die = '';
+}
+
+##############################################################################
+# Test revert_change().
+can_ok $engine, 'revert_change';
+ok $engine->revert_change($change), 'Revert the change';
+is_deeply $engine->seen, [
+ [run_file => $change->revert_file],
+ [log_revert_change => $change],
+], 'It should have been reverted';
+is_deeply +MockOutput->get_info_literal, [
+ [' - foo ..', '..........', ' ']
+], 'Should have shown reverted change name';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok'],
+], 'And the revert failure should be "ok"';
+
+##############################################################################
+# Test revert().
+can_ok $engine, 'revert';
+$engine->plan($plan);
+
+# Start with no deployed IDs.
+@deployed_changes = ();
+ok $engine->revert,
+ 'Should return success for no changes to revert';
+is_deeply +MockOutput->get_info, [
+ [__ 'Nothing to revert (nothing deployed)']
+], 'Should have notified that there is nothing to revert';
+is_deeply $engine->seen, [
+ [deployed_changes => undef],
+], 'It should only have called deployed_changes()';
+is_deeply +MockOutput->get_info, [], 'Nothing should have been output';
+
+# Try reverting to an unknown change.
+throws_ok { $engine->revert('nonexistent') } 'App::Sqitch::X',
+ 'Revert should die on unknown change';
+is $@->ident, 'revert', 'Should be another "revert" error';
+is $@->message, __x(
+ 'Unknown change: "{change}"',
+ change => 'nonexistent',
+), 'The message should mention it is an unknown change';
+is_deeply $engine->seen, [['change_id_for', {
+ change_id => undef,
+ change => 'nonexistent',
+ tag => undef,
+ project => 'sql',
+}]], 'Should have called change_id_for() with change name';
+is_deeply +MockOutput->get_info, [], 'Nothing should have been output';
+
+# Try reverting to an unknown change ID.
+throws_ok { $engine->revert('8d77c5f588b60bc0f2efcda6369df5cb0177521d') } 'App::Sqitch::X',
+ 'Revert should die on unknown change ID';
+is $@->ident, 'revert', 'Should be another "revert" error';
+is $@->message, __x(
+ 'Unknown change: "{change}"',
+ change => '8d77c5f588b60bc0f2efcda6369df5cb0177521d',
+), 'The message should mention it is an unknown change';
+is_deeply $engine->seen, [['change_id_for', {
+ change_id => '8d77c5f588b60bc0f2efcda6369df5cb0177521d',
+ change => undef,
+ tag => undef,
+ project => 'sql',
+}]], 'Should have called change_id_for() with change ID';
+is_deeply +MockOutput->get_info, [], 'Nothing should have been output';
+
+# Revert an undeployed change.
+throws_ok { $engine->revert('@alpha') } 'App::Sqitch::X',
+ 'Revert should die on undeployed change';
+is $@->ident, 'revert', 'Should be another "revert" error';
+is $@->message, __x(
+ 'Change not deployed: "{change}"',
+ change => '@alpha',
+), 'The message should mention that the change is not deployed';
+is_deeply $engine->seen, [['change_id_for', {
+ change => '',
+ change_id => undef,
+ tag => 'alpha',
+ project => 'sql',
+}]], 'change_id_for';
+is_deeply +MockOutput->get_info, [], 'Nothing should have been output';
+
+# Revert to a point with no following changes.
+$offset_change = $changes[0];
+push @resolved => $offset_change->id;
+ok $engine->revert($changes[0]->id),
+ 'Should return success for revert even with no changes';
+is_deeply +MockOutput->get_info, [
+ [__x(
+ 'No changes deployed since: "{change}"',
+ change => $changes[0]->id,
+ )]
+], 'No subsequent change error message should be correct';
+
+delete $changes[0]->{_rework_tags}; # For deep comparison.
+is_deeply $engine->seen, [
+ [change_id_for => {
+ change_id => $changes[0]->id,
+ change => undef,
+ tag => undef,
+ project => 'sql',
+ }],
+ [ change_offset_from_id => [$changes[0]->id, 0] ],
+ [deployed_changes_since => $changes[0]],
+], 'Should have called change_id_for and deployed_changes_since';
+
+# Revert with nothing deployed.
+ok $engine->revert,
+ 'Should return success for known but undeployed change';
+is_deeply +MockOutput->get_info, [
+ [__ 'Nothing to revert (nothing deployed)']
+], 'No changes message should be correct';
+
+is_deeply $engine->seen, [
+ [deployed_changes => undef],
+], 'Should have called deployed_changes';
+
+# Now revert from a deployed change.
+my @dbchanges;
+@deployed_changes = map {
+ my $plan_change = $_;
+ my $params = {
+ id => $plan_change->id,
+ name => $plan_change->name,
+ project => $plan_change->project,
+ note => $plan_change->note,
+ planner_name => $plan_change->planner_name,
+ planner_email => $plan_change->planner_email,
+ timestamp => $plan_change->timestamp,
+ tags => [ map { $_->name } $plan_change->tags ],
+ };
+ push @dbchanges => my $db_change = App::Sqitch::Plan::Change->new(
+ plan => $plan,
+ %{ $params },
+ );
+ $db_change->add_tag( App::Sqitch::Plan::Tag->new(
+ name => $_->name, plan => $plan, change => $db_change
+ ) ) for $plan_change->tags;
+ $db_change->tags; # Autovivify _tags For changes with no tags.
+ $params;
+} @changes[0..3];
+
+MockOutput->ask_yes_no_returns(1);
+ok $engine->revert, 'Revert all changes';
+is_deeply $engine->seen, [
+ [deployed_changes => undef],
+ [check_revert_dependencies => [reverse @dbchanges[0..3]] ],
+ [run_file => $dbchanges[3]->revert_file ],
+ [log_revert_change => $dbchanges[3] ],
+ [run_file => $dbchanges[2]->revert_file ],
+ [log_revert_change => $dbchanges[2] ],
+ [run_file => $dbchanges[1]->revert_file ],
+ [log_revert_change => $dbchanges[1] ],
+ [run_file => $dbchanges[0]->revert_file ],
+ [log_revert_change => $dbchanges[0] ],
+], 'Should have reverted the changes in reverse order';
+is_deeply +MockOutput->get_ask_yes_no, [
+ [__x(
+ 'Revert all changes from {destination}?',
+ destination => $engine->destination,
+ ), 1],
+], 'Should have prompted to revert all changes';
+is_deeply +MockOutput->get_info_literal, [
+ [' - lolz ..', '.........', ' '],
+ [' - widgets @beta ..', '', ' '],
+ [' - users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'It should have said it was reverting all changes and listed them';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+], 'And the revert successes should be emitted';
+
+# Try with log-only.
+ok $engine->log_only(1), 'Enable log_only';
+ok $engine->revert(undef, 1), 'Revert all changes log-only';
+delete @{ $_ }{qw(_path_segments _rework_tags)} for @dbchanges; # These need to be invisible.
+is_deeply $engine->seen, [
+ [deployed_changes => undef],
+ [check_revert_dependencies => [reverse @dbchanges[0..3]] ],
+ [log_revert_change => $dbchanges[3] ],
+ [log_revert_change => $dbchanges[2] ],
+ [log_revert_change => $dbchanges[1] ],
+ [log_revert_change => $dbchanges[0] ],
+], 'Log-only Should have reverted the changes in reverse order';
+is_deeply +MockOutput->get_ask_yes_no, [
+ [__x(
+ 'Revert all changes from {destination}?',
+ destination => $engine->destination,
+ ), 1],
+], 'Log-only should have prompted to revert all changes';
+is_deeply +MockOutput->get_info_literal, [
+ [' - lolz ..', '.........', ' '],
+ [' - widgets @beta ..', '', ' '],
+ [' - users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'It should have said it was reverting all changes and listed them';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+], 'And the revert successes should be emitted';
+
+# Should exit if the revert is declined.
+MockOutput->ask_yes_no_returns(0);
+throws_ok { $engine->revert } 'App::Sqitch::X', 'Should abort declined revert';
+is $@->ident, 'revert', 'Declined revert ident should be "revert"';
+is $@->exitval, 1, 'Should have exited with value 1';
+is $@->message, __ 'Nothing reverted', 'Should have exited with proper message';
+is_deeply $engine->seen, [
+ [deployed_changes => undef],
+], 'Should have called deployed_changes only';
+is_deeply +MockOutput->get_ask_yes_no, [
+ [__x(
+ 'Revert all changes from {destination}?',
+ destination => $engine->destination,
+ ), 1],
+], 'Should have prompt to revert all changes';
+is_deeply +MockOutput->get_info, [
+], 'It should have emitted nothing else';
+
+# Revert all changes with no prompt.
+MockOutput->ask_yes_no_returns(1);
+$engine->log_only(0);
+$engine->no_prompt(1);
+ok $engine->revert, 'Revert all changes with no prompt';
+is_deeply $engine->seen, [
+ [deployed_changes => undef],
+ [check_revert_dependencies => [reverse @dbchanges[0..3]] ],
+ [run_file => $dbchanges[3]->revert_file ],
+ [log_revert_change => $dbchanges[3] ],
+ [run_file => $dbchanges[2]->revert_file ],
+ [log_revert_change => $dbchanges[2] ],
+ [run_file => $dbchanges[1]->revert_file ],
+ [log_revert_change => $dbchanges[1] ],
+ [run_file => $dbchanges[0]->revert_file ],
+ [log_revert_change => $dbchanges[0] ],
+], 'Should have reverted the changes in reverse order';
+is_deeply +MockOutput->get_ask_yes_no, [], 'Should have no prompt';
+
+is_deeply +MockOutput->get_info_literal, [
+ [' - lolz ..', '.........', ' '],
+ [' - widgets @beta ..', '', ' '],
+ [' - users @alpha ..', '.', ' '],
+ [' - roles ..', '........', ' '],
+], 'It should have said it was reverting all changes and listed them';
+is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Reverting all changes from {destination}',
+ destination => $engine->destination,
+ )],
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+ [__ 'ok'],
+], 'And the revert successes should be emitted';
+
+# Now just revert to an earlier change.
+$engine->no_prompt(0);
+$offset_change = $dbchanges[1];
+push @resolved => $offset_change->id;
+@deployed_changes = @deployed_changes[2..3];
+ok $engine->revert('@alpha'), 'Revert to @alpha';
+
+delete $dbchanges[1]->{_rework_tags}; # These need to be invisible.
+is_deeply $engine->seen, [
+ [change_id_for => { change_id => undef, change => '', tag => 'alpha', project => 'sql' }],
+ [ change_offset_from_id => [$dbchanges[1]->id, 0] ],
+ [deployed_changes_since => $dbchanges[1]],
+ [check_revert_dependencies => [reverse @dbchanges[2..3]] ],
+ [run_file => $dbchanges[3]->revert_file ],
+ [log_revert_change => $dbchanges[3] ],
+ [run_file => $dbchanges[2]->revert_file ],
+ [log_revert_change => $dbchanges[2] ],
+], 'Should have reverted only changes after @alpha';
+is_deeply +MockOutput->get_ask_yes_no, [
+ [__x(
+ 'Revert changes to {change} from {destination}?',
+ destination => $engine->destination,
+ change => $dbchanges[1]->format_name_with_tags,
+ ), 1],
+], 'Should have prompt to revert to change';
+is_deeply +MockOutput->get_info_literal, [
+ [' - lolz ..', '.........', ' '],
+ [' - widgets @beta ..', '', ' '],
+], 'Output should show what it reverts to';
+is_deeply +MockOutput->get_info, [
+ [__ 'ok'],
+ [__ 'ok'],
+], 'And the revert successes should be emitted';
+
+MockOutput->ask_yes_no_returns(0);
+$offset_change = $dbchanges[1];
+push @resolved => $offset_change->id;
+throws_ok { $engine->revert('@alpha') } 'App::Sqitch::X',
+ 'Should abort declined revert to @alpha';
+is $@->ident, 'revert:confirm', 'Declined revert ident should be "revert:confirm"';
+is $@->exitval, 1, 'Should have exited with value 1';
+is $@->message, __ 'Nothing reverted', 'Should have exited with proper message';
+is_deeply $engine->seen, [
+ [change_id_for => { change_id => undef, change => '', tag => 'alpha', project => 'sql' }],
+ [change_offset_from_id => [$dbchanges[1]->id, 0] ],
+ [deployed_changes_since => $dbchanges[1]],
+], 'Should have called revert methods';
+is_deeply +MockOutput->get_ask_yes_no, [
+ [__x(
+ 'Revert changes to {change} from {destination}?',
+ change => $dbchanges[1]->format_name_with_tags,
+ destination => $engine->destination,
+ ), 1],
+], 'Should have prompt to revert to @alpha';
+is_deeply +MockOutput->get_info, [
+], 'It should have emitted nothing else';
+
+# Try to revert just the last change with no prompt
+MockOutput->ask_yes_no_returns(1);
+$engine->no_prompt(1);
+my $rev_file = $dbchanges[-1]->revert_file; # Grab before deleting _rework_tags.
+my $rtags = delete $dbchanges[-1]->{_rework_tags}; # These need to be invisible.
+$offset_change = $dbchanges[-1];
+push @resolved => $offset_change->id;
+@deployed_changes = $deployed_changes[-1];
+ok $engine->revert('@HEAD^'), 'Revert to @HEAD^';
+is_deeply $engine->seen, [
+ [change_id_for => { change_id => undef, change => '', tag => 'HEAD', project => 'sql' }],
+ [change_offset_from_id => [$dbchanges[-1]->id, -1] ],
+ [deployed_changes_since => $dbchanges[-1]],
+ [check_revert_dependencies => [{ %{ $dbchanges[-1] }, _rework_tags => $rtags }] ],
+ [run_file => $rev_file ],
+ [log_revert_change => { %{ $dbchanges[-1] }, _rework_tags => $rtags } ],
+], 'Should have reverted one changes for @HEAD^';
+is_deeply +MockOutput->get_ask_yes_no, [], 'Should have no prompt';
+is_deeply +MockOutput->get_info_literal, [
+ [' - lolz ..', '', ' '],
+], 'Output should show what it reverts to';
+is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Reverting changes to {change} from {destination}',
+ destination => $engine->destination,
+ change => $dbchanges[-1]->format_name_with_tags,
+ )],
+ [__ 'ok'],
+], 'And the header and "ok" should be emitted';
+
+##############################################################################
+# Test change_id_for_depend().
+can_ok $CLASS, 'change_id_for_depend';
+
+$offset_change = $dbchanges[1];
+my ($dep) = $make_deps->( 1, 'foo' );
+throws_ok { $engine->change_id_for_depend( $dep ) } 'App::Sqitch::X',
+ 'Should get error from change_id_for_depend when change not in plan';
+is $@->ident, 'plan', 'Should get ident "plan" from change_id_for_depend';
+is $@->message, __x(
+ 'Unable to find change "{change}" in plan {file}',
+ change => $dep->key_name,
+ file => $target->plan_file,
+), 'Should have proper message from change_id_for_depend error';
+
+PLANOK: {
+ my $mock_depend = Test::MockModule->new('App::Sqitch::Plan::Depend');
+ $mock_depend->mock(id => sub { undef });
+ $mock_depend->mock(change => sub { undef });
+ throws_ok { $engine->change_id_for_depend( $dep ) } 'App::Sqitch::X',
+ 'Should get error from change_id_for_depend when no ID';
+ is $@->ident, 'engine', 'Should get ident "engine" when no ID';
+ is $@->message, __x(
+ 'Invalid dependency: {dependency}',
+ dependency => $dep->as_string,
+ ), 'Should have proper messag from change_id_for_depend error';
+
+ # Let it have the change.
+ $mock_depend->unmock('change');
+
+ push @resolved => $changes[1]->id;
+ is $engine->change_id_for_depend( $dep ), $changes[1]->id,
+ 'Get a change id';
+ is_deeply $engine->seen, [
+ [change_id_for => {
+ change_id => $dep->id,
+ change => $dep->change,
+ tag => $dep->tag,
+ project => $dep->project,
+ first => 1,
+ }],
+ ], 'Should have passed dependency params to change_id_for()';
+}
+
+##############################################################################
+# Test find_change().
+can_ok $CLASS, 'find_change';
+push @resolved => $dbchanges[1]->id;
+is $engine->find_change(
+ change_id => $resolved[0],
+ change => 'hi',
+ tag => 'yo',
+), $dbchanges[1], 'find_change() should work';
+is_deeply $engine->seen, [
+ [change_id_for => {
+ change_id => $dbchanges[1]->id,
+ change => 'hi',
+ tag => 'yo',
+ project => 'sql',
+ }],
+ [change_offset_from_id => [ $dbchanges[1]->id, undef ]],
+], 'Its parameters should have been passed to change_id_for and change_offset_from_id';
+
+# Pass a project and an ofset.
+push @resolved => $dbchanges[1]->id;
+is $engine->find_change(
+ change => 'hi',
+ offset => 1,
+ project => 'fred',
+), $dbchanges[1], 'find_change() should work';
+is_deeply $engine->seen, [
+ [change_id_for => {
+ change_id => undef,
+ change => 'hi',
+ tag => undef,
+ project => 'fred',
+ }],
+ [change_offset_from_id => [ $dbchanges[1]->id, 1 ]],
+], 'Project and offset should have been passed off';
+
+##############################################################################
+# Test find_change_id().
+can_ok $CLASS, 'find_change_id';
+push @resolved => $dbchanges[1]->id;
+is $engine->find_change_id(
+ change_id => $resolved[0],
+ change => 'hi',
+ tag => 'yo',
+), $dbchanges[1]->id, 'find_change_id() should work';
+is_deeply $engine->seen, [
+ [change_id_for => {
+ change_id => $dbchanges[1]->id,
+ change => 'hi',
+ tag => 'yo',
+ project => 'sql',
+ }],
+ [change_id_offset_from_id => [ $dbchanges[1]->id, undef ]],
+], 'Its parameters should have been passed to change_id_for and change_offset_from_id';
+
+# Pass a project and an ofset.
+push @resolved => $dbchanges[1]->id;
+is $engine->find_change_id(
+ change => 'hi',
+ offset => 1,
+ project => 'fred',
+), $dbchanges[1]->id, 'find_change_id() should work';
+is_deeply $engine->seen, [
+ [change_id_for => {
+ change_id => undef,
+ change => 'hi',
+ tag => undef,
+ project => 'fred',
+ }],
+ [change_id_offset_from_id => [ $dbchanges[1]->id, 1 ]],
+], 'Project and offset should have been passed off';
+
+##############################################################################
+# Test verify_change().
+can_ok $CLASS, 'verify_change';
+$change = App::Sqitch::Plan::Change->new( name => 'users', plan => $target->plan );
+ok $engine->verify_change($change), 'Verify a change';
+is_deeply $engine->seen, [
+ [run_file => $change->verify_file ],
+], 'The change file should have been run';
+is_deeply +MockOutput->get_info, [], 'Should have no info output';
+
+# Try a change with no verify script.
+$change = App::Sqitch::Plan::Change->new( name => 'roles', plan => $target->plan );
+ok $engine->verify_change($change), 'Verify a change with no verify script.';
+is_deeply $engine->seen, [], 'No abstract methods should be called';
+is_deeply +MockOutput->get_info, [], 'Should have no info output';
+is_deeply +MockOutput->get_vent, [
+ [__x 'Verify script {file} does not exist', file => $change->verify_file],
+], 'A warning about no verify file should have been emitted';
+
+##############################################################################
+# Test check_deploy_dependenices().
+$mock_engine->unmock('check_deploy_dependencies');
+can_ok $engine, 'check_deploy_dependencies';
+
+CHECK_DEPLOY_DEPEND: {
+ # Make sure dependencies check out for all the existing changes.
+ $plan->reset;
+ ok $engine->check_deploy_dependencies($plan),
+ 'All planned changes should be okay';
+ is_deeply $engine->seen, [
+ [ are_deployed_changes => [map { $plan->change_at($_) } 0..$plan->count - 1] ],
+ ], 'Should have called are_deployed_changes';
+
+ # Make sure it works when depending on a previous change.
+ my $change = $plan->change_at(3);
+ push @{ $change->_requires } => $make_deps->( 0, 'users' );
+ ok $engine->check_deploy_dependencies($plan),
+ 'Dependencies should check out even when within those to be deployed';
+ is_deeply [ map { $_->resolved_id } map { $_->requires } $plan->changes ],
+ [ $plan->change_at(1)->id ],
+ 'Resolved ID should be populated';
+
+ # Make sure it fails if there is a conflict within those to be deployed.
+ push @{ $change->_conflicts } => $make_deps->( 1, 'widgets' );
+ throws_ok { $engine->check_deploy_dependencies($plan) } 'App::Sqitch::X',
+ 'Conflict should throw exception';
+ is $@->ident, 'deploy', 'Should be a "deploy" error';
+ is $@->message, __nx(
+ 'Conflicts with previously deployed change: {changes}',
+ 'Conflicts with previously deployed changes: {changes}',
+ scalar 1,
+ changes => 'widgets',
+ ), 'Should have localized message about the local conflict';
+ shift @{ $change->_conflicts };
+
+ # Now test looking stuff up in the database.
+ my $mock_depend = Test::MockModule->new('App::Sqitch::Plan::Depend');
+ my @depend_ids;
+ $mock_depend->mock(id => sub { shift @depend_ids });
+
+ my @conflicts = $make_deps->( 1, qw(foo bar) );
+ $change = App::Sqitch::Plan::Change->new(
+ name => 'foo',
+ plan => $target->plan,
+ conflicts => \@conflicts,
+ );
+ $plan->_changes->append($change);
+
+ my $start_from = $plan->count - 1;
+ $plan->position( $start_from - 1);
+ push @resolved, '2342', '253245';
+ throws_ok { $engine->check_deploy_dependencies($plan, $start_from) } 'App::Sqitch::X',
+ 'Conflict should throw exception';
+ is $@->ident, 'deploy', 'Should be a "deploy" error';
+ is $@->message, __nx(
+ 'Conflicts with previously deployed change: {changes}',
+ 'Conflicts with previously deployed changes: {changes}',
+ scalar 2,
+ changes => 'foo bar',
+ ), 'Should have localized message about conflicts';
+
+ is_deeply $engine->seen, [
+ [ are_deployed_changes => [map { $plan->change_at($_) } 0..$start_from-1] ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'foo',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'bar',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ ], 'Should have called change_id_for() twice';
+ is_deeply [ map { $_->resolved_id } @conflicts ], [undef, undef],
+ 'Conflicting dependencies should have no resolved IDs';
+
+ # Fail with multiple conflicts.
+ push @{ $plan->change_at(3)->_conflicts } => $make_deps->( 1, 'widgets' );
+ $plan->reset;
+ push @depend_ids => $plan->change_at(2)->id;
+ push @resolved, '2342', '253245', '2323434';
+ throws_ok { $engine->check_deploy_dependencies($plan) } 'App::Sqitch::X',
+ 'Conflict should throw another exception';
+ is $@->ident, 'deploy', 'Should be a "deploy" error';
+ is $@->message, __nx(
+ 'Conflicts with previously deployed change: {changes}',
+ 'Conflicts with previously deployed changes: {changes}',
+ scalar 3,
+ changes => 'widgets foo bar',
+ ), 'Should have localized message about all three conflicts';
+
+ is_deeply $engine->seen, [
+ [ change_id_for => {
+ change_id => undef,
+ change => 'users',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'foo',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'bar',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ ], 'Should have called change_id_for() twice';
+ is_deeply [ map { $_->resolved_id } @conflicts ], [undef, undef],
+ 'Conflicting dependencies should have no resolved IDs';
+
+ ##########################################################################
+ # Die on missing dependencies.
+ my @requires = $make_deps->( 0, qw(foo bar foo) );
+ $change = App::Sqitch::Plan::Change->new(
+ name => 'blah',
+ plan => $target->plan,
+ requires => \@requires,
+ );
+ $plan->_changes->append($change);
+ $start_from = $plan->count - 1;
+ $plan->position( $start_from - 1);
+
+ push @resolved, undef, undef;
+ throws_ok { $engine->check_deploy_dependencies($plan, $start_from) } 'App::Sqitch::X',
+ 'Missing dependencies should throw exception';
+ is $@->ident, 'deploy', 'Should be another "deploy" error';
+ is $@->message, __nx(
+ 'Missing required change: {changes}',
+ 'Missing required changes: {changes}',
+ scalar 2,
+ changes => 'foo bar',
+ ), 'Should have localized message missing dependencies without dupes';
+
+ is_deeply $engine->seen, [
+ [ change_id_for => {
+ change_id => undef,
+ change => 'foo',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'bar',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'foo',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ ], 'Should have called check_requires';
+ is_deeply [ map { $_->resolved_id } @requires ], [undef, undef, undef],
+ 'Missing requirements should not have resolved';
+
+ # Make sure we see both conflict and prereq failures.
+ push @resolved, '2342', '253245', '2323434', undef, undef;
+ $plan->reset;
+
+ throws_ok { $engine->check_deploy_dependencies($plan, $start_from) } 'App::Sqitch::X',
+ 'Missing dependencies should throw exception';
+ is $@->ident, 'deploy', 'Should be another "deploy" error';
+ is $@->message, join(
+ "\n",
+ __nx(
+ 'Conflicts with previously deployed change: {changes}',
+ 'Conflicts with previously deployed changes: {changes}',
+ scalar 3,
+ changes => 'widgets foo',
+ ),
+ __nx(
+ 'Missing required change: {changes}',
+ 'Missing required changes: {changes}',
+ scalar 2,
+ changes => 'foo bar',
+ ),
+ ), 'Should have localized conflicts and required error messages';
+
+ is_deeply $engine->seen, [
+ [ change_id_for => {
+ change_id => undef,
+ change => 'widgets',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'users',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'foo',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'bar',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'foo',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'bar',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ [ change_id_for => {
+ change_id => undef,
+ change => 'foo',
+ tag => undef,
+ project => 'sql',
+ first => 1,
+ } ],
+ ], 'Should have called check_requires';
+ is_deeply [ map { $_->resolved_id } @requires ], [undef, undef, undef],
+ 'Missing requirements should not have resolved';
+}
+
+# Test revert dependency-checking.
+$mock_engine->unmock('check_revert_dependencies');
+can_ok $engine, 'check_revert_dependencies';
+
+CHECK_REVERT_DEPEND: {
+ my $change = App::Sqitch::Plan::Change->new(
+ name => 'urfa',
+ id => '24234234234e',
+ plan => $plan,
+ );
+
+ # Have revert change fail with requiring changes.
+ my $req = {
+ change_id => '23234234',
+ change => 'blah',
+ asof_tag => undef,
+ project => $plan->project,
+ };
+ @requiring = [$req];
+
+ throws_ok { $engine->check_revert_dependencies($change) } 'App::Sqitch::X',
+ 'Should get error reverting change another depend on';
+ is $@->ident, 'revert', 'Dependent error ident should be "revert"';
+ is $@->message, __nx(
+ 'Change "{change}" required by currently deployed change: {changes}',
+ 'Change "{change}" required by currently deployed changes: {changes}',
+ 1,
+ change => 'urfa',
+ changes => 'blah'
+ ), 'Dependent error message should be correct';
+ is_deeply $engine->seen, [
+ [changes_requiring_change => $change ],
+ ], 'It should have check for requiring changes';
+
+ # Add a second requiring change.
+ my $req2 = {
+ change_id => '99999',
+ change => 'harhar',
+ asof_tag => '@foo',
+ project => 'elsewhere',
+ };
+ @requiring = [$req, $req2];
+
+ throws_ok { $engine->check_revert_dependencies($change) } 'App::Sqitch::X',
+ 'Should get error reverting change others depend on';
+ is $@->ident, 'revert', 'Dependent error ident should be "revert"';
+ is $@->message, __nx(
+ 'Change "{change}" required by currently deployed change: {changes}',
+ 'Change "{change}" required by currently deployed changes: {changes}',
+ 2 ,
+ change => 'urfa',
+ changes => 'blah elsewhere:harhar@foo'
+ ), 'Dependent error message should be correct';
+ is_deeply $engine->seen, [
+ [changes_requiring_change => $change ],
+ ], 'It should have check for requiring changes';
+
+ # Try it with two changes.
+ my $req3 = {
+ change_id => '94949494',
+ change => 'frobisher',
+ project => 'whu',
+ };
+ @requiring = ([$req, $req2], [$req3]);
+
+ my $change2 = App::Sqitch::Plan::Change->new(
+ name => 'kazane',
+ id => '8686868686',
+ plan => $plan,
+ );
+
+ throws_ok { $engine->check_revert_dependencies($change, $change2) } 'App::Sqitch::X',
+ 'Should get error reverting change others depend on';
+ is $@->ident, 'revert', 'Dependent error ident should be "revert"';
+ is $@->message, join(
+ "\n",
+ __nx(
+ 'Change "{change}" required by currently deployed change: {changes}',
+ 'Change "{change}" required by currently deployed changes: {changes}',
+ 2 ,
+ change => 'urfa',
+ changes => 'blah elsewhere:harhar@foo'
+ ),
+ __nx(
+ 'Change "{change}" required by currently deployed change: {changes}',
+ 'Change "{change}" required by currently deployed changes: {changes}',
+ 1,
+ change => 'kazane',
+ changes => 'whu:frobisher'
+ ),
+ ), 'Dependent error message should be correct';
+ is_deeply $engine->seen, [
+ [changes_requiring_change => $change ],
+ [changes_requiring_change => $change2 ],
+ ], 'It should have checked twice for requiring changes';
+}
+
+##############################################################################
+# Test _trim_to().
+can_ok $engine, '_trim_to';
+
+# Should get an error when a change is not in the plan.
+throws_ok { $engine->_trim_to( 'foo', 'nonexistent', [] ) } 'App::Sqitch::X',
+ '_trim_to should complain about a nonexistent change key';
+is $@->ident, 'foo', '_trim_to nonexistent key error ident should be "foo"';
+is $@->message, __x(
+ 'Cannot find "{change}" in the database or the plan',
+ change => 'nonexistent',
+), '_trim_to nonexistent key error message should be correct';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => 'nonexistent',
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ]
+], 'It should have passed the change name and ROOT tag to change_id_for';
+
+# Should get an error when it's in the plan but not the database.
+throws_ok { $engine->_trim_to( 'yep', 'blah', [] ) } 'App::Sqitch::X',
+ '_trim_to should complain about an undeployed change key';
+is $@->ident, 'yep', '_trim_to undeployed change error ident should be "yep"';
+is $@->message, __x(
+ 'Change "{change}" has not been deployed',
+ change => 'blah',
+), '_trim_to undeployed change error message should be correct';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => 'blah',
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ]
+], 'It should have passed change "blah" change_id_for';
+
+# Should get an error when it's deployed but not in the plan.
+@resolved = ('whatever');
+throws_ok { $engine->_trim_to( 'oop', 'whatever', [] ) } 'App::Sqitch::X',
+ '_trim_to should complain about an unplanned change key';
+is $@->ident, 'oop', '_trim_to unplanned change error ident should be "oop"';
+is $@->message, __x(
+ 'Change "{change}" is deployed, but not planned',
+ change => 'whatever',
+), '_trim_to unplanned change error message should be correct';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => 'whatever',
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ],
+ [ change_id_offset_from_id => ['whatever', 0]],
+], 'It should have passed "whatever" to change_id_offset_from_id';
+
+# Let's mess with changes. Start by shifting nothing.
+my $to_trim = [@changes];
+@resolved = ($changes[0]->id);
+my $key = $changes[0]->name;
+is $engine->_trim_to('foo', $key, $to_trim), 0,
+ qq{_trim_to should find "$key" at index 0};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes ],
+ 'Changes should be untrimmed';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => $key,
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ],
+ [ change_id_offset_from_id => [$changes[0]->id, 0]],
+], 'It should have passed change 0 ID to change_id_offset_from_id';
+
+# Try shifting to the third change.
+$to_trim = [@changes];
+@resolved = ($changes[2]->id);
+$key = $changes[2]->name;
+is $engine->_trim_to('foo', $key, $to_trim), 2,
+ qq{_trim_to should find "$key" at index 2};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[2..$#changes] ],
+ 'First two changes should be shifted off';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => $key,
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ],
+ [ change_id_offset_from_id => [$changes[2]->id, 0]],
+], 'It should have passed change 2 ID to change_id_offset_from_id';
+
+# Try popping nothing.
+$to_trim = [@changes];
+@resolved = ($changes[-1]->id);
+$key = $changes[-1]->name;
+is $engine->_trim_to('foo', $key, $to_trim, 1), $#changes,
+ qq{_trim_to should find "$key" at last index};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes ],
+ 'Changes should be untrimmed';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => $key,
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ],
+ [ change_id_offset_from_id => [$changes[-1]->id, 0]],
+], 'It should have passed change -1 ID to change_id_offset_from_id';
+
+# Try shifting to the third-to-last change.
+$to_trim = [@changes];
+@resolved = ($changes[-3]->id);
+$key = $changes[-3]->name;
+is $engine->_trim_to('foo', $key, $to_trim, 1), 4,
+ qq{_trim_to should find "$key" at index 4};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[0..$#changes-2] ],
+ 'Last two changes should be popped off';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => $key,
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ],
+ [ change_id_offset_from_id => [$changes[-3]->id, 0]],
+], 'It should have passed change -3 ID to change_id_offset_from_id';
+
+# ^ should be handled relative to deployed changes.
+$to_trim = [@changes];
+@resolved = ($changes[-3]->id);
+$key = $changes[-4]->name;
+is $engine->_trim_to('foo', "$key^", $to_trim, 1), 4,
+ qq{_trim_to should find "$key^" at index 4};
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => $key,
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ],
+ [ change_id_offset_from_id => [$changes[-3]->id, -1]],
+], 'Should pass change -3 ID and offset -1 to change_id_offset_from_id';
+
+# ~ should be handled relative to deployed changes.
+$to_trim = [@changes];
+@resolved = ($changes[-3]->id);
+$key = $changes[-2]->name;
+is $engine->_trim_to('foo', "$key~", $to_trim, 1), 4,
+ qq{_trim_to should find "$key~" at index 4};
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => $key,
+ change_id => undef,
+ project => 'sql',
+ tag => undef,
+ } ],
+ [ change_id_offset_from_id => [$changes[-3]->id, 1]],
+], 'Should pass change -3 ID and offset 1 to change_id_offset_from_id';
+
+# @HEAD and HEAD should be handled relative to deployed changes, not the plan.
+$to_trim = [@changes];
+@resolved = ($changes[2]->id);
+$key = '@HEAD';
+is $engine->_trim_to('foo', $key, $to_trim), 2,
+ qq{_trim_to should find "$key" at index 2};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[2..$#changes] ],
+ 'First two changes should be shifted off';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => '',
+ change_id => undef,
+ project => 'sql',
+ tag => 'HEAD',
+ } ],
+ [ change_id_offset_from_id => [$changes[2]->id, 0]],
+], 'Should pass tag HEAD to change_id_for';
+
+$to_trim = [@changes];
+@resolved = ($changes[2]->id);
+$key = 'HEAD';
+is $engine->_trim_to('foo', $key, $to_trim), 2,
+ qq{_trim_to should find "$key" at index 2};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[2..$#changes] ],
+ 'First two changes should be shifted off';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => undef,
+ change_id => undef,
+ project => 'sql',
+ tag => 'HEAD',
+ } ],
+ [ change_id_offset_from_id => [$changes[2]->id, 0]],
+], 'Should pass tag @HEAD to change_id_for';
+
+# @ROOT and ROOT should be handled relative to deployed changes, not the plan.
+$to_trim = [@changes];
+@resolved = ($changes[2]->id);
+$key = '@ROOT';
+is $engine->_trim_to('foo', $key, $to_trim, 1), 2,
+ qq{_trim_to should find "$key" at index 2};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[0,1,2] ],
+ 'All but First three changes should be popped off';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => '',
+ change_id => undef,
+ project => 'sql',
+ tag => 'ROOT',
+ } ],
+ [ change_id_offset_from_id => [$changes[2]->id, 0]],
+], 'Should pass tag ROOT to change_id_for';
+
+$to_trim = [@changes];
+@resolved = ($changes[2]->id);
+$key = 'ROOT';
+is $engine->_trim_to('foo', $key, $to_trim, 1), 2,
+ qq{_trim_to should find "$key" at index 2};
+is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[0,1,2] ],
+ 'All but First three changes should be popped off';
+is_deeply $engine->seen, [
+ [ change_id_for => {
+ change => undef,
+ change_id => undef,
+ project => 'sql',
+ tag => 'ROOT',
+ } ],
+ [ change_id_offset_from_id => [$changes[2]->id, 0]],
+], 'Should pass tag @ROOT to change_id_for';
+
+##############################################################################
+# Test _verify_changes().
+can_ok $engine, '_verify_changes';
+$engine->seen;
+
+# Start with a single change with a valid verify script.
+is $engine->_verify_changes(1, 1, 0, $changes[1]), 0,
+ 'Verify of a single change should return errcount 0';
+is_deeply +MockOutput->get_emit_literal, [[
+ ' * users @alpha ..', '', ' ',
+]], 'Declared output should list the change';
+is_deeply +MockOutput->get_emit, [['ok']],
+ 'Emitted Output should reflect the verification of the change';
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+is_deeply $engine->seen, [
+ [run_file => $changes[1]->verify_file ],
+], 'The verify script should have been run';
+
+# Try a single change with no verify script.
+is $engine->_verify_changes(0, 0, 0, $changes[0]), 0,
+ 'Verify of another single change should return errcount 0';
+is_deeply +MockOutput->get_emit_literal, [[
+ ' * roles ..', '', ' ',
+]], 'Declared output should list the change';
+is_deeply +MockOutput->get_emit, [['ok']],
+ 'Emitted Output should reflect the verification of the change';
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+is_deeply +MockOutput->get_vent, [
+ [__x 'Verify script {file} does not exist', file => $changes[0]->verify_file],
+], 'A warning about no verify file should have been emitted';
+is_deeply $engine->seen, [
+], 'The verify script should not have been run';
+
+# Try multiple changes.
+is $engine->_verify_changes(0, 1, 0, @changes[0,1]), 0,
+ 'Verify of two changes should return errcount 0';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * roles ..', '.......', ' '],
+ [' * users @alpha ..', '', ' '],
+], 'Declared output should list both changes';
+is_deeply +MockOutput->get_emit, [['ok'], ['ok']],
+ 'Emitted Output should reflect the verification of the changes';
+
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+is_deeply +MockOutput->get_vent, [
+ [__x 'Verify script {file} does not exist', file => $changes[0]->verify_file],
+], 'A warning about no verify file should have been emitted';
+is_deeply $engine->seen, [
+ [run_file => $changes[1]->verify_file ],
+], 'Only one verify script should have been run';
+
+# Try multiple changes and show undeployed changes.
+my @plan_changes = $plan->changes;
+is $engine->_verify_changes(0, 1, 1, @changes[0,1]), 0,
+ 'Verify of two changes and show pending';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * roles ..', '.......', ' '],
+ [' * users @alpha ..', '', ' '],
+], 'Delcared output should list deployed changes';
+is_deeply +MockOutput->get_emit, [
+ ['ok'], ['ok'],
+ [__n 'Undeployed change:', 'Undeployed changes:', 2],
+ map { [ ' * ', $_->format_name_with_tags] } @plan_changes[2..$#plan_changes]
+], 'Emitted output should include list of pending changes';
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+is_deeply +MockOutput->get_vent, [
+ [__x 'Verify script {file} does not exist', file => $changes[0]->verify_file],
+], 'A warning about no verify file should have been emitted';
+is_deeply $engine->seen, [
+ [run_file => $changes[1]->verify_file ],
+], 'Only one verify script should have been run';
+
+# Try a change that is not in the plan.
+$change = App::Sqitch::Plan::Change->new( name => 'nonexistent', plan => $plan );
+is $engine->_verify_changes(1, 0, 0, $change), 1,
+ 'Verify of a change not in the plan should return errcount 1';
+is_deeply +MockOutput->get_emit_literal, [[
+ ' * nonexistent ..', '', ' '
+]], 'Declared Output should reflect the verification of the change';
+is_deeply +MockOutput->get_emit, [['not ok']],
+ 'Emitted Output should reflect the failure of the verify';
+is_deeply +MockOutput->get_comment, [[__ 'Not present in the plan' ]],
+ 'Should have a comment about the change missing from the plan';
+is_deeply $engine->seen, [], 'No verify script should have been run';
+
+# Try a change in the wrong place in the plan.
+my $mock_plan = Test::MockModule->new(ref $plan);
+$mock_plan->mock(index_of => 5);
+is $engine->_verify_changes(1, 0, 0, $changes[1]), 1,
+ 'Verify of an out-of-order change should return errcount 1';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * users @alpha ..', '', ' '],
+], 'Declared output should reflect the verification of the change';
+is_deeply +MockOutput->get_emit, [['not ok']],
+ 'Emitted Output should reflect the failure of the verify';
+is_deeply +MockOutput->get_comment, [[__ 'Out of order' ]],
+ 'Should have a comment about the out-of-order change';
+is_deeply $engine->seen, [
+ [run_file => $changes[1]->verify_file ],
+], 'The verify script should have been run';
+
+# Make sure that multiple issues add up.
+$mock_engine->mock( verify_change => sub { hurl 'WTF!' });
+is $engine->_verify_changes(1, 0, 0, $changes[1]), 2,
+ 'Verify of a change with 2 issues should return 2';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * users @alpha ..', '', ' '],
+], 'Declared output should reflect the verification of the change';
+is_deeply +MockOutput->get_emit, [['not ok']],
+ 'Emitted Output should reflect the failure of the verify';
+is_deeply +MockOutput->get_comment, [
+ [__ 'Out of order' ],
+ ['WTF!'],
+], 'Should have comment about the out-of-order change and script failure';
+is_deeply $engine->seen, [], 'No abstract methods should have been called';
+
+# Make sure that multiple changes with multiple issues add up.
+$mock_engine->mock( verify_change => sub { hurl 'WTF!' });
+is $engine->_verify_changes(0, -1, 0, @changes[0,1]), 4,
+ 'Verify of 2 changes with 2 issues each should return 4';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * roles ..', '.......', ' '],
+ [' * users @alpha ..', '', ' '],
+], 'Declraed output should reflect the verification of both changes';
+is_deeply +MockOutput->get_emit, [['not ok'], ['not ok']],
+ 'Emitted Output should reflect the failure of both verifies';
+is_deeply +MockOutput->get_comment, [
+ [__ 'Out of order' ],
+ ['WTF!'],
+ [__ 'Out of order' ],
+ ['WTF!'],
+], 'Should have comment about the out-of-order changes and script failures';
+is_deeply $engine->seen, [], 'No abstract methods should have been called';
+
+# Unmock before moving on.
+$mock_plan->unmock('index_of');
+$mock_engine->unmock('verify_change');
+
+# Now deal with changes in the plan but not in the list.
+is $engine->_verify_changes($#changes, $plan->count - 1, 0, $changes[-1]), 2,
+ '_verify_changes with two undeployed changes should returne 2';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * dr_evil ..', '', ' '],
+ [' * foo ..', '....', ' ' , 'not ok', ' '],
+ [' * blah ..', '...', ' ' , 'not ok', ' '],
+], 'Listed changes should be both deployed and undeployed';
+is_deeply +MockOutput->get_emit, [['ok']],
+ 'Emitted Output should reflect 1 pass';
+is_deeply +MockOutput->get_comment, [
+ [__ 'Not deployed' ],
+ [__ 'Not deployed' ],
+], 'Should have comments for undeployed changes';
+is_deeply $engine->seen, [], 'No abstract methods should have been called';
+
+##############################################################################
+# Test verify().
+can_ok $engine, 'verify';
+my @verify_changes;
+$mock_engine->mock( _load_changes => sub { @verify_changes });
+
+# First, test with no changes.
+ok $engine->verify,
+ 'Should return success for no deployed changes';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+ [__ 'No changes deployed'],
+], 'Notification of the verify should be emitted';
+
+# Try no changes *and* nothing in the plan.
+my $count = 0;
+$mock_plan->mock(count => sub { $count });
+ok $engine->verify,
+ 'Should return success for no changes';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+ [__ 'Nothing to verify (no planned or deployed changes)'],
+], 'Notification of the verify should be emitted';
+
+# Now return some changes but have nothing in the plan.
+@verify_changes = @changes;
+throws_ok { $engine->verify } 'App::Sqitch::X',
+ 'Should get error for no planned changes';
+is $@->ident, 'verify', 'No planned changes ident should be "verify"';
+is $@->exitval, 2, 'No planned changes exitval should be 2';
+is $@->message, __ 'There are deployed changes, but none planned!',
+ 'No planned changes message should be correct';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+], 'Notification of the verify should be emitted';
+
+# Let's do one change and have it pass.
+$mock_plan->mock(index_of => 0);
+$count = 1;
+@verify_changes = ($changes[1]);
+undef $@;
+ok $engine->verify, 'Verify one change';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+], 'Notification of the verify should be emitted';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * ' . $changes[1]->format_name_with_tags . ' ..', '', ' ' ],
+], 'The one change name should be declared';
+is_deeply +MockOutput->get_emit, [
+ ['ok'],
+ [__ 'Verify successful'],
+], 'Success should be emitted';
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+
+# Verify two changes.
+MockOutput->get_vent;
+$mock_plan->unmock('index_of');
+@verify_changes = @changes[0,1];
+ok $engine->verify, 'Verify two changes';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+], 'Notification of the verify should be emitted';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * roles ..', '.......', ' ' ],
+ [' * users @alpha ..', '', ' ' ],
+], 'The two change names should be declared';
+is_deeply +MockOutput->get_emit, [
+ ['ok'], ['ok'],
+ [__ 'Verify successful'],
+], 'Both successes should be emitted';
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+is_deeply +MockOutput->get_vent, [
+ [__x(
+ 'Verify script {file} does not exist',
+ file => $changes[0]->verify_file,
+ )]
+], 'Should have warning about missing verify script';
+
+# Make sure a reworked change (that is, one with a suffix) is ignored.
+my $mock_change = Test::MockModule->new(ref $change);
+$mock_change->mock(is_reworked => 1);
+@verify_changes = @changes[0,1];
+ok $engine->verify, 'Verify with a reworked change changes';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+], 'Notification of the verify should be emitted';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * roles ..', '.......', ' ' ],
+ [' * users @alpha ..', '', ' ' ],
+], 'The two change names should be emitted';
+is_deeply +MockOutput->get_emit, [
+ ['ok'], ['ok'],
+ [__ 'Verify successful'],
+], 'Both successes should be emitted';
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+is_deeply +MockOutput->get_vent, [], 'Should have no warnings';
+
+$mock_change->unmock('is_reworked');
+
+# Make sure we can trim.
+@verify_changes = @changes;
+@resolved = map { $_->id } @changes[1,2];
+ok $engine->verify('users', 'widgets'), 'Verify two specific changes';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+], 'Notification of the verify should be emitted';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * users @alpha ..', '.', ' ' ],
+ [' * widgets @beta ..', '', ' ' ],
+], 'The two change names should be emitted';
+is_deeply +MockOutput->get_emit, [
+ ['ok'], ['ok'],
+ [__ 'Verify successful'],
+], 'Both successes should be emitted';
+is_deeply +MockOutput->get_comment, [], 'Should have no comments';
+is_deeply +MockOutput->get_vent, [
+ [__x(
+ 'Verify script {file} does not exist',
+ file => $changes[2]->verify_file,
+ )]
+], 'Should have warning about missing verify script';
+
+# Now fail!
+$mock_engine->mock( verify_change => sub { hurl 'WTF!' });
+@verify_changes = @changes;
+@resolved = map { $_->id } @changes[1,2];
+throws_ok { $engine->verify('users', 'widgets') } 'App::Sqitch::X',
+ 'Should get failure for failing verify scripts';
+is $@->ident, 'verify', 'Failed verify ident should be "verify"';
+is $@->exitval, 2, 'Failed verify exitval should be 2';
+is $@->message, __ 'Verify failed', 'Faield verify message should be correct';
+is_deeply +MockOutput->get_info, [
+ [__x 'Verifying {destination}', destination => $engine->destination],
+], 'Notification of the verify should be emitted';
+my $msg = __ 'Verify Summary Report';
+is_deeply +MockOutput->get_emit_literal, [
+ [' * users @alpha ..', '.', ' ' ],
+ [' * widgets @beta ..', '', ' ' ],
+], 'Both change names should be declared';
+is_deeply +MockOutput->get_emit, [
+ ['not ok'], ['not ok'],
+ [ "\n", $msg ],
+ [ '-' x length $msg ],
+ [__x 'Changes: {number}', number => 2 ],
+ [__x 'Errors: {number}', number => 2 ],
+], 'Output should include the failure report';
+is_deeply +MockOutput->get_comment, [
+ ['WTF!'],
+ ['WTF!'],
+], 'Should have the errors in comments';
+is_deeply +MockOutput->get_vent, [], 'Nothing should have been vented';
+
+__END__
+diag $_->format_name_with_tags for @changes;
+diag '======';
+diag $_->format_name_with_tags for $plan->changes;
diff --git a/t/engine/deploy/func/add_user.sql b/t/engine/deploy/func/add_user.sql
new file mode 100644
index 00000000..1587f43f
--- /dev/null
+++ b/t/engine/deploy/func/add_user.sql
@@ -0,0 +1,13 @@
+-- Deploy func/add_user
+-- requires: users
+
+BEGIN;
+
+CREATE FUNCTION __myapp.add_user(
+ nick TEXT,
+ pass TEXT
+) RETURNS VOID LANGUAGE SQL AS $$
+ INSERT INTO __myapp.users VALUES(nick, MD5(pass));
+$$;
+
+COMMIT;
diff --git a/t/engine/deploy/users.sql b/t/engine/deploy/users.sql
new file mode 100644
index 00000000..c9fd6947
--- /dev/null
+++ b/t/engine/deploy/users.sql
@@ -0,0 +1,6 @@
+SET client_min_messages = warning;
+CREATE SCHEMA __myapp;
+CREATE TABLE __myapp.users (
+ nick TEXT PRIMARY KEY,
+ name TEXT NOT NULL
+);
diff --git a/t/engine/deploy/widgets.sql b/t/engine/deploy/widgets.sql
new file mode 100644
index 00000000..ade26d08
--- /dev/null
+++ b/t/engine/deploy/widgets.sql
@@ -0,0 +1,7 @@
+-- requires: users
+-- conflicts: dr_evil
+SET client_min_messages = warning;
+CREATE TABLE __myapp.widgets (
+ name TEXT PRIMARY KEY,
+ owner TEXT NOT NULL REFERENCES __myapp.users(nick)
+);
diff --git a/t/engine/revert/func/add_user.sql b/t/engine/revert/func/add_user.sql
new file mode 100644
index 00000000..8e3f260a
--- /dev/null
+++ b/t/engine/revert/func/add_user.sql
@@ -0,0 +1,7 @@
+-- Revert func/add_user
+
+BEGIN;
+
+DROP FUNCTION __myapp.add_user(TEXT, TEXT);
+
+COMMIT;
diff --git a/t/engine/revert/users.sql b/t/engine/revert/users.sql
new file mode 100644
index 00000000..f0b7bcfd
--- /dev/null
+++ b/t/engine/revert/users.sql
@@ -0,0 +1,2 @@
+SET client_min_messages = warning;
+DROP SCHEMA IF EXISTS __myapp CASCADE;
diff --git a/t/engine/revert/widgets.sql b/t/engine/revert/widgets.sql
new file mode 100644
index 00000000..a9d15064
--- /dev/null
+++ b/t/engine/revert/widgets.sql
@@ -0,0 +1,2 @@
+SET client_min_messages = warning;
+DROP TABLE IF EXISTS __myapp.widgets;
diff --git a/t/engine/reworked/deploy/users@alpha.sql b/t/engine/reworked/deploy/users@alpha.sql
new file mode 100644
index 00000000..c9fd6947
--- /dev/null
+++ b/t/engine/reworked/deploy/users@alpha.sql
@@ -0,0 +1,6 @@
+SET client_min_messages = warning;
+CREATE SCHEMA __myapp;
+CREATE TABLE __myapp.users (
+ nick TEXT PRIMARY KEY,
+ name TEXT NOT NULL
+);
diff --git a/t/engine/reworked/revert/users@alpha.sql b/t/engine/reworked/revert/users@alpha.sql
new file mode 100644
index 00000000..f0b7bcfd
--- /dev/null
+++ b/t/engine/reworked/revert/users@alpha.sql
@@ -0,0 +1,2 @@
+SET client_min_messages = warning;
+DROP SCHEMA IF EXISTS __myapp CASCADE;
diff --git a/t/engine/sqitch.plan b/t/engine/sqitch.plan
new file mode 100644
index 00000000..eceeb501
--- /dev/null
+++ b/t/engine/sqitch.plan
@@ -0,0 +1,7 @@
+%project=engine
+
++ users 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # User roles
+@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # Good to go!
++ widgets [users !dr_evil] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # All in
++ func/add_user [users] 2012-10-09T18:28:29Z Barack Obama <potus@whitehouse.gov> # Add users.
++ users [users@alpha] 2012-10-09T19:28:29Z Barack Obama <potus@whitehouse.gov> # Add users.
diff --git a/t/engine_cmd.t b/t/engine_cmd.t
new file mode 100644
index 00000000..f7a94cc1
--- /dev/null
+++ b/t/engine_cmd.t
@@ -0,0 +1,631 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 201;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::Exception;
+use Test::Warn;
+use Test::Dir;
+use Test::File qw(file_not_exists_ok file_exists_ok);
+use Test::NoWarnings;
+use File::Copy;
+use Path::Class;
+use File::Temp 'tempdir';
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::engine';
+
+##############################################################################
+# Set up a test directory and config file.
+my $tmp_dir = tempdir CLEANUP => 1;
+
+File::Copy::copy file(qw(t engine.conf))->stringify, "$tmp_dir"
+ or die "Cannot copy t/engine.conf to $tmp_dir: $!\n";
+File::Copy::copy file(qw(t engine sqitch.plan))->stringify, "$tmp_dir"
+ or die "Cannot copy t/engine/sqitch.plan to $tmp_dir: $!\n";
+chdir $tmp_dir;
+my $config = TestConfig->from(local => 'engine.conf');
+my $psql = 'psql' . (App::Sqitch::ISWIN ? '.exe' : '');
+
+##############################################################################
+# Load an engine command and test the basics.
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+isa_ok my $cmd = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'engine',
+ config => $config,
+}), $CLASS, 'Engine command';
+isa_ok $cmd, 'App::Sqitch::Command', 'Engine command';
+
+can_ok $cmd, qw(
+ options
+ configure
+ execute
+ list
+ add
+ remove
+ rm
+ show
+ update_config
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::TargetConfigCommand"),
+ "$CLASS does TargetConfigCommand";
+
+is_deeply [$CLASS->options], [qw(
+ target=s
+ plan-file|f=s
+ registry=s
+ client=s
+ extension=s
+ top-dir=s
+ dir|d=s%
+ set|s=s%
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure({}, {}), { properties => {}},
+ 'Default config should contain empty properties';
+
+# Make sure configure ignores config file.
+is_deeply $CLASS->configure({ foo => 'bar'}), { properties => {} },
+ 'configure() should ignore config file';
+
+# Check default property values.
+ok my $conf = $CLASS->configure($config, {
+ top_dir => 'top',
+ plan_file => 'my.plan',
+ registry => 'bats',
+ client => 'cli',
+ extension => 'ddl',
+ target => 'db:pg:foo',
+ dir => {
+ deploy => 'dep',
+ revert => 'rev',
+ verify => 'ver',
+ reworked => 'wrk',
+ reworked_deploy => 'rdep',
+ reworked_revert => 'rrev',
+ reworked_verify => 'rver',
+ },
+ set => {
+ foo => 'bar',
+ prefix => 'x_',
+ },
+}), 'Get full config';
+
+is_deeply $conf->{properties}, {
+ top_dir => 'top',
+ plan_file => 'my.plan',
+ registry => 'bats',
+ client => 'cli',
+ extension => 'ddl',
+ target => 'db:pg:foo',
+ deploy_dir => 'dep',
+ revert_dir => 'rev',
+ verify_dir => 'ver',
+ reworked_dir => 'wrk',
+ reworked_deploy_dir => 'rdep',
+ reworked_revert_dir => 'rrev',
+ reworked_verify_dir => 'rver',
+ variables => {
+ foo => 'bar',
+ prefix => 'x_',
+ },
+}, 'Should have properties';
+isa_ok $conf->{properties}{$_}, 'Path::Class::File', "$_ file attribute" for qw(
+ plan_file
+);
+isa_ok $conf->{properties}{$_}, 'Path::Class::Dir', "$_ directory attribute" for (
+ 'top_dir',
+ 'reworked_dir',
+ map { ($_, "reworked_$_") } qw(deploy_dir revert_dir verify_dir)
+);
+
+# Make sure invalid directories are ignored.
+throws_ok { $CLASS->new($CLASS->configure({}, {
+ dir => { foo => 'bar' },
+})) } 'App::Sqitch::X', 'Should fail on invalid directory name';
+is $@->ident, 'engine', 'Invalid directory ident should be "engine"';
+is $@->message, __x(
+ 'Unknown directory name: {prop}',
+ prop => 'foo',
+), 'The invalid directory messsage should be correct';
+
+throws_ok { $CLASS->new($CLASS->configure({}, {
+ dir => { foo => 'bar', cavort => 'ha' },
+})) } 'App::Sqitch::X', 'Should fail on invalid directory names';
+is $@->ident, 'engine', 'Invalid directories ident should be "engine"';
+is $@->message, __x(
+ 'Unknown directory names: {props}',
+ props => 'cavort, foo',
+), 'The invalid properties messsage should be correct';
+
+##############################################################################
+# Test list().
+ok $cmd->list, 'Run list()';
+is_deeply +MockOutput->get_emit, [['mysql'], ['pg'], ['sqlite']],
+ 'The list of engines should have been output';
+
+# Make it verbose.
+isa_ok $cmd = $CLASS->new({
+ sqitch => App::Sqitch->new( config => $config, options => { verbosity => 1 })
+}), $CLASS, 'Verbose engine';
+ok $cmd->list, 'Run verbose list()';
+is_deeply +MockOutput->get_emit, [
+ ["mysql\tdb:mysql://root@/foo"],
+ ["pg\tdb:pg:try"],
+ ["sqlite\twidgets"]
+], 'The list of engines and their targets should have been output';
+
+##############################################################################
+# Test add().
+MISSINGARGS: {
+ # Test handling of no name.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->add } qr/USAGE/,
+ 'No name arg to add() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+}
+
+# Should die on existing key.
+throws_ok { $cmd->add('pg') } 'App::Sqitch::X',
+ 'Should get error for existing engine';
+is $@->ident, 'engine', 'Existing engine error ident should be "engine"';
+is $@->message, __x(
+ 'Engine "{engine}" already exists',
+ engine => 'pg'
+), 'Existing engine error message should be correct';
+
+# Now add a new engine.
+dir_not_exists_ok $_ for qw(deploy revert verify);
+ok $cmd->add('vertica'), 'Add engine "vertica"';
+dir_exists_ok $_ for qw(deploy revert verify);
+$config->load;
+is $config->get(key => 'engine.vertica.target'), 'db:vertica:',
+ 'Engine "test" target should have been set';
+for my $key (qw(
+ client
+ registry
+ top_dir
+ plan_file
+ deploy_dir
+ revert_dir
+ verify_dir
+ extension
+)) {
+ is $config->get(key => "engine.vertica.$key"), undef,
+ qq{Engine "vertica" should have no $key set};
+}
+is_deeply $config->get_section(section => 'engine.vertica.variables'), {},
+ qq{Engine "vertica" should have no variables set};
+
+# Should die on target that doesn't match the engine.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { target => 'db:sqlite:' },
+}), $CLASS, 'Engine with target property';
+throws_ok { $cmd->add('firebird' ) } 'App::Sqitch::X',
+ 'Should get error for engine/target mismatch';
+is $@->ident, 'engine', 'Target mismatch ident should be "engine"';
+is $@->message, __x(
+ 'Cannot assign URI using engine "{new}" to engine "{old}"',
+ new => 'sqlite',
+ old => 'firebird',
+), 'Target mismatch message should be correct';
+
+# Try all the properties.
+my %props = (
+ target => 'db:firebird:foo',
+ client => 'poo',
+ registry => 'reg',
+ top_dir => dir('top'),
+ plan_file => file('my.plan'),
+ deploy_dir => dir('dep'),
+ revert_dir => dir('rev'),
+ verify_dir => dir('ver'),
+ reworked_dir => dir('r'),
+ reworked_deploy_dir => dir('r/d'),
+ extension => 'ddl',
+ variables => { ay => 'first', Bee => 'second' },
+);
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { %props },
+}), $CLASS, 'Engine with all properties';
+file_not_exists_ok 'my.plan';
+dir_not_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify);
+ok $cmd->add('firebird'), 'Add engine "firebird"';
+dir_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify);
+file_exists_ok 'my.plan';
+$config->load;
+while (my ($k, $v) = each %props) {
+ if ($k ne 'variables') {
+ is $config->get(key => "engine.firebird.$k"), $v,
+ qq{Engine "firebird" should have $k set};
+ } else {
+ is_deeply $config->get_section(section => "engine.firebird.$k"), $v,
+ qq{Engine "firebird" should have $k};
+ }
+}
+
+##############################################################################
+# Test alter().
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+}), $CLASS, 'Engine with no properties';
+
+MISSINGARGS: {
+ # Test handling of no name.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->alter } qr/USAGE/,
+ 'No name arg to add() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+}
+
+throws_ok { $cmd->alter('nonexistent' ) } 'App::Sqitch::X',
+ 'Should get error from alter for nonexistent engine';
+is $@->ident, 'engine', 'Nonexistent engine error ident should be "engine"';
+is $@->message, __x(
+ 'Unknown engine "{engine}"',
+ engine => 'nonexistent'
+), 'Nonexistent engine error message should be correct';
+
+# Should die on missing key.
+throws_ok { $cmd->alter('oracle') } 'App::Sqitch::X',
+ 'Should get error for missing engine';
+is $@->ident, 'engine', 'Missing engine error ident should be "engine"';
+is $@->message, __x(
+ 'Missing Engine "{engine}"; use "{command}" to add it',
+ engine => 'oracle',
+ command => 'add oracle db:oracle:',
+), 'Missing engine error message should be correct';
+
+# Try all the properties.
+%props = (
+ target => 'db:firebird:bar',
+ client => 'argh',
+ registry => 'migrations',
+ top_dir => dir('fb'),
+ plan_file => file('fb.plan'),
+ deploy_dir => dir('fb/dep'),
+ revert_dir => dir('fb/rev'),
+ verify_dir => dir('fb/ver'),
+ reworked_dir => dir('fb/r'),
+ reworked_deploy_dir => dir('fb/r/d'),
+ extension => 'fbsql',
+ variables => { ay => 'x', ceee => 'third' },
+);
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { %props },
+}), $CLASS, 'Engine with more properties';
+ok $cmd->alter('firebird'), 'Alter engine "firebird"';
+$config->load;
+while (my ($k, $v) = each %props) {
+ if ($k ne 'variables') {
+ is $config->get(key => "engine.firebird.$k"), $v,
+ qq{Engine "firebird" should have $k set};
+ } else {
+ $v->{Bee} = 'second';
+ is_deeply $config->get_section(section => "engine.firebird.$k"), $v,
+ qq{Engine "firebird" should have $k};
+ }
+}
+
+# Try changing the top directory.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { top_dir => dir 'pg' },
+}), $CLASS, 'Engine with new top_dir property';
+dir_not_exists_ok dir $_ for qw(pg pg/deploy pg/revert pg/verify);
+ok $cmd->alter('pg'), 'Alter engine "pg"';
+dir_exists_ok dir $_ for qw(pg pg/deploy pg/revert pg/verify);
+$config->load;
+is $config->get(key => 'engine.pg.top_dir'), 'pg',
+ 'The pg top_dir should have been set';
+
+# An attempt to alter a missing engine should show the target if in props.
+throws_ok { $cmd->alter('oracle') } 'App::Sqitch::X',
+ 'Should again get error for missing engine';
+is $@->ident, 'engine', 'Missing engine error ident should still be "engine"';
+is $@->message, __x(
+ 'Missing Engine "{engine}"; use "{command}" to add it',
+ engine => 'oracle',
+ command => 'add oracle db:oracle:',
+), 'Missing engine error message should include target property';
+
+# Should die on target mismatch engine.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { target => 'db:sqlite:' },
+}), $CLASS, 'Engine with target property';
+throws_ok { $cmd->alter('firebird' ) } 'App::Sqitch::X',
+ 'Should get error for engine/target mismatch';
+is $@->ident, 'engine', 'Target mismatch ident should be "engine"';
+is $@->message, __x(
+ 'Cannot assign URI using engine "{new}" to engine "{old}"',
+ new => 'sqlite',
+ old => 'firebird',
+), 'Target mismatch message should be correct';
+
+##############################################################################
+# Test remove.
+MISSINGARGS: {
+ # Test handling of no names.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->remove } qr/USAGE/,
+ 'No name args to remove() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+}
+
+# Should get an error if the engine does not exist.
+throws_ok { $cmd->remove('nonexistent', 'existant' ) } 'App::Sqitch::X',
+ 'Should get error for nonexistent engine';
+is $@->ident, 'engine', 'Nonexistent engine error ident should be "engine"';
+is $@->message, __x(
+ 'Unknown engine "{engine}"',
+ engine => 'nonexistent'
+), 'Nonexistent engine error message should be correct';
+
+# Remove one that exists.
+ok $cmd->remove('mysql'), 'Remove';
+$config->load;
+is $config->get(key => "engine.mysql.target"), undef,
+ qq{Engine "mysql" should now be gone};
+is_deeply $config->get_section(section => "engine.mysql.variables"), {},
+ qq{Engine "mysql" should have no variables};
+
+# Create it again with variables.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { variables => { x => 1} },
+}), $CLASS, 'Engein with variables';
+ok $cmd->add('mysql', 'db:mysql:'), 'Add engine "mysql"';
+$config->load;
+is $config->get(key => "engine.mysql.target"), 'db:mysql:',
+ qq{Engine "mysql" should be back};
+is_deeply $config->get_section(section => "engine.mysql.variables"), { x => 1},
+ qq{Engine "mysql" should have variables};
+
+# Remoce it again.
+ok $cmd->remove('mysql'), 'Remove';
+$config->load;
+is $config->get(key => "engine.mysql.target"), undef,
+ qq{Engine "mysql" should be gone again};
+is_deeply $config->get_section(section => "engine.mysql.variables"), {},
+ qq{Engine "mysql" should have no variables};
+
+
+##############################################################################
+# Test show.
+ok $cmd->show, 'Run show()';
+is_deeply +MockOutput->get_emit, [
+ ['firebird'], ['pg'], ['sqlite'], ['vertica']
+], 'Show with no names should emit the list of engines';
+
+# Try one engine.
+ok $cmd->show('sqlite'), 'Show sqlite';
+is_deeply +MockOutput->get_emit, [
+ ['* sqlite'],
+ [' ', 'Target: ', 'widgets'],
+ [' ', 'Registry: ', 'sqitch'],
+ [' ', 'Client: ', '/usr/sbin/sqlite3'],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'foo.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+], 'The full "sqlite" engine should have been shown';
+
+# Try multiples.
+$config->update('engine.vertica.client' => 'vsql.exe');
+ok $cmd->show(qw(sqlite vertica firebird)), 'Show three engines';
+is_deeply +MockOutput->get_emit, [
+ ['* sqlite'],
+ [' ', 'Target: ', 'widgets'],
+ [' ', 'Registry: ', 'sqitch'],
+ [' ', 'Client: ', '/usr/sbin/sqlite3'],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'foo.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+ ['* vertica'],
+ [' ', 'Target: ', 'db:vertica:'],
+ [' ', 'Registry: ', 'sqitch'],
+ [' ', 'Client: ', 'vsql.exe'],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'sqitch.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+ ['* firebird'],
+ [' ', 'Target: ', 'db:firebird:bar'],
+ [' ', 'Registry: ', 'migrations'],
+ [' ', 'Client: ', 'argh'],
+ [' ', 'Top Directory: ', 'fb'],
+ [' ', 'Plan File: ', 'fb.plan'],
+ [' ', 'Extension: ', 'fbsql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', dir 'fb/dep'],
+ [' ', ' Revert: ', dir 'fb/rev'],
+ [' ', ' Verify: ', dir 'fb/ver'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', dir 'fb/r'],
+ [' ', ' Deploy: ', dir 'fb/r/d'],
+ [' ', ' Revert: ', dir 'fb/r/revert'],
+ [' ', ' Verify: ', dir 'fb/r/verify'],
+ [' ', 'Variables:'],
+ [' ay: x'],
+ [' Bee: second'],
+ [' ceee: third'],
+], 'All three engines should have been shown';
+
+##############################################################################
+# Test execute().
+isa_ok $cmd = $CLASS->new({ sqitch => $sqitch }), $CLASS, 'Simple engine';
+for my $spec (
+ [ undef, 'list' ],
+ [ 'list' ],
+ [ 'add' ],
+ [ 'set-target' ],
+ [ 'set-registry' ],
+ [ 'set-client' ],
+ [ 'remove' ],
+ [ 'rm', 'remove' ],
+ [ 'rename' ],
+ [ 'show' ],
+) {
+ my ($arg, $meth) = @{ $spec };
+ $meth //= $arg;
+ $meth =~ s/-/_/g;
+ my $mocker = Test::MockModule->new($CLASS);
+ my @args;
+ $mocker->mock($meth => sub { @args = @_ });
+ ok $cmd->execute($spec->[0]), "Execute " . ($spec->[0] // 'undef');
+ is_deeply \@args, [$cmd], "$meth() should have been called";
+
+ # Make sure args are passed.
+ ok $cmd->execute($spec->[0], qw(pg db:pg:)),
+ "Execute " . ($spec->[0] // 'undef') . ' with args';
+ is_deeply \@args, [$cmd, qw(pg db:pg:)],
+ "$meth() should have been passed args";
+}
+
+# Make sure an invalid action dies with a usage statement.
+MISSINGARGS: {
+ # Test handling of no names.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->execute('nonexistent') } qr/USAGE/,
+ 'Should get an exception for a nonexistent action';
+ is_deeply \@args, [$cmd, __x(
+ 'Unknown action "{action}"',
+ action => 'nonexistent',
+ )], 'Nonexistent action message should be passed to usage';
+}
+
+##############################################################################
+# Test update_config.
+$config->group_set($config->local_file, [
+ {key => 'core.mysql.target', value => 'widgets' },
+ {key => 'core.mysql.client', value => 'mysql.exe' },
+ {key => 'core.mysql.registry', value => 'spliff' },
+ {key => 'core.mysql.host', value => 'localhost' },
+ {key => 'core.mysql.port', value => 1234 },
+ {key => 'core.mysql.username', value => 'fred' },
+ {key => 'core.mysql.password', value => 'barb' },
+ {key => 'core.mysql.db_name', value => 'ouch' },
+]);
+$cmd->sqitch->config->load;
+my $core = $cmd->sqitch->config->get_section(section => 'core.mysql');
+ok $cmd->update_config, 'Update the config';
+$cmd->sqitch->config->load;
+is_deeply $cmd->sqitch->config->get_section(section => 'core.mysql'), $core,
+ 'The core.mysql config should still be present';
+is_deeply $cmd->sqitch->config->get_section(section => 'engine.mysql'), {
+ target => 'widgets',
+ client => 'mysql.exe',
+ registry => 'spliff',
+}, 'MySQL config should have been rewritten without deprecated keys';
+
+# Try with no target.
+$config->rename_section(
+ from => 'engine.mysql',
+ filename => $config->local_file,
+);
+$config->group_set($config->local_file, [
+ {key => 'core.mysql.target', value => undef },
+ {key => 'core.mysql.client', value => 'mysql.exe' },
+ {key => 'core.mysql.registry', value => 'spliff' },
+ {key => 'core.mysql.host', value => 'localhost' },
+ {key => 'core.mysql.port', value => 1234 },
+ {key => 'core.mysql.username', value => 'fred' },
+ {key => 'core.mysql.password', value => 'barb' },
+ {key => 'core.mysql.db_name', value => 'ouch' },
+]);
+$cmd->sqitch->config->load;
+$core = $cmd->sqitch->config->get_section(section => 'core.mysql');
+ok $cmd->update_config, 'Update the config again';
+$cmd->sqitch->config->load;
+is_deeply $cmd->sqitch->config->get_section(section => 'core.mysql'), $core,
+ 'The core.mysql config should again remain';
+is_deeply $cmd->sqitch->config->get_section(section => 'engine.mysql'), {
+ target => 'db:mysql://fred:barb@localhost:1234/ouch',
+ client => 'mysql.exe',
+ registry => 'spliff',
+}, 'MySQL config should have been rewritten with an integrated target';
+
+# Try with no deprecated keys.
+$config->rename_section(
+ from => 'engine.mysql',
+ filename => $config->local_file,
+);
+$config->group_set($config->local_file, [
+ {key => 'core.mysql.client', value => 'mysql.exe' },
+ {key => 'core.mysql.registry', value => 'spliff' },
+ {key => 'core.mysql.host', value => undef },
+ {key => 'core.mysql.port', value => undef },
+ {key => 'core.mysql.username', value => undef },
+ {key => 'core.mysql.password', value => undef },
+ {key => 'core.mysql.db_name', value => undef },
+]);
+$cmd->sqitch->config->load;
+$core = $cmd->sqitch->config->get_section(section => 'core.mysql');
+ok $cmd->update_config, 'Update the config again';
+$cmd->sqitch->config->load;
+is_deeply $cmd->sqitch->config->get_section(section => 'core.mysql'), $core,
+ 'The core.mysql config should again remain';
+is_deeply $cmd->sqitch->config->get_section(section => 'engine.mysql'), {
+ target => 'db:mysql:',
+ client => 'mysql.exe',
+ registry => 'spliff',
+}, 'MySQL config should have been rewritten with a default target';
diff --git a/t/exasol.t b/t/exasol.t
new file mode 100644
index 00000000..48671056
--- /dev/null
+++ b/t/exasol.t
@@ -0,0 +1,381 @@
+#!/usr/bin/perl -w
+
+# To test against a live Exasol database, you must set the EXA_URI environment variable.
+# this is a stanard URI::db URI, and should look something like this:
+#
+# export EXA_URI=db:exasol://dbadmin:password@localhost:5433/dbadmin?Driver=Exasol
+#
+# Note that it must include the `?Driver=$driver` bit so that DBD::ODBC loads
+# the proper driver.
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use Test::MockModule;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use Capture::Tiny 0.12 qw(:all);
+use Try::Tiny;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+
+delete $ENV{"VSQL_$_"} for qw(USER PASSWORD DATABASE HOST PORT);
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::exasol';
+ require_ok $CLASS or die;
+}
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $uri = URI::db->new('db:exasol:');
+my $config = TestConfig->new('core.engine' => 'exasol');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => $uri,
+);
+isa_ok my $exa = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+
+is $exa->key, 'exasol', 'Key should be "exasol"';
+is $exa->name, 'Exasol', 'Name should be "Exasol"';
+
+my $client = 'exaplus' . (App::Sqitch::ISWIN ? '.exe' : '');
+is $exa->client, $client, 'client should default to exaplus';
+is $exa->registry, 'sqitch', 'registry default should be "sqitch"';
+is $exa->uri, $uri, 'DB URI should be "db:exasol:"';
+my $dest_uri = $uri->clone;
+is $exa->destination, $dest_uri->as_string,
+ 'Destination should default to "db:exasol:"';
+is $exa->registry_destination, $exa->destination,
+ 'Registry destination should be the same as destination';
+
+my @std_opts = (
+ '-q',
+ '-L',
+ '-pipe',
+ '-x',
+ '-autoCompletion' => 'OFF',
+ '-encoding' => 'UTF8',
+ '-autocommit' => 'OFF',
+);
+
+is_deeply [$exa->exaplus], [$client, @std_opts],
+ 'exaplus command should be std opts-only';
+
+is $exa->_script, join( "\n" => (
+ 'SET FEEDBACK OFF;',
+ 'SET HEADING OFF;',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT 4;',
+ $exa->_registry_variable,
+) ), '_script should work';
+
+ok $exa->set_variables(foo => 'baz', whu => 'hi there', yo => q{'stellar'}),
+ 'Set some variables';
+
+is $exa->_script, join( "\n" => (
+ 'SET FEEDBACK OFF;',
+ 'SET HEADING OFF;',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT 4;',
+ "DEFINE foo='baz';",
+ "DEFINE whu='hi there';",
+ "DEFINE yo='''stellar''';",
+ $exa->_registry_variable,
+) ), '_script should assemble variables';
+
+##############################################################################
+# Test other configs for the target.
+ENV: {
+ my $mocker = Test::MockModule->new('App::Sqitch');
+ $mocker->mock(sysuser => 'sysuser=whatever');
+ my $exa = $CLASS->new(sqitch => $sqitch, target => $target);
+ is $exa->target->name, 'db:exasol:',
+ 'Target name should NOT fall back on sysuser';
+ is $exa->registry_destination, $exa->destination,
+ 'Registry target should be the same as destination';
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.exasol.client' => '/path/to/exaplus',
+ 'engine.exasol.target' => 'db:exasol://me:myself@localhost:4444',
+ 'engine.exasol.registry' => 'meta',
+);
+
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $exa = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another exasol';
+is $exa->client, '/path/to/exaplus', 'client should be as configured';
+is $exa->uri->as_string, 'db:exasol://me:myself@localhost:4444',
+ 'uri should be as configured';
+is $exa->registry, 'meta', 'registry should be as configured';
+is_deeply [$exa->exaplus], [qw(
+ /path/to/exaplus
+ -u me
+ -p myself
+ -c localhost:4444
+), @std_opts], 'exaplus command should be configured from URI config';
+
+is $exa->_script, join( "\n" => (
+ 'SET FEEDBACK OFF;',
+ 'SET HEADING OFF;',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT 4;',
+ 'DEFINE registry=meta;',
+) ), '_script should use registry from config settings';
+
+##############################################################################
+# Test _run() and _capture().
+can_ok $exa, qw(_run _capture);
+my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+my (@capture, @spool);
+$mock_sqitch->mock(spool => sub { shift; @spool = @_ });
+my $mock_run3 = Test::MockModule->new('IPC::Run3');
+$mock_run3->mock(run3 => sub { @capture = @_ });
+
+ok $exa->_run(qw(foo bar baz)), 'Call _run';
+my $fh = shift @spool;
+is_deeply \@spool, [$exa->exaplus],
+ 'EXAplus command should be passed to spool()';
+
+is join('', <$fh> ), $exa->_script(qw(foo bar baz)),
+ 'The script should be spooled';
+
+ok $exa->_capture(qw(foo bar baz)), 'Call _capture';
+is_deeply \@capture, [
+ [$exa->exaplus], \$exa->_script(qw(foo bar baz)), [], [],
+ { return_if_system_error => 1 },
+], 'Command and script should be passed to run3()';
+
+# Let's make sure that IPC::Run3 actually works as expected.
+$mock_run3->unmock_all;
+my $echo = Path::Class::file(qw(t echo.pl));
+my $mock_exa = Test::MockModule->new($CLASS);
+$mock_exa->mock(exaplus => sub { $^X, $echo, qw(hi there) });
+
+is join (', ' => $exa->_capture(qw(foo bar baz))), "hi there\n",
+ '_capture should actually capture';
+
+# Make it die.
+my $die = Path::Class::file(qw(t die.pl));
+$mock_exa->mock(exaplus => sub { $^X, $die, qw(hi there) });
+like capture_stderr {
+ throws_ok {
+ $exa->_capture('whatever'),
+ } 'App::Sqitch::X', '_capture should die when exaplus dies';
+}, qr/^OMGWTF/m, 'STDERR should be emitted by _capture';
+
+##############################################################################
+# Test _file_for_script().
+can_ok $exa, '_file_for_script';
+is $exa->_file_for_script(Path::Class::file 'foo'), 'foo',
+ 'File without special characters should be used directly';
+is $exa->_file_for_script(Path::Class::file '"foo"'), '""foo""',
+ 'Double quotes should be SQL-escaped';
+
+# Get the temp dir used by the engine.
+ok my $tmpdir = $exa->tmpdir, 'Get temp dir';
+isa_ok $tmpdir, 'Path::Class::Dir', 'Temp dir';
+
+# Make sure a file with @ is aliased.
+my $file = $tmpdir->file('foo@bar.sql');
+$file->touch; # File must exist, because on Windows it gets copied.
+is $exa->_file_for_script($file), $tmpdir->file('foo_bar.sql'),
+ 'File with special char should be aliased';
+
+# Make sure double-quotes are escaped.
+WIN32: {
+ $file = $tmpdir->file('"foo$bar".sql');
+ my $mock_file = Test::MockModule->new(ref $file);
+ # Windows doesn't like the quotation marks, so prevent it from writing.
+ $mock_file->mock(copy_to => 1) if App::Sqitch::ISWIN;
+ is $exa->_file_for_script($file), $tmpdir->file('""foo_bar"".sql'),
+ 'File with special char and quotes should be aliased';
+}
+
+##############################################################################
+# Test file and handle running.
+my @run;
+$mock_exa->mock(_capture => sub {shift; @run = @_ });
+ok $exa->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+is_deeply \@run, ['@"foo/bar.sql"'],
+ 'File should be passed to capture()';
+
+ok $exa->run_file('foo/"bar".sql'), 'Run foo/"bar".sql';
+is_deeply \@run, ['@"foo/""bar"".sql"'],
+ 'Double quotes in file passed to capture() should be escaped';
+
+ok $exa->run_handle('FH'), 'Spool a "file handle"';
+my $handles = shift @spool;
+is_deeply \@spool, [$exa->exaplus],
+ 'exaplus command should be passed to spool()';
+isa_ok $handles, 'ARRAY', 'Array ove handles should be passed to spool';
+$fh = $handles->[0];
+is join('', <$fh>), $exa->_script, 'First file handle should be script';
+is $handles->[1], 'FH', 'Second should be the passed handle';
+
+# Verify should go to capture unless verosity is > 1.
+$mock_exa->mock(_capture => sub {shift; @capture = @_ });
+ok $exa->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+is_deeply \@capture, ['@"foo/bar.sql"'],
+ 'Verify file should be passed to capture()';
+
+$mock_sqitch->mock(verbosity => 2);
+ok $exa->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+
+is_deeply \@capture, ['@"foo/bar.sql"'],
+ 'Verify file should be passed to run() for high verbosity';
+
+$mock_sqitch->unmock_all;
+$mock_exa->unmock_all;
+
+##############################################################################
+# Test DateTime formatting stuff.
+ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')";
+is sprintf($ts2char->(), 'foo'),
+ qq{'year:' || CAST(EXTRACT(YEAR FROM foo) AS SMALLINT)
+ || ':month:' || CAST(EXTRACT(MONTH FROM foo) AS SMALLINT)
+ || ':day:' || CAST(EXTRACT(DAY FROM foo) AS SMALLINT)
+ || ':hour:' || CAST(EXTRACT(HOUR FROM foo) AS SMALLINT)
+ || ':minute:' || CAST(EXTRACT(MINUTE FROM foo) AS SMALLINT)
+ || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM foo) AS NUMERIC(9,4)))
+ || ':time_zone:UTC'},
+ '_ts2char should work';
+
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+
+$dt = App::Sqitch::DateTime->new(
+ year => 2017, month => 11, day => 06,
+ hour => 11, minute => 47, second => 35, time_zone => 'Europe/Stockholm');
+is $exa->_char2ts($dt), '2017-11-06 10:47:35',
+ '_char2ts should present timestamp at UTC w/o tz identifier';
+
+##############################################################################
+# Test SQL helpers.
+is $exa->_listagg_format, q{GROUP_CONCAT(%s SEPARATOR ' ')}, 'Should have _listagg_format';
+is $exa->_ts_default, 'current_timestamp', 'Should have _ts_default';
+is $exa->_regex_op, 'REGEXP_LIKE', 'Should have _regex_op';
+is $exa->_simple_from, ' FROM dual', 'Should have _simple_from';
+is $exa->_limit_default, '18446744073709551611', 'Should have _limit_default';
+
+DBI: {
+ local *DBI::errstr;
+ ok !$exa->_no_table_error, 'Should have no table error';
+ ok !$exa->_no_column_error, 'Should have no column error';
+ $DBI::errstr = 'object foo not found';
+ ok $exa->_no_table_error, 'Should now have table error';
+ ok $exa->_no_column_error, 'Should now have no column error';
+}
+
+is_deeply [$exa->_limit_offset(8, 4)],
+ [['LIMIT 8', 'OFFSET 4'], []],
+ 'Should get limit and offset';
+is_deeply [$exa->_limit_offset(0, 2)],
+ [['LIMIT 18446744073709551611', 'OFFSET 2'], []],
+ 'Should get limit and offset when offset only';
+is_deeply [$exa->_limit_offset(12, 0)], [['LIMIT 12'], []],
+ 'Should get only limit with 0 offset';
+is_deeply [$exa->_limit_offset(12)], [['LIMIT 12'], []],
+ 'Should get only limit with noa offset';
+is_deeply [$exa->_limit_offset(0, 0)], [[], []],
+ 'Should get no limit or offset for 0s';
+is_deeply [$exa->_limit_offset()], [[], []],
+ 'Should get no limit or offset for no args';
+
+is_deeply [$exa->_regex_expr('corn', 'Obama$')],
+ ['corn REGEXP_LIKE ?', '.*Obama$'],
+ 'Should use regexp_like and prepend wildcard to regex';
+is_deeply [$exa->_regex_expr('corn', '^Obama')],
+ ['corn REGEXP_LIKE ?', '^Obama.*'],
+ 'Should use regexp_like and append wildcard to regex';
+is_deeply [$exa->_regex_expr('corn', '^Obama$')],
+ ['corn REGEXP_LIKE ?', '^Obama$'],
+ 'Should not chande regex with both anchors';
+is_deeply [$exa->_regex_expr('corn', 'Obama')],
+ ['corn REGEXP_LIKE ?', '.*Obama.*'],
+ 'Should append wildcards to both ends without anchors';
+
+##############################################################################
+# Can we do live tests?
+my $dbh;
+END {
+ return unless $dbh;
+ $dbh->{Driver}->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh;
+ });
+
+ $dbh->{RaiseError} = 0;
+ $dbh->{PrintError} = 1;
+ $dbh->do($_) for (
+ 'DROP SCHEMA sqitch CASCADE',
+ 'DROP SCHEMA sqitchtest CASCADE',
+ );
+}
+
+$uri = URI->new($ENV{EXA_URI} || 'db:dbadmin:password@localhost/dbadmin');
+my $err = try {
+ $exa->use_driver;
+ $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, {
+ PrintError => 0,
+ RaiseError => 1,
+ AutoCommit => 1,
+ });
+ undef;
+} catch {
+ eval { $_->message } || $_;
+};
+
+DBIEngineTest->run(
+ class => $CLASS,
+ target_params => [ uri => $uri ],
+ alt_target_params => [ uri => $uri, registry => 'sqitchtest' ],
+ skip_unless => sub {
+ my $self = shift;
+ die $err if $err;
+ # Make sure we have exaplus and can connect to the database.
+ $self->sqitch->probe( $self->client, '-version' );
+ $self->_capture('SELECT 1 FROM dual;');
+ },
+ engine_err_regex => qr/\[EXASOL\]\[EXASolution driver\]syntax error/,
+ init_error => __x(
+ 'Sqitch already initialized',
+ schema => 'sqitchtest',
+ ),
+ add_second_format => q{%s + interval '1' second},
+ test_dbh => sub {
+ my $dbh = shift;
+ # Make sure the sqitch schema is the first in the search path.
+ is $dbh->selectcol_arrayref('SELECT current_schema')->[0],
+ 'SQITCHTEST', 'The Sqitch schema should be the current schema';
+ },
+);
+
+done_testing;
diff --git a/t/firebird.t b/t/firebird.t
new file mode 100644
index 00000000..ac7a474a
--- /dev/null
+++ b/t/firebird.t
@@ -0,0 +1,380 @@
+#!/usr/bin/perl -w
+#
+# To test against a live Firebird database, you must set the FIREBIRD_URI environment variable.
+# this is a stanard URI::db URI, and should look something like this:
+#
+# export FIREBIRD_URI=db:firebird://sysdba:password@localhost//path/to/test.db
+#
+#
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Test::MockModule;
+use Path::Class;
+use Try::Tiny;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use File::Basename qw(dirname);
+use File::Spec::Functions;
+use File::Temp 'tempdir';
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+my $uri;
+my $tmpdir;
+my $have_fb_driver = 1; # assume DBD::Firebird is installed and so is Firebird
+
+# Is DBD::Firebird realy installed?
+try { require DBD::Firebird; } catch { $have_fb_driver = 0; };
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::firebird';
+ require_ok $CLASS or die;
+ $uri = URI->new($ENV{FIREBIRD_URI} || do {
+ my $user = $ENV{ISC_USER} || $ENV{DBI_USER} || 'SYSDBA';
+ my $pass = $ENV{ISC_PASSWORD} || $ENV{DBI_PASS} || 'masterkey';
+ "db:firebird://$user:$pass@/"
+ });
+ delete $ENV{$_} for qw(ISC_USER ISC_PASSWORD);
+ $tmpdir = File::Spec->tmpdir();
+}
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $config = TestConfig->new('core.engine' => 'firebird');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI->new('db:firebird:foo.fdb'),
+);
+isa_ok my $fb = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+
+is $fb->key, 'firebird', 'Key should be "firebird"';
+is $fb->name, 'Firebird', 'Name should be "Firebird"';
+is $fb->username, $ENV{ISC_USER}, 'Should have username from environment';
+is $fb->password, $ENV{ISC_PASSWORD}, 'Should have password from environment';
+
+my $have_fb_client;
+if ($have_fb_driver && (my $client = try { $fb->client })) {
+ $have_fb_client = 1;
+ like $client, qr/isql|fbsql|isql-fb/,
+ 'client should default to isql | fbsql | isql-fb';
+}
+
+is $fb->uri->dbname, file('foo.fdb'), 'dbname should be filled in';
+is $fb->registry_uri->dbname, 'sqitch.fdb',
+ 'registry dbname should be "sqitch.fdb"';
+
+is $fb->registry_destination, $fb->registry_uri->as_string,
+ 'registry_destination should be the same as registry URI';
+
+my @std_opts = (
+ '-quiet',
+ '-bail',
+ '-sqldialect' => '3',
+ '-pagelength' => '16384',
+ '-charset' => 'UTF8',
+);
+
+my $dbname = $fb->connection_string($fb->uri);
+is_deeply([$fb->isql], [$fb->client, @std_opts, $dbname],
+ 'isql command should be std opts-only') if $have_fb_client;
+
+isa_ok $fb = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+ok $fb->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'),
+ 'Set some variables';
+
+is_deeply([$fb->isql], [$fb->client, @std_opts, $dbname],
+ 'isql command should be std opts-only') if $have_fb_client;
+
+##############################################################################
+# Make sure environment variables are read.
+ENV: {
+ local $ENV{ISC_USER} = '__kamala__';
+ local $ENV{ISC_PASSWORD} = 'answer the question';
+ ok my $fb = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a firebird with environment variables set';
+ is $fb->username, $ENV{ISC_USER}, 'Should have username from environment';
+ is $fb->password, $ENV{ISC_PASSWORD}, 'Should have password from environment';
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.firebird.client' => '/path/to/isql',
+ 'engine.firebird.target' => 'db:firebird://freddy:s3cr3t@db.example.com:1234/widgets',
+ 'engine.firebird.registry' => 'meta',
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+ok $fb = $CLASS->new(sqitch => $sqitch, target => $target), 'Create another firebird';
+
+is $fb->client, '/path/to/isql', 'client should be as configured';
+is $fb->uri, URI::db->new('db:firebird://freddy:s3cr3t@db.example.com:1234/widgets'),
+ 'URI should be as configured';
+like $fb->destination, qr{db:firebird://freddy:?\@db.example.com:1234/widgets},
+ 'destination should default to URI without password';
+like $fb->registry_destination, qr{db:firebird://freddy:?\@db.example.com:1234/meta},
+ 'registry_destination should be URI with configured registry and no password';
+is_deeply [$fb->isql], [(
+ '/path/to/isql',
+ '-user', 'freddy',
+ '-password', 's3cr3t',
+), @std_opts, 'db.example.com/1234:widgets'], 'firebird command should be configured';
+
+##############################################################################
+# Test connection_string.
+can_ok $fb, 'connection_string';
+for my $file (qw(
+ foo.fdb
+ /blah/hi.fdb
+ C:/blah/hi.fdb
+)) {
+ # DB name only.
+ is $fb->connection_string( URI::db->new("db:firebird:$file") ),
+ $file, "Connection for db:firebird:$file";
+ # DB name and host.
+ is $fb->connection_string( URI::db->new("db:firebird:foo.com/$file") ),
+ "foo.com/$file", "Connection for db:firebird:foo.com/$file";
+ # DB name, host, and port
+ is $fb->connection_string( URI::db->new("db:firebird:foo.com:1234/$file") ),
+ "foo.com:1234/$file", "Connection for db:firebird:foo.com/$file:1234";
+}
+
+throws_ok { $fb->connection_string( URI::db->new('db:firebird:') ) }
+ 'App::Sqitch::X', 'Should get an exception for no db name';
+is $@->ident, 'firebird', 'No dbname exception ident should be "firebird"';
+is $@->message, __x(
+ 'Database name missing in URI {uri}',
+ uri => 'db:firebird:',
+), 'No dbname exception message should be correct';
+
+
+##############################################################################
+# Test _run(), _capture(), and _spool().
+can_ok $fb, qw(_run _capture _spool);
+my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+my (@run, $exp_pass);
+$mock_sqitch->mock(run => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @run = @_;
+ if (defined $exp_pass) {
+ is $ENV{ISC_PASSWORD}, $exp_pass, qq{ISC_PASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{ISC_PASSWORD}, 'ISC_PASSWORD should not exist';
+ }
+});
+
+my @capture;
+$mock_sqitch->mock(capture => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @capture = @_;
+ if (defined $exp_pass) {
+ is $ENV{ISC_PASSWORD}, $exp_pass, qq{ISC_PASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{ISC_PASSWORD}, 'ISC_PASSWORD should not exist';
+ }
+});
+
+my @spool;
+$mock_sqitch->mock(spool => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @spool = @_;
+ if (defined $exp_pass) {
+ is $ENV{ISC_PASSWORD}, $exp_pass, qq{ISC_PASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{ISC_PASSWORD}, 'ISC_PASSWORD should not exist';
+ }
+});
+
+$exp_pass = 's3cr3t';
+$target->uri->password($exp_pass);
+ok $fb->_run(qw(foo bar baz)), 'Call _run';
+is_deeply \@run, [$fb->isql, qw(foo bar baz)],
+ 'Command should be passed to run()';
+
+ok $fb->_spool('FH'), 'Call _spool';
+is_deeply \@spool, ['FH', $fb->isql],
+ 'Command should be passed to spool()';
+
+ok $fb->_capture(qw(foo bar baz)), 'Call _capture';
+is_deeply \@capture, [$fb->isql, qw(foo bar baz)],
+ 'Command should be passed to capture()';
+
+# Without password.
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $fb = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a firebird with sqitch with no pw';
+$exp_pass = undef;
+$target->uri->password($exp_pass);
+ok $fb->_run(qw(foo bar baz)), 'Call _run again';
+is_deeply \@run, [$fb->isql, qw(foo bar baz)],
+ 'Command should be passed to run() again';
+
+ok $fb->_spool('FH'), 'Call _spool again';
+is_deeply \@spool, ['FH', $fb->isql],
+ 'Command should be passed to spool() again';
+
+ok $fb->_capture(qw(foo bar baz)), 'Call _capture again';
+is_deeply \@capture, [$fb->isql, qw(foo bar baz)],
+ 'Command should be passed to capture() again';
+
+##############################################################################
+# Test file and handle running.
+ok $fb->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+is_deeply \@run, [$fb->isql, '-input', 'foo/bar.sql'],
+ 'File should be passed to run()';
+
+ok $fb->run_handle('FH'), 'Spool a "file handle"';
+is_deeply \@spool, ['FH', $fb->isql],
+ 'Handle should be passed to spool()';
+
+# Verify should go to capture unless verosity is > 1.
+ok $fb->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+is_deeply \@capture, [$fb->isql, '-input', 'foo/bar.sql'],
+ 'Verify file should be passed to capture()';
+
+$mock_sqitch->mock(verbosity => 2);
+ok $fb->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+is_deeply \@run, [$fb->isql, '-input', 'foo/bar.sql'],
+ 'Verify file should be passed to run() for high verbosity';
+
+$mock_sqitch->unmock_all;
+
+##############################################################################
+# Test DateTime formatting stuff.
+can_ok $CLASS, '_ts2char_format';
+is sprintf($CLASS->_ts2char_format, 'foo'),
+ q{'year:' || CAST(EXTRACT(YEAR FROM foo) AS SMALLINT)
+ || ':month:' || CAST(EXTRACT(MONTH FROM foo) AS SMALLINT)
+ || ':day:' || CAST(EXTRACT(DAY FROM foo) AS SMALLINT)
+ || ':hour:' || CAST(EXTRACT(HOUR FROM foo) AS SMALLINT)
+ || ':minute:' || CAST(EXTRACT(MINUTE FROM foo) AS SMALLINT)
+ || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM foo) AS NUMERIC(9,4)))
+ || ':time_zone:UTC'},
+ '_ts2char_format should work'; # WORKS! :)
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+
+##############################################################################
+
+# Can we do live tests?
+my ($data_dir, $fb_version, @cleanup) = ($tmpdir);
+my $err = try {
+ return unless $have_fb_driver;
+ if ($uri->dbname) {
+ $data_dir = dirname $uri->dbname; # Assumes local OS semantics.
+ } else {
+ # Assume we're running locally and create the database.
+ my $dbpath = catfile($tmpdir, '__sqitchtest__');
+ $data_dir = $tmpdir;
+ $uri->dbname($dbpath);
+ DBD::Firebird->create_database({
+ db_path => $dbpath,
+ user => $uri->user,
+ password => $uri->password,
+ character_set => 'UTF8',
+ page_size => 16384,
+ });
+ @cleanup = ($dbpath);
+ }
+ # Try to connect.
+ my $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, {
+ PrintError => 0,
+ RaiseError => 1,
+ AutoCommit => 1,
+ });
+ $fb_version = $dbh->selectcol_arrayref(q{
+ SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION')
+ FROM rdb$database
+ })->[0];
+ push @cleanup => map { catfile $data_dir, $_ } qw(__sqitchtest __metasqitch);
+ return undef;
+} catch {
+ eval { $_->message } || $_;
+};
+
+END {
+ return if $ENV{CI}; # No need to clean up under Travis.
+ foreach my $dbname (@cleanup) {
+ $uri->dbname($dbname);
+ my $dsn = $uri->dbi_dsn . q{;ib_dialect=3;ib_charset=UTF8};
+ my $dbh = DBI->connect($dsn, $uri->user, $uri->password, {
+ FetchHashKeyName => 'NAME_lc',
+ AutoCommit => 1,
+ RaiseError => 0,
+ PrintError => 0,
+ }) or die $DBI::errstr;
+
+ # Disconnect any other database handles.
+ $dbh->{Driver}->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh;
+ });
+
+ # Kill all other connections.
+ $dbh->do('DELETE FROM MON$ATTACHMENTS WHERE MON$ATTACHMENT_ID <> CURRENT_CONNECTION');
+ $dbh->func('ib_drop_database') or diag "Cannot drop '$dbname': $DBI::errstr";
+ }
+}
+
+DBIEngineTest->run(
+ class => $CLASS,
+ target_params => [ uri => $uri, registry => catfile($data_dir, '__metasqitch') ],
+ alt_target_params => [ uri => $uri, registry => catfile($data_dir, '__sqitchtest') ],
+ skip_unless => sub {
+ my $self = shift;
+ die $err if $err;
+ # Make sure we have the right isql and can connect to the
+ # database. Adapted from the FirebirdMaker.pm module of
+ # DBD::Firebird.
+ my $cmd = $self->client;
+ my $cmd_echo = qx(echo "quit;" | "$cmd" -z -quiet 2>&1 );
+ return 0 unless $cmd_echo =~ m{Firebird}ims;
+ # Skip if no DBD::Firebird.
+ return 0 unless $have_fb_driver;
+ say "# Connected to Firebird $fb_version" if $fb_version;
+ return 1;
+ },
+ engine_err_regex => qr/\QDynamic SQL Error\E/xms,
+ init_error => __x(
+ 'Sqitch database {database} already initialized',
+ database => catfile($data_dir, '__sqitchtest'),
+ ),
+ add_second_format => q{dateadd(1 second to %s)},
+ test_dbh => sub {
+ my $dbh = shift;
+ # Check the session configuration...
+ # To try: https://www.firebirdsql.org/refdocs/langrefupd21-intfunc-get_context.html
+ is(
+ $dbh->selectcol_arrayref(q{
+ SELECT rdb$get_context('SYSTEM', 'DB_NAME')
+ FROM rdb$database
+ })->[0],
+ catfile($data_dir, '__sqitchtest'),
+ 'The Sqitch db should be the current db'
+ );
+ },
+);
+
+done_testing;
diff --git a/t/help.t b/t/help.t
new file mode 100644
index 00000000..6fde4b3d
--- /dev/null
+++ b/t/help.t
@@ -0,0 +1,93 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 20;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::Exception;
+use Test::Warn;
+use Config;
+use File::Spec;
+use Test::MockModule;
+use Test::NoWarnings;
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::help';
+
+ok my $sqitch = App::Sqitch->new, 'Load a sqitch sqitch object';
+my $config = TestConfig->new;
+
+isa_ok my $help = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'help',
+ config => $config,
+}), $CLASS, 'Load help command';
+isa_ok $help, 'App::Sqitch::Command', 'Help command';
+
+can_ok $help, qw(
+ options
+ execute
+ find_and_show
+);
+
+is_deeply [$CLASS->options], [qw(
+ guide|g
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+my $mock = Test::MockModule->new($CLASS);
+my @args;
+$mock->mock(_pod2usage => sub { @args = @_} );
+
+ok $help->execute, 'Execute help';
+is_deeply \@args, [
+ $help,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitchcommands'),
+ '-verbose' => 2,
+ '-exitval' => 0,
+], 'Should show sqitch app docs';
+
+ok $help->execute('config'), 'Execute "config" help';
+is_deeply \@args, [
+ $help,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch-config'),
+ '-verbose' => 2,
+ '-exitval' => 0,
+], 'Should show "config" command docs';
+
+ok $help->execute('changes'), 'Execute "changes" help';
+is_deeply \@args, [
+ $help,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitchchanges'),
+ '-verbose' => 2,
+ '-exitval' => 0,
+], 'Should show "changes" command docs';
+
+ok $help->execute('tutorial'), 'Execute "tutorial" help';
+is_deeply \@args, [
+ $help,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitchtutorial'),
+ '-verbose' => 2,
+ '-exitval' => 0,
+], 'Should show "tutorial" command docs';
+
+my @fail;
+$mock->mock(fail => sub { @fail = @_ });
+throws_ok { $help->execute('nonexistent') } 'App::Sqitch::X',
+ 'Should get an exception for "nonexistent" help';
+is $@->ident, 'help', 'Exception ident should be "help"';
+is $@->message, __x(
+ 'No manual entry for {command}',
+ command => 'sqitch-nonexistent',
+), 'Should get failure message for nonexistent command';
+is $@->exitval, 1, 'Exception exit val should be 1';
diff --git a/t/init.t b/t/init.t
new file mode 100644
index 00000000..b1775f5a
--- /dev/null
+++ b/t/init.t
@@ -0,0 +1,641 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 187;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Path::Class;
+use Test::Dir;
+use Test::File qw(file_not_exists_ok file_exists_ok);
+use Test::Exception;
+use Test::Warn;
+use Test::File::Contents;
+use Test::NoWarnings;
+use File::Path qw(remove_tree make_path);
+use URI;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $exe_ext = App::Sqitch::ISWIN ? '.exe' : '';
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Command::init';
+ use_ok $CLASS or die;
+}
+
+isa_ok $CLASS, 'App::Sqitch::Command', $CLASS;
+chdir 't';
+
+##############################################################################
+# Test options and configuration.
+my $config = TestConfig->new;
+my $sqitch = App::Sqitch->new( config => $config);
+
+isa_ok my $init = $CLASS->new(
+ sqitch => $sqitch,
+ properties => {
+ top_dir => dir('init.mkdir'),
+ reworked_dir => dir('init.mkdir/reworked'),
+ },
+), $CLASS, 'Init command';
+isa_ok $init, 'App::Sqitch::Command', 'Init commmand';
+
+can_ok $init, qw(
+ uri
+ properties
+ options
+ configure
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::TargetConfigCommand"),
+ "$CLASS does TargetConfigCommand";
+
+is_deeply [$init->options], [qw(
+ uri=s
+ engine=s
+ target=s
+ plan-file|f=s
+ registry=s
+ client=s
+ extension=s
+ top-dir=s
+ dir|d=s%
+ set|s=s%
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure({}, {}), { properties => {}},
+ 'Default config should contain empty properties';
+is_deeply $CLASS->configure({}, { uri => 'https://example.com' }), {
+ uri => URI->new('https://example.com'),
+ properties => {},
+}, 'Should accept a URI in options';
+ok my $conf = $CLASS->configure({}, {
+ uri => 'https://example.com',
+ engine => 'pg',
+ top_dir => 'top',
+ plan_file => 'my.plan',
+ registry => 'bats',
+ client => 'cli',
+ extension => 'ddl',
+ target => 'db:pg:foo',
+ dir => {
+ deploy => 'dep',
+ revert => 'rev',
+ verify => 'ver',
+ reworked => 'wrk',
+ reworked_deploy => 'rdep',
+ reworked_revert => 'rrev',
+ reworked_verify => 'rver',
+ },
+ set => {
+ foo => 'bar',
+ prefix => 'x_',
+ },
+}), 'Get full config';
+
+isa_ok $conf->{uri}, 'URI', 'uri propertiy';
+is_deeply $conf->{properties}, {
+ engine => 'pg',
+ top_dir => 'top',
+ plan_file => 'my.plan',
+ registry => 'bats',
+ client => 'cli',
+ extension => 'ddl',
+ target => 'db:pg:foo',
+ deploy_dir => 'dep',
+ revert_dir => 'rev',
+ verify_dir => 'ver',
+ reworked_dir => 'wrk',
+ reworked_deploy_dir => 'rdep',
+ reworked_revert_dir => 'rrev',
+ reworked_verify_dir => 'rver',
+ variables => {
+ foo => 'bar',
+ prefix => 'x_',
+ },
+}, 'Should have properties';
+isa_ok $conf->{properties}{$_}, 'Path::Class::File', "$_ file attribute" for qw(
+ plan_file
+);
+isa_ok $conf->{properties}{$_}, 'Path::Class::Dir', "$_ directory attribute" for (
+ 'top_dir',
+ 'reworked_dir',
+ map { ($_, "reworked_$_") } qw(deploy_dir revert_dir verify_dir)
+);
+
+# Make sure invalid directories are ignored.
+throws_ok { $CLASS->new($CLASS->configure({}, {
+ dir => { foo => 'bar' },
+})) } 'App::Sqitch::X', 'Should fail on invalid directory name';
+is $@->ident, 'init', 'Invalid directory ident should be "init"';
+is $@->message, __x(
+ 'Unknown directory name: {prop}',
+ prop => 'foo',
+), 'The invalid directory messsage should be correct';
+
+throws_ok { $CLASS->new($CLASS->configure({}, {
+ dir => { foo => 'bar', cavort => 'ha' },
+})) } 'App::Sqitch::X', 'Should fail on invalid directory names';
+is $@->ident, 'init', 'Invalid directories ident should be "init"';
+is $@->message, __x(
+ 'Unknown directory names: {props}',
+ props => 'cavort, foo',
+), 'The invalid properties messsage should be correct';
+
+isa_ok my $target = $init->config_target, 'App::Sqitch::Target', 'default target';
+
+##############################################################################
+# Test make_directories_for.
+can_ok $init, 'make_directories_for';
+dir_not_exists_ok $target->top_dir;
+dir_not_exists_ok $_ for $init->directories_for($target);
+
+my $top_dir_string = $target->top_dir->stringify;
+END { remove_tree $top_dir_string if -e $top_dir_string }
+
+ok $init->make_directories_for($target), 'Make the directories';
+dir_exists_ok $_ for $init->directories_for($target);
+
+my $sep = dir('')->stringify;
+my $dirs = $init->properties;
+is_deeply +MockOutput->get_info, [
+ [__x "Created {file}", file => $target->deploy_dir . $sep],
+ [__x "Created {file}", file => $target->revert_dir . $sep],
+ [__x "Created {file}", file => $target->verify_dir . $sep],
+ [__x "Created {file}", file => $dirs->{reworked_dir}->subdir('deploy') . $sep],
+ [__x "Created {file}", file => $dirs->{reworked_dir}->subdir('revert') . $sep],
+ [__x "Created {file}", file => $dirs->{reworked_dir}->subdir('verify') . $sep],
+], 'Each should have been sent to info';
+
+# Do it again.
+ok $init->make_directories_for($target), 'Make the directories again';
+is_deeply +MockOutput->get_info, [], 'Nothing should have been sent to info';
+
+# Delete one of them.
+remove_tree $target->revert_dir->stringify;
+ok $init->make_directories_for($target), 'Make the directories once more';
+dir_exists_ok $target->revert_dir, 'revert dir exists again';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $target->revert_dir . $sep],
+], 'Should have noted creation of revert dir';
+
+remove_tree $top_dir_string;
+
+# Handle errors.
+FSERR: {
+ # Make mkpath to insert an error.
+ my $mock = Test::MockModule->new('File::Path');
+ $mock->mock( mkpath => sub {
+ my ($file, $p) = @_;
+ ${ $p->{error} } = [{ $file => 'Permission denied yo'}];
+ return;
+ });
+
+ throws_ok { $init->make_directories_for($target) } 'App::Sqitch::X',
+ 'Should fail on permission issue';
+ is $@->ident, 'init', 'Permission error should have ident "init"';
+ is $@->message, __x(
+ 'Error creating {path}: {error}',
+ path => $target->deploy_dir,
+ error => 'Permission denied yo',
+ ), 'The permission error should be formatted properly';
+}
+
+##############################################################################
+# Test write_config().
+$sqitch = App::Sqitch->new(config => $config);
+can_ok $init, 'write_config';
+
+my $write_dir = 'init.write';
+make_path $write_dir;
+END { remove_tree $write_dir }
+chdir $write_dir;
+END { chdir File::Spec->updir }
+my $conf_file = $sqitch->config->local_file;
+
+my $uri = URI->new('https://github.com/sqitchers/sqitch/');
+
+ok $init = $CLASS->new(
+ sqitch => $sqitch,
+), 'Another init object';
+file_not_exists_ok $conf_file;
+$target = $init->config_target;
+
+# Write empty config.
+ok $init->write_config, 'Write the config';
+file_exists_ok $conf_file;
+is_deeply $config->data_from($conf_file), {
+}, 'The configuration file should have no variables';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+], 'The creation should be sent to info';
+my $top_dir = File::Spec->curdir;
+my $deploy_dir = File::Spec->catdir(qw(deploy));
+my $revert_dir = File::Spec->catdir(qw(revert));
+my $verify_dir = File::Spec->catdir(qw(verify));
+my $plan_file = $target->top_dir->file('sqitch.plan')->cleanup->stringify;
+file_contents_like $conf_file, qr{\Q[core]
+ # engine =
+ # plan_file = $plan_file
+ # top_dir = $top_dir
+}m, 'All in core section should be commented-out';
+unlink $conf_file;
+
+# Set two options.
+$sqitch = App::Sqitch->new(config => $config);
+ok $init = $CLASS->new( sqitch => $sqitch, properties => { extension => 'foo' } ),
+ 'Another init object';
+$target = $init->config_target;
+ok $init->write_config, 'Write the config';
+file_exists_ok $conf_file;
+is_deeply $config->data_from($conf_file), {
+ 'core.extension' => 'foo',
+}, 'The configuration should have been written with the one setting';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+], 'The creation should be sent to info';
+
+file_contents_like $conf_file, qr{
+ # engine =
+ # plan_file = $plan_file
+ # top_dir = $top_dir
+}m, 'Other settings should be commented-out';
+
+# Go again.
+ok $init->write_config, 'Write the config again';
+is_deeply $config->data_from($conf_file), {
+ 'core.extension' => 'foo',
+}, 'The configuration should be unchanged';
+is_deeply +MockOutput->get_info, [
+], 'Nothing should have been sent to info';
+
+USERCONF: {
+ # Delete the file and write with a user config loaded.
+ unlink $conf_file;
+ my $config = TestConfig->from( user => file +File::Spec->updir, 'user.conf' );
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $init = $CLASS->new( sqitch => $sqitch, properties => { extension => 'foo' }),
+ 'Make an init object with user config';
+ file_not_exists_ok $conf_file;
+ ok $init->write_config, 'Write the config with a user conf';
+ file_exists_ok $conf_file;
+ is_deeply $config->data_from($conf_file), {
+ 'core.extension' => 'foo',
+ }, 'The configuration should just have core.top_dir';
+ is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+ ], 'The creation should be sent to info again';
+ file_contents_like $conf_file, qr{\Q
+ # engine =
+ # plan_file = $plan_file
+ # top_dir = $top_dir
+}m, 'Other settings should be commented-out';
+}
+
+SYSTEMCONF: {
+ # Delete the file and write with a system config loaded.
+ unlink $conf_file;
+ my $config = TestConfig->from( system => file +File::Spec->updir, 'sqitch.conf' );
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $init = $CLASS->new( sqitch => $sqitch, properties => { extension => 'foo' } ),
+ 'Make an init object with system config';
+ ok $target = $init->config_target, 'Get target';
+ file_not_exists_ok $conf_file;
+ ok $init->write_config, 'Write the config with a system conf';
+ file_exists_ok $conf_file;
+ is_deeply $config->data_from($conf_file), {
+ 'core.extension' => 'foo',
+ 'core.engine' => 'pg',
+ }, 'The configuration should have local and system config' or diag $conf_file->slurp;
+ is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+ ], 'The creation should be sent to info again';
+
+ my $plan_file = $target->top_dir->file('sqitch.plan')->stringify;
+ file_contents_like $conf_file, qr{\Q
+ # plan_file = $plan_file
+ # top_dir = migrations
+}m, 'Other settings should be commented-out';
+}
+
+##############################################################################
+# Now get it to write a bunch of other stuff.
+unlink $conf_file;
+$sqitch = App::Sqitch->new(config => $config);
+
+ok $init = $CLASS->new(
+ sqitch => $sqitch,
+ properties => {
+ engine => 'sqlite',
+ top_dir => dir('top'),
+ plan_file => file('my.plan'),
+ registry => 'bats',
+ client => 'cli',
+ target => 'db:sqlite:foo',
+ extension => 'ddl',
+ deploy_dir => dir('dep'),
+ revert_dir => dir('rev'),
+ verify_dir => dir('tst'),
+ reworked_deploy_dir => dir('rdep'),
+ reworked_revert_dir => dir('rrev'),
+ reworked_verify_dir => dir('rtst'),
+ variables => { ay => 'first', Bee => 'second' },
+ }
+), 'Create new init with sqitch non-default attributes';
+
+ok $init->write_config, 'Write the config with core attrs';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+], 'The creation should be sent to info once more';
+
+is_deeply $config->data_from($conf_file), {
+ 'core.top_dir' => 'top',
+ 'core.plan_file' => 'my.plan',
+ 'core.deploy_dir' => 'dep',
+ 'core.revert_dir' => 'rev',
+ 'core.verify_dir' => 'tst',
+ 'core.reworked_deploy_dir' => 'rdep',
+ 'core.reworked_revert_dir' => 'rrev',
+ 'core.reworked_verify_dir' => 'rtst',
+ 'core.extension' => 'ddl',
+ 'core.engine' => 'sqlite',
+ 'core.variables.ay' => 'first',
+ 'core.variables.bee' => 'second',
+ 'engine.sqlite.registry' => 'bats',
+ 'engine.sqlite.client' => 'cli',
+ 'engine.sqlite.target' => 'db:sqlite:foo',
+}, 'The configuration should have been written with core and engine values';
+
+##############################################################################
+# Try it with no options.
+unlink $conf_file;
+$sqitch = App::Sqitch->new(config => $config);
+ok $init = $CLASS->new( sqitch => $sqitch, properties => { engine => 'sqlite' } ),
+ 'Create new init with sqitch with default engine attributes';
+ok $init->write_config, 'Write the config with engine attrs';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+], 'The creation should be sent to info again again';
+is_deeply $config->data_from($conf_file), {
+ 'core.engine' => 'sqlite',
+}, 'The configuration should have been written with only the engine var';
+
+file_contents_like $conf_file, qr{^\Q# [engine "sqlite"]
+ # target = db:sqlite:
+ # registry = sqitch
+ # client = sqlite3$exe_ext
+}m, 'Engine section should be present but commented-out';
+
+# Now build it with other config.
+USERCONF: {
+ # Delete the file and write with a user config loaded.
+ unlink $conf_file;
+ my $config = TestConfig->from( user => file +File::Spec->updir, 'user.conf' );
+ $config->update('core.engine' => 'sqlite');
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $init = $CLASS->new( sqitch => $sqitch ),
+ 'Make an init with sqlite and user config';
+ file_not_exists_ok $conf_file;
+ ok $init->write_config, 'Write the config with sqlite config';
+ is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+ ], 'The creation should be sent to info once more';
+
+ is_deeply $config->data_from($conf_file), {
+ 'core.engine' => 'sqlite',
+ }, 'New config should have been written with sqlite values';
+
+ file_contents_like $conf_file, qr{^\t\Q# client = /opt/local/bin/sqlite3\E\n}m,
+ 'Configured client should be included in a comment';
+ file_contents_like $conf_file, qr/^\t# target = db:sqlite:my\.db\n/m,
+ 'Configured target should be included in a comment';
+ file_contents_like $conf_file, qr/^\t# registry = meta\n/m,
+ 'Configured registry should be included in a comment';
+}
+
+##############################################################################
+# Now get it to write engine.pg stuff.
+unlink $conf_file;
+$config->replace;
+$sqitch = App::Sqitch->new(config => $config);
+
+ok $init = $CLASS->new(
+ sqitch => $sqitch,
+ properties => { engine => 'pg', client => '/to/psql' },
+), 'Create new init with sqitch with more non-default engine attributes';
+ok $init->write_config, 'Write the config with more engine attrs';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+], 'The creation should be sent to info one more time';
+
+is_deeply $config->data_from($conf_file), {
+ 'core.engine' => 'pg',
+ 'engine.pg.client' => '/to/psql',
+}, 'The configuration should have been written with client values' or diag $conf_file->slurp;
+
+file_contents_like $conf_file, qr/^\t# registry = sqitch\n/m,
+ 'registry should be included in a comment';
+
+# Try it with no config or options.
+unlink $conf_file;
+$sqitch = App::Sqitch->new(config => $config);
+ok $init = $CLASS->new( sqitch => $sqitch, properties => { engine => 'pg' } ),
+ 'Create new init with sqitch with default engine attributes';
+ok $init->write_config, 'Write the config with engine attrs';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+], 'The creation should be sent to info again again again';
+is_deeply $config->data_from($conf_file), {
+ 'core.engine' => 'pg',
+}, 'The configuration should have been written with only the engine var' or diag $conf_file->slurp;
+
+file_contents_like $conf_file, qr{^\Q# [engine "pg"]
+ # target = db:pg:
+ # registry = sqitch
+ # client = psql$exe_ext
+}m, 'Engine section should be present but commented-out' or diag $conf_file->slurp;
+
+USERCONF: {
+ # Delete the file and write with a user config loaded.
+ unlink $conf_file;
+ my $config = TestConfig->from( user => file +File::Spec->updir, 'user.conf' );
+ $config->update('core.engine' => 'pg');
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $init = $CLASS->new( sqitch => $sqitch ),
+ 'Make an init with pg and user config';
+ file_not_exists_ok $conf_file;
+ ok $init->write_config, 'Write the config with pg config';
+ is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file]
+ ], 'The pg config creation should be sent to info';
+
+ is_deeply $config->data_from($conf_file), {
+ 'core.engine' => 'pg',
+ }, 'The configuration should have been written with pg options' or diag $conf_file->slurp;
+
+ file_contents_like $conf_file, qr/^\t# registry = meta\n/m,
+ 'Configured registry should be in a comment';
+ file_contents_like $conf_file,
+ qr{^\t# target = db:pg://postgres\@localhost/thingies\n}m,
+ 'Configured target should be in a comment';
+}
+
+##############################################################################
+# Test write_plan().
+can_ok $init, 'write_plan';
+$target = $init->config_target;
+$plan_file = $target->plan_file;
+file_not_exists_ok $plan_file, 'Plan file should not yet exist';
+ok $init->write_plan( project => 'nada' ), 'Write the plan file';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $plan_file]
+], 'The plan creation should be sent to info';
+file_exists_ok $plan_file, 'Plan file should now exist';
+file_contents_is $plan_file,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" .
+ '%project=nada' . "\n\n",
+ 'The contents should be correct';
+
+# Make sure we don't overwrite the file when initializing again.
+ok $init->write_plan( project => 'nada' ), 'Write the plan file again';
+file_exists_ok $plan_file, 'Plan file should still exist';
+file_contents_is $plan_file,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" .
+ '%project=nada' . "\n\n",
+ 'The contents should be identical';
+
+# Make sure we get an error trying to initalize a different plan.
+throws_ok { $init->write_plan( project => 'oopsie' ) } 'App::Sqitch::X',
+ 'Should get an error initialing a different project';
+is $@->ident, 'init', 'Initialization error ident should be "init"';
+is $@->message, __x(
+ 'Cannot initialize because project "{project}" already initialized in {file}',
+ project => 'nada',
+ file => $plan_file,
+), 'Initialzation error message should be correct';
+
+# Write a different file.
+my $fh = $plan_file->open('>:utf8_strict') or die "Cannot open $plan_file: $!\n";
+$fh->say('# testing 1, 2, 3');
+$fh->close;
+
+# Try writing again.
+throws_ok { $init->write_plan( project => 'foofoo' ) } 'App::Sqitch::X',
+ 'Should get an error initialzing a non-plan file';
+is $@->ident, 'init', 'Non-plan file error ident should be "init"';
+is $@->message, __x(
+ 'Cannot initialize because {file} already exists and is not a valid plan file',
+ file => $plan_file,
+), 'Non-plan file error message should be correct';
+file_contents_like $plan_file, qr/testing 1, 2, 3/,
+ 'The file should not be overwritten';
+
+# Make sure a URI gets written, if present.
+$plan_file->remove;
+$sqitch = App::Sqitch->new(config => $config);
+END { remove_tree dir('plan.dir')->stringify };
+ok $init = $CLASS->new(
+ sqitch => $sqitch,
+ uri => $uri,
+ properties => { top_dir => dir('plan.dir') },
+), 'Create new init with sqitch with project and URI';
+$target = $init->config_target;
+$plan_file = $target->plan_file;
+ok $init->write_plan( project => 'howdy', uri => $init->uri ), 'Write the plan file again';
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $plan_file->dir . $sep],
+ [__x 'Created {file}', file => $plan_file]
+], 'The plan creation should be sent to info againq';
+file_exists_ok $plan_file, 'Plan file should again exist';
+file_contents_is $plan_file,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" .
+ '%project=howdy' . "\n" .
+ '%uri=' . $uri->canonical . "\n\n",
+ 'The plan should include the project and uri pragmas';
+
+##############################################################################
+# Test _validate_project().
+can_ok $init, '_validate_project';
+NOPROJ: {
+ # Test handling of no command.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $CLASS->_validate_project }
+ qr/USAGE/, 'No project should yield usage';
+ is_deeply \@args, [$CLASS], 'No args should be passed to usage';
+}
+
+# Test invalid project names.
+my @bad_names = (
+ '^foo', # No leading punctuation
+ 'foo^', # No trailing punctuation
+ 'foo^6', # No trailing punctuation+digit
+ 'foo^666', # No trailing punctuation+digits
+ '%hi', # No leading punctuation
+ 'hi!', # No trailing punctuation
+ 'foo@bar', # No @ allowed at all
+ 'foo:bar', # No : allowed at all
+ '+foo', # No leading +
+ '-foo', # No leading -
+ '@foo', # No leading @
+);
+for my $bad (@bad_names) {
+ throws_ok { $init->_validate_project($bad) } 'App::Sqitch::X',
+ qq{Should get error for invalid project name "$bad"};
+ is $@->ident, 'init', qq{Bad project "$bad" ident should be "init"};
+ is $@->message, __x(
+ qq{invalid project name "{project}": project names must not }
+ . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in '
+ . 'punctuation or digits following punctuation',
+ project => $bad
+ ), qq{Bad project "$bad" error message should be correct};
+}
+
+##############################################################################
+# Bring it all together, yo.
+$conf_file->remove;
+$plan_file->remove;
+ok $init->execute('foofoo'), 'Execute!';
+
+# Should have directories.
+for my $attr (map { "$_\_dir"} qw(top deploy revert verify)) {
+ dir_exists_ok $target->$attr;
+}
+
+# Should have config and plan.
+file_exists_ok $conf_file;
+file_exists_ok $plan_file;
+
+# Should have the output.
+my @dir_messages = map {
+ [__x 'Created {file}', file => $target->$_ . $sep]
+} map { "$_\_dir" } qw(deploy revert verify);
+is_deeply +MockOutput->get_info, [
+ [__x 'Created {file}', file => $conf_file],
+ [__x 'Created {file}', file => $plan_file],
+ @dir_messages,
+], 'Should have status messages';
+
+file_contents_is $plan_file,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" .
+ '%project=foofoo' . "\n" .
+ '%uri=' . $uri->canonical . "\n\n",
+ 'The plan should have the --project name';
diff --git a/t/item_formatter.t b/t/item_formatter.t
new file mode 100644
index 00000000..335737fe
--- /dev/null
+++ b/t/item_formatter.t
@@ -0,0 +1,287 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 158;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::NoWarnings;
+use Test::Exception;
+use Path::Class;
+use Term::ANSIColor qw(color);
+use App::Sqitch::DateTime;
+use Encode;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+use LC;
+
+my $CLASS = 'App::Sqitch::ItemFormatter';
+require_ok $CLASS;
+can_ok $CLASS => qw(
+ new
+ abbrev
+ date_format
+ color
+ formatter
+ format
+);
+
+isa_ok my $formatter = $CLASS->new, $CLASS, 'Instantiated object';
+ok !$formatter->abbrev, 'Should not be abbreviated by default';
+is $formatter->date_format, 'iso', 'Default date format should be "iso"';
+
+###############################################################################
+# Test all formatting characters.
+my $cdt = App::Sqitch::DateTime->now;
+my $pdt = $cdt->clone->subtract(days => 1);
+my $local_cdt = $cdt->clone;
+$local_cdt->set_time_zone('local');
+my $local_pdt = $pdt->clone;
+$local_pdt->set_time_zone('local');
+my $craw = $cdt->as_string( format => 'raw' );
+
+my $event = {
+ event => 'deploy',
+ project => 'logit',
+ change_id => '000011112222333444',
+ change => 'lolz',
+ tags => [ '@beta', '@gamma' ],
+ committer_name => 'larry',
+ committer_email => 'larry@example.com',
+ committed_at => $cdt,
+ planner_name => 'damian',
+ planner_email => 'damian@example.com',
+ planned_at => $pdt,
+ note => "For the LOLZ.\n\nYou know, funny stuff and cute kittens, right?",
+ requires => [qw(foo bar)],
+ conflicts => []
+};
+
+$_->set_locale($LC::TIME) for ($local_cdt, $local_pdt);
+
+for my $spec (
+ ['%e', { event => 'deploy' }, 'deploy' ],
+ ['%e', { event => 'revert' }, 'revert' ],
+ ['%e', { event => 'fail' }, 'fail' ],
+
+ ['%L', { event => 'deploy' }, __ 'Deploy' ],
+ ['%L', { event => 'revert' }, __ 'Revert' ],
+ ['%L', { event => 'fail' }, __ 'Fail' ],
+
+ ['%l', { event => 'deploy' }, __ 'deploy' ],
+ ['%l', { event => 'revert' }, __ 'revert' ],
+ ['%l', { event => 'fail' }, __ 'fail' ],
+
+ ['%{event}_', {}, __ 'Event: ' ],
+ ['%{change}_', {}, __ 'Change: ' ],
+ ['%{committer}_', {}, __ 'Committer:' ],
+ ['%{planner}_', {}, __ 'Planner: ' ],
+ ['%{by}_', {}, __ 'By: ' ],
+ ['%{date}_', {}, __ 'Date: ' ],
+ ['%{committed}_', {}, __ 'Committed:' ],
+ ['%{planned}_', {}, __ 'Planned: ' ],
+ ['%{name}_', {}, __ 'Name: ' ],
+ ['%{email}_', {}, __ 'Email: ' ],
+ ['%{requires}_', {}, __ 'Requires: ' ],
+ ['%{conflicts}_', {}, __ 'Conflicts:' ],
+
+ ['%H', { change_id => '123456789' }, '123456789' ],
+ ['%h', { change_id => '123456789' }, '123456789' ],
+ ['%{5}h', { change_id => '123456789' }, '12345' ],
+ ['%{7}h', { change_id => '123456789' }, '1234567' ],
+
+ ['%n', { change => 'foo' }, 'foo'],
+ ['%n', { change => 'bar' }, 'bar'],
+ ['%o', { project => 'foo' }, 'foo'],
+ ['%o', { project => 'bar' }, 'bar'],
+
+ ['%c', { committer_name => 'larry', committer_email => 'larry@example.com' }, 'larry <larry@example.com>'],
+ ['%{n}c', { committer_name => 'damian' }, 'damian'],
+ ['%{name}c', { committer_name => 'chip' }, 'chip'],
+ ['%{e}c', { committer_email => 'larry@example.com' }, 'larry@example.com'],
+ ['%{email}c', { committer_email => 'damian@example.com' }, 'damian@example.com'],
+
+ ['%{date}c', { committed_at => $cdt }, $cdt->as_string( format => 'iso' ) ],
+ ['%{date:rfc}c', { committed_at => $cdt }, $cdt->as_string( format => 'rfc' ) ],
+ ['%{d:long}c', { committed_at => $cdt }, $cdt->as_string( format => 'long' ) ],
+ ["%{d:cldr:HH'h' mm'm'}c", { committed_at => $cdt }, $local_cdt->format_cldr( q{HH'h' mm'm'} ) ],
+ ["%{d:strftime:%a at %H:%M:%S}c", { committed_at => $cdt }, $local_cdt->strftime('%a at %H:%M:%S') ],
+
+ ['%p', { planner_name => 'larry', planner_email => 'larry@example.com' }, 'larry <larry@example.com>'],
+ ['%{n}p', { planner_name => 'damian' }, 'damian'],
+ ['%{name}p', { planner_name => 'chip' }, 'chip'],
+ ['%{e}p', { planner_email => 'larry@example.com' }, 'larry@example.com'],
+ ['%{email}p', { planner_email => 'damian@example.com' }, 'damian@example.com'],
+
+ ['%{date}p', { planned_at => $pdt }, $pdt->as_string( format => 'iso' ) ],
+ ['%{date:rfc}p', { planned_at => $pdt }, $pdt->as_string( format => 'rfc' ) ],
+ ['%{d:long}p', { planned_at => $pdt }, $pdt->as_string( format => 'long' ) ],
+ ["%{d:cldr:HH'h' mm'm'}p", { planned_at => $pdt }, $local_pdt->format_cldr( q{HH'h' mm'm'} ) ],
+ ["%{d:strftime:%a at %H:%M:%S}p", { planned_at => $pdt }, $local_pdt->strftime('%a at %H:%M:%S') ],
+
+ ['%t', { tags => [] }, '' ],
+ ['%t', { tags => ['@foo'] }, ' @foo' ],
+ ['%t', { tags => ['@foo', '@bar'] }, ' @foo, @bar' ],
+ ['%{|}t', { tags => [] }, '' ],
+ ['%{|}t', { tags => ['@foo'] }, ' @foo' ],
+ ['%{|}t', { tags => ['@foo', '@bar'] }, ' @foo|@bar' ],
+
+ ['%T', { tags => [] }, '' ],
+ ['%T', { tags => ['@foo'] }, ' (@foo)' ],
+ ['%T', { tags => ['@foo', '@bar'] }, ' (@foo, @bar)' ],
+ ['%{|}T', { tags => [] }, '' ],
+ ['%{|}T', { tags => ['@foo'] }, ' (@foo)' ],
+ ['%{|}T', { tags => ['@foo', '@bar'] }, ' (@foo|@bar)' ],
+
+ ['%r', { requires => [] }, '' ],
+ ['%r', { requires => ['foo'] }, ' foo' ],
+ ['%r', { requires => ['foo', 'bar'] }, ' foo, bar' ],
+ ['%{|}r', { requires => [] }, '' ],
+ ['%{|}r', { requires => ['foo'] }, ' foo' ],
+ ['%{|}r', { requires => ['foo', 'bar'] }, ' foo|bar' ],
+
+ ['%R', { requires => [] }, '' ],
+ ['%R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ],
+ ['%R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo, bar\n" ],
+ ['%{|}R', { requires => [] }, '' ],
+ ['%{|}R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ],
+ ['%{|}R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo|bar\n" ],
+
+ ['%x', { conflicts => [] }, '' ],
+ ['%x', { conflicts => ['foo'] }, ' foo' ],
+ ['%x', { conflicts => ['foo', 'bax'] }, ' foo, bax' ],
+ ['%{|}x', { conflicts => [] }, '' ],
+ ['%{|}x', { conflicts => ['foo'] }, ' foo' ],
+ ['%{|}x', { conflicts => ['foo', 'bax'] }, ' foo|bax' ],
+
+ ['%X', { conflicts => [] }, '' ],
+ ['%X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ],
+ ['%X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo, bar\n" ],
+ ['%{|}X', { conflicts => [] }, '' ],
+ ['%{|}X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ],
+ ['%{|}X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo|bar\n" ],
+
+ ['%{yellow}C', {}, '' ],
+ ['%{:event}C', { event => 'deploy' }, '' ],
+ ['%v', {}, "\n" ],
+ ['%%', {}, '%' ],
+
+ ['%s', { note => 'hi there' }, 'hi there' ],
+ ['%s', { note => "hi there\nyo" }, 'hi there' ],
+ ['%s', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, 'subject line' ],
+ ['%{ }s', { note => 'hi there' }, ' hi there' ],
+ ['%{xx}s', { note => 'hi there' }, 'xxhi there' ],
+
+ ['%b', { note => 'hi there' }, '' ],
+ ['%b', { note => "hi there\nyo" }, 'yo' ],
+ ['%b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "first graph\n\nsecond graph\n\n" ],
+ ['%{ }b', { note => 'hi there' }, '' ],
+ ['%{xxx }b', { note => "hi there\nyo" }, "xxx yo" ],
+ ['%{x}b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xfirst graph\nx\nxsecond graph\nx\n" ],
+ ['%{ }b', { note => "hi there\r\nyo" }, " yo" ],
+
+ ['%B', { note => 'hi there' }, 'hi there' ],
+ ['%B', { note => "hi there\nyo" }, "hi there\nyo" ],
+ ['%B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "subject line\n\nfirst graph\n\nsecond graph\n\n" ],
+ ['%{ }B', { note => 'hi there' }, ' hi there' ],
+ ['%{xxx }B', { note => "hi there\nyo" }, "xxx hi there\nxxx yo" ],
+ ['%{x}B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xsubject line\nx\nxfirst graph\nx\nxsecond graph\nx\n" ],
+ ['%{ }B', { note => "hi there\r\nyo" }, " hi there\r\n yo" ],
+
+ ['%{change}a', $event, "change $event->{change}\n" ],
+ ['%{change_id}a', $event, "change_id $event->{change_id}\n" ],
+ ['%{event}a', $event, "event $event->{event}\n" ],
+ ['%{tags}a', $event, 'tags ' . join(', ', @{ $event->{tags} }) . "\n" ],
+ ['%{requires}a', $event, 'requires ' . join(', ', @{ $event->{requires} }) . "\n" ],
+ ['%{conflicts}a', $event, '' ],
+ ['%{committer_name}a', $event, "committer_name $event->{committer_name}\n" ],
+ ['%{committed_at}a', $event, "committed_at $craw\n" ],
+) {
+ (my $desc = encode_utf8 $spec->[2]) =~ s/\n/[newline]/g;
+ local $ENV{ANSI_COLORS_DISABLED} = 1;
+ is $formatter->format( $spec->[0], $spec->[1] ), $spec->[2],
+ qq{Format "$spec->[0]" should output "$desc"};
+}
+
+throws_ok { $formatter->format( '%_', {} ) } 'App::Sqitch::X',
+ 'Should get exception for format "%_"';
+is $@->ident, 'format', '%_ error ident should be "format"';
+is $@->message, __ 'No label passed to the _ format',
+ '%_ error message should be correct';
+throws_ok { $formatter->format( '%{foo}_', {} ) } 'App::Sqitch::X',
+ 'Should get exception for unknown label in format "%_"';
+is $@->ident, 'format', 'Invalid %_ label error ident should be "format"';
+is $@->message, __x(
+ 'Unknown label "{label}" passed to the _ format',
+ label => 'foo'
+), 'Invalid %_ label error message should be correct';
+
+ok $formatter = $CLASS->new( abbrev => 4 ),
+ 'Instantiate with abbrev => 4';
+is $formatter->format( '%h', { change_id => '123456789' } ),
+ '1234', '%h should respect abbrev';
+is $formatter->format( '%H', { change_id => '123456789' } ),
+ '123456789', '%H should not respect abbrev';
+
+ok $formatter = $CLASS->new( date_format => 'rfc' ),
+ 'Instantiate with date_format => "rfc"';
+is $formatter->format( '%{date}c', { committed_at => $cdt } ),
+ $cdt->as_string( format => 'rfc' ),
+ '%{date}c should respect the date_format attribute';
+is $formatter->format( '%{d:iso}c', { committed_at => $cdt } ),
+ $cdt->as_string( format => 'iso' ),
+ '%{iso}c should override the date_format attribute';
+
+throws_ok { $formatter->format( '%{foo}a', {}) } 'App::Sqitch::X',
+ 'Should get exception for unknown attribute passed to %a';
+is $@->ident, 'format', '%a error ident should be "log"';
+is $@->message, __x(
+ '{attr} is not a valid change attribute', attr => 'foo'
+), '%a error message should be correct';
+
+# Test colors.
+delete $ENV{ANSI_COLORS_DISABLED};
+ok $formatter = $CLASS->new( color => 'always' ),
+ 'Construct with color "always"';
+for my $color (qw(yellow red blue cyan magenta)) {
+ is $formatter->format( "%{$color}C", {} ), color($color),
+ qq{Format "%{$color}C" should output }
+ . color($color) . $color . color('reset');
+}
+
+for my $spec (
+ [ ':event', { event => 'deploy' }, 'green', 'deploy' ],
+ [ ':event', { event => 'revert' }, 'blue', 'revert' ],
+ [ ':event', { event => 'fail' }, 'red', 'fail' ],
+) {
+ is $formatter->format( "%{$spec->[0]}C", $spec->[1] ), color($spec->[2]),
+ qq{Format "%{$spec->[0]}C" on "$spec->[3]" should output }
+ . color($spec->[2]) . $spec->[2] . color('reset');
+}
+
+throws_ok { $formatter->format( '%{BLUELOLZ}C', {} ) } 'App::Sqitch::X',
+ 'Should get an error for an invalid color';
+is $@->ident, 'format', 'Invalid color error ident should be "log"';
+is $@->message, __x(
+ '{color} is not a valid ANSI color', color => 'BLUELOLZ'
+), 'Invalid color error message should be correct';
+
+# Make sure color "never" works.
+ok $formatter = $CLASS->new( color => 'never' ),
+ 'Construct with color "never"';
+for my $color (qw(yellow red blue cyan magenta)) {
+ is $formatter->format( "%{$color}C", {} ), '',
+ qq{Format "%{$color}C" should not output a color};
+}
+
+# Make sure an unknown format character throws a proper exception.
+throws_ok { $formatter->format('%Z', {}) } 'App::Sqitch::X',
+ 'Should get an exception for a bad format code';
+is $@->ident, 'format',
+ 'bad format code format error ident should be "log"';
+is $@->message, __x(
+ 'Unknown format code "{code}"', code => 'Z',
+), 'bad format code format error message should be correct';
diff --git a/t/lib/App/Sqitch/Command/bad.pm b/t/lib/App/Sqitch/Command/bad.pm
new file mode 100644
index 00000000..b6d16a9f
--- /dev/null
+++ b/t/lib/App/Sqitch/Command/bad.pm
@@ -0,0 +1,3 @@
+package App::Sqitch::Command::bad;
+use Moo;
+die 'LOL BADZ';
diff --git a/t/lib/App/Sqitch/Command/good.pm b/t/lib/App/Sqitch/Command/good.pm
new file mode 100644
index 00000000..31107b5c
--- /dev/null
+++ b/t/lib/App/Sqitch/Command/good.pm
@@ -0,0 +1,20 @@
+package App::Sqitch::Command::good;
+use Moo;
+extends 'App::Sqitch::Command';
+
+1;
+
+=head1 NAME
+
+good - Good stuff.
+
+=head1 SYNOPSIS
+
+
+
+=head1 DESCRIPTION
+
+
+
+=cut
+
diff --git a/t/lib/App/Sqitch/Engine/bad.pm b/t/lib/App/Sqitch/Engine/bad.pm
new file mode 100644
index 00000000..14918aa8
--- /dev/null
+++ b/t/lib/App/Sqitch/Engine/bad.pm
@@ -0,0 +1,3 @@
+package App::Sqitch::Engine::bad;
+
+die 'LOL BADZ';
diff --git a/t/lib/App/Sqitch/Engine/good.pm b/t/lib/App/Sqitch/Engine/good.pm
new file mode 100644
index 00000000..44fe05cb
--- /dev/null
+++ b/t/lib/App/Sqitch/Engine/good.pm
@@ -0,0 +1,18 @@
+package App::Sqitch::Engine::good;
+extends 'App::Sqitch::Engine';
+1;
+
+=head1 NAME
+
+good - Good stuff.
+
+=head1 SYNOPSIS
+
+
+
+=head1 DESCRIPTION
+
+
+
+=cut
+
diff --git a/t/lib/DBIEngineTest.pm b/t/lib/DBIEngineTest.pm
new file mode 100644
index 00000000..91bee08f
--- /dev/null
+++ b/t/lib/DBIEngineTest.pm
@@ -0,0 +1,1807 @@
+package DBIEngineTest;
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Try::Tiny;
+use Test::More;
+use Test::Exception;
+use Time::HiRes qw(sleep);
+use Path::Class 0.33 qw(file dir);
+use Digest::SHA qw(sha1_hex);
+use Locale::TextDomain qw(App-Sqitch);
+use File::Temp 'tempdir';
+
+# Just die on warnings.
+use Carp; BEGIN { $SIG{__WARN__} = \&Carp::confess }
+
+sub run {
+ my ( $self, %p ) = @_;
+
+ my $class = $p{class};
+ my @sqitch_params = @{ $p{sqitch_params} || [] };
+ my $user1_name = 'Marge Simpson';
+ my $user1_email = 'marge@example.com';
+ my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+
+ # Mock script hashes using lines from the README.
+ my $mock_change = Test::MockModule->new('App::Sqitch::Plan::Change');
+ my @lines = grep { $_ } file('README.md')->slurp(
+ chomp => 1,
+ iomode => '<:encoding(UTF-8)'
+ );
+ # Each change should retain its own hash.
+ my $orig_deploy_hash;
+ $mock_change->mock(_deploy_hash => sub {
+ my $self = shift;
+ $self->$orig_deploy_hash || sha1_hex shift @lines;
+ });
+ $orig_deploy_hash = $mock_change->original('_deploy_hash');
+
+ can_ok $class, qw(
+ initialized
+ initialize
+ run_file
+ run_handle
+ log_deploy_change
+ log_fail_change
+ log_revert_change
+ earliest_change_id
+ latest_change_id
+ is_deployed_tag
+ is_deployed_change
+ change_id_for
+ change_id_for_depend
+ name_for_change_id
+ change_offset_from_id
+ change_id_offset_from_id
+ load_change
+ );
+
+ subtest 'live database' => sub {
+ my $sqitch = App::Sqitch->new(
+ @sqitch_params,
+ user_name => $user1_name,
+ user_email => $user1_email,
+ config => TestConfig->new(
+ 'core.engine' => $class->key,
+ 'core.top_dir' => dir(qw(t engine))->stringify,
+ 'core.plan_file' => file(qw(t engine sqitch.plan))->stringify,
+ )
+ );
+ my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ @{ $p{target_params} || [] },
+ );
+ my $engine = $class->new(
+ sqitch => $sqitch,
+ target => $target,
+ @{ $p{engine_params} || [] },
+ );
+ if (my $code = $p{skip_unless}) {
+ try {
+ $code->( $engine ) || die 'NO';
+ } catch {
+ (my $msg = eval { $_->message } || $_) =~ s/^/# /g;
+ plan skip_all => sprintf(
+ 'Unable to live-test %s engine: %s',
+ $class->name,
+ substr($msg, 2),
+ ) unless $ENV{'LIVE_' . uc $engine->key . '_REQUIRED'};
+ fail 'Connect to ' . $class->name;
+ diag substr $msg, 2;
+ } or return;
+ }
+ if (my $q = $p{version_query}) {
+ say '# Connected to ', $engine->dbh->selectcol_arrayref($q)->[0];
+ }
+ ok $engine, 'Engine instantiated';
+
+ ok !$engine->initialized, 'Database should not yet be initialized';
+ OLDREG: {
+ my $mock_file = Test::MockModule->new('Path::Class::File');
+ my $dir = file(__FILE__)->dir->subdir('upgradable_registries');
+ $mock_file->mock( dir => sub { $dir } );
+ ok $engine->initialize, 'Initialize the database';
+ };
+ ok $engine->initialized, 'Database should now be initialized';
+ ok !$engine->needs_upgrade, 'Registry should not need upgrading';
+ my $get_releases = sub {
+ my $releases = $engine->dbh->selectall_arrayref(q{
+ SELECT version, installer_name, installer_email
+ FROM releases
+ ORDER BY version
+ });
+ $_->[0] = sprintf '%.1f', $_->[0] for @{ $releases };
+ return $releases;
+ };
+ is_deeply $get_releases->(), [
+ [$engine->registry_release + 0, $sqitch->user_name, $sqitch->user_email]
+ ], 'The release should be registered';
+
+ # Let's make sure upgrades work.
+ $engine->dbh->do('DROP TABLE releases');
+ ok $engine->needs_upgrade, 'Registry should need upgrading';
+ MOCKINFO: {
+ my $sqitch_mocker = Test::MockModule->new(ref $sqitch);
+ my @args;
+ $sqitch_mocker->mock(info => sub { shift; push @args => @_ });
+ ok $engine->upgrade_registry, 'Upgrade the registry';
+ is_deeply \@args, [__x(
+ 'Upgrading the Sqitch registry from {old} to {new}',
+ old => 0,
+ new => '1.1',
+ ), ' * ' . __x(
+ 'From {old} to {new}',
+ old => 0,
+ new => '1.0',
+ ), ' * ' . __x(
+ 'From {old} to {new}',
+ old => '1.0',
+ new => '1.1',
+ )], 'Should have info output for upgrade';
+ }
+ ok !$engine->needs_upgrade, 'Registry should no longer need upgrading';
+ is_deeply $get_releases->(), [
+ [ '1.0', $sqitch->user_name, $sqitch->user_email ],
+ [ '1.1', $sqitch->user_name, $sqitch->user_email ],
+ ], 'The release should be registered again';
+
+ # Try it with a different Sqitch DB.
+ $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ @{ $p{alt_target_params} || [] },
+ );
+ ok $engine = $class->new(
+ sqitch => $sqitch,
+ target => $target,
+ @{ $p{alt_engine_params} || [] },
+ ), 'Create engine with alternate params';
+
+ is $engine->earliest_change_id, undef, 'No init, earliest change';
+ is $engine->latest_change_id, undef, 'No init, no latest change';
+
+ ok !$engine->initialized, 'Database should no longer seem initialized';
+ ok $engine->initialize, 'Initialize the database again';
+ ok $engine->initialized, 'Database should be initialized again';
+ ok !$engine->needs_upgrade, 'Registry should not need upgrading';
+
+ is $engine->earliest_change_id, undef, 'Still no earlist change';
+ is $engine->latest_change_id, undef, 'Still no latest changes';
+
+ # Make sure a second attempt to initialize dies.
+ throws_ok { $engine->initialize } 'App::Sqitch::X',
+ 'Should die on existing schema';
+ is $@->ident, 'engine', 'Mode should be "engine"';
+ is $@->message, $p{init_error},
+ 'And it should show the proper schema in the error message';
+
+ throws_ok { $engine->dbh->do('INSERT blah INTO __bar_____') } 'App::Sqitch::X',
+ 'Database error should be converted to Sqitch exception';
+ is $@->ident, $DBI::state, 'Ident should be SQL error state';
+ like $@->message, $p{engine_err_regex}, 'The message should be from the engine';
+ like $@->previous_exception, qr/DBD::[^:]+::db do failed: /,
+ 'The DBI error should be in preview_exception';
+
+ is $engine->current_state, undef, 'Current state should be undef';
+ is_deeply all( $engine->current_changes ), [], 'Should have no current changes';
+ is_deeply all( $engine->current_tags ), [], 'Should have no current tags';
+ is_deeply all( $engine->search_events ), [], 'Should have no events';
+
+ ##########################################################################
+ # Test the database connection, if appropriate.
+ if (my $code = $p{test_dbh}) {
+ $code->($engine->dbh);
+ }
+
+ ##########################################################################
+ # Test register_project().
+ can_ok $engine, 'register_project';
+ can_ok $engine, 'registered_projects';
+
+ is_deeply [ $engine->registered_projects ], [],
+ 'Should have no registered projects';
+
+ ok $engine->register_project, 'Register the project';
+ is_deeply [ $engine->registered_projects ], ['engine'],
+ 'Should have one registered project, "engine"';
+ is_deeply $engine->dbh->selectall_arrayref(
+ 'SELECT project, uri, creator_name, creator_email FROM projects'
+ ), [['engine', undef, $sqitch->user_name, $sqitch->user_email]],
+ 'The project should be registered';
+
+ # Try to register it again.
+ ok $engine->register_project, 'Register the project again';
+ is_deeply [ $engine->registered_projects ], ['engine'],
+ 'Should still have one registered project, "engine"';
+ is_deeply $engine->dbh->selectall_arrayref(
+ 'SELECT project, uri, creator_name, creator_email FROM projects'
+ ), [['engine', undef, $sqitch->user_name, $sqitch->user_email]],
+ 'The project should still be registered only once';
+
+ # Register a different project name.
+ MOCKPROJECT: {
+ my $plan_mocker = Test::MockModule->new(ref $target->plan );
+ $plan_mocker->mock(project => 'groovy');
+ $plan_mocker->mock(uri => 'https://example.com/');
+ ok $engine->register_project, 'Register a second project';
+ }
+
+ is_deeply [ $engine->registered_projects ], ['engine', 'groovy'],
+ 'Should have both registered projects';
+ is_deeply $engine->dbh->selectall_arrayref(
+ 'SELECT project, uri, creator_name, creator_email FROM projects ORDER BY created_at'
+ ), [
+ ['engine', undef, $sqitch->user_name, $sqitch->user_email],
+ ['groovy', 'https://example.com/', $sqitch->user_name, $sqitch->user_email],
+ ], 'Both projects should now be registered';
+
+ # Try to register with a different URI.
+ MOCKURI: {
+ my $plan_mocker = Test::MockModule->new(ref $target->plan );
+ my $plan_proj = 'engine';
+ my $plan_uri = 'https://example.net/';
+ $plan_mocker->mock(project => sub { $plan_proj });
+ $plan_mocker->mock(uri => sub { $plan_uri });
+ throws_ok { $engine->register_project } 'App::Sqitch::X',
+ 'Should get an error for defined URI vs NULL registered URI';
+ is $@->ident, 'engine', 'Defined URI error ident should be "engine"';
+ is $@->message, __x(
+ 'Cannot register "{project}" with URI {uri}: already exists with NULL URI',
+ project => 'engine',
+ uri => $plan_uri,
+ ), 'Defined URI error message should be correct';
+
+ # Try it when the registered URI is NULL.
+ $plan_proj = 'groovy';
+ throws_ok { $engine->register_project } 'App::Sqitch::X',
+ 'Should get an error for different URIs';
+ is $@->ident, 'engine', 'Different URI error ident should be "engine"';
+ is $@->message, __x(
+ 'Cannot register "{project}" with URI {uri}: already exists with URI {reg_uri}',
+ project => 'groovy',
+ uri => $plan_uri,
+ reg_uri => 'https://example.com/',
+ ), 'Different URI error message should be correct';
+
+ # Try with a NULL project URI.
+ $plan_uri = undef;
+ throws_ok { $engine->register_project } 'App::Sqitch::X',
+ 'Should get an error for NULL plan URI';
+ is $@->ident, 'engine', 'NULL plan URI error ident should be "engine"';
+ is $@->message, __x(
+ 'Cannot register "{project}" without URI: already exists with URI {uri}',
+ project => 'groovy',
+ uri => 'https://example.com/',
+ ), 'NULL plan uri error message should be correct';
+
+ # It should succeed when the name and URI are the same.
+ $plan_uri = 'https://example.com/';
+ ok $engine->register_project, 'Register "groovy" again';
+ is_deeply [ $engine->registered_projects ], ['engine', 'groovy'],
+ 'Should still have two registered projects';
+ is_deeply $engine->dbh->selectall_arrayref(
+ 'SELECT project, uri, creator_name, creator_email FROM projects ORDER BY created_at'
+ ), [
+ ['engine', undef, $sqitch->user_name, $sqitch->user_email],
+ ['groovy', 'https://example.com/', $sqitch->user_name, $sqitch->user_email],
+ ], 'Both projects should still be registered';
+
+ # Now try the same URI but a different name.
+ $plan_proj = 'bob';
+ throws_ok { $engine->register_project } 'App::Sqitch::X',
+ 'Should get error for an project with the URI';
+ is $@->ident, 'engine', 'Existing URI error ident should be "engine"';
+ is $@->message, __x(
+ 'Cannot register "{project}" with URI {uri}: project "{reg_proj}" already using that URI',
+ project => $plan_proj,
+ uri => $plan_uri,
+ reg_proj => 'groovy',
+ ), 'Exising URI error message should be correct';
+ }
+
+ ######################################################################
+ # Test log_deploy_change().
+ my $plan = $target->plan;
+ my $change = $plan->change_at(0);
+ my ($tag) = $change->tags;
+ is $change->name, 'users', 'Should have "users" change';
+ ok !$engine->is_deployed_change($change), 'The change should not be deployed';
+ is_deeply [$engine->are_deployed_changes($change)], [],
+ 'The change should not be deployed';
+
+ ok $engine->log_deploy_change($change), 'Deploy "users" change';
+ ok $engine->is_deployed_change($change), 'The change should now be deployed';
+ is_deeply [$engine->are_deployed_changes($change)], [$change->id],
+ 'The change should now be deployed';
+
+ is $engine->earliest_change_id, $change->id, 'Should get users ID for earliest change ID';
+ is $engine->earliest_change_id(1), undef, 'Should get no change offset 1 from earliest';
+ is $engine->latest_change_id, $change->id, 'Should get users ID for latest change ID';
+ is $engine->latest_change_id(1), undef, 'Should get no change offset 1 from latest';
+
+ is_deeply all_changes($engine), [[
+ $change->id, 'users', 'engine', 'User roles', $sqitch->user_name, $sqitch->user_email,
+ $change->planner_name, $change->planner_email,
+ ]],'A record should have been inserted into the changes table';
+ is_deeply get_dependencies($engine, $change->id), [], 'Should have no dependencies';
+ is_deeply [ $engine->changes_requiring_change($change) ], [],
+ 'Change should not be required';
+
+
+ my @event_data = ([
+ 'deploy',
+ $change->id,
+ 'users',
+ 'engine',
+ 'User roles',
+ $engine->_log_requires_param($change),
+ $engine->_log_conflicts_param($change),
+ $engine->_log_tags_param($change),
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $change->planner_name,
+ $change->planner_email
+ ]);
+
+ is_deeply all_events($engine), \@event_data,
+ 'A record should have been inserted into the events table';
+
+ is_deeply all_tags($engine), [[
+ $tag->id,
+ '@alpha',
+ $change->id,
+ 'engine',
+ 'Good to go!',
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $tag->planner_name,
+ $tag->planner_email,
+ ]], 'The tag should have been logged';
+
+ is $engine->name_for_change_id($change->id), 'users@alpha',
+ 'name_for_change_id() should return the change name with tag';
+
+ ok my $state = $engine->current_state, 'Get the current state';
+ isa_ok my $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime',
+ 'committed_at value';
+ is $dt->time_zone->name, 'UTC', 'committed_at TZ should be UTC';
+ is_deeply $state, {
+ project => 'engine',
+ change_id => $change->id,
+ script_hash => $change->script_hash,
+ change => 'users',
+ note => 'User roles',
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ tags => ['@alpha'],
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ planned_at => $change->timestamp,
+ }, 'The rest of the state should look right';
+ is_deeply all( $engine->current_changes ), [{
+ change_id => $change->id,
+ script_hash => $change->script_hash,
+ change => 'users',
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ committed_at => $dt,
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ planned_at => $change->timestamp,
+ }], 'Should have one current change';
+ is_deeply all( $engine->current_tags('nonesuch') ), [],
+ 'Should have no current chnages for nonexistent project';
+ is_deeply all( $engine->current_tags ), [{
+ tag_id => $tag->id,
+ tag => '@alpha',
+ committed_at => dt_for_tag( $engine, $tag->id ),
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ planner_name => $tag->planner_name,
+ planner_email => $tag->planner_email,
+ planned_at => $tag->timestamp,
+ }], 'Should have one current tags';
+ is_deeply all( $engine->current_tags('nonesuch') ), [],
+ 'Should have no current tags for nonexistent project';
+ my @events = ({
+ event => 'deploy',
+ project => 'engine',
+ change_id => $change->id,
+ change => 'users',
+ note => 'User roles',
+ requires => $engine->_log_requires_param($change),
+ conflicts => $engine->_log_conflicts_param($change),
+ tags => $engine->_log_tags_param($change),
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ committed_at => dt_for_event($engine, 0),
+ planned_at => $change->timestamp,
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ });
+ is_deeply all( $engine->search_events ), \@events, 'Should have one event';
+
+ ######################################################################
+ # Test log_new_tags().
+ ok $engine->log_new_tags($change), 'Log new tags for "users" change';
+ is_deeply all_tags($engine), [[
+ $tag->id,
+ '@alpha',
+ $change->id,
+ 'engine',
+ 'Good to go!',
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $tag->planner_name,
+ $tag->planner_email,
+ ]], 'The tag should be the same';
+
+ # Delete that tag.
+ $engine->dbh->do('DELETE FROM tags');
+ is_deeply all_tags($engine), [], 'Should now have no tags';
+
+ # Put it back.
+ ok $engine->log_new_tags($change), 'Log new tags for "users" change again';
+ is_deeply all_tags($engine), [[
+ $tag->id,
+ '@alpha',
+ $change->id,
+ 'engine',
+ 'Good to go!',
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $tag->planner_name,
+ $tag->planner_email,
+ ]], 'The tag should be back';
+
+ ######################################################################
+ # Test log_revert_change(). First shift existing event dates.
+ ok $engine->log_revert_change($change), 'Revert "users" change';
+ ok !$engine->is_deployed_change($change), 'The change should no longer be deployed';
+ is_deeply [$engine->are_deployed_changes($change)], [],
+ 'The change should no longer be deployed';
+
+ is $engine->earliest_change_id, undef, 'Should get undef for earliest change';
+ is $engine->latest_change_id, undef, 'Should get undef for latest change';
+
+ is_deeply all_changes($engine), [],
+ 'The record should have been deleted from the changes table';
+ is_deeply all_tags($engine), [], 'And the tag record should have been removed';
+ is_deeply get_dependencies($engine, $change->id), [], 'Should still have no dependencies';
+ is_deeply [ $engine->changes_requiring_change($change) ], [],
+ 'Change should not be required';
+
+ push @event_data, [
+ 'revert',
+ $change->id,
+ 'users',
+ 'engine',
+ 'User roles',
+ $engine->_log_requires_param($change),
+ $engine->_log_conflicts_param($change),
+ $engine->_log_tags_param($change),
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $change->planner_name,
+ $change->planner_email
+ ];
+
+ is_deeply all_events($engine), \@event_data,
+ 'The revert event should have been logged';
+
+ is $engine->name_for_change_id($change->id), undef,
+ 'name_for_change_id() should no longer return the change name';
+ is $engine->current_state, undef, 'Current state should be undef again';
+ is_deeply all( $engine->current_changes ), [],
+ 'Should again have no current changes';
+ is_deeply all( $engine->current_tags ), [], 'Should again have no current tags';
+
+ unshift @events => {
+ event => 'revert',
+ project => 'engine',
+ change_id => $change->id,
+ change => 'users',
+ note => 'User roles',
+ requires => $engine->_log_requires_param($change),
+ conflicts => $engine->_log_conflicts_param($change),
+ tags => $engine->_log_tags_param($change),
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ committed_at => dt_for_event($engine, 1),
+ planned_at => $change->timestamp,
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ };
+ is_deeply all( $engine->search_events ), \@events, 'Should have two events';
+
+ ######################################################################
+ # Test log_fail_change().
+ ok $engine->log_fail_change($change), 'Fail "users" change';
+ ok !$engine->is_deployed_change($change), 'The change still should not be deployed';
+ is_deeply [$engine->are_deployed_changes($change)], [],
+ 'The change still should not be deployed';
+ is $engine->earliest_change_id, undef, 'Should still get undef for earliest change';
+ is $engine->latest_change_id, undef, 'Should still get undef for latest change';
+ is_deeply all_changes($engine), [], 'Still should have not changes table record';
+ is_deeply all_tags($engine), [], 'Should still have no tag records';
+ is_deeply get_dependencies($engine, $change->id), [], 'Should still have no dependencies';
+ is_deeply [ $engine->changes_requiring_change($change) ], [],
+ 'Change should not be required';
+
+ push @event_data, [
+ 'fail',
+ $change->id,
+ 'users',
+ 'engine',
+ 'User roles',
+ $engine->_log_requires_param($change),
+ $engine->_log_conflicts_param($change),
+ $engine->_log_tags_param($change),
+ $sqitch->user_name,
+ $sqitch->user_email,
+ $change->planner_name,
+ $change->planner_email
+ ];
+
+ is_deeply all_events($engine), \@event_data, 'The fail event should have been logged';
+ is $engine->current_state, undef, 'Current state should still be undef';
+ is_deeply all( $engine->current_changes ), [], 'Should still have no current changes';
+ is_deeply all( $engine->current_tags ), [], 'Should still have no current tags';
+
+ unshift @events => {
+ event => 'fail',
+ project => 'engine',
+ change_id => $change->id,
+ change => 'users',
+ note => 'User roles',
+ requires => $engine->_log_requires_param($change),
+ conflicts => $engine->_log_conflicts_param($change),
+ tags => $engine->_log_tags_param($change),
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ committed_at => dt_for_event($engine, 2),
+ planned_at => $change->timestamp,
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ };
+ is_deeply all( $engine->search_events ), \@events, 'Should have 3 events';
+
+ # From here on in, use a different committer.
+ my $user2_name = 'Homer Simpson';
+ my $user2_email = 'homer@example.com';
+ $mock_sqitch->mock( user_name => $user2_name );
+ $mock_sqitch->mock( user_email => $user2_email );
+
+ ######################################################################
+ # Test a change with dependencies.
+ ok $engine->log_deploy_change($change), 'Deploy the change again';
+ ok $engine->is_deployed_tag($tag), 'The tag again should be deployed';
+ is $engine->earliest_change_id, $change->id, 'Should again get users ID for earliest change ID';
+ is $engine->earliest_change_id(1), undef, 'Should still get no change offset 1 from earliest';
+ is $engine->latest_change_id, $change->id, 'Should again get users ID for latest change ID';
+ is $engine->latest_change_id(1), undef, 'Should still get no change offset 1 from latest';
+
+ ok my $change2 = $plan->change_at(1), 'Get the second change';
+ is_deeply [sort $engine->are_deployed_changes($change, $change2)], [$change->id],
+ 'Only the first change should be deployed';
+ my ($req) = $change2->requires;
+ ok $req->resolved_id($change->id), 'Set resolved ID in required depend';
+ # Send this change back in time.
+ $engine->dbh->do(
+ 'UPDATE changes SET committed_at = ?',
+ undef, '2013-03-30 00:47:47',
+ );
+ ok $engine->log_deploy_change($change2), 'Deploy second change';
+ is $engine->earliest_change_id, $change->id, 'Should still get users ID for earliest change ID';
+ is $engine->earliest_change_id(1), $change2->id,
+ 'Should get "widgets" offset 1 from earliest';
+ is $engine->earliest_change_id(2), undef, 'Should get no change offset 2 from earliest';
+ is $engine->latest_change_id, $change2->id, 'Should get "widgets" ID for latest change ID';
+ is $engine->latest_change_id(1), $change->id,
+ 'Should get "user" offset 1 from earliest';
+ is $engine->latest_change_id(2), undef, 'Should get no change offset 2 from latest';
+
+ is_deeply all_changes($engine), [
+ [
+ $change->id,
+ 'users',
+ 'engine',
+ 'User roles',
+ $user2_name,
+ $user2_email,
+ $change->planner_name,
+ $change->planner_email,
+ ],
+ [
+ $change2->id,
+ 'widgets',
+ 'engine',
+ 'All in',
+ $user2_name,
+ $user2_email,
+ $change2->planner_name,
+ $change2->planner_email,
+ ],
+ ], 'Should have both changes and requires/conflcits deployed';
+ is_deeply [sort $engine->are_deployed_changes($change, $change2)],
+ [sort $change->id, $change2->id],
+ 'Both changes should be deployed';
+ is_deeply get_dependencies($engine, $change->id), [],
+ 'Should still have no dependencies for "users"';
+ is_deeply get_dependencies($engine, $change2->id), [
+ [
+ $change2->id,
+ 'conflict',
+ 'dr_evil',
+ undef,
+ ],
+ [
+ $change2->id,
+ 'require',
+ 'users',
+ $change->id,
+ ],
+ ], 'Should have both dependencies for "widgets"';
+
+ is_deeply [ $engine->changes_requiring_change($change) ], [{
+ project => 'engine',
+ change_id => $change2->id,
+ change => 'widgets',
+ asof_tag => undef,
+ }], 'Change "users" should be required by "widgets"';
+ is_deeply [ $engine->changes_requiring_change($change2) ], [],
+ 'Change "widgets" should not be required';
+
+ push @event_data, [
+ 'deploy',
+ $change->id,
+ 'users',
+ 'engine',
+ 'User roles',
+ $engine->_log_requires_param($change),
+ $engine->_log_conflicts_param($change),
+ $engine->_log_tags_param($change),
+ $user2_name,
+ $user2_email,
+ $change->planner_name,
+ $change->planner_email,
+ ], [
+ 'deploy',
+ $change2->id,
+ 'widgets',
+ 'engine',
+ 'All in',
+ $engine->_log_requires_param($change2),
+ $engine->_log_conflicts_param($change2),
+ $engine->_log_tags_param($change2),
+ $user2_name,
+ $user2_email,
+ $change2->planner_name,
+ $change2->planner_email,
+ ];
+ is_deeply all_events($engine), \@event_data,
+ 'The new change deploy should have been logged';
+
+ is $engine->name_for_change_id($change2->id), 'widgets@HEAD',
+ 'name_for_change_id() should return name with symbolic tag @HEAD';
+
+ ok $state = $engine->current_state, 'Get the current state again';
+ isa_ok $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime',
+ 'committed_at value';
+ is $dt->time_zone->name, 'UTC', 'committed_at TZ should be UTC';
+ is_deeply $state, {
+ project => 'engine',
+ change_id => $change2->id,
+ script_hash => $change2->script_hash,
+ change => 'widgets',
+ note => 'All in',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+ planned_at => $change2->timestamp,
+ tags => [],
+ }, 'The state should reference new change';
+
+ my @current_changes = (
+ {
+ change_id => $change2->id,
+ script_hash => $change2->script_hash,
+ change => 'widgets',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_change( $engine, $change2->id ),
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+ planned_at => $change2->timestamp,
+ },
+ {
+ change_id => $change->id,
+ script_hash => $change->script_hash,
+ change => 'users',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_change( $engine, $change->id ),
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ planned_at => $change->timestamp,
+ },
+ );
+
+ is_deeply all( $engine->current_changes ), \@current_changes,
+ 'Should have two current changes in reverse chronological order';
+
+ my @current_tags = (
+ {
+ tag_id => $tag->id,
+ tag => '@alpha',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_tag( $engine, $tag->id ),
+ planner_name => $tag->planner_name,
+ planner_email => $tag->planner_email,
+ planned_at => $tag->timestamp,
+ },
+ );
+ is_deeply all( $engine->current_tags ), \@current_tags,
+ 'Should again have one current tags';
+
+ unshift @events => {
+ event => 'deploy',
+ project => 'engine',
+ change_id => $change2->id,
+ change => 'widgets',
+ note => 'All in',
+ requires => $engine->_log_requires_param($change2),
+ conflicts => $engine->_log_conflicts_param($change2),
+ tags => $engine->_log_tags_param($change2),
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_event($engine, 4),
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+ planned_at => $change2->timestamp,
+ }, {
+ event => 'deploy',
+ project => 'engine',
+ change_id => $change->id,
+ change => 'users',
+ note => 'User roles',
+ requires => $engine->_log_requires_param($change),
+ conflicts => $engine->_log_conflicts_param($change),
+ tags => $engine->_log_tags_param($change),
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_event($engine, 3),
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ planned_at => $change->timestamp,
+ };
+ is_deeply all( $engine->search_events ), \@events, 'Should have 5 events';
+
+ ######################################################################
+ # Test deployed_changes(), deployed_changes_since(), load_change, and
+ # change_offset_from_id(), and change_id_offset_from_id()
+ can_ok $engine, qw(
+ deployed_changes
+ deployed_changes_since
+ load_change
+ change_offset_from_id
+ change_id_offset_from_id
+ );
+ my $change_hash = {
+ id => $change->id,
+ name => $change->name,
+ project => $change->project,
+ note => $change->note,
+ timestamp => $change->timestamp,
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+ tags => ['@alpha'],
+ };
+ my $change2_hash = {
+ id => $change2->id,
+ name => $change2->name,
+ project => $change2->project,
+ note => $change2->note,
+ timestamp => $change2->timestamp,
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+ tags => [],
+ };
+
+ is_deeply [$engine->deployed_changes], [$change_hash, $change2_hash],
+ 'Should have two deployed changes';
+ is_deeply [$engine->deployed_changes_since($change)], [$change2_hash],
+ 'Should find one deployed since the first one';
+ is_deeply [$engine->deployed_changes_since($change2)], [],
+ 'Should find none deployed since the second one';
+
+ is_deeply $engine->load_change($change->id), $change_hash,
+ 'Should load change 1';
+ is_deeply $engine->load_change($change2->id), $change2_hash,
+ 'Should load change 2';
+ is_deeply $engine->load_change('whatever'), undef,
+ 'load() should return undef for uknown change ID';
+
+ is_deeply $engine->change_offset_from_id($change->id, undef), $change_hash,
+ 'Should load change with no offset';
+ is_deeply $engine->change_offset_from_id($change2->id, 0), $change2_hash,
+ 'Should load change with offset 0';
+
+ is_deeply $engine->change_id_offset_from_id($change->id, undef), $change->id,
+ 'Should get change ID with no offset';
+ is_deeply $engine->change_id_offset_from_id($change2->id, 0), $change2->id,
+ 'Should get change ID with offset 0';
+
+ # Now try some offsets.
+ is_deeply $engine->change_offset_from_id($change->id, 1), $change2_hash,
+ 'Should find change with offset 1';
+ is_deeply $engine->change_offset_from_id($change2->id, -1), $change_hash,
+ 'Should find change with offset -1';
+ is_deeply $engine->change_offset_from_id($change->id, 2), undef,
+ 'Should find undef change with offset 2';
+
+ is_deeply $engine->change_id_offset_from_id($change->id, 1), $change2->id,
+ 'Should find change ID with offset 1';
+ is_deeply $engine->change_id_offset_from_id($change2->id, -1), $change->id,
+ 'Should find change ID with offset -1';
+ is_deeply $engine->change_id_offset_from_id($change->id, 2), undef,
+ 'Should find undef change ID with offset 2';
+
+ # Revert change 2.
+ ok $engine->log_revert_change($change2), 'Revert "widgets"';
+ is_deeply [$engine->deployed_changes], [$change_hash],
+ 'Should now have one deployed change ID';
+ is_deeply [$engine->deployed_changes_since($change)], [],
+ 'Should find none deployed since that one';
+
+ # Add another one.
+ ok $engine->log_deploy_change($change2), 'Log another change';
+ is_deeply [$engine->deployed_changes], [$change_hash, $change2_hash],
+ 'Should have both deployed change IDs';
+ is_deeply [$engine->deployed_changes_since($change)], [$change2_hash],
+ 'Should find only the second after the first';
+ is_deeply [$engine->deployed_changes_since($change2)], [],
+ 'Should find none after the second';
+
+ ok $state = $engine->current_state, 'Get the current state once more';
+ isa_ok $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime',
+ 'committed_at value';
+ is $dt->time_zone->name, 'UTC', 'committed_at TZ should be UTC';
+ is_deeply $state, {
+ project => 'engine',
+ change_id => $change2->id,
+ script_hash => $change2->script_hash,
+ change => 'widgets',
+ note => 'All in',
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ tags => [],
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+ planned_at => $change2->timestamp,
+ }, 'The new state should reference latest change';
+
+ # These were reverted and re-deployed, so might have new timestamps.
+ $current_changes[0]->{committed_at} = dt_for_change( $engine, $change2->id );
+ $current_changes[1]->{committed_at} = dt_for_change( $engine, $change->id );
+ is_deeply all( $engine->current_changes ), \@current_changes,
+ 'Should still have two current changes in reverse chronological order';
+ is_deeply all( $engine->current_tags ), \@current_tags,
+ 'Should still have one current tags';
+
+ unshift @events => {
+ event => 'deploy',
+ project => 'engine',
+ change_id => $change2->id,
+ change => 'widgets',
+ note => 'All in',
+ requires => $engine->_log_requires_param($change2),
+ conflicts => $engine->_log_conflicts_param($change2),
+ tags => $engine->_log_tags_param($change2),
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_event($engine, 6),
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+ planned_at => $change2->timestamp,
+ }, {
+ event => 'revert',
+ project => 'engine',
+ change_id => $change2->id,
+ change => 'widgets',
+ note => 'All in',
+ requires => $engine->_log_requires_param($change2),
+ conflicts => $engine->_log_conflicts_param($change2),
+ tags => $engine->_log_tags_param($change2),
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_event($engine, 5),
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+ planned_at => $change2->timestamp,
+ };
+ is_deeply all( $engine->search_events ), \@events, 'Should have 7 events';
+
+ ######################################################################
+ # Deploy the new changes with two tags.
+ $plan->add( name => 'fred', note => 'Hello Fred' );
+ $plan->add( name => 'barney', note => 'Hello Barney' );
+ $plan->tag( name => 'beta', note => 'Note beta' );
+ $plan->tag( name => 'gamma', note => 'Note gamma' );
+ ok my $fred = $plan->get('fred'), 'Get the "fred" change';
+ ok $engine->log_deploy_change($fred), 'Deploy "fred"';
+ sleep 0.1; # Give SQLite a little time to tick microseconds.
+ ok my $barney = $plan->get('barney'), 'Get the "barney" change';
+ ok $engine->log_deploy_change($barney), 'Deploy "barney"';
+
+ is $engine->earliest_change_id, $change->id, 'Earliest change should sill be "users"';
+ is $engine->earliest_change_id(1), $change2->id,
+ 'Should still get "widgets" offset 1 from earliest';
+ is $engine->earliest_change_id(2), $fred->id,
+ 'Should get "fred" offset 2 from earliest';
+ is $engine->earliest_change_id(3), $barney->id,
+ 'Should get "barney" offset 3 from earliest';
+
+ is $engine->latest_change_id, $barney->id, 'Latest change should be "barney"';
+ is $engine->latest_change_id(1), $fred->id, 'Should get "fred" offset 1 from latest';
+ is $engine->latest_change_id(2), $change2->id, 'Should get "widgets" offset 2 from latest';
+ is $engine->latest_change_id(3), $change->id, 'Should get "users" offset 3 from latest';
+
+ $state = $engine->current_state;
+ # MySQL's group_concat() does not by default sort by row order, alas.
+ $state->{tags} = [ sort @{ $state->{tags} } ]
+ if $class eq 'App::Sqitch::Engine::mysql';
+ is_deeply $state, {
+ project => 'engine',
+ change_id => $barney->id,
+ script_hash => $barney->script_hash,
+ change => 'barney',
+ note => 'Hello Barney',
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ committed_at => dt_for_change( $engine,$barney->id),
+ tags => [qw(@beta @gamma)],
+ planner_name => $barney->planner_name,
+ planner_email => $barney->planner_email,
+ planned_at => $barney->timestamp,
+ }, 'Barney should be in the current state';
+
+ unshift @current_changes => {
+ change_id => $barney->id,
+ script_hash => $barney->script_hash,
+ change => 'barney',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_change( $engine, $barney->id ),
+ planner_name => $barney->planner_name,
+ planner_email => $barney->planner_email,
+ planned_at => $barney->timestamp,
+ }, {
+ change_id => $fred->id,
+ script_hash => $fred->script_hash,
+ change => 'fred',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_change( $engine, $fred->id ),
+ planner_name => $fred->planner_name,
+ planner_email => $fred->planner_email,
+ planned_at => $fred->timestamp,
+ };
+
+ is_deeply all( $engine->current_changes ), \@current_changes,
+ 'Should have all four current changes in reverse chron order';
+
+ my ($beta, $gamma) = $barney->tags;
+ if (my $format = $p{add_second_format}) {
+ my $set = sprintf $format, 'committed_at';
+ $engine->dbh->do(
+ "UPDATE tags SET committed_at = $set WHERE tag = '\@gamma'"
+ );
+ }
+ unshift @current_tags => {
+ tag_id => $gamma->id,
+ tag => '@gamma',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_tag( $engine, $gamma->id ),
+ planner_name => $gamma->planner_name,
+ planner_email => $gamma->planner_email,
+ planned_at => $gamma->timestamp,
+ }, {
+ tag_id => $beta->id,
+ tag => '@beta',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_tag( $engine, $beta->id ),
+ planner_name => $beta->planner_name,
+ planner_email => $beta->planner_email,
+ planned_at => $beta->timestamp,
+ };
+
+ is_deeply all( $engine->current_tags ), \@current_tags,
+ 'Should now have three current tags in reverse chron order';
+
+ unshift @events => {
+ event => 'deploy',
+ project => 'engine',
+ change_id => $barney->id,
+ change => 'barney',
+ note => 'Hello Barney',
+ requires => $engine->_log_requires_param($barney),
+ conflicts => $engine->_log_conflicts_param($barney),
+ tags => $engine->_log_tags_param($barney),
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_event($engine, 8),
+ planner_name => $barney->planner_name,
+ planner_email => $barney->planner_email,
+ planned_at => $barney->timestamp,
+ }, {
+ event => 'deploy',
+ project => 'engine',
+ change_id => $fred->id,
+ change => 'fred',
+ note => 'Hello Fred',
+ requires => $engine->_log_requires_param($fred),
+ conflicts => $engine->_log_conflicts_param($fred),
+ tags => $engine->_log_tags_param($fred),
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_event($engine, 7),
+ planner_name => $fred->planner_name,
+ planner_email => $fred->planner_email,
+ planned_at => $fred->timestamp,
+ };
+ is_deeply all( $engine->search_events ), \@events, 'Should have 9 events';
+
+ ######################################################################
+ # Test search_events() parameters.
+ is_deeply all( $engine->search_events(limit => 2) ), [ @events[0..1] ],
+ 'The limit param to search_events should work';
+
+ is_deeply all( $engine->search_events(offset => 4) ), [ @events[4..$#events] ],
+ 'The offset param to search_events should work';
+
+ is_deeply all( $engine->search_events(limit => 3, offset => 4) ), [ @events[4..6] ],
+ 'The limit and offset params to search_events should work together';
+
+ is_deeply all( $engine->search_events( direction => 'DESC' ) ), \@events,
+ 'Should work to set direction "DESC" in search_events';
+ is_deeply all( $engine->search_events( direction => 'desc' ) ), \@events,
+ 'Should work to set direction "desc" in search_events';
+ is_deeply all( $engine->search_events( direction => 'descending' ) ), \@events,
+ 'Should work to set direction "descending" in search_events';
+
+ is_deeply all( $engine->search_events( direction => 'ASC' ) ),
+ [ reverse @events ],
+ 'Should work to set direction "ASC" in search_events';
+ is_deeply all( $engine->search_events( direction => 'asc' ) ),
+ [ reverse @events ],
+ 'Should work to set direction "asc" in search_events';
+ is_deeply all( $engine->search_events( direction => 'ascending' ) ),
+ [ reverse @events ],
+ 'Should work to set direction "ascending" in search_events';
+ throws_ok { $engine->search_events( direction => 'foo' ) } 'App::Sqitch::X',
+ 'Should catch exception for invalid search direction';
+ is $@->ident, 'DEV', 'Search direction error ident should be "DEV"';
+ is $@->message, 'Search direction must be either "ASC" or "DESC"',
+ 'Search direction error message should be correct';
+
+ is_deeply all( $engine->search_events( committer => 'Simpson$' ) ), \@events,
+ 'The committer param to search_events should work';
+ is_deeply all( $engine->search_events( committer => "^Homer" ) ),
+ [ @events[0..5] ],
+ 'The committer param to search_events should work as a regex';
+ is_deeply all( $engine->search_events( committer => 'Simpsonized$' ) ), [],
+ qq{Committer regex should fail to match with "Simpsonized\$"};
+
+ is_deeply all( $engine->search_events( change => 'users' ) ),
+ [ @events[5..$#events] ],
+ 'The change param to search_events should work with "users"';
+ is_deeply all( $engine->search_events( change => 'widgets' ) ),
+ [ @events[2..4] ],
+ 'The change param to search_events should work with "widgets"';
+ is_deeply all( $engine->search_events( change => 'fred' ) ),
+ [ $events[1] ],
+ 'The change param to search_events should work with "fred"';
+ is_deeply all( $engine->search_events( change => 'fre$' ) ), [],
+ 'The change param to search_events should return nothing for "fre$"';
+ is_deeply all( $engine->search_events( change => '(er|re)' ) ),
+ [@events[1, 5..8]],
+ 'The change param to search_events should return match "(er|re)"';
+
+ is_deeply all( $engine->search_events( event => [qw(deploy)] ) ),
+ [ grep { $_->{event} eq 'deploy' } @events ],
+ 'The event param should work with "deploy"';
+ is_deeply all( $engine->search_events( event => [qw(revert)] ) ),
+ [ grep { $_->{event} eq 'revert' } @events ],
+ 'The event param should work with "revert"';
+ is_deeply all( $engine->search_events( event => [qw(fail)] ) ),
+ [ grep { $_->{event} eq 'fail' } @events ],
+ 'The event param should work with "fail"';
+ is_deeply all( $engine->search_events( event => [qw(revert fail)] ) ),
+ [ grep { $_->{event} ne 'deploy' } @events ],
+ 'The event param should work with "revert" and "fail"';
+ is_deeply all( $engine->search_events( event => [qw(deploy revert fail)] ) ),
+ \@events,
+ 'The event param should work with "deploy", "revert", and "fail"';
+ is_deeply all( $engine->search_events( event => ['foo'] ) ), [],
+ 'The event param should return nothing for "foo"';
+
+ # Add an external project event.
+ ok my $ext_plan = App::Sqitch::Plan->new(
+ sqitch => $sqitch,
+ target => $target,
+ project => 'groovy',
+ ), 'Create external plan';
+ ok my $ext_change = $ext_plan->add(
+ plan => $ext_plan,
+ name => 'crazyman',
+ note => 'Crazy, right?',
+ ), "Create external change";
+
+ # Because we're gonna use a regular expression on events.project to
+ # get events from multiple projects, we need to make sure that we get
+ # things in the proper order, such as on MySQL 5.5, where there is no
+ # datetime precision. So pretend we're about to insert another
+ # "engine" project record to get the MySQL engine to wait out a clock
+ # second tick before inserting our "groovy" change. This is purely so
+ # we get things back in the proper order for the `project => 'g'` test
+ # below. In reality it shouldn't matter much.
+ $engine->_prepare_to_log(events => $barney);
+
+ ok $engine->log_deploy_change($ext_change), 'Log the external change';
+ my $ext_event = {
+ event => 'deploy',
+ project => 'groovy',
+ change_id => $ext_change->id,
+ change => $ext_change->name,
+ note => $ext_change->note,
+ requires => $engine->_log_requires_param($ext_change),
+ conflicts => $engine->_log_conflicts_param($ext_change),
+ tags => $engine->_log_tags_param($ext_change),
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_event($engine, 9),
+ planner_name => $user2_name,
+ planner_email => $user2_email,
+ planned_at => $ext_change->timestamp,
+ };
+ is_deeply all( $engine->search_events( project => '^engine$' ) ), \@events,
+ 'The project param to search_events should work';
+ is_deeply all( $engine->search_events( project => '^groovy$' ) ), [$ext_event],
+ 'The project param to search_events should work with external project';
+ is_deeply all( $engine->search_events( project => 'g' ) ), [$ext_event, @events],
+ 'The project param to search_events should match across projects';
+ is_deeply all( $engine->search_events( project => 'nonexistent' ) ), [],
+ qq{Project regex should fail to match with "nonexistent"};
+
+ # Make sure we do not see these changes where we should not.
+ ok !grep( { $_ eq $ext_change->id } $engine->deployed_changes),
+ 'deployed_changes should not include external change';
+ ok !grep( { $_ eq $ext_change->id } $engine->deployed_changes_since($change)),
+ 'deployed_changes_since should not include external change';
+
+ is $engine->earliest_change_id, $change->id,
+ 'Earliest change should sill be "users"';
+ isnt $engine->latest_change_id, $ext_change->id,
+ 'Latest change ID should not be from external project';
+
+ throws_ok { $engine->search_events(foo => 1) } 'App::Sqitch::X',
+ 'Should catch exception for invalid search param';
+ is $@->ident, 'DEV', 'Invalid search param error ident should be "DEV"';
+ is $@->message, 'Invalid parameters passed to search_events(): foo',
+ 'Invalid search param error message should be correct';
+
+ throws_ok { $engine->search_events(foo => 1, bar => 2) } 'App::Sqitch::X',
+ 'Should catch exception for invalid search params';
+ is $@->ident, 'DEV', 'Invalid search params error ident should be "DEV"';
+ is $@->message, 'Invalid parameters passed to search_events(): bar, foo',
+ 'Invalid search params error message should be correct';
+
+ ######################################################################
+ # Now that we have a change from an externa project, get its state.
+ ok $state = $engine->current_state('groovy'), 'Get the "groovy" state';
+ isa_ok $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime',
+ 'groofy committed_at value';
+ is $dt->time_zone->name, 'UTC', 'groovy committed_at TZ should be UTC';
+ is_deeply $state, {
+ project => 'groovy',
+ change_id => $ext_change->id,
+ script_hash => $ext_change->script_hash,
+ change => $ext_change->name,
+ note => $ext_change->note,
+ committer_name => $sqitch->user_name,
+ committer_email => $sqitch->user_email,
+ tags => [],
+ planner_name => $ext_change->planner_name,
+ planner_email => $ext_change->planner_email,
+ planned_at => $ext_change->timestamp,
+ }, 'The rest of the state should look right';
+
+ ######################################################################
+ # Test change_id_for().
+ for my $spec (
+ [
+ 'change_id only',
+ { change_id => $change->id },
+ $change->id,
+ ],
+ [
+ 'change only',
+ { change => $change->name },
+ $change->id,
+ ],
+ [
+ 'change + tag',
+ { change => $change->name, tag => 'alpha' },
+ $change->id,
+ ],
+ [
+ 'change@HEAD',
+ { change => $change->name, tag => 'HEAD' },
+ $change->id,
+ ],
+ [
+ 'tag only',
+ { tag => 'alpha' },
+ $change->id,
+ ],
+ [
+ 'ROOT',
+ { tag => 'ROOT' },
+ $change->id,
+ ],
+ [
+ 'HEAD',
+ { tag => 'HEAD' },
+ $barney->id,
+ ],
+ [
+ 'project:ROOT',
+ { tag => 'ROOT', project => 'groovy' },
+ $ext_change->id,
+ ],
+ [
+ 'project:HEAD',
+ { tag => 'HEAD', project => 'groovy' },
+ $ext_change->id,
+ ],
+ ) {
+ my ( $desc, $params, $exp_id ) = @{ $spec };
+ is $engine->change_id_for(%{ $params }), $exp_id, "Should find id for $desc";
+ }
+
+ for my $spec (
+ [
+ 'unkonwn id',
+ { change_id => 'whatever' },
+ ],
+ [
+ 'unkonwn change',
+ { change => 'whatever' },
+ ],
+ [
+ 'unkonwn tag',
+ { tag => 'whatever' },
+ ],
+ [
+ 'change + unkonwn tag',
+ { change => $change->name, tag => 'whatever' },
+ ],
+ [
+ 'change@ROOT',
+ { change => $change->name, tag => 'ROOT' },
+ ],
+ [
+ 'change + different project',
+ { change => $change->name, project => 'whatever' },
+ ],
+ [
+ 'tag + different project',
+ { tag => 'alpha', project => 'whatever' },
+ ],
+ ) {
+ my ( $desc, $params ) = @{ $spec };
+ is $engine->change_id_for(%{ $params }), undef, "Should find nothing for $desc";
+ }
+
+ ######################################################################
+ # Test change_id_for_depend().
+ my $id = '4f1e83f409f5f533eeef9d16b8a59e2c0aa91cc1';
+ my $i;
+
+ for my $spec (
+ [
+ 'id only',
+ { id => $id },
+ { id => $id },
+ ],
+ [
+ 'change + tag',
+ { change => 'bart', tag => 'epsilon' },
+ { name => 'bart' }
+ ],
+ [
+ 'change only',
+ { change => 'lisa' },
+ { name => 'lisa' },
+ ],
+ [
+ 'tag only',
+ { tag => 'sigma' },
+ { name => 'maggie' },
+ ],
+ ) {
+ my ( $desc, $dep_params, $chg_params ) = @{ $spec };
+
+ # Test as an internal dependency.
+ INTERNAL: {
+ ok my $change = $plan->add(
+ name => 'foo' . ++$i,
+ %{$chg_params},
+ ), "Create internal $desc change";
+
+ # Tag it if necessary.
+ if (my $tag = $dep_params->{tag}) {
+ ok $plan->tag(name => $tag), "Add tag internal \@$tag";
+ }
+
+ # Should start with unsatisfied dependency.
+ ok my $dep = App::Sqitch::Plan::Depend->new(
+ plan => $plan,
+ project => $plan->project,
+ %{ $dep_params },
+ ), "Create internal $desc dependency";
+ is $engine->change_id_for_depend($dep), undef,
+ "Internal $desc depencency should not be satisfied";
+
+ # Once deployed, dependency should be satisfied.
+ ok $engine->log_deploy_change($change),
+ "Log internal $desc change deployment";
+ is $engine->change_id_for_depend($dep), $change->id,
+ "Internal $desc depencency should now be satisfied";
+
+ # Revert it and try again.
+ sleep 0.1; # Give SQLite a little time to tick microseconds.
+ ok $engine->log_revert_change($change),
+ "Log internal $desc change reversion";
+ is $engine->change_id_for_depend($dep), undef,
+ "Internal $desc depencency should again be unsatisfied";
+ }
+
+ # Now test as an external dependency.
+ EXTERNAL: {
+ # Make sure we have unique IDs.
+ $_->{id} = 'dcb10d16276c9be8956274740d9f332bd71344ed'
+ for grep { $_->{id} } $dep_params, $chg_params;
+
+ # Make Change and Tag return registered external project "groovy".
+ $dep_params->{project} = 'groovy';
+ my $line_mocker = Test::MockModule->new('App::Sqitch::Plan::Line');
+ $line_mocker->mock(project => $dep_params->{project});
+
+ ok my $change = App::Sqitch::Plan::Change->new(
+ plan => $plan,
+ name => 'foo' . ++$i,
+ %{$chg_params},
+ ), "Create external $desc change";
+
+ # Tag it if necessary.
+ if (my $tag = $dep_params->{tag}) {
+ ok $change->add_tag(App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ change => $change,
+ name => $tag,
+ ) ), "Add tag external \@$tag";
+ }
+
+ # Should start with unsatisfied dependency.
+ ok my $dep = App::Sqitch::Plan::Depend->new(
+ plan => $plan,
+ project => $plan->project,
+ %{ $dep_params },
+ ), "Create external $desc dependency";
+ is $engine->change_id_for_depend($dep), undef,
+ "External $desc depencency should not be satisfied";
+
+ # Once deployed, dependency should be satisfied.
+ ok $engine->log_deploy_change($change),
+ "Log external $desc change deployment";
+
+ is $engine->change_id_for_depend($dep), $change->id,
+ "External $desc depencency should now be satisfied";
+
+ # Revert it and try again.
+ sleep 0.1; # Give SQLite a little time to tick microseconds.
+ ok $engine->log_revert_change($change),
+ "Log external $desc change reversion";
+ is $engine->change_id_for_depend($dep), undef,
+ "External $desc depencency should again be unsatisfied";
+ }
+ }
+
+ ok my $ext_change2 = App::Sqitch::Plan::Change->new(
+ plan => $ext_plan,
+ name => 'outside_in',
+ ), "Create another external change";
+ ok $ext_change2->add_tag( my $ext_tag = App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ change => $ext_change2,
+ name => 'meta',
+ ) ), 'Add tag external "meta"';
+
+ ok $engine->log_deploy_change($ext_change2), 'Log the external change with tag';
+
+ # Make sure name_for_change_id() works properly.
+ ok $engine->dbh->do(q{DELETE FROM tags WHERE project = 'engine'}),
+ 'Delete the engine project tags';
+ is $engine->name_for_change_id($change2->id), 'widgets@HEAD',
+ 'name_for_change_id() should return "widgets@HEAD" for its ID';
+ is $engine->name_for_change_id($ext_change2->id), 'outside_in@meta',
+ 'name_for_change_id() should return "outside_in@meta" for its ID';
+
+ # Make sure current_changes and current_tags are project-scoped.
+ is_deeply all( $engine->current_changes ), \@current_changes,
+ 'Should have only the "engine" changes from current_changes';
+ is_deeply all( $engine->current_changes('groovy') ), [
+ {
+ change_id => $ext_change2->id,
+ script_hash => $ext_change2->script_hash,
+ change => $ext_change2->name,
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_change( $engine, $ext_change2->id ),
+ planner_name => $ext_change2->planner_name,
+ planner_email => $ext_change2->planner_email,
+ planned_at => $ext_change2->timestamp,
+ }, {
+ change_id => $ext_change->id,
+ script_hash => $ext_change->script_hash,
+ change => $ext_change->name,
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_change( $engine, $ext_change->id ),
+ planner_name => $ext_change->planner_name,
+ planner_email => $ext_change->planner_email,
+ planned_at => $ext_change->timestamp,
+ }
+ ], 'Should get only requestd project changes from current_changes';
+ is_deeply all( $engine->current_tags ), [],
+ 'Should no longer have "engine" project tags';
+ is_deeply all( $engine->current_tags('groovy') ), [{
+ tag_id => $ext_tag->id,
+ tag => '@meta',
+ committer_name => $user2_name,
+ committer_email => $user2_email,
+ committed_at => dt_for_tag( $engine, $ext_tag->id ),
+ planner_name => $ext_tag->planner_name,
+ planner_email => $ext_tag->planner_email,
+ planned_at => $ext_tag->timestamp,
+ }], 'Should get groovy tags from current_chages()';
+
+ ######################################################################
+ # Test changes with multiple and cross-project dependencies.
+ ok my $hyper = $plan->add(
+ name => 'hypercritical',
+ requires => ['engine:fred', 'groovy:crazyman'],
+ ), 'Create change "hypercritial" in current plan';
+ $_->resolved_id( $engine->change_id_for_depend($_) ) for $hyper->requires;
+ ok $engine->log_deploy_change($hyper), 'Log change "hyper"';
+
+ is_deeply [ $engine->changes_requiring_change($hyper) ], [],
+ 'No changes should require "hypercritical"';
+ is_deeply [ $engine->changes_requiring_change($fred) ], [{
+ project => 'engine',
+ change_id => $hyper->id,
+ change => $hyper->name,
+ asof_tag => undef,
+ }], 'Change "hypercritical" should require "fred"';
+
+ is_deeply [ $engine->changes_requiring_change($ext_change) ], [{
+ project => 'engine',
+ change_id => $hyper->id,
+ change => $hyper->name,
+ asof_tag => undef,
+ }], 'Change "hypercritical" should require "groovy:crazyman"';
+
+ # Add another change with more depencencies.
+ ok my $ext_change3 = App::Sqitch::Plan::Change->new(
+ plan => $ext_plan,
+ name => 'elsewise',
+ requires => [
+ App::Sqitch::Plan::Depend->new(
+ plan => $ext_plan,
+ project => 'engine',
+ change => 'fred',
+ ),
+ App::Sqitch::Plan::Depend->new(
+ plan => $ext_plan,
+ change => 'crazyman',
+ ),
+ ]
+ ), "Create a third external change";
+ $_->resolved_id( $engine->change_id_for_depend($_) ) for $ext_change3->requires;
+ ok $engine->log_deploy_change($ext_change3), 'Log change "elsewise"';
+
+ is_deeply [
+ sort { $b->{change} cmp $a->{change} }
+ $engine->changes_requiring_change($fred)
+ ], [
+ {
+ project => 'engine',
+ change_id => $hyper->id,
+ change => $hyper->name,
+ asof_tag => undef,
+ },
+ {
+ project => 'groovy',
+ change_id => $ext_change3->id,
+ change => $ext_change3->name,
+ asof_tag => undef,
+ },
+ ], 'Change "fred" should be required by changes in two projects';
+
+ is_deeply [
+ sort { $b->{change} cmp $a->{change} }
+ $engine->changes_requiring_change($ext_change)
+ ], [
+ {
+ project => 'engine',
+ change_id => $hyper->id,
+ change => $hyper->name,
+ asof_tag => undef,
+ },
+ {
+ project => 'groovy',
+ change_id => $ext_change3->id,
+ change => $ext_change3->name,
+ asof_tag => undef,
+ },
+ ], 'Change "groovy:crazyman" should be required by changes in two projects';
+
+ ######################################################################
+ # Test begin_work() and finish_work().
+ can_ok $engine, qw(begin_work finish_work);
+ my $mock_dbh = Test::MockModule->new(ref $engine->dbh, no_auto => 1);
+ my $txn;
+ $mock_dbh->mock(begin_work => sub { $txn = 1 });
+ $mock_dbh->mock(commit => sub { $txn = 0 });
+ $mock_dbh->mock(rollback => sub { $txn = -1 });
+ my @do;
+ $mock_dbh->mock(do => sub {
+ shift;
+ @do = @_;
+ });
+ ok $engine->begin_work, 'Begin work';
+ is $txn, 1, 'Should have started a transaction';
+ ok $engine->finish_work, 'Finish work';
+ is $txn, 0, 'Should have committed a transaction';
+ ok $engine->begin_work, 'Begin work again';
+ is $txn, 1, 'Should have started another transaction';
+ ok $engine->rollback_work, 'Rollback work';
+ is $txn, -1, 'Should have rolled back a transaction';
+ $mock_dbh->unmock('do');
+
+ ######################################################################
+ # Revert and re-deploy all the changes.
+ my @all_changes = ($change, $change2, $fred, $barney, $ext_change, $ext_change2, $hyper, $ext_change3);
+ ok $engine->log_revert_change($_),
+ 'Revert "' . $_->name . '" change' for reverse @all_changes;
+ ok $engine->log_deploy_change($_),
+ 'Deploy "' . $_->name . '" change' for @all_changes;
+
+ ######################################################################
+ # Add a reworked change.
+ ok my $rev_change = $plan->rework( name => 'users' ), 'Rework change "users"';
+ my $deploy_file = $rev_change->deploy_file;
+ my $tmp_dir = dir( tempdir CLEANUP => 1 );
+ $deploy_file->copy_to($tmp_dir);
+ my $fh = $deploy_file->opena or die "Cannot open $deploy_file: $!\n";
+ try {
+ say $fh '-- Append line to reworked script so it gets a new SHA-1 hash';
+ close $fh;
+ $_->resolved_id( $engine->change_id_for_depend($_) ) for $rev_change->requires;
+ ok $engine->log_deploy_change($rev_change), 'Deploy the reworked change';
+ } finally {
+ # Restore the reworked script.
+ $tmp_dir->file( $deploy_file->basename )->copy_to($deploy_file);
+ };
+
+ # Make sure that change_id_for() chokes on the dupe.
+ MOCKVENT: {
+ my $sqitch_mocker = Test::MockModule->new(ref $sqitch);
+ my @args;
+ $sqitch_mocker->mock(vent => sub { shift; push @args => \@_ });
+ throws_ok { $engine->change_id_for( change => 'users') } 'App::Sqitch::X',
+ 'Should die on ambiguous change spec';
+ is $@->ident, 'engine', 'Mode should be "engine"';
+ is $@->message, __ 'Change Lookup Failed',
+ 'And it should report change lookup failure';
+ is_deeply \@args, [
+ [__x(
+ 'Change "{change}" is ambiguous. Please specify a tag-qualified change:',
+ change => 'users',
+ )],
+ [ ' * ', $rev_change->format_name . '@HEAD' ],
+ [ ' * ', $change->format_tag_qualified_name ],
+ ], 'Should have vented output for lookup failure';
+
+ # But it should work okay if we ask for the first ID.
+ ok my $id = $engine->change_id_for(change => 'users', first => 1),
+ 'Should get ID for first of ambiguous change spec';
+ is $id, $change->id, 'Should now have first change id';
+ }
+
+ is $engine->change_id_for( change => 'users', tag => 'alpha'), $change->id,
+ 'change_id_for() should find the tag-qualified change ID';
+ is $engine->change_id_for( change => 'users', tag => 'HEAD'), $rev_change->id,
+ 'change_id_for() should find the reworked change ID @HEAD';
+
+ ######################################################################
+ # Tag and Rework the change again.
+ ok $plan->tag(name => 'theta'), 'Tag the plan "theta"';
+ ok $engine->log_new_tags($rev_change), 'Log new tag';
+
+ ok my $rev_change2 = $plan->rework( name => 'users' ),
+ 'Rework change "users" again';
+ $fh = $deploy_file->opena or die "Cannot open $deploy_file: $!\n";
+ try {
+ say $fh '-- Append another line to reworked script for a new SHA-1 hash';
+ close $fh;
+ $_->resolved_id( $engine->change_id_for_depend($_) ) for $rev_change2->requires;
+ ok $engine->log_deploy_change($rev_change2), 'Deploy the reworked change';
+ } finally {
+ # Restore the reworked script.
+ $tmp_dir->file( $deploy_file->basename )->copy_to($deploy_file);
+ };
+
+ # make sure that change_id_for is still good with things.
+ for my $spec (
+ [
+ 'alpha instance of change',
+ { change => 'users', tag => 'alpha' },
+ $change->id,
+ ],
+ [
+ 'HEAD instance of change',
+ { change => 'users', tag => 'HEAD' },
+ $rev_change2->id,
+ ],
+ [
+ 'second instance of change by tag',
+ { change => 'users', tag => 'theta' },
+ $rev_change->id,
+ ],
+ ) {
+ my ( $desc, $params, $exp_id ) = @{ $spec };
+ is $engine->change_id_for(%{ $params }), $exp_id, "Should find id for $desc";
+ }
+
+ # Unmock everything and call it a day.
+ $mock_dbh->unmock_all;
+ $mock_sqitch->unmock_all;
+
+ ######################################################################
+ # Let's make sure script_hash upgrades work.
+ $engine->dbh->do('UPDATE changes SET script_hash = change_id');
+ ok $engine->_update_script_hashes, 'Update script hashes';
+
+ # Make sure they were updated properly.
+ my $sth = $engine->dbh->prepare(
+ 'SELECT change_id, script_hash FROM changes WHERE project = ?',
+ );
+ $sth->execute($plan->project);
+ while (my $row = $sth->fetch) {
+ my $change = $plan->get($row->[0]);
+ is $row->[1], $change->script_hash,
+ 'Should have updated script hash for ' . $change->name;
+ }
+
+ # Make sure no other projects were updated.
+ $sth = $engine->dbh->prepare(
+ 'SELECT change_id, script_hash FROM changes WHERE project <> ?',
+ );
+ $sth->execute($plan->project);
+ while (my $row = $sth->fetch) {
+ is $row->[1], $row->[0],
+ 'Change ID and script hash should be ' . substr $row->[0], 0, 6;
+ }
+
+ ######################################################################
+ # All done.
+ done_testing;
+ };
+}
+
+sub dt_for_change {
+ my $engine = shift;
+ my $col = sprintf $engine->_ts2char_format, 'committed_at';
+ my $dtfunc = $engine->can('_dt');
+ $dtfunc->($engine->dbh->selectcol_arrayref(
+ "SELECT $col FROM changes WHERE change_id = ?",
+ undef, shift
+ )->[0]);
+}
+
+sub dt_for_tag {
+ my $engine = shift;
+ my $col = sprintf $engine->_ts2char_format, 'committed_at';
+ my $dtfunc = $engine->can('_dt');
+ $dtfunc->($engine->dbh->selectcol_arrayref(
+ "SELECT $col FROM tags WHERE tag_id = ?",
+ undef, shift
+ )->[0]);
+}
+
+sub all {
+ my $iter = shift;
+ my @res;
+ while (my $row = $iter->()) {
+ push @res => $row;
+ }
+ return \@res;
+}
+
+sub dt_for_event {
+ my ($engine, $offset) = @_;
+ my $col = sprintf $engine->_ts2char_format, 'committed_at';
+ my $dtfunc = $engine->can('_dt');
+ my $dbh = $engine->dbh;
+ return $dtfunc->($engine->dbh->selectcol_arrayref(qq{
+ SELECT ts FROM (
+ SELECT ts, rownum AS rnum FROM (
+ SELECT $col AS ts
+ FROM events
+ ORDER BY committed_at ASC
+ )
+ ) WHERE rnum = ?
+ }, undef, $offset + 1)->[0]) if $dbh->{Driver}->{Name} eq 'Oracle';
+ return $dtfunc->($engine->dbh->selectcol_arrayref(
+ "SELECT FIRST 1 SKIP $offset $col FROM events ORDER BY committed_at ASC",
+ )->[0]) if $dbh->{Driver}->{Name} eq 'Firebird';
+ return $dtfunc->($engine->dbh->selectcol_arrayref(
+ "SELECT $col FROM events ORDER BY committed_at ASC LIMIT 1 OFFSET $offset",
+ )->[0]);
+}
+
+sub all_changes {
+ shift->dbh->selectall_arrayref(q{
+ SELECT change_id, c.change, project, note, committer_name, committer_email,
+ planner_name, planner_email
+ FROM changes c
+ ORDER BY committed_at
+ });
+}
+
+sub all_tags {
+ shift->dbh->selectall_arrayref(q{
+ SELECT tag_id, tag, change_id, project, note,
+ committer_name, committer_email, planner_name, planner_email
+ FROM tags
+ ORDER BY committed_at
+ });
+}
+
+sub all_events {
+ shift->dbh->selectall_arrayref(q{
+ SELECT event, change_id, e.change, project, note, requires, conflicts, tags,
+ committer_name, committer_email, planner_name, planner_email
+ FROM events e
+ ORDER BY committed_at
+ });
+}
+
+sub get_dependencies {
+ shift->dbh->selectall_arrayref(q{
+ SELECT change_id, type, dependency, dependency_id
+ FROM dependencies
+ WHERE change_id = ?
+ ORDER BY dependency
+ }, undef, shift);
+}
+
+1;
diff --git a/t/lib/LC.pm b/t/lib/LC.pm
new file mode 100644
index 00000000..ed95ae76
--- /dev/null
+++ b/t/lib/LC.pm
@@ -0,0 +1,17 @@
+package LC;
+
+our $TIME = do {
+ if ($^O eq 'MSWin32') {
+ require Win32::Locale;
+ Win32::Locale::get_locale();
+ } else {
+ require POSIX;
+ POSIX::setlocale( POSIX::LC_TIME() );
+ }
+};
+
+# https://github.com/sqitchers/sqitch/issues/230#issuecomment-103946451
+# https://rt.cpan.org/Ticket/Display.html?id=104574
+$TIME = 'en_US_POSIX' if $TIME eq 'C.UTF-8';
+
+1;
diff --git a/t/lib/MockOutput.pm b/t/lib/MockOutput.pm
new file mode 100644
index 00000000..5dfe9406
--- /dev/null
+++ b/t/lib/MockOutput.pm
@@ -0,0 +1,74 @@
+package MockOutput;
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+use Test::MockModule 0.05;
+
+our $MOCK = Test::MockModule->new('App::Sqitch');
+
+my @mocked = qw(
+ trace
+ trace_literal
+ debug
+ debug_literal
+ info
+ info_literal
+ comment
+ comment_literal
+ emit
+ emit_literal
+ vent
+ vent_literal
+ warn
+ warn_literal
+ page
+ page_literal
+ prompt
+ ask_yes_no
+);
+
+my $INPUT;
+sub prompt_returns { $INPUT = $_[1]; }
+
+my $Y_N;
+sub ask_yes_no_returns { $Y_N = $_[1]; }
+
+my %CAPTURED;
+
+__PACKAGE__->clear;
+
+for my $meth (@mocked) {
+ $MOCK->mock($meth => sub {
+ shift;
+ push @{ $CAPTURED{$meth} } => [@_];
+ });
+
+ my $get = sub {
+ my $ret = $CAPTURED{$meth};
+ $CAPTURED{$meth} = [];
+ return $ret;
+ };
+
+ no strict 'refs';
+ *{"get_$meth"} = $get;
+}
+
+$MOCK->mock(prompt => sub {
+ shift;
+ push @{ $CAPTURED{prompt} } => [@_];
+ return $INPUT;
+});
+
+$MOCK->mock(ask_yes_no => sub {
+ shift;
+ push @{ $CAPTURED{ask_yes_no} } => [@_];
+ return $Y_N;
+});
+
+sub clear {
+ %CAPTURED = map { $_ => [] } @mocked;
+}
+
+1;
diff --git a/t/lib/TestConfig.pm b/t/lib/TestConfig.pm
new file mode 100644
index 00000000..1d396697
--- /dev/null
+++ b/t/lib/TestConfig.pm
@@ -0,0 +1,148 @@
+package TestConfig;
+use strict;
+use warnings;
+use base 'App::Sqitch::Config';
+use Path::Class;
+
+# Creates and returns a new TestConfig, which inherits from
+# App::Sqitch::Config. Sets nonexistent values for the file locations and
+# calls update() on remaining args.
+#
+# my $config = TestConfig->new(
+# 'core.engine' => 'sqlite',
+# 'add.all' => 1,
+# 'deploy.variables' => { _prefix => 'test_', user => 'bob' }
+# 'foo.bar' => [qw(one two three)],
+# );
+sub new {
+ my $self = shift->SUPER::new;
+ $self->{test_local_file} = 'nonexistent.local';
+ $self->{test_user_file} = 'nonexistent.user';
+ $self->{test_system_file} = 'nonexistent.system';
+ $self->update(@_);
+ return $self;
+}
+
+# Pass in key/value pairs to set the data. Does not clear existing data. Keys
+# should be "$section.$name". Values can be scalars, arrays, or hashes.
+# Scalars are simply set as-is, unless the value is `undef`, in which case the
+# key is deleted. Arrays are set as multiple values for the key. Hashes have
+# each of their keys appended as "$section.$name.$key", with the values
+# assigned as-is. Existing keys will be replaced with the new values.
+#
+# my $config->update(
+# 'core.engine' => 'sqlite',
+# 'add.all' => 1,
+# 'deploy.variables' => { _prefix => 'test_', user => 'bob' }
+# 'foo.bar' => [qw(one two three)],
+# );
+sub update {
+ my $self = shift;
+ my %p = @_ or return;
+ $self->data({}) unless $self->is_loaded;
+ # Set a unique origin to be sure to override any previous values for each key.
+ my @args = (origin => ('update_' . ++$self->{__update}));
+
+ while (my ($k, $v) = each %p) {
+ my $ref = ref $v;
+ if ($ref eq '') {
+ if (defined $v) {
+ $k =~ s/[.]([^.]+)$//;
+ $self->define(@args, section => $k, name => $1, value => $v);
+ } else {
+ $self->set_multiple( $k, 0 ) if $self->is_multiple( $k );
+ $k = lc $k;
+ delete $_->{$k} for ($self->origins, $self->data, $self->casing);
+ }
+ } elsif ($ref eq 'HASH') {
+ $self->define(@args, section => $k, name => $_, value => $v->{$_} )
+ for keys %{ $v };
+ } elsif ($ref eq 'ARRAY') {
+ $k =~ s/[.]([^.]+)$//;
+ $self->define(@args, section => $k, name => $1, value => $_)
+ for @{ $v };
+ } else {
+ require Carp;
+ Carp::confess("Cannot set config value of type $ref");
+ }
+ }
+}
+
+# Like update(), but replaces all existing data with new data.
+sub replace {
+ my $self = shift;
+ $self->data({});
+ $self->multiple({});
+ $self->origins({});
+ $self->casing({});
+ $self->config_files([]);
+ $self->update(@_);
+}
+
+# Creates and returns a new TestConfig, which inherits from
+# App::Sqitch::Config. Parameters specify files to load using the keys "local",
+# "user", and "system". Any file not specified will be set to a nonexistent
+# value. Once the files are set, the data is loaded from the files and the
+# TestObject returned.
+#
+# my $config = TestObject->from(
+# local => 'test.conf',
+# user => 'user.conf',
+# system => 'system.conf',
+# );
+sub from {
+ my ($class, %p) = @_;
+ my $self = shift->SUPER::new;
+ for my $level (qw(local user system)) {
+ $self->{"test_${level}_file"} = $p{$level} || "nonexistent.$level";
+ }
+ $self->load;
+ return $self;
+}
+
+# Creates and returns a Test::MockModule object that can be used to mock
+# methods on the TestConfig class. Pass pairs of parameters to be passed on to
+# the mock() method of the Test::MockModule object before returning.
+#
+# my $sysdir = dir 'nonexistent';
+# my $usrdir = dir 'nonexistent';
+# my $mock = TestConfig->mock(
+# system_dir => sub { $sysdir },
+# user_dir => sub { $usrdir },
+# );
+sub mock {
+ my $class = shift;
+ require Test::MockModule;
+ my $mocker = Test::MockModule->new($class);
+ $mocker->mock(shift, shift) while @_;
+ return $mocker;
+}
+
+# Returns the test local file.
+sub local_file { file $_[0]->{test_local_file} }
+
+# Returns the test user file.
+sub user_file { file $_[0]->{test_user_file} }
+
+# Returns the test system file.
+sub system_file { file $_[0]->{test_system_file} }
+
+# Overrides the parent implementation to load only the local file, to avoid
+# inadvertent loading of configuration files in parent directories. Unlikely
+# to be called directly by tests.
+sub load_dirs {
+ my $self = shift;
+ # Exclude files in parent directories.
+ $self->load_file($self->local_file);
+}
+
+# Parses the specified configuration file and returns a hash reference. May be
+# called as either a class or instance method; in neither case is the data
+# stored anywhere other than the returned hash reference.
+sub data_from {
+ my $conf = shift->SUPER::new;
+ $conf->load_file(shift);
+ $conf->data;
+}
+
+1;
diff --git a/t/lib/upgradable_registries/exasol.sql b/t/lib/upgradable_registries/exasol.sql
new file mode 100644
index 00000000..376005e0
--- /dev/null
+++ b/t/lib/upgradable_registries/exasol.sql
@@ -0,0 +1,139 @@
+CREATE SCHEMA IF NOT EXISTS &registry;
+
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.0.';
+
+CREATE TABLE &registry..releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ installer_name VARCHAR2(512 CHAR) NOT NULL,
+ installer_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry..releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry..releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry..releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry..releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE &registry..projects (
+ project VARCHAR2(512 CHAR) PRIMARY KEY,
+ uri VARCHAR2(512 CHAR) NULL, -- UNIQUE should also be used here, but not supported in EXASOL
+ created_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ creator_name VARCHAR2(512 CHAR) NOT NULL,
+ creator_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN &registry..projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN &registry..projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN &registry..projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN &registry..projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN &registry..projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE &registry..changes (
+ change_id CHAR(40) PRIMARY KEY,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN &registry..changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN &registry..changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN &registry..changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..changes.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN &registry..changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN &registry..changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE &registry..tags (
+ tag_id CHAR(40) PRIMARY KEY,
+ tag VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+ -- UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE &registry..tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN &registry..tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN &registry..tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN &registry..tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN &registry..tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN &registry..tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN &registry..tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN &registry..tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN &registry..tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN &registry..tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE &registry..dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id), -- ON DELETE CASCADE,
+ type VARCHAR2(8) NOT NULL,
+ dependency VARCHAR2(1024 CHAR) NOT NULL,
+ dependency_id CHAR(40) NULL REFERENCES &registry..changes(change_id),
+ -- CONSTRAINT dependencies_check CHECK (
+ -- (type = 'require' AND dependency_id IS NOT NULL)
+ -- OR (type = 'conflict' AND dependency_id IS NULL)
+ -- ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE &registry..dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN &registry..dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN &registry..dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN &registry..dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN &registry..dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TABLE &registry..events (
+ event VARCHAR2(6) NOT NULL,
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ requires VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL,
+ conflicts VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL,
+ tags VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+-- CREATE INDEX &registry..events_pkey ON &registry..events(change_id, committed_at);
+
+COMMENT ON TABLE &registry..events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN &registry..events.event IS 'Type of event.';
+COMMENT ON COLUMN &registry..events.change_id IS 'Change ID.';
+COMMENT ON COLUMN &registry..events.change IS 'Change name.';
+COMMENT ON COLUMN &registry..events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..events.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..events.requires IS 'List of the names of required changes.';
+COMMENT ON COLUMN &registry..events.conflicts IS 'List of the names of conflicting changes.';
+COMMENT ON COLUMN &registry..events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN &registry..events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN &registry..events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN &registry..events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..events.planner_email IS 'Email address of the user who plan planned the change.';
+
+COMMIT;
diff --git a/t/lib/upgradable_registries/firebird.sql b/t/lib/upgradable_registries/firebird.sql
new file mode 100644
index 00000000..c848d698
--- /dev/null
+++ b/t/lib/upgradable_registries/firebird.sql
@@ -0,0 +1,327 @@
+/*
+ * Sqitch database deployment metadata v1.0.;
+ */
+
+/*
+ * Required PAGE SIZE = 16384 to avoid error: "key size exceeds
+ * implementation restriction for index..."
+ */
+
+-- Table: releases
+
+CREATE TABLE releases (
+ version FLOAT NOT NULL PRIMARY KEY,
+ installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ installer_name VARCHAR(255) NOT NULL,
+ installer_email VARCHAR(255) NOT NULL
+);
+
+UPDATE RDB$RELATIONS SET
+ RDB$DESCRIPTION = 'Sqitch registry releases.'
+ WHERE RDB$RELATION_NAME = 'RELEASES';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Version of the Sqitch registry.'
+ WHERE RDB$RELATION_NAME = 'RELEASES' AND RDB$FIELD_NAME = 'VERSION';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the registry release was installed.'
+ WHERE RDB$RELATION_NAME = 'VERSIONS' AND RDB$FIELD_NAME = 'INSTALLED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who installed the registry release.'
+ WHERE RDB$RELATION_NAME = 'VERSIONS' AND RDB$FIELD_NAME = 'INSTALLER_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who installed the registry release.'
+ WHERE RDB$RELATION_NAME = 'VERSIONS' AND RDB$FIELD_NAME = 'INSTALLER_EMAIL';
+
+-- Table: projects
+
+CREATE TABLE projects (
+ project VARCHAR(255) NOT NULL PRIMARY KEY,
+ uri VARCHAR(255) UNIQUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ creator_name VARCHAR(255) NOT NULL,
+ creator_email VARCHAR(255) NOT NULL
+);
+
+-- Description (comments)
+
+UPDATE RDB$RELATIONS SET
+ RDB$DESCRIPTION = 'Sqitch projects deployed to this database.'
+ WHERE RDB$RELATION_NAME = 'PROJECTS';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Unique Name of a project.'
+ WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'PROJECT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Optional project URI.'
+ WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'URI';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the project was added to the database.'
+ WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'CREATED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who added the project.'
+ WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'CREATOR_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who added the project.'
+ WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'CREATOR_EMAIL';
+
+-- Table: changes
+
+CREATE TABLE changes (
+ change_id VARCHAR(40) NOT NULL PRIMARY KEY,
+ change VARCHAR(255) NOT NULL,
+ project VARCHAR(255) NOT NULL REFERENCES projects(project)
+ ON UPDATE CASCADE,
+ note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ committer_name VARCHAR(255) NOT NULL,
+ committer_email VARCHAR(255) NOT NULL,
+ planned_at TIMESTAMP NOT NULL,
+ planner_name VARCHAR(255) NOT NULL,
+ planner_email VARCHAR(255) NOT NULL
+);
+
+-- Description (comments)
+
+UPDATE RDB$RELATIONS SET
+ RDB$DESCRIPTION = 'Tracks the changes currently deployed to the database.'
+ WHERE RDB$RELATION_NAME = 'CHANGES';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Change primary key.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'CHANGE_ID';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of a deployed change.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'CHANGE';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the Sqitch project to which the change belongs.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PROJECT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Description of the change.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'NOTE';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the change was deployed.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'COMMITTED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who deployed the change.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'COMMITTER_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who deployed the change.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'COMMITTER_EMAIL';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the change was added to the plan.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PLANNED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who planed the change.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PLANNER_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who planned the change.'
+ WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PLANNER_EMAIL';
+
+-- Table: tags
+
+CREATE TABLE tags (
+ tag_id CHAR(40) NOT NULL PRIMARY KEY,
+ tag VARCHAR(250) NOT NULL,
+ project VARCHAR(255) NOT NULL REFERENCES projects(project)
+ ON UPDATE CASCADE,
+ change_id CHAR(40) NOT NULL REFERENCES changes(change_id)
+ ON UPDATE CASCADE,
+ note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ committer_name VARCHAR(512) NOT NULL,
+ committer_email VARCHAR(512) NOT NULL,
+ planned_at TIMESTAMP NOT NULL,
+ planner_name VARCHAR(512) NOT NULL,
+ planner_email VARCHAR(512) NOT NULL,
+ UNIQUE(project, tag)
+);
+
+-- Description (comments)
+
+UPDATE RDB$RELATIONS SET
+ RDB$DESCRIPTION = 'Tracks the tags currently applied to the database.'
+ WHERE RDB$RELATION_NAME = 'TAGS';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Tag primary key.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'TAG_ID';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Project-unique tag name.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'TAG';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the Sqitch project to which the tag belongs.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PROJECT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'ID of last change deployed before the tag was applied.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'CHANGE_ID';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Description of the tag.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'NOTE';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the tag was applied to the database.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'COMMITTED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who applied the tag.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'COMMITTER_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who applied the tag.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'COMMITTER_EMAIL';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the tag was added to the plan.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PLANNED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who planed the tag.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PLANNER_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who planned the tag.'
+ WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PLANNER_EMAIL';
+
+-- Table: dependencies
+
+CREATE TABLE dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES changes(change_id)
+ ON UPDATE CASCADE ON DELETE CASCADE,
+ type VARCHAR(8) NOT NULL,
+ dependency VARCHAR(512) NOT NULL,
+ dependency_id CHAR(40) REFERENCES changes(change_id)
+ ON UPDATE CASCADE CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+-- Description (comments)
+
+UPDATE RDB$RELATIONS SET
+ RDB$DESCRIPTION = 'Tracks the currently satisfied dependencies.'
+ WHERE RDB$RELATION_NAME = 'DEPENDENCIES';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'ID of the depending change.'
+ WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'CHANGE_ID';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Type of dependency.'
+ WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'TYPE';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Dependency name.'
+ WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'DEPENDENCY';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Change ID the dependency resolves to.'
+ WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'DEPENDENCY_ID';
+
+-- Table: events
+
+CREATE TABLE events (
+ event VARCHAR(6) NOT NULL
+ CHECK (event IN ('deploy', 'revert', 'fail')),
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR(512) NOT NULL,
+ project VARCHAR(255) NOT NULL REFERENCES projects(project)
+ ON UPDATE CASCADE,
+ note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ requires BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ conflicts BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ tags BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL,
+ committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ committer_name VARCHAR(512) NOT NULL,
+ committer_email VARCHAR(512) NOT NULL,
+ planned_at TIMESTAMP NOT NULL,
+ planner_name VARCHAR(512) NOT NULL,
+ planner_email VARCHAR(512) NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+-- Description (comments)
+
+UPDATE RDB$RELATIONS SET
+ RDB$DESCRIPTION = 'Contains full history of all deployment events.'
+ WHERE RDB$RELATION_NAME = 'EVENTS';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Type of event.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'EVENT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Change ID.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'CHANGE_ID';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Change name.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'CHANGE';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the Sqitch project to which the change belongs.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PROJECT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Description of the change.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'NOTE';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Array of the names of required changes.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'REQUIRES';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Array of the names of conflicting changes.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'CONFLICTS';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Tags associated with the change.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'TAGS';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the event was committed.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'COMMITTED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who committed the event.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'COMMITTER_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who committed the event.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'COMMITTER_EMAIL';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Date the event was added to the plan.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PLANNED_AT';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Name of the user who planed the change.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PLANNER_NAME';
+
+UPDATE RDB$RELATION_FIELDS
+ SET RDB$DESCRIPTION = 'Email address of the user who plan planned the change.'
+ WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PLANNER_EMAIL';
+
+COMMIT;
diff --git a/t/lib/upgradable_registries/mysql.sql b/t/lib/upgradable_registries/mysql.sql
new file mode 100644
index 00000000..4c1182ab
--- /dev/null
+++ b/t/lib/upgradable_registries/mysql.sql
@@ -0,0 +1,189 @@
+BEGIN;
+
+SET SESSION sql_mode = ansi;
+
+CREATE TABLE releases (
+ version FLOAT PRIMARY KEY
+ COMMENT 'Version of the Sqitch registry.',
+ installed_at TIMESTAMP NOT NULL
+ COMMENT 'Date the registry release was installed.',
+ installer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who installed the registry release.',
+ installer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who installed the registry release.'
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Sqitch registry releases.'
+;
+
+CREATE TABLE projects (
+ project VARCHAR(255) PRIMARY KEY
+ COMMENT 'Unique Name of a project.',
+ uri VARCHAR(255) NULL UNIQUE
+ COMMENT 'Optional project URI',
+ created_at DATETIME(6) NOT NULL
+ COMMENT 'Date the project was added to the database.',
+ creator_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who added the project.',
+ creator_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who added the project.'
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Sqitch projects deployed to this database.'
+;
+
+CREATE TABLE changes (
+ change_id VARCHAR(40) PRIMARY KEY
+ COMMENT 'Change primary key.',
+ "change" VARCHAR(255) NOT NULL
+ COMMENT 'Name of a deployed change.',
+ project VARCHAR(255) NOT NULL
+ COMMENT 'Name of the Sqitch project to which the change belongs.'
+ REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL
+ COMMENT 'Description of the change.',
+ committed_at DATETIME(6) NOT NULL
+ COMMENT 'Date the change was deployed.',
+ committer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who deployed the change.',
+ committer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who deployed the change.',
+ planned_at DATETIME(6) NOT NULL
+ COMMENT 'Date the change was added to the plan.',
+ planner_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who planed the change.',
+ planner_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who planned the change.'
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Tracks the changes currently deployed to the database.'
+;
+
+CREATE TABLE tags (
+ tag_id VARCHAR(40) PRIMARY KEY
+ COMMENT 'Tag primary key.',
+ tag VARCHAR(255) NOT NULL
+ COMMENT 'Project-unique tag name.',
+ project VARCHAR(255) NOT NULL
+ COMMENT 'Name of the Sqitch project to which the tag belongs.'
+ REFERENCES projects(project) ON UPDATE CASCADE,
+ change_id VARCHAR(40) NOT NULL
+ COMMENT 'ID of last change deployed before the tag was applied.'
+ REFERENCES changes(change_id) ON UPDATE CASCADE,
+ note VARCHAR(255) NOT NULL
+ COMMENT 'Description of the tag.',
+ committed_at DATETIME(6) NOT NULL
+ COMMENT 'Date the tag was applied to the database.',
+ committer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who applied the tag.',
+ committer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who applied the tag.',
+ planned_at DATETIME(6) NOT NULL
+ COMMENT 'Date the tag was added to the plan.',
+ planner_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who planed the tag.',
+ planner_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who planned the tag.',
+ UNIQUE(project, tag)
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Tracks the tags currently applied to the database.'
+;
+
+CREATE TABLE dependencies (
+ change_id VARCHAR(40) NOT NULL
+ COMMENT 'ID of the depending change.'
+ REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type VARCHAR(8) NOT NULL
+ COMMENT 'Type of dependency.',
+ dependency VARCHAR(255) NOT NULL
+ COMMENT 'Dependency name.',
+ dependency_id VARCHAR(40) NULL
+ COMMENT 'Change ID the dependency resolves to.'
+ REFERENCES changes(change_id) ON UPDATE CASCADE,
+ PRIMARY KEY (change_id, dependency)
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Tracks the currently satisfied dependencies.'
+;
+
+CREATE TABLE events (
+ event ENUM ('deploy', 'fail', 'revert') NOT NULL
+ COMMENT 'Type of event.',
+ change_id VARCHAR(40) NOT NULL
+ COMMENT 'Change ID.',
+ "change" VARCHAR(255) NOT NULL
+ COMMENT 'Change name.',
+ project VARCHAR(255) NOT NULL
+ COMMENT 'Name of the Sqitch project to which the change belongs.'
+ REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL
+ COMMENT 'Description of the change.',
+ requires TEXT NOT NULL
+ COMMENT 'List of the names of required changes.',
+ conflicts TEXT NOT NULL
+ COMMENT 'List of the names of conflicting changes.',
+ tags TEXT NOT NULL
+ COMMENT 'List of tags associated with the change.',
+ committed_at DATETIME(6) NOT NULL
+ COMMENT 'Date the event was committed.',
+ committer_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who committed the event.',
+ committer_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who committed the event.',
+ planned_at DATETIME(6) NOT NULL
+ COMMENT 'Date the event was added to the plan.',
+ planner_name VARCHAR(255) NOT NULL
+ COMMENT 'Name of the user who planed the change.',
+ planner_email VARCHAR(255) NOT NULL
+ COMMENT 'Email address of the user who plan planned the change.',
+ PRIMARY KEY (change_id, committed_at)
+) ENGINE InnoDB,
+ CHARACTER SET 'utf8',
+ COMMENT 'Contains full history of all deployment events.'
+;
+
+-- ## BEGIN 5.5
+-- MySQL does not support checks, so we kind of create our own. The checkit()
+-- function works sort of like a CHECK: if the first argument is 0 or NULL, it
+-- throws the second argument as an exception. Conveniently, verify scripts
+-- can also use it to ensure an error is thrown when a change cannot be
+-- verified. Requires MySQL 5.5.0.
+
+DELIMITER |
+
+CREATE FUNCTION checkit(doit INTEGER, message VARCHAR(256)) RETURNS INTEGER DETERMINISTIC
+BEGIN
+ IF doit IS NULL OR doit = 0 THEN
+ SIGNAL SQLSTATE 'ERR0R' SET MESSAGE_TEXT = message;
+ END IF;
+ RETURN doit;
+END;
+|
+
+CREATE TRIGGER ck_insert_dependency BEFORE INSERT ON dependencies
+FOR EACH ROW BEGIN
+ -- DO does not work. https://bugs.mysql.com/bug.php?id=69647
+ SET @dummy := checkit(
+ (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL)
+ OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL),
+ 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set'
+ );
+END;
+|
+
+CREATE TRIGGER ck_update_dependency BEFORE UPDATE ON dependencies
+FOR EACH ROW BEGIN
+ -- DO does not work. https://bugs.mysql.com/bug.php?id=69647
+ SET @dummy := checkit(
+ (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL)
+ OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL),
+ 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set'
+ );
+END;
+|
+
+DELIMITER ;
+-- ## END 5.5
+
+COMMIT;
diff --git a/t/lib/upgradable_registries/oracle.sql b/t/lib/upgradable_registries/oracle.sql
new file mode 100644
index 00000000..ed6f949a
--- /dev/null
+++ b/t/lib/upgradable_registries/oracle.sql
@@ -0,0 +1,136 @@
+CREATE TABLE &registry..releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ installer_name VARCHAR2(512 CHAR) NOT NULL,
+ installer_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry..releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry..releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry..releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry..releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE &registry..projects (
+ project VARCHAR2(512 CHAR) PRIMARY KEY,
+ uri VARCHAR2(512 CHAR) NULL UNIQUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ creator_name VARCHAR2(512 CHAR) NOT NULL,
+ creator_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN &registry..projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN &registry..projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN &registry..projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN &registry..projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN &registry..projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE &registry..changes (
+ change_id CHAR(40) PRIMARY KEY,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+COMMENT ON TABLE &registry..changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN &registry..changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN &registry..changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN &registry..changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..changes.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN &registry..changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN &registry..changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN &registry..changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE &registry..tags (
+ tag_id CHAR(40) PRIMARY KEY,
+ tag VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL,
+ UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE &registry..tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN &registry..tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN &registry..tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN &registry..tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN &registry..tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN &registry..tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN &registry..tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN &registry..tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN &registry..tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN &registry..tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN &registry..tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE &registry..dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES &registry..changes(change_id) ON DELETE CASCADE,
+ type VARCHAR2(8) NOT NULL,
+ dependency VARCHAR2(1024 CHAR) NOT NULL,
+ dependency_id CHAR(40) NULL REFERENCES &registry..changes(change_id),
+ CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE &registry..dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN &registry..dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN &registry..dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN &registry..dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN &registry..dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TYPE &registry..sqitch_array AS varray(1024) OF VARCHAR2(512);
+/
+
+CREATE TABLE &registry..events (
+ event VARCHAR2(6) NOT NULL CHECK (event IN ('deploy', 'revert', 'fail')),
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR2(512 CHAR) NOT NULL,
+ project VARCHAR2(512 CHAR) NOT NULL REFERENCES &registry..projects(project),
+ note VARCHAR2(4000 CHAR) DEFAULT '',
+ requires &registry..SQITCH_ARRAY DEFAULT &registry..SQITCH_ARRAY() NOT NULL,
+ conflicts &registry..SQITCH_ARRAY DEFAULT &registry..SQITCH_ARRAY() NOT NULL,
+ tags &registry..SQITCH_ARRAY DEFAULT &registry..SQITCH_ARRAY() NOT NULL,
+ committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
+ committer_name VARCHAR2(512 CHAR) NOT NULL,
+ committer_email VARCHAR2(512 CHAR) NOT NULL,
+ planned_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ planner_name VARCHAR2(512 CHAR) NOT NULL,
+ planner_email VARCHAR2(512 CHAR) NOT NULL
+);
+
+CREATE UNIQUE INDEX &registry..events_pkey ON &registry..events(change_id, committed_at);
+
+COMMENT ON TABLE &registry..events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN &registry..events.event IS 'Type of event.';
+COMMENT ON COLUMN &registry..events.change_id IS 'Change ID.';
+COMMENT ON COLUMN &registry..events.change IS 'Change name.';
+COMMENT ON COLUMN &registry..events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry..events.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry..events.requires IS 'Array of the names of required changes.';
+COMMENT ON COLUMN &registry..events.conflicts IS 'Array of the names of conflicting changes.';
+COMMENT ON COLUMN &registry..events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN &registry..events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN &registry..events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN &registry..events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN &registry..events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry..events.planner_email IS 'Email address of the user who plan planned the change.';
diff --git a/t/lib/upgradable_registries/pg.sql b/t/lib/upgradable_registries/pg.sql
new file mode 100644
index 00000000..59f1d397
--- /dev/null
+++ b/t/lib/upgradable_registries/pg.sql
@@ -0,0 +1,140 @@
+BEGIN;
+
+SET client_min_messages = warning;
+CREATE SCHEMA IF NOT EXISTS :"registry";
+
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.';
+
+CREATE TABLE :"registry".releases (
+ version REAL PRIMARY KEY,
+ installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN :"registry".releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN :"registry".releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN :"registry".releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN :"registry".releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE :"registry".projects (
+ project TEXT PRIMARY KEY,
+ uri TEXT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ creator_name TEXT NOT NULL,
+ creator_email TEXT NOT NULL
+):tableopts;
+
+COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN :"registry".projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN :"registry".projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN :"registry".projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN :"registry".projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN :"registry".projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE :"registry".changes (
+ change_id TEXT PRIMARY KEY,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL
+):tableopts;
+
+COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN :"registry".changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN :"registry".changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN :"registry".changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN :"registry".changes.note IS 'Description of the change.';
+COMMENT ON COLUMN :"registry".changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN :"registry".changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN :"registry".changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN :"registry".changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN :"registry".changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN :"registry".changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE :"registry".tags (
+ tag_id TEXT PRIMARY KEY,
+ tag TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE,
+ change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, tag)
+):tableopts;
+
+COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN :"registry".tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN :"registry".tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN :"registry".tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN :"registry".tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN :"registry".tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN :"registry".tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN :"registry".tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN :"registry".tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN :"registry".tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN :"registry".tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN :"registry".tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE :"registry".dependencies (
+ change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ dependency TEXT NOT NULL,
+ dependency_id TEXT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+):tableopts;
+
+COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN :"registry".dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN :"registry".dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN :"registry".dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN :"registry".dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TABLE :"registry".events (
+ event TEXT NOT NULL CHECK (event IN ('deploy', 'revert', 'fail')),
+ change_id TEXT NOT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ requires TEXT[] NOT NULL DEFAULT '{}',
+ conflicts TEXT[] NOT NULL DEFAULT '{}',
+ tags TEXT[] NOT NULL DEFAULT '{}',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+):tableopts;
+
+COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN :"registry".events.event IS 'Type of event.';
+COMMENT ON COLUMN :"registry".events.change_id IS 'Change ID.';
+COMMENT ON COLUMN :"registry".events.change IS 'Change name.';
+COMMENT ON COLUMN :"registry".events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN :"registry".events.note IS 'Description of the change.';
+COMMENT ON COLUMN :"registry".events.requires IS 'Array of the names of required changes.';
+COMMENT ON COLUMN :"registry".events.conflicts IS 'Array of the names of conflicting changes.';
+COMMENT ON COLUMN :"registry".events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN :"registry".events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN :"registry".events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN :"registry".events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN :"registry".events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN :"registry".events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN :"registry".events.planner_email IS 'Email address of the user who plan planned the change.';
+
+COMMIT;
diff --git a/t/lib/upgradable_registries/snowflake.sql b/t/lib/upgradable_registries/snowflake.sql
new file mode 100644
index 00000000..7ea7a25e
--- /dev/null
+++ b/t/lib/upgradable_registries/snowflake.sql
@@ -0,0 +1,139 @@
+CREATE SCHEMA IF NOT EXISTS &registry;
+
+COMMENT ON SCHEMA &registry IS 'Sqitch database deployment metadata v1.0.';
+
+CREATE TABLE &registry.releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp,
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE &registry.releases IS 'Sqitch registry releases.';
+COMMENT ON COLUMN &registry.releases.version IS 'Version of the Sqitch registry.';
+COMMENT ON COLUMN &registry.releases.installed_at IS 'Date the registry release was installed.';
+COMMENT ON COLUMN &registry.releases.installer_name IS 'Name of the user who installed the registry release.';
+COMMENT ON COLUMN &registry.releases.installer_email IS 'Email address of the user who installed the registry release.';
+
+CREATE TABLE &registry.projects (
+ project TEXT PRIMARY KEY,
+ uri TEXT NULL UNIQUE,
+ created_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp,
+ creator_name TEXT NOT NULL,
+ creator_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE &registry.projects IS 'Sqitch projects deployed to this database.';
+COMMENT ON COLUMN &registry.projects.project IS 'Unique Name of a project.';
+COMMENT ON COLUMN &registry.projects.uri IS 'Optional project URI';
+COMMENT ON COLUMN &registry.projects.created_at IS 'Date the project was added to the database.';
+COMMENT ON COLUMN &registry.projects.creator_name IS 'Name of the user who added the project.';
+COMMENT ON COLUMN &registry.projects.creator_email IS 'Email address of the user who added the project.';
+
+CREATE TABLE &registry.changes (
+ change_id TEXT PRIMARY KEY,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES &registry.projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMP_TZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL
+);
+
+COMMENT ON TABLE &registry.changes IS 'Tracks the changes currently deployed to the database.';
+COMMENT ON COLUMN &registry.changes.change_id IS 'Change primary key.';
+COMMENT ON COLUMN &registry.changes.change IS 'Name of a deployed change.';
+COMMENT ON COLUMN &registry.changes.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry.changes.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry.changes.committed_at IS 'Date the change was deployed.';
+COMMENT ON COLUMN &registry.changes.committer_name IS 'Name of the user who deployed the change.';
+COMMENT ON COLUMN &registry.changes.committer_email IS 'Email address of the user who deployed the change.';
+COMMENT ON COLUMN &registry.changes.planned_at IS 'Date the change was added to the plan.';
+COMMENT ON COLUMN &registry.changes.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry.changes.planner_email IS 'Email address of the user who planned the change.';
+
+CREATE TABLE &registry.tags (
+ tag_id TEXT PRIMARY KEY,
+ tag TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES &registry.projects(project) ON UPDATE CASCADE,
+ change_id TEXT NOT NULL REFERENCES &registry.changes(change_id) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE &registry.tags IS 'Tracks the tags currently applied to the database.';
+COMMENT ON COLUMN &registry.tags.tag_id IS 'Tag primary key.';
+COMMENT ON COLUMN &registry.tags.tag IS 'Project-unique tag name.';
+COMMENT ON COLUMN &registry.tags.project IS 'Name of the Sqitch project to which the tag belongs.';
+COMMENT ON COLUMN &registry.tags.change_id IS 'ID of last change deployed before the tag was applied.';
+COMMENT ON COLUMN &registry.tags.note IS 'Description of the tag.';
+COMMENT ON COLUMN &registry.tags.committed_at IS 'Date the tag was applied to the database.';
+COMMENT ON COLUMN &registry.tags.committer_name IS 'Name of the user who applied the tag.';
+COMMENT ON COLUMN &registry.tags.committer_email IS 'Email address of the user who applied the tag.';
+COMMENT ON COLUMN &registry.tags.planned_at IS 'Date the tag was added to the plan.';
+COMMENT ON COLUMN &registry.tags.planner_name IS 'Name of the user who planed the tag.';
+COMMENT ON COLUMN &registry.tags.planner_email IS 'Email address of the user who planned the tag.';
+
+CREATE TABLE &registry.dependencies (
+ change_id TEXT NOT NULL REFERENCES &registry.changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ dependency TEXT NOT NULL,
+ dependency_id TEXT NULL REFERENCES &registry.changes(change_id) ON UPDATE CASCADE,
+ -- CONSTRAINT dependencies_check CHECK (
+ -- (type = 'require' AND dependency_id IS NOT NULL)
+ -- OR (type = 'conflict' AND dependency_id IS NULL)
+ -- ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE &registry.dependencies IS 'Tracks the currently satisfied dependencies.';
+COMMENT ON COLUMN &registry.dependencies.change_id IS 'ID of the depending change.';
+COMMENT ON COLUMN &registry.dependencies.type IS 'Type of dependency.';
+COMMENT ON COLUMN &registry.dependencies.dependency IS 'Dependency name.';
+COMMENT ON COLUMN &registry.dependencies.dependency_id IS 'Change ID the dependency resolves to.';
+
+CREATE TABLE &registry.events (
+ event TEXT NOT NULL,
+ -- CONSTRAINT events_event_check CHECK (
+ -- event IN ('deploy', 'revert', 'fail', 'merge')
+ -- ),
+ change_id TEXT NOT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES &registry.projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ requires TEXT NOT NULL DEFAULT '',
+ conflicts TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+COMMENT ON TABLE &registry.events IS 'Contains full history of all deployment events.';
+COMMENT ON COLUMN &registry.events.event IS 'Type of event.';
+COMMENT ON COLUMN &registry.events.change_id IS 'Change ID.';
+COMMENT ON COLUMN &registry.events.change IS 'Change name.';
+COMMENT ON COLUMN &registry.events.project IS 'Name of the Sqitch project to which the change belongs.';
+COMMENT ON COLUMN &registry.events.note IS 'Description of the change.';
+COMMENT ON COLUMN &registry.events.requires IS 'Array of the names of required changes.';
+COMMENT ON COLUMN &registry.events.conflicts IS 'Array of the names of conflicting changes.';
+COMMENT ON COLUMN &registry.events.tags IS 'Tags associated with the change.';
+COMMENT ON COLUMN &registry.events.committed_at IS 'Date the event was committed.';
+COMMENT ON COLUMN &registry.events.committer_name IS 'Name of the user who committed the event.';
+COMMENT ON COLUMN &registry.events.committer_email IS 'Email address of the user who committed the event.';
+COMMENT ON COLUMN &registry.events.planned_at IS 'Date the event was added to the plan.';
+COMMENT ON COLUMN &registry.events.planner_name IS 'Name of the user who planed the change.';
+COMMENT ON COLUMN &registry.events.planner_email IS 'Email address of the user who plan planned the change.';
diff --git a/t/lib/upgradable_registries/sqlite.sql b/t/lib/upgradable_registries/sqlite.sql
new file mode 100644
index 00000000..9aa00ec7
--- /dev/null
+++ b/t/lib/upgradable_registries/sqlite.sql
@@ -0,0 +1,75 @@
+BEGIN;
+
+CREATE TABLE releases (
+ version FLOAT PRIMARY KEY,
+ installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ installer_name TEXT NOT NULL,
+ installer_email TEXT NOT NULL
+);
+
+CREATE TABLE projects (
+ project TEXT PRIMARY KEY,
+ uri TEXT NULL UNIQUE,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ creator_name TEXT NOT NULL,
+ creator_email TEXT NOT NULL
+);
+
+CREATE TABLE changes (
+ change_id TEXT PRIMARY KEY,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL
+);
+
+CREATE TABLE tags (
+ tag_id TEXT PRIMARY KEY,
+ tag TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ UNIQUE(project, tag)
+);
+
+CREATE TABLE dependencies (
+ change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ dependency TEXT NOT NULL,
+ dependency_id TEXT NULL REFERENCES changes(change_id) ON UPDATE CASCADE CHECK (
+ (type = 'require' AND dependency_id IS NOT NULL)
+ OR (type = 'conflict' AND dependency_id IS NULL)
+ ),
+ PRIMARY KEY (change_id, dependency)
+);
+
+CREATE TABLE events (
+ event TEXT NOT NULL CHECK (event IN ('deploy', 'revert', 'fail')),
+ change_id TEXT NOT NULL,
+ change TEXT NOT NULL,
+ project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE,
+ note TEXT NOT NULL DEFAULT '',
+ requires TEXT NOT NULL DEFAULT '',
+ conflicts TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '',
+ committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ committer_name TEXT NOT NULL,
+ committer_email TEXT NOT NULL,
+ planned_at DATETIME NOT NULL,
+ planner_name TEXT NOT NULL,
+ planner_email TEXT NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+COMMIT;
diff --git a/t/lib/upgradable_registries/vertica.sql b/t/lib/upgradable_registries/vertica.sql
new file mode 100644
index 00000000..dc7b8754
--- /dev/null
+++ b/t/lib/upgradable_registries/vertica.sql
@@ -0,0 +1,84 @@
+CREATE SCHEMA :"registry";
+
+COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.';
+
+CREATE TABLE :"registry".releases (
+ version FLOAT PRIMARY KEY,
+ installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ installer_name VARCHAR(1024) NOT NULL,
+ installer_email VARCHAR(1024) NOT NULL
+);
+
+COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.';
+
+CREATE TABLE :"registry".projects (
+ project VARCHAR(1024) PRIMARY KEY ENCODING AUTO,
+ uri VARCHAR(1024) NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ creator_name VARCHAR(1024) NOT NULL,
+ creator_email VARCHAR(1024) NOT NULL
+);
+
+COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.';
+
+CREATE TABLE :"registry".changes (
+ change_id CHAR(40) PRIMARY KEY ENCODING AUTO,
+ change VARCHAR(1024) NOT NULL,
+ project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project),
+ note VARCHAR(65000) NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name VARCHAR(1024) NOT NULL,
+ committer_email VARCHAR(1024) NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name VARCHAR(1024) NOT NULL,
+ planner_email VARCHAR(1024) NOT NULL
+);
+
+COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.';
+
+CREATE TABLE :"registry".tags (
+ tag_id CHAR(40) PRIMARY KEY ENCODING AUTO,
+ tag VARCHAR(1024) NOT NULL,
+ project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project),
+ change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id),
+ note VARCHAR(65000) NOT NULL DEFAULT '',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name VARCHAR(1024) NOT NULL,
+ committer_email VARCHAR(1024) NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name VARCHAR(1024) NOT NULL,
+ planner_email VARCHAR(1024) NOT NULL,
+ UNIQUE(project, tag)
+);
+
+COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.';
+
+CREATE TABLE :"registry".dependencies (
+ change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id),
+ type VARCHAR(8) NOT NULL ENCODING AUTO,
+ dependency VARCHAR(2048) NOT NULL,
+ dependency_id CHAR(40) NULL REFERENCES :"registry".changes(change_id),
+ PRIMARY KEY (change_id, dependency)
+);
+
+COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.';
+
+CREATE TABLE :"registry".events (
+ event VARCHAR(6) NOT NULL ENCODING AUTO,
+ change_id CHAR(40) NOT NULL,
+ change VARCHAR(1024) NOT NULL,
+ project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project),
+ note VARCHAR(65000) NOT NULL DEFAULT '',
+ requires LONG VARCHAR NOT NULL DEFAULT '{}',
+ conflicts LONG VARCHAR NOT NULL DEFAULT '{}',
+ tags LONG VARCHAR NOT NULL DEFAULT '{}',
+ committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ committer_name VARCHAR(1024) NOT NULL,
+ committer_email VARCHAR(1024) NOT NULL,
+ planned_at TIMESTAMPTZ NOT NULL,
+ planner_name VARCHAR(1024) NOT NULL,
+ planner_email VARCHAR(1024) NOT NULL,
+ PRIMARY KEY (change_id, committed_at)
+);
+
+COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.';
diff --git a/t/linelist.t b/t/linelist.t
new file mode 100644
index 00000000..bd3c96a4
--- /dev/null
+++ b/t/linelist.t
@@ -0,0 +1,81 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 28;
+#use Test::More 'no_plan';
+use Test::NoWarnings;
+use Test::Exception;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use lib 't/lib';
+use TestConfig;
+
+BEGIN { require_ok 'App::Sqitch::Plan::LineList' or die }
+
+my $sqitch = App::Sqitch->new(config => TestConfig->new('core.engine' => 'sqlite'));
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target);
+
+my $foo = App::Sqitch::Plan::Change->new(plan => $plan, name => 'foo');
+my $bar = App::Sqitch::Plan::Change->new(plan => $plan, name => 'bar');
+my $baz = App::Sqitch::Plan::Change->new(plan => $plan, name => 'baz');
+my $yo1 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo');
+my $yo2 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo');
+
+my $blank = App::Sqitch::Plan::Blank->new(plan => $plan);
+my $alpha = App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ change => $yo1,
+ name => 'alpha',
+);
+
+my $lines = App::Sqitch::Plan::LineList->new(
+ $foo,
+ $bar,
+ $yo1,
+ $alpha,
+ $blank,
+ $baz,
+ $yo2,
+);
+
+is $lines->count, 7, 'Count should be six';
+is_deeply [$lines->items], [$foo, $bar, $yo1, $alpha, $blank, $baz, $yo2],
+ 'Lines should be in order';
+is $lines->item_at(0), $foo, 'Should have foo at 0';
+is $lines->item_at(1), $bar, 'Should have bar at 1';
+is $lines->item_at(2), $yo1, 'Should have yo1 at 2';
+is $lines->item_at(3), $alpha, 'Should have @alpha at 3';
+is $lines->item_at(4), $blank, 'Should have blank at 4';
+is $lines->item_at(5), $baz, 'Should have baz at 5';
+is $lines->item_at(6), $yo2, 'Should have yo2 at 6';
+
+is $lines->index_of('non'), undef, 'Should not find "non"';
+is $lines->index_of($foo), 0, 'Should find foo at 0';
+is $lines->index_of($bar), 1, 'Should find bar at 1';
+is $lines->index_of($yo1), 2, 'Should find yo1 at 2';
+is $lines->index_of($alpha), 3, 'Should find @alpha at 3';
+is $lines->index_of($blank), 4, 'Should find blank at 4';
+is $lines->index_of($baz), 5, 'Should find baz at 5';
+is $lines->index_of($yo2), 6, 'Should find yo2 at 6';
+
+my $hi = App::Sqitch::Plan::Change->new(plan => $plan, name => 'hi');
+ok $lines->append($hi), 'Append hi';
+is $lines->count, 8, 'Count should now be eight';
+is_deeply [$lines->items], [$foo, $bar, $yo1, $alpha, $blank, $baz, $yo2, $hi],
+ 'Lines should be in order with $hi at the end';
+
+# Try inserting.
+my $oy = App::Sqitch::Plan::Change->new(plan => $plan, name => 'oy');
+ok $lines->insert_at($oy, 3), 'Insert a change at index 3';
+is $lines->count, 9, 'Count should now be nine';
+is_deeply [$lines->items], [$foo, $bar, $yo1, $oy, $alpha, $blank, $baz, $yo2, $hi],
+ 'Lines should be in order with $oy at index 3';
+is $lines->index_of($oy), 3, 'Should find oy at 3';
+is $lines->index_of($alpha), 4, 'Should find @alpha at 4';
+is $lines->index_of($hi), 8, 'Should find hi at 8';
+
diff --git a/t/local.conf b/t/local.conf
new file mode 100644
index 00000000..3588c218
--- /dev/null
+++ b/t/local.conf
@@ -0,0 +1,15 @@
+[core]
+ engine = pg
+
+[engine "pg"]
+ target = mydb
+
+[engine "sqlite"]
+ target = devdb
+
+[target "devdb"]
+ uri = db:sqlite:
+
+[target "mydb"]
+ uri = db:pg:mydb
+ plan_file = t/plans/dependencies.plan
diff --git a/t/log.t b/t/log.t
new file mode 100644
index 00000000..568b9d71
--- /dev/null
+++ b/t/log.t
@@ -0,0 +1,754 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 253;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::NoWarnings;
+use Test::Exception;
+use Test::Warn;
+use Test::MockModule;
+use Path::Class;
+use Term::ANSIColor qw(color);
+use Encode;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+use LC;
+
+my $CLASS = 'App::Sqitch::Command::log';
+require_ok $CLASS;
+
+my $plan_file = Path::Class::File->new('t/sql/sqitch.plan')->stringify;
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => Path::Class::Dir->new('test-log')->stringify,
+ 'core.plan_file' => $plan_file,
+);
+ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object';
+isa_ok my $log = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'log',
+ config => $config,
+}), $CLASS, 'log command';
+
+can_ok $log, qw(
+ target
+ change_pattern
+ project_pattern
+ committer_pattern
+ max_count
+ skip
+ reverse
+ format
+ options
+ execute
+ configure
+ headers
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::ConnectingCommand"),
+ "$CLASS does ConnectingCommand";
+
+is_deeply [$CLASS->options], [qw(
+ event=s@
+ target|t=s
+ change-pattern|change=s
+ project-pattern|project=s
+ committer-pattern|committer=s
+ format|f=s
+ date-format|date=s
+ max-count|n=i
+ skip=i
+ reverse!
+ color=s
+ no-color
+ abbrev=i
+ oneline
+ headers!
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+)], 'Options should be correct';
+
+##############################################################################
+# Test database.
+is $log->target, undef, 'Default target should be undef';
+isa_ok $log = $CLASS->new(
+ sqitch => $sqitch,
+ target => 'foo',
+), $CLASS, 'new status with target';
+is $log->target, 'foo', 'Should have target "foo"';
+
+##############################################################################
+# Test configure().
+my $configured = $CLASS->configure($config, {});
+isa_ok delete $configured->{formatter}, 'App::Sqitch::ItemFormatter', 'Formatter';
+is_deeply $configured, {_params => []},
+ 'Should get empty hash for no config or options';
+
+# Test date_format validation.
+$config->update('log.date_format' => 'nonesuch');
+throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X',
+ 'Should get error for invalid date format in config';
+is $@->ident, 'datetime',
+ 'Invalid date format error ident should be "datetime"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'nonesuch',
+), 'Invalid date format error message should be correct';
+
+throws_ok { $CLASS->configure($config, { date_format => 'non'}), {} }
+ 'App::Sqitch::X',
+ 'Should get error for invalid date format in optsions';
+is $@->ident, 'datetime',
+ 'Invalid date format error ident should be "log"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'non',
+), 'Invalid date format error message should be correct';
+
+# Test format validation.
+$config = TestConfig->new('log.format' => 'nonesuch');
+throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X',
+ 'Should get error for invalid format in config';
+is $@->ident, 'log',
+ 'Invalid format error ident should be "log"';
+is $@->message, __x(
+ 'Unknown log format "{format}"',
+ format => 'nonesuch',
+), 'Invalid format error message should be correct';
+
+throws_ok { $CLASS->configure($config, { format => 'non'}), {} }
+ 'App::Sqitch::X',
+ 'Should get error for invalid format in optsions';
+is $@->ident, 'log',
+ 'Invalid format error ident should be "log"';
+is $@->message, __x(
+ 'Unknown log format "{format}"',
+ format => 'non',
+), 'Invalid format error message should be correct';
+
+# Test color configuration.
+$config = TestConfig->new;
+$configured = $CLASS->configure( $config, { no_color => 1 } );
+is $configured->{formatter}->color, 'never',
+ 'Configuration should respect --no-color, setting "never"';
+
+# Test oneline configuration.
+$configured = $CLASS->configure( $config, { oneline => 1 });
+is $configured->{format}, '%{:event}C%h %l%{reset}C %o:%n %s',
+ '--oneline should set format';
+is $configured->{formatter}{abbrev}, 6, '--oneline should set abbrev to 6';
+
+$configured = $CLASS->configure( $config, { oneline => 1, format => 'format:foo', abbrev => 5 });
+is $configured->{format}, 'foo', '--oneline should not override --format';
+is $configured->{formatter}{abbrev}, 5, '--oneline should not overrride --abbrev';
+
+$config->update('log.color' => 'auto');
+$configured = $CLASS->configure( $config, { no_color => 1 } );
+
+is $configured->{formatter}->color, 'never',
+ 'Configuration should respect --no-color even when configure is set';
+
+NEVER: {
+ my $configured = $CLASS->configure( $config, { color => 'never' } );
+ is $configured->{formatter}->color, 'never',
+ 'Configuration should respect color option';
+
+ # Try it with config.
+ $config->update('log.color' => 'never');
+ $configured = $CLASS->configure( $config, {} );
+ is $configured->{formatter}->color, 'never',
+ 'Configuration should respect color config';
+}
+
+ALWAYS: {
+ my $configured = $CLASS->configure( $config, { color => 'always' } );
+ is_deeply $configured->{formatter}->color, 'always',
+ 'Configuration should respect color option';
+
+ # Try it with config.
+ $config->update('log.color' => 'always');
+ $configured = $CLASS->configure( $config, {} );
+ is_deeply $configured->{formatter}->color, 'always',
+ 'Configuration should respect color config';
+}
+
+AUTO: {
+ for my $enabled (0, 1) {
+ $config->update('log.color' => 'always');
+ my $configured = $CLASS->configure( $config, { color => 'auto' } );
+ is_deeply $configured->{formatter}->color, 'auto',
+ 'Configuration should respect color option';
+
+ # Try it with config.
+ $config->update('log.color' => 'auto');
+ $configured = $CLASS->configure( $config, {} );
+ is_deeply $configured->{formatter}->color, 'auto',
+ 'Configuration should respect color config';
+ }
+}
+
+###############################################################################
+# Test named formats.
+my $cdt = App::Sqitch::DateTime->now;
+my $pdt = $cdt->clone->subtract(days => 1);
+my $event = {
+ event => 'deploy',
+ project => 'logit',
+ change_id => '000011112222333444',
+ change => 'lolz',
+ tags => [ '@beta', '@gamma' ],
+ committer_name => 'larry',
+ committer_email => 'larry@example.com',
+ committed_at => $cdt,
+ planner_name => 'damian',
+ planner_email => 'damian@example.com',
+ planned_at => $pdt,
+ note => "For the LOLZ.\n\nYou know, funny stuff and cute kittens, right?",
+ requires => [qw(foo bar)],
+ conflicts => []
+};
+
+my $ciso = $cdt->as_string( format => 'iso' );
+my $craw = $cdt->as_string( format => 'raw' );
+my $piso = $pdt->as_string( format => 'iso' );
+my $praw = $pdt->as_string( format => 'raw' );
+for my $spec (
+ [ raw => "deploy 000011112222333444 (\@beta, \@gamma)\n"
+ . "name lolz\n"
+ . "project logit\n"
+ . "requires foo, bar\n"
+ . "planner damian <damian\@example.com>\n"
+ . "planned $praw\n"
+ . "committer larry <larry\@example.com>\n"
+ . "committed $craw\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ full => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n"
+ . __('Name: ') . " lolz\n"
+ . __('Project: ') . " logit\n"
+ . __('Requires: ') . " foo, bar\n"
+ . __('Planner: ') . " damian <damian\@example.com>\n"
+ . __('Planned: ') . " __PDATE__\n"
+ . __('Committer:') . " larry <larry\@example.com>\n"
+ . __('Committed:') . " __CDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ long => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n"
+ . __('Name: ') . " lolz\n"
+ . __('Project: ') . " logit\n"
+ . __('Planner: ') . " damian <damian\@example.com>\n"
+ . __('Committer:') . " larry <larry\@example.com>\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ medium => __('Deploy') . " 000011112222333444\n"
+ . __('Name: ') . " lolz\n"
+ . __('Committer:') . " larry <larry\@example.com>\n"
+ . __('Date: ') . " __CDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ short => __('Deploy') . " 000011112222333444\n"
+ . __('Name: ') . " lolz\n"
+ . __('Committer:') . " larry <larry\@example.com>\n\n"
+ . " For the LOLZ.\n",
+ ],
+ [ oneline => '000011112222333444 ' . __('deploy') . ' logit:lolz For the LOLZ.' ],
+) {
+ local $ENV{ANSI_COLORS_DISABLED} = 1;
+ my $configured = $CLASS->configure( $config, { format => $spec->[0] } );
+ my $format = $configured->{format};
+ ok my $log = $CLASS->new( sqitch => $sqitch, %{ $configured } ),
+ qq{Instantiate with format "$spec->[0]"};
+ (my $exp = $spec->[1]) =~ s/__CDATE__/$ciso/;
+ $exp =~ s/__PDATE__/$piso/;
+ is $log->formatter->format( $log->format, $event ), $exp,
+ qq{Format "$spec->[0]" should output correctly};
+
+ if ($spec->[1] =~ /__CDATE__/) {
+ # Test different date formats.
+ for my $date_format (qw(rfc long medium)) {
+ ok my $log = $CLASS->new(
+ sqitch => $sqitch,
+ format => $format,
+ formatter => App::Sqitch::ItemFormatter->new(date_format => $date_format),
+ ), qq{Instantiate with format "$spec->[0]" and date format "$date_format"};
+ my $date = $cdt->as_string( format => $date_format );
+ (my $exp = $spec->[1]) =~ s/__CDATE__/$date/;
+ $date = $pdt->as_string( format => $date_format );
+ $exp =~ s/__PDATE__/$date/;
+ is $log->formatter->format( $log->format, $event ), $exp,
+ qq{Format "$spec->[0]" and date format "$date_format" should output correctly};
+ }
+ }
+
+ if ($spec->[1] =~ s/\s+[(]?[@]beta,\s+[@]gamma[)]?//) {
+ # Test without tags.
+ local $event->{tags} = [];
+ (my $exp = $spec->[1]) =~ s/__CDATE__/$ciso/;
+ $exp =~ s/__PDATE__/$piso/;
+ is $log->formatter->format( $log->format, $event ), $exp,
+ qq{Format "$spec->[0]" should output correctly without tags};
+ }
+}
+
+###############################################################################
+# Test all formatting characters.
+my $local_cdt = $cdt->clone;
+$local_cdt->set_time_zone('local');
+$local_cdt->set_locale($LC::TIME);
+my $local_pdt = $pdt->clone;
+$local_pdt->set_time_zone('local');
+$local_pdt->set_locale($LC::TIME);
+
+my $formatter = $log->formatter;
+for my $spec (
+ ['%e', { event => 'deploy' }, 'deploy' ],
+ ['%e', { event => 'revert' }, 'revert' ],
+ ['%e', { event => 'fail' }, 'fail' ],
+
+ ['%L', { event => 'deploy' }, __ 'Deploy' ],
+ ['%L', { event => 'revert' }, __ 'Revert' ],
+ ['%L', { event => 'fail' }, __ 'Fail' ],
+
+ ['%l', { event => 'deploy' }, __ 'deploy' ],
+ ['%l', { event => 'revert' }, __ 'revert' ],
+ ['%l', { event => 'fail' }, __ 'fail' ],
+
+ ['%{event}_', {}, __ 'Event: ' ],
+ ['%{change}_', {}, __ 'Change: ' ],
+ ['%{committer}_', {}, __ 'Committer:' ],
+ ['%{planner}_', {}, __ 'Planner: ' ],
+ ['%{by}_', {}, __ 'By: ' ],
+ ['%{date}_', {}, __ 'Date: ' ],
+ ['%{committed}_', {}, __ 'Committed:' ],
+ ['%{planned}_', {}, __ 'Planned: ' ],
+ ['%{name}_', {}, __ 'Name: ' ],
+ ['%{email}_', {}, __ 'Email: ' ],
+ ['%{requires}_', {}, __ 'Requires: ' ],
+ ['%{conflicts}_', {}, __ 'Conflicts:' ],
+
+ ['%H', { change_id => '123456789' }, '123456789' ],
+ ['%h', { change_id => '123456789' }, '123456789' ],
+ ['%{5}h', { change_id => '123456789' }, '12345' ],
+ ['%{7}h', { change_id => '123456789' }, '1234567' ],
+
+ ['%n', { change => 'foo' }, 'foo'],
+ ['%n', { change => 'bar' }, 'bar'],
+ ['%o', { project => 'foo' }, 'foo'],
+ ['%o', { project => 'bar' }, 'bar'],
+
+ ['%c', { committer_name => 'larry', committer_email => 'larry@example.com' }, 'larry <larry@example.com>'],
+ ['%{n}c', { committer_name => 'damian' }, 'damian'],
+ ['%{name}c', { committer_name => 'chip' }, 'chip'],
+ ['%{e}c', { committer_email => 'larry@example.com' }, 'larry@example.com'],
+ ['%{email}c', { committer_email => 'damian@example.com' }, 'damian@example.com'],
+
+ ['%{date}c', { committed_at => $cdt }, $cdt->as_string( format => 'iso' ) ],
+ ['%{date:rfc}c', { committed_at => $cdt }, $cdt->as_string( format => 'rfc' ) ],
+ ['%{d:long}c', { committed_at => $cdt }, $cdt->as_string( format => 'long' ) ],
+ ["%{d:cldr:HH'h' mm'm'}c", { committed_at => $cdt }, $local_cdt->format_cldr( q{HH'h' mm'm'} ) ],
+ ["%{d:strftime:%a at %H:%M:%S}c", { committed_at => $cdt }, $local_cdt->strftime('%a at %H:%M:%S') ],
+
+ ['%p', { planner_name => 'larry', planner_email => 'larry@example.com' }, 'larry <larry@example.com>'],
+ ['%{n}p', { planner_name => 'damian' }, 'damian'],
+ ['%{name}p', { planner_name => 'chip' }, 'chip'],
+ ['%{e}p', { planner_email => 'larry@example.com' }, 'larry@example.com'],
+ ['%{email}p', { planner_email => 'damian@example.com' }, 'damian@example.com'],
+
+ ['%{date}p', { planned_at => $pdt }, $pdt->as_string( format => 'iso' ) ],
+ ['%{date:rfc}p', { planned_at => $pdt }, $pdt->as_string( format => 'rfc' ) ],
+ ['%{d:long}p', { planned_at => $pdt }, $pdt->as_string( format => 'long' ) ],
+ ["%{d:cldr:HH'h' mm'm'}p", { planned_at => $pdt }, $local_pdt->format_cldr( q{HH'h' mm'm'} ) ],
+ ["%{d:strftime:%a at %H:%M:%S}p", { planned_at => $pdt }, $local_pdt->strftime('%a at %H:%M:%S') ],
+
+ ['%t', { tags => [] }, '' ],
+ ['%t', { tags => ['@foo'] }, ' @foo' ],
+ ['%t', { tags => ['@foo', '@bar'] }, ' @foo, @bar' ],
+ ['%{|}t', { tags => [] }, '' ],
+ ['%{|}t', { tags => ['@foo'] }, ' @foo' ],
+ ['%{|}t', { tags => ['@foo', '@bar'] }, ' @foo|@bar' ],
+
+ ['%T', { tags => [] }, '' ],
+ ['%T', { tags => ['@foo'] }, ' (@foo)' ],
+ ['%T', { tags => ['@foo', '@bar'] }, ' (@foo, @bar)' ],
+ ['%{|}T', { tags => [] }, '' ],
+ ['%{|}T', { tags => ['@foo'] }, ' (@foo)' ],
+ ['%{|}T', { tags => ['@foo', '@bar'] }, ' (@foo|@bar)' ],
+
+ ['%r', { requires => [] }, '' ],
+ ['%r', { requires => ['foo'] }, ' foo' ],
+ ['%r', { requires => ['foo', 'bar'] }, ' foo, bar' ],
+ ['%{|}r', { requires => [] }, '' ],
+ ['%{|}r', { requires => ['foo'] }, ' foo' ],
+ ['%{|}r', { requires => ['foo', 'bar'] }, ' foo|bar' ],
+
+ ['%R', { requires => [] }, '' ],
+ ['%R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ],
+ ['%R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo, bar\n" ],
+ ['%{|}R', { requires => [] }, '' ],
+ ['%{|}R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ],
+ ['%{|}R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo|bar\n" ],
+
+ ['%x', { conflicts => [] }, '' ],
+ ['%x', { conflicts => ['foo'] }, ' foo' ],
+ ['%x', { conflicts => ['foo', 'bax'] }, ' foo, bax' ],
+ ['%{|}x', { conflicts => [] }, '' ],
+ ['%{|}x', { conflicts => ['foo'] }, ' foo' ],
+ ['%{|}x', { conflicts => ['foo', 'bax'] }, ' foo|bax' ],
+
+ ['%X', { conflicts => [] }, '' ],
+ ['%X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ],
+ ['%X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo, bar\n" ],
+ ['%{|}X', { conflicts => [] }, '' ],
+ ['%{|}X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ],
+ ['%{|}X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo|bar\n" ],
+
+ ['%{yellow}C', {}, '' ],
+ ['%{:event}C', { event => 'deploy' }, '' ],
+ ['%v', {}, "\n" ],
+ ['%%', {}, '%' ],
+
+ ['%s', { note => 'hi there' }, 'hi there' ],
+ ['%s', { note => "hi there\nyo" }, 'hi there' ],
+ ['%s', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, 'subject line' ],
+ ['%{ }s', { note => 'hi there' }, ' hi there' ],
+ ['%{xx}s', { note => 'hi there' }, 'xxhi there' ],
+
+ ['%b', { note => 'hi there' }, '' ],
+ ['%b', { note => "hi there\nyo" }, 'yo' ],
+ ['%b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "first graph\n\nsecond graph\n\n" ],
+ ['%{ }b', { note => 'hi there' }, '' ],
+ ['%{xxx }b', { note => "hi there\nyo" }, "xxx yo" ],
+ ['%{x}b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xfirst graph\nx\nxsecond graph\nx\n" ],
+ ['%{ }b', { note => "hi there\r\nyo" }, " yo" ],
+
+ ['%B', { note => 'hi there' }, 'hi there' ],
+ ['%B', { note => "hi there\nyo" }, "hi there\nyo" ],
+ ['%B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "subject line\n\nfirst graph\n\nsecond graph\n\n" ],
+ ['%{ }B', { note => 'hi there' }, ' hi there' ],
+ ['%{xxx }B', { note => "hi there\nyo" }, "xxx hi there\nxxx yo" ],
+ ['%{x}B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xsubject line\nx\nxfirst graph\nx\nxsecond graph\nx\n" ],
+ ['%{ }B', { note => "hi there\r\nyo" }, " hi there\r\n yo" ],
+
+ ['%{change}a', $event, "change $event->{change}\n" ],
+ ['%{change_id}a', $event, "change_id $event->{change_id}\n" ],
+ ['%{event}a', $event, "event $event->{event}\n" ],
+ ['%{tags}a', $event, 'tags ' . join(', ', @{ $event->{tags} }) . "\n" ],
+ ['%{requires}a', $event, 'requires ' . join(', ', @{ $event->{requires} }) . "\n" ],
+ ['%{conflicts}a', $event, '' ],
+ ['%{committer_name}a', $event, "committer_name $event->{committer_name}\n" ],
+ ['%{committed_at}a', $event, "committed_at $craw\n" ],
+) {
+ local $ENV{ANSI_COLORS_DISABLED} = 1;
+ (my $desc = encode_utf8 $spec->[2]) =~ s/\n/[newline]/g;
+ is $formatter->format( $spec->[0], $spec->[1] ), $spec->[2],
+ qq{Format "$spec->[0]" should output "$desc"};
+}
+
+throws_ok { $formatter->format( '%_', {} ) } 'App::Sqitch::X',
+ 'Should get exception for format "%_"';
+is $@->ident, 'format', '%_ error ident should be "format"';
+is $@->message, __ 'No label passed to the _ format',
+ '%_ error message should be correct';
+throws_ok { $formatter->format( '%{foo}_', {} ) } 'App::Sqitch::X',
+ 'Should get exception for unknown label in format "%_"';
+is $@->ident, 'format', 'Invalid %_ label error ident should be "format"';
+is $@->message, __x(
+ 'Unknown label "{label}" passed to the _ format',
+ label => 'foo'
+), 'Invalid %_ label error message should be correct';
+
+ok $log = $CLASS->new(
+ sqitch => $sqitch,
+ formatter => App::Sqitch::ItemFormatter->new(abbrev => 4)
+), 'Instantiate with abbrev => 4';
+is $log->formatter->format( '%h', { change_id => '123456789' } ),
+ '1234', '%h should respect abbrev';
+is $log->formatter->format( '%H', { change_id => '123456789' } ),
+ '123456789', '%H should not respect abbrev';
+
+ok $log = $CLASS->new(
+ sqitch => $sqitch,
+ formatter => App::Sqitch::ItemFormatter->new(date_format => 'rfc')
+), 'Instantiate with date_format => "rfc"';
+is $log->formatter->format( '%{date}c', { committed_at => $cdt } ),
+ $cdt->as_string( format => 'rfc' ),
+ '%{date}c should respect the date_format attribute';
+is $log->formatter->format( '%{d:iso}c', { committed_at => $cdt } ),
+ $cdt->as_string( format => 'iso' ),
+ '%{iso}c should override the date_format attribute';
+
+throws_ok { $formatter->format( '%{foo}a', {}) } 'App::Sqitch::X',
+ 'Should get exception for unknown attribute passed to %a';
+is $@->ident, 'format', '%a error ident should be "format"';
+is $@->message, __x(
+ '{attr} is not a valid change attribute', attr => 'foo'
+), '%a error message should be correct';
+
+
+delete $ENV{ANSI_COLORS_DISABLED};
+for my $color (qw(yellow red blue cyan magenta)) {
+ is $formatter->format( "%{$color}C", {} ), color($color),
+ qq{Format "%{$color}C" should output }
+ . color($color) . $color . color('reset');
+}
+
+for my $spec (
+ [ ':event', { event => 'deploy' }, 'green', 'deploy' ],
+ [ ':event', { event => 'revert' }, 'blue', 'revert' ],
+ [ ':event', { event => 'fail' }, 'red', 'fail' ],
+) {
+ is $formatter->format( "%{$spec->[0]}C", $spec->[1] ), color($spec->[2]),
+ qq{Format "%{$spec->[0]}C" on "$spec->[3]" should output }
+ . color($spec->[2]) . $spec->[2] . color('reset');
+}
+
+# Make sure other colors work.
+my $yellow = color('yellow') . '%s' . color('reset');
+my $green = color('green') . '%s' . color('reset');
+$event->{conflicts} = [qw(dr_evil)];
+for my $spec (
+ [ full => sprintf($green, __ ('Deploy') . ' 000011112222333444')
+ . " (\@beta, \@gamma)\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Project: ') . " logit\n"
+ . __ ('Requires: ') . " foo, bar\n"
+ . __ ('Conflicts:') . " dr_evil\n"
+ . __ ('Planner: ') . " damian <damian\@example.com>\n"
+ . __ ('Planned: ') . " __PDATE__\n"
+ . __ ('Committer:') . " larry <larry\@example.com>\n"
+ . __ ('Committed:') . " __CDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ long => sprintf($green, __ ('Deploy') . ' 000011112222333444')
+ . " (\@beta, \@gamma)\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Project: ') . " logit\n"
+ . __ ('Planner: ') . " damian <damian\@example.com>\n"
+ . __ ('Committer:') . " larry <larry\@example.com>\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ medium => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Committer:') . " larry <larry\@example.com>\n"
+ . __ ('Date: ') . " __CDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ short => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Committer:') . " larry <larry\@example.com>\n\n"
+ . " For the LOLZ.\n",
+ ],
+ [ oneline => sprintf "$green %s %s", '000011112222333444' . ' '
+ . __('deploy'), 'logit:lolz', 'For the LOLZ.',
+ ],
+) {
+ my $format = $CLASS->configure( $config, { format => $spec->[0] } )->{format};
+ ok my $log = $CLASS->new( sqitch => $sqitch, format => $format ),
+ qq{Instantiate with format "$spec->[0]" again};
+ (my $exp = $spec->[1]) =~ s/__CDATE__/$ciso/;
+ $exp =~ s/__PDATE__/$piso/;
+ is $log->formatter->format( $log->format, $event ), $exp,
+ qq{Format "$spec->[0]" should output correctly with color};
+}
+
+throws_ok { $formatter->format( '%{BLUELOLZ}C', {} ) } 'App::Sqitch::X',
+ 'Should get an error for an invalid color';
+is $@->ident, 'format', 'Invalid color error ident should be "format"';
+is $@->message, __x(
+ '{color} is not a valid ANSI color', color => 'BLUELOLZ'
+), 'Invalid color error message should be correct';
+
+##############################################################################
+# Test execute().
+my $emock = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+$emock->mock(destination => 'flipr');
+
+my $mock_target = Test::MockModule->new('App::Sqitch::Target');
+my ($target_name_arg, $orig_meth);
+$target_name_arg = '_blah';
+$mock_target->mock(new => sub {
+ my $self = shift;
+ my %p = @_;
+ $target_name_arg = $p{name};
+ $self->$orig_meth(@_);
+});
+$orig_meth = $mock_target->original('new');
+
+# First test for uninitialized DB.
+my $init = 0;
+$emock->mock(initialized => sub { $init });
+throws_ok { $log->execute } 'App::Sqitch::X',
+ 'Should get exception for unititialied db';
+is $@->ident, 'log', 'Uninit db error ident should be "log"';
+is $@->exitval, 1, 'Uninit db exit val should be 1';
+is $@->message, __x(
+ 'Database {db} has not been initialized for Sqitch',
+ db => 'db:sqlite:',
+), 'Uninit db error message should be correct';
+is $target_name_arg, undef, 'Should have passed undef to Target';
+
+# Next, test for no events.
+$init = 1;
+$target_name_arg = '_blah';
+my @events;
+my $iter = sub { shift @events };
+my $search_args;
+$emock->mock(search_events => sub {
+ shift;
+ $search_args = [@_];
+ return $iter;
+});
+$log = $CLASS->new(sqitch => $sqitch);
+throws_ok { $log->execute } 'App::Sqitch::X',
+ 'Should get error for empty event table';
+is $@->ident, 'log', 'no events error ident should be "log"';
+is $@->exitval, 1, 'no events exit val should be 1';
+is $@->message, __x(
+ 'No events logged for {db}',
+ db => 'flipr',
+), 'no events error message should be correct';
+is_deeply $search_args, [limit => 1],
+ 'Search should have been limited to one row';
+is $target_name_arg, undef, 'Should have passed undef to Target again';
+
+# Okay, let's add some events.
+push @events => {}, $event;
+$target_name_arg = '_blah';
+$log = $CLASS->new(sqitch => $sqitch);
+ok $log->execute, 'Execute log';
+is $target_name_arg, undef, 'Should have passed undef to Target once more';
+is_deeply $search_args, [
+ event => undef,
+ change => undef,
+ project => undef,
+ committer => undef,
+ limit => undef,
+ offset => undef,
+ direction => 'DESC'
+], 'The proper args should have been passed to search_events';
+
+is_deeply +MockOutput->get_page, [
+ [__x 'On database {db}', db => 'flipr'],
+ [ $log->formatter->format( $log->format, $event ) ],
+], 'The change should have been paged';
+
+# Make sure a passed target is processed.
+push @events => {}, $event;
+$target_name_arg = '_blah';
+ok $log->execute('db:sqlite:whatever.db'), 'Execute with target arg';
+is $target_name_arg, 'db:sqlite:whatever.db',
+ 'Target name should have been passed to Target';
+is_deeply $search_args, [
+ event => undef,
+ change => undef,
+ project => undef,
+ committer => undef,
+ limit => undef,
+ offset => undef,
+ direction => 'DESC'
+], 'The proper args should have been passed to search_events';
+
+is_deeply +MockOutput->get_page, [
+ [__x 'On database {db}', db => 'flipr'],
+ [ $log->formatter->format( $log->format, $event ) ],
+], 'The change should have been paged';
+
+# Make sure we can pass a plan file.
+push @events => {}, $event;
+$target_name_arg = '_blah';
+ok $log->execute($plan_file), 'Execute with plan file arg';
+is $target_name_arg, 'db:sqlite:',
+ 'Default engine target should have been passed to Target';
+is_deeply $search_args, [
+ event => undef,
+ change => undef,
+ project => undef,
+ committer => undef,
+ limit => undef,
+ offset => undef,
+ direction => 'DESC'
+], 'The proper args should have been passed to search_events';
+
+is_deeply +MockOutput->get_page, [
+ [__x 'On database {db}', db => 'flipr'],
+ [ $log->formatter->format( $log->format, $event ) ],
+], 'The change should have been paged';
+
+# Set attributes and add more events.
+my $event2 = {
+ event => 'revert',
+ change_id => '84584584359345',
+ change => 'barf',
+ tags => [],
+ committer_name => 'theory',
+ committer_email => 'theory@example.com',
+ committed_at => $cdt,
+ note => 'Oh man this was a bad idea',
+};
+push @events => {}, $event, $event2;
+isa_ok $log = $CLASS->new(
+ sqitch => $sqitch,
+ target => 'db:sqlite:foo.db',
+ event => [qw(revert fail)],
+ change_pattern => '.+',
+ project_pattern => '.+',
+ committer_pattern => '.+',
+ max_count => 10,
+ skip => 5,
+ reverse => 1,
+ headers => 0,
+), $CLASS, 'log with attributes';
+
+$target_name_arg = '_blah';
+ok $log->execute, 'Execute log with attributes';
+is $target_name_arg, $log->target, 'Should have passed target name to Target';
+is_deeply $search_args, [
+ event => [qw(revert fail)],
+ change => '.+',
+ project => '.+',
+ committer => '.+',
+ limit => 10,
+ offset => 5,
+ direction => 'ASC'
+], 'All params should have been passed to search_events';
+
+is_deeply +MockOutput->get_page, [
+ [ $log->formatter->format( $log->format, $event ) ],
+ [ $log->formatter->format( $log->format, $event2 ) ],
+], 'Both changes should have been paged with no headers';
+
+# Make sure we get a warning when both the option and the arg are specified.
+push @events => {}, $event;
+ok $log->execute('pg'), 'Execute log with attributes';
+is $target_name_arg, 'db:pg:', 'Should have passed enginetarget to Target';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $log->target,
+)]], 'Should have got warning for two targets';
+
+# Make sure we catch bad format codes.
+isa_ok $log = $CLASS->new(
+ sqitch => $sqitch,
+ format => '%Z',
+), $CLASS, 'log with bad format';
+
+push @events, {}, $event;
+$target_name_arg = '_blah';
+throws_ok { $log->execute } 'App::Sqitch::X',
+ 'Should get an exception for a bad format code';
+is $@->ident, 'format',
+ 'bad format code format error ident should be "format"';
+is $@->message, __x(
+ 'Unknown format code "{code}"', code => 'Z',
+), 'bad format code format error message should be correct';
+is $target_name_arg, $log->target, 'Should have passed target name to Target';
diff --git a/t/mooseless.t b/t/mooseless.t
new file mode 100644
index 00000000..d09fd431
--- /dev/null
+++ b/t/mooseless.t
@@ -0,0 +1,31 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+
+use Test::More;
+use File::Find qw(find);
+use Module::Runtime qw(use_module);
+
+my $test = sub {
+ return unless $_ =~ /\.pm$/;
+
+ my $module = $File::Find::name;
+ $module =~ s!^(blib[/\\])?lib[/\\]!!;
+ $module =~ s![/\\]!::!g;
+ $module =~ s/\.pm$//;
+
+ eval { use_module $module; };
+ if ($@) {
+ diag "Couldn't load $module: $@";
+ undef $@;
+ return;
+ }
+
+ ok ! $INC{'Moose.pm'}, "No moose in $module";
+};
+
+find($test, 'lib');
+
+done_testing();
diff --git a/t/multiplan.conf b/t/multiplan.conf
new file mode 100644
index 00000000..ae8261d6
--- /dev/null
+++ b/t/multiplan.conf
@@ -0,0 +1,13 @@
+[core]
+ engine = pg
+
+[engine "pg"]
+ top_dir = engine
+ reworked_dir = engine/reworked
+
+[engine "sqlite"]
+ top_dir = engine
+ reworked_dir = engine/reworked
+
+[engine "mysql"]
+ top_dir = sql
diff --git a/t/mysql.t b/t/mysql.t
new file mode 100644
index 00000000..b5e974f8
--- /dev/null
+++ b/t/mysql.t
@@ -0,0 +1,521 @@
+#!/usr/bin/perl -w
+
+# To test against a live MySQL database, you must set the MYSQL_URI environment variable.
+# this is a stanard URI::db URI, and should look something like this:
+#
+# export MYSQL_URI=db:mysql://root:password@localhost:3306/information_schema
+#
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Test::MockModule;
+use Path::Class;
+use Try::Tiny;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use File::Temp 'tempdir';
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+
+my $mm = eval { Test::MockModule->new('MySQL::Config') };
+$mm->mock(parse_defaults => {}) if $mm;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::mysql';
+ require_ok $CLASS or die;
+ delete $ENV{$_} for qw(MYSQL_PWD MYSQL_HOST MYSQL_TCP_PORT);
+}
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $config = TestConfig->new('core.engine' => 'mysql');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+isa_ok my $mysql = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+
+is $mysql->key, 'mysql', 'Key should be "mysql"';
+is $mysql->name, 'MySQL', 'Name should be "MySQL"';
+
+my $client = 'mysql' . (App::Sqitch::ISWIN ? '.exe' : '');
+my $uri = URI::db->new('db:mysql:');
+is $mysql->client, $client, 'client should default to mysql';
+is $mysql->registry, 'sqitch', 'registry default should be "sqitch"';
+my $sqitch_uri = $uri->clone;
+$sqitch_uri->dbname('sqitch');
+is $mysql->registry_uri, $sqitch_uri, 'registry_uri should be correct';
+is $mysql->uri, $uri, qq{uri should be "$uri"};
+is $mysql->registry_destination, 'db:mysql:sqitch',
+ 'registry_destination should be the same as registry_uri';
+
+my @std_opts = (
+ (App::Sqitch::ISWIN ? () : '--skip-pager' ),
+ '--silent',
+ '--skip-column-names',
+ '--skip-line-numbers',
+);
+my $vinfo = try { $sqitch->probe($mysql->client, '--version') } || '';
+if ($vinfo =~ /mariadb/i) {
+ my ($version) = $vinfo =~ /Ver\s(\S+)/;
+ my ($maj, undef, $pat) = split /[.]/ => $version;
+ push @std_opts => '--abort-source-on-error'
+ if $maj > 5 || ($maj == 5 && $pat >= 66);
+}
+
+my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+my $warning;
+$mock_sqitch->mock(warn => sub { shift; $warning = [@_] });
+is_deeply [$mysql->mysql], [$client, '--user', $sqitch->sysuser, @std_opts],
+ 'mysql command should be user and std opts-only';
+is_deeply $warning, [__x
+ 'Database name missing in URI "{uri}"',
+ uri => $mysql->uri
+], 'Should have emitted a warning for no database name';
+$mock_sqitch->unmock_all;
+
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:mysql:foo'),
+);
+isa_ok $mysql = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+
+##############################################################################
+# Make sure environment variables are read.
+ENV: {
+ local $ENV{MYSQL_PWD} = '__KAMALA';
+ local $ENV{MYSQL_HOST} = 'sqitch.sql';
+ local $ENV{MYSQL_TCP_PORT} = 11238;
+ ok my $mysql = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create engine with MYSQL_PWD set';
+ is $mysql->password, $ENV{MYSQL_PWD},
+ 'Password should be set from environment';
+ is $mysql->uri->host, $ENV{MYSQL_HOST}, 'URI should reflect MYSQL_HOST';
+ is $mysql->uri->port, $ENV{MYSQL_TCP_PORT}, 'URI should reflect MYSQL_TCP_PORT';
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.mysql.client' => '/path/to/mysql',
+ 'engine.mysql.target' => 'db:mysql://foo.com/widgets',
+ 'engine.mysql.registry' => 'meta',
+);
+my $mysql_version = 'mysql Ver 15.1 Distrib 10.0.15-MariaDB';
+$mock_sqitch->mock(probe => sub { $mysql_version });
+push @std_opts => '--abort-source-on-error'
+ unless $std_opts[-1] eq '--abort-source-on-error';
+
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another mysql';
+is $mysql->client, '/path/to/mysql', 'client should be as configured';
+is $mysql->uri->as_string, 'db:mysql://foo.com/widgets',
+ 'URI should be as configured';
+is $mysql->target->name, $mysql->uri->as_string, 'target name should be the URI';
+is $mysql->destination, $mysql->uri->as_string, 'destination should be the URI';
+is $mysql->registry, 'meta', 'registry should be as configured';
+is $mysql->registry_uri->as_string, 'db:mysql://foo.com/meta',
+ 'Sqitch DB URI should be the same as uri but with DB name "meta"';
+is $mysql->registry_destination, $mysql->registry_uri->as_string,
+ 'registry_destination should be the sqitch DB URL';
+is_deeply [$mysql->mysql], [
+ '/path/to/mysql',
+ '--user', $sqitch->sysuser,
+ '--database', 'widgets',
+ '--host', 'foo.com',
+ @std_opts
+], 'mysql command should be configured';
+
+##############################################################################
+# Make sure URI params get passed through to the client.
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI->new('db:mysql://foo.com/widgets?' . join(
+ '&',
+ 'mysql_compression=1',
+ 'mysql_ssl=1',
+ 'mysql_connect_timeout=20',
+ 'mysql_init_command=BEGIN',
+ 'mysql_socket=/dev/null',
+ 'mysql_ssl_client_key=/foo/key',
+ 'mysql_ssl_client_cert=/foo/cert',
+ 'mysql_ssl_ca_file=/foo/cafile',
+ 'mysql_ssl_ca_path=/foo/capath',
+ 'mysql_ssl_cipher=blowfeld',
+ 'mysql_client_found_rows=20',
+ 'mysql_foo=bar',
+ ),
+));
+ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a mysql with query params';
+is_deeply [$mysql->mysql], [qw(
+ /path/to/mysql
+), '--user', $sqitch->sysuser, qw(
+ --database widgets
+ --host foo.com
+), @std_opts, qw(
+ --compress
+ --ssl
+ --connect_timeout 20
+ --init-command BEGIN
+ --socket /dev/null
+ --ssl-key /foo/key
+ --ssl-cert /foo/cert
+ --ssl-ca /foo/cafile
+ --ssl-capath /foo/capath
+ --ssl-cipher blowfeld
+)], 'mysql command should be configured with query vals';
+
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI->new('db:mysql://foo.com/widgets?' . join(
+ '&',
+ 'mysql_compression=0',
+ 'mysql_ssl=0',
+ 'mysql_connect_timeout=20',
+ 'mysql_client_found_rows=20',
+ 'mysql_foo=bar',
+ ),
+));
+ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a mysql with disabled query params';
+is_deeply [$mysql->mysql], [qw(
+ /path/to/mysql
+), '--user', $sqitch->sysuser, qw(
+ --database widgets
+ --host foo.com
+), @std_opts, qw(
+ --connect_timeout 20
+)], 'mysql command should not have disabled param options';
+
+##############################################################################
+# Test _run(), _capture(), and _spool().
+can_ok $mysql, qw(_run _capture _spool);
+my (@run, $exp_pass);
+$mock_sqitch->mock(run => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @run = @_;
+ if (defined $exp_pass) {
+ is $ENV{MYSQL_PWD}, $exp_pass, qq{MYSQL_PWD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{MYSQL_PWD}, 'MYSQL_PWD should not exist';
+ }
+});
+
+my @capture;
+$mock_sqitch->mock(capture => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @capture = @_;
+ if (defined $exp_pass) {
+ is $ENV{MYSQL_PWD}, $exp_pass, qq{MYSQL_PWD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{MYSQL_PWD}, 'MYSQL_PWD should not exist';
+ }
+});
+
+my @spool;
+$mock_sqitch->mock(spool => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @spool = @_;
+ if (defined $exp_pass) {
+ is $ENV{MYSQL_PWD}, $exp_pass, qq{MYSQL_PWD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{MYSQL_PWD}, 'MYSQL_PWD should not exist';
+ }
+});
+
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a mysql with sqitch with options';
+$exp_pass = 's3cr3t';
+$target->uri->password($exp_pass);
+ok $mysql->_run(qw(foo bar baz)), 'Call _run';
+is_deeply \@run, [$mysql->mysql, qw(foo bar baz)],
+ 'Command should be passed to run()';
+
+ok $mysql->_spool('FH'), 'Call _spool';
+is_deeply \@spool, [['FH'], $mysql->mysql],
+ 'Command should be passed to spool()';
+$mysql->set_variables(foo => 'bar', '"that"' => "'this'");
+ok $mysql->_spool('FH'), 'Call _spool with variables';
+ok my $fh = shift @{ $spool[0] }, 'Get variables file handle';
+is_deeply \@spool, [['FH'], $mysql->mysql],
+ 'Command should be passed to spool() after variables handle';
+is join("\n", <$fh>), qq{SET \@"""that""" = '''this''', \@"foo" = 'bar';\n},
+ 'Variables should have been escaped and set';
+$mysql->clear_variables;
+
+ok $mysql->_capture(qw(foo bar baz)), 'Call _capture';
+is_deeply \@capture, [$mysql->mysql, qw(foo bar baz)],
+ 'Command should be passed to capture()';
+
+# Without password.
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a mysql with sqitch with no pw';
+$exp_pass = undef;
+$target->uri->password($exp_pass);
+ok $mysql->_run(qw(foo bar baz)), 'Call _run again';
+is_deeply \@run, [$mysql->mysql, qw(foo bar baz)],
+ 'Command should be passed to run() again';
+
+ok $mysql->_spool('FH'), 'Call _spool again';
+is_deeply \@spool, [['FH'], $mysql->mysql],
+ 'Command should be passed to spool() again';
+
+ok $mysql->_capture(qw(foo bar baz)), 'Call _capture again';
+is_deeply \@capture, [$mysql->mysql, qw(foo bar baz)],
+ 'Command should be passed to capture() again';
+
+##############################################################################
+# Test file and handle running.
+ok $mysql->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+is_deeply \@run, [$mysql->mysql, '--execute', 'source foo/bar.sql'],
+ 'File should be passed to run()';
+@run = ();
+
+ok $mysql->run_handle('FH'), 'Spool a "file handle"';
+is_deeply \@spool, [['FH'], $mysql->mysql],
+ 'Handle should be passed to spool()';
+@spool = ();
+
+# Verify should go to capture unless verosity is > 1.
+ok $mysql->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+is_deeply \@capture, [$mysql->mysql, '--execute', 'source foo/bar.sql'],
+ 'Verify file should be passed to capture()';
+@capture = ();
+
+$mock_sqitch->mock(verbosity => 2);
+ok $mysql->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+is_deeply \@run, [$mysql->mysql, '--execute', 'source foo/bar.sql'],
+ 'Verifile file should be passed to run() for high verbosity';
+@run = ();
+
+# Try with variables.
+$mysql->set_variables(foo => 'bar', '"that"' => "'this'");
+my $set = qq{SET \@"""that""" = '''this''', \@"foo" = 'bar';\n};
+
+ok $mysql->run_file('foo/bar.sql'), 'Run foo/bar.sql with vars';
+is_deeply \@run, [$mysql->mysql, '--execute', "${set}source foo/bar.sql"],
+ 'Variabls and file should be passed to run()';
+@run = ();
+
+ok $mysql->run_handle('FH'), 'Spool a "file handle"';
+ok $fh = shift @{ $spool[0] }, 'Get variables file handle';
+is_deeply \@spool, [['FH'], $mysql->mysql],
+ 'File handle should be passed to spool() after variables handle';
+is join("\n", <$fh>), $set, 'Variables should have been escaped and set';
+@spool = ();
+
+ok $mysql->run_verify('foo/bar.sql'), 'Verbosely verify foo/bar.sql with vars';
+is_deeply \@run, [$mysql->mysql, '--execute', "${set}source foo/bar.sql"],
+ 'Variables and verify file should be passed to run()';
+@run = ();
+
+# Reset verbosity to send verify to spool.
+$mock_sqitch->unmock('verbosity');
+ok $mysql->run_verify('foo/bar.sql'), 'Verify foo/bar.sql with vars';
+is_deeply \@capture, [$mysql->mysql, '--execute', "${set}source foo/bar.sql"],
+ 'Verify file should be passed to capture()';
+@capture = ();
+
+$mysql->clear_variables;
+$mock_sqitch->unmock_all;
+
+##############################################################################
+# Test DateTime formatting stuff.
+can_ok $CLASS, '_ts2char_format';
+is sprintf($CLASS->_ts2char_format, 'foo'),
+ q{date_format(foo, 'year:%Y:month:%m:day:%d:hour:%H:minute:%i:second:%S:time_zone:UTC')},
+ '_ts2char_format should work';
+
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+
+##############################################################################
+# Test SQL helpers.
+is $mysql->_listagg_format, q{GROUP_CONCAT(%s SEPARATOR ' ')}, 'Should have _listagg_format';
+is $mysql->_regex_op, 'REGEXP', 'Should have _regex_op';
+is $mysql->_simple_from, '', 'Should have _simple_from';
+is $mysql->_limit_default, '18446744073709551615', 'Should have _limit_default';
+
+SECS: {
+ my $mock = Test::MockModule->new($CLASS);
+ my $dbh = {mysql_serverinfo => 'foo', mysql_serverversion => 50604};
+ $mock->mock(dbh => $dbh);
+ is $mysql->_ts_default, 'utc_timestamp(6)',
+ 'Should have _ts_default with fractional seconds';
+
+ $dbh->{mysql_serverversion} = 50101;
+ my $my51 = $CLASS->new(sqitch => $sqitch, target => $target);
+ is $my51->_ts_default, 'utc_timestamp',
+ 'Should have _ts_default without fractional seconds on 5.1';
+
+ $dbh->{mysql_serverversion} = 50604;
+ $dbh->{mysql_serverinfo} = 'Something about MariaDB man';
+ my $maria = $CLASS->new(sqitch => $sqitch, target => $target);
+ is $maria->_ts_default, 'utc_timestamp',
+ 'Should have _ts_default without fractional seconds on mariadb';
+}
+
+DBI: {
+ local *DBI::state;
+ local *DBI::err;
+ ok !$mysql->_no_table_error, 'Should have no table error';
+ ok !$mysql->_no_column_error, 'Should have no column error';
+
+ $DBI::state = '42S02';
+ ok $mysql->_no_table_error, 'Should now have table error';
+ ok !$mysql->_no_column_error, 'Still should have no column error';
+
+ $DBI::state = '42000';
+ $DBI::err = '1049';
+ ok $mysql->_no_table_error, 'Should again have table error';
+ ok !$mysql->_no_column_error, 'Still should have no column error';
+
+ $DBI::state = '42S22';
+ $DBI::err = '1054';
+ ok !$mysql->_no_table_error, 'Should again have no table error';
+ ok $mysql->_no_column_error, 'Should now have no column error';
+}
+
+is_deeply [$mysql->_limit_offset(8, 4)],
+ [['LIMIT ?', 'OFFSET ?'], [8, 4]],
+ 'Should get limit and offset';
+is_deeply [$mysql->_limit_offset(0, 2)],
+ [['LIMIT ?', 'OFFSET ?'], ['18446744073709551615', 2]],
+ 'Should get limit and offset when offset only';
+is_deeply [$mysql->_limit_offset(12, 0)], [['LIMIT ?'], [12]],
+ 'Should get only limit with 0 offset';
+is_deeply [$mysql->_limit_offset(12)], [['LIMIT ?'], [12]],
+ 'Should get only limit with noa offset';
+is_deeply [$mysql->_limit_offset(0, 0)], [[], []],
+ 'Should get no limit or offset for 0s';
+is_deeply [$mysql->_limit_offset()], [[], []],
+ 'Should get no limit or offset for no args';
+
+is_deeply [$mysql->_regex_expr('corn', 'Obama$')],
+ ['corn REGEXP ?', 'Obama$'],
+ 'Should use REGEXP for regex expr';
+
+##############################################################################
+# Can we do live tests?
+my $dbh;
+
+my $db = '__sqitchtest__' . $$;
+my $reg1 = '__metasqitch' . $$;
+my $reg2 = '__sqitchtest' . $$;
+
+END {
+ return unless $dbh;
+ $dbh->{Driver}->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh;
+ });
+
+ return unless $dbh->{Active};
+ $dbh->do("DROP DATABASE IF EXISTS $_") for ($db, $reg1, $reg2);
+}
+
+
+$uri = URI->new($ENV{MYSQL_URI} || 'db:mysql://root@/information_schema');
+$uri->dbname('information_schema') unless $uri->dbname;
+my $err = try {
+ $mysql->use_driver;
+ $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, {
+ PrintError => 0,
+ RaiseError => 1,
+ AutoCommit => 1,
+ });
+
+ # Make sure we have a version we can use.
+ if ($dbh->{mysql_serverinfo} =~ /mariadb/i) {
+ die "MariaDB >= 50300 required; this is $dbh->{mysql_serverversion}\n"
+ unless $dbh->{mysql_serverversion} >= 50300;
+ }
+ else {
+ die "MySQL >= 50000 required; this is $dbh->{mysql_serverversion}\n"
+ unless $dbh->{mysql_serverversion} >= 50000;
+ }
+
+ $dbh->do("CREATE DATABASE $db");
+ $uri->dbname($db);
+ undef;
+} catch {
+ eval { $_->message } || $_;
+};
+
+DBIEngineTest->run(
+ class => $CLASS,
+ target_params => [ registry => $reg1, uri => $uri ],
+ alt_target_params => [ registry => $reg2, uri => $uri ],
+ skip_unless => sub {
+ my $self = shift;
+ die $err if $err;
+ # Make sure we have mysql and can connect to the database.
+ $self->sqitch->probe( $self->client, '--version' );
+ say '# Connected to MySQL ' . $self->_capture('--execute' => 'SELECT version()');
+ 1;
+ },
+ engine_err_regex => qr/^You have an error /,
+ init_error => __x(
+ 'Sqitch database {database} already initialized',
+ database => $reg2,
+ ),
+ add_second_format => q{date_add(%s, interval 1 second)},
+ test_dbh => sub {
+ my $dbh = shift;
+ # Check the session configuration.
+ for my $spec (
+ [character_set_client => 'utf8'],
+ [character_set_server => 'utf8'],
+ ($dbh->{mysql_serverversion} < 50500 ? () : ([default_storage_engine => 'InnoDB'])),
+ [time_zone => '+00:00'],
+ [group_concat_max_len => 32768],
+ ) {
+ is $dbh->selectcol_arrayref('SELECT @@SESSION.' . $spec->[0])->[0],
+ $spec->[1], "Setting $spec->[0] should be set to $spec->[1]";
+ }
+
+ # Special-case sql_mode.
+ my $sql_mode = $dbh->selectcol_arrayref('SELECT @@SESSION.sql_mode')->[0];
+ for my $mode (qw(
+ ansi
+ strict_trans_tables
+ no_auto_value_on_zero
+ no_zero_date
+ no_zero_in_date
+ only_full_group_by
+ error_for_division_by_zero
+ )) {
+ like $sql_mode, qr/\b\Q$mode\E\b/i, "sql_mode should include $mode";
+ }
+ },
+);
+
+done_testing;
diff --git a/t/odbc/odbcinst.ini b/t/odbc/odbcinst.ini
new file mode 100644
index 00000000..2fffd23e
--- /dev/null
+++ b/t/odbc/odbcinst.ini
@@ -0,0 +1,11 @@
+[Exasol]
+Description = ODBC for Exasol
+Driver = /opt/EXASOL_ODBC-6.0.4/lib/linux/x86_64/libexaodbc-uo2214lv1.so
+
+[Vertica]
+Description = ODBC for Vertica
+Driver = /opt/vertica/lib64/libverticaodbc.so
+
+[Snowflake]
+Description = ODBC for Snowflake
+Driver = /usr/lib64/snowflake/odbc/lib/libSnowflake.so
diff --git a/t/odbc/vertica.ini b/t/odbc/vertica.ini
new file mode 100644
index 00000000..c2520cfb
--- /dev/null
+++ b/t/odbc/vertica.ini
@@ -0,0 +1,4 @@
+[Driver]
+DriverManagerEncoding=UTF-16
+ODBCInstLib=/usr/lib64/libodbcinst.so
+ErrorMessagesPath=/opt/vertica/lib64
diff --git a/t/options.t b/t/options.t
new file mode 100644
index 00000000..62b55be5
--- /dev/null
+++ b/t/options.t
@@ -0,0 +1,210 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More;
+use Test::MockModule;
+use Test::Exception;
+use Capture::Tiny 0.12 ':all';
+use Locale::TextDomain qw(App-Sqitch);
+use lib 't/lib';
+use TestConfig;
+
+my ($catch_chdir, $chdir_to, $chdir_fail);
+BEGIN {
+ $catch_chdir = 0;
+ # Stub out chdir.
+ *CORE::GLOBAL::chdir = sub {
+ return CORE::chdir(@_) unless $catch_chdir;
+ $chdir_to = shift;
+ return !$chdir_fail;
+ };
+}
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch';
+ use_ok $CLASS or die;
+}
+
+is_deeply [$CLASS->_core_opts], [qw(
+ chdir|cd|C=s
+ etc-path
+ no-pager
+ quiet
+ verbose|V|v+
+ help
+ man
+ version
+)], 'Options should be correct';
+
+##############################################################################
+# Test _find_cmd.
+can_ok $CLASS, '_find_cmd';
+
+CMD: {
+ # Mock output methods.
+ my $mocker = Test::MockModule->new($CLASS);
+ my $pod;
+ $mocker->mock(_pod2usage => sub { $pod = $_[1]; undef });
+ my @vent;
+ $mocker->mock(vent => sub { shift; push @vent => \@_ });
+
+ # Try no args.
+ my @args = ();
+ is $CLASS->_find_cmd(\@args), undef, 'Should find no command for no args';
+ is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage';
+ is_deeply \@vent, [], 'Should have vented nothing';
+ ($pod, @vent) = ();
+
+ # Try an invalid command.
+ @args = qw(barf);
+ is $CLASS->_find_cmd(\@args), undef, 'Should find no command for invalid command';
+ is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage';
+ is_deeply \@vent, [
+ [__x '"{command}" is not a valid command', command => 'barf'],
+ ], 'Should have vented an invalid command message';
+ ($pod, @vent) = ();
+
+ # Obvious options should be ignored.
+ for my $opt (qw(
+ --foo
+ --client=psql
+ -R
+ -X=yup
+ )) {
+ @args = ($opt, 'crack');
+ is $CLASS->_find_cmd(\@args), undef,
+ "Should find no command with option $opt";
+ is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage';
+ is_deeply \@vent, [
+ [__x '"{command}" is not a valid command', command => 'crack'],
+ ], qq{Should not have reported $opt as invalid command};
+ ($pod, @vent) = ();
+ }
+
+ # Lone -- should cancel processing.
+ @args = ('--', 'tag');
+ is $CLASS->_find_cmd(\@args), undef, 'Should find no command after --';
+ is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage';
+ is_deeply \@vent, [], 'Should have vented nothing';
+ ($pod, @vent) = ();
+
+ # Valid command should be removed from args.
+ for my $cmd (qw(bundle config help plan show tag)) {
+ @args = (qw(--foo=bar -xy), $cmd, qw(--quack back -x y -z));
+ my $class = "App::Sqitch::Command::$cmd";
+
+ is $CLASS->_find_cmd(\@args), $class, qq{Should find class for "$cmd"};
+ is $pod, undef, 'Should not have called _pod2usage';
+ is_deeply \@vent, [], 'Should have vented nothing';
+ is_deeply \@args, [qw(--foo=bar -xy --quack back -x y -z)],
+ qq{Should have removed "$cmd" from args};
+ ($pod, @vent) = ();
+
+ @args = (qw(--foo=bar), $cmd, qw(verify -x));
+ is $CLASS->_find_cmd(\@args), $class, qq{Should find class for "$cmd" again};
+ is $pod, undef, 'Should not have called _pod2usage';
+ is_deeply \@vent, [], 'Should have vented nothing';
+ is_deeply \@args, [qw(--foo=bar verify -x)],
+ qq{Should have left subsequent valid command after "$cmd" in args};
+ ($pod, @vent) = ();
+ }
+}
+
+##############################################################################
+# Test _parse_core_opts
+can_ok $CLASS, '_parse_core_opts';
+
+is_deeply $CLASS->_parse_core_opts([]), {},
+ 'Should have default config for no options';
+
+# Make sure we can get help.
+HELP: {
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(_pod2usage => sub { @args = @_} );
+ ok $CLASS->_parse_core_opts(['--help']), 'Ask for help';
+ is_deeply \@args, [ $CLASS, 'sqitchcommands', '-exitval', 0, '-verbose', 2 ],
+ 'Should have been helped';
+ ok $CLASS->_parse_core_opts(['--man']), 'Ask for man';
+ is_deeply \@args, [ $CLASS, 'sqitch', '-exitval', 0, '-verbose', 2 ],
+ 'Should have been manned';
+}
+
+# Silence warnings.
+my $mock = Test::MockModule->new($CLASS);
+$mock->mock(warn => undef);
+
+##############################################################################
+# Try lots of options.
+my $opts = $CLASS->_parse_core_opts([
+ '--verbose', '--verbose',
+ '--no-pager',
+]);
+
+is_deeply $opts, {
+ verbosity => 2,
+ no_pager => 1,
+}, 'Should parse lots of options';
+
+# Make sure --quiet trumps --verbose.
+is_deeply $CLASS->_parse_core_opts([
+ '--verbose', '--verbose', '--quiet'
+]), { verbosity => 0 }, '--quiet should trump verbosity.';
+
+##############################################################################
+# Try short options.
+is_deeply $CLASS->_parse_core_opts([
+ '-VVV',
+]), {
+ verbosity => 3,
+}, 'Short options should work';
+
+USAGE: {
+ my $mock = Test::MockModule->new('Pod::Usage');
+ my %args;
+ $mock->mock(pod2usage => sub { %args = @_} );
+ ok $CLASS->_pod2usage('sqitch-add', foo => 'bar'), 'Run _pod2usage';
+ is_deeply \%args, {
+ '-sections' => '(?i:(Usage|Synopsis|Options))',
+ '-verbose' => 2,
+ '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch-add'),
+ '-exitval' => 2,
+ 'foo' => 'bar',
+ }, 'Proper args should have been passed to Pod::Usage';
+}
+
+# Test --chdir.
+$catch_chdir = 1;
+ok $opts = $CLASS->_parse_core_opts(['--chdir', 'foo/bar']),
+ 'Parse --chdir';
+is $chdir_to, 'foo/bar', 'Should have changed to foo/bar';
+is_deeply $opts, {}, 'Should have preserved no opts';
+
+ok $opts = $CLASS->_parse_core_opts(['--cd', 'go/dir']), 'Parse --cd';
+is $chdir_to, 'go/dir', 'Should have changed to go/dir';
+is_deeply $opts, {}, 'Should have preserved no opts';
+
+ok $opts = $CLASS->_parse_core_opts(['-C', 'hi crampus']), 'Parse -C';
+is $chdir_to, 'hi crampus', 'Should have changed to hi cramus';
+is_deeply $opts, {}, 'Should have preserved no opts';
+
+# Make sure it fails properly.
+CHDIE: {
+ local $! = 9;
+ $chdir_fail = 1;
+ my $exp_err = do { chdir 'nonesuch'; $! };
+ throws_ok { $CLASS->_parse_core_opts(['-C', 'nonesuch']) }
+ 'App::Sqitch::X', 'Should get error when chdir fails';
+ is $@->ident, 'fs', 'Error ident should be "fs"';
+ is $@->message, __x(
+ 'Cannot change to directory {directory}: {error}',
+ directory => 'nonesuch',
+ error => $exp_err,
+ ), 'Error message should be correct';
+}
+
+done_testing;
diff --git a/t/oracle.t b/t/oracle.t
new file mode 100644
index 00000000..658d7266
--- /dev/null
+++ b/t/oracle.t
@@ -0,0 +1,571 @@
+#!/usr/bin/perl -w
+
+# Environment variables required to test:
+#
+# * ORAUSER
+# * ORAPASS
+# * TWO_TASK
+#
+# Tests can be run against the Developer Days VM with a bit of configuration.
+# Download the VM from:
+#
+# https://www.oracle.com/technetwork/database/enterprise-edition/databaseappdev-vm-161299.html
+#
+# Once the VM is imported into VirtualBox and started, login with the username
+# "oracle" and the password "oracle". Then, in VirtualBox, go to Settings ->
+# Network, select the NAT adapter, and add two port forwarding rules
+# (https://barrymcgillin.blogspot.com/2011/12/using-oracle-developer-days-virtualbox.html):
+#
+# Host Port | Guest Port
+# -----------+------------
+# 1521 | 1521
+# 2222 | 22
+#
+# Then restart the VM. You should then be able to connect from your host with:
+#
+# sqlplus sys/oracle@localhost/ORCL as sysdba
+#
+# If this fails with either of these errors:
+#
+# ORA-01017: invalid username/password; logon denied
+# ORA-21561: OID generation failed
+#
+# Make sure that your computer's hostname is on the localhost line of
+# /etc/hosts (https://sourceforge.net/p/tora/discussion/52737/thread/f68b89ad/):
+#
+# > hostname
+# stickywicket
+# > grep 127 /etc/hosts
+# 127.0.0.1 localhost stickywicket
+#
+# Once connected, execute this SQL to create the user and give it access:
+#
+# CREATE USER sqitchtest IDENTIFIED BY oracle;
+# GRANT ALL PRIVILEGES TO sqitchtest;
+#
+# Now the tests can be run with:
+#
+# ORAUSER=sqitchtest ORAPASS=oracle TWO_TASK=localhost/ORCL prove -lv t/oracle.t
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More 0.94;
+use Test::MockModule;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use Capture::Tiny 0.12 qw(:all);
+use Try::Tiny;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::oracle';
+ require_ok $CLASS or die;
+ delete $ENV{ORACLE_HOME};
+}
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $config = TestConfig->new('core.engine' => 'oracle');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+isa_ok my $ora = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+
+is $ora->key, 'oracle', 'Key should be "oracle"';
+is $ora->name, 'Oracle', 'Name should be "Oracle"';
+
+my $client = 'sqlplus' . (App::Sqitch::ISWIN ? '.exe' : '');
+is $ora->client, $client, 'client should default to sqlplus';
+ORACLE_HOME: {
+ local $ENV{ORACLE_HOME} = '/foo/bar';
+ my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+ isa_ok my $ora = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+ is $ora->client, Path::Class::file('/foo/bar', $client)->stringify,
+ 'client should use $ORACLE_HOME';
+}
+
+is $ora->registry, '', 'registry default should be empty';
+is $ora->uri, 'db:oracle:', 'Default URI should be "db:oracle"';
+
+my $dest_uri = $ora->uri->clone;
+$dest_uri->dbname(
+ $ENV{TWO_TASK}
+ || (App::Sqitch::ISWIN ? $ENV{LOCAL} : undef)
+ || $ENV{ORACLE_SID}
+);
+is $ora->target->name, $ora->uri, 'Target name should be the uri stringified';
+is $ora->destination, $dest_uri->as_string,
+ 'Destination should fall back on environment variables';
+is $ora->registry_destination, $ora->destination,
+ 'Registry target should be the same as target';
+
+my @std_opts = qw(-S -L /nolog);
+is_deeply [$ora->sqlplus], [$client, @std_opts],
+ 'sqlplus command should connect to /nolog';
+
+is $ora->_script, join( "\n" => (
+ 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT SQL.SQLCODE;',
+ 'connect ',
+ $ora->_registry_variable,
+) ), '_script should work';
+
+# Set up a target URI.
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:oracle://fred:derf@/blah')
+);
+isa_ok $ora = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+
+is $ora->_script, join( "\n" => (
+ 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT SQL.SQLCODE;',
+ 'connect fred/"derf"@"blah"',
+ $ora->_registry_variable,
+) ), '_script should assemble connection string';
+
+# Add a host name.
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:oracle://fred:derf@there/blah')
+);
+isa_ok $ora = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+
+is $ora->_script('@foo'), join( "\n" => (
+ 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT SQL.SQLCODE;',
+ 'connect fred/"derf"@//there/"blah"',
+ $ora->_registry_variable,
+ '@foo',
+) ), '_script should assemble connection string with host';
+
+# Add a port and varibles.
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new(
+ 'db:oracle://fred:derf%20%22derf%22@there:1345/blah%20%22blah%22'
+ ),
+);
+isa_ok $ora = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+ok $ora->set_variables(foo => 'baz', whu => 'hi there', yo => q{"stellar"}),
+ 'Set some variables';
+
+is $ora->_script, join( "\n" => (
+ 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT SQL.SQLCODE;',
+ 'DEFINE foo="baz"',
+ 'DEFINE whu="hi there"',
+ 'DEFINE yo="""stellar"""',
+ 'connect fred/"derf ""derf"""@//there:1345/"blah ""blah"""',
+ $ora->_registry_variable,
+) ), '_script should assemble connection string with host, port, and vars';
+
+# Try a URI with nothing but the database name.
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:oracle:secure_user_tns.tpg'),
+);
+is $target->uri->dbi_dsn, 'dbi:Oracle:secure_user_tns.tpg',
+ 'Database-only URI should produce proper DSN';
+isa_ok $ora = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+is $ora->_script('@foo'), join( "\n" => (
+ 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT SQL.SQLCODE;',
+ 'connect /@"secure_user_tns.tpg"',
+ $ora->_registry_variable,
+ '@foo',
+) ), '_script should assemble connection string with just dbname';
+
+# Try a URI with double slash, but otherwise just the db name.
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:oracle://:@/wallet_tns_name'),
+);
+is $target->uri->dbi_dsn, 'dbi:Oracle:wallet_tns_name',
+ 'Database and double-slash URI should produce proper DSN';
+isa_ok $ora = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+is $ora->_script('@foo'), join( "\n" => (
+ 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF',
+ 'WHENEVER OSERROR EXIT 9;',
+ 'WHENEVER SQLERROR EXIT SQL.SQLCODE;',
+ 'connect /@"wallet_tns_name"',
+ $ora->_registry_variable,
+ '@foo',
+) ), '_script should assemble connection string with double-slash and dbname';
+
+
+##############################################################################
+# Test other configs for the destination.
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+ENV: {
+ # Make sure we override system-set vars.
+ local $ENV{TWO_TASK};
+ local $ENV{ORACLE_SID};
+ for my $env (qw(TWO_TASK ORACLE_SID)) {
+ my $ora = $CLASS->new(sqitch => $sqitch, target => $target);
+ local $ENV{$env} = '$ENV=whatever';
+ is $ora->target->name, "db:oracle:", "Target name should not read \$$env";
+ is $ora->destination, "db:oracle:\$ENV=whatever", "Destination should read \$$env";
+ is $ora->registry_destination, $ora->destination,
+ 'Registry destination should be the same as destination';
+ }
+
+ $ENV{TWO_TASK} = 'mydb';
+ $ora = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target);
+ is $ora->target->name, 'db:oracle:', 'Target should be the default';
+ is $ora->destination, 'db:oracle:mydb',
+ 'Destination should prefer $TWO_TASK to username';
+ is $ora->registry_destination, $ora->destination,
+ 'Registry destination should be the same as destination';
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.oracle.client' => '/path/to/sqlplus',
+ 'engine.oracle.target' => 'db:oracle://bob:hi@db.net:12/howdy',
+ 'engine.oracle.registry' => 'meta',
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+ok $ora = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another ora';
+
+is $ora->client, '/path/to/sqlplus', 'client should be as configured';
+is $ora->uri->as_string, 'db:oracle://bob:hi@db.net:12/howdy',
+ 'DB URI should be as configured';
+like $ora->target->name, qr{^db:oracle://bob:?\@db\.net:12/howdy$},
+ 'Target name should be the passwordless URI stringified';
+like $ora->destination, qr{^db:oracle://bob:?\@db\.net:12/howdy$},
+ 'Destination should be the URI without the password';
+is $ora->registry_destination, $ora->destination,
+ 'registry_destination should replace be the same URI';
+is $ora->registry, 'meta', 'registry should be as configured';
+is_deeply [$ora->sqlplus], ['/path/to/sqlplus', @std_opts],
+ 'sqlplus command should be configured';
+
+$config->update(
+ 'engine.oracle.client' => '/path/to/sqlplus',
+ 'engine.oracle.registry' => 'meta',
+);
+
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+ok $ora = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create yet another ora';
+is $ora->client, '/path/to/sqlplus', 'client should be as configured';
+is $ora->registry, 'meta', 'registry should be as configured';
+is_deeply [$ora->sqlplus], ['/path/to/sqlplus', @std_opts],
+ 'sqlplus command should be configured';
+
+##############################################################################
+# Test _run() and _capture().
+can_ok $ora, qw(_run _capture);
+my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+my (@capture, @spool);
+$mock_sqitch->mock(spool => sub { shift; @spool = @_ });
+my $mock_run3 = Test::MockModule->new('IPC::Run3');
+$mock_run3->mock(run3 => sub { @capture = @_ });
+
+ok $ora->_run(qw(foo bar baz)), 'Call _run';
+my $fh = shift @spool;
+is_deeply \@spool, [$ora->sqlplus],
+ 'SQLPlus command should be passed to spool()';
+
+is join('', <$fh> ), $ora->_script(qw(foo bar baz)),
+ 'The script should be spooled';
+
+ok $ora->_capture(qw(foo bar baz)), 'Call _capture';
+is_deeply \@capture, [
+ [$ora->sqlplus], \$ora->_script(qw(foo bar baz)), [], [],
+ { return_if_system_error => 1 },
+], 'Command and script should be passed to run3()';
+
+# Let's make sure that IPC::Run3 actually works as expected.
+$mock_run3->unmock_all;
+my $echo = Path::Class::file(qw(t echo.pl));
+my $mock_ora = Test::MockModule->new($CLASS);
+$mock_ora->mock(sqlplus => sub { $^X, $echo, qw(hi there) });
+
+is join (', ' => $ora->_capture(qw(foo bar baz))), "hi there\n",
+ '_capture should actually capture';
+
+# Make it die.
+my $die = Path::Class::file(qw(t die.pl));
+$mock_ora->mock(sqlplus => sub { $^X, $die, qw(hi there) });
+like capture_stderr {
+ throws_ok {
+ $ora->_capture('whatever'),
+ } 'App::Sqitch::X', '_capture should die when sqlplus dies';
+}, qr/^OMGWTF/, 'STDERR should be emitted by _capture';
+
+##############################################################################
+# Test _file_for_script().
+can_ok $ora, '_file_for_script';
+is $ora->_file_for_script(Path::Class::file 'foo'), 'foo',
+ 'File without special characters should be used directly';
+is $ora->_file_for_script(Path::Class::file '"foo"'), '""foo""',
+ 'Double quotes should be SQL-escaped';
+
+# Get the temp dir used by the engine.
+ok my $tmpdir = $ora->tmpdir, 'Get temp dir';
+isa_ok $tmpdir, 'Path::Class::Dir', 'Temp dir';
+
+# Make sure a file with @ is aliased.
+my $file = $tmpdir->file('foo@bar.sql');
+$file->touch; # File must exist, because on Windows it gets copied.
+is $ora->_file_for_script($file), $tmpdir->file('foo_bar.sql'),
+ 'File with special char should be aliased';
+
+# Make sure double-quotes are escaped.
+WIN32: {
+ $file = $tmpdir->file('"foo$bar".sql');
+ my $mock_file = Test::MockModule->new(ref $file);
+ # Windows doesn't like the quotation marks, so prevent it from writing.
+ $mock_file->mock(copy_to => 1) if App::Sqitch::ISWIN;
+ is $ora->_file_for_script($file), $tmpdir->file('""foo_bar"".sql'),
+ 'File with special char and quotes should be aliased';
+}
+
+##############################################################################
+# Test file and handle running.
+my @run;
+$mock_ora->mock(_run => sub {shift; @run = @_ });
+ok $ora->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+is_deeply \@run, ['@"foo/bar.sql"'],
+ 'File should be passed to run()';
+
+ok $ora->run_file('foo/"bar".sql'), 'Run foo/"bar".sql';
+is_deeply \@run, ['@"foo/""bar"".sql"'],
+ 'Double quotes in file passed to run() should be escaped';
+
+ok $ora->run_handle('FH'), 'Spool a "file handle"';
+my $handles = shift @spool;
+is_deeply \@spool, [$ora->sqlplus],
+ 'sqlplus command should be passed to spool()';
+isa_ok $handles, 'ARRAY', 'Array ove handles should be passed to spool';
+$fh = $handles->[0];
+is join('', <$fh>), $ora->_script, 'First file handle should be script';
+is $handles->[1], 'FH', 'Second should be the passed handle';
+
+# Verify should go to capture unless verosity is > 1.
+$mock_ora->mock(_capture => sub {shift; @capture = @_ });
+ok $ora->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+is_deeply \@capture, ['@"foo/bar.sql"'],
+ 'Verify file should be passed to capture()';
+
+$mock_sqitch->mock(verbosity => 2);
+ok $ora->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+is_deeply \@run, ['@"foo/bar.sql"'],
+ 'Verifile file should be passed to run() for high verbosity';
+
+$mock_sqitch->unmock_all;
+$mock_ora->unmock_all;
+
+##############################################################################
+# Test DateTime formatting stuff.
+ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')";
+is sprintf($ts2char->(), 'foo'), join( ' || ',
+ q{to_char(foo AT TIME ZONE 'UTC', '"year":YYYY')},
+ q{to_char(foo AT TIME ZONE 'UTC', ':"month":MM')},
+ q{to_char(foo AT TIME ZONE 'UTC', ':"day":DD')},
+ q{to_char(foo AT TIME ZONE 'UTC', ':"hour":HH24')},
+ q{to_char(foo AT TIME ZONE 'UTC', ':"minute":MI')},
+ q{to_char(foo AT TIME ZONE 'UTC', ':"second":SS')},
+ q{':time_zone:UTC'},
+), '_ts2char_format should work';
+
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+is $CLASS->_char2ts($dt),
+ join(' ', $dt->ymd('-'), $dt->hms(':'), $dt->time_zone->name),
+ 'Should have _char2ts';
+
+##############################################################################
+# Test SQL helpers.
+is $ora->_listagg_format, q{CAST(COLLECT(CAST(%s AS VARCHAR2(512))) AS sqitch_array)},
+ 'Should have _listagg_format';
+is $ora->_regex_op, 'REGEXP_LIKE(%s, ?)', 'Should have _regex_op';
+is $ora->_simple_from, ' FROM dual', 'Should have _simple_from';
+is $ora->_limit_default, undef, 'Should have _limit_default';
+is $ora->_ts_default, 'current_timestamp', 'Should have _ts_default';
+is $ora->_can_limit, 0, 'Should have _can_limit false';
+
+is $ora->_multi_values(1, 'FOO'), 'SELECT FOO FROM dual',
+ 'Should get single expression from _multi_values';
+is $ora->_multi_values(2, 'LOWER(?)'),
+ "SELECT LOWER(?) FROM dual\nUNION ALL SELECT LOWER(?) FROM dual",
+ 'Should get double expression from _multi_values';
+is $ora->_multi_values(4, 'X'),
+ "SELECT X FROM dual\nUNION ALL SELECT X FROM dual\nUNION ALL SELECT X FROM dual\nUNION ALL SELECT X FROM dual",
+ 'Should get quadrupal expression from _multi_values';
+
+DBI: {
+ local *DBI::err;
+ ok !$ora->_no_table_error, 'Should have no table error';
+ ok !$ora->_no_column_error, 'Should have no column error';
+
+ $DBI::err = 942;
+ ok $ora->_no_table_error, 'Should now have table error';
+ ok !$ora->_no_column_error, 'Still should have no column error';
+
+ $DBI::err = 904;
+ ok !$ora->_no_table_error, 'Should again have no table error';
+ ok $ora->_no_column_error, 'Should now have no column error';
+}
+
+# Test _log_tags_param.
+my $plan = App::Sqitch::Plan->new(
+ sqitch => $sqitch,
+ target => $target,
+ 'project' => 'oracle',
+);
+my $change = App::Sqitch::Plan::Change->new(
+ name => 'oracle_test',
+ plan => $plan,
+);
+my @tags = map {
+ App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ name => $_,
+ change => $change,
+ )
+} qw(xxx yyy zzz);
+$change->add_tag($_) for @tags;
+is_deeply $ora->_log_tags_param($change), [qw(@xxx @yyy @zzz)],
+ '_log_tags_param should format tags';
+
+# Test _log_requires_param.
+my @req = map {
+ App::Sqitch::Plan::Depend->new(
+ %{ App::Sqitch::Plan::Depend->parse($_) },
+ plan => $plan,
+ )
+} qw(aaa bbb ccc);
+
+my $mock_change = Test::MockModule->new(ref $change);
+$mock_change->mock(requires => sub { @req });
+is_deeply $ora->_log_requires_param($change), [qw(aaa bbb ccc)],
+ '_log_requires_param should format prereqs';
+
+# Test _log_conflicts_param.
+$mock_change->mock(conflicts => sub { @req });
+is_deeply $ora->_log_conflicts_param($change), [qw(aaa bbb ccc)],
+ '_log_conflicts_param should format prereqs';
+
+$mock_change->unmock_all;
+
+##############################################################################
+# Can we do live tests?
+if (App::Sqitch::ISWIN && eval { require Win32::API}) {
+ # Call kernel32.SetErrorMode(SEM_FAILCRITICALERRORS):
+ # "The system does not display the critical-error-handler message box.
+ # Instead, the system sends the error to the calling process." and
+ # "A child process inherits the error mode of its parent process."
+ my $SetErrorMode = Win32::API->new('kernel32', 'SetErrorMode', 'I', 'I');
+ my $SEM_FAILCRITICALERRORS = 0x0001;
+ $SetErrorMode->Call($SEM_FAILCRITICALERRORS);
+}
+my $dbh;
+END {
+ return unless $dbh;
+ $dbh->{Driver}->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh;
+ });
+
+ $dbh->{RaiseError} = 0;
+ $dbh->{PrintError} = 1;
+ $dbh->do($_) for (
+ 'DROP TABLE events',
+ 'DROP TABLE dependencies',
+ 'DROP TABLE tags',
+ 'DROP TABLE changes',
+ 'DROP TABLE projects',
+ 'DROP TABLE releases',
+ 'DROP TYPE sqitch_array',
+ 'DROP TABLE oe.events',
+ 'DROP TABLE oe.dependencies',
+ 'DROP TABLE oe.tags',
+ 'DROP TABLE oe.changes',
+ 'DROP TABLE oe.projects',
+ 'DROP TABLE oe.releases',
+ 'DROP TYPE oe.sqitch_array',
+ );
+}
+
+my $user = $ENV{ORAUSER} || 'scott';
+my $pass = $ENV{ORAPASS} || 'tiger';
+my $err = try {
+ $ora->use_driver;
+ my $dsn = 'dbi:Oracle:';
+ $dbh = DBI->connect($dsn, $user, $pass, {
+ PrintError => 0,
+ RaiseError => 1,
+ AutoCommit => 1,
+ });
+ undef;
+} catch {
+ eval { $_->message } || $_;
+};
+
+my $uri = URI->new('db:oracle:');
+$uri->user($user);
+$uri->password($pass);
+# $uri->dbname( $ENV{TWO_TASK} || $ENV{LOCAL} || $ENV{ORACLE_SID} );
+DBIEngineTest->run(
+ class => $CLASS,
+ version_query => q{SELECT * FROM v$version WHERE banner LIKE 'Oracle%'},
+ target_params => [ uri => $uri ],
+ alt_target_params => [ uri => $uri, registry => 'oe' ],
+ skip_unless => sub {
+ my $self = shift;
+ die $err if $err;
+ # Make sure we have sqlplus and can connect to the database.
+ $self->sqitch->probe( $self->client, '-v' );
+ $self->_capture('SELECT 1 FROM dual;');
+ },
+ engine_err_regex => qr/^ORA-00925: /,
+ init_error => __ 'Sqitch already initialized',
+ add_second_format => q{%s + interval '1' second},
+);
+
+done_testing;
diff --git a/t/pg.t b/t/pg.t
new file mode 100644
index 00000000..e2dc8a92
--- /dev/null
+++ b/t/pg.t
@@ -0,0 +1,322 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More 0.94;
+use Test::MockModule;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use Capture::Tiny 0.12 qw(:all);
+use Try::Tiny;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::pg';
+ require_ok $CLASS or die;
+ delete $ENV{PGPASSWORD};
+}
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $uri = URI::db->new('db:pg:');
+my $config = TestConfig->new('core.engine' => 'pg');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => $uri,
+);
+isa_ok my $pg = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+
+is $pg->key, 'pg', 'Key should be "pg"';
+is $pg->name, 'PostgreSQL', 'Name should be "PostgreSQL"';
+
+my $client = 'psql' . (App::Sqitch::ISWIN ? '.exe' : '');
+is $pg->client, $client, 'client should default to psqle';
+is $pg->registry, 'sqitch', 'registry default should be "sqitch"';
+is $pg->uri, $uri, 'DB URI should be "db:pg:"';
+my $dest_uri = $uri->clone;
+$dest_uri->dbname($ENV{PGDATABASE} || $ENV{PGUSER} || $sqitch->sysuser);
+is $pg->destination, $dest_uri->as_string,
+ 'Destination should fall back on environment variables';
+is $pg->registry_destination, $pg->destination,
+ 'Registry destination should be the same as destination';
+
+my @std_opts = (
+ '--quiet',
+ '--no-psqlrc',
+ '--no-align',
+ '--tuples-only',
+ '--set' => 'ON_ERROR_STOP=1',
+ '--set' => 'registry=sqitch',
+);
+my $sysuser = $sqitch->sysuser;
+is_deeply [$pg->psql], [$client, @std_opts],
+ 'psql command should be conninfo, and std opts-only';
+
+isa_ok $pg = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+ok $pg->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'),
+ 'Set some variables';
+is_deeply [$pg->psql], [
+ $client,
+ '--set' => 'foo=baz',
+ '--set' => 'whu=hi there',
+ '--set' => 'yo=stellar',
+ @std_opts,
+], 'Variables should be passed to psql via --set';
+
+##############################################################################
+# Test other configs for the target.
+ENV: {
+ # Make sure we override system-set vars.
+ local $ENV{PGDATABASE};
+ for my $env (qw(PGDATABASE PGUSER PGPASSWORD)) {
+ my $pg = $CLASS->new(sqitch => $sqitch, target => $target);
+ local $ENV{$env} = "\$ENV=whatever";
+ is $pg->target->uri, "db:pg:", "Target should not read \$$env";
+ is $pg->registry_destination, $pg->destination,
+ 'Registry target should be the same as destination';
+ }
+
+ my $mocker = Test::MockModule->new('App::Sqitch');
+ $mocker->mock(sysuser => 'sysuser=whatever');
+ my $pg = $CLASS->new(sqitch => $sqitch, target => $target);
+ is $pg->target->uri, 'db:pg:', 'Target should not fall back on sysuser';
+ is $pg->registry_destination, $pg->destination,
+ 'Registry target should be the same as destination';
+
+ $ENV{PGDATABASE} = 'mydb';
+ $pg = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target);
+ is $pg->target->uri, 'db:pg:', 'Target should be the default';
+ is $pg->registry_destination, $pg->destination,
+ 'Registry target should be the same as destination';
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.pg.client' => '/path/to/psql',
+ 'engine.pg.target' => 'db:pg://localhost/try?sslmode=disable&connect_timeout=5',
+ 'engine.pg.registry' => 'meta',
+);
+$std_opts[-1] = 'registry=meta';
+
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $pg = $CLASS->new(sqitch => $sqitch, target => $target), 'Create another pg';
+is $pg->client, '/path/to/psql', 'client should be as configured';
+is $pg->uri->as_string, 'db:pg://localhost/try?sslmode=disable&connect_timeout=5',
+ 'uri should be as configured';
+is $pg->registry, 'meta', 'registry should be as configured';
+is_deeply [$pg->psql], [
+ '/path/to/psql',
+ '--dbname',
+ "dbname=try host=localhost connect_timeout=5 sslmode=disable",
+@std_opts], 'psql command should be configured from URI config';
+
+##############################################################################
+# Test _run(), _capture(), and _spool().
+can_ok $pg, qw(_run _capture _spool);
+my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+my (@run, $exp_pass);
+$mock_sqitch->mock(run => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @run = @_;
+ if (defined $exp_pass) {
+ is $ENV{PGPASSWORD}, $exp_pass, qq{PGPASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{PGPASSWORD}, 'PGPASSWORD should not exist';
+ }
+});
+
+my @capture;
+$mock_sqitch->mock(capture => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @capture = @_;
+ if (defined $exp_pass) {
+ is $ENV{PGPASSWORD}, $exp_pass, qq{PGPASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{PGPASSWORD}, 'PGPASSWORD should not exist';
+ }
+});
+
+my @spool;
+$mock_sqitch->mock(spool => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @spool = @_;
+ if (defined $exp_pass) {
+ is $ENV{PGPASSWORD}, $exp_pass, qq{PGPASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{PGPASSWORD}, 'PGPASSWORD should not exist';
+ }
+});
+
+$target->uri->password('s3cr3t');
+$exp_pass = 's3cr3t';
+ok $pg->_run(qw(foo bar baz)), 'Call _run';
+is_deeply \@run, [$pg->psql, qw(foo bar baz)],
+ 'Command should be passed to run()';
+
+ok $pg->_spool('FH'), 'Call _spool';
+is_deeply \@spool, ['FH', $pg->psql],
+ 'Command should be passed to spool()';
+
+ok $pg->_capture(qw(foo bar baz)), 'Call _capture';
+is_deeply \@capture, [$pg->psql, qw(foo bar baz)],
+ 'Command should be passed to capture()';
+
+# Without password.
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $pg = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a pg with sqitch with no pw';
+$exp_pass = undef;
+ok $pg->_run(qw(foo bar baz)), 'Call _run again';
+is_deeply \@run, [$pg->psql, qw(foo bar baz)],
+ 'Command should be passed to run() again';
+
+ok $pg->_spool('FH'), 'Call _spool again';
+is_deeply \@spool, ['FH', $pg->psql],
+ 'Command should be passed to spool() again';
+
+ok $pg->_capture(qw(foo bar baz)), 'Call _capture again';
+is_deeply \@capture, [$pg->psql, qw(foo bar baz)],
+ 'Command should be passed to capture() again';
+
+##############################################################################
+# Test file and handle running.
+ok $pg->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+is_deeply \@run, [$pg->psql, '--file', 'foo/bar.sql'],
+ 'File should be passed to run()';
+
+ok $pg->run_handle('FH'), 'Spool a "file handle"';
+is_deeply \@spool, ['FH', $pg->psql],
+ 'Handle should be passed to spool()';
+
+# Verify should go to capture unless verosity is > 1.
+ok $pg->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+is_deeply \@capture, [$pg->psql, '--file', 'foo/bar.sql'],
+ 'Verify file should be passed to capture()';
+
+$mock_sqitch->mock(verbosity => 2);
+ok $pg->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+is_deeply \@run, [$pg->psql, '--file', 'foo/bar.sql'],
+ 'Verifile file should be passed to run() for high verbosity';
+
+$mock_sqitch->unmock_all;
+
+##############################################################################
+# Test DateTime formatting stuff.
+ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')";
+is sprintf($ts2char->(), 'foo'),
+ q{to_char(foo AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')},
+ '_ts2char_format should work';
+
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+
+##############################################################################
+# Test _psql_major_version.
+for my $spec (
+ ['11beta3', 11],
+ ['11.3', 11],
+ ['10', 10],
+ ['9.6.3', 9],
+ ['8.4.2', 8],
+ ['9.0.19', 9],
+) {
+ $mock_sqitch->mock(probe => "psql (PostgreSQL) $spec->[0]");
+ is $pg->_psql_major_version, $spec->[1],
+ "Should find major version $spec->[1] in $spec->[0]";
+}
+$mock_sqitch->unmock('probe');
+
+##############################################################################
+# Can we do live tests?
+$config->replace('core.engine' => 'pg');
+$sqitch = App::Sqitch->new(config => $config);
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+$pg = $CLASS->new(sqitch => $sqitch, target => $target);
+my $dbh;
+my $db = '__sqitchtest__' . $$;
+END {
+ return unless $dbh;
+ $dbh->{Driver}->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh;
+ });
+
+ $dbh->do("DROP DATABASE $db") if $dbh->{Active};
+}
+
+my $pguser = $ENV{PGUSER} || 'postgres';
+
+my $err = try {
+ $pg->_capture('--version');
+ $pg->use_driver;
+ $dbh = DBI->connect('dbi:Pg:dbname=template1', $pguser, '', {
+ PrintError => 0,
+ RaiseError => 1,
+ AutoCommit => 1,
+ });
+ $dbh->do($_) for (
+ "CREATE DATABASE $db",
+ "ALTER DATABASE $db SET lc_messages = 'C'",
+ );
+ undef;
+} catch {
+ eval { $_->message } || $_;
+};
+
+DBIEngineTest->run(
+ class => $CLASS,
+ version_query => 'SELECT version()',
+ target_params => [
+ uri => URI::db->new("db:pg://$pguser\@/$db"),
+ ],
+ alt_target_params => [
+ registry => '__sqitchtest',
+ uri => URI::db->new("db:pg://$pguser\@/$db"),
+ ],
+ skip_unless => sub {
+ my $self = shift;
+ die $err if $err;
+ # Make sure we have psql and can connect to the database.
+ $self->sqitch->probe( $self->client, '--version' );
+ $self->_capture('--command' => 'SELECT version()');
+ },
+ engine_err_regex => qr/^ERROR: /,
+ init_error => __x(
+ 'Sqitch schema "{schema}" already exists',
+ schema => '__sqitchtest',
+ ),
+ test_dbh => sub {
+ my $dbh = shift;
+ # Make sure the sqitch schema is the first in the search path.
+ is $dbh->selectcol_arrayref('SELECT current_schema')->[0],
+ '__sqitchtest', 'The Sqitch schema should be the current schema';
+ },
+);
+
+done_testing;
diff --git a/t/plan.t b/t/plan.t
new file mode 100644
index 00000000..ed998392
--- /dev/null
+++ b/t/plan.t
@@ -0,0 +1,2034 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Locale::TextDomain qw(App-Sqitch);
+use Path::Class;
+use Test::Exception;
+use Test::File;
+use Test::Deep;
+use Test::File::Contents;
+use Encode;
+#use Test::NoWarnings;
+use File::Path qw(make_path remove_tree);
+use App::Sqitch::DateTime;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Plan';
+ use_ok $CLASS or die;
+}
+
+can_ok $CLASS, qw(
+ sqitch
+ target
+ file
+ changes
+ position
+ load
+ syntax_version
+ project
+ uri
+ _parse
+ check_changes
+ open_script
+);
+
+my $config = TestConfig->new('core.engine' => 'sqlite');
+my $sqitch = App::Sqitch->new( config => $config );
+my $target = App::Sqitch::Target->new( sqitch => $sqitch );
+isa_ok my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target),
+ $CLASS;
+is $plan->file, $target->plan_file, 'File should be coopied from Target';
+
+# Set up some some utility functions for creating changes.
+sub blank {
+ App::Sqitch::Plan::Blank->new(
+ plan => $plan,
+ lspace => $_[0] // '',
+ note => $_[1] // '',
+ );
+}
+
+my $prev_tag;
+my $prev_change;
+my %seen;
+
+sub clear {
+ undef $prev_tag;
+ undef $prev_change;
+ %seen = ();
+ return ();
+}
+
+my $ts = App::Sqitch::DateTime->new(
+ year => 2012,
+ month => 7,
+ day => 16,
+ hour => 17,
+ minute => 25,
+ second => 7,
+ time_zone => 'UTC',
+);
+
+sub ts($) {
+ my $str = shift || return $ts;
+ my @parts = split /[-:T]/ => $str;
+ return App::Sqitch::DateTime->new(
+ year => $parts[0],
+ month => $parts[1],
+ day => $parts[2],
+ hour => $parts[3],
+ minute => $parts[4],
+ second => $parts[5],
+ time_zone => 'UTC',
+ );
+}
+
+my $vivify = 0;
+my $project;
+
+sub dep($) {
+ App::Sqitch::Plan::Depend->new(
+ plan => $plan,
+ (defined $project ? (project => $project) : ()),
+ %{ App::Sqitch::Plan::Depend->parse(shift) },
+ )
+}
+
+sub change($) {
+ my $p = shift;
+ if ( my $op = delete $p->{op} ) {
+ @{ $p }{ qw(lopspace operator ropspace) } = split /([+-])/, $op;
+ $p->{$_} //= '' for qw(lopspace ropspace);
+ }
+
+ $p->{requires} = [ map { dep $_ } @{ $p->{requires} } ]
+ if $p->{requires};
+ $p->{conflicts} = [ map { dep "!$_" } @{ $p->{conflicts} }]
+ if $p->{conflicts};
+
+ $prev_change = App::Sqitch::Plan::Change->new(
+ plan => $plan,
+ timestamp => ts delete $p->{ts},
+ planner_name => 'Barack Obama',
+ planner_email => 'potus@whitehouse.gov',
+ ( $prev_tag ? ( since_tag => $prev_tag ) : () ),
+ ( $prev_change ? ( parent => $prev_change ) : () ),
+ %{ $p },
+ );
+ if (my $duped = $seen{ $p->{name} }) {
+ $duped->add_rework_tags(map { $seen{$_}-> tags } @{ $p->{rtag} });
+ }
+ $seen{ $p->{name} } = $prev_change;
+ if ($vivify) {
+ $prev_change->id;
+ $prev_change->tags;
+ }
+ return $prev_change;
+}
+
+sub tag($) {
+ my $p = shift;
+ my $ret = delete $p->{ret};
+ $prev_tag = App::Sqitch::Plan::Tag->new(
+ plan => $plan,
+ change => $prev_change,
+ timestamp => ts delete $p->{ts},
+ planner_name => 'Barack Obama',
+ planner_email => 'potus@whitehouse.gov',
+ %{ $p },
+ );
+ $prev_change->add_tag($prev_tag);
+ $prev_tag->id, if $vivify;
+ return $ret ? $prev_tag : ();
+}
+
+sub prag {
+ App::Sqitch::Plan::Pragma->new(
+ plan => $plan,
+ lspace => $_[0] // '',
+ hspace => $_[1] // '',
+ name => $_[2],
+ (defined $_[3] ? (lopspace => $_[3]) : ()),
+ (defined $_[4] ? (operator => $_[4]) : ()),
+ (defined $_[5] ? (ropspace => $_[5]) : ()),
+ (defined $_[6] ? (value => $_[6]) : ()),
+ rspace => $_[7] // '',
+ note => $_[8] // '',
+ );
+}
+
+my $mocker = Test::MockModule->new($CLASS);
+# Do no sorting for now.
+my $sorted = 0;
+sub sorted () {
+ my $ret = $sorted;
+ $sorted = 0;
+ return $ret;
+}
+$mocker->mock(check_changes => sub { $sorted++; shift, shift, shift; @_ });
+
+sub version () {
+ prag(
+ '', '', 'syntax-version', '', '=', '', App::Sqitch::Plan::SYNTAX_VERSION
+ );
+}
+
+##############################################################################
+# Test parsing.
+my $file = file qw(t plans widgets.plan);
+my $fh = $file->open('<:utf8_strict');
+ok my $parsed = $plan->_parse($file, $fh),
+ 'Should parse simple "widgets.plan"';
+is sorted, 1, 'Should have sorted changes';
+isa_ok $parsed->{changes}, 'ARRAY', 'changes';
+isa_ok $parsed->{lines}, 'ARRAY', 'lines';
+
+cmp_deeply $parsed->{changes}, [
+ clear,
+ change { name => 'hey', ts => '2012-07-16T14:01:20' },
+ change { name => 'you', ts => '2012-07-16T14:01:35' },
+ tag {
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T14:02:05',
+ rspace => ' '
+ },
+,
+], 'All "widgets.plan" changes should be parsed';
+
+cmp_deeply $parsed->{lines}, [
+ clear,
+ version,
+ prag( '', '', 'project', '', '=', '', 'widgets'),
+ blank('', 'This is a note'),
+ blank(),
+ blank(' ', 'And there was a blank line.'),
+ blank(),
+ change { name => 'hey', ts => '2012-07-16T14:01:20' },
+ change { name => 'you', ts => '2012-07-16T14:01:35' },
+
+ tag {
+ ret => 1,
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T14:02:05',
+ rspace => ' '
+ },
+], 'All "widgets.plan" lines should be parsed';
+
+# Plan with multiple tags.
+$file = file qw(t plans multi.plan);
+$fh = $file->open('<:utf8_strict');
+ok $parsed = $plan->_parse($file, $fh),
+ 'Should parse multi-tagged "multi.plan"';
+is sorted, 2, 'Should have sorted changes twice';
+cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ project => 'multi',
+}, 'Should have captured the multi pragmas';
+cmp_deeply $parsed, {
+ changes => [
+ clear,
+ change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' },
+ change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' },
+ tag {
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T17:24:07',
+ rspace => ' ',
+ planner_name => 'julie',
+ planner_email => 'j@ul.ie',
+ },
+ change { name => 'this/rocks', pspace => ' ' },
+ change { name => 'hey-there', note => 'trailing note!', rspace => ' ' },
+ tag { name =>, 'bar' },
+ tag { name => 'baz' },
+ ],
+ lines => [
+ clear,
+ version,
+ prag( '', '', 'project', '', '=', '', 'multi'),
+ blank('', 'This is a note'),
+ blank(),
+ blank('', 'And there was a blank line.'),
+ blank(),
+ change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' },
+ change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' },
+ tag {
+ ret => 1,
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T17:24:07',
+ rspace => ' ',
+ planner_name => 'julie',
+ planner_email => 'j@ul.ie',
+ },
+ blank(' '),
+ change { name => 'this/rocks', pspace => ' ' },
+ change { name => 'hey-there', note => 'trailing note!', rspace => ' ' },
+ tag { name =>, 'bar', ret => 1 },
+ tag { name => 'baz', ret => 1 },
+ ],
+}, 'Should have "multi.plan" lines and changes';
+
+# Try a plan with changes appearing without a tag.
+$file = file qw(t plans changes-only.plan);
+$fh = $file->open('<:utf8_strict');
+ok $parsed = $plan->_parse($file, $fh), 'Should read plan with no tags';
+is sorted, 1, 'Should have sorted changes';
+cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ project => 'changes_only',
+}, 'Should have captured the changes-only pragmas';
+cmp_deeply $parsed, {
+ lines => [
+ clear,
+ version,
+ prag( '', '', 'project', '', '=', '', 'changes_only'),
+ blank('', 'This is a note'),
+ blank(),
+ blank('', 'And there was a blank line.'),
+ blank(),
+ change { name => 'hey' },
+ change { name => 'you' },
+ change { name => 'whatwhatwhat' },
+ ],
+ changes => [
+ clear,
+ change { name => 'hey' },
+ change { name => 'you' },
+ change { name => 'whatwhatwhat' },
+ ],
+}, 'Should have lines and changes for tagless plan';
+
+# Try plans with DOS line endings.
+$file = file qw(t plans dos.plan);
+$fh = $file->open('<:utf8_strict');
+ok $parsed = $plan->_parse($file, $fh), 'Should read plan with DOS line endings';
+is sorted, 1, 'Should have sorted changes';
+cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ project => 'dos',
+}, 'Should have captured the dos pragmas';
+
+# Try a plan with a bad change name.
+$file = file qw(t plans bad-change.plan);
+$fh = $file->open('<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on plan with bad change name';
+is $@->ident, 'parse', 'Bad change name error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 5,
+ error => __(
+ qq{Invalid name; names must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ ),
+), 'And the bad change name error message should be correct';
+
+is sorted, 0, 'Should not have sorted changes';
+
+my @bad_names = (
+ '^foo', # No leading punctuation
+ 'foo^', # No trailing punctuation
+ 'foo^6', # No trailing punctuation+digit
+ 'foo^666', # No trailing punctuation+digits
+ '%hi', # No leading punctuation
+ 'hi!', # No trailing punctuation
+ 'foo@bar', # No @ allowed at all
+ 'foo:bar', # No : allowed at all
+ '+foo', # No leading +
+ '-foo', # No leading -
+ '@foo', # No leading @
+);
+
+# Try other invalid change and tag name issues.
+my $prags = '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION
+ . "\n%project=test\n\n";
+for my $name (@bad_names) {
+ for my $line ("+$name", "\@$name") {
+ next if $line eq '%hi'; # This would be a pragma.
+ my $buf = $prags . $line;
+ my $what = $line =~ /^[@]/ ? 'tag' : 'change';
+ my $fh = IO::File->new(\$buf, '<:utf8_strict');
+ throws_ok { $plan->_parse('baditem', $fh) } 'App::Sqitch::X',
+ qq{Should die on plan with bad name "$line"};
+ is $@->ident, 'parse', 'Exception ident should be "parse"';
+ is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => 'baditem',
+ lineno => 4,
+ error => __(
+ qq{Invalid name; names must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ )
+ ), qq{And "$line" should trigger the appropriate message};
+ is sorted, 0, 'Should not have sorted changes';
+ }
+}
+
+# Try some valid change and tag names.
+my $tsnp = '2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>';
+my $foo_proj = App::Sqitch::Plan::Pragma->new(
+ plan => $plan,
+ name => 'project',
+ value => 'foo',
+ operator => '=',
+);
+for my $name (
+ 'foo', # alpha
+ '12', # digits
+ 't', # char
+ '6', # digit
+ '阱阪阬', # multibyte
+ 'foo/bar', # middle punct
+ 'beta1', # ending digit
+ 'foo_', # ending underscore
+ '_foo', # leading underscore
+ 'v1.0-1b', # punctuation followed by digit in middle
+ 'v1.2-1', # version number with dash
+ 'v1.2+1', # version number with plus
+ 'v1.2_1', # version number with underscore
+) {
+ # Test a change name.
+ my $lines = encode_utf8 "\%project=foo\n\n$name $tsnp";
+ my $fh = IO::File->new(\$lines, '<:utf8_strict');
+ ok my $parsed = $plan->_parse('ooditem', $fh),
+ encode_utf8(qq{Should parse "$name"});
+ cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ project => 'foo',
+ }, encode_utf8("Should have captured the $name pragmas");
+ cmp_deeply $parsed, {
+ changes => [ clear, change { name => $name } ],
+ lines => [ clear, version, $foo_proj, blank, change { name => $name } ],
+ }, encode_utf8(qq{Should have pragmas in plan with change "$name"});
+
+ # Test a tag name.
+ my $tag = '@' . $name;
+ $lines = encode_utf8 "\%project=foo\n\nfoo $tsnp\n$tag $tsnp";
+ $fh = IO::File->new(\$lines, '<:utf8_strict');
+ ok $parsed = $plan->_parse('gooditem', $fh),
+ encode_utf8(qq{Should parse "$tag"});
+ cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ project => 'foo',
+ }, encode_utf8(qq{Should have pragmas in plan with tag "$name"});
+ cmp_deeply $parsed, {
+ changes => [ clear, change { name => 'foo' }, tag { name => $name } ],
+ lines => [
+ clear,
+ version,
+ $foo_proj,
+ blank,
+ change { name => 'foo' },
+ tag { name => $name, ret => 1 }
+ ],
+ }, encode_utf8(qq{Should have line and change for "$tag"});
+}
+is sorted, 26, 'Should have sorted changes 18 times';
+
+# Try planning with other reserved names.
+for my $reserved (qw(HEAD ROOT)) {
+ my $root = $prags . '@' . $reserved . " $tsnp";
+ $file = file qw(t plans), "$reserved.plan";
+ $fh = IO::File->new(\$root, '<:utf8_strict');
+ throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ qq{Should die on plan with reserved tag "\@$reserved"};
+ is $@->ident, 'parse', qq{\@$reserved exception should have ident "plan"};
+ is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 4,
+ error => __x(
+ '"{name}" is a reserved name',
+ name => '@' . $reserved,
+ ),
+ ), qq{And the \@$reserved error message should be correct};
+ is sorted, 0, "Should have sorted \@$reserved changes nonce";
+}
+
+# Try a plan with a change name that looks like a sha1 hash.
+my $sha1 = '6c2f28d125aff1deea615f8de774599acf39a7a1';
+$file = file qw(t plans sha1.plan);
+$fh = IO::File->new(\"$prags$sha1 $tsnp", '<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on plan with SHA1 change name';
+is $@->ident, 'parse', 'The SHA1 error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 4,
+ error => __x(
+ '"{name}" is invalid because it could be confused with a SHA1 ID',
+ name => $sha1,
+ ),
+), 'And the SHA1 error message should be correct';
+is sorted, 0, 'Should have sorted changes nonce';
+
+# Try a plan with a tag but no change.
+$file = file qw(t plans tag-no-change.plan);
+$fh = IO::File->new(\"$prags\@foo $tsnp\nbar $tsnp", '<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on plan with tag but no preceding change';
+is $@->ident, 'parse', 'The missing change error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 4,
+ error => __x(
+ 'Tag "{tag}" declared without a preceding change',
+ tag => 'foo',
+ ),
+), 'And the missing change error message should be correct';
+is sorted, 0, 'Should have sorted changes nonce';
+
+# Try a plan with a duplicate tag name.
+$file = file qw(t plans dupe-tag.plan);
+$fh = $file->open('<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on plan with dupe tag';
+is $@->ident, 'parse', 'The dupe tag error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 12,
+ error => __x(
+ 'Tag "{tag}" duplicates earlier declaration on line {line}',
+ tag => 'bar',
+ line => 7,
+ ),
+), 'And the missing change error message should be correct';
+is sorted, 2, 'Should have sorted changes twice';
+
+# Try a plan with a duplicate change within a tag section.
+$file = file qw(t plans dupe-change.plan);
+$fh = $file->open('<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on plan with dupe change';
+is $@->ident, 'parse', 'The dupe change error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 9,
+ error => __x(
+ 'Change "{change}" duplicates earlier declaration on line {line}',
+ change => 'greets',
+ line => 7,
+ ),
+), 'And the dupe change error message should be correct';
+is sorted, 1, 'Should have sorted changes once';
+
+# Try a plan with an invalid requirement.
+$fh = IO::File->new(\"\%project=foo\n\nfoo [^bar] $tsnp", '<:utf8_strict');
+throws_ok { $plan->_parse('badreq', $fh ) } 'App::Sqitch::X',
+ 'Should die on invalid dependency';
+is $@->ident, 'parse', 'The invalid dependency error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => 'badreq',
+ lineno => 3,
+ error => __x(
+ '"{dep}" is not a valid dependency specification',
+ dep => '^bar',
+ ),
+), 'And the invalid dependency error message should be correct';
+is sorted, 0, 'Should have sorted changes nonce';
+
+# Try a plan with duplicate requirements.
+$fh = IO::File->new(\"\%project=foo\n\nfoo [bar baz bar] $tsnp", '<:utf8_strict');
+throws_ok { $plan->_parse('dupedep', $fh ) } 'App::Sqitch::X',
+ 'Should die on dupe dependency';
+is $@->ident, 'parse', 'The dupe dependency error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => 'dupedep',
+ lineno => 3,
+ error => __x(
+ 'Duplicate dependency "{dep}"',
+ dep => 'bar',
+ ),
+), 'And the dupe dependency error message should be correct';
+is sorted, 0, 'Should have sorted changes nonce';
+
+# Try a plan without a timestamp.
+$file = file qw(t plans no-timestamp.plan);
+$fh = IO::File->new(\"${prags}foo hi <t\@heo.ry>", '<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on change with no timestamp';
+is $@->ident, 'parse', 'The missing timestamp error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 4,
+ error => __ 'Missing timestamp',
+), 'And the missing timestamp error message should be correct';
+is sorted, 0, 'Should have sorted changes nonce';
+
+# Try a plan without a planner.
+$file = file qw(t plans no-planner.plan);
+$fh = IO::File->new(\"${prags}foo 2012-07-16T23:12:34Z", '<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on change with no planner';
+is $@->ident, 'parse', 'The missing parsener error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 4,
+ error => __ 'Missing planner name and email',
+), 'And the missing planner error message should be correct';
+is sorted, 0, 'Should have sorted changes nonce';
+
+# Try a plan with neither timestamp nor planner.
+$file = file qw(t plans no-timestamp-or-planner.plan);
+$fh = IO::File->new(\"%project=foo\n\nfoo", '<:utf8_strict');
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should die on change with no timestamp or planner';
+is $@->ident, 'parse', 'The missing timestamp or parsener error ident should be "parse"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 3,
+ error => __ 'Missing timestamp and planner name and email',
+), 'And the missing timestamp or planner error message should be correct';
+is sorted, 0, 'Should have sorted changes nonce';
+
+# Try a plan with pragmas.
+$file = file qw(t plans pragmas.plan);
+$fh = $file->open('<:utf8_strict');
+ok $parsed = $plan->_parse($file, $fh),
+ 'Should parse plan with pragmas"';
+is sorted, 1, 'Should have sorted changes once';
+cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ foo => 'bar',
+ project => 'pragmata',
+ uri => 'https://github.com/sqitchers/sqitch/',
+ strict => 1,
+}, 'Should have captured all of the pragmas';
+cmp_deeply $parsed, {
+ changes => [
+ clear,
+ change { name => 'hey' },
+ change { name => 'you' },
+ ],
+ lines => [
+ clear,
+ prag( '', ' ', 'syntax-version', '', '=', '', App::Sqitch::Plan::SYNTAX_VERSION),
+ prag( ' ', '', 'foo', ' ', '=', ' ', 'bar', ' ', 'lolz'),
+ prag( '', ' ', 'project', '', '=', '', 'pragmata'),
+ prag( '', ' ', 'uri', '', '=', '', 'https://github.com/sqitchers/sqitch/'),
+ prag( '', ' ', 'strict'),
+ blank(),
+ change { name => 'hey' },
+ change { name => 'you' },
+ blank(),
+ ],
+}, 'Should have "multi.plan" lines and changes';
+
+# Try a plan with deploy/revert operators.
+$file = file qw(t plans deploy-and-revert.plan);
+$fh = $file->open('<:utf8_strict');
+ok $parsed = $plan->_parse($file, $fh),
+ 'Should parse plan with deploy and revert operators';
+is sorted, 2, 'Should have sorted changes twice';
+cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ project => 'deploy_and_revert',
+}, 'Should have captured the deploy-and-revert pragmas';
+
+cmp_deeply $parsed, {
+ changes => [
+ clear,
+ change { name => 'hey', op => '+' },
+ change { name => 'you', op => '+' },
+ change { name => 'dr_evil', op => '+ ', lspace => ' ' },
+ tag { name => 'foo' },
+ change { name => 'this/rocks', op => '+', pspace => ' ' },
+ change { name => 'hey-there', lspace => ' ' },
+ change {
+ name => 'dr_evil',
+ note => 'revert!',
+ op => '-',
+ rspace => ' ',
+ pspace => ' ',
+ rtag => [qw(dr_evil)],
+ },
+ tag { name => 'bar', lspace => ' ' },
+ ],
+ lines => [
+ clear,
+ version,
+ prag( '', '', 'project', '', '=', '', 'deploy_and_revert'),
+ blank,
+ change { name => 'hey', op => '+' },
+ change { name => 'you', op => '+' },
+ change { name => 'dr_evil', op => '+ ', lspace => ' ' },
+ tag { name => 'foo', ret => 1 },
+ blank( ' '),
+ change { name => 'this/rocks', op => '+', pspace => ' ' },
+ change { name => 'hey-there', lspace => ' ' },
+ change {
+ name => 'dr_evil',
+ note => 'revert!',
+ op => '-',
+ rspace => ' ',
+ pspace => ' ',
+ rtag => [qw(dr_evil)],
+ },
+ tag { name => 'bar', lspace => ' ', ret => 1 },
+ ],
+}, 'Should have "deploy-and-revert.plan" lines and changes';
+
+# Try a non-existent plan file with load().
+$file = file qw(t hi nonexistent.plan);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+throws_ok { App::Sqitch::Plan->new(sqitch => $sqitch, target => $target)->load } 'App::Sqitch::X',
+ 'Should get exception for nonexistent plan file';
+is $@->ident, 'plan', 'Nonexistent plan file ident should be "plan"';
+is $@->message, __x(
+ 'Plan file {file} does not exist',
+ file => $file,
+), 'Nonexistent plan file message should be correct';
+
+# Try a plan with dependencies.
+$file = file qw(t plans dependencies.plan);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), $CLASS,
+ 'Plan with sqitch with plan file with dependencies';
+is $plan->file, $target->plan_file, 'File should be coopied from Sqitch';
+ok $parsed = $plan->load, 'Load plan with dependencies file';
+is_deeply $parsed->{changes}, [
+ clear,
+ change { name => 'roles', op => '+' },
+ change { name => 'users', op => '+', pspace => ' ', requires => ['roles'] },
+ change { name => 'add_user', op => '+', pspace => ' ', requires => [qw(users roles)] },
+ change { name => 'dr_evil', op => '+' },
+ tag { name => 'alpha' },
+ change {
+ name => 'users',
+ op => '+',
+ pspace => ' ',
+ requires => ['users@alpha'],
+ rtag => [qw(dr_evil add_user users)],
+ },
+ change { name => 'dr_evil', op => '-', rtag => [qw(dr_evil)] },
+ change {
+ name => 'del_user',
+ op => '+',
+ pspace => ' ',
+ requires => ['users'],
+ conflicts => ['dr_evil']
+ },
+], 'The changes should include the dependencies';
+is sorted, 2, 'Should have sorted changes twice';
+
+# Try a plan with cross-project dependencies.
+$file = file qw(t plans project_deps.plan);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), $CLASS,
+ 'Plan with sqitch with plan file with project deps';
+is $plan->file, $target->plan_file, 'File should be coopied from Sqitch';
+ok $parsed = $plan->load, 'Load plan with project deps file';
+is_deeply $parsed->{changes}, [
+ clear,
+ change { name => 'roles', op => '+' },
+ change { name => 'users', op => '+', pspace => ' ', requires => ['roles'] },
+ change { name => 'add_user', op => '+', pspace => ' ', requires => [qw(users roles log:logger)] },
+ change { name => 'dr_evil', op => '+' },
+ tag { name => 'alpha' },
+ change {
+ name => 'users',
+ op => '+',
+ pspace => ' ',
+ requires => ['users@alpha'],
+ rtag => [qw(dr_evil add_user users)],
+ },
+ change { name => 'dr_evil', op => '-', rtag => [qw(dr_evil)] },
+
+ change {
+ name => 'del_user',
+ op => '+',
+ pspace => ' ',
+ requires => ['users', 'log:logger@beta1'],
+ conflicts => ['dr_evil']
+ },
+], 'The changes should include the cross-project deps';
+is sorted, 2, 'Should have sorted changes twice';
+
+# Should fail with dependencies on tags.
+$file = file qw(t plans tag_dependencies.plan);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+$fh = IO::File->new(\"%project=tagdep\n\nfoo $tsnp\n\@bar [:foo] $tsnp", '<:utf8_strict');
+isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target),
+ $CLASS, 'Plan with sqitch with plan with tag dependencies';
+is $plan->file, $target->plan_file, 'File should be coopied from Sqitch';
+throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X',
+ 'Should get an exception for tag with dependencies';
+is $@->ident, 'parse', 'The tag dependencies error ident should be "plan"';
+is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => $file,
+ lineno => 4,
+ error => __ 'Tags may not specify dependencies',
+), 'And the tag dependencies error message should be correct';
+
+# Make sure that lines() loads the plan.
+$file = file qw(t plans multi.plan);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), $CLASS,
+ 'Plan with sqitch with plan file';
+is $plan->file, $target->plan_file, 'File should be coopied from Sqitch';
+cmp_deeply [$plan->lines], [
+ clear,
+ version,
+ prag( '', '', 'project', '', '=', '', 'multi'),
+ blank('', 'This is a note'),
+ blank(),
+ blank('', 'And there was a blank line.'),
+ blank(),
+ change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' },
+ change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' },
+ tag {
+ ret => 1,
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T17:24:07',
+ rspace => ' ',
+ planner_name => 'julie',
+ planner_email => 'j@ul.ie',
+ },
+ blank(' '),
+ change { name => 'this/rocks', pspace => ' ' },
+ change { name => 'hey-there', note => 'trailing note!', rspace => ' ' },
+ tag { name =>, 'bar', ret => 1 },
+ tag { name => 'baz', ret => 1 },
+], 'Lines should be parsed from file';
+
+$vivify = 1;
+cmp_deeply [$plan->changes], [
+ clear,
+ change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' },
+ change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' },
+ tag {
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T17:24:07',
+ rspace => ' ',
+ planner_name => 'julie',
+ planner_email => 'j@ul.ie',
+ },
+ change { name => 'this/rocks', pspace => ' ' },
+ change { name => 'hey-there', note => 'trailing note!', rspace => ' ' },
+ tag { name =>, 'bar' },
+ tag { name => 'baz' },
+], 'Changes should be parsed from file';
+
+clear;
+change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' };
+change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' };
+
+my $foo_tag = tag {
+ ret => 1,
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T17:24:07',
+ rspace => ' ',
+ planner_name => 'julie',
+ planner_email => 'j@ul.ie',
+};
+
+change { name => 'this/rocks', pspace => ' ' };
+change { name => 'hey-there', rspace => ' ', note => 'trailing note!' };
+cmp_deeply [$plan->tags], [
+ $foo_tag,
+ tag { name =>, 'bar', ret => 1 },
+ tag { name => 'baz', ret => 1 },
+], 'Should get all tags from tags()';
+is sorted, 2, 'Should have sorted changes twice';
+
+ok $parsed = $plan->load, 'Load should parse plan from file';
+cmp_deeply delete $parsed->{pragmas}, {
+ syntax_version => App::Sqitch::Plan::SYNTAX_VERSION,
+ project => 'multi',
+}, 'Should have captured the multi pragmas';
+$vivify = 0;
+cmp_deeply $parsed, {
+ lines => [
+ clear,
+ version,
+ prag( '', '', 'project', '', '=', '', 'multi'),
+ blank('', 'This is a note'),
+ blank(),
+ blank('', 'And there was a blank line.'),
+ blank(),
+ change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' },
+ change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' },
+ tag {
+ ret => 1,
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T17:24:07',
+ rspace => ' ',
+ planner_name => 'julie',
+ planner_email => 'j@ul.ie',
+ },
+ blank(' '),
+ change { name => 'this/rocks', pspace => ' ' },
+ change { name => 'hey-there', note => 'trailing note!', rspace => ' ' },
+ tag { name =>, 'bar', ret => 1 },
+ tag { name => 'baz', ret => 1 },
+ ],
+ changes => [
+ clear,
+ change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' },
+ change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' },
+ tag {
+ name => 'foo',
+ note => 'look, a tag!',
+ ts => '2012-07-16T17:24:07',
+ rspace => ' ',
+ planner_name => 'julie',
+ planner_email => 'j@ul.ie',
+ },
+ change { name => 'this/rocks', pspace => ' ' },
+ change { name => 'hey-there', note => 'trailing note!', rspace => ' ' },
+ tag { name =>, 'bar' },
+ tag { name => 'baz' },
+ ],
+}, 'And the parsed file should have lines and changes';
+is sorted, 2, 'Should have sorted changes twice';
+
+##############################################################################
+# Test the interator interface.
+can_ok $plan, qw(
+ index_of
+ contains
+ get
+ seek
+ reset
+ next
+ current
+ peek
+ do
+);
+
+is $plan->position, -1, 'Position should start at -1';
+is $plan->current, undef, 'Current should be undef';
+ok my $change = $plan->next, 'Get next change';
+isa_ok $change, 'App::Sqitch::Plan::Change', 'First change';
+is $change->name, 'hey', 'It should be the first change';
+is $plan->position, 0, 'Position should be at 0';
+is $plan->count, 4, 'Count should be 4';
+is $plan->current, $change, 'Current should be current';
+is $plan->change_at(0), $change, 'Should get first change from change_at(0)';
+
+ok my $next = $plan->peek, 'Peek to next change';
+isa_ok $next, 'App::Sqitch::Plan::Change', 'Peeked change';
+is $next->name, 'you', 'Peeked change should be second change';
+is $plan->last->format_name, 'hey-there', 'last() should return last change';
+is $plan->current, $change, 'Current should still be current';
+is $plan->peek, $next, 'Peek should still be next';
+is $plan->next, $next, 'Next should be the second change';
+is $plan->position, 1, 'Position should be at 1';
+is $plan->change_at(1), $next, 'Should get second change from change_at(1)';
+
+ok my $third = $plan->peek, 'Peek should return an object';
+isa_ok $third, 'App::Sqitch::Plan::Change', 'Third change';
+is $third->name, 'this/rocks', 'It should be the foo tag';
+is $plan->current, $next, 'Current should be the second change';
+is $plan->next, $third, 'Should get third change next';
+is $plan->position, 2, 'Position should be at 2';
+is $plan->current, $third, 'Current should be third change';
+is $plan->change_at(2), $third, 'Should get third change from change_at(1)';
+
+ok my $fourth = $plan->next, 'Get fourth change';
+isa_ok $fourth, 'App::Sqitch::Plan::Change', 'Fourth change';
+is $fourth->name, 'hey-there', 'Fourth change should be "hey-there"';
+is $plan->position, 3, 'Position should be at 3';
+
+is $plan->peek, undef, 'Peek should return undef';
+is $plan->next, undef, 'Next should return undef';
+is $plan->position, 4, 'Position should be at 7';
+
+is $plan->next, undef, 'Next should still return undef';
+is $plan->position, 4, 'Position should still be at 7';
+ok $plan->reset, 'Reset the plan';
+
+is $plan->position, -1, 'Position should be back at -1';
+is $plan->current, undef, 'Current should still be undef';
+is $plan->next, $change, 'Next should return the first change again';
+is $plan->position, 0, 'Position should be at 0 again';
+is $plan->current, $change, 'Current should be first change';
+is $plan->index_of($change->name), 0, "Index of change should be 0";
+ok $plan->contains($change->name), 'Plan should contain change';
+is $plan->get($change->name), $change, 'Should be able to get change 0 by name';
+is $plan->find($change->name), $change, 'Should be able to find change 0 by name';
+is $plan->get($change->id), $change, 'Should be able to get change 0 by ID';
+is $plan->find($change->id), $change, 'Should be able to find change 0 by ID';
+is $plan->index_of('@bar'), 3, 'Index of @bar should be 3';
+ok $plan->contains('@bar'), 'Plan should contain @bar';
+is $plan->get('@bar'), $fourth, 'Should be able to get hey-there via @bar';
+is $plan->get($fourth->id), $fourth, 'Should be able to get hey-there via @bar ID';
+is $plan->find('@bar'), $fourth, 'Should be able to find hey-there via @bar';
+is $plan->find($fourth->id), $fourth, 'Should be able to find hey-there via @bar ID';
+ok $plan->seek('@bar'), 'Seek to the "@bar" change';
+is $plan->position, 3, 'Position should be at 3 again';
+is $plan->current, $fourth, 'Current should be fourth again';
+is $plan->index_of('you'), 1, 'Index of you should be 1';
+ok $plan->contains('you'), 'Plan should contain "you"';
+is $plan->get('you'), $next, 'Should be able to get change 1 by name';
+is $plan->find('you'), $next, 'Should be able to find change 1 by name';
+ok $plan->seek('you'), 'Seek to the "you" change';
+is $plan->position, 1, 'Position should be at 1 again';
+is $plan->current, $next, 'Current should be second again';
+is $plan->index_of('baz'), undef, 'Index of baz should be undef';
+ok !$plan->contains('baz'), 'Plan should not contain "baz"';
+is $plan->index_of('@baz'), 3, 'Index of @baz should be 3';
+ok $plan->contains('@baz'), 'Plan should contain @baz';
+ok $plan->seek('@baz'), 'Seek to the "baz" change';
+is $plan->position, 3, 'Position should be at 3 again';
+is $plan->current, $fourth, 'Current should be fourth again';
+
+is $plan->change_at(0), $change, 'Should still get first change from change_at(0)';
+is $plan->change_at(1), $next, 'Should still get second change from change_at(1)';
+is $plan->change_at(2), $third, 'Should still get third change from change_at(1)';
+
+# Make sure seek() chokes on a bad change name.
+throws_ok { $plan->seek('nonesuch') } 'App::Sqitch::X',
+ 'Should die seeking invalid change';
+is $@->ident, 'plan', 'Invalid seek change error ident should be "plan"';
+is $@->message, __x(
+ 'Cannot find change "{change}" in plan',
+ change => 'nonesuch',
+), 'And the failure message should be correct';
+
+# Get all!
+my @changes = ($change, $next, $third, $fourth);
+cmp_deeply [$plan->changes], \@changes, 'All should return all changes';
+ok $plan->reset, 'Reset the plan again';
+$plan->do(sub {
+ is shift, $changes[0], 'Change ' . $changes[0]->name . ' should be passed to do sub';
+ is $_, $changes[0], 'Change ' . $changes[0]->name . ' should be the topic in do sub';
+ shift @changes;
+});
+
+# There should be no more to iterate over.
+$plan->do(sub { fail 'Should not get anything passed to do()' });
+
+##############################################################################
+# Let's try searching changes.
+isa_ok my $iter = $plan->search_changes, 'CODE',
+ 'search_changes() should return a code ref';
+
+my $get_all_names = sub {
+ my $iter = shift;
+ my @res;
+ while (my $change = $iter->()) {
+ push @res => $change->name;
+ }
+ return \@res;
+};
+
+is_deeply $get_all_names->($iter), [qw(hey you this/rocks hey-there)],
+ 'All the changes should be returned in the proper order';
+
+# Try reverse order.
+is_deeply $get_all_names->( $plan->search_changes( direction => 'DESC' ) ),
+ [qw(hey-there this/rocks you hey)], 'Direction "DESC" should work';
+
+# Try invalid directions.
+throws_ok { $plan->search_changes( direction => 'foo' ) } 'App::Sqitch::X',
+ 'Should get error for invalid direction';
+is $@->ident, 'DEV', 'Invalid direction error ident should be "DEV"';
+is $@->message, 'Search direction must be either "ASC" or "DESC"',
+ 'Invalid direction error message should be correct';
+
+# Try ascending lowercased.
+is_deeply $get_all_names->( $plan->search_changes( direction => 'asc' ) ),
+ [qw(hey you this/rocks hey-there)], 'Direction "asc" should work';
+
+# Try change name.
+is_deeply $get_all_names->( $plan->search_changes( name => 'you')),
+ [qw(you)], 'Search by change name should work';
+
+is_deeply $get_all_names->( $plan->search_changes( name => 'hey')),
+ [qw(hey hey-there)], 'Search by change name should work as a regex';
+
+is_deeply $get_all_names->( $plan->search_changes( name => '[-/]')),
+ [qw(this/rocks hey-there)],
+ 'Search by change name should with a character class';
+
+# Try planner name.
+is_deeply $get_all_names->( $plan->search_changes( planner => 'Barack' ) ),
+ [qw(this/rocks hey-there)], 'Search by planner should work';
+
+is_deeply $get_all_names->( $plan->search_changes( planner => 'a..a' ) ),
+ [qw(you)], 'Search by planner should work as a regex';
+
+# Search by operation.
+is_deeply $get_all_names->( $plan->search_changes( operation => 'deploy' ) ),
+ [qw(hey you this/rocks hey-there)], 'Search by operation "deploy" should work';
+
+is_deeply $get_all_names->( $plan->search_changes( operation => 'revert' ) ),
+ [], 'Search by operation "rever" should return nothing';
+
+# Fake out an operation.
+my $mock_change = Test::MockModule->new('App::Sqitch::Plan::Change');
+$mock_change->mock( operator => sub { return shift->name =~ /hey/ ? '-' : '+' });
+
+is_deeply $get_all_names->( $plan->search_changes( operation => 'DEPLOY' ) ),
+ [qw(you this/rocks)], 'Search by operation "DEPLOY" should now return two changes';
+
+is_deeply $get_all_names->( $plan->search_changes( operation => 'REVERT' ) ),
+ [qw(hey hey-there)], 'Search by operation "REVERT" should return the other two';
+
+$mock_change->unmock_all;
+
+# Make sure we test only for legal operations.
+throws_ok { $plan->search_changes( operation => 'foo' ) } 'App::Sqitch::X',
+ 'Should get an error for unknown operation';
+is $@->ident, 'DEV', 'Unknown operation error ident should be "DEV"';
+is $@->message, 'Unknown change operation "foo"',
+ 'Unknown operation error message should be correct';
+
+# Test offset and limit.
+is_deeply $get_all_names->( $plan->search_changes( offset => 2 ) ),
+ [qw(this/rocks hey-there)], 'Search with offset 2 should work';
+
+is_deeply $get_all_names->( $plan->search_changes( offset => 2, limit => 1 ) ),
+ [qw(this/rocks)], 'Search with offset 2, limit 1 should work';
+
+is_deeply $get_all_names->( $plan->search_changes( offset => 3, direction => 'desc' ) ),
+ [qw(hey)], 'Search with offset 3 and dierction "desc" should work';
+
+is_deeply $get_all_names->( $plan->search_changes( offset => 2, limit => 1, direction => 'desc' ) ),
+ [qw(you)], 'Search with offset 2, limit 1, dierction "desc" should work';
+
+##############################################################################
+# Test writing the plan.
+can_ok $plan, 'write_to';
+my $to = file 'plan.out';
+END { unlink $to }
+file_not_exists_ok $to;
+ok $plan->write_to($to), 'Write out the file';
+file_exists_ok $to;
+my $v = App::Sqitch->VERSION;
+file_contents_is $to,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . $file->slurp(iomode => '<:utf8_strict'),
+ 'The contents should look right';
+
+# Make sure it will start from a certain point.
+ok $plan->write_to($to, 'this/rocks'), 'Write out the file from "this/rocks"';
+file_contents_is $to,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=multi' . "\n"
+ . '# This is a note' . "\n"
+ . "\n"
+ . $plan->find('this/rocks')->as_string . "\n"
+ . $plan->find('hey-there')->as_string . "\n"
+ . join( "\n", map { $_->as_string } $plan->find('hey-there')->tags ) . "\n",
+ 'Plan should have been written from "this/rocks" through tags at end';
+
+# Make sure it ends at a certain point.
+ok $plan->write_to($to, undef, 'you'), 'Write the file up to "you"';
+file_contents_is $to,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=multi' . "\n"
+ . '# This is a note' . "\n"
+ . "\n"
+ . '# And there was a blank line.' . "\n"
+ . "\n"
+ . $plan->find('hey')->as_string . "\n"
+ . $plan->find('you')->as_string . "\n"
+ . join( "\n", map { $_->as_string } $plan->find('you')->tags ) . "\n",
+ 'Plan should have been written through "you" and its tags';
+
+# Try both.
+ok $plan->write_to($to, '@foo', 'this/rocks'),
+ 'Write from "@foo" to "this/rocks"';
+file_contents_is $to,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=multi' . "\n"
+ . '# This is a note' . "\n"
+ . "\n"
+ . $plan->find('you')->as_string . "\n"
+ . join( "\n", map { $_->as_string } $plan->find('you')->tags ) . "\n"
+ . ' ' . "\n"
+ . $plan->find('this/rocks')->as_string . "\n",
+ 'Plan should have been written from "@foo" to "this/rocks"';
+
+# End with a tag.
+ok $plan->write_to($to, 'hey', '@foo'), 'Write from "hey" to "@foo"';
+file_contents_is $to,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . '%project=multi' . "\n"
+ . '# This is a note' . "\n"
+ . "\n"
+ . $plan->find('hey')->as_string . "\n"
+ . $plan->find('you')->as_string . "\n"
+ . join( "\n", map { $_->as_string } $plan->find('you')->tags ) . "\n",
+ 'Plan should have been written from "hey" through "@foo"';
+
+##############################################################################
+# Test _is_valid.
+can_ok $plan, '_is_valid';
+
+for my $name (@bad_names) {
+ throws_ok { $plan->_is_valid( tag => $name) } 'App::Sqitch::X',
+ qq{Should find "$name" invalid};
+ is $@->ident, 'plan', qq{Invalid name "$name" error ident should be "plan"};
+ is $@->message, __x(
+ qq{"{name}" is invalid: tags must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ name => $name,
+ ), qq{And the "$name" error message should be correct};
+}
+
+# Try some valid names.
+for my $name (
+ 'foo', # alpha
+ '12', # digits
+ 't', # char
+ '6', # digit
+ '阱阪阬', # multibyte
+ 'foo/bar', # middle punct
+ 'beta1', # ending digit
+ 'v1.2-1', # version number with dash
+ 'v1.2+1', # version number with plus
+ 'v1.2_1', # version number with underscore
+) {
+ local $ENV{FOO} = 1;
+ my $disp = Encode::encode_utf8($name);
+ ok $plan->_is_valid(change => $name), qq{Name "$disp" should be valid};
+}
+
+##############################################################################
+# Try adding a tag.
+ok my $tag = $plan->tag( name => 'w00t' ), 'Add tag "w00t"';
+is $plan->count, 4, 'Should have 4 changes';
+ok $plan->contains('@w00t'), 'Should find "@w00t" in plan';
+is $plan->index_of('@w00t'), 3, 'Should find "@w00t" at index 3';
+is $plan->last->name, 'hey-there', 'Last change should be "hey-there"';
+is_deeply [map { $_->name } $plan->last->tags], [qw(bar baz w00t)],
+ 'The w00t tag should be on the last change';
+isa_ok $tag, 'App::Sqitch::Plan::Tag';
+is $tag->name, 'w00t', 'The returned tag should be @w00t';
+is $tag->change, $plan->last, 'The @w00t change should be the last change';
+
+ok $plan->write_to($to), 'Write out the file again';
+file_contents_is $to,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . $file->slurp(iomode => '<:utf8_strict')
+ . $tag->as_string . "\n",
+ { encoding => 'UTF-8' },
+ 'The contents should include the "w00t" tag';
+# Try passing the tag name with a leading @.
+ok my $tag2 = $plan->tag( name => '@alpha' ), 'Add tag "@alpha"';
+ok $plan->contains('@alpha'), 'Should find "@alpha" in plan';
+is $plan->index_of('@alpha'), 3, 'Should find "@alpha" at index 3';
+is $tag2->name, 'alpha', 'The returned tag should be @alpha';
+is $tag2->change, $plan->last, 'The @alpha change should be the last change';
+
+# Try specifying the change to tag.
+ok my $tag3 = $plan->tag(name => 'blarney', change => 'you'),
+ 'Tag change "you"';
+is $plan->count, 4, 'Should still have 4 changes';
+ok $plan->contains('@blarney'), 'Should find "@blarney" in plan';
+is $plan->index_of('@blarney'), 1, 'Should find "@blarney" at index 1';
+is_deeply [map { $_->name } $plan->change_at(1)->tags], [qw(foo blarney)],
+ 'The blarney tag should be on the second change';
+isa_ok $tag3, 'App::Sqitch::Plan::Tag';
+is $tag3->name, 'blarney', 'The returned tag should be @blarney';
+is $tag3->change, $plan->change_at(1), 'The @blarney change should be the second change';
+
+# Should choke on a duplicate tag.
+throws_ok { $plan->tag( name => 'w00t' ) } 'App::Sqitch::X',
+ 'Should get error trying to add duplicate tag';
+is $@->ident, 'plan', 'Duplicate tag error ident should be "plan"';
+is $@->message, __x(
+ 'Tag "{tag}" already exists',
+ tag => '@w00t',
+), 'And the error message should report it as a dupe';
+
+# Should choke on an invalid tag names.
+for my $name (@bad_names, 'foo#bar') {
+ next if $name =~ /^@/;
+ throws_ok { $plan->tag( name => $name ) } 'App::Sqitch::X',
+ qq{Should get error for invalid tag "$name"};
+ is $@->ident, 'plan', qq{Invalid name "$name" error ident should be "plan"};
+ is $@->message, __x(
+ qq{"{name}" is invalid: tags must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ name => $name,
+ ), qq{And the "$name" error message should be correct};
+}
+
+# Validate reserved names.
+for my $reserved (qw(HEAD ROOT)) {
+ throws_ok { $plan->tag( name => $reserved ) } 'App::Sqitch::X',
+ qq{Should get error for reserved tag "$reserved"};
+ is $@->ident, 'plan', qq{Reserved tag "$reserved" error ident should be "plan"};
+ is $@->message, __x(
+ '"{name}" is a reserved name',
+ name => $reserved,
+ ), qq{And the reserved tag "$reserved" message should be correct};
+}
+
+throws_ok { $plan->tag( name => $sha1 ) } 'App::Sqitch::X',
+ 'Should get error for a SHA1 tag';
+is $@->ident, 'plan', 'SHA1 tag error ident should be "plan"';
+is $@->message, __x(
+ '"{name}" is invalid because it could be confused with a SHA1 ID',
+ name => $sha1,,
+), 'And the reserved name error should be output';
+
+##############################################################################
+# Try adding a change.
+ok my $new_change = $plan->add(name => 'booyah', note => 'Hi there'),
+ 'Add change "booyah"';
+is $plan->count, 5, 'Should have 5 changes';
+ok $plan->contains('booyah'), 'Should find "booyah" in plan';
+is $plan->index_of('booyah'), 4, 'Should find "booyah" at index 4';
+is $plan->last->name, 'booyah', 'Last change should be "booyah"';
+isa_ok $new_change, 'App::Sqitch::Plan::Change';
+is $new_change->as_string, join (' ',
+ 'booyah',
+ $new_change->timestamp->as_string,
+ $new_change->format_planner,
+ $new_change->format_note,
+), 'Should have plain stringification of "booya"';
+
+my $contents = $file->slurp(iomode => '<:utf8_strict');
+$contents =~ s{(\s+this/rocks)}{"\n" . $tag3->as_string . $1}ems;
+ok $plan->write_to($to), 'Write out the file again';
+file_contents_is $to,
+ '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n"
+ . $contents
+ . $tag->as_string . "\n"
+ . $tag2->as_string . "\n\n"
+ . $new_change->as_string . "\n",
+ { encoding => 'UTF-8' },
+ 'The contents should include the "booyah" change';
+
+# Make sure dependencies are verified.
+ok $new_change = $plan->add(name => 'blow', requires => ['booyah']),
+ 'Add change "blow"';
+is $plan->count, 6, 'Should have 6 changes';
+ok $plan->contains('blow'), 'Should find "blow" in plan';
+is $plan->index_of('blow'), 5, 'Should find "blow" at index 5';
+is $plan->last->name, 'blow', 'Last change should be "blow"';
+is $new_change->as_string,
+ 'blow [booyah] ' . $new_change->timestamp->as_string . ' '
+ . $new_change->format_planner,
+ 'Should have nice stringification of "blow [booyah]"';
+is [$plan->lines]->[-1], $new_change,
+ 'The new change should have been appended to the lines, too';
+
+# Make sure dependencies are unique.
+ok $new_change = $plan->add(name => 'jive', requires => [qw(blow blow)]),
+ 'Add change "jive" with dupe dependency';
+is $plan->count, 7, 'Should have 7 changes';
+ok $plan->contains('jive'), 'Should find "jive" in plan';
+is $plan->index_of('jive'), 6, 'Should find "jive" at index 6';
+is $plan->last->name, 'jive', 'jive change should be "jive"';
+is_deeply [ map { $_->change } $new_change->requires ], ['blow'],
+ 'Should have dependency "blow"';
+is $new_change->as_string,
+ 'jive [blow] ' . $new_change->timestamp->as_string . ' '
+ . $new_change->format_planner,
+ 'Should have nice stringification of "jive [blow]"';
+is [$plan->lines]->[-1], $new_change,
+ 'The new change should have been appended to the lines, too';
+
+# Make sure externals and conflicts are unique.
+ok $new_change = $plan->add(
+ name => 'moo',
+ requires => [qw(ext:foo ext:foo)],
+ conflicts => [qw(blow blow ext:whu ext:whu)],
+), 'Add change "moo" with dupe dependencies';
+
+is $plan->count, 8, 'Should have 8 changes';
+ok $plan->contains('moo'), 'Should find "moo" in plan';
+is $plan->index_of('moo'), 7, 'Should find "moo" at index 7';
+is $plan->last->name, 'moo', 'moo change should be "moo"';
+is_deeply [ map { $_->as_string } $new_change->requires ], ['ext:foo'],
+ 'Should require "ext:whu"';
+is_deeply [ map { $_->as_string } $new_change->conflicts ], [qw(blow ext:whu)],
+ 'Should conflict with "blow" and "ext:whu"';
+is $new_change->as_string,
+ 'moo [ext:foo !blow !ext:whu] ' . $new_change->timestamp->as_string . ' '
+ . $new_change->format_planner,
+ 'Should have nice stringification of "moo [ext:foo !blow !ext:whu]"';
+is [$plan->lines]->[-1], $new_change,
+ 'The new change should have been appended to the lines, too';
+
+# Should choke on a duplicate change.
+throws_ok { $plan->add(name => 'blow') } 'App::Sqitch::X',
+ 'Should get error trying to add duplicate change';
+is $@->ident, 'plan', 'Duplicate change error ident should be "plan"';
+is $@->message, __x(
+ qq{Change "{change}" already exists in plan {file}.\nUse "sqitch rework" to copy and rework it},
+ change => 'blow',
+ file => $plan->file,
+), 'And the error message should suggest "rework"';
+
+# Should choke on an invalid change names.
+for my $name (@bad_names) {
+ throws_ok { $plan->add( name => $name ) } 'App::Sqitch::X',
+ qq{Should get error for invalid change "$name"};
+ is $@->ident, 'plan', qq{Invalid name "$name" error ident should be "plan"};
+ is $@->message, __x(
+ qq{"{name}" is invalid: changes must not begin with punctuation, }
+ . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation',
+ name => $name,
+ ), qq{And the "$name" error message should be correct};
+}
+
+# Try a reserved name.
+for my $reserved (qw(HEAD ROOT)) {
+ throws_ok { $plan->add( name => $reserved ) } 'App::Sqitch::X',
+ qq{Should get error for reserved name "$reserved"};
+ is $@->ident, 'plan', qq{Reserved name "$reserved" error ident should be "plan"};
+ is $@->message, __x(
+ '"{name}" is a reserved name',
+ name => $reserved,
+ ), qq{And the reserved name "$reserved" message should be correct};
+}
+
+# Try an unknown dependency.
+throws_ok { $plan->add( name => 'whu', requires => ['nonesuch' ] ) } 'App::Sqitch::X',
+ 'Should get failure for failed dependency';
+is $@->ident, 'plan', 'Dependency error ident should be "plan"';
+is $@->message, __x(
+ 'Cannot add change "{change}": requires unknown change "{req}"',
+ change => 'whu',
+ req => 'nonesuch',
+), 'The dependency error should be correct';
+
+# Try invalid dependencies.
+throws_ok { $plan->add( name => 'whu', requires => ['^bogus' ] ) } 'App::Sqitch::X',
+ 'Should get failure for invalid dependency';
+is $@->ident, 'plan', 'Invalid dependency error ident should be "plan"';
+is $@->message, __x(
+ '"{dep}" is not a valid dependency specification',
+ dep => '^bogus',
+), 'The invalid dependency error should be correct';
+
+throws_ok { $plan->add( name => 'whu', conflicts => ['^bogus' ] ) } 'App::Sqitch::X',
+ 'Should get failure for invalid conflict';
+is $@->ident, 'plan', 'Invalid conflict error ident should be "plan"';
+is $@->message, __x(
+ '"{dep}" is not a valid dependency specification',
+ dep => '^bogus',
+), 'The invalid conflict error should be correct';
+
+# Should choke on an unknown tag, too.
+throws_ok { $plan->add(name => 'whu', requires => ['@nonesuch' ] ) } 'App::Sqitch::X',
+ 'Should get failure for failed tag dependency';
+is $@->ident, 'plan', 'Tag dependency error ident should be "plan"';
+is $@->message, __x(
+ 'Cannot add change "{change}": requires unknown change "{req}"',
+ change => 'whu',
+ req => '@nonesuch',
+), 'The tag dependency error should be correct';
+
+# Should choke on a change that looks like a SHA1.
+throws_ok { $plan->add(name => $sha1) } 'App::Sqitch::X',
+ 'Should get error for a SHA1 change';
+is $@->ident, 'plan', 'SHA1 tag error ident should be "plan"';
+is $@->message, __x(
+ '"{name}" is invalid because it could be confused with a SHA1 ID',
+ name => $sha1,,
+), 'And the reserved name error should be output';
+
+##############################################################################
+# Try reworking a change.
+can_ok $plan, 'rework';
+ok my $rev_change = $plan->rework( name => 'you' ), 'Rework change "you"';
+isa_ok $rev_change, 'App::Sqitch::Plan::Change';
+is $rev_change->name, 'you', 'Reworked change should be "you"';
+ok my $orig = $plan->change_at($plan->first_index_of('you')),
+ 'Get original "you" change';
+is $orig->name, 'you', 'It should also be named "you"';
+is_deeply [ map { $_->format_name } $orig->rework_tags ],
+ [qw(@bar)], 'And it should have the one rework tag';
+is $orig->deploy_file, $target->deploy_dir->file('you@bar.sql'),
+ 'The original file should now be named you@bar.sql';
+is $rev_change->as_string,
+ 'you [you@bar] ' . $rev_change->timestamp->as_string . ' '
+ . $rev_change->format_planner,
+ 'It should require the previous "you" change';
+is [$plan->lines]->[-1], $rev_change,
+ 'The new "you" should have been appended to the lines, too';
+
+# Make sure it was appended to the plan.
+ok $plan->contains('you@HEAD'), 'Should find "you@HEAD" in plan';
+is $plan->index_of('you@HEAD'), 8, 'It should be at position 8';
+is $plan->count, 9, 'The plan count should be 9';
+
+# Tag and add again, to be sure we can do it multiple times.
+ok $plan->tag( name => '@beta1' ), 'Tag @beta1';
+ok my $rev_change2 = $plan->rework( name => 'you' ),
+ 'Rework change "you" again';
+isa_ok $rev_change2, 'App::Sqitch::Plan::Change';
+is $rev_change2->name, 'you', 'New reworked change should be "you"';
+ok $orig = $plan->change_at($plan->first_index_of('you')),
+ 'Get original "you" change again';
+is $orig->name, 'you', 'It should still be named "you"';
+is_deeply [ map { $_->format_name } $orig->rework_tags ],
+ [qw(@bar)], 'And it should have the one rework tag';
+ok $rev_change = $plan->get('you@beta1'), 'Get you@beta1';
+is $rev_change->name, 'you', 'The second "you" should be named that';
+is_deeply [ map { $_->format_name } $rev_change->rework_tags ],
+ [qw(@beta1)], 'And the second change should have the rework_tag "@beta1"';
+is_deeply [ $rev_change2->rework_tags ],
+ [], 'But the new reworked change should have no rework tags';
+is $rev_change2->as_string,
+ 'you [you@beta1] ' . $rev_change2->timestamp->as_string . ' '
+ . $rev_change2->format_planner,
+ 'It should require the previous "you" change';
+is [$plan->lines]->[-1], $rev_change2,
+ 'The new reworking should have been appended to the lines';
+
+# Make sure it was appended to the plan.
+ok $plan->contains('you@HEAD'), 'Should find "you@HEAD" in plan';
+is $plan->index_of('you@HEAD'), 9, 'It should be at position 9';
+is $plan->count, 10, 'The plan count should be 10';
+
+# Try a nonexistent change name.
+throws_ok { $plan->rework( name => 'nonexistent' ) } 'App::Sqitch::X',
+ 'rework should die on nonexistent change';
+is $@->ident, 'plan', 'Nonexistent change error ident should be "plan"';
+is $@->message, __x(
+ qq{Change "{change}" does not exist in {file}.\nUse "sqitch add {change}" to add it to the plan},
+ change => 'nonexistent',
+ file => $plan->file,
+), 'And the error should suggest "sqitch add"';
+
+# Try reworking without an intervening tag.
+throws_ok { $plan->rework( name => 'you' ) } 'App::Sqitch::X',
+ 'rework_stpe should die on lack of intervening tag';
+is $@->ident, 'plan', 'Missing tag error ident should be "plan"';
+is $@->message, __x(
+ qq{Cannot rework "{change}" without an intervening tag.\nUse "sqitch tag" to create a tag and try again},
+ change => 'you',
+), 'And the error should suggest "sqitch tag"';
+
+# Make sure it checks dependencies.
+throws_ok { $plan->rework( name => 'booyah', requires => ['nonesuch' ] ) }
+ 'App::Sqitch::X',
+ 'rework should die on failed dependency';
+is $@->ident, 'plan', 'Rework dependency error ident should be "plan"';
+is $@->message, __x(
+ 'Cannot rework change "{change}": requires unknown change "{req}"',
+ change => 'booyah',
+ req => 'nonesuch',
+), 'The rework dependency error should be correct';
+
+# Try invalid dependencies.
+throws_ok { $plan->rework( name => 'booyah', requires => ['^bogus' ] ) } 'App::Sqitch::X',
+ 'Should get failure for invalid dependency';
+is $@->ident, 'plan', 'Invalid dependency error ident should be "plan"';
+is $@->message, __x(
+ '"{dep}" is not a valid dependency specification',
+ dep => '^bogus',
+), 'The invalid dependency error should be correct';
+
+throws_ok { $plan->rework( name => 'booyah', conflicts => ['^bogus' ] ) } 'App::Sqitch::X',
+ 'Should get failure for invalid conflict';
+is $@->ident, 'plan', 'Invalid conflict error ident should be "plan"';
+is $@->message, __x(
+ '"{dep}" is not a valid dependency specification',
+ dep => '^bogus',
+), 'The invalid conflict error should be correct';
+
+##############################################################################
+# Try a plan with a duplicate change in different tag sections.
+$file = file qw(t plans dupe-change-diff-tag.plan);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target),
+ $CLASS, 'Plan shoud work plan with dupe change across tags';
+is $plan->file, $target->plan_file, 'File should be coopied from Sqitch';
+is $plan->project, 'dupe_change_diff_tag', 'Project name should be set';
+cmp_deeply [ $plan->lines ], [
+ clear,
+ version,
+ prag( '', '', 'project', '', '=', '', 'dupe_change_diff_tag'),
+ blank,
+ change { name => 'whatever' },
+ tag { name => 'foo', ret => 1 },
+ blank(),
+ change { name => 'hi' },
+ tag { name => 'bar', ret => 1 },
+ blank(),
+ change { name => 'greets' },
+ change { name => 'whatever', rtag => [qw(hi whatever)] },
+], 'Lines with dupe change should be read from file';
+
+$vivify = 1;
+cmp_deeply [ $plan->changes ], [
+ clear,
+ change { name => 'whatever' },
+ tag { name => 'foo' },
+ change { name => 'hi' },
+ tag { name => 'bar' },
+ change { name => 'greets' },
+ change { name => 'whatever', rtag => [qw(hi whatever)] },
+], 'Noes with dupe change should be read from file';
+is sorted, 3, 'Should have sorted changes three times';
+
+# Try to find whatever.
+ok $plan->contains('whatever'), 'Should find "whatever" in plan';
+throws_ok { $plan->index_of('whatever') } 'App::Sqitch::X',
+ 'Should get an error trying to find dupe key.';
+is $@->ident, 'plan', 'Dupe key error ident should be "plan"';
+is $@->message, __ 'Change lookup failed',
+ 'Dupe key error message should be correct';
+is_deeply +MockOutput->get_vent, [
+ [__x(
+ 'Change "{change}" is ambiguous. Please specify a tag-qualified change:',
+ change => 'whatever',
+ )],
+ [ ' * ', 'whatever@HEAD' ],
+ [ ' * ', 'whatever@foo' ],
+], 'Should have output listing tag-qualified changes';
+
+is $plan->index_of('whatever@HEAD'), 3, 'Should get 3 for whatever@HEAD';
+is $plan->index_of('whatever@bar'), 0, 'Should get 0 for whatever@bar';
+
+# Make sure seek works, too.
+throws_ok { $plan->seek('whatever') } 'App::Sqitch::X',
+ 'Should get an error seeking dupe key.';
+is $@->ident, 'plan', 'Dupe key error ident should be "plan"';
+is $@->message, __ 'Change lookup failed',
+ 'Dupe key error message should be correct';
+is_deeply +MockOutput->get_vent, [
+ [__x(
+ 'Change "{change}" is ambiguous. Please specify a tag-qualified change:',
+ change => 'whatever',
+ )],
+ [ ' * ', 'whatever@HEAD' ],
+ [ ' * ', 'whatever@foo' ],
+], 'Should have output listing tag-qualified changes';
+
+is $plan->index_of('whatever@HEAD'), 3, 'Should find whatever@HEAD at index 3';
+is $plan->index_of('whatever@bar'), 0, 'Should find whatever@HEAD at index 0';
+is $plan->first_index_of('whatever'), 0,
+ 'Should find first instance of whatever at index 0';
+is $plan->first_index_of('whatever', '@bar'), 3,
+ 'Should find first instance of whatever after @bar at index 5';
+ok $plan->seek('whatever@HEAD'), 'Seek whatever@HEAD';
+is $plan->position, 3, 'Position should be 3';
+ok $plan->seek('whatever@bar'), 'Seek whatever@bar';
+is $plan->position, 0, 'Position should be 0';
+is $plan->last_tagged_change->name, 'hi', 'Last tagged change should be "hi"';
+
+##############################################################################
+# Test open_script.
+make_path dir(qw(sql deploy stuff))->stringify;
+END { remove_tree 'sql' };
+
+can_ok $CLASS, 'open_script';
+my $change_file = file qw(sql deploy bar.sql);
+$fh = $change_file->open('>') or die "Cannot open $change_file: $!\n";
+$fh->say('-- This is a comment');
+$fh->close;
+ok $fh = $plan->open_script($change_file), 'Open bar.sql';
+is $fh->getline, "-- This is a comment\n", 'It should be the right file';
+$fh->close;
+
+file(qw(sql deploy baz.sql))->touch;
+ok $fh = $plan->open_script(file qw(sql deploy baz.sql)), 'Open baz.sql';
+is $fh->getline, undef, 'It should be empty';
+
+# Make sure it dies on an invalid file.
+throws_ok { $plan->open_script(file 'nonexistent' ) } 'App::Sqitch::X',
+ 'open_script() should die on nonexistent file';
+is $@->ident, 'io', 'Nonexistent file error ident should be "io"';
+is $@->message, __x(
+ 'Cannot open {file}: {error}',
+ file => 'nonexistent',
+ error => $! || 'No such file or directory',
+), 'Nonexistent file error message should be correct';
+
+##############################################################################
+# Test check_changes()
+$mocker->unmock('check_changes');
+can_ok $CLASS, 'check_changes';
+my @deps;
+my $i = 0;
+my $j = 0;
+$mock_change->mock(requires => sub {
+ my $reqs = caller eq 'App::Sqitch::Plan' ? $deps[$i++] : $deps[$j++];
+ @{ $reqs->{requires} };
+});
+
+sub changes {
+ clear;
+ $i = $j = 0;
+ map {
+ change { name => $_ };
+ } @_;
+}
+
+# Start with no dependencies.
+$project = 'foo';
+my %ddep = ( requires => [], conflicts => [] );
+@deps = ({%ddep}, {%ddep}, {%ddep});
+cmp_deeply [map { $_->name } $plan->check_changes({}, changes qw(this that other))],
+ [qw(this that other)], 'Should get original order when no dependencies';
+
+@deps = ({%ddep}, {%ddep}, {%ddep});
+cmp_deeply [map { $_->name } $plan->check_changes('foo', changes qw(this that other))],
+ [qw(this that other)], 'Should get original order when no prepreqs';
+
+# Have that require this.
+@deps = ({%ddep}, {%ddep, requires => [dep 'this']}, {%ddep});
+cmp_deeply [map { $_->name }$plan->check_changes('foo', changes qw(this that other))],
+ [qw(this that other)], 'Should get original order when that requires this';
+
+# Have other require that.
+@deps = ({%ddep}, {%ddep, requires => [dep 'this']}, {%ddep, requires => [dep 'that']});
+cmp_deeply [map { $_->name } $plan->check_changes('foo', changes qw(this that other))],
+ [qw(this that other)], 'Should get original order when other requires that';
+
+my $deperr = sub {
+ join "\n ", __n(
+ 'Dependency error detected:',
+ 'Dependency errors detected:',
+ @_
+ ), @_
+};
+
+# Have this require other.
+@deps = ({%ddep, requires => [dep 'other']}, {%ddep}, {%ddep});
+throws_ok {
+ $plan->check_changes('foo', changes qw(this that other))
+} 'App::Sqitch::X', 'Should get error for out-of-order dependency';
+is $@->ident, 'parse', 'Unordered dependency error ident should be "parse"';
+is $@->message, $deperr->(__nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 2,
+ change => 'this',
+ required => 'other',
+ num => 2,
+) . "\n " . __xn(
+ 'HINT: move "{change}" down {num} line in {plan}',
+ 'HINT: move "{change}" down {num} lines in {plan}',
+ 2,
+ change => 'this',
+ num => 2,
+ plan => $plan->file,
+)), 'And the unordered dependency error message should be correct';
+
+# Have this require other and that.
+@deps = ({%ddep, requires => [dep 'other', dep 'that']}, {%ddep}, {%ddep});
+throws_ok {
+ $plan->check_changes('foo', changes qw(this that other));
+} 'App::Sqitch::X', 'Should get error for multiple dependency errors';
+is $@->ident, 'parse', 'Multiple dependency error ident should be "parse"';
+is $@->message, $deperr->(
+ __nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 2,
+ change => 'this',
+ required => 'other',
+ num => 2,
+ ), __nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 1,
+ change => 'this',
+ required => 'that',
+ num => 1,
+ ) . "\n " . __xn(
+ 'HINT: move "{change}" down {num} line in {plan}',
+ 'HINT: move "{change}" down {num} lines in {plan}',
+ 2,
+ change => 'this',
+ num => 2,
+ plan => $plan->file,
+ ),
+), 'And the multiple dependency error message should be correct';
+
+# Have that require a tag.
+@deps = ({%ddep}, {%ddep, requires => [dep '@howdy']}, {%ddep});
+cmp_deeply [$plan->check_changes('foo', {'@howdy' => 2 }, changes qw(this that other))],
+ [changes qw(this that other)], 'Should get original order when requiring a tag';
+
+# Requires a step as of a tag.
+@deps = ({%ddep}, {%ddep, requires => [dep 'foo@howdy']}, {%ddep});
+cmp_deeply [$plan->check_changes('foo', {'foo' => 1, '@howdy' => 2 }, changes qw(this that other))],
+ [changes qw(this that other)],
+ 'Should get original order when requiring a step as-of a tag';
+
+# Should die if the step comes *after* the specified tag.
+@deps = ({%ddep}, {%ddep, requires => [dep 'foo@howdy']}, {%ddep});
+throws_ok { $plan->check_changes('foo', {'foo' => 3, '@howdy' => 2 }, changes qw(this that other)) }
+ 'App::Sqitch::X', 'Should get failure for a step after a tag';
+is $@->ident, 'parse', 'Step after tag error ident should be "parse"';
+is $@->message, $deperr->(__x(
+ 'Unknown change "{required}" required by change "{change}"',
+ required => 'foo@howdy',
+ change => 'that',
+)), 'And we the unknown change as-of a tag message should be correct';
+
+# Add a cycle.
+@deps = ({%ddep, requires => [dep 'that']}, {%ddep, requires => [dep 'this']}, {%ddep});
+throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X',
+ 'Should get failure for a cycle';
+is $@->ident, 'parse', 'Cycle error ident should be "parse"';
+is $@->message, $deperr->(
+ __nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 1,
+ change => 'this',
+ required => 'that',
+ num => 1,
+ ) . "\n " . __xn(
+ 'HINT: move "{change}" down {num} line in {plan}',
+ 'HINT: move "{change}" down {num} lines in {plan}',
+ 1,
+ change => 'this',
+ num => 1,
+ plan => $plan->file,
+ ),
+), 'The cycle error message should be correct';
+
+# Add an extended cycle.
+@deps = (
+ {%ddep, requires => [dep 'that']},
+ {%ddep, requires => [dep 'other']},
+ {%ddep, requires => [dep 'this']}
+);
+throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X',
+ 'Should get failure for a two-hop cycle';
+is $@->ident, 'parse', 'Two-hope cycle error ident should be "parse"';
+is $@->message, $deperr->(
+ __nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 1,
+ change => 'this',
+ required => 'that',
+ num => 1,
+ ) . "\n " . __xn(
+ 'HINT: move "{change}" down {num} line in {plan}',
+ 'HINT: move "{change}" down {num} lines in {plan}',
+ 1,
+ change => 'this',
+ num => 1,
+ plan => $plan->file,
+ ), __nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 1,
+ change => 'that',
+ required => 'other',
+ num => 1,
+ ) . "\n " . __xn(
+ 'HINT: move "{change}" down {num} line in {plan}',
+ 'HINT: move "{change}" down {num} lines in {plan}',
+ 1,
+ change => 'that',
+ num => 1,
+ plan => $plan->file,
+ ),
+), 'The two-hop cycle error message should be correct';
+
+# Okay, now deal with depedencies from earlier change sections.
+@deps = ({%ddep, requires => [dep 'foo']}, {%ddep}, {%ddep});
+cmp_deeply [$plan->check_changes('foo', { foo => 1}, changes qw(this that other))],
+ [changes qw(this that other)], 'Should get original order with earlier dependency';
+
+# Mix it up.
+@deps = ({%ddep, requires => [dep 'other', dep 'that']}, {%ddep, requires => [dep 'sqitch']}, {%ddep});
+throws_ok {
+ $plan->check_changes('foo', {sqitch => 1 }, changes qw(this that other))
+} 'App::Sqitch::X', 'Should get error with misordered and seen dependencies';
+is $@->ident, 'parse', 'Misorderd and seen error ident should be "parse"';
+is $@->message, $deperr->(
+ __nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 2,
+ change => 'this',
+ required => 'other',
+ num => 2,
+ ), __nx(
+ 'Change "{change}" planned {num} change before required change "{required}"',
+ 'Change "{change}" planned {num} changes before required change "{required}"',
+ 1,
+ change => 'this',
+ required => 'that',
+ num => 1,
+ ) . "\n " . __xn(
+ 'HINT: move "{change}" down {num} line in {plan}',
+ 'HINT: move "{change}" down {num} lines in {plan}',
+ 2,
+ change => 'this',
+ num => 2,
+ plan => $plan->file,
+ ),
+), 'And the misordered and seen error message should be correct';
+
+# Make sure it fails on unknown previous dependencies.
+@deps = ({%ddep, requires => [dep 'foo']}, {%ddep}, {%ddep});
+throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X',
+ 'Should die on unknown dependency';
+is $@->ident, 'parse', 'Unknown dependency error ident should be "parse"';
+is $@->message, $deperr->(__x(
+ 'Unknown change "{required}" required by change "{change}"',
+ required => 'foo',
+ change => 'this',
+)), 'And the error should point to the offending change';
+
+# Okay, now deal with depedencies from earlier change sections.
+@deps = ({%ddep, requires => [dep '@foo']}, {%ddep}, {%ddep});
+throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X',
+ 'Should die on unknown tag dependency';
+is $@->ident, 'parse', 'Unknown tag dependency error ident should be "parse"';
+is $@->message, $deperr->(__x(
+ 'Unknown change "{required}" required by change "{change}"',
+ required => '@foo',
+ change => 'this',
+)), 'And the error should point to the offending change';
+
+# Allow dependencies from different projects.
+@deps = ({%ddep}, {%ddep, requires => [dep 'bar:bob']}, {%ddep});
+cmp_deeply [$plan->check_changes('foo', changes qw(this that other))],
+ [changes qw(this that other)], 'Should get original order with external dependency';
+$project = undef;
+
+# Make sure that a change does not require itself
+@deps = ({%ddep, requires => [dep 'this']}, {%ddep}, {%ddep});
+throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X',
+ 'Should die on self dependency';
+is $@->ident, 'parse', 'Self dependency error ident should be "parse"';
+is $@->message, $deperr->(__x(
+ 'Change "{change}" cannot require itself',
+ change => 'this',
+)), 'And the self dependency error should be correct';
+
+# Make sure sort ordering respects the original ordering.
+@deps = (
+ {%ddep},
+ {%ddep},
+ {%ddep, requires => [dep 'that']},
+ {%ddep, requires => [dep 'that', dep 'this']},
+);
+cmp_deeply [$plan->check_changes('foo', changes qw(this that other thing))],
+ [changes qw(this that other thing)],
+ 'Should get original order with cascading dependencies';
+$project = undef;
+
+@deps = (
+ {%ddep},
+ {%ddep},
+ {%ddep, requires => [dep 'that']},
+ {%ddep, requires => [dep 'that', dep 'this', dep 'other']},
+ {%ddep, requires => [dep 'that', dep 'this']},
+);
+cmp_deeply [$plan->check_changes('foo', changes qw(this that other thing yowza))],
+ [changes qw(this that other thing yowza)],
+ 'Should get original order with multiple cascading dependencies';
+$project = undef;
+
+##############################################################################
+# Test dependency testing.
+can_ok $plan, '_check_dependencies';
+$mock_change->unmock('requires');
+
+for my $req (qw(hi greets whatever @foo whatever@foo ext:larry ext:greets)) {
+ $change = App::Sqitch::Plan::Change->new(
+ plan => $plan,
+ name => 'lazy',
+ requires => [dep $req],
+ );
+ my $req_proj = $req =~ /:/ ? do {
+ (my $p = $req) =~ s/:.+//;
+ $p;
+ } : $plan->project;
+ my ($dep) = $change->requires;
+ is $dep->project, $req_proj,
+ qq{Depend "$req" should be in project "$req_proj"};
+ ok $plan->_check_dependencies($change, 'add'),
+ qq{Dependency on "$req" should succeed};
+}
+
+for my $req (qw(wanker @blah greets@foo)) {
+ $change = App::Sqitch::Plan::Change->new(
+ plan => $plan,
+ name => 'lazy',
+ requires => [dep $req],
+ );
+ throws_ok { $plan->_check_dependencies($change, 'bark') } 'App::Sqitch::X',
+ qq{Should get error trying to depend on "$req"};
+ is $@->ident, 'plan', qq{Dependency "req" error ident should be "plan"};
+ is $@->message, __x(
+ 'Cannot rework change "{change}": requires unknown change "{req}"',
+ change => 'lazy',
+ req => $req,
+ ), qq{And should get unknown dependency message for "$req"};
+}
+
+##############################################################################
+# Test pragma accessors.
+is $plan->uri, undef, 'Should have undef URI when no pragma';
+$file = file qw(t plans pragmas.plan);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target),
+ $CLASS, 'Plan with sqitch with plan file with dependencies';
+is $plan->file, $target->plan_file, 'File should be coopied from Sqitch';
+is $plan->syntax_version, App::Sqitch::Plan::SYNTAX_VERSION,
+ 'syntax_version should be set';
+is $plan->project, 'pragmata', 'Project should be set';
+is $plan->uri, URI->new('https://github.com/sqitchers/sqitch/'),
+ 'Should have URI from pragma';
+isa_ok $plan->uri, 'URI', 'It';
+
+# Make sure we get an error if there is no project pragma.
+$fh = IO::File->new(\"%strict\n\nfoo $tsnp", '<:utf8_strict');
+throws_ok { $plan->_parse('noproject', $fh) } 'App::Sqitch::X',
+ 'Should die on plan with no project pragma';
+is $@->ident, 'parse', 'Missing prorject error ident should be "parse"';
+is $@->message, __x('Missing %project pragma in {file}', file => 'noproject'),
+ 'The missing project error message should be correct';
+
+# Make sure we get an error for an invalid project name.
+for my $bad (@bad_names) {
+ my $fh = IO::File->new(\"%project=$bad\n\nfoo $tsnp", '<:utf8_strict');
+ throws_ok { $plan->_parse(badproj => $fh) } 'App::Sqitch::X',
+ qq{Should die on invalid project name "$bad"};
+ is $@->ident, 'parse', qq{Ident for bad proj "$bad" should be "parse"};
+ my $error = __x(
+ 'invalid project name "{project}": project names must not '
+ . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in '
+ . 'punctuation or digits following punctuation',
+ project => $bad);
+ is $@->message, __x(
+ 'Syntax error in {file} at line {lineno}: {error}',
+ file => 'badproj',
+ lineno => 1,
+ error => $error
+ ), qq{Error message for bad project "$bad" should be correct};
+}
+
+done_testing;
diff --git a/t/plan_cmd.t b/t/plan_cmd.t
new file mode 100644
index 00000000..bf33393b
--- /dev/null
+++ b/t/plan_cmd.t
@@ -0,0 +1,675 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 229;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::NoWarnings;
+use Test::Exception;
+use Test::Warn;
+use Test::MockModule;
+use Path::Class;
+use Term::ANSIColor qw(color);
+use Encode;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+use LC;
+
+my $CLASS = 'App::Sqitch::Command::plan';
+require_ok $CLASS;
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir('test-plan_cmd')->stringify,
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+);
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+isa_ok my $cmd = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'plan',
+ config => $config,
+}), $CLASS, 'plan command';
+
+can_ok $cmd, qw(
+ target
+ change_pattern
+ planner_pattern
+ max_count
+ skip
+ reverse
+ format
+ options
+ execute
+ configure
+ headers
+);
+
+is_deeply [$CLASS->options], [qw(
+ event=s
+ target|t=s
+ change-pattern|change=s
+ planner-pattern|planner=s
+ format|f=s
+ date-format|date=s
+ max-count|n=i
+ skip=i
+ reverse!
+ color=s
+ no-color
+ abbrev=i
+ oneline
+ headers!
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+##############################################################################
+# Test configure().
+my $configured = $CLASS->configure($config, {});
+isa_ok delete $configured->{formatter}, 'App::Sqitch::ItemFormatter', 'Formatter';
+is_deeply $configured, {},
+ 'Should get empty hash for no config or options';
+
+# Test date_format validation.
+$config->update('plan.date_format' => 'nonesuch');
+throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X',
+ 'Should get error for invalid date format in config';
+is $@->ident, 'datetime',
+ 'Invalid date format error ident should be "datetime"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'nonesuch',
+), 'Invalid date format error message should be correct';
+
+throws_ok { $CLASS->configure($config, { date_format => 'non'}), {} }
+ 'App::Sqitch::X',
+ 'Should get error for invalid date format in optsions';
+is $@->ident, 'datetime',
+ 'Invalid date format error ident should be "plan"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'non',
+), 'Invalid date format error message should be correct';
+
+# Test format validation.
+$config = TestConfig->new('plan.format' => 'nonesuch');
+throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X',
+ 'Should get error for invalid format in config';
+is $@->ident, 'plan',
+ 'Invalid format error ident should be "plan"';
+is $@->message, __x(
+ 'Unknown plan format "{format}"',
+ format => 'nonesuch',
+), 'Invalid format error message should be correct';
+
+throws_ok { $CLASS->configure($config, { format => 'non'}), {} }
+ 'App::Sqitch::X',
+ 'Should get error for invalid format in optsions';
+is $@->ident, 'plan',
+ 'Invalid format error ident should be "plan"';
+is $@->message, __x(
+ 'Unknown plan format "{format}"',
+ format => 'non',
+), 'Invalid format error message should be correct';
+
+# Test color configuration.
+$config = TestConfig->new;
+$configured = $CLASS->configure( $config, { no_color => 1 } );
+is $configured->{formatter}->color, 'never',
+ 'Configuration should respect --no-color, setting "never"';
+
+# Test oneline configuration.
+$configured = $CLASS->configure( $config, { oneline => 1 });
+is $configured->{format}, '%{:event}C%h %l%{reset}C %n%{cyan}C%t%{reset}C',
+ '--oneline should set format';
+is $configured->{formatter}{abbrev}, 6, '--oneline should set abbrev to 6';
+
+$configured = $CLASS->configure( $config, { oneline => 1, format => 'format:foo', abbrev => 5 });
+is $configured->{format}, 'foo', '--oneline should not override --format';
+is $configured->{formatter}{abbrev}, 5, '--oneline should not overrride --abbrev';
+
+$config->update('plan.color' => 'auto');
+$configured = $CLASS->configure( $config, { no_color => 1 } );
+is $configured->{formatter}->color, 'never',
+ 'Configuration should respect --no-color even when configure is set';
+
+NEVER: {
+ my $configured = $CLASS->configure( $config, { color => 'never' } );
+ is $configured->{formatter}->color, 'never',
+ 'Configuration should respect color option';
+
+ # Try it with config.
+ $config->update('plan.color' => 'never');
+ $configured = $CLASS->configure( $config, {} );
+ is $configured->{formatter}->color, 'never',
+ 'Configuration should respect color config';
+}
+
+ALWAYS: {
+ my $configured = $CLASS->configure( $config, { color => 'always' } );
+ is_deeply $configured->{formatter}->color, 'always',
+ 'Configuration should respect color option';
+
+ # Try it with config.
+ $config->update('plan.color' => 'always');
+ $configured = $CLASS->configure( $config, {} );
+ is_deeply $configured->{formatter}->color, 'always',
+ 'Configuration should respect color config';
+}
+
+AUTO: {
+ for my $enabled (0, 1) {
+ $config->update('plan.color' => 'always');
+ my $configured = $CLASS->configure( $config, { color => 'auto' } );
+ is_deeply $configured->{formatter}->color, 'auto',
+ 'Configuration should respect color option';
+
+ # Try it with config.
+ $config->update('plan.color' => 'auto');
+ $configured = $CLASS->configure( $config, {} );
+ is_deeply $configured->{formatter}->color, 'auto',
+ 'Configuration should respect color config';
+ }
+}
+
+###############################################################################
+# Test named formats.
+my $cdt = App::Sqitch::DateTime->now;
+my $pdt = $cdt->clone->subtract(days => 1);
+my $change = {
+ event => 'deploy',
+ project => 'planit',
+ change_id => '000011112222333444',
+ change => 'lolz',
+ tags => [ '@beta', '@gamma' ],
+ planner_name => 'damian',
+ planner_email => 'damian@example.com',
+ planned_at => $pdt,
+ note => "For the LOLZ.\n\nYou know, funny stuff and cute kittens, right?",
+ requires => [qw(foo bar)],
+ conflicts => []
+};
+
+my $piso = $pdt->as_string( format => 'iso' );
+my $praw = $pdt->as_string( format => 'raw' );
+for my $spec (
+ [ raw => "deploy 000011112222333444 (\@beta, \@gamma)\n"
+ . "name lolz\n"
+ . "project planit\n"
+ . "requires foo, bar\n"
+ . "planner damian <damian\@example.com>\n"
+ . "planned $praw\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ full => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n"
+ . __('Name: ') . " lolz\n"
+ . __('Project: ') . " planit\n"
+ . __('Requires: ') . " foo, bar\n"
+ . __('Planner: ') . " damian <damian\@example.com>\n"
+ . __('Planned: ') . " __PDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ long => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n"
+ . __('Name: ') . " lolz\n"
+ . __('Project: ') . " planit\n"
+ . __('Planner: ') . " damian <damian\@example.com>\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ medium => __('Deploy') . " 000011112222333444\n"
+ . __('Name: ') . " lolz\n"
+ . __('Planner: ') . " damian <damian\@example.com>\n"
+ . __('Date: ') . " __PDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ short => __('Deploy') . " 000011112222333444\n"
+ . __('Name: ') . " lolz\n"
+ . __('Planner: ') . " damian <damian\@example.com>\n\n"
+ . " For the LOLZ.\n",
+ ],
+ [ oneline => '000011112222333444 ' . __('deploy') . ' lolz @beta, @gamma' ],
+) {
+ local $ENV{ANSI_COLORS_DISABLED} = 1;
+ my $configured = $CLASS->configure( $config, { format => $spec->[0] } );
+ my $format = $configured->{format};
+ ok my $cmd = $CLASS->new( sqitch => $sqitch, %{ $configured } ),
+ qq{Instantiate with format "$spec->[0]"};
+ (my $exp = $spec->[1]) =~ s/__PDATE__/$piso/;
+ is $cmd->formatter->format( $cmd->format, $change ), $exp,
+ qq{Format "$spec->[0]" should output correctly};
+
+ if ($spec->[1] =~ /__PDATE__/) {
+ # Test different date formats.
+ for my $date_format (qw(rfc long medium)) {
+ ok my $cmd = $CLASS->new(
+ sqitch => $sqitch,
+ format => $format,
+ formatter => App::Sqitch::ItemFormatter->new(date_format => $date_format),
+ ), qq{Instantiate with format "$spec->[0]" and date format "$date_format"};
+ my $date = $pdt->as_string( format => $date_format );
+ (my $exp = $spec->[1]) =~ s/__PDATE__/$date/;
+ is $cmd->formatter->format( $cmd->format, $change ), $exp,
+ qq{Format "$spec->[0]" and date format "$date_format" should output correctly};
+ }
+ }
+
+ if ($spec->[1] =~ s/\s+[(]?[@]beta,\s+[@]gamma[)]?//) {
+ # Test without tags.
+ local $change->{tags} = [];
+ (my $exp = $spec->[1]) =~ s/__PDATE__/$piso/;
+ is $cmd->formatter->format( $cmd->format, $change ), $exp,
+ qq{Format "$spec->[0]" should output correctly without tags};
+ }
+}
+
+###############################################################################
+# Test all formatting characters.
+my $local_pdt = $pdt->clone;
+$local_pdt->set_time_zone('local');
+$local_pdt->set_locale($LC::TIME);
+
+my $formatter = $cmd->formatter;
+for my $spec (
+ ['%e', { event => 'deploy' }, 'deploy' ],
+ ['%e', { event => 'revert' }, 'revert' ],
+ ['%e', { event => 'fail' }, 'fail' ],
+
+ ['%L', { event => 'deploy' }, __ 'Deploy' ],
+ ['%L', { event => 'revert' }, __ 'Revert' ],
+ ['%L', { event => 'fail' }, __ 'Fail' ],
+
+ ['%l', { event => 'deploy' }, __ 'deploy' ],
+ ['%l', { event => 'revert' }, __ 'revert' ],
+ ['%l', { event => 'fail' }, __ 'fail' ],
+
+ ['%{event}_', {}, __ 'Event: ' ],
+ ['%{change}_', {}, __ 'Change: ' ],
+ ['%{planner}_', {}, __ 'Planner: ' ],
+ ['%{by}_', {}, __ 'By: ' ],
+ ['%{date}_', {}, __ 'Date: ' ],
+ ['%{planned}_', {}, __ 'Planned: ' ],
+ ['%{name}_', {}, __ 'Name: ' ],
+ ['%{email}_', {}, __ 'Email: ' ],
+ ['%{requires}_', {}, __ 'Requires: ' ],
+ ['%{conflicts}_', {}, __ 'Conflicts:' ],
+
+ ['%H', { change_id => '123456789' }, '123456789' ],
+ ['%h', { change_id => '123456789' }, '123456789' ],
+ ['%{5}h', { change_id => '123456789' }, '12345' ],
+ ['%{7}h', { change_id => '123456789' }, '1234567' ],
+
+ ['%n', { change => 'foo' }, 'foo'],
+ ['%n', { change => 'bar' }, 'bar'],
+ ['%o', { project => 'foo' }, 'foo'],
+ ['%o', { project => 'bar' }, 'bar'],
+
+ ['%p', { planner_name => 'larry', planner_email => 'larry@example.com' }, 'larry <larry@example.com>'],
+ ['%{n}p', { planner_name => 'damian' }, 'damian'],
+ ['%{name}p', { planner_name => 'chip' }, 'chip'],
+ ['%{e}p', { planner_email => 'larry@example.com' }, 'larry@example.com'],
+ ['%{email}p', { planner_email => 'damian@example.com' }, 'damian@example.com'],
+
+ ['%{date}p', { planned_at => $pdt }, $pdt->as_string( format => 'iso' ) ],
+ ['%{date:rfc}p', { planned_at => $pdt }, $pdt->as_string( format => 'rfc' ) ],
+ ['%{d:long}p', { planned_at => $pdt }, $pdt->as_string( format => 'long' ) ],
+ ["%{d:cldr:HH'h' mm'm'}p", { planned_at => $pdt }, $local_pdt->format_cldr( q{HH'h' mm'm'} ) ],
+ ["%{d:strftime:%a at %H:%M:%S}p", { planned_at => $pdt }, $local_pdt->strftime('%a at %H:%M:%S') ],
+
+ ['%t', { tags => [] }, '' ],
+ ['%t', { tags => ['@foo'] }, ' @foo' ],
+ ['%t', { tags => ['@foo', '@bar'] }, ' @foo, @bar' ],
+ ['%{|}t', { tags => [] }, '' ],
+ ['%{|}t', { tags => ['@foo'] }, ' @foo' ],
+ ['%{|}t', { tags => ['@foo', '@bar'] }, ' @foo|@bar' ],
+
+ ['%T', { tags => [] }, '' ],
+ ['%T', { tags => ['@foo'] }, ' (@foo)' ],
+ ['%T', { tags => ['@foo', '@bar'] }, ' (@foo, @bar)' ],
+ ['%{|}T', { tags => [] }, '' ],
+ ['%{|}T', { tags => ['@foo'] }, ' (@foo)' ],
+ ['%{|}T', { tags => ['@foo', '@bar'] }, ' (@foo|@bar)' ],
+
+ ['%r', { requires => [] }, '' ],
+ ['%r', { requires => ['foo'] }, ' foo' ],
+ ['%r', { requires => ['foo', 'bar'] }, ' foo, bar' ],
+ ['%{|}r', { requires => [] }, '' ],
+ ['%{|}r', { requires => ['foo'] }, ' foo' ],
+ ['%{|}r', { requires => ['foo', 'bar'] }, ' foo|bar' ],
+
+ ['%R', { requires => [] }, '' ],
+ ['%R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ],
+ ['%R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo, bar\n" ],
+ ['%{|}R', { requires => [] }, '' ],
+ ['%{|}R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ],
+ ['%{|}R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo|bar\n" ],
+
+ ['%x', { conflicts => [] }, '' ],
+ ['%x', { conflicts => ['foo'] }, ' foo' ],
+ ['%x', { conflicts => ['foo', 'bax'] }, ' foo, bax' ],
+ ['%{|}x', { conflicts => [] }, '' ],
+ ['%{|}x', { conflicts => ['foo'] }, ' foo' ],
+ ['%{|}x', { conflicts => ['foo', 'bax'] }, ' foo|bax' ],
+
+ ['%X', { conflicts => [] }, '' ],
+ ['%X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ],
+ ['%X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo, bar\n" ],
+ ['%{|}X', { conflicts => [] }, '' ],
+ ['%{|}X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ],
+ ['%{|}X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo|bar\n" ],
+
+ ['%{yellow}C', {}, '' ],
+ ['%{:event}C', { event => 'deploy' }, '' ],
+ ['%v', {}, "\n" ],
+ ['%%', {}, '%' ],
+
+ ['%s', { note => 'hi there' }, 'hi there' ],
+ ['%s', { note => "hi there\nyo" }, 'hi there' ],
+ ['%s', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, 'subject line' ],
+ ['%{ }s', { note => 'hi there' }, ' hi there' ],
+ ['%{xx}s', { note => 'hi there' }, 'xxhi there' ],
+
+ ['%b', { note => 'hi there' }, '' ],
+ ['%b', { note => "hi there\nyo" }, 'yo' ],
+ ['%b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "first graph\n\nsecond graph\n\n" ],
+ ['%{ }b', { note => 'hi there' }, '' ],
+ ['%{xxx }b', { note => "hi there\nyo" }, "xxx yo" ],
+ ['%{x}b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xfirst graph\nx\nxsecond graph\nx\n" ],
+ ['%{ }b', { note => "hi there\r\nyo" }, " yo" ],
+
+ ['%B', { note => 'hi there' }, 'hi there' ],
+ ['%B', { note => "hi there\nyo" }, "hi there\nyo" ],
+ ['%B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "subject line\n\nfirst graph\n\nsecond graph\n\n" ],
+ ['%{ }B', { note => 'hi there' }, ' hi there' ],
+ ['%{xxx }B', { note => "hi there\nyo" }, "xxx hi there\nxxx yo" ],
+ ['%{x}B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xsubject line\nx\nxfirst graph\nx\nxsecond graph\nx\n" ],
+ ['%{ }B', { note => "hi there\r\nyo" }, " hi there\r\n yo" ],
+
+ ['%{change}a', $change, "change $change->{change}\n" ],
+ ['%{change_id}a', $change, "change_id $change->{change_id}\n" ],
+ ['%{event}a', $change, "event $change->{event}\n" ],
+ ['%{tags}a', $change, 'tags ' . join(', ', @{ $change->{tags} }) . "\n" ],
+ ['%{requires}a', $change, 'requires ' . join(', ', @{ $change->{requires} }) . "\n" ],
+ ['%{conflicts}a', $change, '' ],
+) {
+ local $ENV{ANSI_COLORS_DISABLED} = 1;
+ (my $desc = encode_utf8 $spec->[2]) =~ s/\n/[newline]/g;
+ is $formatter->format( $spec->[0], $spec->[1] ), $spec->[2],
+ qq{Format "$spec->[0]" should output "$desc"};
+}
+
+throws_ok { $formatter->format( '%_', {} ) } 'App::Sqitch::X',
+ 'Should get exception for format "%_"';
+is $@->ident, 'format', '%_ error ident should be "format"';
+is $@->message, __ 'No label passed to the _ format',
+ '%_ error message should be correct';
+throws_ok { $formatter->format( '%{foo}_', {} ) } 'App::Sqitch::X',
+ 'Should get exception for unknown label in format "%_"';
+is $@->ident, 'format', 'Invalid %_ label error ident should be "format"';
+is $@->message, __x(
+ 'Unknown label "{label}" passed to the _ format',
+ label => 'foo'
+), 'Invalid %_ label error message should be correct';
+
+ok $cmd = $CLASS->new(
+ sqitch => $sqitch,
+ formatter => App::Sqitch::ItemFormatter->new(abbrev => 4)
+), 'Instantiate with abbrev => 4';
+is $cmd->formatter->format( '%h', { change_id => '123456789' } ),
+ '1234', '%h should respect abbrev';
+is $cmd->formatter->format( '%H', { change_id => '123456789' } ),
+ '123456789', '%H should not respect abbrev';
+
+ok $cmd = $CLASS->new(
+ sqitch => $sqitch,
+ formatter => App::Sqitch::ItemFormatter->new(date_format => 'rfc')
+), 'Instantiate with date_format => "rfc"';
+is $cmd->formatter->format( '%{date}p', { planned_at => $cdt } ),
+ $cdt->as_string( format => 'rfc' ),
+ '%{date}p should respect the date_format attribute';
+is $cmd->formatter->format( '%{d:iso}p', { planned_at => $cdt } ),
+ $cdt->as_string( format => 'iso' ),
+ '%{iso}p should override the date_format attribute';
+
+throws_ok { $formatter->format( '%{foo}a', {}) } 'App::Sqitch::X',
+ 'Should get exception for unknown attribute passed to %a';
+is $@->ident, 'format', '%a error ident should be "format"';
+is $@->message, __x(
+ '{attr} is not a valid change attribute', attr => 'foo'
+), '%a error message should be correct';
+
+delete $ENV{ANSI_COLORS_DISABLED};
+for my $color (qw(yellow red blue cyan magenta)) {
+ is $formatter->format( "%{$color}C", {} ), color($color),
+ qq{Format "%{$color}C" should output }
+ . color($color) . $color . color('reset');
+}
+
+for my $spec (
+ [ ':event', { event => 'deploy' }, 'green', 'deploy' ],
+ [ ':event', { event => 'revert' }, 'blue', 'revert' ],
+ [ ':event', { event => 'fail' }, 'red', 'fail' ],
+) {
+ is $formatter->format( "%{$spec->[0]}C", $spec->[1] ), color($spec->[2]),
+ qq{Format "%{$spec->[0]}C" on "$spec->[3]" should output }
+ . color($spec->[2]) . $spec->[2] . color('reset');
+}
+
+# Make sure other colors work.
+my $yellow = color('yellow') . '%s' . color('reset');
+my $green = color('green') . '%s' . color('reset');
+my $cyan = color('cyan') . ' %s' . color('reset');
+$change->{conflicts} = [qw(dr_evil)];
+for my $spec (
+ [ full => sprintf($green, __ ('Deploy') . ' 000011112222333444')
+ . " (\@beta, \@gamma)\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Project: ') . " planit\n"
+ . __ ('Requires: ') . " foo, bar\n"
+ . __ ('Conflicts:') . " dr_evil\n"
+ . __ ('Planner: ') . " damian <damian\@example.com>\n"
+ . __ ('Planned: ') . " __PDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ long => sprintf($green, __ ('Deploy') . ' 000011112222333444')
+ . " (\@beta, \@gamma)\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Project: ') . " planit\n"
+ . __ ('Planner: ') . " damian <damian\@example.com>\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ medium => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Planner: ') . " damian <damian\@example.com>\n"
+ . __ ('Date: ') . " __PDATE__\n\n"
+ . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n"
+ ],
+ [ short => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n"
+ . __ ('Name: ') . " lolz\n"
+ . __ ('Planner: ') . " damian <damian\@example.com>\n\n"
+ . " For the LOLZ.\n",
+ ],
+ [ oneline => sprintf "$green %s$cyan", '000011112222333444' . ' '
+ . __('deploy'), 'lolz', '@beta, @gamma',
+ ],
+) {
+ my $format = $CLASS->configure( $config, { format => $spec->[0] } )->{format};
+ ok my $cmd = $CLASS->new( sqitch => $sqitch, format => $format ),
+ qq{Instantiate with format "$spec->[0]" again};
+ (my $exp = $spec->[1]) =~ s/__PDATE__/$piso/;
+ is $cmd->formatter->format( $cmd->format, $change ), $exp,
+ qq{Format "$spec->[0]" should output correctly with color};
+}
+
+throws_ok { $formatter->format( '%{BLUELOLZ}C', {} ) } 'App::Sqitch::X',
+ 'Should get an error for an invalid color';
+is $@->ident, 'format', 'Invalid color error ident should be "format"';
+is $@->message, __x(
+ '{color} is not a valid ANSI color', color => 'BLUELOLZ'
+), 'Invalid color error message should be correct';
+
+
+##############################################################################
+# Test execute().
+my $pmock = Test::MockModule->new('App::Sqitch::Plan');
+
+# First, test for no changes.
+$pmock->mock(count => 0);
+
+my $plan = $cmd->default_target->plan;
+throws_ok { $cmd->execute } 'App::Sqitch::X',
+ 'Should get error for no changes';
+is $@->ident, 'plan', 'no changes error ident should be "plan"';
+is $@->exitval, 1, 'no changes exit val should be 1';
+is $@->message, __x(
+ 'No changes in {file}',
+ file => $plan->file,
+), 'no changes error message should be correct';
+$pmock->unmock('count');
+
+# Okay, let's see some changes.
+my @changes;
+my $iter = sub { shift @changes };
+my $search_args;
+$pmock->mock(search_changes => sub {
+ shift;
+ $search_args = [@_];
+ return $iter;
+});
+
+$change = $plan->change_at(0);
+push @changes => $change;
+ok $cmd->execute, 'Execute plan';
+is_deeply $search_args, [
+ operation => undef,
+ name => undef,
+ planner => undef,
+ limit => undef,
+ offset => undef,
+ direction => 'ASC'
+], 'The proper args should have been passed to search_events';
+
+my $fmt_params = {
+ event => $change->is_deploy ? 'deploy' : 'revert',
+ project => $change->project,
+ change_id => $change->id,
+ change => $change->name,
+ note => $change->note,
+ tags => [ map { $_->format_name } $change->tags ],
+ requires => [ map { $_->as_string } $change->requires ],
+ conflicts => [ map { $_->as_string } $change->conflicts ],
+ planned_at => $change->timestamp,
+ planner_name => $change->planner_name,
+ planner_email => $change->planner_email,
+};
+is_deeply +MockOutput->get_page, [
+ ['# ', __x 'Project: {project}', project => $plan->project ],
+ ['# ', __x 'File: {file}', file => $plan->file ],
+ [''],
+ [ $cmd->formatter->format( $cmd->format, $fmt_params ) ],
+], 'The event should have been paged';
+
+# Set attributes and add more events.
+my $change2 = $plan->change_at(1);
+push @changes => $change, $change2;
+isa_ok $cmd = $CLASS->new(
+ sqitch => $sqitch,
+ event => 'deploy',
+ change_pattern => '.+',
+ project_pattern => '.+',
+ planner_pattern => '.+',
+ max_count => 10,
+ skip => 5,
+ reverse => 1,
+ headers => 0,
+), $CLASS, 'plan with attributes';
+
+ok $cmd->execute, 'Execute plan with attributes';
+is_deeply $search_args, [
+ operation => 'deploy',
+ name => '.+',
+ planner => '.+',
+ limit => 10,
+ offset => 5,
+ direction => 'DESC'
+], 'All params should have been passed to search_events';
+
+my $fmt_params2 = {
+ event => $change2->is_deploy ? 'deploy' : 'revert',
+ project => $change2->project,
+ change_id => $change2->id,
+ change => $change2->name,
+ note => $change2->note,
+ tags => [ map { $_->format_name } $change2->tags ],
+ requires => [ map { $_->as_string } $change2->requires ],
+ conflicts => [ map { $_->as_string } $change2->conflicts ],
+ planned_at => $change2->timestamp,
+ planner_name => $change2->planner_name,
+ planner_email => $change2->planner_email,
+};
+
+is_deeply +MockOutput->get_page, [
+ [ $cmd->formatter->format( $cmd->format, $fmt_params ) ],
+ [ $cmd->formatter->format( $cmd->format, $fmt_params2 ) ],
+], 'Both events should have been paged without headers';
+
+# Make sure we catch bad format codes.
+isa_ok $cmd = $CLASS->new(
+ sqitch => $sqitch,
+ format => '%Z',
+), $CLASS, 'plan with bad format';
+
+push @changes, $change;
+throws_ok { $cmd->execute } 'App::Sqitch::X',
+ 'Should get an exception for a bad format code';
+is $@->ident, 'format',
+ 'bad format code format error ident should be "format"';
+is $@->message, __x(
+ 'Unknown format code "{code}"', code => 'Z',
+), 'bad format code format error message should be correct';
+
+# Gotta make sure params are parsed.
+my $mock_cmd = Test::MockModule->new($CLASS);
+my (@params, $orig_parse);
+$mock_cmd->mock(parse_args => sub {
+ my $self = shift;
+ @params = @_;
+ $self->$orig_parse(@_);
+});
+$orig_parse = $mock_cmd->original('parse_args');
+
+# Try specifying an unkonwn target.
+ok $cmd = $CLASS->new( sqitch => $sqitch, target => 'foo'),
+ 'Create plan command with unknown target option';
+throws_ok { $cmd->execute } 'App::Sqitch::X',
+ 'Should get error for unknown target';
+is $@->ident, 'target', 'Unknown target error ident should be "plan"';
+is $@->exitval, 2, 'Unknown target changes exit val should be 2';
+is $@->message, __x('Cannot find target "{target}"', target => 'foo'),
+ 'Unknown target error message should be correct';
+is_deeply \@params, [ target => 'foo', args => [] ],
+ 'Should have passed target for parsing';
+
+# Try passing an engine target.
+ok $cmd = $CLASS->new( sqitch => $sqitch),
+ 'Create plan command with target option';
+ok $cmd->execute('sqlite'), 'Execute with engine arg';
+is_deeply \@params, [ target => undef, args => [qw(sqlite)] ],
+ 'Should have passed engine for parsing';
+
+# Try both --target and arg..
+ok $cmd = $CLASS->new( sqitch => $sqitch, target => 'db:pg:'),
+ 'Create plan command with target option';
+ok $cmd->execute('sqlite'), 'Execute with multiple targets';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; using {target}',
+ target => 'db:pg:',
+)]], 'Should have got warning for two targets';
diff --git a/t/plans/bad-change.plan b/t/plans/bad-change.plan
new file mode 100644
index 00000000..6dcb5a86
--- /dev/null
+++ b/t/plans/bad-change.plan
@@ -0,0 +1,8 @@
+%project=bad_change
+# This is a note
+
+# And there was a blank line.
+what what what 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # OHNOEZ, No white space allowed!
+
+
+
diff --git a/t/plans/changes-only.plan b/t/plans/changes-only.plan
new file mode 100644
index 00000000..54d7c4f9
--- /dev/null
+++ b/t/plans/changes-only.plan
@@ -0,0 +1,8 @@
+%project=changes_only
+# This is a note
+
+# And there was a blank line.
+
+hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+whatwhatwhat 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/plans/dependencies.plan b/t/plans/dependencies.plan
new file mode 100644
index 00000000..3929dc51
--- /dev/null
+++ b/t/plans/dependencies.plan
@@ -0,0 +1,12 @@
+%project=dependencies
+
++roles 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++users [roles] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++add_user [users roles] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
++users [users@alpha] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+-dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++del_user [!dr_evil users] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
diff --git a/t/plans/deploy-and-revert.plan b/t/plans/deploy-and-revert.plan
new file mode 100644
index 00000000..e9c78392
--- /dev/null
+++ b/t/plans/deploy-and-revert.plan
@@ -0,0 +1,11 @@
+%project=deploy_and_revert
+
++hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+ + dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
++this/rocks 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+ hey-there 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+-dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # revert!
+ @bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/plans/dos.plan b/t/plans/dos.plan
new file mode 100644
index 00000000..8f6c4d16
--- /dev/null
+++ b/t/plans/dos.plan
@@ -0,0 +1,8 @@
+%project=dos
+# This is a note
+
+# And there was a blank line.
+
+hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+whatwhatwhat 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/plans/dupe-change-diff-tag.plan b/t/plans/dupe-change-diff-tag.plan
new file mode 100644
index 00000000..cda297c0
--- /dev/null
+++ b/t/plans/dupe-change-diff-tag.plan
@@ -0,0 +1,10 @@
+%project=dupe_change_diff_tag
+
+whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+hi 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+greets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/plans/dupe-change.plan b/t/plans/dupe-change.plan
new file mode 100644
index 00000000..afc676a2
--- /dev/null
+++ b/t/plans/dupe-change.plan
@@ -0,0 +1,10 @@
+%project=dupe_change
+
+whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+hi 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+greets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+tallyho 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+greets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/plans/dupe-tag.plan b/t/plans/dupe-tag.plan
new file mode 100644
index 00000000..32d63c85
--- /dev/null
+++ b/t/plans/dupe-tag.plan
@@ -0,0 +1,14 @@
+%project=dupe_tag
+
+whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+hi 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+@stink 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+@blah 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@w00t 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+OHNOEZ 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/plans/multi.plan b/t/plans/multi.plan
new file mode 100644
index 00000000..953d4081
--- /dev/null
+++ b/t/plans/multi.plan
@@ -0,0 +1,13 @@
+%project=multi
+# This is a note
+
+# And there was a blank line.
+
+hey 2012-07-16T17:25:07Z theory <t@heo.ry>
+you 2012-07-16T17:25:07Z anna <a@n.na>
+@foo 2012-07-16T17:24:07Z julie <j@ul.ie> # look, a tag!
+
+this/rocks 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+hey-there 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # trailing note!
+@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@baz 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/plans/pragmas.plan b/t/plans/pragmas.plan
new file mode 100644
index 00000000..91358e2a
--- /dev/null
+++ b/t/plans/pragmas.plan
@@ -0,0 +1,9 @@
+% syntax-version=1.0.0
+ %foo = bar # lolz
+% project=pragmata
+% uri=https://github.com/sqitchers/sqitch/
+% strict
+
+hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
diff --git a/t/plans/project_deps.plan b/t/plans/project_deps.plan
new file mode 100644
index 00000000..6cbbb9ec
--- /dev/null
+++ b/t/plans/project_deps.plan
@@ -0,0 +1,12 @@
+%project=dependencies
+
++roles 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++users [roles] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++add_user [users roles log:logger] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
++users [users@alpha] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+-dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
++del_user [!dr_evil users log:logger@beta1] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
diff --git a/t/plans/reserved-tag.plan b/t/plans/reserved-tag.plan
new file mode 100644
index 00000000..28f1cc6d
--- /dev/null
+++ b/t/plans/reserved-tag.plan
@@ -0,0 +1,10 @@
+%project=reserved_tag
+
+hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@HEAD 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+ruh-roh 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
diff --git a/t/plans/widgets.plan b/t/plans/widgets.plan
new file mode 100644
index 00000000..f5ad82f8
--- /dev/null
+++ b/t/plans/widgets.plan
@@ -0,0 +1,8 @@
+%project=widgets
+# This is a note
+
+ # And there was a blank line.
+
+hey 2012-07-16T14:01:20Z Barack Obama <potus@whitehouse.gov>
+you 2012-07-16T14:01:35Z Barack Obama <potus@whitehouse.gov>
+@foo 2012-07-16T14:02:05Z Barack Obama <potus@whitehouse.gov> # look, a tag!
diff --git a/t/pragma.t b/t/pragma.t
new file mode 100644
index 00000000..92ce6a8d
--- /dev/null
+++ b/t/pragma.t
@@ -0,0 +1,63 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 10;
+#use Test::More 'no_plan';
+use Test::NoWarnings;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Plan::Pragma';
+ require_ok $CLASS or die;
+}
+
+can_ok $CLASS, qw(
+ name
+ lspace
+ rspace
+ hspace
+ ropspace
+ lopspace
+ note
+ plan
+ value
+);
+
+my $config = TestConfig->new('core.engine' => 'sqlite');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target);
+isa_ok my $dir = $CLASS->new(
+ name => 'foo',
+ plan => $plan,
+), $CLASS;
+isa_ok $dir, 'App::Sqitch::Plan::Line';
+
+is $dir->format_name, '%foo', 'Name should format as "%foo"';
+is $dir->format_value, '', 'Value should format as ""';
+is $dir->as_string, '%foo', 'should stringify to "%foo"';
+
+ok $dir = $CLASS->new(
+ name => 'howdy',
+ value => 'woody',
+ plan => $plan,
+ lspace => ' ',
+ hspace => ' ',
+ rspace => "\t",
+ lopspace => ' ',
+ operator => '=',
+ ropspace => ' ',
+ note => 'blah blah blah',
+), 'Create pragma with more stuff';
+
+is $dir->as_string, " % howdy = woody\t# blah blah blah",
+ 'It should stringify correctly';
diff --git a/t/read.pl b/t/read.pl
new file mode 100644
index 00000000..20881c29
--- /dev/null
+++ b/t/read.pl
@@ -0,0 +1,3 @@
+use 5.010;
+
+print while <STDIN>;
diff --git a/t/rebase.t b/t/rebase.t
new file mode 100644
index 00000000..de45063d
--- /dev/null
+++ b/t/rebase.t
@@ -0,0 +1,674 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Path::Class qw(dir file);
+use App::Sqitch::X qw(hurl);
+use Locale::TextDomain qw(App-Sqitch);
+use Test::MockModule;
+use Test::Exception;
+use Test::Warn;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::rebase';
+require_ok $CLASS or die;
+
+isa_ok $CLASS, 'App::Sqitch::Command';
+can_ok $CLASS, qw(
+ target
+ options
+ configure
+ new
+ onto_change
+ upto_change
+ log_only
+ execute
+ deploy_variables
+ revert_variables
+ does
+ _collect_deploy_vars
+ _collect_revert_vars
+);
+
+ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_"
+ for qw(RevertDeployCommand ConnectingCommand ContextCommand);
+
+is_deeply [$CLASS->options], [qw(
+ onto-change|onto=s
+ upto-change|upto=s
+ plan-file|f=s
+ top-dir=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+ target|t=s
+ mode=s
+ verify!
+ set|s=s%
+ set-deploy|e=s%
+ set-revert|r=s%
+ log-only
+ y
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+);
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ verify => 0,
+ mode => 'all',
+ prompt_accept => 1,
+ _params => [],
+ _cx => [],
+}, 'Should have empty default configuration with no config or opts';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ mode => 'all',
+ deploy_variables => { foo => 'bar' },
+ revert_variables => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}, 'Should have set option';
+
+is_deeply $CLASS->configure($config, {
+ y => 1,
+ set_deploy => { foo => 'bar' },
+ log_only => 1,
+ verify => 1,
+ mode => 'tag',
+}), {
+ mode => 'tag',
+ no_prompt => 1,
+ prompt_accept => 1,
+ deploy_variables => { foo => 'bar' },
+ verify => 1,
+ log_only => 1,
+ _params => [],
+ _cx => [],
+}, 'Should have mode, deploy_variables, verify, no_prompt, and log_only';
+
+is_deeply $CLASS->configure($config, {
+ y => 0,
+ set_revert => { foo => 'bar' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ revert_variables => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}, 'Should have set_revert option and no_prompt false';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+ set_deploy => { foo => 'dep', hi => 'you' },
+ set_revert => { foo => 'rev', hi => 'me' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'dep', hi => 'you' },
+ revert_variables => { foo => 'rev', hi => 'me' },
+ _params => [],
+ _cx => [],
+}, 'set_deploy and set_revert should overrid set';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+ set_deploy => { hi => 'you' },
+ set_revert => { hi => 'me' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'bar', hi => 'you' },
+ revert_variables => { foo => 'bar', hi => 'me' },
+ _params => [],
+ _cx => [],
+}, 'set_deploy and set_revert should merge with set';
+
+is_deeply $CLASS->configure($config, {
+ set => { foo => 'bar' },
+ set_deploy => { hi => 'you' },
+ set_revert => { my => 'yo' },
+}), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'bar', hi => 'you' },
+ revert_variables => { foo => 'bar', my => 'yo' },
+ _params => [],
+ _cx => [],
+}, 'set_revert should merge with set_deploy';
+
+CONFIG: {
+ my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'deploy.variables' => { foo => 'bar', hi => 21 },
+ );
+
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ verify => 0,
+ mode => 'all',
+ prompt_accept => 1,
+ _params => [],
+ _cx => [],
+ }, 'Should have deploy configuration';
+
+ # Try setting variables.
+ is_deeply $CLASS->configure($config, {
+ onto_change => 'whu',
+ set => { foo => 'yo', yo => 'stellar' },
+ }), {
+ mode => 'all',
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ deploy_variables => { foo => 'yo', yo => 'stellar' },
+ revert_variables => { foo => 'yo', yo => 'stellar' },
+ onto_change => 'whu',
+ _params => [],
+ _cx => [],
+ }, 'Should have merged variables';
+ is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+ # Make sure we can override mode, prompting, and verify.
+ $config->replace(
+ 'core.engine' => 'sqlite',
+ 'revert.no_prompt' => 1,
+ 'revert.prompt_accept' => 0,
+ 'deploy.verify' => 1,
+ 'deploy.mode' => 'tag',
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 1,
+ prompt_accept => 0,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt true';
+
+ # Rebase option takes precendence
+ $config->update(
+ 'rebase.no_prompt' => 0,
+ 'rebase.prompt_accept' => 1,
+ 'rebase.verify' => 0,
+ 'rebase.mode' => 'change',
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 0,
+ mode => 'change',
+ _params => [],
+ _cx => [],
+ }, 'Should have false no_prompt, verify, and true prompt_accept from rebase config';
+
+ $config->update(
+ 'revert.no_prompt' => undef,
+ 'revert.prompt_accept' => undef,
+ 'rebase.verify' => undef,
+ 'rebase.mode' => undef,
+ 'rebase.no_prompt' => 1,
+ 'rebase.prompt_accept' => 0,
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 1,
+ prompt_accept => 0,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have true no_prompt, verify, and false prompt_accept from rebase from deploy';
+
+ # But option should override.
+ is_deeply $CLASS->configure($config, {y => 0, verify => 0, mode => 'all'}), {
+ no_prompt => 0,
+ verify => 0,
+ mode => 'all',
+ prompt_accept => 0,
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt, prompt_accept false and mode all again';
+
+ $config->update(
+ 'revert.no_prompt' => 0,
+ 'revert.prompt_accept' => 1,
+ 'rebase.no_prompt' => undef,
+ 'rebase.prompt_accept' => undef,
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt false and prompt_accept true for revert config';
+
+ is_deeply $CLASS->configure($config, {y => 1}), {
+ no_prompt => 1,
+ prompt_accept => 1,
+ verify => 1,
+ mode => 'tag',
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt true with -y';
+}
+
+##############################################################################
+# Test accessors.
+isa_ok my $rebase = $CLASS->new(
+ sqitch => $sqitch,
+ target => 'foo',
+), $CLASS, 'new status with target';
+is $rebase->target, 'foo', 'Should have target "foo"';
+
+isa_ok $rebase = $CLASS->new(sqitch => $sqitch), $CLASS;
+is $rebase->target, undef, 'Should have undef target';
+is $rebase->onto_change, undef, 'onto_change should be undef';
+is $rebase->upto_change, undef, 'upto_change should be undef';
+
+# Mock the engine interface.
+my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+my @dep_args;
+$mock_engine->mock(deploy => sub { shift; @dep_args = @_ });
+my @rev_args;
+$mock_engine->mock(revert => sub { shift; @rev_args = @_ });
+my @vars;
+$mock_engine->mock(set_variables => sub { shift; push @vars => [@_] });
+
+##############################################################################
+# Test _collect_deploy_vars and _collect_revert_vars.
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {},
+ 'Should collect no variables for deploy';
+is_deeply { $rebase->_collect_revert_vars($target) }, {},
+ 'Should collect no variables for revert';
+
+# Add core variables.
+$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'SELECT',
+}, 'Should collect core deploy vars for deploy';
+is_deeply { $rebase->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'SELECT',
+}, 'Should collect core revert vars for revert';
+
+# Add deploy variables.
+$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Should override core vars with deploy vars for deploy';
+
+is_deeply { $rebase->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Should override core vars with deploy vars for revert';
+
+# Add revert variables.
+$config->update('revert.variables' => { dance => 'disco', lunch => 'pizza' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Deploy vars should be unaffected by revert vars';
+is_deeply { $rebase->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'pizza',
+}, 'Should override deploy vars with revert vars for revert';
+
+# Add engine variables.
+$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey', priv => 'UP' });
+my $uri = URI::db->new('db:pg:');
+$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'whiskey',
+}, 'Should override deploy vars with engine vars for deploy';
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'whiskey',
+}, 'Should override rebase vars with engine vars for revert';
+
+# Add target variables.
+$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'winning',
+}, 'Should override engine vars with deploy vars for deploy';
+is_deeply { $rebase->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'winning',
+}, 'Should override engine vars with target vars for revert';
+
+# Add --set variables.
+my %opts = (
+ set => { status => 'tired', herb => 'oregano' },
+);
+$rebase = $CLASS->new(
+ sqitch => $sqitch,
+ %{ $CLASS->configure($config, { %opts }) },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should override target vars with --set vars for deploy';
+is_deeply { $rebase->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should override target vars with --set variables for revert';
+
+# Add --set-deploy-vars
+$opts{set_deploy} = { herb => 'basil', color => 'black' };
+$rebase = $CLASS->new(
+ sqitch => $sqitch,
+ %{ $CLASS->configure($config, { %opts }) },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'basil',
+ color => 'black',
+}, 'Should override --set vars with --set-deploy variables for deploy';
+is_deeply { $rebase->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should not override --set vars with --set-deploy variables for revert';
+
+# Add --set-revert-vars
+$opts{set_revert} = { herb => 'garlic', color => 'red' };
+$rebase = $CLASS->new(
+ sqitch => $sqitch,
+ %{ $CLASS->configure($config, { %opts }) },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $rebase->_collect_deploy_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'salsa',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'basil',
+ color => 'black',
+}, 'Should not override --set vars with --set-revert variables for deploy';
+is_deeply { $rebase->_collect_revert_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UP',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'garlic',
+ color => 'red',
+}, 'Should override --set vars with --set-revert variables for revert';
+
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+);
+$rebase = $CLASS->new( sqitch => $sqitch);
+
+##############################################################################
+# Test execute().
+my $mock_cmd = Test::MockModule->new($CLASS);
+my $orig_method;
+$mock_cmd->mock(parse_args => sub {
+ my @ret = shift->$orig_method(@_);
+ $target = $ret[0][0];
+ @ret;
+});
+$orig_method = $mock_cmd->original('parse_args');
+
+ok $rebase->execute('@alpha'), 'Execute to "@alpha"';
+is_deeply \@dep_args, [undef, 'all'],
+ 'undef, and "all" should be passed to the engine deploy';
+is_deeply \@vars, [[], []],
+ 'No vars should have been passed through to the engine';
+is_deeply \@rev_args, ['@alpha'],
+ '"@alpha" should be passed to the engine revert';
+ok !$target->engine->no_prompt, 'Engine should prompt';
+ok !$target->engine->log_only, 'Engine should no be log only';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Pass a target.
+@vars = ();
+ok $rebase->execute('db:sqlite:yow'), 'Execute with target';
+is_deeply \@dep_args, [undef, 'all'],
+ 'undef, and "all" should be passed to the engine deploy';
+is_deeply \@rev_args, [undef],
+ 'undef should be passed to the engine revert';
+is_deeply \@vars, [[], []],
+ 'No vars should have been passed through to the engine';
+ok !$target->engine->no_prompt, 'Engine should prompt';
+ok !$target->engine->log_only, 'Engine should no be log only';
+is $target->name, 'db:sqlite:yow', 'The target name should be as passed';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Pass both.
+@vars = ();
+ok $rebase->execute('db:sqlite:yow', 'widgets'), 'Execute with onto and target';
+is_deeply \@dep_args, [undef, 'all'],
+ 'undef, and "all" should be passed to the engine deploy';
+is_deeply \@rev_args, ['widgets'],
+ '"widgets" should be passed to the engine revert';
+is_deeply \@vars, [[], []],
+ 'No vars should have been passed through to the engine';
+ok !$target->engine->no_prompt, 'Engine should prompt';
+ok !$target->engine->log_only, 'Engine should no be log only';
+is $target->name, 'db:sqlite:yow', 'The target name should be as passed';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Pass all three!
+@vars = ();
+ok $rebase->execute('db:sqlite:yow', 'roles', 'widgets'),
+ 'Execute with three args';
+is_deeply \@dep_args, ['widgets', 'all'],
+ '"widgets", and "all" should be passed to the engine deploy';
+is_deeply \@rev_args, ['roles'],
+ '"roles" should be passed to the engine revert';
+is_deeply \@vars, [[], []],
+ 'No vars should have been passed through to the engine';
+ok !$target->engine->no_prompt, 'Engine should prompt';
+ok !$target->engine->log_only, 'Engine should no be log only';
+is $target->name, 'db:sqlite:yow', 'The target name should be as passed';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Pass no args.
+@vars = @dep_args = @rev_args = ();
+ok $rebase->execute, 'Execute';
+is_deeply \@dep_args, [undef, 'all'],
+ 'undef and "all" should be passed to the engine deploy';
+is_deeply \@rev_args, [undef],
+ 'undef and = should be passed to the engine revert';
+is_deeply \@vars, [[], []],
+ 'No vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Mix it up with options.
+isa_ok $rebase = $CLASS->new(
+ target => 'db:sqlite:lolwut',
+ no_prompt => 1,
+ log_only => 1,
+ verify => 1,
+ sqitch => $sqitch,
+ mode => 'tag',
+ onto_change => 'foo',
+ upto_change => 'bar',
+ deploy_variables => { foo => 'bar', one => 1 },
+ revert_variables => { hey => 'there' },
+), $CLASS, 'Object with to and variables';
+
+@vars = @dep_args = @rev_args = ();
+ok $rebase->execute, 'Execute again';
+is $target->name, 'db:sqlite:lolwut', 'Target name should be from option';
+ok $target->engine->no_prompt, 'Engine should be no_prompt';
+ok $target->engine->log_only, 'Engine should be log_only';
+ok $target->engine->with_verify, 'Engine should verify';
+is_deeply \@dep_args, ['bar', 'tag'],
+ '"bar", "tag", and 1 should be passed to the engine deploy';
+is_deeply \@rev_args, ['foo'], '"foo" and 1 should be passed to the engine revert';
+is @vars, 2, 'Variables should have been passed to the engine twice';
+is_deeply { @{ $vars[0] } }, { hey => 'there' },
+ 'The revert vars should have been passed first';
+is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 },
+ 'The deploy vars should have been next';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Make sure we get warnings for too many things.
+@dep_args = @rev_args, @vars = ();
+ok $rebase->execute('db:sqlite:yow', 'roles', 'widgets'),
+ 'Execute with three args';
+is $target->name, 'db:sqlite:lolwut', 'Target name should be from option';
+ok $target->engine->no_prompt, 'Engine should be no_prompt';
+ok $target->engine->log_only, 'Engine should be log_only';
+ok $target->engine->with_verify, 'Engine should verify';
+is_deeply \@dep_args, ['bar', 'tag'],
+ '"bar", "tag", and 1 should be passed to the engine deploy';
+is_deeply \@rev_args, ['foo'], '"foo" and 1 should be passed to the engine revert';
+is @vars, 2, 'Variables should have been passed to the engine twice';
+is_deeply { @{ $vars[0] } }, { hey => 'there' },
+ 'The revert vars should have been passed first';
+is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 },
+ 'The deploy vars should have been next';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; connecting to {target}',
+ target => 'db:sqlite:lolwut',
+)], [__x(
+ 'Too many changes specified; rebasing onto "{onto}" up to "{upto}"',
+ onto => 'foo',
+ upto => 'bar',
+)]], 'Should have two warnings';
+
+# Make sure we get an exception for unknown args.
+throws_ok { $rebase->execute(qw(greg)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown arg';
+is $@->ident, 'rebase', 'Unknow arg ident should be "rebase"';
+is $@->message, __x(
+ 'Unknown argument "{arg}"',
+ arg => 'greg',
+), 'Should get an exeption for two unknown arg';
+
+throws_ok { $rebase->execute(qw(greg jon)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown args';
+is $@->ident, 'rebase', 'Unknow args ident should be "rebase"';
+is $@->message, __x(
+ 'Unknown arguments: {arg}',
+ arg => 'greg, jon',
+), 'Should get an exeption for two unknown args';
+
+# If nothing is deployed, or we are already at the revert target, the revert
+# should be skipped.
+@dep_args = @rev_args = @vars = ();
+$mock_engine->mock(revert => sub { hurl { ident => 'revert', message => 'foo', exitval => 1 } });
+ok $rebase->execute, 'Execute once more';
+is_deeply \@dep_args, ['bar', 'tag'],
+ '"bar", "tag", and 1 should be passed to the engine deploy';
+is @vars, 2, 'Variables should have been passed to the engine twice';
+is_deeply { @{ $vars[0] } }, { hey => 'there' },
+ 'The revert vars should have been passed first';
+is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 },
+ 'The deploy vars should have been next';
+is_deeply +MockOutput->get_info, [['foo']],
+ 'Should have emitted info for non-fatal revert exception';
+
+# Should die for fatal, unknown, or confirmation errors.
+for my $spec (
+ [ confirm => App::Sqitch::X->new(ident => 'revert:confirm', message => 'foo', exitval => 1) ],
+ [ fatal => App::Sqitch::X->new(ident => 'revert', message => 'foo', exitval => 2) ],
+ [ unknown => bless { } => __PACKAGE__ ],
+) {
+ $mock_engine->mock(revert => sub { die $spec->[1] });
+ throws_ok { $rebase->execute } ref $spec->[1],
+ "Should rethrow $spec->[0] exception";
+}
+
+done_testing;
diff --git a/t/revert.t b/t/revert.t
new file mode 100644
index 00000000..2bd0bd0d
--- /dev/null
+++ b/t/revert.t
@@ -0,0 +1,347 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Path::Class qw(dir file);
+use Test::MockModule;
+use Test::Exception;
+use Test::Warn;
+use Locale::TextDomain qw(App-Sqitch);
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::revert';
+require_ok $CLASS or die;
+
+isa_ok $CLASS, 'App::Sqitch::Command';
+can_ok $CLASS, qw(
+ target
+ options
+ configure
+ new
+ to_change
+ log_only
+ execute
+ variables
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_"
+ for qw(ContextCommand ConnectingCommand);
+
+is_deeply [$CLASS->options], [qw(
+ target|t=s
+ to-change|to|change=s
+ set|s=s%
+ log-only
+ y
+ plan-file|f=s
+ top-dir=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+);
+my $sqitch = App::Sqitch->new(config => $config);
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ _params => [],
+ _cx => [],
+}, 'Should have empty default configuration with no config or opts';
+
+is_deeply $CLASS->configure($config, {
+ y => 1,
+ set => { foo => 'bar' },
+}), {
+ no_prompt => 1,
+ prompt_accept => 1,
+ variables => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}, 'Should have set option';
+
+CONFIG: {
+ my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'revert.variables' => { foo => 'bar', hi => 21 },
+ );
+
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt false, prompt_accept true';
+
+ # Make sure we can override prompting.
+ $config->update(
+ 'revert.no_prompt' => 1,
+ 'revert.prompt_accept' => 0,
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 1,
+ prompt_accept => 0,
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt true, prompt_accept false';
+
+ # But option should override.
+ is_deeply $CLASS->configure($config, {y => 0}), {
+ no_prompt => 0,
+ prompt_accept => 0,
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt false again';
+
+ $config->update(
+ 'revert.no_prompt' => 0,
+ 'revert.prompt_accept' => 1,
+ );
+ is_deeply $CLASS->configure($config, {}), {
+ no_prompt => 0,
+ prompt_accept => 1,
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt false for false config';
+
+ is_deeply $CLASS->configure($config, {y => 1}), {
+ no_prompt => 1,
+ prompt_accept => 1,
+ _params => [],
+ _cx => [],
+ }, 'Should have no_prompt true with -y';
+}
+
+##############################################################################
+# Test construction.
+isa_ok my $revert = $CLASS->new(
+ sqitch => $sqitch,
+ target => 'foo',
+ no_prompt => 1,
+), $CLASS, 'new revert with target';
+is $revert->target, 'foo', 'Should have target "foo"';
+is $revert->to_change, undef, 'to_change should be undef';
+isa_ok $revert = $CLASS->new(sqitch => $sqitch, no_prompt => 1), $CLASS;
+is $revert->target, undef, 'Should have undef default target';
+is $revert->to_change, undef, 'to_change should be undef';
+
+##############################################################################
+# Test _collect_vars.
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $revert->_collect_vars($target) }, {}, 'Should collect no variables';
+
+# Add core variables.
+$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $revert->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'SELECT',
+}, 'Should collect core vars';
+
+# Add deploy variables.
+$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $revert->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Should override core vars with deploy vars';
+
+# Add revert variables.
+$config->update('revert.variables' => { dance => 'disco', lunch => 'pizza' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $revert->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'pizza',
+}, 'Should override deploy vars with revert vars';
+
+# Add engine variables.
+$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey' });
+my $uri = URI::db->new('db:pg:');
+$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri);
+is_deeply { $revert->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'whiskey',
+}, 'Should override revert vars with engine vars';
+
+# Add target variables.
+$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $revert->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'winning',
+}, 'Should override engine vars with target vars';
+
+# Add --set variables.
+$revert = $CLASS->new(
+ sqitch => $sqitch,
+ variables => { status => 'tired', herb => 'oregano' },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $revert->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should override target vars with --set variables';
+
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+);
+$revert = $CLASS->new( sqitch => $sqitch, no_prompt => 1);
+
+##############################################################################
+# Test execution.
+# Mock the engine interface.
+my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+my @args;
+$mock_engine->mock(revert => sub { shift; @args = @_ });
+my @vars;
+$mock_engine->mock(set_variables => sub { shift; @vars = @_ });
+
+my $mock_cmd = Test::MockModule->new($CLASS);
+my $orig_method;
+$mock_cmd->mock(parse_args => sub {
+ my @ret = shift->$orig_method(@_);
+ $target = $ret[0][0];
+ @ret;
+});
+$orig_method = $mock_cmd->original('parse_args');
+
+# Pass the change.
+ok $revert->execute('@alpha'), 'Execute to "@alpha"';
+ok $target->engine->no_prompt, 'Engine should be no_prompt';
+ok !$target->engine->log_only, 'Engine should not be log_only';
+is_deeply \@args, ['@alpha'],
+ '"@alpha" should be passed to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Pass nothing.
+@args = ();
+ok $revert->execute, 'Execute';
+is_deeply \@args, [undef],
+ 'undef should be passed to the engine';
+is_deeply {@vars}, { },
+ 'No vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should still have no warnings';
+
+# Pass the target.
+ok $revert->execute('db:sqlite:hi'), 'Execute to target';
+ok $target->engine->no_prompt, 'Engine should be no_prompt';
+ok !$target->engine->log_only, 'Engine should not be log_only';
+is_deeply \@args, [undef],
+ 'undef" should be passed to the engine';
+is $target->name, 'db:sqlite:hi', 'Target name should be as passed';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Pass them both!
+ok $revert->execute('db:sqlite:lol', 'widgets'), 'Execute with change and target';
+ok $target->engine->no_prompt, 'Engine should be no_prompt';
+ok !$target->engine->log_only, 'Engine should not be log_only';
+is_deeply \@args, ['widgets'],
+ '"widgets" should be passed to the engine';
+is $target->name, 'db:sqlite:lol', 'Target name should be as passed';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# And reverse them.
+ok $revert->execute('db:sqlite:lol', 'widgets'), 'Execute with target and change';
+ok $target->engine->no_prompt, 'Engine should be no_prompt';
+ok !$target->engine->log_only, 'Engine should not be log_only';
+is_deeply \@args, ['widgets'],
+ '"widgets" should be passed to the engine';
+is $target->name, 'db:sqlite:lol', 'Target name should be as passed';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Now specify options.
+isa_ok $revert = $CLASS->new(
+ sqitch => $sqitch,
+ target => 'db:sqlite:welp',
+ to_change => 'foo',
+ log_only => 1,
+ variables => { foo => 'bar', one => 1 },
+), $CLASS, 'Object with to and variables';
+
+@args = ();
+ok $revert->execute, 'Execute again';
+ok !$target->engine->no_prompt, 'Engine should not be no_prompt';
+ok $target->engine->log_only, 'Engine should be log_only';
+is_deeply \@args, ['foo'],
+ '"foo" and 1 should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is $target->name, 'db:sqlite:welp', 'Target name should be from option';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Try also passing the target and change.
+ok $revert->execute('db:sqlite:lol', '@alpha'), 'Execute with options and args';
+ok !$target->engine->no_prompt, 'Engine should not be no_prompt';
+ok $target->engine->log_only, 'Engine should be log_only';
+is_deeply \@args, ['foo'],
+ '"foo" and 1 should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is $target->name, 'db:sqlite:welp', 'Target name should be from option';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; connecting to {target}',
+ target => 'db:sqlite:welp',
+)], [__x(
+ 'Too many changes specified; reverting to "{change}"',
+ change => 'foo',
+)]], 'Should have two warnings';
+
+# Make sure we get an exception for unknown args.
+throws_ok { $revert->execute(qw(greg)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown arg';
+is $@->ident, 'revert', 'Unknow arg ident should be "revert"';
+is $@->message, __x(
+ 'Unknown argument "{arg}"',
+ arg => 'greg',
+), 'Should get an exeption for two unknown arg';
+
+throws_ok { $revert->execute(qw(greg jon)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown args';
+is $@->ident, 'revert', 'Unknow args ident should be "revert"';
+is $@->message, __x(
+ 'Unknown arguments: {arg}',
+ arg => 'greg, jon',
+), 'Should get an exeption for two unknown args';
+
+done_testing;
diff --git a/t/rework.conf b/t/rework.conf
new file mode 100644
index 00000000..38f726e1
--- /dev/null
+++ b/t/rework.conf
@@ -0,0 +1,2 @@
+[rework]
+ open_editor = true
diff --git a/t/rework.t b/t/rework.t
new file mode 100644
index 00000000..d252f370
--- /dev/null
+++ b/t/rework.t
@@ -0,0 +1,978 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 234;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::Exception;
+use Test::Warn;
+use App::Sqitch::Command::add;
+use Path::Class;
+use Test::File qw(file_not_exists_ok file_exists_ok);
+use Test::File::Contents qw(file_contents_identical file_contents_is files_eq);
+use File::Path qw(make_path remove_tree);
+use Test::NoWarnings;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::rework';
+my $test_dir = dir 'test-rework';
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => $test_dir->stringify,
+);
+ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object';
+
+isa_ok my $rework = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'rework',
+ config => $config,
+}), $CLASS, 'rework command';
+my $target = $rework->default_target;
+
+sub dep($) {
+ my $dep = App::Sqitch::Plan::Depend->new(
+ conflicts => 0,
+ %{ App::Sqitch::Plan::Depend->parse(shift) },
+ plan => $rework->default_target->plan,
+ );
+ $dep->project;
+ return $dep;
+}
+
+can_ok $CLASS, qw(
+ change_name
+ requires
+ conflicts
+ note
+ execute
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::ContextCommand"),
+ "$CLASS does ContextCommand";
+
+is_deeply [$CLASS->options], [qw(
+ change-name|change|c=s
+ requires|r=s@
+ conflicts|x=s@
+ all|a!
+ note|n|m=s@
+ open-editor|edit|e!
+ plan-file|f=s
+ top-dir=s
+)], 'Options should be set up';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), { _cx => [] },
+ 'Should have default configuration with no config or opts';
+
+is_deeply $CLASS->configure($config, {
+ requires => [qw(foo bar)],
+ conflicts => ['baz'],
+ note => [qw(hi there)],
+}), {
+ requires => [qw(foo bar)],
+ conflicts => ['baz'],
+ note => [qw(hi there)],
+ _cx => [],
+}, 'Should have get requires, conflicts, and note options';
+
+# open_editor handling
+CONFIG: {
+ my $config = TestConfig->from(local => File::Spec->catfile(qw(t rework.conf)));
+ is_deeply $CLASS->configure($config, {}), { _cx => []},
+ 'Grabs nothing from config';
+
+ ok my $sqitch = App::Sqitch->new(config => $config), 'Load Sqitch project';
+ isa_ok my $rework = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'rework',
+ config => $config,
+ }), $CLASS, 'rework command';
+ ok $rework->open_editor, 'Coerces rework.open_editor from config string boolean';
+}
+
+##############################################################################
+# Test attributes.
+is_deeply $rework->requires, [], 'Requires should be an arrayref';
+is_deeply $rework->conflicts, [], 'Conflicts should be an arrayref';
+is_deeply $rework->note, [], 'Note should be an arrayref';
+
+##############################################################################
+# Test execute().
+make_path $test_dir->stringify;
+END { remove_tree $test_dir->stringify if -e $test_dir->stringify };
+my $plan_file = $target->plan_file;
+my $fh = $plan_file->open('>') or die "Cannot open $plan_file: $!";
+say $fh "%project=empty\n\n";
+$fh->close or die "Error closing $plan_file: $!";
+
+my $plan = $target->plan;
+
+throws_ok { $rework->execute('foo') } 'App::Sqitch::X',
+ 'Should get an example for nonexistent change';
+is $@->ident, 'plan', 'Nonexistent change error ident should be "plan"';
+is $@->message, __x(
+ qq{Change "{change}" does not exist in {file}.\n}
+ . 'Use "sqitch add {change}" to add it to the plan',
+ change => 'foo',
+ file => $plan->file,
+), 'Fail message should say the step does not exist';
+
+# Use the add command to create a step.
+my $deploy_file = file qw(test-rework deploy foo.sql);
+my $revert_file = file qw(test-rework revert foo.sql);
+my $verify_file = file qw(test-rework verify foo.sql);
+
+my $change_mocker = Test::MockModule->new('App::Sqitch::Plan::Change');
+my %request_params;
+$change_mocker->mock(request_note => sub {
+ my $self = shift;
+ %request_params = @_;
+ return $self->note;
+});
+
+# Use the same plan.
+my $mock_plan = Test::MockModule->new(ref $target);
+$mock_plan->mock(plan => $plan);
+
+ok my $add = App::Sqitch::Command::add->new(
+ sqitch => $sqitch,
+ change_name => 'foo',
+ template_directory => Path::Class::dir(qw(etc templates))
+), 'Create another add with template_directory';
+file_not_exists_ok($_) for ($deploy_file, $revert_file, $verify_file);
+ok $add->execute, 'Execute with the --change option';
+file_exists_ok($_) for ($deploy_file, $revert_file, $verify_file);
+ok my $foo = $plan->get('foo'), 'Get the "foo" change';
+
+throws_ok { $rework->execute('foo') } 'App::Sqitch::X',
+ 'Should get an example for duplicate change';
+is $@->ident, 'plan', 'Duplicate change error ident should be "plan"';
+is $@->message, __x(
+ qq{Cannot rework "{change}" without an intervening tag.\n}
+ . 'Use "sqitch tag" to create a tag and try again',
+ change => 'foo',
+), 'Fail message should say a tag is needed';
+
+# Tag it, and *then* it should work.
+ok $plan->tag( name => '@alpha' ), 'Tag it';
+
+my $deploy_file2 = file qw(test-rework deploy foo@alpha.sql);
+my $revert_file2 = file qw(test-rework revert foo@alpha.sql);
+my $verify_file2 = file qw(test-rework verify foo@alpha.sql);
+MockOutput->get_info;
+
+file_not_exists_ok($_) for ($deploy_file2, $revert_file2, $verify_file2);
+ok $rework->execute('foo'), 'Rework "foo"';
+
+# The files should have been copied.
+file_exists_ok($_) for ($deploy_file, $revert_file, $verify_file);
+file_exists_ok($_) for ($deploy_file2, $revert_file2, $verify_file2);
+file_contents_identical($deploy_file2, $deploy_file);
+file_contents_identical($verify_file2, $verify_file);
+file_contents_identical($revert_file, $deploy_file);
+file_contents_is($revert_file2, <<'EOF', 'New revert should revert');
+-- Revert empty:foo from sqlite
+
+BEGIN;
+
+-- XXX Add DDLs here.
+
+COMMIT;
+EOF
+
+# The note should have been required.
+is_deeply \%request_params, {
+ for => __ 'rework',
+ scripts => [$deploy_file, $revert_file, $verify_file],
+}, 'It should have prompted for a note';
+
+# The plan file should have been updated.
+ok $plan->load, 'Reload the plan file';
+ok my @steps = $plan->changes, 'Get the steps';
+is @steps, 2, 'Should have two steps';
+is $steps[0]->name, 'foo', 'First step should be "foo"';
+is $steps[1]->name, 'foo', 'Second step should also be "foo"';
+is_deeply [$steps[1]->requires], [dep 'foo@alpha'],
+ 'Reworked step should require the previous step';
+
+is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'foo [foo@alpha]',
+ file => $target->plan_file,
+ )],
+ [__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ 3,
+ )],
+ [" * $deploy_file"],
+ [" * $revert_file"],
+ [" * $verify_file"],
+], 'And the info message should suggest editing the old files';
+is_deeply +MockOutput->get_debug, [
+ [__x(
+ 'Copied {src} to {dest}',
+ dest => $deploy_file2,
+ src => $deploy_file,
+ )],
+ [__x(
+ 'Copied {src} to {dest}',
+ dest => $revert_file2,
+ src => $revert_file,
+ )],
+ [__x(
+ 'Copied {src} to {dest}',
+ dest => $verify_file2,
+ src => $verify_file,
+ )],
+ [__x(
+ 'Copied {src} to {dest}',
+ dest => $revert_file,
+ src => $deploy_file,
+ )],
+], 'Debug should show file copying';
+
+##############################################################################
+# Let's do that again. This time with more dependencies and fewer files.
+$deploy_file = file qw(test-rework deploy bar.sql);
+$revert_file = file qw(test-rework revert bar.sql);
+$verify_file = file qw(test-rework verify bar.sql);
+ok $add = App::Sqitch::Command::add->new(
+ sqitch => $sqitch,
+ template_directory => Path::Class::dir(qw(etc templates)),
+ with_scripts => { revert => 0, verify => 0 },
+), 'Create another add with template_directory';
+file_not_exists_ok($_) for ($deploy_file, $revert_file, $verify_file);
+$add->execute('bar');
+file_exists_ok($deploy_file);
+file_not_exists_ok($_) for ($revert_file, $verify_file);
+ok $plan->tag( name => '@beta' ), 'Tag it with @beta';
+
+my $deploy_file3 = file qw(test-rework deploy bar@beta.sql);
+my $revert_file3 = file qw(test-rework revert bar@beta.sql);
+my $verify_file3 = file qw(test-rework verify bar@beta.sql);
+MockOutput->get_info;
+
+isa_ok $rework = App::Sqitch::Command::rework->new(
+ sqitch => $sqitch,
+ command => 'rework',
+ config => $config,
+ requires => ['foo'],
+ note => [qw(hi there)],
+ conflicts => ['dr_evil'],
+), $CLASS, 'rework command with requirements and conflicts';
+
+# Check the files.
+file_not_exists_ok($_) for ($deploy_file3, $revert_file3, $verify_file3);
+ok $rework->execute('bar'), 'Rework "bar"';
+file_exists_ok($deploy_file);
+file_not_exists_ok($_) for ($revert_file, $verify_file);
+file_exists_ok($deploy_file3);
+file_not_exists_ok($_) for ($revert_file3, $verify_file3);
+
+# The note should have been required.
+is_deeply \%request_params, {
+ for => __ 'rework',
+ scripts => [$deploy_file],
+}, 'It should have prompted for a note';
+
+# The plan file should have been updated.
+ok $plan->load, 'Reload the plan file again';
+ok @steps = $plan->changes, 'Get the steps';
+is @steps, 4, 'Should have four steps';
+is $steps[0]->name, 'foo', 'First step should be "foo"';
+is $steps[1]->name, 'foo', 'Second step should also be "foo"';
+is $steps[2]->name, 'bar', 'First step should be "bar"';
+is $steps[3]->name, 'bar', 'Second step should also be "bar"';
+is_deeply [$steps[3]->requires], [dep 'bar@beta', dep 'foo'],
+ 'Requires should have been passed to reworked change';
+is_deeply [$steps[3]->conflicts], [dep '!dr_evil'],
+ 'Conflicts should have been passed to reworked change';
+is $steps[3]->note, "hi\n\nthere",
+ 'Note should have been passed as comment';
+
+is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'bar [bar@beta foo !dr_evil]',
+ file => $target->plan_file,
+ )],
+ [__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ 1,
+ )],
+ [" * $deploy_file"],
+], 'And the info message should show only the one file to modify';
+
+is_deeply +MockOutput->get_debug, [
+ [__x(
+ 'Copied {src} to {dest}',
+ dest => $deploy_file3,
+ src => $deploy_file,
+ )],
+ [__x(
+ 'Skipped {dest}: {src} does not exist',
+ dest => $revert_file3,
+ src => $revert_file,
+ )],
+ [__x(
+ 'Skipped {dest}: {src} does not exist',
+ dest => $verify_file3,
+ src => $verify_file,
+ )],
+ [__x(
+ 'Skipped {dest}: {src} does not exist',
+ dest => $revert_file,
+ src => $revert_file3, # No previous revert, no need for new revert.
+ )],
+], 'Should have debug oputput for missing files';
+
+# Make sure --open-editor works
+MOCKSHELL: {
+ my $sqitch_mocker = Test::MockModule->new('App::Sqitch');
+ my $shell_cmd;
+ $sqitch_mocker->mock(shell => sub { $shell_cmd = $_[1] });
+ $sqitch_mocker->mock(quote_shell => sub { shift; join ' ' => @_ });
+
+ ok $rework = $CLASS->new(
+ sqitch => $sqitch,
+ template_directory => Path::Class::dir(qw(etc templates)),
+ note => ['Testing --open-editor'],
+ open_editor => 1,
+ ), 'Create another add with open_editor';
+
+ ok $plan->tag( name => '@gamma' ), 'Tag it';
+
+ my $rework_file = file qw(test-rework deploy bar.sql);
+ my $deploy_file = file qw(test-rework deploy bar@gamma.sql);
+ my $revert_file = file qw(test-rework revert bar@gamma.sql);
+ my $verify_file = file qw(test-rework verify bar@gamma.sql);
+ MockOutput->get_info;
+
+ file_not_exists_ok($_) for ($deploy_file, $revert_file, $verify_file);
+ ok $rework->execute('bar'), 'Rework "bar"';
+
+ # The files should have been copied.
+ file_exists_ok($_) for ($rework_file, $deploy_file);
+ file_not_exists_ok($_) for ($revert_file, $verify_file);
+
+ is $shell_cmd, join(' ', $sqitch->editor, $rework_file),
+ 'It should have prompted to edit sql files';
+
+ is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'bar [bar@gamma]',
+ file => $target->plan_file,
+ )],
+ [__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ 1,
+ )],
+ [" * $rework_file"],
+ ], 'And the info message should suggest editing the old files';
+ MockOutput->get_debug; # empty debug.
+};
+
+# Make sure a configuration with multiple plans works.
+$mock_plan->unmock('plan');
+MULTIPLAN: {
+ my $dstring = $test_dir->stringify;
+ remove_tree $dstring;
+ make_path $dstring;
+ END { remove_tree $dstring if -e $dstring };
+ chdir $dstring;
+
+ my $conf = file 'multirework.conf';
+ $conf->spew(join "\n",
+ '[core]',
+ 'engine = pg',
+ '[engine "pg"]',
+ 'top_dir = pg',
+ '[engine "sqlite"]',
+ 'top_dir = sqlite',
+ '[engine "mysql"]',
+ 'top_dir = mysql',
+ );
+
+ # Create plan files and determine the scripts that to be created.
+ my %scripts = map {
+ my $dir = dir $_;
+ $dir->mkpath;
+ $dir->file('sqitch.plan')->spew(join "\n",
+ '%project=rework', '',
+ 'widgets 2012-07-16T17:25:07Z anna <a@n.na>',
+ 'gadgets 2012-07-16T18:25:07Z anna <a@n.na>',
+ '@foo 2012-07-16T17:24:07Z julie <j@ul.ie>', '',
+ );
+
+ # Make the script files.
+ my (@change, @reworked);
+ for my $type (qw(deploy revert verify)) {
+ my $subdir = $dir->subdir($type);
+ $subdir->mkpath;
+ my $script = $subdir->file('widgets.sql');
+ $script->spew("-- $subdir widgets");
+ push @change => $script;
+ push @reworked => $subdir->file('widgets@foo.sql');
+ }
+
+ # Return the scripts.
+ $_ => { change => \@change, reworked => \@reworked };
+ } qw(pg sqlite mysql);
+
+ # Load up the configuration for this project.
+ my $config = TestConfig->from(local => $conf);
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my $rework = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple plans'],
+ all => 1,
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create another rework with custom multiplan config';
+
+ my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is @targets, 3, 'Should have three targets';
+
+ # Make sure the target list matches our script list order (by engine).
+ # pg always comes first, as primary engine, but the other two are random.
+ push @targets, splice @targets, 1, 1 if $targets[1]->engine_key ne 'sqlite';
+
+ # Let's do this thing!
+ ok $rework->execute('widgets'), 'Rework change "widgets" in all plans';
+ for my $target(@targets) {
+ my $ekey = $target->engine_key;
+ ok my $head = $target->plan->get('widgets@HEAD'),
+ "Get widgets\@HEAD from the $ekey plan";
+ ok my $foo = $target->plan->get('widgets@foo'),
+ "Get widgets\@foo from the $ekey plan";
+ cmp_ok $head->id, 'ne', $foo->id,
+ "The two $ekey widgets should be different changes";
+ }
+
+ # All the files should exist, now.
+ while (my ($k, $v) = each %scripts) {
+ file_exists_ok $_ for map { @{ $v->{$_} } } qw(change reworked);
+ # Deploy and verify files should be the same.
+ files_eq $v->{change}[0], $v->{reworked}[0];
+ files_eq $v->{change}[2], $v->{reworked}[2];
+ # New revert should be the same as old deploy.
+ files_eq $v->{change}[1], $v->{reworked}[0];
+ }
+
+ # Make sure we see the proper output.
+ my $info = MockOutput->get_info;
+ my $note = $request_params{scripts};
+ my $ekey = $targets[1]->engine_key;
+ if ($info->[1][0] !~ /$ekey/) {
+ # Got the targets in a different order. So reorder results to match.
+ ($info->[1], $info->[2]) = ($info->[2], $info->[1]);
+ push @{ $info } => splice @{ $info }, 7, 3;
+ push @{ $note } => splice @{ $note }, 3, 3;
+ }
+ is_deeply $note, [map { @{ $scripts{$_}{change} }} qw(pg sqlite mysql)],
+ 'Should have listed the files in the note prompt';
+ is_deeply $info, [
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@foo]',
+ file => $targets[0]->plan_file,
+ )],
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@foo]',
+ file => $targets[1]->plan_file,
+ )],
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@foo]',
+ file => $targets[2]->plan_file,
+ )],
+ [__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ 3,
+ )],
+ map {
+ map { [" * $_" ] } @{ $scripts{$_}{change} }
+ } qw(pg sqlite mysql)
+ ], 'And the info message should show the two files to modify';
+
+ my $debug = +MockOutput->get_debug;
+ if ($debug->[4][0] !~ /$ekey/) {
+ # Got the targets in a different order. So reorder results to match.
+ push @{ $debug } => splice @{ $debug }, 4, 4;
+ }
+ is_deeply $debug, [
+ map {
+ my ($c, $r) = @{ $scripts{$_} }{qw(change reworked)};
+ (
+ map { [__x(
+ 'Copied {src} to {dest}',
+ src => $c->[$_],
+ dest => $r->[$_],
+ )] } (0..2)
+ ),
+ [__x(
+ 'Copied {src} to {dest}',
+ src => $c->[0],
+ dest => $c->[1],
+ )]
+ } qw(pg sqlite mysql)
+ ], 'Should have debug oputput for all copied files';
+
+ # # Make sure we get an error using --all and a target arg.
+ throws_ok { $rework->execute('foo', 'pg' ) } 'App::Sqitch::X',
+ 'Should get an error for --all and a target arg';
+ is $@->ident, 'rework', 'Mixed arguments error ident should be "rework"';
+ is $@->message, __(
+ 'Cannot specify both --all and engine, target, or plan arugments'
+ ), 'Mixed arguments error message should be correct';
+
+ # # Now try reworking a change to just one engine. Remove --all
+ %scripts = map {
+ my $dir = dir $_;
+ $dir->mkpath;
+
+ # Make the script files.
+ my (@change, @reworked);
+ for my $type (qw(deploy revert verify)) {
+ my $subdir = $dir->subdir($type);
+ $subdir->mkpath;
+ my $script = $subdir->file('gadgets.sql');
+ $script->spew("-- $subdir gadgets");
+ push @change => $script;
+ # Only SQLite is reworked.
+ push @reworked => $subdir->file('gadgets@foo.sql')
+ if $_ eq 'sqlite';
+ }
+
+ # Return the scripts.
+ $_ => { change => \@change, reworked => \@reworked };
+ } qw(pg sqlite mysql);
+
+ ok $rework = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple plans'],
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create yet another rework with custom multiplan config';
+
+ ok $rework->execute('gadgets', 'sqlite'),
+ 'Rework change "gadgets" in the sqlite plan';
+ my %targets = map { $_->engine_key => $_ }
+ App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is keys %targets, 3, 'Should still have three targets';
+ my $name = 'gadgets@foo';
+ for my $ekey(qw(pg mysql)) {
+ my $target = $targets{$ekey};
+ ok my $head = $target->plan->get('gadgets@HEAD'),
+ "Get gadgets\@HEAD from the $ekey plan";
+ ok my $foo = $target->plan->get('gadgets@foo'),
+ "Get gadgets\@foo from the $ekey plan";
+ cmp_ok $head->id, 'eq', $foo->id,
+ "The two $ekey gadgets should be the same change";
+ }
+ do {
+ my $ekey = 'sqlite';
+ my $target = $targets{$ekey};
+ ok my $head = $target->plan->get('gadgets@HEAD'),
+ "Get gadgets\@HEAD from the $ekey plan";
+ ok my $foo = $target->plan->get('gadgets@foo'),
+ "Get gadgets\@foo from the $ekey plan";
+ cmp_ok $head->id, 'ne', $foo->id,
+ "The two $ekey gadgets should be different changes";
+ };
+
+ # All the files should exist, now.
+ while (my ($k, $v) = each %scripts) {
+ file_exists_ok $_ for map { @{ $v->{$_} } } qw(change reworked);
+ next if $k ne 'sqlite';
+ # Deploy and verify files should be the same.
+ files_eq $v->{change}[0], $v->{reworked}[0];
+ files_eq $v->{change}[2], $v->{reworked}[2];
+ # New revert should be the same as old deploy.
+ files_eq $v->{change}[1], $v->{reworked}[0];
+ }
+
+ is_deeply \%request_params, {
+ for => __ 'rework',
+ scripts => $scripts{sqlite}{change},
+ }, 'Should have listed SQLite scripts in the note prompt';
+
+ # Clear the output.
+ MockOutput->get_info;
+ MockOutput->get_debug;
+ chdir File::Spec->updir;
+}
+
+# Make sure we update only one plan but write out multiple target files.
+MULTITARGET: {
+ my $dstring = $test_dir->stringify;
+ remove_tree $dstring;
+ make_path $dstring;
+ END { remove_tree $dstring if -e $dstring };
+ chdir $dstring;
+
+ my $conf = file 'multiadd.conf';
+ $conf->spew(join "\n",
+ '[core]',
+ 'engine = pg',
+ 'plan_file = sqitch.plan',
+ '[engine "pg"]',
+ 'top_dir = pg',
+ '[engine "sqlite"]',
+ 'top_dir = sqlite',
+ '[add]',
+ 'all = true',
+ );
+ file('sqitch.plan')->spew(join "\n",
+ '%project=rework', '',
+ 'widgets 2012-07-16T17:25:07Z anna <a@n.na>',
+ 'gadgets 2012-07-16T18:25:07Z anna <a@n.na>',
+ '@foo 2012-07-16T17:24:07Z julie <j@ul.ie>', '',
+ );
+
+ # Create the scripts.
+ my %scripts = map {
+ my $dir = dir $_;
+ my (@change, @reworked);
+ for my $type (qw(deploy revert verify)) {
+ my $subdir = $dir->subdir($type);
+ $subdir->mkpath;
+ my $script = $subdir->file('widgets.sql');
+ $script->spew("-- $subdir widgets");
+ push @change => $script;
+ push @reworked => $subdir->file('widgets@foo.sql');
+ }
+
+ # Return the scripts.
+ $_ => { change => \@change, reworked => \@reworked };
+ } qw(pg sqlite);
+
+ # Load up the configuration for this project.
+ $config = TestConfig->from(local => $conf);
+ $sqitch = App::Sqitch->new(config => $config);
+ ok my $rework = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple plans'],
+ all => 1,
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create another rework with custom multiplan config';
+
+ my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is @targets, 2, 'Should have two targets';
+ is $targets[0]->plan_file, $targets[1]->plan_file,
+ 'Targets should use the same plan file';
+ my $target = $targets[0];
+
+ # Let's do this thing!
+ ok $rework->execute('widgets'), 'Rework change "widgets" in all plans';
+
+ ok my $head = $target->plan->get('widgets@HEAD'),
+ "Get widgets\@HEAD from the plan";
+ ok my $foo = $target->plan->get('widgets@foo'),
+ "Get widgets\@foo from the plan";
+ cmp_ok $head->id, 'ne', $foo->id,
+ "The two widgets should be different changes";
+
+ # All the files should exist, now.
+ while (my ($k, $v) = each %scripts) {
+ file_exists_ok $_ for map { @{ $v->{$_} } } qw(change reworked);
+ # Deploy and verify files should be the same.
+ files_eq $v->{change}[0], $v->{reworked}[0];
+ files_eq $v->{change}[2], $v->{reworked}[2];
+ # New revert should be the same as old deploy.
+ files_eq $v->{change}[1], $v->{reworked}[0];
+ }
+
+ is_deeply \%request_params, {
+ for => __ 'rework',
+ scripts => [ map {@{ $scripts{$_}{change} }} qw(pg sqlite)],
+ }, 'Should have listed all the files to edit in the note prompt';
+
+ # And the output should be correct.
+ is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@foo]',
+ file => $target->plan_file,
+ )],
+ [__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ 3,
+ )],
+ map {
+ map { [" * $_" ] } @{ $scripts{$_}{change} }
+ } qw(pg sqlite)
+ ], 'And the info message should show the two files to modify';
+
+ # As should the debug output
+ is_deeply +MockOutput->get_debug, [
+ map {
+ my ($c, $r) = @{ $scripts{$_} }{qw(change reworked)};
+ (
+ map { [__x(
+ 'Copied {src} to {dest}',
+ src => $c->[$_],
+ dest => $r->[$_],
+ )] } (0..2)
+ ),
+ [__x(
+ 'Copied {src} to {dest}',
+ src => $c->[0],
+ dest => $c->[1],
+ )]
+ } qw(pg sqlite)
+ ], 'Should have debug oputput for all copied files';
+
+ chdir File::Spec->updir;
+}
+
+# Try two plans with different tags.
+MULTITAG: {
+ my $dstring = $test_dir->stringify;
+ remove_tree $dstring;
+ make_path $dstring;
+ END { remove_tree $dstring if -e $dstring };
+ chdir $test_dir->stringify;
+
+ my $conf = file 'multirework.conf';
+ $conf->spew(join "\n",
+ '[core]',
+ 'engine = pg',
+ '[engine "pg"]',
+ 'top_dir = pg',
+ '[engine "sqlite"]',
+ 'top_dir = sqlite',
+ );
+
+ # Create plan files and determine the scripts that to be created.
+ my %scripts = map {
+ my $dir = dir $_;
+ $dir->mkpath;
+ my $tag = $_ eq 'pg' ? 'foo' : 'bar';
+ $dir->file('sqitch.plan')->spew(join "\n",
+ '%project=rework', '',
+ 'widgets 2012-07-16T17:25:07Z anna <a@n.na>',
+ "\@$tag 2012-07-16T17:24:07Z julie <j\@ul.ie>", '',
+ );
+
+ # Make the script files.
+ my (@change, @reworked);
+ for my $type (qw(deploy revert verify)) {
+ my $subdir = $dir->subdir($type);
+ $subdir->mkpath;
+ my $script = $subdir->file('widgets.sql');
+ $script->spew("-- $subdir widgets");
+ push @change => $script;
+ push @reworked => $subdir->file("widgets\@$tag.sql");
+ }
+
+ # Return the scripts.
+ $_ => { change => \@change, reworked => \@reworked };
+ } qw(pg sqlite);
+
+ # Load up the configuration for this project.
+ $config = TestConfig->from(local => $conf);
+ $sqitch = App::Sqitch->new(config => $config);
+ ok my $rework = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple plans'],
+ all => 1,
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create another rework with custom multiplan config';
+
+ my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is @targets, 2, 'Should have two targets';
+
+ # Let's do this thing!
+ ok $rework->execute('widgets'), 'Rework change "widgets" in all plans';
+ for my $target(@targets) {
+ my $ekey = $target->engine_key;
+ my $tag = $ekey eq 'pg' ? 'foo' : 'bar';
+ ok my $head = $target->plan->get('widgets@HEAD'),
+ "Get widgets\@HEAD from the $ekey plan";
+ ok my $prev = $target->plan->get("widgets\@$tag"),
+ "Get widgets\@$tag from the $ekey plan";
+ cmp_ok $head->id, 'ne', $prev->id,
+ "The two $ekey widgets should be different changes";
+ }
+
+ is_deeply \%request_params, {
+ for => __ 'rework',
+ scripts => [ map {@{ $scripts{$_}{change} }} qw(pg sqlite)],
+ }, 'Should have listed all the files to edit in the note prompt';
+
+ # And the output should be correct.
+ is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@foo]',
+ file => $targets[0]->plan_file,
+ )],
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@bar]',
+ file => $targets[1]->plan_file,
+ )],
+ [__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ 2,
+ )],
+ map {
+ map { [" * $_" ] } @{ $scripts{$_}{change} }
+ } qw(pg sqlite)
+ ], 'And the info message should show the two files to modify';
+
+ # As should the debug output
+ is_deeply +MockOutput->get_debug, [
+ map {
+ my ($c, $r) = @{ $scripts{$_} }{qw(change reworked)};
+ (
+ map { [__x(
+ 'Copied {src} to {dest}',
+ src => $c->[$_],
+ dest => $r->[$_],
+ )] } (0..2)
+ ),
+ [__x(
+ 'Copied {src} to {dest}',
+ src => $c->[0],
+ dest => $c->[1],
+ )]
+ } qw(pg sqlite)
+ ], 'Should have debug oputput for all copied files';
+
+ chdir File::Spec->updir;
+}
+
+# Make sure we're okay with multiple plans sharing the same top dir.
+ONETOP: {
+ remove_tree $test_dir->stringify;
+ make_path $test_dir->stringify;
+ END { remove_tree $test_dir->stringify };
+ chdir $test_dir->stringify;
+ my $conf = file 'multirework.conf';
+ $conf->spew(join "\n",
+ '[core]',
+ 'engine = pg',
+ '[engine "pg"]',
+ 'plan_file = pg.plan',
+ '[engine "sqlite"]',
+ 'plan_file = sqlite.plan',
+ );
+
+ # Write the two plan files.
+ file("$_.plan")->spew(join "\n",
+ '%project=rework', '',
+ 'widgets 2012-07-16T17:25:07Z anna <a@n.na>',
+ '@foo 2012-07-16T17:24:07Z julie <j@ul.ie>', '',
+ ) for qw(pg sqlite);
+
+ # One set of scripts for both.
+ my (@change, @reworked);
+ for my $type (qw(deploy revert verify)) {
+ my $dir = dir $type;
+ $dir->mkpath;
+ my $script = $dir->file('widgets.sql');
+ $script->spew("-- $dir widgets");
+ push @change => $script;
+ push @reworked => $dir->file('widgets@foo.sql');
+ }
+
+ # Load up the configuration for this project.
+ $config = TestConfig->from(local => $conf);
+ $sqitch = App::Sqitch->new(config => $config);
+ ok my $rework = $CLASS->new(
+ sqitch => $sqitch,
+ note => ['Testing multiple plans'],
+ all => 1,
+ template_directory => dir->parent->subdir(qw(etc templates))
+ ), 'Create another rework with custom multiplan config';
+
+ my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+ is @targets, 2, 'Should have two targets';
+
+ ok $rework->execute('widgets'), 'Rework change "widgets" in all plans';
+ for my $target(@targets) {
+ my $ekey = $target->engine_key;
+ ok my $head = $target->plan->get('widgets@HEAD'),
+ "Get widgets\@HEAD from the $ekey plan";
+ ok my $foo = $target->plan->get('widgets@foo'),
+ "Get widgets\@foo from the $ekey plan";
+ cmp_ok $head->id, 'ne', $foo->id,
+ "The two $ekey widgets should be different changes";
+ }
+
+ # Make sure the files were written properly.
+ file_exists_ok $_ for (@change, @reworked);
+ # Deploy and verify files should be the same.
+ files_eq $change[0], $reworked[0];
+ files_eq $change[2], $reworked[2];
+ # New revert should be the same as old deploy.
+ files_eq $change[1], $reworked[0];
+
+ is_deeply \%request_params, {
+ for => __ 'rework',
+ scripts => \@change,
+ }, 'Should have listed the files to edit in the note prompt';
+
+ # And the output should be correct.
+ is_deeply +MockOutput->get_info, [
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@foo]',
+ file => $targets[0]->plan_file,
+ )],
+ [__x(
+ 'Added "{change}" to {file}.',
+ change => 'widgets [widgets@foo]',
+ file => $targets[1]->plan_file,
+ )],
+ [__n(
+ 'Modify this file as appropriate:',
+ 'Modify these files as appropriate:',
+ 2,
+ )],
+ map { [" * $_" ] } @change,
+ ], 'And the info message should show the two files to modify';
+
+ # As should the debug output
+ is_deeply +MockOutput->get_debug, [
+ (
+ map { [__x(
+ 'Copied {src} to {dest}',
+ src => $change[$_],
+ dest => $reworked[$_],
+ )] } (0..2)
+ ),
+ [__x(
+ 'Copied {src} to {dest}',
+ src => $change[0],
+ dest => $change[1],
+ )],
+ ], 'Should have debug oputput for all copied files';
+
+ chdir File::Spec->updir;
+}
diff --git a/t/show.t b/t/show.t
new file mode 100644
index 00000000..93cf7b3d
--- /dev/null
+++ b/t/show.t
@@ -0,0 +1,198 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use Path::Class;
+use Test::Exception;
+use Test::Warn;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::MockModule;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::show';
+require_ok $CLASS or die;
+
+isa_ok $CLASS, 'App::Sqitch::Command';
+can_ok $CLASS, qw(execute exists_only target does);
+
+ok $CLASS->does("App::Sqitch::Role::ContextCommand"),
+ "$CLASS does ContextCommand";
+
+is_deeply [$CLASS->options], [qw(
+ target|t=s
+ exists|e!
+ plan-file|f=s
+ top-dir=s
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+my $config = TestConfig->new(
+ 'core.engine' => 'pg',
+ 'core.plan_file' => file(qw(t engine sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t engine))->stringify,
+ 'core.reworked_dir' => dir(qw(t engine reworked))->stringify,
+);
+my $sqitch = App::Sqitch->new(config => $config);
+
+isa_ok my $show = $CLASS->new(sqitch => $sqitch), $CLASS;
+ok !$show->exists_only, 'exists_only should be false by default';
+
+ok my $eshow = $CLASS->new(sqitch => $sqitch, exists_only => 1),
+ 'Construct with exists_only';
+ok $eshow->exists_only, 'exists_only should be set';
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {_cx => []},
+ 'Should get empty hash for no config or options';
+
+is_deeply $CLASS->configure($config, {exists => 1}),
+ { exists_only => 1, _cx => [] },
+ 'Should get exists_only => 1 for exist in options';
+
+##############################################################################
+# Start with the change.
+ok my $change = $show->default_target->plan->get('widgets'), 'Get a change';
+
+ok $show->execute( change => $change->id ), 'Find change by id';
+is_deeply +MockOutput->get_emit, [[ $change->info ]],
+ 'The change info should have been emitted';
+
+# Try by name.
+ok $show->execute( change => $change->name ), 'Find change by name';
+is_deeply +MockOutput->get_emit, [[ $change->info ]],
+ 'The change info should have been emitted again';
+
+# What happens for something unknown?
+throws_ok { $show->execute( change => 'nonexistent' ) } 'App::Sqitch::X',
+ 'Should get an error for an unknown change';
+is $@->ident, 'show', 'Unknown change error ident should be "show"';
+is $@->message, __x('Unknown change "{change}"', change => 'nonexistent'),
+ 'Should get proper error for unknown change';
+
+# What about with exists_only?
+ok !$eshow->execute( change => 'nonexistent' ),
+ 'Should return false for uknown change and exists_only';
+is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted';
+
+# Let's find a change by tag.
+my $tag = ($show->default_target->plan->tags)[0];
+$change = $tag->change;
+ok $show->execute( change => $tag->id ), 'Find change by tag id';
+is_deeply +MockOutput->get_emit, [[ $change->info ]],
+ 'The change info should have been emitted';
+
+# And the tag name.
+ok $show->execute( change => $tag->format_name ), 'Find change by tag';
+is_deeply +MockOutput->get_emit, [[ $change->info ]],
+ 'The change info should have been emitted';
+
+# Make sure it works with exists_only.
+ok $eshow->execute( change => $change->id ), 'Run exists with ID';
+is_deeply +MockOutput->get_emit, [],
+ 'There should be no output';
+
+# Great, let's look a the tag itself.
+ok $show->execute( tag => $tag->id ), 'Find tag by id';
+is_deeply +MockOutput->get_emit, [[ $tag->info ]],
+ 'The tag info should have been emitted';
+
+# Should work with exists_only, too.
+ok $eshow->execute( tag => $tag->id ), 'Find tag by id with exists_only';
+is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted';
+
+ok $show->execute( tag => $tag->name ), 'Find tag by name';
+is_deeply +MockOutput->get_emit, [[ $tag->info ]],
+ 'The tag info should have been emitted';
+
+ok $show->execute( tag => $tag->format_name ), 'Find tag by formatted name';
+is_deeply +MockOutput->get_emit, [[ $tag->info ]],
+ 'The tag info should have been emitted';
+
+# Try an invalid tag.
+throws_ok { $show->execute( tag => 'nope') } 'App::Sqitch::X',
+ 'Should get error for non-existent tag';
+is $@->ident, 'show', 'Unknown tag error ident should be "show"';
+is $@->message, __x('Unknown tag "{tag}"', tag => 'nope' ),
+ 'Should get proper error for unknown tag';
+
+# Try invalid tag with exists_only.
+ok !$eshow->execute( tag => 'nope'),
+ 'Should return false for non-existent tag and exists_only';
+is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted';
+
+# Also an invalid sha1.
+throws_ok { $show->execute( tag => '7ecba288708307ef714362c121691de02ffb364d') }
+ 'App::Sqitch::X',
+ 'Should get error for non-existent tag ID';
+is $@->ident, 'show', 'Unknown tag ID error ident should be "show"';
+is $@->message, __x('Unknown tag "{tag}"', tag => '7ecba288708307ef714362c121691de02ffb364d' ),
+ 'Should get proper error for unknown tag ID';
+
+# Now let's look at files.
+ok $show->execute(deploy => $change->id), 'Show a deploy file';
+is_deeply +MockOutput->get_emit, [[ $change->deploy_file->slurp(iomode => '<:raw') ]],
+ 'The deploy file should have been emitted';
+
+# With exists_only.
+ok $eshow->execute(deploy => $change->id), 'Show a deploy file with exists_only';
+is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted';
+
+ok $show->execute(revert => $change->id), 'Show a revert file';
+is_deeply +MockOutput->get_emit, [[ $change->revert_file->slurp(iomode => '<:raw') ]],
+ 'The revert file should have been emitted';
+
+# Nonexistent verify file.
+throws_ok { $show->execute( verify => $change->id ) } 'App::Sqitch::X',
+ 'Should get error for nonexistent varify file';
+is $@->ident, 'show', 'Nonexistent file error ident should be "show"';
+is $@->message, __x('File "{path}" does not exist', path => $change->verify_file ),
+ 'Should get proper error for nonexistent file';
+
+# Nonexistent with exists_only.
+ok !$eshow->execute( verify => $change->id ),
+ 'Should return false for nonexistent file';
+is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted';
+
+# Now an unknown type.
+throws_ok { $show->execute(foo => 'bar') } 'App::Sqitch::X',
+ 'Should get error for uknown type';
+is $@->ident, 'show', 'Unknown type error ident should be "show"';
+is $@->message, __x(
+ 'Unknown object type "{type}',
+ type => 'foo',
+), 'Should get proper error for unknown type';
+
+# Try specifying a non-default target.
+$config = TestConfig->from( local => file 't', 'local.conf');
+$sqitch = App::Sqitch->new(config => $config);
+my $file = file qw(t plans dependencies.plan);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file);
+ok $change = $target->plan->get('add_user'), 'Get a change';
+
+# Set it up.
+isa_ok $show = $CLASS->new(sqitch => $sqitch, target => 'mydb'), $CLASS;
+is $show->target, 'mydb', 'Target should be set';
+ok $show->execute( change => $change->id ), 'Find change by id';
+is_deeply +MockOutput->get_emit, [[ $change->info ]],
+ 'The change info should have been emitted';
+
+# Now try invalid args.
+my $mock = Test::MockModule->new($CLASS);
+my @usage;
+$mock->mock(usage => sub { shift; @usage = @_; die 'USAGE' });
+throws_ok { $show->execute } qr/USAGE/, 'Should get usage for missing params';
+is_deeply \@usage, [], 'Nothing should have been passed to usage';
+
+done_testing;
diff --git a/t/snowflake.t b/t/snowflake.t
new file mode 100644
index 00000000..60ce88da
--- /dev/null
+++ b/t/snowflake.t
@@ -0,0 +1,562 @@
+#!/usr/bin/perl -w
+
+# To test against a live Snowflake database, you must set the SNOWSQL_URI environment variable.
+# this is a stanard URI::db URI, and should look something like this:
+#
+# export SNOWSQL_URI=db:snowflake://username:password@accountname/dbname?Driver=Snowflake;warehouse=warehouse
+#
+# Note that it must include the `?Driver=$driver` bit so that DBD::ODBC loads
+# the proper driver.
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More 0.94;
+use Test::MockModule;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use Capture::Tiny 0.12 qw(:all);
+use File::Temp 'tempdir';
+use Path::Class;
+use Try::Tiny;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use App::Sqitch::DateTime;
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+
+delete $ENV{"SNOWSQL_$_"} for qw(USER PASSWORD DATABASE HOST PORT);
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::snowflake';
+ require_ok $CLASS or die;
+ $ENV{SNOWSQL_ACCOUNT} = 'nonesuch';
+}
+
+# Mock the home directory to prevent reading a user config file.
+my $tmp_dir = dir tempdir CLEANUP => 1;
+local $ENV{HOME} = $tmp_dir->stringify;
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $uri = 'db:snowflake:';
+my $config = TestConfig->new('core.engine' => 'snowflake');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI::db->new($uri),
+);
+
+# Disable config file parsing for the remainder of the tests.
+my $mock_snow = Test::MockModule->new($CLASS);
+$mock_snow->mock(_snowcfg => {});
+
+isa_ok my $snow = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+
+is $snow->username, $sqitch->sysuser, 'Username should be sysuser';
+is $snow->password, undef, 'Password should be undef';
+is $snow->key, 'snowflake', 'Key should be "snowflake"';
+is $snow->name, 'Snowflake', 'Name should be "Snowflake"';
+is $snow->driver, 'DBD::ODBC 1.59', 'Driver should be DBD::ODBC';
+is $snow->default_client, 'snowsql', 'Default client should be snowsql';
+my $client = 'snowsql' . (App::Sqitch::ISWIN ? '.exe' : '');
+is $snow->client, $client, 'client should default to snowsql';
+
+is $snow->registry, 'sqitch', 'Registry default should be "sqitch"';
+my $exp_uri = URI->new(
+ sprintf 'db:snowflake://%s.snowflakecomputing.com/%s',
+ $ENV{SNOWSQL_ACCOUNT}, $sqitch->sysuser,
+)->as_string;
+is $snow->uri, $exp_uri, 'DB URI should be filled in';
+is $snow->destination, $exp_uri, 'Destination should be URI string';
+is $snow->registry_destination, $snow->destination,
+ 'Registry destination should be the same as destination';
+
+# Test environment variables.
+SNOWENV: {
+ local $ENV{SNOWSQL_USER} = 'kamala';
+ local $ENV{SNOWSQL_PWD} = 'gimme';
+ local $ENV{SNOWSQL_REGION} = 'Australia';
+ local $ENV{SNOWSQL_WAREHOUSE} = 'madrigal';
+ local $ENV{SNOWSQL_ACCOUNT} = 'egregious';
+ local $ENV{SNOWSQL_HOST} = 'test.snowflake.com';
+ local $ENV{SNOWSQL_PORT} = 4242;
+ local $ENV{SNOWSQL_DATABASE} = 'tryme';
+
+ my $target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri));
+ my $snow = $CLASS->new( sqitch => $sqitch, target => $target );
+ is $snow->uri, 'db:snowflake://test.snowflake.com:4242/tryme',
+ 'Should build URI from environment';
+ is $snow->username, 'kamala', 'Should read username from environment';
+ is $snow->password, 'gimme', 'Should read password from environment';
+ is $snow->account, 'test', 'Should read account from host';
+ is $snow->warehouse, 'madrigal', 'Should read warehouse from environment';
+
+ # Delete host.
+ $target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri));
+ delete $ENV{SNOWSQL_HOST};
+ $snow = $CLASS->new( sqitch => $sqitch, target => $target );
+ is $snow->uri, 'db:snowflake://egregious.Australia.snowflakecomputing.com:4242/tryme',
+ 'Should build URI host from account and region environment vars';
+ is $snow->account, 'egregious', 'Should read account from environment';
+
+ # SQITCH_PASSWORD has priority.
+ local $ENV{SQITCH_PASSWORD} = 'irule';
+ $target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri));
+ is $target->password, 'irule', 'Target password should be from SQITCH_PASSWORD';
+ $snow = $CLASS->new( sqitch => $sqitch, target => $target );
+ is $snow->password, 'irule', 'Should prefer password from SQITCH_PASSWORD';
+}
+
+# Name the target.
+my $named_target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI->new($uri),
+ name => 'jonsnow',
+);
+
+isa_ok $snow = $CLASS->new(
+ sqitch => $sqitch,
+ target => $named_target,
+), $CLASS;
+
+is $snow->destination, 'jonsnow', 'Destination should be target name';
+is $snow->registry_destination, $snow->destination,
+ 'Registry destination should be the same as destination';
+
+##############################################################################
+# Test snowsql options.
+my @con_opts = (
+ '--accountname' => $ENV{SNOWSQL_ACCOUNT},
+ '--username' => $snow->username,
+ '--dbname' => $snow->uri->dbname,
+);
+
+my @std_opts = (
+ '--noup',
+ '--option' => 'auto_completion=false',
+ '--option' => 'echo=false',
+ '--option' => 'execution_only=false',
+ '--option' => 'friendly=false',
+ '--option' => 'header=false',
+ '--option' => 'exit_on_error=true',
+ '--option' => 'stop_on_error=true',
+ '--option' => 'output_format=csv',
+ '--option' => 'paging=false',
+ '--option' => 'timing=false',
+ '--option' => 'results=true',
+ '--option' => 'wrap=false',
+ '--option' => 'rowset_size=1000',
+ '--option' => 'syntax_style=default',
+ '--option' => 'variable_substitution=true',
+ '--variable' => 'registry=sqitch',
+ '--variable' => 'warehouse=' . $snow->warehouse,
+);
+is_deeply [$snow->snowsql], [$client, @con_opts, @std_opts],
+ 'snowsql command should be std opts-only';
+
+isa_ok $snow = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+ok $snow->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'),
+ 'Set some variables';
+is_deeply [$snow->snowsql], [
+ $client,
+ @con_opts,
+ '--variable' => 'foo=baz',
+ '--variable' => 'whu=hi there',
+ '--variable' => 'yo=stellar',
+ @std_opts,
+], 'Variables should be passed to snowsql via --set';
+
+##############################################################################
+# Test other configs for the target.
+ENV: {
+ # Make sure we override system-set vars.
+ local $ENV{SNOWSQL_DATABASE};
+ local $ENV{SNOWSQL_USER};
+ for my $env (qw(SNOWSQL_DATABASE SNOWSQL_USER)) {
+ my $snow = $CLASS->new(sqitch => $sqitch, target => $target);
+ local $ENV{$env} = "\$ENV=whatever";
+ is $snow->target->name, "db:snowflake:", "Target name should not read \$$env";
+ is $snow->registry_destination, $snow->destination,
+ 'Registry target should be the same as destination';
+ }
+
+ my $mocker = Test::MockModule->new('App::Sqitch');
+ $mocker->mock(sysuser => 'sysuser=whatever');
+ my $snow = $CLASS->new(sqitch => $sqitch, target => $target);
+ is $snow->target->name, 'db:snowflake:',
+ 'Target name should not fall back on sysuser';
+ is $snow->registry_destination, $snow->destination,
+ 'Registry target should be the same as destination';
+
+ $ENV{SNOWSQL_DATABASE} = 'mydb';
+ $snow = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target);
+ is $snow->target->name, 'db:snowflake:', 'Target name should be the default';
+ is $snow->registry_destination, $snow->destination,
+ 'Registry target should be the same as destination';
+}
+
+##############################################################################
+# Make sure we read snowsql config file.
+SNOWSQLCFGFILE: {
+ # Create the mock config directory.
+ my $cfgdir = $tmp_dir->subdir('.snowsql');
+ $cfgdir->mkpath;
+ my $cfgfn = $cfgdir->file('config');
+
+ my $cfg = {
+ username => 'jonSnow',
+ password => 'winter is cøming',
+ accountname => 'golem',
+ region => 'Africa',
+ warehousename => 'LaBries',
+ rolename => 'ACCOUNTADMIN',
+ dbname => 'dolphin',
+ };
+
+ # Unset the mock.
+ $mock_snow->unmock('_snowcfg');
+
+ for my $qm (q{}, q{'}, q{"}) {
+ # Write out a the config file.
+ open my $fh, '>:utf8', $cfgfn or die "Cannot open $cfgfn: $!\n";
+ print {$fh} "[connections]\n";
+ while (my ($k, $v) = each %{ $cfg }) {
+ print {$fh} "$k = $qm$v$qm\n";
+ }
+
+ # Add a named connection, which should be ignored.
+ print {$fh} "[connections.winner]\nusername = ${qm}WINNING$qm\n";
+ close $fh or die "Cannot close $cfgfn: $!\n";
+
+ # Make sure we read it in.
+ my $target = App::Sqitch::Target->new(
+ name => 'db:snowflake:',
+ sqitch => $sqitch,
+ );
+ my $snow = $CLASS->new( sqitch => $sqitch, target => $target );
+ is_deeply $snow->_snowcfg, $cfg, 'Should have read config from file';
+ }
+
+ # Reset default mock.
+ $mock_snow->mock(_snowcfg => {});
+}
+
+##############################################################################
+# Make sure we read snowsql config connection settings.
+SNOWSQLCFG: {
+ local $ENV{SNOWSQL_ACCOUNT};
+ local $ENV{SNOWSQL_HOST};
+ my $target = App::Sqitch::Target->new(
+ name => 'db:snowflake:',
+ sqitch => $sqitch,
+ );
+
+ # Read config.
+ $mock_snow->mock(_snowcfg => {
+ username => 'jon_snow',
+ password => 'let me in',
+ accountname => 'flipr',
+ rolename => 'SYSADMIN',
+ warehousename => 'Waterbed',
+ dbname => 'monkey',
+ });
+ my $snow = $CLASS->new( sqitch => $sqitch, target => $target );
+ is $snow->username, 'jon_snow',
+ 'Should read username fron snowsql config file';
+ is $snow->password, 'let me in',
+ 'Should read password fron snowsql config file';
+ is $snow->account, 'flipr',
+ 'Should read accountname fron snowsql config file';
+ is $snow->uri->dbname, 'monkey',
+ 'Should read dbname from snowsql config file';
+ is $snow->warehouse, 'Waterbed',
+ 'Should read warehousename fron snowsql config file';
+ is $snow->role, 'SYSADMIN',
+ 'Should read rolename fron snowsql config file';
+ is $snow->uri->host, 'flipr.snowflakecomputing.com',
+ 'Should derive host name from config file accounte name';
+
+ # Reset default mock.
+ $mock_snow->mock(_snowcfg => {});
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.snowflake.client' => '/path/to/snowsql',
+ 'engine.snowflake.target' => 'db:snowflake://fred:hi@foo/try?warehouse=foo;role=yup',
+ 'engine.snowflake.registry' => 'meta',
+);
+$std_opts[-3] = 'registry=meta';
+$std_opts[-1] = 'warehouse=foo';
+
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $snow = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another snowflake';
+
+is $snow->account, 'foo', 'Should extract account from URI';
+is $snow->username, 'fred', 'Should extract username from URI';
+is $snow->password, 'hi', 'Should extract password from URI';
+is $snow->warehouse, 'foo', 'Should extract warehouse from URI';
+is $snow->role, 'yup', 'Should extract role from URI';
+is $snow->registry, 'meta', 'registry should be as configured';
+is $snow->uri->as_string,
+ 'db:snowflake://fred:hi@foo.snowflakecomputing.com/try?warehouse=foo;role=yup',
+ 'URI should be as configured with full domain name';
+is $snow->destination,
+ 'db:snowflake://fred:@foo.snowflakecomputing.com/try?warehouse=foo;role=yup',
+ 'Destination should omit password';
+
+is $snow->client, '/path/to/snowsql', 'client should be as configured';
+is_deeply [$snow->snowsql], [qw(
+ /path/to/snowsql
+ --accountname foo
+ --username fred
+ --dbname try
+ --rolename yup
+), @std_opts], 'snowsql command should be configured from URI config';
+
+##############################################################################
+# Test SQL helpers.
+is $snow->_listagg_format, q{listagg(%s, ' ')}, 'Should have _listagg_format';
+is $snow->_ts_default, 'current_timestamp', 'Should have _ts_default';
+is $snow->_regex_op, 'REGEXP', 'Should have _regex_op';
+is $snow->_simple_from, ' FROM dual', 'Should have _simple_from';
+is $snow->_limit_default, '4611686018427387903', 'Should have _limit_default';
+
+DBI: {
+ local *DBI::state;
+ ok !$snow->_no_table_error, 'Should have no table error';
+ ok !$snow->_no_column_error, 'Should have no column error';
+ $DBI::state = '42S02';
+ ok $snow->_no_table_error, 'Should now have table error';
+ ok !$snow->_no_column_error, 'Still should have no column error';
+ $DBI::state = '42703';
+ ok !$snow->_no_table_error, 'Should again have no table error';
+ ok $snow->_no_column_error, 'Should now have no column error';
+}
+
+is_deeply [$snow->_limit_offset(8, 4)],
+ [['LIMIT 8', 'OFFSET 4'], []],
+ 'Should get limit and offset';
+is_deeply [$snow->_limit_offset(0, 2)],
+ [['LIMIT 4611686018427387903', 'OFFSET 2'], []],
+ 'Should get limit and offset when offset only';
+is_deeply [$snow->_limit_offset(12, 0)], [['LIMIT 12'], []],
+ 'Should get only limit with 0 offset';
+is_deeply [$snow->_limit_offset(12)], [['LIMIT 12'], []],
+ 'Should get only limit with noa offset';
+is_deeply [$snow->_limit_offset(0, 0)], [[], []],
+ 'Should get no limit or offset for 0s';
+is_deeply [$snow->_limit_offset()], [[], []],
+ 'Should get no limit or offset for no args';
+
+is_deeply [$snow->_regex_expr('corn', 'Obama$')],
+ ["regexp_substr(corn, ?) IS NOT NULL", 'Obama$'],
+ 'Should use regexp_substr IS NOT NULL for regex expr';
+
+##############################################################################
+# Test _run(), _capture() _spool(), and _probe().
+$config->replace('core.engine' => 'snowflake');
+can_ok $snow, qw(_run _capture _spool);
+my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+my ($exp_pass, @capture) = ('s3cr3t');
+$mock_sqitch->mock(capture => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @capture = @_;
+ if (defined $exp_pass) {
+ is $ENV{SNOWSQL_PWD}, $exp_pass, qq{SNOWSQL_PWD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{SNOWSQL_PWD}, 'SNOWSQL_PWD should not exist';
+ }
+ return;
+});
+
+my @spool;
+$mock_sqitch->mock(spool => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @spool = @_;
+ if (defined $exp_pass) {
+ is $ENV{SNOWSQL_PWD}, $exp_pass, qq{SNOWSQL_PWD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{SNOWSQL_PWD}, 'SNOWSQL_PWD should not exist';
+ }
+});
+
+my @probe;
+$mock_sqitch->mock(probe => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @probe = @_;
+ if (defined $exp_pass) {
+ is $ENV{SNOWSQL_PWD}, $exp_pass, qq{SNOWSQL_PWD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{SNOWSQL_PWD}, 'SNOWSQL_PWD should not exist';
+ }
+ return;
+});
+
+$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri));
+$target->uri->password($exp_pass);
+ok $snow = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a snowflake with sqitch with options';
+
+ok $snow->_run(qw(foo bar baz)), 'Call _run';
+is_deeply \@capture, [$snow->snowsql, qw(foo bar baz)],
+ 'Command should be passed to capture()';
+
+ok $snow->_spool('FH'), 'Call _spool';
+is_deeply \@spool, ['FH', $snow->snowsql, $snow->_verbose_opts],
+ 'Command should be passed to spool()';
+
+lives_ok { $snow->_capture(qw(foo bar baz)) } 'Call _capture';
+is_deeply \@capture, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)],
+ 'Command should be passed to capture()';
+
+lives_ok { $snow->_probe(qw(foo bar baz)) } 'Call _probe';
+is_deeply \@probe, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)],
+ 'Command should be passed to probe()';
+
+# Without password.
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $snow = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a snowflake with sqitch with no pw';
+$exp_pass = undef;
+ok $snow->_run(qw(foo bar baz)), 'Call _run again';
+is_deeply \@capture, [$snow->snowsql, qw(foo bar baz)],
+ 'Command should be passed to capture() again';
+
+ok $snow->_spool('FH'), 'Call _spool again';
+is_deeply \@spool, ['FH', $snow->snowsql, $snow->_verbose_opts],
+ 'Command should be passed to spool() again';
+
+lives_ok { $snow->_capture(qw(foo bar baz)) } 'Call _capture again';
+is_deeply \@capture, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)],
+ 'Command should be passed to capture() again';
+
+lives_ok { $snow->_probe(qw(foo bar baz)) } 'Call _probe again';
+is_deeply \@probe, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)],
+ 'Command should be passed to probe() again';
+
+##############################################################################
+# Test file and handle running.
+ok $snow->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+is_deeply \@capture, [$snow->snowsql, $snow->_quiet_opts, '--filename', 'foo/bar.sql'],
+ 'File should be passed to capture()';
+
+ok $snow->run_handle('FH'), 'Spool a "file handle"';
+is_deeply \@spool, ['FH', $snow->snowsql, $snow->_verbose_opts],
+ 'Handle should be passed to spool()';
+
+# Verify should go to capture unless verosity is > 1.
+# ok $snow->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+# is_deeply \@capture, [$snow->snowsql, '--filename', 'foo/bar.sql'],
+# 'Verify file should be passed to capture()';
+
+$mock_sqitch->mock(verbosity => 2);
+ok $snow->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+is_deeply \@capture, [$snow->snowsql, $snow->_verbose_opts, '--filename', 'foo/bar.sql'],
+ 'Verifile file should be passed to run() for high verbosity';
+
+$mock_sqitch->unmock_all;
+
+##############################################################################
+# Test DateTime formatting stuff.
+ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')";
+is sprintf($ts2char->(), 'foo'),
+ q{to_varchar(CONVERT_TIMEZONE('UTC', foo), '"year:"YYYY":month:"MM":day:"DD":hour:"HH24":minute:"MI":second:"SS":time_zone:UTC"')},
+ '_ts2char_format should work';
+
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+
+ok my $now = App::Sqitch::DateTime->now, 'Construct a datetime object';
+is $snow->_char2ts($now), $now->as_string(format => 'iso'),
+ 'Should get ISO output from _char2ts';
+
+##############################################################################
+# Can we do live tests?
+my $dbh;
+END {
+ return unless $dbh;
+ $dbh->{Driver}->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh;
+ });
+
+ $dbh->{RaiseError} = 0;
+ $dbh->{PrintError} = 1;
+ $dbh->do($_) for (
+ 'DROP SCHEMA IF EXISTS sqitch CASCADE',
+ 'DROP SCHEMA IF EXISTS __sqitchtest CASCADE',
+ );
+}
+
+$uri = URI->new($ENV{SNOWSQL_URI} || 'db:snowflake://accountname/?Driver=Snowflake');
+$uri->host($uri->host . ".snowflakecomputing.com") if $uri->host !~ /snoflakecomputing[.]com/;
+my $err = try {
+ $snow->use_driver;
+ $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, {
+ PrintError => 0,
+ RaiseError => 1,
+ AutoCommit => 1,
+ });
+ undef;
+} catch {
+ eval { $_->message } || $_;
+};
+
+DBIEngineTest->run(
+ class => $CLASS,
+ version_query => q{SELECT 'Snowflake ' || CURRENT_VERSION()},
+ target_params => [ uri => $uri ],
+ alt_target_params => [ uri => $uri, registry => '__sqitchtest' ],
+ skip_unless => sub {
+ my $self = shift;
+ die $err if $err;
+ # Make sure we have vsql and can connect to the database.
+ $self->sqitch->probe( $self->client, '--version' );
+ $self->_capture('--query' => 'SELECT CURRENT_DATE FROM dual');
+ },
+ engine_err_regex => qr/\bSQL\s+compilation\s+error:/,
+ init_error => __x(
+ 'Sqitch schema "{schema}" already exists',
+ schema => '__sqitchtest',
+ ),
+ test_dbh => sub {
+ my $dbh = shift;
+ # Make sure the sqitch schema is the first in the search path.
+ is $dbh->selectcol_arrayref('SELECT current_schema()')->[0],
+ '__SQITCHTEST', 'The Sqitch schema should be the current schema';
+ },
+ add_second_format => 'dateadd(second, 1, %s)',
+
+);
+
+done_testing;
diff --git a/t/sqitch b/t/sqitch
new file mode 100755
index 00000000..350e7fc0
--- /dev/null
+++ b/t/sqitch
@@ -0,0 +1,16 @@
+#!/usr/bin/env perl -CAS
+
+use POSIX qw(setlocale);
+BEGIN {
+ if ($^O eq 'MSWin32') {
+ require Win32::Locale;
+ setlocale POSIX::LC_ALL, Win32::Locale::get_locale();
+ } else {
+ setlocale POSIX::LC_ALL, '';
+ }
+}
+use FindBin;
+use lib "$FindBin::Bin/../lib";
+use App::Sqitch;
+
+exit App::Sqitch->go;
diff --git a/t/sqitch.conf b/t/sqitch.conf
new file mode 100644
index 00000000..ca5c4f9a
--- /dev/null
+++ b/t/sqitch.conf
@@ -0,0 +1,24 @@
+[core]
+ uri = https://github.com/sqitchers/sqitch/
+ engine = pg
+ top_dir = migrations
+ extension = ddl
+ pager = less -r
+
+[engine "pg"]
+ client = /usr/local/pgsql/bin/psql
+
+[revert]
+ to = gamma
+ count = 2
+ revision = 1.1
+
+[bundle]
+ from = gamma
+ tags_only = true
+ dest_dir = _build/sql
+[foo "BAR"]
+ baz = hello
+[guess "Yes.No"]
+ red = true
+ Calico = false
diff --git a/t/sql/deploy/roles.sql b/t/sql/deploy/roles.sql
new file mode 100644
index 00000000..63889917
--- /dev/null
+++ b/t/sql/deploy/roles.sql
@@ -0,0 +1 @@
+-- Create roles. \ No newline at end of file
diff --git a/t/sql/deploy/users.sql b/t/sql/deploy/users.sql
new file mode 100644
index 00000000..2bd98af3
--- /dev/null
+++ b/t/sql/deploy/users.sql
@@ -0,0 +1,2 @@
+-- Create users.
+-- requires: roles
diff --git a/t/sql/deploy/widgets.sql b/t/sql/deploy/widgets.sql
new file mode 100644
index 00000000..fe52fee1
--- /dev/null
+++ b/t/sql/deploy/widgets.sql
@@ -0,0 +1,2 @@
+-- Create widgets.
+-- requires: users
diff --git a/t/sql/sqitch.plan b/t/sql/sqitch.plan
new file mode 100644
index 00000000..27feaec3
--- /dev/null
+++ b/t/sql/sqitch.plan
@@ -0,0 +1,8 @@
+%project=sql
+
+roles 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+users 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+
+widgets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
+@beta 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>
diff --git a/t/sql/verify/users.sql b/t/sql/verify/users.sql
new file mode 100644
index 00000000..a8ed305c
--- /dev/null
+++ b/t/sql/verify/users.sql
@@ -0,0 +1 @@
+SELECT nick, name FROM __myapp.users WHERE FALSE;
diff --git a/t/sqlite.t b/t/sqlite.t
new file mode 100644
index 00000000..45a33a92
--- /dev/null
+++ b/t/sqlite.t
@@ -0,0 +1,384 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Test::MockModule;
+use Path::Class;
+use Try::Tiny;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use File::Temp 'tempdir';
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::sqlite';
+ require_ok $CLASS or die;
+}
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $config = TestConfig->new('core.engine' => 'sqlite');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI->new('db:sqlite:foo.db'),
+);
+isa_ok my $sqlite = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS;
+
+is $sqlite->key, 'sqlite', 'Key should be "sqlite"';
+is $sqlite->name, 'SQLite', 'Name should be "SQLite"';
+
+is $sqlite->client, 'sqlite3' . (App::Sqitch::ISWIN ? '.exe' : ''),
+ 'client should default to sqlite3';
+is $sqlite->uri->dbname, file('foo.db'), 'dbname should be filled in';
+is $sqlite->target, $target, 'Target attribute should be specified target';
+is $sqlite->destination, $sqlite->uri->as_string,
+ 'Destination should be uri stringified';
+is $sqlite->registry_destination, $sqlite->registry_uri->as_string,
+ 'Registry target should be registry_uri stringified';
+
+# Pretend for now that we always have a valid SQLite.
+my $mock_sqitch = Test::MockModule->new(ref $sqitch);
+my $sqlite_version = '3.7.12 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af';
+$mock_sqitch->mock(capture => sub { return $sqlite_version });
+
+my @std_opts = (
+ '-noheader',
+ '-bail',
+ '-batch',
+ '-csv',
+);
+
+is_deeply [$sqlite->sqlite3], [$sqlite->client, @std_opts, $sqlite->uri->dbname],
+ 'sqlite3 command should have the proper opts';
+
+##############################################################################
+# Make sure we get an error for no database name.
+my $tmp_dir = Path::Class::dir( tempdir CLEANUP => 1 );
+my $have_sqlite = try { $sqlite->use_driver };
+if ($have_sqlite) {
+ # We have DBD::SQLite.
+ # Find out if it's built with SQLite >= 3.7.11.
+ my $dbh = DBI->connect('DBI:SQLite:');
+ my @v = split /[.]/ => $dbh->{sqlite_version};
+ $have_sqlite = $v[0] > 3 || ($v[0] == 3 && ($v[1] > 7 || ($v[1] == 7 && $v[2] >= 11)));
+ unless ($have_sqlite) {
+ # We have DBD::SQLite, but it is too old. Make sure we complain about that.
+ isa_ok $sqlite = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+ ), $CLASS;
+ throws_ok { $sqlite->dbh } 'App::Sqitch::X', 'Should get an error for old SQLite';
+ is $@->ident, 'sqlite', 'Unsupported SQLite error ident should be "sqlite"';
+ is $@->message, __x(
+ 'Sqitch requires SQLite 3.7.11 or later; DBD::SQLite was built with {version}',
+ version => $dbh->{sqlite_version}
+ ), 'Unsupported SQLite error message should be correct';
+ }
+} else {
+ # No DBD::SQLite at all.
+ throws_ok { $sqlite->dbh } 'App::Sqitch::X',
+ 'Should get an error without DBD::SQLite';
+ is $@->ident, 'sqlite', 'No DBD::SQLite error ident should be "sqlite"';
+ is $@->message, __x(
+ '{driver} required to manage {engine}',
+ driver => $sqlite->driver,
+ engine => $sqlite->name,
+ ), 'No DBD::SQLite error message should be correct';
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.sqlite.client' => '/path/to/sqlite3',
+ 'engine.sqlite.target' => 'test',
+ 'engine.sqlite.registry' => 'meta',
+ 'target.test.uri' => 'db:sqlite:/path/to/sqlite.db',
+);
+$target = ref($target)->new( sqitch => $sqitch );
+ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another sqlite';
+is $sqlite->client, '/path/to/sqlite3',
+ 'client should fall back on config';
+is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqlite.db',
+ 'dbname should fall back on config';
+is $sqlite->target, $target, 'Target should be as specified';
+is $sqlite->destination, 'test',
+ 'Destination should be configured target name';
+is $sqlite->registry_uri->as_string, 'db:sqlite:/path/to/meta.db',
+ 'registry_uri should fall back on config';
+is $sqlite->registry_destination, $sqlite->registry_uri->as_string,
+ 'Registry target should be configured registry_uri stringified';
+
+# Try a registry with an extension and a dbname without.
+$config->update(
+ 'engine.sqlite.registry' => 'meta.db',
+ 'engine.sqlite.target' => 'test',
+ 'target.test.uri' => 'db:sqlite:/path/to/sqitch',
+);
+$target = ref($target)->new( sqitch => $sqitch );
+ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another sqlite';
+is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqitch',
+ 'dbname should fall back on config with no extension';
+is $sqlite->target, $target, 'Target should be as specified';
+is $sqlite->destination, 'test',
+ 'Destination should be configured target name';
+is $sqlite->registry_uri->as_string, 'db:sqlite:/path/to/meta.db',
+ 'registry_uri should fall back on config wth extension';
+is $sqlite->registry_destination, $sqlite->registry_uri->as_string,
+ 'Registry target should be configured registry_uri stringified';
+
+# Also try a registry with no extension and a dbname with.
+$config->update(
+ 'engine.sqlite.registry' => 'registry',
+ 'engine.sqlite.target' => 'noext',
+ 'target.noext.uri' => 'db:sqlite:/path/to/sqitch.db',
+);
+$target = ref($target)->new( sqitch => $sqitch );
+ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another sqlite';
+is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqitch.db',
+ 'dbname should fall back on config with no extension';
+is $sqlite->target, $target, 'Target should be as specified';
+is $sqlite->destination, 'noext',
+ 'Destination should be configured target name';
+is $sqlite->registry_uri->as_string, 'db:sqlite:/path/to/registry.db',
+ 'registry_uri should fall back on config wth extension';
+is $sqlite->registry_destination, $sqlite->registry_uri->as_string,
+ 'Registry target should be configured registry_uri stringified';
+
+# Try a registry with an absolute path.
+$config->update(
+ 'engine.sqlite.registry' => '/some/other/path.db',
+ 'engine.sqlite.target' => 'abs',
+ 'target.abs.uri' => 'db:sqlite:/path/to/sqitch.db',
+);
+$target = ref($target)->new( sqitch => $sqitch );
+ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another sqlite';
+is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqitch.db',
+ 'dbname should fall back on config with no extension';
+is $sqlite->target, $target, 'Target should be as specified';
+is $sqlite->destination, 'abs',
+ 'Destination should be configured target name';
+is $sqlite->registry_uri->as_string, 'db:sqlite:/some/other/path.db',
+ 'registry_uri should fall back on config wth extension';
+is $sqlite->registry_destination, $sqlite->registry_uri->as_string,
+ 'Registry target should be configured registry_uri stringified';
+
+##############################################################################
+# Test _read().
+$config->replace('core.engine' => 'sqlite');
+my $db_name = $tmp_dir->file('sqitch.db');
+$target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => URI->new("db:sqlite:$db_name")
+);
+ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target ),
+ 'Instantiate with a temporary database file';
+can_ok $sqlite, qw(_read);
+SKIP: {
+ skip 'DBD::SQLite not available', 3 unless $have_sqlite;
+ is $sqlite->_read('foo'), q{.read 'foo'}, '_read() should work';
+ is $sqlite->_read('foo bar'), q{.read 'foo bar'},
+ '_read() should SQL-quote the file name';
+ is $sqlite->_read('foo \'bar\''), q{.read 'foo ''bar'''},
+ '_read() should SQL-quote quotes, too';
+}
+
+##############################################################################
+# Test _run(), _capture(), and _spool().
+can_ok $sqlite, qw(_run _capture _spool);
+
+my (@run, @capture, @spool);
+$mock_sqitch->mock(run => sub { shift; @run = @_ });
+$mock_sqitch->mock(capture => sub { shift; @capture = @_; return $sqlite_version });
+$mock_sqitch->mock(spool => sub { shift; @spool = @_ });
+
+ok $sqlite->_run(qw(foo bar baz)), 'Call _run';
+is_deeply \@run, [$sqlite->sqlite3, qw(foo bar baz)],
+ 'Command should be passed to run()';
+
+ok $sqlite->_spool('FH'), 'Call _spool';
+is_deeply \@spool, ['FH', $sqlite->sqlite3],
+ 'Command should be passed to spool()';
+
+ok $sqlite->_capture(qw(foo bar baz)), 'Call _capture';
+is_deeply \@capture, [$sqlite->sqlite3, qw(foo bar baz)],
+ 'Command should be passed to capture()';
+
+# Test file and handle running.
+SKIP: {
+ skip 'DBD::SQLite not available', 2 unless $have_sqlite;
+ ok $sqlite->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+ is_deeply \@run, [$sqlite->sqlite3, ".read 'foo/bar.sql'"],
+ 'File should be passed to run()';
+}
+
+ok $sqlite->run_handle('FH'), 'Spool a "file handle"';
+is_deeply \@spool, ['FH', $sqlite->sqlite3],
+ 'Handle should be passed to spool()';
+
+SKIP: {
+ skip 'DBD::SQLite not available', 2 unless $have_sqlite;
+
+ # Verify should go to capture unless verosity is > 1.
+ ok $sqlite->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+ is_deeply \@capture, [$sqlite->sqlite3, ".read 'foo/bar.sql'"],
+ 'Verify file should be passed to capture()';
+
+ $mock_sqitch->mock(verbosity => 2);
+ ok $sqlite->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+ is_deeply \@run, [$sqlite->sqlite3, ".read 'foo/bar.sql'"],
+ 'Verifile file should be passed to run() for high verbosity';
+}
+
+##############################################################################
+# Test DateTime formatting stuff.
+can_ok $CLASS, '_ts2char_format';
+is sprintf($CLASS->_ts2char_format, 'foo'),
+ q{strftime('year:%Y:month:%m:day:%d:hour:%H:minute:%M:second:%S:time_zone:UTC', foo)},
+ '_ts2char should work';
+
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+
+##############################################################################
+# Test checking the SQLite version.
+for my $v (qw(
+ 3.3.9
+ 3.3.10
+ 3.3.200
+ 3.4.0
+ 3.4.8
+ 3.7.11
+ 3.8.12
+ 3.10.0
+ 4.1.30
+)) {
+ $sqlite_version = "$v 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af";
+ ok my $sqlite = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+ ), "Create command for v$v";
+ ok $sqlite->sqlite3, "Should be okay with sqlite v$v";
+}
+
+for my $v (qw(
+ 3.3.8
+ 3.3.0
+ 3.2.8
+ 3.0.1
+ 3.0.0
+ 2.8.1
+ 2.20.0
+ 1.0.0
+)) {
+ $sqlite_version = "$v 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af";
+ ok my $sqlite = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+ ), "Create command for v$v";
+ throws_ok { $sqlite->sqlite3 } 'App::Sqitch::X', "Should not be okay with v$v";
+ is $@->ident, 'sqlite', qq{Should get ident "sqlite" for v$v};
+ is $@->message, __x(
+ 'Sqitch requires SQLite 3.3.9 or later; {client} is {version}',
+ client => $sqlite->client,
+ version => $v
+ ), "Should get proper error message for v$v";
+}
+
+$mock_sqitch->unmock_all;
+
+##############################################################################
+# Test against extra newline in capture.
+$sqlite_version = '3.7.12 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af';
+$mock_sqitch->mock(capture => sub { return ( "\n",$sqlite_version) });
+{
+ ok my $sqlite = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+ ), "Create command for v3.7.12 with newline";
+ ok $sqlite->sqlite3, "Should be okay with sqlite version v3.7.12 with newline";
+}
+
+# Un-mock for live tests below
+$mock_sqitch->unmock_all;
+
+##############################################################################
+my $alt_db = $db_name->dir->file('sqitchtest.db');
+# Can we do live tests?
+END {
+ my %drivers = DBI->installed_drivers;
+ for my $driver (values %drivers) {
+ $driver->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active};
+ });
+ }
+}
+
+DBIEngineTest->run(
+ class => $CLASS,
+ version_query => q{select 'SQLite ' || sqlite_version()},
+ target_params => [ uri => URI->new("db:sqlite:$db_name") ],
+ alt_target_params => [
+ registry => 'sqitchtest',
+ uri => URI->new("db:sqlite:$db_name"),
+ ],
+ skip_unless => sub {
+ my $self = shift;
+
+ # Should have the database handle and client.
+ $self->dbh && $self->sqlite3;
+
+ # Make sure we have a supported version.
+ my $version = $self->dbh->{sqlite_version};
+ my @v = split /[.]/ => $version;
+ die "SQLite >= 3.7.11 required; DBD::SQLite built with $version\n"
+ unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 7 || ($v[1] == 7 && $v[2] >= 11)));
+
+ $version = (split / / => scalar $self->sqitch->capture( $self->client, '-version' ))[0];
+ @v = split /[.]/ => $version;
+ die "SQLite >= 3.3.9 required; CLI is $version\n"
+ unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 3 || ($v[1] == 3 && $v[2] >= 9)));
+ say "# Detected SQLite CLI $version";
+ return 1;
+ },
+ engine_err_regex => qr/^near "blah": syntax error/,
+ init_error => __x(
+ 'Sqitch database {database} already initialized',
+ database => $alt_db,
+ ),
+ test_dbh => sub {
+ my $dbh = shift;
+ # Make sure foreign key constraints are enforced.
+ ok $dbh->selectcol_arrayref('PRAGMA foreign_keys')->[0],
+ 'The foreign_keys pragma should be enabled';
+ },
+ add_second_format => q{strftime('%%Y-%%m-%%d %%H:%%M:%%f', strftime('%%J', %s) + (1/86400.0))},
+);
+
+done_testing;
diff --git a/t/status.t b/t/status.t
new file mode 100644
index 00000000..84d11398
--- /dev/null
+++ b/t/status.t
@@ -0,0 +1,616 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 124;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::NoWarnings;
+use Test::Exception;
+use Test::Warn;
+use Test::MockModule;
+use Path::Class;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::status';
+require_ok $CLASS;
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => 'test-status',
+);
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch object';
+isa_ok my $status = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'status',
+ config => $config,
+}), $CLASS, 'status command';
+
+can_ok $status, qw(
+ project
+ show_changes
+ show_tags
+ date_format
+ options
+ execute
+ configure
+ emit_state
+ emit_changes
+ emit_tags
+ emit_status
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_"
+ for qw(ContextCommand ConnectingCommand);
+
+is_deeply [ $CLASS->options ], [qw(
+ project=s
+ target|t=s
+ show-tags
+ show-changes
+ date-format|date=s
+ plan-file|f=s
+ top-dir=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+my $engine_mocker = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+my @projs;
+$engine_mocker->mock( registered_projects => sub { @projs });
+my $initialized;
+$engine_mocker->mock( initialized => sub {
+ diag "Gonna return $initialized" if $ENV{RELEASE_TESTING};
+ $initialized;
+} );
+
+my $mock_target = Test::MockModule->new('App::Sqitch::Target');
+my ($target, $orig_new);
+$mock_target->mock(new => sub { $target = shift->$orig_new(@_); });
+$orig_new = $mock_target->original('new');
+
+# Start with uninitialized database.
+$initialized = 0;
+
+##############################################################################
+# Test project.
+$status->target($status->default_target);
+throws_ok { $status->project } 'App::Sqitch::X',
+ 'Should have error for uninitialized database';
+is $@->ident, 'status', 'Uninitialized database error ident should be "status"';
+is $@->message, __(
+ 'Database not initialized for Sqitch'
+), 'Uninitialized database error message should be correct';
+
+# Specify a project.
+isa_ok $status = $CLASS->new(
+ sqitch => $sqitch,
+ project => 'foo',
+), $CLASS, 'new status command';
+is $status->project, 'foo', 'Should have project "foo"';
+
+# Look up the project in the database.
+ok $sqitch = App::Sqitch->new( config => $config),
+ 'Load a sqitch object with SQLite';
+
+ok $status = $CLASS->new(sqitch => $sqitch), 'Create another status command';
+$status->target($status->default_target);
+throws_ok { $status->project } 'App::Sqitch::X',
+ 'Should get an error for uninitialized db';
+is $@->ident, 'status', 'Uninitialized db error ident should be "status"';
+is $@->message, __ 'Database not initialized for Sqitch',
+ 'Uninitialized db error message should be correct';
+
+# Try no registered projects.
+$initialized = 1;
+throws_ok { $status->project } 'App::Sqitch::X',
+ 'Should get an error for no registered projects';
+is $@->ident, 'status', 'No projects error ident should be "status"';
+is $@->message, __ 'No projects registered',
+ 'No projects error message should be correct';
+
+# Try too many registered projects.
+@projs = qw(foo bar);
+throws_ok { $status->project } 'App::Sqitch::X',
+ 'Should get an error for too many projects';
+is $@->ident, 'status', 'Too many projects error ident should be "status"';
+is $@->message, __x(
+ 'Use --project to select which project to query: {projects}',
+ projects => join __ ', ', @projs,
+), 'Too many projects error message should be correct';
+
+# Go for one project.
+@projs = ('status');
+is $status->project, 'status', 'Should find single project';
+$engine_mocker->unmock_all;
+
+# Fall back on plan project name.
+
+ok $sqitch = App::Sqitch->new(config => TestConfig->new(
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+));
+
+isa_ok $status = $CLASS->new( sqitch => $sqitch ), $CLASS,
+ 'another status command';
+$status->target($status->default_target);
+is $status->project, $target->plan->project, 'Should have plan project';
+
+##############################################################################
+# Test database.
+is $status->target_name, undef, 'Default target should be undef';
+isa_ok $status = $CLASS->new(
+ sqitch => $sqitch,
+ target_name => 'foo',
+), $CLASS, 'new status with target';
+is $status->target_name, 'foo', 'Should have target "foo"';
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {_params => [], _cx => []},
+ 'Should get empty hash for no config or options';
+$config->update('status.date_format' => 'nonesuch');
+throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X',
+ 'Should get error for invalid date format in config';
+is $@->ident, 'datetime',
+ 'Invalid date format error ident should be "datetime"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'nonesuch',
+), 'Invalid date format error message should be correct';
+
+$config->replace(
+ 'status.show_changes' => 1,
+ 'status.show_tags' => 0,
+);
+is_deeply $CLASS->configure($config, {}), {
+ show_changes => 1,
+ show_tags => 0,
+ _params => [],
+ _cx => [],
+}, 'Should get bool values set from config';
+
+throws_ok { $CLASS->configure($config, { date_format => 'non'}), {} }
+ 'App::Sqitch::X',
+ 'Should get error for invalid date format in optsions';
+is $@->ident, 'datetime',
+ 'Invalid date format error ident should be "status"';
+is $@->message, __x(
+ 'Unknown date format "{format}"',
+ format => 'non',
+), 'Invalid date format error message should be correct';
+
+##############################################################################
+# Test emit_state().
+my $dt = App::Sqitch::DateTime->new(
+ year => 2012,
+ month => 7,
+ day => 7,
+ hour => 16,
+ minute => 12,
+ second => 47,
+ time_zone => 'America/Denver',
+);
+
+my $state = {
+ project => 'mystatus',
+ change_id => 'someid',
+ change => 'widgets_table',
+ committer_name => 'fred',
+ committer_email => 'fred@example.com',
+ committed_at => $dt->clone,
+ tags => [],
+ planner_name => 'barney',
+ planner_email => 'barney@example.com',
+ planned_at => $dt->clone->subtract(days => 2),
+};
+$dt->set_time_zone('local');
+my $ts = $dt->as_string( format => $status->date_format );
+
+ok $status->emit_state($state), 'Emit the state';
+is_deeply +MockOutput->get_comment, [
+ [__x 'Project: {project}', project => 'mystatus'],
+ [__x 'Change: {change_id}', change_id => 'someid'],
+ [__x 'Name: {change}', change => 'widgets_table'],
+ [__x 'Deployed: {date}', date => $ts],
+ [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com' ],
+], 'The state should have been emitted';
+
+# Try with a tag.
+$state-> {tags} = ['@alpha'];
+ok $status->emit_state($state), 'Emit the state with a tag';
+is_deeply +MockOutput->get_comment, [
+ [__x 'Project: {project}', project => 'mystatus'],
+ [__x 'Change: {change_id}', change_id => 'someid'],
+ [__x 'Name: {change}', change => 'widgets_table'],
+ [__nx 'Tag: {tags}', 'Tags: {tags}', 1, tags => '@alpha'],
+ [__x 'Deployed: {date}', date => $ts],
+ [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com' ],
+], 'The state should have been emitted with a tag';
+
+# Try with mulitple tags.
+$state-> {tags} = ['@alpha', '@beta', '@gamma'];
+ok $status->emit_state($state), 'Emit the state with multiple tags';
+is_deeply +MockOutput->get_comment, [
+ [__x 'Project: {project}', project => 'mystatus'],
+ [__x 'Change: {change_id}', change_id => 'someid'],
+ [__x 'Name: {change}', change => 'widgets_table'],
+ [__nx 'Tag: {tags}', 'Tags: {tags}', 3,
+ tags => join(__ ', ', qw(@alpha @beta @gamma))],
+ [__x 'Deployed: {date}', date => $ts],
+ [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com' ],
+], 'The state should have been emitted with multiple tags';
+
+##############################################################################
+# Test emit_changes().
+my @current_changes;
+my $project;
+$engine_mocker->mock(current_changes => sub {
+ $project = $_[1];
+ sub { shift @current_changes };
+});
+@current_changes = ({
+ change_id => 'someid',
+ change => 'foo',
+ committer_name => 'anna',
+ committer_email => 'anna@example.com',
+ committed_at => $dt,
+ planner_name => 'anna',
+ planner_email => 'anna@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+});
+$config->replace('core.engine' => 'sqlite');
+$sqitch = App::Sqitch->new(config => $config);
+ok $status = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'status',
+ config => $config,
+}), 'Create status command with an engine';
+
+ok $status->emit_changes, 'Try to emit changes';
+is_deeply +MockOutput->get_comment, [],
+ 'Should have emitted no changes';
+
+ok $status = App::Sqitch::Command::status->new(
+ sqitch => $sqitch,
+ show_changes => 1,
+ project => 'foo',
+), 'Create change-showing status command';
+$status->target($status->default_target);
+
+ok $status->emit_changes, 'Emit changes again';
+is $project, 'foo', 'Project "foo" should have been passed to current_changes';
+is_deeply +MockOutput->get_comment, [
+ [''],
+ [__n 'Change:', 'Changes:', 1],
+ [" foo - $ts - anna <anna\@example.com>"],
+], 'Should have emitted one change';
+
+# Add a couple more changes.
+@current_changes = (
+ {
+ change_id => 'someid',
+ change => 'foo',
+ committer_name => 'anna',
+ committer_email => 'anna@example.com',
+ committed_at => $dt,
+ planner_name => 'anna',
+ planner_email => 'anna@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+ },
+ {
+ change_id => 'anid',
+ change => 'blech',
+ committer_name => 'david',
+ committer_email => 'david@example.com',
+ committed_at => $dt,
+ planner_name => 'david',
+ planner_email => 'david@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+ },
+ {
+ change_id => 'anotherid',
+ change => 'long_name',
+ committer_name => 'julie',
+ committer_email => 'julie@example.com',
+ committed_at => $dt,
+ planner_name => 'julie',
+ planner_email => 'julie@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+ },
+);
+
+ok $status->emit_changes, 'Emit changes thrice';
+is $project, 'foo',
+ 'Project "foo" again should have been passed to current_changes';
+is_deeply +MockOutput->get_comment, [
+ [''],
+ [__n 'Change:', 'Changes:', 3],
+ [" foo - $ts - anna <anna\@example.com>"],
+ [" blech - $ts - david <david\@example.com>"],
+ [" long_name - $ts - julie <julie\@example.com>"],
+], 'Should have emitted three changes';
+
+##############################################################################
+# Test emit_tags().
+my @current_tags;
+$engine_mocker->mock(current_tags => sub {
+ $project = $_[1];
+ sub { shift @current_tags };
+});
+
+ok $status->emit_tags, 'Try to emit tags';
+is_deeply +MockOutput->get_comment, [], 'No tags should have been emitted';
+
+ok $status = App::Sqitch::Command::status->new(
+ sqitch => $sqitch,
+ show_tags => 1,
+ project => 'bar',
+), 'Create tag-showing status command';
+$status->target($status->default_target);
+
+# Try with no tags.
+ok $status->emit_tags, 'Try to emit tags again';
+is $project, 'bar', 'Project "bar" should be passed to current_tags()';
+is_deeply +MockOutput->get_comment, [
+ [''],
+ [__ 'Tags: None.'],
+], 'Should have emitted a header for no tags';
+
+@current_tags = ({
+ tag_id => 'tagid',
+ tag => '@alpha',
+ committer_name => 'duncan',
+ committer_email => 'duncan@example.com',
+ committed_at => $dt,
+ planner_name => 'duncan',
+ planner_email => 'duncan@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+});
+
+ok $status->emit_tags, 'Emit tags';
+is $project, 'bar', 'Project "bar" should again be passed to current_tags()';
+is_deeply +MockOutput->get_comment, [
+ [''],
+ [__n 'Tag:', 'Tags:', 1],
+ [" \@alpha - $ts - duncan <duncan\@example.com>"],
+], 'Should have emitted one tag';
+
+# Add a couple more tags.
+@current_tags = (
+ {
+ tag_id => 'tagid',
+ tag => '@alpha',
+ committer_name => 'duncan',
+ committer_email => 'duncan@example.com',
+ committed_at => $dt,
+ planner_name => 'duncan',
+ planner_email => 'duncan@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+ },
+ {
+ tag_id => 'myid',
+ tag => '@beta',
+ committer_name => 'nick',
+ committer_email => 'nick@example.com',
+ committed_at => $dt,
+ planner_name => 'nick',
+ planner_email => 'nick@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+ },
+ {
+ tag_id => 'yourid',
+ tag => '@gamma',
+ committer_name => 'jacqueline',
+ committer_email => 'jacqueline@example.com',
+ committed_at => $dt,
+ planner_name => 'jacqueline',
+ planner_email => 'jacqueline@example.com',
+ planned_at => $dt->clone->subtract( hours => 4 ),
+ },
+);
+
+ok $status->emit_tags, 'Emit tags again';
+is $project, 'bar', 'Project "bar" should once more be passed to current_tags()';
+is_deeply +MockOutput->get_comment, [
+ [''],
+ [__n 'Tag:', 'Tags:', 3],
+ [" \@alpha - $ts - duncan <duncan\@example.com>"],
+ [" \@beta - $ts - nick <nick\@example.com>"],
+ [" \@gamma - $ts - jacqueline <jacqueline\@example.com>"],
+], 'Should have emitted all three tags';
+
+##############################################################################
+# Test emit_status().
+my $file = file qw(t plans multi.plan);
+$config->update('core.plan_file' => $file->stringify);
+$sqitch = App::Sqitch->new(config => $config);
+ok $status = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'status',
+ config => $config,
+}), 'Create status command with actual plan command';
+$status->target($target = $status->default_target);
+my @changes = $target->plan->changes;
+
+# Start with an up-to-date state.
+$state->{change_id} = $changes[-1]->id;
+ok $status->emit_status($state), 'Emit status';
+is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line';
+is_deeply +MockOutput->get_emit, [
+ [__ 'Nothing to deploy (up-to-date)'],
+], 'Should emit up-to-date output';
+
+# Start with second-to-last change.
+$state->{change_id} = $changes[2]->id;
+ok $status->emit_status($state), 'Emit status again';
+is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line';
+is_deeply +MockOutput->get_emit, [
+ [__n 'Undeployed change:', 'Undeployed changes:', 1],
+ [' * ', $changes[3]->format_name_with_tags],
+], 'Should emit list of undeployed changes';
+
+# Start with second step.
+$state->{change_id} = $changes[1]->id;
+ok $status->emit_status($state), 'Emit status thrice';
+is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line';
+is_deeply +MockOutput->get_emit, [
+ [__n 'Undeployed change:', 'Undeployed changes:', 2],
+ map { [' * ', $_->format_name_with_tags] } @changes[2..$#changes],
+], 'Should emit list of undeployed changes';
+
+# Now go for an ID that cannot be found.
+$state->{change_id} = 'nonesuchid';
+throws_ok { $status->emit_status($state) } 'App::Sqitch::X', 'Die on invalid ID';
+is $@->ident, 'status', 'Invalid ID error ident should be "status"';
+is $@->message, __ 'Make sure you are connected to the proper database for this project.',
+ 'The invalid ID error message should be correct';
+is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line';
+is_deeply +MockOutput->get_vent, [
+ [__x 'Cannot find this change in {file}', file => $file],
+], 'Should have a message about inability to find the change';
+
+##############################################################################
+# Test execute().
+my ($target_name_arg, $orig_meth);
+$target_name_arg = '_blah';
+$mock_target->mock(new => sub {
+ my $self = shift;
+ my %p = @_;
+ $target_name_arg = $p{name};
+ $self->$orig_meth(@_);
+});
+$orig_meth = $mock_target->original('new');
+
+ok $status = App::Sqitch::Command::status->new(
+ sqitch => $sqitch,
+ config => $config,
+), 'Recreate status command';
+
+my $check_output = sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 1;
+ is_deeply +MockOutput->get_comment, [
+ [__x 'On database {db}', db => $target->engine->destination ],
+ [__x 'Project: {project}', project => 'mystatus'],
+ [__x 'Change: {change_id}', change_id => $state->{change_id}],
+ [__x 'Name: {change}', change => 'widgets_table'],
+ [__nx 'Tag: {tags}', 'Tags: {tags}', 3,
+ tags => join(__ ', ', qw(@alpha @beta @gamma))],
+ [__x 'Deployed: {date}', date => $ts],
+ [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com'],
+ [''],
+ ], 'The state should have been emitted';
+ is_deeply +MockOutput->get_emit, [
+ [__n 'Undeployed change:', 'Undeployed changes:', 2],
+ map { [' * ', $_->format_name_with_tags] } @changes[2..$#changes],
+ ], 'Should emit list of undeployed changes';
+};
+
+
+$state->{change_id} = $changes[1]->id;
+$engine_mocker->mock( current_state => $state );
+ok $status->execute, 'Execute';
+$check_output->();
+is $target_name_arg, undef, 'No target name should have been passed to Target';
+
+# Test with a database argument.
+ok $status->execute('db:sqlite:'), 'Execute with target arg';
+$check_output->();
+is $target_name_arg, 'db:sqlite:', 'Name "db:sqlite:" should have been passed to Target';
+
+# Pass the target in an option.
+ok $status = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'status',
+ config => $config,
+ args => ['--target', 'db:sqlite:'],
+}), 'Create status command with a target option';
+ok $status->execute, 'Execute with target attribute';
+$check_output->();
+is $target_name_arg, 'db:sqlite:', 'Name "db:sqlite:" should have been passed to Target';
+
+# Test with two targets.
+ok $status->execute('db:pg:'), 'Execute with target attribute and arg';
+$check_output->();
+is $target_name_arg, 'db:pg:', 'Name "db:sqlite:" should have been passed to Target';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; connecting to {option}',
+ option => $status->target_name,
+)]], 'Should have got warning for two targets';
+
+# Test with a plan file param and no option.
+ok $status = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'status',
+ config => $config,
+}), 'Create status command with no target option';
+ok $status->execute($file), 'Execute with plan file';
+$check_output->();
+is $target_name_arg, 'db:sqlite:', 'Name "db:sqlite:" should have been passed to Target';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+# Test with unknown plan.
+for my $spec (
+ [ 'specified', App::Sqitch->new(config => $config) ],
+ [ 'external', $sqitch ],
+) {
+ my ( $desc, $sqitch ) = @{ $spec };
+ ok $status = $CLASS->new(
+ sqitch => $sqitch,
+ project => 'foo',
+ ), "Create status command with $desc project";
+
+ ok $status->execute, "Execute for $desc project";
+ is_deeply +MockOutput->get_comment, [
+ [__x 'On database {db}', db => $target->engine->destination ],
+ [__x 'Project: {project}', project => 'mystatus'],
+ [__x 'Change: {change_id}', change_id => $state->{change_id}],
+ [__x 'Name: {change}', change => 'widgets_table'],
+ [__nx 'Tag: {tags}', 'Tags: {tags}', 3,
+ tags => join(__ ', ', qw(@alpha @beta @gamma))],
+ [__x 'Deployed: {date}', date => $ts],
+ [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com'],
+ [''],
+ ], "The $desc project state should have been emitted";
+ is_deeply +MockOutput->get_emit, [
+ [__x 'Status unknown. Use --plan-file to assess "{project}" status', project => 'foo'],
+ ], "Should emit unknown status message for $desc project";
+}
+
+# Test with no changes.
+$engine_mocker->mock( current_state => undef );
+throws_ok { $status->execute } 'App::Sqitch::X', 'Die on no state';
+is $@->ident, 'status', 'No state error ident should be "status"';
+is $@->message, __ 'No changes deployed',
+ 'No state error message should be correct';
+is_deeply +MockOutput->get_comment, [
+ [__x 'On database {db}', db => $target->engine->destination ],
+], 'The "On database" comment should have been emitted';
+
+# Test with no initilization.
+$initialized = 0;
+$engine_mocker->mock( initialized => sub { $initialized } );
+$engine_mocker->mock( current_state => sub { die 'No Sqitch tables' } );
+throws_ok { $status->execute } 'App::Sqitch::X',
+ 'Should get an error for uninitialized db';
+is $@->ident, 'status', 'Uninitialized db error ident should be "status"';
+is $@->message, __x(
+ 'Database {db} has not been initialized for Sqitch',
+ db => $status->engine->destination,
+), 'Uninitialized db error message should be correct';
diff --git a/t/tag.t b/t/tag.t
new file mode 100644
index 00000000..fcfe4348
--- /dev/null
+++ b/t/tag.t
@@ -0,0 +1,167 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+use Test::More tests => 22;
+#use Test::More 'no_plan';
+use Test::NoWarnings;
+use Path::Class;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use Test::MockModule;
+use Digest::SHA;
+use URI;
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Plan::Tag';
+ require_ok $CLASS or die;
+ delete $ENV{PGDATABASE};
+ delete $ENV{PGUSER};
+ delete $ENV{USER};
+}
+
+can_ok $CLASS, qw(
+ name
+ info
+ id
+ lspace
+ rspace
+ note
+ plan
+ timestamp
+ planner_name
+ planner_email
+ format_planner
+);
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+);
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target);
+my $change = App::Sqitch::Plan::Change->new( plan => $plan, name => 'roles' );
+
+isa_ok my $tag = $CLASS->new(
+ name => 'foo',
+ plan => $plan,
+ change => $change,
+), $CLASS;
+isa_ok $tag, 'App::Sqitch::Plan::Line';
+my $mock_plan = Test::MockModule->new('App::Sqitch::Plan');
+$mock_plan->mock(index_of => 0); # no other changes
+
+is $tag->format_name, '@foo', 'Name should format as "@foo"';
+isa_ok $tag->timestamp, 'App::Sqitch::DateTime', 'Timestamp';
+
+is $tag->planner_name, $sqitch->user_name,
+ 'Planner name shoudld default to user name';
+is $tag->planner_email, $sqitch->user_email,
+ 'Planner email shoudld default to user email';
+is $tag->format_planner, join(
+ ' ',
+ $sqitch->user_name,
+ '<' . $sqitch->user_email . '>'
+), 'Planner name and email should format properly';
+
+my $ts = $tag->timestamp->as_string;
+is $tag->as_string, "\@foo $ts ". $tag->format_planner,
+ 'Should as_string to "@foo" + timstamp + planner';
+my $uri = URI->new('https://github.com/sqitchers/sqitch/');
+$mock_plan->mock( uri => $uri );
+is $tag->info, join("\n",
+ 'project sql',
+ 'uri https://github.com/sqitchers/sqitch/',
+ 'tag @foo',
+ 'change ' . $change->id,
+ 'planner ' . $tag->format_planner,
+ 'date ' . $ts,
+), 'Tag info should incldue the URI';
+
+my $date = App::Sqitch::DateTime->new(
+ year => 2012,
+ month => 7,
+ day => 16,
+ hour => 17,
+ minute => 25,
+ second => 7,
+ time_zone => 'UTC',
+);
+
+ok $tag = $CLASS->new(
+ name => 'howdy',
+ plan => $plan,
+ change => $change,
+ lspace => ' ',
+ rspace => "\t",
+ note => 'blah blah blah',
+ timestamp => $date,
+ planner_name => 'Barack Obama',
+ planner_email => 'potus@whitehouse.gov',
+), 'Create tag with more stuff';
+
+my $ts2 = '2012-07-16T17:25:07Z';
+is $tag->as_string,
+ " \@howdy $ts2 Barack Obama <potus\@whitehouse.gov>\t# blah blah blah",
+ 'It should as_string correctly';
+
+$mock_plan->mock(index_of => 1);
+$mock_plan->mock(change_at => $change);
+is $tag->change, $change, 'Change should be correct';
+is $tag->format_planner, 'Barack Obama <potus@whitehouse.gov>',
+ 'Planner name and email should format properly';
+
+# Make sure it gets the change even if there is a tag in between.
+my @prevs = ($tag, $change);
+$mock_plan->mock(index_of => 8);
+$mock_plan->mock(change_at => sub { shift @prevs });
+is $tag->change, $change, 'Change should be for previous change';
+
+is $tag->info, join("\n",
+ 'project sql',
+ 'uri https://github.com/sqitchers/sqitch/',
+ 'tag @howdy',
+ 'change ' . $change->id,
+ 'planner Barack Obama <potus@whitehouse.gov>',
+ 'date 2012-07-16T17:25:07Z',
+ '', 'blah blah blah',
+), 'Tag info should include the change';
+
+is $tag->id, do {
+ my $content = $tag->info;
+ Digest::SHA->new(1)->add(
+ 'tag ' . length($content) . "\0" . $content
+ )->hexdigest;
+},'Tag ID should be correct';
+
+##############################################################################
+# Test ID for a tag with a UTF-8 name.
+ok $tag = $CLASS->new(
+ name => '阱阪阬',
+ plan => $plan,
+ change => $change,
+), 'Create tag with UTF-8 name';
+
+is $tag->info, join("\n",
+ 'project sql',
+ 'uri https://github.com/sqitchers/sqitch/',
+ 'tag ' . '@阱阪阬',
+ 'change ' . $change->id,
+ 'planner ' . $tag->format_planner,
+ 'date ' . $tag->timestamp->as_string,
+), 'The name should be decoded text in info';
+
+is $tag->id, do {
+ my $content = Encode::encode_utf8 $tag->info;
+ Digest::SHA->new(1)->add(
+ 'tag ' . length($content) . "\0" . $content
+ )->hexdigest;
+},'Tag ID should be hahsed from encoded UTF-8';
diff --git a/t/tag_cmd.t b/t/tag_cmd.t
new file mode 100644
index 00000000..ba645ee4
--- /dev/null
+++ b/t/tag_cmd.t
@@ -0,0 +1,370 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 86;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::Exception;
+use Test::Warn;
+use Test::NoWarnings;
+use Path::Class qw(file dir);
+use File::Path qw(make_path remove_tree);
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::tag';
+
+my $dir = dir 'test-tag_cmd';
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => $dir->stringify,
+);
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+isa_ok my $tag = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'tag',
+ config => $config,
+}), $CLASS, 'tag command';
+ok !$tag->all, 'The all attribute should be false by default';
+
+can_ok $CLASS, qw(
+ options
+ configure
+ note
+ execute
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::ContextCommand"),
+ "$CLASS does ContextCommand";
+
+is_deeply [$CLASS->options], [qw(
+ tag-name|tag|t=s
+ change-name|change|c=s
+ all|a!
+ note|n|m=s@
+ plan-file|f=s
+ top-dir=s
+)], 'Should have note option';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+##############################################################################
+# Test configure().
+my (@params, $orig_get);
+my $cmock = TestConfig->mock(
+ get => sub { my $c = shift; push @params, \@_; $orig_get->($c, @_) },
+);
+$orig_get = $cmock->original('get');
+
+is_deeply $CLASS->configure($config, {}), { _cx => [] },
+ 'Should get empty hash for no config or options';
+is_deeply \@params, [], 'Should not have fetched boolean tag.all config';
+@params = ();
+is_deeply $CLASS->configure(
+ $config,
+ { tag_name => 'foo', change_name => 'bar', all => 1 }
+),
+ { tag_name => 'foo', change_name => 'bar', all => 1, _cx => [] },
+ 'Should get populated hash for no all options';
+
+is_deeply \@params, [], 'Should not have fetched boolean tag.all config';
+@params = ();
+$cmock->unmock_all;
+
+##############################################################################
+# Test tagging a single plan.
+make_path $dir->stringify;
+END { remove_tree $dir->stringify };
+my $plan_file = $tag->default_target->plan_file;
+$plan_file->spew("%project=empty\n\n");
+
+# Override request_note().
+my $tag_mocker = Test::MockModule->new('App::Sqitch::Plan::Tag');
+my %request_params;
+$tag_mocker->mock(request_note => sub {
+ my $self = shift;
+ %request_params = @_;
+ $self->note;
+});
+
+my $reload = sub {
+ my $plan = shift;
+ $plan->_plan( $plan->load);
+ delete $plan->{$_} for qw(_changes _lines project uri);
+ 1;
+};
+
+my $plan = $tag->default_target->plan;
+ok $plan->add( name => 'foo' ), 'Add change "foo"';
+$plan->write_to( $plan->file );
+
+# Tag it.
+isa_ok $tag = App::Sqitch::Command::tag->new({ sqitch => $sqitch }),
+ $CLASS, 'new tag command';
+ok $tag->execute('alpha'), 'Tag @alpha';
+ok $reload->($plan), 'Reload plan';
+is $plan->get('@alpha')->name, 'foo', 'Should have tagged "foo"';
+is $plan->get('@alpha')->name, 'foo', 'New tag should have been written';
+is [$plan->tags]->[-1]->note, '', 'New tag should have empty note';
+is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'foo',
+ tag => '@alpha',
+ file => $plan->file,
+ ]
+], 'The info message should be correct';
+
+# With no arg, should get a list of tags.
+ok $tag->execute, 'Execute with no arg';
+is_deeply +MockOutput->get_info, [
+ ['@alpha'],
+], 'The one tag should have been listed';
+is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note';
+
+# Add a tag.
+ok $plan->tag( name => '@beta' ), 'Add tag @beta';
+$plan->write_to( $plan->file );
+ok $tag->execute, 'Execute with no arg again';
+is_deeply +MockOutput->get_info, [
+ ['@alpha'],
+ ['@beta'],
+], 'Both tags should have been listed';
+is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note';
+
+# Set a note and a name.
+isa_ok $tag = App::Sqitch::Command::tag->new({
+ sqitch => $sqitch,
+ note => [qw(hello there)],
+ tag_name => 'gamma',
+}), $CLASS, 'tag command with note';
+$plan = $tag->default_target->plan;
+
+ok $tag->execute, 'Tag @gamma';
+is $plan->get('@gamma')->name, 'foo', 'Gamma tag should be on change "foo"';
+is [$plan->tags]->[-1]->note, "hello\n\nthere", 'Gamma tag should have note';
+ok $reload->($plan), 'Reload plan';
+is $plan->get('@gamma')->name, 'foo', 'Gamma tag should have been written';
+is [$plan->tags]->[-1]->note, "hello\n\nthere", 'Written tag should have note';
+is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'foo',
+ tag => '@gamma',
+ file => $plan->file,
+ ]
+], 'The gamma note should be correct';
+
+# Tag a specific change.
+isa_ok $tag = App::Sqitch::Command::tag->new({
+ sqitch => $sqitch,
+ note => ['here we go'],
+}), $CLASS, 'tag command with note';
+$plan = $tag->default_target->plan;
+
+ok $plan->add( name => 'bar' ), 'Add change "bar"';
+ok $plan->add( name => 'baz' ), 'Add change "baz"';
+$plan->write_to( $plan->file );
+ok $tag->execute('delta', 'bar'), 'Tag change "bar" with @delta';
+ok $reload->($plan), 'Reload plan';
+is $plan->get('@delta')->name, 'bar', 'Should have tagged "bar"';
+ok $reload->($plan), 'Reload plan';
+is $plan->get('@delta')->name, 'bar', 'New tag should have been written';
+is [$plan->tags]->[-1]->note, 'here we go', 'New tag should have the proper note';
+is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'bar',
+ tag => '@delta',
+ file => $plan->file,
+ ]
+], 'The info message should be correct';
+
+# Use --change to tage a specific change.
+isa_ok $tag = App::Sqitch::Command::tag->new({
+ sqitch => $sqitch,
+ change_name => 'bar',
+ note => ['here we go'],
+}), $CLASS, 'tag command with change name';
+$plan = $tag->default_target->plan;
+
+ok $tag->execute('zeta'), 'Tag change "bar" with @zeta';
+is $plan->get('@zeta')->name, 'bar', 'Should have tagged "bar" with @zeta';
+ok $reload->($plan), 'Reload plan';
+is $plan->get('@zeta')->name, 'bar', 'Tag @zeta should have been written';
+is [$plan->tags]->[-1]->note, 'here we go', 'Tag @zeta should have the proper note';
+is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'bar',
+ tag => '@zeta',
+ file => $plan->file,
+ ]
+], 'The zeta info message should be correct';
+
+##############################################################################
+# Let's deal with multiple engines.
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'engine.pg.plan_file' => $plan->file->stringify,
+ 'engine.sqlite.plan_file' => $plan->file->stringify,
+ 'engine.mysql.plan_file' => $plan->file->stringify,
+);
+
+ok $sqitch = App::Sqitch->new(config => $config),
+ 'Load another sqitch sqitch object';
+
+isa_ok $tag = App::Sqitch::Command::tag->new({
+ sqitch => $sqitch,
+ all => 1,
+ note => ['here we go again'],
+}), $CLASS, 'another tag command';
+$plan = $tag->default_target->plan;
+ok $tag->execute('whacko'), 'Tag with @whacko';
+is $plan->get('@whacko')->name, 'baz', 'Should have tagged "baz" with @whacko';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'baz',
+ tag => '@whacko',
+ file => $plan->file,
+ ]
+], 'The whacko info message should be correct';
+
+# With --all and args, should get an error.
+throws_ok { $tag->execute('fred', 'pg') } 'App::Sqitch::X',
+ 'Should get an error for --all and a target arg';
+is $@->ident, 'tag', 'Mixed arguments error ident should be "tag"';
+is $@->message, __(
+ 'Cannot specify both --all and engine, target, or plan arugments'
+), 'Mixed arguments error message should be correct';
+
+# Great. Now try two plans!
+(my $pg = $dir->file('pg.plan')->stringify) =~ s{\\}{\\\\}g;
+(my $sqlite = $dir->file('sqlite.plan')->stringify) =~ s{\\}{\\\\}g;
+$dir->file("$_.plan")->spew(
+ "%project=tag\n\n${_}_change 2012-07-16T17:25:07Z Hi <hi\@foo.com>\n"
+) for qw(pg sqlite);
+
+$config->replace(
+ 'core.engine' => 'pg',
+ 'core.top_dir' => $dir->stringify,
+ 'engine.pg.plan_file' => $pg,
+ 'engine.sqlite.plan_file' => $sqlite,
+ 'tag.all' => 1,
+);
+ok $sqitch = App::Sqitch->new(config => $config),
+ 'Load another sqitch sqitch object';
+isa_ok $tag = App::Sqitch::Command::tag->new({
+ sqitch => $sqitch,
+ note => ['here we go again'],
+}), $CLASS, 'yet another tag command';
+
+ok $tag->execute('dubdub'), 'Tag with @dubdub';
+my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+is @targets, 2, 'Should have two targets';
+is $targets[0]->plan->get('@dubdub')->name, 'pg_change',
+ 'Should have tagged pg plan change "pg_change" with @dubdub';
+is $targets[1]->plan->get('@dubdub')->name, 'sqlite_change',
+ 'Should have tagged sqlite plan change "sqlite_change" with @dubdub';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'pg_change',
+ tag => '@dubdub',
+ file => $targets[0]->plan_file,
+ ],
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'sqlite_change',
+ tag => '@dubdub',
+ file => $targets[1]->plan_file,
+ ],
+], 'The dubdub info message should show both plans tagged';
+
+# With tag.all and an argument, we should just get the argument.
+ok $tag->execute('shoot', 'sqlite'), 'Tag sqlite plan with @shoot';
+@targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+is @targets, 2, 'Should still have two targets';
+ok !$targets[0]->plan->get('@shoot'),
+ 'Should not have tagged pg plan change "sqlite_change" with @shoot';
+is $targets[1]->plan->get('@shoot')->name, 'sqlite_change',
+ 'Should have tagged sqlite plan change "sqlite_change" with @shoot';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'sqlite_change',
+ tag => '@shoot',
+ file => $targets[1]->plan_file,
+ ],
+], 'The shoot info message should the sqlite plan getting tagged';
+
+# Without --all or tag.all, we should just get the default target.
+$config->replace(
+ 'core.engine' => 'pg',
+ 'core.to_dir' => $dir->stringify,
+ 'engine.pg.plan_file' => $pg,
+ 'engine.sqlite.plan_file' => $sqlite,
+);
+$sqitch = App::Sqitch->new(config => $config);
+isa_ok $tag = App::Sqitch::Command::tag->new({
+ sqitch => $sqitch,
+ note => ['here we go again'],
+}), $CLASS, 'yet another tag command';
+ok $tag->execute('huwah'), 'Tag with @huwah';
+@targets = App::Sqitch::Target->all_targets(sqitch => $sqitch);
+is @targets, 2, 'Should still have two targets';
+is $targets[0]->plan->get('@huwah')->name, 'pg_change',
+ 'Should have tagged pg plan change "pg_change" with @huwah';
+ok !$targets[1]->plan->get('@huwah'),
+ 'Should not have tagged sqlite plan change "sqlite_change" with @huwah';
+
+is_deeply +MockOutput->get_info, [
+ [__x
+ 'Tagged "{change}" with {tag} in {file}',
+ change => 'pg_change',
+ tag => '@huwah',
+ file => $targets[0]->plan_file,
+ ],
+], 'The huwah info message should the pg plan getting tagged';
+
+# Make sure we die if the passed name conflicts with a target.
+TARGET: {
+ my $mock_add = Test::MockModule->new($CLASS);
+ $mock_add->mock(parse_args => sub {
+ return undef, undef, [$tag->default_target];
+ });
+ $mock_add->mock(name => 'blog');
+ my $mock_target = Test::MockModule->new('App::Sqitch::Target');
+ $mock_target->mock(name => 'blog');
+
+ throws_ok { $tag->execute('blog') } 'App::Sqitch::X',
+ 'Should get an error for conflict with target name';
+ is $@->ident, 'tag', 'Conflicting target error ident should be "tag"';
+ is $@->message, __x(
+ 'Name "{name}" identifies a target; use "--tag {name}" to use it for the tag name',
+ name => 'blog',
+ ), 'Conflicting target error message should be correct';
+}
diff --git a/t/target.conf b/t/target.conf
new file mode 100644
index 00000000..3b743724
--- /dev/null
+++ b/t/target.conf
@@ -0,0 +1,13 @@
+[core]
+ engine = pg
+
+[target "dev"]
+ uri = db:pg:widgets
+
+[target "qa"]
+ uri = db:pg://qa.example.com/qa_widgets
+ registry = meta
+ client = /usr/sbin/psql
+
+[target "prod"]
+ uri = db:pg://prod.example.us/pr_widgets
diff --git a/t/target.t b/t/target.t
new file mode 100644
index 00000000..075a4658
--- /dev/null
+++ b/t/target.t
@@ -0,0 +1,669 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More;
+use App::Sqitch;
+use Path::Class qw(dir file);
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use List::Util qw(first);
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+BEGIN {
+ $CLASS = 'App::Sqitch::Target';
+ use_ok $CLASS or die;
+}
+
+##############################################################################
+# Load a target and test the basics.
+my $config = TestConfig->new('core.engine' => 'sqlite');
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS;
+can_ok $target, qw(
+ new
+ name
+ target
+ uri
+ sqitch
+ engine
+ registry
+ client
+ plan_file
+ plan
+ top_dir
+ deploy_dir
+ revert_dir
+ verify_dir
+ reworked_dir
+ reworked_deploy_dir
+ reworked_revert_dir
+ reworked_verify_dir
+ extension
+ variables
+);
+
+# Look at default values.
+is $target->name, 'db:sqlite:', 'Name should be "db:sqlite:"';
+is $target->target, $target->name, 'Target should be alias for name';
+is $target->uri, URI::db->new('db:sqlite:'), 'URI should be "db:sqlite:"';
+is $target->sqitch, $sqitch, 'Sqitch should be as passed';
+is $target->engine_key, 'sqlite', 'Engine key should be "sqlite"';
+isa_ok $target->engine, 'App::Sqitch::Engine::sqlite', 'Engine';
+is $target->registry, $target->engine->default_registry,
+ 'Should have default registry';
+my $client = $target->engine->default_client;
+$client .= '.exe' if App::Sqitch::ISWIN && $client !~ /[.](?:exe|bat)$/;
+is $target->client, $client, 'Should have default client';
+is $target->top_dir, dir, 'Should have default top_dir';
+is $target->deploy_dir, $target->top_dir->subdir('deploy'),
+ 'Should have default deploy_dir';
+is $target->revert_dir, $target->top_dir->subdir('revert'),
+ 'Should have default revert_dir';
+is $target->verify_dir, $target->top_dir->subdir('verify'),
+ 'Should have default verify_dir';
+is $target->reworked_dir, $target->top_dir, 'Should have default reworked_dir';
+is $target->reworked_deploy_dir, $target->reworked_dir->subdir('deploy'),
+ 'Should have default reworked_deploy_dir';
+is $target->reworked_revert_dir, $target->reworked_dir->subdir('revert'),
+ 'Should have default reworked_revert_dir';
+is $target->reworked_verify_dir, $target->reworked_dir->subdir('verify'),
+ 'Should have default reworked_verify_dir';
+is $target->extension, 'sql', 'Should have default extension';
+is $target->plan_file, $target->top_dir->file('sqitch.plan')->cleanup,
+ 'Should have default plan file';
+isa_ok $target->plan, 'App::Sqitch::Plan', 'Should get plan';
+is $target->plan->file, $target->plan_file,
+ 'Plan file should be copied from Target';
+my $uri = $target->uri;
+is $target->dsn, $uri->dbi_dsn, 'DSN should be from URI';
+is $target->username, $uri->user, 'Username should be from URI';
+is $target->password, $uri->password, 'Password should be from URI';
+is_deeply $target->variables, {}, 'Variables should be empty';
+
+do {
+ isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS;
+ local $ENV{SQITCH_USERNAME} = 'kamala';
+ local $ENV{SQITCH_PASSWORD} = 'S3cre7s';
+ is $target->username, $ENV{SQITCH_USERNAME},
+ 'Username should be from environment variable';
+ is $target->password, $ENV{SQITCH_PASSWORD},
+ 'Password should be from environment variable';
+};
+
+##############################################################################
+# Let's look at how the object is created based on the params to new().
+# First try no params.
+throws_ok { $CLASS->new } qr/^Missing required arguments:/,
+ 'Should get error for missing params';
+
+# Pass both name and URI.
+$uri = URI::db->new('db:pg://hi:there@localhost/blah'),
+isa_ok $target = $CLASS->new(
+ sqitch => $sqitch,
+ name => 'foo',
+ uri => $uri,
+ variables => {a => 1},
+), $CLASS, 'Target with name and URI';
+
+is $target->name, 'foo', 'Name should be "foo"';
+is $target->target, $target->name, 'Target should be alias for name';
+is $target->uri, $uri, 'URI should be set as passed';
+is $target->sqitch, $sqitch, 'Sqitch should be as passed';
+is $target->engine_key, 'pg', 'Engine key should be "pg"';
+isa_ok $target->engine, 'App::Sqitch::Engine::pg', 'Engine';
+is $target->dsn, $uri->dbi_dsn, 'DSN should be from URI';
+is $target->username, 'hi', 'Username should be from URI';
+do {
+ local $ENV{SQITCH_PASSWORD} = 'lolz';
+ is $target->password, 'lolz', 'Password should be from environment';
+};
+is_deeply $target->variables, {a => 1}, 'Variables should be set';
+
+# Pass a URI but no name.
+isa_ok $target = $CLASS->new(
+ sqitch => $sqitch,
+ uri => $uri,
+), $CLASS, 'Target with URI';
+like $target->name, qr{db:pg://hi:?\@localhost/blah},
+ 'Name should be URI without password';
+is $target->target, $target->name, 'Target should be alias for name';
+is $target->engine_key, 'pg', 'Engine key should be "pg"';
+isa_ok $target->engine, 'App::Sqitch::Engine::pg', 'Engine';
+is $target->dsn, $uri->dbi_dsn, 'DSN should be from URI';
+is $target->username, $uri->user, 'Username should be from URI';
+is $target->password, $uri->password, 'Password should be from URI';
+
+# Set the URI via SQITCH_TARGET.
+ENV: {
+ local $ENV{SQITCH_TARGET} = 'db:pg:';
+ isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS,
+ 'Target from environment';
+ is $target->name, 'db:pg:', 'Name should be set';
+ is $target->uri, 'db:pg:', 'URI should be set';
+ is $target->engine_key, 'pg', 'Engine key should be "pg"';
+ isa_ok $target->engine, 'App::Sqitch::Engine::pg', 'Engine';
+}
+
+# Set up a config.
+CONSTRUCTOR: {
+ my (@get_params, $orig_get);
+ my $mock = TestConfig->mock(
+ get => sub { my $c = shift; push @get_params => \@_; $orig_get->($c, @_); }
+ );
+ $orig_get = $mock->original('get');
+ $config->replace('core.engine' => 'sqlite');
+
+ # Pass neither, but rely on the engine in the Sqitch object.
+ my $sqitch = App::Sqitch->new(config => $config);
+ isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Default target';
+ is $target->name, 'db:sqlite:', 'Name should be "db:sqlite:"';
+ is $target->uri, URI::db->new('db:sqlite:'), 'URI should be "db:sqlite:"';
+ is_deeply \@get_params, [
+ [key => 'core.target'],
+ [key => 'core.engine'],
+ [key => 'engine.sqlite.target'],
+ ], 'Should have tried to get engine target';
+
+ # Try with just core.engine.
+ delete $sqitch->options->{engine};
+ $config->update('core.engine' => 'mysql');
+ @get_params = ();
+ isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Default target';
+ is $target->name, 'db:mysql:', 'Name should be "db:mysql:"';
+ is $target->uri, URI::db->new('db:mysql:'), 'URI should be "db:mysql"';
+ is_deeply \@get_params, [
+ [key => 'core.target'],
+ [key => 'core.engine'],
+ [key => 'engine.mysql.target'],
+ ], 'Should have tried to get core.target, core.engine and then the target';
+
+ # Try with no engine option but a name that looks like a URI.
+ @get_params = ();
+ delete $sqitch->options->{engine};
+ isa_ok $target = $CLASS->new(
+ sqitch => $sqitch,
+ name => 'db:pg:',
+ ), $CLASS, 'Target with URI in name';
+ is $target->name, 'db:pg:', 'Name should be "db:pg:"';
+ is $target->uri, URI::db->new('db:pg:'), 'URI should be "db:pg"';
+ is_deeply \@get_params, [], 'Should have fetched no config';
+
+ # Try it with a name with no engine.
+ throws_ok { $CLASS->new(sqitch => $sqitch, name => 'db:') } 'App::Sqitch::X',
+ 'Should have error for no engine in URI';
+ is $@->ident, 'target', 'Should have target ident';
+ is $@->message, __x(
+ 'No engine specified by URI {uri}; URI must start with "db:$engine:"',
+ uri => 'db:',
+ ), 'Should have message about no engine-less URI';
+
+ # Try it with no configured core engine or target.
+ $config->replace;
+ throws_ok { $CLASS->new(sqitch => $sqitch) } 'App::Sqitch::X',
+ 'Should have error for no engine or target';
+ is $@->ident, 'target', 'Should have target ident';
+ is $@->message, __(
+ 'No project configuration found. Run the "init" command to initialize a project',
+ ), 'Should have message about no configuration';
+
+ # Try it with a config file but no engine config.
+ MOCK: {
+ my $mock_init = TestConfig->mock(initialized => 1);
+ throws_ok { $CLASS->new(sqitch => $sqitch) } 'App::Sqitch::X',
+ 'Should again have error for no engine or target';
+ is $@->ident, 'target', 'Should have target ident again';
+ is $@->message, __(
+ 'No engine specified; specify via target or core.engine',
+ ), 'Should have message about no specified engine';
+ }
+
+ # Try with engine-less URI.
+ @get_params = ();
+ isa_ok $target = $CLASS->new(
+ sqitch => $sqitch,
+ uri => URI::db->new('db:'),
+ ), $CLASS, 'Engineless target';
+ is $target->name, 'db:', 'Name should be "db:"';
+ is $target->uri, URI::db->new('db:'), 'URI should be "db:"';
+ is_deeply \@get_params, [], 'Should not have tried to get engine target';
+
+ is $target->sqitch, $sqitch, 'Sqitch should be as passed';
+ is $target->engine_key, undef, 'Engine key should be undef';
+ throws_ok { $target->engine } 'App::Sqitch::X',
+ 'Should get exception for no engine';
+ is $@->ident, 'engine', 'Should have engine ident';
+ is $@->message, __(
+ 'No engine specified; specify via target or core.engine',
+ ), 'Should have message about no engine';
+
+ is $target->top_dir, dir, 'Should have default top_dir';
+ is $target->deploy_dir, $target->top_dir->subdir('deploy'),
+ 'Should have default deploy_dir';
+ is $target->revert_dir, $target->top_dir->subdir('revert'),
+ 'Should have default revert_dir';
+ is $target->verify_dir, $target->top_dir->subdir('verify'),
+ 'Should have default verify_dir';
+ is $target->reworked_dir, $target->top_dir, 'Should have default reworked_dir';
+ is $target->reworked_deploy_dir, $target->reworked_dir->subdir('deploy'),
+ 'Should have default reworked_deploy_dir';
+ is $target->reworked_revert_dir, $target->reworked_dir->subdir('revert'),
+ 'Should have default reworked_revert_dir';
+ is $target->reworked_verify_dir, $target->reworked_dir->subdir('verify'),
+ 'Should have default reworked_verify_dir';
+ is $target->extension, 'sql', 'Should have default extension';
+ is $target->plan_file, $target->top_dir->file('sqitch.plan')->cleanup,
+ 'Should have default plan file';
+ isa_ok $target->plan, 'App::Sqitch::Plan', 'Should get plan';
+ is $target->plan->file, $target->plan_file,
+ 'Plan file should be copied from Target';
+ is $target->dsn, '', 'DSN should be empty';
+ is $target->username, undef, 'Username should be undef';
+ is $target->password, undef, 'Password should be undef';
+
+ # Try passing a proper URI via the name.
+ @get_params = ();
+ isa_ok $target = $CLASS->new(sqitch => $sqitch, name => 'db:pg://a:b@foo/scat'), $CLASS,
+ 'Engine URI target';
+ like $target->name, qr{db:pg://a:?\@foo/scat}, 'Name should be "db:pg://a@foo/scat"';
+ is $target->uri, URI::db->new('db:pg://a:b@foo/scat'),
+ 'URI should be "db:pg://a:b@foo/scat"';
+ is_deeply \@get_params, [], 'Nothing should have been fetched from config';
+
+ # Pass nothing, but let a URI be in core.target.
+ @get_params = ();
+ $config->update('core.target' => 'db:pg://s:b@ack/shi');
+ isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS,
+ 'Engine URI core.target';
+ like $target->name, qr{db:pg://s:?\@ack/shi}, 'Name should be "db:pg://s@ack/shi"';
+ is $target->uri, URI::db->new('db:pg://s:b@ack/shi'),
+ 'URI should be "db:pg://s:b@ack/shi"';
+ is_deeply \@get_params, [[key => 'core.target']],
+ 'Should have fetched core.target from config';
+
+ # Pass nothing, but let a target name be in core.target.
+ @get_params = ();
+ $config->update(
+ 'core.target' => 'shout',
+ 'target.shout.uri' => 'db:pg:w:e@we/bar',
+ );
+ isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS,
+ 'Engine name core.target';
+ is $target->name, 'shout', 'Name should be "shout"';
+ is $target->uri, URI::db->new('db:pg:w:e@we/bar'),
+ 'URI should be "db:pg:w:e@we/bar"';
+ is_deeply \@get_params, [
+ [key => 'core.target'],
+ [key => 'target.shout.uri']
+ ], 'Should have fetched target.shout.uri from config';
+
+ # Mock get_section.
+ my (@sect_params, $orig_sect);
+ $mock->mock(get_section => sub {
+ my $c = shift; push @sect_params => \@_; $orig_sect->($c, @_);
+ });
+ $orig_sect = $mock->original('get_section');
+
+ # Try it with a name.
+ $sqitch->options->{engine} = 'sqlite';
+ @get_params = ();
+ throws_ok { $CLASS->new(sqitch => $sqitch, name => 'foo') } 'App::Sqitch::X',
+ 'Should have exception for unknown named target';
+ is $@->ident, 'target', 'Unknown target error ident should be "target"';
+ is $@->message, __x(
+ 'Cannot find target "{target}"',
+ target => 'foo',
+ ), 'Unknown target error message should be correct';
+ is_deeply \@get_params, [[key => 'target.foo.uri']],
+ 'Should have requested target URI from config';
+ is_deeply \@sect_params, [[section => 'target.foo']],
+ 'Should have requested target.foo section';
+
+ # Let the name section exist, but without a URI.
+ @get_params = @sect_params = ();
+ $config->replace('target.foo.bar' => 1);
+ throws_ok { $CLASS->new(sqitch => $sqitch, name => 'foo') } 'App::Sqitch::X',
+ 'Should have exception for URL-less named target';
+ is $@->ident, 'target', 'URL-less target error ident should be "target"';
+ is $@->message, __x(
+ 'No URI associated with target "{target}"',
+ target => 'foo',
+ ), 'URL-less target error message should be correct';
+ is_deeply \@get_params, [[key => 'target.foo.uri']],
+ 'Should have requested target URI from config';
+ is_deeply \@sect_params, [[section => 'target.foo']],
+ 'Should have requested target.foo section';
+
+ # Now give it a URI.
+ @get_params = @sect_params = ();
+ $config->replace( 'target.foo.uri' => 'db:pg:foo');
+ $sqitch = App::Sqitch->new(config => $config);
+ isa_ok $target = $CLASS->new(sqitch => $sqitch, name => 'foo'), $CLASS,
+ 'Named target';
+ is $target->name, 'foo', 'Name should be "foo"';
+ is $target->uri, URI::db->new('db:pg:foo'), 'URI should be "db:pg:foo"';
+ is_deeply \@get_params, [[key => 'target.foo.uri']],
+ 'Should have requested target URI from config';
+ is_deeply \@sect_params, [],
+ 'Should not have requested deprecated pg section';
+
+ # Let the name be looked up by the engine.
+ @get_params = @sect_params = ();
+ $config->update(
+ 'core.target' => 'foo',
+ 'target.foo.uri' => 'db:sqlite:foo',
+ );
+ isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Engine named target';
+ is $target->name, 'foo', 'Name should be "foo"';
+ is $target->uri, URI::db->new('db:sqlite:foo'), 'URI should be "db:sqlite:foo"';
+ is_deeply \@get_params, [
+ [key => 'core.target'],
+ [key => 'target.foo.uri']
+ ], 'Should have requested engine target and target URI from config';
+ is_deeply \@sect_params, [], 'Should have requested no section';
+
+ # Let the name come from the environment.
+ ENV: {
+ @get_params = @sect_params = ();
+ $config->replace('target.bar.uri' => 'db:sqlite:bar');
+ local $ENV{SQITCH_TARGET} = 'bar';
+ isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Environment-named target';
+ is $target->name, 'bar', 'Name should be "bar"';
+ is $target->uri, URI::db->new('db:sqlite:bar'), 'URI should be "db:sqlite:bar"';
+ is_deeply \@get_params, [[key => 'target.bar.uri']],
+ 'Should have requested target URI from config';
+ is_deeply \@sect_params, [], 'Should have requested no sections';
+ }
+
+ # Make sure uri params work.
+ @get_params = @sect_params = ();
+ $config->replace('core.engine' => 'pg');
+ $uri = URI::db->new('db:pg://fred@foo.com:12245/widget');
+ isa_ok $target = $CLASS->new(
+ sqitch => $sqitch,
+ host => 'foo.com',
+ port => 12245,
+ user => 'fred',
+ dbname => 'widget',
+ ), $CLASS, 'URI-munged target';
+ is_deeply \@sect_params, [], 'Should have requested no section';
+ like $target->name, qr{db:pg://fred:?\@foo.com:12245/widget},
+ 'Name should be passwordless stringified URI';
+ is $target->uri, $uri, 'URI should be tweaked by URI params';
+
+ # URI params should work when URI read from target config.
+ $uri = URI::db->new('db:pg://foo.com/widget');
+ @get_params = @sect_params = ();
+ $sqitch->options->{db_host} = 'foo.com';
+ $sqitch->options->{db_name} = 'widget';
+ $config->update('target.foo.uri' => 'db:pg:');
+ isa_ok $target = $CLASS->new(
+ sqitch => $sqitch,
+ name => 'foo',
+ host => 'foo.com',
+ dbname => 'widget',
+ ), $CLASS, 'Foo target';
+ is_deeply \@get_params, [ [key => 'target.foo.uri' ]],
+ 'Should have requested target URI';
+ is_deeply \@sect_params, [], 'Should have fetched no section';
+ is $target->name, 'foo', 'Name should be as passed';
+ is $target->uri, $uri, 'URI should be tweaked by URI params';
+
+ # URI params should work when URI passsed.
+ $uri = URI::db->new('db:pg://:1919/');
+ @get_params = @sect_params = ();
+ $sqitch->options->{db_host} = 'foo.com';
+ $sqitch->options->{db_name} = 'widget';
+ isa_ok $target = $CLASS->new(
+ sqitch => $sqitch,
+ name => 'db:pg:widget',
+ host => '',
+ dbname => '',
+ port => 1919,
+ ), $CLASS, 'URI target';
+ is_deeply \@get_params, [], 'Should have requested no config';
+ is_deeply \@sect_params, [], 'Should have fetched no section';
+ is $target->name, $uri, 'Name should tweaked by URI params';
+ is $target->uri, $uri, 'URI should be tweaked by URI params';
+}
+
+CONFIG: {
+ # Look at how attributes are populated from options, config.
+ my $opts = {};
+ $config->replace(
+ 'core.engine' => 'pg',
+ 'core.registry' => 'myreg',
+ 'core.client' => 'pgsql',
+ 'core.plan_file' => 'my.plan',
+ 'core.top_dir' => 'top',
+ 'core.deploy_dir' => 'dep',
+ 'core.revert_dir' => 'rev',
+ 'core.verify_dir' => 'ver',
+ 'core.reworked_dir' => 'wrk',
+ 'core.reworked_deploy_dir' => 'rdep',
+ 'core.reworked_revert_dir' => 'rrev',
+ 'core.reworked_verify_dir' => 'rver',
+ 'core.extension' => 'ddl',
+ );
+ my $sqitch = App::Sqitch->new(options => $opts, config => $config);
+ my $target = $CLASS->new(
+ sqitch => $sqitch,
+ name => 'foo',
+ uri => URI::db->new('db:pg:foo'),
+ );
+
+ is $target->registry, 'myreg', 'Registry should be "myreg"';
+ is $target->client, 'pgsql', 'Client should be "pgsql"';
+ is $target->plan_file, 'my.plan', 'Plan file should be "my.plan"';
+ isa_ok $target->plan_file, 'Path::Class::File', 'Plan file';
+ isa_ok my $plan = $target->plan, 'App::Sqitch::Plan', 'Plan';
+ is $plan->file, $target->plan_file, 'Plan should use target plan file';
+ is $target->top_dir, 'top', 'Top dir should be "top"';
+ isa_ok $target->top_dir, 'Path::Class::Dir', 'Top dir';
+ is $target->deploy_dir, 'dep', 'Deploy dir should be "dep"';
+ isa_ok $target->deploy_dir, 'Path::Class::Dir', 'Deploy dir';
+ is $target->revert_dir, 'rev', 'Revert dir should be "rev"';
+ isa_ok $target->revert_dir, 'Path::Class::Dir', 'Revert dir';
+ is $target->verify_dir, 'ver', 'Verify dir should be "ver"';
+ isa_ok $target->verify_dir, 'Path::Class::Dir', 'Verify dir';
+ is $target->reworked_dir, 'wrk', 'Reworked dir should be "wrk"';
+ isa_ok $target->reworked_dir, 'Path::Class::Dir', 'Reworked dir';
+ is $target->reworked_deploy_dir, 'rdep', 'Reworked deploy dir should be "rdep"';
+ isa_ok $target->reworked_deploy_dir, 'Path::Class::Dir', 'Reworked deploy dir';
+ is $target->reworked_revert_dir, 'rrev', 'Reworked revert dir should be "rrev"';
+ isa_ok $target->reworked_revert_dir, 'Path::Class::Dir', 'Reworked revert dir';
+ is $target->reworked_verify_dir, 'rver', 'Reworked verify dir should be "rver"';
+ isa_ok $target->reworked_verify_dir, 'Path::Class::Dir', 'Reworked verify dir';
+ is $target->extension, 'ddl', 'Extension should be "ddl"';
+ is_deeply $target->variables, {}, 'Should have no variables';
+
+ # Add engine config.
+ $config->update(
+ 'engine.pg.registry' => 'yoreg',
+ 'engine.pg.client' => 'mycli',
+ 'engine.pg.plan_file' => 'pg.plan',
+ 'engine.pg.top_dir' => 'pg',
+ 'engine.pg.deploy_dir' => 'pgdep',
+ 'engine.pg.revert_dir' => 'pgrev',
+ 'engine.pg.verify_dir' => 'pgver',
+ 'engine.pg.reworked_dir' => 'pg/r',
+ 'engine.pg.reworked_deploy_dir' => 'pgrdep',
+ 'engine.pg.reworked_revert_dir' => 'pgrrev',
+ 'engine.pg.reworked_verify_dir' => 'pgrver',
+ 'engine.pg.extension' => 'pgddl',
+ 'engine.pg.variables' => { x => 'ex', y => 'why', z => 'zee' },
+ );
+ $target = $CLASS->new(
+ sqitch => $sqitch,
+ name => 'foo',
+ uri => URI::db->new('db:pg:foo'),
+ );
+
+ is $target->registry, 'yoreg', 'Registry should be "yoreg"';
+ is $target->client, 'mycli', 'Client should be "mycli"';
+ is $target->plan_file, 'pg.plan', 'Plan file should be "pg.plan"';
+ isa_ok $target->plan_file, 'Path::Class::File', 'Plan file';
+ isa_ok $plan = $target->plan, 'App::Sqitch::Plan', 'Plan';
+ is $plan->file, $target->plan_file, 'Plan should use target plan file';
+ is $target->top_dir, 'pg', 'Top dir should be "pg"';
+ isa_ok $target->top_dir, 'Path::Class::Dir', 'Top dir';
+ is $target->deploy_dir, 'pgdep', 'Deploy dir should be "pgdep"';
+ isa_ok $target->deploy_dir, 'Path::Class::Dir', 'Deploy dir';
+ is $target->revert_dir, 'pgrev', 'Revert dir should be "pgrev"';
+ isa_ok $target->revert_dir, 'Path::Class::Dir', 'Revert dir';
+ is $target->verify_dir, 'pgver', 'Verify dir should be "pgver"';
+ isa_ok $target->verify_dir, 'Path::Class::Dir', 'Verify dir';
+ is $target->reworked_dir, dir('pg/r'), 'Reworked dir should be "pg/r"';
+ isa_ok $target->reworked_dir, 'Path::Class::Dir', 'Reworked dir';
+ is $target->reworked_deploy_dir, 'pgrdep', 'Reworked deploy dir should be "pgrdep"';
+ isa_ok $target->reworked_deploy_dir, 'Path::Class::Dir', 'Reworked deploy dir';
+ is $target->reworked_revert_dir, 'pgrrev', 'Reworked revert dir should be "pgrrev"';
+ isa_ok $target->reworked_revert_dir, 'Path::Class::Dir', 'Reworked revert dir';
+ is $target->reworked_verify_dir, 'pgrver', 'Reworked verify dir should be "pgrver"';
+ isa_ok $target->reworked_verify_dir, 'Path::Class::Dir', 'Reworked verify dir';
+ is $target->extension, 'pgddl', 'Extension should be "pgddl"';
+ is_deeply $target->variables, {x => 'ex', y => 'why', z => 'zee'},
+ 'Variables should be read from engine.variables';
+
+ # Add target config.
+ $config->update(
+ 'target.foo.registry' => 'fooreg',
+ 'target.foo.client' => 'foocli',
+ 'target.foo.plan_file' => 'foo.plan',
+ 'target.foo.top_dir' => 'foo',
+ 'target.foo.deploy_dir' => 'foodep',
+ 'target.foo.revert_dir' => 'foorev',
+ 'target.foo.verify_dir' => 'foover',
+ 'target.foo.reworked_dir' => 'foo/r',
+ 'target.foo.reworked_deploy_dir' => 'foodepr',
+ 'target.foo.reworked_revert_dir' => 'foorevr',
+ 'target.foo.reworked_verify_dir' => 'fooverr',
+ 'target.foo.extension' => 'fooddl',
+ 'engine.pg.variables' => { z => 'zie', a => 'ay' },
+ );
+ $target = $CLASS->new(
+ sqitch => $sqitch,
+ name => 'foo',
+ uri => URI::db->new('db:pg:foo'),
+ );
+
+ is $target->registry, 'fooreg', 'Registry should be "fooreg"';
+ is $target->client, 'foocli', 'Client should be "foocli"';
+ is $target->plan_file, 'foo.plan', 'Plan file should be "foo.plan"';
+ isa_ok $target->plan_file, 'Path::Class::File', 'Plan file';
+ isa_ok $plan = $target->plan, 'App::Sqitch::Plan', 'Plan';
+ is $plan->file, $target->plan_file, 'Plan should use target plan file';
+ is $target->top_dir, 'foo', 'Top dir should be "foo"';
+ isa_ok $target->top_dir, 'Path::Class::Dir', 'Top dir';
+ is $target->deploy_dir, 'foodep', 'Deploy dir should be "foodep"';
+ isa_ok $target->deploy_dir, 'Path::Class::Dir', 'Deploy dir';
+ is $target->revert_dir, 'foorev', 'Revert dir should be "foorev"';
+ isa_ok $target->revert_dir, 'Path::Class::Dir', 'Revert dir';
+ is $target->verify_dir, 'foover', 'Verify dir should be "foover"';
+ isa_ok $target->verify_dir, 'Path::Class::Dir', 'Verify dir';
+ is $target->reworked_dir, dir('foo/r'), 'Reworked dir should be "foo/r"';
+ isa_ok $target->reworked_dir, 'Path::Class::Dir', 'Reworked dir';
+ is $target->reworked_deploy_dir, 'foodepr', 'Reworked deploy dir should be "foodepr"';
+ isa_ok $target->reworked_deploy_dir, 'Path::Class::Dir', 'Reworked deploy dir';
+ is $target->reworked_revert_dir, 'foorevr', 'Reworked revert dir should be "foorevr"';
+ isa_ok $target->reworked_revert_dir, 'Path::Class::Dir', 'Reworked revert dir';
+ is $target->reworked_verify_dir, 'fooverr', 'Reworked verify dir should be "fooverr"';
+ isa_ok $target->reworked_verify_dir, 'Path::Class::Dir', 'Reworked verify dir';
+ is $target->extension, 'fooddl', 'Extension should be "fooddl"';
+ is_deeply $target->variables, {x => 'ex', y => 'why', z => 'zie', a => 'ay'},
+ 'Variables should be read from engine., and target.variables';
+}
+
+sub _load($) {
+ my $config = App::Sqitch::Config->new;
+ $config->load_file(file 't', "$_[0].conf");
+ return $config;
+}
+
+ALL: {
+ # Let's test loading all targets. Start with only core.
+ my $config = TestConfig->from(local => file qw(t core.conf) );
+ my $sqitch = App::Sqitch->new(config => $config);
+ ok my @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all targets';
+ is @targets, 1, 'Should have one target';
+ is $targets[0]->name, 'db:pg:',
+ 'It should be the generic core engine target';
+
+ # Now load one with a core target defined.
+ $config = TestConfig->from(local => file qw(t core_target.conf) );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok @targets = $CLASS->all_targets(sqitch => $sqitch),
+ 'Load all targets with core target config';
+ is @targets, 1, 'Should again have one target';
+ is $targets[0]->name, 'db:pg:whatever', 'It should be the named target';
+ is_deeply $targets[0]->variables, {}, 'It should have no variables';
+
+ # Try it with both engine and target defined.
+ $sqitch->config->load_file(file 't', 'core.conf');
+ ok @targets = $CLASS->all_targets(sqitch => $sqitch),
+ 'Load all targets with core engine and target config';
+ is @targets, 1, 'Should still have one target';
+ is $targets[0]->name, 'db:pg:whatever', 'It should again be the named target';
+ is_deeply $targets[0]->variables, {}, 'It should have no variables';
+
+ # Great, now let's load one with some engines in it.
+ $config = TestConfig->from(local => file qw(t user.conf) );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all user conf targets';
+ is @targets, 4, 'Should have four user targets';
+ is_deeply [ sort map { $_->name } @targets ], [
+ 'db:firebird:',
+ 'db:mysql:',
+ 'db:pg://postgres@localhost/thingies',
+ 'db:sqlite:my.db',
+ ], 'Should have all the engine targets';
+ my $mysql = first { $_->name eq 'db:mysql:' } @targets;
+ is_deeply $mysql->variables, {prefix => 'foo_'},
+ 'MySQL target should have engine variables';
+
+ # Load one with targets.
+ $config = TestConfig->from(local => file qw(t target.conf) );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all target conf targets';
+ is @targets, 4, 'Should have three targets';
+ is $targets[0]->name, 'db:pg:', 'Core engine should be default target';
+ is_deeply [ sort map { $_->name } @targets ], [qw(db:pg: dev prod qa)],
+ 'Should have the core target plus the named targets';
+
+ # Load one with engines and targets.
+ $config = TestConfig->from(local => file qw(t local.conf) );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all local conf targets';
+ is @targets, 2, 'Should have two local targets';
+ is $targets[0]->name, 'mydb', 'Core engine should be lead to default target';
+ is_deeply [ sort map { $_->name } @targets ], [qw(devdb mydb)],
+ 'Should have the core target plus the named targets';
+
+ # Mix up a core engine, engines, and targets.
+ $config = TestConfig->from(local => file qw(t engine.conf) );
+ $sqitch = App::Sqitch->new(config => $config);
+ ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all engine conf targets';
+ is @targets, 3, 'Should have three engine conf targets';
+ is_deeply [ sort map { $_->name } @targets ],
+ [qw(db:mysql://root@/foo db:pg:try widgets)],
+ 'Should have the engine and target targets';
+
+ # Make sure parameters are set on all targets.
+ ok @targets = $CLASS->all_targets(
+ sqitch => $sqitch,
+ registry => 'quack',
+ dbname => 'w00t',
+ ), 'Overload all engine conf targets';
+ is @targets, 3, 'Should again have three engine conf targets';
+ is_deeply [ sort map { $_->uri->as_string } @targets ],
+ [qw(db:mysql://root@/w00t db:pg:w00t db:sqlite:w00t)],
+ 'Should have set dbname on all target URIs';
+ is_deeply [ map { $_->registry } @targets ], [('quack') x 3],
+ 'Should have set the registry on all targets.';
+}
+
+done_testing;
diff --git a/t/target_cmd.t b/t/target_cmd.t
new file mode 100644
index 00000000..2770673f
--- /dev/null
+++ b/t/target_cmd.t
@@ -0,0 +1,766 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 243;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::Exception;
+use Test::Warn;
+use Test::Dir;
+use Test::File qw(file_not_exists_ok file_exists_ok);
+use Test::NoWarnings;
+use File::Copy;
+use Path::Class;
+use File::Temp 'tempdir';
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::target';
+
+##############################################################################
+# Set up a test directory and config file.
+my $tmp_dir = tempdir CLEANUP => 1;
+
+File::Copy::copy file(qw(t target.conf))->stringify, "$tmp_dir"
+ or die "Cannot copy t/target.conf to $tmp_dir: $!\n";
+File::Copy::copy file(qw(t engine sqitch.plan))->stringify, "$tmp_dir"
+ or die "Cannot copy t/engine/sqitch.plan to $tmp_dir: $!\n";
+chdir $tmp_dir;
+my $psql = 'psql' . (App::Sqitch::ISWIN ? '.exe' : '');
+
+##############################################################################
+# Load a target command and test the basics.
+my $config = TestConfig->from(local => 'target.conf');
+ok my $sqitch = App::Sqitch->new(config => $config),
+ 'Load a sqitch sqitch object';
+isa_ok my $cmd = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'target',
+ config => $config,
+}), $CLASS, 'Target command';
+isa_ok $cmd, 'App::Sqitch::Command', 'Target command';
+
+can_ok $cmd, qw(
+ options
+ configure
+ execute
+ list
+ add
+ remove
+ rename
+ rm
+ show
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::TargetConfigCommand"),
+ "$CLASS does TargetConfigCommand";
+
+is_deeply [$CLASS->options], [qw(
+ uri=s
+ plan-file|f=s
+ registry=s
+ client=s
+ extension=s
+ top-dir=s
+ dir|d=s%
+ set|s=s%
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+##############################################################################
+# Test configure().
+is_deeply $cmd->properties, {}, 'Default properties should be empty';
+
+# Make sure configure ignores config file.
+is_deeply $CLASS->configure({ foo => 'bar'}, {}), { properties => {} },
+ 'configure() should ignore config file';
+
+# Check default property values.
+ok my $conf = $CLASS->configure($config, {
+ top_dir => 'top',
+ plan_file => 'my.plan',
+ registry => 'bats',
+ client => 'cli',
+ extension => 'ddl',
+ uri => 'db:pg:foo',
+ dir => {
+ deploy => 'dep',
+ revert => 'rev',
+ verify => 'ver',
+ reworked => 'wrk',
+ reworked_deploy => 'rdep',
+ reworked_revert => 'rrev',
+ reworked_verify => 'rver',
+ },
+ set => {
+ foo => 'bar',
+ prefix => 'x_',
+ },
+}), 'Get full config';
+
+is_deeply $conf->{properties}, {
+ top_dir => 'top',
+ plan_file => 'my.plan',
+ registry => 'bats',
+ client => 'cli',
+ extension => 'ddl',
+ uri => URI->new('db:pg:foo'),
+ deploy_dir => 'dep',
+ revert_dir => 'rev',
+ verify_dir => 'ver',
+ reworked_dir => 'wrk',
+ reworked_deploy_dir => 'rdep',
+ reworked_revert_dir => 'rrev',
+ reworked_verify_dir => 'rver',
+ variables => {
+ foo => 'bar',
+ prefix => 'x_',
+ }
+}, 'Should have properties';
+isa_ok $conf->{properties}{$_}, 'Path::Class::File', "$_ file attribute" for qw(
+ plan_file
+);
+isa_ok $conf->{properties}{$_}, 'Path::Class::Dir', "$_ directory attribute" for (
+ 'top_dir',
+ 'reworked_dir',
+ map { ($_, "reworked_$_") } qw(deploy_dir revert_dir verify_dir)
+);
+
+# Make sure invalid directories are ignored.
+throws_ok { $CLASS->new($CLASS->configure({}, {
+ dir => { foo => 'bar' },
+})) } 'App::Sqitch::X', 'Should fail on invalid directory name';
+is $@->ident, 'target', 'Invalid directory ident should be "target"';
+is $@->message, __x(
+ 'Unknown directory name: {prop}',
+ prop => 'foo',
+), 'The invalid directory messsage should be correct';
+
+throws_ok { $CLASS->new($CLASS->configure({}, {
+ dir => { foo => 'bar', cavort => 'ha' },
+})) } 'App::Sqitch::X', 'Should fail on invalid directory names';
+is $@->ident, 'target', 'Invalid directories ident should be "target"';
+is $@->message, __x(
+ 'Unknown directory names: {props}',
+ props => 'cavort, foo',
+), 'The invalid properties messsage should be correct';
+
+##############################################################################
+# Test list().
+ok $cmd->list, 'Run list()';
+is_deeply +MockOutput->get_emit, [['dev'], ['prod'], ['qa']],
+ 'The list of targets should have been output';
+
+# Make it verbose.
+isa_ok $cmd = $CLASS->new({
+ sqitch => App::Sqitch->new( config => $config, options => { verbosity => 1 })
+}), $CLASS, 'Verbose engine';
+ok $cmd->list, 'Run verbose list()';
+is_deeply +MockOutput->get_emit, [
+ ["dev\tdb:pg:widgets"],
+ ["prod\tdb:pg://prod.example.us/pr_widgets"],
+ ["qa\tdb:pg://qa.example.com/qa_widgets"]
+], 'The list of targets and their URIs should have been output';
+
+##############################################################################
+# Test add().
+MISSINGARGS: {
+ # Test handling of no name.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->add } qr/USAGE/,
+ 'No name arg to add() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+
+ @args = ();
+ throws_ok { $cmd->add('foo') } qr/USAGE/,
+ 'No URI arg to add() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+}
+
+# Should die on existing key.
+throws_ok { $cmd->add('dev', 'db:pg:') } 'App::Sqitch::X',
+ 'Should get error for existing target';
+is $@->ident, 'target', 'Existing target error ident should be "target"';
+is $@->message, __x(
+ 'Target "{target}" already exists',
+ target => 'dev'
+), 'Existing target error message should be correct';
+
+# Now add a new target.
+dir_not_exists_ok $_ for qw(deploy revert verify);
+ok $cmd->add('test', 'db:pg:test'), 'Add target "test"';
+dir_exists_ok $_ for qw(deploy revert verify);
+$config->load;
+is $config->get(key => 'target.test.uri'), 'db:pg:test',
+ 'Target "test" URI should have been set';
+for my $key (qw(
+ client
+ registry
+ top_dir
+ plan_file
+ deploy_dir
+ revert_dir
+ verify_dir
+ extension
+)) {
+ is $config->get(key => "target.test.$key"), undef,
+ qq{Target "test" should have no $key set};
+}
+is_deeply $config->get_section(section => 'target.test.variables'), {},
+ qq{Target "test" should have no variables set}
+;
+# Try adding a target with a registry.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { registry => 'meta' },
+}), $CLASS, 'Target with registry';
+ok $cmd->add('withreg', 'db:pg:withreg'), 'Add target "withreg"';
+$config->load;
+is $config->get(key => 'target.withreg.uri'), 'db:pg:withreg',
+ 'Target "withreg" URI should have been set';
+is $config->get(key => 'target.withreg.registry'), 'meta',
+ 'Target "withreg" registry should have been set';
+for my $key (qw(
+ client
+ top_dir
+ plan_file
+ deploy_dir
+ revert_dir
+ verify_dir
+ extension
+)) {
+ is $config->get(key => "target.withreg.$key"), undef,
+ qq{Target "test" should have no $key set};
+
+}
+is_deeply $config->get_section(section => 'target.withreg.variables'), {},
+ qq{Target "withreg" should have no variables set};
+
+# Try a client.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { client => 'hi.exe' },
+}), $CLASS, 'Target with client';
+ok $cmd->add('withcli', 'db:pg:withcli'), 'Add target "withcli"';
+$config->load;
+is $config->get(key => 'target.withcli.uri'), 'db:pg:withcli',
+ 'Target "withcli" URI should have been set';
+is $config->get(key => 'target.withcli.client'), 'hi.exe',
+ 'Target "withcli" should have client set';
+for my $key (qw(
+ registry
+ top_dir
+ plan_file
+ deploy_dir
+ revert_dir
+ verify_dir
+ extension
+)) {
+ is $config->get(key => "target.withcli.$key"), undef,
+ qq{Target "withcli" should have no $key set};
+}
+is_deeply $config->get_section(section => 'target.withcli.variables'), {},
+ qq{Target "withcli" should have no variables set};
+
+# Try both.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { client => 'ack', registry => 'foo', variables => { a => 'y' } },
+}), $CLASS, 'Target with client and registry';
+ok $cmd->add('withboth', 'db:pg:withboth'), 'Add target "withboth"';
+$config->load;
+is $config->get(key => 'target.withboth.uri'), 'db:pg:withboth',
+ 'Target "withboth" URI should have been set';
+is $config->get(key => 'target.withboth.registry'), 'foo',
+ 'Target "withboth" registry should have been set';
+is $config->get(key => 'target.withboth.client'), 'ack',
+ 'Target "withboth" should have client set';
+for my $key (qw(
+ top_dir
+ plan_file
+ deploy_dir
+ revert_dir
+ verify_dir
+ extension
+)) {
+ is $config->get(key => "target.withboth.$key"), undef,
+ qq{Target "withboth" should have no $key set};
+}
+is_deeply $config->get_section(section => 'target.withboth.variables'), {a => 'y'},
+ qq{Target "withboth" should have variables set};
+
+# Try all the properties.
+my %props = (
+ client => 'poo',
+ registry => 'reg',
+ top_dir => dir('top'),
+ plan_file => file('my.plan'),
+ deploy_dir => dir('dep'),
+ revert_dir => dir('rev'),
+ verify_dir => dir('ver'),
+ reworked_dir => dir('r'),
+ reworked_deploy_dir => dir('r/d'),
+ extension => 'ddl',
+ variables => { ay => 'first', Bee => 'second' },
+);
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { %props },
+}), $CLASS, 'Target with all properties';
+file_not_exists_ok 'my.plan';
+dir_not_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify);
+ok $cmd->add('withall', 'db:pg:withall'), 'Add target "withall"';
+dir_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify);
+file_exists_ok 'my.plan';
+$config->load;
+is $config->get(key => "target.withall.uri"), 'db:pg:withall',
+ qq{Target "withall" should have uri set};
+while (my ($k, $v) = each %props) {
+ if ($k ne 'variables') {
+ is $config->get(key => "target.withall.$k"), $v,
+ qq{Target "withall" should have $k set};
+ } else {
+ is_deeply $config->get_section(section => "target.withall.$k"), $v,
+ qq{Target "withall" should have $k set};
+ }
+}
+
+##############################################################################
+# Test alter().
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+}), $CLASS, 'Target with no properties';
+
+MISSINGARGS: {
+ # Test handling of no name.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->alter } qr/USAGE/,
+ 'No name arg to alter() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+}
+
+# Should die on missing key.
+throws_ok { $cmd->alter('nonesuch') } 'App::Sqitch::X',
+ 'Should get error for missing target';
+is $@->ident, 'target', 'Missing target error ident should be "target"';
+is $@->message, __x(
+ 'Missing Target "{target}"; use "{command}" to add it',
+ target => 'nonesuch',
+ command => 'add nonesuch $uri',
+), 'Missing target error message should be correct';
+
+# Should include the URI, if present, in the error message.
+$cmd->properties->{uri} = URI::db->new('db:pg:');
+throws_ok { $cmd->alter('nonesuch') } 'App::Sqitch::X',
+ 'Should get error for missing target with URI';
+is $@->ident, 'target', 'Missing target with URI error ident should be "target"';
+is $@->message, __x(
+ 'Missing Target "{target}"; use "{command}" to add it',
+ target => 'nonesuch',
+ command => 'add nonesuch db:pg:',
+), 'Missing target error message should include URI';
+
+
+# Try all the properties.
+%props = (
+ uri => URI->new('db:firebird:bar'),
+ client => 'argh',
+ registry => 'migrations',
+ top_dir => dir('fb'),
+ plan_file => file('fb.plan'),
+ deploy_dir => dir('fb/dep'),
+ revert_dir => dir('fb/rev'),
+ verify_dir => dir('fb/ver'),
+ reworked_dir => dir('fb/r'),
+ reworked_deploy_dir => dir('fb/r/d'),
+ extension => 'fbsql',
+ variables => { ay => 'x', ceee => 'third' },
+);
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { %props },
+}), $CLASS, 'Target with more properties';
+ok $cmd->alter('withall'), 'Alter target "withall"';
+$config->load;
+while (my ($k, $v) = each %props) {
+ if ($k ne 'variables') {
+ is $config->get(key => "target.withall.$k"), $v,
+ qq{Target "withall" should have $k set};
+ } else {
+ $v->{Bee} = 'second';
+ is_deeply $config->get_section(section => "target.withall.$k"), $v,
+ qq{Target "withall" should have merged $k set};
+ }
+}
+
+# Try changing the top directory.
+isa_ok $cmd = $CLASS->new({
+ sqitch => $sqitch,
+ properties => { top_dir => dir 'big' },
+}), $CLASS, 'Target with new top_dir property';
+dir_not_exists_ok dir $_ for qw(big big/deploy big/revert big/verify);
+ok $cmd->alter('withall'), 'Alter target "withall"';
+dir_exists_ok dir $_ for qw(big big/deploy big/revert big/verify);
+$config->load;
+is $config->get(key => 'target.withall.top_dir'), 'big',
+ 'The withall top_dir should have been set';
+
+##############################################################################
+# Test rename.
+MISSINGARGS: {
+ # Test handling of no names.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->rename } qr/USAGE/,
+ 'No name args to rename() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+
+ @args = ();
+ throws_ok { $cmd->rename('foo') } qr/USAGE/,
+ 'No second arg to rename() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+}
+
+# Should get an error if the target does not exist.
+throws_ok { $cmd->rename('nonexistent', 'existant' ) } 'App::Sqitch::X',
+ 'Should get error for nonexistent target';
+is $@->ident, 'target', 'Nonexistent target error ident should be "target"';
+is $@->message, __x(
+ 'Unknown target "{target}"',
+ target => 'nonexistent'
+), 'Nonexistent target error message should be correct';
+
+# Rename one that exists.
+ok $cmd->rename('withboth', 'àlafois'), 'Rename';
+$config->load;
+ok $config->get(key => "target.àlafois.uri"),
+ qq{Target "àlafois" should now be present};
+is $config->get(key => "target.àlafois.variables.a"), 'y',
+ qq{Target "àlafois" variables should now be present};
+is $config->get(key => "target.withboth.uri"), undef,
+ qq{Target "withboth" should no longer be present};
+is $config->get(key => "target.withboth.variables.a"), undef,
+ qq{Target "withboth" variables should be gone};
+is_deeply $config->get_section(section => "target.àlafois.variables"), { a => 'y' },
+ qq{Target "àlafois" should have variables};
+
+# Make sure we die on dependencies.
+$config->group_set( $config->local_file, [
+ {key => 'core.target', value => 'prod'},
+ {key => 'engine.firebird.target', value => 'prod'},
+]);
+$cmd->sqitch->config->load;
+# Should get an error for a target with dependencies.
+throws_ok { $cmd->rename('prod', 'fodder' ) } 'App::Sqitch::X',
+ 'Should get error renaming a target with dependencies';
+is $@->ident, 'target', 'Dependency target error ident should be "target"';
+is $@->message, __x(
+ q{Cannot rename target "{target}" because it's referenced by: {engines}},
+ target => 'prod',
+ engines => 'core.target, engine.firebird.target',
+), 'Dependency target error message should be correct';
+
+# Should get no error removing a target with no variables.
+ok $cmd->rename('test', 'funky'), 'Rename "test"';
+$config->load;
+ok $config->get(key => "target.funky.uri"),
+ qq{Target "funky" should now be present};
+is $config->get(key => "target.test.uri"), undef,
+ qq{Target "test" should no longer be present};
+is_deeply $config->get_section(section => "target.funky.variables"), {},
+ qq{Target "funcky" should have no variables};
+
+##############################################################################
+# Test remove.
+MISSINGARGS: {
+ # Test handling of no names.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->remove } qr/USAGE/,
+ 'No name args to remove() should yield usage';
+ is_deeply \@args, [$cmd], 'No args should be passed to usage';
+}
+
+# Should get an error if the target does not exist.
+throws_ok { $cmd->remove('nonexistent', 'existant' ) } 'App::Sqitch::X',
+ 'Should get error for nonexistent target';
+is $@->ident, 'target', 'Nonexistent target error ident should be "target"';
+is $@->message, __x(
+ 'Unknown target "{target}"',
+ target => 'nonexistent'
+), 'Nonexistent target error message should be correct';
+
+# Remove one that exists.
+ok $cmd->remove('àlafois'), 'Remove';
+$config->load;
+is $config->get(key => "target.àlafois.uri"), undef,
+ qq{Target "àlafois" should now be gone};
+is_deeply $config->get_section(section => "target.àlafois.variables"), {},
+ qq{Target "àlafois" variables should be gone, too};
+
+throws_ok { $cmd->remove('prod' ) } 'App::Sqitch::X',
+ 'Should get error removing a target with dependencies';
+is $@->ident, 'target', 'Dependency target error ident should be "target"';
+is $@->message, __x(
+ q{Cannot rename target "{target}" because it's referenced by: {engines}},
+ target => 'prod',
+ engines => 'core.target, engine.firebird.target',
+), 'Dependency target error message should be correct';
+
+# Remove one without variables, too.
+ok $cmd->remove('funky'), 'Remove "funky"';
+$config->load;
+is $config->get(key => "target.funky.uri"), undef,
+ qq{Target "funky" should now be gone};
+
+##############################################################################
+# Test show.
+ok $cmd->show, 'Run show()';
+is_deeply +MockOutput->get_emit, [
+ ['dev'], ['prod'], ['qa'], ['withall'], ['withcli'], ['withreg']
+], 'Show with no names should emit the list of targets';
+
+# Try one target.
+ok $cmd->show('dev'), 'Show dev';
+is_deeply +MockOutput->get_emit, [
+ ['* dev'],
+ [' ', 'URI: ', 'db:pg:widgets'],
+ [' ', 'Registry: ', 'sqitch'],
+ [' ', 'Client: ', $psql],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'sqitch.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+], 'The "dev" target should have been shown';
+
+# Try a target with a non-default client.
+ok $cmd->show('withcli'), 'Show withcli';
+is_deeply +MockOutput->get_emit, [
+ ['* withcli'],
+ [' ', 'URI: ', 'db:pg:withcli'],
+ [' ', 'Registry: ', 'sqitch'],
+ [' ', 'Client: ', 'hi.exe'],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'sqitch.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+], 'The "with_cli" target should have been shown';
+
+# Try a target with a non-default registry.
+ok $cmd->show('withreg'), 'Show withreg';
+is_deeply +MockOutput->get_emit, [
+ ['* withreg'],
+ [' ', 'URI: ', 'db:pg:withreg'],
+ [' ', 'Registry: ', 'meta'],
+ [' ', 'Client: ', $psql],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'sqitch.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+], 'The "withreg" target should have been shown';
+
+# Try a target with variables.
+ok $cmd->show('withall'), 'Show withall';
+#use Data::Dump; ddx +MockOutput->get_emit;
+is_deeply +MockOutput->get_emit, [
+ ['* withall'],
+ [' ', 'URI: ', 'db:firebird:bar'],
+ [' ', 'Registry: ', 'migrations'],
+ [' ', 'Client: ', 'argh'],
+ [' ', 'Top Directory: ', 'big'],
+ [' ', 'Plan File: ', 'fb.plan'],
+ [' ', 'Extension: ', 'fbsql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', dir qw(fb dep)],
+ [' ', ' Revert: ', dir qw(fb rev)],
+ [' ', ' Verify: ', dir qw(fb ver)],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', dir qw(fb r)],
+ [' ', ' Deploy: ', dir qw(fb r d)],
+ [' ', ' Revert: ', dir qw(fb r revert)],
+ [' ', ' Verify: ', dir qw(fb r verify)],
+ [' ', 'Variables:'],
+ [' ay: x'],
+ [' Bee: second'],
+ [' ceee: third'],
+], 'The "withall" target should have been shown with variables';
+
+# Try multiples.
+ok $cmd->show(qw(dev qa withreg)), 'Show three targets';
+is_deeply +MockOutput->get_emit, [
+ ['* dev'],
+ [' ', 'URI: ', 'db:pg:widgets'],
+ [' ', 'Registry: ', 'sqitch'],
+ [' ', 'Client: ', $psql],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'sqitch.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+ ['* qa'],
+ [' ', 'URI: ', 'db:pg://qa.example.com/qa_widgets'],
+ [' ', 'Registry: ', 'meta'],
+ [' ', 'Client: ', '/usr/sbin/psql'],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'sqitch.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+ ['* withreg'],
+ [' ', 'URI: ', 'db:pg:withreg'],
+ [' ', 'Registry: ', 'meta'],
+ [' ', 'Client: ', $psql],
+ [' ', 'Top Directory: ', '.'],
+ [' ', 'Plan File: ', 'sqitch.plan'],
+ [' ', 'Extension: ', 'sql'],
+ [' ', 'Script Directories:'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'Reworked Script Directories:'],
+ [' ', ' Reworked: ', '.'],
+ [' ', ' Deploy: ', 'deploy'],
+ [' ', ' Revert: ', 'revert'],
+ [' ', ' Verify: ', 'verify'],
+ [' ', 'No Variables'],
+], 'All three targets should have been shown';
+
+##############################################################################
+# Test execute().
+isa_ok $cmd = $CLASS->new({ sqitch => $sqitch }), $CLASS, 'Simple target';
+for my $spec (
+ [ undef, 'list' ],
+ [ 'list' ],
+ [ 'add' ],
+ [ 'remove' ],
+ [ 'rm', 'remove' ],
+ [ 'rename' ],
+ [ 'show' ],
+) {
+ my ($arg, $meth) = @{ $spec };
+ $meth //= $arg;
+ $meth =~ s/-/_/g;
+ my $mocker = Test::MockModule->new($CLASS);
+ my @args;
+ $mocker->mock($meth => sub { @args = @_ });
+ ok $cmd->execute($spec->[0]), "Execute " . ($spec->[0] // 'undef');
+ is_deeply \@args, [$cmd], "$meth() should have been called";
+
+ # Make sure args are passed.
+ ok $cmd->execute($spec->[0], qw(foo bar)),
+ "Execute " . ($spec->[0] // 'undef') . ' with args';
+ is_deeply \@args, [$cmd, qw(foo bar)],
+ "$meth() should have been passed args";
+}
+
+# Make sure an invalid action dies with a usage statement.
+MISSINGARGS: {
+ # Test handling of no names.
+ my $mock = Test::MockModule->new($CLASS);
+ my @args;
+ $mock->mock(usage => sub { @args = @_; die 'USAGE' });
+ throws_ok { $cmd->execute('nonexistent') } qr/USAGE/,
+ 'Should get an exception for a nonexistent action';
+ is_deeply \@args, [$cmd, __x(
+ 'Unknown action "{action}"',
+ action => 'nonexistent',
+ )], 'Nonexistent action message should be passed to usage';
+}
+
+##############################################################################
+# Test URI validation.
+for my $val (
+ 'rock',
+ 'https://www.google.com/',
+) {
+ my $uri = URI->new($val);
+ throws_ok {
+ $CLASS->new({ sqitch => $sqitch, properties => { uri => $uri } })
+ } 'App::Sqitch::X', "Invalid URI $val should throw an error";
+ is $@->ident, 'target', qq{Invalid URI $val error ident should be "target"};
+ is $@->message, __x(
+ 'URI "{uri}" is not a database URI',
+ uri => $uri,
+ ), qq{Invalid URI $val error message should be correct};
+}
+
+my $uri = URI->new('db:');
+throws_ok {
+ $CLASS->new({ sqitch => $sqitch, properties => { uri => $uri } })
+} 'App::Sqitch::X', 'Engineless URI should throw an error';
+is $@->ident, 'target', 'Engineless URI error ident should be "target"';
+is $@->message, __x(
+ 'No database engine in URI "{uri}"',
+ uri => $uri,
+), 'Engineless URI error message should be correct';
+
+$uri = URI->new('db:nonesuch:foo');
+throws_ok {
+ $CLASS->new({ sqitch => $sqitch, properties => { uri => $uri } })
+} 'App::Sqitch::X', 'Unknown engine URI should throw an error';
+is $@->ident, 'target', 'Unknown engine URI error ident should be "target"';
+is $@->message, __x(
+ 'Unknown engine "{engine}" in URI "{uri}"',
+ uri => $uri,
+ engine => 'nonesuch',
+), 'Unknown engine URI error message should be correct';
diff --git a/t/templates.conf b/t/templates.conf
new file mode 100644
index 00000000..d5555fee
--- /dev/null
+++ b/t/templates.conf
@@ -0,0 +1,9 @@
+[core]
+ engine = pg
+
+[add "templates"]
+ deploy = etc/templates/deploy/pg.tmpl
+ revert = etc/templates/revert/pg.tmpl
+ test = etc/templates/verify/pg.tmpl
+ verify = etc/templates/verify/pg.tmpl
+
diff --git a/t/upgrade.t b/t/upgrade.t
new file mode 100644
index 00000000..e641a4df
--- /dev/null
+++ b/t/upgrade.t
@@ -0,0 +1,118 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use utf8;
+use Test::More tests => 25;
+#use Test::More 'no_plan';
+use App::Sqitch;
+use Locale::TextDomain qw(App-Sqitch);
+use Test::NoWarnings;
+use Test::Exception;
+use Test::Warn;
+use Test::MockModule;
+use Path::Class;
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::upgrade';
+require_ok $CLASS;
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.top_dir' => dir->new('test-upgrade')->stringify,
+);
+ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object';
+isa_ok my $upgrade = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'upgrade',
+ config => $config,
+}), $CLASS, 'upgrade command';
+
+can_ok $upgrade, qw(
+ target
+ options
+ execute
+ configure
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::ConnectingCommand"),
+ "$CLASS does ConnectingCommand";
+
+is_deeply [ $CLASS->options ], [qw(
+ target|t=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+# Start with the engine up-to-date.
+my $engine_mocker = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+my $registry_version = App::Sqitch::Engine->registry_release;
+my $upgrade_called = 0;
+$engine_mocker->mock(registry_version => sub { $registry_version });
+$engine_mocker->mock(upgrade_registry => sub { $upgrade_called = 1 });
+
+ok $upgrade->execute, 'Execute upgrade';
+ok !$upgrade_called, 'Upgrade should not have been called';
+is_deeply +MockOutput->get_info, [[__x(
+ 'Registry {registry} is up-to-date at version {version}',
+ registry => 'db:sqlite:',
+ version => App::Sqitch::Engine->registry_release,
+)]], 'Should get output for up-to-date registry';
+
+# Pass in a different target.
+ok $upgrade->execute('db:sqlite:foo.db'), 'Execute upgrade with target';
+ok !$upgrade_called, 'Upgrade should again not have been called';
+is_deeply +MockOutput->get_info, [[__x(
+ 'Registry {registry} is up-to-date at version {version}',
+ registry => 'db:sqlite:sqitch.db',
+ version => App::Sqitch::Engine->registry_release,
+)]], 'Should get output for up-to-date registry with target';
+
+# Pass in an engine.
+ok $upgrade->execute('sqlite'), 'Execute upgrade with engine';
+ok !$upgrade_called, 'Upgrade should again not have been called';
+is_deeply +MockOutput->get_info, [[__x(
+ 'Registry {registry} is up-to-date at version {version}',
+ registry => 'db:sqlite:',
+ version => App::Sqitch::Engine->registry_release,
+)]], 'Should get output for up-to-date registry with target';
+
+# Specify a target as an option.
+isa_ok $upgrade = App::Sqitch::Command->load({
+ sqitch => $sqitch,
+ command => 'upgrade',
+ config => $config,
+ args => [qw(--target db:sqlite:my.sqlite)],
+}), $CLASS, 'upgrade command with target';
+
+ok $upgrade->execute, 'Execute upgrade with target option';
+ok !$upgrade_called, 'Upgrade should still not have been called';
+is_deeply +MockOutput->get_info, [[__x(
+ 'Registry {registry} is up-to-date at version {version}',
+ registry => 'db:sqlite:sqitch.sqlite',
+ version => App::Sqitch::Engine->registry_release,
+)]], 'Should get output for up-to-date registry with target option';
+
+# Now make it upgrade.
+$registry_version = 0.1;
+ok $upgrade->execute, 'Execute upgrade with out-of-date registry';
+ok $upgrade_called, 'Upgrade should now have been called';
+is_deeply +MockOutput->get_info, [[__x(
+ 'Upgrading registry {registry} to version {version}',
+ registry => 'db:sqlite:sqitch.sqlite',
+ version => App::Sqitch::Engine->registry_release,
+)]], 'Should get output for the upgrade';
diff --git a/t/user.conf b/t/user.conf
new file mode 100644
index 00000000..e62f34b2
--- /dev/null
+++ b/t/user.conf
@@ -0,0 +1,24 @@
+[user]
+ name = Michael Stonebraker
+ email = michael@example.com
+
+[engine "pg"]
+ client = /opt/local/pgsql/bin/psql
+ target = db:pg://postgres@localhost/thingies
+ registry = meta
+
+[engine "mysql"]
+ client = /opt/local/mysql/bin/mysql
+ registry = meta
+
+[engine "mysql.variables"]
+ prefix = foo_
+
+[engine "sqlite"]
+ client = /opt/local/bin/sqlite3
+ registry = meta
+ target = db:sqlite:my.db
+
+[engine "firebird"]
+ client = /opt/firebird/bin/isql
+ registry = meta
diff --git a/t/verify.t b/t/verify.t
new file mode 100644
index 00000000..184e94a6
--- /dev/null
+++ b/t/verify.t
@@ -0,0 +1,298 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More;
+use App::Sqitch;
+use App::Sqitch::Target;
+use Path::Class qw(dir file);
+use Test::MockModule;
+use Test::Exception;
+use Test::Warn;
+use Locale::TextDomain qw(App-Sqitch);
+use lib 't/lib';
+use MockOutput;
+use TestConfig;
+
+my $CLASS = 'App::Sqitch::Command::verify';
+require_ok $CLASS or die;
+
+isa_ok $CLASS, 'App::Sqitch::Command';
+can_ok $CLASS, qw(
+ target
+ options
+ configure
+ new
+ from_change
+ to_change
+ variables
+ does
+);
+
+ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_"
+ for qw(ContextCommand ConnectingCommand);
+
+is_deeply [$CLASS->options], [qw(
+ target|t=s
+ from-change|from=s
+ to-change|to=s
+ set|s=s%
+ plan-file|f=s
+ top-dir=s
+ registry=s
+ client|db-client=s
+ db-name|d=s
+ db-user|db-username|u=s
+ db-host|h=s
+ db-port|p=i
+)], 'Options should be correct';
+
+warning_is {
+ Getopt::Long::Configure(qw(bundling pass_through));
+ ok Getopt::Long::GetOptionsFromArray(
+ [], {}, App::Sqitch->_core_opts, $CLASS->options,
+ ), 'Should parse options';
+} undef, 'Options should not conflict with core options';
+
+my $config = TestConfig->new(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+);
+my $sqitch = App::Sqitch->new(config => $config);
+
+##############################################################################
+# Test configure().
+is_deeply $CLASS->configure($config, {}), {
+ _params => [],
+ _cx => [],
+}, 'Should have default configuration with no config or opts';
+
+is_deeply $CLASS->configure($config, {
+ from_change => 'foo',
+ to_change => 'bar',
+ set => { foo => 'bar' },
+}), {
+ from_change => 'foo',
+ to_change => 'bar',
+ variables => { foo => 'bar' },
+ _params => [],
+ _cx => [],
+}, 'Should have changes and variables from options';
+
+CONFIG: {
+ my $config = TestConfig->new(
+ 'verify.variables' => { foo => 'bar', hi => 21 },
+ );
+ is_deeply $CLASS->configure($config, {}), { _params => [], _cx => [] },
+ 'Should have no config if no options';
+}
+
+##############################################################################
+# Test construction.
+isa_ok my $verify = $CLASS->new(
+ sqitch => $sqitch,
+ target => 'foo',
+), $CLASS, 'new status with target';
+is $verify->target, 'foo', 'Should have target "foo"';
+
+isa_ok $verify = $CLASS->new(sqitch => $sqitch), $CLASS;
+is $verify->target, undef, 'Default target should be undef';
+is $verify->from_change, undef, 'from_change should be undef';
+is $verify->to_change, undef, 'to_change should be undef';
+
+##############################################################################
+# Test _collect_vars.
+my $target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $verify->_collect_vars($target) }, {}, 'Should collect no variables';
+
+# Add core variables.
+$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $verify->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'SELECT',
+}, 'Should collect core vars';
+
+# Add deploy variables.
+$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $verify->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'salsa',
+}, 'Should override core vars with deploy vars';
+
+# Add verify variables.
+$config->update('verify.variables' => { dance => 'disco', lunch => 'pizza' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch);
+is_deeply { $verify->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'pizza',
+}, 'Should override deploy vars with verify vars';
+
+# Add engine variables.
+$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey' });
+my $uri = URI::db->new('db:pg:');
+$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri);
+is_deeply { $verify->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'whiskey',
+}, 'Should override verify vars with engine vars';
+
+# Add target variables.
+$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' });
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $verify->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'winning',
+}, 'Should override engine vars with target vars';
+
+# Add --set variables.
+$verify = $CLASS->new(
+ sqitch => $sqitch,
+ variables => { status => 'tired', herb => 'oregano' },
+);
+$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri);
+is_deeply { $verify->_collect_vars($target) }, {
+ prefix => 'widget',
+ priv => 'UPDATE',
+ dance => 'disco',
+ lunch => 'burrito',
+ drink => 'scotch',
+ status => 'tired',
+ herb => 'oregano',
+}, 'Should override target vars with --set variables';
+
+$config->replace(
+ 'core.engine' => 'sqlite',
+ 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify,
+ 'core.top_dir' => dir(qw(t sql))->stringify,
+);
+$verify = $CLASS->new( sqitch => $sqitch, no_prompt => 1);
+
+##############################################################################
+# Test execution.
+# Mock the engine interface.
+my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite');
+my @args;
+$mock_engine->mock(verify => sub { shift; @args = @_ });
+my @vars;
+$mock_engine->mock(set_variables => sub { shift; @vars = @_ });
+
+ok $verify->execute, 'Execute with nothing.';
+is_deeply \@args, [undef, undef],
+ 'Two undefs should be passed to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should have no warnings';
+
+ok $verify->execute('@alpha'), 'Execute from "@alpha"';
+is_deeply \@args, ['@alpha', undef],
+ '"@alpha" and undef should be passed to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should again have no warnings';
+
+ok $verify->execute('@alpha', '@beta'), 'Execute from "@alpha" to "@beta"';
+is_deeply \@args, ['@alpha', '@beta'],
+ '"@alpha" and "@beat" should be passed to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should still have no warnings';
+
+isa_ok $verify = $CLASS->new(
+ sqitch => $sqitch,
+ from_change => 'foo',
+ to_change => 'bar',
+ variables => { foo => 'bar', one => 1 },
+), $CLASS, 'Object with from, to, and variables';
+
+ok $verify->execute, 'Execute again';
+is_deeply \@args, ['foo', 'bar'],
+ '"foo" and "bar" should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [], 'Still should have no warnings';
+
+# Pass and specify changes.
+ok $verify->execute('roles', 'widgets'), 'Execute with command-line args';
+is_deeply \@args, ['foo', 'bar'],
+ '"foo" and "bar" should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many changes specified; verifying from "{from}" to "{to}"',
+ from => 'foo',
+ to => 'bar',
+)]], 'Should have warning about which roles are used';
+
+# Pass a target.
+$target = 'db:pg:';
+my $mock_cmd = Test::MockModule->new(ref $verify);
+my ($target_name_arg, $orig_meth);
+$mock_cmd->mock(parse_args => sub {
+ my $self = shift;
+ my %p = @_;
+ my @ret = $self->$orig_meth(@_);
+ $target_name_arg = $ret[0][0]->name;
+ $ret[0][0] = $self->default_target;
+ return @ret;
+});
+$orig_meth = $mock_cmd->original('parse_args');
+
+ok $verify->execute($target), 'Execute with target arg';
+is $target_name_arg, $target, 'The target should have been passed to the engine';
+is_deeply \@args, ['foo', 'bar'],
+ '"foo" and "bar" should be passed to the engine';
+is_deeply {@vars}, { foo => 'bar', one => 1 },
+ 'Vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should once again have no warnings';
+
+# Pass a --target option.
+isa_ok $verify = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS, 'Object with target';
+$target_name_arg = undef;
+@vars = ();
+ok $verify->execute, 'Execute with no args';
+is $target_name_arg, $target, 'The target option should have been passed to the engine';
+is_deeply \@args, [undef, undef], 'Undefs should be passed to the engine';
+is_deeply {@vars}, {}, 'No vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [], 'Should once again have no warnings';
+
+# Pass a target, get a warning.
+ok $verify->execute('db:sqlite:', 'roles', 'widgets'),
+ 'Execute with two targegs and two changes';
+is $target_name_arg, $target, 'The target option should have been passed to the engine';
+is_deeply \@args, ['roles', 'widgets'],
+ 'The two changes should be passed to the engine';
+is_deeply {@vars}, {}, 'No vars should have been passed through to the engine';
+is_deeply +MockOutput->get_warn, [[__x(
+ 'Too many targets specified; connecting to {target}',
+ target => $verify->default_target->name,
+)]], 'Should have warning about too many targets';
+
+# Make sure we get an exception for unknown args.
+throws_ok { $verify->execute(qw(greg)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown arg';
+is $@->ident, 'verify', 'Unknow arg ident should be "verify"';
+is $@->message, __x(
+ 'Unknown argument "{arg}"',
+ arg => 'greg',
+), 'Should get an exeption for two unknown arg';
+
+throws_ok { $verify->execute(qw(greg jon)) } 'App::Sqitch::X',
+ 'Should get an exception for unknown args';
+is $@->ident, 'verify', 'Unknow args ident should be "verify"';
+is $@->message, __x(
+ 'Unknown arguments: {arg}',
+ arg => 'greg, jon',
+), 'Should get an exeption for two unknown args';
+
+done_testing;
diff --git a/t/vertica.t b/t/vertica.t
new file mode 100644
index 00000000..3b3e4c4d
--- /dev/null
+++ b/t/vertica.t
@@ -0,0 +1,320 @@
+#!/usr/bin/perl -w
+
+# To test against a live Vertica database, you must set the VSQL_URI environment variable.
+# this is a stanard URI::db URI, and should look something like this:
+#
+# export VSQL_URI=db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica
+#
+# Note that it must include the `?Driver=$driver` bit so that DBD::ODBC loads
+# the proper driver.
+
+use strict;
+use warnings;
+use 5.010;
+use Test::More 0.94;
+use Test::MockModule;
+use Test::Exception;
+use Locale::TextDomain qw(App-Sqitch);
+use Capture::Tiny 0.12 qw(:all);
+use Try::Tiny;
+use App::Sqitch;
+use App::Sqitch::Target;
+use App::Sqitch::Plan;
+use lib 't/lib';
+use DBIEngineTest;
+use TestConfig;
+
+my $CLASS;
+
+delete $ENV{"VSQL_$_"} for qw(USER PASSWORD DATABASE HOST PORT);
+
+BEGIN {
+ $CLASS = 'App::Sqitch::Engine::vertica';
+ require_ok $CLASS or die;
+}
+
+is_deeply [$CLASS->config_vars], [
+ target => 'any',
+ registry => 'any',
+ client => 'any',
+], 'config_vars should return three vars';
+
+my $uri = URI::db->new('db:vertica:');
+my $config = TestConfig->new('core.engine' => 'vertica');
+my $sqitch = App::Sqitch->new(config => $config);
+my $target = App::Sqitch::Target->new(
+ sqitch => $sqitch,
+ uri => $uri,
+);
+isa_ok my $vta = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+
+is $vta->key, 'vertica', 'Key should be "vertica"';
+is $vta->name, 'Vertica', 'Name should be "Vertica"';
+
+my $client = 'vsql' . (App::Sqitch::ISWIN ? '.exe' : '');
+is $vta->client, $client, 'client should default to vsql';
+is $vta->registry, 'sqitch', 'registry default should be "sqitch"';
+is $vta->uri, $uri, 'DB URI should be "db:vertica:"';
+my $dest_uri = $uri->clone;
+$dest_uri->dbname($ENV{VSQL_DATABASE} || $ENV{VSQL_USER} || $sqitch->sysuser);
+is $vta->destination, $dest_uri->as_string,
+ 'Destination should fall back on environment variables';
+is $vta->registry_destination, $vta->destination,
+ 'Registry destination should be the same as destination';
+
+my @std_opts = (
+ '--quiet',
+ '--no-vsqlrc',
+ '--no-align',
+ '--tuples-only',
+ '--set' => 'ON_ERROR_STOP=1',
+ '--set' => 'registry=sqitch',
+);
+is_deeply [$vta->vsql], [$client, '--username', $sqitch->sysuser, @std_opts],
+ 'vsql command should be username and std opts-only';
+
+isa_ok $vta = $CLASS->new(
+ sqitch => $sqitch,
+ target => $target,
+), $CLASS;
+ok $vta->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'),
+ 'Set some variables';
+is_deeply [$vta->vsql], [
+ $client,
+ '--username', $sqitch->sysuser,
+ '--set' => 'foo=baz',
+ '--set' => 'whu=hi there',
+ '--set' => 'yo=stellar',
+ @std_opts,
+], 'Variables should be passed to vsql via --set';
+
+##############################################################################
+# Test other configs for the target.
+ENV: {
+ # Make sure we override system-set vars.
+ local $ENV{VSQL_DATABASE};
+ local $ENV{VSQL_USER};
+ local $ENV{VSQL_PASSWORD};
+ for my $env (qw(VSQL_DATABASE VSQL_USER VSQL_PASSWORD)) {
+ my $vta = $CLASS->new(sqitch => $sqitch, target => $target);
+ local $ENV{$env} = "\$ENV=whatever";
+ is $vta->target->name, "db:vertica:", "Target name should not read \$$env";
+ is $vta->registry_destination, $vta->destination,
+ 'Registry target should be the same as destination';
+ is $vta->username, $ENV{VSQL_USER} || $sqitch->sysuser,
+ "Should have username when $env set";
+ is $vta->password, $ENV{VSQL_PASSWORD},
+ "Should have password when $env set";
+ }
+
+ my $mocker = Test::MockModule->new('App::Sqitch');
+ $mocker->mock(sysuser => 'sysuser=whatever');
+ my $vta = $CLASS->new(sqitch => $sqitch, target => $target);
+ is $vta->target->name, 'db:vertica:',
+ 'Target name should not fall back on sysuser';
+ is $vta->registry_destination, $vta->destination,
+ 'Registry target should be the same as destination';
+
+ $ENV{VSQL_DATABASE} = 'mydb';
+ $vta = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target);
+ is $vta->target->name, 'db:vertica:', 'Target name should be the default';
+ is $vta->registry_destination, $vta->destination,
+ 'Registry target should be the same as destination';
+}
+
+##############################################################################
+# Make sure config settings override defaults.
+$config->update(
+ 'engine.vertica.client' => '/path/to/vsql',
+ 'engine.vertica.target' => 'db:vertica://localhost/try',
+ 'engine.vertica.registry' => 'meta',
+);
+$std_opts[-1] = 'registry=meta';
+
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $vta = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create another vertica';
+is $vta->client, '/path/to/vsql', 'client should be as configured';
+is $vta->uri->as_string, 'db:vertica://localhost/try',
+ 'uri should be as configured';
+is $vta->registry, 'meta', 'registry should be as configured';
+is_deeply [$vta->vsql], [
+ '/path/to/vsql',
+ '--username', $sqitch->sysuser,
+ '--dbname', 'try',
+ '--host', 'localhost',
+ @std_opts
+], 'vsql command should be configured from URI config';
+
+##############################################################################
+# Test _run(), _capture(), and _spool().
+can_ok $vta, qw(_run _capture _spool);
+my $mock_sqitch = Test::MockModule->new('App::Sqitch');
+my (@run, $exp_pass);
+$mock_sqitch->mock(run => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @run = @_;
+ if (defined $exp_pass) {
+ is $ENV{VSQL_PASSWORD}, $exp_pass, qq{VSQL_PASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{VSQL_PASSWORD}, 'VSQL_PASSWORD should not exist';
+ }
+});
+
+my @capture;
+$mock_sqitch->mock(capture => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @capture = @_;
+ if (defined $exp_pass) {
+ is $ENV{VSQL_PASSWORD}, $exp_pass, qq{VSQL_PASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{VSQL_PASSWORD}, 'VSQL_PASSWORD should not exist';
+ }
+});
+
+my @spool;
+$mock_sqitch->mock(spool => sub {
+ local $Test::Builder::Level = $Test::Builder::Level + 2;
+ shift;
+ @spool = @_;
+ if (defined $exp_pass) {
+ is $ENV{VSQL_PASSWORD}, $exp_pass, qq{VSQL_PASSWORD should be "$exp_pass"};
+ } else {
+ ok !exists $ENV{VSQL_PASSWORD}, 'VSQL_PASSWORD should not exist';
+ }
+});
+
+$exp_pass = 's3cr3t';
+$target->uri->password($exp_pass);
+ok $vta->_run(qw(foo bar baz)), 'Call _run';
+is_deeply \@run, [$vta->vsql, qw(foo bar baz)],
+ 'Command should be passed to run()';
+
+ok $vta->_spool('FH'), 'Call _spool';
+is_deeply \@spool, ['FH', $vta->vsql],
+ 'Command should be passed to spool()';
+
+ok $vta->_capture(qw(foo bar baz)), 'Call _capture';
+is_deeply \@capture, [$vta->vsql, qw(foo bar baz)],
+ 'Command should be passed to capture()';
+
+# Without password.
+$target = App::Sqitch::Target->new( sqitch => $sqitch );
+ok $vta = $CLASS->new(sqitch => $sqitch, target => $target),
+ 'Create a vertica with sqitch with no pw';
+$exp_pass = undef;
+ok $vta->_run(qw(foo bar baz)), 'Call _run again';
+is_deeply \@run, [$vta->vsql, qw(foo bar baz)],
+ 'Command should be passed to run() again';
+
+ok $vta->_spool('FH'), 'Call _spool again';
+is_deeply \@spool, ['FH', $vta->vsql],
+ 'Command should be passed to spool() again';
+
+ok $vta->_capture(qw(foo bar baz)), 'Call _capture again';
+is_deeply \@capture, [$vta->vsql, qw(foo bar baz)],
+ 'Command should be passed to capture() again';
+
+##############################################################################
+# Test file and handle running.
+ok $vta->run_file('foo/bar.sql'), 'Run foo/bar.sql';
+is_deeply \@run, [$vta->vsql, '--file', 'foo/bar.sql'],
+ 'File should be passed to run()';
+
+ok $vta->run_handle('FH'), 'Spool a "file handle"';
+is_deeply \@spool, ['FH', $vta->vsql],
+ 'Handle should be passed to spool()';
+
+# Verify should go to capture unless verosity is > 1.
+ok $vta->run_verify('foo/bar.sql'), 'Verify foo/bar.sql';
+is_deeply \@capture, [$vta->vsql, '--file', 'foo/bar.sql'],
+ 'Verify file should be passed to capture()';
+
+$mock_sqitch->mock(verbosity => 2);
+ok $vta->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again';
+is_deeply \@run, [$vta->vsql, '--file', 'foo/bar.sql'],
+ 'Verifile file should be passed to run() for high verbosity';
+
+$mock_sqitch->unmock_all;
+
+##############################################################################
+# Test DateTime formatting stuff.
+ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')";
+is sprintf($ts2char->(), 'foo'),
+ q{to_char(foo AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')},
+ '_ts2char_format should work';
+
+ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')";
+isa_ok my $dt = $dtfunc->(
+ 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC'
+), 'App::Sqitch::DateTime', 'Return value of _dt()';
+is $dt->year, 2012, 'DateTime year should be set';
+is $dt->month, 7, 'DateTime month should be set';
+is $dt->day, 5, 'DateTime day should be set';
+is $dt->hour, 15, 'DateTime hour should be set';
+is $dt->minute, 7, 'DateTime minute should be set';
+is $dt->second, 1, 'DateTime second should be set';
+is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set';
+
+##############################################################################
+# Can we do live tests?
+my $dbh;
+END {
+ return unless $dbh;
+ $dbh->{Driver}->visit_child_handles(sub {
+ my $h = shift;
+ $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh;
+ });
+
+ $dbh->{RaiseError} = 0;
+ $dbh->{PrintError} = 1;
+ $dbh->do($_) for (
+ 'DROP SCHEMA sqitch CASCADE',
+ 'DROP SCHEMA __sqitchtest CASCADE',
+ );
+}
+
+$uri = URI->new($ENV{VSQL_URI} || 'db:vertica://dbadmin:password@localhost/dbadmin');
+my $err = try {
+ $vta->use_driver;
+ $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, {
+ PrintError => 0,
+ RaiseError => 1,
+ AutoCommit => 1,
+ });
+ undef;
+} catch {
+ eval { $_->message } || $_;
+};
+
+DBIEngineTest->run(
+ class => $CLASS,
+ version_query => 'SELECT version()',
+ target_params => [ uri => $uri ],
+ alt_target_params => [ uri => $uri, registry => '__sqitchtest' ],
+ skip_unless => sub {
+ my $self = shift;
+ die $err if $err;
+ # Make sure we have vsql and can connect to the database.
+ $self->sqitch->probe( $self->client, '--version' );
+ $self->_capture('--command' => 'SELECT version()');
+ },
+ engine_err_regex => qr/\bERROR \d+:/,
+ init_error => __x(
+ 'Sqitch schema "{schema}" already exists',
+ schema => '__sqitchtest',
+ ),
+ test_dbh => sub {
+ my $dbh = shift;
+ # Make sure the sqitch schema is the first in the search path.
+ is $dbh->selectcol_arrayref('SELECT current_schema')->[0],
+ '__sqitchtest', 'The Sqitch schema should be the current schema';
+ },
+);
+
+done_testing;
diff --git a/t/x.t b/t/x.t
new file mode 100644
index 00000000..dbd5778e
--- /dev/null
+++ b/t/x.t
@@ -0,0 +1,78 @@
+#!/usr/bin/perl -w
+
+use strict;
+use Test::More;
+use Test::Exception;
+use Try::Tiny;
+use Path::Class;
+use lib 't/lib';
+use TestConfig;
+
+my $CLASS;
+BEGIN {
+ $CLASS = 'App::Sqitch::X';
+ require_ok $CLASS or die;
+ $CLASS->import(':all');
+}
+
+isa_ok my $x = $CLASS->new(ident => 'test', message => 'Die'), $CLASS, 'X object';
+
+for my $role(qw(
+ Throwable
+ StackTrace::Auto
+)) {
+ ok $x->does($role), "X object does $role";
+}
+
+# Make sure default ident works.
+ok $x = $CLASS->new(message => 'whatever'), 'Create X without ident';
+is $x->ident, 'DEV', 'Default ident should be "DEV"';
+
+throws_ok { hurl basic => 'OMFG!' } $CLASS;
+isa_ok $x = $@, $CLASS, 'Thrown object';
+is $x->ident, 'basic', 'Ident should be "basic"';
+is $x->message, 'OMFG!', 'The message should have been passed';
+ok $x->stack_trace->frames, 'It should have a stack trace';
+is $x->exitval, 2, 'Exit val should be 2';
+is +($x->stack_trace->frames)[0]->filename, file(qw(t x.t)),
+ 'The trace should start in this file';
+
+# NB: Don't use `local $@`, as it does not work on Perls < 5.14.
+throws_ok { $@ = 'Yo dawg'; hurl 'OMFG!' } $CLASS;
+isa_ok $x = $@, $CLASS, 'Thrown object';
+is $x->ident, 'DEV', 'Ident should be "DEV"';
+is $x->message, 'OMFG!', 'The message should have been passed';
+is $x->exitval, 2, 'Exit val should again be 2';
+is $x->previous_exception, 'Yo dawg',
+ 'Previous exception should have been passed';
+
+throws_ok { hurl {ident => 'blah', message => 'OMFG!', exitval => 1} } $CLASS;
+isa_ok $x = $@, $CLASS, 'Thrown object';
+is $x->message, 'OMFG!', 'The params should have been passed';
+is $x->exitval, 1, 'Exit val should be 1';
+is $x->as_string, join("\n", grep { defined }
+ $x->message,
+ $x->previous_exception,
+ $x->stack_trace
+), 'Stringification should work';
+
+is $x->as_string, "$x", 'Stringification should work';
+
+# Do some actual exception handling.
+try {
+ hurl io => 'Cannot open file';
+} catch {
+ return fail "Not a Sqitch::X: $_" unless eval { $_->isa('App::Sqitch::X') };
+ is $_->ident, 'io', 'Should be an "io" exception';
+};
+
+# Make sure we can goto hurl.
+try {
+ @_ = (io => 'Cannot open file');
+ goto &hurl;
+} catch {
+ return fail "Not a Sqitch::X: $_" unless eval { $_->isa('App::Sqitch::X') };
+ is $_->ident, 'io', 'Should catch error called via &goto';
+};
+
+done_testing;