Skip to content

Fix WriteLinesToFile rewriting unchanged file when custom encoding is used - #14146

Merged
AlesProkop merged 6 commits into
dotnet:mainfrom
huulinhnguyen-dev:dev/huulinhnguyen/fix-writelinestofile-encoding-writeonlywhendifferent
Jul 8, 2026
Merged

AlesProkop merged 6 commits into
dotnet:mainfrom
huulinhnguyen-dev:dev/huulinhnguyen/fix-writelinestofile-encoding-writeonlywhendifferent

Conversation

@huulinhnguyen-dev

Copy link
Copy Markdown
Contributor

Fixes #14071

Context

WriteLinesToFile with WriteOnlyWhenDifferent="true" is supposed to skip the write (and preserve the file timestamp) when the content it would produce is identical to what's already on disk. This keeps incremental builds fast and avoids unnecessary timestamp churn that triggers downstream rebuilds.

This skip logic was broken whenever a custom Encoding was specified (utf-8, unicode, utf-32, etc.): the file was rewritten on every build even when nothing changed, so any target depending on its timestamp re-ran each time.

There were two compounding root causes in FilesAreIdentical:

  1. Wrong encoding for comparison — it encoded the candidate content with the default UTF-8 (no-BOM) encoding (s_defaultEncoding) instead of the encoding the task actually writes the file with.
  2. Preamble (BOM) ignoredFile.WriteAllText prepends encoding.GetPreamble() (the BOM) to the file, but the comparison never accounted for those bytes.

Because of (1) and (2), the byte length and/or content never matched the on-disk file, so it was always treated as "different" and rewritten.

Changes Made

  • Thread the real encoding through the call chain: ExecuteShouldWriteFileForOverwriteFilesAreIdentical.
  • In FilesAreIdentical, encode the candidate content with that encoding and include encoding.GetPreamble() in both the length check and the byte-by-byte comparison.
  • Kept the existing streamed/chunked comparison (no loading the whole file into memory). s_defaultEncoding is still used as the default when no Encoding is supplied — no dead code.

Testing

Notes

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes WriteLinesToFile’s WriteOnlyWhenDifferent="true" behavior when a custom Encoding is specified, ensuring the task correctly detects unchanged output and avoids rewriting the file (preserving timestamps and incremental build correctness).

Changes:

  • Thread the resolved Encoding through ExecuteShouldWriteFileForOverwriteFilesAreIdentical.
  • Update FilesAreIdentical to include the encoding preamble (BOM) in both length and byte-by-byte comparisons.
  • Add a regression test covering multiple BOM-emitting encodings.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/Tasks/FileIO/WriteLinesToFile.cs Use the actual task encoding (including BOM) when determining whether an overwrite would change file bytes.
src/Tasks.UnitTests/WriteLinesToFile_Tests.cs Add regression coverage for WriteOnlyWhenDifferent with custom encodings.

Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
Comment thread src/Tasks/FileIO/WriteLinesToFile.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@AlesProkop
AlesProkop marked this pull request as ready for review July 7, 2026 14:32
@AlesProkop

Copy link
Copy Markdown
Member

/review

@AlesProkop

Copy link
Copy Markdown
Member

Expert review (ultimate-reviewer · portable core + dotnet/msbuild expert-reviewer)

Verdict: LGTM — 0 blocking, 0 major, 0 moderate, 1 optional nit. 12 of 13 evaluated dimensions clean. Considered the prior conversation: the 3 earlier Copilot inline concerns are all resolved by the simplification in 5d5f9ce.

Prior review concerns — all resolved

  • long used as an array index (would not compile) → gone; content is now indexed with int contentOffset.
  • int overflow in preamble.Length + newContentBytes.Length → fixed with (long) cast.
  • checked((int)(fileOffset + i)) could throw and be swallowed → removed; the per-byte checked/long scheme was replaced by comparing the preamble once up front, then a simple int offset over the content.

