Skip to content
186 changes: 150 additions & 36 deletions shape-doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,19 +255,24 @@
no section registers that submodule: the leg's entry names each under
`unregistered_submodules`, and the row says how to register it or remove it.
So it does for a submodule beside a `.gitmodules` whose sections register
other paths, and for one in a leg whose index tracks no `.gitmodules`
(#1080), where the leg's entry says which under `unregistered_beside`. There
git reads the one on this disk, untracked, or else HEAD's copy, and so does
the doctor: a submodule that copy registers is not named. A recursive clone
of the leg leaves each empty. The doctor names nothing, though `git
submodule status` counts the submodule unregistered (exit 128), in these
states: beside a `.gitmodules` the index tracks and this disk lacks, where
git reads the index's copy, `git add .gitmodules` would stage the file's
deletion, and no section can be written in it until it is restored (#1093);
for a submodule whose path is not UTF-8 or holds a control character
(`UNWRITABLE_IN_A_PLAN`); beside a `.gitmodules` that is unmerged; and
beside a `.gitmodules` git refuses, or one on this disk that is not a
regular file: a link, which git reads through, or a directory.
other paths, for one in a leg whose index tracks no `.gitmodules` (#1080),
and for one beside a `.gitmodules` the index tracks and this disk lacks
(#1093), where the leg's entry says which under `unregistered_beside`. In a
leg whose index tracks none, git reads the one on this disk, untracked, or
else HEAD's copy; beside one this disk lacks, the index's copy; and so does
the doctor: a submodule that copy registers is not named. Beside one this
disk lacks, the way out restores it first (`git restore .gitmodules`),
since `git add .gitmodules` would stage its deletion; where the index marks
that file skip-worktree, as a sparse checkout does (family.py's
`gitmodules-sparse`), the submodule is named but that first step exits 1, a
limit. A recursive clone of the leg leaves each empty. The doctor names
nothing, though `git submodule status` counts the submodule unregistered
(exit 128), in these states: for a submodule whose path is not UTF-8 or
holds a control character (`UNWRITABLE_IN_A_PLAN`); beside a `.gitmodules`
that is unmerged, while its copy on this disk is the conflict, registers
the submodule or is not there, since git reads no copy then; and beside a
`.gitmodules` git refuses, or one on this disk that is not a regular file:
a link, which git reads through, or a directory.

A SUBMODULE MOVED OR REMOVED SINCE THE ADOPTION (#525). Its row is a
person's to change, by hand, in the pull request that bumps the pins; no
Expand Down Expand Up @@ -659,19 +664,25 @@ def not_in_the_standard(detail: str) -> int:
#: The key of a leg's entry naming each submodule it holds beside a
#: `.gitmodules` that holds no section, so that nothing registers it (#914),
#: or beside one whose sections register other paths, or in a leg whose
#: index tracks none (#1080); present only on a leg that has one.
#: index tracks none (#1080), or beside one its index tracks and this disk
#: lacks (#1093); present only on a leg that has one.
UNREGISTERED_SUBMODULES = "unregistered_submodules"
#: The key of a leg's entry saying what its `unregistered_submodules` sit
#: beside where that is not a `.gitmodules` with no section (#1080): one
#: of the two values below. Absent beside one with no section, so that the
#: entry #914 gives stays as it was; present only on a leg that has one.
#: beside where that is not a `.gitmodules` on this disk with no section
#: (#1080): one of the three values below. Absent beside one with no
#: section, so that the entry #914 gives stays as it was; present only on a
#: leg that has one.
UNREGISTERED_BESIDE = "unregistered_beside"
#: A `.gitmodules` with sections, none of which registers the submodule.
BESIDE_OTHER_SECTIONS = "other_sections"
#: The leg's index tracks no `.gitmodules`, and the copy git reads instead,
#: untracked on this disk or else HEAD's, does not register the submodule,
#: or there is none.
BESIDE_NO_GITMODULES = "no_gitmodules"
#: The leg's index tracks a `.gitmodules` this disk lacks, and the index's
#: copy, which git reads then, does not register the submodule, whether it
#: holds no section or sections for other paths (#1093).
BESIDE_INDEX_COPY = "index_copy"
#: The key of a leg's entry naming each `answered_paths:` row for that leg
#: whose path is a directory of it written without its `/` (#655); present
#: only on a leg that has one.
Expand Down Expand Up @@ -8246,6 +8257,7 @@ def held_clauses(detail: dict) -> str:
unregistered_clause(legs),
other_sections_clause(legs),
no_gitmodules_clause(legs),
index_copy_clause(legs),
answers_clause(detail.get(ANSWERED_PATHS) or []),
unslashed_clause(legs),
slashed_clause(legs),
Expand Down Expand Up @@ -8324,9 +8336,10 @@ def unregistered_clause(legs: list) -> str:
the leg does not know.

ONLY THE LEGS WHOSE ENTRY GIVES NO `unregistered_beside` (#1080): a
submodule beside other sections, or in a leg whose index tracks no
`.gitmodules`, has its own clause (`other_sections_clause`,
`no_gitmodules_clause`).
submodule beside other sections, in a leg whose index tracks no
`.gitmodules`, or beside one its index tracks and this disk lacks
(#1093), has its own clause (`other_sections_clause`,
`no_gitmodules_clause`, `index_copy_clause`).
"""
held = unregistered_beside(legs, None)
if not held:
Expand Down Expand Up @@ -8403,6 +8416,59 @@ def no_gitmodules_clause(legs: list) -> str:
"<its path in the leg>` in its leg")


def index_copy_clause(legs: list) -> str:
"""The clause naming each submodule a leg holds beside a `.gitmodules`
its index tracks and this disk lacks, where the index's copy does not
register it (`unregistered_gitlinks`), or "" where there is none
(#1093).

SAID, as `unregistered_clause` is: git reads the index's copy there,
so `git submodule status` exits 128, and a recursive clone of a commit
of that index leaves the submodule empty (measured, git 2.43).

THE FILE GOES BACK FIRST. `git add .gitmodules`, typed where the file
is gone, stages its deletion and takes every other submodule's section
out of the index with it, and no section can be written in a file
that is not there. `git restore .gitmodules` puts the index's copy
back on this disk, and then each way out is typed as printed: a
section written in it gives `git submodule status` 0; `git add
.gitmodules`, which stages nothing once the file is restored, then
`git rm --cached` each exit 0, and the gitlink leaves the index while
the index's copy keeps every section it held (measured, git 2.43).
The `git add .gitmodules` step stays because a person may do both:
once a section is written for one submodule, `git rm --cached` of
another exits 128 with `please stage your changes to .gitmodules`
until the file is staged (measured).

ONE STATE TAKES MORE. Where the index's copy holds a line git refuses,
git reads it only up to that line, and once restored it is a file on
this disk that git refuses whole: `git submodule status` exits 128
with `bad config line` (measured), and the doctor names nothing, as
beside any file git refuses. The removal still holds as printed; a
section holds once that line is mended too.

AND ONE IS A LIMIT: a `.gitmodules` the index marks skip-worktree, as a
sparse checkout leaves it (`S` in `git ls-files -t`; family.py's
`gitmodules-sparse`). git reads the index's copy there too, and the
submodule is named, but `git restore .gitmodules` exits 1 (`pathspec
'.gitmodules' did not match any file(s) known to git`), as `git
checkout -- .gitmodules` does (measured, git 2.43): the clause's first
step is not true until the mark is cleared.
"""
held = unregistered_beside(legs, BESIDE_INDEX_COPY)
if not held:
return ""
return (f"{len(held)} submodule(s) sit beside a `.gitmodules` their "
"leg's index tracks and this disk lacks, whose copy in the "
"index, which git reads, has no section for them, so nothing "
"registers them and a recursive clone of the leg leaves each "
f"empty (#1093): {first_three(held)}; first restore that "
"`.gitmodules` in its leg (`git restore .gitmodules`), then give "
"each a section with its `path` and `url` in it, or, once it is "
"staged (`git add .gitmodules`), remove its gitlink with `git rm "
"--cached <its path in the leg>` in its leg")


def slashed_clause(legs: list) -> str:
"""The clause naming each `answered_paths:` row that names a file of its
leg with the `/` a directory's row ends with, or "" where none does
Expand Down Expand Up @@ -8883,39 +8949,50 @@ def unregistered_gitlinks(adopt, mount: Path, rel: str, tracked: list,
"""Each submodule `held` that no section of the leg's `.gitmodules`
registers, as its path in the row, and what it sits beside, or `([],
None)` (#1080): a `.gitmodules` whose sections register other paths
(`BESIDE_OTHER_SECTIONS`), or no `.gitmodules` in the leg's index
(`BESIDE_NO_GITMODULES`). `tracked` is every path the leg's index
lists, `paths` its files.
(`BESIDE_OTHER_SECTIONS`), no `.gitmodules` in the leg's index
(`BESIDE_NO_GITMODULES`), or one the leg's index tracks and this disk
lacks (`BESIDE_INDEX_COPY`, #1093). `tracked` is every path the leg's
index lists, `paths` its files.

WHERE THE INDEX TRACKS NO `.gitmodules`, the copy git reads instead is
read (`registered_unindexed`): the untracked file on this disk, or else
HEAD's. A submodule it registers is not named, since `git submodule
status` finds its mapping there and exits 0; one it does not, or every
one where there is no copy at all, is named beside none.

NOT WHERE THE INDEX TRACKS A `.gitmodules` THIS DISK LACKS (#1093). git
reads the index's copy there, and `git submodule status` exits 128
beside one with no section for the submodule, but this names nothing:
`git add .gitmodules` would stage the file's deletion, and no section
can be written in a file that is not there until it is restored.
WHERE THE INDEX TRACKS A `.gitmodules` THIS DISK LACKS (#1093), git
reads the index's copy, and so does this (`registered_in_the_index`):
a submodule it registers is not named, and one it does not is named
beside it, whether it holds no section or sections for other paths.
Its clause (`index_copy_clause`) has the file restored first: `git add
.gitmodules` would stage the file's deletion, and no section can be
written in a file that is not there. Not while it is unmerged: git
reads no copy then, and this names nothing.

ASKED ONLY WHERE `empty_gitmodules` NAMED NONE: beside a `.gitmodules`
that holds no section, #914's clause names each, and the entry #914
gives stays as it was.

IN EACH STATE A RECURSIVE CLONE OF THE LEG exits 0 and leaves the
submodule's directory empty, and `git submodule status` there exits 128
with `no submodule mapping found in .gitmodules` (measured, git 2.43).
A `.gitmodules` the doctor cannot read as git does (`registered_paths`)
names nothing, as `holds_no_section` calls no such file empty: one that
is unmerged, that git refuses, or that is not a regular file here (a
link, which git reads through, or a directory).
with `no submodule mapping found in .gitmodules` (measured, git 2.43),
or, where the copy holds a line git refuses, both exit 128 with `bad
config line`. A `.gitmodules` the doctor cannot read as git does
(`registered_paths`) names nothing, as `holds_no_section` calls no such
file empty: one that is unmerged, while its copy on this disk is the
conflict, registers the submodule or is not there; one that git
refuses; or one that is not a regular file here (a link, which git
reads through, or a directory).
"""
if not held:
return [], None
if adopt.GITMODULES not in tracked:
registered = registered_unindexed(adopt, mount)
beside = BESIDE_NO_GITMODULES
elif (adopt.GITMODULES in paths
and not os.path.lexists(mount / adopt.GITMODULES)):
registered = registered_in_the_index(adopt, mount)
beside = BESIDE_INDEX_COPY
else:
registered = (registered_paths(mount / adopt.GITMODULES)
if adopt.GITMODULES in paths else None)
Expand Down Expand Up @@ -8951,7 +9028,43 @@ def registered_unindexed(adopt, mount: Path) -> set | None:
head = f"HEAD:{adopt.GITMODULES}"
if git_answer(["rev-parse", "-q", "--verify", head], mount) is None:
return set()
listing = gitmodules_listing(mount, ["--blob", head], partial=True)
return registered_in_blob(mount, head)


def registered_in_the_index(adopt, mount: Path) -> set | None:
"""The paths registered by the index's copy of `.gitmodules`, in a leg
whose index tracks one and whose disk lacks it, or None where git reads
no copy, or refuses that one whole (#1093).

WHERE GIT READS IT, in the order `gitmodules_entry` reads in (#668):
with no file on this disk, git's `config_from_gitmodules` reads the
index's copy, `:.gitmodules`, before HEAD's, and reads it up to its
first bad line (`registered_in_blob`). Measured, git 2.43: `git
submodule status` exits 0 beside an index copy with a section for the
submodule, whatever HEAD's holds, and 128 beside one that holds no
section, or only sections for other paths, though HEAD's register it.

NONE WHILE THE FILE IS UNMERGED: the index holds it only at stages 1,
2 and 3, so `rev-parse --verify` finds no `:.gitmodules` and git reads
no copy at all (`is_gitmodules_unmerged`). `git submodule status`
exits 128 there, but this names nothing, as for an unmerged file whose
copy on this disk holds the conflict: reading no copy as one with no
section would name a submodule whose section the merge's result may
hold.
"""
index = f":{adopt.GITMODULES}"
if git_answer(["rev-parse", "-q", "--verify", index], mount) is None:
return None
return registered_in_blob(mount, index)


def registered_in_blob(mount: Path, blob: str) -> set | None:
"""The paths registered by the copy of `.gitmodules` at `blob`, HEAD's
or the index's, or None where git refuses it whole (#1080, #1093).
Read as `gitmodules_entry` reads a copy that is not the working tree's:
up to its first bad line, keeping every key before it (`submodule_keys`,
#733)."""
listing = gitmodules_listing(mount, ["--blob", blob], partial=True)
keys = (None if listing is None
else submodule_keys(listing, working_tree=False))
return None if keys is None else registered_by(keys)
Expand Down Expand Up @@ -9091,8 +9204,9 @@ def audit_leg(adopt, policy, patterns: list, role: str, rel: str,
registers its submodules, could never read `ok`. One that holds no
section, in a leg that holds no submodule, asks nothing at all
(`empty_gitmodules`, #655, #914). A submodule no section registers is
named, beside sections, or in a leg whose index tracks no `.gitmodules`,
too (`unregistered_gitlinks`, #1080).
named, beside sections, in a leg whose index tracks no `.gitmodules`, or
beside one its index tracks and this disk lacks, too
(`unregistered_gitlinks`, #1080, #1093).

AND `answered_paths:` IS READ FOR THE QUESTIONS AND FOR THE PATHS IN THE
WRONG LEG ALIKE (#525, #655): `answered_questions` takes from each those
Expand Down
Loading
Loading