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:
1 parent
87719bf396
commit
9ef021bea1
5 files changed
+220
-7
No files matched your search
@@ -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")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user