diff --git a/backlog.md b/backlog.md new file mode 100644 index 0000000..4fb2c9a --- /dev/null +++ b/backlog.md @@ -0,0 +1,14 @@ +# Backlog + +## Validate config команда + +Добавить команду `xd validate` — проверка конфигурации на ошибки и потенциальные проблемы. + +### Что проверять + +- Одинаковые имена проектов (сейчас serde откажет, но стоит выводить понятное сообщение) +- Одинаковые `workdir` + `branch` комбинации у разных проектов +- Дублирующиеся проекты с одинаковыми deploy путями (одна папка, одна ветка, разные имена) +- Ветки, указанные в конфиге, которые не существуют в git репозитории +- Reachability серверов (resolve host, проверить SSH config) +- Sync target пути, которые выглядят подозрительно diff --git a/docs/plan-git-branch-deploy.md b/docs/plan-git-branch-deploy.md new file mode 100644 index 0000000..b377dc7 --- /dev/null +++ b/docs/plan-git-branch-deploy.md @@ -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>` — вызывает `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>`) +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`. Реализуем итеративно, по мере потребностей. diff --git a/docs/roadmap-git-branch-roadmap.md b/docs/roadmap-git-branch-roadmap.md new file mode 100644 index 0000000..f5a844b --- /dev/null +++ b/docs/roadmap-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-деплоев (вне репо) + / ← имя папки, в которой лежит репо (изоляция от одноимённых репо в разных местах) + / ← изоляция разных проектов из одного workdir + staging/ ← worktree для ветки staging + main/ ← worktree для ветки main +``` + +Правила: + +- Worktree создаётся как `/.xd-worktrees///` +- Итоговый путь запрашиваем у git / берём из `worktree list --porcelain`, не строим вручную из имени ветки +- `.gitignore` не нужен — main worktree всегда чистый, статус/CI/линтеры не видят мусор +- Если удалить репо — worktree-ветки остаются отдельно и не ломают ничего + +### Cleanup при ошибках (WorktreeGuard) + +Rust не имеет `finally`. Используем RAII — структуру `WorktreeGuard`, которая удаляет worktree в `Drop`: + +```rust +struct WorktreeGuard { + path: Option, + 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-.lock + → Windows: %TEMP%\xd\deploy-.lock + → Unix: /tmp/xd/deploy-.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 ' or specify --branch " +``` + +### 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 + → git worktree add --track origin/ (создаст tracking branch) + → Если fetch fails: abort "Cannot fetch branch '{branch}' from origin" +``` + +### Network offline при remote-only branch + +```txt +git fetch origin → ошибка (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 ` сам вернёт ошибку при невалидном пути + → Читаем фактический путь из вывода `git worktree list --porcelain`, не конструируем сами + → Если git вернул ошибку — пробрасываем её as-is (abort с текстом git) +``` + +Ветки типа `feature/foo-bar` → worktree path: `.xd-worktrees//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 " +``` + +### 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` | `git rev-parse --abbrev-ref HEAD` — вернёт "HEAD" если detached | +| `is_detached_head` | `(workdir: &Path) -> Result` | `current_branch() == "HEAD"` | +| `has_uncommitted` | `(workdir: &Path) -> Result` | `git status --porcelain` — не пусто = есть изменения | +| `branch_exists` | `(workdir: &Path, branch: &str) -> Result` | `git rev-parse --verify `, fallback на `git ls-remote origin ` | +| `fetch_branch` | `(workdir: &Path, branch: &str) -> Result<()>` | `git fetch origin ` — для remote-only веток | +| `worktree_list` | `(workdir: &Path) -> Result>` | `git worktree list --porcelain` — возвращает пути worktrees | +| `create_worktree` | `(workdir: &Path, project: &str, branch: &str) -> Result` | `git worktree add /.xd-worktrees/// `; путь берётся из `worktree list --porcelain` | +| `remove_worktree` | `(path: &Path) -> Result<()>` | `git worktree remove --force` | +| `stash` | `(workdir: &Path) -> Result` | `git stash push -m "xd-auto-stash"`, возвращает true если что-то застешилось | +| `stash_pop` | `(workdir: &Path) -> Result<()>` | `git stash pop` — может вернуть ошибку при конфликте | +| `is_worktree_clean` | `(path: &Path) -> Result` | Проверяет что worktree пуст (нет untracked/modified файлов) | +| `is_shallow` | `(workdir: &Path) -> Result` | `git rev-parse --is-shallow-repository` | +| `worktree_list_branch` | `(workdir: &Path, branch: &str) -> Result>` | Фильтрует 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>` | `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::>() + .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, + + /// Override target branch (uses worktree if not on this branch) + #[arg(short, long)] + pub branch: Option, + + /// 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//)