summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid E. Wheeler <david@justatheory.com>2023-12-29 19:00:22 -0500
committerDavid E. Wheeler <david@justatheory.com>2024-01-24 10:24:28 -0500
commit86a33b175cd9071441d3ef0eab51868a1542ac0e (patch)
tree9936bfa3a4f883b05d0f8b76c59a850cbcf338c1
parent4ba34ec65e4a5fe2c55ed5f5c8e37c386fc69074 (diff)
Use locale pragma instead of POSIX::setlocale()
The Pragma is more likely to do the right thing, as confirmed by new tests, which fail when using `setlocale` and now succeed with `use locale`. The tests, in `xt/locale`, include compiled locale dictionaries (`*.mo` files) with a single message for the tested languages. This is in contrast to the released locale dictionaries, which are generated at release time but not stored in the repository. Update the `Language-Team` header in the project localization packages in `po` directory to `Sqitch Hackers <sqitch-hackers@googlegroups.com>`. Update the `os.yml` and `perl.yml` workflows, which run all tests including the new locale tests, to install the required locales on Linux and to set the full `runs-on:` image name in the matrix (in response to shogo82148/actions-setup-perl#1699). Also remove the installation of an older version of Locale::TextDomain from those workflows, since gflohr/libintl-perl#7 has been fixed and released. While at it, upgrade to `actions/checkout@v4` in all workflows and use `runner.os` instead of `matrix.os` in conditionals.
-rw-r--r--.github/workflows/cockroach.yml2
-rw-r--r--.github/workflows/coverage.yml2
-rw-r--r--.github/workflows/exasol.yml2
-rw-r--r--.github/workflows/firebird.yml2
-rw-r--r--.github/workflows/mysql.yml2
-rw-r--r--.github/workflows/oracle.yml2
-rw-r--r--.github/workflows/os.yml18
-rw-r--r--.github/workflows/perl.yml10
-rw-r--r--.github/workflows/pg.yml2
-rw-r--r--.github/workflows/release.yml2
-rw-r--r--.github/workflows/snowflake.yml2
-rw-r--r--.github/workflows/sqlite.yml2
-rw-r--r--.github/workflows/vertica.yml2
-rw-r--r--.github/workflows/yugabyte.yml2
-rw-r--r--.gitignore3
-rw-r--r--Changes4
-rwxr-xr-xbin/sqitch11
-rw-r--r--dist.ini2
-rw-r--r--dist/cpanfile3
-rw-r--r--lib/App/Sqitch/Engine.pm9
-rw-r--r--po/de_DE.po2
-rw-r--r--po/fr_FR.po2
-rw-r--r--po/it_IT.po2
-rwxr-xr-xt/sqitch10
-rw-r--r--xt/locale/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mobin0 -> 462 bytes
-rw-r--r--xt/locale/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mobin0 -> 465 bytes
-rw-r--r--xt/locale/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mobin0 -> 460 bytes
-rw-r--r--xt/locale/README.md43
-rw-r--r--xt/locale/po/de_DE.po12
-rw-r--r--xt/locale/po/fr_FR.po12
-rw-r--r--xt/locale/po/it_IT.po12
-rw-r--r--xt/locale/test-cli.t57
32 files changed, 182 insertions, 54 deletions
diff --git a/.github/workflows/cockroach.yml b/.github/workflows/cockroach.yml
index a6087cac..6466785b 100644
--- a/.github/workflows/cockroach.yml
+++ b/.github/workflows/cockroach.yml
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Start CockroachDB
run: docker run -d -p 26257:26257 cockroachdb/cockroach:latest-v${{ matrix.version }} start-single-node --insecure
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index b433912a..500bc8eb 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -49,7 +49,7 @@ jobs:
run: rm -rf /opt/hostedtoolcache
- name: Start CockroachDB
run: docker run -d -p 26257:26257 cockroachdb/cockroach:latest start-single-node --insecure
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
diff --git a/.github/workflows/exasol.yml b/.github/workflows/exasol.yml
index d10db9d2..1f33fd39 100644
--- a/.github/workflows/exasol.yml
+++ b/.github/workflows/exasol.yml
@@ -20,7 +20,7 @@ jobs:
ports: [ 8563 ]
options: --privileged
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Clients
run: .github/ubuntu/exasol.sh
- name: Setup Perl
diff --git a/.github/workflows/firebird.yml b/.github/workflows/firebird.yml
index 36a5541a..de7c120e 100644
--- a/.github/workflows/firebird.yml
+++ b/.github/workflows/firebird.yml
@@ -29,7 +29,7 @@ jobs:
ISC_PASSWORD: nix
FIREBIRD_DATABASE: sqitchtest.db
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Clients
run: .github/ubuntu/firebird.sh
- name: Setup Perl
diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml
index 14cb78ac..03a7beec 100644
--- a/.github/workflows/mysql.yml
+++ b/.github/workflows/mysql.yml
@@ -39,7 +39,7 @@ jobs:
ports: [ 3306 ]
options: --health-cmd="healthcheck.sh --innodb_initialized || mysqladmin ping --protocol=tcp" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Clients
run: .github/ubuntu/mysql.sh
- name: Setup Perl
diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml
index 186501eb..ea8487ac 100644
--- a/.github/workflows/oracle.yml
+++ b/.github/workflows/oracle.yml
@@ -40,7 +40,7 @@ jobs:
--health-timeout 10s
--health-retries 10
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Clients
run: .github/ubuntu/oracle.sh
- name: Setup Perl
diff --git a/.github/workflows/os.yml b/.github/workflows/os.yml
index 6293bb68..59bea613 100644
--- a/.github/workflows/os.yml
+++ b/.github/workflows/os.yml
@@ -12,27 +12,29 @@ jobs:
strategy:
matrix:
include:
- - { icon: 🐧, os: ubuntu, name: Linux }
- - { icon: 🍎, os: macos, name: macOS }
- - { icon: 🪟, os: windows, name: Windows }
+ - { icon: 🐧, on: ubuntu, name: Linux }
+ - { icon: 🍎, on: macos, name: macOS }
+ - { icon: 🪟, on: windows, name: Windows }
name: ${{ matrix.icon }} ${{ matrix.name }}
- runs-on: ${{ matrix.os }}-latest
+ runs-on: ${{ matrix.on }}-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
with: { perl-version: latest }
- run: perl -V
+ - if: runner.os == 'Linux'
+ name: Install Apt Packages
+ run: sudo apt-get install -qq aspell-en language-pack-fr language-pack-en language-pack-de language-pack-it
- name: Cache CPAN Modules
uses: actions/cache@v3
with:
path: local
key: perl-${{ steps.perl.outputs.perl-hash }}
- run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends ExtUtils::MakeMaker List::MoreUtils::XS
- # Remove Locale::TextDomain if https://github.com/gflohr/libintl-perl/issues/7 fixed and released.
- - if: ${{ matrix.os == 'windows' }}
- run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends Encode Win32::Console::ANSI Win32API::Net Win32::Locale Win32::ShellQuote DateTime::TimeZone::Local::Win32 Locale::TextDomain@1.31
+ - if: runner.os == 'Windows'
+ run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends Encode Win32::Console::ANSI Win32API::Net Win32::Locale Win32::ShellQuote DateTime::TimeZone::Local::Win32
- run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends --cpanfile dist/cpanfile
- run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends Test::Spelling Test::Pod Test::Pod::Coverage
- name: prove
diff --git a/.github/workflows/perl.yml b/.github/workflows/perl.yml
index 22ddac38..56319729 100644
--- a/.github/workflows/perl.yml
+++ b/.github/workflows/perl.yml
@@ -19,20 +19,22 @@ jobs:
name: 🧅 Perl ${{ matrix.perl }} on ${{ matrix.os[0] }} ${{ matrix.os[1] }}
runs-on: ${{ matrix.os[1] }}-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
with: { perl-version: "${{ matrix.perl }}" }
- run: perl -V
+ - if: runner.os == 'Linux'
+ name: Install Apt Packages
+ run: sudo apt-get install -qq language-pack-fr language-pack-en language-pack-de language-pack-it
- name: Cache CPAN Modules
uses: actions/cache@v3
with:
path: local
key: perl-${{ steps.perl.outputs.perl-hash }}
- # Remove Locale::TextDomain if https://github.com/gflohr/libintl-perl/issues/7 fixed and released.
- - if: ${{ matrix.os[1] == 'windows' }}
- run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends Encode Win32::Console::ANSI Win32API::Net Win32::Locale Win32::ShellQuote DateTime::TimeZone::Local::Win32 Locale::TextDomain@1.31
+ - if: runner.os == 'Windows'
+ run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends Encode Win32::Console::ANSI Win32API::Net Win32::Locale Win32::ShellQuote DateTime::TimeZone::Local::Win32
- run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends --cpanfile dist/cpanfile
- run: cpm install --verbose --show-build-log-on-failure --no-test --with-recommends Test::Spelling Test::Pod Test::Pod::Coverage
- name: prove
diff --git a/.github/workflows/pg.yml b/.github/workflows/pg.yml
index c97c11ba..39a81739 100644
--- a/.github/workflows/pg.yml
+++ b/.github/workflows/pg.yml
@@ -15,7 +15,7 @@ jobs:
name: 🐘 Postgres ${{ matrix.pg }}
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9f5a1c1a..f27f3336 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,7 +10,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Check out the repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
diff --git a/.github/workflows/snowflake.yml b/.github/workflows/snowflake.yml
index 7dc089c5..45ddd26c 100644
--- a/.github/workflows/snowflake.yml
+++ b/.github/workflows/snowflake.yml
@@ -11,7 +11,7 @@ jobs:
name: ❄️ Snowflake
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Clients
run: .github/ubuntu/snowflake.sh
- name: Setup Perl
diff --git a/.github/workflows/sqlite.yml b/.github/workflows/sqlite.yml
index 4c0b758d..12e1f4c1 100644
--- a/.github/workflows/sqlite.yml
+++ b/.github/workflows/sqlite.yml
@@ -16,7 +16,7 @@ jobs:
name: 💡 SQLite ${{ matrix.sqlite }}
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
diff --git a/.github/workflows/vertica.yml b/.github/workflows/vertica.yml
index b524bbd3..5b734dfb 100644
--- a/.github/workflows/vertica.yml
+++ b/.github/workflows/vertica.yml
@@ -27,7 +27,7 @@ jobs:
image: ${{ matrix.image }}:${{ matrix.version }}
ports: [ 5433 ]
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Clients
run: .github/ubuntu/vertica.sh
- name: Setup Perl
diff --git a/.github/workflows/yugabyte.yml b/.github/workflows/yugabyte.yml
index 9524be9c..34c886e3 100644
--- a/.github/workflows/yugabyte.yml
+++ b/.github/workflows/yugabyte.yml
@@ -34,7 +34,7 @@ jobs:
uses: jameshartig/yugabyte-db-action@master
with:
yb_image_tag: "${{ matrix.tag }}"
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup Perl
id: perl
uses: shogo82148/actions-setup-perl@v1
diff --git a/.gitignore b/.gitignore
index 8033e00e..a3efe757 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,7 +13,8 @@
/_rpmbuild
/target
.build/
-*.mo
.al
/latest_changes.md
/local/
+/LocaleData/
+/lib/LocaleData/
diff --git a/Changes b/Changes
index e2f29ade..f45ae341 100644
--- a/Changes
+++ b/Changes
@@ -25,6 +25,10 @@ Revision history for Perl extension App::Sqitch
- Tests now require Test::Warn 0.31 or later, as newline handling issues
cause test failures in earlier versions. Thanks to Slaven Rezić for the
test reports and identifying the issue.
+ - Updated the locale configuration to fix issues in more recent versions
+ of Perl, and added tests to ensure that the sqitch CLI executes and
+ properly emits localized messages (except on Windows, where the language
+ codes are incompatible).
1.4.0 2023-08-01T23:37:30Z
- Fixed Snowflake warehouse and role setup to properly quote identifiers
diff --git a/bin/sqitch b/bin/sqitch
index e14a0111..89c77f22 100755
--- a/bin/sqitch
+++ b/bin/sqitch
@@ -1,15 +1,6 @@
#!perl -w -CAS
# VERSION
-use POSIX qw(setlocale);
-BEGIN {
- if ($^O eq 'MSWin32') {
- require Win32::Locale;
- setlocale POSIX::LC_ALL, Win32::Locale::get_locale();
- } else {
- setlocale POSIX::LC_ALL, '';
- }
-}
+use locale;
use App::Sqitch;
-
exit App::Sqitch->go;
diff --git a/dist.ini b/dist.ini
index 262efdd6..890ef97c 100644
--- a/dist.ini
+++ b/dist.ini
@@ -2,7 +2,7 @@ name = App-Sqitch
license = MIT
copyright_holder = "iovation Inc., David E. Wheeler"
copyright_year = 2012-2023
-version = v1.4.1-dev
+version = v1.4.1
[GatherDir]
exclude_filename = dist/cpanfile
diff --git a/dist/cpanfile b/dist/cpanfile
index 4e19f017..4558297b 100644
--- a/dist/cpanfile
+++ b/dist/cpanfile
@@ -1,4 +1,4 @@
-# This file is generated by Dist::Zilla::Plugin::CPANFile v6.030
+# This file is generated by Dist::Zilla::Plugin::CPANFile v6.031
# Do not edit this file directly. To change prereqs, edit the `dist.ini` file.
requires "Algorithm::Backoff::Exponential" => "0.006";
@@ -54,6 +54,7 @@ requires "URI::QueryParam" => "0";
requires "URI::db" => "0.20";
requires "User::pwent" => "0";
requires "constant" => "0";
+requires "locale" => "0";
requires "namespace::autoclean" => "0.16";
requires "overload" => "0";
requires "parent" => "0";
diff --git a/lib/App/Sqitch/Engine.pm b/lib/App/Sqitch/Engine.pm
index 0c04a316..ea408dbf 100644
--- a/lib/App/Sqitch/Engine.pm
+++ b/lib/App/Sqitch/Engine.pm
@@ -313,11 +313,10 @@ sub revert {
hurl revert => __('Missing required parameter $prompt_default')
unless defined $prompt_default;
} else {
- warnings::warnif(
- "deprecated",
- "Engine::revert() requires the `prompt` and `prompt_default` arguments.\n"
- . 'Omitting them will become fatal in a future release.',
- );
+ warnings::warnif(deprecated => join ("\n",
+ "Engine::revert() requires the `prompt` and `prompt_default` arguments.",
+ 'Omitting them will become fatal in a future release.',
+ ));
$prompt = !($self->no_prompt // 0);
$prompt_default = $self->prompt_accept // 1;
diff --git a/po/de_DE.po b/po/de_DE.po
index 6eceb8bc..b561564c 100644
--- a/po/de_DE.po
+++ b/po/de_DE.po
@@ -10,7 +10,7 @@ msgstr ""
"POT-Creation-Date: 2023-07-30 20:02-0400\n"
"PO-Revision-Date: 2012-08-31 17:15-0700\n"
"Last-Translator: Thomas Iguchi <ti@nobu-games.com>\n"
-"Language-Team: German <david@justatheory.com>\n"
+"Language-Team: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
diff --git a/po/fr_FR.po b/po/fr_FR.po
index dc2502ea..283ceb1b 100644
--- a/po/fr_FR.po
+++ b/po/fr_FR.po
@@ -10,7 +10,7 @@ msgstr ""
"POT-Creation-Date: 2023-07-30 20:02-0400\n"
"PO-Revision-Date: 2012-10-12 11:28-0700\n"
"Last-Translator: Arnaud Assad <arhuman@gmail.com>\n"
-"Language-Team: French <arhuman@gmail.com>\n"
+"Language-Team: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
diff --git a/po/it_IT.po b/po/it_IT.po
index 6b716b1c..14a586a3 100644
--- a/po/it_IT.po
+++ b/po/it_IT.po
@@ -10,7 +10,7 @@ msgstr ""
"POT-Creation-Date: 2023-07-30 20:02-0400\n"
"PO-Revision-Date: 2017-10-12 10:30+0200\n"
"Last-Translator: Luca Ferrari <fluca1978@gmail.com>\n"
-"Language-Team: Italian <fluca1978@gmail.com>\n"
+"Language-Team: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
"Language: it_IT\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
diff --git a/t/sqitch b/t/sqitch
index 350e7fc0..cf9171dc 100755
--- a/t/sqitch
+++ b/t/sqitch
@@ -1,14 +1,6 @@
#!/usr/bin/env perl -CAS
-use POSIX qw(setlocale);
-BEGIN {
- if ($^O eq 'MSWin32') {
- require Win32::Locale;
- setlocale POSIX::LC_ALL, Win32::Locale::get_locale();
- } else {
- setlocale POSIX::LC_ALL, '';
- }
-}
+use locale;
use FindBin;
use lib "$FindBin::Bin/../lib";
use App::Sqitch;
diff --git a/xt/locale/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo b/xt/locale/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo
new file mode 100644
index 00000000..3c1b7c0e
--- /dev/null
+++ b/xt/locale/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo
Binary files differ
diff --git a/xt/locale/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo b/xt/locale/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo
new file mode 100644
index 00000000..5ff3b87c
--- /dev/null
+++ b/xt/locale/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo
Binary files differ
diff --git a/xt/locale/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo b/xt/locale/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo
new file mode 100644
index 00000000..bbb9aa09
--- /dev/null
+++ b/xt/locale/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo
Binary files differ
diff --git a/xt/locale/README.md b/xt/locale/README.md
new file mode 100644
index 00000000..bc5900eb
--- /dev/null
+++ b/xt/locale/README.md
@@ -0,0 +1,43 @@
+Sqitch Locale Test
+==================
+
+This directory contains the files necessary to test the Sqitch CLI to ensure it
+properly detects locale settings and emits translated messages. The
+[`po` directory here](./po/), unlike the canonical translations in the root `po`
+directory, contains only a few languages translating a single message:
+
+```
+"{command}" is not a valid command
+```
+
+The `LocaleData` directory contains the compiled forms of these dictionaries,
+and unlike the main dictionaries, these are committed to the repository. This
+allows the [OS](.github/workflows/os.yml) and [Perl](.github/workflows/os.yml)
+workflows to run without the overhead of compiling them (a PITA since `gettext`
+is hard to get on Windows and Dist::Zilla supports only more recent versions of
+Perl). If the messages need to change, recompile the dictionaries with these
+commands:
+
+```sh
+cpanm Dist::Zilla --notest
+dzil authordeps --missing | cpanm --notest
+dzil msg-compile -d xt/locale xt/locale/po/*.po
+```
+
+For errors where it can't find `msgformat` or `gettext`, be sure that [gettext]
+is installed (readily available via `apt-get`, `yum`, or `brew`).
+
+Now run the test, which validates the output from [`bin/sqitch`](bin/sqitch):
+
+```sh
+prove -lv xt/locale/test-cli.t
+```
+
+If tests fail, be sure each of the locales is installed on your system.
+Apt-based systems, for example, require the relevant language packs:
+
+```sh
+sudo apt-get install -qq language-pack-fr language-pack-en language-pack-de language-pack-it
+```
+
+ [gettext]: https://www.gnu.org/software/gettext/
diff --git a/xt/locale/po/de_DE.po b/xt/locale/po/de_DE.po
new file mode 100644
index 00000000..1f1cabee
--- /dev/null
+++ b/xt/locale/po/de_DE.po
@@ -0,0 +1,12 @@
+msgid ""
+msgstr ""
+"Language: de\n"
+"Project-Id-Version: Sqitch 1.4.1\n"
+"PO-Revision-Date: 22024-01-06T21:10:06Z\n"
+"Last-Translator: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
+"Language-Team: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+msgid "\"{command}\" is not a valid command"
+msgstr "\"{command}\" ist ein ungültiger Befehl"
diff --git a/xt/locale/po/fr_FR.po b/xt/locale/po/fr_FR.po
new file mode 100644
index 00000000..17df5618
--- /dev/null
+++ b/xt/locale/po/fr_FR.po
@@ -0,0 +1,12 @@
+msgid ""
+msgstr ""
+"Language: fr\n"
+"Project-Id-Version: Sqitch 1.4.1\n"
+"PO-Revision-Date: 22024-01-06T21:10:06Z\n"
+"Last-Translator: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
+"Language-Team: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+msgid "\"{command}\" is not a valid command"
+msgstr "\"{command}\" n'est pas une commande valide"
diff --git a/xt/locale/po/it_IT.po b/xt/locale/po/it_IT.po
new file mode 100644
index 00000000..0edd5753
--- /dev/null
+++ b/xt/locale/po/it_IT.po
@@ -0,0 +1,12 @@
+msgid ""
+msgstr ""
+"Language: it\n"
+"Project-Id-Version: Sqitch 1.4.1\n"
+"PO-Revision-Date: 22024-01-06T21:10:06Z\n"
+"Last-Translator: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
+"Language-Team: Sqitch Hackers <sqitch-hackers@googlegroups.com>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+msgid "\"{command}\" is not a valid command"
+msgstr "\"{command}\" non è un comando valido"
diff --git a/xt/locale/test-cli.t b/xt/locale/test-cli.t
new file mode 100644
index 00000000..ec666b2f
--- /dev/null
+++ b/xt/locale/test-cli.t
@@ -0,0 +1,57 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+use Test::More tests => 4;
+use File::Spec;
+use Capture::Tiny qw(:all);
+
+# Requires xt/locale/LocaleData; see xt/locale/README.md for details.
+my @cli = (qw(-Ilib -CAS -Ixt/locale), File::Spec->catfile(qw(bin sqitch)));
+
+# Windows has its own locale names for some reason.
+# https://stackoverflow.com/q/77771097/79202
+my %lang_for = (
+ "en_US" => 'English_United States.1252',
+ "fr_FR" => 'French_France.1252',
+ "de_DE" => 'German_Germany.1252',
+ "it_IT" => 'Italian_Italy.1252',
+);
+
+# Other supported OSes just use the code name.
+if ($^O ne 'MSWin32') {
+ $lang_for{$_} = "$_.UTF-8" for keys %lang_for;
+}
+
+# Each locale must be installed on the local system. Adding a new lang? Also add
+# the relevant language-pack-XX package to os.yml and perl.yml.
+for my $tc (
+ { lang => 'en_US', err => q{"nonesuch" is not a valid command} },
+ { lang => 'fr_FR', err => q{"nonesuch" n'est pas une commande valide} },
+ { lang => 'de_DE', err => q{"nonesuch" ist ein ungültiger Befehl} },
+ { lang => 'it_IT', err => q{"nonesuch" non è un comando valido} },
+) {
+ subtest $tc->{lang} || 'default' => sub {
+ local $ENV{LC_ALL} = $lang_for{$tc->{lang}};
+
+ # Test successful run.
+ my ($stdout, $stderr, $exit) = capture { system $^X, @cli, 'help' };
+ is $exit >> 8, 0, 'Should have exited normally';
+ like $stdout, qr/\AUsage\b/, 'Should have usage statement in STDOUT';
+ is $stderr, '', 'Should have no STDERR';
+
+ # Test localized error.
+ ($stdout, $stderr, $exit) = capture { system $^X, @cli, 'nonesuch' };
+ is $exit >> 8, 2, 'Should have exit val 2';
+ is $stdout, '', 'Should have no STDOUT';
+ TODO: {
+ # The Windows locales don't translate into the language codes
+ # recognized by Locale::TextDomain/gettext. Not at all sure how to
+ # fix this. Some relevant notes in the FAQ:
+ # https://metacpan.org/dist/libintl-perl/view/lib/Locale/libintlFAQ.pod#How-do-I-switch-languages-or-force-a-certain-language-independently-from-user-settings-read-from-the-environment?
+ local $TODO = $^O eq 'MSWin32' ? 'localization fails on Windows' : '';
+ like $stderr, qr/\A\Q$tc->{err}/,
+ 'Should have localized error message in STDERR';
+ }
+ };
+}