Selbst-Update: lose/geänderte Dateien werden automatisch beiseitegelegt (t_f73a1cc5)

EditorUpdater.pull: vor dem pull --ff-only git stash push -u (erfasst
GEÄNDERT + UNTRACKED — genau die losen .import-Dateien, die am 19.10.
den Update-Knopf blockierten). Nach dem Pull zurückholen (apply+drop);
kollidiert das Zurückholen: reset --hard gegen halb gepoppte Zustände,
Stash bleibt als Sicherung, ehrliche Klartext-Meldung in der Statuszeile
statt roher Git-Zeile. Alte Auto-Stashes gleicher Kennung werden beim
Anlegen verworfen (keine Anhäufung). main.gd zeigt die Klartext-Info.

tests/test_updater.gd: Negativprobe (nackter pull bricht an untracked
vs. getrackt ab), Fix-Probe (läuft durch), geänderte Datei bleibt
erhalten, Konflikt-Fall mit Sicherungs-Stash, Anti-Anhäufung. 69 checks.
This commit is contained in:
Hermes (scopedvr-dev) 2026-10-19 18:41:19 +00:00
parent 3e07e91f5a
commit 90c3c7fdc7
3 changed files with 219 additions and 4 deletions

View file

@ -168,14 +168,40 @@ static func banner_text(local_sha: String, remote_sha: String) -> String:
return "🔄 Update verfügbar: %s → %s" % [short_sha(local_sha), short_sha(remote_sha)] return "🔄 Update verfügbar: %s → %s" % [short_sha(local_sha), short_sha(remote_sha)]
## Klartext-Status für die Statuszeile (Karte t_f73a1cc5): Wenn lokale
## (meist von Godot erzeugte) Dateien das Update blockieren WÜRDEN, legt der
## Pull sie selbst beiseite — Sly liest diesen Satz statt einer rohen Git-
## Zeile („Bitte verschieben oder entfernen Sie diese…“).
const AUTO_STASH_INFO_TEXT := "Update blockiert: eigene erzeugte Dateien — werden automatisch beiseitegelegt und das Update läuft weiter."
## Ehrliche Meldung, wenn das Zurückholen kollidiert ist (Stash bleibt als
## Sicherung liegen; Godot baut die erzeugten Dateien beim Import neu).
const STASH_KEPT_TEXT := "Erzeugte Dateien bleiben beiseitegelegt — beim nächsten Start neu aufgebaut (Sicherungs-Stash bleibt liegen, nichts verloren)."
## Kennung der eigenen Auto-Stashes (Verwerfen alter Einträge, damit sie
## sich nicht anhäufen).
const AUTO_STASH_MESSAGE := "auto: Selbst-Update"
## Update ausführen: git pull --ff-only (Klon zieht nur nach, dreht nichts ## Update ausführen: git pull --ff-only (Klon zieht nur nach, dreht nichts
## um). Rückgabe {ok, output, errors}. Auth-Fehler bekommen einen klaren ## um). Vorher werden lokal Erzeugtes/Geändertes AUTOMATISCH beiseitegelegt
## Zusatz-Hinweis (kein Login-Prompt — Karte t_1457e935). ## (Karte t_f73a1cc5): `git stash push -u` erfasst GEÄNDERT **und** UNTRACKED
## Dateien (`--autostash` allein reicht nicht — es lässt untracked liegen;
## genau die haben am 19.10. den Pull blockiert). Nach dem Pull wird das
## Beiseitegelegte zurückgeholt; kollidiert das (Typfall: lose `.import`-
## Datei trifft auf dieselbe Datei neu getrackt), bleibt der Stash als
## Sicherung liegen und die Statuszeile bekommt den Klartext — NIE ein halb
## gepoppter Zustand. Rückgabe {ok, output, errors, info} (info = Klartext
## fürs Statuszeile-Fenster, leer wenn nichts besonderes passiert ist).
static func pull(git_exe: String, project_dir: String) -> Dictionary: static func pull(git_exe: String, project_dir: String) -> Dictionary:
var stash_info := _stash_local_changes(git_exe, project_dir)
var r := git_run(git_exe, project_dir, PackedStringArray(["pull", "--ff-only"])) var r := git_run(git_exe, project_dir, PackedStringArray(["pull", "--ff-only"]))
var errors: Array = [] var errors: Array = []
var text := str(r["output"]).strip_edges() var text := str(r["output"]).strip_edges()
var info := ""
if not bool(r["ok"]): if not bool(r["ok"]):
# Pull fehlgeschlagen (z. B. offline): Beiseitegelegtes zurückholen,
# damit nichts „verschwindet", wenn der Fehler nur am Netz lag.
if not stash_info.is_empty():
_restore_stash(git_exe, project_dir)
errors.append("git pull --ff-only fehlgeschlagen (Exit-Code %s):" % str(r["code"])) errors.append("git pull --ff-only fehlgeschlagen (Exit-Code %s):" % str(r["code"]))
if not text.is_empty(): if not text.is_empty():
errors.append(text) errors.append(text)
@ -183,7 +209,79 @@ static func pull(git_exe: String, project_dir: String) -> Dictionary:
errors.append("(keine Ausgabe — git nicht gefunden oder Verbindung sofort weg)") errors.append("(keine Ausgabe — git nicht gefunden oder Verbindung sofort weg)")
if looks_like_auth_error(text): if looks_like_auth_error(text):
errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).") errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).")
return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors} else:
if not stash_info.is_empty():
info = AUTO_STASH_INFO_TEXT
var restored := _restore_stash(git_exe, project_dir)
if not bool(restored["ok"]):
info += "\n" + STASH_KEPT_TEXT
return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors, "info": info}
## Lokales Beiseitelegen vor dem Pull (Karte t_f73a1cc5). Rückgabe: Referenz
## auf den eigenen Auto-Stash (z. B. "stash@{0}") oder "" wenn es nichts zu
## beiseitelegen gab / der Stash nicht angelegt werden konnte.
## - Zuerst alte EIGENE Auto-Stashes verwerfen (gleiche Kennung) — sie
## enthalten nur erzeugte Dateien und dürfen sich nicht anhäufen.
## - "No local changes to save" endet ebenfalls mit Exit 0 → NICHT am Text
## matchen, sondern prüfen, ob ein Eintrag mit unserer Kennung entstand.
static func _stash_local_changes(git_exe: String, project_dir: String) -> String:
_drop_auto_stashes(git_exe, project_dir)
var r := git_run(git_exe, project_dir, PackedStringArray([
"stash", "push", "-u", "-m", AUTO_STASH_MESSAGE]))
if not bool(r["ok"]):
return ""
return _auto_stash_ref(git_exe, project_dir)
## Referenz des eigenen Auto-Stashes ("stash@{N}") oder "".
static func _auto_stash_ref(git_exe: String, project_dir: String) -> String:
var r := git_run(git_exe, project_dir, PackedStringArray([
"stash", "list", "--pretty=format:%gd|%s"]))
if not bool(r["ok"]):
return ""
for line in str(r["output"]).split("\n"):
var parts := str(line).strip_edges().split("|", true, 1)
if parts.size() == 2 and str(parts[1]).contains(AUTO_STASH_MESSAGE):
return str(parts[0])
return ""
## Alle EIGENEN Auto-Stashes (Kennung AUTO_STASH_MESSAGE) verwerfen — von
## hinten, weil die Indizes sich beim Drop verschieben.
static func _drop_auto_stashes(git_exe: String, project_dir: String) -> void:
var r := git_run(git_exe, project_dir, PackedStringArray([
"stash", "list", "--pretty=format:%gd|%s"]))
if not bool(r["ok"]):
return
var refs: Array = []
for line in str(r["output"]).split("\n"):
var parts := str(line).strip_edges().split("|", true, 1)
if parts.size() == 2 and str(parts[1]).contains(AUTO_STASH_MESSAGE):
refs.append(str(parts[0]))
refs.reverse()
for ref in refs:
git_run(git_exe, project_dir, PackedStringArray(["stash", "drop", ref]))
## Beiseitegelegtes zurückholen. apply (nicht pop), damit die Referenz nicht
## am Stack verschiebt; bei Erfolg wird der Eintrag gedroppt. Kollidiert das
## Zurückholen (Typfall: erzeugte Datei ist inzwischen getrackt), wird mit
## `git reset --hard` der halb gepoppte Zustand weggeräumt und der Stash ALS
## SICHERUNG LIEGEN GELASSEN — kein Datenverlust, Godot erzeugt die Dateien
## beim nächsten Import-Lauf neu. Bewusst KEIN `git clean`: untracked
## Nutzdaten (z. B. Maps/) dürfen nie angefasst werden.
## Rückgabe {ok, kept, output}.
static func _restore_stash(git_exe: String, project_dir: String) -> Dictionary:
var ref := _auto_stash_ref(git_exe, project_dir)
if ref.is_empty():
return {"ok": true, "kept": false, "output": ""}
var r := git_run(git_exe, project_dir, PackedStringArray(["stash", "apply", ref]))
if bool(r["ok"]):
git_run(git_exe, project_dir, PackedStringArray(["stash", "drop", ref]))
return {"ok": true, "kept": false, "output": str(r["output"])}
git_run(git_exe, project_dir, PackedStringArray(["reset", "--hard"]))
return {"ok": false, "kept": true, "output": str(r["output"])}
## Import-Lauf: <godot> --headless --path <projekt> --import. Muss NACH dem ## Import-Lauf: <godot> --headless --path <projekt> --import. Muss NACH dem

