Compare commits

...
18 Commits
Author SHA1 Message Date
Ku6epXBOCTuK 1d259feae6 docs: update readme 2026-09-07 17:22:34 +05:00
Ku6epXBOCTuK d194890315 fix: rename env variables (remained after the project was renamed) 2026-09-07 02:46:44 +05:00
Ku6epXBOCTuK 97eea5e064 feat: simplify config toml, add local after commands 2026-09-07 02:43:23 +05:00
Ku6epXBOCTuK eca349a706 feat: edit config command 2026-09-03 18:36:21 +05:00
Ku6epXBOCTuK 95c88fe5d9 refactor: fix clippy warnings 2026-09-03 10:27:40 +05:00
Ku6epXBOCTuK 6ad1720fcb feat: add branch - config and workflow 2026-09-03 08:33:45 +05:00
Ku6epXBOCTuK 973d348628 feat: add plan and backlog 2026-09-03 08:23:46 +05:00
Ku6epXBOCTuK 097b68c39d feat: update app with new commands flow 2026-08-29 18:54:38 +05:00
Ku6epXBOCTuK 9979af9702 docs: update docs, archive old 2026-08-29 17:04:01 +05:00
Ku6epXBOCTuK 1a2bb73b62 chore: rename app 2026-08-29 14:18:47 +05:00
Ku6epXBOCTuK ec98d30b31 fix: add short names for commands 2026-08-28 08:00:00 +05:00
Ku6epXBOCTuK e099995f42 chore: change exe name to short xd 2026-08-28 00:29:43 +05:00
Ku6epXBOCTuK 608dc5f624 feat: add deploy from current work directory command 2026-08-25 11:08:11 +05:00
Ku6epXBOCTuK 5126e10e9d feat: add base dir to server 2026-08-23 08:33:39 +05:00
Ku6epXBOCTuK 31bbeca941 feat: add interactive pick project flow 2026-08-23 08:08:21 +05:00
Ku6epXBOCTuK de9b6ad0dd feat: add list servers command 2026-08-23 07:42:41 +05:00
Ku6epXBOCTuK 0374af34fa feat: add servers description 2026-08-23 07:31:48 +05:00
Ku6epXBOCTuK beab08cf67 fix: add ansi support and remove emoji 2026-08-23 07:19:30 +05:00
15 changed files with 2106 additions and 127 deletions
Generated
+121 -1
View File
@@ -346,6 +346,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "console"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@@ -524,6 +536,19 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "dialoguer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
dependencies = [
"console",
"fuzzy-matcher",
"shell-words",
"tempfile",
"zeroize",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.11.3" version = "0.11.3"
@@ -620,6 +645,21 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "enable-ansi-support"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea7457668b3da8a4b702f3d79e131aa3e81cd7e81cc95fb2d54fce9f182ecc77"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "enum_dispatch" name = "enum_dispatch"
version = "0.3.13" version = "0.3.13"
@@ -638,6 +678,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "ff" name = "ff"
version = "0.14.0" version = "0.14.0"
@@ -758,6 +814,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.9" version = "0.14.9"
@@ -1020,6 +1085,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1587,6 +1658,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -1752,6 +1836,12 @@ dependencies = [
"sponge-cursor", "sponge-cursor",
] ]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -1919,6 +2009,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
@@ -1939,6 +2042,15 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.1"
@@ -2030,6 +2142,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "universal-hash" name = "universal-hash"
version = "0.6.1" version = "0.6.1"
@@ -2354,12 +2472,14 @@ dependencies = [
] ]
[[package]] [[package]]
name = "xboctploy" name = "xboct-deploy"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"colored", "colored",
"dialoguer",
"enable-ansi-support",
"home", "home",
"russh", "russh",
"russh-sftp", "russh-sftp",
+8 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "xboctploy" name = "xboct-deploy"
version = "0.1.0" version = "0.1.0"
description = "Lightweight CLI to deploy hobby projects from a local PC to a VPS over SSH" description = "Lightweight CLI to deploy hobby projects from a local PC to a VPS over SSH"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
repository = "https://xboct-git.duckdns.org/Ku6epXBOCTuK/xboctploy" repository = "https://xboct-git.duckdns.org/Ku6epXBOCTuK/xboct-deploy"
keywords = ["deploy", "ssh", "sftp", "vps", "cli"] keywords = ["deploy", "ssh", "sftp", "vps", "cli"]
categories = ["command-line-utilities", "development-tools"] categories = ["command-line-utilities", "development-tools"]
rust-version = "1.88" rust-version = "1.88"
@@ -14,6 +14,8 @@ edition = "2024"
anyhow = "1.0.104" anyhow = "1.0.104"
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
colored = "3.1.1" colored = "3.1.1"
dialoguer = { version = "0.12.0", features = ["fuzzy-select"] }
enable-ansi-support = "0.3.1"
home = "0.5.12" home = "0.5.12"
russh = { version = "0.62.7", default-features = false, features = [ russh = { version = "0.62.7", default-features = false, features = [
"ring", "ring",
@@ -26,3 +28,7 @@ ssh2-config = "0.7.2"
tokio = { version = "1.53.1", features = ["rt", "time", "io-util"] } tokio = { version = "1.53.1", features = ["rt", "time", "io-util"] }
toml = "1.1.4" toml = "1.1.4"
walkdir = "2.5.0" walkdir = "2.5.0"
[[bin]]
name = "xd"
path = "src/main.rs"
+113 -35
View File
@@ -1,4 +1,4 @@
# xboctploy # xboct-deploy
Легковесная CLI-утилита на Rust для централизованного деплоя хобби-проектов Легковесная CLI-утилита на Rust для централизованного деплоя хобби-проектов
(Node.js, Rust) с локального ПК на слабую VPS по SSH. (Node.js, Rust) с локального ПК на слабую VPS по SSH.
@@ -14,60 +14,134 @@
cargo install --path . cargo install --path .
``` ```
Требуется Rust 1.88+. Кроссплатформенно: Windows (MSVC), Linux, macOS. Бинарник называется **`xd`**. Требуется Rust 1.88+. Кроссплатформенно:
Windows (MSVC), Linux, macOS.
## Быстрый старт ## Команды
```sh ```sh
xboctploy --list # показать проекты из конфига xd list # показать проекты из конфига
xboctploy --dry-run demo # напечатать план деплоя без выполнения xd list-servers # показать серверы из конфига
xboctploy demo # задеплоить проект demo xd pick # интерактивно выбрать проект (нечёткий поиск)
xd deploy my-node-app # задеплоить проект my-node-app
xd deploy # задеплоить проект текущей папки (workdir == cwd)
xd --dry-run deploy my-node-app # напечатать план деплоя без выполнения
xd edit-config # открыть глобальный конфиг в $EDITOR
xd # статус: какой проект соответствует текущей папке
# (вне проекта — список проектов из конфига)
``` ```
Вывод: Глобальные флаги:
```txt ```txt
deploying 'demo' -c, --config <PATH> явный путь к конфигу (работает и внутри подкоманд)
🟢 [Local] running: npm run build... Success! (1.2s) -g, --global использовать только глобальный конфиг (конфликтует с -c)
🔵 [SFTP] transferring dist to /var/www/pages... Done! (14 file(s), 231.5 KiB) (829ms) --dry-run печать плана без выполнения; НЕ глобальный — ставится
🟡 [Remote] running: pm2 restart pages... Success! (102ms) до подкоманды: `xd --dry-run deploy name`
🎉 Deploy successful! (total 2.3s)
``` ```
`xd deploy` без имени ищет проект, чей `workdir` совпадает с текущей папкой,
и падает с ошибкой, если такого нет. `xd pick` требует реальный терминал —
в скриптах используйте `xd list`.
Вывод деплоя:
```txt
[Git] on branch 'main' ✓
deploying 'my-node-app'
[Local] running: npm run build... Success! (1.2s)
connecting...
[SFTP] transferring dist to /var/www/pages/my-node-app... Done! (14 file(s), 231.5 KiB) (829ms)
[Remote] running: pm2 restart pages... Success! (102ms)
[LocalAfter] running: curl -s -X POST https://example.com/hook... Success! (45ms)
Deploy successful! (total 2.3s)
```
Строка `[Git]` появляется только при заданном `branch`, `connecting...` — перед
первым SSH-шагом (sync/remote), `[LocalAfter]` — шаги `local_after`.
## Конфигурация ## Конфигурация
Файл ищется по цепочке: Файл ищется по цепочке:
1. `--config PATH`, если указан явно; 1. `-c/--config PATH`, если указан явно (конфликтует с `-g`);
2. `./deploy.toml` в текущей папке — удобно держать рядом с проектом и в тестах; 2. `./deploy.toml` в текущей папке — удобно держать рядом с проектом и в тестах;
3. глобальный `~/.config/xboctploy/deploy.toml` — запуск «из любой папки». 3. глобальный `~/.config/xboct-deploy/deploy.toml` — запуск «из любой папки».
Схема: Флаг `-g/--global` исключает шаг 2: используется только глобальный конфиг.
Деплой проекта текущей папки (`xd deploy` без имени) ищет конфиг по этой же
цепочке, так что временный локальный `./deploy.toml` переопределяет глобальный —
создали файл, задеплоились, удалили.
Схема — **один проект = один блок**, все параметры точечными ключами:
```toml
[servers.my-vps] # профиль сервера: описывается один раз
host = "195.0.2.10" # алиас из ~/.ssh/config или IP
port = 2222
user = "deploy"
key_path = "~/.ssh/id_ed25519"
base_dir = "/var/www/pages" # корень для относительных sync target
[projects.my-node-app]
server = "my-vps" # ссылка на профиль SSH
workdir = 'C:\code\my-node-app' # где выполнять локальные команды (~ разворачивается)
branch = "main" # опционально: защита от деплоя чужой ветки
env.PUBLIC_BASE_PATH = "/my-node-app" # переменные для локальной сборки
local.commands = ["npm ci", "npm run build"] # шаги сборки на вашей машине
sync = "dist" # залить dist -> <base_dir>/my-node-app
remote.commands = ["pm2 restart pages"] # команды управления по SSH
```
### Поле `sync`
Три формы — выбирай по смыслу:
```toml
sync = "dist" # один источник; target = имя проекта
sync = ["dist", "static"] # несколько источников; target = имя проекта
sync = [{ source = "web/build", target = "/opt/static" }] # полные правила
```
Во всех формах `target` без `/` на конце резолвится через `base_dir` сервера
(`"my-node-app"``/var/www/pages/my-node-app`). Абсолютный target
(`"/opt/static"`) используется как есть. Отдельных блоков `[[...sync]]`,
`[projects.x.env]`, `[projects.x.local]`, `[projects.x.remote]` больше нет —
это не нужно помнить и копировать, всё в блоке проекта.
Проект может задать параметры подключения и напрямую (`host`, `user`, `port`,
`key_path` вместо `server`) — тогда они имеют приоритет над профилем.
Приоритет параметров SSH: проект → `[servers.<name>]``~/.ssh/config` → дефолт.
### Локальные команды после деплоя (`local_after`)
Выполняются на вашей машине **после** sync + remote и **всегда** — и при
успехе, и после любого упавшего шага (deploy всё равно упадёт). Для очистки
артефактов или вебхука с результатом:
```toml ```toml
[projects.my-node-app] [projects.my-node-app]
workdir = "~/code/my-node-app" # где выполнять локальные команды (~ разворачивается) server = "my-vps"
host = "my-vps" # алиас из ~/.ssh/config или IP workdir = 'C:\code\my-node-app'
user = "deploy" # опционально; иначе из ~/.ssh/config или юзер ОС local.commands = ["npm run build"]
port = 2222 # опционально; иначе из ~/.ssh/config, иначе 22 sync = "dist"
key_path = "~/.ssh/id_ed25519" # опционально; иначе identity_file из ssh-config, remote.commands = ["pm2 restart pages"]
# иначе ~/.ssh/id_rsa
[projects.my-node-app.local] # шаги сборки на вашей машине [projects.my-node-app.local_after]
commands = ["npm ci", "npm run build"] commands = [
'del /q dist',
[projects.my-node-app.env] # переменные для локальных команд сборки 'curl -s -X POST https://example.com/hook -d "result=%XD_DEPLOY_RESULT%"',
PUBLIC_BASE_PATH = "/my-node-app" ]
[[projects.my-node-app.sync]] # что заливать на сервер
source = "dist" # файл или папка (относительно workdir)
target = "/var/www/pages"
[projects.my-node-app.remote] # команды управления на сервере
commands = ["pm2 restart pages"]
``` ```
Приоритет параметров SSH: deploy.toml → `~/.ssh/config` → дефолт. Внутри доступны переменные (в cmd — `%VAR%`, в sh — `$VAR`):
- `XD_DEPLOY_RESULT``success` либо `failed`;
- `XD_ERROR` — текст ошибки (только при `failed`).
Эти же переменные доступны в `--dry-run`, где показываются в плане без
выполнения.
### Windows-нюанс TOML ### Windows-нюанс TOML
@@ -112,5 +186,9 @@ export DB_URL=postgres://localhost/app
```sh ```sh
just test # fmt + clippy -D warnings + cargo test just test # fmt + clippy -D warnings + cargo test
just run # деплой из test/deploy.toml (в git не входит) just build # cargo build
just install # cargo install --path . (бинарник xd)
just run # деплой проекта github-tracker из test/deploy.toml (в git не входит)
``` ```
Схема деплоя и планы — в `docs/`, текущие задачи — в `backlog.md`.
+14
View File
@@ -0,0 +1,14 @@
# Backlog
## Validate config команда
Добавить команду `xd validate` — проверка конфигурации на ошибки и потенциальные проблемы.
### Что проверять
- Одинаковые имена проектов (сейчас serde откажет, но стоит выводить понятное сообщение)
- Одинаковые `workdir` + `branch` комбинации у разных проектов
- Дублирующиеся проекты с одинаковыми deploy путями (одна папка, одна ветка, разные имена)
- Ветки, указанные в конфиге, которые не существуют в git репозитории
- Reachability серверов (resolve host, проверить SSH config)
- Sync target пути, которые выглядят подозрительно
+167
View File
@@ -0,0 +1,167 @@
# План: Deploy разных git веток из одной папки
## Проблема
Сейчас проекты идентифицируются по `workdir` — два проекта в одной папке не работают корректно. Нет интеграции с git — нельзя деплоить конкретную ветку или удостовериться, что деплоится та ветка, которая задумана.
## Статус / подход
**Итеративная разработка.** Реализуем минимальный полезный шаг, пользуемся утилитой вживую, и только по мере реальных потребностей добавляем следующее.
Всё, что **не** реализовано сейчас (worktree, stash, `.env` sync и прочие edge cases), вынесено в отдельный файл `docs/git-branch-roadmap.md` — там детальная проработка, но **только для обсуждения**, на будущее. Сюда смотрим, когда понадобится следующий шаг.
---
## Шаг 1 (РЕАЛИЗУЕМ сейчас): поле `branch` + проверка совпадения при деплое
### Решение
Добавить опциональное поле `branch` в проект. Поведение при деплое:
- `branch` **не задан** → деплоим как раньше (полная совместимость, ничего не проверяем).
- `branch` **задан**:
- определить текущую git ветку в `workdir`;
- текущая **совпадает** с `branch` → деплоим напрямую;
- текущая **не совпадает****abort** с внятным сообщением.
Никаких worktree, stash, авто-переключений. Только безопасная проверка перед тем, как что-то улетит на прод.
> В подсказках пользователю используем `git switch`.
### Почему именно так
- Устраняет главный риск: деплой не той ветки, чем задумано (например, забыли переключиться с `main` на `hotfix`).
- Нулевая вероятность потери данных — мы ничего не двигаем, не переключаем, не stash'им.
- Минимум кода, ничего не ломает для существующих конфигов.
### Конфиг
```toml
[projects.my-app]
workdir = 'C:\projects\my-app'
branch = "staging"
```
Поле `branch` опционально. Без него — работает как раньше.
### UX
```bash
cd C:\projects\my-app # сейчас на ветке main, в конфиге branch = "staging"
xd deploy
# → error: project 'my-app' is on branch 'main', expected 'staging'.
# Switch branches: git switch staging
# Or deploy a different project.
```
### Изменения по файлам
#### 1. `src/config.rs` — поле `branch` (+валидация)
Добавить в struct `Project` опциональное строковое поле `branch`.
В `Config::validate` (рядом с остальными проверками проекта) добавить: если `branch` задан — отклонить пустую/пробельную строку (`.trim().is_empty()`). Также `.trim()` при сравнении в `deploy()` — чтобы `branch = " staging "` в конфиге корректно матчился с `staging` из git.
Note: валидацию «workdir — git repo» здесь **не** делаем (это рантайм-проверка, зависит от состояния машины, а не от конфига) — она в `deploy()`. В `validate` только синтаксическая проверка ветки.
#### 2. `src/git.rs` — НОВЫЙ МОДУЛЬ
Обёртка над git CLI через `std::process::Command`.
Одна функция `current_branch(workdir) -> Result<Option<String>>` — вызывает `git rev-parse --abbrev-ref HEAD` и возвращает:
- `Ok(Some(имя))` — обычная ветка;
- `Ok(Some("HEAD"))` — detached HEAD;
- `Ok(None)` — не git repo (не внутри рабочего дерева);
- `Err` — git реально упал (битый `.git` **или** `git` не установлен в PATH).
> **Почему `Option`, а не только имя:** `Ok(None)` = не git-репозиторий — это **не** ошибка сама по себе (её осмысляет вызывающий), а `Err` = git действительно сломался. Так семантика однозначна и не путает detached (`"HEAD"`).
>
> **`git` не установлен:** `Command::new("git")` упадёт с ошибкой ОС ( NotFound), которая попадёт в `Err`. Это **должно** отличаться от `None` — см. п.2 в deploy() ниже.
#### 3. `src/deploy.rs` — проверка ветки перед деплоем
В самом начале `deploy()`**строго до** ветки `dry_run` и до раннего выхода «nothing to do», чтобы dry-run тоже валидировал ветку. Алгоритм (только если `branch` задан в конфиге):
1. Вызвать `current_branch(workdir)` и обработать ошибку.
2. `Ok(None)` (не git repo) → abort с текстом: «project X has branch Y set, но workdir не git-репозиторий».
3. `Err` (git не установлен) → abort с текстом: «project X has branch Y set, но `git` не найден в PATH. Установите git: https://git-scm.com/downloads».
4. `Err` (битый репо) → abort с текстом: «project X: git repo повреждён (bit .git directory)».
5. `"HEAD"` (detached) → abort с отдельным текстом: «project X в detached HEAD, ожидается ветка Y; вернись на ветку `git switch -c Y`».
6. Имя ≠ `branch` → abort с текстом: «project X на ветке A, ожидается B; переключись `git switch B`».
7. Имя == `branch` → строка `[Git] on branch 'Y' ✓` и продолжаем.
Ключевое поведение:
- detached → abort с точной подсказкой (`git switch -c`), т.к. `git checkout` в detached просто создаст новую ветку;
- `Ok(None)` (не git repo) → abort с понятным текстом;
- git не установлен → отдельный abort с подсказкой установки (а не generic "git failed");
- `git rev-parse` работает и из **подпапки** репо (git поднимается вверх), поэтому `workdir` не обязан быть корнем — два проекта в одной папке увидят одну ветку (корректно).
#### 4. `src/main.rs` — без изменений (проверка внутри `deploy`)
#### 5. `src/cli.rs` — без изменений
### Тесты
#### `src/git.rs`
- `current_branch` возвращает `Some(имя_ветки)` в git репо
- `current_branch` возвращает `Some("HEAD")` при detached HEAD (`git switch --detach`)
- `current_branch` возвращает `Ok(None)` если не git repo
- `current_branch``Err` если `git` падает (например, битый `.git`)
- `current_branch``Err` если `git` не установлен в PATH (模拟: невалидный путь к git)
> **Грабль с тестами — пустой репо.** `git rev-parse --abbrev-ref HEAD` на свежеинициализированном репо **без коммитов** (unborn HEAD) не даёт стабильной ветки (зависит от `init.defaultBranch`/версии git). Поэтому все тесты с репозиторием должны: `git init -b test` + создать минимум **один коммит** до проверки. Существующий `tmpdir()` (`config.rs`) git не инициализирует — новый хелпер нужен отдельный (например, `git_repo(tag)` в `git.rs`, который делает init/commit и чистит за собой).
>
> Зависимость: тесты требуют наличия `git` в PATH окружения. Для этого проекта приемлемо (git и так нужен для работы), но это новая зависимость для CI/машины — зафиксировать явно.
#### `src/deploy.rs`
- Deploy с `branch` == текущая ветка → продолжается
- Deploy с `branch` != текущая ветка → abort с текстом ошибки
- Deploy без `branch` → ничего не проверяет (полная совместимость)
- Deploy с `branch`, workdir не git repo → abort (сообщение «not a git repository»)
- Deploy с `branch`, git не установлен в PATH → abort (сообщение «git not found» + ссылка на установку)
- Deploy с `branch`, в detached HEAD → abort (сообщение «detached HEAD state»)
- Deploy с `branch`, пустая строка → ошибка валидации конфига
- Deploy с `branch`, пробелы в начале/конце (`" staging "`) → trim работает, ветка матчится корректно
- Dry-run с `branch` всё ещё печатает план (после успешной проверки ветки)
### Зависимости
Новых crate не требуется — git вызывается через `std::process::Command`.
---
## Порядок реализации (шаг 1)
1. `src/git.rs` — новый модуль (`current_branch``Result<Option<String>>`)
2. `src/config.rs` — поле `branch` + валидация непустой строки в `Config::validate`
3. `src/deploy.rs` — проверка ветки в начале `deploy()` (обработка `None` / detached / несовпадения)
4. Тесты (в `git.rs` — на реальных репо с коммитом; в `config.rs` — на непустой `branch`)
5. `cargo test` + `cargo clippy`
## Сложности / риски (что нельзя сломать)
- **Обратная совместимость** — главное правило: ветка проверяется **только** если `branch` задан в конфиге. Без `branch` поведение `deploy()` идентично текущему. `deny_unknown_fields` требует добавить поле в struct (сделано), старые конфиги не затрагиваются.
- **Порядок в `deploy()`** — проверка ветки строго в начале, до `dry_run` и «nothing to do»: dry-run обязан валидировать ветку.
- **`workdir` как подпапка репо** — работает, т.к. git поднимается вверх; это фича (не баг), в т.ч. для двух проектов в одном репо.
- **Empty repo в тестах** — без коммита ветки нестабильны; в тестах всегда делать init + commit.
- **Новая зависимость тестов от `git`** в PATH.
- **Detached HEAD** — отдельное сообщение; не путать с несовпадением веток.
---
## Known Limitations
- **TOCTOU (check-then-act)** — проверка ветки и реальный deploy разделены по времени. Ветка может измениться между проверкой и rsync. Это **фундаментальное ограничение** подхода (plan-based guard, не lock). В реальности маловероятно, но документируем явно. Если станет проблемой — следующий шаг (worktree / lock).
- **Bare repositories** — `git rev-parse --abbrev-ref HEAD` работает в bare repos, но поведение может быть неожиданным (ветка HEAD в bare repo может не совпадать с ожидаемой). Bare repos не являются целевым кейсом для шага 1. Если понадобится — отдельная проработка.
- **Submodules** — если `workdir` указывает на subdirectory внутри submodule'а, `git rev-parse` вернёт ветку submodule'а, а не родительского репо. Это корректное поведение git, но может удивить пользователя. Решение: если проект — submodule, branch в конфиге должен указывать ветку submodule'а.
---
## Дальнейшие шаги
Детальная проработка будущих возможностей — в `docs/git-branch-roadmap.md`. Реализуем итеративно, по мере потребностей.
+587
View File
@@ -0,0 +1,587 @@
# Roadmap: git-интеграция деплоя (обсуждение, на будущее)
> Этот файл содержит детальную проработку будущих возможностей git-интеграции.
> Это **НЕ план к реализации** — это материал для обсуждения и осмысления.
> Реализуем итеративно: по мере использования утилиты и реальных потребностей.
>
> Текущий реализованный шаг — поле `branch` + проверка совпадения при деплое (см. `git-branch-deploy-plan.md`).
---
## Общая картина
Сейчас реализован только минимальный шаг (проверка ветки). Дальше, по мере необходимости, можно добавлять:
- **A. Deploy конкретной ветки** через `git worktree` (когда target != текущей).
- **B. `.env` и другие untracked файлы** в worktree (отдельный механизм, не связан со stash).
- **C. CLI `--branch`** флаг (переопределение target).
- **D. Auto-stash** uncommitted changes (по умолчанию — не нужен).
- **E. Lock-файл** против concurrent deploys.
- **F. Прочие edge cases.**
Ниже — детали каждого, чтобы при выборе не начинать с нуля.
---
## UX (будущее)
### Команды
```bash
cd C:\projects\my-app
# Авто-определение по текущей git ветке (если N проектов в папке)
xd deploy
# Переопределение ветки через флаг
xd deploy --branch staging
xd deploy --branch hotfix
# Явное указание имени проекта
xd deploy my-app-production
# С auto-stash при uncommitted изменениях
xd deploy --branch staging --auto-stash
# С сохранением worktree после деплоя (для отладки)
xd deploy --branch staging --keep-worktree
```
### Матрица поведения
| Ситуация | `xd deploy` | `xd deploy --branch X` |
| -------------------------------------- | --------------------------------------------- | --------------------------------- |
| 1 проект в папке, без `branch` | деплоит текущую ветку | deploys X через worktree |
| 1 проект в папке, с `branch` | деплоит branch из конфига | deploys X через worktree |
| N проектов в папке, все с `branch` | деплоит тот, чей `branch` = текущая git ветка | deploys X |
| Uncommitted + нет `--auto-stash` | abort | abort |
| Uncommitted + `--auto-stash` | — | stash → worktree → deploy → pop |
| Branch не существует | — | abort с ошибкой |
| Workdir не git repo, `branch` задан | abort | abort |
| Workdir не git repo, `branch` не задан | деплоит как раньше | — |
| Detached HEAD | abort: "checkout branch first" | abort: "checkout branch first" |
| Worktree уже существует (остаток) | — | пересоздать (remove + create) |
| Stash pop — конфликт | — | stash сохранён, инструкция по pop |
### Цепочка определения "что деплоить"
```txt
1. Имя проекта на CLI? → используем его
2. Нет имени? → find_project_by_cwd():
a. 1 проект с этим workdir? → он
b. N проектов? → текущая git ветка = branch в конфиге?
→ нашли? → деплоим
→ не нашли? → ошибка: "укажите ветку или имя проекта"
3. --branch X на CLI? → переопределяет target branch
4. branch в конфиге? → target branch
5. Ни того ни другого? → деплоим текущую ветку (без worktree)
```
### Worktree trigger
```txt
target branch == текущая git branch → деплоим из workdir напрямую
target branch != текущая git branch → worktree → деплой → cleanup
```
---
## A. Worktree для deploy конкретной ветки
Когда `branch` != текущая ветка — не abort, а собрать ветку через `git worktree` (изолированно, вне репо), задеплоить, почистить.
### Структура worktrees (вне репо)
Все worktree-деплои хранятся **вне** репозитория, на том же уровне, что и сам репо:
```txt
repo/
.git/
src/...
deploy.toml
repo/../.xd-worktrees/ ← общая площадка xd-деплоев (вне репо)
<parent-dir>/ ← имя папки, в которой лежит репо (изоляция от одноимённых репо в разных местах)
<project-name>/ ← изоляция разных проектов из одного workdir
staging/ ← worktree для ветки staging
main/ ← worktree для ветки main
```
Правила:
- Worktree создаётся как `<repo/.git/../..>/.xd-worktrees/<parent-dir>/<project-name>/<branch>`
- Итоговый путь запрашиваем у git / берём из `worktree list --porcelain`, не строим вручную из имени ветки
- `.gitignore` не нужен — main worktree всегда чистый, статус/CI/линтеры не видят мусор
- Если удалить репо — worktree-ветки остаются отдельно и не ломают ничего
### Cleanup при ошибках (WorktreeGuard)
Rust не имеет `finally`. Используем RAII — структуру `WorktreeGuard`, которая удаляет worktree в `Drop`:
```rust
struct WorktreeGuard {
path: Option<PathBuf>,
repo_path: PathBuf,
}
impl Drop for WorktreeGuard {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
// Игнорируем ошибку — не можем сделать ничего если cleanup fails
let _ = git::remove_worktree(&path);
}
}
}
impl WorktreeGuard {
fn new(worktree_path: PathBuf, repo_path: PathBuf) -> Self {
Self { path: Some(worktree_path), repo_path }
}
fn keep(mut self) { self.path = None; } // --keep-worktree
}
```
Использование в deploy:
```rust
let guard = if need_worktree {
Some(WorktreeGuard::new(worktree_path, project.workdir.clone()))
} else {
None
};
// ... deploy logic ...
// Guard автоматически удалит worktree при выходе из scope (включая panic/error)
if let Some(g) = guard {
if opts.keep_worktree { g.keep(); }
}
```
### Новая логика в deploy (Phase 0.5 — перед local commands)
```txt
1. Определить effective_branch:
- target_branch (из CLI --branch)
- или project.branch (из конфига)
- или None (деплоим текущую ветку, без worktree)
2. Если effective_branch задан:
a. Проверить что workdir существует (fs::metadata)
b. Проверить что workdir — git репозиторий (git rev-parse --git-dir)
c. Проверить что workdir/cwd не внутри .xd-worktrees
d. Проверить что ветка существует (git::branch_exists)
→ Если local: ok
→ Если только remote: git::fetch_branch + создать tracking worktree
→ Если не существует: abort
e. Получить текущую ветку (git::current_branch)
→ Если "HEAD" (detached): abort
f. Если текущая == target:
workdir = project.workdir (без worktree)
g. Если текущая != target:
i. Проверить uncommitted (git::has_uncommitted)
→ если есть + нет auto_stash → abort
→ если есть + auto_stash + detached HEAD → abort
→ если есть + auto_stash + ветка → git::stash (с проверкой существующего auto-stash)
ii. Проверить worktree не существует (git::worktree_list_branch)
→ если существует: git::remove_worktree
iii. Создать worktree (git::create_worktree) → вне репо, путь из worktree list
iv. Если есть submodules: git::submodule_update
v. workdir = worktree_path
vi. Создать DeployLock (для защиты от concurrent deploys)
3. Phase 1-4: работают с workdir (обычный или worktree)
4. Cleanup (через Drop — WorktreeGuard + DeployLock):
- WorktreeGuard::drop → git::remove_worktree (если path Some)
- DeployLock::drop → удалить lock file
- Если stash был сделан:
→ Попытаться git::stash_pop (из оригинального workdir)
→ Если конфликт: вывести инструкцию, stash остаётся
```
---
## B. `.env` и другие untracked файлы в worktree
`.env` обычно лежит в `.gitignore` и просто **не существует для git-веток**. При создании worktree из другого branch'а `.env` туда не попадёт → локальный build может упасть.
**Это отдельный механизм, не связанный со stash.** При создании worktree — копировать набор untracked конфиг-файлов из основного workdir в worktree.
```toml
[projects.my-app]
workdir = 'C:\projects\my-app'
branch = "staging"
sync_worktree = [".env", ".env.local"]
```
Почему это не конфликтует со stash:
| Механизм | Что делает | Связь со stash |
| ------------------ | --------------------------------------- | -------------- |
| `.env` копирование | copy untracked конфиг-файлов в worktree | нет |
| Uncommitted stash | tracked-файлы с изменениями разработки | да |
Безопасность:
- `.env` не в git → копируется как есть, ничего не обновляется.
- worktree удаляется после деплоя → копия исчезает.
- Оригинальный `.env` не трогается.
- Если файла нет → просто пропускаем (build может работать и без него).
---
## C. CLI `--branch` флаг
```bash
xd deploy --branch staging # переопределить target branch
xd deploy --branch hotfix
```
Поле `--branch` переопределяет `project.branch`. Если оба заданы и различаются → предупреждение (не abort):
```txt
"Overriding project branch '{config_branch}' with CLI branch '{cli_branch}'"
```
---
## D. Auto-stash uncommitted changes (по умолчанию — НЕ надо)
Uncommitted changes — признак активной разработки. Это **другой контекст**, несовместимый с деплоем другой ветки из этой же папки. Дефолт — **abort при uncommitted**, без авто-stash. `--auto-stash` — опциональный escape-hatch для редких случаев.
Связанные ограничения (чтобы не потерять данные):
- **Не stash'ить в detached HEAD** — stash в detached HEAD не привязан к ветке; при сбое между stash и pop изменения теряют ветку-контекст. Detached HEAD + uncommitted → всегда abort, даже с `--auto-stash`.
- **Проверять существующий auto-stash** перед новым (`git stash list --grep="xd-auto-stash"`) → abort, если найден, с инструкцией pop/drop.
- **При конфликте pop** — stash остаётся в stash list, выводим инструкцию, deploy считается успешным.
- **Stash искать по message, не по индексу** — чтобы индексы не путались между несколькими деплоями.
- **Stash push** должен включать untracked (`--include-untracked`), иначе новые файлы не застешатся.
- **Перед pop** проверять, что не detached HEAD.
---
## E. Lock-файл против concurrent deploys
Защита от двух параллельных деплоев одного проекта.
```txt
Lock file: {temp_dir}/xd/deploy-<project-name>.lock
→ Windows: %TEMP%\xd\deploy-<project-name>.lock
→ Unix: /tmp/xd/deploy-<project-name>.lock
→ При начале деплоя: попробовать создать lock file (create_new(true))
→ Если lock существует:
→ Проверить PID процесса из lock
→ Если процесс жив: abort "Deploy already running (PID {pid})"
→ Если процесс мёртв (stale lock): удалить lock, продолжить
→ При завершении (включая error paths): удалить lock через Drop
```
```rust
struct DeployLock { path: PathBuf }
impl Drop for DeployLock { fn drop(&mut self) { fs::remove_file(&self.path).ok(); } }
```
Важно: lock должен создаваться **до** проверки uncommitted и stash, иначе два разных branch-деплоя одного workdir могут гоняться друг через друга (race).
Известный edge case: PID может быть переиспользован после crash → лучше дополнительно хранить timestamp/hostname или использовать файловый лок (`fs2::File::lock_exclusive`).
---
## F. Прочие edge cases (по мере надобности)
### Detached HEAD
`git rev-parse --abbrev-ref HEAD` в detached HEAD возвращает `HEAD`, а не имя ветки:
```txt
current_branch == "HEAD" →
→ Если target_branch задан: OK, используем worktree
→ Если target_branch не задан: abort с ошибкой
"You are in detached HEAD state.
Run 'git checkout <branch>' or specify --branch <name>"
```
### Workdir не существует / является файлом
```txt
fs::canonicalize(workdir) fails →
→ Abort: "Project workdir does not exist: {path}"
fs::metadata(workdir) → is_file() == true →
→ Abort: "Workdir is a file, not a directory: {path}"
```
### Remote-only ветки
```txt
branch_exists (local) = false, branch_exists (remote) = true →
→ git fetch origin <branch>
→ git worktree add --track origin/<branch> (создаст tracking branch)
→ Если fetch fails: abort "Cannot fetch branch '{branch}' from origin"
```
### Network offline при remote-only branch
```txt
git fetch origin <branch> → ошибка (network, DNS, timeout) →
→ Abort: "Cannot fetch branch '{branch}' from origin.
Check your network connection or deploy from a local branch."
```
### Shallow clone
```txt
git rev-parse --is-shallow-repository → true →
→ Если target branch != текущая:
→ git worktree add может упасть (нет полной истории)
→ Попробовать с --depth 1
→ Если и это падает:
→ abort "Shallow clone detected, cannot create worktree for branch '{branch}'.
Run 'git fetch --unshallow' first, or deploy from current branch."
```
### Submodules
```txt
При создании worktree:
→ Проверить наличие .gitmodules в workdir
→ Если есть: после worktree create → git submodule update --init --recursive
→ Если submodule update падает:
→ warning "Submodule init failed in worktree, deploy continues"
→ Если git не установлен: warning, deploy продолжается
```
### Git LFS
```txt
Если worktree создана для проекта с LFS:
→ После checkout worktree: git lfs pull (если .gitattributes существует)
→ Если git-lfs не установлен: warning, но не abort (deploy продолжается)
```
### Workdir внутри .xd-worktrees (рекурсия)
```txt
workdir (или cwd при авто-определении) содержит "/.xd-worktrees/" сегмент →
→ Abort: "Workdir is inside an .xd-worktrees directory: {path}
Deploying from within a worktree is not supported."
```
Если не проверить → xd может создать worktree внутри уже работающего worktree → вложенность, деплой не туда.
### Sanitization ветки — НЕ НУЖЕН
```txt
Имена веток и пути worktree НЕ санитайзим вручную:
→ git сам валидирует имена через `git check-ref-format` (запрещает * ? < > | " : , . .. и т.д.)
→ `git worktree add <path>` сам вернёт ошибку при невалидном пути
→ Читаем фактический путь из вывода `git worktree list --porcelain`, не конструируем сами
→ Если git вернул ошибку — пробрасываем её as-is (abort с текстом git)
```
Ветки типа `feature/foo-bar` → worktree path: `.xd-worktrees/<project>/feature/foo-bar` — git сам сделает поддиректории.
### Worktree уже существует (остаток)
```txt
→ Проверить existence через git::worktree_list или fs::metadata
→ Если существует:
→ git worktree remove --force (даже если пустой)
→ Создать заново
→ Если remove fails (locked, busy):
→ На Windows: попробовать 2-3 раза с задержкой (файлы могут быть заняты антивирусом)
→ Если всё ещё fails: abort с инструкцией "закрой процессы в worktree и повтори"
```
### Git worktree locked
```txt
git worktree remove --force → exit code != 0, output содержит "locked" →
→ Abort: "Worktree '{path}' is locked by git.
Run 'git worktree unlock {path}' manually, then retry."
```
### Multiple worktrees одной ветки
```txt
git worktree list показывает 2+ worktrees для одной target branch →
→ Abort: "Branch '{branch}' already has {n} worktrees ({paths}).
Remove extras with: git worktree remove <path>"
```
### Stash — существует другой auto-stash
```txt
При stash (до worktree):
→ git stash list --grep="xd-auto-stash" — проверить наличие предыдущего auto-stash
→ Если найден:
→ abort "Previous auto-stash exists (stash@{N}).
Run 'git stash pop stash@{N}' or 'git stash drop stash@{N}' first."
→ При stash pop: искать stash по message, не по индексу
```
---
## Сигнатуры git-функций (для справки при будущей реализации)
| Функция | Сигнатура | Описание |
| ---------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `current_branch` | `(workdir: &Path) -> Result<String>` | `git rev-parse --abbrev-ref HEAD` — вернёт "HEAD" если detached |
| `is_detached_head` | `(workdir: &Path) -> Result<bool>` | `current_branch() == "HEAD"` |
| `has_uncommitted` | `(workdir: &Path) -> Result<bool>` | `git status --porcelain` — не пусто = есть изменения |
| `branch_exists` | `(workdir: &Path, branch: &str) -> Result<bool>` | `git rev-parse --verify <branch>`, fallback на `git ls-remote origin <branch>` |
| `fetch_branch` | `(workdir: &Path, branch: &str) -> Result<()>` | `git fetch origin <branch>` — для remote-only веток |
| `worktree_list` | `(workdir: &Path) -> Result<Vec<PathBuf>>` | `git worktree list --porcelain` — возвращает пути worktrees |
| `create_worktree` | `(workdir: &Path, project: &str, branch: &str) -> Result<PathBuf>` | `git worktree add <repo_parent>/.xd-worktrees/<parent-dir>/<project>/<branch> <branch>`; путь берётся из `worktree list --porcelain` |
| `remove_worktree` | `(path: &Path) -> Result<()>` | `git worktree remove <path> --force` |
| `stash` | `(workdir: &Path) -> Result<bool>` | `git stash push -m "xd-auto-stash"`, возвращает true если что-то застешилось |
| `stash_pop` | `(workdir: &Path) -> Result<()>` | `git stash pop` — может вернуть ошибку при конфликте |
| `is_worktree_clean` | `(path: &Path) -> Result<bool>` | Проверяет что worktree пуст (нет untracked/modified файлов) |
| `is_shallow` | `(workdir: &Path) -> Result<bool>` | `git rev-parse --is-shallow-repository` |
| `worktree_list_branch` | `(workdir: &Path, branch: &str) -> Result<Vec<PathBuf>>` | Фильтрует worktree_list по ветке |
| `has_gitmodules` | `(workdir: &Path) -> bool` | fs::exists(workdir.join(".gitmodules")) |
| `submodule_update` | `(workdir: &Path, worktree: &Path) -> Result<()>` | `git submodule update --init --recursive` в worktree |
| `has_locked_worktree` | `(output: &str) -> bool` | Проверяет stderr/out на наличие "locked" |
| `stash_find_xd` | `(workdir: &Path) -> Result<Option<usize>>` | `git stash list --grep="xd-auto-stash"` → индекс или None |
---
## Проектирование find-логики (будущее, для справки)
```rust
/// Все проекты с workdir == cwd
pub fn find_projects_by_cwd<'a>(cfg: &'a Config, cwd: &Path) -> Vec<&'a str> {
let current = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
cfg.projects.iter()
.filter(|(_, p)| canonicalized(&p.workdir) == current)
.map(|(name, _)| name.as_str())
.collect()
}
/// Выбор проекта: 1 проект → он, N проектов → по branch
pub fn find_project_by_cwd<'a>(
cfg: &'a Config,
cwd: &Path,
cli_branch: Option<&str>,
) -> Result<&'a str> {
let matches = find_projects_by_cwd(cfg, cwd);
match matches.len() {
0 => bail!("no project found in {}", cwd.display()),
1 => Ok(matches[0]),
_ => {
// N проектов — нужна ветка для различения
let target = cli_branch.or_else(|| {
git::current_branch(cwd).ok().as_deref()
});
match target {
Some(branch) => {
let found = matches.iter().find(|name| {
cfg.projects[*name].branch.as_deref() == Some(branch)
});
match found {
Some(name) => Ok(name),
None => bail!(
"no project with branch '{branch}' in {}. Available: {}",
cwd.display(),
matches.iter()
.filter_map(|n| cfg.projects[*n].branch.as_deref())
.collect::<Vec<_>>()
.join(", ")
),
}
}
None => bail!(
"multiple projects in {} — specify --project or --branch. Found: {}",
cwd.display(),
matches.join(", ")
),
}
}
}
}
```
---
## CLI-флаги (будущее, для справки)
```rust
#[derive(Args, Debug)]
pub struct DeployArgs {
pub project_name: Option<String>,
/// Override target branch (uses worktree if not on this branch)
#[arg(short, long)]
pub branch: Option<String>,
/// Auto-stash uncommitted changes instead of aborting
#[arg(long)]
pub auto_stash: bool,
/// Keep worktree after deploy (for debugging)
#[arg(long)]
pub keep_worktree: bool,
}
```
---
## Тесты (будущее, для справки)
### `src/git.rs`
- `current_branch` возвращает имя ветки
- `current_branch` возвращает "HEAD" при detached HEAD
- `has_uncommitted` определяет чистый/грязный repo
- `branch_exists` находит существующую ветку
- `branch_exists` не находит несуществующую ветку
- `branch_exists` находит remote-only ветку
- `fetch_branch` скачивает remote ветку
- `worktree_list` возвращает список worktrees
- `create_worktree` создаёт и возвращает путь
- `create_worktree` создаёт поддиректории для веток с `/`
- `remove_worktree` удаляет worktree
- `is_worktree_clean` определяет чистый/грязный worktree
- `create_worktree` создаёт worktree вне репо (в .xd-worktrees рядом)
- `is_shallow` определяет shallow/non-shallow clone
- `worktree_list_branch` фильтрует worktrees по ветке
- `stash_find_xd` находит существующий xd-auto-stash
- `stash_find_xd` не находит если нет auto-stash
- `has_locked_worktree` определяет locked текст
- `submodule_update` инициализирует submodules в worktree
### `src/config.rs`
- `find_projects_by_cwd` находит все проекты с одинаковым workdir
- `find_project_by_cwd` с 1 проектом — возвращает его
- `find_project_by_cwd` с N проектами + branch — находит нужный
- `find_project_by_cwd` с N проектами без branch — ошибка
- `find_project_by_cwd` с несуществующим workdir — abort
- Парсинг конфига с `branch` полем
### `src/deploy.rs`
- Dry-run показывает worktree шаги
- Dry-run показывает "worktree exists, will be recreated"
- Deploy с worktree создаёт/удаляет worktree
- Deploy без worktree (текущая ветка) — напрямую из workdir
- Auto-stash при uncommitted + --auto-stash
- Abort при uncommitted без --auto-stash
- Worktree guard удаляет worktree при ошибке
- Worktree guard НЕ удаляет worktree при --keep-worktree
- Stash pop при конфликте — stash остаётся, выводится инструкция
- Deploy lock блокирует повторный deploy
- Deploy lock удаляется после завершения
- Abort при shallow clone + другая ветка
- Warning при submodules + submodule update fails
- Abort при workdir = файл
- Abort при workdir внутри .xd-worktrees
- Abort при non-valid имени ветки (ошибка пробрасывается от git as-is)
- Abort при существующем auto-stash в stash list
- Abort при locked worktree
- Предупреждение при CLI --branch переопределяет project.branch
- Abort при detached HEAD + uncommitted (даже с --auto-stash)
- Abort при multiple worktrees одной ветки
- Worktree создаётся вне репо (repo/../.xd-worktrees/<project>/<branch>)
+46
View File
@@ -0,0 +1,46 @@
# 📄 Обновленный UX-манифест
## Определение контекста
- **Папка проекта:** папка, в которой есть `deploy.toml` или путь к которой прописан в глобальном конфиге.
- **Приоритет загрузки конфига:** `--config` $\rightarrow$ файл проекта (`deploy.toml`) $\rightarrow$ глобальный конфиг. Флаг `--global` (`-g`) принудительно оставляет только глобальный конфиг.
---
## 1. Запуск без подкоманд (`xd [FLAGS]`)
Проверка: находимся ли мы в папке проекта?
- **Да** — выводит краткую информацию по текущему проекту и подсказку:
`To deploy current project run 'xd deploy'`
- **Нет** — выводит список проектов из глобального конфига.
---
## 2. Подкоманда `xd deploy [PROJECT_NAME]`
Логика определения цели деплоя:
1. **Если `PROJECT_NAME` указан:** деплоить проект с этим именем из текущего конфига.
2. **Если `PROJECT_NAME` НЕ указан:**
- **Мы в папке проекта** $\rightarrow$ деплоить текущий проект.
- **Мы НЕ в папке проекта** $\rightarrow$ завершить работу с понятной ошибкой и подсказкой:
`Error: Project not found in current directory. Specify PROJECT_NAME or run 'xd pick' / 'xd list'`
---
## 3. Список подкоманд
- **`xd deploy [PROJECT]`** — запустить деплой проекта (текущего или указанного).
- **`xd list`** (алиас: `ls`) — вывести список проектов из текущего конфига.
- **`xd list-servers`** — вывести список серверов из текущего конфига.
- **`xd pick`** — интерактивный выбор проекта из текущего конфига с последующим деплоем.
---
## 4. Глобальные флаги (доступны для любых подкоманд)
- **`-c, --config <FILE>`** — путь к файлу конфигурации.
- **`-g, --global`** — использовать только глобальный конфиг (_конфликтует с `--config_`).
- **`--dry-run`** — прогон без выполнения действий.
+43 -9
View File
@@ -1,21 +1,55 @@
use std::path::PathBuf; use std::path::PathBuf;
use clap::{ArgGroup, Parser}; use clap::{Args, Parser, Subcommand};
/// Deploy hobby projects from a local PC to a VPS over SSH. /// Deploy hobby projects from a local PC to a VPS over SSH.
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(name = "xboctploy", version, about)] #[command(name = "xboct-deploy", version, about)]
#[command(group = ArgGroup::new("target").required(true).args(["project", "list"]))]
pub struct Cli { pub struct Cli {
/// Project name from deploy.toml // Global flags
pub project: Option<String>, /// Use global config only
#[arg(short, long, global = true)]
pub global: bool,
/// Explicit path to the config file /// Explicit path to the config file
#[arg(long, value_name = "PATH")] #[arg(
short,
long,
value_name = "PATH",
global = true,
conflicts_with = "global"
)]
pub config: Option<PathBuf>, pub config: Option<PathBuf>,
/// Print planned steps without executing anything /// Print planned steps without executing anything
#[arg(long)] #[arg(long)]
pub dry_run: bool, pub dry_run: bool,
/// List configured projects and exit
#[arg(long)] #[command(subcommand)]
pub list: bool, pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Run deploy
Deploy(DeployArgs),
/// Interactive select project to deploy
Pick,
/// List all available projects
List,
/// List all available servers
ListServers,
/// Open global config in $EDITOR
EditConfig,
}
#[derive(Args, Debug)]
pub struct DeployArgs {
/// Name of the project to deploy
#[arg(value_name = "PROJECT_NAME")]
pub project_name: Option<String>,
} }
+414 -24
View File
@@ -6,27 +6,43 @@ use anyhow::{Context, Result, bail};
use serde::Deserialize; use serde::Deserialize;
pub const CONFIG_FILE_NAME: &str = "deploy.toml"; pub const CONFIG_FILE_NAME: &str = "deploy.toml";
pub const GLOBAL_DIR: &str = "xboctploy"; pub const GLOBAL_DIR: &str = "xboct-deploy";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Config { pub struct Config {
#[serde(default)]
pub servers: BTreeMap<String, Server>,
pub projects: BTreeMap<String, Project>, pub projects: BTreeMap<String, Project>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Project { pub struct Server {
pub workdir: PathBuf,
pub host: String, pub host: String,
pub base_dir: Option<String>,
pub user: Option<String>, pub user: Option<String>,
pub port: Option<u16>, pub port: Option<u16>,
pub key_path: Option<PathBuf>, pub key_path: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Project {
pub server: Option<String>,
pub host: Option<String>,
pub user: Option<String>,
pub port: Option<u16>,
pub key_path: Option<PathBuf>,
pub workdir: PathBuf,
pub branch: Option<String>,
#[serde(default)] #[serde(default)]
pub env: BTreeMap<String, String>, pub env: BTreeMap<String, String>,
#[serde(default)] #[serde(default)]
pub local: Option<Commands>, pub local: Option<Commands>,
#[serde(default)] #[serde(default)]
pub local_after: Option<Commands>,
#[serde(default, deserialize_with = "de_sync")]
pub sync: Vec<SyncRule>, pub sync: Vec<SyncRule>,
#[serde(default)] #[serde(default)]
pub remote: Option<Commands>, pub remote: Option<Commands>,
@@ -42,20 +58,87 @@ pub struct Commands {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct SyncRule { pub struct SyncRule {
pub source: PathBuf, pub source: PathBuf,
#[serde(default)]
pub target: PathBuf, pub target: PathBuf,
} }
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SyncSpec {
Source(PathBuf),
Sources(Vec<PathBuf>),
Rule(SyncRule),
Rules(Vec<SyncRule>),
}
fn de_sync<'de, D>(deserializer: D) -> Result<Vec<SyncRule>, D::Error>
where
D: serde::de::Deserializer<'de>,
{
Ok(match SyncSpec::deserialize(deserializer)? {
SyncSpec::Source(source) => vec![SyncRule {
source,
target: PathBuf::new(),
}],
SyncSpec::Sources(sources) => sources
.into_iter()
.map(|source| SyncRule {
source,
target: PathBuf::new(),
})
.collect(),
SyncSpec::Rule(rule) => vec![rule],
SyncSpec::Rules(rules) => rules,
})
}
impl Config { impl Config {
pub fn validate(mut self) -> Result<Self> { pub fn validate(mut self) -> Result<Self> {
if self.projects.is_empty() { if self.projects.is_empty() {
bail!("no [projects.*] sections defined"); bail!("no [projects.*] sections defined");
} }
for (name, project) in &mut self.projects { for (server_name, server) in &mut self.servers {
if project.host.trim().is_empty() { if let Some(kp) = &mut server.key_path {
*kp = expand_tilde(kp)
.with_context(|| format!("server '{server_name}': invalid key_path"))?;
}
}
let Self { servers, projects } = &mut self;
for (name, project) in projects.iter_mut() {
if let Some(server_name) = &project.server {
let Some(server) = servers.get(server_name) else {
let known = servers.keys().cloned().collect::<Vec<_>>().join(", ");
bail!("project '{name}': unknown server '{server_name}' (defined: {known})");
};
if project.host.is_none() {
project.host = Some(server.host.clone());
}
if project.user.is_none() {
project.user = server.user.clone();
}
if project.port.is_none() {
project.port = server.port;
}
if project.key_path.is_none() {
project.key_path = server.key_path.clone();
}
}
let Some(host) = &project.host else {
bail!(
"project '{name}': no host (set 'host' or reference a '[servers.*]' profile via 'server')"
);
};
if host.trim().is_empty() {
bail!("project '{name}': host must not be empty"); bail!("project '{name}': host must not be empty");
} }
if let Some(branch) = &mut project.branch
&& branch.trim().is_empty()
{
bail!("project '{name}': branch must not be empty or whitespace");
}
for (phase, cmds) in [ for (phase, cmds) in [
("local", &mut project.local), ("local", &mut project.local),
("local_after", &mut project.local_after),
("remote", &mut project.remote), ("remote", &mut project.remote),
] { ] {
if let Some(c) = cmds if let Some(c) = cmds
@@ -70,9 +153,38 @@ impl Config {
*kp = expand_tilde(kp) *kp = expand_tilde(kp)
.with_context(|| format!("project '{name}': invalid key_path"))?; .with_context(|| format!("project '{name}': invalid key_path"))?;
} }
for rule in &mut project.sync {
if rule.target.as_os_str().is_empty() {
rule.target = PathBuf::from(name);
}
}
for rule in &mut project.sync { for rule in &mut project.sync {
rule.source = expand_tilde(&rule.source) rule.source = expand_tilde(&rule.source)
.with_context(|| format!("project '{name}': invalid sync source"))?; .with_context(|| format!("project '{name}': invalid sync source"))?;
rule.target = expand_tilde(&rule.target)
.with_context(|| format!("project '{name}': invalid sync target"))?;
}
let base_dir = project
.server
.as_ref()
.and_then(|s| servers.get(s))
.and_then(|s| s.base_dir.as_deref());
for rule in &mut project.sync {
if rule.target.to_string_lossy().starts_with('/') {
continue;
}
let Some(base) = base_dir else {
bail!(
"project '{name}': sync target '{}' is relative, but the referenced server has no 'base_dir'",
rule.target.display()
);
};
let rel = rule.target.to_string_lossy().replace('\\', "/");
rule.target = PathBuf::from(format!(
"{}/{}",
base.trim_end_matches('/'),
rel.trim_start_matches('/')
));
} }
for key in project.env.keys() { for key in project.env.keys() {
let valid = !key.is_empty() let valid = !key.is_empty()
@@ -101,16 +213,48 @@ pub fn expand_tilde(path: &Path) -> Result<PathBuf> {
Ok(path.to_path_buf()) Ok(path.to_path_buf())
} }
pub fn display(path: &Path) -> String {
let Some(home) = home::home_dir() else {
return path.display().to_string();
};
let Ok(rest) = path.strip_prefix(&home) else {
return path.display().to_string();
};
format!("~/{}", rest.to_string_lossy().replace('\\', "/"))
}
pub fn global_config_path(home: Option<&Path>) -> Option<PathBuf> {
Some(
home?
.join(".config")
.join(GLOBAL_DIR)
.join(CONFIG_FILE_NAME),
)
}
pub fn global_path(home: Option<&Path>) -> Option<PathBuf> {
let path = global_config_path(home)?;
path.is_file().then_some(path)
}
fn pick_config(cwd: &Path, home: Option<&Path>) -> Option<PathBuf> { fn pick_config(cwd: &Path, home: Option<&Path>) -> Option<PathBuf> {
let local = cwd.join(CONFIG_FILE_NAME); let local = cwd.join(CONFIG_FILE_NAME);
if local.is_file() { if local.is_file() {
return Some(local); return Some(local);
} }
let global = home? global_path(home)
.join(".config") }
.join(GLOBAL_DIR)
.join(CONFIG_FILE_NAME); pub fn find_by_cwd<'a>(cfg: &'a Config, cwd: &Path) -> Option<&'a str> {
global.is_file().then_some(global) let current = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
cfg.projects.iter().find_map(|(name, project)| {
let wd = canonicalized(&project.workdir);
(wd == current).then_some(name.as_str())
})
}
fn canonicalized(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
} }
pub fn resolve(explicit: Option<&Path>) -> Result<PathBuf> { pub fn resolve(explicit: Option<&Path>) -> Result<PathBuf> {
@@ -144,18 +288,12 @@ mod tests {
const VALID: &str = r#" const VALID: &str = r#"
[projects.web] [projects.web]
workdir = "~/code/web"
host = "vps" host = "vps"
workdir = "~/code/web"
[projects.web.local] env.PUBLIC_BASE_PATH = "/web"
commands = ["npm run build"] local.commands = ["npm run build"]
sync = [{ source = "dist", target = "/var/www/web" }]
[[projects.web.sync]] remote.commands = ["pm2 restart web"]
source = "dist"
target = "/var/www/web"
[projects.web.remote]
commands = ["pm2 restart web"]
"#; "#;
#[test] #[test]
@@ -176,6 +314,51 @@ commands = ["pm2 restart web"]
assert!(toml::from_str::<Config>("this is [not toml").is_err()); assert!(toml::from_str::<Config>("this is [not toml").is_err());
} }
#[test]
fn empty_branch_is_rejected() {
let s = r#"
[projects.web]
workdir = "."
host = "vps"
branch = ""
"#;
let err = parse(s).unwrap_err();
assert!(err.to_string().contains("branch"), "{err:#}");
}
#[test]
fn whitespace_branch_is_rejected() {
let s = r#"
[projects.web]
workdir = "."
host = "vps"
branch = " "
"#;
let err = parse(s).unwrap_err();
assert!(err.to_string().contains("branch"), "{err:#}");
}
#[test]
fn non_empty_branch_is_accepted() {
let s = r#"
[projects.web]
workdir = "."
host = "vps"
branch = "staging"
"#;
let cfg = parse(s).unwrap();
assert_eq!(
cfg.projects.get("web").unwrap().branch.as_deref(),
Some("staging")
);
}
#[test]
fn missing_branch_defaults_to_none() {
let cfg = parse(VALID).unwrap();
assert_eq!(cfg.projects.get("web").unwrap().branch, None);
}
#[test] #[test]
fn unknown_field_is_rejected() { fn unknown_field_is_rejected() {
let s = r#" let s = r#"
@@ -193,18 +376,189 @@ hostt = "typo"
assert!(parse(s).is_err()); assert!(parse(s).is_err());
} }
const SERVERS: &str = r#"
[servers.main]
host = "10.0.0.9"
port = 2327
user = "deploy"
key_path = "~/.ssh/xd_key"
[projects.web]
workdir = "."
server = "main"
[projects.api]
workdir = "."
server = "main"
port = 2222
"#;
#[test]
fn server_profile_fills_connection_params() {
let cfg = parse(SERVERS).unwrap();
let web = cfg.projects.get("web").unwrap();
assert_eq!(web.host.as_deref(), Some("10.0.0.9"));
assert_eq!(web.port, Some(2327));
assert_eq!(web.user.as_deref(), Some("deploy"));
let key = web.key_path.as_ref().unwrap();
assert!(key.ends_with("xd_key"), "{}", key.display());
}
#[test]
fn project_fields_override_server_profile() {
let cfg = parse(SERVERS).unwrap();
let api = cfg.projects.get("api").unwrap();
assert_eq!(api.port, Some(2222));
assert_eq!(api.user.as_deref(), Some("deploy"));
}
#[test]
fn unknown_server_is_clear_error() {
let s = r#"
[projects.web]
workdir = "."
server = "nope"
"#;
let err = parse(s).unwrap_err();
assert!(err.to_string().contains("unknown server 'nope'"), "{err:#}");
}
#[test]
fn missing_host_without_server_is_error() {
let s = r#"
[projects.web]
workdir = "."
"#;
let err = parse(s).unwrap_err();
assert!(err.to_string().contains("no host"), "{err:#}");
}
#[test]
fn relative_sync_target_resolves_against_server_base_dir() {
let s = r#"
[servers.main]
host = "10.0.0.9"
base_dir = "/var/www/pages/"
[projects.web]
server = "main"
workdir = "."
sync = [
{ source = "dist", target = "web-app" },
{ source = "dist/favicon.ico", target = "/opt/static/favicon.ico" },
]
"#;
let cfg = parse(s).unwrap();
let sync = &cfg.projects.get("web").unwrap().sync;
assert_eq!(sync[0].target.to_string_lossy(), "/var/www/pages/web-app");
assert_eq!(sync[1].target.to_string_lossy(), "/opt/static/favicon.ico");
}
#[test]
fn relative_sync_target_without_base_dir_is_error() {
let s = r#"
[projects.web]
workdir = "."
host = "10.0.0.9"
sync = [{ source = "dist", target = "web-app" }]
"#;
let err = parse(s).unwrap_err();
assert!(err.to_string().contains("base_dir"), "{err:#}");
}
#[test] #[test]
fn empty_commands_section_is_rejected() { fn empty_commands_section_is_rejected() {
let s = r#" let s = r#"
[projects.web] [projects.web]
workdir = "." workdir = "."
host = "vps" host = "vps"
[projects.web.remote] remote.commands = []
commands = []
"#; "#;
assert!(parse(s).is_err()); assert!(parse(s).is_err());
} }
#[test]
fn short_sync_uses_project_name_as_target() {
let s = r#"
[servers.main]
host = "10.0.0.9"
base_dir = "/var/www/pages"
[projects.web]
server = "main"
workdir = "."
sync = "dist"
"#;
let cfg = parse(s).unwrap();
let rule = &cfg.projects.get("web").unwrap().sync[0];
assert_eq!(rule.source.to_string_lossy(), "dist");
assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web");
}
#[test]
fn sync_source_list_repeats_project_name_as_target() {
let s = r#"
[servers.main]
host = "10.0.0.9"
base_dir = "/var/www/pages"
[projects.web]
server = "main"
workdir = "."
sync = ["dist", "static"]
"#;
let cfg = parse(s).unwrap();
let sync = &cfg.projects.get("web").unwrap().sync;
assert_eq!(sync.len(), 2);
for rule in sync {
assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web");
}
assert_eq!(sync[0].source.to_string_lossy(), "dist");
assert_eq!(sync[1].source.to_string_lossy(), "static");
}
#[test]
fn sync_table_without_target_uses_project_name() {
let s = r#"
[servers.main]
host = "10.0.0.9"
base_dir = "/var/www/pages"
[projects.web]
server = "main"
workdir = "."
sync = [{ source = "web/build" }]
"#;
let cfg = parse(s).unwrap();
let rule = &cfg.projects.get("web").unwrap().sync[0];
assert_eq!(rule.source.to_string_lossy(), "web/build");
assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web");
}
#[test]
fn local_after_parses_and_validates() {
let s = r#"
[projects.web]
workdir = "."
host = "vps"
local_after.commands = ["del /q dist"]
"#;
let cfg = parse(s).unwrap();
let project = cfg.projects.get("web").unwrap();
assert_eq!(
project.local_after.as_ref().unwrap().commands,
vec!["del /q dist".to_string()]
);
let empty = r#"
[projects.web]
workdir = "."
host = "vps"
local_after.commands = []
"#;
assert!(parse(empty).is_err());
}
#[test] #[test]
fn tilde_expands_to_home() { fn tilde_expands_to_home() {
let home = home::home_dir().unwrap(); let home = home::home_dir().unwrap();
@@ -219,9 +573,21 @@ commands = []
); );
} }
#[test]
fn display_abbreviates_home_with_tilde() {
let home = home::home_dir().unwrap();
let inside = home.join(".config").join(GLOBAL_DIR).join(CONFIG_FILE_NAME);
assert_eq!(
display(&inside),
format!("~/.config/{GLOBAL_DIR}/{CONFIG_FILE_NAME}")
);
let outside = home.ancestors().nth(1).unwrap();
assert_eq!(display(outside), outside.display().to_string());
}
fn tmpdir(tag: &str) -> PathBuf { fn tmpdir(tag: &str) -> PathBuf {
let dir = let dir =
std::env::temp_dir().join(format!("xboctploy-tests-{}-{tag}", std::process::id())); std::env::temp_dir().join(format!("xboct-deploy-tests-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap(); fs::create_dir_all(&dir).unwrap();
dir dir
@@ -260,4 +626,28 @@ commands = []
assert_eq!(pick_config(&cwd, Some(&root.join("nope"))), None); assert_eq!(pick_config(&cwd, Some(&root.join("nope"))), None);
let _ = fs::remove_dir_all(root); let _ = fs::remove_dir_all(root);
} }
#[test]
fn finds_project_whose_workdir_matches_cwd() {
let root = tmpdir("find-cwd");
let proj = root.join("proj");
fs::create_dir_all(&proj).unwrap();
let s = format!(
r#"
[projects.web]
workdir = '{}'
host = "vps"
[projects.api]
workdir = '~/code/api'
host = "vps"
"#,
proj.display()
);
let cfg = toml::from_str::<Config>(&s).unwrap().validate().unwrap();
assert_eq!(find_by_cwd(&cfg, &proj), Some("web"));
assert_eq!(find_by_cwd(&cfg, &root.join("other")), None);
let _ = fs::remove_dir_all(root);
}
} }
+297 -25
View File
@@ -1,12 +1,16 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::Result; use anyhow::{Result, bail};
use colored::Colorize; use colored::Colorize;
use crate::config::Project; use crate::config::Project;
use crate::{local, ssh, sync}; use crate::{git, local, ssh, sync};
pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> { pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
if let Some(expected) = &project.branch {
verify_branch(name, project, expected)?;
}
println!("deploying '{name}'"); println!("deploying '{name}'");
let local_cmds = project let local_cmds = project
@@ -17,27 +21,59 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
.remote .remote
.as_ref() .as_ref()
.map_or([].as_slice(), |c| c.commands.as_slice()); .map_or([].as_slice(), |c| c.commands.as_slice());
let local_after_cmds = project
.local_after
.as_ref()
.map_or([].as_slice(), |c| c.commands.as_slice());
if dry_run { if dry_run {
print_plan(project, local_cmds, remote_cmds); print_plan(project, local_cmds, remote_cmds, local_after_cmds);
return Ok(()); return Ok(());
} }
if local_cmds.is_empty() && project.sync.is_empty() && remote_cmds.is_empty() { if local_cmds.is_empty()
println!("nothing to do: no local commands, sync rules or remote commands"); && project.sync.is_empty()
&& remote_cmds.is_empty()
&& local_after_cmds.is_empty()
{
println!(
"nothing to do: no local commands, sync rules, remote commands or post-deploy commands"
);
return Ok(()); return Ok(());
} }
let total = Instant::now(); let total = Instant::now();
let primary = run_deploy_steps(project, local_cmds, remote_cmds);
let after = run_local_after(project, &primary);
match (primary.is_ok(), after.is_ok()) {
(true, true) => {
println!(
"{} (total {})",
"Deploy successful!".green().bold(),
fmt_dur(total.elapsed())
);
Ok(())
}
(false, _) => Err(primary.unwrap_err()),
(true, false) => Err(after.unwrap_err()),
}
}
fn run_deploy_steps(
project: &Project,
local_cmds: &[String],
remote_cmds: &[String],
) -> Result<()> {
for cmd in local_cmds { for cmd in local_cmds {
let step = Instant::now(); let step = Instant::now();
if let Err(err) = local::execute(cmd, &project.workdir, &project.env) { if let Err(err) = local::execute(cmd, &project.workdir, &project.env) {
fail_line("🟢 [Local]", &format!("running: {cmd}..."), err.to_string()); fail_line("[Local]", &format!("running: {cmd}..."), err.to_string());
return Err(err); return Err(err);
} }
success_line( success_line(
"🟢 [Local]", "[Local]",
&format!("running: {cmd}..."), &format!("running: {cmd}..."),
"Success!", "Success!",
step.elapsed(), step.elapsed(),
@@ -59,13 +95,13 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
let label = format!("transferring {} to {}...", rule.source.display(), target); let label = format!("transferring {} to {}...", rule.source.display(), target);
match result { match result {
Ok(stats) => success_line( Ok(stats) => success_line(
"🔵 [SFTP]", "[SFTP]",
&label, &label,
&format!("Done! ({stats})"), &format!("Done! ({stats})"),
step.elapsed(), step.elapsed(),
), ),
Err(err) => { Err(err) => {
fail_line("🔵 [SFTP]", &label, err.to_string()); fail_line("[SFTP]", &label, err.to_string());
return Err(err); return Err(err);
} }
} }
@@ -74,15 +110,11 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
for cmd in remote_cmds { for cmd in remote_cmds {
let step = Instant::now(); let step = Instant::now();
if let Err(err) = session.exec(cmd) { if let Err(err) = session.exec(cmd) {
fail_line( fail_line("[Remote]", &format!("running: {cmd}..."), err.to_string());
"🟡 [Remote]",
&format!("running: {cmd}..."),
err.to_string(),
);
return Err(err); return Err(err);
} }
success_line( success_line(
"🟡 [Remote]", "[Remote]",
&format!("running: {cmd}..."), &format!("running: {cmd}..."),
"Success!", "Success!",
step.elapsed(), step.elapsed(),
@@ -90,35 +122,98 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
} }
} }
println!(
"🎉 {} (total {})",
"Deploy successful!".green().bold(),
fmt_dur(total.elapsed())
);
Ok(()) Ok(())
} }
fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) { fn run_local_after(project: &Project, primary: &Result<()>) -> Result<()> {
let Some(cmds) = &project.local_after else {
return Ok(());
};
let mut env = project.env.clone();
match primary {
Ok(()) => {
env.insert("XD_DEPLOY_RESULT".to_string(), "success".to_string());
}
Err(err) => {
env.insert("XD_DEPLOY_RESULT".to_string(), "failed".to_string());
env.insert("XD_ERROR".to_string(), format!("{err:#}"));
}
}
for cmd in &cmds.commands {
let step = Instant::now();
if let Err(err) = local::execute(cmd, &project.workdir, &env) {
fail_line(
"[LocalAfter]",
&format!("running: {cmd}..."),
err.to_string(),
);
return Err(err);
}
success_line(
"[LocalAfter]",
&format!("running: {cmd}..."),
"Success!",
step.elapsed(),
);
}
Ok(())
}
fn verify_branch(name: &str, project: &Project, expected: &str) -> Result<()> {
let expected = expected.trim();
let current = git::current_branch(&project.workdir)?;
match current.as_deref() {
None => bail!(
"project '{name}' has branch '{expected}' set, but {} is not a git repository",
crate::config::display(&project.workdir)
),
Some("HEAD") => bail!(
"project '{name}' is in detached HEAD state, expected branch '{expected}'; \
return to a working branch with `git switch -c {expected}`"
),
Some(actual) if actual != expected => bail!(
"project '{name}' is on branch '{actual}', expected '{expected}'; \
switch branches with `git switch {expected}`"
),
_ => {
println!("[Git] on branch '{}' ✓", expected.green());
Ok(())
}
}
}
fn print_plan(
project: &Project,
local_cmds: &[String],
remote_cmds: &[String],
local_after_cmds: &[String],
) {
println!("dry-run plan:"); println!("dry-run plan:");
for cmd in local_cmds { for cmd in local_cmds {
println!(" 🟢 [Local] {cmd}"); println!(" [Local] {cmd}");
} }
for rule in &project.sync { for rule in &project.sync {
println!( println!(
" 🔵 [SFTP] {} -> {}", " [SFTP] {} -> {}",
rule.source.display(), rule.source.display(),
rule.target.display() rule.target.display()
); );
} }
for cmd in remote_cmds { for cmd in remote_cmds {
println!(" 🟡 [Remote] {cmd}"); println!(" [Remote] {cmd}");
}
for cmd in local_after_cmds {
println!(" [LocalAfter] {cmd}");
} }
println!("{}", "nothing executed".dimmed()); println!("{}", "nothing executed".dimmed());
} }
fn connect(project: &Project) -> Result<ssh::Session> { fn connect(project: &Project) -> Result<ssh::Session> {
let target = ssh::Target { let target = ssh::Target {
host_alias: project.host.clone(), host_alias: project
.host
.clone()
.expect("host is guaranteed by config validation"),
user: project.user.clone(), user: project.user.clone(),
port: project.port, port: project.port,
key_path: project.key_path.clone(), key_path: project.key_path.clone(),
@@ -155,3 +250,180 @@ fn fmt_dur(d: Duration) -> String {
format!("{:.2}s", d.as_secs_f64()) format!("{:.2}s", d.as_secs_f64())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Commands, Project};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn tmpdir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("xboct-deploy-dep-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
fn git_repo(tag: &str) -> PathBuf {
let dir = tmpdir(tag);
git(&dir, &["init", "-b", "staging"]);
fs::write(dir.join("f.txt"), "hi").unwrap();
git(&dir, &["add", "."]);
git(
&dir,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-m",
"init",
],
);
dir
}
fn project_with_branch(workdir: PathBuf, branch: Option<String>) -> Project {
Project {
workdir,
branch,
server: None,
host: Some("vps".to_string()),
user: None,
port: None,
key_path: None,
env: Default::default(),
local: None,
local_after: None,
sync: Vec::new(),
remote: None,
}
}
fn result_echo_cmd(out: &Path) -> String {
if cfg!(windows) {
format!("echo %XD_DEPLOY_RESULT%> {}", out.display())
} else {
format!("echo $XD_DEPLOY_RESULT > {}", out.display())
}
}
fn fail_cmd() -> String {
if cfg!(windows) {
"exit /b 3".to_string()
} else {
"false".to_string()
}
}
#[test]
fn local_after_runs_on_success_with_result() {
let dir = tmpdir("afterok");
let out = dir.join("result.txt");
let mut project = project_with_branch(dir.clone(), None);
project.local_after = Some(Commands {
commands: vec![result_echo_cmd(&out)],
});
deploy("p", &project, false).unwrap();
let content = fs::read_to_string(&out).unwrap();
assert_eq!(content.trim(), "success");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn local_after_runs_on_failure_with_result() {
let dir = tmpdir("afterfail");
let out = dir.join("result.txt");
let mut project = project_with_branch(dir.clone(), None);
project.local = Some(Commands {
commands: vec![fail_cmd()],
});
project.local_after = Some(Commands {
commands: vec![result_echo_cmd(&out)],
});
let err = deploy("p", &project, false).unwrap_err();
assert!(err.to_string().contains("FAILED"), "{err:#}");
let content = fs::read_to_string(&out).unwrap();
assert_eq!(content.trim(), "failed");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn dry_run_lists_local_after_but_does_not_execute() {
let dir = tmpdir("afterdry");
let out = dir.join("result.txt");
let mut project = project_with_branch(dir.clone(), None);
project.local_after = Some(Commands {
commands: vec![result_echo_cmd(&out)],
});
deploy("p", &project, true).unwrap();
assert!(!out.exists());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn no_branch_deploys_without_check() {
let dir = tmpdir("nobranch");
let project = project_with_branch(dir.clone(), None);
deploy("p", &project, false).unwrap();
let _ = fs::remove_dir_all(dir);
}
#[test]
fn matching_branch_continues() {
let dir = git_repo("match");
let project = project_with_branch(dir.clone(), Some("staging".to_string()));
deploy("p", &project, false).unwrap();
let _ = fs::remove_dir_all(dir);
}
#[test]
fn mismatched_branch_aborts() {
let dir = git_repo("mismatch");
let project = project_with_branch(dir.clone(), Some("main".to_string()));
let err = deploy("p", &project, false).unwrap_err();
assert!(err.to_string().contains("on branch 'staging'"), "{err:#}");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn not_a_git_repo_aborts() {
let dir = tmpdir("norepo");
let project = project_with_branch(dir.clone(), Some("staging".to_string()));
let err = deploy("p", &project, false).unwrap_err();
assert!(err.to_string().contains("not a git repository"), "{err:#}");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn detached_head_aborts() {
let dir = git_repo("detached");
git(&dir, &["switch", "--detach"]);
let project = project_with_branch(dir.clone(), Some("staging".to_string()));
let err = deploy("p", &project, false).unwrap_err();
assert!(err.to_string().contains("detached HEAD"), "{err:#}");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn dry_run_validates_branch() {
let dir = git_repo("drymatch");
let project = project_with_branch(dir.clone(), Some("main".to_string()));
let err = deploy("p", &project, true).unwrap_err();
assert!(err.to_string().contains("on branch 'staging'"), "{err:#}");
let _ = fs::remove_dir_all(dir);
}
}
+114
View File
@@ -0,0 +1,114 @@
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
pub fn current_branch(workdir: &Path) -> Result<Option<String>> {
let out = match Command::new("git")
.current_dir(workdir)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
{
Ok(out) => out,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!("`git` not found in PATH — install git to use the 'branch' check")
}
Err(err) => return Err(err).context("failed to spawn `git`"),
};
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let fail = stderr.trim();
if fail.contains("not a git repository")
|| fail.contains("not inside any git repository")
|| fail.contains("does not appear to be a git repository")
{
return Ok(None);
}
bail!("`git rev-parse` failed: {}", fail);
}
let name = String::from_utf8(out.stdout)
.context("git returned non-UTF-8 output")?
.trim()
.to_string();
if name.is_empty() {
return Ok(None);
}
Ok(Some(name))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn tmpdir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("xboct-deploy-git-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
fn git_repo(tag: &str) -> PathBuf {
let dir = tmpdir(tag);
git(&dir, &["init", "-b", "test"]);
fs::write(dir.join("f.txt"), "hi").unwrap();
git(&dir, &["add", "."]);
git(
&dir,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-m",
"init",
],
);
dir
}
#[test]
fn returns_branch_in_repo() {
let dir = git_repo("branch");
assert_eq!(current_branch(&dir).unwrap().as_deref(), Some("test"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn returns_head_when_detached() {
let dir = git_repo("detached");
git(&dir, &["switch", "--detach"]);
assert_eq!(current_branch(&dir).unwrap().as_deref(), Some("HEAD"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn returns_none_outside_repo() {
let dir = tmpdir("norepo");
assert_eq!(current_branch(&dir).unwrap(), None);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn works_from_subdirectory() {
let dir = git_repo("subdir");
let sub = dir.join("inner");
fs::create_dir_all(&sub).unwrap();
assert_eq!(current_branch(&sub).unwrap().as_deref(), Some("test"));
let _ = fs::remove_dir_all(dir);
}
}
+8 -8
View File
@@ -97,7 +97,7 @@ mod tests {
fn tmpdir(tag: &str) -> PathBuf { fn tmpdir(tag: &str) -> PathBuf {
let dir = let dir =
std::env::temp_dir().join(format!("xboctploy-local-{}-{tag}", std::process::id())); std::env::temp_dir().join(format!("xboct-deploy-local-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap(); fs::create_dir_all(&dir).unwrap();
dir dir
@@ -212,11 +212,11 @@ mod tests {
fn env_vars_visible_inside_local_commands() { fn env_vars_visible_inside_local_commands() {
let workdir = tmpdir("dotenv-exec"); let workdir = tmpdir("dotenv-exec");
let echo_var = if cfg!(windows) { let echo_var = if cfg!(windows) {
"echo %XBP_TEST_VAR%" "echo %XD_TEST_VAR%"
} else { } else {
"echo $XBP_TEST_VAR" "echo $XD_TEST_VAR"
}; };
fs::write(workdir.join(".env"), "XBP_TEST_VAR=hello-env\n").unwrap(); fs::write(workdir.join(".env"), "XD_TEST_VAR=hello-env\n").unwrap();
let mut command = shell_command(echo_var); let mut command = shell_command(echo_var);
command.current_dir(&workdir); command.current_dir(&workdir);
for (k, v) in load_dotenv(&workdir).unwrap() { for (k, v) in load_dotenv(&workdir).unwrap() {
@@ -233,13 +233,13 @@ mod tests {
fn config_env_overrides_dotenv() { fn config_env_overrides_dotenv() {
let workdir = tmpdir("dotenv-precedence"); let workdir = tmpdir("dotenv-precedence");
let echo_var = if cfg!(windows) { let echo_var = if cfg!(windows) {
"echo %XBP_P_VAR%" "echo %XD_P_VAR%"
} else { } else {
"echo $XBP_P_VAR" "echo $XD_P_VAR"
}; };
fs::write(workdir.join(".env"), "XBP_P_VAR=from-dotenv\n").unwrap(); fs::write(workdir.join(".env"), "XD_P_VAR=from-dotenv\n").unwrap();
let mut extra = BTreeMap::new(); let mut extra = BTreeMap::new();
extra.insert("XBP_P_VAR".to_string(), "from-config".to_string()); extra.insert("XD_P_VAR".to_string(), "from-config".to_string());
let mut command = shell_command(echo_var); let mut command = shell_command(echo_var);
command.current_dir(&workdir); command.current_dir(&workdir);
for (k, v) in load_dotenv(&workdir).unwrap() { for (k, v) in load_dotenv(&workdir).unwrap() {
+166 -15
View File
@@ -1,18 +1,30 @@
mod cli; mod cli;
mod config; mod config;
mod deploy; mod deploy;
mod git;
mod local; mod local;
mod ssh; mod ssh;
mod sync; mod sync;
use std::process::ExitCode; use std::{fs, path::Path, process::ExitCode};
use anyhow::{Context, Result}; use anyhow::{Context, Result, bail};
use clap::Parser; use clap::Parser;
use colored::Colorize; use colored::Colorize;
use crate::{
cli::{Cli, Commands, DeployArgs},
config::{Config, Project},
};
fn main() -> ExitCode { fn main() -> ExitCode {
let cli = cli::Cli::parse(); #[cfg(windows)]
if enable_ansi_support::enable_ansi_support().is_err() {
colored::control::set_override(false);
}
let cli = Cli::parse();
match run(&cli) { match run(&cli) {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(err) => { Err(err) => {
@@ -22,27 +34,166 @@ fn main() -> ExitCode {
} }
} }
fn run(cli: &cli::Cli) -> Result<()> { fn handle_deploy(cli: &Cli, args: &DeployArgs, cfg: &config::Config) -> Result<()> {
let path = config::resolve(cli.config.as_deref())?; let name = match &args.project_name {
let cfg = config::load(&path)?; Some(name) => name,
None => get_project_name_by_cwd(cfg)?,
};
if cli.list { let project = &cfg.projects[name];
println!("config: {}", path.display());
println!("configured projects:"); deploy(cli, name, project)
}
fn handle_list(path: &Path, cfg: &config::Config) -> Result<()> {
println!("Config: {}", config::display(path));
println!("Configured projects:");
for name in cfg.projects.keys() { for name in cfg.projects.keys() {
println!(" - {name}"); println!(" - {name}");
} }
return Ok(()); Ok(())
} }
let name = cli fn handle_edit_config() -> Result<()> {
.project let path = config::global_config_path(home::home_dir().as_deref())
.as_deref() .context("cannot determine home directory")?;
.expect("clap guarantees <PROJECT> or --list");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
if !path.exists() {
fs::write(&path, "").with_context(|| format!("cannot create {}", path.display()))?;
}
let editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| {
#[cfg(target_os = "windows")]
{
"notepad".to_string()
}
#[cfg(not(target_os = "windows"))]
{
"vi".to_string()
}
});
let cmd = format!("{editor} {}", path.display());
let mut process = {
#[cfg(target_os = "windows")]
{
let mut c = std::process::Command::new("cmd");
c.arg("/C").arg(&cmd);
c
}
#[cfg(not(target_os = "windows"))]
{
let mut c = std::process::Command::new("sh");
c.arg("-c").arg(&cmd);
c
}
};
let status = process
.status()
.with_context(|| format!("failed to launch editor: {editor}"))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
fn handle_list_servers(path: &Path, cfg: &config::Config) -> Result<()> {
println!("config: {}", config::display(path));
println!("configured servers:");
for (name, server) in &cfg.servers {
let mut line = format!(" - {name} {}", server.host);
if let Some(port) = server.port {
line.push_str(&format!(":{port}"));
}
if let Some(user) = &server.user {
line.push_str(&format!(" as {user}"));
}
println!("{line}");
}
Ok(())
}
fn handle_pick(cli: &Cli, path: &Path, cfg: &config::Config) -> Result<()> {
let name = match pick_project(cfg)? {
Some(name) => name,
None => return Ok(()),
};
let project = cfg let project = cfg
.projects .projects
.get(name) .get(&name)
.with_context(|| format!("project '{name}' not found in {}", path.display()))?; .with_context(|| format!("project '{name}' not found in {}", path.display()))?;
deploy(cli, &name, project)
}
fn handle_default(path: &Path, cfg: &config::Config) -> Result<()> {
match get_project_name_by_cwd(cfg) {
Ok(project_name) => {
println!("Current project: '{}'", project_name);
println!("`To deploy run 'xd deploy'`");
Ok(())
}
Err(_) => {
println!("No project found in current directory");
println!("Run 'xd deploy [project]' to deploy a project");
handle_list(path, cfg)
}
}
}
fn deploy(cli: &Cli, name: &str, project: &Project) -> Result<()> {
deploy::deploy(name, project, cli.dry_run) deploy::deploy(name, project, cli.dry_run)
} }
fn run(cli: &Cli) -> Result<()> {
let path = config::resolve(cli.config.as_deref())?;
let cfg = config::load(&path)?;
match &cli.command {
Some(Commands::Deploy(args)) => handle_deploy(cli, args, &cfg),
Some(Commands::Pick) => handle_pick(cli, &path, &cfg),
Some(Commands::List) => handle_list(&path, &cfg),
Some(Commands::ListServers) => handle_list_servers(&path, &cfg),
Some(Commands::EditConfig) => handle_edit_config(),
None => handle_default(&path, &cfg),
}
}
fn pick_project(cfg: &config::Config) -> Result<Option<String>> {
use dialoguer::FuzzySelect;
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() {
bail!("interactive picker needs a real terminal; use --list instead");
}
let names: Vec<&str> = cfg.projects.keys().map(String::as_str).collect();
match FuzzySelect::new()
.with_prompt("Pick a project")
.items(&names)
.default(0)
.interact()
{
Ok(idx) => Ok(Some(names[idx].to_string())),
Err(dialoguer::Error::IO(err)) if err.kind() == std::io::ErrorKind::Interrupted => {
println!("cancelled.");
Ok(None)
}
Err(err) => Err(err).context("project selection failed"),
}
}
fn get_project_name_by_cwd(cfg: &Config) -> Result<&str> {
let cwd = std::env::current_dir().context("cannot determine current directory")?;
match config::find_by_cwd(cfg, &cwd) {
Some(name) => Ok(name),
None => bail!("no project found in {}", cwd.display()),
}
}