# 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//)