Implement 3-way merge on concurrent saves using git merge-file
This commit is contained in:
+68
-12
@@ -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:
|
||||
|
||||
+1
-1
Submodule site updated: 1539bdb3a7...476f605a4a
Reference in New Issue
Block a user