feat: publish gate lists the staged state; a missing or unreachable remote stops before the commit (#159)
Files changed: - CHANGES.md - INSTALL.md - VERSION - instructions/publish-cycle.md - instructions/setup-instance.md - tools/CONTRACT.md - tools/chemenu/commands/git_publish.py - tools/chemenu/tests/test_git_publish.py
This commit is contained in:
1 parent
08dde007dd
commit
eadc052f6c
8 files changed
+486
-205
No files matched your search
+45
-2
@@ -59,11 +59,13 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8.0.0-beta.9 - 2026-09-30 - bug-report instruction: step 1 no longer calls every bundle unpseudonymised
|
## 8.0.0-beta.10 - 2026-09-30 - publish: the gate lists the staged state; a missing or unreachable remote stops before the commit
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
**Breaking Change:** Page titles must form valid, unique file names on Windows and macOS: new and rename refuse forbidden characters, reserved names (including INDEX and COLLECTION), a trailing dot or space, and titles that collide with another page by case or Unicode normalization; lint reports existing violations as hard errors - rename each affected page with tools/wikitool rename
|
**Breaking Change:**
|
||||||
|
- Page titles must form valid, unique file names on Windows and macOS: new and rename refuse forbidden characters, reserved names (including INDEX and COLLECTION), a trailing dot or space, and titles that collide with another page by case or Unicode normalization; lint reports existing violations as hard errors - rename each affected page with tools/wikitool rename
|
||||||
|
- publish without --no-push now exits 1 before committing when the remote is unreachable or not configured, where it used to commit locally and fail at the push - an offline session or a local-only instance must pass --no-push
|
||||||
|
|
||||||
**Migration:** none required - No page format changes; the rule only refuses titles, and each affected page is renamed individually with tools/wikitool rename
|
**Migration:** none required - No page format changes; the rule only refuses titles, and each affected page is renamed individually with tools/wikitool rename
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image
|
- Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image
|
||||||
- Bug-report collector: tools/bugreport.py and instructions/bug-report.md
|
- Bug-report collector: tools/bugreport.py and instructions/bug-report.md
|
||||||
- Bug-report collector can pseudonymise identities, in two stages
|
- Bug-report collector can pseudonymise identities, in two stages
|
||||||
|
- publish: the gate lists the staged state; a missing or unreachable remote stops before the commit
|
||||||
|
|
||||||
**Low impact**
|
**Low impact**
|
||||||
- version bump no longer points at version release in its output
|
- version bump no longer points at version release in its output
|
||||||
@@ -116,6 +119,46 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- bug-report instruction: step 1 no longer calls every bundle unpseudonymised
|
- bug-report instruction: step 1 no longer calls every bundle unpseudonymised
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### publish: the gate lists the staged state; a missing or unreachable remote stops before the commit (Gitea #159)
|
||||||
|
|
||||||
|
**The Mass-Update Gate counted a path twice.** `collect_changes` read `git status --porcelain`, which
|
||||||
|
reports the index and the working tree separately. A path staged as deleted that sits in the working
|
||||||
|
tree again (`git rm -r raw`, then `git restore --source=HEAD -- raw/CONTRACT.md`) appears as `D ` and
|
||||||
|
`??`; the gate counted both, while `git add -A` cancels them out and the commit held neither. The list
|
||||||
|
a human approved therefore named a deletion and a new file that were never committed. `collect_changes`
|
||||||
|
now stages into a scratch copy of the index (`GIT_INDEX_FILE`) and reads `git diff --cached --no-renames`
|
||||||
|
from it, so the list is the state the commit will hold. The real index and the working tree stay
|
||||||
|
byte-identical, also when the computation fails; `git add` writes the new blobs into the object store,
|
||||||
|
where `gc` collects the unreferenced ones.
|
||||||
|
|
||||||
|
- The digest in the `--confirm` token is now the blob id of the staged content instead of a sha256 over
|
||||||
|
the working-tree file, so the token binds to exactly what is committed. A deletion still has none.
|
||||||
|
- A rename is listed as its old path deleted plus its new path added, which is what the commit holds and
|
||||||
|
what the "deletions by name" note has to see. The `renamed` status is gone from the scale line.
|
||||||
|
- `_numstat`, `_untracked_stat`, `_changed_files`, `parse_porcelain_entries` and `parse_porcelain_z` are
|
||||||
|
removed; nothing else called them. The "Files changed:" list in the commit message comes from the same
|
||||||
|
list and is correct for the same reason.
|
||||||
|
- With `--path`, the list is restricted to that subtree even when more is staged, as the commit is.
|
||||||
|
|
||||||
|
**One message for two states became three.** A failed fetch was reported as "No remote configured, or
|
||||||
|
origin could not be reached". It is now `no-remote`, `remote-lacks-branch` (the remote answers and has no
|
||||||
|
such branch yet - the first publish of an instance) or `unreachable`, told apart by `git remote get-url`
|
||||||
|
and the exit code of `git ls-remote --exit-code`, each with its own message. `sync` exits 0 in all three.
|
||||||
|
|
||||||
|
**`publish` stops before the commit when it cannot publish.** Without `--no-push`, `unreachable` and
|
||||||
|
`no-remote` end the call with exit 1 at the reconcile - before the gate, `git add` and the commit, and
|
||||||
|
also on a clean tree, where it used to say "Nothing to commit". It used to commit and fail at the push,
|
||||||
|
which left a commit that only a hand-made `git push` could send. The messages name `--no-push` as the
|
||||||
|
way to a local commit; the next `publish` that reaches the remote sends that commit along. `remote-lacks-branch`
|
||||||
|
is unaffected, so the first publish of an instance still commits and pushes. The Publish-Remote Gate,
|
||||||
|
which runs first when `.wikitool-remotes.json` exists, is unchanged, and so is the retry after a rejected
|
||||||
|
push: a remote that has become unreachable by then reports the original push error.
|
||||||
|
|
||||||
|
This is a **breaking** change in the sense of the version model: an offline session, or an instance that
|
||||||
|
stays local, has to pass `--no-push` on every `publish`. No content changes, so `**Migration:**` stays
|
||||||
|
"none required". `instructions/publish-cycle.md` has the new decision point, `setup-instance.md` step 4
|
||||||
|
and `INSTALL.md` say what a local-only instance now sees without the flag.
|
||||||
|
|
||||||
### bug-report instruction: step 1 no longer calls every bundle unpseudonymised
|
### bug-report instruction: step 1 no longer calls every bundle unpseudonymised
|
||||||
|
|
||||||
Step 1 told the agent to announce that the bundle "is not pseudonymised" and then, one paragraph
|
Step 1 told the agent to announce that the bundle "is not pseudonymised" and then, one paragraph
|
||||||
|
|||||||
+2
-1
@@ -67,7 +67,8 @@ Zwei Schritte, von denen nur der erste rein menschlich ist:
|
|||||||
anderen Repo übernommen, und ist zugleich der Autorname jeder künftig angelegten
|
anderen Repo übernommen, und ist zugleich der Autorname jeder künftig angelegten
|
||||||
Wiki-Seite (`$WIKI_AUTHOR` überschreibt dies bei Bedarf).
|
Wiki-Seite (`$WIKI_AUTHOR` überschreibt dies bei Bedarf).
|
||||||
- **Remote** (optional) - eine URL, wenn du das Repo auf einen Server pushen willst; sonst
|
- **Remote** (optional) - eine URL, wenn du das Repo auf einen Server pushen willst; sonst
|
||||||
bleibt die Instanz lokal, und jedes `publish` läuft mit `--no-push`.
|
bleibt die Instanz lokal, und jedes `publish` läuft mit `--no-push` - ohne das Flag
|
||||||
|
bricht `publish` mit Exit 1 ab, bevor es committet.
|
||||||
- **Autorenkonventionen** - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels
|
- **Autorenkonventionen** - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels
|
||||||
und Hedging-Regel stehen in `kb/CONVENTIONS.md`, dazu je Collection die Regeln in
|
und Hedging-Regel stehen in `kb/CONVENTIONS.md`, dazu je Collection die Regeln in
|
||||||
`kb/<name>/COLLECTION.md`. Die Distribution bringt davon nur die `.template`-Dateien mit:
|
`kb/<name>/COLLECTION.md`. Die Distribution bringt davon nur die `.template`-Dateien mit:
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ consistent.
|
|||||||
|
|
||||||
- **Ten or more files changed?** `publish` exits 42. Show the user its output and stop; see
|
- **Ten or more files changed?** `publish` exits 42. Show the user its output and stop; see
|
||||||
[gates.md](gates.md).
|
[gates.md](gates.md).
|
||||||
|
- **Remote unreachable or not configured?** `publish` ends with exit 1 before it commits:
|
||||||
|
nothing is staged, committed or pushed, and the message names the remote. Ask the user whether
|
||||||
|
to commit locally with `--no-push`, and run that only on their answer. Never push by hand
|
||||||
|
(AGENTS.md invariant 5): the next `publish` that reaches the remote sends the local commit
|
||||||
|
together with whatever is new. A local-only instance, which has no remote at all, passes
|
||||||
|
`--no-push` on every call ([setup-instance.md](setup-instance.md), step 4).
|
||||||
- **Query or lint pass?** Neither auto-publishes. Run `publish` only if asked to.
|
- **Query or lint pass?** Neither auto-publishes. Run `publish` only if asked to.
|
||||||
- **Nothing under `kb/` changed?** Skip steps 1 and 2; a change to `tools/` or `instructions/`
|
- **Nothing under `kb/` changed?** Skip steps 1 and 2; a change to `tools/` or `instructions/`
|
||||||
does not affect the catalog.
|
does not affect the catalog.
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ and ready for its first ingest.
|
|||||||
end state:
|
end state:
|
||||||
- Given: `git remote add origin <url>`
|
- Given: `git remote add origin <url>`
|
||||||
- Not given: stay local - then **every** later `tools/wikitool publish` needs a `--no-push`
|
- Not given: stay local - then **every** later `tools/wikitool publish` needs a `--no-push`
|
||||||
(which also drops its branch check, see step 2).
|
(which also drops its branch check, see step 2). Without it, `publish` ends with exit 1
|
||||||
|
before it commits anything, because there is no remote to publish to.
|
||||||
|
|
||||||
5. **Decision point - authoring conventions.** The distribution ships no filled-in conventions,
|
5. **Decision point - authoring conventions.** The distribution ships no filled-in conventions,
|
||||||
only `kb/CONVENTIONS.md.template` and one `kb/<name>/COLLECTION.md.template` per collection.
|
only `kb/CONVENTIONS.md.template` and one `kb/<name>/COLLECTION.md.template` per collection.
|
||||||
|
|||||||
+9
-4
@@ -1640,7 +1640,8 @@ Fetch `<remote>/<branch>` and bring the local branch up to date with it.
|
|||||||
**EXIT STATUS**
|
**EXIT STATUS**
|
||||||
|
|
||||||
- 0 success
|
- 0 success
|
||||||
- 0 No remote configured, or the remote cannot be reached - reported and skipped, not a failure
|
- 0 No remote configured - reported and skipped, not a failure
|
||||||
|
- 0 The remote cannot be reached - reported and skipped, not a failure
|
||||||
- 1 The automatic rebase hit a real conflict (git failed); it is aborted cleanly
|
- 1 The automatic rebase hit a real conflict (git failed); it is aborted cleanly
|
||||||
- 42 Rebase-review gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff
|
- 42 Rebase-review gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff
|
||||||
|
|
||||||
@@ -1660,6 +1661,7 @@ Fetch `<remote>/<branch>` and bring the local branch up to date with it.
|
|||||||
- A refused call performs no rebase attempt and leaves the branch where it was.
|
- A refused call performs no rebase attempt and leaves the branch where it was.
|
||||||
- The `--confirm-rebase` token covers the exact upstream state and the set of files touched on both sides; either one moving makes it stale.
|
- The `--confirm-rebase` token covers the exact upstream state and the set of files touched on both sides; either one moving makes it stale.
|
||||||
- Makes no commit, no push, and no forced operation of any kind.
|
- Makes no commit, no push, and no forced operation of any kind.
|
||||||
|
- Three messages for a fetch that fails: no remote of that name, a remote that answers but has no such branch yet (a new, empty repository), and a remote that cannot be reached. All three exit 0 here; `publish` stops on the first and the last.
|
||||||
- Run it once at the start of a writing session.
|
- Run it once at the start of a writing session.
|
||||||
|
|
||||||
**SEE ALSO**
|
**SEE ALSO**
|
||||||
@@ -1696,6 +1698,7 @@ Reconcile with `<remote>/<branch>`, then stage all changes, commit, and push.
|
|||||||
- 0 success
|
- 0 success
|
||||||
- 1 git failed - `git add`, `git commit`, `git push`, or the reconcile's automatic rebase
|
- 1 git failed - `git add`, `git commit`, `git push`, or the reconcile's automatic rebase
|
||||||
- 1 The push target (`--branch`) is not the checked-out branch, or HEAD is detached; the unborn branch of a fresh `git init` is not this case
|
- 1 The push target (`--branch`) is not the checked-out branch, or HEAD is detached; the unborn branch of a fresh `git init` is not this case
|
||||||
|
- 1 No `--no-push`, and the remote is not configured or cannot be reached; nothing was committed
|
||||||
- 1 `--yes`/`-y` was passed - the flag does not exist and fails with an explicit error
|
- 1 `--yes`/`-y` was passed - the flag does not exist and fails with an explicit error
|
||||||
- 1 `.wikitool-remotes.json` is unreadable or has no usable `allowed_push_urls` list
|
- 1 `.wikitool-remotes.json` is unreadable or has no usable `allowed_push_urls` list
|
||||||
- 42 Mass-Update Gate: `--threshold` (default 10) or more counted files would be committed, or the `--confirm` token does not match this changeset
|
- 42 Mass-Update Gate: `--threshold` (default 10) or more counted files would be committed, or the `--confirm` token does not match this changeset
|
||||||
@@ -1706,6 +1709,7 @@ Reconcile with `<remote>/<branch>`, then stage all changes, commit, and push.
|
|||||||
|
|
||||||
- git failed - `git add`, `git commit`, `git push`, or the reconcile's automatic rebase -> Do not retry and do not force - report and ask the user. `publish` has already made its one retry of a rejected push itself, where a reconcile resolved the rejection
|
- git failed - `git add`, `git commit`, `git push`, or the reconcile's automatic rebase -> Do not retry and do not force - report and ask the user. `publish` has already made its one retry of a rejected push itself, where a reconcile resolved the rejection
|
||||||
- The push target (`--branch`) is not the checked-out branch, or HEAD is detached; the unborn branch of a fresh `git init` is not this case -> Check out the branch you mean to publish, or pass `--branch <checked-out branch>`, then retry once
|
- The push target (`--branch`) is not the checked-out branch, or HEAD is detached; the unborn branch of a fresh `git init` is not this case -> Check out the branch you mean to publish, or pass `--branch <checked-out branch>`, then retry once
|
||||||
|
- No `--no-push`, and the remote is not configured or cannot be reached; nothing was committed -> Show the message to the user and ask whether to commit locally with `--no-push`. Never push by hand - the next `publish` that reaches the remote sends that commit
|
||||||
- `--yes`/`-y` was passed - the flag does not exist and fails with an explicit error -> Drop it. The Mass-Update Gate is cleared only with `--confirm <token>` from the gate's own refusal output
|
- `--yes`/`-y` was passed - the flag does not exist and fails with an explicit error -> Drop it. The Mass-Update Gate is cleared only with `--confirm <token>` from the gate's own refusal output
|
||||||
- `.wikitool-remotes.json` is unreadable or has no usable `allowed_push_urls` list -> Show the error to the user and stop - a malformed file is not permission, and fixing or deleting it is theirs to do
|
- `.wikitool-remotes.json` is unreadable or has no usable `allowed_push_urls` list -> Show the error to the user and stop - a malformed file is not permission, and fixing or deleting it is theirs to do
|
||||||
- Mass-Update Gate: `--threshold` (default 10) or more counted files would be committed, or the `--confirm` token does not match this changeset -> Show the user the command's full output verbatim and stop. Once they have approved it, run the re-run line the output prints, which carries `--confirm <token>`. Without that token, or with a wrong, invented or superseded one, it exits 42 again with the current state
|
- Mass-Update Gate: `--threshold` (default 10) or more counted files would be committed, or the `--confirm` token does not match this changeset -> Show the user the command's full output verbatim and stop. Once they have approved it, run the re-run line the output prints, which carries `--confirm <token>`. Without that token, or with a wrong, invented or superseded one, it exits 42 again with the current state
|
||||||
@@ -1721,14 +1725,15 @@ Reconcile with `<remote>/<branch>`, then stage all changes, commit, and push.
|
|||||||
**NOTES**
|
**NOTES**
|
||||||
|
|
||||||
- Order: branch check and Publish-Remote Gate, then the reconcile with `<remote>/<branch>`, then the Mass-Update Gate, then `git add -A`, commit and push. `--no-push` skips all but the Mass-Update Gate and the commit.
|
- Order: branch check and Publish-Remote Gate, then the reconcile with `<remote>/<branch>`, then the Mass-Update Gate, then `git add -A`, commit and push. `--no-push` skips all but the Mass-Update Gate and the commit.
|
||||||
|
- Without `--no-push`, a remote that is not configured or cannot be reached ends the call with exit 1 at the reconcile - before the gate, `git add` and the commit, and also on a clean tree. Nothing is committed, the index and the working tree are unchanged, and the message names `--no-push` as the way to a local commit. The next `publish` that reaches the remote pushes that commit along with whatever is new. A remote that answers but has no `<branch>` yet (a new, empty repository) is not this case: the first publish of an instance commits and pushes as before.
|
||||||
- Reconcile: fetches `<remote>/<branch>`, fast-forwards when only the remote moved, rebases the local commits on top when both sides moved but touched disjoint files, and exits 42 (rebase-review gate) when both sides touched the same file. A refused reconcile performs no rebase attempt. The `--confirm-rebase` token covers the exact upstream state and the set of files touched on both sides.
|
- Reconcile: fetches `<remote>/<branch>`, fast-forwards when only the remote moved, rebases the local commits on top when both sides moved but touched disjoint files, and exits 42 (rebase-review gate) when both sides touched the same file. A refused reconcile performs no rebase attempt. The `--confirm-rebase` token covers the exact upstream state and the set of files touched on both sides.
|
||||||
- The push target must be the checked-out branch; this is checked before anything is staged. The unborn branch of a fresh `git init -b main` counts as checked out, so the first publish of a new instance works; a real detached HEAD is refused.
|
- The push target must be the checked-out branch; this is checked before anything is staged. The unborn branch of a fresh `git init -b main` counts as checked out, so the first publish of a new instance works; a real detached HEAD is refused.
|
||||||
- With nothing new to stage, a local commit the remote lacks is still pushed: one left behind by an earlier publish whose push failed, or every commit when the remote answers but does not have the branch yet (a new, empty remote repository).
|
- With nothing new to stage, a local commit the remote lacks is still pushed: one left behind by an earlier publish whose push failed, or every commit when the remote answers but does not have the branch yet (a new, empty remote repository).
|
||||||
- A remote that cannot be reached is not read as lacking the branch: on a clean tree `publish` reports "Nothing to commit" and attempts no push.
|
- A rejected push that finds the remote unreachable on its one retry reports the original push error.
|
||||||
- A rejected push gets exactly one more reconcile-and-push; never more than one.
|
- A rejected push gets exactly one more reconcile-and-push; never more than one.
|
||||||
- Mass-Update Gate: counts the files that would be committed, refuses with exit 42 at `--threshold` (default 10) or more, and prints a review report - a scale line (file count, total lines added/removed, status breakdown), attention notes where they apply (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. The gate is evaluated before anything is staged, so a refused publish leaves the working tree untouched.
|
- Mass-Update Gate: counts the files that would be committed, refuses with exit 42 at `--threshold` (default 10) or more, and prints a review report - a scale line (file count, total lines added/removed, status breakdown), attention notes where they apply (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. The list is what the commit will hold: it is computed from a scratch copy of the index after `git add -A`, so a path that is staged as deleted and back in the working tree is not counted twice, and a rename counts as its old path deleted plus its new path added. The real index and the working tree are not touched, so a refused publish leaves both byte-identical.
|
||||||
- Never counted and never shown for approval, but committed like everything else: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`). The refusal line accounts for both, by reason.
|
- Never counted and never shown for approval, but committed like everything else: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`). The refusal line accounts for both, by reason.
|
||||||
- The `--confirm` token covers each counted path, its contents and the publish target: a different file list or edited contents need a new clearance.
|
- The `--confirm` token covers each counted path, the blob id of its contents and the publish target: a different file list or edited contents need a new clearance.
|
||||||
- Publish-Remote Gate: when the checkout carries `.wikitool-remotes.json` and the push URL of `--remote` is not listed in it, exits 42 before the reconcile fetches anything. The URL is read with `git remote get-url --push`, so a repointed remote does not pass on its name. An absent file means unrestricted; a malformed one is an error, not permission.
|
- Publish-Remote Gate: when the checkout carries `.wikitool-remotes.json` and the push URL of `--remote` is not listed in it, exits 42 before the reconcile fetches anything. The URL is read with `git remote get-url --push`, so a repointed remote does not pass on its name. An absent file means unrestricted; a malformed one is an error, not permission.
|
||||||
- `--path` (repeatable) scopes the whole operation - gate count, staging and commit - to that subtree.
|
- `--path` (repeatable) scopes the whole operation - gate count, staging and commit - to that subtree.
|
||||||
- After a successful commit or push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md` or a path ending in `CONTRACT.md`, prints one reminder line: 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`. It is not a gate: no exit code change, nothing to clear, and silent for an ordinary content publish.
|
- After a successful commit or push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md` or a path ending in `CONTRACT.md`, prints one reminder line: 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`. It is not a gate: no exit code change, nothing to clear, and silent for an ordinary content publish.
|
||||||
|
|||||||
@@ -49,9 +49,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import NamedTuple, Optional
|
from typing import NamedTuple, Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -160,48 +164,17 @@ def publish_remote_refusal(remote: str, branch: str) -> Optional[str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_porcelain_entries(stdout: str) -> list[tuple[str, str]]:
|
# The status letters `git diff --cached --raw` emits under `--no-renames` (renames and copies
|
||||||
"""Parse `git status --porcelain -z` output into (status_code, path) pairs.
|
# are switched off, so neither R nor C can appear). Anything else - M, and T for a type change -
|
||||||
|
# is a modification.
|
||||||
NUL-delimited output is used instead of line-splitting because it is the
|
_STATUS_WORDS = {"A": "added", "D": "deleted"}
|
||||||
only form that survives paths containing spaces, quotes, or newlines
|
|
||||||
(line mode quotes and escapes them instead). Rename/copy entries carry a
|
|
||||||
second NUL-separated field with the original path; the new path is what
|
|
||||||
gets committed, so the original is consumed and discarded.
|
|
||||||
"""
|
|
||||||
fields = [f for f in stdout.split("\0") if f != ""]
|
|
||||||
entries: list[tuple[str, str]] = []
|
|
||||||
index = 0
|
|
||||||
while index < len(fields):
|
|
||||||
entry = fields[index]
|
|
||||||
index += 1
|
|
||||||
if len(entry) < 4:
|
|
||||||
continue
|
|
||||||
status_code, path = entry[:2], entry[3:]
|
|
||||||
if status_code[0] in ("R", "C") or status_code[1] in ("R", "C"):
|
|
||||||
index += 1 # skip the original path of a rename/copy
|
|
||||||
entries.append((status_code, path))
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def parse_porcelain_z(stdout: str) -> list[str]:
|
def describe_status(letter: str) -> str:
|
||||||
"""Just the paths - what the gate counts and what `git add -A` would stage."""
|
"""`git diff --raw` status letter -> the word a reviewer needs. Deletions and additions
|
||||||
return [path for _, path in parse_porcelain_entries(stdout)]
|
are what a reader scans for first, so they must not be flattened into a generic
|
||||||
|
"changed"."""
|
||||||
|
return _STATUS_WORDS.get(letter, "modified")
|
||||||
def describe_status(status_code: str) -> str:
|
|
||||||
"""Porcelain XY code -> the word a reviewer needs. Deletions and additions
|
|
||||||
are what a reader scans for first, so they must not be flattened into a
|
|
||||||
generic "changed"."""
|
|
||||||
if status_code == "??":
|
|
||||||
return "added"
|
|
||||||
if "D" in status_code:
|
|
||||||
return "deleted"
|
|
||||||
if "R" in status_code or "C" in status_code:
|
|
||||||
return "renamed"
|
|
||||||
if "A" in status_code:
|
|
||||||
return "added"
|
|
||||||
return "modified"
|
|
||||||
|
|
||||||
|
|
||||||
class FileChange(NamedTuple):
|
class FileChange(NamedTuple):
|
||||||
@@ -230,123 +203,96 @@ class FileChange(NamedTuple):
|
|||||||
return f"+{self.added}/-{self.removed}"
|
return f"+{self.added}/-{self.removed}"
|
||||||
|
|
||||||
|
|
||||||
def _numstat(paths: list[str]) -> dict[str, tuple[int, int]]:
|
def _git_path(*args: str) -> str:
|
||||||
"""Lines added/removed per tracked file, against HEAD.
|
result = _run(["git", "rev-parse", "--git-path", *args])
|
||||||
|
|
||||||
`git diff HEAD` covers staged and unstaged changes together, which is what
|
|
||||||
`publish` is about to commit. Untracked files are absent from it and are
|
|
||||||
measured by reading them instead. A repository with no commits yet has no
|
|
||||||
HEAD to diff against - that is the first-commit case from
|
|
||||||
`instructions/setup-instance.md`, where everything is untracked anyway - so
|
|
||||||
a failure here is normal and yields no entries rather than an error.
|
|
||||||
|
|
||||||
`-z` is required, not a preference: without it git renders a path with any
|
|
||||||
non-ASCII byte in quoted form ("kb/W\\303\\266rterbuch.md"), while
|
|
||||||
`_changed_files` reads raw paths from `git status --porcelain -z`. The two
|
|
||||||
then never match, the caller's lookup misses, and the file is measured as
|
|
||||||
though git had never seen it - every line an addition, no removals. A
|
|
||||||
rewritten page reported as a pure insertion hides exactly what a reviewer
|
|
||||||
is being asked to approve.
|
|
||||||
"""
|
|
||||||
result = _run(["git", "diff", "--numstat", "-z", "HEAD", "--", *paths])
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return {}
|
fail(f"git rev-parse failed:\n{result.stderr}")
|
||||||
stats: dict[str, tuple[int, int]] = {}
|
return result.stdout.strip()
|
||||||
# With -z each record is "added\tremoved\tpath" terminated by NUL. A rename
|
|
||||||
# or copy leaves the path empty and follows with two more records, the old
|
|
||||||
# and the new path; the new one is what `git status` reports.
|
def _parse_raw(stdout: str) -> list[tuple[str, str, str]]:
|
||||||
records = result.stdout.split("\0")
|
"""`git diff --raw -z --no-renames --no-abbrev` -> (status letter, new blob id, path).
|
||||||
index = 0
|
|
||||||
while index < len(records):
|
Each record is ":<old mode> <new mode> <old id> <new id> <letter>" and, after a NUL, the
|
||||||
record = records[index]
|
path. NUL-delimited output is the only form that survives a path with a space, a quote or
|
||||||
index += 1
|
a non-ASCII byte, which line mode quotes and escapes instead.
|
||||||
if not record:
|
"""
|
||||||
|
fields = [f for f in stdout.split("\0") if f != ""]
|
||||||
|
entries: list[tuple[str, str, str]] = []
|
||||||
|
for index in range(0, len(fields) - 1, 2):
|
||||||
|
meta = fields[index].lstrip(":").split(" ")
|
||||||
|
if len(meta) < 5:
|
||||||
continue
|
continue
|
||||||
|
entries.append((meta[4][0], meta[3], fields[index + 1]))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_numstat(stdout: str) -> dict[str, tuple[int, int]]:
|
||||||
|
"""`git diff --numstat -z --no-renames` -> path -> (added, removed); (-1, -1) for a binary
|
||||||
|
file, for which git writes "-" in both columns."""
|
||||||
|
stats: dict[str, tuple[int, int]] = {}
|
||||||
|
for record in stdout.split("\0"):
|
||||||
fields = record.split("\t")
|
fields = record.split("\t")
|
||||||
if len(fields) < 3:
|
if len(fields) < 3:
|
||||||
continue
|
continue
|
||||||
added, removed, path = fields[0], fields[1], fields[2]
|
added, removed, path = fields[0], fields[1], fields[2]
|
||||||
if not path:
|
|
||||||
if index + 1 >= len(records):
|
|
||||||
continue
|
|
||||||
path = records[index + 1]
|
|
||||||
index += 2
|
|
||||||
# git writes "-" for both counts on a binary file.
|
|
||||||
stats[path] = (-1, -1) if added == "-" else (int(added), int(removed))
|
stats[path] = (-1, -1) if added == "-" else (int(added), int(removed))
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def _untracked_stat(path: str) -> tuple[int, int, str]:
|
|
||||||
"""(added, removed, digest) for a file git has never seen: every line is an
|
|
||||||
addition. Anything unreadable as UTF-8 counts as binary rather than
|
|
||||||
guessing at a line count."""
|
|
||||||
full = config.ROOT / path
|
|
||||||
try:
|
|
||||||
data = full.read_bytes()
|
|
||||||
except OSError:
|
|
||||||
return (-1, -1, "")
|
|
||||||
digest = hashlib.sha256(data).hexdigest()[:16]
|
|
||||||
try:
|
|
||||||
text = data.decode("utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
return (-1, -1, digest)
|
|
||||||
return (len(text.splitlines()), 0, digest)
|
|
||||||
|
|
||||||
|
|
||||||
def _digest_of(path: str) -> str:
|
|
||||||
"""A short content digest of the working-tree file, or "" if it is gone.
|
|
||||||
|
|
||||||
This is what binds a clearance to file *contents* and not merely to file
|
|
||||||
*names*: without it, approving a list and then rewriting one of those files
|
|
||||||
before confirming would still publish, which is the same "approved A,
|
|
||||||
published B" hole the token exists to close.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return hashlib.sha256((config.ROOT / path).read_bytes()).hexdigest()[:16]
|
|
||||||
except OSError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def collect_changes(paths: list[str]) -> list[FileChange]:
|
def collect_changes(paths: list[str]) -> list[FileChange]:
|
||||||
"""Every path `git add -A` would stage, with its status, churn and content
|
"""Every path `git add -A -- <paths>` would put into the commit, with its status, churn
|
||||||
digest. Ordered by path so the result is stable."""
|
and content digest. Ordered by path so the result is stable.
|
||||||
result = _run(["git", "status", "--porcelain", "-z", "-uall", "--", *paths])
|
|
||||||
|
The list is computed from the state *after* staging, which is the only state the commit
|
||||||
|
sees: `git status` reports the index and the working tree separately, so a path that is
|
||||||
|
staged as deleted and sits in the working tree again appears twice and cancels out at
|
||||||
|
`git add -A`. The staging happens in a copy of the index, so a refused publish leaves the
|
||||||
|
real index - and the working tree - byte-identical. `git add` does write the new blobs into
|
||||||
|
the object store; unreferenced ones are `gc`'s to collect.
|
||||||
|
|
||||||
|
The digest is the blob id of the staged content, so a clearance token is bound to exactly
|
||||||
|
what is committed. A deletion has no content and therefore no digest. `--no-renames` lists
|
||||||
|
a rename as its old path (deleted) plus its new one (added), which is what the commit holds
|
||||||
|
and what the attention note on deletions has to see.
|
||||||
|
"""
|
||||||
|
index_path = config.ROOT / _git_path("index")
|
||||||
|
scratch = Path(tempfile.mkdtemp(prefix="wikitool-index-"))
|
||||||
|
try:
|
||||||
|
temp_index = scratch / "index"
|
||||||
|
if index_path.is_file():
|
||||||
|
shutil.copyfile(index_path, temp_index)
|
||||||
|
env = {**os.environ, "GIT_INDEX_FILE": str(temp_index)}
|
||||||
|
|
||||||
|
def run(args: list[str]) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args], cwd=config.ROOT, capture_output=True, text=True, env=env,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
fail(f"git status failed:\n{result.stderr}")
|
fail(f"git {args[0]} failed:\n{result.stderr}")
|
||||||
entries = parse_porcelain_entries(result.stdout)
|
return result.stdout
|
||||||
numstat = _numstat(paths)
|
|
||||||
|
run(["add", "-A", "--", *paths])
|
||||||
|
diff = ["diff", "--cached", "--no-renames", "-z"]
|
||||||
|
raw = _parse_raw(run([*diff, "--raw", "--no-abbrev", "--", *paths]))
|
||||||
|
numstat = _parse_numstat(run([*diff, "--numstat", "--", *paths]))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(scratch, ignore_errors=True)
|
||||||
|
|
||||||
changes: list[FileChange] = []
|
changes: list[FileChange] = []
|
||||||
for status_code, path in entries:
|
for letter, blob, path in raw:
|
||||||
status = describe_status(status_code)
|
status = describe_status(letter)
|
||||||
|
added, removed = numstat.get(path, (0, 0))
|
||||||
if status == "deleted":
|
if status == "deleted":
|
||||||
# A deletion's churn is every line the file had, and git already
|
# A deletion's churn is every line the file had, and git already knows it:
|
||||||
# knows it. Short-circuiting to 0/0 here (the first version of this
|
# reporting 0 here once hid four fifths of the removals in the one direction a
|
||||||
# code) silently understated the headline: one 718-line file went
|
# reviewer most needs them not understated.
|
||||||
# out reported as `-174` against git's own `-891`, hiding four
|
|
||||||
# fifths of the removals in the one direction a reviewer most needs
|
|
||||||
# not understated. The digest stays empty - there is no content
|
|
||||||
# left to fingerprint - which is itself what moves the token.
|
|
||||||
_, removed = numstat.get(path, (0, 0))
|
|
||||||
changes.append(FileChange(path, status, 0, max(removed, 0), ""))
|
changes.append(FileChange(path, status, 0, max(removed, 0), ""))
|
||||||
elif path in numstat:
|
|
||||||
added, removed = numstat[path]
|
|
||||||
changes.append(FileChange(path, status, added, removed, _digest_of(path)))
|
|
||||||
else:
|
else:
|
||||||
added, removed, digest = _untracked_stat(path)
|
changes.append(FileChange(path, status, added, removed, blob))
|
||||||
changes.append(FileChange(path, status, added, removed, digest))
|
|
||||||
return sorted(changes, key=lambda change: change.path)
|
return sorted(changes, key=lambda change: change.path)
|
||||||
|
|
||||||
|
|
||||||
def _changed_files(paths: list[str]) -> list[str]:
|
|
||||||
"""Every path `git add -A` would stage, including untracked files,
|
|
||||||
optionally restricted to a pathspec."""
|
|
||||||
result = _run(["git", "status", "--porcelain", "-z", "-uall", "--", *paths])
|
|
||||||
if result.returncode != 0:
|
|
||||||
fail(f"git status failed:\n{result.stderr}")
|
|
||||||
return parse_porcelain_z(result.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -549,7 +495,7 @@ def scale_line(changes: list[FileChange]) -> str:
|
|||||||
by_status[change.status] = by_status.get(change.status, 0) + 1
|
by_status[change.status] = by_status.get(change.status, 0) + 1
|
||||||
breakdown = ", ".join(
|
breakdown = ", ".join(
|
||||||
f"{by_status[status]} {status}"
|
f"{by_status[status]} {status}"
|
||||||
for status in ("added", "modified", "renamed", "deleted")
|
for status in ("added", "modified", "deleted")
|
||||||
if by_status.get(status)
|
if by_status.get(status)
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
@@ -753,12 +699,20 @@ def remote_ref_exists(remote: str, branch: str) -> bool:
|
|||||||
|
|
||||||
def fetch_remote(remote: str, branch: str) -> bool:
|
def fetch_remote(remote: str, branch: str) -> bool:
|
||||||
"""`git fetch <remote> <branch>`, true on success. A failure here (no remote configured,
|
"""`git fetch <remote> <branch>`, true on success. A failure here (no remote configured,
|
||||||
network/auth, or a branch that does not exist on the remote yet) is never fatal on its own -
|
network/auth, or a branch that does not exist on the remote yet) is not an answer by itself:
|
||||||
every caller falls back to today's behaviour and lets the eventual `git push` report the
|
`reconcile` tells those three apart, and only `publish` treats two of them as a reason to
|
||||||
real error, so an offline or brand-new instance sees no new failure mode."""
|
stop."""
|
||||||
return _run(["git", "fetch", remote, branch]).returncode == 0
|
return _run(["git", "fetch", remote, branch]).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def remote_url(remote: str) -> Optional[str]:
|
||||||
|
"""The fetch URL of `remote`, or None when no such remote is configured."""
|
||||||
|
result = _run(["git", "remote", "get-url", remote])
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() or None
|
||||||
|
|
||||||
|
|
||||||
def divergence(local_ref: str, remote_ref: str) -> str:
|
def divergence(local_ref: str, remote_ref: str) -> str:
|
||||||
"""Where `local_ref` stands relative to `remote_ref`: "up-to-date", "ff-possible" (remote
|
"""Where `local_ref` stands relative to `remote_ref`: "up-to-date", "ff-possible" (remote
|
||||||
only, local can fast-forward), "local-ahead" (local only, nothing to pull), or "diverged"
|
only, local can fast-forward), "local-ahead" (local only, nothing to pull), or "diverged"
|
||||||
@@ -811,8 +765,9 @@ def rebase_review_token(remote: str, branch: str, local_before: str, remote_tip:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ReconcileOutcome:
|
class ReconcileOutcome:
|
||||||
"""What happened when the local branch was brought up to date with the remote before a
|
"""What happened when the local branch was brought up to date with the remote before a
|
||||||
publish/sync. `status` is one of: "no-remote-or-fetch-failed", "up-to-date",
|
publish/sync. `status` is one of: "no-remote", "remote-lacks-branch", "unreachable",
|
||||||
"fast-forwarded", "local-ahead", "rebased", "needs-review", "conflict"."""
|
"up-to-date", "fast-forwarded", "local-ahead", "rebased", "needs-review", "conflict".
|
||||||
|
The first three are the ways the fetch can fail; `detail` then carries the remote's URL."""
|
||||||
status: str
|
status: str
|
||||||
pulled_commits: list[str] = field(default_factory=list)
|
pulled_commits: list[str] = field(default_factory=list)
|
||||||
overlap_files: list[str] = field(default_factory=list)
|
overlap_files: list[str] = field(default_factory=list)
|
||||||
@@ -835,7 +790,14 @@ def reconcile(remote: str, branch: str, confirm_rebase: Optional[str] = None) ->
|
|||||||
job) and `publish` (proactively before staging, and once more if the eventual push is
|
job) and `publish` (proactively before staging, and once more if the eventual push is
|
||||||
rejected - the real, narrow race this whole module exists to close)."""
|
rejected - the real, narrow race this whole module exists to close)."""
|
||||||
if not fetch_remote(remote, branch) or not remote_ref_exists(remote, branch):
|
if not fetch_remote(remote, branch) or not remote_ref_exists(remote, branch):
|
||||||
return ReconcileOutcome(status="no-remote-or-fetch-failed")
|
url = remote_url(remote)
|
||||||
|
if url is None:
|
||||||
|
return ReconcileOutcome(status="no-remote")
|
||||||
|
# `remote_lacks_branch` reads the ls-remote exit code: 2 means the remote answered and
|
||||||
|
# has no such branch (a new, empty repository), anything else that it did not answer.
|
||||||
|
if remote_lacks_branch(remote, branch):
|
||||||
|
return ReconcileOutcome(status="remote-lacks-branch", detail=url)
|
||||||
|
return ReconcileOutcome(status="unreachable", detail=url)
|
||||||
|
|
||||||
remote_ref = f"{remote}/{branch}"
|
remote_ref = f"{remote}/{branch}"
|
||||||
state = divergence(branch, remote_ref)
|
state = divergence(branch, remote_ref)
|
||||||
@@ -916,9 +878,9 @@ def _local_ahead_of_remote(remote: str, branch: str) -> bool:
|
|||||||
# No tracking ref, which `remote_ref_exists` cannot tell apart from an unreachable
|
# 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
|
# 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
|
# 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
|
# precisely the stranded state above. An unreachable or missing remote never gets this
|
||||||
# offline or local-only instance sees no new behaviour and the eventual `git push`
|
# far in `publish`, which stops on it before the commit; the answer here stays exact
|
||||||
# (when there is something to stage) still reports the real error.
|
# for a remote that goes away between the reconcile and this call.
|
||||||
return remote_lacks_branch(remote, branch) and _has_commits(branch)
|
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")
|
||||||
@@ -962,11 +924,39 @@ def _reconcile_summary(outcome: ReconcileOutcome, remote: str, branch: str) -> s
|
|||||||
return f"Rebased onto {len(outcome.pulled_commits)} new commit(s) from {remote}/{branch}{reviewed}."
|
return f"Rebased onto {len(outcome.pulled_commits)} new commit(s) from {remote}/{branch}{reviewed}."
|
||||||
if outcome.status == "up-to-date":
|
if outcome.status == "up-to-date":
|
||||||
return f"Already up to date with {remote}/{branch}."
|
return f"Already up to date with {remote}/{branch}."
|
||||||
if outcome.status == "no-remote-or-fetch-failed":
|
if outcome.status == "no-remote":
|
||||||
return f"No remote configured, or {remote} could not be reached - continuing without a pull."
|
return f"No remote '{remote}' configured - nothing to pull."
|
||||||
|
if outcome.status == "remote-lacks-branch":
|
||||||
|
return f"{remote} has no branch '{branch}' yet - nothing to pull."
|
||||||
|
if outcome.status == "unreachable":
|
||||||
|
return f"{remote} ({outcome.detail}) could not be reached - nothing pulled."
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def publish_stop_message(outcome: ReconcileOutcome, remote: str, branch: str) -> Optional[str]:
|
||||||
|
"""The ERROR `publish` ends with, before gate, staging and commit, when the remote it was
|
||||||
|
asked to publish to is missing or cannot be reached - else None.
|
||||||
|
|
||||||
|
`publish` without `--no-push` means "publish", and that is not possible. Committing anyway
|
||||||
|
left a commit that only a hand-made `git push` could send, which invariant 5 rules out, so
|
||||||
|
the stop comes first and names the way to a local commit.
|
||||||
|
"""
|
||||||
|
if outcome.status == "unreachable":
|
||||||
|
return (
|
||||||
|
f"Cannot publish: {remote} ({outcome.detail}) could not be reached. Nothing was "
|
||||||
|
"committed or pushed. To commit locally in the meantime, run the same `publish` "
|
||||||
|
f"with `--no-push`; the next `publish` that reaches {remote} pushes that commit "
|
||||||
|
"together with whatever is new."
|
||||||
|
)
|
||||||
|
if outcome.status == "no-remote":
|
||||||
|
return (
|
||||||
|
f"Cannot publish: this checkout has no remote named '{remote}'. Nothing was "
|
||||||
|
"committed or pushed. A local-only instance calls every `publish` with `--no-push` "
|
||||||
|
"(instructions/setup-instance.md, step 4); to publish, add the remote first."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command: str) -> str:
|
def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command: str) -> str:
|
||||||
"""Turn a `ReconcileOutcome` into this module's fail/needs_clearance contract: raises via
|
"""Turn a `ReconcileOutcome` into this module's fail/needs_clearance contract: raises via
|
||||||
`fail()` on `conflict`, raises via `needs_clearance()` on `needs-review` (emitting the same
|
`fail()` on `conflict`, raises via `needs_clearance()` on `needs-review` (emitting the same
|
||||||
@@ -1021,12 +1011,19 @@ def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command
|
|||||||
"The `--confirm-rebase` token covers the exact upstream state and the set of files "
|
"The `--confirm-rebase` token covers the exact upstream state and the set of files "
|
||||||
"touched on both sides; either one moving makes it stale.",
|
"touched on both sides; either one moving makes it stale.",
|
||||||
"Makes no commit, no push, and no forced operation of any kind.",
|
"Makes no commit, no push, and no forced operation of any kind.",
|
||||||
|
"Three messages for a fetch that fails: no remote of that name, a remote that answers "
|
||||||
|
"but has no such branch yet (a new, empty repository), and a remote that cannot be "
|
||||||
|
"reached. All three exit 0 here; `publish` stops on the first and the last.",
|
||||||
"Run it once at the start of a writing session.",
|
"Run it once at the start of a writing session.",
|
||||||
),
|
),
|
||||||
failures=(
|
failures=(
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="No remote configured, or the remote cannot be reached - reported and "
|
cause="No remote configured - reported and skipped, not a failure",
|
||||||
"skipped, not a failure",
|
reaction="",
|
||||||
|
code=0,
|
||||||
|
),
|
||||||
|
cli_contract.Failure(
|
||||||
|
cause="The remote cannot be reached - reported and skipped, not a failure",
|
||||||
reaction="",
|
reaction="",
|
||||||
code=0,
|
code=0,
|
||||||
),
|
),
|
||||||
@@ -1099,6 +1096,13 @@ def sync_command(
|
|||||||
"Order: branch check and Publish-Remote Gate, then the reconcile with "
|
"Order: branch check and Publish-Remote Gate, then the reconcile with "
|
||||||
"`<remote>/<branch>`, then the Mass-Update Gate, then `git add -A`, commit and push. "
|
"`<remote>/<branch>`, then the Mass-Update Gate, then `git add -A`, commit and push. "
|
||||||
"`--no-push` skips all but the Mass-Update Gate and the commit.",
|
"`--no-push` skips all but the Mass-Update Gate and the commit.",
|
||||||
|
"Without `--no-push`, a remote that is not configured or cannot be reached ends the "
|
||||||
|
"call with exit 1 at the reconcile - before the gate, `git add` and the commit, and "
|
||||||
|
"also on a clean tree. Nothing is committed, the index and the working tree are "
|
||||||
|
"unchanged, and the message names `--no-push` as the way to a local commit. The next "
|
||||||
|
"`publish` that reaches the remote pushes that commit along with whatever is new. A "
|
||||||
|
"remote that answers but has no `<branch>` yet (a new, empty repository) is not this "
|
||||||
|
"case: the first publish of an instance commits and pushes as before.",
|
||||||
"Reconcile: fetches `<remote>/<branch>`, fast-forwards when only the remote moved, "
|
"Reconcile: fetches `<remote>/<branch>`, fast-forwards when only the remote moved, "
|
||||||
"rebases the local commits on top when both sides moved but touched disjoint files, "
|
"rebases the local commits on top when both sides moved but touched disjoint files, "
|
||||||
"and exits 42 (rebase-review gate) when both sides touched the same file. A refused "
|
"and exits 42 (rebase-review gate) when both sides touched the same file. A refused "
|
||||||
@@ -1110,22 +1114,25 @@ def sync_command(
|
|||||||
"With nothing new to stage, a local commit the remote lacks is still pushed: one left "
|
"With nothing new to stage, a local commit the remote lacks is still pushed: one left "
|
||||||
"behind by an earlier publish whose push failed, or every commit when the remote "
|
"behind by an earlier publish whose push failed, or every commit when the remote "
|
||||||
"answers but does not have the branch yet (a new, empty remote repository).",
|
"answers but does not have the branch yet (a new, empty remote repository).",
|
||||||
"A remote that cannot be reached is not read as lacking the branch: on a clean tree "
|
"A rejected push that finds the remote unreachable on its one retry reports the "
|
||||||
"`publish` reports \"Nothing to commit\" and attempts no push.",
|
"original push error.",
|
||||||
"A rejected push gets exactly one more reconcile-and-push; never more than one.",
|
"A rejected push gets exactly one more reconcile-and-push; never more than one.",
|
||||||
"Mass-Update Gate: counts the files that would be committed, refuses with exit 42 at "
|
"Mass-Update Gate: counts the files that would be committed, refuses with exit 42 at "
|
||||||
"`--threshold` (default 10) or more, and prints a review report - a scale line (file "
|
"`--threshold` (default 10) or more, and prints a review report - a scale line (file "
|
||||||
"count, total lines added/removed, status breakdown), attention notes where they apply "
|
"count, total lines added/removed, status breakdown), attention notes where they apply "
|
||||||
"(deletions by name, control-plane and harness-config touches, published pages, the "
|
"(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 "
|
"largest single change, binaries), and every counted path grouped by area with its "
|
||||||
"status and churn. The gate is evaluated before anything is staged, so a refused "
|
"status and churn. The list is what the commit will hold: it is computed from a scratch "
|
||||||
"publish leaves the working tree untouched.",
|
"copy of the index after `git add -A`, so a path that is staged as deleted and back in "
|
||||||
|
"the working tree is not counted twice, and a rename counts as its old path deleted "
|
||||||
|
"plus its new path added. The real index and the working tree are not touched, so a "
|
||||||
|
"refused publish leaves both byte-identical.",
|
||||||
"Never counted and never shown for approval, but committed like everything else: "
|
"Never counted and never shown for approval, but committed like everything else: "
|
||||||
"anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, "
|
"anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, "
|
||||||
"`kb/log.md`, `kb/provenance.md`, every `INDEX.md`). The refusal line accounts for "
|
"`kb/log.md`, `kb/provenance.md`, every `INDEX.md`). The refusal line accounts for "
|
||||||
"both, by reason.",
|
"both, by reason.",
|
||||||
"The `--confirm` token covers each counted path, its contents and the publish target: "
|
"The `--confirm` token covers each counted path, the blob id of its contents and the "
|
||||||
"a different file list or edited contents need a new clearance.",
|
"publish target: a different file list or edited contents need a new clearance.",
|
||||||
"Publish-Remote Gate: when the checkout carries `.wikitool-remotes.json` and the push "
|
"Publish-Remote Gate: when the checkout carries `.wikitool-remotes.json` and the push "
|
||||||
"URL of `--remote` is not listed in it, exits 42 before the reconcile fetches anything. "
|
"URL of `--remote` is not listed in it, exits 42 before the reconcile fetches anything. "
|
||||||
"The URL is read with `git remote get-url --push`, so a repointed remote does not pass "
|
"The URL is read with `git remote get-url --push`, so a repointed remote does not pass "
|
||||||
@@ -1154,6 +1161,13 @@ def sync_command(
|
|||||||
reaction="Check out the branch you mean to publish, or pass `--branch <checked-out "
|
reaction="Check out the branch you mean to publish, or pass `--branch <checked-out "
|
||||||
"branch>`, then retry once",
|
"branch>`, then retry once",
|
||||||
),
|
),
|
||||||
|
cli_contract.Failure(
|
||||||
|
cause="No `--no-push`, and the remote is not configured or cannot be reached; "
|
||||||
|
"nothing was committed",
|
||||||
|
reaction="Show the message to the user and ask whether to commit locally with "
|
||||||
|
"`--no-push`. Never push by hand - the next `publish` that reaches the remote "
|
||||||
|
"sends that commit",
|
||||||
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="`--yes`/`-y` was passed - the flag does not exist and fails with an "
|
cause="`--yes`/`-y` was passed - the flag does not exist and fails with an "
|
||||||
"explicit error",
|
"explicit error",
|
||||||
@@ -1292,6 +1306,9 @@ def publish_command(
|
|||||||
# a deliberate local-only commit.
|
# a deliberate local-only commit.
|
||||||
if push:
|
if push:
|
||||||
outcome = reconcile(remote, branch, confirm_rebase)
|
outcome = reconcile(remote, branch, confirm_rebase)
|
||||||
|
stop = publish_stop_message(outcome, remote, branch)
|
||||||
|
if stop:
|
||||||
|
fail(stop)
|
||||||
summary = apply_reconcile(outcome, remote, branch, "publish")
|
summary = apply_reconcile(outcome, remote, branch, "publish")
|
||||||
if summary:
|
if summary:
|
||||||
typer.echo(summary)
|
typer.echo(summary)
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ from chemenu.commands.git_publish import (
|
|||||||
format_changes,
|
format_changes,
|
||||||
group_of,
|
group_of,
|
||||||
is_generated,
|
is_generated,
|
||||||
parse_porcelain_entries,
|
|
||||||
parse_porcelain_z,
|
|
||||||
publish_command,
|
publish_command,
|
||||||
reconcile,
|
reconcile,
|
||||||
remote_lacks_branch,
|
remote_lacks_branch,
|
||||||
@@ -50,21 +48,6 @@ def test_default_threshold_matches_farzas_rule():
|
|||||||
assert DEFAULT_MASS_UPDATE_THRESHOLD == 10
|
assert DEFAULT_MASS_UPDATE_THRESHOLD == 10
|
||||||
|
|
||||||
|
|
||||||
def test_porcelain_parsing_handles_paths_with_spaces():
|
|
||||||
stdout = " M kb/concepts/Hybrid Search.md\0?? kb/Lint Report 2026-08-13.md\0"
|
|
||||||
assert parse_porcelain_z(stdout) == [
|
|
||||||
"kb/concepts/Hybrid Search.md",
|
|
||||||
"kb/Lint Report 2026-08-13.md",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_porcelain_parsing_reports_the_new_path_of_a_rename():
|
|
||||||
"""Rename entries carry the original path in a second NUL field; only the
|
|
||||||
new path is what actually gets committed."""
|
|
||||||
stdout = "R kb/concepts/New Name.md\0kb/concepts/Old Name.md\0 M AGENTS.md\0"
|
|
||||||
assert parse_porcelain_z(stdout) == ["kb/concepts/New Name.md", "AGENTS.md"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_branch_mismatch_names_both_branches_and_the_fix():
|
def test_branch_mismatch_names_both_branches_and_the_fix():
|
||||||
"""Regression guard: `git push origin main` from a feature branch pushes the
|
"""Regression guard: `git push origin main` from a feature branch pushes the
|
||||||
ref named `main` - an unrelated, usually unchanged commit - and exits 0, so
|
ref named `main` - an unrelated, usually unchanged commit - and exits 0, so
|
||||||
@@ -81,10 +64,6 @@ def test_branch_mismatch_handles_detached_head():
|
|||||||
assert "--branch None" not in message
|
assert "--branch None" not in message
|
||||||
|
|
||||||
|
|
||||||
def test_porcelain_parsing_of_empty_status_is_empty():
|
|
||||||
assert parse_porcelain_z("") == []
|
|
||||||
|
|
||||||
|
|
||||||
def _msg(changed, threshold=10, token="tok123456789", stale=None):
|
def _msg(changed, threshold=10, token="tok123456789", stale=None):
|
||||||
"""`changed` may be paths (convenience) or FileChange records."""
|
"""`changed` may be paths (convenience) or FileChange records."""
|
||||||
records = [fc(c) if isinstance(c, str) else c for c in changed]
|
records = [fc(c) if isinstance(c, str) else c for c in changed]
|
||||||
@@ -509,20 +488,11 @@ def test_the_clearance_request_emits_a_matchable_token(repo):
|
|||||||
|
|
||||||
# --- grouping and review hints ---
|
# --- grouping and review hints ---
|
||||||
|
|
||||||
def test_status_words_come_from_the_porcelain_code():
|
def test_status_words_come_from_the_raw_status_letter():
|
||||||
assert describe_status("??") == "added"
|
assert describe_status("A") == "added"
|
||||||
assert describe_status("A ") == "added"
|
assert describe_status("D") == "deleted"
|
||||||
assert describe_status(" D") == "deleted"
|
assert describe_status("M") == "modified"
|
||||||
assert describe_status("D ") == "deleted"
|
assert describe_status("T") == "modified"
|
||||||
assert describe_status("R ") == "renamed"
|
|
||||||
assert describe_status(" M") == "modified"
|
|
||||||
|
|
||||||
|
|
||||||
def test_porcelain_entries_keep_the_status_alongside_the_path():
|
|
||||||
stdout = " M kb/a.md\0?? kb/b.md\0 D kb/c.md\0"
|
|
||||||
assert parse_porcelain_entries(stdout) == [
|
|
||||||
(" M", "kb/a.md"), ("??", "kb/b.md"), (" D", "kb/c.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_generated_files_are_recognised_wherever_they_sit():
|
def test_generated_files_are_recognised_wherever_they_sit():
|
||||||
@@ -1056,10 +1026,8 @@ def test_rebase_review_gate_emits_matchable_telemetry(repo):
|
|||||||
|
|
||||||
|
|
||||||
def test_numstat_survives_a_non_ascii_filename(repo):
|
def test_numstat_survives_a_non_ascii_filename(repo):
|
||||||
"""`git status --porcelain -z` emits raw paths, but `git diff --numstat`
|
"""`git diff --numstat -z` and `--raw -z` emit raw paths, which is what lets the churn of
|
||||||
quotes non-ASCII ones ("ausw\\303\\274rfeln"). When the two disagree the
|
a path with a non-ASCII byte be matched to its status: a rewrite must not read as a pure
|
||||||
numstat lookup misses and the file falls through to the untracked path,
|
|
||||||
which reports every line as an addition - a rewrite shown as a pure
|
|
||||||
insertion, hiding the removals a reviewer most needs to see."""
|
insertion, hiding the removals a reviewer most needs to see."""
|
||||||
name = "kb/Wörterbuch.md"
|
name = "kb/Wörterbuch.md"
|
||||||
(repo / name).write_text("eins\nzwei\ndrei\n", encoding="utf-8")
|
(repo / name).write_text("eins\nzwei\ndrei\n", encoding="utf-8")
|
||||||
@@ -1073,6 +1041,246 @@ def test_numstat_survives_a_non_ascii_filename(repo):
|
|||||||
assert (change.added, change.removed) == (1, 2)
|
assert (change.added, change.removed) == (1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the gate lists the staged state, from a scratch index --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _index_bytes(root):
|
||||||
|
path = root / _git(root, "rev-parse", "--git-path", "index").stdout.strip()
|
||||||
|
return path.read_bytes() if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _status(root):
|
||||||
|
return _git(root, "status", "--porcelain", "-uall").stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_a_deletion_and_restore_the_file(repo):
|
||||||
|
"""The state a `git rm -r raw` followed by `git restore --source=HEAD -- raw/CONTRACT.md`
|
||||||
|
leaves: one path staged as deleted *and* present in the working tree."""
|
||||||
|
(repo / "raw").mkdir()
|
||||||
|
(repo / "raw/CONTRACT.md").write_text("c\n", encoding="utf-8")
|
||||||
|
(repo / "raw/a.md").write_text("x\n", encoding="utf-8")
|
||||||
|
_git(repo, "add", "-A")
|
||||||
|
_git(repo, "commit", "-m", "raw")
|
||||||
|
_git(repo, "rm", "-rq", "raw")
|
||||||
|
_git(repo, "restore", "--source=HEAD", "--", "raw/CONTRACT.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gate_counts_a_path_once_when_it_is_staged_deleted_and_back_in_the_tree(repo):
|
||||||
|
_stage_a_deletion_and_restore_the_file(repo)
|
||||||
|
# The premise: status really does list raw/CONTRACT.md twice.
|
||||||
|
assert _status(repo).count("raw/CONTRACT.md") == 2
|
||||||
|
|
||||||
|
changes = collect_changes([])
|
||||||
|
|
||||||
|
assert [(c.path, c.status) for c in changes] == [("raw/a.md", "deleted")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_listed_paths_are_the_paths_the_commit_holds(repo):
|
||||||
|
_stage_a_deletion_and_restore_the_file(repo)
|
||||||
|
(repo / "README.md").write_text("init\nmore\n", encoding="utf-8")
|
||||||
|
(repo / "kb/new.md").write_text("new\n", encoding="utf-8")
|
||||||
|
(repo / "kb/moved.md").write_text("moved\n", encoding="utf-8")
|
||||||
|
_git(repo, "add", "-A")
|
||||||
|
_git(repo, "commit", "-m", "moved")
|
||||||
|
(repo / "kb/moved.md").rename(repo / "kb/renamed.md")
|
||||||
|
|
||||||
|
listed = sorted(c.path for c in collect_changes([]))
|
||||||
|
_publish(message="check the list")
|
||||||
|
|
||||||
|
committed = _git(repo, "show", "--name-only", "--no-renames", "--format=", "HEAD").stdout
|
||||||
|
assert listed == sorted(committed.split())
|
||||||
|
assert "kb/moved.md" in listed and "kb/renamed.md" in listed
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rename_is_listed_as_its_old_path_deleted_and_its_new_path_added(repo):
|
||||||
|
(repo / "kb/old.md").write_text("content\n", encoding="utf-8")
|
||||||
|
_git(repo, "add", "-A")
|
||||||
|
_git(repo, "commit", "-m", "old")
|
||||||
|
(repo / "kb/old.md").rename(repo / "kb/new.md")
|
||||||
|
_git(repo, "add", "-A") # staged, so git status would report an R entry
|
||||||
|
|
||||||
|
by_path = {c.path: c.status for c in collect_changes([])}
|
||||||
|
|
||||||
|
assert by_path == {"kb/old.md": "deleted", "kb/new.md": "added"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_digest_is_the_blob_id_of_what_gets_committed(repo):
|
||||||
|
(repo / "kb/page.md").write_text("page\n", encoding="utf-8")
|
||||||
|
digest = {c.path: c.digest for c in collect_changes([])}["kb/page.md"]
|
||||||
|
_publish(message="page")
|
||||||
|
assert digest == _git(repo, "rev-parse", "HEAD:kb/page.md").stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_path_scope_lists_only_that_subtree_even_when_more_is_staged(repo):
|
||||||
|
(repo / "kb/in.md").write_text("in\n", encoding="utf-8")
|
||||||
|
(repo / "other.md").write_text("out\n", encoding="utf-8")
|
||||||
|
_git(repo, "add", "other.md")
|
||||||
|
|
||||||
|
assert [c.path for c in collect_changes(["kb"])] == ["kb/in.md"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_publish_leaves_index_and_status_byte_identical(repo):
|
||||||
|
_stage_a_deletion_and_restore_the_file(repo)
|
||||||
|
_write_files(repo, 10)
|
||||||
|
index_before, status_before = _index_bytes(repo), _status(repo)
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="big change")
|
||||||
|
|
||||||
|
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
|
||||||
|
assert _index_bytes(repo) == index_before
|
||||||
|
assert _status(repo) == status_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_first_publish_leaves_a_missing_index_missing(fresh_instance):
|
||||||
|
"""No index file at all yet: the scratch index starts empty and the real one must not
|
||||||
|
appear as a side effect."""
|
||||||
|
_write_files(fresh_instance, 10)
|
||||||
|
assert _index_bytes(fresh_instance) is None
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="first")
|
||||||
|
|
||||||
|
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
|
||||||
|
assert _index_bytes(fresh_instance) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failing_computation_leaves_the_index_alone_and_removes_its_scratch_copy(
|
||||||
|
repo, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
scratch_root = tmp_path / "scratch"
|
||||||
|
scratch_root.mkdir()
|
||||||
|
monkeypatch.setattr("tempfile.tempdir", str(scratch_root))
|
||||||
|
_write_files(repo, 2)
|
||||||
|
index_before = _index_bytes(repo)
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
collect_changes(["no/such/path"]) # `git add` refuses a pathspec that matches nothing
|
||||||
|
|
||||||
|
assert _index_bytes(repo) == index_before
|
||||||
|
assert list(scratch_root.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- a missing or unreachable remote: three messages, and publish stops early --
|
||||||
|
|
||||||
|
|
||||||
|
def _break_the_remote(repo):
|
||||||
|
_git(repo, "remote", "set-url", "origin", str(repo.parent / "nope.git"))
|
||||||
|
|
||||||
|
|
||||||
|
def _outcomes(repo, mutate):
|
||||||
|
mutate(repo)
|
||||||
|
return reconcile("origin", "main", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_three_ways_a_fetch_fails_get_three_statuses_and_three_messages(repo):
|
||||||
|
no_remote = _outcomes(repo, lambda r: _git(r, "remote", "remove", "origin"))
|
||||||
|
_git(repo, "remote", "add", "origin", str(_remote_for(repo)))
|
||||||
|
lacks = reconcile("origin", "never-pushed", None)
|
||||||
|
_break_the_remote(repo)
|
||||||
|
unreachable = reconcile("origin", "main", None)
|
||||||
|
|
||||||
|
assert (no_remote.status, lacks.status, unreachable.status) == (
|
||||||
|
"no-remote", "remote-lacks-branch", "unreachable",
|
||||||
|
)
|
||||||
|
messages = {
|
||||||
|
git_publish._reconcile_summary(no_remote, "origin", "main"),
|
||||||
|
git_publish._reconcile_summary(lacks, "origin", "never-pushed"),
|
||||||
|
git_publish._reconcile_summary(unreachable, "origin", "main"),
|
||||||
|
}
|
||||||
|
assert len(messages) == 3
|
||||||
|
assert str(repo.parent / "nope.git") in git_publish._reconcile_summary(
|
||||||
|
unreachable, "origin", "main"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_ends_with_exit_0_in_all_three_cases(repo, capsys):
|
||||||
|
_sync(branch="never-pushed") # the remote answers and lacks the branch
|
||||||
|
_break_the_remote(repo)
|
||||||
|
_sync() # unreachable
|
||||||
|
_git(repo, "remote", "remove", "origin")
|
||||||
|
_sync() # no remote at all
|
||||||
|
assert capsys.readouterr().out.count("\n") >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_without_no_push_stops_before_the_commit_on_an_unreachable_remote(repo):
|
||||||
|
_break_the_remote(repo)
|
||||||
|
(repo / "kb/page.md").write_text("page\n", encoding="utf-8")
|
||||||
|
head, index, status = (_git(repo, "rev-parse", "HEAD").stdout, _index_bytes(repo), _status(repo))
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="offline")
|
||||||
|
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
assert _git(repo, "rev-parse", "HEAD").stdout == head
|
||||||
|
assert _index_bytes(repo) == index and _status(repo) == status
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_unreachable_stop_names_the_remote_its_url_and_no_push(repo, capsys):
|
||||||
|
_break_the_remote(repo)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
_publish(message="offline")
|
||||||
|
err = capsys.readouterr()
|
||||||
|
text = err.out + err.err
|
||||||
|
assert "origin" in text and str(repo.parent / "nope.git") in text and "--no-push" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stop_holds_on_a_clean_tree_too(repo):
|
||||||
|
_break_the_remote(repo)
|
||||||
|
assert _status(repo) == ""
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="nothing to do")
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_without_a_remote_stops_before_the_commit_and_points_at_no_push(repo, capsys):
|
||||||
|
_git(repo, "remote", "remove", "origin")
|
||||||
|
(repo / "kb/page.md").write_text("page\n", encoding="utf-8")
|
||||||
|
head, index, status = (_git(repo, "rev-parse", "HEAD").stdout, _index_bytes(repo), _status(repo))
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="local")
|
||||||
|
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
assert _git(repo, "rev-parse", "HEAD").stdout == head
|
||||||
|
assert _index_bytes(repo) == index and _status(repo) == status
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "--no-push" in captured.out + captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stop_holds_without_a_remote_on_a_clean_tree_too(repo):
|
||||||
|
_git(repo, "remote", "remove", "origin")
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="nothing to do")
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_push_still_commits_locally_when_the_remote_is_unreachable(repo):
|
||||||
|
_break_the_remote(repo)
|
||||||
|
(repo / "kb/page.md").write_text("page\n", encoding="utf-8")
|
||||||
|
_publish(message="offline", push=False)
|
||||||
|
assert "kb/page.md" in _git(repo, "show", "--name-only", "HEAD").stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_push_still_commits_locally_when_there_is_no_remote(repo):
|
||||||
|
_git(repo, "remote", "remove", "origin")
|
||||||
|
(repo / "kb/page.md").write_text("page\n", encoding="utf-8")
|
||||||
|
_publish(message="local", push=False)
|
||||||
|
assert "kb/page.md" in _git(repo, "show", "--name-only", "HEAD").stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_commit_left_behind_while_offline_goes_out_with_the_next_reachable_publish(repo):
|
||||||
|
good_url = git_publish.remote_url("origin")
|
||||||
|
_break_the_remote(repo)
|
||||||
|
(repo / "kb/offline.md").write_text("offline\n", encoding="utf-8")
|
||||||
|
_publish(message="offline", push=False)
|
||||||
|
_git(repo, "remote", "set-url", "origin", good_url)
|
||||||
|
|
||||||
|
_publish(message="back online")
|
||||||
|
|
||||||
|
assert "offline" in _remote_log(_remote_for(repo))
|
||||||
|
|
||||||
|
|
||||||
# --- Publish-Remote Gate -----------------------------------------------------
|
# --- Publish-Remote Gate -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user