Skip to content
Merged
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
387 changes: 361 additions & 26 deletions adopt-project.py

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,8 +585,15 @@ options:
given (the TEST path): each relative submodule url the
plan keeps in a leg is resolved from the bare
repository `execute` would create there, as that run
resolves it, and not from GitHub's. Nothing is created
there.
resolves it, and not from GitHub's, and `check` asks,
as `execute` asks before it makes either leg, whether
each leg's bare repository is already there and
whether this directory can be made and written in.
Nothing is created there. Without it, `check` asks
GitHub with `gh repo view --json nameWithOwner`
whether either leg's name exists (a name GitHub only
redirects to a repository renamed or moved away does
not), and a `gh` that cannot answer is a NOTE.
--work-dir WORK_DIR where `check` works: an `org/repo` --source, or the
plan's `source.repository` where the clone `plan` made
is gone or no longer whole, is cloned there. Give the
Expand Down Expand Up @@ -626,7 +633,11 @@ options:
inside a repository, as `source-not-top-level`.
--local-remote-dir LOCAL_REMOTE_DIR
create the legs as bare repositories here instead of
calling `gh` (the TEST path)
calling `gh` (the TEST path). Both are asked about
before either is made: one already there, or a
directory that cannot be made and written in, is
refused, as `gh repo view` finding either leg's name
is without it.
--allow-empty-leg {spec,code}
proceed even though NO path is assigned to this leg,
seeding it from the shape's template. Unioned with the
Expand Down
33 changes: 23 additions & 10 deletions scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -1539,9 +1539,10 @@ def _not_a_folder(path: Path) -> str | None:
else "is not a folder")


def folder_cannot_be_made(path: Path) -> str | None:
"""Why `path` cannot be made as a folder, with every folder above it, and
written in, or None when it can (#609). It makes nothing.
def folder_in_the_way(path: Path) -> tuple[Path, str] | None:
"""`(where, why)` when `path` cannot be made as a folder, with every
folder above it, and written in: the one that stands in the way, and
why. None when it can (#609). It makes nothing.

The question `Path.mkdir(parents=True, exist_ok=True)` and the first
write into the folder ask, put without making anything: the nearest of
Expand All @@ -1551,24 +1552,36 @@ def folder_cannot_be_made(path: Path) -> str | None:
says yes for root, and for any folder on Windows, as `mkdir` would). A
`path` nothing stands at is asked of the folder above it.

