publish hält eine ungeborene main für einen detached HEAD - der erste Commit einer neuen Instanz scheitert am dokumentierten Weg #96

Closed
opened 2026-09-12 11:31:35 +00:00 by torben · 3 comments
Owner

Befund

Beim Einrichten einer frischen 5.0.0-Instanz aus dem Release-Tarball (Weg A aus INSTALL.md) verweigerte Schritt 14 aus instructions/setup-instance.md den ersten Commit:

$ tools/wikitool publish --message "chore: initial instance setup"
ERROR Refusing to push `main` from a detached HEAD. Check out a branch first - pushing a
named ref from a detached HEAD would publish something other than the commit just made.
$ echo $?
1

HEAD war dabei nicht detached, sondern ein ungeborener Branch (git symbolic-ref HEAD -> refs/heads/main, git status -sb -> ## No commits yet on main).

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

Warum das zählt

instructions/setup-instance.md Schritt 14 und INSTALL.md (Weg A und B) nennen publish --message als den Weg zum ersten Commit einer neuen Instanz, und doctor sagt an derselben Stelle fix: Make the first commit via publish once ready. Genau dieser dokumentierte Befehl schlug fehl - bei jeder neuen Instanz, beim ersten schreibenden wikitool-Aufruf überhaupt. Invariante 5 schnitt den naheliegenden Ausweg ab: ein Agent darf nicht auf git commit von Hand ausweichen. Zusammen mit #97 hieß das: eine neue Instanz mit leerem Remote ließ sich mit 5.0.0 über keinen dokumentierten Weg initial veröffentlichen.

Ursache - verifiziert

Die ursprüngliche Vermutung traf zu. current_branch() fragte git rev-parse --abbrev-ref HEAD; auf einem ungeborenen Branch endet der Aufruf mit Exit 128 ("ambiguous argument 'HEAD': unknown revision") und fiel damit in denselben None-Zweig wie ein echter detached HEAD. branch_mismatch_message() formulierte daraus die detached-HEAD-Ablehnung, publish_command() brach damit ab.

Gemessen in einem frisch initialisierten Repo:

Zustand rev-parse --abbrev-ref HEAD symbolic-ref --short -q HEAD
Ungeborener Branch (git init -b main) rc 128, stdout HEAD rc 0, stdout main
Echter detached HEAD (git checkout <sha>) rc 0, stdout HEAD rc 1, stdout leer
Normaler Checkout rc 0, stdout main rc 0, stdout main

symbolic-ref unterscheidet die beiden Zustände genau dort, wo rev-parse sie zusammenwirft, und liefert im dritten Fall dasselbe.

Entscheidung und Umsetzung

Frage 1 (trifft die Ableitung zu?) - ja, siehe Tabelle.

Frage 2 (soll die Prüfung im ungeborenen Fall greifen?) - nein, und mit der gewählten Umsetzung stellte sie sich nicht mehr. Das Motiv der Prüfung (git push <remote> <branch> pusht den benannten Ref, nicht HEAD) existiert im ungeborenen Fall nicht: es gibt genau einen Branch, der Commit entsteht auf ihm, origin hat den Ref noch gar nicht. Die Prüfung wurde deshalb nicht ausgesetzt, sondern korrekt beantwortet: current_branch() liefert auf ungeborenem main jetzt "main", der Vergleich checked_out != branch geht von selbst durch. Ein Sonderfall im Aufrufer entfiel.

Umgesetzt: current_branch() liest git symbolic-ref --short -q HEAD statt git rev-parse --abbrev-ref HEAD; leere Ausgabe oder rc != 0 bleibt None (= detached HEAD). Der Docstring hält fest, warum symbolic-ref und nicht rev-parse - dass es liest, was HEAD benennt, statt worauf es zeigt.

Blast radius, wie erwartet klein: current_branch() hat genau einen Aufrufer, die Branch-Prüfung in publish_command(). Der restliche Publish-Pfad trug den ungeborenen Fall bereits: _numstat() dokumentiert ihn ausdrücklich und liefert {}, _changed_files() liest git status --porcelain, das ohne Commit funktioniert, und reconcile() fällt gegen ein leeres Remote auf no-remote-or-fetch-failed zurück. Es war keine weitere Anpassung nötig.

Akzeptanzkriterien

  • In einem Repo nach git init -b main ohne jeden Commit legt tools/wikitool publish --message "<text>" den Commit auf main an und pusht ihn nach origin/main - ohne --no-push und ohne handgemachtes git commit. Test: test_publish_makes_the_first_commit_of_a_new_instance.
  • Ein echter detached HEAD wird weiterhin mit der bisherigen Begründung abgelehnt. branch_mismatch_message() blieb unverändert; es änderte sich nur, welcher Zustand None erzeugt. Test: test_current_branch_still_reports_a_real_detached_head_as_none.
  • Ein Push auf einen anderen als den ausgecheckten Branch wird weiterhin abgelehnt, mit unverändertem --branch-Hinweis, auch auf einem ungeborenen Branch. Test: test_publish_on_an_unborn_branch_still_refuses_a_different_target_branch.
  • Das Mass-Update-Gate verhält sich im ungeborenen Fall unverändert: Exit 42 mit Token, und vor dem Gate wird nichts gestaged - geprüft inklusive "Branch weiterhin ungeboren, Remote weiterhin leer". Test: test_the_gate_still_holds_on_a_fresh_instance_and_stages_nothing.
  • tools/chemenu/tests/test_git_publish.py trägt je einen Test für den ungeborenen Branch und den echten detached HEAD. Umgesetzt über ein eigenes fresh_instance-Fixture (ungeborener Branch, leeres bare-Remote, kein Commit) - der repo-Fixture taugte wie erwartet nicht, weil er einen Init-Commit anlegt und pusht.
  • Doku zieht nach. tools/CONTRACT.md benennt die Branch-Prüfung jetzt in der publish-Kommandozeile und als eigenständigen Exit-1-Grund im Fehlerkontrakt, jeweils mit dem ungeborenen Branch als ausdrücklicher Ausnahme. instructions/setup-instance.md Schritt 2 und 14 sowie INSTALL.md blieben unverändert: geprüft und inhaltlich richtig - sie beschrieben nie den Workaround, sondern den Weg, der jetzt tatsächlich funktioniert. --no-push musste beim ersten Commit folglich nicht erwähnt werden.

Verifikation

  • pytest in tools/: 1204 Tests grün, davon 96 in test_git_publish.py.
  • Gegenprobe, dass die neuen Tests echte Regressionstests sind: mit zurückgedrehtem Fix scheitern genau die vier verhaltensprüfenden Tests aus #96/#97 (test_current_branch_names_an_unborn_branch_rather_than_calling_it_detached, test_publish_makes_the_first_commit_of_a_new_instance, test_the_gate_still_holds_on_a_fresh_instance_and_stages_nothing, test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet); die Schutztests bestehen erwartungsgemäß in beide Richtungen.
  • Leere-Maschine-Lauf nach instructions/dev/testing-conventions.md Schritt 6 (env -i ohne HOME/Git-Config): identisches Ergebnis, 1204 grün - kein Leck über die Umgebung.
  • tools/wikitool docs verify und tools/wikitool instructions verify: beide OK.

Beteiligte Dateien

  • tools/chemenu/commands/git_publish.py - current_branch()
  • tools/chemenu/tests/test_git_publish.py - fresh_instance-Fixture, _remote_log(), vier Tests
  • tools/CONTRACT.md - publish-Kommandozeile und Fehlerkontrakt-Zeile

Beziehung zu #97

Gemeinsam umgesetzt, ein Commit (9ef021b), ein Version-Bump (--patch, 5.0.0 -> 5.0.1-beta.1): verschiedene Funktionen und Ursachen, aber dieselbe Datei, dieselbe Testdatei, dieselbe Doku-Zeile, und die Kette ließ sich erst gemeinsam als Ganzes testen. --patch war die richtige Stufe - reine Fehlerbehebung, keine Schnittstellenänderung, in beide Richtungen drop-in.

## Befund Beim Einrichten einer frischen 5.0.0-Instanz aus dem Release-Tarball (Weg A aus `INSTALL.md`) verweigerte Schritt 14 aus `instructions/setup-instance.md` den ersten Commit: ``` $ tools/wikitool publish --message "chore: initial instance setup" ERROR Refusing to push `main` from a detached HEAD. Check out a branch first - pushing a named ref from a detached HEAD would publish something other than the commit just made. $ echo $? 1 ``` HEAD war dabei nicht detached, sondern ein ungeborener Branch (`git symbolic-ref HEAD` -> `refs/heads/main`, `git status -sb` -> `## No commits yet on main`). Betroffene Version: 5.0.0. Behoben in **5.0.1-beta.1**, Commit `9ef021b`. ## Warum das zählt `instructions/setup-instance.md` Schritt 14 und `INSTALL.md` (Weg A und B) nennen `publish --message` als *den* Weg zum ersten Commit einer neuen Instanz, und `doctor` sagt an derselben Stelle `fix: Make the first commit via publish once ready`. Genau dieser dokumentierte Befehl schlug fehl - bei jeder neuen Instanz, beim ersten schreibenden `wikitool`-Aufruf überhaupt. Invariante 5 schnitt den naheliegenden Ausweg ab: ein Agent darf nicht auf `git commit` von Hand ausweichen. Zusammen mit #97 hieß das: eine neue Instanz mit leerem Remote ließ sich mit 5.0.0 über keinen dokumentierten Weg initial veröffentlichen. ## Ursache - verifiziert Die ursprüngliche Vermutung traf zu. `current_branch()` fragte `git rev-parse --abbrev-ref HEAD`; auf einem ungeborenen Branch endet der Aufruf mit **Exit 128** ("ambiguous argument 'HEAD': unknown revision") und fiel damit in denselben `None`-Zweig wie ein echter detached HEAD. `branch_mismatch_message()` formulierte daraus die detached-HEAD-Ablehnung, `publish_command()` brach damit ab. Gemessen in einem frisch initialisierten Repo: | Zustand | `rev-parse --abbrev-ref HEAD` | `symbolic-ref --short -q HEAD` | |---|---|---| | Ungeborener Branch (`git init -b main`) | rc 128, stdout `HEAD` | rc 0, stdout `main` | | Echter detached HEAD (`git checkout <sha>`) | rc 0, stdout `HEAD` | rc 1, stdout leer | | Normaler Checkout | rc 0, stdout `main` | rc 0, stdout `main` | `symbolic-ref` unterscheidet die beiden Zustände genau dort, wo `rev-parse` sie zusammenwirft, und liefert im dritten Fall dasselbe. ## Entscheidung und Umsetzung **Frage 1** (trifft die Ableitung zu?) - ja, siehe Tabelle. **Frage 2** (soll die Prüfung im ungeborenen Fall greifen?) - **nein, und mit der gewählten Umsetzung stellte sie sich nicht mehr.** Das Motiv der Prüfung (`git push <remote> <branch>` pusht den *benannten Ref*, nicht `HEAD`) existiert im ungeborenen Fall nicht: es gibt genau einen Branch, der Commit entsteht auf ihm, `origin` hat den Ref noch gar nicht. Die Prüfung wurde deshalb nicht *ausgesetzt*, sondern **korrekt beantwortet**: `current_branch()` liefert auf ungeborenem `main` jetzt `"main"`, der Vergleich `checked_out != branch` geht von selbst durch. Ein Sonderfall im Aufrufer entfiel. **Umgesetzt:** `current_branch()` liest `git symbolic-ref --short -q HEAD` statt `git rev-parse --abbrev-ref HEAD`; leere Ausgabe oder rc != 0 bleibt `None` (= detached HEAD). Der Docstring hält fest, warum `symbolic-ref` und nicht `rev-parse` - dass es liest, was `HEAD` *benennt*, statt worauf es zeigt. **Blast radius, wie erwartet klein:** `current_branch()` hat genau einen Aufrufer, die Branch-Prüfung in `publish_command()`. Der restliche Publish-Pfad trug den ungeborenen Fall bereits: `_numstat()` dokumentiert ihn ausdrücklich und liefert `{}`, `_changed_files()` liest `git status --porcelain`, das ohne Commit funktioniert, und `reconcile()` fällt gegen ein leeres Remote auf `no-remote-or-fetch-failed` zurück. Es war keine weitere Anpassung nötig. ## Akzeptanzkriterien - [x] In einem Repo nach `git init -b main` ohne jeden Commit legt `tools/wikitool publish --message "<text>"` den Commit auf `main` an und pusht ihn nach `origin/main` - ohne `--no-push` und ohne handgemachtes `git commit`. Test: `test_publish_makes_the_first_commit_of_a_new_instance`. - [x] Ein echter detached HEAD wird weiterhin mit der bisherigen Begründung abgelehnt. `branch_mismatch_message()` blieb unverändert; es änderte sich nur, welcher Zustand `None` erzeugt. Test: `test_current_branch_still_reports_a_real_detached_head_as_none`. - [x] Ein Push auf einen anderen als den ausgecheckten Branch wird weiterhin abgelehnt, mit unverändertem `--branch`-Hinweis, auch auf einem ungeborenen Branch. Test: `test_publish_on_an_unborn_branch_still_refuses_a_different_target_branch`. - [x] Das Mass-Update-Gate verhält sich im ungeborenen Fall unverändert: Exit 42 mit Token, und vor dem Gate wird nichts gestaged - geprüft inklusive "Branch weiterhin ungeboren, Remote weiterhin leer". Test: `test_the_gate_still_holds_on_a_fresh_instance_and_stages_nothing`. - [x] `tools/chemenu/tests/test_git_publish.py` trägt je einen Test für den ungeborenen Branch und den echten detached HEAD. Umgesetzt über ein eigenes `fresh_instance`-Fixture (ungeborener Branch, leeres bare-Remote, kein Commit) - der `repo`-Fixture taugte wie erwartet nicht, weil er einen Init-Commit anlegt und pusht. - [x] Doku zieht nach. `tools/CONTRACT.md` benennt die Branch-Prüfung jetzt in der `publish`-Kommandozeile *und* als eigenständigen Exit-1-Grund im Fehlerkontrakt, jeweils mit dem ungeborenen Branch als ausdrücklicher Ausnahme. `instructions/setup-instance.md` Schritt 2 und 14 sowie `INSTALL.md` blieben unverändert: geprüft und inhaltlich richtig - sie beschrieben nie den Workaround, sondern den Weg, der jetzt tatsächlich funktioniert. `--no-push` musste beim ersten Commit folglich nicht erwähnt werden. ## Verifikation - `pytest` in `tools/`: 1204 Tests grün, davon 96 in `test_git_publish.py`. - Gegenprobe, dass die neuen Tests echte Regressionstests sind: mit zurückgedrehtem Fix scheitern genau die vier verhaltensprüfenden Tests aus #96/#97 (`test_current_branch_names_an_unborn_branch_rather_than_calling_it_detached`, `test_publish_makes_the_first_commit_of_a_new_instance`, `test_the_gate_still_holds_on_a_fresh_instance_and_stages_nothing`, `test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet`); die Schutztests bestehen erwartungsgemäß in beide Richtungen. - Leere-Maschine-Lauf nach `instructions/dev/testing-conventions.md` Schritt 6 (`env -i` ohne HOME/Git-Config): identisches Ergebnis, 1204 grün - kein Leck über die Umgebung. - `tools/wikitool docs verify` und `tools/wikitool instructions verify`: beide OK. ## Beteiligte Dateien - `tools/chemenu/commands/git_publish.py` - `current_branch()` - `tools/chemenu/tests/test_git_publish.py` - `fresh_instance`-Fixture, `_remote_log()`, vier Tests - `tools/CONTRACT.md` - `publish`-Kommandozeile und Fehlerkontrakt-Zeile ## Beziehung zu #97 Gemeinsam umgesetzt, ein Commit (`9ef021b`), ein Version-Bump (`--patch`, 5.0.0 -> 5.0.1-beta.1): verschiedene Funktionen und Ursachen, aber dieselbe Datei, dieselbe Testdatei, dieselbe Doku-Zeile, und die Kette ließ sich erst gemeinsam als Ganzes testen. `--patch` war die richtige Stufe - reine Fehlerbehebung, keine Schnittstellenänderung, in beide Richtungen drop-in.
torben added the prio/plannedsize/Sarea/workflowkind/defectstatus/unconfirmed labels 2026-09-12 11:31:35 +00:00
Author
Owner

Der --no-push-Workaround aus diesem Issue führt in einen zweiten, eigenständigen Bug: #97. Zusammen verhindern beide jeden dokumentierten ersten Publish einer neuen Instanz gegen ein leeres Remote.

Der `--no-push`-Workaround aus diesem Issue führt in einen zweiten, eigenständigen Bug: #97. Zusammen verhindern beide jeden dokumentierten ersten Publish einer neuen Instanz gegen ein leeres Remote.
torben added prio/blocking and removed prio/plannedstatus/unconfirmed labels 2026-09-12 15:02:49 +00:00
Author
Owner

Changelog: Verdacht gegen den Baum geprüft und bestätigt (rev-parse --abbrev-ref HEAD endet auf ungeborenem Branch mit rc 128) - status/unconfirmed entfällt, prio/planned -> prio/blocking. Beide offenen Fragen beantwortet und als Abschnitt "Entscheidung" in den Body geschrieben: Fix per git symbolic-ref --short -q HEAD, wodurch sich Frage 2 (Prüfung im ungeborenen Fall aussetzen?) auflöst statt beantwortet werden zu müssen. Blast radius ergänzt (ein einziger Aufrufer; der restliche Publish-Pfad trägt den Fall bereits). Akzeptanzkriterien um den --branch-Fall auf ungeborenem Branch und den Fixture-Hinweis erweitert. Schnitt festgehalten: gemeinsame Umsetzung mit #97, ein --patch-Bump.

**Changelog:** Verdacht gegen den Baum geprüft und bestätigt (`rev-parse --abbrev-ref HEAD` endet auf ungeborenem Branch mit rc 128) - `status/unconfirmed` entfällt, `prio/planned` -> `prio/blocking`. Beide offenen Fragen beantwortet und als Abschnitt "Entscheidung" in den Body geschrieben: Fix per `git symbolic-ref --short -q HEAD`, wodurch sich Frage 2 (Prüfung im ungeborenen Fall aussetzen?) auflöst statt beantwortet werden zu müssen. Blast radius ergänzt (ein einziger Aufrufer; der restliche Publish-Pfad trägt den Fall bereits). Akzeptanzkriterien um den `--branch`-Fall auf ungeborenem Branch und den Fixture-Hinweis erweitert. Schnitt festgehalten: gemeinsame Umsetzung mit #97, 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 sechs Akzeptanzkriterien abgehakt und je mit dem Test benannt, der sie hält; Abschnitt "Verifikation" ergänzt (1204 Tests grün, Gegenprobe gegen den zurückgedrehten Fix, Leere-Maschine-Lauf, docs verify/instructions verify). Ergebnis des letzten Kriteriums festgehalten: tools/CONTRACT.md wurde in beiden Tabellen nachgezogen, instructions/setup-instance.md und INSTALL.md blieben nach Prüfung unverändert - --no-push musste dort nicht erwähnt werden. Keine docs/-Seite betroffen: die Gates sind unverändert, docs/why-gates-are-code.md trägt keine Aussage, die sich bewegt hätte.

**Changelog:** Umgesetzt und veröffentlicht (`9ef021b`, 5.0.0 -> 5.0.1-beta.1). Body auf den Endstand geschrieben: alle sechs Akzeptanzkriterien abgehakt und je mit dem Test benannt, der sie hält; Abschnitt "Verifikation" ergänzt (1204 Tests grün, Gegenprobe gegen den zurückgedrehten Fix, Leere-Maschine-Lauf, `docs verify`/`instructions verify`). Ergebnis des letzten Kriteriums festgehalten: `tools/CONTRACT.md` wurde in beiden Tabellen nachgezogen, `instructions/setup-instance.md` und `INSTALL.md` blieben nach Prüfung unverändert - `--no-push` musste dort nicht erwähnt werden. Keine `docs/`-Seite betroffen: die Gates sind unverändert, `docs/why-gates-are-code.md` trägt keine Aussage, die sich bewegt hätte.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: torben/chemenu#96