30 KiB
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 HEADhas_uncommittedопределяет чистый/грязный repobranch_existsнаходит существующую веткуbranch_existsне находит несуществующую веткуbranch_existsнаходит remote-only веткуfetch_branchскачивает remote веткуworktree_listвозвращает список worktreescreate_worktreeсоздаёт и возвращает путьcreate_worktreeсоздаёт поддиректории для веток с/remove_worktreeудаляет worktreeis_worktree_cleanопределяет чистый/грязный worktreecreate_worktreeсоздаёт worktree вне репо (в .xd-worktrees рядом)is_shallowопределяет shallow/non-shallow cloneworktree_list_branchфильтрует worktrees по веткеstash_find_xdнаходит существующий xd-auto-stashstash_find_xdне находит если нет auto-stashhas_locked_worktreeопределяет locked текстsubmodule_updateинициализирует submodules в worktree
src/config.rs
find_projects_by_cwdнаходит все проекты с одинаковым workdirfind_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//)