Skip to content
Merged
157 changes: 144 additions & 13 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,20 +371,147 @@ def _move(self, name: str, path: str) -> None:
#: and nothing dies.
SUBMODULE_VALUED_KEY_RE = re.compile(
r"submodule\.(?P<name>.*)\.(?:path|url|ignore|update|branch)")
#: The keys git's submodule reader dies on when one has a VALUE it refuses,
#: under a name it does not ignore (#215); `_refused_value` says which
#: values. `ignore` with a value git does not know only warns, and `path`,
#: `url` and `branch` take any value, so none of them is here.
SUBMODULE_TYPED_KEY_RE = re.compile(
r"submodule\.(?P<name>.*)\.(?P<key>update|shallow|fetchrecursesubmodules)")
#: The values `update` may take in a `.gitmodules` (submodule.c,
#: `parse_submodule_update_type`), as git spells them, case and all.
UPDATE_STRATEGIES = ("checkout", "rebase", "merge", "none")
#: The words git reads as a boolean, in any ASCII case (config.c / parse.c,
#: `git_parse_maybe_bool_text`).
BOOLEAN_WORDS = ("true", "yes", "on", "false", "no", "off")
#: An integer as `git_parse_signed` reads one: `strtoimax(value, &end, 0)`,
#: which skips C whitespace and takes a sign and a hex, octal or decimal
#: number -- and, where the C library reads C23 binary numbers (glibc 2.38
#: or later, for one), a `0b` binary one -- and then a unit, which is all
#: that may follow.
GIT_INTEGER_RE = re.compile(
r"[ \t\n\v\f\r]*(?P<sign>[+-]?)(?P<digits>0[xX][0-9a-fA-F]+|0[bB][01]+"
r"|0[0-7]*|[1-9][0-9]*)(?P<unit>[kKmMgG]?)")
#: `get_unit_factor`, by the unit's lower case.
UNIT_FACTORS = {"": 1, "k": 1 << 10, "m": 1 << 20, "g": 1 << 30}
#: The `int` git reads a boolean's number into: 2**31 - 1 is its largest.
GIT_INT_BITS = 31
#: Who refuses a value as a boolean (`_who_refuses_a_boolean`): no git
#: reads it, or only some of the gits `execute` may run do, and why.
EVERY_GIT = "every git"
OLDER_GITS_REFUSE = ("a number git 2.49 and older refuse, and 2.50 and later "
"read")
SOME_LIBRARIES_REFUSE = ("a `0b` number, which a git reads only where its C "
"library reads C23 binary numbers (glibc 2.38 or "
"later, for one)")


def _who_refuses_a_boolean(value: str) -> str | None:
"""None where every git takes `value` as a boolean
(`git_parse_maybe_bool`): empty, which is false; one of BOOLEAN_WORDS;
or an integer `git_parse_int` takes, which is true unless it is zero.
Otherwise EVERY_GIT where no git takes it, or why only some do.

The integer must fit an `int` once its unit is applied. Where gits
differ on one, `check` refuses it with the gits that refuse it: a
refusal there costs an edit to an absurd value, and a git that refuses
it in the extraction costs both leg repositories. git 2.50 moved the
bound below zero by one (`(-max - 1) / factor`, where 2.49 and before
have `-max / factor`), so -2147483648, -2097152k, -2048m and -2g,
however spelled, are OLDER_GITS_REFUSE: measured with git 2.43, each
passed `check` and `execute` died after both leg repositories were
made. A `0b` number, which git reads only where its C library's
`strtoimax` does, is SOME_LIBRARIES_REFUSE."""
if not value or (value.isascii() and value.lower() in BOOLEAN_WORDS):
return None
number = GIT_INTEGER_RE.fullmatch(value)
if number is None:
return EVERY_GIT
digits = number["digits"]
binary = digits[1:2] in ("b", "B")
if digits[1:2] in ("x", "X"):
size = int(digits[2:], 16)
elif binary:
size = int(digits[2:], 2)
else:
size = int(digits, 8 if digits.startswith("0") else 10)
factor = UNIT_FACTORS[number["unit"].lower()]
every_git_reads = ((1 << GIT_INT_BITS) - 1) // factor
newer_gits_read = ((1 << GIT_INT_BITS) // factor if number["sign"] == "-"
else every_git_reads)
if size > newer_gits_read:
return EVERY_GIT
if binary:
return SOME_LIBRARIES_REFUSE
if size > every_git_reads:
return OLDER_GITS_REFUSE
return None


