Skip to content

test_validate_family_index_gitmodules.py and validate-family.py: a row pins path-twice's wording of whose file it puts back, the index's or HEAD's as the index holds none, a row pins the value check's reading of HEAD's where the deletion of .gitmodules is staged, and _listed_as_git_reads's docstring says it returns nothing where the work tree's file is on disk (#1181) - #1208

Merged
brettheap merged 7 commits into
mainfrom
fix/1181-gitmodules-mutant-rows
Oct 23, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1181

This PR lands after #1124 (for #1077). It is stacked on #1124's head 8dfa32bd7, so its diff is one commit. Merge #1124 first, then retarget this PR to main. It is the follow-up of RV1124b's review of #1124 (see #1150 for the neighbouring follow-ups from the first review).

What changed

  • tests/test_validate_family_index_gitmodules.py
    • PT7 (NIT 1). In test_1077_path_twice_records_its_put_back_for_the_next_finding, after the existing `{put_back}` assertion, a second assertion pins the whole put-back wording: back first from {GONE[how][0]}, {put_back}, so that every other section stays. It rides on the two existing rows, [deleted] (the index's) and [deletion-staged] (HEAD's, as the index holds none). The row's docstring says path-twice names whose file it puts back.
    • S1c (NIT 2). A new row, test_1077_a_value_head_gives_where_the_deletion_is_staged_puts_head_s_back. update = !true is committed in members/IRRS's section and the deletion of .gitmodules is staged, so the index holds no file and git reads HEAD's. The row checks the premise (git submodule status exits 128), then that found_alike(holder, *MODES) returns the one finding as the issue gives it, whole. It types the two lines in order, runs git add -- .gitmodules and a plain git commit, and checks that !true is not in HEAD, head_paths is {ROW, OTHER_ROW}, git status --porcelain is empty, and ends_well passes (the gate in every mode, and a plain clone).
  • templates/family-root/scripts/validate-family.py (NIT 3): the docstring of _listed_as_git_reads only. It opens "Where the work tree's .gitmodules is gone from disk, ..." and says it returns (b'', '') where the work tree's file is on disk. The script has no pinned copy and no checked-in digest. Its AST is equal to 8dfa32bd7's once that one docstring is dropped (sha256 63f4b9d5... there, 30abb4ea... here).

Counts

Mutants

Each mutant was applied to validate-family.py in a scratch worktree of this head, run against the new rows file (16 rows) and a copy of #1124's original 15-row file (31 rows together), and restored with its sha256 checked (30abb4ea... before and after).

Mutant Edit New file (16 rows) #1124's 15 old rows
PT7 path-twice's f"from {whose}, written f"from the index's, 1 failed: [deletion-staged]; [deleted] passes 15 passed (survives)
S1c _listed_as_git_reads reads spec = f":{GITMODULES}" alone, with no fallback to HEAD's 1 failed: the new S1c row 15 passed (survives)

Merge-trees

git merge-tree --write-tree of each ref against this head was clean:

No other open PR touches tests/test_validate_family_index_gitmodules.py.

🤖 Generated with Claude Code
Lane: openreposhape-4 (openRepoShape-4)

Summary by Sourcery

Strengthen validation coverage and documentation for .gitmodules reads from the index or HEAD.

Enhancements:

  • Clarify .gitmodules source selection and put-back wording when the index is empty or the work tree file is absent.

Tests:

  • Add coverage for staged .gitmodules deletions that require falling back to HEAD and verify the complete path-twice diagnostic wording.

Brett Heap and others added 3 commits October 21, 2026 14:59
…git dies on read the `.gitmodules` git reads, the index's where the work tree's is gone, and every section's lines end `git add -- .gitmodules`, so a plain `git commit` after them takes the section out of HEAD (#1077)

RV1069's review of #1069 (#1036) found four follow-ups, each measured on
main before this change.

NIT 1. `gitmodules-path-twice` read the work tree's file alone. A second
section for members/IRRS staged in the index's file, the work tree's
deleted, was `family ok` in every mode, and a clean clone of the commit
after it failed its update and its gate. It reads the sections as git
reads them now (`_sections_as_git_reads`), as `gitmodules-path-unnamed`
does since #1036, and where the work tree's file is gone it names the
line that puts back the file git reads first, recorded so no later
finding puts it back over the hand edit.

