From a608f8e6abe23f2e04a60831057a97685c415de2 Mon Sep 17 00:00:00 2001 From: Yuliia Kovalova Date: Wed, 15 Jul 2026 18:12:53 +0200 Subject: [PATCH 1/4] Add copy-to-output-directory MSBuild skill Adds a focused dotnet-msbuild skill documenting how to choose a CopyToOutputDirectory / CopyToPublishDirectory mode, including the `IfDifferent` mode and the `$(SkipUnchangedFilesOnCopyAlways)` property introduced in MSBuild 17.13 / .NET SDK 9.0.2xx (dotnet/msbuild#11052). Covers mode semantics (Never/PreserveNewest/Always/IfDifferent), why `Always` is a per-build perf hit, the SkipUnchangedFiles timestamp+size comparison, transitive copy through ProjectReference, and version requirements. Registers the skill in the msbuild, msbuild-code-review, and build-perf agent routing lists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 881d26ad-87b7-48e3-8ec6-043163944437 --- .../dotnet-msbuild/agents/build-perf.agent.md | 1 + .../agents/msbuild-code-review.agent.md | 1 + .../dotnet-msbuild/agents/msbuild.agent.md | 2 + .../skills/copy-to-output-directory/SKILL.md | 100 ++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md diff --git a/plugins/dotnet-msbuild/agents/build-perf.agent.md b/plugins/dotnet-msbuild/agents/build-perf.agent.md index ae0ea50e8a..3eb0d53268 100644 --- a/plugins/dotnet-msbuild/agents/build-perf.agent.md +++ b/plugins/dotnet-msbuild/agents/build-perf.agent.md @@ -76,6 +76,7 @@ Load these skills for detailed guidance on specific optimization areas: - `build-parallelism` — Parallelism and graph build - `eval-performance` — Evaluation performance - `check-bin-obj-clash` — Output path conflicts +- `copy-to-output-directory` — Removing the `Always` copy perf hit (`IfDifferent`, `$(SkipUnchangedFilesOnCopyAlways)`) ## Important Notes - Always use `/bl` to generate binlogs for data-driven analysis diff --git a/plugins/dotnet-msbuild/agents/msbuild-code-review.agent.md b/plugins/dotnet-msbuild/agents/msbuild-code-review.agent.md index ab2bb90161..73ef314f66 100644 --- a/plugins/dotnet-msbuild/agents/msbuild-code-review.agent.md +++ b/plugins/dotnet-msbuild/agents/msbuild-code-review.agent.md @@ -76,3 +76,4 @@ This agent draws knowledge from these companion skills — load them for detaile - `directory-build-organization` — Build infrastructure organization - `check-bin-obj-clash` — Output path conflict detection - `incremental-build` — Incremental build correctness +- `copy-to-output-directory` — CopyToOutputDirectory mode selection; replacing the `Always` perf anti-pattern with `IfDifferent` diff --git a/plugins/dotnet-msbuild/agents/msbuild.agent.md b/plugins/dotnet-msbuild/agents/msbuild.agent.md index 5557edff47..249b2f19b8 100644 --- a/plugins/dotnet-msbuild/agents/msbuild.agent.md +++ b/plugins/dotnet-msbuild/agents/msbuild.agent.md @@ -39,6 +39,7 @@ Classify the user's request and route to the appropriate specialist: | Modernize legacy projects | `msbuild-code-review` agent + `msbuild-modernization` skill | | Organize build infrastructure | This agent + `directory-build-organization` skill | | Incremental build broken | This agent + `incremental-build` skill | +| Choosing/fixing CopyToOutputDirectory behavior (`IfDifferent`, `Always` perf hit) | This agent + `copy-to-output-directory` skill | When routing to a specialized agent, provide context about the user's request so the agent can pick up seamlessly. @@ -82,6 +83,7 @@ This agent has access to a comprehensive set of troubleshooting and optimization - `directory-build-organization` — Directory.Build infrastructure - `check-bin-obj-clash` — Output path conflict detection - `including-generated-files` — Build-generated file inclusion +- `copy-to-output-directory` — CopyToOutputDirectory mode selection (`Never`/`PreserveNewest`/`Always`/`IfDifferent`) ## Common Troubleshooting Patterns diff --git a/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md new file mode 100644 index 0000000000..6c0c7d8e8a --- /dev/null +++ b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md @@ -0,0 +1,100 @@ +--- +name: copy-to-output-directory +description: "Choosing MSBuild CopyToOutputDirectory modes including IfDifferent (MSBuild 17.13+) and $(SkipUnchangedFilesOnCopyAlways), instead of the per-build Always copy perf hit." +license: MIT +--- + +# Choosing a CopyToOutputDirectory Mode + +## Overview + +The `CopyToOutputDirectory` metadata (and its publish counterpart `CopyToPublishDirectory`) controls whether an item — `Content`, `None`, `EmbeddedResource`, or `Compile` — is copied next to your build output, and under what conditions the copy happens. Picking the wrong mode causes either stale files in `bin/` or an unnecessary per-build performance hit. + +As of **MSBuild 17.13 / .NET SDK 9.0.2xx** there are four values: + +| Mode | Copies when… | Incremental cost | Typical use | +| --- | --- | --- | --- | +| `Never` (default) | Never | None | Files not needed at runtime | +| `PreserveNewest` | Source is **newer** than destination (or destination missing) | Cheap (timestamp check) | The common case — source files you edit | +| `Always` | **Every build**, unconditionally | Expensive — copies on every build even in no-op builds | Legacy workaround; avoid (see below) | +| `IfDifferent` | Source **differs** from destination in either direction (newer **or** older, or size differs, or destination missing) | Cheap (timestamp + size check) | Destination may be mutated between builds | + +```xml + + + + +``` + +You can use either the attribute form shown above or the child-element form: + +```xml + + IfDifferent + +``` + +## Why `Always` is usually the wrong choice + +`Always` re-copies the file on **every** build, including otherwise-clean incremental/no-op builds. On projects with many or large content files this is a measurable, recurring cost and a common cause of "why is my no-op build not instant?" reports. + +Historically `Always` was the only way to handle a specific scenario: **the destination file can change between builds** — for example a SQLite database, a storage/state file, or a config file that a test run mutates. With `PreserveNewest`, if the destination is modified (making its timestamp *newer* than the source) MSBuild will *not* restore the original source file, because the source is no longer newer. People reached for `Always` to force the file back into a known-good state — paying the copy cost on every build as a side effect. + +## `IfDifferent`: copy when different, in either direction + +`IfDifferent` is the targeted fix for that scenario. It copies the source over the destination whenever the two **differ** — whether the source is newer *or* older than the destination, whether the size differs, or the destination is missing — but skips the copy when they are already identical. + +Under the hood the `_CopyDifferingSourceItemsToOutputDirectory` target uses the `Copy` task with `SkipUnchangedFiles="true"`. MSBuild's "unchanged" check compares **last-write timestamp and file size**; if either differs the file is copied. This gives you the correctness of `Always` (a mutated destination gets overwritten back to the source content) without the unconditional per-build copy. + +Use `IfDifferent` when: + +- A test run or the app itself writes to the copied file (databases, caches, state/storage files, editable config) and you want each build to reset it to the source version. +- You were using `Always` purely as a "keep the output in sync with the source" mechanism, not because you truly need a copy on every single build. + +```xml + + + + +``` + +## Globally softening `Always` with `$(SkipUnchangedFilesOnCopyAlways)` + +If you have an existing codebase full of `CopyToOutputDirectory="Always"` items and want the performance benefit without editing every item, set the property: + +```xml + + true + +``` + +This makes the `_CopyOutOfDateSourceItemsToOutputDirectoryAlways` target pass `SkipUnchangedFiles="true"` to its `Copy` task, so `Always` items are only copied when they actually differ — effectively giving `Always` the same skip-unchanged behavior as `IfDifferent`. + +- Default is `false` for backwards compatibility (classic `Always` = copy every build). +- Set it in `Directory.Build.props` to opt an entire repo in at once. +- Prefer converting individual items to `IfDifferent` when you can; use this property when a bulk, non-invasive opt-in is more practical. + +## How the modes flow through the build + +`GetCopyToOutputDirectoryItems` buckets each item by its `CopyToOutputDirectory` value into three copy targets, which run from `CopyFilesToOutputDirectory`: + +- `_CopyOutOfDateSourceItemsToOutputDirectory` — `PreserveNewest` items (incremental via `Inputs`/`Outputs` timestamp comparison). +- `_CopyOutOfDateSourceItemsToOutputDirectoryAlways` — `Always` items (unconditional copy unless `$(SkipUnchangedFilesOnCopyAlways)` is `true`). +- `_CopyDifferingSourceItemsToOutputDirectory` — `IfDifferent` items (`SkipUnchangedFiles="true"`). + +All copied files are registered in `FileWrites`, so `dotnet clean` removes them. + +**Transitive copy:** items marked `Always`, `PreserveNewest`, or `IfDifferent` also flow to referencing projects through `ProjectReference` (via `_CopyToOutputDirectoryTransitiveItems`). `Never` items do not. `IfDifferent` participates in ClickOnce publish item collection alongside `Always`/`PreserveNewest`. + +## Version requirement + +`IfDifferent` and `$(SkipUnchangedFilesOnCopyAlways)` require **MSBuild 17.13 or later** (**.NET SDK 9.0.2xx+ / Visual Studio 2022 17.13+**). On older toolsets the value is not recognized: it will not match the `Always`/`PreserveNewest`/`IfDifferent` conditions in the common targets, so the item is silently **not copied**. Gate usage on the toolset if you must support older SDKs, or require the minimum SDK via `global.json`. + +## Quick decision guide + +- Don't need the file at runtime → `Never` (or omit — it's the default). +- Normal source file you edit → `PreserveNewest`. +- Destination gets mutated between builds and must be reset to the source → `IfDifferent`. +- You truly need a fresh copy on literally every build → `Always` (rare). +- Stuck with lots of legacy `Always` and want the perf win without edits → keep `Always` but set `$(SkipUnchangedFilesOnCopyAlways)=true`. From 63ffc970d5b0708b24ed7c14d66114856c260b25 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova <95473390+YuliiaKovalova@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:40:41 +0200 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../dotnet-msbuild/skills/copy-to-output-directory/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md index 6c0c7d8e8a..b4f639eaae 100644 --- a/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md +++ b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md @@ -42,9 +42,9 @@ Historically `Always` was the only way to handle a specific scenario: **the dest ## `IfDifferent`: copy when different, in either direction -`IfDifferent` is the targeted fix for that scenario. It copies the source over the destination whenever the two **differ** — whether the source is newer *or* older than the destination, whether the size differs, or the destination is missing — but skips the copy when they are already identical. +`IfDifferent` is the targeted fix for that scenario. It copies the source over the destination whenever MSBuild considers the two files **different** (destination missing, file size differs, or last-write timestamps differ in either direction) and skips the copy when they are unchanged. -Under the hood the `_CopyDifferingSourceItemsToOutputDirectory` target uses the `Copy` task with `SkipUnchangedFiles="true"`. MSBuild's "unchanged" check compares **last-write timestamp and file size**; if either differs the file is copied. This gives you the correctness of `Always` (a mutated destination gets overwritten back to the source content) without the unconditional per-build copy. +Under the hood the `_CopyDifferingSourceItemsToOutputDirectory` target uses the `Copy` task with `SkipUnchangedFiles="true"`. MSBuild's "unchanged" check compares **last-write timestamp and file size**; if either differs the file is copied. This usually achieves the "reset a mutated destination" goal of `Always` without the unconditional per-build copy (it is a timestamp+size heuristic, not a content hash). Use `IfDifferent` when: From 7b22251165a4178ea86a9abb3664757ef97ae84e Mon Sep 17 00:00:00 2001 From: Yuliia Kovalova Date: Thu, 16 Jul 2026 10:33:20 +0200 Subject: [PATCH 3/4] Address review feedback on copy-to-output-directory skill - Soften the IfDifferent description to avoid overclaiming: it skips when "unchanged per MSBuild's heuristic" (timestamp + size, not a content hash), rather than "identical"/full Always equivalence. - Correct the target-flow wording: the three copy targets run as dependencies of _CopySourceItemsToOutputDirectory (invoked by CopyFilesToOutputDirectory). - Restore the USE FOR / DO NOT USE FOR routing pattern for the skill's frontmatter description, matching sibling skills. - Reclaim skill-menu budget headroom (per maintainer note) by tightening the verbose property-patterns and target-authoring descriptions; dotnet-msbuild rendered menu is now ~14,897/15,000 chars. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 881d26ad-87b7-48e3-8ec6-043163944437 --- .../skills/copy-to-output-directory/SKILL.md | 8 ++++---- plugins/dotnet-msbuild/skills/property-patterns/SKILL.md | 2 +- plugins/dotnet-msbuild/skills/target-authoring/SKILL.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md index b4f639eaae..50e28de027 100644 --- a/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md +++ b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md @@ -1,6 +1,6 @@ --- name: copy-to-output-directory -description: "Choosing MSBuild CopyToOutputDirectory modes including IfDifferent (MSBuild 17.13+) and $(SkipUnchangedFilesOnCopyAlways), instead of the per-build Always copy perf hit." +description: "Choosing an MSBuild CopyToOutputDirectory / CopyToPublishDirectory mode: Never, PreserveNewest, Always, and IfDifferent (MSBuild 17.13+), plus $(SkipUnchangedFilesOnCopyAlways). USE FOR: removing the per-build Always copy perf hit; resetting output files mutated between builds. DO NOT USE FOR: general incremental-build diagnosis (use incremental-build); non-MSBuild build systems." license: MIT --- @@ -42,9 +42,9 @@ Historically `Always` was the only way to handle a specific scenario: **the dest ## `IfDifferent`: copy when different, in either direction -`IfDifferent` is the targeted fix for that scenario. It copies the source over the destination whenever MSBuild considers the two files **different** (destination missing, file size differs, or last-write timestamps differ in either direction) and skips the copy when they are unchanged. +`IfDifferent` is the targeted fix for that scenario. It copies the source over the destination whenever MSBuild considers the two **different** — whether the source is newer *or* older than the destination, whether the size differs, or the destination is missing — and skips the copy when the destination is unchanged per MSBuild's heuristic. -Under the hood the `_CopyDifferingSourceItemsToOutputDirectory` target uses the `Copy` task with `SkipUnchangedFiles="true"`. MSBuild's "unchanged" check compares **last-write timestamp and file size**; if either differs the file is copied. This usually achieves the "reset a mutated destination" goal of `Always` without the unconditional per-build copy (it is a timestamp+size heuristic, not a content hash). +Under the hood the `_CopyDifferingSourceItemsToOutputDirectory` target uses the `Copy` task with `SkipUnchangedFiles="true"`. That "unchanged" check is a **heuristic**: it compares **last-write timestamp and file size only** — not a content hash — so a destination that was edited to the same size and timestamp as the source is treated as unchanged and is *not* re-copied. In practice this restores a mutated destination back to the source version on the next build (the reason people reached for `Always`) while avoiding the unconditional per-build copy. Use `IfDifferent` when: @@ -77,7 +77,7 @@ This makes the `_CopyOutOfDateSourceItemsToOutputDirectoryAlways` target pass `S ## How the modes flow through the build -`GetCopyToOutputDirectoryItems` buckets each item by its `CopyToOutputDirectory` value into three copy targets, which run from `CopyFilesToOutputDirectory`: +`GetCopyToOutputDirectoryItems` buckets each item by its `CopyToOutputDirectory` value. Three copy targets then do the work as dependencies of `_CopySourceItemsToOutputDirectory` (which is itself invoked by `CopyFilesToOutputDirectory`): - `_CopyOutOfDateSourceItemsToOutputDirectory` — `PreserveNewest` items (incremental via `Inputs`/`Outputs` timestamp comparison). - `_CopyOutOfDateSourceItemsToOutputDirectoryAlways` — `Always` items (unconditional copy unless `$(SkipUnchangedFilesOnCopyAlways)` is `true`). diff --git a/plugins/dotnet-msbuild/skills/property-patterns/SKILL.md b/plugins/dotnet-msbuild/skills/property-patterns/SKILL.md index 6c8f5c19a0..c0e3793c7b 100644 --- a/plugins/dotnet-msbuild/skills/property-patterns/SKILL.md +++ b/plugins/dotnet-msbuild/skills/property-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: property-patterns -description: "MSBuild property definition patterns: conditional defaults, composition/concatenation, path normalization, trailing slash handling, TFM detection helpers, and property evaluation order. USE FOR: diagnosing and fixing MSBuild property definition issues in .props or .csproj files, reviewing and fixing shared property configuration anti-patterns, fixing DefineConstants or NoWarn being overwritten instead of appended, fixing unconditional property assignments that prevent project-level overrides, fixing unquoted conditions that fail when properties are empty, fixing hardcoded paths that break cross-platform builds, setting property defaults that can be overridden, understanding property evaluation order and last-write-wins semantics. DO NOT USE FOR: props vs targets placement (use directory-build-organization), item operations (use item-management), target structure (use target-authoring), general anti-patterns (use msbuild-antipatterns), non-MSBuild build systems." +description: "MSBuild property definition patterns: conditional defaults, composition/concatenation, path normalization, trailing-slash handling, TFM detection helpers, and evaluation order. USE FOR: diagnosing and fixing property definition issues and shared-property anti-patterns in .props/.csproj; DefineConstants or NoWarn overwritten instead of appended; unconditional assignments that block project-level overrides; unquoted conditions that fail on empty properties; hardcoded paths that break cross-platform builds; setting overridable defaults; property evaluation order and last-write-wins semantics. DO NOT USE FOR: props vs targets placement (use directory-build-organization), item operations (use item-management), target structure (use target-authoring), general anti-patterns (use msbuild-antipatterns), non-MSBuild build systems." license: MIT --- diff --git a/plugins/dotnet-msbuild/skills/target-authoring/SKILL.md b/plugins/dotnet-msbuild/skills/target-authoring/SKILL.md index 0e72084bc1..c54248036d 100644 --- a/plugins/dotnet-msbuild/skills/target-authoring/SKILL.md +++ b/plugins/dotnet-msbuild/skills/target-authoring/SKILL.md @@ -1,6 +1,6 @@ --- name: target-authoring -description: "Canonical patterns for writing custom MSBuild targets. USE FOR: diagnosing and fixing custom target authoring anti-patterns, reviewing MSBuild target definitions for correctness, diagnosing broken SDK target chains across files (e.g., Directory.Build.targets silently redefining SDK targets), fixing targets that replace CompileDependsOn instead of extending it with $(CompileDependsOn), fixing query targets that return stale results due to Outputs vs Returns misuse, fixing missing Inputs/Outputs causing unnecessary rebuilds, fixing missing FileWrites registration. Covers DependsOnTargets vs BeforeTargets vs AfterTargets, the Build→CoreBuild three-level pattern, hooking into the build pipeline, the $(XxxDependsOn) chain-extension pattern. DO NOT USE FOR: incremental build tuning (use incremental-build), parallelization (use build-parallelism), general anti-patterns (use msbuild-antipatterns), non-MSBuild build systems." +description: "Canonical patterns for writing custom MSBuild targets. USE FOR: diagnosing and fixing custom target authoring anti-patterns; broken SDK target chains across files (e.g., Directory.Build.targets silently redefining SDK targets); targets that replace CompileDependsOn instead of extending it with $(CompileDependsOn); query targets returning stale results from Outputs vs Returns misuse; missing Inputs/Outputs causing unnecessary rebuilds; missing FileWrites registration. Covers DependsOnTargets vs BeforeTargets vs AfterTargets, the Build→CoreBuild three-level pattern, and the $(XxxDependsOn) chain-extension pattern. DO NOT USE FOR: incremental build tuning (use incremental-build), parallelization (use build-parallelism), general anti-patterns (use msbuild-antipatterns), non-MSBuild build systems." license: MIT --- From 7b047f26a74f59b806f66b31231fb9cf741bbb7e Mon Sep 17 00:00:00 2001 From: Yuliia Kovalova Date: Thu, 16 Jul 2026 10:40:20 +0200 Subject: [PATCH 4/4] Fix article: 'an SQLite' per review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 881d26ad-87b7-48e3-8ec6-043163944437 --- plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md index 50e28de027..d6737e4d71 100644 --- a/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md +++ b/plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md @@ -38,7 +38,7 @@ You can use either the attribute form shown above or the child-element form: `Always` re-copies the file on **every** build, including otherwise-clean incremental/no-op builds. On projects with many or large content files this is a measurable, recurring cost and a common cause of "why is my no-op build not instant?" reports. -Historically `Always` was the only way to handle a specific scenario: **the destination file can change between builds** — for example a SQLite database, a storage/state file, or a config file that a test run mutates. With `PreserveNewest`, if the destination is modified (making its timestamp *newer* than the source) MSBuild will *not* restore the original source file, because the source is no longer newer. People reached for `Always` to force the file back into a known-good state — paying the copy cost on every build as a side effect. +Historically `Always` was the only way to handle a specific scenario: **the destination file can change between builds** — for example an SQLite database, a storage/state file, or a config file that a test run mutates. With `PreserveNewest`, if the destination is modified (making its timestamp *newer* than the source) MSBuild will *not* restore the original source file, because the source is no longer newer. People reached for `Always` to force the file back into a known-good state — paying the copy cost on every build as a side effect. ## `IfDifferent`: copy when different, in either direction