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")
+125
View File
@@ -19,6 +19,7 @@ from chemenu.commands.git_publish import (
clearance_message,
collect_changes,
counted_files,
current_branch,
describe_status,
format_changes,
group_of,
@@ -27,6 +28,7 @@ from chemenu.commands.git_publish import (
parse_porcelain_z,
publish_command,
reconcile,
remote_lacks_branch,
rerun_command,
scale_line,
sync_command,
@@ -342,6 +344,41 @@ def repo(tmp_path, monkeypatch):
return root
@pytest.fixture
def fresh_instance(tmp_path, monkeypatch):
"""A brand-new instance as `instructions/setup-instance.md` builds one: `git init -b main`,
an identity, a remote pointing at an *empty* repository, and no commit anywhere - neither
locally nor on the remote.
Deliberately not the `repo` fixture with a step removed. That one makes an initial commit
and pushes it, which is exactly the state the first publish of an instance has not reached
yet: the local branch is unborn and the remote has no `main`. Both defects this covers live
in that gap.
"""
root = tmp_path / "instance"
remote = tmp_path / "empty-remote.git"
root.mkdir()
subprocess.run(["git", "init", "-b", "main", "--bare", str(remote)], check=True, capture_output=True)
_git(root, "init", "-b", "main")
_git(root, "config", "user.name", "Test")
_git(root, "config", "user.email", "test@example.com")
_git(root, "remote", "add", "origin", str(remote))
(root / "kb").mkdir()
(root / "README.md").write_text("fresh instance\n", encoding="utf-8")
monkeypatch.setattr(config, "ROOT", root)
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
return root
def _remote_log(remote_path):
"""The remote's one-line log, or "" for a remote with no commits at all - `git log` exits
non-zero on an empty repository, so `_git`'s assertion cannot be used here."""
return subprocess.run(
["git", "log", "--oneline"], cwd=remote_path, capture_output=True, text=True,
).stdout
def _write_files(root, n, prefix="kb/page"):
for i in range(n):
(root / f"{prefix}{i}.md").write_text(f"page {i}\n", encoding="utf-8")
@@ -724,6 +761,94 @@ def test_publish_pushes_a_stranded_local_commit_with_no_new_changes(repo):
assert "stranded commit" in remote_log
def test_current_branch_names_an_unborn_branch_rather_than_calling_it_detached(fresh_instance):
"""`git init -b main` with no commit yet: HEAD names `main` but does not resolve. Reporting
None here is what made the documented first publish of an instance fail as a detached HEAD."""
assert current_branch() == "main"
def test_current_branch_still_reports_a_real_detached_head_as_none(fresh_instance):
"""The other half of the same distinction - the state the refusal is actually for."""
_git(fresh_instance, "add", "-A")
_git(fresh_instance, "commit", "-m", "first")
head = _git(fresh_instance, "rev-parse", "HEAD").stdout.strip()
_git(fresh_instance, "checkout", "--detach", head)
assert current_branch() is None
def test_publish_makes_the_first_commit_of_a_new_instance(fresh_instance):
"""The exact `setup-instance.md` step 14 scenario: unborn `main`, empty remote, nothing but
untracked files. It must commit and push, not refuse as a detached HEAD."""
_publish(message="chore: initial instance setup", threshold=100)
assert "initial instance setup" in _remote_log(fresh_instance.parent / "empty-remote.git")
def test_publish_on_an_unborn_branch_still_refuses_a_different_target_branch(fresh_instance):
"""Naming the unborn branch does not weaken the branch check - it makes it answerable. A
push aimed elsewhere is refused with the same `--branch` hint as on any other checkout."""
with pytest.raises(typer.Exit) as excinfo:
_publish(message="change", branch="feature", threshold=100)
assert excinfo.value.exit_code == 1
assert _remote_log(fresh_instance.parent / "empty-remote.git") == ""
def test_the_gate_still_holds_on_a_fresh_instance_and_stages_nothing(fresh_instance):
"""A real distribution is far over the threshold at step 14, so the first publish is meant
to end at the gate - and leave the tree untouched, which has to hold with no HEAD to
compare against either."""
_write_files(fresh_instance, 10)
with pytest.raises(typer.Exit) as excinfo:
_publish(message="chore: initial instance setup")
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
# Nothing staged, nothing committed: all 11 files (10 pages plus the fixture's README)
# are still untracked, and the branch is still unborn.
status = _git(fresh_instance, "status", "--porcelain", "-uall").stdout.strip().splitlines()
assert len(status) == 11 and all(line.startswith("??") for line in status)
assert current_branch() == "main" and not git_publish._has_commits("main")
assert _remote_log(fresh_instance.parent / "empty-remote.git") == ""
def test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet(fresh_instance):
"""The `--no-push`-then-`publish` chain: the commit exists, the working tree is clean, and
the remote has never seen this branch. "Nothing to commit" here strands the commit forever,
because invariant 5 rules out a hand-made `git push`."""
_publish(message="chore: initial instance setup", push=False, threshold=100)
assert _remote_log(fresh_instance.parent / "empty-remote.git") == ""
_publish(message="chore: initial instance setup", threshold=100)
assert "initial instance setup" in _remote_log(fresh_instance.parent / "empty-remote.git")
def test_an_unborn_branch_is_not_ahead_of_an_empty_remote(fresh_instance):
"""Nothing committed anywhere: there is no commit that could be ahead, and claiming
otherwise would send `publish` into a push of nothing."""
assert git_publish._local_ahead_of_remote("origin", "main") is False
def test_an_unreachable_remote_is_not_mistaken_for_one_that_lacks_the_branch(fresh_instance):
""""Cannot reach the remote" and "the remote has no such branch" both leave no tracking ref.
Only the second means every local commit is unpushed; treating the first that way would turn
every offline publish into a failed push."""
_git(fresh_instance, "add", "-A")
_git(fresh_instance, "commit", "-m", "local only")
_git(fresh_instance, "remote", "set-url", "origin", str(fresh_instance.parent / "nope.git"))
assert remote_lacks_branch("origin", "main") is False
assert git_publish._local_ahead_of_remote("origin", "main") is False
def test_a_reachable_remote_without_the_branch_is_told_apart_from_one_that_has_it(repo):
"""The positive half, on the fixture whose remote genuinely carries `main`."""
assert remote_lacks_branch("origin", "main") is False
assert remote_lacks_branch("origin", "never-pushed") is True
def test_publish_auto_rebases_a_disjoint_divergence(repo):
"""The writer's change and this session's change touch different files: (a) alone is
enough, so this must go straight through - no exit 42."""