Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 76 additions & 6 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1964,6 +1964,10 @@
SOURCE_UNREADABLE_PERMISSION = (
"Make it readable by this user: `chmod u+rx` on the directory and on "
"every directory above it, or fix who owns them. ")
#: The same for a file, a `.git` that names the git directory (#1099).
SOURCE_UNREADABLE_FILE_PERMISSION = (
"Make it readable by this user: `chmod u+r` on the file and `chmod "
"u+rx` on every directory above it, or fix who owns them. ")
SOURCE_UNREADABLE_OTHER = (
"The operating system gave another reason than permission, which no "
"`chmod` puts right: put right what it names. ")
Expand Down Expand Up @@ -2041,15 +2045,18 @@


def _source_unreadable(named: str, exc: OSError, untold: str,
instead: str = "") -> Refusal:
instead: str = "", *,
permission: str = SOURCE_UNREADABLE_PERMISSION
) -> Refusal:
"""`source-unreadable` (#430): `named` (`--source '<it>'`, or the plan's
`source.local_path`) is a path whose `stat` the operating system refused
for another reason than that nothing is there, `<named>: cannot be read
(<reason>)`, as `validate-pins.py`'s `_cannot_read` words it; `untold` is
what that leaves unknown, which is not guessed. The way out follows the
cause (`SOURCE_UNREADABLE_PERMISSION`, `SOURCE_UNREADABLE_OTHER`), and
cause (`permission`, a directory's `SOURCE_UNREADABLE_PERMISSION` unless
the caller names a file's, or `SOURCE_UNREADABLE_OTHER`), and
`instead`, another way to name the source, ends it."""
advice = (SOURCE_UNREADABLE_PERMISSION if isinstance(exc, PermissionError)
advice = (permission if isinstance(exc, PermissionError)
else SOURCE_UNREADABLE_OTHER)
return Refusal(
SOURCE_UNREADABLE,
Expand Down Expand Up @@ -2080,7 +2087,7 @@
except OSError as exc:
if name is None:
raise _source_unreadable(f"--source {spec!r}", exc,
"whether a repository is there") from exc

Check failure on line 2090 in adopt-project.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "whether a repository is there" 3 times.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaFfXlwFx4txvvx6CpT7&open=AaFfXlwFx4txvvx6CpT7&pullRequest=1141
here = _spelled("./" + spec.rstrip("/"))
raise _source_unreadable(
f"--source {spec!r}", exc,
Expand Down Expand Up @@ -2634,6 +2641,41 @@
return None


def _git_refused(path: Path) -> tuple[OSError, bool] | None:
"""`(the OSError, whether the .git is a file)`: how the operating system
refuses this user the `.git` in `path`; None where `path` holds no
`.git`, or one git can be let into (#1099).

GIT SAYS "NOT A GIT REPOSITORY" OF A `.git` IT CANNOT READ, as it says
of a clone emptied, and climbs to the directory above, which may be
another repository. So a clone `plan` made whose `.git` is at mode 000
was "no longer whole (git finds no repository there)", and its
`source.repository` was cloned again from GitHub beside it. The `.git`
is asked here, before git runs, as `_is_a` asks a path (#430): a
directory by whether the `HEAD` in it is there, a `stat` that needs the
directory searched and nothing of `HEAD` itself, and a file, which names
the git directory elsewhere, by opening it, as git reads it. Nothing
there, a `HEAD` that is not there, or a `.git` that is neither, is git's
own question, asked as before. A `.git` whose own `stat` is refused is
in a `path` this user cannot search, which is `path`'s to name
(`_top_level`, `_not_a_whole_clone`, #918)."""
dot_git = path / ".git"
try:
a_directory = _is_a(dot_git, stat.S_ISDIR)
a_file = not a_directory and _is_a(dot_git, stat.S_ISREG)
except OSError:
return None
try:
if a_directory:
# only its refusal is asked: a `HEAD` not there is git's to read
_is_there(dot_git / "HEAD")
elif a_file:
os.close(os.open(dot_git, os.O_RDONLY))
except OSError as exc:
return None if _absent(exc) else (exc, a_file)
return None


class Source:
"""A repository being read, and nothing more. Nothing here writes to it."""

Expand Down Expand Up @@ -6607,7 +6649,7 @@
f"names no `owner/repo` on {GITHUB_HOST}", SOURCE_REMEDIATION)
return name

def _recorded_source(self, local_path: str, work_root: Path,

Check failure on line 6652 in adopt-project.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaFfXlwFx4txvvx6CpT8&open=AaFfXlwFx4txvvx6CpT8&pullRequest=1141
unwritable: str | None, made: list | None,
done: str = "") -> Source:
"""The source the plan's `source.local_path` records, read in place
Expand Down Expand Up @@ -6658,7 +6700,12 @@
GitHub beside the one still there. So is a directory this user
cannot enter (#918): git runs in it, and at mode 000 that was a
`PermissionError` traceback where `plan` did not make it, and "no
longer whole (git finds no repository there)" where it did.
longer whole (git finds no repository there)" where it did. So is a
directory whose `.git` this user cannot read (`_git_refused`,
#1099), named by that `.git`: git says "not a git repository" of it,
so at mode 000 that was `git-failed` where `plan` did not make it,
and "no longer whole (git finds no repository there)", cloned again
from GitHub, where it did.

A PATH THIS LOCALE CANNOT SPELL IS ASKED AS ITS UTF-8 BYTES
(`_recorded_spelling`, #918): a plan written in UTF-8 mode for
Expand All @@ -6682,6 +6729,9 @@
there = _is_there(path)
except OSError as exc:
raise unreadable(exc) from exc
refused = _git_refused(path) if there else None
if refused is not None:
raise self._git_unreadable(local_path, *refused) from refused[0]
if self.source.get("cloned") is not True:
if there:
return Source.in_place(_top_level(path, f"{named},",
Expand Down Expand Up @@ -6733,6 +6783,25 @@
f"the plan's `source.local_path`, {_spelled(local_path)}, "
f"{state}, and {why}", PLAN_SOURCE_GONE_REMEDY)

def _git_unreadable(self, local_path: str, exc: OSError,
a_file: bool) -> Refusal:
"""`source-unreadable` of the `.git` in the plan's
`source.local_path`, which the operating system refuses this user
(`exc`; `a_file` where that `.git` is a file), naming that `.git` and
the reason, as a recorded path this user cannot enter is named
(#918). What git would find there cannot be told, so the clone is
neither read nor called no longer whole nor cloned again (#1099)."""
return _source_unreadable(
f"the `.git` in the plan's `source.local_path`, "
f"{_spelled(os.path.join(local_path, '.git'))}", exc,
("whether `plan`'s clone is still whole, to be read, or no longer "
"whole, to be cloned again," if self.source.get("cloned") is True
else "whether a repository is there"),
"pass the source again with --source, which is held to the "
"plan's `commit:`",
permission=(SOURCE_UNREADABLE_FILE_PERMISSION if a_file
else SOURCE_UNREADABLE_PERMISSION))

def names(self) -> dict[str, str]:
"""The three repository names the plan declares, validated as values.

Expand Down Expand Up @@ -17521,8 +17590,9 @@
"whole, its `source.repository` is cloned again, and any other "
"`local_path` that is gone is refused as `plan-source-gone`; one that "
"is a file, or a directory inside a repository, as "
"`source-not-top-level`; and one this user cannot reach, a clone "
"`plan` made included, as `source-unreadable`, never cloned again.")
"`source-not-top-level`; and one this user cannot reach, or one whose "
"`.git` this user cannot read, a clone `plan` made included, as "
"`source-unreadable`, never cloned again.")


def main(argv: list[str] | None = None) -> int:
Expand Down
10 changes: 6 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,8 +640,9 @@ options:
and any other `local_path` that is gone is refused as
`plan-source-gone`; one that is a file, or a directory
inside a repository, as `source-not-top-level`; and
one this user cannot reach, a clone `plan` made
included, as `source-unreadable`, never cloned again.
one this user cannot reach, or one whose `.git` this
user cannot read, a clone `plan` made included, as
`source-unreadable`, never cloned again.
--local-remote-dir LOCAL_REMOTE_DIR
the directory `execute --local-remote-dir` will be
given (the TEST path): each relative submodule url the
Expand Down Expand Up @@ -699,8 +700,9 @@ options:
and any other `local_path` that is gone is refused as
`plan-source-gone`; one that is a file, or a directory
inside a repository, as `source-not-top-level`; and
one this user cannot reach, a clone `plan` made
included, as `source-unreadable`, never cloned again.
one this user cannot reach, or one whose `.git` this
user cannot read, a clone `plan` made included, as
`source-unreadable`, never cloned again.
--local-remote-dir LOCAL_REMOTE_DIR
create the legs as bare repositories here instead of
calling `gh` (the TEST path). Both are asked about
Expand Down
234 changes: 234 additions & 0 deletions tests/test_adopt_unreadable_clone_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# SPDX-License-Identifier: Apache-2.0
"""`adopt-project.py`: a plan's `source.local_path` whose `.git` this user
cannot read is refused by name, never called a clone that is no longer
whole, and never cloned again (#1099).

ON 4ed7c7bf5 (2026-10-21), Python 3.12.3, git 2.43.0, a non-root user, a
stub `gh` that records and refuses every call, and local remotes, with the
plan's `source.local_path` a directory this user can enter and its `.git` at
mode 000:

- recorded as a clone `plan` made (`source.cloned: true`), `check` and
`execute --yes` printed "NOTE the plan's `source.local_path`, ..., is a
clone `plan` made that is no longer whole (git finds no repository
there), so its `source.repository`, testorg/Thing, is cloned again from
GitHub" and "cloning testorg/Thing", and asked `gh repo clone` once. A
`.git` that is a file (`gitdir:`) at mode 000 did the same.
- recorded with `source.cloned: false`: `REFUSED git-failed`, "`git
rev-parse --is-inside-git-dir` in ... exited 128: fatal: not a git
repository", or, for a `.git` file, "fatal: error opening '.../.git':
Permission denied".

Git says "not a git repository" of a `.git` it cannot read, as it says of a
clone emptied, so its exit cannot tell the two apart. Now each is
`source-unreadable`, naming that `.git` and the operating system's reason,
with the cure for a directory or for a file, before anything is cloned, and
`gh` is never asked. A `.git` this user can read is read as before, and a
directory this user cannot enter is still named by itself (#918).

A real mode runs in a child, skipped on Windows and as root (`needs_a_mode`),
as `tests/test_adopt_unenterable_source_and_locale.py` runs it. The same
refusal is asked in-process with the `stat` of `.git/HEAD` refused, which
runs everywhere.
"""

from __future__ import annotations

import contextlib
import errno
import os
import stat
from pathlib import Path

import pytest

from conftest import (ADOPT, git, load_without_bytecode, make_source_repo,
run_script, spelled)
from test_adopt_temp_work_roots import (PROJECT, SMALL_TREE,
planned_from_github, recorded)
from test_adopt_unsearchable_source import (CURE, DENIED, UNREADABLE, URL,
needs_a_mode, planned_locally,
recording_gh, run, said)

DIRECTORY, FILE = "directory", "file"
#: What `source-unreadable` cannot tell of the `.git`, by `source.cloned`.
UNTOLD = {False: "whether a repository is there",
True: "whether `plan`'s clone is still whole, to be read, or no "
"longer whole, to be cloned again,"}
#: The way out, by what the `.git` is.
CURES = {DIRECTORY: CURE,
FILE: ("Make it readable by this user: `chmod u+r` on the file and "
"`chmod u+rx` on every directory above it, or fix who owns "
"them. Then run the same command again")}
#: The way past it that names the source otherwise.
INSTEAD = ("or pass the source again with --source, which is held to the "
"plan's `commit:`.")
#: What 4ed7c7bf5 said of such a `.git`, by `source.cloned`.
FALSE_ANSWERS = ("Traceback", "that is no longer whole",
"git finds no repository there", "is cloned again from",
"cloning ", "git-failed", "not a git repository",
"error opening", "plan-source-gone", "source-unclonable")


@pytest.fixture(scope="module")
def adopter():
return load_without_bytecode(ADOPT, "adopt_unreadable_clone_git")


def a_clone(tmp_path: Path, kind: str) -> Path:
"""A clone of a small source, at `<tmp>/Thing`, whose `origin` is the
GitHub url `plan` records as `source.repository`. Its `.git` is a
directory, or, for `FILE`, a file naming the git directory
`<tmp>/store.git`, as `git clone --separate-git-dir` leaves it."""
origin = make_source_repo(tmp_path / "origin" / PROJECT, tree=SMALL_TREE,
edits=())
clone = tmp_path / PROJECT
apart = ["--separate-git-dir", str(tmp_path / "store.git")] \
if kind == FILE else []
git("clone", "-q", *apart, str(origin), str(clone), cwd=tmp_path)
git("remote", "set-url", "origin", f"{URL}.git", cwd=clone)
assert (clone / ".git").is_file() is (kind == FILE), "premise"
return clone


@contextlib.contextmanager
def at_mode_000(path: Path):
"""`path` at mode 000 for the `with`, its mode given back in a `finally`.
The mode is asserted to bite: a file system that ignores modes would pass
every row here for the wrong reason."""
before = stat.S_IMODE(path.stat().st_mode)
path.chmod(0)
try:
assert not os.access(path, os.R_OK), path
yield path
finally:
path.chmod(before)


def refused_head(monkeypatch, clone: str) -> None:
"""`os.stat` of `<clone>/.git/HEAD` refused, `Permission denied`, as the
operating system refuses it where that `.git` cannot be searched; every
other `stat` is the real one, and git itself reads the clone."""
head = os.path.normcase(os.path.join(clone, ".git", "HEAD"))
real = os.stat

def refusing(path, *args, **kwargs):
if not isinstance(path, int) and \
os.path.normcase(os.fsdecode(path)) == head:
raise PermissionError(errno.EACCES, "Permission denied",
os.fsdecode(path))
return real(path, *args, **kwargs)

monkeypatch.setattr(os, "stat", refusing)


def the_refusal(local_path: str, cloned: bool) -> str:
"""The line `source-unreadable` names the recorded `.git` with."""
return (f"{UNREADABLE}the `.git` in the plan's `source.local_path`, "
f"{spelled(os.path.join(local_path, '.git'))}: {DENIED}, so "
f"{UNTOLD[cloned]} cannot be told")


def assert_no_false_answer(text: str) -> None:
"""None of the words 4ed7c7bf5 said of such a `.git`."""
for words in FALSE_ANSWERS:
assert words not in text, (words, text)


@needs_a_mode
@pytest.mark.parametrize("kind", [DIRECTORY, FILE])
@pytest.mark.parametrize("cloned", [False, True], ids=["given", "cloned"])
@pytest.mark.parametrize("verb", ["check", "execute"])
def test_a_recorded_git_this_user_cannot_read_is_refused_by_name(
tmp_path, verb, cloned, kind):
"""The plan's `source.local_path` holds a `.git` at mode 000: on
4ed7c7bf5 a clone `plan` made was "no longer whole (git finds no
repository there)" and cloned again, `gh` asked, and any other was
`git-failed`. Now `source-unreadable`, naming the `.git`, in `check` and
`execute` alike; nothing is cloned or made and `gh` is never asked."""
clone = a_clone(tmp_path, kind)
plan = planned_locally(tmp_path, clone, cloned)
local_path = str(recorded(plan)["local_path"])
env, log = recording_gh(tmp_path / "gh-bin")
work = tmp_path / f"{verb}-work"

with at_mode_000(clone / ".git"):
proc = run_script(ADOPT, verb, "--plan", str(plan), "--work-dir",
str(work), "--local-remote-dir",
str(tmp_path / "remotes"),
*(["--yes"] if verb == "execute" else []), env=env)

assert proc.returncode == 2, said(proc)
assert the_refusal(local_path, cloned) in proc.stderr
assert CURES[kind] in proc.stderr
assert INSTEAD in proc.stderr
assert_no_false_answer(said(proc))
assert log.read_bytes() == b""
assert not (work / "source-readonly").exists()
assert not (tmp_path / "remotes").exists()


@pytest.mark.parametrize("kind", [DIRECTORY, FILE])
@pytest.mark.parametrize("cloned", [False, True], ids=["given", "cloned"])
def test_a_recorded_git_this_user_can_read_is_read_as_before(
tmp_path, cloned, kind):
"""The same plans with the `.git` as `git clone` left it: `plan ok`, as
on 4ed7c7bf5, read in place and `gh` never asked."""
clone = a_clone(tmp_path, kind)
plan = planned_locally(tmp_path, clone, cloned)
env, log = recording_gh(tmp_path / "gh-bin")

proc = run_script(ADOPT, "check", "--plan", str(plan), "--work-dir",
str(tmp_path / "check-work"), "--local-remote-dir",
str(tmp_path / "remotes"), env=env)

assert proc.returncode == 0, said(proc)
assert "plan ok" in proc.stdout
assert "source-unreadable" not in said(proc)
assert log.read_bytes() == b""


def test_a_recorded_clone_whose_git_is_refused_is_not_cloned_again(
adopter, monkeypatch, capsys, tmp_path):
"""`plan --source testorg/Thing` records its clone, and the `stat` of its
`.git/HEAD` is refused: `source-unreadable`, naming the `.git`, and no
second clone is asked for. In-process, so it runs as root and on
Windows too."""
planned = planned_from_github(adopter, monkeypatch, tmp_path)
assert len(planned.calls) == 1, planned.calls
refused_head(monkeypatch, planned.clone)

code, out, err = run(adopter, capsys, "check", "--plan",
str(planned.plan), "--work-dir",
str(tmp_path / "check-work"), "--local-remote-dir",
str(tmp_path / "remotes"))

assert code == 2, out + err
assert the_refusal(planned.clone, True) in err
assert CURES[DIRECTORY] in err
assert_no_false_answer(out + err)
assert len(planned.calls) == 1, planned.calls


@pytest.mark.parametrize("held", ["nothing", "a-git-directory", "a-git-file",
"a-git-directory-with-no-head", "a-file"])
def test_a_git_that_can_be_read_or_is_not_there_is_gits_question(
adopter, tmp_path, held):
"""`_git_refused` answers only for a `.git` the operating system refuses:
where there is none, where git can be let into it, where it holds no
`HEAD`, as a clone emptied, and where the source is a file, it leaves the
question to git, as before."""
if held == "a-git-directory":
path = a_clone(tmp_path, DIRECTORY)
elif held == "a-git-file":
path = a_clone(tmp_path, FILE)
elif held == "a-file":
path = tmp_path / PROJECT
path.write_bytes(b"not a directory\n")
else:
path = tmp_path / PROJECT
path.mkdir()
if held == "a-git-directory-with-no-head":
(path / ".git").mkdir()

assert adopter._git_refused(path) is None
Loading