Deliberately excluded from #2843, which was scoped to pins only. -l0 is not a pin (see "Why this was excluded" below), so it is filed separately rather than smuggled in.
All figures below measured on git 2.55.0.windows.3.
The claim
attribute_file's header calls rename detection load-bearing, and it is right:
With --no-renames a git mv decomposes into a delete plus an add, the delete side reaches the enumeration below as a whole-file removal, and every line in a moved file gets attributed to whoever last touched it -- so relocating a large file a recent commit had added would fire.
-M was pinned in #2843 to stop a developer's diff.renames = false from causing exactly that. But -M does not make rename detection unconditional. Git applies its own default diff.renameLimit of 1000, and above it git silently stops pairing and reports the relocation as adds plus deletes — the precise decomposition the header says must never happen. No hostile developer config is required. Git's own default is enough.
Measured: -l0 means UNBOUNDED, not "zero/never"
Fixture: 1100 files relocated src/oldN.txt -> dst/newN.txt, each also having one line changed.
| flags |
result |
stderr |
-M (as shipped) |
1100 A + 1100 D |
warning: exhaustive rename detection was skipped due to too many files. |
-M -l0 |
1100 R |
none |
-M -l5000 |
1100 R |
none |
-M -l1 |
1100 A + 1100 D |
same warning |
-l0 matches -l5000, not -l1. Git's own warning names the fix: "you may want to set your diff.renameLimit variable to at least 1100 and retry the command."
Canary consequence: a false positive with no hostile config
Same fixture, shipped threshold 200, shipped flags, empty git config:
SILENT REVERT SUSPECTED
removed by baea247c5 chore: relocate the corpus
content from d9d1dd777 feat: land the corpus
(1 commit(s) earlier on main)
lines lost 11000 (threshold 200, window 40 commits)
Exit 1. With diff.renameLimit=0 or =5000 the same commit reports ok and exits 0.
This is a false positive reachable in CI on a clean machine. The relocated content is all present in the tree; the detector simply lost the pairing and blamed the old-side paths.
IMPORTANT QUALIFIERS — what actually reaches the limit
The exposure is much narrower than "relocate more than 1000 files". Git has two cheap pre-passes that pair files before the O(N^2) inexact pass, and diff.renameLimit gates neither of them. Both were measured.
1. A pure git mv with no content change never consults the limit. Byte-identical blobs pair in git's exact-rename pre-pass. 1100-file pure-move control fixture, default limit: 1100 R, no warning, canary exit 0. Even -l1 reports 1100 R.
2. A relocation that PRESERVES basenames never consults the limit either. This one is not in the original write-up and it materially narrows the finding. Git's basename-match optimization pairs src/fN.txt with dst/fN.txt cheaply, and that pass is not limit-gated. Measured:
| fixture |
-M (default) |
-M -l1 |
1100 files, src/fN.txt -> dst/fN.txt, content touched |
1100 R |
1100 R |
1100 files, src/oldN.txt -> dst/newN.txt, content touched |
1100 A + 1100 D |
1100 A + 1100 D |
Confirmed again at 10 files with -l1: basenames preserved -> 10 R; basenames changed -> 10 A + 10 D plus the warning.
So the exposure is: relocate more than ~1000 files AND change their content AND change their basenames. A plain directory move — the common shape in this repo's skill and doc restructures — keeps basenames and is immune. A rename-and-relocate sweep is not. That is the shape a fix has to justify itself against.
Trip point
Measured for the shape src == dst == N, basename-changing and content-touched, with nothing else in the commit:
- N = 1000 ->
1000 R, no warning
- N = 1001 -> warning +
1001 A + 1001 D
Git's gate is a product, roughly (dst <= L || src <= L) && dst*src <= L*L, so a commit that also adds or deletes unrelated files moves the trip point. Do not treat 1001 as a fixed number.
Only scan_commit is affected — -M and any -l are INERT in attribute_file
attribute_file's diff is pathspec-limited to a single old-side file, so there is no destination candidate to pair with and rename detection has nothing to do. Verified by hashing the diff output for every MD-filtered path of two calibration commits (cc58cbc, 6 paths; 91e77fc, 55 paths) under -M, -M -l1 and -M -l0: byte-identical in all 61 cases.
A fix should therefore touch only the scan_commit enumeration site. Adding -l0 to attribute_file would be pure noise.
Why this was excluded from #2843 — state this explicitly
-l0 is not a pin, and the distinction is the whole reason this is a separate issue.
--no-ext-diff, --no-textconv, --no-show-signature, --diff-algorithm=myers, -M, --no-ignore-revs-file all neutralize caller configuration. Each restores a git default. They change nothing about what the detector computes on a clean machine; they make a local run match CI.
-l0 overrides git's own default and changes what the detector computes on every machine, CI included. It is a behavior change, not a normalization.
- It also removes a bound on
git diff's O(N^2) rename cost — the exact bound git's default exists to impose.
#2843 was scoped to pins only, so this was correctly left out rather than quietly bundled.
Measured no-op on the current corpus
Adopting -l0 moves no calibration figure today. Paths presented to the enumeration diff per calibration commit:
Max 55 against a limit of 1000. Diff output was verified byte-identical with and without -l0 on all six commits. No commit in the calibration set contains a rename at all (--name-status shows only M, plus 2 A on c8470ef), so none of the 853 / 451 / 346 / 298 / 390 / 340 attributions move.
REQUIRED before adopting -l0: a runtime measurement
This is an acceptance condition, not a suggestion.
-l0 removes a cost bound on an O(N^2) algorithm, and the canary's full scan already takes about 9m21s (as reported by the lane that measured it). Whoever fixes this must measure the full-scan runtime with -l0 applied and record the before/after in the PR. If the scan time moves materially, -l5000 — a raised bound rather than no bound — is the safer landing and produces the identical 1100 R result on every fixture above.
Do not adopt -l0 on the strength of "it is a no-op on the current corpus". It is a no-op on paths; the runtime question is about what happens on the commit that finally isn't.
Related
Siblings from the same review pass: #2874, #2875, #2837, #2833, #2846, #2855, #2865, #2843, #2847, #2691, #2656. Companion recall-gap finding filed as #2883.
Deliberately excluded from #2843, which was scoped to pins only.
-l0is not a pin (see "Why this was excluded" below), so it is filed separately rather than smuggled in.All figures below measured on git 2.55.0.windows.3.
The claim
attribute_file's header calls rename detection load-bearing, and it is right:-Mwas pinned in #2843 to stop a developer'sdiff.renames = falsefrom causing exactly that. But-Mdoes not make rename detection unconditional. Git applies its own defaultdiff.renameLimitof 1000, and above it git silently stops pairing and reports the relocation as adds plus deletes — the precise decomposition the header says must never happen. No hostile developer config is required. Git's own default is enough.Measured:
-l0means UNBOUNDED, not "zero/never"Fixture: 1100 files relocated
src/oldN.txt->dst/newN.txt, each also having one line changed.-M(as shipped)1100 A+1100 Dwarning: exhaustive rename detection was skipped due to too many files.-M -l01100 R-M -l50001100 R-M -l11100 A+1100 D-l0matches-l5000, not-l1. Git's own warning names the fix: "you may want to set your diff.renameLimit variable to at least 1100 and retry the command."Canary consequence: a false positive with no hostile config
Same fixture, shipped threshold 200, shipped flags, empty git config:
Exit 1. With
diff.renameLimit=0or=5000the same commit reportsokand exits 0.This is a false positive reachable in CI on a clean machine. The relocated content is all present in the tree; the detector simply lost the pairing and blamed the old-side paths.
IMPORTANT QUALIFIERS — what actually reaches the limit
The exposure is much narrower than "relocate more than 1000 files". Git has two cheap pre-passes that pair files before the O(N^2) inexact pass, and
diff.renameLimitgates neither of them. Both were measured.1. A pure
git mvwith no content change never consults the limit. Byte-identical blobs pair in git's exact-rename pre-pass. 1100-file pure-move control fixture, default limit:1100 R, no warning, canary exit 0. Even-l1reports1100 R.2. A relocation that PRESERVES basenames never consults the limit either. This one is not in the original write-up and it materially narrows the finding. Git's basename-match optimization pairs
src/fN.txtwithdst/fN.txtcheaply, and that pass is not limit-gated. Measured:-M(default)-M -l1src/fN.txt->dst/fN.txt, content touched1100 R1100 Rsrc/oldN.txt->dst/newN.txt, content touched1100 A+1100 D1100 A+1100 DConfirmed again at 10 files with
-l1: basenames preserved ->10 R; basenames changed ->10 A+10 Dplus the warning.So the exposure is: relocate more than ~1000 files AND change their content AND change their basenames. A plain directory move — the common shape in this repo's skill and doc restructures — keeps basenames and is immune. A rename-and-relocate sweep is not. That is the shape a fix has to justify itself against.
Trip point
Measured for the shape src == dst == N, basename-changing and content-touched, with nothing else in the commit:
1000 R, no warning1001 A+1001 DGit's gate is a product, roughly
(dst <= L || src <= L) && dst*src <= L*L, so a commit that also adds or deletes unrelated files moves the trip point. Do not treat 1001 as a fixed number.Only
scan_commitis affected —-Mand any-lare INERT inattribute_fileattribute_file's diff is pathspec-limited to a single old-side file, so there is no destination candidate to pair with and rename detection has nothing to do. Verified by hashing the diff output for every MD-filtered path of two calibration commits (cc58cbc, 6 paths; 91e77fc, 55 paths) under-M,-M -l1and-M -l0: byte-identical in all 61 cases.A fix should therefore touch only the
scan_commitenumeration site. Adding-l0toattribute_filewould be pure noise.Why this was excluded from #2843 — state this explicitly
-l0is not a pin, and the distinction is the whole reason this is a separate issue.--no-ext-diff,--no-textconv,--no-show-signature,--diff-algorithm=myers,-M,--no-ignore-revs-fileall neutralize caller configuration. Each restores a git default. They change nothing about what the detector computes on a clean machine; they make a local run match CI.-l0overrides git's own default and changes what the detector computes on every machine, CI included. It is a behavior change, not a normalization.git diff's O(N^2) rename cost — the exact bound git's default exists to impose.#2843 was scoped to pins only, so this was correctly left out rather than quietly bundled.
Measured no-op on the current corpus
Adopting
-l0moves no calibration figure today. Paths presented to the enumeration diff per calibration commit:Max 55 against a limit of 1000. Diff output was verified byte-identical with and without
-l0on all six commits. No commit in the calibration set contains a rename at all (--name-statusshows only M, plus 2 A on c8470ef), so none of the 853 / 451 / 346 / 298 / 390 / 340 attributions move.REQUIRED before adopting
-l0: a runtime measurementThis is an acceptance condition, not a suggestion.
-l0removes a cost bound on an O(N^2) algorithm, and the canary's full scan already takes about 9m21s (as reported by the lane that measured it). Whoever fixes this must measure the full-scan runtime with-l0applied and record the before/after in the PR. If the scan time moves materially,-l5000— a raised bound rather than no bound — is the safer landing and produces the identical1100 Rresult on every fixture above.Do not adopt
-l0on the strength of "it is a no-op on the current corpus". It is a no-op on paths; the runtime question is about what happens on the commit that finally isn't.Related
Siblings from the same review pass: #2874, #2875, #2837, #2833, #2846, #2855, #2865, #2843, #2847, #2691, #2656. Companion recall-gap finding filed as #2883.