food-market/app/gitsync.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:29:53 +03:00

400 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Синхронизация зашифрованной базы через системный 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(
"Локальная база и база на сервере разошлись — их правили с двух машин. "
"Слить зашифрованные файлы автоматически нельзя. "
"Удалённая версия сохранена рядом, выбери нужную в настройках."
)