Files
xboct-deploy/docs/plan-git-branch-deploy.md

168 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: 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`. Реализуем итеративно, по мере потребностей.