Why the fix is correct (verified)

  • Both write paths use File.WriteAllText(..., encoding) — the non-transactional path (ExecuteNonTransactional) and the transactional path (SaveAtomically, ChangeWave 18.3) — so the compare in FilesAreIdentical and the actual write always use the same encoding object and therefore the same GetPreamble().
  • Empirically confirmed on .NET that encoding.GetPreamble() exactly matches the BOM bytes File.WriteAllText prepends for utf-8, unicode, and utf-32including the empty-content case (e.g. utf-8 writes a 3-byte file for ""), so expectedLength = preamble + content holds even for empty output.
  • Bounds are safe: after the length check, the bytes remaining past the preamble equal newContentBytes.Length exactly, so contentOffset + i never overruns. contentOffset as int is fine because a byte array length is bounded by int.MaxValue.
  • Default (no Encoding) path is unchanged: s_defaultEncoding is UTF8Encoding(emitBOM: false), so its preamble is empty — no regression for the common case.

Compatibility / ChangeWave

No gate needed. This restores the documented WriteOnlyWhenDifferent contract (skip + preserve timestamp on identical content). It makes the build do less work on unchanged, byte-identical output, so there is no stale-output risk and no new warning/error. The transactional write path is already behind ChangeWave 18.3; the comparison fix applies correctly to both paths.

Clean dimensions

Correctness/edge cases, Backward-compat/ChangeWave, Concurrency ([MSBuildMultiThreadableTask]; no new shared state; FileShare.Read), Error handling (reuses existing resources; unreadable → treated as different → safe rewrite), Performance (streamed/chunked compare preserved; buffered ReadByte; no whole-file load), Design, Portability (byte-level compare), Documentation (<remarks> explains why the BOM matters), Simplicity/Naming, Observability, Change hygiene.

Optional nit — test coverage

src/Tasks.UnitTests/WriteLinesToFile_Tests.cs (WriteOnlyWhenDifferentRespectsEncoding) asserts only the timestamp before/after. Optionally, after the a3 rewrite, also assert the bytes round-trip in the target encoding, e.g. File.ReadAllText(file, System.Text.Encoding.GetEncoding(encoding)) equals "File contents2" + Environment.NewLine. Not required — the skip behavior (the actual bug) is already well covered, and this test would fail against the pre-fix code.

Automated review via the ultimate-reviewer skill. Findings are advisory.

