v0.8.00-beta - een kapotte git-cache herstelt zichzelf
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 18m16s

"fatal: shallow file has changed since we read it" komt van twee git fetch in
dezelfde map. Bij het opstarten synchroniseert Server Up alles, en ondertussen
kan iemand op Synchroniseren drukken of een app installeren. Er zat geen slot
op, dus een synchronisatie kon over een andere heen lopen en de cache
halverwege achterlaten - waarna de repo zich niet meer liet bijwerken en de app
store leeg bleef.

- een slot per repo, zodat er nog maar een git tegelijk in een cache werkt
- lukt bijwerken niet, om welke reden dan ook, dan wordt de cache weggegooid en
  opnieuw opgehaald. Er staat niets in dat bewaard hoeft te blijven, dus dat is
  het juiste antwoord op elke fout hier. Dit repareert ook caches die al stuk
  zijn

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
This commit is contained in:
Ramon 2026-08-03 20:51:42 +02:00
parent 3fd6c19409
commit 2d85668021
4 changed files with 125 additions and 5 deletions

View file

@ -1,3 +1,26 @@
# v0.8.00-beta — Een kapotte cache herstelt zichzelf
`fatal: shallow file has changed since we read it` liet een repo achter die zich
niet meer liet bijwerken — en daarmee een app store die leeg bleef.
Die melding komt van twee `git fetch` in dezelfde map. Bij het opstarten
synchroniseert Server Up alles, en ondertussen kan je op **Synchroniseren**
drukken of een app installeren die de repo ook aanraakt. Er zat geen slot op:
elke synchronisatie kon over een andere heen lopen en de cache halverwege
achterlaten, met een `.git/shallow` waar git niets meer mee kan.
Twee dingen erbij:
- **Eén git tegelijk per repo.** Botsen kan nu niet meer.
- **Zelfherstel.** Lukt het bijwerken niet — om welke reden dan ook — dan wordt
de cache weggegooid en opnieuw opgehaald. Er staat niets in dat bewaard hoeft
te blijven, dus dat is het juiste antwoord op élke fout hier, en niet alleen
op deze. Een repo die zich niet meer laat bijwerken kan daarmee niet meer
bestaan.
Dat repareert ook een cache die al stuk is: de eerstvolgende synchronisatie
haalt hem gewoon opnieuw op.
# v0.7.99-beta — "Gekoppeld" zei niets
`--doctor` meldde vrolijk dat `/opt/serverup` in de container gekoppeld was,

View file

@ -1 +1 @@
0.7.99-beta
0.8.00-beta

View file

@ -1,5 +1,5 @@
"""Git operations — clone, pull, scan for stacks and modules."""
import json, os, re, subprocess, shutil
import json, os, re, subprocess, shutil, threading
from pathlib import Path
from core.docker import COMPOSE_NAMES
from core import boilerplates as bp
@ -15,6 +15,18 @@ def guess_logo_url(name: str) -> str:
CACHE = Path(os.environ.get("SU_GIT_CACHE", "/data/git"))
# Eén git tegelijk per repo. Bij het opstarten wordt alles gesynchroniseerd, en
# ondertussen kan iemand op "Synchroniseren" drukken of een app installeren.
# Twee `git fetch` in dezelfde map leveren "fatal: shallow file has changed
# since we read it" op — en laten de cache halverwege achter.
_repo_sloten: dict[str, threading.Lock] = {}
_sloten_lock = threading.Lock()
def _slot(rid: str) -> threading.Lock:
with _sloten_lock:
return _repo_sloten.setdefault(rid, threading.Lock())
# Fix git "dubious ownership" — container draait als root, cache kan door andere uid zijn
_git_safe_set = False
@ -60,7 +72,9 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
env["GIT_TERMINAL_PROMPT"] = "0"
try:
if dest.exists() and (dest / ".git").exists():
with _slot(rid):
vers_ophalen = not (dest.exists() and (dest / ".git").exists())
if not vers_ophalen:
if log_fn:
log_fn(f"Bijwerken {rid} ({branch})…")
# Geen `git pull --rebase`. Dat commando leunt op de
@ -84,12 +98,25 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
r = subprocess.run(["git", "reset", "--hard", "FETCH_HEAD"],
cwd=str(dest), capture_output=True, text=True,
env=env, timeout=60)
else:
if r.returncode != 0:
# Wat er ook misging — een half afgebroken fetch die een kapot
# `.git/shallow` achterliet, een verdwenen tak, een lege map —
# de cache bevat niets dat bewaard hoeft te blijven. Opnieuw
# ophalen is daarom het antwoord op élke fout hier, in plaats
# van een repo die zich niet meer laat bijwerken en een app
# store die leeg blijft.
kort = (r.stderr or r.stdout or "").strip().splitlines()
if log_fn:
log_fn(f"Bijwerken mislukte ({kort[-1][:90] if kort else '?'});"
f" ik haal {rid} opnieuw op")
vers_ophalen = True
if vers_ophalen:
if log_fn:
log_fn(f"Clone {url}")
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest)
shutil.rmtree(dest, ignore_errors=True)
r = subprocess.run(["git", "clone", "--branch", branch, "--depth", "1",
clone_url, str(dest)],
capture_output=True, text=True, env=env, timeout=120)

