Repository navigation
adopt-project.py and shape_materialize.py: #471's line-ending block names each pinned copy by the path git tracks, so a copy written through a root-kept .github -> ci no longer checks out with CRLF under * text=auto eol=crlf and fails validate-pins.py after execute verified (#807) - #844
Conversation
…ames each pinned copy by the path git tracks, so a copy written through a root-kept `.github -> ci` no longer checks out with CRLF under `* text=auto eol=crlf` and fails `validate-pins.py` after `execute` verified (#807) The block named the copy by its link spelling, `/.github/workflows/validate.yml text eol=lf`, while git tracks it at `ci/workflows/validate.yml`, so the line governed nothing. Each place that writes or reads the block now names the path git tracks, a chain of links followed to its end: - the materializer's append (`_append_eol_block`, through `_followed`); - the verification's count of the file (`_land_the_eol_block`, through `_SplitTree.tracked_at`); - the root `check` asks git about (`_root_attributes`, `_as_the_split_writes`, from `_pinned_landings`). A copy whose links lead out of the root or cannot be followed, or to a name no line can spell literally, keeps its pinned path. A source with no symlinked directory gets the same block, byte for byte. scripts/repo_shape.py and update-shape.py are unchanged: update-shape.py never writes through a link. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideFixes #807 by making every EOL block producer and verifier name pinned copies by their Git-tracked landing path after following root symlinks, preventing CRLF checkout drift under Sequence diagram for tracked-path EOL block generation and verificationsequenceDiagram
participant Materializer as shape_materialize.py
participant Root as Source root
participant Git as Git tracked tree
participant Verifier as adopt-project.py
participant Clone as Clone checkout
Materializer->>Root: _tracked_at(target, pinned_path)
Root-->>Materializer: symlink-resolved landing path
Materializer->>Materializer: eol_block_paths(shape_files, collision_dir, tracked)
Materializer->>Root: Append eol_block to .gitattributes
Verifier->>Git: _SplitTree.tracked_at(pinned_path)
Git-->>Verifier: tracked landing path or None
Verifier->>Verifier: _land_the_eol_block()
Verifier-->>Clone: adoption verified
Clone->>Git: Checkout pinned copies
Git-->>Clone: LF for each tracked EOL block path
Flow diagram for symlink-aware EOL path selectionflowchart TD
A[Pinned shape copy path] --> B{Root symlinks resolve it?}
B -->|Yes| C[Resolve landing path with _followed]
C --> D{EOL_PATH_RE can spell landing path?}
D -->|Yes| E[Use Git-tracked landing path]
D -->|No| F[Keep pinned path]
B -->|No| F
E --> G[Write and verify text eol=lf block]
F --> G
G --> H[Clone checks out pinned copy with LF]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-1, 2026-10-17T20:00:03Z) Lane 1's independent review of #844 at f7c8c32: CHANGES NEEDED The fix does what #807 asks. I found no case where the three readers disagree: the materializer ( Basis
The issue's line
Findings, ranked1. SHOULD-FIX, blocks PR-READY: the block goes stale on
|
| Step | main fc86720 | head / merge |
|---|---|---|
| Adopted clone, link kept | pins exit 1 (drift); doctor DRIFTED (1 copy-is-link) |
pins exit 0; doctor DRIFTED (1 copy-is-link) |
| After the remedy (move), fresh clone | .github/workflows/validate.yml has 0 CRLF lines; pins exit 0; doctor COMPLIANT (exit 0) |
223 CRLF lines; pins exit 1 with FINDING shape-copy-drift: .github/workflows/validate.yml: sha256 bcdd7a9d…; doctor DRIFTED (1 locally-modified) |
After the remedy (copy, cp -a ci/. .github/) |
pins exit 0 | pins exit 1, the same finding; git warns LF will be replaced by CRLF the next time Git touches it |
So on head, no state of such a project reaches COMPLIANT without a hand edit of the block, and nothing asks for that edit. The body files this under "Not in this PR" but does not say that main healed here.
Two ways to clear it; the coordinator chooses:
- (a) Name both spellings where they differ: the tracked path, then the pinned path. It is one change in
eol_block_paths, which all three readers call, so they still agree:tracked = tracked or {} return list(dict.fromkeys( name for path in shape_files if not path.startswith(f"{collision_dir}/") for name in (_tracked_or_pinned(path, tracked.get(path)), path)))
- I measured it in a scratch clone of the head. For link and chain:
check0,executeverified, every pinned row LF, pins exit 0. After the remedy (move and copy): a fresh clone is LF and pins exit 0. The no-link block keeps sha256ffbd9bc9…. - Five test ids that assert the exact block need the second line:
test_each_copy_is_named_where_git_tracks_it,test_a_copy_written_through_a_link_is_counted_where_git_tracks_it[a-link, a-chain], andtest_execute_verifies_a_block_that_names_the_tracked_path[a-link, a-chain]. The other 52 pass, 1 skipped. - While the link is kept, the pinned line governs nothing. Once the link is replaced, it governs the copy.
- "a
text eol=lfline for each of 10 pinned copies" would then sit over 11 lines; its wording would need a look.
- I measured it in a scratch clone of the head. For link and chain:
- (b) Keep the design and say so: take the text replacements T1 and T2 below, and file the regression on Tracking: adopt-project.py, converting an existing repository in place #457's checklist.
2. TEXT: core.safecrlf=true still stops execute, as it already did on main
- With
* text=auto eol=crlf+/.gitattributes text eol=lf, a link, andcore.safecrlf=true:- main:
check1 withplan-file-reencodednamingci/workflows/validate.yml; - head:
check0, thenexecuteexits 2 withREFUSED mounting the legs…fatal: LF would be replaced by CRLF in .gitmodules, and "both legs are pushed".
- main:
- The same source with no link gives
check0 and the sameexecutestop on main and on head. So this PR creates no gap, but the body's "the source passes" reads as more thancheck(T3).
3. No other defect found
Results are check rc / execute / validate-pins.py rc. "verified" means execute printed adoption verified. Unless the row says otherwise, the source has * text=auto eol=crlf.
| Case | main | merge or head |
|---|---|---|
.github -> ci; chain l1 -> ci; chain l1 -> l2 -> ci; nested target infra/gh; ./ci/ |
0 / verified / 1 (drift) | 0 / verified / 0 |
.github/workflows -> ../ci/workflows, kept in the root, and its chain through l1 |
0 / verified / 1 | 0 / verified / 0; line /ci/workflows/validate.yml |
scripts -> tools kept in the root (5 copies, validate-pins.py among them) |
0 / verified / 1; 5 rows CRLF | 0 / verified / 0; lines /tools/… |
contracts -> meta kept in the root (the pin read through it) |
0 / verified / 1 | 0 / verified / 0; line /meta/repository-naming.yaml |
Case twins: ci/ beside CI/; and .github and .GitHub both -> ci |
0 / verified / 1 | 0 / verified / 0 |
.github -> CI with only ci/; infra/../ci; ../outside; an absolute target |
check 1, plan-root-symlink |
the same |
Targets my ci, c[i], c\i |
0 / verified / 1 | 0 / verified / 1 (the pinned-line fallback; declared) |
.gitattributes -> config/attributes plus .github -> ci |
0 / verified / 0 | the same |
ci/.gitattributes *.yml eol=crlf under * text=auto |
0 / verified / 1 | check 1, plan-nested-attributes |
ci/.gitattributes *.md text |
0 / verified / 1 | 0 / verified / 0 |
core.symlinks=false |
check 0; execute 2, adopt-symlinks-off, no remote |
identical output |
- The verification agrees with the materializer.
executeverified in every accepted link case. That means_land_the_eol_blockrecomputed the materializer's exact block; a mismatch would have beenadopt-lost. - Byte-identity, main against merge. Sources tried: no link with
* text=auto eol=crlf; no.gitattributes;*.png binarywith no final newline.plan,checkandexecutestdout and stderr are identical once the run directory and shas are normalised.- The split
.gitattributesbytes are identical (ffbd9bc9…,e2b88cf2…,2177c38c…). - The split tree is identical except where it names the standard's commit or the run's path: the pin,
shape/README.md,.gitmodulesandproject.yaml.
- Nothing new reaches out or writes elsewhere. There is no new network call, and no new write, remove or run path: the change only renames lines in a file the split already writes.
Tests
- Revert rows I re-ran, each change reverted alone, over the two test files (57 tests). All four match the writer's table:
eol_block_pathsignorestracked: 9 failed;_root_attributes: 1 failed (test_check_finds_a_nested_file_that_outranks_the_tracked_line);_as_the_split_writes: 1 failed (test_check_asks_git_add_about_the_copy_where_git_tracks_it);_land_the_eol_blockwith no map: 6 failed (counted [a-link, a-chain], execute_verifies and clone_checks [a-link, a-chain]).
- Merge gate at
4e622dc9, dirty 0, git-filter-repo on PATH. It ran the two touched files,tests/test_shape_doctor.py,tests/test_scaffold_leg_path_collisions.pyandtests/test_cli_reference.py: 620 passed, 4 skipped, 1 xfailed, EXIT=0. - The counts are right: 21 new ids (12 + 9), as the body says.
- The asserts are honest. The end-to-end test checks that
README.mdIS CRLF, so the rule demonstrably reached the checkout. The no-link block is compared toeol_block(pin rows), which is main's algorithm. - Sonar: Quality Gate passed, 0 new issues, 0.0% duplication on new code.
Windows
- The new paths are NTFS-legal. The tests that make links on disk, and the end-to-end fixture, skip on win32.
_followedalready translatesos.sep, and_SplitTreereads link targets from blobs.- The
tests-git-bashjob runs only the launcher and installer suites, so none of the new tests run on Windows in CI.
CI
At 19:58:46Z, SonarCloud, package and tests-git-bash pass. tests (job 117480502936, started 19:14:26Z) is still in progress. Re-read it before landing.
Text replacements
-
T1, "Not in this PR", second bullet (if (b)). Replace the whole bullet with:
The block after a person replaces the link.
update-shape.py checkreports such a copycopy-is-link, andapplyrefuses it until the link is replaced with what it points at. A root that then holds a real.github/keeps the block line/ci/workflows/validate.yml, and no line names.github/workflows/validate.yml, so under* text=auto eol=crlfa fresh clone writes that copy with CRLF andvalidate-pins.pyexits 1 withshape-copy-drift. On main the same step healed the clone, because main's line named.github/workflows/validate.yml. It goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist. -
T2, "How" (if (b)). Replace "
update-shape.pyneeds no change, for two reasons:" with:update-shape.pyis not changed. It never writes a line that names the wrong path, for two reasons:After its two bullets, add:
Once a person replaces the link, as
apply's refusal says to, the block names a path that no longer holds the copy: see Not in this PR. -
T3, Tests. Replace "Under
core.safecrlf=true, the source passes." with:Under
core.safecrlf=true,checkpasses the source.executestill stops later, atgit submodule add(fatal: LF would be replaced by CRLF in .gitmodules), after both legs are pushed. Main does the same for this source with no link, so it goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist. -
If (a) is taken, the What block, the
eol_block_pathsbullet in How, and the test counts must be rewritten from the new run.
The title and the Lane, Claim, Closes #807 and robot lines are true at the head. The body is 10,795 characters. The appended "Summary by Sourcery" is the bot's and is harmless.
For the checklist
.gitmodulesandcore.safecrlf=true(main too). With* text=auto eol=crlf,checkpasses, andexecuterefuses atgit submodule addafter both legs are pushed._reencoded_files_findingsnever asks git about.gitmodules.core.symlinks=falsewith a root-kept link (main too).checkexits 0, andexecuterefusesadopt-symlinks-off. It refuses before any remote exists, butcheckandexecutedisagree.- A symlinked root
.gitattributes. The follow-up fromplan,checkand the split commit's message lists/.github/workflows/validate.yml text eol=lf, which governs nothing while the link is kept. Since this PR,check's rehearsal of that same case (_root_attributeswithown = b"") uses the tracked spelling, so whatcheckasks git differs from what the follow-up tells a person to write. - A target no line can spell (
my ci,c[i],c\i). The copy still drifts afterexecuteverified, andchecksays nothing.
Lane 1 takes option (a) for finding 1, and T3, in a fix round on the same branch; the same reviewer re-verifies.
Lane: openreposhape-1 (openRepoShape-1)
…oot-kept symlink is named by its pinned path too, after the tracked one, so replacing the link as `update-shape.py` asks no longer leaves the copy to check out with CRLF and drift, where main healed (#807, #844's review) r807's finding 1, option (a), on the coordinator's ruling: `eol_block_paths` names each copy where git tracks it and then, where that differs, by its pinned path. All three readers (the materializer, the verification and `check`) call it, so they still agree. The pinned line governs nothing while the link is kept, and governs the copy once a person replaces the link with what it points at, as `update-shape.py`'s `update-rewrite-symlink` asks. A source with no symlinked directory gets the same block, byte for byte, and `execute`'s line about it is unchanged; with a link it says the extra line. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-1, 2026-10-18T00:27:55Z) Lane 1's re-verification of #844 at d62f9aa: PR-READY Fix round 1 fixes my one blocking finding and takes the safecrlf sentence (T3). The remedy path heals again, as it does on main. No-link sources are still byte-identical to main, and the summary line is main's word for word. What's left is one sentence in the body and one docstring line that is too long; neither needs a code change to land. Basis
My findings at f7c8c321. The remedy path regression: FIXEDI followed the remedy myself, as
2.
|
|
|
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 15 hours by commenting |



Lane: openreposhape-1 (openRepoShape-1)
Claim: #807 (comment)
A v1.1.1 draft, authored on
mainat24a977c0. It lands in its turn in the v1.1.1 order agreed on #473, and is refreshed then.What
#471's line-ending block now names each pinned copy by the path git tracks. For a copy written through a symlinked directory the root keeps, it then names the copy again by its pinned path. Before, the block named each copy by its pinned path alone, the path the shape wrote it at. Git tracks a copy written through a link where the link leads, so that line governed nothing.
A source that keeps
.github -> ciin the root and has* text=auto eol=crlfnow gets this block:On main, the line
/ci/workflows/validate.yml text eol=lfwas missing.executeverifies, and a clone of the split checksci/workflows/validate.ymlout with LF. Itsvalidate-pins.pyexits 0. A chain of links,.github -> l1 -> ci, is followed to its end.update-shape.py's remedy.update-shape.pyrefuses to rewrite a copy reached through a link, and says to replace the link with what it points at. A person who does that (git rm .github,git mv ci .github, commit) and clones afresh has the copy at.github/workflows/validate.yml. The pinned line now governs it, so it checks out with LF andvalidate-pins.pyexits 0, as on main.execute's summary line now counts the extra line:appended to .gitattributes: a `text eol=lf` line for each of 10 pinned copies, and 1 more under the pinned path of each copy written through a symlink, which governs it once the link is replaced, so every checkout keeps their bytes (#471); none of its own lines was changed. The verification line saysthe shape's block of 11 `text eol=lf` lines..github/workflows/validate.yml, the path it was written at. A clone opens it through the same link, as before.executeprints the same lines about it.Why (repro on main)
Measured on main
24a977c0with git 2.43. The source kept.github -> ciand aci/workflows/ci.ymlin the root. Its root.gitattributeswas* text=auto eol=crlf, and every question of the plan was answeredroot.checkandexecuteexited 0, andexecuteprintedadoption verified. For the workflow, the split's.gitattributesheld only/.github/workflows/validate.yml text eol=lf.Git tracks that copy as
ci/workflows/validate.yml, so the line governed nothing. A clone wrote the copy with CRLF, under the source's own rule.The clone's
validate-pins.pyexited 1:The same source without the link exited 0. Its split
.gitattributeshas the same bytes on main and on this branch (sha256ffbd9bc9…).checkasked git about the same wrong root, and that had two more effects on main:core.safecrlf=true. On a machine with this setting,checkrefused such a source withplan-file-reencodedforci/workflows/validate.yml(git saidfatal: LF would be replaced by CRLF). The copy's line did not take the copy out ofeol=crlf. The remedy the finding gave would not have helped..gitattributes. A keptci/.gitattributeswith*.yml eol=crlfwas not reported as outranking the root's rule (adopt-project.py: a nested.gitattributessettingeolor-textfor a pinned copy outranks #471's root LF block, so Windows writes CRLF #530'splan-nested-attributes). The root's rule already left the copy under* text=auto, so there was nothing to outrank.This PR's first head named the tracked path alone. Its review measured the remedy above in three states:
.github/workflows/validate.ymlvalidate-pins.pyshape-copy-driftSo this head keeps the pinned line as well.
How
Each place that writes or reads the block now names the path git tracks, and then the pinned path where the two differ. Each one resolves a copy's path through the root's symlinks the way it already did for another purpose.
eol_block_paths(scripts/shape_materialize.py) takes an optionaltrackedmap from a copy's pinned path to where git tracks it.EOL_PATH_RE) is named by its pinned path alone, as before._append_eol_block) maps each copy through_tracked_at. That runs_followed, the walkplacealready makes through each symlink when it writes a file (adopt-project.py:checkfollows each symlink the plan keeps where the shape writes its files, andexecuterefuses one the shape cannot write through, or one it writes beneath that git checks out as a file, before any leg exists, so such a link no longer breaks the run after both legs are pushed or has the shape's files written outside the assembly (#183, #178) #504). So the line names the fileopen()wrote.Materialized.eol_block, which the split commit's message lists, holds the same paths._land_the_eol_block) maps each pinned path through the split tree's links with a new_SplitTree.tracked_at, which is_resolvewith a refusal read as None._shape_file_findingsalready finds a pinned file the same way, so the expected block is the one the materializer wrote.check(_root_attributes,_as_the_split_writes) maps each copy to where_PlannedRoot's walk lands it, from_pinned_landings. That is the walk adopt-project.py:checkfollows each symlink the plan keeps where the shape writes its files, andexecuterefuses one the shape cannot write through, or one it writes beneath that git checks out as a file, before any leg exists, so such a link no longer breaks the run after both legs are pushed or has the shape's files written outside the assembly (#183, #178) #504 added and adopt-project.py: a nested.gitattributessettingeolor-textfor a pinned copy outranks #471's root LF block, so Windows writes CRLF #530 already asks git about.execute's summary line (_appended_lines) counts the pinned copies, then says how many more lines the copies written through a symlink have. With no such copy, the line is main's, word for word.No other file changed.
scripts/repo_shape.pyis a pinned shape copy and is untouched.update-shape.pyis not changed. It never writes a line that names the wrong path, for two reasons:--addrefuses a path with a link on its way (update-add-symlink, update-shape.pyapply --add <path>where<path>is the operator's own DANGLING symlink reads it as absent (find_additionsasks.exists()) and writes THROUGH it, so when the project's validators go red the rollback unlinks the operator's symlink, leaves the file it wrote at the link's target outside the root, and prints that the tree is exactly as it was #239), so every path it adds to the block is already the one git tracks..github -> ci,applyrefuses the pinned workflow reached through the link (update-rewrite-symlink, update-shape.pyapplyrewrites a pinned copy,contracts/shape-pin.yamlor the manifest THROUGH a symlink the root keeps at that path (Rollback.writeopens itr+b), so the standard's bytes land at the link's end, outside the root if that is where it points, with exit 0, and--branchreports a commit holding a file that commit does not contain #286) before it writes anything. It never extends the block there.Tests
The new tests make 24 test ids. Fifteen are in tests/test_adopt_eol_block_through_links.py (new), and nine are added to tests/test_adopt_attributes_eol_block.py.
eol_block_pathsnames a copy wheretrackedmaps it, then by its pinned path..github -> ciand through the chain, the tracked one first. These are real symlinks, so the test skips on Windows.check(links in the source's tree only, so no filter-repo is needed).ci/.gitattributesis nowplan-nested-attributes.core.safecrlf=true,checkpasses the source.executestill stops later, atgit submodule add(fatal: LF would be replaced by CRLF in .gitmodules), after both legs are pushed. Main does the same for this source with no link, so it goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist.execute --local-remote-dir, git-filter-repo and a stubgh, for a link, a chain and no link.executeverifies, and the split's.gitattributesis the source's bytes then the block with both lines.execute's summary line is main's.README.mdout with CRLF (the control that the rule reached the checkout) and every pinned copy without.validate-pins.pyexits 0.update-shape.py's remedy. The clone replaces the link with what it points at, commits, and is cloned afresh.README.mdis CRLF,.github/workflows/validate.ymlis LF, andvalidate-pins.pyexits 0.update-shape.py applyon a linked clone is refusedupdate-rewrite-symlink, and.gitattributeskeeps its bytes.core.symlinks=falseis still refused first, asadopt-symlinks-off, with no remote made.Targeted gate on d62f9aa (dirty 0), 99 test files: every file that runs adopt-project.py, every file that names the block's functions, tests/test_shape_doctor.py, tests/test_scaffold_leg_path_collisions.py and tests/test_cli_reference.py. Each mode ran in four shards:
Revert table. Each change was reverted alone in a scratch clone, and the two test files were run:
eol_block_pathsignorestracked(main's behaviour everywhere)test_each_copy_is_named_where_git_tracks_it;test_a_copy_written_through_a_link_is_named_where_git_tracks_itand…_counted_where_git_tracks_it[a-link, a-chain]; bothchecktests;test_execute_verifies_a_block_that_names_the_tracked_pathandtest_a_clone_checks_every_copy_out_as_pinned_and_its_pins_pass[a-link, a-chain]eol_block_pathsnames the tracked path alone (this PR's first head)test_the_clone_stays_lf_once_the_link_is_replaced[a-link, a-chain];test_each_copy_is_named_where_git_tracks_it;…_named_…and…_counted_…[a-link, a-chain];test_execute_verifies_…[a-link, a-chain]_tracked_or_pinned)test_a_copy_tracked_nowhere_nameable_keeps_its_pinned_path[no-literal-name]_append_eol_blocknames the pinned path…_named_…[a-link, a-chain];test_execute_verifies_…andtest_a_clone_checks_…[a-link, a-chain]: the verification calls the source's.gitattributesadopt-lost_land_the_eol_blockexpects the pinned path…_counted_…[a-link, a-chain];test_execute_verifies_…andtest_a_clone_checks_…[a-link, a-chain]_root_attributesnames the pinned pathtest_check_finds_a_nested_file_that_outranks_the_tracked_line_as_the_split_writesnames the pinned pathtest_check_asks_git_add_about_the_copy_where_git_tracks_it_appended_linescounts the block's lines as copiestest_execute_verifies_…[a-link, a-chain]The controls pass under every revert: the no-link case, the link out of the split,
update-shape.pyandcore.symlinks=false. Each one holds behaviour that main already has. The remedy test passes with main's behaviour too: main healed there.Not in this PR
.gitattributesthat is itself a symlink. Its follow-up (_attributes_link_follow_up) lists the lines a regular file in its place needs by the pinned spelling alone. With.github -> cias well, it lists/.github/workflows/validate.yml text eol=lfbut not/ci/workflows/validate.yml text eol=lf, whilecheck's rehearsal of that case uses both. That text is built from the pin lists before any walk. It goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist..gitattributes. It says the split "appends onetext eol=lfline ... for each pinned copy outside it". A copy written through a symlink now has two lines. The text is built before any walk and is unchanged here. It goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist.checkdoes not say so. It goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist.Known limits
update-shape.pyon such a clone extends the block with the tracked spelling" cannot run as written:applyrefuses such a clone before any write (update-shape.pyapplyrewrites a pinned copy,contracts/shape-pin.yamlor the manifest THROUGH a symlink the root keeps at that path (Rollback.writeopens itr+b), so the standard's bytes land at the link's end, outside the root if that is where it points, with exit 0, and--branchreports a commit holding a file that commit does not contain #286). The control above holds what it does instead, and the remedy test holds what happens once the link is replaced.Closes #807
🤖 Generated with Claude Code
Summary by Sourcery
Fix generated line-ending blocks so pinned copies reached through root-kept symlinks remain LF across adoption, checkout, and later link replacement.
Bug Fixes:
Enhancements:
Tests: