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 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
Reference in New Issue
Block a user