wordmove-ng 6.0.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b938b4ef6d1ed30ecf48f1cbe1d48ba2f1a2ef223247189a3da3a0c1b840238
4
- data.tar.gz: a3ffcdb129f255a82a7257f7d50a9d7c8b56195fd7e8a169ff04cceed8216281
3
+ metadata.gz: dd4eb4e95fb86d7ce9727f7483018c1a8b11dfb05ea5bdaace45bbde8a3b7122
4
+ data.tar.gz: 88ff0153cf9147f4876dade9e035a8207a3396e06350dea1e51ad4e0c946fcbf
5
5
  SHA512:
6
- metadata.gz: 7d8d9baa3cecea48ac16a2853c927695eb1ad865bc0df9efc01f5ba6d034e96afab4322eeae3da3b9a605584a5fe44ed4ff58c3995daea9d4b9f08de616720ac
7
- data.tar.gz: 211ca5b52895d6fef092985a0bec08cd7bad97845c6579187699856e1d79339d510da596a60247bc3aa4296b1bdb7e85859cebbeda51a853b36335bdb86723b0
6
+ metadata.gz: 21e75d8a0f362a0ad35610b544756d0324d7ac519a1a9bea065cb4549c971087ebd67928d78615522ecf9950f067ebea7f7aeddde085f692cacd4effe8f66a30
7
+ data.tar.gz: f99871ec8da25f7201894496dbe1d164b820e351f437f86f21c1623d68a95d9f61fcf3f0a7f498e22e15f03b69cdc6b9626f49624dee0b9ad8ec5c34552e5f98
@@ -2,12 +2,12 @@
2
2
  #
3
3
  # 1. release-please watches master. From Conventional Commits it opens (and keeps
4
4
  # updating) a "release PR" that bumps lib/wordmove/version.rb and CHANGELOG.md.
5
- # Merging that PR creates the vX.Y.Z tag and the GitHub release.
6
- # 2. The publish job then builds the gem and pushes it to rubygems.org with
7
- # trusted publishing (OIDC, no API key) and attaches the .gem to the release.
8
- #
9
- # The publish job also runs when a v* tag is pushed by hand, which is how the
10
- # very first release (v6.0.0) is cut.
5
+ # Merging that PR creates the vX.Y.Z tag and the GitHub release, using the
6
+ # RELEASE_PLEASE_TOKEN PAT so that the tag push triggers this workflow again.
7
+ # 2. That tag push runs the publish job, which builds the gem, pushes it to
8
+ # rubygems.org with trusted publishing (OIDC, no API key) and attaches the
9
+ # .gem to the release. A v* tag pushed by hand at the head of master goes
10
+ # through the same job.
11
11
  #
12
12
  # One-time setup (see README "Releasing"):
13
13
  # - rubygems.org: pending trusted publisher for gem wordmove-ng, repository
@@ -36,9 +36,6 @@ jobs:
36
36
  permissions:
37
37
  contents: write
38
38
  pull-requests: write
39
- outputs:
40
- release_created: ${{ steps.release.outputs.release_created }}
41
- tag_name: ${{ steps.release.outputs.tag_name }}
42
39
  steps:
43
40
  # A fine-grained PAT (secret RELEASE_PLEASE_TOKEN, contents + pull-requests
44
41
  # read/write) instead of the built-in token: PRs opened with the built-in
@@ -52,14 +49,10 @@ jobs:
52
49
  manifest-file: .release-please-manifest.json
53
50
 
54
51
  publish:
55
- needs: [release-please]
56
- # Runs after release-please created a release, or on a manually pushed tag.
57
- # The `always()` is needed because release-please is skipped on tag pushes.
58
- if: >-
59
- always() && (
60
- needs.release-please.outputs.release_created == 'true' ||
61
- startsWith(github.ref, 'refs/tags/v')
62
- )
52
+ # Only on tag pushes. release-please's tag (created with the PAT) triggers
53
+ # this workflow like any other push, so publishing from the master run as
54
+ # well would race it and fail with "Tag vX.Y.Z has already been created".
55
+ if: startsWith(github.ref, 'refs/tags/v')
63
56
  runs-on: ubuntu-latest
64
57
  environment: release
65
58
  permissions:
@@ -80,7 +73,7 @@ jobs:
80
73
 
81
74
  - name: Verify the tag points at the head of master
82
75
  env:
83
- TAG: ${{ needs.release-please.outputs.tag_name || github.ref_name }}
76
+ TAG: ${{ github.ref_name }}
84
77
  run: |
85
78
  git fetch --tags --force
86
79
  tag_commit="$(git rev-parse "refs/tags/$TAG^{commit}")"
@@ -104,7 +97,7 @@ jobs:
104
97
  - name: Attach the gem to the GitHub release
105
98
  env:
106
99
  GH_TOKEN: ${{ github.token }}
107
- TAG: ${{ needs.release-please.outputs.tag_name || github.ref_name }}
100
+ TAG: ${{ github.ref_name }}
108
101
  # release-please creates the release itself; a hand-pushed tag has none yet.
109
102
  run: |
110
103
  if ! gh release view "$TAG" >/dev/null 2>&1; then
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "6.0.2"
2
+ ".": "6.1.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to wordmove-ng are documented here. The project follows
5
5
  are listed at https://github.com/welaika/wordmove/releases; the kokiddp fork's changes
