diff --git a/templates/family-root/scripts/bootstrap.py b/templates/family-root/scripts/bootstrap.py
index c9db04cf..70e30d6f 100755
--- a/templates/family-root/scripts/bootstrap.py
+++ b/templates/family-root/scripts/bootstrap.py
@@ -30,6 +30,20 @@
no url for, nor where it read nothing from `.gitmodules`, both below,
since it fetched nothing then (#839).
+ Where every row's member is checked out and git's init in this
+ holder died all the same, at a gitlink this holder records with no
+ url or at a `.gitmodules` a merge left unmerged, git updated none of
+ them, so the run fails, saying where git stopped and what cures it,
+ as below, with no advice (#879). It said `family bootstrap ok`
+ there, beside git's exit 128, as a run with every member checked
+ out passes (#648), and gave the advice where git stopped at a
+ gitlink. This holder's records are the test, so an exit 128 they do
+ not show is read as before, the advice and `ok` where every member
+ is checked out: git stopped inside a member, at a gitlink in that
+ member's own index with no url, or at a gitlink of this holder's at
+ a commit no remote holds (`not our ref`) (lane 1's SHOULD 4 on
+ #889).
+
"every member and leg fetched" is said only where git exited 0 AND
every row's member is checked out (#648). A member left NOT CHECKED
OUT where no credential would check it out is said here instead, with
@@ -66,8 +80,8 @@
checks the member out whatever the clone chose. For the third and
the last, `validate-family.py --no-members` names a path two
sections give, or a gitlink in members/ that no row names; `git rm
- --cached` takes out one outside members/, which the validator does
- not read; and where the gitlink is a row's own, with one section,
+ --cached` takes out one outside members/, as every mode of the
+ validator says (#878); and where it is a row's own, with one section,
the line says to write its section back. Where git read the index's
or HEAD's file only up to a line it cannot parse, or up to a key it
takes no value for (a bare `url` line, which `git config --list`
@@ -95,9 +109,14 @@
(c) EXITS 0 unless a member's own command failed, or (a) left a member not
checked out that no credential would check out, or found a line or a
- value in `.gitmodules` that git cannot read (#768). Any other member that
- is not checked out is REPORTED and skipped, not a failure: it is the
- state a missing credential leaves, and it is already reported in (a).
+ value in `.gitmodules` that git cannot read (#768), or found that,
+ with every member checked out, git's init in this holder died at a
+ gitlink this holder records with no url or at an unmerged
+ `.gitmodules` (#879); an exit 128 this holder's records do not show
+ is no failure here, as (a) says. Any
+ other member that is not checked out is REPORTED and skipped, not a
+ failure: it is the state a missing credential leaves, and it is
+ already reported in (a).
Where (a) cannot start git, the run STOPS there, exit 2, refused
`git-not-runnable` as `validate-family.py` refuses it (#694): each
member's own bootstrap runs git too, so (b) would only say it again.
@@ -144,6 +163,10 @@
NO_URL = "git has no url for that path"
EXITED_0 = "git exited 0 and checked nothing out there"
STOPPED = "git stopped at {}, a gitlink it has no url for"
+#: What (a)'s line says where git updated no member, by whether `members:`
+#: names any row: under "0 member(s)" it said no member was updated (lane
+#: 1's NIT 5 on #889).
+NONE_UPDATED = {True: "NO MEMBER UPDATED", False: "NOTHING UPDATED"}
#: The whys a `.gitmodules` git read only up to a line it cannot parse, or
#: up to a key with no value it stops at, may be the cause of, since what
#: it did not read is past that line (`Gitmodules.cut`, #856).
@@ -203,9 +226,13 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]:
"""(a) `git submodule update --init --recursive`, authenticated or not.
Where every member is checked out and git died on a value in
- `.gitmodules`, or on a work tree file it cannot parse, so it updated
- none of them, the one failure line it returns is for `.gitmodules`
- (#768).
+ `.gitmodules`, or on a work tree file it cannot parse, or its init
+ died at a `.gitmodules` a merge left unmerged, so it updated none of
+ them, the one failure line it returns is for `.gitmodules` (#768,
+ #879); where its init in this holder died at a gitlink this holder
+ records with no url, it is for that gitlink (`updated_none`, #879).
+ Its line says no member was updated, or, where `rows` names none,
+ nothing was (lane 1's NIT 5 on #889).
Returns a failure line for each of `rows` whose member it leaves not
checked out where no credential would check it out (`unfetchable`), for
@@ -241,16 +268,19 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]:
+ ", ".join(f"{spelled(path)} ({reason(why, stopper, modules)})"
for _, path, why in left)
+ ". " + cures(left, stopper, rows, modules), file=sys.stderr)
- elif modules.refused or modules.unreadable:
- why = REFUSED if modules.refused else UNREADABLE
- print(" NO MEMBER UPDATED, and not for want of a credential: "
- + reason(why, stopper, modules) + ". "
- + cures(left, stopper, rows, modules), file=sys.stderr)
- return [f"{GITMODULES} (git cannot read it)"]
- elif proc.returncode == 0:
+ return [f"{spelled(row.get('project') or '?')} (not checked out)"
+ for row, _, _ in left]
+ whole = updated_none(stopper, modules)
+ if whole is not None:
+ print(f" {NONE_UPDATED[bool(rows)]}, and not for want of a "
+ "credential: " + reason(whole, stopper, modules) + ". "
+ + cures(left, stopper, rows, modules, (whole,)),
+ file=sys.stderr)
+ return [f"{spelled(stopper)} (git stopped at it)" if whole == STOPPED
+ else f"{GITMODULES} (git cannot read it)"]
+ if proc.returncode == 0:
print(" every member and leg fetched")
- return [f"{spelled(row.get('project') or '?')} (not checked out)"
- for row, _, _ in left]
+ return []
def fetch_incomplete(proc: subprocess.CompletedProcess, source: str,
@@ -317,9 +347,12 @@ def git_not_started(exc: OSError) -> Refusal | None:
def cures(left: list[tuple[dict, str, str]], stopper: str | None,
- rows: list[dict], modules: Gitmodules) -> str:
+ rows: list[dict], modules: Gitmodules,
+ whole: tuple[str, ...] = ()) -> str:
"""What (a)'s line says cures the rows `left` names, one sentence for
- each kind of cause among them (#648):
+ each kind of cause among them (#648), or, where it names none, what
+ cures `whole`, why git updated no member (`updated_none`, #879), one
+ reason or none:
- A `.gitmodules` a merge left unmerged in the index, which git reads
nothing from (`Gitmodules.unmerged`): the file resolved and added.
@@ -344,9 +377,9 @@ def cures(left: list[tuple[dict, str, str]], stopper: str | None,
`member-uninitialized` for the first row where every row's gitlink
is there, as with a stray or a hand mount, and `member-gitlink-absent`
where a row's is only in another case, which the first sentence
- names. A gitlink outside `members/`, which no mode of the validator
- asks about, is taken out of the index: no row names it, and with no
- url no clone can check it out.
+ names. A gitlink outside `members/` is taken out of the index, as
+ every mode of the validator says (#878): no row names it, and with
+ no url no clone can check it out.
- A row git exited 0 over: the update that checks it out whatever the
clone chose. `--init` with the path registers a member a subset
clone left out, and `--checkout` overrides an `update = none`.
@@ -359,7 +392,7 @@ def cures(left: list[tuple[dict, str, str]], stopper: str | None,
for those: a section written back where the work tree holds no file
would be the only one in it, and `validate-family.py` reads HEAD's
file alone, not the index's."""
- whys = {why for _, _, why in left}
+ whys = {why for _, _, why in left} | set(whole)
said = gitmodules_cures(whys, modules)
if modules.cut:
left = [entry for entry in left if entry[2] not in CUT_WHYS]
@@ -879,7 +912,10 @@ def unfetchable(root: Path, rows: list[dict], exited_0: bool
checked out is the missing credential's, and stays the skip it always
was. A path two rows give is said once. Where every row's member is
checked out and git did not exit 0, `.gitmodules` is read all the
- same, for `fetch_members` to ask `refused` of."""
+ same, and git's init asked where it stopped, for `fetch_members` to
+ ask why git updated none (`updated_none`): this returned before
+ `init_stop` there, so a run whose update git's init stopped said
+ `family bootstrap ok` (#879)."""
left: dict[str, dict] = {}
for row in rows:
if not (member_path(root, row) / ".git").exists():
@@ -887,8 +923,6 @@ def unfetchable(root: Path, rows: list[dict], exited_0: bool
if not left and exited_0:
return [], None, Gitmodules(Counter(), {}, {})
modules = gitmodules_sections(root)
- if not left:
- return [], None, modules
config = submodule_config(root)
stopper = None if exited_0 else init_stop(root, modules, config)
found: list[tuple[dict, str, str]] = []
@@ -905,12 +939,9 @@ def row_reason(root: Path, path: str, modules: Gitmodules,
"""Why `unfetchable` says the member at `path`, not checked out, is
none a credential would check out (#648, #768), in the order it asks,
or None where it is the missing credential's."""
- if modules.unmerged:
- return UNMERGED
- if modules.refused:
- return REFUSED
- if modules.unreadable:
- return UNREADABLE
+ unread = file_reason(modules)
+ if unread is not None:
+ return unread
if recorded_gitlink(root, path) is None:
return NO_GITLINK
if not modules.given[path]:
@@ -922,6 +953,30 @@ def row_reason(root: Path, path: str, modules: Gitmodules,
return None if stopper is None else STOPPED
+def file_reason(modules: Gitmodules) -> str | None:
+ """Why git read nothing from `.gitmodules` (#648, #768), as (a)'s line
+ says it, or None where it read the file: a merge left it unmerged, it
+ gives a value git dies on, or it is a work tree file git cannot
+ parse."""
+ if modules.unmerged:
+ return UNMERGED
+ if modules.refused:
+ return REFUSED
+ return UNREADABLE if modules.unreadable else None
+
+
+def updated_none(stopper: str | None, modules: Gitmodules) -> str | None:
+ """Why git updated no member, where every row's member is checked out
+ and git did not exit 0, or None: what `file_reason` says, or `STOPPED`
+ where git's init in this holder died at a gitlink this holder records
+ with no url (`stopper`, `init_stop`), before it updated any (#879).
+ None where it asked for a credential, as a leg's fetch does.
+ `fetch_members` asks it only where `unfetchable` left no row, whose
+ line says why of each, so it asks nothing of the rows (lane 1's NIT 4
+ on #889: a check of them here decided nothing)."""
+ return file_reason(modules) or (None if stopper is None else STOPPED)
+
+
def members_of(root: Path) -> tuple[str, list[dict]]:
manifest_path = root / MANIFEST
if not manifest_path.is_file():
diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py
index ace9c3ec..cd5dd1a5 100755
--- a/templates/family-root/scripts/validate-family.py
+++ b/templates/family-root/scripts/validate-family.py
@@ -125,6 +125,19 @@
`contracts/shape-pin.yaml`. Editing a copy in place is drift and the exit
is to carry it upstream, never to update the digest.
+EVERY REMEDY IS TYPED AS PRINTED, IN THE ORDER PRINTED (#879). Where the
+work tree's `.gitmodules` is gone from disk, the first remedy that writes
+it puts it back first, from where git reads it, but for a
+`gitmodules-value-refused` finding, which puts back HEAD's, whose value
+its lines take out (#768), and no later one puts it back over what the
+lines between wrote: a staged removal's finding after one that writes
+the file, or says to commit it, writes its section back key by key and
+stages the file, on disk or not. A `git rm` of a gitlink comes after
+`git add -- .gitmodules` where lines printed before it leave that file
+with changes not staged; and a checkout `git mv` moved off a row's path,
+whose `.git` file names that member's store, is named, with its `git -C`
+status, before the update that points the store at the row's path.
+
EXIT CODES
0 the manifest is valid, every member is in lockstep, every copy matches
1 a FINDING: drift a reviewer can act on
@@ -261,6 +274,17 @@ def __init__(self) -> None:
self.notes: list[str] = []
#: Each finding's code, in step with `findings` (#539).
self.codes: list[str] = []
+ #: Where the work tree's `.gitmodules` is gone from disk, git's name
+ #: for the file an earlier finding's line put back there,
+ #: `:.gitmodules` or `HEAD:.gitmodules`, so that no later finding
+ #: puts it back over what the lines between wrote (#879); empty
+ #: until one has.
+ self.put_back = ""
+ #: Whether a finding printed before says to write the work tree's
+ #: `.gitmodules`, or to commit it as it stands, so that no later
+ #: finding puts HEAD's file back over that (`_section_back`, lane
+ #: 1's MUST 1 and SHOULD 1 on #889).
+ self.wrote = False
def finding(self, code: str, detail: str) -> None:
self.findings.append(f"FINDING {code}: {detail}")
@@ -548,16 +572,26 @@ def _gitlink_put_back(root: Path, path: str, commit: str,
as `member-gitmodules-absent` writes one (`_section_lines`); and a
member that is to go has its row taken out by hand. Either is
committed, and a member put back is checked out as
- `member-uninitialized` says.
+ `member-uninitialized` says. Where the work tree's `.gitmodules` is
+ gone from disk, the section's lines come after the one that puts the
+ file back from where git reads it (`_file_put_back`): `git config -f`
+ made a file of that one section, which staged and committed dropped
+ every other (#879).
"""
head = _head_gitmodules(root)
sections = head[1] if head is not None else {}
given = {keys["path"] for keys in sections.values() if "path" in keys}
lines = ("" if path in given
else _section_lines(path, repository, sections))
+ put_back, whose = _file_put_back(root, None) if lines else ("", "")
written = (", and its .gitmodules section back by hand, in a POSIX "
- "shell, as family.py add writes one, with the lines after it, "
- "then stage .gitmodules" if lines else "")
+ "shell, as family.py add writes one, with the lines after it"
+ + (f", the first of which puts {GITMODULES}, gone from the "
+ f"work tree, back from {whose}, so that every other "
+ "section stays" if put_back else "")
+ + ", then stage .gitmodules" if lines else "")
+ if put_back:
+ lines = f"\n {put_back}{lines}"
return (
"Remediation: family.py add refuses the row, which is there "
"(member-already-present), and family.py remove cannot take the "
@@ -629,7 +663,14 @@ def _gitlink_in_another_case(root: Path, path: str, commit: str,
"Remediation: the member is recorded in the wrong case. "
+ _wrong_case_move(path, commit, variants,
_move_context(root, path, variants, repository,
- gitlinks)))
+ gitlinks, None)))
+
+
+#: What the `-f` sentence says is on disk at a gitlink's path (`_held_as`):
+#: a checkout, as #839 said, or what is there, which is none (rv882's NIT 2
+#: on #882).
+CHECKOUT = "a checkout"
+NO_CHECKOUT = "what is on disk there, which is no checkout"
class MoveContext(NamedTuple):
@@ -639,21 +680,29 @@ class MoveContext(NamedTuple):
or under the row's path; and `section`, the lines that make
`.gitmodules` give the row's path, with what they do, or empty where a
section of HEAD's already gives it. `forced`, the variants whose `git
- rm --cached` takes `-f` (`_forced`, #839)."""
+ rm --cached` takes `-f` (`_forced`, #839), and `forced_over`, what is
+ on disk at them, as the `-f` sentence says it (`_forced_over`, rv882's
+ NIT 2 on #882, and lane 1's NIT 3 on #889). `stage`, whether `git add
+ -- .gitmodules` comes first (`_stage_first`, #879)."""
sparse: tuple[str, ...] = ()
in_the_way: tuple[str, ...] = ()
section: str = ""
section_said: str = ""
forced: tuple[str, ...] = ()
+ stage: bool = False
+ forced_over: str = CHECKOUT
def _move_context(root: Path, path: str, variants: list[str],
- repository, gitlinks: dict[str, str]) -> MoveContext:
+ repository, gitlinks: dict[str, str],
+ report: Report | None) -> MoveContext:
"""What the index and HEAD's `.gitmodules` hold around the move of a
member recorded only at `variants` to its row's `path` (#762), and
which variants the move's `git rm --cached` takes out only with `-f`
- (`_forced`, by `gitlinks`, `_member_gitlinks`, #839).
+ (`_forced`, by `gitlinks`, `_member_gitlinks`, #839). `report` holds
+ the findings printed before the move, or is None for members mode's
+ refusal, which is printed alone (#879).
`--sparse` (the issue's (f)): a variant a sparse checkout keeps off
disk (`git ls-files -t`'s `S`, as `family.py`'s `_skip_worktree` reads
@@ -684,7 +733,47 @@ def _move_context(root: Path, path: str, variants: list[str],
first variant's where two have one; and where none gives a variant's
either, the section is written as `family.py add` writes one
(`_section_lines`). Then `git add` stages the file.
+
+ WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, the section's
+ lines come after the one that puts it back from where git reads it,
+ unless a finding printed before the move did (`_file_put_back`): `git
+ config -f` made a file of that one key, which the move's `git add`
+ staged, so the commit dropped every other section, and the put-back of
+ a `member-gitmodules-url-absent` after it restored that staged file
+ (#879's C1, rv876's P5b and P5e).
+
+ `git add -- .gitmodules` COMES FIRST where a value finding's lines,
+ printed before it, may leave that file with changes not staged, or it
+ holds some as it stands (`_stage_first`): git takes no gitlink out of
+ the index then, and the move's `git rm` exited 128 (#879's C3).
"""
+ sparse, in_the_way = _index_around(root, path, variants)
+ section, said = _move_section(root, path, variants, repository, report)
+ edited = report is not None and "gitmodules-value-refused" in report.codes
+ forced = _forced_variants(root, variants, gitlinks)
+ return MoveContext(sparse, in_the_way, section, said, forced,
+ _stage_first(root, edited), _forced_over(root, forced))
+
+
+def _forced_over(root: Path, forced: tuple[str, ...]) -> str:
+ """What the `-f` sentence says is on disk at the variants `forced`
+ (`_held_as`): `CHECKOUT` or `NO_CHECKOUT` where every one holds the
+ same, and each by its path where they differ, since one said for all
+ called a checkout no checkout (lane 1's NIT 3 on #889)."""
+ held = {variant: _held_as(root, variant) for variant in forced}
+ if len(set(held.values())) < 2:
+ return next(iter(held.values()), CHECKOUT)
+ return ", and over ".join(
+ f"a checkout at {variant}" if what == CHECKOUT
+ else f"what is on disk at {variant}, which is no checkout"
+ for variant, what in held.items())
+
+
+def _index_around(root: Path, path: str, variants: list[str]
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
+ """`(the variants a sparse checkout keeps off disk, every entry the
+ index holds at or under path but those a merge left unmerged)`, from
+ `git ls-files -t`, for `_move_context` (#762's (f) and (g))."""
with _git_runs(f"the index entries at and around {path} could not be "
"listed"):
listed = git_out(["ls-files", "-t", "-z"], cwd=root, binary=True)
@@ -696,29 +785,47 @@ def _move_context(root: Path, path: str, variants: list[str],
sparse.add(name)
if tag != b"M" and (name == path or name.startswith(path + "/")):
in_the_way[name] = None
+ return tuple(sorted(sparse)), tuple(in_the_way)
+
+
+def _move_section(root: Path, path: str, variants: list[str], repository,
+ report: Report | None) -> tuple[str, str]:
+ """`(the lines that make .gitmodules give path, what they do)` for the
+ wrong-case move (`_move_context`, #762's (e)), or two empty strings
+ where a section of HEAD's gives it already: the section of a variant's
+ path given the row's, or one written as `family.py add` writes it,
+ after the line that puts the file back where it is gone from the work
+ tree and no finding before put it back (`_file_put_back`, #879's C1),
+ then `git add`. Where `report` holds the findings printed before it,
+ the write is recorded there (`Report.wrote`), so no finding after it
+ puts HEAD's file back over it (lane 1's MUST 1 on #889, its P5)."""
head = _head_gitmodules(root)
sections = head[1] if head is not None else {}
given = {keys["path"]: name for name, keys in sections.items()
if "path" in keys}
- section = said = ""
- if path not in given:
- named = next((given[variant] for variant in variants
- if variant in given), None)
- if named is not None:
- section = (f"\n git config -f {GITMODULES} "
- f"{shlex.quote(f'submodule.{named}.path')} "
- f"{shlex.quote(path)}")
- said = (f"give {path} back to the section of HEAD's "
- f"{GITMODULES} that gives the variant's path, as git mv "
- "leaves it")
- else:
- section = _section_lines(path, repository, sections)
- said = (f"write its section as family.py add writes one, since "
- f"no section of HEAD's {GITMODULES} gives {path} or the "
- "variant's path")
- section += f"\n git add -- {GITMODULES}"
- return MoveContext(tuple(sorted(sparse)), tuple(in_the_way), section,
- said, _forced_variants(root, variants, gitlinks))
+ if path in given:
+ return "", ""
+ named = next((given[variant] for variant in variants
+ if variant in given), None)
+ if named is not None:
+ section = (f"\n git config -f {GITMODULES} "
+ f"{shlex.quote(f'submodule.{named}.path')} "
+ f"{shlex.quote(path)}")
+ said = (f"give {path} back to the section of HEAD's {GITMODULES} "
+ "that gives the variant's path, as git mv leaves it")
+ else:
+ section = _section_lines(path, repository, sections)
+ said = (f"write its section as family.py add writes one, since no "
+ f"section of HEAD's {GITMODULES} gives {path} or the "
+ "variant's path")
+ put_back, whose = _file_put_back(root, report)
+ if put_back:
+ section = f"\n {put_back}{section}"
+ said = (f"put {GITMODULES}, gone from the work tree, back from "
+ f"{whose}, so that every other section stays, then {said}")
+ if report is not None:
+ report.wrote = True
+ return f"{section}\n git add -- {GITMODULES}", said
def _forced_variants(root: Path, variants: list[str],
@@ -734,23 +841,78 @@ def _forced(root: Path, path: str, commit: str) -> str | None:
`_member_gitlinks` reads at `commit`, out of the index as it is, else
what HEAD records at `path`, its commit or `""` for nothing, where that
takes `-f` (#839): the index stages the gitlink at a commit HEAD does
- not record there, and a checkout is on disk at it, a `.git` entry. Git
- refuses it then wherever that checkout has moved on or holds a file it
- does not track (`the following file has staged content different from
- both the file and the HEAD`, exit 1), measured on git 2.43 after `git
- mv members/
Members/
`, staged, and a commit or an untracked file
- in the checkout it moved (w761's candidate 2 on #828), and `-f`, with
- `--cached`, touches no file. A gitlink HEAD alone records, at a staged
- removal or a conflict, is read at HEAD's commit and takes none."""
- if not os.path.lexists(root / path / ".git"):
+ not record there, and something is on disk at it (`_on_disk`). Git
+ refuses it then wherever a checkout there has moved on or holds a file
+ it does not track (`the following file has staged content different
+ from both the file and the HEAD`, exit 1), measured on git 2.43 after
+ `git mv members/
Members/
`, staged, and a commit or an untracked
+ file in the checkout it moved (w761's candidate 2 on #828), and `-f`,
+ with `--cached`, touches no file. It refuses it too over a directory
+ that holds files and no `.git`, a file or a symbolic link, and takes it
+ over an empty directory or nothing (git 2.43, rv882's NIT 2 on #882).
+ A gitlink HEAD alone records, at a staged removal or a conflict, is
+ read at HEAD's commit and takes none."""
+ if not _on_disk(root, path):
return None
+ head = _head_records(root, path)
+ return None if head == commit else head
+
+
+def _head_records(root: Path, path: str) -> str:
+ """The commit HEAD records at `path`, lower case, or `""` for nothing
+ (`_forced`, `_staged_over`)."""
with _git_runs(f"the gitlink HEAD records at {path} could not be read"):
try:
- head = git_out(["rev-parse", "--verify", "--quiet",
+ return git_out(["rev-parse", "--verify", "--quiet",
f"HEAD:{path}"], cwd=root).lower()
except Refusal:
- head = ""
- return None if head == commit else head
+ return ""
+
+
+def _staged_over(root: Path, path: str, commit: str) -> bool:
+ """Whether a plain `git rm -- ` refuses the gitlink at `path`,
+ which `_member_gitlinks` reads at `commit`, as staged (`STAGED_AT`):
+ the index stages it at a commit HEAD does not record there, and
+ anything is on disk at it, an empty directory too, which `git rm
+ --cached` takes with no `-f` (`_forced`). Measured on git 2.43: with
+ nothing on disk it took one staged and not committed; it refused one,
+ exit 1, saying `the following file has changes staged in the index`
+ where what is on disk matches what is staged, an empty directory, the
+ clean checkout `git mv members/ members/
2` moved, or a checkout
+ moved to `HEAD~1` and `git add`-ed, where HEAD records another commit,
+ and `... has staged content different from both the file and the HEAD`
+ where it differs too, a commit or an untracked file in the checkout
+ `git mv` moved, a directory holding a file, or a file (lane 1's SHOULD
+ on #889, round 2, and its NITs 1 and 2, round 3)."""
+ return (os.path.lexists(root / path)
+ and _head_records(root, path) != commit)
+
+
+def _on_disk(root: Path, path: str) -> bool:
+ """Whether anything but an empty directory is on disk at `path`, where
+ a staged gitlink's `git rm --cached` takes `-f` (`_forced`): git 2.43
+ refused it, with no `-f`, over a checkout, a directory holding a file
+ and no `.git` (rv882's NIT 2 on #882), a file and a symbolic link, and
+ took it over an empty directory and over nothing. A directory this user
+ cannot list is taken to hold something: `-f`, with `--cached`, touches
+ no file."""
+ where = root / path
+ if not os.path.lexists(where):
+ return False
+ if where.is_symlink() or not where.is_dir():
+ return True
+ try:
+ with os.scandir(where) as entries:
+ return any(True for _ in entries)
+ except OSError:
+ return True
+
+
+def _held_as(root: Path, path: str) -> str:
+ """What the `-f` sentence says is on disk at `path` (`_forced_said`):
+ `CHECKOUT` where a `.git` entry is there, else `NO_CHECKOUT`."""
+ return (CHECKOUT if os.path.lexists(root / path / ".git")
+ else NO_CHECKOUT)
def _wrong_case_move(path: str, commit: str, variants: list[str],
@@ -786,26 +948,31 @@ def _wrong_case_move(path: str, commit: str, variants: list[str],
is kept).
THE `git rm` TAKES `-f` where the index stages a variant at a commit
- HEAD does not record there and a checkout is on disk at it
- (`MoveContext.forced`, `_forced`, #839): git refuses it there wherever
- that checkout has moved on or holds a file it does not track, and the
- text says what `-f` does (`FORCED_SAID`).
+ HEAD does not record there and anything but an empty directory is on
+ disk at it (`MoveContext.forced`, `_forced`, `_on_disk`, #839, rv882's
+ NIT 2 on #882): git refuses it there wherever a checkout there has
+ moved on or holds a file it does not track, and over a directory that
+ holds files and no `.git`, a file or a symbolic link. The text says
+ what `-f` does (`FORCED_SAID`) and what is on disk there, each
+ variant's where they differ (`MoveContext.forced_over`, lane 1's NIT 3
+ on #889). It comes after `git add -- .gitmodules` where
+ `MoveContext.stage` (`_stage_first`, #879's C3).
"""
taken_out = " ".join(shlex.quote(variant) for variant in variants)
moved = " and ".join(_guarded_move(variant, path) for variant in variants)
- status = " or ".join(f"`git -C {shlex.quote(variant)} status`"
- for variant in variants)
- unpushed = " or ".join(f"`git -C {shlex.quote(variant)} log --oneline "
- "HEAD --branches --not --remotes`"
- for variant in variants)
+ status = _statuses(variants)
+ unpushed = _unpushed(variants)
flag = (" --sparse" if context.sparse else "") + (
" -f" if context.forced else "")
+ lead = (STAGE_SAID if context.stage else "") + "move"
+ lead = (f"Then {lead}" if context.in_the_way
+ else f"{lead[0].upper()}{lead[1:]}")
return (
_entries_aside(path, context.in_the_way)
- + ("Then move" if context.in_the_way else "Move")
- + " its gitlink to the row's path, at the commit the row pins, in "
- "the index alone, which touches no file, with the "
- + ("first two lines below" if context.section
+ + f"{lead} its gitlink to the row's path, at the commit the row "
+ "pins, in the index alone, which touches no file, with the "
+ + ("next two lines" if context.stage
+ else "first two lines below" if context.section
else "two lines below")
+ _first_line_flags(context)
+ (f"; the lines after them {context.section_said}, and stage "
@@ -838,8 +1005,9 @@ def _wrong_case_move(path: str, commit: str, variants: list[str],
"checkout git mv moved to a variant names the member's own store, "
f"which that update points at {path}, so {status} reads {path} "
"after it. The "
- + ("lines" if context.section else "two lines") + ":\n"
- f" git rm -r --cached{flag} --ignore-unmatch -- {taken_out}\n"
+ + ("lines" if context.section or context.stage else "two lines")
+ + ":\n" + (f" {STAGE_LINE}\n" if context.stage else "")
+ + f" git rm -r --cached{flag} --ignore-unmatch -- {taken_out}\n"
" git update-index --add --cacheinfo "
f"{shlex.quote(f'160000,{commit},{path}')}{context.section}")
@@ -892,7 +1060,8 @@ def _first_line_flags(context: MoveContext) -> str:
if context.forced:
said.append(f"-f, since the index stages "
f"{' and '.join(context.forced)} at a commit HEAD does "
- f"not record there, over a checkout: {FORCED_SAID}")
+ f"not record there, over {context.forced_over}: "
+ f"{FORCED_SAID}")
return f" (the first with {', and with '.join(said)})" if said else ""
@@ -1520,7 +1689,11 @@ def _check_gitmodules_values(root: Path, members: list,
`https://github.com/` and the row's `repository:` (`_written_back`).
Where HEAD's file alone gives the value, the work tree's is what to
commit, and where the work tree has no `.gitmodules`, the first such
- finding puts HEAD's back first."""
+ finding puts HEAD's back first, whose value its lines take out, and
+ not the index's, which git reads there: a fix staged in the index's
+ file is lost (#768, lane 1's NIT 1 on #889). Every one of these is
+ recorded as a write (`Report.wrote`): a staged removal's finding after
+ it then puts no file back over the fix (lane 1's SHOULD 1)."""
present = os.path.lexists(root / GITMODULES)
here = _submodule_entries(_gitmodules_listed(root))
head = _head_listed(root)
@@ -1532,6 +1705,7 @@ def _check_gitmodules_values(root: Path, members: list,
and isinstance(row.get("project"), str) and row["project"]}
put_back = present
for name, key in dict.fromkeys([*in_work, *in_head]):
+ report.wrote = True
said = _value_said(name, key, in_work, in_head)
if present and (name, key) not in in_work:
report.finding("gitmodules-value-refused",
@@ -1542,6 +1716,8 @@ def _check_gitmodules_values(root: Path, members: list,
report.finding("gitmodules-value-refused", _value_taken_out(
said, f"submodule.{name}.{key}", back, put_back=put_back,
committed=(name, key) in in_head))
+ if not put_back:
+ report.put_back = f"HEAD:{GITMODULES}"
put_back = True
return bool(in_work), bool(in_work or in_head)
@@ -1724,6 +1900,23 @@ def _gitmodules_put_back(root: Path) -> tuple[str, str, str]:
return "", "", ""
+def _file_put_back(root: Path, report: Report | None) -> tuple[str, str]:
+ """`(the line that puts the work tree's .gitmodules back, whose file it
+ puts back)` for a remedy whose lines write that file with `git config
+ -f` where it is gone from disk (`_gitmodules_put_back`), or two empty
+ strings: where the work tree holds one, and where a finding printed
+ before it put it back (`Report.put_back`), so ONE PUT-BACK SERVES EVERY
+ FINDING and none puts it back over what the lines between wrote
+ (#879). `report` holds the findings printed before it, and records the
+ put-back; None for a refusal, printed alone, which always says it."""
+ if report is not None and report.put_back:
+ return "", ""
+ spec, line, whose = _gitmodules_put_back(root)
+ if report is not None:
+ report.put_back = spec
+ return line, whose
+
+
def _check_gitmodules_paths(sections: dict[str, dict[str, str]],
report: Report) -> None:
"""No two sections of `.gitmodules` give ONE path (#300).
@@ -1736,7 +1929,9 @@ def _check_gitmodules_paths(sections: dict[str, dict[str, str]],
fetched. `add` refuses to write one now; this finds a holder that
already has one. `sections` is `_gitmodules_sections`'s reading. A
section whose path no row names is `_check_gitmodules_named`'s question
- (#361).
+ (#361). Its by-hand edit is recorded as a write (`Report.wrote`), so a
+ staged removal's finding after it puts no file back over the edit
+ (lane 1's SHOULD 1 on #889).
"""
paths: dict[str, list[str]] = {}
for name, keys in sections.items():
@@ -1760,6 +1955,7 @@ def _check_gitmodules_paths(sections: dict[str, dict[str, str]],
f"-f {GITMODULES} --unset-all submodule..` for every "
"key that section holds, so a submodule later given its name "
f"inherits none of them, then commit {GITMODULES}.")
+ report.wrote = True
def _in_members(path: str) -> bool:
@@ -2038,63 +2234,105 @@ def _check_gitlinks_named(root: Path, gitlinks: dict[str, str],
typed again failed: it says to commit the removal instead and prints
no `git rm` (#681). A hand mount's keeps the branch and the `add`.
+ EACH `git rm` COMES AFTER `git add -- .gitmodules` where a value
+ finding's lines, printed before it, may leave that file with changes
+ not staged, or it holds some as it stands (`_stage_first`): git takes
+ no gitlink out of the index then, `--cached` and `-f` or not, and the
+ stray's, the hand mount's and the move's `git rm` each exited 128
+ (#879's C3). The move puts back a `.gitmodules` gone from the work
+ tree before it writes one, unless a finding before it did
+ (`_move_context`, #879's C1).
+
A gitlink outside `members/` is no member's mount, and no row can name
it: `_check_gitlinks_elsewhere` and `_check_gitmodules_named` ask
about it (#878).
+
+ The gitlinks in `members/` no row names are listed once, before the
+ loop, and each one's remedy is `_variant_remedy`'s or
+ `_mounted_by_hand`'s, so this stays within S3776's limit (rv882's
+ SHOULD 3 on #882).
+
+ A hand mount's plain `git rm` takes its section out of the work tree's
+ `.gitmodules`, as the move's lines write one (`_move_section`): each is
+ recorded as a write (`Report.wrote`), so a staged removal's finding
+ after it puts no file back over it (lane 1's SHOULD 1 on #889).
"""
- holds = set(gitlinks)
- head = _head_gitmodules(root) if set(gitlinks) - set(named) else None
+ unnamed = [path for path in sorted(gitlinks)
+ if path not in named and _in_members(path)]
+ if not unnamed:
+ return
+ head = _head_gitmodules(root)
fetched = ({keys["path"] for keys in head[1].values()
if "path" in keys and "url" in keys}
if head is not None else set())
- for path, commit in sorted(gitlinks.items()):
- if path in named or not _in_members(path):
- continue
- said = (f"{path} is a gitlink at {commit} that no row of members: "
- + ("names, so a recursive clone of this holder fetches it "
- "and nothing here pins, digests or identifies it."
- if path in fetched else
- f"names and no section of HEAD's {GITMODULES} gives a "
- "url, so a recursive clone of this holder checks out "
- "nothing at it, in a plain clone git submodule update "
- "--init stops there (No url found for submodule path, "
- "exit 128) before it checks out any member, and nothing "
- "here pins, digests or identifies it."))
+ stage = head is not None and _stage_first(
+ root, "gitmodules-value-refused" in report.codes)
+ for path in unnamed:
+ said = _gitlink_unnamed_said(path, gitlinks[path], path in fetched)
variant = _case_variant(path, named)
if variant is not None:
- row_path, row = variant
- kind = _variant_case(path, row_path, row, holds)
- if kind == WRONG_CASE and row_path == row.canonical and row.pin:
- variants = sorted(other for other in holds
- if other != row_path
- and other.casefold() == row_path.casefold())
- report.finding(
- "member-gitlink-unnamed",
- f"{said}{_variant_said(row_path, row)} The member is "
- "recorded in the wrong case. "
- + _wrong_case_move(row_path, row.pin, variants,
- _move_context(root, row_path,
- variants,
- row.repository,
- gitlinks)))
- continue
- if kind != STRAY:
- report.finding(
- "member-gitlink-unnamed",
- said + _variant_advice(kind, path, row_path, row,
- "gitlink"))
- continue
report.finding(
"member-gitlink-unnamed",
- f"{said}{_variant_said(row_path, row)} "
- + _second_path_out(root, path, commit, path in removed))
+ said + _variant_remedy(root, path, variant, gitlinks,
+ path in removed, stage, report))
continue
- parts = path.split("/")
- project = parts[1] if len(parts) == 2 and parts[1] else None
report.finding(
"member-gitlink-unnamed",
- f"{said} " + _mounted_by_hand(root, path, project,
- path in removed))
+ f"{said} " + _mounted_by_hand(
+ root, path, _hand_project(path), path in removed, stage,
+ _staged_over(root, path, gitlinks[path])))
+ report.wrote = report.wrote or path not in removed
+
+
+def _gitlink_unnamed_said(path: str, commit: str, fetched: bool) -> str:
+ """What `member-gitlink-unnamed` says first of the gitlink at `path`,
+ at `commit`, by whether a section of HEAD's `.gitmodules` gives it a
+ url (`fetched`): a recursive clone fetches it, or no clone checks it
+ out and a plain clone's update stops there (#762)."""
+ return (f"{path} is a gitlink at {commit} that no row of members: "
+ + ("names, so a recursive clone of this holder fetches it and "
+ "nothing here pins, digests or identifies it." if fetched else
+ f"names and no section of HEAD's {GITMODULES} gives a url, so "
+ "a recursive clone of this holder checks out nothing at it, "
+ "in a plain clone git submodule update --init stops there (No "
+ "url found for submodule path, exit 128) before it checks out "
+ "any member, and nothing here pins, digests or identifies "
+ "it."))
+
+
+def _hand_project(path: str) -> str | None:
+ """The project a hand mount at `members/` would be mounted as, `
`,
+ or None for a path deeper or shallower than that."""
+ parts = path.split("/")
+ return parts[1] if len(parts) == 2 and parts[1] else None
+
+
+def _variant_remedy(root: Path, path: str, variant: tuple[str, RowPath],
+ gitlinks: dict[str, str], staged: bool, stage: bool,
+ report: Report) -> str:
+ """What `member-gitlink-unnamed` says, after what the gitlink at `path`
+ is, of one a row names in another case (`variant`, `_case_variant`):
+ the wrong-case move where it is the only record of the member
+ (`_wrong_case_move`), the second path's `git rm --cached` where it is
+ a STRAY (`_second_path_out`, after `git add -- .gitmodules` where
+ `stage`, and the commit alone where its removal is `staged`), and
+ `_variant_advice`'s words for the others, with no command."""
+ row_path, row = variant
+ kind = _variant_case(path, row_path, row, set(gitlinks))
+ if kind == WRONG_CASE and row_path == row.canonical and row.pin:
+ variants = sorted(other for other in gitlinks
+ if other != row_path
+ and other.casefold() == row_path.casefold())
+ return (f"{_variant_said(row_path, row)} The member is recorded in "
+ "the wrong case. "
+ + _wrong_case_move(row_path, row.pin, variants,
+ _move_context(root, row_path, variants,
+ row.repository, gitlinks,
+ report)))
+ if kind != STRAY:
+ return _variant_advice(kind, path, row_path, row, "gitlink")
+ return (f"{_variant_said(row_path, row)} "
+ + _second_path_out(root, path, gitlinks[path], staged, stage))
#: What a gitlink whose removal is staged and not committed says in place of
@@ -2119,8 +2357,9 @@ def _second_path_out(root: Path, path: str, commit: str,
(w761's candidate 2 on #828, measured on the gate's state).
`gitlink-unnamed` says the same of a gitlink outside `members/` that no
- section gives (#878), after `git add -- .gitmodules` where
- `stage_first` (`_stage_first`)."""
+ section gives (#878). Both come after `git add -- .gitmodules` where
+ `stage_first` (`_stage_first`): after a value finding's lines the
+ stray's plain `git rm --cached` exited 128 (#879's C3)."""
if staged:
return f"{STAGED_REMOVAL} Commit that removal."
head = _forced(root, path, commit)
@@ -2128,7 +2367,7 @@ def _second_path_out(root: Path, path: str, commit: str,
else "Take")
+ " it out of the index alone, which touches no file"
+ _forced_said(head, "the second line" if stage_first
- else "the line")
+ else "the line", _held_as(root, path))
+ ":\n " + (f"{STAGE_LINE}\n " if stage_first else "")
+ _cached_removal(path, head))
@@ -2137,11 +2376,16 @@ def _second_path_out(root: Path, path: str, commit: str,
#: after where `.gitmodules` may hold changes not staged (`_stage_first`,
#: #878), and what the text says of it, before what it says of the `git rm`.
STAGE_LINE = f"git add -- {GITMODULES}"
-STAGE_SAID = (f"stage {GITMODULES} with the first line, since git rm refuses "
- "to take any gitlink out of the index, -f or not, while "
- f"{GITMODULES} holds changes not staged (please stage your "
- f"changes to {GITMODULES} or stash them to proceed, exit 128), "
- "as lines printed above may leave it or as it stands, then ")
+STAGE_WHY = ("since git rm refuses to take any gitlink out of the index, -f "
+ f"or not, while {GITMODULES} holds changes not staged (please "
+ f"stage your changes to {GITMODULES} or stash them to proceed, "
+ "exit 128), as lines printed above may leave it or as it stands")
+#: Said of `git add -- .gitmodules` beside why: it stages every other
+#: change in the file too, which the commit after it then holds (rv882's
+#: NIT 3 on #882).
+STAGE_TOO = "which stages every other change in it too"
+STAGE_SAID = (f"stage {GITMODULES} with the first line, {STAGE_TOO}, "
+ f"{STAGE_WHY}, then ")
def _stage_first(root: Path, edited: bool) -> bool:
@@ -2180,19 +2424,21 @@ def _stage_first(root: Path, edited: bool) -> bool:
return git_out(["hash-object", "--", GITMODULES], cwd=root) != staged
-def _forced_said(head: str | None, line: str) -> str:
+def _forced_said(head: str | None, line: str,
+ held: str = CHECKOUT) -> str:
"""What the text says after "take it out of the index alone, which
touches no file" of the `git rm --cached` it prints as `line`, where
`_forced` answered `head` (#839): that the removal is committed, and
- where the line takes `-f`, why, and what `-f` does (`FORCED_SAID`),
- and that nothing is left to commit where HEAD records nothing."""
+ where the line takes `-f`, why, over what is on disk there (`held`,
+ `_held_as`), and what `-f` does (`FORCED_SAID`), and that nothing is
+ left to commit where HEAD records nothing."""
if head is None:
return ", and commit that"
return ((", and commit that. The index stages it at a commit HEAD does "
"not record there" if head else
": HEAD records nothing there, so nothing of it is left to "
"commit. The index stages it")
- + f" over a checkout, so {line} takes -f: {FORCED_SAID}")
+ + f" over {held}, so {line} takes -f: {FORCED_SAID}")
def _cached_removal(path: str, head: str | None) -> str:
@@ -2235,13 +2481,18 @@ def _check_gitlinks_elsewhere(root: Path, gitlinks: dict[str, str],
`git add -- .gitmodules` COMES FIRST where a `gitmodules-value-refused`
finding, printed before this one, may leave the file with changes not
staged, or it holds some as it stands (`_stage_first`): git takes no
- gitlink out of the index then, and the `git rm` exited 128."""
+ gitlink out of the index then, and the `git rm` exited 128.
+
+ WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, the paths are
+ read from the file git reads in its place (`_given_as_git_reads`):
+ read from the work tree, none was given, and this said "no section of
+ .gitmodules gives" of a gitlink the index's and HEAD's gave, whose
+ plain clone checked it out (rv882's SHOULD 2 on #882)."""
unnamed = [path for path in sorted(gitlinks)
if path not in named and not _in_members(path)]
if not unnamed:
return
- given = {keys["path"] for keys in _gitmodules_sections(root).values()
- if "path" in keys}
+ given = _given_as_git_reads(root)
stage_first = _stage_first(root,
"gitmodules-value-refused" in report.codes)
for path in unnamed:
@@ -2260,14 +2511,47 @@ def _check_gitlinks_elsewhere(root: Path, gitlinks: dict[str, str],
stage_first))
+def _given_as_git_reads(root: Path) -> set[str]:
+ """Every path a section of `.gitmodules` gives, as git's submodule
+ commands read the file: the work tree's where it is on disk, else the
+ one git reads in its place (`_gitmodules_put_back`, the index's, then
+ HEAD's), else none (rv882's SHOULD 2 on #882)."""
+ if os.path.lexists(root / GITMODULES):
+ return {keys["path"] for keys in _gitmodules_sections(root).values()
+ if "path" in keys}
+ spec = _gitmodules_put_back(root)[0]
+ return _blob_paths(root, spec) if spec else set()
+
+
+#: What a hand mount's text says its `git rm` meets where the index stages
+#: the gitlink at a commit HEAD does not record there (`_mounted_by_hand`,
+#: lane 1's SHOULD on #889, round 2): git's two refusals, each exit 1, the
+#: second where what is on disk differs from what is staged too, as git
+#: 2.43 gave them (`_staged_over`; lane 1's NIT 1 on #889, round 3).
+STAGED_AT = (", and while the index stages it at a commit HEAD does not "
+ "record there, as it does here (the following file has changes "
+ "staged in the index, or, where what is on disk differs too, "
+ "has staged content different from both the file and the HEAD, "
+ "exit 1),")
+
+
def _mounted_by_hand(root: Path, path: str, project: str | None,
- staged: bool) -> str:
+ staged: bool, stage_first: bool = False,
+ staged_at: bool = False) -> str:
"""What a gitlink no row names, in no case, says to do after what it is:
put its row back, or take a hand mount out, commit that, and mount it
with `family.py add`, on a branch (`_check_gitlinks_named`). Where its
removal is staged already (#681), the same with no `git rm`, and the
checkout `git rm --cached` leaves at the path, which `family.py add`
- refuses (`member-path-taken`), deleted by hand first (rv681's S1)."""
+ refuses (`member-path-taken`), deleted by hand first (rv681's S1).
+ Its `git rm` comes after `git add -- .gitmodules` where `stage_first`
+ (`_stage_first`): after a value finding's lines it exited 128 (#879's
+ C3). Where that `git rm` refuses the gitlink as staged (`staged_at`,
+ as `_staged_over` reads it), as after `git mv members/
+ members/
2`, staged, the text says so (`STAGED_AT`), where it said
+ only that the line refuses while the checkout holds changes (lane 1's
+ SHOULD on #889, round 2). No line here takes such a gitlink out:
+ `-f` would take the checkout's changes with it."""
member = (PROJECT_PLACEHOLDER if project is None
else shlex.quote(project))
add = (f" {PYTHON} scripts/family.py add --family-root "
@@ -2288,20 +2572,27 @@ def _mounted_by_hand(root: Path, path: str, project: str | None,
"one commit, and names the object store git rm leaves under "
f".git/modules if one is still there. {BRANCH_LEFT}\n"
f" {_switch_line(root, 'add', project)}\n{add}")
+ staging, rm_at, add_at = ((f"stage {GITMODULES} with the second, "
+ f"{STAGE_TOO}, {STAGE_WHY}, ", "third",
+ "fourth")
+ if stage_first else ("", "second", "third"))
return (
f"{RESTORE_ROW} If it was mounted by hand, make a branch "
"with the first command below, since family.py add refuses the "
- "holder's tracking branch, take it out with the second, which "
- f"refuses while its checkout holds changes and takes its "
- f"{GITMODULES} section out too, and commit that removal, since "
+ f"holder's tracking branch, {staging}take it out with the "
+ f"{rm_at}, which refuses while its checkout holds changes"
+ + (STAGED_AT if staged_at else "")
+ + f" and takes its {GITMODULES} section out too, and commit that "
+ "removal, since "
"family.py add refuses a path under another spelling of members/ "
"that HEAD holds and the index no longer does, then mount it "
- "with the third, from a checkout of openRepoShape: family.py add "
- "writes its row and its gitlink in one commit, and names the "
+ f"with the {add_at}, from a checkout of openRepoShape: family.py "
+ "add writes its row and its gitlink in one commit, and names the "
"object store git rm leaves under .git/modules if one is still "
f"there. {BRANCH_LEFT}\n"
f" {_switch_line(root, 'add', project)}\n"
- f" git rm -- {shlex.quote(path)}\n{add}")
+ + (f" {STAGE_LINE}\n" if stage_first else "")
+ + f" git rm -- {shlex.quote(path)}\n{add}")
def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]],
@@ -2345,7 +2636,15 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]],
again fails. A gitlink whose removal is staged prints none, as above.
After a finding whose lines write `.gitmodules`, a value's or an
earlier section's (`edited`), the `git rm` comes after `git add --
- .gitmodules`, which it needs (`_stage_first`).
+ .gitmodules`, which it needs (`_stage_first`). Each finding that
+ prints lines is recorded as a write (`Report.wrote`), so a staged
+ removal's finding after it puts no file back over them (lane 1's
+ SHOULD 1 on #889).
+
+ What a section in another case says is `_variant_advice`'s, and the
+ rest of each remedy is `_section_out`'s, after `_gitlink_out_first`'s
+ `git rm` where it prints one, so this stays within S3776's limit
+ (rv882's SHOULD 3 on #882).
"""
holds = {keys["path"] for keys in sections.values() if "path" in keys}
given = Counter(keys["path"] for keys in sections.values()
@@ -2356,61 +2655,71 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]],
path = keys.get("path")
if path is None or path in named:
continue
- url = f"url {keys['url']}" if "url" in keys else "no url"
- said = (f'{GITMODULES} gives the path {path} to the submodule named '
- f'"{name}" ({url}), and no row of members: names that path, '
- "so nothing pins what it registers, and the next family.py "
- "add of its name or its path meets it.")
+ said = _section_unnamed_said(name, keys)
variant = _case_variant(path, named)
- lead = "Take"
if variant is not None:
- row_path, row = variant
- kind = _variant_case(path, row_path, row, holds)
+ kind = _variant_case(path, variant[0], variant[1], holds)
if kind != STRAY:
- report.finding(
- "gitmodules-path-unnamed",
- said + _variant_advice(kind, path, row_path, row,
- "section"))
+ report.finding("gitmodules-path-unnamed",
+ said + _variant_advice(kind, path, *variant,
+ "section"))
continue
- said += _variant_said(row_path, row)
+ said += _variant_said(*variant) + _section_out(
+ name, keys, "Take", f"then commit {GITMODULES}")
+ elif (path in gitlinks and path not in removed and given[path] == 1
+ and _in_members(path)):
+ report.finding(
+ "gitmodules-path-unnamed",
+ f"{said}{SECTION_ROW_BACK} Otherwise the git rm printed above "
+ f"for the gitlink at {path} takes this section out too, so no "
+ "command is printed here.")
+ continue
else:
- said += (" If its row was deleted or its path: edited by hand, "
- "put the row back as git last had it.")
- lead = "Otherwise take"
- if (path in gitlinks and path not in removed and given[path] == 1
- and _in_members(path)):
- report.finding(
- "gitmodules-path-unnamed",
- f"{said} Otherwise the git rm printed above for the "
- f"gitlink at {path} takes this section out too, so no "
- "command is printed here.")
- continue
- first = (_gitlink_out_first(root, name, path, gitlinks, removed,
- taken, edited)
- if variant is None else None)
- then, line = f"then commit {GITMODULES}", ""
- if first is not None:
- first_said, line, then = first
- said += first_said
- lead = "Then, with the lines after it, take"
- removal = "".join(
- f"\n git config -f {GITMODULES} --unset-all "
- f"{shlex.quote(f'submodule.{name}.{key}')}" for key in keys)
- report.finding(
- "gitmodules-path-unnamed",
- f"{said} {lead} the section out by hand, in a POSIX shell, every "
- "key it holds, so a submodule later given its name inherits none "
- f"of them, {then}:{line}{removal}")
- edited = True
-
-
-def _gitlink_out_first(root: Path, name: str, path: str,
+ said += SECTION_ROW_BACK + _gitlink_out_first(
+ root, name, keys, gitlinks, removed, taken, edited)
+ report.finding("gitmodules-path-unnamed", said)
+ edited = report.wrote = True
+
+
+#: What `gitmodules-path-unnamed` says of a section no row names, in no
+#: case, before what it says to do.
+SECTION_ROW_BACK = (" If its row was deleted or its path: edited by hand, put "
+ "the row back as git last had it.")
+
+
+def _section_unnamed_said(name: str, keys: dict[str, str]) -> str:
+ """What `gitmodules-path-unnamed` says first of the section `name`,
+ which gives `keys["path"]` and no row names (#361)."""
+ url = f"url {keys['url']}" if "url" in keys else "no url"
+ return (f'{GITMODULES} gives the path {keys["path"]} to the submodule '
+ f'named "{name}" ({url}), and no row of members: names that '
+ "path, so nothing pins what it registers, and the next "
+ "family.py add of its name or its path meets it.")
+
+
+def _section_out(name: str, keys: dict[str, str], lead: str, then: str,
+ line: str = "") -> str:
+ """The end of `gitmodules-path-unnamed`'s text, which takes the section
+ `name` out by key (#300), every key it holds (`keys`), after `lead`,
+ ending `then`, with `line`, a `git rm` printed first, before those
+ lines."""
+ removal = "".join(
+ f"\n git config -f {GITMODULES} --unset-all "
+ f"{shlex.quote(f'submodule.{name}.{key}')}" for key in keys)
+ return (f" {lead} the section out by hand, in a POSIX shell, every key "
+ "it holds, so a submodule later given its name inherits none of "
+ f"them, {then}:{line}{removal}")
+
+
+def _gitlink_out_first(root: Path, name: str, keys: dict[str, str],
gitlinks: dict[str, str], removed: frozenset[str],
- taken: set[str],
- edited: bool) -> tuple[str, str, str] | None:
- """What `gitmodules-path-unnamed` adds for the section `name`, whose
- `path` is a gitlink outside `members/` (#878), `(what it says, the
- line it prints first, how it ends)`, or None where it adds nothing.
+ taken: set[str], edited: bool) -> str:
+ """The rest of `gitmodules-path-unnamed`'s text for the section `name`,
+ no row naming its path in any case, after what it says of the row:
+ where that path (`keys["path"]`) is a gitlink outside `members/`
+ (#878), the `git rm --cached` that takes the gitlink out first, then
+ the section's own lines (`_section_out`), and elsewhere those lines
+ alone, after "Otherwise".
THE GITLINK GOES FIRST, out of the index alone, with `git rm --cached`,
which touches no file and leaves the section, and the commit, as
@@ -2432,15 +2741,17 @@ def _gitlink_out_first(root: Path, name: str, path: str,
or where it holds some as it stands (`_stage_first`): git takes no
gitlink out of the index then, and the `git rm` exited 128.
- None for a path in `members/`, whose gitlink has a finding of its own
- (`member-gitlink-unnamed`), for one that is no gitlink, and one
+ No `git rm` for a path in `members/`, whose gitlink has a finding of
+ its own (`member-gitlink-unnamed`), for one that is no gitlink, and one
whose removal is staged (`removed`), where a `git rm` fails (`pathspec
... did not match`), and for one an earlier section's finding took out
- (`taken`): the `git rm` typed again fails there too. A path it adds to
- is added to `taken`."""
+ (`taken`): the `git rm` typed again fails there too. A path it prints
+ one for is added to `taken`."""
+ path = keys["path"]
if (path not in gitlinks or path in removed or _in_members(path)
or path in taken):
- return None
+ return _section_out(name, keys, "Otherwise take",
+ f"then commit {GITMODULES}")
taken.add(path)
head = _forced(root, path, gitlinks[path])
in_head = _head_gitmodules(root)
@@ -2455,9 +2766,11 @@ def _gitlink_out_first(root: Path, name: str, path: str,
"(No url found for submodule path, exit 128) before it checks out "
"any member: " + (STAGE_SAID if stage else "")
+ f"take it out of the index alone, which touches no file, with "
- f"{nth}" + _forced_said(head, nth) + ".",
- (f"\n {STAGE_LINE}" if stage else "")
- + f"\n {_cached_removal(path, head)}", then)
+ f"{nth}"
+ + _forced_said(head, nth, _held_as(root, path)) + "."
+ + _section_out(name, keys, "Then, with the lines after it, take",
+ then, (f"\n {STAGE_LINE}" if stage else "")
+ + f"\n {_cached_removal(path, head)}"))
def _section_lines(path: str, repository,
@@ -2528,7 +2841,13 @@ def _check_gitlinks_registered(root: Path, members: list,
`member-gitmodules-absent`'s lines, staged and committed, dropped every
other section (rv820's review, comment 6237354577 on #839), and a
second `member-gitmodules-url-absent`'s put the file back over the url
- the first had written (#839).
+ the first had written (#839). Nor where the wrong-case move printed
+ before them put it back (`Report.put_back`, #879's C1), whose write the
+ url-absent finding's put-back undid; the one these print is recorded
+ there, for `member-gitlink-removal-staged` after them (#879's C2), and
+ so is each write, on disk or not (`Report.wrote`): that finding's
+ `git checkout HEAD -- .gitmodules` took a section these wrote out
+ again (lane 1's SHOULD 1 on #889).
"""
head = _head_gitmodules(root)
if head is None:
@@ -2536,26 +2855,27 @@ def _check_gitlinks_registered(root: Path, members: list,
held, sections = head
given = Counter(keys["path"] for keys in sections.values()
if "path" in keys)
- back = ("", "") if refused else _gitmodules_put_back(root)[1:]
+ spec, line, whose = (("", "", "") if refused or report.put_back
+ else _gitmodules_put_back(root))
+ back = (line, whose)
unregistered: set[str] = set()
- for row in members:
- project = row.get("project") if isinstance(row, dict) else None
- if not isinstance(project, str) or not project:
- continue
- path = f"{DEFAULT_MEMBERS_DIR}/{project}"
- if (str(row.get("path") or path) != path or path in unregistered
- or path not in gitlinks):
+ for row, _, path in _rows_at_their_paths(members):
+ if path not in gitlinks:
continue
if path in given:
if given[path] == 1 and _url_absent(root, path, gitlinks[path],
row, sections, report, back):
unregistered.add(path)
+ report.put_back = report.put_back or spec
+ report.wrote = True
back = ("", "")
continue
unregistered.add(path)
report.finding("member-gitmodules-absent", _absent_said(
path, gitlinks[path], held,
_section_lines(path, row.get("repository"), sections), back))
+ report.put_back = report.put_back or spec
+ report.wrote = True
back = ("", "")
return unregistered
@@ -2646,75 +2966,288 @@ def _check_removals_staged(root: Path, members: list,
BOTH WAYS OUT ARE TYPED (rv762's probes, git 2.43): to stay, `git reset
-q -- ` puts the gitlink back as HEAD records it; where a plain
`git rm` also took its section out of the work tree's `.gitmodules`
- (`worktree`), `git checkout HEAD -- .gitmodules` puts that back, and
+ (`worktree`), the lines `_section_back` gives put that back, and
where it took the checkout, `git submodule update --init --recursive --
` checks it out again, since that update dies `No url found`
while the section is gone. To go, the same, then `family.py remove`,
which takes the row, the gitlink and the section out in one commit and
refuses a work tree's `.gitmodules` that is not the index's
(`gitmodules-unstaged`) and a staged removal alike, on a branch, as the
- duplicate-id remedy names it.
+ duplicate-id remedy names it. HEAD's `.gitmodules` is read once: the
+ rows whose removal is staged and whose path it gives, `wanted`, for
+ the first put-back of a file gone from disk (lane 1's MUST 1 on #889),
+ and every key it gives a value git dies on, `refused`
+ (`_values_refused`), which a section written back key by key leaves
+ out. The checkouts it names are `_moved_checkouts`'s.
"""
if not removed:
return set()
- head = _head_gitmodules(root)
- in_head = ({keys["path"] for keys in head[1].values() if "path" in keys}
- if head is not None else set())
- in_worktree = {keys["path"] for keys in worktree.values()
- if "path" in keys}
- staged: set[str] = set()
+ head = _head_listed(root)
+ listed = head[1] if head is not None else b""
+ sections = _sections_listed(listed)
+ given = {keys["path"] for keys in sections.values() if "path" in keys}
+ rows = [(row, project, path)
+ for row, project, path in _rows_at_their_paths(members)
+ if path in removed]
+ wanted = {path for _, _, path in rows if path in given}
+ refused = set(_values_refused(root, _submodule_entries(listed)))
+ for row, project, path in rows:
+ lines = _put_back_lines(root, path, _section_back(
+ root, path, sections, worktree, report, (wanted, refused)))
+ report.finding("member-gitlink-removal-staged", _removal_staged_said(
+ root, row, project, path, gitlinks[path], lines,
+ _moved_checkouts(root, path, gitlinks, sections)))
+ return {path for _, _, path in rows}
+
+
+def _rows_at_their_paths(members: list) -> Iterator[tuple[dict, str, str]]:
+ """`(row, project, path)` for each row of `members:` whose project is a
+ name and whose `path:` is its own, `members/`, or none, once
+ for each path: the rows `_check_gitlinks_registered` and
+ `_check_removals_staged` ask, which `_check_member` reads at that path.
+ A row whose `path:` is another is `member-path`'s (#611, #762)."""
+ seen: set[str] = set()
for row in members:
project = row.get("project") if isinstance(row, dict) else None
if not isinstance(project, str) or not project:
continue
path = f"{DEFAULT_MEMBERS_DIR}/{project}"
- if (str(row.get("path") or path) != path or path in staged
- or path not in removed):
- continue
- staged.add(path)
- lines = _put_back_lines(root, path, path in in_head
- and path not in in_worktree)
- report.finding("member-gitlink-removal-staged", _removal_staged_said(
- root, row, project, path, gitlinks[path], lines))
- return staged
+ if str(row.get("path") or path) == path and path not in seen:
+ seen.add(path)
+ yield row, project, path
+
+
+def _moved_checkouts(root: Path, path: str, gitlinks: dict[str, str],
+ sections: dict[str, dict[str, str]]
+ ) -> tuple[list[str], list[str]]:
+ """`(moved, clones)`, what `member-gitlink-removal-staged` names
+ (`_moved_said`) of the row's gitlink at `path`, where no checkout is
+ there, else two empty lists.
+
+ `moved`: each other gitlink the index holds whose `.git` FILE names
+ the member's own store (`_store`, `_names_store`), the checkout `git
+ mv` moved, whose store the update the finding prints last points back
+ at `path` (#879's C4).
+ Any path, not a case variant alone: after `git mv members/
+ renamed-1`, typed as printed, `renamed-1` was left untracked, named
+ by no finding, with a file only it held unseen (lane 1's SHOULD 3 on
+ #889).
+
+ `clones`: each case variant of `path` the index holds a gitlink at
+ whose `.git` is a DIRECTORY, a clone of its own, which holds its
+ commits there and nowhere else. It was said to be the checkout `git
+ mv` moved, to delete by hand, which lost a commit only it held (lane
+ 1's SHOULD 2 on #889)."""
+ if (root / path / ".git").exists():
+ return [], []
+ store = _store(root, path, sections)
+ others = [other for other in sorted(gitlinks) if other != path]
+ moved = [other for other in others
+ if _names_store(root / other / ".git", store)]
+ clones = [other for other in others
+ if other.casefold() == path.casefold()
+ and (root / other / ".git").is_dir()]
+ return moved, clones
+
+
+def _store(root: Path, path: str,
+ sections: dict[str, dict[str, str]]) -> str:
+ """Where this holder keeps the store of the submodule at `path`, as git
+ does, `modules/` in its git directory, resolved and with its case
+ normalised for `_names_store`, or nothing where git cannot name that
+ directory: `` the section of HEAD's `.gitmodules` (`sections`)
+ that gives the path, the last, as git reads it, else the path, as `git
+ submodule add` names one (lane 1's SHOULD 3 on #889)."""
+ names = [name for name, keys in sections.items()
+ if keys.get("path") == path]
+ with _git_runs("this holder's git directory could not be read"):
+ try:
+ common = git_out(["rev-parse", "--git-common-dir"], cwd=root,
+ binary=True)
+ except Refusal:
+ return ""
+ common_dir = root / common.decode("utf-8", "surrogateescape").strip()
+ return os.path.normcase(os.path.realpath(
+ common_dir / "modules" / (names[-1] if names else path)))
+
+
+def _names_store(gitfile: Path, store: str) -> bool:
+ """Whether `gitfile` is a `.git` FILE that names `store` (`_store`), read
+ as git reads one: `gitdir: `, trailing blanks stripped, the path
+ relative to the file's directory unless it is absolute."""
+ if not store or not gitfile.is_file():
+ return False
+ try:
+ text = gitfile.read_bytes().decode("utf-8", "surrogateescape")
+ except OSError:
+ return False
+ text = text.rstrip()
+ if not text.startswith("gitdir: "):
+ return False
+ named = os.path.join(gitfile.parent, text[len("gitdir: "):])
+ return os.path.normcase(os.path.realpath(named)) == store
+
+
+#: What `member-gitlink-removal-staged` says its `git checkout HEAD --
+#: .gitmodules` puts back (#762).
+SECTION_TAKEN = (f"the {GITMODULES} section git rm took out, putting that "
+ "file back as HEAD has it and dropping any other change to "
+ "it")
+
+
+def _section_back(root: Path, path: str,
+ sections: dict[str, dict[str, str]],
+ worktree: dict[str, dict[str, str]],
+ report: Report,
+ asked: tuple[set[str], set[tuple[str, str]]]
+ ) -> list[tuple[list[str], str]]:
+ """The lines `member-gitlink-removal-staged` prints for `.gitmodules`,
+ with what they put back, where HEAD's (`sections`) gives `path` (#762):
+ none where the work tree's (`worktree`) gives it, nor where the file a
+ finding before put back in place of one gone from disk does
+ (`Report.put_back`), and else `git checkout HEAD -- .gitmodules`, which
+ puts back the section a plain `git rm` took out.
+
+ NOTHING IS PUT BACK OVER WHAT A FINDING PRINTED BEFORE WROTE, or where
+ it put the file back (`Report.wrote`, `Report.put_back`): the section
+ HEAD's gives is written in key by key, as HEAD has it, but for a key
+ it gives a value git dies on (`asked`'s second, which
+ `gitmodules-value-refused` takes out), and the file staged
+ (`_keys_back`). Where the work tree's file is gone, only a put-back
+ makes a file to write into: the first finding that writes one puts it
+ back and records that (#879's C1), so `Report.put_back` alone is asked
+ there. A hand mount's `git rm`, counted as a write, writes nothing
+ into a file that is gone, and the key-by-key lines then made a file of
+ one section, which the commit recorded (lane 1's MUST 2 on #889). The
+ `git checkout HEAD` took
+ out again a section a finding before had written, where the work
+ tree's file was gone (#879's C2, rv876's P5d) and where it was on disk
+ (lane 1's SHOULD 1 on #889); and written in alone, the section stayed
+ out of the index's file, which the commit records, so the commit
+ dropped it (lane 1's MUST 1, its P5).
+
+ WHERE THE WORK TREE'S FILE IS GONE FROM DISK and no finding before put
+ it back, this one does, and records that: from where git reads it
+ (`_gitmodules_put_back`) where that file gives every path `asked`'s
+ first holds, each row whose removal is staged and whose path HEAD's
+ gives, so that
+ a deletion of `.gitmodules` staged beside the removal is undone too,
+ with nothing left to commit, and else from HEAD (`SECTION_TAKEN`). Put
+ back from the index's, which a plain `git rm` of a second row had
+ taken that row's section out of, the second row's finding wrote it in
+ key by key and the commit dropped it (lane 1's MUST 1, its P4)."""
+ given = {keys["path"] for keys in sections.values() if "path" in keys}
+ if path not in given:
+ return []
+ on_disk = os.path.lexists(root / GITMODULES)
+ if on_disk and any(keys.get("path") == path for keys in worktree.values()):
+ return []
+ if report.put_back and path in _blob_paths(root, report.put_back):
+ return []
+ wanted, refused = asked
+ if report.put_back or (on_disk and report.wrote):
+ report.wrote = True
+ return _keys_back(path, sections, on_disk, refused)
+ if on_disk:
+ return [([f"git checkout HEAD -- {GITMODULES}"], SECTION_TAKEN)]
+ spec, line, whose = _gitmodules_put_back(root)
+ if wanted <= _blob_paths(root, spec):
+ report.put_back = spec
+ return [([line], f"the {GITMODULES} gone from the work tree, from "
+ f"{whose}")]
+ report.put_back = f"HEAD:{GITMODULES}"
+ return [([f"git checkout HEAD -- {GITMODULES}"], SECTION_TAKEN)]
+
+
+def _keys_back(path: str, sections: dict[str, dict[str, str]],
+ on_disk: bool, refused: set[tuple[str, str]]
+ ) -> list[tuple[list[str], str]]:
+ """`_section_back`'s lines after a finding that wrote the work tree's
+ `.gitmodules` or put it back: the section of HEAD's (`sections`) that
+ gives `path`, the last, as git reads it, written in key by key as HEAD
+ has it, into the work tree's file as the lines above leave it
+ (`on_disk`) or the file a line above put back, but for a key HEAD's
+ gives a value git dies on (`refused`), which written back would be
+ `gitmodules-value-refused` again; then `git add -- .gitmodules`
+ (`STAGE_LINE`): the commit records the index's file, and a section
+ written into the work tree's alone stayed out of it (lane 1's MUST 1
+ on #889)."""
+ name = [name for name, keys in sections.items()
+ if keys.get("path") == path][-1]
+ into = ("in the work tree's file, as the lines above leave it" if on_disk
+ else "in the file a line above put back")
+ return [([f"git config -f {GITMODULES} "
+ f"{shlex.quote(f'submodule.{name}.{key}')} "
+ f"{shlex.quote(value)}"
+ for key, value in sections[name].items()
+ if (name, key) not in refused],
+ f"the {GITMODULES} section git rm took out, key by key as HEAD "
+ f"has it, {into}"),
+ ([STAGE_LINE], f"that section in the index, staging {GITMODULES} "
+ f"whole, {STAGE_TOO}")]
+
+
+def _blob_paths(root: Path, spec: str) -> set[str]:
+ """Every path a section of the `.gitmodules` git names `spec` gives
+ (`:.gitmodules`, the index's, or `HEAD:.gitmodules`), read as
+ `_gitmodules_sections` reads the work tree's; none where git cannot
+ read it (#879)."""
+ with _git_runs(f"{spec} could not be read as git reads it"):
+ try:
+ listed = git_out(["config", "-z", "--blob", spec, "--list"],
+ cwd=root, binary=True)
+ except Refusal:
+ return set()
+ return {keys["path"] for keys in _sections_listed(listed).values()
+ if "path" in keys}
def _put_back_lines(root: Path, path: str,
- section_taken: bool) -> list[tuple[str, str]]:
+ section: list[tuple[list[str], str]]
+ ) -> list[tuple[list[str], str]]:
"""The lines `member-gitlink-removal-staged` prints that keep the member
- at `path`, each with what it puts back (#762): its gitlink; the section
- a plain `git rm` took out of the work tree's `.gitmodules`, where
- `section_taken` (HEAD's file gives the path and the work tree's does
- not); and its checkout, where none is there."""
- lines = [(f"git reset -q -- {shlex.quote(path)}", "its gitlink")]
- if section_taken:
- lines.append((f"git checkout HEAD -- {GITMODULES}",
- f"the {GITMODULES} section git rm took out, "
- "putting that file back as HEAD has it and "
- "dropping any other change to it"))
+ at `path`, each group with what it puts back (#762): its gitlink;
+ `section`, the lines for `.gitmodules` (`_section_back`, #879); and
+ its checkout, where none is there."""
+ lines = [([f"git reset -q -- {shlex.quote(path)}"], "its gitlink"),
+ *section]
if not (root / path / ".git").exists():
- lines.append(("git submodule update --init --recursive -- "
- f"{shlex.quote(path)}", "its checkout"))
+ lines.append((["git submodule update --init --recursive -- "
+ f"{shlex.quote(path)}"], "its checkout"))
return lines
+#: What `member-gitlink-removal-staged` says after its lines where they
+#: stage `.gitmodules` (`_keys_back`), in place of "and nothing is left to
+#: commit": the file holds what the findings above write (lane 1's MUST 1
+#: on #889).
+STAGED_BACK = (f": commit {GITMODULES} then, with what the lines above "
+ "write in it, and nothing else is left to commit.")
+
+
def _removal_staged_said(root: Path, row: dict, project: str, path: str,
- commit: str, lines: list[tuple[str, str]]) -> str:
+ commit: str, lines: list[tuple[list[str], str]],
+ found: tuple[list[str], list[str]]) -> str:
"""`member-gitlink-removal-staged`'s text for the row naming `project`,
whose gitlink at `path` is at `commit` in HEAD, with `lines`
- (`_put_back_lines`) below it (#762)."""
+ (`_put_back_lines`) below it (#762), and what it says of the checkouts
+ `_moved_checkouts` `found` (`_moved_said`, #879's C4). Where the lines
+ stage `.gitmodules`, it says to commit that (`STAGED_BACK`)."""
member_id = str(row.get("id") or project)
remove = (f"{PYTHON} scripts/family.py remove --family-root "
f"{shlex.quote(str(root))} --member {shlex.quote(project)}")
- commands = "\n ".join(line for line, _ in lines)
+ commands = "\n ".join(line for group, _ in lines for line in group)
+ staged = any(group == [STAGE_LINE] for group, _ in lines)
return (
f"{path} is a gitlink at {commit} that a row of members: names and "
"HEAD records, and the index holds nothing at that path: its removal "
"is staged and not committed, so the next commit un-registers the "
"member under its row, and this gate then refuses "
"member-gitlink-absent. If the member is to stay, "
- + _put_back_said([said for _, said in lines])
- + ", and nothing is left to commit. If it is to go, put it back the "
+ + _put_back_said(lines)
+ + (STAGED_BACK if staged else ", and nothing is left to commit.")
+ + _moved_said(path, *found)
+ + " If it is to go, put it back the "
"same way, then take it out with family.py remove, from a checkout "
"of openRepoShape, which takes its row, its gitlink and its section "
"out in one commit, on a branch other than the holder's tracking "
@@ -2723,15 +3256,95 @@ def _removal_staged_said(root: Path, row: dict, project: str, path: str,
f"`{remove}`. {BRANCH_LEFT}\n {commands}")
-def _put_back_said(put: list[str]) -> str:
- """What the lines `member-gitlink-removal-staged` prints put back, one
- to a line, in their order: the gitlink first (#762)."""
- if len(put) == 1:
- return f"the line below puts {put[0]} back as HEAD records it"
- ordinals = ["the first", "the second", "the third"]
+def _moved_said(path: str, moved: list[str], clones: list[str]) -> str:
+ """What `member-gitlink-removal-staged` says, before its last line, the
+ update, of each checkout of the member `git mv` moved off `path`
+ (`moved`) and each clone of its own at a case variant of it
+ (`clones`), or nothing (`_moved_checkouts`).
+
+ THE CHECKOUT `git mv` MOVED (#879's C4): after the lines were typed as
+ printed it stayed there, untracked and named nowhere, so `family.py
+ add` refused one at a case variant (`family-members-dir-variant`), and
+ after the update `git -C status` read `path`, so a file only
+ it held went unseen. Said as `family.py add`'s closing sentences say
+ it (#828, #865), and of one outside `members/` too (lane 1's SHOULD 3
+ on #889).
+
+ A CLONE OF ITS OWN, whose `.git` is a directory, holds its commits
+ there and nowhere else: said as the wrong-case move says it
+ (`_wrong_case_move`), to move it out whole, not to delete it (lane 1's
+ SHOULD 2 on #889)."""
+ said = ""
+ if moved:
+ refused = (", and family.py add refuses it as "
+ "family-members-dir-variant"
+ if any(other.casefold() == path.casefold()
+ for other in moved) else "")
+ status = _statuses(moved)
+ said += (
+ f" The checkout git mv moved to {' or '.join(moved)} stays "
+ f"there, untracked{refused}: its .git file names the member's "
+ "store under this holder's .git/modules/ by a path relative to "
+ "it, and its commits are kept there whatever becomes of the "
+ "checkout, so only what is not committed is in the checkout "
+ f"alone, which {status} shows. Delete it by hand once nothing "
+ "only it holds is needed, before the last line: that update "
+ f"points the store at {path}, after which {status} reads {path} "
+ "instead.")
+ if clones:
+ said += (
+ f" The clone at {' or '.join(clones)}, whose .git is a "
+ "directory, stays there, untracked, and family.py add refuses it "
+ "as family-members-dir-variant: it holds its commits there and "
+ f"nowhere else, and {_unpushed(clones)} lists the ones not "
+ "pushed, so move it out of the holder whole, by hand, rather "
+ "than delete it.")
+ return said
+
+
+def _statuses(paths: list[str]) -> str:
+ """`git -C status`, backticked, for each of `paths`, joined by
+ `or`: what shows what only a checkout holds (#681, #879)."""
+ return " or ".join(f"`git -C {shlex.quote(path)} status`"
+ for path in paths)
+
+
+def _unpushed(paths: list[str]) -> str:
+ """`git -C log --oneline HEAD --branches --not --remotes`,
+ backticked, for each of `paths`, joined by `or`: what lists the
+ commits a clone of its own holds that no remote does (rv681's S2,
+ #879)."""
+ return " or ".join(f"`git -C {shlex.quote(path)} log --oneline HEAD "
+ "--branches --not --remotes`" for path in paths)
+
+
+#: How `_put_back_said` counts the lines below a finding.
+ORDINALS = ("first", "second", "third", "fourth", "fifth", "sixth",
+ "seventh", "eighth", "ninth", "tenth")
+
+
+def _put_back_said(groups: list[tuple[list[str], str]]) -> str:
+ """What the lines `member-gitlink-removal-staged` prints put back, in
+ their order, the gitlink first (#762): each group of lines
+ (`_put_back_lines`) with what it puts back, `the second` for one line
+ and `the second to the fourth` for more (#879)."""
+ if len(groups) == 1 and len(groups[0][0]) == 1:
+ return f"the line below puts {groups[0][1]} back as HEAD records it"
+ said, at = [], 0
+ for group, what in groups:
+ span = f"the {_ordinal(at)}"
+ if len(group) > 1:
+ span += f" to the {_ordinal(at + len(group) - 1)}"
+ said.append(f"{span} {what}")
+ at += len(group)
return ("the lines below put the member back as HEAD has it: "
- + ", ".join(f"{ordinals[at]} {said}"
- for at, said in enumerate(put)))
+ + ", ".join(said))
+
+
+def _ordinal(at: int) -> str:
+ """`first` for the line at 0, and so on (`ORDINALS`); `th` past the
+ tenth (#879)."""
+ return ORDINALS[at] if at < len(ORDINALS) else f"{at + 1}th"
def _init_stop_said(root: Path, sections: dict[str, dict[str, str]],
diff --git a/tests/test_family_holder_remedies_compose.py b/tests/test_family_holder_remedies_compose.py
new file mode 100644
index 00000000..5cf8899b
--- /dev/null
+++ b/tests/test_family_holder_remedies_compose.py
@@ -0,0 +1,1525 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The family holder's pinned `scripts/validate-family.py` and
+`scripts/bootstrap.py`: remedies that undid each other or refused when
+typed as printed, a checkout `git mv` moved that nothing named, and `ok`
+beside git's exit 128 (#879).
+
+THE ISSUE'S FIVE LINES, each reproduced on #876's head (a7f6c569) before
+this change, and the states beside them found with it:
+
+- C1. Where the work tree's `.gitmodules` is gone, the wrong-case move's
+ `git config -f .gitmodules submodule..path ` made a file
+ of that one key, which its `git add` staged: committed, HEAD lost every
+ other section (rv876's P5e), and under `--no-members` a later
+ `member-gitmodules-url-absent` put that staged file back over its own
+ write (P5b). The move puts the file back first now, and no finding
+ after it does. So does `member-gitlink-absent`'s own section lines
+ (#681), whose one-section file was the same fault.
+- C2. `member-gitlink-removal-staged` read "section taken" from the empty
+ work tree and printed `git checkout HEAD -- .gitmodules` after a finding
+ had put the file back and written a section there, which it took out
+ again (P5d). It reads the file git reads now, puts nothing back after an
+ earlier finding did, and where a plain `git rm` took its section out,
+ writes that section back key by key into the file put back.
+- C3. After a `gitmodules-value-refused` finding's lines, a stray's `git
+ rm --cached` exited 128 (`please stage your changes to .gitmodules or
+ stash them to proceed`, P5g), and so did a hand mount's `git rm` and the
+ wrong-case move's `git rm -r --cached`. Each comes after `git add --
+ .gitmodules` there now, as #878's do.
+- C4. After the `git mv` state's lines were typed, the checkout `git mv`
+ moved stayed at `Members/`, untracked and named nowhere, so `family.py
+ add` refused it, and after the update `git -C Members/
status` read
+ the row's path. `member-gitlink-removal-staged` names it now, with that
+ status, before its update.
+- C5. With every member checked out, bootstrap said `family bootstrap ok`
+ where git's update had exited 128, its init stopped at a gitlink it has
+ no url for or at an unmerged `.gitmodules`. It fails now, naming the
+ stop and its cure, with no credential advice.
+
+Every remedy is typed as printed, in the order printed, split as a POSIX
+shell splits it and run with no shell; then the gate is asked in every
+mode, and a plain clone's `git submodule update --init --recursive` must
+check out both members. A url a remedy writes is the one `family.py add`
+writes, on GitHub, and the clone reaches the fixture's local remotes
+through it by a `url..insteadOf` for the one command (`cloning`).
+THE CONTROLS: the same states with the work tree's `.gitmodules` on disk,
+or with no value finding before the `git rm`, print what they printed.
+
+RV882'S ITEMS ON #882, TAKEN HERE (lane 2's review of #878): both of
+`_stage_first`'s branches that print no `git add -- .gitmodules`, where
+the work tree holds no file and where the index holds none (SHOULD 1);
+`gitlink-unnamed` reading the paths from the file git reads where the
+work tree's is gone (SHOULD 2); and `-f` over what is on disk at a staged
+gitlink and is no checkout, a directory holding a file or a file, and
+none over an empty directory, which git takes the plain line over (NIT
+2). NIT 3's clause is in `STAGE_SAID`, which the C3 rows pin.
+
+LANE 1'S REVIEW OF #889, ROUND 1, TAKEN HERE: a staged removal's section
+written back key by key is staged too, and the first removal puts back
+HEAD's file where the index's lacks a section a second removal needs
+(MUST 1, its P4 and P5); no removal puts HEAD's file back over an earlier
+finding's write with the file on disk either (SHOULD 1); a clone of its
+own at a case variant is said to be one (SHOULD 2); a checkout `git mv`
+moved out of `members/` is named (SHOULD 3); a symbolic link to an empty
+directory takes `-f` (NIT 2); the move says what is on disk at each
+variant (NIT 3); a run with no row says nothing was updated (NIT 5); and
+the value finding's put-back serves the removal after it (NIT 6).
+
+LANE 1'S REVIEW OF #889, ROUND 2, TAKEN HERE: a hand mount's `git rm`
+writes nothing into a `.gitmodules` that is gone, so a removal after it
+puts the file back itself rather than write a section into no file (MUST
+2); a clone at no case variant of the row's path is not said to be one
+`family.py add` refuses (the NIT, lane 1's mutant MY3); and a hand mount's
+`git rm` says it refuses a gitlink staged at a commit HEAD does not record
+there (the SHOULD).
+
+THE SAME FAMILY, OFFLINE RULES AND HELPERS as
+`tests/test_family_holder_bootstrap_texts.py` (#839) and
+`tests/test_family_unnamed_gitlink.py` (#878). A case variant is an index
+record (`recorded_at`, `stage_gitlink`), but for the `git mv` states, which
+need `Members/IRRS` apart from `members/IRRS` on disk and are skipped where
+the disk folds case (`case_sensitive_disk`).
+"""
+
+from __future__ import annotations
+
+import re
+import shlex
+import shutil
+from pathlib import Path
+
+import pytest
+
+from conftest import rmtree, run_script
+from test_family import FAMILY, SPARE, family, validate # noqa: F401
+from test_family_add_refusals import holder, offline # noqa: F401
+from test_family_bootstrap_gate_order import (
+ OUTSIDE_CURE, STOPPED, UNMERGED, UNMERGED_CURE, UNNAMED_CURE, bootstrap,
+ fetching, hand_mounted, stray, type_line, with_config, without_url,
+)
+from test_family_dry_run import isolated
+from test_family_head_lost_gitmodules import ( # noqa: F401
+ absent, assert_passes_in_every_mode, case_sensitive_disk, commit_all,
+ committed_gitmodules, github_to, pinned, recorded_at,
+ staged_and_committed, typed, variant_refused,
+)
+from test_family_holder_bootstrap_texts import (
+ WORK, after_fetch_line, put_back_first, section_taken_out,
+ unmerged_gitmodules,
+)
+from test_family_member_path import MEMBER, OTHER, raw
+from test_family_remove_gitmodules import verb_args
+from test_family_unnamed_gitlink import (
+ COMMIT_THAT, FORCED_NO_HEAD, RENAMED, STAGE_LINE, STAGE_SAID, TAKE_OUT,
+ TOOLS, assert_a_plain_clone_checks_out_every_member, at_tools,
+ found_in_every_mode, outside, section_taken_out_alone, staged_at,
+ submodule_added,
+)
+from test_validate_family_typed_states import (
+ GONE, ROW, SECTION_GIVEN_BACK, URL_ABSENT, VARIANT_PATH, gone,
+ unnamed_as_moved,
+)
+from test_validate_family_unnamed_mounts import (
+ GITLINK_SAID, GITLINK_SAID_FETCHED, VARIANT, codes, commands, findings,
+ spare_commits, stage_gitlink,
+)
+
+MODES = ((), ("--pins",), ("--no-members",))
+GITMODULES = ".gitmodules"
+
+#: What a remedy that writes `.gitmodules` says first where the work
+#: tree's is gone, `{whose}` as `GONE` names it (C1).
+PUT_BACK_THEN = (".gitmodules, gone from the work tree, back from {whose}, "
+ "so that every other section stays")
+
+
+def cloning(offline, tmp_path: Path, family) -> dict:
+ """The offline environment under which a plain clone checks the members
+ out from the fixture's local remotes, through the GitHub url a remedy
+ writes as `family.py add` writes one (`github_to`)."""
+ key, value = github_to(family["remotes"])[1].split("=", 1)
+ return with_config(fetching(isolated(offline, tmp_path)), key, value)
+
+
+def typed_in_order(root: Path, env: dict, result) -> None:
+ """Every `git` command every finding of `result` prints, typed as
+ printed, in the order printed: not `family.py add`, which mounts a hand
+ mount again only where the person wants it."""
+ for finding in findings(result):
+ for line in commands(finding):
+ if line.startswith("git "):
+ type_line(root, env, line)
+
+
+def refusal_lines(result) -> list[str]:
+ """The command lines a refusal prints, as printed."""
+ return [line.strip() for line in result.stderr.splitlines()[2:]]
+
+
+def found_alike(root: Path, *modes: tuple[str, ...]) -> list[str]:
+ """The gate's findings, exit 1, the same in each of `modes`."""
+ seen = []
+ for extra in modes:
+ result = validate(root, *extra)
+ assert result.returncode == 1, (extra, result.stdout + result.stderr)
+ seen.append(findings(result))
+ assert all(found == seen[0] for found in seen), seen
+ return seen[0]
+
+
+def ends_well(root: Path, env: dict, family, tmp_path: Path) -> None:
+ """The gate passes in every mode, and a plain clone checks out both
+ members (#878's helper)."""
+ assert_passes_in_every_mode(root, 2)
+ assert_a_plain_clone_checks_out_every_member(root, env, family, tmp_path)
+
+
+# --- C1: the wrong-case move where the work tree's .gitmodules is gone -------
+
+def moved_in_head(root: Path, env: dict) -> str:
+ """MEMBER's gitlink recorded only at `Members/IRRS`, and the section of
+ HEAD's `.gitmodules` giving that path, as a committed `git mv` leaves
+ them (rv876's P5e state), by index records. Returns the commit."""
+ commit = recorded_at(root, env, MEMBER, VARIANT_PATH)
+ data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout
+ line = f"\tpath = {ROW}\n".encode()
+ assert data.count(line) == 1, data
+ committed_gitmodules(root, env, data.replace(
+ line, f"\tpath = {VARIANT_PATH}\n".encode()))
+ return commit
+
+
+def moved_refused(commit: str, how: str | None) -> list[str]:
+ """The members-mode refusal for `moved_in_head`'s state, with the line
+ that puts `.gitmodules` back first where the work tree's is gone as
+ `how` says (`GONE`), or none."""
+ section = (f"git config -f .gitmodules submodule.{ROW}.path {ROW}",
+ "git add -- .gitmodules")
+ said = SECTION_GIVEN_BACK
+ if how is not None:
+ whose, line = GONE[how]
+ section = (line, *section)
+ said = f"put {PUT_BACK_THEN.format(whose=whose)}, then {said}"
+ return variant_refused(ROW, commit, [VARIANT_PATH],
+ {VARIANT_PATH: commit}, section=section,
+ section_said=said)
+
+
+@pytest.mark.parametrize("how", GONE)
+def test_c1_the_move_puts_the_file_back_before_its_section_line(
+ family, holder, offline, tmp_path, how):
+ """C1, rv876's P5e. Members mode's refusal is printed alone; its `git
+ config -f` line made a file of one key, which committed left HEAD with
+ no other section. It puts the file back first now, from where git
+ reads it. Typed as printed and committed, HEAD keeps OTHER's section,
+ the gate passes in every mode and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ commit = moved_in_head(holder, env)
+ gone(holder, env, how)
+
+ refused = validate(holder)
+
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert refused.stderr.splitlines() == moved_refused(commit, how)
+ for line in refusal_lines(refused):
+ type_line(holder, env, line)
+ commit_all(holder, env, "Move the gitlink as printed")
+ assert f'[submodule "members/{OTHER}"]'.encode() in raw(
+ holder, env, "show", f"HEAD:{GITMODULES}").stdout
+ ends_well(holder, env, family, tmp_path)
+
+
+@pytest.mark.parametrize("how", GONE)
+def test_c1_one_put_back_serves_the_move_and_a_url_finding_after_it(
+ family, holder, offline, tmp_path, how):
+ """C1, rv876's P5b: the same state with OTHER's url gone too. Under
+ `--no-members` the move prints first, and `member-gitmodules-url-absent`
+ after it put back the file the move had just staged, one key in it.
+ The move puts the file back first now, and the url finding puts nothing
+ back. Typed in order and committed, the gate passes in every mode and a
+ plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ commit = moved_in_head(holder, env)
+ data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout
+ committed_gitmodules(holder, env, without_url(data, OTHER))
+ gone(holder, env, how)
+
+ result = validate(holder, "--no-members")
+
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert findings(result) == [
+ unnamed_as_moved(VARIANT_PATH, commit, moved_refused(commit, how),
+ said=GITLINK_SAID_FETCHED),
+ URL_ABSENT.format(commit=pinned(holder, OTHER))]
+ typed_in_order(holder, env, result)
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_c1_control_with_the_file_on_disk_the_move_is_as_it_was(
+ family, holder, offline, tmp_path):
+ """No misfire: the same state with the work tree's `.gitmodules` on
+ disk. The move prints no put-back, as before; typed and committed, the
+ gate passes in every mode."""
+ env = cloning(offline, tmp_path, family)
+ commit = moved_in_head(holder, env)
+
+ refused = validate(holder)
+
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert refused.stderr.splitlines() == moved_refused(commit, None)
+ for line in refusal_lines(refused):
+ type_line(holder, env, line)
+ commit_all(holder, env, "Move the gitlink as printed")
+ ends_well(holder, env, family, tmp_path)
+
+
+@pytest.mark.parametrize("how", GONE)
+def test_c1_the_absent_gitlink_s_section_lines_put_the_file_back_first(
+ family, holder, offline, tmp_path, how):
+ """C1's sibling, found with it: `member-gitlink-absent`'s own remedy
+ (#681) for a member taken out by a plain `git rm`, committed, with the
+ work tree's `.gitmodules` then gone. Its section lines made a file of
+ MEMBER's section alone, so committed, HEAD lost OTHER's. The file is
+ put back first now. Typed as printed, committed, and the member checked
+ out as the text says, the gate passes in every mode."""
+ env = cloning(offline, tmp_path, family)
+ commit = pinned(holder, MEMBER)
+ raw(holder, env, "rm", "-q", "--", ROW)
+ commit_all(holder, env, "MEMBER taken out by hand")
+ gone(holder, env, how)
+ whose, line = GONE[how]
+
+ refused = validate(holder)
+
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert (f"with the lines after it, the first of which puts "
+ f"{PUT_BACK_THEN.format(whose=whose)}, then stage .gitmodules. "
+ in refused.stderr), refused.stderr
+ assert refusal_lines(refused) == [
+ f"git update-index --add --cacheinfo 160000,{commit},{ROW}", line,
+ f"git config -f .gitmodules submodule.{ROW}.path {ROW}",
+ f"git config -f .gitmodules submodule.{ROW}.url "
+ f"https://github.com/InkRouter/{MEMBER}.git"]
+ for line in refusal_lines(refused):
+ type_line(holder, env, line)
+ staged_and_committed(holder, env, "Put the member back as printed")
+ raw(holder, env, "submodule", "update", "--init", "--recursive")
+ ends_well(holder, env, family, tmp_path)
+
+
+# --- C2: the removal staged, beside a finding that put the file back --------
+
+@pytest.mark.parametrize("how", GONE)
+def test_c2_the_removal_s_line_puts_nothing_back_over_an_earlier_write(
+ family, holder, offline, tmp_path, how):
+ """C2, rv876's P5d: `git rm --cached members/IRRS`, OTHER's section out
+ of HEAD's `.gitmodules`, and the work tree's file gone. The absent
+ finding puts the file back and writes OTHER's section; the removal's
+ `git checkout HEAD -- .gitmodules`, read from the empty work tree, then
+ took it out, so the commit had nothing to commit. It reads the file git
+ reads now, which gives MEMBER's section, and prints no put-back. Typed
+ in order and committed, the gate passes and a plain clone checks both
+ out."""
+ env = cloning(offline, tmp_path, family)
+ data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout
+ committed_gitmodules(holder, env, section_taken_out(data, OTHER))
+ raw(holder, env, "rm", "-q", "--cached", "--", ROW)
+ gone(holder, env, how)
+
+ found = found_alike(holder, *MODES)
+
+ assert [finding.split(":", 1)[0] for finding in found] == [
+ "FINDING member-gitmodules-absent",
+ "FINDING member-gitlink-removal-staged"]
+ assert found[0] == put_back_first(
+ absent(OTHER, pinned(holder, OTHER), held=True), how)
+ assert commands(found[1]) == [f"git reset -q -- {ROW}"]
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+def url_of(root: Path, env: dict, path: str) -> str:
+ """The url the section of HEAD's `.gitmodules` named `path` gives."""
+ return raw(root, env, "config", "--blob", f"HEAD:{GITMODULES}",
+ f"submodule.{path}.url").stdout.decode("utf-8").strip()
+
+
+#: What `member-gitlink-removal-staged` says its stage line does, and what
+#: it says after its lines where they stage `.gitmodules` (lane 1's MUST 1
+#: on #889).
+STAGED_WHOLE = ("that section in the index, staging .gitmodules whole, which "
+ "stages every other change in it too")
+STAGED_BACK = (": commit .gitmodules then, with what the lines above write "
+ "in it, and nothing else is left to commit.")
+
+
+def written_back(root: Path, env: dict, path: str) -> list[str]:
+ """The removal's lines that write the section of HEAD's `.gitmodules`
+ named `path` back, key by key as HEAD has it, then stage the file (lane
+ 1's MUST 1 on #889). Read before the state is made."""
+ return [f"git config -f .gitmodules submodule.{path}.path {path}",
+ f"git config -f .gitmodules submodule.{path}.url "
+ f"{shlex.quote(url_of(root, env, path))}",
+ "git add -- .gitmodules"]
+
+
+def test_c2_a_section_git_rm_took_is_written_back_into_the_file_put_back(
+ family, holder, offline, tmp_path):
+ """C2's other half, found with it: a plain `git rm members/IRRS`, which
+ takes its section out of the work tree's `.gitmodules` and stages that,
+ beside OTHER's section out of HEAD's, and the work tree's file then
+ deleted. The absent finding puts back the index's, which lacks MEMBER's
+ section, and the removal's `git checkout HEAD -- .gitmodules` took
+ OTHER's out again. Now the removal writes MEMBER's section back, key by
+ key as HEAD has it, into the file put back, and stages the file (lane
+ 1's MUST 1 on #889), before the update, which dies `No url found`
+ without it. Typed in order and committed, the gate passes and a plain
+ clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout
+ committed_gitmodules(holder, env, section_taken_out(data, OTHER))
+ lines = written_back(holder, env, ROW)
+ raw(holder, env, "rm", "-q", "--", ROW)
+ gone(holder, env, "deleted")
+
+ result = validate(holder, "--no-members")
+
+ assert codes(result) == ["member-gitmodules-absent",
+ "member-gitlink-removal-staged"]
+ removal = findings(result)[1]
+ assert commands(removal) == [
+ f"git reset -q -- {ROW}", *lines,
+ f"git submodule update --init --recursive -- {ROW}"]
+ assert ("the first its gitlink, the second to the third the .gitmodules "
+ "section git rm took out, key by key as HEAD has it, in the file "
+ f"a line above put back, the fourth {STAGED_WHOLE}, the fifth its "
+ "checkout" + STAGED_BACK) in removal, removal
+ typed_in_order(holder, env, result)
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+#: `(how the removal and the file's loss are made, the line the removal
+#: prints for .gitmodules)`: the removal's own finding alone, where it is
+#: the one that puts the file back.
+ALONE_GONE = {
+ "plain-rm-file-deleted": (
+ lambda root, env: (raw(root, env, "rm", "-q", "--", ROW),
+ (root / GITMODULES).unlink()),
+ "git checkout HEAD -- .gitmodules"),
+ "cached-rm-deletion-staged": (
+ lambda root, env: (raw(root, env, "rm", "-q", "--cached", "--", ROW),
+ raw(root, env, "rm", "-q", "--", GITMODULES)),
+ "git checkout HEAD -- .gitmodules"),
+ "cached-rm-file-deleted": (
+ lambda root, env: (raw(root, env, "rm", "-q", "--cached", "--", ROW),
+ (root / GITMODULES).unlink()),
+ "git checkout -- .gitmodules"),
+}
+
+
+@pytest.mark.parametrize("make, line", ALONE_GONE.values(), ids=ALONE_GONE)
+def test_c2_a_removal_alone_puts_the_file_back_as_git_reads_it(
+ family, holder, offline, tmp_path, make, line):
+ """The removal's own finding, with no other before it, where the work
+ tree's `.gitmodules` is gone: it puts the file back, from HEAD where a
+ plain `git rm` took MEMBER's section out of the index's, else from
+ where git reads it, so a deletion of `.gitmodules` staged beside the
+ removal is undone too. Typed as printed, nothing is left to commit, and
+ the gate passes in every mode."""
+ env = cloning(offline, tmp_path, family)
+ make(holder, env)
+
+ (removal,) = found_alike(holder, *MODES)
+
+ assert line in commands(removal)
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_c2_control_with_the_file_on_disk_the_removal_is_as_it_was(
+ family, holder, offline, tmp_path):
+ """No misfire: a plain `git rm members/IRRS` with the work tree's
+ `.gitmodules` on disk prints what it printed: the reset, `git checkout
+ HEAD -- .gitmodules` for the section `git rm` took out, and the
+ update."""
+ env = cloning(offline, tmp_path, family)
+ raw(holder, env, "rm", "-q", "--", ROW)
+
+ (removal,) = found_alike(holder, *MODES)
+
+ assert commands(removal) == [
+ f"git reset -q -- {ROW}", "git checkout HEAD -- .gitmodules",
+ f"git submodule update --init --recursive -- {ROW}"]
+ assert ("the first its gitlink, the second the .gitmodules section git "
+ "rm took out, putting that file back as HEAD has it and dropping "
+ "any other change to it, the third its checkout") in removal
+
+
+# --- C3: a git rm of a gitlink after a value finding's lines ----------------
+
+def valued(data: bytes, path: str) -> bytes:
+ """`.gitmodules` bytes with the section giving `path` given `update =
+ None` too, a value git dies on (#768)."""
+ line = f"\tpath = {path}\n".encode()
+ assert data.count(line) == 1, data
+ return data.replace(line, line + b"\tupdate = None\n")
+
+
+def value_committed(root: Path, env: dict, path: str = ROW) -> None:
+ """The section giving `path`, MEMBER's unless said, given `update =
+ None`, committed (#768)."""
+ committed_gitmodules(root, env, valued(
+ raw(root, env, "show", f"HEAD:{GITMODULES}").stdout, path))
+
+
+def staged_first(text: str) -> str:
+ """`text` after #878's sentence that stages `.gitmodules` first."""
+ said = STAGE_SAID + text
+ return said[0].upper() + said[1:]
+
+
+@pytest.mark.parametrize("value", [True, False], ids=["value", "control"])
+def test_c3_the_stray_s_git_rm_stages_the_file_first_after_a_value(
+ family, holder, offline, tmp_path, value):
+ """C3, rv876's P5g: a committed `update = None`, and a stray gitlink at
+ `Members/IRRS` staged with nothing on disk. The value finding's
+ `--unset-all` leaves `.gitmodules` with changes not staged, and the
+ stray's `git rm --cached` then exited 128. It comes after `git add --
+ .gitmodules` now, in every mode, as #878's do. With no value finding
+ before it, the control, it prints none. Typed in order, and the value's
+ fix committed, nothing is left, the gate passes and a plain clone
+ checks both out."""
+ env = cloning(offline, tmp_path, family)
+ if value:
+ value_committed(holder, env)
+ commit = pinned(holder, MEMBER)
+ stage_gitlink(holder, VARIANT_PATH, commit)
+ take = (staged_first(TAKE_OUT) + COMMIT_THAT + ":" + STAGE_LINE if value
+ else f"Take{TAKE_OUT[4:]}{COMMIT_THAT}:")
+
+ found = found_alike(holder, *MODES)
+
+ assert found[-1] == (
+ GITLINK_SAID.format(path=VARIANT_PATH, commit=commit)
+ + VARIANT.format(row_path=ROW, project=MEMBER) + f" {take}"
+ + f"\n git rm --cached -- {VARIANT_PATH}")
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ if value:
+ staged_and_committed(holder, env, "Typed in order")
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_c3_a_hand_mount_s_git_rm_stages_the_file_first_after_a_value(
+ family, holder, offline, tmp_path):
+ """C3's sibling, found with it: a hand mount's plain `git rm`, after
+ the value finding's lines, exited 128 too. `git add -- .gitmodules`
+ comes before it now, the second of the four, and the text counts the
+ others on. Typed in order to the removal, and committed, the gate
+ passes and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ value_committed(holder, env)
+ path = hand_mounted(holder, env, family)
+
+ result = validate(holder, "--no-members")
+
+ assert codes(result) == ["gitmodules-value-refused",
+ "member-gitlink-unnamed"]
+ mount = findings(result)[1]
+ switch, stage, removal, add = commands(mount)
+ assert (stage, removal) == ("git add -- .gitmodules", f"git rm -- {path}")
+ assert (", stage .gitmodules with the second, which stages every other "
+ "change in it too, since git rm refuses to take any gitlink out "
+ "of the index") in mount
+ assert ("take it out with the third, which refuses while its checkout "
+ "holds changes") in mount
+ assert "then mount it with the fourth, " in mount
+ assert add.startswith("python3 scripts/family.py add "), add
+ for line in commands(findings(result)[0]) + [switch, stage, removal]:
+ type_line(holder, env, line)
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_c3_the_wrong_case_move_s_git_rm_stages_the_file_first_after_a_value(
+ family, holder, offline, tmp_path):
+ """C3's sibling, found with it: the wrong-case move under
+ `--no-members`, after the value finding's lines: its `git rm -r
+ --cached` exited 128, and its `update-index` then left the gitlink at
+ both paths. `git add -- .gitmodules` comes first now. Typed in order
+ and committed, the gate passes and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ value_committed(holder, env)
+ commit = recorded_at(holder, env, MEMBER, VARIANT_PATH)
+
+ result = validate(holder, "--no-members")
+
+ assert codes(result) == ["gitmodules-value-refused",
+ "member-gitlink-unnamed"]
+ move = findings(result)[1]
+ assert commands(move) == [
+ "git add -- .gitmodules",
+ f"git rm -r --cached --ignore-unmatch -- {VARIANT_PATH}",
+ f"git update-index --add --cacheinfo 160000,{commit},{ROW}"]
+ assert (" The member is recorded in the wrong case. "
+ + staged_first("move its gitlink to the row's path, at the "
+ "commit the row pins, in the index alone, which "
+ "touches no file, with the next two lines, and "
+ "commit that.")) in move
+ typed_in_order(holder, env, result)
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+# --- C4: the checkout git mv moved ------------------------------------------
+
+#: What `member-gitlink-removal-staged` says of the checkout `git mv` moved
+#: to `Members/IRRS`, before its update (C4).
+MOVED = (
+ " The checkout git mv moved to {variant} stays there, untracked{refused}: "
+ "its .git file "
+ "names the member's store under this holder's .git/modules/ by a path "
+ "relative to it, and its commits are kept there whatever becomes of the "
+ "checkout, so only what is not committed is in the checkout alone, which "
+ "`git -C {variant} status` shows. Delete it by hand once nothing only it "
+ "holds is needed, before the last line: that update points the store at "
+ "{path}, after which `git -C {variant} status` reads {path} instead.")
+
+#: What it says of one at a case variant: `family.py add` refuses it.
+REFUSED_AS_VARIANT = (", and family.py add refuses it as "
+ "family-members-dir-variant")
+
+#: The `git mv` states: nothing more, and #876's two.
+MOVES = {"clean": lambda checkout, env: None, **WORK}
+
+
+@pytest.mark.parametrize("work", MOVES.values(), ids=MOVES)
+def test_c4_the_checkout_git_mv_moved_is_named_before_the_update(
+ family, holder, offline, tmp_path, case_sensitive_disk, work):
+ """C4, in the three `git mv` states rv876 measured: `git mv
+ members/IRRS Members/IRRS`, staged, clean, with a commit, or with an
+ untracked file in the checkout it moved. After the lines were typed,
+ `Members/IRRS` stayed, untracked and named nowhere, `family.py add`
+ refused it, and the update made its status read `members/IRRS`. The
+ removal's finding names it now, in every mode, with `git -C
+ Members/IRRS status` before the update. Typed in order, with that
+ status read and the checkout deleted by hand before the last line as
+ the text says, nothing is left untracked, the gate passes, `family.py
+ add` takes the spare member, the commit is in the member's store, and a
+ plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ (holder / "Members").mkdir()
+ raw(holder, env, "mv", ROW, VARIANT_PATH)
+ work(holder / VARIANT_PATH, env)
+ made = raw(holder / VARIANT_PATH, env, "rev-parse", "HEAD").stdout
+
+ found = found_alike(holder, *MODES)
+
+ assert ("and nothing is left to commit."
+ + MOVED.format(variant=VARIANT_PATH, path=ROW,
+ refused=REFUSED_AS_VARIANT)
+ + " If it is to go,") in found[-1], found[-1]
+ lines = [line for finding in found for line in commands(finding)]
+ assert lines[-1] == f"git submodule update --init --recursive -- {ROW}"
+ for line in lines[:-1]:
+ type_line(holder, env, line)
+ status = raw(holder, env, "-C", VARIANT_PATH, "status", "--porcelain")
+ assert (b"?? scratch.txt\n" in status.stdout) is (work is WORK.get(
+ "untracked")), status.stdout
+ rmtree(holder / VARIANT_PATH)
+ type_line(holder, env, lines[-1])
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ store = holder / ".git" / "modules" / ROW
+ assert raw(holder, env, f"--git-dir={store}", "cat-file", "-e",
+ made.decode("ascii").strip(), check=False).returncode == 0
+ added = run_script(FAMILY, *verb_args("add", holder, family),
+ "--dry-run", env=env)
+ assert added.returncode == 0, added.stdout + added.stderr
+ ends_well(holder, env, family, tmp_path)
+
+
+# --- C5: bootstrap with every member checked out ----------------------------
+
+#: What (a) says where every row's member is checked out and git updated
+#: none of them, and how the run then ends (C5).
+NONE_UPDATED = (" NO MEMBER UPDATED, and not for want of a credential: "
+ "{why}. {cure}")
+STOP_FAILED = "family bootstrap FAILED: {path} (git stopped at it)"
+
+
+def outside_gitlink(root: Path, env: dict, family) -> str:
+ """SPARE's gitlink recorded by hand at `vendor/IRQS`, outside
+ `members/`, with no section, and committed. Returns its path."""
+ stage_gitlink(root, f"vendor/{SPARE}", spare_commits(family)[1])
+ commit_all(root, env, "A gitlink mounted by hand outside members/")
+ return f"vendor/{SPARE}"
+
+
+#: `(how the stop is made in the holder, every member checked out, what
+#: the line says cures it)`: each a gitlink git's init has no url for.
+STOPS = {
+ "outside": (outside_gitlink, OUTSIDE_CURE),
+ "stray": (stray, UNNAMED_CURE),
+ "hand-mount": (hand_mounted, UNNAMED_CURE),
+}
+
+
+@pytest.mark.parametrize("make, cure", STOPS.values(), ids=STOPS)
+def test_c5_a_stop_with_every_member_checked_out_fails_naming_it(
+ family, holder, offline, tmp_path, make, cure):
+ """C5: every member checked out, as in the holder itself, and a gitlink
+ git's init has no url for, at which `git submodule update --init`
+ exited 128 before it updated any member. Bootstrap gave the credential
+ advice and said ok. It names the stop now, with its cure, gives no
+ advice, and fails on the gitlink. The gate's remedy for that gitlink
+ typed as printed and committed, the run ends ok and a plain clone
+ checks both out."""
+ env = cloning(offline, tmp_path, family)
+ path = make(holder, env, family)
+
+ result = bootstrap(holder, env, "--skip-members")
+
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert after_fetch_line(result) == [
+ NONE_UPDATED.format(why=STOPPED.format(path=path),
+ cure=cure.format(path=path)),
+ STOP_FAILED.format(path=path)]
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ commit_all(holder, env, "The gate's remedy, typed as printed")
+ booted = bootstrap(holder, env, "--skip-members")
+ assert booted.stdout.endswith("\nfamily bootstrap ok\n"), (
+ booted.stdout + booted.stderr)
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_c5_a_bad_line_after_the_first_section_fails_naming_a_stop(
+ family, holder, offline, tmp_path):
+ """C5, the issue's own state (RV877's O1): every member checked out, a
+ line git cannot parse committed in HEAD's `.gitmodules` after the first
+ section, and the work tree's file deleted. Git reads the index's up to
+ that line and dies `No url found` at the second member. Bootstrap said
+ ok with the advice. It fails now on the gitlink it stopped at, with no
+ advice: which one is the blob's reading, #877's (#856), so either
+ member's path is taken here."""
+ env = cloning(offline, tmp_path, family)
+ data = (holder / GITMODULES).read_bytes()
+ first = data.index(b"[submodule", 1)
+ bad = (chr(9) + 'branch = "unterminated' + chr(10)).encode("ascii")
+ committed_gitmodules(holder, env, data[:first] + bad + data[first:])
+ (holder / GITMODULES).unlink()
+
+ result = bootstrap(holder, env, "--skip-members")
+
+ assert result.returncode == 1, result.stdout + result.stderr
+ said = after_fetch_line(result)
+ assert said[0].startswith(
+ " NO MEMBER UPDATED, and not for want of a credential: git stopped "
+ "at members/"), said
+ assert re.fullmatch(rf"family bootstrap FAILED: members/({MEMBER}|"
+ rf"{OTHER}) \(git stopped at it\)", said[1]), said
+
+
+def test_c5_an_unmerged_file_with_every_member_checked_out_fails(
+ family, holder, offline, tmp_path):
+ """C5's unmerged case: every member checked out, and `.gitmodules` held
+ only at a merge's conflict stages, which git reads nothing from, so its
+ init died at the first gitlink. Bootstrap said ok. It says why and the
+ merge's cure now, and fails on `.gitmodules`; resolved and added as the
+ cure says, the run ends ok."""
+ env = cloning(offline, tmp_path, family)
+ unmerged_gitmodules(holder, env)
+
+ result = bootstrap(holder, env, "--skip-members")
+
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert after_fetch_line(result) == [
+ NONE_UPDATED.format(why=UNMERGED, cure=UNMERGED_CURE),
+ "family bootstrap FAILED: .gitmodules (git cannot read it)"]
+ raw(holder, env, "add", GITMODULES)
+ booted = bootstrap(holder, env, "--skip-members")
+ assert booted.stdout.endswith("\nfamily bootstrap ok\n"), (
+ booted.stdout + booted.stderr)
+
+
+def test_c5_control_the_holder_as_made_bootstraps_ok(family, holder, offline,
+ tmp_path):
+ """No misfire: the holder as `family.py` made it, every member checked
+ out and nothing that stops git's init. Asking `init_stop` with no row
+ left finds nothing, and the run ends ok, saying every member and leg
+ was fetched, with nothing on stderr."""
+ env = cloning(offline, tmp_path, family)
+
+ result = bootstrap(holder, env, "--skip-members")
+
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert " every member and leg fetched" in result.stdout.splitlines()
+ assert result.stderr == ""
+
+
+# --- rv882's items on #882, taken here ---------------------------------------
+
+#: What the `-f` sentence says is on disk at a gitlink with no `.git` entry
+#: there (rv882's NIT 2), in place of "a checkout".
+OVER_FILES = "what is on disk there, which is no checkout"
+
+
+def head_paths(root: Path, env: dict) -> set[str]:
+ """Every path a section of HEAD's `.gitmodules` gives."""
+ listed = raw(root, env, "config", "--blob", f"HEAD:{GITMODULES}",
+ "--get-regexp", r"\.path$").stdout.decode("utf-8")
+ return {line.split(" ", 1)[1] for line in listed.splitlines()}
+
+
+def test_rv882_should_1_no_git_add_where_the_work_tree_holds_no_file(
+ family, holder, offline, tmp_path):
+ """rv882's SHOULD 1 on #882: (D), the gitlink at tools/x that no
+ section gives, with the work tree's `.gitmodules` deleted and not
+ staged. No `git add -- .gitmodules` is printed, in any mode: it would
+ stage that deletion, the commit after it deleted the file from HEAD,
+ and a plain clone stopped at members/IRRS. Typed as printed and
+ committed, HEAD's `.gitmodules` still gives both members, the gate
+ passes in every mode and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ section_taken_out_alone(holder, env, family)
+ gone(holder, env, "deleted")
+
+ finding = found_in_every_mode(holder, "gitlink-unnamed")
+
+ assert finding == outside(TOOLS, staged_at(holder, env, TOOLS),
+ COMMIT_THAT)
+ typed(holder, env, finding)
+ commit_all(holder, env, "Typed as printed")
+ assert head_paths(holder, env) == {ROW, f"members/{OTHER}"}
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_rv882_should_1_no_git_add_where_the_index_holds_no_file(
+ family, holder, offline, tmp_path):
+ """rv882's SHOULD 1, the other branch: (D) with `.gitmodules` on disk
+ and its removal staged (`git rm --cached`), so the index holds none.
+ No `git add -- .gitmodules` is printed: it would stage the file again
+ over the removal the person staged. The `git rm --cached` alone, typed
+ as printed, exits 0, and the staged removal stays as it was."""
+ env = cloning(offline, tmp_path, family)
+ section_taken_out_alone(holder, env, family)
+ raw(holder, env, "rm", "-q", "--cached", "--", GITMODULES)
+
+ finding = found_in_every_mode(holder, "gitlink-unnamed")
+
+ assert finding == outside(TOOLS, staged_at(holder, env, TOOLS),
+ COMMIT_THAT)
+ typed(holder, env, finding)
+ staged = raw(holder, env, "diff", "--cached", "--name-status").stdout
+ assert staged == f"D\t{GITMODULES}\nD\t{TOOLS}\n".encode(), staged
+
+
+@pytest.mark.parametrize("how", GONE)
+def test_rv882_should_2_a_gitlink_the_file_git_reads_gives_is_no_finding(
+ family, holder, offline, tmp_path, how):
+ """rv882's SHOULD 2 on #882: (A), IRSS mounted at tools/x with `git
+ submodule add` and committed, with the work tree's `.gitmodules` gone.
+ The index's or HEAD's file, which git reads in its place, gives tools/x
+ a section, and a plain clone checks it out, while `gitlink-unnamed`
+ said that no section gave it. The paths are read from the file git
+ reads now: no mode finds `gitlink-unnamed`, and the gate passes."""
+ env = cloning(offline, tmp_path, family)
+ at_tools(holder, env, family)
+ gone(holder, env, how)
+
+ assert_passes_in_every_mode(holder, 2)
+
+
+def on_disk_under(root: Path, path: str) -> list[str]:
+ """Every entry on disk under `path`, and its bytes where it is a file."""
+ return sorted((str(entry.relative_to(root)),
+ entry.read_bytes() if entry.is_file() else b"")
+ for entry in [root / path, *(root / path).rglob("*")])
+
+
+def files_at(root: Path, path: str) -> None:
+ """A directory at `path` that holds a file and no `.git`."""
+ (root / path).mkdir(parents=True)
+ (root / path / "kept.txt").write_bytes(b"kept\n")
+
+
+def a_file_at(root: Path, path: str) -> None:
+ """A file at `path`."""
+ (root / path).parent.mkdir(parents=True, exist_ok=True)
+ (root / path).write_bytes(b"kept\n")
+
+
+def an_empty_directory_at(root: Path, path: str) -> None:
+ """An empty directory at `path`."""
+ (root / path).mkdir(parents=True)
+
+
+def a_link_to_an_empty_directory_at(root: Path, path: str) -> None:
+ """A symbolic link at `path` to an empty directory outside the holder
+ (lane 1's NIT 2 on #889), or a skip where none can be made."""
+ target = root.parent / "an-empty-directory"
+ target.mkdir()
+ (root / path).parent.mkdir(parents=True, exist_ok=True)
+ try:
+ (root / path).symlink_to(target, target_is_directory=True)
+ except OSError as exc:
+ pytest.skip(f"no symbolic link can be made here: {exc}")
+
+
+#: `(what is on disk at tools/x, the flag its git rm --cached carries, what
+#: the text says of it)`: git 2.43 refused the plain line over the first
+#: two (rv882's NIT 2) and the fourth (`use -f to force removal`, exit 1,
+#: lane 1's NIT 2 on #889), and took it over the third.
+ON_DISK = {
+ "a-directory-holding-a-file": (files_at, " -f", FORCED_NO_HEAD.replace(
+ "over a checkout", f"over {OVER_FILES}")),
+ "a-file": (a_file_at, " -f", FORCED_NO_HEAD.replace(
+ "over a checkout", f"over {OVER_FILES}")),
+ "an-empty-directory": (an_empty_directory_at, "", COMMIT_THAT),
+ "a-link-to-an-empty-directory": (
+ a_link_to_an_empty_directory_at, " -f", FORCED_NO_HEAD.replace(
+ "over a checkout", f"over {OVER_FILES}")),
+}
+
+
+@pytest.mark.parametrize("make, flag, said", ON_DISK.values(), ids=ON_DISK)
+def test_rv882_nit_2_a_gitlink_staged_over_what_is_no_checkout(
+ family, holder, offline, tmp_path, make, flag, said):
+ """rv882's NIT 2 on #882: IRQS's gitlink staged by hand at tools/x,
+ HEAD recording nothing there, over a directory holding a file and no
+ `.git`, or a file. Git refused the plain `git rm --cached` there
+ (`staged content different from both the file and the HEAD`, exit 1),
+ and `_forced` asked for a `.git` entry alone, so no `-f` was printed.
+ It is now, with what is on disk there; over an empty directory, which
+ git takes the plain line over, it is not. Typed as printed, the line
+ exits 0 and leaves what was on disk; the gate passes in every mode and
+ a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ make(holder, TOOLS)
+ commit = spare_commits(family)[1]
+ stage_gitlink(holder, TOOLS, commit)
+ before = on_disk_under(holder, "tools")
+
+ finding = found_in_every_mode(holder, "gitlink-unnamed")
+
+ assert finding == outside(TOOLS, commit, said, flag)
+ typed(holder, env, finding)
+ assert on_disk_under(holder, "tools") == before
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_rv882_nit_2_the_move_takes_f_over_what_is_no_checkout(
+ family, holder, offline, tmp_path, case_sensitive_disk):
+ """rv882's NIT 2, in the wrong-case move, which asks `_forced` too:
+ MEMBER recorded only at `Members/IRRS` and committed, then staged
+ there at another commit, over a directory holding a file and no
+ `.git`. The move's `git rm -r --cached` carries `-f` now, and says
+ what is on disk there; typed as printed and committed, it exits 0,
+ leaves the file, and the gate passes in members mode."""
+ env = cloning(offline, tmp_path, family)
+ commit = recorded_at(holder, env, MEMBER, VARIANT_PATH)
+ other = spare_commits(family)[1]
+ stage_gitlink(holder, VARIANT_PATH, other)
+ files_at(holder, VARIANT_PATH)
+
+ refused = validate(holder)
+
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert refused.stderr.splitlines() == [
+ line.replace("over a checkout", f"over {OVER_FILES}")
+ for line in variant_refused(ROW, commit, [VARIANT_PATH],
+ {VARIANT_PATH: other},
+ forced=(VARIANT_PATH,))]
+ for line in refusal_lines(refused):
+ type_line(holder, env, line)
+ commit_all(holder, env, "Moved as printed")
+ assert (holder / VARIANT_PATH / "kept.txt").read_bytes() == b"kept\n"
+ assert validate(holder).returncode == 0
+
+
+# --- lane 1's review of #889, round 1 ---------------------------------------
+
+OTHER_ROW = f"members/{OTHER}"
+
+
+def test_round1_must_1_p4_the_first_removal_puts_back_head_s_file(
+ family, holder, offline, tmp_path):
+ """Lane 1's MUST 1 on #889, its P4: `git rm --cached members/IRRS`,
+ `git rm members/IRSS`, and the work tree's `.gitmodules` deleted. The
+ first removal put back the index's file, which the second `git rm` had
+ taken IRSS's section out of, and the second wrote that section into
+ the work tree's file alone: `MM .gitmodules`, and a plain commit
+ dropped it from HEAD. The first puts back HEAD's now, since the
+ index's lacks a section another staged removal needs, and the second
+ prints no line for the file, as #876's head printed. Typed in order,
+ nothing is left to commit, and the gate passes in every mode."""
+ env = cloning(offline, tmp_path, family)
+ raw(holder, env, "rm", "-q", "--cached", "--", ROW)
+ raw(holder, env, "rm", "-q", "--", OTHER_ROW)
+ gone(holder, env, "deleted")
+
+ found = found_alike(holder, *MODES)
+
+ assert [commands(finding) for finding in found] == [
+ [f"git reset -q -- {ROW}", "git checkout HEAD -- .gitmodules"],
+ [f"git reset -q -- {OTHER_ROW}",
+ f"git submodule update --init --recursive -- {OTHER_ROW}"]]
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ ends_well(holder, env, family, tmp_path)
+
+
+@pytest.mark.parametrize("how", ["deleted", "on-disk"])
+def test_round1_must_1_p5_the_section_written_back_is_staged(
+ family, holder, offline, tmp_path, how):
+ """Lane 1's MUST 1, its P5: MEMBER recorded only at `Members/IRRS`
+ (`moved_in_head`), then `git rm members/IRSS`, under `--no-members`.
+ Where the work tree's `.gitmodules` was gone, the move put back the
+ index's, which lacks IRSS's section, and the removal wrote that
+ section into the work tree's file alone, so the commit the move names
+ dropped it; on disk, the removal's `git checkout HEAD -- .gitmodules`
+ took the move's write out. Either way it writes IRSS's section back
+ key by key and stages the file now. Typed in order and committed with
+ a plain `git commit`, as the move says, nothing is left, HEAD's
+ `.gitmodules` gives both members, and the gate passes in every mode."""
+ env = cloning(offline, tmp_path, family)
+ moved_in_head(holder, env)
+ lines = written_back(holder, env, OTHER_ROW)
+ raw(holder, env, "rm", "-q", "--", OTHER_ROW)
+ if how == "deleted":
+ gone(holder, env, how)
+
+ result = validate(holder, "--no-members")
+
+ assert codes(result)[0] == "member-gitlink-unnamed"
+ assert codes(result)[-1] == "member-gitlink-removal-staged"
+ assert commands(findings(result)[-1]) == [
+ f"git reset -q -- {OTHER_ROW}", *lines,
+ f"git submodule update --init --recursive -- {OTHER_ROW}"]
+ typed_in_order(holder, env, result)
+ commit_all(holder, env, "Committed as the move says")
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ assert head_paths(holder, env) == {ROW, OTHER_ROW}
+ ends_well(holder, env, family, tmp_path)
+
+
+def stale_section(root: Path, env: dict, family) -> None:
+ """A section for `members/IRQS`, which no row names and no gitlink is
+ at, committed in HEAD's `.gitmodules`: one `family.py remove` left."""
+ data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout
+ committed_gitmodules(root, env, data + (
+ f'[submodule "members/{SPARE}"]\n\tpath = members/{SPARE}\n'
+ f"\turl = https://github.com/InkRouter/{SPARE}.git\n").encode())
+
+
+def mounted_with_its_section(root: Path, env: dict, family) -> None:
+ """A member mounted by hand at `members/IRQS` with `git submodule
+ add`, its section with it, committed, and no row."""
+ submodule_added(root, env, family, f"members/{SPARE}")
+ commit_all(root, env, "A member mounted by hand with its section")
+
+
+def head_edited(edit):
+ """A state made by `edit` on HEAD's `.gitmodules` bytes, committed."""
+ def made(root: Path, env: dict, family) -> None:
+ data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout
+ committed_gitmodules(root, env, edit(data))
+ return made
+
+
+def value_staged_out(path: str):
+ """A state in which `update = None` is committed in the section giving
+ `path`, and the file without it is staged: git rm dies reading a work
+ tree's file that gives such a value (`invalid value for
+ 'submodule..update'`, exit 128), so the person mended it first."""
+ def made(root: Path, env: dict, family) -> None:
+ data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout
+ committed_gitmodules(root, env, valued(data, path))
+ (root / GITMODULES).write_bytes(data)
+ raw(root, env, "add", "--", GITMODULES)
+ return made
+
+
+#: `(the state before the removal, the code of the finding it makes)`: each
+#: finding's lines write the work tree's `.gitmodules`, or it says to
+#: commit the file as it stands, and the removal's `git checkout HEAD --
+#: .gitmodules` put HEAD's back over that (lane 1's SHOULD 1 on #889). The
+#: value in MEMBER's own section is one a section written back key by key
+#: must leave out.
+EARLIER_WRITES = {
+ "section-absent": (head_edited(
+ lambda data: section_taken_out(data, OTHER)),
+ "member-gitmodules-absent"),
+ "url-absent": (head_edited(lambda data: without_url(data, OTHER)),
+ "member-gitmodules-url-absent"),
+ "value-in-another-section": (value_staged_out(OTHER_ROW),
+ "gitmodules-value-refused"),
+ "value-in-its-own-section": (value_staged_out(ROW),
+ "gitmodules-value-refused"),
+ "a-section-no-row-names": (stale_section, "gitmodules-path-unnamed"),
+ "a-hand-mount-with-its-section": (mounted_with_its_section,
+ "member-gitlink-unnamed"),
+}
+
+
+@pytest.mark.parametrize("make, code", EARLIER_WRITES.values(),
+ ids=EARLIER_WRITES)
+def test_round1_should_1_no_put_back_over_an_earlier_write_on_disk(
+ family, holder, offline, tmp_path, make, code):
+ """Lane 1's SHOULD 1 on #889: a finding whose lines write the work
+ tree's `.gitmodules`, or that says to commit it as it stands, then a
+ plain `git rm members/IRRS`, with the file on disk. The removal's `git
+ checkout HEAD -- .gitmodules` put HEAD's file back over that, so the
+ commit after it had nothing to commit and the earlier finding came
+ back. It writes MEMBER's section
+ back key by key now, leaving out a value git dies on, and stages the
+ file, in every mode. Typed in order and committed, the gate passes in
+ every mode and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ lines = written_back(holder, env, ROW)
+ make(holder, env, family)
+ raw(holder, env, "rm", "-q", "--", ROW)
+
+ found = found_alike(holder, *MODES)
+
+ assert f"FINDING {code}: " in found[0], found[0]
+ assert found[-1].startswith("FINDING member-gitlink-removal-staged: ")
+ assert commands(found[-1]) == [
+ f"git reset -q -- {ROW}", *lines,
+ f"git submodule update --init --recursive -- {ROW}"]
+ assert ("the second to the third the .gitmodules section git rm took "
+ "out, key by key as HEAD has it, in the work tree's file, as the "
+ f"lines above leave it, the fourth {STAGED_WHOLE}, the fifth its "
+ "checkout" + STAGED_BACK) in found[-1], found[-1]
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_round1_should_1_a_second_section_taken_out_by_hand_stays_out(
+ family, holder, offline, tmp_path):
+ """Lane 1's SHOULD 1, beside `gitmodules-path-twice`, whose remedy is a
+ by-hand `--unset-all` of each key of the section that is not the
+ member's: a second section giving IRSS's path committed, then a plain
+ `git rm members/IRRS`. The removal's `git checkout HEAD --
+ .gitmodules` put the second section back. It writes MEMBER's section
+ back key by key now and stages the file. The second section taken out
+ by hand as that finding says, then the removal's lines, as printed
+ before, typed and committed, the gate passes in every mode."""
+ env = cloning(offline, tmp_path, family)
+ lines = written_back(holder, env, ROW)
+ data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout
+ committed_gitmodules(holder, env, data + (
+ f'[submodule "second"]\n\tpath = {OTHER_ROW}\n'
+ "\turl = https://github.com/InkRouter/Second.git\n").encode())
+ raw(holder, env, "rm", "-q", "--", ROW)
+
+ found = found_alike(holder, *MODES)
+
+ assert [finding.split(":", 1)[0] for finding in found] == [
+ "FINDING gitmodules-path-twice",
+ "FINDING member-gitlink-removal-staged"]
+ assert commands(found[1]) == [
+ f"git reset -q -- {ROW}", *lines,
+ f"git submodule update --init --recursive -- {ROW}"]
+ for key in ("path", "url"):
+ raw(holder, env, "config", "-f", GITMODULES, "--unset-all",
+ f"submodule.second.{key}")
+ typed(holder, env, found[1])
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+#: What `member-gitlink-removal-staged` says of a clone of its own at a case
+#: variant of the row's path (lane 1's SHOULD 2 on #889), as the wrong-case
+#: move says it.
+CLONE_KEPT = (
+ " The clone at {variant}, whose .git is a directory, stays there, "
+ "untracked, and family.py add refuses it as family-members-dir-variant: "
+ "it holds its commits there and nowhere else, and `git -C {variant} log "
+ "--oneline HEAD --branches --not --remotes` lists the ones not pushed, "
+ "so move it out of the holder whole, by hand, rather than delete it.")
+
+
+def test_round1_should_2_a_clone_at_a_case_variant_is_moved_out_whole(
+ family, holder, offline, tmp_path, case_sensitive_disk):
+ """Lane 1's SHOULD 2 on #889: `git rm --cached members/IRRS`, its
+ checkout deleted, IRRS cloned at `Members/IRRS` with a commit only that
+ clone holds, and `git add Members/IRRS`. The removal's finding said
+ the checkout `git mv` moved was there, its commits kept in the
+ member's store, to delete by hand: no `git mv` happened, its `.git` is
+ a directory, and deleted, the commit was lost. It says what a clone
+ of its own is now, in every mode, with the `git log` that lists that
+ commit, and to move it out whole. Typed in order, the clone moved out
+ of the holder as it says, nothing is left, the moved clone keeps the
+ commit, the gate passes in every mode and a plain clone checks both
+ out."""
+ env = cloning(offline, tmp_path, family)
+ raw(holder, env, "rm", "-q", "--cached", "--", ROW)
+ rmtree(holder / ROW)
+ (holder / "Members").mkdir()
+ raw(holder, env, "clone", "-q", "--",
+ str(family["remotes"] / f"{MEMBER}.git"), VARIANT_PATH)
+ clone = holder / VARIANT_PATH
+ raw(clone, env, "-c", "user.name=t", "-c", "user.email=t@t", "commit",
+ "-q", "--allow-empty", "-m", "Only this clone holds it")
+ made = raw(clone, env, "rev-parse", "HEAD").stdout.decode("ascii").strip()
+ raw(holder, env, "add", "--", VARIANT_PATH)
+
+ found = found_alike(holder, *MODES)
+
+ assert "The checkout git mv moved" not in found[-1]
+ assert CLONE_KEPT.format(variant=VARIANT_PATH) in found[-1], found[-1]
+ for finding in found:
+ typed(holder, env, finding)
+ listed = raw(holder, env, "-C", VARIANT_PATH, "log", "--oneline", "HEAD",
+ "--branches", "--not", "--remotes").stdout.decode().split()
+ assert made.startswith(listed[0]), listed
+ kept = tmp_path / "kept-out"
+ shutil.move(str(clone), str(kept))
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ assert raw(kept, env, "cat-file", "-e", made,
+ check=False).returncode == 0
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_round1_should_3_a_checkout_git_mv_moved_out_of_members_is_named(
+ family, holder, offline, tmp_path):
+ """Lane 1's SHOULD 3 on #889: `git mv members/IRRS renamed-1`, staged,
+ with an untracked file in the checkout it moved. Typed as printed,
+ `renamed-1/` was left untracked and named by no finding, after the
+ update its status read members/IRRS, so the file went unseen, and the
+ gate passed. The removal's finding names it now, in every mode, as C4
+ names one at a case variant, with no `family.py add` refusal, which
+ only a variant meets; its section is written back key by key, after
+ the unnamed section's lines took it out. Typed in order but the last
+ line, its status shows the file; deleted by hand as the text says,
+ then the update typed and the file committed, nothing is left, the
+ gate passes in every mode and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ lines = written_back(holder, env, ROW)
+ raw(holder, env, "mv", "--", ROW, RENAMED)
+ (holder / RENAMED / "scratch.txt").write_bytes(b"mine\n")
+
+ found = found_alike(holder, *MODES)
+
+ assert codes_of(found) == ["gitmodules-path-unnamed",
+ "member-gitlink-removal-staged"]
+ assert (MOVED.format(variant=RENAMED, path=ROW, refused="")
+ + " If it is to go,") in found[-1], found[-1]
+ assert commands(found[-1]) == [
+ f"git reset -q -- {ROW}", *lines,
+ f"git submodule update --init --recursive -- {ROW}"]
+ typed_lines = [line for finding in found for line in commands(finding)]
+ for line in typed_lines[:-1]:
+ type_line(holder, env, line)
+ status = raw(holder, env, "-C", RENAMED, "status", "--porcelain").stdout
+ assert b"?? scratch.txt\n" in status, status
+ rmtree(holder / RENAMED)
+ type_line(holder, env, typed_lines[-1])
+ staged_and_committed(holder, env, "Typed in order")
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ ends_well(holder, env, family, tmp_path)
+
+
+def codes_of(found: list[str]) -> list[str]:
+ """The code of each of `found`, the findings as printed."""
+ return [finding.split(":", 1)[0].removeprefix("FINDING ")
+ for finding in found]
+
+
+def test_round1_nit_3_the_move_says_what_is_on_disk_at_each_variant(
+ family, holder, offline, tmp_path, case_sensitive_disk):
+ """Lane 1's NIT 3 on #889: MEMBER recorded only at `MEMBERS/IRRS` and
+ `Members/IRRS`, both then staged at another commit, a directory
+ holding a file at the one and a checkout at the other. The `-f`
+ sentence said of both "what is on disk there, which is no checkout"
+ once one was no checkout. It says each by its path now. Typed as
+ printed and committed, the lines exit 0, and the gate passes in
+ members mode."""
+ env = cloning(offline, tmp_path, family)
+ variants = ["MEMBERS/IRRS", VARIANT_PATH]
+ commit = recorded_at(holder, env, MEMBER, *variants)
+ other = spare_commits(family)[1]
+ for variant in variants:
+ stage_gitlink(holder, variant, other)
+ files_at(holder, variants[0])
+ raw(holder, env, "init", "-q", "--", variants[1])
+
+ refused = validate(holder)
+
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert refused.stderr.splitlines() == [
+ line.replace("over a checkout", f"over what is on disk at "
+ f"{variants[0]}, which is no checkout, and over a "
+ f"checkout at {variants[1]}")
+ for line in variant_refused(ROW, commit, variants,
+ dict.fromkeys(variants, other),
+ forced=tuple(variants))]
+ for line in refusal_lines(refused):
+ type_line(holder, env, line)
+ commit_all(holder, env, "Moved as printed")
+ assert validate(holder).returncode == 0
+
+
+#: What (a) says where no row is declared and git updated nothing (lane 1's
+#: NIT 5 on #889).
+NOTHING_UPDATED = NONE_UPDATED.replace("NO MEMBER UPDATED", "NOTHING UPDATED")
+
+
+def no_rows(root: Path, env: dict) -> None:
+ """`members: []` in `family.yaml`, staged: the rows are its last key,
+ rewritten wholesale by `family.py`."""
+ manifest = root / "family.yaml"
+ text = manifest.read_text(encoding="utf-8")
+ manifest.write_text(text[:text.index("\nmembers:")] + "\nmembers: []\n",
+ encoding="utf-8")
+ raw(root, env, "add", "--", "family.yaml")
+
+
+def test_round1_nit_5_with_no_rows_nothing_updated_is_said(
+ family, holder, offline, tmp_path):
+ """Lane 1's NIT 5 on #889: `members: []`, and a gitlink at vendor/IRQS
+ that no section gives, committed. Git's init stops there, so the run
+ fails, as it should, where #876's head said ok; but its line said "NO
+ MEMBER UPDATED" under "0 member(s)". It says nothing was updated
+ now."""
+ env = cloning(offline, tmp_path, family)
+ no_rows(holder, env)
+ path = outside_gitlink(holder, env, family)
+
+ result = bootstrap(holder, env, "--skip-members")
+
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert result.stdout.splitlines()[0].endswith(" 0 member(s)")
+ assert after_fetch_line(result) == [STOP_FAILED.format(path=path)]
+ assert NOTHING_UPDATED.format(
+ why=STOPPED.format(path=path),
+ cure=OUTSIDE_CURE.format(path=path)) in result.stderr.splitlines()
+
+
+def test_round1_nit_6_the_value_s_put_back_serves_the_removal_after_it(
+ family, holder, offline, tmp_path):
+ """Lane 1's NIT 6 on #889 (its mutant MX1): `update = None` committed
+ in IRSS's section, `git rm --cached members/IRRS`, and the work tree's
+ `.gitmodules` deleted. The value finding puts back HEAD's file and
+ records that, so the removal after it prints no line for the file,
+ which HEAD's gives MEMBER's section in. Put back again from the
+ index's, which holds the value, it undid the fix, and the commit died
+ (`invalid value for 'submodule.members/IRSS.update'`, exit 128).
+ Typed in order and committed, the gate passes in every mode and a
+ plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ value_committed(holder, env, OTHER_ROW)
+ raw(holder, env, "rm", "-q", "--cached", "--", ROW)
+ gone(holder, env, "deleted")
+
+ found = found_alike(holder, *MODES)
+
+ assert codes_of(found) == ["gitmodules-value-refused",
+ "member-gitlink-removal-staged"]
+ assert commands(found[0])[0] == "git checkout HEAD -- .gitmodules"
+ assert commands(found[1]) == [f"git reset -q -- {ROW}"]
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ staged_and_committed(holder, env, "Typed in order")
+ ends_well(holder, env, family, tmp_path)
+
+
+# --- lane 1's review of #889, round 2 ---------------------------------------
+
+#: `(the flags of the removal, the lines its finding prints)` where a hand
+#: mount's `git rm` came before it and the work tree's `.gitmodules` is
+#: gone: the index's file put back, or HEAD's after a plain `git rm`, as
+#: round 0 printed (lane 1's MUST 2 on #889, round 2).
+HAND_MOUNT_THEN = {
+ "cached": (["--cached"],
+ [f"git reset -q -- {ROW}", "git checkout -- .gitmodules"]),
+ "plain": ([], [f"git reset -q -- {ROW}",
+ "git checkout HEAD -- .gitmodules",
+ f"git submodule update --init --recursive -- {ROW}"]),
+}
+
+
+@pytest.mark.parametrize("kind", HAND_MOUNT_THEN)
+def test_round2_must_2_a_hand_mount_s_git_rm_writes_nothing_in_a_gone_file(
+ family, holder, offline, tmp_path, kind):
+ """Lane 1's MUST 2 on #889, round 2: SPARE mounted by hand at
+ `members/IRQS` (`hand_mounted`), then `git rm --cached members/IRRS`
+ or a plain `git rm members/IRRS`, and the work tree's `.gitmodules`
+ deleted. Round 1 counted the hand mount's `git rm` as a write, though
+ it writes nothing into a file that is gone, so the removal wrote
+ MEMBER's section key by key "in the file a line above put back", which
+ no line had: committed, HEAD's `.gitmodules` held that one section,
+ and the gate found `member-gitmodules-absent` in every mode. Only a
+ put-back makes a file to write into there, so the removal puts the
+ file back itself, as round 0 did. Typed in order and committed,
+ nothing is left, HEAD's `.gitmodules` gives both members, the gate
+ passes in every mode and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ flags, lines = HAND_MOUNT_THEN[kind]
+ hand_mounted(holder, env, family)
+ raw(holder, env, "rm", "-q", *flags, "--", ROW)
+ gone(holder, env, "deleted")
+
+ found = found_alike(holder, *MODES)
+
+ assert codes_of(found) == ["member-gitlink-unnamed",
+ "member-gitlink-removal-staged"]
+ assert commands(found[1]) == lines
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ staged_and_committed(holder, env, "Typed in order")
+ assert raw(holder, env, "status", "--porcelain").stdout == b""
+ assert head_paths(holder, env) == {ROW, OTHER_ROW}
+ ends_well(holder, env, family, tmp_path)
+
+
+def test_round2_nit_a_clone_at_no_case_variant_is_not_said_refused(
+ family, holder, offline, tmp_path):
+ """Lane 1's NIT on #889, round 2 (its mutant MY3): `git rm --cached
+ members/IRRS`, its checkout deleted, then IRRS cloned at `tools/x`,
+ which is no case variant of the row's path, and `git add tools/x`.
+ `member-gitlink-removal-staged` names a clone of its own only at a
+ case variant of the row's path, which `family.py add` refuses as
+ `family-members-dir-variant` (round 1's SHOULD 2); of one at
+ `tools/x`, which `gitlink-unnamed` takes out of the index, it says in
+ every mode what it says with no clone there. Typed in order, the clone
+ stays where it is, untracked, nothing is left to commit, the gate
+ passes in every mode and a plain clone checks both out."""
+ env = cloning(offline, tmp_path, family)
+ raw(holder, env, "rm", "-q", "--cached", "--", ROW)
+ rmtree(holder / ROW)
+ alone = found_alike(holder, *MODES)
+ raw(holder, env, "clone", "-q", "--",
+ str(family["remotes"] / f"{MEMBER}.git"), TOOLS)
+ raw(holder, env, "add", "--", TOOLS)
+
+ found = found_alike(holder, *MODES)
+
+ assert codes_of(found) == ["gitlink-unnamed",
+ "member-gitlink-removal-staged"]
+ assert found[1] == alone[-1]
+ typed_in_order(holder, env, validate(holder, "--no-members"))
+ assert raw(holder, env, "status", "--porcelain").stdout == b"?? tools/\n"
+ ends_well(holder, env, family, tmp_path)
+
+
+#: What a hand mount's text says of its `git rm` where git refuses the
+#: gitlink as staged (lane 1's SHOULD on #889, round 2), with git's two
+#: refusals (its NIT 1, round 3), and elsewhere.
+STAGED_REFUSED = (
+ "take it out with the second, which refuses while its checkout holds "
+ "changes, and while the index stages it at a commit HEAD does not record "
+ "there, as it does here (the following file has changes staged in the "
+ "index, or, where what is on disk differs too, has staged content "
+ "different from both the file and the HEAD, exit 1), and takes its "
+ ".gitmodules section out too")
+PLAIN_REFUSED = ("take it out with the second, which refuses while its "
+ "checkout holds changes and takes its .gitmodules section "
+ "out too")
+
+
+def moved_by_git_mv(root: Path, env: dict, family) -> str:
+ """`git mv members/IRRS members/IRRS2`, staged. Returns the new path."""
+ raw(root, env, "mv", "--", ROW, f"{ROW}2")
+ return f"{ROW}2"
+
+
+def staged_by_hand(make):
+ """A state in which SPARE's gitlink is staged at `members/IRQS`, not
+ committed, with what `make` puts on disk there."""
+ def staged(root: Path, env: dict, family) -> str:
+ path = f"members/{SPARE}"
+ stage_gitlink(root, path, spare_commits(family)[1])
+ make(root / path)
+ return path
+ return staged
+
+
+def committed_with_its_checkout(root: Path, env: dict, family) -> str:
+ """`mounted_with_its_section`'s state. Returns the gitlink's path."""
+ mounted_with_its_section(root, env, family)
+ return f"members/{SPARE}"
+
+
+def moved_with_a_commit(root: Path, env: dict, family) -> str:
+ """`moved_by_git_mv`'s state, then a commit in the checkout it moved.
+ Returns the new path."""
+ path = moved_by_git_mv(root, env, family)
+ raw(root / path, env, "-c", "user.name=t", "-c", "user.email=t@t",
+ "commit", "-q", "--allow-empty", "-m", "Made in the moved checkout")
+ return path
+
+
+def holding_a_file(where: Path) -> None:
+ """A directory at `where` that holds a file and no `.git`."""
+ where.mkdir()
+ (where / "kept.txt").write_bytes(b"kept\n")
+
+
+def moved_back_and_added(root: Path, env: dict, family) -> str:
+ """`committed_with_its_checkout`'s state, then its checkout moved to
+ `HEAD~1` and `git add`-ed: HEAD records another commit there (lane 1's
+ NIT 2 on #889, round 3). Returns the path."""
+ path = committed_with_its_checkout(root, env, family)
+ raw(root / path, env, "checkout", "-q", "HEAD~1")
+ raw(root, env, "add", "--", path)
+ return path
+
+
+#: `(the state, whether a plain git rm refuses its gitlink as staged)`:
+#: measured on git 2.43, it took one staged over nothing on disk, and one
+#: committed with its checkout. Where it refuses, the first three say `has
+#: changes staged in the index`, and the others, whose disk differs from
+#: what is staged, `has staged content different from both the file and
+#: the HEAD` (lane 1's NIT 1 on #889, round 3).
+STAGED_STATES = {
+ "git-mv": (moved_by_git_mv, True),
+ "an-empty-directory": (staged_by_hand(Path.mkdir), True),
+ "head-records-another-commit": (moved_back_and_added, True),
+ "git-mv-and-a-commit-in-it": (moved_with_a_commit, True),
+ "a-directory-holding-a-file": (staged_by_hand(holding_a_file), True),
+ "nothing-on-disk": (staged_by_hand(lambda where: None), False),
+ "committed-with-its-checkout": (committed_with_its_checkout, False),
+}
+
+
+def git_refusal(stderr: bytes) -> str:
+ """What `git rm` said it refused, after "the following file ", up to
+ its colon, on one line: git wraps the longer of its two refusals."""
+ said = stderr.decode("utf-8").split("the following file ", 1)[1]
+ return " ".join(said.split(":", 1)[0].split())
+
+
+@pytest.mark.parametrize("make, refused", STAGED_STATES.values(),
+ ids=STAGED_STATES)
+def test_round2_should_a_hand_mount_s_git_rm_says_it_refuses_a_staged_gitlink(
+ family, holder, offline, tmp_path, make, refused):
+ """Lane 1's SHOULD on #889, round 2: a gitlink no row names, staged at
+ a commit HEAD does not record there, as `git mv members/IRRS
+ members/IRRS2` leaves one, or as one staged by hand over an empty
+ directory. The hand mount's `git rm -- ` exits 1 there, as
+ before this change, where its text said only that it refuses while
+ its checkout holds changes. It says that refusal too now, in every
+ mode, and not where nothing is on disk or HEAD records the gitlink as
+ staged, which git takes. No line takes such a gitlink out: `-f` would
+ take the checkout's changes with it. Round 3 (lane 1's NITs 1 and 2):
+ the text quotes the refusal git prints, either of two, and a gitlink
+ HEAD records at another commit gets it too."""
+ env = cloning(offline, tmp_path, family)
+ path = make(holder, env, family)
+
+ found = found_alike(holder, *MODES)
+
+ mount = found[0]
+ assert mount.startswith(f"FINDING member-gitlink-unnamed: {path} "), mount
+ assert (STAGED_REFUSED if refused else PLAIN_REFUSED) in mount, mount
+ switch, removal, _ = commands(mount)
+ assert removal == f"git rm -- {path}"
+ type_line(holder, env, switch)
+ typed = raw(holder, env, *shlex.split(removal)[1:], check=False)
+ assert typed.returncode == int(refused), typed.stderr
+ if refused:
+ assert git_refusal(typed.stderr) in mount, typed.stderr
diff --git a/tests/test_family_unnamed_gitlink.py b/tests/test_family_unnamed_gitlink.py
index 6f922260..4450822d 100644
--- a/tests/test_family_unnamed_gitlink.py
+++ b/tests/test_family_unnamed_gitlink.py
@@ -96,8 +96,10 @@
#: What it says first where lines printed before it may leave `.gitmodules`
#: with changes not staged, or it holds some, and the line it prints for
-#: that: git takes no gitlink out of the index then (#879's C3).
-STAGE_SAID = ("stage .gitmodules with the first line, since git rm refuses to "
+#: that: git takes no gitlink out of the index then (#879's C3), and the
+#: line stages every other change in the file too (rv882's NIT 3).
+STAGE_SAID = ("stage .gitmodules with the first line, which stages every "
+ "other change in it too, since git rm refuses to "
"take any gitlink out of the index, -f or not, while "
".gitmodules holds changes not staged (please stage your "
"changes to .gitmodules or stash them to proceed, exit 128), "