WarperSan pushed a commit to WarperSan/ThunderPipe that referenced this pull request Sep 17, 2026
Updated
[Microsoft.Build.Utilities.Core](https://github.com/dotnet/msbuild) from
18.9.6 to 18.10.1.

<details>
<summary>Release notes</summary>

_Sourced from [Microsoft.Build.Utilities.Core's
releases](https://github.com/dotnet/msbuild/releases)._

## 18.10.1

## What's Changed
* [vs16.11] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13103
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13796
* [vs17.8] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13902
* [vs17.11] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13903
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13909
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13986
* Add vs18.9 to merge-flow config; retire vs18.3 by @​JanProvaznik in
dotnet/msbuild#14214
* Bump labeler-cache-retention to use issue-labeler v2.1.0 by
@​jeffhandley in dotnet/msbuild#14171
* Bump main to 18.10.0 after vs18.9 snap by @​JanProvaznik in
dotnet/msbuild#14216
* Improve release skill: Phase 2 DARC rules, VMR backflow, deterministic
baseline by @​JanProvaznik in
dotnet/msbuild#14220
* Determinize release: hardcode OptProf baseline + Phase 3.2 baseline
resolver by @​JanProvaznik in
dotnet/msbuild#14222
* Serialize BuildRequestConfiguration.RequestedTargets to fix solution
metaproject MSB4057 in parallel builds by @​ViktorHofer in
dotnet/msbuild#14223
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14203
* Core support for AbsolutePath/FileInfo/DirectoryInfo and ITaskItem<T>
as task parameters by @​baronfel in
dotnet/msbuild#13971
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14206
* Fix existence cache kind poisoning by @​AlesProkop in
dotnet/msbuild#14249
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14226
* Don't disable the MSBuild server for /mt builds when node reuse is off
by @​AR-May in dotnet/msbuild#14248
* Enhance expert reviewer guidelines with additional checks. by @​AR-May
in dotnet/msbuild#14255
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14253
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14268
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14267
* Bump github/gh-aw-actions/setup from 0.81.6 to 0.82.2 by
@​dependabot[bot] in dotnet/msbuild#14266
* Avoid boxing the struct enumerator in
PropertyDictionary<T>.GetEnumerator() by @​nareshjo in
dotnet/msbuild#14272
* Refresh copy marker when implementation output changes by @​AlesProkop
in dotnet/msbuild#14231
* Send task-host build process environment as delta by @​OvesN in
dotnet/msbuild#14126
* Add regression coverage for metadata newline preservation by
@​VolPlita in dotnet/msbuild#14261
* Fix EmbedInBinlog items with relative paths from child projects by
@​huulinhnguyen-dev in dotnet/msbuild#13990
* Stop requiring VersionPrefix updates in servicing - insert prerelease
versions to VS by @​ViktorHofer in
dotnet/msbuild#14277
* Fix WriteLinesToFile rewriting unchanged file when custom encoding is
used by @​huulinhnguyen-dev in
dotnet/msbuild#14146
* Enable trim/AOT analyzers for Microsoft.Build and clean up annotations
by @​JeremyKuhne in dotnet/msbuild#14064
* [automated] Merge branch 'vs18.9' => 'main' by @​github-actions[bot]
in dotnet/msbuild#14291
* Fix MicroBuild plugin feed URL to use allowed pkgs.dev.azure.com
format by @​AlesProkop in dotnet/msbuild#14295
* Pass ExcludeRestorePackageImports during restore to avoid redundant
evaluations by @​ViktorHofer with @​Copilot in
dotnet/msbuild#14274
* [vs18.7] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13988
* Adopt Clever Test Selection (CTS) as parallel, non-blocking PR
pipeline by @​jankratochvilcz in
dotnet/msbuild#14212
* Harden exceptions when connecting to server by @​JanProvaznik in
dotnet/msbuild#14292
* Update MicrosoftBuildVersion in analyzer template by
@​github-actions[bot] in dotnet/msbuild#13886
* Fix MSBuild Server client dropping build result under WaitAny race
(#​14172) by @​JanProvaznik in
dotnet/msbuild#14251
* Partially revert #​13660: remove NuGet RestoreTask transient TaskHost
workaround by @​JanProvaznik in
dotnet/msbuild#14297
* Disable daily AI credits guardrail for Expert Code Review workflow by
@​JanProvaznik with @​Copilot in
dotnet/msbuild#14314
* Localized file check-in by OneLocBuild Task: Build definition ID 9434:
Build ID 14614733 by @​dotnet-bot in
dotnet/msbuild#14246
* Add opt-in partial (stop-after-pass) project evaluation by
@​ViktorHofer in dotnet/msbuild#14290
* Use partial evaluation for -getProperty/-getItem without a target by
@​ViktorHofer in dotnet/msbuild#14296
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14324
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14333
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14330
* Bump github/gh-aw-actions/setup from 0.82.2 to 0.82.8 by
@​dependabot[bot] in dotnet/msbuild#14328
* Restrict partial evaluation to ProjectInstance by @​ViktorHofer in
dotnet/msbuild#14340
 ... (truncated)

Commits viewable in [compare
view](dotnet/msbuild@v18.9.6...v18.10.1).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Microsoft.Build.Utilities.Core&package-manager=nuget&previous-version=18.9.6&new-version=18.10.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This was referenced Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WriteLinesToFile's WriteOnlyWhenDifferent=true does not work when Encoding is provided

4 participants