feat: add plan and backlog

This commit is contained in:
2026-09-03 08:23:46 +05:00
parent 097b68c39d
commit 973d348628
3 changed files with 768 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
# План: Deploy разных git веток из одной папки
## Проблема
Сейчас проекты идентифицируются по `workdir` — два проекта в одной папке не работают корректно. Нет интеграции с git — нельзя деплоить конкретную ветку или удостовериться, что деплоится та ветка, которая задумана.
## Статус / подход
**Итеративная разработка.** Реализуем минимальный полезный шаг, пользуемся утилитой вживую, и только по мере реальных потребностей добавляем следующее.
Всё, что **не** реализовано сейчас (worktree, stash, `.env` sync и прочие edge cases), вынесено в отдельный файл `docs/git-branch-roadmap.md` — там детальная проработка, но **только для обсуждения**, на будущее. Сюда смотрим, когда понадобится следующий шаг.
---
## Шаг 1 (РЕАЛИЗУЕМ сейчас): поле `branch` + проверка совпадения при деплое
### Решение
Добавить опциональное поле `branch` в проект. Поведение при деплое:
- `branch` **не задан** → деплоим как раньше (полная совместимость, ничего не проверяем).
- `branch` **задан**:
- определить текущую git ветку в `workdir`;
- текущая **совпадает** с `branch` → деплоим напрямую;
- текущая **не совпадает****abort** с внятным сообщением.
Никаких worktree, stash, авто-переключений. Только безопасная проверка перед тем, как что-то улетит на прод.
> В подсказках пользователю используем `git switch`.
### Почему именно так
- Устраняет главный риск: деплой не той ветки, чем задумано (например, забыли переключиться с `main` на `hotfix`).
- Нулевая вероятность потери данных — мы ничего не двигаем, не переключаем, не stash'им.
- Минимум кода, ничего не ломает для существующих конфигов.
### Конфиг
```toml
[projects.my-app]
workdir = 'C:\projects\my-app'
branch = "staging"
```
Поле `branch` опционально. Без него — работает как раньше.
### UX
```bash
cd C:\projects\my-app # сейчас на ветке main, в конфиге branch = "staging"
xd deploy
# → error: project 'my-app' is on branch 'main', expected 'staging'.
# Switch branches: git switch staging
# Or deploy a different project.
```
### Изменения по файлам
#### 1. `src/config.rs` — поле `branch` (+валидация)
Добавить в struct `Project` опциональное строковое поле `branch`.
В `Config::validate` (рядом с остальными проверками проекта) добавить: если `branch` задан — отклонить пустую/пробельную строку (`.trim().is_empty()`). Также `.trim()` при сравнении в `deploy()` — чтобы `branch = " staging "` в конфиге корректно матчился с `staging` из git.
Note: валидацию «workdir — git repo» здесь **не** делаем (это рантайм-проверка, зависит от состояния машины, а не от конфига) — она в `deploy()`. В `validate` только синтаксическая проверка ветки.
#### 2. `src/git.rs` — НОВЫЙ МОДУЛЬ
Обёртка над git CLI через `std::process::Command`.
Одна функция `current_branch(workdir) -> Result<Option<String>>` — вызывает `git rev-parse --abbrev-ref HEAD` и возвращает:
- `Ok(Some(имя))` — обычная ветка;
- `Ok(Some("HEAD"))` — detached HEAD;
- `Ok(None)` — не git repo (не внутри рабочего дерева);
- `Err` — git реально упал (битый `.git` **или** `git` не установлен в PATH).
> **Почему `Option`, а не только имя:** `Ok(None)` = не git-репозиторий — это **не** ошибка сама по себе (её осмысляет вызывающий), а `Err` = git действительно сломался. Так семантика однозначна и не путает detached (`"HEAD"`).
>
> **`git` не установлен:** `Command::new("git")` упадёт с ошибкой ОС ( NotFound), которая попадёт в `Err`. Это **должно** отличаться от `None` — см. п.2 в deploy() ниже.
#### 3. `src/deploy.rs` — проверка ветки перед деплоем
В самом начале `deploy()`**строго до** ветки `dry_run` и до раннего выхода «nothing to do», чтобы dry-run тоже валидировал ветку. Алгоритм (только если `branch` задан в конфиге):
1. Вызвать `current_branch(workdir)` и обработать ошибку.
2. `Ok(None)` (не git repo) → abort с текстом: «project X has branch Y set, но workdir не git-репозиторий».
3. `Err` (git не установлен) → abort с текстом: «project X has branch Y set, но `git` не найден в PATH. Установите git: https://git-scm.com/downloads».
4. `Err` (битый репо) → abort с текстом: «project X: git repo повреждён (bit .git directory)».
5. `"HEAD"` (detached) → abort с отдельным текстом: «project X в detached HEAD, ожидается ветка Y; вернись на ветку `git switch -c Y`».
6. Имя ≠ `branch` → abort с текстом: «project X на ветке A, ожидается B; переключись `git switch B`».
7. Имя == `branch` → строка `[Git] on branch 'Y' ✓` и продолжаем.
Ключевое поведение:
- detached → abort с точной подсказкой (`git switch -c`), т.к. `git checkout` в detached просто создаст новую ветку;
- `Ok(None)` (не git repo) → abort с понятным текстом;
- git не установлен → отдельный abort с подсказкой установки (а не generic "git failed");
- `git rev-parse` работает и из **подпапки** репо (git поднимается вверх), поэтому `workdir` не обязан быть корнем — два проекта в одной папке увидят одну ветку (корректно).
#### 4. `src/main.rs` — без изменений (проверка внутри `deploy`)
#### 5. `src/cli.rs` — без изменений
### Тесты
#### `src/git.rs`
- `current_branch` возвращает `Some(имя_ветки)` в git репо
- `current_branch` возвращает `Some("HEAD")` при detached HEAD (`git switch --detach`)
- `current_branch` возвращает `Ok(None)` если не git repo
- `current_branch``Err` если `git` падает (например, битый `.git`)
- `current_branch``Err` если `git` не установлен в PATH (模拟: невалидный путь к git)
> **Грабль с тестами — пустой репо.** `git rev-parse --abbrev-ref HEAD` на свежеинициализированном репо **без коммитов** (unborn HEAD) не даёт стабильной ветки (зависит от `init.defaultBranch`/версии git). Поэтому все тесты с репозиторием должны: `git init -b test` + создать минимум **один коммит** до проверки. Существующий `tmpdir()` (`config.rs`) git не инициализирует — новый хелпер нужен отдельный (например, `git_repo(tag)` в `git.rs`, который делает init/commit и чистит за собой).
>
> Зависимость: тесты требуют наличия `git` в PATH окружения. Для этого проекта приемлемо (git и так нужен для работы), но это новая зависимость для CI/машины — зафиксировать явно.
#### `src/deploy.rs`
- Deploy с `branch` == текущая ветка → продолжается
- Deploy с `branch` != текущая ветка → abort с текстом ошибки
- Deploy без `branch` → ничего не проверяет (полная совместимость)
- Deploy с `branch`, workdir не git repo → abort (сообщение «not a git repository»)
- Deploy с `branch`, git не установлен в PATH → abort (сообщение «git not found» + ссылка на установку)
- Deploy с `branch`, в detached HEAD → abort (сообщение «detached HEAD state»)
- Deploy с `branch`, пустая строка → ошибка валидации конфига
- Deploy с `branch`, пробелы в начале/конце (`" staging "`) → trim работает, ветка матчится корректно
- Dry-run с `branch` всё ещё печатает план (после успешной проверки ветки)
### Зависимости
Новых crate не требуется — git вызывается через `std::process::Command`.
---
## Порядок реализации (шаг 1)
1. `src/git.rs` — новый модуль (`current_branch``Result<Option<String>>`)
2. `src/config.rs` — поле `branch` + валидация непустой строки в `Config::validate`
3. `src/deploy.rs` — проверка ветки в начале `deploy()` (обработка `None` / detached / несовпадения)
4. Тесты (в `git.rs` — на реальных репо с коммитом; в `config.rs` — на непустой `branch`)
5. `cargo test` + `cargo clippy`
## Сложности / риски (что нельзя сломать)
- **Обратная совместимость** — главное правило: ветка проверяется **только** если `branch` задан в конфиге. Без `branch` поведение `deploy()` идентично текущему. `deny_unknown_fields` требует добавить поле в struct (сделано), старые конфиги не затрагиваются.
- **Порядок в `deploy()`** — проверка ветки строго в начале, до `dry_run` и «nothing to do»: dry-run обязан валидировать ветку.
- **`workdir` как подпапка репо** — работает, т.к. git поднимается вверх; это фича (не баг), в т.ч. для двух проектов в одном репо.
- **Empty repo в тестах** — без коммита ветки нестабильны; в тестах всегда делать init + commit.
- **Новая зависимость тестов от `git`** в PATH.
- **Detached HEAD** — отдельное сообщение; не путать с несовпадением веток.
---
## Known Limitations
- **TOCTOU (check-then-act)** — проверка ветки и реальный deploy разделены по времени. Ветка может измениться между проверкой и rsync. Это **фундаментальное ограничение** подхода (plan-based guard, не lock). В реальности маловероятно, но документируем явно. Если станет проблемой — следующий шаг (worktree / lock).
- **Bare repositories** — `git rev-parse --abbrev-ref HEAD` работает в bare repos, но поведение может быть неожиданным (ветка HEAD в bare repo может не совпадать с ожидаемой). Bare repos не являются целевым кейсом для шага 1. Если понадобится — отдельная проработка.
- **Submodules** — если `workdir` указывает на subdirectory внутри submodule'а, `git rev-parse` вернёт ветку submodule'а, а не родительского репо. Это корректное поведение git, но может удивить пользователя. Решение: если проект — submodule, branch в конфиге должен указывать ветку submodule'а.
---
## Дальнейшие шаги
Детальная проработка будущих возможностей — в `docs/git-branch-roadmap.md`. Реализуем итеративно, по мере потребностей.