168 lines
13 KiB
Markdown
168 lines
13 KiB
Markdown
# План: 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`. Реализуем итеративно, по мере потребностей.
|