View file

@ -173,3 +173,73 @@ def test_lokale_rommel_in_de_cache_blokkeert_niets(env, tmp_path, monkeypatch):
ok, msg = git.clone_or_pull(repo)
assert ok, msg
assert (git.cache_dir("proef") / "app.txt").read_text() == "van main"
def test_een_kapotte_cache_haalt_zichzelf_opnieuw_op(env, tmp_path, monkeypatch):
""""fatal: shallow file has changed since we read it" liet een repo achter
die zich niet meer liet bijwerken, en daarmee een lege app store.
De cache bevat niets dat bewaard hoeft te blijven, dus opnieuw ophalen is
het antwoord op elke fout niet alleen op deze."""
import shutil as _shutil
from core import git
if not _shutil.which("git"):
pytest.skip("git niet aanwezig")
bron = _repo_met_twee_takken(tmp_path)
monkeypatch.setattr("core.valid_repo_url", lambda u: True)
repo = {"id": "proef", "url": f"file://{bron}", "branch": "main"}
assert git.clone_or_pull(repo)[0]
cache = git.cache_dir("proef")
# Een `.git/shallow` met onzin erin: precies wat een halverwege afgebroken
# fetch achterlaat.
(cache / ".git" / "shallow").write_text("dit is geen commit\n", encoding="utf-8")
(cache / "app.txt").unlink()
regels = []
ok, msg = git.clone_or_pull(repo, log_fn=regels.append)
assert ok, f"de cache herstelt zichzelf niet: {msg}"
assert (cache / "app.txt").read_text() == "van main"
assert any("opnieuw op" in r for r in regels), regels
def test_twee_synchronisaties_tegelijk_botsen_niet(env, tmp_path, monkeypatch):
"""Bij het opstarten wordt alles gesynchroniseerd; ondertussen kan iemand
op Synchroniseren drukken. Twee `git fetch` in dezelfde map leveren
"shallow file has changed" op en laten de cache halverwege achter."""
import shutil as _shutil
import threading
from core import git
if not _shutil.which("git"):
pytest.skip("git niet aanwezig")
bron = _repo_met_twee_takken(tmp_path)
monkeypatch.setattr("core.valid_repo_url", lambda u: True)
repo = {"id": "proef", "url": f"file://{bron}", "branch": "main"}
assert git.clone_or_pull(repo)[0]
tegelijk = []
bezig = threading.Lock()
echt = git.subprocess.run
def merk_op(cmd, **kw):
if cmd[:2] == ["git", "fetch"]:
if not bezig.acquire(blocking=False):
tegelijk.append(cmd)
else:
try:
return echt(cmd, **kw)
finally:
bezig.release()
return echt(cmd, **kw)
monkeypatch.setattr(git.subprocess, "run", merk_op)
draden = [threading.Thread(target=git.clone_or_pull, args=(dict(repo),))
for _ in range(4)]
for d in draden:
d.start()
for d in draden:
d.join(timeout=60)
assert not tegelijk, f"{len(tegelijk)} fetches liepen door elkaar heen"
assert (cache_na := (git.cache_dir("proef") / "app.txt")).read_text() == "van main", cache_na