NIT 2. The value check read the work tree's file and HEAD's, never the
index's. An `update = !true` staged in the index's file alone, in a named
member's section, was `family ok` while git's own `submodule status`
exited 128. It reads the file git reads (`_listed_as_git_reads`) and
names the index's where that is the one read. Its lines put that file
back, not HEAD's, and end "then stage", or "then stage and commit" where
HEAD's gives the value too. Where HEAD's alone gives it, the text says to
commit the index's file, with no line, so a fix staged there is kept,
and `member-gitmodules-absent` then puts the file back itself
(`_check_gitlinks_registered` asks `Report.put_back` alone). Git is asked
in the order it was asked before, the work tree's file, then HEAD's, then
the one read in its place, so `git-not-runnable` names what it named.

NIT 3. Where HEAD's file held the section, `gitmodules-path-unnamed`'s
lines ended "then commit .gitmodules" and staged nothing, so a plain
`git commit` after them left the section in HEAD and a clone's gate
failed. Every branch, in members/, outside it and in another case, prints
`git add -- .gitmodules` after the keys and says "then stage and commit"
there; path-twice ends "then stage and commit" too.

NIT 4. A row now covers the case-variant branch where HEAD's file holds
no section of the name (RV1069's probe `test_r2_stray_not_in_head`).

tests/test_validate_family_index_gitmodules.py holds the new rows: each
types only the printed lines, and what the text says in words, then a
plain `git commit`. The older rows that pinned the old text, HEAD's
put-back and #1036's control are updated to the new behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…its own, so #1111's hygiene test passes, path-twice says "back first from" the file it puts back, and two rows pin the value's write-back and path-twice's recorded put-back (#1077)

Fix round 1 on #1124, from RV1124's review at 672845c.

S1. `_listed_as_git_reads` opened with a bare `os.path.lexists(root /
GITMODULES)`, which #1111's `test_path_guard_hygiene.py` fails by file
and line. Its one caller, `_check_gitmodules_values`, asks it only where
the work tree's file is gone, and `_gitmodules_put_back`, which the
function calls next, asks the same `lexists` (it is on the test's
allowlist), so the guard is dropped.

N3. Two behaviours had no row; mutants A and B survived all 439 rows of
the PR's nine files. A row for each, from RV1124's probes P10 and P7:
- the value's write-back reads the file git reads, not HEAD's (A);
- path-twice records its put-back, so `gitmodules-path-unnamed` after it
  prints none and a plain commit keeps neither `second` nor
  `members/Old` in HEAD (B), where the work tree's file is deleted or
  its deletion is staged.

N4. Path-twice said "back from HEAD's, as the index holds none first":
"first" sat on the wrong phrase. It says "back first from {whose}".

N5. `_gitlink_out_first`'s docstring said its text ends "then commit
.gitmodules"; every branch stages now, and it says which words each
branch ends with.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…w pins path-twice's wording of whose file it puts back, the index's or HEAD's as the index holds none, a row pins the value check's reading of HEAD's where the deletion of `.gitmodules` is staged, and `_listed_as_git_reads`'s docstring says it returns nothing where the work tree's file is on disk (#1181)

Two mutants of #1124 survived all 15 of its rows: PT7 (path-twice says
"from the index's" whatever file it puts back) and S1c
(`_listed_as_git_reads` reads `:.gitmodules` alone, with no fallback to
HEAD's). The path-twice row now asserts the whole put-back wording for
both ways the file is gone, and a new row gives the value check HEAD's
`update = !true` with the deletion staged. The code is unchanged but
for the docstring (RV1124b's NIT 3 on #1124).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This PR strengthens .gitmodules validation coverage for staged and unstaged deletion states, pins the user-facing path-twice wording to the correct file source, and updates the helper docstring to match its actual work-tree behavior.

Sequence diagram for .gitmodules source selection during validation

sequenceDiagram
    participant Validator
    participant Git as git
    participant Index
    participant HEAD
    participant WorkTree

    Validator->>WorkTree: Check .gitmodules presence
    alt work tree file is gone
        Validator->>Index: Read .gitmodules
        alt index contains file
            Index-->>Validator: Index version
        else index contains no file
            Validator->>HEAD: Read .gitmodules
            HEAD-->>Validator: HEAD version
        end
        Validator->>Git: git config -z --list
        Git-->>Validator: Values from selected source
    else work tree file is on disk
        Validator-->>Validator: Return (b'', '')
    end
Loading

Flow diagram for staged .gitmodules deletion validation

flowchart TD
    A[Commit update = !true in members/IRRS] --> B[Stage deletion of .gitmodules]
    B --> C[git submodule status exits 128]
    C --> D[_listed_as_git_reads falls back from index to HEAD]
    D --> E[found_alike returns the expected finding]
    E --> F[git add -- .gitmodules]
    F --> G[Plain git commit]
    G --> H[HEAD excludes !true and repository status is clean]
Loading

File-Level Changes

Change Details Files
Clarify and pin .gitmodules source selection and put-back wording across deletion states.
  • Add an assertion that path-twice findings identify whether the restored file comes from the index or HEAD.
  • Add coverage for a staged .gitmodules deletion, verifying fallback to HEAD, finding text, remediation commands, and clean post-commit behavior.
tests/test_validate_family_index_gitmodules.py
Update the helper documentation to accurately describe work-tree and fallback behavior.
  • Document that the helper returns no content when the work-tree .gitmodules exists on disk and reads index content before falling back to HEAD.
templates/family-root/scripts/validate-family.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1181 Add regression coverage that pins path-twice’s put-back wording to the correct source file for both deletion states: the index when the work-tree file is deleted, and HEAD when its deletion is staged. ✅
#1181 Add a regression test for the value check when .gitmodules deletion is staged and HEAD contains update = !true, verifying the HEAD fallback finding and the prescribed repair path through a clean commit and passing gate. ✅
#1181 Clarify _listed_as_git_reads’s docstring so it does not imply the helper reads the index or HEAD when the work-tree .gitmodules file is present on disk. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

RV1208: independent review of #1208 at bc60609c: READY

No MUST, no SHOULD and no NIT. The PT7 assertion and the S1c row each fail on their mutant, and the 15 old rows stay green under both. Seven mutants of my own beside the guards the new row and the new assertion pin are each killed by the new file. The new docstring of _listed_as_git_reads is true in every state I probed. Nothing in the script changed but that docstring.

What I measured

My own clone and worktrees, the head bc60609c (validate-family.py sha256 30abb4ea...; #1124's head 8dfa32bd has 63f4b9d5...). Every pytest ran as /usr/bin/python3 -m pytest -p no:cacheprovider in the landing gate's environment: EMAIL set; GIT_ALLOW_PROTOCOL, GIT_SSH_COMMAND and LANES_* unset; an empty GIT_CONFIG_GLOBAL; GIT_CONFIG_NOSYSTEM=1; test identities; a stub gh first on PATH; GH_TOKEN=bogus and an empty GH_CONFIG_DIR. Git is 2.43.0 and Python 3.12.3. Every run started with 45G or more free.

The host rebooted at about 15:45Z while three of my mutant runs were going. I restored each of those three scripts and checked its sha256, then ran those mutants again from the start. The table gives the re-runs.

The diff. One commit on top of 8dfa32bd: the docstring of _listed_as_git_reads (+9/-7), and in the rows file one assertion, one row docstring and one new row (+46/-3). No added line is longer than 79 characters.

Nothing but the docstring changed in the script. With every module, class and function docstring stripped, ast.dump of 8dfa32bd's script equals bc60609c's (True). The only docstring that differs is _listed_as_git_reads's. Nothing in the repository holds a digest of the script.

The docstring is true. I loaded the head's script and called _listed_as_git_reads(root) in 13 states of a scratch repository. I compared the bytes it returns with git config -z --blob <spec> --list, and they were equal in all 13. 8dfa32bd's script gives the same output in all 13.

State lexists Returns
work tree, index and HEAD all hold the file yes (b'', '')
only the work tree holds it (untracked) yes (b'', '')
the work tree's differs from the index's yes (b'', '')
on disk; git rm --cached (HEAD's only) yes (b'', '')
a dangling symlink in the work tree yes (b'', '')
a directory named .gitmodules yes (b'', '')
deleted, not staged no the index's bytes, :.gitmodules
deletion staged no HEAD's bytes, HEAD:.gitmodules
only the index holds it (never committed) no the index's bytes, :.gitmodules
deleted; the index's differs from HEAD's no the index's bytes, :.gitmodules
unmerged (stages 1 to 3) and deleted no HEAD's bytes, HEAD:.gitmodules
nothing holds it no (b'', '')
deleted; the index's blob is malformed no (b'', ':.gitmodules')

Tests

Mutants

Each mutant was applied to templates/family-root/scripts/validate-family.py in a scratch worktree of bc60609c. Each was run against the new rows file (16 rows) and a copy of #1124's 15-row file from 8dfa32bd. Afterwards the head's script was copied back and its sha256 checked: 30abb4ea... after every one. With no mutant, the new file has 16 passed and the old copy 15 passed.

Mutant Edit New file (16 rows) #1124's 15 rows
PT7 path-twice's f"from {whose}, written f"from the index's, 1 failed: path_twice...[deletion-staged] (the new assertion, line 172); [deleted] passes 15 passed (survives)
S1c _listed_as_git_reads: spec = f":{GITMODULES}", return _blob_listed(root, spec), spec 1 failed: the S1c row; the text is #1181's "in HEAD, which a clone reads, not in the index's file ... Commit the index's .gitmodules" 15 passed (survives)
M1 path-twice always says from HEAD's, as the index holds none, 2 failed: a_second_section... (TWICE_PUT_BACK) and path_twice...[deleted] (the new assertion) 1 failed (TWICE_PUT_BACK)
M2 GITMODULES_SOURCES: HEAD's whose is "HEAD's" (no ", as the index holds none") 2 failed: path_twice...[deletion-staged] and the S1c row 15 passed (survives)
M3 _file_put_back always names the index's: return line, GITMODULES_SOURCES[0][2] if line else "" 2 failed: path_twice...[deletion-staged] and the S1c row 15 passed (survives)
M4 _listed_as_git_reads keeps the right spec but reads the index's bytes: _blob_listed(root, f":{GITMODULES}") 1 failed: the S1c row ("not in HEAD's file, which git reads here") 15 passed (survives)
M5 READ_HERE loses its HEAD:.gitmodules key 1 failed: the S1c row (no finding printed) 15 passed (survives)
M6 _value_taken_out asks staged before committed ("then stage", not "then stage and commit") 1 failed: the S1c row 15 passed (survives)
M7 committed=(name, key) in in_head and present 1 failed: the S1c row 15 passed (survives)

So the new assertion is the only row in the file that pins HEAD's put-back wording in path-twice (PT7, M2, M3). The new row is the only one that pins all of these where the deletion is staged:

  • the value check's fallback to HEAD's (S1c, M4);
  • its HEAD:.gitmodules key (M5);
  • its "then stage and commit" (M6, M7).

Merge-trees

git merge-tree --write-tree of bc60609c against each ref was clean, with no conflicts:

Ref Merged tree
origin/main ef619c74a 44e7ae49
#1124's head 8dfa32bd7 ae2d7a9c, this head's own tree
#1166's head 5102b4294 c62861a6
#1172's head 5aa0f116d e00e3051
#1172's base branch, #1147's head b75babee3 c83b883c

Findings

None at MUST, SHOULD or NIT.

Two notes for the landing, not findings:

Lane: openreposhape-4 (openRepoShape-4)

Base automatically changed from fix/1077-gitmodules-checks-read-the-file-git-reads to main October 23, 2026 18:16
Brett Heap and others added 2 commits October 23, 2026 18:19
Restack of #1208 (see #1181) after #1124 squash-landed as dea964a: merge one of three. The parent's final head, whose tree is the squash's tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Restack of #1208 (see #1181): merge two of three. The squash's tree equals the parent's final head's tree, so the conflicts in #1124's two files that this branch also changes (validate-family.py and test_validate_family_index_gitmodules.py) take this branch's side, which holds the squash's content plus #1208's change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

W1181r: #1208 restacked onto main after #1124's squash dea964a at 99cc49d3

New head 99cc49d35a9adc5dfa42f6dcb82c440d610dafbd (was bc60609c4), pushed without force. GitHub reads it back as MERGEABLE, base main, still a draft, and closingIssuesReferences lists 1181.

Three merges, in this order (merge commits, no rebase):

  1. b340b9b7b merges validate-family.py: gitmodules-path-twice and the check for values git dies on read the .gitmodules git reads, the index's where the work tree's is gone, and every section's lines end git add -- .gitmodules, so a plain git commit after them takes the section out of HEAD (#1077) #1124's final head 2d3bea2df (the update-branch merge, parents 8dfa32bd7 and ef619c74a). No conflict.
  2. 99cc49d35 merges the squash dea964aa1. Before it: git diff 2d3bea2df dea964aa1 over validate-family.py: gitmodules-path-twice and the check for values git dies on read the .gitmodules git reads, the index's where the work tree's is gone, and every section's lines end git add -- .gitmodules, so a plain git commit after them takes the section out of HEAD (#1077) #1124's ten files was empty (both trees are e9533266). It conflicted in two of those files and nowhere else: templates/family-root/scripts/validate-family.py and tests/test_validate_family_index_gitmodules.py. For those two files only I took git checkout --ours, which is this branch's side: the squash's text plus test_validate_family_index_gitmodules.py and validate-family.py: a row pins path-twice's wording of whose file it puts back, the index's or HEAD's as the index holds none, a row pins the value check's reading of HEAD's where the deletion of .gitmodules is staged, and _listed_as_git_reads's docstring says it returns nothing where the work tree's file is on disk (#1181) #1208's change.
  3. git merge origin/main after a fresh fetch, repeated before the push. origin/main is dea964aa1, the squash itself, which merge 2 already holds, so git said "Already up to date" and made no commit. Main had not moved past the squash.

Proofs:

Tests at 99cc49d35, in the landing gate's environment (stub gh, empty global gitconfig, 44G free): tests/test_validate_family_index_gitmodules.py (16 rows) and tests/test_repo_hygiene.py (101 rows), 117 passed in 66 s, exit 0.

git merge-tree --write-tree origin/main HEAD is clean (it gives HEAD's own tree), and so is merge-tree against the two other open PRs that touch validate-family.py, #1166 and #1172.

RV1208's READY was at bc60609c; the reviewed delta is unchanged (proof c).

Lane: openreposhape-4 (openRepoShape-4)

@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
@brettheap
brettheap marked this pull request as ready for review October 23, 2026 20:32
@sonarqubecloud

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Approved.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@brettheap
brettheap merged commit e04826d into main Oct 23, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1181-gitmodules-mutant-rows branch October 23, 2026 20:34
brettheap pushed a commit that referenced this pull request Oct 23, 2026
#1208 touches validate-family.py (a docstring) and
tests/test_validate_family_index_gitmodules.py (a new row); the text
merges cleanly. The new row's re-pin to this branch's stage line follows
in its own commit (rv1227's M1).

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 23, 2026
…ue HEAD gives where the deletion of `.gitmodules` is staged expects the stage line last and types no `git add` by hand, since this branch's lines end with it

rv1227's M1 on #1227. Main's #1208 added the row while its finding ended
at the `--unset-all` line; this branch's `_value_taken_out` ends every
line set that says to stage the file with `git add -- .gitmodules`.
Measured on the merge (git 2.43.0): the three printed lines typed in
order, then a plain `git commit`, leave HEAD without the value, the
index and the work tree equal to it and `git status` clean, and the gate
passes; typed without the stage line, the commit exits 1 (`no changes
added to commit`) and HEAD keeps the value.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant