Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fff4214
validate-pins.py: a leg's git-directory `HEAD` this user cannot open,…
Oct 23, 2026
2eada67
validate-family.py: a member whose `.git` file this user cannot open,…
Oct 23, 2026
0deac3a
test_validate_family_unreadable.py: a member whose `.git` gitfile ope…
Nov 8, 2026
2a992d3
Merge #1246's head 0deac3a6 (l3-1221-member-git-unreadable) into l3-1…
Nov 8, 2026
bc60207
Merge #1239's head fff42145 (l3-1222-gate-head-unreadable) into l3-12…
Nov 8, 2026
9ef8f0a
validate-family.py: a member whose git directory's `HEAD` this user c…
Nov 8, 2026
66741c6
Merge branch 'main' into l3-1222-gate-head-unreadable
brettheap Nov 9, 2026
ae969d1
Merge branch 'main' into l3-1221-member-git-unreadable
brettheap Nov 9, 2026
a9783a3
Merge branch 'main' into l3-1222-gate-head-unreadable
brettheap Nov 10, 2026
2bbf4b7
Merge branch 'main' into l3-1221-member-git-unreadable
brettheap Nov 10, 2026
12a8311
Merge branch 'main' into l3-1221-member-git-unreadable
brettheap Nov 10, 2026
2feb697
Merge #1239's final head a9783a37 (l3-1222-gate-head-unreadable) into…
Nov 10, 2026
5aac438
Merge #1246's final head 12a8311b (l3-1221-member-git-unreadable) int…
Nov 10, 2026
1962f74
Merge #1246's squash 30272026 into l3-1256-member-head-unreadable (re…
Nov 10, 2026
be62b70
validate-pins.py and validate-family.py: a leg's or member's git-dire…
Nov 10, 2026
088acd8
Merge branch 'main' into l3-1256-member-head-unreadable
brettheap Nov 10, 2026
f5a3427
Merge #1263's final head 088acd82 (restack after #1263 landed as a98f…
Nov 10, 2026
25c52b4
Merge #1263's squash a98fe7cb (main), with ours for the PR's own file…
Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion templates/assembly-root/scripts/validate-pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,8 @@ def _refuse_an_unreadable_git_dir(root: Path, dotgit: Path,
(`_gitfile_opens`): only where it is a regular file, and an open that
finds nothing is a git directory with no `HEAD`. One this user cannot
open is named, `.git/modules/spec/HEAD` or `spec/.git/HEAD`, with the
same remedy."""
same remedy. So is one git cannot read as a `HEAD`, a FIFO among them
(#1290): see `_a_head_git_cannot_read`."""
named = dotgit
try:
gitdir = _leg_git_dir(dotgit)
Expand All @@ -480,10 +481,52 @@ def _refuse_an_unreadable_git_dir(root: Path, dotgit: Path,
if _is_there(head):
named = named / "HEAD"
_gitfile_opens(head)
_a_head_git_cannot_read(head)
except OSError as exc:
raise _unreadable_file(code, _named(root, named), exc, reads) from exc


# A GIT DIRECTORY'S `HEAD` THAT GIT CANNOT READ AS ONE (#1290, RV1239 on
# #1239, W1256 on #1263). The guard above opens a `HEAD` only where it is a
# regular file, since the open of a FIFO waits for a writer; but git's own
# open of it, in the pinned commit's `git rev-parse`, waited, and `git_out`
# has no timeout, so the gate gave no answer at all. A `HEAD` that `stat`,
# which follows a link, finds neither a file nor a directory (a FIFO, a
# socket or a device, or a symlink to one) is refused by name before git
# is asked. A socket, or a symlink to a FIFO or a device, which git does
# not wait on, ended the run in `pin-commit-unresolvable`, which says the
# pinned commit is missing; git reads a symlinked `HEAD` only where it
# names `refs/`. A directory, a symlink to a file or a directory, and a
# symlink that leads nowhere are left to git, as they were. The root's own
# `.git/HEAD`, asked here too (`_refuse_an_unreadable_root`, #1146), is
# `git-repo-unreadable` the same way.
#
# HERE, AND NOT AS A TIMEOUT IN `git_out` (#1290's choice (b)). `git_out` is
# `scripts/repo_shape.py`'s, a shape copy every root pins on its own and
# may hold older than this file (#370, #610), so a timeout there reaches no
# root whose copy predates it; it is shared by every tool, `tree_digest`'s
# `ls-tree -r` over a whole leg among them, with no flag to say how long is
# too long; and a call that timed out raises `subprocess.TimeoutExpired`,
# which no caller here catches (a traceback, exit 1, a finding's code), or,
# made a `git-failed` refusal, reads as `pin-commit-unresolvable` again.
# What else in a git directory git waits on, its `config` a FIFO among
# them (measured: no answer), is not asked here.

def _a_head_git_cannot_read(head: Path) -> None:
"""Raise an `OSError` whose reason says what stands there, as
`_what_stands` names it, for a git directory's `HEAD` that is neither a
file nor a directory (#1290), so `_refuse_an_unreadable_git_dir` names
it as it names a `HEAD` this user cannot open: `<HEAD>: cannot be read
(a FIFO, not a file)`, or `(a symlink to a device, not a file)`, with
the remedy for a cause that is no permission, which says to put a
regular file back in its place. `family-root`'s `validate-family.py`
raises the same words for a member's."""
what = _what_stands(
head, lambda mode: stat.S_ISREG(mode) or stat.S_ISDIR(mode))
if what is not None:
raise OSError(None, f"{what}, not a file" if what else "not a file")


#: The refusal of a root whose own repository this user cannot read (#1146),
#: and what that repository is read for, as `_unreadable_file`'s remedy
#: names it. `validate-manifest.py` refuses its `.git` by the same code.
Expand Down
53 changes: 52 additions & 1 deletion templates/family-root/scripts/validate-family.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,8 @@ def _refuse_an_unreadable_git_dir(checkout: Path, path: str) -> None:
a FIFO waits for a writer, and an open that finds nothing is a git
directory with no `HEAD`. One this user cannot open is named,
`.git/modules/<path>/HEAD` or `<path>/.git/HEAD`, with the same
remedy."""
remedy. So is one git cannot read as a `HEAD`, a FIFO among them
(#1290): see `_a_head_git_cannot_read`."""
dotgit = checkout / ".git"
named = f"{path}/.git"
try:
Expand All @@ -746,10 +747,60 @@ def _refuse_an_unreadable_git_dir(checkout: Path, path: str) -> None:
if _is_a(head, _any_kind):
named = named_head
_gitfile_opens(head)
_a_head_git_cannot_read(head)
except OSError as exc:
raise _unreadable(MEMBER_UNREADABLE, named, exc) from exc


# A GIT DIRECTORY'S `HEAD` THAT GIT CANNOT READ AS ONE (#1290, RV1239 on
# #1239, W1256 on #1263). The guard above opens a `HEAD` only where it is a
# regular file, since the open of a FIFO waits for a writer; but git's own
# open of it, in the pinned commit's `git rev-parse`, waited, and `git_out`
# has no timeout, so the gate gave no answer at all. A `HEAD` that `stat`,
# which follows a link, finds neither a file nor a directory (a FIFO, a
# socket or a device, or a symlink to one) is refused by name before git
# is asked. A socket, or a symlink to a FIFO or a device, which git does
# not wait on, ended the run in `member-commit-unresolvable`, which says the
# pinned commit is missing; git reads a symlinked `HEAD` only where it
# names `refs/`. A directory, a symlink to a file or a directory, and a
# symlink that leads nowhere are left to git, as they were. The holder's
# own `HEAD` is not asked here (#1172's).
#
# HERE, AND NOT AS A TIMEOUT IN `git_out` (#1290's choice (b)). `git_out` is
# `scripts/repo_shape.py`'s, a shape copy every root pins on its own and
# may hold older than this file (#370, #610), so a timeout there reaches no
# root whose copy predates it; it is shared by every tool, `tree_digest`'s
# `ls-tree -r` over a whole leg among them, with no flag to say how long is
# too long; and a call that timed out raises `subprocess.TimeoutExpired`,
# which no caller here catches (a traceback, exit 1, a finding's code), or,
# made a `git-failed` refusal, reads as `member-commit-unresolvable` again.
# What else in a git directory git waits on, its `config` a FIFO among
# them (measured: no answer), is not asked here.

#: What a `stat` mode that is neither a file nor a directory names, as
#: `validate-pins.py`'s `_KINDS` names it (#1290). A device is either kind.
_HEAD_KINDS = (
(stat.S_ISFIFO, "a FIFO"), (stat.S_ISSOCK, "a socket"),
(stat.S_ISCHR, "a device"), (stat.S_ISBLK, "a device"))


def _a_head_git_cannot_read(head: Path) -> None:
"""Raise an `OSError` whose reason says what stands there, in
`validate-pins.py`'s words for a leg's, for a member git directory's
`HEAD` that is neither a file nor a directory (#1290), so
`_refuse_an_unreadable_git_dir` names it as it names a `HEAD` this user
cannot open: `<HEAD>: cannot be read (a FIFO, not a file)`, or `(a
symlink to a device, not a file)`, with the remedy for a cause that is
no permission, which says to put a regular file back in its place. Its
`stat`, then its own `lstat`, as that gate's `_what_stands` asks."""
mode = head.stat().st_mode
if stat.S_ISREG(mode) or stat.S_ISDIR(mode):
return
link = "a symlink to " if stat.S_ISLNK(head.lstat().st_mode) else ""
kind = next((name for test, name in _HEAD_KINDS if test(mode)), None)
raise OSError(None, f"{link}{kind}, not a file" if kind else "not a file")


def _checkout_gone(root: Path, path: str) -> bool:
"""`_gone` of the `.git` at the gitlink path `path`, or
`member-unreadable` naming it where its `stat` was refused for any
Expand Down
77 changes: 65 additions & 12 deletions tests/test_validate_family_unreadable.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
regular file, and refused `member-unreadable`, naming that `HEAD`; a
parity row holds the name to the gate's.

A MEMBER GIT DIRECTORY'S `HEAD` THAT GIT CANNOT READ AS ONE (#1290), as
the gate's leg's: a FIFO is not opened by #1256's guard, but git's open of
it waits for a writer, and the gate gave no answer in 60 s. A `HEAD` that
is neither a file nor a directory (a FIFO, a socket or a device, or a
symlink to one) is `member-unreadable`, naming it and what stands there,
before git is asked; each run over a FIFO is bounded, and a parity row
holds the line to the gate's.

WHAT IS ON DISK IS ASKED ONLY WHERE IT IS SAID (round 2, r956's SHOULD 1
on #956): `_forced` and `_staged_over` ask git first, and where HEAD
records a gitlink at the index's own commit, the disk under it is never
Expand Down Expand Up @@ -98,6 +106,9 @@
from test_validate_family_unnamed_mounts import (
codes, commands, findings, spare_commits, stage_gitlink,
)
from test_validate_pins_spelled_and_named import (
FIFO_HEADS, NOT_HEADS, bounded,
)

OTHER_ROW = f"members/{OTHER}"
HAND = f"members/{SPARE}"
Expand Down Expand Up @@ -1027,6 +1038,26 @@ def test_a_member_git_directory_head_whose_open_is_refused_is_refused(
assert "member-commit-unresolvable" not in err


@pytest.mark.parametrize("make, reason", FIFO_HEADS)
@pytest.mark.parametrize("made,head", HEADS)
def test_a_member_fifo_head_is_refused_by_name_within_a_bound(
holder, request, made, head, make, reason):
"""#1290, W1256 on #1263: the holder's own gate, the member's git
directory's `HEAD` a FIFO, or a symlink to one. #1256's guard does not
open it, but `git rev-parse` did, and waited for a writer: the gate gave
no answer in 60 s. It is `member-unreadable`, exit 2, naming that `HEAD`
and what stands there, with the advice for a cause that is no
permission, before git is asked."""
extra = made(holder, request)
(holder / head).rename(holder / (head + "-aside"))
make(holder / head)
result = bounded(holder / "scripts" / "validate-family.py", holder,
*extra)
assert_refused(result.returncode, result.stderr, MEMBER_UNREADABLE, head,
reason)
assert "member-commit-unresolvable" not in result.stderr


def test_the_member_tree_asked_alone_names_a_head_whole_outside(
holder, request, monkeypatch):
"""`_check_member_tree` asked alone, the open of the `HEAD` refused: it
Expand Down Expand Up @@ -1183,29 +1214,51 @@ def test_a_member_head_is_named_as_the_gate_names_a_leg_s(


@pytest.mark.parametrize("own_directory", OWN_DIRECTORY)
@pytest.mark.parametrize("make", [
pytest.param(lambda head: head.mkdir(), id="directory"),
pytest.param(lambda head: os.mkfifo(head), id="fifo",
marks=pytest.mark.skipif(not hasattr(os, "mkfifo"),
reason="no FIFO on this platform")),
])
def test_a_member_head_that_is_no_regular_file_is_not_opened(
sources, tmp_path, monkeypatch, own_directory, make):
def test_a_member_head_that_is_a_directory_is_left_to_git(
sources, tmp_path, monkeypatch, own_directory):
"""Only a `HEAD` that is a regular file is opened (#1256), in the family
gate as in the leg gate (#1222): one that is anything else is git's to
answer for, and the open of a FIFO would wait for a writer. An open of
either fails the row rather than hanging it."""
gate as in the leg gate (#1222), and one that is a directory is git's to
answer for, as it always was: the control for #1290's rows. An open
fails the row."""
gitdir = member_git_directory(tmp_path, own_directory)
head = gitdir / "HEAD"
head.rename(gitdir / "HEAD-aside")
make(head)
head.mkdir()
failing(monkeypatch, "open", "HEAD", OSError(errno.EIO, "opened"))
assert sources.family._refuse_an_unreadable_git_dir(tmp_path / ROW,
ROW) is None
assert sources.gate._refuse_an_unreadable_git_dir(
tmp_path, tmp_path / ROW / ".git") is None


@pytest.mark.parametrize("own_directory", OWN_DIRECTORY)
@pytest.mark.parametrize("make, reason", NOT_HEADS)
def test_a_member_head_git_cannot_read_is_named_as_the_gate_names_a_leg_s(
sources, tmp_path, monkeypatch, own_directory, make, reason):
"""PARITY (#1290): a `HEAD` that `stat`, which follows a link, finds
neither a file nor a directory, a FIFO, a socket, or a symlink to a FIFO
or a device, is refused unopened, and the family gate's line is the leg
gate's, naming the same `HEAD` and what stands there. The guards are
asked alone, so that where one lets it through, the row fails and git
is never started on a FIFO."""
gitdir = member_git_directory(tmp_path, own_directory)
head = gitdir / "HEAD"
head.rename(gitdir / "HEAD-aside")
make(head)
failing(monkeypatch, "open", "HEAD", OSError(errno.EIO, "opened"))
family_refusal = refusal_of(
sources.family, sources.family._refuse_an_unreadable_git_dir,
tmp_path / ROW, ROW)
gate_refusal = refusal_of(
sources.gate, sources.gate._refuse_an_unreadable_git_dir, tmp_path,
tmp_path / ROW / ".git")
named = head.relative_to(tmp_path).as_posix()
assert family_refusal.code == MEMBER_UNREADABLE
assert family_refusal.detail == f"{named}: cannot be read ({reason})"
assert gate_refusal.code == "pin-submodule-unreadable"
assert gate_refusal.detail == family_refusal.detail


def test_a_member_head_no_system_call_can_name_is_left_to_git(sources,
tmp_path):
"""A `gitdir: ` line that holds a NUL names a git directory no system
Expand Down
Loading
Loading