Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
400 lines
18 KiB
Python
400 lines
18 KiB
Python
"""Синхронизация зашифрованной базы через системный 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}$")
|
||
|
||
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/<branch>. Возвращает её 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 _commit(self, remote: str | None) -> tuple[bool, bool, str | None]:
|
||
"""Закоммитить базу. Возвращает (закоммитили, через amend, sha до amend)."""
|
||
self._run(["add", "--", self.rel_path])
|
||
|
||
staged = self._run(
|
||
["diff", "--cached", "--quiet", "--", self.rel_path], check=False
|
||
).returncode != 0
|
||
if not staged:
|
||
return False, False, None
|
||
|
||
if 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(), "--", self.rel_path])
|
||
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 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 _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(
|
||
"Локальная база и база на сервере разошлись — их правили с двух машин. "
|
||
"Слить зашифрованные файлы автоматически нельзя. "
|
||
"Удалённая версия сохранена рядом, выбери нужную в настройках."
|
||
)
|