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:
+52
@@ -35,6 +35,58 @@ dev-checkout concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 5.0.1-beta.1 - 2026-09-12 - publish: ungeborene main ist kein detached HEAD, erster Push zu leerem Remote (schliesst #96, #97)
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- publish: ungeborene main ist kein detached HEAD, erster Push zu leerem Remote (schliesst #96, #97)
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Zwei Defekte, die zusammen dazu führten, dass eine frisch aufgesetzte Instanz
|
||||||
|
sich über keinen dokumentierten Weg initial veröffentlichen ließ. Beide sitzen
|
||||||
|
in `publish` und wurden erst beim Einrichten einer 5.0.0-Instanz aus dem
|
||||||
|
Release-Tarball sichtbar - der Pfad, den `instructions/setup-instance.md`
|
||||||
|
Schritt 14 als *den* ersten schreibenden Aufruf einer neuen Instanz nennt.
|
||||||
|
|
||||||
|
**Ein ungeborener Branch ist kein detached HEAD.** `current_branch()` fragte
|
||||||
|
`git rev-parse --abbrev-ref HEAD`. Nach `git init -b main`, vor dem ersten
|
||||||
|
Commit, zeigt `HEAD` auf einen Ref, der sich nicht auflöst: `rev-parse` endet
|
||||||
|
mit Exit 128 und war damit von einem echten detached HEAD nicht zu
|
||||||
|
unterscheiden. Der erste `publish` einer neuen Instanz brach deshalb mit
|
||||||
|
"Refusing to push `main` from a detached HEAD" ab, und Invariante 5 schneidet
|
||||||
|
den naheliegenden Ausweg (`git commit` von Hand) ab. Die Funktion liest jetzt
|
||||||
|
`git symbolic-ref --short -q HEAD` - was `HEAD` *benennt* statt worauf es
|
||||||
|
zeigt. Damit beantwortet die Branch-Prüfung den ungeborenen Fall korrekt,
|
||||||
|
statt für ihn ausgesetzt werden zu müssen: sie vergleicht `main` mit `main`
|
||||||
|
und lässt durch. Ein echter detached HEAD wird unverändert abgelehnt, und ein
|
||||||
|
`--branch`, das nicht dem ausgecheckten entspricht, ebenfalls.
|
||||||
|
|
||||||
|
**Ein nie gepushter Branch ist nicht "nicht ahead".** `_local_ahead_of_remote()`
|
||||||
|
entscheidet, ob ein sauberer Working-Tree trotzdem etwas zu pushen hat, und
|
||||||
|
gab `False` zurück, sobald kein Tracking-Ref existierte. Genau das ist der Fall
|
||||||
|
bei einem frisch angelegten, leeren Remote-Repository: der lokale Commit stand,
|
||||||
|
`publish` meldete "Nothing to commit" und pushte nie - beliebig oft
|
||||||
|
wiederholbar. Unterschieden wird jetzt über `git ls-remote --exit-code`, dessen
|
||||||
|
Exit-Code die drei Lagen ohne Textvergleich trennt (0 = Ref vorhanden,
|
||||||
|
2 = Remote erreichbar und ohne diesen Ref, 128 = unerreichbar oder gar nicht
|
||||||
|
konfiguriert); auf die Meldung zu matchen schiede aus, weil git sie übersetzt.
|
||||||
|
Nur der mittlere Fall gilt als "ahead", und auch dort nur, wenn lokal
|
||||||
|
überhaupt ein Commit existiert. Ein unerreichbares Remote behält bewusst das
|
||||||
|
bisherige Verhalten, damit Offline- und Nur-lokal-Instanzen keine
|
||||||
|
Verhaltensänderung sehen. `remote_ref_exists()` bleibt unangetastet - sein
|
||||||
|
zweiter Aufrufer `reconcile()` meint damit weiterhin richtig "nichts zum
|
||||||
|
Abgleichen da".
|
||||||
|
|
||||||
|
`tools/CONTRACT.md` zieht beides nach: die `publish`-Zeile beschrieb den
|
||||||
|
Strandungsfall bisher als gelöst, was für einen nie gepushten Branch nicht
|
||||||
|
stimmte, und der Fehlerkontrakt benennt die Branch-Prüfung jetzt als
|
||||||
|
eigenständigen Exit-1-Grund. `instructions/setup-instance.md` und `INSTALL.md`
|
||||||
|
blieben inhaltlich richtig - sie hatten den Umweg nie beschrieben, sondern den
|
||||||
|
Weg, der jetzt tatsächlich funktioniert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5.0.0 - 2026-09-11 - TOC-Pflicht in docs verify, Konfidenz-Mechanismus ersatzlos entfernt
|
## 5.0.0 - 2026-09-11 - TOC-Pflicht in docs verify, Konfidenz-Mechanismus ersatzlos entfernt
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|||||||
+2
-2
@@ -150,7 +150,7 @@ tools/wikitool <command> --help
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` |
|
| `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` |
|
||||||
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending in `CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish |
|
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. Refuses before staging anything when the push target is not the checked-out branch, so a `git push <branch>` cannot quietly publish a ref other than the commit just made; the *unborn* branch of a fresh `git init -b main` counts as checked out, which is what lets the first publish of a new instance work (`instructions/setup-instance.md` step 14), while a genuine detached HEAD is still refused. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it, and neither does a branch the remote has never seen (a newly created, empty remote repository). A remote that cannot be reached at all is deliberately not read that way: it keeps reporting "Nothing to commit" on a clean tree rather than attempting a push, so an offline or local-only instance is unaffected. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending in `CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish |
|
||||||
|
|
||||||
### Workshop runs and session budget
|
### Workshop runs and session budget
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ is atomic, and whether a retry is safe.
|
|||||||
| Command | Exit 1 means | Atomic? | Retry policy |
|
| Command | Exit 1 means | Atomic? | Retry policy |
|
||||||
|---------|--------------|---------|--------------|
|
|---------|--------------|---------|--------------|
|
||||||
| `sync` | The automatic rebase hit a real conflict (git failed) | No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure | For a conflict: **do not retry, do not force** - resolve manually and re-run. **Exit 42, not 1**, when the rebase-review gate needs clearance: show the user the command's full output verbatim (upstream commits, the overlapping files, their diff) and stop; re-running with `--confirm-rebase <token>` clears it, and a wrong, invented, or superseded token exits 42 again with the current state. No remote configured, or one that cannot be reached, is not a failure - reported and skipped |
|
| `sync` | The automatic rebase hit a real conflict (git failed) | No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure | For a conflict: **do not retry, do not force** - resolve manually and re-run. **Exit 42, not 1**, when the rebase-review gate needs clearance: show the user the command's full output verbatim (upstream commits, the overlapping files, their diff) and stop; re-running with `--confirm-rebase <token>` clears it, and a wrong, invented, or superseded token exits 42 again with the current state. No remote configured, or one that cannot be reached, is not a failure - reported and skipped |
|
||||||
| `publish` | git failed, **or** `--yes`/`-y` was passed. **Exit 42, not 1**, when the Mass-Update Gate, the rebase-review gate (raised by the same reconcile `sync` performs), or the Publish-Remote Gate refuses | No - sequential git operations, but both gates run before staging | For git failures: **do not retry, do not force** - report and ask the user (the reconcile step already retried the push once on its own, if a rebase resolved the rejection). For exit 42: show the user the command's full output verbatim and stop; it names the evidence and the `--confirm <token>` or `--confirm-rebase <token>` line to re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the exception with no such line: it names the push URL that would have been written to and the ones this checkout allows, and only the user resolves it |
|
| `publish` | git failed, the push target is not the checked-out branch (including a real detached HEAD - but *not* the unborn branch of a fresh `git init`, which is a normal first publish), **or** `--yes`/`-y` was passed. **Exit 42, not 1**, when the Mass-Update Gate, the rebase-review gate (raised by the same reconcile `sync` performs), or the Publish-Remote Gate refuses | No - sequential git operations, but both gates run before staging | For git failures: **do not retry, do not force** - report and ask the user (the reconcile step already retried the push once on its own, if a rebase resolved the rejection). For exit 42: show the user the command's full output verbatim and stop; it names the evidence and the `--confirm <token>` or `--confirm-rebase <token>` line to re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the exception with no such line: it names the push URL that would have been written to and the ones this checkout allows, and only the user resolves it |
|
||||||
|
|
||||||
### Workshop runs and session budget
|
### Workshop runs and session budget
|
||||||
|
|
||||||
|
|||||||
@@ -348,12 +348,22 @@ def _changed_files(paths: list[str]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def current_branch() -> Optional[str]:
|
def current_branch() -> Optional[str]:
|
||||||
"""The checked-out branch, or None in a detached HEAD / non-checkout."""
|
"""The checked-out branch, or None in a detached HEAD / non-checkout.
|
||||||
result = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
||||||
|
`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:
|
if result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
name = result.stdout.strip()
|
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:
|
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:
|
def _local_ahead_of_remote(remote: str, branch: str) -> bool:
|
||||||
"""Whether `branch` currently has a commit `<remote>/<branch>` lacks - true right after a
|
"""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
|
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
|
work on top of a moved remote. Used to decide whether an otherwise-empty working tree still
|
||||||
has something to push."""
|
has something to push."""
|
||||||
if not remote_ref_exists(remote, branch):
|
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}"])
|
result = _run(["git", "rev-list", "--count", f"{remote}/{branch}..{branch}"])
|
||||||
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
|
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from chemenu.commands.git_publish import (
|
|||||||
clearance_message,
|
clearance_message,
|
||||||
collect_changes,
|
collect_changes,
|
||||||
counted_files,
|
counted_files,
|
||||||
|
current_branch,
|
||||||
describe_status,
|
describe_status,
|
||||||
format_changes,
|
format_changes,
|
||||||
group_of,
|
group_of,
|
||||||
@@ -27,6 +28,7 @@ from chemenu.commands.git_publish import (
|
|||||||
parse_porcelain_z,
|
parse_porcelain_z,
|
||||||
publish_command,
|
publish_command,
|
||||||
reconcile,
|
reconcile,
|
||||||
|
remote_lacks_branch,
|
||||||
rerun_command,
|
rerun_command,
|
||||||
scale_line,
|
scale_line,
|
||||||
sync_command,
|
sync_command,
|
||||||
@@ -342,6 +344,41 @@ def repo(tmp_path, monkeypatch):
|
|||||||
return root
|
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"):
|
def _write_files(root, n, prefix="kb/page"):
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
(root / f"{prefix}{i}.md").write_text(f"page {i}\n", encoding="utf-8")
|
(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
|
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):
|
def test_publish_auto_rebases_a_disjoint_divergence(repo):
|
||||||
"""The writer's change and this session's change touch different files: (a) alone is
|
"""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."""
|
enough, so this must go straight through - no exit 42."""
|
||||||
|
|||||||
Reference in New Issue
Block a user