6
6
  between 5.2.2 and this project are summarised under 6.0.0.
7
7
 
8
+ ## [6.1.0](https://github.com/tekgnosis-net/wordmove-ng/compare/v6.0.3...v6.1.0) (2026-10-06)
9
+
10
+
11
+ ### Features
12
+
13
+ * add the auto command that picks push or pull per component ([#9](https://github.com/tekgnosis-net/wordmove-ng/issues/9)) ([6733e70](https://github.com/tekgnosis-net/wordmove-ng/commit/6733e70c5e67b2628c6e8863fb1c6f85be714f13))
14
+
15
+ ## [6.0.3](https://github.com/tekgnosis-net/wordmove-ng/compare/v6.0.2...v6.0.3) (2026-09-30)
16
+
17
+
18
+ ### Documentation
19
+
20
+ * expand the Ruby installation guide ([#7](https://github.com/tekgnosis-net/wordmove-ng/issues/7)) ([5972e2c](https://github.com/tekgnosis-net/wordmove-ng/commit/5972e2cf45f8ef6ebf1c2724b7637a8f4d319e11))
21
+
8
22
  ## [6.0.2](https://github.com/tekgnosis-net/wordmove-ng/compare/v6.0.1...v6.0.2) (2026-09-12)
9
23
 
10
24
 
data/CLAUDE.md CHANGED
@@ -86,7 +86,13 @@ Key pieces:
86
86
  - **`Prerequisites`** (`lib/wordmove/prerequisites.rb`): one `sh` probe listing missing
87
87
  programs; `DB_SOURCE`/`DB_TARGET`/`REMOTE_ALL` requirement sets shared by deployer and
88
88
  doctor.
89
+ - **`auto` command** (`lib/wordmove/auto_planner.rb` + `auto_command.rb`): per-component
90
+ direction from newest-mtime (directories) or latest post/comment (db) on both sides; core
91
+ never decided; plan-only unless `--apply`; exit 3 = pending moves. Specs stub `SshRunner`
92
+ and the planner's `local_shell`.
89
93
  - **Knobs**: `global.maintenance_mode` (env override `WORDMOVE_MAINTENANCE_MODE`),
94
+ `global.auto_window` / `global.auto_clock_skew_max` (`WORDMOVE_AUTO_WINDOW`,
95
+ `WORDMOVE_AUTO_CLOCK_SKEW_MAX`),
90
96
  `global.collation_fallbacks`, `global.charset_fallbacks`. Add new toggles as movefile keys
91
97
  with an env override and document them in README + CHANGELOG in the same commit.
92
98
 
data/README.md CHANGED
@@ -35,7 +35,9 @@ in a few places where the old behaviour was unsafe. See [Upgrading from wordmove
35
35
 
36
36
  ## Installation
37
37
 
38
- Ruby 3.0 or newer is required.
38
+ Ruby 3.0 or newer is required. If your system Ruby is older or missing, the docs walk
39
+ through [installing Ruby](https://tekgnosis-net.github.io/wordmove-ng/installation/#ruby)
40
+ with your package manager, rbenv, RVM, mise or Docker.
39
41
 
40
42
  ```bash
41
43
  gem install wordmove-ng
@@ -71,6 +73,10 @@ Component flags: `-w` core, `-u` uploads, `-t` themes, `-p` plugins, `-m` mu-plu
71
73
  `-l` languages, `-d` database, `--all` everything (`--all --no-uploads` to exclude one).
72
74
  `-s` simulates, `-e` picks the environment, `-c` points at another movefile.
73
75
 
76
+ `wordmove-ng auto -e production --all` compares each component on both sides by timestamp and
77
+ prints which direction it would move; add `--apply` to execute. See the
78
+ [usage page](https://tekgnosis-net.github.io/wordmove-ng/usage/#auto) for the signals and caveats.
79
+
74
80
  ## Peer dependencies
75
81
 
76
82
  wordmove-ng is orchestration glue around standard tools.
@@ -320,12 +326,12 @@ stored anywhere.
320
326
  1. Every push to `master` updates a "release PR" that bumps `lib/wordmove/version.rb` and
321
327
  `CHANGELOG.md` according to the commits since the last release.
322
328
  2. Merging that PR creates the `vX.Y.Z` tag and the GitHub release.
323
- 3. The `publish` job in `.github/workflows/release.yml` then runs the test suite on the
324
- tagged commit, builds the gem, pushes it to rubygems.org and attaches the `.gem` file to
325
- the GitHub release.
329
+ 3. The tag push triggers the `publish` job in `.github/workflows/release.yml`, which runs
330
+ the test suite on the tagged commit, builds the gem, pushes it to rubygems.org and
331
+ attaches the `.gem` file to the GitHub release.
326
332
 
327
- Pushing a `v*` tag by hand at the head of `master` triggers the same publish job; that is
328
- how 6.0.0 was cut. Merge the release PR only when there is something worth shipping:
333
+ Pushing a `v*` tag by hand at the head of `master` goes through the same publish job; that
334
+ is how 6.0.0 was cut. Merge the release PR only when there is something worth shipping:
329
335
  release-please keeps proposing a patch release for any Conventional Commit, including
330
336
  `ci:` and `docs:` ones.
331
337
 
@@ -20,7 +20,7 @@ set -euo pipefail
20
20
  # ---- settings (override with environment variables) --------------------------
21
21
  SITE_DIR="${SITE_DIR:-/path/to/wordpress}" # directory holding movefile.yml
22
22
  ENVIRONMENT="${ENVIRONMENT:-production}" # remote environment (-e)
23
- ACTION="${ACTION:-pull}" # pull or push
23
+ ACTION="${ACTION:-pull}" # pull, push, or auto (plan only unless COMPONENTS adds --apply)
24
24
  COMPONENTS="${COMPONENTS:---all --no-db}" # component flags
25
25
  LOG_DIR="${LOG_DIR:-$HOME/log/wordmove}"
26
26
  KEEP_LOGS="${KEEP_LOGS:-30}" # how many run logs to keep
@@ -28,8 +28,8 @@ RUBY_VERSION="${RUBY_VERSION:-}" # e.g. ruby-3.4.9 for rvm; emp
28
28
  # -----------------------------------------------------------------------------
29
29
 
30
30
  case "$ACTION" in
31
- pull|push) ;;
32
- *) echo "ACTION must be pull or push, got '$ACTION'" >&2; exit 2 ;;
31
+ pull|push|auto) ;;
32
+ *) echo "ACTION must be pull, push or auto, got '$ACTION'" >&2; exit 2 ;;
33
33
  esac
34
34
 
35
35
  # Ruby version managers are not loaded by cron.
data/docs/automation.md CHANGED
@@ -140,6 +140,10 @@ tail -n 40 ~/log/wordmove/production-*.log
140
140
 
141
141
  Running `wordmove-ng doctor` under the same `env -i` prefix is the quickest check that the key and every tool resolve without an agent.
142
142
 
143
+ ## Drift reports with `auto`
144
+
145
+ `wordmove-ng auto -e production --all` without `--apply` compares every component on both sides and exits `3` when something has drifted, `0` when everything is in sync. Scheduled from cron with `MAILTO` set, that is a nightly "which side changed" report with no risk of moving anything: `ACTION=auto COMPONENTS="--all"` works with the shipped script since the exit code propagates. Add `--apply` only once you trust the signals for your site; see the warning on the [usage page]({{ site.baseurl }}/usage/#auto).
146
+
143
147
  ## Database jobs
144
148
 
145
149
  For a scheduled database push enable `global.maintenance_mode` so the seconds between import and `wp search-replace` show visitors the maintenance page instead of the wrong URLs. Scheduled database pulls are the common case (a nightly copy of production for development) and need nothing special; each run leaves a `local-backup-<timestamp>.sql.gz` in `wp-content/`, so prune those occasionally.
@@ -100,6 +100,8 @@ production:
100
100
  | `maintenance_mode` | `false` | Wrap the target's database import and adaptation in `wp maintenance-mode activate`/`deactivate`. `WORDMOVE_MAINTENANCE_MODE=1` in the environment forces it on for one run. |
101
101
  | `collation_fallbacks` | built-in table | Collations rewritten before import, e.g. `utf8mb3_uca1400_ai_ci: utf8mb4_unicode_ci`. Setting the key replaces the default table. |
102
102
  | `charset_fallbacks` | `utf8mb3: utf8mb4` | Charsets rewritten before import. |
103
+ | `auto_window` | `300` | `wordmove-ng auto`: timestamp differences up to this many seconds count as in sync. Env override `WORDMOVE_AUTO_WINDOW`. |
104
+ | `auto_clock_skew_max` | `60` | `wordmove-ng auto`: abort when local and remote clocks differ by more than this many seconds. Env override `WORDMOVE_AUTO_CLOCK_SKEW_MAX`. |
103
105
  | `sql_adapter` | ignored | Accepted with a warning for 5.x movefiles. |
104
106
 
105
107
  ## `local` and each remote
data/docs/contributing.md CHANGED
@@ -38,9 +38,9 @@ Releases are automated by [release-please](https://github.com/googleapis/release
38
38
 
39
39
  1. Every push to `master` updates a "release PR" that bumps the version and the changelog from the commits since the last release. release-please proposes a patch release for any Conventional Commit, `ci:` and `docs:` included, so merge the PR only when there is something worth shipping.
40
40
  2. Merging it creates the `vX.Y.Z` tag and the GitHub release.
41
- 3. The `publish` job in `.github/workflows/release.yml` re-runs the suite on the tagged commit, builds the gem, pushes it to rubygems.org and attaches the `.gem` file to the GitHub release.
41
+ 3. The tag push triggers the `publish` job in `.github/workflows/release.yml`, which re-runs the suite on the tagged commit, builds the gem, pushes it to rubygems.org and attaches the `.gem` file to the GitHub release.
42
42
 
43
- Pushing a `v*` tag by hand at the head of `master` triggers the same publish job.
43
+ Pushing a `v*` tag by hand at the head of `master` goes through the same publish job.
44
44
 
45
45
  `master` is protected: changes land through pull requests whose `test` checks pass, and the rules apply to administrators too. release-please opens its PR with a fine-grained personal access token (secret `RELEASE_PLEASE_TOKEN`) because PRs opened with the built-in Actions token never trigger the checks.
46
46
 
data/docs/installation.md CHANGED
@@ -11,11 +11,124 @@ nav_order: 2
11
11
 
12
12
  ## Ruby
13
13
 
14
- wordmove-ng needs **Ruby 3.0 or newer**. Check with `ruby --version`. If your system Ruby is older or missing, use a version manager:
14
+ wordmove-ng needs **Ruby 3.0 or newer** and is tested on 3.0 through 4.0. Check what you have:
15
15
 
16
- - [rbenv](https://github.com/rbenv/rbenv#installation)
17
- - [RVM](https://rvm.io/)
18
- - [mise](https://mise.jdx.dev/) (`mise use -g ruby@3.4`)
16
+ ```bash
17
+ ruby --version
18
+ ```
19
+
20
+ If that prints 3.0 or newer you can skip to [the gem](#the-gem). Otherwise pick one of the routes below. A version manager is the usual choice: it leaves the system Ruby alone (several distributions depend on it), installs gems into your home directory without `sudo`, and lets you upgrade Ruby later without touching anything else. Any current 3.x works; `3.4` is a good default and is what the project itself uses.
21
+
22
+ ### Option A: your distribution's Ruby
23
+ {: .no_toc }
24
+
25
+ The quickest route when the packaged version is recent enough. Debian 12, Ubuntu 22.04 and later, Fedora and Arch all ship Ruby 3.x.
26
+
27
+ ```bash
28
+ # Debian / Ubuntu
29
+ sudo apt update && sudo apt install -y ruby-full build-essential
30
+ # Fedora
31
+ sudo dnf install -y ruby ruby-devel @development-tools
32
+ # Arch
33
+ sudo pacman -S ruby base-devel
34
+ ```
35
+
36
+ Then install the gem for your user only, so no `sudo` is needed and the system stays untouched:
37
+
38
+ ```bash
39
+ gem install --user-install wordmove-ng
40
+ ```
41
+
42
+ `--user-install` puts executables under `~/.local/share/gem/ruby/<version>/bin` (older Rubies: `~/.gem/ruby/<version>/bin`). Add that directory to your `PATH` in `~/.bashrc` or `~/.zshrc`; `gem env` prints the exact path under `USER INSTALLATION DIRECTORY`.
43
+
44
+ ### Option B: rbenv
45
+ {: .no_toc }
46
+
47
+ Lightweight, shell-agnostic, and packaged everywhere. `rbenv` selects a Ruby; the `ruby-build` plugin compiles one.
48
+
49
+ ```bash
50
+ # macOS
51
+ brew install rbenv ruby-build
52
+ # Debian / Ubuntu (rbenv from apt is often old; the git checkout below is current)
53
+ sudo apt install -y git build-essential autoconf libssl-dev libyaml-dev zlib1g-dev libffi-dev libgmp-dev rustc
54
+ git clone https://github.com/rbenv/rbenv.git ~/.rbenv
55
+ git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
56
+ ~/.rbenv/bin/rbenv init # prints the line to add to your shell profile; add it, then reopen the terminal
57
+ ```
58
+
59
+ ```bash
60
+ rbenv install 3.4.11 # compiles Ruby, takes a few minutes
61
+ rbenv global 3.4.11
62
+ ruby --version
63
+ gem install wordmove-ng
64
+ rbenv rehash # makes the wordmove-ng executable visible
65
+ ```
66
+
67
+ `rbenv install --list` shows the versions available; run `git -C ~/.rbenv/plugins/ruby-build pull` (or `brew upgrade ruby-build`) if a recent one is missing.
68
+
69
+ ### Option C: RVM
70
+ {: .no_toc }
71
+
72
+ Heavier than rbenv but familiar to many WordPress developers, and its gemsets isolate one project's gems from another's.
73
+
74
+ ```bash
75
+ gpg --keyserver keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
76
+ \curl -sSL https://get.rvm.io | bash -s stable
77
+ source ~/.rvm/scripts/rvm # or reopen the terminal
78
+ ```
79
+
80
+ ```bash
81
+ rvm install 3.4.11
82
+ rvm use 3.4.11 --default
83
+ gem install wordmove-ng
84
+ ```
85
+
86
+ RVM installs the build dependencies for you (it may ask for your `sudo` password once). To keep wordmove-ng in its own gemset: `rvm gemset create wordmove && rvm use 3.4.11@wordmove --default` before `gem install`. If you use the [cron script]({{ site.baseurl }}/automation/), set `RUBY_VERSION` to the same `ruby-3.4.11` or `ruby-3.4.11@wordmove` string.
87
+
88
+ ### Option D: mise
89
+ {: .no_toc }
90
+
91
+ One tool for Ruby, Node, PHP and more, with a single config file per project. Good if you already manage other runtimes with it.
92
+
93
+ ```bash
94
+ curl https://mise.run | sh
95
+ echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc # or: mise activate zsh >> ~/.zshrc
96
+ exec $SHELL
97
+ ```
98
+
99
+ ```bash
100
+ mise use --global ruby@3.4
101
+ ruby --version
102
+ gem install wordmove-ng
103
+ ```
104
+
105
+ mise compiles Ruby through ruby-build as well, so on Linux install the same build packages listed under rbenv first; on macOS `brew install openssl@3 readline libyaml gmp autoconf`.
106
+
107
+ ### Docker
108
+ {: .no_toc }
109
+
110
+ If you would rather not install Ruby at all, run wordmove-ng from the official Ruby image. Mount your site and your SSH configuration, and make sure the peer tools are installed in the container:
111
+
112
+ ```bash
113
+ docker run --rm -it \
114
+ -v "$PWD":/site -w /site \
115
+ -v "$HOME/.ssh":/root/.ssh:ro \
116
+ ruby:3.4 bash -c 'apt-get update -qq && apt-get install -y -qq rsync openssh-client mariadb-client >/dev/null \
117
+ && gem install -q wordmove-ng && wordmove-ng doctor'
118
+ ```
119
+
120
+ For repeated use bake that into a small Dockerfile. Note that `local` in your movefile then means "inside the container": `wordpress_path` is `/site`, and the local database must be reachable from the container (`host: host.docker.internal` on Docker Desktop, or the compose service name).
121
+
122
+ ### Checking the result
123
+ {: .no_toc }
124
+
125
+ ```bash
126
+ ruby --version # 3.0 or newer
127
+ gem --version
128
+ wordmove-ng --version
129
+ ```
130
+
131
+ If `wordmove-ng` is not found right after installing, the executable directory is not in your `PATH` yet: open a new terminal, and for rbenv run `rbenv rehash`. `gem env` shows where executables were placed.
19
132
 
20
133
  ## The gem
21
134
 
data/docs/quick-start.md CHANGED
@@ -72,6 +72,7 @@ A push of the database backs up the remote database to `wp-content/production-ba
72
72
  | `wordmove-ng push -e production --all --no-db` | mirror every folder, keep the production database |
73
73
  | `wordmove-ng push -e production --all --no-uploads` | everything but uploads |
74
74
  | `wordmove-ng pull -e production -d --no-adapt` | import the production database without rewriting URLs |
75
+ | `wordmove-ng auto -e production --all` | show which components are newer on which side, change nothing |
75
76
  | `wordmove-ng list` | show every environment and its vhost |
76
77
  | `wordmove-ng help push` | all flags |
77
78
 
data/docs/usage.md CHANGED
@@ -59,6 +59,47 @@ Options:
59
59
 
60
60
  `pull` copies from the remote given with `-e` to `local`; `push` copies from `local` to that remote. At least one component flag (or `--all`) is required.
61
61
 
62
+ ### `auto`
63
+ {: .no_toc }
64
+
65
+ Decides **per component** whether to push or pull, from timestamps on both sides, and prints a plan. Nothing changes unless you pass `--apply`.
66
+
67
+ ```bash
68
+ wordmove-ng auto -e production -t -p -u -d # print the plan
69
+ wordmove-ng auto -e production --all --apply -s # execute it as a dry run
70
+ wordmove-ng auto -e production --all --apply # execute it
71
+ ```
72
+
73
+ ```
74
+ ▬▬ Auto plan for "production" ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬
75
+ themes PUSH local 2026-10-06 09:12:40 remote 2026-10-01 18:03:11 local newer by 400769s
76
+ plugins SKIP local 2026-10-01 18:03:11 remote 2026-10-01 18:03:11 in sync (within 300s)
77
+ uploads PULL local 2026-09-28 11:20:02 remote 2026-10-06 08:55:30 remote newer by 682528s
78
+ db PULL local 2026-09-28 11:19:57 remote 2026-10-06 08:57:01 remote newer by 682624s
79
+ ℹ️ info | Plan only. Re-run with --apply to execute it (add -s for a dry run).
80
+ ```
81
+
82
+ How each side is measured:
83
+
84
+ | Component | Signal |
85
+ | --- | --- |
86
+ | uploads, themes, plugins, mu-plugins, languages | newest file modification time beneath the directory (`find … -printf '%T@'`) |
87
+ | database | latest of `MAX(post_modified_gmt)` and `MAX(comment_date_gmt)`, via `wp db query` on each side |
88
+ | core (`-w`) | never decided automatically; always `SKIP` |
89
+
90
+ Guard rails:
91
+
92
+ - Differences up to `global.auto_window` seconds (default 300) count as **in sync** and are skipped.
93
+ - Local and remote clocks are compared first; a skew above `global.auto_clock_skew_max` seconds (default 60) aborts, a smaller one is reported as a warning.
94
+ - A direction forbidden by the movefile shows as `BLOCKED` and is never executed.
95
+ - If a probe fails on either side the row is `ERROR` and nothing is applied, even with `--apply`.
96
+ - With `--apply`, pushes run first and pulls second, each wrapped in the usual `before`/`after` hooks, through exactly the same steps as `push` and `pull` (backups, prerequisites, `--simulate`).
97
+
98
+ Exit codes: `0` when nothing needs to move or after a successful `--apply`, `3` when the plan has pending moves and `--apply` was not given, `1` on any error. Cron can therefore run `auto` without `--apply` as a nightly "drift report" and treat `3` as "something to look at".
99
+
100
+ {: .warning }
101
+ Timestamps are evidence, not truth. A `git checkout` or an rsync without `--times` touches every file and makes that side look newer; a plugin auto-update on production happening after your local theme work makes production "newer" and would delete your work on `--apply`. Read the plan before applying it. The database signal only covers posts and comments; option and settings changes are invisible to it.
102
+
62
103
  ## Flags
63
104
 
64
105
  | Flag | Meaning |
@@ -78,6 +119,7 @@ Options:
78
119
  | `--no-adapt` | import the database without running `wp search-replace` |
79
120
  | `-v`, `--verbose` | more output |
80
121
  | `--debug` | accepted for compatibility; had an effect only with the removed FTP deployer |
122
+ | `--apply` | `auto` only: execute the plan instead of printing it |
81
123
 
82
124
  Component paths respect the `paths` section of each environment.
83
125
 
@@ -4,6 +4,10 @@ mapping:
4
4
  type: str
5
5
  maintenance_mode:
6
6
  type: bool
7
+ auto_window:
8
+ type: int
9
+ auto_clock_skew_max:
10
+ type: int
7
11
  collation_fallbacks:
8
12
  type: map
9
13
  mapping:
@@ -0,0 +1,43 @@
1
+ module Wordmove
2
+ # Helpers behind `wordmove-ng auto`, kept out of the Thor class so the
3
+ # command definitions stay readable.
4
+ module AutoCommand
5
+ private
6
+
7
+ def selected_tasks(options)
8
+ tasks = []
9
+ handle_options(options) { |task| tasks << task }
10
+ tasks
11
+ end
12
+
13
+ def print_auto_plan(rows, warnings, environment)
14
+ logger.task "Auto plan for \"#{environment}\""
15
+ warnings.each { |w| logger.warn(w) }
16
+ width = rows.map { |r| r.task.length }.max
17
+ rows.each do |row|
18
+ logger.plain format(
19
+ " %-#{width}s %-7s local %-20s remote %-20s %s",
20
+ row.task, row.direction.to_s.upcase, stamp(row.local_at), stamp(row.remote_at),
21
+ row.reason
22
+ )
23
+ end
24
+ end
25
+
26
+ def stamp(epoch)
27
+ epoch.nil? ? '-' : Time.at(epoch).strftime('%Y-%m-%d %H:%M:%S')
28
+ end
29
+
30
+ def apply_auto_plan(rows, options)
31
+ deployer = Wordmove::Deployer::Base.deployer_for(options.deep_symbolize_keys)
32
+ %i[push pull].each do |action|
33
+ tasks = rows.select { |r| r.direction == action }.map(&:task)
34
+ next if tasks.empty?
35
+
36
+ Wordmove::Hook.run(action, :before, options)
37
+ guardian = Wordmove::Guardian.new(options: options, action: action)
38
+ tasks.each { |task| deployer.send("#{action}_#{task}") if guardian.allows(task.to_sym) }
39
+ Wordmove::Hook.run(action, :after, options)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,194 @@
1
+ require 'open3'
2
+ require 'shellwords'
3
+ require 'time'
4
+
5
+ module Wordmove
6
+ # Decides, per component, whether local or the remote holds the newer
7
+ # content, so `wordmove-ng auto` can push some components and pull others.
8
+ #
9
+ # Directories are compared by the newest file modification time beneath
10
+ # them; the database by the latest post or comment change on each side.
11
+ # Differences inside `global.auto_window` seconds are treated as in sync.
12
+ # The WordPress core is never decided automatically: its files change on
13
+ # every checkout and on every update, and mirroring it the wrong way is the
14
+ # most destructive mistake possible.
15
+ class AutoPlanner
16
+ class ClockSkewError < StandardError; end
17
+
18
+ Row = Struct.new(:task, :local_at, :remote_at, :direction, :reason) do
19
+ def pending?
20
+ %i[push pull].include?(direction)
21
+ end
22
+ end
23
+
24
+ DEFAULT_WINDOW = 300
25
+ DEFAULT_CLOCK_SKEW_MAX = 60
26
+ DIRECTORY_TASKS = %w[uploads themes plugins mu_plugins languages].freeze
27
+ EPOCH = /\A\d+(\.\d+)?\z/
28
+ TIMESTAMP = /\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\z/
29
+
30
+ attr_reader :warnings, :environment
31
+
32
+ def initialize(cli_options)
33
+ @cli_options = cli_options
34
+ @movefile = Wordmove::Movefile.new(cli_options[:config])
35
+ @movefile.load_dotenv(cli_options)
36
+ @options = @movefile.fetch(false)
37
+ @environment = @movefile.environment(cli_options)
38
+ @runner = SshRunner.new(@options[environment][:ssh])
39
+ @warnings = []
40
+ end
41
+
42
+ def self.pending?(rows)
43
+ rows.any?(&:pending?)
44
+ end
45
+
46
+ # Returns one Row per task, in the given order.
47
+ def plan(tasks)
48
+ check_clock_skew!
49
+ tasks.map { |task| plan_task(task) }
50
+ end
51
+
52
+ def window
53
+ knob('WORDMOVE_AUTO_WINDOW', :auto_window, DEFAULT_WINDOW)
54
+ end
55
+
56
+ def clock_skew_max
57
+ knob('WORDMOVE_AUTO_CLOCK_SKEW_MAX', :auto_clock_skew_max, DEFAULT_CLOCK_SKEW_MAX)
58
+ end
59
+
60
+ private
61
+
62
+ def plan_task(task)
63
+ return Row.new(task, nil, nil, :skip, 'core is never decided automatically') if core?(task)
64
+
65
+ local_at, local_err = probe(:local, task)
66
+ remote_at, remote_err = probe(:remote, task)
67
+ error = probe_error(local_err, remote_err)
68
+ return Row.new(task, local_at, remote_at, :error, error) if error
69
+
70
+ direction, reason = decide(local_at, remote_at)
71
+ direction, reason = apply_forbid(task, direction, reason)
72
+ Row.new(task, local_at, remote_at, direction, reason)
73
+ end
74
+
75
+ def core?(task)
76
+ task == 'wordpress'
77
+ end
78
+
79
+ def probe_error(local_err, remote_err)
80
+ return "local probe failed: #{local_err}" if local_err
81
+ return "remote probe failed: #{remote_err}" if remote_err
82
+
83
+ nil
84
+ end
85
+
86
+ def decide(local_at, remote_at)
87
+ return [:skip, 'both sides empty'] if local_at.nil? && remote_at.nil?
88
+ return [:push, 'remote empty'] if remote_at.nil?
89
+ return [:pull, 'local empty'] if local_at.nil?
90
+
91
+ delta = (remote_at - local_at).to_i
92
+ return [:skip, "in sync (within #{window}s)"] if delta.abs <= window
93
+
94
+ delta.positive? ? [:pull, "remote newer by #{delta}s"] : [:push, "local newer by #{-delta}s"]
95
+ end
96
+
97
+ def apply_forbid(task, direction, reason)
98
+ return [direction, reason] unless %i[push pull].include?(direction)
99
+
100
+ forbidden = @options.dig(environment, :forbid, direction, task.to_sym) == true
101
+ return [direction, reason] unless forbidden
102
+
103
+ [:blocked, "#{reason}; #{direction} forbidden by movefile"]
104
+ end
105
+
106
+ # Returns [epoch_or_nil, error_or_nil]. Probe output comes from a remote
107
+ # host and is treated as untrusted: anything that is not a plain number or
108
+ # timestamp is reported as an error rather than interpreted.
109
+ def probe(side, task)
110
+ command = task == 'db' ? db_command(side) : dir_command(side, task)
111
+ stdout, stderr, code = execute(side, command)
112
+ return [nil, stderr.to_s.strip.empty? ? "exit #{code}" : stderr.strip] unless code.zero?
113
+
114
+ [task == 'db' ? parse_db(stdout) : parse_epoch(stdout), nil]
115
+ rescue ArgumentError => e
116
+ [nil, "unparseable output: #{e.message}"]
117
+ end
118
+
119
+ def dir_command(side, task)
120
+ path = WordpressDirectory.new(task.to_sym, side_options(side)).path
121
+ # Newest regular file mtime under the directory; empty output if none.
122
+ "find #{Shellwords.escape(path)} -type f -printf '%T@\\n' 2>/dev/null | sort -n | tail -1"
123
+ end
124
+
125
+ # Latest post and comment change, one per line. The table prefix is read
126
+ # with `wp db prefix` so custom prefixes work; wp handles the credentials.
127
+ def db_command(side)
128
+ path = Shellwords.escape(side_options(side)[:wordpress_path])
129
+ prefix = "$(wp db prefix --path=#{path} --allow-root)"
130
+ sql = "SELECT MAX(post_modified_gmt) FROM #{prefix}posts " \
131
+ "UNION ALL SELECT MAX(comment_date_gmt) FROM #{prefix}comments"
132
+ "wp db query \"#{sql}\" --skip-column-names --path=#{path} --allow-root"
133
+ end
134
+
135
+ def parse_epoch(stdout)
136
+ value = stdout.to_s.strip
137
+ return nil if value.empty?
138
+
139
+ expect_format!(value, EPOCH, 'an epoch')
140
+ value.to_f
141
+ end
142
+
143
+ def expect_format!(value, pattern, what)
144
+ return if value.match?(pattern)
145
+
146
+ raise ArgumentError, "expected #{what}, got #{value.inspect[0, 40]}"
147
+ end
148
+
149
+ def parse_db(stdout)
150
+ stamps = stdout.to_s.lines.map(&:strip).reject { |l| l.empty? || l == 'NULL' }
151
+ return nil if stamps.empty?
152
+
153
+ stamps.map do |stamp|
154
+ expect_format!(stamp, TIMESTAMP, 'a timestamp')
155
+ Time.parse("#{stamp} UTC").to_i
156
+ end.max
157
+ end
158
+
159
+ def check_clock_skew!
160
+ local_out, local_err, local_code = local_shell('date +%s')
161
+ rout, rerr, rcode = @runner.run('date +%s')
162
+ raise ClockSkewError, "could not read the local clock: #{local_err}" unless local_code.zero?
163
+ raise ClockSkewError, "could not read the remote clock: #{rerr}" unless rcode.zero?
164
+
165
+ skew = (rout.to_i - local_out.to_i).abs
166
+ if skew > clock_skew_max
167
+ raise ClockSkewError,
168
+ "clock skew of #{skew}s between local and \"#{environment}\" exceeds the " \
169
+ "maximum of #{clock_skew_max}s; fix the clocks (ntp) before trusting timestamps"
170
+ end
171
+ @warnings << "clock skew of #{skew}s between local and \"#{environment}\"" if skew.positive?
172
+ end
173
+
174
+ def execute(side, command)
175
+ side == :local ? local_shell(command) : @runner.run(command)
176
+ end
177
+
178
+ def local_shell(command)
179
+ stdout, stderr, status = Open3.capture3('sh', '-c', command)
180
+ [stdout, stderr, status.exitstatus]
181
+ end
182
+
183
+ def side_options(side)
184
+ side == :local ? @options[:local] : @options[environment]
185
+ end
186
+
187
+ def knob(env_name, key, default)
188
+ raw = ENV.fetch(env_name, nil)
189
+ raw = @options.dig(:global, key) if raw.nil? || raw.to_s.strip.empty?
190
+ value = raw.nil? ? default : raw.to_i
191
+ value.clamp(0, 86_400 * 30)
192
+ end
193
+ end
194
+ end
data/lib/wordmove/cli.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  module Wordmove
2
2
  class CLI < Thor
3
+ include AutoCommand
4
+
3
5
  map %w[--version -v] => :__print_version
4
6
 
5
7
  desc "--version, -v", "Print the version"
@@ -104,6 +106,58 @@ module Wordmove
104
106
  Wordmove::Hook.run(:pull, :after, options)
105
107
  end
106
108
 
109
+ desc "auto", "Decides per component whether to push or pull, from timestamps on both sides"
110
+ long_desc <<-LONGDESC
111
+ Compares each selected component on local and on the remote (newest file
112
+ modification time for directories, latest post or comment change for the
113
+ database) and proposes a direction per component. The WordPress core is
114
+ never decided automatically.
115
+
116
+ Without --apply only the plan is printed and nothing changes; the exit
117
+ code is 3 when at least one component would move, 0 when everything is in
118
+ sync. With --apply the plan is executed through the regular push and pull
119
+ steps, honouring --simulate, forbid rules and hooks.
120
+ LONGDESC
121
+ shared_options.each do |option, args|
122
+ method_option option, args
123
+ end
124
+ method_option :apply, type: :boolean, default: false,
125
+ desc: "Execute the plan instead of only printing it"
126
+ # rubocop:disable-next Metrics/MethodLength
127
+ def auto
128
+ ensure_wordpress_options_presence!(options)
129
+ begin
130
+ planner = Wordmove::AutoPlanner.new(options.deep_symbolize_keys)
131
+ rows = planner.plan(selected_tasks(options))
132
+ rescue MovefileNotFound, Wordmove::AutoPlanner::ClockSkewError,
133
+ UndefinedEnvironment, UnmetPeerDependencyError => e
134
+ logger.error(e.message)
135
+ exit 1
136
+ rescue Psych::SyntaxError => e
137
+ logger.error("Your movefile is not parsable due to a syntax error: #{e.message}")
138
+ exit 1
139
+ end
140
+
141
+ print_auto_plan(rows, planner.warnings, planner.environment)
142
+
143
+ if rows.any? { |r| r.direction == :error }
144
+ logger.error "Some components could not be compared; nothing was changed."
145
+ exit 1
146
+ end
147
+
148
+ unless Wordmove::AutoPlanner.pending?(rows)
149
+ logger.success "Nothing to do."
150
+ return
151
+ end
152
+
153
+ unless options[:apply]
154
+ logger.info "Plan only. Re-run with --apply to execute it (add -s for a dry run)."
155
+ exit 3
156
+ end
157
+
158
+ apply_auto_plan(rows, options)
159
+ end
160
+
107
161
  desc "push", "Pushes WP data from local machine to remote host"
108
162
  shared_options.each do |option, args|
109
163
  method_option option, args
@@ -2,6 +2,11 @@ global:
2
2
  # Put the target site in `wp maintenance-mode` while its database is replaced.
3
3
  # Can also be forced with the WORDMOVE_MAINTENANCE_MODE=1 environment variable.
4
4
  maintenance_mode: false
5
+ # `wordmove-ng auto`: timestamp differences up to this many seconds count as "in sync",
6
+ # and a larger clock skew between local and the remote aborts the comparison.
7
+ # Env overrides: WORDMOVE_AUTO_WINDOW, WORDMOVE_AUTO_CLOCK_SKEW_MAX.
8
+ # auto_window: 300
9
+ # auto_clock_skew_max: 60
5
10
 
6
11
  local:
7
12
  vhost: <%= vhost %>
@@ -1,3 +1,3 @@
1
1
  module Wordmove
2
- VERSION = "6.0.2".freeze
2
+ VERSION = "6.1.0".freeze
3
3
  end
data/lib/wordmove.rb CHANGED
@@ -22,6 +22,8 @@ require 'yaml'
22
22
 
23
23
  require 'wordmove/ssh_runner'
24
24
  require 'wordmove/prerequisites'
25
+ require 'wordmove/auto_planner'
26
+ require 'wordmove/auto_command'
25
27
  require 'photocopier'
26
28
 
27
29
  require 'wordmove/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wordmove-ng
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.2
4
+ version: 6.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kumar
@@ -275,6 +275,8 @@ files:
275
275
  - lib/wordmove/assets/wordmove_schema_global.yml
276
276
  - lib/wordmove/assets/wordmove_schema_local.yml
277
277
  - lib/wordmove/assets/wordmove_schema_remote.yml
278
+ - lib/wordmove/auto_command.rb
279
+ - lib/wordmove/auto_planner.rb
278
280
  - lib/wordmove/cli.rb
279
281
  - lib/wordmove/deployer/base.rb
280
282
  - lib/wordmove/deployer/ssh.rb