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 re
import secrets
import shutil
import sqlite3
import tempfile
import time
from contextlib import contextmanager
from dataclasses import dataclass
@@ -74,6 +76,42 @@ async def _git_file_hash(file_path: Path) -> str:
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:
rel = str(file_path.relative_to(_ROOT))
env = _git_env()
@@ -319,13 +357,22 @@ async def get_page(slug: str, user: User = Depends(require_auth)) -> JSONRespons
@app.post("/pages/{slug:path}")
async def save_page(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
path = _slug_to_path(slug, pages_dir)
if body.base_hash is not None:
current = await _git_file_hash(path)
if current and current != body.base_hash:
raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare")
result = _save(slug, body, pages_dir)
content = body.content
if body.base_hash and path.exists():
current_hash = await _git_file_hash(path)
if current_hash and current_hash != body.base_hash:
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")
return result
return JSONResponse({"slug": str(path.relative_to(pages_dir).with_suffix("")), "saved": True})
@app.delete("/pages/{slug:path}")
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}")
async def save_story(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse:
path = _slug_to_path(slug, stories_dir)
if body.base_hash is not None:
current = await _git_file_hash(path)
if current and current != body.base_hash:
raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare")
result = _save(slug, body, stories_dir)
content = body.content
if body.base_hash and path.exists():
current_hash = await _git_file_hash(path)
if current_hash and current_hash != body.base_hash:
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")
return result
return JSONResponse({"slug": str(path.relative_to(stories_dir).with_suffix("")), "saved": True})
@app.delete("/stories/{slug:path}")
async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse: