publish kann den allerersten Commit nie zu einem leeren Remote pushen - _local_ahead_of_remote verwechselt "Branch nie gepusht" mit "nicht ahead" #97

Closed
opened 2026-09-12 11:38:03 +00:00 by torben · 2 comments
Owner

Befund

Nach publish --no-push (dem Workaround aus #96) existierte ein lokaler Commit auf main, origin war ein frisch angelegtes, komplett leeres Repository. Der zweite, dokumentierte publish-Aufruf sollte diesen Commit pushen:

$ tools/wikitool publish --message "chore: initial instance setup"
No remote configured, or origin could not be reached - continuing without a pull.
OK Nothing to commit.

Kein Push fand statt - verifiziert per git ls-remote origin (leer) direkt danach. Beliebig oft wiederholt, solange der Working-Tree sauber war, blieb das Ergebnis dasselbe: der Commit blieb für immer lokal.

Betroffene Version: 5.0.0. Behoben in 5.0.1-beta.1, Commit 9ef021b.

Ursache - verifiziert

git fetch origin main scheitert gegen ein leeres Remote-Repo erwartungsgemäß (fatal: couldn't find remote ref main). Das ist in fetch_remote() als harmloser Fall dokumentiert, und reconcile() behandelte es korrekt als no-remote-or-fetch-failed.

Das Problem saß in _local_ahead_of_remote(), die entscheidet, ob trotz leerem Working-Tree noch etwas zu pushen ist:

def _local_ahead_of_remote(remote: str, branch: str) -> bool:
    if not remote_ref_exists(remote, branch):
        return False
    ...

remote_ref_exists() dokumentiert selbst den Fall, der hier eintrat: "false for a remote that was never fetched, or a branch that has never been pushed (the very first publish)". Genau dieser Fall wurde als False gewertet, obwohl lokal ein Commit existierte, den der Remote nicht hatte. In publish_command() führte das zu einem frühen Return - changes leer, local_ahead fälschlich False, git push nie aufgerufen.

Dieselbe Funktion trug im eigenen Docstring die Selbstbeschreibung "true right after a stranded commit from a previous failed push" - sie sollte genau diesen Fall erkennen, tat es aber nicht, wenn der Remote-Branch noch nie existiert hatte. Ohne Fix gab es keinen Weg, einen bereits committeten Stand über tools/wikitool publish zu einem leeren Remote zu bringen; Invariante 5 verbietet den naheliegenden Ausweg (rohes git push).

Präzisierung gegenüber der ersten Fassung: "jede neue Instanz" war zu weit gegriffen. Mit #96 behoben läuft der Normalpfad (publish -> Gate 42 -> publish --confirm) durch, weil changes dort nicht leer ist und der Push regulär stattfindet. Dieser Defekt griff, sobald der Commit stand und der Working-Tree sauber war: nach --no-push, nach einem an Netz/Auth gescheiterten Push, und bei jedem Wiederholungsversuch danach.

Entscheidung und Umsetzung

Die offene Frage aus Akzeptanzkriterium 3 - wie "Branch existiert auf dem Remote nicht" von "Remote unerreichbar" unterschieden wird - ist beantwortet: git ls-remote --exit-code <remote> <branch> trennt die drei Lagen über den Exit-Code allein, ohne Ausgabe zu parsen. Auf die Meldung zu matchen schied aus, weil git seine Fehlertexte übersetzt.

Empirisch gemessen:

Situation ls-remote --exit-code <remote> <branch>
Ref existiert auf dem Remote rc 0
Remote erreichbar, Ref fehlt (leeres Repo) rc 2
Remote unerreichbar (falsche URL, Netz, Auth) rc 128
Gar kein Remote konfiguriert rc 128

Umgesetzt, wie geplant:

  1. Neuer Helper remote_lacks_branch(remote, branch) kapselt den Aufruf und ist True nur bei rc 2. Sein Docstring hält fest, dass der Exit-Code und nicht die Meldung gelesen wird, und warum.
  2. Neuer Helper _has_commits(branch) (git rev-list --count -n 1 <branch>, rc 128 auf ungeborenem Branch). -n 1 deckelt den Walk, statt die ganze Historie zu zählen, um eine Ja/Nein-Frage zu beantworten.
  3. _local_ahead_of_remote() behandelt den fehlenden Tracking-Ref zweigeteilt: remote_lacks_branch(...) and _has_commits(...). Ein unerreichbares Remote behält das bisherige False.
  4. remote_ref_exists() blieb unverändert - sein zweiter Aufrufer reconcile() meint damit weiterhin richtig "nichts zum Abgleichen da". Der Fix sitzt ausschließlich in _local_ahead_of_remote().

Warum der unerreichbare Fall bewusst nicht als "ahead" gilt: sonst liefe jeder Publish ohne Netz in einen scheiternden git push statt in das heutige "Nothing to commit" - eine Verhaltensänderung für Offline- und Nur-lokal-Instanzen, die dieser Defekt nicht verlangt. Wo es tatsächlich etwas zu committen gibt, meldet der Push den echten Netz-/Auth-Fehler ohnehin unverändert.

Akzeptanzkriterien

  • Gegen ein erreichbares, aber komplett leeres Remote-Repository pusht publish einen bereits vorhandenen lokalen Commit nach origin/main, auch bei sauberem Working-Tree und leerem changes. Test: test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet.
  • Die Kette aus #96 funktioniert vollständig: git init -b main -> publish --no-push -> publish pusht. Genau diese Abfolge ist der Testkörper des vorigen Punktes.
  • Ein unerreichbares Remote (rc 128) wird nicht als "ahead" gewertet; das Verhalten bei sauberem Working-Tree ist exakt das bisherige. Test: test_an_unreachable_remote_is_not_mistaken_for_one_that_lacks_the_branch, das beide Ebenen prüft (remote_lacks_branch und _local_ahead_of_remote).
  • Ein ungeborener Branch gegen ein leeres Remote gilt nicht als "ahead" - kein Push-Versuch ohne Commit. Test: test_an_unborn_branch_is_not_ahead_of_an_empty_remote.
  • remote_ref_exists() und reconcile() verhalten sich unverändert; die bestehenden sync- und Rebase-Review-Tests liefen ohne jede Anpassung durch. Zusätzlich hält test_a_reachable_remote_without_the_branch_is_told_apart_from_one_that_has_it die positive Hälfte der Unterscheidung fest.
  • tools/chemenu/tests/test_git_publish.py trägt die Tests mit leerem erreichbarem bare-Remote und mit unerreichbarem Remote, über das neue fresh_instance-Fixture. Der repo-Fixture taugte wie erwartet für keinen von beiden.
  • Die publish-Zeile in tools/CONTRACT.md sagt jetzt die Wahrheit: die Zusage zum Strandungsfall gilt ausdrücklich auch für einen Branch, den der Remote nie gesehen hat, und der unerreichbare Fall ist als bewusste Ausnahme benannt.
  • instructions/setup-instance.md Schritt 14 beschreibt den tatsächlichen Ablauf - bestätigt, nicht angenommen: der Schritt war bereits richtig und blieb unverändert.

Verifikation

  • pytest in tools/: 1204 Tests grün, davon 96 in test_git_publish.py.
  • Gegenprobe mit zurückgedrehtem Fix: von den hier ergänzten Tests scheitert test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet; die drei Schutztests (unerreichbares Remote, ungeborener Branch nicht ahead, positive Unterscheidung) bestehen erwartungsgemäß in beide Richtungen - sie halten fest, was sich nicht ändern durfte.
  • Leere-Maschine-Lauf nach instructions/dev/testing-conventions.md Schritt 6: identisches Ergebnis, 1204 grün.
  • tools/wikitool docs verify und tools/wikitool instructions verify: beide OK.

Beteiligte Dateien

  • tools/chemenu/commands/git_publish.py - _local_ahead_of_remote(), neu remote_lacks_branch() und _has_commits(); remote_ref_exists() und fetch_remote() unverändert
  • tools/chemenu/tests/test_git_publish.py
  • tools/CONTRACT.md (publish-Zeile)

Beziehung zu #96

Verschiedene Funktionen, verschiedene Ursachen, dieselbe Situation deckte beide auf: der allererste Publish einer neuen Instanz. #96 verhinderte den ersten Commit selbst, dieser hier dessen Push. Gemeinsam umgesetzt, ein Commit (9ef021b), ein --patch-Bump (5.0.0 -> 5.0.1-beta.1).

## Befund Nach `publish --no-push` (dem Workaround aus #96) existierte ein lokaler Commit auf `main`, `origin` war ein frisch angelegtes, komplett leeres Repository. Der zweite, dokumentierte `publish`-Aufruf sollte diesen Commit pushen: ``` $ tools/wikitool publish --message "chore: initial instance setup" No remote configured, or origin could not be reached - continuing without a pull. OK Nothing to commit. ``` Kein Push fand statt - verifiziert per `git ls-remote origin` (leer) direkt danach. Beliebig oft wiederholt, solange der Working-Tree sauber war, blieb das Ergebnis dasselbe: der Commit blieb für immer lokal. Betroffene Version: 5.0.0. Behoben in **5.0.1-beta.1**, Commit `9ef021b`. ## Ursache - verifiziert `git fetch origin main` scheitert gegen ein leeres Remote-Repo erwartungsgemäß (`fatal: couldn't find remote ref main`). Das ist in `fetch_remote()` als harmloser Fall dokumentiert, und `reconcile()` behandelte es korrekt als `no-remote-or-fetch-failed`. Das Problem saß in `_local_ahead_of_remote()`, die entscheidet, ob trotz leerem Working-Tree noch etwas zu pushen ist: ```python def _local_ahead_of_remote(remote: str, branch: str) -> bool: if not remote_ref_exists(remote, branch): return False ... ``` `remote_ref_exists()` dokumentiert selbst den Fall, der hier eintrat: *"false for a remote that was never fetched, or **a branch that has never been pushed (the very first publish)**"*. Genau dieser Fall wurde als `False` gewertet, obwohl lokal ein Commit existierte, den der Remote nicht hatte. In `publish_command()` führte das zu einem frühen Return - `changes` leer, `local_ahead` fälschlich `False`, `git push` nie aufgerufen. Dieselbe Funktion trug im eigenen Docstring die Selbstbeschreibung *"true right after a stranded commit from a previous failed push"* - sie sollte genau diesen Fall erkennen, tat es aber nicht, wenn der Remote-Branch noch nie existiert hatte. Ohne Fix gab es keinen Weg, einen bereits committeten Stand über `tools/wikitool publish` zu einem leeren Remote zu bringen; Invariante 5 verbietet den naheliegenden Ausweg (rohes `git push`). **Präzisierung gegenüber der ersten Fassung:** "jede neue Instanz" war zu weit gegriffen. Mit #96 behoben läuft der Normalpfad (`publish` -> Gate 42 -> `publish --confirm`) durch, weil `changes` dort nicht leer ist und der Push regulär stattfindet. Dieser Defekt griff, sobald der Commit stand und der Working-Tree sauber war: nach `--no-push`, nach einem an Netz/Auth gescheiterten Push, und bei jedem Wiederholungsversuch danach. ## Entscheidung und Umsetzung Die offene Frage aus Akzeptanzkriterium 3 - wie "Branch existiert auf dem Remote nicht" von "Remote unerreichbar" unterschieden wird - ist beantwortet: **`git ls-remote --exit-code <remote> <branch>`** trennt die drei Lagen über den Exit-Code allein, ohne Ausgabe zu parsen. Auf die Meldung zu matchen schied aus, weil git seine Fehlertexte übersetzt. Empirisch gemessen: | Situation | `ls-remote --exit-code <remote> <branch>` | |---|---| | Ref existiert auf dem Remote | rc 0 | | Remote erreichbar, Ref fehlt (leeres Repo) | rc **2** | | Remote unerreichbar (falsche URL, Netz, Auth) | rc 128 | | Gar kein Remote konfiguriert | rc 128 | **Umgesetzt, wie geplant:** 1. Neuer Helper `remote_lacks_branch(remote, branch)` kapselt den Aufruf und ist `True` nur bei rc 2. Sein Docstring hält fest, dass der Exit-Code und nicht die Meldung gelesen wird, und warum. 2. Neuer Helper `_has_commits(branch)` (`git rev-list --count -n 1 <branch>`, rc 128 auf ungeborenem Branch). `-n 1` deckelt den Walk, statt die ganze Historie zu zählen, um eine Ja/Nein-Frage zu beantworten. 3. `_local_ahead_of_remote()` behandelt den fehlenden Tracking-Ref zweigeteilt: `remote_lacks_branch(...) and _has_commits(...)`. Ein unerreichbares Remote behält das bisherige `False`. 4. **`remote_ref_exists()` blieb unverändert** - sein zweiter Aufrufer `reconcile()` meint damit weiterhin richtig "nichts zum Abgleichen da". Der Fix sitzt ausschließlich in `_local_ahead_of_remote()`. **Warum der unerreichbare Fall bewusst nicht als "ahead" gilt:** sonst liefe jeder Publish ohne Netz in einen scheiternden `git push` statt in das heutige "Nothing to commit" - eine Verhaltensänderung für Offline- und Nur-lokal-Instanzen, die dieser Defekt nicht verlangt. Wo es tatsächlich etwas zu committen gibt, meldet der Push den echten Netz-/Auth-Fehler ohnehin unverändert. ## Akzeptanzkriterien - [x] Gegen ein erreichbares, aber komplett leeres Remote-Repository pusht `publish` einen bereits vorhandenen lokalen Commit nach `origin/main`, auch bei sauberem Working-Tree und leerem `changes`. Test: `test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet`. - [x] Die Kette aus #96 funktioniert vollständig: `git init -b main` -> `publish --no-push` -> `publish` pusht. Genau diese Abfolge ist der Testkörper des vorigen Punktes. - [x] Ein unerreichbares Remote (rc 128) wird **nicht** als "ahead" gewertet; das Verhalten bei sauberem Working-Tree ist exakt das bisherige. Test: `test_an_unreachable_remote_is_not_mistaken_for_one_that_lacks_the_branch`, das beide Ebenen prüft (`remote_lacks_branch` und `_local_ahead_of_remote`). - [x] Ein ungeborener Branch gegen ein leeres Remote gilt **nicht** als "ahead" - kein Push-Versuch ohne Commit. Test: `test_an_unborn_branch_is_not_ahead_of_an_empty_remote`. - [x] `remote_ref_exists()` und `reconcile()` verhalten sich unverändert; die bestehenden `sync`- und Rebase-Review-Tests liefen ohne jede Anpassung durch. Zusätzlich hält `test_a_reachable_remote_without_the_branch_is_told_apart_from_one_that_has_it` die positive Hälfte der Unterscheidung fest. - [x] `tools/chemenu/tests/test_git_publish.py` trägt die Tests mit leerem erreichbarem bare-Remote und mit unerreichbarem Remote, über das neue `fresh_instance`-Fixture. Der `repo`-Fixture taugte wie erwartet für keinen von beiden. - [x] Die `publish`-Zeile in `tools/CONTRACT.md` sagt jetzt die Wahrheit: die Zusage zum Strandungsfall gilt ausdrücklich auch für einen Branch, den der Remote nie gesehen hat, und der unerreichbare Fall ist als bewusste Ausnahme benannt. - [x] `instructions/setup-instance.md` Schritt 14 beschreibt den tatsächlichen Ablauf - bestätigt, nicht angenommen: der Schritt war bereits richtig und blieb unverändert. ## Verifikation - `pytest` in `tools/`: 1204 Tests grün, davon 96 in `test_git_publish.py`. - Gegenprobe mit zurückgedrehtem Fix: von den hier ergänzten Tests scheitert `test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet`; die drei Schutztests (unerreichbares Remote, ungeborener Branch nicht ahead, positive Unterscheidung) bestehen erwartungsgemäß in beide Richtungen - sie halten fest, was sich *nicht* ändern durfte. - Leere-Maschine-Lauf nach `instructions/dev/testing-conventions.md` Schritt 6: identisches Ergebnis, 1204 grün. - `tools/wikitool docs verify` und `tools/wikitool instructions verify`: beide OK. ## Beteiligte Dateien - `tools/chemenu/commands/git_publish.py` - `_local_ahead_of_remote()`, neu `remote_lacks_branch()` und `_has_commits()`; `remote_ref_exists()` und `fetch_remote()` unverändert - `tools/chemenu/tests/test_git_publish.py` - `tools/CONTRACT.md` (`publish`-Zeile) ## Beziehung zu #96 Verschiedene Funktionen, verschiedene Ursachen, dieselbe Situation deckte beide auf: der allererste Publish einer neuen Instanz. #96 verhinderte den ersten Commit selbst, dieser hier dessen Push. Gemeinsam umgesetzt, ein Commit (`9ef021b`), ein `--patch`-Bump (5.0.0 -> 5.0.1-beta.1).
torben added the prio/blockingsize/Sarea/workflowkind/defect labels 2026-09-12 11:38:03 +00:00
Author
Owner

Changelog: Offene Designfrage aus Akzeptanzkriterium 3 beantwortet und als Abschnitt "Entscheidung" in den Body geschrieben: git ls-remote --exit-code <remote> <branch> trennt die drei Fälle über den Exit-Code (0 / 2 / 128, empirisch gemessen), ohne locale-abhängige Meldungen zu parsen. Fix bleibt auf _local_ahead_of_remote() beschränkt - remote_ref_exists() behält seine heutige Bedeutung für reconcile(). Nullcommit-Schutz und die Begründung, warum der unerreichbare Fall bewusst beim heutigen False bleibt, ergänzt. Reichweite im Abschnitt "Warum das zählt" korrigiert: "jede neue Instanz" war zu weit - mit #96 behoben läuft der Normalpfad durch, dieser Defekt greift bei bereits stehendem Commit und sauberem Working-Tree. Zwei Akzeptanzkriterien ergänzt (ungeborener Branch nicht "ahead"; reconcile unverändert), die tools/CONTRACT.md-Zeile als heute falsche Zusage benannt. Schnitt: gemeinsame Umsetzung mit #96, ein --patch-Bump.

**Changelog:** Offene Designfrage aus Akzeptanzkriterium 3 beantwortet und als Abschnitt "Entscheidung" in den Body geschrieben: `git ls-remote --exit-code <remote> <branch>` trennt die drei Fälle über den Exit-Code (0 / 2 / 128, empirisch gemessen), ohne locale-abhängige Meldungen zu parsen. Fix bleibt auf `_local_ahead_of_remote()` beschränkt - `remote_ref_exists()` behält seine heutige Bedeutung für `reconcile()`. Nullcommit-Schutz und die Begründung, warum der unerreichbare Fall bewusst beim heutigen `False` bleibt, ergänzt. Reichweite im Abschnitt "Warum das zählt" korrigiert: "jede neue Instanz" war zu weit - mit #96 behoben läuft der Normalpfad durch, dieser Defekt greift bei bereits stehendem Commit und sauberem Working-Tree. Zwei Akzeptanzkriterien ergänzt (ungeborener Branch nicht "ahead"; `reconcile` unverändert), die `tools/CONTRACT.md`-Zeile als heute falsche Zusage benannt. Schnitt: gemeinsame Umsetzung mit #96, ein `--patch`-Bump.
Author
Owner

Changelog: Umgesetzt und veröffentlicht (9ef021b, 5.0.0 -> 5.0.1-beta.1). Body auf den Endstand geschrieben: alle acht Akzeptanzkriterien abgehakt und je mit dem Test benannt, der sie hält. Gegenüber dem Plan eine Ergänzung: neben remote_lacks_branch() kam ein zweiter Helper _has_commits() dazu, statt den Nullcommit-Schutz inline zu schreiben - git rev-list --count -n 1, damit die Ja/Nein-Frage nicht die ganze Historie zählt. Abschnitt "Verifikation" ergänzt (1204 Tests grün, Gegenprobe gegen den zurückgedrehten Fix, Leere-Maschine-Lauf, docs verify/instructions verify). Letztes Kriterium bestätigt statt angenommen: instructions/setup-instance.md Schritt 14 war bereits richtig und blieb unverändert.

**Changelog:** Umgesetzt und veröffentlicht (`9ef021b`, 5.0.0 -> 5.0.1-beta.1). Body auf den Endstand geschrieben: alle acht Akzeptanzkriterien abgehakt und je mit dem Test benannt, der sie hält. Gegenüber dem Plan eine Ergänzung: neben `remote_lacks_branch()` kam ein zweiter Helper `_has_commits()` dazu, statt den Nullcommit-Schutz inline zu schreiben - `git rev-list --count -n 1`, damit die Ja/Nein-Frage nicht die ganze Historie zählt. Abschnitt "Verifikation" ergänzt (1204 Tests grün, Gegenprobe gegen den zurückgedrehten Fix, Leere-Maschine-Lauf, `docs verify`/`instructions verify`). Letztes Kriterium bestätigt statt angenommen: `instructions/setup-instance.md` Schritt 14 war bereits richtig und blieb unverändert.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: torben/chemenu#97