def _refused_value(key: str, value: str) -> str | None:
"""What git's submodule reader dies on, in its words with the key and
the value spelled, when `key` is one SUBMODULE_TYPED_KEY_RE names and
`value` one git refuses for it; None otherwise (#215).

git reads the CHECKED-OUT `.gitmodules` through `gitmodules_cb`
(submodule-config.c), which sets `overwrite`, so `parse_config` reads
every value, a key given twice included, and dies on the first it
refuses. Read in the source code of git 2.39.5 through 2.51.0, the
same in each but for the bound `_who_refuses_a_boolean` names, and
measured with git 2.43 over `git fast-export`:

* `update`, `parse_submodule_update_strategy` (submodule.c): one of
UPDATE_STRATEGIES, or `!` and a command, which `parse_config`
refuses in a `.gitmodules`; anything else, an empty value too, ends
it with `invalid value for 'submodule.<name>.update'`.
* `shallow`, `git_config_bool`: a value `git_parse_maybe_bool` does not
read ends it with `bad boolean config value '<value>' for '<key>'`.
* `fetchrecursesubmodules`, `parse_fetch_recurse`, which dies on an
error for the checked-out file (`die_on_error`): a boolean or
`on-demand`, exactly; anything else ends it with `bad <key>
argument: <value>`.

A number only some gits read as a boolean is refused as the gits that
refuse it say, and the finding says which gits those are."""
typed = SUBMODULE_TYPED_KEY_RE.fullmatch(key)
if typed is None or _a_name_git_ignores(typed["name"]):
return None
said = _spelled(value) if value else "an empty value"
if typed["key"] == "update":
if value in UPDATE_STRATEGIES:
return None
why = (f"a command, which git never takes from a {GITMODULES}"
if value.startswith("!") else
f"which is not one of {_listed(list(UPDATE_STRATEGIES))}")
return f"invalid value for {_spelled(key)}: {said}, {why}"
refusers = _who_refuses_a_boolean(value)
if refusers is None:
return None
if typed["key"] == "shallow":
return (f"bad boolean config value {said} for {_spelled(key)}"
+ ("" if refusers == EVERY_GIT else f", {refusers}"))
if value == "on-demand":
return None
return (f"bad {_spelled(key)} argument: {said}, "
+ ("which is neither a boolean nor `on-demand`"
if refusers == EVERY_GIT else refusers))


