"""Синхронизация зашифрованной базы через системный git. Git тут — канал бэкапа и переноса между машинами, а не источник истории: зашифрованный блоб непрозрачен, `git diff` по нему бесполезен. Историю ведёт журнал внутри базы (см. journal.py). Два решения, определяющие всё остальное: 1. **Коммитим только путь базы.** Все команды заканчиваются на `-- data/vault.fmdb`, поэтому незакоммиченные правки исходников никогда не уедут в автокоммит вместе с данными. 2. **Один коммит на день.** Дельта-сжатие на шифртексте не работает, каждый коммит хранит полную копию файла. При часовых пушах это ~1800 коммитов и порядка 60 МБ в год. Поэтому в течение суток дописываем в ту же вершину через --amend. Форс — только --force-with-lease и только когда мы точно знаем, что вершина наша и никем не сдвинута. """ from __future__ import annotations import os import re import shutil import subprocess import sys from dataclasses import dataclass from datetime import date from pathlib import Path from urllib.parse import quote, urlsplit, urlunsplit from .models import GitSettings COMMIT_PREFIX = "vault" COMMIT_RE = re.compile(r"^vault \d{4}-\d{2}-\d{2}$") # Служебные файлы репозитория данных. Создаются один раз и уезжают вместе # с первым коммитом базы. SETUP_FILES = { ".gitattributes": ( "# База зашифрована. Если git примет её за текст и подставит CRLF,\n" "# файл перестанет расшифровываться. Помечаем явно.\n" "*.fmdb binary\n" "vault.fmdb binary\n" ), ".gitignore": ( "# Рабочие копии базы. Синхронизируется только vault.fmdb.\n" "*.bak\n" "*.tmp\n" "vault.remote.fmdb\n" "export-*.json\n" ), } LOCAL_TIMEOUT = 30 NETWORK_TIMEOUT = 180 class GitError(Exception): """Любая проблема с git. Текст показывается пользователю как есть.""" class GitNotAvailable(GitError): pass class Diverged(GitError): """Локальная и удалённая версии разошлись. Слить два зашифрованных блоба автоматически невозможно, поэтому выбор всегда за пользователем — молча затирать чью-то работу нельзя. """ @dataclass class SyncResult: committed: bool = False amended: bool = False pushed: bool = False pulled: bool = False message: str = "" def git_executable() -> str | None: return shutil.which("git") def commit_message(today: date | None = None) -> str: """Сообщение коммита без единой цифры о бизнесе. Дата нужна, чтобы понимать, можно ли дописать в сегодняшнюю вершину. Количества партий и суммы сюда не попадают: история git может быть видна тому, у кого нет пароля от базы. """ return f"{COMMIT_PREFIX} {(today or date.today()).isoformat()}" def auth_url(remote_url: str, token: str) -> str: """Подставить токен в URL. В .git/config токен не пишем — иначе он лежал бы открытым текстом рядом с зашифрованной базой, обесценивая всё шифрование. Расплата: на время вызова URL виден в списке процессов. """ if not token or not remote_url.startswith(("http://", "https://")): return remote_url parts = urlsplit(remote_url) host = parts.netloc.rsplit("@", 1)[-1] netloc = f"oauth2:{quote(token, safe='')}@{host}" return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) def scrub(text: str, token: str) -> str: """Убрать токен из текста перед показом или логированием.""" if not token: return text return text.replace(token, "***").replace(quote(token, safe=""), "***") class GitSync: """Обёртка над git для одного репозитория с одним синхронизируемым файлом.""" def __init__(self, repo_dir: Path, rel_path: str, settings: GitSettings): self.repo_dir = Path(repo_dir) self.rel_path = rel_path self.settings = settings # --- запуск git --- def _run(self, args: list[str], timeout: int = LOCAL_TIMEOUT, check: bool = True) -> subprocess.CompletedProcess: exe = git_executable() if exe is None: raise GitNotAvailable( "git не найден. Установи Git для Windows — без него " "синхронизация не работает, но приложение считает всё локально." ) env = dict(os.environ) # Без этого git может молча повиснуть на запросе логина в фоновом потоке. env["GIT_TERMINAL_PROMPT"] = "0" env["GCM_INTERACTIVE"] = "never" env["GIT_ASKPASS"] = "" env["LC_ALL"] = "C" kwargs = {} if sys.platform == "win32": # Иначе при каждом тике синхронизации мигает окно консоли. kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW cmd = [exe, "-c", "credential.helper=", "-c", "core.autocrlf=false", *args] try: proc = subprocess.run( cmd, cwd=self.repo_dir, env=env, capture_output=True, timeout=timeout, **kwargs, ) except subprocess.TimeoutExpired as exc: raise GitError(f"git не ответил за {timeout} с — проверь сеть.") from exc if check and proc.returncode != 0: stderr = proc.stderr.decode("utf-8", "replace").strip() raise GitError(scrub(stderr or "неизвестная ошибка git", self.settings.token)) return proc def _out(self, args: list[str], **kwargs) -> str: return self._run(args, **kwargs).stdout.decode("utf-8", "replace").strip() # --- состояние репозитория --- @property def branch(self) -> str: return self.settings.branch or "main" @property def _remote_ref(self) -> str: return f"refs/remotes/origin/{self.branch}" def is_repo(self) -> bool: return (self.repo_dir / ".git").exists() def head_sha(self) -> str | None: proc = self._run(["rev-parse", "HEAD"], check=False) if proc.returncode != 0: return None # репозиторий без единого коммита return proc.stdout.decode().strip() def remote_sha(self) -> str | None: proc = self._run(["rev-parse", self._remote_ref], check=False) if proc.returncode != 0: return None return proc.stdout.decode().strip() def _is_ancestor(self, maybe_ancestor: str, descendant: str) -> bool: return self._run( ["merge-base", "--is-ancestor", maybe_ancestor, descendant], check=False ).returncode == 0 def has_uncommitted_vault_changes(self) -> bool: return bool(self._out(["status", "--porcelain", "--", self.rel_path])) # --- подготовка --- def ensure_repo(self) -> None: """Создать репозиторий и прописать remote, если их ещё нет.""" if not self.is_repo(): self.repo_dir.mkdir(parents=True, exist_ok=True) self._run(["init", "-b", self.branch]) self._run(["config", "user.name", self.settings.author_name or "food-market"]) self._run(["config", "user.email", self.settings.author_email or "food-market@localhost"]) if self.settings.remote_url: # Кладём чистый URL: токен подставляется только в момент вызова. existing = self._run(["remote", "get-url", "origin"], check=False) if existing.returncode != 0: self._run(["remote", "add", "origin", self.settings.remote_url]) elif existing.stdout.decode().strip() != self.settings.remote_url: self._run(["remote", "set-url", "origin", self.settings.remote_url]) # --- обмен с сервером --- def fetch(self) -> str | None: """Забрать удалённую ветку в origin/. Возвращает её sha.""" if not self.settings.remote_url: return None url = auth_url(self.settings.remote_url, self.settings.token) proc = self._run( ["fetch", "--no-tags", url, f"+refs/heads/{self.branch}:{self._remote_ref}"], timeout=NETWORK_TIMEOUT, check=False, ) if proc.returncode != 0: stderr = proc.stderr.decode("utf-8", "replace") # Пустой репозиторий или ветки ещё нет — это нормальный первый запуск. if "couldn't find remote ref" in stderr or "not our ref" in stderr: return None raise GitError(scrub(stderr.strip(), self.settings.token)) return self.remote_sha() def save_remote_copy(self, target: Path) -> Path | None: """Выложить удалённую версию базы отдельным файлом для разбора конфликта.""" proc = self._run(["show", f"{self._remote_ref}:{self.rel_path}"], check=False) if proc.returncode != 0: return None target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(proc.stdout) return target # --- коммит --- def _can_amend(self, remote: str | None) -> bool: """Можно ли дописать в сегодняшнюю вершину вместо нового коммита. Все условия обязательны. Достаточно одному не совпасть — идём обычным путём, без переписывания истории. """ if not self.settings.daily_squash: return False head = self.head_sha() if head is None: return False # Вершина должна быть уже на сервере и никем не сдвинута, иначе # force-push затрёт чужую работу. if remote is None or head != remote: return False subject = self._out(["log", "-1", "--format=%s"]) if not COMMIT_RE.match(subject) or subject != commit_message(): return False # Коммит должен трогать ровно наш файл: иначе amend утащит в себя # чужие изменения, попавшие в ту же вершину. touched = self._out(["show", "--name-only", "--format=", "HEAD"]).split("\n") return [t for t in touched if t.strip()] == [self.rel_path] def _pending_setup_files(self) -> list[str]: """Создать служебные файлы и вернуть те, которых git ещё не знает.""" pending = [] for name, content in SETUP_FILES.items(): path = self.repo_dir / name if not path.exists(): path.write_text(content, encoding="utf-8") tracked = self._run(["ls-files", "--error-unmatch", name], check=False).returncode == 0 if not tracked: pending.append(name) return pending def _commit(self, remote: str | None) -> tuple[bool, bool, str | None]: """Закоммитить базу. Возвращает (закоммитили, через amend, sha до amend).""" # Явный список путей — гарантия, что в автокоммит не утащит ничего # постороннего, что окажется в папке. targets = [self.rel_path, *self._pending_setup_files()] self._run(["add", "--", *targets]) staged = self._run( ["diff", "--cached", "--quiet", "--", *targets], check=False ).returncode != 0 if not staged: return False, False, None # Amend возможен, только когда коммитим ровно базу: иначе переписали бы # вершину, в которой лежит что-то ещё. if targets == [self.rel_path] and self._can_amend(remote): pre_amend = self.head_sha() self._run(["commit", "--amend", "--no-edit", "--", self.rel_path]) return True, True, pre_amend self._run(["commit", "-m", commit_message(), "--", *targets]) return True, False, None def _push(self, amended: bool, lease: str | None) -> None: url = auth_url(self.settings.remote_url, self.settings.token) args = ["push"] if amended and lease: # Явный lease: пуш пройдёт, только если на сервере до сих пор та же # вершина, которую мы переписали. args.append(f"--force-with-lease=refs/heads/{self.branch}:{lease}") args += [url, f"HEAD:refs/heads/{self.branch}"] proc = self._run(args, timeout=NETWORK_TIMEOUT, check=False) if proc.returncode == 0: # push сам не двигает refs/remotes/origin/*, а от актуальности этой # ссылки зависит право на amend в следующий раз. head = self.head_sha() if head: self._run(["update-ref", self._remote_ref, head], check=False) return stderr = proc.stderr.decode("utf-8", "replace") if "stale info" in stderr or "non-fast-forward" in stderr or "fetch first" in stderr: raise Diverged( "На сервере появились изменения с другой машины. " "Автопуш остановлен, чтобы ничего не затереть." ) raise GitError(scrub(stderr.strip(), self.settings.token)) # --- основной сценарий --- def sync(self, remote_copy_path: Path | None = None) -> SyncResult: """Полный цикл: fetch → примирение → коммит → push. Fetch идёт первым намеренно. Право на amend зависит от того, что сейчас на сервере, поэтому спрашивать надо до переписывания истории, а не после. """ if not self.settings.remote_url: raise GitError("Не задан адрес репозитория — укажи его в настройках.") self.ensure_repo() remote = self.fetch() result = SyncResult() head = self.head_sha() if head is None and remote: self._adopt_remote_history(remote, remote_copy_path) head = self.head_sha() if remote and head and head != remote: behind = self._is_ancestor(head, remote) ahead = self._is_ancestor(remote, head) if behind and not ahead: if self.has_uncommitted_vault_changes(): # Правки с двух машин одновременно: то, что лежит локально, # и то, что на сервере — обе версии настоящие. self._report_divergence(remote_copy_path) self._run(["merge", "--ff-only", self._remote_ref]) result.pulled = True result.message = "Подтянуты изменения с сервера" elif not ahead and not behind: self._report_divergence(remote_copy_path) committed, amended, lease = self._commit(remote) result.committed = committed result.amended = amended head = self.head_sha() if head and head != self.remote_sha(): self._push(amended, lease) result.pushed = True if not result.message: if result.pushed and amended: result.message = "Сохранено в сегодняшний коммит" elif result.pushed: result.message = "Отправлено на сервер" else: result.message = "Изменений нет" return result # --- ручное разрешение конфликта --- # # Обе операции разрушительны, поэтому вызываются только из настроек после # явного подтверждения. Автоматика сюда не заходит никогда. def reset_to_remote(self) -> None: """Откатить локальную копию к версии с сервера.""" self.fetch() if self.remote_sha() is None: raise GitError("На сервере ещё нет ветки — откатываться не к чему.") self._run(["reset", "--hard", self._remote_ref]) def force_push(self) -> None: """Перезаписать сервер локальной версией.""" self.ensure_repo() self._run(["add", "--", self.rel_path]) if self._run(["diff", "--cached", "--quiet", "--", self.rel_path], check=False).returncode != 0: self._run(["commit", "-m", commit_message(), "--", self.rel_path]) url = auth_url(self.settings.remote_url, self.settings.token) proc = self._run( ["push", "--force", url, f"HEAD:refs/heads/{self.branch}"], timeout=NETWORK_TIMEOUT, check=False, ) if proc.returncode != 0: raise GitError(scrub(proc.stderr.decode("utf-8", "replace").strip(), self.settings.token)) head = self.head_sha() if head: self._run(["update-ref", self._remote_ref, head], check=False) def _adopt_remote_history(self, remote: str, remote_copy_path: Path | None) -> None: """Встать на историю сервера, когда локальных коммитов ещё нет. Так бывает при первом запуске: репозиторий данных уже заведён и, как правило, содержит README. Если начать свою историю параллельно, первый же пуш упрётся в non-fast-forward. """ remote_has_vault = ( self._run(["cat-file", "-e", f"{remote}:{self.rel_path}"], check=False).returncode == 0 ) if remote_has_vault and (self.repo_dir / self.rel_path).exists(): # На сервере уже лежит база, и локально создали ещё одну. Обе # настоящие — какая нужна, решать пользователю. self._report_divergence(remote_copy_path) self._run(["update-ref", f"refs/heads/{self.branch}", remote]) # База не отслеживается, поэтому reset её не тронет — заберём только # то, что уже есть на сервере. self._run(["reset", "--hard", remote]) def _report_divergence(self, remote_copy_path: Path | None) -> None: if remote_copy_path is not None: self.save_remote_copy(remote_copy_path) raise Diverged( "Локальная база и база на сервере разошлись — их правили с двух машин. " "Слить зашифрованные файлы автоматически нельзя. " "Удалённая версия сохранена рядом, выбери нужную в настройках." )