Skip to content

shape-doctor.py: a shape copy renamed on both sides of a merge, DD at its old name, is named gone on both sides with each new name a side's own commit holds the base's blob of it at, never as a merge of hunks git did not merge (#783) - #789

Merged
brettheap merged 16 commits into
mainfrom
l3-783-both-renamed
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #783 (comment)

Closes #783.

Stacked on #781; lands after it. #781 is itself stacked on #779, and all three change pin_restore. This branch is #781's head 0a9afe3a plus three commits: 3dd7ffb8 (#783), 37da4e52 (round 2) and abcad3997cfb82c297f5a62ddba6244106b214b9 (round 3). Against main the diff therefore shows #779's and #781's changes too. The file:line references below are to abcad399, except in the Round 2 section and its mutant table, which keep 37da4e52's.

Lands after the v1.1.0 tag.

What happened

Ours renames the copy Makefile to a.mk, and theirs renames it to b.mk.

Measured on #781's head 0a9afe3a, git 2.43 ort, with the standard moving only bootstrap.py:

  • The index: DD Makefile with stage 1 alone (b9fac472, the base's blob), AU a.mk with stage 2 alone, and UA b.mk with stage 3 alone.
  • The why: "Makefile is a copy conflicted too. … a person merges it by hand, keeping the hunks git merged", followed by the --accept-local sentence. git merged no hunks. a.mk and b.mk were not named, and left_for_a_person was ['Makefile', 'project.yaml'].
  • With an edit on one side (ours appends a line before renaming): git writes its merge of the copy's three versions to both new names. Both a.mk stage 2 and b.mk stage 3 are 06cfdf63, so no index stage holds the base's blob. Only theirs' own commit (MERGE_HEAD:b.mk) still does.

The issue's claims held except one: its AU/UA labels are swapped. It says AU b.mk and UA a.mk; measured, ours' new name is AU a.mk (stage 2) and theirs' is UA b.mk (stage 3), as RV-b confirmed. (Round 1 of this body said every claim held; that was wrong.)

What changed (shape-doctor.py)

  • A copy at stage 1 alone (DD) leaves copies and other_side (pin_restore, :4005-4022).

    • It is never taken, and it never gets the hunks sentence or the --accept-local sentence.
    • Only the conflicted paths no pin has a row for are its candidate new names (:4017-4022, round 2, S1).
    • Its new names join left_for_a_person, built from a set (:4044-4047), and its sentences the why (:4078-4081).
  • copies_renamed_twice (:4263-4330) names a new name only on exact evidence (side_moved_it, :4341-4360). A path one side alone added (stage 2 or 3 alone), and no pin has a row for, is that side's new name for the copy where the commit that side records (HEAD for stage 2, MERGE_HEAD for stage 3) holds at that path, and at no other path of that commit (tree_holders, :4201, :4352, round 3, S4), either:

    More on the rule:

    • Why the side's own commit and not the index: git writes its merge to both new names, so where a side edited the copy no index stage holds the base's blob. The side that renamed without an edit still holds it in its own commit.
    • Why the pin row too: where the standard moved the copy between the two pins, each side's apply rewrote it, so neither commit holds the base's blob, and neither name was proved. Each side's commit holds exactly what its own pin row records.
    • A side whose commit holds an edit is not proved. No similarity index is used. Nor is git's merged content, the same at both names, taken as a pairing.
    • One path of the side's own commit (round 3): shape-doctor.py: a shape copy renamed on one side of a merge, conflicted under a name no pin has a row for, is named in a conflicted shape pin's why and left_for_a_person as renamed from that copy, for a person to settle, and never taken (#753) #781's one-path rule, applied to the side's tree. A project that keeps its own twin of the copy in step with it, and renames the twin too, holds the proving bytes at two new names, and git pairs one of them with the twin. Neither is proved, as either may be the project's own file. The same holds for the base's blob written at two paths.
    • Where git names no merge base (no MERGE_HEAD, as a cherry-pick, a rebase or merge --squash leaves), nothing is proved.
  • twice_renamed_copies (:4363-4395) writes the sentence:

    • both sides proved: "Makefile is gone on both sides of the merge, renamed to a.mk on the side taken here and to b.mk on the other side, and no pin lists the new names, so neither name is taken: a person keeps one name, or both, by hand";
    • one side proved: "…, renamed to b.mk on the other side, and no pin lists the new name; where the side taken here put it is not proved here, so neither name is taken: a person settles where it goes, by hand";
    • neither proved (for instance, beside an own file holding its bytes): "Makefile is gone on both sides of the merge, and where either side put it is not proved here, so neither name is taken: a person settles where it goes, by hand".
  • still_listed is shape-doctor.py: a shape copy renamed on one side of a merge, conflicted under a name no pin has a row for, is named in a conflicted shape pin's why and left_for_a_person as renamed from that copy, for a person to settle, and never taken (#753) #781's N1 sentence, moved out of renamed_copies. It now covers a copy renamed on one side or both: "The pin taken here still lists Makefile, so this doctor reports it missing once the merge is finished, unless it is renamed back". It is said only where the pin taken here has the row.

  • base_holders reads the base's whole tree, by blob, once for both rename rules, through tree_holders since round 3. copies_renamed now takes it instead of reading it.

  • Docstrings: a paragraph in pin_restore's unmerged entry (:3856-3868), the "never named" rule, and copies_renamed_twice's.

Tests (tests/test_doctor_pin_restore_merges.py)

test_a_copy_renamed_on_both_sides_is_named_for_a_person[...] (:1389) runs the table TWICE (:1341) of Twice cases (:1314):

case what proves each name NEXT takes left_for_a_person finished keeping a.mk
renamed-on-both-sides base blob, both --ours Makefile, a.mk, b.mk, project.yaml DRIFTED (1 copy-missing)
…-edited-on-ours base blob, theirs only --ours Makefile, b.mk, project.yaml DRIFTED (1 copy-missing)
…-beside-a-twin nothing (an own file holds the base blob) --ours Makefile, project.yaml DRIFTED (1 copy-missing)
…-a-twin-kept-in-step (round 3) base blob, theirs only (ours holds it at a.mk and tools/x.mk) --ours Makefile, b.mk, project.yaml not asked (the twin's paths stay unmerged)
…-theirs-moving-its-row (S1) base blob, ours; b.mk is theirs' pinned copy --ours Makefile, a.mk, b.mk, project.yaml DRIFTED (1 copy-missing)
…-theirs-taken (S2) base blob, both; b.mk is the side taken here's --theirs Makefile, a.mk, b.mk, project.yaml, scripts/bootstrap.py not asked
…-theirs-taken-edited-on-ours (S2) base blob, theirs only, the side taken here --theirs Makefile, b.mk, project.yaml, scripts/bootstrap.py not asked
…-the-kept-pin-without-its-row (S2) base blob, both; no "still lists" --ours Makefile, a.mk, b.mk, project.yaml COMPLIANT, SHAPE BEHIND (0 upstream-changed, 1 upstream-added)
…-the-standard-moved (S3) each side's pin row --ours Makefile, a.mk, b.mk, project.yaml DRIFTED (1 copy-missing)
…-the-standard-moved-beside-a-twin (S3) each side's pin row (the twin holds the old bytes) --ours Makefile, a.mk, b.mk, project.yaml DRIFTED (1 copy-missing)
…-the-standard-moved-a-twin-kept-in-step (S4, RV-b's state) pin row, theirs only (ours holds its row's bytes at a.mk and tools/x.mk) --ours Makefile, b.mk, project.yaml not asked (the twin's paths stay unmerged)
…-the-standard-moved-the-base-holding-ours (S3) pin row, theirs only (the base holds ours' new bytes elsewhere) --ours Makefile, b.mk, project.yaml DRIFTED (1 copy-missing)
…-the-standard-moved-no-merge-head (S3) nothing (no base) --ours Makefile, project.yaml not asked

Each case checks:

  • the index is DD/AU/UA (stages [1], [2], [3]);
  • the NEXT is checkout <side> -- contracts/shape-pin.yaml, with scripts/bootstrap.py where both pins read and it is taken;
  • the why holds the Makefile's sentence, then the "still lists" sentence where the pin taken here lists it (and "still lists" nowhere else), then "Then finish";
  • every name in left_for_a_person but the Makefile is said exactly once;
  • "hunks" is said only for scripts/bootstrap.py where it is left to a person, never for the Makefile;
  • the NEXT runs as printed, and the finished verdict where the table gives one.

merged_after takes sources, and own may add lines to the copy's bytes. renamed takes row=True to move the pin's row in the same commit. in_step and in_turn (round 3) build the twin kept in step, with real commits on each side.

Before the fix: the first three cases fail on #781's doctor. On 3dd7ffb8's doctor with only the wording NIT applied, 4 of the 11 fail: …-theirs-moving-its-row (S1) and the three standard-moved cases that prove a name (S3); the S2 cases pass there, as RV-b said (only the tests were missing).

Round 3 mutant: dropping the one-path check (:4352, tree.get(…) != [path]) fails 2 of the 13 both-sides cases, …-a-twin-kept-in-step and …-the-standard-moved-a-twin-kept-in-step: killed. 37da4e52's doctor fails the same two, with left_for_a_person ['Makefile', 'a.mk', 'b.mk', 'project.yaml', 'tools/x.mk']. The file was restored byte for byte (sha256 checked).

Round 2 mutants (lines at 37da4e52), each applied to a backed-up working copy, the both-sides cases run, then restored byte for byte (sha256 checked):

guard mutant result
sides-label (:4346) number == stage → number == b"2" 2 failed (…-theirs-taken, …-theirs-taken-edited-on-ours), killed
gone-copies-only (:4007) + other_side dropped 1 failed (…-the-kept-pin-without-its-row), killed
S1, only unpinned candidates (:4018-4021) all of stages passed 1 failed (…-theirs-moving-its-row), killed
S1, left_for_a_person from a set (:4043-4046) a list again 11 passed: equivalent, since with the S1 filter no path sits in two groups
S3, pin-row evidence (:4332) never accepted 3 failed (the three standard-moved cases that prove a name), killed
S3, those bytes at no base path (:4332) check dropped 1 failed (…-the-base-holding-ours), killed
no merge base, nothing proved (:4304) held and dropped 1 failed (…-no-merge-head), killed
the base's blob at the copy alone (:4331) return True 1 failed (…-beside-a-twin), killed
the stage's own commit (:4250) HEAD and MERGE_HEAD swapped 9 failed, killed
DD only (:4008) any copy with a stage 1 counted as gone 11 failed, killed
a twin of the OLD bytes does not block pin-row evidence (:4328) the base-blob gate applied to both evidences 1 failed (…-the-standard-moved-beside-a-twin), killed
the side's OWN pin row (:4306) the other side's row 3 failed, killed
early return where nothing is gone (:4293) dropped 11 passed: equivalent, it only saves two pin reads

The mutants ran on the working copy before a docstring-only edit (copies_renamed_twice's "NO OTHER PATH" paragraph). The restored file's sha256 matched the backup each time.

Commands, at the head, each with a stub gh first on PATH that exits 97, GH_TOKEN=bogus and an empty GH_CONFIG_DIR:

/usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt -o tmp_path_retention_policy=failed tests/test_doctor_pin_restore_merges.py tests/test_doctor_next_is_a_command.py tests/test_repo_hygiene.py tests/test_cli_reference.py
201 passed in 150.64s (0:02:30)

/usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-sd -o tmp_path_retention_policy=failed tests/test_shape_doctor.py -k "conflicted or unmerged or merge"
29 passed, 411 deselected in 46.87s

Pinned shape copies

None. shape-doctor.py is in no copy list.

Docs

None changed. No doc describes the conflicted-pin restore.

Round 2 (review by RV-b; lines at 37da4e52)

  • S1, the dropped guard is reachable. Restored: pin_restore passes copies_renamed_twice only the conflicted paths no pin has a row for (:4016-4021), as RV-b measured it, and left_for_a_person is built from a set (:4043-4046). Theirs renaming the Makefile to b.mk and moving its row to - path: b.mk now names b.mk once, as the other side's copy, and the Makefile's sentence says the other side's name is not proved. Test: …-theirs-moving-its-row.
  • S2, two guards untested. Added …-theirs-taken and …-theirs-taken-edited-on-ours (ours' pin restaged unreadable, --theirs taken, b.mk named as the side taken here's), and …-the-kept-pin-without-its-row (both names, no "still lists", finished COMPLIANT, SHAPE BEHIND (…)). sides-label and gone-copies-only are killed (table above).
  • S3, pin-row evidence. Added (side_moved_it, pinned_sha256). The base-tree check carries over: the bytes must be held at no path of the merge base. So an own file holding the copy's OLD bytes does not block a name the standard moved it to, while one holding a side's NEW bytes does. With no merge base, nothing is proved. Tests: the four …-the-standard-moved… cases, RV-b's scenario among them.
  • NITs. "so nothing is taken whole" is now "so neither name is taken" in all three sentences (:4353, :4363, :4369). The body now says the issue's AU/UA labels were swapped.
  • Several proved paths on one side are not reachable. git pairs one new name per side with the rename; a copy one side also copies to c.mk is a clean add, never AU/UA. The [paths] list stays, so nothing breaks should git ever give more.
  • Out of scope: left as noted under "Not covered".

Round 3 (re-verification by RV-b)

  • S4, the pin-row proof could name the project's own file. RV-b's state: the base holds the Makefile and the project's own twin tools/Makefile.orig with the same bytes, the standard moves the Makefile, ours keeps the twin in step with a.mk and each side renames the twin too. Ours' commit then held the bytes of its pin row at a.mk and at tools/x.mk, which git paired with the twin, and the why said "renamed to a.mk and tools/x.mk on the side taken here". Fixed my own way, after reading RV-b's trial (rvb789-r2-fix-unique, a separate tree_holders beside base_holders): tree_holders (:4201) is the one tree reader, base_holders (:4190) now calls it, and side_moved_it refuses any path whose bytes the side's own commit holds at another path too (:4352, the bytes must be held at [path] exactly), for both proofs. Each side's tree is read once per pin_restore (:4317-4319).
  • Tests: …-the-standard-moved-a-twin-kept-in-step (S4, pin-row proof) and …-a-twin-kept-in-step (the base-blob proof's gap: the twin's base bytes differ, ours writes the Makefile's base blob into it). Both are built with real commits and git merge. Each names only b.mk and leaves ['Makefile', 'b.mk', 'project.yaml']. …-the-standard-moved still proves both names, and …-the-standard-moved-beside-a-twin is unchanged.
  • Mutant: without the one-path check both new cases fail (table above).
  • NIT 1, Sourcery's ❌: extended under "For the reviewer".
  • NIT 2, "one side deleted it": that phrase is shape-doctor.py: a conflicted shape pin's NEXT reads both sides' pins, so a copy conflicted beside it that only the side not taken has a files: row for is named in its why and left_for_a_person for a person to merge, and never taken (#687) #736's other_sides_copies sentence (:4164, landed on main as bf1f099b), and the same sentence covers a real delete/modify conflict (by_conflict). It is not local to this PR, so I left it. For a path only one side added and only the other side's pin lists (…-theirs-moving-its-row), "one side alone holds it", as renamed_copies says, would be the truer words.

Not covered

Where no rename conflict happens (both sides rename to the same name, or -s recursive -X no-renames), nothing says the kept pin still lists the old name, and the verdict ends DRIFTED (1 copy-missing). Main and #781 behave the same.

For the reviewer

  • The pin row is read from the side's commit (HEAD:contracts/shape-pin.yaml, MERGE_HEAD:…), not from its index stage, so it matches the bytes read from that same commit. In the --theirs cases, ours' index stage is restaged unreadable while HEAD's pin still reads; both evidences agree there.
  • Sourcery's ❌ on the issue's fallback ("at least name the AU/UA paths whose blob matches the base's copy"): in a real merge such a path is named whenever the side's own commit holds that blob at that one path. Nothing is named where the base holds that blob at two paths (shape-doctor.py: a shape copy renamed on one side of a merge, conflicted under a name no pin has a row for, is named in a conflicted shape pin's why and left_for_a_person as renamed from that copy, for a person to settle, and never taken (#753) #781's rule that a blob two paths hold names nothing), or where the side's own commit holds it at two paths (round 3). Nothing is named either where there is no MERGE_HEAD: a real cherry-pick (CHERRY_PICK_HEAD), a rebase (REBASE_HEAD) or git merge --squash. There, git names no merge base, though a.mk:2 and b.mk:3 can both equal Makefile:1. The why then says neither side's name is "proved here", which is true. RV-b measured all three; …-the-standard-moved-no-merge-head stands in for them. No follow-up issue is filed for it.

🤖 Generated with Claude Code

Summary by Sourcery

Teach shape-doctor.py to safely diagnose copies renamed on both sides of a merge without claiming Git merged hunks or guessing unproven destinations.

New Features:

  • Handle shape copies renamed independently on both sides of a merge, identifying only new names proven by each side’s committed tree or pin data.
  • Report unresolved copy names and pin rows accurately in merge diagnostics and suggested manual-resolution paths.

Bug Fixes:

  • Prevent doubly renamed copies from being misclassified as ordinary hunk conflicts or left unnamed when Git records the old path as DD.
  • Avoid treating Git’s merged content or edited side content as proof of a rename, including cases with duplicate files, moved pin rows, missing merge bases, and twin files.

Enhancements:

  • Centralize merge-base and commit-tree blob ownership checks and separate reporting for twice-renamed copies from single-side renames.
  • Expand merge diagnostics to distinguish proven and unproven destination names and to report copies still listed by the selected pin.

Tests:

  • Add comprehensive merge scenarios covering dual renames, edits, moved pin rows, selected-side handling, standard moves, duplicate files, pin-row evidence, and missing merge heads.

Brett Heap and others added 5 commits October 17, 2026 01:27
…in reads, and names no line where no side's pin reads, so run as printed it no longer leaves the doctor naming the same NEXT again (#752)

`pin_restore` took `--ours` wherever ours held a pin, read or not, and
`--theirs` only where ours held none. A side whose committed pin does not
read, taken whole, left the pin as it was, so the doctor named the same
`checkout` again, run after run.

- `side_to_take` takes ours where its pin reads, else theirs where that
  reads, and the `why` then says only that side's pin reads instead of
  offering `--theirs takes the other side`, which would offer the circle.
- Where no side's pin reads, both the `shape currency` and the `manifest
  kinds` rows name no NEXT, and say that taking a side whole puts back no
  pin that reads, so finishing the merge, or aborting it to put back first
  the pin a side broke, is a person's call. `unreadable_pin_row` keeps its
  `update-shape.py check` line for the `none` case alone, and
  `unreadable_next` says the stuck pin's `why` rather than send the reader
  to this doctor again.
- The side taken now always reads, so `other_sides_copies` loses #736's
  wording for a kept pin that does not read; its other words are the same.

Tests: one side's pin unreadable, each side, with the NEXT run as printed
and the doctor read after it; no side's pin readable, with both sides
holding one and with one. Each fails on the doctor before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted under a name no pin has a row for, is named in a conflicted shape pin's `why` and `left_for_a_person` as renamed from that copy, for a person to settle, and never taken (#753)

Where one side renamed the Makefile to build.mk and the other edited it,
git carries the conflict to build.mk, which no pin's row lists, so the
`why` and `left_for_a_person` said nothing of it and only the `merge` row
listed the path.

- `pin_restore` keeps each conflicted path's stage 1, the merge base's
  blob, from the `ls-files -u` it already reads.
- `copies_renamed` calls a path no pin lists a rename of a copy where the
  merge base (`merge-base HEAD MERGE_HEAD`) held nothing at that path and
  its stage 1 is, object for object, the blob the base holds at that
  copy's path, and one copy alone matches. git gives a path the base did
  not hold a stage 1 only where it paired it with one the base did. A
  path the base held is no rename, though its bytes be a copy's, as the
  source's own file beside a collision copy can be (the collision-layout
  test in test_shape_doctor.py holds that). No similarity index is
  guessed at, and any other kind of conflict names nothing, as before.
- `renamed_copies` gives the sentence: "build.mk, renamed from the copy
  Makefile, is conflicted too, and no pin has a row for it under its new
  name, so neither side is taken whole: a person merges it by hand,
  keeping the hunks git merged", or, where one side alone holds it (a
  rename the other side deleted), that a person keeps or deletes it.
- `by_conflict` is the split `other_sides_copies` made inline, shared.

Tests: the rename on the side `--ours` takes, on the other side, and on
one side with the copy deleted on the other. Each fails on the doctor
before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path in the whole merge base holds its stage-1 blob and that path is a copy, so a file of the project's own with a copy's bytes is never named; the `why` says the copy is reported missing once the merge is finished, where the pin taken here still lists it (review of #781)

RV-b's review of #781 at 23f3879:

- S1. `copies_renamed` listed only the copies' paths in the base, so a
  file of the project's own holding a copy's bytes (tools/bootstrap.py,
  byte for byte scripts/bootstrap.py), renamed on one side and edited on
  the other, was named "tools/boot.py, renamed from the copy
  scripts/bootstrap.py" and given to a person, though a file of the
  project's own is never named. It now reads the base's whole tree, one
  `ls-tree -r -z`, and calls a path a rename only where exactly one path
  holds its stage-1 blob and that path is a copy. Not git's `diff -M`
  record: a similarity guess of its own, made apart from the merge's.
  What that costs: a copy renamed beside a file with its exact bytes is
  not named, as before #753.
- N1. Where the pin taken here still lists the old name, the `why` adds
  "The pin taken here still lists Makefile, so this doctor reports it
  missing once the merge is finished, unless it is renamed back".

Tests: the own file renamed, and the copy renamed beside its twin, name
nothing (both were named before; dropping the uniqueness guard now fails
them); each rename case finishes the merge with build.mk kept and reads
`DRIFTED (1 copy-missing)`. The rename setup is shared (`merged_after`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tes no copy holds, renamed, is named no copy's rename, and a rename the kept pin has no row for says nothing is missing, so both guards of #753's rename rule are held (review of #781, round 3)

RV-b's re-verification of #781 at 06e1de8 found two guards of
`copies_renamed` and `renamed_copies` that no test held:

- "the one path holding the blob is a copy": dropped, a project's own
  docs/notes.md, renamed to docs/n.md on one side and edited on the
  other, was named "renamed from the copy docs/notes.md". Now
  `test_a_rename_of_no_copy_names_nothing[the-projects-own-unique-file-
  renamed]` holds it, beside round 2's two twin cases (the table and the
  test renamed to cover all three).
- "the pin taken here lists the old name": dropped, a merge that ends
  COMPLIANT was told the Makefile would be reported missing. Now
  `test_a_rename_the_kept_pin_has_no_row_for_says_nothing_is_missing`
  restages the kept pin without the Makefile's row, finds no "still
  lists" sentence, and finishes the merge to a verdict that is COMPLIANT
  and not copy-missing.

`merged_after` takes an own file's bytes as well as a copy's, and
`finished_keeping` finishes a rename's merge for both tests. In
shape-doctor.py, `copies_renamed`'s docstring calls "the base held
nothing at the new name" belt and braces since round 2: alone it acts
only on a rename onto a path the base held (rename/add).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at its old name, is named gone on both sides with each new name a side's own commit holds the base's blob of it at, never as a merge of hunks git did not merge, and the pin taken here's row for it is said to go missing (#783)

Ours renames the copy Makefile to a.mk and theirs to b.mk. git leaves
`DD Makefile` (stage 1 alone), `AU a.mk` and `UA b.mk`, neither with a
stage 1, so #781's rename rule saw neither new name, and the Makefile, a
conflicted copy, got the copies' sentence: "a person merges it by hand,
keeping the hunks git merged", where git merged none.

- `pin_restore` takes a copy at stage 1 alone out of `copies` and
  `other_side`: never taken, and never the hunks sentence.
- `copies_renamed_twice` names a path one side alone added as that side's
  new name for the copy where the commit that side records (`HEAD` for
  stage 2, `MERGE_HEAD` for stage 3) holds the merge base's blob of the
  copy there, and no other path of the base holds that blob. git writes its
  merge of the copy's three versions to both new names, so where a side
  edited the copy no index stage holds the base's blob; that side's own
  commit does where it renamed without an edit. A side whose commit holds
  an edit is not proved: no similarity index, and git's merged content,
  the same at both names, is not taken for a pairing.
- `twice_renamed_copies` says: "Makefile is gone on both sides of the
  merge, renamed to a.mk on the side taken here and to b.mk on the other
  side, and no pin lists the new names, so nothing is taken whole: a
  person keeps one name, or both, by hand"; where one side's name is not
  proved, that it is not; where neither is, that a person settles where it
  goes.
- `still_listed` is #781's "The pin taken here still lists ..." sentence,
  moved out of `renamed_copies`, said of a copy renamed on one side or
  both that the pin taken here still lists.
- `base_holders` is the base's whole tree by blob, read once for both
  rename rules; `copies_renamed` takes it.

Tests: a rename on both sides; with an edit on ours; beside an own file
holding the Makefile's bytes. Each finishes the merge keeping a.mk and
reads `DRIFTED (1 copy-missing)`. Each fails on #781's doctor; four
mutants (the `DD` test, the blob test, the one-path test, the stage's
commit) each fail at least one.

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

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR extends shape-doctor’s conflicted-pin analysis to recognize copies renamed on both sides of a merge using conservative, exact blob evidence, while improving readable-pin side selection and adding focused behavioral and mutation-tested coverage.

Sequence diagram for conservative two-sided copy rename detection

sequenceDiagram
    participant Doctor as shape-doctor.py
    participant Index as Merge index
    participant Base as Merge base tree
    participant Commit as Side commit
    Doctor->>Index: read ls-files -u
    Index-->>Doctor: DD old name&#59; AU and UA new names
    Doctor->>Base: base_holders()
    Base-->>Doctor: blob to base paths
    Doctor->>Commit: rev-parse HEAD:path or MERGE_HEAD:path
    Commit-->>Doctor: committed blob
    alt committed blob equals base blob and base holds it once
        Doctor->>Doctor: copies_renamed_twice()
        Doctor-->>Doctor: name the side's new path
    else edited or ambiguous evidence
        Doctor-->>Doctor: leave destination unproved
    end
    Doctor-->>Doctor: twice_renamed_copies() reports hand resolution
Loading

Flow diagram for detecting copies renamed on both merge sides

flowchart TD
    A[Conflicted merge index] --> B{Copy has stage 1 only DD?}
    B -- No --> C[Existing copy conflict handling]
    B -- Yes --> D[Read merge-base tree by blob]
    D --> E[Find AU and UA paths]
    E --> F{Side commit holds base blob exactly?}
    F -- No --> G[Leave destination unproved]
    F -- Yes --> H{Blob occurs at exactly one base path?}
    H -- No --> G
    H -- Yes --> I[Prove side-specific new name]
    I --> J[Report old copy gone on both sides]
    J --> K[Leave names for person to resolve]
Loading

File-Level Changes

Change Details Files
Detect copies renamed on both sides using exact merge-base blob evidence from each side’s recorded commit, and report ambiguous cases for manual resolution.
  • Capture stage-1 merge-base blobs and build a complete merge-base blob-to-path index.
  • Recognize DD old names plus AU/UA new names as twice-renamed copies only when a side’s HEAD or MERGE_HEAD contains the unchanged base blob and the blob is unique in the base.
  • Generate distinct explanations for both proved names, one proved name, or no proved names; never treat these cases as hunk merges or take them automatically.
  • Track copies still listed by the selected pin and report their eventual missing status.
shape-doctor.py
Refine conflicted pin side selection and remediation reporting so only a readable pin is taken and unresolved unreadable-pin merges expose no misleading command.
  • Select the merge side whose pin reads, or emit a person-directed explanation when neither side reads.
  • Propagate no-command cases through unreadable-pin rows while preserving the detailed why text.
  • Update related copy-conflict grouping and documentation to reflect the revised ownership and remediation rules.
shape-doctor.py
Add comprehensive merge scenarios and mutation coverage for one-sided and two-sided copy renames, exact-blob safeguards, and pin readability behavior.
  • Test DD/AU/UA index states, generated NEXT commands, manual-resolution wording, and post-resolution copy-missing results.
  • Cover edited-side merges and collisions with project-owned files sharing the copy’s bytes.
  • Verify that duplicate base blobs, committed edits, and swapped stage-to-commit mappings do not falsely prove a rename.
tests/test_doctor_pin_restore_merges.py

Assessment against linked issues

Issue Objective Addressed Explanation
#783 Recognize a DD copy at its old path as potentially renamed on both sides, and name the new AU/UA paths when there is evidence tying them to the base copy. ✅
#783 Avoid telling a person to keep merged hunks for a DD path, where Git did not merge any hunks. ✅
#783 When the rename pairing cannot be proved, at least name added paths whose blob matches the base copy. ❌ The implementation names added paths only when the corresponding side's commit holds the base blob at that path and the blob is unique in the base tree. If that evidence is insufficient—including when the base blob also occurs at another path—it leaves the added paths unnamed. The issue asks for matching added paths to be named as a fallback when pairing cannot be proved.

Possibly linked issues


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

(lane openRepoShape-3, 2026-10-17T09:26:11Z) Independent review of #789 by RV-b (opus), top commit of head 3dd7ffb8: NOT READY. No MUST; three SHOULDs to settle first. S3 may instead be filed and named in the body. CI: Sonar, package and tests-git-bash pass; tests is pending.

The writer's claims hold:

One small error in the body: it says every claim in the issue held, but the issue swapped the AU/UA labels.

S1: the dropped guard is reachable (shape-doctor.py:4271, :4015, :4037).

  • Scenario: ours renames Makefile → a.mk. Theirs renames it → b.mk and, in the same commit, moves its pin row to - path: b.mk (sha unchanged). The merge gives DD Makefile, AU a.mk, UA b.mk and UU on the pin.

  • Measured: b.mk is named twice, in two contradictory sentences:

    • "b.mk is conflicted too, and the other side's pin calls it a copy where the pin taken here does not, and one side deleted it…";
    • "…renamed to a.mk on the side taken here and to b.mk on the other side, and no pin lists the new names". That is false: theirs' pin lists b.mk.

    left_for_a_person is ['Makefile', 'a.mk', 'b.mk', 'b.mk', 'project.yaml'].

  • Fix, measured: pass only unpinned paths as candidates:
    copies_renamed_twice(ctx, gone, {name: held_at for name, held_at in stages.items() if name not in named}, held).
    Then b.mk is named once, the Makefile sentence says the other side's name is not proved, and the test file stays at 35 passed.

  • Also: add this state as a test, and build left_for_a_person from a set.

S2: two guards are untested, and both mutants survive and print false sentences. The head is right in both states; only the tests are missing.

  • sides-label (:4295, number == stage → number == b"2"):
    • with --theirs taken (ours' pin restaged unreadable), it swaps the sides;
    • in the edited case, it calls the side taken unproved.
  • gone-copies-only (:4006, drop + other_side): for a copy only the other side's pin lists, it says "one side deleted it", when both did, and names neither new name. The head gives both names and finishes COMPLIANT, SHAPE BEHIND (… 1 upstream-added).
  • Fix: two TWICE-style cases:
    • --theirs taken, via a_pin_restaged(root, 2, lambda blob: UNREADABLE_PIN), plain and edited;
    • the kept pin without the row, via a_pin_without_a_row(root, 2, MAKEFILE).

S3: when the standard moved the copy, neither new name is proved, though exact evidence exists (:4245-4285; the tests avoid it with sources=(BOOTSTRAP_SOURCE,)).

  • Scenario: the issue's flow (a) with the standard moving the Makefile too, then both sides rename it.
    • Each side's apply rewrote the Makefile, so neither commit holds the base blob.
    • The WHY says neither new name is proved, and left_for_a_person is [Makefile, project.yaml].
    • git itself prints "CONFLICT (rename/rename): Makefile renamed to a.mk in HEAD and to b.mk in …".
  • The evidence: HEAD:a.mk's sha256 equals the HEAD pin's Makefile row (a41ef0ea…), and MERGE_HEAD:b.mk's equals the MERGE_HEAD pin's row (8e65dd54…).
  • Fix: also accept a candidate whose side's commit holds the bytes that side's own pin row records for the copy. That is exact evidence with no similarity, and it covers the base-blob case too. Otherwise, document it and file it under v1.1.1.

NITs:

Cases measured on the head:

case result
the PR's three as claimed
an edit on BOTH sides, conflicting or clean neither proved, no "hunks"
an edit on theirs only a.mk proved, the other side's unproved
#781's rename-and-delete still passes
both renamed to the SAME name no conflict, nothing said
the same name with conflicting edits #781's rule names it
cherry-pick neither proved, which is true
-s recursive same as ort
-X no-renames nothing said, as on main

Mutants:

mutant result
index-stage killed
still-no-twice killed
held-gate killed
one-proved-wording killed
sides-label survives (S2)
gone-copies-only survives (S2)

Tests:

  • the writer's set: 191 passed;
  • tests/test_shape_doctor.py -k "conflicted or unmerged or merge": 29 passed.

Merges: clean with bf1f099b, every open lane-3 v1.1.1 PR, and #630.

Lane: openreposhape-3 (openRepoShape-3)

…ew name by the bytes that side's own pin row records too, so a move of the standard no longer hides both names; a path a pin has a row for is never also named a new name, and the sentences say "neither name is taken" (review of #789)

- S1: `pin_restore` passes `copies_renamed_twice` only the conflicted
  paths no pin has a row for. Theirs renaming the Makefile to b.mk and
  moving its row to `- path: b.mk` in the same commit named b.mk twice,
  as the other side's copy and as a new name "no pin lists", and
  `left_for_a_person` held it twice. `left_for_a_person` is built from a
  set.
- S3: `side_moved_it` also proves a new name where the side's own commit
  (`HEAD`, `MERGE_HEAD`) holds there the bytes whose sha256 that commit's
  own shape pin records in its row for the copy (`pinned_sha256`). Where
  the standard moved the copy between the two pins, neither commit holds
  the merge base's blob, and neither name was proved. Exact, no
  similarity. Those bytes must be held at no path of the merge base, as
  the base's blob must be held at the copy alone, and with no merge base
  nothing is proved.
- NIT: "so nothing is taken whole" is "so neither name is taken" in the
  three sentences.

Tests: `TWICE` is a table of `Twice` cases, eight new: theirs moving its
row (S1); `--theirs` taken, plain and edited on ours, where ours' pin does
not read; the kept pin without the Makefile's row, finished COMPLIANT
(S2); the standard moving the Makefile, beside a twin of its old bytes,
with the base holding ours' new bytes, and with no MERGE_HEAD (S3). Each
case also checks every other name is said once, and "hunks" only for a
copy left to a person.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T10:03:32Z) Re-verification of #789 round 2 by RV-b (opus), on head 37da4e52 (a fast-forward of 3dd7ffb8): NOT READY. No MUST; one new SHOULD. S1, S2 and S3 from round 1 are fixed as described.

S4: the new pin-row proof can name a project's own file as the copy's new name (shape-doctor.py:4304, :4320-4336).

  • Scenario, with real commits and git merge:
    • The base holds the copy Makefile and the project's own twin tools/Makefile.orig, with the same bytes.
    • The standard moves the Makefile, and both sides apply it.
    • Theirs runs git mv Makefile b.mk and git mv tools/Makefile.orig tools/y.mk.
    • Ours runs git mv Makefile a.mk, keeps the twin in step with a.mk's new bytes, then runs git mv tools/Makefile.orig tools/x.mk.
    • git reports two rename/rename conflicts, DD/AU/UA for each pair.
  • What the doctor says: "renamed to a.mk and tools/x.mk on the side taken here … a person keeps one name, or both". left_for_a_person holds tools/x.mk. git itself paired tools/x.mk with the twin, so a person following the why may delete their own file.
  • Why the guard misses it: HEAD:a.mk and HEAD:tools/x.mk both hold the bytes ours' pin row records (a41ef0ea…). The guard at :4332 asks only whether the BASE holds those bytes.
  • Fix, tried locally: prove a name only where those bytes sit at exactly ONE path of that side's own commit (ls-tree -r -z HEAD / MERGE_HEAD). That is shape-doctor.py: a shape copy renamed on one side of a merge, conflicted under a name no pin has a row for, is named in a conflicted shape pin's why and left_for_a_person as renamed from that copy, for a person to settle, and never taken (#753) #781's one-path rule, applied to the side's tree.
    • The trial adds a tree_holders helper: 22 lines added, 4 removed.
    • Results: the twin case names only b.mk; the-standard-moved still proves both names; …-beside-a-twin is unchanged; the test file gives 43 passed.
    • It also closes the same gap in the base-blob proof, where a side writes the base's bytes at two conflicted names.
  • Test: add the twin-kept-in-step case to TWICE.

NITs:

Rulings on W687's open points:

  1. Should an old-bytes twin block the pin-row proof? No: blocking it would lose the correct proof in …-beside-a-twin. But a base-only guard is not the safe rule (S4). One path in the side's own commit is.
  2. Is the update-ref -d MERGE_HEAD stand-in faithful? Yes, measured. A real cherry-pick (CHERRY_PICK_HEAD), a real rebase (REBASE_HEAD; DD, UA a.mk, AU b.mk) and merge --squash all find no base. Each proves nothing and says "neither proved", the same as the test.
  3. Sourcery's ❌: right for real merges, incomplete otherwise (the first NIT).

Checked:

Runs:

what result
the four files 199 passed
test_shape_doctor.py -k "conflicted or unmerged or merge" 29 passed
RV-b's probes 24 passed
predicted merge with #791 (both change pin_restore): test_doctor_pin_restore_merges.py + test_doctor_no_lazy_fetch.py 57 passed

Merges: clean with main and with every open lane-3 PR, #630 included.

Lane: openreposhape-3 (openRepoShape-3)

…ew name only where that side's own commit holds the proving bytes at that one path, so a project's own twin kept in step with the copy and renamed too is never named the copy's new name (review of #789, round 3)

RV-b's S4: the base holds the Makefile and the project's own twin of it,
`tools/Makefile.orig`. Ours keeps the twin in step with a.mk, and each
side renames the twin too. Ours' commit then holds the bytes its pin row
records for the Makefile at a.mk and at tools/x.mk, which git paired with
the twin, and the `why` said "renamed to a.mk and tools/x.mk on the side
taken here": a person following it could delete their own file.

- `side_moved_it` proves a name only where the side's own commit holds
  its bytes at that path alone (`tree_holders`, `ls-tree -r -z` of HEAD
  or MERGE_HEAD): #781's one-path rule, applied to the side's tree. It
  closes the same gap for the base's blob written at two paths.
- `base_holders` reads the base through `tree_holders`.

Tests: two `TWICE` cases, the twin kept in step with the standard moving
(pin-row proof) and without (base-blob proof). Each names only b.mk and
leaves `[Makefile, b.mk, project.yaml]`; both fail without the one-path
check.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T10:18:12Z) Re-verification of #789 round 3 by RV-b (opus), on head abcad399 (a fast-forward of 37da4e52): READY. No MUST, no SHOULD; S4 is fixed.

1. != [path] (shape-doctor.py:4352) is never wrong in the unsafe direction. Both lists are read with -z, so neither quoting nor core.quotePath can make them differ.

  • Measured from a subdirectory, with core.quotePath=true, spaces and non-ASCII names, after a rename/rename conflict: ls-files -u -z and ls-tree -r -z HEAD give the same bytes, relative to the subdirectory.
  • A non-UTF-8 name decodes with replace on both sides. rev-parse then fails, and the path is not proved.
  • One path held twice cannot exist in a tree.
  • Stricter than len == 1: the difference shows only where path forms disagree, and then it refuses the path, the safe outcome.

2. The base_holders → tree_holders refactor keeps the base proof unchanged. All 24 probe states from rounds 1 and 2 were re-run. 22 answer as in round 2, and the 2 that changed come from the new guard:

  • S4's twin kept in step: names only b.mk, with left_for_a_person ['Makefile', 'b.mk', 'project.yaml'].
  • Ours renames to a.mk and also copies the bytes to c.mk: a.mk is no longer proved. That is the safe direction (see the NIT).

The rest are unchanged: every no-MERGE_HEAD state, -s recursive, -X no-renames, the pinned target, both renamed to the same name, --theirs taken, and the kept pin without the row.

3. The two new cases build real states: real commits and a real git merge, asserting DD/AU/UA.

NITs:

Runs:

  • test_doctor_pin_restore_merges.py: 45 passed;
  • test_shape_doctor.py -k "conflicted or unmerged or merge" (run once): 29 passed;
  • probes: 24 passed.

CI: SonarCloud, package and tests-git-bash pass; tests was still pending at review time.

Lane: openreposhape-3 (openRepoShape-3)

…t 2.46 and later refuse `git update-ref -d` on a pseudoref (#783)

CI's git 2.55.0 answered `refusing to update pseudoref 'MERGE_HEAD'` to the helper's `update-ref -d MERGE_HEAD`, which the container's git 2.43.0 allows. The helper now unlinks the file at the path `git rev-parse --git-path MERGE_HEAD` gives, a linked worktree's own included, and asserts it is gone; MERGE_HEAD stays a file under both ref backends, so it holds on every git. A missing file is an error: the helper follows a real merge.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T20:23:42Z) RV789d's re-check of #789's fix round (abcad399 -> 3cfd34bb): READY. One commit, test-only: the no_merge_head helper deletes MERGE_HEAD's file at git rev-parse --git-path MERGE_HEAD, because git 2.46 and later refuse git update-ref -d on a pseudoref. That was CI's failure on git 2.55.0 in run 39024003006. CI tests on 3cfd34bb was still running at the check; #789 lands only once it is green, after the v1.1.0 tag.


Verdict: READY. I found no MUST or SHOULD items. Commit 3cfd34b fixes the CI failure, and the test still checks the state it names. Everything I ran was read-only; I made no GitHub write. The PR head was still 3cfd34b at my last read.

1. The diff. git diff --stat abcad399 3cfd34bb shows tests/test_doctor_pin_restore_merges.py | 16 ++++++++++++++--, 1 file changed, 14 insertions(+), 2 deletions(-). The only change is the no_merge_head helper at lines 1342-1357: a docstring, and a body that replaces update-ref -d with --git-path, joins a relative answer onto root, then unlink() and assert not exists. Nothing else changed.

2. The test still tests its state. I copied one conflicted rename/rename merge (under it's my work/Atlas) into two repositories. In one I ran update-ref -d MERGE_HEAD with git 2.43; in the other I called the real T.no_merge_head, imported from the 3cfd34b tests.

  • rev-parse -q --verify MERGE_HEAD gives rc=1 out='' in both.
  • git status says neither "You have unmerged paths" nor "All conflicts fixed but you are still merging" in either (False/False). Both still list "Unmerged paths:", because the index is still conflicted.
  • ls-files -u is byte-identical in both (Makefile 1, a.mk 2, b.mk 3, f 1/2/3).
  • MERGE_MSG, MERGE_MODE, AUTO_MERGE, ORIG_HEAD are left in both.
  • A hash of every file in the two trees differs only in .git/packed-refs. Git 2.43's update-ref -d writes a header-only # pack-refs with: peeled fully-peeled sorted, which holds no ref.
  • shape-doctor.py reads MERGE_HEAD only through merge-base HEAD MERGE_HEAD (:4197), COMMIT_OF_STAGE (:4260) and the file check in merge_in_progress (:9804). It never reads MERGE_MSG, MERGE_MODE, AUTO_MERGE or packed-refs. Git reads MERGE_MODE only while MERGE_HEAD exists, and a cherry-pick leaves MERGE_MSG too. So for the doctor and for git, the two states are the same.
  • The test's own line :1483 (== [[1], [2], [3]]) checks the index after the helper runs.

3. Path handling.

  • Plain repository: --git-path answers '.git/MERGE_HEAD\n', which is relative, and the helper joins it onto root.
  • Linked worktree (it's my work/linked tree, both trees mid-merge): the answer is absolute, .../Atlas/.git/worktrees/linked-tree/MERGE_HEAD. The linked tree then gives rev-parse rc=1, while the main tree keeps its MERGE_HEAD (rc=0, still "You have unmerged paths").
  • Missing file: the helper raises FileNotFoundError rather than passing silently.
  • The blank and the apostrophe cause no problem; the fixture's case passes under the path .../it's my work/Atlas.
  • I also checked the git sources, which confirm the docstring. In v2.46.0, refs.c ref_transaction_update() holds "refusing to update pseudoref '%s'" with pseudo_refs[] = {"FETCH_HEAD","MERGE_HEAD"}, and v2.45.0 lacks it. refs_read_raw_ref sends both names to refs_read_special_head, which reads <gitdir>/<name> as a file under either ref backend.
  • I could not run reftable or git 2.46 or later locally, because the container has 2.43.0.

4. git 2.46+ emulation. I put a shim git first on PATH. It answers update-ref on MERGE_HEAD or FETCH_HEAD with error: refusing to update pseudoref '<name>' and exit 1, which are CI's message and code, and execs /usr/bin/git otherwise.

  • abcad39, the single case: E error: refusing to update pseudoref 'MERGE_HEAD' / 1 failed in 7.84s. This is exactly CI's failure in run 39024003006 on git 2.55.0.
  • 3cfd34b, the single case: 1 passed in 5.15s.
  • 3cfd34b, the full file under the shim: 45 passed in 170.37s. The shim's log holds one refusal, from the abcad39 run only.

5. Mutants, each run on the case [renamed-on-both-sides-the-standard-moved-no-merge-head]:

Mutant Result
no-op (return) killed at :1491 left_for_a_person == left: 'a.mk' != 'project.yaml', b.mk extra
unlinks MERGE_MSG at git's path killed, same assertion
unlinks AUTO_MERGE at git's path killed, same assertion
wrong path Path("MERGE_HEAD") with missing_ok=True killed, same assertion (the helper's own assert passes; the doctor's assertion catches it)
hard-coded .git/MERGE_HEAD survives (1 passed)

The surviving mutant is equivalent in this fixture, because the root is never a linked worktree. Only my probe in check 3 proves the linked-worktree behaviour.

6. Other callers.

7. Merges with git merge-tree --write-tree, all clean:

Merged with Result
origin/main 6f64eea clean, tree 59419a28
#781 head 0a9afe3 (an ancestor) clean, tree 4878ab23 (the head's own tree)
#779 head 06eb23e (an ancestor) clean, tree 4878ab23

8. Tests (each run with the stub gh exiting 97, GH_TOKEN=bogus and an empty GH_CONFIG_DIR):

  • 3cfd34b, full file, git 2.43: 45 passed in 160.78s (0:02:40).
  • 3cfd34b, full file, under the shim: 45 passed in 170.37s (0:02:50).
  • Single case under the shim: abcad39 1 failed in 7.84s; 3cfd34b 1 passed in 5.15s.

CI on 3cfd34b (I did not wait for it): SonarCloud pass, package pass, tests-git-bash pass, and tests was still pending at my last look. tests-git-bash, the Windows job, runs only three launcher and installer files, so it says nothing about this file. On Windows, the relative answer .git/MERGE_HEAD joined onto Path works and is plain ASCII, but I did not measure it there.

NITs (optional, not blocking):

  • N1 tests/test_doctor_pin_restore_merges.py:1352-1355: these four lines repeat tests/test_doctor_unfinished_merge.py:69-72 (and the doctor's git_dir_holds). They are below Sonar's duplication threshold, and Sonar passed. A possible fix later is a shared git_path(root, name) helper in conftest.py, used by both files. This round does not need it.

Lane: openreposhape-3 (openRepoShape-3)

brettheap and others added 7 commits October 17, 2026 18:37
Restack of #781 after its parent #779 (#752) squash-landed on main as c100843.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Restack of #781 after its parent #779 (#752) squash-landed on main. Conflicts in shape-doctor.py and tests/test_doctor_pin_restore_merges.py taken ours; S's tree equals F's tree, so the merge changes no file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#781's final head a61f4e1 and its squash ed052f2 hold the same tree. The
squash conflicted only in shape-doctor.py and tests/test_doctor_pin_restore_merges.py,
the two files #781 and this branch both change; both are taken ours, which
changes neither file from the merge of a61f4e1 above.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-18T02:31:31Z) #789 is restacked after #781's squash (ed052f2), as 033c49d7, by lane 3's three-merge recipe (writer R789, sonnet-xhigh):

  1. 26bb2186 merges shape-doctor.py: a shape copy renamed on one side of a merge, conflicted under a name no pin has a row for, is named in a conflicted shape pin's why and left_for_a_person as renamed from that copy, for a person to settle, and never taken (#753) #781's final head a61f4e15. It is clean.
  2. 033c49d7 merges the squash ed052f2a, taking ours only in shape-doctor.py: a shape copy renamed on one side of a merge, conflicted under a name no pin has a row for, is named in a conflicted shape pin's why and left_for_a_person as renamed from that copy, for a person to settle, and never taken (#753) #781's two files, shape-doctor.py and tests/test_doctor_pin_restore_merges.py, which conflicted as expected. The squash and the final head have the same tree, and this merge changed nothing.
  3. Main was ed052f2a itself, so the third merge was "Already up to date".

Lane: openreposhape-3 (openRepoShape-3)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 07:20
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 3 days and 12 hours by commenting @sourcery-ai review. Upgrade to get a review now.

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