publish: ungeborene main ist kein detached HEAD, erster Push zu leerem Remote (schliesst #96, #97)
CI / verify (push) Successful in 54s
Release / release (push) Successful in 37s

Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/git_publish.py
- tools/chemenu/tests/test_git_publish.py
This commit is contained in:
torben committed 2026-09-12 17:08:03 +02:00
1 parent 87719bf396
commit 9ef021bea1
5 files changed
+220 -7

No files matched your search

+40 -4
View File
@@ -348,12 +348,22 @@ def _changed_files(paths: list[str]) -> list[str]:
def current_branch() -> Optional[str]:
"""The checked-out branch, or None in a detached HEAD / non-checkout."""
result = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
"""The checked-out branch, or None in a detached HEAD / non-checkout.
`git symbolic-ref` and not `git rev-parse --abbrev-ref`, because the two disagree on
exactly the state a brand-new instance is in. On an *unborn* branch - `git init -b main`,
no commit yet, which is where `instructions/setup-instance.md` calls `publish` for the
first time - `HEAD` points at a ref that does not resolve, so `rev-parse` exits 128 and
the caller cannot tell it apart from a real detached HEAD. `symbolic-ref` reads what HEAD
*names* rather than what it resolves to, so it answers `main` there, empty (exit 1) on a
detached HEAD, and the branch name in the ordinary case - the distinction the branch check
below actually needs.
"""
result = _run(["git", "symbolic-ref", "--short", "-q", "HEAD"])
if result.returncode != 0:
return None
name = result.stdout.strip()
return None if not name or name == "HEAD" else name
return name or None
def branch_mismatch_message(checked_out: Optional[str], branch: str) -> str:
@@ -877,13 +887,39 @@ def reconcile(remote: str, branch: str, confirm_rebase: Optional[str] = None) ->
)
def remote_lacks_branch(remote: str, branch: str) -> bool:
"""Whether `remote` is reachable *and* has no `branch` on it - the brand-new instance whose
remote repository was created empty, as distinct from a remote nobody can reach at all.
`git ls-remote --exit-code` separates the two by exit code alone: 0 when the ref is there,
2 when the remote answered and does not have it, 128 when the remote could not be reached
(a bad URL, no network, refused auth) or is not configured. Reading the code rather than the
message is not a style preference - git translates `fatal: couldn't find remote ref main`,
so matching on the text would work only in an English locale.
"""
return _run(["git", "ls-remote", "--exit-code", remote, branch]).returncode == 2
def _has_commits(branch: str) -> bool:
"""Whether `branch` points at anything yet - false on the unborn branch of a fresh
`git init`, where there is nothing that could be ahead of anything."""
result = _run(["git", "rev-list", "--count", "-n", "1", branch])
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
def _local_ahead_of_remote(remote: str, branch: str) -> bool:
"""Whether `branch` currently has a commit `<remote>/<branch>` lacks - true right after a
stranded commit from a previous failed push, and also right after `reconcile` rebases local
work on top of a moved remote. Used to decide whether an otherwise-empty working tree still
has something to push."""
if not remote_ref_exists(remote, branch):
return False
# No tracking ref, which `remote_ref_exists` cannot tell apart from an unreachable
# remote - and the very first publish of an instance lands here. A remote that answers
# and simply has no such branch yet means every local commit is unpushed, which is
# precisely the stranded state above; an unreachable one keeps the old answer, so an
# offline or local-only instance sees no new behaviour and the eventual `git push`
# (when there is something to stage) still reports the real error.
return remote_lacks_branch(remote, branch) and _has_commits(branch)
result = _run(["git", "rev-list", "--count", f"{remote}/{branch}..{branch}"])
return result.returncode == 0 and result.stdout.strip() not in ("", "0")