Skip to content

Scope .editorconfig discovery to included files in dotnet format folder mode#55248

Open
wellWINeo wants to merge 7 commits into
dotnet:mainfrom
wellWINeo:format-editorconfig-scoped-discovery
Open

Scope .editorconfig discovery to included files in dotnet format folder mode#55248
wellWINeo wants to merge 7 commits into
dotnet:mainfrom
wellWINeo:format-editorconfig-scoped-discovery

Conversation

@wellWINeo

@wellWINeo wellWINeo commented Jul 11, 2026

Copy link
Copy Markdown

Summary

dotnet format whitespace --folder scanned the entire workspace tree for .editorconfig files (EditorConfigFinder.GetEditorConfigPaths used GetFiles(".editorconfig", SearchOption.AllDirectories)). In monorepos with large non-.NET subtrees (e.g. node_modules) that whole-tree scan dominates folder-mode runtime, even though the vast majority of those directories contain no file being formatted.

This PR replaces the scan with an upward walk: for the set of files the workspace will actually format, it collects the .editorconfig files in each file's ancestor directories (deduplicating shared ancestors), via EditorConfigFinder.GetEditorConfigPathsForFiles. The full-tree GetEditorConfigPaths(folderPath) is removed.

Correctness. A non-global .editorconfig only affects files in its own directory subtree, so the only configs that can change a file's effective options are those on the path from the workspace root down to the file's own directory. The folder workspace formats exactly the resolved file set, so the union of ancestor walks over that set yields identical analyzer-config resolution for every document; the configs the old scan collected from directories that are not an ancestor of any formatted file could never have matched them. Two edge cases where this differs from the old scan are in Notes.

Measurements