THE FOLDER IS NAMED AS `shown_source` NAMES A PATH (#692): as it is, and
as Python's `repr` spells it when a character in it is not printable.
Printed raw, an ESC in a folder's name reached the terminal and a line
break began a line of the person's choosing. A printable path is named
byte for byte, so `why.endswith(CANNOT_WRITE_IN)` still holds.
`folder_cannot_be_made` says it as one sentence, naming the folder as
`shown_source` names a path (#692). `adopt-project.py` asks it of
`--local-remote-dir` (#798), and names `where` its own way.
"""
for here in (path, *path.parents):
if not os.path.lexists(here):
continue
why = _not_a_folder(here)
if why:
return f"{shown_source(here)} {why}"
return here, why
if not os.access(here, os.W_OK | os.X_OK):
return f"{shown_source(here)} {CANNOT_WRITE_IN}"
return here, CANNOT_WRITE_IN
return None
return None


def folder_cannot_be_made(path: Path) -> str | None:
"""Why `path` cannot be made as a folder, with every folder above it, and
written in, or None when it can (#609): `folder_in_the_way`'s answer, as
`<where> <why>`. It makes nothing.

THE FOLDER IS NAMED AS `shown_source` NAMES A PATH (#692): as it is, and
as Python's `repr` spells it when a character in it is not printable.
Printed raw, an ESC in a folder's name reached the terminal and a line
break began a line of the person's choosing. A printable path is named
byte for byte, so `why.endswith(CANNOT_WRITE_IN)` still holds."""
found = folder_in_the_way(path)
return f"{shown_source(found[0])} {found[1]}" if found else None


def _unwritable_remediation(choose: str) -> str:
"""What a refusal of a folder this user cannot write in says to do, for
a folder to make and for a working tree alike: give this user write and
Expand Down
65 changes: 57 additions & 8 deletions tests/gh_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@
status`. Any other argv -- `gh version`, `gh auth status --hostname x` -- is
not a probe and is refused.

ONE MORE CALL IS ANSWERED (#798): `gh repo view <owner>/<name> --json
nameWithOwner`, which `adopt-project.py check` asks of each leg repository it
would create, as `execute` does before it makes either. Every `check` in this
suite that is given no `--local-remote-dir` asks it twice, and refused, each
such test would fail. It is answered as a `gh` with no login answers it, the
state the other two answers describe: exit 4 and gh's own words
(`NEEDS_A_LOGIN`), so `check` prints, on every machine, the NOTE that it
could not ask, and still passes. It is logged as a probe. Only that exact
shape is answered: `--json name`, `--json visibility` or any other field,
and any other `repo view`, are refused. SO IT ANSWERS ADOPT'S CALL ALONE:
`scaffold-project.py` and `family.py` ask whether their own repositories
exist with `--json name`, so a test of either that reaches gh with no stub
of its own is still failed at teardown (`tests/test_gh_guard.py`).

STANDARD LIBRARY ONLY, and importable: `tests/conftest.py` and
`tests/test_gh_guard.py` read the same table, the same record format and the
same decision from here, so the guard and the check cannot disagree about
Expand All @@ -46,6 +60,7 @@

import json
import os
import re
import shlex
import sys
from pathlib import Path
Expand Down Expand Up @@ -77,6 +92,36 @@
("auth", "status"): (1, "", NOT_LOGGED_IN + "\n"),
}

#: gh's own words, on stderr with exit 4, for a command that needs a login on
#: a machine that has none (gh 2.x, outside CI): how `ASKS_WHETHER_IT_EXISTS`
#: is answered (#798).
NEEDS_A_LOGIN = (
"To get started with GitHub CLI, please run: gh auth login\n"
"Alternatively, populate the GH_TOKEN environment variable with a GitHub "
"API authentication token.")
NO_LOGIN_EXIT = 4

#: `repo view <owner>/<name> --json nameWithOwner`: one owner and one name, as
#: a plan names them (an `org:` GitHub would refuse, `a+b`, included), with no
#: space or further `/`, and no option.
ASKS_WHETHER_IT_EXISTS = re.compile(r"[^\s/-][^\s/]*/[^\s/]+")


def answer(argv) -> tuple | None:
"""`(exit status, stdout, stderr)` for a call the guard answers: one of
the two `PROBES`, or whether a repository exists, as a `gh` with no
login answers it (#798). None for anything else, which is refused."""
argv = list(argv)
probe = PROBES.get(tuple(argv))
if probe is not None:
return probe
if (len(argv) == 5 and argv[:2] == ["repo", "view"]
and argv[3:] == ["--json", "nameWithOwner"]
and ASKS_WHETHER_IT_EXISTS.fullmatch(argv[2])):
return NO_LOGIN_EXIT, "", NEEDS_A_LOGIN + "\n"
return None


#: The refusal, ONE line on stderr. `{argv}` is the call as `shown` spells it.
REFUSAL = ("gh guard (tests/conftest.py): no test may run the operator's gh, "
"so `gh {argv}` was refused and logged; a test that means to run "
Expand All @@ -88,8 +133,10 @@


def is_probe(argv) -> bool:
"""Whether `argv` is one of the two `PROBES`, exactly."""
return tuple(argv) in PROBES
"""Whether `argv` is a call the guard answers (`answer`): one of the two
`PROBES`, exactly, or `repo view <owner>/<name> --json nameWithOwner`.
"""
return answer(argv) is not None


def shown(argv) -> str:
Expand Down Expand Up @@ -133,9 +180,11 @@ def failure(lines) -> str | None:
if not named:
return None
return ("tests/conftest.py's gh guard logged "
f"{len(named)} line(s) that are not the machine row's two probes "
"-- calls to gh made with no stub of the test's own ahead of the "
"guard on PATH, each refused:\n" + "\n".join(named) + "\n"
f"{len(named)} line(s) that are not a call it answers (the "
"machine row's two probes, and `repo view <owner>/<name> --json "
"nameWithOwner`) -- calls to gh made with no stub of the test's "
"own ahead of the guard on PATH, each refused:\n"
+ "\n".join(named) + "\n"
"No test may reach the operator's gh. A test that means to run "
"gh puts a stub of its own first on PATH, as "
"tests/test_neutral_pin_validator.py does.")
Expand All @@ -147,9 +196,9 @@ def main(argv) -> int:
with open(log, "a", encoding="utf-8", newline="\n") as handle:
handle.write(record(argv, os.environ.get("PYTEST_CURRENT_TEST", ""))
+ "\n")
answer = PROBES.get(tuple(argv))
if answer is not None:
code, out, err = answer
answered = answer(argv)
if answered is not None:
code, out, err = answered
sys.stdout.write(out)
sys.stderr.write(err)
return code
Expand Down
Loading
Loading