def _registrations(listed: bytes) -> Registrations | str:
"""`git config -z --list` output, read by `Registrations`; or, for a
key SUBMODULE_VALUED_KEY_RE names with no value at all, what git's
submodule reader dies on. `-z` prints such a key with no newline, and
git looks for its value after it has filtered the name and before it
filters the value."""
"""`git config -z --list` output, read by `Registrations`; or what git's
submodule reader dies on, at the first key in the file it dies on: one
SUBMODULE_VALUED_KEY_RE names with no value at all, or one whose value
it refuses (`_refused_value`, #215). `-z` prints a key with no value
with no newline, and git looks for its value after it has filtered the
name and before it filters the value."""
registrations = Registrations()
for record in listed.split(b"\x00"):
key, newline, value = _git_path(record).partition("\n")
valued = SUBMODULE_VALUED_KEY_RE.fullmatch(key)
if valued and not newline and not _a_name_git_ignores(valued["name"]):
return f"missing value for '{key}'"
refused = _refused_value(key, value) if newline else None
if refused:
return refused
match = SUBMODULE_KEY_RE.fullmatch(key)
if not match:
continue
Expand Down Expand Up @@ -651,9 +778,13 @@ def registered_submodules(self, tree: list) -> Registrations | str:
in `git fast-export` with `missing value for 'submodule.b.path'`,
after both leg repositories were made. Where the history holds none
either, nothing ever reads the file that way, and it registers
nothing (#166's S3). A line git cannot parse at all is another
matter: every reader dies on it, and the history says only which
reader dies first.
nothing (#166's S3). A VALUE git's submodule reader refuses --
`update = bogus`, `shallow = maybe`, `_refused_value` -- is read the
same way, wherever the gitlink is (#215): `git config --list` exits
0 on it, and on main `check` passed it while `execute` died in `git
fast-export` after both leg repositories were made. A line git
cannot parse at all is another matter: every reader dies on it, and
the history says only which reader dies first.
"""
if not any(path == GITMODULES for path, _, _, _ in tree):
return Registrations()
Expand Down Expand Up @@ -2037,11 +2168,11 @@ def _where_git_fails(entries: list, tree: list, registry_leg: str | None,
gitlink, which the clone checks out as a directory and which
registers nothing.

A key with no value is fatal to git's submodule reader alone, which git
runs only for a gitlink, so `Source.registered_submodules` reads it as
registering nothing where neither the tree nor the history `git
fast-export` diffs holds one, and it reaches here only where one of them
does.
A key with no value, or a value git refuses (#215), is fatal to git's
submodule reader alone, which git runs only for a gitlink, so
`Source.registered_submodules` reads it as registering nothing where
neither the tree nor the history `git fast-export` diffs holds one, and
it reaches here only where one of them does.
"""
a_file = any(path == GITMODULES and mode in FILE_MODES
for path, mode, _, _ in tree)
Expand Down
120 changes: 120 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
from __future__ import annotations

import os
import re
import shutil
import stat
import subprocess
import sys
from argparse import Namespace
from pathlib import Path

import pytest
Expand Down Expand Up @@ -380,3 +382,121 @@ def resolve(plan_path: Path, path: str, leg: str,
handle.write(text.replace(
needle, f" - path: {path}\n leg: {leg}\n"
f" resolution: \"{why}\"\n", 1))


# --- `.gitmodules` sources, and what `check` and `execute` say of them -----
#
# Shared by `test_adopt_history_gitlinks.py` (#195) and
# `test_adopt_gitmodules_values.py` (#215), which build sources that hold a
# submodule at GITLINK, at the tip or only in the history, beside a
# `.gitmodules` git's submodule reader may die on.

GITMODULES = ".gitmodules"
#: The submodule those sources hold, registered under the name `b`.
GITLINK = "vendor/b"


def as_human(*args: str, cwd: Path) -> subprocess.CompletedProcess:
"""`git <args>` with an identity, for the commits and merges a test
makes in a source."""
return git("-c", "user.name=Source Human",
"-c", "user.email=source@invalid.example", *args, cwd=cwd)


def put_gitmodules(source: Path, text: str | bytes) -> None:
"""`text` as the source's `.gitmodules`, staged: BYTES, so it is LF on
every platform, as a hand edit would leave it."""
(source / GITMODULES).write_bytes(
text if isinstance(text, bytes) else text.encode())
git("add", "--", GITMODULES, cwd=source)


def link(source: Path) -> None:
"""A gitlink at GITLINK, staged straight into the index, as `git add` of
an embedded clone records one; no checkout of it is needed. It names
`main`'s commit, which an orphan branch's first commit has no HEAD to
give."""
head = git("rev-parse", "main", cwd=source).stdout.strip()
git("update-index", "--add", "--cacheinfo", f"160000,{head},{GITLINK}",
cwd=source)


def named(verb: str = "(adds|removes|changes)") -> str:
"""How `_exported_gitlink` names a commit's change to the gitlink, as a
pattern: `verb` is what that commit does to it."""
return rf"commit [0-9a-f]{{12}} {verb} `vendor/b`"


def exported_in_a_clone(source: Path,
base: Path) -> subprocess.CompletedProcess:
"""`git fast-export --all` in a clone of `source`, as `git filter-repo`
runs it in the extraction's clone: the run whose failure the scan
foretells. It needs git alone, so it runs where `git filter-repo` is
not installed; bytes, because it exports the source's own content."""
clone = base / "export-clone"
git("clone", "-q", "--", str(source), str(clone), cwd=base)
return subprocess.run(["git", "fast-export", "--all"], cwd=clone,
capture_output=True, check=False)


def planned(source: Path, base: Path, answers: dict) -> Path:
"""`plan` for `source` into `base/plan/` (project Northwind), each entry
`answers` names given its leg: an entry `plan` asks about is resolved,
and any other has its `leg:` set."""
plan = base / "plan" / "adoption-plan.yaml"
plan.parent.mkdir()
written = write_plan(source, plan)
assert written.returncode == 0, written.stderr + written.stdout
text = plan.read_bytes().decode()
for path, leg in answers.items():
entry = re.search(rf" - path: {re.escape(path)}\n leg: (\S+)\n",
text)
assert entry, f"no entry {path} in the plan"
if entry[1] == "null":
resolve(plan, path, leg)
text = plan.read_bytes().decode()
else:
text = text.replace(entry[0],
f" - path: {path}\n leg: {leg}\n", 1)
plan.write_bytes(text.encode())
return plan


def findings(plan: Path) -> list[tuple[str, str]]:
"""`check`'s findings on `plan`, as `(code, detail)`; its exit is 1 when
there is one and 0 when there is none."""
result = run_script(ADOPT, "check", "--plan", str(plan))
found = [tuple(line.split(" ", 1)[1].split(": ", 1))
for line in result.stderr.splitlines()
if line.startswith("FINDING ")]
assert result.returncode == (1 if found else 0), result.stderr
return found


def in_the_history(adopter, verb: str = "removes") -> re.Pattern:
"""How the finding ends for a gitlink only the history holds: the
commit `git log` meets first, which `verb`s it."""
return re.compile(re.escape(adopter.FAILS_IN_THE_HISTORY).replace(
re.escape("{}"), named(verb)) + "$")


def refused_before_any_leg(adopter, monkeypatch: pytest.MonkeyPatch,
plan: Path, base: Path):
"""`execute --yes` on `plan`, in this process, its probe for `git
filter-repo` stubbed, because the refusal under test comes before the
tool would be used. The refusal must be `check`'s findings, raised by
`_refuse_what_check_finds` before `_create_leg_remotes`, so the
directory the legs go in is never made; it is returned."""
monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None)
found = findings(plan)
remotes = base / "remotes"
args = Namespace(plan=str(plan), source=None, local_remote_dir=remotes,
allow_empty_leg=[], yes=True, work_dir=base / "work")
with pytest.raises(adopter.Refusal) as caught:
adopter.cmd_execute(args)
refusal = caught.value
assert refusal.detail.splitlines()[1:] == [
f" FINDING {code}: {detail}" for code, detail in found]
assert "no leg repository was created" in refusal.remediation
assert not remotes.exists()
return refusal
Loading
Loading