View file

@ -1202,7 +1202,13 @@ func _updater_run_thread() -> void:
if not bool(pull["ok"]): if not bool(pull["ok"]):
call_deferred("_update_finished", false, "Update abgebrochen — git pull fehlgeschlagen:", pull["errors"]) call_deferred("_update_finished", false, "Update abgebrochen — git pull fehlgeschlagen:", pull["errors"])
return return
call_deferred("_set_status", "Update geladen — Version wird importiert (Godot-Import-Lauf)…") # Karte t_f73a1cc5: Wenn der Pull eigene Dateien automatisch beiseite-
# gelegt hat, zeigt die Statuszeile den Klartext statt der technischen
# Import-Meldung (KEINE rohe Git-Zeile).
var info := str(pull.get("info", ""))
if info.is_empty():
info = "Update geladen — Version wird importiert (Godot-Import-Lauf)…"
call_deferred("_set_status", info)
var godot := TestRun.find_godot(cfg) var godot := TestRun.find_godot(cfg)
if godot.is_empty(): if godot.is_empty():
call_deferred("_update_finished", false, "Update abgebrochen — kein Godot-Programm für den Import-Lauf:", [ call_deferred("_update_finished", false, "Update abgebrochen — kein Godot-Programm für den Import-Lauf:", [

View file

@ -82,6 +82,39 @@ func _write_file(path: String, text: String) -> void:
f.close() f.close()
func _file_text(path: String) -> String:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return "<fehlt>"
var text := f.get_as_text()
f.close()
return text
## Nur getrackte Verschmutzungen: false, wenn eine NICHT-untracked Zeile in
## git status --porcelain auftaucht (halb gepoppter Konflikt hinterlässt
## genau solche Zeilen).
func _clean_tracked(dir: String) -> bool:
var r := _git(dir, ["status", "--porcelain"])
for line in str(r["output"]).split("\n"):
var text := str(line).strip_edges()
if not text.is_empty() and not text.begins_with("??"):
return false
return true
## Anzahl der EIGENEN Auto-Stashes (Kennung "auto: Selbst-Update").
func _auto_stash_count(git: String, dir: String) -> int:
var r := EditorUpdater.git_run(git, dir, PackedStringArray([
"stash", "list", "--pretty=format:%gd|%s"]))
var count := 0
for line in str(r["output"]).split("\n"):
var parts := str(line).strip_edges().split("|", true, 1)
if parts.size() == 2 and str(parts[1]).contains("auto: Selbst-Update"):
count += 1
return count
func _run_tests() -> void: func _run_tests() -> void:
var git := "git" var git := "git"
@ -182,6 +215,84 @@ func _run_tests() -> void:
_check((dead_pull["errors"] as Array).size() > 0, "Pull-Fehlerliste fürs Dialog-Fenster") _check((dead_pull["errors"] as Array).size() > 0, "Pull-Fehlerliste fürs Dialog-Fenster")
_git(work, ["remote", "set-url", "origin", origin]) _git(work, ["remote", "set-url", "origin", origin])
print("== Selbst-Update: lose Dateien blockieren den Pull nicht (Karte t_f73a1cc5) ==")
# Origin bekommt eine GETRACKTE Import-Datei — lokal existiert sie als
# untracked (Godot erzeugt sie beim Import). Genau die Kollision, die am
# 19.10. den Update-Knopf blockiert hat.
DirAccess.make_dir_recursive_absolute(other.path_join("library/thumbs"))
_write_file(other.path_join("library/thumbs/zz_probe.png.import"), "origin-version\n")
_git(other, ["add", "library/thumbs/zz_probe.png.import"])
_git(other, ["commit", "-m", "v3: Import-Datei getrackt"])
_check(bool(_git(other, ["push", "origin", "main"])["ok"]),
"Setup: v3 gepusht (Import-Datei jetzt getrackt)")
# NEGATIVPROBE: der NACKTE Pull (ohne Fix) bricht an der losen Datei ab.
DirAccess.make_dir_recursive_absolute(work.path_join("library/thumbs"))
_write_file(work.path_join("library/thumbs/zz_probe.png.import"), "lokal-erzeugt\n")
var raw := EditorUpdater.git_run(git, work, PackedStringArray(["pull", "--ff-only"]))
_check(not bool(raw["ok"]), "NEGATIVPROBE: nackter pull --ff-only bricht an der Kollision ab")
_check(not str(raw["output"]).strip_edges().is_empty(),
"NEGATIVPROBE: git liefert die rohe (verwirrende) Fehlerzeile")
# MIT Fix: EditorUpdater.pull legt selbst beiseite und zieht durch.
var fixed := EditorUpdater.pull(git, work)
_check(bool(fixed["ok"]), "MIT Fix: pull läuft ohne Handeingriff durch")
_check(str(fixed["info"]).contains("beiseitegelegt"),
"Klartext-Info fürs Statuszeilen-Fenster gesetzt")
_check(_file_text(work.path_join("library/thumbs/zz_probe.png.import")) == "origin-version\n",
"kein halb gepoppter Zustand: getrackte Version liegt an")
_check(_clean_tracked(work), "Arbeitsbaum (getrackt) sauber — Godot baut die Datei beim Import neu")
_check(_auto_stash_count(git, work) == 1, "Sicherungs-Stash bleibt bei Konflikt liegen")
print("== Selbst-Update: geänderte getrackte Datei bleibt erhalten ==")
# v4 fasst a.txt NICHT an → das Zurückholen der lokalen b.txt-Änderung
# MUSS klappen: apply + drop, kein Stash-Rest.
_write_file(work.path_join("b.txt"), "lokal geändert\n")
_write_file(other.path_join("c.txt"), "neu\n")
_git(other, ["add", "c.txt"])
_git(other, ["commit", "-m", "v4: nur c.txt"])
_check(bool(_git(other, ["push", "origin", "main"])["ok"]), "Setup: v4 gepusht")
var fixed2 := EditorUpdater.pull(git, work)
_check(bool(fixed2["ok"]), "pull mit lokaler Änderung läuft durch")
_check(_file_text(work.path_join("b.txt")) == "lokal geändert\n",
"lokal geänderte Datei ist zurückgeholt (kein Datenverlust)")
_check(_auto_stash_count(git, work) == 0,
"Auto-Stash nach erfolgreichem Rückholen aufgeräumt (keine Anhäufung)")
print("== Selbst-Update: Kollision mit Origin → Stash bleibt als Sicherung ==")
# v5 ändert dieselbe Datei wie lokal → Zurückholen kollidiert; der Stash
# bleibt als Sicherung liegen, der Arbeitsbaum ist sauber auf v5.
_write_file(work.path_join("a.txt"), "lokal-geändert\n")
_write_file(other.path_join("a.txt"), "origin-geändert\n")
_git(other, ["add", "a.txt"])
_git(other, ["commit", "-m", "v5: a.txt geändert"])
_check(bool(_git(other, ["push", "origin", "main"])["ok"]), "Setup: v5 gepusht")
var fixed3 := EditorUpdater.pull(git, work)
_check(bool(fixed3["ok"]), "Update läuft trotzdem durch (Änderung liegt sicher im Stash)")
_check(_file_text(work.path_join("a.txt")) == "origin-geändert\n",
"Arbeitsbaum = Origin-Stand (kein Konflikt-Brei, keine halbe Datei)")
_check(str(fixed3["info"]).contains("neu aufgebaut"),
"ehrliche Meldung: beiseitegelegt, beim nächsten Start neu aufgebaut")
var show := EditorUpdater.git_run(git, work, PackedStringArray([
"stash", "show", "-p", "stash@{0}"]))
_check(str(show["output"]).contains("lokal-geändert"),
"geänderte Datei liegt unverlustfrei im Sicherungs-Stash")
print("== Auto-Stash: alte Kennung wird verworfen, nichts häuft sich an ==")
_write_file(work.path_join("library/thumbs/zwei.png.import"), "alt\n")
var s1 := EditorUpdater._stash_local_changes(git, work)
_write_file(work.path_join("library/thumbs/drei.png.import"), "neu\n")
var s2 := EditorUpdater._stash_local_changes(git, work)
_check(not s1.is_empty() and not s2.is_empty(), "beide Läufe legen einen Auto-Stash an")
_check(_auto_stash_count(git, work) == 1, "genau EIN Auto-Stash übrig (alter verworfen)")
# Für die Folge-Abschnitte aufräumen: Stash zurückholen (klappert, weil
# die zwei untracked Dateien an keine getrackte Datei grenzen) und die
# Probendateien wegwerfen.
_check(bool(EditorUpdater._restore_stash(git, work)["ok"]), "Aufräumen: Stash zurückgeholt")
DirAccess.remove_absolute(work.path_join("library/thumbs/zwei.png.import"))
DirAccess.remove_absolute(work.path_join("library/thumbs/drei.png.import"))
_check(_auto_stash_count(git, work) == 0, "Aufräumen: kein Auto-Stash übrig")
print("== Import-Abbruch-Pfad ==") print("== Import-Abbruch-Pfad ==")
_check(EditorUpdater.import_errors([]).is_empty(), "leere Import-Ausgabe = keine Fehler") _check(EditorUpdater.import_errors([]).is_empty(), "leere Import-Ausgabe = keine Fehler")
_check(EditorUpdater.import_errors(["Eine normale Zeile"]).is_empty(), "normale Ausgabe = keine Fehler") _check(EditorUpdater.import_errors(["Eine normale Zeile"]).is_empty(), "normale Ausgabe = keine Fehler")