Compare commits
7
Commits
097b68c39d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d259feae6 | ||
|
|
d194890315 | ||
|
|
97eea5e064 | ||
|
|
eca349a706 | ||
|
|
95c88fe5d9 | ||
|
|
6ad1720fcb | ||
|
|
973d348628 |
@@ -14,42 +14,66 @@
|
|||||||
cargo install --path .
|
cargo install --path .
|
||||||
```
|
```
|
||||||
|
|
||||||
Требуется Rust 1.88+. Кроссплатформенно: Windows (MSVC), Linux, macOS.
|
Бинарник называется **`xd`**. Требуется Rust 1.88+. Кроссплатформенно:
|
||||||
|
Windows (MSVC), Linux, macOS.
|
||||||
|
|
||||||
## Быстрый старт
|
## Команды
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
xboct-deploy --list # показать проекты из конфига
|
xd list # показать проекты из конфига
|
||||||
xboct-deploy --list-servers # показать серверы из конфига
|
xd list-servers # показать серверы из конфига
|
||||||
xboct-deploy --pick # интерактивно выбрать проект (нечёткий поиск)
|
xd pick # интерактивно выбрать проект (нечёткий поиск)
|
||||||
xboct-deploy --dry-run demo # напечатать план деплоя без выполнения
|
xd deploy my-node-app # задеплоить проект my-node-app
|
||||||
xboct-deploy demo # задеплоить проект demo
|
xd deploy # задеплоить проект текущей папки (workdir == cwd)
|
||||||
xboct-deploy --this # задеплоить проект текущей папки (глобальный конфиг)
|
xd --dry-run deploy my-node-app # напечатать план деплоя без выполнения
|
||||||
|
xd edit-config # открыть глобальный конфиг в $EDITOR
|
||||||
|
xd # статус: какой проект соответствует текущей папке
|
||||||
|
# (вне проекта — список проектов из конфига)
|
||||||
```
|
```
|
||||||
|
|
||||||
Вывод:
|
Глобальные флаги:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
deploying 'demo'
|
-c, --config <PATH> явный путь к конфигу (работает и внутри подкоманд)
|
||||||
|
-g, --global использовать только глобальный конфиг (конфликтует с -c)
|
||||||
|
--dry-run печать плана без выполнения; НЕ глобальный — ставится
|
||||||
|
до подкоманды: `xd --dry-run deploy name`
|
||||||
|
```
|
||||||
|
|
||||||
|
`xd deploy` без имени ищет проект, чей `workdir` совпадает с текущей папкой,
|
||||||
|
и падает с ошибкой, если такого нет. `xd pick` требует реальный терминал —
|
||||||
|
в скриптах используйте `xd list`.
|
||||||
|
|
||||||
|
Вывод деплоя:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
[Git] on branch 'main' ✓
|
||||||
|
deploying 'my-node-app'
|
||||||
[Local] running: npm run build... Success! (1.2s)
|
[Local] running: npm run build... Success! (1.2s)
|
||||||
[SFTP] transferring dist to /var/www/pages... Done! (14 file(s), 231.5 KiB) (829ms)
|
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)
|
[Remote] running: pm2 restart pages... Success! (102ms)
|
||||||
|
[LocalAfter] running: curl -s -X POST https://example.com/hook... Success! (45ms)
|
||||||
Deploy successful! (total 2.3s)
|
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/xboct-deploy/deploy.toml` — запуск «из любой папки».
|
3. глобальный `~/.config/xboct-deploy/deploy.toml` — запуск «из любой папки».
|
||||||
|
|
||||||
`--this` деплоит проект, чей `workdir` совпадает с текущей папкой; конфиг
|
Флаг `-g/--global` исключает шаг 2: используется только глобальный конфиг.
|
||||||
ищется по обычной цепочке, так что временный локальный `./deploy.toml`
|
Деплой проекта текущей папки (`xd deploy` без имени) ищет конфиг по этой же
|
||||||
переопределяет глобальный — создали файл, задеплоились, удалили.
|
цепочке, так что временный локальный `./deploy.toml` переопределяет глобальный —
|
||||||
|
создали файл, задеплоились, удалили.
|
||||||
|
|
||||||
Схема:
|
Схема — **один проект = один блок**, все параметры точечными ключами:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[servers.my-vps] # профиль сервера: описывается один раз
|
[servers.my-vps] # профиль сервера: описывается один раз
|
||||||
@@ -60,29 +84,65 @@ key_path = "~/.ssh/id_ed25519"
|
|||||||
base_dir = "/var/www/pages" # корень для относительных sync target
|
base_dir = "/var/www/pages" # корень для относительных sync target
|
||||||
|
|
||||||
[projects.my-node-app]
|
[projects.my-node-app]
|
||||||
server = "my-vps" # ссылка на профиль
|
server = "my-vps" # ссылка на профиль SSH
|
||||||
workdir = "~/code/my-node-app" # где выполнять локальные команды (~ разворачивается)
|
workdir = 'C:\code\my-node-app' # где выполнять локальные команды (~ разворачивается)
|
||||||
|
branch = "main" # опционально: защита от деплоя чужой ветки
|
||||||
[projects.my-node-app.env] # переменные для локальных команд сборки
|
env.PUBLIC_BASE_PATH = "/my-node-app" # переменные для локальной сборки
|
||||||
PUBLIC_BASE_PATH = "/my-node-app"
|
local.commands = ["npm ci", "npm run build"] # шаги сборки на вашей машине
|
||||||
|
sync = "dist" # залить dist -> <base_dir>/my-node-app
|
||||||
[projects.my-node-app.local] # шаги сборки на вашей машине
|
remote.commands = ["pm2 restart pages"] # команды управления по SSH
|
||||||
commands = ["npm ci", "npm run build"]
|
|
||||||
|
|
||||||
[[projects.my-node-app.sync]] # что заливать на сервер
|
|
||||||
source = "dist" # файл или папка (относительно workdir)
|
|
||||||
target = "my-node-app" # относительный -> base_dir сервера;
|
|
||||||
# абсолютный "/path" используется как есть
|
|
||||||
|
|
||||||
[projects.my-node-app.remote] # команды управления на сервере
|
|
||||||
commands = ["pm2 restart pages"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Поле `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`,
|
Проект может задать параметры подключения и напрямую (`host`, `user`, `port`,
|
||||||
`key_path` вместо `server`) — тогда они имеют приоритет над профилем.
|
`key_path` вместо `server`) — тогда они имеют приоритет над профилем.
|
||||||
|
|
||||||
Приоритет параметров SSH: проект → `[servers.<name>]` → `~/.ssh/config` → дефолт.
|
Приоритет параметров SSH: проект → `[servers.<name>]` → `~/.ssh/config` → дефолт.
|
||||||
|
|
||||||
|
### Локальные команды после деплоя (`local_after`)
|
||||||
|
|
||||||
|
Выполняются на вашей машине **после** sync + remote и **всегда** — и при
|
||||||
|
успехе, и после любого упавшего шага (deploy всё равно упадёт). Для очистки
|
||||||
|
артефактов или вебхука с результатом:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[projects.my-node-app]
|
||||||
|
server = "my-vps"
|
||||||
|
workdir = 'C:\code\my-node-app'
|
||||||
|
local.commands = ["npm run build"]
|
||||||
|
sync = "dist"
|
||||||
|
remote.commands = ["pm2 restart pages"]
|
||||||
|
|
||||||
|
[projects.my-node-app.local_after]
|
||||||
|
commands = [
|
||||||
|
'del /q dist',
|
||||||
|
'curl -s -X POST https://example.com/hook -d "result=%XD_DEPLOY_RESULT%"',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Внутри доступны переменные (в cmd — `%VAR%`, в sh — `$VAR`):
|
||||||
|
|
||||||
|
- `XD_DEPLOY_RESULT` — `success` либо `failed`;
|
||||||
|
- `XD_ERROR` — текст ошибки (только при `failed`).
|
||||||
|
|
||||||
|
Эти же переменные доступны в `--dry-run`, где показываются в плане без
|
||||||
|
выполнения.
|
||||||
|
|
||||||
### Windows-нюанс TOML
|
### Windows-нюанс TOML
|
||||||
|
|
||||||
В basic-строках `"..."` бэкслеши — escape-символы, путь `C:\Users` сломает парсинг.
|
В basic-строках `"..."` бэкслеши — escape-символы, путь `C:\Users` сломает парсинг.
|
||||||
@@ -125,6 +185,10 @@ 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
@@ -0,0 +1,14 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
## Validate config команда
|
||||||
|
|
||||||
|
Добавить команду `xd validate` — проверка конфигурации на ошибки и потенциальные проблемы.
|
||||||
|
|
||||||
|
### Что проверять
|
||||||
|
|
||||||
|
- Одинаковые имена проектов (сейчас serde откажет, но стоит выводить понятное сообщение)
|
||||||
|
- Одинаковые `workdir` + `branch` комбинации у разных проектов
|
||||||
|
- Дублирующиеся проекты с одинаковыми deploy путями (одна папка, одна ветка, разные имена)
|
||||||
|
- Ветки, указанные в конфиге, которые не существуют в git репозитории
|
||||||
|
- Reachability серверов (resolve host, проверить SSH config)
|
||||||
|
- Sync target пути, которые выглядят подозрительно
|
||||||
@@ -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`. Реализуем итеративно, по мере потребностей.
|
||||||
@@ -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>)
|
||||||
@@ -42,6 +42,9 @@ pub enum Commands {
|
|||||||
|
|
||||||
/// List all available servers
|
/// List all available servers
|
||||||
ListServers,
|
ListServers,
|
||||||
|
|
||||||
|
/// Open global config in $EDITOR
|
||||||
|
EditConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
|
|||||||
+197
-33
@@ -29,17 +29,20 @@ pub struct Server {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub workdir: PathBuf,
|
|
||||||
pub server: Option<String>,
|
pub server: Option<String>,
|
||||||
pub host: Option<String>,
|
pub host: 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>,
|
||||||
|
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>,
|
||||||
@@ -55,9 +58,40 @@ 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() {
|
||||||
@@ -97,8 +131,14 @@ impl Config {
|
|||||||
if host.trim().is_empty() {
|
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
|
||||||
@@ -113,6 +153,11 @@ 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"))?;
|
||||||
@@ -178,11 +223,17 @@ pub fn display(path: &Path) -> String {
|
|||||||
format!("~/{}", rest.to_string_lossy().replace('\\', "/"))
|
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> {
|
pub fn global_path(home: Option<&Path>) -> Option<PathBuf> {
|
||||||
let path = home?
|
let path = global_config_path(home)?;
|
||||||
.join(".config")
|
|
||||||
.join(GLOBAL_DIR)
|
|
||||||
.join(CONFIG_FILE_NAME);
|
|
||||||
path.is_file().then_some(path)
|
path.is_file().then_some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,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]
|
||||||
@@ -269,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#"
|
||||||
@@ -291,7 +381,7 @@ hostt = "typo"
|
|||||||
host = "10.0.0.9"
|
host = "10.0.0.9"
|
||||||
port = 2327
|
port = 2327
|
||||||
user = "deploy"
|
user = "deploy"
|
||||||
key_path = "~/.ssh/xbp_key"
|
key_path = "~/.ssh/xd_key"
|
||||||
|
|
||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "."
|
workdir = "."
|
||||||
@@ -311,7 +401,7 @@ port = 2222
|
|||||||
assert_eq!(web.port, Some(2327));
|
assert_eq!(web.port, Some(2327));
|
||||||
assert_eq!(web.user.as_deref(), Some("deploy"));
|
assert_eq!(web.user.as_deref(), Some("deploy"));
|
||||||
let key = web.key_path.as_ref().unwrap();
|
let key = web.key_path.as_ref().unwrap();
|
||||||
assert!(key.ends_with("xbp_key"), "{}", key.display());
|
assert!(key.ends_with("xd_key"), "{}", key.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -351,16 +441,12 @@ host = "10.0.0.9"
|
|||||||
base_dir = "/var/www/pages/"
|
base_dir = "/var/www/pages/"
|
||||||
|
|
||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "."
|
|
||||||
server = "main"
|
server = "main"
|
||||||
|
workdir = "."
|
||||||
[[projects.web.sync]]
|
sync = [
|
||||||
source = "dist"
|
{ source = "dist", target = "web-app" },
|
||||||
target = "web-app"
|
{ source = "dist/favicon.ico", target = "/opt/static/favicon.ico" },
|
||||||
|
]
|
||||||
[[projects.web.sync]]
|
|
||||||
source = "dist/favicon.ico"
|
|
||||||
target = "/opt/static/favicon.ico"
|
|
||||||
"#;
|
"#;
|
||||||
let cfg = parse(s).unwrap();
|
let cfg = parse(s).unwrap();
|
||||||
let sync = &cfg.projects.get("web").unwrap().sync;
|
let sync = &cfg.projects.get("web").unwrap().sync;
|
||||||
@@ -374,10 +460,7 @@ target = "/opt/static/favicon.ico"
|
|||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "."
|
workdir = "."
|
||||||
host = "10.0.0.9"
|
host = "10.0.0.9"
|
||||||
|
sync = [{ source = "dist", target = "web-app" }]
|
||||||
[[projects.web.sync]]
|
|
||||||
source = "dist"
|
|
||||||
target = "web-app"
|
|
||||||
"#;
|
"#;
|
||||||
let err = parse(s).unwrap_err();
|
let err = parse(s).unwrap_err();
|
||||||
assert!(err.to_string().contains("base_dir"), "{err:#}");
|
assert!(err.to_string().contains("base_dir"), "{err:#}");
|
||||||
@@ -389,12 +472,93 @@ target = "web-app"
|
|||||||
[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();
|
||||||
|
|||||||
+284
-11
@@ -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,19 +21,51 @@ 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) {
|
||||||
@@ -86,15 +122,72 @@ 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}");
|
||||||
@@ -109,6 +202,9 @@ fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String])
|
|||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -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() {
|
||||||
|
|||||||
+64
-12
@@ -1,11 +1,12 @@
|
|||||||
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::{path::Path, process::ExitCode};
|
use std::{fs, path::Path, process::ExitCode};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@@ -41,20 +42,70 @@ fn handle_deploy(cli: &Cli, args: &DeployArgs, cfg: &config::Config) -> Result<(
|
|||||||
|
|
||||||
let project = &cfg.projects[name];
|
let project = &cfg.projects[name];
|
||||||
|
|
||||||
deploy(&cli, &name, project)
|
deploy(cli, name, project)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_list(path: &Path, cfg: &config::Config) -> Result<()> {
|
fn handle_list(path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
println!("Config: {}", config::display(&path));
|
println!("Config: {}", config::display(path));
|
||||||
println!("Configured projects:");
|
println!("Configured projects:");
|
||||||
for name in cfg.projects.keys() {
|
for name in cfg.projects.keys() {
|
||||||
println!(" - {name}");
|
println!(" - {name}");
|
||||||
}
|
}
|
||||||
return Ok(());
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_edit_config() -> Result<()> {
|
||||||
|
let path = config::global_config_path(home::home_dir().as_deref())
|
||||||
|
.context("cannot determine home directory")?;
|
||||||
|
|
||||||
|
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<()> {
|
fn handle_list_servers(path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
println!("config: {}", config::display(&path));
|
println!("config: {}", config::display(path));
|
||||||
println!("configured servers:");
|
println!("configured servers:");
|
||||||
for (name, server) in &cfg.servers {
|
for (name, server) in &cfg.servers {
|
||||||
let mut line = format!(" - {name} {}", server.host);
|
let mut line = format!(" - {name} {}", server.host);
|
||||||
@@ -66,11 +117,11 @@ fn handle_list_servers(path: &Path, cfg: &config::Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_pick(cli: &Cli, path: &Path, cfg: &config::Config) -> Result<()> {
|
fn handle_pick(cli: &Cli, path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
let name = match pick_project(&cfg)? {
|
let name = match pick_project(cfg)? {
|
||||||
Some(name) => name,
|
Some(name) => name,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
@@ -80,7 +131,7 @@ fn handle_pick(cli: &Cli, path: &Path, cfg: &config::Config) -> Result<()> {
|
|||||||
.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)
|
deploy(cli, &name, project)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_default(path: &Path, cfg: &config::Config) -> Result<()> {
|
fn handle_default(path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
@@ -99,7 +150,7 @@ fn handle_default(path: &Path, cfg: &config::Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn deploy(cli: &Cli, name: &str, project: &Project) -> Result<()> {
|
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<()> {
|
fn run(cli: &Cli) -> Result<()> {
|
||||||
@@ -107,10 +158,11 @@ fn run(cli: &Cli) -> Result<()> {
|
|||||||
let cfg = config::load(&path)?;
|
let cfg = config::load(&path)?;
|
||||||
|
|
||||||
match &cli.command {
|
match &cli.command {
|
||||||
Some(Commands::Deploy(args)) => handle_deploy(&cli, args, &cfg),
|
Some(Commands::Deploy(args)) => handle_deploy(cli, args, &cfg),
|
||||||
Some(Commands::Pick) => handle_pick(&cli, &path, &cfg),
|
Some(Commands::Pick) => handle_pick(cli, &path, &cfg),
|
||||||
Some(Commands::List) => handle_list(&path, &cfg),
|
Some(Commands::List) => handle_list(&path, &cfg),
|
||||||
Some(Commands::ListServers) => handle_list_servers(&path, &cfg),
|
Some(Commands::ListServers) => handle_list_servers(&path, &cfg),
|
||||||
|
Some(Commands::EditConfig) => handle_edit_config(),
|
||||||
None => handle_default(&path, &cfg),
|
None => handle_default(&path, &cfg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +192,7 @@ fn pick_project(cfg: &config::Config) -> Result<Option<String>> {
|
|||||||
|
|
||||||
fn get_project_name_by_cwd(cfg: &Config) -> Result<&str> {
|
fn get_project_name_by_cwd(cfg: &Config) -> Result<&str> {
|
||||||
let cwd = std::env::current_dir().context("cannot determine current directory")?;
|
let cwd = std::env::current_dir().context("cannot determine current directory")?;
|
||||||
match config::find_by_cwd(&cfg, &cwd) {
|
match config::find_by_cwd(cfg, &cwd) {
|
||||||
Some(name) => Ok(name),
|
Some(name) => Ok(name),
|
||||||
None => bail!("no project found in {}", cwd.display()),
|
None => bail!("no project found in {}", cwd.display()),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user