diff --git a/docs/handbook.html b/docs/handbook.html
index 5eddf825..11b4688f 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -1314,7 +1314,10 @@
Advancing a leg
is a root it cannot bootstrap); a dirty root; an untracked or ignored workflow file it would
rewrite; a workflow file or the leg’s own pin that is not UTF-8, naming each file and its
first bad byte before anything is written (a file git does not track needs the re-save and no
- commit); a root standing on its own tracking branch (the next step is a pull request, never a
+ commit); a workflow file, the .github/workflows/ directory or the leg’s pin
+ that this user cannot open, naming the path and the system’s reason, with the same ids
+ and the remedy of making it readable and running the command again; a root standing on its
+ own tracking branch (the next step is a pull request, never a
push to the default branch); and a validator that goes red — rolling the pin, the
workflow files, the leg’s checkout and the index back first, so a refusal leaves the
tree exactly as it was found.
@@ -1790,8 +1793,9 @@ For AI assistants
refusing an untracked or ignored one it would rewrite, added (#207); the assistant rules
saying that some scaffold refusals name a value to change, added (#288);
bump-leg.py refusing a workflow file or the leg’s pin that is not UTF-8, by
- name, added (#289) — a page cannot name the commit that contains it, and the blob below
- is the fact a test checks.
+ name, added (#289); bump-leg.py refusing a workflow file, the workflows directory
+ or the leg’s pin that this user cannot open, by name, added (#383) — a page cannot
+ name the commit that contains it, and the blob below is the fact a test checks.
README.md blob e8da93817b0c1a195300800373a2895c0e0280e5. Every command, flag, exit code
and file name on this page is quoted from that README and from AGENTS.md.
diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py
index d00af077..0ab27b7d 100755
--- a/scripts/bump-leg.py
+++ b/scripts/bump-leg.py
@@ -59,6 +59,15 @@
file git does not track is refused the same way and marked `(untracked or
ignored)`: it needs the re-save and no commit, where a tracked one is
committed after it.
+ * the same two ids for a file or a directory this user cannot OPEN (#383):
+ a workflow file, `.github/workflows/` itself or the leg's pin that the
+ operating system refuses (`Permission denied`) or fails to read, naming
+ the path and the system's own reason. The remedy is to make it readable
+ by this user (`chmod u+r`, or fix who owns it) and run the same command
+ again, not a re-save. Refused before anything is written, under
+ `--dry-run` as well, whether or not the file names the leg, and a line
+ for a file git does not track is marked as it is for a file that is not
+ UTF-8.
* a validator that goes red after the rewrite. Every byte is rolled back
first — the pin file, the workflow files, the leg's checkout and the index
— so a refusal leaves the tree exactly as it was found.
@@ -84,12 +93,15 @@
from __future__ import annotations
import argparse
+import errno
import os
import re
+import stat
import subprocess
import sys
from functools import partial
from pathlib import Path
+from typing import Callable
SHAPE_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SHAPE_ROOT / "scripts"))
@@ -316,9 +328,106 @@ def _not_utf8(root: Path, path: Path, exc: UnicodeDecodeError) -> str:
f"{exc.start} ({exc.reason})")
+#: The `OSError`s that `Path.is_file()` and `Path.is_dir()` answered False to
+#: on Python 3.9 to 3.13 (pathlib's `_ignore_error`), because each means there
+#: is nothing at the name to open: no such file, a part of the path that is
+#: not a directory, a bad descriptor, a symlink loop, and Windows' "not
+#: ready", "invalid name" and "cannot resolve". Every other one was raised.
+_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP)
+_ABSENT_WINERRORS = (21, 123, 1921)
+
+
+def _is_a(path: Path, kind: Callable[[int], bool]) -> bool:
+ """Whether `path` is there and of `kind` (`stat.S_ISREG`, `stat.S_ISDIR`),
+ raising the `OSError` of a `stat` the operating system REFUSED (#383).
+
+ NOT `Path.is_file()` OR `Path.is_dir()`, whose answer depends on the
+ Python. Up to 3.13 they raised a refused `stat` (`PermissionError`) and
+ answered False only when nothing was there; from 3.14 they call
+ `os.path.isfile` and `isdir`, which answer False to ANY `OSError`. On 3.14
+ the pin inside a `contracts/` this user cannot search was "missing"
+ (`bump-leg-pin-missing`, whose remedy restores a file that is there), and
+ every file in a `.github/workflows/` this user can list but not search
+ was skipped as no file at all: the bump went ahead, `validate-pins.py`
+ found the workflow "pinned but absent", and the run was rolled back as
+ `bump-leg-validators-red`. Asked here, every Python answers as 3.9 to
+ 3.13 did, and a refused `stat` is a refusal that names the path.
+ """
+ try:
+ mode = path.stat().st_mode
+ except OSError as exc:
+ if (exc.errno in _ABSENT_ERRNOS
+ or getattr(exc, "winerror", None) in _ABSENT_WINERRORS):
+ return False
+ raise
+ except ValueError:
+ # a name the operating system cannot take at all (a NUL in it)
+ return False
+ return kind(mode)
+
+
+def _os_reason(exc: OSError) -> str:
+ """The operating system's own words for `exc` (`Permission denied`), or
+ what Python made of it when there are none (an `OSError` raised with no
+ errno carries no `strerror`)."""
+ return exc.strerror or str(exc)
+
+
+def _cannot_open(root: Path, path: Path, exc: OSError, *,
+ directory: bool = False) -> str:
+ """`: cannot be read: ` (`cannot be listed`
+ for a directory, named with a trailing `/`), in the shape `_not_utf8`
+ gives a file this tool cannot decode, so a line says what failed on which
+ file and the two causes read alike (#383)."""
+ shown = path.relative_to(root).as_posix() + ("/" if directory else "")
+ return (f"{shown}: cannot be {'listed' if directory else 'read'}: "
+ f"{_os_reason(exc)}")
+
+
+def _cannot_open_advice(errors: list[OSError]) -> str:
+ """What to do about a file or a directory the operating system refused,
+ for each cause present in `errors`, ending in a space so it joins the
+ sentence after it (#383).
+
+ NOT the re-save advice a file that is not UTF-8 is given: its bytes were
+ never the trouble, and no edit to them makes a file readable. A refusal
+ of permission (`PermissionError`, which is `EACCES` and `EPERM`) is put
+ right by the mode or the owner; any other `OSError` (a file removed or
+ replaced while this ran, an I/O error, a path that stopped being a file)
+ is not a permission and is not told to `chmod`.
+ """
+ advice = ""
+ if any(isinstance(error, PermissionError) for error in errors):
+ advice += ("Make what this user may not open readable by this "
+ "user: `chmod u+r` on a file, `chmod u+rx` on a directory "
+ "(and on every directory above it inside the root), or "
+ "fix who owns it. ")
+ if any(not isinstance(error, PermissionError) for error in errors):
+ advice += ("Where the operating system gave another reason, put a "
+ "regular file this user can open back in its place, or "
+ "take away one nobody wants, and make sure nothing is "
+ "changing the directory while this runs. ")
+ return advice
+
+
+def _pin_unopened_refusal(root: Path, pin_path: Path,
+ exc: OSError) -> Refusal:
+ """`bump-leg-pin-unreadable` for a pin the operating system refused, the
+ id a pin that is not UTF-8 has, with this cause's own remedy (#383)."""
+ return Refusal(
+ "bump-leg-pin-unreadable",
+ _cannot_open(root, pin_path, exc),
+ "Remediation: this tool must read the leg's pin to rewrite "
+ "`commit:` and `digests.tree_sha256` in it, and "
+ "`scripts/validate-pins.py` reads it too. "
+ + _cannot_open_advice([exc])
+ + "Then run the same command again. Nothing in the working "
+ "tree, the index or the leg's checkout was changed.")
+
+
def _leg_pin_text(root: Path, pin_path: Path) -> str:
"""The leg's `contracts/-pin.yaml` as text, or a refusal naming it
- when it is not UTF-8 (#289).
+ when it is not UTF-8 (#289) or this user cannot open it (#383).
Read here rather than through `load_yaml` because `rewrite_pin` is a LINE
REWRITE of the file's own text, which a parsed mapping cannot be. A
@@ -329,9 +438,18 @@ def _leg_pin_text(root: Path, pin_path: Path) -> str:
refusal promises only what is true: the working tree, the index and the
leg's checkout are as they were. (The leg's object store was fetched into
already, which `--dry-run` says it does.)
+
+ THE OPERATING SYSTEM'S REFUSAL IS THE SAME REFUSAL, BY ANOTHER CAUSE
+ (#383). A pin this user may not open raised `PermissionError`, which is
+ an `OSError` and no `Refusal`: the same traceback, exit 1, from this same
+ read, with `--dry-run` no different. The id is the one a pin that is not
+ UTF-8 has, so one id says "this tool cannot read the leg's pin" whatever
+ the reason, with the cause's own detail and its own remedy.
"""
try:
return pin_path.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise _pin_unopened_refusal(root, pin_path, exc) from exc
except UnicodeDecodeError as exc:
raise Refusal(
"bump-leg-pin-unreadable",
@@ -347,10 +465,47 @@ def _leg_pin_text(root: Path, pin_path: Path) -> str:
"index or the leg's checkout was changed.") from exc
+def _workflow_children(root: Path, workflows: Path) -> list[Path]:
+ """The entries of `workflows`, sorted — or a refusal naming the directory
+ when this user cannot list it (#383).
+
+ `plan_workflows` lists the directory for the names git is compared with,
+ and `_workflow_texts` lists it again to read the files, so both go through
+ here. A directory this user may not list raised `PermissionError` out of
+ `iterdir`, which `main()` does not catch: a traceback, exit 1, naming
+ Python's error and no refusal. It reads as CLEAN to git
+ (`git status --porcelain` prints its `Permission denied` on stderr, writes
+ nothing to stdout and exits 0), so `_ensure_root_clean` lets the run
+ through to here. The listing is before anything is written, under
+ `--dry-run` as well, so the refusal promises only what is true: the
+ working tree, the index and the leg's checkout are as they were.
+ """
+ try:
+ return sorted(workflows.iterdir())
+ except OSError as exc:
+ raise _workflows_dir_refusal(root, workflows, exc) from exc
+
+
+def _workflows_dir_refusal(root: Path, workflows: Path,
+ exc: OSError) -> Refusal:
+ """`bump-leg-workflow-unreadable` for the DIRECTORY, named as one."""
+ return Refusal(
+ "bump-leg-workflow-unreadable",
+ _cannot_open(root, workflows, exc, directory=True),
+ "Remediation: this tool reads every workflow file in the directory "
+ "named above, whether or not it names this leg, and "
+ "`scripts/validate-pins.py` reads them too, so a directory it cannot "
+ "list is one it cannot bump. "
+ + _cannot_open_advice([exc])
+ + "Then run the same command again. Nothing in the working tree, "
+ "the index or the leg's checkout was changed.")
+
+
def _workflow_texts(root: Path, workflows: Path, tracked: set[str],
listed: set[str]) -> list[tuple[Path, str]]:
"""Every `.yml` and `.yaml` file in `workflows`, read as UTF-8 — or ONE
- refusal naming each that is not (#289).
+ refusal naming each that is not (#289) or that this user cannot open
+ (#383).
EVERY FILE IS READ, whether or not it names the leg, so a file that is not
UTF-8 is REFUSED BY NAME and never skipped, never read lossily.
@@ -375,43 +530,84 @@ def _workflow_texts(root: Path, workflows: Path, tracked: set[str],
It is asked only of a file already refused and reads no file (`tracked` is
in hand, `_same_file` is a `stat`), so every read still comes before
anything is written.
+
+ THE OPERATING SYSTEM'S REFUSAL IS THE SAME REFUSAL, BY ANOTHER CAUSE
+ (#383). A file this user may not open raised `PermissionError`, an
+ `OSError` that `main()` does not catch either: the same traceback, exit 1,
+ `--dry-run` included. Any `OSError` of the read or of the `stat` before
+ it (`_is_a`, which a directory this user can list but not search
+ refuses) is named the same way, as a line of its own with the system's
+ reason, whether or not the file names the leg; a file that is not UTF-8
+ and one that cannot be opened meet in ONE refusal, each group with its own
+ header and its own advice, because the re-save the first needs does
+ nothing for the second. Each line says whether git tracks the file as
+ before, and a file that was only unreadable is never told to be
+ committed: restoring a mode changes no content, so there is nothing to
+ commit for it, tracked or not.
"""
texts: list[tuple[Path, str]] = []
unreadable: list[tuple[str, bool]] = []
- for path in sorted(workflows.iterdir()):
- if path.suffix not in (".yml", ".yaml") or not path.is_file():
- continue
+ unopened: list[tuple[str, bool, OSError]] = []
+ for path in _workflow_children(root, workflows):
try:
+ if (path.suffix not in (".yml", ".yaml")
+ or not _is_a(path, stat.S_ISREG)):
+ continue
texts.append((path, path.read_text(encoding="utf-8")))
except UnicodeDecodeError as exc:
held = _tracked_spelling(root, path, tracked, listed) is not None
unreadable.append((_not_utf8(root, path, exc), held))
- if unreadable:
+ except OSError as exc:
+ held = _tracked_spelling(root, path, tracked, listed) is not None
+ unopened.append((_cannot_open(root, path, exc), held, exc))
+ if unreadable or unopened:
+ marked = " (untracked or ignored)"
+ detail: list[str] = []
advice = ""
- if any(held for _, held in unreadable):
- advice += "Commit each file git tracks. "
- if not all(held for _, held in unreadable):
- advice += ("A file marked (untracked or ignored) needs no "
- "commit, only the re-save: committing it would put "
- "it on this branch, so either leave it uncommitted "
- "or move it out of .github/workflows/ until the bump "
- "has landed. ")
+ if unreadable:
+ detail.append(
+ f"{len(unreadable)} workflow file(s) are not UTF-8:\n"
+ + "\n".join(f" {line}" + ("" if held else marked)
+ for line, held in unreadable))
+ advice += (
+ "this tool rewrites each workflow file as UTF-8 "
+ "text and reads none lossily, because the rewritten text is "
+ "written back and would carry U+FFFD where your byte was; "
+ "`scripts/validate-pins.py` reads every workflow file on "
+ "disk as UTF-8 too, so skipping one would only move the "
+ "failure there. In each line above, the byte is counted from "
+ "0 at the start of the file and is the first that is not "
+ "UTF-8: re-save every file named as UTF-8 (a UTF-16 file "
+ "with a byte-order mark needs converting, not editing), "
+ "whether or not it names this leg. ")
+ if any(held for _, held in unreadable):
+ advice += "Commit each file git tracks. "
+ if not all(held for _, held in unreadable):
+ advice += ("A file marked (untracked or ignored) needs no "
+ "commit, only the re-save: committing it would "
+ "put it on this branch, so either leave it "
+ "uncommitted or move it out of "
+ ".github/workflows/ until the bump has landed. ")
+ if unopened:
+ detail.append(
+ f"{len(unopened)} workflow file(s) cannot be opened:\n"
+ + "\n".join(f" {line}" + ("" if held else marked)
+ for line, held, _ in unopened))
+ advice += (
+ "Each file named as cannot be opened is one this tool "
+ "cannot rewrite, and `scripts/validate-pins.py` reads every "
+ "workflow file on disk too, whether or not it names this "
+ "leg, so skipping one would only move the failure there. "
+ + _cannot_open_advice([error for _, _, error in unopened])
+ + "No commit is needed for a file that could not be opened: "
+ "made readable, it has no change to commit. ")
+ if not all(held for _, held, _ in unopened):
+ advice += ("A file marked (untracked or ignored) is not in "
+ "the repository: it can also be moved out of "
+ ".github/workflows/ until the bump has landed. ")
raise Refusal(
- "bump-leg-workflow-unreadable",
- f"{len(unreadable)} workflow file(s) are not UTF-8:\n"
- + "\n".join(f" {line}" + ("" if held
- else " (untracked or ignored)")
- for line, held in unreadable),
- "Remediation: this tool rewrites each workflow file as UTF-8 "
- "text and reads none lossily, because the rewritten text is "
- "written back and would carry U+FFFD where your byte was; "
- "`scripts/validate-pins.py` reads every workflow file on disk as "
- "UTF-8 too, so skipping one would only move the failure there. "
- "In each line above, the byte is counted from 0 at the start of "
- "the file and is the first that is not UTF-8: re-save every file "
- "named as UTF-8 (a UTF-16 file with a byte-order mark needs "
- "converting, not editing), whether or not it names this leg. "
- + advice
+ "bump-leg-workflow-unreadable", "\n".join(detail),
+ "Remediation: " + advice
+ "Then run the same command again. Nothing in the working "
"tree, the index or the leg's checkout was changed.")
return texts
@@ -509,14 +705,20 @@ def plan_workflows(root: Path, repository: str,
file through it, read through the disk's name and written through
git's, which the filesystem has already said are the same file.
- A file that is not UTF-8 is a refusal (`_workflow_texts`, #289).
+ A file that is not UTF-8, or that this user cannot open, is a refusal
+ (`_workflow_texts`, #289 and #383), and so is the directory itself when
+ this user cannot list it (`_workflow_children`, #383).
"""
plans: list[tuple[Path, str, int]] = []
workflows = root / ".github" / "workflows"
- if not workflows.is_dir():
- return plans
+ try:
+ if not _is_a(workflows, stat.S_ISDIR):
+ return plans
+ except OSError as exc:
+ # a directory above `workflows` this user cannot search (#383)
+ raise _workflows_dir_refusal(root, workflows, exc) from exc
tracked = _tracked_workflows(root, workflows)
- listed = {child.name for child in workflows.iterdir()}
+ listed = {child.name for child in _workflow_children(root, workflows)}
untracked: list[tuple[str, int]] = []
for path, text in _workflow_texts(root, workflows, tracked, listed):
moved = 0
@@ -870,12 +1072,19 @@ def _recorded_leg_commit(root: Path, path: str) -> str:
def _leg_pin_path(root: Path, pin_rel: str) -> Path:
- """The leg's `contracts/-pin.yaml`, refused if it does not exist.
+ """The leg's `contracts/-pin.yaml`, refused if it does not exist or
+ cannot be reached (#383).
Split from `cmd_bump` for #136.
"""
pin_path = root / pin_rel
- if not pin_path.is_file():
+ try:
+ present = _is_a(pin_path, stat.S_ISREG)
+ except OSError as exc:
+ # `contracts/` this user cannot search: the `stat` is refused, which
+ # is not an absent file (#383)
+ raise _pin_unopened_refusal(root, pin_path, exc) from exc
+ if not present:
raise Refusal(
"bump-leg-pin-missing",
f"{pin_rel} does not exist, so half of the invariant has nothing "
diff --git a/tests/test_bump_leg_unopenable.py b/tests/test_bump_leg_unopenable.py
new file mode 100644
index 00000000..eb49862f
--- /dev/null
+++ b/tests/test_bump_leg_unopenable.py
@@ -0,0 +1,678 @@
+# SPDX-License-Identifier: Apache-2.0
+"""`scripts/bump-leg.py`: a file or a directory it must READ and this user
+cannot OPEN is refused by name, before anything moves (#383).
+
+`tests/test_bump_leg_unreadable.py` (#289) covers a file whose BYTES are not
+UTF-8. This is the other way a read fails: the operating system refuses it.
+The tool reads `contracts/-pin.yaml` and every `.yml` and `.yaml` file
+under `.github/workflows/`, and lists that directory, with no handler, and
+`main()` catches `Refusal` and `CommandFailed` only. A `PermissionError` (an
+`OSError`) ended the run in a traceback, exit 1, naming Python's error and not
+a refusal, under `--dry-run` as well, because every one of those reads comes
+before its early return. Now each is the refusal of its own kind, exit 2, with
+the ids #289 introduced, so one id says "this tool cannot read this file"
+whichever the reason: `bump-leg-workflow-unreadable` for a workflow file or the
+directory, `bump-leg-pin-unreadable` for the pin. Each names the path and the
+operating system's reason, and the remedy is to make it readable by this user
+and run the same command again, never the re-save a file that is not UTF-8
+is given.
+
+HOW THE TREE COMES TO BE ONE THE TOOL GETS THIS FAR WITH. With git's default
+configuration `chmod 000` on a tracked file changes its ctime, git must re-read
+it to compare, cannot, and reports ` M`: the run is then refused
+`bump-leg-root-dirty`, which is another refusal and is not under test here.
+The traceback is what the operator got when git read the tree as clean, so
+each mode-000 test says `git update-index --assume-unchanged` of every tracked
+file it makes unreadable, which needs no sleep and no second status, asserts
+that `git status --porcelain` is empty WHILE the file is unreadable, and clears
+the flag when the mode is restored (a flag left on is a `git add` that adds
+nothing). The mode is restored in a `finally`, so the temporary directory can
+be removed.
+
+THE TESTS THAT NEED A MODE SKIP ON WINDOWS and when the suite runs as root,
+who reads anything. The tests that make the operating system fail for another
+reason (a file gone between the listing and the read, an I/O error) do it
+with a stand-in for the call, so they run everywhere, root included.
+
+A REFUSED `stat` IS ASKED OF THE OPERATING SYSTEM, NOT OF `Path.is_file()`.
+Up to Python 3.13 `is_file` and `is_dir` raised a refused `stat`; from 3.14
+they answer False to ANY `OSError`, and CI's 3.14 turned two mode tests here
+red at e2c78ee (an unsearchable `contracts/` read as a missing pin, and the
+files of a workflows directory listable but not searchable were skipped). The
+last section stands in for 3.14's answer with a refused `stat`, in-process, so
+a tool that leans on `is_file` raising fails on every Python, root included.
+
+EVERY MODE-000 TEST COMPARES THE WHOLE TREE, as
+`tests/test_bump_leg_rollback.py` does, from a snapshot taken BEFORE the mode
+is changed (the snapshot reads the files): the root's HEAD, the pin's and
+every workflow's bytes, the gitlink, the root's `git status`, an empty
+`git diff --cached`, and the leg's HEAD and branch. A refused bump is then run
+AGAIN with the mode restored, to show that the remedy the refusal gives is the
+whole of it.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import errno
+import os
+import stat
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from conftest import ORG, PROJECT, git
+# A fixture is used by being named in this module's namespace, which is what
+# the import of `bump_leg_module` is for.
+from test_bump_leg import ( # noqa: F401
+ bump, bump_leg_module, committed, ignore_locally, ignoring_case,
+ reference_both_legs, respelled, validate,
+)
+from test_bump_leg_rollback import assert_nothing_changed, snapshot
+from test_bump_leg_unreadable import (
+ LATIN1_COMMENT, MODES, WORKFLOWS, commit_bytes, prepared,
+)
+
+#: Where the advanced spec commit is pushed. Not another module's branch: the
+#: bare remotes are session-scoped, and a second, different commit pushed to
+#: a name already taken is refused as non-fast-forward.
+ADVANCE_BRANCH = "bump-leg-unopenable-advance"
+
+needs_a_mode = pytest.mark.skipif(
+ sys.platform == "win32" or (hasattr(os, "geteuid") and os.geteuid() == 0),
+ reason="a mode that refuses the owner is a POSIX mode, and root reads "
+ "through every one (tests/test_family_add_refusals.py)")
+
+#: A workflow that names no leg: no `@` of its own would ever move.
+NAMES_NO_LEG = b"on: [pull_request]\njobs: {}\n"
+
+
+@pytest.fixture(scope="module")
+def advanced(scaffolded, tmp_path_factory) -> str:
+ """A real new commit on the spec leg's bare remote; returns its sha."""
+ work = (tmp_path_factory.mktemp("spec-advance-unopenable")
+ / f"{PROJECT}-spec")
+ proc = subprocess.run(
+ ["git", "clone", "-q",
+ str(scaffolded["remotes"] / f"{PROJECT}-spec.git"), str(work)],
+ capture_output=True, text=True, check=False)
+ assert proc.returncode == 0, proc.stderr
+ (work / "UNOPENABLE-CONTRACT.md").write_text("# a contract gained\n")
+ git("add", "--", "UNOPENABLE-CONTRACT.md", cwd=work)
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm",
+ "Advance the spec leg for the unopenable-file tests", cwd=work)
+ git("push", "-q", "origin", f"HEAD:refs/heads/{ADVANCE_BRANCH}", cwd=work)
+ return git("rev-parse", "HEAD", cwd=work).stdout.strip()
+
+
+@contextlib.contextmanager
+def made_unopenable(project: Path, *relatives: str, mode: int = 0):
+ """Set `mode` on each of `relatives` (000 by default: no read, no search)
+ while git still reads the tree as clean, and put everything back.
+
+ Git first: `--assume-unchanged` on every file git tracks at or under each
+ path, so `git status` neither re-reads a file it cannot nor reports it
+ changed. The deepest path takes its mode first and the shallowest gives
+ it back first, so a file inside a directory made mode 000 is never
+ reached through that directory.
+ """
+ flagged: list[str] = []
+ for relative in relatives:
+ listing = git("ls-files", "-z", "--", relative, cwd=project).stdout
+ for name in filter(None, listing.split("\0")):
+ git("update-index", "--assume-unchanged", "--", name, cwd=project)
+ flagged.append(name)
+ paths = [project / relative for relative in relatives]
+ modes = {path: stat.S_IMODE(path.stat().st_mode) for path in paths}
+ try:
+ for path in sorted(paths, key=lambda item: len(item.parts),
+ reverse=True):
+ path.chmod(mode)
+ yield
+ finally:
+ for path in sorted(modes, key=lambda item: len(item.parts)):
+ path.chmod(modes[path])
+ for name in flagged:
+ git("update-index", "--no-assume-unchanged", "--", name,
+ cwd=project)
+
+
+def refused_while_unopenable(project: Path, scaffolded: dict, to: str,
+ code: str, *relatives: str, extra: tuple = (),
+ mode: int = 0) -> str:
+ """Make `relatives` unopenable, bump, and assert EVERYTHING the issue's
+ table asks of the row: git reads the tree as clean (so it is the read that
+ fails and not `bump-leg-root-dirty`), exit 2, the refusal's id, "Permission
+ denied", no traceback and no UTF-8 advice, the tree still clean while the
+ mode is on, and, once it is back, the whole tree as it was found.
+ Returns stderr for a test that asserts more."""
+ before = snapshot(project)
+ with made_unopenable(project, *relatives, mode=mode):
+ assert git("status", "--porcelain", cwd=project).stdout == "", (
+ "git must read the tree as clean, or the refusal under test is "
+ "`bump-leg-root-dirty`")
+ result = bump(project, scaffolded, to, *extra)
+ assert git("status", "--porcelain", cwd=project).stdout == "", (
+ "the tree is as clean after the run as before it")
+ output = result.stderr + result.stdout
+ assert result.returncode == 2, output
+ assert f"REFUSED {code}" in result.stderr, output
+ assert "Permission denied" in result.stderr, output
+ assert "Traceback" not in output, output
+ assert "PermissionError" not in output, output
+ assert "re-save" not in result.stderr, (
+ "the file's bytes are not the trouble")
+ assert "UTF-8" not in result.stderr, output
+ assert_nothing_changed(project, before)
+ return result.stderr
+
+
+def refused_again_fine(project: Path, scaffolded: dict, to: str, extra: tuple,
+ *moved: str) -> None:
+ """THE REMEDY IS THE WHOLE OF IT: with the mode back, the same command
+ runs, and a bump leaves a root the project's own validator passes, with
+ every file in `moved` in the lockstep commit."""
+ result = bump(project, scaffolded, to, *extra)
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert "Traceback" not in result.stderr
+ if not extra:
+ assert set(moved) <= committed(project)
+ assert validate(project).returncode == 0
+ assert git("status", "--porcelain", cwd=project).stdout == ""
+
+
+# --- a workflow file ---------------------------------------------------------
+
+@needs_a_mode
+@MODES
+def test_a_workflow_naming_the_leg_that_cannot_be_opened_is_refused(
+ project, scaffolded, advanced, extra):
+ """THE ISSUE'S ROW (a) AND (b): the file names the leg and is committed,
+ so the tracked-files rule is met and only the read stands in the way. On
+ the base it is `PermissionError` out of `plan_workflows`, exit 1, bump and
+ `--dry-run` alike. The refusal names the file by its path in the
+ repository and the system's reason, and says to make it readable and run
+ the same command again; the tracked file's line is not marked untracked."""
+ prepared(project)
+
+ stderr = refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-workflow-unreadable",
+ ".github/workflows/legs.yml", extra=extra)
+
+ assert (".github/workflows/legs.yml: cannot be read: Permission denied"
+ in stderr)
+ assert "1 workflow file(s) cannot be opened" in stderr
+ assert "chmod u+r" in stderr and "fix who owns it" in stderr
+ assert "Then run the same command again" in stderr
+ assert "(untracked or ignored)" not in stderr
+ assert "Commit each file" not in stderr
+ assert "commit it" not in stderr
+ refused_again_fine(project, scaffolded, advanced, extra,
+ ".github/workflows/legs.yml")
+
+
+@needs_a_mode
+@MODES
+@pytest.mark.parametrize("name", ["other.yml", "other.yaml"])
+def test_a_workflow_naming_no_leg_that_cannot_be_opened_is_refused(
+ project, scaffolded, advanced, extra, name):
+ """THE REFUSAL COVERS THE FILE WHETHER OR NOT IT NAMES THE LEG, as #289's
+ does: `plan_workflows` reads every file in the directory, and
+ `validate-pins.py` does too. This file holds no `@`, so none of its
+ own would move; it is read anyway. `.yaml` is read as well as `.yml`."""
+ prepared(project)
+ commit_bytes(project, WORKFLOWS / name, NAMES_NO_LEG)
+
+ refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-workflow-unreadable",
+ f".github/workflows/{name}", extra=extra)
+
+
+@needs_a_mode
+def test_every_workflow_that_cannot_be_opened_is_named_in_one_refusal(
+ project, scaffolded, advanced):
+ """Two unopenable files, one run: the operator fixes both rather than
+ meeting them one refusal at a time."""
+ prepared(project)
+ commit_bytes(project, WORKFLOWS / "other.yml", NAMES_NO_LEG)
+
+ stderr = refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-workflow-unreadable",
+ ".github/workflows/legs.yml", ".github/workflows/other.yml")
+
+ assert "2 workflow file(s) cannot be opened" in stderr
+ assert ".github/workflows/legs.yml: cannot be read" in stderr
+ assert ".github/workflows/other.yml: cannot be read" in stderr
+
+
+@needs_a_mode
+@MODES
+@pytest.mark.parametrize("how", ["untracked", "ignored"])
+def test_an_unopenable_draft_is_marked_and_not_told_to_be_committed(
+ project, scaffolded, advanced, extra, how):
+ """#352's TRACKED/UNTRACKED MARKING, KEPT FOR THIS CAUSE. A draft nobody
+ committed (or that the clone's own `info/exclude` ignores) names no leg
+ and cannot be opened. It is refused all the same, because
+ `validate-pins.py` reads every file on disk, but its line is marked, and
+ nothing in the remediation tells its operator to commit it: that puts a
+ draft workflow on the bump branch, which `bump-leg-workflow-untracked`
+ exists to prevent, and `git add` refuses an ignored one. Making it
+ readable, or moving it away, is enough, and with the mode back the bump
+ goes through and leaves the draft out of the lockstep commit."""
+ prepared(project)
+ relative = ".github/workflows/draft.yml"
+ draft = project / WORKFLOWS / "draft.yml"
+ draft.write_bytes(NAMES_NO_LEG)
+ if how == "ignored":
+ ignore_locally(project, relative)
+ assert git("check-ignore", "-q", relative, cwd=project,
+ check=False).returncode == 0, "the file is ignored"
+ assert git("ls-files", "--", relative, cwd=project).stdout == "", (
+ "git does not track it")
+
+ before = snapshot(project)
+ with made_unopenable(project, relative):
+ result = bump(project, scaffolded, advanced, *extra)
+ stderr = result.stderr
+ assert result.returncode == 2, stderr + result.stdout
+ assert "REFUSED bump-leg-workflow-unreadable" in stderr
+ line = next(line for line in stderr.splitlines() if relative in line)
+ assert line.rstrip().endswith("(untracked or ignored)"), line
+ assert "Permission denied" in line
+ assert "commit it" not in stderr and "Commit each file" not in stderr, (
+ "an operator who commits the draft puts it on the bump branch")
+ assert "is not in the repository" in stderr
+ assert "Traceback" not in stderr
+ assert_nothing_changed(project, before)
+
+ result = bump(project, scaffolded, advanced, *extra)
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert relative not in result.stdout, "the draft is nobody's business now"
+ assert git("ls-files", "--", relative, cwd=project).stdout == ""
+ if not extra:
+ assert relative not in committed(project)
+ assert validate(project).returncode == 0
+
+
+@needs_a_mode
+def test_a_non_utf8_file_and_an_unopenable_one_meet_in_one_refusal(
+ project, scaffolded, advanced):
+ """TWO CAUSES, ONE REFUSAL, EACH WITH ITS OWN ADVICE. A tracked Latin-1
+ file and a tracked file made unreadable: both are named under their own
+ header, the re-save advice and the commit advice are given for the first,
+ and `chmod` for the second, which is told nothing about re-saving. The
+ count line #352 wrote is unchanged for the first."""
+ prepared(project)
+ commit_bytes(project, WORKFLOWS / "latin1.yml", LATIN1_COMMENT)
+ commit_bytes(project, WORKFLOWS / "other.yml", NAMES_NO_LEG)
+ offset = (project / WORKFLOWS / "latin1.yml").read_bytes().index(b"\xe9")
+
+ before = snapshot(project)
+ with made_unopenable(project, ".github/workflows/other.yml"):
+ result = bump(project, scaffolded, advanced)
+ stderr = result.stderr
+ assert result.returncode == 2, stderr + result.stdout
+ assert "REFUSED bump-leg-workflow-unreadable" in stderr
+ assert "Traceback" not in stderr
+ assert "1 workflow file(s) are not UTF-8:" in stderr
+ assert (f".github/workflows/latin1.yml: not UTF-8 at byte {offset} "
+ "(invalid continuation byte)") in stderr
+ assert "1 workflow file(s) cannot be opened:" in stderr
+ assert (".github/workflows/other.yml: cannot be read: Permission denied"
+ in stderr)
+ assert stderr.index("are not UTF-8") < stderr.index("cannot be opened"), (
+ "the same order every run: not UTF-8 first")
+ assert "re-save" in stderr and "Commit each file git tracks" in stderr
+ assert "chmod u+r" in stderr
+ assert_nothing_changed(project, before)
+
+
+@needs_a_mode
+def test_an_unopenable_workflow_is_refused_before_nothing_to_do(
+ project, scaffolded):
+ """EXIT 1 MUST NOT HIDE IT. `--to` the commit every fact already names is
+ `nothing to do` (exit 1), but the reads come before that decision: a tree
+ the next real bump cannot read is not one this run may call settled."""
+ prepared(project)
+ pinned = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip()
+
+ refused_while_unopenable(
+ project, scaffolded, pinned, "bump-leg-workflow-unreadable",
+ ".github/workflows/legs.yml")
+
+
+# --- the directory -----------------------------------------------------------
+
+@needs_a_mode
+@MODES
+def test_a_workflows_directory_that_cannot_be_listed_is_refused_naming_it(
+ project, scaffolded, advanced, extra):
+ """THE ISSUE'S ROW (e). `.github/workflows/` itself mode 000: git reads the
+ tree as clean (its `Permission denied` goes to stderr and `git status`
+ exits 0), the walk raised `PermissionError` out of `iterdir`, and the run
+ ended in a traceback. The refusal names the DIRECTORY, with a trailing
+ `/`, and says `chmod u+rx`."""
+ prepared(project)
+
+ stderr = refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-workflow-unreadable",
+ ".github/workflows", extra=extra)
+
+ assert (".github/workflows/: cannot be listed: Permission denied"
+ in stderr)
+ assert "chmod u+rx" in stderr
+ assert "cannot be opened" not in stderr, "no file was reached"
+ refused_again_fine(project, scaffolded, advanced, extra,
+ ".github/workflows/legs.yml")
+
+
+@needs_a_mode
+def test_a_workflows_directory_listable_but_not_searchable_is_refused(
+ project, scaffolded, advanced):
+ """A DIRECTORY WITH `r` AND NO `x`: its names list, and every `stat` of a
+ name in it is refused. The listing succeeds, so it is the `stat` that
+ raises, and it is a file-level refusal naming each: the same family, a
+ different frame. Asked through `Path.is_file()`, Python 3.14 answers "no
+ file" instead, every file is skipped, the bump goes ahead and
+ `validate-pins.py` rolls it back as `bump-leg-validators-red` (CI at
+ e2c78ee)."""
+ prepared(project)
+
+ stderr = refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-workflow-unreadable",
+ ".github/workflows", mode=stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
+
+ assert ".github/workflows/legs.yml: cannot be read: Permission denied" \
+ in stderr
+ assert "chmod u+rx" in stderr
+
+
+@needs_a_mode
+@MODES
+def test_a_github_directory_that_cannot_be_searched_names_the_workflows(
+ project, scaffolded, advanced, extra):
+ """`.github/` mode 000, the directory ABOVE the workflows: the `stat` of
+ `.github/workflows` is refused before any listing, and it is the
+ directory refusal, whose `chmod u+rx` advice names every directory above
+ it inside the root. Asked through `Path.is_dir()`, Python 3.14 answers
+ "no directory", and the bump went on as if the project had no workflow
+ to move."""
+ prepared(project)
+
+ stderr = refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-workflow-unreadable",
+ ".github", extra=extra)
+
+ assert (".github/workflows/: cannot be listed: Permission denied"
+ in stderr)
+ assert "chmod u+rx" in stderr and "every directory above it" in stderr
+ refused_again_fine(project, scaffolded, advanced, extra,
+ ".github/workflows/legs.yml")
+
+
+# --- the pin -----------------------------------------------------------------
+
+@needs_a_mode
+@MODES
+def test_a_leg_pin_that_cannot_be_opened_is_refused_by_name(
+ project, scaffolded, advanced, extra):
+ """THE ISSUE'S ROW (c). The pin is read OUTSIDE `load_yaml`, so the
+ refusal #289 gave a pin that is not UTF-8 has to be given one this user
+ cannot open, under the same id. It names `contracts/spec-pin.yaml`."""
+ prepared(project)
+
+ stderr = refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-pin-unreadable",
+ "contracts/spec-pin.yaml", extra=extra)
+
+ assert ("contracts/spec-pin.yaml: cannot be read: Permission denied"
+ in stderr)
+ assert "chmod u+r" in stderr
+ refused_again_fine(project, scaffolded, advanced, extra,
+ "contracts/spec-pin.yaml")
+
+
+@needs_a_mode
+def test_a_contracts_directory_that_cannot_be_searched_names_the_pin(
+ project, scaffolded, advanced):
+ """`contracts/` mode 000: the `stat` of the pin is refused before the read
+ is reached. That is not an absent pin (`bump-leg-pin-missing`, whose
+ remedy is to restore it from history): the file is there and cannot be
+ reached, so it is the pin refusal, with the permission remedy. Python
+ 3.14's `Path.is_file()` answers "no file" here, which is how CI at e2c78ee
+ got `bump-leg-pin-missing`."""
+ prepared(project)
+
+ stderr = refused_while_unopenable(
+ project, scaffolded, advanced, "bump-leg-pin-unreadable", "contracts")
+
+ assert ("contracts/spec-pin.yaml: cannot be read: Permission denied"
+ in stderr)
+ assert "bump-leg-pin-missing" not in stderr
+
+
+# --- an OSError that is not a permission (on every platform, root too) -------
+
+def refusal_of(module, call, *args):
+ with pytest.raises(module.Refusal) as refused:
+ call(*args)
+ return refused.value
+
+
+@pytest.mark.parametrize("failure", [
+ FileNotFoundError(errno.ENOENT, "No such file or directory"),
+ IsADirectoryError(errno.EISDIR, "Is a directory"),
+ OSError(errno.EIO, "Input/output error"),
+], ids=["vanished", "now-a-directory", "io-error"])
+def test_a_workflow_that_fails_for_another_reason_is_refused_sans_chmod(
+ bump_leg_module, project, advanced, monkeypatch, failure):
+ """THE `OSError` FAMILY, NOT `PermissionError` ALONE (the issue's second
+ point): a file that vanishes between the listing and the read, one that
+ stopped being a file, an I/O error. The same refusal, the system's own
+ reason on the line, and a remedy that does not tell the operator to
+ `chmod` what is not a permission. Called in-process with the read
+ replaced, so it runs on every platform and as root."""
+ reference_both_legs(project)
+ real = Path.read_text
+
+ def read_text(self, *args, **kwargs):
+ if self.name == "legs.yml":
+ raise type(failure)(failure.errno, failure.strerror, str(self))
+ return real(self, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "read_text", read_text)
+ refusal = refusal_of(bump_leg_module, bump_leg_module.plan_workflows,
+ project, f"{ORG}/{PROJECT}-spec", advanced)
+
+ assert refusal.code == "bump-leg-workflow-unreadable"
+ assert (f".github/workflows/legs.yml: cannot be read: {failure.strerror}"
+ in refusal.detail)
+ assert "chmod" not in refusal.remediation
+ assert "Where the operating system gave another reason" in (
+ refusal.remediation)
+ assert "(untracked or ignored)" not in refusal.detail, "it is tracked"
+
+
+def test_a_pin_that_fails_for_another_reason_is_refused_by_name(
+ bump_leg_module, project, monkeypatch):
+ """The pin's own `OSError` that is no permission: the same id, the
+ system's reason, no `chmod`."""
+ real = Path.read_text
+
+ def read_text(self, *args, **kwargs):
+ if self.name == "spec-pin.yaml":
+ raise OSError(errno.EIO, "Input/output error", str(self))
+ return real(self, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "read_text", read_text)
+ refusal = refusal_of(bump_leg_module, bump_leg_module._leg_pin_text,
+ project, project / "contracts" / "spec-pin.yaml")
+
+ assert refusal.code == "bump-leg-pin-unreadable"
+ assert refusal.detail == ("contracts/spec-pin.yaml: cannot be read: "
+ "Input/output error")
+ assert "chmod" not in refusal.remediation
+ assert "UTF-8" not in refusal.remediation
+
+
+def test_a_listing_that_fails_for_another_reason_names_the_directory(
+ bump_leg_module, project, advanced, monkeypatch):
+ """`iterdir` failing with an I/O error: the directory refusal, with the
+ system's reason and no `chmod`. Both listings go through one helper, so
+ the first one `plan_workflows` makes is the one that is refused."""
+ reference_both_legs(project)
+ real = Path.iterdir
+
+ def iterdir(self):
+ if self.name == "workflows":
+ raise OSError(errno.EIO, "Input/output error", str(self))
+ return real(self)
+
+ monkeypatch.setattr(Path, "iterdir", iterdir)
+ refusal = refusal_of(bump_leg_module, bump_leg_module.plan_workflows,
+ project, f"{ORG}/{PROJECT}-spec", advanced)
+
+ assert refusal.code == "bump-leg-workflow-unreadable"
+ assert refusal.detail == (".github/workflows/: cannot be listed: "
+ "Input/output error")
+ assert "chmod" not in refusal.remediation
+
+
+def test_a_respelled_unopenable_workflow_is_refused_as_the_disk_spells_it(
+ bump_leg_module, project, advanced, monkeypatch):
+ """THE #344 COMPOSITION, through #344's own seam, so it runs on every
+ disk. A tracked `legs.yml` renamed outside git to `Legs.yml` that cannot
+ be opened. Every file is read before the loop asks `_tracked_spelling`
+ anything, so it is refused under the spelling the DISK gives it, and its
+ line is not marked untracked: it is the tracked file, under git's
+ spelling, which is what the question is asked."""
+ reference_both_legs(project)
+ workflows = project / WORKFLOWS
+ respelled(workflows, "legs.yml", "Legs.yml")
+ monkeypatch.setattr(bump_leg_module, "_same_file", ignoring_case,
+ raising=False)
+ real = Path.read_text
+
+ def read_text(self, *args, **kwargs):
+ if self.name == "Legs.yml":
+ raise PermissionError(errno.EACCES, "Permission denied",
+ str(self))
+ return real(self, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "read_text", read_text)
+ refusal = refusal_of(bump_leg_module, bump_leg_module.plan_workflows,
+ project, f"{ORG}/{PROJECT}-spec", advanced)
+
+ assert refusal.code == "bump-leg-workflow-unreadable"
+ assert (".github/workflows/Legs.yml: cannot be read: Permission denied"
+ in refusal.detail)
+ assert ".github/workflows/legs.yml" not in refusal.detail
+ assert "(untracked or ignored)" not in refusal.detail
+ assert "chmod u+r" in refusal.remediation
+
+
+# --- a refused `stat`, as Python 3.14 answers it (everywhere, root too) -----
+
+def refused_stat(monkeypatch, *names: str) -> None:
+ """`Path.stat` refused (`Permission denied`) for each of `names`, as it is
+ for a name inside a directory this user may not search, and
+ `Path.is_file`, `is_dir` and `exists` answering as Python 3.14's do:
+ False to ANY `OSError` (they call `os.path.isfile` and the like), where
+ 3.9 to 3.13 raised a refused `stat`.
+
+ CI's 3.14 is where the mode tests above first met that answer (e2c78ee):
+ `is_file` said "no file", and the tool took an unsearchable `contracts/`
+ for a missing pin and skipped every file of a workflows directory it could
+ list but not search. In-process, so this runs on every Python and every
+ platform and as root, and on a Python before 3.14 it still fails a tool
+ that asks `is_file` or `is_dir` and counts on the raise."""
+ real = Path.stat
+
+ def refusing(self, *args, **kwargs):
+ if self.name in names:
+ raise PermissionError(errno.EACCES, "Permission denied", str(self))
+ return real(self, *args, **kwargs)
+
+ def as_python_314(kind):
+ def answer(self, *args, **kwargs):
+ try:
+ return kind(self.stat().st_mode)
+ except (OSError, ValueError):
+ return False
+ return answer
+
+ monkeypatch.setattr(Path, "stat", refusing)
+ monkeypatch.setattr(Path, "is_file", as_python_314(stat.S_ISREG))
+ monkeypatch.setattr(Path, "is_dir", as_python_314(stat.S_ISDIR))
+ monkeypatch.setattr(Path, "exists", as_python_314(lambda mode: True))
+
+
+def test_a_workflow_whose_stat_is_refused_is_named_not_skipped(
+ bump_leg_module, project, advanced, monkeypatch):
+ """A WORKFLOWS DIRECTORY LISTABLE BUT NOT SEARCHABLE, on every Python. The
+ listing names `legs.yml` and its `stat` is refused: the file refusal names
+ it, with the permission remedy. A file skipped as "not a file" is a bump
+ that goes ahead without it and is rolled back by `validate-pins.py`."""
+ reference_both_legs(project)
+ refused_stat(monkeypatch, "legs.yml")
+
+ refusal = refusal_of(bump_leg_module, bump_leg_module.plan_workflows,
+ project, f"{ORG}/{PROJECT}-spec", advanced)
+
+ assert refusal.code == "bump-leg-workflow-unreadable"
+ assert (".github/workflows/legs.yml: cannot be read: Permission denied"
+ in refusal.detail)
+ assert "1 workflow file(s) cannot be opened" in refusal.detail
+ assert "chmod u+r" in refusal.remediation
+
+
+def test_a_workflows_directory_whose_stat_is_refused_is_named(
+ bump_leg_module, project, advanced, monkeypatch):
+ """A `.github/` this user may not search, on every Python: the `stat` of
+ `.github/workflows` is refused, and it is the directory refusal. A
+ directory taken for "not there" is a bump that moves no workflow."""
+ reference_both_legs(project)
+ refused_stat(monkeypatch, "workflows")
+
+ refusal = refusal_of(bump_leg_module, bump_leg_module.plan_workflows,
+ project, f"{ORG}/{PROJECT}-spec", advanced)
+
+ assert refusal.code == "bump-leg-workflow-unreadable"
+ assert refusal.detail == (".github/workflows/: cannot be listed: "
+ "Permission denied")
+ assert "chmod u+rx" in refusal.remediation
+
+
+def test_a_pin_whose_stat_is_refused_is_not_called_missing(
+ bump_leg_module, project, monkeypatch):
+ """A `contracts/` this user may not search, on every Python: the pin is
+ there and cannot be reached, which is `bump-leg-pin-unreadable` with the
+ permission remedy, never `bump-leg-pin-missing`, whose remedy restores a
+ file nobody lost."""
+ refused_stat(monkeypatch, "spec-pin.yaml")
+
+ refusal = refusal_of(bump_leg_module, bump_leg_module._leg_pin_path,
+ project, "contracts/spec-pin.yaml")
+
+ assert refusal.code == "bump-leg-pin-unreadable"
+ assert refusal.detail == ("contracts/spec-pin.yaml: cannot be read: "
+ "Permission denied")
+ assert "chmod u+r" in refusal.remediation
+
+
+@pytest.mark.parametrize("missing", [
+ "contracts/nowhere-pin.yaml",
+ "contracts/spec-pin.yaml/below",
+], ids=["no-such-file", "a-file-on-the-path"])
+def test_a_pin_that_is_not_there_is_still_missing(
+ bump_leg_module, project, missing):
+ """THE OTHER HALF OF THE ANSWER: nothing at the name (`ENOENT`), or a part
+ of the path that is a file (`ENOTDIR`), is the absent pin it always was,
+ and not a file this user cannot open."""
+ refusal = refusal_of(bump_leg_module, bump_leg_module._leg_pin_path,
+ project, missing)
+
+ assert refusal.code == "bump-leg-pin-missing"
+ assert refusal.detail.startswith(f"{missing} does not exist")