Repository: ariga/atlas
Pull request #840 (2022-06-01 10:12:10)

doc/blog: announce crdb


Patch:
diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml
new file mode 100644
index 00000000..f54c6fe7
--- /dev/null
+++ b/.github/workflows/build-docker.yaml
@@ -0,0 +1,34 @@
+name: Build Docker
+on:
+  pull_request:
+  push:
+    branches:
+      - master
+
+env:
+  CRDB_VERSIONS: v21.2.11 v22.1.0
+
+jobs:
+  build-services:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2.4.0
+      - uses: actions/setup-go@v2
+        with:
+          go-version: '1.18'
+      - name: Log in to registry
+        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin
+      - name: "build cockroach image"
+        run: |
+          VER="${{ env.CRDB_VERSIONS }}"
+          for i in $VER
+          do
+            :
+            if ! docker manifest inspect ghcr.io/ariga/cockroachdb-single-node:$i; then
+              go run internal/ci/cockroach/main.go $i > internal/ci/cockroach/Dockerfile
+              docker build -t ghcr.io/ariga/cockroachdb-single-node:$i internal/ci/cockroach/
+              docker push ghcr.io/ariga/cockroachdb-single-node:$i
+            else
+              echo image already exists
+            fi
+          done
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 19ab4d71..d51fb7d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,7 +40,7 @@ jobs:
         run: go generate ./...
       - name: Verify generated files are checked in properly
         run: |
-          status=$(git status --porcelain)
+          status=$(git status --porcelain | grep -v "go.\(sum\|mod\)" | cat)
           if [ -n "$status" ]; then
             echo "you need to run 'go generate ./...' and commit the changes"
             echo "$status"
@@ -71,7 +71,10 @@ jobs:
         working-directory: schema
       - name: Run cli tests
         run: go test -race ./...
-        working-directory: cmd
+        working-directory: cmd/atlascmd
+      - name: Run atlasci tests
+        run: go test -race ./internal/ci/...
+        working-directory: cmd/atlasci
 
   
   integration-mysql56:
@@ -480,7 +483,7 @@ jobs:
     runs-on: ubuntu-latest
     services:
       cockroach:
-        image: timveil/cockroachdb-single-node:latest
+        image: ghcr.io/ariga/cockroachdb-single-node:v21.2.11
         
         ports:
           - 26257:26257
diff --git a/cmd/atlas/doc.tmpl b/cmd/atlas/doc.tmpl
index 03ae307b..1e7ef325 100644
--- a/cmd/atlas/doc.tmpl
+++ b/cmd/atlas/doc.tmpl
@@ -17,10 +17,8 @@ For a more detailed introduction to the CLI capabilities, head over to the
 
 ## Distributed Binaries
 
