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

30 KiB
Raw Permalink Blame History

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 (будущее)

Команды

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

Цепочка определения "что деплоить"

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

target branch == текущая git branch → деплоим из workdir напрямую
target branch != текущая git branch → worktree → деплой → cleanup

A. Worktree для deploy конкретной ветки

Когда branch != текущая ветка — не abort, а собрать ветку через git worktree (изолированно, вне репо), задеплоить, почистить.

Структура worktrees (вне репо)

Все worktree-деплои хранятся вне репозитория, на том же уровне, что и сам репо:

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:

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:

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)

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.

[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 флаг

xd deploy --branch staging      # переопределить target branch
xd deploy --branch hotfix

Поле --branch переопределяет project.branch. Если оба заданы и различаются → предупреждение (не abort):

"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

Защита от двух параллельных деплоев одного проекта.

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
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, а не имя ветки:

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 не существует / является файлом

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 ветки

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

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

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

При создании 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

Если worktree создана для проекта с LFS:
  → После checkout worktree: git lfs pull (если .gitattributes существует)
  → Если git-lfs не установлен: warning, но не abort (deploy продолжается)

Workdir внутри .xd-worktrees (рекурсия)

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 ветки — НЕ НУЖЕН

Имена веток и пути 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 уже существует (остаток)

→ Проверить existence через git::worktree_list или fs::metadata
→ Если существует:
  → git worktree remove --force (даже если пустой)
  → Создать заново
→ Если remove fails (locked, busy):
  → На Windows: попробовать 2-3 раза с задержкой (файлы могут быть заняты антивирусом)
  → Если всё ещё fails: abort с инструкцией "закрой процессы в worktree и повтори"

Git worktree locked

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 одной ветки

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

При 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-логики (будущее, для справки)

/// Все проекты с 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-флаги (будущее, для справки)

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