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

588 lines
30 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.
# 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>)