On a large work monorepo (400+ C# projects plus a JS frontend with ~230 node_modules directories), formatting one file. Numbers are the median of 3 timed runs after 1 warmup; "Before" and "After" are two Debug builds of this branch — the parent commit and this change — measured on the same machine:

Command Before After
dotnet format whitespace . --folder --include src/App/Program.cs 1.09s 0.55s

References

Notes

  • --exclude and MSBuild (project/solution) workspaces are unchanged; this only affects folder-mode .editorconfig discovery. Both concrete --include lists and glob includes now use the ancestor walk (globs resolve to files under the workspace folder, so their ancestor set is the same the scan would have found).
  • Out-of-workspace includes: a concrete --include path resolving outside the workspace folder now has the .editorconfig files in its own ancestor directories applied. Previously folder mode discovered configs from the workspace folder's tree only, so such a file was formatted with the workspace folder's configs rather than its own — a behavior change (arguably more correct).
  • is_global configs: a file named .editorconfig containing is_global = true is treated by Roslyn as a global analyzer config (its top-level options apply regardless of directory). The ancestor walk only visits directories on the path from a formatted file up to the root, so a global-marked .editorconfig in a directory that is not an ancestor of any formatted file is no longer discovered. Rare: the convention for repo-wide configuration is .globalconfig (not discovered by folder mode under old or new code), and is_global is not part of the EditorConfig spec. Use .globalconfig for genuinely global configuration.
  • On case-sensitive filesystems the ancestor walk's ordinal-ignore-case directory deduplication is not strictly behavior-preserving: configs for distinct included directories differing only by case may be omitted. This follows existing case-insensitive folder-mode matching conventions; making it filesystem-aware would be a wider folder-mode change.

@wellWINeo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

@wellWINeo
wellWINeo marked this pull request as ready for review July 14, 2026 15:26
Copilot AI review requested due to automatic review settings July 14, 2026 15:26
@wellWINeo
wellWINeo requested a review from a team as a code owner July 14, 2026 15:26
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

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

Improves dotnet format whitespace --folder --include <file> performance by avoiding a full workspace .editorconfig scan when the include set is an explicit list of files under the workspace folder, and instead collecting only ancestor .editorconfig files that can actually apply.

Changes:

  • Add EditorConfigFinder.GetEditorConfigPathsForFiles(...) to discover .editorconfig files by walking ancestor directories for specific files.
  • Update folder-mode solution loading to use the file-based .editorconfig discovery when --include is a concrete file list (and --exclude is empty) fully contained under the workspace folder.
  • Add unit tests covering ancestor-only .editorconfig discovery and duplicate prevention.

Reviewed changes

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

File Description
test/dotnet-format.UnitTests/Utilities/EditorConfigFinderTests.cs Adds unit coverage for the new file-based .editorconfig discovery behavior.
src/Dotnet.Format/dotnet-format/Workspaces/FolderWorkspace_FolderSolutionLoader.cs Switches .editorconfig discovery to a fast ancestor-walk when includes are concrete files under the workspace.
src/Dotnet.Format/dotnet-format/Utilities/EditorConfigFinder.cs Introduces GetEditorConfigPathsForFiles to collect applicable configs without scanning the full folder tree.

Comment thread src/Dotnet.Format/dotnet-format/Utilities/EditorConfigFinder.cs Outdated
// Only configs in the files' ancestor directories can apply to them. Included
// files resolving outside the workspace folder keep the original scan so their
// behavior is unchanged.
var folderPrefix = absoluteFolderPath.EndsWith(Path.DirectorySeparatorChar)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you just make this part of calculating the absoluteFolderPath above?

if (!Path.EndsInDirectorySeparator(absoluteFolderPath))
{
    absoluteFolderPath += Path.DirectorySeparatorChar;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Obsolete — folderPrefix only existed for the containment check, which is removed.

return editorConfigPaths.ToImmutable();
}

public static ImmutableArray<string> GetEditorConfigPathsForFiles(ImmutableArray<string> filePaths)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you have GetEditorConfigPaths(string) simply call this method? Update where you set directory name to first check if the path is a directory before calling GetDirectoryName

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed GetEditorConfigPaths(string) instead of unifying — the loader always calls GetEditorConfigPathsForFiles with the resolved file list, so there's a single discovery path.

foreach (var filePath in filePaths)
{
var directoryName = Path.GetDirectoryName(filePath);
if (string.IsNullOrEmpty(directoryName) || !Directory.Exists(directoryName))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider only doing the Directory.Exists() check after visitedDirectories check, so that way we don't pay it once we've already added the directory to the HashSet.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Applied — dropped the guard entirely; File.Exists doesn't throw on a missing directory.

var editorConfigPaths = hasConcreteIncludePaths
&& filePaths.All(filePath => filePath.StartsWith(folderPrefix, StringComparison.OrdinalIgnoreCase))
? EditorConfigFinder.GetEditorConfigPathsForFiles(filePaths)
: EditorConfigFinder.GetEditorConfigPaths(folderPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So does this mean that as long as there's one glob then we'll still end up finding all the editor configs in the entire folder instead? What happens if we just always call GetEditorConfigPathsForFiles?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — always file-based now; GetEditorConfigPaths(folderPath) removed.

Instead of gating the ancestor walk to concrete --include lists and falling back to a full-tree scan, always collect .editorconfig files by walking up from each formatted file. Remove the now-unused GetEditorConfigPaths(folderPath) full scan and the containment gating. In GetEditorConfigPathsForFiles, use File.Exists instead of DirectoryInfo.GetFiles and drop the redundant Directory.Exists guard.
@wellWINeo

Copy link
Copy Markdown
Author

Adopting @jasonmalinowski's suggestion to always use the ancestor walk (dropping the concrete-include gating) removes two things the previous version preserved. Both are now documented in the PR description Notes; flagging here since they differ from what you reviewed.

  1. Out-of-workspace concrete includes. The previous version kept the full scan whenever an --include path resolved outside the workspace folder, so such a file was formatted with the folder's config tree rather than its own. With the ancestor walk it now gets the .editorconfig files in its actual ancestor directories — arguably more correct, but a behavior change (it invalidated the old "outside the workspace also use the original scan" note, now removed).
  2. is_global-marked .editorconfig. Treated by Roslyn as a global analyzer config — top-level options apply regardless of directory. The old scan found such a file anywhere under the workspace folder; the ancestor walk only visits a formatted file's ancestor directories, so a global-marked .editorconfig off that path (e.g. analyzers/.editorconfig when formatting the repo root with no formatted sources under analyzers/) is no longer discovered. Narrow — the convention is .globalconfig (never discovered by folder mode; those come through MSBuild in project/solution mode, which this PR doesn't touch) — and arguably closer to the EditorConfig spec, which doesn't define is_global. I've gone with accept-and-document (see Notes); say the word if you'd rather preserve the old discovery for either case.

@jasonmalinowski

Copy link
Copy Markdown
Member

@wellWINeo I think both of those seem like reasonable behavior changes to me (unless @JoeRobich wants to overrule me.) The first behavior seems otherwise pretty broken (you'll format with the wrong settings), and in the second case, if there's multiple global configs in the same repo, you'll get "who knows" behavior (since we won't have the right logic for which one to consume.)

@JoeRobich JoeRobich left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like the direction this is headed.


var filePaths = GetMatchingFilePaths(absoluteFolderPath, fileMatcher);
var editorConfigPaths = EditorConfigFinder.GetEditorConfigPaths(folderPath);
var hasConcreteIncludePaths = fileMatcher.Exclude.IsDefaultOrEmpty && AreAllFilePaths(fileMatcher.Include);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Lets revert pulling this out of the GetMatchingFilePaths method

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done

}
}

private static bool AreAllFilePaths(ImmutableArray<string> globs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Lets revert pulling this out of the GetMatchingFilePaths method

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done

…gFilePaths

With the .editorconfig discovery ternary gone, the flag and helper no longer need to be hoisted to LoadSolutionInfo, so restore GetMatchingFilePaths to its original shape.
…derSolutionLoader.cs

Co-authored-by: Joey Robichaud <joseph.robichaud@microsoft.com>
@JoeRobich

Copy link
Copy Markdown
Member

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.

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.

5 participants