-Starting [v0.3.0](https://github.com/ariga/atlas/releases/tag/v0.3.0),
-ֿthe distributed binaries include code for a [Management UI](ui/intro.md) wrapping the
-core atlas engine that is not currently released publicly. The binaries
-themselves are still released under the same [Apache License 2.0](https://github.com/ariga/atlas/blob/master/LICENSE).
+The binaries distributed in official releases are released under the  [Ariga End User License](https://ariga.io/legal/atlas/eula).
+If you would like to build Atlas from source follow the instructions [here](https://atlasgo.io/cli/reference#building-from-source).
 
 ### Building from Source
 
diff --git a/cmd/atlasci/go.mod b/cmd/atlasci/go.mod
new file mode 100644
index 00000000..0bc8791e
--- /dev/null
+++ b/cmd/atlasci/go.mod
@@ -0,0 +1,42 @@
+module ariga.io/atlas/cmd/atlasci
+
+go 1.17
+
+replace ariga.io/atlas => ../..
+
+require (
+	ariga.io/atlas v0.4.1
+	github.com/go-git/go-billy/v5 v5.3.1
+	github.com/go-git/go-git/v5 v5.4.2
+	github.com/mattn/go-sqlite3 v1.14.13
+	github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942
+)
+
+require (
+	github.com/Microsoft/go-winio v0.5.2 // indirect
+	github.com/ProtonMail/go-crypto v0.0.0-20220517143526-88bb52951d5b // indirect
+	github.com/acomagu/bufpipe v1.0.3 // indirect
+	github.com/agext/levenshtein v1.2.1 // indirect
+	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/emirpasic/gods v1.18.1 // indirect
+	github.com/go-git/gcfg v1.5.0 // indirect
+	github.com/go-openapi/inflect v0.19.0 // indirect
+	github.com/google/go-cmp v0.5.6 // indirect
+	github.com/hashicorp/hcl/v2 v2.10.0 // indirect
+	github.com/imdario/mergo v0.3.13 // indirect
+	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/sergi/go-diff v1.2.0 // indirect
+	github.com/xanzy/ssh-agent v0.3.1 // indirect
+	github.com/zclconf/go-cty v1.8.0 // indirect
+	golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
+	golang.org/x/net v0.0.0-20220526153639-5463443f8c37 // indirect
+	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
+	golang.org/x/text v0.3.7 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
+	gopkg.in/yaml.v3 v3.0.0 // indirect
+)
diff --git a/cmd/atlasci/go.sum b/cmd/atlasci/go.sum
new file mode 100644
index 00000000..5b129bca
--- /dev/null
+++ b/cmd/atlasci/go.sum
@@ -0,0 +1,283 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+entgo.io/ent v0.10.2-0.20220502113020-4ac82f5bb3f0/go.mod h1:Zh61BPvB+cL6VWEyN8f1YoDacrMjQf2KDlDeX26xq2k=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
+github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
+github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
+github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
+github.com/ProtonMail/go-crypto v0.0.0-20220517143526-88bb52951d5b h1:lcbBNuQhppsc7A5gjdHmdlqUqJfgGMylBdGyDs0j7G8=
+github.com/ProtonMail/go-crypto v0.0.0-20220517143526-88bb52951d5b/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
+github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
+github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
+github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
+github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
+github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
+github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
+github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
+github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
+github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
+github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
+github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
+github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
+github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
+github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
+github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
+github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
+github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
+github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
+github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
+github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
+github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
+github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
+github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/hcl/v2 v2.10.0 h1:1S1UnuhDGlv3gRFV4+0EdwB+znNP5HmcGbIqwnSCByg=
+github.com/hashicorp/hcl/v2 v2.10.0/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg=
+github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
+github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
+github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
+github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
+github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk=
+github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
+github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
+github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g=
+github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 h1:t0lM6y/M5IiUZyvbBTcngso8SZEZICH7is9B6g/obVU=
+github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
+github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
+github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
+github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
+github.com/xanzy/ssh-agent v0.3.1 h1:AmzO1SSWxw73zxFZPRwaMN1MohDw8UyHnmuxyceTEGo=
+github.com/xanzy/ssh-agent v0.3.1/go.mod h1:QIE4lCeL7nkC25x+yA3LBIYfwCc1TFziCtG7cBAac6w=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
+github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA=
+github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
+github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
+go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
+golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
+golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220526153639-5463443f8c37 h1:lUkvobShwKsOesNfWWlCS5q7fnbG1MEliIzwu886fn8=
+golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.9-0.20211216111533-8d383106f7e7/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/cmd/atlasci/internal/ci/ci.go b/cmd/atlasci/internal/ci/ci.go
new file mode 100644
index 00000000..02e6ebf4
--- /dev/null
+++ b/cmd/atlasci/internal/ci/ci.go
@@ -0,0 +1,245 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package ci
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"path/filepath"
+	"sort"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlclient"
+
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+	"github.com/go-git/go-git/v5/utils/merkletrie"
+)
+
+type (
+	// A ChangeDetector takes a migration directory and splits it into the "base" files (already merged) and new ones.
+	ChangeDetector interface {
+		// DetectChanges splits the files of a migration directory into the "base" files (already merged) and new ones.
+		DetectChanges(context.Context) ([]migrate.File, []migrate.File, error)
+	}
+
+	// A ChangeLoader takes a set of migration files and will create multiple schema.Changes out of it.
+	// It will also label migration files as either "generated" or "handcrafted".
+	ChangeLoader interface {
+		// LoadChanges converts each of the given migration files into one Changes.
+		LoadChanges(context.Context, []migrate.File) ([]*sqlcheck.File, error)
+	}
+
+	// DirScanner stitches migrate.Dir and migrate.Scanner into one interface.
+	DirScanner interface {
+		migrate.Dir
+		migrate.Scanner
+	}
+)
+
+type (
+	// GitChangeDetector implements the ChangeDetector interface by utilizing a git repository.
+	GitChangeDetector struct {
+		root string     // path to the git repository root
+		base string     // name of the base branch (e.g. master)
+		path string     // path of the migration directory relative to the repository root (in slash notation)
+		dir  DirScanner // the migration directory to load migration files from
+	}
+
+	// GitChangeDetectorOption allows configuring GitChangeDetector with functional arguments.
+	GitChangeDetectorOption func(*GitChangeDetector) error
+)
+
+// NewGitChangeDetector configures a new GitChangeDetector.
+func NewGitChangeDetector(root string, dir DirScanner, opts ...GitChangeDetectorOption) (*GitChangeDetector, error) {
+	if root == "" {
+		return nil, errors.New("internal/ci: root cannot be empty")
+	}
+	if dir == nil {
+		return nil, errors.New("internal/ci: dir cannot be nil")
+	}
+	d := &GitChangeDetector{root: root, dir: dir}
+	for _, opt := range opts {
+		if err := opt(d); err != nil {
+			return nil, err
+		}
+	}
+	if d.base == "" {
+		d.base = "master"
+	}
+	if d.path == "" {
+		d.path = "migrations"
+	}
+	return d, nil
+}
+
+// WithBase configures the git base branch name for a GitChangeDetector.
+func WithBase(base string) GitChangeDetectorOption {
+	return func(d *GitChangeDetector) error {
+		d.base = base
+		return nil
+	}
+}
+
+// WithMigrationsPath configures the git base branch name for a GitChangeDetector.
+func WithMigrationsPath(path string) GitChangeDetectorOption {
+	return func(d *GitChangeDetector) error {
+		d.path = filepath.ToSlash(path)
+		return nil
+	}
+}
+
+// DetectChanges implements the ChangeDetector interface.
+func (d *GitChangeDetector) DetectChanges(_ context.Context) ([]migrate.File, []migrate.File, error) {
+	// Fetch all the files of the migration directory.
+	files, err := d.dir.Files()
+	if err != nil {
+		return nil, nil, fmt.Errorf("internal/ci: reading migration directory: %w", err)
+	}
+	// Diff the base tree and head tree.
+	r, err := git.PlainOpen(d.root)
+	if err != nil {
+		return nil, nil, fmt.Errorf("internal/ci: open git repo: %w", err)
+	}
+	baseT, err := treeObject(r, plumbing.NewBranchReferenceName(d.base))
+	if err != nil {
+		return nil, nil, fmt.Errorf("internal/ci: %w", err)
+	}
+	headT, err := treeObject(r, plumbing.HEAD)
+	if err != nil {
+		return nil, nil, fmt.Errorf("internal/ci: %w", err)
+	}
+	diff, err := baseT.Diff(headT)
+	if err != nil {
+		return nil, nil, err
+	}
+	if len(diff) == 0 {
+		return files, nil, nil
+	}
+	// Since isNewFile assumes a sorted slice of changes, make sure to sort it.
+	sort.Sort(diff)
+	// Iterate over the migration files. If we find a file, that has been added in the diff between base and head,
+	// every migration file preceding it can be considered old, the file itself and everything thereafter new,
+	// since Atlas assumes a linear migration history.
+	for i, f := range files {
+		ok, err := d.isNewFile(diff, f)
+		if err != nil {
+			return nil, nil, err
+		}
+		if !ok {
+			continue
+		}
+		return files[:i], files[i:], nil
+	}
+	return files, nil, nil
+}
+
+var _ ChangeDetector = (*GitChangeDetector)(nil)
+
+// isNewFile determines if the given file has been added in the given tree diff.
+// It assumes the diff is sorted by filename.
+func (d *GitChangeDetector) isNewFile(diff object.Changes, file migrate.File) (bool, error) {
+	n := filepath.ToSlash(filepath.Join(d.path, file.Name())) // git uses "/" as path separator regardless of platform
+	i := sort.Search(len(diff), func(i int) bool {
+		return diff[i].To.Name >= n
+	})
+	if i <= len(diff) && diff[i].To.Name == n {
+		a, err := diff[i].Action()
+		if err != nil {
+			return false, err
+		}
+		return a == merkletrie.Insert, nil
+	}
+	// If the file has not been found in the diff, it must be an old one.
+	return false, nil
+}
+
+// treeObject returns the object.Tree for a git reference name (e.g. refs/head/master).
+func treeObject(r *git.Repository, n plumbing.ReferenceName) (*object.Tree, error) {
+	ref, err := r.Reference(n, true)
+	if err != nil {
+		return nil, fmt.Errorf("reference feature branch: %w", err)
+	}
+	c, err := r.CommitObject(ref.Hash())
+	if err != nil {
+		return nil, fmt.Errorf("de-reference commit %q: %w", ref.Hash(), err)
+	}
+	t, err := c.Tree()
+	if err != nil {
+		return nil, fmt.Errorf("get tree for commit %q: %w", c.Hash, err)
+	}
+	return t, nil
+}
+
+// DevLoader implements the ChangesLoader interface using a dev-driver.
+type DevLoader struct {
+	// Dev environment used as a sandbox instantiated to the starting point (e.g. base branch).
+	Dev *sqlclient.Client
+	// Scan is used for scanning the migration directory.
+	Scan migrate.Scanner
+}
+
+// LoadChanges implements the ChangesLoader interface.
+func (d *DevLoader) LoadChanges(ctx context.Context, files []migrate.File) ([]*sqlcheck.File, error) {
+	diff := make([]*sqlcheck.File, len(files))
+	current, err := d.Dev.InspectRealm(ctx, nil)
+	if err != nil {
+		return nil, err
+	}
+	for i, f := range files {
+		diff[i] = &sqlcheck.File{
+			File: f,
+		}
+		stmts, err := d.Scan.Stmts(f)
+		if err != nil {
+			return nil, err
+		}
+		for _, s := range stmts {
+			if _, err := d.Dev.ExecContext(ctx, s); err != nil {
+				return nil, err
+			}
+			target, err := d.Dev.InspectRealm(ctx, nil)
+			if err != nil {
+				return nil, err
+			}
+			changes, err := d.Dev.RealmDiff(current, target)
+			if err != nil {
+				return nil, err
+			}
+			current = target
+			// In case the change is recognized by Atlas.
+			if len(changes) > 0 {
+				p, err := pos(f, s)
+				if err != nil {
+					return nil, err
+				}
+				diff[i].Changes = append(diff[i].Changes, &sqlcheck.Change{
+					Pos:     p,
+					Stmt:    s,
+					Changes: changes,
+				})
+			}
+		}
+	}
+	return diff, nil
+}
+
+// pos returns the position of a statement in migration file.
+func pos(f migrate.File, stmt string) (int, error) {
+	buf, err := io.ReadAll(f)
+	if err != nil {
+		return 0, err
+	}
+	i := bytes.Index(buf, []byte(stmt))
+	if i == -1 {
+		return 0, fmt.Errorf("statement %q was not found in %q", stmt, buf)
+	}
+	return i, nil
+}
diff --git a/cmd/atlasci/internal/ci/run.go b/cmd/atlasci/internal/ci/run.go
new file mode 100644
index 00000000..5fe372bf
--- /dev/null
+++ b/cmd/atlasci/internal/ci/run.go
@@ -0,0 +1,64 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package ci
+
+import (
+	"context"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlclient"
+)
+
+// Runner is used to execute CI jobs.
+type Runner struct {
+	// DevClient configures the "dev driver" to calculate
+	// migration changes by the driver.
+	Dev *sqlclient.Client
+
+	// RunChangeDetector configures the ChangeDetector to
+	// be used by the runner.
+	ChangeDetector ChangeDetector
+
+	// Scan is used for scanning the migration directory.
+	Scan migrate.Scanner
+
+	// Analyzer defines the analysis to be run in the CI job.
+	Analyzer sqlcheck.Analyzer
+
+	// Reporter is used to report diagnostics in the CI.
+	Reporter sqlcheck.Reporter
+}
+
+// Run executes the CI job.
+func (r *Runner) Run(ctx context.Context) error {
+	base, feat, err := r.ChangeDetector.DetectChanges(ctx)
+	if err != nil {
+		return err
+	}
+	// Bring the dev environment to the base point.
+	for _, f := range base {
+		stmt, err := r.Scan.Stmts(f)
+		if err != nil {
+			return err
+		}
+		for _, s := range stmt {
+			if _, err := r.Dev.ExecContext(ctx, s); err != nil {
+				return err
+			}
+		}
+	}
+	l := &DevLoader{Dev: r.Dev, Scan: r.Scan}
+	files, err := l.LoadChanges(ctx, feat)
+	if err != nil {
+		return err
+	}
+	for _, f := range files {
+		if err := r.Analyzer.Analyze(ctx, &sqlcheck.Pass{File: f, Dev: r.Dev, Report: r.Reporter}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/cmd/atlascmd/migrate.go b/cmd/atlascmd/migrate.go
index b2be3f41..d735224f 100644
--- a/cmd/atlascmd/migrate.go
+++ b/cmd/atlascmd/migrate.go
@@ -11,37 +11,45 @@ import (
 	"io"
 	"io/ioutil"
 	"path/filepath"
+	"strconv"
 	"strings"
+	"time"
 
+	entmigrate "ariga.io/atlas/cmd/atlascmd/migrate"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
 	"ariga.io/atlas/sql/sqltool"
+	"github.com/fatih/color"
 	"github.com/spf13/pflag"
 
 	"github.com/spf13/cobra"
 )
 
 const (
-	migrateFlagDevURL      = "dev-url"
-	migrateFlagDir         = "dir"
-	migrateFlagForce       = "force"
-	migrateFlagFormat      = "format"
-	migrateFlagSchema      = "schema"
-	migrateDiffFlagTo      = "to"
-	migrateDiffFlagVerbose = "verbose"
+	migrateFlagDevURL          = "dev-url"
+	migrateFlagDir             = "dir"
+	migrateFlagForce           = "force"
+	migrateFlagFormat          = "format"
+	migrateFlagLog             = "log"
+	migrateFlagRevisionsSchema = "revisions-schema"
+	migrateFlagTo              = "to"
+	migrateFlagSchema          = "schema"
+	migrateDiffFlagVerbose     = "verbose"
 )
 
 var (
 	// MigrateFlags are the flags used in MigrateCmd (and sub-commands).
 	MigrateFlags struct {
-		DirURL  string
-		DevURL  string
-		ToURL   string
-		Schemas []string
-		Format  string
-		Force   bool
-		Verbose bool
+		DirURL         string
+		DevURL         string
+		ToURL          string
+		Schemas        []string
+		Format         string
+		LogFormat      string
+		RevisionSchema string
+		Force          bool
+		Verbose        bool
 	}
 	// MigrateCmd represents the migrate command. It wraps several other sub-commands.
 	MigrateCmd = &cobra.Command{
@@ -75,6 +83,21 @@ to re-hash the contents and resolve the error
 			return nil
 		},
 	}
+	// MigrateApplyCmd represents the 'atlas migrate apply' subcommand.
+	MigrateApplyCmd = &cobra.Command{
+		Use:   "apply",
+		Short: "Applies pending migration files on the connected database.",
+		Long: `'atlas migrate apply' reads the migration state of the connected database and computes what migrations are pending.
+It then attempts to apply the pending migration files in the correct order onto the database. 
+The first argument denotes the maximum number of migration files to apply.
+As a safety measure 'atlas migrate apply' will abort with an error, if:
+  - the migration directory is not in sync with the 'atlas.sum' file
+  - the migration and database history do not match each other`,
+		Example: `  atlas migrate apply --to mysql://user:pass@localhost:3306/dbname
+  atlas migrate apply 1 --dir file:///path/to/migration/directory --to mysql://user:pass@localhost:3306/dbname`,
+		Args: cobra.MaximumNArgs(1),
+		RunE: CmdMigrateApplyRun,
+	}
 	// MigrateDiffCmd represents the 'atlas migrate diff' subcommand.
 	MigrateDiffCmd = &cobra.Command{
 		Use:   "diff",
@@ -116,8 +139,8 @@ This command should be used whenever a manual change in the migration directory
 the atlas.sum file. If there is a mismatch it will be reported. If the --dev-url flag is given, the migration files are 
 executed on the connected database in order to validate SQL semantics.`,
 		Example: `  atlas migrate validate
-  atlas migrate validate --dir /path/to/migration/directory
-  atlas migrate validate --dir /path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev`,
+  atlas migrate validate --dir file:///path/to/migration/directory
+  atlas migrate validate --dir file:///path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev`,
 		PreRunE: migrateFlagsFromEnv,
 		RunE:    CmdMigrateValidateRun,
 	}
@@ -126,6 +149,7 @@ executed on the connected database in order to validate SQL semantics.`,
 func init() {
 	// Add sub-commands.
 	Root.AddCommand(MigrateCmd)
+	MigrateCmd.AddCommand(MigrateApplyCmd)
 	MigrateCmd.AddCommand(MigrateDiffCmd)
 	MigrateCmd.AddCommand(MigrateHashCmd)
 	MigrateCmd.AddCommand(MigrateNewCmd)
@@ -134,24 +158,98 @@ func init() {
 	devURL := func(set *pflag.FlagSet) {
 		set.StringVarP(&MigrateFlags.DevURL, migrateFlagDevURL, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
 	}
+	toURL := func(set *pflag.FlagSet) {
+		set.StringVarP(&MigrateFlags.ToURL, migrateFlagTo, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
+	}
 	// Global flags.
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.DirURL, migrateFlagDir, "", "file://migrations", "select migration directory using URL format")
 	MigrateCmd.PersistentFlags().StringSliceVarP(&MigrateFlags.Schemas, migrateFlagSchema, "", nil, "set schema names")
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.Format, migrateFlagFormat, "", formatAtlas, "set migration file format")
 	MigrateCmd.PersistentFlags().BoolVarP(&MigrateFlags.Force, migrateFlagForce, "", false, "force a command to run on a broken migration directory state")
 	MigrateCmd.PersistentFlags().SortFlags = false
+	// Apply flags.
+	MigrateApplyCmd.Flags().StringVarP(&MigrateFlags.LogFormat, migrateFlagLog, "", logFormatTTY, "log format to use")
+	MigrateApplyCmd.Flags().StringVarP(&MigrateFlags.RevisionSchema, migrateFlagRevisionsSchema, "", "", "schema name where the revisions table is to be created")
+	toURL(MigrateApplyCmd.Flags())
 	// Diff flags.
 	devURL(MigrateDiffCmd.Flags())
-	MigrateDiffCmd.Flags().StringVarP(&MigrateFlags.ToURL, migrateDiffFlagTo, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
+	toURL(MigrateDiffCmd.Flags())
 	MigrateDiffCmd.Flags().BoolVarP(&MigrateFlags.Verbose, migrateDiffFlagVerbose, "", false, "enable verbose logging")
 	MigrateDiffCmd.Flags().SortFlags = false
 	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateFlagDevURL))
-	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateDiffFlagTo))
+	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateFlagTo))
 	// Validate flags.
 	devURL(MigrateValidateCmd.Flags())
 	receivesEnv(MigrateCmd)
 }
 
+// CmdMigrateApplyRun is the command executed when running the CLI with 'migrate apply' args.
+func CmdMigrateApplyRun(cmd *cobra.Command, args []string) error {
+	var (
+		n   int
+		err error
+	)
+	if len(args) > 1 {
+		n, err = strconv.Atoi(args[1])
+		if err != nil {
+			return err
+		}
+	}
+	// Open the migration directory.
+	dir, err := dir()
+	if err != nil {
+		return err
+	}
+	// Open a client to the database.
+	target, err := sqlclient.Open(cmd.Context(), MigrateFlags.ToURL)
+	if err != nil {
+		return err
+	}
+	// Get the correct log format and destination. Currently, only os.Stdout is supported.
+	l, err := logFormat(cmd.OutOrStdout())
+	if err != nil {
+		return err
+	}
+	// Currently, only in DB revisions are supported.
+	opts := []entmigrate.Option{entmigrate.WithSchema(MigrateFlags.RevisionSchema)}
+	rrw, err := entmigrate.NewEntRevisions(target, opts...)
+	if err != nil {
+		return err
+	}
+	if err := rrw.Init(cmd.Context()); err != nil {
+		return err
+	}
+	// Wrap the whole execution in one transaction. This behaviour will change once
+	// there are insights about migration files available.
+	tx, err := target.Tx(cmd.Context(), nil)
+	if err != nil {
+		return err
+	}
+	// Get the executor.
+	ex, err := migrate.NewExecutor(tx.Driver, dir, rrw, migrate.WithLogger(l))
+	if err != nil {
+		return err
+	}
+	defer func(rrw *entmigrate.EntRevisions, ctx context.Context) {
+		if err2 := rrw.Flush(ctx); err2 != nil {
+			if err != nil {
+				err = fmt.Errorf("%v: %w", err2, err)
+			} else {
+				err = err2
+			}
+		}
+	}(rrw, cmd.Context())
+	err = ex.ExecuteN(cmd.Context(), n)
+	if err != nil {
+		if err2 := tx.Rollback(); err2 != nil {
+			err = fmt.Errorf("%v: %w", err2, err)
+		}
+		return err
+	}
+	err = tx.Commit()
+	return err
+}
+
 // CmdMigrateDiffRun is the command executed when running the CLI with 'migrate diff' args.
 func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	// Open a dev driver.
@@ -248,7 +346,7 @@ func CmdMigrateValidateRun(cmd *cobra.Command, _ []string) error {
 	if err != nil {
 		return err
 	}
-	ex, err := migrate.NewExecutor(dev.Driver, dir, migrate.NoopRevisionReadWriter{})
+	ex, err := migrate.NewExecutor(dev.Driver, dir, migrate.NopRevisionReadWriter{})
 	if err != nil {
 		return err
 	}
@@ -351,6 +449,73 @@ func formatter() (migrate.Formatter, error) {
 	}
 }
 
+const (
+	logFormatTTY = "tty"
+)
+
+// LogTTY is a migrate.Logger that pretty prints execution progress.
+// If the connected out is not a tty, it will fall back to a non-colorful output.
+type LogTTY struct {
+	out         io.Writer
+	start       time.Time
+	fileStart   time.Time
+	fileCounter int
+	stmtCounter int
+}
+
+var (
+	cyan    = color.CyanString
+	yellow  = color.YellowString
+	dash    = yellow("--")
+	arr     = cyan("->")
+	indent2 = strings.Repeat(" ", 2)
+	indent4 = strings.Repeat(indent2, 2)
+)
+
+// Log implements the migrate.Logger interface.
+func (l *LogTTY) Log(e migrate.LogEntry) {
+	switch e := e.(type) {
+	case migrate.LogExecution:
+		l.start = time.Now()
+		fmt.Fprintf(l.out, "Migrating to version %v", cyan(e.To))
+		if e.From != "" {
+			fmt.Fprintf(l.out, " from %v", cyan(e.From))
+		}
+		fmt.Fprintf(l.out, " (%d migrations in total):\n", len(e.Files))
+	case migrate.LogFile:
+		l.fileCounter++
+		if !l.fileStart.IsZero() {
+			l.reportFileEnd()
+		}
+		l.fileStart = time.Now()
+		fmt.Fprintf(l.out, "\n%s%v migrating version %v\n", indent2, dash, cyan(e.Version))
+	case migrate.LogStmt:
+		l.stmtCounter++
+		fmt.Fprintf(l.out, "%s%v %s\n", indent4, arr, e.SQL)
+	case migrate.LogDone:
+		l.reportFileEnd()
+		fmt.Fprintf(l.out, "\n%s%v\n", indent2, cyan(strings.Repeat("-", 25)))
+		fmt.Fprintf(l.out, "%s%v %v\n", indent2, dash, time.Since(l.start))
+		fmt.Fprintf(l.out, "%s%v %v migrations\n", indent2, dash, l.fileCounter)
+		fmt.Fprintf(l.out, "%s%v %v sql statements\n", indent2, dash, l.stmtCounter)
+	default:
+		fmt.Fprintf(l.out, "%v", e)
+	}
+}
+
+func (l *LogTTY) reportFileEnd() {
+	fmt.Fprintf(l.out, "%s%v ok (%v)\n", indent2, dash, yellow("%s", time.Since(l.fileStart)))
+}
+
+func logFormat(out io.Writer) (migrate.Logger, error) {
+	switch MigrateFlags.LogFormat {
+	case logFormatTTY:
+		return &LogTTY{out: out}, nil
+	default:
+		return nil, fmt.Errorf("unknown log-format %q", MigrateFlags.LogFormat)
+	}
+}
+
 func migrateFlagsFromEnv(cmd *cobra.Command, _ []string) error {
 	activeEnv, err := selectEnv(GlobalFlags.SelectedEnv)
 	if err != nil {
@@ -373,7 +538,7 @@ func migrateFlagsFromEnv(cmd *cobra.Command, _ []string) error {
 		}
 		toURL = "file://" + toURL
 	}
-	if err := maySetFlag(cmd, migrateDiffFlagTo, toURL); err != nil {
+	if err := maySetFlag(cmd, migrateFlagTo, toURL); err != nil {
 		return err
 	}
 	if s := "[" + strings.Join(activeEnv.Schemas, "") + "]"; len(activeEnv.Schemas) > 0 {
diff --git a/cmd/atlascmd/migrate/migrate.go b/cmd/atlascmd/migrate/migrate.go
index 917e154f..a0e408f9 100644
--- a/cmd/atlascmd/migrate/migrate.go
+++ b/cmd/atlascmd/migrate/migrate.go
@@ -13,6 +13,7 @@ import (
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
+	"entgo.io/ent/dialect"
 	"entgo.io/ent/dialect/sql"
 	entschema "entgo.io/ent/dialect/sql/schema"
 )
@@ -20,10 +21,11 @@ import (
 type (
 	// A EntRevisions provides implementation for the migrate.RevisionReadWriter interface.
 	EntRevisions struct {
-		ac     *sqlclient.Client // underlying Atlas client
-		sc     *sqlclient.Client // underlying Atlas client connected to the named schema
-		ec     *ent.Client       // underlying Ent client
-		schema string            // name of the schema the revision table resides in
+		ac     *sqlclient.Client  // underlying Atlas client
+		sc     *sqlclient.Client  // underlying Atlas client connected to the named schema
+		ec     *ent.Client        // underlying Ent client
+		schema string             // name of the schema the revision table resides in
+		cache  []migrate.Revision // cache stores writes to Ent for blocked connections (like in SQLite).
 	}
 
 	// Option allows to configure EntRevisions by using functional arguments.
@@ -82,11 +84,14 @@ func (r *EntRevisions) Init(ctx context.Context) error {
 			return err
 		}
 	}
+	r.ac.AddClosers(r.sc)
 	r.ec = ent.NewClient(ent.Driver(sql.OpenDB(r.sc.Name, r.sc.DB)))
 	return r.ec.Schema.Create(ctx, entschema.WithAtlas(true))
 }
 
 // ReadRevisions reads the revisions from the revisions table.
+//
+// ReadRevisions will not return results only saved to cache.
 func (r *EntRevisions) ReadRevisions(ctx context.Context) (migrate.Revisions, error) {
 	revs, err := r.ec.Revision.Query().Order(ent.Asc(revision.FieldID)).All(ctx)
 	if err != nil {
@@ -108,33 +113,52 @@ func (r *EntRevisions) ReadRevisions(ctx context.Context) (migrate.Revisions, er
 	return ret, nil
 }
 
-// WriteRevisions writes the revisions to the revisions table.
-func (r *EntRevisions) WriteRevisions(ctx context.Context, rs migrate.Revisions) error {
-	bulk := make([]*ent.RevisionCreate, len(rs))
-	for i, rev := range rs {
-		bulk[i] = r.ec.Revision.Create().
-			SetID(rev.Version).
-			SetDescription(rev.Description).
-			SetExecutionState(revision.ExecutionState(rev.ExecutionState)).
-			SetExecutedAt(rev.ExecutedAt).
-			SetExecutionTime(rev.ExecutionTime).
-			SetHash(rev.Hash).
-			SetOperatorVersion(rev.OperatorVersion).
-			SetMeta(rev.Meta)
+// WriteRevision writes a revision to the revisions table.
+func (r *EntRevisions) WriteRevision(ctx context.Context, rev *migrate.Revision) error {
+	if r.useCache() {
+		// Do not store the pointer since we want to maintain the order for writes to the database.
+		r.cache = append(r.cache, *rev)
+		return nil
+	}
+	return r.write(ctx, rev)
+}
+
+// Flush writes the changes saved in memory to the database.
+//
+// This method exists to support both execution of migration in a transaction and saving revision for SQLite flavors,
+// since attempting to write to the database while in a transaction will fail there.
+func (r *EntRevisions) Flush(ctx context.Context) error {
+	if !r.useCache() {
+		return nil
 	}
-	return r.ec.Revision.CreateBulk(bulk...).
-		OnConflict(
-			sql.ConflictColumns(revision.FieldID),
-		).
+	for i := range r.cache {
+		if err := r.write(ctx, &r.cache[i]); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// write attempts to write the given revision to the database.
+func (r *EntRevisions) write(ctx context.Context, rev *migrate.Revision) error {
+	return r.ec.Revision.Create().
+		SetID(rev.Version).
+		SetDescription(rev.Description).
+		SetExecutionState(revision.ExecutionState(rev.ExecutionState)).
+		SetExecutedAt(rev.ExecutedAt).
+		SetExecutionTime(rev.ExecutionTime).
+		SetHash(rev.Hash).
+		SetOperatorVersion(rev.OperatorVersion).
+		SetMeta(rev.Meta).
+		OnConflict(sql.ConflictColumns(revision.FieldID)).
 		UpdateNewValues().
 		Exec(ctx)
 }
 
-// Close closes the Atlas client connected to the revision schema (if any).
-func (r *EntRevisions) Close() {
-	if r.sc != nil {
-		r.sc.Close()
-	}
+func (r *EntRevisions) useCache() bool {
+	// For SQLite dialect and flavors we have to enable the revision write cache to postpone writing to
+	// the database until the transaction wrapping the migration execution has been committed.
+	return r.ac.Name == dialect.SQLite
 }
 
 var _ migrate.RevisionReadWriter = (*EntRevisions)(nil)
diff --git a/cmd/atlascmd/schema.go b/cmd/atlascmd/schema.go
index 3707e15e..2b4c84b5 100644
--- a/cmd/atlascmd/schema.go
+++ b/cmd/atlascmd/schema.go
@@ -200,7 +200,7 @@ func schemaFlagsFromEnv(cmd *cobra.Command, _ []string) error {
 	if err := maySetFlag(cmd, fileFlag, activeEnv.Source); err != nil {
 		return err
 	}
-	if s := "[" + strings.Join(activeEnv.Schemas, "") + "]"; len(activeEnv.Schemas) > 0 {
+	if s := strings.Join(activeEnv.Schemas, ""); len(activeEnv.Schemas) > 0 {
 		if err := maySetFlag(cmd, schemaFlag, s); err != nil {
 			return err
 		}
diff --git a/doc/md/cli/reference.md b/doc/md/cli/reference.md
index 217e8c15..cdf98e8b 100644
--- a/doc/md/cli/reference.md
+++ b/doc/md/cli/reference.md
@@ -14,10 +14,8 @@ For a more detailed introduction to the CLI capabilities, head over to the
 
 ## Distributed Binaries
 
-Starting [v0.3.0](https://github.com/ariga/atlas/releases/tag/v0.3.0),
-ֿthe distributed binaries include code for a [Management UI](ui/intro.md) wrapping the
-core atlas engine that is not currently released publicly. The binaries
-themselves are still released under the same [Apache License 2.0](https://github.com/ariga/atlas/blob/master/LICENSE).
+The binaries distributed in official releases are released under the  [Ariga End User License](https://ariga.io/legal/atlas/eula).
+If you would like to build Atlas from source follow the instructions [here](https://atlasgo.io/cli/reference#building-from-source).
 
 ### Building from Source
 
@@ -70,6 +68,38 @@ atlas migrate
 ```
 
 
+### atlas migrate apply
+
+Applies pending migration files on the connected database.
+
+#### Usage
+```
+atlas migrate apply [flags]
+```
+
+#### Details
+'atlas migrate apply' reads the migration state of the connected database and computes what migrations are pending.
+It then attempts to apply the pending migration files in the correct order onto the database. 
+The first argument denotes the maximum number of migration files to apply.
+As a safety measure 'atlas migrate apply' will abort with an error, if:
+  - the migration directory is not in sync with the 'atlas.sum' file
+  - the migration and database history do not match each other
+
+#### Example
+
+```
+  atlas migrate apply --to mysql://user:pass@localhost:3306/dbname
+  atlas migrate apply 1 --dir file:///path/to/migration/directory --to mysql://user:pass@localhost:3306/dbname
+```
+#### Flags
+```
+      --log string                log format to use (default "tty")
+      --revisions-schema string   schema name where the revisions table is to be created
+      --to string                 [driver://username:password@address/dbname?param=value] select a data source using the URL format
+
+```
+
+
 ### atlas migrate diff
 
 Compute the diff between the migration directory and a connected database and create a new migration file.
@@ -154,8 +184,8 @@ executed on the connected database in order to validate SQL semantics.
 
 ```
   atlas migrate validate
-  atlas migrate validate --dir /path/to/migration/directory
-  atlas migrate validate --dir /path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev
+  atlas migrate validate --dir file:///path/to/migration/directory
+  atlas migrate validate --dir file:///path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev
 ```
 #### Flags
 ```
diff --git a/doc/md/getting-started/01-introduction.mdx b/doc/md/getting-started/01-introduction.mdx
index 4ff86567..716608d2 100644
--- a/doc/md/getting-started/01-introduction.mdx
+++ b/doc/md/getting-started/01-introduction.mdx
@@ -83,10 +83,9 @@ move the atlas binary to a file location on your system PATH.
 Latest release is updated twice a day and is based on the most recent tagged release.
 :::
 
-The distributed binaries include code for a [Management UI](../ui/intro.md) wrapping the
-core Atlas engine that is not currently released publicly. The binaries
-themselves are released under the [Ariga End User License](https://ariga.io/legal/atlas/eula).
-If you would like to build Atlas from source without the UI follow the instructions [here](../cli/reference.md#building-from-source).
+The binaries distributed in official releases are released under the  [Ariga End User License](https://ariga.io/legal/atlas/eula).
+If you would like to build Atlas from source follow the instructions [here](https://atlasgo.io/cli/reference#building-from-source).
+
 
 To verify that the CLI is installed correctly run:
 ```shell
diff --git a/doc/website/blog/2022-06-09-announcing-cockroachdb-support-preview.md b/doc/website/blog/2022-06-09-announcing-cockroachdb-support-preview.md
new file mode 100644
index 00000000..6526f534
--- /dev/null
+++ b/doc/website/blog/2022-06-09-announcing-cockroachdb-support-preview.md
@@ -0,0 +1,144 @@
+---
+title: Announcing v0.4.2 with preview support for CockroachDB
+authors: Hedwigz
+tags: [cockroachdb, integration, announcement]
+image: https://blog.ariga.io/uploads/images/posts/cockroachdb/cockroachdb.png
+---
+
+Today, I'm happy to announce the release of [v0.4.2](https://github.com/ariga/atlas/releases/tag/v0.4.2) of the Atlas CLI. 
+This version includes many improvements and fixes, but I wanted to share with you exciting news about something I
+personally worked on. As of v0.4.2, Atlas includes preview support for CockroachDB 🎉 
+
+## Atlas
+[Atlas](https://atlasgo.io) is an open-source project that helps developers to better manage their database
+schemas. It has a [CLI tool](https://atlasgo.io/cli/reference) and a
+[Terraform integration](https://atlasgo.io/blog/2022/05/04/announcing-terraform-provider). By using Atlas's
+Data Definition Language (with a syntax similar to Terraform), users can plan, verify and apply changes
+to their databases in a simple, declarative workflow.
+Earlier this year, Atlas became the [migration engine for Ent](https://entgo.io/blog/2022/01/20/announcing-new-migration-engine),
+a widely popular, Linux Foundation backed entity framework for Go.
+
+## CockroachDB
+[CockroachDB](https://www.cockroachlabs.com/) is an [open-source](https://github.com/cockroachdb/cockroach) NewSQL 
+database. From their README:
+> CockroachDB is a distributed SQL database built on a transactional and strongly-consistent 
+> key-value store. It scales horizontally; survives disk, machine, rack, and even datacenter 
+> failures with minimal latency disruption and no manual intervention; supports strongly-consistent 
+> ACID transactions; and provides a familiar SQL API for structuring, manipulating, and querying data.  
+  
+CockroachDB has been gaining popularity and many of you [have](https://github.com/ent/ent/issues/2545)
+[been](https://github.com/ariga/atlas/issues/785#issue-1231951038) [asking](https://github.com/ariga/atlas/issues/785#issuecomment-1125853135) 
+for Atlas to support it.
+
+While CockroachDB aims to be PostgreSQL compatible, it still has some incompatibilities 
+(e.g. [1](https://github.com/cockroachdb/cockroach/issues/20296#issuecomment-1066140651), 
+[2](https://github.com/cockroachdb/cockroach/issues/82064),[3](https://github.com/cockroachdb/cockroach/issues/81659))
+which prevented Atlas users using the existing Postgres dialect from working with it.  
+  
+With the latest release of Atlas, the Postgres driver automatically detects if it is connected to a CockroachDB
+database and uses a custom driver which provides compatability with CockroachDB.
+
+### Getting started with Atlas and CockroachDB
+
+Let's see how we can use Atlas CLI to manage the schema of a CockroachDB database. 
+Start by downloading the latest version of Atlas, on macOS:
+```
+brew install ariga/tap/atlas
+```
+For installation instructions on other platforms, see [the docs](https://atlasgo.io/cli/getting-started/setting-up#install-the-cli).
+
+For the purpose of this example, let's spin up a local, [single-node CockroachDB cluster](https://www.cockroachlabs.com/docs/stable/cockroach-start-single-node.html)
+in a container by running:
+```
+docker run --rm -d -p 26257:26257 --name crdb cockroachdb/cockroach start-single-node --insecure
+```
+
+Next, let's seed the database with a simple table:
+```
+docker exec crdb cockroach sql --insecure -e 'CREATE TABLE users (id int primary key);'
+```
+
+After creating the `users` table, use Atlas's `schema inspect` command to read the schema of our local database and save the result to a file:
+```
+atlas schema inspect -u 'postgres://root:pass@localhost:26257/?sslmode=disable' --schema public > schema.hcl
+```
+Observe the current HCL representation of the `public` schema, which contains our newly created table, `users`:
+```hcl
+table "users" {
+  schema = schema.public
+  column "id" {
+    null = false
+    type = bigint
+  }
+  primary_key {
+    columns = [column.id]
+  }
+}
+schema "public" {
+}
+```
+
+Next, edit `schema.hcl` to add a column to the `users` table: 
+
+```hcl title="schema.hcl" {7-9}
+table "users" {
+  schema = schema.public
+  column "id" {
+    null = false
+    type = bigint
+  }
+  column "name" {
+    type = varchar(100)
+  }
+  primary_key {
+    columns = [column.id]
+  }
+}
+schema "public" {
+}
+```
+Now apply the schema using the `schema apply` command:
+```
+atlas schema apply -u 'postgres://root:pass@localhost:26257/?sslmode=disable' --schema public -f schema.hcl
+```
+Atlas prints out the planned changes and asks for your confirmation: 
+```
+-- Planned Changes:
+-- Create "test" table
+ALTER TABLE "public"."users" ADD COLUMN "name" character varying(100) NOT NULL
+? Are you sure?:
+  ▸ Apply
+    Abort
+```
+After hitting "Apply", Atlas applies the desired schema to the database:
+```
+✔ Apply
+```
+
+We have successfully applied our schema to our database. 
+
+To stop the container running CockroachDB run:
+
+```
+docker stop crdb
+```
+
+### Learn more about Atlas
+
+In this short example, we demonstrated two of Atlas's basic features: database inspection
+and declarative schema migration (applying a desired schema on a database). Here are some topics
+you may want to explore when getting started with Atlas:
+* [Learn the DDL](/ddl/sql) - learn how to define any SQL resource in Atlas's data definition
+  language.
+* [Try the Terraform Provider](https://atlasgo.io/blog/2022/05/04/announcing-terraform-provider) - see how you can use 
+  the Atlas Terraform Provider to integrate schema management in your general Infrastructure-as-Code workflows.
+* [Use the `migrate` command to author migrations](/cli/reference#atlas-migrate) - In addition to the Terraform-like
+ declarative workflow, Atlas can manage a migration script directory for you based on your desired schema.
+
+# Preview support
+The integration of Atlas with CockroachDB is well tested with version `v21.2.11` (at the time of writing, 
+`latest`) and will be extended in the future. If you're using other versions of CockroachDB or looking 
+for help, don't hesitate to [file an issue](https://github.com/ariga/atlas/issues) or join our
+[Discord channel](https://discord.gg/zZ6sWVg6NT).
+
+Have questions? Feedback? Find our team [on our Discord server](https://discord.gg/zZ6sWVg6NT).
diff --git a/doc/website/src/css/custom.css b/doc/website/src/css/custom.css
index 5ef830e8..8bf26e6e 100644
--- a/doc/website/src/css/custom.css
+++ b/doc/website/src/css/custom.css
@@ -19,6 +19,7 @@
 }
 
 .footer__title {
+  color: #000939;
   font-size: 22px;
 }
 .footer__link-item {
diff --git a/go.mod b/go.mod
index 06afbfd1..c65bc537 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.17
 require (
 	entgo.io/ent v0.10.2-0.20220502113020-4ac82f5bb3f0
 	github.com/DATA-DOG/go-sqlmock v1.5.0
+	github.com/fatih/color v1.13.0
 	github.com/go-openapi/inflect v0.19.0
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/hashicorp/hcl/v2 v2.10.0
@@ -28,11 +29,11 @@ require (
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
+	github.com/mattn/go-colorable v0.1.9 // indirect
+	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
 	golang.org/x/text v0.3.7 // indirect
-	golang.org/x/tools v0.1.9-0.20211216111533-8d383106f7e7 // indirect
-	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 )
diff --git a/go.sum b/go.sum
index e365cb3c..68043dcc 100644
--- a/go.sum
+++ b/go.sum
@@ -20,6 +20,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
 github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -50,6 +52,11 @@ github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
 github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
 github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
+github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk=
 github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -91,6 +98,9 @@ golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -100,10 +110,8 @@ golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.1.9-0.20211216111533-8d383106f7e7 h1:M1gcVrIb2lSn2FIL19DG0+/b8nNVKJ7W7b4WcAGZAYM=
-golang.org/x/tools v0.1.9-0.20211216111533-8d383106f7e7/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/ci/ci.tmpl b/internal/ci/ci.tmpl
index d858ff96..b2a8c8c1 100644
--- a/internal/ci/ci.tmpl
+++ b/internal/ci/ci.tmpl
@@ -40,7 +40,7 @@ jobs:
         run: go generate ./...
       - name: Verify generated files are checked in properly
         run: |
-          status=$(git status --porcelain)
+          status=$(git status --porcelain | grep -v "go.\(sum\|mod\)" | cat)
           if [ -n "$status" ]; then
             echo "you need to run 'go generate ./...' and commit the changes"
             echo "$status"
@@ -71,7 +71,10 @@ jobs:
         working-directory: schema
       - name: Run cli tests
         run: go test -race ./...
-        working-directory: cmd
+        working-directory: cmd/atlascmd
+      - name: Run atlasci tests
+        run: go test -race ./internal/ci/...
+        working-directory: cmd/atlasci
 
   {{ range $ }}
   integration-{{ .Version }}:
diff --git a/internal/ci/cockroach/Dockerfile.tmpl b/internal/ci/cockroach/Dockerfile.tmpl
new file mode 100644
index 00000000..3ea8f263
--- /dev/null
+++ b/internal/ci/cockroach/Dockerfile.tmpl
@@ -0,0 +1,6 @@
+FROM cockroachdb/cockroach:{{ .Version}}
+
+EXPOSE 8080
+EXPOSE 26257
+
+ENTRYPOINT ["/cockroach/cockroach", "start-single-node", "--insecure"]
\ No newline at end of file
diff --git a/internal/ci/cockroach/main.go b/internal/ci/cockroach/main.go
new file mode 100644
index 00000000..a35a9ce6
--- /dev/null
+++ b/internal/ci/cockroach/main.go
@@ -0,0 +1,38 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package main
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"text/template"
+)
+
+type params struct {
+	Version string
+}
+
+//go:embed Dockerfile.tmpl
+var dockerTmpl string
+
+func main() {
+	if len(os.Args) < 2 {
+		fmt.Println("please supply version as argument e.g. 'v22.1.0'")
+		os.Exit(1)
+	}
+
+	p := params{
+		Version: os.Args[1],
+	}
+	t, err := template.New("docker").Parse(dockerTmpl)
+	if err != nil {
+		panic(err)
+	}
+	err = t.Execute(os.Stdout, p)
+	if err != nil {
+		panic(err)
+	}
+}
diff --git a/internal/ci/main.go b/internal/ci/main.go
index 3b8f5604..93f2fec0 100644
--- a/internal/ci/main.go
+++ b/internal/ci/main.go
@@ -158,7 +158,7 @@ var (
 		},
 		{
 			Version: "cockroach",
-			Image:   "timveil/cockroachdb-single-node:latest",
+			Image:   "ghcr.io/ariga/cockroachdb-single-node:v21.2.11",
 			Regex:   re("Cockroach.*/%sCockroach"),
 			Ports:   []string{"26257:26257"},
 		},
diff --git a/internal/integration/go.sum b/internal/integration/go.sum
index f004c973..b4cc7ef4 100644
--- a/internal/integration/go.sum
+++ b/internal/integration/go.sum
@@ -28,6 +28,7 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
 github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -81,6 +82,8 @@ github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
 github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
 github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
+github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk=
 github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@@ -162,6 +165,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index cd860a63..10b58dd6 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -177,7 +177,7 @@ type (
 	// Atlas drivers provide a sql based implementation of this interface by default.
 	RevisionReadWriter interface {
 		ReadRevisions(context.Context) (Revisions, error)
-		WriteRevisions(context.Context, Revisions) error
+		WriteRevision(context.Context, *Revision) error
 	}
 
 	// Planner can plan the steps to take to migrate from one state to another. It uses the enclosed FS to write
@@ -262,7 +262,7 @@ func DisableChecksum() PlannerOption {
 // Plan calculates the migration Plan required for moving the current state (from) state to
 // the next state (to). A StateReader can be a directory, static schema elements or a Driver connection.
 func (p *Planner) Plan(ctx context.Context, name string, to StateReader) (*Plan, error) {
-	ex, err := NewExecutor(p.drv, p.dir, NoopRevisionReadWriter{})
+	ex, err := NewExecutor(p.drv, p.dir, NopRevisionReadWriter{})
 	if err != nil {
 		return nil, err
 	}
@@ -286,10 +286,12 @@ func (p *Planner) Plan(ctx context.Context, name string, to StateReader) (*Plan,
 
 // WritePlan writes the given Plan to the Dir based on the configured Formatter.
 func (p *Planner) WritePlan(plan *Plan) error {
+	// Format the plan into files.
 	files, err := p.fmt.Format(plan)
 	if err != nil {
 		return err
 	}
+	// Store the files in the migration directory.
 	for _, f := range files {
 		d, err := io.ReadAll(f)
 		if err != nil {
@@ -299,6 +301,7 @@ func (p *Planner) WritePlan(plan *Plan) error {
 			return err
 		}
 	}
+	// If enabled, update the sum file.
 	if p.sum {
 		sum, err := HashSum(p.dir)
 		if err != nil {
@@ -334,12 +337,16 @@ func NewExecutor(drv Driver, dir Dir, rrw RevisionReadWriter, opts ...ExecutorOp
 		return nil, errors.New("sql/migrate: execute: dir cannot be nil")
 	}
 	if rrw == nil {
-		return nil, errors.New("sql/migrate: execute: mockRevisionReadWriter cannot be nil")
+		return nil, errors.New("sql/migrate: execute: rrw cannot be nil")
 	}
 	// If the driver does not support acquiring a lock, don't execute migrations as this can potentially be fatal.
 	if _, ok := drv.(schema.Locker); !ok {
 		return nil, ErrLockUnsupported
 	}
+	// Check if the Dir implements Scanner interface.
+	if _, ok := dir.(Scanner); !ok {
+		return nil, errors.New("sql/migrate: execute: no scanner available")
+	}
 	p := &Executor{drv: drv, dir: dir, rrw: rrw}
 	for _, opt := range opts {
 		if err := opt(p); err != nil {
@@ -357,133 +364,168 @@ func WithLogger(log Logger) ExecutorOption {
 	}
 }
 
-// Execute executes n missing migration files on the database. Passing in n <= 0 means running all pending migrations.
-func (e *Executor) Execute(ctx context.Context, n int) (err error) {
+// Lock acquires a lock for the executor.
+// It is considered a user error to not call Lock before the Pending and Execute methods.
+func (e *Executor) Lock(ctx context.Context) (schema.UnlockFunc, error) {
 	unlock, err := e.drv.(schema.Locker).Lock(ctx, "atlas_migration_execute", 0)
 	if err != nil {
-		return fmt.Errorf("sql/migrate: acquiring database lock: %w", err)
+		return nil, fmt.Errorf("sql/migrate: acquiring database lock: %w", err)
 	}
-	defer unlock()
+	return unlock, nil
+}
+
+// Pending returns all pending (not applied) migration files in the migration directory.
+func (e *Executor) Pending(ctx context.Context) ([]File, error) {
 	// Don't operate with a broken migration directory.
-	// TODO(maseeelch): do not check here but let the caller check it before? Or let the caller decide if to skip this flag by using some flags.
 	if err := Validate(e.dir); err != nil {
-		return fmt.Errorf("sql/migrate: execute: validate migration directory: %w", err)
-	}
-	// Check if the Dir implements Scanner interface.
-	sc, ok := e.dir.(Scanner)
-	if !ok {
-		return errors.New("sql/migrate: execute: no scanner available")
+		return nil, fmt.Errorf("sql/migrate: execute: validate migration directory: %w", err)
 	}
 	// Read all applied database revisions.
-	revisions, err := e.rrw.ReadRevisions(ctx)
+	revs, err := e.rrw.ReadRevisions(ctx)
 	if err != nil {
-		return fmt.Errorf("sql/migrate: execute: read revisions: %w", err)
+		return nil, fmt.Errorf("sql/migrate: execute: read revisions: %w", err)
 	}
 	// Select the correct migration files.
+	sc := e.dir.(Scanner)
 	migrations, err := sc.Files()
 	if err != nil {
-		return fmt.Errorf("sql/migrate: execute: select migration files: %w", err)
+		return nil, fmt.Errorf("sql/migrate: execute: select migration files: %w", err)
 	}
 	// Check if the existing revisions did come from the migration directory and not from somewhere else.
 	hf, err := readHashFile(e.dir)
 	if err != nil && !errors.Is(err, os.ErrNotExist) {
-		return fmt.Errorf("sql/migrate: execute: read atlas.sum file: %w", err)
+		return nil, fmt.Errorf("sql/migrate: execute: read atlas.sum file: %w", err)
 	}
 	// If there is no atlas.sum file there are no migration files.
 	if errors.Is(err, os.ErrNotExist) {
-		return ErrNoPendingFiles
+		return nil, ErrNoPendingFiles
 	}
 	// For up to len(revisions) revisions the migration files must both match in order and content.
-	if len(revisions) > len(migrations) { // TODO(masseelch): keep compaction in mind.
-		return errors.New("sql/migrate: execute: revisions and migrations mismatch: more revisions than migrations")
+	if len(revs) > len(migrations) { // TODO(masseelch): keep compaction in mind.
+		return nil, errors.New("sql/migrate: execute: revisions and migrations mismatch: more revisions than migrations")
 	}
-	for i := range revisions {
+	for i := range revs {
 		m := migrations[i]
 		v, err := sc.Version(m)
 		if err != nil {
-			return fmt.Errorf("sql/migrate: execute: scan version from %q: %w", m.Name(), err)
+			return nil, fmt.Errorf("sql/migrate: execute: scan version from %q: %w", m.Name(), err)
 		}
 		d, err := sc.Desc(m)
 		if err != nil {
-			return fmt.Errorf("sql/migrate: execute: scan description from %q: %w", m.Name(), err)
+			return nil, fmt.Errorf("sql/migrate: execute: scan description from %q: %w", m.Name(), err)
 		}
-		r := revisions[i]
+		r := revs[i]
 		if v != r.Version || d != r.Description || hf[i].H != r.Hash {
-			return fmt.Errorf("sql/migrate: execute: revisions and migrations mismatch: rev %q <> file %q", v, r.Version)
+			return nil, fmt.Errorf("sql/migrate: execute: revisions and migrations mismatch: rev %q <> file %q", v, r.Version)
 		}
 	}
-	if len(migrations) == len(revisions) {
-		return ErrNoPendingFiles
+	if len(migrations) == len(revs) {
+		return nil, ErrNoPendingFiles
 	}
-	defer func(rrw RevisionReadWriter, ctx context.Context, revisions *Revisions) {
-		if dErr := rrw.WriteRevisions(ctx, *revisions); dErr != nil {
-			if err != nil {
-				err = fmt.Errorf("sql/migrate: execute: write revisions: %w: %v", dErr, err)
-				return
-			}
-			err = dErr
-		}
-	}(e.rrw, ctx, &revisions)
-	// Determine what migrations to run.
-	var pending []File
-	if n <= 0 {
-		// If n<=0 run every pending migrations.
-		pending = migrations[len(revisions):]
-	} else {
-		// Else run n pending migrations.
-		pc := len(migrations) - len(revisions)
-		if n > pc {
-			n = pc
+	return migrations[len(revs):], nil
+}
+
+// Execute executes the given migration file on the database.
+func (e *Executor) Execute(ctx context.Context, m File) (err error) {
+	r := &Revision{ExecutedAt: time.Now(), ExecutionState: stateOngoing}
+	if err := e.rrw.WriteRevision(ctx, r); err != nil {
+		return fmt.Errorf("sql/migrate: execute: storing revision: %w", err)
+	}
+	// Make sure to store the Revision information.
+	defer func(ctx context.Context, rrw RevisionReadWriter, r *Revision) {
+		if err2 := e.rrw.WriteRevision(ctx, r); err2 != nil {
+			err = wrap(fmt.Errorf("execute: write revision: %w", err2), err)
 		}
-		pending = migrations[len(revisions) : len(revisions)+n]
+	}(ctx, e.rrw, r)
+	sc := e.dir.(Scanner)
+	v, err := sc.Version(m)
+	if err != nil {
+		return r.setGoErr(fmt.Errorf("sql/migrate: execute: scan version from %q: %w", m.Name(), err))
+	}
+	r.Version = v
+	d, err := sc.Desc(m)
+	if err != nil {
+		return r.setGoErr(fmt.Errorf("sql/migrate: execute: scan description from %q: %w", m.Name(), err))
+
+	}
+	r.Description = d
+	if e.log != nil {
+		e.log.Log(LogFile{v, d})
+	}
+	hf, err := HashSum(e.dir)
+	if err != nil {
+		return fmt.Errorf("sql/migrate: execute: create hash file: %w", err)
+	}
+	h, err := hf.sumByName(m.Name())
+	if err != nil {
+		return r.setGoErr(fmt.Errorf("sql/migrate: execute: scanning checksum for file %q: %w", m.Name(), err))
+	}
+	r.Hash = h
+	stmts, err := sc.Stmts(m)
+	if err != nil {
+		return r.setGoErr(fmt.Errorf("sql/migrate: execute: scanning statements from file %q: %w", m.Name(), err))
+	}
+	for _, stmt := range stmts {
+		if e.log != nil {
+			e.log.Log(LogStmt{stmt})
+		}
+		if _, err := e.drv.ExecContext(ctx, stmt); err != nil {
+			return r.setSQLErr(
+				fmt.Errorf("sql/migrate: execute: executing statement %q from version %q: %w", stmt, v, err),
+				stmt,
+			)
+		}
+		r.done(true)
+	}
+	return
+}
+
+// ExecuteN executes n pending migration files. If n<=0 all pending migration files are executed.
+func (e *Executor) ExecuteN(ctx context.Context, n int) error {
+	unlock, err := e.Lock(ctx)
+	if err != nil {
+		return err
+	}
+	defer unlock()
+	pending, err := e.Pending(ctx)
+	if err != nil {
+		return err
+	}
+	if n > 0 {
+		if n >= len(pending) {
+			n = len(pending)
+		}
+		pending = pending[:n]
 	}
 	if e.log != nil {
 		names := make([]string, len(pending))
 		for i := range pending {
 			names[i] = pending[i].Name()
 		}
-		e.log.Log(LogExecution{names})
-	}
-	// TODO(masseelch): run in a transaction
-	for _, m := range pending {
-		r := &Revision{ExecutedAt: time.Now(), ExecutionState: stateOngoing}
-		revisions = append(revisions, r)
-		v, err := sc.Version(m)
-		if err != nil {
-			return r.setGoErr(fmt.Errorf("sql/migrate: execute: scan version from %q: %w", m.Name(), err))
-		}
-		r.Version = v
-		d, err := sc.Desc(m)
+		revs, err := e.rrw.ReadRevisions(ctx)
 		if err != nil {
-			return r.setGoErr(fmt.Errorf("sql/migrate: execute: scan description from %q: %w", m.Name(), err))
+			return fmt.Errorf("sql/migrate: execute: read revisions: %w", err)
 		}
-		r.Description = d
-		if e.log != nil {
-			e.log.Log(LogFile{v, d})
-		}
-		h, err := hf.sumByName(m.Name())
+		last := pending[len(pending)-1]
+		v, err := e.dir.(Scanner).Version(last)
 		if err != nil {
-			return r.setGoErr(fmt.Errorf("sql/migrate: execute: scanning checksum for file %q: %w", m.Name(), err))
+			return fmt.Errorf("sql/migrate: execute: scan version from %q: %w", last.Name(), err)
 		}
-		r.Hash = h
-		stmts, err := sc.Stmts(m)
-		if err != nil {
-			return r.setGoErr(fmt.Errorf("sql/migrate: execute: scanning statements from file %q: %w", m.Name(), err))
+		l := LogExecution{To: v, Files: names}
+		if len(revs) > 0 {
+			l.From = revs[len(revs)-1].Version
 		}
-		for _, stmt := range stmts {
-			if e.log != nil {
-				e.log.Log(LogStmt{stmt})
-			}
-			if _, err := e.drv.ExecContext(ctx, stmt); err != nil {
-				return r.setSQLErr(
-					fmt.Errorf("sql/migrate: execute: executing statement %q from version %q: %w", stmt, v, err),
-					stmt,
-				)
-			}
-			r.done(true)
+		e.log.Log(l)
+	}
+	for _, m := range pending {
+		if err := e.Execute(ctx, m); err != nil {
+			return err
 		}
 	}
-	return
+	if e.log != nil {
+		e.log.Log(LogDone{})
+	}
+	return err
 }
 
 // ErrNotClean is returned when the connected dev-db is not in a clean state (aka it has schemas and tables).
@@ -503,7 +545,7 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 	if c, ok := e.drv.(interface {
 		// The IsClean method can be added to a Driver to override how to
 		// determine if a connected database is in a clean state.
-		// This interface exists only to support SQLite favors.
+		// This interface exists solely to support SQLite flavors.
 		IsClean(context.Context) (bool, error)
 	}); ok {
 		e, err := c.IsClean(ctx)
@@ -526,7 +568,7 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 	defer func() {
 		if e, ok := e.drv.(interface {
 			// The Clean method can be added to a Driver to change how to clean a database.
-			// This interface exists only to support SQLite favors.
+			// This interface exists solely to support SQLite flavors.
 			Clean(context.Context) error
 		}); ok {
 			if derr := e.Clean(ctx); derr != nil {
@@ -542,12 +584,12 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 		for i, s := range realm.Schemas {
 			del[i] = &schema.DropSchema{S: s, Extra: []schema.Clause{&schema.IfExists{}}}
 		}
-		if derr := e.drv.ApplyChanges(ctx, del); derr != nil {
-			err = wrap(derr, err)
+		if err2 := e.drv.ApplyChanges(ctx, del); err2 != nil {
+			err = wrap(err2, err)
 		}
 	}()
 	// Replay the migration directory on the database.
-	if err := e.Execute(ctx, 0); err != nil && !errors.Is(err, ErrNoPendingFiles) {
+	if err := e.ExecuteN(ctx, 0); err != nil && !errors.Is(err, ErrNoPendingFiles) {
 		return nil, fmt.Errorf("sql/migrate: read migration directory state: %w", err)
 	}
 	// Inspect the database back and return the result.
@@ -555,20 +597,22 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 	return
 }
 
-// NoopRevisionReadWriter is a RevisionsReadWriter that does nothing.
+// NopRevisionReadWriter is a RevisionsReadWriter that does nothing.
 // It is useful for one-time replay of the migration directory.
-type NoopRevisionReadWriter struct{}
+type NopRevisionReadWriter struct{}
 
 // ReadRevisions implements RevisionsReadWriter.ReadRevisions,
-func (NoopRevisionReadWriter) ReadRevisions(context.Context) (Revisions, error) {
+func (NopRevisionReadWriter) ReadRevisions(context.Context) (Revisions, error) {
 	return nil, nil
 }
 
-// WriteRevisions implements RevisionsReadWriter.WriteRevisions,
-func (NoopRevisionReadWriter) WriteRevisions(context.Context, Revisions) error {
+// WriteRevision implements RevisionsReadWriter.WriteRevision,
+func (NopRevisionReadWriter) WriteRevision(context.Context, *Revision) error {
 	return nil
 }
 
+var _ RevisionReadWriter = (*NopRevisionReadWriter)(nil)
+
 // done computes and sets the ExecutionTime.
 func (r *Revision) done(ok bool) {
 	r.ExecutionTime = time.Now().Sub(r.ExecutedAt)
@@ -602,9 +646,14 @@ type (
 		logEntry()
 	}
 
-	// LogExecution is sent once when execution of the migration files has been started.
+	// LogExecution is sent once when execution of multiple migration files has been started.
 	// It holds the filenames of the pending migration files.
 	LogExecution struct {
+		// From what version.
+		From string
+		// To what version.
+		To string
+		// Migration Files to be executed.
 		Files []string
 	}
 
@@ -620,11 +669,15 @@ type (
 	LogStmt struct {
 		SQL string
 	}
+
+	// LogDone is sent if the execution is done.
+	LogDone struct{}
 )
 
 func (LogExecution) logEntry() {}
 func (LogFile) logEntry()      {}
 func (LogStmt) logEntry()      {}
+func (LogDone) logEntry()      {}
 
 // LocalDir implements Dir for a local path. It implements the Scanner interface compatible with
 // migration files generated by the DefaultFormatter.
@@ -707,10 +760,10 @@ func (d *LocalDir) Desc(f File) (string, error) {
 	return strings.TrimSuffix(split[1], ".sql"), nil
 }
 
-var (
-	_ Dir     = (*LocalDir)(nil)
-	_ Scanner = (*LocalDir)(nil)
-)
+var _ interface {
+	Dir
+	Scanner
+} = (*LocalDir)(nil)
 
 // LocalFile is used by LocalDir to implement the Scanner interface.
 type LocalFile struct {
@@ -951,19 +1004,6 @@ func readHashFile(dir Dir) (HashFile, error) {
 	return fh, nil
 }
 
-// reverse changes for the down migration.
-func reverse(changes []*Change) []*Change {
-	n := len(changes)
-	rev := make([]*Change, n)
-	if n%2 == 1 {
-		rev[n/2] = changes[n/2]
-	}
-	for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
-		rev[i], rev[j] = changes[j], changes[i]
-	}
-	return rev
-}
-
 func wrap(err1, err2 error) error {
 	if err2 != nil {
 		return fmt.Errorf("sql/migrate: %w: %v", err2, err1)
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index 6e8a6c4d..10a55724 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -829,7 +829,7 @@ const (
 	paramsQuery = `SELECT setting FROM pg_settings WHERE name IN ('lc_collate', 'lc_ctype', 'server_version_num', 'crdb_version') ORDER BY name DESC`
 
 	// Query to list database schemas.
-	schemasQuery = "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast') AND schema_name NOT LIKE 'pg_%temp_%' ORDER BY schema_name"
+	schemasQuery = "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast', 'crdb_internal', 'pg_extension') AND schema_name NOT LIKE 'pg_%temp_%' ORDER BY schema_name"
 
 	// Query to list specific database schemas.
 	schemasQueryArgs = "SELECT schema_name FROM information_schema.schemata WHERE schema_name %s ORDER BY schema_name"
diff --git a/sql/schema/schema.go b/sql/schema/schema.go
index 287e5089..5656388a 100644
--- a/sql/schema/schema.go
+++ b/sql/schema/schema.go
@@ -282,7 +282,7 @@ type (
 		Text string
 	}
 
-	// Charset describes a column or a table character set setting.
+	// Charset describes a column or a table character-set setting.
 	Charset struct {
 		V string
 	}
diff --git a/sql/sqlcheck/sqlcheck.go b/sql/sqlcheck/sqlcheck.go
new file mode 100644
index 00000000..c6a1d573
--- /dev/null
+++ b/sql/sqlcheck/sqlcheck.go
@@ -0,0 +1,83 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package sqlcheck
+
+import (
+	"context"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
+)
+
+type (
+	// An Analyzer describes a migration file analyzer.
+	Analyzer interface {
+		// Analyze executes the analysis function.
+		Analyze(context.Context, *Pass) error
+	}
+
+	// A Pass provides information to the Run function that
+	// applies a specific analyzer to an SQL file.
+	Pass struct {
+		// A migration file and the changes it describes.
+		File *File
+
+		// Dev is a driver-specific environment used to execute analysis work.
+		Dev *sqlclient.Client
+
+		// Report reports a diagnostic
+		Report Reporter
+	}
+
+	// File represents a parsed version of a migration file.
+	File struct {
+		migrate.File
+		// Changes is the list of changes this file represents.
+		Changes []*Change
+	}
+
+	// A Change in a migration file.
+	Change struct {
+		schema.Changes        // The actual changes.
+		Stmt           string // The SQL statement generated this change.
+		Pos            int    // The position of the statement in the file.
+	}
+
+	// A Diagnostic is a message associated with a source location or range.
+	Diagnostic struct {
+		Pos     int    // Diagnostic position.
+		Message string // Diagnostic message.
+	}
+
+	// Reporter represents a diagnostic reporter.
+	Reporter interface {
+		Report(Diagnostic)
+	}
+)
+
+// Analyzers implements Analyzer.
+type Analyzers []Analyzer
+
+// Analyze implements Analyzer.
+func (a Analyzers) Analyze(ctx context.Context, p *Pass) error {
+	for _, a := range a {
+		if err := a.Analyze(ctx, p); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ReporterFunc is a function that implements Reporter.
+type ReporterFunc func(Diagnostic)
+
+// Report calls f(d).
+func (f ReporterFunc) Report(d Diagnostic) {
+	f(d)
+}
+
+// NopReporter is a Reporter that does nothing.
+var NopReporter Reporter = ReporterFunc(func(Diagnostic) {})
diff --git a/sql/sqlclient/client.go b/sql/sqlclient/client.go
index 436679c6..729a5022 100644
--- a/sql/sqlclient/client.go
+++ b/sql/sqlclient/client.go
@@ -43,6 +43,18 @@ type (
 		// and encoding the schema documents.
 		schemaspec.Marshaler
 		schemahcl.Evaluator
+
+		// A func to open a migrate.Driver with a given schema.ExecQuerier. Used when creating a TxClient.
+		openDriver func(schema.ExecQuerier) (migrate.Driver, error)
+	}
+
+	// TxClient is returned by calling Client.Tx. It behaves the same as Client,
+	// but wraps all operations within a transaction.
+	TxClient struct {
+		*Client
+
+		// The transaction this Client wraps.
+		Tx *sql.Tx
 	}
 
 	// URL extends the standard url.URL with additional
@@ -58,6 +70,34 @@ type (
 	}
 )
 
+// Tx returns a transactional client.
+func (c *Client) Tx(ctx context.Context, opts *sql.TxOptions) (*TxClient, error) {
+	if c.openDriver == nil {
+		return nil, errors.New("sql/sqlclient: unexpected driver opener: <nil>")
+	}
+	tx, err := c.DB.BeginTx(ctx, opts)
+	if err != nil {
+		return nil, fmt.Errorf("sql/sqlclient: starting transaction: %w", err)
+	}
+	drv, err := c.openDriver(tx)
+	if err != nil {
+		return nil, fmt.Errorf("sql/sqlclient: opening atlas driver: %w", err)
+	}
+	ic := *c
+	ic.Driver = drv
+	return &TxClient{Client: &ic, Tx: tx}, nil
+}
+
+// Commit the transaction.
+func (c *TxClient) Commit() error {
+	return c.Tx.Commit()
+}
+
+// Rollback the transaction.
+func (c *TxClient) Rollback() error {
+	return c.Tx.Rollback()
+}
+
 // AddClosers adds list of closers to close at the end of the client lifetime.
 func (c *Client) AddClosers(closers ...io.Closer) {
 	c.closers = append(c.closers, closers...)
@@ -154,18 +194,18 @@ func OpenURL(ctx context.Context, u *url.URL, opts ...OpenOption) (*Client, erro
 	}
 	// If there is a schema given and the driver allows to change the schema for the url, do it.
 	if cfg.schema != "" {
-		sc, ok := v.(driver).parser.(SchemaChanger)
+		sc, ok := v.(*driver).parser.(SchemaChanger)
 		if !ok {
 			return nil, ErrUnsupported
 		}
 		u = sc.ChangeSchema(u, cfg.schema)
 	}
-	client, err := v.(driver).Open(ctx, u)
+	client, err := v.(*driver).Open(ctx, u)
 	if err != nil {
 		return nil, err
 	}
 	if client.URL == nil {
-		client.URL = v.(driver).parser.ParseURL(u)
+		client.URL = v.(*driver).parser.ParseURL(u)
 	}
 	return client, nil
 }
@@ -181,9 +221,10 @@ func OpenSchema(s string) OpenOption {
 
 type (
 	registerOptions struct {
-		parser   URLParser
-		flavours []string
-		codec    interface {
+		openDriver func(schema.ExecQuerier) (migrate.Driver, error)
+		parser     URLParser
+		flavours   []string
+		codec      interface {
 			schemaspec.Marshaler
 			schemahcl.Evaluator
 		}
@@ -223,6 +264,15 @@ func RegisterCodec(m schemaspec.Marshaler, e schemahcl.Evaluator) RegisterOption
 	}
 }
 
+// RegisterDriverOpener registers a func to create a migrate.Driver from a schema.ExecQuerier.
+// Registering this function is implicitly done when using DriverOpener.
+// The passed opener is used when creating a TxClient.
+func RegisterDriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) RegisterOption {
+	return func(opts *registerOptions) {
+		opts.openDriver = open
+	}
+}
+
 // DriverOpener is a helper Opener creator for sharing between all drivers.
 func DriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) Opener {
 	return OpenerFunc(func(ctx context.Context, u *url.URL) (*Client, error) {
@@ -230,8 +280,8 @@ func DriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) Opener
 		if !ok {
 			return nil, fmt.Errorf("sql/sqlclient: unexpected missing opener %q", u.Scheme)
 		}
-		ur := v.(driver).parser.ParseURL(u)
-		db, err := sql.Open(v.(driver).name, ur.DSN)
+		ur := v.(*driver).parser.ParseURL(u)
+		db, err := sql.Open(v.(*driver).name, ur.DSN)
 		if err != nil {
 			return nil, err
 		}
@@ -243,10 +293,11 @@ func DriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) Opener
 			return nil, err
 		}
 		return &Client{
-			Name:   v.(driver).name,
-			DB:     db,
-			URL:    ur,
-			Driver: drv,
+			Name:       v.(*driver).name,
+			DB:         db,
+			URL:        ur,
+			Driver:     drv,
+			openDriver: open,
 		}, nil
 	})
 }
@@ -274,14 +325,23 @@ func Register(name string, opener Opener, opts ...RegisterOption) {
 			return c, nil
 		})
 	}
+	// If there was a driver opener registered by a call to RegisterDriverOpener, it has precedence.
+	if opt.openDriver != nil {
+		f := opener
+		opener = OpenerFunc(func(ctx context.Context, u *url.URL) (*Client, error) {
+			c, err := f.Open(ctx, u)
+			if err != nil {
+				return nil, err
+			}
+			c.openDriver = opt.openDriver
+			return c, err
+		})
+	}
+	drv := &driver{opener, name, opt.parser}
 	for _, f := range append(opt.flavours, name) {
 		if _, ok := drivers.Load(f); ok {
 			panic("sql/sqlclient: Register called twice for " + f)
 		}
-		drivers.Store(f, driver{
-			name:   name,
-			parser: opt.parser,
-			Opener: opener,
-		})
+		drivers.Store(f, drv)
 	}
 }
diff --git a/sql/sqlite/driver.go b/sql/sqlite/driver.go
index 4debfb47..2e2412e2 100644
--- a/sql/sqlite/driver.go
+++ b/sql/sqlite/driver.go
@@ -49,7 +49,12 @@ func init() {
 		sqlclient.RegisterCodec(MarshalHCL, EvalHCL),
 		sqlclient.RegisterFlavours("sqlite"),
 		sqlclient.RegisterURLParser(sqlclient.URLParserFunc(func(u *url.URL) *sqlclient.URL {
-			return &sqlclient.URL{URL: u, DSN: strings.TrimPrefix(u.String(), u.Scheme+"://"), Schema: mainFile}
+			uc := &sqlclient.URL{URL: u, DSN: strings.TrimPrefix(u.String(), u.Scheme+"://"), Schema: mainFile}
+			if mode := u.Query().Get("mode"); mode == "memory" {
+				// The "file:" prefix is mandatory for memory modes.
+				uc.DSN = "file:" + uc.DSN
+			}
+			return uc
 		})),
 	)
 }


Test patch:
diff --git a/cmd/atlasci/internal/ci/ci_test.go b/cmd/atlasci/internal/ci/ci_test.go
new file mode 100644
index 00000000..f53bb4b9
--- /dev/null
+++ b/cmd/atlasci/internal/ci/ci_test.go
@@ -0,0 +1,165 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package ci_test
+
+import (
+	"context"
+	"io"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"ariga.io/atlas/cmd/atlasci/internal/ci"
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
+	_ "ariga.io/atlas/sql/sqlite"
+
+	"github.com/go-git/go-billy/v5/osfs"
+	"github.com/go-git/go-billy/v5/util"
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/cache"
+	"github.com/go-git/go-git/v5/plumbing/object"
+	"github.com/go-git/go-git/v5/storage/filesystem"
+	_ "github.com/mattn/go-sqlite3"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGitChangeDetector(t *testing.T) {
+	cs, err := ci.NewGitChangeDetector("", nil)
+	require.Nil(t, cs)
+	require.EqualError(t, err, "internal/ci: root cannot be empty")
+
+	cs, err = ci.NewGitChangeDetector("testdata", nil)
+	require.Nil(t, cs)
+	require.EqualError(t, err, "internal/ci: dir cannot be nil")
+
+	tmp, err := util.TempDir(osfs.Default, "", "testdata")
+	require.NoError(t, err)
+	fs := osfs.New(tmp)
+	require.NoError(t, fs.MkdirAll("migrations", 0755))
+	r, err := git.Init(filesystem.NewStorage(fs, cache.NewObjectLRUDefault()), fs)
+	require.NoError(t, err)
+	w, err := r.Worktree()
+	require.NoError(t, err)
+
+	_, err = fs.Create("migrations/1_applied.sql")
+	require.NoError(t, err)
+	_, err = w.Add("migrations/1_applied.sql")
+	require.NoError(t, err)
+	commit, err := w.Commit("first migration file", &git.CommitOptions{
+		Author: &object.Signature{Name: "a8m"},
+	})
+	require.NoError(t, err)
+	_, err = r.CommitObject(commit)
+	require.NoError(t, err)
+
+	// OK
+	d, err := migrate.NewLocalDir(filepath.Join(fs.Root(), "migrations"))
+	require.NoError(t, err)
+	cs, err = ci.NewGitChangeDetector(fs.Root(), d)
+	require.NoError(t, err)
+	require.NotNil(t, cs)
+
+	// No diff.
+	base, feat, err := cs.DetectChanges(context.Background())
+	require.NoError(t, err)
+	require.Len(t, base, 1)
+	require.Empty(t, feat)
+	require.Equal(t, "1_applied.sql", base[0].Name())
+
+	// Feature branch.
+	err = w.Checkout(&git.CheckoutOptions{
+		Create: true,
+		Branch: plumbing.NewBranchReferenceName("feature"),
+		Keep:   true,
+	})
+	require.NoError(t, err)
+	_, err = fs.Create("migrations/2_new.sql")
+	require.NoError(t, err)
+	_, err = fs.Create("migrations/3_new_the_second.sql")
+	require.NoError(t, err)
+	_, err = w.Add("migrations/2_new.sql")
+	require.NoError(t, err)
+	_, err = w.Add("migrations/3_new_the_second.sql")
+	require.NoError(t, err)
+	commit, err = w.Commit("second and third migration files", &git.CommitOptions{
+		Author: &object.Signature{Name: "a8m"},
+	})
+	require.NoError(t, err)
+	_, err = r.CommitObject(commit)
+	require.NoError(t, err)
+
+	base, feat, err = cs.DetectChanges(context.Background())
+	require.NoError(t, err)
+	require.Len(t, base, 1)
+	require.Len(t, feat, 2)
+	require.Equal(t, "1_applied.sql", base[0].Name())
+	require.Equal(t, "2_new.sql", feat[0].Name())
+	require.Equal(t, "3_new_the_second.sql", feat[1].Name())
+}
+
+func TestDevLoader_LoadChanges(t *testing.T) {
+	ctx := context.Background()
+	c, err := sqlclient.Open(ctx, "sqlite://ci?mode=memory&cache=shared&_fk=1")
+	require.NoError(t, err)
+	defer c.Close()
+	err = c.ApplyChanges(ctx, []schema.Change{
+		&schema.AddTable{
+			T: schema.NewTable("users").AddColumns(schema.NewIntColumn("id", "int")),
+		},
+	})
+	require.NoError(t, err)
+	l := &ci.DevLoader{Dev: c, Scan: testDir{}}
+	diff, err := l.LoadChanges(ctx, nil)
+	require.NoError(t, err)
+	require.Empty(t, diff)
+
+	files := []migrate.File{
+		testFile{name: "1.sql", content: "CREATE TABLE t1 (id INT)"},
+		testFile{name: "2.sql", content: "CREATE TABLE t2 (id INT)\nDROP TABLE users"},
+	}
+	diff, err = l.LoadChanges(ctx, files)
+	require.NoError(t, err)
+	require.Equal(t, files[0], diff[0].File)
+	require.Len(t, diff[0].Changes, 1)
+	require.Zero(t, diff[0].Changes[0].Pos)
+	require.Equal(t, "CREATE TABLE t1 (id INT)", diff[0].Changes[0].Stmt)
+	require.IsType(t, (*schema.AddTable)(nil), diff[0].Changes[0].Changes[0])
+
+	require.Equal(t, files[1], diff[1].File)
+	require.Len(t, diff[1].Changes, 2)
+	require.Zero(t, diff[1].Changes[0].Pos)
+	require.Equal(t, "CREATE TABLE t2 (id INT)", diff[1].Changes[0].Stmt)
+	require.IsType(t, (*schema.AddTable)(nil), diff[1].Changes[0].Changes[0])
+	require.Zero(t, diff[1].Changes[0].Pos)
+	require.Equal(t, "DROP TABLE users", diff[1].Changes[1].Stmt)
+	require.IsType(t, (*schema.DropTable)(nil), diff[1].Changes[1].Changes[0])
+}
+
+type testDir struct {
+	migrate.Scanner
+}
+
+func (testDir) Stmts(f migrate.File) ([]string, error) {
+	buf, err := io.ReadAll(f)
+	if err != nil {
+		return nil, err
+	}
+	return strings.Split(string(buf), "\n"), nil
+}
+
+type testFile struct {
+	name, content string
+}
+
+func (f testFile) Name() string {
+	return f.name
+}
+
+func (f testFile) Read(p []byte) (n int, err error) {
+	return copy(p, f.content), io.EOF
+}
diff --git a/cmd/atlasci/internal/ci/run_test.go b/cmd/atlasci/internal/ci/run_test.go
new file mode 100644
index 00000000..158dad3f
--- /dev/null
+++ b/cmd/atlasci/internal/ci/run_test.go
@@ -0,0 +1,62 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package ci_test
+
+import (
+	"context"
+	"testing"
+
+	"ariga.io/atlas/cmd/atlasci/internal/ci"
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlclient"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestRunner_Run(t *testing.T) {
+	ctx := context.Background()
+	c, err := sqlclient.Open(ctx, "sqlite://run?mode=memory&cache=shared&_fk=1")
+	require.NoError(t, err)
+	r := &ci.Runner{
+		Scan: testDir{},
+		Dev:  c,
+		ChangeDetector: testDetector{
+			base: []migrate.File{
+				testFile{name: "1.sql", content: "CREATE TABLE users (id INT)"},
+			},
+			feat: []migrate.File{
+				testFile{name: "2.sql", content: "CREATE TABLE pets (id INT)\nDROP TABLE users"},
+			},
+		},
+		Analyzer: &testAnalyzer{},
+		Reporter: sqlcheck.NopReporter,
+	}
+	require.NoError(t, r.Run(ctx))
+
+	passes := r.Analyzer.(*testAnalyzer).passes
+	require.Len(t, passes, 1)
+	changes := passes[0].File.Changes
+	require.Len(t, changes, 2)
+	require.Equal(t, "CREATE TABLE pets (id INT)", changes[0].Stmt)
+	require.Equal(t, "DROP TABLE users", changes[1].Stmt)
+}
+
+type testAnalyzer struct {
+	passes []*sqlcheck.Pass
+}
+
+func (t *testAnalyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
+	t.passes = append(t.passes, p)
+	return nil
+}
+
+type testDetector struct {
+	base, feat []migrate.File
+}
+
+func (t testDetector) DetectChanges(context.Context) ([]migrate.File, []migrate.File, error) {
+	return t.base, t.feat, nil
+}
diff --git a/cmd/atlascmd/migrate/migrate_test.go b/cmd/atlascmd/migrate/migrate_test.go
index 142f41de..1064367d 100644
--- a/cmd/atlascmd/migrate/migrate_test.go
+++ b/cmd/atlascmd/migrate/migrate_test.go
@@ -9,8 +9,10 @@ import (
 	"fmt"
 	"path/filepath"
 	"testing"
+	"time"
 
 	"ariga.io/atlas/cmd/atlascmd/migrate/ent/revision"
+	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/sqlclient"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/stretchr/testify/require"
@@ -23,9 +25,6 @@ func TestEntRevisions_Init(t *testing.T) {
 	)
 	require.NoError(t, err)
 	r, err := NewEntRevisions(c)
-	t.Cleanup(func() {
-		r.Close()
-	})
 	require.NoError(t, err)
 
 	require.NoError(t, r.Init(context.Background()))
@@ -36,3 +35,33 @@ func TestEntRevisions_Init(t *testing.T) {
 	_, ok := s.Table(revision.Table)
 	require.True(t, ok)
 }
+
+func TestEntRevisions_Flush(t *testing.T) {
+	ctx := context.Background()
+	c, err := sqlclient.Open(
+		context.Background(),
+		fmt.Sprintf("sqlite://%s?cache=shared&mode=memory&_fk=true", filepath.Join(t.TempDir(), "revision")),
+	)
+	require.NoError(t, err)
+	r, err := NewEntRevisions(c)
+	require.NoError(t, err)
+	require.True(t, r.useCache())
+	require.NoError(t, r.Init(ctx))
+
+	// Writing will only fill the cache.
+	require.NoError(t, r.WriteRevision(ctx, &migrate.Revision{
+		Version:        "version",
+		ExecutedAt:     time.Now(),
+		ExecutionState: "ongoing",
+	}))
+	require.Len(t, r.cache, 1)
+	revs, err := r.ReadRevisions(ctx)
+	require.NoError(t, err)
+	require.Len(t, revs, 0)
+
+	// Flushing will save the cached revision.
+	require.NoError(t, r.Flush(ctx))
+	revs, err = r.ReadRevisions(ctx)
+	require.NoError(t, err)
+	require.Len(t, revs, 1)
+}
diff --git a/cmd/atlascmd/migrate_test.go b/cmd/atlascmd/migrate_test.go
index 93fcdf79..45c4092c 100644
--- a/cmd/atlascmd/migrate_test.go
+++ b/cmd/atlascmd/migrate_test.go
@@ -20,6 +20,7 @@ import (
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
+	"ariga.io/atlas/sql/sqlite"
 	_ "ariga.io/atlas/sql/sqlite"
 
 	_ "github.com/mattn/go-sqlite3"
@@ -31,6 +32,81 @@ func TestMigrate(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestMigrate_Apply(t *testing.T) {
+	p := t.TempDir()
+
+	// Fails on empty directory.
+	_, err := runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://"+p,
+		"--to", openSQLite(t, ""),
+	)
+	require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+
+	// Fails on directory without sum file.
+	require.NoError(t, os.Rename(
+		filepath.FromSlash("testdata/sqlite/atlas.sum"),
+		filepath.FromSlash("testdata/sqlite/atlas.sum.bak"),
+	))
+	t.Cleanup(func() {
+		os.Rename(filepath.FromSlash("testdata/sqlite/atlas.sum.bak"), filepath.FromSlash("testdata/sqlite/atlas.sum"))
+	})
+
+	_, err = runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://testdata/sqlite",
+		"--to", openSQLite(t, ""),
+	)
+	require.ErrorIs(t, err, migrate.ErrChecksumNotFound)
+	require.NoError(t, os.Rename(
+		filepath.FromSlash("testdata/sqlite/atlas.sum.bak"),
+		filepath.FromSlash("testdata/sqlite/atlas.sum"),
+	))
+
+	// A lock will prevent execution.
+	sqlclient.Register(
+		"sqlitelockapply",
+		sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) {
+			client, err := sqlclient.Open(ctx, strings.Replace(u.String(), u.Scheme, "sqlite", 1))
+			if err != nil {
+				return nil, err
+			}
+			client.Driver = &sqliteLockerDriver{client.Driver}
+			return client, nil
+		}),
+		sqlclient.RegisterDriverOpener(func(db schema.ExecQuerier) (migrate.Driver, error) {
+			drv, err := sqlite.Open(db)
+			if err != nil {
+				return nil, err
+			}
+			return &sqliteLockerDriver{drv}, nil
+		}),
+	)
+	f, err := os.Create(filepath.Join(p, "test.db"))
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	s, err := runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://testdata/sqlite",
+		"--to", fmt.Sprintf("sqlitelockapply://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
+	)
+	require.ErrorIs(t, err, errLock)
+	require.True(t, strings.HasPrefix(s, "Error: sql/migrate: acquiring database lock: "+errLock.Error()))
+
+	// Will work and print stuff to the console.
+	s, err = runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://testdata/sqlite",
+		"--to", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
+	)
+	require.NoError(t, err)
+	require.Contains(t, s, "20220318104614")                         // log to version
+	require.Contains(t, s, "CREATE TABLE tbl (`col` int NOT NULL);") // logs statement
+	require.Contains(t, s, "1 migrations")                           // logs amount of migrations
+	require.Contains(t, s, "1 sql statements")                       // logs amount of statement
+}
+
 func TestMigrate_Diff(t *testing.T) {
 	p := t.TempDir()
 
@@ -59,7 +135,7 @@ func TestMigrate_Diff(t *testing.T) {
 	require.FileExists(t, filepath.Join(p, "atlas.sum"))
 
 	// A lock will prevent diffing.
-	sqlclient.Register("sqlitelock", sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) {
+	sqlclient.Register("sqlitelockdiff", sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) {
 		client, err := sqlclient.Open(ctx, strings.Replace(u.String(), u.Scheme, "sqlite", 1))
 		if err != nil {
 			return nil, err
@@ -74,11 +150,11 @@ func TestMigrate_Diff(t *testing.T) {
 		Root, "migrate", "diff",
 		"name",
 		"--dir", "file://"+t.TempDir(),
-		"--dev-url", fmt.Sprintf("sqlitelock://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
+		"--dev-url", fmt.Sprintf("sqlitelockdiff://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
 		"--to", hclURL(t),
 	)
-	require.True(t, strings.HasPrefix(s, "Error: "+lockErr))
-	require.EqualError(t, err, lockErr)
+	require.True(t, strings.HasPrefix(s, "Error: "+errLock.Error()))
+	require.ErrorIs(t, err, errLock)
 }
 
 func TestMigrate_New(t *testing.T) {
@@ -127,10 +203,10 @@ func TestMigrate_New(t *testing.T) {
 	require.FileExists(t, filepath.Join(p, v+"_liquibase.sql"))
 	require.Equal(t, 9, countFiles(t, p))
 
-	f := filepath.Join("testdata", "new.sql")
+	f := filepath.Join("testdata", "mysql", "new.sql")
 	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
 	t.Cleanup(func() { os.Remove(f) })
-	s, err = runCmd(Root, "migrate", "new", "--dir", "file://testdata")
+	s, err = runCmd(Root, "migrate", "new", "--dir", "file://testdata/mysql")
 	require.NotZero(t, s)
 	require.Error(t, err)
 }
@@ -138,14 +214,14 @@ func TestMigrate_New(t *testing.T) {
 func TestMigrate_Validate(t *testing.T) {
 	// Without re-playing.
 	MigrateFlags.DevURL = "" // global flags are set from other tests ...
-	s, err := runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
+	s, err := runCmd(Root, "migrate", "validate", "--dir", "file://testdata/mysql")
 	require.Zero(t, s)
 	require.NoError(t, err)
 
-	f := filepath.Join("testdata", "new.sql")
+	f := filepath.Join("testdata", "mysql", "new.sql")
 	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
 	t.Cleanup(func() { os.Remove(f) })
-	s, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
+	s, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata/mysql")
 	require.NotZero(t, s)
 	require.Error(t, err)
 	require.NoError(t, os.Remove(f))
@@ -165,17 +241,17 @@ func TestMigrate_Validate(t *testing.T) {
 	require.NoError(t, err)
 
 	// Should fail since the files are not compatible with SQLite.
-	_, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata", "--dev-url", openSQLite(t, ""))
+	_, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata/mysql", "--dev-url", openSQLite(t, ""))
 	require.Error(t, err)
 }
 
 func TestMigrate_Hash(t *testing.T) {
-	s, err := runCmd(Root, "migrate", "hash", "--dir", "file://testdata")
+	s, err := runCmd(Root, "migrate", "hash", "--dir", "file://testdata/mysql")
 	require.Zero(t, s)
 	require.NoError(t, err)
 
 	p := t.TempDir()
-	err = copyFile(filepath.Join("testdata", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql"))
+	err = copyFile(filepath.Join("testdata", "mysql", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql"))
 	require.NoError(t, err)
 
 	s, err = runCmd(Root, "migrate", "hash", "--dir", "file://"+p, "--force")
@@ -194,7 +270,7 @@ func TestMigrate_Hash(t *testing.T) {
 
 	p = t.TempDir()
 	require.NoError(t, copyFile(
-		filepath.Join("testdata", "20220318104614_initial.sql"),
+		filepath.Join("testdata", "mysql", "20220318104614_initial.sql"),
 		filepath.Join(p, "20220318104614_initial.sql"),
 	))
 	s, err = runCmd(Root, "migrate", "hash", "--dir", "file://"+os.Getenv("MIGRATION_DIR"))
@@ -305,10 +381,10 @@ func copyFile(src, dst string) error {
 
 type sqliteLockerDriver struct{ migrate.Driver }
 
-const lockErr = "lockErr"
+var errLock = errors.New("lockErr")
 
 func (d *sqliteLockerDriver) Lock(context.Context, string, time.Duration) (schema.UnlockFunc, error) {
-	return func() error { return nil }, errors.New(lockErr)
+	return func() error { return nil }, errLock
 }
 
 func countFiles(t *testing.T, p string) int {
diff --git a/cmd/atlascmd/testdata/atlas.sum b/cmd/atlascmd/testdata/atlas.sum
deleted file mode 100644
index 822b27e5..00000000
--- a/cmd/atlascmd/testdata/atlas.sum
+++ /dev/null
@@ -1,2 +0,0 @@
-h1:CuXS3DZEf7JpEt4+3+YB0nDZtOK6mSZDnajdwfY1COM=
-20220318104614_initial.sql h1:EoDHPlX7fTGn5qiCdR5xhwFh+DrOi3cQ7Y49BsIy97k=
diff --git a/cmd/atlascmd/testdata/20220318104614_initial.sql b/cmd/atlascmd/testdata/mysql/20220318104614_initial.sql
similarity index 100%
rename from cmd/atlascmd/testdata/20220318104614_initial.sql
rename to cmd/atlascmd/testdata/mysql/20220318104614_initial.sql
diff --git a/cmd/atlascmd/testdata/mysql/20220420213403_second.sql b/cmd/atlascmd/testdata/mysql/20220420213403_second.sql
new file mode 100644
index 00000000..b829d7b4
--- /dev/null
+++ b/cmd/atlascmd/testdata/mysql/20220420213403_second.sql
@@ -0,0 +1,1 @@
+ALTER TABLE `atlantis`.`tbl` ADD `col_2` TEXT;
diff --git a/cmd/atlascmd/testdata/mysql/atlas.sum b/cmd/atlascmd/testdata/mysql/atlas.sum
new file mode 100644
index 00000000..0d6a1fba
--- /dev/null
+++ b/cmd/atlascmd/testdata/mysql/atlas.sum
@@ -0,0 +1,3 @@
+h1:EGX5/CEEerpLWqYQNHB1veTXon8t05wEGJiX2fOtFXg=
+20220318104614_initial.sql h1:EoDHPlX7fTGn5qiCdR5xhwFh+DrOi3cQ7Y49BsIy97k=
+20220420213403_second.sql h1:cAioQjDgJkOIiMAyEwqaurPs0EHpTeu0CnWlJzNj5SE=
diff --git a/cmd/atlascmd/testdata/sqlite/20220318104614_initial.sql b/cmd/atlascmd/testdata/sqlite/20220318104614_initial.sql
new file mode 100644
index 00000000..eaf12fab
--- /dev/null
+++ b/cmd/atlascmd/testdata/sqlite/20220318104614_initial.sql
@@ -0,0 +1,2 @@
+-- create "tbl" table
+CREATE TABLE tbl (`col` int NOT NULL);
diff --git a/cmd/atlascmd/testdata/sqlite/atlas.sum b/cmd/atlascmd/testdata/sqlite/atlas.sum
new file mode 100644
index 00000000..50c6c028
--- /dev/null
+++ b/cmd/atlascmd/testdata/sqlite/atlas.sum
@@ -0,0 +1,2 @@
+h1:lk7K9/v0zF26ssxQzMbXGJn1lIIO1mO77MHp9oQ763s=
+20220318104614_initial.sql h1:FifWjY2X0g2YVnb18Qm+QBPvoldDOOob7bS0LrFuCXc=
diff --git a/internal/integration/cockroach_test.go b/internal/integration/cockroach_test.go
index 428d2358..3bc38042 100644
--- a/internal/integration/cockroach_test.go
+++ b/internal/integration/cockroach_test.go
@@ -78,6 +78,12 @@ func crdbRun(t *testing.T, fn func(*crdbTest)) {
 	}
 }
 
+func TestCockroach_EntRevisions(t *testing.T) {
+	crdbRun(t, func(t *crdbTest) {
+		testEntRevisions(t)
+	})
+}
+
 func TestCockroach_Executor(t *testing.T) {
 	crdbRun(t, func(t *crdbTest) {
 		testExecutor(t)
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index fa93a2ad..a57348a0 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -481,9 +481,6 @@ func testEntRevisions(t T) {
 
 	r, err := entmigrate.NewEntRevisions(c, entmigrate.WithSchema(name))
 	require.NoError(t, err)
-	t.Cleanup(func() {
-		r.Close()
-	})
 
 	require.NoError(t, r.Init(context.Background()))
 	t.dropSchemas(name)
@@ -532,14 +529,14 @@ func testExecutor(t T) {
 
 	ex, err := migrate.NewExecutor(t.driver(), dir, t.revisionsStorage())
 	require.NoError(t, err)
-	require.NoError(t, ex.Execute(context.Background(), 2)) // usersT and postsT
+	require.NoError(t, ex.ExecuteN(context.Background(), 2)) // usersT and postsT
 	require.Len(t, *t.revisionsStorage().(*rrw), 2)
 	ensureNoChange(t, postsT, usersT)
-	require.NoError(t, ex.Execute(context.Background(), 1)) // petsT
+	require.NoError(t, ex.ExecuteN(context.Background(), 1)) // petsT
 	require.Len(t, *t.revisionsStorage().(*rrw), 3)
 	ensureNoChange(t, petsT, postsT, usersT)
 
-	require.ErrorIs(t, ex.Execute(context.Background(), 1), migrate.ErrNoPendingFiles)
+	require.ErrorIs(t, ex.ExecuteN(context.Background(), 1), migrate.ErrNoPendingFiles)
 }
 
 func plan(t T, name string, changes ...schema.Change) *migrate.Plan {
@@ -550,12 +547,18 @@ func plan(t T, name string, changes ...schema.Change) *migrate.Plan {
 
 type rrw migrate.Revisions
 
-func (r *rrw) WriteRevisions(ctx context.Context, revs migrate.Revisions) error {
-	*r = rrw(revs)
+func (r *rrw) WriteRevision(_ context.Context, rev *migrate.Revision) error {
+	for i, rev2 := range *r {
+		if rev2.Version == rev.Version {
+			(*r)[i] = rev
+			return nil
+		}
+	}
+	*r = append(*r, rev)
 	return nil
 }
 
-func (r *rrw) ReadRevisions(ctx context.Context) (migrate.Revisions, error) {
+func (r *rrw) ReadRevisions(context.Context) (migrate.Revisions, error) {
 	return migrate.Revisions(*r), nil
 }
 
diff --git a/internal/integration/script_test.go b/internal/integration/script_test.go
index 51cf2f1b..22c4e92a 100644
--- a/internal/integration/script_test.go
+++ b/internal/integration/script_test.go
@@ -41,6 +41,7 @@ func TestMySQL_Script(t *testing.T) {
 				"cmphcl":  t.cmdCmpHCL,
 				"cmpshow": t.cmdCmpShow,
 				"execsql": t.cmdExec,
+				"atlas":   t.cmdCLI,
 			},
 		})
 	})
@@ -88,11 +89,28 @@ func (t *myTest) setupScript(env *testscript.Env) error {
 	env.Setenv("version", t.version)
 	env.Setenv("charset", attrs[0].(*schema.Charset).V)
 	env.Setenv("collate", attrs[1].(*schema.Collation).V)
+	if err := replaceDBURL(env, t.dsn("")); err != nil {
+		return err
+	}
 	return setupScript(t.T, env, t.db, "DROP SCHEMA IF EXISTS %s")
 }
 
+func replaceDBURL(env *testscript.Env, url string) error {
+	// Set the workdir in the test atlas.hcl file.
+	projectFile := filepath.Join(env.WorkDir, "atlas.hcl")
+	if b, err := os.ReadFile(projectFile); err == nil {
+		rep := strings.ReplaceAll(string(b), "URL", url)
+		return os.WriteFile(projectFile, []byte(rep), 0600)
+	}
+	return nil
+}
+
 func (t *pgTest) setupScript(env *testscript.Env) error {
 	env.Setenv("version", t.version)
+	u := strings.ReplaceAll(t.dsn(), "/test", "/")
+	if err := replaceDBURL(env, u); err != nil {
+		return err
+	}
 	return setupScript(t.T, env, t.db, "DROP SCHEMA IF EXISTS %s CASCADE")
 }
 
@@ -118,6 +136,9 @@ func setupScript(t *testing.T, env *testscript.Env, db *sql.DB, dropCmd string)
 	// Store the testscript.T for later use.
 	// See "only" function below.
 	env.Values[keyT] = env.T()
+	if err := setupCLITest(t, env); err != nil {
+		return err
+	}
 	return nil
 }
 
@@ -142,12 +163,9 @@ func (t *liteTest) setupScript(env *testscript.Env) error {
 	// environment as tests run in parallel.
 	env.Values[keyDB] = db
 	env.Values[keyDrv] = drv
-	cliPath, err := buildCmd(t.T)
-	if err != nil {
+	if err := setupCLITest(t.T, env); err != nil {
 		return err
 	}
-	env.Setenv(cliPathKey, cliPath)
-
 	// Set the workdir in the test atlas.hcl file.
 	projectFile := filepath.Join(env.WorkDir, "atlas.hcl")
 	if b, err := os.ReadFile(projectFile); err == nil {
@@ -158,6 +176,15 @@ func (t *liteTest) setupScript(env *testscript.Env) error {
 	return nil
 }
 
+func setupCLITest(t *testing.T, env *testscript.Env) error {
+	cliPath, err := buildCmd(t)
+	if err != nil {
+		return err
+	}
+	env.Setenv(cliPathKey, cliPath)
+	return nil
+}
+
 // cmdOnly executes only tests that their driver version matches the given pattern.
 // For example, "only 8" or "only 8 maria*"
 func cmdOnly(ts *testscript.TestScript, neg bool, args []string) {
@@ -193,6 +220,10 @@ func (t *myTest) cmdCmpShow(ts *testscript.TestScript, _ bool, args []string) {
 	})
 }
 
+func (t *myTest) cmdCLI(ts *testscript.TestScript, neg bool, args []string) {
+	cmdCLI(ts, neg, args, t.dsn("test"))
+}
+
 func (t *pgTest) cmdCmpShow(ts *testscript.TestScript, _ bool, args []string) {
 	cmdCmpShow(ts, args, func(schema, name string) (string, error) {
 		buf, err := exec.Command("docker", "ps", "-qa", "-f", fmt.Sprintf("publish=%d", t.port)).CombinedOutput()
@@ -366,10 +397,14 @@ func (t *liteTest) cmdExec(ts *testscript.TestScript, _ bool, args []string) {
 }
 
 func (t *liteTest) cmdCLI(ts *testscript.TestScript, neg bool, args []string) {
+	dbURL := fmt.Sprintf("sqlite://file:%s/atlas.sqlite?cache=shared&_fk=1", ts.Getenv("WORK"))
+	cmdCLI(ts, neg, args, dbURL)
+}
+
+func cmdCLI(ts *testscript.TestScript, neg bool, args []string, dbURL string) {
 	var (
-		workDir = ts.Getenv("WORK")
-		dbURL   = fmt.Sprintf("sqlite://file:%s/atlas.sqlite?cache=shared&_fk=1", workDir)
 		cliPath = ts.Getenv(cliPathKey)
+		workDir = ts.Getenv("WORK")
 	)
 	for i, arg := range args {
 		args[i] = strings.ReplaceAll(arg, "URL", dbURL)
diff --git a/internal/integration/sqlite_test.go b/internal/integration/sqlite_test.go
index 6cd1e0c8..3e04ea45 100644
--- a/internal/integration/sqlite_test.go
+++ b/internal/integration/sqlite_test.go
@@ -57,9 +57,6 @@ func TestSQLite_EntRevisions(t *testing.T) {
 
 		r, err := entmigrate.NewEntRevisions(c)
 		require.NoError(t, err)
-		t.Cleanup(func() {
-			r.Close()
-		})
 
 		require.NoError(t, r.Init(context.Background()))
 
diff --git a/internal/integration/testdata/mysql/cli-project-schemas.txt b/internal/integration/testdata/mysql/cli-project-schemas.txt
new file mode 100644
index 00000000..2c90f061
--- /dev/null
+++ b/internal/integration/testdata/mysql/cli-project-schemas.txt
@@ -0,0 +1,29 @@
+atlas schema apply --env local --auto-approve > out.txt
+exec cat out.txt
+stdout 'CREATE TABLE `script_cli_project_schemas`'
+
+-- 1.hcl --
+schema "script_cli_project_schemas" {
+}
+table "users" {
+   schema = schema.script_cli_project_schemas
+   column "id" {
+     type = bigint
+     null = false
+   }
+}
+-- atlas.hcl --
+env "local" {
+    url = "URL"
+    src = "./1.hcl"
+    schemas = ["script_cli_project_schemas"]
+}
+-- expected.sql --
+CREATE TABLE `users` (
+  `id` bigint NOT NULL
+)
+-- 0.hcl --
+schema "$db" {
+    charset = "$charset"
+    collate = "$collate"
+}
\ No newline at end of file
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index e759970f..887cef99 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -190,11 +190,11 @@ func TestExecutor_ReadState(t *testing.T) {
 	require.NoError(t, err)
 
 	// Locking not supported.
-	_, err = migrate.NewExecutor(&mockDriver{}, d, migrate.NoopRevisionReadWriter{})
+	_, err = migrate.NewExecutor(&mockDriver{}, d, migrate.NopRevisionReadWriter{})
 	require.ErrorIs(t, err, migrate.ErrLockUnsupported)
 
 	drv := &lockMockDriver{&mockDriver{}}
-	ex, err := migrate.NewExecutor(drv, d, migrate.NoopRevisionReadWriter{})
+	ex, err := migrate.NewExecutor(drv, d, migrate.NopRevisionReadWriter{})
 	require.NoError(t, err)
 
 	_, err = ex.ReadState(ctx)
@@ -202,6 +202,7 @@ func TestExecutor_ReadState(t *testing.T) {
 	require.Equal(t, []string{"DROP TABLE IF EXISTS t;", "CREATE TABLE t(c int);"}, drv.executed)
 	require.Equal(t, 2, drv.lockCounter)
 	require.Equal(t, 2, drv.unlockCounter)
+	require.True(t, drv.released())
 
 	// Does not work if locked.
 	drv.locks = map[string]struct{}{"atlas_migration_directory_state": {}}
@@ -209,6 +210,7 @@ func TestExecutor_ReadState(t *testing.T) {
 	require.EqualError(t, err, "sql/migrate: acquiring database lock: lockErr")
 	require.Equal(t, 2, drv.lockCounter)
 	require.Equal(t, 2, drv.unlockCounter)
+	require.False(t, drv.released())
 	drv.locks = make(map[string]struct{})
 
 	// Does not work if database is not clean.
@@ -217,10 +219,11 @@ func TestExecutor_ReadState(t *testing.T) {
 	require.EqualError(t, err, "sql/migrate: connected database is not clean")
 	require.Equal(t, 3, drv.lockCounter)
 	require.Equal(t, 3, drv.unlockCounter)
+	require.True(t, drv.released())
 
 	// Works.
 	edrv := &emptyMockDriver{drv}
-	ex, err = migrate.NewExecutor(edrv, d, migrate.NoopRevisionReadWriter{})
+	ex, err = migrate.NewExecutor(edrv, d, migrate.NopRevisionReadWriter{})
 	require.NoError(t, err)
 	_, err = ex.ReadState(ctx)
 	require.NoError(t, err)
@@ -232,6 +235,7 @@ func TestExecutor_ReadState(t *testing.T) {
 	}, edrv.applied)
 	require.Equal(t, 5, drv.lockCounter)
 	require.Equal(t, 5, drv.unlockCounter)
+	require.True(t, drv.released())
 }
 
 func TestLocalDir(t *testing.T) {
@@ -304,7 +308,7 @@ func TestExecutor(t *testing.T) {
 	dir, err := migrate.NewLocalDir(t.TempDir())
 	require.NoError(t, err)
 	ex, err = migrate.NewExecutor(&mockDriver{}, dir, nil)
-	require.EqualError(t, err, "sql/migrate: execute: mockRevisionReadWriter cannot be nil")
+	require.EqualError(t, err, "sql/migrate: execute: rrw cannot be nil")
 	require.Nil(t, ex)
 
 	// Does not work if no locking mechanism is provided.
@@ -319,7 +323,7 @@ func TestExecutor(t *testing.T) {
 	ex, err = migrate.NewExecutor(&lockMockDriver{&mockDriver{}}, dir, &mockRevisionReadWriter{})
 	require.NoError(t, err)
 	require.NotNil(t, ex)
-	require.ErrorIs(t, ex.Execute(context.Background(), 0), migrate.ErrChecksumMismatch)
+	require.ErrorIs(t, ex.ExecuteN(context.Background(), 0), migrate.ErrChecksumMismatch)
 
 	// Prerequisites.
 	var (
@@ -345,58 +349,80 @@ func TestExecutor(t *testing.T) {
 	require.NoError(t, err)
 
 	// Applies all of them.
-	require.NoError(t, ex.Execute(context.Background(), 0))
+	require.NoError(t, ex.ExecuteN(context.Background(), 0))
 	require.Equal(t, drv.executed, []string{
 		"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;", "ALTER TABLE t_sub ADD c2 int;",
 	})
 	requireEqualRevisions(t, migrate.Revisions{rev1, rev2}, migrate.Revisions(*rrw))
 	require.Equal(t, []migrate.LogEntry{
-		migrate.LogExecution{Files: []string{"1.a_sub.up.sql", "2.10.x-20_description.sql"}},
+		migrate.LogExecution{To: "2.10.x-20", Files: []string{"1.a_sub.up.sql", "2.10.x-20_description.sql"}},
 		migrate.LogFile{Version: "1.a", Desc: "sub.up"},
 		migrate.LogStmt{SQL: "CREATE TABLE t_sub(c int);"},
 		migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c1 int;"},
 		migrate.LogFile{Version: "2.10.x-20", Desc: "description"},
 		migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c2 int;"},
+		migrate.LogDone{},
 	}, []migrate.LogEntry(*log))
+	require.Equal(t, drv.lockCounter, 1)
+	require.Equal(t, drv.unlockCounter, 1)
+	require.True(t, drv.released())
 
 	// No pending files.
-	require.ErrorIs(t, ex.Execute(context.Background(), 0), migrate.ErrNoPendingFiles)
+	require.ErrorIs(t, ex.ExecuteN(context.Background(), 0), migrate.ErrNoPendingFiles)
 
 	// Apply one by one.
 	*rrw = mockRevisionReadWriter{}
 	*drv = lockMockDriver{&mockDriver{}}
 
-	require.NoError(t, ex.Execute(context.Background(), 1))
+	require.NoError(t, ex.ExecuteN(context.Background(), 1))
 	require.Equal(t, drv.executed, []string{"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;"})
 	requireEqualRevisions(t, migrate.Revisions{rev1}, migrate.Revisions(*rrw))
 
-	require.NoError(t, ex.Execute(context.Background(), 1))
+	require.NoError(t, ex.ExecuteN(context.Background(), 1))
 	require.Equal(t, drv.executed, []string{
 		"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;", "ALTER TABLE t_sub ADD c2 int;",
 	})
 	requireEqualRevisions(t, migrate.Revisions{rev1, rev2}, migrate.Revisions(*rrw))
-	require.ErrorIs(t, ex.Execute(context.Background(), 0), migrate.ErrNoPendingFiles)
+	require.ErrorIs(t, ex.ExecuteN(context.Background(), 0), migrate.ErrNoPendingFiles)
+
+	require.Equal(t, drv.lockCounter, 3)
+	require.Equal(t, drv.unlockCounter, 3)
+	require.True(t, drv.released())
 
 	// Suppose first revision is already executed, only execute second migration file.
 	*rrw = mockRevisionReadWriter(migrate.Revisions{rev1})
 	*drv = lockMockDriver{&mockDriver{}}
 
-	require.NoError(t, ex.Execute(context.Background(), 0))
+	require.NoError(t, ex.ExecuteN(context.Background(), 0))
 	require.Equal(t, drv.executed, []string{"ALTER TABLE t_sub ADD c2 int;"})
 	requireEqualRevisions(t, migrate.Revisions{rev1, rev2}, migrate.Revisions(*rrw))
-	require.ErrorIs(t, ex.Execute(context.Background(), 0), migrate.ErrNoPendingFiles)
+	require.ErrorIs(t, ex.ExecuteN(context.Background(), 0), migrate.ErrNoPendingFiles)
+
+	require.Equal(t, drv.lockCounter, 2)
+	require.Equal(t, drv.unlockCounter, 2)
+	require.True(t, drv.released())
 
 	// Unknown revision present.
 	*rrw = mockRevisionReadWriter(migrate.Revisions{&migrate.Revision{Version: "unknown"}})
-	require.EqualError(t, ex.Execute(context.Background(), 0),
+	*drv = lockMockDriver{&mockDriver{}}
+	require.EqualError(t, ex.ExecuteN(context.Background(), 0),
 		`sql/migrate: execute: revisions and migrations mismatch: rev "1.a" <> file "unknown"`,
 	)
 
+	require.Equal(t, drv.lockCounter, 1)
+	require.Equal(t, drv.unlockCounter, 1)
+	require.True(t, drv.released())
+
 	// More revisions than migration files.
 	*rrw = mockRevisionReadWriter(migrate.Revisions{&migrate.Revision{Version: "unknown"}, rev1, rev2})
-	require.EqualError(t, ex.Execute(context.Background(), 0),
+	*drv = lockMockDriver{&mockDriver{}}
+	require.EqualError(t, ex.ExecuteN(context.Background(), 0),
 		`sql/migrate: execute: revisions and migrations mismatch: more revisions than migrations`,
 	)
+
+	require.Equal(t, drv.lockCounter, 1)
+	require.Equal(t, drv.unlockCounter, 1)
+	require.True(t, drv.released())
 }
 
 type (
@@ -448,23 +474,32 @@ func (m *lockMockDriver) Lock(_ context.Context, name string, _ time.Duration) (
 		return nil
 	}, nil
 }
+func (m *lockMockDriver) released() bool {
+	return len(m.locks) == 0
+}
 func (m *emptyMockDriver) IsClean(context.Context) (bool, error) {
 	return true, nil
 }
 
 type mockRevisionReadWriter migrate.Revisions
 
-func (r *mockRevisionReadWriter) WriteRevisions(_ context.Context, revs migrate.Revisions) error {
-	*r = mockRevisionReadWriter(revs)
+func (rrw *mockRevisionReadWriter) WriteRevision(_ context.Context, r *migrate.Revision) error {
+	for i, rev := range *rrw {
+		if rev.Version == r.Version {
+			(*rrw)[i] = r
+			return nil
+		}
+	}
+	*rrw = append(*rrw, r)
 	return nil
 }
 
-func (r *mockRevisionReadWriter) ReadRevisions(_ context.Context) (migrate.Revisions, error) {
-	return migrate.Revisions(*r), nil
+func (rrw *mockRevisionReadWriter) ReadRevisions(_ context.Context) (migrate.Revisions, error) {
+	return migrate.Revisions(*rrw), nil
 }
 
-func (r *mockRevisionReadWriter) clean() {
-	*r = mockRevisionReadWriter(migrate.Revisions{})
+func (rrw *mockRevisionReadWriter) clean() {
+	*rrw = mockRevisionReadWriter(migrate.Revisions{})
 }
 
 type mockLogger []migrate.LogEntry
diff --git a/sql/sqlclient/client_test.go b/sql/sqlclient/client_test.go
index 4834100b..971de93b 100644
--- a/sql/sqlclient/client_test.go
+++ b/sql/sqlclient/client_test.go
@@ -10,6 +10,9 @@ import (
 	"net/url"
 	"testing"
 
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"github.com/DATA-DOG/go-sqlmock"
 	"github.com/stretchr/testify/require"
 
 	"ariga.io/atlas/sql/sqlclient"
@@ -71,3 +74,55 @@ func TestClient_AddClosers(t *testing.T) {
 type closerFunc func() error
 
 func (f closerFunc) Close() error { return f() }
+
+func TestClient_Tx(t *testing.T) {
+	db, mock, err := sqlmock.New()
+	require.NoError(t, err)
+	defer db.Close()
+
+	const stmt = "create database `test`"
+	mock.ExpectBegin()
+	mock.ExpectExec(stmt).WillReturnResult(sqlmock.NewResult(0, 1))
+	mock.ExpectCommit()
+	mock.ExpectBegin()
+	mock.ExpectExec(stmt).WillReturnResult(sqlmock.NewResult(0, 1))
+	mock.ExpectRollback()
+
+	sqlclient.Register(
+		"tx",
+		sqlclient.OpenerFunc(func(context.Context, *url.URL) (*sqlclient.Client, error) {
+			return &sqlclient.Client{Name: "tx", DB: db, Driver: &mockDriver{db: db}}, nil
+		}),
+		sqlclient.RegisterDriverOpener(func(db schema.ExecQuerier) (migrate.Driver, error) {
+			return &mockDriver{db: db}, nil
+		}),
+	)
+
+	c, err := sqlclient.Open(context.Background(), "tx://")
+	require.NoError(t, err)
+
+	// Commit works.
+	tx, err := c.Tx(context.Background(), nil)
+	require.NoError(t, err)
+	_, err = tx.ExecContext(context.Background(), stmt)
+	require.NoError(t, err)
+	require.NoError(t, tx.Commit())
+
+	// Rollback works as well.
+	tx, err = c.Tx(context.Background(), nil)
+	require.NoError(t, err)
+	_, err = tx.ExecContext(context.Background(), stmt)
+	require.NoError(t, err)
+	require.NoError(t, tx.Rollback())
+
+	require.NoError(t, mock.ExpectationsWereMet())
+}
+
+type mockDriver struct {
+	migrate.Driver
+	db schema.ExecQuerier
+}
+
+func (m *mockDriver) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
+	return m.db.ExecContext(ctx, query, args...)
+}