Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/dotnet-msbuild/agents/build-perf.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
2 changes: 2 additions & 0 deletions plugins/dotnet-msbuild/agents/msbuild.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
100 changes: 100 additions & 0 deletions plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
<ItemGroup>
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="testdata\seed.db" CopyToOutputDirectory="IfDifferent" />
</ItemGroup>
```

You can use either the attribute form shown above or the child-element form:

```xml
<None Include="testdata\seed.db">
<CopyToOutputDirectory>IfDifferent</CopyToOutputDirectory>
</None>
```

## 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.
Comment thread
YuliiaKovalova marked this conversation as resolved.
Outdated

## `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.

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:

- 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
<ItemGroup>
<!-- Reset the fixture DB to the source copy whenever it has drifted,
but don't pay a copy on every no-op build. -->
<None Include="fixtures\catalog.db" CopyToOutputDirectory="IfDifferent" />
</ItemGroup>
```

## 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
<PropertyGroup>
<SkipUnchangedFilesOnCopyAlways>true</SkipUnchangedFilesOnCopyAlways>
</PropertyGroup>
```

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`.