Implement 3-way merge on concurrent saves using git merge-file

This commit is contained in:
brutsalvadi
2026-05-08 08:47:47 +02:00
parent 80dc51a706
commit fe35846b67
2 changed files with 69 additions and 13 deletions
+68 -12
View File
@@ -6,7 +6,9 @@ import asyncio
import os import os
import re import re
import secrets import secrets
import shutil
import sqlite3 import sqlite3
import tempfile
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
@@ -74,6 +76,42 @@ async def _git_file_hash(file_path: Path) -> str:
return "" return ""
async def _three_way_merge(
file_path: Path, user_content: str, base_hash: str, handle: str
) -> tuple[str, bool]:
"""Returns (merged_content, has_conflict). Falls back to user_content on error."""
rel = str(file_path.relative_to(_ROOT))
env = _git_env()
proc = await asyncio.create_subprocess_exec(
"git", "show", f"{base_hash}:{rel}",
cwd=str(_ROOT), env=env,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return user_content, False
base_content = stdout.decode()
current_content = file_path.read_text(encoding="utf-8")
tmpdir = tempfile.mkdtemp()
try:
p_current = Path(tmpdir) / "current.md"
p_base = Path(tmpdir) / "base.md"
p_user = Path(tmpdir) / "user.md"
p_current.write_text(current_content, encoding="utf-8")
p_base.write_text(base_content, encoding="utf-8")
p_user.write_text(user_content, encoding="utf-8")
proc = await asyncio.create_subprocess_exec(
"git", "merge-file",
"-L", "attuale", "-L", "base", "-L", handle,
str(p_current), str(p_base), str(p_user),
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return p_current.read_text(encoding="utf-8"), proc.returncode > 0
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
async def _git_commit(file_path: Path, handle: str, verb: str) -> None: async def _git_commit(file_path: Path, handle: str, verb: str) -> None:
rel = str(file_path.relative_to(_ROOT)) rel = str(file_path.relative_to(_ROOT))
env = _git_env() env = _git_env()
@@ -319,13 +357,22 @@ async def get_page(slug: str, user: User = Depends(require_auth)) -> JSONRespons
@app.post("/pages/{slug:path}") @app.post("/pages/{slug:path}")
async def save_page(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse: async def save_page(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
path = _slug_to_path(slug, pages_dir) path = _slug_to_path(slug, pages_dir)
if body.base_hash is not None: content = body.content
current = await _git_file_hash(path) if body.base_hash and path.exists():
if current and current != body.base_hash: current_hash = await _git_file_hash(path)
raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare") if current_hash and current_hash != body.base_hash:
result = _save(slug, body, pages_dir) merged, conflict = await _three_way_merge(path, body.content, body.base_hash, user.handle)
if conflict:
raise HTTPException(409, detail={
"message": "Conflitto — risolvi i marcatori e salva di nuovo",
"content": merged,
"base_hash": current_hash,
})
content = merged
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
await _git_commit(path, user.handle, "edited") await _git_commit(path, user.handle, "edited")
return result return JSONResponse({"slug": str(path.relative_to(pages_dir).with_suffix("")), "saved": True})
@app.delete("/pages/{slug:path}") @app.delete("/pages/{slug:path}")
async def delete_page(slug: str, user: User = Depends(require_auth)) -> JSONResponse: async def delete_page(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
@@ -348,13 +395,22 @@ async def get_story(slug: str, user: User = Depends(require_auth)) -> JSONRespon
@app.post("/stories/{slug:path}") @app.post("/stories/{slug:path}")
async def save_story(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse: async def save_story(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
path = _slug_to_path(slug, stories_dir) path = _slug_to_path(slug, stories_dir)
if body.base_hash is not None: content = body.content
current = await _git_file_hash(path) if body.base_hash and path.exists():
if current and current != body.base_hash: current_hash = await _git_file_hash(path)
raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare") if current_hash and current_hash != body.base_hash:
result = _save(slug, body, stories_dir) merged, conflict = await _three_way_merge(path, body.content, body.base_hash, user.handle)
if conflict:
raise HTTPException(409, detail={
"message": "Conflitto — risolvi i marcatori e salva di nuovo",
"content": merged,
"base_hash": current_hash,
})
content = merged
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
await _git_commit(path, user.handle, "edited") await _git_commit(path, user.handle, "edited")
return result return JSONResponse({"slug": str(path.relative_to(stories_dir).with_suffix("")), "saved": True})
@app.delete("/stories/{slug:path}") @app.delete("/stories/{slug:path}")
async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse: async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse:
+1 -1
Submodule site updated: 1539bdb3a7...476f605a4a