Skip to content

fix: collapse an unmerged submodule's merge stages into one entry [patch] - #111

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-9w6i65
Sep 14, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-9w6i65

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #103

What was wrong

GitSubmoduleParser.ParseGitlinks filtered ls-files --stage records on the mode field alone and discarded the stage field. During an unfinished merge git emits one record per stage for an unmerged path, so a submodule both sides moved to divergent commits came back as three GitSubmodule entries sharing one Path, each with a different Sha, for a directory that exists once on disk.

submodule status reports that same path once, with a U marker and git's null object id. ApplyStatus matched all three entries against that single line, and Apply suppressed the object id only for Uninitialised — so each entry also reported 0000000000000000000000000000000000000000 as its CheckedOutSha. That value is well-formed enough that nothing downstream would question it as a commit.

Verified against git 2.43, which is what the issue reported:

$ git ls-files --stage -z -- libs/sub
160000 941c54aba3ecbbf714e1aa40a2aa1a1e4dfe7ff0 1	libs/sub
160000 7182602cb7a28003eb333e162df18908e81a7866 2	libs/sub
160000 d62f3736e0a7fef1a8dc59dfff3a2b84ffd8c095 3	libs/sub

$ git submodule status
U0000000000000000000000000000000000000000 libs/sub

The change

ParseGitlinks reads the stage and emits one entry per path. Stage 0 wins when present; among the unmerged stages the order is 2 ("ours" — the commit the branch being merged into records), then 3, then 1. The fallbacks are not theoretical: a submodule deleted on one side produces stages 1 and 3 with no 2 at all. Git's own ordering is preserved — a later stage revises the path's entry in place rather than moving it.

Two details worth calling out:

  • The mode filter still runs before the stage is validated. Unmerged blobs carry the same per-stage records and are most of any real conflict, so they are skipped exactly as they always were rather than being held to a gitlink's expectations.
  • A stage outside 03 throws GitParseException, unlike submodule status's marker characters which degrade to Unknown. ls-files is plumbing with a closed set: a fifth stage means the record was misread, and ranking it anyway would pick one of the path's commit ids at random.

Apply treats an all-zero object id the way it already treats Uninitialised, so CheckedOutSha is null rather than the null OID. The test is by digit rather than against a constant, because the id is 64 characters under --object-format=sha256.

GitSubmodule's own docs and CLAUDE.md's submodule design note record both behaviours.

Tests

Covered at both tiers, as the issue's "Missing coverage" section asks.

Unit (GitIntegration.Test/Builders/GitSubmoduleBuilderTests.cs), from captured git 2.43 output:

  • CollapsesAnUnmergedSubmoduleIntoOneEntryAsync — three stages become one entry carrying stage 2's id, with Conflicted state and a null CheckedOutSha. Merged gitlinks either side of it assert the collapse keeps git's ordering, and stage 1 being listed first / stage 3 last means neither a first-wins nor a last-wins collapse would pass.
  • FallsBackToTheirStageWhenOursIsAbsent — stages 1 and 3 with no 2.
  • SkipsAnUnmergedBlobWithoutReadingItsStage — the mode-filter-first ordering.
  • ThrowsForAStageGitDoesNotDefine.

Integration (GitIntegration.Test/Integration/GitSubmoduleTests.cs), which needs a real unmerged index:

  • ReportsAConflictedSubmoduleOnceWithNoCheckedOutCommitAsync — builds two superproject branches whose gitlinks point at divergent submodule commits and merges them. Both branches are cut from the submodule's single commit before either moves, because git resolves a submodule merge itself when one side is an ancestor of the other, and a merge it can resolve produces no conflict to read. merge is out of scope for this library, so the fixture runs it directly and asserts it fails.

Verification

  • dotnet build and dotnet build -c Release: 0 warnings, 0 errors.
  • KTSU_GIT_INTEGRATION_TESTS_REQUIRED=1 GIT_CONFIG_NOSYSTEM=1 dotnet test: 593 passed, 0 failed, integration tier included rather than skipped.
  • Mutation-checked by substitution, per CLAUDE.md, in two independent runs so each half of the fix is shown to be load-bearing:
    • Reverting ParseGitlinks fails 4 of the 5 new tests (SkipsAnUnmergedBlobWithoutReadingItsStage passes either way — it is a regression guard on behaviour this change must not disturb).
    • Substituting IsNullObjectId's result fails the two CheckedOutSha assertions. Deleting the call instead fails the build with IDE0051: IsNullObjectId is unused — exactly the case CLAUDE.md warns about, which is why the mutation is a substitution.
    • The tree was re-verified clean after each revert.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y1rCxPMXL6tjQaqr1noz3f


Generated by Claude Code

…tch]

`ls-files --stage` emits one record per merge stage for an unmerged path, and
`GitSubmoduleParser` filtered those records on the mode field alone, discarding
the stage. A submodule both sides of a merge moved to divergent commits was
therefore reported three times, sharing one `Path` and carrying three
contradictory `Sha` values, for a directory that exists once on disk.

`submodule status` reports that same path once, with a `U` marker and git's
null object id. `ApplyStatus` matched all three entries against that one line,
and `Apply` suppressed the object id only for `Uninitialised` — so each entry
also reported forty zeroes as its `CheckedOutSha`, well-formed enough that
nothing downstream would question it as a commit.

Read the stage and emit one entry per path: stage 0 when present, otherwise 2
("ours") then 3 then 1, since a submodule deleted on one side produces 1 and 3
with no 2. The mode filter still runs first, so an unmerged blob is skipped
rather than held to a gitlink's expectations of its stage field. A stage
outside 0-3 throws: `ls-files` is plumbing with a closed set, so an unexpected
one means the record was misread, and ranking it anyway would pick one of the
path's commit ids at random.

`Apply` now treats an all-zero object id the way it already treats
`Uninitialised`. The test is by digit rather than against a constant, because
the id is 64 characters under `--object-format=sha256`.

Covered at both tiers: captured output in the builder tests, and an integration
test that builds a real unmerged index by merging two superproject branches
whose gitlinks diverge.

Fixes #103

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y1rCxPMXL6tjQaqr1noz3f
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit d93682f into main Sep 14, 2026
16 of 17 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-9w6i65 branch September 14, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A conflicted submodule is listed three times, with the all-zero object id reported as CheckedOutSha

2 participants